From 4a7fc07ee2a0cfb6d0fd2d13b1947a3ffb870fe0 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 6 Aug 2026 16:56:35 -0500 Subject: [PATCH] feat(launch): isolate harness homes and record immutable launch provenance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mosaic wrote into the operator's harness base installs — ~/.claude, ~/.pi/agent, ~/.codex, ~/.config/opencode — for settings, instructions, and a 102-symlink skill farm per harness. Any experiment with hooks or gating therefore mutated the operator's own tooling, and a broken framework change could take out the very harness needed to repair it. Harness home isolation ---------------------- Each runtime now reads config from a dedicated mosaic-owned home via the harness's own config-dir variable: claude CLAUDE_CONFIG_DIR ~/.config/mosaic/.claude pi PI_CODING_AGENT_DIR ~/.config/mosaic/.pi (replaces ~/.pi/agent) codex CODEX_HOME ~/.config/mosaic/.codex opencode XDG_CONFIG_HOME ~/.config/mosaic/.opencode These paths are manifest-UNKNOWN, so rule 3 (#791) resolves them to operator ownership and a keep-mode upgrade can neither overwrite nor prune them. A bare `claude` / `pi` keeps its own config AND auth, making it a structural break-glass rather than one depending on restoring a file under pressure. opencode is blunter than the rest: it has no dedicated variable and follows XDG, so isolation also relocates XDG lookups for anything it spawns. Documented in place. mosaic-sync-skills now links into those homes and cleans the legacy farms it previously planted in base installs. Ownership is proven by RESOLUTION, not by name — only symlinks resolving inside the canonical/local skills dirs are removed, mirroring the refusal already in commands/skill.js. Verified against a real install: codex's own .system directory survived while its 102 mosaic links were removed. Both resolution prefixes are length-checked first; an empty prefix would make "$resolved" == "$prefix/"* match every absolute path and delete foreign symlinks. Immutable launch record ----------------------- Every launch now appends one record to fleet/run/sessions/events.ndjson before exec. Mandatory, mechanical, no model involvement. pi rewrites its own argv to a bare `pi`, so /proc//cmdline destroys the launch evidence — that has already produced a confident wrong diagnosis ("this agent bypassed the launcher"), disproved only by the parent's argv and only because the parent had not yet exited. A record written before exec is the only place this survives. The path is the #797 Runtime Session Ledger, already operator-classified and already covered by test-upgrade-manifest-guard.sh, which seeds it and proves a populated ledger survives keep-mode upgrades — but nothing shipped ever wrote it. This implements it in the shape that guard already asserts (0600 files under a 0700 dir). `mosaic` writes session.launch; launch-runtime.py appends lease.register with the broker session id and activation capability. They correlate by an explicit MOSAIC_LAUNCH_ID, never by pid: execRuntime uses spawnSync, so the runtime is a child with a different pid. Records normative fragment digests (CONSTITUTION/AGENTS/SOUL/USER/STANDARDS/ TOOLS/RUNTIME) — the same set the broker hashes for promotion, so drift is mechanically detectable rather than a matter of judgement. Credential-safe: env is captured as PRESENT NAMES ONLY, and argv values over 256 bytes become a sha256 + length rather than being inlined. Also fixes CLI_VERSION resolution: '@mosaicstack/mosaic/package.json' is not in the package exports map and always throws ERR_PACKAGE_PATH_NOT_EXPORTED. resolveTool() uses that same failing specifier, which is why its documented preference for bundled tools over the deployed ~/.config/mosaic copy has never once applied — noted in place, not fixed here. Verified on sb-it-1-dt: isolated homes written and base installs byte-identical for all four harnesses; 408 legacy symlinks removed with 1 foreign entry preserved; launch records paired across the spawn boundary. typecheck shows zero errors in launch.ts (the @mosaicstack/types failures are pre-existing and reproduce on a pristine origin/main worktree). --- .../tools/_scripts/mosaic-sync-skills | 71 +++++++ .../tools/lease-broker/launch-runtime.py | 73 ++++++- packages/mosaic/src/commands/launch.ts | 183 +++++++++++++++++- 3 files changed, 318 insertions(+), 9 deletions(-) diff --git a/packages/mosaic/framework/tools/_scripts/mosaic-sync-skills b/packages/mosaic/framework/tools/_scripts/mosaic-sync-skills index c4c3c202..0a9d02f9 100755 --- a/packages/mosaic/framework/tools/_scripts/mosaic-sync-skills +++ b/packages/mosaic/framework/tools/_scripts/mosaic-sync-skills @@ -153,7 +153,24 @@ if [[ $link_only -eq 1 ]]; then exit 0 fi +# Skills are linked into the MOSAIC-OWNED harness homes, never a base install. +# Paths mirror the config-dir env vars the launcher injects (HARNESS_HOME_ENV in +# commands/launch.js): +# claude CLAUDE_CONFIG_DIR -> /skills +# pi PI_CODING_AGENT_DIR -> /skills (replaces ~/.pi/agent) +# codex CODEX_HOME -> /skills +# opencode XDG_CONFIG_HOME -> /opencode/skills (XDG adds a level) link_targets=( + "$MOSAIC_HOME/.claude/skills" + "$MOSAIC_HOME/.codex/skills" + "$MOSAIC_HOME/.opencode/opencode/skills" + "$MOSAIC_HOME/.pi/skills" +) + +# Pre-isolation installs planted the same symlink farm directly in the operator's +# base installs. Those are now orphaned: the launcher no longer reads them, but +# they persist and make a "clean" base install look mosaic-managed. +legacy_link_targets=( "$HOME/.claude/skills" "$HOME/.codex/skills" "$HOME/.config/opencode/skills" @@ -252,6 +269,60 @@ prune_stale_links_in_target() { done < <(find "$target_dir" -mindepth 1 -maxdepth 1 -type l -print0) } +# Remove mosaic-owned symlinks left in a base install by a pre-isolation sync. +# +# Ownership is proven by RESOLUTION, not by name: only links resolving inside the +# canonical or local skills dirs are removed. Anything else — a real directory, a +# link elsewhere, an unresolvable link — is left untouched. This mirrors the +# refusal in commands/skill.js ("only symlinks pointing inside the Mosaic skills +# directory are managed") and preserves e.g. codex's own `.system` dir. +# +# The directory itself is kept: mosaic-doctor warns when ~/.pi/agent/skills is +# missing, and an empty dir is the correct end state, not an absent one. +cleanup_legacy_target() { + local target_dir="$1" + local removed=0 kept=0 + + [[ -d "$target_dir" ]] || return 0 + + while IFS= read -r -d '' link_path; do + local resolved owned=0 + resolved="$(readlink -m "$link_path" 2>/dev/null || true)" + + # Guard the empty-prefix trap: an unset *_real would make "$resolved" == "/"* + # match every absolute path and delete foreign links. + if [[ -n "$resolved" ]]; then + if [[ -n "$canonical_real" && "$resolved" == "$canonical_real/"* ]]; then + owned=1 + elif [[ -n "$local_real" && "$resolved" == "$local_real/"* ]]; then + owned=1 + fi + fi + + if [[ $owned -eq 1 ]]; then + rm -f "$link_path" + removed=$((removed + 1)) + else + kept=$((kept + 1)) + fi + done < <(find "$target_dir" -mindepth 1 -maxdepth 1 -type l -print0) + + if [[ $removed -gt 0 ]]; then + echo "[mosaic-skills] Legacy cleanup: removed $removed mosaic symlink(s) from $target_dir (preserved $kept foreign)" + fi +} + +for legacy in "${legacy_link_targets[@]}"; do + # Skip anything that is also a current target, so isolation can never + # self-destruct if the two lists ever overlap. + skip=0 + for target in "${link_targets[@]}"; do + [[ "$legacy" == "$target" ]] && skip=1 + done + [[ $skip -eq 1 ]] && continue + cleanup_legacy_target "$legacy" +done + for target in "${link_targets[@]}"; do mkdir -p "$target" diff --git a/packages/mosaic/framework/tools/lease-broker/launch-runtime.py b/packages/mosaic/framework/tools/lease-broker/launch-runtime.py index c0fe8259..30a4f756 100644 --- a/packages/mosaic/framework/tools/lease-broker/launch-runtime.py +++ b/packages/mosaic/framework/tools/lease-broker/launch-runtime.py @@ -8,7 +8,9 @@ import json import os import socket import sys +import time from collections.abc import Callable, Mapping, Sequence +from datetime import datetime, timezone from pathlib import Path from typing import Final @@ -53,6 +55,48 @@ def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, o return value +def _self_starttime() -> str | None: + """Field 22 of our own /proc stat — the anchor starttime the broker records. + + Read past the comm field's parens, since a process name may contain them. + """ + try: + raw = Path(f"/proc/{os.getpid()}/stat").read_text() + return raw.rsplit(")", 1)[1].split()[19] + except (OSError, IndexError, ValueError): + return None + + +def _append_launch_record(environ: Mapping[str, str], record: dict[str, object]) -> None: + """Append one NDJSON event to the #797 Runtime Session Ledger. + + `fleet/run/sessions/` is operator-classified in framework-manifest.txt and is + already covered by test-upgrade-manifest-guard.sh, so an upgrade can neither + overwrite nor prune it. Files 0600 under a 0700 dir, matching what that guard + asserts. + + Never raises: a launch must not be denied over bookkeeping. But it also never + fails silently — a missing record is exactly the kind of gap that made the + 2026-08-06 MUTATOR_UNVERIFIED investigation cost a day. + """ + try: + mosaic_home = environ.get("MOSAIC_HOME") or str(Path.home() / ".config" / "mosaic") + directory = Path(mosaic_home) / "fleet" / "run" / "sessions" + directory.mkdir(parents=True, exist_ok=True) + os.chmod(directory, 0o700) + framed = { + "seq": time.time_ns() // 1_000_000, + "ts": datetime.now(timezone.utc).isoformat(), + **record, + } + path = directory / "events.ndjson" + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) + with os.fdopen(descriptor, "w") as handle: + handle.write(json.dumps(framed, separators=(",", ":")) + "\n") + except (OSError, ValueError, TypeError) as error: + print(f"[mosaic] WARNING: launch record not written: {error}", file=sys.stderr) + + def main( argv: Sequence[str] | None = None, *, @@ -94,8 +138,9 @@ def main( # silent pass and never folded into the generic registration-failure # branch. try: + activation_capability = probe_activation_capability(source_environment) assert_activation_capability_matches( - probe_activation_capability(source_environment), + activation_capability, expected_activation_capability, ) except VersionCouplingError as version_error: @@ -128,6 +173,32 @@ def main( print("Mosaic lease broker registration failed; runtime launch denied.", file=sys.stderr) return 1 + # Immutable launch record, half two. `mosaic` wrote `session.launch` with the + # config/provenance it knows; only this process knows the broker session id + # and the activation capability it just asserted. os.execvpe preserves the + # PID, so this PID is BOTH the anchor pid and the join key back to that + # record. Never fatal — bookkeeping must not deny a launch — but never + # silent either. + _append_launch_record( + source_environment, + { + "kind": "lease.register", + # Joins back to `mosaic`'s session.launch record. NOT pid: execRuntime() + # spawns rather than execs, so this process is a CHILD of mosaic with a + # different pid. This pid IS the broker anchor pid (os.execvpe below + # preserves it), which is a separate and still-useful fact. + "launch_id": source_environment.get("MOSAIC_LAUNCH_ID"), + "pid": os.getpid(), + "runtime": arguments.runtime, + "session_id": session_id, + "runtime_generation": generation, + "generation_file": str(generation_file), + "anchor_starttime": _self_starttime(), + "activation_capability": activation_capability, + "command": Path(command[0]).name, + }, + ) + environment = dict(source_environment) environment["MOSAIC_LEASE_SESSION_ID"] = session_id environment["MOSAIC_RUNTIME_GENERATION"] = str(generation) diff --git a/packages/mosaic/src/commands/launch.ts b/packages/mosaic/src/commands/launch.ts index 3a0dd998..c4d8f3d2 100644 --- a/packages/mosaic/src/commands/launch.ts +++ b/packages/mosaic/src/commands/launch.ts @@ -14,9 +14,11 @@ import { readdirSync, realpathSync, rmSync, + appendFileSync, } from 'node:fs'; +import { createHash, randomBytes } from 'node:crypto'; import { createRequire } from 'node:module'; -import { homedir } from 'node:os'; +import { homedir, hostname } from 'node:os'; import { join, dirname } from 'node:path'; import type { Command } from 'commander'; import { @@ -42,6 +44,163 @@ const RUNTIME_LABELS: Record = { pi: 'Pi', }; +// ─── Harness home isolation ────────────────────────────────────────────────── +// Mosaic-launched runtimes read config from a dedicated home under the mosaic +// tree — never the operator's base install. A bare `claude` / `pi` therefore +// keeps its own config AND its own auth, and stays a working break-glass no +// matter what mosaic does to its own tree. +// +// These paths are manifest-UNKNOWN, which resolves to operator ownership +// (framework-manifest.txt rule 3, #791), so a keep-mode `mosaic update` can +// neither overwrite nor prune them. Overwrite-mode install still would. +// +// opencode has no dedicated config-dir variable and follows XDG, so isolating it +// sets XDG_CONFIG_HOME for that process tree. That is blunter than the other +// three: it also relocates XDG lookups for anything opencode spawns. +const HARNESS_HOME_ENV: Record = { + claude: 'CLAUDE_CONFIG_DIR', + pi: 'PI_CODING_AGENT_DIR', + codex: 'CODEX_HOME', + opencode: 'XDG_CONFIG_HOME', +}; + +/** Dedicated mosaic-owned home for a runtime: ~/.config/mosaic/. */ +function harnessHome(runtime: RuntimeName): string { + return join(MOSAIC_HOME, `.${runtime}`); +} + +/** + * Env overlay pointing a runtime at its mosaic-owned home. The directory is + * created on demand so a first launch does not fail on a missing path. + */ +function harnessEnv(runtime: RuntimeName): Record { + const key = HARNESS_HOME_ENV[runtime]; + if (!key) return {}; + const home = harnessHome(runtime); + mkdirSync(home, { recursive: true }); + return { [key]: home }; +} + +// ─── Launch record (immutable provenance) ──────────────────────────────────── +// MANDATORY and MECHANICAL: every launch appends one record of what the agent +// actually launched with, written before exec. No model involvement, no opt-out. +// +// WHY LAUNCH-TIME AND NOT INSPECT-LATER: pi rewrites its own argv to a bare +// `pi`, so /proc//cmdline DESTROYS the launch evidence. That has already +// produced a confident wrong diagnosis ("this agent bypassed the launcher"), +// disproved only by the parent process's argv and only because the parent had +// not yet exited. A record written before exec is the only place this survives. +// +// Lands in fleet/run/sessions/ — the #797 Runtime Session Ledger path, already +// operator-classified in framework-manifest.txt and already covered by +// test-upgrade-manifest-guard.sh, so an upgrade can neither overwrite nor prune +// it. +// +// CORRELATION is by an explicit MOSAIC_LAUNCH_ID, never by pid: execRuntime() +// uses spawnSync, so the runtime is a CHILD with a different pid. +// launch-runtime.py appends the matching `lease.register` event. +// +// NEVER records a credential value: env is captured as PRESENT NAMES ONLY, and +// oversized argv values (the composed system prompt) become a digest + length. +const LAUNCH_LEDGER_DIR = join(MOSAIC_HOME, 'fleet', 'run', 'sessions'); + +const CLI_VERSION: string | null = (() => { + try { + // Resolved RELATIVELY: the package `exports` map does not expose + // package.json, so '@mosaicstack/mosaic/package.json' throws + // ERR_PACKAGE_PATH_NOT_EXPORTED. Same relative depth from src/ and dist/. + return (createRequire(import.meta.url)('../../package.json') as { version: string }).version; + } catch { + return null; + } +})(); + +interface NormativeFragmentDigest { + source_id: string; + sha256: string | null; + bytes: number | null; + missing?: boolean; +} + +function sha256Of(value: string | Buffer): string { + return createHash('sha256').update(value).digest('hex'); +} + +/** + * Hash the normative sources injected into the agent. This is "what the agent + * IS" — and it is the same fragment set the lease broker hashes for promotion, + * so an unexpected digest here is a mechanically detectable red flag rather than + * a matter of judgement. + */ +function normativeFragmentDigests(runtime: RuntimeName): 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')], + ]; + return candidates.map(([sourceId, path]) => { + try { + const bytes = readFileSync(path); + return { source_id: sourceId, sha256: sha256Of(bytes), bytes: bytes.length }; + } catch { + return { source_id: sourceId, sha256: null, bytes: null, missing: true }; + } + }); +} + +/** argv with oversized values replaced by a digest, so the record stays small + * and never inlines injected content verbatim. */ +function redactArgv(argv: string[]): string[] { + return argv.map((a) => + typeof a === 'string' && a.length > 256 + ? `` + : a, + ); +} + +function recordLaunch(runtime: RuntimeName, cliArgs: string[], yolo: boolean): void { + try { + mkdirSync(LAUNCH_LEDGER_DIR, { 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')}`; + process.env['MOSAIC_LAUNCH_ID'] = launchId; + const record = { + seq: Date.now(), + kind: 'session.launch', + launch_id: launchId, + ts: new Date().toISOString(), + host: hostname(), + pid: process.pid, + runtime, + mode: yolo ? 'yolo' : 'normal', + cwd: process.cwd(), + cli_version: CLI_VERSION, + config_home: harnessHome(runtime), + config_home_isolated: true, + config_home_env: HARNESS_HOME_ENV[runtime] ?? null, + argv: redactArgv(cliArgs), + normative_fragments: normativeFragmentDigests(runtime), + // names only — values are never recorded + mosaic_env_present: Object.keys(process.env) + .filter((k) => k.startsWith('MOSAIC_')) + .sort(), + }; + appendFileSync(join(LAUNCH_LEDGER_DIR, 'events.ndjson'), `${JSON.stringify(record)}\n`, { + mode: 0o600, + }); + } catch (err) { + // Never block a launch on bookkeeping — but never fail silently either. + console.error( + `[mosaic] WARNING: launch record not written: ${err instanceof Error ? err.message : String(err)}`, + ); + } +} + // ─── Pre-flight checks ────────────────────────────────────────────────────── function checkMosaicHome(): void { @@ -105,11 +264,11 @@ interface SettingsAudit { function auditClaudeSettings(): SettingsAudit { const warnings: string[] = []; - const settingsPath = join(homedir(), '.claude', 'settings.json'); + const settingsPath = join(harnessHome('claude'), 'settings.json'); const settings = readJson(settingsPath); if (!settings) { - warnings.push('~/.claude/settings.json not found — hooks and plugins will be missing'); + warnings.push(`${settingsPath} not found — hooks and plugins will be missing`); return { warnings }; } @@ -561,7 +720,9 @@ function skillRealPath(dir: string): string { /** Skill roots Pi auto-discovers natively (no `--skill` needed): its global * skills dir and the project-local one relative to the launch cwd. */ function piNativeSkillRoots(cwd: string = process.cwd()): string[] { - return [join(homedir(), '.pi', 'agent', 'skills'), join(cwd, '.pi', 'skills')]; + // PI_CODING_AGENT_DIR replaces ~/.pi/agent (not ~/.pi), so skills live at + // /skills — there is no extra 'agent' segment under the isolated home. + return [join(harnessHome('pi'), 'skills'), join(cwd, '.pi', 'skills')]; } /** Enumerate skill dirs under a set of roots, deduped by real path. A directory @@ -764,12 +925,13 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev cliArgs.push(...args); } console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`); + recordLaunch('claude', cliArgs, yolo); execLeaseGatedRuntime('claude', cliArgs, process.env, yolo); break; } case 'codex': { - ensureRuntimeConfig('codex', join(homedir(), '.codex', 'instructions.md')); + ensureRuntimeConfig('codex', join(harnessHome('codex'), 'instructions.md')); const cliArgs = yolo ? ['--dangerously-bypass-approvals-and-sandbox'] : []; if (hasMissionNoArgs) { cliArgs.push(missionPrompt); @@ -777,14 +939,17 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev cliArgs.push(...args); } console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`); - execRuntime('codex', cliArgs); + recordLaunch('codex', cliArgs, yolo); + execRuntime('codex', cliArgs, { ...process.env, ...harnessEnv('codex') }); break; } case 'opencode': { - ensureRuntimeConfig('opencode', join(homedir(), '.config', 'opencode', 'AGENTS.md')); + // opencode follows XDG, so its config resolves to $XDG_CONFIG_HOME/opencode. + ensureRuntimeConfig('opencode', join(harnessHome('opencode'), 'opencode', 'AGENTS.md')); console.log(`[mosaic] Launching ${label}${modeStr}...`); - execRuntime('opencode', args); + recordLaunch('opencode', args, yolo); + execRuntime('opencode', args, { ...process.env, ...harnessEnv('opencode') }); break; } @@ -799,6 +964,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev cliArgs.push(...args); } console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`); + recordLaunch('pi', cliArgs, yolo); execLeaseGatedRuntime('pi', cliArgs); break; } @@ -835,6 +1001,7 @@ function execLeaseGatedRuntime( [launcher, ...dangerousArgs, '--runtime', runtime, '--', runtime, ...args], { ...baseEnv, + ...harnessEnv(runtime), MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(baseEnv), MOSAIC_RUNTIME_GENERATION: baseEnv['MOSAIC_RUNTIME_GENERATION'] ?? '1', },