This document maps the §8 security model from dicta-design.md to the
specific code, tests, and configuration that enforce each item. The
design doc is the spec; this file is the audit map.
If you are reviewing dicta's security, start here. Each item names the concrete enforcement point so you can verify the claim.
Vulnerabilities: report privately via GitHub's private vulnerability reporting for this repository. As a fallback, you can email matthewjayhunter@gmail.com. Do not file public issues for exploitable bugs.
dicta is a per-user Linux daemon with two privileged capabilities:
synthesizing keystrokes (via ydotoold) and capturing microphone
audio (via PipeWire/PulseAudio). It runs as the user — not root, not
in the input group — and ships single-key activation only (no PTT,
no kernel evdev access). The threat we mitigate is "untrusted-but-not-
privileged code on the same machine attempts to misuse dicta's IPC,
its TLS posture, or its filesystem footprint." Threats we do NOT
defend against: a fully-compromised user account, a compromised
PipeWire daemon, or a malicious browser extension snooping the same
microphone stream.
Type-mode is the only path that can synthesize keystrokes; clip-mode
goes through wl-copy and the user pastes manually. Enforcement:
cmd/dictad/session.go—OnUtteranceonly invokesType()whenmode == modeType. The Pause toggle is the user's input gate.internal/dispatch/typer.go—\nis stripped from every payload before invokingydotool(D12). Tests ininternal/dispatch/typer_test.gocover the strip behavior.- The dispatch package validates the
ydotoolbinary path against an allowlist (pathOnAllowlist); off-allowlist binaries fail at daemon construction, not at first dispatch.
Capture starts when a session opens and stops when it closes. There is no always-on listening in v1 (wakeword is deferred to v2 per D3).
cmd/dictad/audiomonitor.go— capture is started/stopped by the daemon, with the session orchestrator deciding when viaToggle.cmd/dictad/session.go::Shutdown— explicitly closes any open session on SIGTERM so capture stops cleanly.
Cleanup ships disabled by default. Enabling requires explicit
--cleanup-enabled plus an endpoint URL.
internal/cleanup/cleaner.go::New— returns the passthrough cleaner unlessEnabled=true.internal/cleanup/http.go— emits a startup WARN if the endpoint ishttp://(transcripts in cleartext) or iftls_verify=false.- The mechanical system prompt is the constant
MechanicalSystemPrompt; tests incleaner_test.goenforce that it is not runtime-templated (no%s/%d).
All clip-mode output goes to wl-copy; the panel buffer is the safety
boundary (D12). Nothing reaches the clipboard until the user presses
Enter inside the panel.
cmd/dictad/session.go::Commit— the only path that callsclipper.Clip, gated onmode == modeClip. Cancel discards the buffer.internal/dispatch/clipper.go— pipes via stdin (no argv);wl-copybinary path validated against the allowlist.cmd/dicta-preview/main.go— Enter and Escape are scoped viakey.Filterso they reach the panel logic; nothing else triggers commit.
dicta does not auto-download models or whisper-server binaries. Users install both themselves. Enforcement:
internal/whispersup/config.go—BinaryandModelPathare validated against allowlists (DefaultBinaryAllowlist,DefaultModelPathAllowlist).internal/whispersup/supervisor.go—exec.Commandis built from typed config values, never via shell.
dictad and dicta build with CGO_ENABLED=0. MemoryDenyWriteExecute=true
in the systemd unit relies on this property — anything that introduces
CGo or a JIT into the daemon will cause the kernel to refuse the page
mappings and the daemon will fail to start.
packaging/systemd/dictad.service—MemoryDenyWriteExecute=true.cmd/dicta-preview/is the only binary permitted to use CGo (Gio Wayland), and it runs as a separate user process without the daemon's hardening.
internal/config/— typed TOML parser (per design; phase 9 of the build).- Subprocess argv lists are built from typed config values everywhere:
internal/dispatch/typer.go,internal/dispatch/clipper.go,internal/whispersup/supervisor.go,cmd/dictad/preview.go. - Path values are allowlist-checked (see items 1, 5, 11).
The control socket is created with mode 0600, user-owned. Adequate
for single-user systems; no cryptographic auth.
internal/control/server.go::Listen—os.Chmod(addr, 0o600)immediately after Listen.proto/proto.go::MaxLineBytes = 64 * 1024— bounds the per-line parse work to mitigate DoS via huge JSON.internal/control/fuzz_test.go::FuzzCommandUnmarshal— verifies the parser tolerates malformed/oversized/embedded-NUL input without panic.
All HTTP clients verify certificates. tls_verify = false is testing-
only and emits a startup WARN.
internal/asr/selector.go::selectOpenAI— emits a WARN onInsecureSkipTLSVerify=true. Test:TestSelectOpenAI_InsecureSkipVerifyEmitsWarning.internal/cleanup/http.go::newHTTPCleaner— same WARN. Test:TestNew_InsecureSkipVerifyEmitsWarning.
Type-mode dispatch strips \n defensively before ydotool to prevent
newline injection into shell prompts.
internal/dispatch/typer.go::Type— strips\nand\r. Tests intyper_test.go::TestTyper_StripsNewlines.- Clip-mode does not strip
\nbecause Shift+Enter is a documented user action in the panel; the clipboard is the boundary.
Transcript and audio capture are sensitive by definition. Both opt-in.
internal/audit/audit.go::New— returns passthrough unlessEnabled=true.--audit-enabled(JSONL) and--audit-keep-audio(WAV) are separate flags. Daemon logs a WARN when audit is enabled so accidental opt-ins are visible injournalctl.internal/audit/audit.go::sanitizeID— utterance IDs are sanitized before becoming filenames so a misbehaving ASR backend can't path-traverse out ofaudio/.internal/audit/config.go::DefaultPathAllowlist+ path validation inNew— rejects directories outside$XDG_DATA_HOME/dictaor/var/lib/dicta.- Files are mode
0600, directories0700. - Retention sweeper runs at startup and via
SweepLoop; deletes day- directories older thanRetentionDays(0= forever). - The Record's
PCMfield hasjson:"-"so audio bytes never serialize into JSONL.
packaging/systemd/dictad.service applies the full hardening posture:
NoNewPrivileges=trueProtectSystem=strict,ProtectHome=read-only,ReadWritePaths=%h/.local/share/dicta %tPrivateTmp=trueProtectKernelTunables=true,ProtectKernelModules=true,ProtectControlGroups=trueRestrictAddressFamilies=AF_UNIX AF_INET AF_INET6— no netlink, no packet sockets.RestrictNamespaces=true,LockPersonality=true,MemoryDenyWriteExecute=true(relies on D13).RestrictRealtime=trueSystemCallFilter=@system-service,SystemCallErrorNumber=EPERM
dicta does not attempt to defend against:
- A fully-compromised user account.
- A compromised PipeWire/PulseAudio daemon (it would have direct microphone access regardless).
- A malicious systemd unit running as the same user (it could read the control socket directly).
- Side-channel attacks against the cleanup or ASR endpoints.
- Network adversaries when the user explicitly opts into
tls_verify = false(the WARN at startup is the contract).
go test -race ./...— race-detector clean across the repo.goleakis wired into TestMain for every package with goroutines (cmd/dictad,internal/audio,internal/asr,internal/audit,internal/cleanup,internal/control,internal/whispersup).go test -fuzz=FuzzCommandUnmarshal ./internal/controlexercises the wire-protocol parser; the seed corpus runs as a regular test on every CI build.