Skip to content

Commit b31b6f4

Browse files
committed
Cover write mode in sandbox and runner specs
Write mode previously had zero coverage in the pipeline tests: every runner.smoke and sandbox.spec scenario hardcoded mode='read-only'. The writeSettings call in sandbox.ts and the WRITE_TOOLS branch in runner.buildArgs were both untested, so a regression in either would have shipped silently. Sandbox spec adds two scenarios. One asserts that write mode plants .claude/settings.json with the exact allow (Write/Edit(../outputs/**)) and deny (Write/Edit(**)) rules, AND that the relative path between projectDir and outputsDir is exactly '../outputs' — those two invariants have to stay in sync or the planted rule fails to match real writes. The other asserts read-only mode plants no settings file. Runner spec adds two scenarios. One inspects the spawned argv and confirms both --tools and --allowedTools carry Write,Edit on top of the read-only baseline, and that the persisted toolAllowlist on the final config matches. The other is end-to-end: a new write-output fake-claude scenario writes to ../outputs/<name> from cwd (mimicking the side effect of claude's Write tool) and emits a tool_use; the test reads the file from mdredd's reported outputsDir, verifies the outputs event payload, and confirms the toolUse made it into the transcript. withSandbox in runner.smoke.ts grew an optional { mode } argument so the helper can serve both modes without duplicating its 50-line setup.
1 parent c450a08 commit b31b6f4

3 files changed

Lines changed: 208 additions & 6 deletions

File tree

test/fake-claude.mjs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
* FAKE_CLAUDE_SCENARIO=long FAKE_CLAUDE_DELAY_MS=N — sleep N ms before any output
1414
* FAKE_CLAUDE_SCENARIO=novel — emit an unknown top-level event type
1515
* FAKE_CLAUDE_SCENARIO=permission-denied — emit a permission denial event
16+
* FAKE_CLAUDE_SCENARIO=write-output FAKE_CLAUDE_OUTPUT_NAME=N FAKE_CLAUDE_OUTPUT_BODY=B
17+
* — synthesize a Write tool_use targeting ../outputs/<N> and
18+
* actually create that file (the real claude would have
19+
* done it via its Write tool implementation)
1620
*
1721
* Flags are intentionally ignored (except --json-schema which causes an early JSON result).
1822
*/
@@ -382,6 +386,31 @@ async function runNovel() {
382386
emitResult({ numTurns: 1, result: 'hello despite novelty' });
383387
}
384388

389+
async function runWriteOutput() {
390+
// Real claude's Write tool would create the file as a side effect of the
391+
// tool_use; we mimic that here so the test can verify the file lands at
392+
// mdredd's reported outputsDir. cwd is <run>/project/, so '../outputs/<name>'
393+
// resolves to <run>/outputs/<name>.
394+
const name = env.FAKE_CLAUDE_OUTPUT_NAME ?? 'result.txt';
395+
const body = env.FAKE_CLAUDE_OUTPUT_BODY ?? 'hello from fake claude';
396+
const target = `../outputs/${name}`;
397+
const { mkdir, writeFile } = await import('node:fs/promises');
398+
const path = await import('node:path');
399+
const absTarget = path.resolve(process.cwd(), target);
400+
await mkdir(path.dirname(absTarget), { recursive: true });
401+
await writeFile(absTarget, body, 'utf8');
402+
403+
emitSystemInit();
404+
emitMessageStart();
405+
emitToolUse('Write', { file_path: target, content: body });
406+
emitMessageEnd('tool_use');
407+
emitUserToolResult('tu-0', `wrote ${body.length} bytes to ${target}`);
408+
emitMessageStart();
409+
emitTextBlock(`wrote ${name}`);
410+
emitMessageEnd('end_turn');
411+
emitResult({ numTurns: 1, result: `wrote ${name}` });
412+
}
413+
385414
async function runPermissionDenied() {
386415
emitSystemInit();
387416
emitMessageStart();
@@ -429,6 +458,9 @@ async function main() {
429458
case 'permission-denied':
430459
await runPermissionDenied();
431460
break;
461+
case 'write-output':
462+
await runWriteOutput();
463+
break;
432464
default:
433465
stderr.write(`fake-claude: unknown scenario "${scenario}"\n`);
434466
exit(2);

test/runner.smoke.ts

Lines changed: 123 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ async function withSandbox(
4747
outputsDir: string;
4848
initialConfig: RunConfig;
4949
}) => Promise<void>,
50+
options: { mode?: 'read-only' | 'write' } = {},
5051
): Promise<void> {
52+
const mode = options.mode ?? 'read-only';
5153
const cwd = await mkdtemp(join(tmpdir(), 'mdredd-smoke-cwd-'));
5254
const storageRoot = join(cwd, 'agents', 'mdredd');
5355
const runFolder = `run-${Date.now()}`;
@@ -58,7 +60,7 @@ async function withSandbox(
5860
variantType: 'CLAUDE.md',
5961
skillOrAgentName: null,
6062
variantContent: `# ${scenarioName}\nBe concise.\n`,
61-
mode: 'read-only',
63+
mode,
6264
});
6365
const initialConfig: RunConfig = {
6466
runFolder,
@@ -71,7 +73,7 @@ async function withSandbox(
7173
prompt: 'test prompt',
7274
model: 'haiku',
7375
effort: null,
74-
mode: 'read-only',
76+
mode,
7577
status: 'preparing',
7678
startedAt: new Date().toISOString(),
7779
endedAt: null,
@@ -1126,4 +1128,123 @@ await scenario('effort flag: dropped when model rejects the value (sonnet + xhig
11261128
);
11271129
});
11281130

1131+
await scenario('write mode: --tools and --allowedTools include Write and Edit', async () => {
1132+
await withSandbox(
1133+
'write-tools',
1134+
async ({ runDir, projectDir, outputsDir, initialConfig }) => {
1135+
const dumpPath = join(runDir, 'argv.txt');
1136+
const runner = new Runner({
1137+
claudeBin: fakeBin,
1138+
projectDir,
1139+
runDir,
1140+
outputsDir,
1141+
prompt: 'go',
1142+
model: 'haiku',
1143+
mode: 'write',
1144+
initialConfig,
1145+
env: {
1146+
...process.env,
1147+
FAKE_CLAUDE_SCENARIO: 'happy',
1148+
FAKE_CLAUDE_DUMP_ARGS: dumpPath,
1149+
},
1150+
});
1151+
await runner.start();
1152+
const final = await runner.wait();
1153+
if (final.status !== 'completed') throw new Error(`expected completed, got ${final.status}`);
1154+
const argv = (await readFile(dumpPath, 'utf8')).split('\n');
1155+
// --tools and --allowedTools both carry the same comma-joined list.
1156+
// Inspect both: a regression where one is set correctly but the other
1157+
// isn't would leave write mode silently neutered.
1158+
for (const flag of ['--tools', '--allowedTools']) {
1159+
const idx = argv.indexOf(flag);
1160+
if (idx < 0) throw new Error(`expected ${flag} in argv, got: ${argv.join(' ')}`);
1161+
const value = argv[idx + 1] ?? '';
1162+
const tools = value.split(',');
1163+
if (!tools.includes('Write') || !tools.includes('Edit')) {
1164+
throw new Error(`${flag} missing Write/Edit, got '${value}'`);
1165+
}
1166+
if (!tools.includes('Read') || !tools.includes('Glob')) {
1167+
throw new Error(`${flag} dropped read-only tools, got '${value}'`);
1168+
}
1169+
}
1170+
// The mirrored toolAllowlist on the persisted config drives the judge
1171+
// rubric and the topbar harness summary, so it has to track too.
1172+
if (!final.toolAllowlist.includes('Write') || !final.toolAllowlist.includes('Edit')) {
1173+
throw new Error(
1174+
`persisted toolAllowlist missing Write/Edit: ${final.toolAllowlist.join(',')}`,
1175+
);
1176+
}
1177+
},
1178+
{ mode: 'write' },
1179+
);
1180+
});
1181+
1182+
await scenario(
1183+
'write mode: child writes to ../outputs/, file lands in outputsDir and surfaces via outputs event',
1184+
async () => {
1185+
await withSandbox(
1186+
'write-end-to-end',
1187+
async ({ runDir, projectDir, outputsDir, initialConfig }) => {
1188+
const outputName = 'result.txt';
1189+
const outputBody = 'persisted across finalize';
1190+
let observedOutputs: { path: string; bytes: number }[] | null = null;
1191+
const runner = new Runner({
1192+
claudeBin: fakeBin,
1193+
projectDir,
1194+
runDir,
1195+
outputsDir,
1196+
prompt: 'go',
1197+
model: 'haiku',
1198+
mode: 'write',
1199+
initialConfig,
1200+
env: {
1201+
...process.env,
1202+
FAKE_CLAUDE_SCENARIO: 'write-output',
1203+
FAKE_CLAUDE_OUTPUT_NAME: outputName,
1204+
FAKE_CLAUDE_OUTPUT_BODY: outputBody,
1205+
},
1206+
});
1207+
runner.on('outputs', (files) => {
1208+
observedOutputs = files.map((f) => ({ path: f.path, bytes: f.bytes }));
1209+
});
1210+
await runner.start();
1211+
const final = await runner.wait();
1212+
if (final.status !== 'completed') {
1213+
throw new Error(`expected completed, got ${final.status}`);
1214+
}
1215+
// 1. The file the child wrote at '../outputs/result.txt' from its cwd
1216+
// must end up at the outputsDir mdredd reported. This is the
1217+
// relative-path invariant between projectDir and outputsDir.
1218+
const written = await readFile(join(outputsDir, outputName), 'utf8');
1219+
if (written !== outputBody) {
1220+
throw new Error(`outputs/${outputName} content mismatch: '${written}'`);
1221+
}
1222+
// 2. The runner emits an 'outputs' event after finalize listing every
1223+
// file under outputsDir. Drives the UI and judge.
1224+
if (!observedOutputs) throw new Error('runner did not emit outputs event');
1225+
const match = (observedOutputs as { path: string; bytes: number }[]).find(
1226+
(f) => f.path === outputName,
1227+
);
1228+
if (!match) {
1229+
throw new Error(
1230+
`outputs event missing ${outputName}: ${JSON.stringify(observedOutputs)}`,
1231+
);
1232+
}
1233+
if (match.bytes !== Buffer.byteLength(outputBody, 'utf8')) {
1234+
throw new Error(`outputs event bytes mismatch: got ${match.bytes}`);
1235+
}
1236+
// 3. The transcript records the Write tool_use so the judge has
1237+
// visibility into what the child did. A finalize bug that wiped
1238+
// the run dir or skipped the toolUse event would surface here.
1239+
const transcript = JSON.parse(await readFile(join(runDir, 'transcript.json'), 'utf8')) as {
1240+
events: Array<{ t: string; tool?: string }>;
1241+
};
1242+
const writeUse = transcript.events.find((e) => e.t === 'toolUse' && e.tool === 'Write');
1243+
if (!writeUse) throw new Error('transcript missing Write toolUse event');
1244+
},
1245+
{ mode: 'write' },
1246+
);
1247+
},
1248+
);
1249+
11291250
console.log('\nAll runner smoke scenarios passed.');

test/sandbox.spec.ts

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { mkdir, mkdtemp, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises';
1+
import { mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises';
22
import { tmpdir } from 'node:os';
3-
import { join } from 'node:path';
3+
import { join, relative } from 'node:path';
44
import { buildSandbox } from '../src/server/sandbox.js';
55
import { listDir, readFileCapped, FsBrowserError } from '../src/server/fsBrowser.js';
66
import { pathExists } from '../src/server/fsUtil.js';
@@ -27,7 +27,7 @@ async function withCwd(run: (cwd: string) => Promise<void>): Promise<void> {
2727
}
2828
}
2929

30-
async function build(cwd: string) {
30+
async function build(cwd: string, mode: 'read-only' | 'write' = 'read-only') {
3131
const storageRoot = join(cwd, '.storage');
3232
return buildSandbox({
3333
cwd,
@@ -36,7 +36,7 @@ async function build(cwd: string) {
3636
variantType: 'CLAUDE.md',
3737
skillOrAgentName: null,
3838
variantContent: '# test\n',
39-
mode: 'read-only',
39+
mode,
4040
});
4141
}
4242

@@ -222,6 +222,55 @@ await scenario('fsBrowser: readFileCapped still works on real files', async () =
222222
});
223223
});
224224

225+
await scenario(
226+
'sandbox: write mode plants .claude/settings.json with outputs-only permission rules',
227+
async () => {
228+
await withCwd(async (cwd) => {
229+
const sb = await build(cwd, 'write');
230+
if (sb.settingsPath === null) {
231+
throw new Error('write mode should report a settingsPath');
232+
}
233+
const expectedPath = join(sb.projectDir, '.claude', 'settings.json');
234+
if (sb.settingsPath !== expectedPath) {
235+
throw new Error(`settingsPath = ${sb.settingsPath}, expected ${expectedPath}`);
236+
}
237+
// The allow rule uses `../outputs/**` — that pattern only resolves to the
238+
// run's outputs/ if claude's cwd (projectDir) is exactly one level below
239+
// outputsDir. Assert the relative-path invariant explicitly so a future
240+
// sandbox layout change can't silently desync from the planted rule.
241+
const rel = relative(sb.projectDir, sb.outputsDir);
242+
if (rel !== '../outputs') {
243+
throw new Error(`outputsDir relative to projectDir = '${rel}', expected '../outputs'`);
244+
}
245+
const settings = JSON.parse(await readFile(sb.settingsPath, 'utf8'));
246+
const allow = settings?.permissions?.allow;
247+
const deny = settings?.permissions?.deny;
248+
if (
249+
!Array.isArray(allow) ||
250+
!allow.includes('Write(../outputs/**)') ||
251+
!allow.includes('Edit(../outputs/**)')
252+
) {
253+
throw new Error(`allow rules missing or wrong: ${JSON.stringify(allow)}`);
254+
}
255+
if (!Array.isArray(deny) || !deny.includes('Write(**)') || !deny.includes('Edit(**)')) {
256+
throw new Error(`deny rules missing or wrong: ${JSON.stringify(deny)}`);
257+
}
258+
});
259+
},
260+
);
261+
262+
await scenario('sandbox: read-only mode plants no .claude/settings.json', async () => {
263+
await withCwd(async (cwd) => {
264+
const sb = await build(cwd, 'read-only');
265+
if (sb.settingsPath !== null) {
266+
throw new Error(`read-only mode should not produce a settings file, got ${sb.settingsPath}`);
267+
}
268+
if (await pathExists(join(sb.projectDir, '.claude', 'settings.json'))) {
269+
throw new Error('read-only mode left a .claude/settings.json on disk');
270+
}
271+
});
272+
});
273+
225274
if (failures > 0) {
226275
console.log(`\n${failures} sandbox security scenario(s) FAILED.`);
227276
process.exit(1);

0 commit comments

Comments
 (0)