Skip to content

Fix three privacy and supply-chain issues - #9

Open
L65FREAD wants to merge 3 commits into
digimata:masterfrom
L65FREAD:security/privacy-and-supply-chain
Open

Fix three privacy and supply-chain issues#9
L65FREAD wants to merge 3 commits into
digimata:masterfrom
L65FREAD:security/privacy-and-supply-chain

Conversation

@L65FREAD

Copy link
Copy Markdown

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.swift pointed the LaunchAgent's StandardErrorPath at /tmp/parrot.err.log, and Parrot.swift logged the full text of every completed transcription to stderr. Anyone who ran parrot install --launch-at-login has 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.

  • Transcript line now records timing and length only (→ 0.42s · 63 chars)
  • New --echo-transcripts restores the full text; 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 transcript text
  • Generated plist now points both StandardOutPath and StandardErrorPath at /dev/null
  • New parrot install --log-file opts into ~/Library/Logs/parrot.log, created 0600 before launchctl bootstrap
  • Plist also sets Umask 127 — launchd recreates a missing std-path file at 0644, so without it 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. Never deletes silently; parrot install --purge-legacy-logs does that, and works standalone
  • parrot doctor gains a legacy-logs check
  • --dump-wav had the same bug at smaller scale (raw mic audio to /tmp/parrot-last.wav); now ~/Library/Caches/parrot/last-capture.wav at 0600 in a 0700 directory

2. The event tap subscribed to far more than it needed

The tap requested keyDown and keyUp alongside flagsChanged, then discarded everything but flagsChanged one line later. It was .listenOnly and nothing was done with the events, but --debug-hotkey printed the keycode of every event it saw.

  • eventsOfInterest is now flagsChanged only, with a comment so it doesn't get widened back
  • Removed the now-unreachable guard type == .flagsChanged and the parameter that existed only to serve it
  • Debug output prints the raw flags bitmask and no longer reads keyboardEventKeycode
  • --debug-hotkey's help no longer promises "every keyboard event the tap sees"
  • Related bug, same file: .tapDisabledByTimeout / .tapDisabledByUserInput were handled by doing nothing, so the daemon could silently stop responding to the hotkey. Both now re-enable the tap and log a line

One 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.sh resolved the latest tag, downloaded, extracted and installed with no integrity check, even though release.yml has always uploaded a .sha256 that nothing ever fetched.

  • Downloads ${ASSET}.sha256 and verifies with shasum -a 256 -c before extracting; prints the digest; fails closed on mismatch or a missing checksum, with no unverified fallback
  • Inspects the archive with tar -tzf first, rejecting anything that isn't exactly the member parrot, or that has an absolute or .. path
  • PARROT_VERSION pins an exact tag (piping to sh leaves no way to pass arguments)
  • The blanket xattr -d com.apple.quarantine was a no-opcurl doesn't set that attribute; it's applied by apps opting into LSFileQuarantineEnabled. It now reports whether there was anything to remove
  • Made POSIX-clean so the documented | sh is honest: dropped set -o pipefail, and the tag lookup no longer pipes curl into grep, where a failed download was masked by grep's exit status. Keeps the | sh one-liner valid, so gh-pages/index.html needs no change
  • release.yml adds actions/attest-build-provenance with id-token / attestations write. Note permissions: replaces rather than merges, so contents: write is re-declared — without that the release upload would start failing with a 403

On 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 .sha256 beside it. The attestation is the part that actually helps.

Why provenance is 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). 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 has gh installed. The checksum is the check that blocks. PARROT_REQUIRE_ATTESTATION=1 makes provenance failures fatal too.

Two things that need you

Verified / not verified

Tested: 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 (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.5 installs that tag and its digest matches the published one. All three commits build independently with swift 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 (no keyDown/keyUp subscription and no keyboardEventKeycode read 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-hotkey will still emit a line when you type a capital letter, because Shift is itself a flagsChanged event. What's gone is any keycode, and any output for plain character keys.

Nothing under UI/, Transcription/, or AudioCapture.swift's capture path was touched. No new dependencies.

Unrelated, spotted while working and left alone: README.md documents parrot --hotkey right-option, but no such flag exists on Run — possibly documented ahead of #4/#7.


Claude was used heavily for this work.

L65FREAD added 3 commits July 29, 2026 22:29
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.
@FernandoGomes83

Copy link
Copy Markdown

Field report from running this PR in a fork, one real regression: the Umask key in the LaunchAgent plist applies to everything the daemon creates, not just the log. WhisperKit/HubApi model downloads create their cache directories under the daemon's umask, so with Umask = 127 every new directory is born 0600 — no execute bit — and the next write inside it fails with EACCES (NSCocoaErrorDomain 513 / NSPOSIXErrorDomain 13 from downloadBase). Symptom: any model download triggered from inside the daemon fails; downloads from a terminal work, which makes it look intermittent.

What we settled on: drop Umask entirely and rely on prepareLogFile pre-creating the log 0600 (every install rewrite re-tightens it). The one gap — launchd recreating a deleted log at 0644 — seemed acceptable since the log never holds transcript text by this PR's own design. An alternative if 0600 must be unconditional: have the daemon chmod its own log at startup instead of using umask.

FernandoGomes83 added a commit to FernandoGomes83/parrot that referenced this pull request Jul 31, 2026
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.
FernandoGomes83 added a commit to FernandoGomes83/parrot that referenced this pull request Jul 31, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants