Fix three privacy and supply-chain issues - #9
Conversation
Anyone who ran `parrot install --launch-at-login` has been accumulating a
plaintext record of every sentence they have ever dictated in
/tmp/parrot.err.log — world-readable, never rotated, with no size cap. The
generated plist redirected the daemon's stderr there, and stderr carried the
full text of every completed transcription. Passwords, private messages,
medical details: all of it, retained indefinitely, readable by any local user
or unsandboxed process. Existing users need to be told to go look at that file.
- The transcript line now records timing and length only ("→ 0.42s · 63 chars").
--echo-transcripts restores the full text and its help says the text will
appear in logs. It is deliberately not forwarded into the LaunchAgent's
ProgramArguments, so the daemon can never be configured to log transcripts.
- The generated plist points StandardOutPath/StandardErrorPath at /dev/null.
`parrot install --log-file` opts into ~/Library/Logs/parrot.log instead,
created 0600 before `launchctl bootstrap`. launchd appends to an existing
std-path file and leaves its mode alone, but recreates a missing one under
its own umask (0644), so the plist also sets Umask 127 — otherwise the 0600
silently degrades the first time the log is deleted.
- `parrot install` now warns about pre-existing /tmp/parrot.{out,err}.log,
naming each path and its size in bytes. They are never deleted silently;
`parrot install --purge-legacy-logs` removes them, and works standalone.
- `parrot doctor` gains a legacy-logs check that warns while either file exists.
- --dump-wav wrote raw microphone audio to /tmp/parrot-last.wav, the same class
of bug at smaller scale. It now writes ~/Library/Caches/parrot/last-capture.wav
at 0600 inside a 0700 directory, and its help says the file holds raw audio.
Verified: a fresh install writes /dev/null for both path keys; --log-file
produces -rw-------; doctor warns with a legacy log present and passes without
one; --purge-legacy-logs exits 0 standalone, where it previously hit the
"specify exactly one of" guard and exited 64.
The tap subscribed to keyDown and keyUp as well as flagsChanged, so the process received every keystroke typed anywhere on the system — then discarded all of it one line later with `guard type == .flagsChanged`. The tap is .listenOnly so it could not modify or suppress anything, and nothing was done with the events, but --debug-hotkey printed the keycode of every event it saw, which is a keylogger in all but intent. A push-to-talk modifier needs flagsChanged and nothing else. - eventsOfInterest is now flagsChanged only, with a comment saying why, so nobody widens it back. - Dropped the now-unreachable `guard type == .flagsChanged`, and the type parameter to handle() that existed only to serve it. - The debug line prints the raw flags bitmask and no longer reads keyboardEventKeycode, which is meaningless for flagsChanged anyway. - --debug-hotkey's help no longer promises "every keyboard event the tap sees", which is no longer true and should not be. Also in this file: .tapDisabledByTimeout and .tapDisabledByUserInput were handled by doing nothing, with a comment suggesting the user restart parrot. macOS disables taps that block too long and whenever Secure Input engages, so the daemon could silently stop responding to the hotkey with no indication. Both now re-enable the tap and write a line to stderr. These two event types are delivered out of band regardless of the subscribed mask, so narrowing the mask does not affect them. Note for review: Secure Input engages on password fields, which a dictation daemon sits next to constantly. A bare re-enable will not stick while it is active, so this can produce repeated disable/re-enable cycles and one stderr line each. Rate-limiting was left out to keep this diff to the reported issue. Verified by inspection: no keyDown/keyUp subscription and no keyboardEventKeycode read remain in the source. The live check — that Fn press/release still emits and typing plain text does not — needs Accessibility granted and a physical keypress, so it has not been run here. Note that Shift/Cmd/Ctrl/Option are themselves flagsChanged events, so typing a capital letter will legitimately still produce a debug line; what is gone is any keycode and any output for plain character keys.
scripts/install.sh resolved the latest tag, downloaded the tarball, extracted
it and moved the binary into /usr/local/bin with no integrity check at any
step, even though release.yml has always uploaded a .sha256 alongside the
artifact. Nothing ever fetched it.
install.sh:
- Downloads ${ASSET}.sha256 and verifies with `shasum -a 256 -c` before
extracting. Prints the verified digest. Fails closed on mismatch and on a
missing checksum asset; there is no unverified fallback.
- Inspects the archive with `tar -tzf` before extracting and rejects anything
that isn't exactly the single member `parrot`, or that contains an absolute
or `..` path.
- Honors PARROT_VERSION to pin an exact tag, since piping to sh leaves no way
to pass arguments.
- The blanket `xattr -d com.apple.quarantine` was a no-op: curl does not set
that attribute, it's applied by apps opting into LSFileQuarantineEnabled.
Now it reports whether there was anything to remove.
- POSIX-clean, so the documented `| sh` invocation is honest: `set -o pipefail`
is gone, and the tag lookup no longer pipes curl into grep, where a failed
download was masked by grep's exit status. Shebang is now /bin/sh. This keeps
the `| sh` one-liner valid, so gh-pages/index.html needs no change.
release.yml:
- Adds actions/attest-build-provenance on the release tarball, with id-token
and attestations write permissions. Note that a permissions block replaces
the defaults rather than merging, so contents: write is re-declared — without
that the release upload would start failing with a 403.
A checksum published in the same release as the artifact does NOT make the
install trustworthy. It proves the download wasn't corrupted in transit;
whoever can replace the tarball can replace the .sha256 beside it. The
attestation is the part that actually helps, because it's signed by GitHub and
binds the artifact to a workflow run at a commit.
Provenance verification is deliberately advisory rather than fail-closed.
`gh attestation verify` requires the user to be logged in even for a public
repo, and returns the same exit code for "no attestation exists", "your token
expired" and a genuine verification failure (cli/cli#9338). Since no release
before this change has an attestation — and v0.0.5 can never retroactively get
one — failing closed would make parrot uninstallable today for everyone who has
gh installed. The checksum is the check that blocks. Set
PARROT_REQUIRE_ATTESTATION=1 to make provenance failures fatal too.
NOTE FOR THE MAINTAINER: scripts/install.sh and the gh-pages copy served at
digimata.github.io/parrot/install.sh must be updated together — they are
currently byte-identical (sha256 2ca5a0b6...a936ee) and this commit only
changes the copy on this branch. I can't push to gh-pages.
Verified against a local server and against the real v0.0.5 release: a tampered
tarball, a missing .sha256, and an archive with an extra member each exit 1
with nothing installed; PARROT_VERSION=v0.0.5 installs that tag and its digest
matches the published one. The attestation step itself can't be tested until a
tag is built by the updated workflow.
|
Field report from running this PR in a fork, one real regression: the What we settled on: drop |
Transcripts no longer logged by default (timing/length only; --echo-transcripts opts in), LaunchAgent discards output unless --log-file (0600), doctor flags and install --purge-legacy-logs removes the old world-readable /tmp logs, event tap narrowed to flagsChanged only and re-enabled when the system disables it, install.sh verifies sha256 + attestation, release workflow signs build provenance. Also fixes MenuBarController super.init order from the digimata#14 merge.
Umask 0177 (from the digimata#9 merge) applied to everything the daemon creates, not just the log — model-cache directories were born 0600 without an execute bit, so switching to a not-yet-downloaded model from the menu failed with EACCES inside the LaunchAgent. The log stays 0600 via prepareLogFile, re-tightened on every install rewrite; a log recreated by launchd is 0644 but holds no transcript text.
Three independent fixes, one per commit, so they can be reviewed or cherry-picked separately. The first one is the urgent one.
1. Transcripts were being written to a world-readable log, permanently
Install.swiftpointed the LaunchAgent'sStandardErrorPathat/tmp/parrot.err.log, andParrot.swiftlogged the full text of every completed transcription to stderr. Anyone who ranparrot install --launch-at-loginhas a plaintext record of every sentence they have ever dictated, in a world-readable directory, with no rotation and no size cap. This is worth telling existing users about directly.→ 0.42s · 63 chars)--echo-transcriptsrestores the full text; its help says the text will appear in logsProgramArguments, so the daemon can never be configured to log transcript textStandardOutPathandStandardErrorPathat/dev/nullparrot install --log-fileopts into~/Library/Logs/parrot.log, created0600beforelaunchctl bootstrapUmask127 — launchd recreates a missing std-path file at0644, so without it the0600silently degrades the first time the log is deletedparrot installnow warns about pre-existing/tmp/parrot.{out,err}.log, naming each path and its size. Never deletes silently;parrot install --purge-legacy-logsdoes that, and works standaloneparrot doctorgains a legacy-logs check--dump-wavhad the same bug at smaller scale (raw mic audio to/tmp/parrot-last.wav); now~/Library/Caches/parrot/last-capture.wavat0600in a0700directory2. The event tap subscribed to far more than it needed
The tap requested
keyDownandkeyUpalongsideflagsChanged, then discarded everything butflagsChangedone line later. It was.listenOnlyand nothing was done with the events, but--debug-hotkeyprinted the keycode of every event it saw.eventsOfInterestis nowflagsChangedonly, with a comment so it doesn't get widened backguard type == .flagsChangedand the parameter that existed only to serve itkeyboardEventKeycode--debug-hotkey's help no longer promises "every keyboard event the tap sees".tapDisabledByTimeout/.tapDisabledByUserInputwere handled by doing nothing, so the daemon could silently stop responding to the hotkey. Both now re-enable the tap and log a lineOne thing to weigh: Secure Input engages on password fields, which a dictation daemon sits beside constantly, and a bare re-enable won't stick while it's active. Rate-limiting was left out to keep the diff scoped — happy to add it if you'd prefer.
3. The installer verified nothing it downloaded
scripts/install.shresolved the latest tag, downloaded, extracted and installed with no integrity check, even thoughrelease.ymlhas always uploaded a.sha256that nothing ever fetched.${ASSET}.sha256and verifies withshasum -a 256 -cbefore extracting; prints the digest; fails closed on mismatch or a missing checksum, with no unverified fallbacktar -tzffirst, rejecting anything that isn't exactly the memberparrot, or that has an absolute or..pathPARROT_VERSIONpins an exact tag (piping toshleaves no way to pass arguments)xattr -d com.apple.quarantinewas a no-op —curldoesn't set that attribute; it's applied by apps opting intoLSFileQuarantineEnabled. It now reports whether there was anything to remove| shis honest: droppedset -o pipefail, and the tag lookup no longer pipescurlintogrep, where a failed download was masked by grep's exit status. Keeps the| shone-liner valid, sogh-pages/index.htmlneeds no changerelease.ymladdsactions/attest-build-provenancewithid-token/attestationswrite. Notepermissions:replaces rather than merges, socontents: writeis re-declared — without that the release upload would start failing with a 403On the honest limit: a checksum published in the same release as the artifact does not make the install trustworthy. It proves the download wasn't corrupted; whoever can replace the tarball can replace the
.sha256beside it. The attestation is the part that actually helps.Why provenance is advisory rather than fail-closed:
gh attestation verifyrequires the user to be logged in even for a public repo, and returns the same exit code for "no attestation exists", "your token expired", and a genuine verification failure (cli/cli#9338). No release before this change has an attestation, and v0.0.5 can never retroactively get one — so failing closed would make parrot uninstallable today for everyone who hasghinstalled. The checksum is the check that blocks.PARROT_REQUIRE_ATTESTATION=1makes provenance failures fatal too.Two things that need you
gh-pagesmust be updated together.scripts/install.shand the copy served atdigimata.github.io/parrot/install.share currently byte-identical (2ca5a0b6…a936ee). I can't push togh-pages, so this PR only changes the copy onmaster.Verified / not verified
Tested: fresh install writes
/dev/nullfor both path keys;--log-fileproduces-rw-------; doctor warns with a legacy log present and passes without one;--purge-legacy-logsexits 0 standalone (it previously hit the "specify exactly one of" guard and exited 64). Installer, against both a local server and the real v0.0.5 release: a tampered tarball, a missing.sha256, and an archive with an extra member each exit 1 with nothing installed;PARROT_VERSION=v0.0.5installs that tag and its digest matches the published one. All three commits build independently withswift build -c release.Not tested: the Fn press/release path with
--debug-hotkey, which needs Accessibility granted and a physical keypress — the narrowing is verified by inspection instead (nokeyDown/keyUpsubscription and nokeyboardEventKeycoderead remain). And the attestation itself can't be exercised until a tag is built by the updated workflow.One correction to how this is easy to describe:
--debug-hotkeywill still emit a line when you type a capital letter, because Shift is itself aflagsChangedevent. What's gone is any keycode, and any output for plain character keys.Nothing under
UI/,Transcription/, orAudioCapture.swift's capture path was touched. No new dependencies.Unrelated, spotted while working and left alone:
README.mddocumentsparrot --hotkey right-option, but no such flag exists onRun— possibly documented ahead of #4/#7.Claude was used heavily for this work.