From b84481e898c7c58c1c138398b94f8c8ef6ff90e3 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Tue, 15 Sep 2026 23:02:37 +0200 Subject: [PATCH] feat(trace): record how a trace session was run `chainloop trace init` and `chainloop trace run` both produce a CHAINLOOP_AI_CODING_SESSION material with an identical shape, so an ongoing coding session and a one-shot wrapped command are indistinguishable once attested. Add session.mode to the AI coding session payload: "coding" for the git-hook path installed by trace init, "generic" for trace run. An absent mode means "coding", so sessions recorded before this change keep their meaning. The mode is also published as the chainloop.material.aisession.mode annotation, which is an exact key/value match consumers can filter on without downloading and parsing the session itself. The field is a plain string rather than a closed enumeration so that further modes need no schema version bump. Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: 79f9ea21-3bbd-4ee9-8640-097df91d751c --- app/cli/cmd/trace_hook.go | 2 + app/cli/pkg/action/trace_hook_handler.go | 18 ++++- app/cli/pkg/action/trace_run.go | 2 + .../ai-coding-session-0.1.schema.json | 4 ++ .../schemavalidators/schemavalidators_test.go | 63 ++++++++++++++++ .../aicodingsession/aicodingsession.go | 29 +++++++- .../materials/aicodingsession/mode_test.go | 43 +++++++++++ .../materials/aicodingsession/redact.go | 1 + .../materials/aicodingsession/redact_test.go | 2 + .../testdata/session-with-secrets.json | 1 + .../materials/chainloop_ai_coding_session.go | 14 +++- .../chainloop_ai_coding_session_test.go | 71 +++++++++++++++++++ 12 files changed, 246 insertions(+), 4 deletions(-) create mode 100644 pkg/attestation/crafter/materials/aicodingsession/mode_test.go diff --git a/app/cli/cmd/trace_hook.go b/app/cli/cmd/trace_hook.go index 974465fd0..45fe24941 100644 --- a/app/cli/cmd/trace_hook.go +++ b/app/cli/cmd/trace_hook.go @@ -21,6 +21,7 @@ import ( "github.com/chainloop-dev/chainloop/app/cli/internal/trace/cursor" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/opencode" "github.com/chainloop-dev/chainloop/app/cli/pkg/action" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" "github.com/spf13/cobra" ) @@ -98,6 +99,7 @@ func newTraceHookGitPrePushCmd() *cobra.Command { return action.HandlePrePushHook(cmd.Context(), requireTrace, logger, action.RunTracePushOpts{ ActionOpts: ActionOpts, CLIVersion: Version, + Mode: aicodingsession.ModeCoding, }) }, } diff --git a/app/cli/pkg/action/trace_hook_handler.go b/app/cli/pkg/action/trace_hook_handler.go index ef29356fc..4255c56e4 100644 --- a/app/cli/pkg/action/trace_hook_handler.go +++ b/app/cli/pkg/action/trace_hook_handler.go @@ -31,6 +31,7 @@ import ( tracegit "github.com/chainloop-dev/chainloop/app/cli/internal/trace/git" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/providers" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" "github.com/rs/zerolog" ) @@ -359,6 +360,14 @@ type RunTracePushOpts struct { // fire again, and its own terminal already showed the links. SkipAgentNotification bool + // Mode records how the session was driven, as one of the + // aicodingsession.Mode* constants: ModeCoding for the git-hook path + // installed by `trace init`, ModeGeneric for `trace run`. Both entry + // points reach this same push, so the mode has to come from the caller — + // nothing in the session itself distinguishes them. Empty means + // ModeCoding. + Mode string + // ActionOpts is the root command's initialized options, used to build // the attestation executor. Required: the push cannot run without it. ActionOpts *ActionsOpts @@ -396,7 +405,10 @@ func RunTracePush(ctx context.Context, log zerolog.Logger, opts RunTracePushOpts return err } - log.Debug().Str("state_dir", store.Dir()).Str("repo_root", repoRoot).Bool("allow_empty", opts.AllowEmpty).Msg("trace push invoked") + sessionMode := aicodingsession.ResolveMode(opts.Mode) + + log.Debug().Str("state_dir", store.Dir()).Str("repo_root", repoRoot). + Bool("allow_empty", opts.AllowEmpty).Str("mode", sessionMode).Msg("trace push invoked") allCommits, err := store.LoadAllCommitRecords() if err != nil { @@ -531,6 +543,10 @@ func RunTracePush(ctx context.Context, log zerolog.Logger, opts RunTracePushOpts continue } + // Set by the caller rather than the provider: the mode is about how the + // session was driven, not about which agent produced it. + result.Data.Session.Mode = sessionMode + // Apply repo-wide context with per-session commit overrides if gitCtxErr == nil { sessionCtx := *gitCtx diff --git a/app/cli/pkg/action/trace_run.go b/app/cli/pkg/action/trace_run.go index 83f924e7c..e158496bf 100644 --- a/app/cli/pkg/action/trace_run.go +++ b/app/cli/pkg/action/trace_run.go @@ -27,6 +27,7 @@ import ( "github.com/chainloop-dev/chainloop/app/cli/internal/trace/hooks" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/providers" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" "github.com/rs/zerolog" ) @@ -196,6 +197,7 @@ func TraceRun(ctx context.Context, log zerolog.Logger, opts TraceRunOpts) error ProjectVersion: opts.ProjectVersion, IgnoreYAML: true, SkipAgentNotification: true, + Mode: aicodingsession.ModeGeneric, ActionOpts: opts.ActionOpts, CLIVersion: opts.CLIVersion, }) diff --git a/internal/schemavalidators/internal_schemas/aicodingsession/ai-coding-session-0.1.schema.json b/internal/schemavalidators/internal_schemas/aicodingsession/ai-coding-session-0.1.schema.json index 573744d48..a2ae25fa7 100644 --- a/internal/schemavalidators/internal_schemas/aicodingsession/ai-coding-session-0.1.schema.json +++ b/internal/schemavalidators/internal_schemas/aicodingsession/ai-coding-session-0.1.schema.json @@ -46,6 +46,10 @@ "type": "string", "description": "Human-readable session slug" }, + "mode": { + "type": "string", + "description": "How the session was run: 'coding' for a session traced through the git hooks installed by `chainloop trace init` (the meaning of an absent mode), 'generic' for a single-shot `chainloop trace run`. Left unconstrained so further modes need no schema version bump." + }, "started_at": { "type": "string", "minLength": 1, diff --git a/internal/schemavalidators/schemavalidators_test.go b/internal/schemavalidators/schemavalidators_test.go index ef9dac4ca..d39ea2e7e 100644 --- a/internal/schemavalidators/schemavalidators_test.go +++ b/internal/schemavalidators/schemavalidators_test.go @@ -276,6 +276,69 @@ func TestValidateAICodingSession(t *testing.T) { } } +// TestValidateAICodingSessionMode covers session.mode, which records how +// `chainloop trace` was driven. It is optional — an absent mode means "coding" — +// and deliberately unconstrained beyond being a string, so that modes added +// later need no schema version bump. +func TestValidateAICodingSessionMode(t *testing.T) { + loadPayload := func(t *testing.T) map[string]any { + t.Helper() + f, err := os.ReadFile("./testdata/ai_coding_session_valid.json") + require.NoError(t, err) + + var payload map[string]any + require.NoError(t, json.Unmarshal(f, &payload)) + return payload + } + + session := func(t *testing.T, payload map[string]any) map[string]any { + t.Helper() + s, ok := payload["session"].(map[string]any) + require.True(t, ok) + return s + } + + t.Run("the fixture validates without a mode", func(t *testing.T) { + payload := loadPayload(t) + require.NotContains(t, session(t, payload), "mode") + require.NoError(t, schemavalidators.ValidateAICodingSession(payload, "")) + }) + + accepted := []struct { + name string + mode string + }{ + {name: "coding", mode: "coding"}, + {name: "generic", mode: "generic"}, + {name: "a mode this schema version predates", mode: "spec"}, + } + + for _, tc := range accepted { + t.Run("accepts "+tc.name, func(t *testing.T) { + payload := loadPayload(t) + session(t, payload)["mode"] = tc.mode + require.NoError(t, schemavalidators.ValidateAICodingSession(payload, "")) + }) + } + + rejected := []struct { + name string + key string + value any + }{ + {name: "a non-string mode", key: "mode", value: 3}, + {name: "an unknown sibling of mode", key: "modality", value: "coding"}, + } + + for _, tc := range rejected { + t.Run("rejects "+tc.name, func(t *testing.T) { + payload := loadPayload(t) + session(t, payload)[tc.key] = tc.value + require.Error(t, schemavalidators.ValidateAICodingSession(payload, "")) + }) + } +} + func TestValidateSecurityContext(t *testing.T) { testCases := []struct { name string diff --git a/pkg/attestation/crafter/materials/aicodingsession/aicodingsession.go b/pkg/attestation/crafter/materials/aicodingsession/aicodingsession.go index d3ed1de69..442926314 100644 --- a/pkg/attestation/crafter/materials/aicodingsession/aicodingsession.go +++ b/pkg/attestation/crafter/materials/aicodingsession/aicodingsession.go @@ -24,6 +24,29 @@ const ( EvidenceSchemaURL = "https://schemas.chainloop.dev/aicodingsession/0.1/ai-coding-session.schema.json" ) +// How `chainloop trace` was driven for a session. Recorded so that consumers +// can tell an ongoing coding session apart from a one-shot wrapped command +// instead of presenting them as the same thing. More modes are expected, so the +// field is a plain string rather than a closed enumeration. +const ( + // ModeCoding is a session traced through the agent and git hooks installed + // by `chainloop trace init`. It is what an absent mode means, so sessions + // recorded before the field existed keep their meaning. + ModeCoding = "coding" + // ModeGeneric is a single-shot session wrapped by `chainloop trace run`. + ModeGeneric = "generic" +) + +// ResolveMode maps an unset mode onto the one an absent mode means, so that the +// default is written down once instead of at each producer and consumer. +func ResolveMode(mode string) string { + if mode == "" { + return ModeCoding + } + + return mode +} + // Agent identifies the AI agent provider. type Agent struct { Name string `json:"name"` @@ -32,8 +55,10 @@ type Agent struct { // Session holds timing and identity information for the coding session. type Session struct { - ID string `json:"id"` - Slug string `json:"slug,omitempty"` + ID string `json:"id"` + Slug string `json:"slug,omitempty"` + // Mode is one of the Mode* constants. Empty means ModeCoding. + Mode string `json:"mode,omitempty"` StartedAt string `json:"started_at"` EndedAt string `json:"ended_at,omitempty"` DurationSeconds int `json:"duration_seconds"` diff --git a/pkg/attestation/crafter/materials/aicodingsession/mode_test.go b/pkg/attestation/crafter/materials/aicodingsession/mode_test.go new file mode 100644 index 000000000..facf272f7 --- /dev/null +++ b/pkg/attestation/crafter/materials/aicodingsession/mode_test.go @@ -0,0 +1,43 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package aicodingsession + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestResolveMode(t *testing.T) { + testCases := []struct { + name string + mode string + want string + }{ + {name: "an unset mode is a coding session", mode: "", want: ModeCoding}, + {name: "an explicit coding mode is kept", mode: ModeCoding, want: ModeCoding}, + {name: "a generic mode is kept", mode: ModeGeneric, want: ModeGeneric}, + // Modes are not a closed set: one added after this binary was built + // must pass through rather than collapse to the default. + {name: "a mode this version predates is kept", mode: "spec", want: "spec"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, ResolveMode(tc.mode)) + }) + } +} diff --git a/pkg/attestation/crafter/materials/aicodingsession/redact.go b/pkg/attestation/crafter/materials/aicodingsession/redact.go index bdfbf0499..d20973b1b 100644 --- a/pkg/attestation/crafter/materials/aicodingsession/redact.go +++ b/pkg/attestation/crafter/materials/aicodingsession/redact.go @@ -46,6 +46,7 @@ var protectedPaths = []string{ "/data/agent/version", "/data/session/id", "/data/session/slug", + "/data/session/mode", "/data/session/started_at", "/data/session/ended_at", "/data/git_context/branch", diff --git a/pkg/attestation/crafter/materials/aicodingsession/redact_test.go b/pkg/attestation/crafter/materials/aicodingsession/redact_test.go index 495447df1..3bf7303e8 100644 --- a/pkg/attestation/crafter/materials/aicodingsession/redact_test.go +++ b/pkg/attestation/crafter/materials/aicodingsession/redact_test.go @@ -89,6 +89,7 @@ func TestEligible(t *testing.T) { {"/data/agent/version", false}, {"/data/session/id", false}, {"/data/session/slug", false}, + {"/data/session/mode", false}, {"/data/session/started_at", false}, {"/data/session/ended_at", false}, {"/data/git_context/branch", false}, @@ -208,6 +209,7 @@ func TestRedact(t *testing.T) { "data/schema_version", "data/agent/name", "data/session/id", + "data/session/mode", "data/session/started_at", "data/git_context/commit_start", "data/git_context/commit_end", diff --git a/pkg/attestation/crafter/materials/aicodingsession/testdata/session-with-secrets.json b/pkg/attestation/crafter/materials/aicodingsession/testdata/session-with-secrets.json index 3a6037023..756153e1b 100644 --- a/pkg/attestation/crafter/materials/aicodingsession/testdata/session-with-secrets.json +++ b/pkg/attestation/crafter/materials/aicodingsession/testdata/session-with-secrets.json @@ -10,6 +10,7 @@ "session": { "id": "fa8acbe6-a176-4c2a-b51e-fd4541615eb5", "slug": "stateful-wobbling-sutherland", + "mode": "generic", "started_at": "2026-03-25T15:10:49.161Z", "ended_at": "2026-03-25T16:59:14.988Z", "duration_seconds": 6505 diff --git a/pkg/attestation/crafter/materials/chainloop_ai_coding_session.go b/pkg/attestation/crafter/materials/chainloop_ai_coding_session.go index 86eff53ec..0b9c0ab8d 100644 --- a/pkg/attestation/crafter/materials/chainloop_ai_coding_session.go +++ b/pkg/attestation/crafter/materials/chainloop_ai_coding_session.go @@ -33,7 +33,16 @@ import ( "github.com/rs/zerolog" ) -var annotationAICodingModel = api.CreateAnnotation("material.aiagent.model") +var ( + annotationAICodingModel = api.CreateAnnotation("material.aiagent.model") + // How the session was driven, published so that a consumer can separate an + // ongoing coding session from a one-shot `chainloop trace run` without + // downloading and parsing the session itself — an annotation query is an + // exact key/value match. Always set, defaulting to the meaning of an absent + // mode, so that the filter behaves the same for sessions recorded before the + // field existed. + annotationAICodingSessionMode = api.CreateAnnotation("material.aisession.mode") +) type ChainloopAICodingSessionCrafter struct { *crafterCommon @@ -135,6 +144,9 @@ func (c *ChainloopAICodingSessionCrafter) Craft(ctx context.Context, artifactPat material.Annotations[annotationAICodingModel] = data.Model.Primary } + // Surface how the session was run + material.Annotations[annotationAICodingSessionMode] = aicodingsession.ResolveMode(data.Session.Mode) + return &CraftResult{Material: material, Content: redacted}, nil } diff --git a/pkg/attestation/crafter/materials/chainloop_ai_coding_session_test.go b/pkg/attestation/crafter/materials/chainloop_ai_coding_session_test.go index 11144f23d..5a22b349a 100644 --- a/pkg/attestation/crafter/materials/chainloop_ai_coding_session_test.go +++ b/pkg/attestation/crafter/materials/chainloop_ai_coding_session_test.go @@ -349,6 +349,77 @@ func TestChainloopAICodingSessionCrafter_Annotations(t *testing.T) { _, exists := got.Annotations[annotationAICodingModel] assert.False(t, exists, "model annotation should not be present") } + + // None of these fixtures carries a mode, so they all take the default. + assert.Equal(t, aicodingsession.ModeCoding, got.Annotations[annotationAICodingSessionMode]) }) } } + +// TestChainloopAICodingSessionCrafter_ModeAnnotation checks that the mode a +// session declares reaches the attestation as an annotation, and that a session +// with no mode is annotated as "coding" rather than left without the key. The +// annotation is the cheap filter: it is an exact key/value match a consumer can +// query without downloading and parsing the session itself. +func TestChainloopAICodingSessionCrafter_ModeAnnotation(t *testing.T) { + testCases := []struct { + name string + mode string + expectedMode string + }{ + {name: "no mode falls back to coding", mode: "", expectedMode: aicodingsession.ModeCoding}, + {name: "an explicit coding mode", mode: aicodingsession.ModeCoding, expectedMode: aicodingsession.ModeCoding}, + {name: "a generic mode from trace run", mode: aicodingsession.ModeGeneric, expectedMode: aicodingsession.ModeGeneric}, + {name: "a mode this version predates", mode: "spec", expectedMode: "spec"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + path := sessionFixtureWithMode(t, "./testdata/ai-coding-session-minimal.json", tc.mode) + + logger := zerolog.Nop() + schema := &schemaapi.CraftingSchema_Material{ + Name: "test", + Type: schemaapi.CraftingSchema_Material_CHAINLOOP_AI_CODING_SESSION, + } + + uploader := mUploader.NewUploader(t) + uploader.On("Upload", context.TODO(), mock.Anything, mock.Anything, mock.Anything). + Return(&casclient.UpDownStatus{Digest: "deadbeef", Filename: path}, nil) + + crafter, err := NewChainloopAICodingSessionCrafter(schema, &casclient.CASBackend{Uploader: uploader}, &logger) + require.NoError(t, err) + + got, err := craftedMaterial(crafter.Craft(context.TODO(), path)) + require.NoError(t, err) + + assert.Equal(t, tc.expectedMode, got.Annotations[annotationAICodingSessionMode]) + }) + } +} + +// sessionFixtureWithMode copies a session fixture into a temp file, setting +// data.session.mode to mode, or leaving it out when mode is empty. +func sessionFixtureWithMode(t *testing.T, fixture, mode string) string { + t.Helper() + + raw, err := os.ReadFile(fixture) + require.NoError(t, err) + + var evidence struct { + ID string `json:"chainloop.material.evidence.id"` + Schema string `json:"schema"` + Data aicodingsession.Data `json:"data"` + } + require.NoError(t, json.Unmarshal(raw, &evidence)) + + evidence.Data.Session.Mode = mode + + out, err := json.Marshal(evidence) + require.NoError(t, err) + + path := filepath.Join(t.TempDir(), "session.json") + require.NoError(t, os.WriteFile(path, out, 0600)) + + return path +}