skarn(1)
AI coding session security scanner with built-in session search
skarn 0.24.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 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, and VS Code Copilot Chat), 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. 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, and Microsoft Scout. Read-only and offline; free, no license.
baseline <audit|accept> <file> [<fingerprint>] [options]- Interactively triage findings into a committed baseline (audit), or record a false-positive fingerprint (accept).
search <query> [options]- 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.
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.
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, and Microsoft Scout - and report the risky patterns it finds: hooks that exfiltrate local content or fetch-and-execute remote code, 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. 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. Vet surfaces risky configuration and does not change it.
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. 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 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.
Guard options
Control the real-time pre-execution hook used for editor/agent integration (Claude Code, Cursor, Codex CLI, Copilot CLI, and Gemini 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) - 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.
Setup options
Guided onboarding: detect installed AI coding agents (Claude Code, Cursor, Codex CLI, Copilot CLI, Gemini CLI), 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 has 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; --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>) and rewritten atomically; a target file that does not parse as JSON is refused, never overwritten. On a terminal it runs an interactive wizard with default-yes prompts; --yes accepts everything, --print writes nothing.
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. 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>, 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 and always exits 0. 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.
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.
Global options
Available on a bare invocation.
Recent options
List and filter recent sessions.
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.
Search options
Search past sessions. The query is positional and flag order is free.
Restore options
Restore a session by id.
Stats options
Session analytics. A positional session id switches to a per-session view.
Export options
Export session data.
Cmds options
Shell commands run across sessions.
Mcps options
MCP calls made across sessions.
Tools options
Tools used across sessions.
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 carry the same license id and move the expiry forward, 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), so a poisoned environment cannot send the license anywhere in cleartext. Renew exits 0 when the new license is installed, 1 when the service cannot be reached, 2 when the service declines (its reason is printed verbatim), 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.
Exit status
0- Clean, or informational output only.
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, and --json always exits 0). Also: the first-run EULA prompt was declined, so the invoked operation was not performed.
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, or the policy requires a baseline and none was supplied.
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. For vet: the home directory could not be resolved (so the configuration was never located at all), or a config file that exists could not be read, so the configuration was only partly seen. 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 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, the recall commands, guard, setup, doctor, vet, 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 - so a poisoned environment cannot redirect the license token to a cleartext endpoint. 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).
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.
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.
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 doctor prepends to every managed (MDM/fleet) config path it 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. 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. Only doctor reads it. 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.
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.
~/.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.
~/.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 Code hooks and permission grants. Read (never written) by vet, and by the project-local ./.claude/settings.json and ./.claude/settings.local.json when one exists.
~/.claude.json, ~/.mcp.json- Claude Code MCP server definitions. Read by vet, along with a project-local ./.mcp.json when one exists.
~/.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.
~/.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.
~/.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.
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.
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.
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.
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.
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.
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 enforce exits 2 on an unparseable event.
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
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 always exits 0.
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 unless --no-redact is given.
The only feature that transmits anything off the machine is the maintained feed: --update-rules and --feed-url fetch a signed feed bundle from the subscriber channel. No secret ever leaves the machine. It is off by default and is disabled by --offline.
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.