skarn(1)

AI coding session security scanner with built-in session search

skarn 0.27.0

Real-time guard: every hook event it gates, per host, is documented in the guard hook-event reference.

Synopsis

skarn check [options]
skarn assess [options]
skarn vet [options]
skarn mcp
skarn baseline <audit|accept> <file> [<fingerprint>] [options]
skarn search <query> [options]
skarn recent [options]
skarn restore <session>
skarn messages <session> [options]
skarn stats [options]
skarn tools [options]
skarn mcps [options]
skarn cmds [options]
skarn export [options]
skarn guard [report|accept <fingerprint>] [options]
skarn setup [options]
skarn doctor [options]
skarn completion <shell>
skarn serve [options]
skarn taxonomies
skarn license [<file>|-|renew] [options]
skarn eula [accept]
skarn --version
skarn --help

Description

Skarn is an AI coding session security scanner with built-in session search. It detects leaked credentials and the attack patterns that cause or exploit them in the local session transcripts of AI coding assistants (Claude Code, Gemini CLI, Codex CLI, Cursor, VS Code Copilot Chat, Kimi Code CLI, Grok Build, Grok Bot, and Antigravity), and it lets you search, browse, and analyze those sessions.

Skarn is a single static binary. It scans locally and makes no network connection by default; the only features that transmit data off the machine are opt-in and named explicitly in the SECURITY section below.

Skarn has two faces over one parsing engine. The recall commands (search, recent, stats, and friends) are the daily-use surface and never trigger a scan, with one exception: export redacts by default, which runs the detector over the sessions it emits to mask every secret, and --no-redact dumps raw content and skips that scan. The check command is the security scanner: it runs the detection rules, correlates attack chains, and computes a session risk score for CI/CD gating. Every finding is mapped to MITRE ATLAS, the OWASP Top 10 for LLM Applications 2025, and CWE; run skarn taxonomies to print the crosswalk and the pinned framework versions.

Commands

check [options]
Scan AI sessions for leaked credentials and attack patterns.
assess [options]
Scan every AI session on this machine and print a friendly risk summary, with an optional shareable redacted report and a scoped incident dossier. Free, no config, no license. Run at a terminal it also names, once per release, a hook in your own per-user agent config that has fallen behind this build, so an event a newer template gates does not go unscanned unnoticed; it looks at that config only, never at a repo's committed hooks and never at a fleet-managed one.
vet [options]
Statically vet the local AI assistant configuration (hooks, MCP servers, permission grants, plugins and skills) for risky patterns across Claude Code, Codex CLI, Cursor, Copilot, Gemini CLI, Grok Build, Grok Bot, and Microsoft Scout. Read-only and offline; free, no license.
mcp
Run a local stdio MCP server exposing read-only, redacted scan, config-vetting and session-metadata tools to an AI coding assistant.
baseline <audit|accept> <file> [<fingerprint>] [options]
Interactively triage findings into a committed baseline (audit), or record a false-positive fingerprint (accept).
Search past AI sessions (literal text, or --regex).
recent [options]
List recent sessions.
restore <session>
Restore a session.
messages <session> [options]
Show the messages of a session.
stats [options]
Session analytics: tokens, models, tools, and timing (text, json, csv, or html).
tools [options]
Show the tools used across sessions.
mcps [options]
Show the MCP calls made across sessions.
cmds [options]
Show the shell commands run across sessions.
export [options]
Export sessions as text, json, ndjson, or html (redacted by default).
guard [report|accept <fingerprint>] [options]
Real-time pre-execution hook for editor/agent integration; reads an event on stdin and emits a verdict. guard report summarizes the audit log, forecasts what enforcing would cost, prints the enforce flip when the window is clean, and at a terminal names, once per release, a hook in your own per-user agent config that has fallen behind this build; guard accept records a flagged finding as a false positive so the guard stops blocking it.
setup [options]
Wire the skarn guard hook into detected AI coding agents (merge-based, reversible), then verify it with the guard self-test.
doctor [options]
Check whether skarn is actually protecting this machine: binary, license, wired agent hooks, guard log, session stores, and the guard self-test.
completion <shell>
Print a shell completion script for bash, zsh, or fish.
serve [options]
Start the local web UI (search, recent, stats, and a redacted scan view) bound to 127.0.0.1.
taxonomies
Print the standards crosswalk (MITRE ATLAS, OWASP LLM Top 10, CWE) and the EU Cyber Resilience Act obligation axis (process evidence relevant to an obligation, not proof of product conformity).
license [<file>|-|renew] [options]
Show the active license, install one from a file (or stdin), or renew it from the license service.
eula [accept]
Print the Skarn End User License Agreement, or record acceptance of the current version (accept).

Options

Check options

Control the security scan: which rules run, the scan window, output format, and CI gating. check requires a license and refuses with exit 7 before any scan without a usable one; the free license is issued at https://getskarn.com/free after a quick email confirmation and is verified offline. Only --audit-verify is exempt: verifying an audit log's hash chain needs no license.

--rules path
Load a custom TOML rules file in addition to the bundled rules.
--ai-rules path
Load an AI-specific rules file.
--no-ai-rules
Disable the AI-specific rules.
--no-default-rules
Disable the bundled detection rules.
--feed path
Apply a signed maintained-feed bundle over the baseline rules. Requires a Team license.
--update-rules
Fetch and verify the latest feed from the subscriber channel, then scan. Requires a Team license.
--feed-url url
Feed channel URL for --update-rules; falls back to $SKARN_FEED_URL. It must be https - plain http is accepted only against loopback - and must carry no userinfo, so a poisoned channel cannot receive the subscriber credential in cleartext. Requires a Team license.
--offline
Disable all network access (the maintained-feed fetch).
--severity level
Report only findings at or above this severity. One of: low, medium, high, critical. The default is medium.
--canary-file path
File of planted canary tokens, one per line; a match is a proven breach.
--check-packages
Flag typosquatted or URL-sourced package installs found in sessions (offline).
--check-code
Flag insecure code written by the assistant during the session.
--hours n
Scan window in hours; 0 means no time limit. The default is 720.
--cli name
Restrict the scan to one assistant's sessions. A name this machine has no tool for is refused with exit 6 rather than scanned as empty, so a typo cannot pass for a clean result. A custom tool declared in ~/.skarn.json is accepted by its configured name. One of: claude, gemini, codex, cursor, copilot, kimi, grok, grokbot, antigravity.
--project path
Restrict the scan to matching projects: a comma-separated list of names or paths, each matched case-insensitively against a session's project display name, its canonical root path, or a parent directory of that root.
--session id
Scan a single session by id.
--format fmt
Output format. ndjson is an ECS-shaped event stream (one redacted finding per line) for SIEM ingest. sarif (partialFingerprints), ndjson (skarn.fingerprint), evidence, and the json cross-session chains (fingerprint and exfilFingerprint) carry the finding fingerprint, a deterministic unkeyed two-stage hash (the sha256 of the rule id, a NUL byte, and the lowercase hex sha256 of the ASCII-lowercased secret), so a guessable secret is recoverable from it by candidate testing wherever that output travels. evidence is a redacted Markdown evidence pack for a product technical file, and is the one value that requires a Team license. One of: text, json, sarif, ndjson, evidence. The default is text.
-v, --verbose
Verbose output.
--no-redact
Show secrets in full instead of redacting them, in the text, json and sarif reports only. Use with caution. The ndjson stream, the evidence pack, the artifacts written by --drop, serve and assess, the baseline files, and the guard ignore this flag and stay redacted.
--no-color
Disable colored output.
--policy path
Load a declarative TOML policy file (fail thresholds, required/forbidden rules, baseline enforcement, profile); the --fail-on-* and --profile flags override it. A file that cannot be read or parsed exits 4 rather than falling back to the embedded default, which gates nothing: a gate you asked for and did not get must never pass silently. Requires a Team license.
--profile preset
Output profile. enterprise keeps high-confidence findings and dedups them. One of: enterprise. Requires an Enterprise license.
--fail-on-severity level
Exit 1 if any finding is at or above this severity. One of: low, medium, high, critical.
--fail-on-risk 0-100
Exit 2 if the session risk score exceeds this threshold.
--fail-on-scan-error
Fail closed (exit 6) when the scan was incomplete: a tool source errored out of discovery, a session store that exists could not be read, a session's parse failed, the assistant compacted a session before the scan so its transcript no longer holds the whole conversation, or the scan read no sessions at all. Off by default (a partial scan still reports, and an empty scan still warns, with a stderr warning).
--baseline path
Suppress findings recorded in this baseline; fail only on new findings. A single file is free; a directory unions every .json baseline inside it (an org-shared accepted set) and requires a Team license. A fingerprint any member labelled a true positive is left out of that union, so a confirmed leak keeps failing the gate whatever another member labelled it. Without this flag, ~/.config/skarn/baseline.json is applied automatically when it exists (suppression only; --baseline-create and --baseline-merge targets stay explicit).
--baseline-create
Write the current findings to the --baseline file as the accepted set and exit. Under --fail-on-scan-error an incomplete scan is refused instead: the run exits 6 and the existing file is left as it was, because an accepted set recorded from a scan that read only part of the machine would suppress findings that were never examined.
--baseline-merge file
Consolidate the --baseline input (a file or a directory of per-member baselines) into one distributable file and exit. Requires a Team license.
--audit-log file
Append a hash-chained record of this scan (timestamp, policy, finding counts, verdict; no secrets) to this local log; detects in-place edits and reordering of recorded history. Requires a Team license.
--audit-verify file
Verify an audit log's hash chain and exit (no scan). A broken chain exits 1. A log that exists but could not be read - a permission failure, a directory, a log past the read cap - exits 6, because not one byte of it was examined and an unexamined log must never read as an intact one. An absent log is a valid empty chain and exits 0, so a verify that runs before the first scan does not fail. Needs no license.
--drop dir
After the scan, write a self-describing drop artifact to <dir>/<identity>/<runId>.json: identity, org, host, run id, scan window, the --project scope it declares, and the redacted summary counts. Its counts cover the declared scope, not the machine: the session count is the number that passed --project and --session. Scope is recorded twice because the two sides speak different vocabularies - scope.projects is the --project needles verbatim, which is often a path, and scope.resolved is the project names those needles actually matched. Content is attributed against scope.resolved, so a run scoped by path still names the project its exposure came from, while a run that declared no scope resolves nothing and therefore names no project at all. The assistant list and the last-activity timestamp describe the sessions the scan read inside that scope, not the ones that produced a finding, so a machine with nothing to report still says which assistants run on it and when it was last used; the guard mode is resolved by the same wired-hook walk doctor performs, which only this flag pays for, and an absent guard mode means it could not be resolved rather than that no hook is wired. Written atomically, so a second run in the same window adds a file rather than overwriting one and a concurrent reader never sees a partial artifact. The artifact carries a schema version and is never reinterpreted across versions, so a release that moves that version needs the machines upgraded alongside whatever assembles their artifacts, and the ones written at the older version cleared out of the collection directory. Skarn does not transmit it; moving the directory is the organization's job. Identity comes from SKARN_IDENTITY, falling back to a hostname-derived tag with a warning.
--product name
Release lineage for --format evidence: the product this scan's sessions produced changes for. Without lineage the evidence pack states that it cannot be attached to a specific technical file.
--product-version ver
Release lineage for --format evidence: the product version the pack describes.
--build-id id
Release lineage for --format evidence: the commit or build identifier the pack describes.
--sbom-ref ref
Release lineage for --format evidence: a reference to the product's declared SBOM, recorded as supplied (control characters masked). Skarn neither parses nor fetches it.
--sbom path
Reconcile production dependencies the sessions added to a manifest against this declared SBOM (CycloneDX or SPDX 2.x JSON), reporting each undeclared addition as low-severity workflow-drift evidence (so pair it with --severity low). Unlike --sbom-ref, the file is read and parsed; an unreadable or malformed one fails the scan.

Assess options

The zero-config machine-scan wedge: scan every AI coding session on the machine and print a friendly risk summary, no flags and no license. -o writes a shareable redacted report (HTML or Markdown); --json emits the redacted scan for scripting. The scan is scoped by the same filters as check.

-o, --out file
Write a self-contained, redacted share report to this file; .md or .markdown renders Markdown, any other extension renders HTML.
--json
Emit the redacted scan as JSON (the same shape as the serve /api/scan view) for scripting, instead of the friendly summary. With --dossier the body is the dossier envelope that wraps this scan, not the bare scan shape.
--offline
Disable all network access. Assess is already egress-free; this is the belt-and-suspenders guarantee.
--hours n
Scan window in hours; 0 (the default) scans every session on the machine. The default is 0.
--severity level
Only summarize findings at or above this severity; the default (low) scans everything and curates the display. One of: low, medium, high, critical. The default is low.
--cli name
Restrict the scan to one assistant's sessions. A name this machine has no tool for is refused with exit 6 rather than scanned as empty, so a typo cannot pass for a clean result. A custom tool declared in ~/.skarn.json is accepted by its configured name. One of: claude, gemini, codex, cursor, copilot, kimi, grok, grokbot, antigravity.
--project path
Restrict the scan to matching projects: a comma-separated list of names or paths, each matched case-insensitively against a session's project display name, its canonical root path, or a parent directory of that root.
--session id
Assess a single session by id.
--rules path
Load a custom TOML rules file in addition to the bundled rules.
--baseline path
Suppress findings recorded in this accepted-findings baseline. Without this flag, ~/.config/skarn/baseline.json is applied automatically when it exists. A directory of baselines requires a Team license; assess never gates, so an unlicensed directory degrades to a warning and an unsuppressed scan.
--no-color
Disable colored output.
--dossier selector
Scope -o and --json to one finding by its 64-character fingerprint (a reused credential matches every session it appears in) or one session by id, auto-detected by shape, as an incident dossier: the redacted facts Skarn holds for the selection, plus an explicit list of the CRA Article 14 reporting fields Skarn cannot supply. A local share artifact; Skarn does not file reports or determine severity.

Vet options

Statically vet this machine's AI assistant configuration - hook commands, MCP server definitions, permission grants, and installed plugins and skills across Claude Code, Codex CLI, Cursor, Copilot, Gemini CLI, Grok Build, Grok Bot, and Microsoft Scout - and report the risky patterns it finds: hooks that exfiltrate local content or fetch-and-execute remote code, hooks that post the session event envelope to a remote http endpoint, hooks that rewrite an assistant's own config, MCP endpoints on remote hosts, MCP launchers with no pinned version or digest, permission grants that approve a whole class of actions, grants that let the assistant ask to widen its own authority, and extensions installed from a remote source or carrying no integrity marker, plus two file-level patterns read from permission metadata and declared posture flags alone: a plaintext credential store whose file mode grants group or other read, and an assistant configured with an always-on local-exec and egress channel. Vet reports declared configuration, not the effective merge across layers: a grant a higher layer denies, or a permission mode an administrator lock disables, is still surfaced, because the declaration exists and is one edit away from active. Project configuration is read at the cwd-level file only, so a host that also loads configuration from parent directories is covered there and no further. Vet is read-only and offline: it opens each config file for reading, never writes one, and makes no network call. A config file that does not exist is skipped; a config file that exists but cannot be read is a coverage gap that fails closed under a --fail-on flag. Each of the per-assistant configuration files listed below is opened once and its permission metadata is read off that same handle, and a path that is not a regular file is named as a coverage gap rather than read. The config-file count the report prints, and the configFilesRead field in the json and sarif output, count every such file vet opened and examined, including the two whose contents it deliberately never reads, so a file a finding names is always inside the count rather than absent from it. The hook directories and extension manifests vet also walks keep their own older read path. The one file whose permission metadata decides a finding, Grok Bot's credential store, is additionally opened without following a symlink, so a symlinked store is reported as a coverage gap named SymlinkNotFollowed rather than measured through its target; every other config file is still read through a symlink, so a dotfiles-managed configuration keeps its coverage. Vet surfaces risky configuration and does not change it.

