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
11 changes: 6 additions & 5 deletions backlog/tasks/task-3 - Support-Cursor.md
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -22,8 +23,8 @@ Register preToolUse, postToolUse and sessionStart in .cursor/hooks.json (version

## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #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
<!-- AC:END -->
1 change: 1 addition & 0 deletions src/agents/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
124 changes: 124 additions & 0 deletions src/agents/cursor.ts
Original file line number Diff line number Diff line change
@@ -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<string, Handler[]> {
const config = load(root, CONFIG);
const hooks = config.hooks;
return hooks && typeof hooks === "object" ? (hooks as Record<string, Handler[]>) : {};
}

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<string, Handler[]>;
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;
}
},
};
5 changes: 3 additions & 2 deletions src/agents/index.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -27,4 +28,4 @@ export function wiredAgents(state: State): Agent[] {
return (state.agents ?? [claude.name]).flatMap((name) => agentNamed(name) ?? []);
}

export { claude };
export { claude, cursor };
2 changes: 2 additions & 0 deletions src/agents/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
11 changes: 7 additions & 4 deletions src/hook.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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"];
Expand Down Expand Up @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Commands

Options
--json Machine-readable output
--agents <list> init: agents to wire, comma-separated (claude); default is
--agents <list> 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
Expand Down
7 changes: 4 additions & 3 deletions src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ interface MatcherGroup {
[key: string]: unknown;
}

interface Settings {
export interface Settings {
hooks?: Record<string, MatcherGroup[]>;
[key: string]: unknown;
}
Expand All @@ -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;
Expand All @@ -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");
}
Expand Down
106 changes: 106 additions & 0 deletions test/cursor.test.js
Original file line number Diff line number Diff line change
@@ -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/);
});
Loading