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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions 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 @@ -82,6 +83,8 @@ Why not Yes:
- **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.
- **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, 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)
}
142 changes: 142 additions & 0 deletions app/cli/internal/trace/git/attributes.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading