diff --git a/packages/mosaic/src/commands/launch-child-env.spec.ts b/packages/mosaic/src/commands/launch-child-env.spec.ts new file mode 100644 index 00000000..e406992c --- /dev/null +++ b/packages/mosaic/src/commands/launch-child-env.spec.ts @@ -0,0 +1,338 @@ +/** + * What the launched runtime actually receives in its environment. + * + * These tests do not inspect `minimalLaunchEnv` and do not use a test seam. They run the real + * `fleet launch` route -- register, apply, compose, lease gate, exec -- with a shim standing in + * for the runtime binary, and the shim dumps its own environment. So the thing under test is the + * environment at the far end of the whole chain, after `launch-runtime.py` has added the lease + * variables, rather than the object the launcher believed it was building. The two differ, and + * only the first one matters. + * + * The property being defended: an operator's environment is large, grows over time, and contains + * names that make a child execute code before its first instruction (`BASH_ENV`, `PYTHONSTARTUP`, + * `NODE_OPTIONS`, `LD_PRELOAD`) as well as credentials for accounts the seat is deliberately not + * pegged to. A composed seat must receive a closed set of names, and "closed" is only true if + * something measures it. + */ +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Command } from 'commander'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { registerFleetLaunchCommand } from './fleet-launch-command.js'; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +/** + * Names permitted to reach a composed seat, written out rather than derived from the launcher. + * + * Deriving it would make the test agree with the code by construction and detect nothing. The + * cost of a literal list is that adding a variable means editing this file, which is the point: + * a new name in a seat's environment should be a decision someone made, not a side effect. + * + * PWD, SHLVL and `_` are absent because the shim's own shell sets them after exec; they are + * filtered at the measurement site, not permitted here. + */ +const PERMITTED_CHILD_ENV = new Set([ + // inherited from the operator by the launcher's allowlist + 'PATH', + 'HOME', + 'USER', + 'LOGNAME', + 'SHELL', + 'TERM', + 'COLORTERM', + 'TMPDIR', + 'XDG_RUNTIME_DIR', + // fixed by the launcher + 'LANG', + 'LC_ALL', + // declared by the seat profile and composition + 'CLAUDE_CONFIG_DIR', + 'MOSAIC_AGENT_NAME', + 'SEAT_FLAG', + // minted per launch for ledger correlation + 'MOSAIC_LAUNCH_ID', + // added by the lease gate in launch-runtime.py + 'MOSAIC_LEASE_BROKER_SOCKET', + 'MOSAIC_LEASE_GENERATION_FILE', + 'MOSAIC_LEASE_RUNTIME', + 'MOSAIC_LEASE_SESSION_ID', + 'MOSAIC_RECEIPT_OBSERVER_SOCKET', + 'MOSAIC_RUNTIME_GENERATION', +]); + +/** + * Operator environment that must not survive composition. + * + * Three classes, all real. Loader hooks run attacker-chosen code inside the runtime before it + * does anything (`BASH_ENV`/`ENV` for shells, `PYTHON*` for the interpreter that runs the lease + * gate, `NODE_*` for the runtime itself, `LD_*` for every dynamically linked binary in the tree). + * Package configuration redirects where code is fetched from. Provider credentials belong to the + * operator's accounts, and a seat pegged to its own auth bundle that can still read them is not + * pegged to anything. + * + * The values are distinctive so the diagnostics check below can search for them by content. + */ +const OPERATOR_ONLY_ENV: Record = { + BASH_ENV: '/poison-a1b2/bash_env.sh', + ENV: '/poison-a1b2/env.sh', + PYTHONPATH: '/poison-a1b2/pythonpath', + PYTHONSTARTUP: '/poison-a1b2/pythonstartup.py', + NODE_OPTIONS: '--require /poison-a1b2/preload.js', + NODE_PATH: '/poison-a1b2/node_path', + NPM_CONFIG_PREFIX: '/poison-a1b2/npm_prefix', + NPM_CONFIG_REGISTRY: 'https://poison-a1b2.example.invalid/', + LD_PRELOAD: '/poison-a1b2/preload.so', + LD_LIBRARY_PATH: '/poison-a1b2/lib', + ANTHROPIC_API_KEY: 'poison-a1b2-anthropic-key', + OPENAI_API_KEY: 'poison-a1b2-openai-key', + GH_TOKEN: 'poison-a1b2-github-token', + GITEA_TOKEN: 'poison-a1b2-gitea-token', + AWS_SECRET_ACCESS_KEY: 'poison-a1b2-aws-secret', + SSH_AUTH_SOCK: '/poison-a1b2/ssh-agent.sock', +}; + +interface Fixture { + root: string; + systemHome: string; + userHome: string; + agentDir: string; + seatHome: string; + bin: string; + dump: string; + ledger: string; +} + +function fixture(profileEnv: Record, extraProfile: Record = {}) { + const root = mkdtempSync(join(tmpdir(), 'mosaic-child-env-')); + roots.push(root); + const systemHome = join(root, 'system'); + const userHome = join(root, 'user'); + const agentDir = join(userHome, 'fleet', 'agents', 'fred'); + const seatHome = join(agentDir, '.claude'); + const namedBundleDir = join(userHome, 'auth', 'claude', 'fred_example.com'); + const bin = join(root, 'bin'); + const dump = join(root, 'child-env.txt'); + + mkdirSync(join(systemHome, 'runtime', 'claude'), { recursive: true }); + mkdirSync(join(systemHome, 'tools', '_scripts'), { recursive: true }); + mkdirSync(seatHome, { recursive: true }); + mkdirSync(namedBundleDir, { recursive: true }); + mkdirSync(bin, { recursive: true }); + + writeFileSync( + join(agentDir, 'profile.json'), + `${JSON.stringify({ schema: 1, harness: 'claude', env: profileEnv, ...extraProfile }, null, 2)}\n`, + ); + writeFileSync(join(namedBundleDir, '.credentials.json'), '{}\n', { mode: 0o600 }); + writeFileSync( + join(namedBundleDir, 'account.json'), + '{"oauthAccount":{"emailAddress":"fred@example.com"}}\n', + ); + symlinkSync('fred_example.com', join(userHome, 'auth', 'claude', 'primary'), 'dir'); + + writeFileSync(join(systemHome, 'AGENTS.md'), '# fixture\n'); + writeFileSync(join(systemHome, 'SOUL.md'), '# fixture\n'); + const frameworkSettings = readFileSync( + join(process.cwd(), 'framework', 'runtime', 'claude', 'settings.json'), + ); + writeFileSync(join(systemHome, 'runtime', 'claude', 'settings.json'), frameworkSettings); + writeFileSync( + join(systemHome, 'runtime', 'claude', 'RUNTIME.md'), + readFileSync(join(process.cwd(), 'framework', 'runtime', 'claude', 'RUNTIME.md')), + ); + const helper = join(systemHome, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'); + writeFileSync( + helper, + readFileSync( + join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'), + ), + { mode: 0o700 }, + ); + chmodSync(helper, 0o700); + writeFileSync(join(seatHome, '.claude.json'), frameworkSettings.toString(), { mode: 0o600 }); + + // The shim records its own environment and exits. `claude` is the measurement point; `python3` + // is present only so a PATH lookup for it would succeed -- the lease gate deliberately takes the + // root-owned interpreter instead, so this copy should never run, and the assertions below do not + // depend on which one does. + for (const name of ['claude', 'python3']) { + const path = join(bin, name); + writeFileSync( + path, + `#!/usr/bin/env bash\nenv > ${JSON.stringify(`${dump}.${name}`)}\nexit 0\n`, + { + mode: 0o700, + }, + ); + chmodSync(path, 0o700); + } + + return { + root, + systemHome, + userHome, + agentDir, + seatHome, + bin, + dump, + ledger: join(systemHome, 'fleet', 'run', 'sessions', 'events.ndjson'), + } satisfies Fixture; +} + +/** Run the real launch route with a controlled operator environment. */ +function launch(fx: Fixture, operatorEnv: Record): Map { + const program = new Command().exitOverride(); + const fleet = program.command('fleet'); + // The launcher execs and then exits; the fixture runtime returns instead, so the exit is the + // normal end of this route rather than a failure. + const exit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + const saved = new Map(); + const set = (name: string, value: string): void => { + saved.set(name, process.env[name]); + process.env[name] = value; + }; + + try { + for (const [name, value] of Object.entries(OPERATOR_ONLY_ENV)) set(name, value); + for (const [name, value] of Object.entries(operatorEnv)) set(name, value); + saved.set('PATH', process.env['PATH']); + process.env['PATH'] = `${fx.bin}:${process.env['PATH'] ?? ''}`; + registerFleetLaunchCommand(fleet, () => fx.systemHome, { userHome: fx.userHome }); + try { + program.parse(['node', 'mosaic', 'fleet', 'launch', 'fred']); + } catch { + // exec replaced by the mocked exit above + } + } finally { + exit.mockRestore(); + for (const [name, value] of saved) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + } + + const path = `${fx.dump}.claude`; + if (!existsSync(path)) throw new Error('runtime shim never ran; nothing was measured'); + const env = new Map(); + for (const line of readFileSync(path, 'utf8').split('\n')) { + const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u.exec(line); + // Names the shim's own shell sets after exec, not names the launcher passed. + if (match && !['PWD', 'SHLVL', '_', 'OLDPWD'].includes(match[1]!)) + env.set(match[1]!, match[2]!); + } + return env; +} + +const OPERATOR_BASELINE: Record = { + LANG: 'en_US.UTF-8', + LC_ALL: 'en_US.UTF-8', + TERM: 'xterm-256color', + COLORTERM: 'truecolor', +}; + +describe('composed seat child environment', () => { + it('hands the runtime no name outside the permitted set', () => { + const fx = fixture({ SEAT_FLAG: 'yes' }); + const env = launch(fx, { ...OPERATOR_BASELINE, HOME: join(fx.root, 'operator-home') }); + + const unexpected = [...env.keys()].filter((name) => !PERMITTED_CHILD_ENV.has(name)).sort(); + expect( + unexpected, + 'a name reached the seat that nobody declared; add it to PERMITTED_CHILD_ENV only if it belongs there', + ).toEqual([]); + }); + + it('drops operator loader hooks, package configuration, and provider credentials', () => { + const fx = fixture({ SEAT_FLAG: 'yes' }); + const env = launch(fx, { ...OPERATOR_BASELINE, HOME: join(fx.root, 'operator-home') }); + + const survivors = Object.keys(OPERATOR_ONLY_ENV) + .filter((name) => env.has(name)) + .sort(); + expect(survivors, 'operator-only variables reached the seat').toEqual([]); + }); + + it('gives the runtime the declared seat values, not the operator equivalents', () => { + const fx = fixture({ SEAT_FLAG: 'yes' }); + const env = launch(fx, { ...OPERATOR_BASELINE, HOME: join(fx.root, 'operator-home') }); + + expect(env.get('CLAUDE_CONFIG_DIR')).toBe(fx.seatHome); + expect(env.get('MOSAIC_AGENT_NAME')).toBe('fred'); + expect(env.get('SEAT_FLAG')).toBe('yes'); + }); + + it('fixes the locale instead of inheriting the operator locale', () => { + const fx = fixture({ SEAT_FLAG: 'yes' }); + const env = launch(fx, { + LANG: 'de_DE.UTF-8', + LC_ALL: 'de_DE.UTF-8', + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + HOME: join(fx.root, 'operator-home'), + }); + + expect(env.get('LANG')).toBe('C.UTF-8'); + expect(env.get('LC_ALL')).toBe('C.UTF-8'); + }); + + it('lets a seat that needs a different locale declare one', () => { + const fx = fixture({ SEAT_FLAG: 'yes', LANG: 'de_DE.UTF-8', LC_ALL: 'de_DE.UTF-8' }); + const env = launch(fx, { ...OPERATOR_BASELINE, HOME: join(fx.root, 'operator-home') }); + + expect(env.get('LANG')).toBe('de_DE.UTF-8'); + expect(env.get('LC_ALL')).toBe('de_DE.UTF-8'); + }); + + it('inherits the allowlisted operator values it is supposed to inherit', () => { + const fx = fixture({ SEAT_FLAG: 'yes' }); + const env = launch(fx, { ...OPERATOR_BASELINE, HOME: join(fx.root, 'operator-home') }); + + expect(env.get('TERM')).toBe('xterm-256color'); + expect(env.get('COLORTERM')).toBe('truecolor'); + expect(env.get('PATH')).toContain(fx.bin); + }); + + it('mints a fresh launch id rather than forwarding the operator session id', () => { + const fx = fixture({ SEAT_FLAG: 'yes' }); + const env = launch(fx, { + ...OPERATOR_BASELINE, + HOME: join(fx.root, 'operator-home'), + MOSAIC_LAUNCH_ID: 'operator-session-launch-id', + }); + + const childId = env.get('MOSAIC_LAUNCH_ID'); + expect(childId).toBeDefined(); + expect(childId).not.toBe('operator-session-launch-id'); + // The id is only useful if the ledger records the same one; correlation is by this value and + // never by pid, because exec makes the runtime a different process. + expect(readFileSync(fx.ledger, 'utf8')).toContain(`"launch_id":"${childId}"`); + }); + + it('keeps operator environment values out of the launch ledger', () => { + const fx = fixture({ SEAT_FLAG: 'yes' }); + launch(fx, { ...OPERATOR_BASELINE, HOME: join(fx.root, 'operator-home') }); + + // The ledger records env as present names only, by design. This checks the design holds for + // values as well as for the credential file it was written to protect. + const ledger = readFileSync(fx.ledger, 'utf8'); + for (const [name, value] of Object.entries(OPERATOR_ONLY_ENV)) { + expect(ledger, `ledger leaked the value of ${name}`).not.toContain(value); + } + }); +}); diff --git a/packages/mosaic/src/commands/launch.ts b/packages/mosaic/src/commands/launch.ts index 82d2a687..af9100a8 100644 --- a/packages/mosaic/src/commands/launch.ts +++ b/packages/mosaic/src/commands/launch.ts @@ -1174,8 +1174,38 @@ interface RuntimeLaunchContext { readonly finalExecutor?: (runtime: RuntimeName, args: string[], env: NodeJS.ProcessEnv) => void; } +/** + * Locale for a composed launch. + * + * A seat that inherits the operator's locale behaves differently depending on who happened to + * start it: locale selects message language, collation, and number and date formatting, so the + * same runtime doing the same work emits different text. That is a reproducibility problem for + * the seat and a correctness problem for anything parsing what it prints. + * + * C.UTF-8 rather than C: both are unambiguous, but plain C is ASCII and would mangle non-ASCII + * output, so pinning it would trade one defect for another. A seat that genuinely needs a + * different locale declares LANG or LC_ALL in its profile, and the declared value wins. + */ +const COMPOSED_LAUNCH_LOCALE = 'C.UTF-8'; + +/** + * The environment a composed (fleet) launch hands its child. + * + * Built from an empty object rather than by subtracting from `process.env`, so the set of names + * that reach the child is a closed list that has to be edited deliberately. An allowlist fails + * safe as the operator's environment grows; a denylist silently passes every variable nobody + * thought of, which is where `BASH_ENV`, `PYTHONSTARTUP`, `NODE_OPTIONS` and `LD_PRELOAD` live -- + * names that execute attacker-chosen code inside a process that was otherwise fully validated. + * + * Locale is fixed rather than inherited (above). Everything else here is inherited because the + * child needs the operator's actual value: PATH is resolved and validated separately before use, + * and HOME remains the operator's -- see the residual recorded in the AMD1213-D scratchpad. + */ function minimalLaunchEnv(declared: Readonly>): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = {}; + const env: NodeJS.ProcessEnv = { + LANG: COMPOSED_LAUNCH_LOCALE, + LC_ALL: COMPOSED_LAUNCH_LOCALE, + }; for (const name of [ 'PATH', 'HOME', @@ -1184,8 +1214,6 @@ function minimalLaunchEnv(declared: Readonly>): NodeJS.Pr 'SHELL', 'TERM', 'COLORTERM', - 'LANG', - 'LC_ALL', 'TMPDIR', 'XDG_RUNTIME_DIR', ]) {