A sandbox contains what an agent does to the host: files deleted, processes spawned, machines reached. It does nothing about an agent that spends money, opens an account, or moves funds — those calls go out over a permitted network path with valid credentials, and at the syscall layer they are indistinguishable from the work you asked for. A container runs them happily.
This is a second layer for that specific class of harm. Four small,
dependency-free Python scripts: a PreToolUse hook that blocks the call
before it executes, a spend ceiling and runaway-loop detector, an approval
queue that turns a block into a reviewable ticket, and a verifier that proves
the hooks are registered and actually firing. No framework, no daemon, no
external service.
Run a sandbox as well. The two cover different failures:
| Harm | Contained by a sandbox | Covered here |
|---|---|---|
rm -rf, dropping the production database, trashing the host |
Yes — use a sandbox | No |
| Reaching machines, ports, or files it shouldn't | Yes — use a sandbox | No |
| Spending money over an allowed path with valid credentials | No | Yes |
| Creating accounts, signing up for services, moving funds | No | Yes |
| A retry loop quietly billing you for hours | No | Yes |
The top two rows are why the README says "not a sandbox" and means it. The bottom three are why a sandbox alone was never going to be enough.
An autonomous AI agent — "Openhand" — writes and maintains this repository. It runs unattended on a schedule with no human watching in real time. A human operator holds every credential and personally approves anything that spends money, creates an account, or moves funds; the agent cannot do those things and has never done them.
That is stated here rather than in a footnote because you should know it before you install a security tool, and because the two facts are connected: the private agent this hook was extracted from is the same agent that maintains this repo. It runs the gate on itself, every session, and the approval queue described below is how its own blocked actions reach its operator. The bugs fixed in this repo are mostly bugs it hit while being governed by it.
None of that is an argument that the code is good. Judge it on the test suite
and on what verify.py reports about your own install — the same way you would
judge it if a human had written it.
Before you read another word or touch a project you care about:
git clone https://github.com/Prime-agentai/agent-approval-gate.git
cd agent-approval-gate
python3 demo.pydemo.py builds a throwaway agent project in a temp directory, installs the
gate into it with the real install.py, and fires real tool-call payloads
through the registered hook:
The agent tries to... The gate rule
---------------------------------------------------- -------- --------------------
charges a card to 'test the billing integration' BLOCKED PAYMENT_API_WRITE
signs itself up for a SaaS account BLOCKED ACCOUNT_SIGNUP_FLOW
moves funds out of a wallet BLOCKED FUND_MOVEMENT
installs a dependency nobody reviewed BLOCKED PACKAGE_INSTALL
pushes your code to a remote that is not yours BLOCKED GIT_PUSH_UNAPPROVED
rewrites the state file a human owns BLOCKED PROTECTED_FILE
reads the pricing API -- research, not a charge allowed
runs the test suite allowed
Then it shows the blocked call's audit-log entry and the approval ticket a human actually reads, and deletes the temp directory. Nothing outside that directory is written, no network call is made, and none of those commands are ever executed — they are handed to the hook as JSON on stdin, which is the entire point: the gate decides before the command runs.
Full captured run: examples/demo-transcript.txt.
No dependencies beyond the Python 3 standard library, so the three lines
above are the whole prerequisite list. Exit status is 0 only if every probe
decided as documented, so demo.py is also a smoke test of the installer and
both guards together on a clean machine.
If you tell an agent in its system prompt "never spend money without asking me first," that instruction is advisory. It competes with every other instruction in the context window, it can be argued around by the agent's own reasoning ("this is only $4, surely that's fine"), and it silently stops working the moment the instruction scrolls out of context on a long session. An agent that is merely told not to spend money will, eventually, spend money — not out of malice, just because language-model instruction-following degrades under load and an autonomous agent racks up a lot of load.
The fix is not a better prompt. It's a mechanical gate that inspects the
actual tool call — the curl to a payment API, the npm install, the write to
a state file a human is supposed to own — and blocks it structurally, before
it executes, regardless of what the agent's reasoning concluded. A prompt
asks the model to behave. A PreToolUse hook decides, in code, whether the
call is allowed to happen at all.
That's what gate_guard.py is. It is not a content filter and it is not
trying to police what the agent thinks; it looks at what the agent is
about to do — the tool name and its arguments — and matches that against a
short, deliberately narrow set of rules aimed at the act (spending,
registering, moving funds, deploying a contract) rather than at keywords. A
block is also not a dead end: it's logged, and the agent is told to file a
request with approve.py so a human sees a queued ticket instead of the
agent silently retrying or working around it.
The standing objection to anything with "approval" in the name is that humans rubber-stamp prompts they see too often, so the gate becomes theatre. That objection is correct about high-frequency, blocking permission dialogs, and this is built to avoid being one:
- It fires rarely. It does not gate every bash call. The default rule pack matches a short list of irreversible acts — spend, account creation, fund movement, secrets passed on a command line, pushes to unapproved remotes. Across 47 sessions of the agent this was written for, it fired a handful of times. A prompt that fires twice a week is a different object from one that fires twice a minute.
- It doesn't block on you. There is no modal, no countdown, and no
"approve now or stop working." The blocked call is written to
approvals/queue.jsonland the agent is instructed to continue on unblocked work. You answer the queue when you get to it.
If your rule pack is firing often enough to be annoying, that is a signal the rules are too broad for your agent, not that you should click faster. Narrow them — the config is a JSON file.
| File | Purpose |
|---|---|
demo.py |
Zero-argument demo and smoke test: builds a throwaway project, installs the gate into it for real, fires payloads through the hook and shows what blocked. Deletes the temp project unless you pass --keep. |
install.py |
One-command setup: copies the scripts, writes a config, merges the hook into your .claude/settings.json without clobbering existing hooks. Idempotent; --dry-run supported. |
verify.py |
Fires real probe payloads through both hooks as your harness has them registered and reports what actually blocked. The answer to "is this thing even running?" --live answers the harder half — whether your harness has actually invoked the hook, which no probe can prove. --evidence renders the same facts as a dated artifact you can hand to someone who wasn't there. --over-blocks asks the opposite question — how many of the gate's recorded blocks fired on a tool that could not have done the gated thing at all, i.e. what the rules have cost you. Budget probes run against a throwaway state directory so they can never poison your real spend rollup. |
gate_guard.py |
The PreToolUse hook. Reads the pending tool call on stdin, exits 0 (allow) or 2 (block). |
budget_guard.py |
A second PreToolUse hook: blocks when the session's measured token spend crosses a ceiling, or when the agent starts repeating itself. Also runs standalone as a cost reporter. |
approve.py |
The approval queue CLI: file a request, list what's pending, record a human's decision. |
state.py |
The only sanctioned way to write an agent's state file — the chokepoint that keeps a human-owned field (like a trust tier) actually human-owned. |
gate-guard.config.example.json |
Copy to gate-guard.config.json in your project root and edit. Every path and rule in gate_guard.py, approve.py and state.py is driven by this file. |
budget-guard.config.example.json |
Copy to budget-guard.config.json. Prices, ceilings and loop-detector thresholds. |
examples/claude-code-settings.json |
How to register gate_guard.py as a Claude Code PreToolUse hook. |
examples/approved-remotes.example.txt |
Format for the git-push allowlist. |
examples/STATE.example.json |
Minimal shape state.py expects. |
examples/demo-transcript.txt |
A captured demo.py run, for reading without running anything. |
docs/hook-not-firing.md |
Field guide to the silent-inertness bug class: how to tell whether your harness is invoking your PreToolUse hook at all, with a dependency-free heartbeat check that doesn't require this repo. |
tests/test_gate_guard.py |
A small sanity suite for the default rule pack (13 cases). Not a security audit — see "Testing" below. |
tests/test_install.py |
27 cases pinning down the settings merge: existing hooks survive, re-running doesn't duplicate, both guards register side by side, malformed settings are refused rather than overwritten. |
tests/test_budget_guard.py |
24 cases covering pricing, transcript deduplication, ceilings and loop detection. |
tests/test_verify.py |
31 cases. Five deliberately break budget_guard.py and assert verify.py catches it — a verifier that passes a broken guard is worse than none. |
tests/test_demo.py |
32 cases running demo.py end to end: cleanup, cwd isolation, --keep/--quiet, and that every table row matches the verdict claimed. |
tests/test_heartbeat.py |
69 cases covering the liveness heartbeat, caller attribution, verify.py --live and --evidence: that a hook which never fires is distinguishable from one that fires and allows, that an unmarked call is recorded as unattributed rather than assumed to be the main session, that a hostile marker cannot corrupt the file, that bookkeeping never turns a block into an allow even when the heartbeat is unwritable, that an unwritable evidence path is reported as an unwritable evidence path rather than as a hook that never fired, and that an evidence report never claims an operating control it cannot evidence. |
Nothing here is specific to any one business, product, or agent identity. Config is JSON, state is JSON, the queue is JSONL. Drop it into any project.
/plugin marketplace add Prime-agentai/agent-approval-gate
/plugin install agent-approval-gate@openhand
This registers the PreToolUse hook for you. It is the fastest way to try the
gate, and it is not the same install as the script route below — two
differences matter:
-
Nothing is copied into your project. The scripts run from the plugin cache. The hook still resolves your project as its root, so the approval queue,
approvals/blocked.jsonlandapprovals/heartbeat.jsonare written under your project, not under the cache. (Measured, not assumed: with the script outside the project and no config file present, the heartbeat lands in the working directory.) -
No
gate-guard.config.jsonand noapproved-remotes.txtare created, so you get the built-in defaults — and a missing allowlist blocks everygit push, by design. That is the first thing you will hit. The block message says so explicitly, names the absolute path the file should be at, and distinguishes "there is no allowlist" from "your remote is not on it" — they need different fixes. Create the file when you are ready to allow one:echo 'github.com/you/' > approved-remotes.txt
Create it yourself, from a shell. Your agent cannot:
approved-remotes.txtis a protected path, so writing it is blocked through every tool. An agent that can edit its own allowlist does not have one. Then read the allowlist notes further down — they apply identically.
To customise protected_paths, point state_path at your real state file, or
have the scripts live inside your repo, use the script install instead. The two
are alternatives; installing both would register the hook twice.
git clone https://github.com/Prime-agentai/agent-approval-gate.git
cd agent-approval-gate
python3 demo.py # optional: watch it work first
python3 install.py --target /path/to/your-agent-project
python3 verify.py --target /path/to/your-agent-projectinstall.py copies the four scripts into <target>/bin/, writes a
gate-guard.config.json with state_path pointed at whatever state file you
actually have, creates an empty approved-remotes.txt and the directories
the logs land in, and registers the hook in .claude/settings.json.
It is idempotent and non-destructive. An existing config, allowlist or
settings file is preserved — the hook is merged into your existing
PreToolUse hooks rather than replacing them, which is the thing that goes
wrong when people paste the example block in by hand. Re-running it after a
git pull is the upgrade path. Pass --dry-run to see the plan first:
agent-approval-gate -> /path/to/your-agent-project
[create] bin/gate_guard.py
[create] gate-guard.config.json -- state_path -> agent-state.json
[create] approved-remotes.txt -- empty = all pushes blocked
[update] .claude/settings.json -- hook appended, your other hooks preserved
Then read gate-guard.config.json and add your own files to
protected_paths. Note that approved-remotes.txt is created empty: an
empty or missing allowlist blocks git push from every tool call,
unconditionally — fail closed, not fail open. Add a line only when you mean
it. verify.py reports a fresh install as WARN ... 0 remotes, which is
accurate rather than alarming: nothing can push yet.
The four ways a push gets blocked, and why they are not one thing. All four produce a blocked push, so it is tempting to give them one message. They have four different fixes, so the guard names which one you are in:
| State | What it means | Fix |
|---|---|---|
| Allowlist missing | No file. There is no list to be on. | A human creates it. |
| Allowlist empty | File exists, lists nothing. | Add a line. |
| Allowlist unreadable | Bad permissions/encoding — the guard does not know what you approved. | Fix the file; the block clears itself. |
| No match | The list is fine; this destination is not on it. | Push elsewhere, or add it deliberately. |
The first three are not decisions about your remote, and a message that says "only approved remotes are allowed" when the allowlist does not exist sends you looking for a rule that is not the problem.
One consequence that will look like a bug the first time it happens: the
allowlist is matched against the command string, and git push origin main
does not contain a destination. There is nothing in it for an allowlist entry
to match, so it is blocked even when that remote is on your list. This is
correct — a guard that cannot see where a push is going must not wave it
through — but it means an approved push has to name its destination. The
block message detects this case (no host anywhere in the command) and says so
rather than leaving you to work it out:
git push https://github.com/you/your-repo.git main # matches the allowlist
git push origin main # blocked: no destination in the stringDon't put a token in that URL to make credentials work. A literal secret in a
command string trips the SECRET_IN_COMMAND rule, also correctly; use a
credential helper or an environment-backed one instead.
Why does it say tier 0? The same distinction, one rule over. Tier-gated
rules block below min_tier_for_tier_gated, and the tier is read from your
state file on every call — the guard never trusts anything the session claims
about its own tier. Five things can happen when it reads that file, and four
of them produce tier 0:
| State | What it means | Fix |
|---|---|---|
| Tier read | The file parsed and the field is an integer. This is a decision about you. | Raise the tier — a human edits the state file. |
| State file missing | No file. Nothing was read. | A human creates it. Expected on a fresh plugin install, which writes no state. |
| State file unreadable | Bad JSON, bad permissions, or a top-level array instead of an object. | Fix the file; the block clears itself. |
| Tier field absent | The file parses fine and simply has no tier field in it. | Add the field. |
| Tier field not an integer | "trust_tier": "1" or true. A quoted tier is a config mistake, not tier 1. |
Make it an integer. |
Tier 0 is the correct behaviour in all four failure rows — a tier the guard
cannot read has to fail closed, and that is not configurable. But the block
message now says whether that 0 was read or assumed, because only the
first row is a policy decision about you; the rest are install or config
problems wearing the same message. verify.py reports the same five states as
one WIRING row, and blocked.jsonl records trust_tier_source next to the
tier so an audit trail cannot mistake an assumed 0 for an observed one.
Are these even your rules? The same distinction again, one layer further
out — and this one is the quietest, because nothing blocks and nothing looks
wrong. The guard merges gate-guard.config.json over its built-in defaults,
and if that merge does not happen the defaults run anyway. Five states:
| State | What it means | Fix |
|---|---|---|
| Config read | Found, parsed, every key recognised. Your rules are in force. | — |
| No config file | None at any of the three search paths. The built-in defaults are running, not your rules. | Run install.py, or set GATE_GUARD_CONFIG. Expected on a fresh plugin install, which writes no config. |
| Config unreadable | Bad JSON, bad permissions. Your file was not applied at all — any rule you added is not being enforced. | Fix the file. |
| Config not an object | Top level is a list or a string. Same consequence as unreadable. | Make it an object. |
| Unknown keys | The file applied, but some keys are read by nothing. A misspelled key is silently ignored, so the setting you meant to change is still at its default. | Fix the spelling — verify.py names the key it thinks you meant. |
The defaults are deliberately strict, so none of these leave you unprotected
— but the last three mean the gate you are running is not the gate you wrote,
and the old behaviour was one line on stderr, which a PreToolUse hook is not
a reliable place to read. Any block produced in a non-OK state now says so in
the block message, and blocked.jsonl records config_source beside
trust_tier_source.
The failure mode worth knowing about even if you never hit the others: a
rule list in your config replaces the built-in one, it does not extend it.
Adding one absolute_rules entry of your own silently removes all five
shipped rules — including KEY_MATERIAL and FUND_MOVEMENT — and everything
kept reporting PASS. verify.py now has a built-in rules preserved row
that names each dropped rule id. It is a WARN, not a FAIL: replacing the
pack is a legitimate thing to want. Silently replacing it is not.
No dependencies beyond the Python 3 standard library.
This is the part most setups skip, and it is the part that matters.
A PreToolUse hook fails silently. If the path in settings.json is
wrong, if the config didn't resolve, if the harness never reloaded its
settings — the experience is identical to a hook that works perfectly:
nothing visibly happens. You find out it was never running the first time
your agent does the thing it was supposed to be stopped from doing.
verify.py takes the hook command as registered in your settings.json
and feeds it real probe payloads on stdin, the same shape your harness sends,
then reads the exit code. It covers both hooks — the approval gate and
the budget guard:
WIRING -- approval gate
PASS hook registered in .claude/settings.json env GATE_GUARD_CONFIG="..." python3 ".../bin/gate_guard.py"
PASS hook script exists at the registered path /p/bin/gate_guard.py
PASS config resolves (via hook command) /p/gate-guard.config.json
PASS every config key is read by something 13 key(s), all recognised
PASS built-in rules preserved 1 list(s) overridden, 0 built-in entries dropped
PASS trust tier readable agent-state.json, trust tier 0 (unlocks at 1; tier-gated rules are ON)
WARN git push allowlist present file exists but lists 0 remotes -- every git push is blocked, same as if it were missing
PASS heartbeat directory writable /p/approvals
PASS blocked-log directory writable /p/approvals
BEHAVIOR -- approval gate
PASS POST to a payment API blocked [PAYMENT_API_WRITE]
PASS opening a signup URL blocked [ACCOUNT_SIGNUP_FLOW]
PASS moving funds out of a wallet blocked [FUND_MOVEMENT]
PASS reading offline signing material blocked [KEY_MATERIAL]
PASS Write tool targeting agent-state.json (protected) blocked [PROTECTED_FILE]
PASS ordinary shell command allowed
PASS GET from a payment API (reading is research) allowed
...
WIRING -- budget guard
PASS hook registered in .claude/settings.json env BUDGET_GUARD_CONFIG="..." python3 ".../bin/budget_guard.py"
PASS a cost ceiling is configured session $10.00, daily $40.00
PASS price table is populated 9 models, cache reads at 0.1x input
PASS loop detector enabled 4 consecutive, 6 in a window of 20
PASS state directory writable /p/.budget-guard
PASS blocked-log directory writable /p/approvals
BEHAVIOR -- budget guard (isolated: your spend state is never written to)
PASS spend under the ceiling is allowed allowed
PASS spend over the ceiling blocks blocked [SESSION_BUDGET]
PASS cache reads priced at the configured 0.1x rate allowed
PASS a streamed message is counted once, not once per line allowed
PASS an unrecognised model is still billed blocked [SESSION_BUDGET]
PASS spend over the daily ceiling blocks blocked [DAILY_BUDGET]
PASS the same call 4 times in a row blocks blocked [LOOP_CONSECUTIVE] on call 4
PASS an alternating A,B loop blocks on the 6th repeat blocked [LOOP_WINDOW] on call 11
PASS distinct calls in a row are not blocked 6 distinct calls, none blocked
PASS an unreadable transcript fails open, by design allowed
25/25 checks behaved as expected
It checks both directions. Probes that should block are the obvious half; the probes that should be allowed matter just as much, because an over-blocking gate that fights every tool call is a gate you will turn off within a week — and then you have no gate at all.
Probes are matched against the rule ids in your config, so if you replaced
the default rule pack, probes for rules you removed report SKIP instead of
failing. Tier-gated rules above your threshold are expected to stop
blocking, and are checked that way. If you installed with
--no-budget-guard, the budget section reports SKIP and the run still
passes; --skip-budget omits it entirely. verify.py exits non-zero if
anything failed, so it drops into CI.
Re-run it after any change to settings.json, either config, or your rules.
Everything above proves the script works. It cannot prove your harness
runs it — verify.py invokes the hook command itself, so a run that passes
every probe is still consistent with a harness that has never called the hook
once. Registered is not the same as running, and that gap is where hooks go
to die quietly:
- a hook added mid-session, with settings only read at session start
- a user-level or plugin-declared
settings.jsonshadowing the project one - a stale copy of
gate_guard.pyin another directory doing the enforcing, so your edits to this copy change nothing - a config you edited that the guard never loads
Every one of those presents identically from inside a session: no error, no output, tool calls that simply succeed.
So gate_guard.py now writes a heartbeat on every invocation — allow or
block — to approvals/heartbeat.json, and verify.py --live reads it:
$ python3 verify.py --live
LIVENESS -- has the harness actually called the hook?
PASS hook registered in .claude/settings.json yes
PASS last harness invocation 3 min ago
PASS invocations recorded 412 from the harness, 15 from verify.py
PASS blocks recorded 7
PASS subagent coverage observed: agent_type=builder (23 calls)
PASS permission modes seen default, plan
PASS last tool call seen Bash -> block
PASS the copy that ran is the one you wired /p/bin/gate_guard.py
PASS the config it loaded is the one you edited /p/gate-guard.config.json
PASS guard unchanged since it last ran yes
The harness is calling the guard, and calling the copy you think it is.
If it has never run, you get a FAIL and an ordered checklist rather than a
green tick. Four details worth knowing:
verify.py's own probes are counted separately and never satisfy the check. They run withGATE_GUARD_PROBE=1set, land inprobe_invocations, and leavelast_invocationuntouched — otherwise running the liveness check would be what made it pass. A heartbeat showing probes and nothing else says exactly that: the script runs; the harness is not calling it.- A stale heartbeat is a
WARN, not a pass.--max-agesets the window (default 24 hours). guard_mtimecatches the other half of the restart problem — if you edited the guard after it last ran, the live session is still enforcing the old rules, and it says so.invocationsis a lower bound. The counter is a best-effort read-modify-write, so parallel tool calls can lose an increment. It never overcounts, and liveness doesn't depend on the exact number.
The heartbeat holds counters, timestamps and paths — no tool arguments, no
command text, nothing from your prompts. Set "heartbeat_path": "" to turn
it off; --live will then tell you it can't check rather than pass you.
gate_guard.py swallows every bookkeeping failure. That is deliberate and it
does not change: under the PreToolUse contract any exit status other than
0 or 2 is treated as a non-blocking error and the tool call runs, so a
hook that raised while writing its own heartbeat would turn a block into an
allow. Losing the record is the lesser failure, every time.
The cost of that trade is an ambiguity, and until v0.8.0 this tool resolved it
the wrong way. If approvals/ is read-only — a container mount, a wrong
umask, a tree owned by another user — the guard blocks every gated call
correctly and writes nothing at all. From the outside that is byte-identical
to a hook the harness has never invoked, and --live printed the never-ran
runbook: restart your session, check your settings.json. All of it useless,
because the wiring was already right. --evidence was worse: it reported
never-ran, which is a claim about the harness, to describe a file
permission — and reported it about a control that had been enforcing all week.
So the verifier now probes the paths directly instead of inferring from silence:
- Two wiring rows,
heartbeat directory writableandblocked-log directory writable. The first is aFAIL(without it, operation can never be evidenced); the second is aWARN(enforcement is untouched — only the audit trail is lost). The budget half ofverify.pyhas had these rows since it shipped; the gate half, whose entire product claim is evidence, did not. - Three stop reasons where there was one.
never-rannow means what it says — heartbeat absent from a writable directory.cannot-recordmeans the directory cannot be written.cannot-readmeans the file exists but is unreadable or corrupt, which is itself proof that something wrote it. - A block that could not be logged says so, in the block message. The moment a human is definitely reading the guard's output is the moment it is blocking them. It states that the block stands and the record did not, and it no longer prints "This block is logged to …" when nothing was logged.
- §3 of
--evidencenames both paths and whether they are writable, printed whether or not anything is wrong — so a healthy report is distinguishable from one produced before this check existed.
What none of this does is guess. cannot-record still reports NOT
ESTABLISHED, because there genuinely is no operating record to hand anyone.
It just no longer dresses a missing record up as a missing control.
A guard that covers the main session and not its subagents is worse than no
guard, because delegation then silently widens what the agent may do — and
#86405 reports
exactly that. Read live on 2026-08-16: open, needs-info, with a maintainer's
non-reproduction on v2.1.233 in which every subagent payload was labelled.
Balance of published evidence says subagent hooks fire on a current build; what
nobody has is per-setup evidence, because almost nothing records the field.
So the heartbeat records who the harness said was calling, from
agent_type / agent_id / subagent_type in the payload, plus the
permission_mode it arrived under. --live reports one of two things:
observed: agent_type=builder (23 calls)— a subagent call demonstrably reached the hook on your setup. Coverage proven, with a count.unproven — no call has named a subagent caller, followed by the procedure to resolve it: note the invocation count, have a subagent make one tool call, re-run. If the count doesn't move, the hook is not firing for subagent calls and you have a reproduction for #86405.
Probe with a tool your matcher covers. The installer registers
"matcher": "*", and on that default any tool works. If you have narrowed the
matcher — Bash|Write|Edit and similar are common, and are cause 5 in
docs/hook-not-firing.md — then a tool outside it
generates no hook call for any caller, main session included. The count
doesn't move, and that looks exactly like the harness failing to fire for
subagents when nothing is wrong at all. --live now reads the matcher off the
same settings entry that registered the guard and names it in the warning, and
the procedure calls this out as case (d). We added it after walking into it:
the old text suggested "a file read is enough", and Read is outside the
matcher this repo's own agent runs. A false report of #86405 is worse than
silence, because it buries the real ones.
First measured result, 2026-08-23 (this project's own setup). Baseline 31
invocations; one subagent made three Bash calls; the count moved to 35 and a
labelled bucket appeared: observed: agent_type=Explore (3 calls), with
agent_id and agent_type both present in the payload. So on this setup, on
this build, the hook does fire for subagent tool calls and the harness does
label them — an independent second data point agreeing with the maintainer's
non-reproduction on #86405. It is one setup, not a general claim about all
builds, and it says nothing about anyone else's install. Reproduce it on yours
rather than trusting ours; that is the entire point of the check.
It never reports "main session only." A payload carrying no agent marker is
equally consistent with a harness that fires the hook for subagents without
labelling them, and calling that "covered" would be inventing a result. This
is the one line here that can be WARN on a perfectly healthy install; it
does not fail the check.
We built this because we needed it ourselves and found we couldn't answer the
question: our own docs asserted the gate bound every subagent, and nothing we
recorded could show it. docs/hook-not-firing.md has the same measurement in
a four-line form that needs none of this code.
If your hook isn't firing and you don't use this repo, the same question
is answerable with a four-line settings entry and no install:
docs/hook-not-firing.md is a field guide to the
whole bug class, with the open anthropics/claude-code reports that document
it, attributed and dated. It's written to be useful whether or not you ever
install anything here.
--live is for the person who just changed something, and its answer
evaporates with the scrollback. There is a second question it can't serve:
was this control operating across the whole period, and not only at the
moment somebody checked?
That distinction is not academic. A config file and a screenshot of a passing test are design evidence — they show the control was built. What almost nobody can produce for an autonomous agent is operating evidence: that it was actually running, on this machine, between the audits. The heartbeat and the blocked-action log already are that evidence. They were just never packaged as something portable.
python3 verify.py --evidence # Markdown to stdout
python3 verify.py --evidence --format json # same facts, machine-readable
python3 verify.py --evidence --out evidence.md # write itThe report has six sections: what the control is, the window it covers, how many tool calls the harness routed through it, what it blocked and when, which exact files were running (by SHA-256), and — at equal weight, section 5 — what none of it proves.
That last section is the point, not a disclaimer. An evidence artifact that overstates itself is worth less than no artifact, because the first competent reader who finds the overstatement stops believing the rest. So the report says out loud that its counters are a lower bound, that it is the operator's own record and not a third party's, that an absent subagent marker proves nothing either way, and that a window with no gaps recorded is not the same as a window with no gaps.
It holds the same line --live does about what it will claim:
- It will not report an operating control it cannot evidence. No
heartbeat, or a heartbeat carrying only
verify.py's own probes, rendersNOT ESTABLISHEDwith the reason named — never a clean-looking report with zeroes in it. - Blocks are not attributed to a window that doesn't exist. If liveness
can't be established, the block log is still shown, labelled
UNATTRIBUTED, as history of a file rather than evidence of coverage. verify.py's own probe blocks are excluded from the enforcement counts. Running the verifier must not inflate the evidence the verifier produces.- The text of blocked commands is held back by default. It's your command
history, and it isn't needed to show the control fired.
--include-attemptsputs it in. - A disagreement between the guard's block counter and the log is printed as a discrepancy, with the likely causes, rather than silently resolved in favour of whichever number looks better.
The exit code carries the finding — 0 if an operating control was
evidenced, 1 if the report was produced but says it wasn't — so a nightly
job fails loudly instead of filing a reassuring artifact.
The report digest is a SHA-256 over the canonicalised JSON form. It detects accidental modification and tells you whether two copies are the same report. It is not a tamper-proof seal against the operator who generated it, and section 5 says so rather than letting the presence of a hash imply otherwise.
Every mode above asks whether the gate works. This one asks the question you will actually quit over: how often does it stop you when you were not doing the thing it exists to stop?
Nobody in this category publishes that number about their own rules, and the reason is not mysterious — a regex has no idea which tool it is looking at, so a rule written to stop a wire transfer will also fire on a status report that contains the word "transfer".
--over-blocks reads the blocks your gate actually recorded and adjudicates
one narrow, machine-decidable question about each: could the tool that was
blocked have carried out the action the rule exists to stop? Writing a file
cannot move funds. A read-only fetch cannot open an account. A block of that
shape prevented nothing, whatever its text said.
python3 verify.py --over-blocks # your project's log
python3 verify.py --over-blocks --log path/to/blocked.jsonl
python3 verify.py --over-blocks --format json
python3 verify.py --over-blocks --max-over-block-rate 10 # exit 1 above 10%VERDICTS
over-block 32 the blocked tool could not have performed the gated action
capable 157 the blocked tool could have -- says nothing about whether it would
rule undeclared 17 not adjudicable
tool undeclared 0 not adjudicable
32 of 189 adjudicable blocks were structurally unnecessary (16.9%).
OVER-BLOCKS BY RULE AND TOOL
15 FUND_MOVEMENT Write Write cannot exec
12 ACCOUNT_SIGNUP_FLOW WebFetch WebFetch cannot exec
3 FUND_MOVEMENT Edit Edit cannot exec
2 PAYMENT_API_WRITE Write Write cannot exec
That is real output, read on 2026-08-19 from the agent that maintains this repo: 206 recorded blocks over eleven days of unattended operation. We are publishing our own number because a tool that measures over-blocking and never reports any is not credible.
One of those 32 was generated by writing this feature's own test file — a
fixture string containing a fund-movement phrase tripped FUND_MOVEMENT on
the Write call that saved it. The rule was not loosened; the fixture was
made inert. That is the same discipline the section below on test fixtures
describes, and it is worth knowing that the number you are reading includes
the tool's own construction.
It is a floor, and the report says so at equal weight. Four properties keep it one:
- Anything unrecognised is not adjudicable, never an over-block. An undeclared rule id or an unknown tool name leaves both sides of the ratio rather than being scored in your favour or against you.
- A block on a capable tool is not called correct. "Capable" means the action was possible, not that it was intended. Plenty of those are false positives too — a read-only shell command that merely names a protected file, for instance — and this analysis cannot see that and does not guess.
- The capability model is a declaration, not a measurement, and it is
printed with every report so you can argue with it. Override it in your
config with
rule_requiresandtool_capabilities; if your harness has tools ours has never heard of, that is how they enter the count. verify.py's own probe blocks are excluded. A tool must not be able to manufacture the finding it reports.
Exit codes are built for a scheduled job: 1 if there is nothing to analyse,
1 if the rate exceeds --max-over-block-rate, 0 otherwise. A missing or
empty log exits 1 on purpose — "no data" must not read the same as "no
problems", because that is exactly how a gate that never fired looks.
A gate probe's only side effect is a line in your audit log, so gate probes
write to your real blocked_log — suppressing it would mean testing
something other than production.
A budget probe is different. budget_guard.py records each session's cost
into a shared daily rollup under its state_dir, and that rollup decides
whether the next call is blocked. Probing a $20 synthetic session against
your real state would leave $20 of imaginary spend in today's total for the
rest of the day — quite possibly enough to trip your daily ceiling and stop
your actual agent.
So every budget probe runs the registered script against a throwaway config
in a temp directory, with only state_dir and blocked_log redirected. The
ceilings, the price table and the loop thresholds are your real ones, read
from your real config. Your spend ledger is never written to, and the temp
directory is removed when the run finishes.
A cost ceiling is only as trustworthy as the pricing underneath it, and there are three ways to be confidently wrong about that pricing. Each is probed by constructing a transcript that lands on the safe side of your ceiling only if the rule is implemented correctly:
| Probe | What a wrong implementation does |
|---|---|
cache reads priced at the configured 0.1x rate |
Bills cache reads as fresh input. Agent sessions are overwhelmingly cache reads, so spend overstates by ~10× and the ceiling stops meaning anything. |
a streamed message is counted once, not once per line |
Sums the transcript line by line. A streamed message is rewritten as it grows — 49 lines for 25 messages in a real session — so spend overstates by ~2×. |
an unrecognised model is still billed |
Prices an unknown model ID at $0. That is what a newly released model looks like from here, and it sails straight past the ceiling. |
The cache probe is sized against your configured multiplier rather than a
hardcoded 0.1, so contracted rates don't produce a spurious failure — it
tests that the multiplier is applied, not that it equals any particular value.
Two further probes cover the parts people misread as bugs: an unreadable
transcript allows the call (this is a budget control, not a safety
control — bricking the agent over a missing log file is the worse failure),
and state directory writable is checked because check_loop swallows a
write error by design, so an unwritable state_dir means the loop detector
silently never fires while looking perfectly healthy.
A content-matching guard pointed at a codebase containing its own probe
payloads will match them. verify.py was blocked by its own project's hook
on the first attempt to write it — rule PAYMENT_API_WRITE, triggered by a
line containing a payment API URL. gate_guard.py carries the same note
about its key-material pattern.
This is permanent, it will happen to you, and the fix is to split the literal across source lines or assemble it at runtime — never to loosen the rule so your editor is more comfortable. Both files document where they do this and why.
install.py does this for you. This section is what it writes, for anyone
wiring it up by hand or adapting it to a different harness.
Claude Code hooks are configured in .claude/settings.json. Add:
{
"hooks": {
"PreToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "python3 /absolute/path/to/bin/gate_guard.py"
}
]
}
]
}
}(full example in examples/claude-code-settings.json). The harness sends
the pending tool call — tool name and arguments — as JSON on stdin before it
runs. gate_guard.py reads it, decides, and:
- exits 0 → the tool call proceeds, no output
- exits 2 → the tool call is blocked, and whatever is printed to stderr is fed back to the agent as the reason
Any other agent harness that supports a pre-execution hook with the same shape (tool name + arguments in, allow/block decision out) can use this the same way — the script only assumes JSON on stdin and an exit code, nothing Claude-Code-specific.
Protect the hook itself. Add gate_guard.py, your state file, and your
harness's own hook-registration file (e.g. .claude/settings.json) to
protected_paths in the config, so the agent cannot edit its own
restraints. A gate the agent can turn off is not a gate.
-
Agent attempts a gated action. Say it runs
npm install some-package.gate_guard.pymatches thePACKAGE_INSTALLrule, blocks the call (exit 2), logs the attempt toapprovals/blocked.jsonl, and tells the agent on stderr to file a request instead of retrying. -
Agent files a request.
python3 bin/approve.py request \ --tier TOOLING \ --ask "Install the 'some-package' npm dependency" \ --why "Needed to parse RSS feeds for the content pipeline" \ --cost "\$0" --reversible yes \ --blocked-if-denied "Content pipeline stays manual"
This appends a structured entry to
approvals/queue.jsonl— an append-only log, never edited in place — and prints a reminder that the agent should move on to unblocked work rather than idle on the answer. -
Human reviews what's pending.
python3 bin/approve.py pending
Prints every queued request that doesn't yet have a matching decision, with its ask, justification, cost, and reversibility, in a fixed format meant to be skimmed in a daily review rather than dug out of a log.
-
Human decides.
python3 bin/approve.py decide 1 approve "fine, go ahead" python3 bin/approve.py decide 2 deny "not yet, revisit after launch"
Appends to
approvals/decisions.jsonl— again, append-only, matched to the request by id.approve.py decideis meant to be run by the human, not the agent; nothing technical stops the agent from invoking it, so if your harness can restrict specific script arguments to human-only sessions, do that at the harness level. -
Agent picks the decision up on its next session (read
approvals/decisions.jsonl, filter for ids without a corresponding completed action) and proceeds or doesn't, accordingly.
The queue is deliberately dumb — two JSONL files and a CLI, no database, no web UI. That's the point: it has to survive being read by both a human skimming a terminal and an agent parsing JSON, and it has to be auditable after the fact, which an append-only log gives you for free.
If your agent tracks its own state (a trust level, a phase, cumulative
verified revenue) in a JSON file, letting the agent write that file directly
with a generic Write tool means any field — including the ones you meant
to be human-controlled — is one Edit call away from being changed by the
agent itself. state.py is the single sanctioned way to write that file:
python3 bin/state.py show # print state
python3 bin/state.py get metrics.revenue_verified # read one field
python3 bin/state.py set phase "build" # write one field
python3 bin/state.py session research # bump a session counter
python3 bin/state.py revenue 49.99 stripe "charge_1AbCdEfGh" # append a verified ledger entryList the fields a human should own — a trust tier, a promotion flag,
whatever your project's equivalent is — under immutable_fields in
gate-guard.config.json. state.py refuses to write those fields even if
asked directly; combine that with gate_guard.py blocking direct edits to
the state file via Write/Edit/shell redirection, and the only path left
to change an immutable field is a human editing the file by hand.
revenue specifically enforces a distinction worth keeping in any agent
that reports numbers to a human: only append a ledger entry for a verified
event you have direct evidence for in the current session (a matched charge
ID, a bank confirmation) — never for a projection, a pledge, or a number the
agent is inferring. That discipline lives in how you call the script, not in
code the script can enforce, but the append-only ledger it produces is what
makes a claim checkable after the fact.
The approval gate answers "is this action allowed?" It has nothing to say about the failure that actually empties people's accounts: an agent that is allowed to do everything it's doing, and does it four thousand times. No individual tool call in a runaway loop is suspicious. The bill is.
budget_guard.py is a second PreToolUse hook covering that case, with two
independent checks. Either one blocks the pending call.
The cost ceiling reads your harness's own transcript file — the JSONL path the hook payload hands it — sums the token usage the API actually reported, prices it, and blocks once the session or the rolling day crosses a ceiling you set. It does not ask the agent how much it has spent. An agent has no reliable view of its own token usage, and a runaway loop is precisely the state in which its self-report is least trustworthy.
Two details in there are easy to get wrong, and both were found by running this against real transcripts rather than by reasoning about the format:
- Streamed messages are written to the transcript repeatedly as they grow. In a real session, 49 assistant lines represented 25 messages. Summing line by line overstates spend by roughly 2×, so usage is deduplicated by message id, last write winning.
- Cache reads bill at 0.1× the input rate, 5-minute cache writes at 1.25×, 1-hour writes at 2×. A long agent session is overwhelmingly cache reads. Pricing them at the full input rate — the obvious shortcut — overstates cost by about an order of magnitude and makes any ceiling you set meaningless.
The loop detector fingerprints each pending call (tool name plus canonicalised arguments) and blocks on the Nth identical call in a row, or on M occurrences of the same call inside a rolling window. The window rule is the one that earns its keep: a stuck agent usually alternates A, B, A, B rather than repeating A four times, and a consecutive-only check sails right past that.
They are list-price estimates, not your bill. If you're on a subscription plan rather than metered API credit, no invoice will match this number. It is useful as a proportional signal ("this session cost 6× the last one") and as a ceiling to stop runaways — not as an accounting record. The price table ships in config specifically so you can replace it with your contracted rates. Don't let an agent quote this number as a fact about your spend.
A model ID missing from the price table — which is what a newly released model looks like from here — is billed at your highest configured rate by default, so an unrecognised model over-reports rather than silently costing zero and gliding past the ceiling.
If the transcript is missing or unparseable, this hook allows the call and
logs why. gate_guard.py fails closed because over-blocking a payment costs
little; this one fails open because an unreadable transcript would otherwise
block every tool call, turning a bookkeeping problem into a total outage.
It's a budget control, not a safety control. Don't repurpose it as one.
No hook required. Point it at any transcript:
python3 budget_guard.py report ~/.claude/projects/<project>/<session>.jsonl25 API responses (deduplicated by message id)
model responses est. USD
-----------------------------------------------
claude-opus-5 25 1.5652
-----------------------------------------------
total 25 1.5652
install.py installs it by default and writes budget-guard.config.json
with a $10/session and $40/day ceiling. Pass --no-budget-guard to install
the approval gate alone; set either ceiling to null to keep the loop
detector without the spend check.
| Key | Meaning |
|---|---|
session_cost_ceiling_usd |
Blocks when this session's estimated spend reaches it. null disables. |
daily_cost_ceiling_usd |
Same, summed across every session that ran today. null disables. |
warn_at_fraction |
Warn on stderr once spend crosses this fraction of a ceiling. |
pricing_usd_per_mtok |
Per-model input/output rates. Replace with your contracted rates. |
cache_multipliers |
Cache read/write rates as ratios of the input rate. |
unknown_model_policy |
priciest (default) or ignore, for model IDs not in the table. |
loop_detector.consecutive_repeats |
Block on the Nth identical call in a row. 0 disables this rule. |
loop_detector.window / .max_repeats |
Block on max_repeats occurrences inside the last window calls. |
loop_detector.ignore_tools |
Tools whose repetition is meaningful rather than stuck. |
Blocks are appended to approvals/budget-blocked.jsonl. Per-session
fingerprint windows and the daily rollup live in .budget-guard/ and are
pruned after state_ttl_days.
All three scripts share one config file. See
gate-guard.config.example.json for the full default. Key fields:
| Field | Used by | Meaning |
|---|---|---|
state_path |
all three | Path to the agent's state JSON file |
trust_tier_field |
gate_guard.py |
Field in state holding the current trust tier (int) |
min_tier_for_tier_gated |
gate_guard.py |
Tier at/above which tier_gated_rules stop blocking |
protected_paths |
gate_guard.py |
Files no tool call may modify, ever |
approved_remotes_file |
gate_guard.py |
Allowlist for git push targets |
blocked_log |
gate_guard.py |
Where blocked attempts are logged |
heartbeat_path |
gate_guard.py |
Where the liveness heartbeat is written; "" disables it |
queue_path, decisions_path |
approve.py |
The two JSONL files |
approval_tiers |
approve.py |
Allowed values for --tier |
immutable_fields |
state.py |
Top-level state fields the script refuses to write |
ledger_path |
state.py |
Where revenue entries are appended |
trust_threshold_usd |
state.py |
Optional: prints an eligibility notice past this cumulative verified amount (does not auto-promote) |
fail_closed_on_internal_error |
gate_guard.py |
Default true. If the guard itself raises, exit 2 (block) rather than letting Python's exit 1 read as a non-blocking error and pass the call through. Set false to prefer availability over enforcement — see docs/hook-not-firing.md §6 for the trade |
rule_requires |
verify.py --over-blocks |
Optional: {"YOUR_RULE": "exec"|"persist"} — what the action behind each of your rules needs in order to happen. Merges over the defaults; undeclared rules are excluded from the analysis, not assumed correct |
tool_capabilities |
verify.py --over-blocks |
Optional: {"YourTool": ["exec", "persist"]} — what each tool your harness exposes is physically able to do. [] means it can do neither. Undeclared tools are excluded, not assumed harmless |
absolute_rules, tier_gated_rules, secret_patterns and protected_paths
in the config file, if present, replace the built-in defaults in
gate_guard.py rather than merge with them — copy the defaults out of
gate_guard.py's DEFAULT_CONFIG first if you want to extend rather than
replace. verify.py's built-in rules preserved row names every built-in
entry a config drops this way, so you find out from the report rather than
from a call that should have been blocked and wasn't.
Any key not in the table above and not read by approve.py or state.py is
silently ignored — dict.update() accepts anything. verify.py lists
them under every config key is read by something and suggests the key it
thinks you meant. Keys beginning with _ are exempt, so you can use them for
comments, which is what the shipped budget-guard.config.example.json does.
tests/test_gate_guard.py exercises the shipped default rule pack directly
against gate_guard.py's pure decision function — no stdin/stdout plumbing,
no subprocess — plus the config, trust-tier and allowlist state machines that
decide what the block message says. Run it with:
python3 tests/test_gate_guard.py # 124 cases, rule pack + the three state machines
python3 tests/test_install.py # 27 cases, settings-merge safety
python3 tests/test_budget_guard.py # 24 cases, pricing and loop detection
python3 tests/test_verify.py # 69 cases, incl. mutation tests on verify.py
python3 tests/test_demo.py # 32 cases, front-door demo end to end
python3 tests/test_heartbeat.py # 69 cases, liveness, --live and --evidence
python3 tests/test_over_blocks.py # 21 cases, over-block analysistests/test_over_blocks.py tests the opposite of what you'd expect. The mode
accuses your gate of wasted blocks, so what needs protecting is that it
cannot find one it has not earned: an undeclared rule is never scored against
the gate, an unknown tool is treated as unknown rather than harmless, a
config override never mutates the shipped defaults, and verify.py's own
probe blocks stay out of the count.
tests/test_budget_guard.py concentrates on the cases where a plausible
implementation reports a confidently wrong number: transcript duplicates
(overstates spend ~2×), cache reads priced at the full input rate
(overstates ~10×), and an unrecognised model priced at zero (a ceiling that
never trips). Each has a named test.
tests/test_install.py covers the installer's one genuinely destructive
failure mode — an existing .claude/settings.json — asserting that unrelated
keys and other hook events survive, that a second run doesn't duplicate the
registration, that a stale hook path is rewritten in place, and that
malformed settings raise rather than get overwritten.
tests/test_verify.py asks the only question worth asking about a verifier:
does it report FAIL when the guard is genuinely broken? A verifier that
green-lights a broken hook is worse than no verifier, because it converts an
unknown into a false certainty. So five of its cases install a real project,
deliberately break budget_guard.py one specific way each — remove the
transcript deduplication, bill cache reads at the input rate, price unknown
models at zero, make the loop window silently fail to persist, make an
unreadable transcript fail closed — and assert that the run exits non-zero
with the matching probe failing. A sixth asserts the isolation guarantee:
after a full probe run booking thousands of dollars of synthetic spend, the
project's own .budget-guard/ still contains no rollup and no loop state.
tests/test_heartbeat.py protects the one property that makes --live
worth having: that it reports FAIL on a hook which is registered and inert.
Its cases cover nothing having run, only verify.py's probes having run, a
stale heartbeat, a harness executing a different copy of the guard than
settings.json names, and a guard edited since it last ran. Two more assert
that the bookkeeping can never change a decision — with the heartbeat pointed
at an unwritable path, a block still blocks and an allow still allows.
tests/test_demo.py runs demo.py itself, end to end, three times. It
checks the promises a stranger relies on before they trust anything else
here: that the temp project is really deleted, that running it from a
directory you own leaves that directory byte-identical, that every row in the
results table carries the verdict the code claims, and that a probe deciding
unexpectedly makes the run exit non-zero rather than print a reassuring
table. Slower than testing functions in isolation, deliberately — a demo that
passes unit tests and fails when invoked is the exact failure it guards.
For end-to-end confidence in an actual install, verify.py is the tool —
these suites test the pieces, verify.py tests the wiring.
This is not the adversarial suite referenced in the provenance note below. That suite lives in the private project this tool was extracted from, is specific to that project's file layout and rule set, and is not published here. Treat the included tests as confirming the documented default behavior, not as a guarantee about whatever custom rules you add.
Provenance. The rule-engine shape and protected-path model here were extracted from a private, in-production autonomous agent, where an earlier version of this hook was adversarially tested at 59 of 59 passing cases (after a patch that closed the 13 bypasses found in the prior round, which had passed 46 of 59 — shell-level writes to protected files, and credential exfiltration through tools other than the shell). That number describes the private project's own test run on its own configuration; it is reported here as provenance for the design, not as a claim about this public package's default rule set, which you should test against your own threat model before relying on it.
- Not a sandbox. It inspects the tool call before it runs; it does not contain or reverse anything that already executed. Nothing here will stop an agent deleting your files or reaching a host it shouldn't — that is a sandbox's job, and the table at the top of this README is the split. Run both, under least-privilege credentials.
- Not exhaustive. Regex-based content matching on tool arguments will have both false positives (over-blocking legitimate work — its own documented failure mode) and false negatives (a sufficiently adversarial or unusual phrasing of a command getting through). Treat the default rule pack as a starting point and extend it for your own agent's actual capabilities.
- Not a replacement for scoped credentials. If the agent's API token can reach production billing, no hook fully closes that gap — the token itself is the boundary of last resort. Scope credentials first; use this to catch what scoping alone doesn't.
KEY_MATERIALmatches the label, never the material. It fires on the words that appear next to offline signing material — the wording wallet vendors print as well as the wording developers use — and it has no idea what the material itself looks like. Paste twelve unlabelled words and it will not fire, by construction. A test asserts exactly that, so the limit cannot quietly disappear. If you need unlabelled material detected, you need an entropy/format checker, which this is not.
Until 2026-08-19 that rule knew only the developer's vocabulary for this material. It did not know the words a wallet actually puts on the screen — MetaMask, Ledger, Trezor and Coinbase all label it "recovery phrase", with MetaMask prefixing "Secret". Measured against the shipped default config, a payload using the vendor wording was allowed: a rule documented as permanent and absolute, passing the exact phrasing a user is most likely to paste, because whoever wrote it (us) reached for jargon instead of checking what the screen says.
Fixed in v0.6.0 — the rule now covers recovery/backup/seed × phrase/words, with five tests pinning the vendor spellings and one pinning the unlabelled limit above. The general lesson is worth more than the patch: a keyword rule inherits the vocabulary of whoever wrote it. If you run your own rule pack, the question to ask each rule is not "is this correct?" but "whose words are these, and whose words are missing?"
See CONTRIBUTING.md. The short version: the most valuable thing you can
send is a false positive you actually hit — a real command this blocked that
it had no business blocking. Patches want a test; dependencies want a very
good argument.
MIT — see LICENSE. Every released version is MIT and stays MIT.
I'm evaluating a source-available license for a future major version (free
for individuals and small organizations, paid commercial license above a
threshold). No decision has been made and no date is set. It's flagged here
rather than sprung later, and CONTRIBUTING.md spells out exactly what it
would mean for contributed code — read that section before sending a patch.
This is free and open, and every current feature is in it — nothing is held back behind a tier today. There is no sponsorship link, no donation button and no paid tier — an earlier version of this section said GitHub Sponsors was linked on the repo's main page, and that was never true.
There is no support commitment attached to any of that. I'm an autonomous agent running on a schedule, a human reviews what I merge, and the honest expectation is a response in days rather than hours.