diff --git a/cli/package.json b/cli/package.json index 80c037a..21d1d78 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openagentlock/cli", - "version": "0.1.14", + "version": "0.1.15", "type": "module", "license": "SEE LICENSE IN LICENSE", "description": "OpenAgentLock CLI — a firewall for AI coding agents. Detects local agent harnesses (Claude Code, Codex CLI, Cursor, OpenCode, Cline, Gemini CLI, Continue, Copilot), gates risky tool calls via a Go control plane, anchors decisions in a Rust Merkle ledger.", @@ -43,7 +43,8 @@ "detect": "bun run src/index.ts detect", "install-into": "bun run src/index.ts install", "typecheck": "tsc --noEmit", - "build": "bun build src/index.ts --target=bun --outdir=dist" + "build": "bun build src/index.ts --target=bun --outdir=dist", + "prepublishOnly": "test -x ./agentlock && npm pack --dry-run --json | node -e \"const m=JSON.parse(require('fs').readFileSync(0,'utf8'));if(!m[0].files.some(f=>f.path==='agentlock'))throw new Error('agentlock wrapper missing from tarball — see 0.1.12 regression');\"" }, "dependencies": { "@noble/ed25519": "^3.1.0", diff --git a/cli/src/commands/hook-claude-code.ts b/cli/src/commands/hook-claude-code.ts index 0a6e2d4..7f6ac31 100644 --- a/cli/src/commands/hook-claude-code.ts +++ b/cli/src/commands/hook-claude-code.ts @@ -6,9 +6,12 @@ // as `type: "http"` directly at the daemon, every hook fire became a // browser-style fetch from inside Claude. A daemon outage rendered as a // red "PreToolUse:Bash hook error / ECONNREFUSED" banner on every tool -// call. Routing through this shim lets us fail-open on transport errors -// (exit 0) and emit a one-time friendly nudge instead — matching the UX -// codex and cursor already get. +// call. Routing through this shim lets us fail-open silently on transport +// errors (exit 0). The user-visible "daemon is down" signal is provided +// out-of-band by Claude Code's `statusLine` config, which the installer +// wires to /bin/agentlock-status — that surface lives +// outside the model's input stream so it doesn't read as a prompt- +// injection attempt the way `additionalContext` did in earlier designs. // // Wire shape (Claude Code → daemon → Claude Code): // stdin: Claude's native event JSON (session_id, hook_event_name, @@ -19,9 +22,8 @@ // hook was HTTP-typed // stdout: on deny, the JSON envelope (Claude Code accepts either an // exit code OR JSON, we emit both for safety) -// exit: 0 on allow / observability, 2 on deny +// exit: 0 on allow / observability / fail-open, 2 on deny -import { clearDaemonDownMarker, warnDaemonDownOnce } from "../util/daemon-warn.ts"; const ALLOWED_EVENTS = new Set([ "session-start", @@ -73,8 +75,6 @@ export async function runHookClaudeCode(argv: string[]): Promise { process.exit(0); } - // Validate JSON locally so a malformed body doesn't waste a daemon - // round trip. Pass through verbatim if it parses. try { JSON.parse(raw); } catch (e) { @@ -94,14 +94,13 @@ export async function runHookClaudeCode(argv: string[]): Promise { headers: { "Content-Type": "application/json" }, body: raw, }); - } catch (e) { - warnDaemonDownOnce("claude-code", url, e as Error); - process.exit(0); // fail-open + } catch { + // Daemon unreachable. Silent fail-open — never write to stdout + // (Claude would read it as model-input text). The statusLine UI + // element is the user-visible "daemon offline" signal. + process.exit(0); } - // Successful round-trip — re-arm the nudge for the next outage. - clearDaemonDownMarker(); - if (!res.ok) { process.stderr.write( `agentlock hook claude-code ${event}: daemon returned ${res.status}\n`, diff --git a/cli/src/commands/hook-codex.ts b/cli/src/commands/hook-codex.ts index 96efd48..1560aeb 100644 --- a/cli/src/commands/hook-codex.ts +++ b/cli/src/commands/hook-codex.ts @@ -15,8 +15,17 @@ // daemon outage from soft-bricking the user's coding session. The // daemon-side ledger is the source of truth; if it can't be reached, // monitor mode is the safer default than blocking everything. - -import { clearDaemonDownMarker, warnDaemonDownOnce } from "../util/daemon-warn.ts"; +// +// Codex has no `statusLine` analog (Claude's persistent UI element under +// the chat) and no chat-injection field that's safe from prompt-injection +// flags. Empirically Codex also hides hook stderr on exit-0 — it only +// surfaces hook output as a "(failed)" banner when the hook exits non- +// zero. Which means there's no in-Codex surface we can write a live or +// once-per-session OAL status indicator into without making it look like +// an error. We stay silent here; users wanting a live indicator wire the +// `agentlock-status` script into their shell prompt (visible when they +// start/stop a Codex session) or rely on Claude Code's statusLine in +// parallel chats. const ALLOWED_EVENTS = new Set([ "session-start", @@ -68,8 +77,6 @@ export async function runHookCodex(argv: string[]): Promise { process.exit(0); } - // Validate JSON locally so a malformed body doesn't waste a daemon - // round trip. Pass through verbatim if it parses. try { JSON.parse(raw); } catch (e) { @@ -88,14 +95,14 @@ export async function runHookCodex(argv: string[]): Promise { headers: { "Content-Type": "application/json" }, body: raw, }); - } catch (e) { - warnDaemonDownOnce("codex", url, e as Error); - process.exit(0); // fail-open + } catch { + // Daemon unreachable — silent fail-open. No stdout (would land in + // model input). No stderr (Codex would render exit-0 stderr as a + // failed-hook banner if it surfaced it at all, and on success it's + // hidden — so the line would either be alarming or invisible). + process.exit(0); } - // Successful round-trip — re-arm the nudge for the next outage. - clearDaemonDownMarker(); - if (!res.ok) { process.stderr.write( `agentlock hook codex ${event}: daemon returned ${res.status}\n`, diff --git a/cli/src/commands/hook-cursor.ts b/cli/src/commands/hook-cursor.ts index f19955a..152b392 100644 --- a/cli/src/commands/hook-cursor.ts +++ b/cli/src/commands/hook-cursor.ts @@ -16,13 +16,16 @@ // a fixed reason pointing at the dashboard. // // Failure modes are fail-open (exit 0). The daemon's ledger is the source -// of truth; a missing daemon should not soft-brick the user's IDE. +// of truth; a missing daemon should not soft-brick the user's IDE. On a +// transport failure we keep stdout to a plain `{permission: "allow"}` +// envelope — Cursor's hook spec offers no UI surface that's outside the +// model's input stream (no statusLine equivalent), so users wanting a +// live "daemon offline" indicator should rely on Claude Code's statusLine +// or a shell-prompt integration of /bin/agentlock-status. // Operators who want fail-closed semantics can install with // `failClosed: true` in the wired hook entries — Cursor will then treat // any exit-code error as a deny regardless of what we write to stdout. -import { clearDaemonDownMarker, warnDaemonDownOnce } from "../util/daemon-warn.ts"; - const ALLOWED_EVENTS = new Set([ "session-start", "pre-tool-use", @@ -89,8 +92,6 @@ export async function runHookCursor(argv: string[]): Promise { process.exit(0); } - // Validate JSON locally so a malformed body doesn't waste a daemon - // round trip. Pass through verbatim if it parses. try { JSON.parse(raw); } catch (e) { @@ -110,14 +111,12 @@ export async function runHookCursor(argv: string[]): Promise { headers: { "Content-Type": "application/json" }, body: raw, }); - } catch (e) { - warnDaemonDownOnce("cursor", url, e as Error); - process.exit(0); // fail-open + } catch { + // Daemon unreachable — silent fail-open. Cursor has no UI surface + // outside the model's input stream we can write to safely. + emitAllow(); } - // Successful round-trip — re-arm the nudge for the next outage. - clearDaemonDownMarker(); - if (!res.ok) { process.stderr.write( `agentlock hook cursor ${event}: daemon returned ${res.status}\n`, diff --git a/cli/src/commands/install.ts b/cli/src/commands/install.ts index fd78f90..05bcfdd 100644 --- a/cli/src/commands/install.ts +++ b/cli/src/commands/install.ts @@ -25,7 +25,7 @@ // For multi-harness dev runs prefer AGENTLOCK_DEV_HOME=./dev which // re-roots every detector AND the daemon's apply paths. -import { existsSync } from "node:fs"; +import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { detectAll } from "../detect/index.ts"; @@ -38,18 +38,58 @@ import { executeUninstallOps, readExistingFiles, } from "../util/install-fs.ts"; -import { home } from "../util/paths.ts"; +import { binDir, home, isWin } from "../util/paths.ts"; import { mintAttestedSession, type AttestedTier } from "../util/session-mint.ts"; -// Source-tree default: cli/agentlock is a bash wrapper that does -// `exec bun run cli/src/index.ts "$@"`, so harnesses can spawn -// `agentlock hook codex ` without needing a compiled binary. -// `process.execPath` alone points at `bun`, which crashes (exit 1) when -// invoked as `bun hook codex pre-tool-use` because `hook` isn't a script. -function defaultAgentlockBinary(): string { - const wrapper = resolve(import.meta.dir, "..", "..", "agentlock"); - if (existsSync(wrapper)) return wrapper; - return process.execPath; +// Stable wrapper path: /bin/agentlock. Lives in our state dir, +// not in the package manager's volatile node_modules tree, so package +// upgrades / reinstalls don't strand the wired hook command at a path the +// shell can't spawn (which renders as red "PreToolUse hook error" banners +// in Claude Code, and similar in Cursor / Codex). Re-running `agentlock +// install` rewrites the wrapper, picking up any new index.ts location. +// +// The wrapper itself is bash-only — Windows wiring lands separately. +export function installAndResolveAgentlockBinary(): string { + if (isWin()) { + throw new Error( + "agentlock install: Windows wrapper not yet supported. Use macOS/Linux for now.", + ); + } + const indexPath = resolve(import.meta.dir, "..", "index.ts"); + const dir = binDir(); + const wrapper = join(dir, "agentlock"); + const body = `#!/usr/bin/env bash\nexec bun run "${indexPath}" "$@"\n`; + mkdirSync(dir, { recursive: true }); + writeFileSync(wrapper, body, { flag: "w" }); + chmodSync(wrapper, 0o755); + return wrapper; +} + +// Tiny health-check script wired into Claude Code's `statusLine` config. +// Output renders as a UI element under the chat — never injected into the +// model's input stream — so the user sees live "is the daemon up?" without +// a prompt-injection vector. Curl with a 200ms timeout keeps the status +// line snappy; a hung daemon fails to "offline" instead of stalling the UI. +export function installStatusLineScript(): string { + if (isWin()) { + throw new Error( + "agentlock install: Windows status-line not yet supported. Use macOS/Linux for now.", + ); + } + const dir = binDir(); + const script = join(dir, "agentlock-status"); + const body = `#!/usr/bin/env bash +url="\${AGENTLOCK_DAEMON_URL:-http://127.0.0.1:7878}" +if curl --max-time 1 -fs "$url/v1/health" >/dev/null 2>&1; then + printf 'OpenAgentLock \\xe2\\x9c\\x93' +else + printf 'OpenAgentLock \\xe2\\x9a\\xa0 daemon offline' +fi +`; + mkdirSync(dir, { recursive: true }); + writeFileSync(script, body, { flag: "w" }); + chmodSync(script, 0o755); + return script; } type InstallTier = "unattested" | AttestedTier; @@ -352,16 +392,22 @@ export async function runInstall(argv: string[] = []): Promise { cursorHooks, ]); + // Write the status-line script alongside the binary wrapper. Daemon + // wires this path into ~/.claude/settings.json `statusLine` so users + // see live OAL health without any chat injection. + const statusLineScript = installStatusLineScript(); + const planReq = { session_id: sessionId, harnesses: chosen, daemon_url: daemonUrl, config_dir_override: flags.configDirOverride, // Pass an absolute path so Codex's command-hook spawn doesn't depend - // on PATH at hook-fire time. Source-tree dev runs use the - // `cli/agentlock` wrapper; AGENTLOCK_BINARY lets release builds - // override (e.g. point at the compiled single-file binary). - agentlock_binary: process.env.AGENTLOCK_BINARY ?? defaultAgentlockBinary(), + // on PATH at hook-fire time. The wrapper lives under agentlockHome() + // so it survives package-manager upgrades; AGENTLOCK_BINARY lets + // release builds override (e.g. point at a compiled single-file binary). + agentlock_binary: process.env.AGENTLOCK_BINARY ?? installAndResolveAgentlockBinary(), + status_line_script: statusLineScript, harness_config_dirs: hostConfigDirs, existing_files: existingFiles, }; diff --git a/cli/src/util/daemon-warn.ts b/cli/src/util/daemon-warn.ts deleted file mode 100644 index 03f3236..0000000 --- a/cli/src/util/daemon-warn.ts +++ /dev/null @@ -1,60 +0,0 @@ -// One-time "daemon is down" nudge for hook shims (codex / cursor / claude-code). -// -// The shims fail-open on ECONNREFUSED so the user's session keeps working, -// but silent fail-open means a user with the daemon stopped has no idea -// they're running unprotected. This helper writes one friendly stderr line -// the first time a session can't reach the daemon, then stays silent — -// gated by a marker file under agentlockHome(). The marker is cleared the -// next time any shim talks to the daemon successfully, so the nudge fires -// again on the next outage. -// -// stderr is the right surface here: every harness we wire (Codex, Cursor, -// Claude Code) renders shim stderr in its own log/UI. The shims keep -// process.exit(0) regardless — this helper is for the message only. -// -// We intentionally don't try to be clever about *which* harness suppresses -// the marker. One marker, one message per outage, across all harnesses. -// If a user runs codex and cursor concurrently, they get the nudge once. -// That matches user mental model: "OAL is down" is global, not per-tool. -// -// Failures inside the helper itself (e.g. mkdir -p denied) are swallowed. -// We never want the nudge path to add a new failure mode on top of the -// daemon outage we're already trying to soften. - -import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { agentlockHome } from "./paths.ts"; - -function markerPath(): string { - return join(agentlockHome(), "daemon-down-warned"); -} - -export function warnDaemonDownOnce(harness: string, url: string, err: Error): void { - const path = markerPath(); - if (existsSync(path)) return; - try { - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, new Date().toISOString() + "\n", { flag: "w" }); - } catch { - // Best-effort: if we can't write the marker, we'd nudge every - // invocation. Better than crashing the shim path. - } - process.stderr.write( - `OpenAgentLock daemon isn't running — ${harness} is running unprotected.\n` + - ` Tried: ${url} (${err.message})\n` + - ` Start it with \`docker compose up -d\` or see https://openagentlock.github.io/OpenAgentLock/guide/installation/.\n` + - ` (This message shows once per outage.)\n`, - ); -} - -// Called from the shim when a daemon round-trip succeeds. Cheap no-op when -// the marker isn't there. Re-arms the nudge for the next outage. -export function clearDaemonDownMarker(): void { - const path = markerPath(); - if (!existsSync(path)) return; - try { - unlinkSync(path); - } catch { - // Same posture as warn: best-effort. - } -} diff --git a/cli/src/util/paths.ts b/cli/src/util/paths.ts index 83d3bea..3d3247e 100644 --- a/cli/src/util/paths.ts +++ b/cli/src/util/paths.ts @@ -44,6 +44,11 @@ export function agentlockHome(): string { return process.env.AGENTLOCK_HOME ?? join(appSupport(), "OpenAgentLock"); } +/** Stable wrapper-script home; survives package upgrades that move node_modules. */ +export function binDir(): string { + return join(agentlockHome(), "bin"); +} + /** VS Code user dir (extension globalStorage lives under this). */ export function vscodeUserDir(): string | null { if (isMac()) return join(home(), "Library", "Application Support", "Code", "User"); diff --git a/cli/tests/hook-claude-code.test.ts b/cli/tests/hook-claude-code.test.ts index 7cfc3f2..396297c 100644 --- a/cli/tests/hook-claude-code.test.ts +++ b/cli/tests/hook-claude-code.test.ts @@ -1,9 +1,11 @@ // E2E test for the `agentlock hook claude-code ` shim. Spawns a // mock daemon over Bun.serve, runs the shim binary with stdin piped JSON, // and asserts the shim exits 0 (allow) or 2 (deny) and forwards the -// payload verbatim to /v1/hooks/claude-code/. Mirrors hook-codex's -// shape — both shims share daemon-warn helper, deny path, fail-open -// posture; this test is what catches regressions if those drift. +// payload verbatim to /v1/hooks/claude-code/. The daemon-down +// path must be silent on stdout AND stderr — any text would either land +// in Claude's input stream or trigger a red harness banner. The visible +// "daemon offline" signal is owned out-of-band by the statusLine config +// the installer writes (see installStatusLineScript in commands/install). import { afterEach, describe, expect, test } from "bun:test"; import { spawn } from "node:child_process"; @@ -160,26 +162,23 @@ describe("hook claude-code shim", () => { expect(r.stdout).toContain('"permissionDecision":"deny"'); }); - test("daemon unreachable → fail-open exit 0 with one-shot nudge", async () => { - const payload = JSON.stringify({ - session_id: "sess_z", - hook_event_name: "PreToolUse", - tool_name: "Bash", - tool_use_id: "t_03", - tool_input: { command: "ls" }, - }); + test("daemon unreachable → silent fail-open on every event", async () => { + // Every event must produce empty stdout AND empty stderr with exit 0. + // Anything else either pollutes the model's input stream or triggers + // a red 'hook error' banner in Claude Code's UI. const home = mkdtempSync(join(tmpdir(), "agentlock-test-")); - const r = await runShim(["pre-tool-use"], payload, "http://127.0.0.1:1", { - AGENTLOCK_HOME: home, - }); - expect(r.code).toBe(0); - expect(r.stderr).toContain("OpenAgentLock daemon isn't running"); - // Second run reuses the marker → stays silent. - const r2 = await runShim(["pre-tool-use"], payload, "http://127.0.0.1:1", { - AGENTLOCK_HOME: home, - }); - expect(r2.code).toBe(0); - expect(r2.stderr).toBe(""); + for (const event of ["session-start", "pre-tool-use", "post-tool-use", "stop"]) { + const payload = JSON.stringify({ + session_id: "sess_z", + hook_event_name: event, + }); + const r = await runShim([event], payload, "http://127.0.0.1:1", { + AGENTLOCK_HOME: home, + }); + expect(r.code).toBe(0); + expect(r.stdout).toBe(""); + expect(r.stderr).toBe(""); + } }); test("unknown event → exit 2 with usage", async () => { diff --git a/cli/tests/hook-codex.test.ts b/cli/tests/hook-codex.test.ts index d57b9ca..3390174 100644 --- a/cli/tests/hook-codex.test.ts +++ b/cli/tests/hook-codex.test.ts @@ -5,8 +5,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { spawn } from "node:child_process"; -import { mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; import { join } from "node:path"; interface MockExpect { @@ -159,30 +157,21 @@ describe("hook codex shim", () => { expect(r.stdout).toContain('"permissionDecision":"deny"'); }); - test("daemon unreachable → fail-open exit 0 with one-shot nudge", async () => { - const payload = JSON.stringify({ - session_id: "sess_z", - hook_event_name: "PreToolUse", - tool_name: "Bash", - tool_use_id: "t_03", - tool_input: { command: "ls" }, - }); - // Fresh AGENTLOCK_HOME per test so the nudge marker doesn't leak - // between runs (and we get a clean assertion on the first-run text). - const home = mkdtempSync(join(tmpdir(), "agentlock-test-")); - // Use a port we know nothing's listening on. - const r = await runShim(["pre-tool-use"], payload, "http://127.0.0.1:1", { - AGENTLOCK_HOME: home, - }); - expect(r.code).toBe(0); - expect(r.stderr).toContain("OpenAgentLock daemon isn't running"); - // Second invocation under the same AGENTLOCK_HOME → marker exists → - // nudge stays silent. Confirms the dedupe behavior end-to-end. - const r2 = await runShim(["pre-tool-use"], payload, "http://127.0.0.1:1", { - AGENTLOCK_HOME: home, - }); - expect(r2.code).toBe(0); - expect(r2.stderr).toBe(""); + test("daemon unreachable → silent fail-open on every event", async () => { + // Codex hides hook stderr on exit-0 and renders any non-zero exit + // as a "(failed)" banner that looks like a real error. Neither is + // an acceptable channel for a status nudge, so we stay completely + // silent — empty stdout, empty stderr, exit 0. + for (const event of ["session-start", "pre-tool-use", "post-tool-use", "stop"]) { + const r = await runShim( + [event], + JSON.stringify({ session_id: "sess_q", hook_event_name: event }), + "http://127.0.0.1:1", + ); + expect(r.code).toBe(0); + expect(r.stdout).toBe(""); + expect(r.stderr).toBe(""); + } }); test("unknown event → exit 2 with usage", async () => { diff --git a/cli/tests/hook-cursor.test.ts b/cli/tests/hook-cursor.test.ts new file mode 100644 index 0000000..85f77ae --- /dev/null +++ b/cli/tests/hook-cursor.test.ts @@ -0,0 +1,184 @@ +// E2E test for the `agentlock hook cursor ` shim. Spawns a mock +// daemon over Bun.serve, runs the shim binary with stdin piped JSON, +// and asserts the shim emits Cursor's expected +// {permission, agent_message?} envelope on stdout, plus the right exit +// code. Mirrors the claude-code/codex test shape. + +import { afterEach, describe, expect, test } from "bun:test"; +import { spawn } from "node:child_process"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +interface MockExpect { + status?: number; + body?: unknown; +} + +interface RecordedRequest { + path: string; + body: unknown; +} + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function startMockDaemon( + expects: Record, + recorded: RecordedRequest[], +): { server: ReturnType; url: string } { + const server = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url); + const body = req.method === "POST" ? await req.json().catch(() => null) : null; + recorded.push({ path: url.pathname, body }); + const cfg = expects[url.pathname]; + if (cfg) { + return jsonResponse(cfg.status ?? 200, cfg.body ?? { continue: true }); + } + return new Response("not found", { status: 404 }); + }, + }); + return { server, url: `http://127.0.0.1:${server.port}` }; +} + +interface ShimResult { + code: number | null; + stdout: string; + stderr: string; +} + +function runShim( + args: string[], + payload: string, + daemonUrl: string, + extraEnv: Record = {}, +): Promise { + return new Promise((resolve, reject) => { + const entry = join(import.meta.dir, "..", "src", "index.ts"); + const proc = spawn("bun", ["run", entry, "hook", "cursor", ...args], { + env: { + ...process.env, + AGENTLOCK_DAEMON_URL: daemonUrl, + ...extraEnv, + }, + }); + let stdout = ""; + let stderr = ""; + proc.stdout.on("data", (c: Buffer) => { + stdout += c.toString("utf8"); + }); + proc.stderr.on("data", (c: Buffer) => { + stderr += c.toString("utf8"); + }); + proc.on("error", reject); + proc.on("close", (code: number | null) => { + resolve({ code, stdout, stderr }); + }); + proc.stdin.write(payload); + proc.stdin.end(); + }); +} + +describe("hook cursor shim", () => { + let server: ReturnType | null = null; + afterEach(() => { + server?.stop(true); + server = null; + }); + + test("allow → exit 0 with permission:allow", async () => { + const recorded: RecordedRequest[] = []; + const m = startMockDaemon( + { + "/v1/hooks/cursor/pre-tool-use": { + status: 200, + body: { + continue: true, + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "allow", + }, + }, + }, + }, + recorded, + ); + server = m.server; + + const payload = JSON.stringify({ + conversation_id: "conv_x", + hook_event_name: "preToolUse", + tool_name: "Bash", + tool_input: { command: "ls" }, + }); + const r = await runShim(["pre-tool-use"], payload, m.url); + expect(r.code).toBe(0); + expect(JSON.parse(r.stdout)).toEqual({ permission: "allow" }); + expect(recorded[0].path).toBe("/v1/hooks/cursor/pre-tool-use"); + }); + + test("deny → exit 2 with reason on stderr and stdout", async () => { + const recorded: RecordedRequest[] = []; + const m = startMockDaemon( + { + "/v1/hooks/cursor/pre-tool-use": { + status: 200, + body: { + continue: false, + stopReason: "matched rule rogue.destructive-bash (deny)", + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "matched rule rogue.destructive-bash (deny)", + }, + }, + }, + }, + recorded, + ); + server = m.server; + + const payload = JSON.stringify({ + conversation_id: "conv_y", + hook_event_name: "preToolUse", + tool_name: "Bash", + tool_input: { command: "rm -rf /tmp/x" }, + }); + const r = await runShim(["pre-tool-use"], payload, m.url); + expect(r.code).toBe(2); + expect(r.stderr).toContain("rogue.destructive-bash"); + expect(r.stdout).toContain('"permission":"deny"'); + expect(r.stdout).toContain("rogue.destructive-bash"); + }); + + test("daemon unreachable → silent fail-open with plain allow envelope on every event", async () => { + // Cursor has no UI surface outside the model's input stream that we + // can write to (no statusLine, no safe agent_message). On a transport + // failure we just emit a plain {permission:allow} so Cursor doesn't + // surface a hook error and the user's tool call goes through. + const home = mkdtempSync(join(tmpdir(), "agentlock-test-")); + for (const event of ["session-start", "pre-tool-use", "post-tool-use"]) { + const r = await runShim( + [event], + JSON.stringify({ conversation_id: "conv_x", hook_event_name: event }), + "http://127.0.0.1:1", + { AGENTLOCK_HOME: home }, + ); + expect(r.code).toBe(0); + expect(r.stderr).toBe(""); + expect(JSON.parse(r.stdout)).toEqual({ permission: "allow" }); + } + }); + + test("unknown event → exit 2 with usage", async () => { + const r = await runShim(["bogus"], "", "http://127.0.0.1:1"); + expect(r.code).toBe(2); + expect(r.stderr).toContain("usage"); + }); +}); diff --git a/cli/tests/install-fs.test.ts b/cli/tests/install-fs.test.ts index d44475f..6b96937 100644 --- a/cli/tests/install-fs.test.ts +++ b/cli/tests/install-fs.test.ts @@ -16,6 +16,10 @@ import { executeUninstallOps, readExistingFiles, } from "../src/util/install-fs.ts"; +import { + installAndResolveAgentlockBinary, + installStatusLineScript, +} from "../src/commands/install.ts"; import type { InstallFileOp, InstallUninstallOp } from "../src/util/api.ts"; let workdir: string; @@ -289,3 +293,80 @@ describe("readExistingFiles", () => { expect(Object.keys(got)).toHaveLength(0); }); }); + +describe("installAndResolveAgentlockBinary", () => { + test("writes an executable wrapper under /bin and returns its path", async () => { + const home = await mkdtemp(join(tmpdir(), "agentlock-binhome-")); + const prev = process.env.AGENTLOCK_HOME; + process.env.AGENTLOCK_HOME = home; + try { + const path = installAndResolveAgentlockBinary(); + expect(path).toBe(join(home, "bin", "agentlock")); + const st = await fs.stat(path); + expect(st.isFile()).toBe(true); + // Owner exec bit set so /bin/sh can spawn it from harness hook entries. + expect(st.mode & 0o100).toBe(0o100); + const body = await fs.readFile(path, "utf8"); + expect(body).toContain("#!/usr/bin/env bash"); + expect(body).toContain("exec bun run"); + expect(body).toContain("src/index.ts"); + } finally { + if (prev === undefined) delete process.env.AGENTLOCK_HOME; + else process.env.AGENTLOCK_HOME = prev; + await fs.rm(home, { recursive: true, force: true }); + } + }); + + test("rewrites the wrapper idempotently on repeat calls", async () => { + const home = await mkdtemp(join(tmpdir(), "agentlock-binhome-")); + const prev = process.env.AGENTLOCK_HOME; + process.env.AGENTLOCK_HOME = home; + try { + const a = installAndResolveAgentlockBinary(); + const bodyA = await fs.readFile(a, "utf8"); + const b = installAndResolveAgentlockBinary(); + const bodyB = await fs.readFile(b, "utf8"); + expect(b).toBe(a); + expect(bodyB).toBe(bodyA); + } finally { + if (prev === undefined) delete process.env.AGENTLOCK_HOME; + else process.env.AGENTLOCK_HOME = prev; + await fs.rm(home, { recursive: true, force: true }); + } + }); +}); + +describe("installStatusLineScript", () => { + test("writes an executable health-check script and reports offline when daemon is unreachable", async () => { + const home = await mkdtemp(join(tmpdir(), "agentlock-statusline-")); + const prev = process.env.AGENTLOCK_HOME; + process.env.AGENTLOCK_HOME = home; + try { + const path = installStatusLineScript(); + expect(path).toBe(join(home, "bin", "agentlock-status")); + const st = await fs.stat(path); + expect(st.isFile()).toBe(true); + expect(st.mode & 0o100).toBe(0o100); + + // Run the script with a guaranteed-unreachable daemon URL — must + // print the offline indicator without erroring. + const proc = Bun.spawnSync([path], { + env: { ...process.env, AGENTLOCK_DAEMON_URL: "http://127.0.0.1:1" }, + stdio: ["ignore", "pipe", "pipe"], + }); + const out = new TextDecoder().decode(proc.stdout); + expect(out).toContain("OpenAgentLock"); + expect(out).toContain("offline"); + expect(proc.exitCode).toBe(0); + } finally { + if (prev === undefined) delete process.env.AGENTLOCK_HOME; + else process.env.AGENTLOCK_HOME = prev; + await fs.rm(home, { recursive: true, force: true }); + } + }); + + // Note: we don't have a "reports protected" test here because + // Bun.spawnSync blocks the event loop, so any in-process Bun.serve mock + // can't accept connections during the curl call. The healthy-path is + // verified in manual e2e and via the daemon's own integration tests. +}); diff --git a/control-plane/internal/api/install.go b/control-plane/internal/api/install.go index 20bea15..3ffc38e 100644 --- a/control-plane/internal/api/install.go +++ b/control-plane/internal/api/install.go @@ -52,6 +52,11 @@ type installPlanRequest struct { // CLI callers should pass an absolute path so the dev loop and CI // don't depend on PATH lookups inside Codex's spawn environment. AgentlockBinary string `json:"agentlock_binary,omitempty"` + // StatusLineScript is the absolute path to the small bash script the + // CLI wrote at install time that prints "OpenAgentLock ✓ / ⚠ daemon + // offline". When set, Claude Code's settings.json gets a statusLine + // entry pointing at it. Empty means "skip the statusLine wiring." + StatusLineScript string `json:"status_line_script,omitempty"` // HarnessConfigDirs lets the CLI pre-resolve per-harness config dirs // on the host, so the daemon doesn't probe its own os.UserHomeDir() // (which is /home/nonroot inside a container). Keys are harness ids @@ -126,7 +131,7 @@ func buildPlanOps(req installPlanRequest) ([]fileOp, []string, []string) { for _, h := range req.Harnesses { switch h { case "claude-code": - ops = append(ops, claudeCodePlan(req.DaemonURL, req.ConfigDirOverride, req.AgentlockBinary, req.HarnessConfigDirs, req.ExistingFiles)) + ops = append(ops, claudeCodePlan(req.DaemonURL, req.ConfigDirOverride, req.AgentlockBinary, req.StatusLineScript, req.HarnessConfigDirs, req.ExistingFiles)) case "codex": codexOps, ws := codexPlan(req.DaemonURL, req.ConfigDirOverride, req.AgentlockBinary, req.HarnessConfigDirs, req.ExistingFiles) ops = append(ops, codexOps...) @@ -230,7 +235,7 @@ func claudeCodeHookConfig(daemonURL, agentlockBinary string) map[string]any { "hooks": []any{ map[string]any{ "type": "command", - "command": fmt.Sprintf("%s hook claude-code %s", bin, event), + "command": fmt.Sprintf("%s hook claude-code %s", shellQuote(bin), event), "env": map[string]any{ "AGENTLOCK_DAEMON_URL": daemonURL, }, @@ -296,7 +301,7 @@ func claudeCodeSettingsPath(configDirOverride string, overrides map[string]strin // The op carries op.BackupPath when an existing file was supplied — the // CLI uses that as the suggested backup name and creates it during apply. // The daemon never reads or writes host files in the new flow. -func claudeCodePlan(daemonURL, configDirOverride, agentlockBinary string, overrides map[string]string, existingFiles map[string]string) fileOp { +func claudeCodePlan(daemonURL, configDirOverride, agentlockBinary, statusLineScript string, overrides map[string]string, existingFiles map[string]string) fileOp { settingsPath, err := claudeCodeSettingsPath(configDirOverride, overrides) if err != nil { // Plan is informational — keep going with a placeholder so the @@ -316,12 +321,15 @@ func claudeCodePlan(daemonURL, configDirOverride, agentlockBinary string, overri backupPath = fmt.Sprintf("%s.agentlock-backup-%d", abs, time.Now().UnixNano()) } - merged, mergeErr := mergeClaudeSettings(existing, daemonURL, agentlockBinary) + merged, mergeErr := mergeClaudeSettings(existing, daemonURL, agentlockBinary, statusLineScript) if mergeErr != nil { // Fall back to the agentlock-only payload so we still produce a // usable op; the CLI will surface the parse error when it sees // the existing file contents differ. hook := map[string]any{"hooks": claudeCodeHookConfig(daemonURL, agentlockBinary)} + if statusLineScript != "" { + hook["statusLine"] = claudeStatusLineEntry(statusLineScript) + } merged, _ = json.MarshalIndent(hook, "", " ") } return fileOp{ @@ -491,7 +499,12 @@ func harnessForPath(path string) string { // bytes. Existing non-agentlock entries under hooks.PreToolUse / hooks.Stop // are preserved. Our own (tagged with _agentlock:true) are replaced, so the // operation is idempotent. -func mergeClaudeSettings(existing []byte, daemonURL, agentlockBinary string) ([]byte, error) { +// +// When statusLineScript is non-empty we additionally write a statusLine +// entry tagged _agentlock:true so users see live "OpenAgentLock ✓ / +// ⚠ daemon offline" under their Claude Code chat. We never clobber a +// user-defined statusLine (one without our tag). +func mergeClaudeSettings(existing []byte, daemonURL, agentlockBinary, statusLineScript string) ([]byte, error) { settings := map[string]any{} if len(existing) > 0 { if err := json.Unmarshal(existing, &settings); err != nil { @@ -519,9 +532,31 @@ func mergeClaudeSettings(existing []byte, daemonURL, agentlockBinary string) ([] } settings["hooks"] = hooks + if statusLineScript != "" { + if existingSL, ok := settings["statusLine"].(map[string]any); ok && !isAgentlockEntry(existingSL) { + // User has their own statusLine — leave it alone. + } else { + settings["statusLine"] = claudeStatusLineEntry(statusLineScript) + } + } + return json.MarshalIndent(settings, "", " ") } +// claudeStatusLineEntry renders the settings.json statusLine block that +// points Claude Code at our health-check script. Claude Code passes this +// string through a shell on every UI render, so spaces in the path (e.g. +// macOS "Library/Application Support") need quoting too — same fix as +// the hook command writers above. +func claudeStatusLineEntry(scriptPath string) map[string]any { + return map[string]any{ + "_agentlock": true, + "type": "command", + "command": shellQuote(scriptPath), + "padding": 0, + } +} + func isAgentlockEntry(v any) bool { m, ok := v.(map[string]any) if !ok { @@ -531,6 +566,20 @@ func isAgentlockEntry(v any) bool { return b } +// shellQuote wraps a path in single quotes so a shell-interpreted hook +// command survives spaces (e.g. macOS "Library/Application Support"). +// Hook configs across Claude Code / Codex / Cursor pass the command +// string through /bin/sh, which splits on unquoted whitespace and +// executes "/Users/ronaldli/Library/Application" as a script — that's +// the "line 1: on: command not found" failure mode that produced red +// "PreToolUse:hook error" banners in earlier installs. Single quotes +// are the simplest robust escape: macOS state dirs can't contain '\''. +// For the (extremely unlikely) edge case where they do, we fall back +// to the close-quote / escaped-quote / open-quote idiom. +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + // --- uninstall ---------------------------------------------------------- type installUninstallRequest struct { @@ -892,6 +941,12 @@ func stripClaudeSettings(existing []byte) ([]byte, int, error) { settings["hooks"] = hooks } + // Strip our statusLine entry too, leaving any user-defined one alone. + if sl, ok := settings["statusLine"].(map[string]any); ok && isAgentlockEntry(sl) { + delete(settings, "statusLine") + removed++ + } + out, err := json.MarshalIndent(settings, "", " ") if err != nil { return nil, 0, fmt.Errorf("marshal: %w", err) diff --git a/control-plane/internal/api/install_codex.go b/control-plane/internal/api/install_codex.go index 487c507..71b021a 100644 --- a/control-plane/internal/api/install_codex.go +++ b/control-plane/internal/api/install_codex.go @@ -91,7 +91,7 @@ func codexHookConfig(daemonURL, agentlockBinary string) map[string]any { "hooks": []any{ map[string]any{ "type": "command", - "command": fmt.Sprintf("%s hook codex %s", bin, event), + "command": fmt.Sprintf("%s hook codex %s", shellQuote(bin), event), "env": map[string]any{ "AGENTLOCK_DAEMON_URL": strings.TrimRight(daemonURL, "/"), }, diff --git a/control-plane/internal/api/install_codex_test.go b/control-plane/internal/api/install_codex_test.go index 2cedd11..d46b21b 100644 --- a/control-plane/internal/api/install_codex_test.go +++ b/control-plane/internal/api/install_codex_test.go @@ -95,8 +95,8 @@ func TestInstallPlan_CodexProducesWriteOpAndWarning(t *testing.T) { t.Fatalf("expected hooks.json path, got %q", path) } content, _ := op["content"].(string) - if !strings.Contains(content, "/usr/local/bin/agentlock hook codex pre-tool-use") { - t.Fatalf("expected shim command in content, got: %s", content) + if !strings.Contains(content, "'/usr/local/bin/agentlock' hook codex pre-tool-use") { + t.Fatalf("expected shell-quoted shim command in content, got: %s", content) } if !strings.Contains(content, `"AGENTLOCK_DAEMON_URL": "http://127.0.0.1:7878"`) { t.Fatalf("expected daemon URL env, got: %s", content) @@ -277,7 +277,7 @@ func TestInstallApply_CodexWritesHooksJson(t *testing.T) { `"PostToolUse"`, `"Stop"`, `"type": "command"`, - `/usr/local/bin/agentlock hook codex pre-tool-use`, + `'/usr/local/bin/agentlock' hook codex pre-tool-use`, `"AGENTLOCK_DAEMON_URL"`, } { if !strings.Contains(s, want) { diff --git a/control-plane/internal/api/install_cursor.go b/control-plane/internal/api/install_cursor.go index cce98b8..2f22a42 100644 --- a/control-plane/internal/api/install_cursor.go +++ b/control-plane/internal/api/install_cursor.go @@ -97,7 +97,7 @@ func cursorHookConfig(daemonURL, agentlockBinary string) map[string]any { "_agentlock": true, "matcher": "*", "type": "command", - "command": fmt.Sprintf("%s hook cursor %s", bin, event), + "command": fmt.Sprintf("%s hook cursor %s", shellQuote(bin), event), "env": map[string]any{ "AGENTLOCK_DAEMON_URL": strings.TrimRight(daemonURL, "/"), }, diff --git a/control-plane/internal/api/install_cursor_test.go b/control-plane/internal/api/install_cursor_test.go index 5e9773b..8b8b8b6 100644 --- a/control-plane/internal/api/install_cursor_test.go +++ b/control-plane/internal/api/install_cursor_test.go @@ -80,7 +80,7 @@ func TestInstallPlan_CursorProducesWriteOp(t *testing.T) { `"afterMCPExecution"`, `"postToolUse"`, `"sessionEnd"`, - `/usr/local/bin/agentlock hook cursor pre-tool-use`, + `'/usr/local/bin/agentlock' hook cursor pre-tool-use`, `"AGENTLOCK_DAEMON_URL": "http://127.0.0.1:7878"`, } { if !strings.Contains(content, want) { @@ -124,7 +124,7 @@ func TestInstallApply_CursorReturnsHooksContent(t *testing.T) { `"postToolUse"`, `"sessionEnd"`, `"type": "command"`, - `/usr/local/bin/agentlock hook cursor pre-tool-use`, + `'/usr/local/bin/agentlock' hook cursor pre-tool-use`, `"AGENTLOCK_DAEMON_URL"`, } { if !strings.Contains(s, want) { diff --git a/control-plane/internal/api/install_test.go b/control-plane/internal/api/install_test.go index c8ffda8..42ad2d6 100644 --- a/control-plane/internal/api/install_test.go +++ b/control-plane/internal/api/install_test.go @@ -231,6 +231,7 @@ func TestClaudeCodePlan_PreservesExistingUserKeys(t *testing.T) { "http://127.0.0.1:7878", filepath.Dir(abs), "", + "", nil, map[string]string{abs: existing}, ) @@ -265,6 +266,99 @@ func TestClaudeCodePlan_PreservesExistingUserKeys(t *testing.T) { } } +// Regression: paths with spaces (e.g. macOS "Library/Application Support") +// must be shell-quoted so /bin/sh doesn't split them when running the hook. +// Without quoting, Claude/Codex/Cursor render a red "hook error" banner with +// "line 1: on: command not found" or "exit code 127" on every event. +func TestClaudeCodePlan_QuotesBinaryPathWithSpaces(t *testing.T) { + settingsPath := filepath.Join(t.TempDir(), "settings.json") + abs, _ := filepath.Abs(settingsPath) + op := claudeCodePlan( + "http://127.0.0.1:7878", + filepath.Dir(abs), + "/Users/x/Library/Application Support/OpenAgentLock/bin/agentlock", + "", + nil, + nil, + ) + // The wired command must wrap the binary path in single quotes so + // /bin/sh treats it as one token. + if !strings.Contains(op.Content, "'/Users/x/Library/Application Support/OpenAgentLock/bin/agentlock'") { + t.Fatalf("binary path not shell-quoted in hook command:\n%s", op.Content) + } +} + +// statusLine wiring: when status_line_script is supplied, the merged +// settings.json must carry an _agentlock-tagged statusLine pointing at +// the script. Re-running keeps it idempotent. A user-defined statusLine +// (no _agentlock tag) must survive untouched. +func TestClaudeCodePlan_StatusLineWiring(t *testing.T) { + settingsPath := filepath.Join(t.TempDir(), "settings.json") + abs, _ := filepath.Abs(settingsPath) + op := claudeCodePlan( + "http://127.0.0.1:7878", + filepath.Dir(abs), + "", + "/usr/local/oal/bin/agentlock-status", + nil, + nil, + ) + var parsed map[string]any + if err := json.Unmarshal([]byte(op.Content), &parsed); err != nil { + t.Fatalf("parse: %v\n%s", err, op.Content) + } + sl, ok := parsed["statusLine"].(map[string]any) + if !ok { + t.Fatalf("statusLine not wired: %+v", parsed) + } + if got := sl["command"]; got != "'/usr/local/oal/bin/agentlock-status'" { + t.Fatalf("statusLine command = %v (expected single-quoted to survive shell parsing of paths with spaces)", got) + } + if b, _ := sl["_agentlock"].(bool); !b { + t.Fatalf("agentlock marker missing on statusLine: %+v", sl) + } + + // User-defined statusLine should be preserved. + existing := `{"statusLine":{"type":"command","command":"/my/own/status"}}` + op2 := claudeCodePlan( + "http://127.0.0.1:7878", + filepath.Dir(abs), + "", + "/usr/local/oal/bin/agentlock-status", + nil, + map[string]string{abs: existing}, + ) + var parsed2 map[string]any + if err := json.Unmarshal([]byte(op2.Content), &parsed2); err != nil { + t.Fatalf("parse2: %v", err) + } + sl2, _ := parsed2["statusLine"].(map[string]any) + if got := sl2["command"]; got != "/my/own/status" { + t.Fatalf("user statusLine clobbered: %v", got) + } +} + +// stripClaudeSettings should remove an agentlock-tagged statusLine so +// uninstall reverts the file cleanly. +func TestStripClaudeSettings_RemovesStatusLine(t *testing.T) { + in := []byte(`{ + "hooks": {"PreToolUse": [{"_agentlock": true}]}, + "statusLine": {"_agentlock": true, "type": "command", "command": "/x"} + }`) + out, removed, err := stripClaudeSettings(in) + if err != nil { + t.Fatalf("strip: %v", err) + } + if removed < 2 { + t.Fatalf("expected at least 2 removed (hook + statusLine), got %d", removed) + } + var parsed map[string]any + _ = json.Unmarshal(out, &parsed) + if _, has := parsed["statusLine"]; has { + t.Fatalf("statusLine not stripped: %s", out) + } +} + // ---- apply ---- func applyBody(fx gateFixture, overrideDir string) string { diff --git a/docs/reference/hooks.md b/docs/reference/hooks.md index 5521f50..ae674a9 100644 --- a/docs/reference/hooks.md +++ b/docs/reference/hooks.md @@ -23,7 +23,17 @@ Claude Code uses **command hooks**. The installer adds entries to `~/.claude/set } ``` -The shim POSTs to `/v1/hooks/claude-code/` and translates the response into Claude's exit-code / JSON contract. Routing through a shim — instead of Claude's native HTTP hooks — lets the harness fail-open silently on a daemon outage instead of surfacing a red "PreToolUse hook error / ECONNREFUSED" banner on every tool call. The first failed round-trip per outage emits a one-line stderr nudge ("daemon isn't running — running unprotected"), then stays silent until the daemon is back. +The shim POSTs to `/v1/hooks/claude-code/` and translates the response into Claude's exit-code / JSON contract. Routing through a shim — instead of Claude's native HTTP hooks — lets the harness fail-open silently on a daemon outage instead of surfacing a red "PreToolUse hook error / ECONNREFUSED" banner on every tool call. + +### Daemon-down UX + +When the daemon is unreachable, the shim never writes user-visible text into stdout — anything that reaches Claude's `additionalContext` / Cursor's `agent_message` lands in the model's input stream and registers as a prompt-injection attempt, regardless of wording. We surface daemon health through channels that bypass the model entirely, with a different surface per harness based on what each one's hook spec actually exposes: + +- **Claude Code — live `statusLine`** (best UX). The installer writes a `statusLine` entry in `~/.claude/settings.json` pointing at a tiny health-check script at `/bin/agentlock-status`. Claude Code re-runs that script on every UI render and shows the result as a persistent element under the chat: `OpenAgentLock ✓` when the daemon is up, `OpenAgentLock ⚠ daemon offline` when it's not. The output is pure UI — never seen by the model. +- **Codex CLI — silent fail-open**. Codex has no `statusLine` analog and hides hook stderr on exit-0 (it only surfaces hook output as a red `(failed)` banner when the hook exits non-zero, which is the wrong channel for a status nudge). There is no in-Codex UI surface available for an indicator that won't either look like an error or pollute the model's input. The shim stays silent on every event when the daemon is unreachable. +- **Cursor — silent fail-open**. Cursor's hook spec has no UI surface that's outside the model's input stream and no statusLine equivalent. On daemon failures the shim emits a plain `{"permission":"allow"}` envelope and stays silent. A live indicator for Cursor would need a real Cursor extension; tracked separately. + +All three harnesses share the wrapper-stability fix: the hook command Claude Code / Codex / Cursor spawn points at `/bin/agentlock` (e.g. `~/Library/Application Support/OpenAgentLock/bin/agentlock` on macOS), written by `agentlock install`. The path lives in our state dir, not in the package manager's `node_modules` tree, so package upgrades don't strand the wired path. The same applies to `agentlock-status`. Both paths are shell-quoted in the wired command string so spaces (`Application Support`) survive `/bin/sh -c` parsing. ## Codex CLI @@ -41,9 +51,17 @@ command = ["agentlock", "hook", "codex", "pre-tool"] Codex command hooks are bash-only today; MCP coverage at the hook layer is a tracked upstream gap, not something we can paper over. +Daemon-down behavior is documented in the **Daemon-down UX** section above — Codex stays silent (it has no UI surface that renders on exit-0 hooks). + +## Cursor + +Cursor (≥1.7) uses **command hooks** in `~/.cursor/hooks.json`. The installer wires the `agentlock hook cursor ` shim for `sessionStart`, `preToolUse`, `beforeShellExecution`, `beforeMCPExecution`, `afterMCPExecution`, `postToolUse`, and `sessionEnd`. The shim emits Cursor's `{permission, agent_message?}` shape on stdout. + +Daemon-down behavior is documented in the **Daemon-down UX** section above — Cursor gets silent fail-open on transport errors. We never set `agent_message` on those, since that field lands in the model's input stream and would register as a prompt-injection attempt. + ## Other harnesses -Cursor, OpenCode, Cline, Gemini CLI, Continue.dev all expose a hook surface but **the installer does not yet write to them**. The detectors find the harness, the picker shows it, and the install plan flags it as not yet implemented. Wiring is a follow-up tracked in the public roadmap. +OpenCode, Cline, Gemini CLI, Continue.dev all expose a hook surface but **the installer does not yet write to them**. The detectors find the harness, the picker shows it, and the install plan flags it as not yet implemented. Wiring is a follow-up tracked in the public roadmap. VS Code Copilot has no general-purpose pre-tool hook surface; we cannot harden it from outside.