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 human approval for Claude Code tool calls in CI (claude -p)

In an interactive session Claude Code asks you before a risky tool call. In claude -p (print mode, the mode CI and cron use) nobody can answer. This guide shows which Claude Code settings decide what happens then, and where a human approval can come from. The first half uses only Claude Code; the second half shows how Deus Witness adds a signed approval. Checked against Claude Code 2.1.310 and the official pages Hooks, Permissions and Headless mode.

What a prompt becomes in claude -p

The headless page says that --permission-mode dontAsk makes Claude Code deny every call that would otherwise prompt, and calls it useful for locked-down CI runs. Calls covered by --allowedTools or permissions.allow still run. So the baseline for CI is simple: allow a short list, and everything else is refused instead of asked.

Without a mode, a call that would prompt is denied in a -p run because no one can answer; a hook's permissionDecisionReason is then what Claude reads in the tool result. Either way, "human approval" in CI cannot mean a terminal prompt. It has to come from outside the terminal.

Layer 1: permissions.deny

Deny rules are evaluated first: "deny, then ask, then allow. The first match in that order determines the outcome" (Permissions page). Put in .claude/settings.json:

{"permissions": {"deny": ["Bash(curl *)", "Bash(wget *)", "Bash(ssh *)", "Read(.env)", "Read(.env.*)", "Edit(.claude/**)"]}}

Limit, quoted from the same page: a Bash rule "matches the command text Claude writes ... and isn't a security boundary around the program". env curl or sh -c 'curl ...' are different text. Treat deny rules as a cheap first fence, not a wall.

Layer 2: a PreToolUse hook

A PreToolUse hook runs before the permission prompt and may return permissionDecision of allow, deny, ask or defer. The Permissions page: "PreToolUse hook decisions don't bypass permission rules." A deny rule still wins over a hook that says allow.

A minimal blocking hook (my test, run with claude -p): .claude/gate.sh prints {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Needs a human"}}, and the settings run it on Bash. Result of claude -p "Run the shell command: echo hello > out.txt" --output-format json: no file, and the JSON answer lists the call under permission_denials.

fail-open vs fail-closed (the part most setups get wrong)

The Hooks page: a hook that exits 0 means success; exit 2 blocks; "any other exit code" is a non-blocking error and "the action goes ahead". A hook whose script is missing, crashes or times out lets the call through. The page says it directly: "A policy hook with a wrong path or a crashing script then lets everything through."

Three things close that hole:

  1. Print a JSON deny yourself on every error you can catch.
  2. Run the command as your-hook || exit 2, so a missing binary becomes a block.
  3. Set "onFailure": "block" on the hook (Claude Code 2.1.295 or newer); a timeout or crash then blocks too. Older versions drop the key silently.

Also remember that claude -p runs the hooks of the project's .claude/settings.json without a trust dialog, unless you pass --bare, which skips hooks entirely (Headless page). In CI that is a choice to make on purpose.

Where the human comes from

The approval must travel: the hook holds the call, a link reaches the owner's phone, the owner approves, the call runs. What you want is for that approval to be a signature you can check later, not a chat message.

How Deus Witness does it

Deus Witness is a hook (deus-witness-hook, PyPI package deus-witness) plus a service. Each tool call goes to the witness; the witness compares it with a mandate that you signed with a passkey, and answers allow, deny or defer. Anything the mandate does not mention is held for you. You approve on a page with your passkey; the witness returns a signed receipt, and the hook checks that receipt before it prints allow. No receipt, no allow.

The setting for CI (all three fail-closed layers, and a 10 minute wait for you) is:

"command": "deus-witness-hook || exit 2", "timeout": 620, "onFailure": "block"
"env": {"DEUS_WITNESS_HOOK_TIMEOUT": "610", "DEUS_WITNESS_HOOK_WAIT": "600", "DEUS_WITNESS_HOOK_ON_DEFER": "deny"}

If you do not answer in time, the call is refused (DEUS_WITNESS_HOOK_ON_DEFER=deny) and Claude is told why. The hook never offers a terminal ask: the person at the terminal must not approve in the owner's place.

Check the install before the job starts:

pip install deus-witness
deus-witness-hook --doctor "$PWD"

With all three layers it prints OK: fail-closed (onFailure block, || exit 2, timeouts in order) and exits 0. With a matcher that leaves out Read, Grep or Glob it prints a fail-open warning and exits 1, so a CI step can stop the job. Without DEUS_WITNESS_KEY set the hook itself answers deny ... WITNESS_NOT_CONFIGURED; I ran that too.

Limits

The witness judges calls that Claude Code makes. It does not see what a script does inside; for that use an OS sandbox (see Safe way to run Claude Code unattended). If the witness cannot be reached, calls are blocked, reads included. Every decision lands in a signed log you can check offline: Audit log for AI agent actions you can verify offline. Step by step setup with your own key: Quickstart, section 9.