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