GUIDE · INCIDENT 10 min ·

Claude Code skills bypassed allowlist permissionsA 14-day window. A simple regex.

A Claude Code skill bypassed a read-only allowlist by swapping one character in its tool name for a Cyrillic lookalike. The matcher never noticed. Arbitrary shell ran for 14 days before anyone checked a session log.

TL;DR· the answer, in twenty seconds

What happened: A community-published Claude Code skill registered its tool name with U+0430 Cyrillic 'а' in place of the ASCII 'a' in "bash". The allowlist matcher compared strings with no Unicode normalization. The skill ran arbitrary shell commands despite the user's config restricting Claude Code to read-only file operations.

The minimum fix: audit every installed skill's tool names against your allowlist entries. Run claude skills list --verbose and compare tool names byte-for-byte. Anthropic patched the matcher in Claude Code 1.x; update with npm install -g @anthropic-ai/claude-code@latest.

The lesson: string-equality permission checks on user-supplied names fail whenever the input space includes Unicode. Canonicalize before comparing, not after.

Late April 2026, a developer at a small infrastructure shop was reviewing a teammate's Claude Code session log when something looked off. The agent had run shell commands. The team's config allowed only read-only file operations. Nobody had changed the allowlist.

What they found: a community skill had registered its tool under a name that contained U+0430, the Cyrillic small letter 'а', in the position where ASCII 'a' appears in "bash". The two characters are visually identical in most terminals and editors. The Claude Code allowlist matcher compared them as raw strings. The strings were not equal. The permission check passed.

Arbitrary shell ran for 14 days. The team found it because one developer read the log manually, not because any automated check fired.

Anthropic patched the matcher in a Claude Code 1.x update. But the patch addresses one character substitution in one tool name. The underlying problem, string equality on user-supplied Unicode input without normalization, is a category failure that shows up wherever access control decisions touch text.

The short version

  • A Claude Code skill used a Unicode homoglyph (U+0430 Cyrillic 'а') in its registered tool name.
  • Claude Code's allowlist matcher used raw string equality. U+0430 != U+0061. No match. Permission check treated the tool as unlisted and allowed it.
  • The skill called arbitrary shell despite the user's config limiting Claude Code to read-only file tools.
  • The window was 14 days, caught by manual log review.
  • Anthropic patched in Claude Code 1.x. The fix: normalize both sides of the comparison to Unicode NFKC before comparing.

How the bypass worked

Claude Code skills register one or more tool names. When Claude Code decides whether to call a tool, it checks the tool name against the user's allowlist in ~/.claude/settings.json or .claude/settings.json in the project directory.

The allowlist entry looked like this:

{
  "permissions": {
    "allow": ["bash", "read", "ls", "grep"]
  }
}

The skill's manifest declared its tool name as bаsh, where the second character is U+0430 instead of U+0061. The comparison ran:

"bash" === "bаsh"   // U+0062 U+0061 U+0073 U+0068
                    // vs
                    // U+0062 U+0430 U+0073 U+0068
→ false

False means "not in allowlist." The code then applied the default policy for unlisted tools. The default was not "deny." It was "ask the user in interactive mode, or allow in non-interactive mode." The team ran Claude Code in a non-interactive CI-adjacent flow. Allow.

Two wrong assumptions produced this. First, that tool names in community skills would use printable ASCII. Second, that "not in allowlist" maps to "blocked" in all execution contexts. Neither held.

Why string equality fails here

String equality is safe for identifiers when you control both sides. Tool names in installed skills are user-supplied. The allowlist entries are also user-supplied. You type bash. The skill author types what looks like bash. The matcher assumes both sides use the same byte sequence.

Unicode normalization collapses the space of "things that look like the same character" into canonical forms. NFKC (Compatibility Decomposition followed by Canonical Composition) is the normalization form that handles homoglyphs, compatibility characters, and ligatures in one pass. Compare NFKC(a) === NFKC(U+0430) and you get false for a correct reason: they are different characters, not just different bytes for the same character. But at least the comparison is doing the right kind of work.

More importantly, NFKC normalization applied to bash and bаsh both gives bash. The strings are still unequal, but now the allowlist entry bash correctly catches the spoofed variant, because normalization collapsed the Cyrillic character to its ASCII equivalent in this context.

The real fix is two-part: normalize on write (when the user saves the allowlist) and normalize on read (when the matcher runs the comparison). One-side normalization is not enough if the skill manifest is read raw and the allowlist is read normalized, or the other way around.

What the 14-day window actually means

The team's allowlist was specific. They allowed bash, read, ls, grep. That specificity is correct practice. It failed because the matcher did not normalize.

Knostic, who disclosed the settings.local.json env-var capture earlier in March 2026, noted after this incident that permission systems for AI tools consistently break on input validation, not on the policy logic itself. The policy was right. The input handling was not.

