Claude Code Permission Wildcards: What * Allows and How to Audit
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.jsonThe 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?
- Create an empty synthetic workspace and use
env -iwith an explicit environment whitelist. - 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. - Connect to a localhost fake Messages API with a dummy key. Return fixed Bash tool_use blocks. Fake commands in PATH only record markers.
- Create a fresh settings folder per condition. Run
claude -p "run the scripted command" --permission-mode defaultand 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.
Where are related articles and primary sources?
- Official Permissions: matching, wrappers, compound commands and limits.
- Official Changelog: 2.1.323 additions and fixes.
- Official Errors: warning meaning and location.
- Official Settings reference: allow, deny and ask.
- SaaS workflows: related configuration and measured articles.
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.
Next reads
Claude Code Subagent MCP: Folder Trust and Startup Controls
Cloned repo inline MCP waits for folder trust. Tests on Claude Code 2.1.319 cover 75 isolated cases, strict and bare exceptions, and a read-only audit.
Postman Auto-Install: Check Claude Code and Codex Settings
Postman auto-install reports, rollback response, source changes and a read-only settings checklist. Local checks; removal and exfiltration unverified.
Claude Code Opus 4.8 Fast Mode Sunset: Settings and Migration
Opus 4.8 fast mode is scheduled for removal on 2026-11-30 with no standard-speed fallback. Official guidance, CLI 2.1.317 checks and 48 read-only cases; migration and billing untested.
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.