--format fmt
Output format. json and sarif carry the same rule ids, severities, config paths, and locators as the text list. One of: text, json, sarif. The default is text.
--fail-on-severity level
Exit 1 if any finding is at or above this severity. Without it, vet always exits 0: it surfaces, it does not gate. One of: low, medium, high, critical.
--fail-on-scan-error
Fail closed (exit 6) when a config file that exists was not read - it could not be read, or it was skipped because it is not that assistant's live configuration - so a partial view of the configuration is never reported as clean. Implied by --fail-on-severity. A home directory that cannot be resolved at all exits 6 regardless of this flag, since nothing was examined.
--offline
Disable all network access. Vet never makes a network call; this is the belt-and-suspenders guarantee.
--no-color
Disable colored output.

Mcp options

Run a local Model Context Protocol (MCP) server so an AI coding assistant can call Skarn directly. The transport is stdio: newline-delimited JSON-RPC 2.0 on stdin and stdout, one server process per client, and the process exits 0 when the client closes stdin. The negotiated protocol version is 2025-11-25; a client that asks for 2025-06-18 gets that version back, and any other request, including the batch-era 2025-03-26 and the stateless 2026-07-28, is answered with 2025-11-25. Batched requests are not accepted: a top-level JSON array is answered with one -32600 error. Four read-only tools are exposed by default, and --enable-recall adds search_sessions and get_session, which return transcript text and are described under that option. scan_sessions returns the same redacted scan report that `skarn assess --json` prints, minus the per-incident context snippet and minus every transcript-authored label, which means the project labels on both the incidents and the attack chains and the tool name on an incident, and takes severity, hours and project. vet_configs returns the masked findings report that `skarn vet --format json` prints and takes no arguments; those findings quote this machine's own configuration, including hook command lines and MCP server definitions, with any credential-shaped value masked. list_sessions returns recent sessions as identifiers and counts only - session id, assistant, the two timestamps, message and tool counts, and token totals - and takes hours, limit, project and cli. session_stats returns the same aggregate counts across those sessions and takes hours, project and cli. Every tool is annotated read-only, non-destructive and closed-world. No tool writes, changes or removes a file, no tool opens a network connection, and no default tool returns session message text or a label authored inside a transcript such as a project name, a git branch, a model id or a tool name. Every value a detector matched is reported only in its masked form, which keeps at most the first character and last two characters and discards the middle; a value under 16 characters, and any personal data, is replaced whole. That is a truncated preview rather than a one-way transform of the whole value. Three classes of string are still passed through as they are: a file path, a session id, and a timestamp. Each is a locator the tools exist to hand back, and each is read from the transcript or the store rather than generated by Skarn, so a credential deliberately planted in a session id or a directory name reaches the client verbatim. A Claude Code store also encodes the project directory into its transcript file name, so the returned session_file carries that directory in encoded form. Every other Skarn surface passes those three through the same way. Tool results are returned to the MCP client that spawned the server, which is the assistant, so they travel wherever that assistant sends its context. The verb needs no license. Project-level configuration for vet_configs is read from the server process's working directory, which is whatever directory the client spawned it in.

--enable-recall
Add the content tools search_sessions and get_session to the server. Off by default. They return transcript content with detected credentials masked and other text as recorded, so anything they return leaves the machine through the connected MCP client and reaches the model provider.

Baseline options

Interactive baseline triage. audit walks each new finding and records a true/false-positive decision with a reason into the committed baseline; accept records a single false-positive fingerprint non-interactively. audit runs the same scan check runs and requires a license the same way: any tier, the free one included, and it refuses with exit 7 before any scan when none is usable; accept scans nothing and needs no license. The audit scan is scoped by the same filters as check. A false positive is suppressed on every later scan, while a finding labeled a true positive is recorded and keeps firing until the credential is rotated - the same labels the real-time guard honors. A true positive wins over a false positive on the same fingerprint wherever the two meet, in one file or across the member files of an org baseline directory, so one member cannot silence a leak another member confirmed. A finding that carries a secret is fingerprinted by that secret, so its acceptance follows the secret across sessions and machines; a finding with no secret (a behavioral finding such as prompt poisoning) is fingerprinted by the session it fired in, so accepting it silences that rule in that session only and the same rule in any other session keeps firing. To accept a finding the guard flagged, use `skarn guard accept <fingerprint>`: it takes the fingerprint straight from the guard's audit record and defaults to the personal baseline the guard reads.

--reason text
Reason recorded with an accepted finding (audit prompts for one; accept takes it as a flag).
--identity id
Override the accepted-by identity stamped on the decision; defaults to $SKARN_IDENTITY.
--cli name
Restrict the audit scan to one assistant's sessions. One of: claude, gemini, codex, cursor, copilot, kimi, grok, grokbot, antigravity.
--project path
Restrict the audit scan to matching projects: a comma-separated list of names or paths, each matched case-insensitively against a session's project display name, its canonical root path, or a parent directory of that root.
--session id
Audit a single session by id.
--rules path
Load a custom TOML rules file for the audit scan in addition to the bundled rules.
--hours n
Audit scan window in hours; 0 means no time limit. The default is 720.
--severity level
Only audit findings at or above this severity. One of: low, medium, high, critical. The default is medium.

Guard options

Control the real-time pre-execution hook used for editor/agent integration (Claude Code, Cursor, Codex CLI, Copilot CLI, Gemini CLI, Grok Build, and the Antigravity CLI). The report subverb reads the guard's own audit log instead of an event: it summarizes the flagged calls per agent (verdicts, top rules, latency, the most recent denies), states what enforcing that window would have cost (blocks per day, prompts per day), and, when the window spans at least five days with zero denies, prints the exact command that flips the wired hooks to enforce. Run at a terminal it reads one thing more, your own per-user hook configs, so it can name a hook that has fallen behind this build once per release; piped or as json it reads nothing but the log. The guard logs only flagged calls, so every count and every rate report prints is over flagged actions, never over all traffic. The accept subverb closes that loop: an audit record whose top finding carries a secret gets a fingerprint (the same secret-scoped key check writes to SARIF partialFingerprints), report prints it under each such deny, and `skarn guard accept <fingerprint>` records it as a false positive so the identical call stops being flagged; a secret-less deny (a structural egress detector, a typosquat, a chain) carries no fingerprint and no accept line. The structural egress detectors are built into the engine, not rule-pack rules, and tier themselves: an egress whose whole payload is visible in the command itself - a bare request, a literal body, query parameters, with no file sent, no credential store named, no earlier secret read in the same call, and no payload bytes the command text does not show (a command substitution, an environment expansion, a shell redirect, a form read, or a pipe feeding the target all keep the deny) - is held at the ask tier - a real approval prompt on the primary shell surfaces (Claude Code PreToolUse, Copilot preToolUse on both the CLI and VS Code, Cursor shell and MCP hooks), a block on the channels with no ask (Codex, Claude Code PermissionRequest, Copilot permissionRequest, Cursor file-read events, Gemini, Grok Build) - while a payload carrying a recognized credential file or upload form, a co-detected secret, or recon earlier in the same call stays a deny. Both subverbs are ungated: report reads the log (plus, at a terminal, your own hook configs for the line above), and accept writes only a file. On a flagged call the guard consults the personal accepted-findings baseline it shares with check (~/.config/skarn/baseline.json, the file form only - never the org-union directory, so the guard can never hit a paid gate): a finding accepted as a false positive is dropped from the verdict, while a finding a human confirmed as a REAL leak (`skarn baseline audit`, label true-positive) is never suppressed and keeps being blocked until the credential is rotated. Only the highest-severity finding of a call carries the fingerprint, so a call hiding several distinct secrets peels one accept at a time; a correlated attack chain still denies even when every secret in the call has been accepted; and a baseline that is missing, unreadable, or malformed suppresses nothing at all - a broken baseline means more blocking, never less.

--guard-mode mode
audit reports only; enforce emits a deny or ask verdict. Enforce needs a license; an unlicensed guard runs audit-only. One of: audit, enforce. The default is audit.
--strict
Also gate Read operations (credential-file reconnaissance).
--agent name
Hook host (default: auto-detect from the event shape; pass codex explicitly for Codex CLI, which shares Claude's event names, and copilot explicitly for Copilot CLI, whose Claude-compat payloads are indistinguishable from Claude's; Gemini CLI's Before*/After* event names are unique, Grok Build's camelCase hookEventName key is unique to it, and the Antigravity CLI's camelCase toolCall/conversationId/workspacePaths envelope is unique to it, so all three auto-detect, and the shipped configs pin them anyway). Grok is the one host that overrides an explicit pin: an event delivered by grok's own hook runner is scanned by the grok adapter even where the invoking config pins another host, because grok runs Claude-config and Cursor-config hooks with its own payload dialect. With --self-test, all (the default) tests the claude, cursor, codex, copilot, gemini, grok, and antigravity adapters, copilot on both its CLI and VS Code dialects. With report, it restricts the summary to one agent (repeatable or comma-separated). One of: claude, cursor, codex, copilot, gemini, grok, antigravity, all.
--event name
The hook event this config entry fires on, for a host whose payload does not name its own event. Only the Copilot CLI needs it: its permissionRequest payload carries a subset of preToolUse's documented fields and nothing separates the two, so a permissionRequest entry must say so or the guard reads it as a preToolUse and answers in a schema that event does not understand. Default: the event is inferred from the payload shape, which never yields permissionRequest. Ignored by every host whose payload names its own event, and by the VS Code Copilot dialect. One of: preToolUse, permissionRequest.
--self-test
Verify the guard wiring instead of reading an event: feed two synthetic fixture events (one benign, one carrying a fake credential) through each adapter's real code path and report the verdicts, latency, and a guard-log write probe. The fake credential is only ever printed redacted, and the self-test deliberately ignores the accepted-findings baseline, so an accepted fingerprint can never make a dead hook look alive. Exit 0 when every adapter passes, 1 otherwise; works unlicensed (audit semantics).
--log path
On the hook invocation, `skarn guard --log <path|none>`: where this call appends its redacted JSONL record. A path takes precedence over $SKARN_GUARD_LOG, none writes no record even when that variable is set, and no flag at all falls back to the variable. A path that expands to a parent-directory segment or carries a control character is refused: no record is written and there is no fallback destination, because a fallback would derive from $XDG_CACHE_HOME or $HOME, which the same hook command can also set. With report: a guard log to summarize, repeatable. Default: $SKARN_GUARD_LOG when set, otherwise the log skarn setup wires for each agent ($XDG_CACHE_HOME/skarn/guard-<agent>-audit.jsonl, plus the home cache beside it when a log already exists there). A log that does not exist is named in the output with the command that wires it, never silently counted as zero, and a configured path the guard refuses is named as refused rather than shown as empty.
--window span
With report: how far back to summarize, as <N>d or all. The default is 7d.
--format fmt
With report: text renders the summary, the enforce-cost forecast, and the recommendation; json emits the whole aggregate as one object with stable field names for fleet scripts (including a forecast object and the denominator string that names what its rates are rates of). One of: text, json. The default is text.
--baseline file
With accept: the accepted-findings file to write. Defaults to the personal baseline the guard itself reads (~/.config/skarn/baseline.json, honoring $XDG_CONFIG_HOME), created on first use - unlike `skarn baseline accept`, which always names its file, because an accept the guard does not read would not stop the block. Always a file, never a directory: the org-union form is not reachable from the guard. Accepting a fingerprint already recorded as a confirmed true positive is refused, so a fingerprint-only accept can never re-silence a real leak.
--reason text
With accept: why this finding is a false positive, recorded in the baseline entry. Do not paste a secret here; the text is stored verbatim.
--identity id
With accept: override the accepted-by identity stamped on the entry; defaults to $SKARN_IDENTITY.

Setup options

Guided onboarding: detect installed AI coding agents (Claude Code, Cursor, Codex CLI, Copilot CLI, Gemini CLI, Grok Build), merge the skarn guard hook into each one's native config in audit mode, and prove the wiring with the guard self-test. --scope project writes committed, shareable configs whose commands are portable by construction (a PATH-guarded invocation and a literal $HOME log path, never this machine's absolute paths) - with one honest exception: Gemini CLI and Grok Build each read a single cross-platform command field, so a file committed from a POSIX machine carries the POSIX form and a native-Windows teammate's hook fails open with a warning instead of running, and grok's project hooks additionally wait on a /hooks-trust grant; --plugin commits the claude plugin requirement instead of hook entries, and the two project routes are mutually exclusive - each removes the other's skarn-owned entries so the guard can never fire twice. Existing foreign hooks are never touched; every modified file is backed up first (*.skarn-bak.<timestamp>, carrying the target's own permissions) and rewritten atomically; a target file that does not parse as JSON is refused, never overwritten. At --scope project a target that is a symlink, that sits behind a symlinked directory, or whose path skarn cannot examine at all, is refused too, before anything is read or created: a committed symlink can name any file on the machine, and following it would copy that file's bytes into a backup beside it and rewrite it in place. User scope still follows the link, because a dotfiles manager legitimately puts one there - and the atomic rewrite then leaves a regular file where the link was, so a stow-managed config has to be relinked afterwards. On a terminal it runs an interactive wizard with default-yes prompts; --yes accepts everything, --print writes nothing.

--agent name
Target agents, repeatable or comma-separated. Default: all detected (an agent is detected when its config directory exists or its CLI is on PATH); naming an agent explicitly also targets it when undetected. One of: claude, cursor, codex, copilot, gemini, grok, all.
--mode mode
Guard mode written into the hook commands. Audit records would-be verdicts to the guard log without blocking; start there, review the log, then flip to enforce. Enforce needs a license to actually block; an unlicensed guard runs audit-only regardless. One of: audit, enforce. The default is audit.
--strict
Add --strict to the generated guard commands (also gates Read operations; fail-closed semantics on Codex).
--log path
Guard log path wired into the hook commands: a SKARN_GUARD_LOG= prefix on the POSIX arms and on Copilot's powershell arm, and a --log flag on the Codex commandWindows arm, which Codex may run under either PowerShell or cmd.exe and where no shell-syntax prefix parses under both. Pass none to disable logging. At --scope project an explicit path is refused: a committed config runs on every teammate's machine, so it carries only the portable per-user default. A path with a parent-directory segment or a control character is refused at any scope, since the guard refuses it at runtime and the hook would write nothing. An explicit path reaches only the arms whose host could have that location: a POSIX-absolute path reaches the POSIX arms, a drive-qualified or UNC path reaches the Windows arm, and the other arm keeps the portable default rather than a destination its host cannot resolve. A path that does reach the Codex Windows arm may use only letters, digits and ~ / \ . - _ : ; anything else is refused rather than escaped, because a bare argument both shells must parse has no single correct escaping. The default is ~/.cache/skarn/guard-<agent>-audit.jsonl.
--command path
The guard executable invoked by the hook commands. At user scope the default resolves this binary's absolute path (preferring the stable Homebrew <prefix>/bin symlink over the versioned Cellar path) because GUI-launched hosts like Cursor do not inherit brew's PATH; pass skarn to opt into PATH-relative. At project scope the default is the bare name skarn, and a value containing a path separator is refused: a committed config must resolve skarn from PATH on every machine. The default is this binary's absolute path.
--scope scope
Write user-level configs (~/.claude/settings.json, ~/.cursor/hooks.json, ~/.codex/hooks.json, ~/.copilot/hooks/skarn.json, ~/.gemini/settings.json, ~/.grok/hooks/skarn.json) or project-level ones (./.claude, ./.cursor, ./.codex, ./.github/hooks/skarn.json, ./.gemini, ./.grok/hooks/skarn.json relative to the working directory), meant to be committed and shared. The two scopes deliberately write different command shapes: user scope bakes this binary's absolute path (immune to a GUI host's missing PATH), project scope emits a portable PATH-guarded command (if command -v skarn ...; then ...; else exit 0; fi) with a literal $HOME log path, so the committed file runs unmodified on every teammate's machine and exits 0 where skarn is not installed. Gemini is the one exception to that promise: it reads a single command field on every platform, so the committed POSIX form does not run under the PowerShell a native-Windows teammate's Gemini uses - the hook exits non-zero, Gemini treats that as a warning and continues, and that machine is unprotected until it wires its own. Grok reads one command field on every platform too, and the same caveat applies to a committed .grok/hooks/skarn.json; its project hooks additionally run only once the folder is trusted with /hooks-trust. Copilot honors $COPILOT_HOME as its user-scope root, and grok honors $GROK_HOME. One of: user, project. The default is user.
--plugin
With --scope project, claude only: write the committed plugin requirement (the skarn entry in extraKnownMarketplaces plus enabledPlugins["skarn-guard@skarn"]) instead of hook entries, so every contributor who opens the repo installs the skarn-guard plugin from the public marketplace. Mutually exclusive with the hooks route in both directions: --plugin removes any skarn-owned hook entries it previously wrote, and a later hooks-route run removes the plugin keys, so the guard can never fire twice per event. Refused at user scope, for non-claude agents, and combined with --update, --mode, --strict, --log, or --command (the plugin ships its own hook files). Foreign marketplaces and plugins are never touched.
--print
Print the final per-agent config to stdout and write nothing; the paste-it-yourself and fleet-template path.
--format fmt
With --print: json emits one {agent, path, content} envelope per agent for machine consumption. One of: text, json. The default is text.
-y, --yes
Non-interactive: accept every prompt's default. Required when running without a terminal.
--update
Only touch agents whose config already carries skarn entries; the audit-to-enforce flip is skarn setup --update --mode enforce. An owned entry is replaced whole - any field you added to it is dropped and a field a newer template stopped writing stays gone - except a hook-call timeout you tuned yourself (timeout, or timeoutSec on copilot), which is carried across only when its entry pairs unambiguously with the replacement and the value is a positive integer within the host's range, and otherwise falls back to the template default. A hook you added inside skarn's own matcher group is kept where you put it: skarn replaces its own entry beside yours instead of adding a second group to the event.
--remove
Surgically remove skarn-owned hook entries, dropping any matcher groups and event arrays they leave empty. Foreign hooks are untouched, the file itself is never deleted, and backups from earlier runs are kept.
--json
Print a machine-readable result summary (per agent: path, action, backup, self-test result) instead of the text report.

Doctor options

Health, wiring, and dead-hook detection. Doctor runs one named check per line - the binary and its path, the license, the hook config of every agent this machine either has installed or carries a managed config for, whether each wired hook command still resolves to an executable, whether the guard mode the hook asks for (audit or enforce) is what actually runs under this binary's license, whether the wiring matches what this version of skarn writes, the guard log's freshness, the session stores it can enumerate, the guard self-test, the cache directory, and how current the detection rules are - and every warning or failure names the exact command that clears it, or the resource that explains it when no single command applies (a free binary whose bundled rules have aged has no command to run, so that one check carries the editions link instead of a fix). Each agent's hook config is read as ordered precedence layers - the MDM/fleet-managed system paths first, then the repo's committed project config in the working directory (./.claude/settings.json, ./.cursor/hooks.json, ./.codex/hooks.json, ./.github/hooks/skarn.json, ./.gemini/settings.json), then the per-user config - so a machine wired only through the managed layer reports as protected and names the managed file, and a repo carrying a committed project config is inspected wherever doctor runs; on Claude and Codex a managed lockdown key (allowManagedHooksOnly / allow_managed_hooks_only) makes every lower layer inert, and doctor reports an inert user or project hook instead of counting it as protection (Cursor and Copilot layer additively, so every present layer runs). On Claude the managed layer is the system managed-settings.json folded together with its managed-settings.d/ drop-in directory the way Claude merges them, so a machine wired only through a drop-in reports as protected and the report names the drop-in file itself; a hook found at the retired C:\ProgramData\ClaudeCode\managed-settings.json is never counted, only noted with the supported path. On Windows the managed layer also covers the HKLM and HKCU SOFTWARE\Policies\ClaudeCode registry keys that Group Policy and Intune deploy, so a fleet managed only through the registry is examined rather than skipped. Claude Code reads exactly one managed source and ignores the rest rather than merging them - an MDM/OS-level policy (the HKLM registry key, or the macOS managed-preferences plist) outranks the system managed-settings.json and its drop-ins, which in turn outrank the user-writable HKCU key - so doctor reports the losing source as inert and never counts its hooks as protection. When the winning source is one skarn cannot decode, such as the managed-preferences plist, doctor reports the coverage gap instead of a protection it cannot confirm. Two Claude managed delivery surfaces still stay outside what doctor can read, both of them above everything local: the server-managed settings delivered at sign-in from the claude.ai admin console or a self-hosted gateway, and a policyHelper executable that computes managed settings at startup. On WSL the whole Windows policy chain is out of reach of a Linux binary and outranks /etc/claude-code, so a claude check that would otherwise pass is downgraded to a warn naming that chain rather than claiming a protection skarn cannot confirm. Gemini is the one host whose managed layer is NOT read first: its own precedence is system-defaults, then user, then project, then the system settings file on top (that file has the final say), and doctor reports its layers in exactly that order; hook arrays still concatenate across all four, so every present layer runs, and what turns hooks off is not a lockdown key but hooksConfig - enabled is a single value the highest layer that sets it wins, disabled is a list of hook names unioned across every layer, and a hook either switch silences is reported inert rather than counted as protection. A committed project hook gets its own guard-command verdicts: an absolute machine-local path or an unrendered __SKARN_PATH__ placeholder FAILs (that file protects nobody but its author), a PATH-resolved command that does not resolve on this machine FAILs with the install command as the fix (the hook exits 0 here, so the repo is not protecting this machine), and Cursor at project scope WARNs naming the GUI-PATH gap - the deliberate, documented cost of a portable committed config. The license check WARNs when there is no license at all (check will not run, exit 7; assess, the guard, and the recall commands still work) and FAILs only when a license is present but broken. The eula check PASSes when an EULA acceptance is recorded (showing the accepted version, timestamp, and method) and WARNs when none is, with fix `skarn eula accept` - doctor itself never asks, so a diagnostic is never blocked by the acceptance gate. The guard-scope check WARNs when a hook wired with --guard-mode enforce runs on an unlicensed binary (the guard silently runs audit: it records the would-be verdict and blocks nothing). The guard-conformance check appears only when a managed layer declares a skarn hook and answers whether this machine runs what the org declared: it WARNs when the managed layer declares enforce on an unlicensed binary (the fleet is observing, not blocking), when the declared command does not resolve to an executable (the managed config alone deploys nothing - the binary must ride the same MDM payload), when a declared event is not gated, or when a hook disagrees with the declared mode; every branch is a WARN with an exact fix, never a FAIL, because a policy divergence is a fleet fact, not a broken install. The grokbot check appears only on a machine where Grok Bot is installed - xAI's always-on desktop agents app, a different product from the Grok Build CLI - and it is a warning that carries no fix, because no skarn command changes what it reports: skarn states this posture and leaves what to do about it to you. When the daemon-connection state is present the warning names the always-on local-exec daemon that lets a cloud agent execute here, and its egress tunnel when that is on; when only the app root is found it reports plain presence and claims no channel. Detecting it is stat-only over ~/.grokbot/ and the macOS app root; the one file skarn opens there is ~/.grokbot/settings.json, for its two posture flags, and it is read only once skarn has proved that path is that file and nothing else - not a link, not a hard link to a credential file, not a pipe - so no daemon or OAuth credential value can reach the report. Check ids (binary, license, eula, agent_config_<agent>, guard_command_<agent>, guard_scope_<agent>, config_drift_<agent>, guard_log_<agent>, self_test_<agent>, guard_conformance_<agent>, grokbot, session_stores, cache_dir, rule_age) are a stable, greppable contract for fleet scripts. A failure exits 1; a warning never does; --json is a data mode that always exits 0 once a report is rendered, with the two refusals that precede any check: an unknown option exits 1, and a home directory that cannot be resolved exits 6. Doctor is hermetic: it makes no network call, shells out to nothing, and enumerates the session stores without reading a session's content. Set SKARN_MANAGED_ROOT to validate a staged managed payload before pushing it; the active root is echoed in the report header and in --json, so a staged report can never pass as a live one.

-v, --verbose
Add detail to every check: the paths searched, each wired hook command, the per-tool session counts, and the resolved Homebrew path.
--json
Emit the full structured result: {version, checks[{id, status, detail, fix, notes}], summary{pass, warn, fail}}, plus managed_root at the top level when SKARN_MANAGED_ROOT is active, managed_registry_root when SKARN_MANAGED_REGISTRY_ROOT is active, and an additive layers array ([{layer, path, effect, lockdown, wired}]) on each agent config check, where a Windows registry layer carries its policy key as the path. A rendered report always exits 0; the two refusals that precede it are an unknown option (exit 1) and a home directory that cannot be resolved (exit 6).

Serve options

Control the localhost web UI. Access requires the per-run token embedded in the URL the command prints at startup; opening that URL sets a session cookie, and scripts may pass it as a token= query parameter instead. The recall views (search, recent, stats) need no license; the security view (/api/scan) is the same engine as check and requires one the same way - without it the endpoint answers 403 and the Security tab shows how to register free. Opening a finding in the Security view shows a detail drawer (severity, taxonomy crosswalk, attack-chain context, and the redacted code context) from which you can accept the finding into your baseline as a false positive - the same write path as skarn baseline accept, recording only the finding's fingerprint, never its secret. The license is resolved once at startup, so installing one while the server is running needs a restart.

--port n
Port to listen on; 0 asks the OS to assign one. Always binds 127.0.0.1 only. The default is 7777.
--rules path
Load a custom TOML rules file in addition to the bundled rules; the web Security scan then also surfaces its findings.
--baseline path
Suppress findings recorded in this accepted-findings baseline; the Security view hides them by default behind a show/hide toggle. Without this flag, ~/.config/skarn/baseline.json is applied automatically when it exists. A directory of baselines (the org form) requires a Team license.
--fleet path
Serve a Fleet roster view (preview) over this fleet.json aggregate of per-machine redacted scan artifacts. The aggregate is produced by an external step that reads each machine's dropped scan output; skarn neither collects nor transmits fleet data. When that step was run against an org-declared machine roster, the aggregate also carries the coverage that roster implies, and the view states how many of the expected machines reported and names the ones that did not; assembled without a roster it carries none, because a machine that never reported is invisible to anything reading only the artifacts that arrived. Rendering an aggregate needs the Enterprise console entitlement: without it, serve refuses with exit 5 before it binds a port and before it reads the file. With it, serve validates the file against the current aggregate contract before it binds a port: a file that declares a schemaVersion is refused with exit 1 when a machine's topExposure names a project absent from the projects that machine resolved, when a machine carries projects while declaring no scope, or when a topExposure secret is not a redaction-chokepoint preview. Every refusal names the offending machine, and the two scope refusals name the project as well, so a rejected aggregate is a deliberate refusal rather than a crash. A file that declares no schemaVersion at all is refused the same way, naming the aggregator version to regenerate it with: no invariant can be evaluated on a file that does not say what it is, and serving one would put whatever it happens to carry, redacted or not, into the Fleet view. The kit that produces the aggregate - the aggregator, the per-platform scheduling units, the managed guard configs, and the rollout runbooks - is published at integrations/fleet/ in https://github.com/skarn-security/skarn-dist, where it downloads with no credential and no license; only rendering the assembled aggregate here needs one. Requires an Enterprise license.

Global options

Available on a bare invocation.

--version
Print the version and exit.
-h, --help
Print help and exit.

Recent options

List and filter recent sessions.

--cli name
Restrict to one assistant's sessions. One of: claude, gemini, codex, cursor, copilot, kimi, grok, grokbot, antigravity.
--project path
Restrict to matching projects: a comma-separated list of names or paths, each matched case-insensitively against a session's project display name, its canonical root path, or a parent directory of that root.
--worktree path
Restrict to sessions whose working directory is under this path.
--since date
Lower bound of the time window, applied to a session file's last-modified time rather than to when the session started: a session begun before the bound but written to inside it is included, which keeps a resumed session visible under the day you worked on it. Takes priority over --hours. Naming one session by id changes this: `skarn search --session <id>`, `skarn stats <id>` and the other per-session views ignore the window altogether, and `skarn messages <id>` is the single command that applies it there, reading BOTH bounds against the session's own start time rather than its file and refusing an out-of-window session with exit 1 instead of filtering it out. A bare YYYY-MM-DD means that day's midnight, shifted by --tz-offset; a full ISO timestamp is used as given.
--until date
Upper bound of the time window, applied to when a session STARTED rather than to when its file was last written: a session that began after the bound is excluded even if it was already running before it. The two bounds therefore read different timestamps. Passing it without --since also drops the --hours lower bound, so the window becomes all history up to the bound. A bare YYYY-MM-DD covers that whole day through 23:59:59, shifted by --tz-offset; a full ISO timestamp is used as given. A session whose start time cannot be parsed is kept. A view that names one session by id ignores this bound, except `skarn messages <id>`, which refuses a session past it with exit 1 rather than dropping it from a list.
--tz-offset minutes
Timezone offset in minutes for date grouping and display. The default is 0.
--hours n
Time window in hours; 0 means no limit. The default is 24.
--format fmt
Output format. One of: text, json, csv. The default is text.
--include-empty
Include sessions with no user or assistant messages (off by default).

Messages options

Show one session's messages. The session is a positional id or --session; the other flags only narrow which session a prefix resolves to.

--cli name
Restrict to one assistant's sessions. One of: claude, gemini, codex, cursor, copilot, kimi, grok, grokbot, antigravity.
--project path
Restrict to matching projects: a comma-separated list of names or paths, each matched case-insensitively against a session's project display name, its canonical root path, or a parent directory of that root.
--worktree path
Restrict to sessions whose working directory is under this path.
--since date
Lower bound of the time window, applied to a session file's last-modified time rather than to when the session started: a session begun before the bound but written to inside it is included, which keeps a resumed session visible under the day you worked on it. Takes priority over --hours. Naming one session by id changes this: `skarn search --session <id>`, `skarn stats <id>` and the other per-session views ignore the window altogether, and `skarn messages <id>` is the single command that applies it there, reading BOTH bounds against the session's own start time rather than its file and refusing an out-of-window session with exit 1 instead of filtering it out. A bare YYYY-MM-DD means that day's midnight, shifted by --tz-offset; a full ISO timestamp is used as given.
--until date
Upper bound of the time window, applied to when a session STARTED rather than to when its file was last written: a session that began after the bound is excluded even if it was already running before it. The two bounds therefore read different timestamps. Passing it without --since also drops the --hours lower bound, so the window becomes all history up to the bound. A bare YYYY-MM-DD covers that whole day through 23:59:59, shifted by --tz-offset; a full ISO timestamp is used as given. A session whose start time cannot be parsed is kept. A view that names one session by id ignores this bound, except `skarn messages <id>`, which refuses a session past it with exit 1 rather than dropping it from a list.
--tz-offset minutes
Timezone offset in minutes for date grouping and display. The default is 0.
--format fmt
Output format. One of: text, json. The default is text.
--session id
Session id to operate on (prefix-matched); equivalent to the positional argument.
-v, --verbose
Verbose output.
--hours n
Time window in hours used only to resolve a session prefix; 0 applies no filter. The default is 0.

