@kendoo.agentdesk/agentdesk 0.22.0 → 0.23.0

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 CHANGED
@@ -13,6 +13,9 @@ Internal refactors, infrastructure changes, and architectural notes are not list
13
13
  ### Added
14
14
  - `[UI]` Private-session sharing flow. When someone visits a session URL they don't own, they now see a friendly "Shh… this one's private" page with a one-click "Request access" button instead of a blank error. The session owner sees pending requests in a new header inbox and can grant viewer access for just that session or the whole project. Viewer-granted sessions show up in the teammate's sidebar tagged as a viewer.
15
15
 
16
+ ### Changed
17
+ - `[Both]` Security improvements and hardening. No action required.
18
+
16
19
  ## [0.22.0] — 2026-05-23
17
20
 
18
21
  ### Changed
package/README.md CHANGED
@@ -272,7 +272,9 @@ Once running, a "Run Team" button appears on [agentdesk.live](https://agentdesk.
272
272
  ### Security
273
273
 
274
274
  - **Per-session identity isolation** — every session runs with `HOME` / `GH_CONFIG_DIR` / `XDG_CONFIG_HOME` pointed at a private scratch dir containing only this project's tracker credentials and commit identity. `gh`, `git`, and `ssh` inside the session cannot see your global accounts, other projects' tokens, or keys elsewhere on disk.
275
- - **Kernel-enforced sandbox when available** — on macOS (`sandbox-exec`) and Linux (`bwrap` / bubblewrap), sessions run with writes restricted to the project dir + scratch home + `/tmp`, and reads denied on known credential locations (`~/.ssh`, `~/.aws`, `~/.config/gh`, `~/.gitconfig`, `~/.netrc`, etc.). If the tool isn't available, sessions fall back to scoped-env isolation with a one-line notice. Opt out for debugging with `AGENTDESK_NO_SANDBOX=1`.
275
+ - **Kernel-enforced sandbox when available** — on macOS (`sandbox-exec`) and Linux (`bwrap` / bubblewrap), sessions run with a strict allowlist policy: full read/write on the project cwd, the per-session scratch HOME, `/tmp`, and common tool caches (`~/.nvm`, `~/.npm`, `~/.cache`, `~/.pyenv`, `~/.rbenv`, `~/.rvm`, `~/.cargo`, `~/.rustup`, `~/.gem`, `~/.local`). Read-only on system paths (`/usr`, `/etc`, `/System`, etc.). **Everything else under your real `$HOME` is unreadable from inside the session** — `~/Documents`, browser cookie stores, other projects' `.env` files, SSH keys, cloud-provider creds. Network stays open so agents can reach the Anthropic API, tracker APIs, package registries, and remote git. If `sandbox-exec` / `bwrap` isn't installed, sessions fall back to scoped-env isolation with a one-line notice.
276
+ - Opt out for debugging: `AGENTDESK_NO_SANDBOX=1` (no kernel sandbox at all).
277
+ - If the strict allowlist breaks a tool you need, fall back to the pre-allowlist denylist policy with `AGENTDESK_SANDBOX_LEGACY=1` and please file an issue so we can extend the allowlist.
276
278
  - **Credentials prerequisite** — scoped sessions push code over HTTPS using a per-project GitHub token. `agentdesk init` saves it to the project's `.env` as `GITHUB_TOKEN`; if you skip that step, the session refuses to start with an actionable message. SSH-only remotes (`git@github.com:...`) are also rejected — the sandbox intentionally isolates `~/.ssh`. Switch the origin to `https://github.com/<owner>/<repo>.git` or run without scoped isolation.
277
279
  - **Outbound only** — no ports opened on your machine
278
280
  - **Project allowlist** — only runs on projects registered via `agentdesk init`
@@ -5,12 +5,21 @@
5
5
  // - macOS: `sandbox-exec` (TrustedBSD sandbox, same primitive App Sandbox uses)
6
6
  // - Linux: `bwrap` (bubblewrap — mount namespaces, same primitive Docker uses)
7
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."
8
+ // AD-30: the policy is an ALLOWLIST deny by default, then re-expose only what
9
+ // legitimate tools need (project cwd, scratchHome, system binaries/libraries,
10
+ // tool caches like ~/.nvm and ~/.npm). The previous denylist of ~12 known
11
+ // credential paths left every other ~/Documents, browser cookie store, and
12
+ // other-project .env readable to a prompt-injected agent.
12
13
  //
13
- // Escape hatch: AGENTDESK_NO_SANDBOX=1 skips this layer entirely.
14
+ // Escape hatches:
15
+ // - AGENTDESK_NO_SANDBOX=1 — skip kernel isolation entirely (scoped HOME still applies)
16
+ // - AGENTDESK_SANDBOX_LEGACY=1 — revert to the pre-AD-30 denylist (use only if strict
17
+ // policy breaks a tool you need; report so we can extend)
18
+ //
19
+ // Failure mode: if sandbox-exec / bwrap isn't installed or the profile can't
20
+ // be written, we emit a one-line notice and spawn the child as normal. The
21
+ // scoped HOME from session-sandbox.mjs still applies — isolation degrades to
22
+ // "scoped env" rather than "kernel boundary."
14
23
 
15
24
  import { execSync } from "child_process";
16
25
  import { existsSync, writeFileSync } from "fs";
@@ -49,6 +58,10 @@ export function probeIsolation() {
49
58
  return cachedProbe;
50
59
  }
51
60
 
61
+ function legacyMode() {
62
+ return process.env.AGENTDESK_SANDBOX_LEGACY === "1";
63
+ }
64
+
52
65
  // Build the spawn arguments for an isolated claude invocation. Takes the
53
66
  // original command/args/options and returns the wrapped form, plus a flag
54
67
  // indicating which isolation mode is active.
@@ -58,39 +71,129 @@ export function wrapIsolatedSpawn({ cmd, args, cwd, scratchHome, sessionId }) {
58
71
  return { cmd, args, isolation: { kind: "none", reason: probe.reason } };
59
72
  }
60
73
 
74
+ const policyKind = legacyMode() ? "legacy" : "strict";
75
+
61
76
  if (probe.kind === "sandbox-exec") {
62
77
  const profilePath = join(scratchHome, "sandbox.sb");
63
- writeFileSync(profilePath, macosProfile({ cwd, scratchHome }), { mode: 0o600 });
78
+ const profile = policyKind === "legacy"
79
+ ? macosProfileLegacy({ cwd, scratchHome })
80
+ : macosProfileStrict({ cwd, scratchHome });
81
+ writeFileSync(profilePath, profile, { mode: 0o600 });
64
82
  return {
65
83
  cmd: "sandbox-exec",
66
84
  args: ["-f", profilePath, cmd, ...args],
67
- isolation: { kind: "sandbox-exec" },
85
+ isolation: { kind: "sandbox-exec", policy: policyKind },
68
86
  };
69
87
  }
70
88
 
71
89
  if (probe.kind === "bwrap") {
90
+ const bwrap = policyKind === "legacy"
91
+ ? bwrapArgsLegacy({ cwd, scratchHome })
92
+ : bwrapArgsStrict({ cwd, scratchHome });
72
93
  return {
73
94
  cmd: "bwrap",
74
- args: [...bwrapArgs({ cwd, scratchHome }), cmd, ...args],
75
- isolation: { kind: "bwrap" },
95
+ args: [...bwrap, cmd, ...args],
96
+ isolation: { kind: "bwrap", policy: policyKind },
76
97
  };
77
98
  }
78
99
 
79
100
  return { cmd, args, isolation: { kind: "none", reason: "unknown probe kind" } };
80
101
  }
