ulukaya/pawl

Deterministic gates for AI agent harnesses

Python

2

97 commits

updated Oct 6, 2026

See the code

See what people are saying

README

pawl

pawl mark: a hook holding a gear

License: Apache-2.0 Python 3.11+ Dependencies: none Claude Code, Codex, Antigravity

Deterministic gates for coding agents, as one plugin for Claude Code, OpenAI Codex and Antigravity.

Agents make the same mistakes over and over, and telling them not to in the prompt stops working after a page. pawl is a set of small checks that run as code, not as instructions, and a report command that tallies what they blocked. Each check watches for one mistake and refuses it, cleans it up, or (for provably read-only commands) waves it through without a prompt. Plain Python standard library, no model calls, nothing added to the prompt but a 144-token skill description.

A pawl is the small part in a ratchet that lets the wheel move forward and stops it from slipping back. Every check here works the same way: the current state is the floor.

Try it

No install, no harness, nothing written outside a scratch directory:

git clone https://github.com/ulukaya/pawl && python3 pawl/hooks/pawl.py demo

Real Pawl hook decisions: deny a script deleting home, stay silent for
project cleanup, and ask before discarding Git work

Recorded through the real Claude Code hook adapter using fixture tool calls. The commands are inspected, never executed; displayed reasons are excerpts. This demonstrates hook decisions rather than a live agent session.

The command above sends thirteen calls through the real dispatcher, written as Antigravity, Claude Code and Codex send it, and prints what each harness is told:

call                        gate        antigravity      claude code      codex
-------------------------------------------------------------------------------
make build                  -           allow            silent           silent
git log --oneline -5        readonly    auto_approve     allow            silent
git reset --hard            git         force_ask        ask              deny
script that runs rm -rf ~/  blast       deny             deny             deny
curl install.sh | sh        pin         force_ask        ask              deny
tail -f server.log          poll        force_ask        ask              deny
same pytest run, 3rd time   loop        force_ask        ask              deny
edit that changes nothing   noop        deny             deny             deny
write with a U+200B         zero-width  allow +rewrite   +rewrite         allow +rewrite
send naming ~/.deploy/      egress      deny             deny             deny
read another session        fence       force_ask        ask              deny
read Chrome's cookie key    creds       force_ask        ask              deny
stop with tail -f running   idle        n/a              block            n/a

silent leaves the harness's own prompt in place; allow and auto_approve skip it. A Codex PreToolUse hook can neither ask nor approve, so there an ask is a deny that tells the agent how to proceed. --verbose adds every reason, --json every raw answer.

On your own sessions

replay sends every tool call from your past Claude Code sessions through the same gates. It runs nothing and keeps no state:

python3 pawl/hooks/pawl.py replay --days 30 --show

It lists each call pawl would have asked about or refused, then a tally per gate, including how many read-only commands it would have approved without a prompt. Run it before installing to see what would change, and after changing a gate to find its false alarms on real work.

What it catches

Grouped by what a miss costs. The first table is work or data an agent cannot take back; those gates are the reason pawl exists.

Can't be undone

The mistakeWhat pawl doesGate
Deletes home, root, a drive or ~/Documents, directly or from a script, trap, npm run, Makefile, python -c or container it runsRefuses; asks before anything else outside the workspaceblast
Runs git reset --hard, git clean -fdx, git commit --no-verify, git push --force or git branch -D and loses workAsks the human firstgit
Pastes internal paths, tokens or hostnames into a messageBlocks the sendsend (egress)
Reads browser cookies, saved passwords or the keychain (security find-generic-password, import browser_cookie3)Asks the human firstcreds
Reads another conversation's private filesAsks the human firstfence
Installs from a branch URL (pip install .../archive/main.zip), runs npm install -g tool with no version, or pipes curl into shAsks the human to pin it or approve itpin
Floods a chat room or inboxCaps sends per channel per day; refuses a send loop it cannot countsend (budget)

Prompts it removes

The mistakeWhat pawl doesGate
Stalls on a permission prompt for ls or git logApproves commands that provably only readreadonly

Time and tokens it saves

The mistakeWhat pawl doesGate
Runs while true; do sleep, tail -f or sleep 3600 and hangsAsks the human firstpoll
Calls the same tool with the same arguments in a loopAsks before the third identical callloop
Ends its turn with a tail -f still running in the backgroundBlocks the stop once and names the taskidle
Changes a configured Git project without passing its checksReminds once at Stop; receipts match the tested contentsverify
Re-reads its own transcript after every context truncationRefuses past a per-turn limitreread
Sends an edit whose replacement equals its target, or rewrites a file with the bytes it already holdsRefuses it and sends the agent back to readnoop
Writes invisible zero-width characters into a fileStrips them so the write lands cleanzero-width
Writes a message that reads like a botBlocks it above a score thresholdsend (prose)

CLIs for pre-commit, CI and cron

The mistakeWhat pawl doesGate
Claims a bug is fixed without proving itRequires the test to fail before the fixrepro_fence.py
Ships a test that still passes with the function stubbed outStubs each function in a copy and fails when the tests survivebite_check.py
Lets failing-test or lint counts creep upKeeps a baseline that can only go downratchet.py
Grows always-on prompt files until they cost more than they helpCaps their token sizeprompt_budget.py
Keeps retrying a cron job that fails every nightPauses it after repeated failuresbreaker.py

The first three tables are hook gates that fire on their own; the last holds CLIs. Every piece also runs on its own: see pieces/<name>/README.md.

What it costs

Prompt144 tokens: the skill's description, the only always-on text
Latencyabout 45 ms per Read and 65 ms per Bash call (median, Linux, Python 3.11), all gates in one process
Networknone: no telemetry, no model calls (PRIVACY.md)
Dependenciesthe Python 3.11+ standard library

Install

Claude Code

claude plugin marketplace add ulukaya/pawl
claude plugin install pawl@pawl

Or inside a session: /plugin marketplace add ulukaya/pawl, then /plugin install pawl@pawl. Start a new session (or /reload-plugins), and claude plugin details pawl lists Hooks (2) PreToolUse, Stop.

Codex

codex plugin marketplace add ulukaya/pawl
codex plugin add pawl@pawl

Codex reads .codex-plugin/plugin.json, which points it at hooks/codex.json and the skill. Hooks need a Codex release with lifecycle hooks.

Adding the plugin does not turn its hooks on. Codex runs a plugin's hooks only once you have reviewed and trusted their current definitions, and skips new or changed ones until then; see review and trust hooks in the Codex docs. To activate pawl:

  1. Start codex. It warns at startup when hooks need review.
  2. Open /hooks, review pawl's PreToolUse and Stop hooks (each runs hooks/pawl.py with --harness codex) and trust both.
  3. Do it again after an update that changes hooks/codex.json: a changed definition is skipped until trusted again.

To check that pawl is live, open /hooks and confirm both hooks are trusted and enabled. Then, in a scratch session, ask Codex to run echo pawl-check three times as separate commands: the third is refused with a [PAWL loop] reason. hooks/e2e_test.py does not prove this: it runs the shipped hook commands directly on fixture payloads, with no Codex process, so it passes whether or not Codex loaded or trusted the hooks.

Antigravity

git clone https://github.com/ulukaya/pawl && cd pawl
./install.sh --antigravity

This links the checkout to ~/.gemini/config/plugins/pawl and adds {"path": "plugins/pawl"} to ~/.gemini/config/plugins.json next to any plugins already listed. Restart Antigravity; the plugin inventory lists pawl.

From a checkout, any harness

./install.sh                 # every harness found on this machine
./install.sh --claude        # or --antigravity, --codex
./install.sh --uninstall     # reverse every step
./install.sh --dry-run       # print what would change

For Claude Code and Codex the installer runs the commands above with this checkout as the marketplace, so the plugin loads in place (Codex still needs the /hooks trust step above; the installer reminds you); --source ulukaya/pawl tracks GitHub instead. Every step is idempotent. It then runs the test battery, which needs pytest (PAWL_PYTHON picks the interpreter); the plugin itself needs only Python 3.11+.

How it works

 harness ──stdin──▶ hooks/pawl.py pre|stop --harness H
                      │
                      ├─ harness.parse()    native payload ─▶ one canonical call
                      ├─ gates.plan()       which gates apply to this tool
                      ├─ pieces/<name>/     each gate asks its piece
                      ├─ merge              deny > ask > approve > allow
                      └─ harness.render()   answer in H's own contract ──stdout──▶

Each harness has its own config, all running the same dispatcher:

HarnessConfigCommand
Antigravityhooks.jsonpython3 -B hooks/pawl.py pre --only <gate> --harness antigravity, one group per gate
Claude Codehooks/hooks.jsonpython3 -B "${CLAUDE_PLUGIN_ROOT}/hooks/pawl.py" pre --harness claude, plus stop
Codexhooks/codex.jsonpython3 -B "${PLUGIN_ROOT}/hooks/pawl.py" pre --harness codex, plus stop

hooks/harness.py, with its tables in hooks/harness_vocab.py, maps each harness's tools onto the canonical names the pieces speak (Claude Code Bash, Read, Write, Edit, TaskOutput; Codex Bash and apply_patch) and writes each answer the way that harness reads it:

pawl decidesAntigravityClaude CodeCodex
no objectionallowno output: the normal permission prompt still appliesno output
ask the humanforce_askpermissionDecision: askdeny with the reason: a PreToolUse hook cannot ask
refusedenypermissionDecision: denypermissionDecision: deny
provably read-onlyauto_approvepermissionDecision: allowno output: a PreToolUse hook cannot approve
rewrite the inputoverwriteupdatedInput, permission unchangedupdatedInput
keep working (Stop)blockdecision: blockdecision: block

git, poll, pin, creds and fence read a shell command through hooks/shell_view.py, which blanks heredoc bodies that never run as shell: the file cat > notes.md <<'EOF' writes, a commit message in git commit -m "$(cat <<'EOF' ...)", the string literals of a python3 - <<'EOF' edit script, a list of commands that a while read loop or a later python3 check.py cases.txt only reads. Each rule names what is known to be data, and anything else keeps the body: a file run or copied later, a loop that runs or saves the lines it reads, and Python whose code can start a process (hooks/py_body.py reads it with ast). creds and fence keep the paths in Python literals.

