Deus Witness

Quickstart | Concepts | API | Verify | Coverage | Teams | Anchors | Recovery | Human approval in CI | Verifiable audit log | Unattended runs | Approval when unattended | Hooks vs deny

How to require approval before Claude Code runs a command unattended (claude -p, GitHub Actions)

You want Claude Code to run unattended, but a few commands (push, deploy, delete, anything with a secret) should wait for a person. In claude -p there is no terminal prompt to answer, so the question is where "approval" can live. This guide separates what Claude Code does by itself from what you have to add. Checked against Claude Code 2.1.310 and the official pages Permissions, Hooks and Headless mode.

1. What Claude Code does by itself

Permission rules are evaluated "deny, then ask, then allow", and the first match decides (Permissions page). A command that would ask gets nobody to answer in print mode: "In a -p run with no host, these requests are denied either way" (Headless page). --permission-mode dontAsk makes that the stated baseline: it "auto-denies every call that would otherwise prompt", and calls covered by --allowedTools or permissions.allow still run (Permissions page; the same sentence adds that an installed mod "can still approve some of those calls"):

claude -p "Run the tests and write SUMMARY.md" \
  --permission-mode dontAsk \
  --allowedTools "Read,Edit,Bash(npm test)" \
  --output-format json

That is a refusal, not an approval. The JSON result lists the refused calls under permission_denials, and claude -p still exits 0 (our run on 2.1.310: exit 0, "subtype": "success", one Write in permission_denials), so count them in a CI step if a refusal should fail the job.

2. Approval has to come from outside the terminal

Three ways, from simple to strong:

A hook that waits has to be fail-closed. The Hooks page: exit 0 is success, exit 2 blocks, any other exit code is a non-blocking error and "the action goes ahead". A missing script or a crash lets the call through. Close it with your-hook || exit 2 and, on Claude Code 2.1.295 or newer, "onFailure": "block" (Hooks page: "Requires Claude Code v2.1.295 or later"; in our check of the published 2.1.294 binary the key does not exist, so an older version ignores it without an error). Set the hook timeout longer than your wait.

Two facts that matter in CI: claude -p runs the project's hooks from .claude/settings.json without a trust dialog, and --bare skips hooks entirely (Headless page). Do not use --bare if your approval is a hook, and re-check after upgrades: the Headless page says --bare "will become the default for -p in a future release". Also keep a deny list for the ways out, because "PreToolUse hook decisions don't bypass permission rules" (Permissions page): an allow from your hook does not beat a matching deny rule. One exception on the same page: with neither managed settings nor a Team or Enterprise /login, an installed mod that handles tool.check "can approve a call that a deny rule refuses or a PreToolUse hook blocked". Install no mods on a CI runner.

3. A signed approval with Deus Witness

Deus Witness is such a hook (deus-witness-hook, PyPI package deus-witness) plus a service. Each tool call goes to the witness. If your signed mandate covers it, the call is allowed; if not, it is held and a link goes to the owner by Telegram, Slack or e-mail. The owner approves with a passkey, the witness returns a signed receipt, and the hook checks that receipt before it prints allow. No receipt, no allow (unless you switch the check off with DEUS_WITNESS_VERIFY=0, which we do not recommend).

The setting for CI, with a 10 minute wait:

"command": "deus-witness-hook || exit 2", "timeout": 620, "onFailure": "block"
"env": {"DEUS_WITNESS_KEY": "<from a CI secret>", "DEUS_WITNESS_HOOK_TIMEOUT": "610", "DEUS_WITNESS_HOOK_WAIT": "600", "DEUS_WITNESS_HOOK_ON_DEFER": "deny"}

Check the install before the job starts, and fail the job if it is not fail-closed:

pip install deus-witness
deus-witness-hook --doctor "$PWD" | tee doctor.txt
grep -q "OK: fail-closed" doctor.txt

Then start Claude through deus-witness run (client 0.2.41 or newer), so that a run without the hook fails instead of passing unchecked:

deus-witness run -- claude -p "your task"

It refuses --bare, --safe-mode, CLAUDE_CODE_SIMPLE and CLAUDE_CODE_SAFE_MODE before the run (exit 2), and after the run exits non-zero with "Witness hook did not run in this session" when no Witness hook ran in it (exit 3 when Claude itself succeeded). That covers the day -p becomes bare by default: the job turns red instead of silently running without approval. It shows that the hook ran, not that each call was approved (the signed log shows that), and it is a guard against a silent switch-off, not against an agent that fakes the mark. Without pip: uvx --from deus-witness deus-witness run -- claude -p "your task".

If nobody answers within the wait, the call is refused and Claude is told why; the job is not retried for you. With DEUS_WITNESS_HOOK_ON_DEFER=deny (the default) the hook never offers a terminal ask, so the person at the terminal cannot approve in the owner's place. Setting it to ask turns a held call into a terminal yes/no, and yes runs the call without a receipt; do not set it in CI.

Limits