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 | Skip permissions | PreToolUse hook | Deny not working | Headless permissions | MCP human-in-the-loop

Claude Code headless mode (claude -p): permissions, approvals and CI

In headless mode (claude -p) nobody can answer a permission prompt. Since v2.1.302 a -p run with no permission mode set starts in Manual mode, and "Calls needing approval are denied unless you approve them with an allow rule, hook, canUseTool callback, or --permission-prompt-tool" (Permission modes page). So a headless run needs its permissions decided before it starts: a list in --allowedTools, a mode in --permission-mode, and, when nobody is around, --permission-prompts none. Refused calls are listed in permission_denials in the JSON output. Checked against Claude Code 2.1.312 and the official Headless, Permission modes, Permissions, CLI reference and GitHub Actions pages.

What happens to each call in -p

The callIn a plain claude -p run
needs no approval (reads in the working directory, built-in read-only commands)runs
matches --allowedTools or permissions.allowruns
matches a deny rulerefused
would prompt (Bash, Edit, WebFetch, MCP not on any list)refused, unless a hook, --permission-prompt-tool or an SDK callback approves it
an MCP tool marked requiresUserInteractionrefused in dontAsk; an allow from --permission-prompt-tool is turned into a deny
a connector tool your organization set to askrefused in dontAsk, even when an allow rule matches

The run itself continues: Claude gets the refusal as a tool result and works around it, or stops. Check permission_denials in --output-format json and fail the job when a call you needed was refused.

Choose a baseline

List what may run. --allowedTools uses permission rule syntax: "The trailing * enables prefix matching, so Bash(git diff *) allows any command starting with git diff. The space before * is important" (Headless page).

claude -p "Run the tests and fix the failing one" \
  --allowedTools "Read,Edit,Bash(npm test),Bash(git diff *)" \
  --permission-mode dontAsk --permission-prompts none --output-format json

Pick a mode on purpose. The Headless page lists three for -p:

--dangerously-skip-permissions is the fourth option and belongs in a container or VM only; what it does and does not skip: --dangerously-skip-permissions explained.

Say that nobody is there. --permission-prompts none (v2.1.259+) matters most when the run has a permission host such as an SDK canUseTool callback or --permission-prompt-tool; "Without the flag, your run waits for that host to answer each permission request." With it, Claude Code removes tools that need a person, such as AskUserQuestion, and cancels MCP elicitation requests that no hook answers.

Two surprises from the project folder

Project allow rules are ignored. permissions.allow in a repository's .claude/settings.json applies only after the workspace trust dialog, and "Claude Code shows the trust dialog in interactive sessions only". In -p those rules are "Not used" and Claude Code prints a this workspace has not been trusted warning to stderr. If your CI relies on a committed allow list, pass it with --allowedTools or --settings instead. Deny and ask rules apply without trust.

Project hooks and MCP servers do run. "Without --bare, a -p session runs the hooks in a project's .claude/settings.json and connects the servers in its .mcp.json, even in a folder you've never trusted." Before running claude -p on a repository you did not write, use --setting-sources user, --bare, or --settings '{"disableAllHooks": true}' (Permissions page).

What --bare skips

--bare skips "auto-discovery of hooks, skills, custom commands, subagents, installed plugins, MCP servers, auto memory, and CLAUDE.md". It does not read your OAuth login: set ANTHROPIC_API_KEY. Load what you need explicitly: --settings for settings, --mcp-config for servers, --plugin-dir for a plugin. The Headless page also says --bare "will become the default for -p in a future release".

That last line matters if a policy hook is part of your safety: a hook that is not loaded says nothing, and the run just goes on. After a run that should have been checked by a hook, confirm from the hook's own log that it ran; do not assume it.

GitHub Actions

The official action passes CLI flags through claude_args ("accepts any Claude Code CLI argument") and settings through the settings input. For a plain-text prompt, "Claude has no shell or GitHub API access until you grant the tools the prompt needs". A locked-down scheduled job:

on:
  schedule:
    - cron: "0 9 * * *"
jobs:
  report:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      issues: read
      id-token: write
    steps:
      - uses: anthropics/claude-code-action@v1
        with:
          anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
          prompt: "Summarize yesterday's commits and open issues"
          claude_args: |
            --permission-mode dontAsk
            --allowedTools "mcp__github__list_commits,mcp__github__list_issues"
            --max-turns 10

Keep the job's GitHub permissions as narrow as the task: the token, not the prompt, decides what a push or comment can do. For the OS boundary under claude -p (sandbox, egress), see Safe way to run Claude Code unattended.

When a call should wait for a person

dontAsk turns "needs approval" into "refused". For most CI work that is right. For a few actions (deploy, push to main, send, delete outside the checkout) you may want the run to wait for a specific person instead. Claude Code gives three places to plug that in; the waiting itself you build:

Deus Witness is one implementation of the first option. Its hook sends each call to the witness, which compares it with a mandate you signed with a passkey; a call outside the mandate waits for your passkey approval (phone or laptop) for up to DEUS_WITNESS_HOOK_WAIT seconds (0 by default, so it is refused at once), and is refused if you do not answer. deus-witness run -- claude -p "..." refuses to start with --bare, and after the run reports when the hook did not run at all. It decides tool calls only; keep the sandbox under it. Setup: Require approval before Claude Code runs a command unattended. If a deny rule seems not to apply in your CI, see permissions.deny not working?.