Skip to content

Commit c18f4d3

Browse files
committed
fix(desktop): act on adversarial review of the security fixes
Six review agents went through the branch line by line. Several of the fixes were wrong, incomplete, or worse than the finding they closed. terminal:write was gated on the whole channel, which is a functional break, not a fix. That channel carries xterm.js's entire upstream stream, and much of it is not typing: the PTY solicits replies the terminal must answer unprompted — DSR cursor position (p10k/starship emit it every prompt), device attributes, focus reports set by tmux and vim. Now only a payload that can submit (one containing a newline) needs input behind it. Also stated plainly in the code: this is a mitigation. Text without a newline still lands in the line buffer where the user's own Enter submits it, and closing that needs the interactive path off the renderer surface, not a better gate. The panel occlusion gate is reverted outright. Occlusion is driven by any element marked data-native-surface-overlay, which includes tooltips (hover, and hover is deliberately not "deliberate input") and toasts (no input at all), so the common case regressed. Worse, the renderer only ever sets panelSnapshot and never clears it, so withholding a frame shows the PREVIOUS overlay's frame — the exact defect panel.test.ts was written to prevent — while the already-delivered frame stays readable. Net negative on both axes. The credential grant ordering is inverted to exact match. reveal was treated as dominating copy, but copy publishes plaintext to the macOS pasteboard: readable by every process, persisted by clipboard managers past the 30s clear, and synced to other devices by Universal Clipboard. The operations are incomparable. Update downloads are constrained to the release asset prefix, not just to https. The feed rewrites every entry to github.com/simstudioai/sim/releases/download/, so nothing legitimate is excluded — while scheme-only validation still admitted an attacker-hosted DMG that the download dialog walks the user through installing, which is worse than the protocol-handler launch originally fixed. The loopback exemption is dropped with it: no legitimate asset is ever http. State goes to 'error', not 'idle', so a blocked shell is not told it is current. Subresource DNS verdicts cache the promise, not the boolean. Caching only the result left every request arriving before the first lookup settled to start its own, and dns.lookup is getaddrinfo on the four-slot libuv threadpool shared with every fs call in main — a page naming hundreds of hosts could stall the settings write and the credential vault. Also: an eighth copy of the credential-token vocabulary in the browser preload was missed by the original commit while its comment claimed parity, so fill went blind to `current-password webauthn`; the OTP/payment readback zeroed valueLength and told the agent a successful fill was still empty, inviting a doubled code; tagName comparisons are upper-cased for XHTML; DIALOG/VIDEO/AUDIO/EMBED/OBJECT are focusable and no longer report opaque; drags count as input; a backwards clock step no longer satisfies a recency gate; and clearing cache or profile now clears resolved-host verdicts too. The closed-shadow residual is documented rather than closed: a host carrying tabindex or contenteditable still reports safe, and refusing those would block Enter and Space on ordinary <div tabindex="0"> buttons, since a closed root is indistinguishable from no root at all.
1 parent fc9469f commit c18f4d3

16 files changed

Lines changed: 372 additions & 214 deletions

File tree