81
102
 
82
- // --- macOS sandbox-exec profile ---------------------------------------------
103
+ // --- macOS profiles ----------------------------------------------------------
104
+
105
+ function sbString(s) {
106
+ return `"${String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
107
+ }
108
+
109
+ // AD-30 strict allowlist. Start with deny-default, then re-expose only the
110
+ // paths and capabilities legitimate tools need. Anything under $HOME that
111
+ // isn't explicitly allowed is unreadable — that includes ~/Documents,
112
+ // browser cookie stores, other projects' .env files, etc.
113
+ function macosProfileStrict({ cwd, scratchHome }) {
114
+ const home = homedir();
115
+
116
+ // Tool installations and caches users typically need read access to. If a
117
+ // path doesn't exist we still list it — sandbox-exec ignores missing paths.
118
+ const userToolPaths = [
119
+ join(home, ".nvm"),
120
+ join(home, ".npm"),
121
+ join(home, ".cache"),
122
+ join(home, ".pyenv"),
123
+ join(home, ".rbenv"),
124
+ join(home, ".rvm"),
125
+ join(home, ".cargo"),
126
+ join(home, ".rustup"),
127
+ join(home, ".gem"),
128
+ join(home, ".local"),
129
+ join(home, "Library", "Caches"),
130
+ ];
131
+
132
+ return [
133
+ `(version 1)`,
134
+ `(deny default)`,
135
+ ``,
136
+ `; Process, IPC, signals, and basic mach lookups so child tools can exec.`,
137
+ `(allow process-exec)`,
138
+ `(allow process-fork)`,
139
+ `(allow signal)`,
140
+ `(allow mach-lookup)`,
141
+ `(allow sysctl-read)`,
142
+ `(allow iokit-open)`,
143
+ `(allow system-socket)`,
144
+ ``,
145
+ `; Network — agents need to reach the Anthropic API, tracker APIs, package`,
146
+ `; registries, and remote git over HTTPS/SSH.`,
147
+ `(allow network*)`,
148
+ ``,
149
+ `; Project cwd + scratch HOME — full read/write.`,
150
+ `(allow file-read* file-write* (subpath ${sbString(cwd)}))`,
151
+ `(allow file-read* file-write* (subpath ${sbString(scratchHome)}))`,
152
+ ``,
153
+ `; System binaries and libraries — read-only.`,
154
+ `(allow file-read* (subpath "/usr"))`,
155
+ `(allow file-read* (subpath "/bin"))`,
156
+ `(allow file-read* (subpath "/sbin"))`,
157
+ `(allow file-read* (subpath "/System"))`,
158
+ `(allow file-read* (subpath "/Library"))`,
159
+ `(allow file-read* (subpath "/Applications"))`,
160
+ `(allow file-read* (subpath "/opt"))`,
161
+ `(allow file-read* (subpath "/private/etc"))`,
162
+ `(allow file-read* (subpath "/private/var/db/timezone"))`,
163
+ ``,
164
+ `; Standard devices.`,
165
+ `(allow file-read* file-write* (literal "/dev/null"))`,
166
+ `(allow file-read* (literal "/dev/random"))`,
167
+ `(allow file-read* (literal "/dev/urandom"))`,
168
+ `(allow file-read* file-write* (literal "/dev/tty"))`,
169
+ `(allow file-read* file-write* (literal "/dev/stdin"))`,
170
+ `(allow file-read* file-write* (literal "/dev/stdout"))`,
171
+ `(allow file-read* file-write* (literal "/dev/stderr"))`,
172
+ ``,
173
+ `; Temp space — child tools and package managers stage here.`,
174
+ `(allow file-read* file-write* (subpath "/tmp"))`,
175
+ `(allow file-read* file-write* (subpath "/private/tmp"))`,
176
+ `(allow file-read* file-write* (subpath "/private/var/folders"))`,
177
+ ``,
178
+ `; Tool installations and caches the user already has in $HOME — read+write`,
179
+ `; because package managers and version managers update their own caches.`,
180
+ `; NOTE: only these specific subpaths are exposed; the rest of $HOME stays`,
181
+ `; denied, so ~/Documents, ~/.ssh, ~/.aws, browser cookies, etc. are NOT`,
182
+ `; readable from inside the sandbox.`,
183
+ ...userToolPaths.map(p => `(allow file-read* file-write* (subpath ${sbString(p)}))`),
184
+ ``,
185
+ ].join("\n");
186
+ }
83
187
 
84
- function macosProfile({ cwd, scratchHome }) {
188
+ // Pre-AD-30 denylist kept as opt-in fallback via AGENTDESK_SANDBOX_LEGACY=1
189
+ // in case the strict policy breaks a tool we haven't accounted for.
190
+ function macosProfileLegacy({ cwd, scratchHome }) {
85
191
  const home = homedir();
86
- // Known-sensitive paths: reads AND writes denied, even though general
87
- // access is allowed, so a confused agent can't slurp up other projects'
88
- // tokens or clobber the user's SSH keys / shell config.
89
192
  const denyPathsSubpath = [
90
193
  join(home, ".ssh"),
91
194
  join(home, ".aws"),
92
195
  join(home, ".gcloud"),
93
- join(home, ".config", "gh"), // real gh config (scratch is under $TMPDIR)
196
+ join(home, ".config", "gh"),
94
197
  join(home, ".docker"),
95
198
  join(home, ".kube"),
96
199
  join(home, ".agentdesk"),
@@ -98,7 +201,7 @@ function macosProfile({ cwd, scratchHome }) {
98
201
  ];
99
202
  const denyPathsLiteral = [
100
203
  join(home, ".netrc"),
101
- join(home, ".gitconfig"), // real gitconfig (scratch has its own)
204
+ join(home, ".gitconfig"),
102
205
  join(home, ".npmrc"),
103
206
  join(home, ".pypirc"),
104
207
  join(home, ".zshrc"),
@@ -108,32 +211,73 @@ function macosProfile({ cwd, scratchHome }) {
108
211
  join(home, ".profile"),
109
212
  ];
110
213
 
111
- const sb = [
214
+ return [
112
215
  `(version 1)`,
113
216
  `(allow default)`,
114
217
  ``,
115
- `; Deny access (read + write) to known credential and shell-config paths.`,
116
- `; Agents can't read other projects' tokens, can't overwrite SSH keys or`,
117
- `; rewrite the user's shell rc files.`,
218
+ `; LEGACY denylist (pre-AD-30). Set AGENTDESK_SANDBOX_LEGACY=1 to use.`,
118
219
  ...denyPathsSubpath.map(p => `(deny file-read* file-write* (subpath ${sbString(p)}))`),
119
220
  ...denyPathsLiteral.map(p => `(deny file-read* file-write* (literal ${sbString(p)}))`),
120
221
  ``,
121
222
  ].join("\n");
