A last-second guard for AI coding agents. It sits in the harness's
PreToolUse hook, reads the command or file the agent is about to act on,
and stops the handful that cannot be undone — destroying a secret,
force-pushing over history, printing a credential into the transcript,
purging a volume — while everything else passes through untouched.
Every denial says what to do instead. That is the point: the agent should finish the turn knowing the sanctioned path, not just that it was blocked.
Real icg check output — all four verdicts, and both input modes
(a shell command, and file content as Write/Edit/apply_patch would
supply it). Reproduce it with docs/assets/demo.sh.
The same evaluation, step by step. Reproduce the trace with
icg check --command "bao kv destroy secret/app/db" --debug.
Two things decide everything: safe patterns are tried first and short-circuit, and among guarded patterns the first match wins. That ordering is what keeps ordinary read-only work quiet — a rule only ever fires on input no safe pattern claimed.
Four verdicts, one per redirect channel a rule can declare — and only
deny stops the command:
| Verdict | Hook response | When |
|---|---|---|
ALLOW |
permissionDecision: allow |
No rule matched, or a safe pattern matched first |
WARNING |
allow + additionalContext |
The rule cannot decide reliably enough to block, but the agent should know |
REWRITE |
allow + updatedInput |
A safe form of the same intent exists — the harness retries with it |
DENY |
permissionDecision: deny |
Irreversible; the reason carries the alternative |
The engine is deterministic, and its evaluation does no network I/O — with
one documented exception: before a non-force git push is judged, the
git-stale-remote-head-push rule runs a single live git ls-remote lookup
against the push's own upstream, and any error in that lookup fails open and
lets the push proceed. The
no-network boundary note records the
exception's activation, failure behavior and fail-open semantics in full.
The engine fails open: an empty pack directory, an unrecognised tool, or
a crashed check allows the command. A missed violation is recoverable; a
wedged agent fleet is not.
A graduated fail-closed policy exists
for once a release has proven itself.
Policy is selected before the engine evaluates a call. Each invocation uses one source; sources are never merged.
For operator commands such as check, explain, coverage, catalog,
status, and health, precedence is:
- explicit
--packpaths; ICG_PACK_DIR, when no--packwas supplied;- the installed chain:
/etc/icg/packs, otherwise the legacy/etc/icg/rule-pack.json; - the checkout's
packs/, but only when neither installed location exists.
The hook has a separate installed-only boundary. An explicit
icg hook --rule-pack <path> wins for that invocation; otherwise it uses
ICG_RULE_PACK, then /etc/icg/packs, then /etc/icg/rule-pack.json (the
legacy artifact). It never reads ICG_PACK_DIR or a checkout fallback. A checkout report selected with
ICG_PACK_DIR or --pack is useful for development and review, but is not
evidence of what the installed hook enforces. See the full
pack-source resolution contract.
Median cost of a check on a warm cache: ~15–20 ms on the reference
environment, measured and reproducible —
scripts/bench-check-latency, record and
method in the
check-latency benchmark note.
That note's budget is enforced, not aspirational: the repo's
definition of done builds the release
binary and fails if a warm-cache p50 reaches 50 ms on the shipped pack
set — a rot gate at roughly 3× the measured median, not a tail SLA. And the
figure is re-measured, not just re-quoted: CI (icg-ci) runs the same bench
on every push to main — gated at the recorded 50 ms runner budget. An
explicit bench-budget-ms=0 override is advisory for diagnostics only.
A non-force git push additionally waits on the stale-remote-head lookup's
one network round trip.
Grab the release binary — or build from source, which needs nothing but a Rust toolchain:
curl -fsSLO https://github.com/jedarden/irreversible-command-gate/releases/download/v0.1.71/icg
chmod +x icg
# or: inspect the checkout's packs explicitly (this is not deployed coverage)
# git clone https://git.ardenone.com/jedarden/irreversible-command-gate.git
# cd irreversible-command-gate && ICG_PACK_DIR="$PWD/packs" \
# cargo run --release -- coverage --list
./icg coverage --list
./icg check --command "bao kv destroy secret/app/db"
./icg check --command "git push --force origin main"
./icg check --command "git status"An unqualified operator command follows the trust-source precedence:
the installed trust source wins when present, and the checkout is only the
last fallback. To inspect a checkout's packs/, set the explicit developer
override ICG_PACK_DIR="$PWD/packs"; that report is not deployed coverage. A
bare binary needs installed packs or an explicit --pack <dir>.
icg check is the human-facing tester and always exits 0 — parse its
output, not its status. icg hook is the machine entry point: one
PreToolUse JSON document in, one decision envelope out.
To actually guard an agent, install the binary and packs root-owned and
register the hook. install.sh does all of it and proves the result
enforces before reporting success:
curl -fsSL https://raw.githubusercontent.com/jedarden/irreversible-command-gate/main/install.sh \
| sudo bash -s -- --hookThat last part matters more than it sounds. icg hook fails open by design:
with no readable pack directory it answers {"permissionDecision":"allow"}
and exits 0, silently. A half-finished install therefore looks exactly like a
working one. The installer sends a known-destructive command through the hook
and refuses to report success unless it comes back denied — so you cannot end
up believing you are guarded by nothing.
--dry-run shows what it would do; --uninstall reverses it. The manual
steps are in the Quick Start Guide.
Eleven rule packs, 29 guarded patterns, 21 safe patterns that keep common read-only forms fast and quiet.
| Pack | Rules | Blocks |
|---|---|---|
openbao |
3 | kv destroy, metadata delete, mount/policy deletion, operator rekey; secret literals in argv; secret reads to stdout |
git |
4 | bare git credential fill; --force push (rewritten to a plain push); commits with no pathspec; pushing over a stale remote head |
secrets |
6 | GitHub tokens and PATs, AWS keys, Slack tokens, Anthropic keys, PEM private-key blocks — in commands and file content |
docker |
3 | system prune --all, volume rm, image rm --force |
image-tag |
2 | :latest and bare-SHA image references in manifests |
kubectl |
3 | kubectl delete; blanket mutating verbs (apply, patch, scale, rollout restart); kubectl create outside Argo Workflow submission — hook front-end only, never PATH-wrapped |
storage-class |
1 | storage classes that cannot be expanded or reclassed in place |
beads · misc · tmux · argocd-topology |
7 | conventions of the fleet this was built for — useful mainly as worked examples |
The first four packs describe footguns that exist wherever the tool does. The last row encodes local convention. The coverage table marks every pack General or Fleet-specific and names every rule id, so you can tell at a glance which ones travel.
Nothing about the engine is fleet-specific — icg new-pack <tool>
scaffolds a pack and its regression test together.
- It is a backstop for an honest, fallible agent, not a boundary against
a hostile one. Policy lives root-owned in
/etc/icg/so the guarded agent cannot rewrite it, but an agent that sets out to defeat the guard can. Keep the harness's own approval and sandbox controls on. - It does not defend against prompt injection or a malicious repository trying to trick an honest agent. Different threat class, explicitly out of scope.
- It does not know who is calling. There is no identity, TTY or privilege
check anywhere in the engine. Rules whose text says "a human runs it"
describe a procedure you follow, not a capability the guard enforces. The
agent/human split you get from hook mode is structural —
icg hookonly runs inside the harness's tool loop — and the PATH wrapper has no such split unless you scope its symlinks to the agent'sPATH(deployment guide).ICG_DISABLED=1is audited, not restricted: an agent can set it as easily as you can. - It does not reach cloud-hosted agent sessions — ChatGPT web, Codex cloud tasks, claude.ai. Only local CLIs invoke local hooks. See multi-harness-integration.md.
- Its
kubectlrules are blanket, not ArgoCD-aware. Thekubectlpack denies mutating verbs whatever the target; scoping them to ArgoCD-managed resources would need live cluster state — ADR-001..github/workflows/*writes andkind: Job/CronJobmanifest content are covered too: built-in guards deny them on Write/Edit and Codexapply_patch— github-workflows-detection-seam.md — redundantly with the org-level hook for as long as both run.
The engine, the packs, both front-ends (hook and PATH wrapper), the
release-integrity machinery, and a green full test suite across tests/
and src/ are in the tree and working. The whole crate is pure Rust with
a small dependency tree and no C toolchain requirement.
v0.1.4 is the current release (2026-09-08). It is the first release
cut by the version auto-bump in icg-ci: before it, a push that did not
touch Cargo.toml produced a green run that shipped nothing, and five fixes
accumulated behind the published v0.1.3. Those fixes are what this release
carries — icg status --denials reads the log the hook actually writes;
cargo test on an instrumented host no longer appends to the live denial
log; beads-shared-checkout-write guards the bead store rather than every
scratch file under .beads/; the hook no longer demands a write lock on
root-owned policy state on every call; and a healthy guarded invocation now
leaves stderr empty for real faults.
v0.1.3 (2026-09-06) remains the release to upgrade from if you are on
v0.1.1 or v0.1.2: it closed a guard bypass where an apostrophe in a
heredoc body made the lexer lose the rest of the command, silently skipping
six of ten command-mode packs including all the Critical destructive rules.
Each release carries the binary, the pack tarball, a byte-level pack
manifest, and the merged rule-pack.json. Several releases now exist, so
icg update's trust-pointer flow has real predecessors to advance from — but
that transition has still not been exercised end to end. Treat icg update
as unproven until it has. Tracked in
docs/plan/plan.md, Phase 0.
Start at docs/README.md for the full map. The short version:
| You are | Read |
|---|---|
| Trying it out | Quick Start |
| Deploying it | Deployment guide → Operator docs |
| Hit a denial | Deny-message guide |
| Writing a rule pack | Rule-pack best practices |
| Building on the policy from another tool | Event catalog API — icg catalog --json |
| An agent working in this repo | AGENTS.md |
| Curious about the design | plan.md · ideas ledger |
icg new-pack <tool> --pack-type command --output-dir packs/Writes <tool>.json and <tool>_pack_tests.rs together, pre-filled, and
refuses to overwrite either. --pack-type content scaffolds a file-content
pack instead.
Before proposing a pack change, run the release gate — it builds the fixed deny-regression corpus and reports any rule that stopped covering what it used to:
icg regression-suite packs --release-gate --output regression-suite.json
icg coverage-diff <previous-pack> <current-pack>Per-pack generation (icg regression-suite packs/<id>.json) works on every
shipped pack. Rules a deny suite cannot represent — a rewrite or warning
channel, a predicate needing live state, the secrets pack's unconditional
matching — are reported in the suite's skipped array with the reason,
rather than aborting the pack. A deny rule with a regex check is never
skipped: if its command cannot be derived from the regex, give it an
example_command in the pack.
MIT — see LICENSE.
Part of jedarden.com.
The GitHub repo is a read-only mirror of
git.ardenone.com/jedarden/irreversible-command-gate — issues and PRs are
welcome on either.
