This is a single-user desktop tool that wires an always-on microphone to Claude, and (optionally) lets Claude read your files, run shell commands, and drive live terminal sessions on your machine or over SSH. That power is the point, but it means the honest threat model is "who gets to drive Claude, and what can Claude then touch." This file records the risks and what the code does about them.
The microphone is an unauthenticated command surface. Speech is detected, transcribed, and sent to the current brain with no wake word, no speaker verification, and no confirm-before-act. Anyone (or anything) your mic can hear (a person beside you, a voice on a call or video, a smart speaker) can talk to your pal. What that buys them depends on the selected brain:
| Brain | What a voice can do |
|---|---|
| Fresh model (default) | Chat, and read any file on disk (read-only tools), with the contents spoken aloud / shown in the bubble |
| 💻 / 🌐 live session | Whatever that session can do, including file writes and shell commands (Bash, Edit, Write are pre-approved in these headless calls), in that project's folder |
Two consequences worth internalizing:
- A live-session brain turns your open mic into a remote shell for that project. Only select one when you're comfortable with that, and mute the ears (🎤) or switch back to a fresh brain when you step away.
- Read-anywhere + spoken output is an exfiltration channel. If you ask the
pal to "look at" a malicious file, injected text in it could steer Claude to
read something sensitive (
~/.ssh/…,~/.aws/credentials, the pal's own config with your OpenRouter key) and say it out loud. Prompt-injection is real here. Don't point the eyes at untrusted files and walk away.
Mitigations available to you: mute the mic (persisted), pick a fresh brain rather than a session when you don't need hands, and keep the machine physically trusted. The mic is the key to everything below it.
If you use an OpenRouter brain, the key is stored plaintext in
~/.cache/pocket_tts_widget/config.json. It's readable by any process running as
you, and, notably, by the read-anywhere eyes tool above. Prefer the claude CLI
brains (no key involved) where you can; if you must store a key, treat that config
file as a secret. Config is now written atomically (temp + rename) so a crash
can't corrupt it.
Reaching sessions on other machines uses a deliberately narrow envelope:
- Only hosts you list in ⚙ are ever contacted.
ssh -oBatchMode=yes: existing keys/agent only (never a password prompt, never stored credentials).- The only remote actions are fixed shell templates (
_SSH_DISCOVER,_SSH_DRIVE) fed over stdin; the model never composes SSH commands. - Your transcribed message crosses as a single control-char-scrubbed, shell-quoted argument.
- Host tokens are validated (
valid_ssh_host) and, importantly, a host starting with-is rejected, sincesshhas no--end-of-options and would otherwise read-oProxyCommand=…as a local-command option.
A compromised listed host can only control what gets typed into its own panes; it cannot escalate onto your machine (its JSON is quoted and used only remotely).
Discovery/validation calls (opening the sessions menu, checking a session pid)
reuse one multiplexed connection per host (ControlMaster) instead of a fresh
handshake+auth every time. Control sockets live under a 0700, per-uid temp
dir and auto-close (ControlPersist=10m) after the last use: no new
credentials or persistent state, just a connection that outlives the widget
by at most that window.
config["asr_cmd"] runs as a shell command by design. The audio path is
shell-quoted (not injectable), but the command string itself is whatever the
config says, so any process that can write your config gets code execution
the next time the mic hears speech. This is inherent to a bring-your-own hook;
only set it to something you trust.
- SSH host validation / leading-
-rejection: closes a local-RCE via a malicious "scan this host" value (valid_ssh_host, applied on both write and read ofssh_hosts). - Atomic + locked config writes:
update_config()serializes all mutators under one lock (they run on pywebview worker threads) so concurrent calls can't clobber each other's fields;save_config()writes temp +os.replace. - Control-char scrub on the local tmux drive: matches the SSH path; a newline in a message can't submit early or inject extra input lines.
- Path-traversal guard on fork cleanup:
_forget_forked_sessionvalidates the id as a bare UUID and confirms the resolved path stays inside the project directory before anyunlink/rmtree. - Bounded, collision-free paste dir: screenshots use unique names and the directory is capped at 20 files.
- Escaped script-embedding in the history window:
Api.open_historybakes the conversation into a<script>tag when it first opens the window; a literal</scriptin a reply is escaped first, so chat text can never close that tag early and inject markup. Every bubble is also rendered throughtextContent-based escaping, never raw HTML.
Making the pal float over another app's true full-screen Space needs it to be a
LaunchServices-registered app, i.e. a .app bundle. That was tried and removed:
launched that way it hit macOS TCC privacy protection on ~/Documents (where the
repo lives) and couldn't read its own code without being granted Documents /
Full-Disk access, a scary permission to give an unsigned, always-on-mic,
floats-over-everything agent, and not worth it. So the feature was dropped
entirely. The pal is launched from a terminal / the .command, running with your
existing shell's permissions, granting no new disk access to a new app identity.
To keep a terminal visible alongside the pal, maximize it instead of using true
full-screen.
The webview layer was audited end to end, across both ui.html and the
history.html window: every dynamic value rendered into a page (tool names,
agent descriptions from remote transcripts, replies, session names/paths,
remote stderr in status messages, conversation history) is HTML-escaped or
set via textContent, and the JS transport uses json.dumps
(ensure_ascii=True). No XSS path was found. All subprocess calls are
argv-based (tmux send-keys with -l --, pbcopy via stdin) except the two
intentional shell hooks above.