@join-pantheon/cli 0.29.1 → 0.31.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.
Files changed (57) hide show
  1. package/README.md +142 -40
  2. package/bin/pantheon.js +12 -0
  3. package/dist/accounts.js +151 -26
  4. package/dist/agents.js +224 -17
  5. package/dist/claude-confine.js +299 -0
  6. package/dist/cli.js +1116 -137
  7. package/dist/client.js +294 -10
  8. package/dist/command-state.js +102 -0
  9. package/dist/config.js +287 -14
  10. package/dist/continued.js +42 -10
  11. package/dist/doctor.js +191 -23
  12. package/dist/file-lock.js +197 -0
  13. package/dist/grants.js +1 -1
  14. package/dist/history-view.js +2 -1
  15. package/dist/home-guard.js +565 -0
  16. package/dist/hooks-install.js +2 -1
  17. package/dist/identity.js +101 -1
  18. package/dist/install.js +76 -26
  19. package/dist/kiro-files.js +2 -2
  20. package/dist/ledger.js +111 -28
  21. package/dist/machine.js +494 -32
  22. package/dist/mcp-supervisor.js +29 -7
  23. package/dist/mcp-windows.js +47 -25
  24. package/dist/mcp.js +99 -36
  25. package/dist/network.js +39 -8
  26. package/dist/nonces.js +130 -0
  27. package/dist/notify.js +26 -9
  28. package/dist/pending.js +121 -22
  29. package/dist/persona.js +21 -0
  30. package/dist/pins.js +161 -0
  31. package/dist/profile.js +22 -1
  32. package/dist/protocol.js +40 -1
  33. package/dist/quickstart.js +173 -24
  34. package/dist/reads.js +37 -0
  35. package/dist/refusal.js +14 -1
  36. package/dist/relay.js +1341 -207
  37. package/dist/repo-find.js +127 -0
  38. package/dist/repo-route.js +70 -0
  39. package/dist/roster-file.js +190 -25
  40. package/dist/run-failure.js +99 -3
  41. package/dist/secret-guard.js +141 -0
  42. package/dist/service.js +91 -35
  43. package/dist/setup-guide.js +91 -31
  44. package/dist/spend.js +6 -2
  45. package/dist/store.js +94 -10
  46. package/dist/text-file.js +74 -0
  47. package/dist/tray.js +24 -3
  48. package/dist/untrusted.js +24 -5
  49. package/dist/update-check.js +2 -0
  50. package/dist/upgrade.js +9 -5
  51. package/dist/verify-act.js +102 -0
  52. package/dist/which.js +69 -12
  53. package/dist/worker-fleet.js +30 -9
  54. package/dist/worker-policy.js +231 -7
  55. package/dist/worker.js +1717 -130
  56. package/helper/macos/menubar.swift +63 -2
  57. package/package.json +2 -2
package/dist/agents.js CHANGED
@@ -6,6 +6,7 @@ import { join, dirname, isAbsolute } from "node:path";
6
6
  import { launcher, kiroDir } from "./install.js";
7
7
  import { agentSpawn, which } from "./which.js";
8
8
  import { PANTHEON_TOOL_NAMES, PANTHEON_TOOLS } from "./tools.js";
9
+ import { claudeRunSettings, NO_CLAUDE_FEATURES, runTmpDir, sandboxCacheEnv, versionAtLeast } from "./claude-confine.js";
9
10
  export function launchArgs(agent, prompt, name) {
10
11
  if (agent === "claude-code")
11
12
  return ["--name", name, prompt];
@@ -109,8 +110,9 @@ export function resolveBinary(backend) {
109
110
  }
110
111
  binaryCache.set(b, found);
111
112
  }
112
- if (binaryCache.get(b))
113
- return b;
113
+ const hit = binaryCache.get(b);
114
+ if (hit)
115
+ return hit;
114
116
  }
115
117
  return null;
116
118
  }
@@ -123,7 +125,16 @@ export const SIGN_IN_CHECK = {
123
125
  };
124
126
  export const NEEDS_SIGN_IN_BEFORE_SPAWN = new Set(["kiro"]);
125
127
  export const NOT_FOR_UNATTENDED_RUNS = new Set(["kiro"]);
