Skip to content

Commit 12eaf6d

Browse files
feat(attestation): carry triage/adjudicate data in the AI security context (#3427)
Signed-off-by: Matías Insaurralde <matias@chainloop.dev>
1 parent d7124ad commit 12eaf6d

7 files changed

Lines changed: 1271 additions & 0 deletions

File tree

‎internal/schemavalidators/internal_schemas/aisecuritycontext/ai-security-context-0.1.schema.json‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,17 @@
7474
"items": {
7575
"$ref": "#/definitions/fingerprint"
7676
}
77+
},
78+
"survivors": {
79+
"type": "array",
80+
"description": "Commits that survived Phase-1 triage — the adjudication work queue, retained after draining as the coverage record. Present on an un-adjudicated (triage-only) or incrementally-built context; absent for a combined triage+adjudicate scan.",
81+
"items": {
82+
"$ref": "#/definitions/survivor"
83+
}
84+
},
85+
"discarded": {
86+
"$ref": "#/definitions/sha_list",
87+
"description": "Commits Phase-1 triage classified and REJECTED — the other half of the triage record, SHAs only because a discard carries nothing else worth recording. With survivors and scan.unresolved it states the full set of commits ever handed to the classifier, so a later walk that re-covers this ground can skip it instead of paying for it again. Absent on a context whose producer did not record it."
7788
}
7889
},
7990
"definitions": {
@@ -310,6 +321,41 @@
310321
"type": "integer",
311322
"minimum": 0,
312323
"description": "Holes the list cap dropped, so a truncated list still reports an honest total"
324+
},
325+
"adjudicated_commits": {
326+
"type": "array",
327+
"items": {
328+
"type": "string"
329+
},
330+
"description": "Commit SHAs driven to a terminal adjudication state across all runs (the adjudication frontier)."
331+
},
332+
"survivors_total": {
333+
"type": "integer",
334+
"minimum": 0,
335+
"description": "How many survivors the context holds at the covered window."
336+
},
337+
"pending_survivors": {
338+
"type": "integer",
339+
"minimum": 0,
340+
"description": "How many survivors still await adjudication — survivors that are neither in the frontier, nor a triage hole, nor abandoned after the retry cap. A count rather than a list because the list is already determined by survivors and adjudicated_commits; zero alongside adjudication_complete is the drained queue."
341+
},
342+
"triage_budget_hit": {
343+
"type": "boolean",
344+
"description": "True when the most recent triage run stopped because it reached its survivor budget (the --max-new-survivors cap) rather than because it exhausted its window, which means there is known-untriaged history immediately behind scan.window.from_sha. Cleared by a later run that exhausts its window without hitting the budget. Not a statement about the adjudication queue — that is pending_survivors/adjudication_complete — and not the same as \"the window does not reach the repository root\": a --last-bounded run does not either, and that question needs git rather than this artifact."
345+
},
346+
"triage_input_tokens": {
347+
"type": "integer",
348+
"minimum": 0,
349+
"description": "Cumulative Phase-1 input tokens across every triage run."
350+
},
351+
"triage_output_tokens": {
352+
"type": "integer",
353+
"minimum": 0,
354+
"description": "Cumulative Phase-1 output tokens across every triage run."
355+
},
356+
"adjudication_complete": {
357+
"type": "boolean",
358+
"description": "True when every survivor (outside holes/abandoned) reached a terminal state — the trust signal that an empty fingerprints list is 'clean' rather than 'not adjudicated yet'."
313359
}
314360
}
315361
},
@@ -354,6 +400,47 @@
354400
}
355401
}
356402
},
403+
"survivor": {
404+
"type": "object",
405+
"title": "Survivor",
406+
"description": "One commit that survived Phase-1 triage: an entry in the adjudication work queue the context carries. All but commit_sha are optional.",
407+
"required": [
408+
"commit_sha"
409+
],
410+
"additionalProperties": false,
411+
"properties": {
412+
"commit_sha": {
413+
"type": "string",
414+
"description": "The commit that triage marked as a candidate for adjudication"
415+
},
416+
"parent_sha": {
417+
"type": "string",
418+
"description": "Parent of the survivor commit. Empty for a root commit."
419+
},
420+
"commit_date": {
421+
"type": "string",
422+
"description": "Committer date of the survivor commit"
423+
},
424+
"subject": {
425+
"type": "string",
426+
"description": "Subject line of the survivor commit"
427+
},
428+
"patch_id": {
429+
"type": "string",
430+
"description": "git patch-id --stable; rebase-durable key for history-rewrite reconciliation. Empty for merge commits."
431+
},
432+
"diff_bytes": {
433+
"type": "integer",
434+
"minimum": 0,
435+
"description": "Size of the survivor's normalised diff in bytes"
436+
},
437+
"attempts": {
438+
"type": "integer",
439+
"minimum": 0,
440+
"description": "Failed adjudication tries; at the cap the survivor is abandoned."
441+
}
442+
}
443+
},
357444
"top_risk": {
358445
"type": "object",
359446
"title": "Top risk",

‎internal/schemavalidators/schemavalidators_test.go‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,10 @@ func TestValidateSecurityContext(t *testing.T) {
286286
name: "valid security context",
287287
filePath: "./testdata/ai_security_context_valid.json",
288288
},
289+
{
290+
name: "valid un-adjudicated (triage-only) context",
291+
filePath: "./testdata/ai_security_context_triage_only.json",
292+
},
289293
{
290294
name: "missing required fields",
291295
filePath: "./testdata/ai_security_context_missing_required.json",
@@ -317,6 +321,109 @@ func TestValidateSecurityContext(t *testing.T) {
317321
}
318322
}
319323

324+
// TestValidateSecurityContextTriageFields covers the optional fields the
325+
// triage/adjudicate split consolidated into the security context in place at
326+
// security-context-0.1: the top-level survivors queue and discard record, and
327+
// the scan_stats counters (adjudicated_commits, survivors_total,
328+
// pending_survivors, triage_budget_hit, triage_input_tokens,
329+
// triage_output_tokens, adjudication_complete). An un-adjudicated (triage-only)
330+
// context carries survivors, discards and the triage counters, a
331+
// fully-adjudicated context carries the adjudication frontier, a combined-scan
332+
// context omits them all, and a genuinely unknown field is still rejected — so
333+
// both the context object and scan_stats keep their additionalProperties: false
334+
// contract.
335+
func TestValidateSecurityContextTriageFields(t *testing.T) {
336+
load := func(t *testing.T) (map[string]any, map[string]any) {
337+
t.Helper()
338+
f, err := os.ReadFile("./testdata/ai_security_context_valid.json")
339+
require.NoError(t, err)
340+
341+
var payload map[string]any
342+
require.NoError(t, json.Unmarshal(f, &payload))
343+
344+
scan, ok := payload["scan"].(map[string]any)
345+
require.True(t, ok, "the fixture must carry a scan object")
346+
return payload, scan
347+
}
348+
349+
t.Run("an un-adjudicated context with survivors and triage counters validates", func(t *testing.T) {
350+
payload, scan := load(t)
351+
payload["survivors"] = []any{
352+
map[string]any{
353+
"commit_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2",
354+
"parent_sha": "c8533df53b0af4b731cb1036ec61aee10e35c67b",
355+
"commit_date": "2026-08-19T19:58:28-03:00",
356+
"subject": "Avoid shell invocation in command handler",
357+
"patch_id": "32d18a48dac298fa43bd3dcffdb3bbfe06a008aa",
358+
"diff_bytes": 451,
359+
"attempts": 0,
360+
},
361+
}
362+
payload["discarded"] = []any{
363+
"1b8f5aa595c0953995c40e92b1669282ba76dd08",
364+
"c8533df53b0af4b731cb1036ec61aee10e35c67b",
365+
}
366+
scan["survivors_total"] = 1
367+
scan["pending_survivors"] = 1
368+
// Budget-stopped rather than window-exhausted: known-untriaged history sits
369+
// immediately behind scan.window.from_sha.
370+
scan["triage_budget_hit"] = true
371+
scan["triage_input_tokens"] = 8883
372+
scan["triage_output_tokens"] = 83
373+
scan["adjudication_complete"] = false
374+
require.NoError(t, schemavalidators.ValidateSecurityContext(payload, ""))
375+
})
376+
377+
t.Run("an adjudicate-produced context validates", func(t *testing.T) {
378+
payload, scan := load(t)
379+
scan["adjudicated_commits"] = []any{"8c948c742bdfc09c4aae6b3c386faeb98f925ff2"}
380+
scan["survivors_total"] = 1
381+
// The drained queue: the producer emits the zero rather than omitting it, so
382+
// "nothing pending" is stated rather than inferred from an absent field.
383+
scan["pending_survivors"] = 0
384+
scan["adjudication_complete"] = true
385+
require.NoError(t, schemavalidators.ValidateSecurityContext(payload, ""))
386+
})
387+
388+
t.Run("a discard list of short SHAs is rejected", func(t *testing.T) {
389+
payload, _ := load(t)
390+
payload["discarded"] = []any{"1b8f5aa"}
391+
require.ErrorContains(t, schemavalidators.ValidateSecurityContext(payload, ""), "pattern")
392+
})
393+
394+
t.Run("a negative pending_survivors is rejected", func(t *testing.T) {
395+
payload, scan := load(t)
396+
scan["pending_survivors"] = -1
397+
require.ErrorContains(t, schemavalidators.ValidateSecurityContext(payload, ""), "minimum")
398+
})
399+
400+
t.Run("a combined-scan context validates without them", func(t *testing.T) {
401+
payload, _ := load(t)
402+
require.NoError(t, schemavalidators.ValidateSecurityContext(payload, ""))
403+
})
404+
405+
t.Run("a survivor missing its required commit_sha is rejected", func(t *testing.T) {
406+
payload, _ := load(t)
407+
payload["survivors"] = []any{map[string]any{"subject": "no sha"}}
408+
require.ErrorContains(t, schemavalidators.ValidateSecurityContext(payload, ""), "missing properties")
409+
})
410+
411+
t.Run("an unknown survivor field is rejected", func(t *testing.T) {
412+
payload, _ := load(t)
413+
payload["survivors"] = []any{map[string]any{
414+
"commit_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2",
415+
"unexpected_key": "x",
416+
}}
417+
require.ErrorContains(t, schemavalidators.ValidateSecurityContext(payload, ""), "additionalProperties")
418+
})
419+
420+
t.Run("an unknown scan_stats field is still rejected", func(t *testing.T) {
421+
payload, scan := load(t)
422+
scan["unexpected_field"] = "x"
423+
require.ErrorContains(t, schemavalidators.ValidateSecurityContext(payload, ""), "additionalProperties")
424+
})
425+
}
426+
320427
func TestValidateOpenAPI(t *testing.T) {
321428
testCases := []struct {
322429
name string
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
{
2+
"schema_version": "security-context-0.1",
3+
"generated_at": "2026-09-10T19:34:26Z",
4+
"repo": {
5+
"owner": "chainloop-dev",
6+
"name": "sample-repo-go",
7+
"url": "https://github.com/chainloop-dev/sample-repo-go",
8+
"ref": "fe5eb735",
9+
"head_sha": "fe5eb735f979f5a4bda31bdff281acd680cf1e5b"
10+
},
11+
"provenance": {
12+
"tool": "strata-go",
13+
"tool_version": "dev",
14+
"protocol": "",
15+
"triage_model": "openai/gpt-5.6-luna:nitro",
16+
"triage_prompt_id": "current-diff-only-v1",
17+
"input_profile": "D0"
18+
},
19+
"scan": {
20+
"window": {
21+
"from_sha": "a6214f62be37e2234f1c816dfd7a84c25376c98f",
22+
"to_sha": "fe5eb735f979f5a4bda31bdff281acd680cf1e5b"
23+
},
24+
"commits_scanned": 11,
25+
"commits_triaged": 11,
26+
"commits_skipped": 0,
27+
"triage_candidates": 3,
28+
"adjudicated": 0,
29+
"findings": 0,
30+
"abstained": 0,
31+
"rejected": 0,
32+
"no_finding": 0,
33+
"triage_errors": 0,
34+
"adjudication_errors": 0,
35+
"anchors_verified": 0,
36+
"anchors_relocated": 0,
37+
"anchors_rejected": 0,
38+
"input_tokens": 0,
39+
"output_tokens": 0,
40+
"wall_clock_s": 0,
41+
"reconciles": true,
42+
"triage_input_tokens": 10930,
43+
"triage_output_tokens": 417,
44+
"adjudication_complete": false,
45+
"survivors_total": 3,
46+
"pending_survivors": 3,
47+
"triage_budget_hit": true
48+
},
49+
"survivors": [
50+
{
51+
"commit_sha": "fe5eb735f979f5a4bda31bdff281acd680cf1e5b",
52+
"parent_sha": "af5f2f747ea110af304d1feb9c0d88073005441e",
53+
"commit_date": "2026-09-10T16:26:54-03:00",
54+
"subject": "fix: reject absolute paths in the ls endpoint",
55+
"patch_id": "cec8571ff951f25c91ba4581690b1c22018ed2f8",
56+
"diff_bytes": 522
57+
},
58+
{
59+
"commit_sha": "ff57f5cd0d76d4b02483d0c89a6c931557156ccb",
60+
"parent_sha": "72a1590ff2f099f67b78a2cd47ea6dbb78a88a43",
61+
"commit_date": "2026-09-10T16:24:44-03:00",
62+
"subject": "fix: reject directory traversal in the ls endpoint",
63+
"patch_id": "81e8ce4bae5aea1d2001440d9440d7dfc1e5f95d",
64+
"diff_bytes": 510
65+
},
66+
{
67+
"commit_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2",
68+
"parent_sha": "c8533df53b0af4b731cb1036ec61aee10e35c67b",
69+
"commit_date": "2026-08-19T19:58:28-03:00",
70+
"subject": "Avoid shell invoction in command handler",
71+
"patch_id": "32d18a48dac298fa43bd3dcffdb3bbfe06a008aa",
72+
"diff_bytes": 451
73+
}
74+
],
75+
"discarded": [
76+
"0d7a7bd3a2f0f8b1c6e4a9f27b35d8e1c4a60f92",
77+
"3b9c1f04e7a25d8c6b0f31e9a47d52c8f6013abd",
78+
"a1f4c70e2b8d95a36c1e47f0b92d58ac6304e7f1"
79+
],
80+
"class_counts": {},
81+
"min_support": 2,
82+
"top_risks": [],
83+
"shared_surfaces": [],
84+
"fingerprints": []
85+
}

‎pkg/attestation/crafter/materials/aisecuritycontext/aisecuritycontext.go‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,53 @@ type ScanStats struct {
105105
DuplicatesMerged int `json:"duplicates_merged,omitempty"`
106106
Unresolved []Unresolved `json:"unresolved,omitempty"`
107107
TruncatedUnresolved int `json:"truncated_unresolved,omitempty"`
108+
109+
// AdjudicatedCommits are the commit SHAs driven to a terminal Phase-2 state
110+
// across all runs — the adjudication frontier.
111+
AdjudicatedCommits []string `json:"adjudicated_commits,omitempty"`
112+
// SurvivorsTotal is how many survivors the context holds at the covered
113+
// window.
114+
SurvivorsTotal int `json:"survivors_total,omitempty"`
115+
// PendingSurvivors is how many survivors still await adjudication: neither in
116+
// the frontier, nor a triage hole, nor abandoned after the retry cap. Zero
117+
// alongside AdjudicationComplete is the drained queue.
118+
PendingSurvivors int `json:"pending_survivors,omitempty"`
119+
// TriageBudgetHit is true when the most recent triage run stopped because it
120+
// reached its survivor budget (the --max-new-survivors cap) rather than
121+
// because it exhausted its window, so known-untriaged history sits immediately
122+
// behind Scan.Window.FromSHA. Cleared by a later run that exhausts its window
123+
// without hitting the budget. It says nothing about the adjudication queue,
124+
// and is not the same as a window that does not reach the repository root — a
125+
// --last-bounded run does not either, and only git can answer that.
126+
TriageBudgetHit bool `json:"triage_budget_hit,omitempty"`
127+
// TriageInputTokens is the cumulative Phase-1 input tokens across every
128+
// triage run.
129+
TriageInputTokens int64 `json:"triage_input_tokens,omitempty"`
130+
// TriageOutputTokens is the cumulative Phase-1 output tokens across every
131+
// triage run.
132+
TriageOutputTokens int64 `json:"triage_output_tokens,omitempty"`
133+
// AdjudicationComplete is true when every survivor (outside holes/abandoned)
134+
// reached a terminal state — the signal that an empty fingerprints list is
135+
// "clean" rather than "not adjudicated yet".
136+
AdjudicationComplete bool `json:"adjudication_complete,omitempty"`
137+
}
138+
139+
// Survivor is one commit that survived Phase-1 triage: an entry in the
140+
// adjudication work queue the context carries. Retained after draining as the
141+
// coverage record. All but CommitSHA are optional.
142+
type Survivor struct {
143+
CommitSHA string `json:"commit_sha"`
144+
ParentSHA string `json:"parent_sha,omitempty"`
145+
CommitDate string `json:"commit_date,omitempty"`
146+
Subject string `json:"subject,omitempty"`
147+
// PatchID is git patch-id --stable: a rebase-durable key for history-rewrite
148+
// reconciliation. Empty for merge commits.
149+
PatchID string `json:"patch_id,omitempty"`
150+
// DiffBytes is the size of the survivor's normalised diff in bytes.
151+
DiffBytes int `json:"diff_bytes,omitempty"`
152+
// Attempts counts failed adjudication tries; at the cap the survivor is
153+
// abandoned.
154+
Attempts int `json:"attempts,omitempty"`
108155
}
109156

110157
// TopRisk is a component with a security-fix history, ranked by severity mass
@@ -267,6 +314,18 @@ type Data struct {
267314
TopRisks []TopRisk `json:"top_risks"`
268315
SharedSurfaces []SharedSurface `json:"shared_surfaces"`
269316
Fingerprints []Fingerprint `json:"fingerprints"`
317+
318+
// Survivors is the adjudication work queue — the commits that survived
319+
// Phase-1 triage, retained after draining as the coverage record. Present on
320+
// an un-adjudicated (triage-only) or incrementally-built context; absent for
321+
// a combined triage+adjudicate scan.
322+
Survivors []Survivor `json:"survivors,omitempty"`
323+
324+
// Discarded are the commits Phase-1 triage classified and REJECTED — SHAs
325+
// only, because a discard carries nothing else worth recording. Together with
326+
// Survivors and Scan.Unresolved it states the full set of commits ever handed
327+
// to the classifier.
328+
Discarded []string `json:"discarded,omitempty"`
270329
}
271330

272331
// Evidence is the Chainloop material envelope around a security context.

0 commit comments

Comments
 (0)