[FEATURE] Headless runs should signal denied permissions at the process level (exit code or stderr), not only in permission_denials

Status Open
Reported on v2.1.251
Maintainer reply None cached
Activity 1 comment · opened Aug 29, 2026

Preflight Checklist

  • [x] I have searched existing requests and this feature hasn't been requested yet
  • [x] This is a single feature request (not multiple features)

Problem Statement

In unattended runs (claude -p from cron/launchd/CI), a denied permission does not change anything the calling process can see. The run ends with:

exit code: 0
"is_error": false
"subtype": "success"

The refusal is visible in the transcript, and — for Bash — in the permission_denials array of --output-format json. But a scheduled job that checks $?, the way every wrapper script does, sees a successful run.

We hit this in production. A scheduled maintenance agent required one interpreter that was missing from its profile's allow list. It ran 8 consecutive times, on schedule, each time waking a model, reporting in prose that it could not run the commands its task required, and exiting 0. Nothing in the process-level result differed from a working run. It went unnoticed for two days.

The cost of that class of failure is not a crash — it is paid compute producing nothing, silently, for as long as nobody reads the prose.

To be clear about what already exists: --output-format json does return permission_denials, and once we knew that, wrapping our runner to read it took ten minutes. This request is not "give us the data". It is that the default, obvious path is the silent one, and the reliable path is opt-in and easy to miss.

Two open reports suggest the JSON field alone is not sufficient either:

  • #82747 — a Read refused by a deny rule is absent from permission_denials, while a denied Bash command is recorded. A harness can see an empty array on a run where every file read was refused.
  • #80223 — a denied pre-loop shell substitution reports subtype: success, is_error: false, num_turns: 0, permission_denials: [], with the only signal an untyped string in a synthetic message.

If the array can be empty on a denied run, then a wrapper built on it inherits those gaps. A process-level signal is the layer that does not depend on every denial path remembering to populate a field.

Proposed Solution

When a run in print/headless mode ends with one or more permission requests denied, make it distinguishable at the process level. In rough order of preference:

  1. A distinct non-zero exit code for "completed, but at least one permission was denied" — a value separate from a hard failure, so callers can tell "blocked" from "crashed".
  2. If changing the exit code is considered breaking: one line on stderr, e.g. claude: 3 permission request(s) denied (see permission_denials). Cheap, non-breaking, visible to any wrapper that captures stderr.
  3. An opt-in flag such as --fail-on-permission-denied for callers who want strict behaviour without a global change.

Any one of these would have surfaced our failure on the first run instead of the eighth.

Alternatives Considered

  • Reading permission_denials ourselves. This is what we now do, and it works for the denials that reach the array. It does not help anyone who has not discovered the field, and per #82747 / #80223 it does not cover every denial path.
  • Parsing the transcript prose. Brittle and language-dependent; the agent's wording is not an API.
  • Asserting on our own side effects (did the expected file change?). Works, but it detects the symptom long after the fact and requires bespoke assertions per task.

Additional Context

Environment: Claude Code 2.1.251, macOS (Apple Silicon), scheduled claude -p runs via launchd with --permission-mode acceptEdits and a restricted --settings profile.

Minimal reproduction:

# profile with an interpreter denied
cat > /tmp/p.json <<'JSON'
{"permissions":{"defaultMode":"acceptEdits","allow":[],"deny":["Bash(node:*)"]}}
JSON

claude -p "Run: node --version" --settings /tmp/p.json \
       --permission-mode acceptEdits --output-format json > out.json
echo "exit=$?"                                   # exit=0
python3 -c "import json;d=json.load(open('out.json'));print(d['is_error'], len(d['permission_denials']))"
# False 1   ← a denial occurred, is_error is false, exit code is 0

Note the asymmetry the reproduction shows: the run knows a permission was denied and records it, yet reports success through the only channel a shell script reads by default.

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