126
- export const KIRO_UNATTENDED_REFUSAL = "Kiro cannot yet be fenced to its worktree for unattended runs; the worker uses another installed agent, or none";
128
+ export const KIRO_UNATTENDED_REFUSAL = "Kiro answers in your live sessions; for runs while you are away the worker uses another installed agent (Claude Code, Codex, Cursor or Copilot)";
129
+ export const SANDBOXED_FOR_UNATTENDED = new Set(["claude-code"]);
130
+ export const unsandboxedRefusal = (label) => `${label} is set to leave teammates' requests for you on this machine, so this one waits for your next session. ` +
131
+ `To have ${label} answer them while you're away: pantheon policy grant unsandboxed-runs`;
132
+ export function agentsNeedingUnsandboxed(available = detectAvailable()) {
133
+ if (available.some((a) => SANDBOXED_FOR_UNATTENDED.has(a.backend.id)))
134
+ return [];
135
+ return [...new Set(available.filter((a) => !NOT_FOR_UNATTENDED_RUNS.has(a.backend.id)).map((a) => a.backend.label))];
136
+ }
137
+ export const claudeSandboxable = (f, os = process.platform) => os !== "win32" && !!f.settings && versionAtLeast(f.version, "2.1.83");
127
138
  export function kiroSignedIn(binary, run = runStatus, env = process.env, home = homedir(), os = platform()) {
128
139
  if (env.KIRO_API_KEY)
129
140
  return { ok: true, detail: "signed in (KIRO_API_KEY in the environment)" };
@@ -219,6 +230,14 @@ export function chooseAgent(preferred, opts = {}) {
219
230
  skipped.push({ backend: c.backend, why: KIRO_UNATTENDED_REFUSAL });
220
231
  if (usable.length)
221
232
  order.splice(0, order.length, ...usable);
233
+ if (!opts.unsandboxedOk) {
234
+ const boxed = order.filter((c) => SANDBOXED_FOR_UNATTENDED.has(c.backend.id));
235
+ for (const c of order)
236
+ if (!SANDBOXED_FOR_UNATTENDED.has(c.backend.id) && boxed.length)
237
+ skipped.push({ backend: c.backend, why: unsandboxedRefusal(c.backend.label) });
238
+ if (boxed.length)
239
+ order.splice(0, order.length, ...boxed);
240
+ }
222
241
  for (const c of order) {
223
242
  const s = state(c.backend, c.binary);
224
243
  if (!s || s.ok || s.unknown)
@@ -228,9 +247,15 @@ export function chooseAgent(preferred, opts = {}) {
228
247
  const first = order.find((o) => !NEEDS_SIGN_IN_BEFORE_SPAWN.has(o.backend.id)) ?? order[0];
229
248
  return { backend: first.backend, binary: first.binary, signIn: state(first.backend, first.binary), because: `${first.because} (none signed in)`, skipped };
230
249
  }
231
- export function unattendedRefusal(c) {
250
+ export function unattendedRefusal(c, o = {}) {
232
251
  if (NOT_FOR_UNATTENDED_RUNS.has(c.backend.id))
233
252
  return KIRO_UNATTENDED_REFUSAL;
253
+ if (!SANDBOXED_FOR_UNATTENDED.has(c.backend.id) && !o.unsandboxedOk) {
254
+ return c.because === "pinned"
255
+ ? `woken runs here are pinned to ${c.backend.label} (a persona's agent: line, or the worker's --agent), which is set to leave teammates' requests for you, so this one waits for your next session. ` +
256
+ `To have ${c.backend.label} answer them: pantheon policy grant unsandboxed-runs — or unpin it so Claude Code takes them`
257
+ : unsandboxedRefusal(c.backend.label);
258
+ }
234
259
  if (!NEEDS_SIGN_IN_BEFORE_SPAWN.has(c.backend.id) || c.signIn?.ok)
235
260
  return null;
236
261
  return `${c.backend.label} is not signed in (${c.signIn?.detail ?? "not checked"}), and signed out it opens a browser to log in, so it is not started while you are away — ${SIGN_IN_CHECK[c.backend.id].fix}`;
@@ -242,7 +267,8 @@ export function describeChoice(c) {
242
267
  export function skippedSummary(c) {
243
268
  if (!c.skipped.length)
244
269
  return "";
245
- const why = [...new Set(c.skipped.map((s) => (s.why === KIRO_UNATTENDED_REFUSAL ? `${s.backend.label} cannot run unattended yet` : "not signed in")))];
270
+ const why = [...new Set(c.skipped.map((s) => (s.why === KIRO_UNATTENDED_REFUSAL ? `${s.backend.label} cannot run unattended yet`
271
+ : s.why === unsandboxedRefusal(s.backend.label) ? `${s.backend.label} has no sandbox` : "not signed in")))];
246
272
  return `${c.skipped.length} skipped (${why.join("; ")})`;
247
273
  }
248
274
  export function resolveAgent(preferred) {
@@ -250,25 +276,155 @@ export function resolveAgent(preferred) {
250
276
  return { backend: c.backend, binary: c.binary };
251
277
  }
252
278
  export { PANTHEON_TOOL_NAMES, PANTHEON_TOOLS } from "./tools.js";
279
+ export const COMMAND_ALLOWLIST = [
280
+ ...["status", "diff", "log", "show", "add", "rm", "mv", "commit", "push", "fetch", "checkout", "switch", "restore",
281
+ "branch", "rev-parse", "ls-files", "stash list", "stash show", "blame", "merge-base", "reset"].map((c) => `git ${c}`),
282
+ "npm test", "npm run", "npm ci", "pnpm test", "pnpm run", "yarn test", "yarn run", "bun test", "bun run",
283
+ "make", "cargo build", "cargo test", "cargo check", "go build", "go test", "go vet",
284
+ "pytest", "python -m pytest", "python3 -m pytest", "mvn", "gradle", "./gradlew", "dotnet build", "dotnet test",
285
+ ];
286
+ export const COMMAND_EXACT = ["npm install", "pnpm install", "yarn install", "bun install"];
287
+ export const claudeCommandRules = (extra = []) => [
288
+ ...COMMAND_ALLOWLIST.map((c) => `Bash(${c} *)`),
289
+ ...COMMAND_EXACT.map((c) => `Bash(${c})`),
290
+ ...extra.map((c) => `Bash(${c} *)`),
291
+ ];
292
+ export const REPO_COMMANDS_FILE = ".pantheon/commands";
293
+ const RUNS_ANYTHING = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh", "ash", "busybox", "pwsh", "powershell", "cmd",
294
+ "env", "eval", "exec", "sudo", "su", "doas", "pkexec", "runuser", "xargs", "nohup", "time", "command", "builtin", "source", ".", "nice", "timeout", "watch",
295
+ "setsid", "stdbuf", "ionice", "flock", "chroot", "unshare", "nsenter", "script", "unbuffer", "strace", "ltrace",
296
+ "ssh", "scp", "curl", "wget", "nc", "ncat", "socat", "osascript", "open", "launchctl", "systemctl", "crontab", "chmod", "chown", "rm",
297
+ "direnv", "devbox", "mise", "asdf", "nix", "nix-shell", "wsl", "start", "rundll32", "mshta", "wscript", "cscript",
298
+ "find", "awk", "gawk", "mawk", "nawk", "sed", "gsed", "parallel", "cross-env", "concurrently", "npm-run-all", "run-p", "run-s", "nodemon", "watchexec", "entr",
299
+ "hyperfine", "tmux", "screen", "gdb", "lldb", "expect", "nu", "xonsh", "mksh", "yash", "elvish", "rc", "oksh", "posh",
300
+ "winpty", "mintty", "wt", "conhost", "git-bash", "schtasks", "runas", "forfiles", "powershell_ise", "vim", "vi", "nvim", "emacs", "less", "more", "man"]);
301
+ const VERSIONED_SHELL = /^(?:ba|z|k|c|tc|da|a|fi|mk)?sh[-.\d]*$|^fish[-.\d]+$|^pwsh[-.\d]+$/;
302
+ const GIT_SAFE_SUB = new Set(["status", "diff", "log", "show", "add", "rm", "mv", "commit", "push", "fetch", "checkout", "switch", "restore", "branch",
303
+ "rev-parse", "ls-files", "blame", "merge-base", "reset", "describe", "shortlog", "cherry-pick", "merge", "pull", "clean", "rev-list", "ls-tree", "cat-file", "diff-tree", "name-rev", "check-ignore", "lfs", "worktree"]);
304
+ const FETCH_AND_RUN = new Set(["npx", "pnpx", "bunx", "uvx", "pipx"]);
305
+ const FETCH_AND_RUN_SUB = {
306
+ npm: /^(?:exec|x)$/, pnpm: /^(?:dlx|exec)$/, yarn: /^(?:dlx|exec)$/, bun: /^x$/, uv: /^tool$/, poetry: /^run$/, pipenv: /^run$/, pdm: /^run$/,
307
+ };
308
+ const MANY_VERBS = new Set(["npm", "pnpm", "yarn", "bun", "uv", "poetry", "pipenv", "pdm", "go", "cargo", "dotnet", "deno", "docker", "podman", "nerdctl", "kubectl", "gradle", "mvn", "make", "just", "task", "rake",
309
+ "pip", "pip3", "gem", "brew"]);
310
+ const RUNS_NEXT = /^(?:run|exec|x|dlx|tool|eval|start|create|compose|container|debug|attach|install|add|i)$/;
311
+ const INTERPRETER = /^(?:node|nodejs|python[0-9.]*|pythonw|pypy[0-9.]*|perl[0-9.]*|ruby|php|deno|bun|lua[0-9.]*|luajit|rscript|tclsh|wish|java|kotlin|groovy|scala|elixir|erl|julia|swift|ts-node|tsx)$/;
312
+ const SCRIPT_PATH = /^(?!\/|~|.*\.\.)(?:[\w@.+\-/]+\.(?:c?js|mjs|cts|mts|ts|tsx|jsx|py|rb|pl|php|lua|r|jar|kts?|groovy|exs?|jl|swift)$|[\w@.+\-]+\/[\w@.+\-/]+$)/i;
313
+ export const programName = (word) => word.replace(/^.*[\\/]/, "").toLowerCase().replace(/\.(?:exe|cmd|bat|com|ps1)$/, "");
314
+ export function checkRepoCommand(line) {
315
+ const t = line.trim();
316
+ if (!t)
317
+ return "empty";
318
+ if (/[;&|`$<>(){}\\*?!\n]/.test(t))
319
+ return "shell syntax (; & | ` $ < > ( ) { } * ? !) is not a command prefix";
320
+ const words = t.split(/\s+/);
321
+ const first = programName(words[0]);
322
+ if (RUNS_ANYTHING.has(first) || VERSIONED_SHELL.test(first))
323
+ return `${first} can run any command, so it would lift the whole list`;
324
+ if (/['"]/.test(t))
325
+ return "quotes are not allowed — write the command plainly";
326
+ if (FETCH_AND_RUN.has(first))
327
+ return `${first} fetches and runs packages, so it would run whatever it is given`;
328
+ const plain = words.slice(1).filter((w) => !w.startsWith("-"));
329
+ const sub = FETCH_AND_RUN_SUB[first];
330
+ if (sub && plain[0] && sub.test(plain[0].toLowerCase()))
331
+ return `${first} ${plain[0]} runs whatever it is given`;
332
+ if (MANY_VERBS.has(first) && !plain.length && !["make", "just", "task", "rake", "gradle", "mvn"].includes(first)) {
333
+ return `${first} alone would allow every ${first} subcommand, one that runs anything included — name it (${first} test, ${first} run lint)`;
334
+ }
335
+ const last = words[words.length - 1].toLowerCase();
336
+ const own = words.length === 2 || (words.length === 3 && /^(?:compose|container)$/i.test(words[1]));
337
+ if (MANY_VERBS.has(first) && own && RUNS_NEXT.test(last) && !(["poetry", "pdm", "pipenv"].includes(first) && last === "install")) {
338
+ return `it ends on "${last}", so the run would choose what that ${/^(?:install|add|i)$/.test(last) ? "installs" : "runs"} — name it (${words.join(" ")} <the program or target>)`;
339
+ }
340
+ if (first === "git" && (words.length < 2 || words[1].startsWith("-")))
341
+ return "git is allowed by subcommand only (git -c … can run anything)";
342
+ if (first === "git" && !GIT_SAFE_SUB.has(words[1].toLowerCase()))
343
+ return `git ${words[1]} is not one a woken run may be given (some git subcommands run programs: rebase --exec, submodule foreach, bisect run)`;
344
+ if (INTERPRETER.test(first)) {
345
+ const script = (first === "deno" || first === "bun") && words[1] === "run" ? words[2] : words[1];
346
+ if (!script || script.startsWith("-") || !SCRIPT_PATH.test(script)) {
347
+ return `${first} is allowed only with a script path after it (${first} scripts/test.${first.startsWith("py") ? "py" : "js"}) — alone it would allow inline code`;
348
+ }
349
+ }
350
+ if (words.some((w) => w === "-c" || w === "--eval" || w === "-e"))
351
+ return "an option that runs inline code";
352
+ return null;
353
+ }
354
+ export function parseRepoCommands(text) {
355
+ const ok = [];
356
+ const refused = [];
357
+ for (const raw of text.split(/\r?\n/)) {
358
+ const line = raw.replace(/\s+#.*$/, "").replace(/^#.*$/, "").trim();
359
+ if (!line)
360
+ continue;
361
+ const why = checkRepoCommand(line);
362
+ if (why)
363
+ refused.push({ line, why });
364
+ else if (!ok.includes(line))
365
+ ok.push(line);
366
+ }
367
+ return { ok: ok.slice(0, 100), refused };
368
+ }
369
+ export function repoCommands(worktree, rev) {
370
+ if (!rev || !/^[0-9a-f]{7,64}$/i.test(rev))
371
+ return { ok: [], refused: [] };
372
+ let bytes;
373
+ try {
374
+ bytes = execFileSync("git", ["-C", worktree, "show", `${rev}:${REPO_COMMANDS_FILE}`], { stdio: ["ignore", "pipe", "ignore"], timeout: 10_000, maxBuffer: 1 << 20 });
375
+ }
376
+ catch {
377
+ return { ok: [], refused: [] };
378
+ }
379
+ const text = decodeCommandsFile(bytes);
380
+ if (text === null)
381
+ return { ok: [], refused: [{ line: "(the whole file)", why: `it is not text — save ${REPO_COMMANDS_FILE} as UTF-8` }] };
382
+ return parseRepoCommands(text);
383
+ }
384
+ export function decodeCommandsFile(b) {
385
+ const even = (x) => x.subarray(0, x.length - (x.length % 2));
386
+ if (b[0] === 0xff && b[1] === 0xfe)
387
+ return even(b.subarray(2)).toString("utf16le");
388
+ if (b[0] === 0xfe && b[1] === 0xff) {
389
+ const le = Buffer.from(even(b.subarray(2)));
390
+ le.swap16();
391
+ return le.toString("utf16le");
392
+ }
393
+ const text = b.toString("utf8").replace(/^\uFEFF/, "");
394
+ return text.includes("\0") ? null : text;
395
+ }
396
+ export const REPO_RULES_ARGV_MAX = 3_000;
397
+ export const COMMAND_GRANT_SCOPE = "a run with commands works in its own worktree on a pantheon/* branch: it runs your tests and builds, commits, pushes its own branch for your review, and never merges. " +
398
+ "Claude Code (macOS and Linux) runs those commands inside its OS sandbox: they write only the run's worktree, its git data and the temp dirs, stay clear of your keys and credentials " +
399
+ "(~/.ssh, ~/.aws, ~/.config/gh, ~/.gnupg, ~/.netrc, ~/.docker/config.json, ~/.kube, ~/.git-credentials, ~/.pantheon), and use git, the repo's scripts through npm/pnpm/yarn/bun, " +
400
+ "make, cargo, go, pytest, mvn/gradle and dotnet, plus whatever the committed .pantheon/commands on the base branch lists. " +
401
+ "Codex runs them in its own workspace-write sandbox, approved by its auto-reviewer. " +
402
+ "Cursor and Copilot run any command without asking, as you and with no sandbox; Claude Code on Windows or before 2.1.83 runs its listed commands the same way, without its sandbox. " +
403
+ "After every run Pantheon checks its own settings and puts back any change a run made to them; " +
404
+ "pantheon setup --unsandboxed no leaves those requests for you instead. Kiro answers in your live sessions. " +
405
+ "Turn commands off any time: pantheon policy revoke run-commands.";
253
406
  export function headlessSpawn(opts) {
254
407
  const l = launcher();
255
- const procEnv = {
408
+ const identity = {
256
409
  PANTHEON_RELAY: opts.cfg.relay,
257
410
  PANTHEON_SPACE: opts.cfg.space,
258
- PANTHEON_TOKEN: opts.cfg.token,
259
411
  PANTHEON_MEMBER: opts.cfg.member,
260
412
  PANTHEON_PUBKEY: opts.cfg.publicKey,
261
- PANTHEON_PRIVKEY: opts.cfg.privateKey,
413
+ ...(opts.cfg.repo ? { PANTHEON_REPO: opts.cfg.repo } : {}),
414
+ ...(opts.cfg.spaceId ? { PANTHEON_SPACE_ID: opts.cfg.spaceId } : {}),
415
+ };
416
+ const procEnv = {
417
+ ...identity,
418
+ PANTHEON_WOKEN_RUN: "1",
262
419
  PANTHEON_AGENT: opts.backend.id,
263
420
  PANTHEON_ROLE: "worker",
264
421
  PANTHEON_BRANCH: opts.branch,
265
422
  ...(opts.base ? { PANTHEON_BASE: opts.base } : {}),
266
423
  ...(opts.goal ? { PANTHEON_GOAL: opts.goal } : {}),
267
- ...(opts.cfg.repo ? { PANTHEON_REPO: opts.cfg.repo } : {}),
268
- ...(opts.cfg.spaceId ? { PANTHEON_SPACE_ID: opts.cfg.spaceId } : {}),
269
424
  };
270
425
  const home = opts.runHome ?? process.env.PANTHEON_HOME;
271
426
  const fileEnv = {
427
+ ...identity,
272
428
  PANTHEON_AGENT: opts.backend.id,
273
429
  PANTHEON_ROLE: "worker",
274
430
  PANTHEON_BRANCH: opts.branch,
@@ -278,19 +434,70 @@ export function headlessSpawn(opts) {
278
434
  };
279
435
  if (opts.backend.id === "claude-code") {
280
436
  const mcpCfg = join(opts.worktree, ".pantheon-mcp.json");
437
+ const writeFiles = [{
438
+ path: ".pantheon-mcp.json",
439
+ contents: JSON.stringify({ mcpServers: { pantheon: { command: l.command, args: l.args, env: fileEnv } } }, null, 2),
440
+ }];
441
+ const bashRules = opts.allowBash ? claudeCommandRules(repoCommands(opts.worktree, opts.commandsRev ?? opts.base).ok) : [];
442
+ const f = opts.claude?.features ?? NO_CLAUDE_FEATURES;
443
+ const notes = [];
444
+ const extra = [];
445
+ let env = procEnv;
446
+ let rulesOnArgv = bashRules;
447
+ if (opts.claude && opts.runHome) {
448
+ const runTmp = runTmpDir(opts.runHome);
449
+ const c = claudeRunSettings({
450
+ allowBash: opts.allowBash, worktree: opts.worktree, runHome: opts.runHome, pantheonHome: opts.claude.pantheonHome,
451
+ ...(opts.readOnlyDirs?.length ? { readOnlyDirs: opts.readOnlyDirs } : {}),
452
+ runTmp, bashRules, version: f.version, ...(opts.claude.home ? { home: opts.claude.home } : {}),
453
+ ...(opts.os ? { os: opts.os } : {}), ...(opts.claude.userSettings ? { userSettings: opts.claude.userSettings } : {}),
454
+ });
455
+ if (f.permissionPrompts)
456
+ extra.push("--permission-prompts", "none");
457
+ if (f.settingSources)
458
+ extra.push("--setting-sources", "user");
459
+ if (f.addDir && f.settings)
460
+ for (const d of c.readOnly)
461
+ extra.push("--add-dir", d);
462
+ if (f.settings) {
463
+ const file = join(opts.runHome, "claude-settings.json");
464
+ writeFiles.push({ path: file, contents: JSON.stringify(c.settings, null, 2) });
465
+ extra.push("--settings", file);
466
+ rulesOnArgv = [];
467
+ notes.push(...c.notes);
468
+ if (c.sandboxed)
469
+ env = { ...procEnv, ...sandboxCacheEnv(runTmp) };
470
+ }
471
+ else if (f.disallowedTools) {
472
+ const deny = c.settings.permissions.deny;
473
+ extra.push("--disallowedTools", ...deny);
474
+ notes.push("no --settings on this Claude Code: deny rules passed with --disallowedTools; no sandbox, so the command list is not a boundary");
475
+ }
476
+ else {
477
+ notes.push("no --settings on this Claude Code: no deny rules and no sandbox — the command list is not a boundary");
478
+ }
479
+ }
480
+ if (rulesOnArgv.length) {
481
+ const fixed = claudeCommandRules().length;
482
+ let used = 0;
483
+ const kept = rulesOnArgv.filter((r, n) => n < fixed || (used += r.length + 3) <= REPO_RULES_ARGV_MAX);
484
+ if (kept.length < rulesOnArgv.length)
485
+ notes.push(`${rulesOnArgv.length - kept.length} of the repo's commands left off: they do not fit on this command line (${REPO_RULES_ARGV_MAX} characters for the repo's own)`);
486
+ rulesOnArgv = kept;
487
+ }
281
488
  return {
282
489
  command: opts.binary,
283
490
  args: [
284
491
  "-p", opts.prompt,
285
492
  "--mcp-config", mcpCfg, "--strict-mcp-config",
286
- "--permission-mode", opts.allowBash ? "bypassPermissions" : "acceptEdits",
287
- ...(opts.allowBash ? [] : ["--allowedTools", ...PANTHEON_TOOLS]),
493
+ "--permission-mode", "acceptEdits",
494
+ ...extra,
495
+ "--allowedTools", ...PANTHEON_TOOLS, ...rulesOnArgv,
288
496
  ],
289
- env: procEnv,
290
- writeFiles: [{
291
- path: ".pantheon-mcp.json",
292
- contents: JSON.stringify({ mcpServers: { pantheon: { command: l.command, args: l.args, env: fileEnv } } }, null, 2),
293
- }],
497
+ env,
498
+ writeFiles,
499
+ ...(notes.length ? { notes } : {}),
500
+ ...(extra.includes("--setting-sources") ? { repoSettingsIgnored: true } : {}),
294
501
  };
295
502
  }
296
503
  if (opts.backend.id === "codex") {
@@ -0,0 +1,299 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { lstatSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
4
+ import { homedir, platform, tmpdir } from "node:os";
5
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
6
+ import { agentSpawn } from "./which.js";
7
+ export const NO_CLAUDE_FEATURES = { version: null, permissionPrompts: false, settings: false, settingSources: false, disallowedTools: false };
8
+ export function parseClaudeHelp(help, version = null) {
9
+ return {
10
+ version,
11
+ permissionPrompts: /(^|\s)--permission-prompts\b/m.test(help),
12
+ settings: /(^|\s)--settings\s/m.test(help),
13
+ settingSources: /(^|\s)--setting-sources\b/m.test(help),
14
+ disallowedTools: /(^|\s)--disallowed-?tools\b/im.test(help),
15
+ addDir: /(^|\s)--add-dir\b/m.test(help),
16
+ };
17
+ }
18
+ export function versionAtLeast(a, b) {
19
+ if (!a)
20
+ return false;
21
+ const pa = a.split(".").map((x) => parseInt(x, 10) || 0);
22
+ const pb = b.split(".").map((x) => parseInt(x, 10) || 0);
23
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
24
+ if ((pa[i] ?? 0) !== (pb[i] ?? 0))
25
+ return (pa[i] ?? 0) > (pb[i] ?? 0);
26
+ }
27
+ return true;
28
+ }
29
+ const runHelp = (binary, args) => {
30
+ try {
31
+ const sp = agentSpawn(binary, args);
32
+ return execFileSync(sp.command, sp.args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 15_000, env: { ...process.env, NO_COLOR: "1" }, ...sp.options });
33
+ }
34
+ catch (e) {
35
+ const out = e.stdout;
36
+ return typeof out === "string" && out ? out : null;
37
+ }
38
+ };
39
+ const featureCache = new Map();
40
+ export function detectClaudeFeatures(binary, run = runHelp) {
41
+ let stamp = "";
42
+ try {
43
+ if (isAbsolute(binary))
44
+ stamp = String(statSync(binary).mtimeMs);
45
+ }
46
+ catch { }
47
+ const key = `${binary}\0${stamp}`;
48
+ const hit = featureCache.get(key);
49
+ if (hit)
50
+ return hit;
51
+ const help = run(binary, ["--help"]);
52
+ const v = run(binary, ["--version"]);
53
+ const version = v ? (/(\d+\.\d+\.\d+)/.exec(v)?.[1] ?? null) : null;
54
+ if (!help)
55
+ return { ...NO_CLAUDE_FEATURES, version, unknown: true };
56
+ const f = parseClaudeHelp(help, version);
57
+ featureCache.set(key, f);
58
+ return f;
59
+ }
60
+ export function describeFeatures(f) {
61
+ const say = (ok, flag) => `${flag} ${ok ? "yes" : "NO"}`;
62
+ const missing = [
63
+ !f.permissionPrompts && "a prompt is refused only by the permission mode",
64
+ !f.settings && (f.disallowedTools ? "no sandbox; deny rules go through --disallowedTools" : "no sandbox and no deny rules"),
65
+ !f.settingSources && "the repo's .claude settings are held out of the worktree instead",
66
+ ].filter(Boolean);
67
+ return `claude ${f.version ?? "(version unknown)"}: ${[
68
+ say(f.permissionPrompts, "--permission-prompts"), say(f.settings, "--settings"),
69
+ say(f.settingSources, "--setting-sources"), say(f.disallowedTools, "--disallowedTools"),
70
+ ].join(", ")}${missing.length ? ` — so: ${missing.join("; ")}` : ""}`;
71
+ }
72
+ export const SECRET_HOME_PATHS = [
73
+ { rel: ".pantheon", dir: true },
74
+ { rel: ".ssh", dir: true },
75
+ { rel: ".aws", dir: true },
76
+ { rel: ".config/gh", dir: true },
77
+ { rel: ".gnupg", dir: true },
78
+ { rel: ".netrc", dir: false },
79
+ { rel: ".docker/config.json", dir: false },
80
+ { rel: ".kube", dir: true },
81
+ { rel: ".git-credentials", dir: false },
82
+ { rel: ".claude/.credentials.json", dir: false },
83
+ { rel: "AppData/Roaming/GitHub CLI", dir: true },
84
+ ];
85
+ export const GIT_DENY_RULES = [
86
+ "Bash(git -c *)", "Bash(git -C *)", "Bash(git --config-env*)", "Bash(git --exec-path*)", "Bash(git --git-dir*)", "Bash(git --work-tree*)",
87
+ "Bash(git * -c *)", "Bash(git * --config-env*)",
88
+ "Bash(git * --upload-pack*)", "Bash(git * --receive-pack*)", "Bash(git * --exec*)",
89
+ "Bash(git * -O*)", "Bash(git * --open-files-in-pager*)",
90
+ "Bash(git * --output*)", "Bash(git * --no-verify*)", "Bash(git commit -n*)", "Bash(git commit * -n*)",
91
+ ...["-d", "-D", "--delete", "-m", "-M", "--move", "-c", "-C", "--copy", "-f", "--force", "-u", "--set-upstream-to", "--unset-upstream", "--edit-description"]
92
+ .flatMap((o) => [`Bash(git branch ${o}*)`, `Bash(git branch * ${o}*)`]),
93
+ ...["-fD", "-Df", "-fd", "-df", "-fM", "-Mf", "-fm", "-mf", "-fC", "-Cf", "-fc", "-cf", "-qD", "-qd"].flatMap((o) => [`Bash(git branch ${o}*)`, `Bash(git branch * ${o}*)`]),
94
+ ...["-B", "-fB", "-qB", "-fqB", "-qfB"].flatMap((o) => [`Bash(git checkout ${o}*)`, `Bash(git checkout * ${o}*)`]),
95
+ ...["-C", "-fC", "-qC", "-fqC", "-qfC"].flatMap((o) => [`Bash(git switch ${o}*)`, `Bash(git switch * ${o}*)`]), "Bash(git switch * --force-create*)", "Bash(git switch --force-create*)",
96
+ "Bash(git fetch *+*)", "Bash(git fetch *:refs/heads/*)",
97
+ "Bash(git push --delete*)", "Bash(git push * --delete*)", "Bash(git push -d *)", "Bash(git push * -d *)",
98
+ "Bash(git push --mirror*)", "Bash(git push * --mirror*)", "Bash(git push --all*)", "Bash(git push * --all*)",
99
+ ];
100
+ const inside = (child, parent) => {
101
+ const r = relative(parent, child);
102
+ return r === "" || (!!r && !r.startsWith("..") && !isAbsolute(r));
103
+ };
104
+ function ruleSpelling(abs, home, os) {
105
+ if (inside(abs, home) && abs !== home)
106
+ return `~/${relative(home, abs).split(sep).join("/")}`;
107
+ if (os === "win32") {
108
+ const m = /^([A-Za-z]):[\\/](.*)$/.exec(abs);
109
+ if (m)
110
+ return `//${m[1].toLowerCase()}/${m[2].split(/[\\/]+/).join("/")}`;
111
+ }
112
+ return `/${abs}`;
113
+ }
114
+ function sandboxSpelling(abs, home) {
115
+ return inside(abs, home) && abs !== home ? `~/${relative(home, abs).split(sep).join("/")}` : abs;
116
+ }
117
+ function userExcludedCommands(file) {
118
+ if (!file)
119
+ return [];
120
+ try {
121
+ const j = JSON.parse(readFileSync(file, "utf8"));
122
+ const xs = j.sandbox?.excludedCommands;
123
+ return Array.isArray(xs) ? xs.filter((x) => typeof x === "string" && !!x.trim()).slice(0, 50) : [];
124
+ }
125
+ catch {
126
+ return [];
127
+ }
128
+ }
129
+ const SECRET_FILE_GLOBS = [".env", ".env.*", "*.pem", "*.key", "*.p12", "*.pfx", "id_rsa*", "id_ed25519*", ".npmrc", ".netrc", "credentials*", "*.keystore", "service-account*.json"];
130
+ const PANTHEON_HOME_ENTRIES = ["config.json", "config.json.bak", "config.json.lock", "worker-policy.json", "profile.json", "continued.json", "bin", "ops", "run-homes", "logs", "mcp", "data"];
131
+ function aroundWorktree(top, keeps, named) {
132
+ const keep = (typeof keeps === "string" ? [keeps] : [...keeps]).map((k) => resolve(k));
133
+ const out = [];
134
+ const walk = (level, isTop) => {
135
+ if (keep.includes(resolve(level)))
136
+ return;
137
+ const nexts = new Set(keep.map((k) => relative(level, k).split(sep)[0]).filter((n) => !!n && n !== ".." && !isAbsolute(n)));
138
+ let names = [];
139
+ try {
140
+ names = readdirSync(level);
141
+ }
142
+ catch { }
143
+ const isDir = (n) => { try {
144
+ return statSync(join(level, n)).isDirectory();
145
+ }
146
+ catch {
147
+ return !n.includes(".");
148
+ } };
149
+ for (const n of new Set([...names, ...(isTop ? named : [])]))
150
+ if (!nexts.has(n))
151
+ out.push({ abs: join(level, n), dir: isDir(n) });
152
+ for (const n of nexts)
153
+ walk(join(level, n), false);
154
+ };
155
+ walk(top, true);
156
+ return out;
157
+ }
158
+ export function claudeRunSettings(i) {
159
+ const home = resolve(i.home ?? homedir());
160
+ const os = i.os ?? platform();
161
+ const worktree = resolve(i.worktree);
162
+ const notes = [];
163
+ const targets = [
164
+ ...SECRET_HOME_PATHS.map((p) => ({ abs: join(home, ...p.rel.split("/")), dir: p.dir })),
165
+ { abs: resolve(i.pantheonHome), dir: true },
166
+ { abs: resolve(i.runHome), dir: true },
167
+ ];
168
+ const seen = new Set();
169
+ const denied = [];
170
+ const secretTargets = targets.filter((t) => t.abs !== resolve(i.pantheonHome));
171
+ const readOnly = (i.readOnlyDirs ?? []).map((d) => resolve(d)).filter((d) => {
172
+ const bad = secretTargets.find((t) => inside(d, t.abs) || inside(t.abs, d));
173
+ if (bad)
174
+ notes.push(`${d} is not opened to the run: it holds or is inside ${bad.abs}`);
175
+ return !bad;
176
+ });
177
+ const keeps = [worktree, ...readOnly];
178
+ for (const t of targets) {
179
+ if (seen.has(t.abs))
180
+ continue;
181
+ seen.add(t.abs);
182
+ const kept = keeps.filter((k) => inside(k, t.abs));
183
+ if (kept.length) {
184
+ if (worktree === t.abs) {
185
+ notes.push(`${t.abs} is this run's worktree, so it is not denied to the run`);
186
+ continue;
187
+ }
188
+ for (const d of aroundWorktree(t.abs, kept, t.abs === resolve(i.pantheonHome) ? PANTHEON_HOME_ENTRIES : []))
189
+ if (!seen.has(d.abs)) {
190
+ seen.add(d.abs);
191
+ denied.push(d);
192
+ }
193
+ continue;
194
+ }
195
+ denied.push(t);
196
+ }
197
+ const fileRules = [
198
+ ...denied.flatMap((t) => {
199
+ const p = `${ruleSpelling(t.abs, home, os)}${t.dir ? "/**" : ""}`;
200
+ return [`Read(${p})`, `Edit(${p})`];
201
+ }),
202
+ ...readOnly.flatMap((d) => [`Edit(${ruleSpelling(d, home, os)}/**)`, ...SECRET_FILE_GLOBS.map((g) => `Read(${ruleSpelling(d, home, os)}/**/${g})`)]),
203
+ ];
204
+ if (!i.allowBash) {
205
+ const permissions = { deny: ["Bash", "PowerShell", ...fileRules] };
206
+ if (versionAtLeast(i.version, "2.1.257"))
207
+ permissions.blockReadsOutsideWorkingDirectories = true;
208
+ else
209
+ notes.push(`reads outside the worktree are not blocked wholesale (needs Claude Code 2.1.257+, this is ${i.version ?? "an unknown version"}) — only the secret paths are denied`);
210
+ notes.push("edits only: no shell, file tools held to the worktree, secrets and Pantheon's files denied");
211
+ return { settings: { permissions }, notes, sandboxed: false, readOnly };
212
+ }
213
+ const excluded = userExcludedCommands(i.userSettings);
214
+ if (excluded.length)
215
+ notes.push(`your own sandbox exclusions are refused to the run: ${excluded.join(", ")}`);
216
+ const permissions = {
217
+ allow: [...i.bashRules, "WebFetch(domain:*)"],
218
+ deny: ["PowerShell", ...fileRules, ...GIT_DENY_RULES, ...excluded.map((c) => `Bash(${c})`)],
219
+ };
220
+ if (os === "win32") {
221
+ notes.push("no OS sandbox on Windows (Claude Code has none there): the command list is the only line, and it is not a boundary; commands run through Git Bash only (the PowerShell tool is denied), so without Git Bash this run has no shell");
222
+ return { settings: { permissions }, notes, sandboxed: false, readOnly };
223
+ }
224
+ const failsClosed = versionAtLeast(i.version, "2.1.83");
225
+ const hooks = join(resolve(i.runHome), "git-hooks");
226
+ const sandbox = {
227
+ enabled: true,
228
+ failIfUnavailable: true,
229
+ autoAllowBashIfSandboxed: false,
230
+ allowUnsandboxedCommands: false,
231
+ filesystem: {
232
+ allowWrite: [...new Set([resolve(i.runTmp), resolve(tmpdir())])],
233
+ denyWrite: readOnly.map((d) => sandboxSpelling(d, home)),
234
+ denyRead: denied.map((t) => sandboxSpelling(t.abs, home)),
235
+ allowRead: [hooks],
236
+ },
237
+ network: { allowLocalBinding: true },
238
+ };
239
+ if (!failsClosed) {
240
+ notes.push(`this Claude Code (${i.version ?? "version unknown"}) cannot refuse to run a command when its sandbox does not start — update it to 2.1.83 or later; until then the command list is the only line, and it is not a boundary`);
241
+ return { settings: { permissions, sandbox }, notes, sandboxed: false, readOnly };
242
+ }
243
+ notes.push(`sandboxed: commands write only the worktree, its repo's git data and the temp dirs; the secret paths and Pantheon's files are unreadable; network allowed`);
244
+ return { settings: { permissions, sandbox }, notes, sandboxed: true, readOnly };
245
+ }
246
+ export function runTmpDir(runHome) {
247
+ const saltFile = join(runHome, ".tmp-salt");
248
+ let salt = "";
249
+ try {
250
+ salt = readFileSync(saltFile, "utf8").trim();
251
+ }
252
+ catch { }
253
+ if (!/^[0-9a-f]{32}$/.test(salt)) {
254
+ salt = randomBytes(16).toString("hex");
255
+ try {
256
+ mkdirSync(runHome, { recursive: true, mode: 0o700 });
257
+ writeFileSync(saltFile, salt, { mode: 0o600 });
258
+ }
259
+ catch { }
260
+ }
261
+ return join(tmpdir(), `pantheon-run-${createHash("sha256").update(`${resolve(runHome)}\0${salt}`).digest("hex").slice(0, 16)}`);
262
+ }
263
+ export function ensureRunTmp(dir) {
264
+ try {
265
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
266
+ const st = lstatSync(dir);
267
+ if (!st.isDirectory() || st.isSymbolicLink())
268
+ return false;
269
+ if (process.platform === "win32")
270
+ return true;
271
+ return (typeof process.getuid !== "function" || st.uid === process.getuid()) && (st.mode & 0o077) === 0;
272
+ }
273
+ catch {
274
+ return false;
275
+ }
276
+ }
277
+ export function sandboxCacheEnv(runTmp) {
278
+ return {
279
+ TMPDIR: runTmp,
280
+ npm_config_cache: join(runTmp, "npm-cache"),
281
+ YARN_CACHE_FOLDER: join(runTmp, "yarn-cache"),
282
+ PIP_CACHE_DIR: join(runTmp, "pip-cache"),
283
+ GOCACHE: join(runTmp, "go-build"),
284
+ XDG_CACHE_HOME: join(runTmp, "cache"),
285
+ };
286
+ }
287
+ export function bashRuleMatches(rule, command) {
288
+ const m = /^Bash\((.*)\)$/s.exec(rule);
289
+ if (!m)
290
+ return false;
291
+ const pat = m[1];
292
+ const cmd = command.trim();
293
+ const stars = (pat.match(/\*/g) ?? []).length;
294
+ if (stars === 1 && pat.endsWith(" *") && cmd === pat.slice(0, -2))
295
+ return true;
296
+ const re = new RegExp(`^${pat.split("*").map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\s\\S]*")}$`);
297
+ return re.test(cmd);
298
+ }
299
+ //# sourceMappingURL=claude-confine.js.map