Skip to content

UDS server-side trust-boundary hardening (0.3.3) - #13

Merged
vr000m merged 24 commits into
mainfrom
feature/uds-trust-boundary-hardening
Jun 28, 2026
Merged

UDS server-side trust-boundary hardening (0.3.3)#13
vr000m merged 24 commits into
mainfrom
feature/uds-trust-boundary-hardening

Conversation

@vr000m

@vr000m vr000m commented Jun 27, 2026

Copy link
Copy Markdown
Owner

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:

  1. Socket-directory ancestor enforcement (primary filesystem boundary).
    Before bind, the server walks every directory from the socket's parent up to
    and including $HOME over the literal lexical path and refuses to start
    unless each is owner-owned and not group/other-writable (sticky-bit dirs
    excepted), creating missing dirs 0700. The walk uses os.lstat so any
    symlink 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+bind a replacement.
  2. Peer-credential authentication (kernel-authoritative backstop). Every UDS
    connection whose kernel-reported peer uid != os.geteuid() is rejected with a
    pre-handshake HTTP 403 (peer not permitted), via macOS getpeereid(2) /
    Linux SO_PEERCRED. Every resolver failure path fails closed. UDS only — TCP
    keeps 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 ancestor
    walk, os.lstat rejects symlink components, creates 0700, returns verified
    literal path) wired into start(); UDS-only peer-cred gate in _process_request.
  • stt_server/_peercred.py (new) — cross-platform peer_uid() resolver
    (SO_PEERCRED / getpeereid via ctypes), fail-closed.
  • stt_server/__main__.py_cmd_serve surfaces startup failures (dir
    enforcement, ServerConfig ValueError, bind OSError, missing backend
    extra) as stt_server: <msg> + exit 1 instead of a bare traceback. Handler
    catches (ValueError, OSError, ImportError) so a missing backend extra also
    surfaces cleanly.
  • stt_server/backends/{mlx_whisper,nemotron,parakeet}.py — a missing backend
    extra re-raises as an actionable … run: uv sync --extra <X> --inexact.
  • scripts/install_stt_agent.sh — adds validate_socket_path() that validates a
    custom PIPECAT_STT_SOCKET (absolute, under $HOME, no symlink component)
    before any mkdir/chmod, so a path the server would reject fails the
    install cleanly with no filesystem mutation; then creates the socket dir 0700
    and self-heals a pre-existing 0755.
  • justfile_ensure-extra makes just stt-install/stt-enable install the
    backend's optional extra (uv sync --extra <X> --inexact); opt out with
    PIPECAT_STT_SKIP_DEP_SYNC=1.
  • pyproject.toml / uv.lock — pin websockets>=16,<17; bump version to
    0.3.3.
  • docs/operations.md, docs/protocol.md — trust-model + socket-security notes;
    pre-handshake connection-rejection table.
  • Tests: peer-cred resolver unit tests, UDS gate tests (fail-closed paths,
    multi-connection), dir-enforcement tests, backend-missing-extra tests, and a
    local cross-uid verifier (scripts/verify_peercred_crossuid.py).

Verification

  • Full suite green: uv run python -m pytest -q → 339 passed, 2 skipped, 0
    failed. ruff check + ruff format --check clean. Includes regression tests
    for the literal-chain symlink rejection and the installer's pre-mutation
    socket-path validation (outside-$HOME + symlinked-parent).
  • Real-server checks (macOS): the agent binds + starts against the live
    ~/Library/Caches/pipecat-stt chain (dir enforcement passes); a same-uid
    status round-trip succeeds with no fail-closed warnings; socket inode is
    0600.
  • Cross-uid 403 verified on real hardware via
    scripts/verify_peercred_crossuid.py: against one permissive socket
    (0o666 / 0711 parent under /tmp), the owning uid (501) completed the
    handshake (101) while nobody (uid 4294967294) was rejected
    (403 peer not permitted); server logged
    stt_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)

  • May require action on existing hosts. Because the server now refuses to
    start against a group/other-writable socket-dir ancestor, an existing 0755
    socket directory — or a custom STT_WS_SOCKET / KODA_STT_SOCKET pointing
    outside $HOME — will block startup. Re-run scripts/install_stt_agent.sh
    (self-heals to 0700), or chmod 700 the dir / move the socket under $HOME.

Cross-repo (Koda)

No client pin bump is required — stt_server/client.py and the wire protocol are
untouched. Koda runs the server from a HEAD checkout, so the hardening lands on
the next checkout update (not at a release); fold the 0755 → 0700 socket-dir
upgrade into that window. The test_branch_diff_does_not_touch_koda_surface
guard 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.3 is prepared on this branch (version bump + CHANGELOG cut, folding in the
already-unreleased README-split docs entry). Tag v0.3.3 + GitHub release +
uvx twine upload happen at merge time. The missing v0.3.2 GitHub release was
also backfilled out-of-band.

Notes for reviewers

  • The Phase 4 / verifier dir-enforcement bypass is a test-only monkeypatch of
    _enforce_socket_dir_secure, unreachable from serve() or normal
    TranscriptionServer construction (no ServerConfig flag).
  • The codebase was built phase-by-phase via /skein:conduct, then hardened over
    three adversarial Codex review rounds:
    1. Cross-uid smoke could fail at the filesystem boundary before peer-cred —
      fixed in 0d9410e, verified above.
    2. No-ship: the initial fix resolved the socket path and bound the resolved
      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 symlink
      components, bind the literal verified path (regression test added).
    3. Medium: the installer chmod-ed a custom socket parent before the
      server's rules could reject it. Fixed in f9f41b6: validate_socket_path()
      refuses an unsupported path before any mkdir/chmod (regression tests
      added).

vr000m added 24 commits June 26, 2026 21:07
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.
@vr000m
vr000m merged commit 6ddbe6a into main Jun 28, 2026
4 checks passed
@vr000m
vr000m deleted the feature/uds-trust-boundary-hardening branch June 28, 2026 08:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant