Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Binary file added docs/pr-evidence/sandbox-cwd-after.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-evidence/sandbox-cwd-before.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
23 changes: 21 additions & 2 deletions src/main/hive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: {
Expand Down
41 changes: 40 additions & 1 deletion test/auto-mode-sandbox.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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');
});
Loading