Skip to content
Merged
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
4 changes: 4 additions & 0 deletions app/cli/internal/trace/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,11 @@ How the sessions are linked to the commits and attested.
|---------|---------------|-------------|--------|--------------|------------|
| Commit trailer | Each commit gets a `Chainloop-Trace-Sessions` trailer with the sessions that contributed to it. | Yes | Yes | Yes | Yes |
| Attestation at push | The `pre-push` hook attests the sessions of the pushed commits. | Yes | Yes | Yes | Yes |
| Shell commands in another checkout | A session can work in a checkout other than the one it started in through shell commands only, for example in a fresh clone. The checkout is found from the command text: the target of `cd` or `pushd`, the path of `git -C`, and the folder that `git clone` or `gh repo clone` creates. The git hooks are installed there and the session is recorded there, the same as for a file tool edit in that checkout. A clone gets the git hooks only after the command that created it, so a commit in that same command does not carry the session. For OpenCode, a plugin installed by an earlier version does not send the command text: install the hooks again to update it. | Yes | Partial | Yes | Yes |

Why not Yes:

- **Shell commands in another checkout, Cursor:** the shell hooks are verified for the Cursor IDE only. A relative path in the command resolves against the first workspace root, not against the folder where the shell runs.
## How each agent is hooked in

| Agent | Hook installation | Hooks |
Expand Down
2 changes: 2 additions & 0 deletions app/cli/internal/trace/claude/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ func (p *Provider) ReadHookInput(r io.Reader) (*trace.HookInput, error) {
trace.HookInput
ToolInput struct {
FilePath string `json:"file_path"`
Command string `json:"command"`
} `json:"tool_input"`
CursorVersion string `json:"cursor_version"`
}
Expand All @@ -226,6 +227,7 @@ func (p *Provider) ReadHookInput(r io.Reader) (*trace.HookInput, error) {
if raw.ToolInput.FilePath != "" {
input.FilePath = raw.ToolInput.FilePath
}
input.Command = raw.ToolInput.Command
input.ToolFailed = input.HookEventName == eventPostToolUseFailure

return &input, nil
Expand Down
13 changes: 13 additions & 0 deletions app/cli/internal/trace/claude/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,19 @@ func TestReadHookInput(t *testing.T) {
assert.Equal(t, "/some/file.go", input.FilePath)
})

