Skip to content

Architecture

talas9 edited this page Oct 10, 2026 · 3 revisions

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

The three layers

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

1. Thin trigger

  • plugins/anti-hall/hooks/hooks.json has one entry per event, with no matcher, each running sh "${CLAUDE_PLUGIN_ROOT}/hooks/ah-hook.sh" <Event>. On engine-proto that is 31 events: every Claude Code hook event except WorktreeCreate and WorktreeRemove (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 table plugins/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.sh in the background (see "Getting the binary" below).

2. The engine daemon

  • One binary, ah-engine. The hook verb is a short-lived client; the serve verb is the resident daemon.
  • The client sends one framed request over a Unix socket (e.sock in 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 serve detached. 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 over daemon.mem_mb (512), ah-engine stop or SIGTERM. A newer client hands over: the old daemon drains and exits. There is no launchd or systemd unit.

3. Plugin config and logic

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.

Hook event flow, step by step

  1. The host fires event E and runs sh ah-hook.sh E with the JSON payload on stdin.
  2. The trigger saves the payload to a private temp file, reads tool_name from it, and finds the engine (~/.anti-hall/ah-engine/bin/ah-engine, then ah-engine on PATH).
  3. It runs ah-engine hook --event E … under a watchdog, with the event's timeout.
  4. The client sends the request to the daemon, starting it if needed.
  5. 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 permissionDecision wins).
  6. The engine exits 0 (answer), 2 (block), a hook's own exit code, or 75 (defer).
  7. 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 fallback

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

Getting the binary

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.

Codex

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").

Where to read next

Home

🗺️ How it works

🛠️ How we work

📄 Templates

🔗 Elsewhere

Clone this wiki locally