-
Notifications
You must be signed in to change notification settings - Fork 3
feat(dictation): warn when macOS Secure Input blocks paste #267
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2811028
2c68afe
a779a6c
943827f
4ed9701
55e3f48
d4edd16
4807bd2
adf36bf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| # Dictation Secure Input warning — 2026-09-27 | ||
|
|
||
| Branch `feature/dictation-secure-input`; plan `docs/plans/dictation-secure-input-plan.md`. | ||
|
|
||
| - `pasteTranscript` (main/services/dictation-paste.ts) takes an injectable | ||
| `isSecureInputActive`. Order: Accessibility check → Secure Input probe → atomic | ||
| paste. Active → clipboard + `{ outcome: "copied", reason: "secure-input" }`. | ||
| Probe errors preserve the transcript and skip synthetic paste. | ||
| - Live detector uses documented Carbon `IsSecureEventInputEnabled()` through | ||
| JXA. The prior investigation used the incorrect `IsSecureEventInput` symbol. | ||
| Verified the documented API is exported in the installed SDK and callable. | ||
| - The atomic transaction checks the target AXValue after the keystroke; absent | ||
| evidence of insertion, it reports copied and retains the transcript. This | ||
| covers Secure Input activation during the focus-check delay. | ||
| - The detector reports only the boolean condition; the UI does not attribute it to an app. | ||
| - `DictationCopiedReason` lives in `renderer/shared/dictation.ts`. The pill's copied | ||
| result renders through `renderer/pill/pill-copied-notice.tsx`. The coordinator | ||
| holds a secure-input result for `WARNING_HIDE_DELAY_MS` (4 s). | ||
| - No Remote protocol, iOS, or Android impact: the pill state is desktop-only IPC. | ||
|
|
||
| Review validation: 15 focused paste/pill tests pass, including a process-owned enable/disable cycle, live Carbon probe, and AppleScript compilation. CI test inventory now registers the pill test. | ||
|
|
||
| The JXA probe explicitly binds `IsSecureEventInputEnabled` as a no-argument boolean function, avoiding reliance on OS BridgeSupport metadata. The plan index and PR description now match the Carbon detector and conservative copy fallback. | ||
|
|
||
| Independent review: reading the original AXValue is optional, so text controls without an accessible value still receive a guarded paste attempt. An unconfirmed result or transport error says “Check the field — transcript copied.” It never instructs a second paste after a possibly successful attempt; the prior clipboard is restored only after confirmed delivery. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| # Dictation Secure Input Warning | ||
|
|
||
| Status: Implemented for review (`feature/dictation-secure-input`). | ||
|
|
||
| Source: Handy parity tracker, P0 — "dictation paste silently fails while macOS | ||
| Secure Event Input is active". | ||
|
|
||
| ## Problem | ||
|
|
||
| macOS Secure Event Input (password fields, a terminal's Secure Keyboard Entry, | ||
| some password managers) drops synthetic keystrokes from other processes. The | ||
| atomic dictation paste (`main/services/dictation-paste.ts`) still reported | ||
| `pasted`, so the pill said "Pasted" while nothing arrived. | ||
|
|
||
| ## Detection | ||
|
|
||
| - The documented Carbon `IsSecureEventInputEnabled()` API is exported and callable | ||
| through JXA. The original investigation used the incorrect `IsSecureEventInput` | ||
| symbol and fell back to an undocumented session dictionary key. | ||
| - Detection now uses Carbon with a bounded timeout. A live test enables and disables | ||
| a process-owned Secure Input claim, verifying the probe follows both transitions. | ||
| - Clipboard restoration requires AXValue evidence of insertion, keeping the transcript | ||
| copied if Secure Input changes during focus revalidation or delivery is uncertain. | ||
|
|
||
| ## Behavior | ||
|
|
||
| 1. Accessibility missing → existing "allow Accessibility" copy (probe skipped). | ||
| 2. Secure Input active → transcript written to the clipboard, no keystroke, and a | ||
| `copied` result with reason `secure-input`. | ||
| 3. Probe failure or unexpected output → logged; transcript stays copied. | ||
| 4. Clipboard restoration requires AXValue evidence of insertion; uncertain | ||
| delivery leaves the transcript available for manual paste. | ||
|
|
||
| The pill shows a warning-tone shield icon, "Secure Input blocked paste", and | ||
| "Transcript copied — press ⌘V to paste." A screen-reader-only sentence explains | ||
| the cause. The result stays visible for 4 s instead of 1.2 s. | ||
|
|
||
| ## Tests | ||
|
|
||
| - `main/services/dictation-paste.test.ts`: injectable detector (active, failing, | ||
| precedence), probe output mapping, live darwin probe. | ||
| - `main/services/dictation-coordinator.test.ts`: reason reaches the pill and the | ||
| hide delay outlasts a normal paste. | ||
| - `renderer/pill/pill-copied-notice.test.tsx`: rendered warning and fallbacks. | ||
|
|
||
| ## Follow-ups | ||
|
|
||
| - Physical acceptance on supported macOS releases remains useful; the atomic | ||
| transaction now preserves transcripts when insertion cannot be confirmed. | ||
| - Optionally surface the condition in Settings → Dictation diagnostics. | ||
|
|
||
| Review refinement: a missing/non-string AXValue does not prevent a paste attempt after focus validation. If delivery cannot be confirmed (including text normalization), the pill asks the user to check the field and preserves the transcript rather than instructing a second paste. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,7 +7,10 @@ import test from "node:test"; | |
| import { promisify } from "node:util"; | ||
| import { | ||
| ATOMIC_PASTE_SCRIPT, | ||
| detectMacSecureInput, | ||
| pasteTranscript, | ||
| runJxa, | ||
| SECURE_INPUT_PROBE_SCRIPT, | ||
| type PasteDeps, | ||
| } from "./dictation-paste.js"; | ||
|
|
||
|
|
@@ -21,6 +24,7 @@ function harness(overrides: Partial<PasteDeps> = {}) { | |
| clipboard = text; | ||
| }, | ||
| isAccessibilityTrusted: () => true, | ||
| isSecureInputActive: async () => false, | ||
| pasteWithPreservedClipboard: async (text) => { | ||
| pastedText = text; | ||
| return true; | ||
|
|
@@ -38,6 +42,14 @@ test("native paste transaction preserves all pasteboard representations and rech | |
| assert.match(ATOMIC_PASTE_SCRIPT, /quietWindow/); | ||
| assert.match(ATOMIC_PASTE_SCRIPT, /is not transcriptText then return "pasted"/); | ||
| assert.match(ATOMIC_PASTE_SCRIPT, /set the clipboard to previousClipboard/); | ||
| assert.match( | ||
| ATOMIC_PASTE_SCRIPT, | ||
| /deliveredValue is originalValue or deliveredValue does not contain transcriptText then return "copied"/, | ||
| ); | ||
| assert.ok( | ||
| ATOMIC_PASTE_SCRIPT.indexOf("deliveredValue is originalValue") < | ||
| ATOMIC_PASTE_SCRIPT.indexOf("set the clipboard to previousClipboard"), | ||
| ); | ||
|
Comment on lines
+45
to
+52
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2 · logic] Source-grep test assertions lock defective control flow and violate repository test rules Growing assert.match assertions against ATOMIC_PASTE_SCRIPT violates the AGENTS.md rule against source-grepping tests and locks the defective control flow where deliveredValue is originalValue immediately exits. Repair: Remove the string-matching assertions against ATOMIC_PASTE_SCRIPT and rely on behavioral tests for clipboard preservation and delivery outcome. |
||
| }); | ||
|
|
||
| test( | ||
|
|
@@ -95,10 +107,25 @@ test("focus changes degrade to the clipboard result returned by the native trans | |
| assert.deepEqual(await pasteTranscript("hello world", subject.deps), { | ||
| outcome: "copied", | ||
| reason: "paste-unavailable", | ||
| message: "Copied — the original text field was no longer focused.", | ||
| message: "Check the field — transcript copied.", | ||
| }); | ||
| }); | ||
|
|
||
| test("an unconfirmed paste never tells the user to insert the transcript again", async () => { | ||
| let textMayAlreadyBeInserted = false; | ||
| const subject = harness({ | ||
| pasteWithPreservedClipboard: async () => { | ||
| textMayAlreadyBeInserted = true; | ||
| return false; | ||
| }, | ||
| }); | ||
| const result = await pasteTranscript("Smart quotes may transform this text", subject.deps); | ||
| assert.equal(textMayAlreadyBeInserted, true); | ||
| assert.equal(result.outcome, "copied"); | ||
| assert.equal(result.message, "Check the field — transcript copied."); | ||
| assert.doesNotMatch(result.message ?? "", /press|⌘V|couldn.t paste/i); | ||
| }); | ||
|
|
||
| test("paste failures leave the transcript on the clipboard instead of throwing", async () => { | ||
| const subject = harness({ | ||
| pasteWithPreservedClipboard: async () => { | ||
|
|
@@ -108,7 +135,90 @@ test("paste failures leave the transcript on the clipboard instead of throwing", | |
| assert.deepEqual(await pasteTranscript("hello world", subject.deps), { | ||
| outcome: "copied", | ||
| reason: "paste-unavailable", | ||
| message: "Copied — Aiden couldn’t paste into the focused app.", | ||
| message: "Check the field — transcript copied.", | ||
| }); | ||
| assert.equal(subject.clipboard(), "hello world"); | ||
| }); | ||
|
|
||
| test("active Secure Input keeps the transcript on the clipboard without sending a keystroke", async () => { | ||
| let attempts = 0; | ||
| const subject = harness({ | ||
| isSecureInputActive: async () => true, | ||
| pasteWithPreservedClipboard: async () => { | ||
| attempts += 1; | ||
| return true; | ||
| }, | ||
| }); | ||
| const result = await pasteTranscript("my secret note", subject.deps); | ||
| assert.equal(result.outcome, "copied"); | ||
| assert.equal(result.reason, "secure-input"); | ||
| assert.match(result.message ?? "", /⌘V/); | ||
| assert.equal(subject.clipboard(), "my secret note"); | ||
| assert.equal(attempts, 0); | ||
| }); | ||
|
|
||
| test("missing Accessibility access takes precedence over Secure Input detection", async () => { | ||
| let probes = 0; | ||
| const subject = harness({ | ||
| isAccessibilityTrusted: () => false, | ||
| isSecureInputActive: async () => { | ||
| probes += 1; | ||
| return true; | ||
| }, | ||
| }); | ||
| const result = await pasteTranscript("hello world", subject.deps); | ||
| assert.equal(result.reason, "accessibility-required"); | ||
| assert.equal(probes, 0); | ||
| }); | ||
|
|
||
| test("a failed Secure Input probe preserves the transcript without attempting paste", async () => { | ||
| const logged: string[] = []; | ||
| const subject = harness({ | ||
| isSecureInputActive: async () => { | ||
| throw new Error("osascript timed out"); | ||
| }, | ||
| log: (message) => logged.push(message), | ||
| }); | ||
| assert.equal((await pasteTranscript("hello world", subject.deps)).outcome, "copied"); | ||
| assert.equal(subject.pastedText(), ""); | ||
| assert.equal(subject.clipboard(), "hello world"); | ||
| assert.equal(logged.length, 1); | ||
| }); | ||
|
|
||
| test("Secure Input detection maps probe output and rejects unrecognized output", async () => { | ||
| const probe = (output: string) => async () => `${output}\n`; | ||
| assert.equal(await detectMacSecureInput(probe("secure")), true); | ||
| assert.equal(await detectMacSecureInput(probe("clear")), false); | ||
| await assert.rejects(detectMacSecureInput(probe("execution error: -2700"))); | ||
| await assert.rejects( | ||
| detectMacSecureInput(async () => { | ||
| throw new Error("spawn failed"); | ||
| }), | ||
| ); | ||
| }); | ||
|
|
||
| test( | ||
| "Secure Input probe runs against the documented Carbon API", | ||
| { skip: process.platform !== "darwin" }, | ||
| async () => { | ||
| assert.match(await runJxa(SECURE_INPUT_PROBE_SCRIPT), /^(secure|clear)$/); | ||
| }, | ||
| ); | ||
|
|
||
| test( | ||
| "documented Secure Input probe follows a process-owned enable/disable cycle", | ||
| { skip: process.platform !== "darwin" }, | ||
| async () => { | ||
| const result = await runJxa(`ObjC.import("Carbon"); | ||
| var before = Boolean($.IsSecureEventInputEnabled()); | ||
| var status = $.EnableSecureEventInput(); | ||
| if (status !== 0) throw new Error("Could not enable Secure Input for test"); | ||
| var enabled; | ||
| try { enabled = Boolean($.IsSecureEventInputEnabled()); } | ||
| finally { $.DisableSecureEventInput(); } | ||
| JSON.stringify({before: before, enabled: enabled, after: Boolean($.IsSecureEventInputEnabled())});`); | ||
| const state = JSON.parse(result) as { before: boolean; enabled: boolean; after: boolean }; | ||
| assert.equal(state.enabled, true); | ||
| assert.equal(state.after, state.before); | ||
| }, | ||
| ); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P2 · logic] Change-detector test asserts verbatim buggy AppleScript implementation
Confidence: 0.85
The test uses assert.match to lock the exact string of the premature abort in ATOMIC_PASTE_SCRIPT rather than testing behavior, violating AGENTS.md test guidelines and failing if the race condition is fixed.
Repair: Remove the source-matching assertions and replace them with behavioral tests verifying paste outcomes and clipboard preservation.