skarn guardhook events
What the real-time guard gates on each AI coding host: the content every hook event scans, how a block is emitted, and which events the shipped configs wire by default.
skarn 0.24.0
How the guard decides
The host pipes a pending action to skarn guard on stdin - a tool call, a submitted prompt, a spawning subagent, a resolved batch of tool outputs - and the guard scans that single action with the full detection engine before it proceeds. It returns one of three verdicts: deny (block the action), ask (escalate to the user's permission prompt), or allow (silent - nothing is emitted at all). Skarn only ever speaks up to tighten, never to auto-approve.
Where a host offers no ask channel, a would-be ask degrades to a block rather than to an allow. Every reason names the matched rule and a redacted preview - never the raw secret. In audit mode (the default, and the only mode an unlicensed guard runs) the guard never decides: it reports the would-be verdict on a non-deciding channel and writes it to the guard log, so you can measure your real would-block rate before enforcing.
This page is generated from the same model that generates the binary's own manual page, and a build gate asserts it against the guard's source and the shipped hook configs - so it describes what the shipped binary does, not what it once did. The equivalent offline reference is man skarn-guard.
Claude Code
Claude Code fires PascalCase events with a JSON envelope on stdin. Five events are wired by default; the two output-scanning events are supported but opt-in because they fire on every tool result.
Routing. Codex shares Claude's PreToolUse and PermissionRequest names, so auto-detection keys on a Codex-only tell (turn_id, or the apply_patch/spawn_agent tools); a Claude event carries none of them and routes to the Claude adapter. VS Code Copilot also shares the envelope, so its own tool-name vocabulary routes it to the Copilot adapter. The shipped Claude PermissionRequest hooks pass --agent claude explicitly anyway.
Fail mode. A non-zero guard exit fails closed on the blocking events. An unparseable event asks in enforce mode.
| Event | Scans | Enforce | Audit | Shipped |
|---|---|---|---|---|
PreToolUse |
tool_name plus the flattened tool_input; a Bash command is also carried raw so a tool description cannot poison the package parse | hookSpecificOutput.permissionDecision = deny or ask, with the redacted reason in permissionDecisionReason | hookSpecificOutput.additionalContext | yes |
PermissionRequest |
the same tool_name plus tool_input as PreToolUse, at the permission-decision step | hookSpecificOutput.decision.behavior = deny, with the redacted reason on the universal systemMessage | systemMessage | yes |
UserPromptSubmit |
prompt | top-level decision = block, with the redacted reason in reason | hookSpecificOutput.additionalContext | yes |
UserPromptExpansion |
command_name and expanded_prompt | top-level decision = block, with the redacted reason in reason | hookSpecificOutput.additionalContext | yes |
TaskCreated |
task_title and task_description | continue = false, with the redacted reason in stopReason (rolls the task creation back) | systemMessage | yes |
PostToolUse |
tool_output (falling back to tool_response) of an in-scope tool | top-level decision = block, with the redacted reason in reason | hookSpecificOutput.additionalContext | opt-in |
PostToolBatch |
tool_output and tool_error of every in-scope call in tool_calls[] | top-level decision = block, with the redacted reason in reason | hookSpecificOutput.additionalContext | opt-in |
Stop |
nothing from the event: it triggers a throttled, scoped, offline scan of recent sessions on this machine | never blocks (advisory only) | systemMessage with suppressOutput | yes |
"Shipped" means the hook configs in the Skarn repository wire the event by default. An opt-in event is fully supported by the binary - add a block for it to your own config to enable it.
Cursor
Cursor fires camelCase events, each with its own stdin shape and its own verdict schema - unlike Claude's single envelope. All four blocking events are wired by default.
Routing. The camelCase event names are unique to Cursor, so auto-detection is unambiguous.
Fail mode. Fail-open by default; --strict plus enforce exits 2 (Cursor's schema-agnostic hard block) on an unparseable event.
| Event | Scans | Enforce | Audit | Shipped |
|---|---|---|---|---|
beforeShellExecution |
command (mapped to the canonical Bash tool) | permission = deny or ask, with the redacted reason in user_message and agent_message | none - Cursor's blocking events carry no non-deciding channel, so audit stays silent and relies on the guard log | yes |
beforeReadFile |
file_path as the input and content as the result (credential-file recon and dotenv contents) | permission = deny (allow/deny only, so a would-be ask degrades to deny) | none (see beforeShellExecution) | yes |
beforeMCPExecution |
tool_input, under the canonical mcp__<server>__<tool> name | permission = deny or ask, with the redacted reason in user_message and agent_message | none (see beforeShellExecution) | yes |
beforeSubmitPrompt |
prompt | continue = false, with the redacted reason in user_message | none (see beforeShellExecution) | yes |
"Shipped" means the hook configs in the Skarn repository wire the event by default. An opt-in event is fully supported by the binary - add a block for it to your own config to enable it.
Codex CLI
Codex's hook system mirrors Claude's - PascalCase events and a near-identical PreToolUse JSON - but its verdict semantics differ: permissionDecision ask and a bare allow both FAIL OPEN, so a would-be ask degrades to deny. Codex fires more events than Skarn wires; the rest are evaluated and deliberately left off the matrix (verified against the OpenAI Hooks guide, 2026-07-21): SessionStart and SubagentStart carry only session and subagent metadata (model, permission_mode, agent_id/type), no credential-bearing tool or prompt content; PreCompact and PostCompact carry only a compaction trigger over already-seen transcript the recall scan covers; SubagentStop carries last_assistant_message (so it is NOT content-free), but that post-hoc subagent output lands in the session store the recall scan and the turn-end advisory already cover, so it is a deliberate deferral pending the cross-action daemon the codex README names. Codex has no SessionEnd event in the current reference; if one lands in a later release it is additive.
Routing. Codex shares Claude's event names, so pass --agent codex explicitly. Auto-detection falls back to a Codex-only tell: turn_id (on every turn-scoped event, PermissionRequest included) or the apply_patch/spawn_agent tools.
Fail mode. Fail-open by default; --strict plus enforce emits an explicit deny JSON on an unparseable event.
| Event | Scans | Enforce | Audit | Shipped |
|---|---|---|---|---|
PreToolUse |
tool_name plus the flattened tool_input; only Bash carries a raw shell command (apply_patch's command field is patch text) | hookSpecificOutput.permissionDecision = deny, with the redacted reason in permissionDecisionReason | continue = true plus systemMessage (Codex PreToolUse has no additionalContext) | yes |
PermissionRequest |
the same tool_name plus tool_input as PreToolUse | hookSpecificOutput.decision.behavior = deny, with the redacted reason in message | systemMessage | yes |
UserPromptSubmit |
prompt | top-level decision = block, with the redacted reason in reason | systemMessage | yes |
PostToolUse |
tool_response of an in-scope tool | top-level decision = block, with the redacted reason in reason | systemMessage | opt-in |
Stop |
nothing from the event: it triggers a throttled, scoped, offline scan of this machine's recent codex sessions | never blocks (advisory only) | systemMessage with suppressOutput; it carries no decision, so Codex never continues the turn | yes |
"Shipped" means the hook configs in the Skarn repository wire the event by default. An opt-in event is fully supported by the binary - add a block for it to your own config to enable it.
GitHub Copilot CLI
The Copilot CLI's native payload is camelCase (toolName / toolArgs / sessionId) and carries no event-name field, so the event is inferred from the field shape - except permissionRequest, whose payload is a subset of preToolUse's and which the config entry therefore names with --event permissionRequest. preToolUse and permissionRequest both block; the other events run full detection and write the audit log but cannot decide. GitHub documents fourteen Copilot CLI hook events; Skarn declares five of them (preToolUse, permissionRequest, userPromptSubmitted, userPromptTransformed, postToolUse) plus preMcpToolCall, and evaluates the rest as deliberately not wired (verified against the GitHub hooks reference, 2026-07-30): sessionStart carries an initialPrompt (scannable), but a session-open detection arm beyond userPromptTransformed is out of this round's scope; subagentStart and subagentStop carry subagent metadata (agentName, transcriptPath), and the subagent's own tool calls are gated by preToolUse while any post-hoc output lands in the session store the recall scan covers; postToolUseFailure carries the failed call's toolArgs plus its error text, which can hold output preToolUse never saw, so leaving it unwired is a deliberate coverage deferral of the same class as the opt-in postToolUse output scan; errorOccurred and notification carry human-readable error and message text (a low-value surface deferred this round, not a claim they can never carry a secret); preCompact carries a compaction trigger and any customInstructions over already-seen transcript; agentStop (Stop) and sessionEnd carry only a stopReason/reason over transcript the recall scan covers. Every one of these is a deliberate coverage deferral, not an assertion that the event can never carry a credential.
Routing. The camelCase field set is the CLI tell. A Copilot payload in Claude-compat mode is byte-indistinguishable from a real Claude event (field names AND tool names), so the shipped Copilot configs always pass --agent copilot explicitly. The CLI loads hooks from policy, then the repository, then the user, then inline repository settings, then inline user settings, then plugins, and how several hooks for one event compose differs by event. On preToolUse any hook returning deny blocks the tool, so a Skarn deny there is final; measured on CLI 1.0.70 the entries after the denying one did not run at all, and on 1.0.61 under SDK 1.0.1 the host's own SDK-registered callback was never invoked for that call either. On permissionRequest every configured hook runs and their outputs are MERGED with later outputs overriding earlier ones, so a Skarn deny there is not final: a hook loading after it that answers allow replaces the decision. That is why preToolUse remains the durable gate and permissionRequest is a second chokepoint rather than a stronger one. A top-level disableAllHooks: true in the repository's .github/copilot/settings.json skips the user- and repository-delivered hooks for that repository, the operator's own included, and logs nothing when it does; whether machine policy hooks survive it is unverified. It silences the layers it reaches across events (sessionStart as well as preToolUse) and leaves the host application's own SDK-registered callbacks running, so doctor reads that file and reports the file layer as inert rather than counting it as protection or calling the machine unprotected.
Fail mode. preToolUse fails CLOSED on a non-zero exit, but a hook TIMEOUT fails OPEN (the shipped configs set timeoutSec 10). permissionRequest treats exit 2 as a deny.
| Event | Scans | Enforce | Audit | Shipped |
|---|---|---|---|---|
preToolUse |
toolName plus toolArgs (an object, or a JSON string that is parsed and flattened) | top-level permissionDecision = deny or ask, with the redacted reason in permissionDecisionReason | additionalContext | yes |
permissionRequest |
toolName, plus toolArgs when the payload carries it | top-level behavior = deny, with the redacted reason in message; that decision short-circuits the normal permission flow, but not other hooks - every configured permissionRequest hook runs and later outputs override earlier ones, so a hook loading after Skarn can answer allow and replace the deny | none | yes |
userPromptSubmitted |
prompt | nothing is emitted - the CLI ignores this event's hook output | none | yes |
userPromptTransformed |
transformedPrompt (the transformed, model-facing prompt text; the payload also carries the pre-transformation prompt, and this row is inferred on transformedPrompt before the prompt-only userPromptSubmitted branch) | nothing is emitted - this event's only output channel is modifiedTransformedPrompt, which rewrites the model-facing prompt, and Skarn declines that mutation channel by policy; there is no block or decision channel on this event, so it is detection and audit-log ONLY | none | opt-in |
preMcpToolCall |
arguments, under the canonical mcp__<server>__<tool> name | nothing is emitted - this event's output controls only MCP request metadata | none | opt-in |
postToolUse |
toolResult | advisory only - never blocks | additionalContext | opt-in |
"Shipped" means the hook configs in the Skarn repository wire the event by default. An opt-in event is fully supported by the binary - add a block for it to your own config to enable it.
GitHub Copilot (VS Code agent mode)
VS Code agent mode speaks a second Copilot dialect: PascalCase event names in a Claude-shaped envelope, camelCase tool_input keys, and VS Code's own tool-name vocabulary (run_in_terminal, create_file, ...), which Skarn maps to canonical names. MCP calls arrive as PreToolUse with an mcp_<server>_<tool> name.
Routing. VS Code discovers hooks from ~/.claude/settings.json (chat.hookFilesLocations), so a machine already running Skarn's Claude guard receives VS Code Copilot events. The VS Code tool-name vocabulary routes them to the Copilot adapter instead of a silent Claude fast-allow; the shipped configs pin --agent copilot.
Fail mode. VS Code blocks only on exit 2; any other non-zero exit fails OPEN.
| Event | Scans | Enforce | Audit | Shipped |
|---|---|---|---|---|
PreToolUse |
tool_name (VS Code vocabulary, mapped to canonical) plus the flattened tool_input | hookSpecificOutput.permissionDecision = deny or ask - both fully honored (the exact inverse of the CLI, where the decision is top-level) | systemMessage | yes |
UserPromptSubmit |
prompt | top-level decision = block, with the redacted reason in reason | systemMessage | yes |
PostToolUse |
tool_response | top-level decision = block, with the redacted reason in reason | systemMessage | opt-in |
"Shipped" means the hook configs in the Skarn repository wire the event by default. An opt-in event is fully supported by the binary - add a block for it to your own config to enable it.
Gemini CLI
Gemini CLI fires eleven PascalCase events with a JSON envelope on stdin; hooks ship on by default since v0.26.0. Skarn gates three of them - BeforeTool (the enforcing pre-execution gate), BeforeAgent (the submitted prompt, an egress channel), and AfterTool (the tool result, opt-in) - and every other event fast-allows. The stdout protocol is strict: the host requires that a hook print nothing to stdout but its final JSON object, so the guard's emit arm is the sole stdout writer and an allow prints nothing at all. Gemini's PUBLISHED output schema documents only allow and deny (alias block), with no ask value; gemini-cli 0.49.0 does route an undocumented decision "ask" on BeforeTool to an interactive confirmation, but skarn deliberately does not emit it: a version that drops the undocumented behavior would read "ask" as no decision and let the call through, so a would-be ask degrades to deny (fail-closed on an undocumented channel). Timeouts are MILLISECONDS here, not seconds (the shipped entries use 5000).
Routing. The Before*/After* event vocabulary is unique to Gemini - no other host uses those names - so auto-detection is unambiguous; the shipped configs pin --agent gemini anyway, the same policy every other host follows. MCP tools arrive named mcp_<server>_<tool> with SINGLE underscores, so the guard keys MCP treatment on that prefix rather than on the canonical mcp__ form.
Fail mode. A non-2 non-zero exit is a warning and fails open; exit 2 blocks with stderr as the reason. --strict plus enforce exits 2 on an unparseable event.
| Event | Scans | Enforce | Audit | Shipped |
|---|---|---|---|---|
BeforeTool |
tool_name (mapped from Gemini's own vocabulary: run_shell_command, write_file, replace, read_file, read_many_files, web_fetch, google_web_search, invoke_agent) plus the flattened tool_input; a run_shell_command command is also carried raw so a tool description cannot poison the package parse | top-level decision = deny, with the redacted reason in reason (a would-be ask degrades to deny: the published schema documents no ask value, and the undocumented one gemini-cli 0.49.0 honors here would fail open on any version that drops it) | systemMessage | yes |
BeforeAgent |
prompt | top-level decision = deny, with the redacted reason in reason | systemMessage | yes |
AfterTool |
tool_response, an object carrying llmContent, returnDisplay, and an optional error, flattened into the scanned bytes | top-level decision = deny, with the reason replacing the tool result before the model sees it | systemMessage | opt-in |
"Shipped" means the hook configs in the Skarn repository wire the event by default. An opt-in event is fully supported by the binary - add a block for it to your own config to enable it.
Wiring it up
skarn setup detects the AI coding agents installed on the machine and merges the guard hook into each one's native config in audit mode - existing hooks untouched, every modified file backed up and rewritten atomically, and the run ends with a self-test that proves the wiring end to end. skarn doctor then verifies it stays alive: it names any event your installed config does not gate, and it catches a dead hook (a wired command whose binary no longer exists), which every host silently fails open on.
Full option reference: the guard, setup, and doctor sections of the manual.