From a996db9258c90194dfeb5f408f00256db6fa70e6 Mon Sep 17 00:00:00 2001 From: ematipico Date: Tue, 15 Sep 2026 17:11:55 +0100 Subject: [PATCH] fix(adversary): isolate sandbox agents --- Dockerfile | 6 +- package.json | 2 +- pnpm-lock.yaml | 2 +- src/adversary/agents/blue-team.ts | 10 +- src/adversary/agents/purple-team.ts | 12 +- src/adversary/sandbox.ts | 278 ++++++++++++++++++++++++++-- tests/adversary-sandbox.test.ts | 192 ++++++++++++++++++- 7 files changed, 472 insertions(+), 30 deletions(-) diff --git a/Dockerfile b/Dockerfile index 30d3381..fb46bbe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # Container image for triage pipeline sandboxes. Pin the base image to the # exact @cloudflare/sandbox SDK version in package.json. -FROM docker.io/cloudflare/sandbox:0.12.3 +FROM docker.io/cloudflare/sandbox:0.12.5 RUN apt-get update \ && apt-get install -y --no-install-recommends \ @@ -29,4 +29,8 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ | sh -s -- -y --no-modify-path --profile minimal --default-toolchain none \ && chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" +# Adversary agent commands run as this user while the sandbox control plane +# remains root for the other workflows that share this image. +RUN useradd --create-home --shell /bin/bash sandbox-agent + EXPOSE 8080 diff --git a/package.json b/package.json index 0550c3d..26cdf51 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "dependencies": { "@biomejs/biome": "2.5.9", "@cloudflare/codemode": "^0.5.1", - "@cloudflare/sandbox": "^0.12.3", + "@cloudflare/sandbox": "0.12.5", "@flue/github": "^2.0.3", "@flue/runtime": "^2.0.3", "hono": "4.12.32", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b32396..5b28aa4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,7 +15,7 @@ importers: specifier: ^0.5.1 version: 0.5.1(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(zod@4.4.3) '@cloudflare/sandbox': - specifier: ^0.12.3 + specifier: 0.12.5 version: 0.12.5 '@flue/github': specifier: ^2.0.3 diff --git a/src/adversary/agents/blue-team.ts b/src/adversary/agents/blue-team.ts index 09c5d5a..3f1f280 100644 --- a/src/adversary/agents/blue-team.ts +++ b/src/adversary/agents/blue-team.ts @@ -32,9 +32,13 @@ export function BlueTeam() { env as unknown as AdversarySandboxEnv, input.sandboxId, ); - useSandbox(adversaryAgentSandbox(sandbox, BLUE_DIR, input.skill.name), { - cwd: BLUE_DIR, - }); + useSandbox( + adversaryAgentSandbox(sandbox, { + cwd: BLUE_DIR, + mountedSkillName: input.skill.name, + }), + { cwd: BLUE_DIR }, + ); const writeResult = useDataWriter('result', { schema: blueTeamResultSchema }); useTool({ diff --git a/src/adversary/agents/purple-team.ts b/src/adversary/agents/purple-team.ts index 7e98285..091299e 100644 --- a/src/adversary/agents/purple-team.ts +++ b/src/adversary/agents/purple-team.ts @@ -34,9 +34,15 @@ export function PurpleTeam() { env as unknown as AdversarySandboxEnv, input.sandboxId, ); - useSandbox(adversaryAgentSandbox(sandbox, RED_DIR, input.skill.name), { - cwd: RED_DIR, - }); + useSandbox( + adversaryAgentSandbox(sandbox, { + cwd: RED_DIR, + mountedSkillName: input.skill.name, + readablePaths: [RED_DIR, BLUE_DIR, BLUE_PATCH_PATH], + writablePaths: [RED_DIR, BLUE_DIR], + }), + { cwd: RED_DIR }, + ); const writeResult = useDataWriter('result', { schema: purpleTeamResultSchema, diff --git a/src/adversary/sandbox.ts b/src/adversary/sandbox.ts index 916358d..0af2968 100644 --- a/src/adversary/sandbox.ts +++ b/src/adversary/sandbox.ts @@ -1,5 +1,5 @@ import { getSandbox, type Sandbox } from '@cloudflare/sandbox'; -import type { SandboxFactory } from '@flue/runtime'; +import type { Sandbox as FlueSandbox, SandboxFactory } from '@flue/runtime'; import { cloudflareSandbox } from '@flue/runtime/cloudflare'; import { adversaryBranchName } from './contracts.ts'; @@ -11,6 +11,7 @@ export const MAX_PATCH_BYTES = 20 * 1_024 * 1_024; const COMMAND_TIMEOUT_SECONDS = 1_800; const OUTPUT_LIMIT = 4_000; +const AGENT_USER = 'sandbox-agent'; export type AdversarySandbox = Sandbox; @@ -36,6 +37,13 @@ interface PullRequestRef extends RepositoryRef { headSha: string; } +interface AdversaryAgentSandboxOptions { + cwd: string; + mountedSkillName: string; + readablePaths?: string[]; + writablePaths?: string[]; +} + export interface CapturedPatch { path: string; size: number; @@ -55,26 +63,108 @@ export function getAdversarySandbox( /** Flue's normal Cloudflare tools, with a non-optional ceiling on every process. */ export function adversaryAgentSandbox( sandbox: AdversarySandbox, - cwd: string, - mountedSkillName: string, + options: AdversaryAgentSandboxOptions, ): SandboxFactory { + const { cwd, mountedSkillName } = options; const base = cloudflareSandbox(sandbox, { cwd }); const workspaceSkillsDir = `${cwd}/.agents/skills`; + const readablePaths = options.readablePaths ?? [cwd]; + const writablePaths = options.writablePaths ?? [cwd]; return { ...base, async createSandbox(options) { const environment = await base.createSandbox(options); + const readablePath = (path: string, mustExist = true) => + canonicalAllowedPath( + environment, + path, + readablePaths, + 'read', + mustExist, + ); + const writablePath = (path: string) => + canonicalAllowedPath(environment, path, writablePaths, 'write', false); return { ...environment, + async readFile(path) { + const content = await readFileAsAgent( + environment, + await readablePath(path), + ); + return Buffer.from(content).toString('utf8'); + }, + async readFileBuffer(path) { + return readFileAsAgent(environment, await readablePath(path)); + }, + async writeFile(path, content) { + const resolved = await writablePath(path); + await writeFileAsAgent(environment, resolved, content); + }, + async stat(path) { + const resolved = await readablePath(path); + const result = await execAgentCommandOrThrow( + environment, + `stat -L -c '%s/%Y/%F' -- ${shellQuote(resolved)} && stat -c '%F' -- ${shellQuote(resolved)}`, + 10_000, + ); + const [target = '', self = ''] = result.stdout.trim().split('\n'); + const [size = '0', mtime = '0', type = ''] = target.split('/'); + return { + isFile: type.includes('regular'), + isDirectory: type === 'directory', + isSymbolicLink: self.trim() === 'symbolic link', + size: Number.parseInt(size, 10), + mtime: new Date(Number.parseInt(mtime, 10) * 1_000), + }; + }, async readdir(path) { - const entries = await environment.readdir(path); + const resolved = await readablePath(path); + const result = await execAgentCommandOrThrow( + environment, + `find ${shellQuote(resolved)} -mindepth 1 -maxdepth 1 -printf '%f\\0'`, + 10_000, + ); + const entries = result.stdout.split('\0').filter(Boolean); // The pinned snapshot is mounted with useSkill; hide its checkout // copy from Flue's workspace discovery to avoid a name collision. - return path === workspaceSkillsDir + return resolved === workspaceSkillsDir ? entries.filter((entry) => entry !== mountedSkillName) : entries; }, - exec(command, execOptions) { + async exists(path) { + try { + const resolved = await readablePath(path, false); + const result = await execAgentCommand( + environment, + `test -e ${shellQuote(resolved)}`, + 10_000, + ); + return result.exitCode === 0; + } catch { + return false; + } + }, + async mkdir(path, mkdirOptions) { + const resolved = await writablePath(path); + await execAgentCommandOrThrow( + environment, + `mkdir ${mkdirOptions?.recursive ? '-p ' : ''}-- ${shellQuote(resolved)}`, + 10_000, + ); + }, + async rm(path, rmOptions) { + const resolved = environment.resolvePath(path); + const parent = resolved.slice(0, resolved.lastIndexOf('/')) || '/'; + const canonicalParent = await writablePath(parent); + const target = `${canonicalParent}/${resolved.slice(resolved.lastIndexOf('/') + 1)}`; + await execAgentCommandOrThrow( + environment, + `rm ${rmOptions?.force ? '-f ' : ''}${rmOptions?.recursive ? '-r ' : ''}-- ${shellQuote(target)}`, + 10_000, + ); + }, + async exec(command, execOptions) { + if (execOptions?.cwd) await readablePath(execOptions.cwd); const requested = execOptions?.timeoutMs ?? COMMAND_TIMEOUT_SECONDS * 1_000; const timeoutMs = Math.min( @@ -82,9 +172,12 @@ export function adversaryAgentSandbox( COMMAND_TIMEOUT_SECONDS * 1_000, ); const seconds = Math.ceil(timeoutMs / 1_000); - return environment.exec( - `GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 GIT_TERMINAL_PROMPT=0 timeout -k 5 ${seconds} sh -c ${shellQuote(command)}`, - { ...execOptions, timeoutMs }, + return execAgentCommand( + environment, + command, + timeoutMs, + seconds, + execOptions, ); }, }; @@ -92,6 +185,102 @@ export function adversaryAgentSandbox( }; } +async function canonicalAllowedPath( + environment: FlueSandbox, + path: string, + allowedPaths: string[], + operation: 'read' | 'write', + mustExist: boolean, +): Promise { + const resolved = environment.resolvePath(path); + const canonical = await environment.exec( + `realpath ${mustExist ? '-e' : '-m'} -- ${shellQuote(resolved)}`, + { timeoutMs: 10_000 }, + ); + if (canonical.exitCode !== 0) { + throw new Error(`Sandbox ${operation} path could not be resolved.`); + } + const canonicalPath = canonical.stdout.trim(); + if ( + !allowedPaths.some( + (allowed) => + canonicalPath === allowed || canonicalPath.startsWith(`${allowed}/`), + ) + ) { + throw new Error(`Sandbox ${operation} denied outside the agent workspace.`); + } + return canonicalPath; +} + +async function execAgentCommand( + environment: FlueSandbox, + command: string, + timeoutMs: number, + seconds = Math.ceil(timeoutMs / 1_000), + execOptions?: Parameters[1], +): ReturnType { + return environment.exec(wrapAgentCommand(command, seconds), { + ...execOptions, + timeoutMs, + }); +} + +function wrapAgentCommand(command: string, seconds: number): string { + return `runuser --user ${AGENT_USER} -- env HOME=/home/${AGENT_USER} GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 GIT_TERMINAL_PROMPT=0 timeout -k 5 ${seconds} sh -c ${shellQuote(command)}`; +} + +async function execAgentCommandOrThrow( + environment: FlueSandbox, + command: string, + timeoutMs: number, +): Promise>> { + const result = await execAgentCommand(environment, command, timeoutMs); + if (result.exitCode !== 0) { + throw new Error( + `Adversary agent command failed (exit ${result.exitCode}): ${tail(result.stderr || result.stdout)}`, + ); + } + return result; +} + +async function readFileAsAgent( + environment: FlueSandbox, + path: string, +): Promise { + const result = await execAgentCommandOrThrow( + environment, + `base64 -w 0 -- ${shellQuote(path)}`, + 30_000, + ); + return Buffer.from(result.stdout, 'base64'); +} + +async function writeFileAsAgent( + environment: FlueSandbox, + path: string, + content: string | Uint8Array, +): Promise { + const stagingPath = `/tmp/factory-agent-write-${crypto.randomUUID()}`; + const parent = path.slice(0, path.lastIndexOf('/')) || '/'; + await environment.writeFile(stagingPath, content); + try { + const protection = await environment.exec( + `chmod 0644 -- ${shellQuote(stagingPath)}`, + { timeoutMs: 10_000 }, + ); + if (protection.exitCode !== 0) { + throw new Error('Unable to protect the staged agent write.'); + } + await execAgentCommandOrThrow( + environment, + `mkdir -p -- ${shellQuote(parent)} && cp -- ${shellQuote(stagingPath)} ${shellQuote(path)}`, + 30_000, + ); + } finally { + await environment.rm(stagingPath, { force: true }); + } +} + /** Give blue only an anonymous detached checkout of the immutable base commit. */ export async function setupBlueWorkspace( sandbox: AdversarySandbox, @@ -99,26 +288,38 @@ export async function setupBlueWorkspace( ): Promise { await prepareDirectories(sandbox, [BLUE_DIR, ADVERSARY_ARTIFACT_DIR]); await cloneExactCommit(sandbox, input, BLUE_DIR); + await grantAgentWorkspace(sandbox, [BLUE_DIR]); } /** Stage tracked and untracked edits and encode them as a size-bounded binary patch. */ export async function captureBluePatch( - sandbox: AdversarySandbox, + sandbox: Pick, baseSha: string, ): Promise { assertSha(baseSha); - await verifyHead(sandbox, BLUE_DIR, baseSha); - await execOrThrow( + const stagingPath = `/tmp/factory-blue-patch-${crypto.randomUUID()}`; + await execAgentSandboxOrThrow( sandbox, 'capture blue changes', [ - `mkdir -p ${shellQuote(ADVERSARY_ARTIFACT_DIR)}`, + `test "$(git -C ${shellQuote(BLUE_DIR)} rev-parse HEAD)" = ${shellQuote(baseSha.toLowerCase())}`, + `rm -f -- ${shellQuote(stagingPath)}`, `git -C ${shellQuote(BLUE_DIR)} add -A`, // POSIX ulimit -f is in 512-byte blocks. Leave one block of headroom. - `(ulimit -f ${Math.floor(MAX_PATCH_BYTES / 512)}; git -C ${shellQuote(BLUE_DIR)} diff --cached --binary --full-index --no-ext-diff --no-textconv --src-prefix=a/ --dst-prefix=b/ ${shellQuote(baseSha)} -- > ${shellQuote(BLUE_PATCH_PATH)})`, + `(ulimit -f ${Math.floor(MAX_PATCH_BYTES / 512)}; git -C ${shellQuote(BLUE_DIR)} diff --cached --binary --full-index --no-ext-diff --no-textconv --src-prefix=a/ --dst-prefix=b/ ${shellQuote(baseSha)} -- > ${shellQuote(stagingPath)})`, ].join(' && '), 300, ); + await execOrThrow( + sandbox, + 'protect blue patch', + [ + `mkdir -p ${shellQuote(ADVERSARY_ARTIFACT_DIR)}`, + `install -o root -g root -m 0444 -- ${shellQuote(stagingPath)} ${shellQuote(BLUE_PATCH_PATH)}`, + `rm -f -- ${shellQuote(stagingPath)}`, + ].join(' && '), + 30, + ); return inspectPatch(sandbox, BLUE_PATCH_PATH); } @@ -142,6 +343,7 @@ export async function setupPurpleWorkspace( `chmod 0444 ${shellQuote(patchPath)}`, 30, ); + await grantAgentWorkspace(sandbox, [BLUE_DIR, RED_DIR]); } /** Prepare a credential-free publisher tree and deterministic commit. */ @@ -208,7 +410,7 @@ export async function pushPublisherBranch( } export async function inspectPatch( - sandbox: AdversarySandbox, + sandbox: Pick, path: string, ): Promise { const result = await execOrThrow( @@ -254,6 +456,18 @@ async function prepareDirectories( ); } +async function grantAgentWorkspace( + sandbox: AdversarySandbox, + directories: string[], +): Promise { + await execOrThrow( + sandbox, + 'grant agent workspace', + `chown -R ${AGENT_USER}:${AGENT_USER} -- ${directories.map(shellQuote).join(' ')}`, + 300, + ); +} + async function cloneExactCommit( sandbox: AdversarySandbox, input: RepositoryRef, @@ -358,8 +572,40 @@ export async function execCommand( }; } +async function execAgentSandboxOrThrow( + sandbox: Pick, + stage: string, + command: string, + timeoutSeconds: number, +): Promise { + const seconds = Math.min( + Math.max(timeoutSeconds, 1), + COMMAND_TIMEOUT_SECONDS, + ); + const result = await sandbox + .exec(wrapAgentCommand(command, seconds), { + timeout: (seconds + 10) * 1_000, + }) + .catch((error: unknown) => { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`Adversary sandbox RPC failed: ${redactToken(detail)}`); + }); + const normalized = { + exitCode: result.exitCode, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + success: result.exitCode === 0, + }; + if (!normalized.success) { + throw new Error( + `Adversary sandbox ${stage} failed (exit ${normalized.exitCode}): ${redactToken(tail(normalized.stderr || normalized.stdout))}`, + ); + } + return normalized; +} + async function execOrThrow( - sandbox: AdversarySandbox, + sandbox: Pick, stage: string, command: string, timeoutSeconds: number, diff --git a/tests/adversary-sandbox.test.ts b/tests/adversary-sandbox.test.ts index ca45313..e8a1327 100644 --- a/tests/adversary-sandbox.test.ts +++ b/tests/adversary-sandbox.test.ts @@ -1,28 +1,71 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ + exec: vi.fn(), + exists: vi.fn(), + mkdir: vi.fn(), + readFile: vi.fn(), + readFileBuffer: vi.fn(), readdir: vi.fn<(path: string) => Promise>(), + rm: vi.fn(), + stat: vi.fn(), + writeFile: vi.fn(), })); vi.mock('@cloudflare/sandbox', () => ({ getSandbox: vi.fn() })); vi.mock('@flue/runtime/cloudflare', () => ({ - cloudflareSandbox: vi.fn(() => ({ + cloudflareSandbox: vi.fn((_sandbox, options: { cwd: string }) => ({ createSandbox: vi.fn(async () => ({ - cwd: '/', - exec: vi.fn(), + cwd: options.cwd, + exec: mocks.exec, + exists: mocks.exists, + mkdir: mocks.mkdir, + readFile: mocks.readFile, + readFileBuffer: mocks.readFileBuffer, readdir: mocks.readdir, + resolvePath: (path: string) => + path.startsWith('/') ? path : `${options.cwd}/${path}`, + rm: mocks.rm, + stat: mocks.stat, + writeFile: mocks.writeFile, })), })), })); import { + ADVERSARY_ARTIFACT_DIR, type AdversarySandbox, adversaryAgentSandbox, BLUE_DIR, + BLUE_PATCH_PATH, + captureBluePatch, RED_DIR, } from '../src/adversary/sandbox.ts'; describe('adversary agent sandbox', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.exec.mockImplementation(async (command: string) => { + const path = command.match(/-- '([^']*)'$/)?.[1]; + const findPath = command.match(/find .*?(\/[A-Za-z0-9._/-]+)/)?.[1]; + const readPath = command.match( + /base64 -w 0 -- .*?(\/[A-Za-z0-9._/-]+)/, + )?.[1]; + let stdout = ''; + if (command.startsWith('realpath ')) stdout = `${path}\n`; + if (findPath) stdout = `${(await mocks.readdir(findPath)).join('\0')}\0`; + if (readPath) { + stdout = Buffer.from(await mocks.readFile(readPath)).toString('base64'); + } + return { + exitCode: 0, + stderr: '', + stdout, + }; + }); + mocks.readdir.mockResolvedValue([]); + }); + it.each([ [BLUE_DIR, 'astro-adversary-blue'], [RED_DIR, 'astro-adversary-purple'], @@ -33,11 +76,150 @@ describe('adversary agent sandbox', () => { mocks.readdir.mockImplementation(async (path) => path === skillsDir ? [skill, 'other-skill'] : [skill], ); - const factory = adversaryAgentSandbox({} as AdversarySandbox, cwd, skill); + const factory = adversaryAgentSandbox({} as AdversarySandbox, { + cwd, + mountedSkillName: skill, + }); const environment = await factory.createSandbox({ id: 'test' }); expect(await environment.readdir(skillsDir)).toEqual(['other-skill']); expect(await environment.readdir(`${cwd}/src`)).toEqual([skill]); }, ); + + it('denies blue filesystem access outside its checkout', async () => { + const factory = adversaryAgentSandbox({} as AdversarySandbox, { + cwd: BLUE_DIR, + mountedSkillName: 'astro-adversary-blue', + }); + const environment = await factory.createSandbox({ id: 'test' }); + + await expect(environment.readFile('/etc/passwd')).rejects.toThrow( + 'Sandbox read denied outside the agent workspace.', + ); + await expect( + environment.writeFile('/usr/bin/file', 'broken'), + ).rejects.toThrow('Sandbox write denied outside the agent workspace.'); + expect(mocks.readFile).not.toHaveBeenCalled(); + expect(mocks.writeFile).not.toHaveBeenCalled(); + }); + + it('lets purple inspect both trees without modifying the source patch', async () => { + mocks.readFile.mockResolvedValue('patch'); + const factory = adversaryAgentSandbox({} as AdversarySandbox, { + cwd: RED_DIR, + mountedSkillName: 'astro-adversary-purple', + readablePaths: [RED_DIR, BLUE_DIR, BLUE_PATCH_PATH], + writablePaths: [RED_DIR, BLUE_DIR], + }); + const environment = await factory.createSandbox({ id: 'test' }); + + await environment.readFile(BLUE_PATCH_PATH); + await environment.writeFile(`${BLUE_DIR}/solution.ts`, 'solution'); + await expect( + environment.writeFile(BLUE_PATCH_PATH, 'changed'), + ).rejects.toThrow('Sandbox write denied outside the agent workspace.'); + await expect( + environment.writeFile(`${ADVERSARY_ARTIFACT_DIR}/other.patch`, 'changed'), + ).rejects.toThrow('Sandbox write denied outside the agent workspace.'); + expect(mocks.readFile).toHaveBeenCalledWith(BLUE_PATCH_PATH); + expect(mocks.writeFile).toHaveBeenCalledWith( + expect.stringMatching(/^\/tmp\/factory-agent-write-/), + 'solution', + ); + expect(mocks.exec).toHaveBeenCalledWith( + expect.stringContaining( + `runuser --user sandbox-agent -- env HOME=/home/sandbox-agent`, + ), + expect.objectContaining({ timeoutMs: 30_000 }), + ); + }); + + it('rejects a symlink that resolves outside an allowed tree', async () => { + mocks.exec.mockImplementation(async (command: string) => ({ + exitCode: 0, + stderr: '', + stdout: command.startsWith('realpath ') ? '/usr/bin/file\n' : '', + })); + const factory = adversaryAgentSandbox({} as AdversarySandbox, { + cwd: BLUE_DIR, + mountedSkillName: 'astro-adversary-blue', + }); + const environment = await factory.createSandbox({ id: 'test' }); + + await expect( + environment.writeFile('system-file', 'broken'), + ).rejects.toThrow('Sandbox write denied outside the agent workspace.'); + expect(mocks.writeFile).not.toHaveBeenCalled(); + }); + + it('denies an execution working directory outside the checkout', async () => { + const factory = adversaryAgentSandbox({} as AdversarySandbox, { + cwd: BLUE_DIR, + mountedSkillName: 'astro-adversary-blue', + }); + const environment = await factory.createSandbox({ id: 'test' }); + + await expect(environment.exec('pwd', { cwd: '/tmp' })).rejects.toThrow( + 'Sandbox read denied outside the agent workspace.', + ); + expect(mocks.exec).toHaveBeenCalledTimes(1); + }); + + it('runs shell commands as the unprivileged agent user', async () => { + const factory = adversaryAgentSandbox({} as AdversarySandbox, { + cwd: BLUE_DIR, + mountedSkillName: 'astro-adversary-blue', + }); + const environment = await factory.createSandbox({ id: 'test' }); + + await environment.exec('whoami'); + expect(mocks.exec).toHaveBeenCalledWith( + expect.stringContaining( + `runuser --user sandbox-agent -- env HOME=/home/sandbox-agent`, + ), + expect.objectContaining({ timeoutMs: 1_800_000 }), + ); + }); + + it('stages large writes instead of putting content in a command argument', async () => { + const content = 'x'.repeat(256 * 1_024); + const factory = adversaryAgentSandbox({} as AdversarySandbox, { + cwd: BLUE_DIR, + mountedSkillName: 'astro-adversary-blue', + }); + const environment = await factory.createSandbox({ id: 'test' }); + + await environment.writeFile(`${BLUE_DIR}/large.txt`, content); + expect(mocks.writeFile).toHaveBeenCalledWith( + expect.stringMatching(/^\/tmp\/factory-agent-write-/), + content, + ); + expect( + mocks.exec.mock.calls.every(([command]) => !command.includes(content)), + ).toBe(true); + }); + + it('captures agent-controlled Git state as the unprivileged user', async () => { + const sha = 'a'.repeat(40); + const exec = vi + .fn() + .mockResolvedValueOnce({ exitCode: 0, stderr: '', stdout: '' }) + .mockResolvedValueOnce({ exitCode: 0, stderr: '', stdout: '' }) + .mockResolvedValueOnce({ + exitCode: 0, + stderr: '', + stdout: `12\n${'b'.repeat(64)} ${BLUE_PATCH_PATH}\n`, + }); + const sandbox = { exec }; + + await captureBluePatch(sandbox, sha); + expect(exec.mock.calls[0]?.[0]).toContain('runuser --user sandbox-agent'); + expect(exec.mock.calls[0]?.[0]).toContain('git -C'); + expect(exec.mock.calls[0]?.[0]).toContain('add -A'); + expect(exec.mock.calls[1]?.[0]).toContain( + 'install -o root -g root -m 0444', + ); + expect(exec.mock.calls[1]?.[0]).not.toContain('git -C'); + }); });