UDS server-side trust-boundary hardening (0.3.3) - #13
Merged
Conversation
Plan covers two server-side measures for the same-host UDS trust boundary: (1) enforce a 0700 owner-owned socket parent dir before bind, refusing to start otherwise; (2) peer-credential auth (macOS getpeereid via ctypes, Linux SO_PEERCRED) rejecting any peer whose uid != server uid on UDS. Reviewed via five-lens /review-plan; all findings folded in (cross-uid test redesign with test-only dir bypass, _cmd_serve error surface, install-script 0700 co-requisite, fail-closed sock-None guard, verified-vs-assumed split). Marker: reviewed 2026-06-26.
Independent Codex review of the plan surfaced two HIGH issues the five-lens pass missed, plus four defensive improvements. All folded in: - H1: enforce owner-owned, non-group/other-writable ancestor chain from the socket bind dir through the trusted root (sticky dirs excepted), not just the immediate parent — closes an ancestor-swap TOCTOU. - H2: drop the _skip_socket_dir_enforcement ServerConfig field (reachable via direct construction); use a test-only subclass/monkeypatch seam instead. - M1: wrap peer_uid() in try/except -> 403 (not just None); add raising test. - M2: pin websockets>=16,<17 (handshake API contract); lockfile row added. - L1: ctypes argtypes/restype + use_errno=True on libc.getpeereid. - L2: type resolver against a minimal structural Protocol, not socket.socket. Re-hashed review marker to bless the revised content.
Add a private _ensure-extra helper that probes a backend's import and runs 'uv sync --extra <X> --inexact' when the extra is missing, wired into stt-install and stt-enable. This makes 'just stt-install nemotron' produce a working agent instead of one that crash-loops on ModuleNotFoundError after a bare 'uv run'/'uv sync' prunes the optional extra. --inexact preserves other backends' extras; PIPECAT_STT_SKIP_DEP_SYNC lets operators (and the recipe tests) opt out. Kept in the justfile operator layer, off the Koda-consumed install_stt_agent.sh.
Each backend's start() re-raises a missing lazy import as 'the <X> extra is not installed ... run: uv sync --extra <X> --inexact', and _cmd_serve now catches ModuleNotFoundError so it prints 'stt_server: <msg>' + exit 1 instead of a bare traceback crash-looping in the LaunchAgent log. Pairs with the just _ensure-extra onboarding helper.
Addresses the Codex adversarial-review finding: the smoke bound its socket under tempfile's default root ($TMPDIR / /var/folders/.../T on macOS), whose per-user ancestors are 0700. A foreign uid fails at path resolution there BEFORE _process_request runs peer_uid, so the cross-uid test could never go green on macOS and a filesystem-boundary failure was indistinguishable from a peer-cred result. - Bind under /tmp (world-traversable, 1777) so the peer can reach the socket. - _assert_chain_traversable(): verify every ancestor up to / is other-traversable before driving the child; fail with a targeted diagnostic otherwise. - Classify the child outcome three ways — rejected (403, OK), accepted (security failure), or inconclusive (path/socket error, never reached the gate) — so a filesystem error can't masquerade as a pass or a peer-cred failure.
Bump version 0.3.2 -> 0.3.3 and cut the CHANGELOG [0.3.3] section (folding in the already-unreleased README-split docs entry). Patch-level: server-side hardening only, no new user-facing feature, no protocol/client change. The [0.3.3] notes flag the one behavior change that can affect existing hosts (a 0755 socket dir / out-of-$HOME socket path now refuses to start). Tag + GitHub release + 'uvx twine upload' happen at merge time, not here.
Complements 'just smoke-peercred', whose cross-uid leg skips without a second local uid reachable via passwordless sudo. This verifier drives a stdlib-only probe (no websockets/venv) against the same permissive socket as BOTH the owning uid (expect 101) and 'nobody' (expect 403) — so a 101-vs-403 split proves peer-cred, not the filesystem, is the discriminator. 'nobody' can run it because the probe lives in /tmp and uses the system python3 (the repo/venv under a 0750 home is unreadable to other uids). Run: sudo -v && uv run python scripts/verify_peercred_crossuid.py
…mirror Two fixes that get the suite fully green: 1. Narrow test_branch_diff_does_not_touch_koda_surface to the Koda-consumed *imported* surface (stt_server/client.py + stt_server/protocol.py) instead of the whole stt_server/ prefix + install script. Per the cross-repo contract a pin bump is only triggered by the imported client/protocol; the server runtime and install script are run from a HEAD checkout and coordinated at checkout time, not via the pin. This unblocks server-side hardening (which intentionally touches server.py/__main__.py/install_stt_agent.sh). 2. Repoint the per-ASR mirror parser from README.md to docs/operations.md, where the table moved when the README was split (PR #12). This was a pre-existing failure unrelated to the hardening — the test still read README.md after the table relocated.
…r gap, probe parity)
- _peercred: unpack SO_PEERCRED as unsigned ('3I'); a uid >= 2**31 was unpacked
signed/negative and would false-reject a legit same-uid peer on Linux.
- _peercred: cache the macOS getpeereid ctypes binding once per process instead
of reloading libc + re-setting argtypes/restype on every UDS connection.
- backends: catch ImportError (not just ModuleNotFoundError) so a present-but-
broken extra (e.g. nemotron's 'from mlx_audio.stt import load' on version skew,
a plain ImportError) surfaces as the actionable message instead of escaping
_cmd_serve as a bare traceback crash-loop.
- justfile _ensure-extra: probe the exact import backend.start() does
('from mlx_audio.stt import load' for nemotron), not a coarser 'import
mlx_audio' that a partially-installed package would pass.
- smoke_peercred: use shutil.which instead of exec'ing 'command' (a shell
builtin, not a guaranteed binary).
…anges - dev plan: tick completed checklist + acceptance-criteria boxes - dev_plans/README: cite PR #13 on the UDS row - CHANGELOG: list scripts/verify_peercred_crossuid.py in [0.3.3] Added - README: document stt-install/stt-enable auto-extra-install + PIPECAT_STT_SKIP_DEP_SYNC, add just smoke-peercred to the recipe list - operations.md: backend-dependency onboarding note + local boundary verification (smoke-peercred + cross-uid verifier)
_enforce_socket_dir_secure now returns resolved_bind / path.name and start() creates, binds, and chmods on that resolved path instead of the literal str(socket_path). The owner/mode ancestor walk verifies the resolved chain, so binding the unresolved path left a symlinked ancestor outside the verified chain repointable in the stat->bind window. Default cache path has no symlink components, so resolved == literal there. Also guard connection.transport is None in the UDS peer-cred gate so a future/edge None fails closed (403) rather than raising AttributeError. Updates the test-only enforcement seam to mirror the new bind-path return contract (returns the socket path, not None).
_cmd_serve caught ModuleNotFoundError, so a partially-present backend extra whose import fails on a missing sub-symbol (plain ImportError, not ModuleNotFoundError) escaped as a bare traceback. ImportError is the superset, so the actionable 'stt_server: <msg>' + exit 1 now covers it.
The Linux resolver reads struct ucred as unsigned (3I) so a uid >= 2**31 stays positive; the mock asserted/packed signed 3i. Both are 12 bytes so the test passed, but it no longer faithfully mirrored the implementation it guards. Match 3I exactly.
smoke_peercred.py and verify_peercred_crossuid.py each carried a near-identical traversability check that would diverge silently. Move the canonical version to scripts/_smoke_lib.py (pure stdlib) and import it in both. Also updates each script's test-only enforcement seam to the new bind-path return contract (returns the socket path, not None).
The floor moved >=13 -> >=16,<17 across every extra (client included), so an environment resolving an older websockets hits a dependency conflict. Wire protocol and stt_server.client API are unchanged, so it stays a patch release; document the caveat in Upgrade notes.
Adversarial-review no-ship: the prior fix resolved path.parent and bound the resolved target, but clients and the LaunchAgent still connect via the literal configured path. That split the verified chain from the client-visible one: a symlink in a group/other-writable lexical ancestor of the literal path was skipped by the resolve()-d walk, so a foreign uid who can write that ancestor could repoint the symlink after startup and hijack the path clients connect to while the server stayed bound to a safe resolved target. _enforce_socket_dir_secure now verifies the LITERAL lexical chain clients traverse (no resolve()), rejects any symlink component via os.lstat, and requires an absolute, ..-free path. It returns and the server binds the literal verified path, so verified == bound == client-visible. Default cache path (no symlink components) is unchanged. Adds a regression test: $HOME/writable (group-writable) / link -> $HOME/safe, socket under link -> startup refuses naming the symlink component. Updates the foreign-owner test to patch os.lstat (recursion-safe lexical match).
Adversarial-review medium: render_plist() mkdir'd + chmod 700'd the socket parent before the server's path rules got a say. A custom PIPECAT_STT_SOCKET outside $HOME, or with a symlinked parent, would have its directory tightened to owner-only and THEN fail at startup when _enforce_socket_dir_secure rejects it — an availability/permissions regression, not a clean fail-closed refusal. Add validate_socket_path() mirroring the server's rules (absolute, ..-free, under $HOME, no symlink component) and call it first in render_plist(), before any mkdir/chmod. A rejected path now exits 1 with an actionable error and zero filesystem mutation. Tests: outside-$HOME and symlinked-parent custom sockets both refuse with exit 1 and leave the operator's directory permissions untouched.
- CHANGELOG: add [0.3.3]/[0.3.2] footer links; set 0.3.3 release date to 2026-06-28. - README: backend-extra callout reflects the actionable 'stt_server: the <extra> extra is not installed … --inexact' message instead of a bare ModuleNotFoundError. - docs/operations.md: document the installer's pre-mutation validate_socket_path() (absolute / under $HOME / no symlink). - dev plan: refresh spec rows + Final Results to the final state (literal-chain os.lstat symlink rejection, ImportError handler, validate_socket_path) and record the post-phase-5 code-review fixes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Server-side hardening of the same-host Unix-domain-socket (UDS) trust boundary,
plus backend-extra onboarding ergonomics. No new user-facing features; clients
connect unchanged — no wire-protocol or client-API change. Targets release
0.3.3.Two independent, layered server-side measures close the same-host UDS attack
surface:
Before bind, the server walks every directory from the socket's parent up to
and including
$HOMEover the literal lexical path and refuses to startunless each is owner-owned and not group/other-writable (sticky-bit dirs
excepted), creating missing dirs
0700. The walk usesos.lstatso anysymlink component is rejected outright — a symlinked ancestor could be
repointed post-startup to hijack the path clients connect to. This makes the
socket un-plantable — no foreign uid can
unlink+binda replacement.connection whose kernel-reported peer uid
!= os.geteuid()is rejected with apre-handshake HTTP
403(peer not permitted), via macOSgetpeereid(2)/Linux
SO_PEERCRED. Every resolver failure path fails closed. UDS only — TCPkeeps the Origin + bearer-token checks unchanged.
The bearer token is retained for TCP/remote (which has neither boundary).
What changed
stt_server/server.py—_enforce_socket_dir_secure()(literal-path ancestorwalk,
os.lstatrejects symlink components, creates0700, returns verifiedliteral path) wired into
start(); UDS-only peer-cred gate in_process_request.stt_server/_peercred.py(new) — cross-platformpeer_uid()resolver(
SO_PEERCRED/getpeereidviactypes), fail-closed.stt_server/__main__.py—_cmd_servesurfaces startup failures (direnforcement,
ServerConfigValueError, bindOSError, missing backendextra) as
stt_server: <msg>+ exit 1 instead of a bare traceback. Handlercatches
(ValueError, OSError, ImportError)so a missing backend extra alsosurfaces cleanly.
stt_server/backends/{mlx_whisper,nemotron,parakeet}.py— a missing backendextra re-raises as an actionable
… run: uv sync --extra <X> --inexact.scripts/install_stt_agent.sh— addsvalidate_socket_path()that validates acustom
PIPECAT_STT_SOCKET(absolute, under$HOME, no symlink component)before any
mkdir/chmod, so a path the server would reject fails theinstall cleanly with no filesystem mutation; then creates the socket dir
0700and self-heals a pre-existing
0755.justfile—_ensure-extramakesjust stt-install/stt-enableinstall thebackend's optional extra (
uv sync --extra <X> --inexact); opt out withPIPECAT_STT_SKIP_DEP_SYNC=1.pyproject.toml/uv.lock— pinwebsockets>=16,<17; bump version to0.3.3.docs/operations.md,docs/protocol.md— trust-model + socket-security notes;pre-handshake connection-rejection table.
multi-connection), dir-enforcement tests, backend-missing-extra tests, and a
local cross-uid verifier (
scripts/verify_peercred_crossuid.py).Verification
uv run python -m pytest -q→ 339 passed, 2 skipped, 0failed.
ruff check+ruff format --checkclean. Includes regression testsfor the literal-chain symlink rejection and the installer's pre-mutation
socket-path validation (outside-
$HOME+ symlinked-parent).~/Library/Caches/pipecat-sttchain (dir enforcement passes); a same-uidstatusround-trip succeeds with no fail-closed warnings; socket inode is0600.403verified on real hardware viascripts/verify_peercred_crossuid.py: against one permissive socket(
0o666/0711parent under/tmp), the owning uid (501) completed thehandshake (
101) whilenobody(uid 4294967294) was rejected(
403 peer not permitted); server loggedstt_server: rejecting UDS peer uid 4294967294 (expected 501). Same socket +perms, only the uid differs → peer-cred (not the filesystem) is provably the
discriminator. This closes the adversarial-review NO-SHIP finding.
Upgrade notes (read before deploying)
start against a group/other-writable socket-dir ancestor, an existing
0755socket directory — or a custom
STT_WS_SOCKET/KODA_STT_SOCKETpointingoutside
$HOME— will block startup. Re-runscripts/install_stt_agent.sh(self-heals to
0700), orchmod 700the dir / move the socket under$HOME.Cross-repo (Koda)
No client pin bump is required —
stt_server/client.pyand the wire protocol areuntouched. Koda runs the server from a HEAD checkout, so the hardening lands on
the next checkout update (not at a release); fold the
0755 → 0700socket-dirupgrade into that window. The
test_branch_diff_does_not_touch_koda_surfaceguard was narrowed to the actual imported surface (
client.py+protocol.py)to match the cross-repo contract — server-runtime changes are coordinated via the
checkout, not the pin.
Release
0.3.3is prepared on this branch (version bump + CHANGELOG cut, folding in thealready-unreleased README-split docs entry). Tag
v0.3.3+ GitHub release +uvx twine uploadhappen at merge time. The missingv0.3.2GitHub release wasalso backfilled out-of-band.
Notes for reviewers
_enforce_socket_dir_secure, unreachable fromserve()or normalTranscriptionServerconstruction (noServerConfigflag)./skein:conduct, then hardened overthree adversarial Codex review rounds:
fixed in
0d9410e, verified above.target, but clients still connect via the literal path — a symlink in a
writable lexical ancestor could be repointed to hijack the client-visible
socket. Fixed in
a50624c: verify the literal chain, reject symlinkcomponents, bind the literal verified path (regression test added).
chmod-ed a custom socket parent before theserver's rules could reject it. Fixed in
f9f41b6:validate_socket_path()refuses an unsupported path before any
mkdir/chmod(regression testsadded).