@kendoo.agentdesk/agentdesk 0.17.0 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/cli/orchestrator.mjs +33 -6
- package/cli/session-isolation.mjs +160 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,11 @@ All user-facing changes to AgentDesk. Each entry is tagged:
|
|
|
8
8
|
|
|
9
9
|
Internal refactors, infrastructure changes, and architectural notes are not listed here.
|
|
10
10
|
|
|
11
|
+
## [0.17.1] — 2026-04-18
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- `[CLI]` Sessions now run inside a kernel-enforced sandbox when one is available — `sandbox-exec` on macOS, `bwrap` (bubblewrap) on Linux. Writes outside the project directory and the session's scratch home are blocked at the kernel level; reads from known credential locations (`~/.ssh`, `~/.aws`, `~/.config/gh`, `~/.gitconfig`, `~/.netrc`, etc.) are denied so a confused agent cannot slurp up other projects' tokens. If the tool isn't installed (Linux without `bubblewrap` / unsupported platform), sessions fall back to the Tier 1 scoped-env isolation and a one-line notice explains why. Set `AGENTDESK_NO_SANDBOX=1` to opt out for debugging.
|
|
15
|
+
|
|
11
16
|
## [0.17.0] — 2026-04-18
|
|
12
17
|
|
|
13
18
|
### Added
|
package/cli/orchestrator.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { fileURLToPath } from "url";
|
|
|
8
8
|
import { buildPrompt, buildSoloPrompt, buildPhasedPrompt } from "./prompt.mjs";
|
|
9
9
|
import { createStreamParser } from "./stream-parser.mjs";
|
|
10
10
|
import { createScratchHome } from "./session-sandbox.mjs";
|
|
11
|
+
import { wrapIsolatedSpawn, probeIsolation } from "./session-isolation.mjs";
|
|
11
12
|
|
|
12
13
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
14
|
const CLI_VERSION = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8")).version;
|
|
@@ -123,9 +124,21 @@ export async function runOrchestrator({
|
|
|
123
124
|
emit({ type: "phase:change", phase: initialPhase, model: representativeModel });
|
|
124
125
|
|
|
125
126
|
const modelArgs = modelArgsForPhase("EXECUTION", config?.phaseModels);
|
|
127
|
+
const wrapped = wrapIsolatedSpawn({
|
|
128
|
+
cmd: "claude",
|
|
129
|
+
args: ["-p", fullPrompt, ...modelArgs, "--allowedTools", "Bash,Read,Edit,Write,Glob,Grep", "--verbose", "--output-format", "stream-json"],
|
|
130
|
+
cwd,
|
|
131
|
+
scratchHome: sandbox.home,
|
|
132
|
+
sessionId,
|
|
133
|
+
});
|
|
134
|
+
if (wrapped.isolation.kind !== "none") {
|
|
135
|
+
console.error(`[agentdesk] session isolation: ${wrapped.isolation.kind}`);
|
|
136
|
+
} else if (wrapped.isolation.reason) {
|
|
137
|
+
console.error(`[agentdesk] hard isolation unavailable (${wrapped.isolation.reason}) — scoped-env only`);
|
|
138
|
+
}
|
|
126
139
|
const child = spawn(
|
|
127
|
-
|
|
128
|
-
|
|
140
|
+
wrapped.cmd,
|
|
141
|
+
wrapped.args,
|
|
129
142
|
{ stdio: ["pipe", "pipe", "inherit"], shell: false, env, cwd }
|
|
130
143
|
);
|
|
131
144
|
child.stdin.end();
|
|
@@ -221,10 +234,17 @@ export async function runOrchestrator({
|
|
|
221
234
|
|
|
222
235
|
// --- Phased orchestrator: runs 3 sequential Claude processes ---
|
|
223
236
|
|
|
224
|
-
async function runSinglePhase({ prompt, cwd, env, teamNames, emit, modelArgs = [] }) {
|
|
237
|
+
async function runSinglePhase({ prompt, cwd, env, teamNames, emit, modelArgs = [], scratchHome, sessionId }) {
|
|
238
|
+
const wrapped = wrapIsolatedSpawn({
|
|
239
|
+
cmd: "claude",
|
|
240
|
+
args: ["-p", prompt, ...modelArgs, "--allowedTools", "Bash,Read,Edit,Write,Glob,Grep", "--verbose", "--output-format", "stream-json"],
|
|
241
|
+
cwd,
|
|
242
|
+
scratchHome,
|
|
243
|
+
sessionId,
|
|
244
|
+
});
|
|
225
245
|
const child = spawn(
|
|
226
|
-
|
|
227
|
-
|
|
246
|
+
wrapped.cmd,
|
|
247
|
+
wrapped.args,
|
|
228
248
|
{ stdio: ["pipe", "pipe", "inherit"], shell: false, env, cwd }
|
|
229
249
|
);
|
|
230
250
|
child.stdin.end();
|
|
@@ -278,6 +298,13 @@ export async function runPhasedOrchestrator({
|
|
|
278
298
|
const startTime = Date.now();
|
|
279
299
|
const teamNames = teamSections.names;
|
|
280
300
|
|
|
301
|
+
const probe = probeIsolation();
|
|
302
|
+
if (probe.kind !== "none") {
|
|
303
|
+
console.error(`[agentdesk] session isolation: ${probe.kind}`);
|
|
304
|
+
} else {
|
|
305
|
+
console.error(`[agentdesk] hard isolation unavailable (${probe.reason}) — scoped-env only`);
|
|
306
|
+
}
|
|
307
|
+
|
|
281
308
|
function timestamp() {
|
|
282
309
|
const d = new Date();
|
|
283
310
|
return [d.getHours(), d.getMinutes(), d.getSeconds()].map(n => String(n).padStart(2, "0")).join(":");
|
|
@@ -330,7 +357,7 @@ export async function runPhasedOrchestrator({
|
|
|
330
357
|
});
|
|
331
358
|
|
|
332
359
|
const modelArgs = modelArgsForPhase(phase, config?.phaseModels);
|
|
333
|
-
const result = await runSinglePhase({ prompt, cwd, env, teamNames, emit, modelArgs });
|
|
360
|
+
const result = await runSinglePhase({ prompt, cwd, env, teamNames, emit, modelArgs, scratchHome: sandbox.home, sessionId });
|
|
334
361
|
|
|
335
362
|
if (onChild) onChild(result.child);
|
|
336
363
|
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Tier 2 kernel-enforced isolation.
|
|
2
|
+
//
|
|
3
|
+
// On top of the scoped HOME from session-sandbox.mjs, wrap the Claude child
|
|
4
|
+
// process in a kernel sandbox when one is available:
|
|
5
|
+
// - macOS: `sandbox-exec` (TrustedBSD sandbox, same primitive App Sandbox uses)
|
|
6
|
+
// - Linux: `bwrap` (bubblewrap — mount namespaces, same primitive Docker uses)
|
|
7
|
+
//
|
|
8
|
+
// Failure mode: if the tool isn't present or the profile can't be written,
|
|
9
|
+
// we emit a one-line notice and spawn the child as normal. The scoped HOME
|
|
10
|
+
// from Phase E still applies — isolation just degrades from "kernel boundary"
|
|
11
|
+
// to "scoped env."
|
|
12
|
+
//
|
|
13
|
+
// Escape hatch: AGENTDESK_NO_SANDBOX=1 skips this layer entirely.
|
|
14
|
+
|
|
15
|
+
import { execSync } from "child_process";
|
|
16
|
+
import { existsSync, writeFileSync } from "fs";
|
|
17
|
+
import { join } from "path";
|
|
18
|
+
import { platform, homedir } from "os";
|
|
19
|
+
|
|
20
|
+
// Detect which kernel-isolation tool is usable. Cached after first probe.
|
|
21
|
+
let cachedProbe = null;
|
|
22
|
+
export function probeIsolation() {
|
|
23
|
+
if (cachedProbe) return cachedProbe;
|
|
24
|
+
if (process.env.AGENTDESK_NO_SANDBOX === "1") {
|
|
25
|
+
cachedProbe = { kind: "none", reason: "AGENTDESK_NO_SANDBOX=1 set" };
|
|
26
|
+
return cachedProbe;
|
|
27
|
+
}
|
|
28
|
+
if (platform() === "darwin") {
|
|
29
|
+
try {
|
|
30
|
+
execSync("command -v sandbox-exec", { stdio: "pipe" });
|
|
31
|
+
cachedProbe = { kind: "sandbox-exec" };
|
|
32
|
+
return cachedProbe;
|
|
33
|
+
} catch {
|
|
34
|
+
cachedProbe = { kind: "none", reason: "sandbox-exec not found" };
|
|
35
|
+
return cachedProbe;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (platform() === "linux") {
|
|
39
|
+
try {
|
|
40
|
+
execSync("command -v bwrap", { stdio: "pipe" });
|
|
41
|
+
cachedProbe = { kind: "bwrap" };
|
|
42
|
+
return cachedProbe;
|
|
43
|
+
} catch {
|
|
44
|
+
cachedProbe = { kind: "none", reason: "bubblewrap not installed — apt install bubblewrap / brew install bubblewrap" };
|
|
45
|
+
return cachedProbe;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
cachedProbe = { kind: "none", reason: `no isolation tool for platform ${platform()}` };
|
|
49
|
+
return cachedProbe;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Build the spawn arguments for an isolated claude invocation. Takes the
|
|
53
|
+
// original command/args/options and returns the wrapped form, plus a flag
|
|
54
|
+
// indicating which isolation mode is active.
|
|
55
|
+
export function wrapIsolatedSpawn({ cmd, args, cwd, scratchHome, sessionId }) {
|
|
56
|
+
const probe = probeIsolation();
|
|
57
|
+
if (probe.kind === "none") {
|
|
58
|
+
return { cmd, args, isolation: { kind: "none", reason: probe.reason } };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (probe.kind === "sandbox-exec") {
|
|
62
|
+
const profilePath = join(scratchHome, "sandbox.sb");
|
|
63
|
+
writeFileSync(profilePath, macosProfile({ cwd, scratchHome }), { mode: 0o600 });
|
|
64
|
+
return {
|
|
65
|
+
cmd: "sandbox-exec",
|
|
66
|
+
args: ["-f", profilePath, cmd, ...args],
|
|
67
|
+
isolation: { kind: "sandbox-exec" },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (probe.kind === "bwrap") {
|
|
72
|
+
return {
|
|
73
|
+
cmd: "bwrap",
|
|
74
|
+
args: [...bwrapArgs({ cwd, scratchHome }), cmd, ...args],
|
|
75
|
+
isolation: { kind: "bwrap" },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { cmd, args, isolation: { kind: "none", reason: "unknown probe kind" } };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// --- macOS sandbox-exec profile ---------------------------------------------
|
|
83
|
+
|
|
84
|
+
function macosProfile({ cwd, scratchHome }) {
|
|
85
|
+
const home = homedir();
|
|
86
|
+
// Known-sensitive user paths where credentials typically live. Reads denied
|
|
87
|
+
// even though general reads are allowed, so a confused agent can't slurp
|
|
88
|
+
// up other projects' tokens or the user's SSH keys.
|
|
89
|
+
const denyReadPaths = [
|
|
90
|
+
join(home, ".ssh"),
|
|
91
|
+
join(home, ".aws"),
|
|
92
|
+
join(home, ".gcloud"),
|
|
93
|
+
join(home, ".config", "gh"), // real gh config (scratch is under $TMPDIR)
|
|
94
|
+
join(home, ".docker"),
|
|
95
|
+
join(home, ".kube"),
|
|
96
|
+
join(home, ".agentdesk"),
|
|
97
|
+
join(home, ".config", "agentdesk"),
|
|
98
|
+
];
|
|
99
|
+
const denyReadLiterals = [
|
|
100
|
+
join(home, ".netrc"),
|
|
101
|
+
join(home, ".gitconfig"), // real gitconfig (scratch has its own)
|
|
102
|
+
join(home, ".npmrc"),
|
|
103
|
+
join(home, ".pypirc"),
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
// Writes allowed only here. Everywhere else: read-only by default.
|
|
107
|
+
const writeSubpaths = [
|
|
108
|
+
cwd,
|
|
109
|
+
scratchHome,
|
|
110
|
+
"/tmp",
|
|
111
|
+
"/private/tmp",
|
|
112
|
+
"/private/var/folders", // macOS TMPDIR lives here
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
const sb = [
|
|
116
|
+
`(version 1)`,
|
|
117
|
+
`(allow default)`,
|
|
118
|
+
``,
|
|
119
|
+
`; Deny reads to known credential locations`,
|
|
120
|
+
...denyReadPaths.map(p => `(deny file-read* (subpath ${sbString(p)}))`),
|
|
121
|
+
...denyReadLiterals.map(p => `(deny file-read* (literal ${sbString(p)}))`),
|
|
122
|
+
``,
|
|
123
|
+
`; Writes: deny everywhere, then allow the project dir + scratch + tmp`,
|
|
124
|
+
`(deny file-write*)`,
|
|
125
|
+
...writeSubpaths.map(p => `(allow file-write* (subpath ${sbString(p)}))`),
|
|
126
|
+
``,
|
|
127
|
+
`; Allow writes to devices so stdio, /dev/null, tty, ptys all work`,
|
|
128
|
+
`(allow file-write* (subpath "/dev"))`,
|
|
129
|
+
``,
|
|
130
|
+
].join("\n");
|
|
131
|
+
|
|
132
|
+
return sb;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function sbString(s) {
|
|
136
|
+
return `"${String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// --- Linux bwrap args --------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
function bwrapArgs({ cwd, scratchHome }) {
|
|
142
|
+
// Everything read-only by default via ro-bind of /. Writable exceptions:
|
|
143
|
+
// the project dir, the scratch HOME, and a private /tmp. /home/<user> is
|
|
144
|
+
// replaced with a tmpfs so the agent can't browse the real home dir.
|
|
145
|
+
const home = homedir();
|
|
146
|
+
return [
|
|
147
|
+
"--ro-bind", "/", "/",
|
|
148
|
+
"--dev-bind", "/dev", "/dev",
|
|
149
|
+
"--proc", "/proc",
|
|
150
|
+
"--tmpfs", "/tmp",
|
|
151
|
+
"--tmpfs", home, // blank user home
|
|
152
|
+
"--bind", cwd, cwd, // project dir writable
|
|
153
|
+
"--bind", scratchHome, scratchHome, // scratch writable (HOME env var points here)
|
|
154
|
+
"--die-with-parent",
|
|
155
|
+
"--unshare-ipc",
|
|
156
|
+
"--unshare-uts",
|
|
157
|
+
"--unshare-pid",
|
|
158
|
+
// Network is NOT unshared — agents need network for tracker APIs and git.
|
|
159
|
+
];
|
|
160
|
+
}
|