@join-pantheon/cli 0.30.0 → 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 CHANGED
@@ -76,13 +76,13 @@ That's it — one command each sets up the **whole machine**: a relay and space
76
76
  (them), pantheon wired into whichever agents each of you already has (**user scope** — every project,
77
77
  every Conductor window, nothing to commit), the wake-up hook so an agent finishes what a peer is
78
78
  waiting for, the **worker** so the machine answers the team even while its human is away (on by
79
- default when run inside a git repo; `--no-worker` opts out — and a peer can use it only once you
80
- approve them on this machine with `pantheon wake allow <name>`), native decision alerts, and the menu bar. Each command
81
- ends with a "You're set" summary, then a short guided setup on a terminal: three questions — your
82
- name, your role, and (inside a repo) whether a run a teammate wakes may execute commands — and one
83
- screen of defaults to keep or change: who may wake your machine while you are away, which alerts you
84
- want, which decisions are yours alone (so agents stop and ask you), the worker's playbook and its base
85
- branch. `pantheon setup` re-runs it any time. An agent setting you up runs `pantheon setup --questions`,
79
+ default when run inside a git repo; `--no-worker` opts out), native decision alerts, and the menu bar. Each command
80
+ ends with a "You're set" summary, then a short guided setup on a terminal: two questions — your
81
+ name and your role — and one screen of defaults to keep or change: your teammates can hand work to
82
+ your machine while you are away, that work runs your tests and commits to its own branch so it
83
+ arrives ready to review, whichever agent you use (Claude Code, Codex, Cursor or Copilot) answers
84
+ them, which alerts you want, which decisions are yours alone (so agents stop and ask you), the
85
+ worker's playbook and its base branch. `pantheon setup` re-runs it any time. An agent setting you up runs `pantheon setup --questions`,
86
86
  asks you in chat, and passes your answers back as flags — to `pantheon setup --yes`, or straight to
87
87
  `pantheon start` / `pantheon join`, which apply them the moment the team exists — so nobody has to
88
88
  open a terminal. Restart your agent once and ask it *"check pantheon_status"*.
@@ -171,8 +171,32 @@ pantheon profile set --space mobile --human-required pricing # authority that
171
171
 
172
172
  The relay checks too: a session whose checkout is not one of the team's repos is refused, and a
173
173
  `DELIVERED` must name a commit in one of them, so work from the wrong repo cannot land in a team.
