Claude Code Permission Wildcards: What * Allows and How to Audit

25 min read
SaaSClaude Code

Bash(npx *) does not restrict the inner command run by npx: * matches text including spaces. Narrow approval to the command you need. Claude Code 2.1.323 adds a startup warning for wildcards in variable values and fixes cases such as NODE_ENV*. We compared 2.1.322 and 2.1.323 using a fake API and harmless fake commands.

Checked: 2026-11-13 JST. Scope: Claude Code 2.1.322 and 2.1.323, macOS 27.0, Node.js 26.12.1. Official documentation was retrieved the same day. A local fake Messages API returns fixed tool_use blocks; this does not measure real model responses or API charges.

Key points:

  • Put the program and required subcommand before * to narrow approval.
  • Include the inner command for runners such as npx and devbox run. Permission rules do not replace a sandbox.
  • Bash(NODE_ENV*) executed our fake command on 2.1.322 and was denied on 2.1.323.
  • The audit does not write settings. It prints allow-array indices and classifications, omitting the rule text.

What does Bash(npx *) approve without asking?

How much text does * match?

The official Permissions documentation says that * matches any text, including spaces. A sole trailing * also matches the bare command. Bash(ls:*) is an equivalent trailing-wildcard form of Bash(ls *).

Rule Documented scope Observation in both versions
All npx: Bash(npx *) Does not restrict what follows npx (derived from official rules) Fake npx ran with prettier, mytool and no arguments
Prettier only: Bash(npx prettier *) Names prettier prettier ran; mytool was denied
Attached wildcard: Bash(deploy*) No space boundary in the program name deploy-prod and deployx also ran
Space boundary: Bash(deploy *) deploy and its arguments deploy ran; deploy-prod was denied
Legacy trailing form: Bash(deploy:*) Trailing wildcard for deploy deploy x ran; deploy-prod was denied
Exact ls program: Bash(ls *) Matches ls, not lsof lsof still ran through another, read-only path
Also matches lsof: Bash(ls*) Also matches lsof lsof ran; execution does not identify the matching path

Why include the inner command for npx or devbox run?

Wrappers lists forms stripped before permission matching. Runners such as npx, devbox run and docker exec are not in that list. They execute commands supplied as arguments, so a broad rule does not constrain the inner operation. Our fake npx only recorded arguments in a marker; it did not download packages or run an inner program.

How do Bash(ls*) and Bash(ls *) differ?

The space defines a program-name boundary: ls* also matches lsof, whereas ls * names ls. Claude Code also has built-in read-only approval. Our fake lsof ran even without an allow rule. We used the non-read-only fake deploy-prod as a negative control for rule matching.

What changed in 2.1.323?

The 2.1.323 changelog reports a fix for attached wildcards skipping permission prompts in some cases. Our deploy* rows did not differ between versions. We observed a difference for the variable-name rule Bash(NODE_ENV*).

Allow rule and fake command 2.1.322 2.1.323
Variable name: Bash(NODE_ENV*) → NODE_ENV=test mytool Executed Permission denied
Attached wildcard: Bash(deploy*) → deploy-prod Executed Executed
Exact value: Bash(PORT=3000 npm run dev) → same command Executed Executed
Same exact-value rule → PORT=4000 npm run dev Permission denied Permission denied

Which rules produce startup warnings?

We observed warnings in the log written by --debug-file. Stderr was empty with JSON output. We did not test the interactive warning display.

Allow rule 2.1.322 debug 2.1.323 debug Warning meaning
Variable value: Bash(PORT=* npm run dev) No corresponding warning Warning A variable-value * also includes spaces
Before subcommand: Bash(git * main) Warning Warning Options can occupy the wildcard before later words
Exact value: Bash(PORT=3000 npm run dev) No corresponding warning No corresponding warning No warning for this exact value in our run

How do I remove the PORT=* warning?

Use the required exact value, such as Bash(PORT=3000 npm run dev). A wildcard includes spaces; it does not constrain only a variable value. We did not verify a dedicated setting that suppresses this warning. Remove the original broad rule when narrowing approval: adding a specific rule while keeping the broad one does not narrow it.

Why does git * main produce a warning?

The wildcard can include a subcommand and options. The documentation warns that git options such as -c can run another program. Put * after the required subcommand, for example Bash(git log *). We used fake git only to observe approval and warnings, without running commands that change real git configuration.

How are commands joined with && or pipes checked?

Compound commands requires each subcommand to be allowed. With only Bash(mytool *), our &&, pipe and semicolon forms ending in deploy-prod were denied, with no command markers. A trailing mytool a && produced a parse error and did not execute. Deny and ask rules apply when any subcommand matches.

