feat(fleet): enforce generated environment boundary (#772)
This commit was merged in pull request #772.
This commit is contained in:
279
packages/mosaic/src/fleet/generated-env-boundary.spec.ts
Normal file
279
packages/mosaic/src/fleet/generated-env-boundary.spec.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
stat,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
AgentEnvBoundaryError,
|
||||
parseAgentEnvironment,
|
||||
renderGeneratedAgentEnvironment,
|
||||
writeAgentEnvironmentProjection,
|
||||
} from './generated-env-boundary.js';
|
||||
|
||||
const generatedValues = {
|
||||
MOSAIC_AGENT_NAME: 'coder0',
|
||||
MOSAIC_AGENT_CLASS: 'code',
|
||||
MOSAIC_AGENT_RUNTIME: 'pi',
|
||||
MOSAIC_AGENT_MODEL: 'openai-codex/gpt-5.6-sol',
|
||||
MOSAIC_AGENT_REASONING: 'high',
|
||||
MOSAIC_AGENT_TOOL_POLICY: 'code',
|
||||
MOSAIC_AGENT_WORKDIR: '/srv/mosaic',
|
||||
MOSAIC_TMUX_SOCKET: 'mosaic-fleet',
|
||||
};
|
||||
|
||||
describe('generated fleet agent environment boundary', (): void => {
|
||||
let cleanup: string | undefined;
|
||||
|
||||
afterEach(async (): Promise<void> => {
|
||||
if (cleanup) {
|
||||
await rm(cleanup, { recursive: true, force: true });
|
||||
cleanup = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it('renders a deterministic complete generated projection', (): void => {
|
||||
expect(renderGeneratedAgentEnvironment(generatedValues)).toBe(
|
||||
[
|
||||
'MOSAIC_AGENT_NAME=coder0',
|
||||
'MOSAIC_AGENT_CLASS=code',
|
||||
'MOSAIC_AGENT_RUNTIME=pi',
|
||||
'MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol',
|
||||
'MOSAIC_AGENT_REASONING=high',
|
||||
'MOSAIC_AGENT_TOOL_POLICY=code',
|
||||
'MOSAIC_AGENT_WORKDIR=/srv/mosaic',
|
||||
'MOSAIC_TMUX_SOCKET=mosaic-fleet',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['generated-key shadowing', 'MOSAIC_AGENT_RUNTIME=codex\n'],
|
||||
['unknown key', 'UNRELATED_SETTING=value\n'],
|
||||
['arbitrary command', 'MOSAIC_AGENT_COMMAND=mosaic yolo codex\n'],
|
||||
['sensitive key', 'MOSAIC_AGENT_TOKEN=do-not-log-me\n'],
|
||||
['malformed syntax', 'export MOSAIC_RUNTIME_BIN=/opt/bin\n'],
|
||||
['duplicate keys', 'MOSAIC_RUNTIME_BIN=/opt/bin\nMOSAIC_RUNTIME_BIN=/other/bin\n'],
|
||||
])('rejects local %s without echoing values', (_name: string, source: string): void => {
|
||||
let error: unknown;
|
||||
try {
|
||||
parseAgentEnvironment(source, 'local');
|
||||
} catch (caught: unknown) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(AgentEnvBoundaryError);
|
||||
expect(String(error)).not.toContain('mosaic yolo codex');
|
||||
expect(String(error)).not.toContain('do-not-log-me');
|
||||
expect(String(error)).toMatch(/key=.*sha256=/);
|
||||
});
|
||||
|
||||
it('rejects unsafe generated paths before any launch consumer can use them', (): void => {
|
||||
expect((): void => {
|
||||
renderGeneratedAgentEnvironment({
|
||||
...generatedValues,
|
||||
MOSAIC_AGENT_WORKDIR: '../outside',
|
||||
});
|
||||
}).toThrow(AgentEnvBoundaryError);
|
||||
});
|
||||
|
||||
it('creates missing managed directories privately before writing a projection', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
|
||||
const mosaicHome = join(cleanup, 'mosaic');
|
||||
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
||||
|
||||
const result = await writeAgentEnvironmentProjection({
|
||||
mosaicHome,
|
||||
agentEnvDir,
|
||||
agentName: 'coder0',
|
||||
generated: generatedValues,
|
||||
});
|
||||
|
||||
for (const directory of [mosaicHome, join(mosaicHome, 'fleet'), agentEnvDir]) {
|
||||
expect((await stat(directory)).mode & 0o777).toBe(0o700);
|
||||
}
|
||||
expect((await stat(result.generatedPath)).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('regenerates desired keys, relocates safe legacy local data, and quarantines forbidden legacy input', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
|
||||
const mosaicHome = join(cleanup, 'mosaic');
|
||||
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
||||
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
|
||||
await writeFile(
|
||||
join(agentEnvDir, 'coder0.env'),
|
||||
[
|
||||
'MOSAIC_AGENT_NAME=stale-name',
|
||||
'MOSAIC_AGENT_RUNTIME=codex',
|
||||
'MOSAIC_RUNTIME_BIN=/opt/mosaic/bin',
|
||||
'MOSAIC_AGENT_COMMAND=mosaic yolo codex --dangerous',
|
||||
'',
|
||||
].join('\n'),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
|
||||
const result = await writeAgentEnvironmentProjection({
|
||||
mosaicHome,
|
||||
agentEnvDir,
|
||||
agentName: 'coder0',
|
||||
generated: generatedValues,
|
||||
});
|
||||
|
||||
expect(await readFile(result.generatedPath, 'utf8')).toBe(
|
||||
renderGeneratedAgentEnvironment(generatedValues),
|
||||
);
|
||||
expect(await readFile(result.localPath, 'utf8')).toBe('MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\n');
|
||||
const quarantine = await readFile(result.quarantinePath!, 'utf8');
|
||||
expect(quarantine).toContain('MOSAIC_AGENT_COMMAND=mosaic yolo codex --dangerous');
|
||||
await expect(readFile(join(agentEnvDir, 'coder0.env'), 'utf8')).rejects.toThrow();
|
||||
expect((await stat(result.generatedPath)).mode & 0o077).toBe(0);
|
||||
expect((await stat(result.localPath)).mode & 0o077).toBe(0);
|
||||
expect(result.diagnostics).toEqual([expect.objectContaining({ key: 'MOSAIC_AGENT_COMMAND' })]);
|
||||
expect(JSON.stringify(result.diagnostics)).not.toContain('mosaic yolo codex --dangerous');
|
||||
});
|
||||
|
||||
it('fails closed on an existing local override with unsafe permissions', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
|
||||
const mosaicHome = join(cleanup, 'mosaic');
|
||||
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
||||
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
|
||||
const localPath = join(agentEnvDir, 'coder0.env.local');
|
||||
await writeFile(localPath, 'MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\n', { mode: 0o600 });
|
||||
await chmod(localPath, 0o644);
|
||||
|
||||
await expect(
|
||||
writeAgentEnvironmentProjection({
|
||||
mosaicHome,
|
||||
agentEnvDir,
|
||||
agentName: 'coder0',
|
||||
generated: generatedValues,
|
||||
}),
|
||||
).rejects.toThrow(/permissions/i);
|
||||
});
|
||||
|
||||
it('rejects an existing group/world-accessible projection directory before reading it', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
|
||||
const mosaicHome = join(cleanup, 'mosaic');
|
||||
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
||||
const localPath = join(agentEnvDir, 'coder0.env.local');
|
||||
const generatedPath = join(agentEnvDir, 'coder0.env.generated');
|
||||
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
|
||||
await writeFile(localPath, 'MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\n', { mode: 0o600 });
|
||||
await chmod(agentEnvDir, 0o777);
|
||||
|
||||
await expect(
|
||||
writeAgentEnvironmentProjection({
|
||||
mosaicHome,
|
||||
agentEnvDir,
|
||||
agentName: 'coder0',
|
||||
generated: generatedValues,
|
||||
}),
|
||||
).rejects.toThrow(/unsafe-permissions/i);
|
||||
|
||||
await expect(readFile(localPath, 'utf8')).resolves.toBe('MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\n');
|
||||
await expect(readFile(generatedPath, 'utf8')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['MOSAIC_HOME', 'symlink'],
|
||||
['MOSAIC_HOME/fleet', 'symlink'],
|
||||
['MOSAIC_HOME/fleet/agents', 'symlink'],
|
||||
['MOSAIC_HOME', 'group/world-writable'],
|
||||
['MOSAIC_HOME/fleet', 'group/world-writable'],
|
||||
['MOSAIC_HOME/fleet/agents', 'group/world-writable'],
|
||||
])(
|
||||
'rejects a %s %s managed ancestor before any projection mutation',
|
||||
async (ancestor: string, hazard: string): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
|
||||
const mosaicHome = join(cleanup, 'mosaic');
|
||||
const fleetDir = join(mosaicHome, 'fleet');
|
||||
const agentEnvDir = join(fleetDir, 'agents');
|
||||
const generatedPath = join(agentEnvDir, 'coder0.env.generated');
|
||||
const localPath = join(agentEnvDir, 'coder0.env.local');
|
||||
const legacyPath = join(agentEnvDir, 'coder0.env');
|
||||
const quarantinePath = join(agentEnvDir, 'coder0.env.quarantine');
|
||||
const managedPaths: Record<string, string> = {
|
||||
MOSAIC_HOME: mosaicHome,
|
||||
'MOSAIC_HOME/fleet': fleetDir,
|
||||
'MOSAIC_HOME/fleet/agents': agentEnvDir,
|
||||
};
|
||||
const unsafePath = managedPaths[ancestor];
|
||||
if (unsafePath === undefined) throw new Error(`Unknown managed ancestor: ${ancestor}`);
|
||||
|
||||
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
|
||||
await chmod(mosaicHome, 0o700);
|
||||
await chmod(fleetDir, 0o700);
|
||||
await chmod(agentEnvDir, 0o700);
|
||||
await writeFile(generatedPath, 'generated-before\n', { mode: 0o600 });
|
||||
await writeFile(localPath, 'MOSAIC_RUNTIME_BIN=/safe/runtime\n', { mode: 0o600 });
|
||||
await writeFile(legacyPath, 'MOSAIC_AGENT_COMMAND=legacy-command\n', { mode: 0o600 });
|
||||
|
||||
if (hazard === 'symlink') {
|
||||
const targetPath = `${unsafePath}-target`;
|
||||
await rename(unsafePath, targetPath);
|
||||
await symlink(targetPath, unsafePath, 'dir');
|
||||
} else {
|
||||
await chmod(unsafePath, 0o777);
|
||||
}
|
||||
|
||||
await expect(
|
||||
writeAgentEnvironmentProjection({
|
||||
mosaicHome,
|
||||
agentEnvDir,
|
||||
agentName: 'coder0',
|
||||
generated: generatedValues,
|
||||
}),
|
||||
).rejects.toThrow(/unsafe-(directory|permissions)/i);
|
||||
|
||||
await expect(readFile(generatedPath, 'utf8')).resolves.toBe('generated-before\n');
|
||||
await expect(readFile(localPath, 'utf8')).resolves.toBe('MOSAIC_RUNTIME_BIN=/safe/runtime\n');
|
||||
await expect(readFile(legacyPath, 'utf8')).resolves.toBe(
|
||||
'MOSAIC_AGENT_COMMAND=legacy-command\n',
|
||||
);
|
||||
await expect(readFile(quarantinePath, 'utf8')).rejects.toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects a symlinked projection directory without mutating its target', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
|
||||
const mosaicHome = join(cleanup, 'mosaic');
|
||||
const targetDirectory = join(cleanup, 'target');
|
||||
const linkedDirectory = join(mosaicHome, 'fleet', 'agents');
|
||||
const legacyPath = join(targetDirectory, 'coder0.env');
|
||||
const generatedPath = join(targetDirectory, 'coder0.env.generated');
|
||||
const localPath = join(targetDirectory, 'coder0.env.local');
|
||||
const quarantinePath = join(targetDirectory, 'coder0.env.quarantine');
|
||||
await mkdir(join(mosaicHome, 'fleet'), { recursive: true, mode: 0o700 });
|
||||
await mkdir(targetDirectory, { mode: 0o700 });
|
||||
await writeFile(legacyPath, 'MOSAIC_AGENT_COMMAND=legacy-command\n', { mode: 0o600 });
|
||||
await writeFile(generatedPath, 'generated-before\n', { mode: 0o600 });
|
||||
await writeFile(localPath, 'local-before\n', { mode: 0o600 });
|
||||
await writeFile(quarantinePath, 'quarantine-before\n', { mode: 0o600 });
|
||||
await symlink(targetDirectory, linkedDirectory, 'dir');
|
||||
|
||||
await expect(
|
||||
writeAgentEnvironmentProjection({
|
||||
mosaicHome,
|
||||
agentEnvDir: linkedDirectory,
|
||||
agentName: 'coder0',
|
||||
generated: generatedValues,
|
||||
}),
|
||||
).rejects.toThrow(/unsafe-directory/i);
|
||||
|
||||
await expect(readFile(legacyPath, 'utf8')).resolves.toBe(
|
||||
'MOSAIC_AGENT_COMMAND=legacy-command\n',
|
||||
);
|
||||
await expect(readFile(generatedPath, 'utf8')).resolves.toBe('generated-before\n');
|
||||
await expect(readFile(localPath, 'utf8')).resolves.toBe('local-before\n');
|
||||
await expect(readFile(quarantinePath, 'utf8')).resolves.toBe('quarantine-before\n');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user