diff --git a/app/cli/internal/trace/README.md b/app/cli/internal/trace/README.md index 8277af1da..e5b512d2f 100644 --- a/app/cli/internal/trace/README.md +++ b/app/cli/internal/trace/README.md @@ -35,11 +35,14 @@ Which lines of a commit the AI wrote. |---------|---------------|-------------|--------|--------------|------------| | Line attribution | Each changed line of a commit is attributed to the AI or to a human, from a snapshot of each file before and after the agent edits it. | Yes | Partial | Yes | Yes | | Shell changes attribution | Changes that the agent makes with shell commands (for example `sed`) are attributed to the AI too, not only changes made with the file tools. | Yes | Partial | Yes | Yes | +| Same-call commit attribution | A commit made in the same shell tool call that changed and staged its files carries that session's trace trailer. | Partial | No | No | No | Why not Yes: - **Line attribution, Cursor:** Cursor has only the `afterFileEdit` hook, which gives the edits but no snapshot before the edit. The provider rebuilds the content before the edit from the edits. - **Shell changes attribution, Cursor:** the provider installs the `preToolUse` and `postToolUse` hooks for the `Shell` tool. These hooks are verified for the Cursor IDE only. It is not verified that the Cursor CLI sends them. +- **Same-call commit attribution, Claude Code:** supported for foreground Bash calls. A background Bash call can continue after its `PostToolUse` hook removes the open-call snapshot, so a later commit from that background process is not attributed. A custom clean filter that transforms file content is handled conservatively as unstaged and can requeue attribution after commit. +- **Same-call commit attribution, Cursor and OpenCode:** these providers do not tag a shell command with the per-call token that git hooks use to load its open snapshot. ## User experience diff --git a/app/cli/internal/trace/claude/hooks.go b/app/cli/internal/trace/claude/hooks.go index e30138e17..8f7368b40 100644 --- a/app/cli/internal/trace/claude/hooks.go +++ b/app/cli/internal/trace/claude/hooks.go @@ -203,11 +203,8 @@ func (p *Provider) ReadHookInput(r io.Reader) (*trace.HookInput, error) { // Cursor-relayed events without a second pass. var raw struct { trace.HookInput - ToolInput struct { - FilePath string `json:"file_path"` - Command string `json:"command"` - } `json:"tool_input"` - CursorVersion string `json:"cursor_version"` + ToolInput json.RawMessage `json:"tool_input"` + CursorVersion string `json:"cursor_version"` } if err := json.Unmarshal(data, &raw); err != nil { return nil, err @@ -224,10 +221,20 @@ func (p *Provider) ReadHookInput(r io.Reader) (*trace.HookInput, error) { } input := raw.HookInput - if raw.ToolInput.FilePath != "" { - input.FilePath = raw.ToolInput.FilePath + var toolInput struct { + FilePath string `json:"file_path"` + Command string `json:"command"` } - input.Command = raw.ToolInput.Command + if len(raw.ToolInput) > 0 { + if err := json.Unmarshal(raw.ToolInput, &toolInput); err != nil { + return nil, err + } + } + if toolInput.FilePath != "" { + input.FilePath = toolInput.FilePath + } + input.Command = toolInput.Command + input.RawToolInput = append(json.RawMessage(nil), raw.ToolInput...) input.ToolFailed = input.HookEventName == eventPostToolUseFailure return &input, nil diff --git a/app/cli/internal/trace/claude/provider.go b/app/cli/internal/trace/claude/provider.go index 9ac279b18..4165c5fa9 100644 --- a/app/cli/internal/trace/claude/provider.go +++ b/app/cli/internal/trace/claude/provider.go @@ -37,6 +37,7 @@ const Name = "claude-code" // Compile-time check that Provider implements trace.Provider. var _ trace.Provider = (*Provider)(nil) +var _ trace.ShellCommandTagger = (*Provider)(nil) // Provider implements trace.Provider for Claude Code sessions. type Provider struct{} @@ -181,6 +182,37 @@ func (p *Provider) IsCommandTool(toolName string) bool { return slices.Contains(commandTools, toolName) } +// TagShellCommand returns a PreToolUse response that exports token for every +// subcommand in the Bash call. Git hooks inherit it and can load the exact +// pre-command snapshot instead of guessing among abandoned calls. +func (p *Provider) TagShellCommand(input *trace.HookInput, token string) error { + if input == nil || input.Command == "" || len(input.RawToolInput) == 0 || token == "" { + return nil + } + + var updatedInput map[string]json.RawMessage + if err := json.Unmarshal(input.RawToolInput, &updatedInput); err != nil { + return fmt.Errorf("parse Bash input: %w", err) + } + + command, err := json.Marshal("export " + state.ShellCallEnv + "=" + token + "; " + input.Command) + if err != nil { + return fmt.Errorf("encode tagged Bash command: %w", err) + } + updatedInput["command"] = command + + output := struct { + HookSpecificOutput struct { + HookEventName string `json:"hookEventName"` + UpdatedInput map[string]json.RawMessage `json:"updatedInput"` + } `json:"hookSpecificOutput"` + }{} + output.HookSpecificOutput.HookEventName = eventPreToolUse + output.HookSpecificOutput.UpdatedInput = updatedInput + + return json.NewEncoder(os.Stdout).Encode(output) +} + // SupportsSessionStartBanner is true for Claude Code: it renders the // systemMessage field of a hook response directly to the user. func (p *Provider) SupportsSessionStartBanner() bool { diff --git a/app/cli/internal/trace/claude/shell_tag_test.go b/app/cli/internal/trace/claude/shell_tag_test.go new file mode 100644 index 000000000..12e252837 --- /dev/null +++ b/app/cli/internal/trace/claude/shell_tag_test.go @@ -0,0 +1,56 @@ +// +// 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" + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/stretchr/testify/require" +) + +func TestTagShellCommand_PreservesInputAndExportsCallToken(t *testing.T) { + // Given + input := &trace.HookInput{ + Command: "git commit -m test", + RawToolInput: json.RawMessage(`{"command":"git commit -m test","description":"commit changes","timeout":120000}`), + } + + // When + out := captureStdout(t, func() { + require.NoError(t, New().TagShellCommand(input, "opaque-token")) + }) + + // Then + var response struct { + HookSpecificOutput struct { + HookEventName string `json:"hookEventName"` + UpdatedInput struct { + Command string `json:"command"` + Description string `json:"description"` + Timeout int `json:"timeout"` + } `json:"updatedInput"` + } `json:"hookSpecificOutput"` + } + require.NoError(t, json.Unmarshal([]byte(out), &response)) + require.Equal(t, eventPreToolUse, response.HookSpecificOutput.HookEventName) + require.Equal(t, "export "+state.ShellCallEnv+"=opaque-token; git commit -m test", + response.HookSpecificOutput.UpdatedInput.Command) + require.Equal(t, "commit changes", response.HookSpecificOutput.UpdatedInput.Description) + require.Equal(t, 120000, response.HookSpecificOutput.UpdatedInput.Timeout) +} diff --git a/app/cli/internal/trace/git/attributes.go b/app/cli/internal/trace/git/attributes.go new file mode 100644 index 000000000..11365c509 --- /dev/null +++ b/app/cli/internal/trace/git/attributes.go @@ -0,0 +1,142 @@ +// +// 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 ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/go-git/go-billy/v6/osfs" + gogit "github.com/go-git/go-git/v6" + gogitconfig "github.com/go-git/go-git/v6/config" + "github.com/go-git/go-git/v6/plumbing/format/gitattributes" + formatindex "github.com/go-git/go-git/v6/plumbing/format/index" + filesystemstorage "github.com/go-git/go-git/v6/storage/filesystem" + "github.com/go-git/go-git/v6/x/plugin" +) + +func attributeMatcher( + repo *gogit.Repository, + repoRoot string, + indexed map[string]*formatindex.Entry, +) (gitattributes.Matcher, error) { + system, err := gitattributes.LoadSystemPatterns(osfs.Default) + if err != nil { + return nil, err + } + global, err := gitattributes.LoadGlobalPatterns(osfs.Default) + if err != nil { + return nil, err + } + configSource, err := plugin.Get(plugin.ConfigLoader()) + if err != nil { + return nil, err + } + globalStore, err := configSource.Load(gogitconfig.GlobalScope) + if err != nil { + return nil, err + } + globalConfig, err := globalStore.Config() + if err != nil { + return nil, err + } + if path := globalConfig.Raw.Section("core").Options.Get("attributesfile"); path != "" { + global, err = configuredAttributes(path, repoRoot) + if err != nil { + return nil, err + } + } + cfg, err := repo.Config() + if err != nil { + return nil, err + } + if path := cfg.Raw.Section("core").Options.Get("attributesfile"); path != "" { + global, err = configuredAttributes(path, repoRoot) + if err != nil { + return nil, err + } + } + attrs := make([]gitattributes.MatchAttribute, 0, len(system)+len(global)) + attrs = append(attrs, system...) + attrs = append(attrs, global...) + + worktreeFS := osfs.New(repoRoot) + root, err := gitattributes.ReadAttributesFile(worktreeFS, nil, ".gitattributes", true) + if err != nil { + return nil, err + } + attrs = append(attrs, root...) + + dirs := make(map[string]struct{}) + for path := range indexed { + for dir := filepath.ToSlash(filepath.Dir(path)); dir != "."; dir = filepath.ToSlash(filepath.Dir(dir)) { + dirs[dir] = struct{}{} + } + } + ordered := make([]string, 0, len(dirs)) + for dir := range dirs { + ordered = append(ordered, dir) + } + sort.Slice(ordered, func(i, j int) bool { + leftDepth, rightDepth := strings.Count(ordered[i], "/"), strings.Count(ordered[j], "/") + if leftDepth == rightDepth { + return ordered[i] < ordered[j] + } + return leftDepth < rightDepth + }) + for _, dir := range ordered { + nested, err := gitattributes.ReadAttributesFile(worktreeFS, strings.Split(dir, "/"), ".gitattributes", false) + if err != nil { + return nil, err + } + attrs = append(attrs, nested...) + } + + storage, ok := repo.Storer.(*filesystemstorage.Storage) + if !ok { + return nil, fmt.Errorf("load info attributes: unsupported repository storage %T", repo.Storer) + } + info, err := gitattributes.ReadAttributesFile(storage.Filesystem(), nil, "info/attributes", true) + if err != nil { + return nil, err + } + attrs = append(attrs, info...) + + // go-git beta.1 walks the matcher stack backwards while overwriting prior + // results, so reverse Git's low-to-high source order before constructing it. + for i, j := 0, len(attrs)-1; i < j; i, j = i+1, j-1 { + attrs[i], attrs[j] = attrs[j], attrs[i] + } + + return gitattributes.NewMatcher(attrs), nil +} + +func configuredAttributes(path, repoRoot string) ([]gitattributes.MatchAttribute, error) { + if strings.HasPrefix(path, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + path = filepath.Join(home, strings.TrimPrefix(path, "~/")) + } else if !filepath.IsAbs(path) { + path = filepath.Join(repoRoot, path) + } + + return gitattributes.ReadAttributesFile(osfs.Default, nil, filepath.Clean(path), true) +} diff --git a/app/cli/internal/trace/git/gogit.go b/app/cli/internal/trace/git/gogit.go index f7f41b0ee..0dd07ea9c 100644 --- a/app/cli/internal/trace/git/gogit.go +++ b/app/cli/internal/trace/git/gogit.go @@ -18,12 +18,12 @@ package git import ( "crypto/sha256" "encoding/hex" + "errors" "fmt" "io" iofs "io/fs" "os" "path/filepath" - "sort" "strings" "time" @@ -266,35 +266,6 @@ func (c *GoGitClient) GeneratedMatcher(repoRoot string) func(path string) bool { } } -// 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) { @@ -339,9 +310,7 @@ func snapshotWorktree(repoRoot string) (map[string]string, error) { // 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.) + // snapshotted so edits to it are attributed. if matcher.Match(parts, false) && !tracked[slashRel] { return nil } @@ -354,6 +323,25 @@ func snapshotWorktree(repoRoot string) (map[string]string, error) { return nil }) + root, rootErr := os.OpenRoot(repoRoot) + if rootErr != nil { + return sig, errors.Join(err, fmt.Errorf("open worktree root: %w", rootErr)) + } + defer func() { _ = root.Close() }() + for path := range tracked { + if _, ok := sig[path]; ok { + continue + } + f, ok := openRegularRootFile(root, path) + if !ok { + continue + } + hash, hashErr := hashFileReader(f) + _ = f.Close() + if hashErr == nil { + sig[path] = hash + } + } return sig, err } @@ -366,20 +354,44 @@ func trackedPaths(repoRoot string) map[string]bool { if err != nil { return nil } - - idx, err := repo.Storer.Index() + idx, err := loadEffectiveIndex(repo) if err != nil { return nil } tracked := make(map[string]bool, len(idx.Entries)) - for _, e := range idx.Entries { - tracked[e.Name] = true + for _, entry := range idx.Entries { + if entry.IntentToAdd { + continue + } + tracked[entry.Name] = true } return tracked } +func openRegularRootFile(root *os.Root, path string) (*os.File, bool) { + parts := strings.Split(filepath.ToSlash(path), "/") + current := "" + for i, part := range parts { + current = filepath.Join(current, filepath.FromSlash(part)) + info, err := root.Lstat(current) + if err != nil || info.Mode()&os.ModeSymlink != 0 { + return nil, false + } + if i < len(parts)-1 { + if !info.IsDir() { + return nil, false + } + } else if !info.Mode().IsRegular() { + return nil, false + } + } + + f, err := root.Open(filepath.FromSlash(path)) + return f, err == nil +} + // hashFileContent returns the hex-encoded SHA-256 of the file's content. func hashFileContent(path string) (string, error) { f, err := os.Open(path) @@ -388,8 +400,12 @@ func hashFileContent(path string) (string, error) { } defer func() { _ = f.Close() }() + return hashFileReader(f) +} + +func hashFileReader(r io.Reader) (string, error) { h := sha256.New() - if _, err := io.Copy(h, f); err != nil { + if _, err := io.Copy(h, r); err != nil { return "", err } diff --git a/app/cli/internal/trace/git/index.go b/app/cli/internal/trace/git/index.go new file mode 100644 index 000000000..b9f66feac --- /dev/null +++ b/app/cli/internal/trace/git/index.go @@ -0,0 +1,241 @@ +// +// 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 ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + gogit "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing" + "github.com/go-git/go-git/v6/plumbing/filemode" + formatcfg "github.com/go-git/go-git/v6/plumbing/format/config" + formatindex "github.com/go-git/go-git/v6/plumbing/format/index" + gogithash "github.com/go-git/go-git/v6/plumbing/hash" + "github.com/go-git/go-git/v6/plumbing/object" +) + +// StagedFiles returns repo-relative paths changed between HEAD and the index +// used by the current git process. Git exposes temporary indexes (for example, +// during git commit --only) through GIT_INDEX_FILE. +func (c *GoGitClient) StagedFiles(repoRoot string) ([]string, error) { + repo, err := openRepo(repoRoot) + if err != nil { + return nil, fmt.Errorf("open repository: %w", err) + } + idx, err := loadEffectiveIndex(repo) + if err != nil { + return nil, err + } + + head, err := headEntries(repo) + if err != nil { + return nil, err + } + + staged := make(map[string]struct{}) + current := make(map[string]*formatindex.Entry) + for _, entry := range idx.Entries { + if entry.IntentToAdd { + continue + } + if entry.Stage != 0 { + staged[entry.Name] = struct{}{} + continue + } + current[entry.Name] = entry + } + + for path, entry := range current { + before, ok := head[path] + if !ok || before.hash != entry.Hash || before.mode != entry.Mode { + staged[path] = struct{}{} + } + } + for path := range head { + if _, ok := current[path]; !ok { + staged[path] = struct{}{} + } + } + + files := make([]string, 0, len(staged)) + for path := range staged { + files = append(files, path) + } + sort.Strings(files) + + return files, nil +} + +// SnapshotIndex returns the content signature currently staged in git's index. +// It excludes symlinks and submodules to match SnapshotWorktree's file scope. +func (c *GoGitClient) SnapshotIndex(repoRoot string) (map[string]string, error) { + repo, err := openRepo(repoRoot) + if err != nil { + return nil, fmt.Errorf("open repository: %w", err) + } + idx, err := loadEffectiveIndex(repo) + if err != nil { + return nil, err + } + + parts := make(map[string][]string, len(idx.Entries)) + for _, entry := range idx.Entries { + if !entry.Mode.IsFile() || entry.Mode == filemode.Symlink { + continue + } + parts[entry.Name] = append(parts[entry.Name], fmt.Sprintf("%d:%s", entry.Stage, entry.Hash)) + } + + sig := make(map[string]string, len(parts)) + for path, stages := range parts { + sort.Strings(stages) + sig[path] = strings.Join(stages, ",") + } + + return sig, nil +} + +// IndexFile returns the exact staged content for path. +func (c *GoGitClient) IndexFile(repoRoot, path string) ([]byte, error) { + repo, err := openRepo(repoRoot) + if err != nil { + return nil, fmt.Errorf("open repository: %w", err) + } + idx, err := loadEffectiveIndex(repo) + if err != nil { + return nil, err + } + + for _, entry := range idx.Entries { + if entry.Name != path || entry.Stage != 0 || !entry.Mode.IsFile() || entry.Mode == filemode.Symlink { + continue + } + + blob, err := repo.BlobObject(entry.Hash) + if err != nil { + return nil, fmt.Errorf("load staged blob %s: %w", path, err) + } + reader, err := blob.Reader() + if err != nil { + return nil, fmt.Errorf("read staged blob %s: %w", path, err) + } + defer func() { _ = reader.Close() }() + + content, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("read staged content %s: %w", path, err) + } + + return content, nil + } + + return nil, fmt.Errorf("staged file %s not found", path) +} + +type treeEntry struct { + hash plumbing.Hash + mode filemode.FileMode +} + +func headEntries(repo *gogit.Repository) (map[string]treeEntry, error) { + entries := make(map[string]treeEntry) + ref, err := repo.Head() + if errors.Is(err, plumbing.ErrReferenceNotFound) { + return entries, nil + } + if err != nil { + return nil, fmt.Errorf("load HEAD: %w", err) + } + commit, err := repo.CommitObject(ref.Hash()) + if err != nil { + return nil, fmt.Errorf("load HEAD commit: %w", err) + } + tree, err := commit.Tree() + if err != nil { + return nil, fmt.Errorf("load HEAD tree: %w", err) + } + + walker := object.NewTreeWalker(tree, true, nil) + defer walker.Close() + for { + name, entry, err := walker.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, fmt.Errorf("walk HEAD tree: %w", err) + } + if entry.Mode == filemode.Dir { + continue + } + entries[name] = treeEntry{hash: entry.Hash, mode: entry.Mode} + } + + return entries, nil +} + +func loadEffectiveIndex(repo *gogit.Repository) (*formatindex.Index, error) { + path := os.Getenv("GIT_INDEX_FILE") + if path == "" { + idx, err := repo.Storer.Index() + if err != nil { + return nil, fmt.Errorf("load index: %w", err) + } + + return idx, nil + } + + root, err := os.OpenRoot(filepath.Dir(path)) + if err != nil { + return nil, fmt.Errorf("open effective index directory: %w", err) + } + defer func() { _ = root.Close() }() + + f, err := root.Open(filepath.Base(path)) + if err != nil { + return nil, fmt.Errorf("open effective index: %w", err) + } + defer func() { _ = f.Close() }() + + cfg, err := repo.Config() + if err != nil { + return nil, fmt.Errorf("load repository config: %w", err) + } + objectFormat := cfg.Extensions.ObjectFormat + if objectFormat == formatcfg.UnsetObjectFormat { + objectFormat = formatcfg.DefaultObjectFormat + } + hasher, err := gogithash.FromObjectFormat(objectFormat) + if err != nil { + return nil, fmt.Errorf("select index hash: %w", err) + } + + idx := &formatindex.Index{} + if err := formatindex.NewDecoder(f, hasher).Decode(idx); err != nil { + return nil, fmt.Errorf("decode effective index: %w", err) + } + if stat, err := f.Stat(); err == nil { + idx.ModTime = stat.ModTime() + } + + return idx, nil +} diff --git a/app/cli/internal/trace/git/index_test.go b/app/cli/internal/trace/git/index_test.go new file mode 100644 index 000000000..7c7d69792 --- /dev/null +++ b/app/cli/internal/trace/git/index_test.go @@ -0,0 +1,120 @@ +// +// 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 ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGoGitClient_SnapshotIndex_ResolutionChangesSignature(t *testing.T) { + // Given + dir := t.TempDir() + runIndexGit(t, dir, "init", "-q") + runIndexGit(t, dir, "read-tree", "--empty") + base := writeGitObject(t, dir, "base\n") + ours := writeGitObject(t, dir, "ours\n") + theirs := writeGitObject(t, dir, "theirs\n") + writeGitIndex(t, dir, fmt.Sprintf( + "100644 %s 1\tconflict.txt\n100644 %s 2\tconflict.txt\n100644 %s 3\tconflict.txt\n", + base, ours, theirs, + )) + + client := NewGoGitClient() + before, err := client.SnapshotIndex(dir) + require.NoError(t, err) + + runIndexGit(t, dir, "read-tree", "--empty") + writeGitIndex(t, dir, fmt.Sprintf("100644 %s 0\tconflict.txt\n", theirs)) + + // When + after, err := client.SnapshotIndex(dir) + require.NoError(t, err) + + // Then + require.Contains(t, before, "conflict.txt") + require.Contains(t, after, "conflict.txt") + require.NotEqual(t, before["conflict.txt"], after["conflict.txt"], + "resolving to an existing conflict stage must change the index signature") +} + +func TestGoGitClient_IndexFile_IgnoresReplacementObjects(t *testing.T) { + // Given + dir := t.TempDir() + runIndexGit(t, dir, "init", "-q") + runIndexGit(t, dir, "read-tree", "--empty") + indexed := writeGitObject(t, dir, "indexed content\n") + replacement := writeGitObject(t, dir, "replacement content\n") + writeGitIndex(t, dir, fmt.Sprintf("100644 %s 0\tindexed.txt\n", indexed)) + runIndexGit(t, dir, "replace", indexed, replacement) + + // When + content, err := NewGoGitClient().IndexFile(dir, "indexed.txt") + require.NoError(t, err) + + // Then + require.Equal(t, []byte("indexed content\n"), content, + "staged content must come from the exact object recorded in the index") +} + +func TestGoGitClient_StagedFiles_ExcludesIntentToAdd(t *testing.T) { + dir := t.TempDir() + runIndexGit(t, dir, "init", "-q") + require.NoError(t, os.WriteFile(filepath.Join(dir, "pending.txt"), []byte("pending\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "staged.txt"), nil, 0o600)) + runIndexGit(t, dir, "add", "-N", "pending.txt") + runIndexGit(t, dir, "add", "staged.txt") + + files, err := NewGoGitClient().StagedFiles(dir) + require.NoError(t, err) + require.Equal(t, []string{"staged.txt"}, files) +} + +func runIndexGit(t *testing.T, dir string, args ...string) 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)) + + return strings.TrimSpace(string(out)) +} + +func writeGitIndex(t *testing.T, dir, entries string) { + t.Helper() + cmd := exec.Command("git", "update-index", "--index-info") + cmd.Dir = dir + cmd.Stdin = strings.NewReader(entries) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git update-index: %s", string(out)) +} + +func writeGitObject(t *testing.T, dir, content string) string { + t.Helper() + cmd := exec.Command("git", "hash-object", "-w", "--stdin") + cmd.Dir = dir + cmd.Stdin = strings.NewReader(content) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git hash-object: %s", string(out)) + + return strings.TrimSpace(string(out)) +} diff --git a/app/cli/internal/trace/git/worktree_index.go b/app/cli/internal/trace/git/worktree_index.go new file mode 100644 index 000000000..3b3b81217 --- /dev/null +++ b/app/cli/internal/trace/git/worktree_index.go @@ -0,0 +1,199 @@ +// +// 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 ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + gogit "github.com/go-git/go-git/v6" + gogitconfig "github.com/go-git/go-git/v6/config" + "github.com/go-git/go-git/v6/plumbing/filemode" + "github.com/go-git/go-git/v6/plumbing/format/gitattributes" + formatindex "github.com/go-git/go-git/v6/plumbing/format/index" + "github.com/go-git/go-git/v6/utils/convert" +) + +// UnstagedFiles returns regular-file paths whose working-tree content differs +// from the effective index. Paths are repo-relative and slash-separated. +func (c *GoGitClient) UnstagedFiles(repoRoot string) (map[string]struct{}, error) { + repo, err := openRepo(repoRoot) + if err != nil { + return nil, fmt.Errorf("open repository: %w", err) + } + idx, err := loadEffectiveIndex(repo) + if err != nil { + return nil, err + } + cfg, err := repo.Config() + if err != nil { + return nil, fmt.Errorf("load repository config: %w", err) + } + worktree, err := snapshotWorktree(repoRoot) + if err != nil { + return nil, fmt.Errorf("snapshot worktree: %w", err) + } + + indexed := make(map[string]*formatindex.Entry) + for _, entry := range idx.Entries { + if entry.Stage == 0 && !entry.IntentToAdd && entry.Mode.IsFile() && entry.Mode != filemode.Symlink { + indexed[entry.Name] = entry + } + } + attributes, err := attributeMatcher(repo, repoRoot, indexed) + if err != nil { + return nil, fmt.Errorf("load git attributes: %w", err) + } + + paths := make(map[string]struct{}) + for path := range worktree { + entry, ok := indexed[path] + if !ok { + paths[path] = struct{}{} + continue + } + same, err := worktreeMatchesIndex(repoRoot, path, entry, repo, cfg, attributes) + if err != nil { + return nil, err + } + if !same { + paths[path] = struct{}{} + } + } + for path := range indexed { + if _, ok := worktree[path]; !ok { + paths[path] = struct{}{} + } + } + + return paths, nil +} + +func worktreeMatchesIndex( + repoRoot, path string, + entry *formatindex.Entry, + repo *gogit.Repository, + cfg *gogitconfig.Config, + attributes gitattributes.Matcher, +) (bool, error) { + fullPath := filepath.Join(repoRoot, filepath.FromSlash(path)) + info, err := os.Stat(fullPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return false, fmt.Errorf("stat worktree file %s: %w", path, err) + } + if cfg.Core.FileMode { + mode, err := filemode.NewFromOSFileMode(info.Mode()) + if err != nil { + return false, fmt.Errorf("read worktree mode %s: %w", path, err) + } + if mode != entry.Mode { + return false, nil + } + } + + content, err := os.ReadFile(fullPath) + if err != nil { + return false, fmt.Errorf("read worktree file %s: %w", path, err) + } + + blob, err := repo.BlobObject(entry.Hash) + if err != nil { + return false, fmt.Errorf("load index blob %s: %w", path, err) + } + reader, err := blob.Reader() + if err != nil { + return false, fmt.Errorf("read index blob %s: %w", path, err) + } + defer func() { _ = reader.Close() }() + staged, err := io.ReadAll(reader) + if err != nil { + return false, fmt.Errorf("read index content %s: %w", path, err) + } + content, canCompare := cleanWorktreeContent(path, content, cfg, attributes) + if !canCompare { + return false, nil + } + + return bytes.Equal(content, staged), nil +} + +func cleanWorktreeContent( + path string, + content []byte, + cfg *gogitconfig.Config, + matcher gitattributes.Matcher, +) ([]byte, bool) { + result, _ := matcher.Match(strings.Split(filepath.ToSlash(path), "/"), nil) + if filter, ok := result["filter"]; ok && !filter.IsUnset() && !noOpCleanFilter(filter, cfg) { + // Reproducing an arbitrary configured filter driver would execute + // repository code, so transformed content stays conservatively dirty. + return nil, false + } + + normalize := cfg.Core.AutoCRLF == "true" || cfg.Core.AutoCRLF == "input" + forceText := false + if text, ok := result["text"]; ok { + switch { + case text.IsUnset(): + normalize = false + case text.IsSet(): + normalize, forceText = true, true + case text.IsValueSet() && strings.EqualFold(text.Value(), "auto"): + normalize = true + } + } + if eol, ok := result["eol"]; ok && !eol.IsUnset() { + normalize, forceText = true, true + } + if !normalize { + return content, true + } + + stat, err := convert.GetStat(bytes.NewReader(content)) + if err != nil || stat.IsBinary() && !forceText { + return content, true + } + var cleaned bytes.Buffer + if _, err := io.Copy(convert.NewLFWriter(&cleaned), bytes.NewReader(content)); err != nil { + return nil, false + } + + return cleaned.Bytes(), true +} + +func noOpCleanFilter(filter gitattributes.Attribute, cfg *gogitconfig.Config) bool { + if !filter.IsValueSet() { + return false + } + section := cfg.Raw.Section("filter") + if !section.HasSubsection(filter.Value()) { + return false + } + driver := section.Subsection(filter.Value()) + if strings.TrimSpace(driver.Options.Get("process")) != "" { + return false + } + clean := strings.TrimSpace(driver.Options.Get("clean")) + return clean == "" || clean == "cat" +} diff --git a/app/cli/internal/trace/git/worktree_index_test.go b/app/cli/internal/trace/git/worktree_index_test.go new file mode 100644 index 000000000..034d4ca50 --- /dev/null +++ b/app/cli/internal/trace/git/worktree_index_test.go @@ -0,0 +1,162 @@ +// +// 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 ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGoGitClient_UnstagedFiles_HonorsAttributeSources(t *testing.T) { + for _, source := range []string{"global", "local", "info"} { + t.Run(source, func(t *testing.T) { + dir := t.TempDir() + home := t.TempDir() + t.Setenv("HOME", home) + runIndexGit(t, dir, "init", "-q") + + switch source { + case "global": + attributes := filepath.Join(home, "attributes") + require.NoError(t, os.WriteFile(attributes, []byte("file.txt text eol=lf\n"), 0o600)) + config := "[core]\n\tattributesFile = ~/attributes\n" + require.NoError(t, os.WriteFile(filepath.Join(home, ".gitconfig"), []byte(config), 0o600)) + case "local": + globalAttributes := filepath.Join(home, "global-attributes") + require.NoError(t, os.WriteFile(globalAttributes, []byte("file.txt -text\n"), 0o600)) + globalConfig := fmt.Sprintf("[core]\n\tattributesFile = %s\n", globalAttributes) + require.NoError(t, os.WriteFile(filepath.Join(home, ".gitconfig"), []byte(globalConfig), 0o600)) + + localAttributes := filepath.Join(dir, "local-attributes") + require.NoError(t, os.WriteFile(localAttributes, []byte("file.txt text eol=lf\n"), 0o600)) + runIndexGit(t, dir, "config", "core.attributesFile", localAttributes) + case "info": + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".git", "info", "attributes"), + []byte("file.txt text eol=lf\n"), + 0o600, + )) + } + + require.NoError(t, os.WriteFile(filepath.Join(dir, "file.txt"), []byte("line\r\n"), 0o600)) + runIndexGit(t, dir, "add", "file.txt") + + content, err := NewGoGitClient().IndexFile(dir, "file.txt") + require.NoError(t, err) + require.Equal(t, []byte("line\n"), content) + + unstaged, err := NewGoGitClient().UnstagedFiles(dir) + require.NoError(t, err) + require.NotContains(t, unstaged, "file.txt") + }) + } +} + +func TestGoGitClient_UnstagedFiles_MissingNestedAttributes(t *testing.T) { + dir := t.TempDir() + runIndexGit(t, dir, "init", "-q") + require.NoError(t, os.MkdirAll(filepath.Join(dir, "nested"), 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "nested", "file.txt"), []byte("content\n"), 0o600)) + runIndexGit(t, dir, "add", "nested/file.txt") + + unstaged, err := NewGoGitClient().UnstagedFiles(dir) + require.NoError(t, err) + require.NotContains(t, unstaged, "nested/file.txt") +} + +func TestGoGitClient_UnstagedFiles_TrackedFileInIgnoredDirectory(t *testing.T) { + dir := t.TempDir() + runIndexGit(t, dir, "init", "-q") + require.NoError(t, os.MkdirAll(filepath.Join(dir, "build"), 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("build/\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "build", "tracked.txt"), []byte("tracked\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "build", "untracked.txt"), []byte("untracked\n"), 0o600)) + runIndexGit(t, dir, "add", ".gitignore") + runIndexGit(t, dir, "add", "-f", "build/tracked.txt") + + snapshot, err := NewGoGitClient().SnapshotWorktree(dir) + require.NoError(t, err) + require.Contains(t, snapshot, "build/tracked.txt") + require.NotContains(t, snapshot, "build/untracked.txt") + + unstaged, err := NewGoGitClient().UnstagedFiles(dir) + require.NoError(t, err) + require.NotContains(t, unstaged, "build/tracked.txt") + + require.NoError(t, os.WriteFile(filepath.Join(dir, "build", "tracked.txt"), []byte("changed\n"), 0o600)) + unstaged, err = NewGoGitClient().UnstagedFiles(dir) + require.NoError(t, err) + require.Contains(t, unstaged, "build/tracked.txt") +} + +func TestGoGitClient_SnapshotWorktree_ExcludesIgnoredIntentToAdd(t *testing.T) { + dir := t.TempDir() + runIndexGit(t, dir, "init", "-q") + require.NoError(t, os.MkdirAll(filepath.Join(dir, "build"), 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("build/\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "build", "pending.txt"), []byte("pending\n"), 0o600)) + runIndexGit(t, dir, "add", ".gitignore") + runIndexGit(t, dir, "add", "-N", "-f", "build/pending.txt") + + snapshot, err := NewGoGitClient().SnapshotWorktree(dir) + require.NoError(t, err) + require.NotContains(t, snapshot, "build/pending.txt") +} + +func TestGoGitClient_SnapshotWorktree_DoesNotFollowEscapingSymlink(t *testing.T) { + dir := t.TempDir() + outside := t.TempDir() + runIndexGit(t, dir, "init", "-q") + require.NoError(t, os.WriteFile(filepath.Join(outside, "outside.txt"), []byte("outside\n"), 0o600)) + require.NoError(t, os.Symlink(outside, filepath.Join(dir, "link"))) + blob := writeGitObject(t, dir, "outside\n") + writeGitIndex(t, dir, fmt.Sprintf("100644 %s 0\tlink/outside.txt\n", blob)) + + snapshot, err := NewGoGitClient().SnapshotWorktree(dir) + require.NoError(t, err) + require.NotContains(t, snapshot, "link/outside.txt") +} + +func TestGoGitClient_SnapshotWorktree_DoesNotFollowInRootSymlink(t *testing.T) { + dir := t.TempDir() + runIndexGit(t, dir, "init", "-q") + require.NoError(t, os.MkdirAll(filepath.Join(dir, "target"), 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "target", "file.txt"), []byte("inside\n"), 0o600)) + require.NoError(t, os.Symlink("target", filepath.Join(dir, "link"))) + blob := writeGitObject(t, dir, "inside\n") + writeGitIndex(t, dir, fmt.Sprintf("100644 %s 0\tlink/file.txt\n", blob)) + + snapshot, err := NewGoGitClient().SnapshotWorktree(dir) + require.NoError(t, err) + require.NotContains(t, snapshot, "link/file.txt") +} + +func TestGoGitClient_UnstagedFiles_ChangedFilterMarksRawEqualityDirty(t *testing.T) { + dir := t.TempDir() + runIndexGit(t, dir, "init", "-q") + require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitattributes"), []byte("filtered.txt filter=mutate\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "filtered.txt"), []byte("same bytes\n"), 0o600)) + runIndexGit(t, dir, "add", ".gitattributes", "filtered.txt") + runIndexGit(t, dir, "config", "filter.mutate.clean", "sed 's/^/clean:/'") + + unstaged, err := NewGoGitClient().UnstagedFiles(dir) + require.NoError(t, err) + require.Contains(t, unstaged, "filtered.txt") +} diff --git a/app/cli/internal/trace/provider.go b/app/cli/internal/trace/provider.go index fba5c355b..703908753 100644 --- a/app/cli/internal/trace/provider.go +++ b/app/cli/internal/trace/provider.go @@ -187,6 +187,13 @@ type SkillTracker interface { SkillRoots(repoRoot string) skill.Roots } +// ShellCommandTagger is implemented by a provider whose pre-tool hook can +// replace shell input before execution. The opaque token reaches git hooks +// through the command environment, tying a commit to one exact tool call. +type ShellCommandTagger interface { + TagShellCommand(input *HookInput, token string) error +} + // SpecCopyReplacer is implemented by a provider that can find the copies of // spec sources in its transcript (spec issue-3556). For the sessions of a // provider without it, the transcript keeps its copies. @@ -310,6 +317,9 @@ type HookInput struct { // Command is the command line of a shell command tool, set by provider's // ReadHookInput. Empty when the provider does not report it. Command string `json:"-"` + // RawToolInput is the provider's full tool input. A provider that rewrites + // shell input preserves every field and changes only Command. + RawToolInput json.RawMessage `json:"-"` // Cwd is the directory the agent session runs in, which is where the // agent files its transcripts. It can differ from the checkout that owns // FilePath, e.g. for an edit in a linked git worktree. Empty when the diff --git a/app/cli/internal/trace/state/snapshot.go b/app/cli/internal/trace/state/snapshot.go index 2be3411d6..cfe4debda 100644 --- a/app/cli/internal/trace/state/snapshot.go +++ b/app/cli/internal/trace/state/snapshot.go @@ -17,13 +17,18 @@ package state import ( "crypto/sha256" + "encoding/base64" "encoding/hex" "encoding/json" + "errors" "fmt" "os" "path/filepath" + "strings" ) +const ShellCallEnv = "CHAINLOOP_TRACE_SHELL_CALL" + // snapshotPath returns the path for a file snapshot: