diff --git a/CHANGELOG.md b/CHANGELOG.md index a557466..ad3ba07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to `@fusengine/harness`. Format: [Keep a Changelog](https:// ## [Unreleased] +## [0.1.91] - 2026-09-01 + +### Fixed + +- Complete native Cursor hook handling and harden confirmation and shell-write enforcement. + ## [0.1.90] - 2026-08-12 ### Fixed diff --git a/docs/adapters.md b/docs/adapters.md index 9fdb284..d0fc9b5 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -24,7 +24,7 @@ assuming a gate that works on Claude Code also works elsewhere. |---|---|---|---|---| | **claude-code** | `adapters/claude/index.ts` | Full: `evaluate` + APEX gates via `handleHook` | 14 event types implemented in `runtime/lifecycle/dispatch.ts` (SessionStart, SessionEnd, SubagentStart/Stop, Stop, PreCompact, PostCompact, TaskCompleted, TeammateIdle, PostToolUseFailure, InstructionsLoaded, UserPromptSubmit, plus Pre/PostToolUse) | Only PreToolUse+PostToolUse are wired by `harness init` (`init/templates.ts:18-27`); the other 12 event types require the consumer's own `.claude/settings.json` to route them. | | **codex** | `adapters/codex/index.ts` + `adapters/codex/apply-patch.ts` | `Bash \| apply_patch` matcher, PostToolUse (`init/templates.ts:29-38`). **`apply_patch` edits are gated**: the patch text is parsed per file, each hunk runs the file gates (protected-path, file-size, DRY) and one violating hunk denies the whole patch (`runtime/apply-patch-gate.ts`, sim scenarios 22-23). `ask` is downgraded to an explicit deny (`respond.ts`) — Codex fails open on unsupported shapes; the deny now carries a `CONFIRM ` recourse (`runtime/confirm/`, see below). | none wired | Upstream: Codex does not always enforce a correct `apply_patch` deny (openai/codex#27833) — the harness emits the right verdict, enforcement is Codex's. Do not add a Codex `PermissionRequest` path until `respond()` emits Codex's own wire shape (`codex/index.ts`). | -| **cursor** | `adapters/cursor/index.ts` | `beforeShellExecution` can deny/ask (shell only, lines 16-21) | none | File edits are **advisory only**: `afterFileEdit` always returns `allow` + a `user_message` correction on violation — a `deny` there has no proven effect (hook launched "informational only"; Cursor's deny-enforcement for file ops is confirmed broken upstream, forum.cursor.com/t/154377). The human sees the message; the model is never re-informed. Platform ceiling, sourced in the adapter JSDoc. | +| **cursor** | `adapters/cursor/index.ts` | The full runtime normalizes native `beforeShellExecution.command` and `preToolUse` `Shell` as `Bash`; `Write` maps to `Edit`. Policy `ask` degrades to native `permission:"deny"` because Cursor does not reliably apply `ask` on `preToolUse`. The public shell/tool adapter uses the same extractor. | `afterFileEdit` observation | `afterFileEdit.edits[]` is fanned out for post consumers and framework visibility, but the edit has already happened: this path remains **advisory only** and cannot retroactively block or roll back the write. | | **gemini-cli** | `adapters/gemini/index.ts` | `BeforeTool` denies via `{decision:"deny",reason}` (lines 22-36) | none | Thin stateless adapter — no session track, no APEX gates reachable through it. | | **cline** | `adapters/cline/index.ts` | `PreToolUse` only; block → `{cancel:true}`, non-block → `contextModification` (lines 24-36) | none | Same as gemini-cli: stateless guard only, `PreToolUse` cannot modify tool parameters (per docs.cline.bot). | | **hermes** | `adapters/hermes/index.ts` | `pre_tool_call` proven: reuses the Claude stdin reader, blocks via `{decision:"block",reason}` (lines 12-36) | untested — no lifecycle dispatch wired for Hermes in this repo | `ask`/`inform` degrade to non-blocking `{context}` — Hermes "has no interactive ask state" (lines 27-28). | diff --git a/docs/runtime.md b/docs/runtime.md index fa58be9..a4cf5da 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -28,12 +28,20 @@ const { stdout, exit } = await handleHook(id, payload, { content blocks (`[{type,text}]`), which is flattened by joining each block's `.text` with `"\n"`; anything else yields `""`, never a throw — then `detectCreationIntent` → `recordBrainstormRequired` (`brainstormGate` fires - on the next edit) and `handleConfirmSubmit` (`./confirm/confirm-submit.ts`) - parses the same text for a `CONFIRM ` reply or an explicit refusal — - see [adapters.md](./adapters.md#confirm-code--recourse-for-a-degraded-ask). - -`normalizeEvent(id, payload)` unifies the payload shapes (Claude/Codex/Gemini/ -Cursor `tool_name`+`tool_input`; Cline nested `preToolUse`). + on the next edit). Kimi then uses the legacy `handleConfirmSubmit`; Codex + processes the same reply before scope-specific early returns and binds it to + a versioned action identity (tool, canonical cwd, canonical command). Its + atomic consumed receipt authorizes sibling callbacks sharing one + `tool_use_id`; it proves authorization at PreToolUse, not command execution. + See [adapters.md](./adapters.md#confirm-code--recourse-for-a-degraded-ask). + +`normalizeEvent(id, payload)` unifies the payload shapes. Cursor has a dedicated +branch for its native events: top-level `beforeShellExecution.command` and +`preToolUse` `Shell` both become `Bash`, `Write` becomes `Edit`, and +`afterFileEdit.edits[]` becomes ordered per-edit post fan-out. That post fan-out +improves observation and framework detection only; it cannot block an edit that +already happened. Claude/Codex/Gemini keep `tool_name`+`tool_input`, and Cline +keeps its nested `preToolUse` shape. ## `gate(input)` diff --git a/package.json b/package.json index fc149e9..a99a7b4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fusengine/harness", - "version": "0.1.90", + "version": "0.1.91", "description": "Harness-agnostic toolkit for AI coding agents: runtime harness detection (Claude Code, Codex, Cursor, Cline, Gemini, Aider...), pure policy core (env config, project/framework detection, SOLID/file-size limits, APEX freshness, guard patterns, portable prompts), cache, project memory, ref routing, state/locks, statusline, per-harness adapters (Claude/Cursor/Cline/Gemini) and a cli-mode harness-check binary. Bun-native, with a built dist for Node + bundlers.", "type": "module", "module": "src/index.ts", @@ -121,6 +121,7 @@ } }, "files": [ + "src/**/*.ts", "dist", "assets", "README.md", @@ -138,7 +139,7 @@ "test": "bun test", "sim": "bun test test/sim/", "typecheck": "tsc --noEmit", - "docs:api": "typedoc", + "docs:api": "bun install --cwd tools/api-docs --frozen-lockfile && bun run --cwd tools/api-docs generate", "build": "tsdown src/index.ts src/config/index.ts src/util/index.ts src/detect/index.ts src/policy/index.ts src/prompt/index.ts src/memory/index.ts src/cache/index.ts src/freshness/index.ts src/refs/index.ts src/statusline/index.ts src/cli/index.ts src/cli/bin.ts src/init/index.ts src/tracking/index.ts src/runtime/index.ts src/adapters/claude/index.ts src/adapters/codex/index.ts src/adapters/cursor/index.ts src/adapters/cline/index.ts src/adapters/gemini/index.ts src/adapters/hermes/index.ts src/adapters/kimi/index.ts --dts --format esm --clean --out-dir dist", "prepublishOnly": "bun test && tsc --noEmit && bun run build" }, diff --git a/src/adapters/cursor/context.ts b/src/adapters/cursor/context.ts new file mode 100644 index 0000000..49abb97 --- /dev/null +++ b/src/adapters/cursor/context.ts @@ -0,0 +1,46 @@ +import { isAbsolute, normalize, relative } from "node:path"; +import { realpathSync } from "node:fs"; + +/** Preserve a Cursor path value only when it is non-empty and NUL-free. */ +export function cursorPath(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 && !value.includes("\0") ? value : undefined; +} + +/** Validate and normalize an absolute path supplied by Cursor. */ +export function cursorAbsolutePath(value: unknown): string | undefined { + const candidate = cursorPath(value); + if (!candidate || !isAbsolute(candidate)) return undefined; + const path = normalize(candidate); + try { + return realpathSync.native(path); + } catch { + return path; + } +} + +/** Preserve distinct, validated Cursor workspace roots in wire order. */ +export function cursorWorkspaceRoots(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const roots = value.map(cursorAbsolutePath).filter((root): root is string => root !== undefined); + return [...new Set(roots)]; +} + +function contains(root: string, filePath: string): boolean { + const rel = relative(root, filePath); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +/** Select Cursor's project scope without replacing a valid payload cwd. */ +export function cursorProjectCwd( + cwd: string | undefined, + workspaceRoots: readonly string[], + filePath: string | undefined, + fallback: string, +): string { + if (cwd) return cwd; + if (filePath) { + const matches = workspaceRoots.filter((root) => contains(root, filePath)); + if (matches.length > 0) return matches.sort((a, b) => b.length - a.length)[0]!; + } + return workspaceRoots[0] ?? fallback; +} diff --git a/src/adapters/cursor/events.ts b/src/adapters/cursor/events.ts new file mode 100644 index 0000000..2e644ce --- /dev/null +++ b/src/adapters/cursor/events.ts @@ -0,0 +1,38 @@ +import type { CursorEventContract } from "./interfaces/types"; + +const EVENT_CONTRACTS = { + sessionStart: { phase: "pre", lifecycle: "SessionStart", response: "session-context", blockable: false, known: true }, + sessionEnd: { phase: "post", lifecycle: "SessionEnd", response: "neutral", blockable: false, known: true }, + beforeSubmitPrompt: { phase: "pre", lifecycle: "UserPromptSubmit", response: "submit-control", blockable: true, known: true }, + preCompact: { phase: "pre", lifecycle: "PreCompact", response: "compact-notice", blockable: false, known: true }, + subagentStart: { phase: "pre", lifecycle: "SubagentStart", response: "permission", blockable: true, known: true }, + subagentStop: { phase: "post", lifecycle: "SubagentStop", response: "followup", blockable: false, known: true }, + preToolUse: { phase: "pre", lifecycle: "PreToolUse", response: "permission", blockable: true, known: true }, + postToolUse: { phase: "post", lifecycle: "PostToolUse", response: "post-context", blockable: false, known: true }, + postToolUseFailure: { phase: "post", lifecycle: "PostToolUseFailure", response: "neutral", blockable: false, known: true }, + beforeShellExecution: { phase: "pre", lifecycle: "BeforeShellExecution", response: "permission", blockable: true, known: true }, + afterShellExecution: { phase: "post", lifecycle: "AfterShellExecution", response: "neutral", blockable: false, known: true }, + beforeMCPExecution: { phase: "pre", lifecycle: "BeforeMCPExecution", response: "permission", blockable: true, known: true }, + afterMCPExecution: { phase: "post", lifecycle: "AfterMCPExecution", response: "neutral", blockable: false, known: true }, + beforeReadFile: { phase: "pre", lifecycle: "BeforeReadFile", response: "permission", blockable: true, known: true }, + afterFileEdit: { phase: "post", lifecycle: "AfterFileEdit", response: "neutral", blockable: false, known: true }, + beforeTabFileRead: { phase: "pre", lifecycle: "BeforeTabFileRead", response: "permission", blockable: true, known: true }, + afterTabFileEdit: { phase: "post", lifecycle: "AfterTabFileEdit", response: "neutral", blockable: false, known: true }, + afterAgentResponse: { phase: "post", lifecycle: "AfterAgentResponse", response: "neutral", blockable: false, known: true }, + afterAgentThought: { phase: "post", lifecycle: "AfterAgentThought", response: "neutral", blockable: false, known: true }, + stop: { phase: "post", lifecycle: "Stop", response: "followup", blockable: false, known: true }, + workspaceOpen: { phase: "pre", lifecycle: "WorkspaceOpen", response: "plugin-paths", blockable: false, known: true }, +} as const satisfies Record; + +const UNKNOWN_EVENT: CursorEventContract = { + phase: "post", + lifecycle: null, + response: "neutral", + blockable: false, + known: false, +}; + +/** Return explicit routing and response metadata for a Cursor event name. */ +export function cursorEventContract(eventName: string): CursorEventContract { + return EVENT_CONTRACTS[eventName as keyof typeof EVENT_CONTRACTS] ?? UNKNOWN_EVENT; +} diff --git a/src/adapters/cursor/index.ts b/src/adapters/cursor/index.ts index 558b849..4cbd9b3 100644 --- a/src/adapters/cursor/index.ts +++ b/src/adapters/cursor/index.ts @@ -1,46 +1,43 @@ -/** - * Cursor adapter (hook-mode). Schemas per cursor.com/docs/hooks (2026): - * `beforeShellExecution` can block; `afterFileEdit` is observe-only. - */ +/** Cursor hook adapter; post-edit handling remains observe-only. */ import { evaluate } from "../../policy/evaluate"; -import { formatPrompt, type PromptKind } from "../../prompt/types"; -import type { CursorShellPayload, CursorEditPayload, CursorResponse, CursorEditResponse } from "./interfaces/types"; +import { formatPrompt } from "../../prompt/types"; +import { extractCursorEvent } from "./normalize"; +import type { CursorShellPayload, CursorToolPayload, CursorEditPayload, CursorResponse, CursorEditResponse } from "./interfaces/types"; + +export type { CursorShellPayload, CursorToolPayload, CursorEditPayload, CursorResponse, CursorEditResponse } from "./interfaces/types"; -export type { CursorShellPayload, CursorEditPayload, CursorResponse, CursorEditResponse } from "./interfaces/types"; +function guardCursor(payload: object): CursorResponse { + const event = extractCursorEvent(payload); + const result = evaluate({ tool: event.tool, filePath: event.filePath, content: event.content, oldString: event.oldString, command: event.command }); + if (result.decision === "allow" || !result.prompt) return { permission: "allow" }; + const message = formatPrompt(result.prompt); + const userMessage = result.prompt.kind === "ask" + ? `[downgraded from ask — Cursor preToolUse does not reliably enforce ask]\n${message}` + : message; + return { permission: "deny", user_message: userMessage, agent_message: message }; +} -function toPermission(kind: PromptKind): "allow" | "deny" | "ask" { - return kind === "block" ? "deny" : kind === "ask" ? "ask" : "allow"; +function namedPayload(payload: object, eventName: string): object { + return Object.hasOwn(payload, "hook_event_name") ? payload : { ...payload, hook_event_name: eventName }; } /** Guard a shell command (git/install policies). */ export function beforeShellExecution(payload: CursorShellPayload): CursorResponse { - const r = evaluate({ tool: "Bash", command: payload.command }); - if (r.decision === "allow" || !r.prompt) return { permission: "allow" }; - const msg = formatPrompt(r.prompt); - return { permission: toPermission(r.prompt.kind), continue: false, user_message: msg, agent_message: msg }; + return guardCursor(namedPayload(payload, "beforeShellExecution")); +} + +/** Guard a generic Cursor tool call using the same extraction as the runtime. */ +export function preToolUse(payload: CursorToolPayload): CursorResponse { + return guardCursor(namedPayload(payload, "preToolUse")); } /** - * Advise on a file edit AFTER Cursor has written it — a HUMAN-VISIBLE audit note, - * never a gate. This is an "after" hook: the edit is already on disk. On a - * SOLID/DRY violation we surface the correction through `user_message` (the only - * channel afterFileEdit exposes — no `agent_message`, so the model is never - * re-informed) while ALWAYS returning `permission: "allow"`. - * - * We deliberately never emit `permission: "deny"` here, for two distinct reasons: - * (1) structural — afterFileEdit was "informational only" at launch (Chacon, - * Cursor hooks beta 1.7, 2025-09: no channel to stop the agent), and a post-write - * deny has no documented rollback; (2) empirical — Cursor staff confirm the - * deny-enforcement path is broken for file operations (forum.cursor.com/t/154377, - * v2.6.18, 2026-03, open) — proven for file READS, plausibly the same for writes. - * So a `deny` would be a false blocking signal; `allow` + `user_message` is the - * only proven-safe shape. + * Complete an observed file edit without emitting pre-execution permission + * fields. Cursor does not define a callback schema for this post hook. * @param payload - The `afterFileEdit` stdin payload. - * @returns Always an allow; carries the user-visible correction on a violation. + * @returns An empty successful response. */ export function afterFileEdit(payload: CursorEditPayload): CursorEditResponse { - const content = payload.edits?.map((e) => e.new_string).join("\n") ?? ""; - const r = evaluate({ tool: "Edit", filePath: payload.file_path, content }); - if (r.decision !== "deny" || !r.prompt) return { permission: "allow" }; - return { permission: "allow", user_message: formatPrompt(r.prompt) }; + void payload; + return {}; } diff --git a/src/adapters/cursor/interfaces/types.ts b/src/adapters/cursor/interfaces/types.ts index 3c9a3a8..af9bfff 100644 --- a/src/adapters/cursor/interfaces/types.ts +++ b/src/adapters/cursor/interfaces/types.ts @@ -2,29 +2,76 @@ export interface CursorShellPayload { command?: string; cwd?: string; + sandbox?: boolean; workspace_roots?: string[]; hook_event_name?: string; } +/** `preToolUse` stdin payload subset consumed by the adapter. */ +export interface CursorToolPayload { + tool_name?: string; + tool_input?: Record; +} + /** `afterFileEdit` stdin payload (subset). */ export interface CursorEditPayload { file_path?: string; edits?: { old_string: string; new_string: string }[]; } +/** One extracted Cursor file edit used by runtime post fan-out. */ +export interface CursorExtractedFile { + filePath: string; + oldString?: string; + content: string; + op: "update"; +} + +/** Shared Cursor extraction result consumed by runtime and public adapter. */ +export interface CursorExtractedEvent { + eventName: string; + lifecycleEvent: string | null; + responseKind: CursorResponseKind; + blockable: boolean; + cwd?: string; + workspaceRoots?: string[]; + phase: "pre" | "post"; + tool: string; + input: Record; + filePath?: string; + content?: string; + oldString?: string; + command?: string; + /** Distinct beforeMCPExecution root and nested commands, in wire order. */ + commandCandidates?: string[]; + files?: CursorExtractedFile[]; +} + +/** Native Cursor stdout contract selected for one hook event. */ +export type CursorResponseKind = + | "permission" + | "post-context" + | "session-context" + | "submit-control" + | "followup" + | "compact-notice" + | "plugin-paths" + | "neutral"; + +/** Routing metadata for a documented Cursor lifecycle event. */ +export interface CursorEventContract { + phase: "pre" | "post"; + lifecycle: string | null; + response: CursorResponseKind; + blockable: boolean; + known: boolean; +} + /** - * `afterFileEdit` stdout response. Its schema (cursor.com/docs/hooks#afterFileEdit) - * is DELIBERATELY narrower than the "before" hooks: `permission` + `user_message` - * only — there is NO `agent_message` and NO `updated_input`. Since the edit is - * already on disk when this "after" hook fires, `deny` cannot revert it and the - * correction reaches only the HUMAN (`user_message`), never the model — so this - * path is strictly ADVISORY, not an enforceable gate. + * Empty afterFileEdit callback. Cursor documents no output fields for this + * post hook, so pre-execution permission fields are intentionally impossible. */ -export interface CursorEditResponse { - permission: "allow" | "deny"; - /** User-visible correction — snake_case (#141516); the only channel afterFileEdit exposes. */ - user_message?: string; -} +export type CursorEditResponse = Record; /** * `beforeShellExecution` stdout response. Message keys are snake_case: @@ -34,7 +81,6 @@ export interface CursorEditResponse { */ export interface CursorResponse { permission: "allow" | "deny" | "ask"; - continue?: boolean; /** User-visible message — snake_case required (#141516, #142589). */ user_message?: string; /** Agent-visible message — snake_case required (#141516, #142589). */ diff --git a/src/adapters/cursor/native-response.ts b/src/adapters/cursor/native-response.ts new file mode 100644 index 0000000..0db5ecc --- /dev/null +++ b/src/adapters/cursor/native-response.ts @@ -0,0 +1,158 @@ +type FieldValidator = (value: unknown) => boolean; + +interface NativeSchema { + fields: Readonly>; + required?: readonly string[]; +} + +const stringValue: FieldValidator = (value) => typeof value === "string"; +const booleanValue: FieldValidator = (value) => typeof value === "boolean"; +const plainRecord = (value: unknown): value is Record => { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + try { + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; + } catch { + return false; + } +}; + +type JsonFrame = { value: unknown; leave?: false } | { value: object; leave: true }; + +function jsonChildren(value: object): unknown[] | null { + const keys = Reflect.ownKeys(value); + const descriptors = Object.getOwnPropertyDescriptors(value); + if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype) return null; + const length = descriptors.length; + if (!length || !("value" in length) || !Number.isSafeInteger(length.value) || length.value < 0) return null; + if (keys.length !== length.value + 1 || keys.some((key) => typeof key === "symbol")) return null; + const children: unknown[] = []; + for (let index = 0; index < length.value; index += 1) { + const descriptor = descriptors[String(index)]; + if (!descriptor?.enumerable || !("value" in descriptor)) return null; + children.push(descriptor.value); + } + return children; + } + if (!plainRecord(value) || keys.some((key) => typeof key === "symbol")) return null; + const children: unknown[] = []; + for (const key of keys) { + const descriptor = descriptors[key as string]; + if (!descriptor?.enumerable || !("value" in descriptor)) return null; + children.push(descriptor.value); + } + return children; +} + +function jsonValue(root: unknown): boolean { + const active = new WeakSet(); + const stack: JsonFrame[] = [{ value: root }]; + while (stack.length > 0) { + const frame = stack.pop()!; + if (frame.leave) { + active.delete(frame.value); + continue; + } + const { value } = frame; + if (value === null || typeof value === "string" || typeof value === "boolean") continue; + if (typeof value === "number") { + if (!Number.isFinite(value)) return false; + continue; + } + if (typeof value !== "object" || active.has(value)) return false; + let children: unknown[] | null; + try { + children = jsonChildren(value); + } catch { + return false; + } + if (!children) return false; + active.add(value); + stack.push({ value, leave: true }); + for (let index = children.length - 1; index >= 0; index -= 1) stack.push({ value: children[index] }); + } + return true; +} + +const recordValue: FieldValidator = (value) => plainRecord(value) && jsonValue(value); +const stringRecord: FieldValidator = (value) => { + if (!recordValue(value)) return false; + try { + return Object.values(Object.getOwnPropertyDescriptors(value as object)) + .every((descriptor) => "value" in descriptor && typeof descriptor.value === "string"); + } catch { + return false; + } +}; +const stringArray: FieldValidator = (value) => Array.isArray(value) && value.every(stringValue); +const permission = (...values: string[]): FieldValidator => (value) => typeof value === "string" && values.includes(value); + +const EMPTY: NativeSchema = { fields: {} }; +const FOLLOWUP: NativeSchema = { fields: { followup_message: stringValue } }; +const PERMISSION_ASK: NativeSchema = { + fields: { permission: permission("allow", "deny", "ask"), user_message: stringValue, agent_message: stringValue }, + required: ["permission"], +}; + +const NATIVE_SCHEMAS = { + sessionStart: { + fields: { env: stringRecord, additional_context: stringValue, continue: booleanValue, user_message: stringValue }, + }, + sessionEnd: EMPTY, + beforeSubmitPrompt: { fields: { continue: booleanValue, user_message: stringValue }, required: ["continue"] }, + preCompact: { fields: { user_message: stringValue } }, + subagentStart: { + fields: { permission: permission("allow", "deny"), user_message: stringValue }, required: ["permission"], + }, + subagentStop: FOLLOWUP, + preToolUse: { + fields: { ...PERMISSION_ASK.fields, updated_input: recordValue }, required: ["permission"], + }, + postToolUse: { fields: { updated_mcp_tool_output: recordValue, additional_context: stringValue } }, + postToolUseFailure: EMPTY, + beforeShellExecution: PERMISSION_ASK, + afterShellExecution: EMPTY, + beforeMCPExecution: PERMISSION_ASK, + afterMCPExecution: EMPTY, + beforeReadFile: { + fields: { permission: permission("allow", "deny"), user_message: stringValue }, required: ["permission"], + }, + afterFileEdit: EMPTY, + beforeTabFileRead: { fields: { permission: permission("allow", "deny") }, required: ["permission"] }, + afterTabFileEdit: EMPTY, + afterAgentResponse: EMPTY, + afterAgentThought: EMPTY, + stop: FOLLOWUP, + workspaceOpen: { fields: { pluginPaths: stringArray } }, +} as const satisfies Record; + +function isNativeCursorResponse(value: unknown, eventName: string): boolean { + try { + if (!recordValue(value)) return false; + const schema = NATIVE_SCHEMAS[eventName as keyof typeof NATIVE_SCHEMAS] as NativeSchema | undefined; + if (!schema) return false; + const descriptors = Object.getOwnPropertyDescriptors(value as object); + if (schema.required?.some((field) => !Object.hasOwn(descriptors, field))) return false; + return Reflect.ownKeys(descriptors).every((field) => { + if (typeof field !== "string") return false; + const descriptor = descriptors[field]; + if (!Object.hasOwn(schema.fields, field)) return false; + const validate = schema.fields[field]; + return descriptor?.enumerable === true && "value" in descriptor + && validate !== undefined && validate(descriptor.value); + }); + } catch { + return false; + } +} + +/** Preserve raw stdout only when JSON.parse proves a documented native Cursor response. */ +export function parseNativeCursorStdout(stdout: string, eventName: string): string | null { + if (typeof stdout !== "string") return null; + try { + return isNativeCursorResponse(JSON.parse(stdout), eventName) ? stdout : null; + } catch { + return null; + } +} diff --git a/src/adapters/cursor/normalize.ts b/src/adapters/cursor/normalize.ts new file mode 100644 index 0000000..8e0618e --- /dev/null +++ b/src/adapters/cursor/normalize.ts @@ -0,0 +1,123 @@ +import { commandToString } from "../../runtime/command-string"; +import { cursorEventContract } from "./events"; +import { cursorAbsolutePath, cursorPath, cursorWorkspaceRoots } from "./context"; +import type { CursorExtractedEvent } from "./interfaces/types"; + +function record(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : undefined; +} + +function str(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function inputRecord(value: unknown): Record | undefined { + const direct = record(value); + if (direct || typeof value !== "string") return direct; + try { + return record(JSON.parse(value)); + } catch { + return undefined; + } +} + +const MCP_ROOT_FIELDS = ["mcp_server_name", "mcp_server_url", "url", "result_json", "duration"] as const; + +function cursorMcpInput(raw: Record, input: Record): Record { + const merged = { ...input }; + for (const field of MCP_ROOT_FIELDS) { + if (Object.hasOwn(raw, field)) merged[field] = raw[field]; + } + return merged; +} + +function sanitizedCursorInput(input: Record): Record { + const safe = { ...input }; + if (typeof safe.file_path === "string" && cursorPath(safe.file_path) === undefined) delete safe.file_path; + if (typeof safe.path === "string" && cursorPath(safe.path) === undefined) delete safe.path; + if (Object.hasOwn(safe, "cwd")) { + const cwd = cursorAbsolutePath(safe.cwd); + if (cwd) safe.cwd = cwd; + else delete safe.cwd; + } + if (Object.hasOwn(safe, "workspace_roots")) { + if (Array.isArray(safe.workspace_roots)) safe.workspace_roots = cursorWorkspaceRoots(safe.workspace_roots); + else delete safe.workspace_roots; + } + return safe; +} + +function cursorToolName(raw: Record, event: string, tool: string | undefined, hasCommand: boolean): string { + if (hasCommand) return "Bash"; + const server = str(raw.mcp_server_name)?.trim().replace(/[^A-Za-z0-9_-]+/g, "_"); + if (/^(before|after)MCPExecution$/i.test(event) && server && tool && !tool.startsWith("mcp__")) { + return `mcp__${server}__${tool}`; + } + if (tool === "Write") return "Edit"; + return tool ?? ""; +} + +function cursorCommands(raw: Record, input: Record): string[] { + const candidates = [commandToString(raw.command), commandToString(input.command)] + .filter((candidate): candidate is string => candidate !== undefined); + return [...new Set(candidates)]; +} + +/** + * Extract one Cursor hook payload into the shared runtime shape. + * + * @param payload - Cursor hook stdin payload. + * @returns Cursor-specific phase, tool, command, and edit fields. + */ +export function extractCursorEvent(payload: object): CursorExtractedEvent { + const raw = payload as Record; + const hookEvent = str(raw.hook_event_name) ?? ""; + const parsedInput = inputRecord(raw.tool_input); + const mcpInput = parsedInput && /^(before|after)MCPExecution$/i.test(hookEvent) + ? cursorMcpInput(raw, parsedInput) + : parsedInput; + const input = sanitizedCursorInput(mcpInput ?? raw); + const contract = cursorEventContract(hookEvent); + const metadata = { + eventName: hookEvent, + lifecycleEvent: contract.lifecycle, + responseKind: contract.response, + blockable: contract.blockable, + cwd: cursorAbsolutePath(raw.cwd), + workspaceRoots: cursorWorkspaceRoots(raw.workspace_roots), + }; + if (!contract.known) { + return { ...metadata, phase: contract.phase, tool: "", input }; + } + const afterFileEdit = /^afterFileEdit$/i.test(hookEvent); + const beforeReadFile = /^beforeReadFile$/i.test(hookEvent); + const beforeTabFileRead = /^beforeTabFileRead$/i.test(hookEvent); + const edits = Array.isArray(raw.edits) + ? raw.edits.map(record).filter((edit): edit is Record => edit !== undefined) + : []; + const filePath = cursorPath(raw.file_path); + if (filePath && edits.length > 0) { + const files = edits.map((edit) => ({ + filePath, + oldString: str(edit.old_string), + content: str(edit.new_string) ?? "", + op: "update" as const, + })); + return { ...metadata, phase: contract.phase, tool: "Edit", input, filePath, content: files.map((file) => file.content).join("\n"), files }; + } + const commands = cursorCommands(raw, input); + const command = commands[0]; + return { + ...metadata, + phase: contract.phase, + tool: afterFileEdit ? "Edit" : beforeReadFile || beforeTabFileRead ? "Read" : cursorToolName(raw, hookEvent, str(raw.tool_name), command !== undefined), + input, + filePath: cursorPath(input.file_path) ?? cursorPath(input.path), + content: str(input.content) ?? str(input.new_string), + oldString: str(input.old_string), + command, + commandCandidates: /^beforeMCPExecution$/i.test(hookEvent) && commands.length > 0 ? commands : undefined, + }; +} diff --git a/src/adapters/cursor/respond.ts b/src/adapters/cursor/respond.ts new file mode 100644 index 0000000..bcb35d1 --- /dev/null +++ b/src/adapters/cursor/respond.ts @@ -0,0 +1,123 @@ +import { formatPrompt, type Prompt } from "../../prompt/types"; +import { cursorEventContract } from "./events"; +import { parseNativeCursorStdout } from "./native-response"; + +const AGENT_MESSAGE_EVENTS = new Set([ + "preToolUse", + "beforeShellExecution", + "beforeMCPExecution", +]); +const USER_MESSAGE_EVENTS = new Set([ + "preToolUse", + "beforeShellExecution", + "beforeMCPExecution", + "beforeReadFile", + "subagentStart", +]); + +function permissionMessages(eventName: string, userMessage?: string, agentMessage?: string): Record { + return { + ...(userMessage && USER_MESSAGE_EVENTS.has(eventName) ? { user_message: userMessage } : {}), + ...(agentMessage && AGENT_MESSAGE_EVENTS.has(eventName) ? { agent_message: agentMessage } : {}), + }; +} + +function joinMessages(...values: unknown[]): string { + return values.filter((value): value is string => typeof value === "string" && value.length > 0).join("\n"); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Render a portable policy prompt using the native Cursor event contract. */ +export function toCursorResponse(prompt: Prompt, eventName: string): string { + const contract = cursorEventContract(eventName); + const message = formatPrompt(prompt); + if (!contract.known || contract.response === "neutral" || contract.response === "plugin-paths") return "{}"; + if (contract.response === "post-context" || contract.response === "session-context") { + return JSON.stringify({ additional_context: message }); + } + if (contract.response === "followup") return JSON.stringify({ followup_message: message }); + if (contract.response === "compact-notice") return JSON.stringify({ user_message: prompt.userMessage ?? message }); + if (contract.response === "submit-control") { + return JSON.stringify({ continue: prompt.kind !== "block", user_message: prompt.userMessage ?? message }); + } + if (prompt.kind === "inform") { + return JSON.stringify({ + permission: "allow", + ...permissionMessages(eventName, prompt.userMessage, prompt.reason ? message : undefined), + }); + } + const userMessage = prompt.kind === "ask" + ? `[downgraded from ask — Cursor does not enforce approval for this event]\n${message}` + : message; + return JSON.stringify({ + permission: "deny", + ...permissionMessages(eventName, userMessage, userMessage), + }); +} + +/** Convert a shared lifecycle handler's output to the native Cursor envelope. */ +export function toCursorLifecycleResponse(stdout: string, eventName: string): string { + const contract = cursorEventContract(eventName); + const native = parseNativeCursorStdout(stdout, eventName); + if (native !== null) return native; + let text = stdout; + let decision: "allow" | "deny" | "ask" | undefined; + let userMessage = ""; + let agentMessage = ""; + let decisionMessage = ""; + let structured = false; + try { + const parsed: unknown = JSON.parse(stdout); + const subagentAsk = eventName === "subagentStart" && isRecord(parsed) + && Object.hasOwn(parsed, "permission") && parsed.permission === "ask"; + const shared = (isRecord(parsed) ? parsed : {}) as { + hookSpecificOutput?: { + additionalContext?: string; + permissionDecision?: "allow" | "deny" | "ask"; + permissionDecisionReason?: string; + }; + systemMessage?: string; + user_message?: string; + reason?: string; + followup_message?: string; + }; + structured = true; + decision = shared.hookSpecificOutput?.permissionDecision ?? (subagentAsk ? "ask" : undefined); + userMessage = shared.systemMessage + ?? (subagentAsk && Object.hasOwn(shared, "user_message") && typeof shared.user_message === "string" + ? shared.user_message : ""); + decisionMessage = joinMessages(shared.hookSpecificOutput?.permissionDecisionReason, shared.reason); + agentMessage = joinMessages(shared.hookSpecificOutput?.additionalContext, decisionMessage); + text = joinMessages(agentMessage, userMessage, shared.followup_message); + } catch { + text = stdout; + } + if (contract.response === "neutral") return "{}"; + if (!text) return contract.response === "permission" ? '{"permission":"allow"}' : "{}"; + if (contract.response === "session-context" || contract.response === "post-context") { + return JSON.stringify({ additional_context: text }); + } + if (contract.response === "permission") { + const permission = decision === "deny" || decision === "ask" ? "deny" : "allow"; + const denied = permission === "deny"; + // Cursor subagentStart can gate creation but has no model-context channel. + // Drop shared context and its "injected" notice on allow: preserving either + // would claim delivery the native event contract cannot perform. + if (eventName === "subagentStart" && !denied) return '{"permission":"allow"}'; + return JSON.stringify({ + permission, + ...permissionMessages( + eventName, + userMessage || (denied ? decisionMessage || agentMessage : ""), + agentMessage || (denied ? decisionMessage || userMessage : structured ? "" : text), + ), + }); + } + if (contract.response === "followup") return JSON.stringify({ followup_message: text }); + if (contract.response === "compact-notice") return JSON.stringify({ user_message: text }); + if (contract.response === "submit-control") return JSON.stringify({ continue: true, user_message: text }); + return "{}"; +} diff --git a/src/cli/bin.ts b/src/cli/bin.ts index 7c919da..3ca852d 100644 --- a/src/cli/bin.ts +++ b/src/cli/bin.ts @@ -21,7 +21,7 @@ import { homedir } from "node:os"; import { checkStaged, stagedContent, stagedFiles } from "./run"; import { runDoctor, runningVersion, versionBanner } from "./doctor"; import { parseScope } from "./scope"; -import { isOversize, oversizeStdout, readStdin, traceHook } from "./hook-io"; +import { isMalformedCursorStdin, isOversize, oversizeStdout, readStdin, traceHook } from "./hook-io"; import { maybePlaySound } from "./hook-sound"; const cmd = process.argv[2]; @@ -43,12 +43,13 @@ if (cmd === "--version" || cmd === "-v") { traceHook("args", { id, scope }); let outcome: Awaited>; try { - const stdin = await readStdin(); + const stdin = await readStdin(id); if (isOversize(stdin)) { const stdout = oversizeStdout(id, stdin.head); if (stdout) process.stdout.write(stdout); process.exit(0); } + if (isMalformedCursorStdin(stdin)) process.exit(1); outcome = await handleHook(id, stdin, { now: Date.now(), cwd: process.cwd(), refsDir, windowMs: resolveTtlSec(process.env) * 1000, scope }); } catch (e) { traceHook("handleHook-threw", e instanceof Error ? `${e.message}\n${e.stack}` : String(e)); throw e; } traceHook("outcome", { stdoutLength: outcome.stdout.length, exit: outcome.exit }); diff --git a/src/cli/cursor-event-scanner.ts b/src/cli/cursor-event-scanner.ts new file mode 100644 index 0000000..a52b91f --- /dev/null +++ b/src/cli/cursor-event-scanner.ts @@ -0,0 +1,175 @@ +import { JsonPrimitiveScanner } from "./json-primitive-scanner"; + +/** + * Fixed cardinality bounds used by the incremental Cursor JSON scanner. + * `maxDepth` counts simultaneously open containers. Deeper valid JSON remains + * indeterminate and the oversized Cursor path fails closed. + */ +export const CURSOR_SCANNER_LIMITS = { tokenEntries: 256, maxDepth: 1024 } as const; + +type Frame = + | { kind: "object"; state: "keyOrEnd" | "key" | "colon" | "value" | "commaOrEnd"; key?: string } + | { kind: "array"; state: "valueOrEnd" | "value" | "commaOrEnd" }; + +/** Incrementally validates JSON and extracts the last top-level Cursor event name. */ +export class CursorEventScanner { + private frames: Frame[] = []; + private mode: "normal" | "string" | "primitive" = "normal"; + private token: number[] = []; + private tokenOverflow = false; + private primitive: JsonPrimitiveScanner | undefined; + private escaped = false; + private unicode = 0; + private rootStarted = false; + private rootComplete = false; + private invalid = false; + private event: string | undefined; + + /** Consume another raw JSON byte chunk without retaining the payload. */ + write(chunk: Uint8Array): void { + for (const byte of chunk) this.consume(byte); + } + + /** Return the event only when the complete stream is valid JSON. */ + finish(): string | undefined { + if (this.mode === "primitive") this.endPrimitive(); + if (this.mode !== "normal" || this.frames.length > 0 || !this.rootComplete) this.invalid = true; + return this.invalid ? undefined : this.event; + } + + private consume(byte: number): void { + if (this.invalid) return; + if (this.mode === "string") { this.stringByte(byte); return; } + if (this.mode === "primitive") { + if (!isDelimiter(byte)) { + if (!this.primitive?.write(byte)) this.invalid = true; + return; + } + this.endPrimitive(); + if (this.invalid) return; + } + if (isWhitespace(byte)) return; + if (byte === 0x22) { this.startString(); return; } + if (byte === 0x7b || byte === 0x5b) { this.startContainer(byte === 0x7b ? "object" : "array"); return; } + if (byte === 0x7d || byte === 0x5d) { this.endContainer(byte === 0x7d ? "object" : "array"); return; } + if (byte === 0x3a) { this.colon(); return; } + if (byte === 0x2c) { this.comma(); return; } + if (isPrimitiveStart(byte)) { this.startPrimitive(byte); return; } + this.invalid = true; + } + + private startString(): void { + this.mode = "string"; + this.token = [0x22]; + this.tokenOverflow = false; + this.escaped = false; + this.unicode = 0; + } + + private startPrimitive(byte: number): void { + this.mode = "primitive"; + this.primitive = new JsonPrimitiveScanner(byte); + } + + private stringByte(byte: number): void { + this.pushToken(byte); + if (this.unicode > 0) { + if (!isHex(byte)) { this.invalid = true; return; } + this.unicode -= 1; + return; + } + if (this.escaped) { + if (byte === 0x75) this.unicode = 4; + else if (![0x22, 0x5c, 0x2f, 0x62, 0x66, 0x6e, 0x72, 0x74].includes(byte)) this.invalid = true; + this.escaped = false; + return; + } + if (byte === 0x5c) { this.escaped = true; return; } + if (byte < 0x20) { this.invalid = true; return; } + if (byte !== 0x22) return; + this.mode = "normal"; + const value = this.tokenOverflow ? undefined : decodeString(this.token); + if (value === null) { this.invalid = true; return; } + this.acceptString(value); + } + + private acceptString(value: string | undefined): void { + const frame = this.frames.at(-1); + if (frame?.kind === "object" && (frame.state === "key" || frame.state === "keyOrEnd")) { + frame.key = value; + frame.state = "colon"; + return; + } + this.acceptValue("string", value); + } + + private startContainer(kind: "object" | "array"): void { + this.acceptValue(kind); + if (this.invalid) return; + if (this.frames.length >= CURSOR_SCANNER_LIMITS.maxDepth) { this.invalid = true; return; } + this.frames.push(kind === "object" ? { kind, state: "keyOrEnd" } : { kind, state: "valueOrEnd" }); + } + + private endContainer(kind: "object" | "array"): void { + const frame = this.frames.at(-1); + const valid = frame?.kind === kind && (kind === "object" + ? frame.state === "keyOrEnd" || frame.state === "commaOrEnd" + : frame.state === "valueOrEnd" || frame.state === "commaOrEnd"); + if (!valid) { this.invalid = true; return; } + this.frames.pop(); + if (this.frames.length === 0) this.rootComplete = true; + } + + private acceptValue(kind: "string" | "primitive" | "object" | "array", value?: string): void { + if (this.rootComplete) { this.invalid = true; return; } + const frame = this.frames.at(-1); + if (!frame) { + if (this.rootStarted) { this.invalid = true; return; } + this.rootStarted = true; + if (kind === "string" || kind === "primitive") this.rootComplete = true; + return; + } + const expected = frame.kind === "object" ? frame.state === "value" : frame.state === "value" || frame.state === "valueOrEnd"; + if (!expected) { this.invalid = true; return; } + if (frame.kind === "object") { + if (this.frames.length === 1 && frame.key === "hook_event_name") this.event = kind === "string" ? value : undefined; + frame.key = undefined; + } + frame.state = "commaOrEnd"; + } + + private colon(): void { + const frame = this.frames.at(-1); + if (frame?.kind !== "object" || frame.state !== "colon") { this.invalid = true; return; } + frame.state = "value"; + } + + private comma(): void { + const frame = this.frames.at(-1); + if (!frame || frame.state !== "commaOrEnd") { this.invalid = true; return; } + frame.state = frame.kind === "object" ? "key" : "value"; + } + + private endPrimitive(): void { + const valid = this.primitive?.finish() === true; + this.mode = "normal"; + this.primitive = undefined; + if (valid) this.acceptValue("primitive"); + else this.invalid = true; + } + + private pushToken(byte: number): void { + if (this.token.length < CURSOR_SCANNER_LIMITS.tokenEntries) this.token.push(byte); + else this.tokenOverflow = true; + } +} + +function decodeString(bytes: number[]): string | null { + try { const value: unknown = JSON.parse(Buffer.from(bytes).toString("utf8")); return typeof value === "string" ? value : null; } + catch { return null; } +} + +function isWhitespace(byte: number): boolean { return byte === 0x20 || byte === 0x09 || byte === 0x0a || byte === 0x0d; } +function isDelimiter(byte: number): boolean { return isWhitespace(byte) || [0x2c, 0x5d, 0x7d].includes(byte); } +function isPrimitiveStart(byte: number): boolean { return byte === 0x2d || byte === 0x74 || byte === 0x66 || byte === 0x6e || byte >= 0x30 && byte <= 0x39; } +function isHex(byte: number): boolean { return byte >= 0x30 && byte <= 0x39 || byte >= 0x41 && byte <= 0x46 || byte >= 0x61 && byte <= 0x66; } diff --git a/src/cli/cursor-stdin-reader.ts b/src/cli/cursor-stdin-reader.ts new file mode 100644 index 0000000..f3e7c95 --- /dev/null +++ b/src/cli/cursor-stdin-reader.ts @@ -0,0 +1,65 @@ +import { readSync } from "node:fs"; +import { CURSOR_SCANNER_LIMITS, CursorEventScanner } from "./cursor-event-scanner"; + +const HEAD_BYTES = 4096; +const CHUNK_BYTES = 64 * 1024; + +export type CursorStdinRead = + | { kind: "ok"; text: string } + | { kind: "oversize"; head: string }; + +/** + * Requested independent Buffer allocation/retention lengths and scanner cardinalities. + * Zero-copy aliases such as `subarray`, backing pools/ArrayBuffers, JS objects/strings, + * and RSS are excluded; this metric is not a physical or total-memory bound. + */ +export function cursorReaderBounds(maxBytes: number): { + bufferAllocationRequestBytes: number; + scannerTokenEntries: number; + scannerFrames: number; +} { + return { + bufferAllocationRequestBytes: maxBytes + CHUNK_BYTES + HEAD_BYTES + CURSOR_SCANNER_LIMITS.tokenEntries, + scannerTokenEntries: CURSOR_SCANNER_LIMITS.tokenEntries, + scannerFrames: CURSOR_SCANNER_LIMITS.maxDepth, + }; +} + +/** + * Read Cursor stdin through EOF for valid top-level event classification. + * Cursor's host-configured execution timeout bounds an idle open pipe. + */ +export function readCursorBounded(fd: number, maxBytes: number): CursorStdinRead { + const chunk = Buffer.alloc(CHUNK_BYTES); + const head = Buffer.alloc(HEAD_BYTES); + const retained = Buffer.alloc(maxBytes); + const scanner = new CursorEventScanner(); + let headLength = 0; + let retainedLength = 0; + let total = 0; + let oversize = false; + for (;;) { + const length = readSync(fd, chunk, 0, CHUNK_BYTES, null); + if (length === 0) break; + const view = chunk.subarray(0, length); + scanner.write(view); + total += length; + if (headLength < HEAD_BYTES) { + const copied = Math.min(length, HEAD_BYTES - headLength); + view.copy(head, headLength, 0, copied); + headLength += copied; + } + if (!oversize) { + const copied = Math.min(length, Math.max(0, maxBytes - retainedLength)); + if (copied > 0) view.copy(retained, retainedLength, 0, copied); + retainedLength += copied; + if (total > maxBytes) oversize = true; + } + } + if (!oversize) return { kind: "ok", text: retained.subarray(0, total).toString("utf8") }; + const event = scanner.finish(); + return { + kind: "oversize", + head: event ? JSON.stringify({ hook_event_name: event }) : head.subarray(0, headLength).toString("utf8"), + }; +} diff --git a/src/cli/hook-io.ts b/src/cli/hook-io.ts index e8d3f3f..68ee9d4 100644 --- a/src/cli/hook-io.ts +++ b/src/cli/hook-io.ts @@ -4,13 +4,18 @@ * Tracing is stderr-only and active only when FUSE_HARNESS_DEBUG=1 AND * CI=true (both set by test/sim/exec.ts; never in an interactive session). * - * Stdin is read BOUNDED (`resolveStdinMaxBytes`, default 16 MiB): the reader - * never buffers past the cap, so an oversized payload cannot exhaust memory - * nor slip an uninspected tool call through (fail-closed — see `bin.ts`). + * `resolveStdinMaxBytes` (default 16 MiB) caps retained payload content. + * The legacy reader may also retain its overflow chunk; Cursor additionally + * requests fixed scanner/chunk buffers while reading to EOF. Buffer alias views, + * JS objects/strings, and RSS are runtime-dependent, so this is not a physical + * memory guarantee. Unclassified oversized Cursor input fails closed. */ import { readSync } from "node:fs"; import { resolveStdinMaxBytes } from "../config/limits"; import { respond } from "../runtime/respond"; +import { cursorEventContract } from "../adapters/cursor/events"; +import { readCursorBounded } from "./cursor-stdin-reader"; +export { cursorReaderBounds, readCursorBounded } from "./cursor-stdin-reader"; const hookDebug = process.env.FUSE_HARNESS_DEBUG === "1" && process.env.CI === "true"; @@ -24,14 +29,28 @@ export type StdinRead = | { kind: "ok"; text: string } | { kind: "oversize"; head: string }; +const MALFORMED_STDIN: unique symbol = Symbol("cursor-malformed-stdin"); +type MalformedStdin = { readonly [MALFORMED_STDIN]: true }; + /** Type guard for the oversize variant (narrows the readStdin union). */ export function isOversize(x: unknown): x is { kind: "oversize"; head: string } { return typeof x === "object" && x !== null && (x as { kind?: unknown }).kind === "oversize"; } -/** First 4 KiB are kept on oversize for event-name sniffing downstream. */ +/** Identify Cursor JSON parse failures without accepting a forgeable payload field. */ +export function isMalformedCursorStdin(x: unknown): x is MalformedStdin { + return typeof x === "object" && x !== null && MALFORMED_STDIN in x; +} + +/** First 4 KiB are the fallback diagnostic probe when no event key is found. */ const HEAD_BYTES = 4096; const CHUNK = 64 * 1024; +const CURSOR_MAX_STDIN_BYTES = 64 * 1024 * 1024; + +/** Resolve and clamp Cursor's stdin cap while preserving other harness limits. */ +export function resolveCursorStdinMaxBytes(env: Record = process.env): number { + return Math.min(CURSOR_MAX_STDIN_BYTES, resolveStdinMaxBytes(env)); +} /** * Read a file descriptor to EOF, bounded at `maxBytes` (+1 byte to detect @@ -47,8 +66,6 @@ export function readBounded(fd: number, maxBytes: number): StdinRead { const n = readSync(fd, buf, 0, CHUNK, null); if (n === 0) break; total += n; - // Copy the chunk: `buf` is reused by the next readSync, so a subarray VIEW - // would be overwritten (multi-chunk head corruption — audit final). parts.push(Buffer.from(buf.subarray(0, n))); if (total > maxBytes) { return { kind: "oversize", head: Buffer.concat(parts).subarray(0, HEAD_BYTES).toString("utf8") }; @@ -58,17 +75,20 @@ export function readBounded(fd: number, maxBytes: number): StdinRead { return { kind: "ok", text: Buffer.concat(parts).toString("utf8") }; } -/** Read the hook payload from stdin; `{}` on empty or invalid JSON (fail-open parity). */ -export async function readStdin(): Promise | StdinRead> { - const cap = resolveStdinMaxBytes(); - const read = readBounded(0, cap); +/** Read hook stdin; Cursor distinguishes malformed non-empty JSON from historical empty input. */ +export async function readStdin(id?: string): Promise | StdinRead | MalformedStdin> { + const cap = id === "cursor" ? resolveCursorStdinMaxBytes() : resolveStdinMaxBytes(); + const read = id === "cursor" ? readCursorBounded(0, cap) : readBounded(0, cap); if (read.kind === "oversize") return read; const text = read.text.trim(); if (!text) return {}; try { const parsed: unknown = JSON.parse(text); return typeof parsed === "object" && parsed !== null ? (parsed as Record) : {}; - } catch (e) { traceHook("stdin-parse-error", e instanceof Error ? e.message : String(e)); return {}; } + } catch (e) { + traceHook("stdin-parse-error", e instanceof Error ? e.message : String(e)); + return id === "cursor" ? { [MALFORMED_STDIN]: true } : {}; + } } /** Blockable hook events (fail-closed on oversize); others are observation-only. */ @@ -82,11 +102,29 @@ const BLOCKABLE = new Set(["PreToolUse", "UserPromptSubmit", "Stop"]); * @param head - The first bytes of the payload (event-name sniffing). */ export function oversizeStdout(id: string, head: string): string { - const event = /"hook_event_name"\s*:\s*"([^"]+)"/.exec(head)?.[1] ?? ""; - if (event && !BLOCKABLE.has(event)) return ""; + const event = id === "cursor" ? probeEvent(head) : legacyProbeEvent(head); + const maxBytes = id === "cursor" ? resolveCursorStdinMaxBytes() : resolveStdinMaxBytes(); + if (id === "cursor" && event) { + const contract = cursorEventContract(event); + if (!contract.known || !contract.blockable) return "{}"; + } + if (id !== "cursor" && event && !BLOCKABLE.has(event)) return ""; return respond(id, { kind: "block", title: "Oversize hook payload", - reason: `stdin payload exceeds ${resolveStdinMaxBytes()} bytes — denied uninspected`, - }); + reason: `stdin payload exceeds ${maxBytes} bytes — denied uninspected`, + }, id === "cursor" ? (event || "preToolUse") : "PreToolUse"); +} + +function legacyProbeEvent(head: string): string { + return /"hook_event_name"\s*:\s*"([^"]+)"/.exec(head)?.[1] ?? ""; +} + +function probeEvent(head: string): string { + try { + const parsed: unknown = JSON.parse(head); + if (typeof parsed !== "object" || parsed === null) return ""; + const event = (parsed as Record).hook_event_name; + return typeof event === "string" ? event : ""; + } catch { return ""; } } diff --git a/src/cli/json-primitive-scanner.ts b/src/cli/json-primitive-scanner.ts new file mode 100644 index 0000000..6fb4508 --- /dev/null +++ b/src/cli/json-primitive-scanner.ts @@ -0,0 +1,83 @@ +type NumberState = "minus" | "zero" | "integer" | "dot" | "fraction" | "exponentMark" | "exponentSign" | "exponent"; + +/** Validate one JSON literal or number incrementally with constant state. */ +export class JsonPrimitiveScanner { + private literal: "true" | "false" | "null" | undefined; + private literalIndex = 0; + private number: NumberState | undefined; + private valid = true; + + constructor(firstByte: number) { + if (firstByte === 0x74) { this.literal = "true"; this.literalIndex = 1; } + else if (firstByte === 0x66) { this.literal = "false"; this.literalIndex = 1; } + else if (firstByte === 0x6e) { this.literal = "null"; this.literalIndex = 1; } + else if (firstByte === 0x2d) this.number = "minus"; + else if (firstByte === 0x30) this.number = "zero"; + else if (isOneToNine(firstByte)) this.number = "integer"; + else this.valid = false; + } + + /** Consume one non-delimiter byte and report whether the prefix stays valid. */ + write(byte: number): boolean { + if (!this.valid) return false; + if (this.literal) { + this.valid = this.literal.charCodeAt(this.literalIndex) === byte; + this.literalIndex += 1; + return this.valid && this.literalIndex <= this.literal.length; + } + this.valid = this.writeNumber(byte); + return this.valid; + } + + /** Return whether the accumulated primitive is complete JSON grammar. */ + finish(): boolean { + if (!this.valid) return false; + if (this.literal) return this.literalIndex === this.literal.length; + return this.number === "zero" || this.number === "integer" || this.number === "fraction" || this.number === "exponent"; + } + + private writeNumber(byte: number): boolean { + if (this.number === "minus") return this.digitAfter(byte, "zero", "integer"); + if (this.number === "zero") return this.afterInteger(byte, false); + if (this.number === "integer") { + if (isDigit(byte)) return true; + return this.afterInteger(byte, true); + } + if (this.number === "dot") { + if (!isDigit(byte)) return false; + this.number = "fraction"; + return true; + } + if (this.number === "fraction") { + if (isDigit(byte)) return true; + return this.startExponent(byte); + } + if (this.number === "exponentMark") { + if (byte === 0x2b || byte === 0x2d) { this.number = "exponentSign"; return true; } + return this.digitAfter(byte, "exponent", "exponent"); + } + if (this.number === "exponentSign") return this.digitAfter(byte, "exponent", "exponent"); + return this.number === "exponent" && isDigit(byte); + } + + private afterInteger(byte: number, digitAllowed: boolean): boolean { + if (digitAllowed && isDigit(byte)) return true; + if (byte === 0x2e) { this.number = "dot"; return true; } + return this.startExponent(byte); + } + + private startExponent(byte: number): boolean { + if (byte !== 0x65 && byte !== 0x45) return false; + this.number = "exponentMark"; + return true; + } + + private digitAfter(byte: number, zero: NumberState, nonzero: NumberState): boolean { + if (byte === 0x30) { this.number = zero; return true; } + if (isOneToNine(byte)) { this.number = nonzero; return true; } + return false; + } +} + +function isDigit(byte: number): boolean { return byte >= 0x30 && byte <= 0x39; } +function isOneToNine(byte: number): boolean { return byte >= 0x31 && byte <= 0x39; } diff --git a/src/init/templates.ts b/src/init/templates.ts index be759a8..56453c5 100644 --- a/src/init/templates.ts +++ b/src/init/templates.ts @@ -33,17 +33,22 @@ export function codexInit(command: string): InitFile[] { content: json({ hooks: { PreToolUse: [{ matcher: "Bash|apply_patch", hooks: [{ type: "command", command }] }], PostToolUse: [{ matcher: "", hooks: [{ type: "command", command }] }], + UserPromptSubmit: [{ matcher: "", hooks: [{ type: "command", command }] }], } }), }]; } -/** Cursor: `.cursor/hooks.json` (version 1) — shell + tool gate + file-edit observe. */ +/** Cursor: `.cursor/hooks.json` (version 1) — supported pre gates and post observers. */ export function cursorInit(command: string): InitFile[] { return [{ path: ".cursor/hooks.json", content: json({ version: 1, hooks: { - beforeShellExecution: [{ command }], - preToolUse: [{ command }], + beforeShellExecution: [{ command, failClosed: true }], + preToolUse: [{ command, failClosed: true }], + beforeMCPExecution: [{ command, failClosed: true }], + beforeReadFile: [{ command, failClosed: true }], + afterShellExecution: [{ command }], + postToolUse: [{ command }], afterFileEdit: [{ command }], } }), }]; diff --git a/src/policy/guards/bash-write-redirects.ts b/src/policy/guards/bash-write-redirects.ts new file mode 100644 index 0000000..c67b8e3 --- /dev/null +++ b/src/policy/guards/bash-write-redirects.ts @@ -0,0 +1,201 @@ +/** One active shell output-redirection operator and its parsed target. */ +export type ShellRedirect = Readonly<{ start: number; operator: string; target: string }>; + +function closingParen(input: string, start: number): number { + let depth = 1; + let quote: "'" | '"' | null = null; + for (let i = start; i < input.length; i++) { + const ch = input[i]; + if (ch === "\\" && quote !== "'") { i++; continue; } + if (quote) { if (ch === quote) quote = null; continue; } + if (ch === "'" || ch === '"') { quote = ch; continue; } + if (ch === "(") { depth++; continue; } + if (ch === ")" && --depth === 0) return i; + } + return input.length; +} + +function closingBacktick(input: string, start: number): number { + for (let i = start; i < input.length; i++) { + if (input[i] === "\\") { i++; continue; } + if (input[i] === "`") return i; + } + return input.length; +} + +function closingArithmetic(input: string, start: number): number { + let depth = 1; + let quote: "'" | '"' | null = null; + for (let i = start; i < input.length; i++) { + const ch = input[i]; + if (ch === "\\" && quote !== "'") { i++; continue; } + if (quote) { if (ch === quote) quote = null; continue; } + if (ch === "'" || ch === '"') { quote = ch; continue; } + if (ch === "(") { depth++; continue; } + if (ch !== ")") continue; + if (depth === 1 && input[i + 1] === ")") return i + 1; + depth--; + } + return input.length; +} + +function closingConditional(input: string, start: number): number { + let quote: "'" | '"' | null = null; + for (let i = start; i < input.length - 1; i++) { + const ch = input[i]; + if (ch === "\\" && quote !== "'") { i++; continue; } + if (quote) { if (ch === quote) quote = null; continue; } + if (ch === "'" || ch === '"') { quote = ch; continue; } + if (ch === "]" && input[i + 1] === "]") return i; + } + return input.length; +} + +function commentStart(input: string, index: number, start: number): boolean { + return input[index] === "#" && (index === start || /[\s;|&()]/.test(input[index - 1] ?? "")); +} + +function commandPosition(input: string, index: number, start: number): boolean { + const prefix = input.slice(start, index); + const segment = prefix.slice(Math.max(prefix.lastIndexOf(";"), prefix.lastIndexOf("|"), prefix.lastIndexOf("&"), prefix.lastIndexOf("\n")) + 1).trim(); + return segment === "" || /^(?:if|elif|while|until|then|do|!)$/.test(segment); +} + +function readTarget(input: string, start: number, end: number): string { + let out = ""; + let quote: "'" | '"' | null = null; + for (let i = start; i < end; i++) { + const ch = input[i] ?? ""; + if (ch === "\\" && quote !== "'") { + if (i + 1 < end) out += input[++i] ?? ""; + continue; + } + if (quote) { + if (ch === quote) quote = null; + else out += ch; + continue; + } + if (ch === "'" || ch === '"') { quote = ch; continue; } + if (/\s|[;&|<>]/.test(ch)) break; + out += ch; + } + return out; +} + +function scanDoubleQuote(input: string, start: number, end: number, out: ShellRedirect[]): number { + for (let i = start; i < end; i++) { + if (input[i] === "\\") { i++; continue; } + if (input[i] === '"') return i; + if (input[i] === "$" && input[i + 1] === "(" && input[i + 2] === "(") { + const close = closingArithmetic(input, i + 3); + scanExpansions(input, i + 3, close - 1, out); + i = close; + } else if (input[i] === "$" && input[i + 1] === "(") { + const close = closingParen(input, i + 2); + scanRange(input, i + 2, close, out); + i = close; + } else if (input[i] === "`") { + const close = closingBacktick(input, i + 1); + scanRange(input, i + 1, close, out); + i = close; + } + } + return end; +} + +function scanExpansions(input: string, start: number, end: number, out: ShellRedirect[]): void { + for (let i = start; i < end; i++) { + if (input[i] === "\\") { i++; continue; } + if (commentStart(input, i, start)) { + const newline = input.indexOf("\n", i + 1); + i = newline < 0 || newline >= end ? end : newline; + continue; + } + if (input[i] === "'") { + const close = input.indexOf("'", i + 1); + i = close < 0 || close >= end ? end : close; + continue; + } + if (input[i] === '"') { i = scanDoubleQuote(input, i + 1, end, out); continue; } + if (input[i] === "$" && input[i + 1] === "(" && input[i + 2] === "(") { + const close = closingArithmetic(input, i + 3); + scanExpansions(input, i + 3, close - 1, out); + i = close; + } else if (input[i] === "$" && input[i + 1] === "(") { + const close = closingParen(input, i + 2); + scanRange(input, i + 2, close, out); + i = close; + } else if (input[i] === "`") { + const close = closingBacktick(input, i + 1); + scanRange(input, i + 1, close, out); + i = close; + } + } +} + +function scanRange(input: string, start: number, end: number, out: ShellRedirect[]): void { + for (let i = start; i < end; i++) { + const ch = input[i]; + if (ch === "\\") { i++; continue; } + if (commentStart(input, i, start)) { + const newline = input.indexOf("\n", i + 1); + i = newline < 0 || newline >= end ? end : newline; + continue; + } + if (ch === "'") { + const close = input.indexOf("'", i + 1); + i = close < 0 || close >= end ? end : close; + continue; + } + if (ch === '"') { i = scanDoubleQuote(input, i + 1, end, out); continue; } + if (ch === "[" && input[i + 1] === "[" && commandPosition(input, i, start)) { + const close = closingConditional(input, i + 2); + scanExpansions(input, i + 2, close, out); + i = close + 1; + continue; + } + if (ch === "(" && input[i + 1] === "(" && commandPosition(input, i, start)) { + const close = closingArithmetic(input, i + 2); + scanExpansions(input, i + 2, close - 1, out); + i = close; + continue; + } + if (ch === "$" && input[i + 1] === "(" && input[i + 2] === "(") { + const close = closingArithmetic(input, i + 3); + scanExpansions(input, i + 3, close - 1, out); + i = close; + continue; + } + if (ch === "$" && input[i + 1] === "(") { + const close = closingParen(input, i + 2); + scanRange(input, i + 2, close, out); + i = close; + continue; + } + if (ch === "`") { + const close = closingBacktick(input, i + 1); + scanRange(input, i + 1, close, out); + i = close; + continue; + } + if (ch !== ">") continue; + if (input[i + 1] === "(") continue; + if (input[i - 1] === "&") { if (input[i + 1] === ">") i++; continue; } + let opStart = i; + while (opStart > start && /\d/.test(input[opStart - 1] ?? "")) opStart--; + if (input[opStart - 1] === "&") opStart--; + let cursor = i + (input[i + 1] === ">" || input[i + 1] === "|" ? 2 : 1); + const operator = input.slice(opStart, cursor); + while (/\s/.test(input[cursor] ?? "")) cursor++; + if (input[cursor] === "&") continue; + const target = readTarget(input, cursor, end); + if (target) out.push({ start: opStart, operator, target }); + } +} + +/** Return active output redirects, excluding quoted/escaped `>` characters. */ +export function shellOutputRedirects(command: string): ShellRedirect[] { + const out: ShellRedirect[] = []; + scanRange(command, 0, command.length, out); + return out; +} diff --git a/src/policy/guards/bash-write-safe-paths.ts b/src/policy/guards/bash-write-safe-paths.ts index b8de4f0..46d4162 100644 --- a/src/policy/guards/bash-write-safe-paths.ts +++ b/src/policy/guards/bash-write-safe-paths.ts @@ -1,6 +1,7 @@ import { join, normalize } from "node:path"; import { homedir } from "node:os"; import { claudeHome, fusengineCache } from "../../runtime/home-state"; +import { shellOutputRedirects } from "./bash-write-redirects"; /** * Writable paths the harness owns — writes here never need Write/Edit's APEX @@ -22,16 +23,12 @@ function resolvePath(raw: string): string { return normalize(expanded.replace(/\$HOME/g, homedir())); } -/** Extract the file path after a `>`/`>>` redirect (parity extract_redirect_target). */ -function extractRedirectTarget(cmd: string): string | null { - const m = cmd.match(/>>\s*(\S+)|(?\s*(\S+)/); - return m ? resolvePath(m[1] ?? m[2] ?? "") : null; -} - /** True when a `>`/`>>` redirect targets a harness-owned safe path (parity is_safe_write_path). */ export function isSafeWritePath(cmd: string): boolean { - const target = extractRedirectTarget(cmd); - return target !== null && SAFE_WRITE_PATHS.some((safe) => target === safe || target.startsWith(safe + "/")); + const targets = shellOutputRedirects(cmd) + .map((redirect) => resolvePath(redirect.target)) + .filter((target) => target !== "/dev/null"); + return targets.length > 0 && targets.every((target) => SAFE_WRITE_PATHS.some((safe) => target === safe || target.startsWith(safe + "/"))); } /** Extract the file argument of `tee`/`dd of=` (parity extract_command_target). */ diff --git a/src/policy/guards/bash-write.ts b/src/policy/guards/bash-write.ts index 395ef92..00e0c2b 100644 --- a/src/policy/guards/bash-write.ts +++ b/src/policy/guards/bash-write.ts @@ -1,8 +1,9 @@ import type { Prompt } from "../../prompt/types"; import type { GuardContext } from "./context"; import { hasSafeWriteTarget, isSafeCommandTarget, isSafeWritePath } from "./bash-write-safe-paths"; +import { shellOutputRedirects } from "./bash-write-redirects"; import { - ASK_WRITERS, CODE_COMMAND_WRITE, CODE_MUTATORS, CODE_REDIRECT, FILE_REDIRECT, NODE_WRITES, + ASK_WRITERS, CODE_COMMAND_WRITE, CODE_MUTATORS, NODE_WRITES, PYTHON_C_ANCHOR, PYTHON_WRITES, RUBY_WRITES, SAFE_PREFIXES, SESSION_STATE_FRAGMENT, } from "./bash-write-patterns"; @@ -11,8 +12,8 @@ export { ASK_WRITERS, CODE_MUTATORS, CODE_REDIRECT, FILE_REDIRECT, SAFE_PREFIXES function blockCodeWrite(reason: string): Prompt { return { kind: "block", title: "Bash write to code file", reason, actions: ["Use the Write/Edit tool instead"] }; } -function askFileWrite(reason: string): Prompt { - return { kind: "ask", title: "Bash file write", reason, actions: ["Use the Write/Edit tool instead"] }; +function askFileWrite(reason: string, ruleId = "bash-write:file-write"): Prompt { + return { kind: "ask", ruleId, title: "Bash file write", reason, actions: ["Use the Write/Edit tool instead"] }; } /** @@ -33,6 +34,7 @@ export function bashWriteGuard(ctx: GuardContext): Prompt | null { if (ctx.tool !== "Bash" || !ctx.command) return null; const cmd: string = ctx.command; const stripped = cmd.trim(); + const redirects = shellOutputRedirects(cmd).filter((redirect) => redirect.target !== "/dev/null"); const mutator = CODE_MUTATORS.find((m) => m.re.test(cmd)); if (mutator) return blockCodeWrite(`${mutator.desc} — Use Edit/Write tools instead`); @@ -41,7 +43,7 @@ export function bashWriteGuard(ctx: GuardContext): Prompt | null { return blockCodeWrite("Python inline script mutates files/spawns a process — Use Edit/Write tools instead"); } - if (SAFE_PREFIXES.some((p) => stripped.startsWith(p)) && !FILE_REDIRECT.test(stripped)) { + if (SAFE_PREFIXES.some((p) => stripped.startsWith(p)) && redirects.length === 0) { return null; } @@ -54,11 +56,11 @@ export function bashWriteGuard(ctx: GuardContext): Prompt | null { }; } - if (FILE_REDIRECT.test(cmd)) { + if (redirects.length > 0) { if (isSafeWritePath(cmd)) return null; - return CODE_REDIRECT.test(cmd) + return redirects.some((redirect) => /\.(?:ts|tsx|js|jsx|py|go|rb|rs|java|kt|php|swift|vue|svelte|astro|css|c|cpp|h)\b/.test(redirect.target)) ? blockCodeWrite("Bash redirect to code file — Use Write/Edit tools (enforces APEX + SOLID specs)") - : askFileWrite("Shell redirect to file detected. Authorize?"); + : askFileWrite("Shell redirect to file detected. Authorize?", "bash-write:file-redirect"); } if (/\bnode\s+-e\b/.test(cmd) && NODE_WRITES.test(cmd)) { diff --git a/src/policy/guards/protected-path.ts b/src/policy/guards/protected-path.ts index b24614c..bd7507a 100644 --- a/src/policy/guards/protected-path.ts +++ b/src/policy/guards/protected-path.ts @@ -1,5 +1,6 @@ import type { Prompt } from "../../prompt/types"; import type { GuardContext } from "./context"; +import { shellOutputRedirects } from "./bash-write-redirects"; /** * Path fragments that mark a location as internal/generated state. @@ -65,7 +66,7 @@ function extractWriteTargets(cmd: string): string[] { const v: string = unquote((t ?? "").trim()); if (v && v !== "/dev/null") out.push(v); }; - for (const m of cmd.matchAll(/(?{1,2}\s*('[^']+'|"[^"]+"|\S+)/g)) push(m[1]); + for (const redirect of shellOutputRedirects(cmd)) push(redirect.target); for (const m of cmd.matchAll(/\btee\b(?:\s+-\S+)*\s+('[^']+'|"[^"]+"|\S+)/g)) push(m[1]); for (const m of cmd.matchAll(/\bdd\b[^|;&]*\bof=('[^']+'|"[^"]+"|\S+)/g)) push(m[1]); for (const seg of cmd.split(/[;&|]+/)) { diff --git a/src/policy/shell-read-refs.ts b/src/policy/shell-read-refs.ts index 4d65cdc..01b8925 100644 --- a/src/policy/shell-read-refs.ts +++ b/src/policy/shell-read-refs.ts @@ -16,6 +16,7 @@ * @packageDocumentation */ import { commandToString } from "../runtime/command-string"; +import { shellOutputRedirects } from "./guards/bash-write-redirects"; /** Read-only shell commands that can target a `.md` reference by path. */ const READ_COMMANDS = new Set(["cat", "head", "tail", "sed", "rg", "ripgrep", "less", "more", "bat"]); @@ -33,8 +34,8 @@ function segments(command: string): string[] { /** Strip a redirection (`>`, `>>`, `<`, `2>`, `&>`, …) and everything after — its target is WRITTEN, not read. */ function beforeRedirect(segment: string): string { - const m = segment.match(/\s(?:\d*>{1,2}|<|&>)\s*\S/); - return m ? segment.slice(0, m.index) : segment; + const first = shellOutputRedirects(segment)[0]; + return first ? segment.slice(0, first.start) : segment; } /** Naive shell tokenizer: whitespace-split, stripping one matching layer of quotes per token. */ diff --git a/src/prompt/types.ts b/src/prompt/types.ts index 2e0557a..6ae6b62 100644 --- a/src/prompt/types.ts +++ b/src/prompt/types.ts @@ -8,6 +8,8 @@ export type PromptKind = "ask" | "block" | "inform"; */ export interface Prompt { kind: PromptKind; + /** Stable policy identifier for confirmation diagnostics. */ + ruleId?: string; /** Short title, e.g. "SOLID file-size limit". */ title: string; /** Why this fired. */ diff --git a/src/runtime/confirm/codex-confirm.ts b/src/runtime/confirm/codex-confirm.ts new file mode 100644 index 0000000..7e2da93 --- /dev/null +++ b/src/runtime/confirm/codex-confirm.ts @@ -0,0 +1,126 @@ +import { homedir } from "node:os"; +import { existsSync, mkdirSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { LOCK_FAILED, withTrackLockSync } from "../../tracking/track-lock-sync"; +import { atomicWrite } from "../../util/json-io"; +import { sanitizeSessionId, sessionsDir } from "../home-state"; +import { commandToString } from "../command-string"; +import { displayCodeForAction, hashForAction } from "./confirm-code"; +import { isSubagentActive } from "./confirm-subagent"; +import type { CodexPromptOrigin } from "./codex-prompt-origin"; + +const TTL_MS = 5 * 60 * 1000; +const STATE_VERSION = 1; + +type RejectReason = "no-token" | "mismatch" | "expired" | "already-consumed" | "missing-tool-use-id" | "state-io"; +type Action = Readonly<{ hash: string; code: string; command: string; ts: number }>; +type Receipt = Action & Readonly<{ toolUseId: string }>; +type CodexState = Readonly<{ codexConfirmPending?: Action; codexConfirmToken?: Action; codexConfirmReceipt?: Receipt }>; + +function statePath(sid: string, home: string): string { + return join(sessionsDir(home), `codex-confirm-${sid}.json`); +} + +function lockDir(sid: string, home: string): string { + return join(sessionsDir(home), ".confirm-locks", sid); +} + +function loadState(sid: string, home: string): CodexState { + const path = statePath(sid, home); + if (!existsSync(path)) return {}; + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as CodexState : {}; +} + +function saveState(sid: string, state: CodexState, home: string): void { + mkdirSync(sessionsDir(home), { recursive: true, mode: 0o700 }); + atomicWrite(statePath(sid, home), JSON.stringify(state, null, 2)); +} + +/** Canonical identity of one Codex shell action and its display token. */ +export function codexAction(tool: string, cwd: string, command: unknown, now: number): Action | null { + const canonicalCommand = commandToString(command); + if (!canonicalCommand) return null; + const identity = JSON.stringify({ version: STATE_VERSION, harness: "codex", tool, cwd: resolve(cwd), command: canonicalCommand }); + return { hash: hashForAction(identity), code: displayCodeForAction(identity), command: canonicalCommand, ts: now }; +} + +/** Atomically authorize or reject a Codex action, recording the current denial when needed. */ +export function authorizeCodexAction( + sessionIdRaw: unknown, + action: Action, + toolUseId: string | undefined, + now: number, + home: string = homedir(), +): { allow: true } | { allow: false; reason: RejectReason } { + const sid = sanitizeSessionId(sessionIdRaw); + if (!sid) return { allow: false, reason: "state-io" }; + try { + const result = withTrackLockSync(lockDir(sid, home), () => { + const state = loadState(sid, home); + const token = state.codexConfirmToken; + const receipt = state.codexConfirmReceipt; + const receiptExpired = receipt !== undefined && now - receipt.ts > TTL_MS; + if (!receiptExpired && receipt?.hash === action.hash && receipt.toolUseId === toolUseId && toolUseId) return { allow: true } as const; + + let reason: RejectReason = "no-token"; + if (token) { + if (token.hash !== action.hash) reason = "mismatch"; + else if (now - token.ts > TTL_MS) reason = "expired"; + else if (!toolUseId) reason = "missing-tool-use-id"; + else { + const { codexConfirmToken: _token, codexConfirmPending: _pending, ...rest } = state; + saveState(sid, { ...rest, codexConfirmReceipt: { ...action, toolUseId } satisfies Receipt }, home); + return { allow: true } as const; + } + } else if (receipt?.hash === action.hash) reason = receiptExpired ? "expired" : "already-consumed"; + + if (reason === "missing-tool-use-id") return { allow: false, reason } as const; + const pending = state.codexConfirmPending; + const stablePending = pending?.hash === action.hash ? pending : action; + const { codexConfirmToken: _token, codexConfirmReceipt: _receipt, ...withoutReceipt } = state; + const rest = receiptExpired ? withoutReceipt : { ...withoutReceipt, codexConfirmReceipt: receipt }; + saveState(sid, { ...rest, codexConfirmPending: stablePending }, home); + return { allow: false, reason } as const; + }); + return result === LOCK_FAILED ? { allow: false, reason: "state-io" } : result; + } catch { + return { allow: false, reason: "state-io" }; + } +} + +/** + * Atomically arm the last Codex denial, consuming its pending record exactly once. + * A classified root prompt may bypass G0; classified subagent/unknown prompts fail closed. + */ +export function submitCodexConfirmation( + sessionIdRaw: unknown, + text: string, + now: number, + home: string = homedir(), + origin?: CodexPromptOrigin, +): void { + const sid = sanitizeSessionId(sessionIdRaw); + if (!sid) return; + const refusal = /\b(non|no|stop|annule|cancel|abort|nope|laisse tomber|pas maintenant)\b/i.test(text); + const typedCode = text.trim().match(/^confirm[ _-]*([0-9a-f]{4})$/i)?.[1]; + try { + withTrackLockSync(lockDir(sid, home), () => { + const state = loadState(sid, home); + if (origin !== undefined && origin !== "root") return; + if (refusal) { + const { codexConfirmPending: _pending, codexConfirmToken: _token, ...rest } = state; + saveState(sid, rest, home); + return; + } + if (!typedCode) return; + if (origin === undefined && isSubagentActive(sid, now, home)) return; + const pending = state.codexConfirmPending; + if (!pending || pending.code.toLowerCase() !== typedCode.toLowerCase()) return; + const { codexConfirmPending: _pending, ...rest } = state; + saveState(sid, { ...rest, codexConfirmToken: { ...pending, ts: now } }, home); + }); + } catch { + // Submission is advisory state wiring; hook execution must remain available. + } +} diff --git a/src/runtime/confirm/codex-prompt-origin.ts b/src/runtime/confirm/codex-prompt-origin.ts new file mode 100644 index 0000000..85bf2a6 --- /dev/null +++ b/src/runtime/confirm/codex-prompt-origin.ts @@ -0,0 +1,16 @@ +/** Provenance classification for a Codex `UserPromptSubmit` payload. */ +export type CodexPromptOrigin = "root" | "subagent" | "unknown"; + +const AGENT_FIELDS = ["agent_id", "agent_type"] as const; + +/** + * Classify Codex prompt provenance from its optional agent metadata. + * Missing metadata is the observed root shape; malformed metadata fails closed. + */ +export function codexPromptOrigin(payload: Record): CodexPromptOrigin { + const present = AGENT_FIELDS.filter((field) => Object.hasOwn(payload, field)); + if (present.length === 0) return "root"; + return present.every((field) => typeof payload[field] === "string" && payload[field].trim().length > 0) + ? "subagent" + : "unknown"; +} diff --git a/src/runtime/confirm/confirm-gate.ts b/src/runtime/confirm/confirm-gate.ts index 85b0803..5df0e1c 100644 --- a/src/runtime/confirm/confirm-gate.ts +++ b/src/runtime/confirm/confirm-gate.ts @@ -3,6 +3,7 @@ import { displayCodeForAction, hashForAction } from "./confirm-code"; import { isIrreversible } from "./confirm-irreversible"; import { recordPendingDeny } from "./confirm-pending"; import { consumeConfirmToken } from "./confirm-state"; +import { authorizeCodexAction, codexAction } from "./codex-confirm"; /** * Harnesses where `respond.ts` silently downgrades `kind: "ask"` to a hard @@ -29,9 +30,26 @@ export type ConfirmVerdict = { allow: true } | { allow: false; prompt: Prompt }; * @param now - Epoch ms. * @param home - Test-only OS home override. */ -export function confirmGate(id: string, prompt: Prompt, command: string | undefined, sessionId: string, now: number, home?: string): ConfirmVerdict | null { +export function confirmGate( + id: string, + prompt: Prompt, + command: string | undefined, + sessionId: string, + now: number, + home?: string, + codex?: Readonly<{ tool: string; cwd: string; toolUseId?: string }>, +): ConfirmVerdict | null { if (prompt.kind !== "ask" || !DEGRADES_ASK_TO_DENY.has(id) || !command || isIrreversible(command)) return null; try { + if (id === "codex" && codex) { + const action = codexAction(codex.tool, codex.cwd, command, now); + if (!action) return null; + const verdict = authorizeCodexAction(sessionId, action, codex.toolUseId, now, home); + if (verdict.allow) return verdict; + const ruleId = prompt.ruleId ?? "policy:ask"; + const diagnostic = `rule ID: ${ruleId}\ncanonical command: ${action.command}\nexpected token: CONFIRM ${action.code}\nrejection: ${verdict.reason}`; + return { allow: false, prompt: { ...prompt, reason: `${prompt.reason}\nPour autoriser, réponds : CONFIRM ${action.code}\n${diagnostic}` } }; + } const hash = hashForAction(command); if (consumeConfirmToken(sessionId, hash, now, home)) return { allow: true }; const code = displayCodeForAction(command); diff --git a/src/runtime/gate.ts b/src/runtime/gate.ts index e28628d..e04a95a 100644 --- a/src/runtime/gate.ts +++ b/src/runtime/gate.ts @@ -41,12 +41,41 @@ export const TRIVIAL_BUDGET = 4; */ export async function gate(input: GateInput): Promise { const prompt = await runGates(input); - const op = { filePath: input.filePath, content: input.content, command: input.command }; + return finalizeGate(input, prompt, input.command); +} + +/** + * Gate independent Cursor command candidates without merging their shell syntax. + * Stateful outcome bookkeeping runs once, against the command that decided the + * aggregate result. A single distinct candidate follows {@link gate} verbatim. + */ +export async function gateCommandCandidates(input: GateInput, candidates: readonly string[]): Promise { + const distinct = [...new Set(candidates)]; + if (distinct.length <= 1) return gate(input); + + let decisive: { prompt: Prompt; command: string } | undefined; + for (const command of distinct) { + const prompt = await runGates({ ...input, command }); + if (!prompt) continue; + if (!decisive || promptRank(prompt) > promptRank(decisive.prompt)) decisive = { prompt, command }; + if (prompt.kind === "block") break; + } + return finalizeGate(input, decisive?.prompt ?? null, decisive?.command ?? input.command); +} + +/** Persist one final gate outcome and apply deny-loop enrichment exactly once. */ +function finalizeGate(input: GateInput, prompt: Prompt | null, command: string | undefined): Prompt | null { + const op = { filePath: input.filePath, content: input.content, command }; const dir = dirname(input.trackFile); recordOneShot(prompt, op, { now: input.now, dir, sessionId: input.sessionId }); return withDenyLoop(prompt, input.tool, op, { now: input.now, dir, windowMs: input.windowMs ?? DEFAULT_WINDOW_MS, sessionId: input.sessionId }); } +/** Higher numeric values dominate while equal values preserve wire order. */ +function promptRank(prompt: Prompt): number { + return prompt.kind === "block" ? 3 : prompt.kind === "ask" ? 2 : 1; +} + /** Stateless guards, then the trivial fast path, then the stateful APEX gates. */ async function runGates(input: GateInput): Promise { // Pre-commit lint hard-block runs FIRST: evaluate()'s GIT_ASK branch would diff --git a/src/runtime/handle-post.ts b/src/runtime/handle-post.ts index 751957d..8c752f1 100644 --- a/src/runtime/handle-post.ts +++ b/src/runtime/handle-post.ts @@ -2,7 +2,6 @@ import { extractText } from "../cache/mcp-response"; import { activityFor } from "./activity"; import { mcpPostStore } from "./mcp"; import { recordActivity } from "./record"; -import { respond } from "./respond"; import { designGate } from "./design"; import { postEditContext } from "./lifecycle-bridge"; import { postTrackingSideEffects } from "./lifecycle/post-tracking"; @@ -12,14 +11,18 @@ import { classifyAgentEvidence, recordAgentEvidence } from "../freshness/agent-e import { recordCodexSpawnEvidence } from "../freshness/codex-spawn-evidence"; import { captureBashReceipt } from "./receipt-capture"; import { recordCodexPostFailure } from "../tracking/codex-post-failure"; -import { designPassNotice } from "../policy/design/gates"; -import { attachSystemMessage } from "../adapters/claude"; -import { refCreditNoticeFor } from "./notices"; import { defaultStateDir } from "./paths"; import { fanOutFiles, firstFileMatch } from "./post-fanout"; +import { postOutcome } from "./post-outcome"; import type { PreContext } from "./handle-pre"; import type { HandleOutcome } from "./handle"; -import type { Prompt } from "../prompt/types"; + +function isCursorAfterFileEdit(id: string, payload: Record): boolean { + if (id !== "cursor") return false; + const hookEvent = typeof payload.hook_event_name === "string" ? payload.hook_event_name : ""; + if (hookEvent) return /^afterFileEdit$/i.test(hookEvent); + return typeof payload.file_path === "string" && Array.isArray(payload.edits); +} /** * Run the PostToolUse pipeline: store the MCP response, emit a design warning, @@ -43,8 +46,16 @@ import type { Prompt } from "../prompt/types"; */ export async function handlePost(ctx: PreContext): Promise { const { id, payload, event, framework, mcpDir, file, opts } = ctx; + const cursorAfterFileEdit = isCursorAfterFileEdit(id, payload); const designCacheDir = ctx.designCacheDir ?? mcpDir; - const response = payload.tool_response ?? payload.tool_output; + let response = payload.tool_response ?? payload.tool_output; + if (id === "cursor" && /^afterMCPExecution$/i.test(event.eventName ?? "")) { + try { + response = typeof payload.result_json === "string" ? JSON.parse(payload.result_json) : payload.result_json; + } catch { + response = undefined; + } + } mcpPostStore(event.tool, event.input, response, mcpDir); const designWarn = designGate(payload, event, designCacheDir, opts.cwd, opts.corpusRoot); const activities = activityFor({ tool: event.tool, input: event.input, sessionId: event.sessionId, framework, now: opts.now, responseLength: extractText(response).length }); @@ -60,56 +71,34 @@ export async function handlePost(ctx: PreContext): Promise { // (Kimi's string `tool_output` would forge a success receipt; see module). await captureBashReceipt(file, event.tool, event.command, payload.tool_result, response, opts.now); if (id === "codex") recordCodexPostFailure(event.tool, payload.tool_result ?? response, { now: opts.now, dir: defaultStateDir(opts.cwd), sessionId: event.sessionId }); - // Codex `apply_patch` fans into per-file events here (tracking, SOLID size, - // Tailwind, post-edit context, and the notice below); every other tool is a - // single-element identity array, so behavior below is unchanged for them. + // Codex `apply_patch` and Cursor `afterFileEdit` fan into per-file events for + // tracking, validation, post-edit context, and notices. const files = fanOutFiles(event); for (const f of files) postTrackingSideEffects(opts.scope ?? "core", f, f.input, opts.now, payload, opts.cwd); const seoDeny = opts.scope === "seo" ? seoPostToolUseResponse(payload) : null; - if (seoDeny) return { stdout: seoDeny, exit: 0 }; + if (seoDeny && !cursorAfterFileEdit) return { stdout: seoDeny, exit: 0 }; if (opts.scope === "solid") { const solidWarn = firstFileMatch(files, checkFileSize); - if (solidWarn) return { stdout: solidWarn, exit: 0 }; + if (solidWarn && !cursorAfterFileEdit) return { stdout: solidWarn, exit: 0 }; } if (opts.scope === "tailwindcss") { const tailwindWarn = firstFileMatch(files, validateTailwind); - if (tailwindWarn) return { stdout: tailwindWarn, exit: 0 }; + if (tailwindWarn && !cursorAfterFileEdit) return { stdout: tailwindWarn, exit: 0 }; } if (opts.scope === "aipilot" && (event.tool === "TaskCreate" || event.tool === "TaskUpdate" || event.tool === "Write" || event.tool === "Edit")) { const out = await aipilotPostToolUse(payload, opts.cwd, id); - if (out) return { stdout: out, exit: 0 }; + if (out && !cursorAfterFileEdit) return { stdout: out, exit: 0 }; } let extra = ""; for (const f of files) { extra = await postEditContext(opts.scope ?? "core", f, opts.now, id); if (extra) break; } - // Python-parity `post_pass`: user-visible pass notice, merged into whatever else fires - // (deny paths above returned already — a deny stays byte-identical). Run - // per fanned file (event.tool/filePath/content are always undefined on the - // raw apply_patch envelope) and CONCATENATE every non-empty line — unlike - // designWarn, a notice is advisory-only, so every file's line is kept, not - // just the first. - const agentId = typeof payload.agent_id === "string" ? payload.agent_id : ""; - const noticeLines: string[] = []; - for (const f of files) { - const n = designPassNotice({ agentId, tool: f.tool, filePath: f.filePath ?? "", content: f.content ?? "", url: "", phase: "post" }, designCacheDir); - if (n?.userMessage) noticeLines.push(n.userMessage); - } - const notice: Prompt | null = noticeLines.length ? { kind: "inform", title: "Design pipeline", reason: "", userMessage: noticeLines.join("\n") } : null; - // Compact compliance notice: a skill/SOLID `.md` reference credited by THIS - // PostToolUse call (dedup'd against the ×11 hook fan-out inside refCreditNoticeFor). - const refNotice = refCreditNoticeFor(activities, event.sessionId, opts.now, defaultStateDir(opts.cwd)); - const userMessage = [notice?.userMessage, refNotice].filter(Boolean).join("\n") || undefined; - if (designWarn) return { stdout: respond(id, userMessage ? { ...designWarn, userMessage } : designWarn, "PostToolUse"), exit: 0 }; - if (!userMessage) return { stdout: extra, exit: 0 }; - const withUserMessage: Prompt = notice ? { ...notice, userMessage } : { kind: "inform", title: "Compliance", reason: "", userMessage }; - if (!extra) return { stdout: respond(id, withUserMessage, "PostToolUse"), exit: 0 }; - // `extra` is already-rendered Claude-shaped stdout (postEditContext): claude/codex get the - // notice attached onto it; other harnesses cannot parse `extra` anyway, so the notice — - // rendered natively by respond() — replaces it (cline's pure notice is "", keeping extra; - // kimi never inherits the Claude-shaped `extra` — it could not parse it). - if (id === "claude-code" || id === "codex") return { stdout: attachSystemMessage(extra, userMessage), exit: 0 }; - if (id === "kimi") return { stdout: respond(id, withUserMessage, "PostToolUse"), exit: 0 }; - return { stdout: respond(id, withUserMessage, "PostToolUse") || extra, exit: 0 }; + return postOutcome({ + id, agentId: typeof payload.agent_id === "string" ? payload.agent_id : "", sessionId: event.sessionId, + now: opts.now, cwd: opts.cwd, activities, files, + designCacheDir, designWarn, extra, + cursorAfterFileEdit, + cursorEventName: typeof payload.hook_event_name === "string" ? payload.hook_event_name : "", + }); } diff --git a/src/runtime/handle-pre.ts b/src/runtime/handle-pre.ts index cdeaafd..0a8e366 100644 --- a/src/runtime/handle-pre.ts +++ b/src/runtime/handle-pre.ts @@ -1,6 +1,6 @@ import { dirname } from "node:path"; import { loadRefs } from "../refs/loader"; -import { gate } from "./gate"; +import { gate, gateCommandCandidates } from "./gate"; import { MCP_TTL_MS, mcpPreIntercept } from "./mcp"; import type { NormalizedEvent } from "./normalize"; import { recordActivity } from "./record"; @@ -14,6 +14,7 @@ import { isAgentTool } from "./is-agent-tool"; import { allowOutcome } from "./pre-allow"; import { applyPatchGate } from "./apply-patch-gate"; import { isBypassPermissions } from "../adapters/codex/permission-mode"; +import { evaluate } from "../policy/evaluate"; import { confirmGate } from "./confirm/confirm-gate"; import type { HandleOptions, HandleOutcome } from "./handle"; @@ -50,7 +51,17 @@ export async function handlePre(ctx: PreContext): Promise { } const designBlock = designGate(payload, event, designCacheDir, opts.cwd, opts.corpusRoot); - if (designBlock) return { stdout: withDenyNotice(id, respond(id, designBlock), designBlock, event.sessionId, dirname(file), opts.now), exit: 0 }; + if (designBlock) return { stdout: withDenyNotice(id, respond(id, designBlock, event.eventName ?? "PreToolUse"), designBlock, event.sessionId, dirname(file), opts.now), exit: 0 }; + + if (id === "cursor" && (event.eventName === "beforeReadFile" || event.eventName === "beforeTabFileRead")) { + const readPolicy = evaluate({ tool: event.tool, filePath: event.filePath }); + return { + stdout: readPolicy.prompt + ? respond(id, readPolicy.prompt, event.eventName) + : JSON.stringify({ permission: "allow" }), + exit: 0, + }; + } // Security scope is advisory-only (ports check-security-skill.py): emit the // non-blocking advisory when the skill is unread, else allow — NEVER run the @@ -79,10 +90,11 @@ export async function handlePre(ctx: PreContext): Promise { // envelope. `event.files` is undefined for every other tool/harness. if (event.files && event.files.length > 0) { const patchPrompt = applyPatchGate(event.files, opts.cwd); - if (patchPrompt) return { stdout: withDenyNotice(id, respond(id, patchPrompt), patchPrompt, event.sessionId, dirname(file), opts.now), exit: 0 }; + if (patchPrompt) return { stdout: withDenyNotice(id, respond(id, patchPrompt, event.eventName ?? "PreToolUse"), patchPrompt, event.sessionId, dirname(file), opts.now), exit: 0 }; } - const prompt = await gate({ + const refs = opts.refsDir ? await loadRefs(opts.refsDir) : undefined; + const gateInput = { sessionId: event.sessionId, framework, tool: event.tool, @@ -90,7 +102,7 @@ export async function handlePre(ctx: PreContext): Promise { content: event.content, command: event.command, cwd: opts.cwd, - refs: opts.refsDir ? await loadRefs(opts.refsDir) : undefined, + refs, isReplaceAll: event.input.replace_all === true, oldString: event.oldString, agentType: event.agentType, @@ -100,19 +112,23 @@ export async function handlePre(ctx: PreContext): Promise { trackFile: file, transcriptPath: typeof payload.transcript_path === "string" ? payload.transcript_path : undefined, neverApproval: id === "codex" && isBypassPermissions(event.permissionMode), - }); + }; + const prompt = id === "cursor" && (event.commandCandidates?.length ?? 0) > 1 + ? await gateCommandCandidates(gateInput, event.commandCandidates!) + : await gate(gateInput); if (prompt) { // CONFIRM flow: ONLY changes anything when Codex/Kimi are about to // downgrade THIS `ask` to a hard deny (confirmGate returns null in every // other case — including every claude-code call, unconditionally, and // every non-`ask` prompt kind — so the line below is byte-identical to // the pre-CONFIRM behavior whenever it applies). - const confirm = confirmGate(id, prompt, event.command, event.sessionId, opts.now, opts.home); + const confirm = confirmGate(id, prompt, event.command, event.sessionId, opts.now, opts.home, + id === "codex" ? { tool: event.tool, cwd: event.cwd ?? opts.cwd, toolUseId: event.toolUseId } : undefined); if (confirm?.allow) { return allowOutcome(id, event, payload, designCacheDir, opts.cwd, { trackFile: file, windowMs: opts.windowMs, now: opts.now }, opts.corpusRoot); } const finalPrompt = confirm ? confirm.prompt : prompt; - return { stdout: withDenyNotice(id, respond(id, finalPrompt), finalPrompt, event.sessionId, dirname(file), opts.now), exit: 0 }; + return { stdout: withDenyNotice(id, respond(id, finalPrompt, event.eventName ?? "PreToolUse"), finalPrompt, event.sessionId, dirname(file), opts.now), exit: 0 }; } // Every gate allowed: hand off to the ALLOW-path assembly (pass notice + // decision-time lesson + evidence-fresh notice). A deny/ask already returned diff --git a/src/runtime/handle-scope-async.ts b/src/runtime/handle-scope-async.ts index 403a5ac..5ece683 100644 --- a/src/runtime/handle-scope-async.ts +++ b/src/runtime/handle-scope-async.ts @@ -5,6 +5,8 @@ */ import { dispatchAipilot } from "./lifecycle"; import { dispatchMemory } from "./lifecycle/memory/dispatch"; +import { cursorEventContract } from "../adapters/cursor/events"; +import { toCursorLifecycleResponse } from "../adapters/cursor/respond"; import type { PluginScope } from "./lifecycle"; /** @@ -18,7 +20,10 @@ import type { PluginScope } from "./lifecycle"; * @returns The native stdout when intercepted, or `null` to fall through. */ export async function asyncScopeStdout(scope: PluginScope | undefined, event: string, payload: Record, cwd: string, now: number, id: string = "claude-code"): Promise { - if (scope === "aipilot") return dispatchAipilot(event, payload, cwd, now, undefined, id); - if (scope === "memory") return dispatchMemory(event, payload, cwd, now); - return null; + const dispatchedEvent = id === "cursor" ? cursorEventContract(event).lifecycle : event; + if (dispatchedEvent === null) return null; + let stdout: string | null = null; + if (scope === "aipilot") stdout = await dispatchAipilot(dispatchedEvent, payload, cwd, now, undefined, id); + if (scope === "memory") stdout = await dispatchMemory(dispatchedEvent, payload, cwd, now); + return id === "cursor" && stdout !== null ? toCursorLifecycleResponse(stdout, event) : stdout; } diff --git a/src/runtime/handle.ts b/src/runtime/handle.ts index 77d8824..492db80 100644 --- a/src/runtime/handle.ts +++ b/src/runtime/handle.ts @@ -17,6 +17,10 @@ import { resetFragmentRegistry } from "./fragment-registry"; import { attachBudgetRecap } from "./inject-budget-recap"; import { promptText } from "./prompt-text"; import { handleConfirmSubmit } from "./confirm/confirm-submit"; +import { submitCodexConfirmation } from "./confirm/codex-confirm"; +import { codexPromptOrigin } from "./confirm/codex-prompt-origin"; +import { cursorProjectCwd } from "../adapters/cursor/context"; +import { toCursorLifecycleResponse } from "../adapters/cursor/respond"; import type { HandleOptions, HandleOutcome } from "./handle-types"; export type { HandleOptions, HandleOutcome } from "./handle-types"; @@ -31,8 +35,17 @@ function rawEventName(payload: Record): string { * POST event it records the activity into the track. The loop that makes the * package behave like the Claude plugin, on any harness. */ -export async function handleHook(id: string, payload: Record, opts: HandleOptions): Promise { +async function handleHookCore(id: string, payload: Record, opts: HandleOptions): Promise { const event = normalizeEvent(id, payload); + if (id === "cursor") { + const cursorCwd = cursorProjectCwd(event.cwd, event.workspaceRoots ?? [], event.filePath, opts.cwd); + if (cursorCwd !== opts.cwd) opts = { ...opts, cwd: cursorCwd }; + } + const rawPrompt = payload.prompt; + const userPrompt = typeof rawPrompt === "string" || Array.isArray(rawPrompt) ? promptText(rawPrompt) : undefined; + if (id === "codex" && rawEventName(payload) === "UserPromptSubmit" && userPrompt !== undefined) { + submitCodexConfirmation(event.sessionId, userPrompt, opts.now, opts.home, codexPromptOrigin(payload)); + } // Fresh slate for this invocation's capFragment tally — one hook event is // exactly one lifecycle branch below (see dispatchLifecycle), so a single // reset here can never mix fragments across unrelated events. @@ -79,10 +92,8 @@ export async function handleHook(id: string, payload: Record, o // blocks on Kimi (see promptText) — either shape is normalized to text; // anything else (field absent, or an unrecognized type) stays `undefined` // so the block below is skipped exactly as before promptText existed. - const rawPrompt = payload.prompt; - const userPrompt = typeof rawPrompt === "string" || Array.isArray(rawPrompt) ? promptText(rawPrompt) : undefined; if (userPrompt !== undefined) { - handleConfirmSubmit(event.sessionId, userPrompt, opts.now, opts.home); + if (id !== "codex") handleConfirmSubmit(event.sessionId, userPrompt, opts.now, opts.home); await withTrack(file, (track) => recordBrainstormRequired(track, detectCreationIntent(userPrompt))); return { stdout: promptSubmitContext(userPrompt, opts.cwd, id), exit: 0 }; } @@ -93,3 +104,13 @@ export async function handleHook(id: string, payload: Record, o return handlePre({ id, payload, event, framework, mcpDir, designCacheDir, file, opts }); } + +/** + * Run one hook and adapt every Cursor scope outcome at the common runtime exit. + * Other harnesses retain the core handler's stdout and exit status unchanged. + */ +export async function handleHook(id: string, payload: Record, opts: HandleOptions): Promise { + const outcome = await handleHookCore(id, payload, opts); + if (id !== "cursor") return outcome; + return { ...outcome, stdout: toCursorLifecycleResponse(outcome.stdout, rawEventName(payload)) }; +} diff --git a/src/runtime/lifecycle-bridge.ts b/src/runtime/lifecycle-bridge.ts index 9741e53..c328dd8 100644 --- a/src/runtime/lifecycle-bridge.ts +++ b/src/runtime/lifecycle-bridge.ts @@ -1,6 +1,8 @@ import { dispatchLifecycle, postEditTypescript, trackSessionChanges, type PluginScope } from "./lifecycle"; import { autoDocumentRead } from "./lifecycle/auto-document-reads"; import { contextResponse } from "../adapters/claude"; +import { cursorEventContract } from "../adapters/cursor/events"; +import { toCursorLifecycleResponse } from "../adapters/cursor/respond"; import type { NormalizedEvent } from "./normalize"; /** Raw event name from a payload (Cline lacks one; lifecycle is Claude-only). */ @@ -32,7 +34,20 @@ function additionalContextOf(stdout: string): string { * @returns The native stdout, or `null` when unhandled. */ export function lifecycleStdout(payload: Record, cwd: string, scope: PluginScope, now: number, id: string = "claude-code"): string | null { - return dispatchLifecycle({ event: rawEvent(payload), payload, cwd, scope, now, id }); + const wireEvent = rawEvent(payload); + if (id !== "cursor") return dispatchLifecycle({ event: wireEvent, payload, cwd, scope, now, id }); + const lifecycleEvent = cursorEventContract(wireEvent).lifecycle; + if (lifecycleEvent === null) return null; + const sessionId = typeof payload.session_id === "string" ? payload.session_id : payload.conversation_id; + const stdout = dispatchLifecycle({ + event: lifecycleEvent, + payload: { ...payload, session_id: sessionId }, + cwd, + scope, + now, + id, + }); + return stdout === null ? null : toCursorLifecycleResponse(stdout, wireEvent); } /** diff --git a/src/runtime/normalize.ts b/src/runtime/normalize.ts index 65cdf2d..cdb942b 100644 --- a/src/runtime/normalize.ts +++ b/src/runtime/normalize.ts @@ -1,17 +1,21 @@ import { parseApplyPatch } from "../adapters/codex/apply-patch"; +import { extractCursorEvent } from "../adapters/cursor/normalize"; import { commandToString } from "./command-string"; import { canonicalizeCodexShellTool } from "./codex-shell-tool"; import { canonicalizeMcpToolName } from "./mcp-tool-name"; -/** One file fanned out of a multi-file edit primitive (Codex `apply_patch`). */ +/** One file/edit fanned out from Codex `apply_patch` or Cursor `afterFileEdit`. */ export interface NormalizedFile { filePath: string; content: string; + oldString?: string; op: "add" | "update" | "delete"; } /** A hook event normalized across harnesses. */ export interface NormalizedEvent { + /** Native event name when the adapter exposes one explicitly (Cursor). */ + eventName?: string; phase: "pre" | "post"; tool: string; input: Record; @@ -21,15 +25,21 @@ export interface NormalizedEvent { /** Edit only: the tool_input.old_string being replaced — lets the file-size gate (policy/evaluate.ts + policy/edit-outcome.ts) compute the post-edit outcome instead of judging the stale on-disk count alone. Undefined for Write (no such field) and for cline/apply_patch (parsed separately, no equivalent field). */ oldString?: string; command?: string; + /** Cursor-only independent command candidates from beforeMCPExecution. */ + commandCandidates?: string[]; /** Subagent type, if the tool-use came from one (Explore/Plan are file-size-exempt). */ agentType?: string; /** Harness-resolved permission mode (Claude emits it natively; Codex maps `AskForApproval::Never` to the same "bypassPermissions" string — see adapters/codex/permission-mode.ts). Generic field, Codex-only consumer today. */ permissionMode?: string; + /** Codex logical tool-use identity, shared by sibling hook callbacks. */ + toolUseId?: string; + /** Harness-reported working directory used to scope Codex authorization. */ + cwd?: string; + /** Validated Cursor multi-root workspace paths in wire order. */ + workspaceRoots?: string[]; /** - * Per-file changes when the tool is a multi-file edit primitive (Codex - * `apply_patch`). Present ONLY for `apply_patch`; the file gates OR each - * entry's verdict so one violating hunk blocks the whole envelope. Left - * undefined for every other tool/harness (single-file `filePath`/`content`). + * Per-file changes for Codex `apply_patch` and per-edit changes for Cursor + * `afterFileEdit`; undefined for single-file events. */ files?: NormalizedFile[]; } @@ -39,9 +49,7 @@ function str(v: unknown): string | undefined { } /** - * Normalize a harness hook payload into a uniform event. Handles Cline's nested - * `preToolUse`/`postToolUse` shape and the top-level `tool_name`/`tool_input` - * shape used by Claude, Codex, Gemini, and Cursor. + * Normalize a hook payload, including Cline nesting and native Cursor events. */ export function normalizeEvent(id: string, payload: Record): NormalizedEvent { if (id === "cline") { @@ -58,6 +66,14 @@ export function normalizeEvent(id: string, payload: Record): No command: str(params.command), }; } + if (id === "cursor") { + return { + ...extractCursorEvent(payload), + sessionId: str(payload.session_id) ?? str(payload.conversation_id) ?? "", + agentType: str(payload.agent_type), + permissionMode: str(payload.permission_mode), + }; + } const event = str(payload.hook_event_name) ?? ""; const input = (payload.tool_input as Record | undefined) ?? payload; const tool = canonicalizeCodexShellTool(id, canonicalizeMcpToolName(id, str(payload.tool_name) ?? "")); @@ -68,6 +84,8 @@ export function normalizeEvent(id: string, payload: Record): No sessionId: str(payload.session_id) ?? str(payload.conversation_id) ?? "", agentType: str(payload.agent_type) ?? str(input.subagent_type), permissionMode: str(payload.permission_mode), + toolUseId: str(payload.tool_use_id), + cwd: str(payload.cwd), }; // Codex's `apply_patch` (its PRIMARY edit primitive) carries the whole change // set as a freeform patch in `command` — no `file_path`/`content`, so the diff --git a/src/runtime/post-fanout.ts b/src/runtime/post-fanout.ts index 951f6f1..db4c88c 100644 --- a/src/runtime/post-fanout.ts +++ b/src/runtime/post-fanout.ts @@ -1,14 +1,14 @@ import type { NormalizedEvent } from "./normalize"; /** - * Fan a Codex `apply_patch` envelope's `event.files` into one synthetic + * Fan Codex `apply_patch` or Cursor `afterFileEdit` `event.files` into one * per-file event per touched file, so the per-file PostToolUse gates (SOLID * size, Tailwind, tracking, post-edit context) see EACH file instead of the * whole patch (whose own `filePath`/`content` are always undefined). `add` * maps to `Write`, `update` to `Edit` (mirrors the Pre-phase `applyPatchGate`'s * tool mapping); `delete`/`move` map to a tool none of those Write|Edit-gated * checks recognize, so they no-op on it without special-casing each gate. - * Returns `[event]` unchanged for every non-`apply_patch` tool/harness. + * Returns `[event]` unchanged when no per-file changes are present. * @param event - The normalized PostToolUse event. * @returns One event per touched file, or the original event. */ @@ -19,6 +19,7 @@ export function fanOutFiles(event: NormalizedEvent): NormalizedEvent[] { tool: f.op === "add" ? "Write" : f.op === "update" ? "Edit" : "apply_patch:delete", filePath: f.filePath, content: f.op === "delete" ? undefined : f.content, + oldString: f.oldString, })); } diff --git a/src/runtime/post-outcome.ts b/src/runtime/post-outcome.ts new file mode 100644 index 0000000..f03d85c --- /dev/null +++ b/src/runtime/post-outcome.ts @@ -0,0 +1,61 @@ +import { attachSystemMessage } from "../adapters/claude"; +import { designPassNotice } from "../policy/design/gates"; +import { refCreditNoticeFor } from "./notices"; +import { defaultStateDir } from "./paths"; +import { respond } from "./respond"; +import type { Activity } from "./record"; +import type { HandleOutcome } from "./handle"; +import type { NormalizedEvent } from "./normalize"; +import { formatPrompt, type Prompt } from "../prompt/types"; + +/** Extract the additional-context body from a Claude-shaped post response. */ +export function additionalContextOf(stdout: string): string { + if (!stdout) return ""; + try { + const parsed = JSON.parse(stdout) as { hookSpecificOutput?: { additionalContext?: string } }; + return parsed.hookSpecificOutput?.additionalContext ?? ""; + } catch { + return ""; + } +} + +function cursorPostToolOutput(eventName: string, designWarn: Prompt | null, userMessage: string | undefined, extra: string): string { + if (!/^postToolUse$/i.test(eventName)) return ""; + const context = [designWarn ? formatPrompt(designWarn) : "", designWarn?.userMessage, userMessage, additionalContextOf(extra)] + .filter(Boolean) + .join("\n"); + return context ? JSON.stringify({ additional_context: context }) : ""; +} + +/** + * Assemble the final PostToolUse response after all side effects and deny paths. + * @param input - Final assembly values captured at their original evaluation times. + * @returns The native hook outcome. + */ +export function postOutcome(input: { + id: string; agentId: string; sessionId: string; now: number; cwd: string; + activities: readonly Activity[]; files: readonly NormalizedEvent[]; + designCacheDir: string; designWarn: Prompt | null; extra: string; + cursorAfterFileEdit: boolean; cursorEventName: string; +}): HandleOutcome { + const { id, agentId, sessionId, now, cwd, activities, files, designCacheDir, designWarn, extra, cursorAfterFileEdit, cursorEventName } = input; + const noticeLines: string[] = []; + for (const f of files) { + const notice = designPassNotice({ agentId, tool: f.tool, filePath: f.filePath ?? "", content: f.content ?? "", url: "", phase: "post" }, designCacheDir); + if (notice?.userMessage) noticeLines.push(notice.userMessage); + } + const notice: Prompt | null = noticeLines.length ? { kind: "inform", title: "Design pipeline", reason: "", userMessage: noticeLines.join("\n") } : null; + const refNotice = refCreditNoticeFor(activities, sessionId, now, defaultStateDir(cwd)); + const userMessage = [notice?.userMessage, refNotice].filter(Boolean).join("\n") || undefined; + // Cursor ignores callback fields for afterFileEdit; complete successfully + // without emitting the permission schema reserved for pre-execution hooks. + if (cursorAfterFileEdit) return { stdout: "{}", exit: 0 }; + if (id === "cursor") return { stdout: cursorPostToolOutput(cursorEventName, designWarn, userMessage, extra), exit: 0 }; + if (designWarn) return { stdout: respond(id, userMessage ? { ...designWarn, userMessage } : designWarn, "PostToolUse"), exit: 0 }; + if (!userMessage) return { stdout: extra, exit: 0 }; + const withUserMessage: Prompt = notice ? { ...notice, userMessage } : { kind: "inform", title: "Compliance", reason: "", userMessage }; + if (!extra) return { stdout: respond(id, withUserMessage, "PostToolUse"), exit: 0 }; + if (id === "claude-code" || id === "codex") return { stdout: attachSystemMessage(extra, userMessage), exit: 0 }; + if (id === "kimi") return { stdout: respond(id, withUserMessage, "PostToolUse"), exit: 0 }; + return { stdout: respond(id, withUserMessage, "PostToolUse") || extra, exit: 0 }; +} diff --git a/src/runtime/pre-allow.ts b/src/runtime/pre-allow.ts index 414f92a..232f8a5 100644 --- a/src/runtime/pre-allow.ts +++ b/src/runtime/pre-allow.ts @@ -86,12 +86,12 @@ export async function allowOutcome( if (lesson) { const userMessage = [notice?.userMessage, evidenceNotice].filter(Boolean).join("\n") || undefined; const merged = userMessage ? { ...lesson, userMessage } : lesson; - return { stdout: respond(id, merged), exit: 0 }; + return { stdout: respond(id, merged, event.eventName ?? "PreToolUse"), exit: 0 }; } if (evidenceNotice) { const userMessage = [notice?.userMessage, evidenceNotice].filter(Boolean).join("\n"); const prompt: Prompt = notice ? { ...notice, userMessage } : { kind: "inform", title: "APEX freshness", reason: "", userMessage }; - return { stdout: respond(id, prompt), exit: 0 }; + return { stdout: respond(id, prompt, event.eventName ?? "PreToolUse"), exit: 0 }; } - return { stdout: notice ? respond(id, notice) : "", exit: 0 }; + return { stdout: notice ? respond(id, notice, event.eventName ?? "PreToolUse") : "", exit: 0 }; } diff --git a/src/runtime/respond.ts b/src/runtime/respond.ts index 8426352..7486d3e 100644 --- a/src/runtime/respond.ts +++ b/src/runtime/respond.ts @@ -2,6 +2,7 @@ import { formatPrompt, type Prompt } from "../prompt/types"; import { denyResponse, contextResponse, informResponse } from "../adapters/claude"; import { toHermesResponse } from "../adapters/hermes"; import { toKimiResponse } from "../adapters/kimi"; +import { toCursorResponse } from "../adapters/cursor/respond"; /** * Map a portable {@link Prompt} to a harness's native hook response, honoring @@ -14,8 +15,8 @@ import { toKimiResponse } from "../adapters/kimi"; * - gemini-cli/cline: their real hook schemas have no interactive "ask" * state (deny is the only blocking outcome), so `ask` and `inform` already * both resolve to non-blocking context injection — unchanged. - * - cursor: `ask` keeps its current best-effort `permission:"ask"` shape; - * only `inform` is fixed to a non-blocking `permission:"allow"` note. + * - cursor: `ask` degrades to a native `permission:"deny"` because Cursor does + * not reliably apply `ask` on `preToolUse`; `inform` stays non-blocking. * - hermes: delegated to the adapter's `toHermesResponse` — `block` -> * `{decision:"block",reason}`; `ask`/`inform` degrade to non-blocking * `{context}` (Hermes has no interactive "ask" state). @@ -73,14 +74,7 @@ export function respond(id: string, prompt: Prompt, event: string = "PreToolUse" if (userMessage) return JSON.stringify({ ...(reason ? { hookSpecificOutput: { additionalContext: message } } : {}), systemMessage: userMessage }); return JSON.stringify({ hookSpecificOutput: { additionalContext: message } }); case "cursor": - // snake_case required — camelCase silently ignored (#141516). The preToolUse - // output schema documents user_message/agent_message on deny (+ask in the - // official examples), NOT on allow — the allow-notice below is best-effort. - if (kind === "inform") { - if (userMessage) return JSON.stringify({ permission: "allow", user_message: userMessage, ...(reason ? { agent_message: message } : {}) }); - return JSON.stringify({ permission: "allow", user_message: message, agent_message: message }); - } - return JSON.stringify({ permission: kind === "block" ? "deny" : "ask", continue: false, user_message: message, agent_message: message }); + return toCursorResponse(prompt, event === "PreToolUse" ? "preToolUse" : event); case "hermes": // Single source of truth for the Hermes wire shape lives in the adapter // (same JSON.stringify contract as the inline cases above). diff --git a/test/adapters.test.ts b/test/adapters.test.ts index fbff1d2..0d1dc27 100644 --- a/test/adapters.test.ts +++ b/test/adapters.test.ts @@ -1,21 +1,22 @@ import { test, expect } from "bun:test"; -import { beforeShellExecution, afterFileEdit } from "../src/adapters/cursor"; +import { beforeShellExecution, preToolUse as cursorPreToolUse, afterFileEdit } from "../src/adapters/cursor"; import { preToolUse } from "../src/adapters/cline"; import { beforeTool } from "../src/adapters/gemini"; import { resolveMaxLines } from "../src/config/limits"; -// Tracks the gate's own resolver (`FUSE_SOLID_MAX_LINES` ?? default) so this -// fixture stays oversized regardless of the ambient env override. const oversized = "x\n".repeat(resolveMaxLines() + 50); -test("cursor: shell deny on git --force, allow safe; edit is advisory (user_message only)", () => { +test("cursor: shell deny on git --force, allow safe; edit returns no unsupported fields", () => { expect(beforeShellExecution({ command: "git push --force" }).permission).toBe("deny"); expect(beforeShellExecution({ command: "git status" }).permission).toBe("allow"); - // afterFileEdit is advisory: ALWAYS allow (never a false deny), violation rides user_message. - const advice = afterFileEdit({ file_path: "a.ts", edits: [{ old_string: "", new_string: oversized }] }); - expect(advice.permission).toBe("allow"); - expect(advice.user_message).toContain("max"); - expect(afterFileEdit({ file_path: "a.ts", edits: [{ old_string: "", new_string: "x" }] }).user_message).toBeUndefined(); + expect(afterFileEdit({ file_path: "a.ts", edits: [{ old_string: "", new_string: "x\n".repeat(250) }] })).toEqual({}); + expect(afterFileEdit({ file_path: "a.ts", edits: [{ old_string: "", new_string: "x" }] })).toEqual({}); +}); + +test("cursor: native shell events share extraction and degrade install asks to deny", () => { + expect(beforeShellExecution({ command: "npm install", cwd: "/project", sandbox: false }).permission).toBe("deny"); + expect(cursorPreToolUse({ tool_name: "Shell", tool_input: { command: "npm install", working_directory: "/project" } }).permission).toBe("deny"); + expect(cursorPreToolUse({ tool_name: "Shell", tool_input: { command: "git status" } }).permission).toBe("allow"); }); test("cline: cancel on oversized code, pass small", () => { diff --git a/test/apply-patch-post.test.ts b/test/apply-patch-post.test.ts index b6488ce..a08fe63 100644 --- a/test/apply-patch-post.test.ts +++ b/test/apply-patch-post.test.ts @@ -39,6 +39,20 @@ test("fanOutFiles: add→Write, update→Edit; delete drops content and maps off ]); }); +test("fanOutFiles: Cursor afterFileEdit preserves each old/new pair", () => { + const event = base({ + tool: "Edit", + files: [ + { filePath: "app.ts", oldString: "a", content: "b", op: "update" }, + { filePath: "app.ts", oldString: "c", content: "d", op: "update" }, + ], + }); + expect(fanOutFiles(event).map((file) => [file.tool, file.filePath, file.oldString, file.content])).toEqual([ + ["Edit", "app.ts", "a", "b"], + ["Edit", "app.ts", "c", "d"], + ]); +}); + test("firstFileMatch: OR across files, first non-empty wins (parity applyPatchGate)", () => { const files = fanOutFiles(base({ files: [{ filePath: "ok.ts", content: "", op: "add" }, { filePath: "bad.ts", content: "", op: "add" }] })); expect(firstFileMatch(files, (_t, p) => (p === "bad.ts" ? "BAD" : ""))).toBe("BAD"); diff --git a/test/bash-write-harness-matrix.test.ts b/test/bash-write-harness-matrix.test.ts new file mode 100644 index 0000000..0c01042 --- /dev/null +++ b/test/bash-write-harness-matrix.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleHook } from "../src/runtime/handle"; + +const ids = ["claude-code", "codex", "cursor", "kimi"] as const; + +function payload(id: typeof ids[number], command: string, cwd: string): Record { + const session_id = `matrix-${id}-${randomUUID()}`; + if (id === "cursor") return { hook_event_name: "beforeShellExecution", session_id, command, cwd }; + return { hook_event_name: "PreToolUse", session_id, tool_use_id: randomUUID(), cwd, tool_name: "Bash", tool_input: { command } }; +} + +function decision(id: typeof ids[number], stdout: string): string { + if (!stdout) return "allow"; + const parsed = JSON.parse(stdout) as { permission?: string; hookSpecificOutput?: { permissionDecision?: string } }; + return id === "cursor" ? parsed.permission ?? "allow" : parsed.hookSpecificOutput?.permissionDecision ?? "allow"; +} + +test("shared shell verdict matrix stays stable across Claude, Codex, Cursor, and Kimi", async () => { + const cwd = mkdtempSync(join(tmpdir(), "fh-shell-matrix-cwd-")); + const home = mkdtempSync(join(tmpdir(), "fh-shell-matrix-home-")); + const cases = [ + { command: "rg '->between' fichier.php", expected: ["allow", "allow", "allow", "allow"] }, + { command: "echo x > result.ts", expected: ["deny", "deny", "deny", "deny"] }, + { command: "echo x > result.log", expected: ["ask", "deny", "deny", "deny"] }, + ] as const; + for (const row of cases) { + const actual = await Promise.all(ids.map(async (id) => decision(id, (await handleHook(id, payload(id, row.command, cwd), { now: 1000, cwd, home })).stdout))); + expect(actual, row.command).toEqual([...row.expected]); + } +}); diff --git a/test/bash-write-redirect-contexts.test.ts b/test/bash-write-redirect-contexts.test.ts new file mode 100644 index 0000000..6eeabdf --- /dev/null +++ b/test/bash-write-redirect-contexts.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from "bun:test"; +import { bashWriteGuard } from "../src/policy/guards/bash-write"; + +const verdict = (command: string): string => bashWriteGuard({ tool: "Bash", command })?.kind ?? "allow"; + +test("preserves legacy &> handling", () => { + expect(verdict("echo x &> out.txt")).toBe("allow"); +}); + +test("detects Bash noclobber override >|", () => { + expect(verdict("echo x >| out.txt")).toBe("ask"); + expect(verdict("echo x >| out.ts")).toBe("block"); +}); + +test("process substitution and comparison operators are not file redirects", () => { + expect(verdict("diff <(printf a) >(printf b)")).toBe("allow"); + expect(verdict("echo $((1 > 0))")).toBe("allow"); + expect(verdict("(( 2 > 1 ))")).toBe("allow"); + expect(verdict('[[ "b" > "a" ]]')).toBe("allow"); +}); + +test("real redirects nested in substitutions inside excluded contexts remain visible", () => { + expect(verdict('[[ "$(printf x > out.txt)" ]]')).toBe("ask"); + expect(verdict("echo $(( $(printf x > out.ts) + 1 ))")).toBe("block"); +}); + +test("ordinary [[ text and comment markers cannot hide a later redirect", () => { + expect(verdict("echo [[ x > out.txt ]]" )).toBe("ask"); + expect(verdict("printf x [[ > out.ts")).toBe("block"); + expect(verdict("echo foo[[ > out.ts")).toBe("block"); + expect(verdict("echo ok # [[\necho x > out.ts")).toBe("block"); + expect(verdict("echo ok # ((\necho x > out.ts")).toBe("block"); +}); + +test("nested parentheses cannot close an outer command substitution early", () => { + expect(verdict('echo "$(echo $((1 > 0)) > out.txt)"')).toBe("ask"); + expect(verdict('echo "$( (printf a); printf x > out.txt)"')).toBe("ask"); + expect(verdict('echo "$(diff <(printf a) >(printf b); echo x > out.ts)"')).toBe("block"); +}); diff --git a/test/bash-write.test.ts b/test/bash-write.test.ts index 724a4e1..b1768b4 100644 --- a/test/bash-write.test.ts +++ b/test/bash-write.test.ts @@ -20,6 +20,23 @@ test("asks before redirect to a non-code file", () => { expect(bashWriteGuard({ tool: "Bash", command: "echo log >> out.txt" })?.kind).toBe("ask"); }); +test("quoted and escaped greater-than characters are not shell redirects", () => { + expect(bashWriteGuard({ tool: "Bash", command: "rg '->between' fichier.php" })).toBeNull(); + expect(bashWriteGuard({ tool: "Bash", command: 'rg "x > y" fichier.php' })).toBeNull(); + expect(bashWriteGuard({ tool: "Bash", command: "rg x\\>y fichier.php" })).toBeNull(); +}); + +test("active redirects include fd append and are found after a quoted motif", () => { + expect(bashWriteGuard({ tool: "Bash", command: "command 2>> errors.log" })?.kind).toBe("ask"); + expect(bashWriteGuard({ tool: "Bash", command: "rg '->between' fichier.php > result.log" })?.kind).toBe("ask"); + expect(bashWriteGuard({ tool: "Bash", command: "rg '->between' fichier.php > result.ts" })?.kind).toBe("block"); +}); + +test("redirects inside command substitutions stay active, including within double quotes", () => { + expect(bashWriteGuard({ tool: "Bash", command: 'echo "$(printf x > result.log)"' })?.kind).toBe("ask"); + expect(bashWriteGuard({ tool: "Bash", command: 'echo "$(printf x > result.ts)"' })?.kind).toBe("block"); +}); + test("passes a plain read + non-Bash tool", () => { expect(bashWriteGuard({ tool: "Bash", command: "ls -la src" })).toBeNull(); expect(bashWriteGuard({ tool: "Write", command: "sed -i x a.ts" })).toBeNull(); diff --git a/test/confirm-codex-action.test.ts b/test/confirm-codex-action.test.ts new file mode 100644 index 0000000..983cc06 --- /dev/null +++ b/test/confirm-codex-action.test.ts @@ -0,0 +1,73 @@ +import { expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { codexInit } from "../src/init/templates"; +import { handleHook, type HandleOptions } from "../src/runtime/handle"; + +const temp = (name: string): string => mkdtempSync(join(tmpdir(), `fh-${name}-`)); +const sid = (label: string): string => `${label}-${randomUUID()}`; +const pre = (s: string, command: unknown, toolUseId?: string, cwd?: string) => ({ + hook_event_name: "PreToolUse", session_id: s, tool_use_id: toolUseId, + cwd, tool_name: "Bash", tool_input: { command }, +}); +const submit = (s: string, prompt: string) => ({ hook_event_name: "UserPromptSubmit", session_id: s, prompt }); +function codeFrom(stdout: string): string { + const code = stdout.match(/CONFIRM ([0-9a-f]{4})/i)?.[1]; + if (!code) throw new Error(`missing code: ${stdout}`); + return code; +} + +test("consumed confirmation text cannot re-arm without a new denial", async () => { + const home = temp("confirm-home"), cwd = temp("confirm-cwd"), s = sid("replay"), command = "git commit -m replay"; + const opts: HandleOptions = { now: 1000, cwd, home }; + const denied = await handleHook("codex", pre(s, command, "tool-a", cwd), opts); + const code = codeFrom(denied.stdout); + await handleHook("codex", submit(s, `CONFIRM ${code}`), { ...opts, now: 1100 }); + await handleHook("codex", pre(s, command, "tool-a", cwd), { ...opts, now: 1200 }); + await handleHook("codex", submit(s, `CONFIRM ${code}`), { ...opts, now: 1250 }); + const replay = await handleHook("codex", pre(s, command, "tool-b", cwd), { ...opts, now: 1300 }); + expect(JSON.parse(replay.stdout).hookSpecificOutput.permissionDecision).toBe("deny"); +}); + +test("modified command invalidates the armed action", async () => { + const home = temp("confirm-home"), cwd = temp("confirm-cwd"), s = sid("mismatch"), opts = { now: 1000, cwd, home }; + const denied = await handleHook("codex", pre(s, "git commit -m a", "tool-a", cwd), opts); + await handleHook("codex", submit(s, `CONFIRM ${codeFrom(denied.stdout)}`), { ...opts, now: 1100 }); + expect((await handleHook("codex", pre(s, "git commit -m b", "tool-b", cwd), { ...opts, now: 1200 })).stdout).toContain("rejection: mismatch"); + const original = await handleHook("codex", pre(s, "git commit -m a", "tool-a", cwd), { ...opts, now: 1300 }); + expect(JSON.parse(original.stdout).hookSpecificOutput.permissionDecision).toBe("deny"); +}); + +test("argv/string transport is canonical and a different cwd invalidates", async () => { + const home = temp("confirm-home"), cwd = temp("confirm-cwd"), other = temp("confirm-cwd"), command = "git commit -m transport"; + const opts: HandleOptions = { now: 1000, cwd, home }, s1 = sid("transport"); + const denied = await handleHook("codex", pre(s1, ["bash", "-lc", command], "tool-a", cwd), opts); + await handleHook("codex", submit(s1, `CONFIRM ${codeFrom(denied.stdout)}`), { ...opts, now: 1100 }); + expect((await handleHook("codex", pre(s1, command, "tool-a", cwd), { ...opts, now: 1200 })).stdout).not.toContain('"permissionDecision":"deny"'); + const s2 = sid("cwd"), denied2 = await handleHook("codex", pre(s2, command, "tool-b", cwd), opts); + await handleHook("codex", submit(s2, `CONFIRM ${codeFrom(denied2.stdout)}`), { ...opts, now: 1100 }); + expect((await handleHook("codex", pre(s2, command, "tool-b", other), { ...opts, now: 1200 })).stdout).toContain("rejection: mismatch"); +}); + +test("diagnostics expose stable fields and missing tool_use_id is typed", async () => { + const home = temp("confirm-home"), cwd = temp("confirm-cwd"), s = sid("diagnostic"), command = "echo log > out.txt"; + const opts: HandleOptions = { now: 1000, cwd, home }, denied = await handleHook("codex", pre(s, command, "tool-a", cwd), { now: 1000, cwd, home }); + expect(denied.stdout).toContain("rule ID: bash-write:file-redirect"); + expect(denied.stdout).toContain(`canonical command: ${command}`); + expect(denied.stdout).toMatch(/expected token: CONFIRM [0-9a-f]{4}/); + expect(denied.stdout).toContain("rejection: no-token"); + await handleHook("codex", submit(s, `CONFIRM ${codeFrom(denied.stdout)}`), { ...opts, now: 1100 }); + expect((await handleHook("codex", pre(s, command, undefined, cwd), { ...opts, now: 1200 })).stdout).toContain("rejection: missing-tool-use-id"); + expect((await handleHook("codex", pre(s, command, "tool-a", cwd), { ...opts, now: 1300 })).stdout).not.toContain('"permissionDecision":"deny"'); +}); + +test("UserPromptSubmit is wired and processed before scope early returns", async () => { + const hooks = JSON.parse(codexInit("harness hook codex")[0]!.content) as { hooks: Record }; + expect(hooks.hooks.UserPromptSubmit?.length).toBe(1); + const home = temp("confirm-home"), cwd = temp("confirm-cwd"), s = sid("scope"), command = "git commit -m scope"; + const opts: HandleOptions = { now: 1000, cwd, home }, denied = await handleHook("codex", pre(s, command, "tool-a", cwd), { now: 1000, cwd, home }); + await handleHook("codex", submit(s, `CONFIRM ${codeFrom(denied.stdout)}`), { ...opts, now: 1100, scope: "rules" }); + expect((await handleHook("codex", pre(s, command, "tool-a", cwd), { ...opts, now: 1200 })).stdout).not.toContain('"permissionDecision":"deny"'); +}); diff --git a/test/confirm-codex-provenance.test.ts b/test/confirm-codex-provenance.test.ts new file mode 100644 index 0000000..7a310bb --- /dev/null +++ b/test/confirm-codex-provenance.test.ts @@ -0,0 +1,142 @@ +import { expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleHook, type HandleOptions } from "../src/runtime/handle"; +import { markSubagentSeen } from "../src/runtime/confirm/confirm-subagent"; + +const temp = (label: string): string => mkdtempSync(join(tmpdir(), `fh-${label}-`)); + +function pre(sessionId: string, command: string, toolUseId: string) { + return { + hook_event_name: "PreToolUse", + session_id: sessionId, + tool_use_id: toolUseId, + tool_name: "Bash", + tool_input: { command }, + }; +} + +function codeFrom(stdout: string): string { + const code = stdout.match(/CONFIRM ([0-9a-f]{4})/i)?.[1]; + if (!code) throw new Error(`missing CONFIRM code: ${stdout}`); + return code; +} + +test("root Codex UserPromptSubmit arms the exact pending action during recent subagent cooldown", async () => { + const home = temp("confirm-provenance-home"); + const cwd = temp("confirm-provenance-cwd"); + const sessionId = `root-${randomUUID()}`; + const command = "git commit -m live-root-confirm"; + const opts: HandleOptions = { now: 1000, cwd, home }; + const denied = await handleHook("codex", pre(sessionId, command, "tool-a"), opts); + const code = codeFrom(denied.stdout); + + markSubagentSeen(sessionId, 1050, home); + await handleHook("codex", { + hook_event_name: "UserPromptSubmit", + session_id: sessionId, + prompt: `CONFIRM ${code}\n`, + }, { ...opts, now: 1100 }); + + const allowed = await handleHook("codex", pre(sessionId, command, "tool-a"), { ...opts, now: 1200 }); + expect(allowed.stdout).not.toContain('"permissionDecision":"deny"'); +}); + +async function remainsDeniedAfterSubmit(agentFields: Record): Promise { + const home = temp("confirm-provenance-home"); + const cwd = temp("confirm-provenance-cwd"); + const sessionId = `closed-${randomUUID()}`; + const command = "git commit -m provenance-closed"; + const opts: HandleOptions = { now: 1000, cwd, home }; + const denied = await handleHook("codex", pre(sessionId, command, "tool-a"), opts); + const code = codeFrom(denied.stdout); + await handleHook("codex", { + hook_event_name: "UserPromptSubmit", + session_id: sessionId, + prompt: `CONFIRM ${code}\n`, + ...agentFields, + }, { ...opts, now: 1100 }); + const retried = await handleHook("codex", pre(sessionId, command, "tool-a"), { ...opts, now: 1200 }); + return retried.stdout.includes('"permissionDecision":"deny"'); +} + +test("explicit Codex agent provenance cannot arm a root pending action", async () => { + expect(await remainsDeniedAfterSubmit({ agent_id: "agent-1", agent_type: "worker" })).toBe(true); + expect(await remainsDeniedAfterSubmit({ agent_id: "agent-1" })).toBe(true); + expect(await remainsDeniedAfterSubmit({ agent_type: "worker" })).toBe(true); +}); + +test("malformed Codex agent provenance fails closed", async () => { + for (const fields of [ + { agent_id: null }, + { agent_type: "" }, + { agent_id: 42 }, + { agent_id: "agent-1", agent_type: null }, + ]) { + expect(await remainsDeniedAfterSubmit(fields)).toBe(true); + } +}); + +test("non-root Codex refusal cannot consume or clear the pending root action", async () => { + for (const agentFields of [{ agent_id: "agent-1" }, { agent_id: null }]) { + const home = temp("confirm-refusal-home"); + const cwd = temp("confirm-refusal-cwd"); + const sessionId = `refusal-${randomUUID()}`; + const command = "git commit -m refusal-provenance"; + const opts: HandleOptions = { now: 1000, cwd, home }; + const denied = await handleHook("codex", pre(sessionId, command, "tool-a"), opts); + const code = codeFrom(denied.stdout); + await handleHook("codex", { + hook_event_name: "UserPromptSubmit", session_id: sessionId, prompt: "cancel", ...agentFields, + }, { ...opts, now: 1050 }); + await handleHook("codex", { + hook_event_name: "UserPromptSubmit", session_id: sessionId, prompt: `CONFIRM ${code}\n`, + }, { ...opts, now: 1100 }); + const allowed = await handleHook("codex", pre(sessionId, command, "tool-a"), { ...opts, now: 1200 }); + expect(allowed.stdout).not.toContain('"permissionDecision":"deny"'); + } +}); + +test("root Codex confirmation rejects wrong, multiline, and incidental tokens", async () => { + for (const promptFor of [ + (_code: string) => "CONFIRM dead\n", + (code: string) => `notes before\nCONFIRM ${code}\n`, + (code: string) => `please CONFIRM ${code} now\n`, + ]) { + const home = temp("confirm-prompt-home"); + const cwd = temp("confirm-prompt-cwd"); + const sessionId = `prompt-${randomUUID()}`; + const command = "git commit -m prompt-shape"; + const opts: HandleOptions = { now: 1000, cwd, home }; + const denied = await handleHook("codex", pre(sessionId, command, "tool-a"), opts); + await handleHook("codex", { + hook_event_name: "UserPromptSubmit", + session_id: sessionId, + prompt: promptFor(codeFrom(denied.stdout)), + }, { ...opts, now: 1100 }); + const retried = await handleHook("codex", pre(sessionId, command, "tool-a"), { ...opts, now: 1200 }); + expect(retried.stdout).toContain('"permissionDecision":"deny"'); + } +}); + +test("agent metadata remains irrelevant to non-Codex UserPromptSubmit handling", async () => { + for (const id of ["claude-code", "kimi", "cursor"]) { + const opts: HandleOptions = { now: 1000, cwd: temp("confirm-cross-cwd"), home: temp("confirm-cross-home") }; + const event = "UserPromptSubmit"; + const root = await handleHook(id, { + hook_event_name: event, + session_id: `cross-root-${randomUUID()}`, + prompt: `ordinary root prompt ${randomUUID()}`, + }, opts); + const attributed = await handleHook(id, { + hook_event_name: event, + session_id: `cross-agent-${randomUUID()}`, + prompt: `ordinary attributed prompt ${randomUUID()}`, + agent_id: "agent-1", + agent_type: "worker", + }, opts); + expect(attributed).toEqual(root); + } +}); diff --git a/test/confirm-concurrency.test.ts b/test/confirm-concurrency.test.ts new file mode 100644 index 0000000..30509fc --- /dev/null +++ b/test/confirm-concurrency.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { authorizeCodexAction, codexAction, submitCodexConfirmation } from "../src/runtime/confirm/codex-confirm"; + +test("confirmation consumption is cross-process atomic and sibling-idempotent", async () => { + const home = mkdtempSync(join(tmpdir(), "fh-confirm-race-home-")); + const cwd = mkdtempSync(join(tmpdir(), "fh-confirm-race-cwd-")); + const sid = `race-${crypto.randomUUID()}`; + const action = codexAction("Bash", cwd, "git commit -m confirm-race", 1000); + if (!action) throw new Error("expected canonical action"); + try { + expect(authorizeCodexAction(sid, action, "tool-a", 1000, home)).toEqual({ allow: false, reason: "no-token" }); + submitCodexConfirmation(sid, `CONFIRM ${action.code}`, 1100, home); + const moduleUrl = pathToFileURL(join(import.meta.dir, "../src/runtime/confirm/codex-confirm.ts")).href; + const code = `import { authorizeCodexAction } from ${JSON.stringify(moduleUrl)}; console.log(JSON.stringify(authorizeCodexAction(${JSON.stringify(sid)}, ${JSON.stringify(action)}, "tool-a", 1200, ${JSON.stringify(home)})));`; + const children = [ + Bun.spawn([process.execPath, "-e", code], { stdout: "pipe" }), + Bun.spawn([process.execPath, "-e", code], { stdout: "pipe" }), + ]; + const results = await Promise.all(children.map(async (child) => { + const stdout = await new Response(child.stdout).text(); + expect(await child.exited).toBe(0); + return JSON.parse(stdout.trim()) as { allow: boolean; reason?: string }; + })); + expect(results).toEqual([{ allow: true }, { allow: true }]); + expect(authorizeCodexAction(sid, action, "tool-b", 1300, home)).toEqual({ allow: false, reason: "already-consumed" }); + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(cwd, { recursive: true, force: true }); + } +}); diff --git a/test/confirm-g0.test.ts b/test/confirm-g0.test.ts index 335f879..ce979e6 100644 --- a/test/confirm-g0.test.ts +++ b/test/confirm-g0.test.ts @@ -16,7 +16,7 @@ const cwd = (): string => mkdtempSync(join(tmpdir(), "fh-confirm-g0-cwd-")); const home = (): string => mkdtempSync(join(tmpdir(), "fh-confirm-g0-home-")); const sid = (label: string): string => `${label}-${randomUUID()}`; -const pre = (id: string, s: string, command: string) => ({ hook_event_name: "PreToolUse", session_id: s, tool_name: "Bash", tool_input: { command } }); +const pre = (id: string, s: string, command: string) => ({ hook_event_name: "PreToolUse", session_id: s, tool_use_id: randomUUID(), tool_name: "Bash", tool_input: { command } }); const submit = (s: string, prompt: string) => ({ hook_event_name: "UserPromptSubmit", session_id: s, prompt }); /** Extract the 4-hex-char code from a "Pour autoriser, réponds : CONFIRM xxxx" deny message. */ @@ -46,7 +46,7 @@ test("G0 unit: the cool-down is a monotone window, not a start/stop toggle — i expect(consumeConfirmToken(s, hashForAction("anything"), past + 1, h)).toBe(true); }); -test("G0 wiring: a real SubagentStart/Stop keeps CONFIRM blocked for the WHOLE cool-down window, not just until Stop", async () => { +test("G0 wiring: explicit subagent prompts stay blocked while provenance-free root prompts bypass the cool-down", async () => { // Uses the REAL default OS home (no `home` override) because dispatch.ts's // SubagentStart/SubagentStop path does not thread HandleOptions.home — a // unique session id keeps this isolated from any other run/session. The @@ -59,21 +59,21 @@ test("G0 wiring: a real SubagentStart/Stop keeps CONFIRM blocked for the WHOLE c const denied = await handleHook("codex", pre("codex", s, cmd), opts); const code = codeFromDeny(denied.stdout); await handleHook("codex", { hook_event_name: "SubagentStart", session_id: s, agent_id: "a1", agent_type: "x" }, { ...opts, now: 1050 }); - await handleHook("codex", submit(s, `CONFIRM ${code}`), { ...opts, now: 1100 }); + await handleHook("codex", { ...submit(s, `CONFIRM ${code}`), agent_id: "a1", agent_type: "x" }, { ...opts, now: 1100 }); const stillDenied = await handleHook("codex", pre("codex", s, cmd), { ...opts, now: 1200 }); expect(JSON.parse(stillDenied.stdout).hookSpecificOutput.permissionDecision).toBe("deny"); - // SubagentStop fires — but there is no decrement/clear anywhere in this - // design, so a confirmation attempt shortly after Stop must STILL be blocked. + + await handleHook("codex", submit(s, `CONFIRM ${code}`), { ...opts, now: 1225 }); + const allowedDuringStartCooldown = await handleHook("codex", pre("codex", s, cmd), { ...opts, now: 1230 }); + expect(allowedDuringStartCooldown.stdout.includes('"permissionDecision":"deny"')).toBe(false); + await handleHook("codex", { hook_event_name: "SubagentStop", session_id: s, agent_id: "a1", agent_type: "x" }, { ...opts, now: 1250 }); - await handleHook("codex", submit(s, `CONFIRM ${code}`), { ...opts, now: 1300 }); - const stillDeniedAfterStop = await handleHook("codex", pre("codex", s, cmd), { ...opts, now: 1400 }); - expect(JSON.parse(stillDeniedAfterStop.stdout).hookSpecificOutput.permissionDecision).toBe("deny"); - // Only once the whole window has elapsed since the LAST sighting (the - // Stop at 1250) does a fresh confirmation succeed. - const past = 1250 + SUBAGENT_WINDOW_MS + 1000; - await handleHook("codex", submit(s, `CONFIRM ${code}`), { ...opts, now: past }); - const allowedNow = await handleHook("codex", pre("codex", s, cmd), { ...opts, now: past + 100 }); - expect(allowedNow.stdout.includes('"permissionDecision":"deny"')).toBe(false); + const cmdAfterStop = "git commit -m confirm-g0-after-stop"; + const deniedAfterStop = await handleHook("codex", pre("codex", s, cmdAfterStop), { ...opts, now: 1300 }); + const codeAfterStop = codeFromDeny(deniedAfterStop.stdout); + await handleHook("codex", submit(s, `CONFIRM ${codeAfterStop}`), { ...opts, now: 1350 }); + const allowedAfterStop = await handleHook("codex", pre("codex", s, cmdAfterStop), { ...opts, now: 1400 }); + expect(allowedAfterStop.stdout.includes('"permissionDecision":"deny"')).toBe(false); } finally { rmSync(sessionStatePath(s), { force: true }); } diff --git a/test/confirm-receipt.test.ts b/test/confirm-receipt.test.ts new file mode 100644 index 0000000..53016c2 --- /dev/null +++ b/test/confirm-receipt.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleHook, type HandleOptions } from "../src/runtime/handle"; + +const pre = (sid: string, command: string, toolUseId: string, cwd: string) => ({ + hook_event_name: "PreToolUse", session_id: sid, tool_use_id: toolUseId, + cwd, tool_name: "Bash", tool_input: { command }, +}); +const submit = (sid: string, prompt: string) => ({ hook_event_name: "UserPromptSubmit", session_id: sid, prompt }); +const code = (stdout: string): string => stdout.match(/CONFIRM ([0-9a-f]{4})/i)?.[1] ?? "missing"; + +test("pending token stays stable, while the consumed receipt expires", async () => { + const home = mkdtempSync(join(tmpdir(), "fh-receipt-home-")); + const cwd = mkdtempSync(join(tmpdir(), "fh-receipt-cwd-")); + const sid = `receipt-${randomUUID()}`, command = "git commit -m receipt"; + const opts: HandleOptions = { now: 1000, cwd, home }; + const first = await handleHook("codex", pre(sid, command, "tool-a", cwd), opts); + const repeated = await handleHook("codex", pre(sid, command, "tool-a", cwd), { ...opts, now: 1050 }); + expect(code(repeated.stdout)).toBe(code(first.stdout)); + await handleHook("codex", submit(sid, `CONFIRM ${code(first.stdout)}`), { ...opts, now: 1100 }); + expect((await handleHook("codex", pre(sid, command, "tool-a", cwd), { ...opts, now: 1200 })).stdout).not.toContain('"permissionDecision":"deny"'); + const expired = await handleHook("codex", pre(sid, command, "tool-a", cwd), { ...opts, now: 1200 + 5 * 60 * 1000 + 1 }); + expect(expired.stdout).toContain("rejection: expired"); +}); diff --git a/test/confirm.test.ts b/test/confirm.test.ts index 57960ba..4fbf147 100644 --- a/test/confirm.test.ts +++ b/test/confirm.test.ts @@ -4,15 +4,23 @@ import { mkdtempSync } from "node:fs"; import { join } from "node:path"; import { randomUUID } from "node:crypto"; import { handleHook, type HandleOptions } from "../src/runtime/handle"; -import { hashForAction, displayCodeForAction } from "../src/runtime/confirm/confirm-code"; import { isIrreversible } from "../src/runtime/confirm/confirm-irreversible"; import { confirmGate } from "../src/runtime/confirm/confirm-gate"; +import { codexInit } from "../src/init/templates"; const cwd = (): string => mkdtempSync(join(tmpdir(), "fh-confirm-cwd-")); const home = (): string => mkdtempSync(join(tmpdir(), "fh-confirm-home-")); const sid = (label: string): string => `${label}-${randomUUID()}`; -const pre = (id: string, s: string, command: string) => ({ hook_event_name: "PreToolUse", session_id: s, tool_name: "Bash", tool_input: { command } }); +const pre = (id: string, s: string, command: string) => ({ hook_event_name: "PreToolUse", session_id: s, tool_use_id: randomUUID(), tool_name: "Bash", tool_input: { command } }); +const codexPre = (s: string, command: string, toolUseId: string, workdir: string) => ({ + hook_event_name: "PreToolUse", + session_id: s, + tool_use_id: toolUseId, + cwd: workdir, + tool_name: "Bash", + tool_input: { command }, +}); const submit = (s: string, prompt: string) => ({ hook_event_name: "UserPromptSubmit", session_id: s, prompt }); /** Extract the 4-hex-char code from a "Pour autoriser, réponds : CONFIRM xxxx" deny message. */ @@ -53,6 +61,25 @@ test("confirm the exact action -> next identical Bash call is allowed", async () expect(allowed.stdout.includes('"permissionDecision":"deny"')).toBe(false); }); +test("codex fan-out: one confirmed tool_use_id allows sibling callbacks idempotently, then denies another id", async () => { + const h = home(); + const workdir = cwd(); + const opts: HandleOptions = { now: 1000, cwd: workdir, home: h }; + const s = sid("codex-fanout"); + const cmd = "git commit -m confirm-fanout"; + const denied = await handleHook("codex", codexPre(s, cmd, "tool-a", workdir), opts); + const code = codeFromDeny(denied.stdout); + await handleHook("codex", submit(s, `CONFIRM ${code}`), { ...opts, now: 1100 }); + + const first = await handleHook("codex", codexPre(s, cmd, "tool-a", workdir), { ...opts, now: 1200 }); + const sibling = await handleHook("codex", codexPre(s, cmd, "tool-a", workdir), { ...opts, now: 1201 }); + const distinct = await handleHook("codex", codexPre(s, cmd, "tool-b", workdir), { ...opts, now: 1202 }); + + expect(first.stdout).not.toContain('"permissionDecision":"deny"'); + expect(sibling.stdout).not.toContain('"permissionDecision":"deny"'); + expect(JSON.parse(distinct.stdout).hookSpecificOutput.permissionDecision).toBe("deny"); +}); + test("G1: a consumed token cannot be replayed for the same action", async () => { const h = home(); const opts: HandleOptions = { now: 1000, cwd: cwd(), home: h }; @@ -66,6 +93,21 @@ test("G1: a consumed token cannot be replayed for the same action", async () => expect(JSON.parse(replay.stdout).hookSpecificOutput.permissionDecision).toBe("deny"); }); +test("codex consumed confirmation text cannot re-arm without a new denial", async () => { + const h = home(); + const workdir = cwd(); + const opts: HandleOptions = { now: 1000, cwd: workdir, home: h }; + const s = sid("codex-submit-replay"); + const cmd = "git commit -m confirm-submit-replay"; + const denied = await handleHook("codex", codexPre(s, cmd, "tool-a", workdir), opts); + const code = codeFromDeny(denied.stdout); + await handleHook("codex", submit(s, `CONFIRM ${code}`), { ...opts, now: 1100 }); + await handleHook("codex", codexPre(s, cmd, "tool-a", workdir), { ...opts, now: 1200 }); + await handleHook("codex", submit(s, `CONFIRM ${code}`), { ...opts, now: 1250 }); + const replay = await handleHook("codex", codexPre(s, cmd, "tool-b", workdir), { ...opts, now: 1300 }); + expect(JSON.parse(replay.stdout).hookSpecificOutput.permissionDecision).toBe("deny"); +}); + test("G2: a token older than the 5-minute TTL is rejected", async () => { const h = home(); const opts: HandleOptions = { now: 1000, cwd: cwd(), home: h }; @@ -79,30 +121,68 @@ test("G2: a token older than the 5-minute TTL is rejected", async () => { expect(JSON.parse(stale.stdout).hookSpecificOutput.permissionDecision).toBe("deny"); }); -test("G3: a token bound to one action's FULL hash never unlocks another action, even when their 4-char display codes collide", async () => { - // Verified collision (node:crypto sha256, computed offline): both share the - // display code "94e3" but their full 64-char hashes differ. - const cmdA = "git commit -m confirm-collision-127"; - const cmdB = "git commit -m confirm-collision-239"; - expect(displayCodeForAction(cmdA)).toBe("94e3"); - expect(displayCodeForAction(cmdB)).toBe("94e3"); - expect(hashForAction(cmdA)).not.toBe(hashForAction(cmdB)); - +test("codex command mismatch invalidates the armed action", async () => { + const cmdA = "git commit -m confirm-action-a"; + const cmdB = "git commit -m confirm-action-b"; const h = home(); - const opts: HandleOptions = { now: 1000, cwd: cwd(), home: h }; - const s = sid("g3-collision"); - const deniedA = await handleHook("codex", pre("codex", s, cmdA), opts); + const workdir = cwd(); + const opts: HandleOptions = { now: 1000, cwd: workdir, home: h }; + const s = sid("command-mismatch"); + const deniedA = await handleHook("codex", codexPre(s, cmdA, "tool-a", workdir), opts); const code = codeFromDeny(deniedA.stdout); - expect(code.toLowerCase()).toBe("94e3"); - // Confirm A's code — this must bind the token to A's FULL hash, not "94e3". await handleHook("codex", submit(s, `CONFIRM ${code}`), { ...opts, now: 1100 }); - // B was never the pending action when the code was typed, and B's hash - // differs from A's — B must stay denied despite the code matching. - const deniedB = await handleHook("codex", pre("codex", s, cmdB), { ...opts, now: 1200 }); + const deniedB = await handleHook("codex", codexPre(s, cmdB, "tool-b", workdir), { ...opts, now: 1200 }); expect(JSON.parse(deniedB.stdout).hookSpecificOutput.permissionDecision).toBe("deny"); - // A itself must still be confirmable (token untouched by B's mismatch, per confirm-state.ts's "no-op on hash mismatch"). - const allowedA = await handleHook("codex", pre("codex", s, cmdA), { ...opts, now: 1300 }); - expect(allowedA.stdout.includes('"permissionDecision":"deny"')).toBe(false); + expect(deniedB.stdout).toContain("rejection: mismatch"); + const deniedAAgain = await handleHook("codex", codexPre(s, cmdA, "tool-a", workdir), { ...opts, now: 1300 }); + expect(JSON.parse(deniedAAgain.stdout).hookSpecificOutput.permissionDecision).toBe("deny"); +}); + +test("codex action identity canonicalizes argv/string transport and rejects a different cwd", async () => { + const h = home(); + const workdir = cwd(); + const other = cwd(); + const opts: HandleOptions = { now: 1000, cwd: workdir, home: h }; + const s1 = sid("transport"); + const cmd = "git commit -m confirm-transport"; + const argv = { ...codexPre(s1, cmd, "tool-a", workdir), tool_input: { command: ["bash", "-lc", cmd] } }; + const denied = await handleHook("codex", argv, opts); + const code = codeFromDeny(denied.stdout); + await handleHook("codex", submit(s1, `CONFIRM ${code}`), { ...opts, now: 1100 }); + const allowed = await handleHook("codex", codexPre(s1, cmd, "tool-a", workdir), { ...opts, now: 1200 }); + expect(allowed.stdout).not.toContain('"permissionDecision":"deny"'); + + const s2 = sid("cwd"); + const deniedAtWorkdir = await handleHook("codex", codexPre(s2, cmd, "tool-b", workdir), opts); + await handleHook("codex", submit(s2, `CONFIRM ${codeFromDeny(deniedAtWorkdir.stdout)}`), { ...opts, now: 1100 }); + const wrongCwd = await handleHook("codex", codexPre(s2, cmd, "tool-b", other), { ...opts, now: 1200 }); + expect(JSON.parse(wrongCwd.stdout).hookSpecificOutput.permissionDecision).toBe("deny"); + expect(wrongCwd.stdout).toContain("rejection: mismatch"); +}); + +test("codex diagnostics name rule, canonical command, expected token, and typed rejection", async () => { + const workdir = cwd(); + const cmd = "echo log > out.txt"; + const out = await handleHook("codex", codexPre(sid("diagnostic"), cmd, "tool-a", workdir), { now: 1000, cwd: workdir, home: home() }); + expect(out.stdout).toContain("rule ID: bash-write:file-redirect"); + expect(out.stdout).toContain(`canonical command: ${cmd}`); + expect(out.stdout).toMatch(/expected token: CONFIRM [0-9a-f]{4}/); + expect(out.stdout).toContain("rejection: no-token"); +}); + +test("codex UserPromptSubmit is wired and arms confirmation before scope early returns", async () => { + const hooks = JSON.parse(codexInit("harness hook codex")[0]!.content) as { hooks: Record }; + expect(hooks.hooks.UserPromptSubmit?.length).toBe(1); + const h = home(); + const workdir = cwd(); + const opts: HandleOptions = { now: 1000, cwd: workdir, home: h }; + const s = sid("scope-submit"); + const cmd = "git commit -m scope-submit"; + const denied = await handleHook("codex", codexPre(s, cmd, "tool-a", workdir), opts); + const code = codeFromDeny(denied.stdout); + await handleHook("codex", submit(s, `CONFIRM ${code}`), { ...opts, now: 1100, scope: "rules" }); + const allowed = await handleHook("codex", codexPre(s, cmd, "tool-a", workdir), { ...opts, now: 1200 }); + expect(allowed.stdout).not.toContain('"permissionDecision":"deny"'); }); test("G4: git push --force is never confirmable (hard block, no CONFIRM code offered at all)", async () => { diff --git a/test/cursor-cli-p0.test.ts b/test/cursor-cli-p0.test.ts new file mode 100644 index 0000000..72b4942 --- /dev/null +++ b/test/cursor-cli-p0.test.ts @@ -0,0 +1,87 @@ +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { isAbsolute, join, resolve } from "node:path"; + +const simBin = process.env.SIM_BIN; +const bin = simBin + ? (isAbsolute(simBin) ? simBin : resolve(import.meta.dir, "..", simBin)) + : join(import.meta.dir, "..", "src", "cli", "bin.ts"); +const runtime = simBin ? "node" : "bun"; +const commands = [ + { name: "root-delete", value: ["rm", "-rf", "/"].join(" "), permission: "deny" }, + { name: "source-sed", value: ["sed", "-i", "'s/a/b/'", "src/app.ts"].join(" "), permission: "deny" }, + { name: "install", value: ["npm", "install"].join(" "), permission: "deny" }, + { name: "safe", value: ["ls", "-la"].join(" "), permission: "allow" }, +] as const; + +function runCursor(payload: Record, env: Record = {}): { exit: number; permission: string; stdout: string } { + const child = spawnSync(runtime, [bin, "hook", "cursor", "core"], { + input: JSON.stringify(payload), + encoding: "utf8", + env: { ...process.env, FUSE_ENFORCE_TTL_SEC: "3600", ...env }, + }); + const stdout = child.stdout.trim(); + const permission = stdout ? (JSON.parse(stdout) as { permission?: string }).permission ?? "allow" : "allow"; + return { exit: child.status ?? 1, permission, stdout }; +} + +function runRaw(id: string, input: string): { exit: number; stdout: string } { + const child = spawnSync(runtime, [bin, "hook", id, "core"], { + input, + encoding: "utf8", + env: { ...process.env, FUSE_ENFORCE_TTL_SEC: "3600" }, + }); + return { exit: child.status ?? 1, stdout: child.stdout }; +} + +test("Cursor CLI gates documented beforeShellExecution and preToolUse Shell payloads", () => { + const shapes = [ + (command: string): Record => ({ hook_event_name: "beforeShellExecution", command, cwd: process.cwd(), sandbox: false }), + (command: string): Record => ({ hook_event_name: "preToolUse", tool_name: "Shell", tool_input: { command, working_directory: process.cwd() } }), + ]; + for (const shape of shapes) { + for (const command of commands) { + const { exit, permission } = runCursor(shape(command.value)); + expect({ exit, permission }, command.name).toEqual({ exit: 0, permission: command.permission }); + } + } +}); + +test("Cursor CLI keeps documented afterFileEdit observe-only without unsupported fields", () => { + const filePath = join(process.cwd(), `.cursor-p0-${process.pid}.ts`); + const result = runCursor({ + hook_event_name: "afterFileEdit", + file_path: filePath, + edits: [{ old_string: "const value = 1", new_string: "const value = 2" }], + }); + expect({ exit: result.exit, response: JSON.parse(result.stdout) }).toEqual({ exit: 0, response: {} }); +}); + +test("Cursor CLI returns exact permission-only allow and deny for documented beforeTabFileRead", () => { + const allow = runCursor({ + hook_event_name: "beforeTabFileRead", + file_path: join(process.cwd(), "src", "index.ts"), + content: "export {};", + }); + expect({ exit: allow.exit, response: JSON.parse(allow.stdout) }).toEqual({ + exit: 0, + response: { permission: "allow" }, + }); + const deny = runCursor({ + hook_event_name: "beforeTabFileRead", + file_path: join(process.cwd(), "src", "oversized.ts"), + content: "x".repeat(256), + }, { FUSE_HOOK_STDIN_MAX_BYTES: "128" }); + expect({ exit: deny.exit, response: JSON.parse(deny.stdout) }).toEqual({ + exit: 0, + response: { permission: "deny" }, + }); +}); + +test("Cursor malformed non-empty stdin exits nonzero without stdout while empty and other harnesses keep parity", () => { + expect(runRaw("cursor", "{not-json")).toEqual({ exit: 1, stdout: "" }); + expect(runRaw("cursor", "")).toEqual({ exit: 0, stdout: "{}" }); + expect(runRaw("claude-code", "{not-json")).toEqual({ exit: 0, stdout: "" }); + expect(runRaw("codex", "{not-json")).toEqual({ exit: 0, stdout: "" }); + expect(runRaw("kimi", "{not-json")).toEqual({ exit: 0, stdout: "" }); +}); diff --git a/test/cursor-context.test.ts b/test/cursor-context.test.ts new file mode 100644 index 0000000..3c0f5f0 --- /dev/null +++ b/test/cursor-context.test.ts @@ -0,0 +1,130 @@ +import { expect, test } from "bun:test"; +import { normalizeEvent } from "../src/runtime/normalize"; +import { handleHook } from "../src/runtime/handle"; +import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { cursorAbsolutePath, cursorProjectCwd } from "../src/adapters/cursor/context"; + +test("Cursor path validation rejects filesystem-invalid NUL bytes", () => { + expect(cursorAbsolutePath("/workspace/\0secret")).toBeUndefined(); +}); + +test("Cursor normalization removes NUL paths from read, tool, and edit wiring", async () => { + const readPayload = { + hook_event_name: "beforeReadFile", + file_path: "/workspace/\0secret", + cwd: "/workspace/\0cwd", + workspace_roots: ["/workspace/\0root", "/workspace/valid"], + content: "preserve-content", + }; + const read = normalizeEvent("cursor", readPayload); + expect(read.input).not.toBe(readPayload); + expect([read.phase, read.tool, read.filePath, read.input.file_path, read.cwd, read.workspaceRoots]) + .toEqual(["pre", "Read", undefined, undefined, undefined, ["/workspace/valid"]]); + expect(read.input.content).toBe("preserve-content"); + expect(readPayload.file_path).toContain("\0"); + expect(readPayload.workspace_roots).toHaveLength(2); + + const tool = normalizeEvent("cursor", { + hook_event_name: "preToolUse", + tool_name: "Write", + tool_input: { file_path: "/workspace/\0tool.ts", path: "/workspace/\0fallback.ts", content: "x" }, + }); + expect([tool.filePath, tool.input.file_path, tool.input.path]) + .toEqual([undefined, undefined, undefined]); + + const editPayload = { + hook_event_name: "afterFileEdit", + file_path: "/workspace/\0edit.ts", + edits: [{ old_string: "a", new_string: "b" }], + }; + const edit = normalizeEvent("cursor", editPayload); + expect([edit.phase, edit.tool, edit.filePath, edit.files, edit.input.file_path]) + .toEqual(["post", "Edit", undefined, undefined, undefined]); + expect(await handleHook("cursor", editPayload, { now: 1000, cwd: "/workspace" })) + .toEqual({ stdout: "{}", exit: 0 }); +}); + +test("Cursor sanitizes mixed top-level paths without mutating valid edit payloads", () => { + const payload = { + hook_event_name: "afterFileEdit", + file_path: "/workspace/valid.ts", + path: "/workspace/\0invalid-path.ts", + cwd: "/workspace/\0invalid-cwd", + workspace_roots: ["/workspace/\0invalid-root", "/workspace/valid-root"], + edits: [{ old_string: "a", new_string: "b" }], + audit_tag: "preserve-me", + }; + const event = normalizeEvent("cursor", payload); + expect(event.input).not.toBe(payload); + expect(event.input).toMatchObject({ + file_path: "/workspace/valid.ts", + workspace_roots: ["/workspace/valid-root"], + edits: payload.edits, + audit_tag: "preserve-me", + }); + expect(event.input).not.toHaveProperty("path"); + expect(event.input).not.toHaveProperty("cwd"); + expect(event.files).toEqual([{ + filePath: "/workspace/valid.ts", + oldString: "a", + content: "b", + op: "update", + }]); + expect(payload.path).toContain("\0"); + expect(payload.cwd).toContain("\0"); + expect(payload.workspace_roots).toHaveLength(2); +}); + +test("Cursor normalization preserves validated cwd and distinct workspace roots", () => { + const event = normalizeEvent("cursor", { + hook_event_name: "preToolUse", + cwd: "/workspace/root-b/../root-b", + workspace_roots: ["/workspace/root-a", "", "/workspace/root-b", "/workspace/root-a", "relative/root"], + tool_name: "Read", + tool_input: { file_path: "/workspace/root-a/src/app.ts" }, + }); + expect(event.cwd).toBe("/workspace/root-b"); + expect(event.workspaceRoots).toEqual(["/workspace/root-a", "/workspace/root-b"]); +}); + +test("Cursor multi-root selection canonicalizes symlinks before choosing the file root", () => { + const base = mkdtempSync(join(tmpdir(), "cursor-multi-root-")); + const actual = join(base, "actual workspace"); + const alias = join(base, "workspace-link"); + mkdirSync(join(actual, "src"), { recursive: true }); + symlinkSync(actual, alias); + try { + const event = normalizeEvent("cursor", { + hook_event_name: "preToolUse", + workspace_roots: [alias, actual], + tool_name: "Write", + tool_input: { file_path: join(actual, "src", "app.ts"), content: "export {};" }, + }); + const canonical = realpathSync.native(actual); + expect(event.workspaceRoots).toEqual([canonical]); + expect(cursorProjectCwd(event.cwd, event.workspaceRoots ?? [], event.filePath, "/fallback")).toBe(canonical); + } finally { + rmSync(base, { recursive: true }); + } +}); + +test("Cursor payload cwd scopes lifecycle project detection instead of process fallback", async () => { + const payloadCwd = mkdtempSync(join(tmpdir(), "cursor-payload-cwd-")); + const fallbackCwd = mkdtempSync(join(tmpdir(), "cursor-fallback-cwd-")); + writeFileSync(join(payloadCwd, "package.json"), "{}"); + try { + const outcome = await handleHook("cursor", { + hook_event_name: "sessionStart", + conversation_id: "cursor-project-cwd", + cwd: payloadCwd, + workspace_roots: [payloadCwd], + }, { now: 1000, cwd: fallbackCwd }); + const response = JSON.parse(outcome.stdout) as { additional_context?: string }; + expect(response.additional_context).toContain("Project: Node.js"); + } finally { + rmSync(payloadCwd, { recursive: true }); + rmSync(fallbackCwd, { recursive: true }); + } +}); diff --git a/test/cursor-followup-advisory.test.ts b/test/cursor-followup-advisory.test.ts new file mode 100644 index 0000000..2c7f5c3 --- /dev/null +++ b/test/cursor-followup-advisory.test.ts @@ -0,0 +1,117 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { postOutcome } from "../src/runtime/post-outcome"; +import { runHook, spawnEnv } from "./sim/exec"; + +const fixtures = join(import.meta.dir, "sim", "fixtures"); +const oversized = Array.from({ length: 400 }, (_, index) => `const line${index} = ${index};`).join("\n"); + +function runAdvisory( + scope: string, + fileName: string, + content: string, + setup?: (cwd: string) => void, + envOverrides: Record = {}, +) { + const cwd = mkdtempSync(join(tmpdir(), `cursor-${scope}-advisory-`)); + const filePath = join(cwd, fileName); + try { + setup?.(cwd); + writeFileSync(filePath, content); + const payload = { + hook_event_name: "afterFileEdit", + session_id: `${scope}-advisory`, + file_path: filePath, + edits: [{ old_string: "", new_string: content }], + }; + const env = spawnEnv(fixtures, cwd, { + FUSE_SOLID_MAX_LINES: "200", + SOLID_PROJECT_TYPE: "generic", + SOLID_FILE_LIMIT: "200", + ...envOverrides, + }); + const result = runHook("cursor", scope, payload, cwd, env); + return { exit: result.exit, response: JSON.parse(result.stdout) as { permission?: string; user_message?: string } }; + } finally { + rmSync(cwd, { recursive: true }); + } +} + +test("Cursor core afterFileEdit emits no unsupported callback fields", () => { + const result = runAdvisory("core", "app.ts", oversized); + expect(result.exit).toBe(0); + expect(result.response).toEqual({}); +}); + +test("Cursor solid afterFileEdit emits no unsupported callback fields", () => { + const result = runAdvisory("solid", "app.ts", oversized); + expect(result.exit).toBe(0); + expect(result.response).toEqual({}); +}); + +test("Cursor solid afterFileEdit stays schema-empty when raw lines and LOC differ", () => { + const content = [ + ...Array.from({ length: 188 }, (_, index) => `const line${index} = ${index};`), + ...Array.from({ length: 13 }, (_, index) => `// spacer ${index}`), + ].join("\n"); + const result = runAdvisory("solid", "app.ts", content, undefined, { SOLID_FILE_LIMIT: "180" }); + expect(result.exit).toBe(0); + expect(result.response).toEqual({}); +}); + +test("Cursor tailwind afterFileEdit emits no unsupported callback fields", () => { + const content = ["@tailwind base;", ...Array.from({ length: 399 }, (_, index) => `.x${index} { @apply flex; }`)].join("\n"); + const result = runAdvisory("tailwindcss", "app.css", content, (cwd) => { + writeFileSync(join(cwd, "package.json"), JSON.stringify({ devDependencies: { tailwindcss: "4.1.0" } })); + }); + expect(result.exit).toBe(0); + expect(result.response).toEqual({}); +}); + +test("Cursor degenerate afterFileEdit payloads never return deny", () => { + const cwd = mkdtempSync(join(tmpdir(), "cursor-degenerate-advisory-")); + try { + for (const edits of [[], undefined, null, { old_string: "a", new_string: "b" }]) { + const payload: Record = { + hook_event_name: "afterFileEdit", + session_id: "degenerate-advisory", + file_path: join(cwd, "app.txt"), + }; + if (edits !== undefined) payload.edits = edits; + const result = runHook("cursor", "core", payload, cwd, spawnEnv(fixtures, cwd)); + expect(result.exit).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({}); + } + } finally { + rmSync(cwd, { recursive: true }); + } +}); + +test("Cursor post outputs use the event-specific schema without changing other harnesses", () => { + const cwd = mkdtempSync(join(tmpdir(), "cursor-post-schema-")); + const base = { + agentId: "", + sessionId: "post-schema", + now: 1000, + cwd, + activities: [], + files: [], + designCacheDir: cwd, + designWarn: { kind: "block", title: "Design", reason: "review required" } as const, + extra: "", + cursorAfterFileEdit: false, + }; + try { + const postTool = postOutcome({ ...base, id: "cursor", cursorEventName: "postToolUse" }); + const parsed = JSON.parse(postTool.stdout) as { permission?: string; additional_context?: string }; + expect(parsed.permission).toBeUndefined(); + expect(parsed.additional_context).toContain("review required"); + expect(postOutcome({ ...base, id: "cursor", cursorEventName: "afterShellExecution" }).stdout).toBe(""); + expect(postOutcome({ ...base, id: "claude-code", cursorEventName: "" }).stdout).toContain("hookSpecificOutput"); + expect(postOutcome({ ...base, id: "kimi", cursorEventName: "" }).stdout).toContain("hookSpecificOutput"); + } finally { + rmSync(cwd, { recursive: true }); + } +}); diff --git a/test/cursor-followup-normalize.test.ts b/test/cursor-followup-normalize.test.ts new file mode 100644 index 0000000..8bd9aac --- /dev/null +++ b/test/cursor-followup-normalize.test.ts @@ -0,0 +1,166 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleHook } from "../src/runtime/handle"; +import { normalizeEvent } from "../src/runtime/normalize"; + +const destructive = ["rm", "-rf", "/"].join(" "); + +test("Cursor afterFileEdit remains post for degenerate edits payloads", () => { + const variants: unknown[] = [[], undefined, null, { old_string: "a", new_string: "b" }]; + for (const edits of variants) { + const payload: Record = { + hook_event_name: "afterFileEdit", + file_path: "/project/app.ts", + }; + if (edits !== undefined) payload.edits = edits; + const event = normalizeEvent("cursor", payload); + expect([event.phase, event.tool, event.filePath]).toEqual(["post", "Edit", "/project/app.ts"]); + } +}); + +test("Cursor postToolUse shell commands are post and never denied", async () => { + const cwd = mkdtempSync(join(tmpdir(), "cursor-post-tool-")); + const payload = { + hook_event_name: "postToolUse", + session_id: "post-tool", + tool_name: "Shell", + tool_input: { command: destructive }, + }; + try { + expect(normalizeEvent("cursor", payload).phase).toBe("post"); + const outcome = await handleHook("cursor", payload, { now: 1000, cwd }); + expect(outcome).toEqual({ stdout: "{}", exit: 0 }); + } finally { + rmSync(cwd, { recursive: true }); + } +}); + +test("Cursor afterShellExecution commands are post and never denied", async () => { + const cwd = mkdtempSync(join(tmpdir(), "cursor-after-shell-")); + const payload = { + hook_event_name: "afterShellExecution", + session_id: "after-shell", + command: destructive, + }; + try { + expect(normalizeEvent("cursor", payload).phase).toBe("post"); + const outcome = await handleHook("cursor", payload, { now: 1000, cwd }); + expect(outcome).toEqual({ stdout: "{}", exit: 0 }); + } finally { + rmSync(cwd, { recursive: true }); + } +}); + +test("Cursor beforeReadFile remains pre and returns native allow", async () => { + const cwd = mkdtempSync(join(tmpdir(), "cursor-before-read-")); + const payload = { + hook_event_name: "beforeReadFile", + session_id: "before-read", + file_path: "/project/app.ts", + }; + try { + const event = normalizeEvent("cursor", payload); + expect([event.phase, event.tool, event.filePath]).toEqual(["pre", "Read", "/project/app.ts"]); + expect(event.input.file_path).toBe("/project/app.ts"); + expect(await handleHook("cursor", payload, { now: 1000, cwd })).toEqual({ stdout: '{"permission":"allow"}', exit: 0 }); + } finally { + rmSync(cwd, { recursive: true }); + } +}); + +test("Cursor beforeShellExecution and preToolUse still deny destructive commands", async () => { + const cwd = mkdtempSync(join(tmpdir(), "cursor-pre-controls-")); + const payloads = [ + { hook_event_name: "beforeShellExecution", command: destructive }, + { hook_event_name: "preToolUse", tool_name: "Shell", tool_input: { command: destructive } }, + ]; + try { + for (const payload of payloads) { + expect(normalizeEvent("cursor", payload).phase).toBe("pre"); + const outcome = await handleHook("cursor", payload, { now: 1000, cwd }); + expect(JSON.parse(outcome.stdout).permission).toBe("deny"); + } + } finally { + rmSync(cwd, { recursive: true }); + } +}); + +test("Cursor command-bearing tool aliases normalize to Bash and deny", async () => { + const cwd = mkdtempSync(join(tmpdir(), "cursor-command-aliases-")); + try { + for (const tool_name of ["Terminal", "shell", "MCP:run_command"]) { + const payload = { + hook_event_name: "preToolUse", + session_id: `alias-${tool_name}`, + tool_name, + tool_input: { command: destructive }, + }; + expect(normalizeEvent("cursor", payload).tool).toBe("Bash"); + const outcome = await handleHook("cursor", payload, { now: 1000, cwd }); + expect(JSON.parse(outcome.stdout).permission).toBe("deny"); + } + } finally { + rmSync(cwd, { recursive: true }); + } +}); + +test("Cursor beforeMCPExecution parses JSON input and gates root plus nested commands", async () => { + const cwd = mkdtempSync(join(tmpdir(), "cursor-before-mcp-")); + const nestedSafe = ["git", "status"].join(" "); + const rootPayload = { + hook_event_name: "beforeMCPExecution", + session_id: "mcp-root", + tool_name: "run_command", + command: destructive, + tool_input: JSON.stringify({ command: nestedSafe, arguments: { cwd } }), + }; + const nestedPayload = { + hook_event_name: "beforeMCPExecution", + session_id: "mcp-nested", + tool_name: "run_command", + tool_input: JSON.stringify({ command: destructive }), + }; + try { + const root = normalizeEvent("cursor", rootPayload); + expect([root.tool, root.command, root.commandCandidates, root.input.command]) + .toEqual(["Bash", destructive, [destructive, nestedSafe], nestedSafe]); + const nested = normalizeEvent("cursor", nestedPayload); + expect([nested.tool, nested.command]).toEqual(["Bash", destructive]); + for (const payload of [rootPayload, nestedPayload]) { + const outcome = await handleHook("cursor", payload, { now: 1000, cwd }); + expect(JSON.parse(outcome.stdout).permission).toBe("deny"); + } + } finally { + rmSync(cwd, { recursive: true }); + } +}); + +test("Cursor malformed JSON fails safely and commandless MCP tools keep their name", () => { + const invalid = normalizeEvent("cursor", { + hook_event_name: "beforeMCPExecution", + tool_name: "lookup", + tool_input: "{not-json", + }); + expect([invalid.tool, invalid.command, invalid.input.tool_input]).toEqual(["lookup", undefined, "{not-json"]); + + const commandless = normalizeEvent("cursor", { + hook_event_name: "preToolUse", + tool_name: "MCP:lookup", + tool_input: JSON.stringify({ query: "value" }), + }); + expect([commandless.tool, commandless.command, commandless.input.query]).toEqual(["MCP:lookup", undefined, "value"]); +}); + +test("Cursor JSON tool input preserves prototype keys without polluting prototypes", () => { + const before = (Object.prototype as Record).polluted; + const event = normalizeEvent("cursor", { + hook_event_name: "beforeMCPExecution", + tool_name: "lookup", + tool_input: '{"__proto__":{"polluted":true},"constructor":{"prototype":{"polluted":true}}}', + }); + expect(Object.hasOwn(event.input, "__proto__")).toBe(true); + expect(Object.getPrototypeOf(event.input)).toBe(Object.prototype); + expect((Object.prototype as Record).polluted).toBe(before); +}); diff --git a/test/cursor-mcp-command-candidates.test.ts b/test/cursor-mcp-command-candidates.test.ts new file mode 100644 index 0000000..1e3babe --- /dev/null +++ b/test/cursor-mcp-command-candidates.test.ts @@ -0,0 +1,169 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleHook } from "../src/runtime/handle"; +import { gateCommandCandidates, type GateInput } from "../src/runtime/gate"; +import { normalizeEvent } from "../src/runtime/normalize"; +import { clearUserGuards, registerGuard } from "../src/policy/guards"; +import { loadState, SIDECAR as ONE_SHOT_SIDECAR } from "../src/tracking/one-shot"; + +const destructive = ["rm", "-rf", "/"].join(" "); +const rootBenign = ["node", "server.mjs"].join(" "); +const nestedBenign = ["git", "status"].join(" "); +const nestedWriter = `node -e "require('fs').writeFileSync('result.txt', 'x')"`; + +async function runPayload(payload: Record) { + const cwd = mkdtempSync(join(tmpdir(), "cursor-mcp-candidates-")); + try { + return { + event: normalizeEvent("cursor", payload), + outcome: await handleHook("cursor", payload, { now: 1000, cwd }), + }; + } finally { + rmSync(cwd, { recursive: true }); + } +} + +async function run(rootCommand: string, nestedCommand?: string) { + const toolInput = nestedCommand === undefined ? { query: "value" } : { command: nestedCommand }; + return runPayload({ + hook_event_name: "beforeMCPExecution", + session_id: "mcp-candidates", + tool_name: "run_command", + command: rootCommand, + tool_input: JSON.stringify(toolInput), + }); +} + +function expectDenied(stdout: string): void { + expect(JSON.parse(stdout).permission).toBe("deny"); +} + +function gateInput(cwd: string, now = 1000): GateInput { + return { + sessionId: "mcp-candidate-gate", + framework: "generic", + tool: "Bash", + command: rootBenign, + cwd, + now, + windowMs: 10_000, + trackFile: join(cwd, "track.json"), + }; +} + +test("Cursor gates a destructive nested command when the MCP root launcher is benign", async () => { + const result = await run(rootBenign, destructive); + expect([result.event.command, result.event.commandCandidates]).toEqual([rootBenign, [rootBenign, destructive]]); + expectDenied(result.outcome.stdout); +}); + +test("Cursor gates a destructive MCP root launcher when the nested command is benign", async () => { + const result = await run(destructive, nestedBenign); + expect([result.event.command, result.event.commandCandidates]).toEqual([destructive, [destructive, nestedBenign]]); + expectDenied(result.outcome.stdout); +}); + +test("Cursor allows independent benign MCP command candidates", async () => { + const result = await run(rootBenign, nestedBenign); + expect([result.event.command, result.event.commandCandidates]).toEqual([rootBenign, [rootBenign, nestedBenign]]); + expect(result.outcome).toEqual({ stdout: '{"permission":"allow"}', exit: 0 }); +}); + +test("Cursor still gates a root-only MCP launcher", async () => { + const result = await run(destructive); + expect(result.event.command).toBe(destructive); + expect(result.event.commandCandidates).toEqual([destructive]); + expectDenied(result.outcome.stdout); +}); + +test("Cursor safe root prefixes cannot hide a nested MCP writer", async () => { + const result = await run(nestedBenign, nestedWriter); + expect(result.event.commandCandidates).toEqual([nestedBenign, nestedWriter]); + expectDenied(result.outcome.stdout); +}); + +test("Cursor evaluates MCP candidates independently across heredoc-looking boundaries", async () => { + const rootTemplate = `node server.mjs --template "< { + const command = `cat < { + const result = await run(destructive, destructive); + expect([result.event.command, result.event.commandCandidates]).toEqual([destructive, [destructive]]); + expectDenied(result.outcome.stdout); +}); + +test("Cursor malformed JSON still gates the root MCP launcher", async () => { + const result = await runPayload({ + hook_event_name: "beforeMCPExecution", + session_id: "mcp-malformed", + tool_name: "run_command", + command: destructive, + tool_input: "{not-json", + }); + expect([result.event.command, result.event.commandCandidates]).toEqual([destructive, [destructive]]); + expectDenied(result.outcome.stdout); +}); + +test("Cursor candidate aggregation lets a later block dominate an earlier ask", async () => { + const cwd = mkdtempSync(join(tmpdir(), "cursor-candidate-rank-")); + try { + const prompt = await gateCommandCandidates(gateInput(cwd), ["apt-get install jq", destructive]); + expect(prompt).toMatchObject({ kind: "block", title: "Dangerous command" }); + } finally { + rmSync(cwd, { recursive: true }); + } +}); + +test("Cursor candidate aggregation lets an ask dominate an earlier inform", async () => { + const cwd = mkdtempSync(join(tmpdir(), "cursor-candidate-rank-")); + clearUserGuards(); + registerGuard((ctx) => ctx.command === "echo advisory" + ? { kind: "inform", title: "Advisory", reason: "test advisory" } + : null); + try { + const prompt = await gateCommandCandidates(gateInput(cwd), ["echo advisory", "apt-get install jq"]); + expect(prompt).toMatchObject({ kind: "ask", title: "Dependency install" }); + } finally { + clearUserGuards(); + rmSync(cwd, { recursive: true }); + } +}); + +test("Cursor candidate bookkeeping records only the decisive outcome", async () => { + const cwd = mkdtempSync(join(tmpdir(), "cursor-candidate-one-shot-")); + try { + await gateCommandCandidates(gateInput(cwd), [rootBenign, destructive]); + const state = loadState(join(cwd, ONE_SHOT_SIDECAR)); + expect(state.firstTry).toBe(0); + expect(state.gates["Dangerous command"]?.denies).toBe(1); + } finally { + rmSync(cwd, { recursive: true }); + } +}); + +test("Cursor deny-loop identity uses the decisive candidate, not the shared root", async () => { + const cwd = mkdtempSync(join(tmpdir(), "cursor-candidate-deny-loop-")); + try { + const first = await gateCommandCandidates(gateInput(cwd, 1000), [rootBenign, destructive]); + const second = await gateCommandCandidates(gateInput(cwd, 4000), [rootBenign, "rm -rf /etc"]); + const retry = await gateCommandCandidates(gateInput(cwd, 7000), [rootBenign, "rm -rf /etc"]); + expect(first?.title).toBe("Dangerous command"); + expect(second?.title).toBe("Dangerous command"); + expect(retry?.title).toBe("[REPEAT] Dangerous command"); + } finally { + rmSync(cwd, { recursive: true }); + } +}); diff --git a/test/cursor-mcp-provenance.test.ts b/test/cursor-mcp-provenance.test.ts new file mode 100644 index 0000000..52a9eed --- /dev/null +++ b/test/cursor-mcp-provenance.test.ts @@ -0,0 +1,67 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { cacheLookup } from "../src/cache/store"; +import { projectLayout } from "../src/config/layout"; +import { handleHook } from "../src/runtime/handle"; +import { normalizeEvent } from "../src/runtime/normalize"; + +test("Cursor MCP string input merges only authoritative root provenance and result fields", () => { + const payload = { + hook_event_name: "beforeMCPExecution", + tool_name: "run_command", + tool_input: JSON.stringify({ command: "nested command", query: "docs", url: "nested-url" }), + command: "transport command", + mcp_server_name: "context7", + mcp_server_url: "https://mcp.example.test", + url: "https://authoritative.example.test", + result_json: '{"content":"result"}', + duration: 42, + ignored_root: "must not merge", + }; + const event = normalizeEvent("cursor", payload); + expect(event.input).toMatchObject({ + command: "nested command", + query: "docs", + mcp_server_name: "context7", + mcp_server_url: "https://mcp.example.test", + url: "https://authoritative.example.test", + result_json: '{"content":"result"}', + duration: 42, + }); + expect(event.input.ignored_root).toBeUndefined(); + expect([event.command, event.commandCandidates]).toEqual([ + "transport command", + ["transport command", "nested command"], + ]); +}); + +test("Cursor afterMCPExecution qualifies the tool and caches root result_json", async () => { + const cwd = mkdtempSync(join(tmpdir(), "cursor-mcp-cache-")); + const tool = "mcp__context7__query-docs"; + const payload = { + hook_event_name: "afterMCPExecution", + session_id: "cursor-mcp-post", + tool_name: "query-docs", + tool_input: JSON.stringify({ query: "react hooks" }), + mcp_server_name: "context7", + result_json: JSON.stringify([{ type: "text", text: "CURSOR MCP RESULT" }]), + duration: 12, + }; + expect(normalizeEvent("cursor", payload).tool).toBe(tool); + expect(await handleHook("cursor", payload, { cwd, now: Date.now() })).toEqual({ stdout: "{}", exit: 0 }); + expect(cacheLookup(projectLayout(cwd).cacheDir, tool, "react hooks", 10_000, Date.now())) + .toContain("CURSOR MCP RESULT"); + + const control = { + hook_event_name: "postToolUse", + session_id: "cursor-mcp-control", + tool_name: tool, + tool_input: { query: "control query" }, + tool_output: [{ type: "text", text: "CONTROL RESULT" }], + }; + await handleHook("cursor", control, { cwd, now: Date.now() }); + expect(cacheLookup(projectLayout(cwd).cacheDir, tool, "control query", 10_000, Date.now())) + .toContain("CONTROL RESULT"); +}); diff --git a/test/cursor-native-idempotence.test.ts b/test/cursor-native-idempotence.test.ts new file mode 100644 index 0000000..a58825f --- /dev/null +++ b/test/cursor-native-idempotence.test.ts @@ -0,0 +1,128 @@ +import { expect, test } from "bun:test"; +import * as nativeResponseModule from "../src/adapters/cursor/native-response"; +import { parseNativeCursorStdout } from "../src/adapters/cursor/native-response"; +import { toCursorLifecycleResponse } from "../src/adapters/cursor/respond"; + +const NATIVE_CASES = [ + ["sessionStart", '{"env":{"MODE":"safe"},"additional_context":"ctx","continue":true,"user_message":"note"}'], + ["sessionEnd", "{}"], + ["beforeSubmitPrompt", '{"continue":false,"user_message":"blocked"}'], + ["preCompact", '{"user_message":"compacting"}'], + ["subagentStart", '{"permission":"deny","user_message":"blocked"}'], + ["subagentStop", '{"followup_message":"continue"}'], + ["preToolUse", '{"permission":"ask","user_message":"ask user","agent_message":"ask agent","updated_input":{"command":"npm ci"}}'], + ["postToolUse", '{"updated_mcp_tool_output":{"modified":"output"},"additional_context":"coverage"}'], + ["postToolUseFailure", "{}"], + ["beforeShellExecution", '{"permission":"ask","user_message":"ask user","agent_message":"ask agent"}'], + ["afterShellExecution", "{}"], + ["beforeMCPExecution", '{"permission":"ask","user_message":"ask user","agent_message":"ask agent"}'], + ["afterMCPExecution", "{}"], + ["beforeReadFile", '{"permission":"deny","user_message":"private"}'], + ["afterFileEdit", "{}"], + ["beforeTabFileRead", '{"permission":"deny"}'], + ["afterTabFileEdit", "{}"], + ["afterAgentResponse", "{}"], + ["afterAgentThought", "{}"], + ["stop", '{"followup_message":"iterate"}'], + ["workspaceOpen", '{"pluginPaths":["/plugins/one","/plugins/two"]}'], +] as const; + +test("native idempotence is recognized only from raw JSON stdout", () => { + const stdout = ' {"permission":"allow","updated_input":{"command":"npm ci"}}\n'; + expect(parseNativeCursorStdout(stdout, "preToolUse")).toBe(stdout); + expect(parseNativeCursorStdout('{"permission":"allow","unknown":true}', "preToolUse")).toBeNull(); + expect(parseNativeCursorStdout("not json", "preToolUse")).toBeNull(); +}); + +test("native stdout parsing rejects non-strings without coercion", () => { + const valid = '{"permission":"allow"}'; + let coercions = 0; + const parseUnknown = parseNativeCursorStdout as (stdout: unknown, eventName: string) => string | null; + const values: unknown[] = [ + { toString: () => { coercions += 1; return valid; } }, + new String(valid), + { [Symbol.toPrimitive]: () => { coercions += 1; return valid; } }, + new Proxy({}, { get: () => { coercions += 1; return () => valid; } }), + ]; + for (const value of values) expect(parseUnknown(value, "preToolUse")).toBeNull(); + expect(coercions).toBe(0); +}); + +test("all documented Cursor native response variants are byte-idempotent", () => { + for (const [event, stdout] of NATIVE_CASES) { + expect(toCursorLifecycleResponse(stdout, event), event).toBe(stdout); + } +}); + +test("permission variants preserve only each event's supported message fields", () => { + const cases = [ + ["preToolUse", ["allow", "deny", "ask"], { user_message: "u", agent_message: "a" }], + ["beforeShellExecution", ["allow", "deny", "ask"], { user_message: "u", agent_message: "a" }], + ["beforeMCPExecution", ["allow", "deny", "ask"], { user_message: "u", agent_message: "a" }], + ["subagentStart", ["allow", "deny"], { user_message: "u" }], + ["beforeReadFile", ["allow", "deny"], { user_message: "u" }], + ["beforeTabFileRead", ["allow", "deny"], {}], + ] as const; + for (const [event, permissions, messages] of cases) { + for (const permission of permissions) { + const stdout = JSON.stringify({ permission, ...messages }); + expect(toCursorLifecycleResponse(stdout, event), `${event}:${permission}`).toBe(stdout); + } + } +}); + +test("Claude envelopes cannot masquerade as native Cursor responses", () => { + const claude = '{"permission":"allow","hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"blocked"}}'; + const converted = toCursorLifecycleResponse(claude, "preToolUse"); + expect(converted).not.toBe(claude); + expect(JSON.parse(converted)).toEqual({ permission: "deny", user_message: "blocked", agent_message: "blocked" }); +}); + +test("neutral Cursor events preserve valid raw object bytes and normalize other output", () => { + const neutral = [ + "sessionEnd", "postToolUseFailure", "afterShellExecution", "afterMCPExecution", + "afterFileEdit", "afterTabFileEdit", "afterAgentResponse", "afterAgentThought", + ]; + for (const event of neutral) { + expect(toCursorLifecycleResponse(" { }\n", event), event).toBe(" { }\n"); + expect(toCursorLifecycleResponse('{"systemMessage":"claude"}', event), event).toBe("{}"); + } +}); + +test("native response module exposes no arbitrary-object recognition path", () => { + expect(Object.keys(nativeResponseModule)).toEqual(["parseNativeCursorStdout"]); +}); + +test("native schema rejects field names inherited by its validator map", () => { + for (const field of ["toString", "constructor"]) { + const stdout = JSON.stringify({ permission: "allow", [field]: "unexpected" }); + expect(parseNativeCursorStdout(stdout, "preToolUse")).toBeNull(); + } +}); + +test("subagentStart ask adapts to deny and retains only its supported user message", () => { + expect(toCursorLifecycleResponse( + '{"permission":"ask","user_message":"approval needed","agent_message":"unsupported"}', + "subagentStart", + )).toBe('{"permission":"deny","user_message":"approval needed"}'); +}); + +test("documented updated_input accepts deeply nested and dense valid JSON", () => { + for (const depth of [63, 64, 1000]) { + let nested: Record = { leaf: true }; + for (let index = 0; index < depth; index += 1) nested = { nested }; + const stdout = JSON.stringify({ permission: "allow", updated_input: nested }); + expect(toCursorLifecycleResponse(stdout, "preToolUse"), `depth:${depth}`).toBe(stdout); + } + const stdout = JSON.stringify({ permission: "allow", updated_input: { values: Array.from({ length: 10_000 }, (_, index) => index) } }); + expect(toCursorLifecycleResponse(stdout, "preToolUse")).toBe(stdout); +}); + +test("raw native parsing rejects malformed JSON and schema-invalid JSON values", () => { + for (const stdout of [ + "{", "null", "[]", '{"permission":"allow","updated_input":null}', + '{"permission":"allow","updated_input":{"value":NaN}}', + ]) { + expect(parseNativeCursorStdout(stdout, "preToolUse"), stdout).toBeNull(); + } +}); diff --git a/test/cursor-native-routing.test.ts b/test/cursor-native-routing.test.ts new file mode 100644 index 0000000..ed3a853 --- /dev/null +++ b/test/cursor-native-routing.test.ts @@ -0,0 +1,140 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { denyResponse } from "../src/adapters/claude"; +import { beforeShellExecution } from "../src/adapters/cursor"; +import { cursorEventContract } from "../src/adapters/cursor/events"; +import { extractCursorEvent } from "../src/adapters/cursor/normalize"; +import { toCursorLifecycleResponse } from "../src/adapters/cursor/respond"; +import { clearUserGuards, registerGuard } from "../src/policy/guards"; +import { asyncScopeStdout } from "../src/runtime/handle-scope-async"; +import { handleHook } from "../src/runtime/handle"; +import { lifecycleStdout } from "../src/runtime/lifecycle-bridge"; +import { respond } from "../src/runtime/respond"; + +test("Cursor lifecycle names map explicitly and unknown events stay neutral", () => { + expect(cursorEventContract("sessionStart")).toMatchObject({ + phase: "pre", lifecycle: "SessionStart", response: "session-context", + }); + expect(cursorEventContract("postToolUseFailure")).toMatchObject({ + phase: "post", lifecycle: "PostToolUseFailure", response: "neutral", + }); + expect(cursorEventContract("preToolUse").lifecycle).toBe("PreToolUse"); + expect(cursorEventContract("futureCursorEvent")).toMatchObject({ + phase: "post", lifecycle: null, response: "neutral", + }); +}); + +test("unknown Cursor events remain observation-only during normalization", () => { + expect(extractCursorEvent({ hook_event_name: "futureCursorEvent", command: "rm -rf /" })).toMatchObject({ + phase: "post", tool: "", eventName: "futureCursorEvent", lifecycleEvent: null, + responseKind: "neutral", blockable: false, + }); +}); + +test("missing or invalid event names never infer a dangerous lifecycle branch", () => { + const payloads: Record[] = [ + { edits: [{ old_string: "safe", new_string: "safe" }], file_path: "/workspace/app.ts", command: "rm -rf /" }, + { hook_event_name: null, command: "rm -rf /" }, + { hook_event_name: 42, command: "rm -rf /" }, + { hook_event_name: "futureCursorEvent", command: "rm -rf /" }, + ]; + for (const payload of payloads) { + expect(extractCursorEvent(payload)).toMatchObject({ phase: "post", tool: "", responseKind: "neutral", blockable: false }); + } +}); + +test("Cursor responses use each event's native serialized shape", () => { + const block = { kind: "block", title: "Protected file", reason: "read denied" } as const; + expect(respond("cursor", block, "beforeReadFile")).toBe( + '{"permission":"deny","user_message":"[BLOCKED] Protected file\\nread denied"}', + ); + expect(respond("cursor", block, "beforeShellExecution")).toBe( + '{"permission":"deny","user_message":"[BLOCKED] Protected file\\nread denied","agent_message":"[BLOCKED] Protected file\\nread denied"}', + ); + expect(respond("cursor", block, "beforeTabFileRead")).toBe('{"permission":"deny"}'); + const note = { kind: "inform", title: "Receipt", reason: "tool observed" } as const; + expect(respond("cursor", note, "postToolUse")).toBe( + '{"additional_context":"[NOTE] Receipt\\ntool observed"}', + ); + expect(respond("cursor", block, "futureCursorEvent")).toBe("{}"); +}); + +test("beforeReadFile enforces the shared policy chain with native deny output", async () => { + clearUserGuards(); + registerGuard(({ tool }) => tool === "Read" + ? { kind: "block", title: "Read policy", reason: "blocked by shared guard" } + : null); + try { + const outcome = await handleHook("cursor", { + hook_event_name: "beforeReadFile", conversation_id: "cursor-read-deny", file_path: "/workspace/private.txt", + }, { now: 1000, cwd: "/workspace" }); + expect(outcome).toEqual({ + stdout: '{"permission":"deny","user_message":"[BLOCKED] Read policy\\nblocked by shared guard"}', exit: 0, + }); + } finally { clearUserGuards(); } +}); + +test("beforeTabFileRead normalizes Read and enforces native allow and deny", async () => { + const payload = { + hook_event_name: "beforeTabFileRead", conversation_id: "cursor-tab-read", + file_path: "/workspace/tab.ts", content: "export {};", + }; + const normalized = extractCursorEvent(payload); + expect([normalized.phase, normalized.tool, normalized.filePath, normalized.content]) + .toEqual(["pre", "Read", "/workspace/tab.ts", "export {};"]); + expect(await handleHook("cursor", payload, { now: 1000, cwd: "/workspace" })) + .toEqual({ stdout: '{"permission":"allow"}', exit: 0 }); + clearUserGuards(); + registerGuard(({ tool }) => tool === "Read" + ? { kind: "block", title: "Tab read policy", reason: "blocked by shared guard" } + : null); + try { + expect(await handleHook("cursor", payload, { now: 1000, cwd: "/workspace" })).toEqual({ + stdout: '{"permission":"deny"}', exit: 0, + }); + } finally { clearUserGuards(); } +}); + +test("lower-camel Cursor lifecycle names reach the internal dispatcher", () => { + expect(lifecycleStdout( + { hook_event_name: "sessionEnd", conversation_id: "cursor-session" }, + "/workspace", "aipilot", 1000, "cursor", + )).toBe("{}"); +}); + +test("Cursor sessionStart converts lifecycle context to its native envelope", async () => { + const cwd = mkdtempSync(join(tmpdir(), "cursor-session-start-")); + writeFileSync(join(cwd, "package.json"), "{}"); + try { + const outcome = await handleHook("cursor", { + hook_event_name: "sessionStart", conversation_id: "cursor-session-start", workspace_roots: [cwd], cwd, + }, { now: 1000, cwd }); + const response = JSON.parse(outcome.stdout) as Record; + expect(typeof response.additional_context).toBe("string"); + expect(response).not.toHaveProperty("hookSpecificOutput"); + expect(response).not.toHaveProperty("systemMessage"); + } finally { rmSync(cwd, { recursive: true }); } +}); + +test("Cursor async scope canonicalizes lower-camel lifecycle names", async () => { + const outcome = await asyncScopeStdout( + "aipilot", "subagentStop", { agent_type: "explore" }, "/workspace", 1000, "cursor", + ); + expect(outcome).toBe("{}"); +}); + +test("Cursor async scope preserves a shared dispatcher deny natively", () => { + expect(toCursorLifecycleResponse( + denyResponse("PreToolUse", "cached documentation must be read locally"), "preToolUse", + )).toBe( + '{"permission":"deny","user_message":"cached documentation must be read locally","agent_message":"cached documentation must be read locally"}', + ); +}); + +test("Cursor public permission wrapper emits no unsupported continue field", () => { + expect(beforeShellExecution({ command: "rm -rf /" })).toEqual({ + permission: "deny", user_message: expect.any(String), agent_message: expect.any(String), + }); +}); diff --git a/test/cursor-response-channels.test.ts b/test/cursor-response-channels.test.ts new file mode 100644 index 0000000..61fbc58 --- /dev/null +++ b/test/cursor-response-channels.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from "bun:test"; +import { toCursorLifecycleResponse, toCursorResponse } from "../src/adapters/cursor/respond"; + +test("Cursor direct inform preserves distinct user and agent channels", () => { + expect(toCursorResponse({ + kind: "inform", + title: "Design review", + reason: "agent guidance", + userMessage: "human notice", + }, "preToolUse")).toBe( + '{"permission":"allow","user_message":"human notice","agent_message":"[NOTE] Design review\\nagent guidance"}', + ); +}); + +test("Cursor lifecycle allow preserves user-only, agent-only, and mixed channels", () => { + expect(toCursorLifecycleResponse('{"systemMessage":"human notice"}', "preToolUse")).toBe( + '{"permission":"allow","user_message":"human notice"}', + ); + expect(toCursorLifecycleResponse( + '{"hookSpecificOutput":{"additionalContext":"agent guidance"}}', + "preToolUse", + )).toBe('{"permission":"allow","agent_message":"agent guidance"}'); + expect(toCursorLifecycleResponse( + '{"systemMessage":"human notice","hookSpecificOutput":{"additionalContext":"agent guidance"}}', + "preToolUse", + )).toBe('{"permission":"allow","user_message":"human notice","agent_message":"agent guidance"}'); +}); diff --git a/test/cursor-runtime-native-boundary.test.ts b/test/cursor-runtime-native-boundary.test.ts new file mode 100644 index 0000000..8b02bb5 --- /dev/null +++ b/test/cursor-runtime-native-boundary.test.ts @@ -0,0 +1,79 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleHook } from "../src/runtime/handle"; + +const root = (prefix: string): string => mkdtempSync(join(tmpdir(), prefix)); + +test("Cursor security advisory crosses the runtime boundary as native agent context", async () => { + const cwd = root("cursor-security-"); + const out = await handleHook("cursor", { + hook_event_name: "preToolUse", + conversation_id: "cursor-security", + tool_name: "Write", + tool_input: { file_path: join(cwd, "app.ts"), content: "export {};" }, + }, { now: 1, cwd, scope: "security" }); + expect(out).toEqual({ + stdout: expect.stringContaining('"permission":"allow"'), + exit: 0, + }); + const parsed = JSON.parse(out.stdout) as Record; + expect(parsed.agent_message).toContain("SECURITY"); + expect(parsed).not.toHaveProperty("hookSpecificOutput"); +}); + +test("Cursor solid deny crosses the runtime boundary as native permission", async () => { + const cwd = root("cursor-solid-"); + const previous = process.env.SOLID_PROJECT_TYPE; + process.env.SOLID_PROJECT_TYPE = "go"; + try { + const out = await handleHook("cursor", { + hook_event_name: "preToolUse", + conversation_id: "cursor-solid", + tool_name: "Write", + tool_input: { file_path: join(cwd, "store.go"), content: "type Store interface {\n}\n" }, + }, { now: 1, cwd, scope: "solid" }); + const parsed = JSON.parse(out.stdout) as Record; + expect(parsed.permission).toBe("deny"); + expect(parsed.agent_message).toContain("internal/interfaces/"); + expect(parsed).not.toHaveProperty("hookSpecificOutput"); + } finally { + if (previous === undefined) delete process.env.SOLID_PROJECT_TYPE; + else process.env.SOLID_PROJECT_TYPE = previous; + } +}); + +test("Cursor scoped postToolUse crosses the runtime boundary as native post context", async () => { + const cwd = root("cursor-seo-"); + writeFileSync(join(cwd, ".fuse-seo"), ""); + const file = join(cwd, "page.html"); + writeFileSync(file, "hi"); + const out = await handleHook("cursor", { + hook_event_name: "postToolUse", + conversation_id: "cursor-seo", + tool_name: "Write", + tool_input: { file_path: file, content: "" }, + tool_output: "ok", + cwd, + }, { now: 1, cwd, scope: "seo" }); + const parsed = JSON.parse(out.stdout) as Record; + expect(parsed.additional_context).toContain("missing SEO elements"); + expect(parsed).not.toHaveProperty("decision"); + expect(parsed).not.toHaveProperty("hookSpecificOutput"); +}); + +test("non-Cursor scope responses retain their existing wire shapes", async () => { + const cwd = root("native-boundary-control-"); + const payload = { + hook_event_name: "PreToolUse", + session_id: "control-security", + tool_name: "Write", + tool_input: { file_path: join(cwd, "app.ts"), content: "export {};" }, + }; + const expected = '{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"SECURITY: Read security skill references before modifying code. Use: Read skills/security-scan/references/scan-patterns.md"}}'; + for (const id of ["claude-code", "codex", "kimi"] as const) { + const out = await handleHook(id, payload, { now: 1, cwd, scope: "security" }); + expect(out).toEqual({ stdout: expected, exit: 0 }); + } +}); diff --git a/test/cursor-stdin-depth.test.ts b/test/cursor-stdin-depth.test.ts new file mode 100644 index 0000000..df5afcb --- /dev/null +++ b/test/cursor-stdin-depth.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from "bun:test"; +import { closeSync, mkdtempSync, openSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { oversizeStdout, readCursorBounded } from "../src/cli/hook-io"; + +function oversizeResponse(payload: string): string { + const file = join(mkdtempSync(join(tmpdir(), "cursor-depth-")), "payload.json"); + writeFileSync(file, payload); + const fd = openSync(file, "r"); + try { + const read = readCursorBounded(fd, 1024); + expect(read.kind).toBe("oversize"); + return oversizeStdout("cursor", read.kind === "oversize" ? read.head : ""); + } finally { + closeSync(fd); + } +} + +function deepPayload(depth: number, event: string, malformed = false): string { + let value = "true"; + for (let index = 0; index < depth; index += 1) value = `{"value":${value}}`; + const payload = `{"nested":${value},"pad":"${"x".repeat(2000)}","hook_event_name":"${event}"}`; + return malformed ? payload.slice(0, -1) : payload; +} + +test("oversized Cursor classifies valid JSON through depth 1000 and fails closed beyond its bound", () => { + for (const depth of [256, 257, 1000]) { + expect(oversizeResponse(deepPayload(depth, "afterFileEdit")), `observe:${depth}`).toBe("{}"); + expect(oversizeResponse(deepPayload(depth, "beforeTabFileRead")), `block:${depth}`).toBe('{"permission":"deny"}'); + } + const malformed = JSON.parse(oversizeResponse(deepPayload(1000, "afterFileEdit", true))); + expect(malformed.permission).toBe("deny"); + const aboveBound = JSON.parse(oversizeResponse(deepPayload(1024, "afterFileEdit"))); + expect(aboveBound.permission).toBe("deny"); +}); diff --git a/test/cursor-stdin.test.ts b/test/cursor-stdin.test.ts new file mode 100644 index 0000000..8b117cc --- /dev/null +++ b/test/cursor-stdin.test.ts @@ -0,0 +1,187 @@ +import { expect, test } from "bun:test"; +import { closeSync, mkdtempSync, openSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { cursorReaderBounds, oversizeStdout, readCursorBounded, resolveCursorStdinMaxBytes } from "../src/cli/hook-io"; + +function readViaFile(text: string, cap: number): ReturnType { + const file = join(mkdtempSync(join(tmpdir(), "cursor-stdin-")), "payload.json"); + writeFileSync(file, text); + const fd = openSync(file, "r"); + try { return readCursorBounded(fd, cap); } finally { closeSync(fd); } +} + +function oversizeResponse(payload: string, cap: number = 1024): string { + const read = readViaFile(payload, cap); + expect(read.kind).toBe("oversize"); + return oversizeStdout("cursor", read.kind === "oversize" ? read.head : ""); +} + +function payloadWithKeyAt(offset: number): string { + const prefix = '{"content":"'; + const key = '"hook_event_name":"beforeTabFileRead"}'; + return `${prefix}${"x".repeat(offset - prefix.length - 2)}",${key}`; +} + +function primitivePayloadAt(value: string, offset: number, duplicate = false): string { + const prefix = duplicate ? `{"hook_event_name":null,"value":${value},"content":"` : `{"value":${value},"content":"`; + const key = '"hook_event_name":"beforeTabFileRead"}'; + return `${prefix}${"x".repeat(offset - prefix.length - 2)}",${key}`; +} + +test("oversized Cursor permission events fail closed with their native shape", () => { + const stdout = oversizeStdout("cursor", '{"hook_event_name":"beforeReadFile"}'); + expect(JSON.parse(stdout)).toEqual({ + permission: "deny", + user_message: expect.stringContaining("stdin payload exceeds"), + }); +}); + +test("oversized Cursor observation events return a neutral object", () => { + expect(oversizeStdout("cursor", '{"hook_event_name":"afterFileEdit"}')).toBe("{}"); +}); + +test("Cursor stdin cap clamps hostile environment overrides without changing the shared resolver", () => { + expect(resolveCursorStdinMaxBytes({ FUSE_HOOK_STDIN_MAX_BYTES: "1" })).toBe(1); + expect(resolveCursorStdinMaxBytes({ FUSE_HOOK_STDIN_MAX_BYTES: String(2 ** 30) })).toBe(64 * 1024 * 1024); + expect(resolveCursorStdinMaxBytes({ FUSE_HOOK_STDIN_MAX_BYTES: "4096" })).toBe(4096); +}); + +test("oversize Cursor diagnostics report the effective clamped reader cap", () => { + const previous = process.env.FUSE_HOOK_STDIN_MAX_BYTES; + process.env.FUSE_HOOK_STDIN_MAX_BYTES = String(2 ** 30); + try { + const stdout = oversizeStdout("cursor", '{"hook_event_name":"beforeReadFile"}'); + const message = JSON.parse(stdout).user_message as string; + expect(message).toContain("67108864 bytes"); + expect(message).not.toContain("1073741824 bytes"); + } finally { + if (previous === undefined) delete process.env.FUSE_HOOK_STDIN_MAX_BYTES; + else process.env.FUSE_HOOK_STDIN_MAX_BYTES = previous; + } +}); + +test("Cursor reader exposes independent Buffer allocation requests and scanner cardinalities", () => { + expect(cursorReaderBounds(1024)).toEqual({ + bufferAllocationRequestBytes: 1024 + 64 * 1024 + 4096 + 256, + scannerTokenEntries: 256, + scannerFrames: 1024, + }); +}); + +test("oversized Cursor detects a late top-level event independent of key order", () => { + const payload = JSON.stringify({ + content: "x".repeat(5000), + hook_event_name: "beforeTabFileRead", + file_path: "/workspace/app.ts", + }); + const read = readViaFile(payload, 1024); + expect(read.kind).toBe("oversize"); + expect(oversizeStdout("cursor", read.kind === "oversize" ? read.head : "")) + .toBe('{"permission":"deny"}'); +}); + +test("oversized Cursor keeps explicit unknown and malformed policies", () => { + expect(oversizeStdout("cursor", '{"hook_event_name":"futureCursorEvent"}')).toBe("{}"); + const malformed = readViaFile("x".repeat(5000), 1024); + const response = JSON.parse(oversizeStdout("cursor", malformed.kind === "oversize" ? malformed.head : "")); + expect(response.permission).toBe("deny"); +}); + +test("oversized Cursor ignores nested and string decoys before a late real key", () => { + const nested = `{"decoy":{"hook_event_name":"afterFileEdit"},"content":"${"x".repeat(70000)}","hook_event_name":"beforeTabFileRead"}`; + const string = `{"content":"nested text \\\"hook_event_name\\\":\\\"afterFileEdit\\\" ${"x".repeat(70000)}","hook_event_name":"beforeTabFileRead"}`; + expect(oversizeResponse(nested)).toBe('{"permission":"deny"}'); + expect(oversizeResponse(string)).toBe('{"permission":"deny"}'); +}); + +test("oversized Cursor uses the last top-level key and decodes escaped key and value", () => { + const duplicate = `{"hook_event_name":"afterFileEdit","content":"${"x".repeat(70000)}","hook_event_name":"beforeTabFileRead"}`; + const escapedKey = `{"content":"${"x".repeat(70000)}","hook_event_\\u006eame":"beforeTabFileRead"}`; + const escapedValue = `{"content":"${"x".repeat(70000)}","hook_event_name":"beforeTabFile\\u0052ead"}`; + expect(oversizeResponse(duplicate)).toBe('{"permission":"deny"}'); + expect(oversizeResponse(escapedKey)).toBe('{"permission":"deny"}'); + expect(oversizeResponse(escapedValue)).toBe('{"permission":"deny"}'); +}); + +test("oversized Cursor event scanning is invariant at chunk boundaries", () => { + for (const offset of [65535, 65536]) { + const payload = payloadWithKeyAt(offset); + expect(payload.indexOf('"hook_event_name"')).toBe(offset); + expect(oversizeResponse(payload)).toBe('{"permission":"deny"}'); + } +}); + +test("oversized Cursor resets object tokens around primitives at chunk boundaries", () => { + for (const value of ["false", "true", "-1.25e+3", "null"]) { + for (const offset of [65535, 65536]) { + const payload = primitivePayloadAt(value, offset); + expect(payload.indexOf('"hook_event_name"'), `${value}@${offset}`).toBe(offset); + expect(oversizeResponse(payload), `${value}@${offset}`).toBe('{"permission":"deny"}'); + } + } +}); + +test("oversized Cursor treats a null event followed by a valid duplicate as last-wins", () => { + for (const offset of [65535, 65536]) { + expect(oversizeResponse(primitivePayloadAt("0", offset, true))).toBe('{"permission":"deny"}'); + } +}); + +test("oversized Cursor streams arbitrarily long valid numbers before and after events", () => { + for (const length of [256, 257, 10000]) { + const number = "9".repeat(length); + const observation = `{"hook_event_name":"afterFileEdit","value":${number},"pad":"${"x".repeat(2000)}"}`; + const blockable = `{"value":${number},"pad":"${"x".repeat(70000)}","hook_event_name":"beforeTabFileRead"}`; + JSON.parse(observation); + JSON.parse(blockable); + expect(oversizeResponse(observation), `observation:${length}`).toBe("{}"); + expect(oversizeResponse(blockable), `blockable:${length}`).toBe('{"permission":"deny"}'); + } +}); + +test("oversized Cursor streams long numbers across chunk boundaries", () => { + for (const offset of [65535, 65536, 131071, 131072]) { + const prefix = `{"pad":"${"x".repeat(offset - 18)}","value":`; + const payload = `${prefix}${"7".repeat(10000)},"hook_event_name":"beforeTabFileRead"}`; + JSON.parse(payload); + expect(oversizeResponse(payload), String(offset)).toBe('{"permission":"deny"}'); + } +}); + +test("oversized Cursor rejects invalid JSON primitive grammar like JSON.parse", () => { + for (const value of ["01", "1.", "1e", "-", "truex", "nulll"]) { + const payload = `{"hook_event_name":"afterFileEdit","value":${value},"pad":"${"x".repeat(5000)}"}`; + expect(() => JSON.parse(payload), value).toThrow(); + const response = JSON.parse(oversizeResponse(payload)); + expect(response.permission, value).toBe("deny"); + } +}); + +test("oversized Cursor treats unterminated JSON as indeterminate fail-closed", () => { + const response = JSON.parse(oversizeResponse(`{"content":"${"x".repeat(70000)},"hook_event_name":"afterFileEdit"`)); + expect(response.permission).toBe("deny"); + expect(response).toHaveProperty("user_message"); + expect(response).toHaveProperty("agent_message"); +}); + +test("every documented Cursor event has an exact native oversize schema", () => { + const events: Record = { + sessionStart: [], sessionEnd: [], postToolUse: [], postToolUseFailure: [], subagentStop: [], + afterShellExecution: [], afterMCPExecution: [], afterFileEdit: [], afterTabFileEdit: [], + afterAgentResponse: [], afterAgentThought: [], stop: [], preCompact: [], workspaceOpen: [], + beforeSubmitPrompt: ["continue", "user_message"], + subagentStart: ["permission", "user_message"], + preToolUse: ["agent_message", "permission", "user_message"], + beforeShellExecution: ["agent_message", "permission", "user_message"], + beforeMCPExecution: ["agent_message", "permission", "user_message"], + beforeReadFile: ["permission", "user_message"], + beforeTabFileRead: ["permission"], + }; + for (const [event, keys] of Object.entries(events)) { + const response = JSON.parse(oversizeResponse(`{"content":"${"x".repeat(5000)}","hook_event_name":"${event}"}`)); + expect(Object.keys(response).sort(), event).toEqual([...keys].sort()); + if (keys.includes("permission")) expect(response.permission, event).toBe("deny"); + if (keys.includes("continue")) expect(response.continue, event).toBe(false); + } +}); diff --git a/test/cursor-subagent-contract.test.ts b/test/cursor-subagent-contract.test.ts new file mode 100644 index 0000000..c93198a --- /dev/null +++ b/test/cursor-subagent-contract.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleHook } from "../src/runtime/handle"; + +const root = (prefix: string): string => mkdtempSync(join(tmpdir(), prefix)); + +test("Cursor subagentStart never claims that unsupported rule context was injected", async () => { + const cwd = root("cursor-subagent-rules-"); + mkdirSync(join(cwd, "rules")); + const sentinel = "CURSOR_SUBAGENT_RULE_SENTINEL"; + writeFileSync(join(cwd, "rules", "00-rules.md"), sentinel); + const out = await handleHook("cursor", { + hook_event_name: "subagentStart", + conversation_id: "cursor-subagent", + subagent_id: "child-1", + subagent_type: "explore", + task: "inspect", + }, { now: 1, cwd, scope: "rules", home: root("cursor-subagent-home-") }); + expect(out).toEqual({ stdout: '{"permission":"allow"}', exit: 0 }); + expect(out.stdout).not.toContain(sentinel); + expect(out.stdout).not.toContain("injected"); +}); + +test("Cursor CLI throws as a host-visible process failure without raw hook stdout", () => { + const cwd = root("cursor-cli-throw-"); + const refs = join(cwd, "refs"); + mkdirSync(join(refs, "unreadable.md"), { recursive: true }); + const home = root("cursor-cli-throw-home-"); + const child = spawnSync("bun", [join(import.meta.dir, "..", "src", "cli", "bin.ts"), "hook", "cursor", "core"], { + cwd, + input: JSON.stringify({ + hook_event_name: "preToolUse", + conversation_id: "cursor-cli-throw", + tool_name: "Shell", + tool_input: { command: "ls" }, + }), + encoding: "utf8", + env: { ...process.env, HOME: home, FUSE_HARNESS_REFS: refs }, + }); + expect(child.status).not.toBe(0); + expect(child.stdout).toBe(""); + expect(child.stderr).not.toContain("hookSpecificOutput"); +}); + +test("Cursor CLI non-oversize JSON uses the last duplicate top-level event key", () => { + const cwd = root("cursor-cli-duplicate-"); + const input = `{"hook_event_name":"afterFileEdit","hook_event_name":"beforeTabFileRead","file_path":${JSON.stringify(join(cwd, "tab.ts"))},"content":"export {};"}`; + const child = spawnSync("bun", [join(import.meta.dir, "..", "src", "cli", "bin.ts"), "hook", "cursor", "core"], { + cwd, + input, + encoding: "utf8", + env: { ...process.env, HOME: root("cursor-cli-duplicate-home-") }, + }); + expect(child.status).toBe(0); + expect(child.stdout).toBe('{"permission":"allow"}'); +}); diff --git a/test/handle.test.ts b/test/handle.test.ts index 391d0b0..dc2f12f 100644 --- a/test/handle.test.ts +++ b/test/handle.test.ts @@ -18,6 +18,32 @@ test("normalizeEvent: claude pre/post + cline nesting", () => { expect(c.filePath).toBe("a.ts"); }); +test("normalizeEvent: documented Cursor shell hooks arm Bash guards", () => { + const before = normalizeEvent("cursor", { hook_event_name: "beforeShellExecution", command: "git push --force", cwd: "/project", sandbox: false }); + expect([before.phase, before.tool, before.command]).toEqual(["pre", "Bash", "git push --force"]); + const generic = normalizeEvent("cursor", { hook_event_name: "preToolUse", tool_name: "Shell", tool_input: { command: "npm install", working_directory: "/project" } }); + expect([generic.phase, generic.tool, generic.command]).toEqual(["pre", "Bash", "npm install"]); + const write = normalizeEvent("cursor", { hook_event_name: "preToolUse", tool_name: "Write", tool_input: {} }); + expect([write.phase, write.tool, write.input]).toEqual(["pre", "Edit", {}]); +}); + +test("normalizeEvent: documented Cursor afterFileEdit derives Edit and preserves every edit", () => { + const event = normalizeEvent("cursor", { + hook_event_name: "afterFileEdit", + file_path: "/project/src/app.ts", + edits: [ + { old_string: "const a = 1", new_string: "const a = 2" }, + { old_string: "const b = 1", new_string: "const b = 2" }, + ], + }); + expect([event.phase, event.tool, event.filePath]).toEqual(["post", "Edit", "/project/src/app.ts"]); + expect(event.content).toBe("const a = 2\nconst b = 2"); + expect(event.files?.map(({ filePath, oldString, content, op }) => [filePath, oldString, content, op])).toEqual([ + ["/project/src/app.ts", "const a = 1", "const a = 2", "update"], + ["/project/src/app.ts", "const b = 1", "const b = 2", "update"], + ]); +}); + test("respond: native block shape per harness", () => { const p = { kind: "block", title: "t", reason: "r" } as const; expect(JSON.parse(respond("claude-code", p)).hookSpecificOutput.permissionDecision).toBe("deny"); @@ -26,6 +52,11 @@ test("respond: native block shape per harness", () => { expect(JSON.parse(respond("cursor", p)).permission).toBe("deny"); }); +test("respond: Cursor degrades policy ask to native deny", () => { + const ask = { kind: "ask", title: "Dependency install", reason: "confirm" } as const; + expect(JSON.parse(respond("cursor", ask)).permission).toBe("deny"); +}); + test("respond: hookEventName defaults to PreToolUse, a POST-phase caller stamps the real event (PostToolUse→PreToolUse fix)", () => { const block = { kind: "block", title: "t", reason: "r" } as const; expect(JSON.parse(respond("claude-code", block)).hookSpecificOutput.hookEventName).toBe("PreToolUse"); diff --git a/test/init.test.ts b/test/init.test.ts index 8446e36..bb35f9e 100644 --- a/test/init.test.ts +++ b/test/init.test.ts @@ -9,7 +9,26 @@ test("templates: pre + post wiring per harness", () => { const claude = JSON.parse(claudeInit("c")[0]!.content) as { hooks: { PreToolUse: { matcher: string }[]; PostToolUse: unknown[] } }; expect(claude.hooks.PreToolUse[0]?.matcher).toBe("Write|Edit|Bash"); expect(claude.hooks.PostToolUse.length).toBe(1); - expect((JSON.parse(cursorInit("x")[0]!.content) as { version: number }).version).toBe(1); + const cursor = JSON.parse(cursorInit("x")[0]!.content) as { + version: number; + hooks: Record; + }; + expect(cursor.version).toBe(1); + expect(Object.keys(cursor.hooks)).toEqual([ + "beforeShellExecution", + "preToolUse", + "beforeMCPExecution", + "beforeReadFile", + "afterShellExecution", + "postToolUse", + "afterFileEdit", + ]); + for (const event of ["beforeShellExecution", "preToolUse", "beforeMCPExecution", "beforeReadFile"]) { + expect(cursor.hooks[event]).toEqual([{ command: "x", failClosed: true }]); + } + for (const event of ["afterShellExecution", "postToolUse", "afterFileEdit"]) { + expect(cursor.hooks[event]).toEqual([{ command: "x" }]); + } const gemini = JSON.parse(geminiInit("x")[0]!.content) as { hooks: { AfterTool: unknown[] } }; expect(gemini.hooks.AfterTool.length).toBe(1); const cline = clineInit("npx harness hook cline"); diff --git a/test/shell-read-refs.test.ts b/test/shell-read-refs.test.ts index fc52956..1dee569 100644 --- a/test/shell-read-refs.test.ts +++ b/test/shell-read-refs.test.ts @@ -59,3 +59,7 @@ test("absent/unparseable command yields nothing", () => { test("a flag-like token ending in .md is not credited", () => { expect(shellReadRefPaths("cat --foo.md")).toEqual([]); }); + +test("greater-than inside a quoted rg pattern does not hide the read target", () => { + expect(shellReadRefPaths('rg "x > y" docs/reference.md')).toEqual(["docs/reference.md"]); +}); diff --git a/test/sim/scenarios/21-cursor-shell-destructive-deny.json b/test/sim/scenarios/21-cursor-shell-destructive-deny.json index cad5bd2..60d183e 100644 --- a/test/sim/scenarios/21-cursor-shell-destructive-deny.json +++ b/test/sim/scenarios/21-cursor-shell-destructive-deny.json @@ -1,41 +1,55 @@ { "name": "cursor-shell-destructive-deny", "harness": "cursor", - "doc": "Cursor `beforeShellExecution` can block a shell command (cursor.com/docs/hooks). The harness routes through the shared evaluate() chain and respond.ts:55-63 renders the cursor envelope: `{permission:\"deny\"|\"ask\", continue:false, user_message, agent_message}` — snake_case required (#141516/#142589, camelCase silently ignored). A destructive git force-push and a heredoc-into-code-file both deny; a plain read allows (empty stdout). Real output verified 2026-07-06 against `bun src/cli/bin.ts hook cursor core`. NOTE: cursor `afterFileEdit` is observe-only (cannot block) — file-edit enforcement on Cursor is best-effort, unlike the shell path proven here.", + "doc": "Documented Cursor beforeShellExecution and preToolUse payloads reach the shared runtime gate. Git force-push, dependency install, and a heredoc write to code all deny; a benign read returns native permission allow.", "env": { "FUSE_ENFORCE_TTL_SEC": "3600" }, "steps": [ { "scope": "core", - "doc": "`git push --force` → GIT_BLOCKED deny → `permission:\"deny\"` (respond.ts:63). Asserts the permission field, the load-bearing shell-block signal Cursor honors.", + "doc": "Documented beforeShellExecution top-level command → GIT_BLOCKED → native permission deny.", "event": { "hook_event_name": "beforeShellExecution", - "session_id": "sc21", - "tool_name": "Bash", - "tool_input": { "command": "git push origin main --force" } + "session_id": "sc21-force", + "command": "git push origin main --force", + "cwd": "$TMP", + "sandbox": false }, "expect": { "exit": 0, "stdout": { "jsonPath": "permission", "equals": "deny" } } }, { "scope": "core", - "doc": "Heredoc redirected into a .ts file → bashWriteGuard deny, same `permission:\"deny\"` envelope. Proves the runGuards chain (not just the git gate) reaches the cursor shell path. Assert the [BLOCKED] tag in user_message.", + "doc": "Documented preToolUse Shell payload → dependency-install ask degraded to native permission deny.", + "event": { + "hook_event_name": "preToolUse", + "session_id": "sc21-install", + "tool_name": "Shell", + "tool_input": { "command": "npm install", "working_directory": "$TMP" } + }, + "expect": { "exit": 0, "stdout": { "jsonPath": "permission", "equals": "deny" } } + }, + { + "scope": "core", + "doc": "Documented beforeShellExecution heredoc write → shared bashWriteGuard deny.", "event": { "hook_event_name": "beforeShellExecution", - "session_id": "sc21", - "tool_name": "Bash", - "tool_input": { "command": "cat < $TMP/x.ts\nexport const x = 1;\nEOF" } + "session_id": "sc21-heredoc", + "command": "cat < $TMP/x.ts\nexport const x = 1;\nEOF", + "cwd": "$TMP", + "sandbox": false }, "expect": { "exit": 0, "stdout": { "contains": "[BLOCKED] Bash write to code file" } } }, { "scope": "core", - "doc": "A plain read (`ls`) → allowed → empty stdout (verified). Parity: a benign shell command is silent on cursor, exactly like every other harness.", + "doc": "A documented benign beforeShellExecution payload returns native permission allow.", "event": { "hook_event_name": "beforeShellExecution", - "session_id": "sc21", - "tool_name": "Bash", - "tool_input": { "command": "ls -la $TMP" } + "session_id": "sc21-read", + "command": "ls -la $TMP", + "cwd": "$TMP", + "sandbox": true }, - "expect": { "exit": 0, "stdout": { "empty": true } } + "expect": { "exit": 0, "stdout": { "jsonPath": "permission", "equals": "allow" } } } ] } diff --git a/test/stdin-cap.test.ts b/test/stdin-cap.test.ts index fd9000f..9e963fa 100644 --- a/test/stdin-cap.test.ts +++ b/test/stdin-cap.test.ts @@ -1,5 +1,5 @@ import { test, expect } from "bun:test"; -import { openSync, closeSync, mkdtempSync, writeFileSync } from "node:fs"; +import { openSync, closeSync, mkdtempSync, readSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { readBounded, oversizeStdout } from "../src/cli/hook-io"; @@ -63,3 +63,13 @@ test("override: FUSE_HOOK_STDIN_MAX_BYTES pins the cap; default is 16 MiB", () = expect(resolveStdinMaxBytes({ FUSE_HOOK_STDIN_MAX_BYTES: "2048" })).toBe(2048); expect(resolveStdinMaxBytes({ FUSE_HOOK_STDIN_MAX_BYTES: "nope" })).toBe(16 * 1024 * 1024); }); + +test("non-Cursor bounded reads retain the historical immediate overflow return", () => { + const file = join(mkdtempSync(join(tmpdir(), "stdin-cap-immediate-")), "payload.json"); + writeFileSync(file, "x".repeat(3 * 64 * 1024)); + const fd = openSync(file, "r"); + try { + expect(readBounded(fd, 128).kind).toBe("oversize"); + expect(readSync(fd, Buffer.alloc(1), 0, 1, null)).toBe(1); + } finally { closeSync(fd); } +}); diff --git a/tools/api-docs/bun.lock b/tools/api-docs/bun.lock new file mode 100644 index 0000000..3eddc96 --- /dev/null +++ b/tools/api-docs/bun.lock @@ -0,0 +1,58 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "@fusengine/api-docs-toolchain", + "devDependencies": { + "typedoc": "0.28.20", + "typescript": "6.0.3", + }, + }, + }, + "packages": { + "@gerrit0/mini-shiki": ["@gerrit0/mini-shiki@3.23.0", "", { "dependencies": { "@shikijs/engine-oniguruma": "^3.23.0", "@shikijs/langs": "^3.23.0", "@shikijs/themes": "^3.23.0", "@shikijs/types": "^3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg=="], + + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="], + + "@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="], + + "@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="], + + "@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], + + "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + + "@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], + + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], + + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + + "linkify-it": ["linkify-it@5.0.2", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q=="], + + "lunr": ["lunr@2.3.9", "", {}, "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow=="], + + "markdown-it": ["markdown-it@14.3.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.5.0", "linkify-it": "^5.0.2", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-4Ej49aYTDFIQ+uBkfX8GBvJGccoARxxPep+7aWTs55ozbjQJpW9M26Fe53vnGgvLeVzva/amzjQQaQu9w0vMhA=="], + + "mdurl": ["mdurl@2.1.0", "", {}, "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg=="], + + "minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], + + "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], + + "typedoc": ["typedoc@0.28.20", "", { "dependencies": { "@gerrit0/mini-shiki": "^3.23.0", "lunr": "^2.3.9", "markdown-it": "^14.3.0", "minimatch": "^10.2.5", "yaml": "^2.9.0" }, "peerDependencies": { "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x" }, "bin": { "typedoc": "bin/typedoc" } }, "sha512-uSKqkh8Cr48vllnEy+jdaAgOeR6Y+QCBW7usgUsKj7gJEfR7stw9U/fE49LBnj2tPRKPY0c0EBJSWe9Appmplg=="], + + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + + "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + } +} diff --git a/tools/api-docs/package.json b/tools/api-docs/package.json new file mode 100644 index 0000000..0e47e50 --- /dev/null +++ b/tools/api-docs/package.json @@ -0,0 +1,12 @@ +{ + "name": "@fusengine/api-docs-toolchain", + "private": true, + "type": "module", + "scripts": { + "generate": "typedoc --options ../../typedoc.json --tsconfig ../../tsconfig.json" + }, + "devDependencies": { + "typedoc": "0.28.20", + "typescript": "6.0.3" + } +} diff --git a/typedoc.json b/typedoc.json index 6abb088..8dfe75c 100644 --- a/typedoc.json +++ b/typedoc.json @@ -5,6 +5,7 @@ "exclude": ["**/*.test.ts", "**/cli/bin.ts"], "out": "docs/api", "readme": "none", + "disableSources": true, "skipErrorChecking": true, "excludeInternal": true, "githubPages": false,