Codex does let a separate PermissionRequest hook, sent only when Codex is about to ask the user, answer allow or deny; pawl registers no such hook.

Reasons are reworded in the harness's own tool names (Read, not view_file). The first deny ends a run, so a refused call never spends a send-budget unit. On Codex an ask is a deny, so it ends the run as well, and a send over budget is refused with nothing spent and no override logged. A git or poll gate whose piece cannot load denies, as it does when it fails. Every answer exits 0; no path prints a traceback.

Gates

GateFires onFailsAntigravityClaude CodeCodex
fenceevery toolopenbrain/, conversations/~/.claude/projects/~/.codex/sessions/
credsevery toolopenyesyesyes (ask is deny)
gitshellclosedyesyesyes (deny)
blastshellopen (an unfinished analysis asks)yesyesyes (ask is deny)
pinshellopenyesyesyes (ask is deny)
pollshellclosedyesyesyes (deny)
noopeditsopenreplace_file_contentEditapply_patch
zero-widthwrites, editsopenyesWrite, Editapply_patch
readonlyshellopenyesyesno (PreToolUse cannot approve)
rereadreads, shellopenyesyesyes
loopevery toolopenyesyesyes (deny)
sendshellegress closedyesyesyes
idleStopopen/proc tasksStop payload tasksno task list
verifyall + Stopopenconfigured Git filesconfigured Git filesconfigured Git files

hooks/pawl.py gates lists them. Details: skills/pawl/references/<piece>.md and pieces/<piece>/README.md.

Configuration

