[BUG] Bash(cmd *) allowlist silently bypassed for commands interpolating user env vars; uninformative denial

Resolved 💬 2 comments Opened Apr 17, 2026 by akolu Closed May 25, 2026

Preflight

  • [x] Searched existing issues (#45469 is adjacent — docs request for leading env-var prefixes — but does not cover this case)
  • [x] Single bug
  • [x] On CC 2.1.112

What's Wrong?

When Claude Code runs in --permission-mode dontAsk, Bash commands that interpolate non-standard environment variables (anything besides $HOME, $PWD, $PATH, $USER, etc.) are denied — even when the command prefix is allowlisted in settings.json.

The denial message is generic:

"Permission to use Bash has been denied because Claude Code is running in don't ask mode. [...]"

It's identical to a "no rule matched" response, so the model has no way to recognize the env-var reference as the cause and try a different form.

What Should Happen?

One of:

  1. The command should be allowed, since the prefix (Bash(curl *)) matches.
  2. Or, if env-var interpolation is deliberately restricted, the denial should say so explicitly — something like: "Blocked: command references non-standard env var $FOO. Add it to an allowed env-var list or rewrite the command."

The current behavior is the worst of both: restriction without a diagnostic signal.

Error Messages/Logs

{
  "permission_denials": [{
    "tool_name": "Bash",
    "tool_input": {
      "command": "curl -s --header \"PRIVATE-TOKEN: $CLAUDE_GITLAB_TOKEN\" \"$CI_API_V4_URL/projects/$CI_PROJECT_ID/merge_requests?state=opened\"",
      "description": "Check for existing MR"
    }
  }]
}

Response text returned to the model (verbatim):

Permission to use Bash has been denied because Claude Code is running in don't ask mode. IMPORTANT: You may attempt to accomplish this action using other tools that might naturally be used to accomplish this goal [...]

Steps to Reproduce

In a container with settings.json:

{"permissions": {"allow": ["Bash(curl *)"]}}

Run:

claude -p "<prompt>" \
  --permission-mode dontAsk \
  --settings /path/to/settings.json \
  --max-turns 3 \
  --output-format json

For each prompt below, the reported permission_denials field reveals the rule:

| # | Prompt (model runs the shown command) | Result |
|---|---|---|
| 1 | curl -s https://httpbin.org/get | ✅ allowed |
| 2 | curl -s --header "X-Thing: $HOME" https://httpbin.org/headers | ✅ allowed |
| 3 | curl -s --header "X-Thing: $FOO" https://httpbin.org/headers (FOO=xyz in env) | ❌ denied |
| 4 | curl -s --header "X-Thing: $MYVAR" https://httpbin.org/headers | ❌ denied |
| 5 | curl -s https://httpbin.org/anything/$CI_PROJECT_ID | ❌ denied |

Standard vars ($HOME) pass; user-defined vars of any name are denied, including innocuous ones ($FOO, $MYVAR).

Claude Model

Sonnet (default)

Regression

Yes — worked on 2.1.111, broken on 2.1.112.

Our GitLab CI had a headless pipeline running the same curl --header "PRIVATE-TOKEN: $CLAUDE_GITLAB_TOKEN" "$CI_API_V4_URL/..." successfully on 2.1.111 (published MR, clean exit). After image rebuild to 2.1.112, same skill denied on same command, every run.

The 2.1.97 changelog mentions "tightening checks around env-var prefixes" — but that phrasing naturally reads as leading assignments (FOO=bar cmd), not interpolated references. If 2.1.112 extended the check to interpolated refs, it's undocumented.

Platform

Anthropic API

Operating System

Other Linux (Alpine 3.23 in Docker)

Terminal/Shell

Non-interactive/CI environment

Additional Information

Downstream failure modes we observed:

  1. Fast bail-out (25 s). Model received one "don't ask mode" denial, interpreted it as "all Bash is blocked," cited the CLAUDE.md safety instruction ("exit non-zero if you cannot complete the workflow"), and exited cleanly. Pipeline succeeded by its own definition but did no work.
  1. Subagent cascade (10 × Task delegations, ~$0.40 wasted). Same trigger, different day. Model saw the denial, spawned a Task subagent with an increasingly explicit prompt ("Bash IS available, just try it"), the subagent refused without invoking Bash at all, model spawned another subagent, etc. Seven consecutive retries, all rejecting on first principles. Classic learned-helplessness: one denial poisoned the fleet. Transcript (jsonl + 10 subagent jsonls) available if useful.

Our workaround:

We moved GitLab API calls behind a wrapper script (/usr/local/bin/gitlab-api) that resolves the env vars internally. Model calls gitlab-api GET merge_requests?state=opened — no env var in the command string, allowlist matches Bash(gitlab-api *). Works reliably, but shifts responsibility to every CI consumer to ship such a wrapper.

Suggestions (in priority order):

  1. Informative denial message when the rule was matched but the env-var check rejected. Single biggest win — prevents the cascade.
  2. Document the semantics on https://code.claude.com/docs/en/permissions (also covered by #45469 for leading prefixes, but interpolation is separate).
  3. Opt-out or namespace allowlist for CI/headless ("permissions.allowEnvVars": ["CI_*", "CLAUDE_GITLAB_TOKEN"], or Bash(curl * $CI_*) syntax). The rule is defensible for interactive use; CI environments intentionally expose env vars as the only interface for tokens/URLs.

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