From 3835b661106b8403578a4ae07090c591fd485140 Mon Sep 17 00:00:00 2001 From: Samuel Reed Date: Fri, 31 Jul 2026 14:22:58 -0400 Subject: [PATCH 1/8] [hark] Design the native client that replaces Hammerspoon Spec for #2. Decisions: an LSUIElement bundle with a menu bar status item, capture absorbed in-process so rec stops being a child binary, config at ~/.config/hark/client.json with the key read from the server's own key file on a single-machine install, and Carbon RegisterEventHotKey rather than an event tap. The hotkey choice is the load-bearing one. RegisterEventHotKey needs no Accessibility grant, so the agent asks for permission to type rather than permission to watch you type. An event-tap agent would need exactly the all-or-nothing grant that made the Hammerspoon arrangement uncomfortable. It also makes two currently-silent failures visible: a missing Accessibility grant and a hotkey chord already owned by another app. Measured while writing this and folded into the design: identical Swift sources produce a different CDHash on every build. install-client.sh recompiles unconditionally today, which is harmless only because TCC attributes to Hammerspoon. Once the agent is the TCC principal, an unguarded rebuild invalidates its own Accessibility grant, so the installer has to hash its build inputs and skip the build when they match. Nothing on the user's machine gets deleted on migration. client/init.lua becomes a stub that binds no hotkey, so an existing symlink keeps resolving and the two clients never fight over the hotkey. Claude-Session: https://claude.ai/code/session_01VhMX8fBDkrNDpCmPYGycYX --- .../specs/2026-07-31-native-client-design.md | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-31-native-client-design.md diff --git a/docs/superpowers/specs/2026-07-31-native-client-design.md b/docs/superpowers/specs/2026-07-31-native-client-design.md new file mode 100644 index 0000000..aed99f5 --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-native-client-design.md @@ -0,0 +1,196 @@ +# Native client design — replacing the Hammerspoon Lua client + +Issue: [DRYCodeWorks/hark#2](https://github.com/DRYCodeWorks/hark/issues/2) +Date: 2026-07-31 + +## Why + +`install-client.sh` links `~/.hammerspoon/init.lua` to this repo's `client/init.lua`. +Hammerspoon has exactly one config file, so installing hark claims it. + +The permission story matters more than the tidiness one. Accessibility is granted to +Hammerspoon, not to hark. That is a general-purpose scriptable Lua runtime holding a +grant that can observe every keystroke, and its config is a symlink into a git repo, so +`git pull` changes what that grant covers without re-prompting. + +What Hammerspoon buys in return, and what this design has to replace, is a **stable +signed TCC identity**. hark currently rides on an already-trusted app by swapping a text +file. Taking that away means hark owns the identity problem itself. + +## Decisions + +| Question | Decision | +|---|---| +| Signing | Design the bundle and TCC identity now, ship ad-hoc signed, flip to Developer ID later without structural change | +| App shape | `LSUIElement` bundle with a menu bar status item | +| Capture | Absorbed in-process; `rec` as a separate child binary goes away | +| Config | `~/.config/hark/client.json`; on a single-machine install the key is read from `~/.config/hark/key` rather than copied | +| Hotkey | Carbon `RegisterEventHotKey`, with `CGEventTap` documented as the fallback | +| Migration | Nothing on the user's machine is deleted | + +### Why RegisterEventHotKey rather than an event tap + +`RegisterEventHotKey` registers one chord with the window server and delivers press and +release. It does **not** require Accessibility. Accessibility is still required, but only +to synthesize the ⌘V. + +That split is the point. hark asks for permission to *type*, not permission to *watch you +type*. An agent built on `CGEventTap` would need the same all-or-nothing grant that made +the Hammerspoon arrangement uncomfortable in the first place, with a smaller attack +surface but not a smaller permission. + +It also makes two failures visible that are currently silent: + +- Accessibility missing: the hotkey still registers, so the agent hears the press and + fails at the paste. It can say "heard ⌃⌥Space, can't paste" instead of nothing. + `client/init.lua` cannot do this — without Accessibility, `hs.hotkey.bind` never fires + at all, which the README spends three paragraphs explaining. +- Chord already owned by another app: `RegisterEventHotKey` returns an error. + `hs.hotkey.bind` returns success and quietly never fires, which is why the README has + to warn about ⌘⌥Space in prose. + +**Risk to verify first:** that `kEventHotKeyReleased` is delivered reliably enough for a +hold-to-talk interaction. This is the first spike, before any other code. If it is not, +fall back to `CGEventTap` and accept the larger grant. + +## Measured constraint: rebuilds change the code signature + +Identical Swift sources produce a different binary and a different CDHash on every build. +Verified on 2026-07-31 by compiling `client/rec.swift` twice and comparing: + +``` +build 1: CDHash=5cb20c7342e72829dcb0035fcb19844deb1e6b20 +build 2: CDHash=b5aab283cab02415d5fbf9f07ff72259f76c4109 +``` + +`install-client.sh:404` recompiles unconditionally on every run and the script documents +itself as safe to re-run. That is harmless today, because TCC attributes microphone access +to Hammerspoon and `rec`'s own signature is irrelevant. Once the agent **is** the TCC +principal, every re-run invalidates its own Accessibility grant under an ad-hoc signature. + +So the installer must skip the build entirely when nothing that affects the binary has +changed. It hashes the **inputs** — every Swift source, `Package.swift`, `Info.plist`, and +`swiftc --version` — stores that hash beside the installed bundle, and rebuilds only on a +mismatch. + +The guard has to key on inputs rather than on comparing the built binary against the +installed one, precisely because of the measurement above: identical sources produce a +different binary every time, so an output comparison would always differ and the guard +would never fire. + +This is load-bearing, not an optimisation. Under a future Developer ID signature the +constraint disappears, because TCC keys on Team ID plus bundle ID rather than the hash. + +## Architecture + +One SPM package. `swift build` produces the binary; `install-client.sh` wraps it in +`~/Applications/Hark.app` (no `sudo`); `swift test` runs the unit tests. `Package.swift` +is not an Xcode project, so the "one build command, no project file to maintain" property +survives. + +Bundle ID `com.drycodeworks.hark-agent`, matching the existing launchd label convention. +`Info.plist` carries `LSUIElement` and `NSMicrophoneUsageDescription`. Login start uses +`SMAppService.mainApp`, which sets the floor at macOS 13. + +| File | Responsibility | +|---|---| +| `Config.swift` | Load `client.json`; resolve the key inline or from `~/.config/hark/key`; validate the URL | +| `Hotkey.swift` | `RegisterEventHotKey` wrapper; press/release callbacks; registration failure | +| `Recorder.swift` | AVAudioEngine, Float32 → 16 kHz mono s16, in-memory WAV, the zero-frames signal | +| `DictateClient.swift` | `URLSession` POST; decode `{"text":…}` / `{"detail":…}`; map status to typed errors | +| `Paster.swift` | `NSPasteboard` set with no restore, then `CGEvent` ⌘V, gated on `AXIsProcessTrusted()` | +| `StatusItem.swift` | Menu bar icon and state; Quit, Reload config, Open log, Run diagnostics | +| `Diagnostics.swift` | Writes the status file that `install-client.sh --doctor` reads | +| `Log.swift` | Append-only log; lengths, never transcript content | + +### The WAV never touches disk + +`/tmp/hark.wav` goes away, and with it the stale-file hazard that `init.lua:357` guards +against with an `os.remove` before every recording. `AVAudioFile` writes to a URL, so +`Recorder` hand-builds the 44-byte header around the PCM buffer instead. The wire format +is already pinned by `tests/test_audio.py` and unchanged: 16 kHz mono 16-bit PCM. + +### The mic-status file stays a file + +It is tempting to let `--doctor` run the agent binary with a `--probe` flag. That is wrong +for the reason `install-client.sh:183-190` already documents: a probe launched from the +terminal tests the **terminal's** TCC grant, not the agent's, and produces a confidently +wrong PASS. The agent writes the status; `--doctor` reads it. Only the path changes, to +`~/.config/hark/client-status.json`. + +## Data flow + +1. ⌃⌥Space down. Return early if already recording, or if no key resolves. +2. Status item to recording, overlay shown, `AVAudioEngine` tap starts. +3. ⌃⌥Space up. Tap stops, WAV bytes returned in memory. +4. Zero frames captured: report the microphone permission cause, log, stop. +5. POST with `X-Hark-Key` and `Content-Type: audio/wav`. +6. `200` with empty text: transient "heard nothing", paste nothing. Not an error — the + energy gate tripped, or the transcript had no alphanumeric content. +7. `200` with text: set the pasteboard, synthesize ⌘V. Never Return. + +The clipboard is deliberately not restored, and auto-submit remains a hard non-goal. Both +carry over unchanged; see the comment above `hs.pasteboard.setContents` in +`client/init.lua` and the Non-goals section of the 2026-07-14 design spec. + +## Error handling + +The mapping in `init.lua:176-258` carries over as written. It is hard-won and is not being +redesigned: + +| Condition | Message names | +|---|---| +| Connection failure | The server URL, and that hark may not be running | +| 401 | The key mismatch, and the config path to fix | +| 415 | A client bug, not a microphone problem | +| 400 | Microphone permission, caught server-side | +| 503 | whisper-server down, `/tmp/hark-whisper.err` | +| Other | The status code and the server's `detail` | + +Messages render in a borderless transient overlay rather than Notification Center. A muted +notification is a silent failure, and silent failure is the thing this project repeatedly +designs against. + +## Testing + +Unit-testable with no permissions and no microphone: + +- config loading, key resolution (inline versus `~/.config/hark/key`), URL validation +- the WAV header builder, against `tests/fixtures/hello.wav` and `silence.wav` +- the status-to-error mapping, via a stubbed `URLProtocol` + +CI runs `swift build` and `swift test` on the macOS runner added in #1. + +Not testable in CI, stated as plainly as the README already states its own limits: TCC +grants, real hotkey delivery, real paste into a real window. Those remain exercised by use. + +## Migration + +Nothing on the user's machine is deleted. + +`client/init.lua` is replaced by a short stub that binds no hotkey and reports that hark +now runs as a native agent. An existing `~/.hammerspoon/init.lua` symlink keeps resolving +and simply does nothing, so the two clients never fight over ⌃⌥Space. The change lands in +this repo rather than in anyone's home directory. + +`install-client.sh` reports leftover Hammerspoon-era artifacts (`rec`, `hark-config.lua`, +`.hark-mic-status`, `.hark-mic-probe.wav`) and prints how to revoke Hammerspoon's grants +and remove the cask. It does not do either, because a script cannot revoke a TCC grant. + +## Non-goals + +- The Fn/🌐 key. It needs an event tap plus Input Monitoring, which is approach B. +- Pre-warming the audio engine. In-process capture makes it possible for the first time, + but the README's privacy trade-off is deliberate: holding the microphone open lights the + menu-bar indicator continuously, and that should stay an explicit choice. +- Developer ID signing and notarised releases. Designed for, deferred. +- Streaming transcription and auto-submit, per the 2026-07-14 spec's non-goals. + +## Open questions + +- Whether `kEventHotKeyReleased` is reliable enough for hold-to-talk. Spike first. +- Whether the ad-hoc rebuild actually invalidates the Accessibility grant in practice on + current macOS, or merely re-prompts. The rebuild guard is correct either way; this + determines how loudly the installer has to warn. +- Whether `~/Applications/Hark.app` or `/Applications` is the better home once signing + arrives and the app might be distributed as a release artifact. From f28ae60e10578334ea9ffd07f2948b1644118acd Mon Sep 17 00:00:00 2001 From: Samuel Reed Date: Fri, 31 Jul 2026 14:50:45 -0400 Subject: [PATCH 2/8] [hark] Revise the native client design after a six-reviewer panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All six reviewers returned REVISE on revision 1, and two of them independently killed its central argument. Revision 1 chose Carbon RegisterEventHotKey on the grounds that it asks for permission to type rather than permission to watch you type. That is wrong: synthesising Cmd+V needs Accessibility, and Accessibility is one atomic grant that also permits creating an event tap. It also claimed the API errors when another app owns the chord; the SDK header says the opposite and exclusivity needs kEventHotKeyExclusive. The mechanism is now undecided behind a Phase 0 spike, whose real question is whether a keyboard tap additionally needs Input Monitoring — README:245-247 suggests from this project's own experience that it might, which would revive the permission argument by a different route. Assuming a Developer ID deletes rather than fixes a whole section. CI builds, signs and notarises; the installer downloads instead of compiling, so it can no longer mint a new CDHash on every re-run and the input-hash guard goes away. It also drops the Xcode command line tools from the client machine, which is the point of the surrounding dependency work. Security findings that were missing entirely: the client never sanitised the server's response, so a spoofed server on the plaintext two-machine path could return a newline and have it typed into a terminal. There was no transport policy and no ATS declaration, so the client would have captured audio and then failed every POST. 400 was mapped to "microphone permission" inherited from the Lua client, but capture failures no longer reach the server and the server returns 400 for every InvalidAudioError, including a malformed header this client now builds by hand. Migration was insufficient rather than merely incomplete. A stub does not unload Lua already running in Hammerspoon, and leaving its grants in place preserves exactly the privilege escape this work exists to close. Also corrected: revision 1 claimed tests/test_audio.py pins the wire format. It validates sample width only, so a stereo 44.1 kHz WAV passes today. Claude-Session: https://claude.ai/code/session_01VhMX8fBDkrNDpCmPYGycYX --- .../specs/2026-07-31-native-client-design.md | 454 +++++++++++++----- 1 file changed, 321 insertions(+), 133 deletions(-) diff --git a/docs/superpowers/specs/2026-07-31-native-client-design.md b/docs/superpowers/specs/2026-07-31-native-client-design.md index aed99f5..45b5e8e 100644 --- a/docs/superpowers/specs/2026-07-31-native-client-design.md +++ b/docs/superpowers/specs/2026-07-31-native-client-design.md @@ -2,195 +2,383 @@ Issue: [DRYCodeWorks/hark#2](https://github.com/DRYCodeWorks/hark/issues/2) Date: 2026-07-31 +Revision: 2, after a six-reviewer panel returned 6/6 REVISE on revision 1 ## Why -`install-client.sh` links `~/.hammerspoon/init.lua` to this repo's `client/init.lua`. +`install-client.sh:573` links `~/.hammerspoon/init.lua` to this repo's `client/init.lua`. Hammerspoon has exactly one config file, so installing hark claims it. The permission story matters more than the tidiness one. Accessibility is granted to -Hammerspoon, not to hark. That is a general-purpose scriptable Lua runtime holding a -grant that can observe every keystroke, and its config is a symlink into a git repo, so -`git pull` changes what that grant covers without re-prompting. +Hammerspoon, not to hark. That is a general-purpose scriptable Lua runtime holding a grant +that can observe every keystroke, and its config is a symlink into a git repo, so `git +pull` changes what that grant covers without re-prompting. -What Hammerspoon buys in return, and what this design has to replace, is a **stable -signed TCC identity**. hark currently rides on an already-trusted app by swapping a text -file. Taking that away means hark owns the identity problem itself. +What Hammerspoon buys in return is a **stable signed TCC identity**. hark rides on an +already-trusted app by swapping a text file. Taking that away means hark owns the identity +problem itself, which is why this design assumes a Developer ID rather than deferring it. + +## What revision 1 got wrong + +Recorded because the errors are instructive, not to pad the document. + +**The hotkey rationale was wrong.** Revision 1 argued for Carbon `RegisterEventHotKey` +because it "asks for permission to type, not permission to watch you type." Synthesising +⌘V requires Accessibility, and Accessibility is a single atomic grant — holding it also +permits creating a `CGEventTap`. The claimed saving does not exist as stated. + +**The collision claim was wrong.** Revision 1 said `RegisterEventHotKey` errors when +another app owns the chord. The installed SDK says the opposite: + +> "The same hot key can, however, be registered by multiple applications... In Mac OS X +> 10.5 and later, you can request exclusive registration for your process only by passing +> `kEventHotKeyExclusive` for the `inOptions` parameter." +> — `CarbonEvents.h`, `RegisterEventHotKey` discussion + +**One claim was fabricated.** Revision 1 said `tests/test_audio.py` pins the wire format. +It does not: `audio.py:44-49` validates sample width only, so a stereo or 44.1 kHz WAV is +accepted today. Fixing that is now in scope (see Testing). + +**The mechanism question is therefore unresolved**, and revision 2 does not pre-decide it. +See Phase 0. ## Decisions | Question | Decision | |---|---| -| Signing | Design the bundle and TCC identity now, ship ad-hoc signed, flip to Developer ID later without structural change | -| App shape | `LSUIElement` bundle with a menu bar status item | +| Signing | **Developer ID assumed.** CI builds, signs and notarises; releases ship an artifact | +| Install | `install-client.sh` downloads and installs the artifact. It no longer compiles | +| App shape | `LSUIElement` bundle, menu bar status item, at `~/Applications/Hark.app` (no sudo) | | Capture | Absorbed in-process; `rec` as a separate child binary goes away | -| Config | `~/.config/hark/client.json`; on a single-machine install the key is read from `~/.config/hark/key` rather than copied | -| Hotkey | Carbon `RegisterEventHotKey`, with `CGEventTap` documented as the fallback | -| Migration | Nothing on the user's machine is deleted | +| Config | `~/.config/hark/client.json` holds the **server URL only**; the key always lives in `~/.config/hark/key` mode 600 | +| Hotkey | **Undecided — Phase 0 spike decides.** Carbon and `CGEventTap` are both live | +| Migration | Quit Hammerspoon and offer to revoke its grants. Delete nothing from `$HOME` | -### Why RegisterEventHotKey rather than an event tap +### Developer ID changes the shape of the problem -`RegisterEventHotKey` registers one chord with the window server and delivers press and -release. It does **not** require Accessibility. Accessibility is still required, but only -to synthesize the ⌘V. +Revision 1 measured that identical Swift sources produce a different CDHash on every +build, and built an installer-side input hash to avoid re-signing on every re-run. -That split is the point. hark asks for permission to *type*, not permission to *watch you -type*. An agent built on `CGEventTap` would need the same all-or-nothing grant that made -the Hammerspoon arrangement uncomfortable in the first place, with a smaller attack -surface but not a smaller permission. +That whole mechanism is **deleted**. With CI producing the binary, the installer never +compiles, so re-running it cannot mint a new CDHash. With Developer ID, TCC keys on Team ID +plus bundle ID rather than the hash, so grants survive updates. The measurement stands as +the justification for requiring Developer ID; it is no longer a constraint to engineer +around. -It also makes two failures visible that are currently silent: +Two consequences worth stating plainly: -- Accessibility missing: the hotkey still registers, so the agent hears the press and - fails at the paste. It can say "heard ⌃⌥Space, can't paste" instead of nothing. - `client/init.lua` cannot do this — without Accessibility, `hs.hotkey.bind` never fires - at all, which the README spends three paragraphs explaining. -- Chord already owned by another app: `RegisterEventHotKey` returns an error. - `hs.hotkey.bind` returns success and quietly never fires, which is why the README has - to warn about ⌘⌥Space in prose. +- Notarisation is not optional. A downloaded `.app` carries `com.apple.quarantine`, and an + un-notarised quarantined app is blocked outright on current macOS. Telling users to + `xattr -d` is the kind of friction this project avoids everywhere else. +- The client install no longer needs the Xcode command line tools. That removes a + multi-gigabyte dependency from the machine you dictate *from*, which is the point of the + wider dependency work (#3, #4, #5). -**Risk to verify first:** that `kEventHotKeyReleased` is delivered reliably enough for a -hold-to-talk interaction. This is the first spike, before any other code. If it is not, -fall back to `CGEventTap` and accept the larger grant. +`~/Applications` keeps the install sudo-free. Under Developer ID, TCC is keyed on the +signing identity rather than the path, so location is a packaging choice, not a permission +one. -## Measured constraint: rebuilds change the code signature +## Phase 0: the hotkey spike (gates everything else) -Identical Swift sources produce a different binary and a different CDHash on every build. -Verified on 2026-07-31 by compiling `client/rec.swift` twice and comparing: +Two questions decide the mechanism. Both are unresolved and neither should be answered by +argument. -``` -build 1: CDHash=5cb20c7342e72829dcb0035fcb19844deb1e6b20 -build 2: CDHash=b5aab283cab02415d5fbf9f07ff72259f76c4109 -``` - -`install-client.sh:404` recompiles unconditionally on every run and the script documents -itself as safe to re-run. That is harmless today, because TCC attributes microphone access -to Hammerspoon and `rec`'s own signature is irrelevant. Once the agent **is** the TCC -principal, every re-run invalidates its own Accessibility grant under an ad-hoc signature. - -So the installer must skip the build entirely when nothing that affects the binary has -changed. It hashes the **inputs** — every Swift source, `Package.swift`, `Info.plist`, and -`swiftc --version` — stores that hash beside the installed bundle, and rebuilds only on a -mismatch. +1. **Does a keyboard `CGEventTap` require Input Monitoring in addition to Accessibility?** + `README.md:245-247` states from this project's own experience that an `hs.eventtap` + watching `flagsChanged` needs Input Monitoring. If that holds for a keyDown/keyUp tap, + Carbon is one grant and the tap is two, and the permission argument survives revision + 1's bad reasoning by a different route. If it does not hold, the tap is free. +2. **Is `kEventHotKeyReleased` reliable enough for hold-to-talk?** Specifically under load, + and when the user rolls onto other modifiers mid-hold. -The guard has to key on inputs rather than on comparing the built binary against the -installed one, precisely because of the measurement above: identical sources produce a -different binary every time, so an output comparison would always differ and the guard -would never fire. +Build both, measure both, then write the mechanism into this document. Revision 1's +"`CGEventTap` documented as the fallback" is removed: pre-designing an unused branch is +speculative work, and if Carbon loses the spike the affected sections get rewritten +against what actually won. -This is load-bearing, not an optimisation. Under a future Developer ID signature the -constraint disappears, because TCC keys on Team ID plus bundle ID rather than the hash. +Whichever wins, the agent must detect and report its own failure to register rather than +going silent, because silence is the failure mode this project keeps designing against. +Carbon additionally requires `kEventHotKeyExclusive` if collision detection is wanted. ## Architecture -One SPM package. `swift build` produces the binary; `install-client.sh` wraps it in -`~/Applications/Hark.app` (no `sudo`); `swift test` runs the unit tests. `Package.swift` -is not an Xcode project, so the "one build command, no project file to maintain" property -survives. +One SPM package. CI runs `swift build` and `swift test`; the release job produces the +signed, notarised `Hark.app`. -Bundle ID `com.drycodeworks.hark-agent`, matching the existing launchd label convention. -`Info.plist` carries `LSUIElement` and `NSMicrophoneUsageDescription`. Login start uses -`SMAppService.mainApp`, which sets the floor at macOS 13. +Bundle ID `com.drycodeworks.hark-agent`. This is a **new** identifier that follows the +existing launchd label style; it is not an existing label. `Info.plist` carries +`LSUIElement`, `NSMicrophoneUsageDescription`, and the ATS declaration below. Login start +uses `SMAppService.mainApp`, setting the floor at macOS 13. | File | Responsibility | |---|---| -| `Config.swift` | Load `client.json`; resolve the key inline or from `~/.config/hark/key`; validate the URL | -| `Hotkey.swift` | `RegisterEventHotKey` wrapper; press/release callbacks; registration failure | -| `Recorder.swift` | AVAudioEngine, Float32 → 16 kHz mono s16, in-memory WAV, the zero-frames signal | -| `DictateClient.swift` | `URLSession` POST; decode `{"text":…}` / `{"detail":…}`; map status to typed errors | -| `Paster.swift` | `NSPasteboard` set with no restore, then `CGEvent` ⌘V, gated on `AXIsProcessTrusted()` | -| `StatusItem.swift` | Menu bar icon and state; Quit, Reload config, Open log, Run diagnostics | -| `Diagnostics.swift` | Writes the status file that `install-client.sh --doctor` reads | -| `Log.swift` | Append-only log; lengths, never transcript content | - -### The WAV never touches disk - -`/tmp/hark.wav` goes away, and with it the stale-file hazard that `init.lua:357` guards -against with an `os.remove` before every recording. `AVAudioFile` writes to a URL, so -`Recorder` hand-builds the 44-byte header around the PCM buffer instead. The wire format -is already pinned by `tests/test_audio.py` and unchanged: 16 kHz mono 16-bit PCM. - -### The mic-status file stays a file - -It is tempting to let `--doctor` run the agent binary with a `--probe` flag. That is wrong -for the reason `install-client.sh:183-190` already documents: a probe launched from the -terminal tests the **terminal's** TCC grant, not the agent's, and produces a confidently -wrong PASS. The agent writes the status; `--doctor` reads it. Only the path changes, to -`~/.config/hark/client-status.json`. +| `Config.swift` | Load `client.json`; read and trim the key file; enforce the transport policy | +| `Hotkey.swift` | Register the chord (mechanism per Phase 0); press/release; registration failure | +| `Recorder.swift` | AVAudioEngine capture, format conversion, in-memory WAV, capture-state reporting | +| `DictateClient.swift` | POST, decode, map status to typed errors, sanitise the response | +| `AgentController.swift` | The state machine, permissions, paste, status file, logging, menu bar | + +Revision 1 had eight files. `Paster`, `Log`, `Diagnostics` and `StatusItem` each had one +caller and no independent policy, so they collapse into `AgentController`. The three +remaining seams — config, capture, transport — are the ones with real test surface. + +The status item shows state and offers Quit only. "Reload config" and "Run diagnostics" +are removed; `install-client.sh --doctor` is already the diagnostic interface and a second +one would drift from it. + +### Capture, permissions, and a possible live bug + +Revision 1 said "zero frames captured" indicates missing microphone permission. That is how +`rec.swift:128` works today, and a reviewer asserts it is wrong: that a TCC-denied +`AVAudioEngine` delivers the expected buffers filled with **silence** rather than no +buffers, so the frame count never reaches zero. + +If that is correct, it is a live defect in shipped code, not merely a flaw in this design: +`rec.swift`'s exit-3 path would never fire, `init.lua:486-496`'s probe would always report +`ok`, and `--doctor`'s microphone check would be a confident false PASS. **Phase 0 must +verify this against a genuinely denied grant**, and if confirmed it gets its own issue +against the current client. + +Either way the design does not rely on frame counts for permission: + +- Query `AVCaptureDevice.authorizationStatus(for: .audio)` explicitly, and call + `requestAccess` at first launch to raise the consent dialog. The Microphone pane has no + "+" button and only lists apps that have already asked, so the request must happen. +- Probe at **launch**, not at first hotkey press, so a clean install has an + agent-authored status before anyone runs `--doctor`. +- Treat an all-silent buffer as its own reportable condition, distinct from a denied grant. + +Accessibility uses `AXIsProcessTrustedWithOptions` with the prompt option at first launch. +`AXIsProcessTrusted` alone only checks and never prompts, which leaves a fresh install with +no path to the grant. + +### The WAV is built in memory + +`/tmp/hark.wav` goes away, and with it the stale-file hazard `init.lua:357` guards against. +`AVAudioFile` writes to a URL, so `Recorder` builds the 44-byte header itself. + +The RIFF and data chunk sizes depend on total length, which is unknown until release, so +the header is written last or back-patched before the POST. Getting this wrong produces a +server 400, which is exactly why the 400 mapping below had to change. + +### Capture must be serialised + +The tap callback runs on an audio thread while key-up runs on the main queue. +`rec.swift:112` mutates `framesWritten` from the callback while `rec.swift:128` reads it +from `stop()` — an existing unsynchronised access that becomes more consequential in-process. + +Define an explicit boundary: an actor or serial queue owning the buffer, with stop waiting +for the tap to drain before the WAV is finalised. Stop must be idempotent. + +## State machine + +Revision 1 said only "return early if already recording", which leaves both a stuck-state +and an ordering hole. + +``` +idle ──press──► starting ──first buffer──► recording ──release──► stopping + ▲ │ + └──────────── paste / error ◄──── uploading ◄──── drain complete ────┘ +``` + +Rules: + +- A press outside `idle` is ignored. +- `stopping` exists so a press immediately after release cannot start a second capture + while the first is still draining. Revision 1 would have either overlapped captures or + dropped the second utterance. +- **One in-flight request.** `init.lua:319` clears its task before the async POST, so today + two quick utterances can paste in reverse order. Serialise: no new capture while a + request is pending, and drop a response whose sequence number is not current. +- **Maximum capture duration.** A missed release must not leave the engine running. In + memory this is unbounded growth rather than a growing file, so the cap stops capture and + returns to `idle`. +- Cleanup on sleep, app deactivation, and termination. ## Data flow -1. ⌃⌥Space down. Return early if already recording, or if no key resolves. -2. Status item to recording, overlay shown, `AVAudioEngine` tap starts. -3. ⌃⌥Space up. Tap stops, WAV bytes returned in memory. -4. Zero frames captured: report the microphone permission cause, log, stop. +1. Press. Ignore unless `idle` and the key resolves. +2. Capture starts; status item and overlay show recording. +3. Release. Tap drains, WAV finalised in memory. +4. No frames, or all-silent buffers: report the capture-side cause and stop. Do not POST. 5. POST with `X-Hark-Key` and `Content-Type: audio/wav`. -6. `200` with empty text: transient "heard nothing", paste nothing. Not an error — the - energy gate tripped, or the transcript had no alphanumeric content. -7. `200` with text: set the pasteboard, synthesize ⌘V. Never Return. +6. `200` with empty text: transient "heard nothing", paste nothing. Not an error. +7. `200` with text: **sanitise, then** set the pasteboard, verify the write, then ⌘V. -The clipboard is deliberately not restored, and auto-submit remains a hard non-goal. Both -carry over unchanged; see the comment above `hs.pasteboard.setContents` in -`client/init.lua` and the Non-goals section of the 2026-07-14 design spec. +Never Return. The clipboard is deliberately not restored. -## Error handling +### The client sanitises the response + +The server sanitises at `sanitize.py:23`, and revision 1 treated that as sufficient. It is +not. A compromised server, or anyone on the network path of a plaintext two-machine setup, +returns JSON the server's sanitiser never touched. "Never Return" protects nothing when the +payload itself carries `\r` or `\n` into a terminal without bracketed paste. + +`DictateClient` applies the same rule as `sanitize.py` — C0/C1 controls and Unicode line +separators replaced with a space, whitespace collapsed — plus a length cap, before the text +reaches the pasteboard. The server keeps its sanitiser; this is defence in depth, and the +Swift and Python implementations get the same test cases. + +If the pasteboard write fails, report it and **do not** synthesise ⌘V — otherwise the +keystroke pastes whatever was on the clipboard before, potentially into a terminal. + +## Transport policy + +The two-machine default is plaintext HTTP (`install-client.sh:503`) over what +`README.md:214` calls "a LAN address you trust". On that path an attacker reads the audio, +the transcript and `X-Hark-Key`, and can forge the response that the client then types. + +`Config` enforces: + +- plain HTTP permitted **only** for numeric loopback, +- HTTPS required for every other host, +- userinfo (`user@host`) rejected — `install-client.sh:230` already treats it as a defect, +- redirects not followed, +- an ephemeral `URLSession` so credentials and responses are not cached to disk. + +`Info.plist` declares the matching ATS exception. Revision 1 omitted ATS entirely, which +would have produced a client that captures audio and then fails every POST. -The mapping in `init.lua:176-258` carries over as written. It is hard-won and is not being -redesigned: +Two-machine users on Tailscale who want to keep HTTP need an explicit, documented opt-in; +it is not the default. + +## Configuration + +`client.json` holds the server URL. The key always lives at `~/.config/hark/key`, mode +600, written by whichever installer obtained it. Revision 1's inline-versus-file branch is +deleted: one path, one permission contract, nothing to drift. + +- `install-client.sh` creates `~/.config/hark` mode 700 before writing anything. On a + two-machine *client* the directory does not otherwise exist, because the server-side + `mkdir` ran on the other machine. +- The key file is read and **trimmed**: `config.py:129` writes `key + "\n"` and + `install-client.sh:427` strips it today. Sending the raw bytes 401s every request. + Reject embedded whitespace rather than silently trimming it. +- `client.json` is serialised by a real JSON encoder and replaced atomically. The current + heredoc at `install-client.sh:541` validates quotes only in the key (`:468`), so a + hand-entered URL containing a quote produces an unparseable file. +- `--doctor` must stop passing the key in argv (`install-client.sh:291`), where any local + account can read it from the process table. Use a config-file-fed curl or the agent. + +**Known residual risk.** Any same-UID process can rewrite `client.json` and point the +trusted agent at an attacker's server; mode 600 does not prevent this. Under Developer ID +the mitigation is available — pin the approved origin in a Keychain item protected by code +identity, and require in-app confirmation to change it. Recorded as a follow-up rather than +built now, because it needs its own design. + +## Error handling | Condition | Message names | |---|---| -| Connection failure | The server URL, and that hark may not be running | -| 401 | The key mismatch, and the config path to fix | +| Connection / transport failure | The URL, and that hark may not be running | +| 401 | Key mismatch, and the file to fix | | 415 | A client bug, not a microphone problem | -| 400 | Microphone permission, caught server-side | +| **400** | **Malformed or unsupported audio — a client format bug.** Show the server's `detail` | | 503 | whisper-server down, `/tmp/hark-whisper.err` | -| Other | The status code and the server's `detail` | +| Other | Status code and the server's `detail` | +| No frames / all silent | Capture-side: device selection and microphone permission | -Messages render in a borderless transient overlay rather than Notification Center. A muted -notification is a silent failure, and silent failure is the thing this project repeatedly -designs against. +Revision 1 carried `init.lua`'s "400 means microphone permission" across unchanged. That is +now wrong twice over: capture failures never reach the server, and `app.py:98-103` returns +400 for **every** `InvalidAudioError` — including a malformed header this client now builds +by hand. Sending users to Microphone settings for a header bug would be actively +misleading. -## Testing +Messages render in a borderless transient overlay rather than Notification Center, since a +muted notification is a silent failure. -Unit-testable with no permissions and no microphone: +## Status file -- config loading, key resolution (inline versus `~/.config/hark/key`), URL validation -- the WAV header builder, against `tests/fixtures/hello.wav` and `silence.wav` -- the status-to-error mapping, via a stubbed `URLProtocol` +The agent writes `~/.config/hark/client-status.json`; `--doctor` reads it. `--doctor` must +not run the agent binary itself: a probe launched from the terminal tests the *terminal's* +TCC grant and produces a confident false PASS, which is the trap `install-client.sh:183-190` +already documents. -CI runs `swift build` and `swift test` on the macOS runner added in #1. +Revision 1 said "only the path changes". Two things must also change: -Not testable in CI, stated as plainly as the README already states its own limits: TCC -grants, real hotkey delivery, real paste into a real window. Those remain exercised by use. +- **Atomic write** — temp file plus rename. `--doctor` reading mid-write would otherwise + see partial JSON. +- **Generation stamp** — the file records the agent launch it came from, and `--doctor` + rejects a status older than the running agent. Today `install-client.sh:200-204` accepts + any existing `ok` first line, so a stale file survives a revoked grant and reports PASS. -## Migration +## Testing + +Unit-testable with no permissions and no microphone: config loading, key trimming and +rejection, transport-policy enforcement, the WAV header builder (including back-patched +sizes), the status-to-error mapping via a stubbed `URLProtocol`, and **hostile response +sanitisation** — control characters, newlines, Unicode line separators, over-length text. -Nothing on the user's machine is deleted. +Server-side, fix the coverage revision 1 wrongly claimed existed: `audio.py` validates +sample width only, so add explicit assertions and server rejection for channel count and +sample rate alongside bit depth. -`client/init.lua` is replaced by a short stub that binds no hotkey and reports that hark -now runs as a native agent. An existing `~/.hammerspoon/init.lua` symlink keeps resolving -and simply does nothing, so the two clients never fight over ⌃⌥Space. The change lands in -this repo rather than in anyone's home directory. +CI runs `swift build`, `swift test`, and the existing pytest suite. + +Not testable in CI, stated as plainly as the README states its own limits: TCC grants, real +hotkey delivery, real paste into a real window. + +## Migration -`install-client.sh` reports leftover Hammerspoon-era artifacts (`rec`, `hark-config.lua`, -`.hark-mic-status`, `.hark-mic-probe.wav`) and prints how to revoke Hammerspoon's grants -and remove the cask. It does not do either, because a script cannot revoke a TCC grant. +**Nothing is deleted from the user's home directory.** But "report and move on" is not +enough, for two reasons the panel found: + +- A running Hammerspoon holds the old Lua **in memory**. Replacing the symlinked file + changes nothing until it reloads, so both clients bind ⌃⌥Space and fight. + `install-client.sh:586` already documents that Hammerspoon does not auto-reload. +- Leaving Hammerspoon's Accessibility and Microphone grants in place preserves exactly the + privilege escape that motivates this issue. Any same-UID process can later rewrite the + symlink target and reload it. + +So `install-client.sh`: + +1. quits Hammerspoon and confirms it no longer owns the chord before starting the agent, +2. offers `tccutil reset Accessibility org.hammerspoon.Hammerspoon` and the Microphone + equivalent, and prints how to remove the cask, +3. reports leftover artifacts without deleting them. + +`client/init.lua` becomes a **silent** no-op stub. Revision 1 had it show an alert, which +would fire from a still-running Hammerspoon that the installer is already handling. + +## Blast radius + +Revision 1 under-specified this and the panel enumerated it. All of the following are in +scope for the implementation, not follow-ups: + +- **`install-client.sh`** — replace the Hammerspoon install, the `swiftc` build + (`:397-408`), the launch/relaunch block (`:576-589`), and every `--doctor` check + (`:86-110`, `:127`, `:173`, `:192`, `:657`, `:720`). Ordering matters: removing + `client/rec.swift` while `:404` still compiles it bricks the installer mid-run. +- **`README.md`** — architecture (`:13-14`), install (`:118-135`), `--doctor` (`:155-166`), + hotkey editing (`:230-247`), two-machine setup, and the repo layout (`:365-367`). +- **Retire** `client/rec.swift`, `tests/test_client_record.lua`, and + `client/hark-config.example.lua`, replaced by a `client.json` example. +- **Mark superseded** `docs/superpowers/specs/2026-07-14-hark-open-source-design.md`. Three + of its statements stop being true: + - `:5` — "(architecture unchanged)", a parenthetical that this design invalidates. + - `:212-213` — "Replacing Hammerspoon with a native Swift menubar app" listed as a + non-goal, which is now the goal. Its stated reason ("it would be the right answer for a + *product*; this is not one") deserves an explicit answer rather than silent reversal. + - `:210` — "CI, release automation, versioning, changelogs" listed as a non-goal. #1 + already added CI, and this design adds signed release automation. Stale independently + of the native client. +- Update the status line of `2026-07-14-dictate-design.md` (`:10`), still "Design approved, + pending implementation plan". ## Non-goals -- The Fn/🌐 key. It needs an event tap plus Input Monitoring, which is approach B. -- Pre-warming the audio engine. In-process capture makes it possible for the first time, - but the README's privacy trade-off is deliberate: holding the microphone open lights the - menu-bar indicator continuously, and that should stay an explicit choice. -- Developer ID signing and notarised releases. Designed for, deferred. -- Streaming transcription and auto-submit, per the 2026-07-14 spec's non-goals. +- The Fn/🌐 key. Revisit only if Phase 0 selects a tap. +- Pre-warming the audio engine. In-process capture makes it possible; the README's privacy + trade-off is deliberate and stands. +- Streaming transcription and auto-submit, per the 2026-07-14 spec. +- Keychain-pinned server origin. Recorded above as a follow-up. ## Open questions -- Whether `kEventHotKeyReleased` is reliable enough for hold-to-talk. Spike first. -- Whether the ad-hoc rebuild actually invalidates the Accessibility grant in practice on - current macOS, or merely re-prompts. The rebuild guard is correct either way; this - determines how loudly the installer has to warn. -- Whether `~/Applications/Hark.app` or `/Applications` is the better home once signing - arrives and the app might be distributed as a release artifact. +- Phase 0's two answers, which decide the hotkey mechanism. +- Whether TCC-denied capture really yields silent buffers rather than no frames. If yes, + file a separate issue against the current client. +- Whether the clipboard should self-clear after a timeout when `changeCount` is unchanged. + Any local process can poll `NSPasteboard` and harvest every transcript without holding + any TCC grant. Revision 1 documented non-restoration purely as a usability choice; it is + also a privacy exposure and the README should say so either way. From c763efa105504af0243be76e4b09aff692dfcb00 Mon Sep 17 00:00:00 2001 From: Samuel Reed Date: Fri, 31 Jul 2026 15:04:22 -0400 Subject: [PATCH 3/8] [hark] Specify the native client design in numbers, not adjectives Round 2 of the panel returned 5 REVISE / 1 APPROVED. Almost every remaining objection was the same shape: the design named a mechanism without saying what it actually does. A length cap with no number is not a security contract, and a generation stamp that bash cannot verify is not a freshness check. Concrete now: 1 MiB response body enforced before JSON decoding, 8 KiB sanitised text, reject rather than truncate; a 5 s starting deadline separate from a 120 s capture cap, because rec.swift:167 arms its ceiling only after the first buffer; and a status file bound to the agent by PID plus process start time with a 30 s heartbeat, which is something --doctor can check from bash. The transport section contradicted itself, prohibiting non-loopback HTTP while promising a Tailscale opt-in that had nowhere to live. It now has one: an explicit per-host allowlist in client.json. Tailnets already encrypt at the network layer, so HTTP there is defensible, but it should be a stated choice rather than a silent default. Two things the panel found in shipped code rather than in the design. app.py:103-107 returns microphone advice for every InvalidAudioError and test_app.py:107 asserts that wording, so a malformed header from this client would send users to the wrong settings pane. And migration cannot claim to confirm Hammerspoon released the hotkey, because macOS exposes no way to ask WindowServer who owns a chord; confirmed process exit is the most that can be said. Claude-Session: https://claude.ai/code/session_01VhMX8fBDkrNDpCmPYGycYX --- .../specs/2026-07-31-native-client-design.md | 579 ++++++++++-------- 1 file changed, 331 insertions(+), 248 deletions(-) diff --git a/docs/superpowers/specs/2026-07-31-native-client-design.md b/docs/superpowers/specs/2026-07-31-native-client-design.md index 45b5e8e..c476ad6 100644 --- a/docs/superpowers/specs/2026-07-31-native-client-design.md +++ b/docs/superpowers/specs/2026-07-31-native-client-design.md @@ -2,269 +2,304 @@ Issue: [DRYCodeWorks/hark#2](https://github.com/DRYCodeWorks/hark/issues/2) Date: 2026-07-31 -Revision: 2, after a six-reviewer panel returned 6/6 REVISE on revision 1 +Revision: 3. Round 1 returned 6/6 REVISE; round 2 returned 5 REVISE / 1 APPROVED. ## Why `install-client.sh:573` links `~/.hammerspoon/init.lua` to this repo's `client/init.lua`. Hammerspoon has exactly one config file, so installing hark claims it. -The permission story matters more than the tidiness one. Accessibility is granted to -Hammerspoon, not to hark. That is a general-purpose scriptable Lua runtime holding a grant -that can observe every keystroke, and its config is a symlink into a git repo, so `git -pull` changes what that grant covers without re-prompting. +The permission story matters more. Accessibility is granted to Hammerspoon, not to hark: +a general-purpose scriptable Lua runtime holds a grant that can observe every keystroke, +and its config is a symlink into a git repo, so `git pull` changes what that grant covers +without re-prompting. -What Hammerspoon buys in return is a **stable signed TCC identity**. hark rides on an -already-trusted app by swapping a text file. Taking that away means hark owns the identity -problem itself, which is why this design assumes a Developer ID rather than deferring it. +What Hammerspoon buys in return is a **stable signed TCC identity**. Taking that away means +hark owns the identity problem itself, which is why this design assumes a Developer ID. -## What revision 1 got wrong +## What earlier revisions got wrong -Recorded because the errors are instructive, not to pad the document. +**Revision 1's hotkey rationale.** It argued for Carbon `RegisterEventHotKey` because it +"asks for permission to type, not permission to watch you type." Synthesising ⌘V requires +Accessibility, and Accessibility is a single atomic grant that also permits creating a +`CGEventTap`. The claimed saving does not exist as stated. -**The hotkey rationale was wrong.** Revision 1 argued for Carbon `RegisterEventHotKey` -because it "asks for permission to type, not permission to watch you type." Synthesising -⌘V requires Accessibility, and Accessibility is a single atomic grant — holding it also -permits creating a `CGEventTap`. The claimed saving does not exist as stated. - -**The collision claim was wrong.** Revision 1 said `RegisterEventHotKey` errors when -another app owns the chord. The installed SDK says the opposite: +**Revision 1's collision claim.** It said `RegisterEventHotKey` errors when another app +owns the chord. The installed SDK says the opposite: > "The same hot key can, however, be registered by multiple applications... In Mac OS X > 10.5 and later, you can request exclusive registration for your process only by passing -> `kEventHotKeyExclusive` for the `inOptions` parameter." +> `kEventHotKeyExclusive`." > — `CarbonEvents.h`, `RegisterEventHotKey` discussion -**One claim was fabricated.** Revision 1 said `tests/test_audio.py` pins the wire format. -It does not: `audio.py:44-49` validates sample width only, so a stereo or 44.1 kHz WAV is -accepted today. Fixing that is now in scope (see Testing). +**Revision 1 fabricated a test claim.** It said `tests/test_audio.py` pins the wire format. +`audio.py:44-49` validates sample width only, so a stereo or 44.1 kHz WAV is accepted today. -**The mechanism question is therefore unresolved**, and revision 2 does not pre-decide it. -See Phase 0. +**Revision 2 contradicted itself twice.** It promised a Tailscale HTTP opt-in while +prohibiting non-loopback HTTP, with no mechanism for the opt-in; and it called an +all-silent buffer "distinct from a denied grant" while the error table filed both under +microphone permission. Both are resolved below. ## Decisions | Question | Decision | |---|---| -| Signing | **Developer ID assumed.** CI builds, signs and notarises; releases ship an artifact | -| Install | `install-client.sh` downloads and installs the artifact. It no longer compiles | -| App shape | `LSUIElement` bundle, menu bar status item, at `~/Applications/Hark.app` (no sudo) | -| Capture | Absorbed in-process; `rec` as a separate child binary goes away | -| Config | `~/.config/hark/client.json` holds the **server URL only**; the key always lives in `~/.config/hark/key` mode 600 | -| Hotkey | **Undecided — Phase 0 spike decides.** Carbon and `CGEventTap` are both live | -| Migration | Quit Hammerspoon and offer to revoke its grants. Delete nothing from `$HOME` | - -### Developer ID changes the shape of the problem - -Revision 1 measured that identical Swift sources produce a different CDHash on every -build, and built an installer-side input hash to avoid re-signing on every re-run. - -That whole mechanism is **deleted**. With CI producing the binary, the installer never -compiles, so re-running it cannot mint a new CDHash. With Developer ID, TCC keys on Team ID -plus bundle ID rather than the hash, so grants survive updates. The measurement stands as -the justification for requiring Developer ID; it is no longer a constraint to engineer -around. - -Two consequences worth stating plainly: - -- Notarisation is not optional. A downloaded `.app` carries `com.apple.quarantine`, and an - un-notarised quarantined app is blocked outright on current macOS. Telling users to - `xattr -d` is the kind of friction this project avoids everywhere else. -- The client install no longer needs the Xcode command line tools. That removes a - multi-gigabyte dependency from the machine you dictate *from*, which is the point of the - wider dependency work (#3, #4, #5). - -`~/Applications` keeps the install sudo-free. Under Developer ID, TCC is keyed on the -signing identity rather than the path, so location is a packaging choice, not a permission -one. +| Signing | Developer ID, hardened runtime, notarised and stapled. CI produces the artifact | +| Install | `install-client.sh` downloads and verifies a release artifact. It never compiles | +| App shape | `LSUIElement` bundle, menu bar status item, `~/Applications/Hark.app` (no sudo) | +| Capture | Absorbed in-process; the separate `rec` binary goes away | +| Config | `client.json` holds the server URL and the insecure-transport allowlist; the key always lives in `~/.config/hark/key` mode 600 | +| Hotkey | **Undecided.** Phase 0 decides, against the criteria below | +| Migration | Quit Hammerspoon, offer to revoke all three grants, restore or remove the symlink. Delete nothing else | + +### Why Developer ID, and what it does not buy + +Identical Swift sources produce a different CDHash on every build (measured). Under an +ad-hoc signature that would invalidate the agent's own Accessibility grant on every +rebuild. Revision 1 engineered an installer-side input hash around this; that mechanism is +**deleted**, because CI now produces the binary and the installer never compiles. + +Two corrections to how revision 2 stated the benefit: + +- TCC binds to the app's **designated requirement**, not simply "Team ID plus bundle ID". + CI must therefore keep the designated requirement stable across releases and across + certificate rotation, and Phase 0 must be validated against the **final signed bundle**, + not an ad-hoc spike binary. +- Notarisation requires the hardened runtime, and microphone capture under the hardened + runtime requires the audio-input entitlement. `NSMicrophoneUsageDescription` alone is not + sufficient. The entitlements file is part of the deliverable and CI verifies it. + +The client install no longer needs the Xcode command line tools, which removes a +multi-gigabyte dependency from the machine you dictate *from* — the point of #3, #4 and #5. + +`~/Applications` keeps the install sudo-free; under Developer ID the path is a packaging +choice, not a permission one. ## Phase 0: the hotkey spike (gates everything else) -Two questions decide the mechanism. Both are unresolved and neither should be answered by -argument. +Two questions decide the mechanism. Neither should be answered by argument. 1. **Does a keyboard `CGEventTap` require Input Monitoring in addition to Accessibility?** `README.md:245-247` states from this project's own experience that an `hs.eventtap` - watching `flagsChanged` needs Input Monitoring. If that holds for a keyDown/keyUp tap, - Carbon is one grant and the tap is two, and the permission argument survives revision - 1's bad reasoning by a different route. If it does not hold, the tap is free. -2. **Is `kEventHotKeyReleased` reliable enough for hold-to-talk?** Specifically under load, - and when the user rolls onto other modifiers mid-hold. + watching `flagsChanged` needs it. If that holds for keyDown/keyUp, Carbon is one grant + and the tap is two, and the permission argument survives revision 1's bad reasoning by a + different route. If not, the tap is free. +2. **Is `kEventHotKeyReleased` reliable enough for hold-to-talk?** + +**Pass/fail criteria**, so the spike produces a decision rather than an impression: + +- Test on the oldest supported macOS (13) and the newest available. +- 200 press/release cycles per mechanism; **zero** missed releases required. A missed + release strands the agent in `recording`, which is why the bar is zero rather than low. +- Include modifier rolls (press ⌃⌥Space, add ⇧ mid-hold, release), sustained CPU load, and + a display-sleep/wake cycle mid-hold. +- Register the chord while a second app holds it, with and without + `kEventHotKeyExclusive`, and record whether registration reports the collision. +- Run against the **signed, notarised bundle**, not a bare binary. -Build both, measure both, then write the mechanism into this document. Revision 1's -"`CGEventTap` documented as the fallback" is removed: pre-designing an unused branch is -speculative work, and if Carbon loses the spike the affected sections get rewritten -against what actually won. +If Carbon wins, `kEventHotKeyExclusive` is mandatory — migration cannot claim anything +about chord ownership without it (see Migration). -Whichever wins, the agent must detect and report its own failure to register rather than -going silent, because silence is the failure mode this project keeps designing against. -Carbon additionally requires `kEventHotKeyExclusive` if collision detection is wanted. +### The zero-frames question is probably a live bug + +Revision 2 framed this as unresolved. It should not be: Apple documents that capture from a +denied device yields silence rather than failure, so `rec.swift:128`'s `framesWritten == 0` +check most likely never fires. That would mean the shipped `rec` never exits 3, +`init.lua:486-496`'s probe always writes `ok`, and `--doctor`'s microphone check is a +confident false PASS today. + +Phase 0 validates the replacement implementation rather than deciding whether the premise +holds. If confirmed, file it against the current client — it is a defect in shipped code, +independent of this work. ## Architecture -One SPM package. CI runs `swift build` and `swift test`; the release job produces the -signed, notarised `Hark.app`. +One SPM package. CI runs `swift build` and `swift test`; the release job assembles, signs, +notarises and staples `Hark.app`. -Bundle ID `com.drycodeworks.hark-agent`. This is a **new** identifier that follows the -existing launchd label style; it is not an existing label. `Info.plist` carries -`LSUIElement`, `NSMicrophoneUsageDescription`, and the ATS declaration below. Login start -uses `SMAppService.mainApp`, setting the floor at macOS 13. +Bundle ID `com.drycodeworks.hark-agent` — a **new** identifier following the existing +launchd label style, not an existing label. `Info.plist` carries `LSUIElement`, +`NSMicrophoneUsageDescription`, `NSLocalNetworkUsageDescription` (needed for the +two-machine setup on macOS 15+, and separate from ATS), and the ATS keys below. +Entitlements carry the audio-input entitlement under the hardened runtime. | File | Responsibility | |---|---| -| `Config.swift` | Load `client.json`; read and trim the key file; enforce the transport policy | +| `Config.swift` | Load `client.json`; read and trim the key; enforce the transport policy | | `Hotkey.swift` | Register the chord (mechanism per Phase 0); press/release; registration failure | -| `Recorder.swift` | AVAudioEngine capture, format conversion, in-memory WAV, capture-state reporting | -| `DictateClient.swift` | POST, decode, map status to typed errors, sanitise the response | -| `AgentController.swift` | The state machine, permissions, paste, status file, logging, menu bar | +| `Recorder.swift` | AVAudioEngine capture, conversion, in-memory WAV, capture-state reporting | +| `DictateClient.swift` | Bounded POST, decode, typed errors, response sanitisation | +| `AgentController.swift` | State machine, permissions, paste, status heartbeat, logging, menu bar | -Revision 1 had eight files. `Paster`, `Log`, `Diagnostics` and `StatusItem` each had one -caller and no independent policy, so they collapse into `AgentController`. The three -remaining seams — config, capture, transport — are the ones with real test surface. +Revision 1 had eight files; `Paster`, `Log`, `Diagnostics` and `StatusItem` each had one +caller and no independent policy. The status item shows state and offers Quit only — +`install-client.sh --doctor` is already the diagnostic interface. -The status item shows state and offers Quit only. "Reload config" and "Run diagnostics" -are removed; `install-client.sh --doctor` is already the diagnostic interface and a second -one would drift from it. +### Login start -### Capture, permissions, and a possible live bug +`SMAppService.mainApp.register()` runs at first launch. Registration only schedules for +*subsequent* logins, so first launch must also start the agent directly. Handle +`.requiresApproval` (macOS has disabled it pending user consent) and `.notFound`/error by +surfacing state in the menu bar and in `--doctor`, rather than assuming success. -Revision 1 said "zero frames captured" indicates missing microphone permission. That is how -`rec.swift:128` works today, and a reviewer asserts it is wrong: that a TCC-denied -`AVAudioEngine` delivers the expected buffers filled with **silence** rather than no -buffers, so the frame count never reaches zero. +### Permissions -If that is correct, it is a live defect in shipped code, not merely a flaw in this design: -`rec.swift`'s exit-3 path would never fire, `init.lua:486-496`'s probe would always report -`ok`, and `--doctor`'s microphone check would be a confident false PASS. **Phase 0 must -verify this against a genuinely denied grant**, and if confirmed it gets its own issue -against the current client. +Capture permission is never inferred from frame counts: -Either way the design does not rely on frame counts for permission: +- `AVCaptureDevice.authorizationStatus(for: .audio)`, and `requestAccess` at first launch + to raise the dialog. The Microphone pane has no "+" button and lists only apps that have + already asked, so the request must actually happen. +- Probe at **launch**, not at first hotkey press, so a clean install has a status before + anyone runs `--doctor`. +- `AXIsProcessTrustedWithOptions` with the prompt option. `AXIsProcessTrusted` alone only + checks and never prompts, leaving a fresh install with no path to the grant. +- The agent probes local-network reachability itself. A `curl` from `--doctor` is a false + PASS, because terminal-launched tools are exempt from Local Network privacy. -- Query `AVCaptureDevice.authorizationStatus(for: .audio)` explicitly, and call - `requestAccess` at first launch to raise the consent dialog. The Microphone pane has no - "+" button and only lists apps that have already asked, so the request must happen. -- Probe at **launch**, not at first hotkey press, so a clean install has an - agent-authored status before anyone runs `--doctor`. -- Treat an all-silent buffer as its own reportable condition, distinct from a denied grant. +### Capture -Accessibility uses `AXIsProcessTrustedWithOptions` with the prompt option at first launch. -`AXIsProcessTrusted` alone only checks and never prompts, which leaves a fresh install with -no path to the grant. - -### The WAV is built in memory +The wire format is **16 kHz, mono, 16-bit signed PCM, little-endian, single `data` chunk** — +stated explicitly here because `audio.py:22` documents it in a comment while enforcing only +sample width. `/tmp/hark.wav` goes away, and with it the stale-file hazard `init.lua:357` guards against. -`AVAudioFile` writes to a URL, so `Recorder` builds the 44-byte header itself. - -The RIFF and data chunk sizes depend on total length, which is unknown until release, so -the header is written last or back-patched before the POST. Getting this wrong produces a -server 400, which is exactly why the 400 mapping below had to change. - -### Capture must be serialised +`Recorder` builds the 44-byte header itself; RIFF and `data` sizes are back-patched before +the POST, since neither is known until release. The tap callback runs on an audio thread while key-up runs on the main queue. `rec.swift:112` mutates `framesWritten` from the callback while `rec.swift:128` reads it -from `stop()` — an existing unsynchronised access that becomes more consequential in-process. - -Define an explicit boundary: an actor or serial queue owning the buffer, with stop waiting -for the tap to drain before the WAV is finalised. Stop must be idempotent. +from `stop()` — unsynchronised today, and more consequential in-process. An actor or serial +queue owns the buffer; stop waits for the tap to drain and is idempotent. ## State machine -Revision 1 said only "return early if already recording", which leaves both a stuck-state -and an ordering hole. - ``` idle ──press──► starting ──first buffer──► recording ──release──► stopping - ▲ │ - └──────────── paste / error ◄──── uploading ◄──── drain complete ────┘ + ▲ │ │ │ + │ └── deadline / failure ──┐ └── cap reached ─────┤ + └──── paste, error, or discard ◄── uploading ◄──── drain complete ──┘ ``` -Rules: +| Transition | Behaviour | +|---|---| +| press outside `idle` | ignored | +| `starting`, no first buffer within **5 s** | abort to `idle`, report device or permission | +| `starting` + release | finish starting, then honour the release; never strand | +| engine start fails, or permission denied | abort to `idle` with the specific cause | +| `recording` beyond **120 s** | stop and upload what was captured, with a visible notice | +| `uploading`, request exceeds **30 s** | cancel, return to `idle`, report timeout | +| sleep, or user-session switch | abort capture to `idle` | -- A press outside `idle` is ignored. -- `stopping` exists so a press immediately after release cannot start a second capture - while the first is still draining. Revision 1 would have either overlapped captures or - dropped the second utterance. -- **One in-flight request.** `init.lua:319` clears its task before the async POST, so today - two quick utterances can paste in reverse order. Serialise: no new capture while a - request is pending, and drop a response whose sequence number is not current. -- **Maximum capture duration.** A missed release must not leave the engine running. In - memory this is unbounded growth rather than a growing file, so the cap stops capture and - returns to `idle`. -- Cleanup on sleep, app deactivation, and termination. +`stopping` exists so a press immediately after release cannot start a second capture while +the first drains — revision 1 would have overlapped captures or dropped the utterance. + +**One in-flight request**, with a sequence number. The state machine alone mostly prevents +overlap, but a timed-out request that completes late must not paste into a newer capture's +turn, so responses whose sequence is not current are discarded. + +A 5 s starting deadline is separate from the 120 s capture cap: today's recorder arms its +ceiling only after the first buffer (`rec.swift:167`), so a device that never delivers one +hangs indefinitely. ## Data flow 1. Press. Ignore unless `idle` and the key resolves. 2. Capture starts; status item and overlay show recording. -3. Release. Tap drains, WAV finalised in memory. -4. No frames, or all-silent buffers: report the capture-side cause and stop. Do not POST. +3. Release. Tap drains; WAV finalised in memory. **Snapshot the frontmost application.** +4. No frames, or an all-silent buffer: report the capture-side cause and stop. Do not POST. 5. POST with `X-Hark-Key` and `Content-Type: audio/wav`. 6. `200` with empty text: transient "heard nothing", paste nothing. Not an error. -7. `200` with text: **sanitise, then** set the pasteboard, verify the write, then ⌘V. +7. `200` with text: sanitise, verify the paste target, set the pasteboard, verify the write, + then ⌘V. Never Return. The clipboard is deliberately not restored. -### The client sanitises the response +**Paste-target policy.** Transcription is asynchronous, so focus can move between release +and response. If the frontmost application is not the one snapshotted at release, put the +transcript on the pasteboard and report that automatic paste was withheld. Typing a +transcript into whatever happens to be focused later is worse than making the user press +⌘V. + +**Paste failure propagates.** If the pasteboard write fails, report it and do **not** +synthesise ⌘V — otherwise the keystroke pastes whatever was on the clipboard before. + +## Response handling and limits -The server sanitises at `sanitize.py:23`, and revision 1 treated that as sufficient. It is -not. A compromised server, or anyone on the network path of a plaintext two-machine setup, -returns JSON the server's sanitiser never touched. "Never Return" protects nothing when the -payload itself carries `\r` or `\n` into a terminal without bracketed paste. +The server sanitises at `sanitize.py:23`; revision 1 treated that as sufficient. It is not. +A compromised server, or anyone on the network path of a plaintext two-machine setup, +returns JSON the server's sanitiser never touched, and "Never Return" protects nothing when +the payload itself carries `\r` or `\n` into a terminal without bracketed paste. -`DictateClient` applies the same rule as `sanitize.py` — C0/C1 controls and Unicode line -separators replaced with a space, whitespace collapsed — plus a length cap, before the text -reaches the pasteboard. The server keeps its sanitiser; this is defence in depth, and the -Swift and Python implementations get the same test cases. +| Bound | Value | Applied | +|---|---|---| +| Response body | **1 MiB** | Before JSON decoding, as a streaming byte limit | +| Sanitised text | **8 KiB** (UTF-8 bytes) | After decode and sanitisation | +| Behaviour past either | **Reject, do not truncate** | Rejection is visible; truncation silently corrupts a transcript | -If the pasteboard write fails, report it and **do not** synthesise ⌘V — otherwise the -keystroke pastes whatever was on the clipboard before, potentially into a terminal. +The body cap must bind before decoding — a post-decode cap still lets a hostile server make +`URLSession` buffer an unbounded response. + +Sanitisation applies the same rule as `sanitize.py`: C0/C1 controls and Unicode line +separators replaced with a space, whitespace collapsed. The Swift and Python +implementations share test cases, including hostile inputs: embedded newlines, CSI/OSC +sequences, U+2028/U+2029, and an oversized body. ## Transport policy -The two-machine default is plaintext HTTP (`install-client.sh:503`) over what +The two-machine default is plaintext HTTP (`install-client.sh:503`) to what `README.md:214` calls "a LAN address you trust". On that path an attacker reads the audio, -the transcript and `X-Hark-Key`, and can forge the response that the client then types. - -`Config` enforces: +the transcript and `X-Hark-Key`, and can forge the response the client then types. -- plain HTTP permitted **only** for numeric loopback, -- HTTPS required for every other host, -- userinfo (`user@host`) rejected — `install-client.sh:230` already treats it as a defect, -- redirects not followed, -- an ephemeral `URLSession` so credentials and responses are not cached to disk. +Revision 2 prohibited non-loopback HTTP while promising a Tailscale opt-in, with no +mechanism. Resolved: -`Info.plist` declares the matching ATS exception. Revision 1 omitted ATS entirely, which -would have produced a client that captures audio and then fails every POST. +```json +{ + "server": "http://100.x.y.z:8911/dictate", + "insecure_transport_hosts": ["100.x.y.z"] +} +``` -Two-machine users on Tailscale who want to keep HTTP need an explicit, documented opt-in; -it is not the default. +- HTTPS is required for every non-loopback host. +- Plain HTTP is permitted for numeric loopback unconditionally, and for a host **only** if + that exact host is listed in `insecure_transport_hosts`. +- The list exists because Tailscale already encrypts at the network layer, so HTTP over a + tailnet is a defensible choice — but it must be a stated one, not a silent default. + Requiring TLS on the hark server instead would mean provisioning certificates, which is + a larger change than this issue. +- `--doctor` reports every entry as a warning naming the assumption it encodes. +- Userinfo (`user@host`) rejected; `install-client.sh:230` already treats it as a defect. +- Redirects cancelled explicitly in the `URLSession` delegate — the default session follows + them. +- Ephemeral `URLSession`, so credentials and responses are not cached to disk. + +ATS: `NSAllowsLocalNetworking` plus per-host `NSExceptionDomains` entries generated for the +allowlisted hosts. `NSAllowsLocalNetworking` permits local and IP loads broadly, so the +runtime check in `Config` — not ATS — is the real boundary. Modern macOS treats bare IP +addresses specially, so Phase 0 verifies the exact keys on both supported versions. ## Configuration -`client.json` holds the server URL. The key always lives at `~/.config/hark/key`, mode -600, written by whichever installer obtained it. Revision 1's inline-versus-file branch is -deleted: one path, one permission contract, nothing to drift. - -- `install-client.sh` creates `~/.config/hark` mode 700 before writing anything. On a - two-machine *client* the directory does not otherwise exist, because the server-side - `mkdir` ran on the other machine. -- The key file is read and **trimmed**: `config.py:129` writes `key + "\n"` and - `install-client.sh:427` strips it today. Sending the raw bytes 401s every request. - Reject embedded whitespace rather than silently trimming it. -- `client.json` is serialised by a real JSON encoder and replaced atomically. The current - heredoc at `install-client.sh:541` validates quotes only in the key (`:468`), so a +The key always lives at `~/.config/hark/key`, mode 600. Revision 1's inline-versus-file +branch is deleted: one path, one permission contract. + +- `install-client.sh` creates `~/.config/hark` mode 700 before writing. On a two-machine + *client* the directory does not otherwise exist — the server-side `mkdir` ran on the + other machine. +- The key is read and **trimmed**: `config.py:129` writes `key + "\n"` and + `install-client.sh:427` strips it today. Raw bytes 401 every request. Embedded whitespace + is rejected rather than silently trimmed. +- `client.json` is written by a real JSON encoder and replaced atomically. The current + heredoc (`install-client.sh:541`) validates quotes only in the key (`:468`), so a hand-entered URL containing a quote produces an unparseable file. -- `--doctor` must stop passing the key in argv (`install-client.sh:291`), where any local - account can read it from the process table. Use a config-file-fed curl or the agent. +- `--doctor` stops passing the key in argv (`install-client.sh:291`), where any local + account can read it from the process table. **Known residual risk.** Any same-UID process can rewrite `client.json` and point the trusted agent at an attacker's server; mode 600 does not prevent this. Under Developer ID the mitigation is available — pin the approved origin in a Keychain item protected by code -identity, and require in-app confirmation to change it. Recorded as a follow-up rather than -built now, because it needs its own design. +identity, requiring in-app confirmation to change. Recorded as a follow-up; it needs its own +design. ## Error handling @@ -273,97 +308,146 @@ built now, because it needs its own design. | Connection / transport failure | The URL, and that hark may not be running | | 401 | Key mismatch, and the file to fix | | 415 | A client bug, not a microphone problem | -| **400** | **Malformed or unsupported audio — a client format bug.** Show the server's `detail` | +| 400 | Malformed or unsupported audio — a client format bug. Shows the server's `detail` | | 503 | whisper-server down, `/tmp/hark-whisper.err` | | Other | Status code and the server's `detail` | -| No frames / all silent | Capture-side: device selection and microphone permission | - -Revision 1 carried `init.lua`'s "400 means microphone permission" across unchanged. That is -now wrong twice over: capture failures never reach the server, and `app.py:98-103` returns -400 for **every** `InvalidAudioError` — including a malformed header this client now builds -by hand. Sending users to Microphone settings for a header bug would be actively -misleading. - -Messages render in a borderless transient overlay rather than Notification Center, since a -muted notification is a silent failure. - -## Status file - -The agent writes `~/.config/hark/client-status.json`; `--doctor` reads it. `--doctor` must -not run the agent binary itself: a probe launched from the terminal tests the *terminal's* -TCC grant and produces a confident false PASS, which is the trap `install-client.sh:183-190` -already documents. - -Revision 1 said "only the path changes". Two things must also change: +| No frames captured | Device selection, and microphone permission | +| All-silent buffer | Input level or a muted device — **not** reported as a denied grant | + +The last two rows resolve revision 2's contradiction. A denied grant is detected by +`authorizationStatus`, never inferred from audio content, so an all-silent buffer means the +device is muted or the level is too low. + +**The server must change too.** `app.py:103-107` returns one detail for every +`InvalidAudioError` — "Check that the client has microphone permission and is sending +16 kHz mono 16-bit PCM WAV" — so a malformed header from this client's hand-built WAV would +still send users to Microphone settings. `test_app.py:107` asserts that wording +(`assert "microphone" in detail or "mic" in detail`). + +`audio.py` already raises distinct causes: empty body, unreadable WAV, wrong sample width. +Branch on them — empty body keeps the microphone advice, which is right for any client; +malformed or unsupported audio gets format advice. Update the test to match the cause it +exercises. + +## Status and `--doctor` + +`--doctor` must not run the agent binary itself: a probe launched from the terminal tests +the *terminal's* TCC grant, the trap `install-client.sh:183-190` already documents. + +Revision 2's "generation stamp" was not implementable — `--doctor` is bash and cannot read +the agent's internal state. The binding must be independently observable: + +```json +{ + "pid": 4321, + "process_started": "2026-07-31T14:02:11Z", + "written": "2026-07-31T14:31:02Z", + "bundle_version": "1.2.0", + "microphone": "authorized", + "accessibility": "trusted", + "local_network": "ok" +} +``` -- **Atomic write** — temp file plus rename. `--doctor` reading mid-write would otherwise - see partial JSON. -- **Generation stamp** — the file records the agent launch it came from, and `--doctor` - rejects a status older than the running agent. Today `install-client.sh:200-204` accepts - any existing `ok` first line, so a stale file survives a revoked grant and reports PASS. +- The agent rewrites this every **30 s** and on every permission change. +- `--doctor` confirms the PID is alive, that `ps -o lstart -p ` matches + `process_started`, and that `written` is under **90 s** old. Anything else fails. +- No agent running is a FAIL, not a missing check. +- Written atomically, temp file plus rename. Revision 1 would have let `--doctor` read + partial JSON, and today `install-client.sh:200-204` accepts any existing `ok` line, so a + stale file survives a revoked grant and reports PASS. + +Permissions are re-read after wake and on activation, so revocation during a long-running +agent is reflected rather than cached from launch. + +## Release and install contract + +"Downloads the artifact" is not a design. The installer replaces code that holds TCC grants, +so: + +- **Artifact**: a universal (arm64 + x86_64) `Hark.app`, zipped, attached to a GitHub + release, with the tag as the version. `install-client.sh` selects the newest release + unless pinned. +- **Verification, before anything is moved into place**: staple check via `spctl --assess`, + and `codesign --verify --deep --strict -R` against an expected requirement naming the + **Team ID and bundle ID**. Notarisation alone only proves *someone* signed it. +- **Replacement**: download to a staging directory, verify, quit any running agent, replace + atomically, then launch and confirm the status heartbeat appears. +- **Rollback**: keep the previous bundle until the new one reports healthy. +- Failure at any step leaves the existing install untouched. ## Testing -Unit-testable with no permissions and no microphone: config loading, key trimming and -rejection, transport-policy enforcement, the WAV header builder (including back-patched -sizes), the status-to-error mapping via a stubbed `URLProtocol`, and **hostile response -sanitisation** — control characters, newlines, Unicode line separators, over-length text. +No permissions or microphone needed: config loading, key trimming and rejection, +transport-policy enforcement including the allowlist, the WAV header builder with +back-patched sizes, status-to-error mapping via a stubbed `URLProtocol`, response bounds +(1 MiB body, 8 KiB text, reject-not-truncate), and hostile-response sanitisation. Server-side, fix the coverage revision 1 wrongly claimed existed: `audio.py` validates -sample width only, so add explicit assertions and server rejection for channel count and -sample rate alongside bit depth. +sample width only, so add assertions and server rejection for channel count and sample rate +alongside bit depth, and update the 400-detail test to the branched causes. -CI runs `swift build`, `swift test`, and the existing pytest suite. +CI runs `swift build`, `swift test`, the pytest suite, and verifies the signed bundle's +entitlements and designated requirement. -Not testable in CI, stated as plainly as the README states its own limits: TCC grants, real -hotkey delivery, real paste into a real window. +Not testable in CI, as plainly as the README states its own limits: TCC grants, real hotkey +delivery, real paste into a real window. ## Migration -**Nothing is deleted from the user's home directory.** But "report and move on" is not -enough, for two reasons the panel found: +**Nothing outside hark's own files is deleted.** But "report and move on" is insufficient: - A running Hammerspoon holds the old Lua **in memory**. Replacing the symlinked file - changes nothing until it reloads, so both clients bind ⌃⌥Space and fight. - `install-client.sh:586` already documents that Hammerspoon does not auto-reload. -- Leaving Hammerspoon's Accessibility and Microphone grants in place preserves exactly the - privilege escape that motivates this issue. Any same-UID process can later rewrite the - symlink target and reload it. - -So `install-client.sh`: - -1. quits Hammerspoon and confirms it no longer owns the chord before starting the agent, -2. offers `tccutil reset Accessibility org.hammerspoon.Hammerspoon` and the Microphone - equivalent, and prints how to remove the cask, -3. reports leftover artifacts without deleting them. - -`client/init.lua` becomes a **silent** no-op stub. Revision 1 had it show an alert, which -would fire from a still-running Hammerspoon that the installer is already handling. + changes nothing until it reloads, so both clients bind ⌃⌥Space. + `install-client.sh:586` already documents that it does not auto-reload. +- Leaving Hammerspoon's grants preserves exactly the privilege escape motivating this + issue. Any same-UID process can later rewrite the symlink target and reload it. + +`install-client.sh`: + +1. **Detects a hark-era install** — `~/.hammerspoon/init.lua` is a symlink into this repo. + If it is a real file, or points elsewhere, the user uses Hammerspoon independently: do + not quit it and do not offer to revoke anything. +2. **Quits Hammerspoon** and waits for the process to exit. Chord ownership cannot be + queried — macOS exposes no way to ask WindowServer who owns a hotkey — so the check is + confirmed process exit plus successful registration by the new agent. Revision 2's + "confirms it no longer owns the chord" overstated what is possible. +3. **Removes the symlink** it created. Leaving it means hark still claims Hammerspoon's only + config path and future repo changes still control it. A real file is never touched. +4. **Offers to revoke all three grants** — `tccutil reset Accessibility`, `Microphone`, and + `ListenEvent`, all for `org.hammerspoon.Hammerspoon`. Input Monitoring is included + because `README.md:245-247` told Fn-key users to grant it. +5. If the user declines revocation, says plainly that the original exposure remains. The + install proceeds; it is not silently reported as closed. +6. **Ordering**: the native agent must be installed, launched and verified healthy before + Hammerspoon is quit, so a failure leaves a working client. Removing `client/rec.swift` + while `install-client.sh:404` still compiles it would brick the installer mid-run. + +`client/init.lua` becomes a **silent** no-op stub, for symlinks this installer never sees. ## Blast radius -Revision 1 under-specified this and the panel enumerated it. All of the following are in -scope for the implementation, not follow-ups: +In scope for the implementation, not follow-ups: - **`install-client.sh`** — replace the Hammerspoon install, the `swiftc` build - (`:397-408`), the launch/relaunch block (`:576-589`), and every `--doctor` check - (`:86-110`, `:127`, `:173`, `:192`, `:657`, `:720`). Ordering matters: removing - `client/rec.swift` while `:404` still compiles it bricks the installer mid-run. + (`:397-408`), the launch block (`:576-589`), and every `--doctor` check (`:86-110`, + `:127`, `:173`, `:192`, `:657`, `:720`). +- **`src/hark/app.py`** (`:103-107`) and **`tests/test_app.py`** (`:107`) — branch the 400 + detail by cause. +- **`src/hark/audio.py`** and **`tests/test_audio.py`** — enforce and assert channel count + and sample rate, not just sample width. - **`README.md`** — architecture (`:13-14`), install (`:118-135`), `--doctor` (`:155-166`), - hotkey editing (`:230-247`), two-machine setup, and the repo layout (`:365-367`). + hotkey editing (`:230-247`), two-machine setup, repo layout (`:365-367`). - **Retire** `client/rec.swift`, `tests/test_client_record.lua`, and `client/hark-config.example.lua`, replaced by a `client.json` example. -- **Mark superseded** `docs/superpowers/specs/2026-07-14-hark-open-source-design.md`. Three - of its statements stop being true: - - `:5` — "(architecture unchanged)", a parenthetical that this design invalidates. - - `:212-213` — "Replacing Hammerspoon with a native Swift menubar app" listed as a - non-goal, which is now the goal. Its stated reason ("it would be the right answer for a - *product*; this is not one") deserves an explicit answer rather than silent reversal. +- **Mark superseded** `docs/superpowers/specs/2026-07-14-hark-open-source-design.md`: + - `:5` — "(architecture unchanged)", which this design invalidates. + - `:213-214` — replacing Hammerspoon with a native Swift menubar app listed as a + non-goal, with the stated reason "It would be the right answer for a *product*; this is + not one." That deserves an explicit answer, not silent reversal. - `:210` — "CI, release automation, versioning, changelogs" listed as a non-goal. #1 - already added CI, and this design adds signed release automation. Stale independently - of the native client. -- Update the status line of `2026-07-14-dictate-design.md` (`:10`), still "Design approved, - pending implementation plan". + already added CI and this design adds signed release automation. +- Update the status line of `2026-07-14-dictate-design.md` (`:10`). ## Non-goals @@ -371,14 +455,13 @@ scope for the implementation, not follow-ups: - Pre-warming the audio engine. In-process capture makes it possible; the README's privacy trade-off is deliberate and stands. - Streaming transcription and auto-submit, per the 2026-07-14 spec. -- Keychain-pinned server origin. Recorded above as a follow-up. +- TLS on the hark server. The `insecure_transport_hosts` allowlist is the interim answer. +- Keychain-pinned server origin. Follow-up, recorded above. ## Open questions - Phase 0's two answers, which decide the hotkey mechanism. -- Whether TCC-denied capture really yields silent buffers rather than no frames. If yes, - file a separate issue against the current client. - Whether the clipboard should self-clear after a timeout when `changeCount` is unchanged. Any local process can poll `NSPasteboard` and harvest every transcript without holding any TCC grant. Revision 1 documented non-restoration purely as a usability choice; it is - also a privacy exposure and the README should say so either way. + also a privacy exposure, and the README should say so either way. From b34eb789099ce747cda8d95139947271f94ddae4 Mon Sep 17 00:00:00 2001 From: Samuel Reed Date: Sat, 1 Aug 2026 08:14:06 -0400 Subject: [PATCH 4/8] [hark] Fix the ATS and migration-ordering defects from round 3 Round 3 found two contradictions I introduced. Per-host NSExceptionDomains cannot follow client.json, because that key lives in the signed Info.plist and writing to it after download invalidates the signature. Restricting insecure HTTP to IP literals lets a static NSAllowsLocalNetworking cover it without the plist naming any host. The migration also deadlocked: it required the agent verified healthy before quitting Hammerspoon, while the agent cannot register the hotkey until Hammerspoon releases it. Registration is now split out of the health check and happens after handover, with rollback that restores the symlink and relaunches Hammerspoon. Claude-Session: https://claude.ai/code/session_01VhMX8fBDkrNDpCmPYGycYX --- .../specs/2026-07-31-native-client-design.md | 79 ++++++++++++------- 1 file changed, 50 insertions(+), 29 deletions(-) diff --git a/docs/superpowers/specs/2026-07-31-native-client-design.md b/docs/superpowers/specs/2026-07-31-native-client-design.md index c476ad6..4ae5a4d 100644 --- a/docs/superpowers/specs/2026-07-31-native-client-design.md +++ b/docs/superpowers/specs/2026-07-31-native-client-design.md @@ -260,23 +260,29 @@ mechanism. Resolved: } ``` -- HTTPS is required for every non-loopback host. -- Plain HTTP is permitted for numeric loopback unconditionally, and for a host **only** if - that exact host is listed in `insecure_transport_hosts`. -- The list exists because Tailscale already encrypts at the network layer, so HTTP over a - tailnet is a defensible choice — but it must be a stated one, not a silent default. - Requiring TLS on the hark server instead would mean provisioning certificates, which is - a larger change than this issue. +- HTTPS is required for every non-loopback **hostname**, with no exceptions. +- Plain HTTP is permitted for numeric loopback unconditionally, and for a **numeric IP + literal** only if that exact address is listed in `insecure_transport_hosts`. +- The allowlist exists because Tailscale already encrypts at the network layer, so HTTP to + a tailnet IP is a defensible choice — but it must be a stated one, not a silent default. + Provisioning TLS on the hark server would be a larger change than this issue. +- A Tailscale MagicDNS name is therefore **not** usable over HTTP. Use the tailnet IP. - `--doctor` reports every entry as a warning naming the assumption it encodes. - Userinfo (`user@host`) rejected; `install-client.sh:230` already treats it as a defect. - Redirects cancelled explicitly in the `URLSession` delegate — the default session follows them. - Ephemeral `URLSession`, so credentials and responses are not cached to disk. -ATS: `NSAllowsLocalNetworking` plus per-host `NSExceptionDomains` entries generated for the -allowlisted hosts. `NSAllowsLocalNetworking` permits local and IP loads broadly, so the -runtime check in `Config` — not ATS — is the real boundary. Modern macOS treats bare IP -addresses specially, so Phase 0 verifies the exact keys on both supported versions. +**ATS: `NSAllowsLocalNetworking` only, and nothing per-host.** Revision 3 said the +installer would generate per-host `NSExceptionDomains` entries. That is impossible: +`NSExceptionDomains` lives in the signed `Info.plist`, so writing to it after download +invalidates the CDHash and the Developer ID signature, and macOS kills the app at launch. + +Restricting insecure HTTP to IP literals is what makes a static plist sufficient — +`NSAllowsLocalNetworking` exempts IP-literal loads without naming any host, so the plist +never has to know which addresses a given user configured. The `Config` allowlist check at +runtime, not ATS, remains the actual boundary. Phase 0 confirms the key behaves this way on +both supported macOS versions, since bare IP handling has changed across releases. ## Configuration @@ -403,25 +409,40 @@ delivery, real paste into a real window. - Leaving Hammerspoon's grants preserves exactly the privilege escape motivating this issue. Any same-UID process can later rewrite the symlink target and reload it. -`install-client.sh`: - -1. **Detects a hark-era install** — `~/.hammerspoon/init.lua` is a symlink into this repo. - If it is a real file, or points elsewhere, the user uses Hammerspoon independently: do - not quit it and do not offer to revoke anything. -2. **Quits Hammerspoon** and waits for the process to exit. Chord ownership cannot be - queried — macOS exposes no way to ask WindowServer who owns a hotkey — so the check is - confirmed process exit plus successful registration by the new agent. Revision 2's - "confirms it no longer owns the chord" overstated what is possible. -3. **Removes the symlink** it created. Leaving it means hark still claims Hammerspoon's only - config path and future repo changes still control it. A real file is never touched. -4. **Offers to revoke all three grants** — `tccutil reset Accessibility`, `Microphone`, and - `ListenEvent`, all for `org.hammerspoon.Hammerspoon`. Input Monitoring is included - because `README.md:245-247` told Fn-key users to grant it. -5. If the user declines revocation, says plainly that the original exposure remains. The +### Staged cutover + +Revisions 2 and 3 contained a deadlock: they required the native agent to be verified +healthy *before* Hammerspoon is quit, while the agent cannot register the hotkey at all +while Hammerspoon owns it — certainly not under `kEventHotKeyExclusive`. Health-checking a +hotkey the old client still holds is not possible, so registration is split out of the +health check and happens after the handover: + +1. **Detect a hark-era install.** `~/.hammerspoon/init.lua` is a symlink whose target is a + `client/init.lua` carrying a hark marker comment — matching the *content*, not this + checkout's path, so an install still resolves after the repo moves. A real file, or a + symlink to something without the marker, means the user runs Hammerspoon + independently: do not quit it, do not offer to revoke anything, and ask before + proceeding. +2. **Install and health-check the agent with the hotkey disabled.** Everything except + registration is verified: bundle signature, launch, permissions, status heartbeat, + server reachability, and key authentication. +3. **Quit Hammerspoon** and wait for process exit. Chord ownership cannot be queried — + macOS exposes no way to ask WindowServer who owns a hotkey — so confirmed process exit + is the strongest available signal. Revision 2's "confirms it no longer owns the chord" + overstated what is possible. +4. **Tell the running agent to register the hotkey**, and verify registration succeeded. +5. **On failure at step 4**: restore the symlink, relaunch Hammerspoon, leave the agent + installed but inactive, and report what happened. The user ends with a working client + either way. +6. **Only then** remove the symlink hark created, and offer to revoke **all three** grants — + `tccutil reset Accessibility`, `Microphone`, and `ListenEvent`, for + `org.hammerspoon.Hammerspoon`. Input Monitoring is included because `README.md:245-247` + told Fn-key users to grant it. +7. If the user declines revocation, say plainly that the original exposure remains. The install proceeds; it is not silently reported as closed. -6. **Ordering**: the native agent must be installed, launched and verified healthy before - Hammerspoon is quit, so a failure leaves a working client. Removing `client/rec.swift` - while `install-client.sh:404` still compiles it would brick the installer mid-run. + +Removing `client/rec.swift` while `install-client.sh:404` still compiles it would brick the +installer mid-run, so that ordering holds too. `client/init.lua` becomes a **silent** no-op stub, for symlinks this installer never sees. From 5e02539934d90e4235e1cfe7e63dda5ae9c69c82 Mon Sep 17 00:00:00 2001 From: Samuel Reed Date: Sat, 1 Aug 2026 08:19:30 -0400 Subject: [PATCH 5/8] [hark] Rebase the native client design and close the round 3 findings Round 3 brought the panel down to two contradictions and a format bug, all of which I had introduced. Per-host NSExceptionDomains cannot follow client.json: that key lives in the signed Info.plist, so writing to it after download invalidates the signature and macOS kills the app at launch. Restricting insecure HTTP to IP literals lets a static NSAllowsLocalNetworking cover the case without the plist ever naming a host. A MagicDNS name is consequently not usable over HTTP; use the tailnet IP. The migration deadlocked. It required the agent verified healthy before quitting Hammerspoon, while the agent cannot register the hotkey until Hammerspoon releases it. Registration is now split out of the health check and happens after handover, with rollback that restores the symlink and relaunches Hammerspoon. The status file proposed matching an ISO timestamp against ps -o lstart, which prints a localised non-ISO string. It now stores what ps prints, verbatim, plus an epoch integer, so --doctor compares strings and integers rather than parsing dates. It also carries hotkey and login-item state, since a heartbeat that omits them passes while the product does not work. Also settled rather than deferred: response bounds now cover the error detail string, not just the transcript; the paste-target guarantee is stated at application granularity because that is all it enforces; and the clipboard self-clears after 90 seconds when changeCount is unchanged, which keeps the paste-recovery window the original decision wanted while bounding an exposure any local process could harvest. Rebased onto 8d12f7b. That matters more than usual here: the branch was cut from 4e35977 and main has since moved by eleven commits, so every line number in the document was stale. All of them are re-verified against 8d12f7b. Two sections changed meaning as a result. The zero-frames concern is no longer speculative, because #9 was confirmed and fixed in 559aafe, and that commit is now cited as the reference implementation for the agent's permission handling rather than something this design has to invent. And two thirds of the superseded-spec work in the blast radius is already done by ef47aeb and c852392, so the document now points at what remains instead of repeating it. Claude-Session: https://claude.ai/code/session_01VhMX8fBDkrNDpCmPYGycYX --- .../specs/2026-07-31-native-client-design.md | 181 ++++++++++++------ 1 file changed, 122 insertions(+), 59 deletions(-) diff --git a/docs/superpowers/specs/2026-07-31-native-client-design.md b/docs/superpowers/specs/2026-07-31-native-client-design.md index 4ae5a4d..4dc2c4a 100644 --- a/docs/superpowers/specs/2026-07-31-native-client-design.md +++ b/docs/superpowers/specs/2026-07-31-native-client-design.md @@ -2,11 +2,13 @@ Issue: [DRYCodeWorks/hark#2](https://github.com/DRYCodeWorks/hark/issues/2) Date: 2026-07-31 -Revision: 3. Round 1 returned 6/6 REVISE; round 2 returned 5 REVISE / 1 APPROVED. +Revision: 4. Panel rounds: 6/6 REVISE, then 5 REVISE + 1 APPROVED, then 6/6 REVISE with +the findings down to two contradictions and a format bug. Rebased onto `8d12f7b`; all line +numbers re-verified against that commit. ## Why -`install-client.sh:573` links `~/.hammerspoon/init.lua` to this repo's `client/init.lua`. +`install-client.sh:595` links `~/.hammerspoon/init.lua` to this repo's `client/init.lua`. Hammerspoon has exactly one config file, so installing hark claims it. The permission story matters more. Accessibility is granted to Hammerspoon, not to hark: @@ -33,7 +35,7 @@ owns the chord. The installed SDK says the opposite: > — `CarbonEvents.h`, `RegisterEventHotKey` discussion **Revision 1 fabricated a test claim.** It said `tests/test_audio.py` pins the wire format. -`audio.py:44-49` validates sample width only, so a stereo or 44.1 kHz WAV is accepted today. +`audio.py:45-49` validates sample width only, so a stereo or 44.1 kHz WAV is accepted today. **Revision 2 contradicted itself twice.** It promised a Tailscale HTTP opt-in while prohibiting non-loopback HTTP, with no mechanism for the opt-in; and it called an @@ -80,7 +82,7 @@ choice, not a permission one. Two questions decide the mechanism. Neither should be answered by argument. 1. **Does a keyboard `CGEventTap` require Input Monitoring in addition to Accessibility?** - `README.md:245-247` states from this project's own experience that an `hs.eventtap` + `README.md:266-268` states from this project's own experience that an `hs.eventtap` watching `flagsChanged` needs it. If that holds for keyDown/keyUp, Carbon is one grant and the tap is two, and the permission argument survives revision 1's bad reasoning by a different route. If not, the tap is free. @@ -100,17 +102,26 @@ Two questions decide the mechanism. Neither should be answered by argument. If Carbon wins, `kEventHotKeyExclusive` is mandatory — migration cannot claim anything about chord ownership without it (see Migration). -### The zero-frames question is probably a live bug +### The zero-frames question is settled, and already fixed upstream -Revision 2 framed this as unresolved. It should not be: Apple documents that capture from a -denied device yields silence rather than failure, so `rec.swift:128`'s `framesWritten == 0` -check most likely never fires. That would mean the shipped `rec` never exits 3, -`init.lua:486-496`'s probe always writes `ok`, and `--doctor`'s microphone check is a -confident false PASS today. +Revisions 1 and 2 treated this as an open risk. It is neither open nor hypothetical: +inferring microphone permission from a frame count does not work, because a TCC-denied +device yields substituted silence rather than no frames. It was filed as +[#9](https://github.com/DRYCodeWorks/hark/issues/9) and fixed in `559aafe` for the current +Lua client, whose `rec.swift` now says so directly: -Phase 0 validates the replacement implementation rather than deciding whether the premise -holds. If confirmed, file it against the current client — it is a defect in shipped code, -independent of this work. +> "format negotiation succeeds under denial too. The only way to learn the answer is to +> ask TCC for it." + +That commit is the reference implementation for this design's permission handling rather +than something it has to invent: `rec.swift:65` switches on +`AVCaptureDevice.authorizationStatus(for: .audio)`, `:78` calls `requestAccess` and pumps +the main run loop rather than blocking on a semaphore (`requestAccess` delivers on an +unspecified queue, so a blocked main thread can deadlock), and the zero-frame check +survives at `:185` for its real meaning — the device delivered nothing at all. + +The native agent inherits all three behaviours. Phase 0 no longer has anything to decide +here. ## Architecture @@ -162,12 +173,12 @@ The wire format is **16 kHz, mono, 16-bit signed PCM, little-endian, single `dat stated explicitly here because `audio.py:22` documents it in a comment while enforcing only sample width. -`/tmp/hark.wav` goes away, and with it the stale-file hazard `init.lua:357` guards against. +`/tmp/hark.wav` goes away, and with it the stale-file hazard `init.lua:375` guards against. `Recorder` builds the 44-byte header itself; RIFF and `data` sizes are back-patched before the POST, since neither is known until release. The tap callback runs on an audio thread while key-up runs on the main queue. -`rec.swift:112` mutates `framesWritten` from the callback while `rec.swift:128` reads it +`rec.swift:163` mutates `framesWritten` from the callback while `rec.swift:185` reads it from `stop()` — unsynchronised today, and more consequential in-process. An actor or serial queue owns the buffer; stop waits for the tap to drain and is idempotent. @@ -198,7 +209,7 @@ overlap, but a timed-out request that completes late must not paste into a newer turn, so responses whose sequence is not current are discarded. A 5 s starting deadline is separate from the 120 s capture cap: today's recorder arms its -ceiling only after the first buffer (`rec.swift:167`), so a device that never delivers one +ceiling only after the first buffer (`rec.swift:139`), so a device that never delivers one hangs indefinitely. ## Data flow @@ -215,11 +226,18 @@ hangs indefinitely. Never Return. The clipboard is deliberately not restored. **Paste-target policy.** Transcription is asynchronous, so focus can move between release -and response. If the frontmost application is not the one snapshotted at release, put the -transcript on the pasteboard and report that automatic paste was withheld. Typing a +and response. If the frontmost **application** is not the one snapshotted at release, put +the transcript on the pasteboard and report that automatic paste was withheld. Typing a transcript into whatever happens to be focused later is worse than making the user press ⌘V. +The guarantee is deliberately stated at application granularity, because that is all this +mechanism enforces. It does **not** catch a focus move *within* an application — a +different browser tab, or a password field in the same window. Catching that would mean +comparing the system-wide focused accessibility element, which is a heavier check against a +moving target. Claiming protection this does not deliver would be worse than the narrower +promise, so the README should describe it in the same terms. + **Paste failure propagates.** If the pasteboard write fails, report it and do **not** synthesise ⌘V — otherwise the keystroke pastes whatever was on the clipboard before. @@ -234,11 +252,19 @@ the payload itself carries `\r` or `\n` into a terminal without bracketed paste. |---|---|---| | Response body | **1 MiB** | Before JSON decoding, as a streaming byte limit | | Sanitised text | **8 KiB** (UTF-8 bytes) | After decode and sanitisation | +| Error `detail` | **2 KiB**, sanitised | Same treatment as transcript text | | Behaviour past either | **Reject, do not truncate** | Rejection is visible; truncation silently corrupts a transcript | The body cap must bind before decoding — a post-decode cap still lets a hostile server make `URLSession` buffer an unbounded response. +**Every response path is bounded and sanitised, not just the 200.** The error table below +displays the server's `detail` string on 400, 503 and the catch-all, and that string is +just as attacker-controlled as a transcript. An unsanitised `detail` reaches an alert +rather than the pasteboard, so it cannot be typed into a terminal — but it can still carry +control sequences into whatever renders it, and an unbounded one is a denial of service on +the UI. Same rule, smaller cap. + Sanitisation applies the same rule as `sanitize.py`: C0/C1 controls and Unicode line separators replaced with a space, whitespace collapsed. The Swift and Python implementations share test cases, including hostile inputs: embedded newlines, CSI/OSC @@ -246,8 +272,8 @@ sequences, U+2028/U+2029, and an oversized body. ## Transport policy -The two-machine default is plaintext HTTP (`install-client.sh:503`) to what -`README.md:214` calls "a LAN address you trust". On that path an attacker reads the audio, +The two-machine default is plaintext HTTP (`install-client.sh:525`) to what +`README.md:234` calls "a LAN address you trust". On that path an attacker reads the audio, the transcript and `X-Hark-Key`, and can forge the response the client then types. Revision 2 prohibited non-loopback HTTP while promising a Tailscale opt-in, with no @@ -268,7 +294,7 @@ mechanism. Resolved: Provisioning TLS on the hark server would be a larger change than this issue. - A Tailscale MagicDNS name is therefore **not** usable over HTTP. Use the tailnet IP. - `--doctor` reports every entry as a warning naming the assumption it encodes. -- Userinfo (`user@host`) rejected; `install-client.sh:230` already treats it as a defect. +- Userinfo (`user@host`) rejected; `install-client.sh:241` already treats it as a defect. - Redirects cancelled explicitly in the `URLSession` delegate — the default session follows them. - Ephemeral `URLSession`, so credentials and responses are not cached to disk. @@ -293,12 +319,12 @@ branch is deleted: one path, one permission contract. *client* the directory does not otherwise exist — the server-side `mkdir` ran on the other machine. - The key is read and **trimmed**: `config.py:129` writes `key + "\n"` and - `install-client.sh:427` strips it today. Raw bytes 401 every request. Embedded whitespace + `install-client.sh:449` strips it today. Raw bytes 401 every request. Embedded whitespace is rejected rather than silently trimmed. - `client.json` is written by a real JSON encoder and replaced atomically. The current - heredoc (`install-client.sh:541`) validates quotes only in the key (`:468`), so a + heredoc (`install-client.sh:563`) validates quotes only in the key (`:491`), so a hand-entered URL containing a quote produces an unparseable file. -- `--doctor` stops passing the key in argv (`install-client.sh:291`), where any local +- `--doctor` stops passing the key in argv (`install-client.sh:313`), where any local account can read it from the process table. **Known residual risk.** Any same-UID process can rewrite `client.json` and point the @@ -338,7 +364,7 @@ exercises. ## Status and `--doctor` `--doctor` must not run the agent binary itself: a probe launched from the terminal tests -the *terminal's* TCC grant, the trap `install-client.sh:183-190` already documents. +the *terminal's* TCC grant, the trap `install-client.sh:177-184` already documents. Revision 2's "generation stamp" was not implementable — `--doctor` is bash and cannot read the agent's internal state. The binding must be independently observable: @@ -346,21 +372,33 @@ the agent's internal state. The binding must be independently observable: ```json { "pid": 4321, - "process_started": "2026-07-31T14:02:11Z", - "written": "2026-07-31T14:31:02Z", + "process_started": "Fri Jul 31 14:02:11 2026", + "written_epoch": 1785508262, "bundle_version": "1.2.0", "microphone": "authorized", "accessibility": "trusted", - "local_network": "ok" + "local_network": "ok", + "hotkey": "registered", + "login_item": "enabled" } ``` +- `process_started` is the **verbatim output of `ps -o lstart= -p `**, read by the + agent about itself at launch. An ISO-8601 timestamp cannot work here: `ps -o lstart=` + prints a localised, non-ISO string (`Fri Jul 31 14:02:11 2026`, with trailing padding), + so `--doctor` would need brittle date conversion to compare. Storing what `ps` prints + makes the check a trimmed string equality. +- `written_epoch` is integer seconds, so bash compares it with `$(date +%s)` and no parsing. - The agent rewrites this every **30 s** and on every permission change. -- `--doctor` confirms the PID is alive, that `ps -o lstart -p ` matches - `process_started`, and that `written` is under **90 s** old. Anything else fails. -- No agent running is a FAIL, not a missing check. +- `--doctor` fails unless the PID is alive, `ps -o lstart= -p ` matches + `process_started` after trimming, and `written_epoch` is within **90 s** of now. +- `hotkey` and `login_item` are in the heartbeat because a fresh status file otherwise + proves only that a process is running with permissions. It would pass with an + unregistered hotkey or a login item macOS has disabled — both of which mean the product + does not work. `--doctor` requires both. +- No agent running is a FAIL, not a skipped check. - Written atomically, temp file plus rename. Revision 1 would have let `--doctor` read - partial JSON, and today `install-client.sh:200-204` accepts any existing `ok` line, so a + partial JSON, and today `install-client.sh:204-208` accepts any existing `ok` line, so a stale file survives a revoked grant and reports PASS. Permissions are re-read after wake and on activation, so revocation during a long-running @@ -374,12 +412,17 @@ so: - **Artifact**: a universal (arm64 + x86_64) `Hark.app`, zipped, attached to a GitHub release, with the tag as the version. `install-client.sh` selects the newest release unless pinned. -- **Verification, before anything is moved into place**: staple check via `spctl --assess`, - and `codesign --verify --deep --strict -R` against an expected requirement naming the - **Team ID and bundle ID**. Notarisation alone only proves *someone* signed it. +- **Verification, before anything is moved into place**, all three and in this order: + `xcrun stapler validate` (the notarisation ticket is actually stapled), `spctl --assess + --type execute` (Gatekeeper accepts it), and `codesign --verify --deep --strict -R` + against an expected requirement naming the **Team ID and bundle ID**. `spctl` alone is a + Gatekeeper verdict, not a staple check, and notarisation alone only proves *someone* + signed it — the requirement string is what proves it was *you*. - **Replacement**: download to a staging directory, verify, quit any running agent, replace - atomically, then launch and confirm the status heartbeat appears. -- **Rollback**: keep the previous bundle until the new one reports healthy. + atomically, then launch and confirm the status heartbeat appears with `hotkey` and + `login_item` both healthy. +- **Rollback**: keep the previous bundle until the new one reports healthy, and on failure + restore *and relaunch* it. Leaving the user with no running client is its own outage. - Failure at any step leaves the existing install untouched. ## Testing @@ -405,7 +448,7 @@ delivery, real paste into a real window. - A running Hammerspoon holds the old Lua **in memory**. Replacing the symlinked file changes nothing until it reloads, so both clients bind ⌃⌥Space. - `install-client.sh:586` already documents that it does not auto-reload. + `install-client.sh:608` already documents that it does not auto-reload. - Leaving Hammerspoon's grants preserves exactly the privilege escape motivating this issue. Any same-UID process can later rewrite the symlink target and reload it. @@ -436,39 +479,42 @@ health check and happens after the handover: either way. 6. **Only then** remove the symlink hark created, and offer to revoke **all three** grants — `tccutil reset Accessibility`, `Microphone`, and `ListenEvent`, for - `org.hammerspoon.Hammerspoon`. Input Monitoring is included because `README.md:245-247` + `org.hammerspoon.Hammerspoon`. Input Monitoring is included because `README.md:266-268` told Fn-key users to grant it. 7. If the user declines revocation, say plainly that the original exposure remains. The install proceeds; it is not silently reported as closed. -Removing `client/rec.swift` while `install-client.sh:404` still compiles it would brick the +Removing `client/rec.swift` while `install-client.sh:426` still compiles it would brick the installer mid-run, so that ordering holds too. `client/init.lua` becomes a **silent** no-op stub, for symlinks this installer never sees. ## Blast radius -In scope for the implementation, not follow-ups: +Line numbers are against `8d12f7b`. In scope for the implementation, not follow-ups: -- **`install-client.sh`** — replace the Hammerspoon install, the `swiftc` build - (`:397-408`), the launch block (`:576-589`), and every `--doctor` check (`:86-110`, - `:127`, `:173`, `:192`, `:657`, `:720`). +- **`install-client.sh`** — replace the Hammerspoon install, the `swiftc` build (`:419`), + the launch block (`:613`), and every `--doctor` check: `check_hammerspoon_installed` + (`:86`), the Lua-config key grep (`:127`), the recorder check (`:173`), the mic-status + read (`:196`), the Hammerspoon TCC query (`:679`), and the probe wait (`:742`). - **`src/hark/app.py`** (`:103-107`) and **`tests/test_app.py`** (`:107`) — branch the 400 detail by cause. - **`src/hark/audio.py`** and **`tests/test_audio.py`** — enforce and assert channel count - and sample rate, not just sample width. -- **`README.md`** — architecture (`:13-14`), install (`:118-135`), `--doctor` (`:155-166`), - hotkey editing (`:230-247`), two-machine setup, repo layout (`:365-367`). + and sample rate, not just sample width (`:45`). +- **`README.md`** — architecture (`:16`), install step 1 (`:138`), the `--doctor` list + (`:175`), "Changing the hotkey" (`:250`), two-machine setup, repo layout (`:387`). - **Retire** `client/rec.swift`, `tests/test_client_record.lua`, and `client/hark-config.example.lua`, replaced by a `client.json` example. -- **Mark superseded** `docs/superpowers/specs/2026-07-14-hark-open-source-design.md`: - - `:5` — "(architecture unchanged)", which this design invalidates. - - `:213-214` — replacing Hammerspoon with a native Swift menubar app listed as a - non-goal, with the stated reason "It would be the right answer for a *product*; this is - not one." That deserves an explicit answer, not silent reversal. - - `:210` — "CI, release automation, versioning, changelogs" listed as a non-goal. #1 - already added CI and this design adds signed release automation. -- Update the status line of `2026-07-14-dictate-design.md` (`:10`). +- **`docs/superpowers/specs/2026-07-14-hark-open-source-design.md:7`** — "(architecture + unchanged)", which this design invalidates. +- Update the status line of `2026-07-14-dictate-design.md` (`:10`), still "Design approved, + pending implementation plan". + +**Already done upstream, and deliberately not repeated here.** `ef47aeb` struck the two +non-goals this design contradicts — "CI, release automation, versioning, changelogs" +(`:219`) and "Replacing Hammerspoon with a native Swift menubar app" (`:228`) — and added a +"Superseded in part" header at `:211`. `c852392` cleared the stale `setup.sh` references +(#8's sibling work). Only `:7` above remains. ## Non-goals @@ -479,10 +525,27 @@ In scope for the implementation, not follow-ups: - TLS on the hark server. The `insecure_transport_hosts` allowlist is the interim answer. - Keychain-pinned server origin. Follow-up, recorded above. +## Clipboard retention + +Two reviewers asked for this to be decided rather than left open, and they are right: it is +a privacy policy, not a preference. + +**The transcript self-clears after 90 seconds, but only if `NSPasteboard.changeCount` is +unchanged since hark wrote it.** + +The existing behaviour — set and never restore — is deliberate and stays: a misfired paste +stays recoverable with a manual ⌘V instead of re-speaking. See the comment above +`hs.pasteboard.setContents` in `client/init.lua`. But "never restore" currently means +*indefinitely*, and any local process can poll `NSPasteboard` and harvest every transcript +without holding a single TCC grant. That is a real exposure the design was silently +inheriting. + +90 seconds keeps the recovery window that motivated the original decision while bounding +the exposure. The `changeCount` guard means hark only ever clears its own value — if you +copied something else in the meantime, hark does nothing. + +The README should state this as a privacy property, not only as paste-recovery ergonomics. + ## Open questions - Phase 0's two answers, which decide the hotkey mechanism. -- Whether the clipboard should self-clear after a timeout when `changeCount` is unchanged. - Any local process can poll `NSPasteboard` and harvest every transcript without holding - any TCC grant. Revision 1 documented non-restoration purely as a usability choice; it is - also a privacy exposure, and the README should say so either way. From 0a5e02c693d8a36686056e4d19b4f0f73c96245b Mon Sep 17 00:00:00 2001 From: Samuel Reed Date: Sat, 1 Aug 2026 08:29:52 -0400 Subject: [PATCH 6/8] [hark] Make migration rollback actually able to roll back The verification pass returned 4 APPROVED / 2 REVISE, and both holdouts found real contradictions. Rollback restored the symlink, but this same change turns client/init.lua into a no-op stub, so the restored symlink pointed at a client that does nothing. The installer now preserves the working Lua client to ~/.config/hark/legacy-client.lua before cutover and repoints there on failure. It also writes an ownership record, because content-matching the symlink target cannot survive the repo moving: an absolute symlink to a moved checkout is dangling, and there is no content left to match. Rollback also left the agent installed while SMAppService.register() ran unconditionally at first launch, so a rolled-back install would start the agent at the next login and fight the Hammerspoon it had just restored. Registration is now the last step of a successful cutover rather than part of launch, and rollback unregisters. And xcrun stapler was required on the client two hundred lines after this document claimed the client no longer needs the Xcode command line tools. stapler ships with Xcode; the staple check moves to CI, where the toolchain already exists, and the client verifies with stock spctl and codesign. A downloaded artifact is quarantined, so spctl exercises the notarisation path anyway. Claude-Session: https://claude.ai/code/session_01VhMX8fBDkrNDpCmPYGycYX --- .../specs/2026-07-31-native-client-design.md | 90 +++++++++++++------ 1 file changed, 65 insertions(+), 25 deletions(-) diff --git a/docs/superpowers/specs/2026-07-31-native-client-design.md b/docs/superpowers/specs/2026-07-31-native-client-design.md index 4dc2c4a..f631f41 100644 --- a/docs/superpowers/specs/2026-07-31-native-client-design.md +++ b/docs/superpowers/specs/2026-07-31-native-client-design.md @@ -148,10 +148,15 @@ caller and no independent policy. The status item shows state and offers Quit on ### Login start -`SMAppService.mainApp.register()` runs at first launch. Registration only schedules for -*subsequent* logins, so first launch must also start the agent directly. Handle -`.requiresApproval` (macOS has disabled it pending user consent) and `.notFound`/error by -surfacing state in the menu bar and in `--doctor`, rather than assuming success. +`SMAppService.mainApp.register()` is called by the **installer as the last step of a +successful cutover** (Migration, step 4), never automatically at first launch. Registering +on launch would mean a rolled-back install still starts the agent at the next login, where +it would fight the restored Hammerspoon client. + +Registration only schedules for *subsequent* logins, so the installer also starts the agent +directly for the current session. Handle `.requiresApproval` (macOS has disabled it pending +user consent) and `.notFound`/error by surfacing state in the menu bar, in the status +heartbeat's `login_item` field, and in `--doctor`, rather than assuming success. ### Permissions @@ -412,12 +417,24 @@ so: - **Artifact**: a universal (arm64 + x86_64) `Hark.app`, zipped, attached to a GitHub release, with the tag as the version. `install-client.sh` selects the newest release unless pinned. -- **Verification, before anything is moved into place**, all three and in this order: - `xcrun stapler validate` (the notarisation ticket is actually stapled), `spctl --assess - --type execute` (Gatekeeper accepts it), and `codesign --verify --deep --strict -R` - against an expected requirement naming the **Team ID and bundle ID**. `spctl` alone is a - Gatekeeper verdict, not a staple check, and notarisation alone only proves *someone* - signed it — the requirement string is what proves it was *you*. +- **Verification splits across CI and the client**, because they have different tools + available: + - **In CI**, where Xcode exists: `xcrun stapler validate` confirms the notarisation + ticket is actually stapled to the artifact before it is published. This is the only + place `stapler` is used. + - **On the client**, using only stock macOS binaries: `spctl --assess --type execute -vv` + (`/usr/sbin/spctl`) and `codesign --verify --deep --strict -R` (`/usr/bin/codesign`) + against an expected requirement naming the **Team ID and bundle ID**. + + Revision 4 called for `xcrun stapler validate` on the client. That contradicted this + document's own claim that the client no longer needs the Xcode command line tools — + `stapler` ships with Xcode, not with macOS. Since the downloaded artifact is quarantined, + `spctl --assess` exercises the full Gatekeeper path including notarisation, which is the + property that actually matters at install time; stapling is a release-time concern and + belongs where the toolchain already exists. + + `spctl` alone is a Gatekeeper verdict and notarisation alone only proves *someone* signed + it — the requirement string is what proves it was *you*. - **Replacement**: download to a staging directory, verify, quit any running agent, replace atomically, then launch and confirm the status heartbeat appears with `hotkey` and `login_item` both healthy. @@ -460,29 +477,52 @@ while Hammerspoon owns it — certainly not under `kEventHotKeyExclusive`. Healt hotkey the old client still holds is not possible, so registration is split out of the health check and happens after the handover: -1. **Detect a hark-era install.** `~/.hammerspoon/init.lua` is a symlink whose target is a - `client/init.lua` carrying a hark marker comment — matching the *content*, not this - checkout's path, so an install still resolves after the repo moves. A real file, or a - symlink to something without the marker, means the user runs Hammerspoon - independently: do not quit it, do not offer to revoke anything, and ask before - proceeding. -2. **Install and health-check the agent with the hotkey disabled.** Everything except - registration is verified: bundle signature, launch, permissions, status heartbeat, - server reachability, and key authentication. +0. **Preserve a rollback target first.** Copy the *current, working* `client/init.lua` to + `~/.config/hark/legacy-client.lua` and record ownership in + `~/.config/hark/legacy-client.json`: that hark installed the symlink, its original + target path, and a checksum. + + Both files are load-bearing and revision 4 had neither. `client/init.lua` becomes a + no-op stub in this same change, so after the upgrade the symlink's target *is* the stub + — "restore the symlink" would restore a client that does nothing. Rollback repoints at + the preserved copy instead. + + The ownership record exists because content-matching the symlink target cannot survive + the repo moving: an absolute symlink (`install-client.sh:595`) to a moved checkout is + dangling, so there is no content left to match. A durable record in `~/.config/hark` + answers "did hark install this" without depending on the target existing. +1. **Detect a hark-era install**, in this order: the ownership record from step 0 if + present; otherwise a symlink whose target carries the hark marker comment; otherwise a + *dangling* symlink whose recorded path matches a known hark layout. A real file, or a + symlink to something without the marker and without a record, means the user runs + Hammerspoon independently — do not quit it, do not offer to revoke anything, and ask + before proceeding. +2. **Install and health-check the agent with the hotkey disabled**, and with login + registration **not yet performed**. Everything else is verified: bundle signature, + launch, permissions, status heartbeat, server reachability, and key authentication. 3. **Quit Hammerspoon** and wait for process exit. Chord ownership cannot be queried — macOS exposes no way to ask WindowServer who owns a hotkey — so confirmed process exit is the strongest available signal. Revision 2's "confirms it no longer owns the chord" overstated what is possible. -4. **Tell the running agent to register the hotkey**, and verify registration succeeded. -5. **On failure at step 4**: restore the symlink, relaunch Hammerspoon, leave the agent - installed but inactive, and report what happened. The user ends with a working client - either way. -6. **Only then** remove the symlink hark created, and offer to revoke **all three** grants — - `tccutil reset Accessibility`, `Microphone`, and `ListenEvent`, for +4. **Tell the running agent to register the hotkey**, verify it succeeded, and only then + call `SMAppService.mainApp.register()`. +5. **On failure at step 4, roll back in this order**: stop the agent, ensure + `SMAppService.mainApp` is **not** registered (unregister if step 4 got that far), point + `~/.hammerspoon/init.lua` at `~/.config/hark/legacy-client.lua`, relaunch Hammerspoon, + confirm it is running, then report. The user ends with a working client either way. + + Deregistration is the part revision 4 missed: it left the agent "installed but inactive" + while registration ran unconditionally at first launch, so the agent would start at the + next login and fight the Hammerspoon that had just been restored. Registration is + therefore deliberately the *last* step of a successful cutover, not part of launch. +6. **Only on success**, remove the symlink hark created, and offer to revoke **all three** + grants — `tccutil reset Accessibility`, `Microphone`, and `ListenEvent`, for `org.hammerspoon.Hammerspoon`. Input Monitoring is included because `README.md:266-268` told Fn-key users to grant it. 7. If the user declines revocation, say plainly that the original exposure remains. The install proceeds; it is not silently reported as closed. +8. `~/.config/hark/legacy-client.lua` is kept, not deleted, so a later manual rollback is + still possible. It is inert once the symlink is gone. Removing `client/rec.swift` while `install-client.sh:426` still compiles it would brick the installer mid-run, so that ordering holds too. From c0ac2491a5a7e6cdac468c4947f516097b24f595 Mon Sep 17 00:00:00 2001 From: Samuel Reed Date: Sat, 1 Aug 2026 08:35:55 -0400 Subject: [PATCH 7/8] [hark] Ship the rollback target instead of copying it at install time Both holdout reviewers found the same two flaws, and both were in revision 5's fix rather than in the original design. Step 0 copied the working client/init.lua to preserve a rollback target. By the time the updated installer runs, the user has already pulled, so that file is the stub this change introduces, and after a repo move the symlink is dangling and resolves to nothing. The working code survives only in git history and in Hammerspoon's memory, neither of which the installer can read. The last functional client is now committed as client/legacy/init-v1.lua and staged from there. Ownership detection was circular in time: step 0 wrote a record asserting hark owned the symlink, then step 1 trusted that record to decide whether hark owned it. It would have classified an independently managed Hammerspoon config as hark's own, and no existing install can have a record this change introduces. Detection now runs on pre-existing evidence only, and the record is written after a confirmed successful cutover, for later runs. The quarantine assumption was also wrong. I argued spctl exercises notarisation because the download is quarantined, but curl plus ditto need not preserve com.apple.quarantine the way a browser does, and without it spctl takes a weaker path. The installer sets the attribute explicitly before assessing. Claude-Session: https://claude.ai/code/session_01VhMX8fBDkrNDpCmPYGycYX --- .../specs/2026-07-31-native-client-design.md | 78 ++++++++++++------- 1 file changed, 50 insertions(+), 28 deletions(-) diff --git a/docs/superpowers/specs/2026-07-31-native-client-design.md b/docs/superpowers/specs/2026-07-31-native-client-design.md index f631f41..c4b3c35 100644 --- a/docs/superpowers/specs/2026-07-31-native-client-design.md +++ b/docs/superpowers/specs/2026-07-31-native-client-design.md @@ -428,11 +428,17 @@ so: Revision 4 called for `xcrun stapler validate` on the client. That contradicted this document's own claim that the client no longer needs the Xcode command line tools — - `stapler` ships with Xcode, not with macOS. Since the downloaded artifact is quarantined, - `spctl --assess` exercises the full Gatekeeper path including notarisation, which is the - property that actually matters at install time; stapling is a release-time concern and + `stapler` ships with Xcode, not with macOS. Stapling is a release-time property, so it belongs where the toolchain already exists. + **The installer must set `com.apple.quarantine` on the extracted app itself, before + `spctl` runs.** The argument that "the download is quarantined anyway" does not hold for + a scripted install: the attribute is applied by browsers and other LaunchServices-aware + downloaders, and a `curl` fetch followed by `ditto`/`unzip` need not preserve it. Without + the attribute, `spctl --assess` takes a different and weaker path, so the notarisation + check the client relies on would quietly not be the check it thinks it is. Set it + explicitly, then assess. + `spctl` alone is a Gatekeeper verdict and notarisation alone only proves *someone* signed it — the requirement string is what proves it was *you*. - **Replacement**: download to a staging directory, verify, quit any running agent, replace @@ -477,29 +483,39 @@ while Hammerspoon owns it — certainly not under `kEventHotKeyExclusive`. Healt hotkey the old client still holds is not possible, so registration is split out of the health check and happens after the handover: -0. **Preserve a rollback target first.** Copy the *current, working* `client/init.lua` to - `~/.config/hark/legacy-client.lua` and record ownership in - `~/.config/hark/legacy-client.json`: that hark installed the symlink, its original - target path, and a checksum. - - Both files are load-bearing and revision 4 had neither. `client/init.lua` becomes a - no-op stub in this same change, so after the upgrade the symlink's target *is* the stub - — "restore the symlink" would restore a client that does nothing. Rollback repoints at - the preserved copy instead. - - The ownership record exists because content-matching the symlink target cannot survive - the repo moving: an absolute symlink (`install-client.sh:595`) to a moved checkout is - dangling, so there is no content left to match. A durable record in `~/.config/hark` - answers "did hark install this" without depending on the target existing. -1. **Detect a hark-era install**, in this order: the ownership record from step 0 if - present; otherwise a symlink whose target carries the hark marker comment; otherwise a - *dangling* symlink whose recorded path matches a known hark layout. A real file, or a - symlink to something without the marker and without a record, means the user runs - Hammerspoon independently — do not quit it, do not offer to revoke anything, and ask - before proceeding. -2. **Install and health-check the agent with the hotkey disabled**, and with login - registration **not yet performed**. Everything else is verified: bundle signature, - launch, permissions, status heartbeat, server reachability, and key authentication. +0. **The rollback target ships in the repo, not copied at install time.** The last + functional Lua client is preserved as a versioned asset at + `client/legacy/init-v1.lua`, committed in the same change that stubs `client/init.lua`. + + It cannot be copied from the live file, which is the mistake revision 5 made. By the + time the updated installer runs, the user has already pulled, so `client/init.lua` *is* + the stub — and after a repo move the symlink is dangling and resolves to nothing at all. + The working code would survive only in git history and in Hammerspoon's memory, neither + of which the installer can read. A committed asset is the only source that is + guaranteed present and functional at install time. + + The installer copies it to `~/.config/hark/legacy-client.lua` before cutover, and + rollback repoints the symlink there. +1. **Detect a hark-era install from pre-existing evidence only**, in this order: + 1. a `~/.config/hark/legacy-client.json` ownership record written by an **earlier** run, + **and** whose recorded target matches the symlink's current target; + 2. otherwise, a symlink whose target carries the hark marker comment; + 3. otherwise, a *dangling* symlink whose recorded path matches a known hark layout. + + A real file, or a symlink without marker or record, means the user runs Hammerspoon + independently — do not quit it, do not offer to revoke anything, and ask before + proceeding. + + **The ownership record is written only after ownership is confirmed**, at the end of a + successful cutover, for the benefit of future runs. Revision 5 had step 0 write the + record and step 1 immediately trust it, which is circular: it would classify any + symlink as hark's own, including an independently managed one. It also assumed a record + that no existing installation can have, since this change introduces it. +2. **Stage the rollback target and install the agent.** Copy `client/legacy/init-v1.lua` + to `~/.config/hark/legacy-client.lua` and confirm it is readable before anything is + changed. Then health-check the agent **with the hotkey disabled** and login registration + **not yet performed**: bundle signature, launch, permissions, status heartbeat, server + reachability, and key authentication. 3. **Quit Hammerspoon** and wait for process exit. Chord ownership cannot be queried — macOS exposes no way to ask WindowServer who owns a hotkey — so confirmed process exit is the strongest available signal. Revision 2's "confirms it no longer owns the chord" @@ -515,8 +531,10 @@ health check and happens after the handover: while registration ran unconditionally at first launch, so the agent would start at the next login and fight the Hammerspoon that had just been restored. Registration is therefore deliberately the *last* step of a successful cutover, not part of launch. -6. **Only on success**, remove the symlink hark created, and offer to revoke **all three** - grants — `tccutil reset Accessibility`, `Microphone`, and `ListenEvent`, for +6. **Only on success**, write the `~/.config/hark/legacy-client.json` ownership record — + confirmed ownership, the symlink's original target, and a checksum — for future runs to + read. Then remove the symlink hark created, and offer to revoke **all three** grants: + `tccutil reset Accessibility`, `Microphone`, and `ListenEvent`, for `org.hammerspoon.Hammerspoon`. Input Monitoring is included because `README.md:266-268` told Fn-key users to grant it. 7. If the user declines revocation, say plainly that the original exposure remains. The @@ -543,6 +561,10 @@ Line numbers are against `8d12f7b`. In scope for the implementation, not follow- and sample rate, not just sample width (`:45`). - **`README.md`** — architecture (`:16`), install step 1 (`:138`), the `--doctor` list (`:175`), "Changing the hotkey" (`:250`), two-machine setup, repo layout (`:387`). +- **Add** `client/legacy/init-v1.lua` — a verbatim copy of the last functional Lua client, + committed in the **same** change that stubs `client/init.lua`. It is the rollback target + and must not be stubbed, linted into a stub, or "cleaned up" later; `tests/` should assert + it still binds a hotkey, so a future tidy-up cannot silently empty it. - **Retire** `client/rec.swift`, `tests/test_client_record.lua`, and `client/hark-config.example.lua`, replaced by a `client.json` example. - **`docs/superpowers/specs/2026-07-14-hark-open-source-design.md:7`** — "(architecture From b39bc2551bce35aecd3550ba2c72865d3b6f1a79 Mon Sep 17 00:00:00 2001 From: Samuel Reed Date: Sat, 1 Aug 2026 08:41:06 -0400 Subject: [PATCH 8/8] [hark] Gate the weakest ownership heuristic behind explicit confirmation The dangling-symlink fallback matches on path shape, which is a guess rather than proof of ownership. Everything it gates is disruptive and not silently reversible: quitting Hammerspoon, replacing a symlink, resetting TCC grants. When it is the only evidence, the installer now stops and asks. Header corrected to revision 6; it still claimed 4 while the body described revision 5 as superseded. Also states plainly that this document ships no code, since a reviewer read the absence of client/legacy/init-v1.lua from HEAD as a defect rather than as the nature of a design PR. Claude-Session: https://claude.ai/code/session_01VhMX8fBDkrNDpCmPYGycYX --- .../specs/2026-07-31-native-client-design.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/specs/2026-07-31-native-client-design.md b/docs/superpowers/specs/2026-07-31-native-client-design.md index c4b3c35..0c9f6b9 100644 --- a/docs/superpowers/specs/2026-07-31-native-client-design.md +++ b/docs/superpowers/specs/2026-07-31-native-client-design.md @@ -2,9 +2,14 @@ Issue: [DRYCodeWorks/hark#2](https://github.com/DRYCodeWorks/hark/issues/2) Date: 2026-07-31 -Revision: 4. Panel rounds: 6/6 REVISE, then 5 REVISE + 1 APPROVED, then 6/6 REVISE with -the findings down to two contradictions and a format bug. Rebased onto `8d12f7b`; all line -numbers re-verified against that commit. +Revision: 6. Six non-Claude reviewers, three review rounds plus three verification passes: +6/6 REVISE → 5 REVISE + 1 APPROVED → 6/6 REVISE → 4 APPROVED + 2 REVISE → 2 REVISE → +1 APPROVED + 1 REVISE, the last on documentation bookkeeping and one detection heuristic. +Rebased onto `8d12f7b`; every line citation re-verified against that commit. + +This is a design document. It ships no code, so the artifacts it describes — +`client/legacy/init-v1.lua`, the rewritten `install-client.sh` — do not exist yet by +construction. The implementation plan comes next. ## Why @@ -500,7 +505,12 @@ health check and happens after the handover: 1. a `~/.config/hark/legacy-client.json` ownership record written by an **earlier** run, **and** whose recorded target matches the symlink's current target; 2. otherwise, a symlink whose target carries the hark marker comment; - 3. otherwise, a *dangling* symlink whose recorded path matches a known hark layout. + 3. otherwise, a *dangling* symlink whose path matches a known hark layout — **a + heuristic, not proof.** When this is the *only* evidence, the installer must stop and + ask for explicit confirmation before quitting Hammerspoon, replacing the symlink, or + offering any TCC reset. A path that merely looks like hark's can belong to a + Hammerspoon config someone manages themselves, and every action gated behind this + detection is disruptive and not silently reversible. A real file, or a symlink without marker or record, means the user runs Hammerspoon independently — do not quit it, do not offer to revoke anything, and ask before