From f9564abc35fbd122d6365ca96f2cda53db0e686a Mon Sep 17 00:00:00 2001 From: Omkhar Arasaratnam Date: Sun, 5 Jul 2026 15:44:33 -0400 Subject: [PATCH 01/14] ^d Add launcher reference sections to launcher-contract.md (D4) D4's acceptance criteria require docs/launcher-contract.md to document the launcher's required host tools, environment expectations, exit codes, and test override flags. The doc previously carried only the per-module contract sections; add a "Launcher reference" block covering those four topics. Every claim is grep-derived from scripts/workcell, scripts/lib/launcher/*.sh, and scripts/lib/*.sh (tool resolvers, scrub_host_process_env, the pinned TRUSTED_HOST_PATH, exit-code inventory, and the harness-only override flags with their gating). Doc-only; no code touched. markdownlint clean. Co-Authored-By: Claude Opus 4.8 --- docs/launcher-contract.md | 110 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/docs/launcher-contract.md b/docs/launcher-contract.md index a0d4208f..a350bb94 100644 --- a/docs/launcher-contract.md +++ b/docs/launcher-contract.md @@ -10,6 +10,116 @@ first call site. This document records the contract of each extracted module. It will grow as further modules are pulled out of the launcher. +## Launcher reference + +The sections below record the cross-cutting launcher contract that the module +sections then refine: the trusted host tools it requires, the environment it +pins and scrubs, the exit codes it uses, and the harness-only override flags it +recognises. Every claim here is derived from `scripts/workcell`, +`scripts/lib/launcher/*.sh`, and `scripts/lib/*.sh`. + +### Required host tools + +The launcher never trusts a bare `PATH` lookup for a host binary. It resolves +each tool to an absolute path through one of two resolvers, both of which abort +the launch (printing `Missing trusted host tool: ` to stderr and exiting +`1`) when no trusted candidate is executable: + +- `resolve_fixed_host_tool ...` + (`scripts/lib/launcher/host-exec.sh`) considers only the fixed, caller-supplied + absolute candidate paths — there is no `PATH` fallback. +- `resolve_host_tool ...` (`scripts/workcell`) tries the fixed + candidates first, then falls back to `type -P `, but every path (and its + canonicalised form) must pass `is_trusted_host_tool_path` before it is + accepted. Its `resolve_host_tool_optional` sibling is identical but returns `1` + instead of aborting, so it is used for non-fatal capability probes. + +| Tool | Resolver | Candidate paths | Purpose | +| --- | --- | --- | --- | +| `go` | `resolve_fixed_host_tool` (`go-hostutil.sh:29`) | `/opt/homebrew/bin/go`, `/usr/local/go/bin/go`, `/usr/local/bin/go`, `/usr/bin/go` | Runs the `workcell-hostutil` and `workcell-colimautil` Go programs via `go run` (see `HOST_GO_BIN`). | +| `colima` | `resolve_host_tool` (`workcell:625,670,5492`); `resolve_host_tool_optional` probe (`workcell:6713`) | `/opt/homebrew/bin/colima`, `/usr/local/bin/colima` | Manages the Colima VM profile that backs the container runtime (`HOST_COLIMA_BIN`). | +| `docker` | `resolve_host_tool` (`workcell:866,3654,3722,3786,4028,4215`); `resolve_host_tool_optional` probe (`workcell:850,1663,6721`) | `/opt/homebrew/bin/docker`, `/usr/local/bin/docker`, `/Applications/Docker.app/Contents/Resources/bin/docker` | Drives the container runtime (`HOST_DOCKER_BIN`). | +| `git` | `resolve_host_tool` (`workcell:4462,8135`); also invoked by name through `run_clean_host_command` (`workcell:7092,7163,7190,7213,7232,7372`) | `/usr/bin/git`, `/opt/homebrew/bin/git`, `/usr/local/bin/git` | Inspects and extracts the launch workspace repository (`HOST_GIT_BIN`). | +| `curl` | invoked by name through `run_clean_host_command` (`workcell:6241,6270`) | resolved from `TRUSTED_HOST_PATH` inside the sanitised `env -i` | Performs `HEAD` preflight reachability checks. | + +All resolutions restrict the binary to the trusted host tool directories, so a +required tool that is absent from those locations aborts the launch rather than +falling back to an attacker-controlled binary. + +### Environment expectations + +The launcher pins its own process environment before doing any work, then +derives a small set of host-context variables. + +**Pinned `PATH`.** The launcher re-execs itself with a cleared environment. The +shebang (`scripts/workcell:1`) is +`#!/usr/bin/env -S -i PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/sbin:/usr/local/sbin:/usr/sbin:/sbin:/Applications/Docker.app/Contents/Resources/bin BASH_ENV= ENV= /bin/bash`, +so `env -i` discards the inherited environment and starts `bash` with only the +fixed `PATH` and cleared `BASH_ENV`/`ENV`. That same string is then frozen as +`readonly TRUSTED_HOST_PATH` and re-exported as `PATH` (`scripts/workcell:5-6`), +and it is the `PATH` handed to every sanitised host command via +`env -i PATH="${TRUSTED_HOST_PATH}"` (`host-exec.sh:49,77`). + +**Scrubbed variables.** `scrub_host_process_env` (`scripts/workcell:7-30`, called +at `scripts/workcell:32`) unsets, in order: + +- `BASH_ENV`, `ENV` — startup-file injection vectors; +- `WORKCELL_TEST_FAIL_AFTER_PROFILE_REFRESH` — always unset (the fault-injection + hook is reachable only through a CLI flag, below); +- `WORKCELL_TEST_SUPPORT_MATRIX_HOST_OS`, `_ARCH`, `_DISTRO`, `_DISTRO_VERSION` — + unset **unless** `WORKCELL_VERIFY_INVARIANTS_SANITIZED_ENTRYPOINT` is `1`; +- `PYTHONPATH`, `PYTHONHOME`, `PYTHONSAFEPATH`, `PYTHONSTARTUP`; +- `RUBYOPT`, `RUBYLIB`, `GEM_HOME`, `GEM_PATH`; +- `PERL5OPT`, `PERL5LIB`, `PERLLIB`, `PERL_MB_OPT`, `PERL_MM_OPT`; +- `LD_PRELOAD`, `LD_LIBRARY_PATH`, `LD_AUDIT`, `LD_DEBUG`, `LD_DEBUG_OUTPUT`, + `LD_BIND_NOW`, `LD_ASSUME_KERNEL`; +- every `DYLD_*` variable found via `compgen -v`. + +**Derived / exported variables.** After scrubbing, the launcher establishes: + +| Variable | Value | Mechanism | +| --- | --- | --- | +| `REAL_HOME` | resolved host home | `resolve_workcell_real_home` (`workcell:71`, from `trusted-docker-client.sh`) | +| `WORKCELL_GO_CACHE_ROOT` (exported) | `${REAL_HOME}/Library/Caches/workcell/go` on Darwin, else `${XDG_CACHE_HOME:-${REAL_HOME}/.cache}/workcell/go` | `workcell:73-79` | +| `COLIMA_STATE_ROOT` | `${REAL_HOME}/.colima` | `workcell:90`; passed to `colima` as `COLIMA_HOME` (`workcell:632`) | +| `GOPATH` / `GOMODCACHE` / `GOCACHE` (exported) | `${cache_root}/gopath`, `/mod-cache`, `/build-cache` under `WORKCELL_GO_CACHE_ROOT` | `ensure_go_run_env` in `go-run-env.sh:30-44` (honours pre-set values) | + +`REAL_HOME` and `WORKCELL_GO_CACHE_ROOT` are deliberately established *before* +`go-hostutil.sh` is sourced, so the Go host-utility wrappers can never run with +an unsanitised host home or Go cache root (`workcell:80-85`). + +### Exit codes + +The launcher uses four numeric exit codes: + +| Code | Meaning | Representative sources | +| --- | --- | --- | +| `0` | Success, or an intentional clean skip (no work to do). | `workcell:3651` (no session id → skip), `workcell:8147` (logs shown) | +| `1` | General failure. In particular, the trusted-tool resolvers abort here when a required host tool is missing. | `host-exec.sh` `resolve_fixed_host_tool`, `workcell:6676` `resolve_host_tool` | +| `2` | Usage / validation / precondition guard failure — by far the most common code. Covers invalid CLI arguments, mode-requirement violations, reserved-variable rejection, a missing host working directory, and Colima profile validation failures. | `workcell:8151,8155` (reserved vars), `workcell:8614-8616` (profile validation), `run_clean_host_command_in_dir` missing-dir (`host-exec.sh`) | +| `88` | Test-only fault injection: a simulated crash immediately after a managed-profile refresh, used by harness recovery tests. Reached only through the hidden CLI flag below. | `maybe_fail_after_profile_refresh_for_tests` (`workcell:2625`) | + +There are no other codes: `grep -nE 'exit [0-9]' scripts/workcell` yields only +`0`, `1`, `2`, and the single `88`. + +### Test override flags + +These variables exist for the validation harness / CI, not for operators. They +are gated so they cannot be smuggled in through a normal launch. + +| Flag | Overrides | Gating | +| --- | --- | --- | +| `WORKCELL_VERIFY_INVARIANTS_SANITIZED_ENTRYPOINT` | Enables the host-detection overrides below (and suppresses their startup scrub). | Honoured only when set to `1` **and** `support_matrix_host_override_allowed` also confirms the parent process is a recognised validation-harness entrypoint (`host-detect.sh`). | +| `WORKCELL_TEST_SUPPORT_MATRIX_HOST_OS` / `_ARCH` / `_DISTRO` / `_DISTRO_VERSION` | Forces the detected host OS / arch / distro / distro version. | Honoured only when `support_matrix_host_override_allowed` returns `0`; otherwise unset by `scrub_host_process_env` at startup (`workcell:12-17`). | +| `WORKCELL_TEST_CODEX_AUTH_FILE` | Points the harness at a fixture Codex auth file (consumed by test subprocesses, never by the launcher). | If non-empty in the launcher's own environment the launcher **refuses to run**: `exit 2`, "reserved for the Workcell test harness" (`workcell:8150-8152`). | +| `WORKCELL_TEST_CLAUDE_KEYCHAIN_EXPORT_FILE` | Points the harness at a fixture Claude keychain export (consumed by test subprocesses, never by the launcher). | Same reject-on-presence guard: `exit 2` (`workcell:8153-8156`). | +| `WORKCELL_TEST_FAIL_AFTER_PROFILE_REFRESH` | Would request the mid-refresh fault injection. | The env variable is **unconditionally unset** at startup (`workcell:11`). The fault is reachable only through the hidden CLI flag `--test-fail-after-profile-refresh`, which sets the internal `TEST_FAIL_AFTER_PROFILE_REFRESH=1` (`workcell:7883-7884`) and triggers `exit 88`. | + +Because the support-matrix overrides depend on both an explicit marker variable +and a verified parent entrypoint, and the credential-fixture variables cause an +outright abort when present, an operator cannot use any of these to spoof the +detected host or redirect credential handling. + ## Host detection (`host-detect.sh`) `scripts/lib/launcher/host-detect.sh` normalises the host environment into the From 9fa530b88a81cae9afcaa12ecd8397ef504c8b64 Mon Sep 17 00:00:00 2001 From: Omkhar Arasaratnam Date: Sun, 5 Jul 2026 15:52:26 -0400 Subject: [PATCH 02/14] ^d Correct exit-code and host-tool contract claims in launcher-contract.md (P2/P3) (three accuracy fixes flagged in review: the Exit codes section claimed the launcher only ever exits 0/1/2/88, but a normal run passes the supervised child's status through unchanged - exit ${DOCKER_STATUS}/${BUILD_STATUS}/exit $? - so an end-to-end invocation can surface any 0-255 status per stability-contract.md; and the Required host tools section overstated the fail-closed guarantee - curl and git's run_clean_host_command invocations are sanitised-PATH by-name lookups, not absolute-resolved aborts, and curl is optional since its preflight is best-effort with || true; documented both mechanisms and marked curl optional) (markdownlint green, doc-only; doc/code accuracy) --- docs/launcher-contract.md | 40 ++++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/docs/launcher-contract.md b/docs/launcher-contract.md index a350bb94..2e5755da 100644 --- a/docs/launcher-contract.md +++ b/docs/launcher-contract.md @@ -20,10 +20,10 @@ recognises. Every claim here is derived from `scripts/workcell`, ### Required host tools -The launcher never trusts a bare `PATH` lookup for a host binary. It resolves -each tool to an absolute path through one of two resolvers, both of which abort -the launch (printing `Missing trusted host tool: ` to stderr and exiting -`1`) when no trusted candidate is executable: +The launcher never trusts a bare `PATH` lookup for a host binary. Its core tools +(`go`, `colima`, `docker`) are resolved to an absolute path through one of two +resolvers, both of which abort the launch (printing `Missing trusted host tool: +` to stderr and exiting `1`) when no trusted candidate is executable: - `resolve_fixed_host_tool ...` (`scripts/lib/launcher/host-exec.sh`) considers only the fixed, caller-supplied @@ -39,12 +39,17 @@ the launch (printing `Missing trusted host tool: ` to stderr and exiting | `go` | `resolve_fixed_host_tool` (`go-hostutil.sh:29`) | `/opt/homebrew/bin/go`, `/usr/local/go/bin/go`, `/usr/local/bin/go`, `/usr/bin/go` | Runs the `workcell-hostutil` and `workcell-colimautil` Go programs via `go run` (see `HOST_GO_BIN`). | | `colima` | `resolve_host_tool` (`workcell:625,670,5492`); `resolve_host_tool_optional` probe (`workcell:6713`) | `/opt/homebrew/bin/colima`, `/usr/local/bin/colima` | Manages the Colima VM profile that backs the container runtime (`HOST_COLIMA_BIN`). | | `docker` | `resolve_host_tool` (`workcell:866,3654,3722,3786,4028,4215`); `resolve_host_tool_optional` probe (`workcell:850,1663,6721`) | `/opt/homebrew/bin/docker`, `/usr/local/bin/docker`, `/Applications/Docker.app/Contents/Resources/bin/docker` | Drives the container runtime (`HOST_DOCKER_BIN`). | -| `git` | `resolve_host_tool` (`workcell:4462,8135`); also invoked by name through `run_clean_host_command` (`workcell:7092,7163,7190,7213,7232,7372`) | `/usr/bin/git`, `/opt/homebrew/bin/git`, `/usr/local/bin/git` | Inspects and extracts the launch workspace repository (`HOST_GIT_BIN`). | -| `curl` | invoked by name through `run_clean_host_command` (`workcell:6241,6270`) | resolved from `TRUSTED_HOST_PATH` inside the sanitised `env -i` | Performs `HEAD` preflight reachability checks. | - -All resolutions restrict the binary to the trusted host tool directories, so a -required tool that is absent from those locations aborts the launch rather than -falling back to an attacker-controlled binary. +| `git` | `resolve_host_tool` → absolute `HOST_GIT_BIN` (`workcell:4462,8135`); **also** invoked by name via `run_clean_host_command` (`workcell:7092,7163,7190,7213,7232,7372`) | `/usr/bin/git`, `/opt/homebrew/bin/git`, `/usr/local/bin/git` | Inspects and extracts the launch workspace repository. | +| `curl` | **sanitised-PATH by name only** (no absolute resolve) via `run_clean_host_command` (`workcell:6241,6270`) | found on `TRUSTED_HOST_PATH` inside the sanitised `env -i` | Best-effort `HEAD` preflight for release URLs; **optional** — absence is swallowed (`\|\| true`), not fatal. | + +The two absolute resolvers restrict the binary to the trusted host-tool +directories, so a resolver-backed tool (`go`, `colima`, `docker`, and `git`'s +`HOST_GIT_BIN`) that is absent aborts the launch rather than falling back to an +attacker-controlled binary. The `run_clean_host_command ` invocations of +`git` and `curl` instead look the name up on the sanitised `TRUSTED_HOST_PATH` at +call time: still confined to the trusted `PATH`, but **not** absolute-resolved and +**not** fail-closed — a missing `curl` leaves the preflight URL empty rather than +raising `Missing trusted host tool`, so `curl` is optional, not required. ### Environment expectations @@ -90,7 +95,8 @@ an unsanitised host home or Go cache root (`workcell:80-85`). ### Exit codes -The launcher uses four numeric exit codes: +The launcher's **own** exit codes — those it originates for its own +skip/failure conditions — are four: | Code | Meaning | Representative sources | | --- | --- | --- | @@ -99,8 +105,16 @@ The launcher uses four numeric exit codes: | `2` | Usage / validation / precondition guard failure — by far the most common code. Covers invalid CLI arguments, mode-requirement violations, reserved-variable rejection, a missing host working directory, and Colima profile validation failures. | `workcell:8151,8155` (reserved vars), `workcell:8614-8616` (profile validation), `run_clean_host_command_in_dir` missing-dir (`host-exec.sh`) | | `88` | Test-only fault injection: a simulated crash immediately after a managed-profile refresh, used by harness recovery tests. Reached only through the hidden CLI flag below. | `maybe_fail_after_profile_refresh_for_tests` (`workcell:2625`) | -There are no other codes: `grep -nE 'exit [0-9]' scripts/workcell` yields only -`0`, `1`, `2`, and the single `88`. +Those four are the only *literal* codes the launcher originates +(`grep -nE 'exit [0-9]' scripts/workcell` yields only `0`, `1`, `2`, and the +single `88`). Beyond them, a normal end-to-end invocation **passes the supervised +child's exit status through unchanged** — a completed session exits with the +container's status (`exit "${DOCKER_STATUS}"`, `workcell:8797`), a build failure +with `${BUILD_STATUS}` (`workcell:8698`), and session subcommands propagate their +helper's status (`exit $?`, e.g. `workcell:4182,4188`). An end-to-end run can +therefore surface any status in `0`–`255` (including `128+N` for a signalled +child), as [`stability-contract.md`](stability-contract.md) documents; the four +codes above are the launcher's own, distinct from that passthrough. ### Test override flags From 1021191bd767e3b6560adf18155bb6de2b489432 Mon Sep 17 00:00:00 2001 From: Omkhar Arasaratnam Date: Sun, 5 Jul 2026 16:00:27 -0400 Subject: [PATCH 03/14] ^d Scope host-tool table and correct fixed-resolver trust in launcher-contract.md (P2) (two more review accuracy fixes: the required-tools table implied a launcher-wide contract but omitted backend/build prerequisites - aws + session-manager-plugin for aws-ec2-ssm, gcloud for gcp-vm via missing_launch_host_tools_csv, and docker-buildx via ensure_workcell_trusted_buildx - so scoped the table to local core tools and added those; and the trust claim overstated resolve_fixed_host_tool, which only checks -x on the literal candidate with no canonicalisation or is_trusted_host_tool_path validation - unlike resolve_host_tool - so distinguished the weaker fixed-path allowlist from the canonical-target-validated resolver) (markdownlint green, doc-only; doc/code security-accuracy) --- docs/launcher-contract.md | 39 ++++++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/docs/launcher-contract.md b/docs/launcher-contract.md index 2e5755da..6f0aad5d 100644 --- a/docs/launcher-contract.md +++ b/docs/launcher-contract.md @@ -26,12 +26,16 @@ resolvers, both of which abort the launch (printing `Missing trusted host tool: ` to stderr and exiting `1`) when no trusted candidate is executable: - `resolve_fixed_host_tool ...` - (`scripts/lib/launcher/host-exec.sh`) considers only the fixed, caller-supplied - absolute candidate paths — there is no `PATH` fallback. + (`scripts/lib/launcher/host-exec.sh`) returns the first caller-supplied candidate + that is executable (`-x`) — no `PATH` fallback, but also **no canonicalisation**: + it trusts the literal candidate path as written, so a fixed candidate that is a + symlink is followed by the kernel without re-validating the target. Trust here + rests on those hard-coded paths not being attacker-writable. - `resolve_host_tool ...` (`scripts/workcell`) tries the fixed - candidates first, then falls back to `type -P `, but every path (and its - canonicalised form) must pass `is_trusted_host_tool_path` before it is - accepted. Its `resolve_host_tool_optional` sibling is identical but returns `1` + candidates first, then falls back to `type -P `, but every path **and its + canonicalised form** must pass `is_trusted_host_tool_path` before it is accepted + (`workcell:6660-6661`), so it cannot resolve to a target outside the trusted + prefixes. Its `resolve_host_tool_optional` sibling is identical but returns `1` instead of aborting, so it is used for non-fatal capability probes. | Tool | Resolver | Candidate paths | Purpose | @@ -42,14 +46,23 @@ resolvers, both of which abort the launch (printing `Missing trusted host tool: | `git` | `resolve_host_tool` → absolute `HOST_GIT_BIN` (`workcell:4462,8135`); **also** invoked by name via `run_clean_host_command` (`workcell:7092,7163,7190,7213,7232,7372`) | `/usr/bin/git`, `/opt/homebrew/bin/git`, `/usr/local/bin/git` | Inspects and extracts the launch workspace repository. | | `curl` | **sanitised-PATH by name only** (no absolute resolve) via `run_clean_host_command` (`workcell:6241,6270`) | found on `TRUSTED_HOST_PATH` inside the sanitised `env -i` | Best-effort `HEAD` preflight for release URLs; **optional** — absence is swallowed (`\|\| true`), not fatal. | -The two absolute resolvers restrict the binary to the trusted host-tool -directories, so a resolver-backed tool (`go`, `colima`, `docker`, and `git`'s -`HOST_GIT_BIN`) that is absent aborts the launch rather than falling back to an -attacker-controlled binary. The `run_clean_host_command ` invocations of -`git` and `curl` instead look the name up on the sanitised `TRUSTED_HOST_PATH` at -call time: still confined to the trusted `PATH`, but **not** absolute-resolved and -**not** fail-closed — a missing `curl` leaves the preflight URL empty rather than -raising `Missing trusted host tool`, so `curl` is optional, not required. +A resolver-backed tool that is absent aborts the launch rather than falling back +to an attacker-controlled binary — but the two resolvers differ in strength, as +above: `resolve_host_tool` (`colima`, `docker`, `git`'s `HOST_GIT_BIN`) validates +the canonical target, while `resolve_fixed_host_tool` (`go`) trusts its literal +fixed paths. The `run_clean_host_command ` invocations of `git` and `curl` +instead look the name up on the sanitised `TRUSTED_HOST_PATH` at call time: still +confined to the trusted `PATH`, but **not** absolute-resolved and **not** +fail-closed — a missing `curl` leaves the preflight URL empty rather than raising +`Missing trusted host tool`, so `curl` is optional, not required. + +This table covers the tools every **local** launch resolves. Two prerequisites +sit outside it. Remote-preview backends probe extra tools via +`missing_launch_host_tools_csv` (`scripts/workcell:6708`) — `aws` and +`session-manager-plugin` for the `aws-ec2-ssm` backend, `gcloud` for `gcp-vm` — +and image builds require a system `docker-buildx` plugin binary +(`ensure_workcell_trusted_buildx`, `scripts/lib/trusted-docker-client.sh`), which +aborts with `Missing trusted docker-buildx binary` when none is found. ### Environment expectations From cb6917fc1f10d7810ba6ae1f1e677f76ac5b115e Mon Sep 17 00:00:00 2001 From: Omkhar Arasaratnam Date: Sun, 5 Jul 2026 16:09:52 -0400 Subject: [PATCH 04/14] ^d Note gh as a publish-pr host prerequisite in launcher-contract.md (P3) (the host-tool reference omitted gh, which the workcell publish-pr subcommand resolves as required on its non-dry-run path via the Go publish helper internal/publishpr; an operator satisfying only the local-core table could still fail at publication time, so added it to the out-of-table prerequisites note alongside the backend and buildx tools) (markdownlint green, doc-only; operator prerequisite completeness) --- docs/launcher-contract.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/launcher-contract.md b/docs/launcher-contract.md index 6f0aad5d..bfa8efdf 100644 --- a/docs/launcher-contract.md +++ b/docs/launcher-contract.md @@ -56,13 +56,17 @@ confined to the trusted `PATH`, but **not** absolute-resolved and **not** fail-closed — a missing `curl` leaves the preflight URL empty rather than raising `Missing trusted host tool`, so `curl` is optional, not required. -This table covers the tools every **local** launch resolves. Two prerequisites +This table covers the tools every **local** launch resolves; a few prerequisites sit outside it. Remote-preview backends probe extra tools via `missing_launch_host_tools_csv` (`scripts/workcell:6708`) — `aws` and -`session-manager-plugin` for the `aws-ec2-ssm` backend, `gcloud` for `gcp-vm` — -and image builds require a system `docker-buildx` plugin binary +`session-manager-plugin` for the `aws-ec2-ssm` backend, `gcloud` for `gcp-vm`. +Image builds require a system `docker-buildx` plugin binary (`ensure_workcell_trusted_buildx`, `scripts/lib/trusted-docker-client.sh`), which -aborts with `Missing trusted docker-buildx binary` when none is found. +aborts with `Missing trusted docker-buildx binary` when none is found. And the +`workcell publish-pr` subcommand resolves `gh` on its non-dry-run path (required; +via the Go publish helper `internal/publishpr`, `ResolveHostTool(ctx, "gh", true, +…)`), failing with `Missing trusted host tool: gh` at publication time if it is +absent. ### Environment expectations From 746dd3f24d7b25c3822897bfd2a7095486d7202d Mon Sep 17 00:00:00 2001 From: Omkhar Arasaratnam Date: Sun, 5 Jul 2026 16:16:00 -0400 Subject: [PATCH 05/14] ^d Qualify the cleared-environment contract in launcher-contract.md (P2) (the env expectations described a blanket env -i re-exec/clear, but that only holds when the shebang is honored on direct execution; when the launcher is invoked as bash scripts/workcell or /bin/bash -p scripts/workcell - the form the repo's own verify-invariants and publish-pr harnesses use - the shebang is bypassed, env -i never runs, and only the explicit scrub_host_process_env list plus the PATH reset apply; documented this so security reasoning does not assume unlisted variables are absent on the harness/subcommand path) (markdownlint green, doc-only; doc/code security-accuracy) --- docs/launcher-contract.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/launcher-contract.md b/docs/launcher-contract.md index bfa8efdf..46ebbb5b 100644 --- a/docs/launcher-contract.md +++ b/docs/launcher-contract.md @@ -73,14 +73,21 @@ absent. The launcher pins its own process environment before doing any work, then derives a small set of host-context variables. -**Pinned `PATH`.** The launcher re-execs itself with a cleared environment. The -shebang (`scripts/workcell:1`) is +**Pinned `PATH`.** On direct execution the launcher re-execs itself with a +**cleared** environment: the shebang (`scripts/workcell:1`) is `#!/usr/bin/env -S -i PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/sbin:/usr/local/sbin:/usr/sbin:/sbin:/Applications/Docker.app/Contents/Resources/bin BASH_ENV= ENV= /bin/bash`, so `env -i` discards the inherited environment and starts `bash` with only the -fixed `PATH` and cleared `BASH_ENV`/`ENV`. That same string is then frozen as -`readonly TRUSTED_HOST_PATH` and re-exported as `PATH` (`scripts/workcell:5-6`), -and it is the `PATH` handed to every sanitised host command via -`env -i PATH="${TRUSTED_HOST_PATH}"` (`host-exec.sh:49,77`). +fixed `PATH` and cleared `BASH_ENV`/`ENV`. **This wholesale clear only happens +when the shebang is honored.** When the launcher is instead invoked as +`bash scripts/workcell` / `/bin/bash -p scripts/workcell` — the form the repo's own +verify-invariants and publish-pr harnesses use — the shebang is bypassed, `env -i` +never runs, and the inherited environment is *not* wholesale cleared; there, only +the explicit `scrub_host_process_env` list (below) plus the `PATH` reset apply, so +security reasoning must not assume unlisted variables are absent on that path. In +both cases the `PATH` string is frozen as `readonly TRUSTED_HOST_PATH` and +re-exported as `PATH` (`scripts/workcell:5-6`), and it is the `PATH` handed to +every sanitised host command via `env -i PATH="${TRUSTED_HOST_PATH}"` +(`host-exec.sh:49,77`). **Scrubbed variables.** `scrub_host_process_env` (`scripts/workcell:7-30`, called at `scripts/workcell:32`) unsets, in order: From e698d0ca1057a8180535a5041a9663c17d8525ae Mon Sep 17 00:00:00 2001 From: Omkhar Arasaratnam Date: Sun, 5 Jul 2026 16:24:02 -0400 Subject: [PATCH 06/14] ^d Document the bash-path Go-cache and support-matrix scrub caveats in launcher-contract.md (P2) (two more consequences of the shebang-bypassed bash/-p invocation path: (1) the Go-cache invariant claimed the wrappers can never run with an unsanitised Go cache root, but ensure_go_run_env honours a pre-set GOPATH/GOMODCACHE/GOCACHE which scrub_host_process_env does not unset - reframed as an ordering guarantee that yields a defined, not necessarily default, cache root; (2) the support-matrix scrub is gated only on the SANITIZED_ENTRYPOINT marker while honoring is gated on support_matrix_host_override_allowed - separated the two gates so the vars are documented as possibly present-but-ignored on the bash path rather than guaranteed absent) (markdownlint green, doc-only; doc/code security-accuracy) --- docs/launcher-contract.md | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/docs/launcher-contract.md b/docs/launcher-contract.md index 46ebbb5b..f1e2495e 100644 --- a/docs/launcher-contract.md +++ b/docs/launcher-contract.md @@ -114,8 +114,13 @@ at `scripts/workcell:32`) unsets, in order: | `GOPATH` / `GOMODCACHE` / `GOCACHE` (exported) | `${cache_root}/gopath`, `/mod-cache`, `/build-cache` under `WORKCELL_GO_CACHE_ROOT` | `ensure_go_run_env` in `go-run-env.sh:30-44` (honours pre-set values) | `REAL_HOME` and `WORKCELL_GO_CACHE_ROOT` are deliberately established *before* -`go-hostutil.sh` is sourced, so the Go host-utility wrappers can never run with -an unsanitised host home or Go cache root (`workcell:80-85`). +`go-hostutil.sh` is sourced (`workcell:80-85`), so the wrappers never run before +the host home and default Go cache root are fixed. This is an **ordering** +guarantee, not full confinement: `ensure_go_run_env` **honours a pre-set** +`GOPATH`/`GOMODCACHE`/`GOCACHE` (`go-run-env.sh:33-35`), and those three are not in +the `scrub_host_process_env` list, so on the shebang-bypassed +`bash scripts/workcell` path (above) a caller-supplied Go cache can persist — the +wrappers are guaranteed a *defined* cache root, not necessarily the default one. ### Exit codes @@ -142,13 +147,16 @@ codes above are the launcher's own, distinct from that passthrough. ### Test override flags -These variables exist for the validation harness / CI, not for operators. They -are gated so they cannot be smuggled in through a normal launch. +These variables exist for the validation harness / CI, not for operators. Each is +gated — hard-rejected, unconditionally unset, or ignored unless its harness +conditions hold — so a normal launch cannot *act* on one. Note the gate that +decides whether a value is **scrubbed** is, for the support-matrix vars, weaker +than the gate that decides whether it is **honoured** (see their rows). | Flag | Overrides | Gating | | --- | --- | --- | -| `WORKCELL_VERIFY_INVARIANTS_SANITIZED_ENTRYPOINT` | Enables the host-detection overrides below (and suppresses their startup scrub). | Honoured only when set to `1` **and** `support_matrix_host_override_allowed` also confirms the parent process is a recognised validation-harness entrypoint (`host-detect.sh`). | -| `WORKCELL_TEST_SUPPORT_MATRIX_HOST_OS` / `_ARCH` / `_DISTRO` / `_DISTRO_VERSION` | Forces the detected host OS / arch / distro / distro version. | Honoured only when `support_matrix_host_override_allowed` returns `0`; otherwise unset by `scrub_host_process_env` at startup (`workcell:12-17`). | +| `WORKCELL_VERIFY_INVARIANTS_SANITIZED_ENTRYPOINT` | Two distinct effects: (a) at startup its presence (`=1`) makes `scrub_host_process_env` **skip unsetting** the four support-matrix vars; (b) it is one condition the detectors check before honouring those overrides. | Effect (a) is gated **only** on the `=1` marker (`workcell:12-17`) — *not* parent-verified. Effect (b) additionally requires `support_matrix_host_override_allowed` to confirm a recognised validation-harness parent (`host-detect.sh`). | +| `WORKCELL_TEST_SUPPORT_MATRIX_HOST_OS` / `_ARCH` / `_DISTRO` / `_DISTRO_VERSION` | Forces the detected host OS / arch / distro / distro version. | **Unset at startup only when the `SANITIZED_ENTRYPOINT` marker is absent** (`workcell:12-17`); with the marker set they survive startup even under a non-harness parent, but the detectors **honour** them only when `support_matrix_host_override_allowed` returns `0` (marker **and** recognised parent). So on the shebang-bypassed `bash scripts/workcell` path they can be *present but ignored*, not guaranteed absent. | | `WORKCELL_TEST_CODEX_AUTH_FILE` | Points the harness at a fixture Codex auth file (consumed by test subprocesses, never by the launcher). | If non-empty in the launcher's own environment the launcher **refuses to run**: `exit 2`, "reserved for the Workcell test harness" (`workcell:8150-8152`). | | `WORKCELL_TEST_CLAUDE_KEYCHAIN_EXPORT_FILE` | Points the harness at a fixture Claude keychain export (consumed by test subprocesses, never by the launcher). | Same reject-on-presence guard: `exit 2` (`workcell:8153-8156`). | | `WORKCELL_TEST_FAIL_AFTER_PROFILE_REFRESH` | Would request the mid-refresh fault injection. | The env variable is **unconditionally unset** at startup (`workcell:11`). The fault is reachable only through the hidden CLI flag `--test-fail-after-profile-refresh`, which sets the internal `TEST_FAIL_AFTER_PROFILE_REFRESH=1` (`workcell:7883-7884`) and triggers `exit 88`. | From 74e8d563b40a1e62d8a81a5614a1221d15b5846c Mon Sep 17 00:00:00 2001 From: Omkhar Arasaratnam Date: Sun, 5 Jul 2026 16:33:30 -0400 Subject: [PATCH 07/14] ^d Stop overstating harness-gate, Codex-var, and Go-resolver contracts in launcher-contract.md (P2) (three accuracy corrections that narrow overstated claims: (1) support_matrix_host_override_allowed's parent check is a command-line substring match, not a verified process identity, so reframed the gates as harness conventions - not a spoof-proof boundary against a local operator - and deferred the threat model to docs/security/; (2) WORKCELL_TEST_CODEX_AUTH_FILE is rejected legacy input no resolver consumes, not a fixture consumed by subprocesses; (3) go has a second, weaker resolution path via go-run-env.sh under --audit-transcript that honours an unscrubbed WORKCELL_GO_BIN, documented alongside the aborting fixed resolver) (markdownlint green, doc-only; doc/code security-accuracy) --- docs/launcher-contract.md | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/docs/launcher-contract.md b/docs/launcher-contract.md index f1e2495e..987ec4a3 100644 --- a/docs/launcher-contract.md +++ b/docs/launcher-contract.md @@ -56,6 +56,15 @@ confined to the trusted `PATH`, but **not** absolute-resolved and **not** fail-closed — a missing `curl` leaves the preflight URL empty rather than raising `Missing trusted host tool`, so `curl` is optional, not required. +`go` additionally has a **second, weaker** resolution path: the +`--audit-transcript` PTY helper (`scripts/lib/pty_transcript`, `workcell:8778`) +sources `go-run-env.sh`, whose Go-binary resolver honours a pre-set +`WORKCELL_GO_BIN`, then `command -v go`, then the fixed candidates +(`go-run-env.sh:47-71`). Unlike `resolve_fixed_host_tool` this does not abort and +is not confined to fixed trusted paths, and `WORKCELL_GO_BIN` is not in the scrub +list — so on the shebang-bypassed `bash` path an inherited `WORKCELL_GO_BIN` can +select the `go` used for the transcript. + This table covers the tools every **local** launch resolves; a few prerequisites sit outside it. Remote-preview backends probe extra tools via `missing_launch_host_tools_csv` (`scripts/workcell:6708`) — `aws` and @@ -157,14 +166,19 @@ than the gate that decides whether it is **honoured** (see their rows). | --- | --- | --- | | `WORKCELL_VERIFY_INVARIANTS_SANITIZED_ENTRYPOINT` | Two distinct effects: (a) at startup its presence (`=1`) makes `scrub_host_process_env` **skip unsetting** the four support-matrix vars; (b) it is one condition the detectors check before honouring those overrides. | Effect (a) is gated **only** on the `=1` marker (`workcell:12-17`) — *not* parent-verified. Effect (b) additionally requires `support_matrix_host_override_allowed` to confirm a recognised validation-harness parent (`host-detect.sh`). | | `WORKCELL_TEST_SUPPORT_MATRIX_HOST_OS` / `_ARCH` / `_DISTRO` / `_DISTRO_VERSION` | Forces the detected host OS / arch / distro / distro version. | **Unset at startup only when the `SANITIZED_ENTRYPOINT` marker is absent** (`workcell:12-17`); with the marker set they survive startup even under a non-harness parent, but the detectors **honour** them only when `support_matrix_host_override_allowed` returns `0` (marker **and** recognised parent). So on the shebang-bypassed `bash scripts/workcell` path they can be *present but ignored*, not guaranteed absent. | -| `WORKCELL_TEST_CODEX_AUTH_FILE` | Points the harness at a fixture Codex auth file (consumed by test subprocesses, never by the launcher). | If non-empty in the launcher's own environment the launcher **refuses to run**: `exit 2`, "reserved for the Workcell test harness" (`workcell:8150-8152`). | -| `WORKCELL_TEST_CLAUDE_KEYCHAIN_EXPORT_FILE` | Points the harness at a fixture Claude keychain export (consumed by test subprocesses, never by the launcher). | Same reject-on-presence guard: `exit 2` (`workcell:8153-8156`). | +| `WORKCELL_TEST_CODEX_AUTH_FILE` | Reserved harness variable name — **rejected legacy input**, not a credential override: no launcher resolver consumes it (the Codex resolver no longer recognises it; see `docs/security/`). | If non-empty in the launcher's own environment the launcher **refuses to run**: `exit 2`, "reserved for the Workcell test harness" (`workcell:8150-8152`). | +| `WORKCELL_TEST_CLAUDE_KEYCHAIN_EXPORT_FILE` | Reserved harness variable name; not consumed as a credential override by the launcher. | Same reject-on-presence guard: `exit 2` (`workcell:8153-8156`). | | `WORKCELL_TEST_FAIL_AFTER_PROFILE_REFRESH` | Would request the mid-refresh fault injection. | The env variable is **unconditionally unset** at startup (`workcell:11`). The fault is reachable only through the hidden CLI flag `--test-fail-after-profile-refresh`, which sets the internal `TEST_FAIL_AFTER_PROFILE_REFRESH=1` (`workcell:7883-7884`) and triggers `exit 88`. | -Because the support-matrix overrides depend on both an explicit marker variable -and a verified parent entrypoint, and the credential-fixture variables cause an -outright abort when present, an operator cannot use any of these to spoof the -detected host or redirect credential handling. +These gates are **harness conventions, not a security boundary against a local +operator**. The support-matrix "parent" check is a *substring match* on the parent +process command line (below), not a verified process identity, so a caller who +controls their own process tree can construct a matching command line. It deters +accidental leakage from an ordinary launch; it is not a defence against deliberate +spoofing by someone who already runs the launcher (who, being local, controls that +environment anyway). The credential-fixture variables are stricter — they force an +`exit 2` abort whenever present. The authoritative host/credential threat model +lives in the security docs under `docs/security/`, not here. ## Host detection (`host-detect.sh`) From 52791619e8601b22f0fe273bccc48dc2189ef296 Mon Sep 17 00:00:00 2001 From: Omkhar Arasaratnam Date: Sun, 5 Jul 2026 16:40:12 -0400 Subject: [PATCH 08/14] ^d Document the launcher-originated 124 timeout exit in launcher-contract.md (P2) (the exit-codes section claimed the launcher's own codes were only 0/1/2/88 - the literal exit grep - but a managed Colima start that exceeds WORKCELL_COLIMA_START_TIMEOUT_SECONDS makes the timeout wrapper return 124 which set -e propagates, so the launcher itself exits 124, matching stability-contract.md; added the 124 row and reframed the section to describe the mechanism - literal exits, set -e propagation, and child passthrough - deferring the authoritative range to stability-contract.md) (markdownlint green, doc-only; doc/code accuracy) --- docs/launcher-contract.md | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/docs/launcher-contract.md b/docs/launcher-contract.md index 987ec4a3..6146ecd7 100644 --- a/docs/launcher-contract.md +++ b/docs/launcher-contract.md @@ -134,7 +134,7 @@ wrappers are guaranteed a *defined* cache root, not necessarily the default one. ### Exit codes The launcher's **own** exit codes — those it originates for its own -skip/failure conditions — are four: +skip/failure conditions — are: | Code | Meaning | Representative sources | | --- | --- | --- | @@ -142,17 +142,19 @@ skip/failure conditions — are four: | `1` | General failure. In particular, the trusted-tool resolvers abort here when a required host tool is missing. | `host-exec.sh` `resolve_fixed_host_tool`, `workcell:6676` `resolve_host_tool` | | `2` | Usage / validation / precondition guard failure — by far the most common code. Covers invalid CLI arguments, mode-requirement violations, reserved-variable rejection, a missing host working directory, and Colima profile validation failures. | `workcell:8151,8155` (reserved vars), `workcell:8614-8616` (profile validation), `run_clean_host_command_in_dir` missing-dir (`host-exec.sh`) | | `88` | Test-only fault injection: a simulated crash immediately after a managed-profile refresh, used by harness recovery tests. Reached only through the hidden CLI flag below. | `maybe_fail_after_profile_refresh_for_tests` (`workcell:2625`) | - -Those four are the only *literal* codes the launcher originates -(`grep -nE 'exit [0-9]' scripts/workcell` yields only `0`, `1`, `2`, and the -single `88`). Beyond them, a normal end-to-end invocation **passes the supervised -child's exit status through unchanged** — a completed session exits with the -container's status (`exit "${DOCKER_STATUS}"`, `workcell:8797`), a build failure -with `${BUILD_STATUS}` (`workcell:8698`), and session subcommands propagate their -helper's status (`exit $?`, e.g. `workcell:4182,4188`). An end-to-end run can -therefore surface any status in `0`–`255` (including `128+N` for a signalled -child), as [`stability-contract.md`](stability-contract.md) documents; the four -codes above are the launcher's own, distinct from that passthrough. +| `124` | A managed operation timed out — e.g. `colima start` exceeding `WORKCELL_COLIMA_START_TIMEOUT_SECONDS`. The timeout wrapper returns `124` and `set -e` propagates it, so the launcher itself exits `124` (matching `stability-contract.md`). | `run_command_with_debug_log` / `start_managed_profile` timeout path | + +`0`/`1`/`2`/`88` are the *literal* `exit` codes in `scripts/workcell` +(`grep -nE 'exit [0-9]'`); `124` is originated indirectly, via `set -e` +propagating a timeout wrapper's non-zero return. Beyond the codes it originates, +a normal end-to-end invocation also **passes the supervised child's exit status +through unchanged** — a completed session exits with the container's status +(`exit "${DOCKER_STATUS}"`, `workcell:8797`), a build failure with +`${BUILD_STATUS}` (`workcell:8698`), and session subcommands propagate their +helper's status (`exit $?`, e.g. `workcell:4182,4188`). So an end-to-end run can +surface any status in `0`–`255` (including `128+N` for a signalled child); +[`stability-contract.md`](stability-contract.md) is the authoritative exit-code +reference. ### Test override flags From b7a7061035be05564b355c02193aff3adb08d980 Mon Sep 17 00:00:00 2001 From: Omkhar Arasaratnam Date: Sun, 5 Jul 2026 16:46:25 -0400 Subject: [PATCH 09/14] ^d Correct the PTY Go resolver as fail-closed in launcher-contract.md (P3) (round-6 wording wrongly called go-run-env.sh's resolver non-aborting; resolve_go_bin exits 1 with 'Missing required tool: go' when no executable go is found (go-run-env.sh:66-68), so it is fail-closed like the fixed resolver; corrected to say fail-closed but not confined to fixed trusted paths - it accepts a command -v go hit and honours an unscrubbed WORKCELL_GO_BIN - which keeps the real bash-path caveat while fixing the abort claim) (markdownlint green, doc-only; corrects a factual error) --- docs/launcher-contract.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/launcher-contract.md b/docs/launcher-contract.md index 6146ecd7..978481cb 100644 --- a/docs/launcher-contract.md +++ b/docs/launcher-contract.md @@ -56,14 +56,16 @@ confined to the trusted `PATH`, but **not** absolute-resolved and **not** fail-closed — a missing `curl` leaves the preflight URL empty rather than raising `Missing trusted host tool`, so `curl` is optional, not required. -`go` additionally has a **second, weaker** resolution path: the +`go` additionally has a **second, differently-scoped** resolution path: the `--audit-transcript` PTY helper (`scripts/lib/pty_transcript`, `workcell:8778`) sources `go-run-env.sh`, whose Go-binary resolver honours a pre-set `WORKCELL_GO_BIN`, then `command -v go`, then the fixed candidates -(`go-run-env.sh:47-71`). Unlike `resolve_fixed_host_tool` this does not abort and -is not confined to fixed trusted paths, and `WORKCELL_GO_BIN` is not in the scrub -list — so on the shebang-bypassed `bash` path an inherited `WORKCELL_GO_BIN` can -select the `go` used for the transcript. +(`go-run-env.sh:47-71`). Like the fixed resolver it is **fail-closed** — it exits +`1` with `Missing required tool: go` when none is found (`go-run-env.sh:66-68`) — +but it is **not confined to fixed trusted paths** (a `command -v go` hit is +accepted) and `WORKCELL_GO_BIN` is not in the scrub list, so on the +shebang-bypassed `bash` path an inherited `WORKCELL_GO_BIN` can select the `go` +used for the transcript. This table covers the tools every **local** launch resolves; a few prerequisites sit outside it. Remote-preview backends probe extra tools via From 9feae91cbc64afebb877fba0934dbefbbe49435a Mon Sep 17 00:00:00 2001 From: Omkhar Arasaratnam Date: Sun, 5 Jul 2026 16:53:57 -0400 Subject: [PATCH 10/14] ^d Correct BASH_ENV timing and the Claude keychain var's dual role in launcher-contract.md (P2) (two accuracy corrections on the shebang-bypassed path: (1) plain bash scripts/workcell processes BASH_ENV before the script body, so a startup-file injection has already run before scrub_host_process_env unsets it - the scrub is not a defence there - whereas /bin/bash -p ignores BASH_ENV; split the two forms rather than grouping them; (2) WORKCELL_TEST_CLAUDE_KEYCHAIN_EXPORT_FILE was wrongly called not-consumed - the self-staging --synthetic-claude-export path sets it internally (prepare_bundle.go:434) for the Claude credential resolver (resolve_provider_credentials.go:16), while an operator-supplied value is still rejected exit 2; documented the dual role) (markdownlint green, doc-only; corrects two factual errors) --- docs/launcher-contract.md | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/docs/launcher-contract.md b/docs/launcher-contract.md index 978481cb..9d3c259a 100644 --- a/docs/launcher-contract.md +++ b/docs/launcher-contract.md @@ -89,13 +89,22 @@ derives a small set of host-context variables. `#!/usr/bin/env -S -i PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/sbin:/usr/local/sbin:/usr/sbin:/sbin:/Applications/Docker.app/Contents/Resources/bin BASH_ENV= ENV= /bin/bash`, so `env -i` discards the inherited environment and starts `bash` with only the fixed `PATH` and cleared `BASH_ENV`/`ENV`. **This wholesale clear only happens -when the shebang is honored.** When the launcher is instead invoked as -`bash scripts/workcell` / `/bin/bash -p scripts/workcell` — the form the repo's own -verify-invariants and publish-pr harnesses use — the shebang is bypassed, `env -i` -never runs, and the inherited environment is *not* wholesale cleared; there, only -the explicit `scrub_host_process_env` list (below) plus the `PATH` reset apply, so -security reasoning must not assume unlisted variables are absent on that path. In -both cases the `PATH` string is frozen as `readonly TRUSTED_HOST_PATH` and +when the shebang is honored.** When the launcher is instead invoked directly by an +interpreter the shebang is bypassed, `env -i` never runs, and only the explicit +`scrub_host_process_env` list (below) plus the `PATH` reset apply — and the two +such forms differ on startup-file vectors: + +- `/bin/bash -p scripts/workcell` (the privileged form the repo's own + verify-invariants and publish-pr harnesses use) ignores `BASH_ENV`/`ENV`, so + those vectors are inert even before the scrub. +- plain `bash scripts/workcell` (no `-p`) **processes `BASH_ENV` before the script + body runs**, so a `BASH_ENV` startup-file injection has already executed by the + time `scrub_host_process_env` unsets the variable — the scrub is *not* a defence + against it on that path. + +Either way, security reasoning must not assume unlisted variables are absent on a +shebang-bypassed launch. In both cases the `PATH` string is frozen as +`readonly TRUSTED_HOST_PATH` and re-exported as `PATH` (`scripts/workcell:5-6`), and it is the `PATH` handed to every sanitised host command via `env -i PATH="${TRUSTED_HOST_PATH}"` (`host-exec.sh:49,77`). @@ -171,7 +180,7 @@ than the gate that decides whether it is **honoured** (see their rows). | `WORKCELL_VERIFY_INVARIANTS_SANITIZED_ENTRYPOINT` | Two distinct effects: (a) at startup its presence (`=1`) makes `scrub_host_process_env` **skip unsetting** the four support-matrix vars; (b) it is one condition the detectors check before honouring those overrides. | Effect (a) is gated **only** on the `=1` marker (`workcell:12-17`) — *not* parent-verified. Effect (b) additionally requires `support_matrix_host_override_allowed` to confirm a recognised validation-harness parent (`host-detect.sh`). | | `WORKCELL_TEST_SUPPORT_MATRIX_HOST_OS` / `_ARCH` / `_DISTRO` / `_DISTRO_VERSION` | Forces the detected host OS / arch / distro / distro version. | **Unset at startup only when the `SANITIZED_ENTRYPOINT` marker is absent** (`workcell:12-17`); with the marker set they survive startup even under a non-harness parent, but the detectors **honour** them only when `support_matrix_host_override_allowed` returns `0` (marker **and** recognised parent). So on the shebang-bypassed `bash scripts/workcell` path they can be *present but ignored*, not guaranteed absent. | | `WORKCELL_TEST_CODEX_AUTH_FILE` | Reserved harness variable name — **rejected legacy input**, not a credential override: no launcher resolver consumes it (the Codex resolver no longer recognises it; see `docs/security/`). | If non-empty in the launcher's own environment the launcher **refuses to run**: `exit 2`, "reserved for the Workcell test harness" (`workcell:8150-8152`). | -| `WORKCELL_TEST_CLAUDE_KEYCHAIN_EXPORT_FILE` | Reserved harness variable name; not consumed as a credential override by the launcher. | Same reject-on-presence guard: `exit 2` (`workcell:8153-8156`). | +| `WORKCELL_TEST_CLAUDE_KEYCHAIN_EXPORT_FILE` | **Dual role.** An *operator-supplied* value is rejected; but the launcher's own self-staging probe (`--synthetic-claude-export`, `workcell:5053`) sets this var **internally** so the Claude credential resolver materialises the synthetic export (`internal/injection/prepare_bundle.go:434`, `internal/authresolve/resolve_provider_credentials.go:16`). | Reject-on-presence guard for *inherited* operator input: `exit 2` if set in the launcher's own env (`workcell:8403-8404`). The internal staging happens later, inside bundle prep, after that guard. | | `WORKCELL_TEST_FAIL_AFTER_PROFILE_REFRESH` | Would request the mid-refresh fault injection. | The env variable is **unconditionally unset** at startup (`workcell:11`). The fault is reachable only through the hidden CLI flag `--test-fail-after-profile-refresh`, which sets the internal `TEST_FAIL_AFTER_PROFILE_REFRESH=1` (`workcell:7883-7884`) and triggers `exit 88`. | These gates are **harness conventions, not a security boundary against a local From 2ae97a2f8955e7712a212df645023e0d7b001d15 Mon Sep 17 00:00:00 2001 From: Omkhar Arasaratnam Date: Sun, 5 Jul 2026 17:08:38 -0400 Subject: [PATCH 11/14] ^d Correct docker's resolver contract in launcher-contract.md (P2) (the docker row claimed absolute resolve_host_tool resolution, but in the main launch path HOST_DOCKER_BIN is set to the bare name docker (workcell:8385) and run on TRUSTED_HOST_PATH via run_workcell_docker_client_command; resolve_host_tool_optional is only a probe/fallback in specific sub-paths; regrouped docker with the by-name-on-TRUSTED_HOST_PATH tools (git/curl) rather than the canonical-target-validated resolvers (colima, git's HOST_GIT_BIN)) (markdownlint green, doc-only; corrects a security-contract overstatement) --- docs/launcher-contract.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/launcher-contract.md b/docs/launcher-contract.md index 9d3c259a..f8a33f83 100644 --- a/docs/launcher-contract.md +++ b/docs/launcher-contract.md @@ -42,19 +42,20 @@ resolvers, both of which abort the launch (printing `Missing trusted host tool: | --- | --- | --- | --- | | `go` | `resolve_fixed_host_tool` (`go-hostutil.sh:29`) | `/opt/homebrew/bin/go`, `/usr/local/go/bin/go`, `/usr/local/bin/go`, `/usr/bin/go` | Runs the `workcell-hostutil` and `workcell-colimautil` Go programs via `go run` (see `HOST_GO_BIN`). | | `colima` | `resolve_host_tool` (`workcell:625,670,5492`); `resolve_host_tool_optional` probe (`workcell:6713`) | `/opt/homebrew/bin/colima`, `/usr/local/bin/colima` | Manages the Colima VM profile that backs the container runtime (`HOST_COLIMA_BIN`). | -| `docker` | `resolve_host_tool` (`workcell:866,3654,3722,3786,4028,4215`); `resolve_host_tool_optional` probe (`workcell:850,1663,6721`) | `/opt/homebrew/bin/docker`, `/usr/local/bin/docker`, `/Applications/Docker.app/Contents/Resources/bin/docker` | Drives the container runtime (`HOST_DOCKER_BIN`). | +| `docker` | In the main launch path `HOST_DOCKER_BIN` is set to the **bare name `docker`** (`workcell:8385`) and run on `TRUSTED_HOST_PATH` via `run_workcell_docker_client_command` (`workcell:1895`); specific sub-paths instead resolve it with `resolve_host_tool_optional` (`workcell:1099,1912`) | `/opt/homebrew/bin/docker`, `/usr/local/bin/docker`, `/Applications/Docker.app/Contents/Resources/bin/docker` (only when a sub-path resolves it) | Drives the container runtime. | | `git` | `resolve_host_tool` → absolute `HOST_GIT_BIN` (`workcell:4462,8135`); **also** invoked by name via `run_clean_host_command` (`workcell:7092,7163,7190,7213,7232,7372`) | `/usr/bin/git`, `/opt/homebrew/bin/git`, `/usr/local/bin/git` | Inspects and extracts the launch workspace repository. | | `curl` | **sanitised-PATH by name only** (no absolute resolve) via `run_clean_host_command` (`workcell:6241,6270`) | found on `TRUSTED_HOST_PATH` inside the sanitised `env -i` | Best-effort `HEAD` preflight for release URLs; **optional** — absence is swallowed (`\|\| true`), not fatal. | A resolver-backed tool that is absent aborts the launch rather than falling back -to an attacker-controlled binary — but the two resolvers differ in strength, as -above: `resolve_host_tool` (`colima`, `docker`, `git`'s `HOST_GIT_BIN`) validates -the canonical target, while `resolve_fixed_host_tool` (`go`) trusts its literal -fixed paths. The `run_clean_host_command ` invocations of `git` and `curl` -instead look the name up on the sanitised `TRUSTED_HOST_PATH` at call time: still -confined to the trusted `PATH`, but **not** absolute-resolved and **not** -fail-closed — a missing `curl` leaves the preflight URL empty rather than raising -`Missing trusted host tool`, so `curl` is optional, not required. +to an attacker-controlled binary — but the resolvers differ in strength, as above: +`resolve_host_tool` (`colima`, and `git`'s `HOST_GIT_BIN`) validates the canonical +target, while `resolve_fixed_host_tool` (`go`) trusts its literal fixed paths. +Several tools are instead run **by name on the sanitised `TRUSTED_HOST_PATH`** +rather than absolute-resolved: `git` and `curl` via `run_clean_host_command`, and +`docker` in the main launch path (`HOST_DOCKER_BIN="docker"`). These stay confined +to the trusted `PATH` but are **not** canonical-target-validated and **not** +fail-closed — e.g. a missing `curl` leaves the preflight URL empty rather than +raising `Missing trusted host tool`, so `curl` is optional, not required. `go` additionally has a **second, differently-scoped** resolution path: the `--audit-transcript` PTY helper (`scripts/lib/pty_transcript`, `workcell:8778`) From d21a7ca35f6093d45ac6e1fe41d4425acdee7364 Mon Sep 17 00:00:00 2001 From: Omkhar Arasaratnam Date: Sun, 5 Jul 2026 17:14:25 -0400 Subject: [PATCH 12/14] ^d Make the required-tools lead-in consistent with docker's by-name resolution (P2) (the previous commit corrected docker's row and the resolver summary but left the section lead-in still listing docker among tools resolved to an absolute path and asserting the launcher never trusts a bare PATH lookup; reworded the lead-in so it states every tool is either absolute-resolved (go/colima/git's HOST_GIT_BIN) or run by name on the pinned TRUSTED_HOST_PATH (docker main path, git/curl), matching the rows and notes) (markdownlint green, doc-only; fixes an internal inconsistency) --- docs/launcher-contract.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/launcher-contract.md b/docs/launcher-contract.md index f8a33f83..ba47fb1d 100644 --- a/docs/launcher-contract.md +++ b/docs/launcher-contract.md @@ -20,10 +20,14 @@ recognises. Every claim here is derived from `scripts/workcell`, ### Required host tools -The launcher never trusts a bare `PATH` lookup for a host binary. Its core tools -(`go`, `colima`, `docker`) are resolved to an absolute path through one of two -resolvers, both of which abort the launch (printing `Missing trusted host tool: -` to stderr and exiting `1`) when no trusted candidate is executable: +The launcher avoids bare, unpinned `PATH` lookups for host binaries: every host +tool is either absolute-resolved or run by name on the pinned `TRUSTED_HOST_PATH` +(never an inherited `PATH`). Some tools (`go`, `colima`, and `git`'s +`HOST_GIT_BIN`) are absolute-resolved through one of two resolvers, both of which +abort the launch (printing `Missing trusted host tool: ` to stderr and +exiting `1`) when no trusted candidate is executable; others (`docker` in the main +path, and `git`/`curl` via `run_clean_host_command`) are run by name on +`TRUSTED_HOST_PATH`, as the rows and notes below detail. The two resolvers are: - `resolve_fixed_host_tool ...` (`scripts/lib/launcher/host-exec.sh`) returns the first caller-supplied candidate From 89da9e7ac374bb7eff757dd5f5aaf4171cfc2842 Mon Sep 17 00:00:00 2001 From: Omkhar Arasaratnam Date: Sun, 5 Jul 2026 17:24:43 -0400 Subject: [PATCH 13/14] ^d Correct stale scripts/workcell line citations and scope the tool table by target (P2/P3) (my earlier docker/keychain edits cited a pre-D4 tree, so scripts/workcell anchors were ~249 lines too high; fixed docker (HOST_DOCKER_BIN=docker at 8136, run_workcell_docker_client_command in trusted-docker-client.sh:151, optional resolves at 850/1663/6721), the keychain self-staging probe (--synthetic-claude-export at 4804) and its reject guard (8154-8156); and scoped the required-tools table to note the resolved set varies by target backend - colima only for TARGET_BACKEND==colima, docker/context for a Docker Desktop target, curl only for the preflight) (markdownlint green, doc-only; auditable-contract accuracy) --- docs/launcher-contract.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/launcher-contract.md b/docs/launcher-contract.md index ba47fb1d..39194892 100644 --- a/docs/launcher-contract.md +++ b/docs/launcher-contract.md @@ -46,7 +46,7 @@ path, and `git`/`curl` via `run_clean_host_command`) are run by name on | --- | --- | --- | --- | | `go` | `resolve_fixed_host_tool` (`go-hostutil.sh:29`) | `/opt/homebrew/bin/go`, `/usr/local/go/bin/go`, `/usr/local/bin/go`, `/usr/bin/go` | Runs the `workcell-hostutil` and `workcell-colimautil` Go programs via `go run` (see `HOST_GO_BIN`). | | `colima` | `resolve_host_tool` (`workcell:625,670,5492`); `resolve_host_tool_optional` probe (`workcell:6713`) | `/opt/homebrew/bin/colima`, `/usr/local/bin/colima` | Manages the Colima VM profile that backs the container runtime (`HOST_COLIMA_BIN`). | -| `docker` | In the main launch path `HOST_DOCKER_BIN` is set to the **bare name `docker`** (`workcell:8385`) and run on `TRUSTED_HOST_PATH` via `run_workcell_docker_client_command` (`workcell:1895`); specific sub-paths instead resolve it with `resolve_host_tool_optional` (`workcell:1099,1912`) | `/opt/homebrew/bin/docker`, `/usr/local/bin/docker`, `/Applications/Docker.app/Contents/Resources/bin/docker` (only when a sub-path resolves it) | Drives the container runtime. | +| `docker` | In the main launch path `HOST_DOCKER_BIN` is set to the **bare name `docker`** (`workcell:8136`) and run on `TRUSTED_HOST_PATH` via `run_workcell_docker_client_command` (`scripts/lib/trusted-docker-client.sh:151`); specific sub-paths instead resolve it with `resolve_host_tool_optional` (`workcell:850,1663,6721`) | `/opt/homebrew/bin/docker`, `/usr/local/bin/docker`, `/Applications/Docker.app/Contents/Resources/bin/docker` (only when a sub-path resolves it) | Drives the container runtime. | | `git` | `resolve_host_tool` → absolute `HOST_GIT_BIN` (`workcell:4462,8135`); **also** invoked by name via `run_clean_host_command` (`workcell:7092,7163,7190,7213,7232,7372`) | `/usr/bin/git`, `/opt/homebrew/bin/git`, `/usr/local/bin/git` | Inspects and extracts the launch workspace repository. | | `curl` | **sanitised-PATH by name only** (no absolute resolve) via `run_clean_host_command` (`workcell:6241,6270`) | found on `TRUSTED_HOST_PATH` inside the sanitised `env -i` | Best-effort `HEAD` preflight for release URLs; **optional** — absence is swallowed (`\|\| true`), not fatal. | @@ -72,8 +72,12 @@ accepted) and `WORKCELL_GO_BIN` is not in the scrub list, so on the shebang-bypassed `bash` path an inherited `WORKCELL_GO_BIN` can select the `go` used for the transcript. -This table covers the tools every **local** launch resolves; a few prerequisites -sit outside it. Remote-preview backends probe extra tools via +This table covers the host tools a **local** launch may resolve; the exact set +depends on the target backend — `missing_launch_host_tools_csv` probes `colima` +only when `TARGET_BACKEND == "colima"`, whereas a Docker Desktop target +(`--target docker-desktop`) probes Docker/its context instead, and `curl` is used +only by the best-effort release-URL preflight. Beyond per-launch resolution, a few +prerequisites sit outside this table entirely. Remote-preview backends probe extra tools via `missing_launch_host_tools_csv` (`scripts/workcell:6708`) — `aws` and `session-manager-plugin` for the `aws-ec2-ssm` backend, `gcloud` for `gcp-vm`. Image builds require a system `docker-buildx` plugin binary @@ -185,7 +189,7 @@ than the gate that decides whether it is **honoured** (see their rows). | `WORKCELL_VERIFY_INVARIANTS_SANITIZED_ENTRYPOINT` | Two distinct effects: (a) at startup its presence (`=1`) makes `scrub_host_process_env` **skip unsetting** the four support-matrix vars; (b) it is one condition the detectors check before honouring those overrides. | Effect (a) is gated **only** on the `=1` marker (`workcell:12-17`) — *not* parent-verified. Effect (b) additionally requires `support_matrix_host_override_allowed` to confirm a recognised validation-harness parent (`host-detect.sh`). | | `WORKCELL_TEST_SUPPORT_MATRIX_HOST_OS` / `_ARCH` / `_DISTRO` / `_DISTRO_VERSION` | Forces the detected host OS / arch / distro / distro version. | **Unset at startup only when the `SANITIZED_ENTRYPOINT` marker is absent** (`workcell:12-17`); with the marker set they survive startup even under a non-harness parent, but the detectors **honour** them only when `support_matrix_host_override_allowed` returns `0` (marker **and** recognised parent). So on the shebang-bypassed `bash scripts/workcell` path they can be *present but ignored*, not guaranteed absent. | | `WORKCELL_TEST_CODEX_AUTH_FILE` | Reserved harness variable name — **rejected legacy input**, not a credential override: no launcher resolver consumes it (the Codex resolver no longer recognises it; see `docs/security/`). | If non-empty in the launcher's own environment the launcher **refuses to run**: `exit 2`, "reserved for the Workcell test harness" (`workcell:8150-8152`). | -| `WORKCELL_TEST_CLAUDE_KEYCHAIN_EXPORT_FILE` | **Dual role.** An *operator-supplied* value is rejected; but the launcher's own self-staging probe (`--synthetic-claude-export`, `workcell:5053`) sets this var **internally** so the Claude credential resolver materialises the synthetic export (`internal/injection/prepare_bundle.go:434`, `internal/authresolve/resolve_provider_credentials.go:16`). | Reject-on-presence guard for *inherited* operator input: `exit 2` if set in the launcher's own env (`workcell:8403-8404`). The internal staging happens later, inside bundle prep, after that guard. | +| `WORKCELL_TEST_CLAUDE_KEYCHAIN_EXPORT_FILE` | **Dual role.** An *operator-supplied* value is rejected; but the launcher's own self-staging probe (`--synthetic-claude-export`, `workcell:4804`) sets this var **internally** so the Claude credential resolver materialises the synthetic export (`internal/injection/prepare_bundle.go:434`, `internal/authresolve/resolve_provider_credentials.go:16`). | Reject-on-presence guard for *inherited* operator input: `exit 2` if set in the launcher's own env (`workcell:8154-8156`). The internal staging happens later, inside bundle prep, after that guard. | | `WORKCELL_TEST_FAIL_AFTER_PROFILE_REFRESH` | Would request the mid-refresh fault injection. | The env variable is **unconditionally unset** at startup (`workcell:11`). The fault is reachable only through the hidden CLI flag `--test-fail-after-profile-refresh`, which sets the internal `TEST_FAIL_AFTER_PROFILE_REFRESH=1` (`workcell:7883-7884`) and triggers `exit 88`. | These gates are **harness conventions, not a security boundary against a local From acbb65af73d14ae76b70c9d80812e8b072830c76 Mon Sep 17 00:00:00 2001 From: Omkhar Arasaratnam Date: Sun, 5 Jul 2026 17:32:27 -0400 Subject: [PATCH 14/14] ^d Correct docker as fail-closed resolve_host_tool in the main launch path (P2) (my prior edits wrongly documented docker's main path as leaving the bare name docker and not fail-closed; in fact the transient HOST_DOCKER_BIN=docker default (workcell:8136) is overridden by prepare_current_target_docker_client - called at workcell:8352 - which resolves docker via resolve_host_tool (workcell:866, canonical-target-validated, aborts if missing) before any docker use; regrouped docker with the fail-closed resolver tools consistently across the section lead-in, its row, and the closing, leaving only git's run_clean_host_command uses and curl as by-name-on-TRUSTED_HOST_PATH) (markdownlint green, doc-only; restores the correct security contract) --- docs/launcher-contract.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/launcher-contract.md b/docs/launcher-contract.md index 39194892..b3856df8 100644 --- a/docs/launcher-contract.md +++ b/docs/launcher-contract.md @@ -22,12 +22,12 @@ recognises. Every claim here is derived from `scripts/workcell`, The launcher avoids bare, unpinned `PATH` lookups for host binaries: every host tool is either absolute-resolved or run by name on the pinned `TRUSTED_HOST_PATH` -(never an inherited `PATH`). Some tools (`go`, `colima`, and `git`'s +(never an inherited `PATH`). Most tools (`go`, `colima`, `docker`, and `git`'s `HOST_GIT_BIN`) are absolute-resolved through one of two resolvers, both of which abort the launch (printing `Missing trusted host tool: ` to stderr and -exiting `1`) when no trusted candidate is executable; others (`docker` in the main -path, and `git`/`curl` via `run_clean_host_command`) are run by name on -`TRUSTED_HOST_PATH`, as the rows and notes below detail. The two resolvers are: +exiting `1`) when no trusted candidate is executable; a couple (`git` and `curl` +via `run_clean_host_command`) are instead run by name on `TRUSTED_HOST_PATH`, as +the rows and notes below detail. The two resolvers are: - `resolve_fixed_host_tool ...` (`scripts/lib/launcher/host-exec.sh`) returns the first caller-supplied candidate @@ -46,20 +46,20 @@ path, and `git`/`curl` via `run_clean_host_command`) are run by name on | --- | --- | --- | --- | | `go` | `resolve_fixed_host_tool` (`go-hostutil.sh:29`) | `/opt/homebrew/bin/go`, `/usr/local/go/bin/go`, `/usr/local/bin/go`, `/usr/bin/go` | Runs the `workcell-hostutil` and `workcell-colimautil` Go programs via `go run` (see `HOST_GO_BIN`). | | `colima` | `resolve_host_tool` (`workcell:625,670,5492`); `resolve_host_tool_optional` probe (`workcell:6713`) | `/opt/homebrew/bin/colima`, `/usr/local/bin/colima` | Manages the Colima VM profile that backs the container runtime (`HOST_COLIMA_BIN`). | -| `docker` | In the main launch path `HOST_DOCKER_BIN` is set to the **bare name `docker`** (`workcell:8136`) and run on `TRUSTED_HOST_PATH` via `run_workcell_docker_client_command` (`scripts/lib/trusted-docker-client.sh:151`); specific sub-paths instead resolve it with `resolve_host_tool_optional` (`workcell:850,1663,6721`) | `/opt/homebrew/bin/docker`, `/usr/local/bin/docker`, `/Applications/Docker.app/Contents/Resources/bin/docker` (only when a sub-path resolves it) | Drives the container runtime. | +| `docker` | Fail-closed `resolve_host_tool`: a transient `HOST_DOCKER_BIN="docker"` default (`workcell:8136`) is **overridden** by `prepare_current_target_docker_client` (called in the main launch path at `workcell:8352`), which resolves it to an absolute, canonical-validated path (`resolve_host_tool docker`, `workcell:866`) before any Docker use; other sub-paths probe with `resolve_host_tool_optional` (`workcell:850,1663,6721`) | `/opt/homebrew/bin/docker`, `/usr/local/bin/docker`, `/Applications/Docker.app/Contents/Resources/bin/docker` | Drives the container runtime (`HOST_DOCKER_BIN`), run via `run_workcell_docker_client_command` (`trusted-docker-client.sh:151`). | | `git` | `resolve_host_tool` → absolute `HOST_GIT_BIN` (`workcell:4462,8135`); **also** invoked by name via `run_clean_host_command` (`workcell:7092,7163,7190,7213,7232,7372`) | `/usr/bin/git`, `/opt/homebrew/bin/git`, `/usr/local/bin/git` | Inspects and extracts the launch workspace repository. | | `curl` | **sanitised-PATH by name only** (no absolute resolve) via `run_clean_host_command` (`workcell:6241,6270`) | found on `TRUSTED_HOST_PATH` inside the sanitised `env -i` | Best-effort `HEAD` preflight for release URLs; **optional** — absence is swallowed (`\|\| true`), not fatal. | A resolver-backed tool that is absent aborts the launch rather than falling back to an attacker-controlled binary — but the resolvers differ in strength, as above: -`resolve_host_tool` (`colima`, and `git`'s `HOST_GIT_BIN`) validates the canonical -target, while `resolve_fixed_host_tool` (`go`) trusts its literal fixed paths. -Several tools are instead run **by name on the sanitised `TRUSTED_HOST_PATH`** -rather than absolute-resolved: `git` and `curl` via `run_clean_host_command`, and -`docker` in the main launch path (`HOST_DOCKER_BIN="docker"`). These stay confined -to the trusted `PATH` but are **not** canonical-target-validated and **not** -fail-closed — e.g. a missing `curl` leaves the preflight URL empty rather than -raising `Missing trusted host tool`, so `curl` is optional, not required. +`resolve_host_tool` (`colima`, `docker`, and `git`'s `HOST_GIT_BIN`) validates the +canonical target, while `resolve_fixed_host_tool` (`go`) trusts its literal fixed +paths. Two tools are instead run **by name on the sanitised `TRUSTED_HOST_PATH`** +rather than absolute-resolved: `git` (in its `run_clean_host_command` uses) and +`curl`. These stay confined to the trusted `PATH` but are **not** +canonical-target-validated and **not** fail-closed — e.g. a missing `curl` leaves +the preflight URL empty rather than raising `Missing trusted host tool`, so `curl` +is optional, not required. `go` additionally has a **second, differently-scoped** resolution path: the `--audit-transcript` PTY helper (`scripts/lib/pty_transcript`, `workcell:8778`)