fix(launch): fix the composed-seat locale and measure the environment the runtime actually gets
Closes the environment half of AMD1213-D defect D3. The executable half landed
in 585dac7a; this is the other thing the card asked for -- a capability-minimal
child environment that is measured rather than asserted.
MEASURED FIRST, THEN CHANGED. I ran the real `fleet launch` route with a shim in
place of the runtime binary and had the shim dump its own environment, so the
subject is what arrives at the far end of the chain -- after composition, after
the lease gate in launch-runtime.py -- and not the object the launcher believed
it was building. Those are different sets and only the first one matters.
What the measurement showed is that most of this defect was already closed by
construction and nobody knew, because nothing tested it. `minimalLaunchEnv`
builds from an empty object over a fixed name list, so BASH_ENV, ENV, PYTHON*,
NODE_*, NPM_CONFIG_*, LD_PRELOAD, LD_LIBRARY_PATH and every provider credential
in the operator's environment are already excluded, and they stay excluded
through the lease gate. I planted all sixteen and none reached the child. An
allowlist that no test names is one careless edit from being a denylist, which
is the actual defect here.
Two real gaps, one fixed and one not:
FIXED -- locale was inherited. A seat picked up the operator's LANG and LC_ALL,
so the same runtime doing the same work emitted different message language,
collation, and number and date formatting depending on who started it. Composed
launches now pin C.UTF-8. C.UTF-8 and not C: both are unambiguous, but plain C
is ASCII and would mangle non-ASCII output, trading one defect for another. A
seat that needs a different locale declares LANG or LC_ALL in its profile and
the declared value still wins -- covered by a test, so the escape hatch cannot
be removed silently. The operator path (no declared env) is untouched.
NOT FIXED, AND DELIBERATELY -- HOME is still the operator's. The card asks for
the seat config root instead, and it is right that this is the remaining leak:
the runtime is pointed at its own config directory, but anything it shells out
to (git, ssh, npm) still reads the operator's dotfiles and therefore the
operator's credentials. I am not changing it inside this amendment. A seat whose
HOME is a bare directory has no gitconfig and no ssh key, so it cannot commit or
push, and the fleet MVP's whole proof is a seat carrying a change to a pushed
branch. Moving HOME before the per-agent home is populated would improve the
isolation and break the deliverable. That population is what the harness-homes
design owns, and this is recorded as a residual there rather than half-done
here.
Eight tests. Each one falsified by inverting the property it claims to defend,
and each inversion hit exactly its own test and nothing else:
- added BASH_ENV to the inherited list -> permitted-set and loader-hook
killers both red, 2 failed
- reverted the locale pin -> locale killer red, 1 failed
- reused an ambient MOSAIC_LAUNCH_ID -> launch-id killer red, 1 failed
- recorded process.env into the ledger -> ledger-value killer red, 1 failed
The permitted-name list in the spec is written out by hand rather than derived
from the launcher. Deriving it would make the test agree with the code by
construction and detect nothing; the cost is that adding a variable means
editing the test, which is the point.
The launch-id test is worth naming separately. recordLaunch overwrites
MOSAIC_LAUNCH_ID in process.env before it is copied to the child, so a seat
launched from an operator session gets a fresh correlation id rather than
inheriting the operator's. That was already true and is now pinned, along with
the requirement that the child's id matches the one in the ledger -- correlation
is by this value and never by pid, because exec makes the runtime a different
process.
Verification: typecheck RC=0. eslint RC=0. prettier clean. Three consecutive
full-package runs under the sanitized lease environment, RC=0, 87 files / 1627
tests passed, 0 failed -- exactly one file and eight tests more than the 86/1619
baseline, so nothing else moved.
Commit-only per scrappy's controlling packet (comms 20260813T212447Z dc43de):
not pushed, PR #1213 not updated, nothing re-authored.
This commit is contained in:
@@ -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<string, string> = {
|
||||
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<string, string>, extraProfile: Record<string, unknown> = {}) {
|
||||
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":"[email protected]"}}\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<string, string>): Map<string, string> {
|
||||
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<string, string | undefined>();
|
||||
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<string, string>();
|
||||
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<string, string> = {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<Record<string, string>>): 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<Record<string, string>>): NodeJS.Pr
|
||||
'SHELL',
|
||||
'TERM',
|
||||
'COLORTERM',
|
||||
'LANG',
|
||||
'LC_ALL',
|
||||
'TMPDIR',
|
||||
'XDG_RUNTIME_DIR',
|
||||
]) {
|
||||
|
||||
Reference in New Issue
Block a user