t.Run("extracts the shell command", func(t *testing.T) {
r := bytes.NewBufferString(`{
"session_id": "abc-123",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {"command": "cd /tmp/repo-b && git commit -m x", "description": "commit"}
}`)
input, err := provider.ReadHookInput(r)
require.NoError(t, err)
assert.Equal(t, "cd /tmp/repo-b && git commit -m x", input.Command)
assert.Empty(t, 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)
Expand Down
11 changes: 7 additions & 4 deletions app/cli/internal/trace/opencode/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,8 +203,9 @@ async function toolEvent(directory: string, hook: string, hookEventName: string,
}
if (commandTools.includes(tool)) {
// The call ID pairs this hook with the other hook of the same call, so
// overlapping commands keep their own snapshots.
await fire(directory, hook, { ...payload, tool_use_id: callID })
// overlapping commands keep their own snapshots. The command line names
// the checkouts that the command works in.
await fire(directory, hook, { ...payload, tool_use_id: callID, command: args?.command ?? "" })
return
}
if (!fileWritingTools.includes(tool)) return
Expand Down Expand Up @@ -422,8 +423,8 @@ 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. A relative file_path is resolved against the working
// directory.
// tool_name, file_path, and for shell tools tool_use_id and command. A
// relative file_path is resolved against the working directory.
func (p *Provider) ReadHookInput(r io.Reader) (*trace.HookInput, error) {
data, err := io.ReadAll(io.LimitReader(r, maxHookPayloadBytes))
if err != nil {
Expand All @@ -437,6 +438,7 @@ func (p *Provider) ReadHookInput(r io.Reader) (*trace.HookInput, error) {
FilePath string `json:"file_path"`
ToolUseID string `json:"tool_use_id"`
SkillDir string `json:"skill_dir"`
Command string `json:"command"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
Expand Down Expand Up @@ -466,6 +468,7 @@ func (p *Provider) ReadHookInput(r io.Reader) (*trace.HookInput, error) {
FilePath: filePath,
ToolUseID: raw.ToolUseID,
SkillDir: skillDir,
Command: raw.Command,
}, nil
}

Expand Down
13 changes: 8 additions & 5 deletions app/cli/internal/trace/opencode/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,8 @@ func TestReadHookInputParsesValidInput(t *testing.T) {

// TestPluginSendsShellCallID pins that both entry points send the tool call ID
// as tool_use_id on shell hooks, so that the pre and post hooks of one command
// pair their snapshots. OpenCode 1.x names it callID, and OpenCode 2 id.
// pair their snapshots, and the command line, which names the checkouts that
// the command works in (PFM-7614). OpenCode 1.x names it callID, and OpenCode 2 id.
func TestPluginSendsShellCallID(t *testing.T) {
repoRoot := t.TempDir()
require.NoError(t, New().InstallHooks(repoRoot))
Expand All @@ -225,7 +226,7 @@ func TestPluginSendsShellCallID(t *testing.T) {
require.NoError(t, err)
content := string(data)

assert.Contains(t, content, "{ ...payload, tool_use_id: callID }")
assert.Contains(t, content, `{ ...payload, tool_use_id: callID, command: args?.command ?? "" }`)
// OpenCode 1.x.
assert.Contains(t, content, `toolEvent(directory, "pre-tool-use", "tool.execute.before", input.sessionID, input.tool, input.callID, output.args)`)
assert.Contains(t, content, `toolEvent(directory, "post-tool-use", "tool.execute.after", input.sessionID, input.tool, input.callID, input.args, input.tool === skillTool ? skillDirFrom(output) : "")`)
Expand Down Expand Up @@ -281,13 +282,15 @@ func TestReadHookInputParsesSkillDir(t *testing.T) {
}

// The plugin sends opencode's callID as tool_use_id on shell hooks, so that
// the pre and post hooks of one command pair their snapshots.
func TestReadHookInputParsesToolUseID(t *testing.T) {
r := bytes.NewBufferString(`{"session_id":"ses_1","hook_event_name":"tool.execute.before","tool_name":"bash","tool_use_id":"call_01"}`)
// the pre and post hooks of one command pair their snapshots. It also sends
// the command line.
func TestReadHookInputParsesShellCall(t *testing.T) {
r := bytes.NewBufferString(`{"session_id":"ses_1","hook_event_name":"tool.execute.before","tool_name":"bash","tool_use_id":"call_01","command":"cd /tmp/repo-b && git commit -m x"}`)
p := New()
input, err := p.ReadHookInput(r)
require.NoError(t, err)
assert.Equal(t, "call_01", input.ToolUseID)
assert.Equal(t, "cd /tmp/repo-b && git commit -m x", input.Command)
}

func TestReadHookInputApplyPatchSingleFile(t *testing.T) {
Expand Down
5 changes: 3 additions & 2 deletions app/cli/internal/trace/opencode/testdata/plugin_full.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,9 @@ async function toolEvent(directory: string, hook: string, hookEventName: string,
}
if (commandTools.includes(tool)) {
// The call ID pairs this hook with the other hook of the same call, so
// overlapping commands keep their own snapshots.
await fire(directory, hook, { ...payload, tool_use_id: callID })
// overlapping commands keep their own snapshots. The command line names
// the checkouts that the command works in.
await fire(directory, hook, { ...payload, tool_use_id: callID, command: args?.command ?? "" })
return
}
if (!fileWritingTools.includes(tool)) return
Expand Down
5 changes: 3 additions & 2 deletions app/cli/internal/trace/opencode/testdata/plugin_tracerun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,9 @@ async function toolEvent(directory: string, hook: string, hookEventName: string,
}
if (commandTools.includes(tool)) {
// The call ID pairs this hook with the other hook of the same call, so
// overlapping commands keep their own snapshots.
await fire(directory, hook, { ...payload, tool_use_id: callID })
// overlapping commands keep their own snapshots. The command line names
// the checkouts that the command works in.
await fire(directory, hook, { ...payload, tool_use_id: callID, command: args?.command ?? "" })
return
}
if (!fileWritingTools.includes(tool)) return
Expand Down
43 changes: 42 additions & 1 deletion app/cli/pkg/action/trace_agent_hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,9 @@ func HandleAgentPreToolUse(provider trace.Provider, log zerolog.Logger) error {
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.
// it and attribute the command's file changes to the AI. Register the
// checkouts the command names first, so the snapshot covers them.
registerCommandCheckouts(provider, input, repoRoot, log)
captureWorktreeSnapshot(store, repoRoot, shellCallKey(input), log)
case provider.IsFileWritingTool(input.ToolName):
if input.FilePath == "" {
Expand Down Expand Up @@ -391,6 +393,41 @@ func registerSessionCheckout(sessionID, fileRoot string, log zerolog.Logger) {
}
}

// registerCommandCheckouts handles each checkout that a shell command names as
// a file tool edit there is handled: it installs the git hooks, writes the
// session record, and adds the checkout to the session's checkout list
// (PFM-7614). Shell hooks run in the session's directory, so without this a
// session that works in another checkout through the shell only, for example
// in a fresh clone, never reaches it, and its commits there carry no session.
// homeRoot is the session's own checkout, which needs none of this.
func registerCommandCheckouts(provider trace.Provider, input *trace.HookInput, homeRoot string, log zerolog.Logger) {
if input.Command == "" {
return
}

cwd := input.Cwd
if cwd == "" {
cwd, _ = os.Getwd()
}

for _, dir := range commandCheckoutDirs(input.Command, cwd) {
// A directory that does not exist yet (the target of a clone, at the
// pre hook) must not resolve to a repository above it.
if _, err := os.Stat(dir); err != nil {
continue
}

store, root, err := state.LocateFrom(dir)
if err != nil || !store.IsGit() || sameDir(root, homeRoot) {
continue
}

log.Debug().Str("root", root).Msg("shell command works in another checkout")
ensureSessionTracked(provider, store, root, input, log)
registerSessionCheckout(input.SessionID, root, log)
}
}

// sameDir reports whether a and b name the same directory, ignoring symlinks
// (e.g. /var and /private/var on macOS).
func sameDir(a, b string) bool {
Expand Down Expand Up @@ -613,6 +650,10 @@ func HandleAgentPostToolUse(provider trace.Provider, log zerolog.Logger) error {
// every file the command changed to the AI.
recordCommandLineRanges(provider, input, store, repoRoot, log)

// A checkout that the command created (`git clone`) did not exist
// at the pre hook.
registerCommandCheckouts(provider, input, repoRoot, log)

// The command may have been a `git push`, whose pre-push hook attested
// a session and left its link behind. Show it now: the pre-push output
// went to this tool call's captured stderr, which the user does not
Expand Down
4 changes: 2 additions & 2 deletions app/cli/pkg/action/trace_agent_hook_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -919,8 +919,8 @@ func TestHandleAgentCommandTool_OtherCheckout(t *testing.T) {
homeAttr := homeStore.LoadAILineAttribution(sid)
assert.Empty(t, homeAttr.Files, "the session's own checkout did not change")

// Accepted gap: without a file tool edit there, a checkout is not
// snapshotted.
// Accepted gap: a checkout that no file tool edited and that the command
// does not name is not snapshotted.
assert.Empty(t, untouchedStore.LoadAILineAttribution(sid).Files)
assert.False(t, untouchedStore.SessionRecordExists(sid))

Expand Down
Loading
Loading