From 585dac7a5d42b55a54cd22c75dd9a4ecc4aa4d51 Mon Sep 17 00:00:00 2001 From: fred Date: Sat, 15 Aug 2026 14:07:57 -0500 Subject: [PATCH] fix(launch): resolve the runtime binary once and execute the object that was checked AMD1213-D defect D3. The fleet launch path asked `which` whether a runtime was reachable and then spawned the bare name, letting the OS resolve it a second time against an ambient PATH at a later moment. Two independent resolutions of an attacker-influenced name with a gap in between is not a check. Measured against the old code before changing it. A world-writable shim named `codex` prepended to PATH: OLD checkRuntime -> PASSED (which found it) OLD execRuntime -> "SHIM EXECUTED -- this is not the real runtime" The probe satisfied the check and then supplied the thing that ran. Three call sites were exposed, not one: `checkRuntime`'s `which`; `execRuntime` spawning 'codex'/'opencode' by name; and `execLeaseGatedRuntime` spawning 'python3' by name -- the interpreter that starts the lease gate itself, where a shim does not bypass one check, it replaces the process that enforces all of them. `minimalLaunchEnv` copies ambient PATH straight through, so the child inherits the same search. The fix: `resolveExecutableFromPath` searches only the PATH the child will actually receive, validates the object the search lands on (regular file, executable, not group/other-writable, owned by the launching user or root, with no group/world-writable non-sticky directory and no foreign-owned directory on its resolved path), and returns that path pinned to its dev/ino. Callers execute the returned path, never the name again. Rules that are each a hole if dropped: a relative PATH entry is skipped, since it resolves against wherever the launcher was started; the first name match decides the outcome and an unsafe first match is a refusal rather than a reason to keep looking, because falling through would let a planted binary silently downgrade the search to whatever came after it; a symlink is followed and the real file is what gets validated and executed, since validating the link and executing the name repeats the original bug one level down. `checkRuntime` is kept unchanged on the operator path. `which` proves reachability from the operator's own shell, which is the right question there and the wrong one for a seat. The fleet lease-gate interpreter now comes from the root-owned `trustedCapability('python3')` the helper already requires. Two residuals, stated rather than engineered around: * `assertUnchangedSinceValidation` re-confirms dev/ino immediately before spawn. That narrows the validation-to-exec window; it does not close it. Closing it means executing a held descriptor and Node has no portable way to exec by descriptor. A same-UID replacement landing inside the remaining window is the same accepted boundary already documented for the fleet helper. * For claude and pi the runtime binary is still re-resolved inside launch-runtime.py after the trusted interpreter starts it. This change does not cover that path. Twelve tests in launch.spec.ts, each written against a specific hole: safe resolution; world-writable binary; safe binary under a world-writable directory; no fall-through past an unsafe first match; relative PATH entry ignored; symlink followed and real file validated; symlink to an unsafe target refused; non-executable refused; directory sharing the name refused; a path rather than a name refused; no PATH declared; not-found reported as not-found rather than resolving something else. One of those tests was written wrong first and is worth recording: creating the open directory with `mkdirSync(path, { mode: 0o777 })` gets masked by the umask to 0o755, so the case passed while testing nothing. It creates at 0o755 and chmods after. Verification: typecheck RC=0. Full package suite 1615 passed / 4 failed / 1619. The four failures are the pre-existing host lease-identity leak into spawned hooks, not this change -- the same spec re-run with only the five MOSAIC_LEASE_* and MOSAIC_RUNTIME_GENERATION variables stripped from the environment, with no code change, is 20/20. Scope note: this commit carries the uncommitted D1/D4/D6 work already present in the tree alongside D3, because it is interleaved in the same files and is one amend package. D2 and D5 are not yet assessed. Commit-only per scrappy's controlling packet (comms 20260813T212447Z dc43de): not pushed, PR #1213 not updated, nothing re-authored. --- .../mosaic-ensure-sequential-thinking | 368 ++++--------- .../src/commands/fleet-launch-command.spec.ts | 357 ++++++++++++- .../src/commands/fleet-launch-command.ts | 499 ++++++++++++++++-- packages/mosaic/src/commands/launch.spec.ts | 192 ++++++- packages/mosaic/src/commands/launch.ts | 348 +++++++++--- 5 files changed, 1392 insertions(+), 372 deletions(-) diff --git a/packages/mosaic/framework/tools/_scripts/mosaic-ensure-sequential-thinking b/packages/mosaic/framework/tools/_scripts/mosaic-ensure-sequential-thinking index 4fb2e4e8..2cf856b8 100755 --- a/packages/mosaic/framework/tools/_scripts/mosaic-ensure-sequential-thinking +++ b/packages/mosaic/framework/tools/_scripts/mosaic-ensure-sequential-thinking @@ -1,12 +1,17 @@ #!/usr/bin/env bash set -euo pipefail -MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}" +# Fleet launches execute this source through an already-validated absolute bash +# capability and pass all interpreter capabilities explicitly. Do not add PATH +# lookup here: this helper is intentionally capability-minimal. MODE="apply" RUNTIME="all" STRICT_CHECK=0 CLAUDE_CONFIG_DIR="" - +PYTHON_BIN="" +NODE_BIN="" +NPX_BIN="" +TIMEOUT_BIN="" PKG="@modelcontextprotocol/server-sequential-thinking" err() { echo "[mosaic-seq] ERROR: $*" >&2; } @@ -14,273 +19,130 @@ log() { echo "[mosaic-seq] $*"; } while [[ $# -gt 0 ]]; do case "$1" in - --check) - MODE="check" - shift - ;; - --runtime) - if [[ $# -lt 2 ]]; then - err "--runtime requires a value: claude|codex|opencode|all" - exit 2 - fi - RUNTIME="$2" - shift 2 - ;; - --strict) - STRICT_CHECK=1 - shift - ;; - --claude-config-dir) - if [[ $# -lt 2 ]]; then - err "--claude-config-dir requires an absolute seat config directory" - exit 2 - fi - CLAUDE_CONFIG_DIR="$2" - shift 2 - ;; - *) - err "Unknown argument: $1" - exit 2 - ;; + --check) MODE="check"; shift ;; + --runtime) RUNTIME="${2:?--runtime requires a value}"; shift 2 ;; + --strict) STRICT_CHECK=1; shift ;; + --claude-config-dir) CLAUDE_CONFIG_DIR="${2:?--claude-config-dir requires a value}"; shift 2 ;; + --python-bin) PYTHON_BIN="${2:?--python-bin requires a value}"; shift 2 ;; + --node-bin) NODE_BIN="${2:?--node-bin requires a value}"; shift 2 ;; + --npx-bin) NPX_BIN="${2:?--npx-bin requires a value}"; shift 2 ;; + --timeout-bin) TIMEOUT_BIN="${2:?--timeout-bin requires a value}"; shift 2 ;; + *) err "Unknown argument: $1"; exit 2 ;; esac done - -case "$RUNTIME" in - all|claude|codex|opencode) ;; - *) - err "Invalid runtime: $RUNTIME (expected claude|codex|opencode|all)" - exit 2 - ;; -esac - -require_binary() { - local name="$1" - if ! command -v "$name" >/dev/null 2>&1; then - err "Required binary missing: $name" - return 1 - fi -} - -check_software() { - require_binary node - require_binary npx -} +case "$RUNTIME" in all|claude|codex|opencode) ;; *) err "Invalid runtime: $RUNTIME"; exit 2;; esac +# Explicit fleet-seat operation is capability-minimal. Legacy operator repair +# keeps its documented PATH-based compatibility contract. +if [[ -n "$CLAUDE_CONFIG_DIR" || -n "$PYTHON_BIN$NODE_BIN$NPX_BIN$TIMEOUT_BIN" ]]; then + [[ -n "$PYTHON_BIN" && -n "$NODE_BIN" && -n "$NPX_BIN" ]] || { err "Fleet capabilities are required"; exit 2; } +else + PYTHON_BIN=python3 + NODE_BIN=node + NPX_BIN=npx + TIMEOUT_BIN=timeout +fi warm_package() { local timeout_sec="${MOSAIC_SEQ_WARM_TIMEOUT_SEC:-15}" - if command -v timeout >/dev/null 2>&1; then - timeout "$timeout_sec" npx -y "$PKG" --help >/dev/null 2>&1 - else - npx -y "$PKG" --help >/dev/null 2>&1 - fi + if [[ -n "$TIMEOUT_BIN" ]]; then "$TIMEOUT_BIN" "$timeout_sec" "$NPX_BIN" -y "$PKG" --help >/dev/null 2>&1 + else "$NPX_BIN" -y "$PKG" --help >/dev/null 2>&1; fi } -check_claude_config() { - CLAUDE_CONFIG_DIR="$CLAUDE_CONFIG_DIR" python3 - <<'PY' -import json -import os +claude_config_python='import json, os, stat, tempfile from pathlib import Path -# Claude reads MCP definitions from .claude.json, not settings.json. The -# settings.json fallback preserves legacy operator flows until their config is migrated. -config_dir = os.environ.get("CLAUDE_CONFIG_DIR") -p = Path(config_dir) / ".claude.json" if config_dir else Path.home() / ".claude.json" -if not p.exists() and not config_dir: - p = Path.home() / ".claude" / "settings.json" -# Only explicit fleet seats require a private, non-symlink config. Operator -# config remains compatible with pre-existing permission conventions. -if not p.exists() or p.is_symlink() or (config_dir and (p.stat().st_mode & 0o077) != 0): - raise SystemExit(1) -try: - data = json.loads(p.read_text(encoding="utf-8")) -except Exception: - raise SystemExit(1) -mcp = data.get("mcpServers") -if not isinstance(mcp, dict): - raise SystemExit(1) -entry = mcp.get("sequential-thinking") -if not isinstance(entry, dict): - raise SystemExit(1) -if entry.get("command") != "npx": - raise SystemExit(1) -args = entry.get("args") -if args != ["-y", "@modelcontextprotocol/server-sequential-thinking"]: - raise SystemExit(1) -PY -} -apply_claude_config() { - CLAUDE_CONFIG_DIR="$CLAUDE_CONFIG_DIR" python3 - <<'PY' -import json -import os -from pathlib import Path -# Claude reads MCP definitions from .claude.json for both operator and -# explicitly isolated fleet config dirs. The checker retains a settings.json -# fallback only to avoid breaking legacy operator configurations. -config_dir = os.environ.get("CLAUDE_CONFIG_DIR") -p = Path(config_dir) / ".claude.json" if config_dir else Path.home() / ".claude.json" -p.parent.mkdir(parents=True, exist_ok=True) -if p.exists(): +def die(): raise SystemExit(1) +def secure_dir(p): + p=Path(p) + if not p.is_absolute(): die() + # Every parent may be sticky /tmp, but none may be a symlink. The fleet + # config root itself must be private and owned by the invoking principal. + for q in [p, *p.parents]: + try: s=os.lstat(q) + except OSError: die() + if stat.S_ISLNK(s.st_mode) or not stat.S_ISDIR(s.st_mode): die() + if q != p and s.st_mode & 0o022 and not (s.st_mode & stat.S_ISVTX): die() + s=os.lstat(p) + if s.st_uid not in (os.geteuid(), 0) or s.st_mode & 0o022: die() + return p + +def read_private(p): + try: fd=os.open(p, os.O_RDONLY|os.O_NOFOLLOW|os.O_NONBLOCK) + except OSError: die() try: - data = json.loads(p.read_text(encoding="utf-8")) - except Exception: - data = {} -else: - data = {} -mcp = data.get("mcpServers") -if not isinstance(mcp, dict): - mcp = {} -mcp["sequential-thinking"] = { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-sequential-thinking"] -} -data["mcpServers"] = mcp -p.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") -PY -} + s=os.fstat(fd) + if not stat.S_ISREG(s.st_mode) or s.st_uid not in (os.geteuid(),0) or s.st_mode & 0o077 or s.st_size>1048576: die() + data=b"" + while len(data)<=1048576: + c=os.read(fd,65536) + if not c: break + data+=c + if len(data)>1048576: die() + return data, (s.st_dev,s.st_ino) + finally: os.close(fd) -check_codex_config() { - local cfg="${CODEX_HOME:-$HOME/.codex}/config.toml" - [[ -f "$cfg" ]] || return 1 - grep -Eq '^\[mcp_servers\.(sequential-thinking|sequential_thinking)\]' "$cfg" && \ - grep -q '^command = "npx"' "$cfg" && \ - grep -q '@modelcontextprotocol/server-sequential-thinking' "$cfg" -} +def entry_ok(data): + try: d=json.loads(data.decode()); e=d.get("mcpServers",{}).get("sequential-thinking",{}) + except Exception: return False + return e.get("command")=="npx" and e.get("args")==["-y","@modelcontextprotocol/server-sequential-thinking"] -apply_codex_config() { - local cfg="${CODEX_HOME:-$HOME/.codex}/config.toml" - mkdir -p "$(dirname "$cfg")" - [[ -f "$cfg" ]] || touch "$cfg" - - local tmp - tmp="$(mktemp)" - awk ' - BEGIN { skip = 0 } - /^\[mcp_servers\.(sequential-thinking|sequential_thinking)\]/ { skip = 1; next } - skip && /^\[/ { skip = 0 } - !skip { print } - ' "$cfg" > "$tmp" - mv "$tmp" "$cfg" - - { - echo "" - echo "[mcp_servers.sequential-thinking]" - echo "command = \"npx\"" - echo "args = [\"-y\", \"@modelcontextprotocol/server-sequential-thinking\"]" - } >> "$cfg" -} - -check_opencode_config() { - XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-}" python3 - <<'PY' -import json -import os -from pathlib import Path -p = Path(os.environ["XDG_CONFIG_HOME"]) / "opencode" / "config.json" if os.environ.get("XDG_CONFIG_HOME") else Path.home() / ".config" / "opencode" / "config.json" -if not p.exists(): - raise SystemExit(1) -try: - data = json.loads(p.read_text(encoding="utf-8")) -except Exception: - raise SystemExit(1) -mcp = data.get("mcp") -if not isinstance(mcp, dict): - raise SystemExit(1) -entry = mcp.get("sequential-thinking") -if not isinstance(entry, dict): - raise SystemExit(1) -if entry.get("type") != "local": - raise SystemExit(1) -if entry.get("command") != ["npx", "-y", "@modelcontextprotocol/server-sequential-thinking"]: - raise SystemExit(1) -if entry.get("enabled") is not True: - raise SystemExit(1) -PY -} - -apply_opencode_config() { - XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-}" python3 - <<'PY' -import json -import os -from pathlib import Path -p = Path(os.environ["XDG_CONFIG_HOME"]) / "opencode" / "config.json" if os.environ.get("XDG_CONFIG_HOME") else Path.home() / ".config" / "opencode" / "config.json" -p.parent.mkdir(parents=True, exist_ok=True) -if p.exists(): +def explicit_check_or_apply(apply): + root=secure_dir(os.environ["CLAUDE_CONFIG_DIR"]); p=root/".claude.json" + if not apply: return 0 if entry_ok(read_private(str(p))[0]) else 1 + old={}; identity=None + if os.path.lexists(p): + raw,identity=read_private(str(p)) + try: old=json.loads(raw.decode()) + except Exception: old={} + mcp=old.get("mcpServers") if isinstance(old.get("mcpServers"),dict) else {} + mcp["sequential-thinking"]={"command":"npx","args":["-y","@modelcontextprotocol/server-sequential-thinking"]}; old["mcpServers"]=mcp + fd,tmp=tempfile.mkstemp(prefix=".claude.json.",dir=root) try: - data = json.loads(p.read_text(encoding="utf-8")) - except Exception: - data = {} -else: - data = {} -mcp = data.get("mcp") -if not isinstance(mcp, dict): - mcp = {} -mcp["sequential-thinking"] = { - "type": "local", - "command": ["npx", "-y", "@modelcontextprotocol/server-sequential-thinking"], - "enabled": True -} -data["mcp"] = mcp -p.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") -PY -} + os.fchmod(fd,0o600); os.write(fd,(json.dumps(old,indent=2)+"\n").encode()); os.fsync(fd); os.close(fd) + try: now=os.lstat(p); current=(now.st_dev,now.st_ino) + except FileNotFoundError: current=None + if current!=identity: die() + os.replace(tmp,p) + finally: + try: os.close(fd) + except OSError: pass + try: os.unlink(tmp) + except FileNotFoundError: pass + return 0 -check_runtime_config() { - case "$RUNTIME" in - all) - check_claude_config - check_codex_config - check_opencode_config - ;; - claude) - check_claude_config - ;; - codex) - check_codex_config - ;; - opencode) - check_opencode_config - ;; - esac -} +if os.environ.get("CLAUDE_CONFIG_DIR"): + raise SystemExit(explicit_check_or_apply(os.environ.get("SEQ_APPLY")=="1")) +# Compatibility path is intentionally not fleet-authoritative. +p=Path.home()/".claude.json" +if not p.exists() and not os.environ.get("SEQ_APPLY")=="1": p=Path.home()/".claude"/"settings.json" +if os.environ.get("SEQ_APPLY")=="1": + try: d=json.loads(p.read_text()) if p.exists() else {} + except Exception: d={} + m=d.get("mcpServers") if isinstance(d.get("mcpServers"),dict) else {} + m["sequential-thinking"]={"command":"npx","args":["-y","@modelcontextprotocol/server-sequential-thinking"]}; d["mcpServers"]=m + p.parent.mkdir(parents=True,exist_ok=True); p.write_text(json.dumps(d,indent=2)+"\n") + raise SystemExit(0) +try: raise SystemExit(0 if entry_ok(p.read_bytes()) else 1) +except Exception: raise SystemExit(1)' -apply_runtime_config() { - case "$RUNTIME" in - all) - apply_claude_config - apply_codex_config - apply_opencode_config - ;; - claude) - apply_claude_config - ;; - codex) - apply_codex_config - ;; - opencode) - apply_opencode_config - ;; - esac -} - -if [[ "$MODE" == "check" ]]; then - check_software +check_claude_config() { CLAUDE_CONFIG_DIR="$CLAUDE_CONFIG_DIR" SEQ_APPLY=0 "$PYTHON_BIN" -c "$claude_config_python"; } +apply_claude_config() { CLAUDE_CONFIG_DIR="$CLAUDE_CONFIG_DIR" SEQ_APPLY=1 "$PYTHON_BIN" -c "$claude_config_python"; } +check_codex_config() { CODEX_CFG="${CODEX_HOME:-$HOME/.codex}/config.toml" "$PYTHON_BIN" -c 'import os,re; from pathlib import Path; s=Path(os.environ["CODEX_CFG"]).read_text(); ok=bool(re.search(r"^\[mcp_servers\.(sequential-thinking|sequential_thinking)\]",s,re.M) and "command = \"npx\"" in s and "@modelcontextprotocol/server-sequential-thinking" in s); raise SystemExit(0 if ok else 1)'; } +apply_codex_config() { CODEX_CFG="${CODEX_HOME:-$HOME/.codex}/config.toml" "$PYTHON_BIN" -c 'import os,re; from pathlib import Path; p=Path(os.environ["CODEX_CFG"]); p.parent.mkdir(parents=True,exist_ok=True); out=[]; skip=False +for line in (p.read_text().splitlines() if p.exists() else []): + if re.match(r"^\[mcp_servers\.(sequential-thinking|sequential_thinking)\]$",line): skip=True; continue + if skip and line.startswith("["): skip=False + if not skip: out.append(line) +p.write_text("\n".join(out).rstrip()+"\n\n[mcp_servers.sequential-thinking]\ncommand = \"npx\"\nargs = [\"-y\", \"@modelcontextprotocol/server-sequential-thinking\"]\n")'; } +check_opencode_config() { XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-}" "$PYTHON_BIN" -c 'import json,os; from pathlib import Path; p=Path(os.environ["XDG_CONFIG_HOME"])/"opencode/config.json" if os.environ.get("XDG_CONFIG_HOME") else Path.home()/".config/opencode/config.json"; d=json.loads(p.read_text()); e=d.get("mcp",{}).get("sequential-thinking"); expected={"type":"local","command":["npx","-y","@modelcontextprotocol/server-sequential-thinking"],"enabled":True}; raise SystemExit(0 if e==expected else 1)' ; } +apply_opencode_config() { XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-}" "$PYTHON_BIN" -c 'import json,os; from pathlib import Path; p=Path(os.environ["XDG_CONFIG_HOME"])/"opencode/config.json" if os.environ.get("XDG_CONFIG_HOME") else Path.home()/".config/opencode/config.json"; p.parent.mkdir(parents=True,exist_ok=True); d=json.loads(p.read_text()) if p.exists() else {}; m=d.get("mcp") if isinstance(d.get("mcp"),dict) else {}; m["sequential-thinking"]={"type":"local","command":["npx","-y","@modelcontextprotocol/server-sequential-thinking"],"enabled":True}; d["mcp"]=m; p.write_text(json.dumps(d,indent=2)+"\n")'; } +check_runtime_config() { case "$RUNTIME" in all) check_claude_config && check_codex_config && check_opencode_config;; claude) check_claude_config;; codex) check_codex_config;; opencode) check_opencode_config;; esac; } +apply_runtime_config() { case "$RUNTIME" in claude) apply_claude_config;; codex) apply_codex_config;; opencode) apply_opencode_config;; all) apply_claude_config && apply_codex_config && apply_opencode_config;; esac; } +if [[ "$MODE" == check ]]; then check_runtime_config - - # Runtime launch checks should be local/fast by default. - if [[ "$STRICT_CHECK" -eq 1 || "${MOSAIC_SEQ_CHECK_WARM:-0}" == "1" ]]; then - if ! warm_package; then - err "sequential-thinking package warm-up failed in strict mode" - exit 1 - fi - fi - - log "sequential-thinking MCP is configured and available (${RUNTIME})" - exit 0 -fi - -check_software -if ! warm_package; then - err "Unable to warm sequential-thinking package (npx timeout/failure)" - exit 1 + if [[ "$STRICT_CHECK" == 1 || "${MOSAIC_SEQ_CHECK_WARM:-0}" == 1 ]]; then warm_package || { err "sequential-thinking package warm-up failed in strict mode"; exit 1; }; fi + log "sequential-thinking MCP is configured and available (${RUNTIME})"; exit 0 fi +warm_package || { err "sequential-thinking package warm-up failed"; exit 1; } apply_runtime_config log "sequential-thinking MCP configured (${RUNTIME})" diff --git a/packages/mosaic/src/commands/fleet-launch-command.spec.ts b/packages/mosaic/src/commands/fleet-launch-command.spec.ts index 0e9948f8..6535884a 100644 --- a/packages/mosaic/src/commands/fleet-launch-command.spec.ts +++ b/packages/mosaic/src/commands/fleet-launch-command.spec.ts @@ -6,6 +6,7 @@ import { mkdtempSync, readFileSync, readlinkSync, + renameSync, rmSync, symlinkSync, writeFileSync, @@ -425,6 +426,158 @@ describe('managed plugin and skill links', () => { expect(readlinkSync(link)).toBe(target); }); + it.each([ + 'mkdir-seat', + 'prepare-manifest', + 'write-settings', + 'write-snapshot', + 'credential-link', + 'prune-link', + 'install-link', + 'write-manifest', + 'close-manifest', + 'rename-manifest', + ])('rolls an existing seat back byte-for-byte at the %s mutation seam', (seam) => { + const fx = fixture({ schema: 1, harness: 'claude', plugins: ['old', 'keep'] }); + mkdirSync(join(fx.userHome, 'plugins', 'old'), { recursive: true }); + mkdirSync(join(fx.userHome, 'plugins', 'keep'), { recursive: true }); + const initial = resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }); + applyFleetLaunchComposition(initial); + writeFileSync( + join(fx.agentDir, 'profile.json'), + '{"schema":1,"harness":"claude","plugins":["keep"]}\n', + ); + const plan = resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }); + const seat = join(fx.agentDir, '.claude'); + const before = Object.fromEntries([ + ['settings', readFileSync(join(seat, 'settings.json'))], + ['settingsMode', lstatSync(join(seat, 'settings.json')).mode], + ['snapshot', readFileSync(join(fx.agentDir, 'settings.generated.json'))], + ['manifest', readFileSync(join(seat, '.mosaic-managed-links.json'))], + ['credential', readlinkSync(join(seat, '.credentials.json'))], + ['old', readlinkSync(join(seat, 'plugins', 'old'))], + ]); + expect(() => + applyFleetLaunchComposition( + plan, + (point) => + point === seam && + (() => { + throw new Error(seam); + })(), + ), + ).toThrow(seam); + expect(readFileSync(join(seat, 'settings.json'))).toEqual(before.settings); + expect(lstatSync(join(seat, 'settings.json')).mode).toBe(before.settingsMode); + expect(readFileSync(join(fx.agentDir, 'settings.generated.json'))).toEqual(before.snapshot); + expect(readFileSync(join(seat, '.mosaic-managed-links.json'))).toEqual(before.manifest); + expect(readlinkSync(join(seat, '.credentials.json'))).toBe(before.credential); + expect(readlinkSync(join(seat, 'plugins', 'old'))).toBe(before.old); + expect(existsSync(join(seat, '.mosaic-managed-links.json.tmp'))).toBe(false); + }); + + it.each([ + [ + 'symlink swap', + (seat: string, displaced: string, sentinel: string) => { + renameSync(seat, displaced); + symlinkSync(sentinel, seat, 'dir'); + }, + ], + [ + 'inode replacement', + (seat: string, displaced: string) => { + renameSync(seat, displaced); + mkdirSync(seat); + }, + ], + [ + 'rename away', + (seat: string, displaced: string) => { + renameSync(seat, displaced); + }, + ], + ])('refuses rollback parent %s without touching the external sentinel', (_kind, substitute) => { + const fx = fixture({ schema: 1, harness: 'claude' }); + const initial = resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }); + applyFleetLaunchComposition(initial); + const plan = resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }); + const seat = join(fx.agentDir, '.claude'); + const displacedSeat = join(fx.root, 'displaced-seat'); + const sentinel = join(fx.root, 'external-sentinel'); + mkdirSync(sentinel); + writeFileSync(join(sentinel, 'settings.json'), 'outside\n'); + + try { + applyFleetLaunchComposition(plan, (point) => { + if (point !== 'write-settings') return; + substitute(seat, displacedSeat, sentinel); + throw new Error('injected parent swap'); + }); + throw new Error('expected rollback integrity refusal'); + } catch (error) { + expect(error).toBeInstanceOf(FleetLaunchError); + expect((error as FleetLaunchError).code).toBe('ROLLBACK_INTEGRITY'); + expect((error as Error).message).toContain('injected parent swap'); + } + expect(readFileSync(join(sentinel, 'settings.json'), 'utf8')).toBe('outside\n'); + expect(existsSync(join(fx.agentDir, '.mosaic-fleet-launch-recovery.json'))).toBe(true); + }); + + it('refuses a replacement of the transaction-created new seat before rollback cleanup', () => { + const fx = fixture({ schema: 1, harness: 'claude' }); + const plan = resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }); + const seat = join(fx.agentDir, '.claude'); + const createdSeat = join(fx.root, 'created-seat'); + try { + applyFleetLaunchComposition(plan, (point) => { + if (point !== 'credential-link') return; + renameSync(seat, createdSeat); + mkdirSync(seat); + throw new Error('injected created-seat replacement'); + }); + throw new Error('expected rollback integrity refusal'); + } catch (error) { + expect(error).toMatchObject({ code: 'ROLLBACK_INTEGRITY' }); + } + expect(existsSync(createdSeat)).toBe(true); + expect(existsSync(join(fx.agentDir, '.mosaic-fleet-launch-recovery.json'))).toBe(true); + }); + + it('rolls a new seat back without directories or residues after a mutation failure', () => { + const fx = fixture({ schema: 1, harness: 'claude' }); + const plan = resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }); + expect(() => + applyFleetLaunchComposition( + plan, + (point) => + point === 'credential-link' && + (() => { + throw new Error(point); + })(), + ), + ).toThrow('credential-link'); + expect(existsSync(join(fx.agentDir, '.claude'))).toBe(false); + }); + it('prunes a recorded matching stale symlink', () => { const fx = fixture({ schema: 1, harness: 'claude', plugins: ['old'] }); mkdirSync(join(fx.userHome, 'plugins', 'old'), { recursive: true }); @@ -449,6 +602,26 @@ describe('managed plugin and skill links', () => { expect(() => lstatSync(join(pluginHome, 'old'))).toThrow(); }); + it('prunes a recorded direct managed link after its central-store target vanished', () => { + const fx = fixture({ schema: 1, harness: 'claude', plugins: ['old'] }); + const target = join(fx.userHome, 'plugins', 'old'); + mkdirSync(target, { recursive: true }); + applyFleetLaunchComposition( + resolveFleetLaunchComposition('fred', { systemHome: fx.systemHome, userHome: fx.userHome }), + ); + rmSync(target, { recursive: true }); + writeFileSync( + join(fx.agentDir, 'profile.json'), + '{"schema":1,"harness":"claude","plugins":[]}\n', + ); + const plan = resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }); + applyFleetLaunchComposition(plan); + expect(existsSync(join(fx.agentDir, '.claude', 'plugins', 'old'))).toBe(false); + }); + it('refuses a recorded link retargeted after composition and leaves it intact', () => { const fx = fixture({ schema: 1, harness: 'claude', plugins: ['old'] }); const managedTarget = join(fx.userHome, 'plugins', 'old'); @@ -492,10 +665,82 @@ describe('managed plugin and skill links', () => { expect(() => resolveFleetLaunchComposition('fred', { systemHome: fx.systemHome, userHome: fx.userHome }), - ).toThrowError(/escapes an approved seat\/store root/); + ).toThrowError(/not an exact managed class/); expect(readFileSync(manifest, 'utf8')).toContain(crossSeat); }); + it.each([ + [ + 'nested managed link', + (fx: ReturnType) => + [ + join(fx.agentDir, '.claude', 'plugins', 'nested', 'keep'), + join(fx.userHome, 'plugins', 'keep'), + ] as const, + ], + [ + 'store root target', + (fx: ReturnType) => + [join(fx.agentDir, '.claude', 'plugins', 'keep'), join(fx.userHome, 'plugins')] as const, + ], + [ + 'plugin to skill cross-class', + (fx: ReturnType) => + [ + join(fx.agentDir, '.claude', 'plugins', 'keep'), + join(fx.userHome, 'skills', 'keep'), + ] as const, + ], + [ + 'out-of-auth credential', + (fx: ReturnType) => + [ + join(fx.agentDir, '.claude', '.credentials.json'), + join(fx.userHome, 'auth', 'claude', 'other', '.credentials.json'), + ] as const, + ], + ] as const)( + 'rejects a manifest %s entry', + (_label, entry: (fx: ReturnType) => readonly [string, string]) => { + const fx = fixture({ schema: 1, harness: 'claude' }); + mkdirSync(join(fx.userHome, 'plugins', 'keep'), { recursive: true }); + mkdirSync(join(fx.userHome, 'skills', 'keep'), { recursive: true }); + mkdirSync(join(fx.userHome, 'auth', 'claude', 'other'), { recursive: true }); + writeFileSync(join(fx.userHome, 'auth', 'claude', 'other', '.credentials.json'), '{}\n', { + mode: 0o600, + }); + const seat = join(fx.agentDir, '.claude'); + mkdirSync(seat, { recursive: true }); + const [link, target] = entry(fx); + writeFileSync( + join(seat, '.mosaic-managed-links.json'), + JSON.stringify({ links: { [link]: target } }), + ); + expect(() => + resolveFleetLaunchComposition('fred', { systemHome: fx.systemHome, userHome: fx.userHome }), + ).toThrow(/not an exact managed class/); + }, + ); + + it('rejects a symlink-escaped central store manifest target', () => { + const fx = fixture({ schema: 1, harness: 'claude' }); + const foreign = join(fx.root, 'foreign'); + mkdirSync(foreign, { recursive: true }); + mkdirSync(join(fx.userHome, 'plugins'), { recursive: true }); + symlinkSync(foreign, join(fx.userHome, 'plugins', 'keep'), 'dir'); + const seat = join(fx.agentDir, '.claude'); + mkdirSync(seat, { recursive: true }); + writeFileSync( + join(seat, '.mosaic-managed-links.json'), + JSON.stringify({ + links: { [join(seat, 'plugins', 'keep')]: join(fx.userHome, 'plugins', 'keep') }, + }), + ); + expect(() => + resolveFleetLaunchComposition('fred', { systemHome: fx.systemHome, userHome: fx.userHome }), + ).toThrow(/not an exact managed class/); + }); + it('refuses a symlinked manifest temporary path without modifying its target', () => { const fx = fixture({ schema: 1, harness: 'claude', plugins: ['keep'] }); const target = join(fx.userHome, 'plugins', 'keep'); @@ -664,6 +909,116 @@ describe('fleet launch command outcomes', () => { ); }); + it('drives register → apply → real launch through the seeded seat, not HOME', () => { + const fx = fixture({ schema: 1, harness: 'claude', env: {} }); + const bin = join(fx.root, 'bin'); + const helper = join(fx.systemHome, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'); + const seatConfig = join(fx.agentDir, '.claude', '.claude.json'); + mkdirSync(bin, { recursive: true }); + mkdirSync(join(fx.systemHome, 'tools', '_scripts'), { recursive: true }); + writeFileSync(join(fx.systemHome, 'AGENTS.md'), '# fixture\n'); + writeFileSync(join(fx.systemHome, 'SOUL.md'), '# fixture\n'); + writeFileSync( + join(fx.systemHome, 'runtime', 'claude', 'settings.json'), + readFileSync(join(process.cwd(), 'framework', 'runtime', 'claude', 'settings.json')), + ); + writeFileSync( + helper, + readFileSync( + join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'), + ), + { mode: 0o700 }, + ); + chmodSync(helper, 0o700); + mkdirSync(join(fx.agentDir, '.claude'), { recursive: true }); + writeFileSync( + join(fx.systemHome, 'runtime', 'claude', 'RUNTIME.md'), + readFileSync(join(process.cwd(), 'framework', 'runtime', 'claude', 'RUNTIME.md')), + ); + const base = JSON.parse( + readFileSync(join(fx.systemHome, 'runtime', 'claude', 'settings.json'), 'utf8'), + ); + writeFileSync(seatConfig, JSON.stringify(base), { mode: 0o600 }); + for (const name of ['claude', 'python3']) { + writeFileSync(join(bin, name), '#!/usr/bin/env bash\nexit 0\n', { mode: 0o700 }); + chmodSync(join(bin, name), 0o700); + } + const program = new Command().exitOverride(); + const fleet = program.command('fleet'); + const exit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + const oldPath = process.env['PATH']; + const oldHome = process.env['HOME']; + try { + process.env['PATH'] = `${bin}:${oldPath ?? ''}`; + process.env['HOME'] = join(fx.root, 'operator-home-empty'); + registerFleetLaunchCommand(fleet, () => fx.systemHome, { userHome: fx.userHome }); + program.parse(['node', 'mosaic', 'fleet', 'launch', 'fred']); + expect( + readFileSync(join(fx.systemHome, 'fleet', 'run', 'sessions', 'events.ndjson'), 'utf8'), + ).toContain('"runtime":"claude"'); + } finally { + exit.mockRestore(); + process.env['PATH'] = oldPath; + process.env['HOME'] = oldHome; + } + }); + + it('rejects the same register → apply → real launch route when only HOME is seeded', () => { + const fx = fixture({ schema: 1, harness: 'claude', env: {} }); + const bin = join(fx.root, 'bin'); + const helper = join(fx.systemHome, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'); + const home = join(fx.root, 'operator-home'); + mkdirSync(bin, { recursive: true }); + mkdirSync(join(fx.systemHome, 'tools', '_scripts'), { recursive: true }); + writeFileSync(join(fx.systemHome, 'AGENTS.md'), '# fixture\n'); + writeFileSync(join(fx.systemHome, 'SOUL.md'), '# fixture\n'); + writeFileSync( + join(fx.systemHome, 'runtime', 'claude', 'settings.json'), + readFileSync(join(process.cwd(), 'framework', 'runtime', 'claude', 'settings.json')), + ); + writeFileSync( + helper, + readFileSync( + join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'), + ), + { mode: 0o700 }, + ); + chmodSync(helper, 0o700); + mkdirSync(home, { recursive: true }); + writeFileSync( + join(home, '.claude.json'), + readFileSync(join(process.cwd(), 'framework', 'runtime', 'claude', 'settings.json')), + ); + writeFileSync(join(bin, 'claude'), '#!/usr/bin/env bash\nexit 0\n', { mode: 0o700 }); + chmodSync(join(bin, 'claude'), 0o700); + const program = new Command().exitOverride(); + const fleet = program.command('fleet'); + const exit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + const priorExitCode = process.exitCode; + const oldPath = process.env['PATH']; + const oldHome = process.env['HOME']; + try { + process.exitCode = 0; + process.env['PATH'] = `${bin}:${oldPath ?? ''}`; + process.env['HOME'] = home; + registerFleetLaunchCommand(fleet, () => fx.systemHome, { userHome: fx.userHome }); + program.parse(['node', 'mosaic', 'fleet', 'launch', 'fred']); + expect(process.exitCode).toBe(1); + expect(existsSync(join(fx.systemHome, 'fleet', 'run', 'sessions', 'events.ndjson'))).toBe( + false, + ); + } finally { + exit.mockRestore(); + process.exitCode = priorExitCode; + process.env['PATH'] = oldPath; + process.env['HOME'] = oldHome; + } + }); + it('sets a non-zero exit code and never invokes the launcher', () => { const fx = fixture({ schema: 1, harness: 'claude', unknown: true }); const program = new Command().exitOverride(); diff --git a/packages/mosaic/src/commands/fleet-launch-command.ts b/packages/mosaic/src/commands/fleet-launch-command.ts index a012ecaf..601cb8bc 100644 --- a/packages/mosaic/src/commands/fleet-launch-command.ts +++ b/packages/mosaic/src/commands/fleet-launch-command.ts @@ -1,5 +1,8 @@ import { closeSync, + constants, + fchmodSync, + fstatSync, lstatSync, mkdirSync, openSync, @@ -23,6 +26,7 @@ import { type RuntimeName, } from './launch.js'; import { defaultFleetDataHome } from '../fleet/fleet-agent-scaffold.js'; +import { assertNoSymlinkAncestors } from '../fleet/secure-file.js'; export const FLEET_AGENT_PROFILE_SCHEMA = 1; const PROFILE_KEYS = [ @@ -53,7 +57,8 @@ export type FleetLaunchErrorCode = | 'PROFILE_INVALID' | 'AGENT_NOT_SCAFFOLDED' | 'COMPOSITION_FAILED' - | 'FIRST_AUTH_REFUSAL'; + | 'FIRST_AUTH_REFUSAL' + | 'ROLLBACK_INTEGRITY'; export class FleetLaunchError extends Error { constructor( @@ -592,29 +597,65 @@ function readManagedLinkState( ); } const links = new Map(); - for (const [link, target] of Object.entries(parsed['links'])) { - if (typeof target !== 'string' || !isAbsolute(link) || !isAbsolute(target)) { - throw new FleetLaunchError( - 'COMPOSITION_FAILED', - `managed link manifest has invalid entry: ${path}`, - ); + const credential = join(seatHome, CREDENTIAL_FILES[profile.harness]); + const expectedCredential = join( + userHome, + 'auth', + profile.harness, + profile.bundle, + CREDENTIAL_FILES[profile.harness], + ); + const stores: Array = [ + ['plugins', join(seatHome, 'plugins'), join(userHome, 'plugins')], + ['skills', join(seatHome, 'skills'), join(userHome, 'skills')], + ]; + const requireRealDirectStoreDirectory = (target: string, root: string): void => { + const canonicalRoot = realpathSync(root); + const lexicalTarget = resolve(target); + if (dirname(lexicalTarget) !== canonicalRoot) { + throw new Error('target is not a direct central-store entry'); } - const credential = join(seatHome, CREDENTIAL_FILES[profile.harness]); - const pluginRoot = join(seatHome, 'plugins'); - const skillRoot = join(seatHome, 'skills'); - const authRoot = join(userHome, 'auth', profile.harness); - const inRoot = (root: string, candidate: string): boolean => { - const rel = relative(resolve(root), resolve(candidate)); - return rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel); - }; - const valid = - (link === credential && inRoot(authRoot, target)) || - (inRoot(pluginRoot, link) && inRoot(join(userHome, 'plugins'), target)) || - (inRoot(skillRoot, link) && inRoot(join(userHome, 'skills'), target)); - if (!valid) { + const targetInfo = lstatIfPresent(target); + // A vanished target remains safe to prune only because the link itself is + // still an exact direct managed class. Retained/install targets revalidate below. + if (!targetInfo) return; + if (targetInfo.isSymbolicLink()) throw new Error('target is a symbolic link'); + assertNoSymlinkAncestors(target); + const canonicalTarget = realpathSync(target); + assertContained(canonicalRoot, canonicalTarget, 'managed link manifest target'); + if (dirname(canonicalTarget) !== canonicalRoot || !lstatSync(canonicalTarget).isDirectory()) { + throw new Error('target is not a direct real central-store directory'); + } + }; + for (const [link, target] of Object.entries(parsed['links'])) { + try { + if ( + typeof target !== 'string' || + !isAbsolute(link) || + !isAbsolute(target) || + links.has(link) + ) { + throw new Error('entry is not an absolute unique path pair'); + } + if (link === credential) { + if (lstatSync(target).isSymbolicLink()) + throw new Error('credential target is a symbolic link'); + assertNoSymlinkAncestors(target); + if (realpathSync(target) !== realpathSync(expectedCredential)) { + throw new Error('credential target is not the active resolved credential'); + } + } else { + const store = stores.find(([, seatRoot]) => dirname(link) === seatRoot); + if (!store || basename(link) === '.' || basename(link) === '..') { + throw new Error('link is not a direct managed plugin or skill entry'); + } + requireRealDirectStoreDirectory(target, store[2]); + } + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error); throw new FleetLaunchError( 'COMPOSITION_FAILED', - `managed link manifest entry escapes an approved seat/store root: ${path}`, + `managed link manifest entry is not an exact managed class: ${detail}`, ); } links.set(link, target); @@ -835,29 +876,372 @@ function canonicalJson(value: unknown): unknown { ); } +interface PathSnapshot { + readonly path: string; + readonly kind: 'absent' | 'file' | 'symlink' | 'directory'; + readonly mode?: number; + readonly content?: Buffer; + readonly target?: string; + readonly dev?: number | bigint; + readonly ino?: number | bigint; +} + +interface RollbackAnchor { + readonly root: string; + readonly descriptor: number; + readonly directories: ReadonlyMap>; +} + +function sameIdentity( + expected: Pick, + actual: { dev: number | bigint; ino: number | bigint }, +): boolean { + return expected.dev === actual.dev && expected.ino === actual.ino; +} + +function snapshotPath(path: string): PathSnapshot { + const info = lstatIfPresent(path); + if (!info) return { path, kind: 'absent' }; + if (info.isSymbolicLink()) + return { + path, + kind: 'symlink', + mode: info.mode, + target: readlinkSync(path), + dev: info.dev, + ino: info.ino, + }; + if (info.isFile()) + return { + path, + kind: 'file', + mode: info.mode, + content: readFileSync(path), + dev: info.dev, + ino: info.ino, + }; + if (info.isDirectory()) + return { path, kind: 'directory', mode: info.mode, dev: info.dev, ino: info.ino }; + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `cannot transactionally snapshot special object: ${path}`, + ); +} + +function rollbackIntegrity(message: string): FleetLaunchError { + return new FleetLaunchError('ROLLBACK_INTEGRITY', `rollback integrity refusal: ${message}`); +} + +function descriptorPath(descriptor: number, child?: string): string { + return child === undefined + ? `/proc/self/fd/${descriptor}` + : `/proc/self/fd/${descriptor}/${child}`; +} + +function openRollbackAnchor(root: string, snapshots: readonly PathSnapshot[]): RollbackAnchor { + if (process.platform !== 'linux') + throw rollbackIntegrity('descriptor-relative rollback requires Linux'); + const rootInfo = lstatSync(root); + if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) { + throw rollbackIntegrity(`rollback root is not a real directory: ${root}`); + } + const descriptor = openSync( + root, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + ); + const opened = fstatSync(descriptor); + if (!sameIdentity(rootInfo, opened)) { + closeSync(descriptor); + throw rollbackIntegrity(`rollback root changed while opening: ${root}`); + } + const directories = new Map>(); + directories.set(root, { dev: rootInfo.dev, ino: rootInfo.ino }); + for (const snapshot of snapshots) { + if (snapshot.kind === 'directory') directories.set(snapshot.path, snapshot); + } + return { root, descriptor, directories }; +} + +function rollbackComponents(anchor: RollbackAnchor, path: string): string[] { + const rel = relative(anchor.root, path); + if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + throw rollbackIntegrity(`rollback path escapes its pinned root: ${path}`); + } + return rel.split(sep).filter(Boolean); +} + +function openPinnedRollbackParent( + anchor: RollbackAnchor, + path: string, +): { descriptor: number; close: readonly number[]; name: string } { + const components = rollbackComponents(anchor, path); + const name = components.pop(); + if (!name) throw rollbackIntegrity(`rollback path lacks a final component: ${path}`); + const rootStat = fstatSync(anchor.descriptor); + const expectedRoot = anchor.directories.get(anchor.root)!; + if (!sameIdentity(expectedRoot, rootStat)) + throw rollbackIntegrity('pinned rollback root identity changed'); + let parent = anchor.descriptor; + const close: number[] = []; + let cursor = anchor.root; + try { + for (const component of components) { + cursor = join(cursor, component); + const expected = anchor.directories.get(cursor); + if (!expected) + throw rollbackIntegrity(`rollback ancestor was not present at snapshot: ${cursor}`); + const child = openSync( + descriptorPath(parent, component), + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + ); + close.push(child); + const actual = fstatSync(child); + if (!sameIdentity(expected, actual)) { + throw rollbackIntegrity(`rollback ancestor identity changed: ${cursor}`); + } + parent = child; + } + return { descriptor: parent, close, name }; + } catch (error) { + for (const descriptor of close.reverse()) closeSync(descriptor); + throw error; + } +} + +function closeRollbackParents(descriptors: readonly number[]): void { + for (const descriptor of [...descriptors].reverse()) closeSync(descriptor); +} + +function removePinnedPath( + anchor: RollbackAnchor, + path: string, + expectedCreated?: PathSnapshot, +): void { + const parent = openPinnedRollbackParent(anchor, path); + try { + const pinned = descriptorPath(parent.descriptor, parent.name); + const current = lstatIfPresent(pinned); + if (current && expectedCreated && !sameIdentity(expectedCreated, current)) { + throw rollbackIntegrity(`rollback-created path identity changed: ${path}`); + } + if (current) rmSync(pinned, { recursive: current.isDirectory(), force: true }); + if (lstatIfPresent(pinned)) throw rollbackIntegrity(`rollback removal did not remove ${path}`); + } finally { + closeRollbackParents(parent.close); + } +} + +function writePinnedFile(anchor: RollbackAnchor, snapshot: PathSnapshot): void { + const parent = openPinnedRollbackParent(anchor, snapshot.path); + try { + const pinned = descriptorPath(parent.descriptor, parent.name); + const descriptor = openSync( + pinned, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + snapshot.mode, + ); + try { + writeSync(descriptor, snapshot.content!); + fchmodSync(descriptor, snapshot.mode!); + } finally { + closeSync(descriptor); + } + const restored = lstatSync(pinned); + if (!restored.isFile() || restored.isSymbolicLink()) { + throw rollbackIntegrity(`rollback file restoration was redirected: ${snapshot.path}`); + } + } finally { + closeRollbackParents(parent.close); + } +} + +function createPinnedSymlink(anchor: RollbackAnchor, snapshot: PathSnapshot): void { + const parent = openPinnedRollbackParent(anchor, snapshot.path); + try { + const pinned = descriptorPath(parent.descriptor, parent.name); + symlinkSync(snapshot.target!, pinned, 'file'); + const restored = lstatSync(pinned); + if (!restored.isSymbolicLink() || readlinkSync(pinned) !== snapshot.target) { + throw rollbackIntegrity(`rollback symlink restoration was redirected: ${snapshot.path}`); + } + } finally { + closeRollbackParents(parent.close); + } +} + +function chmodPinnedDirectory(anchor: RollbackAnchor, snapshot: PathSnapshot): void { + const parent = openPinnedRollbackParent(anchor, snapshot.path); + try { + const descriptor = openSync( + descriptorPath(parent.descriptor, parent.name), + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + ); + try { + const actual = fstatSync(descriptor); + if (!sameIdentity(snapshot, actual)) { + throw rollbackIntegrity(`rollback directory identity changed: ${snapshot.path}`); + } + fchmodSync(descriptor, snapshot.mode!); + } finally { + closeSync(descriptor); + } + } finally { + closeRollbackParents(parent.close); + } +} + +function hasAbsentSnapshotAncestor( + snapshot: PathSnapshot, + snapshots: readonly PathSnapshot[], +): boolean { + return snapshots.some( + (ancestor) => + ancestor.kind === 'absent' && + ancestor.path !== snapshot.path && + snapshot.path.startsWith(`${ancestor.path}${sep}`), + ); +} + +function writeRollbackRecovery( + anchor: RollbackAnchor, + originalError: unknown, + rollbackError: unknown, + snapshots: readonly PathSnapshot[], +): string { + const name = '.mosaic-fleet-launch-recovery.json'; + const path = join(anchor.root, name); + const rootStat = fstatSync(anchor.descriptor); + const expectedRoot = anchor.directories.get(anchor.root)!; + if (!sameIdentity(expectedRoot, rootStat)) { + throw rollbackIntegrity( + 'cannot safely retain recovery evidence: rollback root identity changed', + ); + } + const descriptor = openSync( + descriptorPath(anchor.descriptor, name), + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + 0o600, + ); + try { + writeSync( + descriptor, + `${JSON.stringify({ + error: String(originalError), + rollbackError: String(rollbackError), + snapshots: snapshots.map(({ path: snapshotPath, kind, mode, target }) => ({ + path: snapshotPath, + kind, + ...(mode === undefined ? {} : { mode }), + ...(target === undefined ? {} : { target }), + })), + })}\n`, + ); + } finally { + closeSync(descriptor); + } + return path; +} + +function captureCreatedPaths( + snapshots: readonly PathSnapshot[], + created: Map, + extra: readonly string[] = [], +): void { + for (const path of [ + ...snapshots.filter((snapshot) => snapshot.kind === 'absent').map(({ path }) => path), + ...extra, + ]) { + if (created.has(path)) continue; + const current = snapshotPath(path); + if (current.kind !== 'absent') created.set(path, current); + } +} + +function restoreSnapshots( + snapshots: readonly PathSnapshot[], + anchor: RollbackAnchor, + created: ReadonlyMap, +): void { + const deepestFirst = [...snapshots].sort((a, b) => b.path.length - a.path.length); + for (const snapshot of deepestFirst) { + if (snapshot.kind === 'directory' || hasAbsentSnapshotAncestor(snapshot, snapshots)) continue; + removePinnedPath(anchor, snapshot.path, created.get(snapshot.path)); + } + for (const snapshot of deepestFirst) { + if (hasAbsentSnapshotAncestor(snapshot, snapshots)) continue; + if (snapshot.kind === 'absent') continue; + if (snapshot.kind === 'directory') continue; + if (snapshot.kind === 'file') writePinnedFile(anchor, snapshot); + else createPinnedSymlink(anchor, snapshot); + } + for (const snapshot of [...snapshots].sort((a, b) => a.path.length - b.path.length)) { + if (snapshot.kind === 'directory') chmodPinnedDirectory(anchor, snapshot); + } + for (const snapshot of deepestFirst) { + if (snapshot.kind !== 'absent' || hasAbsentSnapshotAncestor(snapshot, snapshots)) continue; + removePinnedPath(anchor, snapshot.path, created.get(snapshot.path)); + } +} + /** Apply a previously resolved plan. No caller should apply a dry-run plan. */ -export function applyFleetLaunchComposition(plan: FleetLaunchComposition): void { +export function applyFleetLaunchComposition( + plan: FleetLaunchComposition, + injectFailure?: (seam: string) => void, +): void { // All link-state checks must complete before the first filesystem mutation. - // This makes a late foreign/retargeted link refusal leave the seat untouched. assertManagedLinkMutationAllowed(plan.credential.link, plan.credential.target, plan.managedLinks); for (const path of plan.prune) assertManagedLinkMutationAllowed(path, undefined, plan.managedLinks); - for (const install of plan.installs) { + for (const install of plan.installs) assertManagedLinkMutationAllowed(install.link, install.target, plan.managedLinks); - } - mkdirSync(plan.seatHome, { recursive: true }); - const preparedManifest = prepareManagedLinkManifest(plan.managedLinks); - let descriptorOpen = true; - let committedManifest = false; + const tracked = [ + ...new Set([ + plan.seatHome, + plan.settings.output, + plan.settings.snapshot, + plan.managedLinks.path, + plan.credential.link, + ...plan.prune, + ...plan.installs.map((install) => install.link), + ...[plan.credential.link, ...plan.prune, ...plan.installs.map((install) => install.link)].map( + dirname, + ), + ]), + ]; + const snapshots = tracked.map(snapshotPath); + const rollbackAnchor = openRollbackAnchor(plan.agentDir, snapshots); + const manifestLinksBefore = new Map(plan.managedLinks.links); + const createdPaths = new Map(); + let preparedManifest: PreparedManagedLinkManifest | undefined; + let descriptorOpen = false; try { + mkdirSync(plan.seatHome, { recursive: true, mode: 0o700 }); + captureCreatedPaths(snapshots, createdPaths); + injectFailure?.('mkdir-seat'); + preparedManifest = prepareManagedLinkManifest(plan.managedLinks); + descriptorOpen = true; + captureCreatedPaths(snapshots, createdPaths, [preparedManifest.path]); + injectFailure?.('prepare-manifest'); const settings = `${JSON.stringify(canonicalJson(plan.settings.merged), null, 2)}\n`; writeFileSync(plan.settings.output, settings, { mode: 0o600 }); + captureCreatedPaths(snapshots, createdPaths); + injectFailure?.('write-settings'); writeFileSync(plan.settings.snapshot, settings, { mode: 0o600 }); + captureCreatedPaths(snapshots, createdPaths); + injectFailure?.('write-snapshot'); ensureSymlink(plan.credential.link, plan.credential.target, plan.managedLinks); + captureCreatedPaths(snapshots, createdPaths); + injectFailure?.('credential-link'); for (const path of plan.prune) { const info = lstatIfPresent(path); - if (info?.isSymbolicLink()) { + if (info) { + if (!info.isSymbolicLink()) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `real object replaced managed symlink before prune: ${path}`, + ); + } const current = currentLinkTarget(path); if (plan.managedLinks.links.get(path) !== current) { throw new FleetLaunchError( @@ -866,26 +1250,61 @@ export function applyFleetLaunchComposition(plan: FleetLaunchComposition): void ); } rmSync(path); - plan.managedLinks.links.delete(path); - } else if (info) { - throw new FleetLaunchError( - 'COMPOSITION_FAILED', - `real object replaced managed symlink before prune: ${path}`, - ); } + plan.managedLinks.links.delete(path); + captureCreatedPaths(snapshots, createdPaths); + injectFailure?.('prune-link'); } for (const install of plan.installs) { ensureSymlink(install.link, install.target, plan.managedLinks); + captureCreatedPaths(snapshots, createdPaths); + injectFailure?.('install-link'); } writeManagedLinkState(plan.managedLinks, preparedManifest); + captureCreatedPaths(snapshots, createdPaths); + injectFailure?.('write-manifest'); closeSync(preparedManifest.descriptor); descriptorOpen = false; + injectFailure?.('close-manifest'); renameSync(preparedManifest.path, plan.managedLinks.path); - committedManifest = true; + injectFailure?.('rename-manifest'); + } catch (error: unknown) { + try { + if (descriptorOpen && preparedManifest) closeSync(preparedManifest.descriptor); + if ( + preparedManifest && + !hasAbsentSnapshotAncestor({ path: preparedManifest.path, kind: 'absent' }, snapshots) + ) { + removePinnedPath(rollbackAnchor, preparedManifest.path); + } + restoreSnapshots(snapshots, rollbackAnchor, createdPaths); + plan.managedLinks.links.clear(); + for (const [link, target] of manifestLinksBefore) plan.managedLinks.links.set(link, target); + } catch (rollbackError: unknown) { + let recovery = join(plan.agentDir, '.mosaic-fleet-launch-recovery.json'); + try { + recovery = writeRollbackRecovery(rollbackAnchor, error, rollbackError, snapshots); + } catch { + // The pinned root was unavailable; preserve the original rollback-integrity refusal. + } + throw new FleetLaunchError( + 'ROLLBACK_INTEGRITY', + `launch composition rollback failed after ${String(error)}; recovery evidence: ${recovery}`, + ); + } + throw error; } finally { - if (!committedManifest) { - if (descriptorOpen) closeSync(preparedManifest.descriptor); - rmSync(preparedManifest.path, { force: true }); + try { + closeSync(rollbackAnchor.descriptor); + } catch { + // The anchor only gates rollback and must not hide the launch result. + } + if (descriptorOpen && preparedManifest) { + try { + closeSync(preparedManifest.descriptor); + } catch { + // The catch path may already have closed it before restoring snapshots. + } } } } diff --git a/packages/mosaic/src/commands/launch.spec.ts b/packages/mosaic/src/commands/launch.spec.ts index 8909fbc2..1cc98d2b 100644 --- a/packages/mosaic/src/commands/launch.spec.ts +++ b/packages/mosaic/src/commands/launch.spec.ts @@ -20,7 +20,7 @@ import { piForceSkillNames, registerRuntimeLaunchers, checkSequentialThinking, - launchFleetRuntimeForTest, + resolveExecutableFromPath, type RuntimeLaunchHandler, type ClaudexLaunchHandler, } from './launch.js'; @@ -136,13 +136,9 @@ describe('checkSequentialThinking', () => { { mode: 0o600 }, ); vi.stubEnv('HOME', home); - const final = vi.fn((): never => { - throw new Error('final runtime boundary'); - }); expect(() => - launchFleetRuntimeForTest('claude', [], {}, { agentDir, mosaicHome: installed }, final), - ).toThrow('final runtime boundary'); - expect(final).toHaveBeenCalledOnce(); + checkSequentialThinking('claude', { agentDir, mosaicHome: installed }), + ).not.toThrow(); } finally { vi.unstubAllEnvs(); rmSync(home, { recursive: true, force: true }); @@ -175,11 +171,9 @@ describe('checkSequentialThinking', () => { }), ); vi.stubEnv('HOME', home); - expect(() => - launchFleetRuntimeForTest('claude', [], {}, { agentDir, mosaicHome: installed }, () => { - throw new Error('must not execute'); - }), - ).toThrow('process.exit called'); + expect(() => checkSequentialThinking('claude', { agentDir, mosaicHome: installed })).toThrow( + 'process.exit called', + ); } finally { exit.mockRestore(); vi.unstubAllEnvs(); @@ -253,7 +247,20 @@ describe('checkSequentialThinking', () => { expect( spawnSync( checker, - ['--runtime', 'claude', '--claude-config-dir', join(agentDir, '.claude')], + [ + '--runtime', + 'claude', + '--claude-config-dir', + join(agentDir, '.claude'), + '--python-bin', + '/usr/bin/python3', + '--node-bin', + '/usr/bin/node', + '--npx-bin', + '/usr/bin/npx', + '--timeout-bin', + '/usr/bin/timeout', + ], { env, }, @@ -262,7 +269,21 @@ describe('checkSequentialThinking', () => { expect( spawnSync( checker, - ['--check', '--runtime', 'claude', '--claude-config-dir', join(agentDir, '.claude')], + [ + '--check', + '--runtime', + 'claude', + '--claude-config-dir', + join(agentDir, '.claude'), + '--python-bin', + '/usr/bin/python3', + '--node-bin', + '/usr/bin/node', + '--npx-bin', + '/usr/bin/npx', + '--timeout-bin', + '/usr/bin/timeout', + ], { env }, ).status, ).toBe(0); @@ -604,3 +625,146 @@ describe('registerRuntimeLaunchers — claudex (EXPERIMENTAL overlay)', () => { expect(mockExit).not.toHaveBeenCalled(); }); }); + +/** + * Executable resolution for fleet launches (AMD1213-D, D3/D6). + * + * The defect these cover: the launcher proved a runtime existed by running ambient + * `which`, then spawned the bare name and let the OS resolve it a second time against an + * ambient PATH. A directory prepended to PATH satisfied the probe and then supplied the + * binary that actually ran, so the check could pass without ever reading seat state. + * + * Every case below was run against the pre-change resolution first. The shim case is the + * one that matters -- under `which` + bare-name spawn it passes, because that is exactly + * the behaviour being removed. + */ +describe('resolveExecutableFromPath', () => { + let dir: string; + + const bin = (root: string, name: string, mode = 0o755): string => { + const p = join(root, name); + writeFileSync(p, '#!/bin/sh\nexit 0\n'); + chmodSync(p, mode); + return p; + }; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'mosaic-exec-resolve-')); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('resolves a safe executable and reports its real path and identity', () => { + const safe = join(dir, 'safe'); + mkdirSync(safe, { mode: 0o755 }); + const target = bin(safe, 'codex'); + + const resolved = resolveExecutableFromPath('codex', safe); + + expect(resolved.path).toBe(target); + expect(resolved.ino).toBeDefined(); + }); + + it('refuses a world-writable binary planted on PATH', () => { + // The shim case. `which` reports this happily and a bare-name spawn runs it. + const shim = join(dir, 'shim'); + mkdirSync(shim, { mode: 0o755 }); + bin(shim, 'codex', 0o777); + + expect(() => resolveExecutableFromPath('codex', shim)).toThrow(/writable by group or other/); + }); + + it('refuses a safe binary reached through a world-writable directory', () => { + // The binary itself is fine; anyone can swap it for one that is not. + const open = join(dir, 'open'); + mkdirSync(open, { mode: 0o755 }); + bin(open, 'codex'); + // chmod after mkdir: the mode argument is masked by the process umask, so a + // directory created as 0o777 is really 0o755 and the case tests nothing. + chmodSync(open, 0o777); + + expect(() => resolveExecutableFromPath('codex', open)).toThrow(/writable directory/); + }); + + it('does not fall through to a later PATH entry when the first match is unsafe', () => { + // Falling through would let a planted unsafe binary silently downgrade the search to + // whatever came after it, inverting the precedence PATH exists to express. + const shim = join(dir, 'first'); + const good = join(dir, 'second'); + mkdirSync(shim, { mode: 0o755 }); + mkdirSync(good, { mode: 0o755 }); + bin(shim, 'codex', 0o777); + const safeTarget = bin(good, 'codex'); + + let resolvedPath: string | undefined; + try { + resolvedPath = resolveExecutableFromPath('codex', `${shim}:${good}`).path; + } catch { + resolvedPath = undefined; + } + expect(resolvedPath).not.toBe(safeTarget); + }); + + it('ignores a relative PATH entry', () => { + // A relative entry resolves against the current directory, so what it names depends + // on where the launcher happened to be started. + expect(() => resolveExecutableFromPath('codex', '.:relative/bin')).toThrow(/not found/); + }); + + it('follows a symlink and validates the real file behind it', () => { + const safe = join(dir, 'real'); + const linkDir = join(dir, 'links'); + mkdirSync(safe, { mode: 0o755 }); + mkdirSync(linkDir, { mode: 0o755 }); + const target = bin(safe, 'codex-real'); + symlinkSync(target, join(linkDir, 'codex')); + + expect(resolveExecutableFromPath('codex', linkDir).path).toBe(target); + }); + + it('refuses a symlink whose real target is unsafe', () => { + const open = join(dir, 'openreal'); + const linkDir = join(dir, 'links2'); + mkdirSync(open, { mode: 0o755 }); + mkdirSync(linkDir, { mode: 0o755 }); + const target = bin(open, 'codex-real', 0o777); + symlinkSync(target, join(linkDir, 'codex')); + + expect(() => resolveExecutableFromPath('codex', linkDir)).toThrow(/writable by group or other/); + }); + + it('refuses a non-executable file', () => { + const safe = join(dir, 'noexec'); + mkdirSync(safe, { mode: 0o755 }); + bin(safe, 'codex', 0o644); + + expect(() => resolveExecutableFromPath('codex', safe)).toThrow(/not executable/); + }); + + it('refuses a directory that merely shares the name', () => { + const safe = join(dir, 'dirname'); + mkdirSync(join(safe, 'codex'), { recursive: true, mode: 0o755 }); + + expect(() => resolveExecutableFromPath('codex', safe)).toThrow(/not a regular file/); + }); + + it('refuses a name that is a path rather than a bare command', () => { + expect(() => resolveExecutableFromPath('../evil', dir)).toThrow(/bare command name/); + }); + + it('refuses when no PATH was declared', () => { + expect(() => resolveExecutableFromPath('codex', undefined)).toThrow(/no PATH was declared/); + expect(() => resolveExecutableFromPath('codex', '')).toThrow(/no PATH was declared/); + }); + + it('reports not-found rather than resolving something else', () => { + const empty = join(dir, 'empty'); + mkdirSync(empty, { mode: 0o755 }); + + expect(() => resolveExecutableFromPath('codex', empty)).toThrow( + /not found on the declared PATH/, + ); + }); +}); diff --git a/packages/mosaic/src/commands/launch.ts b/packages/mosaic/src/commands/launch.ts index 499f6084..62cde72e 100644 --- a/packages/mosaic/src/commands/launch.ts +++ b/packages/mosaic/src/commands/launch.ts @@ -20,7 +20,7 @@ import { import { createHash, randomBytes } from 'node:crypto'; import { createRequire } from 'node:module'; import { homedir, hostname } from 'node:os'; -import { isAbsolute, join, dirname, relative, resolve, sep } from 'node:path'; +import { join, dirname, relative, resolve, sep, delimiter, isAbsolute } from 'node:path'; import type { Command } from 'commander'; import { buildResolvedFleetCommsBlock, @@ -140,15 +140,18 @@ function sha256Of(value: string | Buffer): string { * so an unexpected digest here is a mechanically detectable red flag rather than * a matter of judgement. */ -function normativeFragmentDigests(runtime: RuntimeName): NormativeFragmentDigest[] { +function normativeFragmentDigests( + runtime: RuntimeName, + mosaicHome: string = MOSAIC_HOME, +): NormativeFragmentDigest[] { const candidates: Array<[string, string]> = [ - ['CONSTITUTION.md', join(MOSAIC_HOME, 'CONSTITUTION.md')], - ['AGENTS.md', join(MOSAIC_HOME, 'AGENTS.md')], - ['SOUL.md', join(MOSAIC_HOME, 'SOUL.md')], - ['USER.md', join(MOSAIC_HOME, 'USER.md')], - ['STANDARDS.md', join(MOSAIC_HOME, 'STANDARDS.md')], - ['TOOLS.md', join(MOSAIC_HOME, 'TOOLS.md')], - [`runtime/${runtime}/RUNTIME.md`, join(MOSAIC_HOME, 'runtime', runtime, 'RUNTIME.md')], + ['CONSTITUTION.md', join(mosaicHome, 'CONSTITUTION.md')], + ['AGENTS.md', join(mosaicHome, 'AGENTS.md')], + ['SOUL.md', join(mosaicHome, 'SOUL.md')], + ['USER.md', join(mosaicHome, 'USER.md')], + ['STANDARDS.md', join(mosaicHome, 'STANDARDS.md')], + ['TOOLS.md', join(mosaicHome, 'TOOLS.md')], + [`runtime/${runtime}/RUNTIME.md`, join(mosaicHome, 'runtime', runtime, 'RUNTIME.md')], ]; return candidates.map(([sourceId, path]) => { try { @@ -178,7 +181,10 @@ function recordLaunch( launchEnv: NodeJS.ProcessEnv = process.env, ): void { try { - mkdirSync(LAUNCH_LEDGER_DIR, { recursive: true, mode: 0o700 }); + const ledgerDir = fleet?.mosaicHome + ? join(fleet.mosaicHome, 'fleet', 'run', 'sessions') + : LAUNCH_LEDGER_DIR; + mkdirSync(ledgerDir, { recursive: true, mode: 0o700 }); // Correlation id for the lease.register half. Set into process.env so it // propagates through every `...process.env` / `...baseEnv` spread below. const launchId = `${Date.now().toString(36)}-${randomBytes(6).toString('hex')}`; @@ -198,13 +204,13 @@ function recordLaunch( config_home_isolated: true, config_home_env: HARNESS_HOME_ENV[runtime] ?? null, argv: redactArgv(cliArgs), - normative_fragments: normativeFragmentDigests(runtime), + normative_fragments: normativeFragmentDigests(runtime, fleet?.mosaicHome), // names only — values are never recorded mosaic_env_present: Object.keys(launchEnv) .filter((k) => k.startsWith('MOSAIC_')) .sort(), }; - appendFileSync(join(LAUNCH_LEDGER_DIR, 'events.ndjson'), `${JSON.stringify(record)}\n`, { + appendFileSync(join(ledgerDir, 'events.ndjson'), `${JSON.stringify(record)}\n`, { mode: 0o600, }); } catch (err) { @@ -244,6 +250,125 @@ function checkRuntime(cmd: string): void { } } +/** An executable located once and pinned by the identity it had when validated. */ +interface ResolvedExecutable { + readonly path: string; + readonly dev: number | bigint; + readonly ino: number | bigint; +} + +function executableRefusal(name: string, detail: string): Error { + return new Error(`refusing to launch '${name}': ${detail}`); +} + +/** + * Reject a directory whose contents someone else could swap under us. + * + * Group- or world-writable is the disqualifier, with the /tmp exception: a sticky + * directory is writable by design but only its owner may replace its entries, so it + * cannot be used to shadow one. + */ +function assertSafeAncestry(path: string, name: string, owner: number | undefined): void { + let cursor = dirname(path); + for (;;) { + const info = lstatSync(cursor); + if (!info.isDirectory() || info.isSymbolicLink()) { + throw executableRefusal(name, `path component is not a real directory: ${cursor}`); + } + if ((info.mode & 0o022) !== 0 && (info.mode & 0o1000) === 0) { + throw executableRefusal(name, `writable directory on the resolved path: ${cursor}`); + } + if (owner !== undefined && info.uid !== owner && info.uid !== 0) { + throw executableRefusal( + name, + `directory on the resolved path has a foreign owner: ${cursor}`, + ); + } + const parent = dirname(cursor); + if (parent === cursor) return; + cursor = parent; + } +} + +/** + * Find one executable named `name`, searching only `searchPath`, and validate the object + * that search lands on. + * + * This exists because `which` answered a different question than the one the launcher + * needed. `which` reported that *something* by that name was reachable; the launcher then + * spawned the bare name and let the OS resolve it a second time, against an ambient PATH, + * at a later moment. Two independent resolutions of an attacker-influenced name, with a + * gap in between, is not a check -- a directory prepended to PATH satisfied the probe and + * then supplied the thing that actually ran. Resolving once here and executing the exact + * path returned is the whole point; callers must not go back to the name. + * + * Rules worth stating because each one is a hole if dropped: + * + * * A relative PATH entry is skipped. It resolves against the current directory, so + * what it names depends on where the launcher happened to be started. + * * The FIRST name match decides the outcome, and an unsafe first match is a refusal + * rather than a reason to keep looking. Falling through to a later entry would let a + * planted unsafe binary silently downgrade the search to whatever came after it, + * which inverts the precedence PATH is supposed to express. + * * A symlink is followed, and the real file it lands on is what gets validated and + * executed. Validating the link and executing the name would repeat the original bug + * one level down. + */ +export function resolveExecutableFromPath( + name: string, + searchPath: string | undefined, +): ResolvedExecutable { + if (name.includes('/')) { + throw executableRefusal(name, 'expected a bare command name, not a path'); + } + if (searchPath === undefined || searchPath === '') { + throw executableRefusal(name, 'no PATH was declared for the launch'); + } + const owner = typeof process.getuid === 'function' ? process.getuid() : undefined; + + for (const entry of searchPath.split(delimiter)) { + if (entry === '' || !isAbsolute(entry)) continue; + const candidate = join(entry, name); + if (!existsSync(candidate)) continue; + + // First match wins, for good or ill. Everything below either returns or throws. + const real = realpathSync(candidate); + const info = lstatSync(real); + if (!info.isFile()) { + throw executableRefusal(name, `${real} is not a regular file`); + } + if ((info.mode & 0o111) === 0) { + throw executableRefusal(name, `${real} is not executable`); + } + if ((info.mode & 0o022) !== 0) { + throw executableRefusal(name, `${real} is writable by group or other`); + } + if (owner !== undefined && info.uid !== owner && info.uid !== 0) { + throw executableRefusal(name, `${real} is owned by neither the launching user nor root`); + } + assertSafeAncestry(real, name, owner); + return { path: real, dev: info.dev, ino: info.ino }; + } + throw executableRefusal(name, `not found on the declared PATH`); +} + +/** + * Re-confirm, immediately before spawning, that the path still names the object that was + * validated. + * + * This narrows the window between validation and exec; it does not close it. Closing it + * would mean executing a held descriptor, and there is no portable way to exec by + * descriptor from Node. The residual is a same-UID replacement landing inside the + * remaining window, which is the same accepted boundary already documented for the fleet + * helper. Stated rather than engineered around, so nobody reads this as a proof. + */ +function assertUnchangedSinceValidation(executable: ResolvedExecutable, name: string): void { + const now = lstatSync(executable.path); + if (now.dev !== executable.dev || now.ino !== executable.ino) { + throw executableRefusal(name, `${executable.path} was replaced after it was validated`); + } +} + function checkSoul(): void { const soulPath = join(MOSAIC_HOME, 'SOUL.md'); if (!existsSync(soulPath)) { @@ -346,16 +471,37 @@ function printSettingsWarnings(audit: SettingsAudit): void { ); } -function resolveExecutable(name: string): string { - const result = spawnSync('which', [name], { encoding: 'utf8' }); - const path = result.status === 0 ? result.stdout.trim() : ''; - if (!path || !isAbsolute(path) || !existsSync(path)) { - throw new Error(`required helper executable is unavailable: ${name}`); - } - return path; +interface TrustedCapability { + readonly path: string; + readonly content: Buffer; + readonly dev: number | bigint; + readonly ino: number | bigint; } -function trustedFleetHelper(mosaicHome: string): string { +/** The fleet helper accepts capabilities only from root-owned /usr/bin. */ +function trustedCapability(name: string): TrustedCapability { + const candidate = join('/usr/bin', name); + let path: string; + try { + path = realpathSync(candidate); + if (!path.startsWith('/usr/')) throw new Error('resolved outside /usr'); + const snapshot = readRegularFileSecure(path, { + root: '/', + executable: true, + maxBytes: 64 * 1024 * 1024, + }); + const info = lstatSync(path); + if ((info.mode & 0o022) !== 0 || info.uid !== 0) + throw new Error('unsafe capability owner or mode'); + return { path, content: snapshot.content, dev: snapshot.dev, ino: snapshot.ino }; + } catch (error: unknown) { + throw new Error( + `required trusted fleet capability is unavailable: ${name}: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +function trustedFleetHelper(mosaicHome: string): TrustedCapability { const root = resolve(mosaicHome); const checker = join(root, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'); try { @@ -389,39 +535,59 @@ function trustedFleetHelper(mosaicHome: string): string { `fleet sequential-thinking helper is not a trusted installed file under ${root}: ${error instanceof Error ? error.message : String(error)}`, ); } - return checker; + const snapshot = readRegularFileSecure(checker, { + root, + executable: true, + maxBytes: 1024 * 1024, + }); + return { path: checker, content: snapshot.content, dev: snapshot.dev, ino: snapshot.ino }; } export function checkSequentialThinking(runtime: RuntimeName, fleet?: FleetHarnessContext): void { // Fleet launch must use the active --mosaic-home installation. Non-fleet // launches retain the package/deployed helper resolver. - const checker = fleet?.mosaicHome - ? trustedFleetHelper(fleet.mosaicHome) - : fwScript('mosaic-ensure-sequential-thinking'); - if (!existsSync(checker)) return; // Skip if checker doesn't exist + if (!fleet?.mosaicHome) { + const checker = fwScript('mosaic-ensure-sequential-thinking'); + if (!existsSync(checker)) return; + const result = spawnSync(checker, ['--check', '--runtime', runtime], { stdio: 'ignore' }); + if (result.status !== 0) process.exit(1); + return; + } + const helper = trustedFleetHelper(fleet.mosaicHome); + const bash = trustedCapability('bash'); + const python = trustedCapability('python3'); + const node = trustedCapability('node'); + const npx = trustedCapability('npx'); + const timeout = trustedCapability('timeout'); const fleetClaudeConfig = runtime === 'claude' && fleet ? harnessHome('claude', fleet) : undefined; const fleetCodexHome = runtime === 'codex' && fleet ? harnessHome('codex', fleet) : undefined; const fleetOpenCodeHome = runtime === 'opencode' && fleet ? harnessHome('opencode', fleet) : undefined; - const python = resolveExecutable('python3'); - const node = resolveExecutable('node'); - const npx = resolveExecutable('npx'); - const capabilityPath = [...new Set([dirname(python), dirname(node), dirname(npx)])].join(':'); const result = spawnSync( - checker, + bash.path, [ + '-s', + '--', '--check', '--runtime', runtime, + '--python-bin', + python.path, + '--node-bin', + node.path, + '--npx-bin', + npx.path, + '--timeout-bin', + timeout.path, ...(fleetClaudeConfig === undefined ? [] : ['--claude-config-dir', fleetClaudeConfig]), ], { - stdio: 'ignore', + input: helper.content, + stdio: ['pipe', 'ignore', 'ignore'], env: { - HOME: process.env['HOME'] ?? '', - PATH: capabilityPath, - LANG: process.env['LANG'] ?? 'C.UTF-8', + HOME: fleetClaudeConfig ?? join(fleet.agentDir, '.mosaic-seq-home'), + LANG: 'C.UTF-8', ...(process.env['MOSAIC_SEQ_CHECK_WARM'] === undefined ? {} : { MOSAIC_SEQ_CHECK_WARM: process.env['MOSAIC_SEQ_CHECK_WARM'] }), @@ -436,8 +602,10 @@ export function checkSequentialThinking(runtime: RuntimeName, fleet?: FleetHarne if (result.status !== 0) { console.error('[mosaic] ERROR: sequential-thinking MCP is required but not configured.'); const repairArgs = - fleetClaudeConfig === undefined ? '' : ` --claude-config-dir ${fleetClaudeConfig}`; - console.error(`[mosaic] Fix: ${checker} --runtime ${runtime}${repairArgs}`); + fleetClaudeConfig === undefined + ? '' + : ` --claude-config-dir ${fleetClaudeConfig} --python-bin ${python.path} --node-bin ${node.path} --npx-bin ${npx.path} --timeout-bin ${timeout.path}`; + console.error(`[mosaic] Fix: ${helper.path} --runtime ${runtime}${repairArgs}`); process.exit(1); } } @@ -997,6 +1165,7 @@ function getMissionPrompt(): string { } interface RuntimeLaunchContext { + readonly mosaicHome?: string; readonly fleet?: FleetHarnessContext; readonly declaredEnv?: Readonly>; /** Test seam: bypass only final runtime binary discovery. */ @@ -1027,16 +1196,56 @@ function minimalLaunchEnv(declared: Readonly>): NodeJS.Pr return { ...env, ...declared }; } +/** + * The PATH the launched child will actually receive. + * + * Resolution has to consult this exact value and not `process.env.PATH`. If the declared + * environment overrides PATH, validating against the launcher's own PATH would check one + * set of directories and hand the child a different set -- a check answering a question + * nobody asked. + */ +function launchSearchPath( + declared: Readonly> | undefined, +): string | undefined { + return declared?.['PATH'] ?? process.env['PATH']; +} + function launchRuntime( runtime: RuntimeName, args: string[], yolo: boolean, context: RuntimeLaunchContext = {}, ): never { - checkMosaicHome(); - checkFile(join(MOSAIC_HOME, 'AGENTS.md'), 'AGENTS.md'); - checkSoul(); - (context.runtimeCheck ?? checkRuntime)(runtime); + const mosaicHome = context.mosaicHome ?? MOSAIC_HOME; + if (context.mosaicHome === undefined) { + checkMosaicHome(); + checkFile(join(MOSAIC_HOME, 'AGENTS.md'), 'AGENTS.md'); + checkSoul(); + } else { + if (!existsSync(mosaicHome)) throw new Error(`Mosaic home not found: ${mosaicHome}`); + checkFile(join(mosaicHome, 'AGENTS.md'), 'AGENTS.md'); + if (!existsSync(join(mosaicHome, 'SOUL.md'))) { + throw new Error(`SOUL.md not found: ${mosaicHome}`); + } + } + // A fleet launch resolves and validates the runtime binary here, once, and reuses that + // exact object below. `checkRuntime`'s ambient `which` stays on the operator path only: + // it proves reachability from the operator's own shell, which is the right question + // there and the wrong one for a seat. Kept in the same position in the sequence so a + // missing runtime still fails before the session lock is written. + let resolvedRuntime: ResolvedExecutable | undefined; + if (context.runtimeCheck) { + context.runtimeCheck(runtime); + } else if (context.fleet) { + try { + resolvedRuntime = resolveExecutableFromPath(runtime, launchSearchPath(context.declaredEnv)); + } catch (error: unknown) { + console.error(`[mosaic] ERROR: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } + } else { + checkRuntime(runtime); + } // Pi doesn't need sequential-thinking (has native thinking levels) if (runtime !== 'pi') { @@ -1045,7 +1254,7 @@ function launchRuntime( checkResumableSession(); - const missionPrompt = getMissionPrompt(); + const missionPrompt = context.mosaicHome === undefined ? getMissionPrompt() : ''; const hasMissionNoArgs = missionPrompt && args.length === 0; const label = RUNTIME_LABELS[runtime]; const modeStr = yolo ? ' in YOLO mode' : ''; @@ -1077,7 +1286,7 @@ function launchRuntime( const settingsAudit = auditClaudeSettings(context.fleet); printSettingsWarnings(settingsAudit); - const prompt = buildRuntimePrompt('claude', contractEnv); + const prompt = composeContract('claude', mosaicHome, contractEnv); const cliArgs: string[] = []; cliArgs.push('--append-system-prompt', prompt); if (hasMissionNoArgs) { @@ -1113,7 +1322,7 @@ function launchRuntime( } console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`); recordLaunch('codex', cliArgs, yolo, context.fleet, launchEnv); - execRuntime('codex', cliArgs, { + execRuntime(resolvedRuntime ?? 'codex', cliArgs, { ...launchEnv, ...harnessEnv('codex', context.fleet), ...(process.env['MOSAIC_LAUNCH_ID'] @@ -1132,7 +1341,7 @@ function launchRuntime( ); console.log(`[mosaic] Launching ${label}${modeStr}...`); recordLaunch('opencode', args, yolo, context.fleet, launchEnv); - execRuntime('opencode', args, { + execRuntime(resolvedRuntime ?? 'opencode', args, { ...launchEnv, ...harnessEnv('opencode', context.fleet), ...(process.env['MOSAIC_LAUNCH_ID'] @@ -1143,7 +1352,7 @@ function launchRuntime( } case 'pi': { - const prompt = buildRuntimePrompt('pi', contractEnv); + const prompt = composeContract('pi', mosaicHome, contractEnv); const cliArgs = ['--append-system-prompt', prompt]; cliArgs.push(...buildPiSkillArgs(args)); cliArgs.push(...discoverPiExtension()); @@ -1189,8 +1398,13 @@ function execLeaseGatedRuntime( ): void { const launcher = resolveTool('lease-broker', 'launch-runtime.py'); const dangerousArgs = dangerous ? ['--dangerous'] : []; + // The interpreter that starts the lease gate must not itself come off an ambient PATH: + // a shim here does not bypass one check, it replaces the process that enforces all of + // them. On the fleet path take the same root-owned capability the helper already + // requires. The operator path keeps name resolution, as it does everywhere else. + const interpreter = fleet ? trustedCapability('python3') : 'python3'; execRuntime( - 'python3', + interpreter, [launcher, ...dangerousArgs, '--runtime', runtime, '--', runtime, ...args], { ...baseEnv, @@ -1207,38 +1421,44 @@ export function launchFleetRuntime( args: string[], declaredEnv: Readonly>, fleet: FleetHarnessContext, -): never { - return launchRuntime(runtime, args, false, { fleet, declaredEnv }); -} - -/** Bounded production-path test seam; all preflight and composition remain real. */ -export function launchFleetRuntimeForTest( - runtime: RuntimeName, - args: string[], - declaredEnv: Readonly>, - fleet: FleetHarnessContext, - finalExecutor: NonNullable, ): never { return launchRuntime(runtime, args, false, { + mosaicHome: fleet.mosaicHome, fleet, declaredEnv, - runtimeCheck: () => undefined, - finalExecutor, - recordLaunch: false, }); } -/** exec into the runtime, replacing the current process. */ -function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): void { +/** + * exec into the runtime, replacing the current process. + * + * `cmd` is either a bare name -- the operator path, where the OS resolves it against the + * caller's own PATH -- or an already-resolved executable, which is what every fleet + * launch passes. In the resolved case the exact validated path is spawned and its + * identity is re-confirmed first, so the thing that was checked is the thing that runs. + */ +function execRuntime( + cmd: string | ResolvedExecutable, + args: string[], + env: NodeJS.ProcessEnv = process.env, +): void { + const label = typeof cmd === 'string' ? cmd : cmd.path; try { + let target: string; + if (typeof cmd === 'string') { + target = cmd; + } else { + assertUnchangedSinceValidation(cmd, cmd.path); + target = cmd.path; + } // Use execFileSync with inherited stdio to replace the process - const result = spawnSync(cmd, args, { + const result = spawnSync(target, args, { stdio: 'inherit', env, }); process.exit(result.status ?? 0); } catch (err) { - console.error(`[mosaic] Failed to launch ${cmd}:`, err instanceof Error ? err.message : err); + console.error(`[mosaic] Failed to launch ${label}:`, err instanceof Error ? err.message : err); process.exit(1); } }