122
-
123
- return sb;
124
- }
125
-
126
- function sbString(s) {
127
- return `"${String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
128
223
  }
129
224
 
130
225
  // --- Linux bwrap args --------------------------------------------------------
131
226
 
132
- function bwrapArgs({ cwd, scratchHome }) {
133
- // Bind the whole filesystem normally so tools that need to write state
134
- // (claude, npm, pip, yarn, etc.) keep working. Then cover the sensitive
135
- // credential/config paths with tmpfs mounts so the agent can't read or
136
- // write them — other projects' .env, SSH keys, shell rc files, etc.
227
+ // AD-30 strict allowlist via bwrap. Read-only binds for system paths, --tmpfs
228
+ // over $HOME so nothing on the user's home directory is visible by default,
229
+ // then re-expose specific tool dirs that exist as read-only binds.
230
+ function bwrapArgsStrict({ cwd, scratchHome }) {
231
+ const home = homedir();
232
+ const args = [
233
+ "--die-with-parent",
234
+ "--unshare-ipc",
235
+ "--unshare-uts",
236
+ "--unshare-pid",
237
+ "--proc", "/proc",
238
+ "--dev", "/dev",
239
+ // System: read-only.
240
+ "--ro-bind", "/usr", "/usr",
241
+ "--ro-bind", "/etc", "/etc",
242
+ "--ro-bind-try", "/lib", "/lib",
243
+ "--ro-bind-try", "/lib64", "/lib64",
244
+ "--ro-bind-try", "/lib32", "/lib32",
245
+ "--ro-bind-try", "/bin", "/bin",
246
+ "--ro-bind-try", "/sbin", "/sbin",
247
+ "--ro-bind-try", "/opt", "/opt",
248
+ // tmp: writable.
249
+ "--bind", "/tmp", "/tmp",
250
+ // Blank the real home; re-expose specific tool dirs below.
251
+ "--tmpfs", home,
252
+ // Project cwd + scratch HOME: writable.
253
+ "--bind", cwd, cwd,
254
+ "--bind", scratchHome, scratchHome,
255
+ // Network is intentionally NOT unshared — agents need tracker APIs and git.
256
+ ];
257
+
258
+ // Tool dirs the user typically has in $HOME — re-expose only if present.
259
+ // `--ro-bind-try` is a no-op if the source path doesn't exist.
260
+ const userToolPaths = [
261
+ join(home, ".nvm"),
262
+ join(home, ".npm"),
263
+ join(home, ".cache"),
264
+ join(home, ".pyenv"),
265
+ join(home, ".rbenv"),
266
+ join(home, ".rvm"),
267
+ join(home, ".cargo"),
268
+ join(home, ".rustup"),
269
+ join(home, ".gem"),
270
+ join(home, ".local"),
271
+ ];
272
+ for (const p of userToolPaths) {
273
+ args.push("--ro-bind-try", p, p);
274
+ }
275
+
276
+ return args;
277
+ }
278
+
279
+ // Pre-AD-30 denylist via bwrap (--bind / / + tmpfs over deny paths).
280
+ function bwrapArgsLegacy({ cwd, scratchHome }) {
137
281
  const home = homedir();
138
282
  const denyPaths = [
139
283
  join(home, ".ssh"),
@@ -162,10 +306,7 @@ function bwrapArgs({ cwd, scratchHome }) {
162
306
  "--unshare-ipc",
163
307
  "--unshare-uts",
164
308
  "--unshare-pid",
165
- // Network is NOT unshared — agents need network for tracker APIs and git.
166
309
  ];
167
310
  for (const p of denyPaths) args.push("--tmpfs", p);
168
- // scratch HOME + project dir stay writable (already bound via --bind / /
169
- // above — tmpfs blanks only the specific deny paths, not the rest).
170
311
  return args;
171
312
  }
@@ -1,7 +1,12 @@
1
1
  // Verify tracker API permissions before starting a session
2
2
  // Checks: read tasks, create tasks, update tasks
3
3
 
4
- import { execSync } from "child_process";
4
+ import { execFileSync } from "child_process";
5
+
6
+ // AD-29: any external string (repo name, ref, etc.) must be regex-validated
7
+ // before it goes into a child process. Even with execFile, an upstream caller
8
+ // passing "../../etc" or similar can trip path semantics in the called tool.
9
+ const GITHUB_REPO_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
5
10
 
6
11
  const LINEAR_API = "https://api.linear.app/graphql";
7
12
 
@@ -187,18 +192,25 @@ async function checkGitHub({ repo }, creds) {
187
192
  const env = { ...process.env };
188
193
  if (creds?.GITHUB_TOKEN) env.GH_TOKEN = creds.GITHUB_TOKEN;
189
194
 
190
- // Check if gh CLI is available
195
+ // AD-29: refuse anything that isn't a clean owner/repo before going near a
196
+ // subprocess. `repo` comes from server-pushed project settings, which an
197
+ // attacker could control via a chained takeover.
198
+ if (!GITHUB_REPO_RE.test(repo)) {
199
+ return { ok: false, errors: [`Invalid GitHub repo format: ${JSON.stringify(repo)} (expected "owner/name")`] };
200
+ }
201
+
202
+ // Check if gh CLI is available. execFileSync (no shell) — argv is static.
191
203
  try {
192
- execSync("gh --version", { stdio: "pipe" });
204
+ execFileSync("gh", ["--version"], { stdio: "pipe" });
193
205
  } catch {
194
206
  return { ok: false, errors: ["GitHub CLI (gh) is not installed — install it from https://cli.github.com"] };
195
207
  }
196
208
 
197
209
  // Check auth status and grab the authenticated login
198
210
  try {
199
- execSync("gh auth status", { stdio: "pipe", env });
211
+ execFileSync("gh", ["auth", "status"], { stdio: "pipe", env });
200
212
  try {
201
- const who = execSync(`gh api user --jq '{login: .login, name: .name}'`, { stdio: "pipe", encoding: "utf-8", env }).trim();
213
+ const who = execFileSync("gh", ["api", "user", "--jq", "{login: .login, name: .name}"], { stdio: "pipe", encoding: "utf-8", env }).trim();
202
214
  const j = JSON.parse(who);
203
215
  identity = { name: j.name || j.login, login: j.login };
204
216
  } catch {}
@@ -206,9 +218,9 @@ async function checkGitHub({ repo }, creds) {
206
218
  return { ok: false, errors: ["GitHub CLI is not authenticated — run 'gh auth login', or paste a GITHUB_TOKEN"] };
207
219
  }
208
220
 
209
- // Check repo access and permissions
221
+ // Check repo access and permissions — argv form, no shell, repo already validated.
210
222
  try {
211
- const result = execSync(`gh api repos/${repo} --jq ".permissions"`, { stdio: "pipe", encoding: "utf-8", env });
223
+ const result = execFileSync("gh", ["api", `repos/${repo}`, "--jq", ".permissions"], { stdio: "pipe", encoding: "utf-8", env });
212
224
  const perms = JSON.parse(result.trim());
213
225
 
214
226
  if (!perms.pull) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kendoo.agentdesk/agentdesk",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "AI team orchestrator for Claude Code — run collaborative agent sessions from your terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,7 +22,7 @@
22
22
  "server": "node server/index.mjs",
23
23
  "build": "vite build",
24
24
  "preview": "vite preview",
25
- "test": "node --test tests/server.test.mjs tests/agents.test.mjs tests/homepage.test.mjs tests/sessionUtils.test.mjs tests/tracker-url.test.mjs tests/session-preflight.test.mjs tests/access.test.mjs",
25
+ "test": "node --test tests/server.test.mjs tests/agents.test.mjs tests/homepage.test.mjs tests/sessionUtils.test.mjs tests/tracker-url.test.mjs tests/session-preflight.test.mjs tests/access.test.mjs tests/project-ownership.test.mjs tests/random-hex.test.mjs",
26
26
  "lint:changelog": "node scripts/lint-changelog.mjs",
27
27
  "prepublishOnly": "node scripts/lint-changelog.mjs"
28
28
  },