Search options

Search past sessions. The query is positional and flag order is free.

--cli name
Restrict to one assistant's sessions. One of: claude, gemini, codex, cursor, copilot, kimi, grok, grokbot, antigravity.
--project path
Restrict to matching projects: a comma-separated list of names or paths, each matched case-insensitively against a session's project display name, its canonical root path, or a parent directory of that root.
--worktree path
Restrict to sessions whose working directory is under this path.
--since date
Lower bound of the time window, applied to a session file's last-modified time rather than to when the session started: a session begun before the bound but written to inside it is included, which keeps a resumed session visible under the day you worked on it. Takes priority over --hours. Naming one session by id changes this: `skarn search --session <id>`, `skarn stats <id>` and the other per-session views ignore the window altogether, and `skarn messages <id>` is the single command that applies it there, reading BOTH bounds against the session's own start time rather than its file and refusing an out-of-window session with exit 1 instead of filtering it out. A bare YYYY-MM-DD means that day's midnight, shifted by --tz-offset; a full ISO timestamp is used as given.
--until date
Upper bound of the time window, applied to when a session STARTED rather than to when its file was last written: a session that began after the bound is excluded even if it was already running before it. The two bounds therefore read different timestamps. Passing it without --since also drops the --hours lower bound, so the window becomes all history up to the bound. A bare YYYY-MM-DD covers that whole day through 23:59:59, shifted by --tz-offset; a full ISO timestamp is used as given. A session whose start time cannot be parsed is kept. A view that names one session by id ignores this bound, except `skarn messages <id>`, which refuses a session past it with exit 1 rather than dropping it from a list.
--tz-offset minutes
Timezone offset in minutes for date grouping and display. The default is 0.
--session id
Restrict to a single session by id (prefix-matched).
--hours n
Time window in hours; 0 searches all history (the default). The default is 0.
--all
Search all history (explicit alias for the default).
--context n
Lines of context to show around each match. The default is 2.
--role role
Restrict matches to one message role. One of: user, assistant, all. The default is all.
--source src
Restrict matches by content source kind. One of: content, tool-input, tool-result, all. The default is content.
--max-results n
Cap how many match rows are rendered; 0 means no cap. The default is 50.
--max-line-length n
Cap rendered snippet length per row; 0 means no cap. The default is 200.
--list
Print one matching session per line instead of per-match rows.
--count
Print one matching session per line with its hit count.
--output fmt
Output format for the active mode. One of: text, json. The default is text.
--json
Shorthand for --output json.
--regex
Treat the query as a PCRE2 regular expression.
--case-sensitive
Case-sensitive matching (default: case-insensitive).

Restore options

Restore a session by id.

--session id
Session id to operate on (prefix-matched); equivalent to the positional argument.

Stats options

Session analytics. A positional session id switches to a per-session view. Under --show timing the active-time number answers to three different key names depending on the output shape, and a reader that asks for the wrong one gets null rather than an error: a by-date or by-project row calls it active_secs, the ungrouped totals and a per-session view call it active_time_secs, and a by-date row carries a second number the other shapes do not, active_wall_secs. The two by-date numbers count differently. active_secs adds each session's own active time to the first day that session touched inside the window, so two sessions you ran side by side both count in full and a day can total more than the clock allows. active_wall_secs merges the same active intervals per calendar day, so overlapping sessions count once, which is the number to bill from. A per-session view lists its pauses under idle_gaps, keyed gap_secs and after_message.

--cli name
Restrict to one assistant's sessions. One of: claude, gemini, codex, cursor, copilot, kimi, grok, grokbot, antigravity.
--project path
Restrict to matching projects: a comma-separated list of names or paths, each matched case-insensitively against a session's project display name, its canonical root path, or a parent directory of that root.
--worktree path
Restrict to sessions whose working directory is under this path.
--since date
Lower bound of the time window, applied to a session file's last-modified time rather than to when the session started: a session begun before the bound but written to inside it is included, which keeps a resumed session visible under the day you worked on it. Takes priority over --hours. Naming one session by id changes this: `skarn search --session <id>`, `skarn stats <id>` and the other per-session views ignore the window altogether, and `skarn messages <id>` is the single command that applies it there, reading BOTH bounds against the session's own start time rather than its file and refusing an out-of-window session with exit 1 instead of filtering it out. A bare YYYY-MM-DD means that day's midnight, shifted by --tz-offset; a full ISO timestamp is used as given.
--until date
Upper bound of the time window, applied to when a session STARTED rather than to when its file was last written: a session that began after the bound is excluded even if it was already running before it. The two bounds therefore read different timestamps. Passing it without --since also drops the --hours lower bound, so the window becomes all history up to the bound. A bare YYYY-MM-DD covers that whole day through 23:59:59, shifted by --tz-offset; a full ISO timestamp is used as given. A session whose start time cannot be parsed is kept. A view that names one session by id ignores this bound, except `skarn messages <id>`, which refuses a session past it with exit 1 rather than dropping it from a list.
--tz-offset minutes
Timezone offset in minutes for date grouping and display. The default is 0.
--hours n
Time window in hours; 0 means no limit. The default is 168.
--session id
Restrict to a single session by id (prefix-matched).
--format fmt
Output format. csv requires --by; html writes a file (-o). One of: text, json, csv, html. The default is text.
--by axis
Group the aggregate by date or project; required for --format csv. One of: date, project.
--show list
Comma list of content blocks to render (default: overview,tokens). text and html only. One of: overview, tokens, models, tools, timing, all.
--open
Open the HTML report in a browser after writing it (--format html only).
-o, --out file
Destination file for --format html. The default is skarn-report.html.

Export options

Export session data.

--cli name
Restrict to one assistant's sessions. One of: claude, gemini, codex, cursor, copilot, kimi, grok, grokbot, antigravity.
--project path
Restrict to matching projects: a comma-separated list of names or paths, each matched case-insensitively against a session's project display name, its canonical root path, or a parent directory of that root.
--worktree path
Restrict to sessions whose working directory is under this path.
--since date
Lower bound of the time window, applied to a session file's last-modified time rather than to when the session started: a session begun before the bound but written to inside it is included, which keeps a resumed session visible under the day you worked on it. Takes priority over --hours. Naming one session by id changes this: `skarn search --session <id>`, `skarn stats <id>` and the other per-session views ignore the window altogether, and `skarn messages <id>` is the single command that applies it there, reading BOTH bounds against the session's own start time rather than its file and refusing an out-of-window session with exit 1 instead of filtering it out. A bare YYYY-MM-DD means that day's midnight, shifted by --tz-offset; a full ISO timestamp is used as given.
--until date
Upper bound of the time window, applied to when a session STARTED rather than to when its file was last written: a session that began after the bound is excluded even if it was already running before it. The two bounds therefore read different timestamps. Passing it without --since also drops the --hours lower bound, so the window becomes all history up to the bound. A bare YYYY-MM-DD covers that whole day through 23:59:59, shifted by --tz-offset; a full ISO timestamp is used as given. A session whose start time cannot be parsed is kept. A view that names one session by id ignores this bound, except `skarn messages <id>`, which refuses a session past it with exit 1 rather than dropping it from a list.
--tz-offset minutes
Timezone offset in minutes for date grouping and display. The default is 0.
--hours n
Time window in hours; 0 means no limit. The default is 168.
--session id
Restrict to a single session by id (prefix-matched).
--format fmt
Output format. One of: text, json, ndjson, html. The default is text.
--no-redact
Emit raw session content; by default every detected secret is redacted.
--out file
Write to this file instead of stdout.
--open
Open the written file in the default viewer (needs --out).
--gzip
Gzip-compress the output (needs --out, or --format html).

Cmds options

Shell commands run across sessions.

--cli name
Restrict to one assistant's sessions. One of: claude, gemini, codex, cursor, copilot, kimi, grok, grokbot, antigravity.
--project path
Restrict to matching projects: a comma-separated list of names or paths, each matched case-insensitively against a session's project display name, its canonical root path, or a parent directory of that root.
--worktree path
Restrict to sessions whose working directory is under this path.
--since date
Lower bound of the time window, applied to a session file's last-modified time rather than to when the session started: a session begun before the bound but written to inside it is included, which keeps a resumed session visible under the day you worked on it. Takes priority over --hours. Naming one session by id changes this: `skarn search --session <id>`, `skarn stats <id>` and the other per-session views ignore the window altogether, and `skarn messages <id>` is the single command that applies it there, reading BOTH bounds against the session's own start time rather than its file and refusing an out-of-window session with exit 1 instead of filtering it out. A bare YYYY-MM-DD means that day's midnight, shifted by --tz-offset; a full ISO timestamp is used as given.
--until date
Upper bound of the time window, applied to when a session STARTED rather than to when its file was last written: a session that began after the bound is excluded even if it was already running before it. The two bounds therefore read different timestamps. Passing it without --since also drops the --hours lower bound, so the window becomes all history up to the bound. A bare YYYY-MM-DD covers that whole day through 23:59:59, shifted by --tz-offset; a full ISO timestamp is used as given. A session whose start time cannot be parsed is kept. A view that names one session by id ignores this bound, except `skarn messages <id>`, which refuses a session past it with exit 1 rather than dropping it from a list.
--tz-offset minutes
Timezone offset in minutes for date grouping and display. The default is 0.
--hours n
Time window in hours; 0 means no limit. The default is 168.
--format fmt
Output format. One of: text, json. The default is text.
--session id
Restrict to a single session by id (prefix-matched).
-v, --verbose
Verbose output.
--failed
Only show entries that failed (errored or exited non-zero).
--unique
Collapse duplicates, keeping the latest occurrence.
--cmd text
Case-insensitive substring filter on the command text.

Mcps options

MCP calls made across sessions.

--cli name
Restrict to one assistant's sessions. One of: claude, gemini, codex, cursor, copilot, kimi, grok, grokbot, antigravity.
--project path
Restrict to matching projects: a comma-separated list of names or paths, each matched case-insensitively against a session's project display name, its canonical root path, or a parent directory of that root.
--worktree path
Restrict to sessions whose working directory is under this path.
--since date
Lower bound of the time window, applied to a session file's last-modified time rather than to when the session started: a session begun before the bound but written to inside it is included, which keeps a resumed session visible under the day you worked on it. Takes priority over --hours. Naming one session by id changes this: `skarn search --session <id>`, `skarn stats <id>` and the other per-session views ignore the window altogether, and `skarn messages <id>` is the single command that applies it there, reading BOTH bounds against the session's own start time rather than its file and refusing an out-of-window session with exit 1 instead of filtering it out. A bare YYYY-MM-DD means that day's midnight, shifted by --tz-offset; a full ISO timestamp is used as given.
--until date
Upper bound of the time window, applied to when a session STARTED rather than to when its file was last written: a session that began after the bound is excluded even if it was already running before it. The two bounds therefore read different timestamps. Passing it without --since also drops the --hours lower bound, so the window becomes all history up to the bound. A bare YYYY-MM-DD covers that whole day through 23:59:59, shifted by --tz-offset; a full ISO timestamp is used as given. A session whose start time cannot be parsed is kept. A view that names one session by id ignores this bound, except `skarn messages <id>`, which refuses a session past it with exit 1 rather than dropping it from a list.
--tz-offset minutes
Timezone offset in minutes for date grouping and display. The default is 0.
--hours n
Time window in hours; 0 means no limit. The default is 168.
--format fmt
Output format. One of: text, json. The default is text.
--session id
Restrict to a single session by id (prefix-matched).
-v, --verbose
Verbose output.
--failed
Only show entries that failed (errored or exited non-zero).
--unique
Collapse duplicates, keeping the latest occurrence.
--mcp name
Substring filter on the MCP tool name.

Tools options

Tools used across sessions.

--cli name
Restrict to one assistant's sessions. One of: claude, gemini, codex, cursor, copilot, kimi, grok, grokbot, antigravity.
--project path
Restrict to matching projects: a comma-separated list of names or paths, each matched case-insensitively against a session's project display name, its canonical root path, or a parent directory of that root.
--worktree path
Restrict to sessions whose working directory is under this path.
--since date
Lower bound of the time window, applied to a session file's last-modified time rather than to when the session started: a session begun before the bound but written to inside it is included, which keeps a resumed session visible under the day you worked on it. Takes priority over --hours. Naming one session by id changes this: `skarn search --session <id>`, `skarn stats <id>` and the other per-session views ignore the window altogether, and `skarn messages <id>` is the single command that applies it there, reading BOTH bounds against the session's own start time rather than its file and refusing an out-of-window session with exit 1 instead of filtering it out. A bare YYYY-MM-DD means that day's midnight, shifted by --tz-offset; a full ISO timestamp is used as given.
--until date
Upper bound of the time window, applied to when a session STARTED rather than to when its file was last written: a session that began after the bound is excluded even if it was already running before it. The two bounds therefore read different timestamps. Passing it without --since also drops the --hours lower bound, so the window becomes all history up to the bound. A bare YYYY-MM-DD covers that whole day through 23:59:59, shifted by --tz-offset; a full ISO timestamp is used as given. A session whose start time cannot be parsed is kept. A view that names one session by id ignores this bound, except `skarn messages <id>`, which refuses a session past it with exit 1 rather than dropping it from a list.
--tz-offset minutes
Timezone offset in minutes for date grouping and display. The default is 0.
--hours n
Time window in hours; 0 means no limit. The default is 168.
--format fmt
Output format. One of: text, json. The default is text.
--session id
Restrict to a single session by id (prefix-matched).
-v, --verbose
Verbose output.
--failed
Only show entries that failed (errored or exited non-zero).
--unique
Collapse duplicates, keeping the latest occurrence.
--tool name
Substring filter on the tool name.

License options

Show the active license, install one, or renew it. With no argument it reports what license this machine is using, where it came from, and when it expires. With a file argument (or - to read stdin) it validates the license and installs it to the config directory, backing up any license already there. With renew it asks the license service for a fresh license for the current subscription, verifies the reply against the signing keys built into this binary BEFORE writing a single byte, and installs it; a reply that does not verify is discarded and nothing is written. Verification is not signature-only: the reply must move the expiry forward, and must carry the same license id when both the installed and the returned license are version 2 claims, so a valid artifact belonging to another subscription is refused too. Renewal happens only when you type it - check, the recall verbs, serve, and guard never renew on their own - and it is the only network call skarn makes besides the opt-in maintained-feed fetch. The endpoint defaults to https://api.getskarn.com and is overridable with $SKARN_LICENSE_RENEW_URL; it must be https (plain http is accepted only against loopback) and carry no userinfo, so a poisoned environment cannot send the license anywhere in cleartext nor hand credentials to the endpoint. Renew exits 0 when the new license is installed; 1 when the renewal could not complete and nothing was installed (the service could not be reached, there is nothing to renew, a local target was refused, the reply was unreadable, or a verified artifact could not be written); 2 when the service declines (its reason is printed as the service worded it, after any echo of the license token is redacted, terminal control bytes are masked, and the text is capped at 300 bytes); and 3 when the reply fails verification. It never prints the license token itself, and it works without a license - and with an expired one - so it never exits 5.

-o, --output path
Write the renewed license to this path instead of the config directory. Only applies to `skarn license renew`; the file is written with owner-only permissions, and an existing file that is not a readable Skarn license is never overwritten.
-y, --yes
Replace an already-installed license without asking. Required when installing over an existing license from a script or from stdin, where there is no terminal to confirm on.
--json
Print the license status as a single JSON object (state, tier, licensee, org, seats, license id, issue and expiry dates, days remaining, and the source it was resolved from). The license token is never included.

