Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:
Expand Down
47 changes: 47 additions & 0 deletions docs/testing/secure-field-433.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions electron/accessibilityHelper.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

Expand Down
61 changes: 58 additions & 3 deletions electron/accessibilityHelper.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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();
});

Expand All @@ -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();
});
Expand Down
20 changes: 17 additions & 3 deletions electron/dictationCoordinator.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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,
};
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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 };
}
}

Expand Down Expand Up @@ -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 };
}
}

Expand Down
Loading
Loading