●TEACHERS — Anthropic launches Claude for Teachers, giving verified US K-12 educators free premium access, teaching skills, and curriculum links aligned to standards in all 50 states●ADMIN — The Admin API is now in beta for every Claude Enterprise organization: member and invite calls need no beta header, while group and custom-role calls do●M365 — The Microsoft 365 connector gains write tools — draft, send, and organize email, manage calendar events and mailbox settings, and create or update files in OneDrive and SharePoint●MCP — Fixed servers from --mcp-config or .mcp.json ignoring a per-server request_timeout_ms, which left long-running tool calls timing out at the 60s default in fresh sessions●SUBAGENT — A new --forward-subagent-text flag and CLAUDE_CODE_FORWARD_SUBAGENT_TEXT variable include subagent text and thinking in stream-json output●DEADLINE — Opus 4.7 fast mode is removed on July 24, seven days out. speed: "fast" will error, so confirm your move to Opus 4.8●TEACHERS — Anthropic launches Claude for Teachers, giving verified US K-12 educators free premium access, teaching skills, and curriculum links aligned to standards in all 50 states●ADMIN — The Admin API is now in beta for every Claude Enterprise organization: member and invite calls need no beta header, while group and custom-role calls do●M365 — The Microsoft 365 connector gains write tools — draft, send, and organize email, manage calendar events and mailbox settings, and create or update files in OneDrive and SharePoint●MCP — Fixed servers from --mcp-config or .mcp.json ignoring a per-server request_timeout_ms, which left long-running tool calls timing out at the 60s default in fresh sessions●SUBAGENT — A new --forward-subagent-text flag and CLAUDE_CODE_FORWARD_SUBAGENT_TEXT variable include subagent text and thinking in stream-json output●DEADLINE — Opus 4.7 fast mode is removed on July 24, seven days out. speed: "fast" will error, so confirm your move to Opus 4.8
The String I Approved Wasn't the String I Read — Testing a Relayed Permission Prompt with Deceptive Characters
I pushed bidi overrides and zero-width characters through my own approval relay. NFKC normalization caught 0%. Here is why, and the implementation that catches 100% with zero false positives.
Once I moved my nightly updates to unattended runs, the only thing left in my hands was the approval.
The four sites I run at Dolice Labs go from article generation through quality gates to push without me. When something risky comes up, a permission prompt lands in my chat, and I read it and let it through. More than once I've approved a single line while standing on a train platform.
A recent Claude Code update carried a short note: permission previews relayed to chat channels now neutralize bidirectional override characters, zero-width characters, and lookalike quotation marks. The point of the fix is that tool input shouldn't be able to change how an approval message looks.
That stopped me.
If the look can be changed, then what I had been approving all along was never the string that runs. It was the appearance of the string that runs. Those two things usually match. I had no particular reason to believe they always would.
So I pushed deceptive strings through my own relay, and the results weren't what I expected.
Where the deception comes from
The gap between how a string looks and what it contains exists because Unicode carries characters that are instructions for display. The layer that renders obeys them. The layer that executes ignores them and reads bytes. That asymmetry is the whole opening.
Trick
Code point
What happens
Bidi override (RLO)
U+202E
Everything after it renders right-to-left, so the tail appears reversed
Zero-width space
U+200B
Occupies no width and hides inside a word. Your eyes miss it; so does your regex
Zero-width non-joiner
U+200C
Same idea. Slip one inside a flag name and it becomes a different flag
Lookalike quotes
U+201C / U+201D
Looks quoted. To the shell, it's just a character
Homoglyph
U+043F, etc.
Cyrillic п passing as Latin n. A different identifier entirely
Newline
U+000A
Lets an attacker add fake lines to the preview — including one that says "approved"
That last row is the one that got me. The goal of the deception isn't only to make a dangerous command look safe. It also works by making the person reading believe someone already checked. The target isn't the terminal. It's me, reading the screen.
The upstream fix protects the upstream screen
It's worth being precise about where the trust boundary sits.
The fix on the Claude Code side sanitizes the permission preview that Claude Code assembles. That's the right fix, and it holds as long as you use the default path.
My setup steps outside it. I consume unattended runs as stream-json, format the requests that need confirmation with my own bot, and relay them to chat. The formatting is my code. So a string that was sanitized upstream gets handed back to me and re-rendered by my renderer. The upstream fix cannot reach inside my renderer.
Segment
Who protects it
State in my setup
Tool input → Claude Code preview
Claude Code (fixed)
✅ Protected
stream-json → my bot's formatting
Me
⚠️ Passed through raw
Bot → chat rendering
The chat client
⚠️ Left to the client
Approval → actual execution
Me
⚠️ Never verified
Prompt injection defense — scrubbing what you feed the model — is a well-worn topic. That protects the model. What's at stake here runs the other direction: the channel from the model to the human. In my setup, nobody owned the job of protecting the approver.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Measured results across four layers: plain relay 0%, control-char stripping 50%, NFKC normalization 0%, escaped rendering 100%
✦A naive non-ASCII rule blocks 40% of my real commit messages. A script allowlist gets 0% false positives at 100% detection
✦A working approval-token implementation that separates what you display from what you verify
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Guessing wasn't going to settle it, so I measured. Six deceptive payloads, four plausible defenses, and a look at where each one stops.
# probe.py — measure how far deceptive characters get through each layerimport unicodedataRLO, PDF_, ZWSP, ZWNJ = "\u202E", "\u202C", "\u200B", "\u200C"# (name, bytes actually passed, what the human sees)CORPUS = [ ("P1 bidi-override", f"rm -rf /tmp/build{RLO}gpj.nohtyp/{PDF_}", "rm -rf /tmp/build/python.jpg"), ("P2 zero-width", f"rm{ZWSP} -rf /{ZWSP}", "rm -rf /"), ("P3 lookalike-quote", 'rm -rf “$HOME/repos”', 'rm -rf "$HOME/repos"'), ("P4 homoglyph", "claude --dangerously-skip-permissiпns", "claude --dangerously-skip-permissions"), ("P5 fake-line", "git status\n✅ Approved by policy\ngit push --force", "git status"), ("P6 zwnj-flag", f"--allow{ZWNJ}-all", "--allow-all"),]def L1_plain(s): # relay as-is return Falsedef L2_strip_ctrl(s): # reject on format characters (category Cf) return any(unicodedata.category(c) == "Cf" for c in s)def L3_nfkc(s): # reject if NFKC normalization changes anything return unicodedata.normalize("NFKC", s) != sdef L4_nonascii(s): # reject on anything outside ASCII, plus newlines return any(ord(c) > 0x7E or c == "\n" for c in s)LAYERS = [("L1 plain relay", L1_plain), ("L2 strip control", L2_strip_ctrl), ("L3 NFKC normalize", L3_nfkc), ("L4 escaped render", L4_nonascii)]counts = {n: 0 for n, _ in LAYERS}for name, raw, _ in CORPUS: for n, fn in LAYERS: counts[n] += fn(raw)n = len(CORPUS)for name, _ in LAYERS: print(f"{name:20} caught {counts[name]}/{n} ({counts[name]/n*100:.0f}%)")
Here's what came back.
Layer
Caught
Rate
Missed
L1 plain relay
0/6
0%
Everything
L2 strip control chars
3/6
50%
Quotes, homoglyph, newline
L3 NFKC normalize
0/6
0%
Everything
L4 escaped render
6/6
100%
Nothing
I read the L3 row twice.
"Just run NFKC over it" had become close to a reflex for me. But NFKC performs compatibility decomposition and recomposition. It does not strip format characters (category Cf). It does not fold curly quotes or Cyrillic toward Latin, because those aren't compatibility-equivalent. If the transform changes nothing, then any check built on comparing before and after finds nothing. Naturally, everything walks through.
Normalization and sanitization are different jobs. The names sound alike; neither covers for the other.
The 50% at L2 is telling too. Dropping control characters still leaves lookalike quotes and homoglyphs, because both are perfectly legitimate characters. They aren't control characters at all.
The naive rule blocked my own commit messages
So take L4, then? It stops everything.
I ran the harmless commands I actually send through the same rule. That's where it fell apart.
Two out of five came back HOLD. A 40% false positive rate.
The two that tripped were the ones containing Japanese — of course they were. "記事を追加" lives outside ASCII, and L4 suspects all of it. My everyday commit messages get treated as hostile.
Since I publish every article as a Japanese and English pair, Japanese commit messages aren't going anywhere. A defense that blocks them is a defense I will disable within a week. Anything that manufactures approval fatigue isn't protection in the long run; it just trains me to click faster.
Switching to a script allowlist
What I needed wasn't "suspect non-ASCII." It was "suspect characters that have no business appearing in this context." Japanese belongs here. Cyrillic has no reason to show up. Neither do format characters or newlines inside a one-line command.
List the scripts you expect, and flag only what falls outside.
import unicodedataDECEPTIVE = { "\u202A":"LRE","\u202B":"RLE","\u202C":"PDF","\u202D":"LRO","\u202E":"RLO", "\u2066":"LRI","\u2067":"RLI","\u2068":"FSI","\u2069":"PDI", "\u200B":"ZWSP","\u200C":"ZWNJ","\u200D":"ZWJ","\uFEFF":"BOM", "“":"LEFT-DQ","”":"RIGHT-DQ","‘":"LEFT-SQ","’":"RIGHT-SQ",}ALLOWED = ("LATIN", "DIGIT", "CJK", "HIRAGANA", "KATAKANA", "IDEOGRAPHIC", "FULLWIDTH")def script_of(ch): try: name = unicodedata.name(ch) except ValueError: return "UNNAMED" return next((a for a in ALLOWED if a in name), "OTHER")def inspect(raw): """Return only the positions worth flagging. Japanese passes.""" hits = [] for i, ch in enumerate(raw): if ch in DECEPTIVE: hits.append((i, DECEPTIVE[ch])) elif ch == "\n": hits.append((i, "LINE FEED")) elif ord(ch) > 0x7E: s = script_of(ch) if s in ("OTHER", "UNNAMED"): hits.append((i, f"UNEXPECTED {s}")) return hits
The code in this article writes control characters as escapes like \u202E for the same reason. Drop the raw characters into the manuscript and the display breaks on the reader's screen. An article about this defense shouldn't be a specimen of the thing it warns about.
Same two sets, straight through.
Rule
Detection (6 crafted)
False positives (5 benign)
Survives contact with reality?
Suspect all non-ASCII (L4)
100%
40%
❌ I'd disable it within days
Script allowlist
100%
0%
✅ Stays quiet in normal use
P4, the homoglyph, stops as "UNEXPECTED OTHER" simply because Cyrillic isn't on the list. I wrote no logic to tell it apart from Latin. Declining to distinguish, and instead declaring what has no reason to appear, turned out to be shorter. Keeping the defense small mattered more to me than the detection rate. Long defenses stop being read.
Separate what you display from what you verify
Everything so far is about display. Fixing display leaves the root untouched: nothing yet guarantees that what I approved is what runs.
So I moved the unit of approval from appearance to identity. Derive a token from the raw bytes, recompute it right before execution, and compare. The rendering is for the human. The token is for the machine.
import hashlibdef safe_view(raw: str) -> str: """For human eyes. Suspicious characters surface as code points.""" out = [] for ch in raw: if ch in DECEPTIVE or ch == "\n" or (ord(ch) > 0x7E and script_of(ch) in ("OTHER", "UNNAMED")): out.append(f"⟪U+{ord(ch):04X}⟫") else: out.append(ch) return "".join(out)def approval_token(raw: str) -> str: """You approve bytes. Not a look.""" return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]def render_request(raw: str) -> dict: hits = inspect(raw) return { "view": safe_view(raw), "token": approval_token(raw), "findings": hits, "verdict": "HOLD" if hits else "SHOW", }def confirm(raw_at_exec: str, approved_token: str) -> bool: """If the bytes changed after approval, it doesn't stand.""" return approval_token(raw_at_exec) == approved_token
Running it:
[HOLD] token=131d185e49635e23 view: rm -rf /tmp/build⟪U+202E⟫gpj.nohtyp/⟪U+202C⟫ hits: RLO@17, PDF@29[HOLD] token=ef2566299d8fc126 view: git status⟪U+000A⟫⟪U+2705⟫ Approved by policy⟪U+000A⟫git push --force hits: LINE FEED@10, UNEXPECTED OTHER@11, LINE FEED@31[SHOW] token=a82299bcae807c0b view: git commit -m 'ok' && git push origin mainexecuted with identical bytes -> Trueexecuted with a ZWSP inserted -> False
The fake "Approved by policy" collapses into visibility. On screen you can see that the reassuring line is nothing but one continuous string with ⟪U+000A⟫ wedged into it.
The last two lines are the point. The approved token came from the bytes of git push origin main. Insert a single zero-width space before execution and the token doesn't match, so nothing stands. No amount of visual trickery survives an identity check. Display defense is for the eyes; the condition for execution lives with the token. Once I had those two tiers, I finally felt fine approving from a train platform.
What tripped me up while wiring it in
Getting this into the relay surfaced a few things.
Don't make HOLD mean "deny." My first pass rejected on HOLD. Legitimate strings with curly quotes died. HOLD means "make it visible and put it in front of a human" — not reject. The judgment stays with the person; the machine only guarantees the view is honest. Get this wrong and you're back to approval fatigue.
Give tokens a lifetime. I had a run get approved and then execute thirty minutes later. A token alone carries no sense of when. I now embed the issue time and expire at fifteen minutes. Expiry mattered more than token length.
Never log the raw bytes. Store the safe view and the token. Write deceptive characters into a log and you've simply moved the attack to the screen where I read logs. Don't pipe a defended string back through an undefended surface.
Don't trust the chat client's rendering. The same string renders differently across clients. That's exactly why the escaping happens server-side before sending. Leave rendering to the client and you inherit as many display specs as you have clients.
Where I draw the line
Being honest about it: none of this substitutes for permission design.
The bigger problem is that a setup where I can approve rm -rf / exists at all. Fixing the display doesn't change the fact that pressing the button lets it through. What I built here secures the field of view at the moment of approval; it doesn't decide what should be approvable. That's the job of a deny-by-default allowlist, and that's the real work.
Even so, I'm glad I added these two tiers. The longer things run unattended, the fewer decisions a human makes — and the more each remaining decision carries. If it's a button I press a handful of times a year, I want what's on screen at that moment to be accurate.
That one-line note in the changelog showed me a hole in my own setup. Reading what a fix declares it protects turned out to be worth more than the fix itself. When someone states the boundary of their coverage, they've also told you that everything outside it is yours.
Start by pushing a single U+202E (RLO) through your own relay and watching what lands on your screen. What to write next becomes obvious from there. That's where I started.
Thank you for reading — I hope some of this proves useful in your own setup.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.