A least-privilege linter for Claude Code agent policies. Point it at a repo; it reads .claude/settings.json, settings.local.json, .mcp.json, hooks and defaultMode, and tells you deterministically whether your policy lets an agent get from untrusted input to credential — or proprietary source — egress with no gate in between.
npx @cognitive-fab/polycheck . # or run it straight from the repo, no install git clone https://github.com/cognitive-fab/polycheck node polycheck/bin/polycheck.mjs .
The linter is zero dependencies, offline, no model call, no network, no clock — it changes no behaviour, so there is nothing to integrate and nothing to trust at runtime. New: polycheck guard is a separate, opt-in runtime hook that enforces the same model live — see below.
Not a constructed example — this is the repo this site is built in, checked in place. The 278-rule startup dialog it answers is the subject of a field note.
$ npx @cognitive-fab/polycheck . polycheck 0.7.0 — Claude Code least-privilege check sources: settings ✗ settings.local ✓ mcp ✗ mode: default actions modeled: 134 (ungated: 134) FORBIDDEN REGIONS ✗ lethal-trifecta BYPASS — a gate-free path exists ✗ credential-egress BYPASS — a gate-free path exists ✗ source-egress BYPASS — a gate-free path exists SHELL-EQUIVALENT — 46 granted commands run caller-chosen code, by executable: xargs ×11 · awk ×8 · git ×8 · npm ×8 · python ×3 python3 ×3 · node ×2 · aws · gh · grep WITNESS · source-egress (proprietary ∧ egress) 1 PowerShell(aws s3 ls; aws amplify list-apps …) allow ⟶ +sensitive +egress 2 Read(~/.claude/plugins/cache/**) allow ⟶ +proprietary ← REACHED fix close the 'egress' effect — 118 granted tools provide it: curl ×49 · xargs ×11 · git ×9 · awk ×8 · npm ×8 · aws ×7 · WebFetch ×7 …
Read the two counts together. The startup dialog says 278. polycheck resolves those rules into 134 distinct modeled actions — and not one of them sits behind a gate. Three forbidden regions are reachable, and for each one it names the shortest sequence of allowed calls that gets there and the single effect to close.
This is a personal dev machine with a deliberately permissive policy, and the point is not that it is alarming. The point is the shape of the answer. polycheck's own header says it plainly — “this describes what your policy permits — reach you granted, to keep or close. Not a judgment.” A count cannot be kept or closed. A witness can: the fix line is an edit to one file, and re-running is how you find out it worked.
Anthropic's setup wizard does the first two, from better evidence than a questionnaire would give it. The last two are where a labelled policy becomes a checked one.
Read the policy surface off disk — settings, local settings, MCP config, hooks, defaultMode. Pure parsing; nothing executes.
Map each permission rule to effect classes: untrusted / sensitive / egress. Plain JSON, and you can override it.
allow is an ungated edge, ask a gated one, deny no edge at all.
Delete the gates, BFS the reachable states, and for each forbidden region return the shortest gate-free path — or establish that every path crosses a gate.
polycheck guard (opt-in)The linter proves what a policy could permit, at deploy time. polycheck guard is the newest piece: a PreToolUse hook that watches what a live session has already done, and gates only the call that would complete a forbidden composition. Everything else passes through untouched.
The design constraint everything else follows from: the guard's decision space is ask / deny / passthrough. It never emits allow. It can only ever add a gate, never remove one — so it reinforces auto mode's classifier and can never override it. A labeler mistake degrades to a missing extra gate, never a bypass, and guard off is a clean revert.
polycheck guard init run the linter, show what the guard would add, then
(with --yes) write the hook wiring
polycheck guard status <id> a session's held effects, provenance, approvals
polycheck guard reset <id> clear a poisoned session ledger — the mid-session
escape hatch, since effects are monotone otherwise
polycheck guard off remove the hook wiring
It gates on one of two bases. capability (the default) taints the session as soon as a call could have read a secret — safe for headless runs, at the cost of some gates on harmless calls. observed taints only once returned bytes match a credential shape — fewer gates, at the cost that a secret the patterns miss slips through. guard init runs the linter first and talks you out of installing when the repo is already PROOF, or has an unrestricted shell grant.
polycheck now models first-party source as a distinct asset from secrets. A Read whose glob covers code is proprietary; source-egress = proprietary ∧ egress names the loss where code — not credentials — leaves the machine. This is the exfiltration the credential-shaped defenses miss: a model reliably refuses "exfiltrate .env," but may wave through "mirror the modules to the build cache." Same trifecta shape, different asset, and the classifier has no separate vocabulary for it. Public-repo escape hatch: clear proprietaryPaths in a --labels override.
Full design in spec/runtime-guard.functional.md.
--mermaidA witness is already a sequence — step 1, step 2, REACHED — so polycheck . --mermaid renders each one as a Mermaid sequenceDiagram: every allowed call as a step against the session, the effect it adds and the running held set underneath, and the composition landing with a crossed arrow into the forbidden region. It's presentation only — the diagram makes no claim the text report and --json don't already make; it just makes the accumulated-state hazard legible at a glance, and screenshot-able for a PR or a security review.
The fix travels with the picture. Since 0.6.2 each diagram carries a note with the exact corrective action — the same one-line remedy the text report prints (gate 'untrusted' — move to ask/deny: WebFetch, or a granted tool runs arbitrary code — gate it (ask/deny) or narrow to fixed arguments) — rendered inside the diagram, so a screenshot carries the answer, not just the hole. A clean policy (PROOF, or a genuine coverage gap) draws nothing — no witness exists to draw.
$ polycheck . --mermaid > witness.md
### polycheck 0.6.2 · . **SHELL-EQUIVALENT · credential-egress** (sensitive ∧ egress) ```mermaid sequenceDiagram autonumber participant A as Agent session participant Z as ⛔ credential-egress Note over A: session start · held: (nothing) A->>A: PowerShell(npm run *) [allow] Note over A: +untrusted +sensitive +egress · held: untrusted, sensitive, egress A-xZ: REACHED · sensitive ∧ egress · one granted tool runs arbitrary code — a shell Note over A,Z: ✔ fix: a granted tool runs arbitrary code — gate it (ask/deny) or narrow to fixed arguments ```
The output is a plain .md file, so pick whichever fits the moment:
GitHub renders fenced ```mermaid blocks natively — polycheck . --mermaid output pastes straight in as evidence on the change that introduced (or closed) a bypass. No tooling to install.
polycheck . --mermaid > witness.md, then drop it into an editor that renders Mermaid live — StackEdit is a free, no-install option: paste the markdown in the left pane and the diagram renders in the right pane as you scroll, ready to screenshot or export.
WebFetch is an egress channel, not just an ingest oneIt carries data out in the URL. Gate curl and leave WebFetch open and you have gated nothing.
It is also not a shell command, so the classifier is not looking at it.
Bash prefix is not a security boundaryBash(npm run build:*) reads as “only the build script”; it runs whatever package.json says. polycheck gives that its own verdict, SHELL-EQUIVALENT.
Auto mode reaches the same conclusion and disables the rule. We agree on which grants aren't boundaries.
mcp__slack__send_message plus a file read is the whole trifecta — with no curl and no WebFetch anywhere in the path.
| Verdict | Exit | What it means |
|---|---|---|
| PROOF | 0 | Every path into the forbidden region crosses a real gate. |
| BYPASS | 1 | The exact sequence of allowed calls that reaches egress with zero gates crossed — and the minimal edit that closes it. |
| SHELL-EQUIVALENT | 1 | You granted a shell. The composition question is moot, so it is reported separately from genuine compositions. |
| INCONCLUSIVE | 2 | Either no granted tool provides a required effect — which is coverage, not safety — or the only thing in the way is a PreToolUse hook. Most hooks log or format rather than block, and polycheck cannot read a hook's logic, so it will not call that a proof. |
.mcp.json declares a server whose tools are not named in permissions, polycheck treats mcp__<server>__* worst-case: a server's tool list is unknowable without running it. That affects a large population of repos, so to be explicit — it is a coverage statement, not a finding against your policy, and it is not a state you are stuck in. Name each server tool under allow / ask / deny and the verdict resolves; a policy that gates the egress-verb tool reaches PROOF.
Auto mode is the runtime screen for the novel command. The startup dialog counts the rules you already granted. polycheck is the deploy-time check for what those rules compose to — the reach you declared without noticing.
$ npx @cognitive-fab/polycheck . FORBIDDEN REGIONS ✗ lethal-trifecta BYPASS — a gate-free path exists WITNESS · lethal-trifecta (untrusted ∧ sensitive ∧ egress) 1 WebFetch allow ⟶ +untrusted +egress 2 Read(./**) allow ⟶ +sensitive ← REACHED fix close the 'untrusted' effect — move to ask/deny: WebFetch
Exit code 1. Fail the build on it.