Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/cli/cmd/trace_hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -98,6 +99,7 @@ func newTraceHookGitPrePushCmd() *cobra.Command {
return action.HandlePrePushHook(cmd.Context(), requireTrace, logger, action.RunTracePushOpts{
ActionOpts: ActionOpts,
CLIVersion: Version,
Mode: aicodingsession.ModeCoding,
})
},
}
Expand Down
18 changes: 17 additions & 1 deletion app/cli/pkg/action/trace_hook_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions app/cli/pkg/action/trace_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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,
Comment thread
jiparis marked this conversation as resolved.
ActionOpts: opts.ActionOpts,
CLIVersion: opts.CLIVersion,
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
63 changes: 63 additions & 0 deletions internal/schemavalidators/schemavalidators_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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"`
Expand Down
43 changes: 43 additions & 0 deletions pkg/attestation/crafter/materials/aicodingsession/mode_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion pkg/attestation/crafter/materials/chainloop_ai_coding_session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading