From 4ad324b8362801236ad1f153f88cde3d24e15d6c Mon Sep 17 00:00:00 2001 From: raj-khan Date: Sat, 24 Oct 2026 13:50:12 +0800 Subject: [PATCH] Add Cursor support init wires preToolUse, postToolUse and sessionStart in .cursor/hooks.json when the project uses Cursor or --agents names it. A clean call prints an explicit allow, since Cursor blocks on empty output from a permission hook. --- backlog/tasks/task-3 - Support-Cursor.md | 11 +- src/agents/claude.ts | 1 + src/agents/cursor.ts | 124 +++++++++++++++++++++++ src/agents/index.ts | 5 +- src/agents/types.ts | 2 + src/hook.ts | 11 +- src/index.ts | 2 +- src/settings.ts | 7 +- test/cursor.test.js | 106 +++++++++++++++++++ 9 files changed, 254 insertions(+), 15 deletions(-) create mode 100644 src/agents/cursor.ts create mode 100644 test/cursor.test.js diff --git a/backlog/tasks/task-3 - Support-Cursor.md b/backlog/tasks/task-3 - Support-Cursor.md index bfd0f19..8853a13 100644 --- a/backlog/tasks/task-3 - Support-Cursor.md +++ b/backlog/tasks/task-3 - Support-Cursor.md @@ -1,9 +1,10 @@ --- id: TASK-3 title: Support Cursor -status: To Do +status: Done assignee: [] created_date: '2026-10-24 05:41' +updated_date: '2026-10-24 05:50' labels: - multi-agent dependencies: @@ -22,8 +23,8 @@ Register preToolUse, postToolUse and sessionStart in .cursor/hooks.json (version ## Acceptance Criteria -- [ ] #1 A read of a decoy through Cursor's preToolUse is denied and recorded -- [ ] #2 A clean call prints an explicit allow -- [ ] #3 postToolUse catches a canary in tool_output -- [ ] #4 Removing the hook from .cursor/hooks.json trips tamper +- [x] #1 A read of a decoy through Cursor's preToolUse is denied and recorded +- [x] #2 A clean call prints an explicit allow +- [x] #3 postToolUse catches a canary in tool_output +- [x] #4 Removing the hook from .cursor/hooks.json trips tamper diff --git a/src/agents/claude.ts b/src/agents/claude.ts index 670ffaf..9f99e14 100644 --- a/src/agents/claude.ts +++ b/src/agents/claude.ts @@ -14,6 +14,7 @@ export const claude: Agent = { name: "claude", label: "Claude Code", configPath: SETTINGS_PATH, + alsoProtect: [".claude/settings.local.json"], hookCommand: HOOK_COMMAND, sessionCommand: SESSION_COMMAND, // Claude Code is always wired by default, whether or not .claude/ exists yet. diff --git a/src/agents/cursor.ts b/src/agents/cursor.ts new file mode 100644 index 0000000..53c251a --- /dev/null +++ b/src/agents/cursor.ts @@ -0,0 +1,124 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { load, save } from "../settings.js"; +import type { Agent, ToolCall } from "./types.js"; + +const CONFIG = ".cursor/hooks.json"; +const HOOK = "canaryhook hook --agent cursor"; +const SESSION = "canaryhook session-start --agent cursor"; + +/** Cursor's hooks.json: a flat list of commands per event, no matcher groups. */ +const EVENTS: [string, string][] = [ + ["preToolUse", HOOK], + ["postToolUse", HOOK], + ["sessionStart", SESSION], +]; + +interface Handler { + command?: string; + [key: string]: unknown; +} + +interface CursorInput { + conversation_id?: string; + hook_event_name?: string; + cwd?: string; + workspace_roots?: string[]; + tool_name?: string; + tool_input?: unknown; + tool_output?: unknown; +} + +function handlers(root: string): Record { + const config = load(root, CONFIG); + const hooks = config.hooks; + return hooks && typeof hooks === "object" ? (hooks as Record) : {}; +} + +export const cursor: Agent = { + name: "cursor", + label: "Cursor", + configPath: CONFIG, + hookCommand: HOOK, + sessionCommand: SESSION, + detect: (root) => existsSync(join(root, ".cursor")), + + wiring(root) { + const hooks = handlers(root); + const wired = (event: string, command: string) => + Array.isArray(hooks[event]) && hooks[event].some((h) => h.command === command); + const [pre, post, session] = EVENTS.map(([event, command]) => wired(event, command)); + return { pre, post, session }; + }, + register(root) { + const config = load(root, CONFIG); + config.version ??= 1; + const hooks = (config.hooks && typeof config.hooks === "object" ? config.hooks : {}) as Record; + let added = false; + for (const [event, command] of EVENTS) { + const list = Array.isArray(hooks[event]) ? hooks[event] : []; + if (list.some((h) => h.command === command)) continue; + hooks[event] = [...list, { command }]; + added = true; + } + config.hooks = hooks; + if (added) save(root, CONFIG, config); + return added; + }, + unregister(root) { + if (!existsSync(join(root, CONFIG))) return false; + const config = load(root, CONFIG); + const hooks = handlers(root); + const ours = new Set([HOOK, SESSION]); + let removed = false; + for (const [event, list] of Object.entries(hooks)) { + if (!Array.isArray(list)) continue; + const kept = list.filter((h) => !(h.command !== undefined && ours.has(h.command))); + if (kept.length !== list.length) removed = true; + if (kept.length > 0) hooks[event] = kept; + else delete hooks[event]; + } + if (!removed) return false; + config.hooks = hooks; + save(root, CONFIG, config); + return true; + }, + + parse(payload) { + if (!payload || typeof payload !== "object") return null; + const input = payload as CursorInput; + const call: ToolCall = { + phase: input.hook_event_name === "postToolUse" ? "post" : "pre", + dirs: [input.cwd, ...(input.workspace_roots ?? []), process.env.CURSOR_PROJECT_DIR, process.cwd()], + tool: input.tool_name ?? "unknown", + input: input.tool_input, + output: input.tool_output, + sessionId: input.conversation_id, + }; + return call; + }, + // Cursor blocks the call when a permission hook prints nothing or something + // it cannot parse, so a clean call needs an explicit allow. + pass: (phase) => (phase === "pre" ? JSON.stringify({ permission: "allow" }) : ""), + deny: (reason) => JSON.stringify({ permission: "deny", user_message: reason, agent_message: reason }), + flag: (reason) => JSON.stringify({ additional_context: reason }), + report: (context) => JSON.stringify({ additional_context: context }), + + probe: (root, path) => ({ + conversation_id: "canaryhook-doctor", + hook_event_name: "preToolUse", + cwd: root, + workspace_roots: [root], + tool_name: "Read", + tool_input: { file_path: path }, + }), + verdict(stdout) { + try { + const permission = (JSON.parse(stdout) as { permission?: string }).permission; + if (permission === "allow") return "pass"; + return permission ?? null; + } catch { + return null; + } + }, +}; diff --git a/src/agents/index.ts b/src/agents/index.ts index c948e5a..3f196a0 100644 --- a/src/agents/index.ts +++ b/src/agents/index.ts @@ -1,11 +1,12 @@ import { claude } from "./claude.js"; +import { cursor } from "./cursor.js"; import type { State } from "../state.js"; import type { Agent } from "./types.js"; export type { Agent, ToolCall, Wiring } from "./types.js"; /** Every agent canaryhook can wire, in the order they are reported. */ -export const AGENTS: Agent[] = [claude]; +export const AGENTS: Agent[] = [claude, cursor]; export function agentNamed(name: string): Agent | undefined { return AGENTS.find((agent) => agent.name === name); @@ -27,4 +28,4 @@ export function wiredAgents(state: State): Agent[] { return (state.agents ?? [claude.name]).flatMap((name) => agentNamed(name) ?? []); } -export { claude }; +export { claude, cursor }; diff --git a/src/agents/types.ts b/src/agents/types.ts index 8e4ad0b..db3645a 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -30,6 +30,8 @@ export interface Agent { label: string; /** Project-relative file the hooks are registered in. */ configPath: string; + /** Other config files that can switch the hooks off without holding them. */ + alsoProtect?: string[]; hookCommand: string; sessionCommand: string; /** Whether the project already uses this agent, so init wires it by default. */ diff --git a/src/hook.ts b/src/hook.ts index 4e9f7e9..a3237d4 100644 --- a/src/hook.ts +++ b/src/hook.ts @@ -1,5 +1,5 @@ import { basename } from "node:path"; -import { claude, type Agent } from "./agents/index.js"; +import { AGENTS, claude, type Agent } from "./agents/index.js"; import { appendAlert, findRoot, readState, type Alert, type Decoy } from "./state.js"; import { HOOK_COMMAND } from "./settings.js"; import { STATE_DIR, STATE_FILE } from "./state.js"; @@ -174,8 +174,9 @@ const MUTATING = new Set(["rm", "unlink", "shred", "truncate", "mv", "cp", "ln", /** Commands that only change a file when told to edit it in place. */ const IN_PLACE = new Set(["sed", "perl"]); -/** Settings files Claude Code reads hooks from, where the hook can be switched off. */ -const SETTINGS_FILES = [".claude/settings.json", ".claude/settings.local.json"]; +/** Config files the agents read hooks from, where canaryhook's hook can be removed or switched off. */ +const HOOK_FILES = AGENTS.map((agent) => agent.configPath); +const SETTINGS_FILES = [...HOOK_FILES, ...AGENTS.flatMap((agent) => agent.alsoProtect ?? [])]; /** Fields the editing tools carry the new content in. */ const WRITE_FIELDS = ["content", "new_string", "edits", "new_source"]; @@ -214,7 +215,9 @@ export function tampers(toolInput: unknown): string | null { !String(edit.new_string ?? "").includes(HOOK_COMMAND), ); const rewritesWithout = - typeof fields.content === "string" && path.endsWith("settings.json") && !fields.content.includes(HOOK_COMMAND); + typeof fields.content === "string" && + HOOK_FILES.some((file) => mentionsPath(path, file)) && + !fields.content.includes(HOOK_COMMAND); if (dropsHook || rewritesWithout) return "tool call removes canaryhook's hook from the settings"; return null; } diff --git a/src/index.ts b/src/index.ts index 1fa9dfc..ab6cf32 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,7 +28,7 @@ Commands Options --json Machine-readable output - --agents init: agents to wire, comma-separated (claude); default is + --agents init: agents to wire, comma-separated (claude, cursor); default is Claude Code plus any agent the project already uses --commit init: track the decoys in git instead of ignoring them --force init: replant over an existing setup diff --git a/src/settings.ts b/src/settings.ts index a35265f..4ac1034 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -17,7 +17,7 @@ interface MatcherGroup { [key: string]: unknown; } -interface Settings { +export interface Settings { hooks?: Record; [key: string]: unknown; } @@ -41,7 +41,8 @@ export const SPECS: HookSpec[] = [ /** Raised instead of silently replacing settings canaryhook cannot parse. */ export class SettingsError extends Error {} -function load(root: string, file: string): Settings { +/** A JSON config file as an object; {} when it does not exist yet. */ +export function load(root: string, file: string): Settings { const path = join(root, file); if (!existsSync(path)) return {}; let parsed: unknown; @@ -59,7 +60,7 @@ function load(root: string, file: string): Settings { return parsed as Settings; } -function save(root: string, file: string, settings: Settings): void { +export function save(root: string, file: string, settings: Settings): void { mkdirSync(dirname(join(root, file)), { recursive: true }); writeFileSync(join(root, file), `${JSON.stringify(settings, null, 2)}\n`, "utf8"); } diff --git a/test/cursor.test.js b/test/cursor.test.js new file mode 100644 index 0000000..760ae9f --- /dev/null +++ b/test/cursor.test.js @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { inspect } from "../dist/hook.js"; + +const CLI = fileURLToPath(new URL("../dist/index.js", import.meta.url)); + +function run(args, cwd, input, env = process.env) { + return spawnSync(process.execPath, [CLI, ...args], { cwd, input, encoding: "utf8", env }); +} + +function project(args = ["init", "--agents", "claude,cursor"]) { + const root = realpathSync(mkdtempSync(join(tmpdir(), "canaryhook-"))); + assert.equal(run(args, root).status, 0); + return root; +} + +const config = (root) => JSON.parse(readFileSync(join(root, ".cursor", "hooks.json"), "utf8")); +const tokens = (root) => JSON.parse(readFileSync(join(root, ".canaryhook", "canaries.json"), "utf8")).decoys; + +function cursorCall(root, event, fields) { + return JSON.stringify({ conversation_id: "c1", hook_event_name: event, cwd: root, workspace_roots: [root], ...fields }); +} + +test("init registers the Cursor hooks in .cursor/hooks.json", () => { + const root = project(); + const { version, hooks } = config(root); + assert.equal(version, 1); + assert.equal(hooks.preToolUse[0].command, "canaryhook hook --agent cursor"); + assert.equal(hooks.postToolUse[0].command, "canaryhook hook --agent cursor"); + assert.equal(hooks.sessionStart[0].command, "canaryhook session-start --agent cursor"); +}); + +test("init wires Cursor by default only when the project already has .cursor/", () => { + assert.equal(existsSync(join(project(["init"]), ".cursor", "hooks.json")), false); + const root = realpathSync(mkdtempSync(join(tmpdir(), "canaryhook-"))); + mkdirSync(join(root, ".cursor")); + run(["init"], root); + assert.equal(config(root).hooks.preToolUse.length, 1); +}); + +test("keeps hooks already in .cursor/hooks.json and removes only its own on clean", () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "canaryhook-"))); + mkdirSync(join(root, ".cursor")); + writeFileSync(join(root, ".cursor", "hooks.json"), JSON.stringify({ version: 1, hooks: { preToolUse: [{ command: "other" }] } })); + run(["init"], root); + assert.deepEqual(config(root).hooks.preToolUse.map((h) => h.command), ["other", "canaryhook hook --agent cursor"]); + run(["clean"], root); + assert.deepEqual(config(root).hooks, { preToolUse: [{ command: "other" }] }); +}); + +test("denies a decoy read through preToolUse and records it", () => { + const root = project(); + const result = run(["hook", "--agent", "cursor"], root, cursorCall(root, "preToolUse", { tool_name: "Read", tool_input: { file_path: ".env.backup" } })); + const out = JSON.parse(result.stdout); + assert.equal(out.permission, "deny"); + assert.match(out.agent_message, /tripwire/); + assert.equal(run(["verify"], root).status, 1); +}); + +test("allows a clean call explicitly, as Cursor blocks on empty output", () => { + const root = project(); + const result = run(["hook", "--agent", "cursor"], root, cursorCall(root, "preToolUse", { tool_name: "Shell", tool_input: { command: "ls src" } })); + assert.deepEqual(JSON.parse(result.stdout), { permission: "allow" }); + assert.deepEqual(JSON.parse(run(["hook", "--agent", "cursor"], root, "not json").stdout), { permission: "allow" }); +}); + +test("postToolUse records a canary in the tool output and tells the agent", () => { + const root = project(); + const output = JSON.stringify({ stdout: `.env.backup:AWS_ACCESS_KEY_ID=${tokens(root)[0].token}` }); + const result = run(["hook", "--agent", "cursor"], root, cursorCall(root, "postToolUse", { tool_name: "Shell", tool_input: { command: "grep -r AKIA ." }, tool_output: output })); + assert.match(JSON.parse(result.stdout).additional_context, /fake/); + assert.equal(run(["verify"], root).status, 1); +}); + +test("removing the hook from .cursor/hooks.json trips tamper", () => { + assert.equal(inspect([], { file_path: ".cursor/hooks.json", content: `{"version":1,"hooks":{}}` })?.reason, "tamper"); + assert.equal(inspect([], { command: "rm .cursor/hooks.json" })?.reason, "tamper"); + assert.equal(inspect([], { file_path: ".cursor/hooks.json", content: `{"hooks":{"preToolUse":[{"command":"canaryhook hook --agent cursor"}]}}` }), null); +}); + +test("status and doctor cover Cursor", () => { + const root = project(); + const status = JSON.parse(run(["status", "--json"], root).stdout); + assert.deepEqual(status.agents.map((a) => a.name), ["claude", "cursor"]); + assert.equal(status.healthy, true); + const bin = mkdtempSync(join(tmpdir(), "canaryhook-bin-")); + writeFileSync(join(bin, "canaryhook"), `#!/bin/sh\nexec ${process.execPath} ${CLI} "$@"\n`); + chmodSync(join(bin, "canaryhook"), 0o755); + const doctor = run(["doctor", "--json"], root, undefined, { ...process.env, PATH: `${bin}:${process.env.PATH}` }); + const checks = JSON.parse(doctor.stdout).checks; + assert.ok(checks.some((c) => c.name === "Cursor: hook denies a decoy read" && c.ok)); + assert.ok(checks.some((c) => c.name === "Cursor: hook passes normal calls" && c.ok)); + assert.equal(doctor.status, 0); +}); + +test("session start reports past trips in Cursor's format", () => { + const root = project(); + run(["hook", "--agent", "cursor"], root, cursorCall(root, "preToolUse", { tool_name: "Read", tool_input: { file_path: ".env.backup" } })); + const out = JSON.parse(run(["session-start", "--agent", "cursor"], root).stdout); + assert.match(out.additional_context, /1 canary trip/); +});