From fec1933ea18acd100c103060d3a3f9f687516345 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Mon, 31 Aug 2026 13:56:50 +0200 Subject: [PATCH 1/7] feat(trace): move the trace internals into app/cli/internal/trace First step of opening `chainloop trace`. The whole subtree moves from the closed-source platform repo unchanged apart from the module prefix: it only ever depended on OSS primitives (pkg/attestation/crafter/materials/ aicodingsession) and third-party libraries. Two adjustments were unavoidable: - TestGoGitClient_GeneratedMatcherProjectAttributes runs the matcher against the real repository .gitattributes, so its expectations were retargeted at this repo's paths. The new expectations were taken from `git check-attr linguist-generated`, and they still exercise the "last match wins" override (ent/** generated, then ent/migrate/** and ent/schema/* not). - Two comments pointed at platform paths that no longer resolve. The cmd and action layers follow in the next commits; nothing references this code yet. Signed-off-by: Jose I. Paris --- app/cli/internal/trace/attribution/diff.go | 89 +++ .../internal/trace/attribution/diff_test.go | 120 ++++ app/cli/internal/trace/attribution/edits.go | 60 ++ .../internal/trace/attribution/edits_test.go | 73 +++ app/cli/internal/trace/attribution/enrich.go | 45 ++ .../internal/trace/attribution/enrich_test.go | 123 ++++ .../internal/trace/attribution/generated.go | 34 ++ .../trace/attribution/generated_test.go | 131 ++++ app/cli/internal/trace/attribution/shell.go | 27 + .../internal/trace/attribution/shell_test.go | 37 ++ app/cli/internal/trace/claude/hooks.go | 310 ++++++++++ app/cli/internal/trace/claude/hooks_test.go | 342 +++++++++++ app/cli/internal/trace/claude/parse.go | 407 +++++++++++++ app/cli/internal/trace/claude/parse_test.go | 289 +++++++++ app/cli/internal/trace/claude/pid_unix.go | 14 + app/cli/internal/trace/claude/pid_windows.go | 22 + app/cli/internal/trace/claude/pricing.go | 57 ++ app/cli/internal/trace/claude/pricing.json | 183 ++++++ app/cli/internal/trace/claude/pricing_test.go | 37 ++ app/cli/internal/trace/claude/provider.go | 191 ++++++ app/cli/internal/trace/claude/session.go | 88 +++ app/cli/internal/trace/claude/session_test.go | 165 +++++ .../trace/claude/testdata/older-session.jsonl | 1 + .../trace/claude/testdata/test-session.jsonl | 7 + .../test-session/subagents/agent-abc123.jsonl | 2 + .../subagents/agent-abc123.meta.json | 1 + app/cli/internal/trace/claude/types.go | 102 ++++ app/cli/internal/trace/config/config.go | 247 ++++++++ app/cli/internal/trace/config/config_test.go | 329 ++++++++++ app/cli/internal/trace/cursor/hooks.go | 315 ++++++++++ app/cli/internal/trace/cursor/hooks_test.go | 194 ++++++ app/cli/internal/trace/cursor/parse.go | 80 +++ app/cli/internal/trace/cursor/parse_test.go | 164 +++++ app/cli/internal/trace/cursor/provider.go | 211 +++++++ app/cli/internal/trace/cursor/session.go | 133 ++++ app/cli/internal/trace/cursor/session_test.go | 55 ++ app/cli/internal/trace/cursor/types.go | 24 + app/cli/internal/trace/git/aggregate.go | 63 ++ app/cli/internal/trace/git/exec.go | 342 +++++++++++ app/cli/internal/trace/git/exec_test.go | 230 +++++++ app/cli/internal/trace/git/git.go | 61 ++ app/cli/internal/trace/git/gogit.go | 571 ++++++++++++++++++ app/cli/internal/trace/git/gogit_test.go | 521 ++++++++++++++++ .../internal/trace/git/merge_branch_test.go | 182 ++++++ app/cli/internal/trace/git/repo.go | 104 ++++ app/cli/internal/trace/git/repo_test.go | 197 ++++++ app/cli/internal/trace/hooks/hooks.go | 254 ++++++++ app/cli/internal/trace/hooks/hooks_test.go | 306 ++++++++++ .../trace/opencode/export_integration_test.go | 56 ++ .../internal/trace/opencode/golden_test.go | 48 ++ app/cli/internal/trace/opencode/hooks.go | 278 +++++++++ app/cli/internal/trace/opencode/hooks_test.go | 304 ++++++++++ app/cli/internal/trace/opencode/parse.go | 241 ++++++++ app/cli/internal/trace/opencode/parse_test.go | 316 ++++++++++ app/cli/internal/trace/opencode/provider.go | 161 +++++ app/cli/internal/trace/opencode/session.go | 55 ++ .../internal/trace/opencode/session_test.go | 106 ++++ .../trace/opencode/testdata/plugin_full.ts | 94 +++ .../opencode/testdata/plugin_tracerun.ts | 90 +++ app/cli/internal/trace/opencode/types.go | 111 ++++ app/cli/internal/trace/provider.go | 147 +++++ app/cli/internal/trace/providers/registry.go | 61 ++ app/cli/internal/trace/state/ailines.go | 111 ++++ app/cli/internal/trace/state/commit.go | 81 +++ app/cli/internal/trace/state/jsonlcopy.go | 128 ++++ app/cli/internal/trace/state/locate.go | 119 ++++ app/cli/internal/trace/state/locate_test.go | 154 +++++ app/cli/internal/trace/state/session.go | 104 ++++ app/cli/internal/trace/state/snapshot.go | 82 +++ app/cli/internal/trace/state/snapshot_test.go | 28 + app/cli/internal/trace/state/state.go | 267 ++++++++ app/cli/internal/trace/state/state_test.go | 346 +++++++++++ app/cli/internal/trace/state/store.go | 56 ++ app/cli/internal/trace/state/trailer.go | 52 ++ app/cli/internal/trace/state/trailer_test.go | 83 +++ app/cli/internal/trace/types.go | 57 ++ 76 files changed, 11276 insertions(+) create mode 100644 app/cli/internal/trace/attribution/diff.go create mode 100644 app/cli/internal/trace/attribution/diff_test.go create mode 100644 app/cli/internal/trace/attribution/edits.go create mode 100644 app/cli/internal/trace/attribution/edits_test.go create mode 100644 app/cli/internal/trace/attribution/enrich.go create mode 100644 app/cli/internal/trace/attribution/enrich_test.go create mode 100644 app/cli/internal/trace/attribution/generated.go create mode 100644 app/cli/internal/trace/attribution/generated_test.go create mode 100644 app/cli/internal/trace/attribution/shell.go create mode 100644 app/cli/internal/trace/attribution/shell_test.go create mode 100644 app/cli/internal/trace/claude/hooks.go create mode 100644 app/cli/internal/trace/claude/hooks_test.go create mode 100644 app/cli/internal/trace/claude/parse.go create mode 100644 app/cli/internal/trace/claude/parse_test.go create mode 100644 app/cli/internal/trace/claude/pid_unix.go create mode 100644 app/cli/internal/trace/claude/pid_windows.go create mode 100644 app/cli/internal/trace/claude/pricing.go create mode 100644 app/cli/internal/trace/claude/pricing.json create mode 100644 app/cli/internal/trace/claude/pricing_test.go create mode 100644 app/cli/internal/trace/claude/provider.go create mode 100644 app/cli/internal/trace/claude/session.go create mode 100644 app/cli/internal/trace/claude/session_test.go create mode 100644 app/cli/internal/trace/claude/testdata/older-session.jsonl create mode 100644 app/cli/internal/trace/claude/testdata/test-session.jsonl create mode 100644 app/cli/internal/trace/claude/testdata/test-session/subagents/agent-abc123.jsonl create mode 100644 app/cli/internal/trace/claude/testdata/test-session/subagents/agent-abc123.meta.json create mode 100644 app/cli/internal/trace/claude/types.go create mode 100644 app/cli/internal/trace/config/config.go create mode 100644 app/cli/internal/trace/config/config_test.go create mode 100644 app/cli/internal/trace/cursor/hooks.go create mode 100644 app/cli/internal/trace/cursor/hooks_test.go create mode 100644 app/cli/internal/trace/cursor/parse.go create mode 100644 app/cli/internal/trace/cursor/parse_test.go create mode 100644 app/cli/internal/trace/cursor/provider.go create mode 100644 app/cli/internal/trace/cursor/session.go create mode 100644 app/cli/internal/trace/cursor/session_test.go create mode 100644 app/cli/internal/trace/cursor/types.go create mode 100644 app/cli/internal/trace/git/aggregate.go create mode 100644 app/cli/internal/trace/git/exec.go create mode 100644 app/cli/internal/trace/git/exec_test.go create mode 100644 app/cli/internal/trace/git/git.go create mode 100644 app/cli/internal/trace/git/gogit.go create mode 100644 app/cli/internal/trace/git/gogit_test.go create mode 100644 app/cli/internal/trace/git/merge_branch_test.go create mode 100644 app/cli/internal/trace/git/repo.go create mode 100644 app/cli/internal/trace/git/repo_test.go create mode 100644 app/cli/internal/trace/hooks/hooks.go create mode 100644 app/cli/internal/trace/hooks/hooks_test.go create mode 100644 app/cli/internal/trace/opencode/export_integration_test.go create mode 100644 app/cli/internal/trace/opencode/golden_test.go create mode 100644 app/cli/internal/trace/opencode/hooks.go create mode 100644 app/cli/internal/trace/opencode/hooks_test.go create mode 100644 app/cli/internal/trace/opencode/parse.go create mode 100644 app/cli/internal/trace/opencode/parse_test.go create mode 100644 app/cli/internal/trace/opencode/provider.go create mode 100644 app/cli/internal/trace/opencode/session.go create mode 100644 app/cli/internal/trace/opencode/session_test.go create mode 100644 app/cli/internal/trace/opencode/testdata/plugin_full.ts create mode 100644 app/cli/internal/trace/opencode/testdata/plugin_tracerun.ts create mode 100644 app/cli/internal/trace/opencode/types.go create mode 100644 app/cli/internal/trace/provider.go create mode 100644 app/cli/internal/trace/providers/registry.go create mode 100644 app/cli/internal/trace/state/ailines.go create mode 100644 app/cli/internal/trace/state/commit.go create mode 100644 app/cli/internal/trace/state/jsonlcopy.go create mode 100644 app/cli/internal/trace/state/locate.go create mode 100644 app/cli/internal/trace/state/locate_test.go create mode 100644 app/cli/internal/trace/state/session.go create mode 100644 app/cli/internal/trace/state/snapshot.go create mode 100644 app/cli/internal/trace/state/snapshot_test.go create mode 100644 app/cli/internal/trace/state/state.go create mode 100644 app/cli/internal/trace/state/state_test.go create mode 100644 app/cli/internal/trace/state/store.go create mode 100644 app/cli/internal/trace/state/trailer.go create mode 100644 app/cli/internal/trace/state/trailer_test.go create mode 100644 app/cli/internal/trace/types.go diff --git a/app/cli/internal/trace/attribution/diff.go b/app/cli/internal/trace/attribution/diff.go new file mode 100644 index 000000000..62a6e373d --- /dev/null +++ b/app/cli/internal/trace/attribution/diff.go @@ -0,0 +1,89 @@ +package attribution + +import ( + "bytes" + "strings" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" + "github.com/sergi/go-diff/diffmatchpatch" +) + +// ComputeLineRanges computes which line ranges in "after" are new or modified +// compared to "before". Returns 1-indexed, inclusive line ranges plus a +// `changed` flag that is true whenever before and after differ — including +// deletion-only changes where no "after" ranges exist. +// If before is nil, the entire file is considered new. +func ComputeLineRanges(before, after []byte) ([]aicodingsession.LineRange, bool) { + if bytes.Equal(before, after) { + return nil, false + } + + afterStr := string(after) + if afterStr == "" { + // Deletion-only: before had content, after is empty. + return nil, true + } + + if before == nil { + lineCount := strings.Count(afterStr, "\n") + if !strings.HasSuffix(afterStr, "\n") { + lineCount++ + } + return []aicodingsession.LineRange{{Start: 1, End: lineCount}}, true + } + + beforeStr := string(before) + dmp := diffmatchpatch.New() + beforeChars, afterChars, lines := dmp.DiffLinesToChars(beforeStr, afterStr) + diffs := dmp.DiffMain(beforeChars, afterChars, false) + diffs = dmp.DiffCharsToLines(diffs, lines) + diffs = dmp.DiffCleanupSemantic(diffs) + + var ranges []aicodingsession.LineRange + afterLine := 1 + + for _, d := range diffs { + lineCount := strings.Count(d.Text, "\n") + if len(d.Text) > 0 && !strings.HasSuffix(d.Text, "\n") { + lineCount++ + } + + switch d.Type { + case diffmatchpatch.DiffEqual: + afterLine += lineCount + case diffmatchpatch.DiffInsert: + if lineCount > 0 { + ranges = append(ranges, aicodingsession.LineRange{ + Start: afterLine, + End: afterLine + lineCount - 1, + }) + afterLine += lineCount + } + case diffmatchpatch.DiffDelete: + // Lines removed from "before" — don't advance afterLine. + } + } + + return mergeAdjacentRanges(ranges), true +} + +// mergeAdjacentRanges merges overlapping or adjacent line ranges. +func mergeAdjacentRanges(ranges []aicodingsession.LineRange) []aicodingsession.LineRange { + if len(ranges) <= 1 { + return ranges + } + + merged := []aicodingsession.LineRange{ranges[0]} + for _, r := range ranges[1:] { + last := &merged[len(merged)-1] + if r.Start <= last.End+1 { + if r.End > last.End { + last.End = r.End + } + } else { + merged = append(merged, r) + } + } + + return merged +} diff --git a/app/cli/internal/trace/attribution/diff_test.go b/app/cli/internal/trace/attribution/diff_test.go new file mode 100644 index 000000000..892b15e5e --- /dev/null +++ b/app/cli/internal/trace/attribution/diff_test.go @@ -0,0 +1,120 @@ +package attribution + +import ( + "testing" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" + "github.com/stretchr/testify/assert" +) + +func TestComputeLineRanges(t *testing.T) { + t.Run("nil before means entire file is new", func(t *testing.T) { + after := []byte("line1\nline2\nline3\n") + ranges, changed := ComputeLineRanges(nil, after) + assert.True(t, changed) + assert.Equal(t, []aicodingsession.LineRange{{Start: 1, End: 3}}, ranges) + }) + + t.Run("both nil is unchanged", func(t *testing.T) { + ranges, changed := ComputeLineRanges(nil, nil) + assert.False(t, changed) + assert.Nil(t, ranges) + }) + + t.Run("empty after with content before is deletion-only", func(t *testing.T) { + ranges, changed := ComputeLineRanges([]byte("old\n"), []byte("")) + assert.True(t, changed) + assert.Nil(t, ranges) + }) + + t.Run("identical content is unchanged", func(t *testing.T) { + content := []byte("line1\nline2\n") + ranges, changed := ComputeLineRanges(content, content) + assert.False(t, changed) + assert.Nil(t, ranges) + }) + + t.Run("deletion-only edit is changed with no ranges", func(t *testing.T) { + before := []byte("line1\nline2\nline3\n") + after := []byte("line1\nline3\n") + ranges, changed := ComputeLineRanges(before, after) + assert.True(t, changed) + assert.Empty(t, ranges) + }) + + t.Run("appended lines detected", func(t *testing.T) { + before := []byte("line1\nline2\n") + after := []byte("line1\nline2\nline3\nline4\n") + ranges, changed := ComputeLineRanges(before, after) + assert.True(t, changed) + assert.Equal(t, []aicodingsession.LineRange{{Start: 3, End: 4}}, ranges) + }) + + t.Run("inserted lines in middle", func(t *testing.T) { + before := []byte("line1\nline3\n") + after := []byte("line1\nline2\nline3\n") + ranges, changed := ComputeLineRanges(before, after) + assert.True(t, changed) + assert.Equal(t, []aicodingsession.LineRange{{Start: 2, End: 2}}, ranges) + }) + + t.Run("replaced lines detected", func(t *testing.T) { + before := []byte("line1\nold\nline3\n") + after := []byte("line1\nnew\nline3\n") + ranges, changed := ComputeLineRanges(before, after) + assert.True(t, changed) + assert.Equal(t, []aicodingsession.LineRange{{Start: 2, End: 2}}, ranges) + }) + + t.Run("new file without trailing newline", func(t *testing.T) { + ranges, changed := ComputeLineRanges(nil, []byte("single line")) + assert.True(t, changed) + assert.Equal(t, []aicodingsession.LineRange{{Start: 1, End: 1}}, ranges) + }) + + t.Run("multiple separate changes", func(t *testing.T) { + before := []byte("a\nb\nc\nd\ne\n") + after := []byte("a\nX\nc\nY\ne\n") + ranges, changed := ComputeLineRanges(before, after) + assert.True(t, changed) + // The diff library may merge nearby changes; verify lines 2 and 4 are covered + assert.NotEmpty(t, ranges) + assert.Equal(t, 2, ranges[0].Start) + assert.Equal(t, 4, ranges[len(ranges)-1].End) + }) +} + +func TestMergeAdjacentRanges(t *testing.T) { + t.Run("empty returns empty", func(t *testing.T) { + assert.Nil(t, mergeAdjacentRanges(nil)) + }) + + t.Run("single range unchanged", func(t *testing.T) { + ranges := []aicodingsession.LineRange{{Start: 1, End: 3}} + assert.Equal(t, ranges, mergeAdjacentRanges(ranges)) + }) + + t.Run("adjacent ranges merged", func(t *testing.T) { + ranges := []aicodingsession.LineRange{ + {Start: 1, End: 3}, + {Start: 4, End: 6}, + } + assert.Equal(t, []aicodingsession.LineRange{{Start: 1, End: 6}}, mergeAdjacentRanges(ranges)) + }) + + t.Run("overlapping ranges merged", func(t *testing.T) { + ranges := []aicodingsession.LineRange{ + {Start: 1, End: 5}, + {Start: 3, End: 8}, + } + assert.Equal(t, []aicodingsession.LineRange{{Start: 1, End: 8}}, mergeAdjacentRanges(ranges)) + }) + + t.Run("non-adjacent ranges kept separate", func(t *testing.T) { + ranges := []aicodingsession.LineRange{ + {Start: 1, End: 3}, + {Start: 5, End: 7}, + } + assert.Equal(t, ranges, mergeAdjacentRanges(ranges)) + }) +} diff --git a/app/cli/internal/trace/attribution/edits.go b/app/cli/internal/trace/attribution/edits.go new file mode 100644 index 000000000..e5e33c6f9 --- /dev/null +++ b/app/cli/internal/trace/attribution/edits.go @@ -0,0 +1,60 @@ +package attribution + +import ( + "bytes" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" +) + +// ReverseApplyEdits reconstructs the "before" content of a file given its +// "after" content and the sequence of old_string → new_string replacements +// that were applied to produce it. Edits are reversed in reverse order. +// +// Best-effort and inherently ambiguous when new_string is not unique in +// the current content: bytes.Index always picks the first occurrence, +// which can be the wrong one if the same literal appears more than once. +// Cursor's afterFileEdit edits are typically context-rich enough that +// this rarely misfires in practice, and the worst-case outcome is a +// downstream line-range comparison reporting noisy ranges rather than a +// crash — but callers should treat the reconstructed bytes as a hint, +// not ground truth. +// +// If an edit's new_string cannot be located in the current content +// (because a later edit overlapped it, or the file was touched +// externally), that edit is skipped. When the resulting "before" equals +// "after" we return nil so callers can fall back to other reconstruction +// strategies. +func ReverseApplyEdits(after []byte, edits []trace.HookEdit) []byte { + if len(edits) == 0 { + return nil + } + + result := make([]byte, len(after)) + copy(result, after) + + for i := len(edits) - 1; i >= 0; i-- { + e := edits[i] + if e.NewString == "" { + continue + } + + newBytes := []byte(e.NewString) + idx := bytes.Index(result, newBytes) + if idx < 0 { + continue + } + + var b bytes.Buffer + b.Grow(len(result) - len(newBytes) + len(e.OldString)) + b.Write(result[:idx]) + b.WriteString(e.OldString) + b.Write(result[idx+len(newBytes):]) + result = b.Bytes() + } + + if bytes.Equal(result, after) { + return nil + } + + return result +} diff --git a/app/cli/internal/trace/attribution/edits_test.go b/app/cli/internal/trace/attribution/edits_test.go new file mode 100644 index 000000000..a314c8760 --- /dev/null +++ b/app/cli/internal/trace/attribution/edits_test.go @@ -0,0 +1,73 @@ +package attribution + +import ( + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/stretchr/testify/require" +) + +func TestReverseApplyEdits(t *testing.T) { + tests := []struct { + name string + after string + edits []trace.HookEdit + want string // expected reconstructed content when wantNil is false + wantNil bool // explicit "result must be nil"; distinct from a legitimate empty result + }{ + { + name: "no edits", + after: "hello", + edits: nil, + wantNil: true, + }, + { + name: "single replace", + after: "hello world", + edits: []trace.HookEdit{{OldString: "there", NewString: "world"}}, + want: "hello there", + }, + { + name: "multiple sequential edits reversed in order", + after: "foo BAR baz QUUX", + edits: []trace.HookEdit{ + {OldString: "bar", NewString: "BAR"}, + {OldString: "quux", NewString: "QUUX"}, + }, + want: "foo bar baz quux", + }, + { + name: "missing new_string is skipped", + after: "hello world", + edits: []trace.HookEdit{ + {OldString: "xyz", NewString: "not present"}, + {OldString: "there", NewString: "world"}, + }, + want: "hello there", + }, + { + name: "insertion (empty old) is reversed to deletion", + after: "prefix-content", + edits: []trace.HookEdit{{OldString: "", NewString: "prefix-"}}, + want: "content", + }, + { + name: "deletion (empty new) skipped because new_string empty", + after: "abc", + edits: []trace.HookEdit{{OldString: "removed", NewString: ""}}, + wantNil: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := ReverseApplyEdits([]byte(tc.after), tc.edits) + if tc.wantNil { + require.Nil(t, got) + + return + } + require.Equal(t, tc.want, string(got)) + }) + } +} diff --git a/app/cli/internal/trace/attribution/enrich.go b/app/cli/internal/trace/attribution/enrich.go new file mode 100644 index 000000000..3e7667957 --- /dev/null +++ b/app/cli/internal/trace/attribution/enrich.go @@ -0,0 +1,45 @@ +package attribution + +import ( + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" +) + +// Enrich adds AI/human attribution to code changes based on AI line range data. +// The sessionID identifies which session produced the changes, and aiFiles maps +// file paths to the line ranges modified by the AI in that session. +func Enrich(sessionID string, aiFiles map[string][]aicodingsession.LineRange, changes *aicodingsession.CodeChanges) { + if len(aiFiles) == 0 { + // No AI line data — all changes are human + changes.HumanLinesAdded = changes.LinesAdded + changes.HumanLinesRemoved = changes.LinesRemoved + for i := range changes.Files { + changes.Files[i].Attribution = trace.AttributionHuman + } + + return + } + + for i := range changes.Files { + f := &changes.Files[i] + // An empty ranges slice is valid: the agent only deleted lines. + if aiRanges, ok := aiFiles[f.Path]; ok { + f.Attribution = trace.AttributionAI + f.LineRanges = aiRanges + f.SessionIDs = []string{sessionID} + // Count AI lines as the lines covered by AI ranges, capped at the file's diff totals + aiLineCount := 0 + for _, r := range aiRanges { + aiLineCount += r.End - r.Start + 1 + } + aiAdded := min(aiLineCount, f.LinesAdded) + changes.AILinesAdded += aiAdded + changes.HumanLinesAdded += f.LinesAdded - aiAdded + changes.AILinesRemoved += f.LinesRemoved + } else { + f.Attribution = trace.AttributionHuman + changes.HumanLinesAdded += f.LinesAdded + changes.HumanLinesRemoved += f.LinesRemoved + } + } +} diff --git a/app/cli/internal/trace/attribution/enrich_test.go b/app/cli/internal/trace/attribution/enrich_test.go new file mode 100644 index 000000000..f39253861 --- /dev/null +++ b/app/cli/internal/trace/attribution/enrich_test.go @@ -0,0 +1,123 @@ +package attribution + +import ( + "testing" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/stretchr/testify/assert" +) + +func TestEnrich(t *testing.T) { + t.Run("no AI data attributes all changes to human", func(t *testing.T) { + changes := &aicodingsession.CodeChanges{ + LinesAdded: 10, + LinesRemoved: 4, + Files: []aicodingsession.FileChange{ + {Path: "a.go", LinesAdded: 10, LinesRemoved: 4}, + }, + } + + Enrich("sess", nil, changes) + + assert.Equal(t, 10, changes.HumanLinesAdded) + assert.Equal(t, 4, changes.HumanLinesRemoved) + assert.Equal(t, 0, changes.AILinesAdded) + assert.Equal(t, 0, changes.AILinesRemoved) + assert.Equal(t, trace.AttributionHuman, changes.Files[0].Attribution) + }) + + t.Run("shell-generated file recorded as AI is not counted as human (PFM-6684)", func(t *testing.T) { + // Regression: a 177,764-line fixture the agent produced via a shell + // command, once recorded in the AI-line map (whole-file range), must be + // attributed to AI — not dumped into the human bucket where it drowns + // the small edits and yields a misleading 0% AI. + aiFiles := map[string][]aicodingsession.LineRange{ + "policies/foo.rego": {{Start: 1, End: 222}}, + "policies/integration-tests/materials/sbom.cyclonedx.json": {{Start: 1, End: 177764}}, + } + changes := &aicodingsession.CodeChanges{ + LinesAdded: 177986, + Files: []aicodingsession.FileChange{ + {Path: "policies/foo.rego", Status: "modified", LinesAdded: 222}, + {Path: "policies/integration-tests/materials/sbom.cyclonedx.json", Status: "created", LinesAdded: 177764}, + }, + } + + Enrich("sess", aiFiles, changes) + + assert.Equal(t, trace.AttributionAI, changes.Files[0].Attribution) + assert.Equal(t, trace.AttributionAI, changes.Files[1].Attribution) + assert.Equal(t, 177986, changes.AILinesAdded) + assert.Equal(t, 0, changes.HumanLinesAdded) + }) + + t.Run("file with AI ranges is attributed to AI", func(t *testing.T) { + aiFiles := map[string][]aicodingsession.LineRange{ + "a.go": {{Start: 1, End: 5}}, + } + changes := &aicodingsession.CodeChanges{ + LinesAdded: 5, + LinesRemoved: 2, + Files: []aicodingsession.FileChange{ + {Path: "a.go", LinesAdded: 5, LinesRemoved: 2}, + }, + } + + Enrich("sess", aiFiles, changes) + + assert.Equal(t, trace.AttributionAI, changes.Files[0].Attribution) + assert.Equal(t, []string{"sess"}, changes.Files[0].SessionIDs) + assert.Equal(t, 5, changes.AILinesAdded) + assert.Equal(t, 2, changes.AILinesRemoved) + assert.Equal(t, 0, changes.HumanLinesAdded) + assert.Equal(t, 0, changes.HumanLinesRemoved) + }) + + t.Run("deletion-only file with empty ranges is attributed to AI", func(t *testing.T) { + // AI removed lines from a file — there are no "after" ranges, but + // the file is still listed in the session's attribution map. + aiFiles := map[string][]aicodingsession.LineRange{ + "a.go": nil, + } + changes := &aicodingsession.CodeChanges{ + LinesAdded: 0, + LinesRemoved: 4, + Files: []aicodingsession.FileChange{ + {Path: "a.go", LinesAdded: 0, LinesRemoved: 4}, + }, + } + + Enrich("sess", aiFiles, changes) + + assert.Equal(t, trace.AttributionAI, changes.Files[0].Attribution) + assert.Equal(t, []string{"sess"}, changes.Files[0].SessionIDs) + assert.Equal(t, 0, changes.AILinesAdded) + assert.Equal(t, 4, changes.AILinesRemoved) + assert.Equal(t, 0, changes.HumanLinesAdded) + assert.Equal(t, 0, changes.HumanLinesRemoved) + }) + + t.Run("files not in aiFiles are attributed to human", func(t *testing.T) { + aiFiles := map[string][]aicodingsession.LineRange{ + "a.go": {{Start: 1, End: 2}}, + } + changes := &aicodingsession.CodeChanges{ + LinesAdded: 6, + LinesRemoved: 3, + Files: []aicodingsession.FileChange{ + {Path: "a.go", LinesAdded: 2, LinesRemoved: 1}, + {Path: "b.go", LinesAdded: 4, LinesRemoved: 2}, + }, + } + + Enrich("sess", aiFiles, changes) + + assert.Equal(t, trace.AttributionAI, changes.Files[0].Attribution) + assert.Equal(t, trace.AttributionHuman, changes.Files[1].Attribution) + assert.Equal(t, 2, changes.AILinesAdded) + assert.Equal(t, 1, changes.AILinesRemoved) + assert.Equal(t, 4, changes.HumanLinesAdded) + assert.Equal(t, 2, changes.HumanLinesRemoved) + }) +} diff --git a/app/cli/internal/trace/attribution/generated.go b/app/cli/internal/trace/attribution/generated.go new file mode 100644 index 000000000..0a3c43814 --- /dev/null +++ b/app/cli/internal/trace/attribution/generated.go @@ -0,0 +1,34 @@ +package attribution + +import ( + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" +) + +// FilterGenerated removes files matched by isGenerated from changes.Files and +// subtracts their contributions from the aggregate line and file-count totals, +// so generated code does not skew downstream AI/human attribution. A nil +// isGenerated leaves changes untouched. +func FilterGenerated(changes *aicodingsession.CodeChanges, isGenerated func(path string) bool) { + if isGenerated == nil { + return + } + filtered := changes.Files[:0] + for _, f := range changes.Files { + if isGenerated(f.Path) { + changes.LinesAdded -= f.LinesAdded + changes.LinesRemoved -= f.LinesRemoved + switch f.Status { + case "created": + changes.FilesCreated-- + case "deleted": + changes.FilesDeleted-- + default: + changes.FilesModified-- + } + + continue + } + filtered = append(filtered, f) + } + changes.Files = filtered +} diff --git a/app/cli/internal/trace/attribution/generated_test.go b/app/cli/internal/trace/attribution/generated_test.go new file mode 100644 index 000000000..56124787d --- /dev/null +++ b/app/cli/internal/trace/attribution/generated_test.go @@ -0,0 +1,131 @@ +package attribution + +import ( + "os" + "path/filepath" + "testing" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" + tracegit "github.com/chainloop-dev/chainloop/app/cli/internal/trace/git" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFilterGenerated(t *testing.T) { + t.Run("nil matcher leaves changes untouched", func(t *testing.T) { + changes := &aicodingsession.CodeChanges{ + FilesModified: 1, + LinesAdded: 5, + Files: []aicodingsession.FileChange{{Path: "a.go", Status: "modified", LinesAdded: 5}}, + } + + FilterGenerated(changes, nil) + + assert.Len(t, changes.Files, 1) + assert.Equal(t, 5, changes.LinesAdded) + assert.Equal(t, 1, changes.FilesModified) + }) + + t.Run("generated created file is dropped and counters decremented", func(t *testing.T) { + isGenerated := func(path string) bool { return path == "gen/out.pb.go" } + changes := &aicodingsession.CodeChanges{ + FilesModified: 1, + FilesCreated: 1, + LinesAdded: 203, + LinesRemoved: 5, + Files: []aicodingsession.FileChange{ + {Path: "a.go", Status: "modified", LinesAdded: 3, LinesRemoved: 5}, + {Path: "gen/out.pb.go", Status: "created", LinesAdded: 200, LinesRemoved: 0}, + }, + } + + FilterGenerated(changes, isGenerated) + + assert.Len(t, changes.Files, 1) + assert.Equal(t, "a.go", changes.Files[0].Path) + assert.Equal(t, 3, changes.LinesAdded) + assert.Equal(t, 5, changes.LinesRemoved) + assert.Equal(t, 1, changes.FilesModified) + assert.Equal(t, 0, changes.FilesCreated) + }) + + t.Run("generated deleted file decrements FilesDeleted", func(t *testing.T) { + isGenerated := func(path string) bool { return path == "old.pb.go" } + changes := &aicodingsession.CodeChanges{ + FilesDeleted: 1, + LinesRemoved: 7, + Files: []aicodingsession.FileChange{ + {Path: "old.pb.go", Status: "deleted", LinesRemoved: 7}, + }, + } + + FilterGenerated(changes, isGenerated) + + assert.Empty(t, changes.Files) + assert.Equal(t, 0, changes.FilesDeleted) + assert.Equal(t, 0, changes.LinesRemoved) + }) + + t.Run("ent gitattributes rules drop generated ent code but keep schema and migrations", func(t *testing.T) { + dir := t.TempDir() + // Order matches the project .gitattributes: general rule first, then + // more-specific overrides (last match wins, per gitattributes spec). + writeGitAttributes(t, dir, + "backend/internal/data/ent/** linguist-generated=true\n"+ + "backend/internal/data/ent/migrate/migrations/** linguist-generated=false\n"+ + "backend/internal/data/ent/schema/* linguist-generated=false\n", + ) + + isGenerated := tracegit.NewGoGitClient().GeneratedMatcher(dir) + + changes := &aicodingsession.CodeChanges{ + FilesCreated: 3, + FilesModified: 4, + FilesDeleted: 1, + LinesAdded: 100 + 50 + 200 + 10 + 7 + 25 + 40, + LinesRemoved: 5 + 2 + 30 + 1 + 0 + 3 + 8 + 4, + Files: []aicodingsession.FileChange{ + // Generated ent code — should be dropped. + {Path: "backend/internal/data/ent/client.go", Status: "modified", LinesAdded: 100, LinesRemoved: 5}, + {Path: "backend/internal/data/ent/account.go", Status: "modified", LinesAdded: 50, LinesRemoved: 2}, + // Generated files directly under migrate/ — must also be dropped (the bug we're locking in). + {Path: "backend/internal/data/ent/migrate/migrate.go", Status: "modified", LinesAdded: 200, LinesRemoved: 30}, + {Path: "backend/internal/data/ent/migrate/schema.go", Status: "deleted", LinesAdded: 0, LinesRemoved: 8}, + // Hand-managed migrations — must be kept. + {Path: "backend/internal/data/ent/migrate/migrations/20240602211927.sql", Status: "created", LinesAdded: 10, LinesRemoved: 1}, + {Path: "backend/internal/data/ent/migrate/migrations/atlas.sum", Status: "modified", LinesAdded: 7, LinesRemoved: 0}, + // Schema source — must be kept. + {Path: "backend/internal/data/ent/schema/ai_coding_session.go", Status: "created", LinesAdded: 25, LinesRemoved: 3}, + // Unrelated path — must be kept. + {Path: "backend/internal/service/evidence.go", Status: "created", LinesAdded: 40, LinesRemoved: 4}, + }, + } + + FilterGenerated(changes, isGenerated) + + gotPaths := make([]string, 0, len(changes.Files)) + for _, f := range changes.Files { + gotPaths = append(gotPaths, f.Path) + } + assert.ElementsMatch(t, []string{ + "backend/internal/data/ent/migrate/migrations/20240602211927.sql", + "backend/internal/data/ent/migrate/migrations/atlas.sum", + "backend/internal/data/ent/schema/ai_coding_session.go", + "backend/internal/service/evidence.go", + }, gotPaths) + + // Surviving line totals: 10+7+25+40 added, 1+0+3+4 removed. + assert.Equal(t, 82, changes.LinesAdded) + assert.Equal(t, 8, changes.LinesRemoved) + // Surviving status counts: 3 created (sql, schema, evidence), 1 modified (atlas.sum), 0 deleted. + assert.Equal(t, 3, changes.FilesCreated) + assert.Equal(t, 1, changes.FilesModified) + assert.Equal(t, 0, changes.FilesDeleted) + }) +} + +func writeGitAttributes(t *testing.T, dir, content string) { + t.Helper() + path := filepath.Join(dir, ".gitattributes") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600), "write .gitattributes") +} diff --git a/app/cli/internal/trace/attribution/shell.go b/app/cli/internal/trace/attribution/shell.go new file mode 100644 index 000000000..5309fc829 --- /dev/null +++ b/app/cli/internal/trace/attribution/shell.go @@ -0,0 +1,27 @@ +package attribution + +import "sort" + +// ChangedPaths diffs two working-tree signatures (repo-relative path → content +// hash) captured before and after a shell command, returning the paths the +// command created or modified (changed) and the paths it removed (deleted). +// Both slices are sorted for deterministic recording. Used to attribute +// files touched by agent-run shell commands, which never fire the file-writing +// tool hooks. +func ChangedPaths(before, after map[string]string) (changed, deleted []string) { + for path, hash := range after { + if prev, ok := before[path]; !ok || prev != hash { + changed = append(changed, path) + } + } + for path := range before { + if _, ok := after[path]; !ok { + deleted = append(deleted, path) + } + } + + sort.Strings(changed) + sort.Strings(deleted) + + return changed, deleted +} diff --git a/app/cli/internal/trace/attribution/shell_test.go b/app/cli/internal/trace/attribution/shell_test.go new file mode 100644 index 000000000..8c7d32b62 --- /dev/null +++ b/app/cli/internal/trace/attribution/shell_test.go @@ -0,0 +1,37 @@ +package attribution + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestChangedPaths(t *testing.T) { + t.Run("detects created, modified, and deleted paths", func(t *testing.T) { + before := map[string]string{"a": "1", "b": "2", "c": "3"} + after := map[string]string{"a": "1", "b": "9", "d": "4"} + + changed, deleted := ChangedPaths(before, after) + + assert.Equal(t, []string{"b", "d"}, changed) // b modified, d created + assert.Equal(t, []string{"c"}, deleted) // c removed + }) + + t.Run("empty when signatures are equal", func(t *testing.T) { + sig := map[string]string{"a": "1", "b": "2"} + + changed, deleted := ChangedPaths(sig, sig) + + assert.Empty(t, changed) + assert.Empty(t, deleted) + }) + + t.Run("all created from empty before", func(t *testing.T) { + after := map[string]string{"b": "2", "a": "1"} + + changed, deleted := ChangedPaths(nil, after) + + assert.Equal(t, []string{"a", "b"}, changed) // sorted + assert.Empty(t, deleted) + }) +} diff --git a/app/cli/internal/trace/claude/hooks.go b/app/cli/internal/trace/claude/hooks.go new file mode 100644 index 000000000..0dd9df39c --- /dev/null +++ b/app/cli/internal/trace/claude/hooks.go @@ -0,0 +1,310 @@ +package claude + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" +) + +const ( + hookMarker = "chainloop trace hook claude" + settingsFile = ".claude/settings.json" + + // Claude Code hook event names. + eventSessionStart = "SessionStart" + eventPreToolUse = "PreToolUse" + eventPostToolUse = "PostToolUse" + eventSessionEnd = "SessionEnd" +) + +// fileWritingTools is the single source of truth for Claude tool names that modify files. +var fileWritingTools = []string{"Edit", "Write", "MultiEdit"} + +// commandTools are Claude tool names that run shell commands. They can create +// or modify arbitrary files, so their changes are captured via a before/after +// working-tree snapshot rather than a per-file snapshot. +var commandTools = []string{"Bash"} + +// hookToolMatcher is the pipe-separated matcher for the pre/post-tool-use hooks. +// It covers both file-writing and command tools; the handler branches on which +// kind fired. +var hookToolMatcher = strings.Join(append(append([]string{}, fileWritingTools...), commandTools...), "|") + +type hookEvent struct { + event string + command string + matcher string // optional: tool name matcher +} + +var hookEvents = []hookEvent{ + {eventSessionStart, "chainloop trace hook claude session-start", ""}, + {eventPreToolUse, "chainloop trace hook claude pre-tool-use", hookToolMatcher}, + {eventPostToolUse, "chainloop trace hook claude post-tool-use", hookToolMatcher}, + {eventSessionEnd, "chainloop trace hook claude session-end", ""}, +} + +// SettingsFile returns the absolute path to .claude/settings.json for the given repo. +func (p *Provider) SettingsFile(repoRoot string) string { + return filepath.Join(repoRoot, settingsFile) +} + +// InstallHooks adds Chainloop hooks to .claude/settings.json. +func (p *Provider) InstallHooks(repoRoot string) error { + return p.installHookEvents(repoRoot, hookEvents) +} + +// InstallHooksForTraceRun installs every hook except SessionEnd; trace +// run drives end-of-session itself. +func (p *Provider) InstallHooksForTraceRun(repoRoot string) error { + events := make([]hookEvent, 0, len(hookEvents)) + for _, h := range hookEvents { + if h.event == eventSessionEnd { + continue + } + events = append(events, h) + } + + return p.installHookEvents(repoRoot, events) +} + +func (p *Provider) installHookEvents(repoRoot string, events []hookEvent) error { + settingsPath := filepath.Join(repoRoot, settingsFile) + + settings, err := readJSONFile(settingsPath) + if err != nil { + return err + } + + hooks, _ := settings["hooks"].(map[string]any) + if hooks == nil { + hooks = make(map[string]any) + } + + changed := false + for _, h := range events { + if addHookEntry(hooks, h.event, h.command, h.matcher) { + changed = true + } + } + + if !changed { + return nil + } + + settings["hooks"] = hooks + + return writeJSONFile(settingsPath, settings) +} + +// UninstallHooks removes Chainloop hooks from .claude/settings.json. +func (p *Provider) UninstallHooks(repoRoot string) error { + settingsPath := filepath.Join(repoRoot, settingsFile) + + settings, err := readJSONFile(settingsPath) + if err != nil { + return err + } + + hooks, _ := settings["hooks"].(map[string]any) + if hooks == nil { + return nil + } + + changed := false + for _, h := range hookEvents { + if removeHookEntry(hooks, h.event) { + changed = true + } + } + + if !changed { + return nil + } + + // Clean up empty hooks map + if len(hooks) == 0 { + delete(settings, "hooks") + } + + // Delete file if empty + if len(settings) == 0 { + return os.Remove(settingsPath) + } + + return writeJSONFile(settingsPath, settings) +} + +// maxHookPayloadBytes caps hook payload reads to defend against runaway or +// malformed payloads. +const maxHookPayloadBytes = 16 * 1024 * 1024 + +// ReadHookInput reads and parses the Claude Code hook JSON from the given reader. +func (p *Provider) ReadHookInput(r io.Reader) (*trace.HookInput, error) { + data, err := io.ReadAll(io.LimitReader(r, maxHookPayloadBytes)) + if err != nil { + return nil, err + } + + // Single unmarshal into a combined struct to avoid double-parsing. + // CursorVersion is read alongside the standard fields so we can detect + // Cursor-relayed events without a second pass. + var raw struct { + trace.HookInput + ToolInput struct { + FilePath string `json:"file_path"` + } `json:"tool_input"` + CursorVersion string `json:"cursor_version"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + + // When Cursor is the runtime relaying the event (both .claude/settings.json + // and .cursor/hooks.json are installed), every payload — including the + // preToolUse/postToolUse it forwards through Claude's hook config — carries + // cursor_version. Native Claude Code never injects that field. Returning + // an empty input makes the Claude handler bail out so the cursor provider's + // afterFileEdit owns the session and recording is not duplicated. + if raw.CursorVersion != "" { + return &trace.HookInput{}, nil + } + + input := raw.HookInput + if raw.ToolInput.FilePath != "" { + input.FilePath = raw.ToolInput.FilePath + } + + return &input, nil +} + +func addHookEntry(hooks map[string]any, event, command, matcher string) bool { + entries, _ := hooks[event].([]any) + + // Check if a chainloop hook already exists for this event + for i, entry := range entries { + if !entryContainsChainloopHook(entry) { + continue + } + // Chainloop entry exists — check if it needs updating (e.g., matcher changed) + m, _ := entry.(map[string]any) + existingMatcher, _ := m["matcher"].(string) + if existingMatcher == matcher { + return false // already up to date + } + // Replace the entry with updated version + entries[i] = buildHookEntry(command, matcher) + hooks[event] = entries + return true + } + + // No existing chainloop entry — add new one + hooks[event] = append(entries, buildHookEntry(command, matcher)) + return true +} + +func buildHookEntry(command, matcher string) map[string]any { + entry := map[string]any{ + "hooks": []any{ + map[string]any{ + "type": "command", + "command": command, + }, + }, + } + if matcher != "" { + entry["matcher"] = matcher + } + return entry +} + +func removeHookEntry(hooks map[string]any, event string) bool { + entries, _ := hooks[event].([]any) + if len(entries) == 0 { + return false + } + + var kept []any + for _, entry := range entries { + if !entryContainsChainloopHook(entry) { + kept = append(kept, entry) + } + } + + if len(kept) == len(entries) { + return false + } + + if len(kept) == 0 { + delete(hooks, event) + } else { + hooks[event] = kept + } + + return true +} + +func entryContainsChainloopHook(entry any) bool { + m, ok := entry.(map[string]any) + if !ok { + return false + } + + innerHooks, _ := m["hooks"].([]any) + for _, h := range innerHooks { + hm, ok := h.(map[string]any) + if !ok { + continue + } + + cmd, _ := hm["command"].(string) + if strings.Contains(cmd, hookMarker) { + return true + } + } + + return false +} + +func readJSONFile(path string) (map[string]any, error) { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return make(map[string]any), nil + } + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + + var result map[string]any + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + + // A file containing `null` unmarshals successfully into a nil map, which + // panics on assignment. + if result == nil { + result = make(map[string]any) + } + + return result, nil +} + +func writeJSONFile(path string, data map[string]any) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("create directory: %w", err) + } + + content, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("marshal JSON: %w", err) + } + + content = append(content, '\n') + + return os.WriteFile(path, content, 0600) +} diff --git a/app/cli/internal/trace/claude/hooks_test.go b/app/cli/internal/trace/claude/hooks_test.go new file mode 100644 index 000000000..dad25138e --- /dev/null +++ b/app/cli/internal/trace/claude/hooks_test.go @@ -0,0 +1,342 @@ +package claude + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInstallHooks(t *testing.T) { + provider := New() + + t.Run("creates settings file with hooks", func(t *testing.T) { + repoRoot := t.TempDir() + + require.NoError(t, provider.InstallHooks(repoRoot)) + + settings := readSettings(t, repoRoot) + hooks := settings["hooks"].(map[string]any) + + assert.Contains(t, hooks, "SessionStart") + assert.Contains(t, hooks, "PreToolUse") + assert.Contains(t, hooks, "PostToolUse") + + // Verify command contents + assertHookCommand(t, hooks, "SessionStart", "chainloop trace hook claude session-start") + assertHookCommand(t, hooks, "PreToolUse", "chainloop trace hook claude pre-tool-use") + assertHookCommand(t, hooks, "PostToolUse", "chainloop trace hook claude post-tool-use") + }) + + t.Run("installs PreToolUse and PostToolUse with matchers", func(t *testing.T) { + repoRoot := t.TempDir() + require.NoError(t, provider.InstallHooks(repoRoot)) + + settings := readSettings(t, repoRoot) + hooks := settings["hooks"].(map[string]any) + + // PreToolUse should have matcher + preEntries := hooks["PreToolUse"].([]any) + preEntry := preEntries[0].(map[string]any) + assert.Equal(t, "Edit|Write|MultiEdit|Bash", preEntry["matcher"]) + + // PostToolUse should have matcher + postEntries := hooks["PostToolUse"].([]any) + postEntry := postEntries[0].(map[string]any) + assert.Equal(t, "Edit|Write|MultiEdit|Bash", postEntry["matcher"]) + + // SessionStart should NOT have matcher + startEntries := hooks["SessionStart"].([]any) + startEntry := startEntries[0].(map[string]any) + _, hasMatcher := startEntry["matcher"] + assert.False(t, hasMatcher) + + // SessionEnd should NOT have matcher + endEntries := hooks["SessionEnd"].([]any) + endEntry := endEntries[0].(map[string]any) + _, hasMatcher = endEntry["matcher"] + assert.False(t, hasMatcher) + }) + + t.Run("upgrades existing hooks to add matcher", func(t *testing.T) { + repoRoot := t.TempDir() + settingsPath := filepath.Join(repoRoot, ".claude", "settings.json") + require.NoError(t, os.MkdirAll(filepath.Dir(settingsPath), 0755)) + + // Simulate old-format PreToolUse without matcher + existing := map[string]any{ + "hooks": map[string]any{ + "PreToolUse": []any{ + map[string]any{ + "hooks": []any{ + map[string]any{"type": "command", "command": "chainloop trace hook claude pre-tool-use"}, + }, + }, + }, + }, + } + data, _ := json.MarshalIndent(existing, "", " ") + require.NoError(t, os.WriteFile(settingsPath, data, 0600)) + + // Re-install should upgrade + require.NoError(t, provider.InstallHooks(repoRoot)) + + settings := readSettings(t, repoRoot) + hooks := settings["hooks"].(map[string]any) + + preEntries := hooks["PreToolUse"].([]any) + require.Len(t, preEntries, 1) + preEntry := preEntries[0].(map[string]any) + assert.Equal(t, "Edit|Write|MultiEdit|Bash", preEntry["matcher"]) + }) + + t.Run("idempotent — running twice produces same result", func(t *testing.T) { + repoRoot := t.TempDir() + + require.NoError(t, provider.InstallHooks(repoRoot)) + require.NoError(t, provider.InstallHooks(repoRoot)) + + settings := readSettings(t, repoRoot) + hooks := settings["hooks"].(map[string]any) + + // Should have exactly one entry per event + sessionStart := hooks["SessionStart"].([]any) + assert.Len(t, sessionStart, 1) + + preToolUse := hooks["PreToolUse"].([]any) + assert.Len(t, preToolUse, 1) + + postToolUse := hooks["PostToolUse"].([]any) + assert.Len(t, postToolUse, 1) + }) + + t.Run("preserves existing settings", func(t *testing.T) { + repoRoot := t.TempDir() + settingsPath := filepath.Join(repoRoot, ".claude", "settings.json") + require.NoError(t, os.MkdirAll(filepath.Dir(settingsPath), 0755)) + + existing := map[string]any{ + "customSetting": "value", + "hooks": map[string]any{ + "PostToolUse": []any{ + map[string]any{ + "matcher": "Edit|Write", + "hooks": []any{ + map[string]any{"type": "command", "command": "format.sh"}, + }, + }, + }, + }, + } + data, _ := json.MarshalIndent(existing, "", " ") + require.NoError(t, os.WriteFile(settingsPath, data, 0600)) + + require.NoError(t, provider.InstallHooks(repoRoot)) + + settings := readSettings(t, repoRoot) + assert.Equal(t, "value", settings["customSetting"]) + + hooks := settings["hooks"].(map[string]any) + assert.Contains(t, hooks, "PostToolUse") + assert.Contains(t, hooks, "SessionStart") + assert.Contains(t, hooks, "PreToolUse") + }) +} + +func TestUninstallHooks(t *testing.T) { + provider := New() + + t.Run("removes chainloop entries", func(t *testing.T) { + repoRoot := t.TempDir() + + require.NoError(t, provider.InstallHooks(repoRoot)) + require.NoError(t, provider.UninstallHooks(repoRoot)) + + settingsPath := filepath.Join(repoRoot, ".claude", "settings.json") + _, err := os.Stat(settingsPath) + assert.True(t, os.IsNotExist(err), "empty settings file should be deleted") + }) + + t.Run("preserves other hook entries", func(t *testing.T) { + repoRoot := t.TempDir() + settingsPath := filepath.Join(repoRoot, ".claude", "settings.json") + require.NoError(t, os.MkdirAll(filepath.Dir(settingsPath), 0755)) + + existing := map[string]any{ + "hooks": map[string]any{ + "PostToolUse": []any{ + map[string]any{ + "hooks": []any{ + map[string]any{"type": "command", "command": "format.sh"}, + }, + }, + }, + }, + } + data, _ := json.MarshalIndent(existing, "", " ") + require.NoError(t, os.WriteFile(settingsPath, data, 0600)) + + require.NoError(t, provider.InstallHooks(repoRoot)) + require.NoError(t, provider.UninstallHooks(repoRoot)) + + settings := readSettings(t, repoRoot) + hooks := settings["hooks"].(map[string]any) + assert.Contains(t, hooks, "PostToolUse") + assert.NotContains(t, hooks, "SessionStart") + assert.NotContains(t, hooks, "PreToolUse") + }) + + t.Run("noop when file does not exist", func(t *testing.T) { + repoRoot := t.TempDir() + assert.NoError(t, provider.UninstallHooks(repoRoot)) + }) +} + +func TestReadHookInput(t *testing.T) { + provider := New() + + t.Run("parses valid input", func(t *testing.T) { + r := bytes.NewBufferString(`{"session_id":"abc-123","hook_event_name":"SessionStart","tool_name":"Edit","cwd":"/some/path"}`) + input, err := provider.ReadHookInput(r) + require.NoError(t, err) + assert.Equal(t, "abc-123", input.SessionID) + assert.Equal(t, "SessionStart", input.HookEventName) + assert.Equal(t, "Edit", input.ToolName) + }) + + t.Run("extracts tool metadata", func(t *testing.T) { + r := bytes.NewBufferString(`{ + "session_id": "abc-123", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": {"file_path": "/some/file.go", "old_string": "foo"} + }`) + input, err := provider.ReadHookInput(r) + require.NoError(t, err) + assert.Equal(t, "abc-123", input.SessionID) + assert.Equal(t, "PreToolUse", input.HookEventName) + assert.Equal(t, "Edit", input.ToolName) + assert.Equal(t, "/some/file.go", input.FilePath) + }) + + t.Run("handles missing tool_input gracefully", func(t *testing.T) { + r := bytes.NewBufferString(`{"session_id":"abc-123","hook_event_name":"SessionStart"}`) + input, err := provider.ReadHookInput(r) + require.NoError(t, err) + assert.Equal(t, "abc-123", input.SessionID) + assert.Equal(t, "SessionStart", input.HookEventName) + assert.Empty(t, input.FilePath) + }) + + t.Run("returns empty for empty session ID", func(t *testing.T) { + r := bytes.NewBufferString(`{"session_id":""}`) + input, err := provider.ReadHookInput(r) + require.NoError(t, err) + assert.Empty(t, input.SessionID) + }) + + t.Run("returns error for invalid JSON", func(t *testing.T) { + r := bytes.NewBufferString(`not json`) + _, err := provider.ReadHookInput(r) + assert.Error(t, err) + }) + + t.Run("bails on Cursor-relayed payloads", func(t *testing.T) { + // When .claude/settings.json and .cursor/hooks.json are both + // installed and the user is running inside Cursor, Cursor's + // runtime fires the Claude-flavored hooks too — every payload + // it relays carries cursor_version. We must skip those so the + // cursor provider's afterFileEdit owns the session and we don't + // double-record line ranges. + r := bytes.NewBufferString(`{ + "session_id": "e1cd0c12-07cb-495b-be1f-b6254bff24e8", + "hook_event_name": "preToolUse", + "tool_name": "Write", + "tool_input": {"file_path": "/repo/README.md"}, + "cursor_version": "3.2.16" + }`) + input, err := provider.ReadHookInput(r) + require.NoError(t, err) + assert.Empty(t, input.SessionID, "Cursor-relayed events must yield an empty input") + assert.Empty(t, input.FilePath) + }) +} + +func TestIsFileWritingTool(t *testing.T) { + provider := New() + + assert.True(t, provider.IsFileWritingTool("Edit")) + assert.True(t, provider.IsFileWritingTool("Write")) + assert.True(t, provider.IsFileWritingTool("MultiEdit")) + assert.False(t, provider.IsFileWritingTool("Read")) + assert.False(t, provider.IsFileWritingTool("Bash")) + assert.False(t, provider.IsFileWritingTool("")) +} + +func TestIsCommandTool(t *testing.T) { + provider := New() + + assert.True(t, provider.IsCommandTool("Bash")) + assert.False(t, provider.IsCommandTool("Edit")) + assert.False(t, provider.IsCommandTool("Write")) + assert.False(t, provider.IsCommandTool("")) +} + +func TestInstallHooksForTraceRun(t *testing.T) { + provider := New() + + t.Run("installs everything except SessionEnd", func(t *testing.T) { + repoRoot := t.TempDir() + + require.NoError(t, provider.InstallHooksForTraceRun(repoRoot)) + + settings := readSettings(t, repoRoot) + hooks := settings["hooks"].(map[string]any) + + assert.Contains(t, hooks, "SessionStart") + assert.Contains(t, hooks, "PreToolUse") + assert.Contains(t, hooks, "PostToolUse") + assert.NotContains(t, hooks, "SessionEnd", "trace run must not install SessionEnd; trace run drives end-of-session itself") + }) +} + +func TestInstallHooksOnNullSettings(t *testing.T) { + repoRoot := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(repoRoot, ".claude"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(repoRoot, settingsFile), []byte("null\n"), 0600)) + + require.NoError(t, New().InstallHooks(repoRoot)) + assert.Contains(t, readSettings(t, repoRoot), "hooks") +} + +func readSettings(t *testing.T, repoRoot string) map[string]any { + t.Helper() + + data, err := os.ReadFile(filepath.Join(repoRoot, ".claude", "settings.json")) + require.NoError(t, err) + + var settings map[string]any + require.NoError(t, json.Unmarshal(data, &settings)) + + return settings +} + +func assertHookCommand(t *testing.T, hooks map[string]any, event, expectedCmd string) { + t.Helper() + + entries, ok := hooks[event].([]any) + require.True(t, ok, "event %s should be an array", event) + require.Len(t, entries, 1) + + entry := entries[0].(map[string]any) + innerHooks := entry["hooks"].([]any) + require.Len(t, innerHooks, 1) + + hook := innerHooks[0].(map[string]any) + assert.Equal(t, "command", hook["type"]) + assert.Equal(t, expectedCmd, hook["command"]) +} diff --git a/app/cli/internal/trace/claude/parse.go b/app/cli/internal/trace/claude/parse.go new file mode 100644 index 000000000..fb7df13ad --- /dev/null +++ b/app/cli/internal/trace/claude/parse.go @@ -0,0 +1,407 @@ +package claude + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "math" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" +) + +// Pricing rates live in pricing.json (loaded by pricing.go via go:embed). + +const ( + maxScannerBuffer = 10 * 1024 * 1024 // 10 MB for large assistant messages + maxWarnings = 50 // cap to prevent unbounded growth from corrupt files +) + +// Transcript record types, as written by Claude Code into the session JSONL. +const ( + recordTypeUser = "user" + recordTypeAssistant = "assistant" + recordTypeProgress = "progress" +) + +// unknownValue stands in for a model or tool name the transcript didn't carry. +const unknownValue = "unknown" + +func findJSONLPath(sessionDir, sessionID string) (string, error) { + if sessionID != "" { + return state.RawSessionPath(sessionDir, sessionID), nil + } + + entries, err := os.ReadDir(sessionDir) + if err != nil { + return "", fmt.Errorf("read session directory: %w", err) + } + + var newest string + var newestTime time.Time + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") { + continue + } + info, err := e.Info() + if err != nil { + continue + } + if newest == "" || info.ModTime().After(newestTime) { + newest = filepath.Join(sessionDir, e.Name()) + newestTime = info.ModTime() + } + } + + if newest == "" { + return "", fmt.Errorf("no .jsonl files found in %s", sessionDir) + } + + return newest, nil +} + +// scanJSONL opens a JSONL file and calls fn for each successfully parsed record. +// Malformed lines are appended to warnings rather than causing an error. +// If rawLines is non-nil, each valid JSON line is appended as a json.RawMessage. +func scanJSONL(path string, warnings *[]string, fn func(*jsonlRecord), rawLines *[]json.RawMessage) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + + scanner := bufio.NewScanner(f) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, maxScannerBuffer) + + lineNo := 0 + for scanner.Scan() { + lineNo++ + raw := bytes.TrimSpace(scanner.Bytes()) + if len(raw) == 0 { + continue + } + + var record jsonlRecord + if err := json.Unmarshal(raw, &record); err != nil { + addWarning(warnings, fmt.Sprintf("Malformed JSON at %s:%d", filepath.Base(path), lineNo)) + continue + } + + if rawLines != nil { + *rawLines = append(*rawLines, json.RawMessage(append([]byte(nil), raw...))) + } + + fn(&record) + } + + return scanner.Err() +} + +func parseJSONL(path string) (*sessionData, []json.RawMessage, error) { + data := &sessionData{ + tokenUsageByModel: make(map[string]*tokenUsage), + toolCounts: make(map[string]int), + messageCounts: make(map[string]int), + modelsSeen: make(map[string]int), + } + + var rawLines []json.RawMessage + + err := scanJSONL(path, &data.warnings, func(record *jsonlRecord) { + msgType := record.Type + switch msgType { + case recordTypeUser, recordTypeAssistant, recordTypeProgress: + data.messageCounts[msgType]++ + } + + if record.Timestamp != "" { + if data.tsMin == "" || record.Timestamp < data.tsMin { + data.tsMin = record.Timestamp + } + if data.tsMax == "" || record.Timestamp > data.tsMax { + data.tsMax = record.Timestamp + } + } + + if data.sessionID == "" && record.SessionID != "" { + data.sessionID = record.SessionID + } + if data.slug == "" && record.Slug != "" { + data.slug = record.Slug + } + if data.version == "" && record.Version != "" { + data.version = record.Version + } + if data.cwd == "" && record.Cwd != "" { + data.cwd = record.Cwd + } + if data.gitBranch == "" && record.GitBranch != "" { + data.gitBranch = record.GitBranch + } + + if msgType == recordTypeAssistant && record.Message != nil { + model := record.Message.Model + if model == "" { + model = unknownValue + } + data.modelsSeen[model]++ + + if record.Message.Usage != nil { + getOrCreateUsage(data.tokenUsageByModel, model).add(record.Message.Usage) + } + + // Content can be a string or an array of blocks; only parse arrays + var blocks []jsonlContentBlock + if json.Unmarshal(record.Message.Content, &blocks) == nil { + for _, block := range blocks { + if block.Type == "tool_use" { + name := block.Name + if name == "" { + name = unknownValue + } + data.toolCounts[name]++ + } + } + } + } + }, &rawLines) + if err != nil { + return nil, nil, fmt.Errorf("reading session file: %w", err) + } + + return data, rawLines, nil +} + +func processSubagents(sessionDir, sessionID string, warnings *[]string) ([]aicodingsession.Subagent, map[string]*tokenUsage, map[string][]json.RawMessage, error) { + subagentsDir := state.RawSubagentDir(sessionDir, sessionID) + + metaFiles, err := filepath.Glob(filepath.Join(subagentsDir, "agent-*.meta.json")) + if err != nil { + return nil, nil, nil, fmt.Errorf("glob subagent meta files: %w", err) + } + if len(metaFiles) == 0 { + return nil, nil, nil, nil + } + + var subagents []aicodingsession.Subagent + totalSubUsage := make(map[string]*tokenUsage) + rawSubagents := make(map[string][]json.RawMessage) + + for _, metaPath := range metaFiles { + base := filepath.Base(metaPath) + agentID := strings.TrimPrefix(base, "agent-") + agentID = strings.TrimSuffix(agentID, ".meta.json") + + metaData, err := os.ReadFile(metaPath) + if err != nil { + continue + } + var meta subagentMeta + if err := json.Unmarshal(metaData, &meta); err != nil { + continue + } + + var rawLines []json.RawMessage + subInput, subOutput := parseSubagentUsage( + filepath.Join(subagentsDir, fmt.Sprintf("agent-%s.jsonl", agentID)), + totalSubUsage, + warnings, + &rawLines, + ) + + if len(rawLines) > 0 { + rawSubagents[agentID] = rawLines + } + + subagents = append(subagents, aicodingsession.Subagent{ + ID: agentID, + Type: meta.AgentType, + Description: meta.Description, + Tokens: aicodingsession.SubagentTokens{Input: subInput, Output: subOutput}, + }) + } + + return subagents, totalSubUsage, rawSubagents, nil +} + +func parseSubagentUsage(jsonlPath string, totalUsage map[string]*tokenUsage, warnings *[]string, rawLines *[]json.RawMessage) (subInput, subOutput int) { + if err := scanJSONL(jsonlPath, warnings, func(record *jsonlRecord) { + if record.Type == recordTypeAssistant && record.Message != nil && record.Message.Usage != nil { + model := record.Message.Model + if model == "" { + model = unknownValue + } + u := record.Message.Usage + subInput += u.InputTokens + subOutput += u.OutputTokens + getOrCreateUsage(totalUsage, model).add(u) + } + }, rawLines); err != nil { + addWarning(warnings, fmt.Sprintf("Failed to read subagent %s: %v", filepath.Base(jsonlPath), err)) + } + + return +} + +func mergeUsage(main, sub map[string]*tokenUsage) map[string]*tokenUsage { + if len(sub) == 0 { + return main + } + + merged := make(map[string]*tokenUsage) + for _, source := range []map[string]*tokenUsage{main, sub} { + for model, u := range source { + bucket := getOrCreateUsage(merged, model) + bucket.InputTokens += u.InputTokens + bucket.OutputTokens += u.OutputTokens + bucket.CacheCreationInputTokens += u.CacheCreationInputTokens + bucket.CacheCreation5mTokens += u.CacheCreation5mTokens + bucket.CacheCreation1hTokens += u.CacheCreation1hTokens + bucket.CacheReadInputTokens += u.CacheReadInputTokens + } + } + + return merged +} + +func computeCost(usage map[string]*tokenUsage, warnings *[]string) float64 { + totalCost := 0.0 + for model, u := range usage { + p, ok := pricing[model] + if !ok { + p = defaultPricing + addWarning(warnings, + fmt.Sprintf("Unknown model '%s', using default (sonnet) pricing", model)) + } + + cost := float64(u.InputTokens)*p.Input/1_000_000 + + float64(u.OutputTokens)*p.Output/1_000_000 + + float64(u.CacheCreation5mTokens)*p.CacheWrite/1_000_000 + + float64(u.CacheCreation1hTokens)*p.CacheWrite1h/1_000_000 + + float64(u.CacheReadInputTokens)*p.CacheRead/1_000_000 + totalCost += cost + } + + return math.Round(totalCost*10000) / 10000 +} + +func buildOutput(data *sessionData, subagents []aicodingsession.Subagent, merged map[string]*tokenUsage, warnings []string, cost float64, rawSession map[string][]json.RawMessage) *aicodingsession.Evidence { + var durationSeconds int + if data.tsMin != "" && data.tsMax != "" { + t0, err0 := time.Parse(time.RFC3339, data.tsMin) + t1, err1 := time.Parse(time.RFC3339, data.tsMax) + if err0 == nil && err1 == nil { + durationSeconds = int(t1.Sub(t0).Seconds()) + } + } + + var totalInput, totalOutput, totalCacheRead, totalCacheCreation int + for _, u := range merged { + totalInput += u.InputTokens + totalOutput += u.OutputTokens + totalCacheRead += u.CacheReadInputTokens + totalCacheCreation += u.CacheCreationInputTokens + } + + primaryModel := unknownValue + maxCount := 0 + for model, count := range data.modelsSeen { + if count > maxCount { + primaryModel = model + maxCount = count + } + } + + // Derive from merged usage to include subagent-only models + modelsUsed := make([]string, 0, len(merged)) + for model := range merged { + modelsUsed = append(modelsUsed, model) + } + sort.Strings(modelsUsed) + + toolSummary := make([]aicodingsession.ToolSummary, 0, len(data.toolCounts)) + totalInvocations := 0 + for name, count := range data.toolCounts { + toolSummary = append(toolSummary, aicodingsession.ToolSummary{ToolName: name, InvocationCount: count}) + totalInvocations += count + } + sort.Slice(toolSummary, func(i, j int) bool { + return toolSummary[i].InvocationCount > toolSummary[j].InvocationCount + }) + + return &aicodingsession.Evidence{ + Schema: aicodingsession.EvidenceSchemaURL, + ID: aicodingsession.EvidenceID, + Data: aicodingsession.Data{ + SchemaVersion: "v1", + Agent: aicodingsession.Agent{ + Name: "claude-code", + Version: data.version, + }, + Session: aicodingsession.Session{ + ID: data.sessionID, + Slug: data.slug, + StartedAt: data.tsMin, + EndedAt: data.tsMax, + DurationSeconds: durationSeconds, + }, + GitContext: &aicodingsession.GitContext{ + Branch: data.gitBranch, + WorkDir: data.cwd, + }, + Model: &aicodingsession.Model{ + Primary: primaryModel, + Provider: "anthropic", + ModelsUsed: modelsUsed, + }, + Usage: &aicodingsession.Usage{ + InputTokens: totalInput, + OutputTokens: totalOutput, + TotalTokens: totalInput + totalOutput, + CacheReadInputTokens: totalCacheRead, + CacheCreationInputTokens: totalCacheCreation, + EstimatedCostUSD: cost, + }, + ToolsUsed: &aicodingsession.ToolsUsed{ + Summary: toolSummary, + TotalInvocations: totalInvocations, + }, + Conversation: &aicodingsession.Conversation{ + TotalMessages: data.messageCounts[recordTypeUser] + data.messageCounts[recordTypeAssistant] + data.messageCounts[recordTypeProgress], + UserMessages: data.messageCounts[recordTypeUser], + AssistantMessages: data.messageCounts[recordTypeAssistant], + }, + Subagents: subagents, + RawSession: rawSession, + Warnings: warnings, + }, + } +} + +func addWarning(warnings *[]string, msg string) { + if warnings == nil { + return + } + if len(*warnings) < maxWarnings { + *warnings = append(*warnings, msg) + } else if len(*warnings) == maxWarnings { + *warnings = append(*warnings, fmt.Sprintf("... further warnings suppressed (limit %d)", maxWarnings)) + } +} + +func getOrCreateUsage(m map[string]*tokenUsage, model string) *tokenUsage { + if u, ok := m[model]; ok { + return u + } + u := &tokenUsage{} + m[model] = u + + return u +} diff --git a/app/cli/internal/trace/claude/parse_test.go b/app/cli/internal/trace/claude/parse_test.go new file mode 100644 index 000000000..13b3e1978 --- /dev/null +++ b/app/cli/internal/trace/claude/parse_test.go @@ -0,0 +1,289 @@ +package claude + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testdataDir = "testdata" + +func TestFindJSONLPath(t *testing.T) { + t.Run("explicit session ID", func(t *testing.T) { + path, err := findJSONLPath(testdataDir, "test-session") + require.NoError(t, err) + assert.Equal(t, filepath.Join(testdataDir, "test-session.jsonl"), path) + }) + + t.Run("auto-detect picks most recent file", func(t *testing.T) { + // Explicitly set older-session.jsonl to a past mtime so the test is + // deterministic regardless of clone state (git doesn't preserve mtimes). + oldTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + require.NoError(t, os.Chtimes(filepath.Join(testdataDir, "older-session.jsonl"), oldTime, oldTime)) + + path, err := findJSONLPath(testdataDir, "") + require.NoError(t, err) + assert.Contains(t, path, "test-session.jsonl") + }) + + t.Run("explicit session ID returns path without checking existence", func(t *testing.T) { + path, err := findJSONLPath(testdataDir, "nonexistent") + require.NoError(t, err) + assert.Contains(t, path, "nonexistent.jsonl") + }) + + t.Run("missing directory", func(t *testing.T) { + _, err := findJSONLPath("/nonexistent/path", "") + assert.Error(t, err) + }) +} + +func TestParseJSONL(t *testing.T) { + data, rawLines, err := parseJSONL(filepath.Join(testdataDir, "test-session.jsonl")) + require.NoError(t, err) + + // 6 valid JSON lines out of 7 (1 malformed) + assert.Len(t, rawLines, 6) + + assert.Equal(t, "test-session", data.sessionID) + assert.Equal(t, "test-slug", data.slug) + assert.Equal(t, "2.1.81", data.version) + assert.Equal(t, "/tmp/test", data.cwd) + assert.Equal(t, "main", data.gitBranch) + + // Timestamps (min/max) + assert.Equal(t, "2026-03-24T00:00:00Z", data.tsMin) + assert.Equal(t, "2026-03-24T00:05:00Z", data.tsMax) + + // Message counts + assert.Equal(t, 2, data.messageCounts["user"]) + assert.Equal(t, 3, data.messageCounts["assistant"]) + assert.Equal(t, 1, data.messageCounts["progress"]) + + // Token usage + opusUsage, ok := data.tokenUsageByModel["claude-opus-4-6"] + require.True(t, ok) + assert.Equal(t, 300, opusUsage.InputTokens) // 100+150+50 + assert.Equal(t, 600, opusUsage.OutputTokens) // 200+300+100 + assert.Equal(t, 130, opusUsage.CacheCreationInputTokens) // 50+0+80 + // Line 2 has only the flat field (back-compat: all 50 tokens attributed to 5m). + // Line 6 has the nested cache_creation breakdown (30 -> 5m, 50 -> 1h). + assert.Equal(t, 80, opusUsage.CacheCreation5mTokens) // 50 (back-compat) + 0 + 30 (nested) + assert.Equal(t, 50, opusUsage.CacheCreation1hTokens) // 0 + 0 + 50 (nested) + assert.Equal(t, 3500, opusUsage.CacheReadInputTokens) // 1000+2000+500 + + // Tool counts + assert.Equal(t, 2, data.toolCounts["Bash"]) + assert.Equal(t, 1, data.toolCounts["Read"]) + + // Model seen counts + assert.Equal(t, 3, data.modelsSeen["claude-opus-4-6"]) + + // Malformed line warning + assert.Len(t, data.warnings, 1) + assert.Contains(t, data.warnings[0], "Malformed JSON") +} + +func TestProcessSubagents(t *testing.T) { + var warnings []string + subagents, subUsage, rawSubagents, err := processSubagents(testdataDir, "test-session", &warnings) + require.NoError(t, err) + + require.Len(t, subagents, 1) + assert.Equal(t, "abc123", subagents[0].ID) + assert.Equal(t, "Explore", subagents[0].Type) + assert.Equal(t, "Explore codebase structure", subagents[0].Description) + assert.Equal(t, 30, subagents[0].Tokens.Input) + assert.Equal(t, 50, subagents[0].Tokens.Output) + + haikuUsage, ok := subUsage["claude-haiku-4-5-20251001"] + require.True(t, ok) + assert.Equal(t, 30, haikuUsage.InputTokens) + assert.Equal(t, 50, haikuUsage.OutputTokens) + + // Raw subagent lines captured + require.Contains(t, rawSubagents, "abc123") + assert.Len(t, rawSubagents["abc123"], 2) +} + +func TestProcessSubagentsNoDir(t *testing.T) { + var warnings []string + subagents, subUsage, rawSubagents, err := processSubagents(testdataDir, "nonexistent", &warnings) + require.NoError(t, err) + assert.Nil(t, subagents) + assert.Nil(t, subUsage) + assert.Nil(t, rawSubagents) +} + +func TestMergeUsage(t *testing.T) { + main := map[string]*tokenUsage{ + "opus": {InputTokens: 100, OutputTokens: 200}, + } + sub := map[string]*tokenUsage{ + "opus": {InputTokens: 50, OutputTokens: 100}, + "haiku": {InputTokens: 30, OutputTokens: 50}, + } + + merged := mergeUsage(main, sub) + assert.Equal(t, 150, merged["opus"].InputTokens) + assert.Equal(t, 300, merged["opus"].OutputTokens) + assert.Equal(t, 30, merged["haiku"].InputTokens) + assert.Equal(t, 50, merged["haiku"].OutputTokens) +} + +func TestMergeUsageEmptySub(t *testing.T) { + main := map[string]*tokenUsage{ + "opus": {InputTokens: 100}, + } + merged := mergeUsage(main, nil) + assert.Same(t, main["opus"], merged["opus"]) +} + +func TestComputeCost(t *testing.T) { + usage := map[string]*tokenUsage{ + "claude-opus-4-6": { + InputTokens: 1_000_000, + OutputTokens: 1_000_000, + CacheCreationInputTokens: 1_000_000, + CacheCreation5mTokens: 1_000_000, + CacheReadInputTokens: 1_000_000, + }, + } + var warnings []string + cost := computeCost(usage, &warnings) + + // $5 input + $25 output + $6.25 cache write (5m) + $0.50 cache read = $36.75 + assert.InDelta(t, 36.75, cost, 0.001) + assert.Empty(t, warnings) +} + +func TestComputeCost_SplitCacheTiers(t *testing.T) { + usage := map[string]*tokenUsage{ + "claude-opus-4-6": { + CacheCreationInputTokens: 1_000_000, + CacheCreation5mTokens: 400_000, + CacheCreation1hTokens: 600_000, + }, + } + var warnings []string + cost := computeCost(usage, &warnings) + + // 0.4M * $6.25 = $2.50 (5m tier) + // 0.6M * $10.00 = $6.00 (1h tier) + // total = $8.50 + assert.InDelta(t, 8.50, cost, 0.001) + assert.Empty(t, warnings) +} + +func TestComputeCostUnknownModel(t *testing.T) { + usage := map[string]*tokenUsage{ + "claude-unknown-99": {InputTokens: 1_000_000}, + } + var warnings []string + cost := computeCost(usage, &warnings) + + // Falls back to sonnet pricing: $3.00 per 1M input + assert.InDelta(t, 3.0, cost, 0.001) + assert.Len(t, warnings, 1) + assert.Contains(t, warnings[0], "Unknown model") +} + +func TestParseSessionEndToEnd(t *testing.T) { + provider := New() + result, err := provider.ParseSession(context.Background(), &trace.ParseOpts{ + SessionDir: testdataDir, + SessionID: "test-session", + }) + require.NoError(t, err) + + assert.Equal(t, "https://schemas.chainloop.dev/aicodingsession/0.1/ai-coding-session.schema.json", result.Schema) + assert.Equal(t, "CHAINLOOP_AI_CODING_SESSION", result.ID) + assert.Equal(t, "v1", result.Data.SchemaVersion) + assert.Equal(t, "claude-code", result.Data.Agent.Name) + assert.Equal(t, "test-session", result.Data.Session.ID) + assert.Equal(t, "test-slug", result.Data.Session.Slug) + + assert.Equal(t, 300, result.Data.Session.DurationSeconds) // 5 minutes + + // Git context + assert.Equal(t, "main", result.Data.GitContext.Branch) + assert.Equal(t, "/tmp/test", result.Data.GitContext.WorkDir) + + assert.Equal(t, "claude-opus-4-6", result.Data.Model.Primary) + assert.Contains(t, result.Data.Model.ModelsUsed, "claude-opus-4-6") + assert.Contains(t, result.Data.Model.ModelsUsed, "claude-haiku-4-5-20251001") + + // Subagents included + assert.Len(t, result.Data.Subagents, 1) + assert.Equal(t, "Explore", result.Data.Subagents[0].Type) + + // Raw session data (flat map: "main" + subagent IDs) + assert.Len(t, result.Data.RawSession["main"], 6) // 6 valid lines, 1 malformed skipped + require.Contains(t, result.Data.RawSession, "abc123") + assert.Len(t, result.Data.RawSession["abc123"], 2) + + // Warnings from malformed line + assert.Len(t, result.Data.Warnings, 1) + + // Write to temp file and verify + tmpDir := t.TempDir() + outPath := filepath.Join(tmpDir, "output.json") + f, err := os.Create(outPath) + require.NoError(t, err) + + enc := json.NewEncoder(f) + require.NoError(t, enc.Encode(result)) + require.NoError(t, f.Close()) + + data, err := os.ReadFile(outPath) + require.NoError(t, err) + assert.Contains(t, string(data), "CHAINLOOP_AI_CODING_SESSION") +} + +// TestParseSession_CraftingCompatibility verifies that the generated evidence +// can be deserialized by the upstream aicodingsession types and passes schema validation. +// This mirrors what ChainloopAICodingSessionCrafter.Craft() does when processing the file. +func TestParseSession_CraftingCompatibility(t *testing.T) { + provider := New() + result, err := provider.ParseSession(context.Background(), &trace.ParseOpts{ + SessionDir: testdataDir, + SessionID: "test-session", + }) + require.NoError(t, err) + + // Serialize as the push flow does + resultBytes, err := json.Marshal(result) + require.NoError(t, err) + + // Step 1: Unmarshal into upstream envelope (same as Craft does) + var envelope struct { + Data json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(resultBytes, &envelope)) + require.NotEmpty(t, envelope.Data) + + // Step 2: Verify upstream struct compatibility — this catches type mismatches + // like the raw_session format issue (object vs array) that caused crafting failures. + var upstreamData aicodingsession.Data + require.NoError(t, json.Unmarshal(envelope.Data, &upstreamData), "evidence must unmarshal into upstream aicodingsession.Data") + assert.Equal(t, "claude-code", upstreamData.Agent.Name) + assert.Equal(t, "test-session", upstreamData.Session.ID) + assert.Equal(t, 300, upstreamData.Session.DurationSeconds) + + // Verify raw_session survives the round-trip as a flat map + require.Contains(t, upstreamData.RawSession, "main") + assert.NotEmpty(t, upstreamData.RawSession["main"]) + + // Verify the full evidence round-trips through upstream Evidence type + var upstreamEvidence aicodingsession.Evidence + require.NoError(t, json.Unmarshal(resultBytes, &upstreamEvidence), "evidence must unmarshal into upstream aicodingsession.Evidence") + assert.Equal(t, aicodingsession.EvidenceID, upstreamEvidence.ID) +} diff --git a/app/cli/internal/trace/claude/pid_unix.go b/app/cli/internal/trace/claude/pid_unix.go new file mode 100644 index 000000000..4739ebf03 --- /dev/null +++ b/app/cli/internal/trace/claude/pid_unix.go @@ -0,0 +1,14 @@ +//go:build !windows + +package claude + +import "syscall" + +// isPIDAlive reports whether a process with the given PID exists by sending signal 0. +func isPIDAlive(pid int) bool { + if pid <= 0 { + return false + } + + return syscall.Kill(pid, 0) == nil +} diff --git a/app/cli/internal/trace/claude/pid_windows.go b/app/cli/internal/trace/claude/pid_windows.go new file mode 100644 index 000000000..e451367ae --- /dev/null +++ b/app/cli/internal/trace/claude/pid_windows.go @@ -0,0 +1,22 @@ +//go:build windows + +package claude + +import "os" + +// isPIDAlive reports whether a process with the given PID exists. +// On Windows os.FindProcess opens a handle to the process and fails if it does not exist, +// unlike on Unix where it always succeeds. +func isPIDAlive(pid int) bool { + if pid <= 0 { + return false + } + + proc, err := os.FindProcess(pid) + if err != nil { + return false + } + defer proc.Release() + + return true +} diff --git a/app/cli/internal/trace/claude/pricing.go b/app/cli/internal/trace/claude/pricing.go new file mode 100644 index 000000000..1a3776e15 --- /dev/null +++ b/app/cli/internal/trace/claude/pricing.go @@ -0,0 +1,57 @@ +package claude + +import ( + _ "embed" + "encoding/json" + "fmt" +) + +// modelPricing holds per-million-token rates in USD for a Claude model. +type modelPricing struct { + // Input is the price per 1M base input tokens. + Input float64 `json:"input"` + // Output is the price per 1M output tokens. + Output float64 `json:"output"` + // CacheWrite is the price per 1M tokens written to the 5-minute prompt cache tier (1.25x base input). + CacheWrite float64 `json:"cache_write"` + // CacheWrite1h is the price per 1M tokens written to the 1-hour prompt cache tier (2x base input). + CacheWrite1h float64 `json:"cache_write_1h"` + // CacheRead is the price per 1M tokens read from the prompt cache (0.1x base input). + CacheRead float64 `json:"cache_read"` +} + +// pricingFile is the schema of the embedded pricing.json data file. +type pricingFile struct { + Version string `json:"version"` + DefaultModel string `json:"default_model"` + Models map[string]modelPricing `json:"models"` +} + +//go:embed pricing.json +var pricingJSON []byte + +var ( + pricing map[string]modelPricing + defaultPricing modelPricing + pricingVersion string +) + +func init() { + var f pricingFile + if err := json.Unmarshal(pricingJSON, &f); err != nil { + panic(fmt.Sprintf("claude trace: invalid embedded pricing.json: %v", err)) + } + + if len(f.Models) == 0 { + panic("claude trace: embedded pricing.json has no models") + } + + def, ok := f.Models[f.DefaultModel] + if !ok { + panic(fmt.Sprintf("claude trace: default_model %q missing from pricing.json models", f.DefaultModel)) + } + + pricing = f.Models + defaultPricing = def + pricingVersion = f.Version +} diff --git a/app/cli/internal/trace/claude/pricing.json b/app/cli/internal/trace/claude/pricing.json new file mode 100644 index 000000000..7a3f989e9 --- /dev/null +++ b/app/cli/internal/trace/claude/pricing.json @@ -0,0 +1,183 @@ +{ + "version": "2026-08-28", + "source": "https://platform.claude.com/docs/en/about-claude/pricing", + "notes": "cache_write = 5-minute tier (~1.25x base input). cache_write_1h = 1-hour tier (~2x base input). cache_read = ~0.1x base input. Multipliers are approximate; the published rates at the source URL are authoritative and may differ due to rounding (e.g. claude-3-haiku-20240307 cache_write is $0.30, not 0.25 * 1.25 = $0.3125).", + "default_model": "claude-sonnet-4-6", + "models": { + "claude-fable-5": { + "input": 10.00, + "output": 50.00, + "cache_write": 12.50, + "cache_write_1h": 20.00, + "cache_read": 1.00 + }, + "claude-mythos-5": { + "input": 10.00, + "output": 50.00, + "cache_write": 12.50, + "cache_write_1h": 20.00, + "cache_read": 1.00 + }, + "claude-opus-5": { + "input": 5.00, + "output": 25.00, + "cache_write": 6.25, + "cache_write_1h": 10.00, + "cache_read": 0.50 + }, + "claude-opus-4-8": { + "input": 5.00, + "output": 25.00, + "cache_write": 6.25, + "cache_write_1h": 10.00, + "cache_read": 0.50 + }, + "claude-opus-4-7": { + "input": 5.00, + "output": 25.00, + "cache_write": 6.25, + "cache_write_1h": 10.00, + "cache_read": 0.50 + }, + "claude-opus-4-6": { + "input": 5.00, + "output": 25.00, + "cache_write": 6.25, + "cache_write_1h": 10.00, + "cache_read": 0.50 + }, + "claude-sonnet-5": { + "input": 2.00, + "output": 10.00, + "cache_write": 2.50, + "cache_write_1h": 4.00, + "cache_read": 0.20 + }, + "claude-sonnet-4-6": { + "input": 3.00, + "output": 15.00, + "cache_write": 3.75, + "cache_write_1h": 6.00, + "cache_read": 0.30 + }, + "claude-haiku-4-5-20251001": { + "input": 1.00, + "output": 5.00, + "cache_write": 1.25, + "cache_write_1h": 2.00, + "cache_read": 0.10 + }, + "claude-haiku-4-5": { + "input": 1.00, + "output": 5.00, + "cache_write": 1.25, + "cache_write_1h": 2.00, + "cache_read": 0.10 + }, + "claude-sonnet-4-5-20250929": { + "input": 3.00, + "output": 15.00, + "cache_write": 3.75, + "cache_write_1h": 6.00, + "cache_read": 0.30 + }, + "claude-sonnet-4-5": { + "input": 3.00, + "output": 15.00, + "cache_write": 3.75, + "cache_write_1h": 6.00, + "cache_read": 0.30 + }, + "claude-opus-4-5-20251101": { + "input": 5.00, + "output": 25.00, + "cache_write": 6.25, + "cache_write_1h": 10.00, + "cache_read": 0.50 + }, + "claude-opus-4-5": { + "input": 5.00, + "output": 25.00, + "cache_write": 6.25, + "cache_write_1h": 10.00, + "cache_read": 0.50 + }, + "claude-opus-4-1-20250805": { + "input": 15.00, + "output": 75.00, + "cache_write": 18.75, + "cache_write_1h": 30.00, + "cache_read": 1.50 + }, + "claude-opus-4-1": { + "input": 15.00, + "output": 75.00, + "cache_write": 18.75, + "cache_write_1h": 30.00, + "cache_read": 1.50 + }, + "claude-sonnet-4-20250514": { + "input": 3.00, + "output": 15.00, + "cache_write": 3.75, + "cache_write_1h": 6.00, + "cache_read": 0.30 + }, + "claude-sonnet-4": { + "input": 3.00, + "output": 15.00, + "cache_write": 3.75, + "cache_write_1h": 6.00, + "cache_read": 0.30 + }, + "claude-opus-4-20250514": { + "input": 15.00, + "output": 75.00, + "cache_write": 18.75, + "cache_write_1h": 30.00, + "cache_read": 1.50 + }, + "claude-opus-4": { + "input": 15.00, + "output": 75.00, + "cache_write": 18.75, + "cache_write_1h": 30.00, + "cache_read": 1.50 + }, + "claude-3-haiku-20240307": { + "input": 0.25, + "output": 1.25, + "cache_write": 0.30, + "cache_write_1h": 0.50, + "cache_read": 0.03 + }, + "claude-3-7-sonnet-20250219": { + "input": 3.00, + "output": 15.00, + "cache_write": 3.75, + "cache_write_1h": 6.00, + "cache_read": 0.30 + }, + "claude-3-5-haiku-20241022": { + "input": 0.80, + "output": 4.00, + "cache_write": 1.00, + "cache_write_1h": 1.60, + "cache_read": 0.08 + }, + "claude-3-5-haiku": { + "input": 0.80, + "output": 4.00, + "cache_write": 1.00, + "cache_write_1h": 1.60, + "cache_read": 0.08 + }, + "claude-3-opus-20240229": { + "input": 15.00, + "output": 75.00, + "cache_write": 18.75, + "cache_write_1h": 30.00, + "cache_read": 1.50 + } + } +} diff --git a/app/cli/internal/trace/claude/pricing_test.go b/app/cli/internal/trace/claude/pricing_test.go new file mode 100644 index 000000000..284eb3596 --- /dev/null +++ b/app/cli/internal/trace/claude/pricing_test.go @@ -0,0 +1,37 @@ +package claude + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPricingEmbedLoads(t *testing.T) { + require.NotEmpty(t, pricingVersion, "pricingVersion should be populated from pricing.json") + require.NotEmpty(t, pricing, "pricing map should be populated from pricing.json") + + assert.NotZero(t, defaultPricing.Input, "defaultPricing should resolve to a non-zero entry") + assert.NotZero(t, defaultPricing.Output) + + opus, ok := pricing["claude-opus-4-7"] + require.True(t, ok, "expected claude-opus-4-7 in embedded pricing table") + assert.Equal(t, 5.00, opus.Input) + assert.Equal(t, 25.00, opus.Output) + assert.Equal(t, 6.25, opus.CacheWrite) + assert.Equal(t, 10.00, opus.CacheWrite1h) + assert.Equal(t, 0.50, opus.CacheRead) + + opus5, ok := pricing["claude-opus-5"] + require.True(t, ok, "expected claude-opus-5 in embedded pricing table") + assert.Equal(t, 5.00, opus5.Input) + assert.Equal(t, 25.00, opus5.Output) + + sonnet5, ok := pricing["claude-sonnet-5"] + require.True(t, ok, "expected claude-sonnet-5 in embedded pricing table") + assert.Equal(t, 2.00, sonnet5.Input) + assert.Equal(t, 10.00, sonnet5.Output) + assert.Equal(t, 2.50, sonnet5.CacheWrite) + assert.Equal(t, 4.00, sonnet5.CacheWrite1h) + assert.Equal(t, 0.20, sonnet5.CacheRead) +} diff --git a/app/cli/internal/trace/claude/provider.go b/app/cli/internal/trace/claude/provider.go new file mode 100644 index 000000000..f56dc69c7 --- /dev/null +++ b/app/cli/internal/trace/claude/provider.go @@ -0,0 +1,191 @@ +package claude + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" +) + +// Name is the provider identifier emitted to evidence and used as the key +// in SessionRecord.Provider. Kept as an exported constant so CLI wiring +// can refer to it without instantiating the provider. +const Name = "claude-code" + +// Compile-time check that Provider implements trace.Provider. +var _ trace.Provider = (*Provider)(nil) + +// Provider implements trace.Provider for Claude Code sessions. +type Provider struct{} + +// New creates a new Claude Code provider. +func New() *Provider { + return &Provider{} +} + +// Name returns the agent identifier. +func (p *Provider) Name() string { + return Name +} + +// DiscoverSession finds the most relevant Claude Code session for the given repo root. +func (p *Provider) DiscoverSession(repoRoot string) (*trace.DiscoveredSession, error) { + session, err := discoverClaudeSession(repoRoot) + if err != nil || session == nil { + return nil, err + } + + return &trace.DiscoveredSession{ + SessionID: session.sessionID, + SessionDir: filepath.Dir(session.jsonlPath), + IsActive: session.isActive, + }, nil +} + +// SessionDirForRepo returns the Claude Code project directory for a given repo root. +func (p *Provider) SessionDirForRepo(repoRoot string) string { + homeDir, err := os.UserHomeDir() + if err != nil { + return "" + } + + return filepath.Join(homeDir, ".claude", "projects", encodeCWDForClaudePath(repoRoot)) +} + +// CopySessionData copies the Claude Code JSONL (and subagent files) from the +// Claude project directory into the store's raw/ directory so pre-push can +// parse them even if Claude rotates its own storage later. +func (p *Provider) CopySessionData(store *state.Store, repoRoot, sessionID string) error { + sourceDir := p.SessionDirForRepo(repoRoot) + if sourceDir == "" { + return nil + } + + return store.CopySessionJSONL(sessionID, sourceDir) +} + +// CaptureFileSnapshot reads the file at input.FilePath and stores its +// content under the store's snapshots/ directory so the post-tool-use +// handler can compute line-range diffs after the edit. A missing file +// (e.g. Claude's Write creating a new file) is treated as a non-error; +// other read errors (permission denied, I/O failures) are surfaced. +func (p *Provider) CaptureFileSnapshot(store *state.Store, input *trace.HookInput) error { + if input == nil || input.FilePath == "" { + return nil + } + + content, err := os.ReadFile(input.FilePath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + + return fmt.Errorf("read %q: %w", input.FilePath, err) + } + + return store.SaveFileSnapshot(input.SessionID, input.FilePath, content) +} + +// ResolveBeforeContent returns the snapshot saved by CaptureFileSnapshot. +// The "after" argument is unused for Claude — we always rely on the pre-edit +// snapshot — but is part of the interface for providers that reconstruct +// from edits instead of snapshots. +func (p *Provider) ResolveBeforeContent(store *state.Store, input *trace.HookInput, _ []byte) []byte { + if input == nil || input.FilePath == "" { + return nil + } + + snap, err := store.LoadFileSnapshot(input.SessionID, input.FilePath) + if err != nil { + return nil + } + + return snap +} + +// CleanupAfterEdit removes the per-edit snapshot. Safe to call when no +// snapshot was ever taken. +func (p *Provider) CleanupAfterEdit(store *state.Store, input *trace.HookInput) { + if input == nil || input.FilePath == "" { + return + } + + store.DeleteFileSnapshot(input.SessionID, input.FilePath) +} + +// IsFileWritingTool returns true if the named tool modifies files on disk. +func (p *Provider) IsFileWritingTool(toolName string) bool { + return slices.Contains(fileWritingTools, toolName) +} + +// IsCommandTool returns true if the named tool runs a shell command. +func (p *Provider) IsCommandTool(toolName string) bool { + return slices.Contains(commandTools, toolName) +} + +// SystemMessage writes a message to stdout for Claude Code to display on session start. +func (p *Provider) SystemMessage(msg string) error { + if msg == "" { + return nil + } + + resp := struct { + SystemMessage string `json:"systemMessage"` + }{SystemMessage: msg} + + return json.NewEncoder(os.Stdout).Encode(resp) +} + +// ParseSession parses a Claude Code session JSONL and returns structured evidence. +func (p *Provider) ParseSession(_ context.Context, opts *trace.ParseOpts) (*aicodingsession.Evidence, error) { + jsonlPath, err := findJSONLPath(opts.SessionDir, opts.SessionID) + if err != nil { + return nil, err + } + + data, rawMain, err := parseJSONL(jsonlPath) + if err != nil { + return nil, err + } + + // The filename carries only the sanitized ID, so prefer the caller's. + if data.sessionID == "" { + data.sessionID = opts.SessionID + } + if data.sessionID == "" { + data.sessionID = strings.TrimSuffix(filepath.Base(jsonlPath), ".jsonl") + } + + warnings := data.warnings + subagents, subUsage, rawSubagents, err := processSubagents(opts.SessionDir, data.sessionID, &warnings) + if err != nil { + return nil, err + } + + merged := mergeUsage(data.tokenUsageByModel, subUsage) + cost := computeCost(merged, &warnings) + + rawSession := map[string][]json.RawMessage{ + "main": rawMain, + } + if rawMain == nil { + rawSession["main"] = []json.RawMessage{} + } + for k, v := range rawSubagents { + if k == "main" { + warnings = append(warnings, "skipping subagent raw session with reserved key 'main'") + continue + } + rawSession[k] = v + } + + return buildOutput(data, subagents, merged, warnings, cost, rawSession), nil +} diff --git a/app/cli/internal/trace/claude/session.go b/app/cli/internal/trace/claude/session.go new file mode 100644 index 000000000..8b3596699 --- /dev/null +++ b/app/cli/internal/trace/claude/session.go @@ -0,0 +1,88 @@ +package claude + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" +) + +func discoverClaudeSession(repoRoot string) (*claudeSession, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, nil + } + + sessionsDir := filepath.Join(homeDir, ".claude", "sessions") + entries, err := os.ReadDir(sessionsDir) + if err != nil { + // No sessions directory means no sessions + return nil, nil + } + + var candidates []claudeSession + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + + data, err := os.ReadFile(filepath.Join(sessionsDir, entry.Name())) + if err != nil { + continue + } + + var sf claudeSessionFile + if err := json.Unmarshal(data, &sf); err != nil { + continue + } + + if sf.CWD != repoRoot { + continue + } + + isActive := isPIDAlive(sf.PID) + jsonlPath := resolveJSONLPath(homeDir, sf.CWD, sf.SessionID) + + candidates = append(candidates, claudeSession{ + sessionID: sf.SessionID, + cwd: sf.CWD, + pid: sf.PID, + isActive: isActive, + jsonlPath: jsonlPath, + startedAt: sf.StartedAt, + }) + } + + if len(candidates) == 0 { + return nil, nil + } + + // Sort: active sessions first, then by StartedAt descending + sort.Slice(candidates, func(i, j int) bool { + if candidates[i].isActive != candidates[j].isActive { + return candidates[i].isActive + } + + return candidates[i].startedAt > candidates[j].startedAt + }) + + best := candidates[0] + + return &best, nil +} + +func resolveJSONLPath(homeDir, cwd, sessionID string) string { + encodedCWD := encodeCWDForClaudePath(cwd) + + return filepath.Join(homeDir, ".claude", "projects", encodedCWD, sessionID+".jsonl") +} + +// encodeCWDForClaudePath encodes a directory path the way Claude Code does for project directories. +// Example: /Users/me/projects/myrepo -> -Users-me-projects-myrepo +// Example: /home/user/project/.claude/worktrees/name -> -home-user-project--claude-worktrees-name +func encodeCWDForClaudePath(cwd string) string { + encoded := strings.ReplaceAll(cwd, string(filepath.Separator), "-") + + return strings.ReplaceAll(encoded, ".", "-") +} diff --git a/app/cli/internal/trace/claude/session_test.go b/app/cli/internal/trace/claude/session_test.go new file mode 100644 index 000000000..f0d0b1584 --- /dev/null +++ b/app/cli/internal/trace/claude/session_test.go @@ -0,0 +1,165 @@ +package claude + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEncodeCWDForClaudePath(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"/Users/me/projects/myrepo", "-Users-me-projects-myrepo"}, + {"/tmp/test", "-tmp-test"}, + {"/a/b/c/d", "-a-b-c-d"}, + {"/home/user/project/.claude/worktrees/name", "-home-user-project--claude-worktrees-name"}, + {"/home/user/.config/test", "-home-user--config-test"}, + } + + for _, tt := range tests { + assert.Equal(t, tt.expected, encodeCWDForClaudePath(tt.input)) + } +} + +func TestDiscoverClaudeSession(t *testing.T) { + t.Run("no sessions directory", func(t *testing.T) { + session, err := discoverClaudeSession("/nonexistent/repo") + assert.NoError(t, err) + assert.Nil(t, session) + }) + + t.Run("discovers matching session", func(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + repoRoot := "/tmp/test-repo" + sessionsDir := filepath.Join(homeDir, ".claude", "sessions") + require.NoError(t, os.MkdirAll(sessionsDir, 0755)) + + sf := claudeSessionFile{ + PID: os.Getpid(), + SessionID: "test-session-id", + CWD: repoRoot, + StartedAt: 1774306212237, + } + data, err := json.Marshal(sf) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(sessionsDir, "12345.json"), data, 0600)) + + projectsDir := filepath.Join(homeDir, ".claude", "projects", encodeCWDForClaudePath(repoRoot)) + require.NoError(t, os.MkdirAll(projectsDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(projectsDir, "test-session-id.jsonl"), []byte("{}"), 0600)) + + session, err := discoverClaudeSession(repoRoot) + require.NoError(t, err) + require.NotNil(t, session) + + assert.Equal(t, "test-session-id", session.sessionID) + assert.Equal(t, repoRoot, session.cwd) + assert.True(t, session.isActive) + }) + + t.Run("ignores sessions for other directories", func(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + sessionsDir := filepath.Join(homeDir, ".claude", "sessions") + require.NoError(t, os.MkdirAll(sessionsDir, 0755)) + + sf := claudeSessionFile{ + PID: os.Getpid(), + SessionID: "other-session", + CWD: "/other/repo", + StartedAt: 1774306212237, + } + data, err := json.Marshal(sf) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(sessionsDir, "99999.json"), data, 0600)) + + session, err := discoverClaudeSession("/my/repo") + assert.NoError(t, err) + assert.Nil(t, session) + }) + + t.Run("prefers active over inactive sessions", func(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + repoRoot := "/tmp/test-repo" + sessionsDir := filepath.Join(homeDir, ".claude", "sessions") + require.NoError(t, os.MkdirAll(sessionsDir, 0755)) + + inactive := claudeSessionFile{ + PID: 999999999, + SessionID: "inactive-session", + CWD: repoRoot, + StartedAt: 2000000000000, + } + data, err := json.Marshal(inactive) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(sessionsDir, "999999999.json"), data, 0600)) + + active := claudeSessionFile{ + PID: os.Getpid(), + SessionID: "active-session", + CWD: repoRoot, + StartedAt: 1000000000000, + } + data, err = json.Marshal(active) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(sessionsDir, "current.json"), data, 0600)) + + session, err := discoverClaudeSession(repoRoot) + require.NoError(t, err) + require.NotNil(t, session) + assert.Equal(t, "active-session", session.sessionID) + assert.True(t, session.isActive) + }) +} + +func TestIsPIDAlive(t *testing.T) { + assert.True(t, isPIDAlive(os.Getpid())) + assert.False(t, isPIDAlive(999999999)) + assert.False(t, isPIDAlive(0)) + assert.False(t, isPIDAlive(-1)) +} + +func TestDiscoverSessionViaProvider(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + repoRoot := "/tmp/provider-test" + sessionsDir := filepath.Join(homeDir, ".claude", "sessions") + require.NoError(t, os.MkdirAll(sessionsDir, 0755)) + + sf := claudeSessionFile{ + PID: os.Getpid(), + SessionID: "provider-session", + CWD: repoRoot, + StartedAt: 1774306212237, + } + data, err := json.Marshal(sf) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(sessionsDir, "pid.json"), data, 0600)) + + projectsDir := filepath.Join(homeDir, ".claude", "projects", encodeCWDForClaudePath(repoRoot)) + require.NoError(t, os.MkdirAll(projectsDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(projectsDir, "provider-session.jsonl"), []byte("{}"), 0600)) + + provider := New() + assert.Equal(t, "claude-code", provider.Name()) + + discovered, err := provider.DiscoverSession(repoRoot) + require.NoError(t, err) + require.NotNil(t, discovered) + + assert.Equal(t, "provider-session", discovered.SessionID) + assert.True(t, discovered.IsActive) + assert.NotEmpty(t, discovered.SessionDir) +} diff --git a/app/cli/internal/trace/claude/testdata/older-session.jsonl b/app/cli/internal/trace/claude/testdata/older-session.jsonl new file mode 100644 index 000000000..4e86e6a44 --- /dev/null +++ b/app/cli/internal/trace/claude/testdata/older-session.jsonl @@ -0,0 +1 @@ +{"type":"user","timestamp":"2025-01-01T00:00:00Z","sessionId":"older-session","message":{"role":"user","content":"old"}} diff --git a/app/cli/internal/trace/claude/testdata/test-session.jsonl b/app/cli/internal/trace/claude/testdata/test-session.jsonl new file mode 100644 index 000000000..e231d3ef4 --- /dev/null +++ b/app/cli/internal/trace/claude/testdata/test-session.jsonl @@ -0,0 +1,7 @@ +{"type":"user","timestamp":"2026-03-24T00:00:00Z","sessionId":"test-session","slug":"test-slug","version":"2.1.81","cwd":"/tmp/test","gitBranch":"main","message":{"role":"user","content":"hello"}} +{"type":"assistant","timestamp":"2026-03-24T00:01:00Z","sessionId":"test-session","slug":"test-slug","version":"2.1.81","cwd":"/tmp/test","gitBranch":"main","message":{"model":"claude-opus-4-6","content":[{"type":"text","text":"Hi there"},{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls"}}],"usage":{"input_tokens":100,"output_tokens":200,"cache_creation_input_tokens":50,"cache_read_input_tokens":1000}}} +{"type":"user","timestamp":"2026-03-24T00:02:00Z","sessionId":"test-session","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"file1.txt\nfile2.txt"}]}} +{"type":"assistant","timestamp":"2026-03-24T00:03:00Z","sessionId":"test-session","message":{"model":"claude-opus-4-6","content":[{"type":"tool_use","id":"toolu_2","name":"Read","input":{"file_path":"/tmp/test/file1.txt"}},{"type":"tool_use","id":"toolu_3","name":"Bash","input":{"command":"cat file2.txt"}}],"usage":{"input_tokens":150,"output_tokens":300,"cache_creation_input_tokens":0,"cache_read_input_tokens":2000}}} +{"type":"progress","timestamp":"2026-03-24T00:03:30Z","sessionId":"test-session","data":{"type":"hook_progress"}} +{"type":"assistant","timestamp":"2026-03-24T00:05:00Z","sessionId":"test-session","message":{"model":"claude-opus-4-6","content":[{"type":"text","text":"Done!"}],"usage":{"input_tokens":50,"output_tokens":100,"cache_creation_input_tokens":80,"cache_read_input_tokens":500,"cache_creation":{"ephemeral_5m_input_tokens":30,"ephemeral_1h_input_tokens":50}}}} +this is a malformed line that should produce a warning diff --git a/app/cli/internal/trace/claude/testdata/test-session/subagents/agent-abc123.jsonl b/app/cli/internal/trace/claude/testdata/test-session/subagents/agent-abc123.jsonl new file mode 100644 index 000000000..53bd7b813 --- /dev/null +++ b/app/cli/internal/trace/claude/testdata/test-session/subagents/agent-abc123.jsonl @@ -0,0 +1,2 @@ +{"type":"user","timestamp":"2026-03-24T00:01:10Z","message":{"role":"user","content":"explore"}} +{"type":"assistant","timestamp":"2026-03-24T00:01:30Z","message":{"model":"claude-haiku-4-5-20251001","content":[{"type":"text","text":"Found files"}],"usage":{"input_tokens":30,"output_tokens":50,"cache_creation_input_tokens":0,"cache_read_input_tokens":100}}} diff --git a/app/cli/internal/trace/claude/testdata/test-session/subagents/agent-abc123.meta.json b/app/cli/internal/trace/claude/testdata/test-session/subagents/agent-abc123.meta.json new file mode 100644 index 000000000..0aa9032eb --- /dev/null +++ b/app/cli/internal/trace/claude/testdata/test-session/subagents/agent-abc123.meta.json @@ -0,0 +1 @@ +{"agentType":"Explore","description":"Explore codebase structure"} \ No newline at end of file diff --git a/app/cli/internal/trace/claude/types.go b/app/cli/internal/trace/claude/types.go new file mode 100644 index 000000000..8d76a4f9c --- /dev/null +++ b/app/cli/internal/trace/claude/types.go @@ -0,0 +1,102 @@ +package claude + +import "encoding/json" + +type tokenUsage struct { + InputTokens int + OutputTokens int + CacheCreationInputTokens int + // CacheCreation5mTokens is the subset of CacheCreationInputTokens written to the 5-minute tier. + CacheCreation5mTokens int + // CacheCreation1hTokens is the subset of CacheCreationInputTokens written to the 1-hour tier. + CacheCreation1hTokens int + CacheReadInputTokens int +} + +func (t *tokenUsage) add(u *jsonlUsage) { + t.InputTokens += u.InputTokens + t.OutputTokens += u.OutputTokens + t.CacheCreationInputTokens += u.CacheCreationInputTokens + t.CacheReadInputTokens += u.CacheReadInputTokens + if u.CacheCreation != nil { + t.CacheCreation5mTokens += u.CacheCreation.Ephemeral5mInputTokens + t.CacheCreation1hTokens += u.CacheCreation.Ephemeral1hInputTokens + } else { + // Older records without the breakdown: attribute everything to the 5-minute tier. + t.CacheCreation5mTokens += u.CacheCreationInputTokens + } +} + +type jsonlRecord struct { + Type string `json:"type"` + Timestamp string `json:"timestamp"` + SessionID string `json:"sessionId"` + Slug string `json:"slug"` + Version string `json:"version"` + Cwd string `json:"cwd"` + GitBranch string `json:"gitBranch"` + Message *jsonlMessage `json:"message,omitempty"` +} + +type jsonlMessage struct { + Model string `json:"model"` + Content json.RawMessage `json:"content"` // can be string or array + Usage *jsonlUsage `json:"usage,omitempty"` +} + +type jsonlUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheCreationInputTokens int `json:"cache_creation_input_tokens"` + CacheReadInputTokens int `json:"cache_read_input_tokens"` + CacheCreation *jsonlCacheCreationInfo `json:"cache_creation,omitempty"` +} + +// jsonlCacheCreationInfo mirrors the nested usage.cache_creation object emitted by recent +// Claude API responses, which splits cache writes by tier (5-minute vs 1-hour). +type jsonlCacheCreationInfo struct { + Ephemeral5mInputTokens int `json:"ephemeral_5m_input_tokens"` + Ephemeral1hInputTokens int `json:"ephemeral_1h_input_tokens"` +} + +type jsonlContentBlock struct { + Type string `json:"type"` + Name string `json:"name"` +} + +type subagentMeta struct { + AgentType string `json:"agentType"` + Description string `json:"description"` +} + +type sessionData struct { + sessionID string + slug string + version string + cwd string + gitBranch string + tsMin string + tsMax string + tokenUsageByModel map[string]*tokenUsage + toolCounts map[string]int + messageCounts map[string]int + modelsSeen map[string]int + warnings []string +} + +// claudeSessionFile matches the JSON structure in ~/.claude/sessions/.json. +type claudeSessionFile struct { + PID int `json:"pid"` + SessionID string `json:"sessionId"` + CWD string `json:"cwd"` + StartedAt int64 `json:"startedAt"` +} + +type claudeSession struct { + sessionID string + cwd string + pid int + isActive bool + jsonlPath string + startedAt int64 +} diff --git a/app/cli/internal/trace/config/config.go b/app/cli/internal/trace/config/config.go new file mode 100644 index 000000000..61b87187f --- /dev/null +++ b/app/cli/internal/trace/config/config.go @@ -0,0 +1,247 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + tracegit "github.com/chainloop-dev/chainloop/app/cli/internal/trace/git" + "gopkg.in/yaml.v3" +) + +const ( + chainloopYMLFile = ".chainloop.yml" + chainloopYAMLFile = ".chainloop.yaml" +) + +// traceWorkflowName is the default workflow name used for trace attestations +// when no override is configured in .chainloop.yml. +const traceWorkflowName = "ai-coding-session" + +// ResolveWorkflowName returns the persisted workflow name when non-empty, +// otherwise falls back to the trace default. Centralizes the default so the +// CLI command and the hook handler stay in sync. +func ResolveWorkflowName(persisted string) string { + if persisted != "" { + return persisted + } + + return traceWorkflowName +} + +// ChainloopYML represents the relevant fields in .chainloop.yml. +type ChainloopYML struct { + ProjectName string `yaml:"projectName"` + ProjectVersion string `yaml:"projectVersion"` + // Organization, when set, forces trace attestations (init, add, push) + // to target that organization instead of the CLI's default. + Organization string `yaml:"organization,omitempty"` + // RequireTrace controls whether the pre-push hook blocks the push + // on attestation failure. nil is treated as false (default-off). + RequireTrace *bool `yaml:"requireTrace,omitempty"` + // WorkflowName, when set, overrides the default trace workflow name + // used when initializing attestations from trace hooks. + WorkflowName string `yaml:"workflowName,omitempty"` +} + +// FindChainloopYML looks for a .chainloop.yml (or .chainloop.yaml) file +// starting from dir and walking up to the git repository root. +// Returns the parsed config if found (with a non-empty projectName), or nil. +func FindChainloopYML(dir string) *ChainloopYML { + dir = resolveDir(dir) + repoRoot, err := tracegit.RepoRoot() + if err != nil { + return loadChainloopYMLWithProject(dir) + } + repoRoot = resolveDir(repoRoot) + + for { + if cfg := loadChainloopYMLWithProject(dir); cfg != nil { + return cfg + } + if dir == repoRoot { + break + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return nil +} + +// LoadProjectFromYML looks for a .chainloop.yml (or .chainloop.yaml) file +// starting from dir and walking up to the git repository root. +// Returns the projectName if found, or empty string if not found. +func LoadProjectFromYML(dir string) string { + if cfg := FindChainloopYML(dir); cfg != nil { + return cfg.ProjectName + } + return "" +} + +// resolveDir returns an absolute, symlink-resolved path. +// Falls back to the absolute path if symlink resolution fails. +func resolveDir(dir string) string { + abs, err := filepath.Abs(dir) + if err != nil { + return dir + } + resolved, err := filepath.EvalSymlinks(abs) + if err != nil { + return abs + } + return resolved +} + +// SaveProjectToYML writes (or updates) the projectName field in +// .chainloop.yml (or .chainloop.yaml) at the given directory, preserving any other fields. +func SaveProjectToYML(dir, project string) error { + return updateChainloopYMLField(dir, "projectName", project) +} + +// SaveRequireTraceToYML writes (or updates) the requireTrace field in +// .chainloop.yml at the given directory, preserving any other fields. +func SaveRequireTraceToYML(dir string, val bool) error { + return updateChainloopYMLField(dir, "requireTrace", val) +} + +// SaveOrganizationToYML writes (or updates) the organization field in +// .chainloop.yml at the given directory, preserving any other fields. +func SaveOrganizationToYML(dir, org string) error { + return updateChainloopYMLField(dir, "organization", org) +} + +// LoadOrganizationFromYML looks for .chainloop.yml starting from dir and returns +// the organization value. Returns empty string when the field is absent. +// Unlike FindChainloopYML, this does not require projectName to be set. +func LoadOrganizationFromYML(dir string) string { + cfg := findChainloopYMLAny(dir) + if cfg == nil { + return "" + } + + return cfg.Organization +} + +// SaveWorkflowToYML writes (or updates) the workflowName field in +// .chainloop.yml at the given directory, preserving any other fields. +func SaveWorkflowToYML(dir, workflow string) error { + return updateChainloopYMLField(dir, "workflowName", workflow) +} + +// LoadWorkflowFromYML looks for .chainloop.yml starting from dir and returns +// the workflowName value. Returns empty string when the field is absent. +// Unlike FindChainloopYML, this does not require projectName to be set. +func LoadWorkflowFromYML(dir string) string { + cfg := findChainloopYMLAny(dir) + if cfg == nil { + return "" + } + + return cfg.WorkflowName +} + +// updateChainloopYMLField reads, updates a single field, and writes back +// the .chainloop.yml file, preserving all other fields. +func updateChainloopYMLField(dir, key string, value any) error { + path := resolveChainloopYMLPath(dir) + + existing := make(map[string]any) + if data, err := os.ReadFile(path); err == nil { + if err := yaml.Unmarshal(data, &existing); err != nil { + return fmt.Errorf("parse %s: %w", filepath.Base(path), err) + } + } + + existing[key] = value + + out, err := yaml.Marshal(existing) + if err != nil { + return fmt.Errorf("marshal %s: %w", filepath.Base(path), err) + } + + return os.WriteFile(path, out, 0600) +} + +// LoadRequireTraceFromYML looks for .chainloop.yml starting from dir +// and returns the requireTrace value. Returns false when the field is +// absent or nil (default-off). +// Unlike FindChainloopYML, this does not require projectName to be set. +func LoadRequireTraceFromYML(dir string) bool { + cfg := findChainloopYMLAny(dir) + if cfg == nil || cfg.RequireTrace == nil { + return false + } + + return *cfg.RequireTrace +} + +// findChainloopYMLAny looks for a .chainloop.yml (or .chainloop.yaml) file +// starting from dir and walking up to the git repository root. +// Unlike FindChainloopYML, it returns any parseable config regardless of +// whether projectName is set. +func findChainloopYMLAny(dir string) *ChainloopYML { + dir = resolveDir(dir) + repoRoot, err := tracegit.RepoRoot() + if err != nil { + return loadChainloopYMLFromDir(dir) + } + repoRoot = resolveDir(repoRoot) + + for { + if cfg := loadChainloopYMLFromDir(dir); cfg != nil { + return cfg + } + if dir == repoRoot { + break + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + + return nil +} + +// resolveChainloopYMLPath returns the path to the existing .chainloop.yml +// (or .chainloop.yaml) in dir. If neither exists, defaults to .chainloop.yml. +func resolveChainloopYMLPath(dir string) string { + for _, name := range []string{chainloopYMLFile, chainloopYAMLFile} { + p := filepath.Join(dir, name) + if _, err := os.Stat(p); err == nil { + return p + } + } + return filepath.Join(dir, chainloopYMLFile) +} + +// loadChainloopYMLFromDir loads the ChainloopYML struct from the given directory, or nil if not found. +func loadChainloopYMLFromDir(dir string) *ChainloopYML { + for _, name := range []string{chainloopYMLFile, chainloopYAMLFile} { + data, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + continue + } + var cfg ChainloopYML + if err := yaml.Unmarshal(data, &cfg); err != nil { + continue + } + return &cfg + } + return nil +} + +// loadChainloopYMLWithProject is like loadChainloopYMLFromDir but only returns +// a config that has a non-empty projectName. This ensures directory walking +// skips empty or incomplete .chainloop.yml files. +func loadChainloopYMLWithProject(dir string) *ChainloopYML { + cfg := loadChainloopYMLFromDir(dir) + if cfg != nil && cfg.ProjectName != "" { + return cfg + } + return nil +} diff --git a/app/cli/internal/trace/config/config_test.go b/app/cli/internal/trace/config/config_test.go new file mode 100644 index 000000000..350009e3d --- /dev/null +++ b/app/cli/internal/trace/config/config_test.go @@ -0,0 +1,329 @@ +package config + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// initTempGitRepo creates a temp dir and runs git init in it. +func initTempGitRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + initGitRepo(t, dir) + return dir +} + +func initGitRepo(t *testing.T, dir string) { + t.Helper() + cmd := exec.Command("git", "init", dir) + require.NoError(t, cmd.Run()) +} + +func TestLoadProjectFromYML(t *testing.T) { + t.Run("reads projectName from .chainloop.yml", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), []byte("projectName: my-project\n"), 0600)) + assert.Equal(t, "my-project", LoadProjectFromYML(dir)) + }) + + t.Run("reads projectName from .chainloop.yaml", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yaml"), []byte("projectName: yaml-project\n"), 0600)) + assert.Equal(t, "yaml-project", LoadProjectFromYML(dir)) + }) + + t.Run("prefers .chainloop.yml over .chainloop.yaml", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), []byte("projectName: from-yml\n"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yaml"), []byte("projectName: from-yaml\n"), 0600)) + assert.Equal(t, "from-yml", LoadProjectFromYML(dir)) + }) + + t.Run("walks up to git repo root", func(t *testing.T) { + repoDir := initTempGitRepo(t) + child := filepath.Join(repoDir, "subdir") + require.NoError(t, os.Mkdir(child, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(repoDir, ".chainloop.yml"), []byte("projectName: parent-project\n"), 0600)) + + // Run from inside the child dir so git rev-parse finds our repo + origDir, _ := os.Getwd() + require.NoError(t, os.Chdir(child)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + assert.Equal(t, "parent-project", LoadProjectFromYML(child)) + }) + + t.Run("does not walk above git repo root", func(t *testing.T) { + parent := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(parent, ".chainloop.yml"), []byte("projectName: outside-repo\n"), 0600)) + + repoDir := filepath.Join(parent, "repo") + require.NoError(t, os.Mkdir(repoDir, 0755)) + initGitRepo(t, repoDir) + + origDir, _ := os.Getwd() + require.NoError(t, os.Chdir(repoDir)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + assert.Empty(t, LoadProjectFromYML(repoDir)) + }) + + t.Run("returns empty when no file found", func(t *testing.T) { + dir := t.TempDir() + assert.Empty(t, LoadProjectFromYML(dir)) + }) + + t.Run("skips file without projectName", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), []byte("otherField: value\n"), 0600)) + assert.Empty(t, LoadProjectFromYML(dir)) + }) +} + +func TestSaveProjectToYML(t *testing.T) { + t.Run("creates new file", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, SaveProjectToYML(dir, "new-project")) + + got := LoadProjectFromYML(dir) + assert.Equal(t, "new-project", got) + }) + + t.Run("preserves existing fields", func(t *testing.T) { + dir := t.TempDir() + existing := "projectName: old-project\nprojectVersion: v1.0.0\n" + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), []byte(existing), 0600)) + + require.NoError(t, SaveProjectToYML(dir, "updated-project")) + + data, err := os.ReadFile(filepath.Join(dir, ".chainloop.yml")) + require.NoError(t, err) + assert.Contains(t, string(data), "projectName: updated-project") + assert.Contains(t, string(data), "projectVersion: v1.0.0") + }) + + t.Run("updates existing projectName", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), []byte("projectName: old\n"), 0600)) + + require.NoError(t, SaveProjectToYML(dir, "new")) + assert.Equal(t, "new", LoadProjectFromYML(dir)) + }) + + t.Run("respects existing .chainloop.yaml extension", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yaml"), []byte("projectName: yaml-ext\nprojectVersion: v2.0.0\n"), 0600)) + + require.NoError(t, SaveProjectToYML(dir, "updated")) + + // Should have updated the .yaml file, not created a new .yml file + _, err := os.Stat(filepath.Join(dir, ".chainloop.yml")) + assert.True(t, os.IsNotExist(err), "should not create .yml when .yaml exists") + + data, err := os.ReadFile(filepath.Join(dir, ".chainloop.yaml")) + require.NoError(t, err) + assert.Contains(t, string(data), "projectName: updated") + assert.Contains(t, string(data), "projectVersion: v2.0.0") + }) + + t.Run("returns error on malformed yaml", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), []byte(":\ninvalid: [yaml\n"), 0600)) + + err := SaveProjectToYML(dir, "proj") + assert.Error(t, err) + }) +} + +func TestRequireTrace(t *testing.T) { + t.Run("defaults to false when field missing", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), + []byte("projectName: p\n"), 0600)) + assert.False(t, LoadRequireTraceFromYML(dir)) + }) + + t.Run("defaults to false when no config file", func(t *testing.T) { + dir := t.TempDir() + assert.False(t, LoadRequireTraceFromYML(dir)) + }) + + t.Run("returns false when explicitly set", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), + []byte("projectName: p\nrequireTrace: false\n"), 0600)) + assert.False(t, LoadRequireTraceFromYML(dir)) + }) + + t.Run("reads from file without projectName", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), + []byte("requireTrace: false\n"), 0600)) + assert.False(t, LoadRequireTraceFromYML(dir)) + }) + + t.Run("returns true when explicitly set", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), + []byte("projectName: p\nrequireTrace: true\n"), 0600)) + assert.True(t, LoadRequireTraceFromYML(dir)) + }) + + t.Run("save and load round-trip", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), + []byte("projectName: p\n"), 0600)) + + require.NoError(t, SaveRequireTraceToYML(dir, false)) + assert.False(t, LoadRequireTraceFromYML(dir)) + + require.NoError(t, SaveRequireTraceToYML(dir, true)) + assert.True(t, LoadRequireTraceFromYML(dir)) + }) + + t.Run("preserves other fields", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), + []byte("projectName: my-proj\nprojectVersion: v1.0.0\n"), 0600)) + + require.NoError(t, SaveRequireTraceToYML(dir, false)) + + data, err := os.ReadFile(filepath.Join(dir, ".chainloop.yml")) + require.NoError(t, err) + assert.Contains(t, string(data), "projectName: my-proj") + assert.Contains(t, string(data), "projectVersion: v1.0.0") + assert.Contains(t, string(data), "requireTrace: false") + }) +} + +func TestWorkflow(t *testing.T) { + t.Run("defaults to empty when field missing", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), + []byte("projectName: p\n"), 0600)) + assert.Empty(t, LoadWorkflowFromYML(dir)) + }) + + t.Run("defaults to empty when no config file", func(t *testing.T) { + dir := t.TempDir() + assert.Empty(t, LoadWorkflowFromYML(dir)) + }) + + t.Run("reads from file without projectName", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), + []byte("workflowName: my-flow\n"), 0600)) + assert.Equal(t, "my-flow", LoadWorkflowFromYML(dir)) + }) + + t.Run("save and load round-trip", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), + []byte("projectName: p\n"), 0600)) + + require.NoError(t, SaveWorkflowToYML(dir, "my-flow")) + assert.Equal(t, "my-flow", LoadWorkflowFromYML(dir)) + + require.NoError(t, SaveWorkflowToYML(dir, "another")) + assert.Equal(t, "another", LoadWorkflowFromYML(dir)) + }) + + t.Run("preserves other fields", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), + []byte("projectName: my-proj\nprojectVersion: v1.0.0\nrequireTrace: true\n"), 0600)) + + require.NoError(t, SaveWorkflowToYML(dir, "my-flow")) + + data, err := os.ReadFile(filepath.Join(dir, ".chainloop.yml")) + require.NoError(t, err) + assert.Contains(t, string(data), "projectName: my-proj") + assert.Contains(t, string(data), "projectVersion: v1.0.0") + assert.Contains(t, string(data), "requireTrace: true") + assert.Contains(t, string(data), "workflowName: my-flow") + }) +} + +func TestFindChainloopYML(t *testing.T) { + t.Run("reads both fields from .chainloop.yml", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), []byte("projectName: my-project\nprojectVersion: v1.2.3\n"), 0600)) + cfg := FindChainloopYML(dir) + require.NotNil(t, cfg) + assert.Equal(t, "my-project", cfg.ProjectName) + assert.Equal(t, "v1.2.3", cfg.ProjectVersion) + }) + + t.Run("returns nil when no file found", func(t *testing.T) { + dir := t.TempDir() + assert.Nil(t, FindChainloopYML(dir)) + }) + + t.Run("returns empty version when not set", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), []byte("projectName: my-project\n"), 0600)) + cfg := FindChainloopYML(dir) + require.NotNil(t, cfg) + assert.Equal(t, "my-project", cfg.ProjectName) + assert.Empty(t, cfg.ProjectVersion) + }) + + t.Run("walks up to git repo root", func(t *testing.T) { + repoDir := initTempGitRepo(t) + child := filepath.Join(repoDir, "subdir") + require.NoError(t, os.Mkdir(child, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(repoDir, ".chainloop.yml"), []byte("projectName: p\nprojectVersion: v2.0.0\n"), 0600)) + + origDir, _ := os.Getwd() + require.NoError(t, os.Chdir(child)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + cfg := FindChainloopYML(child) + require.NotNil(t, cfg) + assert.Equal(t, "p", cfg.ProjectName) + assert.Equal(t, "v2.0.0", cfg.ProjectVersion) + }) + + t.Run("reads requireTrace field", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), + []byte("projectName: p\nrequireTrace: false\n"), 0600)) + cfg := FindChainloopYML(dir) + require.NotNil(t, cfg) + require.NotNil(t, cfg.RequireTrace) + assert.False(t, *cfg.RequireTrace) + }) + + t.Run("requireTrace nil when not set", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), + []byte("projectName: p\n"), 0600)) + cfg := FindChainloopYML(dir) + require.NotNil(t, cfg) + assert.Nil(t, cfg.RequireTrace) + }) + + t.Run("skips file without projectName and walks up", func(t *testing.T) { + repoDir := initTempGitRepo(t) + child := filepath.Join(repoDir, "subdir") + require.NoError(t, os.Mkdir(child, 0755)) + // Child has a .chainloop.yml without projectName + require.NoError(t, os.WriteFile(filepath.Join(child, ".chainloop.yml"), []byte("otherField: value\n"), 0600)) + // Parent has the real config + require.NoError(t, os.WriteFile(filepath.Join(repoDir, ".chainloop.yml"), []byte("projectName: parent\nprojectVersion: v3.0.0\n"), 0600)) + + origDir, _ := os.Getwd() + require.NoError(t, os.Chdir(child)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + cfg := FindChainloopYML(child) + require.NotNil(t, cfg) + assert.Equal(t, "parent", cfg.ProjectName) + assert.Equal(t, "v3.0.0", cfg.ProjectVersion) + }) +} diff --git a/app/cli/internal/trace/cursor/hooks.go b/app/cli/internal/trace/cursor/hooks.go new file mode 100644 index 000000000..894717e64 --- /dev/null +++ b/app/cli/internal/trace/cursor/hooks.go @@ -0,0 +1,315 @@ +package cursor + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" +) + +const ( + // hookMarker identifies Chainloop-installed entries inside .cursor/hooks.json. + hookMarker = "chainloop trace hook cursor" + // settingsFile is the project-local Cursor hooks configuration file. + settingsFile = ".cursor/hooks.json" + // hooksSchemaVersion is the schema version Cursor expects in hooks.json. + hooksSchemaVersion = 1 + // defaultHookTimeoutSeconds is the per-hook timeout persisted in hooks.json. + defaultHookTimeoutSeconds = 30 + + // Cursor hook event names. + eventSessionStart = "sessionStart" + eventSessionEnd = "sessionEnd" + eventAfterFileEdit = "afterFileEdit" + + // syntheticEditToolName is the synthetic tool name set on HookInput for + // afterFileEdit events so generic handlers can treat them like + // Claude-style file-writing tool invocations. + syntheticEditToolName = "edit" +) + +type cursorHookEvent struct { + event string + command string +} + +// cursorHookEvents is the full list of hooks installed by InstallHooks. +var cursorHookEvents = []cursorHookEvent{ + {eventSessionStart, "chainloop trace hook cursor session-start"}, + {eventSessionEnd, "chainloop trace hook cursor session-end"}, + {eventAfterFileEdit, "chainloop trace hook cursor after-file-edit"}, +} + +// SettingsFile returns the absolute path to .cursor/hooks.json for the given repo. +func (p *Provider) SettingsFile(repoRoot string) string { + return filepath.Join(repoRoot, settingsFile) +} + +// InstallHooks adds Chainloop hooks to .cursor/hooks.json. +func (p *Provider) InstallHooks(repoRoot string) error { + return p.installHookEvents(repoRoot, cursorHookEvents) +} + +// InstallHooksForTraceRun installs every hook except SessionEnd; trace +// run drives end-of-session itself. +func (p *Provider) InstallHooksForTraceRun(repoRoot string) error { + events := make([]cursorHookEvent, 0, len(cursorHookEvents)) + for _, h := range cursorHookEvents { + if h.event == eventSessionEnd { + continue + } + events = append(events, h) + } + + return p.installHookEvents(repoRoot, events) +} + +func (p *Provider) installHookEvents(repoRoot string, events []cursorHookEvent) error { + settingsPath := filepath.Join(repoRoot, settingsFile) + + settings, err := readJSONFile(settingsPath) + if err != nil { + return err + } + + hooks, _ := settings["hooks"].(map[string]any) + if hooks == nil { + hooks = make(map[string]any) + } + + changed := false + for _, h := range events { + if addHookEntry(hooks, h.event, h.command) { + changed = true + } + } + + if version, _ := settings["version"].(float64); int(version) != hooksSchemaVersion { + settings["version"] = hooksSchemaVersion + changed = true + } + + if !changed { + return nil + } + + settings["hooks"] = hooks + + return writeJSONFile(settingsPath, settings) +} + +// UninstallHooks removes Chainloop hooks from .cursor/hooks.json. +// Leaves user-authored hooks intact. +func (p *Provider) UninstallHooks(repoRoot string) error { + settingsPath := filepath.Join(repoRoot, settingsFile) + + settings, err := readJSONFile(settingsPath) + if err != nil { + return err + } + + hooks, _ := settings["hooks"].(map[string]any) + if hooks == nil { + return nil + } + + changed := false + for _, h := range cursorHookEvents { + if removeHookEntry(hooks, h.event) { + changed = true + } + } + + if !changed { + return nil + } + + if len(hooks) == 0 { + delete(settings, "hooks") + // version is meaningless without any hooks; drop it too + delete(settings, "version") + } else { + settings["hooks"] = hooks + } + + if len(settings) == 0 { + err := os.Remove(settingsPath) + if errors.Is(err, os.ErrNotExist) { + return nil + } + + return err + } + + return writeJSONFile(settingsPath, settings) +} + +// cursorHookInput is the wire-level structure of a Cursor hook JSON payload. +// Only the fields we consume are modelled; unknown fields are ignored. +type cursorHookInput struct { + ConversationID string `json:"conversation_id"` + SessionID string `json:"session_id"` + HookEventName string `json:"hook_event_name"` + CursorVersion string `json:"cursor_version"` + Model string `json:"model"` + FilePath string `json:"file_path"` + Edits []cursorHookEdit `json:"edits"` +} + +type cursorHookEdit struct { + OldString string `json:"old_string"` + NewString string `json:"new_string"` +} + +// maxHookPayloadBytes caps hook payload reads to defend against runaway or +// malformed payloads (afterFileEdit edits can embed arbitrary file content). +const maxHookPayloadBytes = 16 * 1024 * 1024 + +// ReadHookInput parses a Cursor hook JSON payload from the given reader. +// ConversationID is preferred as the session identifier; session_id is used +// as a fallback for forward-compatibility. +func (p *Provider) ReadHookInput(r io.Reader) (*trace.HookInput, error) { + data, err := io.ReadAll(io.LimitReader(r, maxHookPayloadBytes)) + if err != nil { + return nil, err + } + + var raw cursorHookInput + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + + sessionID := raw.ConversationID + if sessionID == "" { + sessionID = raw.SessionID + } + + input := &trace.HookInput{ + SessionID: sessionID, + HookEventName: raw.HookEventName, + FilePath: raw.FilePath, + AgentVersion: raw.CursorVersion, + Model: raw.Model, + } + + if raw.HookEventName == eventAfterFileEdit { + input.ToolName = syntheticEditToolName + } + + if len(raw.Edits) > 0 { + input.Edits = make([]trace.HookEdit, len(raw.Edits)) + for i, e := range raw.Edits { + input.Edits[i] = trace.HookEdit{OldString: e.OldString, NewString: e.NewString} + } + } + + return input, nil +} + +func addHookEntry(hooks map[string]any, event, command string) bool { + entries, _ := hooks[event].([]any) + + for i, entry := range entries { + if !entryContainsChainloopHook(entry) { + continue + } + m, _ := entry.(map[string]any) + existingCmd, _ := m["command"].(string) + if existingCmd == command { + return false + } + entries[i] = buildHookEntry(command) + hooks[event] = entries + + return true + } + + hooks[event] = append(entries, buildHookEntry(command)) + + return true +} + +func removeHookEntry(hooks map[string]any, event string) bool { + entries, _ := hooks[event].([]any) + if len(entries) == 0 { + return false + } + + var kept []any + for _, entry := range entries { + if !entryContainsChainloopHook(entry) { + kept = append(kept, entry) + } + } + + if len(kept) == len(entries) { + return false + } + + if len(kept) == 0 { + delete(hooks, event) + } else { + hooks[event] = kept + } + + return true +} + +func buildHookEntry(command string) map[string]any { + return map[string]any{ + "command": command, + "timeout": defaultHookTimeoutSeconds, + } +} + +func entryContainsChainloopHook(entry any) bool { + m, ok := entry.(map[string]any) + if !ok { + return false + } + + cmd, _ := m["command"].(string) + + return strings.Contains(cmd, hookMarker) +} + +func readJSONFile(path string) (map[string]any, error) { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return make(map[string]any), nil + } + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + + var result map[string]any + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + + if result == nil { + result = make(map[string]any) + } + + return result, nil +} + +func writeJSONFile(path string, data map[string]any) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("create directory: %w", err) + } + + content, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("marshal JSON: %w", err) + } + + content = append(content, '\n') + + return os.WriteFile(path, content, 0600) +} diff --git a/app/cli/internal/trace/cursor/hooks_test.go b/app/cli/internal/trace/cursor/hooks_test.go new file mode 100644 index 000000000..88e0f5003 --- /dev/null +++ b/app/cli/internal/trace/cursor/hooks_test.go @@ -0,0 +1,194 @@ +package cursor + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInstallHooksCreatesFile(t *testing.T) { + repoRoot := t.TempDir() + p := New() + + require.NoError(t, p.InstallHooks(repoRoot)) + + settingsPath := filepath.Join(repoRoot, settingsFile) + data, err := os.ReadFile(settingsPath) + require.NoError(t, err, "read %s", settingsPath) + + var out map[string]any + require.NoError(t, json.Unmarshal(data, &out), "parse settings json") + + v, _ := out["version"].(float64) + require.Equal(t, hooksSchemaVersion, int(v), "settings version") + + hooks, _ := out["hooks"].(map[string]any) + for _, evt := range []string{eventSessionStart, eventSessionEnd, eventAfterFileEdit} { + entries, _ := hooks[evt].([]any) + require.NotEmpty(t, entries, "no hook entries for event %q", evt) + } +} + +func TestInstallHooksPreservesExistingUserHooks(t *testing.T) { + repoRoot := t.TempDir() + settingsPath := filepath.Join(repoRoot, settingsFile) + require.NoError(t, os.MkdirAll(filepath.Dir(settingsPath), 0755)) + + existing := map[string]any{ + "version": 1, + "hooks": map[string]any{ + eventSessionStart: []any{ + map[string]any{"command": "./my-hook.sh", "timeout": 10}, + }, + }, + } + data, err := json.MarshalIndent(existing, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(settingsPath, data, 0600)) + + p := New() + require.NoError(t, p.InstallHooks(repoRoot)) + + out, err := os.ReadFile(settingsPath) + require.NoError(t, err) + var parsed map[string]any + require.NoError(t, json.Unmarshal(out, &parsed)) + + hooks, ok := parsed["hooks"].(map[string]any) + require.True(t, ok, "hooks is not an object") + entries, ok := hooks[eventSessionStart].([]any) + require.True(t, ok, "%s entries missing", eventSessionStart) + require.Len(t, entries, 2, "expected 2 entries (user + chainloop)") + + var userCount, chainloopCount int + for _, e := range entries { + m, ok := e.(map[string]any) + require.True(t, ok, "entry is not an object") + if cmd, _ := m["command"].(string); cmd == "./my-hook.sh" { + userCount++ + } + if entryContainsChainloopHook(e) { + chainloopCount++ + } + } + assert.Equal(t, 1, userCount, "user hook missing") + assert.Equal(t, 1, chainloopCount, "chainloop hook missing") +} + +func TestUninstallHooksLeavesUserHooks(t *testing.T) { + repoRoot := t.TempDir() + p := New() + require.NoError(t, p.InstallHooks(repoRoot)) + + // Add a user hook alongside chainloop's + settingsPath := filepath.Join(repoRoot, settingsFile) + data, err := os.ReadFile(settingsPath) + require.NoError(t, err) + var parsed map[string]any + require.NoError(t, json.Unmarshal(data, &parsed)) + hooks, ok := parsed["hooks"].(map[string]any) + require.True(t, ok, "hooks is not an object") + hooks[eventSessionStart] = append(hooks[eventSessionStart].([]any), + map[string]any{"command": "./user.sh", "timeout": 5}) + out, err := json.MarshalIndent(parsed, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(settingsPath, out, 0600)) + + require.NoError(t, p.UninstallHooks(repoRoot)) + + raw, err := os.ReadFile(settingsPath) + require.NoError(t, err, "settings file should still exist with user hook") + var after map[string]any + require.NoError(t, json.Unmarshal(raw, &after)) + afterHooks, ok := after["hooks"].(map[string]any) + require.True(t, ok, "hooks is not an object") + entries, ok := afterHooks[eventSessionStart].([]any) + require.True(t, ok, "%s entries missing", eventSessionStart) + require.Len(t, entries, 1, "expected 1 entry (user only)") + m, ok := entries[0].(map[string]any) + require.True(t, ok, "entry is not an object") + cmd, _ := m["command"].(string) + assert.Equal(t, "./user.sh", cmd, "user entry lost, got %+v", m) +} + +func TestUninstallHooksDeletesFileWhenEmpty(t *testing.T) { + repoRoot := t.TempDir() + p := New() + require.NoError(t, p.InstallHooks(repoRoot)) + require.NoError(t, p.UninstallHooks(repoRoot)) + + settingsPath := filepath.Join(repoRoot, settingsFile) + _, err := os.Stat(settingsPath) + assert.True(t, os.IsNotExist(err), "expected %s to be removed, stat err: %v", settingsPath, err) +} + +func TestReadHookInputCapturesCursorVersion(t *testing.T) { + payload := []byte(`{ + "conversation_id": "conv-ver", + "hook_event_name": "sessionStart", + "cursor_version": "3.2.16", + "model": "gpt-5" + }`) + + p := New() + in, err := p.ReadHookInput(bytes.NewReader(payload)) + require.NoError(t, err) + assert.Equal(t, "3.2.16", in.AgentVersion, "AgentVersion") + assert.Equal(t, "gpt-5", in.Model, "Model") +} + +func TestReadHookInputAfterFileEdit(t *testing.T) { + payload := []byte(`{ + "conversation_id": "conv-abc", + "generation_id": "gen-1", + "hook_event_name": "afterFileEdit", + "model": "claude-4", + "cursor_version": "0.48.0", + "file_path": "/abs/path/file.go", + "edits": [ + {"old_string": "foo", "new_string": "bar"}, + {"old_string": "baz", "new_string": "qux"} + ] + }`) + + p := New() + in, err := p.ReadHookInput(bytes.NewReader(payload)) + require.NoError(t, err) + assert.Equal(t, "conv-abc", in.SessionID, "SessionID") + assert.Equal(t, "afterFileEdit", in.HookEventName, "HookEventName") + assert.Equal(t, syntheticEditToolName, in.ToolName, "ToolName") + assert.Equal(t, "/abs/path/file.go", in.FilePath, "FilePath") + require.Len(t, in.Edits, 2, "Edits len") + assert.Equal(t, "foo", in.Edits[0].OldString, "first edit OldString") + assert.Equal(t, "bar", in.Edits[0].NewString, "first edit NewString") +} + +func TestReadHookInputSessionStart(t *testing.T) { + payload := []byte(`{ + "conversation_id": "conv-xyz", + "hook_event_name": "sessionStart", + "cursor_version": "0.48.0" + }`) + + p := New() + in, err := p.ReadHookInput(bytes.NewReader(payload)) + require.NoError(t, err) + assert.Equal(t, "conv-xyz", in.SessionID, "SessionID") + assert.Empty(t, in.ToolName, "ToolName should be empty on sessionStart") + assert.Empty(t, in.Edits, "Edits should be empty on sessionStart") +} + +func TestReadHookInputFallsBackToSessionID(t *testing.T) { + payload := []byte(`{ + "session_id": "fallback-session" + }`) + p := New() + in, err := p.ReadHookInput(bytes.NewReader(payload)) + require.NoError(t, err) + assert.Equal(t, "fallback-session", in.SessionID, "expected fallback to session_id") +} diff --git a/app/cli/internal/trace/cursor/parse.go b/app/cli/internal/trace/cursor/parse.go new file mode 100644 index 000000000..d94e8bab6 --- /dev/null +++ b/app/cli/internal/trace/cursor/parse.go @@ -0,0 +1,80 @@ +package cursor + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" +) + +// parsedTranscript collects the per-session signals we extract from a +// Cursor JSONL transcript. Token usage stays empty because Cursor does not +// expose it in the transcript format. +type parsedTranscript struct { + // UserMessages is the count of records with role "user". + UserMessages int + // AssistantMessages is the count of records with role "assistant". + AssistantMessages int + // ToolCounts maps tool name to invocation count, derived from + // "tool_use" content blocks across assistant messages. + ToolCounts map[string]int + // RawRecords retains every JSONL line for evidence storage. + RawRecords []json.RawMessage +} + +// parseCursorJSONL reads a Cursor agent transcript JSONL file and aggregates +// message and tool-use counts. +func parseCursorJSONL(path string) (*parsedTranscript, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer func() { _ = f.Close() }() + + scanner := bufio.NewScanner(f) + // Transcripts can contain long lines (file contents embedded in a prompt, etc.). + // Match Claude parser's generous buffer to avoid tokenizer errors on wide records. + const maxLineBytes = 10 * 1024 * 1024 + scanner.Buffer(make([]byte, 0, 64*1024), maxLineBytes) + + out := &parsedTranscript{ToolCounts: map[string]int{}} + + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + + var rec cursorJSONLRecord + if err := json.Unmarshal(line, &rec); err != nil { + // Malformed line (e.g. a truncated final record while Cursor is + // still writing). Skip it: storing invalid JSON as a RawMessage + // would fail the evidence encoder and abort the attestation. + continue + } + + out.RawRecords = append(out.RawRecords, json.RawMessage(bytes.Clone(line))) + + switch rec.Role { + case "user": + out.UserMessages++ + case "assistant": + out.AssistantMessages++ + } + + for _, block := range rec.Message.Content { + if block.Type == "tool_use" && block.Name != "" { + out.ToolCounts[block.Name]++ + } + } + } + + if scanErr := scanner.Err(); scanErr != nil && !errors.Is(scanErr, io.EOF) { + return out, fmt.Errorf("scan cursor transcript: %w", scanErr) + } + + return out, nil +} diff --git a/app/cli/internal/trace/cursor/parse_test.go b/app/cli/internal/trace/cursor/parse_test.go new file mode 100644 index 000000000..a7bdf081c --- /dev/null +++ b/app/cli/internal/trace/cursor/parse_test.go @@ -0,0 +1,164 @@ +package cursor + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const sampleJSONL = `{"role":"user","message":{"content":[{"type":"text","text":"hello"}]}} +{"role":"assistant","message":{"content":[{"type":"text","text":"hi"}]}} +{"role":"assistant","message":{"content":[{"type":"text","text":"anything else?"}]}} +` + +func TestParseSessionMessageCounts(t *testing.T) { + rawDir := t.TempDir() + path := filepath.Join(rawDir, "session-1.jsonl") + require.NoError(t, os.WriteFile(path, []byte(sampleJSONL), 0600)) + + p := New() + result, err := p.ParseSession(context.Background(), &trace.ParseOpts{ + SessionDir: rawDir, + SessionID: "session-1", + }) + require.NoError(t, err) + + assert.Equal(t, 1, result.Data.Conversation.UserMessages, "UserMessages") + assert.Equal(t, 2, result.Data.Conversation.AssistantMessages, "AssistantMessages") + assert.Equal(t, agentName, result.Data.Agent.Name, "Agent.Name") + assert.Equal(t, "session-1", result.Data.Session.ID, "Session.ID") + assert.NotEmpty(t, result.Data.Warnings, "expected at least one warning about missing token usage") +} + +func TestParseSessionMissingFile(t *testing.T) { + p := New() + _, err := p.ParseSession(context.Background(), &trace.ParseOpts{ + SessionDir: t.TempDir(), + SessionID: "does-not-exist", + }) + require.Error(t, err, "expected error for missing transcript") +} + +// toolUseJSONL mirrors the shape observed in real Cursor transcripts: +// assistant messages can mix text blocks with tool_use blocks, and the +// same tool name can appear multiple times across records. +const toolUseJSONL = `{"role":"user","message":{"content":[{"type":"text","text":"do work"}]}} +{"role":"assistant","message":{"content":[{"type":"text","text":"reading"},{"type":"tool_use","name":"ReadFile","input":{"path":"/x"}},{"type":"tool_use","name":"ReadFile","input":{"path":"/y"}}]}} +{"role":"assistant","message":{"content":[{"type":"tool_use","name":"ApplyPatch","input":"*** Begin Patch ***"}]}} +{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Grep","input":{"pattern":"foo"}},{"type":"tool_use","name":"ReadFile","input":{"path":"/z"}}]}} +` + +func TestParseSessionPropagatesAgentVersion(t *testing.T) { + rawDir := t.TempDir() + path := filepath.Join(rawDir, "ver-1.jsonl") + require.NoError(t, os.WriteFile(path, []byte(sampleJSONL), 0600)) + + p := New() + result, err := p.ParseSession(context.Background(), &trace.ParseOpts{ + SessionDir: rawDir, + SessionID: "ver-1", + AgentVersion: "3.2.16", + Model: "gpt-5", + }) + require.NoError(t, err) + + assert.Equal(t, "3.2.16", result.Data.Agent.Version, "Agent.Version") + require.NotNil(t, result.Data.Model, "Model") + assert.Equal(t, "gpt-5", result.Data.Model.Primary, "Model.Primary") +} + +func TestParseSessionExtractsToolUsage(t *testing.T) { + rawDir := t.TempDir() + path := filepath.Join(rawDir, "tools-1.jsonl") + require.NoError(t, os.WriteFile(path, []byte(toolUseJSONL), 0600)) + + p := New() + result, err := p.ParseSession(context.Background(), &trace.ParseOpts{ + SessionDir: rawDir, + SessionID: "tools-1", + }) + require.NoError(t, err) + + tools := result.Data.ToolsUsed + require.NotNil(t, tools, "ToolsUsed is nil; expected populated summary") + assert.Equal(t, 5, tools.TotalInvocations, "TotalInvocations") + + got := map[string]int{} + for _, s := range tools.Summary { + got[s.ToolName] = s.InvocationCount + } + want := map[string]int{"ReadFile": 3, "ApplyPatch": 1, "Grep": 1} + for name, count := range want { + assert.Equal(t, count, got[name], "tool %q", name) + } + + // Highest-count tool must come first (sorted desc by count). + require.NotEmpty(t, tools.Summary) + assert.Equal(t, "ReadFile", tools.Summary[0].ToolName, "expected ReadFile first in summary") +} + +func TestCopySessionDataFromFlat(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + repoRoot := "/tmp/myrepo" + sanitized := sanitizeRepoPath(repoRoot) + src := filepath.Join(homeDir, ".cursor", "projects", sanitized, "agent-transcripts") + require.NoError(t, os.MkdirAll(src, 0755)) + srcFile := filepath.Join(src, "conv-1.jsonl") + require.NoError(t, os.WriteFile(srcFile, []byte(sampleJSONL), 0600)) + + gitDir := filepath.Join(t.TempDir(), ".git") + require.NoError(t, os.MkdirAll(gitDir, 0755)) + + p := New() + require.NoError(t, p.CopySessionData(state.NewGitStore(gitDir), repoRoot, "conv-1")) + + dst := filepath.Join(gitDir, "chainloop-trace", "raw", "conv-1.jsonl") + got, err := os.ReadFile(dst) + require.NoError(t, err, "read destination") + require.Equal(t, sampleJSONL, string(got), "destination content mismatch") +} + +func TestCopySessionDataFromNested(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + repoRoot := "/tmp/myrepo" + sanitized := sanitizeRepoPath(repoRoot) + nested := filepath.Join(homeDir, ".cursor", "projects", sanitized, "agent-transcripts", "conv-2") + require.NoError(t, os.MkdirAll(nested, 0755)) + srcFile := filepath.Join(nested, "conv-2.jsonl") + require.NoError(t, os.WriteFile(srcFile, []byte(sampleJSONL), 0600)) + + gitDir := filepath.Join(t.TempDir(), ".git") + require.NoError(t, os.MkdirAll(gitDir, 0755)) + + p := New() + require.NoError(t, p.CopySessionData(state.NewGitStore(gitDir), repoRoot, "conv-2")) + + dst := filepath.Join(gitDir, "chainloop-trace", "raw", "conv-2.jsonl") + _, err := os.Stat(dst) + require.NoError(t, err, "destination missing") +} + +func TestParseCursorJSONLSkipsMalformedLines(t *testing.T) { + path := filepath.Join(t.TempDir(), "session-1.jsonl") + // Trailing line is truncated, as happens when Cursor is still writing. + content := sampleJSONL + " \n" + `{"role":"assistant","message":{"con` + require.NoError(t, os.WriteFile(path, []byte(content), 0600)) + + parsed, err := parseCursorJSONL(path) + require.NoError(t, err) + + // An invalid raw record fails the evidence encoder and aborts the attestation. + _, err = json.Marshal(parsed.RawRecords) + assert.NoErrorf(t, err, "marshal RawRecords (got %d records, want 3)", len(parsed.RawRecords)) +} diff --git a/app/cli/internal/trace/cursor/provider.go b/app/cli/internal/trace/cursor/provider.go new file mode 100644 index 000000000..8c63aaab1 --- /dev/null +++ b/app/cli/internal/trace/cursor/provider.go @@ -0,0 +1,211 @@ +package cursor + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "time" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/attribution" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" +) + +// Name is the provider identifier emitted to evidence and used as the key +// in SessionRecord.Provider. Kept as an exported constant so CLI wiring +// can refer to it without instantiating the provider. +const Name = "cursor" + +// agentName aliases Name for use inside this package's non-Provider code +// (parse output, log fields) so we don't collide with the Provider.Name() +// method name at read time. +const agentName = Name + +// Compile-time check that Provider implements trace.Provider. +var _ trace.Provider = (*Provider)(nil) + +// Provider implements trace.Provider for Cursor agent sessions. +type Provider struct{} + +// New creates a new Cursor provider. +func New() *Provider { + return &Provider{} +} + +// Name returns the agent identifier. +func (p *Provider) Name() string { + return agentName +} + +// DiscoverSession finds the most recent Cursor session for the given repo root. +func (p *Provider) DiscoverSession(repoRoot string) (*trace.DiscoveredSession, error) { + id, path, _, err := discoverCursorSession(repoRoot) + if err != nil || id == "" { + return nil, err + } + + return &trace.DiscoveredSession{ + SessionID: id, + SessionDir: filepath.Dir(path), + // Cursor does not expose a reliable "alive" signal from the transcript + // directory. Treat discovered sessions as potentially active. + IsActive: true, + }, nil +} + +// SessionDirForRepo returns the Cursor agent-transcripts directory for the repo. +func (p *Provider) SessionDirForRepo(repoRoot string) string { + return transcriptDirForRepo(repoRoot) +} + +// IsFileWritingTool returns true for the synthetic "edit" tool name we set on +// afterFileEdit hook inputs. Cursor has no tool-name concept for generic +// preToolUse/postToolUse events in our flow, so "edit" is the sole signal. +func (p *Provider) IsFileWritingTool(toolName string) bool { + return toolName == syntheticEditToolName +} + +// IsCommandTool always returns false for Cursor: shell-execution hooks are not +// yet wired (Cursor installs only sessionStart/sessionEnd/afterFileEdit), so +// shell-driven file changes are not captured for this provider. +func (p *Provider) IsCommandTool(_ string) bool { + return false +} + +// SystemMessage is a no-op for Cursor: the documented hook response channel +// does not include a "systemMessage"-style announcement path comparable to +// Claude Code's SessionStart output. We log nothing to stdout to avoid +// confusing Cursor's JSON response parser. +func (p *Provider) SystemMessage(_ string) error { + return nil +} + +// CaptureFileSnapshot is a no-op for Cursor: the afterFileEdit hook +// delivers old/new strings directly, so no pre-edit snapshot is needed. +func (p *Provider) CaptureFileSnapshot(_ *state.Store, _ *trace.HookInput) error { + return nil +} + +// ResolveBeforeContent reverses the edits reported by Cursor's +// afterFileEdit hook against the file's current content to reconstruct +// the pre-edit state. Returns nil when no edits were reported. +func (p *Provider) ResolveBeforeContent(_ *state.Store, input *trace.HookInput, after []byte) []byte { + if input == nil || len(input.Edits) == 0 { + return nil + } + + return attribution.ReverseApplyEdits(after, input.Edits) +} + +// CleanupAfterEdit is a no-op for Cursor: there's no per-edit state to +// release because no snapshot was taken. +func (p *Provider) CleanupAfterEdit(_ *state.Store, _ *trace.HookInput) {} + +// CopySessionData copies the Cursor transcript for sessionID into +// the store's raw/.jsonl. Handles both flat and nested +// source layouts; the destination is always flat so downstream consumers +// (parse, pre-push) don't need to re-resolve. +func (p *Provider) CopySessionData(store *state.Store, repoRoot, sessionID string) error { + sourceDir := p.SessionDirForRepo(repoRoot) + if sourceDir == "" { + return nil + } + + src, err := resolveSessionJSONL(sourceDir, sessionID) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + + return err + } + + return store.CopyRawSessionFile(sessionID, src) +} + +// ParseSession reads the copied transcript JSONL for sessionID and returns +// a best-effort Evidence. Cursor's transcript format does not include token +// usage, so Usage stays empty and the result carries a warning. +func (p *Provider) ParseSession(_ context.Context, opts *trace.ParseOpts) (*aicodingsession.Evidence, error) { + jsonlPath, err := state.FindRawSessionFile(opts.SessionDir, opts.SessionID) + if err != nil { + return nil, fmt.Errorf("locate cursor transcript: %w", err) + } + + parsed, err := parseCursorJSONL(jsonlPath) + if err != nil { + return nil, err + } + + info, err := os.Stat(jsonlPath) + var startedAt string + if err == nil { + startedAt = info.ModTime().UTC().Format(time.RFC3339) + } + + warnings := []string{ + "cursor transcripts do not expose token usage; usage and cost are reported as zero", + } + + rawSession := map[string][]json.RawMessage{"main": parsed.RawRecords} + if parsed.RawRecords == nil { + rawSession["main"] = []json.RawMessage{} + } + + return aicodingsession.NewEvidence(aicodingsession.Data{ + SchemaVersion: "v1", + Agent: aicodingsession.Agent{ + Name: agentName, + Version: opts.AgentVersion, + }, + Session: aicodingsession.Session{ + ID: opts.SessionID, + StartedAt: startedAt, + }, + Model: &aicodingsession.Model{ + Primary: opts.Model, + Provider: "cursor", + }, + ToolsUsed: buildToolsUsed(parsed.ToolCounts), + Conversation: &aicodingsession.Conversation{ + TotalMessages: parsed.UserMessages + parsed.AssistantMessages, + UserMessages: parsed.UserMessages, + AssistantMessages: parsed.AssistantMessages, + }, + RawSession: rawSession, + Warnings: warnings, + }), nil +} + +// buildToolsUsed converts a tool-name → invocation-count map into the +// Evidence summary, sorted by invocation count descending so the most-used +// tools surface first. +func buildToolsUsed(counts map[string]int) *aicodingsession.ToolsUsed { + if len(counts) == 0 { + return nil + } + + summary := make([]aicodingsession.ToolSummary, 0, len(counts)) + total := 0 + for name, count := range counts { + summary = append(summary, aicodingsession.ToolSummary{ToolName: name, InvocationCount: count}) + total += count + } + sort.Slice(summary, func(i, j int) bool { + if summary[i].InvocationCount != summary[j].InvocationCount { + return summary[i].InvocationCount > summary[j].InvocationCount + } + + return summary[i].ToolName < summary[j].ToolName + }) + + return &aicodingsession.ToolsUsed{ + Summary: summary, + TotalInvocations: total, + } +} diff --git a/app/cli/internal/trace/cursor/session.go b/app/cli/internal/trace/cursor/session.go new file mode 100644 index 000000000..ab7f40ecd --- /dev/null +++ b/app/cli/internal/trace/cursor/session.go @@ -0,0 +1,133 @@ +package cursor + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +// sanitizeRepoPath encodes a repo root the way Cursor does for its projects +// directory: drop the leading path separator, replace any run of +// non-alphanumerics with a single dash. +// +// Example: /Users/me/proj/myrepo -> Users-me-proj-myrepo +func sanitizeRepoPath(repoRoot string) string { + s := strings.TrimPrefix(repoRoot, string(filepath.Separator)) + + var b strings.Builder + b.Grow(len(s)) + prevDash := false + for _, r := range s { + if isAlphanumeric(r) { + b.WriteRune(r) + prevDash = false + continue + } + if !prevDash { + b.WriteByte('-') + prevDash = true + } + } + + return strings.Trim(b.String(), "-") +} + +func isAlphanumeric(r rune) bool { + return (r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') +} + +// transcriptDirForRepo returns the Cursor agent-transcripts directory for a repo: +// ~/.cursor/projects//agent-transcripts/ +func transcriptDirForRepo(repoRoot string) string { + homeDir, err := os.UserHomeDir() + if err != nil { + return "" + } + + return filepath.Join(homeDir, ".cursor", "projects", sanitizeRepoPath(repoRoot), "agent-transcripts") +} + +// resolveSessionJSONL returns the path to the transcript file for sessionID +// inside dir, handling both the CLI flat layout (/.jsonl) and the +// IDE nested layout (//.jsonl). Returns an fs.ErrNotExist-wrapping +// error if neither candidate exists, and surfaces any other Stat error +// (e.g. permission denied) so callers don't silently treat it as missing. +func resolveSessionJSONL(dir, sessionID string) (string, error) { + flat := filepath.Join(dir, sessionID+".jsonl") + switch _, err := os.Stat(flat); { + case err == nil: + return flat, nil + case !errors.Is(err, os.ErrNotExist): + return "", fmt.Errorf("stat %q: %w", flat, err) + } + + nested := filepath.Join(dir, sessionID, sessionID+".jsonl") + switch _, err := os.Stat(nested); { + case err == nil: + return nested, nil + case !errors.Is(err, os.ErrNotExist): + return "", fmt.Errorf("stat %q: %w", nested, err) + } + + return "", fmt.Errorf("cursor transcript for %q not found in %q: %w", sessionID, dir, os.ErrNotExist) +} + +// discoverCursorSession walks the agent-transcripts directory for repoRoot +// and returns the most recently modified transcript file. Returns nil when +// no transcripts exist. +func discoverCursorSession(repoRoot string) (sessionID, jsonlPath string, modTime int64, err error) { + dir := transcriptDirForRepo(repoRoot) + if dir == "" { + return "", "", 0, nil + } + + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", "", 0, nil + } + + return "", "", 0, err + } + + var bestID, bestPath string + var bestTime int64 + + for _, entry := range entries { + name := entry.Name() + var candidateID, candidatePath string + var candidateTime int64 + + switch { + case entry.IsDir(): + nested := filepath.Join(dir, name, name+".jsonl") + info, err := os.Stat(nested) + if err != nil { + continue + } + candidateID = name + candidatePath = nested + candidateTime = info.ModTime().UnixNano() + case strings.HasSuffix(name, ".jsonl"): + info, err := entry.Info() + if err != nil { + continue + } + candidateID = strings.TrimSuffix(name, ".jsonl") + candidatePath = filepath.Join(dir, name) + candidateTime = info.ModTime().UnixNano() + default: + continue + } + + if candidateTime > bestTime { + bestID = candidateID + bestPath = candidatePath + bestTime = candidateTime + } + } + + return bestID, bestPath, bestTime, nil +} diff --git a/app/cli/internal/trace/cursor/session_test.go b/app/cli/internal/trace/cursor/session_test.go new file mode 100644 index 000000000..4c8cdf63e --- /dev/null +++ b/app/cli/internal/trace/cursor/session_test.go @@ -0,0 +1,55 @@ +package cursor + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSanitizeRepoPath(t *testing.T) { + tests := []struct { + in, want string + }{ + {"/Users/me/projects/myrepo", "Users-me-projects-myrepo"}, + {"/home/user/project-name", "home-user-project-name"}, + {"/a//b", "a-b"}, + {"/a/b.c/d", "a-b-c-d"}, + {"", ""}, + } + + for _, tc := range tests { + assert.Equal(t, tc.want, sanitizeRepoPath(tc.in), "sanitizeRepoPath(%q)", tc.in) + } +} + +func TestResolveSessionJSONLFlat(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "abc123.jsonl") + require.NoError(t, os.WriteFile(path, []byte("{}"), 0600)) + + got, err := resolveSessionJSONL(dir, "abc123") + require.NoError(t, err) + require.Equal(t, path, got) +} + +func TestResolveSessionJSONLNested(t *testing.T) { + dir := t.TempDir() + nestedDir := filepath.Join(dir, "abc123") + require.NoError(t, os.MkdirAll(nestedDir, 0755)) + path := filepath.Join(nestedDir, "abc123.jsonl") + require.NoError(t, os.WriteFile(path, []byte("{}"), 0600)) + + got, err := resolveSessionJSONL(dir, "abc123") + require.NoError(t, err) + require.Equal(t, path, got) +} + +func TestResolveSessionJSONLNotFound(t *testing.T) { + dir := t.TempDir() + _, err := resolveSessionJSONL(dir, "missing") + require.Error(t, err, "expected error for missing transcript") + require.ErrorIs(t, err, os.ErrNotExist) +} diff --git a/app/cli/internal/trace/cursor/types.go b/app/cli/internal/trace/cursor/types.go new file mode 100644 index 000000000..93e411989 --- /dev/null +++ b/app/cli/internal/trace/cursor/types.go @@ -0,0 +1,24 @@ +package cursor + +// cursorJSONLRecord matches a single line in a Cursor agent transcript. +// The transcript schema is minimal compared to Claude Code's: only role + +// content-blocks of type "text". No tool_use blocks, no token usage. +type cursorJSONLRecord struct { + Role string `json:"role"` + Message cursorJSONLBody `json:"message"` +} + +type cursorJSONLBody struct { + Content []cursorContentBlock `json:"content"` +} + +type cursorContentBlock struct { + // Type is the block kind. Observed values: "text" (plain message body) + // and "tool_use" (assistant invoking a built-in tool such as ReadFile, + // ApplyPatch, StrReplace, Grep, Glob). + Type string `json:"type"` + // Text holds the message body for "text" blocks. + Text string `json:"text,omitempty"` + // Name holds the tool name for "tool_use" blocks (empty otherwise). + Name string `json:"name,omitempty"` +} diff --git a/app/cli/internal/trace/git/aggregate.go b/app/cli/internal/trace/git/aggregate.go new file mode 100644 index 000000000..c54c624a3 --- /dev/null +++ b/app/cli/internal/trace/git/aggregate.go @@ -0,0 +1,63 @@ +package git + +import ( + "sort" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" +) + +// fileAgg is the running per-path total used while accumulating diffs from a +// set of commits. Backends share it so the aggregation rule (sum lines, +// last-wins status) lives in one place. +type fileAgg struct { + linesAdded int + linesRemoved int + status string +} + +// addCommitStats folds one commit's diff into the running totals. +func addCommitStats(out *aicodingsession.CodeChanges, byPath map[string]*fileAgg, perCommit *aicodingsession.CodeChanges) { + out.LinesAdded += perCommit.LinesAdded + out.LinesRemoved += perCommit.LinesRemoved + + for _, f := range perCommit.Files { + agg, ok := byPath[f.Path] + if !ok { + agg = &fileAgg{} + byPath[f.Path] = agg + } + agg.linesAdded += f.LinesAdded + agg.linesRemoved += f.LinesRemoved + if f.Status != "" { + agg.status = f.Status + } + } +} + +// finalizeAggregatedFiles converts the running per-path map into a sorted +// Files slice and per-status counters on out. +func finalizeAggregatedFiles(out *aicodingsession.CodeChanges, byPath map[string]*fileAgg) { + paths := make([]string, 0, len(byPath)) + for p := range byPath { + paths = append(paths, p) + } + sort.Strings(paths) + + for _, p := range paths { + agg := byPath[p] + out.Files = append(out.Files, aicodingsession.FileChange{ + Path: p, + Status: agg.status, + LinesAdded: agg.linesAdded, + LinesRemoved: agg.linesRemoved, + }) + switch agg.status { + case StatusCreated: + out.FilesCreated++ + case StatusDeleted: + out.FilesDeleted++ + default: + out.FilesModified++ + } + } +} diff --git a/app/cli/internal/trace/git/exec.go b/app/cli/internal/trace/git/exec.go new file mode 100644 index 000000000..046a7b0f9 --- /dev/null +++ b/app/cli/internal/trace/git/exec.go @@ -0,0 +1,342 @@ +package git + +import ( + "fmt" + "os/exec" + "strconv" + "strings" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" +) + +// ExecClient implements Client by shelling out to the git binary. +type ExecClient struct{} + +var _ Client = (*ExecClient)(nil) + +func NewExecClient() *ExecClient { return &ExecClient{} } + +// resolveMergeBase finds the merge-base commit between the current branch and the default remote branch. +func resolveMergeBase(repoRoot string) string { + baseBranch := detectBaseBranch(repoRoot) + if baseBranch == "" { + return "" + } + + return gitOutput(repoRoot, "merge-base", baseBranch, "HEAD") +} + +// Context collects repository metadata: remote URL, branch, commits since base. +func (c *ExecClient) Context(repoRoot string) (*aicodingsession.GitContext, error) { + ctx := &aicodingsession.GitContext{ + WorkDir: repoRoot, + } + + ctx.Repository = gitOutput(repoRoot, "config", "--get", "remote.origin.url") + ctx.Branch = gitOutput(repoRoot, "rev-parse", "--abbrev-ref", "HEAD") + + return ctx, nil +} + +// CodeChanges collects file-level diff stats between the merge base and HEAD. +func (c *ExecClient) CodeChanges(repoRoot string) (*aicodingsession.CodeChanges, error) { + mergeBase := resolveMergeBase(repoRoot) + if mergeBase == "" { + return &aicodingsession.CodeChanges{}, nil + } + + return parseDiffStats(repoRoot, fmt.Sprintf("%s...HEAD", mergeBase)), nil +} + +// CodeChangesForRange collects file-level diff stats for a specific commit range. +// The range covers startSHA^..endSHA (changes introduced by the commits). +func (c *ExecClient) CodeChangesForRange(repoRoot, startSHA, endSHA string) (*aicodingsession.CodeChanges, error) { + rangeSpec := fmt.Sprintf("%s^..%s", startSHA, endSHA) + return parseDiffStats(repoRoot, rangeSpec), nil +} + +// LocalReachableSHAs returns the union of SHAs reachable from every local +// branch tip. Implemented as a single `git rev-list` invocation across all +// refs under refs/heads/. +func (c *ExecClient) LocalReachableSHAs(repoRoot string) (map[string]bool, error) { + out := gitOutput(repoRoot, "rev-list", "--branches=*") + if out == "" { + return map[string]bool{}, nil + } + + shas := make(map[string]bool) + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line != "" { + shas[line] = true + } + } + + return shas, nil +} + +// emptyTreeSHA is git's well-known SHA-1 hash of an empty tree +// (`git hash-object -t tree /dev/null`). Used as the "before" side when +// diffing a root commit, which has no parent for `^` to resolve against. +const emptyTreeSHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" + +// CodeChangesForCommits aggregates per-commit (parent..commit) diffs across a +// possibly non-contiguous set of SHAs. See the GoGitClient counterpart for the +// rationale; this implementation walks each SHA via parseDiffStats and sums. +func (c *ExecClient) CodeChangesForCommits(repoRoot string, shas []string) (*aicodingsession.CodeChanges, error) { + out := &aicodingsession.CodeChanges{} + if len(shas) == 0 { + return out, nil + } + + byPath := make(map[string]*fileAgg) + for _, sha := range shas { + base := commitParentOrEmptyTree(repoRoot, sha) + addCommitStats(out, byPath, parseDiffStats(repoRoot, fmt.Sprintf("%s..%s", base, sha))) + } + finalizeAggregatedFiles(out, byPath) + + return out, nil +} + +// commitParentOrEmptyTree returns the parent SHA of `sha`, or the empty-tree +// SHA when `sha` is a root commit (`^` doesn't resolve). The fallback +// makes a root commit's diff the full tree as additions, matching the +// GoGitClient's zero-baseHash behavior. +func commitParentOrEmptyTree(repoRoot, sha string) string { + if parent := gitOutput(repoRoot, "rev-parse", "--verify", "--quiet", sha+"^"); parent != "" { + return parent + } + + return emptyTreeSHA +} + +// parseDiffStats runs git diff with the given range spec and returns parsed stats. +func parseDiffStats(repoRoot, rangeSpec string) *aicodingsession.CodeChanges { + info := &aicodingsession.CodeChanges{} + + // Collect per-file line stats from numstat + perFileStats := make(map[string][2]int) // path → [added, removed] + numstat := gitOutput(repoRoot, "diff", "--numstat", rangeSpec) + if numstat != "" { + for _, line := range strings.Split(numstat, "\n") { + fields := strings.SplitN(line, "\t", 3) + if len(fields) != 3 { + continue + } + + added, _ := strconv.Atoi(fields[0]) // binary files show "-" + removed, _ := strconv.Atoi(fields[1]) // which Atoi returns 0 + + info.LinesAdded += added + info.LinesRemoved += removed + + path := fields[2] + perFileStats[path] = [2]int{added, removed} + // For renames/copies numstat shows paths like "old => new" + // or brace-style "dir/{old => new}/file"; resolve to destination. + if dest := resolveNumstatRename(path); dest != "" { + perFileStats[dest] = [2]int{added, removed} + } + } + } + + nameStatus := gitOutput(repoRoot, "diff", "--name-status", rangeSpec) + if nameStatus != "" { + for _, line := range strings.Split(nameStatus, "\n") { + // Rename/copy lines have 3 fields: "R100\told\tnew", others have 2: "M\tfile" + fields := strings.Split(line, "\t") + if len(fields) < 2 { + continue + } + + status := mapGitStatus(fields[0]) + // Use the last field — for renames/copies this is the destination path + path := fields[len(fields)-1] + fci := aicodingsession.FileChange{ + Path: path, + Status: status, + } + if stats, ok := perFileStats[path]; ok { + fci.LinesAdded = stats[0] + fci.LinesRemoved = stats[1] + } + info.Files = append(info.Files, fci) + + switch status { + case StatusCreated: + info.FilesCreated++ + case StatusDeleted: + info.FilesDeleted++ + default: + info.FilesModified++ + } + } + } + + return info +} + +// resolveNumstatRename resolves a numstat rename path to the destination. +// Handles both simple "old => new" and brace-style "dir/{old => new}/file" patterns. +// Returns empty string if the path is not a rename. +func resolveNumstatRename(path string) string { + if !strings.Contains(path, " => ") { + return "" + } + + // Brace-style: "dir/{old => new}/suffix" + if braceStart := strings.Index(path, "{"); braceStart >= 0 { + if braceEnd := strings.Index(path, "}"); braceEnd > braceStart { + inner := path[braceStart+1 : braceEnd] + if _, dest, ok := strings.Cut(inner, " => "); ok { + prefix := path[:braceStart] + suffix := path[braceEnd+1:] + return prefix + dest + suffix + } + } + } + + // Simple: "old => new" + if _, dest, ok := strings.Cut(path, " => "); ok { + return dest + } + return "" +} + +// mapGitStatus converts a git diff status letter to a human-readable status. +func mapGitStatus(code string) string { + if len(code) == 0 { + return StatusModified + } + + switch code[0] { + case 'A': + return StatusCreated + case 'D': + return StatusDeleted + case 'R': + return StatusRenamed + case 'C': + return "copied" + default: + return StatusModified + } +} + +// GeneratedMatcher returns a predicate that reports whether a repo-relative path +// is marked linguist-generated=true, by shelling out to `git check-attr` per call. +// `git check-attr` returns "" on error (e.g. non-git directory), which degrades +// to a no-op matcher. +func (c *ExecClient) GeneratedMatcher(repoRoot string) func(path string) bool { + return func(path string) bool { + if path == "" { + return false + } + + // Output format: ": linguist-generated: " + // Values: "true", "false", "set" (bare attribute), "unspecified". + out := gitOutput(repoRoot, "check-attr", "linguist-generated", "--", path) + if out == "" { + return false + } + + idx := strings.LastIndex(out, ": ") + if idx < 0 { + return false + } + value := out[idx+2:] + + return value == "true" || value == "set" + } +} + +// CommitHeadInfo returns the SHA and message of HEAD. +func (c *ExecClient) CommitHeadInfo(repoRoot string) (sha, message string, err error) { + out := gitOutput(repoRoot, "log", "-1", "--format=%H%n%B", "HEAD") + if out == "" { + return "", "", fmt.Errorf("git log HEAD: no output") + } + + sha, message, _ = strings.Cut(out, "\n") + return sha, strings.TrimSpace(message), nil +} + +// StagedFiles returns repo-relative paths of files staged for the current commit. +func (c *ExecClient) StagedFiles(repoRoot string) ([]string, error) { + out := gitOutput(repoRoot, "diff", "--cached", "--name-only") + if out == "" { + return nil, nil + } + + return strings.Split(strings.TrimRight(out, "\n"), "\n"), nil +} + +// SnapshotWorktree returns a signature of the working tree (repo-relative path +// → content hash). The walk is git-binary-independent, so it shares the +// implementation used by GoGitClient. +func (c *ExecClient) SnapshotWorktree(repoRoot string) (map[string]string, error) { + return snapshotWorktree(repoRoot) +} + +// IsMergeCommit reports whether the commit at sha has more than one parent. +func (c *ExecClient) IsMergeCommit(repoRoot, sha string) (bool, error) { + cmd := exec.Command("git", "rev-list", "--no-walk", "--parents", sha) + cmd.Dir = repoRoot + out, err := cmd.Output() + if err != nil { + return false, fmt.Errorf("rev-list %s: %w", sha, err) + } + + // Output format: " ..." + fields := strings.Fields(strings.TrimSpace(string(out))) + + return len(fields) > 2, nil +} + +// BranchCommitSHAs returns the SHAs reachable from HEAD that are not reachable +// from the default remote branch. The merge base is excluded. Order matches +// `git rev-list HEAD ^` (newest first). When no default remote branch +// is detected, returns every commit reachable from HEAD. +func (c *ExecClient) BranchCommitSHAs(repoRoot string) ([]string, error) { + args := []string{"rev-list", "HEAD"} + if base := detectBaseBranch(repoRoot); base != "" { + args = append(args, "^"+base) + } + + out := gitOutput(repoRoot, args...) + if out == "" { + return nil, nil + } + + return strings.Split(out, "\n"), nil +} + +// detectBaseBranch tries to find the default remote branch (main, master, etc.) +func detectBaseBranch(repoRoot string) string { + ref := gitOutput(repoRoot, "symbolic-ref", "refs/remotes/origin/HEAD") + if ref != "" { + return ref + } + + for _, name := range []string{"origin/main", "origin/master"} { + out := gitOutput(repoRoot, "rev-parse", "--verify", "--quiet", name) + if out != "" { + return name + } + } + + return "" +} + +// gitOutput runs a git command and returns trimmed stdout, or empty string on error. +func gitOutput(repoRoot string, args ...string) string { + cmd := exec.Command("git", args...) + cmd.Dir = repoRoot + out, err := cmd.Output() + if err != nil { + return "" + } + + return strings.TrimSpace(string(out)) +} diff --git a/app/cli/internal/trace/git/exec_test.go b/app/cli/internal/trace/git/exec_test.go new file mode 100644 index 000000000..572a28a59 --- /dev/null +++ b/app/cli/internal/trace/git/exec_test.go @@ -0,0 +1,230 @@ +package git + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExecClient_Context(t *testing.T) { + cwd, err := os.Getwd() + require.NoError(t, err) + + c := NewExecClient() + ctx, err := c.Context(cwd) + require.NoError(t, err) + + assert.NotEmpty(t, ctx.Repository, "repository should not be empty") + assert.NotEmpty(t, ctx.Branch, "branch should not be empty") + assert.Equal(t, cwd, ctx.WorkDir) +} + +func TestExecClient_ContextInvalidDir(t *testing.T) { + c := NewExecClient() + ctx, err := c.Context(t.TempDir()) + require.NoError(t, err) + + assert.Empty(t, ctx.Repository) + assert.Empty(t, ctx.Branch) +} + +func TestExecClient_CodeChanges(t *testing.T) { + cwd, err := os.Getwd() + require.NoError(t, err) + + c := NewExecClient() + changes, err := c.CodeChanges(cwd) + require.NoError(t, err) + + assert.NotNil(t, changes) + assert.GreaterOrEqual(t, changes.FilesCreated+changes.FilesModified+changes.FilesDeleted, 0) +} + +func TestExecClient_CodeChangesInvalidDir(t *testing.T) { + c := NewExecClient() + changes, err := c.CodeChanges(t.TempDir()) + require.NoError(t, err) + assert.NotNil(t, changes) + assert.Empty(t, changes.Files) +} + +func TestExecClient_CodeChangesForRange(t *testing.T) { + // Create a temporary git repo with three commits so startSHA is never the + // root commit (startSHA^ must exist for the range to work). + dir := t.TempDir() + run := func(args ...string) string { return gitOutput(dir, args...) } + + run("init", "-b", "main") + run("config", "user.email", "test@test.com") + run("config", "user.name", "Test") + + require.NoError(t, os.WriteFile(dir+"/init.txt", []byte("init"), 0600)) + run("add", "init.txt") + run("commit", "-m", "init") + + require.NoError(t, os.WriteFile(dir+"/a.txt", []byte("hello"), 0600)) + run("add", "a.txt") + run("commit", "-m", "first") + first := run("rev-parse", "HEAD") + + require.NoError(t, os.WriteFile(dir+"/b.txt", []byte("world"), 0600)) + run("add", "b.txt") + run("commit", "-m", "second") + second := run("rev-parse", "HEAD") + + c := NewExecClient() + changes, err := c.CodeChangesForRange(dir, first, second) + require.NoError(t, err) + require.NotNil(t, changes) + require.Len(t, changes.Files, 2, "range should include a.txt and b.txt") + + paths := map[string]string{} + for _, f := range changes.Files { + paths[f.Path] = f.Status + } + assert.Equal(t, "created", paths["a.txt"]) + assert.Equal(t, "created", paths["b.txt"]) +} + +func TestExecClient_CodeChangesForRangeInvalidDir(t *testing.T) { + c := NewExecClient() + changes, err := c.CodeChangesForRange(t.TempDir(), "abc", "def") + require.NoError(t, err) + assert.NotNil(t, changes) + assert.Empty(t, changes.Files) +} + +func TestExecClient_CodeChangesForCommits(t *testing.T) { + dir := t.TempDir() + run := func(args ...string) string { return gitOutput(dir, args...) } + + run("init", "-b", "main") + run("config", "user.email", "test@test.com") + run("config", "user.name", "Test") + run("config", "commit.gpgsign", "false") + + require.NoError(t, os.WriteFile(dir+"/init.txt", []byte("init\n"), 0600)) + run("add", "init.txt") + run("commit", "-m", "init") + + // commit-A: creates a.txt with one line + require.NoError(t, os.WriteFile(dir+"/a.txt", []byte("alpha\n"), 0600)) + run("add", "a.txt") + run("commit", "-m", "A") + commitA := run("rev-parse", "HEAD") + + // commit-B (interleaved, NOT in our session): creates b.txt + require.NoError(t, os.WriteFile(dir+"/b.txt", []byte("bravo\n"), 0600)) + run("add", "b.txt") + run("commit", "-m", "B") + + // commit-C: extends a.txt with two more lines + require.NoError(t, os.WriteFile(dir+"/a.txt", []byte("alpha\nbeta\ngamma\n"), 0600)) + run("add", "a.txt") + run("commit", "-m", "C") + commitC := run("rev-parse", "HEAD") + + c := NewExecClient() + + t.Run("aggregates non-contiguous commits and excludes the gap", func(t *testing.T) { + changes, err := c.CodeChangesForCommits(dir, []string{commitA, commitC}) + require.NoError(t, err) + require.Len(t, changes.Files, 1, "only a.txt belongs to the queried set") + assert.Equal(t, "a.txt", changes.Files[0].Path) + assert.Equal(t, 3, changes.Files[0].LinesAdded, "1 line from A + 2 lines from C") + assert.Equal(t, 3, changes.LinesAdded) + }) + + t.Run("empty input returns empty result", func(t *testing.T) { + changes, err := c.CodeChangesForCommits(dir, nil) + require.NoError(t, err) + assert.Empty(t, changes.Files) + }) + + t.Run("root commit diffs against the empty tree", func(t *testing.T) { + // Fresh repo; a root commit has no parent, so `sha^` doesn't resolve. + // CodeChangesForCommits must fall back to the empty tree so the root + // commit's full content shows up as additions. + rootDir := t.TempDir() + runRoot := func(args ...string) string { return gitOutput(rootDir, args...) } + runRoot("init", "-b", "main") + runRoot("config", "user.email", "test@test.com") + runRoot("config", "user.name", "Test") + runRoot("config", "commit.gpgsign", "false") + + require.NoError(t, os.WriteFile(rootDir+"/seed.txt", []byte("seed1\nseed2\nseed3\n"), 0600)) + runRoot("add", "seed.txt") + runRoot("commit", "-m", "root") + rootSHA := runRoot("rev-parse", "HEAD") + + changes, err := c.CodeChangesForCommits(rootDir, []string{rootSHA}) + require.NoError(t, err) + require.Len(t, changes.Files, 1, "root commit's content must show up as additions") + assert.Equal(t, "seed.txt", changes.Files[0].Path) + assert.Equal(t, 3, changes.Files[0].LinesAdded) + }) +} + +func TestExecClient_GeneratedMatcher(t *testing.T) { + // Set up a real git repo so `git check-attr` has a working environment. + dir := t.TempDir() + run := func(args ...string) string { return gitOutput(dir, args...) } + + run("init", "-b", "main") + run("config", "user.email", "test@test.com") + run("config", "user.name", "Test") + + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".gitattributes"), + []byte( + "frontend/src/pbgen/** linguist-generated=true\n"+ + "gen/** linguist-generated\n"+ + "docs/** linguist-generated=false\n", + ), + 0o600, + )) + + c := NewExecClient() + m := c.GeneratedMatcher(dir) + + t.Run("linguist-generated=true matches", func(t *testing.T) { + assert.True(t, m("frontend/src/pbgen/foo.ts")) + assert.True(t, m("frontend/src/pbgen/nested/bar.ts")) + assert.False(t, m("frontend/src/components/foo.ts")) + }) + + t.Run("bare linguist-generated is treated as set", func(t *testing.T) { + assert.True(t, m("gen/out.pb.go")) + }) + + t.Run("linguist-generated=false returns false", func(t *testing.T) { + assert.False(t, m("docs/manual.md")) + }) + + t.Run("path not covered by any rule returns false", func(t *testing.T) { + assert.False(t, m("backend/internal/service/evidence.go")) + }) + + t.Run("empty path returns false", func(t *testing.T) { + assert.False(t, m("")) + }) +} + +func TestExecClient_GeneratedMatcherNonRepo(t *testing.T) { + // Non-git directory: `git check-attr` fails; matcher must degrade to false. + c := NewExecClient() + m := c.GeneratedMatcher(t.TempDir()) + assert.False(t, m("anything")) + assert.False(t, m("foo/bar.go")) +} + +func TestMapGitStatus(t *testing.T) { + assert.Equal(t, "created", mapGitStatus("A")) + assert.Equal(t, "deleted", mapGitStatus("D")) + assert.Equal(t, "renamed", mapGitStatus("R100")) + assert.Equal(t, "modified", mapGitStatus("M")) + assert.Equal(t, "modified", mapGitStatus("")) +} diff --git a/app/cli/internal/trace/git/git.go b/app/cli/internal/trace/git/git.go new file mode 100644 index 000000000..1e6cb2ba8 --- /dev/null +++ b/app/cli/internal/trace/git/git.go @@ -0,0 +1,61 @@ +package git + +import ( + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" +) + +// File statuses reported in aicodingsession.FileChange.Status. The control +// plane buckets on these exact strings when it ingests the evidence, so they +// are a wire contract, not free-form labels. +const ( + StatusCreated = "created" + StatusModified = "modified" + StatusDeleted = "deleted" + StatusRenamed = "renamed" +) + +// Client abstracts git operations so the implementation can be swapped +// (e.g. from exec-based to go-git). +type Client interface { + // Context collects repository metadata: remote URL, branch, commits since base. + Context(repoRoot string) (*aicodingsession.GitContext, error) + // CodeChanges collects file-level diff stats between the merge base and HEAD. + CodeChanges(repoRoot string) (*aicodingsession.CodeChanges, error) + // CodeChangesForRange collects file-level diff stats for a specific commit range. + // The range covers startSHA^..endSHA (i.e. changes introduced by the commits). + CodeChangesForRange(repoRoot, startSHA, endSHA string) (*aicodingsession.CodeChanges, error) + // CodeChangesForCommits collects file-level diff stats for the union of the + // provided commit SHAs. Each commit's parent..commit diff is computed + // individually and summed by file path, so a non-contiguous set (e.g. one + // session's commits interleaved with another's on the same branch) is + // handled correctly. The order in which commits are visited is unspecified; + // per-file Status reflects the most recently visited commit's view. + CodeChangesForCommits(repoRoot string, shas []string) (*aicodingsession.CodeChanges, error) + // CommitHeadInfo returns the SHA and message of HEAD. + CommitHeadInfo(repoRoot string) (sha, message string, err error) + // GeneratedMatcher returns a predicate that reports whether a repo-relative + // path is marked linguist-generated=true in the repository's .gitattributes. + // Implementations must return a no-op matcher (always false) when attributes + // can't be read — filtering is best-effort and must not block attribution. + GeneratedMatcher(repoRoot string) func(path string) bool + // StagedFiles returns repo-relative paths of files staged for the current commit. + StagedFiles(repoRoot string) ([]string, error) + // SnapshotWorktree returns a signature of the working tree as a map of + // repo-relative path (forward slashes) → content hash, skipping the .git + // directory and any .gitignore'd paths. Used to detect files changed by an + // agent-run shell command by diffing a before/after signature. + SnapshotWorktree(repoRoot string) (map[string]string, error) + // IsMergeCommit reports whether the commit identified by sha has more than + // one parent. Returns an error when the commit cannot be resolved. + IsMergeCommit(repoRoot, sha string) (bool, error) + // BranchCommitSHAs returns the SHAs reachable from HEAD that are not + // reachable from the default remote branch (HEAD ^merge-base). The merge + // base itself is excluded. Order is unspecified. + BranchCommitSHAs(repoRoot string) ([]string, error) + // LocalReachableSHAs returns the union of SHAs reachable from every + // local branch tip (refs/heads/*). Used by the post-push GC to identify + // CommitRecords that are still alive on at least one branch — anything + // missing is an orphan from a deleted branch or a pre-rebase rewrite and + // is safe to drop. + LocalReachableSHAs(repoRoot string) (map[string]bool, error) +} diff --git a/app/cli/internal/trace/git/gogit.go b/app/cli/internal/trace/git/gogit.go new file mode 100644 index 000000000..60caa569f --- /dev/null +++ b/app/cli/internal/trace/git/gogit.go @@ -0,0 +1,571 @@ +package git + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + iofs "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" + "github.com/go-git/go-billy/v6/osfs" + gogit "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing" + "github.com/go-git/go-git/v6/plumbing/format/gitattributes" + "github.com/go-git/go-git/v6/plumbing/format/gitignore" + "github.com/go-git/go-git/v6/plumbing/object" + "github.com/go-git/go-git/v6/utils/merkletrie" +) + +// openRepo opens a git repository, detecting .git from the given path or its parents. +// EnableDotGitCommonDir is set to support git worktrees. +func openRepo(path string) (*gogit.Repository, error) { + return gogit.PlainOpenWithOptions(path, &gogit.PlainOpenOptions{ + DetectDotGit: true, + }) +} + +// GoGitClient implements Client using go-git (no external git binary required). +type GoGitClient struct{} + +var _ Client = (*GoGitClient)(nil) + +func NewGoGitClient() *GoGitClient { return &GoGitClient{} } + +func (c *GoGitClient) Context(repoRoot string) (*aicodingsession.GitContext, error) { + ctx := &aicodingsession.GitContext{WorkDir: repoRoot} + + repo, err := openRepo(repoRoot) + if err != nil { + return ctx, nil + } + + // Remote URL + remote, err := repo.Remote("origin") + if err == nil && len(remote.Config().URLs) > 0 { + ctx.Repository = remote.Config().URLs[0] + } + + // Branch + head, err := repo.Head() + if err != nil { + return ctx, nil + } + if head.Name().IsBranch() { + ctx.Branch = head.Name().Short() + } else { + ctx.Branch = "HEAD" + } + + return ctx, nil +} + +func (c *GoGitClient) CodeChanges(repoRoot string) (*aicodingsession.CodeChanges, error) { + repo, err := openRepo(repoRoot) + if err != nil { + return &aicodingsession.CodeChanges{}, nil + } + + head, err := repo.Head() + if err != nil { + return &aicodingsession.CodeChanges{}, nil + } + + baseBranch := detectBaseBranchGoGit(repo) + if baseBranch == nil { + return &aicodingsession.CodeChanges{}, nil + } + + headCommit, err := repo.CommitObject(head.Hash()) + if err != nil { + return &aicodingsession.CodeChanges{}, nil + } + + baseCommit, err := repo.CommitObject(baseBranch.Hash()) + if err != nil { + return &aicodingsession.CodeChanges{}, nil + } + + mergeBaseCommits, err := headCommit.MergeBase(baseCommit) + if err != nil || len(mergeBaseCommits) == 0 { + return &aicodingsession.CodeChanges{}, nil + } + + return diffTrees(repo, mergeBaseCommits[0].Hash, head.Hash()) +} + +func (c *GoGitClient) CodeChangesForRange(repoRoot, startSHA, endSHA string) (*aicodingsession.CodeChanges, error) { + repo, err := openRepo(repoRoot) + if err != nil { + return &aicodingsession.CodeChanges{}, nil + } + + // We want changes introduced by startSHA through endSHA (startSHA^..endSHA). + startCommit, err := repo.CommitObject(plumbing.NewHash(startSHA)) + if err != nil { + return &aicodingsession.CodeChanges{}, nil + } + + // Use the first parent of startSHA as the base + var baseHash plumbing.Hash + parents := startCommit.Parents() + parent, err := parents.Next() + if err == nil { + baseHash = parent.Hash + } + // If no parent (root commit), baseHash is zero-value which gives an empty tree + + return diffTrees(repo, baseHash, plumbing.NewHash(endSHA)) +} + +// CodeChangesForCommits computes the union of per-commit (parent..commit) +// diffs for the given SHAs, summing line counts per file path. This is what +// runTracePush needs when a session's commits are non-contiguous on the branch +// — a SHA range diff would over-count by including unrelated commits in the +// gap. Unresolvable SHAs are skipped (best-effort) rather than failing the +// whole computation; that lets attestation continue when a stale CommitRecord +// references a SHA that's been pruned by git. +func (c *GoGitClient) CodeChangesForCommits(repoRoot string, shas []string) (*aicodingsession.CodeChanges, error) { + out := &aicodingsession.CodeChanges{} + if len(shas) == 0 { + return out, nil + } + + repo, err := openRepo(repoRoot) + if err != nil { + return out, nil + } + + byPath := make(map[string]*fileAgg) + for _, sha := range shas { + commit, err := repo.CommitObject(plumbing.NewHash(sha)) + if err != nil { + continue + } + + var baseHash plumbing.Hash + parents := commit.Parents() + if parent, err := parents.Next(); err == nil { + baseHash = parent.Hash + } + + stats, err := diffTrees(repo, baseHash, commit.Hash) + if err != nil { + continue + } + addCommitStats(out, byPath, stats) + } + finalizeAggregatedFiles(out, byPath) + + return out, nil +} + +func (c *GoGitClient) CommitHeadInfo(repoRoot string) (sha, message string, err error) { + repo, err := openRepo(repoRoot) + if err != nil { + return "", "", fmt.Errorf("open repo: %w", err) + } + + head, err := repo.Head() + if err != nil { + return "", "", fmt.Errorf("get HEAD: %w", err) + } + + commit, err := repo.CommitObject(head.Hash()) + if err != nil { + return "", "", fmt.Errorf("get commit: %w", err) + } + + return head.Hash().String(), strings.TrimSpace(commit.Message), nil +} + +// GeneratedMatcher returns a predicate that reports whether a repo-relative path +// is marked linguist-generated=true in the repository's .gitattributes, parsed +// in-process via go-git's gitattributes parser. +func (c *GoGitClient) GeneratedMatcher(repoRoot string) func(path string) bool { + fs := osfs.New(repoRoot) + // allowMacro=false: macros are only valid in .git/info/attributes + attrs, err := gitattributes.ReadAttributesFile(fs, nil, ".gitattributes", false) + if err != nil || len(attrs) == 0 { + return func(string) bool { return false } + } + + // go-git's Matcher.Match iterates the stack from end to start and overwrites + // the result map on each match. With Match(parts, nil) the loop runs to + // completion, so the LAST iteration that matches wins — i.e. the FIRST entry + // in the stack. To get git's documented "last pattern in the file wins" + // semantics, reverse the stack here. Only safe because we always pass a nil + // attribute filter; a non-nil filter would invert the early-return logic. + for i, j := 0, len(attrs)-1; i < j; i, j = i+1, j-1 { + attrs[i], attrs[j] = attrs[j], attrs[i] + } + + matcher := gitattributes.NewMatcher(attrs) + return func(path string) bool { + if path == "" { + return false + } + parts := strings.Split(path, "/") + result, matched := matcher.Match(parts, nil) + if !matched { + return false + } + attr, ok := result["linguist-generated"] + if !ok { + return false + } + // `linguist-generated` alone is treated as set; `linguist-generated=true` + // is the explicit form used by GitHub Linguist. + if attr.IsSet() { + return true + } + if attr.IsValueSet() && strings.EqualFold(attr.Value(), "true") { + return true + } + + return false + } +} + +// StagedFiles returns repo-relative paths of files staged for the current commit. +func (c *GoGitClient) StagedFiles(repoRoot string) ([]string, error) { + repo, err := openRepo(repoRoot) + if err != nil { + return nil, fmt.Errorf("open repo: %w", err) + } + + wt, err := repo.Worktree() + if err != nil { + return nil, fmt.Errorf("get worktree: %w", err) + } + + status, err := wt.Status() + if err != nil { + return nil, fmt.Errorf("get status: %w", err) + } + + var files []string + for path, s := range status { + if s.Staging != gogit.Unmodified && s.Staging != gogit.Untracked { + files = append(files, path) + } + } + + sort.Strings(files) + + return files, nil +} + +// SnapshotWorktree returns a signature of the working tree (repo-relative path +// → SHA-256 content hash), honoring .gitignore and skipping the .git directory. +func (c *GoGitClient) SnapshotWorktree(repoRoot string) (map[string]string, error) { + return snapshotWorktree(repoRoot) +} + +// snapshotWorktree walks repoRoot and hashes every regular, non-ignored file. +// gitignore patterns are read in-process via go-git's parser (best-effort: an +// unreadable .gitignore yields no patterns rather than an error). Symlinks and +// unreadable files are skipped. The walk never fails the caller — attribution +// is best-effort and must not block the agent. +func snapshotWorktree(repoRoot string) (map[string]string, error) { + wtfs := osfs.New(repoRoot) + patterns, _ := gitignore.ReadPatterns(wtfs, nil) + matcher := gitignore.NewMatcher(patterns) + tracked := trackedPaths(repoRoot) + + sig := make(map[string]string) + err := filepath.WalkDir(repoRoot, func(p string, d iofs.DirEntry, err error) error { + if err != nil { + return nil // skip unreadable entries, keep walking + } + + rel, relErr := filepath.Rel(repoRoot, p) + if relErr != nil || rel == "." { + return nil + } + slashRel := filepath.ToSlash(rel) + parts := strings.Split(slashRel, "/") + + if d.IsDir() { + if d.Name() == ".git" || matcher.Match(parts, true) { + return filepath.SkipDir + } + + return nil + } + + if !d.Type().IsRegular() { + return nil + } + // Ignore rules apply only to untracked files, matching git: a tracked + // file that also matches a .gitignore pattern (e.g. force-added + // generated code) is still part of the working tree and must be + // snapshotted so edits to it are attributed. (Tracked files nested + // inside an ignored directory are the one gap — that directory is + // pruned above for walk performance.) + if matcher.Match(parts, false) && !tracked[slashRel] { + return nil + } + + h, herr := hashFileContent(p) + if herr != nil { + return nil + } + sig[slashRel] = h + + return nil + }) + + return sig, err +} + +// trackedPaths returns the set of repo-relative paths (forward-slash separated) +// recorded in the git index. Best-effort: returns nil when the repository or +// its index cannot be read, in which case ignore rules apply to every file. +func trackedPaths(repoRoot string) map[string]bool { + repo, err := openRepo(repoRoot) + if err != nil { + return nil + } + + idx, err := repo.Storer.Index() + if err != nil { + return nil + } + + tracked := make(map[string]bool, len(idx.Entries)) + for _, e := range idx.Entries { + tracked[e.Name] = true + } + + return tracked +} + +// hashFileContent returns the hex-encoded SHA-256 of the file's content. +func hashFileContent(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer func() { _ = f.Close() }() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + + return hex.EncodeToString(h.Sum(nil)), nil +} + +// IsMergeCommit reports whether the commit at sha has more than one parent. +func (c *GoGitClient) IsMergeCommit(repoRoot, sha string) (bool, error) { + repo, err := openRepo(repoRoot) + if err != nil { + return false, fmt.Errorf("open repo: %w", err) + } + + commit, err := repo.CommitObject(plumbing.NewHash(sha)) + if err != nil { + return false, fmt.Errorf("resolve commit %s: %w", sha, err) + } + + return commit.NumParents() > 1, nil +} + +// BranchCommitSHAs returns the SHAs reachable from HEAD that are not reachable +// from the default remote branch. The merge base is excluded. Order is +// unspecified. +func (c *GoGitClient) BranchCommitSHAs(repoRoot string) ([]string, error) { + repo, err := openRepo(repoRoot) + if err != nil { + return nil, fmt.Errorf("open repo: %w", err) + } + + head, err := repo.Head() + if err != nil { + return nil, fmt.Errorf("get HEAD: %w", err) + } + + headCommit, err := repo.CommitObject(head.Hash()) + if err != nil { + return nil, fmt.Errorf("resolve HEAD commit: %w", err) + } + + // Pre-seed the iterator's seen-set with the merge base(s). The preorder + // iter skips any commit already in seen and stops descending into its + // parents, so a single walk from HEAD naturally prunes at the merge base. + seen := make(map[plumbing.Hash]bool) + if baseRef := detectBaseBranchGoGit(repo); baseRef != nil { + if baseCommit, err := repo.CommitObject(baseRef.Hash()); err == nil { + if mergeBases, err := headCommit.MergeBase(baseCommit); err == nil { + for _, mb := range mergeBases { + seen[mb.Hash] = true + } + } + } + } + + var shas []string + iter := object.NewCommitPreorderIter(headCommit, seen, nil) + if err := iter.ForEach(func(c *object.Commit) error { + shas = append(shas, c.Hash.String()) + + return nil + }); err != nil { + return nil, fmt.Errorf("walk commits: %w", err) + } + + return shas, nil +} + +// LocalReachableSHAs walks every local branch tip (refs/heads/*) and returns +// the union of reachable SHAs. The shared `seen` map both deduplicates work +// across branch walks (preorder iter skips already-seen commits and stops +// descending into their parents) and records every visited commit so the +// caller can build the live-SHA set. +func (c *GoGitClient) LocalReachableSHAs(repoRoot string) (map[string]bool, error) { + repo, err := openRepo(repoRoot) + if err != nil { + return nil, fmt.Errorf("open repo: %w", err) + } + + branches, err := repo.Branches() + if err != nil { + return nil, fmt.Errorf("list branches: %w", err) + } + defer branches.Close() + + // go-git's NewCommitPreorderIter accepts `seen` as an input-only skip set + // and does not mutate it. Track visits explicitly via the callback so the + // resulting set really is "everything reachable". + seen := make(map[plumbing.Hash]bool) + if err := branches.ForEach(func(ref *plumbing.Reference) error { + commit, err := repo.CommitObject(ref.Hash()) + if err != nil { + // Branch points at a non-commit (or pruned object) — skip rather + // than fail the whole GC. + return nil + } + iter := object.NewCommitPreorderIter(commit, seen, nil) + + return iter.ForEach(func(c *object.Commit) error { + seen[c.Hash] = true + + return nil + }) + }); err != nil { + return nil, fmt.Errorf("walk branches: %w", err) + } + + out := make(map[string]bool, len(seen)) + for h := range seen { + out[h.String()] = true + } + + return out, nil +} + +// detectBaseBranchGoGit finds the default remote branch reference. +func detectBaseBranchGoGit(repo *gogit.Repository) *plumbing.Reference { + // Try refs/remotes/origin/HEAD + ref, err := repo.Reference(plumbing.NewRemoteReferenceName("origin", "HEAD"), true) + if err == nil { + return ref + } + + // Fallback to origin/main, then origin/master + for _, name := range []string{"main", "master"} { + ref, err = repo.Reference(plumbing.NewRemoteReferenceName("origin", name), true) + if err == nil { + return ref + } + } + + return nil +} + +// diffTrees computes diff stats between two commits identified by hash. +// A zero-value baseHash means diff against an empty tree. +// Note: go-git's tree diff does not detect renames/copies; they appear as delete+create. +func diffTrees(repo *gogit.Repository, baseHash, headHash plumbing.Hash) (*aicodingsession.CodeChanges, error) { + info := &aicodingsession.CodeChanges{} + + headCommit, err := repo.CommitObject(headHash) + if err != nil { + return info, nil + } + headTree, err := headCommit.Tree() + if err != nil { + return info, nil + } + + var baseTree *object.Tree + if !baseHash.IsZero() { + baseCommit, err := repo.CommitObject(baseHash) + if err != nil { + return info, nil + } + baseTree, err = baseCommit.Tree() + if err != nil { + return info, nil + } + } + + changes, err := object.DiffTree(baseTree, headTree) + if err != nil { + return info, nil + } + + // Collect file statuses from changes + for _, change := range changes { + action, err := change.Action() + if err != nil { + continue + } + + var status, path string + switch action { + case merkletrie.Insert: + status = StatusCreated + path = change.To.Name + info.FilesCreated++ + case merkletrie.Delete: + status = StatusDeleted + path = change.From.Name + info.FilesDeleted++ + default: + status = StatusModified + path = change.To.Name + info.FilesModified++ + } + + info.Files = append(info.Files, aicodingsession.FileChange{ + Path: path, + Status: status, + }) + } + + // Get line stats from patch and populate per-file counts + patch, err := changes.Patch() + if err != nil { + return info, nil + } + perFileStats := make(map[string][2]int) // path → [added, removed] + for _, stat := range patch.Stats() { + info.LinesAdded += stat.Addition + info.LinesRemoved += stat.Deletion + perFileStats[stat.Name] = [2]int{stat.Addition, stat.Deletion} + } + for i := range info.Files { + if stats, ok := perFileStats[info.Files[i].Path]; ok { + info.Files[i].LinesAdded = stats[0] + info.Files[i].LinesRemoved = stats[1] + } + } + + return info, nil +} diff --git a/app/cli/internal/trace/git/gogit_test.go b/app/cli/internal/trace/git/gogit_test.go new file mode 100644 index 000000000..85b90908e --- /dev/null +++ b/app/cli/internal/trace/git/gogit_test.go @@ -0,0 +1,521 @@ +package git + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + gogit "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/config" + "github.com/go-git/go-git/v6/plumbing/object" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGoGitClient_Context(t *testing.T) { + cwd, err := os.Getwd() + require.NoError(t, err) + + c := NewGoGitClient() + ctx, err := c.Context(cwd) + require.NoError(t, err) + + assert.NotEmpty(t, ctx.Repository, "repository should not be empty") + assert.NotEmpty(t, ctx.Branch, "branch should not be empty") + assert.Equal(t, cwd, ctx.WorkDir) +} + +func TestGoGitClient_ContextInvalidDir(t *testing.T) { + c := NewGoGitClient() + ctx, err := c.Context(t.TempDir()) + require.NoError(t, err) + + assert.Empty(t, ctx.Repository) + assert.Empty(t, ctx.Branch) +} + +func TestGoGitClient_CodeChanges(t *testing.T) { + cwd, err := os.Getwd() + require.NoError(t, err) + + c := NewGoGitClient() + changes, err := c.CodeChanges(cwd) + require.NoError(t, err) + + assert.NotNil(t, changes) + assert.GreaterOrEqual(t, changes.FilesCreated+changes.FilesModified+changes.FilesDeleted, 0) +} + +func TestGoGitClient_CodeChangesInvalidDir(t *testing.T) { + c := NewGoGitClient() + changes, err := c.CodeChanges(t.TempDir()) + require.NoError(t, err) + assert.NotNil(t, changes) + assert.Empty(t, changes.Files) +} + +func TestGoGitClient_CodeChangesForRange(t *testing.T) { + // Create a temporary git repo with three commits using go-git APIs. + dir := t.TempDir() + + repo, err := gogit.PlainInit(dir, false) + require.NoError(t, err) + + cfg, err := repo.Config() + require.NoError(t, err) + cfg.Commit.GpgSign = config.NewOptBool(false) + require.NoError(t, repo.SetConfig(cfg)) + + wt, err := repo.Worktree() + require.NoError(t, err) + + sig := &object.Signature{ + Name: "Test", + Email: "test@test.com", + When: time.Now(), + } + + // Init commit + require.NoError(t, os.WriteFile(dir+"/init.txt", []byte("init"), 0600)) + _, err = wt.Add("init.txt") + require.NoError(t, err) + _, err = wt.Commit("init", &gogit.CommitOptions{Author: sig}) + require.NoError(t, err) + + // First commit + require.NoError(t, os.WriteFile(dir+"/a.txt", []byte("hello"), 0600)) + _, err = wt.Add("a.txt") + require.NoError(t, err) + first, err := wt.Commit("first", &gogit.CommitOptions{Author: sig}) + require.NoError(t, err) + + // Second commit + require.NoError(t, os.WriteFile(dir+"/b.txt", []byte("world"), 0600)) + _, err = wt.Add("b.txt") + require.NoError(t, err) + second, err := wt.Commit("second", &gogit.CommitOptions{Author: sig}) + require.NoError(t, err) + + c := NewGoGitClient() + changes, err := c.CodeChangesForRange(dir, first.String(), second.String()) + require.NoError(t, err) + require.NotNil(t, changes) + require.Len(t, changes.Files, 2, "range should include a.txt and b.txt") + + paths := map[string]string{} + for _, f := range changes.Files { + paths[f.Path] = f.Status + } + assert.Equal(t, "created", paths["a.txt"]) + assert.Equal(t, "created", paths["b.txt"]) +} + +func TestGoGitClient_CodeChangesForRangeInvalidDir(t *testing.T) { + c := NewGoGitClient() + changes, err := c.CodeChangesForRange(t.TempDir(), "abc", "def") + require.NoError(t, err) + assert.NotNil(t, changes) + assert.Empty(t, changes.Files) +} + +// CodeChangesForCommits aggregates a non-contiguous set of SHAs by summing +// each commit's parent..commit diff per file path. The contiguous-range +// counterpart can't do this — it would over-count commits in the gap. +func TestGoGitClient_CodeChangesForCommits(t *testing.T) { + dir := t.TempDir() + repo, err := gogit.PlainInit(dir, false) + require.NoError(t, err) + cfg, err := repo.Config() + require.NoError(t, err) + cfg.Commit.GpgSign = config.NewOptBool(false) + require.NoError(t, repo.SetConfig(cfg)) + wt, err := repo.Worktree() + require.NoError(t, err) + sig := &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()} + + require.NoError(t, os.WriteFile(filepath.Join(dir, "init.txt"), []byte("init\n"), 0600)) + _, err = wt.Add("init.txt") + require.NoError(t, err) + _, err = wt.Commit("init", &gogit.CommitOptions{Author: sig}) + require.NoError(t, err) + + // commit-A: creates a.txt (one line) + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("alpha\n"), 0600)) + _, err = wt.Add("a.txt") + require.NoError(t, err) + commitA, err := wt.Commit("A", &gogit.CommitOptions{Author: sig}) + require.NoError(t, err) + + // commit-B (interleaved, NOT in our session): creates b.txt + require.NoError(t, os.WriteFile(filepath.Join(dir, "b.txt"), []byte("bravo\n"), 0600)) + _, err = wt.Add("b.txt") + require.NoError(t, err) + _, err = wt.Commit("B", &gogit.CommitOptions{Author: sig}) + require.NoError(t, err) + + // commit-C: extends a.txt with two more lines + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("alpha\nbeta\ngamma\n"), 0600)) + _, err = wt.Add("a.txt") + require.NoError(t, err) + commitC, err := wt.Commit("C", &gogit.CommitOptions{Author: sig}) + require.NoError(t, err) + + c := NewGoGitClient() + + t.Run("aggregates non-contiguous commits and excludes the gap", func(t *testing.T) { + // Session owns commit-A and commit-C; commit-B is human or another + // session's. Range [A..C] would over-count by including B's b.txt. + changes, err := c.CodeChangesForCommits(dir, []string{commitA.String(), commitC.String()}) + require.NoError(t, err) + require.Len(t, changes.Files, 1, "only a.txt belongs to the queried set") + assert.Equal(t, "a.txt", changes.Files[0].Path) + assert.Equal(t, 3, changes.Files[0].LinesAdded, "1 line from A + 2 lines from C") + assert.Equal(t, 0, changes.Files[0].LinesRemoved) + assert.Equal(t, 3, changes.LinesAdded) + }) + + t.Run("empty input returns empty result", func(t *testing.T) { + changes, err := c.CodeChangesForCommits(dir, nil) + require.NoError(t, err) + assert.Empty(t, changes.Files) + assert.Equal(t, 0, changes.LinesAdded) + }) + + t.Run("unresolvable SHA is skipped, not fatal", func(t *testing.T) { + changes, err := c.CodeChangesForCommits(dir, []string{commitA.String(), "deadbeef00000000000000000000000000000000"}) + require.NoError(t, err) + require.Len(t, changes.Files, 1) + assert.Equal(t, "a.txt", changes.Files[0].Path) + }) +} + +func TestGoGitClient_CommitHeadInfo(t *testing.T) { + cwd, err := os.Getwd() + require.NoError(t, err) + + c := NewGoGitClient() + sha, message, err := c.CommitHeadInfo(cwd) + require.NoError(t, err) + + assert.NotEmpty(t, sha, "SHA should not be empty") + assert.NotEmpty(t, message, "message should not be empty") + assert.Len(t, sha, 40, "SHA should be 40 hex characters") +} + +func TestGoGitClient_CommitHeadInfoInvalidDir(t *testing.T) { + c := NewGoGitClient() + _, _, err := c.CommitHeadInfo(t.TempDir()) + assert.Error(t, err) +} + +func TestGoGitClient_GeneratedMatcher(t *testing.T) { + writeGitAttributes := func(t *testing.T, dir, content string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitattributes"), []byte(content), 0o600)) + } + + t.Run("missing .gitattributes returns no-op matcher", func(t *testing.T) { + m := NewGoGitClient().GeneratedMatcher(t.TempDir()) + assert.False(t, m("frontend/src/pbgen/foo.ts")) + assert.False(t, m("anything")) + }) + + t.Run("linguist-generated=true pattern matches nested paths", func(t *testing.T) { + dir := t.TempDir() + writeGitAttributes(t, dir, "frontend/src/pbgen/** linguist-generated=true\n") + + m := NewGoGitClient().GeneratedMatcher(dir) + assert.True(t, m("frontend/src/pbgen/foo.ts")) + assert.True(t, m("frontend/src/pbgen/nested/bar.ts")) + assert.False(t, m("frontend/src/components/foo.ts")) + }) + + t.Run("bare linguist-generated is treated as set", func(t *testing.T) { + dir := t.TempDir() + writeGitAttributes(t, dir, "gen/** linguist-generated\n") + + m := NewGoGitClient().GeneratedMatcher(dir) + assert.True(t, m("gen/out.pb.go")) + }) + + t.Run("linguist-generated=false override wins for nested paths", func(t *testing.T) { + dir := t.TempDir() + // Order matters: later rules override earlier ones for the same attribute. + writeGitAttributes(t, dir, + "backend/ent/** linguist-generated=true\n"+ + "backend/ent/schema/* linguist-generated=false\n", + ) + + m := NewGoGitClient().GeneratedMatcher(dir) + assert.True(t, m("backend/ent/client.go")) + assert.False(t, m("backend/ent/schema/user.go")) + }) + + t.Run("path not covered returns false", func(t *testing.T) { + dir := t.TempDir() + writeGitAttributes(t, dir, "frontend/src/pbgen/** linguist-generated=true\n") + + m := NewGoGitClient().GeneratedMatcher(dir) + assert.False(t, m("backend/internal/service/evidence.go")) + assert.False(t, m("")) + }) + + t.Run("unrelated attributes do not trigger generated flag", func(t *testing.T) { + dir := t.TempDir() + writeGitAttributes(t, dir, + "*.go diff=golang\n"+ + "*.png binary\n", + ) + + m := NewGoGitClient().GeneratedMatcher(dir) + assert.False(t, m("main.go")) + assert.False(t, m("logo.png")) + }) + + t.Run("linguist-generated=true matches deeply nested paths", func(t *testing.T) { + dir := t.TempDir() + writeGitAttributes(t, dir, "frontend/src/pbgen/** linguist-generated=true\n") + + m := NewGoGitClient().GeneratedMatcher(dir) + assert.True(t, m("frontend/src/pbgen/backend/v1/shared_messages_pb.ts")) + assert.True(t, m("frontend/src/pbgen/a/b/c/d/e.ts")) + }) + + t.Run("**/*.pb.go matches generated proto Go files anywhere", func(t *testing.T) { + dir := t.TempDir() + writeGitAttributes(t, dir, "**/*.pb.go linguist-generated=true\n") + + m := NewGoGitClient().GeneratedMatcher(dir) + assert.True(t, m("backend/api/gen/backend/v1/shared_messages.pb.go")) + assert.True(t, m("backend/internal/conf/backend/config/v1/conf.pb.go")) + assert.False(t, m("backend/internal/conf/backend/config/v1/conf.proto")) + }) +} + +// TestGoGitClient_GeneratedMatcherProjectAttributes exercises the matcher against +// the real project .gitattributes, locking in the expected classification of +// representative paths under git's "last match wins" semantics. +func TestGoGitClient_GeneratedMatcherProjectAttributes(t *testing.T) { + _, repoRoot, err := FindGitDirAndRoot() + require.NoError(t, err) + + m := NewGoGitClient().GeneratedMatcher(repoRoot) + + // Generated by entgo, protoc and wire: must be filtered. + assert.True(t, m("app/controlplane/pkg/data/ent/client.go")) + assert.True(t, m("app/controlplane/pkg/data/ent/organization.go")) + assert.True(t, m("app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts")) + assert.True(t, m("app/controlplane/api/gen/jsonschema/foo.json")) + assert.True(t, m("app/controlplane/api/controlplane/v1/response_messages.pb.go")) + assert.True(t, m("app/controlplane/api/controlplane/v1/response_messages.pb.validate.go")) + assert.True(t, m("app/controlplane/cmd/wire_gen.go")) + + // Hand-managed despite living under a generated tree: the later + // linguist-generated=false lines win under "last match wins". + assert.False(t, m("app/controlplane/pkg/data/ent/migrate/migrate.go")) + assert.False(t, m("app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum")) + assert.False(t, m("app/controlplane/pkg/data/ent/schema/workflow.go")) + + // Unrelated source: not generated. + assert.False(t, m("app/cli/cmd/root.go")) + assert.False(t, m("pkg/attestation/crafter/crafter.go")) + assert.False(t, m("app/cli/internal/trace/git/gogit.go")) +} + +// TestGoGitClient_Worktree exercises all Client methods from a linked git worktree. +// Setup uses exec for worktree creation (go-git doesn't support creating linked worktrees), +// then verifies the GoGitClient can correctly read from the worktree. +func TestGoGitClient_Worktree(t *testing.T) { + // Skip if git binary is not available (needed for worktree setup only) + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git binary not found, skipping worktree test") + } + + // --- Setup: create main repo with origin remote and a linked worktree --- + mainDir := t.TempDir() + wtDir := filepath.Join(t.TempDir(), "linked-wt") + + // Initialize main repo using exec (to respect system default branch name) + run := func(args ...string) string { + cmd := exec.Command("git", args...) + cmd.Dir = mainDir + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v failed: %s", args, string(out)) + return strings.TrimSpace(string(out)) + } + + run("init", "-b", "main") + run("config", "user.email", "test@test.com") + run("config", "user.name", "Test") + + require.NoError(t, os.WriteFile(filepath.Join(mainDir, "init.txt"), []byte("init"), 0600)) + run("add", "init.txt") + run("commit", "-m", "initial commit") + + // Add a fake origin remote so Context() can read the remote URL + run("remote", "add", "origin", "https://github.com/example/repo.git") + // Create origin/main ref so merge-base detection works + run("update-ref", "refs/remotes/origin/main", "HEAD") + + // Create a feature branch and add commits + run("checkout", "-b", "feature") + + require.NoError(t, os.WriteFile(filepath.Join(mainDir, "a.txt"), []byte("hello"), 0600)) + run("add", "a.txt") + run("commit", "-m", "first feature commit") + firstHash := run("rev-parse", "HEAD") + + require.NoError(t, os.WriteFile(filepath.Join(mainDir, "b.txt"), []byte("world"), 0600)) + run("add", "b.txt") + run("commit", "-m", "second feature commit") + secondHash := run("rev-parse", "HEAD") + + // Switch main repo back to main before creating the linked worktree + run("checkout", "main") + + // Create a linked worktree on the feature branch (requires exec) + cmd := exec.Command("git", "worktree", "add", wtDir, "feature") + cmd.Dir = mainDir + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git worktree add failed: %s", string(out)) + + // --- Test: all GoGitClient methods from the linked worktree --- + c := NewGoGitClient() + + t.Run("Context", func(t *testing.T) { + ctx, err := c.Context(wtDir) + require.NoError(t, err) + + assert.Equal(t, "https://github.com/example/repo.git", ctx.Repository) + assert.Equal(t, "feature", ctx.Branch) + assert.Equal(t, wtDir, ctx.WorkDir) + assert.Equal(t, 0, ctx.CommitCount, "Context should not collect commits (overridden per-session)") + assert.Empty(t, ctx.CommitStart) + assert.Empty(t, ctx.CommitEnd) + }) + + t.Run("CommitHeadInfo", func(t *testing.T) { + sha, message, err := c.CommitHeadInfo(wtDir) + require.NoError(t, err) + + assert.Equal(t, secondHash, sha) + assert.Equal(t, "second feature commit", message) + }) + + t.Run("CodeChangesForRange", func(t *testing.T) { + changes, err := c.CodeChangesForRange(wtDir, firstHash, secondHash) + require.NoError(t, err) + require.Len(t, changes.Files, 2, "range should include a.txt and b.txt") + + paths := map[string]string{} + for _, f := range changes.Files { + paths[f.Path] = f.Status + } + assert.Equal(t, "created", paths["a.txt"]) + assert.Equal(t, "created", paths["b.txt"]) + assert.Greater(t, changes.LinesAdded, 0) + }) + + t.Run("CodeChanges", func(t *testing.T) { + changes, err := c.CodeChanges(wtDir) + require.NoError(t, err) + assert.NotNil(t, changes) + assert.GreaterOrEqual(t, changes.FilesCreated, 2) + }) + + t.Run("FindGitDirAndRoot", func(t *testing.T) { + // Save and restore cwd + origDir, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(origDir) }() + + require.NoError(t, os.Chdir(wtDir)) + + gitDir, repoRoot, err := FindGitDirAndRoot() + require.NoError(t, err) + + // Resolve symlinks for comparison (macOS /var -> /private/var) + resolvedWtDir, err := filepath.EvalSymlinks(wtDir) + require.NoError(t, err) + resolvedMainDir, err := filepath.EvalSymlinks(mainDir) + require.NoError(t, err) + + assert.Equal(t, resolvedWtDir, repoRoot, "repoRoot should be the worktree directory") + // gitDir should point to the worktree-specific git dir inside the main repo's .git/worktrees/ + assert.Contains(t, gitDir, "worktrees", "gitDir should be inside .git/worktrees/") + assert.True(t, strings.HasPrefix(gitDir, resolvedMainDir), "gitDir should be under the main repo") + + // The gitDir should actually exist + _, err = os.Stat(gitDir) + assert.NoError(t, err, "gitDir should exist on disk") + }) +} + +func TestGoGitClient_SnapshotWorktree(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "keep.txt"), []byte("hello\n"), 0600)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "sub"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "sub", "nested.go"), []byte("package sub\n"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(root, ".gitignore"), []byte("ignored.txt\nbuild/\n"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(root, "ignored.txt"), []byte("secret\n"), 0600)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "build"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "build", "out.bin"), []byte("artifact\n"), 0600)) + // A .git directory must always be skipped, even without a real repo. + require.NoError(t, os.MkdirAll(filepath.Join(root, ".git"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(root, ".git", "config"), []byte("[core]\n"), 0600)) + + c := NewGoGitClient() + sig, err := c.SnapshotWorktree(root) + require.NoError(t, err) + + // Non-ignored files present (forward-slash keys), ignored + .git excluded. + assert.Contains(t, sig, "keep.txt") + assert.Contains(t, sig, "sub/nested.go") + assert.Contains(t, sig, ".gitignore") + assert.NotContains(t, sig, "ignored.txt") + assert.NotContains(t, sig, "build/out.bin") + assert.NotContains(t, sig, ".git/config") + + // Modifying a file changes its signature; adding one adds a key. + require.NoError(t, os.WriteFile(filepath.Join(root, "keep.txt"), []byte("hello world\n"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(root, "new.txt"), []byte("new\n"), 0600)) + sig2, err := c.SnapshotWorktree(root) + require.NoError(t, err) + assert.NotEqual(t, sig["keep.txt"], sig2["keep.txt"]) + assert.Contains(t, sig2, "new.txt") +} + +func TestGoGitClient_SnapshotWorktree_TrackedIgnoredFile(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git binary not found") + } + root := t.TempDir() + run := func(args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = root + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, out) + } + run("init", "-b", "main") + run("config", "user.email", "t@t.com") + run("config", "user.name", "t") + + // *.gen is ignored, but one .gen file is force-added and tracked. + require.NoError(t, os.WriteFile(filepath.Join(root, ".gitignore"), []byte("*.gen\n"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(root, "tracked.gen"), []byte("tracked\n"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(root, "untracked.gen"), []byte("untracked\n"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(root, "normal.txt"), []byte("normal\n"), 0600)) + run("add", "-f", "tracked.gen") + run("add", "normal.txt", ".gitignore") + run("commit", "-m", "init") + + sig, err := NewGoGitClient().SnapshotWorktree(root) + require.NoError(t, err) + + assert.Contains(t, sig, "tracked.gen", "force-added tracked file must be snapshotted despite matching .gitignore") + assert.Contains(t, sig, "normal.txt") + assert.NotContains(t, sig, "untracked.gen", "untracked ignored file must be excluded") +} diff --git a/app/cli/internal/trace/git/merge_branch_test.go b/app/cli/internal/trace/git/merge_branch_test.go new file mode 100644 index 000000000..66fe8be50 --- /dev/null +++ b/app/cli/internal/trace/git/merge_branch_test.go @@ -0,0 +1,182 @@ +package git + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// branchedRepo holds the directory and commit SHAs produced by +// setupBranchedRepo, in chronological order. +type branchedRepo struct { + dir string + initialSHA string + featureA string + featureB string + sideSHA string + mergeSHA string +} + +// setupBranchedRepo creates a repo with: +// - main branch with one initial commit +// - origin/main ref tracking that commit +// - feature branch with two additional commits, then a merge of a side branch +func setupBranchedRepo(t *testing.T) branchedRepo { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git binary not found") + } + + dir := t.TempDir() + run := func(args ...string) string { + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, string(out)) + + return string(out) + } + + run("init", "-b", "main") + run("config", "user.email", "test@test.com") + run("config", "user.name", "Test") + run("config", "commit.gpgsign", "false") + + require.NoError(t, os.WriteFile(filepath.Join(dir, "init.txt"), []byte("init"), 0600)) + run("add", "init.txt") + run("commit", "-m", "initial") + initialSHA := strings.TrimSpace(run("rev-parse", "HEAD")) + + // Make origin/main point at the initial commit so detectBaseBranchGoGit finds it. + run("update-ref", "refs/remotes/origin/main", "HEAD") + + // Feature branch with two commits. + run("checkout", "-b", "feature") + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hello"), 0600)) + run("add", "a.txt") + run("commit", "-m", "feature a") + featureA := strings.TrimSpace(run("rev-parse", "HEAD")) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "b.txt"), []byte("world"), 0600)) + run("add", "b.txt") + run("commit", "-m", "feature b") + featureB := strings.TrimSpace(run("rev-parse", "HEAD")) + + // Side branch off main. + run("branch", "side", initialSHA) + run("checkout", "side") + require.NoError(t, os.WriteFile(filepath.Join(dir, "c.txt"), []byte("side"), 0600)) + run("add", "c.txt") + run("commit", "-m", "side commit") + sideSHA := strings.TrimSpace(run("rev-parse", "HEAD")) + + // Merge side into feature with a real merge commit. + run("checkout", "feature") + run("merge", "--no-ff", "-m", "merge side", "side") + mergeSHA := strings.TrimSpace(run("rev-parse", "HEAD")) + + return branchedRepo{ + dir: dir, + initialSHA: initialSHA, + featureA: featureA, + featureB: featureB, + sideSHA: sideSHA, + mergeSHA: mergeSHA, + } +} + +func TestGoGitClient_IsMergeCommit(t *testing.T) { + repo := setupBranchedRepo(t) + c := NewGoGitClient() + + t.Run("regular commit", func(t *testing.T) { + isMerge, err := c.IsMergeCommit(repo.dir, repo.featureA) + require.NoError(t, err) + assert.False(t, isMerge) + }) + + t.Run("merge commit", func(t *testing.T) { + isMerge, err := c.IsMergeCommit(repo.dir, repo.mergeSHA) + require.NoError(t, err) + assert.True(t, isMerge) + }) + + t.Run("unknown sha", func(t *testing.T) { + _, err := c.IsMergeCommit(repo.dir, "0000000000000000000000000000000000000000") + assert.Error(t, err) + }) +} + +func TestGoGitClient_BranchCommitSHAs(t *testing.T) { + repo := setupBranchedRepo(t) + c := NewGoGitClient() + + shas, err := c.BranchCommitSHAs(repo.dir) + require.NoError(t, err) + + // Set form: feature branch ahead of origin/main is repo.featureA, repo.featureB, + // repo.sideSHA, repo.mergeSHA. The merge base (initial) itself is excluded. + got := map[string]bool{} + for _, sha := range shas { + got[sha] = true + } + + assert.True(t, got[repo.featureA], "repo.featureA should be in branch SHAs") + assert.True(t, got[repo.featureB], "repo.featureB should be in branch SHAs") + assert.True(t, got[repo.sideSHA], "repo.sideSHA should be in branch SHAs (reachable via merge)") + assert.True(t, got[repo.mergeSHA], "repo.mergeSHA should be in branch SHAs") + assert.False(t, got[repo.initialSHA], "merge base (initial) should be excluded") +} + +func TestExecClient_IsMergeCommit(t *testing.T) { + repo := setupBranchedRepo(t) + c := NewExecClient() + + t.Run("regular commit", func(t *testing.T) { + isMerge, err := c.IsMergeCommit(repo.dir, repo.featureA) + require.NoError(t, err) + assert.False(t, isMerge) + }) + + t.Run("merge commit", func(t *testing.T) { + isMerge, err := c.IsMergeCommit(repo.dir, repo.mergeSHA) + require.NoError(t, err) + assert.True(t, isMerge) + }) +} + +func TestIsMergeInProgress(t *testing.T) { + t.Run("no merge head present", func(t *testing.T) { + assert.False(t, IsMergeInProgress(t.TempDir())) + }) + + t.Run("merge head present", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "MERGE_HEAD"), []byte("abc\n"), 0600)) + assert.True(t, IsMergeInProgress(dir)) + }) +} + +func TestExecClient_BranchCommitSHAs(t *testing.T) { + repo := setupBranchedRepo(t) + c := NewExecClient() + + shas, err := c.BranchCommitSHAs(repo.dir) + require.NoError(t, err) + + got := map[string]bool{} + for _, sha := range shas { + got[sha] = true + } + + assert.True(t, got[repo.featureA]) + assert.True(t, got[repo.featureB]) + assert.True(t, got[repo.sideSHA]) + assert.True(t, got[repo.mergeSHA]) + assert.False(t, got[repo.initialSHA]) +} diff --git a/app/cli/internal/trace/git/repo.go b/app/cli/internal/trace/git/repo.go new file mode 100644 index 000000000..c5d5fc3b6 --- /dev/null +++ b/app/cli/internal/trace/git/repo.go @@ -0,0 +1,104 @@ +package git + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +// ErrNotARepository reports that no .git was found from cwd up to root. It is +// the only error callers should read as "fall back to non-git mode": every +// other failure (unreadable cwd, malformed .git file) means a repository is +// there but broken, and must be surfaced. +var ErrNotARepository = errors.New("not a git repository (or any parent up to root)") + +// FindGitDir returns the path to the .git directory for the current repository. +func FindGitDir() (string, error) { + gitDir, _, err := findGitDirAndRoot() + return gitDir, err +} + +// RepoRoot returns the top-level directory of the current git repository. +func RepoRoot() (string, error) { + _, root, err := findGitDirAndRoot() + return root, err +} + +// FindGitDirAndRoot returns both the .git directory and repo root. +func FindGitDirAndRoot() (gitDir, repoRoot string, err error) { + return findGitDirAndRoot() +} + +// findGitDirAndRoot walks up from cwd to locate .git, avoiding go-git's strict +// config validation which fails on repos with invalid branch config. +func findGitDirAndRoot() (string, string, error) { + cwd, err := os.Getwd() + if err != nil { + return "", "", fmt.Errorf("get working directory: %w", err) + } + + dir := cwd + for { + dotGit := filepath.Join(dir, ".git") + fi, err := os.Lstat(dotGit) + if err == nil { + if fi.IsDir() { + return dotGit, dir, nil + } + + // .git is a file (worktree) — parse "gitdir: " + gitDir, err := resolveGitFile(dotGit) + if err != nil { + return "", "", fmt.Errorf("resolve .git file: %w", err) + } + + return gitDir, dir, nil + } + + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + + return "", "", ErrNotARepository +} + +// IsMergeInProgress reports whether a merge is in progress in the given .git +// directory. Detected via the presence of the MERGE_HEAD file, which git +// creates when a `git merge` cannot fast-forward and stops to let the user +// produce the merge commit. +func IsMergeInProgress(gitDir string) bool { + _, err := os.Stat(filepath.Join(gitDir, "MERGE_HEAD")) + + return err == nil +} + +// resolveGitFile reads a .git file (used in worktrees) and resolves +// the gitdir path it points to. +func resolveGitFile(dotGitFile string) (string, error) { + data, err := os.ReadFile(dotGitFile) + if err != nil { + return "", fmt.Errorf("read .git file: %w", err) + } + + line := strings.TrimSpace(string(data)) + if !strings.HasPrefix(line, "gitdir: ") { + return "", fmt.Errorf("unexpected .git file content: %s", line) + } + + gitDir := strings.TrimPrefix(line, "gitdir: ") + if !filepath.IsAbs(gitDir) { + gitDir = filepath.Join(filepath.Dir(dotGitFile), gitDir) + } + + gitDir = filepath.Clean(gitDir) + if _, err := os.Stat(gitDir); err != nil { + return "", fmt.Errorf("gitdir path does not exist: %w", err) + } + + return gitDir, nil +} diff --git a/app/cli/internal/trace/git/repo_test.go b/app/cli/internal/trace/git/repo_test.go new file mode 100644 index 000000000..fad8373f7 --- /dev/null +++ b/app/cli/internal/trace/git/repo_test.go @@ -0,0 +1,197 @@ +package git + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/hooks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFindGitDir(t *testing.T) { + gitDir, err := FindGitDir() + require.NoError(t, err) + assert.NotEmpty(t, gitDir) + assert.True(t, filepath.IsAbs(gitDir), "should return absolute path") + + // The .git directory should exist + _, err = os.Stat(gitDir) + assert.NoError(t, err) +} + +func TestRepoRoot(t *testing.T) { + root, err := RepoRoot() + require.NoError(t, err) + assert.NotEmpty(t, root) + assert.True(t, filepath.IsAbs(root), "should return absolute path") + + // The repo root should contain a .git dir + _, err = os.Stat(filepath.Join(root, ".git")) + assert.NoError(t, err) +} + +func TestFindGitDirAndRoot(t *testing.T) { + gitDir, repoRoot, err := FindGitDirAndRoot() + require.NoError(t, err) + + assert.NotEmpty(t, gitDir) + assert.NotEmpty(t, repoRoot) + // gitDir may differ from repoRoot/.git in worktree setups + _, err = os.Stat(gitDir) + assert.NoError(t, err, "gitDir should exist") + _, err = os.Stat(repoRoot) + assert.NoError(t, err, "repoRoot should exist") +} + +func TestFindGitDirFromNonGitDir(t *testing.T) { + // Save and restore cwd + origDir, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(origDir) }() + + tmpDir := t.TempDir() + require.NoError(t, os.Chdir(tmpDir)) + + _, err = FindGitDir() + assert.Error(t, err) +} + +func TestFindGitDirAndRootWithCorruptConfig(t *testing.T) { + origDir, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(origDir) }() + + repoDir := t.TempDir() + gitDir := filepath.Join(repoDir, ".git") + require.NoError(t, os.MkdirAll(gitDir, 0o755)) + + // Invalid branch merge ref triggers go-git's strict config validation + configContent := `[core] + repositoryformatversion = 0 +[branch "broken"] + merge = not-a-valid-ref +` + require.NoError(t, os.WriteFile(filepath.Join(gitDir, "config"), []byte(configContent), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(gitDir, "HEAD"), []byte("ref: refs/heads/main\n"), 0o600)) + + require.NoError(t, os.Chdir(repoDir)) + + foundGitDir, foundRoot, err := FindGitDirAndRoot() + require.NoError(t, err, "should succeed despite corrupt branch config") + + // Resolve symlinks for macOS where /var -> /private/var + expectedGitDir, err := filepath.EvalSymlinks(gitDir) + require.NoError(t, err) + expectedRoot, err := filepath.EvalSymlinks(repoDir) + require.NoError(t, err) + assert.Equal(t, expectedGitDir, foundGitDir) + assert.Equal(t, expectedRoot, foundRoot) +} + +func TestResolveGitFile(t *testing.T) { + // Create a fake worktree setup: .git file pointing to a gitdir + tmpDir := t.TempDir() + realGitDir := filepath.Join(tmpDir, "real-gitdir") + require.NoError(t, os.MkdirAll(realGitDir, 0o755)) + + dotGitFile := filepath.Join(tmpDir, ".git") + require.NoError(t, os.WriteFile(dotGitFile, []byte("gitdir: "+realGitDir+"\n"), 0o600)) + + resolved, err := resolveGitFile(dotGitFile) + require.NoError(t, err) + assert.Equal(t, realGitDir, resolved) +} + +func TestResolveGitFileRelativePath(t *testing.T) { + tmpDir := t.TempDir() + realGitDir := filepath.Join(tmpDir, "worktrees", "my-branch") + require.NoError(t, os.MkdirAll(realGitDir, 0o755)) + + dotGitFile := filepath.Join(tmpDir, ".git") + require.NoError(t, os.WriteFile(dotGitFile, []byte("gitdir: worktrees/my-branch\n"), 0o600)) + + resolved, err := resolveGitFile(dotGitFile) + require.NoError(t, err) + assert.Equal(t, realGitDir, resolved) +} + +// TestFindGitDirAndRootBrokenGitFileIsNotNotFound guards the trace run +// fallback: only ErrNotARepository may be read as "no repository here", so a +// present-but-unusable .git surfaces instead of silently disabling git +// attribution. +func TestFindGitDirAndRootBrokenGitFileIsNotNotFound(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".git"), []byte("garbage\n"), 0o600)) + t.Chdir(dir) + + _, _, err := FindGitDirAndRoot() + require.Error(t, err) + assert.NotErrorIs(t, err, ErrNotARepository) +} + +func TestResolveGitFileInvalidContent(t *testing.T) { + tmpDir := t.TempDir() + dotGitFile := filepath.Join(tmpDir, ".git") + require.NoError(t, os.WriteFile(dotGitFile, []byte("not a gitdir pointer\n"), 0o600)) + + _, err := resolveGitFile(dotGitFile) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unexpected .git file content") +} + +// TestHooksInstallFromWorktree is the integration counterpart to the +// synthetic worktree test in cli/internal/trace/hooks: it sets up a real +// linked worktree via the git binary, then verifies that hooks.Install +// given the worktree-private gitDir from FindGitDirAndRoot lands the +// hooks under the main repo's .git/hooks (the only place git reads them). +func TestHooksInstallFromWorktree(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git binary not found, skipping worktree test") + } + + mainDir := t.TempDir() + wtDir := filepath.Join(t.TempDir(), "linked-wt") + + run := func(args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = mainDir + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v failed: %s", args, string(out)) + } + + run("init", "-b", "main") + run("config", "user.email", "test@test.com") + run("config", "user.name", "Test") + run("commit", "--allow-empty", "-m", "init") + run("worktree", "add", wtDir, "-b", "feature") + + origDir, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(origDir) }() + require.NoError(t, os.Chdir(wtDir)) + + worktreeGitDir, _, err := FindGitDirAndRoot() + require.NoError(t, err) + + hooksDir, err := hooks.Install(worktreeGitDir, false) + require.NoError(t, err) + + resolvedMainDir, err := filepath.EvalSymlinks(mainDir) + require.NoError(t, err) + expectedHooksDir := filepath.Join(resolvedMainDir, ".git", "hooks") + assert.Equal(t, expectedHooksDir, hooksDir, + "Install should resolve worktree gitDir to the main .git/hooks") + + for _, name := range []string{"commit-msg", "post-commit", "pre-push"} { + content, err := os.ReadFile(filepath.Join(expectedHooksDir, name)) + require.NoError(t, err, "hook %s should exist in main .git/hooks", name) + assert.Contains(t, string(content), hooks.HookMarker) + + _, err = os.Stat(filepath.Join(worktreeGitDir, "hooks", name)) + assert.True(t, os.IsNotExist(err), + "hook %s should NOT be written to worktree-private hooks dir", name) + } +} diff --git a/app/cli/internal/trace/hooks/hooks.go b/app/cli/internal/trace/hooks/hooks.go new file mode 100644 index 000000000..668c9edb0 --- /dev/null +++ b/app/cli/internal/trace/hooks/hooks.go @@ -0,0 +1,254 @@ +package hooks + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +const ( + // HookMarker is the comment line embedded in managed hook scripts. + HookMarker = "# chainloop-trace-managed" + hookBackupSuffix = ".chainloop-backup" +) + +// managedHooks defines every git hook that Install creates. +// Order matters: hooks are installed in this order. +var managedHooks = []hookDef{ + {"commit-msg", true}, + {"post-commit", false}, + {"pre-push", false}, +} + +type hookDef struct { + // name is both the git hook filename and the "chainloop trace hook git " subcommand. + name string + passArgs bool +} + +// selectedHooks returns managedHooks with pre-push filtered out when +// skipPrePush is true. +func selectedHooks(skipPrePush bool) []hookDef { + if !skipPrePush { + return managedHooks + } + out := make([]hookDef, 0, len(managedHooks)) + for _, h := range managedHooks { + if h.name == "pre-push" { + continue + } + out = append(out, h) + } + + return out +} + +// IsInstalled reports whether the managed hooks are present and carry +// the chainloop marker. Pass skipPrePush=true to exclude pre-push from +// the check, matching what trace run installs. +// +// gitDir may point at a per-worktree gitdir; the common hooks dir is +// resolved internally (see resolveHooksDir). +func IsInstalled(gitDir string, skipPrePush bool) bool { + hooksDir, err := resolveHooksDir(gitDir) + if err != nil { + return false + } + + for _, h := range selectedHooks(skipPrePush) { + content, err := os.ReadFile(filepath.Join(hooksDir, h.name)) + if err != nil || !strings.Contains(string(content), HookMarker) { + return false + } + } + + return true +} + +// hookContent generates a hook script for the given command. +// When passArgs is true, "$@" is appended so the hook receives its positional arguments +// (e.g. commit-msg receives the message file path as $1). +func hookContent(hookCmd string, passArgs bool) string { + args := "" + if passArgs { + args = ` "$@"` + } + + // The trailing exit 0 keeps tracing from ever blocking a commit or push: + // without it a missing chainloop binary makes sh exit 127 and git aborts. + return fmt.Sprintf("#!/bin/sh\n%s\nchainloop trace hook git %s%s\nexit 0\n", HookMarker, hookCmd, args) +} + +// hookContentWithChain generates a hook script that chains to a backup. +func hookContentWithChain(hookCmd, backupPath string, passArgs bool) string { + args := "" + if passArgs { + args = ` "$@"` + } + + // exec makes the chained hook's status the hook's status. The trailing + // exit 0 covers a backup that lost its executable bit, where the && list + // would otherwise fail the hook (see hookContent). + return fmt.Sprintf("#!/bin/sh\n%s\nchainloop trace hook git %s%s\n[ -x \"%s\" ] && exec \"%s\" \"$@\"\nexit 0\n", + HookMarker, hookCmd, args, backupPath, backupPath) +} + +// Install installs git hooks for trace automation. gitDir may be either +// the main repo's .git or a per-worktree gitdir (.git/worktrees/); +// the resolved hooks directory is returned so callers can log it. +// +// Hooks are always written to the *common* hooks dir, since git only reads +// hooks/ from the shared .git/ even when invoked from a linked worktree. +// +// Pass skipPrePush=true to install only commit-msg and post-commit; used +// by trace run, which drives the attestation push itself. +func Install(gitDir string, skipPrePush bool) (string, error) { + hooksDir, err := resolveHooksDir(gitDir) + if err != nil { + return "", err + } + if err := os.MkdirAll(hooksDir, 0755); err != nil { + return "", fmt.Errorf("create hooks directory: %w", err) + } + + for _, h := range selectedHooks(skipPrePush) { + hookPath := filepath.Join(hooksDir, h.name) + backupPath := hookPath + hookBackupSuffix + + if err := installSingleHook(hookPath, backupPath, h.name, h.passArgs); err != nil { + return "", fmt.Errorf("install %s hook: %w", h.name, err) + } + } + + return hooksDir, nil +} + +func installSingleHook(hookPath, backupPath, hookCmd string, passArgs bool) error { + existing, err := os.ReadFile(hookPath) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("read existing hook: %w", err) + } + + // Lstat: a dangling symlink still occupies backupPath and must not be + // renamed over. + _, statErr := os.Lstat(backupPath) + if statErr != nil && !errors.Is(statErr, os.ErrNotExist) { + return fmt.Errorf("check hook backup: %w", statErr) + } + backupExists := statErr == nil + + if err == nil && !strings.Contains(string(existing), HookMarker) { + // Foreign hook, back it up and chain. An existing backup is the user's + // original hook, saved by an install whose uninstall never restored it, + // so it must never be overwritten. + if backupExists { + return fmt.Errorf("backup %s already exists; restore or remove it before installing", backupPath) + } + if err := os.Rename(hookPath, backupPath); err != nil { + return fmt.Errorf("backup existing hook: %w", err) + } + backupExists = true + } + + // Preserve chaining if a backup exists (idempotent reinstall) + if backupExists { + //nolint:gosec // git only runs hooks that are executable, so 0600 would silently disable them + return os.WriteFile(hookPath, []byte(hookContentWithChain(hookCmd, backupPath, passArgs)), 0755) + } + + //nolint:gosec // git only runs hooks that are executable, so 0600 would silently disable them + return os.WriteFile(hookPath, []byte(hookContent(hookCmd, passArgs)), 0755) +} + +// Uninstall removes git hooks installed by trace. gitDir semantics match +// Install. +func Uninstall(gitDir string) (string, error) { + hooksDir, err := resolveHooksDir(gitDir) + if err != nil { + return "", err + } + + for _, h := range managedHooks { + hookPath := filepath.Join(hooksDir, h.name) + backupPath := hookPath + hookBackupSuffix + + if err := uninstallSingleHook(hookPath, backupPath); err != nil { + return "", fmt.Errorf("uninstall %s hook: %w", h.name, err) + } + } + + return hooksDir, nil +} + +// resolveHooksDir returns the hooks directory git will actually read from, +// given a (possibly worktree-private) gitDir. In a linked worktree, gitDir +// is .git/worktrees/, but git classifies hooks/ as a common path: +// hooks placed under the per-worktree dir are silently ignored. The +// commondir pointer file inside the worktree-private dir tells us where +// the shared .git lives. +func resolveHooksDir(gitDir string) (string, error) { + commonDir, err := resolveCommonDir(gitDir) + if err != nil { + return "", err + } + + return filepath.Join(commonDir, "hooks"), nil +} + +// resolveCommonDir reads the commondir pointer file that git writes inside +// linked worktrees and returns the resolved common gitdir. If the file is +// absent (main checkout), gitDir is returned unchanged. +func resolveCommonDir(gitDir string) (string, error) { + commonDirFile := filepath.Join(gitDir, "commondir") + data, err := os.ReadFile(commonDirFile) + if errors.Is(err, os.ErrNotExist) { + return gitDir, nil + } + if err != nil { + return "", fmt.Errorf("read commondir file: %w", err) + } + + commonDir := strings.TrimSpace(string(data)) + if commonDir == "" { + return "", fmt.Errorf("commondir file is empty: %s", commonDirFile) + } + + if !filepath.IsAbs(commonDir) { + commonDir = filepath.Join(gitDir, commonDir) + } + + commonDir = filepath.Clean(commonDir) + if _, err := os.Stat(commonDir); err != nil { + return "", fmt.Errorf("commondir path does not exist: %w", err) + } + + return commonDir, nil +} + +func uninstallSingleHook(hookPath, backupPath string) error { + content, err := os.ReadFile(hookPath) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("read hook: %w", err) + } + + if !strings.Contains(string(content), HookMarker) { + // Not our hook, leave it alone + return nil + } + + if err := os.Remove(hookPath); err != nil { + return err + } + + // Restore backup if it exists + if err := os.Rename(backupPath, hookPath); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + + return nil +} diff --git a/app/cli/internal/trace/hooks/hooks_test.go b/app/cli/internal/trace/hooks/hooks_test.go new file mode 100644 index 000000000..21cbae29d --- /dev/null +++ b/app/cli/internal/trace/hooks/hooks_test.go @@ -0,0 +1,306 @@ +package hooks + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInstall(t *testing.T) { + t.Run("installs hooks in empty directory", func(t *testing.T) { + gitDir := t.TempDir() + + _, err := Install(gitDir, false) + require.NoError(t, err) + + for _, name := range []string{"commit-msg", "post-commit", "pre-push"} { + hookPath := filepath.Join(gitDir, "hooks", name) + content, err := os.ReadFile(hookPath) + require.NoError(t, err) + assert.Contains(t, string(content), HookMarker) + assert.Contains(t, string(content), "chainloop trace hook git "+name) + + info, err := os.Stat(hookPath) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0755), info.Mode().Perm()) + } + + // commit-msg must forward arguments so the message file path reaches the handler + commitMsgContent, err := os.ReadFile(filepath.Join(gitDir, "hooks", "commit-msg")) + require.NoError(t, err) + assert.Contains(t, string(commitMsgContent), `"$@"`) + + // post-commit and pre-push must NOT forward arguments + for _, name := range []string{"post-commit", "pre-push"} { + content, err := os.ReadFile(filepath.Join(gitDir, "hooks", name)) + require.NoError(t, err) + assert.NotContains(t, string(content), `"$@"`) + } + }) + + t.Run("backs up existing foreign hooks", func(t *testing.T) { + gitDir := t.TempDir() + hooksDir := filepath.Join(gitDir, "hooks") + require.NoError(t, os.MkdirAll(hooksDir, 0755)) + + foreignContent := "#!/bin/sh\necho existing hook\n" + require.NoError(t, os.WriteFile(filepath.Join(hooksDir, "post-commit"), []byte(foreignContent), 0600)) + + _, err := Install(gitDir, false) + require.NoError(t, err) + + // Backup should exist + backupContent, err := os.ReadFile(filepath.Join(hooksDir, "post-commit"+hookBackupSuffix)) + require.NoError(t, err) + assert.Equal(t, foreignContent, string(backupContent)) + + // New hook should chain to backup + hookContent, err := os.ReadFile(filepath.Join(hooksDir, "post-commit")) + require.NoError(t, err) + assert.Contains(t, string(hookContent), HookMarker) + assert.Contains(t, string(hookContent), hookBackupSuffix) + }) + + t.Run("overwrites existing chainloop hooks", func(t *testing.T) { + gitDir := t.TempDir() + hooksDir := filepath.Join(gitDir, "hooks") + require.NoError(t, os.MkdirAll(hooksDir, 0755)) + + // Write a chainloop hook + require.NoError(t, os.WriteFile(filepath.Join(hooksDir, "pre-push"), []byte("#!/bin/sh\n"+HookMarker+"\nold version"), 0600)) + + _, err := Install(gitDir, false) + require.NoError(t, err) + + content, err := os.ReadFile(filepath.Join(hooksDir, "pre-push")) + require.NoError(t, err) + assert.Contains(t, string(content), HookMarker) + assert.NotContains(t, string(content), "old version") + + // No backup should exist + _, err = os.Stat(filepath.Join(hooksDir, "pre-push"+hookBackupSuffix)) + assert.True(t, os.IsNotExist(err)) + }) + + t.Run("writes to common dir when given a worktree gitdir", func(t *testing.T) { + // Simulate a worktree layout: main/.git is the common dir, and + // main/.git/worktrees/wt is the per-worktree gitdir with a + // commondir pointer back to ../... + tmpDir := t.TempDir() + commonDir := filepath.Join(tmpDir, "main", ".git") + require.NoError(t, os.MkdirAll(commonDir, 0o755)) + + worktreeGitDir := filepath.Join(commonDir, "worktrees", "wt") + require.NoError(t, os.MkdirAll(worktreeGitDir, 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(worktreeGitDir, "commondir"), + []byte("../..\n"), + 0o600, + )) + + hooksDir, err := Install(worktreeGitDir, false) + require.NoError(t, err) + assert.Equal(t, filepath.Join(commonDir, "hooks"), hooksDir) + + // Hooks must land under the common dir, not the worktree-private dir. + for _, name := range []string{"commit-msg", "post-commit", "pre-push"} { + content, err := os.ReadFile(filepath.Join(commonDir, "hooks", name)) + require.NoError(t, err, "hook %s should exist in common .git/hooks", name) + assert.Contains(t, string(content), HookMarker) + + _, err = os.Stat(filepath.Join(worktreeGitDir, "hooks", name)) + assert.True(t, os.IsNotExist(err), + "hook %s must NOT be written to worktree-private hooks dir", name) + } + }) +} + +func TestInstallSkipPrePush(t *testing.T) { + t.Run("installs commit-msg and post-commit only", func(t *testing.T) { + gitDir := t.TempDir() + + _, err := Install(gitDir, true) + require.NoError(t, err) + + for _, name := range []string{"commit-msg", "post-commit"} { + content, err := os.ReadFile(filepath.Join(gitDir, "hooks", name)) + require.NoError(t, err, "hook %s should be installed", name) + assert.Contains(t, string(content), HookMarker) + } + + _, err = os.Stat(filepath.Join(gitDir, "hooks", "pre-push")) + assert.True(t, os.IsNotExist(err), "pre-push must not be installed when skipPrePush=true") + }) + + t.Run("IsInstalled with skipPrePush ignores missing pre-push", func(t *testing.T) { + gitDir := t.TempDir() + + _, err := Install(gitDir, true) + require.NoError(t, err) + + assert.True(t, IsInstalled(gitDir, true), "should be installed when only checking the trace-run subset") + assert.False(t, IsInstalled(gitDir, false), "default check requires pre-push and should fail") + }) +} + +func TestInstallRefusesToClobberBackup(t *testing.T) { + gitDir := t.TempDir() + hooksDir := filepath.Join(gitDir, "hooks") + require.NoError(t, os.MkdirAll(hooksDir, 0755)) + + // A leftover backup (crashed uninstall) plus a foreign hook: the backup is + // the user's original and must survive. + original := "#!/bin/sh\necho original\n" + require.NoError(t, os.WriteFile(filepath.Join(hooksDir, "post-commit"+hookBackupSuffix), []byte(original), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(hooksDir, "post-commit"), []byte("#!/bin/sh\necho newer\n"), 0600)) + + _, err := Install(gitDir, false) + require.Error(t, err) + + content, err := os.ReadFile(filepath.Join(hooksDir, "post-commit"+hookBackupSuffix)) + require.NoError(t, err) + assert.Equal(t, original, string(content)) +} + +// TestHookScriptsExitZero runs each generated hook with an empty PATH (no +// chainloop binary, as in GUI git clients) and asserts it still succeeds: +// a non-zero hook aborts the user's commit. +func TestHookScriptsExitZero(t *testing.T) { + gitDir := t.TempDir() + hooksDir := filepath.Join(gitDir, "hooks") + require.NoError(t, os.MkdirAll(hooksDir, 0755)) + + // post-commit gets a foreign hook so the chained variant is covered too, + // with its executable bit cleared to exercise the failing `&&` list. + require.NoError(t, os.WriteFile(filepath.Join(hooksDir, "post-commit"), []byte("#!/bin/sh\nexit 0\n"), 0600)) + + _, err := Install(gitDir, false) + require.NoError(t, err) + + for _, name := range []string{"commit-msg", "post-commit", "pre-push"} { + //nolint:gosec // the hook path is derived from t.TempDir(), not from user input + cmd := exec.Command("/bin/sh", filepath.Join(hooksDir, name), "msgfile") + cmd.Env = []string{"PATH="} + out, err := cmd.CombinedOutput() + assert.NoError(t, err, "hook %s must exit 0 without chainloop on PATH: %s", name, out) + } +} + +func TestUninstall(t *testing.T) { + t.Run("removes chainloop hooks", func(t *testing.T) { + gitDir := t.TempDir() + _, err := Install(gitDir, false) + require.NoError(t, err) + + _, err = Uninstall(gitDir) + require.NoError(t, err) + + for _, name := range []string{"commit-msg", "post-commit", "pre-push"} { + _, err := os.Stat(filepath.Join(gitDir, "hooks", name)) + assert.True(t, os.IsNotExist(err)) + } + }) + + t.Run("restores backup hooks", func(t *testing.T) { + gitDir := t.TempDir() + hooksDir := filepath.Join(gitDir, "hooks") + require.NoError(t, os.MkdirAll(hooksDir, 0755)) + + // Install foreign hook, then chainloop hook on top + foreignContent := "#!/bin/sh\necho foreign\n" + require.NoError(t, os.WriteFile(filepath.Join(hooksDir, "post-commit"), []byte(foreignContent), 0600)) + _, err := Install(gitDir, false) + require.NoError(t, err) + + // Uninstall + _, err = Uninstall(gitDir) + require.NoError(t, err) + + // Foreign hook should be restored + content, err := os.ReadFile(filepath.Join(hooksDir, "post-commit")) + require.NoError(t, err) + assert.Equal(t, foreignContent, string(content)) + }) + + t.Run("leaves foreign hooks alone", func(t *testing.T) { + gitDir := t.TempDir() + hooksDir := filepath.Join(gitDir, "hooks") + require.NoError(t, os.MkdirAll(hooksDir, 0755)) + + foreignContent := "#!/bin/sh\necho untouched\n" + require.NoError(t, os.WriteFile(filepath.Join(hooksDir, "pre-push"), []byte(foreignContent), 0600)) + + _, err := Uninstall(gitDir) + require.NoError(t, err) + + content, err := os.ReadFile(filepath.Join(hooksDir, "pre-push")) + require.NoError(t, err) + assert.Equal(t, foreignContent, string(content)) + }) + + t.Run("noop when no hooks exist", func(t *testing.T) { + gitDir := t.TempDir() + _, err := Uninstall(gitDir) + assert.NoError(t, err) + }) +} + +func TestResolveCommonDir(t *testing.T) { + t.Run("absent commondir file returns gitDir unchanged", func(t *testing.T) { + gitDir := t.TempDir() + + resolved, err := resolveCommonDir(gitDir) + require.NoError(t, err) + assert.Equal(t, gitDir, resolved) + }) + + t.Run("absolute commondir path", func(t *testing.T) { + tmpDir := t.TempDir() + commonDir := filepath.Join(tmpDir, "main", ".git") + require.NoError(t, os.MkdirAll(commonDir, 0o755)) + + worktreeGitDir := filepath.Join(commonDir, "worktrees", "wt") + require.NoError(t, os.MkdirAll(worktreeGitDir, 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(worktreeGitDir, "commondir"), + []byte(commonDir+"\n"), + 0o600, + )) + + resolved, err := resolveCommonDir(worktreeGitDir) + require.NoError(t, err) + assert.Equal(t, commonDir, resolved) + }) + + t.Run("relative commondir path", func(t *testing.T) { + tmpDir := t.TempDir() + commonDir := filepath.Join(tmpDir, "main", ".git") + require.NoError(t, os.MkdirAll(commonDir, 0o755)) + + // git writes "../.." here: from .git/worktrees/ up to main .git. + worktreeGitDir := filepath.Join(commonDir, "worktrees", "wt") + require.NoError(t, os.MkdirAll(worktreeGitDir, 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(worktreeGitDir, "commondir"), + []byte("../..\n"), + 0o600, + )) + + resolved, err := resolveCommonDir(worktreeGitDir) + require.NoError(t, err) + assert.Equal(t, commonDir, resolved) + }) + + t.Run("empty commondir file is rejected", func(t *testing.T) { + gitDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(gitDir, "commondir"), []byte("\n"), 0o600)) + + _, err := resolveCommonDir(gitDir) + require.Error(t, err) + assert.Contains(t, err.Error(), "commondir file is empty") + }) +} diff --git a/app/cli/internal/trace/opencode/export_integration_test.go b/app/cli/internal/trace/opencode/export_integration_test.go new file mode 100644 index 000000000..5f4485deb --- /dev/null +++ b/app/cli/internal/trace/opencode/export_integration_test.go @@ -0,0 +1,56 @@ +//go:build integration + +package opencode + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestRealExportViaFile verifies that CopySessionData captures the full +// export output via file redirect, avoiding the pipe truncation bug where +// `opencode export` (Node.js) doesn't flush stdout to pipes properly. +func TestRealExportViaFile(t *testing.T) { + const sessionID = "ses_0cbfb6771ffeqmW0YTMB4Sp0xN" + + store := state.NewGitStore(t.TempDir()) + provider := New() + err := provider.CopySessionData(store, "/repo", sessionID) + require.NoError(t, err) + + rawDir := store.RawSessionDir() + path := filepath.Join(rawDir, state.SanitizeID(sessionID)+".jsonl") + data, err := os.ReadFile(path) + require.NoError(t, err) + + // Must exceed the 64 KiB pipe truncation limit. + assert.Greater(t, len(data), 65536, "file redirect should capture full output") + t.Logf("Captured %d bytes via file redirect", len(data)) + + var export exportData + require.NoError(t, json.Unmarshal(data, &export)) + assert.Equal(t, sessionID, export.Info.ID) + assert.NotEmpty(t, export.Messages) + t.Logf("Session: %s, messages: %d", export.Info.Title, len(export.Messages)) + + // Verify it parses all the way through the provider. + result, err := provider.ParseSession(context.Background(), &trace.ParseOpts{ + SessionDir: rawDir, + SessionID: sessionID, + }) + require.NoError(t, err) + assert.Equal(t, "opencode", result.Data.Agent.Name) + assert.Equal(t, sessionID, result.Data.Session.ID) + assert.NotEmpty(t, result.Data.Conversation.TotalMessages) + t.Logf("Parsed: %d messages, %d tool invocations", + result.Data.Conversation.TotalMessages, + result.Data.ToolsUsed.TotalInvocations) +} diff --git a/app/cli/internal/trace/opencode/golden_test.go b/app/cli/internal/trace/opencode/golden_test.go new file mode 100644 index 000000000..da079717b --- /dev/null +++ b/app/cli/internal/trace/opencode/golden_test.go @@ -0,0 +1,48 @@ +package opencode + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestPluginGoldenFiles compares the generated plugin output against the +// committed golden files in testdata/. Run with UPDATE_GOLDEN=1 to +// regenerate them after an intentional template change: +// +// UPDATE_GOLDEN=1 go test ./app/cli/internal/trace/opencode/ -run TestPluginGoldenFiles +func TestPluginGoldenFiles(t *testing.T) { + p := New() + + cases := []struct { + name string + install func(string) error + golden string + }{ + {"full", p.InstallHooks, "testdata/plugin_full.ts"}, + {"tracerun", p.InstallHooksForTraceRun, "testdata/plugin_tracerun.ts"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + repoRoot := t.TempDir() + require.NoError(t, tc.install(repoRoot)) + + generated, err := os.ReadFile(filepath.Join(repoRoot, settingsFile)) + require.NoError(t, err) + + if os.Getenv("UPDATE_GOLDEN") == "1" { + require.NoError(t, os.WriteFile(tc.golden, generated, 0600)) + return + } + + expected, err := os.ReadFile(tc.golden) + require.NoError(t, err, "golden file missing; run UPDATE_GOLDEN=1 to generate") + assert.Equal(t, string(expected), string(generated), + "generated plugin does not match golden file %s; run UPDATE_GOLDEN=1 to update", tc.golden) + }) + } +} diff --git a/app/cli/internal/trace/opencode/hooks.go b/app/cli/internal/trace/opencode/hooks.go new file mode 100644 index 000000000..2a71e067d --- /dev/null +++ b/app/cli/internal/trace/opencode/hooks.go @@ -0,0 +1,278 @@ +package opencode + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "slices" + "strings" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" +) + +const ( + // settingsFile is the project-local opencode plugin file path. + settingsFile = ".opencode/plugins/chainloop-trace.ts" +) + +// fileWritingTools is the single source of truth for opencode tool names +// that modify files. The TypeScript plugin's array is generated from this +// slice (see {{FileWritingToolsArray}}) so the plugin and IsFileWritingTool +// can never drift. +var fileWritingTools = []string{"edit", "write", "apply_patch"} + +// commandTools are opencode tool names that run shell commands. Their file +// changes are captured via a before/after working-tree snapshot (they have no +// single file-path argument), so the plugin fires the pre/post-tool-use hooks +// for them without a file_path and the handler branches on the tool kind. +var commandTools = []string{"bash"} + +// bt is a single backtick, used to splice backticks into Go raw string literals. +const bt = "`" + +// pluginTemplate is the TypeScript plugin written to .opencode/plugins/chainloop-trace.ts. +// It fires chainloop trace hook subcommands on session lifecycle and tool events. +// The {{SessionEndBlock}} placeholder is replaced with the session.deleted handler +// for full install, or removed entirely for trace-run install. +// +// Go raw string literals cannot contain backticks, so the TypeScript template +// literal (Bun shell) lines are split at backtick boundaries and spliced with bt. +const pluginTemplate = `import type { Plugin } from "@opencode-ai/plugin" + +export const ChainloopTrace: Plugin = async ({ $ }) => { + const fileWritingTools = {{FileWritingToolsArray}} + const commandTools = {{CommandToolsArray}} + + function filePathsFromArgs(args: any): string[] { + if (args?.filePath) return [args.filePath] + if (args?.path) return [args.path] + if (args?.patchText) return parsePatchPaths(args.patchText) + return [] + } + + // parsePatchPaths extracts affected file paths from an apply_patch + // patchText payload. Each section starts with *** Add File:, *** Update + // File:, or *** Delete File: followed by the path. Paths are deduplicated + // while preserving first-seen order. + function parsePatchPaths(patchText: string): string[] { + const paths: string[] = [] + const seen = new Set() + const re = /^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm + let m + while ((m = re.exec(patchText)) !== null) { + const p = m[1].trim() + if (p && !seen.has(p)) { + seen.add(p) + paths.push(p) + } + } + return paths + } + + // fire-and-forget: tracing must never block tool execution. If chainloop + // is unavailable or errors, log to stderr and move on. + async function fire(event: string, payload: Record) { + const json = JSON.stringify(payload) + try { + await $` + bt + `echo ${json} | chainloop trace hook opencode ${event}` + bt + ` + } catch (err) { + console.error(` + bt + `chainloop-trace: ${event} hook failed: ${err}` + bt + `) + } + } + + return { + event: async ({ event }) => { + if (event.type === "session.created") { + const sessionID = event.properties?.info?.id ?? "" + await fire("session-start", { session_id: sessionID, hook_event_name: "session.created" }) + } +{{SessionEndBlock}} + }, + "tool.execute.before": async (input, output) => { + if (commandTools.includes(input.tool)) { + await fire("pre-tool-use", { + session_id: input.sessionID, + hook_event_name: "tool.execute.before", + tool_name: input.tool, + }) + return + } + if (!fileWritingTools.includes(input.tool)) return + for (const fp of filePathsFromArgs(output.args)) { + await fire("pre-tool-use", { + session_id: input.sessionID, + hook_event_name: "tool.execute.before", + tool_name: input.tool, + file_path: fp, + }) + } + }, + "tool.execute.after": async (input) => { + if (commandTools.includes(input.tool)) { + await fire("post-tool-use", { + session_id: input.sessionID, + hook_event_name: "tool.execute.after", + tool_name: input.tool, + }) + return + } + if (!fileWritingTools.includes(input.tool)) return + for (const fp of filePathsFromArgs(input.args)) { + await fire("post-tool-use", { + session_id: input.sessionID, + hook_event_name: "tool.execute.after", + tool_name: input.tool, + file_path: fp, + }) + } + }, + } +} +` + +// sessionDeletedHandler is the session.deleted block inserted for full install. +// session.idle fires at the end of every agent turn and would prematurely end +// the trace session; session.deleted fires only when the session is destroyed. +const sessionDeletedHandler = ` if (event.type === "session.deleted") { + const sessionID = event.properties?.info?.id ?? "" + await fire("session-end", { session_id: sessionID, hook_event_name: "session.deleted" }) + }` + +// SettingsFile returns the absolute path to the plugin file for the given repo. +func (p *Provider) SettingsFile(repoRoot string) string { + return filepath.Join(repoRoot, settingsFile) +} + +// InstallHooks writes the opencode plugin file with all hooks including session.deleted. +func (p *Provider) InstallHooks(repoRoot string) error { + return p.writePluginFile(repoRoot, true) +} + +// InstallHooksForTraceRun writes the plugin file without the session.deleted handler; +// trace run drives end-of-session itself. +func (p *Provider) InstallHooksForTraceRun(repoRoot string) error { + return p.writePluginFile(repoRoot, false) +} + +func (p *Provider) writePluginFile(repoRoot string, includeSessionEnd bool) error { + pluginPath := filepath.Join(repoRoot, settingsFile) + + content := pluginTemplate + content = strings.Replace(content, "{{FileWritingToolsArray}}", toolsArrayLiteral(fileWritingTools), 1) + content = strings.Replace(content, "{{CommandToolsArray}}", toolsArrayLiteral(commandTools), 1) + if includeSessionEnd { + content = strings.Replace(content, "{{SessionEndBlock}}", sessionDeletedHandler, 1) + } else { + content = strings.Replace(content, "{{SessionEndBlock}}\n", "", 1) + } + + if err := os.MkdirAll(filepath.Dir(pluginPath), 0755); err != nil { + return fmt.Errorf("create plugin directory: %w", err) + } + + if existing, err := os.ReadFile(pluginPath); err == nil && string(existing) == content { + return nil + } + + return os.WriteFile(pluginPath, []byte(content), 0600) +} + +// toolsArrayLiteral renders a tool-name slice as a TypeScript array literal so +// the plugin and the Go IsFileWritingTool/IsCommandTool predicates share one +// source of truth. json.Marshal output is valid TypeScript for a []string. +func toolsArrayLiteral(tools []string) string { + b, _ := json.Marshal(tools) + return string(b) +} + +// UninstallHooks removes the plugin file. The plugin file is entirely +// chainloop-owned, so we remove it without preserving content. +func (p *Provider) UninstallHooks(repoRoot string) error { + pluginPath := filepath.Join(repoRoot, settingsFile) + + err := os.Remove(pluginPath) + if errors.Is(err, os.ErrNotExist) { + return nil + } + + return err +} + +// maxHookPayloadBytes caps hook payload reads to defend against runaway or +// malformed payloads. +const maxHookPayloadBytes = 16 * 1024 * 1024 + +// ReadHookInput reads and parses the opencode hook JSON from the given reader. +// The plugin pipes JSON to stdin with fields: session_id, hook_event_name, +// tool_name, file_path. +func (p *Provider) ReadHookInput(r io.Reader) (*trace.HookInput, error) { + data, err := io.ReadAll(io.LimitReader(r, maxHookPayloadBytes)) + if err != nil { + return nil, err + } + + var raw struct { + SessionID string `json:"session_id"` + HookEventName string `json:"hook_event_name"` + ToolName string `json:"tool_name"` + FilePath string `json:"file_path"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + + return &trace.HookInput{ + SessionID: raw.SessionID, + HookEventName: raw.HookEventName, + ToolName: raw.ToolName, + FilePath: raw.FilePath, + }, nil +} + +// patchFileRe matches *** Add File:, *** Update File:, or *** Delete File: +// section headers in an apply_patch patchText payload. It mirrors the +// regex used by the TypeScript plugin's parsePatchPaths function so the +// parsing logic is testable from Go (the TS plugin splits per-file before +// the Go side ever sees patchText). +var patchFileRe = regexp.MustCompile(`(?m)^\*\*\* (?:Add|Update|Delete) File: (.+)$`) + +// parsePatchPaths extracts affected file paths from an apply_patch +// patchText payload. Each section starts with *** Add File:, *** Update +// File:, or *** Delete File: followed by the path. Paths are deduplicated +// while preserving first-seen order. Both absolute and repository-relative +// paths are returned verbatim — the caller is responsible for any +// normalization. +func parsePatchPaths(patchText string) []string { + var paths []string + seen := make(map[string]bool) + for _, m := range patchFileRe.FindAllStringSubmatch(patchText, -1) { + p := strings.TrimSpace(m[1]) + if p == "" || seen[p] { + continue + } + seen[p] = true + paths = append(paths, p) + } + return paths +} + +// IsFileWritingTool returns true if the named tool modifies files on disk. +func (p *Provider) IsFileWritingTool(toolName string) bool { + return slices.Contains(fileWritingTools, toolName) +} + +// IsCommandTool returns true if the named tool runs a shell command. +func (p *Provider) IsCommandTool(toolName string) bool { + return slices.Contains(commandTools, toolName) +} + +// opencodeBinaryAvailable checks whether the opencode CLI is on PATH. +func opencodeBinaryAvailable() bool { + _, err := exec.LookPath("opencode") + return err == nil +} diff --git a/app/cli/internal/trace/opencode/hooks_test.go b/app/cli/internal/trace/opencode/hooks_test.go new file mode 100644 index 000000000..bd754dcd7 --- /dev/null +++ b/app/cli/internal/trace/opencode/hooks_test.go @@ -0,0 +1,304 @@ +package opencode + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInstallHooksCreatesPluginFile(t *testing.T) { + repoRoot := t.TempDir() + p := New() + + require.NoError(t, p.InstallHooks(repoRoot)) + + pluginPath := filepath.Join(repoRoot, settingsFile) + data, err := os.ReadFile(pluginPath) + require.NoError(t, err) + + content := string(data) + assert.Contains(t, content, "chainloop trace hook opencode") + assert.Contains(t, content, "session.created") + assert.Contains(t, content, "session.deleted") + assert.Contains(t, content, "tool.execute.before") + assert.Contains(t, content, "tool.execute.after") + assert.Contains(t, content, `const fileWritingTools = ["edit","write","apply_patch"]`) +} + +func TestInstallHooksForTraceRunOmitsSessionEnd(t *testing.T) { + repoRoot := t.TempDir() + p := New() + + require.NoError(t, p.InstallHooksForTraceRun(repoRoot)) + + pluginPath := filepath.Join(repoRoot, settingsFile) + data, err := os.ReadFile(pluginPath) + require.NoError(t, err) + + content := string(data) + assert.Contains(t, content, "session.created") + assert.NotContains(t, content, "session.deleted", "trace run must not install session.deleted; trace run drives end-of-session itself") + assert.Contains(t, content, "tool.execute.before") + assert.Contains(t, content, "tool.execute.after") +} + +func TestInstallHooksIdempotent(t *testing.T) { + repoRoot := t.TempDir() + p := New() + + require.NoError(t, p.InstallHooks(repoRoot)) + firstStat, err := os.Stat(filepath.Join(repoRoot, settingsFile)) + require.NoError(t, err) + + require.NoError(t, p.InstallHooks(repoRoot)) + secondStat, err := os.Stat(filepath.Join(repoRoot, settingsFile)) + require.NoError(t, err) + + // File should not be rewritten when content is identical. + assert.Equal(t, firstStat.ModTime(), secondStat.ModTime()) +} + +func TestUninstallHooksRemovesPluginFile(t *testing.T) { + repoRoot := t.TempDir() + p := New() + + require.NoError(t, p.InstallHooks(repoRoot)) + require.NoError(t, p.UninstallHooks(repoRoot)) + + _, err := os.Stat(filepath.Join(repoRoot, settingsFile)) + assert.True(t, os.IsNotExist(err), "plugin file should be removed") +} + +func TestUninstallHooksNoopWhenFileMissing(t *testing.T) { + repoRoot := t.TempDir() + p := New() + assert.NoError(t, p.UninstallHooks(repoRoot)) +} + +func TestReadHookInputParsesValidInput(t *testing.T) { + r := bytes.NewBufferString(`{"session_id":"abc-123","hook_event_name":"session.created","tool_name":"edit","file_path":"/some/file.go"}`) + p := New() + input, err := p.ReadHookInput(r) + require.NoError(t, err) + assert.Equal(t, "abc-123", input.SessionID) + assert.Equal(t, "session.created", input.HookEventName) + assert.Equal(t, "edit", input.ToolName) + assert.Equal(t, "/some/file.go", input.FilePath) +} + +func TestReadHookInputApplyPatchSingleFile(t *testing.T) { + // apply_patch fires one hook per file, so each invocation still carries + // a single file_path — this is the shape the plugin emits after the fix. + r := bytes.NewBufferString(`{"session_id":"ses_apply_patch_test","hook_event_name":"tool.execute.after","tool_name":"apply_patch","file_path":"/tmp/trace-fixture/existing.txt"}`) + p := New() + input, err := p.ReadHookInput(r) + require.NoError(t, err) + assert.Equal(t, "ses_apply_patch_test", input.SessionID) + assert.Equal(t, "apply_patch", input.ToolName) + assert.Equal(t, "/tmp/trace-fixture/existing.txt", input.FilePath) +} + +func TestReadHookInputHandlesMissingFields(t *testing.T) { + r := bytes.NewBufferString(`{"session_id":"abc-123"}`) + p := New() + input, err := p.ReadHookInput(r) + require.NoError(t, err) + assert.Equal(t, "abc-123", input.SessionID) + assert.Empty(t, input.HookEventName) + assert.Empty(t, input.ToolName) + assert.Empty(t, input.FilePath) +} + +func TestReadHookInputReturnsErrorForInvalidJSON(t *testing.T) { + r := bytes.NewBufferString(`not json`) + p := New() + _, err := p.ReadHookInput(r) + assert.Error(t, err) +} + +func TestIsFileWritingTool(t *testing.T) { + p := New() + assert.True(t, p.IsFileWritingTool("edit")) + assert.True(t, p.IsFileWritingTool("write")) + assert.True(t, p.IsFileWritingTool("apply_patch")) + assert.False(t, p.IsFileWritingTool("read")) + assert.False(t, p.IsFileWritingTool("bash")) + assert.False(t, p.IsFileWritingTool("")) +} + +func TestIsCommandTool(t *testing.T) { + p := New() + assert.True(t, p.IsCommandTool("bash")) + assert.False(t, p.IsCommandTool("edit")) + assert.False(t, p.IsCommandTool("write")) + assert.False(t, p.IsCommandTool("")) +} + +func TestSettingsFile(t *testing.T) { + p := New() + assert.Equal(t, filepath.Join("/repo", settingsFile), p.SettingsFile("/repo")) +} + +func TestPluginTemplateHasNoUnreplacedPlaceholders(t *testing.T) { + repoRoot := t.TempDir() + p := New() + require.NoError(t, p.InstallHooks(repoRoot)) + + data, err := os.ReadFile(filepath.Join(repoRoot, settingsFile)) + require.NoError(t, err) + assert.NotContains(t, string(data), "{{SessionEndBlock}}") + assert.NotContains(t, string(data), "{{FileWritingToolsArray}}") +} + +func TestPluginTemplateFileWritingToolsMatchesGoSlice(t *testing.T) { + repoRoot := t.TempDir() + p := New() + require.NoError(t, p.InstallHooks(repoRoot)) + + data, err := os.ReadFile(filepath.Join(repoRoot, settingsFile)) + require.NoError(t, err) + + goLiteral, err := json.Marshal(fileWritingTools) + require.NoError(t, err) + assert.Contains(t, string(data), "const fileWritingTools = "+string(goLiteral)) +} + +func TestPluginTemplateContainsBacktickShellCommand(t *testing.T) { + // The plugin uses Bun's $ template literal for shell commands. Verify + // the backticks survived the Go raw string splice so the plugin is + // syntactically valid TypeScript. + repoRoot := t.TempDir() + p := New() + require.NoError(t, p.InstallHooks(repoRoot)) + + data, err := os.ReadFile(filepath.Join(repoRoot, settingsFile)) + require.NoError(t, err) + assert.Contains(t, string(data), "$`echo") + assert.Contains(t, string(data), "chainloop trace hook opencode") +} + +func TestParsePatchPaths(t *testing.T) { + cases := []struct { + name string + patch string + expected []string + }{ + { + name: "single-file update (absolute path)", + patch: "*** Begin Patch\n" + + "*** Update File: /tmp/trace-fixture/existing.txt\n" + + "@@\n" + + "-old value\n" + + "+new value\n" + + "*** End Patch\n", + expected: []string{"/tmp/trace-fixture/existing.txt"}, + }, + { + name: "add file (absolute path)", + patch: "*** Begin Patch\n" + + "*** Add File: /tmp/trace-fixture/added.txt\n" + + "+created by the agent\n" + + "*** End Patch\n", + expected: []string{"/tmp/trace-fixture/added.txt"}, + }, + { + name: "delete file (absolute path)", + patch: "*** Begin Patch\n" + + "*** Delete File: /tmp/trace-fixture/deleted.txt\n" + + "*** End Patch\n", + expected: []string{"/tmp/trace-fixture/deleted.txt"}, + }, + { + name: "multi-file patch preserving order", + patch: "*** Begin Patch\n" + + "*** Update File: /tmp/trace-fixture/first.txt\n" + + "@@\n" + + "-before\n" + + "+after\n" + + "*** Add File: /tmp/trace-fixture/second.txt\n" + + "+new file\n" + + "*** Delete File: /tmp/trace-fixture/third.txt\n" + + "*** End Patch\n", + expected: []string{ + "/tmp/trace-fixture/first.txt", + "/tmp/trace-fixture/second.txt", + "/tmp/trace-fixture/third.txt", + }, + }, + { + name: "repeated file sections are deduplicated", + patch: "*** Begin Patch\n" + + "*** Update File: /tmp/trace-fixture/dup.txt\n" + + "@@\n" + + "-a\n" + + "+b\n" + + "*** Update File: /tmp/trace-fixture/dup.txt\n" + + "@@\n" + + "-c\n" + + "+d\n" + + "*** End Patch\n", + expected: []string{"/tmp/trace-fixture/dup.txt"}, + }, + { + name: "repository-relative paths", + patch: "*** Begin Patch\n" + + "*** Update File: src/main.go\n" + + "@@\n" + + "-old\n" + + "+new\n" + + "*** Add File: src/new.go\n" + + "+package main\n" + + "*** End Patch\n", + expected: []string{"src/main.go", "src/new.go"}, + }, + { + name: "empty patchText returns nil", + patch: "", + expected: nil, + }, + { + name: "patch with no file sections returns nil", + patch: "*** Begin Patch\n" + + "*** End Patch\n", + expected: nil, + }, + { + name: "paths with surrounding whitespace are trimmed", + patch: "*** Begin Patch\n" + + "*** Update File: /tmp/trace-fixture/spaced.txt \n" + + "@@\n" + + "-a\n" + + "+b\n" + + "*** End Patch\n", + expected: []string{"/tmp/trace-fixture/spaced.txt"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := parsePatchPaths(tc.patch) + assert.Equal(t, tc.expected, got) + }) + } +} + +func TestPluginTemplateContainsPatchParsing(t *testing.T) { + repoRoot := t.TempDir() + p := New() + require.NoError(t, p.InstallHooks(repoRoot)) + + data, err := os.ReadFile(filepath.Join(repoRoot, settingsFile)) + require.NoError(t, err) + content := string(data) + assert.Contains(t, content, "filePathsFromArgs") + assert.Contains(t, content, "parsePatchPaths") + assert.Contains(t, content, "patchText") + assert.Contains(t, content, "Add|Update|Delete") + // Verify the plugin loops over paths rather than sending a single path. + assert.Contains(t, content, "for (const fp of filePathsFromArgs") +} diff --git a/app/cli/internal/trace/opencode/parse.go b/app/cli/internal/trace/opencode/parse.go new file mode 100644 index 000000000..25a865833 --- /dev/null +++ b/app/cli/internal/trace/opencode/parse.go @@ -0,0 +1,241 @@ +package opencode + +import ( + "encoding/json" + "fmt" + "math" + "os" + "sort" + "time" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" +) + +// unknownValue stands in for a model or tool name the export didn't carry. +const unknownValue = "unknown" + +// parseExport reads and parses the opencode export JSON at path into +// structured evidence. Unknown fields are ignored. +func parseExport(path string) (*aicodingsession.Evidence, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read export file: %w", err) + } + + var export exportData + if err := json.Unmarshal(data, &export); err != nil { + return nil, fmt.Errorf("parse export JSON: %w", err) + } + + return buildEvidence(&export), nil +} + +// buildEvidence converts the parsed export into an aicodingsession.Evidence. +func buildEvidence(export *exportData) *aicodingsession.Evidence { + var totalInput, totalOutput, totalCacheRead, totalCacheWrite int + var totalCost float64 + modelsSeen := make(map[string]int) + toolCounts := make(map[string]int) + var userMessages, assistantMessages int + var providerID string + + for _, msg := range export.Messages { + switch msg.Info.Role { + case "user": + userMessages++ + case "assistant": + assistantMessages++ + if msg.Info.ModelID != "" { + modelsSeen[msg.Info.ModelID]++ + } + // Provider is derived from the first assistant message that carries one. + if providerID == "" && msg.Info.ProviderID != "" { + providerID = msg.Info.ProviderID + } + if msg.Info.Tokens != nil { + totalInput += msg.Info.Tokens.Input + totalOutput += msg.Info.Tokens.Output + totalCacheRead += msg.Info.Tokens.Cache.Read + totalCacheWrite += msg.Info.Tokens.Cache.Write + } + totalCost += msg.Info.Cost + } + + // Count completed tool invocations from tool parts. + for _, part := range msg.Parts { + if part.Type == "tool" && part.State != nil && part.State.Status == "completed" { + name := part.Tool + if name == "" { + name = unknownValue + } + toolCounts[name]++ + } + } + } + + // Prefer session-level cost/tokens when available (opencode computes + // these natively); fall back to per-message aggregation. + if export.Info.Cost != nil { + totalCost = *export.Info.Cost + } + if export.Info.Tokens != nil { + totalInput = export.Info.Tokens.Input + totalOutput = export.Info.Tokens.Output + totalCacheRead = export.Info.Tokens.Cache.Read + totalCacheWrite = export.Info.Tokens.Cache.Write + } + + primaryModel := unknownValue + maxCount := 0 + for model, count := range modelsSeen { + if count > maxCount { + primaryModel = model + maxCount = count + } + } + + modelsUsed := make([]string, 0, len(modelsSeen)) + for model := range modelsSeen { + modelsUsed = append(modelsUsed, model) + } + sort.Strings(modelsUsed) + + // Timestamps: opencode uses epoch milliseconds. + startedAt := formatEpochMs(export.Info.Time.Created) + endedAt := formatEpochMs(export.Info.Time.Updated) + var durationSeconds int + if export.Info.Time.Created > 0 && export.Info.Time.Updated > 0 { + durationSeconds = int((export.Info.Time.Updated - export.Info.Time.Created) / 1000) + } + + toolSummary := make([]aicodingsession.ToolSummary, 0, len(toolCounts)) + totalInvocations := 0 + for name, count := range toolCounts { + toolSummary = append(toolSummary, aicodingsession.ToolSummary{ToolName: name, InvocationCount: count}) + totalInvocations += count + } + sort.Slice(toolSummary, func(i, j int) bool { + if toolSummary[i].InvocationCount != toolSummary[j].InvocationCount { + return toolSummary[i].InvocationCount > toolSummary[j].InvocationCount + } + return toolSummary[i].ToolName < toolSummary[j].ToolName + }) + + // Round cost to 4 decimal places to match Claude provider's precision. + totalCost = math.Round(totalCost*10000) / 10000 + + // Raw session: convert each opencode message into a frontend-compatible + // entry (one json.RawMessage per message) so the conversation timeline + // can render individual messages. The entry shape mirrors Claude's + // JSONL format: {type, uuid, timestamp, message: {role, content}}. + rawSession := map[string][]json.RawMessage{ + "main": buildRawSessionEntries(export.Messages), + } + + return aicodingsession.NewEvidence(aicodingsession.Data{ + SchemaVersion: "v1", + Agent: aicodingsession.Agent{ + Name: Name, + Version: export.Info.Version, + }, + Session: aicodingsession.Session{ + ID: export.Info.ID, + Slug: export.Info.Slug, + StartedAt: startedAt, + EndedAt: endedAt, + DurationSeconds: durationSeconds, + }, + Model: &aicodingsession.Model{ + Primary: primaryModel, + Provider: providerID, + ModelsUsed: modelsUsed, + }, + Usage: &aicodingsession.Usage{ + InputTokens: totalInput, + OutputTokens: totalOutput, + TotalTokens: totalInput + totalOutput, + CacheReadInputTokens: totalCacheRead, + CacheCreationInputTokens: totalCacheWrite, + EstimatedCostUSD: totalCost, + }, + ToolsUsed: &aicodingsession.ToolsUsed{ + Summary: toolSummary, + TotalInvocations: totalInvocations, + }, + Conversation: &aicodingsession.Conversation{ + TotalMessages: userMessages + assistantMessages, + UserMessages: userMessages, + AssistantMessages: assistantMessages, + }, + RawSession: rawSession, + }) +} + +// formatEpochMs converts an opencode epoch-ms timestamp to RFC3339; +// returns "" for non-positive values. +func formatEpochMs(ms float64) string { + if ms <= 0 { + return "" + } + t := time.UnixMilli(int64(ms)).UTC() + return t.Format(time.RFC3339) +} + +// buildRawSessionEntries converts opencode messages into the frontend-compatible +// RawSessionEntry format (one entry per message). Each entry carries the +// message role, ID, timestamp, and content blocks (text and tool_use) so the +// ConversationTimeline can render the full transcript. +func buildRawSessionEntries(messages []messageEntry) []json.RawMessage { + entries := make([]json.RawMessage, 0, len(messages)) + for _, msg := range messages { + entry := trace.RawSessionEntry{ + Type: msg.Info.Role, + UUID: msg.Info.ID, + Timestamp: msgTimeRFC3339(msg.Info.Time), + Message: trace.RawSessionMessage{ + Role: msg.Info.Role, + Model: msg.Info.ModelID, + }, + } + + var blocks []any + for _, p := range msg.Parts { + switch p.Type { + case "text": + if p.Text != "" { + blocks = append(blocks, trace.RawSessionTextBlock{Type: "text", Text: p.Text}) + } + case "tool": + if p.State == nil || p.State.Status != "completed" { + continue + } + toolName := p.Tool + if toolName == "" { + toolName = unknownValue + } + blocks = append(blocks, trace.RawSessionToolUseBlock{ + Type: "tool_use", + ID: p.ID, + Name: toolName, + Input: p.State.Input, + }) + } + } + + if len(blocks) > 0 { + content, _ := json.Marshal(blocks) + entry.Message.Content = content + } + + entries = append(entries, trace.MustRawSessionEntry(entry)) + } + return entries +} + +func msgTimeRFC3339(t *msgTime) string { + if t == nil || t.Created <= 0 { + return "" + } + return formatEpochMs(t.Created) +} diff --git a/app/cli/internal/trace/opencode/parse_test.go b/app/cli/internal/trace/opencode/parse_test.go new file mode 100644 index 000000000..746d042a2 --- /dev/null +++ b/app/cli/internal/trace/opencode/parse_test.go @@ -0,0 +1,316 @@ +package opencode + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// sampleExport is a minimal opencode export JSON with one user message, +// two assistant messages (with tokens/cost), and a completed tool part. +const sampleExport = `{ + "info": { + "id": "sess_abc123", + "slug": "refactor-auth", + "title": "Refactor auth module", + "directory": "/home/user/project", + "version": "0.5.0", + "cost": 0.0523, + "tokens": { + "input": 12000, + "output": 3000, + "reasoning": 500, + "cache": { "read": 2000, "write": 1000 } + }, + "time": { "created": 1751702400000, "updated": 1751702700000 } + }, + "messages": [ + { + "info": { + "role": "user", + "id": "msg_001", + "model": { "providerID": "anthropic", "modelID": "claude-sonnet-4-20250514" }, + "time": { "created": 1751702400000 } + }, + "parts": [{ "type": "text", "id": "prt_001", "text": "Fix the auth bug" }] + }, + { + "info": { + "role": "assistant", + "id": "msg_002", + "modelID": "claude-sonnet-4-20250514", + "providerID": "anthropic", + "cost": 0.0261, + "tokens": { + "input": 6000, + "output": 1500, + "reasoning": 250, + "cache": { "read": 1000, "write": 500 } + }, + "time": { "created": 1751702500000, "completed": 1751702550000 } + }, + "parts": [ + { "type": "text", "id": "prt_002", "text": "I'll fix the auth bug." }, + { "type": "tool", "id": "prt_003", "tool": "edit", "state": { "status": "completed" } }, + { "type": "tool", "id": "prt_004", "tool": "read", "state": { "status": "completed" } } + ] + }, + { + "info": { + "role": "assistant", + "id": "msg_003", + "modelID": "claude-sonnet-4-20250514", + "providerID": "anthropic", + "cost": 0.0262, + "tokens": { + "input": 6000, + "output": 1500, + "reasoning": 250, + "cache": { "read": 1000, "write": 500 } + }, + "time": { "created": 1751702600000, "completed": 1751702700000 } + }, + "parts": [ + { "type": "tool", "id": "prt_005", "tool": "edit", "state": { "status": "completed" } } + ] + } + ] +}` + +func TestParseExport(t *testing.T) { + rawDir := t.TempDir() + path := filepath.Join(rawDir, "sess_abc123.jsonl") + require.NoError(t, os.WriteFile(path, []byte(sampleExport), 0600)) + + p := New() + result, err := p.ParseSession(context.Background(), &trace.ParseOpts{ + SessionDir: rawDir, + SessionID: "sess_abc123", + }) + require.NoError(t, err) + + assert.Equal(t, "CHAINLOOP_AI_CODING_SESSION", result.ID) + assert.Equal(t, "v1", result.Data.SchemaVersion) + assert.Equal(t, "opencode", result.Data.Agent.Name) + assert.Equal(t, "0.5.0", result.Data.Agent.Version) + assert.Equal(t, "sess_abc123", result.Data.Session.ID) + assert.Equal(t, "refactor-auth", result.Data.Session.Slug) + + // Duration: 5 minutes = 300 seconds + assert.Equal(t, 300, result.Data.Session.DurationSeconds) + + // Model + require.NotNil(t, result.Data.Model) + assert.Equal(t, "claude-sonnet-4-20250514", result.Data.Model.Primary) + assert.Equal(t, "anthropic", result.Data.Model.Provider) + assert.Contains(t, result.Data.Model.ModelsUsed, "claude-sonnet-4-20250514") + + // Usage: session-level tokens are authoritative when present. + require.NotNil(t, result.Data.Usage) + assert.Equal(t, 12000, result.Data.Usage.InputTokens) + assert.Equal(t, 3000, result.Data.Usage.OutputTokens) + assert.Equal(t, 15000, result.Data.Usage.TotalTokens) + assert.Equal(t, 2000, result.Data.Usage.CacheReadInputTokens) + assert.Equal(t, 1000, result.Data.Usage.CacheCreationInputTokens) + assert.InDelta(t, 0.0523, result.Data.Usage.EstimatedCostUSD, 0.0001) + + // Tools: 2 edits + 1 read = 3 total + require.NotNil(t, result.Data.ToolsUsed) + assert.Equal(t, 3, result.Data.ToolsUsed.TotalInvocations) + + got := map[string]int{} + for _, s := range result.Data.ToolsUsed.Summary { + got[s.ToolName] = s.InvocationCount + } + assert.Equal(t, 2, got["edit"]) + assert.Equal(t, 1, got["read"]) + + // Highest-count tool must come first (sorted desc by count). + assert.Equal(t, "edit", result.Data.ToolsUsed.Summary[0].ToolName) + + // Conversation + require.NotNil(t, result.Data.Conversation) + assert.Equal(t, 1, result.Data.Conversation.UserMessages) + assert.Equal(t, 2, result.Data.Conversation.AssistantMessages) + assert.Equal(t, 3, result.Data.Conversation.TotalMessages) + + // Raw session: one entry per message (3 messages = 3 entries) + require.Contains(t, result.Data.RawSession, "main") + assert.Len(t, result.Data.RawSession["main"], 3) + + // Verify the first entry is a user message with text content + var firstEntry map[string]any + require.NoError(t, json.Unmarshal(result.Data.RawSession["main"][0], &firstEntry)) + assert.Equal(t, "user", firstEntry["type"]) + assert.Equal(t, "msg_001", firstEntry["uuid"]) + + msg, ok := firstEntry["message"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "user", msg["role"]) + + content, ok := msg["content"].([]any) + require.True(t, ok) + require.Len(t, content, 1) + block := content[0].(map[string]any) + assert.Equal(t, "text", block["type"]) + assert.Equal(t, "Fix the auth bug", block["text"]) + + // Verify the second entry is an assistant message with text + tool_use blocks + var secondEntry map[string]any + require.NoError(t, json.Unmarshal(result.Data.RawSession["main"][1], &secondEntry)) + assert.Equal(t, "assistant", secondEntry["type"]) + assert.Equal(t, "msg_002", secondEntry["uuid"]) + + msg2, ok := secondEntry["message"].(map[string]any) + require.True(t, ok) + content2, ok := msg2["content"].([]any) + require.True(t, ok) + // 1 text block + 2 tool_use blocks + require.Len(t, content2, 3) + assert.Equal(t, "text", content2[0].(map[string]any)["type"]) + assert.Equal(t, "tool_use", content2[1].(map[string]any)["type"]) + assert.Equal(t, "edit", content2[1].(map[string]any)["name"]) + assert.Equal(t, "tool_use", content2[2].(map[string]any)["type"]) + assert.Equal(t, "read", content2[2].(map[string]any)["name"]) +} + +func TestParseExportFallsBackToMessageTokens(t *testing.T) { + // When session-level tokens are absent, the parser sums per-message tokens. + export := `{ + "info": { + "id": "sess_notokens", + "title": "No session tokens", + "directory": "/repo", + "version": "0.5.0", + "time": { "created": 1751702400000, "updated": 1751702700000 } + }, + "messages": [ + { + "info": { "role": "assistant", "id": "m1", "modelID": "gpt-4o", "providerID": "openai", "cost": 0.01, + "tokens": { "input": 100, "output": 50, "reasoning": 10, "cache": { "read": 5, "write": 3 } } } + }, + { + "info": { "role": "assistant", "id": "m2", "modelID": "gpt-4o", "providerID": "openai", "cost": 0.02, + "tokens": { "input": 200, "output": 100, "reasoning": 20, "cache": { "read": 10, "write": 7 } } } + } + ] +}` + + rawDir := t.TempDir() + path := filepath.Join(rawDir, state.SanitizeID("sess_notokens")+".jsonl") + require.NoError(t, os.WriteFile(path, []byte(export), 0600)) + + p := New() + result, err := p.ParseSession(context.Background(), &trace.ParseOpts{ + SessionDir: rawDir, + SessionID: "sess_notokens", + }) + require.NoError(t, err) + + require.NotNil(t, result.Data.Usage) + assert.Equal(t, 300, result.Data.Usage.InputTokens) // 100 + 200 + assert.Equal(t, 150, result.Data.Usage.OutputTokens) // 50 + 100 + assert.Equal(t, 450, result.Data.Usage.TotalTokens) + assert.Equal(t, 15, result.Data.Usage.CacheReadInputTokens) // 5 + 10 + assert.Equal(t, 10, result.Data.Usage.CacheCreationInputTokens) // 3 + 7 + assert.InDelta(t, 0.03, result.Data.Usage.EstimatedCostUSD, 0.0001) +} + +func TestParseExportEmptySession(t *testing.T) { + export := `{ + "info": { + "id": "sess_empty", + "title": "Empty", + "directory": "/repo", + "version": "0.5.0", + "time": { "created": 1751702400000, "updated": 1751702400000 } + }, + "messages": [] +}` + + rawDir := t.TempDir() + path := filepath.Join(rawDir, state.SanitizeID("sess_empty")+".jsonl") + require.NoError(t, os.WriteFile(path, []byte(export), 0600)) + + p := New() + result, err := p.ParseSession(context.Background(), &trace.ParseOpts{ + SessionDir: rawDir, + SessionID: "sess_empty", + }) + require.NoError(t, err) + + assert.Equal(t, "opencode", result.Data.Agent.Name) + assert.Equal(t, "sess_empty", result.Data.Session.ID) + assert.Equal(t, 0, result.Data.Session.DurationSeconds) + require.NotNil(t, result.Data.Usage) + assert.Equal(t, 0, result.Data.Usage.InputTokens) + require.NotNil(t, result.Data.ToolsUsed) + assert.Equal(t, 0, result.Data.ToolsUsed.TotalInvocations) + require.NotNil(t, result.Data.Conversation) + assert.Equal(t, 0, result.Data.Conversation.TotalMessages) +} + +func TestParseExportMissingFile(t *testing.T) { + p := New() + _, err := p.ParseSession(context.Background(), &trace.ParseOpts{ + SessionDir: t.TempDir(), + SessionID: "does-not-exist", + }) + assert.Error(t, err) +} + +func TestParseExportInvalidJSON(t *testing.T) { + rawDir := t.TempDir() + path := filepath.Join(rawDir, state.SanitizeID("bad")+".jsonl") + require.NoError(t, os.WriteFile(path, []byte("not json"), 0600)) + + p := New() + _, err := p.ParseSession(context.Background(), &trace.ParseOpts{ + SessionDir: rawDir, + SessionID: "bad", + }) + assert.Error(t, err) +} + +func TestParseExportCraftingCompatibility(t *testing.T) { + rawDir := t.TempDir() + path := filepath.Join(rawDir, "sess_abc123.jsonl") + require.NoError(t, os.WriteFile(path, []byte(sampleExport), 0600)) + + p := New() + result, err := p.ParseSession(context.Background(), &trace.ParseOpts{ + SessionDir: rawDir, + SessionID: "sess_abc123", + }) + require.NoError(t, err) + + resultBytes, err := json.Marshal(result) + require.NoError(t, err) + + var envelope struct { + Data json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(resultBytes, &envelope)) + require.NotEmpty(t, envelope.Data) + + var upstreamData aicodingsession.Data + require.NoError(t, json.Unmarshal(envelope.Data, &upstreamData)) + assert.Equal(t, "opencode", upstreamData.Agent.Name) + assert.Equal(t, "sess_abc123", upstreamData.Session.ID) + require.Contains(t, upstreamData.RawSession, "main") + assert.NotEmpty(t, upstreamData.RawSession["main"]) +} + +func TestFormatEpochMs(t *testing.T) { + assert.Equal(t, "2025-07-05T08:00:00Z", formatEpochMs(1751702400000)) + assert.Equal(t, "", formatEpochMs(0)) + assert.Equal(t, "", formatEpochMs(-1)) +} diff --git a/app/cli/internal/trace/opencode/provider.go b/app/cli/internal/trace/opencode/provider.go new file mode 100644 index 000000000..3b3ddd83d --- /dev/null +++ b/app/cli/internal/trace/opencode/provider.go @@ -0,0 +1,161 @@ +package opencode + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "time" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" +) + +// Name is the provider identifier emitted to evidence and used as the key +// in SessionRecord.Provider. Kept as an exported constant so CLI wiring +// can refer to it without instantiating the provider. +const Name = "opencode" + +// Compile-time check that Provider implements trace.Provider. +var _ trace.Provider = (*Provider)(nil) + +// Provider implements trace.Provider for opencode sessions. +type Provider struct{} + +// New creates a new opencode provider. +func New() *Provider { + return &Provider{} +} + +// Name returns the agent identifier. +func (p *Provider) Name() string { + return Name +} + +// DiscoverSession finds the most recent opencode session for the given repo root. +// Returns nil, nil if no matching session is found or the opencode binary is unavailable. +func (p *Provider) DiscoverSession(repoRoot string) (*trace.DiscoveredSession, error) { + session, err := discoverOpenCodeSession(repoRoot) + if err != nil || session == nil { + return nil, err + } + + return &trace.DiscoveredSession{ + SessionID: session.ID, + SessionDir: "", + // opencode sessions live in a SQLite DB, not a directory; there's + // no reliable "alive" signal from session list alone. Treat + // discovered sessions as potentially active. + IsActive: true, + }, nil +} + +// SessionDirForRepo returns "" for opencode — sessions are stored in a +// SQLite database, not a per-repo directory. The method exists to satisfy +// the interface; callers use CopySessionData for the actual data extraction. +func (p *Provider) SessionDirForRepo(_ string) string { + return "" +} + +// CopySessionData runs `opencode export ` and streams the JSON +// output directly to the store's raw/.jsonl so +// pre-push can parse it independently of opencode's own storage. stdout is +// redirected to the destination file rather than a pipe: opencode +// (Node.js) doesn't reliably flush stdout to pipes, causing truncation at +// pipe buffer boundaries, but a regular file destination is safe. Fails +// gracefully (returns nil) when the opencode binary is missing or the +// export fails — the session is skipped rather than blocking the push, +// and any partially-written file is removed. +func (p *Provider) CopySessionData(store *state.Store, _, sessionID string) error { + if !opencodeBinaryAvailable() { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + rawDir := store.RawSessionDir() + if err := os.MkdirAll(rawDir, 0755); err != nil { + return fmt.Errorf("create raw dir: %w", err) + } + + dst := state.RawSessionPath(rawDir, sessionID) + f, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if err != nil { + return fmt.Errorf("create raw session file: %w", err) + } + + cmd := exec.CommandContext(ctx, "opencode", "export", sessionID) + cmd.Stdout = f + runErr := cmd.Run() + _ = f.Close() + if runErr != nil { + _ = os.Remove(dst) + return nil + } + + return nil +} + +// CaptureFileSnapshot reads the file at input.FilePath and stores its +// content under the store's snapshots/ directory so the post-tool-use +// handler can compute line-range diffs after the edit. A missing file +// (e.g. write creating a new file) is treated as a non-error. +func (p *Provider) CaptureFileSnapshot(store *state.Store, input *trace.HookInput) error { + if input == nil || input.FilePath == "" { + return nil + } + + content, err := os.ReadFile(input.FilePath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + + return fmt.Errorf("read %q: %w", input.FilePath, err) + } + + return store.SaveFileSnapshot(input.SessionID, input.FilePath, content) +} + +// ResolveBeforeContent returns the snapshot saved by CaptureFileSnapshot. +func (p *Provider) ResolveBeforeContent(store *state.Store, input *trace.HookInput, _ []byte) []byte { + if input == nil || input.FilePath == "" { + return nil + } + + snap, err := store.LoadFileSnapshot(input.SessionID, input.FilePath) + if err != nil { + return nil + } + + return snap +} + +// CleanupAfterEdit removes the per-edit snapshot. +func (p *Provider) CleanupAfterEdit(store *state.Store, input *trace.HookInput) { + if input == nil || input.FilePath == "" { + return + } + + store.DeleteFileSnapshot(input.SessionID, input.FilePath) +} + +// SystemMessage is a no-op for opencode: the plugin system has no +// systemMessage channel comparable to Claude Code's SessionStart output. +func (p *Provider) SystemMessage(_ string) error { + return nil +} + +// ParseSession reads the copied export JSON for sessionID and returns +// structured evidence. +func (p *Provider) ParseSession(_ context.Context, opts *trace.ParseOpts) (*aicodingsession.Evidence, error) { + path, err := state.FindRawSessionFile(opts.SessionDir, opts.SessionID) + if err != nil { + return nil, fmt.Errorf("locate opencode export: %w", err) + } + + return parseExport(path) +} diff --git a/app/cli/internal/trace/opencode/session.go b/app/cli/internal/trace/opencode/session.go new file mode 100644 index 000000000..068fac903 --- /dev/null +++ b/app/cli/internal/trace/opencode/session.go @@ -0,0 +1,55 @@ +package opencode + +import ( + "context" + "encoding/json" + "os/exec" + "sort" + "time" +) + +// discoverOpenCodeSession runs `opencode session list --format json`, filters +// sessions whose directory matches repoRoot, and returns the most recently +// updated one. Returns nil, nil when the binary is missing, the command fails, +// or no matching session is found — discovery is best-effort and must never +// block pre-push. +func discoverOpenCodeSession(repoRoot string) (*sessionListEntry, error) { + if !opencodeBinaryAvailable() { + return nil, nil + } + + // Bound the call so a hung opencode binary can never block pre-push. + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "opencode", "session", "list", "--format", "json") + out, err := cmd.Output() + if err != nil { + return nil, nil + } + + var sessions []sessionListEntry + if err := json.Unmarshal(out, &sessions); err != nil { + return nil, nil + } + + // Keep only sessions whose directory matches repoRoot. opencode stores + // the absolute directory the session was started in; compare verbatim. + var matching []sessionListEntry + for _, s := range sessions { + if s.Directory == repoRoot { + matching = append(matching, s) + } + } + + if len(matching) == 0 { + return nil, nil + } + + // Sort by Updated descending — most recent first. + sort.Slice(matching, func(i, j int) bool { + return matching[i].Updated > matching[j].Updated + }) + + return &matching[0], nil +} diff --git a/app/cli/internal/trace/opencode/session_test.go b/app/cli/internal/trace/opencode/session_test.go new file mode 100644 index 000000000..b7b98bf9d --- /dev/null +++ b/app/cli/internal/trace/opencode/session_test.go @@ -0,0 +1,106 @@ +package opencode + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// writeMockOpenCode creates a fake `opencode` binary in a temp dir that +// prints the given JSON to stdout. Returns the temp dir (to use as PATH). +// Uses printf (a POSIX shell builtin) so it works even when PATH is +// restricted to only the temp dir. +func writeMockOpenCode(t *testing.T, output string) string { + t.Helper() + tmpDir := t.TempDir() + binPath := filepath.Join(tmpDir, "opencode") + // printf is a shell builtin — no PATH dependency. + script := "#!/bin/sh\nprintf '%s' " + quoteForSh(output) + "\n" + //nolint:gosec // the mock is executed through PATH, so it must be executable + require.NoError(t, os.WriteFile(binPath, []byte(script), 0755)) + return tmpDir +} + +// quoteForSh wraps a string in single quotes for safe shell use. +// Test data uses double quotes for JSON, so no embedded single-quote +// escaping is needed. +func quoteForSh(s string) string { + return "'" + s + "'" +} + +func TestDiscoverOpenCodeSessionNoBinary(t *testing.T) { + // Force PATH to empty so opencode is not found. + t.Setenv("PATH", "") + session, err := discoverOpenCodeSession("/repo") + assert.NoError(t, err) + assert.Nil(t, session) +} + +func TestDiscoverOpenCodeSessionFromMockBinary(t *testing.T) { + output := `[{"id":"sess_old","title":"Old","directory":"/other/repo","updated":1000,"created":900},{"id":"sess_recent","title":"Recent","directory":"/my/repo","updated":2000,"created":1900},{"id":"sess_older","title":"Older","directory":"/my/repo","updated":1500,"created":1400}]` + t.Setenv("PATH", writeMockOpenCode(t, output)) + + session, err := discoverOpenCodeSession("/my/repo") + require.NoError(t, err) + require.NotNil(t, session) + assert.Equal(t, "sess_recent", session.ID) + assert.Equal(t, "/my/repo", session.Directory) +} + +func TestDiscoverOpenCodeSessionNoMatch(t *testing.T) { + output := `[{"id":"sess1","title":"S1","directory":"/other/repo","updated":1000,"created":900}]` + t.Setenv("PATH", writeMockOpenCode(t, output)) + + session, err := discoverOpenCodeSession("/my/repo") + assert.NoError(t, err) + assert.Nil(t, session) +} + +func TestDiscoverOpenCodeSessionBinaryFails(t *testing.T) { + tmpDir := t.TempDir() + binPath := filepath.Join(tmpDir, "opencode") + // printf to stderr is not a builtin, but exit 1 is enough to fail. + script := "#!/bin/sh\nexit 1\n" + //nolint:gosec // the mock is executed through PATH, so it must be executable + require.NoError(t, os.WriteFile(binPath, []byte(script), 0755)) + t.Setenv("PATH", tmpDir) + + session, err := discoverOpenCodeSession("/my/repo") + assert.NoError(t, err) + assert.Nil(t, session) +} + +func TestDiscoverOpenCodeSessionInvalidJSON(t *testing.T) { + t.Setenv("PATH", writeMockOpenCode(t, "not json")) + + session, err := discoverOpenCodeSession("/my/repo") + assert.NoError(t, err) + assert.Nil(t, session) +} + +func TestDiscoverSessionViaProvider(t *testing.T) { + output := `[{"id":"provider-session","title":"Test","directory":"/tmp/provider-test","updated":2000,"created":1000}]` + t.Setenv("PATH", writeMockOpenCode(t, output)) + + provider := New() + assert.Equal(t, "opencode", provider.Name()) + + discovered, err := provider.DiscoverSession("/tmp/provider-test") + require.NoError(t, err) + require.NotNil(t, discovered) + assert.Equal(t, "provider-session", discovered.SessionID) + assert.True(t, discovered.IsActive) +} + +func TestSessionListEntryParsing(t *testing.T) { + raw := `[{"id":"s1","title":"T1","directory":"/r","updated":1.5,"created":1.0}]` + var entries []sessionListEntry + require.NoError(t, json.Unmarshal([]byte(raw), &entries)) + require.Len(t, entries, 1) + assert.Equal(t, "s1", entries[0].ID) + assert.Equal(t, "/r", entries[0].Directory) +} diff --git a/app/cli/internal/trace/opencode/testdata/plugin_full.ts b/app/cli/internal/trace/opencode/testdata/plugin_full.ts new file mode 100644 index 000000000..57befcbcf --- /dev/null +++ b/app/cli/internal/trace/opencode/testdata/plugin_full.ts @@ -0,0 +1,94 @@ +import type { Plugin } from "@opencode-ai/plugin" + +export const ChainloopTrace: Plugin = async ({ $ }) => { + const fileWritingTools = ["edit","write","apply_patch"] + const commandTools = ["bash"] + + function filePathsFromArgs(args: any): string[] { + if (args?.filePath) return [args.filePath] + if (args?.path) return [args.path] + if (args?.patchText) return parsePatchPaths(args.patchText) + return [] + } + + // parsePatchPaths extracts affected file paths from an apply_patch + // patchText payload. Each section starts with *** Add File:, *** Update + // File:, or *** Delete File: followed by the path. Paths are deduplicated + // while preserving first-seen order. + function parsePatchPaths(patchText: string): string[] { + const paths: string[] = [] + const seen = new Set() + const re = /^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm + let m + while ((m = re.exec(patchText)) !== null) { + const p = m[1].trim() + if (p && !seen.has(p)) { + seen.add(p) + paths.push(p) + } + } + return paths + } + + // fire-and-forget: tracing must never block tool execution. If chainloop + // is unavailable or errors, log to stderr and move on. + async function fire(event: string, payload: Record) { + const json = JSON.stringify(payload) + try { + await $`echo ${json} | chainloop trace hook opencode ${event}` + } catch (err) { + console.error(`chainloop-trace: ${event} hook failed: ${err}`) + } + } + + return { + event: async ({ event }) => { + if (event.type === "session.created") { + const sessionID = event.properties?.info?.id ?? "" + await fire("session-start", { session_id: sessionID, hook_event_name: "session.created" }) + } + if (event.type === "session.deleted") { + const sessionID = event.properties?.info?.id ?? "" + await fire("session-end", { session_id: sessionID, hook_event_name: "session.deleted" }) + } + }, + "tool.execute.before": async (input, output) => { + if (commandTools.includes(input.tool)) { + await fire("pre-tool-use", { + session_id: input.sessionID, + hook_event_name: "tool.execute.before", + tool_name: input.tool, + }) + return + } + if (!fileWritingTools.includes(input.tool)) return + for (const fp of filePathsFromArgs(output.args)) { + await fire("pre-tool-use", { + session_id: input.sessionID, + hook_event_name: "tool.execute.before", + tool_name: input.tool, + file_path: fp, + }) + } + }, + "tool.execute.after": async (input) => { + if (commandTools.includes(input.tool)) { + await fire("post-tool-use", { + session_id: input.sessionID, + hook_event_name: "tool.execute.after", + tool_name: input.tool, + }) + return + } + if (!fileWritingTools.includes(input.tool)) return + for (const fp of filePathsFromArgs(input.args)) { + await fire("post-tool-use", { + session_id: input.sessionID, + hook_event_name: "tool.execute.after", + tool_name: input.tool, + file_path: fp, + }) + } + }, + } +} diff --git a/app/cli/internal/trace/opencode/testdata/plugin_tracerun.ts b/app/cli/internal/trace/opencode/testdata/plugin_tracerun.ts new file mode 100644 index 000000000..fba6019db --- /dev/null +++ b/app/cli/internal/trace/opencode/testdata/plugin_tracerun.ts @@ -0,0 +1,90 @@ +import type { Plugin } from "@opencode-ai/plugin" + +export const ChainloopTrace: Plugin = async ({ $ }) => { + const fileWritingTools = ["edit","write","apply_patch"] + const commandTools = ["bash"] + + function filePathsFromArgs(args: any): string[] { + if (args?.filePath) return [args.filePath] + if (args?.path) return [args.path] + if (args?.patchText) return parsePatchPaths(args.patchText) + return [] + } + + // parsePatchPaths extracts affected file paths from an apply_patch + // patchText payload. Each section starts with *** Add File:, *** Update + // File:, or *** Delete File: followed by the path. Paths are deduplicated + // while preserving first-seen order. + function parsePatchPaths(patchText: string): string[] { + const paths: string[] = [] + const seen = new Set() + const re = /^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm + let m + while ((m = re.exec(patchText)) !== null) { + const p = m[1].trim() + if (p && !seen.has(p)) { + seen.add(p) + paths.push(p) + } + } + return paths + } + + // fire-and-forget: tracing must never block tool execution. If chainloop + // is unavailable or errors, log to stderr and move on. + async function fire(event: string, payload: Record) { + const json = JSON.stringify(payload) + try { + await $`echo ${json} | chainloop trace hook opencode ${event}` + } catch (err) { + console.error(`chainloop-trace: ${event} hook failed: ${err}`) + } + } + + return { + event: async ({ event }) => { + if (event.type === "session.created") { + const sessionID = event.properties?.info?.id ?? "" + await fire("session-start", { session_id: sessionID, hook_event_name: "session.created" }) + } + }, + "tool.execute.before": async (input, output) => { + if (commandTools.includes(input.tool)) { + await fire("pre-tool-use", { + session_id: input.sessionID, + hook_event_name: "tool.execute.before", + tool_name: input.tool, + }) + return + } + if (!fileWritingTools.includes(input.tool)) return + for (const fp of filePathsFromArgs(output.args)) { + await fire("pre-tool-use", { + session_id: input.sessionID, + hook_event_name: "tool.execute.before", + tool_name: input.tool, + file_path: fp, + }) + } + }, + "tool.execute.after": async (input) => { + if (commandTools.includes(input.tool)) { + await fire("post-tool-use", { + session_id: input.sessionID, + hook_event_name: "tool.execute.after", + tool_name: input.tool, + }) + return + } + if (!fileWritingTools.includes(input.tool)) return + for (const fp of filePathsFromArgs(input.args)) { + await fire("post-tool-use", { + session_id: input.sessionID, + hook_event_name: "tool.execute.after", + tool_name: input.tool, + file_path: fp, + }) + } + }, + } +} diff --git a/app/cli/internal/trace/opencode/types.go b/app/cli/internal/trace/opencode/types.go new file mode 100644 index 000000000..6ccc43a67 --- /dev/null +++ b/app/cli/internal/trace/opencode/types.go @@ -0,0 +1,111 @@ +package opencode + +import "encoding/json" + +// types.go mirrors the JSON structures emitted by `opencode export ` +// and `opencode session list --format json`. Only the fields consumed by the +// trace provider are modelled; unknown fields are ignored by encoding/json. + +// exportData is the top-level shape of `opencode export`: +// +// { "info": SessionInfo, "messages": [{ "info": MessageInfo, "parts": [Part] }] } +type exportData struct { + Info sessionInfo `json:"info"` + Messages []messageEntry `json:"messages"` +} + +// sessionInfo mirrors SessionV1.SessionInfo in the opencode schema. +type sessionInfo struct { + ID string `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + Directory string `json:"directory"` + Version string `json:"version"` + Cost *float64 `json:"cost,omitempty"` + Tokens *sessTokens `json:"tokens,omitempty"` + Time sessTime `json:"time"` +} + +type sessTime struct { + Created float64 `json:"created"` + Updated float64 `json:"updated"` +} + +type sessTokens struct { + Input int `json:"input"` + Output int `json:"output"` + Cache sessTokenCache `json:"cache"` +} + +type sessTokenCache struct { + Read int `json:"read"` + Write int `json:"write"` +} + +// messageEntry pairs a message's metadata with its parts. +type messageEntry struct { + Info messageInfo `json:"info"` + Parts []part `json:"parts"` +} + +// messageInfo is the union of User and Assistant message metadata. The +// discriminator is the Role field. Only fields we consume are modelled. +type messageInfo struct { + Role string `json:"role"` + ID string `json:"id"` + ModelID string `json:"modelID,omitempty"` + ProviderID string `json:"providerID,omitempty"` + Cost float64 `json:"cost,omitempty"` + Tokens *msgTokens `json:"tokens,omitempty"` + Time *msgTime `json:"time,omitempty"` + Model *userModelInfo `json:"model,omitempty"` +} + +type userModelInfo struct { + ProviderID string `json:"providerID"` + ModelID string `json:"modelID"` +} + +type msgTokens struct { + Input int `json:"input"` + Output int `json:"output"` + Cache msgTokenCache `json:"cache"` +} + +type msgTokenCache struct { + Read int `json:"read"` + Write int `json:"write"` +} + +type msgTime struct { + Created float64 `json:"created"` + Completed float64 `json:"completed,omitempty"` +} + +// part is a union of all part types in the opencode schema. The +// discriminator is the Type field. Only fields we consume are modelled. +type part struct { + Type string `json:"type"` + ID string `json:"id,omitempty"` + Text string `json:"text,omitempty"` + Tool string `json:"tool,omitempty"` + State *toolState `json:"state,omitempty"` +} + +// toolState is the union of tool states. The discriminator is the Status +// field. Input and Output are captured as raw JSON so the frontend can +// display tool call details without us modelling every tool's schema. +type toolState struct { + Status string `json:"status"` + Input json.RawMessage `json:"input,omitempty"` + Output json.RawMessage `json:"output,omitempty"` +} + +// sessionListEntry mirrors one element of `opencode session list --format json`. +type sessionListEntry struct { + ID string `json:"id"` + Title string `json:"title"` + Directory string `json:"directory"` + Updated float64 `json:"updated"` + Created float64 `json:"created"` +} diff --git a/app/cli/internal/trace/provider.go b/app/cli/internal/trace/provider.go new file mode 100644 index 000000000..33d4a2616 --- /dev/null +++ b/app/cli/internal/trace/provider.go @@ -0,0 +1,147 @@ +package trace + +import ( + "context" + "io" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" +) + +// Provider discovers and parses AI coding sessions for a specific agent. +// +// Providers are stateless singletons from a registry, so the state-touching +// methods below take a *state.Store per call rather than holding one. The store +// already knows where trace state lives — the .git directory inside a +// repository, or the out-of-tree directory `chainloop trace run` picks outside +// one — so implementations never reason about that distinction. +type Provider interface { + // Name returns the agent identifier (e.g., "claude-code", "cursor"). + Name() string + + // DiscoverSession finds the most relevant session for the given repo root. + // Returns nil, nil if no matching session is found. + DiscoverSession(repoRoot string) (*DiscoveredSession, error) + + // ParseSession parses a session and returns structured evidence. + ParseSession(ctx context.Context, opts *ParseOpts) (*aicodingsession.Evidence, error) + + // SessionDirForRepo returns the agent's session data directory for a given repo root. + SessionDirForRepo(repoRoot string) string + + // CopySessionData copies the agent's on-disk session artifacts into + // the store's raw/ directory so pre-push can parse them independently of + // the agent's own storage (which may be rotated/cleaned later). + CopySessionData(store *state.Store, repoRoot, sessionID string) error + + // CaptureFileSnapshot is invoked from the pre-edit hook to record any + // state the provider needs to later reconstruct the file's pre-edit + // content. Providers whose post-edit hook delivers the edit payload + // directly (e.g. Cursor's afterFileEdit with old/new strings) + // implement this as a no-op. + CaptureFileSnapshot(store *state.Store, input *HookInput) error + + // ResolveBeforeContent reconstructs the file's content as it was + // before the edit, given its current ("after") content. Returns nil + // when no reconstruction is possible (no snapshot, no edits, or the + // file was newly created); callers treat nil as "all lines are AI". + ResolveBeforeContent(store *state.Store, input *HookInput, after []byte) []byte + + // CleanupAfterEdit releases any per-edit state captured in + // CaptureFileSnapshot. Called once the post-edit handler is done with + // the file, regardless of whether ranges were recorded. + CleanupAfterEdit(store *state.Store, input *HookInput) + + // InstallHooks installs the agent's hooks in the repo (e.g., .claude/settings.json). + InstallHooks(repoRoot string) error + + // InstallHooksForTraceRun installs the data-gathering subset of hooks + // used by `chainloop trace run`. End-of-session hooks are omitted — + // trace run drives attestation itself. + InstallHooksForTraceRun(repoRoot string) error + + // SettingsFile returns the absolute path to the agent's on-disk + // hooks/settings file (e.g. .claude/settings.json). Callers use it to + // back up the file before install and restore it afterwards. + SettingsFile(repoRoot string) string + + // UninstallHooks removes the agent's hooks from the repo. + UninstallHooks(repoRoot string) error + + // ReadHookInput reads hook invocation input from the given reader. + ReadHookInput(r io.Reader) (*HookInput, error) + + // IsFileWritingTool returns true if the named tool modifies files on disk. + IsFileWritingTool(toolName string) bool + + // IsCommandTool returns true if the named tool runs a shell command (e.g. + // Claude's "Bash"). Such tools can create or modify arbitrary files without + // firing the file-writing hooks, so they are captured via a before/after + // working-tree snapshot instead of a per-file snapshot. + IsCommandTool(toolName string) bool + + // SystemMessage writes a message to stdout for the agent to display on session start. + SystemMessage(msg string) error +} + +// HookInput represents parsed hook invocation data from an AI agent. +type HookInput struct { + // SessionID is the agent-assigned identifier for the session. + SessionID string `json:"session_id"` + // HookEventName is the agent-specific event name (e.g., "PreToolUse", "afterFileEdit"). + HookEventName string `json:"hook_event_name,omitempty"` + // ToolName is the name of the tool being invoked, if applicable. + ToolName string `json:"tool_name,omitempty"` + // FilePath is the absolute path of the file being edited, set by provider's ReadHookInput. + FilePath string `json:"-"` + // AgentVersion is the agent runtime version reported in the hook payload + // (e.g., Cursor's cursor_version). Captured at session-start so parsing + // can set Agent.Version even when the transcript itself doesn't carry it. + AgentVersion string `json:"-"` + // Model is the model identifier reported in the hook payload (e.g., + // Cursor's "model" field). Captured at session-start so parsing can + // set Model.Primary even when the transcript itself doesn't carry it. + Model string `json:"-"` + // Edits carries per-file string replacements reported by the agent. + // Providers that snapshot files pre-edit leave this empty; providers that + // only emit post-edit events (e.g., Cursor's afterFileEdit) populate it so + // consumers can reconstruct the "before" content via reverse application. + Edits []HookEdit `json:"-"` +} + +// HookEdit represents a single old_string → new_string replacement applied to a file. +type HookEdit struct { + // OldString is the text that was replaced. + OldString string + // NewString is the text that replaced OldString. + NewString string +} + +// DiscoveredSession represents a discovered AI coding session (agent-agnostic). +type DiscoveredSession struct { + SessionID string + SessionDir string + IsActive bool +} + +// ParseOpts configures session parsing. +type ParseOpts struct { + // SessionDir is the directory holding the copied session transcript. + SessionDir string + // SessionID identifies the session to parse. + SessionID string + // AgentVersion is the runtime version captured at session-start (when + // the agent reports it via hook payload). Providers whose transcripts + // don't embed a version can use this to populate Agent.Version. + AgentVersion string + // Model is the model identifier captured at session-start. Providers + // whose transcripts don't embed model info can use this to populate + // Model.Primary. + Model string +} + +// DefaultProviderName is the provider used when a SessionRecord predates the +// Provider field or when no explicit provider is selected. Kept here so +// both the providers registry and the action package can reference it +// without a package import. +const DefaultProviderName = "claude-code" diff --git a/app/cli/internal/trace/providers/registry.go b/app/cli/internal/trace/providers/registry.go new file mode 100644 index 000000000..b1d4e4872 --- /dev/null +++ b/app/cli/internal/trace/providers/registry.go @@ -0,0 +1,61 @@ +// Package providers registers all available AI coding agent trace providers +// in one place. Callers (CLI commands, pre-push hook) use this registry +// instead of instantiating provider packages directly, so adding a new +// provider is a one-line change here. +package providers + +import ( + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/claude" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/cursor" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/opencode" +) + +// DefaultProvider is the provider name used when none is specified +// (e.g. `trace init` with no --claude/--cursor flag, or a SessionRecord +// predating the Provider field). +const DefaultProvider = trace.DefaultProviderName + +// all holds the canonical set of providers. Instances are allocated once at +// init so callers don't pay for re-construction on every lookup. +var all = []trace.Provider{ + claude.New(), + cursor.New(), + opencode.New(), +} + +// byName is a lookup-optimized view of all, built once at init. +var byName = func() map[string]trace.Provider { + m := make(map[string]trace.Provider, len(all)) + for _, p := range all { + m[p.Name()] = p + } + + return m +}() + +// All returns every registered provider in a stable, deterministic order. +// The order is meaningful for user-facing listings in `trace init` logs. +func All() []trace.Provider { + return all +} + +// ByName returns the registered provider with the given name, or nil when +// the name is unknown. +func ByName(name string) trace.Provider { + return byName[name] +} + +// ByNames resolves a list of names into providers, dropping any unknown +// entries. Returns an empty slice (not nil) when no names match, so +// callers can range without nil checks. +func ByNames(names []string) []trace.Provider { + out := make([]trace.Provider, 0, len(names)) + for _, n := range names { + if p := byName[n]; p != nil { + out = append(out, p) + } + } + + return out +} diff --git a/app/cli/internal/trace/state/ailines.go b/app/cli/internal/trace/state/ailines.go new file mode 100644 index 000000000..4ccd4e2d6 --- /dev/null +++ b/app/cli/internal/trace/state/ailines.go @@ -0,0 +1,111 @@ +package state + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" +) + +// AILineAttribution tracks which line ranges in which files a session modified. +type AILineAttribution struct { + SessionID string `json:"session_id"` + Files map[string][]aicodingsession.LineRange `json:"files"` +} + +// newAILineAttribution returns an AILineAttribution with an initialized Files map. +func newAILineAttribution(sessionID string) *AILineAttribution { + return &AILineAttribution{SessionID: sessionID, Files: make(map[string][]aicodingsession.LineRange)} +} + +// aiLinesPath returns the path for a session's AI line attribution file (JSONL). +func (s *Store) aiLinesPath(sessionID string) string { + return filepath.Join(s.traceDirPath(), traceDirAILines, sanitizeID(sessionID)+".jsonl") +} + +// aiLineEntry is a single JSONL record appended by RecordLineRanges. +type aiLineEntry struct { + // SessionID is the agent-assigned session ID. The filename carries only + // its sanitized form, which readers cannot map back to the original. + SessionID string `json:"session_id,omitempty"` + File string `json:"file"` + Ranges []aicodingsession.LineRange `json:"ranges"` +} + +// LoadAILineAttribution loads the AI line attribution for a session by +// consolidating all JSONL entries into a single AILineAttribution. +// Returns a zero-value struct if the file doesn't exist. +func (s *Store) LoadAILineAttribution(sessionID string) *AILineAttribution { + attr := newAILineAttribution(sessionID) + + f, err := os.Open(s.aiLinesPath(sessionID)) + if err != nil { + return attr + } + defer func() { _ = f.Close() }() + + dec := json.NewDecoder(f) + for dec.More() { + var entry aiLineEntry + if err := dec.Decode(&entry); err != nil { + break + } + if entry.SessionID != "" { + attr.SessionID = entry.SessionID + } + attr.Files[entry.File] = append(attr.Files[entry.File], entry.Ranges...) + } + + return attr +} + +// RecordLineRanges appends line ranges for a file to a session's attribution JSONL. +// An empty or nil ranges slice still records the file as touched (e.g., deletion-only edits). +func (s *Store) RecordLineRanges(sessionID, filePath string, ranges []aicodingsession.LineRange) error { + path := s.aiLinesPath(sessionID) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("create ai-lines dir: %w", err) + } + + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return err + } + + if err := json.NewEncoder(f).Encode(aiLineEntry{SessionID: sessionID, File: filePath, Ranges: ranges}); err != nil { + _ = f.Close() + return err + } + return f.Close() +} + +// LoadAllAILineAttributions loads AI line attribution for all sessions that have data. +func (s *Store) LoadAllAILineAttributions() ([]*AILineAttribution, error) { + dir := filepath.Join(s.traceDirPath(), traceDirAILines) + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, err + } + + var result []*AILineAttribution + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") { + continue + } + // The filename is the sanitized ID; LoadAILineAttribution replaces it + // with the agent-assigned one recorded in the file. + attr := s.LoadAILineAttribution(strings.TrimSuffix(entry.Name(), ".jsonl")) + if len(attr.Files) > 0 { + result = append(result, attr) + } + } + + return result, nil +} diff --git a/app/cli/internal/trace/state/commit.go b/app/cli/internal/trace/state/commit.go new file mode 100644 index 000000000..2e9833686 --- /dev/null +++ b/app/cli/internal/trace/state/commit.go @@ -0,0 +1,81 @@ +package state + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" +) + +// CommitRecord holds metadata about a commit captured by the post-commit hook. +type CommitRecord struct { + SHA string `json:"sha"` + Message string `json:"message"` + SessionIDs []string `json:"session_ids,omitempty"` + Timestamp string `json:"timestamp"` + // Tracked marks the record as already attested by a previous successful + // trace push. The pre-push hook uses it to skip pushing a redundant + // attestation when no new AI-assisted commits have been recorded since + // the last push (e.g. a bare `git push` with no new commits, or a + // `git tag && git push --tags` over already-pushed commits). + Tracked bool `json:"tracked,omitempty"` +} + +// SaveCommitRecord writes a commit record to /chainloop-trace/commits/.json. +func (s *Store) SaveCommitRecord(rec *CommitRecord) error { + dir := filepath.Join(s.traceDirPath(), traceDirCommits) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + + data, err := json.MarshalIndent(rec, "", " ") + if err != nil { + return err + } + + return os.WriteFile(filepath.Join(dir, rec.SHA+".json"), data, 0600) +} + +// loadCommitRecord reads a single CommitRecord file. Used by the GC pass +// where we only need to inspect each record's metadata, not load every record +// into memory at once. +func loadCommitRecord(path string) (*CommitRecord, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var rec CommitRecord + if err := json.Unmarshal(data, &rec); err != nil { + return nil, err + } + + return &rec, nil +} + +// LoadAllCommitRecords reads all commit records from /chainloop-trace/commits/. +func (s *Store) LoadAllCommitRecords() ([]*CommitRecord, error) { + dir := filepath.Join(s.traceDirPath(), traceDirCommits) + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, err + } + + var records []*CommitRecord + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + rec, err := loadCommitRecord(filepath.Join(dir, entry.Name())) + if err != nil { + continue + } + records = append(records, rec) + } + + return records, nil +} diff --git a/app/cli/internal/trace/state/jsonlcopy.go b/app/cli/internal/trace/state/jsonlcopy.go new file mode 100644 index 000000000..9075349fb --- /dev/null +++ b/app/cli/internal/trace/state/jsonlcopy.go @@ -0,0 +1,128 @@ +package state + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" +) + +// CopySessionJSONL copies the Claude Code JSONL file to /chainloop-trace/raw/.jsonl. +// It also copies subagent files if they exist. +func (s *Store) CopySessionJSONL(sessionID, sourceDir string) error { + rawDir := filepath.Join(s.traceDirPath(), traceDirRaw) + if err := os.MkdirAll(rawDir, 0755); err != nil { + return err + } + + // The source lives in the agent's own directory, named by the ID the agent + // assigned; only our copy gets the sanitized name. Joining an unchecked ID + // here would read outside sourceDir. + if !ValidSessionID(sessionID) { + return fmt.Errorf("session ID %q cannot name a transcript file", sessionID) + } + srcPath := filepath.Join(sourceDir, sessionID+".jsonl") + + if err := copyFile(srcPath, RawSessionPath(rawDir, sessionID)); err != nil { + return fmt.Errorf("copy session JSONL: %w", err) + } + + // Copy subagent directory if present — skip when the directory doesn't exist + subagentsSrc := filepath.Join(sourceDir, sessionID, "subagents") + subagentsDst := RawSubagentDir(rawDir, sessionID) + if err := copyDir(subagentsSrc, subagentsDst); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("copy subagent files: %w", err) + } + + return nil +} + +// RawSessionDir returns the path to /chainloop-trace/raw/ for use as session dir in parsing. +func (s *Store) RawSessionDir() string { + return filepath.Join(s.traceDirPath(), traceDirRaw) +} + +// CopyRawSessionFile copies a single session transcript file from srcPath +// to /chainloop-trace/raw/.jsonl. Used by providers +// whose transcripts are single files (e.g. Cursor); Claude's multi-file +// layout (main + subagents) goes through CopySessionJSONL instead. +func (s *Store) CopyRawSessionFile(sessionID, srcPath string) error { + rawDir := s.RawSessionDir() + dst := filepath.Join(rawDir, SanitizeID(sessionID)+".jsonl") + + return copyFile(srcPath, dst) +} + +// RawSessionPath returns the path a session's raw transcript is copied to +// inside rawDir. Readers and writers must both name the file through this +// helper: the sanitized ID it applies is not recoverable from the raw one. +func RawSessionPath(rawDir, sessionID string) string { + return filepath.Join(rawDir, SanitizeID(sessionID)+".jsonl") +} + +// RawSubagentDir returns the directory a session's copied subagent transcripts +// live in inside rawDir. Same naming contract as RawSessionPath. +func RawSubagentDir(rawDir, sessionID string) string { + return filepath.Join(rawDir, SanitizeID(sessionID), "subagents") +} + +// FindRawSessionFile returns the path to the raw session file for sessionID +// within rawDir, or an error wrapping os.ErrNotExist when missing. +func FindRawSessionFile(rawDir, sessionID string) (string, error) { + path := RawSessionPath(rawDir, sessionID) + if _, err := os.Stat(path); err != nil { + return "", err + } + + return path, nil +} + +func copyFile(src, dst string) error { + srcFile, err := os.Open(src) + if err != nil { + return err + } + defer func() { _ = srcFile.Close() }() + + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return err + } + + dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if err != nil { + return err + } + defer func() { _ = dstFile.Close() }() + + _, err = io.Copy(dstFile, srcFile) + return err +} + +func copyDir(src, dst string) error { + entries, err := os.ReadDir(src) + if err != nil { + return err + } + + if err := os.MkdirAll(dst, 0755); err != nil { + return err + } + + for _, entry := range entries { + srcPath := filepath.Join(src, entry.Name()) + dstPath := filepath.Join(dst, entry.Name()) + + if entry.IsDir() { + if err := copyDir(srcPath, dstPath); err != nil { + return err + } + } else { + if err := copyFile(srcPath, dstPath); err != nil { + return err + } + } + } + + return nil +} diff --git a/app/cli/internal/trace/state/locate.go b/app/cli/internal/trace/state/locate.go new file mode 100644 index 000000000..2c7b5e1e1 --- /dev/null +++ b/app/cli/internal/trace/state/locate.go @@ -0,0 +1,119 @@ +package state + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + + tracegit "github.com/chainloop-dev/chainloop/app/cli/internal/trace/git" +) + +// Locate returns a Store over the directory that parents chainloop-trace +// state, plus the working-tree root. Inside a git repository those are the .git +// directory and the repo root; outside one, they are the NonGitDir of the +// nearest ancestor of cwd with an active `chainloop trace run`, and that +// ancestor. The returned Store reports which of the two it is via IsGit. +// +// Constructing the Store here is deliberate: callers never hold a bare state +// path they could confuse with the repo root returned alongside it. +// +// The non-git branch probes the run-active sentinel rather than the mere +// existence of a state directory, so one left behind by a crashed run is not +// bound to by an unrelated later invocation. Only ErrNotARepository reaches +// that branch: a broken .git means a repository is there and unusable, and +// binding such a clone to an ancestor's out-of-tree state would silently +// record its commits nowhere. +func Locate() (store *Store, root string, err error) { + gitDir, repoRoot, err := tracegit.FindGitDirAndRoot() + switch { + case err == nil: + return NewGitStore(gitDir), repoRoot, nil + case !errors.Is(err, tracegit.ErrNotARepository): + return nil, "", err + } + + cwd, err := os.Getwd() + if err != nil { + return nil, "", fmt.Errorf("get working directory: %w", err) + } + + base, err := nonGitBase() + if err != nil { + return nil, "", err + } + + for dir := resolveDir(cwd); ; { + candidate := NewOutOfTreeStore(filepath.Join(base, hashDir(dir))) + if candidate.IsTraceRunActive() { + return candidate, dir, nil + } + + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + + return nil, "", fmt.Errorf("not a git repository and no active chainloop trace run found from %q up to root", cwd) +} + +// NonGitDir returns the out-of-tree directory that parents trace state for a +// working directory with no git repository, under /chainloop/trace/. +// +// The path is derived from dir alone so that hook subprocesses recompute it +// instead of inheriting it: a hook is a grandchild of `chainloop trace run` +// (run -> agent -> hook), and an agent that rewrites the environment would +// otherwise send the hook to a different directory where it would silently +// record nothing. The user cache dir is $HOME-derived, which survives that far +// better than $TMPDIR, and it keeps live session state away from /tmp reapers. +func NonGitDir(dir string) (string, error) { + base, err := nonGitBase() + if err != nil { + return "", err + } + + return filepath.Join(base, hashDir(dir)), nil +} + +// nonGitBase returns the parent of every non-git trace state directory. +// An error here means $HOME (and $XDG_CACHE_HOME) are unset — the one case that +// must not be papered over with a fallback path, since the run and its hooks +// would then disagree on where state lives. +func nonGitBase() (string, error) { + cacheDir, err := os.UserCacheDir() + if err != nil { + return "", fmt.Errorf("locate user cache directory: %w", err) + } + + return filepath.Join(cacheDir, "chainloop", "trace"), nil +} + +// hashDir returns the state directory name for a working directory. Symlinks +// are resolved first so two spellings of the same directory (e.g. /tmp/x and +// /private/tmp/x on macOS) map to one state directory. +func hashDir(dir string) string { + sum := sha256.Sum256([]byte(resolveDir(dir))) + + return hex.EncodeToString(sum[:])[:16] +} + +// resolveDir returns dir as an absolute, symlink-free path, degrading to the +// closest approximation it can compute. Duplicated from the config package, +// which cannot import this one. +func resolveDir(dir string) string { + abs, err := filepath.Abs(dir) + if err != nil { + return dir + } + + resolved, err := filepath.EvalSymlinks(abs) + if err != nil { + return abs + } + + return resolved +} diff --git a/app/cli/internal/trace/state/locate_test.go b/app/cli/internal/trace/state/locate_test.go new file mode 100644 index 000000000..e055bd015 --- /dev/null +++ b/app/cli/internal/trace/state/locate_test.go @@ -0,0 +1,154 @@ +package state + +import ( + "os" + "path/filepath" + "testing" + + tracegit "github.com/chainloop-dev/chainloop/app/cli/internal/trace/git" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// isolateCacheDir points os.UserCacheDir at a temp directory so non-git state +// tests never touch the developer's real cache. HOME covers macOS, where +// os.UserCacheDir derives from it; XDG_CACHE_HOME covers Linux. +func isolateCacheDir(t *testing.T) { + t.Helper() + + cache := t.TempDir() + t.Setenv("HOME", cache) + t.Setenv("XDG_CACHE_HOME", cache) +} + +// TestNonGitDirSymlink guards the failure mode that would silently stop +// all recording: `trace run` and its hook subprocesses reaching the same +// directory by different names must agree on one state directory. +func TestNonGitDirSymlink(t *testing.T) { + isolateCacheDir(t) + + realDir := t.TempDir() + link := filepath.Join(t.TempDir(), "link") + require.NoError(t, os.Symlink(realDir, link)) + + viaReal, err := NonGitDir(realDir) + require.NoError(t, err) + viaLink, err := NonGitDir(link) + require.NoError(t, err) + + assert.Equal(t, viaReal, viaLink, "a symlink must resolve to the same state dir as its target") + assert.True(t, filepath.IsAbs(viaReal), "should return an absolute path") +} + +func TestNonGitDirIsPerDirectory(t *testing.T) { + isolateCacheDir(t) + + first, err := NonGitDir(t.TempDir()) + require.NoError(t, err) + second, err := NonGitDir(t.TempDir()) + require.NoError(t, err) + + assert.NotEqual(t, first, second, "two working directories must not share a state dir") +} + +func TestLocate(t *testing.T) { + t.Run("inside a git repository returns .git", func(t *testing.T) { + isolateCacheDir(t) + + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".git"), 0755)) + t.Chdir(dir) + + store, root, err := Locate() + require.NoError(t, err) + assert.True(t, store.IsGit()) + assert.Equal(t, filepath.Join(root, ".git"), store.GitDir()) + }) + + t.Run("no repository and no active run errors", func(t *testing.T) { + isolateCacheDir(t) + t.Chdir(t.TempDir()) + + _, _, err := Locate() + assert.Error(t, err) + }) + + t.Run("no repository binds to the active run in cwd", func(t *testing.T) { + isolateCacheDir(t) + + dir := t.TempDir() + t.Chdir(dir) + + want, err := NonGitDir(dir) + require.NoError(t, err) + wantStore := NewOutOfTreeStore(want) + require.NoError(t, wantStore.InitTraceDir()) + require.NoError(t, wantStore.MarkTraceRunActive()) + + store, root, err := Locate() + require.NoError(t, err) + assert.False(t, store.IsGit()) + assert.Empty(t, store.GitDir(), "an out-of-tree store must not pass for a .git directory") + assert.Equal(t, want, store.Dir()) + assert.Equal(t, resolveDir(dir), root) + }) + + t.Run("no repository walks up to an ancestor's active run", func(t *testing.T) { + isolateCacheDir(t) + + dir := t.TempDir() + nested := filepath.Join(dir, "a", "b") + require.NoError(t, os.MkdirAll(nested, 0755)) + + want, err := NonGitDir(dir) + require.NoError(t, err) + wantStore := NewOutOfTreeStore(want) + require.NoError(t, wantStore.InitTraceDir()) + require.NoError(t, wantStore.MarkTraceRunActive()) + + t.Chdir(nested) + + store, root, err := Locate() + require.NoError(t, err) + assert.False(t, store.IsGit()) + assert.Empty(t, store.GitDir(), "an out-of-tree store must not pass for a .git directory") + assert.Equal(t, want, store.Dir()) + assert.Equal(t, resolveDir(dir), root) + }) + + // A broken .git must surface, not silently degrade to non-git mode: the + // clone's commits would then be recorded against an unrelated state dir. + t.Run("a broken .git is not treated as no repository", func(t *testing.T) { + isolateCacheDir(t) + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".git"), []byte("gitdir: /nope/missing\n"), 0600)) + t.Chdir(dir) + + // An active run in the same directory is what Locate would wrongly + // bind to if it read every git error as "no repository". + nonGit, err := NonGitDir(dir) + require.NoError(t, err) + require.NoError(t, NewOutOfTreeStore(nonGit).MarkTraceRunActive()) + + _, _, err = Locate() + require.Error(t, err) + assert.NotErrorIs(t, err, tracegit.ErrNotARepository) + }) + + t.Run("a state dir without the sentinel is ignored", func(t *testing.T) { + isolateCacheDir(t) + + dir := t.TempDir() + t.Chdir(dir) + + // Leftovers from a crashed run that already cleared the sentinel + // must not capture an unrelated invocation. + stale, err := NonGitDir(dir) + require.NoError(t, err) + require.NoError(t, NewOutOfTreeStore(stale).InitTraceDir()) + + _, _, err = Locate() + assert.Error(t, err) + }) +} diff --git a/app/cli/internal/trace/state/session.go b/app/cli/internal/trace/state/session.go new file mode 100644 index 000000000..c17ff3358 --- /dev/null +++ b/app/cli/internal/trace/state/session.go @@ -0,0 +1,104 @@ +package state + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// SessionRecord holds metadata about an AI coding agent session. +type SessionRecord struct { + // SessionID is the agent-assigned identifier for this session. + SessionID string `json:"session_id"` + // Provider identifies the agent that produced this session (e.g. "claude-code", "cursor"). + // Empty on records written before multi-provider support; consumers should treat that as claude-code. + Provider string `json:"provider,omitempty"` + // AgentVersion is the agent runtime version reported at session-start + // (e.g., Cursor's cursor_version). Empty for providers whose hook payload + // does not carry the version. Recorded once on the first hook only. + AgentVersion string `json:"agent_version,omitempty"` + // Model is the model identifier reported at session-start (e.g., + // Cursor's "model" field). Empty for providers whose hook payload does + // not carry the model. Recorded once on the first hook only. + Model string `json:"model,omitempty"` + // Active reports whether the session is ongoing at the time of record write. + Active bool `json:"active"` + // StartedAt is the RFC3339 timestamp of when tracking began for this session. + StartedAt string `json:"started_at"` +} + +// SaveSessionRecord writes a session record to /chainloop-trace/sessions/.json. +func (s *Store) SaveSessionRecord(rec *SessionRecord) error { + dir := filepath.Join(s.traceDirPath(), traceDirSessions) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + + data, err := json.MarshalIndent(rec, "", " ") + if err != nil { + return err + } + + return os.WriteFile(filepath.Join(dir, sanitizeID(rec.SessionID)+".json"), data, 0600) +} + +// SessionRecordExists checks whether a session record file exists for the given session ID. +func (s *Store) SessionRecordExists(sessionID string) bool { + path := filepath.Join(s.traceDirPath(), traceDirSessions, sanitizeID(sessionID)+".json") + _, err := os.Stat(path) + return err == nil +} + +// LoadSessionRecord reads the SessionRecord for sessionID. Returns nil, nil when not found. +func (s *Store) LoadSessionRecord(sessionID string) (*SessionRecord, error) { + path := filepath.Join(s.traceDirPath(), traceDirSessions, sanitizeID(sessionID)+".json") + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + + return nil, err + } + + var rec SessionRecord + if err := json.Unmarshal(data, &rec); err != nil { + return nil, err + } + + return &rec, nil +} + +// LoadAllSessionRecords reads every SessionRecord under +// /chainloop-trace/sessions/ into a map keyed by SessionID. A missing +// directory yields an empty (non-nil) map and no error; per-file decode +// errors are skipped so a single malformed record doesn't abort the load. +func (s *Store) LoadAllSessionRecords() (map[string]*SessionRecord, error) { + dir := filepath.Join(s.traceDirPath(), traceDirSessions) + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return map[string]*SessionRecord{}, nil + } + + return nil, err + } + + out := make(map[string]*SessionRecord, len(entries)) + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } + data, err := os.ReadFile(filepath.Join(dir, entry.Name())) + if err != nil { + continue + } + var rec SessionRecord + if err := json.Unmarshal(data, &rec); err != nil { + continue + } + out[rec.SessionID] = &rec + } + + return out, nil +} diff --git a/app/cli/internal/trace/state/snapshot.go b/app/cli/internal/trace/state/snapshot.go new file mode 100644 index 000000000..df4a69b5c --- /dev/null +++ b/app/cli/internal/trace/state/snapshot.go @@ -0,0 +1,82 @@ +package state + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// snapshotPath returns the path for a file snapshot: /chainloop-trace/snapshots// +func (s *Store) snapshotPath(sessionID, filePath string) string { + h := sha256.Sum256([]byte(filePath)) + name := hex.EncodeToString(h[:8]) // 16-char hex, enough to avoid collisions + return filepath.Join(s.traceDirPath(), traceDirSnapshots, sanitizeID(sessionID), name) +} + +// SaveFileSnapshot stores a file's content before an AI edit. +func (s *Store) SaveFileSnapshot(sessionID, filePath string, content []byte) error { + path := s.snapshotPath(sessionID, filePath) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("create snapshot dir: %w", err) + } + + return os.WriteFile(path, content, 0600) +} + +// LoadFileSnapshot loads a previously stored file snapshot. +func (s *Store) LoadFileSnapshot(sessionID, filePath string) ([]byte, error) { + return os.ReadFile(s.snapshotPath(sessionID, filePath)) +} + +// DeleteFileSnapshot removes a file snapshot after it's been processed. +func (s *Store) DeleteFileSnapshot(sessionID, filePath string) { + path := s.snapshotPath(sessionID, filePath) + _ = os.Remove(path) +} + +// shellPreSignaturePath returns the path storing the pre-command working-tree +// signature for a session: /chainloop-trace/snapshots//shell-pre.json +func (s *Store) shellPreSignaturePath(sessionID string) string { + return filepath.Join(s.traceDirPath(), traceDirSnapshots, sanitizeID(sessionID), "shell-pre.json") +} + +// SaveShellPreSignature stores the working-tree signature captured before an +// agent-run shell command, so the post-command hook can diff against it. A +// single slot per session is used; concurrent shell calls in one turn overwrite +// it (see the parallel-shell limitation). +func (s *Store) SaveShellPreSignature(sessionID string, sig map[string]string) error { + path := s.shellPreSignaturePath(sessionID) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("create snapshot dir: %w", err) + } + + data, err := json.Marshal(sig) + if err != nil { + return fmt.Errorf("marshal shell signature: %w", err) + } + + return os.WriteFile(path, data, 0600) +} + +// LoadShellPreSignature loads the pre-command working-tree signature for a session. +func (s *Store) LoadShellPreSignature(sessionID string) (map[string]string, error) { + data, err := os.ReadFile(s.shellPreSignaturePath(sessionID)) + if err != nil { + return nil, err + } + + var sig map[string]string + if err := json.Unmarshal(data, &sig); err != nil { + return nil, fmt.Errorf("parse shell signature: %w", err) + } + + return sig, nil +} + +// DeleteShellPreSignature removes the pre-command signature once processed. +func (s *Store) DeleteShellPreSignature(sessionID string) { + _ = os.Remove(s.shellPreSignaturePath(sessionID)) +} diff --git a/app/cli/internal/trace/state/snapshot_test.go b/app/cli/internal/trace/state/snapshot_test.go new file mode 100644 index 000000000..961dcfbba --- /dev/null +++ b/app/cli/internal/trace/state/snapshot_test.go @@ -0,0 +1,28 @@ +package state + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestShellPreSignatureRoundTrip(t *testing.T) { + store := NewGitStore(t.TempDir()) + sessionID := "sess-123" + sig := map[string]string{ + "a.go": "hash-a", + "sub/b.json": "hash-b", + } + + require.NoError(t, store.SaveShellPreSignature(sessionID, sig)) + + loaded, err := store.LoadShellPreSignature(sessionID) + require.NoError(t, err) + assert.Equal(t, sig, loaded) + + store.DeleteShellPreSignature(sessionID) + + _, err = store.LoadShellPreSignature(sessionID) + assert.Error(t, err, "signature should be gone after delete") +} diff --git a/app/cli/internal/trace/state/state.go b/app/cli/internal/trace/state/state.go new file mode 100644 index 000000000..4c878a1ed --- /dev/null +++ b/app/cli/internal/trace/state/state.go @@ -0,0 +1,267 @@ +package state + +import ( + "crypto/sha256" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +const ( + // traceDir is the directory holding all trace state. It is created + // inside the Store's parent directory: .git/ inside a repository, or + // the out-of-tree directory `chainloop trace run` picks outside one. + traceDir = "chainloop-trace" + + // Subdirectories inside traceDir. + traceInitializedFile = "initialized" + // traceRunActiveFile is the sentinel created by `chainloop trace run` + // for the lifetime of its wrapped command. It lets hook subprocesses + // detect trace-run mode without depending on environment-variable + // inheritance from the agent. + traceRunActiveFile = "run-active" + traceDirSessions = "sessions" + traceDirRaw = "raw" + traceDirCommits = "commits" + traceDirSnapshots = "snapshots" + traceDirAILines = "ai-lines" + + // Per-record file extensions inside the subdirectories above. + commitRecordExt = ".json" + sessionRecordExt = ".json" + aiLinesExt = ".jsonl" +) + +// InitTraceDir creates the /chainloop-trace/ directory structure. +func (s *Store) InitTraceDir() error { + base := s.traceDirPath() + for _, sub := range []string{traceDirSessions, traceDirRaw, traceDirCommits, traceDirSnapshots, traceDirAILines} { + if err := os.MkdirAll(filepath.Join(base, sub), 0755); err != nil { + return fmt.Errorf("create %s directory: %w", sub, err) + } + } + return nil +} + +// WipeTraceDir clears the single-use data left over by a successful push: +// raw transcripts and per-edit file snapshots. It intentionally preserves +// commits/, ai-lines/, and sessions/ — those carry session attribution that +// survives across pushes (a rebase replays commits with the same trailers, so +// the next push can still attribute them). Bounded growth of the surviving +// subdirectories is the job of GCOrphans, called after the wipe. +func (s *Store) WipeTraceDir() error { + base := s.traceDirPath() + + for _, sub := range []string{traceDirRaw, traceDirSnapshots} { + if err := os.RemoveAll(filepath.Join(base, sub)); err != nil { + return fmt.Errorf("remove %s: %w", sub, err) + } + } + + // Re-create empty subdirectories + return s.InitTraceDir() +} + +// GCOrphans drops trace state whose underlying commit is no longer reachable +// from any local branch. liveSHAs is the union of SHAs reachable from every +// local branch tip; the caller computes it from the git client. +// +// Two cleanups in sequence: +// +// 1. Walk commits/, drop any record whose SHA isn't in liveSHAs (post-rebase +// orphan or commit on a deleted branch). +// 2. Walk ai-lines/ and sessions/, drop any entry whose session ID is no +// longer referenced by a surviving CommitRecord. +// +// An empty liveSHAs is treated as a pathological signal (no local branches +// readable, or the caller fed us garbage) and the GC short-circuits — wiping +// every record on a transient git error would be far worse than skipping a +// cycle of cleanup. +func (s *Store) GCOrphans(liveSHAs map[string]bool) error { + if len(liveSHAs) == 0 { + return nil + } + + commitsDir := filepath.Join(s.traceDirPath(), traceDirCommits) + commitEntries, err := os.ReadDir(commitsDir) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("read commits dir: %w", err) + } + + liveSessions := make(map[string]struct{}) + for _, entry := range commitEntries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), commitRecordExt) { + continue + } + path := filepath.Join(commitsDir, entry.Name()) + rec, readErr := loadCommitRecord(path) + if readErr != nil { + // Unreadable record — drop it; we can't reason about its SHA. + _ = os.Remove(path) + continue + } + if !liveSHAs[rec.SHA] { + _ = os.Remove(path) + continue + } + for _, sid := range rec.SessionIDs { + liveSessions[sanitizeID(sid)] = struct{}{} + } + } + + for _, child := range []struct { + sub string + ext string + }{ + {traceDirAILines, aiLinesExt}, + {traceDirSessions, sessionRecordExt}, + } { + dir := filepath.Join(s.traceDirPath(), child.sub) + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + + return fmt.Errorf("read %s dir: %w", child.sub, err) + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), child.ext) { + continue + } + id := strings.TrimSuffix(entry.Name(), child.ext) + if _, alive := liveSessions[id]; !alive { + _ = os.Remove(filepath.Join(dir, entry.Name())) + } + } + } + + return nil +} + +// RemoveTraceDir removes the entire /chainloop-trace/ directory. +func (s *Store) RemoveTraceDir() error { + return os.RemoveAll(s.traceDirPath()) +} + +// LogFilePath returns the path to the hook log file inside the trace directory. +func (s *Store) LogFilePath() string { + return filepath.Join(s.traceDirPath(), "log.txt") +} + +// AttestationStatePath returns the path to the local attestation state file +// under /chainloop-trace/. This file isolates trace attestation state +// from the default chainloop state location. +func (s *Store) AttestationStatePath() string { + return filepath.Join(s.traceDirPath(), "attestation-state.json") +} + +// IsTraceInitialized checks whether trace has been initialized in this clone. +func (s *Store) IsTraceInitialized() bool { + _, err := os.Stat(filepath.Join(s.traceDirPath(), traceInitializedFile)) + return err == nil +} + +// MarkTraceInitialized creates the sentinel file indicating trace is initialized. +func (s *Store) MarkTraceInitialized() error { + base := s.traceDirPath() + if err := os.MkdirAll(base, 0755); err != nil { + return err + } + return os.WriteFile(filepath.Join(base, traceInitializedFile), nil, 0600) +} + +// RemoveTraceInitialized removes the sentinel file. +func (s *Store) RemoveTraceInitialized() error { + return removeIfExists(filepath.Join(s.traceDirPath(), traceInitializedFile)) +} + +// MarkTraceRunActive marks the trace state as belonging to a running +// `chainloop trace run` session. Removed by ClearTraceRunActive when +// the wrapped command exits. +func (s *Store) MarkTraceRunActive() error { + base := s.traceDirPath() + if err := os.MkdirAll(base, 0755); err != nil { + return err + } + + return os.WriteFile(filepath.Join(base, traceRunActiveFile), nil, 0600) +} + +// ClearTraceRunActive removes the trace-run-active sentinel. +func (s *Store) ClearTraceRunActive() error { + return removeIfExists(filepath.Join(s.traceDirPath(), traceRunActiveFile)) +} + +// IsTraceRunActive reports whether a `chainloop trace run` session owns +// this store's trace state. +func (s *Store) IsTraceRunActive() bool { + _, err := os.Stat(filepath.Join(s.traceDirPath(), traceRunActiveFile)) + + return err == nil +} + +// ValidSessionID reports whether an agent-assigned session ID can safely name +// a file. IDs reach us from agent hook payloads and from commit-message +// trailers, and both are used to read the agent's own transcript directory, so +// anything that is not a single path element could escape it. Real IDs are +// UUID- or slug-shaped, so this rejects nothing legitimate. +// +// Writers additionally pass IDs through SanitizeID, which keeps a rejected ID +// from escaping our own state directory even if a caller skips this check. +func ValidSessionID(id string) bool { + return id != "" && id != "." && id != ".." && filepath.Base(id) == id +} + +// SanitizeID strips path separators and traversal components from an +// identifier before using it in filesystem paths. Exported so providers +// in other packages compute the same on-disk path that the writers in +// this package emit; otherwise an ID containing "/" or ".." would write +// to one filename and be read from another. +// +// A rewritten ID also gets a digest suffix, since distinct IDs ("a/x" and +// "a_x") would otherwise collapse onto one filename and merge two sessions' +// state. Separating the rewritten set is all that is needed: agent-assigned +// IDs are UUID-shaped and pass through untouched. +// +// Returning an already-safe ID verbatim makes this idempotent, which callers +// depend on — GCOrphans compares filename-derived keys against sanitized ones, +// and LoadAllAILineAttributions feeds a filename straight back in. Always +// appending the digest would double-suffix those keys and match nothing. +func SanitizeID(id string) string { + safe := filepath.Base(strings.ReplaceAll(id, "/", "_")) + if safe == "." || safe == ".." { + safe = "_" + } + if safe == id { + return safe + } + + sum := sha256.Sum256([]byte(id)) + + return fmt.Sprintf("%s-%x", safe, sum[:4]) +} + +// sanitizeID is kept as a private alias so existing call sites in this +// package read naturally; new code outside the package should use SanitizeID. +func sanitizeID(id string) string { + return SanitizeID(id) +} + +// removeIfExists removes a file, returning nil if it doesn't exist. +func removeIfExists(path string) error { + err := os.Remove(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + + return err +} + +// NowTimestamp returns the current UTC time in RFC3339 format. +func NowTimestamp() string { + return time.Now().UTC().Format(time.RFC3339) +} diff --git a/app/cli/internal/trace/state/state_test.go b/app/cli/internal/trace/state/state_test.go new file mode 100644 index 000000000..787d129a2 --- /dev/null +++ b/app/cli/internal/trace/state/state_test.go @@ -0,0 +1,346 @@ +package state + +import ( + "os" + "path/filepath" + "testing" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLogFilePath(t *testing.T) { + assert.Equal(t, filepath.Join("/fake/.git", "chainloop-trace", "log.txt"), NewGitStore("/fake/.git").LogFilePath()) +} + +func TestTraceInitialized(t *testing.T) { + store := NewGitStore(t.TempDir()) + + t.Run("not initialized by default", func(t *testing.T) { + assert.False(t, store.IsTraceInitialized()) + }) + + t.Run("mark and check initialized", func(t *testing.T) { + require.NoError(t, store.MarkTraceInitialized()) + assert.True(t, store.IsTraceInitialized()) + }) + + t.Run("remove initialized", func(t *testing.T) { + require.NoError(t, store.MarkTraceInitialized()) + require.NoError(t, store.RemoveTraceInitialized()) + assert.False(t, store.IsTraceInitialized()) + }) + + t.Run("remove nonexistent is noop", func(t *testing.T) { + empty := NewGitStore(t.TempDir()) + require.NoError(t, os.MkdirAll(empty.traceDirPath(), 0755)) + assert.NoError(t, empty.RemoveTraceInitialized()) + }) +} + +func TestInitTraceDir(t *testing.T) { + store := NewGitStore(t.TempDir()) + + require.NoError(t, store.InitTraceDir()) + + base := store.traceDirPath() + for _, sub := range []string{"sessions", "raw", "commits", "snapshots", "ai-lines"} { + info, err := os.Stat(filepath.Join(base, sub)) + require.NoError(t, err) + assert.True(t, info.IsDir()) + } +} + +func TestWipeTraceDir(t *testing.T) { + store := NewGitStore(t.TempDir()) + + require.NoError(t, store.InitTraceDir()) + require.NoError(t, store.MarkTraceInitialized()) + + // Populate one of every kind of state the wipe interacts with. + require.NoError(t, store.SaveCommitRecord(&CommitRecord{SHA: "abc123", Message: "test", SessionIDs: []string{"sess-1"}, Timestamp: "2026-03-28T00:00:00Z"})) + require.NoError(t, store.SaveSessionRecord(&SessionRecord{SessionID: "sess-1", Active: true, StartedAt: "2026-03-28T00:00:00Z"})) + require.NoError(t, store.RecordLineRanges("sess-1", "src/foo.go", []aicodingsession.LineRange{{Start: 1, End: 5}})) + require.NoError(t, store.SaveFileSnapshot("sess-1", "src/foo.go", []byte("before"))) + + require.NoError(t, store.WipeTraceDir()) + + // Sentinel preserved at trace dir root. + assert.True(t, store.IsTraceInitialized()) + + // Single-use state is gone (would otherwise leak into the next push). + _, err := store.LoadFileSnapshot("sess-1", "src/foo.go") + assert.True(t, os.IsNotExist(err), "snapshots should be wiped") + + // Attribution-bearing state survives so a future rebase that replays + // these commits can be re-attributed via the trailer (PFM-5878). + commits, err := store.LoadAllCommitRecords() + require.NoError(t, err) + assert.Len(t, commits, 1, "commits/ must survive the wipe — GC handles bounded growth") + + assert.True(t, store.SessionRecordExists("sess-1"), "session record must survive the wipe") + + attr := store.LoadAILineAttribution("sess-1") + require.NotNil(t, attr) + assert.Equal(t, []aicodingsession.LineRange{{Start: 1, End: 5}}, attr.Files["src/foo.go"], + "ai-lines must survive the wipe so post-rebase attribution still works") +} + +func TestGCOrphans(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + // Three commits; only sha-live is reachable from a local branch. + require.NoError(t, store.SaveCommitRecord(&CommitRecord{SHA: "sha-live", SessionIDs: []string{"sess-live"}, Timestamp: "2026-03-28T00:00:00Z"})) + require.NoError(t, store.SaveCommitRecord(&CommitRecord{SHA: "sha-orphan", SessionIDs: []string{"sess-orphan"}, Timestamp: "2026-03-28T00:00:00Z"})) + require.NoError(t, store.SaveCommitRecord(&CommitRecord{SHA: "sha-shared", SessionIDs: []string{"sess-live"}, Timestamp: "2026-03-28T00:00:00Z"})) + + // Both sessions have ai-lines + session records. + require.NoError(t, store.SaveSessionRecord(&SessionRecord{SessionID: "sess-live", Active: true, StartedAt: "2026-03-28T00:00:00Z"})) + require.NoError(t, store.SaveSessionRecord(&SessionRecord{SessionID: "sess-orphan", Active: false, StartedAt: "2026-03-28T00:00:00Z"})) + require.NoError(t, store.RecordLineRanges("sess-live", "live.go", []aicodingsession.LineRange{{Start: 1, End: 5}})) + require.NoError(t, store.RecordLineRanges("sess-orphan", "orphan.go", []aicodingsession.LineRange{{Start: 1, End: 5}})) + + require.NoError(t, store.GCOrphans(map[string]bool{"sha-live": true, "sha-shared": true})) + + commits, err := store.LoadAllCommitRecords() + require.NoError(t, err) + require.Len(t, commits, 2, "orphan SHA must be dropped; live and shared survive") + + gotSHAs := make([]string, 0, len(commits)) + for _, c := range commits { + gotSHAs = append(gotSHAs, c.SHA) + } + assert.ElementsMatch(t, []string{"sha-live", "sha-shared"}, gotSHAs) + + // sess-live still has a live commit; sess-orphan does not. + assert.True(t, store.SessionRecordExists("sess-live")) + assert.False(t, store.SessionRecordExists("sess-orphan"), "session with no live commits must be GCed") + + live := store.LoadAILineAttribution("sess-live") + assert.NotEmpty(t, live.Files, "ai-lines for live session must survive") + orphan := store.LoadAILineAttribution("sess-orphan") + assert.Empty(t, orphan.Files, "ai-lines for orphan session must be dropped") +} + +// An empty liveSHAs (no local branches readable, transient git error) must +// be treated as "skip the GC", not "wipe everything". The latter would erase +// every CommitRecord and ai-lines/sessions entry on a single bad git call, +// taking a rebase-tolerant session with it. +func TestGCOrphans_EmptyLiveSHAsSkipsCleanup(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + require.NoError(t, store.SaveCommitRecord(&CommitRecord{SHA: "sha-1", SessionIDs: []string{"sess-1"}, Timestamp: "2026-03-28T00:00:00Z"})) + require.NoError(t, store.SaveSessionRecord(&SessionRecord{SessionID: "sess-1", Active: true, StartedAt: "2026-03-28T00:00:00Z"})) + require.NoError(t, store.RecordLineRanges("sess-1", "foo.go", []aicodingsession.LineRange{{Start: 1, End: 3}})) + + require.NoError(t, store.GCOrphans(map[string]bool{})) + + commits, err := store.LoadAllCommitRecords() + require.NoError(t, err) + assert.Len(t, commits, 1, "empty liveSHAs must not drop commit records") + assert.True(t, store.SessionRecordExists("sess-1")) + assert.NotEmpty(t, store.LoadAILineAttribution("sess-1").Files) +} + +func TestValidSessionID(t *testing.T) { + for _, id := range []string{"sess-1", "ses_abc123", "9beb4bd3-6eee-42a5-ab49-8fc04eee921c"} { + assert.True(t, ValidSessionID(id), id) + } + + // An ID that is not a single path element would escape the agent's own + // transcript directory when joined into a read path. + for _, id := range []string{"", ".", "..", "../etc/passwd", "a/x", "../../secret"} { + assert.False(t, ValidSessionID(id), id) + } +} + +func TestSanitizeID(t *testing.T) { + // An agent-assigned ID needs no rewriting, so its on-disk name must not move. + assert.Equal(t, "9beb4bd3-6eee-42a5-ab49-8fc04eee921c", SanitizeID("9beb4bd3-6eee-42a5-ab49-8fc04eee921c")) + + // IDs that collide under rewriting must not share a name. + assert.NotEqual(t, SanitizeID("a/x"), SanitizeID("a_x")) + assert.NotEqual(t, SanitizeID("."), SanitizeID("..")) + for _, id := range []string{"a/x", ".", "..", "../etc/passwd"} { + safe := SanitizeID(id) + assert.Equal(t, safe, filepath.Base(safe), "sanitized %q must be a single path element", id) + // GCOrphans and LoadAllAILineAttributions feed sanitized names back in. + assert.Equal(t, safe, SanitizeID(safe), "SanitizeID must be idempotent") + } +} + +func TestSessionRecordExists(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + t.Run("returns false when no record exists", func(t *testing.T) { + assert.False(t, store.SessionRecordExists("nonexistent")) + }) + + t.Run("returns true after saving record", func(t *testing.T) { + rec := &SessionRecord{SessionID: "sess-1", Active: true, StartedAt: "2026-03-28T00:00:00Z"} + require.NoError(t, store.SaveSessionRecord(rec)) + assert.True(t, store.SessionRecordExists("sess-1")) + }) +} + +func TestSessionRecords(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + t.Run("save session record", func(t *testing.T) { + rec := &SessionRecord{SessionID: "sess-1", Active: true, StartedAt: "2026-03-28T00:00:00Z"} + require.NoError(t, store.SaveSessionRecord(rec)) + + // Verify file was written + data, err := os.ReadFile(filepath.Join(store.traceDirPath(), "sessions", "sess-1.json")) + require.NoError(t, err) + assert.Contains(t, string(data), "sess-1") + }) +} + +func TestCommitRecords(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + t.Run("save and load commit records", func(t *testing.T) { + rec1 := &CommitRecord{SHA: "abc123", Message: "first", SessionIDs: []string{"sess-1"}, Timestamp: "2026-03-28T00:00:00Z"} + rec2 := &CommitRecord{SHA: "def456", Message: "second", Timestamp: "2026-03-28T00:01:00Z"} + require.NoError(t, store.SaveCommitRecord(rec1)) + require.NoError(t, store.SaveCommitRecord(rec2)) + + records, err := store.LoadAllCommitRecords() + require.NoError(t, err) + assert.Len(t, records, 2) + + // Find records by SHA + shas := make(map[string]*CommitRecord) + for _, r := range records { + shas[r.SHA] = r + } + assert.Equal(t, []string{"sess-1"}, shas["abc123"].SessionIDs) + assert.Empty(t, shas["def456"].SessionIDs) + }) + + t.Run("load from empty dir returns nil", func(t *testing.T) { + records, err := NewGitStore(t.TempDir()).LoadAllCommitRecords() + require.NoError(t, err) + assert.Nil(t, records) + }) +} + +func TestFileSnapshots(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + t.Run("save and load snapshot", func(t *testing.T) { + content := []byte("func main() {\n\tfmt.Println(\"hello\")\n}\n") + require.NoError(t, store.SaveFileSnapshot("sess-1", "/path/to/file.go", content)) + + loaded, err := store.LoadFileSnapshot("sess-1", "/path/to/file.go") + require.NoError(t, err) + assert.Equal(t, content, loaded) + }) + + t.Run("load nonexistent snapshot returns error", func(t *testing.T) { + _, err := store.LoadFileSnapshot("sess-1", "/nonexistent/file.go") + assert.Error(t, err) + }) + + t.Run("delete snapshot", func(t *testing.T) { + content := []byte("content") + require.NoError(t, store.SaveFileSnapshot("sess-1", "/delete/me.go", content)) + + store.DeleteFileSnapshot("sess-1", "/delete/me.go") + + _, err := store.LoadFileSnapshot("sess-1", "/delete/me.go") + assert.Error(t, err) + }) +} + +func TestAILineAttribution(t *testing.T) { + t.Run("record and load line ranges", func(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + ranges := []aicodingsession.LineRange{{Start: 1, End: 5}, {Start: 10, End: 15}} + require.NoError(t, store.RecordLineRanges("sess-1", "/path/to/file.go", ranges)) + + attr := store.LoadAILineAttribution("sess-1") + assert.Equal(t, "sess-1", attr.SessionID) + assert.Equal(t, ranges, attr.Files["/path/to/file.go"]) + }) + + t.Run("accumulates ranges for same file", func(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + require.NoError(t, store.RecordLineRanges("sess-1", "/file.go", []aicodingsession.LineRange{{Start: 1, End: 5}})) + require.NoError(t, store.RecordLineRanges("sess-1", "/file.go", []aicodingsession.LineRange{{Start: 20, End: 25}})) + + attr := store.LoadAILineAttribution("sess-1") + assert.Len(t, attr.Files["/file.go"], 2) + }) + + t.Run("multiple files in one session", func(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + require.NoError(t, store.RecordLineRanges("sess-1", "/a.go", []aicodingsession.LineRange{{Start: 1, End: 3}})) + require.NoError(t, store.RecordLineRanges("sess-1", "/b.go", []aicodingsession.LineRange{{Start: 5, End: 10}})) + + attr := store.LoadAILineAttribution("sess-1") + assert.Len(t, attr.Files, 2) + }) + + t.Run("empty ranges record file touch", func(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + require.NoError(t, store.RecordLineRanges("sess-1", "/file.go", nil)) + + attr := store.LoadAILineAttribution("sess-1") + require.Contains(t, attr.Files, "/file.go") + assert.Empty(t, attr.Files["/file.go"]) + + attrs, err := store.LoadAllAILineAttributions() + require.NoError(t, err) + assert.Len(t, attrs, 1) + }) + + t.Run("load nonexistent returns empty", func(t *testing.T) { + store := NewGitStore(t.TempDir()) + attr := store.LoadAILineAttribution("nonexistent") + assert.Empty(t, attr.Files) + }) + + t.Run("load all attributions", func(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + require.NoError(t, store.RecordLineRanges("sess-1", "/a.go", []aicodingsession.LineRange{{Start: 1, End: 3}})) + require.NoError(t, store.RecordLineRanges("sess-2", "/b.go", []aicodingsession.LineRange{{Start: 5, End: 10}})) + + attrs, err := store.LoadAllAILineAttributions() + require.NoError(t, err) + assert.Len(t, attrs, 2) + }) + + t.Run("load all reports the unsanitized session ID", func(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + // Sanitizing "org/sess-1" for the filename loses the original ID, which + // is why the record itself carries it. + rawID := "org/sess-1" + require.NoError(t, store.RecordLineRanges(rawID, "/a.go", []aicodingsession.LineRange{{Start: 1, End: 3}})) + + attrs, err := store.LoadAllAILineAttributions() + require.NoError(t, err) + require.Len(t, attrs, 1) + assert.Equal(t, rawID, attrs[0].SessionID, "consumers key off the agent-assigned ID, not the filename") + }) +} diff --git a/app/cli/internal/trace/state/store.go b/app/cli/internal/trace/state/store.go new file mode 100644 index 000000000..1baab7d77 --- /dev/null +++ b/app/cli/internal/trace/state/store.go @@ -0,0 +1,56 @@ +package state + +import "path/filepath" + +// Store reads and writes the chainloop-trace state under a single parent +// directory: the .git directory inside a repository, or the out-of-tree +// directory `chainloop trace run` picks outside one. Which of the two it is +// decides whether git hooks can be installed and whether the directory itself +// is ours to remove, so the Store carries that rather than leaving callers to +// thread a second copy of the same path alongside it. +type Store struct { + dir string + isGit bool +} + +// NewGitStore returns a Store over a repository's .git directory. +// Nothing is created or validated here. +func NewGitStore(gitDir string) *Store { + return &Store{dir: gitDir, isGit: true} +} + +// NewOutOfTreeStore returns a Store over the directory that holds trace state +// outside a repository, as returned by NonGitDir. +func NewOutOfTreeStore(dir string) *Store { + return &Store{dir: dir} +} + +// IsGit reports whether the state lives inside a repository's .git directory. +func (s *Store) IsGit() bool { + return s.isGit +} + +// GitDir returns the repository's .git directory, and "" outside a repository. +// Callers that install or remove git hooks must take the path from here, so a +// store that is not a .git directory cannot be mistaken for one. +func (s *Store) GitDir() string { + if !s.isGit { + return "" + } + + return s.dir +} + +// Dir returns the parent directory, one level above chainloop-trace/. +// +// Only `trace run`'s cleanup needs it, to drop the empty out-of-tree directory +// after a non-git run. Inside a repository the same path is .git and must never +// be removed, so callers guarding on IsGit are the only legitimate users. +func (s *Store) Dir() string { + return s.dir +} + +// traceDirPath returns the path to /chainloop-trace/. +func (s *Store) traceDirPath() string { + return filepath.Join(s.dir, traceDir) +} diff --git a/app/cli/internal/trace/state/trailer.go b/app/cli/internal/trace/state/trailer.go new file mode 100644 index 000000000..d75b4e6e6 --- /dev/null +++ b/app/cli/internal/trace/state/trailer.go @@ -0,0 +1,52 @@ +package state + +import ( + "sort" + "strings" +) + +// TrailerKey is the git-style trailer the commit-msg hook writes when AI +// sessions are attributed to staged files. The key is matched case-sensitively +// per git trailer convention. +const TrailerKey = "Chainloop-Trace-Sessions" + +// ParseSessionIDsFromTrailer scans a commit message for `Chainloop-Trace-Sessions:` +// trailer lines and returns the sorted, deduped list of session IDs declared in +// the message. Lines must start with the trailer key (no leading whitespace) to +// match git's trailer parsing rules. Empty values and blank entries between +// commas are ignored. +func ParseSessionIDsFromTrailer(message string) []string { + if message == "" { + return nil + } + + seen := make(map[string]struct{}) + for line := range strings.SplitSeq(message, "\n") { + values, ok := strings.CutPrefix(line, TrailerKey+":") + if !ok { + continue + } + + for v := range strings.SplitSeq(values, ",") { + v = strings.TrimSpace(v) + // Trailers come from commit messages, so an ID that cannot name a + // file is either corrupt or hostile; either way it is not ours. + if !ValidSessionID(v) { + continue + } + seen[v] = struct{}{} + } + } + + if len(seen) == 0 { + return nil + } + + out := make([]string, 0, len(seen)) + for v := range seen { + out = append(out, v) + } + sort.Strings(out) + + return out +} diff --git a/app/cli/internal/trace/state/trailer_test.go b/app/cli/internal/trace/state/trailer_test.go new file mode 100644 index 000000000..3d5208bef --- /dev/null +++ b/app/cli/internal/trace/state/trailer_test.go @@ -0,0 +1,83 @@ +package state + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseSessionIDsFromTrailer(t *testing.T) { + cases := []struct { + name string + message string + want []string + }{ + { + name: "empty message", + message: "", + want: nil, + }, + { + name: "no trailer", + message: "fix: typo\n\nA fix.\n", + want: nil, + }, + { + name: "single id", + message: "feat: x\n\nChainloop-Trace-Sessions: abc\n", + want: []string{"abc"}, + }, + { + name: "comma separated values are split, trimmed, and sorted", + message: "feat: x\n\nChainloop-Trace-Sessions: foo, bar\n", + want: []string{"bar", "foo"}, + }, + { + name: "leading and trailing whitespace stripped", + message: "feat: x\n\nChainloop-Trace-Sessions: foo , bar \n", + want: []string{"bar", "foo"}, + }, + { + name: "empty value yields no IDs", + message: "feat: x\n\nChainloop-Trace-Sessions:\n", + want: nil, + }, + { + name: "extra commas are ignored", + message: "feat: x\n\nChainloop-Trace-Sessions: foo,, ,bar\n", + want: []string{"bar", "foo"}, + }, + { + name: "duplicate ids are deduped", + message: "feat: x\n\nChainloop-Trace-Sessions: a, a, b\n", + want: []string{"a", "b"}, + }, + { + name: "unrelated trailer ignored", + message: "feat: x\n\nSigned-off-by: dev \n", + want: nil, + }, + { + name: "case-sensitive key — lowercase is ignored", + message: "feat: x\n\nchainloop-trace-sessions: foo\n", + want: nil, + }, + { + name: "trailer line alongside other trailers", + message: "feat: x\n\nBody.\n\nSigned-off-by: dev \nChainloop-Trace-Sessions: id-1, id-2\n", + want: []string{"id-1", "id-2"}, + }, + { + name: "leading whitespace before the trailer key is ignored", + message: "feat: x\n\n Chainloop-Trace-Sessions: foo\n", + want: nil, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := ParseSessionIDsFromTrailer(tc.message) + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/app/cli/internal/trace/types.go b/app/cli/internal/trace/types.go new file mode 100644 index 000000000..4a6519f89 --- /dev/null +++ b/app/cli/internal/trace/types.go @@ -0,0 +1,57 @@ +//nolint:revive // "trace" names the CLI feature; the runtime/trace clash is inert here +package trace + +import "encoding/json" + +// Attribution constants for file-level code attribution. +const ( + // AttributionAI marks file changes produced by an AI agent. + AttributionAI = "ai" + // AttributionHuman marks file changes produced by a human. + AttributionHuman = "human" +) + +// RawSessionEntry is the wire format for a single message in +// raw_session["main"]. All providers must emit entries matching this +// struct so the frontend's ConversationTimeline can render them +// uniformly. The shape mirrors Claude's JSONL transcript format, which +// the frontend was originally built against. +type RawSessionEntry struct { + Type string `json:"type,omitempty"` + Role string `json:"role,omitempty"` + UUID string `json:"uuid,omitempty"` + Timestamp string `json:"timestamp,omitempty"` + IsMeta bool `json:"isMeta,omitempty"` + Message RawSessionMessage `json:"message"` +} + +// RawSessionMessage is the message body inside a RawSessionEntry. +type RawSessionMessage struct { + Role string `json:"role,omitempty"` + Content json.RawMessage `json:"content,omitempty"` + Model string `json:"model,omitempty"` +} + +// RawSessionTextBlock is a content block carrying plain text. +type RawSessionTextBlock struct { + Type string `json:"type"` + Text string `json:"text"` +} + +// RawSessionToolUseBlock is a content block representing a tool call. +type RawSessionToolUseBlock struct { + Type string `json:"type"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Input json.RawMessage `json:"input,omitempty"` +} + +// MustRawSessionEntry marshals a RawSessionEntry to json.RawMessage. +// Panics only on impossible encoding failures (struct field bugs). +func MustRawSessionEntry(e RawSessionEntry) json.RawMessage { + b, err := json.Marshal(e) + if err != nil { + panic(err) + } + return b +} From b505c38e1b7aebe53ce708fb230cc6905f9607ff Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Mon, 31 Aug 2026 14:02:54 +0200 Subject: [PATCH 2/7] feat(trace): move the trace actions into app/cli/pkg/action Ports TraceRun, RunTracePush, the agent hook handlers and the AttestationExecutor, plus SubprocessExitError, which the CLI needs in a non-internal package so the exit code of a wrapped `trace run` command can be recognised from main. The platform copy read cmd.ActionOpts and cmd.Version directly, which would be an import cycle here (cmd already imports pkg/action). Both are now passed in by the caller: NewAttestationExecutor(base *ActionsOpts, cliVersion string, opts ...) with ActionOpts/CLIVersion fields added to TraceRunOpts and RunTracePushOpts, and HandlePrePushHook taking a RunTracePushOpts so the pre-push hook can forward them. Note cliVersion must be the bare cmd.Version: ActionsOpts.CLIVersion is fullVersion(), which appends the edition and would change the recorded attestation predicate. Test adjustments: - the platform package-level Logger becomes zerolog.Nop(), matching the convention in attestation_init_test.go - initGitRepo resolves symlinks on the temp dir. Without it the three post-tool-use attribution tests fail on macOS, where t.TempDir() returns /var/folders/... but the repo root resolves to /private/var/folders/..., so the repo-relative paths under test come out as "../.." escapes. These were already failing in the platform repo; Linux CI hid it. Signed-off-by: Jose I. Paris --- app/cli/pkg/action/trace_agent_hook.go | 318 ++++++++ app/cli/pkg/action/trace_agent_hook_test.go | 531 ++++++++++++++ app/cli/pkg/action/trace_attestation.go | 312 ++++++++ app/cli/pkg/action/trace_attestation_test.go | 42 ++ app/cli/pkg/action/trace_hook_handler.go | 667 +++++++++++++++++ app/cli/pkg/action/trace_hook_handler_test.go | 677 ++++++++++++++++++ app/cli/pkg/action/trace_run.go | 309 ++++++++ app/cli/pkg/action/trace_run_test.go | 175 +++++ 8 files changed, 3031 insertions(+) create mode 100644 app/cli/pkg/action/trace_agent_hook.go create mode 100644 app/cli/pkg/action/trace_agent_hook_test.go create mode 100644 app/cli/pkg/action/trace_attestation.go create mode 100644 app/cli/pkg/action/trace_attestation_test.go create mode 100644 app/cli/pkg/action/trace_hook_handler.go create mode 100644 app/cli/pkg/action/trace_hook_handler_test.go create mode 100644 app/cli/pkg/action/trace_run.go create mode 100644 app/cli/pkg/action/trace_run_test.go diff --git a/app/cli/pkg/action/trace_agent_hook.go b/app/cli/pkg/action/trace_agent_hook.go new file mode 100644 index 000000000..aa4591273 --- /dev/null +++ b/app/cli/pkg/action/trace_agent_hook.go @@ -0,0 +1,318 @@ +package action + +import ( + "errors" + "os" + "path/filepath" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/attribution" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/config" + tracegit "github.com/chainloop-dev/chainloop/app/cli/internal/trace/git" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/hooks" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/rs/zerolog" +) + +// HandleAgentSessionEnd handles the agent session-end hook. +func HandleAgentSessionEnd(provider trace.Provider, log zerolog.Logger) error { + input, err := provider.ReadHookInput(os.Stdin) + if err != nil || !state.ValidSessionID(input.SessionID) { + log.Debug().Err(err).Msg("session-end: no valid input") + return nil + } + + sessionID := input.SessionID + log.Debug().Str("session_id", sessionID).Msg("session-end hook invoked") + + store, repoRoot, err := state.Locate() + if err != nil { + log.Debug().Err(err).Msg("session-end: no trace state located") + return nil + } + + if err := provider.CopySessionData(store, repoRoot, sessionID); err != nil { + log.Debug().Err(err).Msg("copy session data failed") + } + + log.Debug().Str("session_id", sessionID).Msg("session ended") + + return nil +} + +// HandleAgentSessionStart handles the agent session-start hook. +func HandleAgentSessionStart(provider trace.Provider, log zerolog.Logger) error { + input, err := provider.ReadHookInput(os.Stdin) + if err != nil || !state.ValidSessionID(input.SessionID) { + log.Debug().Err(err).Msg("session-start: no valid input") + return nil + } + + log.Debug().Str("session_id", input.SessionID).Msg("session-start hook invoked") + + store, repoRoot, err := state.Locate() + if err != nil { + log.Debug().Err(err).Msg("session-start: no trace state located") + return nil + } + + ensureSessionTracked(provider, store, repoRoot, input, log) + + if err := provider.SystemMessage("\n\n*** This session will be attested by Chainloop ***"); err != nil { + log.Debug().Err(err).Msg("session-start: failed to send system message") + } + + return nil +} + +// HandleAgentPreToolUse handles the agent pre-tool-use hook. +func HandleAgentPreToolUse(provider trace.Provider, log zerolog.Logger) error { + input, err := provider.ReadHookInput(os.Stdin) + if err != nil || !state.ValidSessionID(input.SessionID) { + log.Debug().Err(err).Msg("pre-tool-use: no valid input") + return nil + } + + log.Debug().Str("session_id", input.SessionID).Msg("pre-tool-use hook invoked") + + store, repoRoot, err := state.Locate() + if err != nil { + log.Debug().Err(err).Msg("pre-tool-use: no trace state located") + return nil + } + + ensureSessionTracked(provider, store, repoRoot, input, log) + + switch { + case provider.IsCommandTool(input.ToolName): + // Shell command: snapshot the whole worktree so the post hook can diff + // it and attribute the command's file changes to the AI. + captureWorktreeSnapshot(store, repoRoot, input.SessionID, log) + case provider.IsFileWritingTool(input.ToolName): + if input.FilePath == "" { + log.Debug().Str("tool", input.ToolName).Msg("pre-tool-use: file-writing tool produced no file path, skipping") + return nil + } + if err := provider.CaptureFileSnapshot(store, input); err != nil { + log.Debug().Err(err).Str("file", input.FilePath).Msg("pre-tool-use: capture snapshot failed") + } + default: + log.Debug().Str("tool", input.ToolName).Msg("pre-tool-use: not a tracked tool, skipping") + } + + return nil +} + +// captureWorktreeSnapshot records the working-tree signature before a shell +// command runs, so HandleAgentPostToolUse can diff it and attribute the files +// the command changed. Best-effort: failures are logged and never block the agent. +func captureWorktreeSnapshot(store *state.Store, repoRoot, sessionID string, log zerolog.Logger) { + sig, err := tracegit.NewGoGitClient().SnapshotWorktree(repoRoot) + if err != nil { + log.Debug().Err(err).Msg("pre-command: worktree snapshot failed") + return + } + + if err := store.SaveShellPreSignature(sessionID, sig); err != nil { + log.Debug().Err(err).Msg("pre-command: save worktree signature failed") + } +} + +// ensureSessionTracked auto-installs git hooks (unconditionally, on every +// invocation — itself idempotent via hooks.IsInstalled), then creates a +// session record if one doesn't already exist and copies the session data. +// Idempotent so any hook entry point can call it safely. Per-input metadata +// (e.g., AgentVersion) is captured on the first call only — subsequent +// hooks for the same session are no-ops, no upsert. +func ensureSessionTracked(provider trace.Provider, store *state.Store, repoRoot string, input *trace.HookInput, log zerolog.Logger) { + // Run install before any early return so hooks recover from missing + // state (deleted .git/hooks, project YAML appearing after session start). + // Outside a repository there is nothing to invoke git hooks, so there is + // nothing to install. + if store.IsGit() { + autoInstallGitHooks(store, repoRoot, log) + } + + sessionID := input.SessionID + if store.SessionRecordExists(sessionID) { + return + } + + log.Debug().Str("session_id", sessionID).Str("state_dir", store.Dir()).Str("provider", provider.Name()).Msg("tracking new session") + + rec := &state.SessionRecord{ + SessionID: sessionID, + Provider: provider.Name(), + AgentVersion: input.AgentVersion, + Model: input.Model, + Active: true, + StartedAt: state.NowTimestamp(), + } + if err := store.SaveSessionRecord(rec); err != nil { + log.Debug().Err(err).Msg("save session record failed") + return + } + + if err := provider.CopySessionData(store, repoRoot, sessionID); err != nil { + log.Debug().Err(err).Msg("copy session data failed") + } +} + +// HandleAgentPostToolUse handles post-edit hooks across providers +// (Claude's post-tool-use, Cursor's afterFileEdit) and records AI-attributed +// line ranges for the edited file. +func HandleAgentPostToolUse(provider trace.Provider, log zerolog.Logger) error { + input, err := provider.ReadHookInput(os.Stdin) + if err != nil || !state.ValidSessionID(input.SessionID) { + log.Debug().Err(err).Msg("post-tool-use: no valid input") + return nil + } + + sessionID := input.SessionID + + isCommand := provider.IsCommandTool(input.ToolName) + isFileWriting := provider.IsFileWritingTool(input.ToolName) + if !isCommand && !isFileWriting { + log.Debug().Str("tool", input.ToolName).Msg("post-tool-use: not a tracked tool, skipping") + return nil + } + if isFileWriting && input.FilePath == "" { + log.Debug().Str("tool", input.ToolName).Msg("post-tool-use: file-writing tool produced no file path, skipping") + return nil + } + + log.Debug().Str("session_id", sessionID).Str("tool", input.ToolName).Str("file", input.FilePath).Msg("post-tool-use hook invoked") + + store, repoRoot, err := state.Locate() + if err != nil { + log.Debug().Err(err).Msg("post-tool-use: no trace state located") + return nil + } + + // Guarantees session tracking + git-hook install when afterFileEdit is + // the first hook we see (Cursor has no pre-tool-use). + ensureSessionTracked(provider, store, repoRoot, input, log) + + if isCommand { + // Shell command: diff the before/after worktree snapshots and attribute + // every file the command changed to the AI. + recordCommandLineRanges(store, repoRoot, sessionID, log) + + return nil + } + + after, err := os.ReadFile(input.FilePath) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + log.Debug().Err(err).Str("file", input.FilePath).Msg("post-tool-use: cannot read file after edit") + provider.CleanupAfterEdit(store, input) + return nil + } + // File was deleted — treat after as empty so attribution records + // the deletion as AI-authored (ComputeLineRanges returns changed=true + // with nil ranges for before≠nil, after=empty). + after = nil + } + + before := provider.ResolveBeforeContent(store, input, after) + + ranges, changed := attribution.ComputeLineRanges(before, after) + + // Normalize to repo-relative path for storage — git diffs use relative paths, + // so ai-lines keys must match for commit correlation and attribution to work. + relPath, err := filepath.Rel(repoRoot, input.FilePath) + if err != nil { + relPath = input.FilePath + } + + if changed { + if err := store.RecordLineRanges(sessionID, relPath, ranges); err != nil { + log.Debug().Err(err).Msg("post-tool-use: record line ranges failed") + } else { + log.Debug().Int("ranges", len(ranges)).Str("file", relPath).Msg("line ranges recorded") + } + } + + provider.CleanupAfterEdit(store, input) + + return nil +} + +// recordCommandLineRanges diffs the worktree signature captured before a shell +// command against the current worktree, and records every created/modified file +// (whole-file range) and every deleted file as AI-attributed. Enrich later caps +// the AI line count to each file's committed diff totals, so whole-file ranges +// yield correct counts. Best-effort: never blocks the agent. +func recordCommandLineRanges(store *state.Store, repoRoot, sessionID string, log zerolog.Logger) { + before, err := store.LoadShellPreSignature(sessionID) + if err != nil { + // No pre-command snapshot (missed pre hook, parallel overwrite) — skip. + log.Debug().Err(err).Msg("post-command: no pre-command worktree signature") + return + } + defer store.DeleteShellPreSignature(sessionID) + + after, err := tracegit.NewGoGitClient().SnapshotWorktree(repoRoot) + if err != nil { + log.Debug().Err(err).Msg("post-command: worktree snapshot failed") + return + } + + changed, deleted := attribution.ChangedPaths(before, after) + for _, relPath := range changed { + content, err := os.ReadFile(filepath.Join(repoRoot, relPath)) + if err != nil { + continue + } + // ChangedPaths already proved the file changed, so record it even when + // the result is empty (created empty or truncated) — ComputeLineRanges + // returns nil ranges for empty content, which marks the file AI-touched + // with zero added lines, matching the deletion-only treatment. + ranges, _ := attribution.ComputeLineRanges(nil, content) + if err := store.RecordLineRanges(sessionID, relPath, ranges); err != nil { + log.Debug().Err(err).Str("file", relPath).Msg("post-command: record line ranges failed") + } + } + for _, relPath := range deleted { + // nil ranges records the file as AI-touched (deletion-only), matching + // the file-writing hook's treatment of deletions. + if err := store.RecordLineRanges(sessionID, relPath, nil); err != nil { + log.Debug().Err(err).Str("file", relPath).Msg("post-command: record deletion failed") + } + } + + log.Debug().Int("changed", len(changed)).Int("deleted", len(deleted)).Str("session_id", sessionID).Msg("command line ranges recorded") +} + +// autoInstallGitHooks installs git hooks if they're not already present +// and a project name is discoverable. Under `chainloop trace run`, +// pre-push is omitted because trace run drives attestation itself; a +// pre-push installed here would double-attest on in-session `git push`. +// +// Trace-run mode is detected via the run-active sentinel file under +// .git/chainloop-trace/, which is robust to whatever environment each +// agent happens to propagate to its hook subprocesses. +// Only meaningful for a store inside a repository; GitDir is empty otherwise. +func autoInstallGitHooks(store *state.Store, repoRoot string, log zerolog.Logger) { + gitDir := store.GitDir() + skipPrePush := store.IsTraceRunActive() + + if hooks.IsInstalled(gitDir, skipPrePush) { + return + } + + if config.LoadProjectFromYML(repoRoot) == "" { + return // can't auto-install without a project name + } + + if _, err := hooks.Install(gitDir, skipPrePush); err != nil { + // Warn, not debug: some failures (e.g. a leftover hook backup) keep + // tracing off until the user intervenes, so they must be visible. + log.Warn().Err(err).Msg("auto-install git hooks failed") + return + } + + if err := store.MarkTraceInitialized(); err != nil { + log.Debug().Err(err).Msg("mark trace initialized failed") + } +} diff --git a/app/cli/pkg/action/trace_agent_hook_test.go b/app/cli/pkg/action/trace_agent_hook_test.go new file mode 100644 index 000000000..c1e3f1c36 --- /dev/null +++ b/app/cli/pkg/action/trace_agent_hook_test.go @@ -0,0 +1,531 @@ +package action + +import ( + "bytes" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/claude" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/hooks" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/opencode" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// initTempGitRepo creates a temp dir and runs git init in it. +func initTempGitRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + cmd := exec.Command("git", "init", dir) + require.NoError(t, cmd.Run()) + return dir +} + +func TestHandleAgentSessionStart(t *testing.T) { + provider := claude.New() + + t.Run("captures session ID from stdin", func(t *testing.T) { + repoDir := initTempGitRepo(t) + gitDir := filepath.Join(repoDir, ".git") + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + origDir, _ := os.Getwd() + require.NoError(t, os.Chdir(repoDir)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + withStdin(t, `{"session_id":"abc-123","cwd":"/some/path"}`) + + require.NoError(t, HandleAgentSessionStart(provider, zerolog.Nop())) + + assert.True(t, store.SessionRecordExists("abc-123")) + }) + + t.Run("no-op when session already tracked", func(t *testing.T) { + repoDir := initTempGitRepo(t) + gitDir := filepath.Join(repoDir, ".git") + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + origDir, _ := os.Getwd() + require.NoError(t, os.Chdir(repoDir)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + // Pre-populate session record + rec := &state.SessionRecord{SessionID: "abc-123", Active: true, StartedAt: "2026-03-28T00:00:00Z"} + require.NoError(t, store.SaveSessionRecord(rec)) + + withStdin(t, `{"session_id":"abc-123"}`) + + require.NoError(t, HandleAgentSessionStart(provider, zerolog.Nop())) + + assert.True(t, store.SessionRecordExists("abc-123")) + }) + + t.Run("tracks multiple sessions independently", func(t *testing.T) { + repoDir := initTempGitRepo(t) + gitDir := filepath.Join(repoDir, ".git") + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + origDir, _ := os.Getwd() + require.NoError(t, os.Chdir(repoDir)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + // Track first session + rec := &state.SessionRecord{SessionID: "session-1", Active: true, StartedAt: "2026-03-28T00:00:00Z"} + require.NoError(t, store.SaveSessionRecord(rec)) + + // Start a new session + withStdin(t, `{"session_id":"session-2"}`) + require.NoError(t, HandleAgentSessionStart(provider, zerolog.Nop())) + + // Both sessions should have records + assert.True(t, store.SessionRecordExists("session-1")) + assert.True(t, store.SessionRecordExists("session-2")) + }) + + t.Run("outputs system message to stdout", func(t *testing.T) { + repoDir := initTempGitRepo(t) + gitDir := filepath.Join(repoDir, ".git") + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + origDir, _ := os.Getwd() + require.NoError(t, os.Chdir(repoDir)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + withStdin(t, `{"session_id":"msg-test","cwd":"/some/path"}`) + stdout := captureStdout(t, func() { + require.NoError(t, HandleAgentSessionStart(provider, zerolog.Nop())) + }) + + assert.Contains(t, stdout, `"systemMessage"`) + assert.Contains(t, stdout, "This session will be attested by Chainloop") + }) + + t.Run("ignores malformed stdin", func(t *testing.T) { + withStdin(t, `not json`) + assert.NoError(t, HandleAgentSessionStart(provider, zerolog.Nop())) + }) + + t.Run("ignores empty session ID", func(t *testing.T) { + withStdin(t, `{"session_id":""}`) + assert.NoError(t, HandleAgentSessionStart(provider, zerolog.Nop())) + }) +} + +func TestHandleAgentSessionEnd(t *testing.T) { + provider := claude.New() + + t.Run("completes without error", func(t *testing.T) { + repoDir := initTempGitRepo(t) + gitDir := filepath.Join(repoDir, ".git") + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + origDir, _ := os.Getwd() + require.NoError(t, os.Chdir(repoDir)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + // Pre-populate session record + rec := &state.SessionRecord{SessionID: "abc-123", Active: true, StartedAt: "2026-03-28T00:00:00Z"} + require.NoError(t, store.SaveSessionRecord(rec)) + + withStdin(t, `{"session_id":"abc-123"}`) + require.NoError(t, HandleAgentSessionEnd(provider, zerolog.Nop())) + }) +} + +func TestAutoInstallGitHooks(t *testing.T) { + t.Run("installs hooks when project discoverable from yml", func(t *testing.T) { + repoDir := initTempGitRepo(t) + gitDir := filepath.Join(repoDir, ".git") + store := state.NewGitStore(gitDir) + + // Create .chainloop.yml with project name + require.NoError(t, os.WriteFile(filepath.Join(repoDir, ".chainloop.yml"), []byte("projectName: my-project\n"), 0600)) + + origDir, _ := os.Getwd() + require.NoError(t, os.Chdir(repoDir)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + autoInstallGitHooks(store, repoDir, zerolog.Nop()) + + // Git hooks should be installed + content, err := os.ReadFile(filepath.Join(gitDir, "hooks", "post-commit")) + require.NoError(t, err) + assert.Contains(t, string(content), hooks.HookMarker) + + // Trace should be marked as initialized + assert.True(t, store.IsTraceInitialized()) + }) + + t.Run("skips when hooks already installed", func(t *testing.T) { + repoDir := initTempGitRepo(t) + gitDir := filepath.Join(repoDir, ".git") + store := state.NewGitStore(gitDir) + + // Install hooks first + _, err := hooks.Install(gitDir, false) + require.NoError(t, err) + + // Write a marker to verify hooks aren't reinstalled + hookPath := filepath.Join(gitDir, "hooks", "post-commit") + original, _ := os.ReadFile(hookPath) + + origDir, _ := os.Getwd() + require.NoError(t, os.Chdir(repoDir)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + autoInstallGitHooks(store, repoDir, zerolog.Nop()) + + // Hook content should be unchanged + current, _ := os.ReadFile(hookPath) + assert.Equal(t, string(original), string(current)) + }) + + t.Run("skips when no project name discoverable", func(t *testing.T) { + repoDir := initTempGitRepo(t) + gitDir := filepath.Join(repoDir, ".git") + store := state.NewGitStore(gitDir) + + origDir, _ := os.Getwd() + require.NoError(t, os.Chdir(repoDir)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + autoInstallGitHooks(store, repoDir, zerolog.Nop()) + + // No hooks should be installed + _, err := os.Stat(filepath.Join(gitDir, "hooks", "post-commit")) + assert.True(t, os.IsNotExist(err)) + }) + + t.Run("skips pre-push when trace-run-active sentinel is present", func(t *testing.T) { + repoDir := initTempGitRepo(t) + gitDir := filepath.Join(repoDir, ".git") + store := state.NewGitStore(gitDir) + + require.NoError(t, os.WriteFile(filepath.Join(repoDir, ".chainloop.yml"), []byte("projectName: my-project\n"), 0600)) + require.NoError(t, store.InitTraceDir()) + require.NoError(t, store.MarkTraceRunActive()) + + origDir, _ := os.Getwd() + require.NoError(t, os.Chdir(repoDir)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + autoInstallGitHooks(store, repoDir, zerolog.Nop()) + + content, err := os.ReadFile(filepath.Join(gitDir, "hooks", "post-commit")) + require.NoError(t, err) + assert.Contains(t, string(content), hooks.HookMarker) + + _, err = os.Stat(filepath.Join(gitDir, "hooks", "pre-push")) + assert.True(t, os.IsNotExist(err), "pre-push must not be installed under trace run mode") + }) +} + +// captureStdout captures stdout output during fn execution. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + origStdout := os.Stdout + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stdout = w + defer func() { os.Stdout = origStdout }() + + fn() + + _ = w.Close() + + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + + return buf.String() +} + +// withStdin replaces os.Stdin with a reader containing the given content for the test duration. +func withStdin(t *testing.T, content string) { + t.Helper() + + origStdin := os.Stdin + r, w, _ := os.Pipe() + _, _ = io.Copy(w, bytes.NewBufferString(content)) + _ = w.Close() + os.Stdin = r + t.Cleanup(func() { os.Stdin = origStdin }) +} + +func TestHandleAgentPostToolUse_DeletionAttribution(t *testing.T) { + dir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + target := filepath.Join(dir, "victim.txt") + require.NoError(t, os.WriteFile(target, []byte("line 1\nline 2\nline 3\n"), 0600)) + + p := opencode.New() + + withStdin(t, `{"session_id":"ses-delete-test","hook_event_name":"tool.execute.before","tool_name":"apply_patch","file_path":"`+target+`"}`) + require.NoError(t, HandleAgentPreToolUse(p, zerolog.Nop())) + + snap, err := store.LoadFileSnapshot("ses-delete-test", target) + require.NoError(t, err) + assert.Equal(t, "line 1\nline 2\nline 3\n", string(snap)) + + require.NoError(t, os.Remove(target)) + + withStdin(t, `{"session_id":"ses-delete-test","hook_event_name":"tool.execute.after","tool_name":"apply_patch","file_path":"`+target+`"}`) + require.NoError(t, HandleAgentPostToolUse(p, zerolog.Nop())) + + attr := store.LoadAILineAttribution("ses-delete-test") + assert.Contains(t, attr.Files, "victim.txt", + "deleted file must have ai-lines attribution recorded") + assert.Empty(t, attr.Files["victim.txt"], + "deleted file must have nil ranges (no lines in after)") +} + +func TestHandleAgentPostToolUse_UpdateAttribution(t *testing.T) { + dir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + target := filepath.Join(dir, "updated.txt") + require.NoError(t, os.WriteFile(target, []byte("old line\n"), 0600)) + + p := opencode.New() + + withStdin(t, `{"session_id":"ses-update-test","hook_event_name":"tool.execute.before","tool_name":"apply_patch","file_path":"`+target+`"}`) + require.NoError(t, HandleAgentPreToolUse(p, zerolog.Nop())) + + require.NoError(t, os.WriteFile(target, []byte("new line\n"), 0600)) + + withStdin(t, `{"session_id":"ses-update-test","hook_event_name":"tool.execute.after","tool_name":"apply_patch","file_path":"`+target+`"}`) + require.NoError(t, HandleAgentPostToolUse(p, zerolog.Nop())) + + attr := store.LoadAILineAttribution("ses-update-test") + assert.Contains(t, attr.Files, "updated.txt") + ranges := attr.Files["updated.txt"] + require.NotEmpty(t, ranges) + assert.Equal(t, 1, ranges[0].Start) + assert.Equal(t, 1, ranges[0].End) +} + +func TestHandleAgentPostToolUse_AddFileAttribution(t *testing.T) { + dir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + target := filepath.Join(dir, "newfile.txt") + p := opencode.New() + + withStdin(t, `{"session_id":"ses-add-test","hook_event_name":"tool.execute.before","tool_name":"apply_patch","file_path":"`+target+`"}`) + require.NoError(t, HandleAgentPreToolUse(p, zerolog.Nop())) + + require.NoError(t, os.WriteFile(target, []byte("brand\nnew\ncontent\n"), 0600)) + + withStdin(t, `{"session_id":"ses-add-test","hook_event_name":"tool.execute.after","tool_name":"apply_patch","file_path":"`+target+`"}`) + require.NoError(t, HandleAgentPostToolUse(p, zerolog.Nop())) + + attr := store.LoadAILineAttribution("ses-add-test") + assert.Contains(t, attr.Files, "newfile.txt") + ranges := attr.Files["newfile.txt"] + require.NotEmpty(t, ranges) + assert.Equal(t, 1, ranges[0].Start) + assert.Equal(t, 3, ranges[0].End) +} + +func TestHandleAgentCommandTool_AttributesShellFileChanges(t *testing.T) { + dir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + p := opencode.New() // opencode's "bash" is a command tool + + // pre-command: snapshot the worktree before the shell command runs. + withStdin(t, `{"session_id":"ses-cmd","hook_event_name":"tool.execute.before","tool_name":"bash"}`) + require.NoError(t, HandleAgentPreToolUse(p, zerolog.Nop())) + + // Simulate the shell command creating a file, modifying a tracked one, and + // creating an empty file (e.g. `touch marker`). + require.NoError(t, os.WriteFile(filepath.Join(dir, "generated.txt"), []byte("a\nb\nc\n"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "init.txt"), []byte("init changed\n"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "marker"), nil, 0600)) + + // post-command: diff the worktree and attribute the changes to the AI. + withStdin(t, `{"session_id":"ses-cmd","hook_event_name":"tool.execute.after","tool_name":"bash"}`) + require.NoError(t, HandleAgentPostToolUse(p, zerolog.Nop())) + + attr := store.LoadAILineAttribution("ses-cmd") + + // Created file: whole-file range recorded (repo-relative key, no symlink drift). + require.Contains(t, attr.Files, "generated.txt") + require.NotEmpty(t, attr.Files["generated.txt"]) + assert.Equal(t, 1, attr.Files["generated.txt"][0].Start) + assert.Equal(t, 3, attr.Files["generated.txt"][0].End) + + // Modified tracked file is also captured. + assert.Contains(t, attr.Files, "init.txt") + + // Empty file created by the command is recorded (AI-touched, no ranges). + assert.Contains(t, attr.Files, "marker") + assert.Empty(t, attr.Files["marker"]) + + // The pre-command signature is cleaned up afterwards. + _, err := store.LoadShellPreSignature("ses-cmd") + assert.Error(t, err) +} + +// TestHandleAgentClaudeCodeSession drives the pre/post-tool-use handlers with +// the real JSON payloads Claude Code pipes to its hooks, across a full session: +// SessionStart → Write (new file) → Edit (modify it) → Bash (generate a file). +// It asserts the AI-line attribution map ends up crediting every file the agent +// touched — including the one produced only by the shell command, which is the +// PFM-6684 regression. +func TestHandleAgentClaudeCodeSession(t *testing.T) { + root := chdirToResolvedGitRepo(t) + gitDir := filepath.Join(root, ".git") + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + p := claude.New() + const sid = "b1f4e0c2-1a2b-4c3d-8e9f-0a1b2c3d4e5f" + tp := filepath.Join(root, ".claude", "transcript.jsonl") + + // 1. SessionStart — Claude sends session_id, transcript_path, cwd, source. + withStdin(t, fmt.Sprintf( + `{"session_id":%q,"transcript_path":%q,"cwd":%q,"hook_event_name":"SessionStart","source":"startup"}`, + sid, tp, root)) + require.NoError(t, HandleAgentSessionStart(p, zerolog.Nop())) + require.True(t, store.SessionRecordExists(sid), "SessionStart should record the session") + + // 2. Write a brand-new file. Claude fires PreToolUse (file does not exist + // yet), performs the write, then PostToolUse with a tool_response. + apiGo := filepath.Join(root, "api.go") + writeInput := fmt.Sprintf(`"tool_name":"Write","tool_input":{"file_path":%q,"content":"package api\n\nfunc A() {}\n"}`, apiGo) + withStdin(t, fmt.Sprintf(`{"session_id":%q,"hook_event_name":"PreToolUse",%s}`, sid, writeInput)) + require.NoError(t, HandleAgentPreToolUse(p, zerolog.Nop())) + + require.NoError(t, os.WriteFile(apiGo, []byte("package api\n\nfunc A() {}\n"), 0600)) // the Write tool's effect + + withStdin(t, fmt.Sprintf(`{"session_id":%q,"hook_event_name":"PostToolUse",%s,"tool_response":{"type":"create","filePath":%q}}`, sid, writeInput, apiGo)) + require.NoError(t, HandleAgentPostToolUse(p, zerolog.Nop())) + + // 3. Edit the file the agent just wrote (append a function). + editInput := fmt.Sprintf(`"tool_name":"Edit","tool_input":{"file_path":%q,"old_string":"func A() {}","new_string":"func A() {}\n\nfunc B() {}"}`, apiGo) + withStdin(t, fmt.Sprintf(`{"session_id":%q,"hook_event_name":"PreToolUse",%s}`, sid, editInput)) + require.NoError(t, HandleAgentPreToolUse(p, zerolog.Nop())) + + require.NoError(t, os.WriteFile(apiGo, []byte("package api\n\nfunc A() {}\n\nfunc B() {}\n"), 0600)) // the Edit tool's effect + + withStdin(t, fmt.Sprintf(`{"session_id":%q,"hook_event_name":"PostToolUse",%s,"tool_response":{"filePath":%q}}`, sid, editInput, apiGo)) + require.NoError(t, HandleAgentPostToolUse(p, zerolog.Nop())) + + // 4. Run a Bash command that generates a file. Bash payloads carry a + // "command" in tool_input and never a file_path — this is the path that + // previously misattributed the generated file to "human". + bashInput := `"tool_name":"Bash","tool_input":{"command":"protoc --go_out=. api.proto","description":"generate bindings"}` + withStdin(t, fmt.Sprintf(`{"session_id":%q,"hook_event_name":"PreToolUse",%s}`, sid, bashInput)) + require.NoError(t, HandleAgentPreToolUse(p, zerolog.Nop())) + + require.NoError(t, os.MkdirAll(filepath.Join(root, "gen"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "gen", "api.pb.go"), []byte("// generated\npackage gen\n\ntype Msg struct{}\n"), 0600)) // the command's effect + + withStdin(t, fmt.Sprintf(`{"session_id":%q,"hook_event_name":"PostToolUse",%s,"tool_response":{"stdout":"","stderr":"","interrupted":false}}`, sid, bashInput)) + require.NoError(t, HandleAgentPostToolUse(p, zerolog.Nop())) + + // Attribution: the Write/Edit file and the Bash-generated file are all AI. + attr := store.LoadAILineAttribution(sid) + + require.Contains(t, attr.Files, "api.go", "Write/Edit file must be recorded") + require.NotEmpty(t, attr.Files["api.go"]) + + require.Contains(t, attr.Files, "gen/api.pb.go", "shell-generated file must be attributed to AI (PFM-6684)") + genRanges := attr.Files["gen/api.pb.go"] + require.NotEmpty(t, genRanges) + assert.Equal(t, 1, genRanges[0].Start) + assert.Equal(t, 4, genRanges[0].End) // whole 4-line generated file + + // The pre-command signature is cleaned up after the Bash post hook. + _, err := store.LoadShellPreSignature(sid) + assert.Error(t, err) +} + +// chdirToResolvedGitRepo creates a git repo, chdirs into its symlink-resolved +// path, and returns that canonical root. Using the resolved path mirrors the +// canonical absolute paths Claude Code passes in hook payloads and keeps +// filepath.Rel keys stable on macOS (where /var is a symlink to /private/var). +func chdirToResolvedGitRepo(t *testing.T) string { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git binary not found") + } + + dir := initTempGitRepo(t) + resolved, err := filepath.EvalSymlinks(dir) + require.NoError(t, err) + + cwd, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(resolved)) + t.Cleanup(func() { _ = os.Chdir(cwd) }) + + return resolved +} + +func TestHandleAgentPostToolUse_EmptyFilePathSkips(t *testing.T) { + _, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + p := opencode.New() + + withStdin(t, `{"session_id":"ses-empty-path","hook_event_name":"tool.execute.after","tool_name":"apply_patch","file_path":""}`) + require.NoError(t, HandleAgentPostToolUse(p, zerolog.Nop())) + + attr := store.LoadAILineAttribution("ses-empty-path") + assert.Empty(t, attr.Files) +} + +func TestHandleAgentPostToolUse_NonFileWritingToolSkips(t *testing.T) { + _, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + p := opencode.New() + + withStdin(t, `{"session_id":"ses-non-writing","hook_event_name":"tool.execute.after","tool_name":"bash","file_path":"/some/path"}`) + require.NoError(t, HandleAgentPostToolUse(p, zerolog.Nop())) + + attr := store.LoadAILineAttribution("ses-non-writing") + assert.Empty(t, attr.Files) +} + +func TestHandleAgentPreToolUse_EmptyFilePathSkips(t *testing.T) { + _, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + p := opencode.New() + + withStdin(t, `{"session_id":"ses-pre-empty","hook_event_name":"tool.execute.before","tool_name":"apply_patch","file_path":""}`) + require.NoError(t, HandleAgentPreToolUse(p, zerolog.Nop())) +} + +func TestHandleAgentPreToolUse_NonFileWritingToolSkips(t *testing.T) { + _, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + p := opencode.New() + + withStdin(t, `{"session_id":"ses-pre-non-writing","hook_event_name":"tool.execute.before","tool_name":"bash","file_path":"/some/path"}`) + require.NoError(t, HandleAgentPreToolUse(p, zerolog.Nop())) +} diff --git a/app/cli/pkg/action/trace_attestation.go b/app/cli/pkg/action/trace_attestation.go new file mode 100644 index 000000000..f4244ec8d --- /dev/null +++ b/app/cli/pkg/action/trace_attestation.go @@ -0,0 +1,312 @@ +package action + +import ( + "context" + "crypto/sha256" + "fmt" + "io" + "os" + "strings" + + "github.com/chainloop-dev/chainloop/pkg/grpcconn" + "github.com/golang-jwt/jwt/v5" + "github.com/rs/zerolog" + "github.com/spf13/viper" + "google.golang.org/grpc" +) + +// AttestationExecutor calls the attestation actions directly via their Go +// API rather than shelling out, so `chainloop trace` can drive a full +// init/add/push cycle in-process. +type AttestationExecutor struct { + actionOpts *ActionsOpts + cliVersion string + localStatePath string + // ownedConn is non-nil when the executor created its own control-plane + // connection (e.g. via WithForcedOrganization). The caller must invoke + // Close to release it. + ownedConn *grpc.ClientConn +} + +type ExecutorOption func(*AttestationExecutor) error + +// NewAttestationExecutor creates an executor from the root command's already +// initialized ActionsOpts. Commands using it must NOT set skipActionOptsInit, +// or base will be nil. cliVersion is recorded in the attestation predicate and +// must be the bare version (cmd.Version), not ActionsOpts.CLIVersion, which +// carries an edition suffix. +func NewAttestationExecutor(base *ActionsOpts, cliVersion string, opts ...ExecutorOption) (*AttestationExecutor, error) { + if base == nil { + return nil, fmt.Errorf("base action options are required") + } + + e := &AttestationExecutor{actionOpts: base, cliVersion: cliVersion} + for _, o := range opts { + if err := o(e); err != nil { + _ = e.Close() + return nil, err + } + } + + return e, nil +} + +// WithLogger overrides the logger used by attestation operations. +// It clones ActionOpts to avoid mutating global state. +func WithLogger(l zerolog.Logger) ExecutorOption { + return func(e *AttestationExecutor) error { + clone := *e.actionOpts + clone.Logger = l + e.actionOpts = &clone + + return nil + } +} + +// WithLocalStatePath sets a file path for local attestation state, +// avoiding remote state and conflicts with other concurrent attestations. +func WithLocalStatePath(path string) ExecutorOption { + return func(e *AttestationExecutor) error { + e.localStatePath = path + + return nil + } +} + +// WithForcedOrganization replaces the control-plane connection with a fresh one +// that carries the given organization name in every request, overriding the +// CLI's default org. Pass an empty string to keep the default connection. +// The new connection is owned by the executor and closed by Close. +func WithForcedOrganization(orgName string) ExecutorOption { + return func(e *AttestationExecutor) error { + if orgName == "" { + return nil + } + + if err := checkTokenOrganization(e.actionOpts.AuthTokenRaw, orgName); err != nil { + return err + } + + conn, err := newControlPlaneConnection(e.actionOpts.AuthTokenRaw, orgName) + if err != nil { + return fmt.Errorf("open forced-org control-plane connection: %w", err) + } + + clone := *e.actionOpts + clone.CPConnection = conn + e.actionOpts = &clone + e.ownedConn = conn + + return nil + } +} + +// checkTokenOrganization fails when the credentials in use are an API token +// minted for an organization other than the pinned one. Org-scoped API tokens +// are the only credentials for which the control plane ignores the +// Chainloop-Organization header (it derives the org from the token instead), +// so without this check the attestation silently lands in the token's org +// rather than the one configured in .chainloop.yml. +// +// Only those tokens carry the org_name claim. User, federated, and +// instance-admin tokens don't, and for all of them the header is honored +// server-side — a user token pinned to an org the user isn't a member of is +// rejected outright — so a missing claim means there is nothing to check. +func checkTokenOrganization(authToken, orgName string) error { + if authToken == "" { + return nil + } + + claims := jwt.MapClaims{} + if _, _, err := jwt.NewParser().ParseUnverified(authToken, claims); err != nil { + // ponytail: rejecting a malformed token is the control plane's job, not ours + return nil + } + + tokenOrg, _ := claims["org_name"].(string) + if tokenOrg == "" || strings.EqualFold(tokenOrg, orgName) { + return nil + } + + return fmt.Errorf("credentials belong to organization %q but %q is configured: API tokens are bound to their own organization, so mint a token for %q instead", tokenOrg, orgName, orgName) +} + +// Close releases resources owned by the executor, currently any control-plane +// connection created via WithForcedOrganization. Safe to call multiple times. +func (e *AttestationExecutor) Close() error { + if e.ownedConn == nil { + return nil + } + + conn := e.ownedConn + e.ownedConn = nil + + return conn.Close() +} + +// newControlPlaneConnection dials the control plane using the settings from +// viper (same keys as the upstream root command) and pins the given org name. +func newControlPlaneConnection(authToken, orgName string) (*grpc.ClientConn, error) { + opts := []grpcconn.Option{ + grpcconn.WithInsecure(viper.GetBool("api-insecure")), + grpcconn.WithOrgName(orgName), + } + + if caValue := viper.GetString("control-plane.api-ca"); caValue != "" { + // Mirror upstream behavior: treat as a file path when the file exists, + // otherwise treat it as raw CA content. + if _, err := os.Stat(caValue); err == nil { + opts = append(opts, grpcconn.WithCAFile(caValue)) + } else { + opts = append(opts, grpcconn.WithCAContent(caValue)) + } + } + + return grpcconn.New(viper.GetString("control-plane.API"), authToken, opts...) +} + +// casURI returns the CAS connection URI from viper config. +func casURI() string { return viper.GetString("artifact-cas.API") } + +// casCAPath returns the CAS CA certificate path from viper config. +func casCAPath() string { return viper.GetString("artifact-cas.api-ca") } + +// casInsecure returns whether the CAS connection is insecure from viper config. +func casInsecure() bool { return viper.GetBool("api-insecure") } + +// CheckAuth verifies the CLI can reach the control plane. +func (e *AttestationExecutor) CheckAuth(_ context.Context) error { + _, err := NewConfigCurrentContext(&ActionsOpts{ + CPConnection: e.actionOpts.CPConnection, + Logger: e.actionOpts.Logger, + AuthTokenRaw: e.actionOpts.AuthTokenRaw, + }).Run() + if err != nil { + return fmt.Errorf("chainloop is not authenticated; run 'chainloop config save' first: %w", err) + } + + return nil +} + +// Init starts a new attestation and returns the workflow run ID. +// It uses a local state path to avoid conflicts with concurrent attestations. +// When version is empty the latest project version is used; otherwise the +// attestation targets that specific version. +func (e *AttestationExecutor) Init(ctx context.Context, workflow, project, version string) (string, error) { + a, err := NewAttestationInit(&AttestationInitOpts{ + ActionsOpts: e.actionOpts, + Force: true, + LocalStatePath: e.localStatePath, + CASURI: casURI(), + CASCAPath: casCAPath(), + ConnectionInsecure: casInsecure(), + }) + if err != nil { + return "", fmt.Errorf("create attestation init action: %w", err) + } + + runOpts := &AttestationInitRunOpts{ + WorkflowName: workflow, + ProjectName: project, + Collectors: []string{"aiconfig"}, + } + if version != "" { + runOpts.ProjectVersion = version + } else { + runOpts.UseLatestVersion = true + } + + id, err := a.Run(ctx, runOpts) + if err != nil { + return "", fmt.Errorf("attestation init: %w", err) + } + + return id, nil +} + +// AddEvidence adds an evidence material to the attestation. +// Empty attestation ID is passed upstream so the crafter uses LocalStatePath +// instead of forcing remote state. +func (e *AttestationExecutor) AddEvidence(ctx context.Context, name, filePath string) error { + a, err := NewAttestationAdd(&AttestationAddOpts{ + ActionsOpts: e.actionOpts, + LocalStatePath: e.localStatePath, + CASURI: casURI(), + CASCAPath: casCAPath(), + ConnectionInsecure: casInsecure(), + }) + if err != nil { + return fmt.Errorf("create attestation add action: %w", err) + } + + if _, err := a.Run(ctx, "", name, filePath, "CHAINLOOP_AI_CODING_SESSION", nil, nil, nil); err != nil { + return fmt.Errorf("attestation add evidence: %w", err) + } + + return nil +} + +// Reset cancels the current attestation. +func (e *AttestationExecutor) Reset(ctx context.Context, trigger, reason string) error { + a, err := NewAttestationReset(&AttestationResetOpts{ + ActionsOpts: e.actionOpts, + LocalStatePath: e.localStatePath, + }) + if err != nil { + return fmt.Errorf("create attestation reset action: %w", err) + } + + if err := a.Run(ctx, "", trigger, reason); err != nil { + return fmt.Errorf("attestation reset: %w", err) + } + + return nil +} + +// Push finalizes and pushes the attestation. +func (e *AttestationExecutor) Push(ctx context.Context) error { + cliVersion, cliDigest, err := e.executableInfo() + if err != nil { + return fmt.Errorf("resolve executable info: %w", err) + } + + a, err := NewAttestationPush(&AttestationPushOpts{ + ActionsOpts: e.actionOpts, + LocalStatePath: e.localStatePath, + CASURI: casURI(), + CASCAPath: casCAPath(), + ConnectionInsecure: casInsecure(), + CLIVersion: cliVersion, + CLIDigest: cliDigest, + }) + if err != nil { + return fmt.Errorf("create attestation push action: %w", err) + } + + if _, err := a.Run(ctx, "", nil, false); err != nil { + return fmt.Errorf("attestation push: %w", err) + } + + return nil +} + +// executableInfo returns the CLI version and SHA-256 digest of the running CLI binary +func (e *AttestationExecutor) executableInfo() (string, string, error) { + ex, err := os.Executable() + if err != nil { + return "", "", fmt.Errorf("locating executable: %w", err) + } + + f, err := os.Open(ex) + if err != nil { + return "", "", fmt.Errorf("opening executable: %w", err) + } + defer func() { _ = f.Close() }() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", "", fmt.Errorf("hashing executable: %w", err) + } + + return e.cliVersion, fmt.Sprintf("sha256:%x", h.Sum(nil)), nil +} diff --git a/app/cli/pkg/action/trace_attestation_test.go b/app/cli/pkg/action/trace_attestation_test.go new file mode 100644 index 000000000..01b916174 --- /dev/null +++ b/app/cli/pkg/action/trace_attestation_test.go @@ -0,0 +1,42 @@ +package action + +import ( + "testing" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCheckTokenOrganization(t *testing.T) { + apiToken := func(t *testing.T, org string) string { + t.Helper() + claims := jwt.MapClaims{"aud": "api-token-auth.chainloop", "jti": "some-id"} + if org != "" { + claims["org_name"] = org + } + s, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte("secret")) + require.NoError(t, err) + + return s + } + + t.Run("API token from another org is rejected", func(t *testing.T) { + err := checkTokenOrganization(apiToken(t, "foo"), "bar") + require.Error(t, err) + assert.Contains(t, err.Error(), "foo") + assert.Contains(t, err.Error(), "bar") + }) + + t.Run("API token from the pinned org passes", func(t *testing.T) { + assert.NoError(t, checkTokenOrganization(apiToken(t, "bar"), "bar")) + assert.NoError(t, checkTokenOrganization(apiToken(t, "BAR"), "bar")) + }) + + t.Run("tokens without an org claim pass", func(t *testing.T) { + // User and federated tokens: the org header is honored server-side. + assert.NoError(t, checkTokenOrganization(apiToken(t, ""), "bar")) + assert.NoError(t, checkTokenOrganization("", "bar")) + assert.NoError(t, checkTokenOrganization("not-a-jwt", "bar")) + }) +} diff --git a/app/cli/pkg/action/trace_hook_handler.go b/app/cli/pkg/action/trace_hook_handler.go new file mode 100644 index 000000000..33ad37e60 --- /dev/null +++ b/app/cli/pkg/action/trace_hook_handler.go @@ -0,0 +1,667 @@ +package action + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "sort" + "strings" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/attribution" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/config" + 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/rs/zerolog" +) + +// HandleCommitMsgHook appends a Chainloop-Trace-Sessions trailer to the commit +// message when AI sessions have modified files staged for commit. +// Errors are logged but never returned (to avoid blocking commits). +func HandleCommitMsgHook(_ context.Context, msgFilePath string, log zerolog.Logger) error { + if err := handleCommitMsg(msgFilePath, log); err != nil { + log.Debug().Err(err).Msg("commit-msg hook failed") + } + + return nil +} + +func handleCommitMsg(msgFilePath string, log zerolog.Logger) error { + gitDir, repoRoot, err := tracegit.FindGitDirAndRoot() + if err != nil { + return err + } + + log.Debug().Str("git_dir", gitDir).Msg("commit-msg hook invoked") + + // Skip merge commits — their content is already attributed in the + // merged-in branch's commits. + if tracegit.IsMergeInProgress(gitDir) { + log.Debug().Msg("merge in progress, skipping trailer") + + return nil + } + + attrs, err := state.NewGitStore(gitDir).LoadAllAILineAttributions() + if err != nil { + return fmt.Errorf("load AI line attributions: %w", err) + } + if len(attrs) == 0 { + return nil + } + + client := tracegit.NewGoGitClient() + stagedFiles, err := client.StagedFiles(repoRoot) + if err != nil { + return fmt.Errorf("get staged files: %w", err) + } + + sessionIDs := matchSessionsToFiles(attrs, stagedFiles) + if len(sessionIDs) == 0 { + return nil + } + + log.Debug().Strs("session_ids", sessionIDs).Msg("appending trace sessions trailer") + + return appendTrailer(msgFilePath, sessionIDs) +} + +// matchSessionsToFiles returns session IDs that have AI line attribution for +// any of the given files. The returned slice is sorted. +func matchSessionsToFiles(attrs []*state.AILineAttribution, files []string) []string { + fileSet := make(map[string]struct{}, len(files)) + for _, f := range files { + fileSet[f] = struct{}{} + } + + var sessionIDs []string + for _, attr := range attrs { + for filePath := range attr.Files { + if _, ok := fileSet[filePath]; ok { + sessionIDs = append(sessionIDs, attr.SessionID) + + break + } + } + } + + sort.Strings(sessionIDs) + + return sessionIDs +} + +// appendTrailer reads the commit message file, appends a Chainloop-Trace-Sessions +// trailer with the given session IDs, and writes the file back. +// Skips if the trailer already exists (idempotent for --amend). +func appendTrailer(msgFilePath string, sessionIDs []string) error { + content, err := os.ReadFile(msgFilePath) + if err != nil { + return fmt.Errorf("read commit message: %w", err) + } + + msg := string(content) + + if strings.Contains(msg, state.TrailerKey+":") { + return nil + } + + trailer := fmt.Sprintf("%s: %s\n", state.TrailerKey, strings.Join(sessionIDs, ", ")) + + // Git trailer convention: trailers follow a blank line after the body. + // Ensure we have at least one blank line before the trailer. + trimmed := strings.TrimRight(msg, "\n") + result := trimmed + "\n\n" + trailer + + return os.WriteFile(msgFilePath, []byte(result), 0600) +} + +// HandlePostCommitHook handles the post-commit git hook. +// Errors are logged but never returned (to avoid blocking commits). +func HandlePostCommitHook(ctx context.Context, log zerolog.Logger) error { + if err := handlePostCommit(ctx, log); err != nil { + log.Debug().Err(err).Msg("post-commit hook failed") + } + + return nil +} + +func handlePostCommit(_ context.Context, log zerolog.Logger) error { + gitDir, repoRoot, err := tracegit.FindGitDirAndRoot() + if err != nil { + return err + } + + log.Debug().Str("git_dir", gitDir).Msg("post-commit hook invoked") + + client := tracegit.NewGoGitClient() + sha, message, err := client.CommitHeadInfo(repoRoot) + if err != nil { + return fmt.Errorf("get commit info: %w", err) + } + + // Skip merge commits: their content is already attributed in the + // merged-in branch's commits, so recording them would double-count. + isMerge, err := client.IsMergeCommit(repoRoot, sha) + if err != nil { + return fmt.Errorf("check merge commit: %w", err) + } + if isMerge { + log.Debug().Str("sha", sha).Msg("merge commit, skipping record") + + return nil + } + + // Prefer the trailer's session IDs when present — they survive rebase + // and cherry-pick verbatim. Fall back to file-based derivation otherwise. + store := state.NewGitStore(gitDir) + + sessionIDs := state.ParseSessionIDsFromTrailer(message) + if len(sessionIDs) == 0 { + sessionIDs = deriveSessionIDsForCommit(client, store, repoRoot, sha) + } + + log.Debug().Str("sha", sha).Strs("session_ids", sessionIDs).Msg("recording commit") + + rec := &state.CommitRecord{ + SHA: sha, + Message: message, + SessionIDs: sessionIDs, + Timestamp: state.NowTimestamp(), + } + + if err := store.SaveCommitRecord(rec); err != nil { + return fmt.Errorf("save commit record: %w", err) + } + + log.Info().Str("sha", sha).Msg("commit record saved") + + return nil +} + +// deriveSessionIDsForCommit scans ai-lines data and returns session IDs +// that have modified any file that appears in the given commit. +func deriveSessionIDsForCommit(client tracegit.Client, store *state.Store, repoRoot, headSHA string) []string { + attrs, err := store.LoadAllAILineAttributions() + if err != nil || len(attrs) == 0 { + return nil + } + + changes, err := client.CodeChangesForRange(repoRoot, headSHA, headSHA) + if err != nil || len(changes.Files) == 0 { + return nil + } + + files := make([]string, 0, len(changes.Files)) + for _, f := range changes.Files { + files = append(files, f.Path) + } + + return matchSessionsToFiles(attrs, files) +} + +// filterCurrentBranchCommits returns the subset of records whose SHA is +// reachable from HEAD but not from the default remote branch (i.e. on-branch), +// and which are not merge commits. Records pointing at SHAs that no longer +// exist in the branch — typically left behind by a rebase — are dropped. +// +// If the branch SHA list cannot be obtained, returns the input unchanged +// (best-effort: missing context shouldn't block attestation). +func filterCurrentBranchCommits(client tracegit.Client, repoRoot string, records []*state.CommitRecord, log zerolog.Logger) []*state.CommitRecord { + branchSHAs, err := client.BranchCommitSHAs(repoRoot) + if err != nil { + log.Debug().Err(err).Msg("could not enumerate branch commits, skipping orphan filter") + + return records + } + + inBranch := make(map[string]struct{}, len(branchSHAs)) + for _, sha := range branchSHAs { + inBranch[sha] = struct{}{} + } + + filtered := make([]*state.CommitRecord, 0, len(records)) + for _, rec := range records { + if _, ok := inBranch[rec.SHA]; !ok { + log.Debug().Str("sha", rec.SHA).Msg("dropping orphan commit record (not in branch)") + continue + } + + isMerge, err := client.IsMergeCommit(repoRoot, rec.SHA) + if err != nil { + log.Debug().Err(err).Str("sha", rec.SHA).Msg("could not check merge status, dropping defensively") + continue + } + if isMerge { + log.Debug().Str("sha", rec.SHA).Msg("dropping merge commit record") + continue + } + + filtered = append(filtered, rec) + } + + return filtered +} + +// RunTracePushOpts configures RunTracePush behaviour. +type RunTracePushOpts struct { + // AllowEmpty makes the push attest every recorded session even when + // no AI-attributed commits exist. Used by `chainloop trace run`, + // which drives one-shot agent sessions that may produce no commits. + AllowEmpty bool + + // ProjectName, when set, overrides the projectName read from + // .chainloop.yml. Lets `trace run` operate without mutating the repo + // config. + ProjectName string + // Organization, when set, overrides the organization read from + // .chainloop.yml. + Organization string + // WorkflowName, when set, overrides the workflowName read from + // .chainloop.yml. + WorkflowName string + // ProjectVersion, when set, targets a specific project version for + // the attestation. Empty means use the latest version. + ProjectVersion string + // IgnoreYAML disables the .chainloop.yml fallback used to fill in + // identity fields. `trace run` sets this so its attestations depend + // only on CLI flags. Pre-push hook callers leave it false to keep + // reading the repo config. + IgnoreYAML bool + + // ActionOpts is the root command's initialized options, used to build + // the attestation executor. Required: the push cannot run without it. + ActionOpts *ActionsOpts + // CLIVersion is the bare CLI version recorded in the attestation + // predicate. + CLIVersion string +} + +// HandlePrePushHook handles the pre-push git hook. +// When requireTrace is true, errors from the attestation push are +// propagated so that the git push is blocked. When false, errors are +// logged but never returned. +func HandlePrePushHook(ctx context.Context, requireTrace bool, log zerolog.Logger, opts RunTracePushOpts) error { + drainPushStdin() + + if err := RunTracePush(ctx, log, opts); err != nil { + if requireTrace { + return fmt.Errorf("attestation failed (--require-trace is enabled): %w", err) + } + + log.Debug().Err(err).Msg("pre-push hook failed") + } + + return nil +} + +// RunTracePush gathers AI-coding-session evidence from the local trace +// state and emits a Chainloop attestation. Shared between the pre-push +// git hook and `chainloop trace run`. +// +//nolint:gocyclo // straight-line orchestration of the pre-push flow; splitting it would only move the branches +func RunTracePush(ctx context.Context, log zerolog.Logger, opts RunTracePushOpts) error { + store, repoRoot, err := state.Locate() + if err != nil { + return err + } + + log.Debug().Str("state_dir", store.Dir()).Str("repo_root", repoRoot).Bool("allow_empty", opts.AllowEmpty).Msg("trace push invoked") + + allCommits, err := store.LoadAllCommitRecords() + if err != nil { + return fmt.Errorf("load commit records: %w", err) + } + log.Debug().Int("commit_count", len(allCommits)).Msg("loaded commit records") + + gitClient := tracegit.NewGoGitClient() + + // Drop orphan records (SHAs no longer in the branch — typically left over + // by a rebase) and any record that turns out to belong to a merge commit. + allCommits = filterCurrentBranchCommits(gitClient, repoRoot, allCommits, log) + + // Discard commits without any session IDs (no AI agent involved) + var aiCommits []*state.CommitRecord + var untrackedAICommits int + for _, c := range allCommits { + if len(c.SessionIDs) > 0 { + aiCommits = append(aiCommits, c) + if !c.Tracked { + untrackedAICommits++ + } + } + } + + if len(aiCommits) == 0 && !opts.AllowEmpty { + log.Info().Msg("no AI-assisted commits, skipping attestation") + + return nil + } + + // If every candidate AI commit has already been attested by a previous + // successful push, there is nothing new to report. Short-circuit to avoid + // emitting a duplicate attestation on no-op `git push` invocations + // (including `git tag && git push --tags` over already-pushed commits). + if len(aiCommits) > 0 && untrackedAICommits == 0 && !opts.AllowEmpty { + log.Info().Int("ai_commit_count", len(aiCommits)).Msg("all AI-assisted commits already attested, skipping push") + + return nil + } + + sessionCommits := make(map[string][]*state.CommitRecord) + for _, c := range aiCommits { + for _, sid := range c.SessionIDs { + sessionCommits[sid] = append(sessionCommits[sid], c) + } + } + for _, commits := range sessionCommits { + sort.Slice(commits, func(i, j int) bool { + return commits[i].Timestamp < commits[j].Timestamp + }) + } + + sessionRecords, err := store.LoadAllSessionRecords() + if err != nil { + log.Debug().Err(err).Msg("could not load session records") + } + + if len(aiCommits) == 0 { + for sid := range sessionRecords { + sessionCommits[sid] = nil + } + if len(sessionCommits) == 0 { + log.Info().Msg("no AI coding sessions recorded, skipping attestation") + + return nil + } + log.Info().Int("session_count", len(sessionCommits)).Msg("no AI-assisted commits; attesting session evidence only") + } else { + log.Info().Int("ai_commit_count", len(aiCommits)).Msg("parsing AI-assisted commit records") + } + + log.Debug().Int("session_count", len(sessionCommits)).Msg("grouped commits by session") + + // Collect repo-level context once (same for all sessions) + rawDir := store.RawSessionDir() + + gitCtx, gitCtxErr := gitClient.Context(repoRoot) + if gitCtxErr != nil { + log.Debug().Err(gitCtxErr).Msg("could not collect git context") + } + + isGenerated := gitClient.GeneratedMatcher(repoRoot) + + // Build evidence for each session + type sessionEvidence struct { + sessionID string + tmpPath string + } + var evidenceFiles []sessionEvidence + + // Register cleanup before the loop so mid-loop returns are covered + defer func() { + for _, ef := range evidenceFiles { + _ = os.Remove(ef.tmpPath) + } + }() + + for sessionID, commits := range sessionCommits { + log.Debug().Str("session", sessionID).Int("commits", len(commits)).Msg("processing session") + + provider := providerForSession(sessionRecords, sessionID) + if provider == nil { + log.Debug().Str("session", sessionID).Msg("no provider registered for session, skipping") + continue + } + + // Fresh copy of session data before parsing — the session-start copy + // may be stale if more conversation happened between start and push. + if err := provider.CopySessionData(store, repoRoot, sessionID); err != nil { + log.Debug().Err(err).Str("session", sessionID).Msg("could not refresh session data") + } + + parseOpts := &trace.ParseOpts{ + SessionDir: rawDir, + SessionID: sessionID, + } + if rec, ok := sessionRecords[sessionID]; ok && rec != nil { + parseOpts.AgentVersion = rec.AgentVersion + parseOpts.Model = rec.Model + } + + result, err := provider.ParseSession(ctx, parseOpts) + if err != nil { + log.Debug().Err(err).Str("session", sessionID).Msg("could not parse session, skipping") + continue + } + + // Apply repo-wide context with per-session commit overrides + if gitCtxErr == nil { + sessionCtx := *gitCtx + sessionCtx.Commits = commitDescriptions(commits) + sessionCtx.CommitCount = len(commits) + if len(commits) > 0 { + sessionCtx.CommitStart = commits[0].SHA + sessionCtx.CommitEnd = commits[len(commits)-1].SHA + } + result.Data.GitContext = &sessionCtx + } + + // Collect code changes scoped to this session's commits. Use a SET-based + // API rather than a SHA range: when the session's commits are non- + // contiguous on the branch (e.g. interleaved with another session or + // human commits), a range diff would over-count by including the gap. + if len(commits) > 0 { + shas := make([]string, 0, len(commits)) + for _, c := range commits { + shas = append(shas, c.SHA) + } + codeChanges, err := gitClient.CodeChangesForCommits(repoRoot, shas) + if err != nil { + log.Debug().Err(err).Msg("could not collect code changes") + } else { + attr := store.LoadAILineAttribution(sessionID) + attribution.FilterGenerated(codeChanges, isGenerated) + attribution.Enrich(sessionID, attr.Files, codeChanges) + result.Data.CodeChanges = codeChanges + } + } + + // Write evidence to temp file + tmpFile, err := os.CreateTemp("", "chainloop-trace-*.json") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + + enc := json.NewEncoder(tmpFile) + enc.SetIndent("", " ") + if err := enc.Encode(result); err != nil { + _ = tmpFile.Close() + _ = os.Remove(tmpFile.Name()) + + return fmt.Errorf("write evidence: %w", err) + } + _ = tmpFile.Close() + + log.Debug().Str("session", sessionID).Msg("generated evidence") + + evidenceFiles = append(evidenceFiles, sessionEvidence{ + sessionID: sessionID, + tmpPath: tmpFile.Name(), + }) + } + + if len(evidenceFiles) == 0 { + log.Debug().Msg("no session evidence could be generated") + + return nil + } + + // Create attestation using local state file to avoid conflicts with other attestations + localStatePath := store.AttestationStatePath() + defer func() { _ = os.Remove(localStatePath) }() + + projectName, organization, workflowName := resolvePushIdentity(repoRoot, opts) + if projectName == "" { + if opts.IgnoreYAML { + return fmt.Errorf("no project name provided") + } + + return fmt.Errorf("no project name found (pass via options or .chainloop.yml)") + } + + log.Debug().Str("project", projectName).Msg("resolved project for trace push") + + execOpts := []ExecutorOption{WithLocalStatePath(localStatePath), WithLogger(log)} + if organization != "" { + log.Debug().Str("forced_org", organization).Msg("forcing organization for trace push") + execOpts = append(execOpts, WithForcedOrganization(organization)) + } + executor, err := NewAttestationExecutor(opts.ActionOpts, opts.CLIVersion, execOpts...) + if err != nil { + // Warn (not debug): a misconfiguration here means no attestation is + // sent at all, and the pre-push hook swallows the returned error + // unless require-trace is on. + log.Warn().Err(err).Msg("skipping trace attestation") + + return err + } + defer func() { _ = executor.Close() }() + + attestationID, err := executor.Init(ctx, workflowName, projectName, opts.ProjectVersion) + if err != nil { + return fmt.Errorf("attestation init: %w", err) + } + + log.Debug().Str("attestation_id", attestationID).Msg("attestation initialized") + + // Add evidence for each session + var addedCount int + for _, ef := range evidenceFiles { + name := evidenceName(ef.sessionID) + if err := executor.AddEvidence(ctx, name, ef.tmpPath); err != nil { + log.Debug().Err(err).Str("session", ef.sessionID).Msg("could not add evidence") + continue + } + addedCount++ + log.Debug().Str("session", ef.sessionID).Str("name", name).Msg("evidence added") + } + + if addedCount == 0 { + log.Debug().Msg("no evidence successfully added, resetting attestation") + _ = executor.Reset(ctx, "trace-push", "no CHAINLOOP_AI_CODING_SESSION evidence added") + + return nil + } + + // Push attestation + log.Debug().Msg("pushing attestation") + if err := executor.Push(ctx); err != nil { + return fmt.Errorf("attestation push: %w", err) + } + + log.Debug().Msg("attestation pushed, wiping single-use trace state") + + // Mark every AI commit included in this attestation as tracked so that a + // subsequent `git push` with no new commits short-circuits at the skip + // check above. Save errors are non-fatal: the attestation already went + // out, and at worst we re-attest the same commits on the next push. + for _, c := range aiCommits { + if c.Tracked { + continue + } + c.Tracked = true + if err := store.SaveCommitRecord(c); err != nil { + log.Debug().Err(err).Str("sha", c.SHA).Msg("could not mark commit record as tracked") + } + } + + _ = store.WipeTraceDir() + if liveSHAs, err := gitClient.LocalReachableSHAs(repoRoot); err == nil { + if err := store.GCOrphans(liveSHAs); err != nil { + log.Debug().Err(err).Msg("orphan GC failed; trace state is intact but not pruned") + } + } else { + log.Debug().Err(err).Msg("could not enumerate local branch SHAs; skipping orphan GC") + } + + return nil +} + +// evidenceName returns the material name for a session evidence document. +// Format: ai-coding-session-. Underscores are +// replaced with hyphens because material names may only contain lowercase +// letters, numbers, and hyphens. +func evidenceName(sessionID string) string { + short := sessionID + if len(short) > 6 { + short = short[:6] + } + + return "ai-coding-session-" + strings.ReplaceAll(short, "_", "-") +} + +// commitDescriptions returns commit strings in "SHA first_line_of_message" format. +func commitDescriptions(commits []*state.CommitRecord) []string { + descs := make([]string, len(commits)) + for i, c := range commits { + firstLine, _, _ := strings.Cut(c.Message, "\n") + firstLine = strings.TrimSpace(firstLine) + if firstLine != "" { + descs[i] = c.SHA + " " + firstLine + } else { + descs[i] = c.SHA + } + } + + return descs +} + +// resolvePushIdentity returns the project name, organization, and +// workflow name to use for an attestation push. Opts override +// .chainloop.yml; YAML fields fill in whatever opts left empty unless +// opts.IgnoreYAML is set, in which case the YAML is skipped entirely. +// Workflow always passes through ResolveWorkflowName so the default kicks in. +func resolvePushIdentity(repoRoot string, opts RunTracePushOpts) (project, org, workflow string) { + project = opts.ProjectName + org = opts.Organization + workflow = opts.WorkflowName + + if !opts.IgnoreYAML && (project == "" || org == "" || workflow == "") { + if yml := config.FindChainloopYML(repoRoot); yml != nil { + if project == "" { + project = yml.ProjectName + } + if org == "" { + org = yml.Organization + } + if workflow == "" { + workflow = yml.WorkflowName + } + } + } + + return project, org, config.ResolveWorkflowName(workflow) +} + +// drainPushStdin reads and discards pre-push stdin to avoid SIGPIPE. +func drainPushStdin() { + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + } +} + +// providerForSession resolves the trace.Provider that owns sessionID. +// Session records written before multi-provider support had no provider +// field and are attributed to trace.DefaultProviderName. +func providerForSession(records map[string]*state.SessionRecord, sessionID string) trace.Provider { + name := trace.DefaultProviderName + + if rec, ok := records[sessionID]; ok && rec != nil && rec.Provider != "" { + return providers.ByName(rec.Provider) + } + + return providers.ByName(name) +} diff --git a/app/cli/pkg/action/trace_hook_handler_test.go b/app/cli/pkg/action/trace_hook_handler_test.go new file mode 100644 index 000000000..28f5c1cca --- /dev/null +++ b/app/cli/pkg/action/trace_hook_handler_test.go @@ -0,0 +1,677 @@ +package action + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/attribution" + tracegit "github.com/chainloop-dev/chainloop/app/cli/internal/trace/git" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEvidenceName(t *testing.T) { + t.Run("truncates long session ID", func(t *testing.T) { + name := evidenceName("abcdefghijklmnop") + assert.Equal(t, "ai-coding-session-abcdef", name) + }) + + t.Run("handles short session ID", func(t *testing.T) { + name := evidenceName("abc") + assert.Equal(t, "ai-coding-session-abc", name) + }) +} + +func TestMatchSessionsToFiles(t *testing.T) { + attrs := []*state.AILineAttribution{ + {SessionID: "session-a", Files: map[string][]aicodingsession.LineRange{"src/foo.go": {{Start: 1, End: 10}}}}, + {SessionID: "session-b", Files: map[string][]aicodingsession.LineRange{"src/bar.go": {{Start: 1, End: 5}}}}, + {SessionID: "session-c", Files: map[string][]aicodingsession.LineRange{"src/baz.go": {{Start: 1, End: 3}}}}, + } + + t.Run("matches sessions that touched staged files", func(t *testing.T) { + ids := matchSessionsToFiles(attrs, []string{"src/foo.go", "src/bar.go"}) + assert.Equal(t, []string{"session-a", "session-b"}, ids) + }) + + t.Run("no match returns nil", func(t *testing.T) { + ids := matchSessionsToFiles(attrs, []string{"src/other.go"}) + assert.Nil(t, ids) + }) + + t.Run("empty staged files returns nil", func(t *testing.T) { + ids := matchSessionsToFiles(attrs, nil) + assert.Nil(t, ids) + }) + + t.Run("empty attrs returns nil", func(t *testing.T) { + ids := matchSessionsToFiles(nil, []string{"src/foo.go"}) + assert.Nil(t, ids) + }) + + t.Run("result is sorted", func(t *testing.T) { + ids := matchSessionsToFiles(attrs, []string{"src/baz.go", "src/foo.go"}) + assert.Equal(t, []string{"session-a", "session-c"}, ids) + }) +} + +func TestAppendTrailer(t *testing.T) { + writeMsg := func(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "COMMIT_EDITMSG") + require.NoError(t, os.WriteFile(path, []byte(content), 0600)) + + return path + } + + t.Run("appends trailer after blank line", func(t *testing.T) { + path := writeMsg(t, "fix: resolve login bug\n") + require.NoError(t, appendTrailer(path, []string{"id-1", "id-2"})) + + got, _ := os.ReadFile(path) + assert.Equal(t, "fix: resolve login bug\n\nChainloop-Trace-Sessions: id-1, id-2\n", string(got)) + }) + + t.Run("handles multi-line messages", func(t *testing.T) { + path := writeMsg(t, "feat: add auth\n\nThis adds OAuth2 support.\n") + require.NoError(t, appendTrailer(path, []string{"s1"})) + + got, _ := os.ReadFile(path) + assert.Contains(t, string(got), "This adds OAuth2 support.\n\nChainloop-Trace-Sessions: s1\n") + }) + + t.Run("skips if trailer already present", func(t *testing.T) { + original := "fix: bug\n\nChainloop-Trace-Sessions: existing-id\n" + path := writeMsg(t, original) + require.NoError(t, appendTrailer(path, []string{"new-id"})) + + got, _ := os.ReadFile(path) + assert.Equal(t, original, string(got)) + }) + + t.Run("single session ID", func(t *testing.T) { + path := writeMsg(t, "chore: cleanup\n") + require.NoError(t, appendTrailer(path, []string{"only-one"})) + + got, _ := os.ReadFile(path) + assert.Contains(t, string(got), "Chainloop-Trace-Sessions: only-one\n") + }) + + t.Run("strips trailing newlines before adding blank line", func(t *testing.T) { + path := writeMsg(t, "fix: something\n\n\n\n") + require.NoError(t, appendTrailer(path, []string{"s1"})) + + got, _ := os.ReadFile(path) + assert.Equal(t, "fix: something\n\nChainloop-Trace-Sessions: s1\n", string(got)) + }) +} + +// fakeBranchClient is a stub implementation of tracegit.Client for testing +// filterCurrentBranchCommits in isolation. +type fakeBranchClient struct { + branchSHAs []string + branchErr error + mergeSHAs map[string]bool + mergeErr error +} + +func (f *fakeBranchClient) Context(string) (*aicodingsession.GitContext, error) { + return &aicodingsession.GitContext{}, nil +} + +func (f *fakeBranchClient) CodeChanges(string) (*aicodingsession.CodeChanges, error) { + return &aicodingsession.CodeChanges{}, nil +} + +func (f *fakeBranchClient) CodeChangesForRange(string, string, string) (*aicodingsession.CodeChanges, error) { + return &aicodingsession.CodeChanges{}, nil +} + +func (f *fakeBranchClient) CodeChangesForCommits(string, []string) (*aicodingsession.CodeChanges, error) { + return &aicodingsession.CodeChanges{}, nil +} + +func (f *fakeBranchClient) CommitHeadInfo(string) (string, string, error) { return "", "", nil } +func (f *fakeBranchClient) GeneratedMatcher(string) func(string) bool { + return func(string) bool { return false } +} +func (f *fakeBranchClient) StagedFiles(string) ([]string, error) { return nil, nil } +func (f *fakeBranchClient) SnapshotWorktree(string) (map[string]string, error) { + return nil, nil +} +func (f *fakeBranchClient) IsMergeCommit(_, sha string) (bool, error) { + return f.mergeSHAs[sha], f.mergeErr +} + +func (f *fakeBranchClient) BranchCommitSHAs(string) ([]string, error) { + return f.branchSHAs, f.branchErr +} + +func (f *fakeBranchClient) LocalReachableSHAs(string) (map[string]bool, error) { + return nil, nil +} + +func TestFilterCurrentBranchCommits(t *testing.T) { + rec := func(sha string) *state.CommitRecord { return &state.CommitRecord{SHA: sha} } + + t.Run("drops orphans not in branch", func(t *testing.T) { + client := &fakeBranchClient{branchSHAs: []string{"new1", "new2"}} + got := filterCurrentBranchCommits(client, "/repo", + []*state.CommitRecord{rec("old1"), rec("new1"), rec("new2"), rec("old2")}, + zerolog.Nop(), + ) + + require.Len(t, got, 2) + assert.Equal(t, "new1", got[0].SHA) + assert.Equal(t, "new2", got[1].SHA) + }) + + t.Run("drops merge commits even when in branch", func(t *testing.T) { + client := &fakeBranchClient{ + branchSHAs: []string{"a", "merge", "b"}, + mergeSHAs: map[string]bool{"merge": true}, + } + got := filterCurrentBranchCommits(client, "/repo", + []*state.CommitRecord{rec("a"), rec("merge"), rec("b")}, + zerolog.Nop(), + ) + + require.Len(t, got, 2) + assert.Equal(t, "a", got[0].SHA) + assert.Equal(t, "b", got[1].SHA) + }) + + t.Run("returns input unchanged on branch enumeration error", func(t *testing.T) { + client := &fakeBranchClient{branchErr: assert.AnError} + input := []*state.CommitRecord{rec("a"), rec("b")} + got := filterCurrentBranchCommits(client, "/repo", input, zerolog.Nop()) + assert.Equal(t, input, got) + }) +} + +func TestRunTracePushEmpty(t *testing.T) { + t.Run("AllowEmpty=false returns nil when there are no AI commits", func(t *testing.T) { + _, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + err := RunTracePush(t.Context(), zerolog.Nop(), RunTracePushOpts{}) + require.NoError(t, err, "no commits + AllowEmpty=false must short-circuit cleanly") + }) + + t.Run("AllowEmpty=true returns nil when there are no sessions either", func(t *testing.T) { + _, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + err := RunTracePush(t.Context(), zerolog.Nop(), RunTracePushOpts{AllowEmpty: true}) + require.NoError(t, err, "no commits + no sessions must short-circuit cleanly even under AllowEmpty") + }) +} + +// TestRunTracePush_SkipsWhenAllTracked verifies that the pre-push hook +// short-circuits when every AI-assisted commit on the branch has already +// been marked tracked by a previous successful push. This is the core +// dedup behaviour for back-to-back `git push` (and `git push --tags`) +// invocations that introduce no new commits. +func TestRunTracePush_SkipsWhenAllTracked(t *testing.T) { + dir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + // Use the SHA of the initial commit so filterCurrentBranchCommits keeps + // the record (it drops SHAs not reachable from HEAD). + sha := headSHA(t, dir) + rec := &state.CommitRecord{ + SHA: sha, + Message: "initial", + SessionIDs: []string{"sess-1"}, + Timestamp: state.NowTimestamp(), + Tracked: true, + } + require.NoError(t, store.SaveCommitRecord(rec)) + + // AllowEmpty=false matches the pre-push hook caller. With every AI + // commit already tracked, RunTracePush must return nil without + // attempting to contact the executor. + err := RunTracePush(t.Context(), zerolog.Nop(), RunTracePushOpts{}) + require.NoError(t, err, "all tracked AI commits must short-circuit cleanly") + + // Record should still be on disk and still marked tracked (we skipped + // before any wipe could run). + got := mustLoadRecord(t, store, sha) + assert.True(t, got.Tracked, "tracked flag must survive the short-circuit") +} + +// TestCommitRecord_TrackedRoundTrip verifies that the new Tracked field +// round-trips through JSON and that legacy records written before this +// change (no `tracked` key on disk) decode as Tracked=false. +func TestCommitRecord_TrackedRoundTrip(t *testing.T) { + gitDir := t.TempDir() + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + t.Run("tracked=true round-trips", func(t *testing.T) { + rec := &state.CommitRecord{ + SHA: "abc", + Message: "m", + SessionIDs: []string{"sess-1"}, + Timestamp: state.NowTimestamp(), + Tracked: true, + } + require.NoError(t, store.SaveCommitRecord(rec)) + + all, err := store.LoadAllCommitRecords() + require.NoError(t, err) + require.Len(t, all, 1) + assert.True(t, all[0].Tracked) + }) + + t.Run("legacy record without tracked key decodes as false", func(t *testing.T) { + legacyDir := t.TempDir() + require.NoError(t, state.NewGitStore(legacyDir).InitTraceDir()) + + raw := `{"sha":"def","message":"legacy","session_ids":["sess-1"],"timestamp":"2026-01-01T00:00:00Z"}` + path := filepath.Join(legacyDir, "chainloop-trace", "commits", "def.json") + require.NoError(t, os.WriteFile(path, []byte(raw), 0600)) + + all, err := state.NewGitStore(legacyDir).LoadAllCommitRecords() + require.NoError(t, err) + require.Len(t, all, 1) + assert.False(t, all[0].Tracked, "legacy records must default to untracked") + }) +} + +// initGitRepo creates a real git repo in dir and chdirs into it for the test. +// Restores cwd at teardown. +func initGitRepo(t *testing.T) (dir, gitDir string) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git binary not found") + } + + // Resolve symlinks: on macOS t.TempDir() hands back /var/folders/... while + // the repo root resolves to /private/var/folders/..., and the mismatch makes + // the repo-relative paths under test come out as "../.." escapes. + dir, err := filepath.EvalSymlinks(t.TempDir()) + require.NoError(t, err) + + run := func(args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, string(out)) + } + run("init", "-b", "main") + run("config", "user.email", "test@test.com") + run("config", "user.name", "Test") + run("config", "commit.gpgsign", "false") + + require.NoError(t, os.WriteFile(filepath.Join(dir, "init.txt"), []byte("init"), 0600)) + run("add", "init.txt") + run("commit", "-m", "initial") + + cwd, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(dir)) + t.Cleanup(func() { _ = os.Chdir(cwd) }) + + return dir, filepath.Join(dir, ".git") +} + +// runGit runs git in dir, failing the test on error. +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, string(out)) +} + +func TestHandlePostCommit_UsesTrailerWhenPresent(t *testing.T) { + dir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + // Make a commit whose message contains a Chainloop-Trace-Sessions trailer + // but with NO matching ai-lines data (so file-based derivation would + // return nothing). The trailer is what carries attribution forward across + // rebase/cherry-pick. + require.NoError(t, os.WriteFile(filepath.Join(dir, "rebased.txt"), []byte("hello"), 0600)) + runGit(t, dir, "add", "rebased.txt") + runGit(t, dir, "commit", "-m", "feat: rebased work\n\nChainloop-Trace-Sessions: orig-1, orig-2\n") + + require.NoError(t, handlePostCommit(t.Context(), zerolog.Nop())) + + commits, err := store.LoadAllCommitRecords() + require.NoError(t, err) + require.Len(t, commits, 1) + assert.Equal(t, []string{"orig-1", "orig-2"}, commits[0].SessionIDs) +} + +func TestHandlePostCommit_SkipsMergeCommit(t *testing.T) { + dir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + // Create a side branch with one commit, then merge it into main with --no-ff + // to force a merge commit. + runGit(t, dir, "checkout", "-b", "side") + require.NoError(t, os.WriteFile(filepath.Join(dir, "side.txt"), []byte("side"), 0600)) + runGit(t, dir, "add", "side.txt") + runGit(t, dir, "commit", "-m", "side commit") + runGit(t, dir, "checkout", "main") + runGit(t, dir, "merge", "--no-ff", "-m", "merge side", "side") + + require.NoError(t, handlePostCommit(t.Context(), zerolog.Nop())) + + commits, err := store.LoadAllCommitRecords() + require.NoError(t, err) + assert.Empty(t, commits, "no CommitRecord should be saved for a merge commit") +} + +func TestHandleCommitMsg_SkipsWhenMergeHeadPresent(t *testing.T) { + dir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + + // Plant ai-lines data so the hook would otherwise append a trailer. + require.NoError(t, store.InitTraceDir()) + require.NoError(t, store.RecordLineRanges("session-x", "init.txt", []aicodingsession.LineRange{{Start: 1, End: 1}})) + + // Plant MERGE_HEAD to simulate a merge in progress. + require.NoError(t, os.WriteFile(filepath.Join(gitDir, "MERGE_HEAD"), []byte("abc\n"), 0600)) + + // Stage a change matching the ai-lines file so matchSessionsToFiles would match. + require.NoError(t, os.WriteFile(filepath.Join(dir, "init.txt"), []byte("changed"), 0600)) + cmd := exec.Command("git", "add", "init.txt") + cmd.Dir = dir + require.NoError(t, cmd.Run()) + + msgPath := filepath.Join(dir, "COMMIT_EDITMSG") + original := "merge: pulling in side\n" + require.NoError(t, os.WriteFile(msgPath, []byte(original), 0600)) + + require.NoError(t, handleCommitMsg(msgPath, zerolog.Nop())) + + got, err := os.ReadFile(msgPath) + require.NoError(t, err) + assert.Equal(t, original, string(got), "merge commit message should not get a trailer") +} + +// TestRebaseLifecycle exercises the full attribution lifecycle across a rebase +// and a merge using a real git repo. It pins the end-to-end behavior the +// manual verification covers: rebased commits inherit attribution via the +// trailer, old SHA records become orphans that the pre-push filter drops, and +// merge commits never produce records at all. +func TestRebaseLifecycle(t *testing.T) { + dir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + // detectBaseBranchGoGit looks for refs/remotes/origin/main, so plant it. + runGit(t, dir, "update-ref", "refs/remotes/origin/main", "HEAD") + + // Plant ai-lines for sess-1 against a.txt so the file-based path also + // works as a fallback. + require.NoError(t, store.RecordLineRanges("sess-1", "a.txt", []aicodingsession.LineRange{{Start: 1, End: 1}})) + + // --- 1. Original commit on a feature branch with the trailer. --- + runGit(t, dir, "checkout", "-b", "feature") + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hello\n"), 0600)) + runGit(t, dir, "add", "a.txt") + runGit(t, dir, "commit", "-m", "feat: add a\n\nChainloop-Trace-Sessions: sess-1\n") + + require.NoError(t, handlePostCommit(t.Context(), zerolog.Nop())) + + oldSHA := headSHA(t, dir) + oldRecord := mustLoadRecord(t, store, oldSHA) + require.Equal(t, []string{"sess-1"}, oldRecord.SessionIDs) + + // --- 2. Advance main with an unrelated commit, then cherry-pick the + // original commit on top — simulates rebase replay onto a moved + // base. The new commit has a different parent, hence a new SHA, + // but git preserves the message (and trailer) verbatim. --- + runGit(t, dir, "checkout", "main") + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.txt"), []byte("main moved\n"), 0600)) + runGit(t, dir, "add", "main.txt") + runGit(t, dir, "commit", "-m", "main: move forward") + runGit(t, dir, "update-ref", "refs/remotes/origin/main", "HEAD") + + runGit(t, dir, "checkout", "-b", "feature-rebased") + runGit(t, dir, "cherry-pick", oldSHA) + + require.NoError(t, handlePostCommit(t.Context(), zerolog.Nop())) + + newSHA := headSHA(t, dir) + require.NotEqual(t, oldSHA, newSHA, "cherry-pick should produce a different SHA") + + newRecord := mustLoadRecord(t, store, newSHA) + assert.Equal(t, []string{"sess-1"}, newRecord.SessionIDs, + "new SHA's record should inherit session IDs from the trailer") + + // Old record still on disk — orphan cleanup is the filter's job, not post-commit's. + all, err := store.LoadAllCommitRecords() + require.NoError(t, err) + assert.Len(t, all, 2) + + // --- 3. The pre-push filter should drop the orphan and keep the new SHA. --- + client := tracegit.NewGoGitClient() + filtered := filterCurrentBranchCommits(client, dir, all, zerolog.Nop()) + require.Len(t, filtered, 1) + assert.Equal(t, newSHA, filtered[0].SHA, "only the in-branch SHA should survive the filter") + + // --- 4. Create and merge a side branch — merge commit must not be recorded. --- + runGit(t, dir, "checkout", "main") + runGit(t, dir, "checkout", "-b", "side") + require.NoError(t, os.WriteFile(filepath.Join(dir, "s.txt"), []byte("side\n"), 0600)) + runGit(t, dir, "add", "s.txt") + runGit(t, dir, "commit", "-m", "side: change") + + runGit(t, dir, "checkout", "feature-rebased") + runGit(t, dir, "merge", "--no-ff", "-m", "merge side", "side") + + require.NoError(t, handlePostCommit(t.Context(), zerolog.Nop())) + + mergeSHA := headSHA(t, dir) + _, err = os.Stat(filepath.Join(gitDir, "chainloop-trace", "commits", mergeSHA+".json")) + assert.True(t, os.IsNotExist(err), "no record should exist for the merge commit") + + // --- 5. Filter again post-merge: still only the rebased SHA in evidence. --- + all, err = store.LoadAllCommitRecords() + require.NoError(t, err) + filtered = filterCurrentBranchCommits(client, dir, all, zerolog.Nop()) + require.Len(t, filtered, 1) + assert.Equal(t, newSHA, filtered[0].SHA) +} + +// headSHA returns the SHA of HEAD in dir. +func headSHA(t *testing.T, dir string) string { + t.Helper() + cmd := exec.Command("git", "rev-parse", "HEAD") + cmd.Dir = dir + out, err := cmd.Output() + require.NoError(t, err) + + return strings.TrimSpace(string(out)) +} + +// mustLoadRecord reads the CommitRecord for sha and fails the test if it doesn't exist. +func mustLoadRecord(t *testing.T, store *state.Store, sha string) *state.CommitRecord { + t.Helper() + all, err := store.LoadAllCommitRecords() + require.NoError(t, err) + for _, r := range all { + if r.SHA == sha { + return r + } + } + require.Failf(t, "no CommitRecord found", "sha %s", sha) + + return nil +} + +func TestCommitDescriptions(t *testing.T) { + t.Run("includes first line of message", func(t *testing.T) { + commits := []*state.CommitRecord{ + {SHA: "abc123", Message: "Implement hello world app"}, + {SHA: "def456", Message: "Add credit header"}, + } + descs := commitDescriptions(commits) + assert.Equal(t, []string{ + "abc123 Implement hello world app", + "def456 Add credit header", + }, descs) + }) + + t.Run("uses only first line for multi-line messages", func(t *testing.T) { + commits := []*state.CommitRecord{ + {SHA: "abc123", Message: "First line\n\nMore details here"}, + } + descs := commitDescriptions(commits) + assert.Equal(t, []string{"abc123 First line"}, descs) + }) +} + +// TestPushRebasePushPreservesAIAttribution is the end-to-end regression test +// for PFM-5878. The scenario it captures, in order: +// +// 1. AI session edits a file (ai-lines recorded for sess-1). +// 2. User commits (commit-msg appends the trailer; post-commit fires). +// 3. User pushes — runTracePush attests with AI attribution; the post-push +// wipe + GC must preserve commits/ and ai-lines/ because they're the only +// things tying a future rebase replay back to the original session. +// 4. origin advances on main (a different change pushed elsewhere). +// 5. Feature branch is rebased onto the new origin/main. Each replayed +// commit's post-commit hook reads the trailer and writes a fresh +// CommitRecord under the new SHA. +// 6. User force-pushes — runTracePush must produce a second attestation +// where main.go is again attributed to AI (line ranges + sessionID +// intact), not silently flipped to human. +// +// The test exercises the same code paths the real hooks invoke, but skips +// the actual attestation executor (no Chainloop control plane required). +// Instead it asserts the inputs and outputs of attribution.Enrich after +// the rebase, which is the single point where the bug manifests. +func TestPushRebasePushPreservesAIAttribution(t *testing.T) { + dir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + const sessionID = "5f3b6dd7-f6ca-4639-a65c-493a12cd2844" + + // --- 0. Establish hello.go on main first so the AI commit's diff truly + // shows it as a deletion (not just an absent file on a side branch). --- + require.NoError(t, os.WriteFile(filepath.Join(dir, "hello.go"), []byte("a\nb\nc\nd\ne\nf\ng\n"), 0600)) + runGit(t, dir, "add", "hello.go") + runGit(t, dir, "commit", "-m", "preexisting hello.go") + // detectBaseBranchGoGit looks for refs/remotes/origin/main; plant it. + runGit(t, dir, "update-ref", "refs/remotes/origin/main", "HEAD") + + // --- 1. AI-edits main.go and records line ranges (one AI line at line 1). --- + // The agent hook would do this in production via state.RecordLineRanges. + require.NoError(t, store.RecordLineRanges(sessionID, "main.go", []aicodingsession.LineRange{{Start: 1, End: 1}})) + require.NoError(t, store.SaveSessionRecord(&state.SessionRecord{ + SessionID: sessionID, Provider: "claude", Active: true, StartedAt: state.NowTimestamp(), + })) + + // --- 2. Author the AI commit on a feature branch. main.go is created + // with 8 lines (1 AI + 7 human); hello.go is deleted. The trailer + // binds the commit to sessionID across rebases. --- + runGit(t, dir, "checkout", "-b", "feature") + require.NoError(t, os.Remove(filepath.Join(dir, "hello.go"))) + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.go"), []byte("ai-line-1\nh1\nh2\nh3\nh4\nh5\nh6\nh7\n"), 0600)) + runGit(t, dir, "add", "-A") + runGit(t, dir, "commit", "-m", "feat: switch to main\n\nChainloop-Trace-Sessions: "+sessionID+"\n") + require.NoError(t, handlePostCommit(t.Context(), zerolog.Nop())) + + preRebaseSHA := headSHA(t, dir) + + // Sanity: pre-rebase Enrich attributes main.go to AI. + preChanges := buildSessionCodeChanges(t, store, dir, preRebaseSHA, sessionID) + require.NotZero(t, preChanges.AILinesAdded, "pre-rebase: main.go must be attributed to AI") + require.Equal(t, 1, preChanges.AILinesAdded, "pre-rebase: only the line covered by ai-lines is AI") + requireFileAttribution(t, preChanges, "main.go", trace.AttributionAI, []string{sessionID}) + requireFileAttribution(t, preChanges, "hello.go", trace.AttributionHuman, nil) + + // --- 3. Simulate the post-push wipe + reachability GC that runs at the + // end of runTracePush. ai-lines/ and commits/ for sessionID must + // survive because the SHA is reachable from feature. --- + require.NoError(t, store.WipeTraceDir()) + gitClient := tracegit.NewGoGitClient() + liveSHAs, err := gitClient.LocalReachableSHAs(dir) + require.NoError(t, err) + require.NotEmpty(t, liveSHAs, "post-push GC must see reachable SHAs") + require.NoError(t, store.GCOrphans(liveSHAs)) + + require.NotEmpty(t, store.LoadAILineAttribution(sessionID).Files, + "post-push: ai-lines for the live session must survive the wipe + GC") + + // --- 4. origin/main advances with an unrelated commit. --- + runGit(t, dir, "checkout", "main") + require.NoError(t, os.WriteFile(filepath.Join(dir, "unrelated.txt"), []byte("upstream change\n"), 0600)) + runGit(t, dir, "add", "unrelated.txt") + runGit(t, dir, "commit", "-m", "main: upstream change") + runGit(t, dir, "update-ref", "refs/remotes/origin/main", "HEAD") + + // --- 5. Rebase the feature branch onto the new main. Cherry-pick is the + // deterministic stand-in for `git rebase` — same effect on hooks + // (post-commit fires per replayed commit) and same trailer + // preservation. --- + runGit(t, dir, "checkout", "-b", "feature-rebased") + runGit(t, dir, "cherry-pick", preRebaseSHA) + require.NoError(t, handlePostCommit(t.Context(), zerolog.Nop())) + + postRebaseSHA := headSHA(t, dir) + require.NotEqual(t, preRebaseSHA, postRebaseSHA, "rebase must produce a new SHA") + + postRec := mustLoadRecord(t, store, postRebaseSHA) + require.Equal(t, []string{sessionID}, postRec.SessionIDs, + "post-rebase CommitRecord must inherit sessionID from the trailer") + + // --- 6. Push 2: the second attestation must again attribute main.go to AI. --- + postChanges := buildSessionCodeChanges(t, store, dir, postRebaseSHA, sessionID) + require.Equal(t, 1, postChanges.AILinesAdded, + "PFM-5878 regression: post-rebase attribution must match pre-rebase, not flip to human") + require.Equal(t, preChanges.LinesAdded, postChanges.LinesAdded, "diff size must be stable across rebase") + require.Equal(t, preChanges.LinesRemoved, postChanges.LinesRemoved) + requireFileAttribution(t, postChanges, "main.go", trace.AttributionAI, []string{sessionID}) + requireFileAttribution(t, postChanges, "hello.go", trace.AttributionHuman, nil) +} + +// buildSessionCodeChanges runs the same diff-and-enrich pipeline runTracePush +// uses for one session: aggregate the session's commits via +// CodeChangesForCommits, then layer on AI line attribution from ai-lines/. +// Returns the resulting CodeChanges so the caller can assert on it. +func buildSessionCodeChanges(t *testing.T, store *state.Store, dir, sha, sessionID string) *aicodingsession.CodeChanges { + t.Helper() + client := tracegit.NewGoGitClient() + cc, err := client.CodeChangesForCommits(dir, []string{sha}) + require.NoError(t, err) + attr := store.LoadAILineAttribution(sessionID) + attribution.Enrich(sessionID, attr.Files, cc) + + return cc +} + +// requireFileAttribution finds path in changes.Files and asserts attribution +// + sessionIDs match. wantSessionIDs may be nil when no session owns the file. +func requireFileAttribution(t *testing.T, changes *aicodingsession.CodeChanges, path, wantAttr string, wantSessionIDs []string) { + t.Helper() + for _, f := range changes.Files { + if f.Path != path { + continue + } + assert.Equal(t, wantAttr, f.Attribution, "attribution for %s", path) + assert.Equal(t, wantSessionIDs, f.SessionIDs, "session IDs for %s", path) + + return + } + require.Failf(t, "file not found in CodeChanges", "%s (got %v)", path, changes.Files) +} diff --git a/app/cli/pkg/action/trace_run.go b/app/cli/pkg/action/trace_run.go new file mode 100644 index 000000000..e9a631268 --- /dev/null +++ b/app/cli/pkg/action/trace_run.go @@ -0,0 +1,309 @@ +package action + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "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/rs/zerolog" +) + +// SubprocessExitError carries the exit code of a wrapped command so the +// CLI can propagate it to the parent shell. +type SubprocessExitError struct { + Command string + ExitCode int +} + +func (e *SubprocessExitError) Error() string { + return fmt.Sprintf("%s exited with status %d", e.Command, e.ExitCode) +} + +// TraceRunOpts configures a single TraceRun invocation. +type TraceRunOpts struct { + // Store owns the chainloop-trace state directory, parented by the + // .git directory inside a repository and otherwise by the out-of-tree + // directory returned by state.NonGitDir. Outside a repository (IsGit + // false) the managed git hooks are disabled, since git could never + // invoke them anyway. + Store *state.Store + // RepoRoot is the repository root containing .chainloop.yml, or the + // working directory when running outside a git repository. + RepoRoot string + // Providers is the list of agent provider names (e.g. "claude-code", + // "cursor") whose info-gathering hooks should be installed. + Providers []string + // Command is the wrapped command: Command[0] is the program, + // Command[1:] are its arguments. + Command []string + // ProjectName, Organization, WorkflowName identify the attestation + // for this run. trace run is isolated from .chainloop.yml, so callers + // must pass these values directly from CLI flags. + ProjectName string + Organization string + WorkflowName string + // ProjectVersion, when set, targets a specific project version. + // Empty means use the latest version. + ProjectVersion string + + // ActionOpts is the root command's initialized options, used to build + // the attestation executor. Required. + ActionOpts *ActionsOpts + // CLIVersion is the bare CLI version recorded in the attestation + // predicate. + CLIVersion string +} + +// TraceRun wraps a single-shot agent invocation: it cleans any prior +// trace state, installs the trace-run subset of git and agent hooks, +// runs the wrapped command, then attests the session and tears +// everything down. Errors from the wrapped command are surfaced as +// SubprocessExitError so the caller can propagate the exit code. +func TraceRun(ctx context.Context, log zerolog.Logger, opts TraceRunOpts) error { + if len(opts.Command) == 0 { + return fmt.Errorf("no command to run") + } + + selectedProviders := providers.ByNames(opts.Providers) + if len(selectedProviders) == 0 { + return fmt.Errorf("no trace providers selected") + } + + var authExecOpts []ExecutorOption + if opts.Organization != "" { + authExecOpts = append(authExecOpts, WithForcedOrganization(opts.Organization)) + } + executor, err := NewAttestationExecutor(opts.ActionOpts, opts.CLIVersion, authExecOpts...) + if err != nil { + return err + } + if err := executor.CheckAuth(ctx); err != nil { + log.Warn().Err(err).Msg("authentication check failed; attestation will fail after the session") + } + if err := executor.Close(); err != nil { + log.Debug().Err(err).Msg("closing auth-check executor") + } + + // Snapshot the agent settings files before we touch anything else + // so we can restore them bit-for-bit on exit, even if the user had + // pre-existing customisations. + backups := snapshotAgentSettings(selectedProviders, opts.RepoRoot, log) + + // Cleanup must precede restoreAgentSettings: it strips every chainloop hook + // entry from the agent settings files, undoing the restore otherwise. Its + // errors are logged inside CleanupTrace and ignored here, because failing + // trace run on partial cleanup is worse than surfacing the warnings. + // Outside a repository the state directory is trace run's alone — + // `chainloop trace init` only ever targets a .git directory — so + // leftovers from a killed run are always ours to reclaim. + ownsState := !opts.Store.IsGit() || traceRunOwnsState(opts.Store) + if ownsState { + _ = CleanupTrace(opts.Store, opts.RepoRoot, log) + } + defer func() { + if ownsState { + _ = CleanupTrace(opts.Store, opts.RepoRoot, log) + } else if err := opts.Store.ClearTraceRunActive(); err != nil { + log.Warn().Err(err).Msg("could not clear trace-run sentinel") + } + restoreAgentSettings(backups, log) + }() + + if err := opts.Store.InitTraceDir(); err != nil { + return fmt.Errorf("create trace directory: %w", err) + } + + // Outside a git repository there is nothing to invoke the git hooks. + if opts.Store.IsGit() { + hooksDir, err := hooks.Install(opts.Store.GitDir(), true) + if err != nil { + return err + } + log.Debug().Str("path", hooksDir).Msg("git hooks installed (commit-msg, post-commit)") + } + + for _, p := range selectedProviders { + if err := p.InstallHooksForTraceRun(opts.RepoRoot); err != nil { + log.Warn().Err(err).Str("provider", p.Name()).Msg("could not install agent hooks") + continue + } + log.Debug().Str("provider", p.Name()).Msg("agent hooks installed") + } + + if err := opts.Store.MarkTraceInitialized(); err != nil { + return fmt.Errorf("mark trace initialized: %w", err) + } + + if err := opts.Store.MarkTraceRunActive(); err != nil { + return fmt.Errorf("mark trace run active: %w", err) + } + + // The wrapped command is supplied by the user on their own machine + // (`chainloop trace run -- `); running it verbatim is the feature and + // crosses no trust boundary. + //nolint:gosec // see above: running the user-supplied command verbatim is the feature + sub := exec.CommandContext(ctx, opts.Command[0], opts.Command[1:]...) // nosemgrep + sub.Stdin = os.Stdin + sub.Stdout = os.Stdout + sub.Stderr = os.Stderr + + log.Debug().Strs("command", opts.Command).Msg("running wrapped command") + + if err := sub.Run(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return &SubprocessExitError{Command: opts.Command[0], ExitCode: exitErr.ExitCode()} + } + + return fmt.Errorf("run %s: %w", opts.Command[0], err) + } + + log.Debug().Msg("wrapped command completed; attesting session") + + return RunTracePush(ctx, log, RunTracePushOpts{ + AllowEmpty: true, + ProjectName: opts.ProjectName, + Organization: opts.Organization, + WorkflowName: opts.WorkflowName, + ProjectVersion: opts.ProjectVersion, + IgnoreYAML: true, + ActionOpts: opts.ActionOpts, + CLIVersion: opts.CLIVersion, + }) +} + +// traceRunOwnsState reports whether the store's trace state is trace run's +// to wipe. A pre-existing install (from trace init or an agent hook's +// auto-install) owns the git hooks and the trace state dir, which holds +// unpushed AI attribution, so trace run must leave it intact. Leftover state +// from a dead trace run still carries the run-active sentinel and is ours. +func traceRunOwnsState(store *state.Store) bool { + return !store.IsTraceInitialized() || store.IsTraceRunActive() +} + +// settingsBackup captures a provider's settings file before trace run +// mutates it, so the file can be restored verbatim on exit. +type settingsBackup struct { + path string + content []byte + mode os.FileMode + existed bool + dirExisted bool +} + +// snapshotAgentSettings reads each selected provider's settings file +// into memory. A missing file is recorded so the restore step can +// remove anything install created. Read failures are warned and the +// provider is dropped from the backup list (best-effort). +func snapshotAgentSettings(provs []trace.Provider, repoRoot string, log zerolog.Logger) []settingsBackup { + backups := make([]settingsBackup, 0, len(provs)) + for _, p := range provs { + b := settingsBackup{path: p.SettingsFile(repoRoot)} + b.dirExisted = dirExists(filepath.Dir(b.path)) + info, err := os.Stat(b.path) + switch { + case err == nil: + content, rerr := os.ReadFile(b.path) + if rerr != nil { + log.Warn().Err(rerr).Str("path", b.path).Msg("could not back up agent settings; install will proceed without restore") + continue + } + b.existed = true + b.content = content + b.mode = info.Mode().Perm() + case errors.Is(err, os.ErrNotExist): + // File didn't exist — restore will delete whatever install creates. + default: + log.Warn().Err(err).Str("path", b.path).Msg("could not stat agent settings; install will proceed without restore") + continue + } + backups = append(backups, b) + } + + return backups +} + +// restoreAgentSettings writes each backed-up settings file back to its +// original path (with original mode), or removes the file if it didn't +// exist before snapshotting. If trace run created the parent directory +// (it did not exist at snapshot time), the directory is also removed +// once empty so trace run leaves no on-disk trace. +func restoreAgentSettings(backups []settingsBackup, log zerolog.Logger) { + for _, b := range backups { + if b.existed { + if err := os.WriteFile(b.path, b.content, b.mode); err != nil { + log.Warn().Err(err).Str("path", b.path).Msg("could not restore agent settings backup") + } + continue + } + if err := os.Remove(b.path); err != nil && !errors.Is(err, os.ErrNotExist) { + log.Warn().Err(err).Str("path", b.path).Msg("could not remove agent settings file created by trace run") + continue + } + if b.dirExisted { + continue + } + // Parent directory didn't exist before the run — install created + // it. Remove it now; os.Remove fails with ENOTEMPTY when other + // content has appeared, which is the no-op we want there. + _ = os.Remove(filepath.Dir(b.path)) + } +} + +// dirExists reports whether path resolves to an existing directory. +// Returns false on any error (missing, permission denied, not-a-dir). +func dirExists(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} + +// CleanupTrace removes the store's chainloop-trace state, the managed git +// hooks, and every known provider's agent hooks. A store outside a repository +// had no git hooks installed in the first place, and the state directory itself +// is trace run's to remove. Every step runs even when an earlier one failed so +// partial cleanup still progresses; the joined error is returned for callers +// that need to fail loudly (e.g. trace uninstall). Callers that want +// best-effort cleanup (e.g. trace run's defer) can ignore the result. +func CleanupTrace(store *state.Store, repoRoot string, log zerolog.Logger) error { + var errs []error + + if store.IsGit() { + if hooksDir, err := hooks.Uninstall(store.GitDir()); err != nil { + log.Warn().Err(err).Msg("could not remove git hooks") + errs = append(errs, fmt.Errorf("remove git hooks: %w", err)) + } else { + log.Debug().Str("path", hooksDir).Msg("git hooks removed") + } + } + + if err := store.RemoveTraceDir(); err != nil { + log.Warn().Err(err).Msg("could not remove trace directory") + errs = append(errs, fmt.Errorf("remove trace directory: %w", err)) + } + + // The out-of-tree state directory is ours alone, so drop it too rather + // than leaving one empty directory per working directory behind. + // os.Remove fails with ENOTEMPTY when anything survived, which is the + // no-op we want there. + if !store.IsGit() { + _ = os.Remove(store.Dir()) + } + + for _, p := range providers.All() { + if err := p.UninstallHooks(repoRoot); err != nil { + log.Warn().Err(err).Str("provider", p.Name()).Msg("could not remove agent hooks") + errs = append(errs, fmt.Errorf("uninstall %s hooks: %w", p.Name(), err)) + continue + } + log.Debug().Str("provider", p.Name()).Msg("agent hooks removed") + } + + return errors.Join(errs...) +} diff --git a/app/cli/pkg/action/trace_run_test.go b/app/cli/pkg/action/trace_run_test.go new file mode 100644 index 000000000..519091989 --- /dev/null +++ b/app/cli/pkg/action/trace_run_test.go @@ -0,0 +1,175 @@ +package action + +import ( + "os" + "path/filepath" + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/claude" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTraceRunOwnsState(t *testing.T) { + t.Run("clean repo", func(t *testing.T) { + assert.True(t, traceRunOwnsState(state.NewGitStore(t.TempDir()))) + }) + + t.Run("live trace init install is not ours", func(t *testing.T) { + store := state.NewGitStore(t.TempDir()) + require.NoError(t, store.MarkTraceInitialized()) + + assert.False(t, traceRunOwnsState(store), "trace run must not wipe a trace init setup") + }) + + t.Run("leftover state from a dead trace run is ours", func(t *testing.T) { + store := state.NewGitStore(t.TempDir()) + require.NoError(t, store.MarkTraceInitialized()) + require.NoError(t, store.MarkTraceRunActive()) + + assert.True(t, traceRunOwnsState(store)) + }) +} + +// TestCleanupTraceNoGit pins the contract `chainloop trace run` relies on +// outside a git repository: an empty gitDir removes the whole out-of-tree state +// directory and never touches a hooks/ directory. +func TestCleanupTraceNoGit(t *testing.T) { + repoRoot := t.TempDir() + stateDir := filepath.Join(t.TempDir(), "state") + store := state.NewOutOfTreeStore(stateDir) + require.NoError(t, store.InitTraceDir()) + + // A hooks/ directory that hooks.Uninstall would have rewritten. + hooksDir := filepath.Join(stateDir, "hooks") + require.NoError(t, os.MkdirAll(hooksDir, 0755)) + sentinel := filepath.Join(hooksDir, "pre-push") + require.NoError(t, os.WriteFile(sentinel, []byte("#!/bin/sh\nexit 0\n"), 0600)) + + require.NoError(t, CleanupTrace(store, repoRoot, zerolog.Nop())) + + _, err := os.Stat(filepath.Join(stateDir, "chainloop-trace")) + assert.True(t, os.IsNotExist(err), "trace state dir should be removed") + + got, err := os.ReadFile(sentinel) + require.NoError(t, err, "hooks/ must be left alone when there is no git dir") + assert.Equal(t, "#!/bin/sh\nexit 0\n", string(got)) +} + +// TestCleanupTraceNoGitRemovesEmptyStateDir covers the tidy-up half: with +// nothing else inside, the per-directory state dir itself goes too. +func TestCleanupTraceNoGitRemovesEmptyStateDir(t *testing.T) { + stateDir := filepath.Join(t.TempDir(), "state") + store := state.NewOutOfTreeStore(stateDir) + require.NoError(t, store.InitTraceDir()) + + require.NoError(t, CleanupTrace(store, t.TempDir(), zerolog.Nop())) + + _, err := os.Stat(stateDir) + assert.True(t, os.IsNotExist(err), "empty state dir should be removed") +} + +// The removal above must never reach a repository: inside one the same +// directory is .git. The Store decides, so an empty .git cannot be mistaken +// for a spent out-of-tree directory. +func TestCleanupTraceKeepsGitDir(t *testing.T) { + gitDir := filepath.Join(t.TempDir(), ".git") + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + require.NoError(t, CleanupTrace(store, t.TempDir(), zerolog.Nop())) + + _, err := os.Stat(gitDir) + require.NoError(t, err, ".git must survive cleanup even with nothing left inside it") +} + +func TestSnapshotAndRestoreAgentSettings(t *testing.T) { + t.Run("existing file is restored verbatim", func(t *testing.T) { + repoRoot := t.TempDir() + p := claude.New() + path := p.SettingsFile(repoRoot) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + original := []byte(`{"theme":"dark","custom":42}`) + require.NoError(t, os.WriteFile(path, original, 0o600)) + + backups := snapshotAgentSettings([]trace.Provider{p}, repoRoot, zerolog.Nop()) + require.Len(t, backups, 1) + + require.NoError(t, os.WriteFile(path, []byte(`{"chainloop":"hooks"}`), 0o600)) + + restoreAgentSettings(backups, zerolog.Nop()) + + got, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, original, got, "restore must produce a bit-for-bit copy of the original file") + }) + + t.Run("missing file stays missing after restore", func(t *testing.T) { + repoRoot := t.TempDir() + p := claude.New() + path := p.SettingsFile(repoRoot) + + backups := snapshotAgentSettings([]trace.Provider{p}, repoRoot, zerolog.Nop()) + require.Len(t, backups, 1) + + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(`{"chainloop":"hooks"}`), 0o600)) + + restoreAgentSettings(backups, zerolog.Nop()) + + _, err := os.Stat(path) + assert.True(t, os.IsNotExist(err), "restore must delete files the run created") + + _, err = os.Stat(filepath.Dir(path)) + assert.True(t, os.IsNotExist(err), "restore must also remove the parent directory if it became empty") + }) + + t.Run("pre-existing empty parent directory is preserved", func(t *testing.T) { + repoRoot := t.TempDir() + p := claude.New() + path := p.SettingsFile(repoRoot) + dir := filepath.Dir(path) + + // User pre-created an empty .claude/ directory. + require.NoError(t, os.MkdirAll(dir, 0o755)) + + backups := snapshotAgentSettings([]trace.Provider{p}, repoRoot, zerolog.Nop()) + require.Len(t, backups, 1) + assert.True(t, backups[0].dirExisted, "snapshot must record that the parent dir existed before") + + // Simulate trace run dropping a settings file into the dir. + require.NoError(t, os.WriteFile(path, []byte(`{"chainloop":"hooks"}`), 0o600)) + + restoreAgentSettings(backups, zerolog.Nop()) + + _, err := os.Stat(path) + assert.True(t, os.IsNotExist(err), "restore must delete the settings file") + info, err := os.Stat(dir) + require.NoError(t, err, "restore must preserve a directory that existed before the run") + assert.True(t, info.IsDir()) + }) + + t.Run("parent directory with sibling content is preserved", func(t *testing.T) { + repoRoot := t.TempDir() + p := claude.New() + path := p.SettingsFile(repoRoot) + sibling := filepath.Join(filepath.Dir(path), "CLAUDE.md") + + backups := snapshotAgentSettings([]trace.Provider{p}, repoRoot, zerolog.Nop()) + require.Len(t, backups, 1) + + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(sibling, []byte(`# notes`), 0o600)) + require.NoError(t, os.WriteFile(path, []byte(`{"chainloop":"hooks"}`), 0o600)) + + restoreAgentSettings(backups, zerolog.Nop()) + + _, err := os.Stat(path) + assert.True(t, os.IsNotExist(err), "restore must delete the settings file") + _, err = os.Stat(sibling) + assert.NoError(t, err, "restore must leave unrelated sibling files alone") + }) +} From 9c213decd9937809a39135a703c093126f14cfef Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Mon, 31 Aug 2026 14:07:38 +0200 Subject: [PATCH 3/7] feat(trace): add the trace command to the CLI Registers `chainloop trace` (init, run, uninstall, and the hidden hook tree) on the root command, completing the port. The hook logger moves here as trace_hooklog.go rather than into pkg/action, where the platform copy lived. Hook commands need to swap the root logger for a colorless, level-filtered one whose output also lands in the trace state's log.txt, and this package already owns that logger var. Exporting a mutable Logger from pkg/action would have added a third competing logger source to a package other projects import. SetLogger/SetLogLevel/DefaultLogger are dropped: --debug now lowers the stderr floor directly via flagDebug. main gains the SubprocessExitError case, first in the switch, so `chainloop trace run -- ` exits with the wrapped command's status instead of always 1. It also reads cmd.Logger() for the final error line, so that line matches the hook's colorless formatting when a hook fails mid-commit or mid-push. Signed-off-by: Jose I. Paris --- app/cli/cmd/root.go | 8 + app/cli/cmd/trace.go | 22 +++ app/cli/cmd/trace_hook.go | 301 ++++++++++++++++++++++++++++++ app/cli/cmd/trace_hooklog.go | 116 ++++++++++++ app/cli/cmd/trace_hooklog_test.go | 89 +++++++++ app/cli/cmd/trace_init.go | 201 ++++++++++++++++++++ app/cli/cmd/trace_run.go | 117 ++++++++++++ app/cli/cmd/trace_uninstall.go | 45 +++++ app/cli/main.go | 15 +- 9 files changed, 912 insertions(+), 2 deletions(-) create mode 100644 app/cli/cmd/trace.go create mode 100644 app/cli/cmd/trace_hook.go create mode 100644 app/cli/cmd/trace_hooklog.go create mode 100644 app/cli/cmd/trace_hooklog_test.go create mode 100644 app/cli/cmd/trace_init.go create mode 100644 app/cli/cmd/trace_run.go create mode 100644 app/cli/cmd/trace_uninstall.go diff --git a/app/cli/cmd/root.go b/app/cli/cmd/root.go index ace1da2a8..1e742176c 100644 --- a/app/cli/cmd/root.go +++ b/app/cli/cmd/root.go @@ -290,6 +290,7 @@ func NewRootCmd(l zerolog.Logger) *cobra.Command { newAttestationCmd(), newArtifactCmd(), newConfigCmd(), newIntegrationCmd(), newOrganizationCmd(), newCASBackendCmd(), newReferrerDiscoverCmd(), newPolicyCmd(), newApplyCmd(), + newTraceCmd(), ) // Load plugins for root command and subcommands (except completion and help) @@ -329,6 +330,13 @@ func isTelemetryDisabled() bool { return os.Getenv(doNotTrackEnv) == "1" || os.Getenv(doNotTrackEnv) == trueString || Version == devVersion } +// Logger returns the root logger. Hook commands swap it in place (see +// InitHookLogger), and main reads it for its final error line so that line +// shares the hook's colorless formatting during a git commit or push. +func Logger() zerolog.Logger { + return logger +} + func initLogger(logger zerolog.Logger) (zerolog.Logger, error) { lvl := zerolog.InfoLevel if flagDebug { diff --git a/app/cli/cmd/trace.go b/app/cli/cmd/trace.go new file mode 100644 index 000000000..b0c55b3e3 --- /dev/null +++ b/app/cli/cmd/trace.go @@ -0,0 +1,22 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +// newTraceCmd creates the trace parent command with all subcommands. +func newTraceCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "trace", + Short: "AI coding session tracing and attestation", + } + + cmd.AddCommand( + newTraceInitCmd(), + newTraceRunCmd(), + newTraceUninstallCmd(), + newTraceHookCmd(), + ) + + return cmd +} diff --git a/app/cli/cmd/trace_hook.go b/app/cli/cmd/trace_hook.go new file mode 100644 index 000000000..0f1e868c0 --- /dev/null +++ b/app/cli/cmd/trace_hook.go @@ -0,0 +1,301 @@ +package cmd + +import ( + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/claude" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/config" + "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/spf13/cobra" +) + +func newTraceHookCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "hook", + Short: "Internal: handle hook invocations", + Hidden: true, + } + + cmd.AddCommand( + newTraceHookGitCmd(), + newTraceHookClaudeCmd(), + newTraceHookCursorCmd(), + newTraceHookOpenCodeCmd(), + ) + + return cmd +} + +func newTraceHookGitCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "git", + Short: "Handle git hook invocations", + } + + cmd.AddCommand( + newTraceHookGitCommitMsgCmd(), + newTraceHookGitPostCommitCmd(), + newTraceHookGitPrePushCmd(), + ) + + return cmd +} + +func newTraceHookGitCommitMsgCmd() *cobra.Command { + return &cobra.Command{ + Use: "commit-msg [msg-file]", + Short: "Handle the commit-msg git hook", + Args: cobra.ExactArgs(1), + Annotations: map[string]string{ + "skipActionOptsInit": "true", + }, + RunE: func(cmd *cobra.Command, args []string) error { + cleanup := InitHookLogger() + defer cleanup() + + return action.HandleCommitMsgHook(cmd.Context(), args[0], logger) + }, + } +} + +func newTraceHookGitPostCommitCmd() *cobra.Command { + return &cobra.Command{ + Use: "post-commit", + Short: "Handle the post-commit git hook", + RunE: func(cmd *cobra.Command, _ []string) error { + cleanup := InitHookLogger() + defer cleanup() + return action.HandlePostCommitHook(cmd.Context(), logger) + }, + } +} + +func newTraceHookGitPrePushCmd() *cobra.Command { + return &cobra.Command{ + Use: "pre-push", + Short: "Handle the pre-push git hook", + RunE: func(cmd *cobra.Command, _ []string) error { + cleanup := InitHookLogger() + defer cleanup() + + requireTrace := config.LoadRequireTraceFromYML(".") + + return action.HandlePrePushHook(cmd.Context(), requireTrace, logger, action.RunTracePushOpts{ + ActionOpts: ActionOpts, + CLIVersion: Version, + }) + }, + } +} + +func newTraceHookClaudeCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "claude", + Short: "Handle Claude Code hook invocations", + } + + cmd.AddCommand( + newTraceHookClaudeSessionStartCmd(), + newTraceHookClaudeSessionEndCmd(), + newTraceHookClaudePreToolUseCmd(), + newTraceHookClaudePostToolUseCmd(), + ) + + return cmd +} + +func newTraceHookClaudeSessionStartCmd() *cobra.Command { + return &cobra.Command{ + Use: "session-start", + Short: "Capture session ID on Claude Code session start", + Annotations: map[string]string{ + "skipActionOptsInit": "true", + }, + RunE: func(_ *cobra.Command, _ []string) error { + cleanup := InitHookLogger() + defer cleanup() + return action.HandleAgentSessionStart(claude.New(), logger) + }, + } +} + +func newTraceHookClaudeSessionEndCmd() *cobra.Command { + return &cobra.Command{ + Use: "session-end", + Short: "Clean up trace state on Claude Code session end", + Annotations: map[string]string{ + "skipActionOptsInit": "true", + }, + RunE: func(_ *cobra.Command, _ []string) error { + cleanup := InitHookLogger() + defer cleanup() + return action.HandleAgentSessionEnd(claude.New(), logger) + }, + } +} + +func newTraceHookClaudePreToolUseCmd() *cobra.Command { + return &cobra.Command{ + Use: "pre-tool-use", + Short: "Snapshot file before Claude Code edit", + Annotations: map[string]string{ + "skipActionOptsInit": "true", + }, + RunE: func(_ *cobra.Command, _ []string) error { + cleanup := InitHookLogger() + defer cleanup() + return action.HandleAgentPreToolUse(claude.New(), logger) + }, + } +} + +func newTraceHookClaudePostToolUseCmd() *cobra.Command { + return &cobra.Command{ + Use: "post-tool-use", + Short: "Record AI line ranges after Claude Code edit", + Annotations: map[string]string{ + "skipActionOptsInit": "true", + }, + RunE: func(_ *cobra.Command, _ []string) error { + cleanup := InitHookLogger() + defer cleanup() + return action.HandleAgentPostToolUse(claude.New(), logger) + }, + } +} + +func newTraceHookCursorCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "cursor", + Short: "Handle Cursor hook invocations", + } + + cmd.AddCommand( + newTraceHookCursorSessionStartCmd(), + newTraceHookCursorSessionEndCmd(), + newTraceHookCursorAfterFileEditCmd(), + ) + + return cmd +} + +func newTraceHookCursorSessionStartCmd() *cobra.Command { + return &cobra.Command{ + Use: "session-start", + Short: "Capture session ID on Cursor session start", + Annotations: map[string]string{ + "skipActionOptsInit": "true", + }, + RunE: func(_ *cobra.Command, _ []string) error { + cleanup := InitHookLogger() + defer cleanup() + return action.HandleAgentSessionStart(cursor.New(), logger) + }, + } +} + +func newTraceHookCursorSessionEndCmd() *cobra.Command { + return &cobra.Command{ + Use: "session-end", + Short: "Clean up trace state on Cursor session end", + Annotations: map[string]string{ + "skipActionOptsInit": "true", + }, + RunE: func(_ *cobra.Command, _ []string) error { + cleanup := InitHookLogger() + defer cleanup() + return action.HandleAgentSessionEnd(cursor.New(), logger) + }, + } +} + +func newTraceHookCursorAfterFileEditCmd() *cobra.Command { + return &cobra.Command{ + Use: "after-file-edit", + Short: "Record AI line ranges after Cursor file edit", + Annotations: map[string]string{ + "skipActionOptsInit": "true", + }, + RunE: func(_ *cobra.Command, _ []string) error { + cleanup := InitHookLogger() + defer cleanup() + return action.HandleAgentPostToolUse(cursor.New(), logger) + }, + } +} + +func newTraceHookOpenCodeCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "opencode", + Short: "Handle opencode hook invocations", + } + + cmd.AddCommand( + newTraceHookOpenCodeSessionStartCmd(), + newTraceHookOpenCodeSessionEndCmd(), + newTraceHookOpenCodePreToolUseCmd(), + newTraceHookOpenCodePostToolUseCmd(), + ) + + return cmd +} + +func newTraceHookOpenCodeSessionStartCmd() *cobra.Command { + return &cobra.Command{ + Use: "session-start", + Short: "Capture session ID on opencode session start", + Annotations: map[string]string{ + "skipActionOptsInit": "true", + }, + RunE: func(_ *cobra.Command, _ []string) error { + cleanup := InitHookLogger() + defer cleanup() + return action.HandleAgentSessionStart(opencode.New(), logger) + }, + } +} + +func newTraceHookOpenCodeSessionEndCmd() *cobra.Command { + return &cobra.Command{ + Use: "session-end", + Short: "Clean up trace state on opencode session end", + Annotations: map[string]string{ + "skipActionOptsInit": "true", + }, + RunE: func(_ *cobra.Command, _ []string) error { + cleanup := InitHookLogger() + defer cleanup() + return action.HandleAgentSessionEnd(opencode.New(), logger) + }, + } +} + +func newTraceHookOpenCodePreToolUseCmd() *cobra.Command { + return &cobra.Command{ + Use: "pre-tool-use", + Short: "Snapshot file before opencode edit", + Annotations: map[string]string{ + "skipActionOptsInit": "true", + }, + RunE: func(_ *cobra.Command, _ []string) error { + cleanup := InitHookLogger() + defer cleanup() + return action.HandleAgentPreToolUse(opencode.New(), logger) + }, + } +} + +func newTraceHookOpenCodePostToolUseCmd() *cobra.Command { + return &cobra.Command{ + Use: "post-tool-use", + Short: "Record AI line ranges after opencode edit", + Annotations: map[string]string{ + "skipActionOptsInit": "true", + }, + RunE: func(_ *cobra.Command, _ []string) error { + cleanup := InitHookLogger() + defer cleanup() + return action.HandleAgentPostToolUse(opencode.New(), logger) + }, + } +} diff --git a/app/cli/cmd/trace_hooklog.go b/app/cli/cmd/trace_hooklog.go new file mode 100644 index 000000000..ed89d0970 --- /dev/null +++ b/app/cli/cmd/trace_hooklog.go @@ -0,0 +1,116 @@ +package cmd + +import ( + "io" + "os" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/rs/zerolog" +) + +// hookLogFile holds the trace log file while hook commands run, so it can be +// closed by the cleanup closure returned from InitHookLogger. +var hookLogFile *os.File + +// stderrMinLevel is the minimum zerolog.Level that hook commands write to +// stderr. All levels still reach the on-disk trace log; stderr is filtered +// to keep git/agent hooks quiet during normal operations. +var stderrMinLevel = zerolog.WarnLevel + +// levelFilterWriter only forwards log entries at or above minLevel to w. +// Used by hook commands to keep stderr quiet while letting the trace log +// file receive every level. +type levelFilterWriter struct { + w io.Writer + minLevel zerolog.Level +} + +func (lw *levelFilterWriter) Write(p []byte) (n int, err error) { + return len(p), nil +} + +func (lw *levelFilterWriter) WriteLevel(level zerolog.Level, p []byte) (n int, err error) { + if level < lw.minLevel { + return len(p), nil + } + + return lw.w.Write(p) +} + +// hookStderrWriter builds the colorless stderr writer used by hook commands. +// The level filter keeps stderr quiet during normal operations while still +// letting the trace log file capture every level. +func hookStderrWriter() *levelFilterWriter { + return &levelFilterWriter{ + w: zerolog.ConsoleWriter{ + Out: os.Stderr, + FormatTimestamp: func(any) string { return "" }, + NoColor: true, + }, + minLevel: stderrMinLevel, + } +} + +// InitHookLogger reconfigures the root logger for git/agent hook commands: +// colorless output, all levels written to the trace state's log.txt, and +// Warn+ (or Debug, with --debug) written to stderr. Colorless matters because +// the hook's output is interleaved with git's during a commit or push, and +// main reads the same logger for its final error line. +// +// If trace state cannot be located or the file cannot be opened, the logger +// falls back to colorless stderr-only output so the rest of the hook still +// runs with consistent formatting. Returns a cleanup function that closes the +// log file — callers should defer it at the top of a hook's RunE. +func InitHookLogger() func() { + closeHookLogFile() + + // --debug lowers the stderr floor; the log file always gets every level. + if flagDebug { + stderrMinLevel = zerolog.DebugLevel + } + + logger = zerolog.New(hookStderrWriter()).Level(stderrMinLevel) + + store, _, err := state.Locate() + if err != nil { + return func() {} + } + + if err := initHookLogFile(store); err != nil { + logger.Debug().Err(err).Msg("could not open hook log file") + return func() {} + } + + return closeHookLogFile +} + +// initHookLogFile opens the trace log file and reassigns the root logger to a +// MultiLevelWriter that writes all levels to the file and delegates stderr +// output to a level-filtered colorless console writer. +func initHookLogFile(store *state.Store) error { + logPath := store.LogFilePath() + + f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return err + } + hookLogFile = f + + fileWriter := zerolog.ConsoleWriter{ + Out: f, + NoColor: true, + } + + logger = zerolog.New(zerolog.MultiLevelWriter(fileWriter, hookStderrWriter())). + Level(zerolog.DebugLevel). + With().Timestamp().Logger() + + return nil +} + +func closeHookLogFile() { + if hookLogFile != nil { + _ = hookLogFile.Close() + hookLogFile = nil + } +} diff --git a/app/cli/cmd/trace_hooklog_test.go b/app/cli/cmd/trace_hooklog_test.go new file mode 100644 index 000000000..a88f48051 --- /dev/null +++ b/app/cli/cmd/trace_hooklog_test.go @@ -0,0 +1,89 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// resetHookLoggerState restores the package-level logger globals to their +// defaults so tests don't leak state to each other. +func resetHookLoggerState(t *testing.T) { + t.Helper() + t.Cleanup(func() { + closeHookLogFile() + stderrMinLevel = zerolog.WarnLevel + }) +} + +func TestInitHookLogFile(t *testing.T) { + store := state.NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + resetHookLoggerState(t) + + require.NoError(t, initHookLogFile(store)) + defer closeHookLogFile() + + // Write a debug message — should appear in the log file + logger.Debug().Msg("test debug message") + + // Close to flush + closeHookLogFile() + + logPath := store.LogFilePath() + content, err := os.ReadFile(logPath) + require.NoError(t, err) + assert.Contains(t, string(content), "test debug message") +} + +func TestInitHookLogFileCreatesFile(t *testing.T) { + store := state.NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + resetHookLoggerState(t) + + logPath := store.LogFilePath() + _, err := os.Stat(logPath) + assert.True(t, os.IsNotExist(err)) + + require.NoError(t, initHookLogFile(store)) + closeHookLogFile() + + _, err = os.Stat(logPath) + assert.NoError(t, err) +} + +func TestInitHookLogFileAppendsToExisting(t *testing.T) { + store := state.NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + resetHookLoggerState(t) + + logPath := store.LogFilePath() + require.NoError(t, os.WriteFile(logPath, []byte("existing content\n"), 0600)) + + require.NoError(t, initHookLogFile(store)) + logger.Debug().Msg("appended message") + closeHookLogFile() + + content, err := os.ReadFile(logPath) + require.NoError(t, err) + assert.Contains(t, string(content), "existing content") + assert.Contains(t, string(content), "appended message") +} + +func TestInitHookLogFileFailsOnBadPath(t *testing.T) { + err := initHookLogFile(state.NewGitStore(filepath.Join(t.TempDir(), "nonexistent", "deep"))) + assert.Error(t, err) +} + +func TestCloseHookLogFileIdempotent(_ *testing.T) { + // Calling close without init should not panic + closeHookLogFile() + closeHookLogFile() +} diff --git a/app/cli/cmd/trace_init.go b/app/cli/cmd/trace_init.go new file mode 100644 index 000000000..5f88584d5 --- /dev/null +++ b/app/cli/cmd/trace_init.go @@ -0,0 +1,201 @@ +package cmd + +import ( + "fmt" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/claude" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/config" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/cursor" + tracegit "github.com/chainloop-dev/chainloop/app/cli/internal/trace/git" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/hooks" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/opencode" + "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/app/cli/pkg/action" + "github.com/spf13/cobra" +) + +// NewTraceInitCmd creates the trace init subcommand. +func newTraceInitCmd() *cobra.Command { + var ( + project string + claudeFlag bool + cursorFlag bool + opencodeFlag bool + ) + + cmd := &cobra.Command{ + Use: "init", + Short: "Initialize git hooks for automatic AI session tracing", + Long: `Initialize git hooks that automatically trace AI coding sessions and +create Chainloop attestations when you push. + +This installs the managed git hooks plus agent-specific hooks for the +selected providers. Pass --claude, --cursor, and/or --opencode to pick +providers; when none is set, Claude Code is used as the default. + +Pass --org to pin the Chainloop organization trace attestations target; the +value is saved to .chainloop.yml and overrides the CLI default on every push. + +Pass --workflow to override the workflow name used when initializing trace +attestations. The value is saved to .chainloop.yml and defaults to +"ai-coding-session" when unset.`, + RunE: func(cmd *cobra.Command, _ []string) error { + gitDir, repoRoot, err := tracegit.FindGitDirAndRoot() + if err != nil { + return err + } + + // If --project was given, persist it to .chainloop.yml + if project != "" { + if err := config.SaveProjectToYML(repoRoot, project); err != nil { + return err + } + logger.Info().Str("project", project).Msg("project name saved to .chainloop.yml") + } else { + project = config.LoadProjectFromYML(repoRoot) + } + if project == "" { + return fmt.Errorf("--project is required (or add projectName to .chainloop.yml)") + } + + // When the user explicitly passes the inherited persistent --org flag, + // persist it to .chainloop.yml so trace hooks target it on every push. + if cmd.Flags().Changed("org") { + orgFlag, err := cmd.Flags().GetString("org") + if err != nil { + return fmt.Errorf("reading --org flag: %w", err) + } + if err := config.SaveOrganizationToYML(repoRoot, orgFlag); err != nil { + return err + } + logger.Info().Str("organization", orgFlag).Msg("organization saved to .chainloop.yml") + } + + if cmd.Flags().Changed("workflow") { + workflowFlag, err := cmd.Flags().GetString("workflow") + if err != nil { + return fmt.Errorf("reading --workflow flag: %w", err) + } + if err := config.SaveWorkflowToYML(repoRoot, workflowFlag); err != nil { + return err + } + logger.Info().Str("workflow", workflowFlag).Msg("workflow saved to .chainloop.yml") + } + workflowName := config.ResolveWorkflowName(config.LoadWorkflowFromYML(repoRoot)) + + // Persist --require-trace when explicitly set, otherwise + // load the persisted value from .chainloop.yml. + requireTrace := config.LoadRequireTraceFromYML(repoRoot) + if cmd.Flags().Changed("require-trace") { + val, err := cmd.Flags().GetBool("require-trace") + if err != nil { + return fmt.Errorf("reading --require-trace flag: %w", err) + } + requireTrace = val + if err := config.SaveRequireTraceToYML(repoRoot, requireTrace); err != nil { + return err + } + } + + // Verify authentication. If the repo pins an organization, use it so + // the auth check reflects the org the hooks will actually target. + var authExecOpts []action.ExecutorOption + if forcedOrg := config.LoadOrganizationFromYML(repoRoot); forcedOrg != "" { + authExecOpts = append(authExecOpts, action.WithForcedOrganization(forcedOrg)) + } + executor, err := action.NewAttestationExecutor(ActionOpts, Version, authExecOpts...) + if err != nil { + return err + } + defer func() { _ = executor.Close() }() + + if err := executor.CheckAuth(cmd.Context()); err != nil { + logger.Warn().Err(err).Msg("authentication check failed") + if requireTrace { + logger.Warn().Msg("require-trace is enabled: pushes with AI-assisted commits will fail until authenticated") + } else { + logger.Warn().Msg("hooks will be installed, but attestation commands will fail until authenticated") + } + } + + // Create trace directory structure + store := state.NewGitStore(gitDir) + if err := store.InitTraceDir(); err != nil { + return fmt.Errorf("create trace directory: %w", err) + } + + // Install git hooks + hooksDir, err := hooks.Install(gitDir, false) + if err != nil { + return err + } + logger.Info(). + Str("path", hooksDir). + Msg("git hooks installed (post-commit, pre-push)") + + // Mark trace as initialized + if err := store.MarkTraceInitialized(); err != nil { + return fmt.Errorf("mark trace initialized: %w", err) + } + + // Resolve which providers to install. Pre-push will infer the + // owning provider per-session from the recorded SessionRecord, + // so the list isn't persisted anywhere — only the agent-side + // hook config files (.claude/settings.json, .cursor/hooks.json) + // determine which providers can register sessions. + selected := selectedTraceProviders(claudeFlag, cursorFlag, opencodeFlag) + selectedProviders := providers.ByNames(selected) + if len(selectedProviders) == 0 { + return fmt.Errorf("no trace providers selected") + } + + for _, p := range selectedProviders { + if err := p.InstallHooks(repoRoot); err != nil { + logger.Warn().Err(err).Str("provider", p.Name()).Msg("could not install agent hooks") + continue + } + logger.Info().Str("provider", p.Name()).Msg("agent hooks installed") + } + + logger.Info(). + Str("project", project). + Str("workflow", workflowName). + Strs("providers", selected). + Msg("trace initialized") + + return nil + }, + } + + cmd.Flags().StringVar(&project, "project", "", "chainloop project name") + cmd.Flags().String("workflow", "", "chainloop workflow name used for trace attestations (defaults to \"ai-coding-session\")") + cmd.Flags().Bool("require-trace", false, "block pushes when attestation fails for AI-assisted commits") + cmd.Flags().BoolVar(&claudeFlag, "claude", false, "install Claude Code hooks (default when no provider flag is set)") + cmd.Flags().BoolVar(&cursorFlag, "cursor", false, "install Cursor hooks") + cmd.Flags().BoolVar(&opencodeFlag, "opencode", false, "install opencode hooks") + + return cmd +} + +// selectedTraceProviders resolves the provider names to install based on the +// --claude, --cursor, and --opencode flags. When none is set, Claude Code is +// used as the default so existing users get the same behavior. +func selectedTraceProviders(claudeFlag, cursorFlag, opencodeFlag bool) []string { + if !claudeFlag && !cursorFlag && !opencodeFlag { + return []string{providers.DefaultProvider} + } + + var out []string + if claudeFlag { + out = append(out, claude.Name) + } + if cursorFlag { + out = append(out, cursor.Name) + } + if opencodeFlag { + out = append(out, opencode.Name) + } + + return out +} diff --git a/app/cli/cmd/trace_run.go b/app/cli/cmd/trace_run.go new file mode 100644 index 000000000..59f2e8f2e --- /dev/null +++ b/app/cli/cmd/trace_run.go @@ -0,0 +1,117 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + + tracegit "github.com/chainloop-dev/chainloop/app/cli/internal/trace/git" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/chainloop-dev/chainloop/app/cli/pkg/action" + "github.com/spf13/cobra" +) + +const traceRunLong = `Wrap an external command (typically an AI coding agent) and emit a +Chainloop attestation for the session once the command exits successfully. +Sessions without git commits are still attested. + +trace run is isolated: it ignores .chainloop.yml entirely. The attestation +identity must come from the --org, --project, --workflow flags (mandatory) +and the optional --version flag. It does not read from or write to +.chainloop.yml, and removes every hook and local trace artifact when the +wrapped command exits, so a session never leaks setup into the next one.` + +// NewTraceRunCmd creates the `trace run` subcommand. +func newTraceRunCmd() *cobra.Command { + var ( + projectFlag string + workflowFlag string + versionFlag string + claudeFlag bool + cursorFlag bool + opencodeFlag bool + ) + + cmd := &cobra.Command{ + Use: "run -- [args...]", + Short: "Run a single-shot AI coding session and attest it", + Long: traceRunLong, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // Outside a git repository trace state lives in a deterministic + // out-of-tree directory keyed by the working directory. The store + // carries which of the two it is, and an out-of-tree one disables + // the git hooks, which git could never invoke anyway. + gitDir, repoRoot, err := tracegit.FindGitDirAndRoot() + + var store *state.Store + switch { + case err != nil && !errors.Is(err, tracegit.ErrNotARepository): + // A repository is there but unusable — don't paper over it + // by silently dropping commit attribution. + return err + case err != nil: + cwd, cwdErr := os.Getwd() + if cwdErr != nil { + return fmt.Errorf("get working directory: %w", cwdErr) + } + + stateDir, dirErr := state.NonGitDir(cwd) + if dirErr != nil { + return dirErr + } + + store, repoRoot = state.NewOutOfTreeStore(stateDir), cwd + logger.Info().Msg("no git repository found; recording the session without commit attribution") + default: + store = state.NewGitStore(gitDir) + } + + // --org is a persistent flag declared on the root command, + // so we cannot MarkFlagRequired it on this subcommand. + organization, err := cmd.Flags().GetString("org") + if err != nil { + return fmt.Errorf("reading --org flag: %w", err) + } + + // MarkFlagRequired and Changed() only check that a flag was + // passed, so an empty value like --workflow "" would slip + // through and silently fall back to the default workflow. + // Validate the actual values here. + if organization == "" { + return fmt.Errorf("--org is required for trace run") + } + if projectFlag == "" { + return fmt.Errorf("--project is required for trace run") + } + if workflowFlag == "" { + return fmt.Errorf("--workflow is required for trace run") + } + + return action.TraceRun(cmd.Context(), logger, action.TraceRunOpts{ + Store: store, + RepoRoot: repoRoot, + Providers: selectedTraceProviders(claudeFlag, cursorFlag, opencodeFlag), + Command: args, + ProjectName: projectFlag, + Organization: organization, + WorkflowName: workflowFlag, + ProjectVersion: versionFlag, + ActionOpts: ActionOpts, + CLIVersion: Version, + }) + }, + } + + cmd.Flags().StringVar(&projectFlag, "project", "", "chainloop project name (required; .chainloop.yml is ignored)") + cmd.Flags().StringVar(&workflowFlag, "workflow", "", "chainloop workflow name used for trace attestations (required; .chainloop.yml is ignored)") + cmd.Flags().StringVar(&versionFlag, "version", "", "chainloop project version (optional; defaults to the latest version)") + cmd.Flags().BoolVar(&claudeFlag, "claude", false, "install Claude Code hooks (default when no provider flag is set)") + cmd.Flags().BoolVar(&cursorFlag, "cursor", false, "install Cursor hooks") + cmd.Flags().BoolVar(&opencodeFlag, "opencode", false, "install opencode hooks") + + _ = cmd.MarkFlagRequired("project") + _ = cmd.MarkFlagRequired("workflow") + + return cmd +} diff --git a/app/cli/cmd/trace_uninstall.go b/app/cli/cmd/trace_uninstall.go new file mode 100644 index 000000000..b20258a7e --- /dev/null +++ b/app/cli/cmd/trace_uninstall.go @@ -0,0 +1,45 @@ +package cmd + +import ( + tracegit "github.com/chainloop-dev/chainloop/app/cli/internal/trace/git" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/chainloop-dev/chainloop/app/cli/pkg/action" + "github.com/spf13/cobra" +) + +// NewTraceUninstallCmd creates the trace uninstall subcommand. +func newTraceUninstallCmd() *cobra.Command { + var yes bool + + cmd := &cobra.Command{ + Use: "uninstall", + Short: "Remove trace git hooks and configuration", + Annotations: map[string]string{ + "skipActionOptsInit": "true", + }, + RunE: func(_ *cobra.Command, _ []string) error { + gitDir, err := tracegit.FindGitDir() + if err != nil { + return err + } + + if !yes { + if !confirmationPrompt("This will remove trace git hooks and configuration. Are you sure?") { + return nil + } + } + + repoRoot, _ := tracegit.RepoRoot() + if err := action.CleanupTrace(state.NewGitStore(gitDir), repoRoot, logger); err != nil { + return err + } + logger.Info().Msg("trace uninstalled") + + return nil + }, + } + + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "skip confirmation prompt") + + return cmd +} diff --git a/app/cli/main.go b/app/cli/main.go index ba566a15f..2cff30ea1 100644 --- a/app/cli/main.go +++ b/app/cli/main.go @@ -20,6 +20,7 @@ import ( "os" "github.com/chainloop-dev/chainloop/app/cli/cmd" + "github.com/chainloop-dev/chainloop/app/cli/pkg/action" v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" "github.com/go-kratos/kratos/v2/errors" jwtMiddleware "github.com/go-kratos/kratos/v2/middleware/auth/jwt" @@ -45,8 +46,12 @@ func main() { }) rootCmd := cmd.NewRootCmd(logger) if err := cmd.Execute(rootCmd); err != nil { - msg, exitCode := errorInfo(err, logger) - logger.Error().Msg(msg) + // cmd.Logger() rather than the local logger: a hook command may have + // swapped the root logger for a colorless one, and this final line + // should match. + hookLogger := cmd.Logger() + msg, exitCode := errorInfo(err, hookLogger) + hookLogger.Error().Msg(msg) os.Exit(exitCode) } } @@ -89,9 +94,15 @@ func errorInfo(err error, logger zerolog.Logger) (string, int) { } var gateErr *cmd.GateError + var subprocessErr *action.SubprocessExitError // Make overrides switch { + case errors.As(err, &subprocessErr): + // `chainloop trace run` — surface the wrapped command's exit code so + // the parent shell sees the same status it would have seen running the + // command directly. + return subprocessErr.Error(), subprocessErr.ExitCode case v1.IsCasBackendErrorReasonRequired(err): msg = "you need to enable a CAS backend first. Refer to `chainloop cas-backend` command or contact your administrator." case v1.IsCasBackendErrorReasonInvalid(err): From 98c59067afb98b2e6aad61c94e8d7700966f8b7d Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Mon, 31 Aug 2026 14:11:51 +0200 Subject: [PATCH 4/7] chore(trace): add license headers, tidy deps and regenerate the CLI reference - Apache-2.0 headers on all 84 ported files, as goheader requires. Build tags stay above the header. - go mod tidy promotes go-git/go-billy/v6 and sergi/go-diff from indirect to direct at the versions already in the graph. No new modules and no go.sum change. - cli-reference.mdx gains trace, trace init, trace run and trace uninstall. The hook subcommands are Hidden and stay out, as intended. `golangci-lint run ./app/cli/...` reports no findings in any ported file. Signed-off-by: Jose I. Paris --- app/cli/cmd/trace.go | 15 ++ app/cli/cmd/trace_hook.go | 15 ++ app/cli/cmd/trace_hooklog.go | 15 ++ app/cli/cmd/trace_hooklog_test.go | 15 ++ app/cli/cmd/trace_init.go | 15 ++ app/cli/cmd/trace_run.go | 15 ++ app/cli/cmd/trace_uninstall.go | 15 ++ app/cli/documentation/cli-reference.mdx | 196 ++++++++++++++++++ app/cli/internal/trace/attribution/diff.go | 15 ++ .../internal/trace/attribution/diff_test.go | 15 ++ app/cli/internal/trace/attribution/edits.go | 15 ++ .../internal/trace/attribution/edits_test.go | 15 ++ app/cli/internal/trace/attribution/enrich.go | 17 +- .../internal/trace/attribution/enrich_test.go | 17 +- .../internal/trace/attribution/generated.go | 15 ++ .../trace/attribution/generated_test.go | 17 +- app/cli/internal/trace/attribution/shell.go | 15 ++ .../internal/trace/attribution/shell_test.go | 15 ++ app/cli/internal/trace/claude/hooks.go | 15 ++ app/cli/internal/trace/claude/hooks_test.go | 15 ++ app/cli/internal/trace/claude/parse.go | 17 +- app/cli/internal/trace/claude/parse_test.go | 17 +- app/cli/internal/trace/claude/pid_unix.go | 15 ++ app/cli/internal/trace/claude/pid_windows.go | 15 ++ app/cli/internal/trace/claude/pricing.go | 15 ++ app/cli/internal/trace/claude/pricing_test.go | 15 ++ app/cli/internal/trace/claude/provider.go | 17 +- app/cli/internal/trace/claude/session.go | 15 ++ app/cli/internal/trace/claude/session_test.go | 15 ++ app/cli/internal/trace/claude/types.go | 15 ++ app/cli/internal/trace/config/config.go | 15 ++ app/cli/internal/trace/config/config_test.go | 15 ++ app/cli/internal/trace/cursor/hooks.go | 15 ++ app/cli/internal/trace/cursor/hooks_test.go | 15 ++ app/cli/internal/trace/cursor/parse.go | 15 ++ app/cli/internal/trace/cursor/parse_test.go | 15 ++ app/cli/internal/trace/cursor/provider.go | 17 +- app/cli/internal/trace/cursor/session.go | 15 ++ app/cli/internal/trace/cursor/session_test.go | 15 ++ app/cli/internal/trace/cursor/types.go | 15 ++ app/cli/internal/trace/git/aggregate.go | 15 ++ app/cli/internal/trace/git/exec.go | 15 ++ app/cli/internal/trace/git/exec_test.go | 15 ++ app/cli/internal/trace/git/git.go | 15 ++ app/cli/internal/trace/git/gogit.go | 15 ++ app/cli/internal/trace/git/gogit_test.go | 15 ++ .../internal/trace/git/merge_branch_test.go | 15 ++ app/cli/internal/trace/git/repo.go | 15 ++ app/cli/internal/trace/git/repo_test.go | 15 ++ app/cli/internal/trace/hooks/hooks.go | 15 ++ app/cli/internal/trace/hooks/hooks_test.go | 15 ++ .../trace/opencode/export_integration_test.go | 15 ++ .../internal/trace/opencode/golden_test.go | 15 ++ app/cli/internal/trace/opencode/hooks.go | 15 ++ app/cli/internal/trace/opencode/hooks_test.go | 15 ++ app/cli/internal/trace/opencode/parse.go | 17 +- app/cli/internal/trace/opencode/parse_test.go | 17 +- app/cli/internal/trace/opencode/provider.go | 17 +- app/cli/internal/trace/opencode/session.go | 15 ++ .../internal/trace/opencode/session_test.go | 15 ++ app/cli/internal/trace/opencode/types.go | 15 ++ app/cli/internal/trace/provider.go | 17 +- app/cli/internal/trace/providers/registry.go | 15 ++ app/cli/internal/trace/state/ailines.go | 15 ++ app/cli/internal/trace/state/commit.go | 15 ++ app/cli/internal/trace/state/jsonlcopy.go | 15 ++ app/cli/internal/trace/state/locate.go | 15 ++ app/cli/internal/trace/state/locate_test.go | 15 ++ app/cli/internal/trace/state/session.go | 15 ++ app/cli/internal/trace/state/snapshot.go | 15 ++ app/cli/internal/trace/state/snapshot_test.go | 15 ++ app/cli/internal/trace/state/state.go | 15 ++ app/cli/internal/trace/state/state_test.go | 15 ++ app/cli/internal/trace/state/store.go | 15 ++ app/cli/internal/trace/state/trailer.go | 15 ++ app/cli/internal/trace/state/trailer_test.go | 15 ++ app/cli/internal/trace/types.go | 15 ++ app/cli/pkg/action/trace_agent_hook.go | 15 ++ app/cli/pkg/action/trace_agent_hook_test.go | 15 ++ app/cli/pkg/action/trace_attestation.go | 15 ++ app/cli/pkg/action/trace_attestation_test.go | 15 ++ app/cli/pkg/action/trace_hook_handler.go | 15 ++ app/cli/pkg/action/trace_hook_handler_test.go | 15 ++ app/cli/pkg/action/trace_run.go | 15 ++ app/cli/pkg/action/trace_run_test.go | 15 ++ go.mod | 4 +- 86 files changed, 1469 insertions(+), 13 deletions(-) diff --git a/app/cli/cmd/trace.go b/app/cli/cmd/trace.go index b0c55b3e3..d4041814d 100644 --- a/app/cli/cmd/trace.go +++ b/app/cli/cmd/trace.go @@ -1,3 +1,18 @@ +// +// 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 cmd import ( diff --git a/app/cli/cmd/trace_hook.go b/app/cli/cmd/trace_hook.go index 0f1e868c0..974465fd0 100644 --- a/app/cli/cmd/trace_hook.go +++ b/app/cli/cmd/trace_hook.go @@ -1,3 +1,18 @@ +// +// 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 cmd import ( diff --git a/app/cli/cmd/trace_hooklog.go b/app/cli/cmd/trace_hooklog.go index ed89d0970..23e1d1c08 100644 --- a/app/cli/cmd/trace_hooklog.go +++ b/app/cli/cmd/trace_hooklog.go @@ -1,3 +1,18 @@ +// +// 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 cmd import ( diff --git a/app/cli/cmd/trace_hooklog_test.go b/app/cli/cmd/trace_hooklog_test.go index a88f48051..1b8fce80d 100644 --- a/app/cli/cmd/trace_hooklog_test.go +++ b/app/cli/cmd/trace_hooklog_test.go @@ -1,3 +1,18 @@ +// +// 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 cmd import ( diff --git a/app/cli/cmd/trace_init.go b/app/cli/cmd/trace_init.go index 5f88584d5..f3ee1886c 100644 --- a/app/cli/cmd/trace_init.go +++ b/app/cli/cmd/trace_init.go @@ -1,3 +1,18 @@ +// +// 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 cmd import ( diff --git a/app/cli/cmd/trace_run.go b/app/cli/cmd/trace_run.go index 59f2e8f2e..a84237964 100644 --- a/app/cli/cmd/trace_run.go +++ b/app/cli/cmd/trace_run.go @@ -1,3 +1,18 @@ +// +// 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 cmd import ( diff --git a/app/cli/cmd/trace_uninstall.go b/app/cli/cmd/trace_uninstall.go index b20258a7e..cd6d9191d 100644 --- a/app/cli/cmd/trace_uninstall.go +++ b/app/cli/cmd/trace_uninstall.go @@ -1,3 +1,18 @@ +// +// 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 cmd import ( diff --git a/app/cli/documentation/cli-reference.mdx b/app/cli/documentation/cli-reference.mdx index 728849efc..3093ca274 100755 --- a/app/cli/documentation/cli-reference.mdx +++ b/app/cli/documentation/cli-reference.mdx @@ -3234,6 +3234,202 @@ Options inherited from parent commands -y, --yes Skip confirmation ``` +## chainloop trace + +AI coding session tracing and attestation + +Options + +``` +-h, --help help for trace +``` + +Options inherited from parent commands + +``` +--artifact-cas string URL for the Artifacts Content Addressable Storage API ($CHAINLOOP_ARTIFACT_CAS_API) (default "api.cas.chainloop.dev:443") +--artifact-cas-ca string CUSTOM CA file for the Artifacts CAS API (optional) ($CHAINLOOP_ARTIFACT_CAS_API_CA) +-c, --config string Path to an existing config file (default is $HOME/.config/chainloop/config.toml) +--control-plane string URL for the Control Plane API ($CHAINLOOP_CONTROL_PLANE_API) (default "api.cp.chainloop.dev:443") +--control-plane-ca string CUSTOM CA file for the Control Plane API (optional) ($CHAINLOOP_CONTROL_PLANE_API_CA) +--debug Enable debug/verbose logging mode +-i, --insecure Skip TLS transport during connection to the control plane ($CHAINLOOP_API_INSECURE) +--max-recv-msg-size int Max size in bytes for incoming gRPC messages (0 = default 33554432) ($CHAINLOOP_API_MAX_RECV_MSG_SIZE) +-n, --org string organization name +-o, --output string Output format, valid options are json and table (default "table") +-t, --token string API token. NOTE: Alternatively use the env variable CHAINLOOP_TOKEN +-y, --yes Skip confirmation +``` + +### chainloop trace help + +Help about any command + +Synopsis + +Help provides help for any command in the application. +Simply type trace help [path to command] for full details. + +``` +chainloop trace help [command] [flags] +``` + +Options + +``` +-h, --help help for help +``` + +Options inherited from parent commands + +``` +--artifact-cas string URL for the Artifacts Content Addressable Storage API ($CHAINLOOP_ARTIFACT_CAS_API) (default "api.cas.chainloop.dev:443") +--artifact-cas-ca string CUSTOM CA file for the Artifacts CAS API (optional) ($CHAINLOOP_ARTIFACT_CAS_API_CA) +-c, --config string Path to an existing config file (default is $HOME/.config/chainloop/config.toml) +--control-plane string URL for the Control Plane API ($CHAINLOOP_CONTROL_PLANE_API) (default "api.cp.chainloop.dev:443") +--control-plane-ca string CUSTOM CA file for the Control Plane API (optional) ($CHAINLOOP_CONTROL_PLANE_API_CA) +--debug Enable debug/verbose logging mode +-i, --insecure Skip TLS transport during connection to the control plane ($CHAINLOOP_API_INSECURE) +--max-recv-msg-size int Max size in bytes for incoming gRPC messages (0 = default 33554432) ($CHAINLOOP_API_MAX_RECV_MSG_SIZE) +-n, --org string organization name +-o, --output string Output format, valid options are json and table (default "table") +-t, --token string API token. NOTE: Alternatively use the env variable CHAINLOOP_TOKEN +-y, --yes Skip confirmation +``` + +### chainloop trace init + +Initialize git hooks for automatic AI session tracing + +Synopsis + +Initialize git hooks that automatically trace AI coding sessions and +create Chainloop attestations when you push. + +This installs the managed git hooks plus agent-specific hooks for the +selected providers. Pass --claude, --cursor, and/or --opencode to pick +providers; when none is set, Claude Code is used as the default. + +Pass --org to pin the Chainloop organization trace attestations target; the +value is saved to .chainloop.yml and overrides the CLI default on every push. + +Pass --workflow to override the workflow name used when initializing trace +attestations. The value is saved to .chainloop.yml and defaults to +"ai-coding-session" when unset. + +``` +chainloop trace init [flags] +``` + +Options + +``` +--claude install Claude Code hooks (default when no provider flag is set) +--cursor install Cursor hooks +-h, --help help for init +--opencode install opencode hooks +--project string chainloop project name +--require-trace block pushes when attestation fails for AI-assisted commits +--workflow string chainloop workflow name used for trace attestations (defaults to "ai-coding-session") +``` + +Options inherited from parent commands + +``` +--artifact-cas string URL for the Artifacts Content Addressable Storage API ($CHAINLOOP_ARTIFACT_CAS_API) (default "api.cas.chainloop.dev:443") +--artifact-cas-ca string CUSTOM CA file for the Artifacts CAS API (optional) ($CHAINLOOP_ARTIFACT_CAS_API_CA) +-c, --config string Path to an existing config file (default is $HOME/.config/chainloop/config.toml) +--control-plane string URL for the Control Plane API ($CHAINLOOP_CONTROL_PLANE_API) (default "api.cp.chainloop.dev:443") +--control-plane-ca string CUSTOM CA file for the Control Plane API (optional) ($CHAINLOOP_CONTROL_PLANE_API_CA) +--debug Enable debug/verbose logging mode +-i, --insecure Skip TLS transport during connection to the control plane ($CHAINLOOP_API_INSECURE) +--max-recv-msg-size int Max size in bytes for incoming gRPC messages (0 = default 33554432) ($CHAINLOOP_API_MAX_RECV_MSG_SIZE) +-n, --org string organization name +-o, --output string Output format, valid options are json and table (default "table") +-t, --token string API token. NOTE: Alternatively use the env variable CHAINLOOP_TOKEN +-y, --yes Skip confirmation +``` + +### chainloop trace run + +Run a single-shot AI coding session and attest it + +Synopsis + +Wrap an external command (typically an AI coding agent) and emit a +Chainloop attestation for the session once the command exits successfully. +Sessions without git commits are still attested. + +trace run is isolated: it ignores .chainloop.yml entirely. The attestation +identity must come from the --org, --project, --workflow flags (mandatory) +and the optional --version flag. It does not read from or write to +.chainloop.yml, and removes every hook and local trace artifact when the +wrapped command exits, so a session never leaks setup into the next one. + +``` +chainloop trace run -- [args...] [flags] +``` + +Options + +``` +--claude install Claude Code hooks (default when no provider flag is set) +--cursor install Cursor hooks +-h, --help help for run +--opencode install opencode hooks +--project string chainloop project name (required; .chainloop.yml is ignored) +--version string chainloop project version (optional; defaults to the latest version) +--workflow string chainloop workflow name used for trace attestations (required; .chainloop.yml is ignored) +``` + +Options inherited from parent commands + +``` +--artifact-cas string URL for the Artifacts Content Addressable Storage API ($CHAINLOOP_ARTIFACT_CAS_API) (default "api.cas.chainloop.dev:443") +--artifact-cas-ca string CUSTOM CA file for the Artifacts CAS API (optional) ($CHAINLOOP_ARTIFACT_CAS_API_CA) +-c, --config string Path to an existing config file (default is $HOME/.config/chainloop/config.toml) +--control-plane string URL for the Control Plane API ($CHAINLOOP_CONTROL_PLANE_API) (default "api.cp.chainloop.dev:443") +--control-plane-ca string CUSTOM CA file for the Control Plane API (optional) ($CHAINLOOP_CONTROL_PLANE_API_CA) +--debug Enable debug/verbose logging mode +-i, --insecure Skip TLS transport during connection to the control plane ($CHAINLOOP_API_INSECURE) +--max-recv-msg-size int Max size in bytes for incoming gRPC messages (0 = default 33554432) ($CHAINLOOP_API_MAX_RECV_MSG_SIZE) +-n, --org string organization name +-o, --output string Output format, valid options are json and table (default "table") +-t, --token string API token. NOTE: Alternatively use the env variable CHAINLOOP_TOKEN +-y, --yes Skip confirmation +``` + +### chainloop trace uninstall + +Remove trace git hooks and configuration + +``` +chainloop trace uninstall [flags] +``` + +Options + +``` +-h, --help help for uninstall +-y, --yes skip confirmation prompt +``` + +Options inherited from parent commands + +``` +--artifact-cas string URL for the Artifacts Content Addressable Storage API ($CHAINLOOP_ARTIFACT_CAS_API) (default "api.cas.chainloop.dev:443") +--artifact-cas-ca string CUSTOM CA file for the Artifacts CAS API (optional) ($CHAINLOOP_ARTIFACT_CAS_API_CA) +-c, --config string Path to an existing config file (default is $HOME/.config/chainloop/config.toml) +--control-plane string URL for the Control Plane API ($CHAINLOOP_CONTROL_PLANE_API) (default "api.cp.chainloop.dev:443") +--control-plane-ca string CUSTOM CA file for the Control Plane API (optional) ($CHAINLOOP_CONTROL_PLANE_API_CA) +--debug Enable debug/verbose logging mode +-i, --insecure Skip TLS transport during connection to the control plane ($CHAINLOOP_API_INSECURE) +--max-recv-msg-size int Max size in bytes for incoming gRPC messages (0 = default 33554432) ($CHAINLOOP_API_MAX_RECV_MSG_SIZE) +-n, --org string organization name +-o, --output string Output format, valid options are json and table (default "table") +-t, --token string API token. NOTE: Alternatively use the env variable CHAINLOOP_TOKEN +``` + ## chainloop version Command line version diff --git a/app/cli/internal/trace/attribution/diff.go b/app/cli/internal/trace/attribution/diff.go index 62a6e373d..0e0f7c846 100644 --- a/app/cli/internal/trace/attribution/diff.go +++ b/app/cli/internal/trace/attribution/diff.go @@ -1,3 +1,18 @@ +// +// 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 attribution import ( diff --git a/app/cli/internal/trace/attribution/diff_test.go b/app/cli/internal/trace/attribution/diff_test.go index 892b15e5e..a5f0d5301 100644 --- a/app/cli/internal/trace/attribution/diff_test.go +++ b/app/cli/internal/trace/attribution/diff_test.go @@ -1,3 +1,18 @@ +// +// 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 attribution import ( diff --git a/app/cli/internal/trace/attribution/edits.go b/app/cli/internal/trace/attribution/edits.go index e5e33c6f9..41fad0521 100644 --- a/app/cli/internal/trace/attribution/edits.go +++ b/app/cli/internal/trace/attribution/edits.go @@ -1,3 +1,18 @@ +// +// 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 attribution import ( diff --git a/app/cli/internal/trace/attribution/edits_test.go b/app/cli/internal/trace/attribution/edits_test.go index a314c8760..6c33f8d16 100644 --- a/app/cli/internal/trace/attribution/edits_test.go +++ b/app/cli/internal/trace/attribution/edits_test.go @@ -1,3 +1,18 @@ +// +// 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 attribution import ( diff --git a/app/cli/internal/trace/attribution/enrich.go b/app/cli/internal/trace/attribution/enrich.go index 3e7667957..a056dd845 100644 --- a/app/cli/internal/trace/attribution/enrich.go +++ b/app/cli/internal/trace/attribution/enrich.go @@ -1,8 +1,23 @@ +// +// 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 attribution import ( - "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" ) // Enrich adds AI/human attribution to code changes based on AI line range data. diff --git a/app/cli/internal/trace/attribution/enrich_test.go b/app/cli/internal/trace/attribution/enrich_test.go index f39253861..d926d785d 100644 --- a/app/cli/internal/trace/attribution/enrich_test.go +++ b/app/cli/internal/trace/attribution/enrich_test.go @@ -1,10 +1,25 @@ +// +// 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 attribution import ( "testing" - "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" "github.com/stretchr/testify/assert" ) diff --git a/app/cli/internal/trace/attribution/generated.go b/app/cli/internal/trace/attribution/generated.go index 0a3c43814..615b9f576 100644 --- a/app/cli/internal/trace/attribution/generated.go +++ b/app/cli/internal/trace/attribution/generated.go @@ -1,3 +1,18 @@ +// +// 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 attribution import ( diff --git a/app/cli/internal/trace/attribution/generated_test.go b/app/cli/internal/trace/attribution/generated_test.go index 56124787d..fccd28800 100644 --- a/app/cli/internal/trace/attribution/generated_test.go +++ b/app/cli/internal/trace/attribution/generated_test.go @@ -1,3 +1,18 @@ +// +// 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 attribution import ( @@ -5,8 +20,8 @@ import ( "path/filepath" "testing" - "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" tracegit "github.com/chainloop-dev/chainloop/app/cli/internal/trace/git" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/app/cli/internal/trace/attribution/shell.go b/app/cli/internal/trace/attribution/shell.go index 5309fc829..4f55b46da 100644 --- a/app/cli/internal/trace/attribution/shell.go +++ b/app/cli/internal/trace/attribution/shell.go @@ -1,3 +1,18 @@ +// +// 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 attribution import "sort" diff --git a/app/cli/internal/trace/attribution/shell_test.go b/app/cli/internal/trace/attribution/shell_test.go index 8c7d32b62..943a35e2b 100644 --- a/app/cli/internal/trace/attribution/shell_test.go +++ b/app/cli/internal/trace/attribution/shell_test.go @@ -1,3 +1,18 @@ +// +// 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 attribution import ( diff --git a/app/cli/internal/trace/claude/hooks.go b/app/cli/internal/trace/claude/hooks.go index 0dd9df39c..ff25e943b 100644 --- a/app/cli/internal/trace/claude/hooks.go +++ b/app/cli/internal/trace/claude/hooks.go @@ -1,3 +1,18 @@ +// +// 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 claude import ( diff --git a/app/cli/internal/trace/claude/hooks_test.go b/app/cli/internal/trace/claude/hooks_test.go index dad25138e..c62484763 100644 --- a/app/cli/internal/trace/claude/hooks_test.go +++ b/app/cli/internal/trace/claude/hooks_test.go @@ -1,3 +1,18 @@ +// +// 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 claude import ( diff --git a/app/cli/internal/trace/claude/parse.go b/app/cli/internal/trace/claude/parse.go index fb7df13ad..c9a2f65ad 100644 --- a/app/cli/internal/trace/claude/parse.go +++ b/app/cli/internal/trace/claude/parse.go @@ -1,3 +1,18 @@ +// +// 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 claude import ( @@ -12,8 +27,8 @@ import ( "strings" "time" - "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" ) // Pricing rates live in pricing.json (loaded by pricing.go via go:embed). diff --git a/app/cli/internal/trace/claude/parse_test.go b/app/cli/internal/trace/claude/parse_test.go index 13b3e1978..bf16a97fc 100644 --- a/app/cli/internal/trace/claude/parse_test.go +++ b/app/cli/internal/trace/claude/parse_test.go @@ -1,3 +1,18 @@ +// +// 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 claude import ( @@ -8,8 +23,8 @@ import ( "testing" "time" - "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/app/cli/internal/trace/claude/pid_unix.go b/app/cli/internal/trace/claude/pid_unix.go index 4739ebf03..19f88f364 100644 --- a/app/cli/internal/trace/claude/pid_unix.go +++ b/app/cli/internal/trace/claude/pid_unix.go @@ -1,5 +1,20 @@ //go:build !windows +// +// 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 claude import "syscall" diff --git a/app/cli/internal/trace/claude/pid_windows.go b/app/cli/internal/trace/claude/pid_windows.go index e451367ae..637cf70ce 100644 --- a/app/cli/internal/trace/claude/pid_windows.go +++ b/app/cli/internal/trace/claude/pid_windows.go @@ -1,5 +1,20 @@ //go:build windows +// +// 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 claude import "os" diff --git a/app/cli/internal/trace/claude/pricing.go b/app/cli/internal/trace/claude/pricing.go index 1a3776e15..5bfcc40e1 100644 --- a/app/cli/internal/trace/claude/pricing.go +++ b/app/cli/internal/trace/claude/pricing.go @@ -1,3 +1,18 @@ +// +// 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 claude import ( diff --git a/app/cli/internal/trace/claude/pricing_test.go b/app/cli/internal/trace/claude/pricing_test.go index 284eb3596..e76b5cd81 100644 --- a/app/cli/internal/trace/claude/pricing_test.go +++ b/app/cli/internal/trace/claude/pricing_test.go @@ -1,3 +1,18 @@ +// +// 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 claude import ( diff --git a/app/cli/internal/trace/claude/provider.go b/app/cli/internal/trace/claude/provider.go index f56dc69c7..388952737 100644 --- a/app/cli/internal/trace/claude/provider.go +++ b/app/cli/internal/trace/claude/provider.go @@ -1,3 +1,18 @@ +// +// 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 claude import ( @@ -10,9 +25,9 @@ import ( "slices" "strings" - "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" "github.com/chainloop-dev/chainloop/app/cli/internal/trace" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" ) // Name is the provider identifier emitted to evidence and used as the key diff --git a/app/cli/internal/trace/claude/session.go b/app/cli/internal/trace/claude/session.go index 8b3596699..23caeaad9 100644 --- a/app/cli/internal/trace/claude/session.go +++ b/app/cli/internal/trace/claude/session.go @@ -1,3 +1,18 @@ +// +// 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 claude import ( diff --git a/app/cli/internal/trace/claude/session_test.go b/app/cli/internal/trace/claude/session_test.go index f0d0b1584..dfd434598 100644 --- a/app/cli/internal/trace/claude/session_test.go +++ b/app/cli/internal/trace/claude/session_test.go @@ -1,3 +1,18 @@ +// +// 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 claude import ( diff --git a/app/cli/internal/trace/claude/types.go b/app/cli/internal/trace/claude/types.go index 8d76a4f9c..1cd09d9f0 100644 --- a/app/cli/internal/trace/claude/types.go +++ b/app/cli/internal/trace/claude/types.go @@ -1,3 +1,18 @@ +// +// 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 claude import "encoding/json" diff --git a/app/cli/internal/trace/config/config.go b/app/cli/internal/trace/config/config.go index 61b87187f..06c62d97f 100644 --- a/app/cli/internal/trace/config/config.go +++ b/app/cli/internal/trace/config/config.go @@ -1,3 +1,18 @@ +// +// 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 config import ( diff --git a/app/cli/internal/trace/config/config_test.go b/app/cli/internal/trace/config/config_test.go index 350009e3d..735d55e65 100644 --- a/app/cli/internal/trace/config/config_test.go +++ b/app/cli/internal/trace/config/config_test.go @@ -1,3 +1,18 @@ +// +// 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 config import ( diff --git a/app/cli/internal/trace/cursor/hooks.go b/app/cli/internal/trace/cursor/hooks.go index 894717e64..662ac4a8e 100644 --- a/app/cli/internal/trace/cursor/hooks.go +++ b/app/cli/internal/trace/cursor/hooks.go @@ -1,3 +1,18 @@ +// +// 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 cursor import ( diff --git a/app/cli/internal/trace/cursor/hooks_test.go b/app/cli/internal/trace/cursor/hooks_test.go index 88e0f5003..1ef225319 100644 --- a/app/cli/internal/trace/cursor/hooks_test.go +++ b/app/cli/internal/trace/cursor/hooks_test.go @@ -1,3 +1,18 @@ +// +// 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 cursor import ( diff --git a/app/cli/internal/trace/cursor/parse.go b/app/cli/internal/trace/cursor/parse.go index d94e8bab6..5d83ad13e 100644 --- a/app/cli/internal/trace/cursor/parse.go +++ b/app/cli/internal/trace/cursor/parse.go @@ -1,3 +1,18 @@ +// +// 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 cursor import ( diff --git a/app/cli/internal/trace/cursor/parse_test.go b/app/cli/internal/trace/cursor/parse_test.go index a7bdf081c..4e06094c6 100644 --- a/app/cli/internal/trace/cursor/parse_test.go +++ b/app/cli/internal/trace/cursor/parse_test.go @@ -1,3 +1,18 @@ +// +// 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 cursor import ( diff --git a/app/cli/internal/trace/cursor/provider.go b/app/cli/internal/trace/cursor/provider.go index 8c63aaab1..dd41f64fe 100644 --- a/app/cli/internal/trace/cursor/provider.go +++ b/app/cli/internal/trace/cursor/provider.go @@ -1,3 +1,18 @@ +// +// 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 cursor import ( @@ -10,10 +25,10 @@ import ( "sort" "time" - "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" "github.com/chainloop-dev/chainloop/app/cli/internal/trace" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/attribution" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" ) // Name is the provider identifier emitted to evidence and used as the key diff --git a/app/cli/internal/trace/cursor/session.go b/app/cli/internal/trace/cursor/session.go index ab7f40ecd..afa5926b4 100644 --- a/app/cli/internal/trace/cursor/session.go +++ b/app/cli/internal/trace/cursor/session.go @@ -1,3 +1,18 @@ +// +// 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 cursor import ( diff --git a/app/cli/internal/trace/cursor/session_test.go b/app/cli/internal/trace/cursor/session_test.go index 4c8cdf63e..df9d78d88 100644 --- a/app/cli/internal/trace/cursor/session_test.go +++ b/app/cli/internal/trace/cursor/session_test.go @@ -1,3 +1,18 @@ +// +// 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 cursor import ( diff --git a/app/cli/internal/trace/cursor/types.go b/app/cli/internal/trace/cursor/types.go index 93e411989..fc985e286 100644 --- a/app/cli/internal/trace/cursor/types.go +++ b/app/cli/internal/trace/cursor/types.go @@ -1,3 +1,18 @@ +// +// 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 cursor // cursorJSONLRecord matches a single line in a Cursor agent transcript. diff --git a/app/cli/internal/trace/git/aggregate.go b/app/cli/internal/trace/git/aggregate.go index c54c624a3..e617802ca 100644 --- a/app/cli/internal/trace/git/aggregate.go +++ b/app/cli/internal/trace/git/aggregate.go @@ -1,3 +1,18 @@ +// +// 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 git import ( diff --git a/app/cli/internal/trace/git/exec.go b/app/cli/internal/trace/git/exec.go index 046a7b0f9..4d49eecb0 100644 --- a/app/cli/internal/trace/git/exec.go +++ b/app/cli/internal/trace/git/exec.go @@ -1,3 +1,18 @@ +// +// 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 git import ( diff --git a/app/cli/internal/trace/git/exec_test.go b/app/cli/internal/trace/git/exec_test.go index 572a28a59..f21b01e27 100644 --- a/app/cli/internal/trace/git/exec_test.go +++ b/app/cli/internal/trace/git/exec_test.go @@ -1,3 +1,18 @@ +// +// 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 git import ( diff --git a/app/cli/internal/trace/git/git.go b/app/cli/internal/trace/git/git.go index 1e6cb2ba8..696b93663 100644 --- a/app/cli/internal/trace/git/git.go +++ b/app/cli/internal/trace/git/git.go @@ -1,3 +1,18 @@ +// +// 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 git import ( diff --git a/app/cli/internal/trace/git/gogit.go b/app/cli/internal/trace/git/gogit.go index 60caa569f..1d446531c 100644 --- a/app/cli/internal/trace/git/gogit.go +++ b/app/cli/internal/trace/git/gogit.go @@ -1,3 +1,18 @@ +// +// 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 git import ( diff --git a/app/cli/internal/trace/git/gogit_test.go b/app/cli/internal/trace/git/gogit_test.go index 85b90908e..08cbf3439 100644 --- a/app/cli/internal/trace/git/gogit_test.go +++ b/app/cli/internal/trace/git/gogit_test.go @@ -1,3 +1,18 @@ +// +// 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 git import ( diff --git a/app/cli/internal/trace/git/merge_branch_test.go b/app/cli/internal/trace/git/merge_branch_test.go index 66fe8be50..a7a14330d 100644 --- a/app/cli/internal/trace/git/merge_branch_test.go +++ b/app/cli/internal/trace/git/merge_branch_test.go @@ -1,3 +1,18 @@ +// +// 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 git import ( diff --git a/app/cli/internal/trace/git/repo.go b/app/cli/internal/trace/git/repo.go index c5d5fc3b6..6c1b6a0a0 100644 --- a/app/cli/internal/trace/git/repo.go +++ b/app/cli/internal/trace/git/repo.go @@ -1,3 +1,18 @@ +// +// 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 git import ( diff --git a/app/cli/internal/trace/git/repo_test.go b/app/cli/internal/trace/git/repo_test.go index fad8373f7..e2f297f2d 100644 --- a/app/cli/internal/trace/git/repo_test.go +++ b/app/cli/internal/trace/git/repo_test.go @@ -1,3 +1,18 @@ +// +// 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 git import ( diff --git a/app/cli/internal/trace/hooks/hooks.go b/app/cli/internal/trace/hooks/hooks.go index 668c9edb0..e2027e996 100644 --- a/app/cli/internal/trace/hooks/hooks.go +++ b/app/cli/internal/trace/hooks/hooks.go @@ -1,3 +1,18 @@ +// +// 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 hooks import ( diff --git a/app/cli/internal/trace/hooks/hooks_test.go b/app/cli/internal/trace/hooks/hooks_test.go index 21cbae29d..eaa77828e 100644 --- a/app/cli/internal/trace/hooks/hooks_test.go +++ b/app/cli/internal/trace/hooks/hooks_test.go @@ -1,3 +1,18 @@ +// +// 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 hooks import ( diff --git a/app/cli/internal/trace/opencode/export_integration_test.go b/app/cli/internal/trace/opencode/export_integration_test.go index 5f4485deb..f8e597b3b 100644 --- a/app/cli/internal/trace/opencode/export_integration_test.go +++ b/app/cli/internal/trace/opencode/export_integration_test.go @@ -1,5 +1,20 @@ //go:build integration +// +// 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 opencode import ( diff --git a/app/cli/internal/trace/opencode/golden_test.go b/app/cli/internal/trace/opencode/golden_test.go index da079717b..3a9618f2e 100644 --- a/app/cli/internal/trace/opencode/golden_test.go +++ b/app/cli/internal/trace/opencode/golden_test.go @@ -1,3 +1,18 @@ +// +// 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 opencode import ( diff --git a/app/cli/internal/trace/opencode/hooks.go b/app/cli/internal/trace/opencode/hooks.go index 2a71e067d..b86929e25 100644 --- a/app/cli/internal/trace/opencode/hooks.go +++ b/app/cli/internal/trace/opencode/hooks.go @@ -1,3 +1,18 @@ +// +// 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 opencode import ( diff --git a/app/cli/internal/trace/opencode/hooks_test.go b/app/cli/internal/trace/opencode/hooks_test.go index bd754dcd7..1b2e43563 100644 --- a/app/cli/internal/trace/opencode/hooks_test.go +++ b/app/cli/internal/trace/opencode/hooks_test.go @@ -1,3 +1,18 @@ +// +// 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 opencode import ( diff --git a/app/cli/internal/trace/opencode/parse.go b/app/cli/internal/trace/opencode/parse.go index 25a865833..0a448ccee 100644 --- a/app/cli/internal/trace/opencode/parse.go +++ b/app/cli/internal/trace/opencode/parse.go @@ -1,3 +1,18 @@ +// +// 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 opencode import ( @@ -8,8 +23,8 @@ import ( "sort" "time" - "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" ) // unknownValue stands in for a model or tool name the export didn't carry. diff --git a/app/cli/internal/trace/opencode/parse_test.go b/app/cli/internal/trace/opencode/parse_test.go index 746d042a2..4598a1e10 100644 --- a/app/cli/internal/trace/opencode/parse_test.go +++ b/app/cli/internal/trace/opencode/parse_test.go @@ -1,3 +1,18 @@ +// +// 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 opencode import ( @@ -7,9 +22,9 @@ import ( "path/filepath" "testing" - "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" "github.com/chainloop-dev/chainloop/app/cli/internal/trace" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/app/cli/internal/trace/opencode/provider.go b/app/cli/internal/trace/opencode/provider.go index 3b3ddd83d..3b61a1cfd 100644 --- a/app/cli/internal/trace/opencode/provider.go +++ b/app/cli/internal/trace/opencode/provider.go @@ -1,3 +1,18 @@ +// +// 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 opencode import ( @@ -8,9 +23,9 @@ import ( "os/exec" "time" - "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" "github.com/chainloop-dev/chainloop/app/cli/internal/trace" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" ) // Name is the provider identifier emitted to evidence and used as the key diff --git a/app/cli/internal/trace/opencode/session.go b/app/cli/internal/trace/opencode/session.go index 068fac903..7ba26b54f 100644 --- a/app/cli/internal/trace/opencode/session.go +++ b/app/cli/internal/trace/opencode/session.go @@ -1,3 +1,18 @@ +// +// 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 opencode import ( diff --git a/app/cli/internal/trace/opencode/session_test.go b/app/cli/internal/trace/opencode/session_test.go index b7b98bf9d..bfd118b43 100644 --- a/app/cli/internal/trace/opencode/session_test.go +++ b/app/cli/internal/trace/opencode/session_test.go @@ -1,3 +1,18 @@ +// +// 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 opencode import ( diff --git a/app/cli/internal/trace/opencode/types.go b/app/cli/internal/trace/opencode/types.go index 6ccc43a67..e190a7279 100644 --- a/app/cli/internal/trace/opencode/types.go +++ b/app/cli/internal/trace/opencode/types.go @@ -1,3 +1,18 @@ +// +// 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 opencode import "encoding/json" diff --git a/app/cli/internal/trace/provider.go b/app/cli/internal/trace/provider.go index 33d4a2616..d1e12099f 100644 --- a/app/cli/internal/trace/provider.go +++ b/app/cli/internal/trace/provider.go @@ -1,11 +1,26 @@ +// +// 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 trace import ( "context" "io" - "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" ) // Provider discovers and parses AI coding sessions for a specific agent. diff --git a/app/cli/internal/trace/providers/registry.go b/app/cli/internal/trace/providers/registry.go index b1d4e4872..183192db7 100644 --- a/app/cli/internal/trace/providers/registry.go +++ b/app/cli/internal/trace/providers/registry.go @@ -1,3 +1,18 @@ +// +// 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 providers registers all available AI coding agent trace providers // in one place. Callers (CLI commands, pre-push hook) use this registry // instead of instantiating provider packages directly, so adding a new diff --git a/app/cli/internal/trace/state/ailines.go b/app/cli/internal/trace/state/ailines.go index 4ccd4e2d6..6c192795d 100644 --- a/app/cli/internal/trace/state/ailines.go +++ b/app/cli/internal/trace/state/ailines.go @@ -1,3 +1,18 @@ +// +// 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 state import ( diff --git a/app/cli/internal/trace/state/commit.go b/app/cli/internal/trace/state/commit.go index 2e9833686..37b6715b2 100644 --- a/app/cli/internal/trace/state/commit.go +++ b/app/cli/internal/trace/state/commit.go @@ -1,3 +1,18 @@ +// +// 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 state import ( diff --git a/app/cli/internal/trace/state/jsonlcopy.go b/app/cli/internal/trace/state/jsonlcopy.go index 9075349fb..55848df48 100644 --- a/app/cli/internal/trace/state/jsonlcopy.go +++ b/app/cli/internal/trace/state/jsonlcopy.go @@ -1,3 +1,18 @@ +// +// 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 state import ( diff --git a/app/cli/internal/trace/state/locate.go b/app/cli/internal/trace/state/locate.go index 2c7b5e1e1..52dab2e9d 100644 --- a/app/cli/internal/trace/state/locate.go +++ b/app/cli/internal/trace/state/locate.go @@ -1,3 +1,18 @@ +// +// 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 state import ( diff --git a/app/cli/internal/trace/state/locate_test.go b/app/cli/internal/trace/state/locate_test.go index e055bd015..b40c6c8a2 100644 --- a/app/cli/internal/trace/state/locate_test.go +++ b/app/cli/internal/trace/state/locate_test.go @@ -1,3 +1,18 @@ +// +// 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 state import ( diff --git a/app/cli/internal/trace/state/session.go b/app/cli/internal/trace/state/session.go index c17ff3358..8d898fd94 100644 --- a/app/cli/internal/trace/state/session.go +++ b/app/cli/internal/trace/state/session.go @@ -1,3 +1,18 @@ +// +// 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 state import ( diff --git a/app/cli/internal/trace/state/snapshot.go b/app/cli/internal/trace/state/snapshot.go index df4a69b5c..594381997 100644 --- a/app/cli/internal/trace/state/snapshot.go +++ b/app/cli/internal/trace/state/snapshot.go @@ -1,3 +1,18 @@ +// +// 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 state import ( diff --git a/app/cli/internal/trace/state/snapshot_test.go b/app/cli/internal/trace/state/snapshot_test.go index 961dcfbba..5f828683e 100644 --- a/app/cli/internal/trace/state/snapshot_test.go +++ b/app/cli/internal/trace/state/snapshot_test.go @@ -1,3 +1,18 @@ +// +// 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 state import ( diff --git a/app/cli/internal/trace/state/state.go b/app/cli/internal/trace/state/state.go index 4c878a1ed..084c56751 100644 --- a/app/cli/internal/trace/state/state.go +++ b/app/cli/internal/trace/state/state.go @@ -1,3 +1,18 @@ +// +// 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 state import ( diff --git a/app/cli/internal/trace/state/state_test.go b/app/cli/internal/trace/state/state_test.go index 787d129a2..93b4c848b 100644 --- a/app/cli/internal/trace/state/state_test.go +++ b/app/cli/internal/trace/state/state_test.go @@ -1,3 +1,18 @@ +// +// 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 state import ( diff --git a/app/cli/internal/trace/state/store.go b/app/cli/internal/trace/state/store.go index 1baab7d77..efd378d6d 100644 --- a/app/cli/internal/trace/state/store.go +++ b/app/cli/internal/trace/state/store.go @@ -1,3 +1,18 @@ +// +// 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 state import "path/filepath" diff --git a/app/cli/internal/trace/state/trailer.go b/app/cli/internal/trace/state/trailer.go index d75b4e6e6..41cdbbb06 100644 --- a/app/cli/internal/trace/state/trailer.go +++ b/app/cli/internal/trace/state/trailer.go @@ -1,3 +1,18 @@ +// +// 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 state import ( diff --git a/app/cli/internal/trace/state/trailer_test.go b/app/cli/internal/trace/state/trailer_test.go index 3d5208bef..52dcc9ad4 100644 --- a/app/cli/internal/trace/state/trailer_test.go +++ b/app/cli/internal/trace/state/trailer_test.go @@ -1,3 +1,18 @@ +// +// 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 state import ( diff --git a/app/cli/internal/trace/types.go b/app/cli/internal/trace/types.go index 4a6519f89..5ee7cd18a 100644 --- a/app/cli/internal/trace/types.go +++ b/app/cli/internal/trace/types.go @@ -1,3 +1,18 @@ +// +// 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. + //nolint:revive // "trace" names the CLI feature; the runtime/trace clash is inert here package trace diff --git a/app/cli/pkg/action/trace_agent_hook.go b/app/cli/pkg/action/trace_agent_hook.go index aa4591273..5ef185551 100644 --- a/app/cli/pkg/action/trace_agent_hook.go +++ b/app/cli/pkg/action/trace_agent_hook.go @@ -1,3 +1,18 @@ +// +// 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 action import ( diff --git a/app/cli/pkg/action/trace_agent_hook_test.go b/app/cli/pkg/action/trace_agent_hook_test.go index c1e3f1c36..ff02c72b4 100644 --- a/app/cli/pkg/action/trace_agent_hook_test.go +++ b/app/cli/pkg/action/trace_agent_hook_test.go @@ -1,3 +1,18 @@ +// +// 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 action import ( diff --git a/app/cli/pkg/action/trace_attestation.go b/app/cli/pkg/action/trace_attestation.go index f4244ec8d..1a87dd102 100644 --- a/app/cli/pkg/action/trace_attestation.go +++ b/app/cli/pkg/action/trace_attestation.go @@ -1,3 +1,18 @@ +// +// 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 action import ( diff --git a/app/cli/pkg/action/trace_attestation_test.go b/app/cli/pkg/action/trace_attestation_test.go index 01b916174..16bf15511 100644 --- a/app/cli/pkg/action/trace_attestation_test.go +++ b/app/cli/pkg/action/trace_attestation_test.go @@ -1,3 +1,18 @@ +// +// 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 action import ( diff --git a/app/cli/pkg/action/trace_hook_handler.go b/app/cli/pkg/action/trace_hook_handler.go index 33ad37e60..ba8db8540 100644 --- a/app/cli/pkg/action/trace_hook_handler.go +++ b/app/cli/pkg/action/trace_hook_handler.go @@ -1,3 +1,18 @@ +// +// 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 action import ( diff --git a/app/cli/pkg/action/trace_hook_handler_test.go b/app/cli/pkg/action/trace_hook_handler_test.go index 28f5c1cca..76fba30d8 100644 --- a/app/cli/pkg/action/trace_hook_handler_test.go +++ b/app/cli/pkg/action/trace_hook_handler_test.go @@ -1,3 +1,18 @@ +// +// 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 action import ( diff --git a/app/cli/pkg/action/trace_run.go b/app/cli/pkg/action/trace_run.go index e9a631268..2d1bfdd93 100644 --- a/app/cli/pkg/action/trace_run.go +++ b/app/cli/pkg/action/trace_run.go @@ -1,3 +1,18 @@ +// +// 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 action import ( diff --git a/app/cli/pkg/action/trace_run_test.go b/app/cli/pkg/action/trace_run_test.go index 519091989..31d2d40fa 100644 --- a/app/cli/pkg/action/trace_run_test.go +++ b/app/cli/pkg/action/trace_run_test.go @@ -1,3 +1,18 @@ +// +// 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 action import ( diff --git a/go.mod b/go.mod index a5956f804..e67e92aa9 100644 --- a/go.mod +++ b/go.mod @@ -108,8 +108,10 @@ require github.com/vektah/gqlparser/v2 v2.5.36 require ( github.com/XSAM/otelsql v0.43.0 github.com/betterleaks/betterleaks v1.8.1 + github.com/go-git/go-billy/v6 v6.0.0-alpha.2 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/moby/moby/api v1.55.0 + github.com/sergi/go-diff v1.4.0 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0 go.opentelemetry.io/otel/sdk v1.45.0 @@ -188,7 +190,6 @@ require ( github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/gcfg/v2 v2.0.2 // indirect github.com/go-git/go-billy/v5 v5.9.0 // indirect - github.com/go-git/go-billy/v6 v6.0.0-alpha.2 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-openapi/runtime/server-middleware v0.30.0 // indirect @@ -275,7 +276,6 @@ require ( github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/segmentio/asm v1.2.1 // indirect - github.com/sergi/go-diff v1.4.0 // indirect github.com/shirou/gopsutil/v4 v4.26.6 // indirect github.com/shurcooL/githubv4 v0.0.0-20260209031235-2402fdf4a9ed // indirect github.com/shurcooL/graphql v0.0.0-20240915155400-7ee5256398cf // indirect From 9fa59e14ecb93fb5666c2ed497222a7eda1b27eb Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Mon, 31 Aug 2026 14:15:59 +0200 Subject: [PATCH 5/7] chore(trace): fix stale constructor names in doc comments Signed-off-by: Jose I. Paris --- app/cli/cmd/trace_init.go | 2 +- app/cli/cmd/trace_run.go | 2 +- app/cli/cmd/trace_uninstall.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/cli/cmd/trace_init.go b/app/cli/cmd/trace_init.go index f3ee1886c..a012b349c 100644 --- a/app/cli/cmd/trace_init.go +++ b/app/cli/cmd/trace_init.go @@ -30,7 +30,7 @@ import ( "github.com/spf13/cobra" ) -// NewTraceInitCmd creates the trace init subcommand. +// newTraceInitCmd creates the trace init subcommand. func newTraceInitCmd() *cobra.Command { var ( project string diff --git a/app/cli/cmd/trace_run.go b/app/cli/cmd/trace_run.go index a84237964..474e7ecba 100644 --- a/app/cli/cmd/trace_run.go +++ b/app/cli/cmd/trace_run.go @@ -36,7 +36,7 @@ and the optional --version flag. It does not read from or write to .chainloop.yml, and removes every hook and local trace artifact when the wrapped command exits, so a session never leaks setup into the next one.` -// NewTraceRunCmd creates the `trace run` subcommand. +// newTraceRunCmd creates the `trace run` subcommand. func newTraceRunCmd() *cobra.Command { var ( projectFlag string diff --git a/app/cli/cmd/trace_uninstall.go b/app/cli/cmd/trace_uninstall.go index cd6d9191d..a50dd0188 100644 --- a/app/cli/cmd/trace_uninstall.go +++ b/app/cli/cmd/trace_uninstall.go @@ -22,7 +22,7 @@ import ( "github.com/spf13/cobra" ) -// NewTraceUninstallCmd creates the trace uninstall subcommand. +// newTraceUninstallCmd creates the trace uninstall subcommand. func newTraceUninstallCmd() *cobra.Command { var yes bool From 9856f6871a2d13cc43150f0ba450be9cd773a256 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Mon, 31 Aug 2026 16:55:26 +0200 Subject: [PATCH 6/7] fix(trace): keep hook output visible on stderr during commit and push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hooks ran but said nothing, so they looked like they were not firing at all. `stderrMinLevel` defaulted to Warn and was only lowered by --debug, which filtered out every Info-level message the hooks emit — "commit record saved" on post-commit, the attestation progress on pre-push. Those are the only signal a user gets that trace is working inside a git commit or push, and the hook wrapper ends in `exit 0`, so nothing surfaced. The enterprise CLI looked like it had the same Warn default, but its root command called SetLogLevel(InfoLevel) on every invocation, and that lowered the floor to Info because Info < Warn. So the shipped behaviour was always Info; the Warn default was dead. Dropping SetLogLevel in the port silently changed it. Now mirrors initLogger: Info normally, Debug with --debug. The on-disk log.txt still receives every level either way. Signed-off-by: Jose I. Paris --- app/cli/cmd/trace_hooklog.go | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/app/cli/cmd/trace_hooklog.go b/app/cli/cmd/trace_hooklog.go index 23e1d1c08..22fed008a 100644 --- a/app/cli/cmd/trace_hooklog.go +++ b/app/cli/cmd/trace_hooklog.go @@ -28,9 +28,10 @@ import ( var hookLogFile *os.File // stderrMinLevel is the minimum zerolog.Level that hook commands write to -// stderr. All levels still reach the on-disk trace log; stderr is filtered -// to keep git/agent hooks quiet during normal operations. -var stderrMinLevel = zerolog.WarnLevel +// stderr. All levels still reach the on-disk trace log; stderr is filtered so +// a hook stays readable when git interleaves its output with the commit or +// push it is running inside. InitHookLogger sets it per invocation. +var stderrMinLevel = zerolog.InfoLevel // levelFilterWriter only forwards log entries at or above minLevel to w. // Used by hook commands to keep stderr quiet while letting the trace log @@ -79,7 +80,11 @@ func hookStderrWriter() *levelFilterWriter { func InitHookLogger() func() { closeHookLogFile() - // --debug lowers the stderr floor; the log file always gets every level. + // Mirror initLogger: Info normally, Debug with --debug. Info matters — + // it carries the "commit record saved" / attestation feedback that tells + // the user trace is doing something during a commit or push. The log file + // always gets every level regardless. + stderrMinLevel = zerolog.InfoLevel if flagDebug { stderrMinLevel = zerolog.DebugLevel } From 109024ba76eb9118e731fe22d3a32b86d22e389c Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Mon, 31 Aug 2026 17:31:39 +0200 Subject: [PATCH 7/7] fix(trace): initialize the root logger in NewRootCmd Cobra rejects unknown subcommands and invalid flags before PersistentPreRunE runs, so the package-level logger was still its zero value when main printed those errors through cmd.Logger(). A zero-value zerolog.Logger has no writer, and with SilenceErrors set main is the only place the message is emitted, so users got a silent non-zero exit. Seed the logger from NewRootCmd so it is usable as soon as the command tree exists. Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: f4cb9dc0-9116-40de-8c9a-56b23ae2f406 --- app/cli/cmd/root.go | 6 ++++++ app/cli/cmd/root_test.go | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/app/cli/cmd/root.go b/app/cli/cmd/root.go index 1e742176c..55e3ce8c2 100644 --- a/app/cli/cmd/root.go +++ b/app/cli/cmd/root.go @@ -93,6 +93,12 @@ func Execute(rootCmd *cobra.Command) error { } func NewRootCmd(l zerolog.Logger) *cobra.Command { + // Seed the root logger here and not only in PersistentPreRunE: cobra + // rejects unknown subcommands and invalid flags before that hook runs, and + // with SilenceErrors set main is the only place those errors get printed. + // A zero-value zerolog.Logger has no writer and would drop them silently. + logger = l + rootCmd := &cobra.Command{ Use: appName, Short: "Chainloop Command Line Interface", diff --git a/app/cli/cmd/root_test.go b/app/cli/cmd/root_test.go index 8799c7fa1..65ef8303b 100644 --- a/app/cli/cmd/root_test.go +++ b/app/cli/cmd/root_test.go @@ -15,8 +15,11 @@ package cmd import ( + "bytes" "testing" + "github.com/rs/zerolog" + "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/stretchr/testify/assert" @@ -111,3 +114,18 @@ func TestLoadAuthToken(t *testing.T) { }) } } + +// Cobra rejects unknown subcommands and invalid flags before PersistentPreRunE +// runs, so main prints those errors through a Logger() that PersistentPreRunE +// never got to initialize. NewRootCmd must leave the root logger usable. +func TestNewRootCmdInitializesLogger(t *testing.T) { + previous := logger + t.Cleanup(func() { logger = previous }) + + var out bytes.Buffer + NewRootCmd(zerolog.New(&out)) + + rootLogger := Logger() + rootLogger.Error().Msg("boom") + assert.Contains(t, out.String(), "boom") +}