174
- The worker serves every bound repo from one process, one run at a time, on `pantheon/<team>/<goal>`
175
- branches. Details and the scenario list: `docs/MULTI-SPACE.md`.
174
+ Details and the scenario list: `docs/MULTI-SPACE.md`.
175
+
176
+ **Several repos (a frontend, a backend, a mobile app) — set up once.**
177
+ - `pantheon use <team> --here` in the folder that holds them binds every repo inside it, and
178
+ registers each with the team, in one go.
179
+ - `pantheon repo find` (and `join`, on its own) looks for the team's repos on this machine — in the
180
+ usual code folders, in a folder you name, and, if you say yes, in the folders your editors and
181
+ agents have opened. It reads only folder names and git remote addresses; nothing leaves the machine.
182
+ - A clone of one of the team's repos, wherever it lives, is that team's the first time an agent is
183
+ opened in it — no binding needed.
184
+ - The relay knows each repo by a fingerprint and its everyday name (`web`), never its address.
185
+
186
+ **One worker, every repo.** The worker serves every repo and folder bound to its team, one run at a
187
+ time, and runs each request where it belongs: in the repo the request names (`the api repo`,
188
+ `` `mobile` ``, `acme/web`), else where the goal's work was delivered, else the repo the asker sent
189
+ it from, else the most recently active one — with the team's other repos here attached read-only
190
+ (clean checkouts, never your working folder) so it can look across them. A request about a team repo
191
+ this machine does not have runs nothing: the asker is told it waits, and it is never answered from
192
+ another repo's code. `pantheon spaces` and `doctor` say what the worker serves and what is not here.
193
+
194
+ **No git at all (a PM, ops, a founder's docs).** `start` or `join` from a plain folder binds it
195
+ (never your home folder). The worker serves it too: a teammate's question is answered from that
196
+ folder, which the run can read but never change; anything it writes goes to a drafts folder of its
197
+ own, and you are told. Answering from a folder unattended needs Claude Code on that machine (the one
198
+ agent that can be opened a folder to read and held off editing it); with another agent, those requests
199
+ wait for your live session, and the teammate is told.
176
200
 
177
201
  ### If something is wrong
178
202
 
@@ -347,20 +371,23 @@ domain is a conflict, not a committee — routing blocks until one of them drops
347
371
  it or delegates. Decide domain ownership out loud before publishing; shared
348
372
  decisions belong to whoever answers for them, with delegation covering absence.
349
373
 
350
- When a teammate you approved to wake this machine asks something and no session of yours picks it up, the
374
+ When a teammate asks this machine something and no session of yours picks it up, the
351
375
  worker spawns a headless agent run to handle it. Verified end to end: an `ASK` from an idle
352
376
  peer's machine was answered in **31 seconds with no human involved**, and:
353
377
 
354
378
  - the run happens in a **dedicated git worktree on a `pantheon/<goal>` branch** — your checkout and
355
379
  your uncommitted work are never touched, and nothing is merged
356
- - it runs with **only the pantheon message tools** approved; commands stay gated unless a person on
357
- this machine grants them (`pantheon policy grant run-commands`, or answering the worker's own request
358
- from `pantheon approvals` or the menu bar — never from the ledger) or you pass `--allow-bash`
359
- - two conditions, both enforced: the team must let the peer wake workers at all (the relay's per-member
360
- `may_wake`, set by `pantheon admit <who> --may-wake` — the relay refuses a claim without it), and,
361
- unless you chose wake "anyone", a person on THIS machine must have approved them:
362
- `pantheon wake allow <who>` (`wake deny`, `wake list`). That list lives only on this machine and
363
- nothing about it is sent to the team. Nobody is approved until you approve them.
380
+ - in a repo it runs your tests, commits and pushes to its own branch (setup's default; `pantheon setup
381
+ --commands no` keeps runs to edits), so work arrives ready to review; a person on this machine can
382
+ also grant commands later (`pantheon policy grant run-commands`, or answering the worker's own
383
+ request from `pantheon approvals` or the menu bar)
384
+ - teammates the team lets wake workers (the relay's per-member `may_wake`, which the invites you send
385
+ include) can hand this machine work — wake "anyone", the default. To approve each teammate on this
386
+ machine first, choose "only people I approve" (`pantheon setup --wake approved`); then
387
+ `pantheon wake allow <who>` (`wake deny`, `wake list`) keeps that list, on this machine only, and the
388
+ first time an unapproved teammate's request needs this machine the menu bar shows "Let <them> start
389
+ work here? Always / Just this once / No" (or `pantheon approvals`). The same buttons answer the
390
+ worker's request for commands; granting it runs the request that needed them.
364
391
  - upgrading keeps what you had said: teammates you admitted with `--may-wake` are carried over; so is
365
392
  everyone the team lets wake if you had chosen "anyone" (or never chose, on a machine set up before
366
393
  0.30, when that was the default); a joiner who was never asked keeps the team's founder. The worker
@@ -420,12 +447,21 @@ With `auto` (the default) the order is: the agent a persona pins, then the one y
420
447
  then PATH order — and a candidate that is not signed in is skipped and named in the run log, so a
421
448
  run never fails on an expired session when another agent could have answered. If nothing is
422
449
  signed in, the first on PATH runs and the alert says exactly that, with the sign-in command.
423
- `pantheon doctor` shows the same choice (`worker agent`). Each backend is driven the way it
424
- actually works, with the same "no shell unless you said so" rule spelled in its own dialect:
425
-
426
- | backend | bash gated (default) | with the command grant (`run-commands` or `--allow-bash`) |
450
+ `pantheon doctor` shows the same choice (`worker agent`).
451
+
452
+ **Whichever agent you use answers your teammates while you are away** — Claude Code, Codex, Cursor
453
+ or Copilot. Claude Code runs inside its own OS sandbox (macOS and Linux, 2.1.83+), so `auto`
454
+ prefers it when it is installed; Codex, Cursor and Copilot answer too, so a team on any of them is
455
+ never kept waiting. To have Codex, Cursor and Copilot leave requests for you instead,
456
+ `pantheon setup --unsandboxed no` (or `pantheon policy revoke unsandboxed-runs`; a woken run can never
457
+ change it). Kiro answers in your live sessions; for unattended runs the worker uses another installed
458
+ agent. Setup turns commands on for a worker that serves a repo (`pantheon setup --commands no` keeps
459
+ runs to edits). Each backend is driven the way it actually works, with the same commands rule
460
+ spelled in its own dialect:
461
+
462
+ | backend | bash gated (`--commands no`) | with commands (setup's default in a repo, `run-commands` or `--allow-bash`) |
427
463
  |---|---|---|
428
- | claude-code | `acceptEdits`, no shell, reads held to the worktree, `~/.ssh` `~/.aws` `~/.config/gh` `~/.pantheon` and similar denied; the repo's own `.claude` settings never load (`--setting-sources user`) | the same file rules, plus Claude Code's OS sandbox (macOS/Linux): commands may write only the worktree, its git data and temp dirs, may not read the secret paths, and a run whose sandbox cannot start does not start. Commands: git subcommands (with its run-a-program options denied), the repo's scripts via npm/pnpm/yarn/bun, make, cargo, go, pytest, mvn/gradle, dotnet, plus the committed `.pantheon/commands` on the base branch (one per line; shells, wrappers, package runners like `npx`, and inline code are refused — use `./node_modules/.bin/vitest`). The run names any command it needed that was refused. On Windows, or a Claude Code too old for these settings, the list is all there is — it is not a boundary |
464
+ | claude-code | `acceptEdits`, no shell, reads held to the worktree, `~/.ssh` `~/.aws` `~/.config/gh` `~/.pantheon` and similar denied; the repo's own `.claude` settings never load (`--setting-sources user`) | the same file rules, plus Claude Code's OS sandbox (macOS/Linux): commands may write only the worktree, its git data and temp dirs, may not read the secret paths, and a run whose sandbox cannot start does not start. Commands: git subcommands (with its run-a-program options denied), the repo's scripts via npm/pnpm/yarn/bun, make, cargo, go, pytest, mvn/gradle, dotnet, plus the committed `.pantheon/commands` on the base branch (one per line; shells, wrappers, package runners like `npx`, and inline code are refused — use `./node_modules/.bin/vitest`). The run names any command it needed that was refused. On Windows, or a Claude Code older than 2.1.83, commands run from that same list without the OS sandbox (`pantheon setup --unsandboxed no` keeps those runs to edits) |
429
465
  | codex | `--sandbox read-only` (codex has no edits-without-shell mode; reads are not restricted) | `--approve-for-me` (Codex's workspace-write sandbox, its auto-reviewer approves; any command) |
430
466
  | cursor | proposes edits, never applies (`-p` without `--force`) | `--force` (any command, no allowlist, no sandbox) |
431
467
  | copilot | `--allow-tool=pantheon-worker --allow-tool=write` (edits without shell, Copilot's `write` kind) | `--allow-all-tools` (any command, no allowlist, no sandbox) |
@@ -434,14 +470,17 @@ actually works, with the same "no shell unless you said so" rule spelled in its
434
470
  **What the command grant really means.** A run allowed to run commands can edit the repo's own
435
471
  scripts and then run them through a listed command (`npm test` runs whatever `package.json` says), so
436
472
  the command list alone never contains it. Where Claude Code's sandbox runs (macOS, Linux) that
437
- sandbox is the boundary. Everywhere else, a command run can do what you can. After every run
473
+ sandbox is the boundary. Everywhere else, a command run can read what you can — and, outside
474
+ Codex's own write sandbox, change it. After every run
438
475
  Pantheon checks for the plain ways it could have widened what the NEXT run may do — a grant or wake
439
476
  approval it added, a loosened wake setting, a changed relay address, a queued menu-bar action —
440
477
  takes them back and tells you. That is a check after the fact, not a boundary: a run that can do
441
478
  what you can could change something it does not look at. Pantheon also refuses to send an act that
442
479
  holds this machine's key, a team token, or text in the shape of a common secret (a private key, a
443
480
  cloud or API token) — a guard against a pasted secret, not against one a run takes care to disguise.
444
- What keeps your secrets from a run is that it cannot read them: the denies and the sandbox above.
481
+ With Claude Code, what keeps your secrets from a run is that it cannot read them: the denies and the
482
+ sandbox above. With other agents, grant commands only where you would be comfortable with the run
483
+ having your access.
445
484
 
446
485
  Claude Code, Cursor and Codex are verified with **real live woken runs** — an idle machine answering a
447
486
  peer's ASK end to end. Codex quirks the tooling handles for
@@ -571,7 +610,8 @@ The rules that make this safe to commit into a shared repo:
571
610
 
572
611
  ## Guardrails
573
612
 
574
- All enforced in the relay, not in a prompt — a prompt can be argued out of its instructions.
613
+ All enforced in code — by the relay, or by each machine where the relay is not to be trusted —
614
+ never in a prompt: a prompt can be argued out of its instructions.
575
615
 
576
616
  | Guard | Default | Why |
577
617
  |---|---|---|
@@ -587,9 +627,10 @@ All enforced in the relay, not in a prompt — a prompt can be argued out of its
587
627
  | Membership | explicit allowlist, ed25519 | the token is not a credential; a name needs its key |
588
628
  | Invites | single-use, expiring | a leaked link is inert once redeemed or timed out |
589
629
  | Claims | one session per obligation | parallel Conductor workspaces cannot duplicate work |
590
- | Wake rights | `pantheon wake allow`, per machine; `--may-wake`, per member for the team | only peers the team lets wake AND you approved here may start a run on your machine |
630
+ | Wake rights | `pantheon wake allow`, per machine; `--may-wake`, per member for the team | under "anyone" (the default): every peer the team lets wake; under "approved": only those you also approved here. Checked by the worker before a run starts, and by the relay at its claim |
591
631
  | Run budget | 12/hour, 1 concurrent | a peer cannot drain your tokens |
592
632
  | Personas | tighten-only | a persona file — even one a teammate committed — can lower caps and turn bash off, never the reverse |
633
+ | Signed acts | every act and goal, ed25519, since 0.31 | checked on each receiving machine against the key it pinned for the sender: the relay cannot forge, alter, re-file or replay a teammate's request without that machine noticing, and a worker runs only requests that check out. A teammate's changed key is held back until you confirm it (`pantheon trust`) |
593
634
  | Worktree hygiene | no secrets on disk | credentials reach a woken run through its environment only; run scratch files are git-excluded |
594
635
  | Verifiable delivery | branch + commit required | "I wrote the file" is not checkable from another machine |
595
636
  | Durable state | goals + obligations on disk | a relay restart used to discard every open goal |
@@ -619,8 +660,9 @@ pantheon mcp the stdio MCP server — your agent runs this
619
660
  ```
620
661
 
621
662
  `pantheon help --all` lists everything. `pantheon watch` is how a human stays oriented without
622
- being in the loop; every act is also appended to the relay's ledger, so the full history is
623
- replayable.
663
+ being in the loop; every act is also appended to the relay's ledger, so a goal's history is
664
+ replayable while it is open — closed goals are compacted once the space needs room, so export what
665
+ you need to keep with `pantheon audit --json`.
624
666
 
625
667
  ## What this still is not
626
668
 
@@ -631,18 +673,55 @@ replayable.
631
673
  the next real gap: `DELIVERED` is now verifiable, but *conformance to the contract* is not.
632
674
  - **Nothing fetches the delivered commit for you.** The relay requires a ref and the receiving
633
675
  delegate is told to check it, but there is no `pantheon fetch` helper yet.
634
- - **The space token is still a shared secret** for *reaching* the relay, even though membership is
635
- now key-based. Treat the relay endpoint as semi-public and rely on the allowlist.
636
- - **A woken run is trusted with your repo.** `--allow-bash` in particular means a peer's request
637
- can execute commands on your machine. Approve wakers (`pantheon wake allow`) deliberately.
676
+ - **The space token still reads a team's history until someone is removed.** Joining takes an
677
+ admitted key, but in a team that has never removed anyone the token alone can read its history
678
+ over HTTP (older clients need that); after any removal, reads need an admitted key. Share invite
679
+ links, not the token.
680
+ - **Commands on a woken run are on by default in a repo.** A run works in its own worktree and
681
+ branch, inside your agent's sandbox where it has one (see above). `pantheon setup --commands no`
682
+ keeps runs to edits, and `pantheon setup --wake approved` lets you approve each teammate first.
683
+
684
+ ## Is Pantheon safe for my team?
685
+
686
+ Short answer: your teammates' agents can hand your machine work while you are away — it runs in its
687
+ own copy of the repo and is delivered on a branch for your review — and whoever runs the relay
688
+ cannot fake or alter a teammate's request. What that means in practice:
689
+
690
+ - **Pantheon never uploads your code, credentials or repo.** The relay passes messages between
691
+ agents and has no filesystem or shell access to anyone's computer; what leaves your machine is
692
+ what an agent writes into a message.
693
+ - **The relay can read the messages** (they are not end-to-end encrypted), so treat a team space
694
+ like a shared team channel. It **cannot write one in a teammate's name or change one**: with 0.31
695
+ on the relay and on your machine, every message is signed by its sender and checked against the
696
+ key your machine keeps for them, and one that does not check out is marked NOT VERIFIED and never
697
+ starts work. (The first time your machine meets a teammate it takes their key from the member
698
+ list; a changed key later is held back until you confirm it.) It can still delay or withhold messages.
699
+ - **Your teammates can hand your machine work while you are away.** `pantheon start` in a repo turns
700
+ the worker on (`--no-worker` leaves it off). Each run works in its own copy of the repo, runs your
701
+ tests, and delivers on its own branch for your review; runs are capped at 12 an hour, and
702
+ `pantheon panic` pauses all of it at once. `pantheon wake list` shows who can hand you work, and
703
+ `pantheon setup --wake approved` lets you approve each teammate first.
704
+ - **You can see and change all of it:** `pantheon policy` (what the worker may do, including whether
705
+ Pantheon updates itself), `pantheon trust` (the teammates' keys this machine checks messages
706
+ against), `pantheon audit`, `pantheon doctor`.
707
+
708
+ For a team of five, the defaults are the setup: everyone's agent answers the team while they are
709
+ away, work arrives tested on its own branch, and decisions you reserved still come to you. Prefer to
710
+ review every change by hand on one machine? `pantheon setup --commands no` there. When Pantheon says
711
+ a teammate's key changed, check it with them before `pantheon trust`. Claude Code runs inside its
712
+ OS sandbox; Codex answers inside its own workspace sandbox, and Cursor and Copilot run commands
713
+ without asking, as you (`pantheon setup --unsandboxed no` has them leave requests for you). The
714
+ full model, and how to report a problem: [joinpantheon.network/security](https://www.joinpantheon.network/security).
638
715
 
639
716
  ## Privacy, data, and support
640
717
 
641
718
  The hosted relay at `relay.joinpantheon.network` holds only what coordination needs: your
642
719
  verified email, username, and device public keys; the IPs and timestamps of your sessions and
643
720
  an audit trail of identity and membership events; and, per space, the acts your delegates
644
- exchanged plus open goals and obligations. Your code, credentials, and repo never leave your
645
- machine — the relay has no filesystem or shell access by design.
721
+ exchanged plus open goals and obligations. Pantheon never uploads your code, credentials or repo,
722
+ and the relay has no filesystem or shell access by design: what leaves your machine is what an
723
+ agent writes into a message (and Pantheon refuses to send its own keys, team tokens, or text in the
724
+ shape of a common secret).
646
725
  Whoever operates the relay can read those acts — the operator dashboard shows each space's
647
726
  message history — so treat a space like a shared team channel, not a private notebook.
648
727
 
package/dist/agents.js CHANGED
@@ -6,7 +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
+ import { claudeRunSettings, NO_CLAUDE_FEATURES, runTmpDir, sandboxCacheEnv, versionAtLeast } from "./claude-confine.js";
10
10
  export function launchArgs(agent, prompt, name) {
11
11
  if (agent === "claude-code")
12
12
  return ["--name", name, prompt];
@@ -125,7 +125,16 @@ export const SIGN_IN_CHECK = {
125
125
  };
126
126
  export const NEEDS_SIGN_IN_BEFORE_SPAWN = new Set(["kiro"]);
127
127
  export const NOT_FOR_UNATTENDED_RUNS = new Set(["kiro"]);
128
- 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");
129
138
  export function kiroSignedIn(binary, run = runStatus, env = process.env, home = homedir(), os = platform()) {
130
139
  if (env.KIRO_API_KEY)
131
140
  return { ok: true, detail: "signed in (KIRO_API_KEY in the environment)" };
@@ -221,6 +230,14 @@ export function chooseAgent(preferred, opts = {}) {
221
230
  skipped.push({ backend: c.backend, why: KIRO_UNATTENDED_REFUSAL });
222
231
  if (usable.length)
223
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
+ }
224
241
  for (const c of order) {
225
242
  const s = state(c.backend, c.binary);
226
243
  if (!s || s.ok || s.unknown)
@@ -230,9 +247,15 @@ export function chooseAgent(preferred, opts = {}) {
230
247
  const first = order.find((o) => !NEEDS_SIGN_IN_BEFORE_SPAWN.has(o.backend.id)) ?? order[0];
231
248
  return { backend: first.backend, binary: first.binary, signIn: state(first.backend, first.binary), because: `${first.because} (none signed in)`, skipped };
232
249
  }
233
- export function unattendedRefusal(c) {
250
+ export function unattendedRefusal(c, o = {}) {
234
251
  if (NOT_FOR_UNATTENDED_RUNS.has(c.backend.id))
235
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
+ }
236
259
  if (!NEEDS_SIGN_IN_BEFORE_SPAWN.has(c.backend.id) || c.signIn?.ok)
237
260
  return null;
238
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}`;
@@ -244,7 +267,8 @@ export function describeChoice(c) {
244
267
  export function skippedSummary(c) {
245
268
  if (!c.skipped.length)
246
269
  return "";
247
- 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")))];
248
272
  return `${c.skipped.length} skipped (${why.join("; ")})`;
249
273
  }
250
274
  export function resolveAgent(preferred) {
@@ -370,19 +394,15 @@ export function decodeCommandsFile(b) {
370
394
  return text.includes("\0") ? null : text;
371
395
  }
372
396
  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).";
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.";
386
406
  export function headlessSpawn(opts) {
387
407
  const l = launcher();
388
408
  const identity = {
@@ -428,6 +448,7 @@ export function headlessSpawn(opts) {
428
448
  const runTmp = runTmpDir(opts.runHome);
429
449
  const c = claudeRunSettings({
430
450
  allowBash: opts.allowBash, worktree: opts.worktree, runHome: opts.runHome, pantheonHome: opts.claude.pantheonHome,
451
+ ...(opts.readOnlyDirs?.length ? { readOnlyDirs: opts.readOnlyDirs } : {}),
431
452
  runTmp, bashRules, version: f.version, ...(opts.claude.home ? { home: opts.claude.home } : {}),
432
453
  ...(opts.os ? { os: opts.os } : {}), ...(opts.claude.userSettings ? { userSettings: opts.claude.userSettings } : {}),
433
454
  });
@@ -435,6 +456,9 @@ export function headlessSpawn(opts) {
435
456
  extra.push("--permission-prompts", "none");
436
457
  if (f.settingSources)
437
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);
438
462
  if (f.settings) {
439
463
  const file = join(opts.runHome, "claude-settings.json");
440
464
  writeFiles.push({ path: file, contents: JSON.stringify(c.settings, null, 2) });
@@ -12,6 +12,7 @@ export function parseClaudeHelp(help, version = null) {
12
12
  settings: /(^|\s)--settings\s/m.test(help),
13
13
  settingSources: /(^|\s)--setting-sources\b/m.test(help),
14
14
  disallowedTools: /(^|\s)--disallowed-?tools\b/im.test(help),
15
+ addDir: /(^|\s)--add-dir\b/m.test(help),
15
16
  };
16
17
  }
17
18
  export function versionAtLeast(a, b) {
@@ -125,14 +126,15 @@ function userExcludedCommands(file) {
125
126
  return [];
126
127
  }
127
128
  }
129
+ const SECRET_FILE_GLOBS = [".env", ".env.*", "*.pem", "*.key", "*.p12", "*.pfx", "id_rsa*", "id_ed25519*", ".npmrc", ".netrc", "credentials*", "*.keystore", "service-account*.json"];
128
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"];
129
- function aroundWorktree(top, worktree, named) {
131
+ function aroundWorktree(top, keeps, named) {
132
+ const keep = (typeof keeps === "string" ? [keeps] : [...keeps]).map((k) => resolve(k));
130
133
  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;
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)));
136
138
  let names = [];
137
139
  try {
138
140
  names = readdirSync(level);
@@ -144,11 +146,13 @@ function aroundWorktree(top, worktree, named) {
144
146
  catch {
145
147
  return !n.includes(".");
146
148
  } };
147
- for (const n of new Set([...names, ...(level === top ? named : [])]))
148
- if (n !== next)
149
+ for (const n of new Set([...names, ...(isTop ? named : [])]))
150
+ if (!nexts.has(n))
149
151
  out.push({ abs: join(level, n), dir: isDir(n) });
150
- level = join(level, next);
151
- }
152
+ for (const n of nexts)
153
+ walk(join(level, n), false);
154
+ };
155
+ walk(top, true);
152
156
  return out;
153
157
  }
154
158
  export function claudeRunSettings(i) {
@@ -163,16 +167,25 @@ export function claudeRunSettings(i) {
163
167
  ];
164
168
  const seen = new Set();
165
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];
166
178
  for (const t of targets) {
167
179
  if (seen.has(t.abs))
168
180
  continue;
169
181
  seen.add(t.abs);
170
- if (inside(worktree, t.abs)) {
182
+ const kept = keeps.filter((k) => inside(k, t.abs));
183
+ if (kept.length) {
171
184
  if (worktree === t.abs) {
172
185
  notes.push(`${t.abs} is this run's worktree, so it is not denied to the run`);
173
186
  continue;
174
187
  }
175
- for (const d of aroundWorktree(t.abs, worktree, t.abs === resolve(i.pantheonHome) ? PANTHEON_HOME_ENTRIES : []))
188
+ for (const d of aroundWorktree(t.abs, kept, t.abs === resolve(i.pantheonHome) ? PANTHEON_HOME_ENTRIES : []))
176
189
  if (!seen.has(d.abs)) {
177
190
  seen.add(d.abs);
178
191
  denied.push(d);
@@ -181,10 +194,13 @@ export function claudeRunSettings(i) {
181
194
  }
182
195
  denied.push(t);
183
196
  }
184
- const fileRules = denied.flatMap((t) => {
185
- const p = `${ruleSpelling(t.abs, home, os)}${t.dir ? "/**" : ""}`;
186
- return [`Read(${p})`, `Edit(${p})`];
187
- });
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
+ ];
188
204
  if (!i.allowBash) {
189
205
  const permissions = { deny: ["Bash", "PowerShell", ...fileRules] };
190
206
  if (versionAtLeast(i.version, "2.1.257"))
@@ -192,7 +208,7 @@ export function claudeRunSettings(i) {
192
208
  else
193
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`);
194
210
  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 };
211
+ return { settings: { permissions }, notes, sandboxed: false, readOnly };
196
212
  }
197
213
  const excluded = userExcludedCommands(i.userSettings);
198
214
  if (excluded.length)
@@ -203,7 +219,7 @@ export function claudeRunSettings(i) {
203
219
  };
204
220
  if (os === "win32") {
205
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");
206
- return { settings: { permissions }, notes, sandboxed: false };
222
+ return { settings: { permissions }, notes, sandboxed: false, readOnly };
207
223
  }
208
224
  const failsClosed = versionAtLeast(i.version, "2.1.83");
209
225
  const hooks = join(resolve(i.runHome), "git-hooks");
@@ -214,7 +230,7 @@ export function claudeRunSettings(i) {
214
230
  allowUnsandboxedCommands: false,
215
231
  filesystem: {
216
232
  allowWrite: [...new Set([resolve(i.runTmp), resolve(tmpdir())])],
217
- denyWrite: [],
233
+ denyWrite: readOnly.map((d) => sandboxSpelling(d, home)),
218
234
  denyRead: denied.map((t) => sandboxSpelling(t.abs, home)),
219
235
  allowRead: [hooks],
220
236
  },
@@ -222,10 +238,10 @@ export function claudeRunSettings(i) {
222
238
  };
223
239
  if (!failsClosed) {
224
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`);
225
- return { settings: { permissions, sandbox }, notes, sandboxed: false };
241
+ return { settings: { permissions, sandbox }, notes, sandboxed: false, readOnly };
226
242
  }
227
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`);
228
- return { settings: { permissions, sandbox }, notes, sandboxed: true };
244
+ return { settings: { permissions, sandbox }, notes, sandboxed: true, readOnly };
229
245
  }
230
246
  export function runTmpDir(runHome) {
231
247
  const saltFile = join(runHome, ".tmp-salt");