Exit status

0
Clean, or informational output only. For mcp: the client closed stdin, which is the normal shutdown.
1
A finding at or above the --fail-on-severity threshold was reported. For setup and guard --self-test: a target was refused or a verification failed. For doctor: at least one check FAILED (a warning never exits nonzero), or an unknown option was passed, which is refused before any check runs. For restore: the recorded working directory or session id could not be rendered safely, so nothing was printed to stdout and the refusal is named on stderr. Also: the first-run EULA prompt was declined, so the invoked operation was not performed. For mcp: an unknown argument was passed, which is refused before the protocol loop starts.
2
The session risk score exceeded the --fail-on-risk threshold. For setup: no terminal to prompt on and no --yes; the exact non-interactive command is printed.
3
A canary token was triggered (a proven breach). This overrides every other gate.
4
A policy precondition was not met: a required rule is missing, the policy requires a baseline and none was supplied or the one supplied did not load, or the --policy file itself could not be read or parsed. A policy that never loaded is never quietly replaced by the embedded default, which gates nothing.
5
A requested paid feature is not covered by the active license (a Team or Enterprise flag without a matching license). Refused before any scan. With no usable license at all, check exits 7 before this gate is consulted.
6
The scan could not complete: session discovery or the scan itself failed, including a home directory that could not be resolved so no session store was ever located, or the detection rules could not be loaded so no scan was ever defined. Under --fail-on-scan-error an otherwise-complete run exits 6 as well whenever coverage was partial: a tool source dropped out of discovery, a session store that exists could not be read, a session's parse failed, the assistant compacted a session before the scan so its transcript no longer holds the whole conversation, or the run read no sessions at all. That verdict is computed BEFORE the report is rendered, so the SARIF invocation exit code, the evidence pack and the audit record all carry 6 rather than describing the run as clean and then exiting nonzero. A --baseline-create run is refused on the same condition before it writes anything, so an incomplete scan is never recorded as the accepted set and the previous baseline survives. For check --audit-verify: the audit log exists but could not be read, so the hash chain was never examined (an absent log is a valid empty chain and exits 0). For vet: the home directory could not be resolved (so the configuration was never located at all), or a config file that exists was not read - it could not be read, or it was skipped because it is not that assistant's live configuration - so the configuration was only partly seen. For doctor: the home directory could not be resolved, so every per-agent check would have read an empty machine - a diagnostic that could not be produced is never reported as a healthy one. Fail-closed - the result is not trustworthy, so it is never reported as clean.
7
No usable license is present - none was found via SKARN_LICENSE, SKARN_LICENSE_FILE, or the installed file - so check, or baseline audit, refused before scanning. The free license is issued at https://getskarn.com/free after a quick email confirmation and is verified offline. Also used when a license IS present but cannot be used at all (revoked, tampered, or signed by an untrusted key). assess, baseline accept, guard accept, the recall commands, guard, setup, doctor, vet, mcp, license, taxonomies and check --audit-verify never exit 7.

Environment

SKARN_SCAN_THREADS
Cap the worker-pool size for every parallel sweep (scan and recall). 1 forces a fully serial run. Default is one worker per core, capped at 32. Output is identical for any value; only latency changes.
SKARN_CLAUDE_DIRS
Colon-separated list of additional Claude Code config directories to scan beyond the default ~/.claude. Each entry is the dir that contains projects/. Roots are de-duplicated.
SKARN_TZ_OFFSET
Default timezone offset in hours applied when grouping recall and stats output by time.
SKARN_NO_TIMING
Suppress the dim "Scanned N sessions" timing line that recall commands print after a text run on a terminal. Already auto-suppressed for json/sarif/csv output and for non-TTY stdout.
SKARN_LICENSE_RENEW_URL
License-service endpoint `skarn license renew` posts to. Defaults to https://api.getskarn.com/v1/license/renew; set it to route renewals through an enterprise proxy. It must be https - plain http is accepted only against loopback - and must carry no userinfo, so a poisoned environment can neither redirect the license token to a cleartext endpoint nor hand credentials to the one it names. No other command reads it.
SKARN_GUARD_LOG
Path to append a redacted JSONL verdict log for guard (opt-in; off when unset). One record per flagged call, carrying the fingerprint that `skarn guard accept` takes; clean calls, and calls whose every finding was already accepted, are not logged.
GEMINI_CLI_SYSTEM_SETTINGS_PATH
Gemini CLI's own override for the path of its managed system settings file; when set it REPLACES the default path, and Gemini also takes its system-defaults file from that path's directory. Doctor resolves both gemini managed layers through it, so the report names the files Gemini actually reads instead of default files it is not.
GEMINI_CLI_SYSTEM_DEFAULTS_PATH
Gemini CLI's own override for the path of its system-defaults file, the lowest of its four settings layers; when set it REPLACES the path Gemini would otherwise derive from the system settings file's directory, and doctor reads the override.
SKARN_IDENTITY
Local-first identity tag (an SSO subject, email, or CI principal) stamped as provenance on baseline entries created with --baseline-create. Optional; Skarn never authenticates it.
SKARN_ORG
Local-first org or team tag stamped alongside SKARN_IDENTITY on baseline provenance. Optional.
SKARN_FEED_URL
Default feed channel URL used by check --update-rules when --feed-url is not given (Team). It must be https - plain http is accepted only against loopback - and must carry no userinfo, so a poisoned environment cannot redirect the subscriber credential to a cleartext endpoint.
SKARN_FEED_TOKEN
Credential presented to the subscriber feed channel when fetching a maintained feed (Team).
SKARN_EULA_ACCEPTED
Set to 1 to suppress the first-run EULA prompt and notice for this run without recording anything (ephemeral environments: containers, CI). Running Skarn constitutes acceptance either way; `skarn eula accept` records it durably instead.
SKARN_LICENSE
License token, resolved before SKARN_LICENSE_FILE and the config path.
SKARN_LICENSE_FILE
Path to a file containing a license token.
CLAUDE_CONFIG_DIR
A Claude Code config directory visible in Skarn's environment; folded into the claude source like an SKARN_CLAUDE_DIRS entry.
COPILOT_HOME
GitHub Copilot CLI config directory visible in Skarn's environment; replaces the default ~/.copilot as the Copilot CLI session root, folded into the copilot source. Microsoft Scout sets this variable to ~/.scout/copilot in the environment of the Copilot CLI child process it spawns, not in the user's shell, so Skarn's own process never sees it; that root is therefore declared in the scanner rather than read from here, exactly as $CODEX_HOME and $KIMI_CODE_HOME are declared rather than guessed.
GROK_HOME
Grok Build config directory visible in Skarn's environment; replaces the default ~/.grok as grok's home. Setup writes its hook file under the directory this names, doctor resolves grok's config, requirements, hooks, and folder-trust files from it, and the guard's own behavior does not depend on it.
GROK_FOLDER_TRUST
Grok Build's folder-trust switch. Set to 0 it ungates every project-scope hook, MCP server, and LSP server, the same as [folder_trust] enabled = false in grok's config; doctor honors it, so a machine that turned the gate off is not reported as having a silenced project hook layer.
KIMI_CODE_HOME
A Kimi Code CLI data directory visible in Skarn's environment; replaces the default ~/.kimi-code as the kimi root, folded into the kimi source, de-duplicated, resolved via expandPath. Each root contributes its sessions/ and user-history/ children.
GROK_HOME
A Grok Build data directory visible in Skarn's environment; grok itself REPLACES ~/.grok with $GROK_HOME rather than layering. Skarn folds both roots, de-duplicated, resolved via expandPath, and accepts each only when it has a sessions/ child.
CODEX_HOME
A Codex CLI data directory visible in Skarn's environment; folded into the codex source alongside the default ~/.codex, de-duplicated, resolved via expandPath. Each root contributes its sessions/ and archived_sessions/ children.
SKARN_MANAGED_ROOT
Prefix prepended to every managed (MDM/fleet) config path skarn reads, so a fleet admin can validate a staged payload directory before pushing it to a single machine (SKARN_MANAGED_ROOT=/tmp/stage skarn doctor). Empty or unset means the live system paths. Doctor applies it to every managed hook config it inspects, and vet applies it to the system Grok Build layers it reads at /etc/grok. Whenever it is active, doctor echoes the root in the report header and emits it as managed_root in --json, so a staged report can never pass as a report about the live machine. It is a filesystem prefix and cannot re-root a registry key; SKARN_MANAGED_REGISTRY_ROOT does that.
SKARN_MANAGED_REGISTRY_ROOT
Registry key doctor reads the Windows managed policy keys under, so an administrator can validate a staged payload without touching live Group Policy (SKARN_MANAGED_REGISTRY_ROOT='HKCU\Software\SkarnStage' skarn doctor reads HKCU\Software\SkarnStage\HKLM\SOFTWARE\Policies\ClaudeCode). The hive stays a key component under the staging root, so the HKLM and HKCU policies stage to distinct keys instead of collapsing into one. Empty or unset means the live policy keys. Whenever it is active, doctor echoes it in the report header and emits it as managed_registry_root in --json, so a staged report can never pass as a report about the live machine. Only doctor reads it.
GROK_HOME
Grok Build's data and configuration directory, default ~/.grok. It relocates the whole Grok home surface that vet reads - config.toml, managed_config.toml, requirements.toml, the hooks/ directory, and the hooks-paths registry - so a relocated Grok home is examined rather than reported clean over an unexamined one.
XDG_CACHE_HOME
Base directory for the rule and feed cache. Defaults to ~/.cache, so the cache lives in ~/.cache/skarn/.

Files

