Deploy-time · deterministic · $0

polycheck — what your agent policy actually permits

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.

Run it on a real policy

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.

Four stages, no model call

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.

1

Scan

Read the policy surface off disk — settings, local settings, MCP config, hooks, defaultMode. Pure parsing; nothing executes.

2

Label

Map each permission rule to effect classes: untrusted / sensitive / egress. Plain JSON, and you can override it.

3

Compile

allow is an ungated edge, ask a gated one, deny no edge at all.

4

Check

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.

The runtime layer: 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.

New in 0.6.0 — your source code is an asset too

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.

See the witness, not just read it — --mermaid

A 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
```

Where to look at it

The output is a plain .md file, so pick whichever fits the moment:

Paste it into a GitHub PR or issue

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.

Redirect it to a file and open it in a live editor

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.

Three things it catches that a careful reviewer misses

WebFetch is an egress channel, not just an ingest one

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

A Bash prefix is not a security boundary

Bash(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.

An MCP tool is an exfil channel when its name is an egress verb

mcp__slack__send_message plus a file read is the whole trifecta — with no curl and no WebFetch anywhere in the path.

The verdicts

VerdictExitWhat 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.
A declared MCP server holds you at INCONCLUSIVE — deliberately. If .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.

They stack

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.

#AIAgents #PromptInjection #LLMSecurity #ClaudeCode #AppSec
Govern the agent
Governing agents → All tools →