Skip to content

Security: ameroyer/pocketpal

Security

SECURITY.md

Security notes: Pocket Pal

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 core trade-off (by design: know it before enabling the powerful modes)

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.

The OpenRouter API key

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.

SSH sessions

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, since ssh has 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.

asr_cmd (bring-your-own transcription)

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.

Fixed in code (from the review)

  • 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 of ssh_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_session validates the id as a bare UUID and confirms the resolved path stays inside the project directory before any unlink/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_history bakes the conversation into a <script> tag when it first opens the window; a literal </script in a reply is escaped first, so chat text can never close that tag early and inject markup. Every bubble is also rendered through textContent-based escaping, never raw HTML.

No .app bundle, no full-screen overlay (deliberately)

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.

Checked and clear

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.

There aren't any published security advisories