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.19.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.

EventScansEnforceAuditShipped
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.

PreToolUse
A deny blocks the call even under bypassPermissions: hooks run before permission-mode checks.
PermissionRequest
A second chokepoint for a call PreToolUse deferred or allowed. The decision has only allow/deny and no reason field, so a would-be ask degrades to deny and additionalContext is invalid here.
UserPromptSubmit
The channel ships content off the machine, so a secret on it is exfiltration. A lone secret is enough to deny here - the destination is not ambiguous. The block is recoverable: the user edits and resubmits.
UserPromptExpansion
The channel ships content off the machine, so a secret on it is exfiltration. The post-expansion text of a slash or custom command - content UserPromptSubmit never sees - gated before it reaches the model.
TaskCreated
The channel ships content off the machine, so a secret on it is exfiltration. A task is subagent fan-out, so it is modeled as the canonical spawn_agent tool. The event has no decision channel and does not support additionalContext.
PostToolUse
Output scanning: catches a secret in a tool RESULT before the model sees it. Opt-in - it fires on every tool result. Prefer PostToolBatch, which sees the same content once per batch.
PostToolBatch
The batch analog of PostToolUse: one invocation per resolved parallel batch, so the cheaper of the two output gates. Opt-in. The batch is scanned as one synthetic interaction named after its first in-scope call.
Stop
The session-end advisory. At most once an hour it reports a redacted count and the highest severity of credential exposures found in recent sessions, and points at skarn assess. Never egresses, never prints a secret value.

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.

EventScansEnforceAuditShipped
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.

beforeReadFile
Gated only under --strict, exactly like Claude's Read; the shipped Cursor config passes --strict on this event alone.
beforeSubmitPrompt
The channel ships content off the machine, so a secret on it is exfiltration. No permission channel, so a would-be ask blocks too. The block is recoverable: the user edits and resubmits.

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.

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.

EventScansEnforceAuditShipped
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

"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.

PermissionRequest
The approval-flow gate: any deny wins, closing PreToolUse's updatedInput and ask fail-open edges.
UserPromptSubmit
The channel ships content off the machine, so a secret on it is exfiltration.
PostToolUse
Output scanning. Opt-in - it fires on every tool result.

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. Only preToolUse can actually block; the other events run full detection and write the audit log but cannot decide.

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.

Fail mode. preToolUse fails CLOSED on a non-zero exit, but a hook TIMEOUT fails OPEN (the shipped configs set timeoutSec 10).

EventScansEnforceAuditShipped
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 the same shape as preToolUse nothing is emitted - detection and the audit log only none yes
userPromptSubmitted prompt nothing is emitted - the CLI ignores this event's hook output none yes
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.

preToolUse
The only enforcing gate on this host, and the only one where ask is a real interactive prompt rather than a degraded deny.
permissionRequest
Byte-identical to preToolUse with no discriminator, so it cannot independently block. It is wired for detection coverage and the audit log.
userPromptSubmitted
The channel ships content off the machine, so a secret on it is exfiltration. A secret typed into a Copilot CLI prompt is detected and logged but NOT blocked before it reaches the provider - unlike Claude Code and Codex, whose prompt-submit hooks can block. A host limitation, not a Skarn one.
preMcpToolCall
Supported but not wired in the shipped configs: it cannot block or advise, so it buys detection coverage in the guard log only.
postToolUse
Output scanning. Opt-in - it fires on every tool result.

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.

EventScansEnforceAuditShipped
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.

UserPromptSubmit
The channel ships content off the machine, so a secret on it is exfiltration. Unlike the Copilot CLI, the VS Code host honors a prompt-submit block. On the cloud agent an ask degrades to deny (non-interactive).
PostToolUse
Output scanning. Opt-in - it fires on every tool result.

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.