diff --git a/.gitignore b/.gitignore index 3ac9ef1c2..846dca498 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ docs/blog-preview-*/ # Finder duplicate files, e.g. "spec 2.js", "sitemap 2.xml" *\ 2.* *\ [0-9].* + +# local hive agent roster snapshots +roster-backups/ diff --git a/docs/pr-evidence/sandbox-cwd-after.png b/docs/pr-evidence/sandbox-cwd-after.png new file mode 100644 index 000000000..6863e893f Binary files /dev/null and b/docs/pr-evidence/sandbox-cwd-after.png differ diff --git a/docs/pr-evidence/sandbox-cwd-before.png b/docs/pr-evidence/sandbox-cwd-before.png new file mode 100644 index 000000000..50619df89 Binary files /dev/null and b/docs/pr-evidence/sandbox-cwd-before.png differ diff --git a/src/main/hive.ts b/src/main/hive.ts index 4537c2bf6..914cf7a5a 100644 --- a/src/main/hive.ts +++ b/src/main/hive.ts @@ -1152,6 +1152,25 @@ export class HiveManager { hooks: [{ type: 'command', command: cmd }] }); const mcpServers = this.buildDefaultMcpServers(cwd, cfg); + // #449 — the agent's OWN project cwd, declared explicitly. + // + // This list used to carry only the dirs BESIDES cwd, on the evidence that + // bypass mode still wrote cwd itself (verified live against claude 2.1.239, + // see below). That stopped holding: once `sandbox.filesystem.allowWrite` is + // present it IS the whole answer, so an agent could no longer git-commit, + // build, or delete inside the very directory it was hired to work in. Every + // write there came back "Operation not permitted", and the only way through + // was turning the sandbox off for each command — which gives up the whole + // layer to get work done. + // + // Naming cwd costs nothing where it was already implied, and restores the + // agent's own workspace where it is not. It does NOT widen the sandbox: the + // agent could always read cwd, and this is the one directory it was pointed + // at. Still gated on `writableDirs` below, so an agent spawned without a + // sandbox request stays exactly as unsandboxed as before. + const sandboxDirs = Array.from(new Set( + [cwd, ...writableDirs].filter((d) => typeof d === 'string' && d.length > 0) + )); return { // Match the TUI's truecolor palette to the harness terminal theme — // PER SESSION, so the user's global Claude theme (their own terminals @@ -1188,8 +1207,8 @@ export class HiveManager { // runs as before rather than refusing to spawn. ...(writableDirs.length ? { - sandbox: { enabled: true, filesystem: { allowWrite: writableDirs } }, - permissions: { additionalDirectories: writableDirs } + sandbox: { enabled: true, filesystem: { allowWrite: sandboxDirs } }, + permissions: { additionalDirectories: sandboxDirs } } : {}), hooks: { diff --git a/test/auto-mode-sandbox.test.cjs b/test/auto-mode-sandbox.test.cjs index 942bb7a89..c2f177a94 100644 --- a/test/auto-mode-sandbox.test.cjs +++ b/test/auto-mode-sandbox.test.cjs @@ -8,6 +8,11 @@ * path-layout problem. Fix: keep the sandbox and declare those paths writable — * codex via `--add-dir`, Claude via `sandbox.filesystem.allowWrite` plus * `permissions.additionalDirectories` in the per-session settings file. + * + * The list also names the agent's OWN project cwd (#449). It used to carry only + * the paths outside cwd, on the evidence that bypass mode still wrote cwd + * itself; once `allowWrite` is present it is the whole answer, so agents lost + * the ability to write in the directory they were hired to work in. */ const test = require('node:test'); const assert = require('node:assert/strict'); @@ -48,9 +53,43 @@ test('a Claude agent gets a native sandbox that still allows its agent dir and t const hiveRoot = path.join(home, 'hive'); assert.equal(settings.sandbox.enabled, true); assert.notEqual(settings.sandbox.failIfUnavailable, true, 'Windows must still spawn'); - assert.deepEqual(settings.sandbox.filesystem.allowWrite, [agentDir, hiveRoot, palace]); + assert.deepEqual(settings.sandbox.filesystem.allowWrite, [home, agentDir, hiveRoot, palace], + 'the project cwd comes first, then the paths outside it'); // Both layers, or the agent deadlocks: Edit/Write allowed but `mv … .done/` denied. assert.deepEqual(settings.permissions.additionalDirectories, settings.sandbox.filesystem.allowWrite); // No bypass of the sandbox anywhere in the injected args. assert.ok(!inj.args.some((a) => /dangerously/.test(a))); }); + +test('the agent can write in the project it was hired to work in (#449)', async () => { + // The reported shape: a project cwd that is NOT a parent of the hive, so + // nothing else in the list happens to cover it. Before this fix every write + // under the project — git commits, build output, rm, test artifacts — came + // back "Operation not permitted". + const home = tmpHome(); + const project = fs.mkdtempSync(path.join(os.tmpdir(), 'md-project-')); + const hive = new HiveManager(() => home); + const inj = await hive.ensureAgent({ id: 'jim-1', name: 'Jim', provider: 'claude', cwd: project }); + const settings = JSON.parse(fs.readFileSync(inj.args[inj.args.indexOf('--settings') + 1], 'utf8')); + + assert.ok(settings.sandbox.filesystem.allowWrite.includes(project), + 'the project cwd is writable'); + assert.ok(settings.permissions.additionalDirectories.includes(project), + 'both layers, or Edit/Write is allowed while Bash is denied'); + // The hive paths it also needs are still there. + assert.ok(settings.sandbox.filesystem.allowWrite.includes(path.join(home, 'hive', 'agents', 'jim-1'))); + assert.ok(settings.sandbox.filesystem.allowWrite.includes(path.join(home, 'hive'))); +}); + +test('the cwd is named once, even when it is also passed as a writable dir', async () => { + const home = tmpHome(); + const project = fs.mkdtempSync(path.join(os.tmpdir(), 'md-project-')); + const hive = new HiveManager(() => home); + const inj = await hive.ensureAgent( + { id: 'jim-1', name: 'Jim', provider: 'claude', cwd: project }, + { extraWritableDirs: [project] } + ); + const settings = JSON.parse(fs.readFileSync(inj.args[inj.args.indexOf('--settings') + 1], 'utf8')); + const seen = settings.sandbox.filesystem.allowWrite.filter((d) => d === project); + assert.equal(seen.length, 1, 'no duplicate entry'); +});