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 +}