apps/desktop/src/main/browser-agent/page-functions.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -353,14 +353,16 @@ describe('readActiveElementState', () => {
353353
['a one-time code', 'one-time-code', '123456'],
354354
['a card number', 'cc-number', '4111111111111111'],
355355
['a card security code', 'cc-csc', '737'],
356-
])('withholds %s on readback but keeps the real tag', (_label, token, value) => {
356+
])('withholds %s on readback but still confirms the fill', (_label, token, value) => {
357357
document.body.innerHTML = `<input type="text" autocomplete="${token}" value="${value}" />`
358358
setActiveElement(document, document.querySelector('input'))
359359

360+
// valueLength is kept: without it a successful type reads as "still empty"
361+
// and the agent types the code a second time.
360362
expect(readActiveElementState()).toEqual({
361363
activeElement: 'input',
362364
selectedChars: 0,
363-
valueLength: 0,
365+
valueLength: value.length,
364366
valuePreview: '',
365367
redacted: true,
366368
})

apps/desktop/src/main/browser-agent/page-functions.ts

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -488,13 +488,18 @@ export function readActiveElementState(): unknown {
488488
redacted: true,
489489
}
490490
}
491-
// Reported as the real tag rather than 'password-field': the agent may
492-
// still type here, it just never learns what is already in the field.
491+
// Only the preview is withheld, and the real tag is kept: the agent is allowed
492+
// to fill these, so it still needs the readback this function exists for.
493+
// Zeroing valueLength told it the field was empty after a successful type, and
494+
// the natural next move is to type again — a doubled OTP or card number.
495+
// A length is not a disclosure here; 6 and 16 are properties of the format.
493496
if (isSensitiveValueField(active)) {
497+
const field = active as HTMLInputElement
498+
const current = String(field.value ?? '')
494499
return {
495500
activeElement: active.tagName.toLowerCase(),
496-
selectedChars: 0,
497-
valueLength: 0,
501+
selectedChars: Math.abs((field.selectionEnd ?? 0) - (field.selectionStart ?? 0)),
502+
valueLength: current.length,
498503
valuePreview: '',
499504
redacted: true,
500505
}
@@ -569,19 +574,38 @@ export function activeElementSecrecy(): string {
569574
// `activeElement` unless focus was retargeted out of a shadow tree, which
570575
// makes "not focusable yet focused" the reliable signal. Frames stay out of
571576
// it so the branch below still classifies them.
577+
// Tag compared upper-cased: tagName preserves case outside the HTML
578+
// namespace and is lower-case for HTML elements in an XHTML document, where
579+
// every comparison below would otherwise miss.
580+
const tag = String(active.tagName || '').toUpperCase()
581+
// RESIDUAL, stated rather than papered over: `tabindex` and
582+
// `contenteditable` exempt an element even on a shadow-capable tag, so a
583+
// host carrying either — `<div tabindex="0">` with a closed root — still
584+
// reports 'safe'. A closed root is indistinguishable from no root at all
585+
// (that is what `mode: 'closed'` buys the page), and `<div tabindex="0">`
586+
// buttons and menu items are everywhere, so refusing them would block Enter
587+
// and Space on ordinary pages to close a targeted case. The only reliable
588+
// detector is `attachShadow` throwing, which is destructive. Narrowing this
589+
// needs the driver to stop trusting a page-derived signal, not a better
590+
// guess here.
572591
const focusableItself =
573-
active === document.body ||
592+
active === active.ownerDocument.body ||
574593
active.isContentEditable ||
575594
active.hasAttribute('tabindex') ||
576-
active.tagName === 'INPUT' ||
577-
active.tagName === 'TEXTAREA' ||
578-
active.tagName === 'SELECT' ||
579-
active.tagName === 'BUTTON' ||
580-
active.tagName === 'A' ||
581-
active.tagName === 'AREA' ||
582-
active.tagName === 'SUMMARY' ||
583-
active.tagName === 'IFRAME' ||
584-
active.tagName === 'FRAME'
595+
tag === 'INPUT' ||
596+
tag === 'TEXTAREA' ||
597+
tag === 'SELECT' ||
598+
tag === 'BUTTON' ||
599+
tag === 'A' ||
600+
tag === 'AREA' ||
601+
tag === 'SUMMARY' ||
602+
tag === 'DIALOG' ||
603+
tag === 'VIDEO' ||
604+
tag === 'AUDIO' ||
605+
tag === 'EMBED' ||
606+
tag === 'OBJECT' ||
607+
tag === 'IFRAME' ||
608+
tag === 'FRAME'
585609
if (!shadow && !focusableItself) return 'opaque'
586610
if (active.tagName === 'IFRAME' || active.tagName === 'FRAME') {
587611
let inner: Document | null = null

apps/desktop/src/main/browser-agent/panel.test.ts

Lines changed: 1 addition & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
22

33
vi.mock('electron', () => import('@/test/electron-mock'))
44

5-
import type { WebContents } from 'electron'
65
import { BrowserWindow, WebContentsView } from 'electron'
76
import * as panelModule from '@/main/browser-agent/panel'
8-
import { trackInputActivity } from '@/main/input-activity'
97

108
type PanelModule = typeof import('@/main/browser-agent/panel')
119

@@ -29,29 +27,6 @@ function freshPanel(): PanelModule {
2927

3028
const PANEL_RECT = { x: 400, y: 64, width: 600, height: 800 }
3129

32-
type InputListener = (event: unknown, input: { type: string }) => void
33-
34-
/**
35-
* Registers a window's renderer with the main-process input tracker and returns
36-
* a way to give it a real click. The occlusion snapshot is only handed over to a
37-
* renderer the user has recently driven, so a fixture has to say so explicitly.
38-
*/
39-
function trackWindowInput(win: BrowserWindow) {
40-
const listeners: InputListener[] = []
41-
const contents = win.webContents as unknown as {
42-
on: (channel: string, listener: InputListener) => void
43-
isDestroyed: () => boolean
44-
}
45-
contents.on = (channel, listener) => {
46-
if (channel === 'input-event') listeners.push(listener)
47-
}
48-
contents.isDestroyed = () => false
49-
trackInputActivity(win.webContents as WebContents)
50-
return () => {
51-
for (const listener of listeners) listener({}, { type: 'mouseDown' })
52-
}
53-
}
54-
5530
/** A panel showing one tab, which is the state occlusion applies to. */
5631
function showPanel(panel: PanelModule) {
5732
const win = new BrowserWindow()
@@ -63,15 +38,13 @@ function showPanel(panel: PanelModule) {
6338
ensureInitialTab: () => {},
6439
onViewDetached: () => {},
6540
})
66-
const press = trackWindowInput(win)
67-
press()
6841
panel.setPanelBounds(PANEL_RECT, win)
6942
/** Swaps in another tab's view, as switching tabs does. */
7043
const switchTab = (next: WebContentsView) => {
7144
active = { id: 'tab-2', view: next, pinned: false }
7245
panel.layout()
7346
}
74-
return { win, view, switchTab, press }
47+
return { win, view, switchTab }
7548
}
7649

7750
/** When the view was hidden, in the global mock invocation order. */
@@ -118,38 +91,6 @@ describe('panel occlusion', () => {
11891
expect(sent).toBeLessThan(hiddenAt(view) as number)
11992
})
12093

121-
it('withholds the frame from a renderer the user has not driven', async () => {
122-
const panel = freshPanel()
123-
const { win, view } = showPanel(panel)
124-
// A script-only caller: no real OS input has reached this renderer inside
125-
// the recency window, so the pixels of the agent page are not handed over.
126-
vi.useFakeTimers()
127-
try {
128-
vi.advanceTimersByTime(5_000)
129-
panel.setPanelOccluded(true, win)
130-
await vi.waitFor(() => expect(hiddenAt(view)).toBeDefined())
131-
} finally {
132-
vi.useRealTimers()
133-
}
134-
135-
expect(snapshotSentAt(win)).toBeUndefined()
136-
})
137-
138-
it('still occludes when the frame is withheld, so nothing is left half-applied', async () => {
139-
const panel = freshPanel()
140-
const { win, view } = showPanel(panel)
141-
vi.useFakeTimers()
142-
try {
143-
vi.advanceTimersByTime(5_000)
144-
panel.setPanelOccluded(true, win)
145-
// Hiding must not depend on the snapshot: an empty capture already
146-
// reaches the same path, so this is an existing outcome, not a new one.
147-
await vi.waitFor(() => expect(hiddenAt(view)).toBeDefined())
148-
} finally {
149-
vi.useRealTimers()
150-
}
151-
})
152-
15394
it('stays visible when the overlay closes while the frame is being taken', async () => {
15495
const { win, view } = showPanel(panel)
15596

apps/desktop/src/main/browser-agent/panel.ts

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import { createLogger } from '@sim/logger'
2020
import { getErrorMessage } from '@sim/utils/errors'
2121
import type { BrowserWindow, WebContentsView } from 'electron'
2222
import type { AgentTab } from '@/main/browser-agent/session'
23-
import { hasRecentDeliberateInput } from '@/main/input-activity'
2423

2524
const logger = createLogger('BrowserAgentPanel')
2625

@@ -334,28 +333,6 @@ function capturePanelSnapshot(onSettled?: () => void): void {
334333
// picture of the page, so it goes to the window still showing the
335334
// browser or nowhere at all.
336335
if (panelWindow() !== win || win.isDestroyed()) return
337-
// A frame of the agent browser is content the renderer's own JS cannot
338-
// otherwise read — the view is a separate process composited over the
339-
// window. It is handed over so an overlay can show a placeholder instead
340-
// of a blank gap, and an overlay opens because the user did something. A
341-
// compromised renderer can drive set-panel-bounds, panel-action navigate
342-
// and set-panel-occluded on its own, so without this the pixels of an
343-
// authenticated page could be collected by script alone, bypassing the
344-
// tool-call binding that guards browser_screenshot for exactly this.
345-
//
346-
// Skipping the send is an already-supported outcome: an empty capture
347-
// returns here too, and `finally` still runs the occlusion, so no new
348-
// state is introduced — at worst a placeholder is missing for one frame.
349-
if (!hasRecentDeliberateInput(win.webContents)) {
350-
logger.warn('Withheld browser panel snapshot with no recent user input')
351-
return
352-
}
353-
// Downscale and JPEG-encode before crossing IPC. capturePage returns a
354-
// device-pixel PNG — on a retina half-window that is millions of pixels,
355-
// and toDataURL's PNG encode is synchronous on the main process, so a
356-
// full-size encode stalls every window's input for the frame. This is a
357-
// placeholder shown under a transient overlay, so a downscaled JPEG is
358-
// indistinguishable and an order of magnitude cheaper to encode and send.
359336
const snapshot: BrowserPanelSnapshot = { dataUrl: encodeSnapshot(image), tabId }
360337
win.webContents.send('browser-agent:panel-snapshot', snapshot)
361338
})

apps/desktop/src/main/browser-agent/session.test.ts

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,9 @@ vi.mock('electron', () => import('@/test/electron-mock'))
44

55
import { MAX_BROWSER_TABS } from '@sim/browser-protocol'
66
import { sleep } from '@sim/utils/helpers'
7-
import type { WebContents } from 'electron'
87
import { BrowserWindow, session as electronSession } from 'electron'
98
import * as panel from '@/main/browser-agent/panel'
109
import * as sessionModule from '@/main/browser-agent/session'
11-
import { trackInputActivity } from '@/main/input-activity'
1210

1311
type SessionModule = typeof import('@/main/browser-agent/session')
1412

@@ -36,8 +34,6 @@ interface MockView {
3634
setVisible: ReturnType<typeof vi.fn>
3735
}
3836

39-
type InputListener = (event: unknown, input: { type: string }) => void
40-
4137
function mainWindowMock() {
4238
const win = new BrowserWindow() as unknown as {
4339
contentView: {
@@ -47,20 +43,6 @@ function mainWindowMock() {
4743
webContents: { getZoomFactor?: ReturnType<typeof vi.fn> }
4844
}
4945
win.webContents.getZoomFactor = vi.fn(() => 1)
50-
// The occlusion snapshot is only handed to a renderer the user has recently
51-
// driven, so these fixtures — which stand in for a user with the panel open —
52-
// register with the input tracker and report one real click.
53-
const listeners: InputListener[] = []
54-
const contents = win.webContents as unknown as {
55-
on: (channel: string, listener: InputListener) => void
56-
isDestroyed: () => boolean
57-
}
58-
contents.on = (channel, listener) => {
59-
if (channel === 'input-event') listeners.push(listener)
60-
}
61-
contents.isDestroyed = () => false
62-
trackInputActivity(win.webContents as unknown as WebContents)
63-
for (const listener of listeners) listener({}, { type: 'mouseDown' })
6446
return win as unknown as BrowserWindow
6547
}
6648

apps/desktop/src/main/browser-agent/session.ts

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
import { registerAgentWebContents } from '@/main/browser-agent/registry'
2828
import {
2929
checkAgentUrl,
30+
clearHostVerdictCache,
3031
isBlockedRequestUrl,
3132
isBlockedSubresourceUrl,
3233
subresourceNeedsResolution,
@@ -315,31 +316,45 @@ function configureAgentPartition(ses: Session): void {
315316
// labels differently than expected fails safe into the checked path; see
316317
// subresourceNeedsResolution.
317318
ses.webRequest.onBeforeRequest((details, callback) => {
319+
// Answered exactly once, and never throwing. A throw inside the `then`
320+
// below would otherwise land in the `catch` and answer a second time, and
321+
// by the time an async check settles the request's loader may be gone —
322+
// now the case for most subresources, not just the odd navigation.
323+
let settled = false
324+
const settle = (cancel: boolean) => {
325+
if (settled) return
326+
settled = true
327+
try {
328+
callback({ cancel })
329+
} catch (error) {
330+
logger.warn('Could not answer an agent request', { error: getErrorMessage(error) })
331+
}
332+
}
318333
if (details.resourceType === 'mainFrame' || details.resourceType === 'subFrame') {
319334
void checkAgentUrl(details.url)
320335
.then((guard) => {
321336
if (!guard.ok) {
322337
logger.warn('Blocked agent document navigation to a private host')
323338
}
324-
callback({ cancel: !guard.ok })
339+
settle(!guard.ok)
325340
})
326341
.catch((error) => {
327342
// Fail closed: an unexpected rejection must cancel, never leave the
328343
// request suspended with no callback.
329344
logger.error('Agent SSRF check failed; cancelling request', { error })
330-
callback({ cancel: true })
345+
settle(true)
331346
})
332347
return
333348
}
334349
if (!subresourceNeedsResolution(details.resourceType)) {
335-
callback({ cancel: isBlockedRequestUrl(details.url) })
350+
settle(isBlockedRequestUrl(details.url))
336351
return
337352
}
338353
void isBlockedSubresourceUrl(details.url)
339-
.then((blocked) => callback({ cancel: blocked }))
354+
.then((blocked) => settle(blocked))
340355
.catch((error) => {
341356
logger.error('Agent subresource SSRF check failed; cancelling request', { error })
342-
callback({ cancel: true })
357+
settle(true)
343358
})
344359
})
345360
ses.on('will-download', (_event, item) => {
@@ -1068,6 +1083,9 @@ export function closeSession(): void {
10681083
* pinned tabs, or browsing trail.
10691084
*/
10701085
export async function clearProfileStorage(): Promise<void> {
1086+
// Cached DNS verdicts are part of the browsing trail: without this a wipe
1087+
// leaves up to the TTL of resolved-host classifications behind.
1088+
clearHostVerdictCache()
10711089
closeLiveTabs()
10721090
// Stays true so a later restore cannot re-read the list being erased here.
10731091
pinnedTabsRestored = true
@@ -1114,7 +1132,12 @@ export async function clearAgentData(kinds: readonly BrowserDataKind[]): Promise
11141132
if (storages.length > 0) {
11151133
await ses.clearStorageData({ storages } as Parameters<Session['clearStorageData']>[0])
11161134
}
1117-
if (kinds.includes('cache')) await ses.clearCache()
1135+
if (kinds.includes('cache')) {
1136+
await ses.clearCache()
1137+
// Resolved-host verdicts are a cache too, and a user clearing the cache
1138+
// means all of it.
1139+
clearHostVerdictCache()
1140+
}
11181141
}
11191142

11201143
export function listTabs(): BrowserTabState[] {

0 commit comments

Comments
 (0)