Echo is a local-only TTS notification daemon. It is not a public service:
its threat model is "a process on this machine POSTs text to be spoken" plus, for the
optional voice ask, "a process on this machine asks the human a question and reads the
answer." This doc describes the trust boundary, egress posture, and secret handling. For the
request flow see ARCHITECTURE.md; for egress detail see
docs/providers-observability.md.
- Localhost only. The daemon binds
localhost:3246(PORT, default 3246). It is meant to be reachable only by other processes on the same machine; do not expose it to a network. - CORS restricted to localhost.
Access-Control-Allow-Originis hard-set tohttp://localhost(core/server.ts);OPTIONSreturns204. Browsers on other origins cannot read responses. - Rate limiting. This is a flood guard against runaway loops, not an authentication
mechanism. The daemon trusts caller-supplied
X-Forwarded-Forfor bucket keys, so changing that header can evade the limits. Bucket rules and the socket-based health exemption are defined indocs/http-api.md. - Input sanitization. Every spoken message passes
validateInput(non-empty string, ≤500 chars) andsanitizeForSpeech, which strips<script,../, shell metacharacters (; & | > < \$ `), and markdown before the text reaches a provider or the macOS banner. - Native terminal visual delivery is adapter-owned, not core. An adapter that routes a
notification's title/body through Herdr or a supported terminal's OSC sequence
(
shared/terminal-notify.ts) normalizes that text independently ofsanitizeForSpeech- stripping control/escape bytes and bounding length - and never writes to a hook's or the daemon's stdout. Alacritty and unproven tmux passthrough are treated as unsupported (fail-closed); the daemon only learns about a successful native delivery after the fact, via the exactvisual_delivery: "native"marker (seedocs/http-api.md).
There is no authentication on /notify - any local process may request speech - and
the same applies to /mute (#83): any local process may flip the global mute. GET /voices
is likewise unauthenticated: any local process can read the configured persona keys and the
default provider name (no API keys or voice ids). Note the
reach is wider than "local process": a POST with an empty or text/plain body is a
CORS simple request, sent without preflight, so a cross-origin web page in a local
browser can fire it blind (e.g. sendBeacon) - the localhost CORS header only prevents
reading the response, not sending the request. That reach is an accepted risk for a
single-user local daemon (impact is audio-only; notifications are still processed and
logged), not an oversight; do not add network exposure without revisiting it.
The optional voice-ask capability adds a microphone to the picture, so it gets its own
boundary notes. Mechanism and process topology: docs/converse.md.
- A second unauthenticated localhost surface. The coordinator binds
127.0.0.1:32468(ECHO_CONVERSE_PORT) and, like/notify, authenticates nobody: any local process can book a turn and cause a question to be spoken. The coordinator never records; the shipped host tools require their own live-session consent before calling it. This is still an accepted single-user local surface, one rung higher, so do not expose it to a network. - Host-session consent is separate from TCC.
echo_askfails closed before capture unless its live Pi, omp or MCP session granted once through a human-facing prompt. Grants and denials exist only in adapter memory and expire at the host boundary each adapter can enforce. MCP has no conversation lifecycle, so Claude Code's enforceable boundary is the stdio MCP process, not a guessed conversation id; seedocs/converse.mdfor exact state and expiry behavior. This gate protects the shipped tools, not arbitrary local programs that call the unauthenticated coordinator and implement their own recorder. - macOS TCC remains the OS gate. The measured direct-terminal topology attributes capture to the terminal application, and the caller-side code normally spawns capture from the host adapter process rather than Echo's coordinator. Source-level checks keep capture code out of the coordinator; they do not enforce runtime ancestry. Pi and omp use the measured shape, while Claude Code's stdio MCP ancestry remains unverified and is recorded on each turn rather than guaranteed. No TCC grant means no audio, and there is deliberately no LaunchAgent for capture.
- Transcription is local-only. No cloud rung and no API key exists for speech-to-text, so a spoken answer never leaves the host.
- The audio and the words stay in the capturing process. The private capture directory is
0700; the recorder output and converted whisper input are forced to0600before use. Every artifact is deleted on success, cancellation, and failure.POST /turn/:id/completereports a character count, never the transcript, so the coordinator never sees the text.
- Default egresses. The default provider
edgettsis Microsoft's online TTS service, so out of the box, spoken text leaves the host to Microsoft. "No external calls" is not the default state. - Disabled providers make zero calls. Egress gating is structural:
speakWithFallbackskips a disabled provider before anyisHealthy()/speak(), andgetProviderStatusonly probes enabled providers. A disabled provider reaches no network. Proven bytests/core/egress-gating.test.ts. - Auditable via
/health. Each provider reportswouldEgressand (when true)egressTarget, so the current egress surface is inspectable at a glance. - Fully-local recipe. Disable
edgetts/elevenlabsand runkokoro(local endpoint) orsay; every enabled provider'swouldEgressthen readsfalse/local.
- No secrets in the repo.
.envfiles are not committed;*.logand/tmp/are gitignored. Never commit an API key. - ElevenLabs key via env.
voices.jsoncarries only the placeholder'${ELEVENLABS_API_KEY}'; the daemon interpolates the real key from the environment at runtime (resolveEnvVar, falling back to a bareELEVENLABS_API_KEY). The key is read once in the provider constructor -/healthreports onlyapiKeyConfigured: true|false, never the key itself. - Config resolves from user-owned paths -
~/.config/echo/config.jsonwins, followed for one release by deprecated process and legacy dotenv fallbacks. Resolution is read-only and never writes values intoprocess.env, so a secret in either file is not hydrated into the environment that same-process modules and spawned helpers inherit.config.jsonrejectsELEVENLABS_API_KEY, which keeps the one secret in a dotenv file that is never staged into the daemon payload. Precedence detail:docs/configuration.md.
Process state must live under user-owned cache/log/config paths, never /tmp:
- Audio temp files:
AUDIO_CACHE_DIR(default~/Library/Caches/echo/audioon macOS, else$XDG_CACHE_HOME/~/.cache), created withmkdirSync(..., { mode: 0o700 })and per-rendermkdtempSyncsubdirectories. - Logs:
~/Library/Logs/echo.log(human) and the separate~/Library/Logs/echo/voice-resolution.jsonl(drop-off log), or$XDG_STATE_HOME/~/.local/stateoff macOS.
This is an invariant (see AGENTS.md): do not write process state to /tmp.
This is a personal/local tool. If you find a security issue, open a GitHub issue (or contact the maintainer) - do not include live secrets in the report.