From fe5cce43d1b9d79fbfe128dc0a39bd93cbe77272 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Fri, 16 Oct 2026 12:25:49 +0200 Subject: [PATCH 1/2] feat(schemavalidators): add optional provenance.trace_discover to AI security context schema Record the outcome of the Chainloop graph lookup that resolves fingerprint commits to AI coding sessions, so a reader can tell 'no AI sessions' apart from 'the lookup could not run or was refused'. The counters are required when status is ran and forbidden otherwise. The field is optional, so existing artifacts stay valid and schema_version is unchanged. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: b0681449-64bf-4654-beb8-c933c3cea0b3 --- .../ai-security-context-0.1.schema.json | 95 +++++++++++++++++++ .../schemavalidators/schemavalidators_test.go | 73 ++++++++++++++ 2 files changed, 168 insertions(+) diff --git a/internal/schemavalidators/internal_schemas/aisecuritycontext/ai-security-context-0.1.schema.json b/internal/schemavalidators/internal_schemas/aisecuritycontext/ai-security-context-0.1.schema.json index ac79ee432..581e11156 100644 --- a/internal/schemavalidators/internal_schemas/aisecuritycontext/ai-security-context-0.1.schema.json +++ b/internal/schemavalidators/internal_schemas/aisecuritycontext/ai-security-context-0.1.schema.json @@ -183,6 +183,101 @@ "anchor_verification": { "type": "string", "description": "Anchor checking mode, e.g. \"relocating\" \u2014 model line numbers may be corrected when the quoted bytes are found elsewhere in the same blob" + }, + "trace_discover": { + "$ref": "#/definitions/trace_discover" + } + } + }, + "trace_discover": { + "type": "object", + "title": "Trace discover outcome", + "description": "Outcome of the Chainloop graph lookup that resolves fingerprint commits without a Chainloop-Trace-Sessions trailer to AI coding sessions. Lets a reader tell 'no AI sessions' from 'the lookup could not run or was refused'. Absent in artifacts produced before the field existed.", + "additionalProperties": false, + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "ran", + "disabled", + "cli_not_found" + ], + "description": "ran: the lookup ran and the counters are set. disabled: the scan ran with --trace-discover=false. cli_not_found: no chainloop CLI was found, so the lookup could not run." + }, + "commits": { + "type": "integer", + "minimum": 0, + "description": "Distinct fix and introducing commits looked up: those whose trailer declared no session." + }, + "matched": { + "type": "integer", + "minimum": 0, + "description": "Commits that resolved to at least one AI coding session. Never more than commits." + }, + "not_found": { + "type": "integer", + "minimum": 0, + "description": "Commits with no node in the Chainloop graph, that is commits that are not attested." + }, + "denied": { + "type": "integer", + "minimum": 0, + "description": "Lookup calls the control plane refused (PermissionDenied or Unauthenticated). Non-zero means the graph was not readable, not that it was empty. Counts calls, so it can exceed commits." + }, + "failed": { + "type": "integer", + "minimum": 0, + "description": "Other failed lookup calls: CLI errors, unparseable output, a referenced attestation or session material that cannot be read, or a material without a session id. Counts calls, so it can exceed commits." + } + }, + "if": { + "properties": { + "status": { + "const": "ran" + } + } + }, + "then": { + "required": [ + "commits", + "matched", + "not_found", + "denied", + "failed" + ] + }, + "else": { + "not": { + "anyOf": [ + { + "required": [ + "commits" + ] + }, + { + "required": [ + "matched" + ] + }, + { + "required": [ + "not_found" + ] + }, + { + "required": [ + "denied" + ] + }, + { + "required": [ + "failed" + ] + } + ] } } }, diff --git a/internal/schemavalidators/schemavalidators_test.go b/internal/schemavalidators/schemavalidators_test.go index cca1936a7..b2117a9a2 100644 --- a/internal/schemavalidators/schemavalidators_test.go +++ b/internal/schemavalidators/schemavalidators_test.go @@ -641,6 +641,79 @@ func TestValidateSecurityContextTriageFields(t *testing.T) { }) } +// TestValidateSecurityContextTraceDiscover covers the optional +// provenance.trace_discover object: the counters are required when the lookup +// ran and forbidden otherwise, and an artifact without the object stays valid. +func TestValidateSecurityContextTraceDiscover(t *testing.T) { + ranCounters := func() map[string]any { + return map[string]any{ + "status": "ran", + "commits": 88, + "matched": 0, + "not_found": 0, + "denied": 88, + "failed": 0, + } + } + with := func(key string, value any) map[string]any { + td := ranCounters() + td[key] = value + return td + } + without := func(keys ...string) map[string]any { + td := ranCounters() + for _, k := range keys { + delete(td, k) + } + return td + } + + testCases := []struct { + name string + traceDiscover map[string]any // nil leaves the field out + wantErr string + }{ + {name: "absent, as in older artifacts"}, + {name: "ran with all counters", traceDiscover: ranCounters()}, + { + name: "ran with matches", + traceDiscover: map[string]any{"status": "ran", "commits": 12, "matched": 3, "not_found": 9, "denied": 0, "failed": 0}, + }, + {name: "disabled without counters", traceDiscover: map[string]any{"status": "disabled"}}, + {name: "cli_not_found without counters", traceDiscover: map[string]any{"status": "cli_not_found"}}, + {name: "missing status", traceDiscover: without("status"), wantErr: "missing properties"}, + {name: "unknown property", traceDiscover: with("session_ids", []any{"x"}), wantErr: "additionalProperties"}, + {name: "unknown status", traceDiscover: map[string]any{"status": "skipped"}, wantErr: "enum"}, + {name: "negative counter", traceDiscover: with("denied", -1), wantErr: "minimum"}, + {name: "ran without counters", traceDiscover: map[string]any{"status": "ran"}, wantErr: "missing properties"}, + {name: "ran with some counters", traceDiscover: without("not_found", "failed"), wantErr: "missing properties"}, + {name: "disabled with a counter", traceDiscover: map[string]any{"status": "disabled", "commits": 0}, wantErr: "not failed"}, + {name: "cli_not_found with a counter", traceDiscover: map[string]any{"status": "cli_not_found", "failed": 1}, wantErr: "not failed"}, + {name: "counter as a string", traceDiscover: with("commits", "3"), wantErr: "expected integer"}, + {name: "counter as a non-integer number", traceDiscover: with("matched", 1.5), wantErr: "expected integer"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + payload, ok := loadJSONFile(t, "./testdata/ai_security_context_valid.json").(map[string]any) + require.True(t, ok, "the fixture must be a JSON object") + + provenance, ok := payload["provenance"].(map[string]any) + require.True(t, ok, "the fixture must carry a provenance object") + if tc.traceDiscover != nil { + provenance["trace_discover"] = tc.traceDiscover + } + + err := schemavalidators.ValidateSecurityContext(payload, "") + if tc.wantErr == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, tc.wantErr) + }) + } +} + func TestValidateOpenAPI(t *testing.T) { testCases := []struct { name string From aec22e4a769e055c354cace4a97e5d522331823d Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Fri, 16 Oct 2026 12:46:07 +0200 Subject: [PATCH 2/2] test(schemavalidators): build trace_discover cases from helpers to satisfy goconst Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: b0681449-64bf-4654-beb8-c933c3cea0b3 --- .../schemavalidators/schemavalidators_test.go | 44 ++++++++++--------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/internal/schemavalidators/schemavalidators_test.go b/internal/schemavalidators/schemavalidators_test.go index b2117a9a2..5bf0a51d2 100644 --- a/internal/schemavalidators/schemavalidators_test.go +++ b/internal/schemavalidators/schemavalidators_test.go @@ -645,23 +645,28 @@ func TestValidateSecurityContextTriageFields(t *testing.T) { // provenance.trace_discover object: the counters are required when the lookup // ran and forbidden otherwise, and an artifact without the object stays valid. func TestValidateSecurityContextTraceDiscover(t *testing.T) { - ranCounters := func() map[string]any { + const status = "status" + onlyStatus := func(s string) map[string]any { + return map[string]any{status: s} + } + ran := func(commits, matched, notFound, denied, failed int) map[string]any { return map[string]any{ - "status": "ran", - "commits": 88, - "matched": 0, - "not_found": 0, - "denied": 88, - "failed": 0, + status: "ran", + "commits": commits, + "matched": matched, + "not_found": notFound, + "denied": denied, + "failed": failed, } } + // with and without vary a lookup where every call was denied. with := func(key string, value any) map[string]any { - td := ranCounters() + td := ran(88, 0, 0, 88, 0) td[key] = value return td } without := func(keys ...string) map[string]any { - td := ranCounters() + td := ran(88, 0, 0, 88, 0) for _, k := range keys { delete(td, k) } @@ -674,21 +679,18 @@ func TestValidateSecurityContextTraceDiscover(t *testing.T) { wantErr string }{ {name: "absent, as in older artifacts"}, - {name: "ran with all counters", traceDiscover: ranCounters()}, - { - name: "ran with matches", - traceDiscover: map[string]any{"status": "ran", "commits": 12, "matched": 3, "not_found": 9, "denied": 0, "failed": 0}, - }, - {name: "disabled without counters", traceDiscover: map[string]any{"status": "disabled"}}, - {name: "cli_not_found without counters", traceDiscover: map[string]any{"status": "cli_not_found"}}, - {name: "missing status", traceDiscover: without("status"), wantErr: "missing properties"}, + {name: "ran with every call denied", traceDiscover: ran(88, 0, 0, 88, 0)}, + {name: "ran with matches", traceDiscover: ran(12, 3, 9, 0, 0)}, + {name: "disabled without counters", traceDiscover: onlyStatus("disabled")}, + {name: "cli_not_found without counters", traceDiscover: onlyStatus("cli_not_found")}, + {name: "missing status", traceDiscover: without(status), wantErr: "missing properties"}, {name: "unknown property", traceDiscover: with("session_ids", []any{"x"}), wantErr: "additionalProperties"}, - {name: "unknown status", traceDiscover: map[string]any{"status": "skipped"}, wantErr: "enum"}, + {name: "unknown status", traceDiscover: onlyStatus("skipped"), wantErr: "enum"}, {name: "negative counter", traceDiscover: with("denied", -1), wantErr: "minimum"}, - {name: "ran without counters", traceDiscover: map[string]any{"status": "ran"}, wantErr: "missing properties"}, + {name: "ran without counters", traceDiscover: onlyStatus("ran"), wantErr: "missing properties"}, {name: "ran with some counters", traceDiscover: without("not_found", "failed"), wantErr: "missing properties"}, - {name: "disabled with a counter", traceDiscover: map[string]any{"status": "disabled", "commits": 0}, wantErr: "not failed"}, - {name: "cli_not_found with a counter", traceDiscover: map[string]any{"status": "cli_not_found", "failed": 1}, wantErr: "not failed"}, + {name: "disabled with counters", traceDiscover: with(status, "disabled"), wantErr: "not failed"}, + {name: "cli_not_found with counters", traceDiscover: with(status, "cli_not_found"), wantErr: "not failed"}, {name: "counter as a string", traceDiscover: with("commits", "3"), wantErr: "expected integer"}, {name: "counter as a non-integer number", traceDiscover: with("matched", 1.5), wantErr: "expected integer"}, }