From 0550e47c22cd407ba525489179c6120d17de3400 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Wed, 14 Oct 2026 20:08:29 +0200 Subject: [PATCH] feat(trace): install the Cursor shell hooks The Cursor trace provider now installs the preToolUse and postToolUse hooks for the Shell tool. The hooks snapshot the worktree around each shell command, so the files that a command changes are attributed to the AI. The pre hook always answers with an allow response, because Cursor blocks the command when the response is not valid JSON. Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: 1462003f-fcb1-4663-b058-5ae4892fd34f --- app/cli/cmd/trace_hook.go | 37 +++++ app/cli/internal/trace/README.md | 8 +- .../internal/trace/cursor/announce_test.go | 12 ++ app/cli/internal/trace/cursor/hooks.go | 59 ++++++-- app/cli/internal/trace/cursor/hooks_test.go | 131 ++++++++++++++++++ app/cli/internal/trace/cursor/provider.go | 27 ++-- app/cli/internal/trace/provider.go | 3 + app/cli/pkg/action/trace_agent_hook.go | 5 +- app/cli/pkg/action/trace_agent_hook_test.go | 28 ++++ 9 files changed, 281 insertions(+), 29 deletions(-) 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).