Skip to content

fix(security): approval surfaces show reordering and invisible characters as escapes - #1049

Merged
Juliusolsson05 merged 12 commits into
mainfrom
fix/bidi-approval-spoofing
Sep 20, 2026
Merged

Juliusolsson05 merged 12 commits into
mainfrom
fix/bidi-approval-spoofing

Conversation

@Juliusolsson05

Copy link
Copy Markdown
Owner

Closes #1029, found in #1026's review.

The attack

Trojan Source (CVE-2021-42574). Unicode bidi overrides and isolates reorder the glyphs a browser draws without changing the bytes a shell runs. A prompt-injected model can therefore propose a command that reads as harmless and executes as something else — the canonical demonstration makes a destructive command appear to sit inside a comment:

rm -rf ~/work ‮# this is fine‬

Zero-width characters hide text outright, and a lone \r scrolls the dangerous half of a line out of a <pre>. Nothing in src/ neutralised any of it, and the permission modal is frequently the only place a command is ever shown — so what it renders is the user's evidence.

The fix

One helper, src/shared/text/visibleControls.ts, renders each such code point as a visible marker: ⟨U+202E RLO⟩.

It escapes rather than strips, deliberately: the approval has to show what will actually run, and deleting the characters would make the modal disagree with the command the provider executes — a second, quieter lie. The table names what each character does, because "it is in a range" is not a reason a future reader can check.

Applied to every approval surface:

Surface Field
Claude permission prompt the command
Claude trust dialog the workspace path
Codex approval modal the command
Codex trust dialog the workspace path
OpenCode permission the subject and the command
Grok permission the title

Tests

  • The helper's contract: the canonical attack string, isolates, zero-width characters, a lone CR versus a real CRLF, and several non-Latin scripts (Japanese, Arabic, accented Latin) left byte-identical.
  • The real OpenCode permission modal, driven from its recorded 1.18.30 ask through the real EventDispatcher, with only the command replaced by the attack shape. It asserts the override renders as ⟨U+202E RLO⟩ and that the raw override never reaches the DOM.

Fail-first: the modal test fails with the view's call removed.

npx tsc -b is clean; 89 provider suites pass (the one failure, imageAttachment, is the known local env failure that also fails on origin/main).

🤖 Generated with Claude Code

Juliusolsson05 and others added 12 commits September 19, 2026 19:18
…ters as escapes

Closes #1029, found in #1026's review. Trojan Source (CVE-2021-42574):
Unicode bidi overrides and isolates reorder the glyphs a browser draws
without changing the bytes a shell runs, so a prompt-injected model can
propose a command that READS as harmless and RUNS as something else —
the canonical demonstration makes a destructive command appear to sit
inside a comment. Zero-width characters hide text outright, and a lone
CR scrolls the dangerous half of a line out of a <pre>. Nothing in src/
neutralised any of it, and the permission modal is frequently the only
place a command is ever shown, so what it renders IS the user's
evidence.

One helper, src/shared/text/visibleControls.ts, renders each such code
point as a visible marker (⟨U+202E RLO⟩). It ESCAPES rather than strips:
the approval must show what will actually run, and deleting the
characters would make the modal disagree with the command the provider
executes — a second, quieter lie. Its table names what each character
does, since "it is in a range" is not a reason a reader can check.

Applied to every approval surface: Claude's permission prompt and trust
dialog, Codex's approval modal and trust dialog, OpenCode's permission
subject and command, and Grok's permission title.

Tests: the helper's contract (the canonical attack string, isolates,
zero-width, a lone CR versus a real CRLF, and several non-Latin scripts
left untouched), plus the REAL OpenCode permission modal driven from its
recorded 1.18.30 ask with only the command replaced by the attack shape.
The modal test fails without the change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…y authorisation surface

Codex review of #1049, six findings:

1. High. The hand-picked table was still short by variation selectors,
   tag characters, Mongolian and Khmer controls, the invisible math
   operators and the combining grapheme joiner — each of which makes two
   different commands render identically (review reproduced `./check.sh`
   and `./check.sh<U+FE0F>` executing different files). The rule is now
   Unicode's `Default_Ignorable_Code_Point`, the property that MEANS
   "renders as nothing", with readable names kept for the characters a
   report will actually contain.