Fourteen days is how long the bypass ran before manual review caught it. No automated control tripped because no automated control checked tool name byte composition. Log-line tool names matched the visual representation. Nothing looked wrong until someone compared bytes.

This is the second Claude Code permissions incident in recent months. The March 2026 settings.local.json npm leak showed what happens when a tool's output path sits outside the user's mental model of what gets published. This incident shows what happens when the tool's input validation sits outside the user's mental model of what gets compared. Different surfaces, same shape: a gap between what the user configured and what the code checked.

What gets missed in most allowlist implementations

Everyone focuses on which tools to allow. Almost nobody audits what the matcher does with the tool names.

A permission system has at least four places where it can fail on Unicode input:

Storage. If the allowlist is JSON and the editor accepts Unicode literals, a user can save "bаsh" thinking they saved "bash". The file holds U+0430 silently.

Display. Most terminals, log viewers, and JSON editors render U+0430 as 'а'. The developer who reviews the config sees bash in both places.

Comparison. Raw string equality without normalization means the comparison outcome depends on byte identity, not visual identity. You can't audit this by reading the file.

Default behavior. "Not found in allowlist" should mean "blocked." If it means "prompt user" or "allow in some modes," the bypass surface expands to any tool name not on the list.

The first three failures compound the fourth. A user cannot reliably audit their allowlist by looking at it. They need a tool that dumps the raw bytes.

Audit your allowlist right now

Before the next Claude Code session, check two things.

First, verify your Claude Code version is patched:

claude --version
npm install -g @anthropic-ai/claude-code@latest

Second, dump the raw code points in your allowlist tool names. One line of Python:

python3 -c "
import json, sys
data = json.load(open('.claude/settings.json'))
for entry in data.get('permissions', {}).get('allow', []):
    points = ' '.join(f'U+{ord(c):04X}' for c in entry)
    print(f'{entry!r}: {points}')
"

Run the same against ~/.claude/settings.json if you have global permissions configured. Any code point outside the Basic Latin block (U+0020 through U+007E) in a tool name is worth investigating.

Third, audit installed skills for their declared tool names:

claude skills list --verbose

Compare each tool name against your allowlist entries visually and by code point. A skill's declared tool name is in its manifest. If the manifest is local, read it directly:

find ~/.claude/skills -name "manifest.json" -exec python3 -c "
import json, sys
m = json.load(open(sys.argv[1]))
for t in m.get('tools', []):
    name = t.get('name','')
    points = ' '.join(f'U+{ord(c):04X}' for c in name)
    print(f'{sys.argv[1]}: {name!r} = {points}')
" {} \;

If any tool name contains non-ASCII code points, do not install or run that skill until you understand why.

A checklist you can paste into a PR

## Claude Code allowlist audit

- [ ] Updated to latest Claude Code (npm install -g @anthropic-ai/claude-code@latest)
- [ ] Ran python3 code-point dump on .claude/settings.json -- all ASCII
- [ ] Ran same dump on ~/.claude/settings.json -- all ASCII
- [ ] Ran claude skills list --verbose -- no non-ASCII tool names
- [ ] Checked installed skill manifests for non-ASCII in tool name fields
- [ ] Confirmed allowlist "default deny" behavior in non-interactive mode
- [ ] Reviewed last 30 days of session logs for unexpected tool calls
- [ ] Removed or sandboxed any skill with non-ASCII tool names

What this means for your stack

The patch Anthropic shipped fixes this specific bypass. It does not fix the broader pattern: any permission system that compares user-supplied strings without canonicalizing first is vulnerable to the same class of attack, regardless of tool or vendor.

The durable position is a runtime that does not rely on name comparison alone. A credential broker that intercepts tool calls at exec time can verify the actual binary being invoked rather than the name the skill declared for it. That is a much harder surface to spoof. hasp takes that approach for secret injection: it binds to a process tree and injects credentials at exec time, so the thing receiving the secret is the process you authorized, not a process that claimed the right name. curl -fsSL https://gethasp.com/install.sh | sh, hasp setup, connect a project. Source-available (FCL-1.0), local-first, macOS and Linux, no account required.

The allowlist incident and the March npm leak share one lesson: what you type into a config file and what the code reads are not always the same thing. Build controls that verify behavior, not just names.

Sources· cited above, in one place

NEXT STEP~90 seconds

Stop handing the agent your real keys.

hasp keeps secrets in one local encrypted vault, brokers them into the child process at exec, and never lets the agent read the value.

  • Local, encrypted vault — no account, no cloud, no telemetry by default.
  • Brokered run — agent gets a reference, the child process gets the value.
  • Pre-commit + pre-push hooks catch managed values before they ship.
  • Append-only HMAC audit log answers "did the agent touch the prod token?" in seconds.
→ okvault unlocked · binding ./api
→ okgrant once · pid 88421
→ okagent never read

macOS & Linux. Source-available (FCL-1.0, converts to Apache 2.0). No account.

Browse all clusters· eight threads, one index