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 @@ -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

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