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
37 changes: 37 additions & 0 deletions app/cli/cmd/trace_hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,8 @@ func newTraceHookCursorCmd() *cobra.Command {
newTraceHookCursorSessionStartCmd(),
newTraceHookCursorSessionEndCmd(),
newTraceHookCursorAfterFileEditCmd(),
newTraceHookCursorPreToolUseCmd(),
newTraceHookCursorPostToolUseCmd(),
)

return cmd
Expand Down Expand Up @@ -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",
Expand Down
8 changes: 4 additions & 4 deletions app/cli/internal/trace/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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`.
12 changes: 12 additions & 0 deletions app/cli/internal/trace/cursor/announce_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
59 changes: 45 additions & 14 deletions app/cli/internal/trace/cursor/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -254,25 +279,26 @@ 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) {
continue
}
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
}
Expand Down Expand Up @@ -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 {
Expand Down
131 changes: 131 additions & 0 deletions app/cli/internal/trace/cursor/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading
Loading