The junior pastes a /permissions screenshot. A deny rule is in the list. The coding agent still ran the tool. They file an allow-all ticket: “the rule allowed everything.”
I stop the run there. A NUL byte in a permission rule is not a wildcard allow. The public Claude Code release that named this field is blunt: a permission rule containing a NUL byte was expanded into a wildcard match; such a rule now matches nothing. That is a matcher bug, not proof that deny lost. [Source: https://github.com/anthropics/claude-code/releases/tag/v2.1.281] [Source: https://code.claude.com/docs/en/changelog]
I already refused to treat a timed-out job as a killed worker in A Timed-Out Job Is Not a Killed Worker. I already refused to treat one matching glob as a sandbox exemption for the rest of the line in One Matching Glob Is Not a Sandbox Exemption for the Rest of the Line. I already refused to treat a present AGENTS.md as the project instructions while CLAUDE.md exists in A Present AGENTS.md Is Not the Project Instructions While CLAUDE.md Exists. This post is the same desk rule for permission bytes. Print the bytes. Print the match. Name who owns the permission list.
The question is not whether a deny line looks present in JSON. The question is whether the named owner can tell a wildcard from a rule that matches nothing.

The ticket that looks like allow-all
Juniors treat a deny miss the way they treat a firewall hole. The line is in settings.json. The tool still ran. They page security: “deny is broken, everything is allowed.”
Two jobs collide on that line.
- Stop a tool the team named. Official docs: deny rules prevent Claude Code from using the specified tool. Rules are evaluated deny, then ask, then allow. The first match in that order wins. [Source: https://code.claude.com/docs/en/permissions]
- Keep the matcher honest. A rule that does not match is not a deny. A rule that used to expand into a wildcard is also not the deny you thought you wrote.
If you only screenshot “the deny is listed,” you will file allow-all. You will not file the bytes.
I do not invent a fake overnight breach. I use the public contract. The matcher field is the ticket, not a version pin in the title.
What a permission rule actually is
Official permissions docs: rules follow Tool or Tool(specifier). A rule that is only the tool name matches every use of that tool. Adding Bash to the allow list lets Claude Code use Bash without a prompt. Adding Bash to the deny list removes Bash from Claude’s context. A scoped rule such as Bash(rm *) leaves the tool available and blocks matching calls. [Source: https://code.claude.com/docs/en/permissions]
Wildcards are a separate field. A * in a Bash rule matches any text, including spaces. A rule with no * matches one exact command. Official docs: Bash(*) is treated as equivalent to Bash. [Source: https://code.claude.com/docs/en/permissions] [Source: https://github.com/anthropics/claude-code/issues/21140]
Copy the docs example as a probe of the lists, not as a bypass recipe.
1{
2 "permissions": {
3 "allow": [
4 "Bash(npm run *)",
5 "Bash(git commit *)"
6 ],
7 "deny": [
8 "Bash(git push *)",
9 "Read(./.env)",
10 "Read(./.env.*)"
11 ]
12 }
13}
[Source: https://code.claude.com/docs/en/permissions] [Source: https://code.claude.com/docs/en/settings]
That JSON is readable UTF-8. Every character you see is the character the matcher sees. A NUL byte is not a character you see in a paste. It is byte 0x00. It does not print. It does not survive every editor. It does survive a bad copy from a binary dump, a Windows device name mix-up, or an agent that wrote a path with an embedded null.
The public fix names the old matcher: a permission rule containing a NUL byte was expanded into a wildcard match. After the fix, such a rule matches nothing. [Source: https://github.com/anthropics/claude-code/releases/tag/v2.1.281] [Source: https://code.claude.com/docs/en/changelog]
Read that twice.
- Before: NUL in the rule → treated like
*. A deny that looked narrow could match like a wildcard. A junior then files “deny allowed everything.” - After: NUL in the rule → matches nothing. The same deny is now a no-op. A junior then files “deny is ignored.” Both tickets skip the bytes.
Neither ticket is allow-all until you print the bytes.
Pins are evidence, not the hook
published_at 2026-09-23T19:19:15Z, prerelease false). The GitHub release and the official changelog use the same sentence: a permission rule containing a NUL byte being expanded into a wildcard match; such a rule now matches nothing. The same release also says Read, Write, Edit, and NotebookEdit fail a file path that contains a null byte with a clear error instead of ending the whole turn. Do not put those numbers in the title. This post is the matcher field, not a patch table. [Source: https://github.com/anthropics/claude-code/releases/tag/v2.1.281] [Source: https://code.claude.com/docs/en/changelog]
Three columns on the ticket
I keep one table on the ticket.
| What you saw | What it is | What it is not |
|---|---|---|
Deny line in /permissions | A listed rule | Proof the matcher used that rule |
| Tool still ran | No matching deny, or the rule matches nothing | Allow-all forever |
| NUL byte in the rule string | Old: expanded to wildcard. Now: matches nothing | A * you typed on purpose |
Visible * in Bash(npm run *) | Documented wildcard | A hidden 0x00 |
| Empty match after the fix | The rule is a no-op | The permission system is off |
The junior screenshot is the first row. They want the third column. Print the second column first.
Name the owner. I use PERMISSION_RULE_OWNER the same way I use a migration owner. The person who owns .claude/settings.json on this desk also owns “did this rule contain a NUL, did it match nothing, or did a real wildcard fire.” A coding agent does not get to file allow-all because a listed deny missed.
Official evaluation order stays on the same ticket. Deny, then ask, then allow. A broad deny such as Bash(aws *) blocks every matching call, including a narrower allow such as Bash(aws s3 ls). An allow rule cannot carve an exception out of a deny rule. [Source: https://code.claude.com/docs/en/permissions]
That order only runs on rules the matcher actually matches. A NUL rule that matches nothing never enters deny. It never enters allow. It is dead text in the file.
Do not heading-clone the compound-glob post. That post is sandbox.excludedCommands on a compound Bash line. This post is permission-rule bytes. Do not heading-clone the AGENTS.md post. That post is which brief the agent reads. This post is which bytes the matcher reads.
Probe the bytes, do not write a NUL rule
I keep a probe in the repo the coding agent uses, not in a gist on a laptop. It does not insert a NUL. It does not craft a wildcard. It classifies existing settings files.
1#!/usr/bin/env python3
2"""Classify permission-rule bytes. Not a NUL-rule recipe."""
3
4from __future__ import annotations
5
6import json
7import os
8from pathlib import Path
9
10NUL = b"\x00"
11
12
13def load_bytes(path: Path) -> bytes:
14 return path.read_bytes() if path.is_file() else b""
15
16
17def rule_strings(raw: bytes) -> list[str]:
18 if not raw or NUL in raw:
19 return []
20 data = json.loads(raw.decode("utf-8"))
21 perms = data.get("permissions") or {}
22 out: list[str] = []
23 for key in ("allow", "deny", "ask"):
24 for item in perms.get(key) or []:
25 if isinstance(item, str):
26 out.append(item)
27 return out
28
29
30def main() -> int:
31 owner = os.environ.get("PERMISSION_RULE_OWNER", "").strip()
32 path = Path(os.environ.get("CLAUDE_SETTINGS", ".claude/settings.json"))
33 raw = load_bytes(path)
34
35 print(f"PERMISSION_RULE_OWNER={owner or 'MISSING'}")
36 print(f"SETTINGS={path}")
37 print(f"BYTES={len(raw)}")
38 print(f"NUL_COUNT={raw.count(NUL)}")
39
40 if not owner:
41 print("VERDICT=NO_OWNER")
42 return 2
43 if not path.is_file():
44 print("VERDICT=NO_SETTINGS_FILE")
45 return 2
46 if NUL in raw:
47 print("VERDICT=NUL_IN_FILE_MATCHER_MUST_BE_REBUILT")
48 return 1
49
50 rules = rule_strings(raw)
51 print(f"RULE_COUNT={len(rules)}")
52 wildcards = [r for r in rules if "*" in r]
53 print(f"VISIBLE_WILDCARD_COUNT={len(wildcards)}")
54 print("VERDICT=NO_NUL_PRINT_VISIBLE_RULES")
55 return 0
56
57
58if __name__ == "__main__":
59 raise SystemExit(main())
Run it in the app that actually ships.
1export PERMISSION_RULE_OWNER="shinjae"
2export CLAUDE_SETTINGS=".claude/settings.json"
3python3 scripts/probe_permission_nul.py
4python3 -c "import json,pathlib; p=pathlib.Path('.claude/settings.json'); print('readable' if p.is_file() else 'missing')"
The script does not call the model. It does not toggle auto mode. VERDICT=NUL_IN_FILE_MATCHER_MUST_BE_REBUILT means you do not have an allow-all policy. You have a file whose matcher used to treat 0x00 as *, and now treats it as match-nothing. Then open /permissions. Copy the listed deny. Put those four lines on the ticket: owner, path, NUL count, verdict.
A second probe is the official lists themselves. Print allow, deny, and ask as UTF-8. If a rule looks like Bash(git push *), that * is a documented wildcard. If the file contains 0x00, that is not a * you typed. Do not “fix” it by pasting a broader Bash(*). Official docs already say Bash(*) matches every Bash call. That is a policy change, not a byte repair. [Source: https://code.claude.com/docs/en/permissions]
The same release also fails Read, Write, Edit, and NotebookEdit when the file path contains a null byte, with a clear error, instead of ending the whole turn. That is a sibling field: path bytes, not permission-rule bytes. Keep it on a second ticket. [Source: https://github.com/anthropics/claude-code/releases/tag/v2.1.281] [Source: https://code.claude.com/docs/en/changelog]

What you must not do
Forbidden:
- File an “allow-all” ticket without printing the settings path, the NUL count, the listed deny, and one human name on the permission list.
- Put a Claude Code version in the title or the first line. The pin is evidence after the decision.
- Mix this field with a compound-glob sandbox ticket, an
AGENTS.mdbrief ticket, or a timed-out job ticket. Those are other posts. - Write a NUL into a permission rule to “demo” the old wildcard. The public notes already state the old expansion and the new match-nothing. You do not need a live poison rule. [Source: https://github.com/anthropics/claude-code/releases/tag/v2.1.281]
- Replace a broken rule with
Bash(*)or a bareBashdeny because the bytes were dirty. BareBashon deny removes the tool from context. That is a different policy. [Source: https://code.claude.com/docs/en/permissions] - Recommend buying a plan, a seat, or a gateway because one rule missed.
- Treat
/permissionslisting the rule as proof the matcher used it. - Treat “matches nothing” as “permission system off.” Official docs: Claude Code enforces rules, not the model. A dead rule is dead text. The rest of the list still runs. [Source: https://code.claude.com/docs/en/permissions]
- Confuse a Windows file named
nulwith a NUL byte inside a JSON string. Those are different bugs. This post is the permission-rule matcher. - Paste
CLAUDE_CODE_DISABLE_SUBSTITUTION_RM_PROMPT=1or any rm bypass because this release also named a substitution-onlyrmprompt. That is a different field. Do not write an rm recipe here.
Allowed:
- Print
allow,deny, andaskfrom the file the session actually loaded. - Count
0x00in that file with a probe. Do not insert0x00. - Keep documented wildcards such as
Bash(npm run *)when the named owner wants that family. [Source: https://code.claude.com/docs/en/permissions] - Name one human as
PERMISSION_RULE_OWNER. - Print
claude --versionafter the decision, in a details block, not in the title. - Rebuild a dirty settings file as UTF-8 JSON with only the visible rules the owner still wants.
GSC this week still has no striking-distance query on the timed-out-job post, the compound-glob post, or the AGENTS.md post. I am not refreshing those URLs. This is a new field, not a synonym of Thursday’s queue clock or Wednesday’s glob.
If you need the broader habit, start at /ai-agent-operations/. Tooling notes live under /developer-tools/. Laravel plus Vue notes live under /laravel-vue-saas/. A first-week map is at /start-here/.
A changelog bullet about a matcher is not permission to skip the bytes.
What you should do Monday morning
- Open the repo that actually ships. Export
PERMISSION_RULE_OWNERto a human name. Runprobe_permission_nul.pyagainst.claude/settings.jsonand, if it exists,.claude/settings.local.json. Write the verdict on the ticket next to that name. - Open
/permissions. Copy every deny line. For each line, write whether the matcher has a visible*or a hidden0x00. If NUL count is greater than zero, rebuild the file as UTF-8. Do not add a broader wildcard to “make deny work.” - If a tool ran that a listed deny should have stopped, file “this rule did not match.” Decide whether the rule was dead (match-nothing), the call used a different tool name, or evaluation order never saw that rule. Official order is deny, then ask, then allow. [Source: https://code.claude.com/docs/en/permissions]
- If someone pastes a settings snippet with an unprintable character, reject the review until the named owner can show
NUL_COUNT=0and a JSON parse of the same file. - Print
claude --version. If the laptop is behind the 23 September 2026 release that named the matcher change, do not treat the local pin as those fields. Compare, then decide an upgrade as change control, not as a social post. [Source: https://github.com/anthropics/claude-code/releases/tag/v2.1.281] [Source: https://code.claude.com/docs/en/changelog] - Confirm coding-agent instructions on this desk name the same owner and forbid “the rule allowed everything” without the four lines: owner, settings path, NUL count, listed deny.
The question is not whether a deny line demos well in a gist. The question is whether the named owner can still tell a wildcard from a rule that matches nothing after handoff.
Further reading
Source Claude Code Docs — configure permissions
Source GitHub — Claude Code v2.1.281 release notes
Source Claude Code Docs — changelog