2. High. The persistent grant was still spoofable: OpenCode's `always`
   patterns and permission name, Codex's clickable options and reason,
   and Claude's title and option labels all rendered raw. Those fields
   describe the authorisation itself, independently of the command.
3. Medium. "CRLF is just a line ending" is a Windows text assumption,
   and this is a command: bash reads `./check.sh\r\n` as a filename
   ending in CR, and the review executed that file. Every CR is escaped
   now; LF is untouched.
4. Medium. Native consent dialogs were outside the protection —
   workflow-source approval (repository-controlled name and identity)
   and extension install consent (attacker-chosen manifest fields).
5. Medium. Destructive confirmation lists showed model-controlled titles
   and paths raw, so a reordered title misrepresents WHICH session a
   bulk close is about to terminate.
6. Medium. The queued-prompt preview and its dialog, which #1029 names
   explicitly. The escape happens before truncation, so a marker cannot
   be cut in half.

Combining accents stay untouched: they render, and escaping every
combining mark would make ordinary prose unreadable.

Tests: the widened rule (a variation selector, CGJ, Mongolian, Khmer, an
invisible operator, a tag character, a musical control), CRLF, ordinary
accents left alone, and the OpenCode `always` scope escaped in the real
recorded modal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…till raw

Round-2 review. The Unicode table itself checked out against
DerivedCoreProperties (15/17/18, 4,174 code points, no gaps), but coverage
did not:

- OpenCode's WILDCARD grant branch (`always: ['*']`) rendered the permission
  name raw. That branch describes the broadest grant we offer, so its scope
  line is the most profitable thing to spoof; the earlier test exercised the
  pattern branch beside it.
- Codex trimmed BEFORE escaping, and `.trim()` removes CR — so `./check.sh\r`,
  the exact trick this work exists to expose, still rendered as `./check.sh`.
- A single-target close confirmation shows no target list, so its summary is
  the only identity the user sees before authorising a kill. It was raw.
- Bulk close rendered project labels and directory paths raw beside the
  checkboxes that choose what dies.
- Grok rendered provider-supplied option labels and approved plan content raw.
- The extension consent dialog escaped `source` in its heading and then
  interpolated it raw into "Install it only if you trust …".

Three new tests, each failing first: the wildcard grant scope (recorded
1.18.30 ask, replayed through the real dispatcher), the Codex carriage return,
and the single-target close summary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round-3 sweep. The six named gaps were fixed; this closes the thirteen the
reviewer's full sweep then found. All the same defect: text an agent or a
repository authors, rendered raw in the sentence the user reads before
granting something.

P1: Root Agent Code Management named its recipient raw — a title like
`Trusted auditor<U+200B>` renders as a different, trusted agent immediately
before the broadest grant this app offers.

P2: Claude's interactive question picker (header, question, option labels and
descriptions — display only, the resolver payload and the transcript keep the
original bytes); the editor's delete and close-with-unsaved-changes dialogs,
where `report.txt<U+200B>` is a different file; the installed-skill install and
update review, where a repository supplies descriptions, source paths and
file lists; bulk provider switch and the provider-switch picker; rewind.

P3: new-agent project choice, project merge, the OpenCode and Grok question
bodies (reject-only today), and Claude's resume prompt, whose age and token
strings are scraped from its screen between two anchors.

A regression test covers the P1. `tsc -b` clean; the pre-existing
imageAttachment corpus failure is unrelated (it fails identically on main).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	src/renderer/src/features/workspace/ui/NewAgentInDialog.tsx
#	src/renderer/src/features/workspace/ui/ProviderSwitchPickerModal.tsx
…ed text

Round-4 review, two more in the same panel. A repository supplies its own
default branch name, its notices and its warnings, and all three sit in the
lines a user reads before approving an install, an update or an Enable:
`main<U+200B>` reads as `main`, and a warning about `scripts/check.sh<U+200B>`
reads as one about `scripts/check.sh` while the collapsed file list beside it
already showed the real name.

