diff --git a/app/cli/cmd/trace_hook.go b/app/cli/cmd/trace_hook.go index ed1ec5f1a..0503ff29f 100644 --- a/app/cli/cmd/trace_hook.go +++ b/app/cli/cmd/trace_hook.go @@ -233,6 +233,8 @@ func newTraceHookCursorCmd() *cobra.Command { newTraceHookCursorSessionStartCmd(), newTraceHookCursorSessionEndCmd(), newTraceHookCursorAfterFileEditCmd(), + newTraceHookCursorPreToolUseCmd(), + newTraceHookCursorPostToolUseCmd(), ) return cmd @@ -280,6 +282,41 @@ func newTraceHookCursorAfterFileEditCmd() *cobra.Command { } } +func newTraceHookCursorPreToolUseCmd() *cobra.Command { + return &cobra.Command{ + Use: "pre-tool-use", + Short: "Snapshot the worktree before a Cursor shell command", + Annotations: map[string]string{ + "skipActionOptsInit": "true", + }, + RunE: func(_ *cobra.Command, _ []string) error { + InitHookLogger() + provider := cursor.New() + // Respond first: Cursor blocks the command when the response is + // not valid JSON, and the trace must never block the agent. + if err := provider.AllowToolUse(); err != nil { + return err + } + + return action.HandleAgentPreToolUse(provider, logger) + }, + } +} + +func newTraceHookCursorPostToolUseCmd() *cobra.Command { + return &cobra.Command{ + Use: "post-tool-use", + Short: "Record AI line ranges after a Cursor shell command", + Annotations: map[string]string{ + "skipActionOptsInit": "true", + }, + RunE: func(_ *cobra.Command, _ []string) error { + InitHookLogger() + return action.HandleAgentPostToolUse(cursor.New(), logger) + }, + } +} + func newTraceHookOpenCodeCmd() *cobra.Command { cmd := &cobra.Command{ Use: "opencode", diff --git a/app/cli/internal/trace/README.md b/app/cli/internal/trace/README.md index 6072463ae..36fb6fb61 100644 --- a/app/cli/internal/trace/README.md +++ b/app/cli/internal/trace/README.md @@ -34,12 +34,12 @@ Which lines of a commit the AI wrote. | Feature | What it means | Claude Code | Cursor | OpenCode 1.x | OpenCode 2 | |---------|---------------|-------------|--------|--------------|------------| | 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 | No | 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 | 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:** Cursor has no hook before and after a shell command. +- **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. ## User experience @@ -53,7 +53,7 @@ What the user sees in the agent. Why not Yes: - **Welcome message, Cursor and OpenCode:** the provider gives the session-start context to the model only. It has no channel that shows a message to the user at session start, so the welcome message is dropped. -- **Session link after push, Cursor:** Cursor has no hook after a shell command, so there is no point at which to show the link. +- **Session link after push, Cursor:** the `postToolUse` hook runs after a shell command, but its response has no field that Cursor shows to the user. The link stays pending until it expires. - **Session link after push, OpenCode:** the plugin hook fires after a shell command, but the response that shows a message to the user is not verified yet. The link stays pending until it expires. ## Specs and skills @@ -106,7 +106,7 @@ How the sessions are linked to the commits and attested. | Agent | Hook installation | Hooks | |-------|-------------------|-------| | Claude Code | `.claude/settings.json` | Session start and end, user prompt, and before and after each file and shell tool and the Skill tool. | -| Cursor | `.cursor/hooks.json` | Session start and end, and `afterFileEdit`. There is no shell hook and no prompt hook. | +| Cursor | `.cursor/hooks.json` | Session start and end, `afterFileEdit`, and `preToolUse` and `postToolUse` for the `Shell` tool. The `preToolUse` hook always allows the command. The provider installs no prompt hook. | | OpenCode | `.opencode/plugins/chainloop-trace.ts` | Session start and end, user prompt, and before and after each tool. One plugin covers both versions: `server()` for 1.x (1.3.4 or later) and `setup()` for 2. The provider picks the export command from `opencode --version`. | All agents share the git hooks `commit-msg`, `post-commit` and `pre-push`. diff --git a/app/cli/internal/trace/cursor/announce_test.go b/app/cli/internal/trace/cursor/announce_test.go index 094a99a5e..cbb96d417 100644 --- a/app/cli/internal/trace/cursor/announce_test.go +++ b/app/cli/internal/trace/cursor/announce_test.go @@ -89,6 +89,18 @@ func TestAnnouncePromptSubmit(t *testing.T) { assert.Empty(t, out) } +// TestAllowToolUse pins the preToolUse response: Cursor blocks the command +// when the response is not valid JSON. +func TestAllowToolUse(t *testing.T) { + out := captureStdout(t, func() { + require.NoError(t, New().AllowToolUse()) + }) + + var got map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &got)) + assert.Equal(t, map[string]any{"permission": "allow"}, got) +} + // captureStdout runs fn and returns what it wrote to stdout. func captureStdout(t *testing.T, fn func()) string { t.Helper() diff --git a/app/cli/internal/trace/cursor/hooks.go b/app/cli/internal/trace/cursor/hooks.go index 688712576..2246bbef4 100644 --- a/app/cli/internal/trace/cursor/hooks.go +++ b/app/cli/internal/trace/cursor/hooks.go @@ -42,23 +42,35 @@ const ( eventSessionStart = "sessionStart" eventSessionEnd = "sessionEnd" eventAfterFileEdit = "afterFileEdit" + eventPreToolUse = "preToolUse" + eventPostToolUse = "postToolUse" // syntheticEditToolName is the synthetic tool name set on HookInput for // afterFileEdit events so generic handlers can treat them like // Claude-style file-writing tool invocations. syntheticEditToolName = "edit" + + // shellToolName is the Cursor tool that runs shell commands. It is also + // the matcher of the preToolUse and postToolUse hooks, so Cursor runs + // them for shell commands only. + shellToolName = "Shell" ) type cursorHookEvent struct { event string command string + // matcher limits a tool hook to the tools whose name it matches. Empty + // for hooks that are not tool hooks. + matcher string } // cursorHookEvents is the full list of hooks installed by InstallHooks. var cursorHookEvents = []cursorHookEvent{ - {eventSessionStart, "chainloop trace hook cursor session-start"}, - {eventSessionEnd, "chainloop trace hook cursor session-end"}, - {eventAfterFileEdit, "chainloop trace hook cursor after-file-edit"}, + {event: eventSessionStart, command: "chainloop trace hook cursor session-start"}, + {event: eventSessionEnd, command: "chainloop trace hook cursor session-end"}, + {event: eventAfterFileEdit, command: "chainloop trace hook cursor after-file-edit"}, + {event: eventPreToolUse, command: "chainloop trace hook cursor pre-tool-use", matcher: shellToolName}, + {event: eventPostToolUse, command: "chainloop trace hook cursor post-tool-use", matcher: shellToolName}, } // SettingsFile returns the absolute path to .cursor/hooks.json for the given repo. @@ -100,7 +112,7 @@ func (p *Provider) installHookEvents(repoRoot string, events []cursorHookEvent) changed := false for _, h := range events { - if addHookEntry(hooks, h.event, h.command) { + if addHookEntry(hooks, h) { changed = true } } @@ -195,6 +207,14 @@ type cursorHookInput struct { WorkspaceRoots []string `json:"workspace_roots"` FilePath string `json:"file_path"` Edits []cursorHookEdit `json:"edits"` + // Cwd, ToolName, ToolUseID and ToolInput are set on preToolUse and + // postToolUse only. + Cwd string `json:"cwd"` + ToolName string `json:"tool_name"` + ToolUseID string `json:"tool_use_id"` + ToolInput struct { + Command string `json:"command"` + } `json:"tool_input"` } type cursorHookEdit struct { @@ -228,12 +248,17 @@ func (p *Provider) ReadHookInput(r io.Reader) (*trace.HookInput, error) { input := &trace.HookInput{ SessionID: sessionID, HookEventName: raw.HookEventName, + ToolName: raw.ToolName, + ToolUseID: raw.ToolUseID, FilePath: raw.FilePath, + Command: raw.ToolInput.Command, + Cwd: raw.Cwd, AgentVersion: raw.CursorVersion, Model: raw.Model, } - // Cursor files transcripts under the workspace it was opened on. A + // Cursor files transcripts under the workspace it was opened on, which + // is not the shell's cwd when a command runs in a subdirectory. A // multi-root workspace has no single answer; the first root is the one // Cursor lists first and the best guess available. if len(raw.WorkspaceRoots) > 0 { @@ -254,8 +279,8 @@ func (p *Provider) ReadHookInput(r io.Reader) (*trace.HookInput, error) { return input, nil } -func addHookEntry(hooks map[string]any, event, command string) bool { - entries, _ := hooks[event].([]any) +func addHookEntry(hooks map[string]any, h cursorHookEvent) bool { + entries, _ := hooks[h.event].([]any) for i, entry := range entries { if !entryContainsChainloopHook(entry) { @@ -263,16 +288,17 @@ func addHookEntry(hooks map[string]any, event, command string) bool { } m, _ := entry.(map[string]any) existingCmd, _ := m["command"].(string) - if existingCmd == command { + existingMatcher, _ := m["matcher"].(string) + if existingCmd == h.command && existingMatcher == h.matcher { return false } - entries[i] = buildHookEntry(command) - hooks[event] = entries + entries[i] = buildHookEntry(h) + hooks[h.event] = entries return true } - hooks[event] = append(entries, buildHookEntry(command)) + hooks[h.event] = append(entries, buildHookEntry(h)) return true } @@ -303,11 +329,16 @@ func removeHookEntry(hooks map[string]any, event string) bool { return true } -func buildHookEntry(command string) map[string]any { - return map[string]any{ - "command": command, +func buildHookEntry(h cursorHookEvent) map[string]any { + entry := map[string]any{ + "command": h.command, "timeout": defaultHookTimeoutSeconds, } + if h.matcher != "" { + entry["matcher"] = h.matcher + } + + return entry } func entryContainsChainloopHook(entry any) bool { diff --git a/app/cli/internal/trace/cursor/hooks_test.go b/app/cli/internal/trace/cursor/hooks_test.go index d42369d06..80bb4afad 100644 --- a/app/cli/internal/trace/cursor/hooks_test.go +++ b/app/cli/internal/trace/cursor/hooks_test.go @@ -251,3 +251,134 @@ func TestHooksInstalled(t *testing.T) { }) } } + +func TestInstallHooksShellToolHooks(t *testing.T) { + testCases := []struct { + name string + install func(p *Provider, repoRoot string) error + wantSessionEnd bool + }{ + {name: "install", install: (*Provider).InstallHooks, wantSessionEnd: true}, + {name: "install for trace run", install: (*Provider).InstallHooksForTraceRun}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + repoRoot := t.TempDir() + require.NoError(t, tc.install(New(), repoRoot)) + + hooks := readInstalledHooks(t, repoRoot) + for event, command := range map[string]string{ + eventPreToolUse: "chainloop trace hook cursor pre-tool-use", + eventPostToolUse: "chainloop trace hook cursor post-tool-use", + } { + entries, _ := hooks[event].([]any) + require.Len(t, entries, 1, "entries for %s", event) + entry, _ := entries[0].(map[string]any) + assert.Equal(t, command, entry["command"], "command for %s", event) + assert.Equal(t, shellToolName, entry["matcher"], "matcher for %s", event) + } + + _, hasSessionEnd := hooks[eventSessionEnd] + assert.Equal(t, tc.wantSessionEnd, hasSessionEnd, "sessionEnd installed") + }) + } +} + +func TestInstallHooksUpdatesChainloopEntryWithoutMatcher(t *testing.T) { + repoRoot := t.TempDir() + settingsPath := filepath.Join(repoRoot, settingsFile) + require.NoError(t, os.MkdirAll(filepath.Dir(settingsPath), 0755)) + require.NoError(t, os.WriteFile(settingsPath, []byte(`{"version":1,"hooks":{"preToolUse":[ + {"command":"./my-hook.sh"}, + {"command":"chainloop trace hook cursor pre-tool-use","timeout":30} + ]}}`), 0600)) + + require.NoError(t, New().InstallHooks(repoRoot)) + + entries, _ := readInstalledHooks(t, repoRoot)[eventPreToolUse].([]any) + require.Len(t, entries, 2) + user, _ := entries[0].(map[string]any) + assert.Equal(t, "./my-hook.sh", user["command"], "user hook kept") + chainloop, _ := entries[1].(map[string]any) + assert.Equal(t, shellToolName, chainloop["matcher"], "matcher added to chainloop hook") +} + +func TestReadHookInputShellTool(t *testing.T) { + testCases := []struct { + name string + payload string + wantCwd string + }{ + { + name: "workspace root wins over the shell cwd", + payload: `{ + "conversation_id": "conv-sh", + "hook_event_name": "preToolUse", + "workspace_roots": ["/abs/repo"], + "cwd": "/abs/repo/sub", + "tool_name": "Shell", + "tool_use_id": "call-1", + "tool_input": {"command": "sed -i s/a/b/ f.txt", "working_directory": "/abs/repo/sub"} + }`, + wantCwd: "/abs/repo", + }, + { + name: "shell cwd when no workspace root", + payload: `{ + "conversation_id": "conv-sh", + "hook_event_name": "postToolUse", + "cwd": "/abs/repo", + "tool_name": "Shell", + "tool_use_id": "call-1", + "tool_input": {"command": "sed -i s/a/b/ f.txt"}, + "tool_output": "{}" + }`, + wantCwd: "/abs/repo", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + in, err := New().ReadHookInput(bytes.NewReader([]byte(tc.payload))) + require.NoError(t, err) + assert.Equal(t, "conv-sh", in.SessionID, "SessionID") + assert.Equal(t, shellToolName, in.ToolName, "ToolName") + assert.Equal(t, "call-1", in.ToolUseID, "ToolUseID") + assert.Equal(t, "sed -i s/a/b/ f.txt", in.Command, "Command") + assert.Equal(t, tc.wantCwd, in.Cwd, "Cwd") + assert.Empty(t, in.FilePath, "FilePath") + }) + } +} + +func TestIsCommandTool(t *testing.T) { + testCases := []struct { + toolName string + want bool + }{ + {toolName: "Shell", want: true}, + {toolName: syntheticEditToolName}, + {toolName: "Read"}, + {toolName: ""}, + } + + for _, tc := range testCases { + t.Run(tc.toolName, func(t *testing.T) { + assert.Equal(t, tc.want, New().IsCommandTool(tc.toolName)) + }) + } +} + +func readInstalledHooks(t *testing.T, repoRoot string) map[string]any { + t.Helper() + + data, err := os.ReadFile(filepath.Join(repoRoot, settingsFile)) + require.NoError(t, err) + + var out map[string]any + require.NoError(t, json.Unmarshal(data, &out)) + hooks, _ := out["hooks"].(map[string]any) + + return hooks +} diff --git a/app/cli/internal/trace/cursor/provider.go b/app/cli/internal/trace/cursor/provider.go index 9768dd2f3..01c64839c 100644 --- a/app/cli/internal/trace/cursor/provider.go +++ b/app/cli/internal/trace/cursor/provider.go @@ -57,17 +57,16 @@ func (p *Provider) Name() string { } // IsFileWritingTool returns true for the synthetic "edit" tool name we set on -// afterFileEdit hook inputs. Cursor has no tool-name concept for generic -// preToolUse/postToolUse events in our flow, so "edit" is the sole signal. +// afterFileEdit hook inputs. The preToolUse/postToolUse hooks are installed +// for the shell tool only, so "edit" is the sole file-writing signal. func (p *Provider) IsFileWritingTool(toolName string) bool { return toolName == syntheticEditToolName } -// IsCommandTool always returns false for Cursor: shell-execution hooks are not -// yet wired (Cursor installs only sessionStart/sessionEnd/afterFileEdit), so -// shell-driven file changes are not captured for this provider. -func (p *Provider) IsCommandTool(_ string) bool { - return false +// IsCommandTool returns true for Cursor's shell tool, whose preToolUse and +// postToolUse hooks snapshot the worktree around each command. +func (p *Provider) IsCommandTool(toolName string) bool { + return toolName == shellToolName } // AnnounceSessionStart writes Cursor's sessionStart hook response. Cursor adds @@ -114,8 +113,18 @@ func (p *Provider) AnnouncePromptSubmit(_ string) error { return nil } -// AnnounceToUser is unsupported for Cursor: it installs only sessionStart, -// sessionEnd and afterFileEdit, so no hook fires after a shell command and +// AllowToolUse writes the preToolUse hook response that lets the tool call +// proceed. Cursor blocks the call when a preToolUse hook writes a response +// that is not valid JSON or does not match the schema, so the pre hook writes +// this response whatever the trace does. +func (p *Provider) AllowToolUse() error { + return json.NewEncoder(os.Stdout).Encode(struct { + Permission string `json:"permission"` + }{Permission: "allow"}) +} + +// AnnounceToUser is unsupported for Cursor: the postToolUse response that +// runs after a shell command has no field that Cursor shows to the user, so // there is nowhere to deliver the message. func (p *Provider) AnnounceToUser(_ string) error { return trace.ErrAnnounceUnsupported diff --git a/app/cli/internal/trace/provider.go b/app/cli/internal/trace/provider.go index 5cda563a1..fba5c355b 100644 --- a/app/cli/internal/trace/provider.go +++ b/app/cli/internal/trace/provider.go @@ -307,6 +307,9 @@ type HookInput struct { ToolName string `json:"tool_name,omitempty"` // FilePath is the absolute path of the file being edited, set by provider's ReadHookInput. FilePath string `json:"-"` + // 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:"-"` // 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/pkg/action/trace_agent_hook.go b/app/cli/pkg/action/trace_agent_hook.go index ace543ffc..d2ca583fc 100644 --- a/app/cli/pkg/action/trace_agent_hook.go +++ b/app/cli/pkg/action/trace_agent_hook.go @@ -605,7 +605,7 @@ func HandleAgentPostToolUse(provider trace.Provider, log zerolog.Logger) error { } // Guarantees session tracking + git-hook install when afterFileEdit is - // the first hook we see (Cursor has no pre-tool-use). + // the first hook we see (Cursor has no pre hook for file edits). ensureSessionTracked(provider, store, repoRoot, input, log) if isCommand { @@ -625,7 +625,8 @@ func HandleAgentPostToolUse(provider trace.Provider, log zerolog.Logger) error { return nil } - // Cursor has no pre-tool-use, so the post hook registers the checkout too. + // Cursor has no pre hook for file edits, so the post hook registers the + // checkout too. registerSessionCheckout(sessionID, repoRoot, log) after, err := os.ReadFile(input.FilePath) diff --git a/app/cli/pkg/action/trace_agent_hook_test.go b/app/cli/pkg/action/trace_agent_hook_test.go index ee3b95651..7e9c00473 100644 --- a/app/cli/pkg/action/trace_agent_hook_test.go +++ b/app/cli/pkg/action/trace_agent_hook_test.go @@ -674,6 +674,34 @@ func TestHandleAgentCommandTool_AttributesShellFileChanges(t *testing.T) { assert.Error(t, err) } +// TestHandleAgentCursorShellCommand drives the pre/post-tool-use handlers with +// the preToolUse and postToolUse payloads Cursor sends for its Shell tool, and +// asserts the file the command created is attributed to the AI. +func TestHandleAgentCursorShellCommand(t *testing.T) { + dir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + p := cursor.New() + const toolInput = `"tool_name":"Shell","tool_use_id":"call-1","tool_input":{"command":"seq 3 > generated.txt"}` + + withStdin(t, `{"conversation_id":"conv-cmd","hook_event_name":"preToolUse",`+toolInput+`}`) + require.NoError(t, HandleAgentPreToolUse(p, zerolog.Nop())) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "generated.txt"), []byte("1\n2\n3\n"), 0600)) + + withStdin(t, `{"conversation_id":"conv-cmd","hook_event_name":"postToolUse",`+toolInput+`,"tool_output":"{}"}`) + require.NoError(t, HandleAgentPostToolUse(p, zerolog.Nop())) + + attr := store.LoadAILineAttribution("conv-cmd") + require.Contains(t, attr.Files, "generated.txt") + require.NotEmpty(t, attr.Files["generated.txt"]) + assert.Equal(t, 3, attr.Files["generated.txt"][0].End) + + _, err := store.LoadShellPreSignature(state.ShellCallKey{SessionID: "conv-cmd", ToolUseID: "call-1"}) + assert.Error(t, err, "pre-command signature is cleaned up") +} + // TestHandleAgentClaudeCodeSession drives the pre/post-tool-use handlers with // the real JSON payloads Claude Code pipes to its hooks, across a full session: // SessionStart → Write (new file) → Edit (modify it) → Bash (generate a file).