Skip to content

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

Description

@akolu

Preflight

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.

  2. 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 [DOCS] Bash permission docs omit env-var prefix matching semantics #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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions