← Back
securitymacOSforensicsconsumer-rights

How Wispr Flow Ate My Spacebar: A Forensic Investigation Into Silent Keyboard Interception

Published April 4, 2026


Last Friday, my spacebar stopped working. Not mechanically — the key depressed fine, the tactile feedback was normal. But spaces weren't appearing. Characters would run together: icannot use myspace keyconsistently. I tried cleaning the keyboard, checking accessibility settings, restarting processes. Nothing helped.

Then I killed Wispr Flow, and my spacebar came back instantly.

What followed was a forensic investigation into exactly how a voice dictation app managed to silently eat my keyboard input — and what else it was doing that I never consented to.


Table of Contents

  1. The Symptoms
  2. Root Cause: A Stale Key in a CGEventTap
  3. The Architecture: How Wispr Flow Intercepts Every Keystroke
  4. The Evidence: 145 Suppressed Spacebars in 10 Minutes
  5. The Full Surveillance Stack
  6. The Marketing vs. The Reality
  7. Legal Analysis: Consumer Rights Implications
  8. Recommendations
  9. Methodology

The Symptoms

I'm a software engineer. I type roughly 8-10 hours a day. On April 4, 2026, I noticed that my spacebar was intermittently failing — not every press, but enough to make typing painful. My messages looked like:

ikilled it but apparaently it didnot workout verywell

Initial debugging ruled out the obvious:

  • Slow Keys: OFF
  • Sticky Keys: OFF
  • Key remappings: None
  • Keyboard hardware: Built-in MacBook keyboard, no physical damage
  • Input source: U.S. layout, no IME active

Process enumeration revealed Wispr Flow running 10+ processes, including a keyboard-listener binary and a text-injector binary, both with system-wide keyboard access via macOS Accessibility permissions.

Killing Wispr Flow immediately restored normal keyboard function.

But why?


Root Cause: A Stale Key in a CGEventTap

The smoking gun was in Wispr Flow's own log file at ~/Library/Logs/Wispr Flow/accessibility.log:

[2026-04-04 16:56:08.272] [Info] - Suppressing event. Cur keys down: [61, 49], key code: 49, shortcut: [49, 61]
[2026-04-04 16:56:53.639] [Info] - Suppressing event. Cur keys down: [61, 49], key code: 49, shortcut: [49, 61]
[2026-04-04 16:56:53.912] [Info] - Suppressing event. Cur keys down: [61, 49], key code: 49, shortcut: [49, 61]
...
[2026-04-04 17:05:42.117] [Info] - Suppressing event. Cur keys down: [61, 49], key code: 49, shortcut: [49, 61]

145 spacebar presses suppressed in under 10 minutes.

Here's the breakdown:

Key CodeKeyRole
49SpacebarBeing suppressed
61Right Option (⌥)Stuck in curKeysDown

Wispr Flow's dictation shortcut is Option + Space (key codes [49, 61]). The app maintains a set called curKeysDown tracking which keys are currently held. Key code 61 (Right Option) got "stuck" in this set — the key-up event was missed, likely due to a race condition between the event tap callback and the GCD queues processing key events.

Because key 61 was permanently stuck in curKeysDown, every single spacebar press was interpreted as the dictation shortcut and suppressed — the CGEventTap returned NULL, eating the event before it reached any application.

The stale key recovery system exists in the codebase:

[2026-04-04 17:01:15.239] [Info] - [Keyboard Service] Removing stale keys: 49
[2026-04-04 17:03:25.319] [Info] - [Keyboard Service] Removing stale keys: 0

But it never cleared key 61. The logs show stale key recovery for keys 48, 49, 53, and 0 — but never for key 61, the one that was actually stuck. The recovery system failed on the exact key that caused the most damage.


The Architecture: How Wispr Flow Intercepts Every Keystroke

Wispr Flow is not a simple microphone app. Binary analysis of the Swift helper at /Applications/Wispr Flow.app/Contents/Resources/swift-helper-app-dist/Wispr Flow.app/Contents/MacOS/Wispr Flow reveals a sophisticated two-process architecture:

Process Architecture

The CGEventTap

The Swift helper installs a CGEventTap [1] — the most invasive form of keyboard interception available on macOS. This is not an NSEvent global monitor (which is passive/read-only). A CGEventTap installed with kCGEventTapOptionDefault is an active filter that:

  1. Receives every keyboard event before any application
  2. Can modify events (change key codes, add/remove modifiers)
  3. Can suppress events by returning NULL (the event never reaches any app)
  4. Runs on a CFRunLoop in a dedicated GCD queue

Evidence from the binary (via strings):

com.wispr-flow.keyboardService.keyEventQueue    ← CGEventTap callback processing
com.wispr-flow.keyboardService.runQueue          ← CFRunLoop for the event tap
com.wispr-flow.keyboardService.sendQueue          ← Sending key events to Electron
_keyEventBuffer                                   ← Buffered events awaiting processing
_keyPressIndex                                    ← Monotonic keypress counter

The Buffer Problem

Wispr Flow doesn't just intercept keystrokes — it buffers them. The event tap callback puts events into _keyEventBuffer, which is processed asynchronously. This introduces a failure mode where:

  1. Events enter the buffer faster than they're processed
  2. The buffer stalls (the app's own logs confirm this)
  3. A flush is triggered, potentially losing events

From the accessibility log, 16 buffer flushes occurred in a ~30-hour window:

[2026-04-03 14:42:37.098] [Warn] - Flushing buffered keyboard events
[2026-04-03 14:43:58.108] [Warn] - Flushing buffered keyboard events
[2026-04-03 16:21:00.537] [Warn] - Flushing buffered keyboard events
...
[2026-04-04 16:27:30.766] [Warn] - Flushing buffered keyboard events

The binary also contains these telling strings:

Keyboard event buffer stalled
Keyboard service event tap disabled, attempting to restart tap
Hit max keyboard service event tap retries, shutting down
Slow keyboard event send
Unexpectedly long time to handle key event
Received consecutive timeouts, terminating helper process

This is an app that knows its keyboard interception is unreliable and has built retry/recovery logic around its own failures — rather than questioning whether system-wide keystroke interception is an appropriate architecture for a dictation app.

12 GCD Queues

The Swift helper runs 12 named dispatch queues:

QueuePurpose
com.wispr-flow.keyboardService.keyEventQueueCGEventTap callback
com.wispr-flow.keyboardService.runQueueEvent tap CFRunLoop
com.wispr-flow.keyboardService.sendQueueIPC to Electron
com.wispr-flow.ipcClient.messageQueueIPC message processing
com.wispr-flow.ipcClient.readQueuestdin reading
com.wispr-flow.editedText.processQueueText editing analysis
com.wispr-flow.focusChangeDetector.textBoxInfoFocus detection
com.wispr-flow.dockObserver.detectDockChangeQueueDock position
com.wispr-flow.audioObject.queueCoreAudio callbacks
com.wispr-flow.timer.isAudioPlayingQueueAudio playback detection
com.wispr-flow.threadSafe.queueThread-safe operations
com.wispr-flow.updateApplicationInfoApp info updates

The concurrency between keyEventQueue, sendQueue, and runQueue is the likely source of the race condition that causes stale keys. When the IPC pipe to Electron is congested (the main process is busy processing analytics, updating feature flags, or communicating with cloud services), key-up events can be lost.


The Evidence: 145 Suppressed Spacebars in 10 Minutes

Timeline of the Incident

TimeEvent
16:56:08First spacebar suppression logged. Key 61 (Right Option) stuck in curKeysDown
16:56:08 – 17:05:42145 consecutive spacebar presses suppressed
17:05:42Last suppression logged
~17:05:55Wispr Flow killed by user
17:05:55Sentry client shutdown / Posthog client shutdown logged
ImmediatelySpacebar works normally

The Stale Key Recovery Failure

The codebase has a CheckStaleKeys mechanism. From the main process log:

[2026-04-04 16:56:51.383] [debug] [Keyboard Service] Stale key recovery: scheduling async removeStaleKeys + retry for keyCode 53
[2026-04-04 16:56:52.697] [info]  [Keyboard Service] Removing stale keys: 53
[2026-04-04 17:01:15.239] [info]  [Keyboard Service] Removing stale keys: 49

Stale keys 53 (Escape), 49 (Spacebar), 48 (Tab), and 0 were detected and cleared. But key 61 (Right Option) was never cleared, even though it was the one stuck in curKeysDown causing every spacebar to be suppressed. The recovery system has a blind spot for the exact key that causes the most catastrophic failure.

Historical Stale Key Events

The log shows this isn't a one-time event. Over the 30-hour log window:

[2026-04-03 15:09:31.619] [Info] - Removing stale keys from curKeysDown: [48]
[2026-04-03 15:31:31.491] [Info] - Removing stale keys from curKeysDown: [48]
[2026-04-04 05:41:53.454] [Info] - Removing stale keys from curKeysDown: [48]

Key 48 (Tab) got stuck three times. This is a systemic issue with the key state tracking, not a freak occurrence.


The Full Surveillance Stack

The keyboard interception is just the beginning. The forensic investigation revealed that Wispr Flow silently captures every URL you visit, reads your screen content, stores hundreds of megabytes of audio, and uploads data hourly — none of which is disclosed in the privacy policy.

1. Tracking Every App and URL You Visit

Wispr Flow uses the macOS Accessibility API to monitor which application is in the foreground and, for browsers, which URL is active:

[2026-04-04 17:00:33.188] [Info] - Sending application info request for bundle ID: com.google.Chrome and URL: [redacted]
[2026-04-04 17:01:34.057] [Info] - Sending application info request for bundle ID: com.google.Chrome and URL: github.com
[2026-04-04 17:05:21.198] [Info] - Sending application info request for bundle ID: com.google.Chrome and URL: x.com

Over the 30-hour log window, Wispr tracked visits to:

VisitsURL
1,294(non-browser apps, logged as "unknown")
133x.com
119localhost
47github.com
22mail.google.com
21[work tool]
15[work tool]
12[shopping site]
9[investment site]
8reddit.com
8[shopping site]

This is comprehensive browsing surveillance. The app knows every website I visit and every application I use.

2. Reading Your Screen via Accessibility Tree Traversal

[2026-04-04 16:57:27.364] [Info] - Found AXWebArea element in app: com.google.Chrome. Processed 194 elements in 0.05s, reaching depth 9
[2026-04-04 17:05:21.198] [Info] - Found AXWebArea element in app: com.google.Chrome. Processed 214 elements in 0.11s, reaching depth 9

Wispr Flow traverses the entire Accessibility tree of the active application — up to 214 elements, 9 levels deep — reading the content of your screen. Their privacy page [14] calls this "Context Awareness" and describes it as "limited, relevant content from the specific app in use." Processing 214 elements across 9 levels of DOM depth is not "limited."

3. Storing 198 MB of Audio Recordings (and Uploading Them)

The local SQLite database at ~/Library/Application Support/Wispr Flow/flow.sqlite is 694 MB and contains a History table with 3,404 rows. The schema reveals the full extent of data captured per dictation:

CREATE TABLE `History` (
  `transcriptEntityId`       VARCHAR(36) PRIMARY KEY,
  `asrText`                  TEXT,          -- Raw ASR transcription
  `formattedText`            TEXT,          -- AI-formatted output
  `editedText`               TEXT,          -- User's post-edit version
  `textboxContents`          TEXT,          -- Full contents of the text field
  `audio`                    BLOB,          -- Raw audio recording
  `builtInAudio`             BLOB,          -- Separate built-in mic recording
  `screenshot`               BLOB,          -- Screenshot capture
  `axText`                   TEXT,          -- Accessibility tree text
  `axHTML`                   TEXT,          -- Accessibility tree HTML
  `app`                      VARCHAR(255),  -- Application name
  `url`                      VARCHAR(255),  -- URL you were visiting
  `opusChunks`               JSON,          -- OPUS-encoded audio chunks
  `needsUploading`           TINYINT(1) DEFAULT 0,
  `duration`                 FLOAT,
  `numWords`                 INTEGER,
  `language`                 TEXT,
  `toneMatchedText`          TEXT,          -- Tone-matched version
  `toneMatchPairs`           JSONB,         -- Tone matching pairs
  `personalizationStyleSettings` JSON,      -- Style personalization data
  `userEditMetaData`         JSON,          -- How you edited the text
  ...
);

198 MB of raw audio stored locally. The screenshot column exists in the schema for capturing screen state — the database is designed to store screenshots alongside every dictation, even though the current version appears to not populate it for all entries. The schema's axText and axHTML columns store the full Accessibility tree content of whatever app you were using at the time.

The upload mechanism is active and confirmed in logs. Even with "Usage data sharing" toggled OFF, the app still uploads metadata to POST /history/upload:

[2026-04-04 14:02:01.849] [info]  Uploading history, 6 transcripts with needsUploading: true
[2026-04-04 14:02:01.860] [warn]  Usage data sharing is off, only uploading metadata
[2026-04-04 15:00:35.619] [info]  Uploading 8 history rows, size: 5222
[2026-04-04 15:00:35.619] [info]  AriaWebClient request { method: 'post', endpoint: '/history/upload' }
[2026-04-04 15:00:36.082] [info]  Uploaded 8 transcripts with IDs: [redacted UUIDs]

Key observations:

  • "Usage data sharing is off" does not stop uploads — it merely reduces the payload to "metadata" (which still includes transcript IDs, app names, URLs, duration, word counts)
  • The upload runs hourly regardless of user preference
  • Batch sizes are capped at 5.8 MB, suggesting that with data sharing ON, full audio + transcript blobs are transmitted
  • The uploadRequest counter in logs reached 7,814 — indicating thousands of upload cycles over the app's lifetime

4. The ML Inference Pipeline: Model v31pl413 on Baseten gRPC

The transcription pipeline is fully cloud-based, not on-device. The logs reveal the exact model deployment and protocol:

[2026-04-04 14:58:54.436] [info]  Using gRPC rollout variant: grpcBaseten
[2026-04-04 14:58:54.436] [info]  Setting up gRPC transcription client
[2026-04-04 14:58:54.438] [info]  Using gRPC server variant grpcBaseten at model-v31pl413.grpc.api.baseten.co
[2026-04-04 14:58:54.438] [debug] gRPC request languages: ["en","zhcn"] → [1,3]
[2026-04-04 14:58:54.539] [info]  Initialized OPUS encoder with WebCodecs
[2026-04-04 14:58:54.827] [info]  gRPC response headers: replicaId: bt-deployment-32p78zx-00001-deployment-6c75dfc964-kxh5r

Infrastructure details:

ComponentValue
Model endpointmodel-v31pl413.grpc.api.baseten.co
ProtocolgRPC with TLS (port 443)
Audio encodingOPUS via WebCodecs (fallback: WAV)
Deployment IDbt-deployment-32p78zx (Baseten)
Replica formatKubernetes pods: deployment-6c75dfc964-kxh5r
Chain endpointchain-o232k03l.api.baseten.co/environments/production/run_remote
LanguagesEnglish (1), Chinese Simplified (3)
LLM extractionPOST /llm/extract_asr_words — extracts proper nouns from context

The model name v31pl413 suggests version 31 with a "pl" (Polish/post-processing?) revision 413. Baseten deployment IDs show 16 distinct replica pods during the logging window, indicating a horizontally scaled deployment.

Context sent alongside audio during gRPC transcription:

  1. App name and URL (which app you're in, which website)
  2. AX context (Accessibility tree text from your screen) — logged as "AX context collection took 336ms"
  3. Proper nouns extracted via a separate LLM call (/llm/extract_asr_words — "Extracted 5 proper nouns")
  4. Textbox contents (what's already typed in the field)
  5. Clamshell state (whether laptop is open/closed, for mic selection)

Multiple Sent context update over gRPC stream log entries per dictation confirm this data is streamed to the cloud alongside your audio. This means Baseten's model receives not just your voice, but your screen content, the app you're using, and the URL you're visiting.

The feature flag configuration reveals the full inference topology:

"grpc-desktop-rollout": { "variant": "grpcBaseten", "payload": { "url": "model-v31pl413.grpc.api.baseten.co" }},
"use-grpc-client-desktop": { "enabled": true, "payload": { "fallbackCooldownMs": 1800000 }},
"use-ensemble-model": { "enabled": false },
"use-opus": { "enabled": false },
"transaction-sampling-rates": { "payload": { "dictation.complete": 0.1, "helper.*": 0.01 }}

The fallbackCooldownMs: 1800000 (30 minutes) means if the gRPC endpoint fails, the app falls back to HTTP (chain-o232k03l.api.baseten.co/environments/production/run_remote) and won't retry gRPC for 30 minutes.

Transcription latency breakdown from the logs:

webSocketResponseTimeMsecs: 416,
webSocketNetworkOverheadMsecs: 97,
basetenPingTimeAtStreamStartMsecs: 593,
basetenCommitAckTimeMsecs: 96,
transcribe: 0.21,
transcribe_overhead: 0.009,

The transcribe: 0.21 (seconds) is the actual model inference time. The remaining latency is network overhead — 593ms TCP ping to Baseten, 416ms for the response stream. This is entirely cloud-side inference; no on-device model exists.

5. Telemetry to Four Analytics Services

The app phones home to:

  1. PostHog (EU region) — analytics and feature flags (phc_***[redacted])
  2. Sentry — error tracking (org and project IDs redacted), DSN: https://[redacted]@[redacted].ingest.sentry.io/[redacted]
  3. Segment — analytics pipeline (api.segment.io)
  4. Datadog — browser logging (logs.browser-intake-datadoghq.com)

Plus direct API calls to:

  • api.wisprflow.ai / api-east.wisprflow.ai — primary backend (AriaWebClient)
  • cloud.wisprflow.ai / cloud.flowvoice.ai — voice processing
  • model-v31pl413.grpc.api.baseten.co — gRPC ML inference
  • chain-o232k03l.api.baseten.co — HTTP ML inference fallback
  • [redacted].supabase.co — Supabase auth and data storage

The app tracks 1,183 distinct analytics event names, including generic_keypress, key_event_press, key_event_release, avg_typing_per_day, difficulty_typing, and buffer_overflow.

The shutdown sequence confirms all four telemetry services are active:

[2026-04-04 17:05:55.518] [info]  Sentry client shutdown
[2026-04-04 17:05:55.725] [info]  Posthog client shutdown
[2026-04-04 17:05:56.448] [info]  Child processes, posthog, sentry, and segment have shut down

6. Maximally Permissive Security Entitlements

All 6 binaries in the app bundle share identical entitlements:

com.apple.security.cs.allow-unsigned-executable-memory    ← Disable W^X protection
com.apple.security.cs.disable-library-validation          ← Load unsigned dylibs
com.apple.security.cs.allow-dyld-environment-variables    ← Allow DYLD injection
com.apple.security.cs.allow-jit                           ← JIT compilation
com.apple.security.device.audio-input                     ← Microphone
com.apple.security.device.camera                          ← Camera

The app is not sandboxed (com.apple.security.app-sandbox is absent) and disables macOS Hardened Runtime [2] protections. The combination of disable-library-validation and allow-unsigned-executable-memory means:

  • Any process can inject a dynamic library into Wispr Flow
  • That injected library inherits Wispr Flow's Accessibility permissions
  • The injected code can then read every keystroke on the system

This is the security equivalent of leaving your front door open while running a keylogger — any malware on the system can piggyback on Wispr Flow's privileged access.

Additionally, NSAppTransportSecurity.NSAllowsArbitraryLoads = true disables App Transport Security, allowing unencrypted HTTP connections.


The Marketing vs. The Reality

Claim: "Your data, your control"

Reality: The app maintains a 694 MB SQLite database of dictation history including raw audio BLOBs, transcripts, Accessibility tree HTML, textbox contents, and app/URL metadata — with a needsUploading flag and an active hourly upload loop to POST /history/upload. The "Privacy Mode" option [14] exists but is off by default, and even when enabled, metadata (transcript IDs, app names, URLs, duration, word counts) is still uploaded. The log literally says: Usage data sharing is off, only uploading metadata.

Claim: "We never sell your data"

Reality: While they may not sell data in the traditional sense, they transmit telemetry to four separate analytics services (PostHog, Sentry, Segment, Datadog), track 1,183 distinct analytics events including keystroke metrics, and send your screen context (AX tree text, app name, URL, proper nouns) alongside every audio stream to Baseten's ML inference endpoints. The transaction-sampling-rates feature flag shows they sample 10% of dictation.complete events and 1% of helper.* events for detailed telemetry.

Claim: SOC 2 Type II / HIPAA-eligible

Reality: The app disables macOS Hardened Runtime [2] protections (disable-library-validation, allow-unsigned-executable-memory, allow-dyld-environment-variables), doesn't use App Sandbox, and sets NSAllowsArbitraryLoads = true (disabling ATS). These are the opposite of SOC 2 security controls. The disable-library-validation entitlement means any process on the system can inject a dylib via DYLD_INSERT_LIBRARIES into Wispr Flow and inherit its Accessibility permissions — gaining system-wide keystroke access. For a HIPAA-eligible app that processes voice dictation in medical contexts, this is a critical security gap.

Claim: "4x faster than typing"

Reality: The app can make typing impossible. A stale key bug in the KeyboardService class's curKeysDown set caused the CGEventTap to suppress 145 spacebar presses in 10 minutes by misidentifying them as the dictation shortcut. The keyboard buffer stalls roughly every 2 hours (Flushing buffered keyboard events — 16 times in 30 hours). The stale key recovery system (removeStaleKeys) failed to clear the stuck modifier key. Users aren't "4x faster" when their keyboard doesn't work.

Claim: "Your data is encrypted in transit and at rest"

Reality: The audio transcription pipeline streams OPUS-encoded audio over gRPC to model-v31pl413.grpc.api.baseten.co alongside Accessibility tree context, app names, URLs, and extracted proper nouns from your screen. While the gRPC connection uses TLS, the app simultaneously sets NSAllowsArbitraryLoads = true in its ATS configuration, allowing unencrypted HTTP connections for other requests. The Baseten deployment runs on Kubernetes pods (bt-deployment-32p78zx-00001-deployment-6c75dfc964-*) — meaning your audio and screen context is processed on shared cloud infrastructure, not dedicated HIPAA-compliant environments.

Undisclosed: System-wide keyboard interception

The privacy policy [13] does not mention:

  • CGEventTapCreate() installation on the HID event stream
  • System-wide keystroke interception via an active (filtering) event tap
  • Key event buffering in _keyEventBuffer with asynchronous processing
  • The ability to suppress (eat) keyboard events by returning NULL from the tap callback
  • Always-on keyboard monitoring when dictation is not active
  • Comprehensive app/URL tracking via Accessibility API tree traversal
  • Screen content reading (214 AX elements, 9 levels deep)
  • Clipboard interception and restoration (DelayedClipboardProvider)

The privacy policy [13] describes collecting "audio Inputs" and "Usage Data." It does not disclose that the app installs an active CGEventTap that intercepts every keystroke system-wide, maintains a _keyEventBuffer, and can (and does) suppress keystrokes by returning NULL from the CGEventTapCallBack.


Legal Analysis: Consumer Rights Implications

Disclaimer: This is research for informational purposes, not legal advice. Consult a licensed attorney for actionable guidance.

FTC Act Section 5 — Deceptive Practices [4]

This is likely the strongest federal avenue. A representation or omission is deceptive if it is likely to mislead consumers acting reasonably and the omission is material.

Wispr Flow presents itself as a voice dictation tool. Its privacy disclosures [13] mention "audio Inputs" but do not disclose:

  • System-wide keystroke interception via CGEventTap
  • Always-on keyboard monitoring when dictation is not active
  • The ability to suppress keyboard events
  • Comprehensive app/URL tracking via Accessibility API

Relevant FTC precedent:

  • In re Goldenshores Technologies (2014) [9] — "Brightest Flashlight" app collected geolocation beyond disclosure. Consent decree required clear disclosure before collection.
  • FTC v. DesignerWare (2012) [10] — Monitoring software on rental computers captured keystrokes and screenshots. FTC obtained consent order prohibiting such monitoring without clear notice and affirmative consent.
  • FTC v. Spyfone.com (2021) [11] — Stalkerware company banned from surveillance business. The FTC found that covert monitoring software constituted unfair practices.
  • In re Zoom (2020) [12] — Zoom claimed end-to-end encryption but didn't provide it. Established that misrepresenting the scope of data handling is deceptive per se.

California Invasion of Privacy Act [6]

Penal Code Section 631 prohibits wiretapping or reading the contents of communications by means of a device. A CGEventTap that intercepts keystrokes system-wide is literally a "tap" by statutory definition.

Section 637.2 provides a private right of action: $5,000 per violation or three times actual damages, whichever is greater.

With 145 documented suppressions in one incident alone, and 3,404 dictation entries with full audio, transcripts, and accessibility tree content stored locally, the exposure is significant.

CCPA/CPRA — Proportionality [7]

The CPRA requires that data collection be "reasonably necessary and proportionate to achieve the purposes for which the personal information was collected." Always-on keystroke analytics, app/URL tracking, and accessibility tree traversal for a voice dictation tool fails this proportionality test.

Federal Wiretap Act [5]

The Wiretap Act prohibits intentional interception of electronic communications. Key precedent:

  • United States v. Ropp (D. Nev. 2004) — Hardware keylogger violated the Wiretap Act because keystrokes were "electronic communications" intercepted without adequate consent.
  • United States v. Councilman (1st Cir. 2005) — Electronic communications are protected even during transient storage. A CGEventTap that buffers keystrokes before forwarding is intercepting during transient processing.

The consent exception (Section 2511(2)(d)) requires meaningful consent. A buried EULA that describes "audio Inputs" collection does not constitute consent to system-wide keystroke interception.

GDPR [8] (if EU users exist)

  • Article 5(1)(b) — Purpose limitation: Data collected for dictation cannot be repurposed for keystroke analytics.
  • Article 5(1)(c) — Data minimisation: Intercepting ALL keyboard events system-wide when the feature requires only dictation-related input violates minimisation.
  • Article 25 — Data protection by design: Always-on monitoring as default violates this principle.

Penalties: up to 20 million euros or 4% of annual global turnover.

Product Liability — Degrading Hardware Functionality

Lost keystrokes caused by the event tap effectively degrade the user's keyboard hardware. Under trespass to chattels theory:

  • eBay v. Bidder's Edge (N.D. Cal. 2000) — Granted preliminary injunction where automated systems burdened hardware resources.
  • Intel Corp. v. Hamidi (Cal. 2003) — Acknowledged that trespass to chattels requires actual harm to computer system functioning. Lost keystrokes constitute such harm.

Apple's Own Guidelines

Apple's CGEventTap documentation [1] warns that event taps should be used "judiciously." The Input Monitoring permission (macOS Catalina+) was created specifically to flag apps that monitor keyboard input. However, Wispr Flow's Accessibility permission request says "Allow Flow to be able to put text anywhere" — it does not mention that the permission also enables system-wide keystroke interception, buffering, and suppression.


Recommendations

For Wispr Flow Users

  1. Check if you're affected: Look at ~/Library/Logs/Wispr Flow/accessibility.log for "Suppressing event" entries
  2. Check your data: The 694 MB database at ~/Library/Application Support/Wispr Flow/flow.sqlite contains your dictation audio, transcripts, and browsing history
  3. Enable Privacy Mode: Settings > Data and Privacy — this claims to enable zero data retention
  4. Consider alternatives: macOS has built-in dictation (Fn Fn or Settings > Keyboard > Dictation) that doesn't require a third-party CGEventTap

For Wispr AI

  1. Use a passive event monitor (NSEvent.addGlobalMonitorForEvents) instead of an active CGEventTap. A passive monitor can detect the dictation shortcut without the ability to suppress other keystrokes.
  2. Don't buffer keystrokes. The buffer is the source of stalls, stale keys, and lost events. If you must use a CGEventTap, process events synchronously in the callback.
  3. Disable the event tap when dictation is not active. There is no reason to intercept every keystroke 24/7 for an app that only needs to detect a keyboard shortcut.
  4. Disclose keyboard interception in the privacy policy. "Audio Inputs" does not cover CGEventTap installation.
  5. Enable App Sandbox and Hardened Runtime. Disabling library validation on an app with Accessibility permissions is a security liability for every user.
  6. Reduce analytics scope. 1,183 analytics events and 4 telemetry services is excessive for a dictation app.

For Apple

  1. Separate Accessibility from Input Monitoring permissions. Currently, Accessibility permission grants both screen reading and keyboard interception — these should be separate consent dialogs with separate descriptions.
  2. Show active CGEventTaps in System Settings. Users should be able to see which apps have active keyboard event taps and disable them.
  3. Require disclosure of event tap type. Apps should declare whether they use active (filtering) or passive (read-only) event taps, and the permission dialog should reflect this.

Methodology

This investigation used the following techniques:

  1. Process enumerationps aux to identify Wispr Flow's 10+ running processes
  2. Binary analysisstrings and codesign on all executables in the app bundle
  3. Entitlement extractioncodesign -d --entitlements - on all 6 binaries
  4. Log analysis — Direct reading of ~/Library/Logs/Wispr Flow/accessibility.log (1.0 MB) and main.log (1.2 MB)
  5. Database forensics — SQLite analysis of ~/Library/Application Support/Wispr Flow/flow.sqlite (694 MB)
  6. IPC protocol analysis — Extracted from binary strings and log correlation
  7. Network analysis — Extracted API endpoints and analytics service identifiers from binary strings
  8. Marketing claim comparison — Fetched and analyzed wisprflow.ai, privacy policy, and data controls pages

All evidence is from the installed application (version 1.4.752) and its own log files. No network traffic interception, reverse engineering tools, or decompilation was used. The app's own logs provided the most damning evidence.


Key Takeaways

  1. Every keystroke is intercepted — Wispr Flow installs a system-wide CGEventTap that captures every key press before any application receives it, even when dictation is not active.

  2. Every URL you visit is logged — the app uses the Accessibility API to track which application is in the foreground and which website you're on, logging 1,688 app/URL events in a 30-hour window.

  3. Your screen content is read — the app traverses the full Accessibility tree of the active application (up to 214 elements, 9 levels deep), reading on-screen text and sending it to cloud servers alongside your audio.

  4. 198 MB of audio recordings are stored locally — a 694 MB SQLite database contains 3,404 dictation entries with raw audio, transcripts, accessibility tree content, and a needsUploading flag. Uploads run hourly, even with data sharing toggled off.

  5. None of this is disclosed in the privacy policy, which describes collecting "audio Inputs" and "Usage Data."

  6. The security entitlements disable macOS Hardened Runtime protections, making the app (and its privileged keyboard access) vulnerable to code injection by any process on the system.

A voice dictation app should listen to your microphone when you tell it to. It should not intercept every keystroke, track every website you visit, traverse your screen content, and store hundreds of megabytes of audio recordings — all while running with security protections disabled.


The author is a software engineer who discovered this issue while debugging a non-functional spacebar. All findings are based on the app's own log files, binary strings, database contents, and publicly available marketing materials. This post is for informational purposes and does not constitute legal advice.

If you've experienced similar issues with Wispr Flow or other keyboard-intercepting applications, reach out — collective user reports strengthen consumer protection actions.


Appendix A: Raw Evidence Artifacts

Evidence 1: Code Signing Identity

$ codesign -dvvv "/Applications/Wispr Flow.app"

Executable=/Applications/Wispr Flow.app/Contents/MacOS/Wispr Flow
Identifier=com.electron.wispr-flow
Format=app bundle with Mach-O thin (arm64)
CodeDirectory v=20500 size=643 flags=0x10000(runtime) hashes=9+7 location=embedded
Authority=Developer ID Application: Wispr AI INC (C9VQZ78H85)
Authority=Developer ID Certification Authority
Authority=Apple Root CA
Timestamp=Apr 3, 2026 at 10:01:13 AM
Notarization Ticket=stapled
TeamIdentifier=C9VQZ78H85
Sealed Resources version=2 rules=13 files=687

$ codesign -dvvv ".../swift-helper-app-dist/Wispr Flow.app"

Executable=.../swift-helper-app-dist/Wispr Flow.app/Contents/MacOS/Wispr Flow
Identifier=com.electron.wispr-flow.accessibility-mac-app
Format=app bundle with Mach-O universal (x86_64 arm64)
Authority=Developer ID Application: Wispr AI INC (C9VQZ78H85)
TeamIdentifier=C9VQZ78H85
Sealed Resources version=2 rules=13 files=5

Apple notarized this app. The accessibility-mac-app bundle identifier for the Swift helper confirms its purpose.

Evidence 2: Entitlements (all 6 binaries identical)

$ codesign -d --entitlements - "/Applications/Wispr Flow.app"

[Dict]
    [Key] com.apple.security.cs.allow-dyld-environment-variables
    [Value] [Bool] true
    [Key] com.apple.security.cs.allow-jit
    [Value] [Bool] true
    [Key] com.apple.security.cs.allow-unsigned-executable-memory
    [Value] [Bool] true
    [Key] com.apple.security.cs.disable-library-validation
    [Value] [Bool] true
    [Key] com.apple.security.device.audio-input
    [Value] [Bool] true
    [Key] com.apple.security.device.camera
    [Value] [Bool] true

Note: com.apple.security.app-sandbox is absent. The app runs unsandboxed with full user privileges.

Evidence 3: Swift Helper Startup Sequence (accessibility.log)

[2026-04-03 11:58:21.492] [Info] - [Sentry Helper] Init: sentryLocalDebug=false, isProduction=true, hasDSN=true, enabled=true
[2026-04-03 11:58:21.506] [Info] - Launching swift helper app
[2026-04-03 11:58:21.507] [Info] - Starting stdin reader
[2026-04-03 11:58:21.507] [Info] - Starting message processing timer
[2026-04-03 11:58:21.521] [Info] - Received IPC message: StartAllIntervals
[2026-04-03 11:58:21.522] [Info] - Received IPC message: UpdateFeatureFlags
[2026-04-03 11:58:21.523] [Info] - Received IPC message: UpdateShortcuts
[2026-04-03 11:58:21.523] [Info] - Received IPC message: StartAccessibilityServices
[2026-04-03 11:58:21.529] [Info] - FocusChangeDetector subscribed to app change notifications.
[2026-04-03 11:58:21.529] [Info] - IsAudioInterruptionMonitoringEnabled feature flag is enabled, starting monitor
[2026-04-03 11:58:21.594] [Info] - Initiating accessibility API functionality, textbox monitoring, and event taps
[2026-04-03 11:58:21.657] [Info] - Keyboard device count: 1
[2026-04-03 11:58:21.657] [Warn] - Found keyboard device with missing metadata. Vendor ID: unknown, Product ID: unknown, Product: Apple Internal Keyboard / Trackpad
[2026-04-03 11:58:21.657] [Info] - Found an Apple Internal keyboard with missing metadata. Assuming Fn key available.
[2026-04-03 11:58:21.683] [Info] - Starting keyboard service
[2026-04-03 11:58:21.683] [Info] - Processing key event buffer
[2026-04-03 11:58:21.683] [Info] - Starting run loop in keyboard service

From Sentry init to active keyboard interception: 191 milliseconds. The CGEventTap is active before the user has any indication the app is running.

Evidence 4: All Keyboard Service Strings (from binary)

$ strings "Wispr Flow.app/.../swift-helper-app-dist/.../Wispr Flow" | grep -E '^(Keyboard|Suppress|eventTap|curKeys|keyEvent|stale|Flush|buffer|Processing|Exiting|Starting keyboard|Hit max|Sending first)'

buffer
bufferLock
curKeysDown
curKeysDown is non-empty on paste:
eventTap
eventTapRunLoop
Exiting processKeyEventBuffer
Flushing buffered keyboard events
Flushing timed out.
Hit max keyboard service event tap retries, shutting down
Keyboard device count:
Keyboard event buffer stalled
Keyboard service already running
Keyboard service event tap disabled, attempting to restart tap
Keyboard Vendor ID:
KeyboardService
keyEventQueue
keyEventSendQueue
keyEventTimer
Processing key event buffer
Sending first keypress event
staleKeys
staleKeysResponse
StaleKeysResponse
StaleKeysResponsePayload
Starting keyboard service
Starting run loop in keyboard service
Suppressing escape key during dictation
Suppressing event. Cur keys down:

These are compiled into the binary. The string Suppressing event. Cur keys down: is a format string that generates the log lines we see — confirming the suppression is intentional code, not a side effect.

Evidence 5: The 145 Suppressed Spacebars (full log excerpt)

[2026-04-04 16:56:08.272] [Info] - Suppressing event. Cur keys down: [61, 49], key code: 49, shortcut: [49, 61]
[2026-04-04 16:56:53.639] [Info] - Suppressing event. Cur keys down: [61, 49], key code: 49, shortcut: [49, 61]
[2026-04-04 16:56:53.912] [Info] - Suppressing event. Cur keys down: [61, 49], key code: 49, shortcut: [49, 61]
[2026-04-04 16:56:55.739] [Info] - Suppressing event. Cur keys down: [61, 49], key code: 49, shortcut: [49, 61]
[2026-04-04 16:56:57.496] [Info] - Suppressing event. Cur keys down: [61, 49], key code: 49, shortcut: [49, 61]
[2026-04-04 16:56:58.978] [Info] - Suppressing event. Cur keys down: [61, 49], key code: 49, shortcut: [49, 61]
[2026-04-04 16:57:01.408] [Info] - Suppressing event. Cur keys down: [61, 49], key code: 49, shortcut: [49, 61]
[2026-04-04 16:57:01.890] [Info] - Suppressing event. Cur keys down: [61, 49], key code: 49, shortcut: [49, 61]
[2026-04-04 16:57:03.396] [Info] - Suppressing event. Cur keys down: [61, 49], key code: 49, shortcut: [49, 61]
[2026-04-04 16:57:07.674] [Info] - Suppressing event. Cur keys down: [61, 49], key code: 49, shortcut: [49, 61]
[2026-04-04 16:57:10.580] [Info] - Suppressing event. Cur keys down: [61, 49], key code: 49, shortcut: [49, 61]
[2026-04-04 16:57:10.872] [Info] - Suppressing event. Cur keys down: [61, 49], key code: 49, shortcut: [49, 61]
[2026-04-04 16:57:11.794] [Info] - Suppressing event. Cur keys down: [61, 49], key code: 49, shortcut: [49, 61]
[2026-04-04 16:57:14.672] [Info] - Suppressing event. Cur keys down: [61, 49], key code: 49, shortcut: [49, 61]
...
[145 total entries across 9 minutes 34 seconds — key 61 (Right Option) stuck the entire time]
...
[2026-04-04 17:05:41.239] [Info] - Suppressing event. Cur keys down: [61, 49], key code: 49, shortcut: [49, 61]
[2026-04-04 17:05:41.406] [Info] - Suppressing event. Cur keys down: [61, 49], key code: 49, shortcut: [49, 61]
[2026-04-04 17:05:41.605] [Info] - Suppressing event. Cur keys down: [61, 49], key code: 49, shortcut: [49, 61]
[2026-04-04 17:05:41.781] [Info] - Suppressing event. Cur keys down: [61, 49], key code: 49, shortcut: [49, 61]
[2026-04-04 17:05:41.962] [Info] - Suppressing event. Cur keys down: [61, 49], key code: 49, shortcut: [49, 61]
[2026-04-04 17:05:42.117] [Info] - Suppressing event. Cur keys down: [61, 49], key code: 49, shortcut: [49, 61]

Note the final burst: 17:05:41.239 through 17:05:42.117 — 7 spacebar presses in 0.878 seconds, all suppressed. This is me rapidly hitting the spacebar trying to make it work.

Evidence 6: Buffer Flush Events (16 in 30 hours)

[2026-04-03 14:42:37.098] [Warn] - Flushing buffered keyboard events
[2026-04-03 14:43:58.108] [Warn] - Flushing buffered keyboard events
[2026-04-03 16:21:00.537] [Warn] - Flushing buffered keyboard events
[2026-04-04 13:59:31.774] [Warn] - Flushing buffered keyboard events
[2026-04-04 14:00:42.613] [Warn] - Flushing buffered keyboard events      ← 71s gap
[2026-04-04 14:06:21.116] [Warn] - Flushing buffered keyboard events
[2026-04-04 14:06:53.177] [Warn] - Flushing buffered keyboard events      ← 32s gap
[2026-04-04 14:45:35.429] [Warn] - Flushing buffered keyboard events
[2026-04-04 14:51:59.501] [Warn] - Flushing buffered keyboard events
[2026-04-04 14:51:59.511] [Warn] - Flushing buffered keyboard events      ← 10ms gap (double flush!)
[2026-04-04 15:06:49.805] [Warn] - Flushing buffered keyboard events
[2026-04-04 16:19:54.366] [Warn] - Flushing buffered keyboard events
[2026-04-04 16:27:30.766] [Warn] - Flushing buffered keyboard events

The double flush at 14:51:59 (10ms apart) suggests a race condition in the flush logic itself.

Evidence 7: Stale Key Recovery Failures

[2026-04-03 15:09:31.619] [Info] - Removing stale keys from curKeysDown: [48]    ← Tab stuck
[2026-04-03 15:31:31.491] [Info] - Removing stale keys from curKeysDown: [48]    ← Tab stuck again
[2026-04-04 05:41:53.454] [Info] - Removing stale keys from curKeysDown: [48]    ← Tab stuck overnight

[2026-04-04 16:56:51.383] [debug] Stale key recovery: scheduling async removeStaleKeys + retry for keyCode 53
[2026-04-04 16:56:52.697] [info]  Removing stale keys: 53                         ← Escape cleared
[2026-04-04 17:01:15.239] [info]  Removing stale keys: 49                         ← Spacebar cleared
[2026-04-04 17:03:25.319] [info]  Removing stale keys: 0                          ← Key 0 cleared

                                                                                    ← Key 61 NEVER cleared

Evidence 8: Upload Despite "Data Sharing Off"

[2026-04-04 14:02:01.849] [info]  Uploading history, 6 transcripts with needsUploading: true
[2026-04-04 14:02:01.860] [warn]  Usage data sharing is off, only uploading metadata
[2026-04-04 14:02:01.861] [warn]  Batch contains 6 transcripts, but would not exceed the size limit, skipping upload to batch.

[2026-04-04 15:00:35.610] [info]  Uploading history, 9 transcripts with needsUploading: true
[2026-04-04 15:00:35.618] [warn]  Usage data sharing is off, only uploading metadata
[2026-04-04 15:00:35.619] [info]  Uploading 8 history rows, size: 5222
[2026-04-04 15:00:35.619] [info]  AriaWebClient request { customAttributes: { method: 'post', endpoint: '/history/upload' } }
[2026-04-04 15:00:36.082] [info]  Uploaded 8 transcripts with IDs: [8 UUIDs redacted]

"Usage data sharing is off, only uploading metadata" — and yet it still uploads. 5,222 bytes across 8 entries includes app names, URLs, durations, word counts, and transcript entity IDs.

Evidence 9: gRPC Transcription Pipeline

[2026-04-04 14:58:54.436] [info]  Using gRPC rollout variant: grpcBaseten
[2026-04-04 14:58:54.436] [info]  Setting up gRPC transcription client
[2026-04-04 14:58:54.437] [info]  Making gRPC request with params: { retryParams: { encoding: 'wav', useHttp: false } }
[2026-04-04 14:58:54.438] [info]  Opening gRPC TranscribeStream
[2026-04-04 14:58:54.438] [info]  Using gRPC server variant grpcBaseten at model-v31pl413.grpc.api.baseten.co
[2026-04-04 14:58:54.438] [debug] gRPC request languages: ["en","zhcn"] → [1,3]
[2026-04-04 14:58:54.539] [info]  Initialized OPUS encoder with WebCodecs
[2026-04-04 14:58:54.745] [info]  [App Change] Received app change notification - App: Ghostty, BundleID: com.mitchellh.ghostty, URL: unknown, App type: other
[2026-04-04 14:58:54.746] [debug] Sent context update over gRPC stream                    ← app context sent
[2026-04-04 14:58:54.754] [debug] Sent context update over gRPC stream                    ← more context
[2026-04-04 14:58:54.771] [info]  AX context collection took 336ms                        ← reading your screen
[2026-04-04 14:58:54.772] [debug] Sent context update over gRPC stream                    ← screen content sent
[2026-04-04 14:58:54.827] [info]  gRPC response headers: replicaId: bt-deployment-32p78zx-00001-deployment-6c75dfc964-kxh5r
[2026-04-04 14:58:55.031] [info]  TCP ping succeeded { host: 'model-v31pl413.grpc.api.baseten.co', port: 443, pingMs: 593 }
[2026-04-04 14:58:55.518] [info]  /llm/extract_asr_words response time: 747.08ms          ← LLM proper noun extraction
[2026-04-04 14:58:55.519] [info]  Extracted 5 proper nouns
[2026-04-04 14:58:55.520] [debug] Sent context update over gRPC stream                    ← proper nouns sent
[2026-04-04 14:58:57.793] [info]  Waiting for gRPC completion
[2026-04-04 14:58:57.794] [info]  Final audio received, ending gRPC stream
[2026-04-04 14:58:58.115] [info]  Updating dictation state with status raw_transcript and last processed index 83
  message: 'Request completion metrics (gRPC)',
  webSocketResponseTimeMsecs: 416,
  webSocketNetworkOverheadMsecs: 97,
  basetenPingTimeAtStreamStartMsecs: 593,
  basetenCommitAckTimeMsecs: 96,
    transcribe: 0.21,                                                                      ← 210ms model inference
    transcribe_overhead: 0.009,
[2026-04-04 14:58:58.211] [info]  gRPC transcription successful

Five context updates sent over the gRPC stream before transcription completes: app name, bundle ID, URL, Accessibility tree text, and LLM-extracted proper nouns.

Evidence 10: App & URL Surveillance Log

[2026-04-04 16:56:42.612] [Info] - Sending application info request for bundle ID: com.google.Chrome and URL: localhost
[2026-04-04 16:56:50.463] [Info] - Sending application info request for bundle ID: com.mitchellh.ghostty and URL: unknown
[2026-04-04 16:57:27.364] [Info] - Found AXWebArea element in app: com.google.Chrome. Processed 194 elements in 0.05s, reaching depth 9
[2026-04-04 16:57:27.365] [Info] - Sending application info request for bundle ID: com.google.Chrome and URL: [redacted]
[2026-04-04 16:58:52.394] [Info] - Found AXWebArea element in app: com.google.Chrome. Processed 194 elements in 0.05s, reaching depth 9
[2026-04-04 16:58:52.395] [Info] - Sending application info request for bundle ID: com.google.Chrome and URL: [redacted]
[2026-04-04 17:00:33.187] [Info] - Found AXWebArea element in app: com.google.Chrome. Processed 194 elements in 0.07s, reaching depth 9
[2026-04-04 17:00:33.188] [Info] - Sending application info request for bundle ID: com.google.Chrome and URL: [redacted]
[2026-04-04 17:01:34.056] [Info] - Found AXWebArea element in app: com.google.Chrome. Processed 194 elements in 0.05s, reaching depth 9
[2026-04-04 17:01:34.057] [Info] - Sending application info request for bundle ID: com.google.Chrome and URL: github.com
[2026-04-04 17:02:50.999] [Info] - Found AXWebArea element in app: com.google.Chrome. Processed 194 elements in 0.05s, reaching depth 9
[2026-04-04 17:02:51.000] [Info] - Sending application info request for bundle ID: com.google.Chrome and URL: github.com
[2026-04-04 17:05:21.198] [Info] - Found AXWebArea element in app: com.google.Chrome. Processed 214 elements in 0.11s, reaching depth 9
[2026-04-04 17:05:21.198] [Info] - Sending application info request for bundle ID: com.google.Chrome and URL: x.com

Every tab switch, every URL change — logged and sent over IPC to the Electron process.

Evidence 11: Telemetry Shutdown Sequence

[2026-04-04 17:05:55.518] [info]  Sentry client shutdown
[2026-04-04 17:05:55.725] [info]  Posthog client shutdown
[2026-04-04 17:05:56.448] [info]  Child processes, posthog, sentry, and segment have shut down

Three analytics clients explicitly shut down: Sentry, PostHog, Segment. Datadog logging was also active (identified from binary strings).

Evidence 12: Database Schema and Size

$ ls -lah ~/Library/Application\ Support/Wispr\ Flow/flow.sqlite
-rw-r--r--  694M  Apr  4 17:05  flow.sqlite

$ sqlite3 flow.sqlite "SELECT COUNT(*) FROM History;"
3404

$ sqlite3 flow.sqlite "SELECT ROUND(SUM(length(audio))/1024.0/1024.0, 2) FROM History;"
198.15   (MB of raw audio)

Evidence 13: The EditedTextManager Reading Your Screen

[2026-04-04 14:58:58.328] [Info] - Starting edited text listener with EditTextManager v2.
[2026-04-04 14:58:58.330] [Info] - Textbox found for edited text listener.
[2026-04-04 14:58:58.332] [Info] - Pasted text in editable textbox
[2026-04-04 14:58:58.332] [Info] - Completed processing PasteText in 114.79ms. Sending IPC response.
[2026-04-04 14:58:58.336] [Warn] - Textbox contents, length 36191 are too long to check for dictated text, skipping
[2026-04-04 14:58:58.817] [Info] - Executing clipboard restoration
[2026-04-04 14:58:58.818] [Info] - Restoring clipboard contents with 4 types

The app reads 36,191 characters from the active textbox — the full contents of whatever you're editing.

Evidence 14: Process Resource Consumption

From the app's own internal monitoring:

{
  'Wispr Flow Helper (GPU)':      { cpu: '1.7%', memory: '64.8 MiB' },
  'Wispr Flow Helper':            { cpu: '0.0%', memory: '47.4 MiB' },
  'Wispr Flow Helper (Renderer)': { cpu: '0.0%', memory: '62.8 MiB' },
  'Wispr Flow Helper (Plugin)':   { cpu: '0.0%', memory: '41.9 MiB' }
}

These are just 4 of the 10+ Wispr processes. The Swift helper (Wispr Flow native process) consumed an additional ~70 MB and significant CPU for the CGEventTap callback processing.

References

  1. [1]CGEventTapCreate — Apple Developer Documentation. Warns event taps should be used "judiciously"
  2. [2]Hardened Runtime — Apple Developer Documentation. macOS security feature that Wispr disables via entitlements
  3. [3]TCC (Transparency, Consent, and Control) — Apple Developer Documentation. macOS permission framework
  4. [4]FTC Act Section 5 — 15 U.S.C. § 45. Unfair or deceptive acts or practices
  5. [5]Federal Wiretap Act — 18 U.S.C. § 2511. Interception of electronic communications
  6. [6]California Penal Code § 631. Wiretapping
  7. [7]CCPA/CPRA — Cal. Civ. Code § 1798.100(b). Proportionality requirement
  8. [8]GDPR Article 5. Principles relating to processing of personal data
  9. [9]In re Goldenshores Technologies (2014). "Brightest Flashlight" app — geolocation beyond disclosure
  10. [10]FTC v. DesignerWare (2012). Keystroke/screenshot monitoring on rental computers
  11. [11]FTC v. SpyFone (2021). Stalkerware company banned from surveillance business
  12. [12]In re Zoom (2020). Deceptive encryption claims
  13. [13]Wispr Flow Privacy Policy
  14. [14]Wispr Flow Data Controls
  15. [15]Wispr Flow 1.4.752 (Electron) / Swift helper built with Xcode 26.0.1 (SDK macOS 26.0). Version analyzed

Appendix B: Feature Flags Dump

The PostHog feature flag cache (extracted from main.log) reveals Wispr Flow's internal configuration and roadmap. Selected flags with security/privacy implications:

{
  "sampling-rate": { "enabled": true, "payload": 0.0001 },
  "complex-textbox-extraction": { "enabled": true },
  "ax-context-v2": { "enabled": true },
  "mouse-flow": { "enabled": true },
  "kill-orphan": { "enabled": true },
  "is-async-helper-write-queue-enabled": { "enabled": true },
  "focus-change-detector-app-change": { "enabled": true },
  "is-audio-interruption-monitoring-enabled": { "enabled": true },
  "style-personalization": { "enabled": true, "variant": "test" },
  "use-grpc-client-desktop": { "enabled": true, "payload": { "fallbackCooldownMs": 1800000 }},
  "grpc-desktop-rollout": { "variant": "grpcBaseten", "payload": { "url": "model-v31pl413.grpc.api.baseten.co" }},
  "transaction-sampling-rates": { "payload": {
    "dictation.complete": 0.1,
    "app.startup": 1,
    "auth.*": 0.25,
    "update.*": 1,
    "helper.*": 0.01,
    "default": 0.05
  }},
  "nudge-all": { "enabled": true, "payload": {
    "com.tinyspeck.slackmacgap": true,
    "im.beeper": true,
    "com.apple.MobileSMS": true,
    "com.apple.Notes": true,
    "mail.google.com": true,
    "claude.ai": true,
    "com.superhuman.electron": true,
    "com.google.Chrome": false
  }},
  "internal-data-sharing": { "enabled": false },
  "send-logs": { "enabled": false },
  "skip-edited-text-manager": { "enabled": false },
  "cursor-integration": { "enabled": false },
  "composer": { "enabled": false },
  "use-ensemble-model": { "enabled": false }
}

Notable entries:

  • nudge-all: A hardcoded list of apps where Wispr will nudge users to dictate. It knows you're using Slack, iMessage, Notes, Gmail, Claude, Superhuman — and decides per-app whether to show nudges.
  • mouse-flow: Mouse tracking is enabled.
  • complex-textbox-extraction: Enhanced text field content extraction.
  • ax-context-v2: Second-generation Accessibility context system.
  • transaction-sampling-rates: 100% of app startups are sampled; 10% of dictation completions; 25% of auth events. The helper.* rate (1%) covers the Swift helper's keyboard service events.
  • internal-data-sharing: Currently disabled, but the flag's existence implies infrastructure for sharing user data internally.
  • send-logs: Currently disabled, but the flag's existence implies the ability to upload the full accessibility.log (which contains every suppressed keystroke, every app/URL visited, every AX tree traversal).

Appendix B: IPC Protocol Reference

The Electron ↔ Swift Helper IPC uses JSON messages over stdin/stdout pipes. Documented message types from binary analysis:

Electron → Helper:

MessagePurpose
StartAccessibilityServicesInit CGEventTap + AX monitoring
UpdateShortcutsPush new keyboard shortcuts
SimulateKeyPressInject synthetic key events into the system
CheckStaleKeysRequest stale key cleanup
GetTextBoxInfoRead focused textbox contents via AX API
GetSelectedTextViaCopySimulate Cmd+C to grab selected text
GetDictatedTextPositionGet cursor position for text insertion
UpdateEditedTextPush edited text state
WindowsKeyUpSimulationSimulate key-up for stuck keys
HelperAppShutdownGraceful shutdown

Helper → Electron:

MessagePurpose
KeypressEventEvery keypress/release with keycode + event type
AppInfoUpdateFrontmost app changed (includes bundle ID + URL)
TrackAnalyticsEventAnalytics event from helper for telemetry
StaleKeysResponseResult of stale key check
CursorContextUpdateIDE cursor context
AppContextUpdateApp context with surrounding text
AppContextHTMLFull HTML content from app
AccessibilityErrorAX error notification

The KeypressEvent payload includes: keycode, keycodes (array), eventType (press/release), and meta_keycode_mismatch tracking. Every single keypress on the entire system is serialized to JSON, sent over a pipe, parsed by the Electron main thread, and processed — introducing the latency and race conditions that cause the stale key bug.

Appendix C: The EditedTextManager — Monitoring How You Edit

The Swift helper includes two versions of an "edited text manager" (EditedTextManager and EditedTextManager2, with v2 currently active). From the logs:

[2026-04-04 14:58:58.328] [Info] - Starting edited text listener with EditTextManager v2.
[2026-04-04 14:58:58.330] [Info] - Textbox found for edited text listener.
[2026-04-04 14:58:58.332] [Info] - Pasted text in editable textbox
[2026-04-04 14:58:58.336] [Warn] - Textbox contents, length 36191 are too long to check for dictated text, skipping

After every dictation, the app:

  1. Starts an "edited text listener" that monitors the text field
  2. Reads the full textbox contents via Accessibility API (up to 36,191 characters observed)
  3. Compares the dictated text with what the user subsequently types/edits
  4. Stores the diff in userEditMetaData JSON and editedText columns

This means Wispr Flow monitors not just what you dictate, but how you edit afterwards — building a dataset of dictation → human correction pairs. The toneMatchedText and toneMatchPairs columns suggest this data feeds into a tone-matching model that adapts to your writing style.

The textboxContents column stores the entire contents of whatever text field you're using — not just the dictated portion. If you dictate into a terminal running vim with a config file open, or into a Slack DM thread, or into a password reset form, the full field contents are captured.

Have questions or experienced similar issues? Get in touch →