First-class worker service lifecycle: generated systemd user unit (D-065, #138) #167

Merged
adrian.tkacz merged 6 commits from task/138-worker-service-lifecycle into develop 2026-10-03 08:58:19 +00:00
Member

Closes #138 (child F of the #132 program). Branch from origin/develop @ db4e1bde28b2398e94b2911eb57bd3b752538dc5; commits 751b814 + remediation e622f15 (review blockers 1–2) + Daybreak remediation 7523bbb (Daybreak blockers 1–4) + live-acceptance remediation 45c0d22 (synthetic sandbox ancestors) + review simplification bfff8a3 (home-anchored service contract, mountinfo mechanism removed). Decision record: D-065 (docs/decisions.md, amended 2026-10-02).

What this does

A paired Linux/WSL worker installs and operates as a normal systemd user service — no root, no hand-edited unit files, no secrets in the unit:

scarcity-router-worker service install --codex-source precision-codex-live
scarcity-router-worker service status | restart | uninstall
  • Generated, never configured. The unit comes from the ACTUAL installed executable (resolved absolute path — a user manager's PATH is minimal) and the ACTUAL state directory (--state-dir always pinned), preserving the run adapter selection (codex sources, loopback Ollama, ZCode sources, legacy Codex triple) via ONE shared flag definition. No --server exists on service install: it uses the stored origin of the already-paired identity, so service tooling cannot introduce (or weaken) a worker origin — the D-056 boundary in the #138 comment holds by construction. Every filesystem-valued argument (--state-dir, the worker executable, --codex-bin, --zcode-bin, --zcode-workspace) resolves EXACTLY ONCE at install to the canonical absolute path that was validated — a user manager can never re-resolve a relative value against its own different working-directory context (Daybreak blocker 1).
  • Idempotent + ownership by marker. Deterministic render (no timestamps): identical flags → byte-identical unit → no-op install. A different marker-carrying unit replaces the old one (config update; service restart applies it). A unit WITHOUT the marker is never overwritten (install) or removed (uninstall) — the old examples/ manual copy is refused with a migration recipe (the example unit is deleted; README documents the one canonical path). The write is a SECURE ATOMIC replacement: trust-checked unit directory, randomized exclusively created temp file, fd-verified, fsync'd, one same-directory rename (Daybreak blocker 3).
  • Single-instance lock. Lifetime OS advisory lock per state directory (<state_dir>/worker.lock; flock/msvcrt; released by the OS on every exit incl. SIGKILL): a foreground run and the service worker are mutually exclusive; the second process fails closed (exit 2) naming the holder and the stop command. The lock file is opened WITHOUT following symlinks/reparse points on both platforms (Daybreak blocker 4).
  • Narrowed service-mode filesystem contract (D-065, 2026-10-02). Security-sensitive mutable state for the systemd user service lives under the invoking user's canonical home: the worker state directory AND the user-unit directory must lie strictly beneath it. /, the home itself and every outside-home location are refused by install with the exact path and reason — never silently relocated. The writable grants the unit declares remain role-validated: no ReadWritePaths role may resolve to / or the invoking user's home; the ZCode state home must stay the dedicated ZCode state subtree; a workspace may live outside $HOME (it is not the worker's identity/security boundary) but never contains a protected boundary. Every refusal happens before anything is written (Daybreak blocker 2).
  • Clean SIGTERM. run wires SIGTERM/SIGINT to the runtime's existing deterministic stop path (stop event, transport closed, in-flight attempts cancelled + reported interrupted per D-043) instead of the default kill.
  • Linger deliberate and visible. Install queries/attempts/loginctl enable-linger and always reports the outcome; failure is a remediation-bearing warning, never an install failure. Uninstall keeps identity/state and never touches linger.
  • Sources UI promotion. The admin Sources page now prints service install --codex-source … as the normal deployment step and frames foreground run as debugging.
  • Hardening matches the example unit's proven pattern plus the real needs: Restart=on-failure/RestartSec=5s, TimeoutStopSec=30s, NoNewPrivileges/PrivateTmp/ProtectSystem=strict/ProtectHome=read-only/RestrictAddressFamilies, ReadWritePaths = state dir + configured ZCode authorized workspace (a ZCode source edits OUTSIDE the state dir) + the ZCode CLI state home for ZCode sources. No SSL_CERT_FILE hacks — CA trust is #139's persistence path.
  • Exit codes: 0 / 2 per worker CLI conventions; status/restart pass systemctl's own code through (3 = inactive); service never opens the worker store as a side effect.

Security posture

No secret enters the unit, the CLI output or an error (negative-scanned by tests, incl. a --code[ =] regex against the --codex-source prefix). Pairing identity and provider-controlled homes stay in the 0700 state directory. Only subprocesses: systemctl --user/loginctl with unit names and verbs. The interfaces guardrail's filesystem-write allowlist covers exactly the two bounded provisioning paths (the 0600 lock file in the worker's own state dir; the 0644 unit in the user's own ~/.config/systemd/user/, written through a randomized exclusive temp in that same directory), each documented in the guardrail.

Validation

  • Full gate green on the final head bfff8a3: uv sync --only-dev, make check (2634 tests OK, 15 platform-gated skips on Linux), uv run basedpyright (0 errors/0 warnings/0 notes), git diff --check.
  • Suites: tests/test_worker_service.py (rendering incl. substitution round-trip oracle + systemd-analyze verify, path normalization, writable-grant refusals, home-contract refusals, unit-writer security, no-secrets scan, install/restart/uninstall lifecycle), lock tests in test_worker_local_store.py (incl. POSIX hardening + Windows-only class), CLI wiring in test_worker_client.py.
  • Windows lock branch verified LIVE: the FILE_FLAG_OPEN_REPARSE_POINT open path was executed on real Windows Python 3.14.3 via WSL interop — normal acquire/release/contention with holder pid read, planted worker.lock file symlink refused with the target untouched, planted directory junction refused. 4/4 Windows tests OK live on this host.

Remediation round 1 (2026-10-01 — review blockers 1 & 2)

Commit e622f15 (blocked reviewed HEAD was 751b814); exactly the two merge blockers, nothing else:

  • Blocker 1 — systemd substitution-safe unit rendering. Every literal % is doubled to %% in ExecStart AND ReadWritePaths words: specifier expansion applies to both settings on the raw value BEFORE quoting is processed, and double quotes never suppress it (verified against the host's systemd 255: a literal %20 fails the unit to load, quoted or not; %%20 loads and expands back to %20). For $: variable substitution is documented Exec*-only (ReadWritePaths does none), so no $ escaping is shared into the quote helper; instead the ExecStart line carries systemd's documented : executable prefix, which suppresses $-variable substitution for the whole command line — a literal $ in any path stays literal. Rendered words round-trip to the ORIGINAL literal filesystem paths (parsing-oracle test + systemd-analyze verify on hostile units and an unescaped control proving the oracle discriminates).
  • Blocker 2 — ZCode writable runtime state. A ZCode-source unit adds exactly the resolved ZCode CLI state home ($HOME/.zcode — the ZCode adapter's own HOME contract, realpath-canonicalized, existing directory) to ReadWritePaths; ProtectHome=read-only is retained and the write grant stays worker state + that one directory + the authorized workspace — never a writable $HOME, never unrelated provider access, Codex-only units unchanged. If the directory does not exist, install refuses (exit 2) with the zcode login zai remediation instead of rendering an impossible path; the tooling never creates, reads or writes ZCode's own state (D-061 constraint 3).
  • Validation on e622f15: uv sync --only-dev, make check (2559 tests OK), uv run basedpyright (0 errors/0 warnings/0 notes), git diff --check; systemd-analyze verify clean on five representative units (codex-only, ZCode, %-path, spaces, literal $).
  • Live ZCode service probe: UNAVAILABLE — no invocable zcode binary exists on this implementation host (PATH and common install locations checked). What WAS verified live: the real ~/.zcode state tree exists on this host (its top-level structure matches the review's evidence), and ZCode-source rendered units carrying the exact real resolved path pass systemd-analyze verify. A headless-run probe is additionally not possible without paid inference (D-061).

Remediation round 2 (2026-10-01 — Daybreak security blockers 1–4)

Commit 7523bbb (blocked reviewed HEAD was e622f15); exactly the four Daybreak blockers, nothing else. D-065 points 2, 3, 4 and 6 record the corrected contract.

  • Blocker 1 — canonical persisted paths. normalize_selection_paths resolves every filesystem-valued argument once at install (realpath; relative input against the installer's cwd) and only the canonical absolute result is validated through build_registry and rendered: --zcode-workspace (must be an existing directory — . from the workspace resolves to the install-time directory, never $HOME), --codex-bin/--zcode-bin (resolved target must be an existing regular executable; a bare command name is refused, never PATH-resolved), --state-dir and the worker executable (already canonical). A workspace supplied as . stays the installation-time directory after reboot. Ordinary foreground run discovery semantics unchanged.
  • Blocker 2 — writable-grant rejection policy (small fixed roles in validate_writable_grants + verify_private_state_dir, not a sandbox subsystem): universal refusal of / and the invoking user's home for every ReadWritePaths role; ZCode state home must stay the dedicated ZCode state subtree (~/.zcode -> $HOME, ~/.zcode -> /, and any $HOME-ancestor broader than the subtree fail install — never a silent fallback); worker state dir must be a dedicated private tree (existing dir, owned by the effective user, not group/world-writable, not //$HOME — one policy shared by install and the run-time lock, ensure_private_tree reused for creation, existing dirs never chmod'd silently); workspace may live outside $HOME but never equals/contains the worker state dir (the credential store) or the ZCode state home, and a state home that is a broad parent of the workspace is refused. Identical grants deduplicate at render; no broader grant can subsume a narrower boundary.
  • Blocker 3 — secure atomic unit write. _verify_unit_directory trusts the target before writing (missing ancestors created 0o700 by the tooling; existing directory must be owned by the invoking user and closed to group/other writes — a shared/attacker-writable XDG_CONFIG_HOME is refused with the path named, never chmod'd silently). _atomic_write writes through a tempfile.mkstemp file created exclusively with a randomized name inside the verified directory (no predictable .tmp-<pid>, no shell, no external mv), fstat-verifies a regular file, sets 0o644 on the open fd, writes+fsyncs through the fd, replaces via one same-directory rename through dir fds (pathname re-resolution minimized), fsyncs the directory where supported, and removes an uncommitted temp on any failure. Marker-gated overwrite policy unchanged.
  • Blocker 4 — no-follow lock. POSIX: openat-relative to the verified private state directory with O_NOFOLLOW|O_CLOEXEC, typed refusal on ELOOP (planted symlink — target untouched), fstat regular-file verification (planted FIFO/device refused), 0600 enforced on the open fd, advisory lock acquired BEFORE any truncate/write, holder pid read back through the already-open descriptor (no second pathname resolution; the old path-based read could have followed a planted link), no unlink/recreate race on release. Byte 0 is the reserved lock byte; the pid line follows it so a Windows contender can read the pid despite the held msvcrt byte-range lock. Windows: CreateFileW with FILE_FLAG_OPEN_REPARSE_POINT resolves the name exactly once and never traverses a reparse point — a planted file symlink (handle to the link itself, FILE_ATTRIBUTE_REPARSE_POINT set) and a planted directory junction (typed access-denied refusal) are both refused; regular files proceed to the unchanged msvcrt.locking path. Executed live on Windows Python 3.14.3 via WSL interop: 4/4 (normal contention with holder pid, pid content, planted symlink refusal with target intact, planted junction refusal).
  • Validation on 7523bbb: uv sync --only-dev, make check (2594 tests OK), uv run basedpyright (0 errors/0 warnings/0 notes), git diff --check. New adversarial coverage: relative workspace ./relative pinned binaries/relative --state-dir resolve to install-time canonical paths; generated unit contains no relative filesystem arguments; workspace /, $HOME, home-ancestor, state-dir containment, ~/.zcode -> $HOME, ~/.zcode -> /, ~/.zcode -> <home-ancestor> refused; ordinary under-home, outside-home and dedicated state-dir layouts accepted; group/world-writable unit dir refused before daemon-reload with pre-existing modes never silently changed; planted predictable temp file/symlink cannot clobber; planted unit symlink replaced as a link with its target intact; planted worker.lock symlink/FIFO refused with target untouched on POSIX and Windows; normal contention, crash/release semantics unchanged.
  • Scope: Daybreak non-blocking hardening observations NOT implemented (O1–O8 untouched); CR/LF rendering rejection and exact-first-line marker hardening NOT implemented; #139/#140/#141/#165/#166 untouched; no general security framework, no new daemon/service architecture, no secrets added.

Remediation round 3 (2026-10-01 — Phase-A live-acceptance blocker)

Commit 45c0d22 (previous HEAD was 85b6e71); exactly the one live-acceptance blocker, nothing else. Superseded by round 4 (the owner decision removed this mechanism rather than extending it; retained for the record).

  • Root cause — systemd's synthetic read-only sandbox view. Reproduced on this host with real systemd-run --user (systemd 255): under ProtectSystem=strict + ProtectHome=read-only the sandbox's mounts live in a user namespace that does NOT map the host's root account, so host-root-owned ancestor directories (/, /home) legitimately stat as the overflow uid 65534 (nobody) on READ-ONLY mounts, while the invoking user's own subtree below reports the real mapped uid 1000. The trusted-ancestor walk (open_trusted_worker_directory) correctly required root/current-user ownership and therefore refused the synthetic / (untrusted worker path component /, exit 2, restart loop) — a correct host-filesystem rule misreading a namespace view.
  • Fix (now historical) — kernel-enforced read-only replaces ownership evidence, ancestors only. A NON-FINAL ancestor with an untrusted displayed owner was accepted only when its mode was closed to group/other writes AND the mount it resides on was read-only in the process's OWN /proc/self/mountinfo view (longest covering mount point wins; later stacked entries shadow; \040 escapes decoded; an unreadable table failed closed). Daybreak's follow-up review found the merge blocker that led to round 4: selecting the effective mount from mountinfo is non-trivial (stacked mounts require mount-ID/parent-ID reasoning, not path/order heuristics), and the implementation could choose a broader/lower read-only mount rather than the actually visible writable mount.
  • ProtectSystem=strict/ProtectHome=read-only remained in the unit byte-for-byte throughout.

Remediation round 4 (2026-10-02 — owner simplification: home-anchored contract, mountinfo removed)

Commit bfff8a3 (previous HEAD was 45c0d22). The owner decision: do not fix or extend the mountinfo algorithm — remove the dependency from the trust model by narrowing the supported systemd service filesystem contract. D-065 amended (points 1, 3, 4, 6); the intended result is LESS code and a simpler security model.

  • New service-mode contract. For service install, security-sensitive mutable state must live under the invoking user's canonical home: the worker state directory must be a STRICT DESCENDANT of canonical home (canonicalized both sides; /, the home itself, and every outside-home location — including a symlink that resolves outside home — are refused with a clear actionable error naming the path, BEFORE anything is validated, written or enabled; the tooling never silently relocates). $HOME/.local/share/scarcity-router/worker is the primary supported path; an explicit custom state directory beneath home remains supported when it satisfies the existing private-directory requirements. The same narrowing applies to the unit location: the canonical unit directory must lie beneath canonical home (normal ~/.config/systemd/user supported; XDG_CONFIG_HOME outside home refused for install). The ZCode state home naturally remains under home and stays narrowly writable exactly as already approved; a ZCode workspace may still legitimately live outside home (role-aware grant restrictions unchanged — the workspace is not the worker's identity/security boundary).
  • Home-anchored descriptor traversal (open_trusted_worker_directory, anchor chosen by topology): for a boundary strictly beneath canonical home, open the canonical home directory, require the opened home to be a real directory owned by the EFFECTIVE USER (not root, not another principal) and closed to group/other writes, retain that home fd, and traverse each descendant component relative to the retained fd with O_DIRECTORY|O_NOFOLLOW|O_CLOEXEC, preserving the existing ownership/private-mode checks below home and retaining the verified final directory fd. The walk NEVER opens or inspects / or /home, never uses mount ownership or mountinfo as security evidence, and never globally trusts UID 65534. This home-anchored traversal IS the systemd-compatible security boundary — and it works identically in foreground mode for under-home state directories.
  • Foreground outside-home behavior unchanged. run --state-dir <outside-home> keeps the conservative root-to-leaf ownership/permission validation with NO mountinfo/read-only-owner relaxation: an untrusted displayed owner remains untrusted there. Service and foreground contracts are explicit: no mode inference from environment details was needed — install-time policy enforcement (service) and topology-anchored walking (both modes) keep one simple verifier.
  • Removed, not dormant. Deleted: _read_mount_table, _unescape_mount_field, _is_mount_prefix, _mount_point_is_readonly, the _PROC_MOUNTINFO constant, the sandbox-ancestor exception inside the walk, the MountInfoReadonlyLookupTests parser suite and the SystemdSandboxNamespaceTests readonly matrix. A source-level test (MountinfoIsNotPartOfTheTrustModelTests) pins that the walk module contains no mount-table reference so no relaxation can quietly grow back. The trust module net-shrinks (−16 lines; the entire mount parser gone).
  • Preserved invariants (all previously approved protections unchanged): state/store boundary established before SQLite access; descriptor-relative state traversal; O_NOFOLLOW; private final state dir; unit-directory descriptor-bound publication; secure atomic writer; marker ownership protection; POSIX lock bound to retained directory identity; Windows reparse/junction protections; broad //$HOME writable-grant rejection; exact path normalization before rendering; no credentials in unit/argv; ProtectSystem=strict; ProtectHome=read-only.
  • Regression coverage: home-anchored boundary matrix (synthetic overflow-uid / and parent view accepted for the under-home boundary — proving those ancestors are outside the trust computation; untrusted owner BELOW home refused without mount reasoning; root-owned final directory below home refused; world-writable directory below home refused; foreign-owned home anchor refused; group-writable home anchor refused). New ServiceHomeContractTests: default state layout installs; explicit private custom state dir beneath home installs; state dir = home refused; state dir = / refused; outside-home state dir refused (paired, path named); symlinked state dir resolving outside home refused; XDG_CONFIG_HOME outside home refused with the unit directory not even provisioned; XDG_CONFIG_HOME beneath home accepted. Existing attack regressions pass unchanged: planted state database symlink, path replacement, lock leaf symlink, lock directory replacement, unsafe shared paths, unit directory replacement, broad writable grants.
  • Real systemd-run --user namespace regression (systemd 255, this host): PASSED — ProtectSystem=strict + ProtectHome=read-only + ReadWritePaths=<private under-home state dir>; the home-anchored walk accepts the legitimate boundary with NO mountinfo exception involved. (Under the make check runner the probe remains environment-gated/skipped, as before; executed directly it runs and passes.)
  • Live ROG acceptance (blocker proof only, per scope): with the paired identity w-e69ce40f9c7842e6 untouched and NO re-pairing, and NO reinstall (the installed unit is byte-identical to what the new renderer produces for the live flags — verified), systemctl --user start scarcity-router-worker: the service reaches active (running) and stays there (single Started journal entry, NRestarts=0, ExecMainStatus=0, one stable main PID), ZERO untrusted worker path component entries, and the worker process holds an ESTABLISHED outbound connection to the loopback DEV Router (127.0.0.1:8790) using the stored identity. The full Phase-A crash/restart/reboot sequence was NOT continued.
  • Validation on bfff8a3: uv sync --only-dev, make check (2634 tests OK, 15 platform-gated skips on Linux), uv run basedpyright (0 errors/0 warnings/0 notes), git diff --check.
  • Scope: no corrected mount-ID graph parser, no further mountinfo heuristics; O1–O8, CR/LF renderer work, marker first-line work, #139/#140/#141/#165/#166 untouched; no new general filesystem-security framework. Exactly 7 files touched: scarcity_router/worker_identity_store.py, scarcity_router/worker_service.py, tests/test_worker_local_store.py, tests/test_worker_service.py, tests/test_worker_client.py, docs/decisions.md, README.md.
  • UX impact: none on the happy path — the documented default layout (~/.local/share/scarcity-router/worker, ~/.config/systemd/user) behaves identically. Rejection paths gained a clearer, actionable boundary: a custom service state dir outside home is now refused at install with the exact path, the supported alternatives and the foreground-run alternative named (necessary security friction; the foreground capability itself is unchanged).

Review gates (per the frozen scope — open at the human decision point)

  • Security classification: security-sensitive → independent GLM-5.3-Flash review mandatory; Daybreak security review when available, else SECURITY_REVIEW_UNAVAILABLE with the gate open. UX assessment mandatory.
  • UX impact: Sources page text changes (install promoted, run framed as debugging); README worker section rewritten around the service workflow; run unchanged as debugging path. Remediation round 1: UX impact: none (unit rendering and install refusal-path only). Remediation round 2: UX impact: none on the happy path (relative inputs still accepted and now display their normalized absolute form); rejection paths gained actionable, path-naming errors (broad grants, shared unit directories, planted lock files) — necessary security friction, each with a remediation hint.

Scope audit

#139 (TLS/CA) not implemented; #140 admin UX not touched beyond the #138-required Sources hint; #141 not started; #165/#166 untouched; no machine-specific Precision configuration (source ids in docs are the frozen scope's documented examples); no credentials committed; no manual-only hidden dependency (the copied-example path is explicitly retired with a migration message).

Non-goals honored

No Windows service mechanics (M10 tray remains the Windows path); no root system units; no own daemonizing logic; no configuration migration.

Closes #138 (child F of the #132 program). Branch from `origin/develop` @ `db4e1bde28b2398e94b2911eb57bd3b752538dc5`; commits `751b814` + remediation `e622f15` (review blockers 1–2) + Daybreak remediation `7523bbb` (Daybreak blockers 1–4) + live-acceptance remediation `45c0d22` (synthetic sandbox ancestors) + review simplification `bfff8a3` (**home-anchored service contract, mountinfo mechanism removed**). Decision record: **D-065** (`docs/decisions.md`, amended 2026-10-02). ## What this does A paired Linux/WSL worker installs and operates as a normal systemd **user** service — no root, no hand-edited unit files, no secrets in the unit: ```bash scarcity-router-worker service install --codex-source precision-codex-live scarcity-router-worker service status | restart | uninstall ``` - **Generated, never configured.** The unit comes from the ACTUAL installed executable (resolved absolute path — a user manager's PATH is minimal) and the ACTUAL state directory (`--state-dir` always pinned), preserving the `run` adapter selection (codex sources, loopback Ollama, ZCode sources, legacy Codex triple) via ONE shared flag definition. No `--server` exists on `service install`: it uses the stored origin of the already-paired identity, so service tooling cannot introduce (or weaken) a worker origin — the D-056 boundary in the #138 comment holds by construction. Every filesystem-valued argument (`--state-dir`, the worker executable, `--codex-bin`, `--zcode-bin`, `--zcode-workspace`) resolves EXACTLY ONCE at install to the canonical absolute path that was validated — a user manager can never re-resolve a relative value against its own different working-directory context (Daybreak blocker 1). - **Idempotent + ownership by marker.** Deterministic render (no timestamps): identical flags → byte-identical unit → no-op install. A different marker-carrying unit replaces the old one (config update; `service restart` applies it). A unit WITHOUT the marker is never overwritten (install) or removed (uninstall) — the old `examples/` manual copy is refused with a migration recipe (the example unit is deleted; README documents the one canonical path). The write is a SECURE ATOMIC replacement: trust-checked unit directory, randomized exclusively created temp file, fd-verified, fsync'd, one same-directory rename (Daybreak blocker 3). - **Single-instance lock.** Lifetime OS advisory lock per state directory (`<state_dir>/worker.lock`; flock/msvcrt; released by the OS on every exit incl. SIGKILL): a foreground `run` and the service worker are mutually exclusive; the second process fails closed (exit 2) naming the holder and the stop command. The lock file is opened WITHOUT following symlinks/reparse points on both platforms (Daybreak blocker 4). - **Narrowed service-mode filesystem contract (D-065, 2026-10-02).** Security-sensitive mutable state for the systemd user service lives under the invoking user's canonical home: the worker state directory AND the user-unit directory must lie strictly beneath it. `/`, the home itself and every outside-home location are refused by install with the exact path and reason — never silently relocated. The writable grants the unit declares remain role-validated: no `ReadWritePaths` role may resolve to `/` or the invoking user's home; the ZCode state home must stay the dedicated ZCode state subtree; a workspace may live outside `$HOME` (it is not the worker's identity/security boundary) but never contains a protected boundary. Every refusal happens before anything is written (Daybreak blocker 2). - **Clean SIGTERM.** `run` wires SIGTERM/SIGINT to the runtime's existing deterministic stop path (stop event, transport closed, in-flight attempts cancelled + reported interrupted per D-043) instead of the default kill. - **Linger deliberate and visible.** Install queries/attempts/`loginctl enable-linger` and always reports the outcome; failure is a remediation-bearing warning, never an install failure. Uninstall keeps identity/state and never touches linger. - **Sources UI promotion.** The admin Sources page now prints `service install --codex-source …` as the normal deployment step and frames foreground `run` as debugging. - **Hardening** matches the example unit's proven pattern plus the real needs: `Restart=on-failure`/`RestartSec=5s`, `TimeoutStopSec=30s`, `NoNewPrivileges`/`PrivateTmp`/`ProtectSystem=strict`/`ProtectHome=read-only`/`RestrictAddressFamilies`, `ReadWritePaths` = state dir + configured ZCode authorized workspace (a ZCode source edits OUTSIDE the state dir) + the ZCode CLI state home for ZCode sources. No `SSL_CERT_FILE` hacks — CA trust is #139's persistence path. - **Exit codes:** 0 / 2 per worker CLI conventions; `status`/`restart` pass `systemctl`'s own code through (3 = inactive); `service` never opens the worker store as a side effect. ## Security posture No secret enters the unit, the CLI output or an error (negative-scanned by tests, incl. a `--code[ =]` regex against the `--codex-source` prefix). Pairing identity and provider-controlled homes stay in the 0700 state directory. Only subprocesses: `systemctl --user`/`loginctl` with unit names and verbs. The interfaces guardrail's filesystem-write allowlist covers exactly the two bounded provisioning paths (the 0600 lock file in the worker's own state dir; the 0644 unit in the user's own `~/.config/systemd/user/`, written through a randomized exclusive temp in that same directory), each documented in the guardrail. ## Validation - Full gate green on the final head `bfff8a3`: `uv sync --only-dev`, `make check` (2634 tests OK, 15 platform-gated skips on Linux), `uv run basedpyright` (0 errors/0 warnings/0 notes), `git diff --check`. - Suites: `tests/test_worker_service.py` (rendering incl. substitution round-trip oracle + `systemd-analyze verify`, path normalization, writable-grant refusals, home-contract refusals, unit-writer security, no-secrets scan, install/restart/uninstall lifecycle), lock tests in `test_worker_local_store.py` (incl. POSIX hardening + Windows-only class), CLI wiring in `test_worker_client.py`. - **Windows lock branch verified LIVE**: the `FILE_FLAG_OPEN_REPARSE_POINT` open path was executed on real Windows Python 3.14.3 via WSL interop — normal acquire/release/contention with holder pid read, planted `worker.lock` file symlink refused with the target untouched, planted directory junction refused. 4/4 Windows tests OK live on this host. ## Remediation round 1 (2026-10-01 — review blockers 1 & 2) Commit `e622f15` (blocked reviewed HEAD was `751b814`); exactly the two merge blockers, nothing else: - **Blocker 1 — systemd substitution-safe unit rendering.** Every literal `%` is doubled to `%%` in `ExecStart` AND `ReadWritePaths` words: specifier expansion applies to both settings on the raw value BEFORE quoting is processed, and double quotes never suppress it (verified against the host's systemd 255: a literal `%20` fails the unit to load, quoted or not; `%%20` loads and expands back to `%20`). For `$`: variable substitution is documented `Exec*`-only (`ReadWritePaths` does none), so no `$` escaping is shared into the quote helper; instead the `ExecStart` line carries systemd's documented `:` executable prefix, which suppresses `$`-variable substitution for the whole command line — a literal `$` in any path stays literal. Rendered words round-trip to the ORIGINAL literal filesystem paths (parsing-oracle test + `systemd-analyze verify` on hostile units and an unescaped control proving the oracle discriminates). - **Blocker 2 — ZCode writable runtime state.** A ZCode-source unit adds exactly the resolved ZCode CLI state home (`$HOME/.zcode` — the ZCode adapter's own `HOME` contract, `realpath`-canonicalized, existing directory) to `ReadWritePaths`; `ProtectHome=read-only` is retained and the write grant stays worker state + that one directory + the authorized workspace — never a writable `$HOME`, never unrelated provider access, Codex-only units unchanged. If the directory does not exist, install refuses (exit 2) with the `zcode login zai` remediation instead of rendering an impossible path; the tooling never creates, reads or writes ZCode's own state (D-061 constraint 3). - **Validation on `e622f15`:** `uv sync --only-dev`, `make check` (2559 tests OK), `uv run basedpyright` (0 errors/0 warnings/0 notes), `git diff --check`; `systemd-analyze verify` clean on five representative units (codex-only, ZCode, `%`-path, spaces, literal `$`). - **Live ZCode service probe: UNAVAILABLE** — no invocable `zcode` binary exists on this implementation host (PATH and common install locations checked). What WAS verified live: the real `~/.zcode` state tree exists on this host (its top-level structure matches the review's evidence), and ZCode-source rendered units carrying the exact real resolved path pass `systemd-analyze verify`. A headless-run probe is additionally not possible without paid inference (D-061). ## Remediation round 2 (2026-10-01 — Daybreak security blockers 1–4) Commit `7523bbb` (blocked reviewed HEAD was `e622f15`); exactly the four Daybreak blockers, nothing else. D-065 points 2, 3, 4 and 6 record the corrected contract. - **Blocker 1 — canonical persisted paths.** `normalize_selection_paths` resolves every filesystem-valued argument once at install (`realpath`; relative input against the installer's cwd) and only the canonical absolute result is validated through `build_registry` and rendered: `--zcode-workspace` (must be an existing directory — `.` from the workspace resolves to the install-time directory, never `$HOME`), `--codex-bin`/`--zcode-bin` (resolved target must be an existing regular executable; a bare command name is refused, never PATH-resolved), `--state-dir` and the worker executable (already canonical). A workspace supplied as `.` stays the installation-time directory after reboot. Ordinary foreground `run` discovery semantics unchanged. - **Blocker 2 — writable-grant rejection policy** (small fixed roles in `validate_writable_grants` + `verify_private_state_dir`, not a sandbox subsystem): universal refusal of `/` and the invoking user's home for every `ReadWritePaths` role; ZCode state home must stay the dedicated ZCode state subtree (`~/.zcode -> $HOME`, `~/.zcode -> /`, and any `$HOME`-ancestor broader than the subtree fail install — never a silent fallback); worker state dir must be a dedicated private tree (existing dir, owned by the effective user, not group/world-writable, not `/`/`$HOME` — one policy shared by install and the run-time lock, `ensure_private_tree` reused for creation, existing dirs never chmod'd silently); workspace may live outside `$HOME` but never equals/contains the worker state dir (the credential store) or the ZCode state home, and a state home that is a broad parent of the workspace is refused. Identical grants deduplicate at render; no broader grant can subsume a narrower boundary. - **Blocker 3 — secure atomic unit write.** `_verify_unit_directory` trusts the target before writing (missing ancestors created 0o700 by the tooling; existing directory must be owned by the invoking user and closed to group/other writes — a shared/attacker-writable `XDG_CONFIG_HOME` is refused with the path named, never chmod'd silently). `_atomic_write` writes through a `tempfile.mkstemp` file created exclusively with a randomized name inside the verified directory (no predictable `.tmp-<pid>`, no shell, no external `mv`), `fstat`-verifies a regular file, sets 0o644 on the open fd, writes+fsyncs through the fd, replaces via one same-directory `rename` through dir fds (pathname re-resolution minimized), fsyncs the directory where supported, and removes an uncommitted temp on any failure. Marker-gated overwrite policy unchanged. - **Blocker 4 — no-follow lock.** POSIX: `openat`-relative to the verified private state directory with `O_NOFOLLOW|O_CLOEXEC`, typed refusal on `ELOOP` (planted symlink — target untouched), `fstat` regular-file verification (planted FIFO/device refused), 0600 enforced on the open fd, advisory lock acquired BEFORE any truncate/write, holder pid read back through the already-open descriptor (no second pathname resolution; the old path-based read could have followed a planted link), no unlink/recreate race on release. Byte 0 is the reserved lock byte; the pid line follows it so a Windows contender can read the pid despite the held `msvcrt` byte-range lock. Windows: `CreateFileW` with `FILE_FLAG_OPEN_REPARSE_POINT` resolves the name exactly once and never traverses a reparse point — a planted file symlink (handle to the link itself, `FILE_ATTRIBUTE_REPARSE_POINT` set) and a planted directory junction (typed access-denied refusal) are both refused; regular files proceed to the unchanged `msvcrt.locking` path. **Executed live on Windows Python 3.14.3 via WSL interop: 4/4** (normal contention with holder pid, pid content, planted symlink refusal with target intact, planted junction refusal). - **Validation on `7523bbb`:** `uv sync --only-dev`, `make check` (2594 tests OK), `uv run basedpyright` (0 errors/0 warnings/0 notes), `git diff --check`. New adversarial coverage: relative workspace `.`/relative pinned binaries/relative `--state-dir` resolve to install-time canonical paths; generated unit contains no relative filesystem arguments; workspace `/`, `$HOME`, home-ancestor, state-dir containment, `~/.zcode -> $HOME`, `~/.zcode -> /`, `~/.zcode -> <home-ancestor>` refused; ordinary under-home, outside-home and dedicated state-dir layouts accepted; group/world-writable unit dir refused before `daemon-reload` with pre-existing modes never silently changed; planted predictable temp file/symlink cannot clobber; planted unit symlink replaced as a link with its target intact; planted `worker.lock` symlink/FIFO refused with target untouched on POSIX and Windows; normal contention, crash/release semantics unchanged. - **Scope:** Daybreak non-blocking hardening observations NOT implemented (O1–O8 untouched); CR/LF rendering rejection and exact-first-line marker hardening NOT implemented; #139/#140/#141/#165/#166 untouched; no general security framework, no new daemon/service architecture, no secrets added. ## Remediation round 3 (2026-10-01 — Phase-A live-acceptance blocker) Commit `45c0d22` (previous HEAD was `85b6e71`); exactly the one live-acceptance blocker, nothing else. **Superseded by round 4** (the owner decision removed this mechanism rather than extending it; retained for the record). - **Root cause — systemd's synthetic read-only sandbox view.** Reproduced on this host with real `systemd-run --user` (systemd 255): under `ProtectSystem=strict` + `ProtectHome=read-only` the sandbox's mounts live in a user namespace that does NOT map the host's root account, so host-root-owned ancestor directories (`/`, `/home`) legitimately `stat` as the overflow uid 65534 (nobody) on READ-ONLY mounts, while the invoking user's own subtree below reports the real mapped uid 1000. The trusted-ancestor walk (`open_trusted_worker_directory`) correctly required root/current-user ownership and therefore refused the synthetic `/` (`untrusted worker path component /`, exit 2, restart loop) — a correct host-filesystem rule misreading a namespace view. - **Fix (now historical) — kernel-enforced read-only replaces ownership evidence, ancestors only.** A NON-FINAL ancestor with an untrusted displayed owner was accepted only when its mode was closed to group/other writes AND the mount it resides on was read-only in the process's OWN `/proc/self/mountinfo` view (longest covering mount point wins; later stacked entries shadow; `\040` escapes decoded; an unreadable table failed closed). Daybreak's follow-up review found the merge blocker that led to round 4: selecting the effective mount from mountinfo is non-trivial (stacked mounts require mount-ID/parent-ID reasoning, not path/order heuristics), and the implementation could choose a broader/lower read-only mount rather than the actually visible writable mount. - `ProtectSystem=strict`/`ProtectHome=read-only` remained in the unit byte-for-byte throughout. ## Remediation round 4 (2026-10-02 — owner simplification: home-anchored contract, mountinfo removed) Commit `bfff8a3` (previous HEAD was `45c0d22`). The owner decision: **do not fix or extend the mountinfo algorithm — remove the dependency from the trust model** by narrowing the supported systemd service filesystem contract. D-065 amended (points 1, 3, 4, 6); the intended result is LESS code and a simpler security model. - **New service-mode contract.** For `service install`, security-sensitive mutable state must live under the invoking user's canonical home: the worker state directory must be a STRICT DESCENDANT of canonical home (canonicalized both sides; `/`, the home itself, and every outside-home location — including a symlink that resolves outside home — are refused with a clear actionable error naming the path, BEFORE anything is validated, written or enabled; the tooling never silently relocates). `$HOME/.local/share/scarcity-router/worker` is the primary supported path; an explicit custom state directory beneath home remains supported when it satisfies the existing private-directory requirements. The same narrowing applies to the unit location: the canonical unit directory must lie beneath canonical home (normal `~/.config/systemd/user` supported; `XDG_CONFIG_HOME` outside home refused for install). The ZCode state home naturally remains under home and stays narrowly writable exactly as already approved; a ZCode workspace may still legitimately live outside home (role-aware grant restrictions unchanged — the workspace is not the worker's identity/security boundary). - **Home-anchored descriptor traversal** (`open_trusted_worker_directory`, anchor chosen by topology): for a boundary strictly beneath canonical home, open the canonical home directory, require the opened home to be a real directory owned by the EFFECTIVE USER (not root, not another principal) and closed to group/other writes, retain that home fd, and traverse each descendant component relative to the retained fd with `O_DIRECTORY|O_NOFOLLOW|O_CLOEXEC`, preserving the existing ownership/private-mode checks below home and retaining the verified final directory fd. The walk NEVER opens or inspects `/` or `/home`, never uses mount ownership or mountinfo as security evidence, and never globally trusts UID 65534. This home-anchored traversal IS the systemd-compatible security boundary — and it works identically in foreground mode for under-home state directories. - **Foreground outside-home behavior unchanged.** `run --state-dir <outside-home>` keeps the conservative root-to-leaf ownership/permission validation with NO mountinfo/read-only-owner relaxation: an untrusted displayed owner remains untrusted there. Service and foreground contracts are explicit: no mode inference from environment details was needed — install-time policy enforcement (service) and topology-anchored walking (both modes) keep one simple verifier. - **Removed, not dormant.** Deleted: `_read_mount_table`, `_unescape_mount_field`, `_is_mount_prefix`, `_mount_point_is_readonly`, the `_PROC_MOUNTINFO` constant, the sandbox-ancestor exception inside the walk, the `MountInfoReadonlyLookupTests` parser suite and the `SystemdSandboxNamespaceTests` readonly matrix. A source-level test (`MountinfoIsNotPartOfTheTrustModelTests`) pins that the walk module contains no mount-table reference so no relaxation can quietly grow back. The trust module net-shrinks (−16 lines; the entire mount parser gone). - **Preserved invariants (all previously approved protections unchanged):** state/store boundary established before SQLite access; descriptor-relative state traversal; `O_NOFOLLOW`; private final state dir; unit-directory descriptor-bound publication; secure atomic writer; marker ownership protection; POSIX lock bound to retained directory identity; Windows reparse/junction protections; broad `/`/`$HOME` writable-grant rejection; exact path normalization before rendering; no credentials in unit/argv; `ProtectSystem=strict`; `ProtectHome=read-only`. - **Regression coverage:** home-anchored boundary matrix (synthetic overflow-uid `/` and parent view accepted for the under-home boundary — proving those ancestors are outside the trust computation; untrusted owner BELOW home refused without mount reasoning; root-owned final directory below home refused; world-writable directory below home refused; foreign-owned home anchor refused; group-writable home anchor refused). New `ServiceHomeContractTests`: default state layout installs; explicit private custom state dir beneath home installs; state dir = home refused; state dir = `/` refused; outside-home state dir refused (paired, path named); symlinked state dir resolving outside home refused; `XDG_CONFIG_HOME` outside home refused with the unit directory not even provisioned; `XDG_CONFIG_HOME` beneath home accepted. Existing attack regressions pass unchanged: planted state database symlink, path replacement, lock leaf symlink, lock directory replacement, unsafe shared paths, unit directory replacement, broad writable grants. - **Real `systemd-run --user` namespace regression (systemd 255, this host): PASSED** — `ProtectSystem=strict` + `ProtectHome=read-only` + `ReadWritePaths=<private under-home state dir>`; the home-anchored walk accepts the legitimate boundary with NO mountinfo exception involved. (Under the `make check` runner the probe remains environment-gated/skipped, as before; executed directly it runs and passes.) - **Live ROG acceptance (blocker proof only, per scope):** with the paired identity `w-e69ce40f9c7842e6` untouched and NO re-pairing, and NO reinstall (the installed unit is byte-identical to what the new renderer produces for the live flags — verified), `systemctl --user start scarcity-router-worker`: the service reaches `active (running)` and stays there (single `Started` journal entry, `NRestarts=0`, `ExecMainStatus=0`, one stable main PID), ZERO `untrusted worker path component` entries, and the worker process holds an ESTABLISHED outbound connection to the loopback DEV Router (127.0.0.1:8790) using the stored identity. The full Phase-A crash/restart/reboot sequence was NOT continued. - **Validation on `bfff8a3`:** `uv sync --only-dev`, `make check` (2634 tests OK, 15 platform-gated skips on Linux), `uv run basedpyright` (0 errors/0 warnings/0 notes), `git diff --check`. - **Scope:** no corrected mount-ID graph parser, no further mountinfo heuristics; O1–O8, CR/LF renderer work, marker first-line work, #139/#140/#141/#165/#166 untouched; no new general filesystem-security framework. Exactly 7 files touched: `scarcity_router/worker_identity_store.py`, `scarcity_router/worker_service.py`, `tests/test_worker_local_store.py`, `tests/test_worker_service.py`, `tests/test_worker_client.py`, `docs/decisions.md`, `README.md`. - `UX impact: none` on the happy path — the documented default layout (`~/.local/share/scarcity-router/worker`, `~/.config/systemd/user`) behaves identically. Rejection paths gained a clearer, actionable boundary: a custom service state dir outside home is now refused at install with the exact path, the supported alternatives and the foreground-`run` alternative named (necessary security friction; the foreground capability itself is unchanged). ## Review gates (per the frozen scope — open at the human decision point) - Security classification: security-sensitive → **independent `GLM-5.3-Flash` review mandatory**; **Daybreak security review when available, else `SECURITY_REVIEW_UNAVAILABLE` with the gate open**. UX assessment mandatory. - `UX impact:` Sources page text changes (install promoted, run framed as debugging); README worker section rewritten around the service workflow; `run` unchanged as debugging path. Remediation round 1: `UX impact: none` (unit rendering and install refusal-path only). Remediation round 2: `UX impact: none` on the happy path (relative inputs still accepted and now display their normalized absolute form); rejection paths gained actionable, path-naming errors (broad grants, shared unit directories, planted lock files) — necessary security friction, each with a remediation hint. ## Scope audit #139 (TLS/CA) not implemented; #140 admin UX not touched beyond the #138-required Sources hint; #141 not started; #165/#166 untouched; no machine-specific Precision configuration (source ids in docs are the frozen scope's documented examples); no credentials committed; no manual-only hidden dependency (the copied-example path is explicitly retired with a migration message). ## Non-goals honored No Windows service mechanics (M10 tray remains the Windows path); no root system units; no own daemonizing logic; no configuration migration.
First-class worker service lifecycle: generated systemd user unit (D-065, #138)
All checks were successful
ci / check (pull_request) Successful in 9m25s
751b81408d
Replace the copied-example unit workflow with product functionality:

- worker_service.py: 'service install/status/restart/uninstall' — the
  unit is generated from the ACTUAL installed executable and the ACTUAL
  worker state directory, preserves the run adapter selection (codex
  sources, loopback Ollama, ZCode sources), embeds no secret, is
  deterministic (idempotent re-install), and is marker-gated: a unit
  this tooling did not generate is never overwritten or removed.
  daemon-reload + enable --now on install; linger is deliberate and
  visible, never fatal; uninstall keeps the paired identity and state.
- worker_local_store.py: lifetime single-instance lock per worker state
  directory (OS advisory lock, released by the OS on every exit) — a
  foreground worker and the service worker fail closed with a useful
  message instead of racing one state directory.
- worker_client.py: 'service' subcommand group sharing the run
  selection-flag definitions; run acquires the lock and wires
  SIGTERM/SIGINT to the runtime's deterministic stop path.
- Sources UI (control_api/server_ui): the service install command is
  the normal deployment; foreground run is framed as debugging.
- examples/scarcity-router-worker.service removed: the refusal message
  and README document the migration from the old manual copy.
- README: one canonical Linux service path (prerequisites, install,
  status/logs, restart after config changes, disable/uninstall,
  recovery); docs/decisions.md D-065; m10-acceptance supersede note.
- tests: test_worker_service.py (unit generation incl. no-secrets
  negative scan, fake-systemctl install/restart/uninstall safety,
  overwrite refusal, idempotency, linger outcomes), lock suites next to
  the store tests, CLI wiring tests (dispatch without a store, run-path
  lock refusal, signal wiring); guardrail write allowlist extended for
  the two new bounded provisioning paths (lock file, generated unit).
Review blockers: specifier-safe unit rendering + exact ZCode state home write grant (#138)
All checks were successful
ci / check (pull_request) Successful in 9m31s
e622f1538f
Blocker 1 - systemd escaping: every literal '%' is doubled to '%%' in
ExecStart and ReadWritePaths words. Specifier expansion applies to both
settings on the raw value BEFORE quoting is processed (verified against
systemd 255: a literal '%20' fails the unit to load quoted or not;
'%%20' loads and expands back to '%20'). For the '$' semantics the
ExecStart line now carries the documented ':' executable prefix, which
suppresses variable substitution for the whole command line: a literal
'$' in any path stays literal. Variable substitution is Exec*-only
(ReadWritePaths does none), so no '$' escaping is shared into
systemd_quote.

Blocker 2 - ZCode writable runtime state: a ZCode-source unit adds
exactly the resolved ZCode CLI state home ($HOME/.zcode - the adapter's
own HOME contract, realpath-canonicalized) to ReadWritePaths;
ProtectHome=read-only is retained, nothing wider is granted, and the
directory must already exist or install refuses with the
'zcode login zai' remediation - never an impossible ReadWritePaths
entry, never a tooling-created ZCode state directory (D-061 constraint
3).

Rendered words round-trip to the original literal paths (parsing-oracle
tests plus systemd-analyze verify on hostile units and the unescaped
control); D-065 points 2 and 4 updated accordingly.
Blocker 1 - path identity: every filesystem-valued argument that enters
the unit (--state-dir, the worker executable, --codex-bin, --zcode-bin,
--zcode-workspace) resolves exactly once at install time to the canonical
absolute path that was validated (realpath; relative input resolves
against the installer's cwd; a pinned bare command name is refused rather
than PATH-resolved), so a user manager can never re-resolve a relative
value against its own different working-directory context after reboot.

Blocker 2 - writable-grant policy (small, fixed roles, not a sandbox
subsystem): no ReadWritePaths role may resolve to / or the invoking
user's home; the ZCode state home must stay the dedicated ZCode state
subtree (~/.zcode -> $HOME or / fails install, as does any ancestor of
$HOME broader than that subtree); the worker state directory must be a
dedicated private tree (existing, owner-owned, not group/world-writable,
not / or $HOME - shared with the lock via verify_private_state_dir); a
workspace may live outside $HOME but never contains or equals the
worker state directory or the ZCode state home, and a state home that is
a broad parent of the workspace is refused. Every refusal names the
offending canonical path and reason and happens before daemon-reload /
enable --now.

Blocker 3 - secure atomic unit write: the unit directory is
trust-checked first (created 0o700 by this tooling when missing; an
existing one must be owned by the invoking user and closed to
group/other writes - a shared or attacker-writable XDG_CONFIG_HOME is
refused, never chmod'd silently); the content goes through a randomized
exclusively created tempfile.mkstemp file inside that directory (never a
predictable .tmp-<pid> path), the descriptor is fstat-verified regular,
0o644 is set on the open fd, bytes are written and fsync'd through the
fd, replacement is one same-directory rename via dir fds followed by a
best-effort directory fsync, and an uncommitted temp is removed on any
failure. A planted predictable temp path can neither redirect the write
nor clobber an unrelated target; a planted unit symlink is replaced as a
link, its target untouched.

Blocker 4 - no-follow lock: POSIX opens the lock openat-relative to the
verified private state directory with O_NOFOLLOW, fstat-verifies a
regular file and enforces 0600 on the open fd - a planted worker.lock
symlink or FIFO is refused typed and its target never touched; the
holder pid is read back through the already-open descriptor (no second
pathname resolution, no symlink-followed pid read). Byte 0 is reserved
for the advisory lock and the pid line follows it, so a Windows
contender can read the pid despite the held msvcrt byte-range lock.
Windows opens with FILE_FLAG_OPEN_REPARSE_POINT (name resolved exactly
once, never traversing a reparse point): a planted symlink or junction
is detected via FILE_ATTRIBUTE_REPARSE_POINT and refused - no
check-then-open window; msvcrt.locking semantics unchanged.

Verified: the Windows lock branch was executed live on real Windows
Python 3.14.3 via WSL interop (normal acquire/release/contention with
holder pid, planted file symlink refused with target intact, planted
junction refused); POSIX suites, install lifecycle, unit rendering and
all new adversarial tests green. D-065 points 2, 3, 4 and 6 record the
corrected contract.
Bind validated worker paths to retained object identity (#138)
All checks were successful
ci / check (pull_request) Successful in 9m32s
85b6e71417
Author
Member

Final four-blocker path-identity remediation published as 85b6e714177dc01885415116b7aa9678c2b4d4b4 on the existing branch. Reviewed old HEAD: 7523bbb07393b1f5020e64ad08fa2cfc32f773ff. origin/develop/PR base remains db4e1bde28b2398e94b2911eb57bd3b752538dc5. Remote HEAD verified by git ls-remote and Forgejo PR API; PR remains open, targeting develop, unmerged. Worktree clean; no merge commits in origin/develop..HEAD.

Mechanism

  • Rendering is serialization only: all installation-normalized paths are emitted verbatim, including state/executable, pinned binaries, workspace and ZCode home. No renderer resolve/realpath/absolute/PATH lookup.
  • State boundary is established before opening any store/SQLite. POSIX traversal opens each component relative to its checked parent with O_DIRECTORY/O_NOFOLLOW/O_CLOEXEC and validates fstat ownership/modes. Default missing state is provisioned 0700 through trusted parents; missing custom state refuses; existing unsafe paths are never repaired. SQLite remains filename-based, justified by complete cross-principal replacement exclusion, not by a claimed fd-relative SQLite open. Nonregular planted database entries refuse before the opener.
  • Ancestors must belong to root/current user; nonsticky group/other-writable parents refuse. Sticky parents are accepted only with a root/current-user-owned next component, protected by sticky rename/unlink semantics. Final state/unit directories belong to current user and disallow group/other writes.
  • Marker inspection, exclusive random temporary creation, fchmod/write/fsync, rename (both dir fds), cleanup and directory fsync use one retained unit-directory identity. Visible identity is compared before systemctl. Alternate symlinked unit-directory chains refuse because systemd's implicit pathname must use the same trusted chain.
  • POSIX lock uses the fd returned by trusted state traversal directly, retains it for lock lifetime, opens worker.lock relative with O_NOFOLLOW/O_CLOEXEC, verifies regular, locks before truncation/PID write, and never unlinks on release.
  • Windows uses NtCreateFile RootDirectory single-component opens and FILE_OPEN_REPARSE_POINT, inspecting actual returned handles, retaining the entire chain without write-data/delete sharing. Lock read/write sharing preserves contention. Real attribute-only in-place junction conversion after inspection is NOT claimed prevented by sharing: the next no-follow handle-relative lookup fails closed before any lock creation/truncation.

Discriminating evidence

Against immutable old HEAD, then remediated tree: renderer workspace preservation false -> true; unsafe DB opener calls 1 -> 0 and victim mode 0600 -> unchanged 0644 (bytes unchanged); unit replacement writes unchecked B/systemctl called -> B untouched, publication retained in A, refusal before systemctl; POSIX lock in unchecked B -> lock only in validated A. Native Windows state/ancestor junction probes: old HEAD acquires and changes victim -> new HEAD refuses with victim unchanged.

Validation

  • uv sync --only-dev: PASS.
  • Focused service/local-store/client/identity-store: 177 tests OK, 15 Windows-only skips on Linux.
  • Native Windows Python 3.14.3 AMD64 (C:\Python314\python.exe, WSL interop): 15 lock adversarial tests + 17 tray tests, 32/32 PASS, zero skips. Includes final symlink/junction, state/ancestor junctions, successful in-place FSCTL attack followed by safe refusal, first-run provisioning, contention/PID, cleanup and process-death release. Independent narrow verification separately passed 15/15 native lock tests.
  • Full make check: 2618 tests OK (15 Windows-only skips covered natively), basedpyright 0 errors/0 warnings/0 notes.
  • Explicit uv run basedpyright and git diff --check: PASS.
  • Independent narrow final verification of frozen remediation diff: no MERGE_BLOCKER found. This is not a substitute for Daybreak or a PR approval; the human/security review gate remains open.

UX impact: intentional security narrowing only. Custom state must already exist/private/paired; symlinked unit-directory chains require private canonical XDG_CONFIG_HOME; Windows reparse chains refuse. Default first-run remains automatic, and errors/README explain recovery.

Files: README.md, docs/decisions.md, worker_identity_store.py, worker_local_store.py, worker_service.py, test_worker_local_store.py, test_worker_service.py. No generic filesystem-security framework. CR/LF renderer hardening, marker first-line refinement, O1–O8, #139/#140/#141/#165/#166 and all unrelated observations remain out of scope. No merge performed.

Final four-blocker path-identity remediation published as `85b6e714177dc01885415116b7aa9678c2b4d4b4` on the existing branch. Reviewed old HEAD: `7523bbb07393b1f5020e64ad08fa2cfc32f773ff`. `origin/develop`/PR base remains `db4e1bde28b2398e94b2911eb57bd3b752538dc5`. Remote HEAD verified by `git ls-remote` and Forgejo PR API; PR remains open, targeting develop, unmerged. Worktree clean; no merge commits in `origin/develop..HEAD`. ## Mechanism - Rendering is serialization only: all installation-normalized paths are emitted verbatim, including state/executable, pinned binaries, workspace and ZCode home. No renderer resolve/realpath/absolute/PATH lookup. - State boundary is established before opening any store/SQLite. POSIX traversal opens each component relative to its checked parent with O_DIRECTORY/O_NOFOLLOW/O_CLOEXEC and validates fstat ownership/modes. Default missing state is provisioned 0700 through trusted parents; missing custom state refuses; existing unsafe paths are never repaired. SQLite remains filename-based, justified by complete cross-principal replacement exclusion, not by a claimed fd-relative SQLite open. Nonregular planted database entries refuse before the opener. - Ancestors must belong to root/current user; nonsticky group/other-writable parents refuse. Sticky parents are accepted only with a root/current-user-owned next component, protected by sticky rename/unlink semantics. Final state/unit directories belong to current user and disallow group/other writes. - Marker inspection, exclusive random temporary creation, fchmod/write/fsync, rename (both dir fds), cleanup and directory fsync use one retained unit-directory identity. Visible identity is compared before systemctl. Alternate symlinked unit-directory chains refuse because systemd's implicit pathname must use the same trusted chain. - POSIX lock uses the fd returned by trusted state traversal directly, retains it for lock lifetime, opens worker.lock relative with O_NOFOLLOW/O_CLOEXEC, verifies regular, locks before truncation/PID write, and never unlinks on release. - Windows uses NtCreateFile RootDirectory single-component opens and FILE_OPEN_REPARSE_POINT, inspecting actual returned handles, retaining the entire chain without write-data/delete sharing. Lock read/write sharing preserves contention. Real attribute-only in-place junction conversion after inspection is NOT claimed prevented by sharing: the next no-follow handle-relative lookup fails closed before any lock creation/truncation. ## Discriminating evidence Against immutable old HEAD, then remediated tree: renderer workspace preservation false -> true; unsafe DB opener calls 1 -> 0 and victim mode 0600 -> unchanged 0644 (bytes unchanged); unit replacement writes unchecked B/systemctl called -> B untouched, publication retained in A, refusal before systemctl; POSIX lock in unchecked B -> lock only in validated A. Native Windows state/ancestor junction probes: old HEAD acquires and changes victim -> new HEAD refuses with victim unchanged. ## Validation - `uv sync --only-dev`: PASS. - Focused service/local-store/client/identity-store: 177 tests OK, 15 Windows-only skips on Linux. - Native Windows Python 3.14.3 AMD64 (`C:\Python314\python.exe`, WSL interop): 15 lock adversarial tests + 17 tray tests, 32/32 PASS, zero skips. Includes final symlink/junction, state/ancestor junctions, successful in-place FSCTL attack followed by safe refusal, first-run provisioning, contention/PID, cleanup and process-death release. Independent narrow verification separately passed 15/15 native lock tests. - Full `make check`: 2618 tests OK (15 Windows-only skips covered natively), basedpyright 0 errors/0 warnings/0 notes. - Explicit `uv run basedpyright` and `git diff --check`: PASS. - Independent narrow final verification of frozen remediation diff: no MERGE_BLOCKER found. This is not a substitute for Daybreak or a PR approval; the human/security review gate remains open. UX impact: intentional security narrowing only. Custom state must already exist/private/paired; symlinked unit-directory chains require private canonical XDG_CONFIG_HOME; Windows reparse chains refuse. Default first-run remains automatic, and errors/README explain recovery. Files: README.md, docs/decisions.md, worker_identity_store.py, worker_local_store.py, worker_service.py, test_worker_local_store.py, test_worker_service.py. No generic filesystem-security framework. CR/LF renderer hardening, marker first-line refinement, O1–O8, #139/#140/#141/#165/#166 and all unrelated observations remain out of scope. No merge performed.
Accept systemd's synthetic read-only sandbox ancestors in the trusted-path walk (#138)
All checks were successful
ci / check (pull_request) Successful in 9m38s
45c0d2229c
Phase-A live acceptance failed inside the generated unit: under
ProtectSystem=strict + ProtectHome=read-only, systemd establishes its
read-only mounts in a mount namespace whose user mapping excludes host
root, so / and /home legitimately report the overflow uid 65534 and the
trusted-ancestor walk refused them ("untrusted worker path component /",
exit 2, restart loop).

The walk now accepts a NON-FINAL ancestor with an untrusted displayed
owner only when its mode is closed to group/other writes AND the mount
it resides on is read-only in the process's own /proc/self/mountinfo
view: the kernel then denies every principal the rename/unlink the
ownership check exists for. UID 65534 is not globally trusted — an
untrusted owner on a writable mount is still refused, the boundary
itself must still be owned by the invoking user, and all fd-relative
O_NOFOLLOW identity checks are unchanged. D-065 records the
namespace-aware clause.

Regression coverage: deterministic sandbox-view matrix (synthetic
ancestors accepted; writable-mount nobody refused; untrusted final dir
refused even on a read-only mount; world-writable ancestor refused even
in the sandbox view), mountinfo longest-prefix/stacked-mount/escaping
parser tests, and a real systemd-run --user acceptance probe (skipped
where no user manager exists) that was itself run live on ROG: the
sandbox accepts the private under-home boundary, and the service now
starts, stays active and connects to the loopback DEV router with the
stored identity.
Anchor the worker trust walk at the user's home; drop mountinfo (D-065, #138)
Some checks failed
ci / check (pull_request) Failing after 9m4s
bfff8a392f
The Daybreak review found a merge blocker in the 45c0d22 remediation:
selecting the effective mount from /proc/self/mountinfo (longest
covering point, stacked-mount shadowing, escaping) requires
mount-ID/parent-ID reasoning, and the walk could pick a broader/lower
read-only mount instead of the actually visible writable one. The owner
decision: do NOT fix or extend the mountinfo algorithm — remove the
dependency from the trust model by narrowing the systemd service
filesystem contract.

New service-mode contract (D-065, amended 2026-10-02): the worker state
directory AND the systemd user-unit directory must lie strictly beneath
the invoking user's canonical home. '/' , the home itself and every
outside-home location (including a symlink that resolves outside) are
refused by `service install` before anything is validated, written or
enabled; the tooling never relocates them. $HOME/.local/share/
scarcity-router/worker and ~/.config/systemd/user are the supported
locations; XDG_CONFIG_HOME outside home is refused for install.

Home-anchored descriptor traversal: open the canonical home, require
the opened home to be a real directory owned by the effective user and
closed to group/other writes, retain that fd, and traverse every
descendant component relative to it with O_DIRECTORY|O_NOFOLLOW|
O_CLOEXEC, preserving the ownership/private-mode checks below home.
Under ProtectSystem=strict + ProtectHome=read-only the synthetic
overflow-uid ancestors (/ and /home) are simply outside the trust
computation — never opened, never inspected, no mount ownership as
security evidence, no global trust for UID 65534.

Foreground `run` keeps custom state directories outside home with the
unchanged conservative root-to-leaf validation and NO read-only-mount
relaxation: an untrusted displayed owner stays untrusted there.

Removed entirely: _read_mount_table, _unescape_mount_field,
_is_mount_prefix, _mount_point_is_readonly and the sandbox-ancestor
exception (the walk module now contains no mount-table reference,
pinned by a source-level test). All previously approved protections
are unchanged: state/store boundary before SQLite, descriptor-relative
traversal, O_NOFOLLOW, private final state dir, unit-directory
descriptor-bound publication, secure atomic writer, marker ownership,
retained-identity POSIX lock, Windows reparse protections, broad / and
$HOME writable-grant rejection, exact normalization, no credentials in
the unit, ProtectSystem=strict, ProtectHome=read-only.

Tests: the mountinfo parser suite is deleted; the sandbox-view matrix
is replaced by a home-anchored boundary matrix (synthetic ancestors
outside the trust computation; untrusted owner/final dir and
world-writable dir below home refused; foreign-owned or group-writable
home anchor refused) plus a discriminating service-contract suite
(default/explicit under-home state installs; home, '/', outside-home
and symlink-escape state dirs refused; XDG_CONFIG_HOME outside home
refused and beneath home accepted). The real systemd-run --user
regression (ProtectSystem=strict + ProtectHome=read-only +
ReadWritePaths) passes on this host with the home-anchored walk.
Existing attack regressions (planted db/lock symlinks, directory
replacement, unsafe shared paths, unit-directory replacement, broad
grants) pass unchanged.

Live ROG acceptance (no re-pairing, no reinstall — the installed unit
is byte-identical to the freshly rendered one): systemctl --user start
reaches active (running) with one stable main PID, zero 'untrusted
worker path component' journal entries, NRestarts=0, and an ESTABLISHED
connection to the loopback DEV Router (127.0.0.1:8790) reusing the
stored identity w-e69ce40f9c7842e6. Phase A was not continued.

Gate: uv sync --only-dev; make check (2634 tests OK, 15 platform-gated
skips); uv run basedpyright (0 errors/0 warnings/0 notes); git diff
--check.
adrian.tkacz deleted branch task/138-worker-service-lifecycle 2026-10-03 08:58:25 +00:00
Sign in to join this conversation.
No description provided.