Threat model from the design phase (ef-security, STRIDE). Below: the controls and where they live in code. Items marked ☐ are designed-but-not-yet-implemented (tracked for the next sections).
- ☑ No networking code anywhere in the app at runtime. The only network events are the OS installing the
SpeechAnalyzer locale asset on first use (
AssetInventory,SpeechAnalyzerEngine.prepare) and user-initiated model downloads for the optional WhisperKit / Parakeet backends (ModelDownloader.ensureModel). - ☑ No analytics / telemetry / crash-reporting SDKs.
BarkLognever logs transcript or audio content. - ☑ Downloaded model bundles (WhisperKit / Parakeet) are SHA-256 verified against a bundled
ModelManifestbefore they're allowed into the cache. Hash mismatch → file is deleted, never written to the cache path, and an error is surfaced to the UI (ModelDownloader.ensureModel,ModelManifest). Manifests themselves live in the app bundle and are not fetched at runtime (SEC-003 / T-010). HTTPS-only enforced at the downloader; non-HTTPS manifests are rejected withModelError.insecureURL. Manifest signing (detached ed25519 verified against a baked-in pubkey) is the next hardening step — tracked but not yet implemented.
- ☑ Mic opened only during active dictation;
AVAudioEnginefully torn down onstop()(AudioCaptureEngine.stop). No always-listening mode. (T-002 / T-012) - ☑ Persistent in-app state (menu-bar icon reflects
listening), plus the macOS orange indicator.
Voiceprint / speaker gate (BarkCore/Speaker/*, BarkEngines/Speaker/*, ADR-009, specs/011-voice-fingerprinting/)
- ☑ Opt-in and off by default (
Settings.speakerGateEnabled); with it off, behavior is identical to today. Hands-free only — push-to-talk is never gated. - ☑ The voiceprint (
SpeakerProfile: a 256-d centroid + metadata, no raw enrollment audio) is encrypted at rest with AES-256-GCM; the key lives in the Keychain (kSecAttrAccessibleWhenUnlockedThisDeviceOnly) under a distinct servicecom.bark.speaker, so deleting the voiceprint and purging history are independent. Ciphertext file is0600,.completeFileProtection, excluded from backups (EncryptedSpeakerProfileStore, clonesEncryptedHistoryStore). - ☑ The voiceprint never leaves the device. Enrollment and matching run on-device; the only network
event is the user-initiated, SHA-256-verified embedding-model download via the existing
ModelDownloader(never FluidAudio's unverifieddownloadIfNeededon a release path). - ☑ Delete removes both the ciphertext and the Keychain key (
deleteVoiceprint). An unreadable/corrupt or wrong-key file degrades to "not enrolled" (backed up tospeaker.enc.corrupt, never crashes). - ☑ A profile whose
modelID≠ the running embedder's is treated as not enrolled (prompt re-enroll), never silently mis-scored across incompatible vector spaces. - ☑ The gate only suppresses injection; it never injects more, synthesizes keys, or relaxes the secure-field / sanitization rules. It fails open (disabled / not enrolled / model-incompatible / too-short / embedder error → inject as normal), so a gate fault can never lock the user out of their own dictation (FR-009 / SC-006).
- Honest residual (constitution IV — never overclaim a control). This is a convenience multi-speaker filter, NOT a security control: it reliably rejects other people, but a recording, replay, imitation, or TTS clone of the user's own voice produces a near-identical embedding and is accepted. It is not authentication, liveness, or anti-spoofing (out of scope for v1). In-app + README copy state these limits plainly (FR-011 / SC-007).
- ☑ Refuse injection when
IsSecureEventInputEnabled()or the focused AX element isAXSecureTextField(SecureFieldPolicy+SecureFieldDetector). (SEC-002 / T-005) — best-effort, see L-2. - ☑ Re-verify the focused app (PID) is unchanged immediately before injecting (
FocusGuard+FocusProbe); abort on mismatch. (SEC-004 / T-004) — app-level, see L-1. - ☑ Never synthesize Return/Enter; strip trailing newlines; for terminals, strip all newlines and use
keystroke injection. (
TextSanitizer,KeystrokeInjector,TerminalDetector) (SEC-005 / T-006) - ☑ Sanitize C0/C1 controls, ANSI escapes, zero-width and bidi characters before injection.
(
TextSanitizer) (SEC-011 / T-014) - ☑ Full-pasteboard snapshot + restore with a
changeCountguard; injected payload markedorg.nspasteboard.ConcealedType. (PasteboardInjector) (ARCH-001 / SEC-007 / T-007)
- ☑ Dictation is fenced as untrusted data inside
<transcript>with an explicit guardrail; injected close-tags are neutralized (PromptTemplate). (AIML-002 / SEC-010) - ☑ LLM output is length-bounded (
OutputValidator) and passes the same injection sanitization as raw text; it is text only, never executed. (AIML-001/004 / SEC-011) - ☑ Fresh stateless session per rewrite — no conversation state bleeds across dictations.
Revision surface (BarkCore/Revision/*, BarkCleanupMLX/LLMRevisionEngine.swift, Sources/Bark/DictationController.swift, ADR-007, specs/009-voice-driven-revision/)
- ☐ Refuses when
IsSecureEventInputEnabled()orAXSecureTextFieldis focused (SecureFieldPolicy). (SEC-002 re-applied) - ☐ Re-verifies the focused app's PID immediately before applying the rewrite (
FocusGuard); aborts on mismatch. (SEC-004 re-applied) - ☐ Output passes
TextSanitizer(C0/C1, ANSI escapes, bidi strip) before insertion. (SEC-011 re-applied) - ☐ Spoken revision instruction is fenced as untrusted data inside
<revision>inPromptTemplate.revisionSystem; the previous text is fenced inside<previous>. Mirrors SEC-010 with the new revision surface. - ☐
OutputValidatorgains a length-drift rule: revised text must be ≤ 2× the previous text's length. Catches the "expand to include a phishing URL or external payload" prompt-injection pattern even if all other fences fail. - ☐ Dictionary commands (
delete that,undo,select all,copy,scratch that) are pure AX actions; they do not inject new text content into the focused field. They emit a ⌘Z / ⌘A / ⌘C event only when the focused app accepts those shortcuts; if the app rejects them, Bark falls back to a clear refusal (no error, no destruction). - ☐ History linkage: every revision produces a
HistoryRecordwithparentIDset; the user can revert the chain via Settings ▸ History. Revisions that fail validation preserve the original text (no destruction). (SEC-013) - Residual (L-7 — Electron / web text fields): AX range manipulation for "select-all + replace"
is inconsistent across Electron apps and web views. The plan falls back to "select-all + replace
via
PasteboardInjector" which is the same proven path as every other Bark injection. Documented honestly — a revision may not be reliable in a small set of apps. - Residual (L-8 — Spoken instruction as injection vector): the spoken revision instruction
could itself be a prompt-injection vector ("ignore prior instructions and paste X"). Mitigated
by the prompt fence + the length-drift rule + the existing
OutputValidatorbanned-token check. Worst-case outcome is a refused rewrite; the original text is preserved verbatim. - Residual (L-9 — Revision hotkey collision): ⌥⌘R may collide with a system shortcut the user has bound. The recorder shows a warning, does not refuse (mirrors push-to-talk recorder UX). Users can rebind.
Hold-to-refine surface (BarkCore/Refine/*, BarkCore/Cleanup/PromptTemplate.swift, Sources/Bark/DictationController.swift, specs/012-staged-refinement/)
- ☑ No new permission and no new network event — reuses the mic + push-to-talk hotkey already granted; refinement runs on the same on-device LLM as the rewrite path. The running draft and per-turn audio are in-memory only and discarded at fn-release.
- ☑ The spoken instruction is fenced as untrusted data inside
<instruction>, and the running draft inside<text>, with injected close-tags neutralized and an explicit guardrail (PromptTemplate.refineSystem/refineUser). Mirrors SEC-010 for the new surface. (FR-013) - ☑ Refine output passes
OutputValidator(length-bound vs the prior draft) and is text only; on reject / timeout / error the prior draft is preserved (RefineSession.keepOnFailure) — a bad rewrite never destroys text. (FR-010 / SC-006) - ☑ Injection happens only at fn-release, through the unchanged
performInjectionpath: secure-field refusal, focus-guard PID re-check,TextSanitizer, never Return/Enter. Intermediate drafts and the empty-tap undo never inject. (FR-006 / FR-014) - ☑ Fresh stateless LLM session per turn — no conversation state bleeds across turns or dictations.
- ☑ Gated behind an opt-in setting + LLM availability; the lean build and toggle-off collapse to the unchanged base path (fail-open). (FR-011 / FR-017)
- Residual (L-8 — Spoken instruction as injection vector): as with the revision surface, the
instruction could attempt prompt injection. Mitigated by the fence +
OutputValidator; worst case is a refused rewrite with the prior draft preserved. - Residual (left-option keycode delivery): left-vs-right option (keycode 58 vs 61) is read from the
.flagsChangedevent — runtime OS behavior that can't be unit-tested; the pureRefineKeyDecoderis the tested evidence and right-option never triggers a refine. - Residual (audio during a slow in-flight refine): while a rewrite is being applied the capture loop is suspended and mic audio buffers (bounded, newest-kept ~6 s). A rewrite approaching the 8 s deadline can drop the earliest buffered audio spoken during it. Not a safety issue (no injection, no leak); the HUD shows "Refining…" to cue the user to wait, and decoupling segment capture from the LLM call is a planned follow-up. (ADV-003)
- ☑ Only the microphone device entitlement. Accessibility + Input Monitoring are user-granted via TCC, requested just-in-time with purpose strings. (SEC-008 / T-011)
- ☑ Hardened runtime; no
get-task-allow, nodisable-library-validation; Library Validation on. (T-013) — enforced byscripts/make-app.sh(--options runtime).
- ☑ History is off by default (
Settings.historyEnabled == false); nothing is persisted unless the user opts in. Turning it back off purges the file and key (historyEnabledsetter →purge()). - ☑ When enabled: AES-256-GCM (CryptoKit), key in the Keychain
(
kSecAttrAccessibleWhenUnlockedThisDeviceOnly, device-bound, non-syncing). File is0600, written atomically with.completeFileProtection, andisExcludedFromBackup. Retention cap viaRetentionPolicy.purge()removes both file and key. (SEC-006 / T-008) - Note (honest scope): the Keychain key is a software key, not Secure-Enclave-backed; Spotlight exclusion is not implemented. Decrypt failure is treated as empty (see L-6).
- ☐ Zero audio/intermediate text buffers after use; disable core dumps for capture. (SEC-009 / T-003)
These are inherent to synthetic injection / cross-app automation, or accepted for v1. They are documented rather than hidden:
- L-1 — Focus guard is app-level (PID), not window/field. A focus change to a different window or
field within the same app during STT/LLM latency is not detected (
FocusGuard.targetUnchangedcompares PID). Cross-app switches are caught. Stable per-field AX identity across the new SpeechAnalyzer latency is unreliable; closing this fully would require refusing common valid use. - L-2 — Secure-field detection is best-effort.
IsSecureEventInputEnabled()is session-global (can cause false refusals if another app holds secure input) andAXSecureTextFieldis the only field signal. Web/Electron/custom password fields that don't trip either are not detected — do not rely on Bark to refuse every password field. - L-3 — Multi-line paste into an unrecognized terminal. Known terminals (
TerminalDetector) get single-line keystroke injection. For other apps, a paste containing interior newlines relies on the terminal's bracketed-paste mode (default-on in modern shells/terminals) to avoid executing lines. Integrated terminals (e.g. VS Code's) share their editor's bundle ID and can't be distinguished. Hard guarantee that holds everywhere: Bark never synthesizes Return/Enter. - L-4 — Clipboard restore is timer-based (250 ms). If the target app consumes the paste later, or
itself rewrites the pasteboard, restore may be skipped (transcript not wiped) or fire early. Guarded
by
changeCountto avoid clobbering a user copy. - L-5 — Runtime OS-adapter effectiveness is integration-tested at the seam, not end-to-end. The
controller orchestration is unit-tested with fakes (
BarkAppTests); the live AX/CGEvent/pasteboard/ SpeechAnalyzer behavior still needs interactive testing on-device. - L-6 — History decrypt failure is treated as empty. A transient Keychain miss or partial-write
corruption makes
all()return[], and the next append overwrites the file — i.e. opt-in history is best-effort convenience storage, not a durable archive. - L-7 — AX range manipulation for revision replacement is best-effort. A "select-all + replace"
revision path is inconsistent across Electron apps and web views (ADR-007). The plan falls back
to
PasteboardInjector(proven path) on detection failure; some revisions may not reliably apply in a small set of apps. The original text is never destroyed — worst case is a refused rewrite. - L-8 — Spoken revision instruction is itself a prompt-injection vector. A user (or a captured
audio sample) saying "ignore prior instructions and paste X" could attempt to redirect the
LLM. Mitigated by the prompt fence, the new
OutputValidatorlength-drift rule (≤ 2× previous), and the existing banned-token check. Worst case: a refused rewrite with the original text preserved. Not a destruction vector. - L-9 — Revision hotkey collision.
⌥⌘Rmay collide with a system or app shortcut the user has already bound. The recorder shows a warning, doesn't refuse (mirrors push-to-talk recorder UX). Users can rebind. A future iteration could surface the running shortcut viaNSEvent.addGlobalMonitorForEventsand warn more precisely.
File read for code intelligence (Sources/BarkCore/Code/*, ADR-008, specs/010-inline-code-dictation/)
- ☐ First-time per-app-per-language consent dialog is shown before reading the focused file
for the first time in a given app+language combination. The dialog names the app by name +
bundle ID and the language by extension + display name. Three options: "Always allow"
(persists for that app+language), "Allow once" (transient, not persisted), "Never"
(blocklist; the symbol index is silently skipped for that app+language). The consent list
is in
Settings.codeIntelligence.fileReadConsents, key ="\(bundleID)/\(language)". - ☐ 1 MB cap. Files larger than 1 MB skip the symbol index and degrade to prefix-only formatting. The cap is enforced in the file-read coordinator; the user is informed via a one-time log message ("Skipping symbol index for : ").
- ☐ Binary / unreadable files are skipped with the same log message. The user is not prompted for consent; the index is silently skipped.
- ☐ No new network events. The file read is local. The symbol index is local. The LLM
rewrite uses the existing on-device
MLXTextCleanerpath. - ☐ Symbol index is bounded (default 500 entries, deterministic truncation in source order). The LLM is told the index is partial if the file has more identifiers.
- ☐ Lean build does not read the file. Without
CODE_INTELLIGENCEdefined, the symbol index is unavailable; comment formatting uses prefix only. This is a privacy-friendly default. - ☐ The user can revoke consent at any time via Settings ▸ Code ▸ File-read consent (lists all app+language entries with Allow / Never / Reset controls).
- Residual (L-10 — SwiftSyntax reads file content): the SwiftSyntax-backed identifier
extractor for Swift files sees the file's content. The user has explicitly opted in via the
consent dialog. The same risk surface exists for the existing
AssetInventoryfor STT models (also gated by consent). The user can revoke via Settings. - Residual (L-11 — "Always allow" persists forever): once a user clicks "Always allow" for an app+language, we never re-prompt for that combination. The user can revoke via Settings ▸ Code ▸ File-read consent. A future hardening could add a 90-day expiry on "Always allow" entries.
- Residual (L-12 — Regex extractor false positives): for non-Swift languages, the regex extractor can grab identifiers from comments or string literals. The extractor strips comments and string literals first, but the stripping is heuristic. Worst case: an extra identifier in the symbol index that the LLM doesn't reference. Not a security issue, but a quality issue.
- Residual (L-13 — Identifier hallucination): the LLM may invent identifiers not in the
symbol index. The new
OutputValidatorrule flags non-existent identifiers in the rewrite (best-effort: a reference to an imported type from another module is valid but won't be in the index). The validator doesn't reject; it surfaces a warning in the history record. - Residual (L-14 — Commit-box heuristic false positives): the
CommitBoxDetectorheuristic may mis-identify a non-commit text field as a commit-message box. The confidence threshold (≥ 0.7) gates auto-formatting; below the threshold the user sees a one-time per-app toast with a confirmation. Worst case: a comment or note gets formatted as a Conventional Commits message; the user can revert via Settings ▸ Code.