@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.
- package/README.md +142 -40
- package/bin/pantheon.js +12 -0
- package/dist/accounts.js +151 -26
- package/dist/agents.js +224 -17
- package/dist/claude-confine.js +299 -0
- package/dist/cli.js +1116 -137
- package/dist/client.js +294 -10
- package/dist/command-state.js +102 -0
- package/dist/config.js +287 -14
- package/dist/continued.js +42 -10
- package/dist/doctor.js +191 -23
- package/dist/file-lock.js +197 -0
- package/dist/grants.js +1 -1
- package/dist/history-view.js +2 -1
- package/dist/home-guard.js +565 -0
- package/dist/hooks-install.js +2 -1
- package/dist/identity.js +101 -1
- 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 +29 -7
- package/dist/mcp-windows.js +47 -25
- package/dist/mcp.js +99 -36
- package/dist/network.js +39 -8
- package/dist/nonces.js +130 -0
- package/dist/notify.js +26 -9
- package/dist/pending.js +121 -22
- package/dist/persona.js +21 -0
- package/dist/pins.js +161 -0
- package/dist/profile.js +22 -1
- package/dist/protocol.js +40 -1
- package/dist/quickstart.js +173 -24
- package/dist/reads.js +37 -0
- package/dist/refusal.js +14 -1
- package/dist/relay.js +1341 -207
- package/dist/repo-find.js +127 -0
- package/dist/repo-route.js +70 -0
- package/dist/roster-file.js +190 -25
- package/dist/run-failure.js +99 -3
- package/dist/secret-guard.js +141 -0
- package/dist/service.js +91 -35
- package/dist/setup-guide.js +91 -31
- package/dist/spend.js +6 -2
- package/dist/store.js +94 -10
- package/dist/text-file.js +74 -0
- package/dist/tray.js +24 -3
- package/dist/untrusted.js +24 -5
- package/dist/update-check.js +2 -0
- package/dist/upgrade.js +9 -5
- package/dist/verify-act.js +102 -0
- package/dist/which.js +69 -12
- package/dist/worker-fleet.js +30 -9
- package/dist/worker-policy.js +231 -7
- package/dist/worker.js +1717 -130
- package/helper/macos/menubar.swift +63 -2
- 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
|
-
|
|
113
|
-
|
|
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
|
|
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`
|
|
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
|
|
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
|
-
|
|
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",
|
|
287
|
-
...
|
|
493
|
+
"--permission-mode", "acceptEdits",
|
|
494
|
+
...extra,
|
|
495
|
+
"--allowedTools", ...PANTHEON_TOOLS, ...rulesOnArgv,
|
|
288
496
|
],
|
|
289
|
-
env
|
|
290
|
-
writeFiles
|
|
291
|
-
|
|
292
|
-
|
|
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
|