/Library/Application Support/ClaudeCode/managed-settings.json, /etc/claude-code/managed-settings.json, C:\Program Files\ClaudeCode\managed-settings.json
Claude Code's MDM-managed settings (macOS, Linux/WSL, Windows). Doctor reads the platform's path as the claude host's managed precedence layer; allowManagedHooksOnly: true there makes every lower layer inert. Read-only; never written.
/Library/Application Support/ClaudeCode/managed-settings.d/*.json, /etc/claude-code/managed-settings.d/*.json, C:\Program Files\ClaudeCode\managed-settings.d\*.json
Claude Code's managed-settings drop-in directories (macOS, Linux/WSL, Windows), where separate teams deploy policy fragments without editing one shared file. Doctor folds a drop-in directory into the SAME managed layer as its sibling managed-settings.json, in the order Claude merges them: the base file first, then every *.json in the directory sorted by name, wired hooks unioned and de-duplicated on the event and the exact command, and allowManagedHooksOnly taken from the last file that sets it (a file that omits the key changes nothing, a file that sets it to false unlocks an earlier true). Files whose name begins with a dot are ignored, as Claude ignores them. Read-only; never written.
C:\ProgramData\ClaudeCode\managed-settings.json
Claude Code's retired Windows managed-settings path. Claude Code no longer reads it, so doctor never counts a hook found there as protection: when the file is present the report carries a note naming the supported path (C:\Program Files\ClaudeCode\managed-settings.json), and when it is absent it is silent. Read-only; never written.
HKLM\SOFTWARE\Policies\ClaudeCode, HKCU\SOFTWARE\Policies\ClaudeCode
Claude Code's Windows registry policy keys (a Settings value of type REG_SZ or REG_EXPAND_SZ holding the same JSON), deployed through Group Policy or Intune. Doctor reads both as claude's managed precedence layer, so a fleet managed entirely through Group Policy or Intune is examined and reported rather than skipped, and finding one is enough to examine the host even where Claude Code itself is not installed. A REG_EXPAND_SZ value is read verbatim without environment expansion, because doctor reports what is configured rather than what it resolves to on this machine. Claude Code reads exactly ONE managed source and ignores the rest rather than merging them, so HKLM outranks the system managed-settings.json and its drop-ins, which in turn outrank the user-writable HKCU key; doctor reports the loser as inert and never counts its hooks as protection. A key that exists but whose Settings value cannot be read or is not a string type is a coverage gap with a reg query command, never a silent absent. On WSL, Claude Code reads the Windows policy chain in addition to /etc/claude-code and gives the Windows sources priority (wslInheritsWindowsSettings, default true), and a Linux skarn binary cannot reach the registry from there; rather than report a protection a Windows-side allowManagedHooksOnly may already have silenced, doctor downgrades what would have been a claude pass to a warn naming that unread chain, with /status inside Claude Code as the way to see which source won. Read-only; never written.
server-managed settings (claude.ai admin console or a self-hosted Claude apps gateway)
Claude Code's remotely delivered managed settings, fetched at sign-in with no local file on the machine, and the policyHelper executable that can compute managed settings at startup. Both outrank every local managed source and, because Claude Code reads only its highest-precedence managed source, both silence the file and registry policies doctor can read. Doctor cannot read either, so a fleet whose hooks arrive only through one of them sees its machines report as unwired. Declared here so the set of managed delivery surfaces is complete. Confirm the winning source on a machine with /status inside Claude Code, which names the delivery channel it loaded.
/Library/Managed Preferences/com.anthropic.claudecode.plist
Claude Code's macOS managed-preferences domain, and the macOS half of the same MDM/OS-level policy tier the Windows HKLM registry key occupies. Declared as a managed source but not decoded; when present, doctor surfaces it as a coverage gap with a plutil command to inspect it, never as a silent absent. Because that tier outranks the file-delivered policy and Claude Code reads only its highest-precedence managed source, a present plist also stops doctor from counting the hooks in managed-settings.json and its drop-ins: those hooks genuinely never run, and the report names the coverage gap instead of a protection it cannot confirm.
/etc/codex/requirements.toml, %ProgramData%\OpenAI\Codex\requirements.toml
Codex's admin-managed requirements file (Unix, Windows). Doctor reads it as the codex host's managed precedence layer via a scoped reader (allow_managed_hooks_only and the [[hooks.<Event>.hooks]] command strings); allow_managed_hooks_only = true makes every lower layer inert. Read-only; never written.
/Library/Managed Preferences/com.openai.codex.plist
Codex's macOS managed-preferences domain. Doctor decodes the XML form (requirements_toml_base64 and config_toml_base64, base64 to the same scoped reader; the lockdown key is honored only from the requirements payload, matching Codex); a binary plist is surfaced as present-but-unreadable with the exact plutil conversion command, never as a silent absent.
/Library/Application Support/Cursor/hooks.json, /etc/cursor/hooks.json, C:\ProgramData\Cursor\hooks.json
Cursor's enterprise-managed hooks (macOS, Linux/WSL, Windows). Doctor reads the platform's path as the cursor host's managed precedence layer; Cursor layers additively (no lockdown key), so every present layer stays active. Read-only; never written.
/etc/github-copilot/policy.d/*.json, C:\ProgramData\GitHub\Copilot\policy.d\*.json
Copilot's managed policy directory (Linux/macOS, Windows). Doctor folds every *.json in the directory into the copilot host's managed precedence layer, sorted by name; Copilot layers additively. Read-only; never written.
/Library/Application Support/GeminiCli/system-defaults.json, /etc/gemini-cli/system-defaults.json, C:\ProgramData\gemini-cli\system-defaults.json
Gemini CLI's managed system-defaults file, the LOWEST of its four settings layers. Read by doctor. Gemini derives this path from the system settings file's directory, so $GEMINI_CLI_SYSTEM_SETTINGS_PATH moves it too, and $GEMINI_CLI_SYSTEM_DEFAULTS_PATH replaces it outright; doctor reads whichever path wins. Hook arrays concatenate across layers, so hooks declared here run alongside the user and project ones rather than replacing them.
/Library/Application Support/GeminiCli/settings.json, /etc/gemini-cli/settings.json, C:\ProgramData\gemini-cli\settings.json
Gemini CLI's managed system settings file, the HIGHEST of its four settings layers - it has the final say on single-valued settings such as hooksConfig.enabled. Read by doctor. $GEMINI_CLI_SYSTEM_SETTINGS_PATH replaces this path, and doctor reads whichever path wins; hook arrays still concatenate across every layer.
./.claude/settings.local.json
Claude Code's personal, gitignored project settings file. Claude merges hooks across every settings file and runs all of them, so a guard wired here protects this checkout and doctor reads it as part of the project layer. skarn setup never writes it: it writes only the committed ./.claude/settings.json.
./.claude/settings.json, ./.cursor/hooks.json, ./.codex/hooks.json, ./.github/hooks/skarn.json, ./.gemini/settings.json
A repo's committed project-scope hook configs (claude, cursor, codex, copilot, gemini), written by skarn setup --scope project and read by doctor as each host's project precedence layer whenever it runs in that directory. Portable by construction: the committed command resolves skarn from PATH at run time and exits 0 where skarn is not installed. Gemini reads one command field on every platform, so its committed POSIX command fails open on a native-Windows teammate; Gemini also runs hooks from a project file only once that folder is trusted.
./.github/copilot/settings.json, ./.github/copilot/settings.local.json, ~/.copilot/settings.json
The repository's Copilot CLI settings. Doctor reads one key from them, the top-level disableAllHooks: setting it true makes the CLI skip the user- and repository-delivered hooks for that repository, the operator's own user-scope hook included, and the CLI logs nothing when it does - so the state is knowable only by reading the file. The settings.local.json sibling takes precedence over settings.json, the CLI reads the same key from the shared cross-tool ./.claude/settings.json and ./.claude/settings.local.json, and the operator's own ~/.copilot/settings.json (honoring $COPILOT_HOME) carries it too, outranked by whatever the repository says. Doctor resolves the repository scope first and consults the user file only when no repository file spoke; nothing documents which of the two repository families wins when they disagree, so it honors a true from either - reporting a live hook as silenced is a false alarm the named file settles, reporting a silenced hook as protection is not. Comments are tolerated on a retry, since the CLI documents JSONC for its settings. When the switch is on, doctor reports the user and project hook layers as inert instead of counting them as protection, and reports a managed policy layer as unverified rather than as either: it does not stop a host application's own in-process hooks, which arrive through the SDK rather than from a file, and whether it skips machine policy hooks is unmeasured (GitHub documents them as exempt). Read-only; never written.
~/.grokbot/
Grok Bot's local-exec daemon state: the daemon connection and credential files, its settings.json, and its rolling daemon log. Doctor detects this directory and the daemon-connection file beside it by stat alone, each one only when the path is of the kind its name implies, and reads exactly one body here, settings.json, for the egressTunnelEnabled and webauthnProxyEnabled posture flags, and only once the open handle itself proves the path is that file and nothing else, so a symlink, a hard link to a credential file, or a pipe left at that name is refused unread. The connection and credential files hold a gateway bearer token, a network token and VNC proxy tokens; skarn never opens them, so no value from them can reach the report. Presence is reported as a warning with no fix, because no skarn command changes it and what to do about an installed app is the operator's decision. Read-only; never written.
~/Library/Application Support/Grok Bot/ (macOS)
Grok Bot's desktop app root. Doctor detects the directory and its sand-secrets.json and sand-client-persistence markers by stat alone and reads no body here at all; the transcript replicas under sand-client-persistence are scanned by the session commands, not by doctor, and the token store is never opened by either. The probe is macOS-only, the platform where the app root is observed; ~/.grokbot/ is checked on every platform. Read-only; never written.
~/.skarn.json
Optional tool configuration: the set of assistant tools and their session paths, plus an optional projects alias map. Both the recall surface and scan discovery (check, assess, baseline audit, the guard's stop advisory, and serve's Security scan) read it, so the security scan and the recall views cover the same corpus. Falls back to a builtin tool set when absent. The projects object renames a resolved project for display: a key beginning ~, $, or / is a path prefix (with ~ and a leading $VAR expanded like sessions_path) matched against the canonical project root on whole path components, longest match winning (so /repo never matches /repository); any other key matches the derived display name case-insensitively; the value is the shown name. Aliases change display only, never the grouping key.
~/.cache/skarn/
Cache for downloaded rule and maintained-feed bundles. Honors $XDG_CACHE_HOME.
~/.config/skarn/baseline.json
The personal accepted-findings baseline, applied automatically when it exists: check, assess, and serve suppress its findings, and guard stops flagging them. Written by `skarn baseline audit/accept` and `skarn guard accept`. Honors $XDG_CONFIG_HOME. Holds fingerprints and hashes, never a raw secret; treat it as sensitive.
~/.config/skarn/eula-accepted.json
The EULA acceptance record: the agreement version accepted, an RFC-3339 timestamp, and the method (prompt, command, env, license-install). Written when acceptance is given at the first-run prompt, by `skarn eula accept`, or by installing a License Token; never transmitted. Honors $XDG_CONFIG_HOME.
~/.claude/
Default Claude Code session root. Scanned automatically; extend with $SKARN_CLAUDE_DIRS or $CLAUDE_CONFIG_DIR.
~/.kimi-code/sessions/
Default Kimi Code CLI session root. One wire.jsonl per agent under sessions/<workdir-key>/<session-id>/agents/<agent>/; the main conversation and every sub-agent stream are scanned as separate sessions. Relocate with $KIMI_CODE_HOME. The legacy python kimi-cli root ~/.kimi/ stores a different session layout and is not scanned; `kimi migrate` converts it.
~/.kimi-code/user-history/
Kimi Code CLI's per-working-directory prompt log, one file per workdir. Scanned in addition to the session tree because it retains prompts from sessions that were aborted before any session record was written. Relocate with $KIMI_CODE_HOME.
~/.grok/sessions/
Default Grok Build session root. One transcript per session at sessions/<url-encoded-cwd>/<session-id>/chat_history.jsonl, with a summary.json sidecar beside it read for the session id, branch, timestamps, model, and title, and for the working directory when the path yields none. The session title is scanned like any other session text. The recorded working directory is re-derived from the group directory name, or from its .cwd file when the encoded path exceeds 255 bytes, and a recorded directory always wins over the sidecar's copy. Relocate with $GROK_HOME. updates.jsonl, rewind_points.jsonl, events.jsonl, and session_search.sqlite are deliberately not scanned. One more sidecar, signals.json, is read for two counters only, compactionCount and totalTokensBeforeCompaction, and so is a non-empty compaction_checkpoints or compaction directory beside the transcript: Grok rewrites the transcript when it compacts a session, dropping turns from the only file Skarn reads, so a session either signal reports as compacted is counted as incomplete coverage and named in the scan-incomplete warning. The two signals are independent, not a fallback chain, and either alone is enough - a saved checkpoint means the compaction happened whatever a sidecar written at some other moment says, since under-reporting a coverage gap is the failure this counter exists to prevent. An empty checkpoint directory counts for nothing, and neither does a sidecar that is missing, unreadable, or malformed; the counter is raised only by a positive signal, never by the absence of one. No session content is taken from either.
~/.grok/sessions/<url-encoded-cwd>/prompt_history.jsonl
Grok Build's per-working-directory prompt log, one file beside the session directories of that working directory. Each record carries a session id, so one file holds prompts from many sessions and each id is scanned as its own session. It is read in addition to the transcripts because it outlives them: a removed, pruned, or partially copied session directory takes its transcript and leaves its prompts, text cleared before it was sent is saved here and reaches no transcript at all, a shell prompt entered with a leading ! is recorded here as the bare command it ran, without the !, while the transcript keeps only a rendered narration of it, and a torn transcript still leaves the prompt. It is not a record of prompts written ahead of the transcript: the transcript is written first. A prompt the session transcript already carries is dropped at scan time, matched on the exact text within the same working directory and session id, so it is reported once; a session whose every prompt the transcript carries does not appear at all. Shell prompts additionally surface in `skarn cmds`. Relocate with $GROK_HOME.
~/Library/Application Support/Grok Bot/sand-client-persistence/ (macOS), ~/.config/Grok Bot/sand-client-persistence/ (Linux), the same directory under %APPDATA% (Windows)
Grok Bot's local chat-transcript store. Grok Bot is xAI's always-on desktop agents app, a different product from the Grok Build CLI above, and it keeps a cleartext replica of every agent's conversation here, so a credential pasted into a Grok Bot chat lands on disk in this directory. One .blob per persistence slice, each filename the base32 (RFC 4648, unpadded lowercase) encoding of a dotted slice name; Skarn decodes the name and scans only a sand.client.slice.account.<account>.transcript.replicas.<agent-id> slice, so the drafts, roster, send-journal, selection, sidebar and client-meta slices beside it are excluded by construction. One replica is one session, identified by the decoded agent id; two blobs decoding to the same agent id are reported once. A .blob whose filename does not decode is counted once per store as incomplete coverage, so it raises the unreadable-store count in the scan-incomplete warning rather than being passed over as if the store were fully read; the warning carries that count, not the offending filename. A document whose value object is missing, is not an object, or holds an entries member that is not an array is reported unreadable rather than as an empty session; an absent entries member reads as an empty replica only at the schema version Skarn was verified against, so a truncated envelope or a later schema that renamed the collection is reported unreadable too, as is a non-empty entries array in which no entry declares a kind Skarn knows; an entries array is otherwise read whatever version the document declares, and recognized entries that carry no text of their own still make a valid session. On Linux the store list also folds $XDG_CONFIG_HOME, where Electron places userData when that variable is set, and an agent replicated into both roots is still reported once. Inside the document, whatever carries a content string is read - an agent turn at message.content and a chat message at content, both stamped in epoch milliseconds - whatever type the entry declares, so a body type a later Grok Bot release introduces is scanned rather than skipped; the widget, attachment and approval bodies observed keep their payload under other keys and so contribute no text. No entry records a working directory, so a Grok Bot session has no project and is left out of project grouping rather than named after the directory it sits in. The app names no data-directory variable, so there is nothing to relocate the store with. Grok Bot's other local files are deliberately not read: its OAuth token store, its local-exec daemon state under ~/.grokbot/, the Electron cookie stores, and its crash and memory telemetry.
~/.copilot/session-state/, ~/.scout/copilot/session-state/
GitHub Copilot CLI session roots, one events.jsonl per session under <root>/session-state/<id>/. Both are scanned automatically, each only when it has a session-state child, and $COPILOT_HOME adds another when it names a distinct root. The second root is Microsoft Scout's: Scout spawns the Copilot CLI it bundles with COPILOT_HOME=~/.scout/copilot, so that CLI writes its transcript there in the clear, in the same format as the standalone CLI. What Skarn reads is that transcript. Discovery under ~/.scout goes to that one location and nowhere else: the root directory is never enumerated, so Scout's own encrypted session index (~/.scout/m-sessions/sessions-index.json) and every other m- file are outside what discovery reaches, and nothing is decrypted. Naming a project for the session afterwards uses the same resolver every source uses, on two inputs: a workspace.json probed three directories above the transcript, which neither the Copilot CLI nor Scout writes, and the cwd the transcript itself names, with a .git pointer stat'd beside it. No session content is read from either. Verified against Scout 0.23.331, 2026-07-30.
~/.claude/settings.json, ~/.claude/settings.local.json, ./.claude/settings.json, ./.claude/settings.local.json
Claude Code hooks and permission grants. Read, never written. The project-local pair is read at the current working directory only.
~/.claude.json, ~/.mcp.json, ./.mcp.json
Claude Code MCP server definitions. Read, never written. The project-local file is read at the current working directory only.
~/.claude/plugins/, ~/.claude/skills/
Installed Claude Code plugins and skills. Vet reads each plugin.json and skill.json manifest for its install source and integrity marker.
~/.codex/sessions/, ~/.codex/archived_sessions/
Default Codex CLI session roots, scanned automatically and extendable with $CODEX_HOME. zstd-compressed rollouts (rollout-*.jsonl.zst, written for rollouts older than 7 days) are read transparently through the same reader as plain ones; when both a plain rollout and its .zst twin exist the plain one is preferred. Only rollout-* files are parsed as sessions, so session_index.jsonl is never scanned.
~/.codex/config.toml, ~/.codex/hooks.json
Codex CLI MCP server definitions and hooks. Read by vet through a scoped reader that keeps only the launcher command, its arguments, and the endpoint url.
~/.cursor/mcp.json, ~/.cursor/hooks.json
Cursor MCP server definitions and hooks. Read by vet.
~/.copilot/mcp-config.json, ~/.copilot/config.json
Copilot CLI MCP server definitions and configuration. Read by vet.
~/.scout/m-settings.json
Microsoft Scout's permission grants, auto-approve settings, and experiment flags. Read by vet. The path has no platform branch: Scout resolves its config root to ~/.scout on macOS and Windows alike.
~/.scout/m-mcp-servers.json
Microsoft Scout MCP server definitions. Read by vet. A user-added server nests its launcher or endpoint under a config object; a built-in server has none, because Scout generates its command at run time.
~/Library/Application Support/Grok Bot/sand-secrets.json
Grok Bot's plaintext third-party credential store, on macOS. Grok Bot is xAI's always-on desktop agents app, a different product from the Grok Build CLI. Vet opens this file, reads its POSIX mode off that handle, and reads not one byte of its contents: the store is never parsed, so no key name and no value from it can reach a finding and the only thing reported is whether the mode grants group or other read. The path is opened without following a symlink, so a symlinked store is reported as a coverage gap rather than measured through its target. The path carries no platform branch, so the store is vetted on macOS only; the Linux and Windows locations are not read.
~/.grokbot/settings.json
Grok Bot's local-exec daemon settings. Vet reads two declared posture flags from this file, egressTunnelEnabled and webauthnProxyEnabled, and records only whether each is present and true. A flag that is absent or false raises nothing, so a default install stays silent.
~/.grokbot/local-exec-daemon-connection.json
Grok Bot's local-exec daemon connection state: the gateway base URL and the bearer, network and VNC proxy tokens the cloud side uses to execute on this machine. Vet treats the file as a presence signal and never parses it, so the gateway URL and every token stay out of the process as well as out of every output format. Its presence declares that the channel is configured, not that a daemon is running.
~/.gemini/settings.json
Gemini CLI's user settings: MCP server definitions read by vet, and the hooks object skarn setup --agent gemini merges the guard into and doctor reads as the user precedence layer.
~/.grok/hooks/skarn.json
Grok Build's user hook file for skarn, written whole by skarn setup --agent grok and read by doctor as the setup-owned half of grok's user layer. Grok loads every *.json in ~/.grok/hooks, so skarn owns a file of its own there and never merges into a foreign one; doctor folds the directory's other files into the same layer, so a hook wired by hand under another name still counts. $GROK_HOME moves the whole directory.
.grok/hooks/skarn.json
Grok Build's project hook file for skarn, written by skarn setup --agent grok --scope project and meant to be committed. Grok runs a project hook only after the folder is trusted (/hooks-trust, or launching with --trust); until then it skips the file silently, so doctor reports that layer as inert rather than as protection.
~/.grok/trusted_folders.toml
Grok Build's folder-trust store, the single gate for whether a repository's own hooks, MCP servers, and LSP servers run. Doctor reads it to decide whether a project hook layer counts: a grant covers its folder and cascades to subdirectories, the nearest recorded folder wins (so an explicit untrust beats an ancestor's grant), and an over-broad key naming the filesystem root or the home directory is ignored the way grok ignores it. A store skarn cannot read or parse is reported as a coverage gap, never as an absent grant. Read-only; never written.
/etc/grok/managed_config.toml, ~/.grok/managed_config.toml
Grok Build's organization-distributed config, root-owned at the system path and server-synced at the user path. Doctor reads the [[hooks.<Event>]] groups through a scoped reader that takes both documented handler notations (an inline-table array and the nested [[hooks.<Event>.hooks]] form) and reports them as managed precedence layers. Grok has no managed-only lockdown key, so these layers add hooks and never silence the others. The system path exists on Unix only; grok resolves no system config directory on Windows. Read-only; never written.
/etc/grok/requirements.toml, ~/.grok/requirements.toml
Grok Build's requirements layer, the highest-authority config tier an organization distributes. Doctor reads its hooks with the same scoped reader as managed_config.toml and reports them as managed layers, which is what makes a skarn hook declared there visible to the fleet conformance check. The system path exists on Unix only. Read-only; never written.
~/.grok/config.toml
Grok Build's user config. Doctor reads two things from it: the [[hooks.<Event>]] groups a user can declare there instead of in a hook file, and [folder_trust] enabled, which when false ungates every project hook (as does GROK_FOLDER_TRUST=0) and so decides whether an untrusted project layer is reported inert at all. Read-only; never written.
~/.grok/config.toml, ~/.grok/managed_config.toml, ~/.grok/requirements.toml
Grok Build's user, managed, and requirements configuration layers. Read by vet through a scoped TOML reader that collects MCP server launchers and endpoints, the variable NAMES (never the values) of an MCP env table, the [ui] permission_mode and yolo settings, allow-action [permission] rules in both the compact string-array and the structured rules forms, hook handlers in both TOML notations, and [[ui.notifications.hooks]] commands. Relocate the whole directory with $GROK_HOME. Syntax the reader does not model, such as a multiline string, is reported as a coverage gap naming the file and line rather than skipped silently.
/etc/grok/managed_config.toml, /etc/grok/requirements.toml
Grok Build's system-wide managed and requirements layers, deployed by an organization. Read by vet exactly as the user layers are; SKARN_MANAGED_ROOT prefixes both paths, so a staged payload can be vetted before it reaches a machine.
~/.grok/hooks/, ~/.grok/hooks-paths
Grok Build's global JSON hook directory and the registry file naming extra hook sources. Vet reads the .json files one level down in the directory, regular files only, and loads each ABSOLUTE line of the registry as a further hook source; relative lines are ignored the way Grok ignores them, and a UNC or //host/share target is recorded as a coverage gap and never opened. Both are bounded per directory and per registry, and exceeding a bound is reported rather than silently truncated.
./.grok/config.toml, ./.grok/hooks/
Grok Build's project-scoped configuration and hook directory, contributing MCP servers, plugins, and permission rules. Vet reads them at the current working directory only, while Grok itself discovers a .grok/config.toml at every level from the repository root down. Project hooks are collected whatever the folder-trust state, because a declared hook becomes live the moment the folder is trusted, with no configuration change.

Guard hook hosts

The hook events skarn guard receives on each supported host, what each event lets the guard scan, and the decision channel it answers on. Events marked opt-in are supported but not wired by the shipped configs.

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.

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.

A non-zero guard exit fails closed on the blocking events. An unparseable event asks in enforce mode.

PreToolUse
Scans tool_name plus the flattened tool_input; a Bash command is also carried raw so a tool description cannot poison the package parse. Enforce: hookSpecificOutput.permissionDecision = deny or ask, with the redacted reason in permissionDecisionReason. Audit: hookSpecificOutput.additionalContext. Wired by the shipped configs. A deny blocks the call even under bypassPermissions: hooks run before permission-mode checks.
PermissionRequest
Scans the same tool_name plus tool_input as PreToolUse, at the permission-decision step. Enforce: hookSpecificOutput.decision.behavior = deny, with the redacted reason on the universal systemMessage. Audit: systemMessage. Wired by the shipped configs. 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
Scans prompt. The channel ships content off the machine, so a secret on it is exfiltration. Enforce: top-level decision = block, with the redacted reason in reason. Audit: hookSpecificOutput.additionalContext. Wired by the shipped configs. A lone secret is enough to deny here - the destination is not ambiguous. The block is recoverable: the user edits and resubmits.
UserPromptExpansion
Scans command_name and expanded_prompt. The channel ships content off the machine, so a secret on it is exfiltration. Enforce: top-level decision = block, with the redacted reason in reason. Audit: hookSpecificOutput.additionalContext. Wired by the shipped configs. The post-expansion text of a slash or custom command - content UserPromptSubmit never sees - gated before it reaches the model.
TaskCreated
Scans task_title and task_description. The channel ships content off the machine, so a secret on it is exfiltration. Enforce: continue = false, with the redacted reason in stopReason (rolls the task creation back). Audit: systemMessage. Wired by the shipped configs. 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 (opt-in)
Scans tool_output (falling back to tool_response) of an in-scope tool. Enforce: top-level decision = block, with the redacted reason in reason. Audit: hookSpecificOutput.additionalContext. Supported but opt-in: not wired by the shipped configs. 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 (opt-in)
Scans tool_output and tool_error of every in-scope call in tool_calls[]. Enforce: top-level decision = block, with the redacted reason in reason. Audit: hookSpecificOutput.additionalContext. Supported but opt-in: not wired by the shipped configs. 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
Scans nothing from the event: it triggers a throttled, scoped, offline scan of recent sessions on this machine. Enforce: never blocks (advisory only). Audit: systemMessage with suppressOutput. Wired by the shipped configs. The stop advisory. Claude fires Stop at the end of each response, not only at session end; the hourly throttle paces it, so 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.

The camelCase event names are unique to Cursor, so auto-detection is unambiguous.

Fail-open by default; --strict plus enforce exits 2 (Cursor's schema-agnostic hard block) on an unparseable event.

beforeShellExecution
Scans command (mapped to the canonical Bash tool). Enforce: permission = deny or ask, with the redacted reason in user_message and agent_message. Audit: none - Cursor's blocking events carry no non-deciding channel, so audit stays silent and relies on the guard log. Wired by the shipped configs.
beforeReadFile
Scans file_path as the input and content as the result (credential-file recon and dotenv contents). Enforce: permission = deny (allow/deny only, so a would-be ask degrades to deny). Audit: none (see beforeShellExecution). Wired by the shipped configs. Gated only under --strict, exactly like Claude's Read; the shipped Cursor config passes --strict on this event alone.
beforeMCPExecution
Scans tool_input, under the canonical mcp__<server>__<tool> name. Enforce: permission = deny or ask, with the redacted reason in user_message and agent_message. Audit: none (see beforeShellExecution). Wired by the shipped configs.
beforeSubmitPrompt
Scans prompt. The channel ships content off the machine, so a secret on it is exfiltration. Enforce: continue = false, with the redacted reason in user_message. Audit: none (see beforeShellExecution). Wired by the shipped configs. 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. 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.

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-open by default; --strict plus enforce emits an explicit deny JSON on an unparseable event.

PreToolUse
Scans tool_name plus the flattened tool_input; only Bash carries a raw shell command (apply_patch's command field is patch text). Enforce: hookSpecificOutput.permissionDecision = deny, with the redacted reason in permissionDecisionReason. Audit: continue = true plus systemMessage (Codex PreToolUse has no additionalContext). Wired by the shipped configs.
PermissionRequest
Scans the same tool_name plus tool_input as PreToolUse. Enforce: hookSpecificOutput.decision.behavior = deny, with the redacted reason in message. Audit: systemMessage. Wired by the shipped configs. The approval-flow gate: any deny wins, closing PreToolUse's updatedInput and ask fail-open edges.
UserPromptSubmit
Scans prompt. The channel ships content off the machine, so a secret on it is exfiltration. Enforce: top-level decision = block, with the redacted reason in reason. Audit: systemMessage. Wired by the shipped configs.
PostToolUse (opt-in)
Scans tool_response of an in-scope tool. Enforce: top-level decision = block, with the redacted reason in reason. Audit: systemMessage. Supported but opt-in: not wired by the shipped configs. Output scanning. Opt-in - it fires on every tool result.
Stop
Scans nothing from the event: it triggers a throttled, scoped, offline scan of this machine's recent codex sessions. Enforce: never blocks (advisory only). Audit: systemMessage with suppressOutput; it carries no decision, so Codex never continues the turn. Wired by the shipped configs. The turn-end advisory - Codex Stop is turn-scoped (it fires when the agent finishes a turn), not a session-end signal. Handled at run() level by isSessionEndEvent (guard.zig), which scans with --agent codex; at most once an hour it reports a redacted count and the highest severity of credential exposures in recent codex sessions and points at skarn assess. Claude and Codex share one hourly advisory budget (the guard-assess-advisory throttle), so activity on either host can suppress the other's nudge for that hour - a deliberate global budget, not a per-host one. Never egresses, never prints a secret value.

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.

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.

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.

preToolUse
Scans toolName plus toolArgs (an object, or a JSON string that is parsed and flattened). Enforce: top-level permissionDecision = deny or ask, with the redacted reason in permissionDecisionReason. Audit: additionalContext. Wired by the shipped configs. The durable gate on this host: a deny from ANY hook blocks the tool, so Skarn's answer here cannot be replaced by a later one. It is also the only Copilot event where ask is a real interactive prompt rather than a degraded deny.
permissionRequest
Scans toolName, plus toolArgs when the payload carries it. Enforce: 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. Audit: none. Wired by the shipped configs. The second chokepoint on this host: the permission-decision step for a call preToolUse deferred or allowed. GitHub documents its payload as a subset of preToolUse's and no field separates the two, so the guard never infers it - the shipped config entries pass --event permissionRequest, and an unpinned payload is read as preToolUse. A would-be ask degrades to deny (the schema has allow and deny and no ask value). Its deny is not final the way a preToolUse deny is: outputs from several permissionRequest hooks merge last-writer-wins, and the load order is policy, repository, user, inline settings, plugins - so a policy-delivered Skarn deny is the MOST overridable placement and a plugin-delivered one the least. Audit is silent by design: the only other output fields are message and an interrupt that stops the whole agent, and a message with no behavior would ride into another hook's merged decision, so there is no channel here that advises without deciding.
userPromptSubmitted
Scans prompt. The channel ships content off the machine, so a secret on it is exfiltration. Enforce: nothing is emitted - the CLI ignores this event's hook output. Audit: none. Wired by the shipped configs. 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.
userPromptTransformed (opt-in)
Scans 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). The channel ships content off the machine, so a secret on it is exfiltration. Enforce: 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. Audit: none. Supported but opt-in: not wired by the shipped configs. On this event Skarn detects and writes the audit log only; it never blocks or decides, because the event's sole output channel is modifiedTransformedPrompt (a prompt rewrite) which Skarn declines by policy - it observes and blocks but never rewrites model-facing content, a Skarn policy rather than a host limitation. Opt-in, not wired in the shipped configs: a config update reaches users independently of the binary, and a binary predating this event would scan the payload as userPromptSubmitted (it carries prompt), so the opt-in row avoids that ambiguity until a release carrying the arm is current.
preMcpToolCall (opt-in)
Scans arguments, under the canonical mcp__<server>__<tool> name. Enforce: nothing is emitted - this event's output controls only MCP request metadata. Audit: none. Supported but opt-in: not wired by the shipped configs. Supported but not wired in the shipped configs: it cannot block or advise, so it buys detection coverage in the guard log only. Added in Copilot CLI 1.0.51 and still firing (no removal through 1.0.73), and still absent from GitHub's hooks-reference event table on re-verification 2026-07-30, which is why it gains no decision channel here: the one output the SDK documents for it is metaToUse, MCP request metadata, and Skarn does not emit a verdict schema the vendor does not publish. The row is kept version-scoped because older supported CLIs still fire it.
postToolUse (opt-in)
Scans toolResult. Enforce: advisory only - never blocks. Audit: additionalContext. Supported but opt-in: not wired by the shipped configs. 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.

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.

VS Code blocks only on exit 2; any other non-zero exit fails OPEN.

PreToolUse
Scans tool_name (VS Code vocabulary, mapped to canonical) plus the flattened tool_input. Enforce: hookSpecificOutput.permissionDecision = deny or ask - both fully honored (the exact inverse of the CLI, where the decision is top-level). Audit: systemMessage. Wired by the shipped configs.
UserPromptSubmit
Scans prompt. The channel ships content off the machine, so a secret on it is exfiltration. Enforce: top-level decision = block, with the redacted reason in reason. Audit: systemMessage. Wired by the shipped configs. 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 (opt-in)
Scans tool_response. Enforce: top-level decision = block, with the redacted reason in reason. Audit: systemMessage. Supported but opt-in: not wired by the shipped configs. Output scanning. Opt-in - it fires on every tool result.

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

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.

A non-2 non-zero exit is a warning and fails open; exit 2 blocks with stderr as the reason. --strict plus a licensed enforce exits 2 on an event the guard cannot parse (malformed or oversized) and on a scan that cannot run (unreadable rules); an out-of-scope action still fast-allows, and an unlicensed guard is forced to audit and exits 0. A hook that runs past its timeout has its decision discarded and the call proceeds, and the observed wait tracks the hook's own runtime up to about 30 seconds rather than the configured value, so the timeout bounds nothing - a fast guard is the real protection. From gemini-cli 0.56.0 a crashed, timed-out, or exit-2 hook also prints a 'Hook execution for <event>: N succeeded, M failed' summary and a '[WARNING] Hook(s) [skarn-guard] failed' line on stderr; only the exit-2 case blocks.

BeforeTool
Scans 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. Enforce: 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). Audit: systemMessage. Wired by the shipped configs. The entry timeout is in milliseconds (5000), unlike Claude's seconds. Gemini reads ONE command field on every platform - there is no windows variant - so setup renders the form the machine it configures needs, and a project-scope file committed from POSIX fails open (a warning) on a native-Windows teammate. MCP calls arrive as mcp_<server>_<tool>; skarn always rewrites the name to the canonical mcp__ form so every tool-keyed detector sees an MCP call, recovering the real server/tool split from mcp_context when it is present, and never fast-allows either way. The event also offers hookSpecificOutput.tool_input, which rewrites the model's arguments - skarn surfaces and blocks but never rewrites, so that channel is deliberately unused.
BeforeAgent
Scans prompt. The channel ships content off the machine, so a secret on it is exfiltration. Enforce: top-level decision = deny, with the redacted reason in reason. Audit: systemMessage. Wired by the shipped configs. A lone secret is enough to deny here - the destination is not ambiguous. A deny blocks the turn before any request leaves for the model provider and discards the message from history (gemini-cli 0.56.0 renders it as 'Agent execution blocked: <reason>' on stderr and exits 0); it is recoverable, the user edits and resubmits.
AfterTool (opt-in)
Scans tool_response, an object carrying llmContent, returnDisplay, and an optional error, flattened into the scanned bytes; from gemini-cli 0.56.0 llmContent arrives wrapped in <untrusted_context> tags, which are scanned through and trigger nothing on their own. Enforce: top-level decision = deny, with the reason replacing the tool result before the model sees it. Audit: systemMessage. Supported but opt-in: not wired by the shipped configs. Output scanning. Opt-in - it fires on every tool result.

Grok Build

Grok Build fires a PascalCase event set with a JSON envelope on stdin, in a camelCase dialect of its own: hookEventName carries a snake_case VALUE ("pre_tool_use"), the tool fields are toolName/toolInput/toolUseId/toolInputTruncated, and PostToolUse adds toolResult. Skarn gates three of those events - PreToolUse (the only enforcement point grok offers), UserPromptSubmit (the submitted prompt, an egress channel), and PostToolUse (the tool result, opt-in) - and every other event fast-allows. Grok has NO non-deciding channel: its passive events ignore stdout entirely and every recognized PreToolUse output decides, so the guard emits nothing at all in audit mode and the would-be verdict lands only in the guard log, which skarn guard report reads. Timeouts are seconds (the shipped entries use grok's 5-second default). The honest posture is fail-open: every grok hook failure, timeout, crash or malformed output is recorded for the session scrollback and blocks nothing, so an explicit deny is the only thing that stops a call.

The camelCase hookEventName key is unique to grok - Claude, Codex, Cursor and VS Code Copilot spell it hook_event_name, and the Copilot CLI carries no event-name field - so an unpinned grok payload auto-detects on key presence, before the Copilot CLI's toolName/sessionId structural tells that it would otherwise trip. Grok is also the ONE place the guard overrides an explicit --agent pin: grok natively runs the hooks configured in ~/.claude/settings.json and ~/.cursor/hooks.json and invokes them with its own dialect, so a hook pinned --agent claude is handed grok events it cannot read. When the process environment carries GROK_HOOK_EVENT (grok's runner injects it for every hook and strips any value a config sets) AND the payload carries hookEventName, the event routes to the grok adapter whatever the pin says. Neither signal alone moves a pin, and a real Claude payload under a claude pin is unaffected either way. MCP tools arrive under their resolved server__tool name with no prefix; the guard rewrites that to the canonical mcp__ form so every tool-keyed detector sees an MCP call, and an MCP call never fast-allows.

Every failure fails open, which is grok's own posture for hooks: a non-zero exit other than 2 is recorded and blocks nothing. Exit 2 is an explicit PreToolUse deny, and a deny on stdout is honored regardless of the exit code (a stdout allow paired with exit 2 still denies, so the guard never pairs the two). --strict plus enforce turns an unparseable event into one stderr line and exit 2.

PreToolUse
Scans toolName (mapped from grok's own vocabulary: run_terminal_command, search_replace, read_file, web_fetch, web_search, spawn_subagent) plus the flattened toolInput; a run_terminal_command command is also carried raw so a tool description cannot poison the package parse. Enforce: top-level decision = deny, with the redacted reason in reason (a would-be ask degrades to deny: grok's schema has no ask value). Audit: nothing - grok has no non-deciding channel, so the would-be verdict goes to the guard log only. Wired by the shipped configs. The shipped entry carries NO matcher: an omitted matcher matches every tool and the guard scopes in-process, which also keeps the file free of a matcher list that would drift from the adapter. grep and list_dir are deliberately unmapped, so they pass through and fall out of scope. toolInputTruncated is tolerated: the guard scans the bytes grok hands it and claims nothing about what was cut.
UserPromptSubmit
Scans prompt. The channel ships content off the machine, so a secret on it is exfiltration. Enforce: nothing - grok ignores stdout on this event, so it is detection and audit only. Audit: nothing - the would-be verdict goes to the guard log only. Wired by the shipped configs. A lone secret is enough to flag here - the destination is not ambiguous - but grok gives the hook no way to stop the turn, so the record is the whole product. A matcher on this event is ignored with a warning by grok, which is the second reason the shipped file carries none.
PostToolUse (opt-in)
Scans toolResult, typed as arbitrary JSON, so a string result and an object one are both flattened into the scanned bytes. Enforce: nothing - grok ignores stdout on this event. Audit: nothing - the would-be verdict goes to the guard log only. Supported but opt-in: not wired by the shipped configs. Output scanning. Opt-in - it fires on every tool result.
pre_tool_use (opt-in)
Scans the same event as PreToolUse. Enforce: top-level decision = deny. Audit: nothing. Supported but opt-in: not wired by the shipped configs. The wire form of PreToolUse: grok's stdin envelope spells the event value in snake_case while its config files use the PascalCase name, and the adapter recognizes both.
user_prompt_submit (opt-in)
Scans the same event as UserPromptSubmit. The channel ships content off the machine, so a secret on it is exfiltration. Enforce: nothing. Audit: nothing. Supported but opt-in: not wired by the shipped configs. The wire form of UserPromptSubmit.
post_tool_use (opt-in)
Scans the same event as PostToolUse. Enforce: nothing. Audit: nothing. Supported but opt-in: not wired by the shipped configs. The wire form of PostToolUse.

Antigravity CLI (agy)

The Antigravity CLI (agy) fires five PascalCase events - PreToolUse, PostToolUse, PreInvocation, PostInvocation, Stop - with a camelCase protojson envelope on stdin and NO event-name field: the hooks.json entry that fired is the only thing that names the event, so the adapter reads the event off the payload shape (a toolCall with no error key is the PreToolUse it gates; every other shape fast-allows). PreToolUse is the whole guard surface on this host: PreInvocation carries no prompt text and PostToolUse carries no tool output, so there is no prompt-submit gate and no result scan (verified on agy 1.1.18 and 1.1.19). The stdout contract is a flat {"decision","reason"} object, and agy FAILS CLOSED on everything but an explicit allow: a deny blocks, and a crash, a timeout (seconds; the shipped entries use 5), invalid JSON, and an empty {} (a missing decision) all block the call and the model sees the hook-failure text; only an empty stdout was observed to proceed (1.1.19), which is undocumented and which the guard never relies on. The guard therefore answers EVERY tool event with an explicit decision: enforce prints deny or allow, audit prints allow with the would-be verdict in reason (agy has no non-deciding channel), an out-of-scope tool gets an explicit allow, and a non-tool event gets the empty object. ask is not a block on agy (it hands the call to the permission layer, which auto-denies in print mode), so a would-be ask degrades to deny. An allow never grants a permission: agy's own permission check still runs after the hook.

The camelCase toolCall / conversationId / workspacePaths / artifactDirectoryPath field set is unique to agy - Claude, Codex, Cursor, Gemini, and VS Code Copilot use snake_case envelopes, the Copilot CLI spells its fields toolName/toolArgs/sessionId, and grok's tell is hookEventName - so an unpinned agy payload auto-detects (before the Claude PascalCase fallback, so a future payload that adds hook_event_name still routes here); the shipped configs pin --agent antigravity anyway, uniform policy. The agy tool vocabulary on the hook wire was pinned live (1.1.19): a file write fires as write_to_file (TargetFile/CodeContent) and an edit as replace_file_content or multi_replace_file_content (TargetFile/ReplacementContent), while run_command, view_file, browser_*, the read_url_content/open_browser_url/search_web network steps, and the agency_tool_call/invoke_subagent/browser_subagent subagent steps keep their step-type spellings; the step-type spellings of the write tools (file_change, code_action, edit_notebook) stay mapped and matched as drift aliases. The MCP dispatch is call_mcp_tool on the hook wire (the binary's hook tool table) and mcp_tool in the transcript the scan parser reads; both are rewritten to the canonical mcp__ form (from the ServerName/ToolName args), so an MCP call never fast-allows. The grok override still applies: a hook process grok spawned routes to grok whatever the pin.

Fail-closed by the host's design, on every outcome but an explicit allow: a crashed, slow (past the entry's timeout, in seconds), or malformed skarn BLOCKS the tool call, and a missing skarn binary blocks every matched call the same way (sh -c exits 127). Enforce and audit are both hard gates here - audit cannot fail more open than enforce, so a machine where the hook is wired and the binary is gone blocks matched calls until the binary is back or the entry is removed. --strict plus a licensed enforce answers an event the guard cannot parse with an explicit deny; without --strict it answers an explicit allow, which is the only way to fail open on this host.

PreToolUse
Scans toolCall.name mapped to canonical (run_command to Bash; write_to_file, file_change, and code_action to Write; replace_file_content and multi_replace_file_content to Edit; notebook_edit and edit_notebook to NotebookEdit; view_file to Read; browser_subagent, invoke_subagent, and agency_tool_call to spawn_agent; every other browser_* plus execute_browser_javascript, capture_browser_screenshot, read_url_content, and open_browser_url to WebFetch; search_web to WebSearch; call_mcp_tool and mcp_tool to mcp__) plus the flattened toolCall.args, with the TargetFile/AbsolutePath path lifted to a canonical file_path line for the write-target detectors; a run_command's CommandLine is also carried raw so toolSummary and toolAction cannot poison the package parse. Enforce: top-level decision = deny, with the redacted reason in reason (a would-be ask degrades to deny: ask is not a block on agy); an allow is printed explicitly, because the contract requires a decision and an empty {} is a deny. Audit: top-level decision = allow, with the would-be verdict (and the unlicensed advisory) in reason - agy has no non-deciding channel. Wired by the shipped configs. The shipped matcher names the scoped tools (run_command|write_to_file|replace_file_content|multi_replace_file_content|file_change|code_action|notebook_edit|edit_notebook|browser_.*|execute_browser_javascript|capture_browser_screenshot|read_url_content|open_browser_url|search_web|call_mcp_tool|mcp_tool|agency_tool_call|invoke_subagent) rather than * so a fail-closed host does not pay a hook round-trip, and its failure modes, on read-only steps. view_file is deliberately NOT in the shipped matcher: without --strict the adapter fast-allows every read, so wiring it would put agy's fail-closed exposure on every file view for no scan; a --strict install adds view_file| to the matcher, which then path-gates the named credential stores (content-keyed read detectors need tool output agy never provides, so a dotenv read is not detected on this host). The event name is not in the payload: a PreToolUse is recognized by its toolCall with no error key, and a PostToolUse (toolCall plus error) is answered with the empty object it expects and scanned for nothing, since it carries no tool output. The hook runs before agy's permission check, and skarn's allow grants nothing.

Examples

Show which license this machine is using, where it came from, and when it expires.

skarn license

Validate a license file and install it; the previous license, if any, is backed up alongside it.

skarn license ~/Downloads/team.skarnlicense

Fetch a fresh license for this subscription, verify it against the keys built into this binary, and install it; nothing is written unless it verifies.

skarn license renew

Scan every AI coding session on this machine and print a friendly risk summary in one command; no flags, no license.

skarn assess

Drive the MCP handshake by hand: the server answers one JSON-RPC line on stdout and exits 0 when stdin closes.

printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}' | skarn mcp

Run the MCP server with the two content tools enabled; put the argument in the client's own mcpServers args, never in a shipped plugin.

skarn mcp --enable-recall

Print what the MCP server exposes and how to register it in a client; it returns before the protocol loop starts.

skarn mcp --help

Write a self-contained, redacted security report to share with a colleague; -o report.md writes a plain-text version.

skarn assess -o report.html

Statically vet this machine's AI assistant configuration - hooks, MCP servers, permission grants, plugins and skills - and list the risky patterns found. Read-only, offline, no license.

skarn vet

Emit the config findings as SARIF for a code-scanning pipeline and exit 1 on anything high or above; an unreadable config file exits 6 rather than reporting a partial view as clean.

skarn vet --format sarif --fail-on-severity high

Detect installed AI coding agents and wire the guard hook into each one in audit mode, with a wizard on a terminal; ends with the guard self-test and a list of every file touched.

skarn setup

Print the exact Cursor hooks.json content that setup would write, without writing anything.

skarn setup --print --agent cursor

Check that skarn is really protecting this machine: license, every wired agent hook, the guard log, and the guard self-test. Each warning or failure names the command that fixes it.

skarn doctor

The same checks as one JSON object for fleet scripts; the check ids are stable and a rendered report always exits 0 (an unknown option is refused with exit 1 and an unresolvable home directory with exit 6, both before any check runs).

skarn doctor --json

Flip every agent that already carries skarn entries from audit to enforce, leaving everything else alone.

skarn setup --update --mode enforce

Summarize the last 30 days of flagged calls per agent (verdicts, top rules, latency, the most recent denies) and, when the window is clean, print the exact command that flips the hooks to enforce.

skarn guard report --window 30d

The same aggregate as one JSON object for fleet scripts: counts are over flagged actions, since the guard does not log clean calls.

skarn guard report --format json

Accept a finding the guard flagged (the fingerprint printed under the deny in guard report) as a false positive, so the identical call stops being blocked. Writes the personal baseline the guard reads; a real leak is never accepted this way.

skarn guard accept 7d343e3a105ae026589b3371d565a4ad2943dd5ba5f81a19c7500b2ab4dbb1f0 --reason 'internal test credential'

Prove the guard wiring: run synthetic benign and fake-credential events through each adapter's real code path and report the verdicts (credential shown redacted).

skarn guard --self-test

Scan the default window (the last 30 days) of all sessions for leaked secrets and attack patterns.

skarn check

Scan all history, report only high-severity-and-above findings, and emit JSON for a pipeline.

skarn check --hours 0 --severity high --format json

Exit non-zero in CI when a session's risk score exceeds 60.

skarn check --fail-on-risk 60

Write a redacted Markdown evidence pack for one product build (Team); redirect it to a dated file such as skarn-evidence-2026-07-24.md to file into a technical file. Omitting the lineage flags renders a pack that states it cannot be attached to a specific technical file.

skarn check --format evidence --product acme-gateway --product-version 3.2.1 --build-id 9f2c1ab

Search past sessions for a regular-expression pattern.

skarn search 'AWS_SECRET' --regex

List recent Claude Code sessions.

skarn recent --cli claude

Render per-project analytics to a self-contained HTML file.

skarn stats --by project --format html -o stats.html

Browse and scan sessions in a local web UI on 127.0.0.1:7777 (localhost only, no egress); open the token-carrying URL printed at startup.

skarn serve --port 7777

Serve the web UI with a custom rule pack layered on the bundled rules; the Security view surfaces its findings too.

skarn serve --rules ./team-rules.toml

Walk each new finding and label it a true or false positive with a reason; the decisions are written into the committed baseline and survive re-scans.

skarn baseline audit .skarn-baseline.json

Record a single false-positive fingerprint (from SARIF partialFingerprints or the ndjson skarn.fingerprint field) into the baseline without the interactive loop.

skarn baseline accept .skarn-baseline.json 7d343e3a105ae026589b3371d565a4ad2943dd5ba5f81a19c7500b2ab4dbb1f0 --reason 'internal test credential'

Security

Skarn scans locally and makes no network connection by default. Secrets are redacted in every output format. --no-redact lifts that only where it is honored - check's text, json and sarif reports, and export's raw session content; the ndjson stream, the evidence pack, the artifacts written by --drop, serve and assess, the baseline files, and the guard ignore it and stay redacted.

Two commands transmit anything off the machine, and only when you type them. skarn check --update-rules fetches a signed feed bundle from the subscriber channel, presenting $SKARN_FEED_TOKEN as a bearer credential; --feed-url names that channel when $SKARN_FEED_URL does not. The feed is off by default and is disabled by --offline. skarn license renew posts the installed license token to the license service to receive a re-signed one. Both refuse any endpoint that is not https, or http against loopback, and any endpoint carrying userinfo. No session content, no finding, and no scanned secret ever leaves the machine through a connection skarn makes. skarn mcp makes no network connection; the results it hands to the connected MCP client (redacted findings by default, redacted session content only with --enable-recall) are forwarded by that client to its model provider - that egress is the client's, and it happens only when you configure the server.

skarn vet reads the AI assistant configuration files listed under FILES read-only: it opens each one for reading, never writes, rewrites, or removes one, and makes no network call. It reports what it finds and leaves every decision about the configuration to you. --offline is accepted for parity and changes nothing.

skarn mcp reads the same session stores as assess and the same configuration files as vet, read-only and with no network call, and answers only the AI coding assistant that spawned it over stdin and stdout. Its results are the redacted scan report, the masked vet report, and session metadata carrying neither message text nor any label authored inside a transcript. Because the assistant receives those results, they travel wherever that assistant sends its context, so treat them as leaving the machine even though skarn itself opens no socket.