Skip to content
5 changes: 4 additions & 1 deletion app/cli/internal/trace/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ Which lines of a commit the AI wrote.
| Partial commits | A commit that holds only part of the AI lines of a file credits the session with those lines. The other AI lines stay pending, and the next commit with them is credited to the session too (spec issue-3639). | Yes | Partial | Yes | Yes | No | Yes |
| Generated line counts | The trace state keeps the lines that each agent edit added and removed, also the lines that never reach a commit. The evidence reports the totals of the session in `ai_generated_lines_added` and `ai_generated_lines_removed`. A session with no commit sends no evidence, so it reports no totals (spec issue-3639). | Yes | Partial | Yes | Yes | No | 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. | Partial | Partial | Partial | Partial | No | Partial |
| 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 | No | No |
| Net contribution | The added lines of a session are the lines that its commits added and that are still in the commit that the push sends. A line that a later commit replaced does not count, and each line counts one time. The removed lines are the removals of the session's commits, less the lines that a commit of the session wrote. The line counts of each file are the sum of its AI and human lines, and a file that the session created stays created when a later commit of the session changes it (spec issue-3639, issue 3674). | Partial | Partial | Partial | Partial | No | Partial |
| Session updates | A later commit that replaces lines of a session sends the evidence of the session again at the next push, with the new numbers. A commit in no session that replaces lines of a session joins it: the commit list of the session holds the commit, and its replacing lines count as human (spec issue-3639). | Partial | Partial | Partial | Partial | No | Partial |
| Line ranges | The line ranges of each file are positions in the file at the commit that the push sends. They cover the lines of the session that are still there, and each range says if its lines are AI or human. The AI ranges hold as many lines as the net AI lines added, and the human ranges as many lines as the net human lines added. A file that the pushed commit no longer holds has no ranges. A range with no attribution is an AI range at edit time. Older CLIs write only these ranges. This CLI writes them for a file that it cannot blame at the pushed commit, and for a file with edits from before line fingerprints (spec issue-3639, line ranges). | Partial | Partial | Partial | Partial | No | Partial |
Expand All @@ -81,7 +82,9 @@ Why not Yes:
- **Net contribution and line ranges, all agents:** the push finds the last writer of each line with `git blame`. A later commit that only re-indents a line takes the line, because blame sees a change. For Cursor, the limits of line attribution apply too.
- **Session updates, all agents:** the update works only in the clone where the work happened, while the CLI keeps the trace state of the session (30 days), and only when the commits of the session are on the branch that the push sends. The push reads only the commits that are not on the default branch of the remote (`origin/HEAD`, or else `origin/main` or `origin/master`). After the commits of a session are pushed to that branch, a later commit does not update the session.
- **Renames and rewrites, all agents:** this works only in the clone where the work happened. Git finds a rename only when the file keeps at least half of its content. Two pushed commits can rename a file one after the other. A later change to the file under its middle name does not send the session again. A rename inside an amend or a squash of the session commit loses the AI lines: the new commit creates the file under its new name, so no line of it matches the trace state (issue 3686). The limits of session updates apply too. For Cursor, the limits of line attribution apply too.
- **Shell changes attribution, all agents:** the hook before a shell command records only a hash of each file, not its content. The file at the `HEAD` commit from before the command stands in for the content before the command. Uncommitted lines that an earlier agent edit recorded are not recorded again, but uncommitted human lines of a file that the command changes count as AI.
- **Shell changes attribution, all agents:** the hook before a shell command records only a hash of each file, not its content. The file at the `HEAD` commit from before the command stands in for the content before the command. Uncommitted lines that an earlier agent edit recorded are not recorded again, but uncommitted human lines of a file that the command changes count as AI. Tracked files below ignored directories can be omitted from the snapshot ([issue 3700](https://github.com/chainloop-dev/chainloop/issues/3700)).
- **Same-call commit attribution, Claude Code:** supported for foreground Bash calls that use the repository's normal index. Temporary indexes such as `GIT_INDEX_FILE` and `git commit --only` are tracked in [issue 3698](https://github.com/chainloop-dev/chainloop/issues/3698). Worktree content is compared with the staged blob as raw bytes; Git attributes, line-ending normalization and clean filters are tracked in [issue 3699](https://github.com/chainloop-dev/chainloop/issues/3699). Background calls, competing input-rewriting hooks, narrow permissions and other providers are tracked in [issue 3701](https://github.com/chainloop-dev/chainloop/issues/3701).
- **Same-call commit attribution, Cursor, OpenCode, Pi and Codex:** these providers do not tag a shell command with the per-call token that git hooks use to load its open snapshot.

## User experience

Expand Down
23 changes: 15 additions & 8 deletions app/cli/internal/trace/claude/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
32 changes: 32 additions & 0 deletions app/cli/internal/trace/claude/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down Expand Up @@ -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 {
Expand Down
56 changes: 56 additions & 0 deletions app/cli/internal/trace/claude/shell_tag_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
83 changes: 83 additions & 0 deletions app/cli/internal/trace/git/index.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
//
// 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"
"io"

"github.com/go-git/go-git/v6/plumbing/filemode"
)

// 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) {
Comment thread
waveywaves marked this conversation as resolved.
repo, err := openRepo(repoRoot)
if err != nil {
return nil, fmt.Errorf("open repository: %w", err)
}
idx, err := repo.Storer.Index()
if err != nil {
return nil, fmt.Errorf("load index: %w", err)
}

sig := make(map[string]string, len(idx.Entries))
for _, entry := range idx.Entries {
if entry.Stage != 0 || !entry.Mode.IsFile() || entry.Mode == filemode.Symlink {
continue
}
sig[entry.Name] = entry.Hash.String()
}

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 := repo.Storer.Index()
if err != nil {
return nil, fmt.Errorf("load index: %w", 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)
}
55 changes: 55 additions & 0 deletions app/cli/internal/trace/git/index_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//
// 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 (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/require"
)

func TestGoGitClient_SnapshotIndexAndIndexFile_ReadStagedContent(t *testing.T) {
// Given
dir := t.TempDir()
runIndexGit(t, dir, "init", "-q")
require.NoError(t, os.WriteFile(filepath.Join(dir, "staged.txt"), []byte("staged content\n"), 0o600))
runIndexGit(t, dir, "add", "staged.txt")

// When
client := NewGoGitClient()
snapshot, err := client.SnapshotIndex(dir)
require.NoError(t, err)
content, err := client.IndexFile(dir, "staged.txt")
require.NoError(t, err)

// Then
require.Contains(t, snapshot, "staged.txt")
require.Equal(t, []byte("staged content\n"), content)
}

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))
}
10 changes: 10 additions & 0 deletions app/cli/internal/trace/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -313,6 +320,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
Expand Down
Loading
Loading