Repository navigation
Architecture
anti-hall is a plugin for Claude Code with a port for Codex. It hooks into the host's lifecycle events (session start, each prompt, each tool call, stop, …) and answers with a block, an advisory text, or nothing.
anti-hall is a Rust engine at its core: ah-engine answers every hook, running the plugin's own rules (logic in plugin JavaScript, settings and texts in plugin TOML). The Node hooks in plugins/anti-hall/hooks/*.js are a temporary compatibility fallback during the migration; v1.0 removes them, after which the engine is the only runtime.
| Where | Status | |
|---|---|---|
| ah-engine (core) |
ah-engine/ (Rust) plus its config and logic under plugins/anti-hall/engine/
|
hooks.json registers one thin trigger per event, and the engine decides |
| Node hooks (temporary fallback) | plugins/anti-hall/hooks/*.js |
Answer only what the engine cannot yet; removed in v1.0 |
This page describes the engine layout, because that is where new capability goes (see "Zero new Node" in the Decision log). Source of truth: docs/AH-ENGINE.md, ah-engine/README.md, ah-engine/DECISIONS.md.
Note
Engine and temporary fallback. During the migration the engine answers what it can prove identical to the Node hook; the rest, and every case where the engine is missing, slow, busy or broken, goes to the temporary Node fallback, which v1.0 removes. A guard is never weaker on the engine.
| Situation | Decides | Notes |
|---|---|---|
| Engine up, check ported and proven | Engine | In-memory answer within a hard time budget |
| Engine up, check defers (exit 75) | Node hook | Same verdict as before |
| No engine, timeout, crash or open breaker | Node hook | Silent allow only if the Node hook is also unavailable |
Claude Code / Codex
│ hook event E, JSON payload on stdin
▼
thin trigger plugins/anti-hall/hooks/ah-hook.sh (POSIX sh, one entry per event)
│ spawns: ah-engine hook --event E --host claude|codex --fallback-map …
▼
hook client ──Unix socket──▶ ah-engine daemon (`ah-engine serve`, one per user, resident)
│ reads at run time:
▼
plugin config and logic plugins/anti-hall/engine/
defaults/*.toml, defaults.pristine/, rules.json, logic/*.js
▲
└── on defer (exit 75), timeout, crash or no engine: the event's Node hooks run instead
-
plugins/anti-hall/hooks/hooks.jsonhas one entry per event, with no matcher, each runningsh "${CLAUDE_PLUGIN_ROOT}/hooks/ah-hook.sh" <Event>. Onengine-protothat is 31 events: every Claude Code hook event exceptWorktreeCreateandWorktreeRemove(decision D87). -
hooks.json, the per-hook registry and the Node fallback list (ah-fallback.list,ah-fallback.map.json, and the Codex variants) are generated from the dispatch tableplugins/anti-hall/engine/defaults/dispatch.toml(ah-engine gen-hooks). A test fails on any byte of difference, so edit the table, not the generated files. - The tool name is read from the payload itself, never from an argument, so a caller cannot narrow which guard rows run.
- On SessionStart the trigger also launches
hooks/ah-engine-bootstrap.shin the background (see "Getting the binary" below).
- One binary,
ah-engine. Thehookverb is a short-lived client; theserveverb is the resident daemon. - The client sends one framed request over a Unix socket (
e.sockin the engine's state dir) with a 2 s deadline (client.deadline_ms). Only a complete OK frame counts as an answer; anything else falls back to Node (decision D11: "fallback, never a silent allow"). - If no daemon is listening, the client spawns
ah-engine servedetached. On that cold call the Node hooks answer while the daemon comes up. - One daemon per user: a lock file next to the socket keeps it single-instance. It has a bounded worker pool (
daemon.workers, default 4; overflow answers BUSY, which falls back) and a watchdog. - It stays resident (
daemon.idle_exit_s = 0, decision D7) so its scheduler and mailbox keep running. It exits on a stall, a stuck worker, RSS overdaemon.mem_mb(512),ah-engine stopor SIGTERM. A newer client hands over: the old daemon drains and exits. There is no launchd or systemd unit.
Nothing tunable is compiled into the binary. The engine reads, at run time:
Path (under plugins/anti-hall/engine/) |
What |
|---|---|
defaults/*.toml, listed by defaults/index.toml
|
Every setting, limit, message text and dispatch row |
defaults.pristine/ |
A read-only, byte-identical copy of the shipped defaults, used for failover and self-heal |
rules.json |
Rule data |
logic/<check>.js, logic/lib/*.js, logic/rules/*.js
|
The decision logic of each check, run in an embedded JavaScript sandbox |
Details in Engine internals.
- The host fires event E and runs
sh ah-hook.sh Ewith the JSON payload on stdin. - The trigger saves the payload to a private temp file, reads
tool_namefrom it, and finds the engine (~/.anti-hall/ah-engine/bin/ah-engine, thenah-engineonPATH). - It runs
ah-engine hook --event E …under a watchdog, with the event's timeout. - The client sends the request to the daemon, starting it if needed.
- The dispatcher selects the table rows for this event and host:
- Node hooks that have no engine check start at once.
- Scripted checks run inside the daemon. A check that cannot decide defers, and its Node hook runs.
- Results are combined the way the host would combine separate hooks: the first exit 2 wins byte for byte, otherwise the first JSON block; several JSON answers merge (contexts joined in order, strongest
permissionDecisionwins).
- The engine exits 0 (answer), 2 (block), a hook's own exit code, or 75 (defer).
- The trigger passes 0 and 2 through. On 75, a timeout, a signal death, a spawn failure, or no engine at all, it runs that event's Node hooks from
ah-fallback.list.
Guard events (PreToolUse, PermissionRequest, Stop, SubagentStop) fail closed when the fallback itself cannot run, except a Stop with stop_hook_active set, which fails open so a turn can never be wedged. A check's own failure (an exception, a CPU-limit interrupt) never blocks the agent (DECISIONS revision 1.120).
The Node hooks are the reference implementation and the safety net:
- They run whenever the engine is missing, defers, times out or crashes, so a user without the engine binary gets full behaviour.
- Every engine check is held to exact parity with its Node hook (decision D31), proven by goldens, parity lanes and replay (see Engine internals).
- Node gets bug fixes only; new capability goes into the engine (decision D80, "zero new Node").
- Some tools stay in Node on purpose because they must work with the engine down:
doctor,update, state migration, and DevSwarm recovery (decision D60).
Users never build the engine. On SessionStart, ah-engine-bootstrap.sh reads plugins/anti-hall/ah-engine.lock, downloads the archive for the platform (macOS arm64/x86_64, Linux x86_64/arm64 on glibc or musl, WSL2 as Linux) from the GitHub Release, and installs it only if its sha256 matches the lock. Any failure is logged and the Node hooks stay in use. Opt out with the setting engine.bootstrap = false or AH_ENGINE_BOOTSTRAP=0. Full procedure: ah-engine/RELEASING.md and Release runbook.
The Codex port registers the same trigger with --host codex, so the engine answers from the Codex rows of the same dispatch table. Every change covers both hosts, or states why one does not apply (AGENTS.md, "Dual-platform parity").
- Engine internals: config loading, the no-hardcoding tests, the script sandbox, goldens and replay.
- DevSwarm: the multi-workspace integration.
- docs/DEVELOPMENT.md: building and testing from a fresh checkout.
anti-hall by Mohammed Talas (@talas9) · Repository · Docs site · Discussions · Contributor wiki: the repository is the source of truth; fix this wiki when they disagree.
🗺️ How it works
- 🏗️ Architecture
- ⚙️ Engine internals
- 🌐 DevSwarm
- 🤖 Repo automation
🛠️ How we work
📄 Templates
🔗 Elsewhere