The installed skill's description and source URL are escaped for the same
reason — they are the identity next to Enable, Disable and Remove.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	src/renderer/src/workspace/tile-tree/TileLeaf/QueueStrip.tsx
Round-5 review. The path comes from the provider's own config resolution —
CLAUDE_CONFIG_DIR=~/.claude<U+200B> makes a different directory read as
~/.claude — and it identifies the file the buttons beside it reveal and
OVERWRITE. Both the installation row and the confirm() text are escaped;
every action still uses the target id.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round-6 sweep. Key names and provider names are user-typed and the validator
permits invisible characters, so `production` and `production<U+200B>`
coexist and read identically — in the list and in the confirmation that
destroys a provider's whole key set. Skill deployment paths sit beside the
controls that reveal, remove and forget them, and CLAUDE_CONFIG_DIR can make
a different directory read as ~/.claude.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 7 stopped spot-checking and enumerated every place the app asks a user
to authorise, approve, confirm, delete, install, grant or overwrite — renderer
dialogs, strips, pickers, action rows, and every main-process dialog call
site. Ten were still raw:

- the explorer's own two-click Delete, which for a clean file IS the
  confirmation — the escaped dialog only appears when a dirty buffer is at
  stake;
- the editor conflict strip's Overwrite / Reload / Recreate, identified only
  by the adjacent tab;
- AI Workspace clear and delete, where the names are model-authored;
- extension Reload / Update / Remove rows, where a tier-0 install never
  reaches the native consent dialog at all;
- remote device Revoke, where the client chooses its own name;
- the generated skill and conventions previews read immediately before
  Save & Enable deploys them to every provider;
- the directory picker, because choosing a working directory IS the Codex
  trust decision (ensureCodexProjectTrust runs on it before spawn);
- rewind's prompt list, which is interactive there — a click replaces the
  live pane, so the escape happens at that call site and the read-only
  View Prompts surface is untouched;
- goal-loop and workflow continuation controls, beside Resume / Raise cap /
  Cancel;
- the local template, theme, tab-order and pin pickers.

Conversation rows are escaped per SEGMENT, because the search-match offsets
index the raw string and escaping changes its length.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…r-0 install

Round-8 review.

The editor tab's full-path tooltip, the AI Workspace attachment path,
provenance and description, the extension manifest version and the prompt
template description were each still raw beside their actions.

The working-directory input keeps its raw text — it is the user's own, and
rewriting it while they type would be its own bug — and gains an escaped
read-only echo, rendered ONLY when the path contains something invisible, so
a pasted `/tmp/project<U+200B>` cannot reach "open and trust this directory"
with its identity shown nowhere.

And a tier-0 extension no longer installs in silence. The stance was "the
repo name is the trust decision"; the silence contradicted it, because
nothing ever rendered that name where the user could compare it — the row
that shows the source appears only after the code is installed. A FIRST
install now always asks, with a prompt that says the extension requests no
capabilities. A reload or update re-runs a source already recorded in the
ledger and stays silent, so extension development keeps its loop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e install box

Round-9 review.

`extensions:install` treats its argument as a repo string the user just typed,
so it always shows the consent dialog — that prompt is the only place a pasted
`owner/repo` gets rendered with its invisible characters escaped before that
repo's code runs. The Update button routed through it, which meant every
update of a tier-0 extension prompted again, and a prompt raised on every
rebuild is a prompt people click through.

`extensions:update-github` is the counterpart of `extensions:update-local`:
the renderer names an extension ID, main reads the recorded `owner/repo` out
of its own ledger, and the first-install reason to prompt does not apply. A
manifest requesting capabilities still prompts, on every install of any kind,
because the grant binds to the bytes being published.

`install()` in the settings row loses its `target` parameter with the last
caller that needed it.

Tests drive the registered handlers over the real install pipeline (stubbed
network, real tar, real containment checks, real ledger, real consent gate)
and count the dialogs it actually raised. Both halves of the gate are pinned
by mutation: routing the update as a first install fails the silence
assertion, and dropping `|| firstInstall` fails the typed-install assertion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Juliusolsson05
Juliusolsson05 merged commit 2670705 into main Sep 20, 2026
2 checks passed
@Juliusolsson05
Juliusolsson05 deleted the fix/bidi-approval-spoofing branch September 20, 2026 06:18
Juliusolsson05 added a commit that referenced this pull request Sep 20, 2026
…entory

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(security): bidi/format control characters can spoof the command shown in approval modals

1 participant