How can I inspect my settings safely?

Save the audit script and explicitly pass the settings JSON you want to inspect. It does not search your home directory. It reads the JSON and classifies only permissions.allow, without settings writes, networking, child processes or launching Claude Code.

node permission-wildcard-audit.mjs /path/to/settings.json

The output index is the zero-based allow-array index. Credentials can appear outside assignments, so we changed the planned partial masking to omit every rule body, using a placeholder such as Bash([omitted]). Inspect the indexed entry in your own editor. This is a syntax heuristic: ok is not a security approval. For example, it does not inspect every option allowed by git log * main. It rejects invalid JSON, missing allow arrays, files larger than 65,536 bytes and file symlinks. It does not prevent symlinks in parent directories or file replacement during a read.

import { lstatSync, readFileSync, realpathSync } from 'node:fs'
import { fileURLToPath } from 'node:url'

// Heuristic review labels. An 'ok' label is not a security approval.
export function classify(rule) {
  if (typeof rule !== 'string') throw Error('allow entries must be strings')
  if (rule === 'Bash' || rule === 'Bash(*)') return 'bare-tool'
  const m = /^Bash\((.*)\)$/.exec(rule)
  if (!m) return 'other-tool'
  // The documented trailing :* form has the same boundary as a trailing space + *.
  const p = m[1].endsWith(':*') ? `${m[1].slice(0, -2)} *` : m[1]
  if (!p.includes('*')) return 'ok'
  if (/^(?:[A-Za-z_][\w]*=[^ ]*\*[^ ]*\s*)+/.test(p)) return 'variable-wildcard'
  if (
    /^(?:npx|bunx|pnpm dlx|devbox run|mise exec|direnv exec|docker exec|bash -c|sh -c|env|xargs\s+-\S+)(?:\s|$)/.test(
      p,
    )
  )
    return 'runner-wildcard'
  if (/[^\s:]\*/.test(p)) return 'attached-wildcard'
  if (/^\S+\s+\*\s+\S/.test(p)) return 'wildcard-before-subcommand'
  if (/^\S+ \*$/.test(p) && p !== 'ls *') return 'program-only-wildcard'
  return 'ok'
}

export function inspect(text) {
  const data = JSON.parse(text)
  if (!data || typeof data !== 'object' || Array.isArray(data))
    throw Error('settings must be an object')
  if (!data.permissions || !Array.isArray(data.permissions.allow))
    throw Error('permissions.allow must be an array')
  return data.permissions.allow.map((rule, index) => ({
    index,
    // Never echo user-supplied rule text: credentials can also appear without '='.
    rule: /^Bash(?:\(|$)/.test(rule) ? 'Bash([omitted])' : '[omitted]',
    classification: classify(rule),
  }))
}

export function audit(path) {
  const stat = lstatSync(path)
  if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 65536)
    throw Error('regular file up to 65,536 bytes required')
  return inspect(readFileSync(path, 'utf8'))
}

function startedDirectly() {
  // Why real paths on both sides: Node may resolve links in import.meta.url while argv[1] stays
  // as typed. Compared as written, a start through a link printed nothing and exited 0.
  try {
    return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
  } catch {
    return false // argv[1] names no file (stdin, -e), so this module was imported.
  }
}
if (startedDirectly()) {
  try {
    if (process.argv.length !== 3)
      throw Error('usage: node permission-wildcard-audit.mjs <settings.json>')
    console.log(JSON.stringify(audit(process.argv[2]), null, 2))
  } catch {
    // JSON parser error text can include secrets. Print only a constant message.
    console.error(
      'Cannot audit: pass valid settings JSON with a string array permissions.allow (regular file, <=65,536 bytes).',
    )
    process.exitCode = 1
  }
}

How can I narrow the rules?

These are rewrite candidates. Check the code being executed and the meaning of its arguments even for a specific rule. A wildcard in an argument position can still permit dangerous options there.

Broad form Narrower candidate Verification here
All npx: Bash(npx *) Bash(npx prettier --check *) --check ran; --write was denied
Git command family: Bash(git *) Bash(git log *) and Bash(git diff *) Official guidance; real git untested
Also matches lsof: Bash(ls*) Exact ls program: Bash(ls *) Documented boundary; lsof execution alone cannot distinguish it
Variable value: Bash(PORT=* npm run dev) Exact value: Bash(PORT=3000 npm run dev) 3000 ran; 4000 was denied
Unrestricted inner command: Bash(devbox run *) Bash(devbox run npm test) npm test ran; mytool was denied

