diff --git a/docs/hooks.md b/docs/hooks.md new file mode 100644 index 0000000..cb060d7 --- /dev/null +++ b/docs/hooks.md @@ -0,0 +1,129 @@ +# Daemon-native hooks + +pi's in-process extension hooks are the single best idea in the pi harness — and they only work for pi sessions, because they run inside the pi process. +codeoid's **hook bus** lifts the same idea to the daemon: user-configured hooks dispatched on the provider-neutral events the daemon already sees, so one rule set applies uniformly whether a session runs on claude, pi, gemini, or openai. + +Rules like: + +- "block any tool call that touches `.env`" +- "rewrite `rm -rf` commands before they reach the approval prompt" +- "redact secrets from recorded tool output" +- "git-checkpoint after every turn" +- "POST an audit event to my webhook on every provider switch" + +This layer is distinct from pi's own extensions (those keep working inside pi sessions) and from Claude Code's `settings.json` hooks (those keep working inside the Claude Code subprocess). +The hook bus sits in the codeoid daemon, between the provider event stream and Session's handling — the one place every backend's traffic flows through. + +## Configuration + +Hooks are declared in `~/.codeoid/config.json`. No plugin loading, no JS API — v1 hooks are shell commands or webhooks: + +```json +{ + "hooks": { + "enabled": true, + "entries": [ + { + "event": "tool_call", + "matcher": "^(Write|Edit|Bash)$", + "type": "command", + "command": "~/.codeoid/hooks/env-guard.sh", + "name": "env-guard", + "timeoutMs": 5000 + }, + { + "event": "after_turn", + "type": "webhook", + "url": "https://audit.example.com/codeoid" + } + ] + } +} +``` + +| Field | Meaning | +| --- | --- | +| `event` | One of the events below. | +| `matcher` | Optional regex on the tool name (`tool_call` / `tool_result` only). Absent = every tool. Invalid regexes fail config load. | +| `type` | `command` (shell via `/bin/sh -c`, cwd = the session's workdir) or `webhook` (HTTP POST). | +| `command` / `url` | Required for the respective type. | +| `timeoutMs` | Per-hook budget, default 10 000, max 60 000. A timed-out hook is killed and ignored. | +| `name` | Display name used in logs and the info messages shown to the user. | + +`CODEOID_HOOKS_ENABLED=false` disables every hook for one invocation without touching the file. + +## Events + +| Event | When | Can do | +| --- | --- | --- | +| `tool_call` | Before a tool executes, **before** the approval gate | block; mutate input | +| `tool_result` | After a tool completes | patch the recorded output | +| `before_turn` | A fresh turn is starting (not mid-turn injections) | append to the system prompt | +| `after_turn` | Turn finished, carries the normalized result | observe | +| `session_start` | Session created (`source`: `"new"` \| `"resume"`) | observe | +| `session_end` | Session destroyed | observe | +| `provider_switched` | Backend switched mid-session (`from`, `to`, `seeded`) | observe | +| `rotated` | Backing context rotated (`reason`, `rotationCount`) | observe | + +Every payload also carries `sessionId`, `sessionName`, `workdir`, and `providerId`. + +### Ordering: hooks run before approval + +A `tool_call` hook is a **policy** layer, not a convenience layer. +It runs before codeoid's approval gate, so: + +- A hook **block** short-circuits — the user is never prompted, the autonomous turn budget is never spent, and an info message explains which hook blocked and why. +- A block wins even for auto-approved safe tools (Read/Grep/Glob) — the gate is uniform. +- A hook **mutation** replaces the tool input before the approval prompt renders, so the user approves what will actually run. An info message records the mutation. + +This matches pi's `tool_call` extension semantics. + +### `tool_result` honesty note + +Native backends run their own agent loop — by the time the daemon sees a tool result, the backend's model has already consumed the original. +`updatedOutput` therefore governs what codeoid **records**: scrollback, transcript, and the canonical history (which is what a switched-to backend sees). +Use it to redact transcripts, not to lie to the current model. + +## Hook contract (command) + +The event payload arrives as JSON on **stdin**. The hook responds with: + +- **exit 0** — stdout may carry a JSON outcome: + - `{"decision": "block", "reason": "..."}` — block the tool (`tool_call` only) + - `{"updatedInput": {...}}` — replace the tool input (`tool_call` only) + - `{"updatedOutput": "..."}` — replace the recorded output (`tool_result` only) + - `{"systemPromptAppend": "..."}` — append to the system prompt (`before_turn` only) +- **exit 2** — block, with stderr as the reason (mirrors Claude Code's hook contract). +- **any other exit / timeout / malformed JSON** — the hook is logged and ignored. + +Fail-open is deliberate for **infra** failures: a crashed hook script must not brick every session. +Blocking is always an explicit hook decision (exit 2 or `decision: block`). + +Multiple hooks on one event run in declaration order; the first block short-circuits, and input mutations chain (each hook sees the previous hook's output). + +Example `env-guard.sh`: + +```sh +#!/bin/sh +# Block any tool call whose input mentions a .env file. +if grep -q '\.env' -; then + echo '{"decision":"block","reason":".env files are off-limits"}' +fi +``` + +## Hook contract (webhook) + +The event payload is POSTed as JSON. +A 2xx response body may carry the same JSON outcome object; non-2xx responses and network errors are logged and ignored. + +## Security + +Hook commands run arbitrary user-configured code by design — but they run in the **daemon's** trust context, whose environment holds the ZeroID root key and other codeoid secrets. +Therefore: + +- Commands get the **hardened subprocess environment** (the same allowlist machinery as provider subprocesses — shared basics only, `CODEOID_*`/`ZEROID_*`/`TELEGRAM_*` always denied). They never inherit raw `process.env`. +- Extra variables can be passed explicitly via `CODEOID_AGENT_ENV_ALLOW=NAME1,NAME2`, the same deliberate operator escape hatch providers use. +- Event data travels on stdin, never in env vars. +- Captured hook output is capped at 1 MiB. + +An in-process JS plugin kind is deliberately not offered in v1 — it is a much larger security surface. A future hook kind can add it behind its own review. diff --git a/src/config.ts b/src/config.ts index 94f3723..c111293 100644 --- a/src/config.ts +++ b/src/config.ts @@ -20,6 +20,7 @@ import { homedir } from "node:os"; import { z } from "zod"; import type { AuthConfig } from "./daemon/auth.js"; import type { OAuthConfig } from "./daemon/oauth.js"; +import { HOOK_EVENTS, type HookEntryConfig } from "./daemon/hooks/types.js"; // ── Paths ──────────────────────────────────────────────────────────────── @@ -367,6 +368,51 @@ const DispatchSchema = z retryBaseMs: 15_000, }); +/** + * Daemon-native hooks (docs/hooks.md) — config-declared commands/webhooks + * dispatched at Session's seams (tool_call, tool_result, before_turn, + * after_turn, lifecycle) uniformly across every backend. Matcher regexes + * are validated here so a typo fails loudly at load, not silently at + * dispatch. + */ +const HookEntrySchema = z + .object({ + event: z.enum(HOOK_EVENTS), + matcher: z.string().optional(), + type: z.enum(["command", "webhook"]), + command: z.string().optional(), + url: z.string().optional(), + timeoutMs: z.number().int().positive().max(60_000).optional(), + name: z.string().optional(), + }) + .refine((e) => e.type !== "command" || (e.command !== undefined && e.command.length > 0), { + message: 'hooks of type "command" require a non-empty `command`', + path: ["command"], + }) + .refine((e) => e.type !== "webhook" || (e.url !== undefined && e.url.length > 0), { + message: 'hooks of type "webhook" require a non-empty `url`', + path: ["url"], + }) + .refine( + (e) => { + if (e.matcher === undefined) return true; + try { + new RegExp(e.matcher); + return true; + } catch { + return false; + } + }, + { message: "`matcher` must be a valid regular expression", path: ["matcher"] }, + ); + +const HooksSchema = z + .object({ + enabled: z.boolean().default(true), + entries: z.array(HookEntrySchema).default([]), + }) + .default({ enabled: true, entries: [] }); + /** Per-backend provider settings. Append-only — one optional block per provider. */ const ProvidersSchema = z .object({ @@ -404,6 +450,7 @@ const RootSchema = z.object({ conductor: ConductorSchema, dispatch: DispatchSchema, providers: ProvidersSchema, + hooks: HooksSchema, }); type ParsedConfig = z.infer; @@ -518,6 +565,15 @@ export interface CodeoidConfig { command: string; }; }; + /** + * Daemon-native hooks — dispatched at Session's seams for every backend. + * Optional in the type so hand-built test configs stay minimal; + * loadConfig always populates it (schema defaults: enabled, no entries). + */ + hooks?: { + enabled: boolean; + entries: HookEntryConfig[]; + }; } // ── Env-var override map ───────────────────────────────────────────────── @@ -573,6 +629,9 @@ const ENV_OVERRIDES: readonly EnvOverride[] = [ // matching the conductor block's convention. { env: "CODEOID_DISPATCH_ENABLED", path: "dispatch.enabled", kind: "boolean" }, { env: "CODEOID_FALLBACK_MODEL", path: "session.fallbackModel", kind: "string" }, + // Hooks kill switch — disable every configured hook per-invocation without + // touching config.json. Entries themselves are file-config only. + { env: "CODEOID_HOOKS_ENABLED", path: "hooks.enabled", kind: "boolean" }, { env: "CODEOID_TURN_STALL_TIMEOUT_MS", path: "session.turnStallTimeoutMs", kind: "int" }, { env: "CODEOID_MCP_TOOL_TIMEOUT_MS", path: "session.mcpToolTimeoutMs", kind: "int" }, ]; @@ -748,6 +807,7 @@ export function loadConfig(opts: LoadOptions = {}): CodeoidConfig { conductor: parsed.conductor, dispatch: parsed.dispatch, providers: parsed.providers, + hooks: parsed.hooks, }; } diff --git a/src/daemon/hooks/bus.ts b/src/daemon/hooks/bus.ts new file mode 100644 index 0000000..697f75c --- /dev/null +++ b/src/daemon/hooks/bus.ts @@ -0,0 +1,396 @@ +/** + * HookBus — dispatches config-declared hooks at Session's seams. + * + * Built once at daemon startup (like ProviderRegistry / CompressionRegistry) + * and shared by every session. Session consults it at well-defined points: + * the tool approval gate (`tool_call`), tool completion (`tool_result`), + * turn start (`before_turn`), and fire-and-forget lifecycle points. + * + * SECURITY: hook commands run arbitrary user-configured code by design, but + * they run in the DAEMON's trust context — whose environment carries the + * ZeroID root key and other codeoid secrets. Every command therefore gets + * the hardened subprocess environment (`buildSubprocessEnv`, shared basics + * only), never a raw `process.env` inherit. Hook-specific data travels on + * stdin as JSON, not in env vars. The `CODEOID_AGENT_ENV_ALLOW` escape + * hatch applies as it does for provider subprocesses. + * + * Error contract: dispatch methods never throw. Infra failures (spawn + * error, timeout, bad JSON, non-2xx, network error) are logged and the + * hook is ignored — fail-open, so a broken hook script can't brick every + * session. Blocking is always an explicit hook decision: exit code 2 or + * `{"decision":"block"}`. + */ + +import { spawn } from "node:child_process"; +import type { CodeoidConfig } from "../../config.js"; +import { buildSubprocessEnv } from "../providers/env.js"; +import type { + HookEntryConfig, + HookEvent, + HookOutcome, + HookSessionContext, + ToolCallHookResult, +} from "./types.js"; + +const DEFAULT_TIMEOUT_MS = 10_000; +/** Hard cap on captured stdout/stderr and webhook bodies — a hook that + * streams gigabytes must not OOM the daemon. */ +const MAX_OUTPUT_BYTES = 1024 * 1024; + +interface CompiledEntry { + config: HookEntryConfig; + /** Compiled `matcher` (validated at config load; null = match all). */ + matcher: RegExp | null; + name: string; + timeoutMs: number; +} + +export class HookBus { + #byEvent = new Map(); + /** Env base for command hooks — injectable so tests control the inherit. */ + #envBase: Record; + + constructor( + entries: readonly HookEntryConfig[], + opts: { env?: Record } = {}, + ) { + this.#envBase = opts.env ?? process.env; + entries.forEach((config, i) => { + let matcher: RegExp | null = null; + if (config.matcher !== undefined) { + try { + matcher = new RegExp(config.matcher); + } catch (err) { + // Config-schema validation rejects bad regexes at load; this + // guards direct construction. Skip rather than match-all — a + // typo'd matcher silently applying to every tool is worse. + console.error( + `[codeoid/hooks] invalid matcher ${JSON.stringify(config.matcher)} — entry skipped: ${err instanceof Error ? err.message : String(err)}`, + ); + return; + } + } + const compiled: CompiledEntry = { + config, + matcher, + name: config.name ?? `${config.type}:${config.event}#${i}`, + timeoutMs: Math.min(config.timeoutMs ?? DEFAULT_TIMEOUT_MS, 60_000), + }; + const list = this.#byEvent.get(config.event) ?? []; + list.push(compiled); + this.#byEvent.set(config.event, list); + }); + } + + /** Total configured entries (post-compilation) — for startup logging. */ + get size(): number { + let n = 0; + for (const list of this.#byEvent.values()) n += list.length; + return n; + } + + /** + * True when at least one hook would fire for this event (and tool, for + * tool events). Callers gate their dispatch on this so sessions with no + * matching hooks pay zero latency on the hot paths. + */ + hasHooks(event: HookEvent, toolName?: string): boolean { + return this.#matching(event, toolName).length > 0; + } + + /** + * `tool_call` — the policy gate, run BEFORE the approval gate. Hooks run + * in declaration order; the first block short-circuits (a policy deny + * should not even prompt the user). `updatedInput` chains — each hook + * sees the previous hook's mutation and full replacement wins. + */ + async dispatchToolCall( + ctx: HookSessionContext, + tool: { toolName: string; toolId: string; input: Record }, + ): Promise { + const result: ToolCallHookResult = { mutatedBy: [] }; + let input = tool.input; + for (const entry of this.#matching("tool_call", tool.toolName)) { + const outcome = await this.#run(entry, ctx, { + event: "tool_call", + toolName: tool.toolName, + toolId: tool.toolId, + input, + }); + if (!outcome) continue; + if (outcome.decision === "block") { + result.blocked = { + reason: outcome.reason ?? "blocked by hook", + hookName: entry.name, + }; + return result; + } + if (outcome.updatedInput) { + input = outcome.updatedInput; + result.updatedInput = input; + result.mutatedBy.push(entry.name); + } + } + return result; + } + + /** + * `tool_result` — observe or patch the recorded output. NOTE: the native + * backend has already consumed the ORIGINAL output inside its own agent + * loop by the time the daemon sees `tool_complete`; `updatedOutput` + * affects what codeoid persists, displays, and carries in the canonical + * history (and therefore what a switched-to backend sees) — not what the + * current backend's model saw. Redaction of transcripts is the use case. + */ + async dispatchToolResult( + ctx: HookSessionContext, + tool: { toolName: string; output: string; success: boolean }, + ): Promise<{ updatedOutput?: string }> { + let output = tool.output; + let patched = false; + for (const entry of this.#matching("tool_result", tool.toolName)) { + const outcome = await this.#run(entry, ctx, { + event: "tool_result", + toolName: tool.toolName, + output, + success: tool.success, + }); + if (outcome?.updatedOutput !== undefined) { + output = outcome.updatedOutput; + patched = true; + } + } + return patched ? { updatedOutput: output } : {}; + } + + /** + * `before_turn` — fired when a fresh turn starts (not for mid-turn + * injections into a running turn). Each hook may contribute a + * `systemPromptAppend`; contributions concatenate in declaration order. + */ + async dispatchBeforeTurn( + ctx: HookSessionContext, + turn: { prompt: string }, + ): Promise<{ systemPromptAppend?: string }> { + const appends: string[] = []; + for (const entry of this.#matching("before_turn")) { + const outcome = await this.#run(entry, ctx, { + event: "before_turn", + prompt: turn.prompt, + }); + if (outcome?.systemPromptAppend) appends.push(outcome.systemPromptAppend); + } + return appends.length > 0 ? { systemPromptAppend: appends.join("\n\n") } : {}; + } + + /** + * Fire-and-forget dispatch for observe-only events (`after_turn` + + * lifecycle). Outcomes are ignored; failures are logged by #run. Never + * blocks the caller — lifecycle seams (constructor, destroy) are not + * places to await user scripts. + */ + emit( + event: HookEvent, + ctx: HookSessionContext, + payload: Record, + ): void { + const entries = this.#matching(event); + if (entries.length === 0) return; + void (async () => { + for (const entry of entries) { + await this.#run(entry, ctx, { event, ...payload }); + } + })(); + } + + #matching(event: HookEvent, toolName?: string): CompiledEntry[] { + const list = this.#byEvent.get(event); + if (!list) return []; + return list.filter((e) => { + if (!e.matcher) return true; + // A tool-matcher entry never fires when the tool name is unknown — + // matching a named filter against nothing would be a silent lie. + if (toolName === undefined) return false; + return e.matcher.test(toolName); + }); + } + + /** Run one hook. Never throws; infra failures log and return null. */ + async #run( + entry: CompiledEntry, + ctx: HookSessionContext, + payload: Record, + ): Promise { + const body = JSON.stringify({ ...payload, ...ctx }); + try { + if (entry.config.type === "command") { + return await this.#runCommand(entry, ctx, body); + } + return await this.#runWebhook(entry, body); + } catch (err) { + console.error( + `[codeoid/hooks] ${entry.name} failed: ${err instanceof Error ? err.message : String(err)}`, + ); + return null; + } + } + + async #runCommand( + entry: CompiledEntry, + ctx: HookSessionContext, + body: string, + ): Promise { + // command is schema-required for type "command"; guard for direct construction. + const command = entry.config.command; + if (!command) return null; + return await new Promise((resolve) => { + const proc = spawn("/bin/sh", ["-c", command], { + cwd: ctx.workdir, + // Hardened env — shared basics only, never the daemon's secrets. + env: buildSubprocessEnv({}, this.#envBase), + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let settled = false; + const finish = (outcome: HookOutcome | null): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(outcome); + }; + const timer = setTimeout(() => { + console.error( + `[codeoid/hooks] ${entry.name} timed out after ${entry.timeoutMs}ms — killed (non-blocking)`, + ); + proc.kill("SIGKILL"); + finish(null); + }, entry.timeoutMs); + proc.stdout.on("data", (chunk: Buffer) => { + if (stdout.length < MAX_OUTPUT_BYTES) stdout += chunk.toString("utf8"); + }); + proc.stderr.on("data", (chunk: Buffer) => { + if (stderr.length < MAX_OUTPUT_BYTES) stderr += chunk.toString("utf8"); + }); + proc.on("error", (err) => { + console.error(`[codeoid/hooks] ${entry.name} spawn failed: ${err.message}`); + finish(null); + }); + proc.on("close", (code) => { + if (settled) return; + // Exit 2 = explicit block (mirrors Claude Code's hook contract); + // stderr carries the reason. + if (code === 2) { + finish({ decision: "block", reason: stderr.trim() || "blocked by hook" }); + return; + } + if (code !== 0) { + console.error( + `[codeoid/hooks] ${entry.name} exited ${code} (non-blocking)${stderr.trim() ? `: ${stderr.trim()}` : ""}`, + ); + finish(null); + return; + } + finish(parseOutcome(entry.name, stdout)); + }); + proc.stdin.on("error", () => { + /* hook may exit without reading stdin — EPIPE is fine */ + }); + proc.stdin.write(body); + proc.stdin.end(); + }); + } + + async #runWebhook(entry: CompiledEntry, body: string): Promise { + const url = entry.config.url; + if (!url) return null; + let res: Response; + try { + res = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body, + signal: AbortSignal.timeout(entry.timeoutMs), + }); + } catch (err) { + console.error( + `[codeoid/hooks] ${entry.name} webhook failed (non-blocking): ${err instanceof Error ? err.message : String(err)}`, + ); + return null; + } + if (!res.ok) { + console.error( + `[codeoid/hooks] ${entry.name} webhook returned ${res.status} (non-blocking)`, + ); + return null; + } + // Stream the body and stop reading at the cap — `res.text()` would + // buffer an arbitrarily large response in full BEFORE any slice, + // letting a misbehaving webhook exhaust daemon memory. + let text = ""; + if (res.body) { + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + try { + while (text.length < MAX_OUTPUT_BYTES) { + const { done, value } = await reader.read(); + if (done) break; + text += decoder.decode(value, { stream: true }); + } + } finally { + reader.cancel().catch(() => {}); + } + text = text.slice(0, MAX_OUTPUT_BYTES); + } + return parseOutcome(entry.name, text); + } +} + +/** + * Parse a hook's JSON outcome, keeping only known, correctly-typed fields. + * Empty / non-JSON output is a normal "no opinion" — only log when the + * hook produced something that LOOKS like JSON but doesn't parse. + */ +function parseOutcome(name: string, raw: string): HookOutcome | null { + const text = raw.trim(); + if (!text) return null; + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + if (text.startsWith("{")) { + console.error(`[codeoid/hooks] ${name} produced malformed JSON — ignored`); + } + return null; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; + const o = parsed as Record; + const outcome: HookOutcome = {}; + if (o.decision === "block") outcome.decision = "block"; + if (typeof o.reason === "string") outcome.reason = o.reason; + if ( + typeof o.updatedInput === "object" && + o.updatedInput !== null && + !Array.isArray(o.updatedInput) + ) { + outcome.updatedInput = o.updatedInput as Record; + } + if (typeof o.updatedOutput === "string") outcome.updatedOutput = o.updatedOutput; + if (typeof o.systemPromptAppend === "string") { + outcome.systemPromptAppend = o.systemPromptAppend; + } + return outcome; +} + +/** + * Build the daemon's HookBus from config. Returns undefined when hooks are + * disabled or none are configured — callers guard with `?.` so sessions + * without hooks pay nothing. + */ +export function createHookBus(config?: CodeoidConfig): HookBus | undefined { + const hooks = config?.hooks; + if (!hooks || hooks.enabled === false || hooks.entries.length === 0) { + return undefined; + } + return new HookBus(hooks.entries); +} diff --git a/src/daemon/hooks/types.ts b/src/daemon/hooks/types.ts new file mode 100644 index 0000000..558a611 --- /dev/null +++ b/src/daemon/hooks/types.ts @@ -0,0 +1,103 @@ +/** + * Hook bus types — codeoid's daemon-native hook layer. + * + * pi's in-process extension hooks (tool_call block/mutate, lifecycle) only + * help pi sessions. The HookBus gives EVERY backend the same user-pluggable + * extensibility, keyed by the provider-neutral events the daemon already + * sees: a "block writes to .env" or "git-checkpoint per turn" rule applies + * uniformly whether the session runs on claude, pi, gemini, or openai. + * + * Hooks are config-declared (`hooks.entries` in config.json) — no plugin + * loading machinery. Two kinds in v1, both mirroring Claude Code's hook + * contract so users can reuse mental models: + * + * - `command`: spawn a shell command with the event JSON on stdin. + * Exit 0 → stdout may carry a JSON outcome; exit 2 → block (stderr is + * the reason); anything else → non-blocking failure (logged, hook + * ignored). An in-process JS plugin kind is deliberately NOT offered — + * that's a much bigger security surface; a future kind can add it. + * - `webhook`: POST the event JSON; a 2xx response body may carry the + * same JSON outcome. Non-2xx / network errors are non-blocking. + * + * Fail-open by design for INFRA failures (a crashed hook script must not + * brick every session); blocking is always an EXPLICIT hook decision + * (exit 2 or `{"decision":"block"}`). + */ + +/** Events a hook entry can subscribe to. */ +export const HOOK_EVENTS = [ + /** Before a tool executes. Can block or mutate the input. */ + "tool_call", + /** After a tool completes. Can patch the recorded output (see bus docs). */ + "tool_result", + /** A fresh turn is about to start. Can append to the system prompt. */ + "before_turn", + /** A turn finished — carries the normalized result. Observe-only. */ + "after_turn", + /** Session constructed (`source`: "new" | "resume"). Observe-only. */ + "session_start", + /** Session destroyed. Observe-only. */ + "session_end", + /** Backend switched mid-session. Observe-only. */ + "provider_switched", + /** Backing context rotated. Observe-only. */ + "rotated", +] as const; + +export type HookEvent = (typeof HOOK_EVENTS)[number]; + +/** One configured hook — the shape of a `hooks.entries[]` item in config. */ +export interface HookEntryConfig { + event: HookEvent; + /** + * Regex matched against the tool name (tool_call / tool_result only). + * Absent = every tool. Ignored for non-tool events. + */ + matcher?: string; + type: "command" | "webhook"; + /** Shell command (`/bin/sh -c`) — required when type is "command". */ + command?: string; + /** POST target — required when type is "webhook". */ + url?: string; + /** Per-hook wall-clock budget in ms. Default 10 000, capped at 60 000. */ + timeoutMs?: number; + /** Display name for logs + info messages. Default `:`. */ + name?: string; +} + +/** Session identity stamped on every hook payload. */ +export interface HookSessionContext { + sessionId: string; + sessionName: string; + workdir: string; + providerId: string; +} + +/** + * Parsed hook response — the JSON a command prints on stdout (exit 0) or a + * webhook returns in a 2xx body. Every field is optional; each dispatch + * point honors only the fields that make sense for its event (a + * `tool_call` hook's `updatedOutput` is ignored, etc.). + */ +export interface HookOutcome { + /** `"block"` stops the action (tool_call only). */ + decision?: "block"; + /** Human-readable reason shown to the user on a block. */ + reason?: string; + /** Replacement tool input (tool_call only) — replaces the input object. */ + updatedInput?: Record; + /** Replacement tool output (tool_result only). */ + updatedOutput?: string; + /** Extra system-prompt text for the starting turn (before_turn only). */ + systemPromptAppend?: string; +} + +/** Aggregate result of dispatching `tool_call` across matching hooks. */ +export interface ToolCallHookResult { + /** Set when a hook blocked the tool — short-circuits remaining hooks. */ + blocked?: { reason: string; hookName: string }; + /** Final input after every hook's mutation (absent = untouched). */ + updatedInput?: Record; + /** Names of hooks that mutated the input (for the info message). */ + mutatedBy: string[]; +} diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 1d3dfed..7af929d 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -23,6 +23,7 @@ import { type CompressionRegistry, createRegistry, } from "./compress/index.js"; +import { createHookBus } from "./hooks/bus.js"; import type { CodeoidConfig } from "../config.js"; import { CAPABILITIES, PROTOCOL_VERSION, type AuthContext, type DaemonMessage } from "../protocol/types.js"; import { parseAuthMsg, parseClientMessage } from "@codeoid/protocol/schemas"; @@ -167,12 +168,19 @@ export class DaemonServer { ? createRegistry(config.fullConfig) : undefined; + // Build the hook bus once at startup — config-declared hooks dispatched + // at every session's seams, uniformly across backends (hooks/bus.ts). + const hooks = createHookBus(config.fullConfig); + if (hooks) { + console.log(`[codeoid] hooks: ${hooks.size} configured`); + } + const rateLimiter = new RateLimiter(); this.#manager = new SessionManager( this.#store, this.#transcriptStore, identityManager, rateLimiter, // Memory is wired post-construction via initMemory() — see start() undefined, - { config: config.fullConfig, compressionRegistry }, + { config: config.fullConfig, compressionRegistry, hooks }, ); // Register cleanup functions. ShutdownManager runs them LIFO, so the diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index b503b74..8517bbf 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -17,6 +17,7 @@ import { createDefaultProviderRegistry, type ProviderRegistry, } from "./providers/registry.js"; +import type { HookBus } from "./hooks/bus.js"; import type { Store } from "./store.js"; import { hasScope, SCOPES } from "../protocol/scopes.js"; import { RateLimiter } from "./rate-limit.js"; @@ -149,6 +150,8 @@ export class SessionManager { #dispatcher: Dispatcher; /** The daemon's provider catalog — one registry, shared by every session. */ #providers: ProviderRegistry; + /** The daemon's hook bus — one instance, shared by every session. */ + #hooks?: HookBus; #testProviderFactory?: () => SessionProvider; /** Stable observer identity — every Session reports status transitions here. */ #statusObserver = (sessionId: string, status: SessionInfo["status"]): void => { @@ -169,6 +172,11 @@ export class SessionManager { * Absent = the built-in catalog (claude, gemini, openai). */ providers?: ProviderRegistry; + /** + * The daemon's hook bus (built once at startup from config.hooks). + * Absent = no hooks; sessions pay zero overhead. + */ + hooks?: HookBus; /** * Test-only: provider factory injected into every Session this manager * constructs, so manager-level integration tests (conductor injection, @@ -186,6 +194,7 @@ export class SessionManager { this.#config = opts?.config; this.#compressionRegistry = opts?.compressionRegistry; this.#providers = opts?.providers ?? createDefaultProviderRegistry(opts?.config); + this.#hooks = opts?.hooks; this.#testProviderFactory = opts?._testProviderFactory; this.#dispatcher = new Dispatcher( store, @@ -273,6 +282,7 @@ export class SessionManager { store: this.#store, transcriptStore: this.#transcriptStore, providers: this.#providers, + hooks: this.#hooks, identityManager: this.#identityManager, existingId: meta.sessionId, memory: this.#memory, @@ -659,6 +669,7 @@ export class SessionManager { store: this.#store, transcriptStore: this.#transcriptStore, providers: this.#providers, + hooks: this.#hooks, ...(this.#identityManager ? { identityManager: this.#identityManager } : {}), @@ -1053,6 +1064,7 @@ export class SessionManager { store: this.#store, transcriptStore: this.#transcriptStore, providers: this.#providers, + hooks: this.#hooks, providerId: msg.providerId, identityManager: this.#identityManager, memory: this.#memory, @@ -1155,6 +1167,7 @@ export class SessionManager { store: this.#store, transcriptStore: this.#transcriptStore, providers: this.#providers, + hooks: this.#hooks, identityManager: this.#identityManager, memory: this.#memory, config: this.#config, @@ -1281,6 +1294,7 @@ export class SessionManager { store: this.#store, transcriptStore: this.#transcriptStore, providers: this.#providers, + hooks: this.#hooks, identityManager: this.#identityManager, memory: this.#memory, config: this.#config, diff --git a/src/daemon/session.ts b/src/daemon/session.ts index 64e5bd9..c5361ba 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -23,6 +23,8 @@ import { isSubagentEvent, } from "./providers/interface.js"; import { createDefaultProviderRegistry, type ProviderRegistry } from "./providers/registry.js"; +import type { HookBus } from "./hooks/bus.js"; +import type { HookSessionContext } from "./hooks/types.js"; import { randomUUID } from "node:crypto"; import type { AuthContext, @@ -203,6 +205,13 @@ export interface SessionCreateOptions { * constructing Session directly) a default registry is built on the fly. */ providers?: ProviderRegistry; + /** + * The daemon's hook bus (config-declared hooks dispatched at this + * session's seams — see hooks/bus.ts). Built once at startup and shared + * across sessions, conductor and workers included (tenant hooks apply + * uniformly). Absent = no hooks, zero overhead. + */ + hooks?: HookBus; /** * Provider override for testing. When present, replaces the registry * lookup so integration tests run without the Claude Agent SDK subprocess. @@ -241,6 +250,7 @@ export class Session { #fleet?: McpSdkServerConfigWithInstance; #compressionRegistry?: CompressionRegistry; #onModels?: SessionCreateOptions["onModels"]; + #hookBus?: HookBus; #status: SessionStatus = "idle"; /** Trailing-debounce timer coalescing persistence of ACTIVE status flips @@ -510,6 +520,7 @@ export class Session { this.#fleet = opts.fleet; this.#compressionRegistry = opts.compressionRegistry; this.#onModels = opts.onModels; + this.#hookBus = opts.hooks; // Tenant-scoped (auth carries account_id/project_id) so two accounts in // the same directory never share memory. this.#workspaceId = workspaceIdFromPath(opts.workdir, opts.auth); @@ -610,6 +621,23 @@ export class Session { providerId: this.#provider.id, }); } + + // Hook seam: session lifecycle. `resume` = rebuilt from persisted state + // (daemon restart) — hooks filter on `source` so they don't fire + // en-masse at boot. Fire-and-forget by contract. + this.#hookBus?.emit("session_start", this.#hookContext(), { + source: opts.existingId ? "resume" : "new", + }); + } + + /** Session identity stamped on every hook payload. */ + #hookContext(): HookSessionContext { + return { + sessionId: this.id, + sessionName: this.name, + workdir: this.workdir, + providerId: this.#provider.id, + }; } /** @@ -789,6 +817,12 @@ export class Session { // and refreshes every client's SessionInfo. this.#persistStatus(); this.#broadcastInfoUpdate(); + // Hook seam: observe-only. #hookContext() already reports the NEW id. + this.#hookBus?.emit("provider_switched", this.#hookContext(), { + from: previous, + to: requested, + seeded, + }); return { ok: true, providerId: requested }; } @@ -1425,6 +1459,18 @@ export class Session { }); }; + // Hook seam: a fresh turn is starting (mid-turn injections above don't + // re-fire this). Hooks may contribute a system-prompt append for THIS + // turn — composed after the stable base append so the cached prompt + // prefix is untouched when no hook contributes. + let hookPromptAppend: string | undefined; + if (this.#hookBus?.hasHooks("before_turn")) { + ({ systemPromptAppend: hookPromptAppend } = await this.#hookBus.dispatchBeforeTurn( + this.#hookContext(), + { prompt: effectivePrompt }, + )); + } + this.#accumulator.pushUserTurn(effectivePrompt); const run = this.#provider.runTurn({ history: this.#accumulator.history, @@ -1432,7 +1478,7 @@ export class Session { model: this.#model ?? undefined, fallbackModel: this.#fallbackModel ?? undefined, workdir: this.workdir, - systemPromptAppend: this.#buildPromptAppend(), + systemPromptAppend: this.#composePromptAppend(hookPromptAppend), canUseTool: this.#makeCanUseToolFn(sender), requestUserInput: (req) => this.requestUserInput(req), sender, @@ -1607,6 +1653,8 @@ export class Session { this.#statusPersistTimer = null; } this.#store.audit(sender.sub, "session.destroy", this.id); + // Hook seam: observe-only lifecycle notification. + this.#hookBus?.emit("session_end", this.#hookContext(), {}); // Tear down the streamInput loop cleanly before wiping storage so we // don't leave a zombie SDK subprocess alive holding the transcript file. await this.#teardownProvider(); @@ -2080,6 +2128,11 @@ export class Session { this.#persistAndBuffer(infoMsg); this.#broadcastRaw(infoMsg); this.#broadcastInfoUpdate(); + // Hook seam: observe-only lifecycle notification. + this.#hookBus?.emit("rotated", this.#hookContext(), { + reason, + rotationCount: this.#rotationCount, + }); } /** @@ -2158,6 +2211,16 @@ export class Session { return parts.length > 0 ? parts.join("\n\n") : undefined; } + /** + * Base prompt append plus a per-turn before_turn hook contribution. The + * hook part goes LAST so the stable base stays a cache-friendly prefix. + */ + #composePromptAppend(hookAppend?: string): string | undefined { + const base = this.#buildPromptAppend(); + if (!hookAppend) return base; + return base ? `${base}\n\n${hookAppend}` : hookAppend; + } + #buildMemoryPromptAppend(): string { const index = this.#indexScheduler?.get() ?? ""; if (!index) return MEMORY_SYSTEM_PROMPT_APPEND; @@ -2196,9 +2259,10 @@ export class Session { * * IMPORTANT: #peekAutoApprove is used for the initial tool_call message * state (UI only). If you call #shouldAutoApprove from tool_start too, the - * budget is decremented twice per tool call (once here, once there) because - * tool_start runs after canUseTool's synchronous section but before its - * first yield — the microtask ordering is deterministic. + * budget is decremented twice per tool call (once here, once there) — + * canUseTool's first yield lets the tool_start handler run before this + * gate, then the decision happens exactly once here (after the hook gate, + * so a hook-blocked tool never burns budget). */ #shouldAutoApprove(toolName: string): boolean { // HARD gate, checked before any mode logic: send-class fleet dispatch @@ -2291,19 +2355,70 @@ export class Session { } #makeCanUseToolFn(sender: AuthContext): ToolApprovalFn { - return async (_toolId, approvalId, toolName, inputObj) => { - const autoApprove = this.#shouldAutoApprove(toolName); - + return async (toolId, approvalId, toolName, inputObj) => { // Yield once so the tool_start event is processed by the event consumer - // (creating the SessionMessage) before we return the approval decision. + // (creating the SessionMessage) before hooks run or the approval + // decision is returned. await Promise.resolve(); + // Hook gate — the policy layer, run BEFORE the approval gate. A hook + // block is a policy deny that never prompts the user (and never burns + // the autonomous budget — #shouldAutoApprove runs after this); an + // input mutation feeds the same updatedInput path the approval + // sanitizer uses. Uniform across modes: a block wins even for + // auto-approved safe tools. + let effectiveInput = inputObj; + if (this.#hookBus?.hasHooks("tool_call", toolName)) { + const hookResult = await this.#hookBus.dispatchToolCall(this.#hookContext(), { + toolName, + toolId, + input: inputObj, + }); + if (hookResult.blocked) { + const { reason, hookName } = hookResult.blocked; + this.#approvalPatchKeys.delete(approvalId); + this.#resolveToolCallMessage(approvalId, { + phase: "cancelled", + reason: "denied", + message: `Blocked by hook "${hookName}": ${reason}`, + }); + this.#store.audit(sender.sub, "session.hook_block", this.id, `tool=${toolName} hook=${hookName}`); + const infoMsg = this.#makeMessage( + "info", + `🪝 Hook "${hookName}" blocked ${toolName}: ${reason}`, + SYSTEM_IDENTITY, + undefined, + undefined, + { event: "hook.blocked", hook: hookName, tool: toolName, reason }, + ); + this.#persistAndBuffer(infoMsg); + this.#broadcastRaw(infoMsg); + return { behavior: "deny" as const, message: `Blocked by hook "${hookName}": ${reason}` }; + } + if (hookResult.updatedInput) { + effectiveInput = hookResult.updatedInput; + this.#applyHookInputMutation(approvalId, effectiveInput); + const infoMsg = this.#makeMessage( + "info", + `🪝 Hook ${hookResult.mutatedBy.map((n) => `"${n}"`).join(", ")} updated the ${toolName} input`, + SYSTEM_IDENTITY, + undefined, + undefined, + { event: "hook.updated_input", hooks: hookResult.mutatedBy, tool: toolName }, + ); + this.#persistAndBuffer(infoMsg); + this.#broadcastRaw(infoMsg); + } + } + + const autoApprove = this.#shouldAutoApprove(toolName); + if (autoApprove) { this.#approvalIdToMessageId.delete(approvalId); // clean up — no manual approval will reference this this.#approvalPatchKeys.delete(approvalId); this.#store.audit(sender.sub, "session.auto_approve", this.id, `tool=${toolName} mode=${this.#mode}`); this.#setStatus("tool_running"); - return { behavior: "allow" as const, updatedInput: inputObj }; + return { behavior: "allow" as const, updatedInput: effectiveInput }; } // Manual approval — wait for user response. @@ -2312,40 +2427,21 @@ export class Session { this.#setStatus(approved ? "tool_running" : "thinking"); // Finalize the tool_call message in scrollback + transcript. - const msgId = this.#approvalIdToMessageId.get(approvalId); - if (msgId) { - // Approved → "executing" (tool hasn't run yet — tool_complete will - // set the final "completed" state with real output). Denied → "cancelled". - const resolvedState = ( - approved - ? { phase: "executing", input: inputObj } - : { phase: "cancelled", reason: "denied" } - ) as unknown as ToolState; - this.#scrollback.updateMessage(msgId, (m) => { - const sm = m as SessionMessage; - if (sm.tool) sm.tool.state = resolvedState; - }); - const toolMsg = this.#toolCallMessages.get(msgId); - if (toolMsg?.tool) { - const resolvedMsg: SessionMessage = { ...toolMsg, tool: { ...toolMsg.tool, state: resolvedState }, timestamp: new Date().toISOString() }; - this.#transcriptStore.append(this.id, resolvedMsg, this.#seq++).catch(() => {}); - this.#broadcastRaw({ - type: "session.message.delta", - sessionId: this.id, - messageId: msgId, - toolStateUpdate: resolvedState, - timestamp: resolvedMsg.timestamp, - }); - } - this.#approvalIdToMessageId.delete(approvalId); - } + // Approved → "executing" (tool hasn't run yet — tool_complete will + // set the final "completed" state with real output). Denied → "cancelled". + this.#resolveToolCallMessage( + approvalId, + approved + ? ({ phase: "executing", input: effectiveInput } as unknown as ToolState) + : { phase: "cancelled", reason: "denied" }, + ); const patchableKeys = this.#approvalPatchKeys.get(approvalId); this.#approvalPatchKeys.delete(approvalId); if (approved) { this.#store.audit(sender.sub, "session.approve", this.id, `tool=${toolName} approvalId=${approvalId}`); const sanitizedPatch = sanitizeApprovalPatch(toolName, updatedInput, patchableKeys); - const merged: Record = sanitizedPatch ? { ...inputObj, ...sanitizedPatch } : inputObj; + const merged: Record = sanitizedPatch ? { ...effectiveInput, ...sanitizedPatch } : effectiveInput; return { behavior: "allow" as const, updatedInput: merged }; } this.#store.audit(sender.sub, "session.deny", this.id, `tool=${toolName} approvalId=${approvalId}`); @@ -2353,6 +2449,63 @@ export class Session { }; } + /** + * Finalize the tool_call message for `approvalId` in scrollback + + * transcript and broadcast the state delta. Shared by the manual + * approval/deny path and the hook-block path so the two can't drift. + */ + #resolveToolCallMessage(approvalId: string, resolvedState: ToolState): void { + const msgId = this.#approvalIdToMessageId.get(approvalId); + if (!msgId) return; + this.#scrollback.updateMessage(msgId, (m) => { + const sm = m as SessionMessage; + if (sm.tool) sm.tool.state = resolvedState; + }); + const toolMsg = this.#toolCallMessages.get(msgId); + if (toolMsg?.tool) { + const resolvedMsg: SessionMessage = { ...toolMsg, tool: { ...toolMsg.tool, state: resolvedState }, timestamp: new Date().toISOString() }; + this.#transcriptStore.append(this.id, resolvedMsg, this.#seq++).catch(() => {}); + this.#broadcastRaw({ + type: "session.message.delta", + sessionId: this.id, + messageId: msgId, + toolStateUpdate: resolvedState, + timestamp: resolvedMsg.timestamp, + }); + } + this.#approvalIdToMessageId.delete(approvalId); + } + + /** + * A tool_call hook replaced the input before the approval gate — update + * the displayed tool message (scrollback + pending transcript copy) and + * broadcast the new state so an approval prompt shows what will ACTUALLY + * run, not the pre-mutation input. + */ + #applyHookInputMutation(approvalId: string, input: Record): void { + const msgId = this.#approvalIdToMessageId.get(approvalId); + if (!msgId) return; + const updateTool = (sm: SessionMessage): void => { + if (!sm.tool) return; + sm.tool.input = input; + if (sm.tool.state.phase === "waiting_confirmation") { + sm.tool.state = { ...sm.tool.state, input }; + } + }; + this.#scrollback.updateMessage(msgId, (m) => updateTool(m as SessionMessage)); + const toolMsg = this.#toolCallMessages.get(msgId); + if (toolMsg?.tool) { + updateTool(toolMsg); + this.#broadcastRaw({ + type: "session.message.delta", + sessionId: this.id, + messageId: msgId, + toolStateUpdate: toolMsg.tool.state, + timestamp: new Date().toISOString(), + }); + } + } + /** * True while event-stream silence is EXPECTED and the stall watchdog (and * the #sendInner liveness guard) must not treat it as a wedged turn: @@ -2717,15 +2870,30 @@ export class Session { } case "tool_complete": { - this.#accumulator.handleEvent(event); + // Hook seam: tool_result hooks may patch the RECORDED output — + // canonical history (fed to the accumulator below), scrollback, + // and transcript. The native backend already consumed the original + // inside its own agent loop; this governs what codeoid persists + // and what a switched-to backend later sees (redaction use case). const msgId = this.#toolUseIdToMessageId.get(event.sdkToolUseId); + const hookToolName = msgId ? this.#toolCallMessages.get(msgId)?.tool?.name : undefined; + let output = event.output; + if (this.#hookBus?.hasHooks("tool_result", hookToolName)) { + const patched = await this.#hookBus.dispatchToolResult(this.#hookContext(), { + toolName: hookToolName ?? "", + output, + success: event.success, + }); + if (patched.updatedOutput !== undefined) output = patched.updatedOutput; + } + this.#accumulator.handleEvent(output === event.output ? event : { ...event, output }); if (msgId) { const toolMsg = this.#toolCallMessages.get(msgId); if (toolMsg?.tool) { const completedState = { phase: "completed", success: event.success, - output: event.output, + output, } as unknown as ToolState; this.#scrollback.updateMessage(msgId, (m) => { const sm = m as SessionMessage; @@ -2867,6 +3035,10 @@ export class Session { case "turn_done": { this.#accumulator.handleEvent(event); this.#recordTurnFromResult(event.result); + // Hook seam: observe-only (git-checkpoint per turn, usage export). + this.#hookBus?.emit("after_turn", this.#hookContext(), { + result: event.result, + }); if (event.result.isError) { const errText = event.result.errorMessage ?? "Turn ended with an error"; const errorMsg = this.#makeMessage("system", `Error: ${errText}`, SYSTEM_IDENTITY, undefined, undefined, { event: "agent_error" }); diff --git a/src/tests/config.test.ts b/src/tests/config.test.ts index e6aa394..3415a6d 100644 --- a/src/tests/config.test.ts +++ b/src/tests/config.test.ts @@ -331,3 +331,62 @@ describe("loadConfig — workspace index tunables", () => { expect(c.workspaceIndex.episodeThreshold).toBe(25); }); }); + +describe("loadConfig — hooks", () => { + it("defaults to enabled with no entries", () => { + const c = loadConfig({ configPath, env: {} }); + expect(c.hooks).toEqual({ enabled: true, entries: [] }); + }); + + it("parses configured hook entries", () => { + writeConfig({ + hooks: { + entries: [ + { event: "tool_call", matcher: "^Bash$", type: "command", command: "./guard.sh" }, + { event: "after_turn", type: "webhook", url: "https://example.com/hook", timeoutMs: 5000 }, + ], + }, + }); + const c = loadConfig({ configPath, env: {} }); + expect(c.hooks?.enabled).toBe(true); + expect(c.hooks?.entries).toHaveLength(2); + expect(c.hooks?.entries[0]).toMatchObject({ event: "tool_call", type: "command", command: "./guard.sh" }); + expect(c.hooks?.entries[1]).toMatchObject({ event: "after_turn", type: "webhook", timeoutMs: 5000 }); + }); + + it("CODEOID_HOOKS_ENABLED=false kills every hook per-invocation", () => { + writeConfig({ + hooks: { + entries: [{ event: "tool_call", type: "command", command: "./guard.sh" }], + }, + }); + const c = loadConfig({ configPath, env: { CODEOID_HOOKS_ENABLED: "false" } }); + expect(c.hooks?.enabled).toBe(false); + expect(c.hooks?.entries).toHaveLength(1); // entries survive; the kill switch gates them + }); + + it("rejects a command hook without a command, a webhook without a url, and a bad matcher", () => { + writeConfig({ hooks: { entries: [{ event: "tool_call", type: "command" }] } }); + expect(() => loadConfig({ configPath, env: {} })).toThrow(/command/); + + writeConfig({ hooks: { entries: [{ event: "tool_call", type: "webhook" }] } }); + expect(() => loadConfig({ configPath, env: {} })).toThrow(/url/); + + writeConfig({ + hooks: { + entries: [{ event: "tool_call", type: "command", command: "x", matcher: "([unclosed" }], + }, + }); + expect(() => loadConfig({ configPath, env: {} })).toThrow(/regular expression/); + }); + + it("rejects an unknown hook event and an out-of-range timeout", () => { + writeConfig({ hooks: { entries: [{ event: "not_a_seam", type: "command", command: "x" }] } }); + expect(() => loadConfig({ configPath, env: {} })).toThrow(); + + writeConfig({ + hooks: { entries: [{ event: "tool_call", type: "command", command: "x", timeoutMs: 120_000 }] }, + }); + expect(() => loadConfig({ configPath, env: {} })).toThrow(); + }); +}); diff --git a/src/tests/hook-bus.test.ts b/src/tests/hook-bus.test.ts new file mode 100644 index 0000000..0ee77ee --- /dev/null +++ b/src/tests/hook-bus.test.ts @@ -0,0 +1,370 @@ +/** + * HookBus unit tests — command + webhook kinds, offline and deterministic. + * + * Command hooks use inline `/bin/sh -c` scripts (printf JSON, exit codes, + * env/stdin dumps to temp files) so no fixture binaries are needed. + * Webhook hooks run against a local Bun.serve on an ephemeral port. + * + * Coverage: + * - block via exit 0 + {"decision":"block"} and via exit code 2 + stderr + * - input mutation chains across hooks; block short-circuits + * - fail-open on: nonzero (non-2) exit, malformed JSON, timeout, + * webhook non-2xx, webhook network error + * - matcher regex gates tool_call/tool_result dispatch + * - env hardening: hook commands get the built env (no CODEOID_ or + * ZEROID_ leak), payload arrives on stdin + * - before_turn appends concatenate; tool_result output patch + * - emit() fire-and-forget reaches the hook + * - createHookBus gating (disabled / empty / populated) + */ + +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { HookBus, createHookBus } from "../daemon/hooks/bus.js"; +import type { HookEntryConfig, HookSessionContext } from "../daemon/hooks/types.js"; +import type { CodeoidConfig } from "../config.js"; + +let tmp: string; + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "codeoid-hooks-")); +}); + +afterEach(() => { + try { + rmSync(tmp, { recursive: true, force: true }); + } catch {} +}); + +function ctx(): HookSessionContext { + return { + sessionId: "s-1", + sessionName: "hooks-test", + workdir: tmp, + providerId: "mock", + }; +} + +function commandEntry( + command: string, + overrides: Partial = {}, +): HookEntryConfig { + return { event: "tool_call", type: "command", command, ...overrides }; +} + +/** Poll until `path` exists (fire-and-forget emit tests). */ +async function waitForFile(path: string, timeoutMs = 3000): Promise { + const deadline = Date.now() + timeoutMs; + while (!existsSync(path)) { + if (Date.now() > deadline) throw new Error(`file never appeared: ${path}`); + await new Promise((r) => setTimeout(r, 20)); + } +} + +describe("HookBus — command hooks", () => { + it("blocks via exit 0 + decision JSON", async () => { + const bus = new HookBus([ + commandEntry(`printf '{"decision":"block","reason":"policy says no"}'`, { + name: "no-writes", + }), + ]); + const result = await bus.dispatchToolCall(ctx(), { + toolName: "Write", + toolId: "t1", + input: { file_path: ".env" }, + }); + expect(result.blocked).toEqual({ reason: "policy says no", hookName: "no-writes" }); + }); + + it("blocks via exit code 2 with stderr as the reason", async () => { + const bus = new HookBus([commandEntry(`echo "nope from stderr" >&2; exit 2`)]); + const result = await bus.dispatchToolCall(ctx(), { + toolName: "Bash", + toolId: "t1", + input: { command: "rm -rf /" }, + }); + expect(result.blocked?.reason).toBe("nope from stderr"); + }); + + it("mutates input, chaining across hooks in declaration order", async () => { + const bus = new HookBus([ + commandEntry(`printf '{"updatedInput":{"file_path":"/step-one"}}'`, { name: "h1" }), + // The second hook sees the FIRST hook's mutation on stdin. + commandEntry( + `cat > "${tmp}/second-stdin.json"; printf '{"updatedInput":{"file_path":"/step-two"}}'`, + { name: "h2" }, + ), + ]); + const result = await bus.dispatchToolCall(ctx(), { + toolName: "Read", + toolId: "t1", + input: { file_path: "/original" }, + }); + expect(result.blocked).toBeUndefined(); + expect(result.updatedInput).toEqual({ file_path: "/step-two" }); + expect(result.mutatedBy).toEqual(["h1", "h2"]); + const secondPayload = JSON.parse(readFileSync(join(tmp, "second-stdin.json"), "utf8")); + expect(secondPayload.input).toEqual({ file_path: "/step-one" }); + }); + + it("block short-circuits later hooks", async () => { + const bus = new HookBus([ + commandEntry(`printf '{"decision":"block","reason":"first"}'`, { name: "blocker" }), + commandEntry(`touch "${tmp}/second-ran"`, { name: "later" }), + ]); + const result = await bus.dispatchToolCall(ctx(), { + toolName: "Bash", + toolId: "t1", + input: {}, + }); + expect(result.blocked?.hookName).toBe("blocker"); + // Give any (incorrect) second dispatch a moment to run before asserting. + await new Promise((r) => setTimeout(r, 100)); + expect(existsSync(join(tmp, "second-ran"))).toBe(false); + }); + + it("fails open on nonzero (non-2) exit, malformed JSON, and timeout", async () => { + const bus = new HookBus([ + commandEntry(`echo "crashed" >&2; exit 1`, { name: "crasher" }), + commandEntry(`printf '{not json'`, { name: "garbled" }), + commandEntry("sleep 30", { name: "slowpoke", timeoutMs: 150 }), + ]); + const result = await bus.dispatchToolCall(ctx(), { + toolName: "Bash", + toolId: "t1", + input: { command: "ls" }, + }); + expect(result.blocked).toBeUndefined(); + expect(result.updatedInput).toBeUndefined(); + }); + + it("passes the hardened env — no daemon secrets, payload on stdin", async () => { + const envFile = join(tmp, "env-dump"); + const stdinFile = join(tmp, "stdin-dump"); + const bus = new HookBus( + [commandEntry(`env > "${envFile}"; cat > "${stdinFile}"`)], + { + env: { + PATH: process.env.PATH, + HOME: "/home/hook-test", + CODEOID_API_KEY: "zeroid-root-key-DO-NOT-LEAK", + ZEROID_URL: "https://auth.example", + TELEGRAM_BOT_TOKEN: "tg-secret", + RANDOM_OTHER: "not-allowlisted", + }, + }, + ); + const result = await bus.dispatchToolCall(ctx(), { + toolName: "Bash", + toolId: "t1", + input: { command: "ls" }, + }); + expect(result.blocked).toBeUndefined(); + const env = readFileSync(envFile, "utf8"); + expect(env).toContain("HOME=/home/hook-test"); + expect(env).not.toContain("CODEOID_API_KEY"); + expect(env).not.toContain("zeroid-root-key"); + expect(env).not.toContain("ZEROID_URL"); + expect(env).not.toContain("TELEGRAM_BOT_TOKEN"); + expect(env).not.toContain("RANDOM_OTHER"); + const payload = JSON.parse(readFileSync(stdinFile, "utf8")); + expect(payload).toMatchObject({ + event: "tool_call", + toolName: "Bash", + toolId: "t1", + input: { command: "ls" }, + sessionId: "s-1", + sessionName: "hooks-test", + workdir: tmp, + providerId: "mock", + }); + }); + + it("matcher regex gates dispatch by tool name", async () => { + const bus = new HookBus([ + commandEntry(`printf '{"decision":"block","reason":"no bash"}'`, { + matcher: "^Bash$", + }), + ]); + expect(bus.hasHooks("tool_call", "Bash")).toBe(true); + expect(bus.hasHooks("tool_call", "Read")).toBe(false); + // A matcher entry never fires when the tool name is unknown. + expect(bus.hasHooks("tool_call")).toBe(false); + + const read = await bus.dispatchToolCall(ctx(), { + toolName: "Read", + toolId: "t1", + input: {}, + }); + expect(read.blocked).toBeUndefined(); + const bash = await bus.dispatchToolCall(ctx(), { + toolName: "Bash", + toolId: "t2", + input: {}, + }); + expect(bash.blocked?.reason).toBe("no bash"); + }); + + it("skips entries with invalid matchers instead of matching everything", () => { + const bus = new HookBus([ + commandEntry(`printf '{"decision":"block"}'`, { matcher: "([unclosed" }), + ]); + expect(bus.size).toBe(0); + expect(bus.hasHooks("tool_call", "Bash")).toBe(false); + }); + + it("before_turn appends concatenate in declaration order", async () => { + const bus = new HookBus([ + commandEntry(`printf '{"systemPromptAppend":"rule one"}'`, { event: "before_turn" }), + commandEntry(`printf '{"systemPromptAppend":"rule two"}'`, { event: "before_turn" }), + ]); + const result = await bus.dispatchBeforeTurn(ctx(), { prompt: "hello" }); + expect(result.systemPromptAppend).toBe("rule one\n\nrule two"); + }); + + it("tool_result hooks patch the recorded output", async () => { + const bus = new HookBus([ + commandEntry(`printf '{"updatedOutput":"[REDACTED]"}'`, { + event: "tool_result", + matcher: "^Read$", + }), + ]); + const patched = await bus.dispatchToolResult(ctx(), { + toolName: "Read", + output: "AWS_SECRET=hunter2", + success: true, + }); + expect(patched.updatedOutput).toBe("[REDACTED]"); + const untouched = await bus.dispatchToolResult(ctx(), { + toolName: "Bash", + output: "ok", + success: true, + }); + expect(untouched.updatedOutput).toBeUndefined(); + }); + + it("emit() fires observe hooks without blocking the caller", async () => { + const marker = join(tmp, "after-turn-ran"); + const bus = new HookBus([ + commandEntry(`cat > "${marker}"`, { event: "after_turn" }), + ]); + bus.emit("after_turn", ctx(), { result: { model: "mock-model" } }); + await waitForFile(marker); + const payload = JSON.parse(readFileSync(marker, "utf8")); + expect(payload.event).toBe("after_turn"); + expect(payload.result).toEqual({ model: "mock-model" }); + }); +}); + +describe("HookBus — webhook hooks", () => { + it("honors block + mutate outcomes from a 2xx JSON body and fails open otherwise", async () => { + const seen: unknown[] = []; + const server = Bun.serve({ + port: 0, + fetch: async (req) => { + const url = new URL(req.url); + seen.push(await req.json()); + if (url.pathname === "/block") { + return Response.json({ decision: "block", reason: "webhook says no" }); + } + if (url.pathname === "/mutate") { + return Response.json({ updatedInput: { file_path: "/from-webhook" } }); + } + return new Response("boom", { status: 500 }); + }, + }); + try { + const base = `http://127.0.0.1:${server.port}`; + const mk = (url: string): HookBus => + new HookBus([{ event: "tool_call", type: "webhook", url }]); + + const blocked = await mk(`${base}/block`).dispatchToolCall(ctx(), { + toolName: "Write", + toolId: "t1", + input: { file_path: "x" }, + }); + expect(blocked.blocked?.reason).toBe("webhook says no"); + expect(seen[0]).toMatchObject({ event: "tool_call", toolName: "Write" }); + + const mutated = await mk(`${base}/mutate`).dispatchToolCall(ctx(), { + toolName: "Write", + toolId: "t2", + input: { file_path: "x" }, + }); + expect(mutated.updatedInput).toEqual({ file_path: "/from-webhook" }); + + // Non-2xx → fail-open. + const failed = await mk(`${base}/oops`).dispatchToolCall(ctx(), { + toolName: "Write", + toolId: "t3", + input: { file_path: "x" }, + }); + expect(failed.blocked).toBeUndefined(); + expect(failed.updatedInput).toBeUndefined(); + } finally { + server.stop(true); + } + }); + + it("caps oversized webhook bodies instead of buffering them whole", async () => { + const server = Bun.serve({ + port: 0, + // 4 MiB of non-JSON — the reader must stop at MAX_OUTPUT_BYTES and + // the oversized (hence unparseable) body must fail open. + fetch: async () => new Response("x".repeat(4 * 1024 * 1024)), + }); + try { + const bus = new HookBus([ + { event: "tool_call", type: "webhook", url: `http://127.0.0.1:${server.port}/big` }, + ]); + const result = await bus.dispatchToolCall(ctx(), { + toolName: "Bash", + toolId: "t1", + input: {}, + }); + expect(result.blocked).toBeUndefined(); + expect(result.updatedInput).toBeUndefined(); + } finally { + server.stop(true); + } + }); + + it("fails open when the webhook is unreachable", async () => { + // Port 1 is reserved/closed — connection refused immediately. + const bus = new HookBus([ + { event: "tool_call", type: "webhook", url: "http://127.0.0.1:1/hook", timeoutMs: 500 }, + ]); + const result = await bus.dispatchToolCall(ctx(), { + toolName: "Bash", + toolId: "t1", + input: {}, + }); + expect(result.blocked).toBeUndefined(); + }); +}); + +describe("createHookBus", () => { + const entry: HookEntryConfig = { event: "tool_call", type: "command", command: "true" }; + + it("returns undefined when hooks are absent, disabled, or empty", () => { + expect(createHookBus(undefined)).toBeUndefined(); + expect(createHookBus({} as CodeoidConfig)).toBeUndefined(); + expect( + createHookBus({ hooks: { enabled: false, entries: [entry] } } as unknown as CodeoidConfig), + ).toBeUndefined(); + expect( + createHookBus({ hooks: { enabled: true, entries: [] } } as unknown as CodeoidConfig), + ).toBeUndefined(); + }); + + it("builds a bus from configured entries", () => { + const bus = createHookBus({ + hooks: { enabled: true, entries: [entry] }, + } as unknown as CodeoidConfig); + expect(bus).toBeDefined(); + expect(bus?.size).toBe(1); + expect(bus?.hasHooks("tool_call", "Bash")).toBe(true); + }); +}); diff --git a/src/tests/session-hooks.test.ts b/src/tests/session-hooks.test.ts new file mode 100644 index 0000000..6d44eb7 --- /dev/null +++ b/src/tests/session-hooks.test.ts @@ -0,0 +1,374 @@ +/** + * Session ↔ HookBus integration tests — MockSessionProvider harness, offline. + * + * Exercises the daemon-native hook seams end-to-end through Session: + * + * H1 tool_call block — the hook denies the tool BEFORE the approval gate: + * the provider sees behavior "deny", the tool message resolves to + * cancelled, an info message explains which hook blocked and why, + * and (in autonomous mode) the turn budget is NOT decremented. + * + * H2 tool_call block wins even for auto-approved safe tools. + * + * H3 tool_call mutation — the provider receives the hook's updatedInput + * and an info message records the mutation. + * + * H4 before_turn — the hook's systemPromptAppend reaches the provider's + * TurnOpts, appended after the session's base append. + * + * H5 tool_result patch — the completed tool message carries the patched + * output (redaction), and the canonical history records it. + * + * H6 Lifecycle observe hooks — session_start and after_turn fire with + * the session context. + * + * H7 No hooks configured — zero behavioral change (guarded dispatch). + */ + +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { Store } from "../daemon/store.js"; +import { TranscriptStore } from "../daemon/transcript.js"; +import { Session, type AttachedClient } from "../daemon/session.js"; +import { MockSessionProvider } from "../daemon/providers/mock/session-provider.js"; +import { mockResult } from "../daemon/providers/mock/index.js"; +import { HookBus } from "../daemon/hooks/bus.js"; +import type { HookEntryConfig } from "../daemon/hooks/types.js"; +import type { DaemonMessage, AuthContext } from "../protocol/types.js"; +import type { ProviderEvent } from "../daemon/providers/interface.js"; + +const TEST_AUTH: AuthContext = { + sub: "user:test-hooks", + scopes: [], + delegationDepth: 0, + accountId: "acc-hooks", + projectId: "proj-hooks", +}; + +let tmp: string; +let store: Store; +let transcriptStore: TranscriptStore; + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "codeoid-session-hooks-")); + store = new Store(join(tmp, "codeoid.db")); + transcriptStore = new TranscriptStore(join(tmp, "transcripts")); +}); + +afterEach(async () => { + await new Promise((r) => setTimeout(r, 100)); + try { + await transcriptStore.flush(); + } catch {} + try { + store.close(); + } catch {} + try { + rmSync(tmp, { recursive: true, force: true }); + } catch {} +}); + +function commandEntry( + command: string, + overrides: Partial = {}, +): HookEntryConfig { + return { event: "tool_call", type: "command", command, ...overrides }; +} + +function makeSession( + provider: MockSessionProvider, + hooks?: HookBus, + initialMode?: { mode: "guarded" | "autonomous" | "interactive"; maxTurns?: number }, +): Session { + const id = randomUUID(); + store.createSession({ + id, + name: "hooks-integ", + workdir: tmp, + status: "idle", + createdBy: TEST_AUTH.sub, + createdAt: new Date().toISOString(), + attachedClients: 0, + accountId: TEST_AUTH.accountId!, + projectId: TEST_AUTH.projectId!, + }); + return new Session({ + name: "hooks-integ", + workdir: tmp, + auth: TEST_AUTH, + store, + transcriptStore, + existingId: id, + _testProvider: provider, + hooks, + ...(initialMode ? { initialMode } : {}), + }); +} + +function makeClient(): { client: AttachedClient; received: DaemonMessage[] } { + const received: DaemonMessage[] = []; + return { + client: { id: randomUUID(), auth: TEST_AUTH, send: (msg) => received.push(msg) }, + received, + }; +} + +function waitForIdle(session: Session, timeoutMs = 8000): Promise { + if (session.status === "idle" || session.status === "error") return Promise.resolve(); + return new Promise((resolve, reject) => { + const watcherId = randomUUID(); + const timer = setTimeout(() => { + session.detach(watcherId); + reject(new Error(`session did not reach idle within ${timeoutMs}ms — status=${session.status}`)); + }, timeoutMs); + const watcher: AttachedClient = { + id: watcherId, + auth: TEST_AUTH, + send(msg) { + if ( + msg.type === "session.status_change" && + (msg.status === "idle" || msg.status === "error") + ) { + clearTimeout(timer); + session.detach(watcherId); + resolve(); + } + }, + }; + session.attach(watcher); + }); +} + +/** One scripted turn: a single Bash tool call, then a text reply. */ +function bashToolTurn(): ProviderEvent[] { + return [ + { + type: "tool_start", + toolId: "t1", + sdkToolUseId: "sdk-t1", + name: "Bash", + input: { command: "rm -rf /tmp/x" }, + approvalId: "a1", + }, + { type: "text_done", content: "done" }, + { type: "turn_done", result: mockResult({ providerId: "mock-session" }) }, + ]; +} + +describe("Session hook integration", () => { + it("H1: tool_call hook blocks before the approval gate (no budget burn)", async () => { + const hooks = new HookBus([ + commandEntry(`printf '{"decision":"block","reason":"env files are off-limits"}'`, { + name: "env-guard", + matcher: "^Bash$", + }), + ]); + const provider = new MockSessionProvider("mock-session", [bashToolTurn()]); + // Autonomous with a budget: a hook block must not decrement it. + const session = makeSession(provider, hooks, { mode: "autonomous", maxTurns: 5 }); + const { client, received } = makeClient(); + session.attach(client); + + await session.send("run it", TEST_AUTH); + await waitForIdle(session); + + expect(provider.canUseToolResults).toHaveLength(1); + expect(provider.canUseToolResults[0]).toMatchObject({ + behavior: "deny", + message: 'Blocked by hook "env-guard": env files are off-limits', + }); + // Budget untouched — the hook gate runs before #shouldAutoApprove. + expect(session.turnsRemaining).toBe(5); + + // The user sees WHY: an info message tagged hook.blocked. + const info = received.find( + (m) => + m.type === "session.message" && + m.metadata?.event === "hook.blocked", + ); + expect(info).toBeDefined(); + if (info?.type === "session.message") { + expect(info.content).toContain("env-guard"); + expect(info.content).toContain("env files are off-limits"); + expect(info.metadata?.tool).toBe("Bash"); + } + + // The tool message resolved to cancelled with the hook's explanation. + const cancelled = received.find( + (m) => + m.type === "session.message.delta" && + m.toolStateUpdate?.phase === "cancelled", + ); + expect(cancelled).toBeDefined(); + if (cancelled?.type === "session.message.delta" && cancelled.toolStateUpdate?.phase === "cancelled") { + expect(cancelled.toolStateUpdate.message).toContain("env-guard"); + } + }); + + it("H2: a hook block wins even for auto-approved safe tools", async () => { + const hooks = new HookBus([ + commandEntry(`printf '{"decision":"block","reason":"no reads today"}'`, { + matcher: "^Read$", + }), + ]); + const provider = new MockSessionProvider("mock-session", [ + [ + { + type: "tool_start", + toolId: "t1", + sdkToolUseId: "sdk-t1", + name: "Read", + input: { file_path: "/etc/passwd" }, + approvalId: "a1", + }, + { type: "text_done", content: "done" }, + { type: "turn_done", result: mockResult({ providerId: "mock-session" }) }, + ], + ]); + const session = makeSession(provider, hooks); + await session.send("read it", TEST_AUTH); + await waitForIdle(session); + + expect(provider.canUseToolResults[0]?.behavior).toBe("deny"); + }); + + it("H3: tool_call hook mutation reaches the provider as updatedInput", async () => { + const hooks = new HookBus([ + commandEntry(`printf '{"updatedInput":{"command":"echo SAFE"}}'`, { + name: "rewriter", + matcher: "^Bash$", + }), + ]); + const provider = new MockSessionProvider("mock-session", [bashToolTurn()]); + const session = makeSession(provider, hooks, { mode: "autonomous" }); + const { client, received } = makeClient(); + session.attach(client); + + await session.send("run it", TEST_AUTH); + await waitForIdle(session); + + expect(provider.canUseToolResults).toHaveLength(1); + expect(provider.canUseToolResults[0]).toMatchObject({ + behavior: "allow", + updatedInput: { command: "echo SAFE" }, + }); + const info = received.find( + (m) => m.type === "session.message" && m.metadata?.event === "hook.updated_input", + ); + expect(info).toBeDefined(); + if (info?.type === "session.message") { + expect(info.metadata?.hooks).toEqual(["rewriter"]); + } + }); + + it("H4: before_turn hook append reaches the provider's TurnOpts", async () => { + const hooks = new HookBus([ + commandEntry(`printf '{"systemPromptAppend":"Always answer in haiku."}'`, { + event: "before_turn", + }), + ]); + const provider = new MockSessionProvider("mock-session", [ + [ + { type: "text_done", content: "ok" }, + { type: "turn_done", result: mockResult({ providerId: "mock-session" }) }, + ], + ]); + const session = makeSession(provider, hooks); + await session.send("hello", TEST_AUTH); + await waitForIdle(session); + + expect(provider.capturedOpts).toHaveLength(1); + expect(provider.capturedOpts[0]?.systemPromptAppend).toContain("Always answer in haiku."); + }); + + it("H5: tool_result hook patches the recorded output and canonical history", async () => { + const hooks = new HookBus([ + commandEntry(`printf '{"updatedOutput":"[REDACTED BY HOOK]"}'`, { + event: "tool_result", + matcher: "^Bash$", + }), + ]); + const provider = new MockSessionProvider("mock-session", [ + [ + { + type: "tool_start", + toolId: "t1", + sdkToolUseId: "sdk-t1", + name: "Bash", + input: { command: "cat secrets.txt" }, + approvalId: "a1", + }, + { type: "tool_complete", sdkToolUseId: "sdk-t1", output: "AWS_SECRET=hunter2", success: true }, + { type: "text_done", content: "done" }, + { type: "turn_done", result: mockResult({ providerId: "mock-session" }) }, + ], + ]); + const session = makeSession(provider, hooks, { mode: "autonomous" }); + const { client, received } = makeClient(); + session.attach(client); + + await session.send("run it", TEST_AUTH); + await waitForIdle(session); + + const completed = received.find( + (m) => + m.type === "session.message.delta" && + m.toolStateUpdate?.phase === "completed", + ); + expect(completed).toBeDefined(); + if (completed?.type === "session.message.delta" && completed.toolStateUpdate?.phase === "completed") { + expect(completed.toolStateUpdate.output).toBe("[REDACTED BY HOOK]"); + expect(completed.toolStateUpdate.output).not.toContain("hunter2"); + } + }); + + it("H6: session_start and after_turn observe hooks fire with session context", async () => { + const marker = join(tmp, "lifecycle.jsonl"); + const hooks = new HookBus([ + commandEntry(`cat >> "${marker}"; printf '\\n' >> "${marker}"`, { event: "session_start" }), + commandEntry(`cat >> "${marker}"; printf '\\n' >> "${marker}"`, { event: "after_turn" }), + ]); + const provider = new MockSessionProvider("mock-session", [ + [ + { type: "text_done", content: "ok" }, + { type: "turn_done", result: mockResult({ providerId: "mock-session" }) }, + ], + ]); + const session = makeSession(provider, hooks); + await session.send("hello", TEST_AUTH); + await waitForIdle(session); + + // Fire-and-forget hooks — poll for both lines to land. + const deadline = Date.now() + 3000; + let lines: string[] = []; + while (Date.now() < deadline) { + if (existsSync(marker)) { + lines = readFileSync(marker, "utf8").split("\n").filter((l) => l.trim().length > 0); + if (lines.length >= 2) break; + } + await new Promise((r) => setTimeout(r, 25)); + } + expect(lines.length).toBeGreaterThanOrEqual(2); + const events = lines.map((l) => JSON.parse(l)); + const start = events.find((e) => e.event === "session_start"); + const after = events.find((e) => e.event === "after_turn"); + expect(start).toMatchObject({ source: "resume", sessionName: "hooks-integ" }); + expect(after?.result?.model).toBeDefined(); + expect(after?.sessionId).toBe(session.id); + }); + + it("H7: without a bus, tool flow is unchanged", async () => { + const provider = new MockSessionProvider("mock-session", [bashToolTurn()]); + const session = makeSession(provider, undefined, { mode: "autonomous", maxTurns: 5 }); + await session.send("run it", TEST_AUTH); + await waitForIdle(session); + + expect(provider.canUseToolResults).toHaveLength(1); + expect(provider.canUseToolResults[0]?.behavior).toBe("allow"); + // Budget decremented exactly once — the pre-hook contract holds. + expect(session.turnsRemaining).toBe(4); + }); +});