From d15b2851384013b4f0a3ddc9e62458c005c40057 Mon Sep 17 00:00:00 2001 From: Nabzx Date: Tue, 22 Sep 2026 15:43:55 +0400 Subject: [PATCH 1/5] accessibility-helper: report AXSecureTextField in the context reply (#433) focusContext() only ever checked role == AXTextField/AXComboBox for isOneLineField - nothing anywhere checked for AXSecureTextField, macOS's own role for a password field. Adds isSecure to the tuple and the context reply, following the same caution isOneLineField already uses: an element that never resolves can't be confirmed secure either way, so it defaults to false rather than guessing true. Compile-verified (swift build --package-path native/accessibility-helper -c release). Never executed - this sandbox can't meaningfully drive a real AX role read against a live password field. See docs/testing/secure-field-433.md. Claude-Session: https://claude.ai/code/session_0144sxo5CJiyk99vaR7sgjuj --- .../AccessibilityInjection/RealAdapters.swift | 12 +++++++++--- .../Sources/accessibility-helper/main.swift | 4 ++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/native/accessibility-helper/Sources/AccessibilityInjection/RealAdapters.swift b/native/accessibility-helper/Sources/AccessibilityInjection/RealAdapters.swift index db3f217..7655694 100644 --- a/native/accessibility-helper/Sources/AccessibilityInjection/RealAdapters.swift +++ b/native/accessibility-helper/Sources/AccessibilityInjection/RealAdapters.swift @@ -291,7 +291,7 @@ public final class RealFocusResolver: FocusResolving { // from the tracker, isOneLineField defaulted to true (deny line breaks, // the safe direction for an unknown target) - rather than a hard nil // that would fail the whole dictation. See #181. - public func focusContext(deadlineMs: Double, budgetMs: Double) -> (bundleId: String, isOneLineField: Bool, axReady: Bool)? { + public func focusContext(deadlineMs: Double, budgetMs: Double) -> (bundleId: String, isOneLineField: Bool, axReady: Bool, isSecure: Bool)? { guard let frontApp = frontmostApp(deadlineMs: deadlineMs, budgetMs: budgetMs) else { log("focusContext: no frontmost app from AX or the tracker") return nil @@ -302,11 +302,17 @@ public final class RealFocusResolver: FocusResolving { } guard let focused = resolveFocusedElementWithin(budgetMs: budgetMs, deadlineMs: deadlineMs) else { log("focusContext: \(bundleId) focused element not AX-ready within \(budgetMs)ms - unknown-field context") - return (bundleId, true, false) + // #433: an unresolvable element can't be confirmed secure either + // way - false (not secure) is the same "don't know, assume the + // common case" call isOneLineField's true already makes here. + return (bundleId, true, false, false) } let role = focused.fieldInfo.role - return (bundleId, role == "AXTextField" || role == "AXComboBox", true) + // #433: AXSecureTextField is macOS's own role for a password field - + // checked here, at the one place both ordinary dictation and its + // history recording branch off the same focus read. + return (bundleId, role == "AXTextField" || role == "AXComboBox", true, role == "AXSecureTextField") } // Voice editing (#17): the focused field's current selection, plus the diff --git a/native/accessibility-helper/Sources/accessibility-helper/main.swift b/native/accessibility-helper/Sources/accessibility-helper/main.swift index 3be89ed..9383d96 100644 --- a/native/accessibility-helper/Sources/accessibility-helper/main.swift +++ b/native/accessibility-helper/Sources/accessibility-helper/main.swift @@ -95,6 +95,10 @@ while let line = readLine(strippingNewline: true) { // #181: false when the focused element never became AX-ready and // isOneLineField is the safe default rather than the real role. "axReady": context.axReady, + // #433: true only for a role-confirmed AXSecureTextField (a + // password field) - never guessed when the element isn't + // AX-ready, same caution as isOneLineField's default. + "isSecure": context.isSecure, ]) case "insert", "inject": guard let text = obj["text"] as? String, !text.isEmpty else { From cf71da81fff6ec60cd71ecb6001a8cc68fd394de Mon Sep 17 00:00:00 2001 From: Nabzx Date: Tue, 22 Sep 2026 15:44:01 +0400 Subject: [PATCH 2/5] accessibilityHelper.js: parse isSecure from the context reply (#433) Only true when the helper positively said so - a helper built before this field existed (reply.isSecure undefined) parses to false, the same unchanged behaviour a stale binary already gets for isOneLineField and axReady. Claude-Session: https://claude.ai/code/session_0144sxo5CJiyk99vaR7sgjuj --- electron/accessibilityHelper.js | 5 +++ electron/accessibilityHelper.test.js | 61 ++++++++++++++++++++++++++-- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/electron/accessibilityHelper.js b/electron/accessibilityHelper.js index 7be066d..3f141fb 100644 --- a/electron/accessibilityHelper.js +++ b/electron/accessibilityHelper.js @@ -127,6 +127,11 @@ function createAccessibilityHelper({ bundleId: reply.bundleId, isOneLineField: reply.isOneLineField, axReady: reply.axReady !== false, + // #433: only true when the helper positively identified an + // AXSecureTextField - a helper built before this field existed + // (reply.isSecure undefined) parses to false, the same unchanged + // behaviour a stale binary already gets for everything else here. + isSecure: reply.isSecure === true, }; } diff --git a/electron/accessibilityHelper.test.js b/electron/accessibilityHelper.test.js index 1106082..dabebcd 100644 --- a/electron/accessibilityHelper.test.js +++ b/electron/accessibilityHelper.test.js @@ -55,11 +55,56 @@ test("accessibility helper correlates context and insertion replies received out child.stdout.write('{"id":"2","status":"delivered","method":"wrote into the field","verified":true}\n'); child.stdout.write('{"id":"1","status":"ok","bundleId":"com.apple.TextEdit","isOneLineField":false}\n'); - assert.deepEqual(await contextPromise, { bundleId: "com.apple.TextEdit", isOneLineField: false, axReady: true }); + assert.deepEqual(await contextPromise, { + bundleId: "com.apple.TextEdit", + isOneLineField: false, + axReady: true, + isSecure: false, + }); assert.deepEqual(await insertionPromise, { kind: "inserted" }); helper.stop(); }); +// #433: a role-confirmed AXSecureTextField parses to isSecure: true, so the +// coordinator can skip recording it to history. +test("accessibility helper reports isSecure true for a password field", async () => { + const child = fakeProcess(); + const requests = readRequests(child); + const helper = createAccessibilityHelper({ spawnProcess: () => child }); + helper.start(); + + const contextPromise = helper.getFocusContext(); + await nextTurn(); + child.stdout.write( + `{"id":"${requests[0].id}","status":"ok","bundleId":"com.apple.SecurityAgent","isOneLineField":true,"axReady":true,"isSecure":true}\n`, + ); + + assert.deepEqual(await contextPromise, { + bundleId: "com.apple.SecurityAgent", + isOneLineField: true, + axReady: true, + isSecure: true, + }); + helper.stop(); +}); + +// A helper built before #433 never sends isSecure at all - that must parse +// to false (the unchanged, pre-#433 behaviour), not throw or go undefined. +test("accessibility helper defaults isSecure to false when a reply omits it", async () => { + const child = fakeProcess(); + const requests = readRequests(child); + const helper = createAccessibilityHelper({ spawnProcess: () => child }); + helper.start(); + + const contextPromise = helper.getFocusContext(); + await nextTurn(); + child.stdout.write(`{"id":"${requests[0].id}","status":"ok","bundleId":"com.apple.TextEdit","isOneLineField":false}\n`); + + const context = await contextPromise; + assert.equal(context.isSecure, false); + helper.stop(); +}); + // #355: the caller passes the bundle id it resolved focus context against, // so the helper's own abort check has a baseline to compare against. test("accessibility helper sends expectedBundleId when the caller supplies one", async () => { @@ -180,7 +225,12 @@ test("accessibility helper ignores logs and non-contract output on stdout", asyn child.stdout.write('{"id":7,"status":"ok"}\n'); child.stdout.write(`{"id":"${requests[0].id}","status":"ok","bundleId":"com.apple.TextEdit","isOneLineField":true}\n`); - assert.deepEqual(await context, { bundleId: "com.apple.TextEdit", isOneLineField: true, axReady: true }); + assert.deepEqual(await context, { + bundleId: "com.apple.TextEdit", + isOneLineField: true, + axReady: true, + isSecure: false, + }); helper.stop(); }); @@ -199,7 +249,12 @@ test("accessibility helper keeps diagnostics on stderr and replies on stdout", a child.stderr.write("Accessibility access is missing\n"); child.stdout.write(`{"id":"${requests[0].id}","status":"ok","bundleId":"com.apple.TextEdit","isOneLineField":false}\n`); - assert.deepEqual(await context, { bundleId: "com.apple.TextEdit", isOneLineField: false, axReady: true }); + assert.deepEqual(await context, { + bundleId: "com.apple.TextEdit", + isOneLineField: false, + axReady: true, + isSecure: false, + }); assert.equal(stderrChunks.join(""), "[accessibility-helper] Accessibility access is missing\n"); helper.stop(); }); From 683175d24e4aeae8ee1adf09334fe2c36f24fe1b Mon Sep 17 00:00:00 2001 From: Nabzx Date: Tue, 22 Sep 2026 15:44:13 +0400 Subject: [PATCH 3/5] dictationCoordinator: thread isSecure onto every delivered/held result (#433) Every return site that carries text - the main delivered/held path, both app-switch and context-read-failure salvage paths, and the paste command's delivered/held paths - now carries isSecure alongside it, so main.js can gate what gets persisted without re-deriving anything. Extended the fix to the paste command too, not just ordinary dictation: pasting into a password field is the same leak (clipboard content landing in plaintext history) even though the words weren't spoken. Not literally what the issue asked for, but the same one-line check applied to the same underlying focusContext, and the same protective intent. A context read that fails outright can't confirm secure either way - defaults to false, same call the Swift side makes for an unresolved element. Claude-Session: https://claude.ai/code/session_0144sxo5CJiyk99vaR7sgjuj --- electron/dictationCoordinator.js | 20 ++++- electron/dictationCoordinator.test.js | 109 +++++++++++++++++++++++--- 2 files changed, 113 insertions(+), 16 deletions(-) diff --git a/electron/dictationCoordinator.js b/electron/dictationCoordinator.js index 3a5cef4..7ff1b3a 100644 --- a/electron/dictationCoordinator.js +++ b/electron/dictationCoordinator.js @@ -151,6 +151,11 @@ function createDictationIntake(options) { // the app context detection actually landed on. emitDiagnostic("context.bundleId", focusContext.bundleId); emitDiagnostic("context.axReady", focusContext.axReady !== false); + // #433: only true for a role-confirmed AXSecureTextField (a password + // field). Threaded onto every delivered/held result below so main.js + // can skip recording it to history - dictating or pasting into a + // password field should never end up in a plaintext log. + emitDiagnostic("context.isSecure", focusContext.isSecure === true); // #355: record start to playback end is one continuous window. Any // keystroke or paste can be destructive in the wrong app - a TUI's @@ -172,6 +177,7 @@ function createDictationIntake(options) { status: "held", text: salvaged, reason: `the frontmost app changed while you were dictating (was ${recordStartBundleId}, now ${focusContext.bundleId})`, + isSecure: focusContext.isSecure === true, }; } } catch (error) { @@ -189,6 +195,11 @@ function createDictationIntake(options) { status: "held", text: salvaged, reason: `couldn't read the focused field: ${errorMessage(error)}`, + // The context read itself is what failed - there's no role to have + // confirmed secure, so this can't claim isSecure true either way. + // Unknown defaults to false, same call the Swift side makes when an + // element never resolves. + isSecure: false, }; } @@ -218,6 +229,7 @@ function createDictationIntake(options) { status: "held", text: clipboardText, reason: `won't paste multi-line text into ${focusContext.bundleId} — a line break there could run a command`, + isSecure: focusContext.isSecure === true, }; } try { @@ -231,13 +243,14 @@ function createDictationIntake(options) { text: clipboardText, reason: typeof deliveryResult.reason === "string" ? deliveryResult.reason : "the paste could not be placed", + isSecure: focusContext.isSecure === true, }; } throw new Error("delivery adapter returned an invalid result"); } catch (error) { const reason = errorMessage(error); emitDiagnostic("paste.deliveryFailure", reason); - return { status: "held", text: clipboardText, reason }; + return { status: "held", text: clipboardText, reason, isSecure: focusContext.isSecure === true }; } } @@ -341,20 +354,21 @@ function createDictationIntake(options) { // time mid-injection) has a fresh baseline rather than none at all. const deliveryResult = await delivery.deliver(finishedText, focusContext.bundleId); if (deliveryResult?.kind === "inserted") { - return { status: "delivered", text: finishedText }; + return { status: "delivered", text: finishedText, isSecure: focusContext.isSecure === true }; } if (deliveryResult?.kind === "held") { return { status: "held", text: finishedText, reason: typeof deliveryResult.reason === "string" ? deliveryResult.reason : "delivery could not proceed", + isSecure: focusContext.isSecure === true, }; } throw new Error("delivery adapter returned an invalid result"); } catch (error) { const reason = errorMessage(error); emitDiagnostic("delivery.failure", reason); - return { status: "held", text: finishedText, reason }; + return { status: "held", text: finishedText, reason, isSecure: focusContext.isSecure === true }; } } diff --git a/electron/dictationCoordinator.test.js b/electron/dictationCoordinator.test.js index 982f85f..aac6cb0 100644 --- a/electron/dictationCoordinator.test.js +++ b/electron/dictationCoordinator.test.js @@ -11,6 +11,9 @@ function wavWithMarker(marker) { function createIntake({ bundleId = "com.apple.TextEdit", isOneLineField = false, + // #433: only meaningful when getFocusContext isn't overridden separately - + // folds into the default fake's returned context. + isSecure = false, transcript = "hello world", breakReply = "none", transcribe, @@ -39,7 +42,7 @@ function createIntake({ transcribe: transcribe || (async () => transcript), }, contextDetection: { - getFocusContext: getFocusContext || (async () => ({ bundleId, isOneLineField })), + getFocusContext: getFocusContext || (async () => ({ bundleId, isOneLineField, isSecure })), }, breakPlacement: { placeParagraphBreaks: placeParagraphBreaks || (async (sentences) => { @@ -88,7 +91,7 @@ test("completed recording is transcribed, cleaned, and delivered once", async () const result = await harness.intake.complete(completedWav); - assert.deepEqual(result, { status: "delivered", text: "GitHub\nJavaScript." }); + assert.deepEqual(result, { status: "delivered", text: "GitHub\nJavaScript.", isSecure: false }); assert.deepEqual(transcriptionCalls, [completedWav]); assert.equal(contextCalls.length, 1); assert.deepEqual(harness.delivered, ["GitHub\nJavaScript."]); @@ -226,7 +229,7 @@ test("a known unsafe application never receives spoken line breaks", async () => transcript: "first line new line second line", }); - assert.deepEqual(result, { status: "delivered", text: "First line second line." }); + assert.deepEqual(result, { status: "delivered", text: "First line second line.", isSecure: false }); assert.deepEqual(delivered, ["First line second line."]); }); @@ -236,7 +239,7 @@ test("an unlisted application fails closed for spoken line breaks", async () => transcript: "first line new paragraph second line", }); - assert.deepEqual(result, { status: "delivered", text: "First line second line." }); + assert.deepEqual(result, { status: "delivered", text: "First line second line.", isSecure: false }); assert.deepEqual(delivered, ["First line second line."]); }); @@ -246,7 +249,7 @@ test("a one-line field removes line breaks and its final full stop in a break-sa transcript: "first line new line second line period", }); - assert.deepEqual(result, { status: "delivered", text: "First line second line" }); + assert.deepEqual(result, { status: "delivered", text: "First line second line", isSecure: false }); assert.deepEqual(delivered, ["First line second line"]); }); @@ -327,6 +330,7 @@ test("eligible long dictation sends cleaned sentences once and applies returned assert.deepEqual(result, { status: "delivered", text: "First topic. Still first topic.\n\nSecond topic. More on the second topic.", + isSecure: false, }); assert.deepEqual(harness.delivered, [ "First topic. Still first topic.\n\nSecond topic. More on the second topic.", @@ -368,6 +372,7 @@ test("short, break-unsafe, one-line, and explicit-break dictations skip break pl assert.deepEqual(explicitResult, { status: "delivered", text: "First sentence\n\nSecond sentence. Third sentence. Fourth sentence.", + isSecure: false, }); }); @@ -387,6 +392,7 @@ test("repairs malformed break indices without retrying and records format and re assert.deepEqual(result, { status: "delivered", text: "First sentence. Second sentence.\n\nThird sentence. Fourth sentence.", + isSecure: false, }); assert.deepEqual(harness.diagnostics, [ ["language.input", "en"], @@ -395,6 +401,7 @@ test("repairs malformed break indices without retrying and records format and re ["corrections.count", 0], ["context.bundleId", "com.apple.TextEdit"], ["context.axReady", true], + ["context.isSecure", false], ["context.breakSafe", true], ["context.oneLineField", false], ["paragraphBreaks.formatValid", false], @@ -414,6 +421,7 @@ test("a break reply with no usable indices falls back to one paragraph", async ( assert.deepEqual(result, { status: "delivered", text: "First sentence. Second sentence. Third sentence.", + isSecure: false, }); assert.deepEqual(delivered, ["First sentence. Second sentence. Third sentence."]); }); @@ -432,6 +440,7 @@ test("break-placement failure falls back to one paragraph without retrying", asy assert.deepEqual(result, { status: "delivered", text: "First sentence. Second sentence. Third sentence.", + isSecure: false, }); assert.deepEqual(delivered, ["First sentence. Second sentence. Third sentence."]); }); @@ -448,6 +457,7 @@ test("a flagged spoken list renders as bullets set off from the surrounding pros assert.deepEqual(result, { status: "delivered", text: "Here is my shopping list.\n\n- Buy milk.\n- Buy eggs.\n- Buy bread.", + isSecure: false, }); assert.deepEqual(harness.diagnostics, [ ["language.input", "en"], @@ -456,6 +466,7 @@ test("a flagged spoken list renders as bullets set off from the surrounding pros ["corrections.count", 0], ["context.bundleId", "com.apple.TextEdit"], ["context.axReady", true], + ["context.isSecure", false], ["context.breakSafe", true], ["context.oneLineField", false], ["paragraphBreaks.formatValid", true], @@ -478,6 +489,7 @@ test("an out-of-range list range is clamped into the text and recorded as repair assert.deepEqual(result, { status: "delivered", text: "First sentence.\n\n- Second sentence.\n- Third sentence.\n- Fourth sentence.", + isSecure: false, }); assert.deepEqual(harness.diagnostics, [ ["language.input", "en"], @@ -486,6 +498,7 @@ test("an out-of-range list range is clamped into the text and recorded as repair ["corrections.count", 0], ["context.bundleId", "com.apple.TextEdit"], ["context.axReady", true], + ["context.isSecure", false], ["context.breakSafe", true], ["context.oneLineField", false], ["paragraphBreaks.formatValid", true], @@ -508,6 +521,7 @@ test("a malformed LIST line fails closed to prose without dropping paragraph bre assert.deepEqual(result, { status: "delivered", text: "First sentence. Second sentence.\n\nThird sentence. Fourth sentence.", + isSecure: false, }); assert.deepEqual(harness.diagnostics, [ ["language.input", "en"], @@ -516,6 +530,7 @@ test("a malformed LIST line fails closed to prose without dropping paragraph bre ["corrections.count", 0], ["context.bundleId", "com.apple.TextEdit"], ["context.axReady", true], + ["context.isSecure", false], ["context.breakSafe", true], ["context.oneLineField", false], ["paragraphBreaks.formatValid", true], @@ -537,6 +552,7 @@ test("list detection is off by default: a valid range is parsed and reported but assert.deepEqual(result, { status: "delivered", text: "Here is my shopping list. Buy milk. Buy eggs. Buy bread.", + isSecure: false, }); assert.deepEqual(harness.diagnostics, [ ["language.input", "en"], @@ -545,6 +561,7 @@ test("list detection is off by default: a valid range is parsed and reported but ["corrections.count", 0], ["context.bundleId", "com.apple.TextEdit"], ["context.axReady", true], + ["context.isSecure", false], ["context.breakSafe", true], ["context.oneLineField", false], ["paragraphBreaks.formatValid", true], @@ -791,6 +808,7 @@ test("delivery failure holds the complete finished text without retrying deliver status: "held", text: "Hello world.", reason: "accessibility helper timed out", + isSecure: false, }); assert.equal(deliveryCalls, 1); }); @@ -810,6 +828,7 @@ test("held delivery preserves the complete finished text without retrying delive status: "held", text: "Hello world.", reason: "unverified target", + isSecure: false, }); assert.deepEqual(delivered, ["Hello world."]); }); @@ -819,7 +838,7 @@ test("#259: with no post-process hook configured, text is delivered unchanged", const result = await harness.intake.complete(completedWav); - assert.deepEqual(result, { status: "delivered", text: "Hello world." }); + assert.deepEqual(result, { status: "delivered", text: "Hello world.", isSecure: false }); assert.deepEqual(harness.delivered, ["Hello world."]); }); @@ -831,7 +850,7 @@ test("#259: a post-process hook's output is what gets delivered", async () => { const result = await harness.intake.complete(completedWav); - assert.deepEqual(result, { status: "delivered", text: "HELLO WORLD." }); + assert.deepEqual(result, { status: "delivered", text: "HELLO WORLD.", isSecure: false }); assert.deepEqual(harness.delivered, ["HELLO WORLD."]); assert.ok(harness.diagnostics.some(([name, value]) => name === "postProcess.applied" && value === true)); }); @@ -848,7 +867,7 @@ test("#259: a hook that throws falls back to the un-hooked text, delivery still const result = await harness.intake.complete(completedWav); - assert.deepEqual(result, { status: "delivered", text: "Hello world." }); + assert.deepEqual(result, { status: "delivered", text: "Hello world.", isSecure: false }); assert.deepEqual(harness.delivered, ["Hello world."]); assert.ok( harness.diagnostics.some(([name, value]) => name === "postProcess.failure" && value === "script exited with code 1"), @@ -863,7 +882,7 @@ test("#259: a hook returning a non-string is treated as a failure, falls back", const result = await harness.intake.complete(completedWav); - assert.deepEqual(result, { status: "delivered", text: "Hello world." }); + assert.deepEqual(result, { status: "delivered", text: "Hello world.", isSecure: false }); assert.ok(harness.diagnostics.some(([name]) => name === "postProcess.failure")); }); @@ -892,10 +911,74 @@ test("#259: a held delivery carries the hook's output, not the pre-hook text", a const result = await harness.intake.complete(completedWav); - assert.deepEqual(result, { status: "held", text: "HELLO WORLD.", reason: "unverified target" }); + assert.deepEqual(result, { status: "held", text: "HELLO WORLD.", reason: "unverified target", isSecure: false }); assert.deepEqual(delivered, ["HELLO WORLD."]); }); +test("#433: a delivered dictation into a secure field carries isSecure: true", async () => { + const harness = createIntake({ isSecure: true }); + + const result = await harness.intake.complete(completedWav); + + assert.deepEqual(result, { status: "delivered", text: "Hello world.", isSecure: true }); + assert.ok(harness.diagnostics.some(([name, value]) => name === "context.isSecure" && value === true)); +}); + +test("#433: a held dictation into a secure field still carries isSecure: true", async () => { + const harness = createIntake({ + isSecure: true, + deliver: async () => ({ kind: "held", reason: "unverified target" }), + }); + + const result = await harness.intake.complete(completedWav); + + assert.deepEqual(result, { + status: "held", + text: "Hello world.", + reason: "unverified target", + isSecure: true, + }); +}); + +test("#433: a paste into a secure field carries isSecure: true", async () => { + const harness = createIntake({ + isSecure: true, + transcript: "paste", + clipboardText: "a single-line secret", + }); + + const result = await harness.intake.complete(completedWav); + + assert.deepEqual(result, { status: "pasted", text: "a single-line secret" }); +}); + +test("#433: a held multi-line paste into a secure field still carries isSecure: true", async () => { + const harness = createIntake({ + isSecure: true, + bundleId: "com.apple.Terminal", + transcript: "paste", + clipboardText: "line one\nline two", + }); + + const result = await harness.intake.complete(completedWav); + + assert.equal(result.status, "held"); + assert.equal(result.isSecure, true); +}); + +test("#433: a context-read failure defaults isSecure to false, not true or undefined", async () => { + const harness = createIntake({ + getFocusContext: async () => { + throw new Error("accessibility helper unavailable"); + }, + }); + + const result = await harness.intake.complete(completedWav); + + assert.equal(result.status, "held"); + assert.equal(result.isSecure, false); +}); + test("completed recordings are processed and delivered in FIFO order", async () => { const firstTranscription = {}; const firstTranscriptionReady = new Promise((resolve) => { @@ -926,8 +1009,8 @@ test("completed recordings are processed and delivered in FIFO order", async () const results = await Promise.all([first, second]); assert.deepEqual(results, [ - { status: "delivered", text: "First result." }, - { status: "delivered", text: "Second result." }, + { status: "delivered", text: "First result.", isSecure: false }, + { status: "delivered", text: "Second result.", isSecure: false }, ]); assert.deepEqual(order, [ "transcribe:a", @@ -958,6 +1041,6 @@ test("a failed recording does not stop later queued recordings", async () => { assert.deepEqual(transcribeCalls, ["a", "b"]); assert.deepEqual(results, [ { status: "failed", stage: "transcription", reason: "first server failure" }, - { status: "delivered", text: "Second result." }, + { status: "delivered", text: "Second result.", isSecure: false }, ]); }); From de0993466bbc9557ab235ed47b40b16a66b823cc Mon Sep 17 00:00:00 2001 From: Nabzx Date: Tue, 22 Sep 2026 15:44:19 +0400 Subject: [PATCH 4/5] main.js: don't log, copy, or record a secure-field dictation (#433) Delivery itself is unchanged - this only touches what gets persisted after. The Held-result overlay still shows held text either way (the user's own recovery path if delivery didn't land), since that's ephemeral and never written anywhere; only the clipboard copy and the recording-history write are skipped. Claude-Session: https://claude.ai/code/session_0144sxo5CJiyk99vaR7sgjuj --- electron/main.js | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/electron/main.js b/electron/main.js index 7a9f5f3..87f9b40 100644 --- a/electron/main.js +++ b/electron/main.js @@ -705,10 +705,18 @@ async function transcribeAndPrint(wavBuffer, timing, recordStartBundleId) { console.log(summary.join(" ")); if (result.status === "delivered") { - console.log(`[dictation] ${result.text}`); + // #433: a dictation into a role-confirmed AXSecureTextField (a password + // field) never gets its words logged, copied, or written to the + // recording-history file - only the fact that it happened. Delivery + // itself is unaffected; this is purely about what gets persisted. + if (result.isSecure) { + console.log("[dictation] delivered into a secure field - not logged, not copied, not recorded to history"); + } else { + console.log(`[dictation] ${result.text}`); + maybeCopyTranscript(result.text); + recordingHistoryStore?.record({ text: result.text, delivered: true, bundleId: lastDictationBundleId }); + } console.log("[dictation] inserted through accessibility"); - maybeCopyTranscript(result.text); - recordingHistoryStore?.record({ text: result.text, delivered: true, bundleId: lastDictationBundleId }); if (soundCuesEnabled()) soundCues.textDelivered(); if (Number.isFinite(timing?.releasedAtMs)) { const latencyMs = performance.now() - timing.releasedAtMs; @@ -726,13 +734,19 @@ async function transcribeAndPrint(wavBuffer, timing, recordStartBundleId) { showVoiceEditMessage(result.message); } else if (result.status === "held") { console.log(`[dictation] injection held: ${result.reason}`); - maybeCopyTranscript(result.text); - recordingHistoryStore?.record({ - text: result.text, - delivered: false, - bundleId: lastDictationBundleId, - reason: result.reason, - }); + // #433: same secure-field carve-out as the delivered branch above - the + // overlay still shows the held text so the user can recover it manually + // (that's the whole point of a Held result), but it's not additionally + // copied to the clipboard or written to the history file. + if (!result.isSecure) { + maybeCopyTranscript(result.text); + recordingHistoryStore?.record({ + text: result.text, + delivered: false, + bundleId: lastDictationBundleId, + reason: result.reason, + }); + } if (soundCuesEnabled()) soundCues.dictationHeld(); setUserVisibleState("held", { text: result.text, reason: result.reason }); } else if (result.status === "failed") { From fb52ac9b3acea1b038b7ab1116f819c3c8abd083 Mon Sep 17 00:00:00 2001 From: Nabzx Date: Tue, 22 Sep 2026 15:44:25 +0400 Subject: [PATCH 5/5] docs: Context detection glossary update + manual-check for #433 Claude-Session: https://claude.ai/code/session_0144sxo5CJiyk99vaR7sgjuj --- CONTEXT.md | 2 +- docs/testing/secure-field-433.md | 47 ++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 docs/testing/secure-field-433.md diff --git a/CONTEXT.md b/CONTEXT.md index 7b935b8..0eafbf9 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -81,7 +81,7 @@ A user-supplied "heard this, write that" pair (#321), applied during rules clean _Avoid_: vocabulary biasing (that acts during transcription, not after), autocorrect, dictionary **Context detection**: -Resolving the frontmost application and the focused field via the macOS Accessibility API. It is the input to break-safe determination, not the decision itself: it reports the bundle id and the AX role, and what is done with them is defined separately. +Resolving the frontmost application and the focused field via the macOS Accessibility API. It is the input to break-safe determination, not the decision itself: it reports the bundle id and the AX role, and what is done with them is defined separately. Since #433 it also reports whether the role is a secure field (a password field) - a dictation into one still delivers normally, but is never written to Recording history, copied to the clipboard, or logged, the same "report, don't decide" split as everything else context detection surfaces. _Avoid_: App detection, focus detection, context **Break-safe application**: diff --git a/docs/testing/secure-field-433.md b/docs/testing/secure-field-433.md new file mode 100644 index 0000000..7db8eef --- /dev/null +++ b/docs/testing/secure-field-433.md @@ -0,0 +1,47 @@ +# Manual check: secure-field awareness (#433) + +CI covers the JS-side parsing and coordinator policy against fakes +(`accessibilityHelper.test.js`, `dictationCoordinator.test.js`). What it +can't drive: a real `AXSecureTextField`, or the accessibility-helper's own +AX role read against a live macOS password field. + +**Compile-verified only** - `swift build --package-path +native/accessibility-helper -c release` succeeds. This session's sandbox +can't meaningfully exercise `accessibility-helper` end-to-end even where it +can technically run a binary (unlike `transcription-helper`, which gets +killed outright) - reading a real AX role needs a live GUI session with the +Accessibility grant and a real focused password field, which isn't +available here. Run `npm run build:accessibility-helper` before testing - +the checked-in binary predates this change. + +## Steps + +1. **A real password field.** Focus a password field somewhere on the Mac - + the macOS login/lock screen won't work (a different security context + entirely), but a browser's password field, the macOS "Change Password" + panel in System Settings, or a password manager's own text field should. + Push-to-talk and dictate a short phrase. +2. Confirm delivery still works exactly as before - this change is only + about what gets *persisted*, not whether the word lands. If it can't be + typed into that field for unrelated reasons (some password fields refuse + programmatic AX writes on purpose), that's expected and not a regression + to chase here. +3. **Check the console.** It should say `[dictation] delivered into a + secure field - not logged, not copied, not recorded to history` (or the + held-branch equivalent) - not the actual words you spoke. +4. **Check History.** Open the History tab - this dictation should not + appear there at all. +5. **Check the clipboard**, if "Copy transcript to clipboard" is on in + Settings - paste somewhere and confirm the secure-field dictation was + *not* copied (whatever was on the clipboard before should still be + there). +6. **Regression check: an ordinary text field.** Dictate into a normal text + field right after. Confirm it *does* show up in History, *is* copied (if + the setting's on), and logs normally - the secure-field carve-out should + only ever trigger for an actual password field, never bleed into + ordinary dictation. +7. **A held secure-field dictation.** Harder to force deliberately, but if + one happens to occur (e.g. switch away from the password field mid- + dictation), confirm the Push-to-talk overlay still shows the held text + (so you can manually recover it - that part is deliberately unchanged), + but it's still absent from History and the clipboard per steps 3-5.