For writing boundaries, see blocking writes outside the working folder. For a custom pre-execution decision, see hook exit codes.

What did the tests show?

We ran 39 separate processes per version, 78 conditions in total, covering no-allow controls, untrusted project allow rules, variables, spaces, runners, compound commands and deny rules. Download the complete result table. Execution requires both a marker and a successful tool_result; denial requires no marker and a denial tool_result. Parse errors are recorded separately.

An allow rule in the untrusted project did not approve mytool in either version. Subagent inline MCP and folder trust also distinguishes source and trust. See Claude Code MCP settings for MCP configuration itself.

How can I reproduce this?

  1. Create an empty synthetic workspace and use env -i with an explicit environment whitelist.
  2. Redirect CLAUDE_CONFIG_DIR, CODEX_HOME, HOME, TMPDIR, XDG and Git configuration into scratch. Deny real settings access and outbound network with the macOS sandbox, and test denial with a scratch canary.
  3. Connect to a localhost fake Messages API with a dummy key. Return fixed Bash tool_use blocks. Fake commands in PATH only record markers.
  4. Create a fresh settings folder per condition. Run claude -p "run the scripted command" --permission-mode default and compare markers, tool_result and debug output.

The 2.1.323 npm tarball was checked against registry integrity. Usage and cost printed by the CLI are calculations from fake responses, not actual API use or charges. Auto-updates and nonessential traffic were disabled. Negative controls remove canary protection, omit fake API tool_use, or ignore the execution marker.

What remains unverified?

Real APIs, real npx downloads, sudo, ask rules, all operating systems, all versions and the interactive UI remain untested. We did not measure model command selection or package-code safety. Permission rules are not a complete shell security boundary and have limits concerning alternative command spellings and executable paths. Search suggestions, organic search traffic and changes in AI citations were not collected.

Frequently asked questions?

Is Bash(npx *) safe?

It is a broad allow rule that does not restrict the inner command run by npx. Include the required command and arguments. This does not mean every compound shell command or other permission check is bypassed.

Should I use Bash(ls*) or Bash(ls *)?

Use Bash(ls *) when you mean ls. The documented form without a space also matches lsof. However, lsof may run as a read-only command, so execution alone does not prove that an allow rule matched.

How do I remove the PORT=* startup warning?

Narrow the rule to the needed value, for example Bash(PORT=3000 npm run dev). We did not verify a setting that disables this warning. Check permission behavior for your own command after rewriting it.

Does one rule approve commands joined by &&?

Each subcommand needs approval. In our tests an allow rule for mytool did not approve the following deploy-prod. A trailing && produced a parse error, which we distinguish from permission-rule matching.

Do deny rules cover sudo or env wrappers?

The 2.1.323 changelog reports fixes for deny and ask rules missing commands through sudo, env and similar wrappers. Our synthetic tests checked env and timeout denial in both versions. We did not test sudo or ask rules.

What should I do on 2.1.322 or earlier?

Update using the official instructions and review broad allow rules. Our only older-version test was 2.1.322. We observed the NODE_ENV* approval difference; this is not a test of every previous version or command.

Go deeper on this topic

SaaS integrations

Give AI the context to continue the work: source conversations, open tasks, and unresolved details, from adoption decisions to integration code.

Article 1 of 21 in this series.

View topic page

Use the topic hub as a map, then continue to the next article in the series. New here? Start at the home hub →

FAQ

Is Bash(npx *) safe?

It is a broad allow rule that does not restrict the inner command run by npx. Include the required command and arguments. This does not mean every compound shell command or other permission check is bypassed.

Should I use Bash(ls*) or Bash(ls *)?

Use Bash(ls *) when you mean ls. The documented form without a space also matches lsof. However, lsof may run as a read-only command, so execution alone does not prove that an allow rule matched.

How do I remove the PORT=* startup warning?

Narrow the rule to the needed value, for example Bash(PORT=3000 npm run dev). We did not verify a setting that disables this warning. Check permission behavior for your own command after rewriting it.

Does one rule approve commands joined by &&?

Each subcommand needs approval. In our tests an allow rule for mytool did not approve the following deploy-prod. A trailing && produced a parse error, which we distinguish from permission-rule matching.

Do deny rules cover sudo or env wrappers?

The 2.1.323 changelog reports fixes for deny and ask rules missing commands through sudo, env and similar wrappers. Our synthetic tests checked env and timeout denial in both versions. We did not test sudo or ask rules.

What should I do on 2.1.322 or earlier?

Update using the official instructions and review broad allow rules. Our only older-version test was 2.1.322. We observed the NODE_ENV* approval difference; this is not a test of every previous version or command.