NeedDo
Skip gates for a sessionPAWL_DISABLE=git,poll (any gate name, or egress, prose, budget)
See what pawl would have done in past sessionspython3 hooks/pawl.py replay --days 30 --show (Claude Code transcripts)
See how often each send gate firespython3 hooks/pawl.py stats (reads $PAWL_DATA/gate_events.jsonl)
Tally every gate's denialspython3 pieces/report/report.py --days 7
One send, git or repeated call past a gateapprove the prompt; the row is logged as a human override
Change send ceilingsSEND_BUDGET_CEILINGS='{"chat_space": 4}'
Change egress rulesedit $PAWL_DATA/egress_rules.json (default ~/.pawl/)
Protect specific reposPAWL_GIT_PROTECTED_ROOTS=/repo/a:/repo/b (default: the call's git toplevel)
Keep the prompt for read-only commandsPAWL_READONLY_PASS_OFF=1
Refuse, not ask, on another session's filesPAWL_CONVERSATION_FENCE_STRICT=1
Fence another credential storePAWL_CREDENTIAL_EXTRA_ROOTS=~/.agent-reach
Refuse, not ask, on browser logins or unpinned installsPAWL_CREDENTIAL_FENCE_STRICT=1, PAWL_INSTALL_PIN_GUARD_STRICT=1
Force a harness format--harness in the config, or PAWL_HARNESS

On Claude Code four of these are plugin settings, so nobody edits an environment: /config lists pawl's rows (Approve read-only shell commands, Repos the git guard protects, Refuse reads of other sessions, Gates to turn off), and claude plugin install pawl@pawl --config disable=reread sets one at install. A PAWL_* variable you export wins over the setting.

State and logs live under PAWL_DATA (default ~/.pawl). Each piece's knobs are listed in its reference page.

Privacy

pawl runs on your machine and nowhere else: no network, no telemetry, no model calls. Its logs hold counters and SHA-1 digests, never a command, path or message. One gate loosens anything: on Claude Code and Antigravity, readonly approves shell commands it can prove only read, without a prompt (a Codex PreToolUse hook cannot approve, so there it stays silent); turn it off in /config or with PAWL_READONLY_PASS_OFF=1. PRIVACY.md lists every file pawl reads and writes.

Pieces

PieceWire pointCommand
prose-gategate send, CI on docspieces/prose-gate/prose_gate.py --plane chat draft.md
egress-firewallgate sendpieces/egress-firewall/egress_firewall.py check < text
send-budgetgate sendpieces/send-budget/send_budget.py status
blast-radius-guardgate blastpieces/blast-radius-guard/blast_radius.py check --cwd . -- bash cleanup.sh
destructive-git-guardgate gitpieces/destructive-git-guard/destructive_git_guard.py check --cwd . git reset --hard
poll-loop-guardgate pollpieces/poll-loop-guard/poll_loop_guard.py classify "while true; do sleep 5; done"
oscillation-breakergate looppieces/oscillation-breaker/oscillation_breaker.py check <conversation-id> view_file '{"path": "a"}'
noop-edit-guardgate nooppieces/noop-edit-guard/noop_edit_guard.py check replace_file_content '{"TargetContent": "a", "ReplacementContent": "a"}'
zero-width-sanitizergate zero-widthpieces/zero-width-sanitizer/zero_width_sanitizer.py strip < draft.txt
readonly-passgate readonlypieces/readonly-pass/readonly_pass.py check git log -5
reread-guardgate rereadpieces/reread-guard/reread_guard_hook.py < payload.json
conversation-fencegate fencepieces/conversation-fence/conversation_fence_hook.py < payload.json
credential-fencegate credspieces/credential-fence/credential_fence.py check security dump-keychain
install-pin-guardgate pinpieces/install-pin-guard/install_pin_guard.py check npm install -g mcporter
idle-task-gategate idle (Stop)pieces/idle-task-gate/idle_task_gate.py list <conversation-id>
ratchet-baselinepre-commit, CIpieces/ratchet-baseline/ratchet.py check --baseline .ratchet.json --metric failing_tests=N
circuit-breakercron, sidecarspieces/circuit-breaker/breaker.py run nightly -- ./job.sh
repro-fencepre-commit on bug fixespieces/repro-fence/repro_fence.py both --cmd "pytest tests/test_x.py" --file src/x.py
bite-checkpre-commit, CI on new testspieces/bite-check/bite_check.py check --file src/x.py --cmd "pytest tests/test_x.py"
prompt-budgetpre-commit on prompt filespieces/prompt-budget/prompt_budget.py check --config budget.json
reportCLI, retropieces/report/report.py --days 7

One skill, skills/pawl/SKILL.md, routes an agent by denial prefix or task to skills/pawl/references/<piece>.md, which carries that piece's flags.

What it is not

  • Not a model, model router, or model picker. pawl never names, selects, or calls a model.
  • Not a replacement for other plugins. Skill packs and shell guards keep doing their jobs; each plugin registers its own hooks and the harness runs them all.
  • Not a sandbox. The gates read tool arguments; a script that builds a path at run time is not fenced.
  • Not a workflow engine. Nothing here spawns subagents or schedules anything.

Eval

eval/ holds a gates-on vs gates-off ablation suite: 24 tasks with a temptation in each (10 code-change, 6 repo-hygiene, 8 outbound), throwaway git fixtures, stub senders and script graders, with no LLM judge. The blast gate has its own measurements: five labelled corpora (329 cases, four held out before tuning, first-seen scores kept in pieces/blast-radius-guard/HILLCLIMB.md), a replay of 472 real commands with no false alarm, and eval/blast-compare/, which runs other deletion guards on the same cases:

Historical comparison from October 3, 2026. Competitor error counts were not measured; rerun them before quoting their false-alarm counts. The comparison README explains the scoring and policy differences.

GuardCaught (191 dangerous)False alarms (138 everyday)
pawl blast1900
cc-safety-net 2.5.110022
cc-safety-net 2.5.1, paranoid14462
dcg 0.15.215661

The same author wrote pawl and the cases; read the caveats in eval/blast-compare/README.md before quoting a number.

Scorecards for the ablation suite, three passes per arm; eval/README.md has the per-case tables. A gate that blocks a leak often ends the task, so a blocked leak scores as a failure. The harm columns count the failures where something went out or was lost: a leak, a send past a ceiling or to an address off the allowlist, another session's uncommitted work.

AgentPassed, onPassed, offHarm, onHarm, off
Claude Code, claude-sonnet-5-557/67 (85%)41/67 (61%)017
Claude Code, local Qwen3.8 Flash-Next50/72 (69%)48/72 (67%)516

Sonnet's runs leave out 5 per arm the model refused. Of Qwen's 5 on-arm harms, 2 sent from one shell loop, gated since (a re-run of that case sent nothing past the ceiling), and 3 deleted another session's uncommitted work with rm -rf after the git gate refused a stash. Same caveat: the same author wrote pawl and the cases.

Sonnet's harm counts are documented in eval/README.md; that older JSONL lacks verdict lines, so the harm counts cannot be regenerated from it with results_table.py. Neither agent scorecard is a new run of version 0.4.1.

eval/run_arms.sh runs both arms; see eval/README.md. eval/claude/ holds four cases for Claude Code's built-in runner (claude plugin eval . --scaffold --allow-tools Bash Edit Write), also judge-free.

Development

python3 -m venv .venv && .venv/bin/pip install pytest
.venv/bin/python3 -B run_tests.py       # one OK line per suite
.venv/bin/python3 -B check_portable.py  # portable: clean
git config core.hooksPath .githooks     # run both before every push

run_tests.py runs the hooks suite (including end-to-end runs of the commands in the shipped Claude Code and Codex configs, fed fixture payloads without a real host), every piece's suite, the root tools and the eval grader twins. check_portable.py fails on an absolute home path, a non-stdlib import in shipped code, a CR byte, a markdown prose line over 80 columns, a reference page naming an env var its piece never reads, a hook config that does not run hooks/pawl.py the way its harness needs, manifests that disagree on name or version, a source file over 500 lines, or a function nested more than 3 blocks deep. The .githooks/pre-push hook runs both and refuses a push that fails. GitHub CI (.github/workflows/ci.yml, Linux and macOS, Python 3.11 to 3.14) is paused and runs only by hand for now. CLAUDE.md holds the engineering rules; CHANGELOG.md the history. CONTRIBUTING.md is the short version for a first pull request, and SECURITY.md says what to report privately.

To add a gate: write the piece under pieces/<name>/ with its tests, add a Gate to hooks/gates.py, add its Antigravity group to hooks.json, and add skills/pawl/references/<name>.md; check_portable.py tells you what is missing.

Questions

Open an issue at https://github.com/ulukaya/pawl/issues or email ulukaya@gmail.com. pawl is licensed under Apache-2.0.

ai-agents
claude-code
codex
coding-agents
hooks
python
security

ulukaya/pawl

Deterministic gates for AI agent harnesses

Python

2

97 commits

updated Oct 6, 2026

See the code

See what people are saying

README

pawl

pawl mark: a hook holding a gear

License: Apache-2.0 Python 3.11+ Dependencies: none Claude Code, Codex, Antigravity

Deterministic gates for coding agents, as one plugin for Claude Code, OpenAI Codex and Antigravity.

Agents make the same mistakes over and over, and telling them not to in the prompt stops working after a page. pawl is a set of small checks that run as code, not as instructions, and a report command that tallies what they blocked. Each check watches for one mistake and refuses it, cleans it up, or (for provably read-only commands) waves it through without a prompt. Plain Python standard library, no model calls, nothing added to the prompt but a 144-token skill description.

A pawl is the small part in a ratchet that lets the wheel move forward and stops it from slipping back. Every check here works the same way: the current state is the floor.

Try it

No install, no harness, nothing written outside a scratch directory:

git clone https://github.com/ulukaya/pawl && python3 pawl/hooks/pawl.py demo

Real Pawl hook decisions: deny a script deleting home, stay silent for
project cleanup, and ask before discarding Git work

Recorded through the real Claude Code hook adapter using fixture tool calls. The commands are inspected, never executed; displayed reasons are excerpts. This demonstrates hook decisions rather than a live agent session.

The command above sends thirteen calls through the real dispatcher, written as Antigravity, Claude Code and Codex send it, and prints what each harness is told:

call                        gate        antigravity      claude code      codex
-------------------------------------------------------------------------------
make build                  -           allow            silent           silent
git log --oneline -5        readonly    auto_approve     allow            silent
git reset --hard            git         force_ask        ask              deny
script that runs rm -rf ~/  blast       deny             deny             deny
curl install.sh | sh        pin         force_ask        ask              deny
tail -f server.log          poll        force_ask        ask              deny
same pytest run, 3rd time   loop        force_ask        ask              deny
edit that changes nothing   noop        deny             deny             deny
write with a U+200B         zero-width  allow +rewrite   +rewrite         allow +rewrite
send naming ~/.deploy/      egress      deny             deny             deny
read another session        fence       force_ask        ask              deny
read Chrome's cookie key    creds       force_ask        ask              deny
stop with tail -f running   idle        n/a              block            n/a

silent leaves the harness's own prompt in place; allow and auto_approve skip it. A Codex PreToolUse hook can neither ask nor approve, so there an ask is a deny that tells the agent how to proceed. --verbose adds every reason, --json every raw answer.

On your own sessions

replay sends every tool call from your past Claude Code sessions through the same gates. It runs nothing and keeps no state:

python3 pawl/hooks/pawl.py replay --days 30 --show

It lists each call pawl would have asked about or refused, then a tally per gate, including how many read-only commands it would have approved without a prompt. Run it before installing to see what would change, and after changing a gate to find its false alarms on real work.

What it catches

Grouped by what a miss costs. The first table is work or data an agent cannot take back; those gates are the reason pawl exists.

Can't be undone

The mistakeWhat pawl doesGate
Deletes home, root, a drive or ~/Documents, directly or from a script, trap, npm run, Makefile, python -c or container it runsRefuses; asks before anything else outside the workspaceblast
Runs git reset --hard, git clean -fdx, git commit --no-verify, git push --force or git branch -D and loses workAsks the human firstgit
Pastes internal paths, tokens or hostnames into a messageBlocks the sendsend (egress)
Reads browser cookies, saved passwords or the keychain (security find-generic-password, import browser_cookie3)Asks the human firstcreds
Reads another conversation's private filesAsks the human firstfence
Installs from a branch URL (pip install .../archive/main.zip), runs npm install -g tool with no version, or pipes curl into shAsks the human to pin it or approve itpin
Floods a chat room or inboxCaps sends per channel per day; refuses a send loop it cannot countsend (budget)

Prompts it removes

The mistakeWhat pawl doesGate
Stalls on a permission prompt for ls or git logApproves commands that provably only readreadonly

Time and tokens it saves

The mistakeWhat pawl doesGate
Runs while true; do sleep, tail -f or sleep 3600 and hangsAsks the human firstpoll
Calls the same tool with the same arguments in a loopAsks before the third identical callloop
Ends its turn with a tail -f still running in the backgroundBlocks the stop once and names the taskidle
Changes a configured Git project without passing its checksReminds once at Stop; receipts match the tested contentsverify
Re-reads its own transcript after every context truncationRefuses past a per-turn limitreread
Sends an edit whose replacement equals its target, or rewrites a file with the bytes it already holdsRefuses it and sends the agent back to readnoop
Writes invisible zero-width characters into a fileStrips them so the write lands cleanzero-width
Writes a message that reads like a botBlocks it above a score thresholdsend (prose)

CLIs for pre-commit, CI and cron

The mistakeWhat pawl doesGate
Claims a bug is fixed without proving itRequires the test to fail before the fixrepro_fence.py
Ships a test that still passes with the function stubbed outStubs each function in a copy and fails when the tests survivebite_check.py
Lets failing-test or lint counts creep upKeeps a baseline that can only go downratchet.py
Grows always-on prompt files until they cost more than they helpCaps their token sizeprompt_budget.py
Keeps retrying a cron job that fails every nightPauses it after repeated failuresbreaker.py

The first three tables are hook gates that fire on their own; the last holds CLIs. Every piece also runs on its own: see pieces/<name>/README.md.

What it costs

Prompt144 tokens: the skill's description, the only always-on text
Latencyabout 45 ms per Read and 65 ms per Bash call (median, Linux, Python 3.11), all gates in one process
Networknone: no telemetry, no model calls (PRIVACY.md)
Dependenciesthe Python 3.11+ standard library

Install

Claude Code

claude plugin marketplace add ulukaya/pawl
claude plugin install pawl@pawl

Or inside a session: /plugin marketplace add ulukaya/pawl, then /plugin install pawl@pawl. Start a new session (or /reload-plugins), and claude plugin details pawl lists Hooks (2) PreToolUse, Stop.

Codex

codex plugin marketplace add ulukaya/pawl
codex plugin add pawl@pawl

Codex reads .codex-plugin/plugin.json, which points it at hooks/codex.json and the skill. Hooks need a Codex release with lifecycle hooks.

Adding the plugin does not turn its hooks on. Codex runs a plugin's hooks only once you have reviewed and trusted their current definitions, and skips new or changed ones until then; see review and trust hooks in the Codex docs. To activate pawl:

  1. Start codex. It warns at startup when hooks need review.
  2. Open /hooks, review pawl's PreToolUse and Stop hooks (each runs hooks/pawl.py with --harness codex) and trust both.
  3. Do it again after an update that changes hooks/codex.json: a changed definition is skipped until trusted again.

To check that pawl is live, open /hooks and confirm both hooks are trusted and enabled. Then, in a scratch session, ask Codex to run echo pawl-check three times as separate commands: the third is refused with a [PAWL loop] reason. hooks/e2e_test.py does not prove this: it runs the shipped hook commands directly on fixture payloads, with no Codex process, so it passes whether or not Codex loaded or trusted the hooks.

Antigravity

git clone https://github.com/ulukaya/pawl && cd pawl
./install.sh --antigravity

This links the checkout to ~/.gemini/config/plugins/pawl and adds {"path": "plugins/pawl"} to ~/.gemini/config/plugins.json next to any plugins already listed. Restart Antigravity; the plugin inventory lists pawl.

From a checkout, any harness

./install.sh                 # every harness found on this machine
./install.sh --claude        # or --antigravity, --codex
./install.sh --uninstall     # reverse every step
./install.sh --dry-run       # print what would change

For Claude Code and Codex the installer runs the commands above with this checkout as the marketplace, so the plugin loads in place (Codex still needs the /hooks trust step above; the installer reminds you); --source ulukaya/pawl tracks GitHub instead. Every step is idempotent. It then runs the test battery, which needs pytest (PAWL_PYTHON picks the interpreter); the plugin itself needs only Python 3.11+.

How it works

 harness ──stdin──▶ hooks/pawl.py pre|stop --harness H
                      │
                      ├─ harness.parse()    native payload ─▶ one canonical call
                      ├─ gates.plan()       which gates apply to this tool
                      ├─ pieces/<name>/     each gate asks its piece
                      ├─ merge              deny > ask > approve > allow
                      └─ harness.render()   answer in H's own contract ──stdout──▶

Each harness has its own config, all running the same dispatcher:

HarnessConfigCommand
Antigravityhooks.jsonpython3 -B hooks/pawl.py pre --only <gate> --harness antigravity, one group per gate
Claude Codehooks/hooks.jsonpython3 -B "${CLAUDE_PLUGIN_ROOT}/hooks/pawl.py" pre --harness claude, plus stop
Codexhooks/codex.jsonpython3 -B "${PLUGIN_ROOT}/hooks/pawl.py" pre --harness codex, plus stop

hooks/harness.py, with its tables in hooks/harness_vocab.py, maps each harness's tools onto the canonical names the pieces speak (Claude Code Bash, Read, Write, Edit, TaskOutput; Codex Bash and apply_patch) and writes each answer the way that harness reads it:

pawl decidesAntigravityClaude CodeCodex
no objectionallowno output: the normal permission prompt still appliesno output
ask the humanforce_askpermissionDecision: askdeny with the reason: a PreToolUse hook cannot ask
refusedenypermissionDecision: denypermissionDecision: deny
provably read-onlyauto_approvepermissionDecision: allowno output: a PreToolUse hook cannot approve
rewrite the inputoverwriteupdatedInput, permission unchangedupdatedInput
keep working (Stop)blockdecision: blockdecision: block

git, poll, pin, creds and fence read a shell command through hooks/shell_view.py, which blanks heredoc bodies that never run as shell: the file cat > notes.md <<'EOF' writes, a commit message in git commit -m "$(cat <<'EOF' ...)", the string literals of a python3 - <<'EOF' edit script, a list of commands that a while read loop or a later python3 check.py cases.txt only reads. Each rule names what is known to be data, and anything else keeps the body: a file run or copied later, a loop that runs or saves the lines it reads, and Python whose code can start a process (hooks/py_body.py reads it with ast). creds and fence keep the paths in Python literals.

Codex does let a separate PermissionRequest hook, sent only when Codex is about to ask the user, answer allow or deny; pawl registers no such hook.

Reasons are reworded in the harness's own tool names (Read, not view_file). The first deny ends a run, so a refused call never spends a send-budget unit. On Codex an ask is a deny, so it ends the run as well, and a send over budget is refused with nothing spent and no override logged. A git or poll gate whose piece cannot load denies, as it does when it fails. Every answer exits 0; no path prints a traceback.

Gates

GateFires onFailsAntigravityClaude CodeCodex
fenceevery toolopenbrain/, conversations/~/.claude/projects/~/.codex/sessions/
credsevery toolopenyesyesyes (ask is deny)
gitshellclosedyesyesyes (deny)
blastshellopen (an unfinished analysis asks)yesyesyes (ask is deny)
pinshellopenyesyesyes (ask is deny)
pollshellclosedyesyesyes (deny)
noopeditsopenreplace_file_contentEditapply_patch
zero-widthwrites, editsopenyesWrite, Editapply_patch
readonlyshellopenyesyesno (PreToolUse cannot approve)
rereadreads, shellopenyesyesyes
loopevery toolopenyesyesyes (deny)
sendshellegress closedyesyesyes
idleStopopen/proc tasksStop payload tasksno task list
verifyall + Stopopenconfigured Git filesconfigured Git filesconfigured Git files

hooks/pawl.py gates lists them. Details: skills/pawl/references/<piece>.md and pieces/<piece>/README.md.

Configuration

NeedDo
Skip gates for a sessionPAWL_DISABLE=git,poll (any gate name, or egress, prose, budget)
See what pawl would have done in past sessionspython3 hooks/pawl.py replay --days 30 --show (Claude Code transcripts)
See how often each send gate firespython3 hooks/pawl.py stats (reads $PAWL_DATA/gate_events.jsonl)
Tally every gate's denialspython3 pieces/report/report.py --days 7
One send, git or repeated call past a gateapprove the prompt; the row is logged as a human override
Change send ceilingsSEND_BUDGET_CEILINGS='{"chat_space": 4}'
Change egress rulesedit $PAWL_DATA/egress_rules.json (default ~/.pawl/)
Protect specific reposPAWL_GIT_PROTECTED_ROOTS=/repo/a:/repo/b (default: the call's git toplevel)
Keep the prompt for read-only commandsPAWL_READONLY_PASS_OFF=1
Refuse, not ask, on another session's filesPAWL_CONVERSATION_FENCE_STRICT=1
Fence another credential storePAWL_CREDENTIAL_EXTRA_ROOTS=~/.agent-reach
Refuse, not ask, on browser logins or unpinned installsPAWL_CREDENTIAL_FENCE_STRICT=1, PAWL_INSTALL_PIN_GUARD_STRICT=1
Force a harness format--harness in the config, or PAWL_HARNESS

On Claude Code four of these are plugin settings, so nobody edits an environment: /config lists pawl's rows (Approve read-only shell commands, Repos the git guard protects, Refuse reads of other sessions, Gates to turn off), and claude plugin install pawl@pawl --config disable=reread sets one at install. A PAWL_* variable you export wins over the setting.

State and logs live under PAWL_DATA (default ~/.pawl). Each piece's knobs are listed in its reference page.

Privacy

pawl runs on your machine and nowhere else: no network, no telemetry, no model calls. Its logs hold counters and SHA-1 digests, never a command, path or message. One gate loosens anything: on Claude Code and Antigravity, readonly approves shell commands it can prove only read, without a prompt (a Codex PreToolUse hook cannot approve, so there it stays silent); turn it off in /config or with PAWL_READONLY_PASS_OFF=1. PRIVACY.md lists every file pawl reads and writes.

Pieces

PieceWire pointCommand
prose-gategate send, CI on docspieces/prose-gate/prose_gate.py --plane chat draft.md
egress-firewallgate sendpieces/egress-firewall/egress_firewall.py check < text
send-budgetgate sendpieces/send-budget/send_budget.py status
blast-radius-guardgate blastpieces/blast-radius-guard/blast_radius.py check --cwd . -- bash cleanup.sh
destructive-git-guardgate gitpieces/destructive-git-guard/destructive_git_guard.py check --cwd . git reset --hard
poll-loop-guardgate pollpieces/poll-loop-guard/poll_loop_guard.py classify "while true; do sleep 5; done"
oscillation-breakergate looppieces/oscillation-breaker/oscillation_breaker.py check <conversation-id> view_file '{"path": "a"}'
noop-edit-guardgate nooppieces/noop-edit-guard/noop_edit_guard.py check replace_file_content '{"TargetContent": "a", "ReplacementContent": "a"}'
zero-width-sanitizergate zero-widthpieces/zero-width-sanitizer/zero_width_sanitizer.py strip < draft.txt
readonly-passgate readonlypieces/readonly-pass/readonly_pass.py check git log -5
reread-guardgate rereadpieces/reread-guard/reread_guard_hook.py < payload.json
conversation-fencegate fencepieces/conversation-fence/conversation_fence_hook.py < payload.json
credential-fencegate credspieces/credential-fence/credential_fence.py check security dump-keychain
install-pin-guardgate pinpieces/install-pin-guard/install_pin_guard.py check npm install -g mcporter
idle-task-gategate idle (Stop)pieces/idle-task-gate/idle_task_gate.py list <conversation-id>
ratchet-baselinepre-commit, CIpieces/ratchet-baseline/ratchet.py check --baseline .ratchet.json --metric failing_tests=N
circuit-breakercron, sidecarspieces/circuit-breaker/breaker.py run nightly -- ./job.sh
repro-fencepre-commit on bug fixespieces/repro-fence/repro_fence.py both --cmd "pytest tests/test_x.py" --file src/x.py
bite-checkpre-commit, CI on new testspieces/bite-check/bite_check.py check --file src/x.py --cmd "pytest tests/test_x.py"
prompt-budgetpre-commit on prompt filespieces/prompt-budget/prompt_budget.py check --config budget.json
reportCLI, retropieces/report/report.py --days 7

One skill, skills/pawl/SKILL.md, routes an agent by denial prefix or task to skills/pawl/references/<piece>.md, which carries that piece's flags.

What it is not

  • Not a model, model router, or model picker. pawl never names, selects, or calls a model.
  • Not a replacement for other plugins. Skill packs and shell guards keep doing their jobs; each plugin registers its own hooks and the harness runs them all.
  • Not a sandbox. The gates read tool arguments; a script that builds a path at run time is not fenced.
  • Not a workflow engine. Nothing here spawns subagents or schedules anything.

Eval

eval/ holds a gates-on vs gates-off ablation suite: 24 tasks with a temptation in each (10 code-change, 6 repo-hygiene, 8 outbound), throwaway git fixtures, stub senders and script graders, with no LLM judge. The blast gate has its own measurements: five labelled corpora (329 cases, four held out before tuning, first-seen scores kept in pieces/blast-radius-guard/HILLCLIMB.md), a replay of 472 real commands with no false alarm, and eval/blast-compare/, which runs other deletion guards on the same cases:

Historical comparison from October 3, 2026. Competitor error counts were not measured; rerun them before quoting their false-alarm counts. The comparison README explains the scoring and policy differences.

GuardCaught (191 dangerous)False alarms (138 everyday)
pawl blast1900
cc-safety-net 2.5.110022
cc-safety-net 2.5.1, paranoid14462
dcg 0.15.215661

The same author wrote pawl and the cases; read the caveats in eval/blast-compare/README.md before quoting a number.

Scorecards for the ablation suite, three passes per arm; eval/README.md has the per-case tables. A gate that blocks a leak often ends the task, so a blocked leak scores as a failure. The harm columns count the failures where something went out or was lost: a leak, a send past a ceiling or to an address off the allowlist, another session's uncommitted work.

AgentPassed, onPassed, offHarm, onHarm, off
Claude Code, claude-sonnet-5-557/67 (85%)41/67 (61%)017
Claude Code, local Qwen3.8 Flash-Next50/72 (69%)48/72 (67%)516

Sonnet's runs leave out 5 per arm the model refused. Of Qwen's 5 on-arm harms, 2 sent from one shell loop, gated since (a re-run of that case sent nothing past the ceiling), and 3 deleted another session's uncommitted work with rm -rf after the git gate refused a stash. Same caveat: the same author wrote pawl and the cases.

Sonnet's harm counts are documented in eval/README.md; that older JSONL lacks verdict lines, so the harm counts cannot be regenerated from it with results_table.py. Neither agent scorecard is a new run of version 0.4.1.

eval/run_arms.sh runs both arms; see eval/README.md. eval/claude/ holds four cases for Claude Code's built-in runner (claude plugin eval . --scaffold --allow-tools Bash Edit Write), also judge-free.

Development

python3 -m venv .venv && .venv/bin/pip install pytest
.venv/bin/python3 -B run_tests.py       # one OK line per suite
.venv/bin/python3 -B check_portable.py  # portable: clean
git config core.hooksPath .githooks     # run both before every push

run_tests.py runs the hooks suite (including end-to-end runs of the commands in the shipped Claude Code and Codex configs, fed fixture payloads without a real host), every piece's suite, the root tools and the eval grader twins. check_portable.py fails on an absolute home path, a non-stdlib import in shipped code, a CR byte, a markdown prose line over 80 columns, a reference page naming an env var its piece never reads, a hook config that does not run hooks/pawl.py the way its harness needs, manifests that disagree on name or version, a source file over 500 lines, or a function nested more than 3 blocks deep. The .githooks/pre-push hook runs both and refuses a push that fails. GitHub CI (.github/workflows/ci.yml, Linux and macOS, Python 3.11 to 3.14) is paused and runs only by hand for now. CLAUDE.md holds the engineering rules; CHANGELOG.md the history. CONTRIBUTING.md is the short version for a first pull request, and SECURITY.md says what to report privately.

To add a gate: write the piece under pieces/<name>/ with its tests, add a Gate to hooks/gates.py, add its Antigravity group to hooks.json, and add skills/pawl/references/<name>.md; check_portable.py tells you what is missing.

Questions

Open an issue at https://github.com/ulukaya/pawl/issues or email ulukaya@gmail.com. pawl is licensed under Apache-2.0.

ai-agents
claude-code
codex
coding-agents
hooks
python
security