@join-pantheon/cli 0.29.1 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +142 -40
  2. package/bin/pantheon.js +12 -0
  3. package/dist/accounts.js +151 -26
  4. package/dist/agents.js +224 -17
  5. package/dist/claude-confine.js +299 -0
  6. package/dist/cli.js +1116 -137
  7. package/dist/client.js +294 -10
  8. package/dist/command-state.js +102 -0
  9. package/dist/config.js +287 -14
  10. package/dist/continued.js +42 -10
  11. package/dist/doctor.js +191 -23
  12. package/dist/file-lock.js +197 -0
  13. package/dist/grants.js +1 -1
  14. package/dist/history-view.js +2 -1
  15. package/dist/home-guard.js +565 -0
  16. package/dist/hooks-install.js +2 -1
  17. package/dist/identity.js +101 -1
  18. package/dist/install.js +76 -26
  19. package/dist/kiro-files.js +2 -2
  20. package/dist/ledger.js +111 -28
  21. package/dist/machine.js +494 -32
  22. package/dist/mcp-supervisor.js +29 -7
  23. package/dist/mcp-windows.js +47 -25
  24. package/dist/mcp.js +99 -36
  25. package/dist/network.js +39 -8
  26. package/dist/nonces.js +130 -0
  27. package/dist/notify.js +26 -9
  28. package/dist/pending.js +121 -22
  29. package/dist/persona.js +21 -0
  30. package/dist/pins.js +161 -0
  31. package/dist/profile.js +22 -1
  32. package/dist/protocol.js +40 -1
  33. package/dist/quickstart.js +173 -24
  34. package/dist/reads.js +37 -0
  35. package/dist/refusal.js +14 -1
  36. package/dist/relay.js +1341 -207
  37. package/dist/repo-find.js +127 -0
  38. package/dist/repo-route.js +70 -0
  39. package/dist/roster-file.js +190 -25
  40. package/dist/run-failure.js +99 -3
  41. package/dist/secret-guard.js +141 -0
  42. package/dist/service.js +91 -35
  43. package/dist/setup-guide.js +91 -31
  44. package/dist/spend.js +6 -2
  45. package/dist/store.js +94 -10
  46. package/dist/text-file.js +74 -0
  47. package/dist/tray.js +24 -3
  48. package/dist/untrusted.js +24 -5
  49. package/dist/update-check.js +2 -0
  50. package/dist/upgrade.js +9 -5
  51. package/dist/verify-act.js +102 -0
  52. package/dist/which.js +69 -12
  53. package/dist/worker-fleet.js +30 -9
  54. package/dist/worker-policy.js +231 -7
  55. package/dist/worker.js +1717 -130
  56. package/helper/macos/menubar.swift +63 -2
  57. package/package.json +2 -2
package/README.md CHANGED
@@ -42,8 +42,8 @@ That gives you a verified account and a unique username no one else can claim,
42
42
  across every space on the hosted relay. A second machine joins the same account
43
43
  with `pantheon login --email you@work.com`. See your devices and sign any of them
44
44
  out with `pantheon sessions`; every identity and membership event is in `pantheon
45
- audit`. (Self-hosting or on a LAN/tailnet? Accounts are optional — skip straight
46
- to `pantheon start`.)
45
+ audit`. (Self-hosting or on a LAN/tailnet? Accounts are optional there — skip
46
+ signing up and point `start` at your own relay: `pantheon start --relay ws://<host>:8787`.)
47
47
 
48
48
  **Then, from your project's repo — or anywhere, if there is no repo yet (see below):**
49
49
 
@@ -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 still needs an explicit
80
- `--may-wake` grant before they can use it), 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"*.
@@ -108,10 +108,10 @@ so and asks for a fresh invite; a relay that cannot be reached asks you for the
108
108
  Already a member — new laptop, or just pulled a newer build? The same full setup is one command:
109
109
  `pantheon install --user` (from your project's repo if you have one).
110
110
 
111
- **Consent happens once, here.** Setup pre-approves exactly the six pantheon *message* tools in
111
+ **Consent happens once, here.** Setup pre-approves exactly the nine pantheon tools — messages, status and reads — in
112
112
  each agent's own permission system (Claude Code `permissions.allow`, Cursor `Mcp()` tokens,
113
113
  Codex per-server approval), so day-to-day coordination never interrupts you with "allow
114
- pantheon_wait?" again. Nothing that executes is pre-approved: `--may-wake` and `--allow-bash`
114
+ pantheon_wait?" again. Nothing that executes is pre-approved: wake rights and `--allow-bash`
115
115
  keep their prompts on purpose — they are the two decisions worth reading. `pantheon leave`
116
116
  removes the grants. What remains is one one-time click per editor that only it can ask for
117
117
  (folder trust in Claude Code, MCP enable in Cursor, `/hooks` approval in Codex).
@@ -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
 
@@ -198,8 +222,8 @@ product itself; you read it, and you decide.
198
222
 
199
223
  <details><summary>The long way round, if you want to control each piece</summary>
200
224
 
201
- `pantheon relay` · `pantheon init` · `pantheon join` · `pantheon members` · `pantheon admit <who> --may-wake`
202
- · `pantheon install` (project-scope: commits a `.mcp.json`, so `pantheon` must be on PATH). Membership by explicit
225
+ `pantheon relay` · `pantheon init` · `pantheon join` · `pantheon members` · `pantheon admit <who>` ·
226
+ `pantheon wake allow <who>` · `pantheon install` (project-scope: commits a `.mcp.json`, so `pantheon` must be on PATH). Membership by explicit
203
227
  allowlist with fingerprint confirmation, rather than by invite. Everything the two-command path
204
228
  does, one step at a time.
205
229
 
@@ -278,7 +302,7 @@ pantheon service uninstall
278
302
  ```
279
303
 
280
304
  A terminal window is not "while you are asleep", so use `pantheon service install` for anything
281
- real. Logs land in `.pantheon/worker.log`.
305
+ real. Logs land in `~/.pantheon/logs/worker.log` (`pantheon logs worker -f`).
282
306
 
283
307
  ### The menu bar (macOS)
284
308
 
@@ -314,7 +338,7 @@ pantheon directory # shared responsibility and authority
314
338
  pantheon approvals # decisions waiting on you
315
339
  pantheon approvals answer <id> 'Approved because…'
316
340
  pantheon profile delegate --to alex --domains technical \
317
- --until 2026-09-05T18:00:00Z # expires automatically; max 30 days
341
+ --until <ISO time, e.g. 2026-11-01T18:00:00Z> # expires automatically; at most 30 days ahead
318
342
  pantheon profile undelegate --to alex # revoke early
319
343
  ```
320
344
 
@@ -347,16 +371,30 @@ 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 peer you granted `--may-wake` 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 six pantheon message tools** approved; bash stays gated unless you pass
357
- `--allow-bash`
358
- - the relay — not the daemon — enforces who may wake you, so a peer without `--may-wake` is
359
- refused even if your worker tries
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.
391
+ - upgrading keeps what you had said: teammates you admitted with `--may-wake` are carried over; so is
392
+ everyone the team lets wake if you had chosen "anyone" (or never chose, on a machine set up before
393
+ 0.30, when that was the default); a joiner who was never asked keeps the team's founder. The worker
394
+ says who was carried and why. Against a relay older than 0.30 (it cannot say who admitted whom) the
395
+ team's `may_wake` keeps deciding until the relay upgrades, so upgrading the CLI first is safe.
396
+ - the agent's environment carries no identity secret; the run is its own process group, killed whole
397
+ on timeout, `pantheon stop` and `pantheon panic`
360
398
  - a human who is actually working wins — but presence is not attention. The rule, in order:
361
399
  1. a turn in flight in any of your windows (your wake-up hook reports it) → the worker waits
362
400
  2. …unless that report is over **30 minutes** old with nothing after it. Claude Code runs no
@@ -409,17 +447,41 @@ With `auto` (the default) the order is: the agent a persona pins, then the one y
409
447
  then PATH order — and a candidate that is not signed in is skipped and named in the run log, so a
410
448
  run never fails on an expired session when another agent could have answered. If nothing is
411
449
  signed in, the first on PATH runs and the alert says exactly that, with the sign-in command.
412
- `pantheon doctor` shows the same choice (`worker agent`). Each backend is driven the way it
413
- actually works, with the same "no shell unless you said so" rule spelled in its own dialect:
414
-
415
- | backend | bash gated (default) | with `--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`) |
416
463
  |---|---|---|
417
- | claude-code | `acceptEdits` + the six pantheon tools | `bypassPermissions` |
418
- | codex | `--sandbox read-only` (codex has no edits-without-shell mode) | `--sandbox workspace-write` |
419
- | cursor | proposes edits, never applies (`-p` without `--force`) | `--force` |
420
- | copilot | `--allow-tool=pantheon-worker --allow-tool=write` (edits without shell, Copilot's `write` kind) | `--allow-all-tools` |
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) |
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) |
466
+ | cursor | proposes edits, never applies (`-p` without `--force`) | `--force` (any command, no allowlist, no sandbox) |
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) |
421
468
  | kiro | an agent whose tools are pantheon's plus `read`, `write`, `grep`, `glob` — all pre-approved | the same, plus `shell` |
422
469
 
470
+ **What the command grant really means.** A run allowed to run commands can edit the repo's own
471
+ scripts and then run them through a listed command (`npm test` runs whatever `package.json` says), so
472
+ the command list alone never contains it. Where Claude Code's sandbox runs (macOS, Linux) that
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
475
+ Pantheon checks for the plain ways it could have widened what the NEXT run may do — a grant or wake
476
+ approval it added, a loosened wake setting, a changed relay address, a queued menu-bar action —
477
+ takes them back and tells you. That is a check after the fact, not a boundary: a run that can do
478
+ what you can could change something it does not look at. Pantheon also refuses to send an act that
479
+ holds this machine's key, a team token, or text in the shape of a common secret (a private key, a
480
+ cloud or API token) — a guard against a pasted secret, not against one a run takes care to disguise.
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.
484
+
423
485
  Claude Code, Cursor and Codex are verified with **real live woken runs** — an idle machine answering a
424
486
  peer's ASK end to end. Codex quirks the tooling handles for
425
487
  you: no per-run approval flag exists in `codex exec`, MCP arrives via `-c` overrides (a worktree
@@ -548,7 +610,8 @@ The rules that make this safe to commit into a shared repo:
548
610
 
549
611
  ## Guardrails
550
612
 
551
- 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.
552
615
 
553
616
  | Guard | Default | Why |
554
617
  |---|---|---|
@@ -564,9 +627,10 @@ All enforced in the relay, not in a prompt — a prompt can be argued out of its
564
627
  | Membership | explicit allowlist, ed25519 | the token is not a credential; a name needs its key |
565
628
  | Invites | single-use, expiring | a leaked link is inert once redeemed or timed out |
566
629
  | Claims | one session per obligation | parallel Conductor workspaces cannot duplicate work |
567
- | Wake rights | `--may-wake`, per member | only named peers 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 |
568
631
  | Run budget | 12/hour, 1 concurrent | a peer cannot drain your tokens |
569
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`) |
570
634
  | Worktree hygiene | no secrets on disk | credentials reach a woken run through its environment only; run scratch files are git-excluded |
571
635
  | Verifiable delivery | branch + commit required | "I wrote the file" is not checkable from another machine |
572
636
  | Durable state | goals + obligations on disk | a relay restart used to discard every open goal |
@@ -596,8 +660,9 @@ pantheon mcp the stdio MCP server — your agent runs this
596
660
  ```
597
661
 
598
662
  `pantheon help --all` lists everything. `pantheon watch` is how a human stays oriented without
599
- being in the loop; every act is also appended to the relay's ledger, so the full history is
600
- 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`.
601
666
 
602
667
  ## What this still is not
603
668
 
@@ -608,18 +673,55 @@ replayable.
608
673
  the next real gap: `DELIVERED` is now verifiable, but *conformance to the contract* is not.
609
674
  - **Nothing fetches the delivered commit for you.** The relay requires a ref and the receiving
610
675
  delegate is told to check it, but there is no `pantheon fetch` helper yet.
611
- - **The space token is still a shared secret** for *reaching* the relay, even though membership is
612
- now key-based. Treat the relay endpoint as semi-public and rely on the allowlist.
613
- - **A woken run is trusted with your repo.** `--allow-bash` in particular means a peer's request
614
- can execute commands on your machine. Grant `--may-wake` 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).
615
715
 
616
716
  ## Privacy, data, and support
617
717
 
618
718
  The hosted relay at `relay.joinpantheon.network` holds only what coordination needs: your
619
719
  verified email, username, and device public keys; the IPs and timestamps of your sessions and
620
720
  an audit trail of identity and membership events; and, per space, the acts your delegates
621
- exchanged plus open goals and obligations. Your code, credentials, and repo never leave your
622
- 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).
623
725
  Whoever operates the relay can read those acts — the operator dashboard shows each space's
624
726
  message history — so treat a space like a shared team channel, not a private notebook.
625
727
 
package/bin/pantheon.js CHANGED
@@ -11,6 +11,18 @@ import { existsSync } from "node:fs";
11
11
  import { dirname, resolve } from "node:path";
12
12
  import { fileURLToPath, pathToFileURL } from "node:url";
13
13
 
14
+ // Node first, before anything that needs a newer one is loaded. On an older
15
+ // Node the first failure was deep and unreadable: node:sqlite missing (it is
16
+ // behind a flag through 22.12), or — on Node 18 behind a proxy — undici 7
17
+ // dying with "File is not defined" on import. This file must stay free of any
18
+ // syntax newer than Node 16, so the check itself runs on the Node it rejects.
19
+ var nodeVersion = process.versions.node.split(".").map(Number);
20
+ var nodeMajor = nodeVersion[0], nodeMinor = nodeVersion[1];
21
+ if (nodeMajor < 22 || (nodeMajor === 22 && nodeMinor < 13) || (nodeMajor === 23 && nodeMinor < 4)) {
22
+ console.error("pantheon needs Node 22.13 or newer (it uses node:sqlite); this is Node " + process.versions.node + ". Install a current Node LTS from https://nodejs.org and run pantheon again.");
23
+ process.exit(1);
24
+ }
25
+
14
26
  const here = dirname(fileURLToPath(import.meta.url));
15
27
  const root = resolve(here, "..");
16
28
  const entry = resolve(root, "dist", "cli.js");
package/dist/accounts.js CHANGED
@@ -1,13 +1,17 @@
1
1
  import { DEFAULT_PLANS, parsePlans, parseLimits, planLimits } from "./plans.js";
2
2
  import { createRequire } from "node:module";
3
3
  import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
4
- import { chmodSync, mkdirSync, readdirSync, rmSync } from "node:fs";
4
+ import { chmodSync, closeSync, mkdirSync, openSync, readdirSync, rmSync } from "node:fs";
5
5
  import { dirname, join } from "node:path";
6
6
  import { fingerprint } from "./identity.js";
7
7
  const nodeRequire = createRequire(import.meta.url);
8
8
  const CODE_TTL_MS = 10 * 60 * 1_000;
9
9
  const CODE_RESEND_COOLDOWN_MS = 60 * 1_000;
10
10
  const MAX_ATTEMPTS = 5;
11
+ export const MAX_FAILS_PER_KEY = 10;
12
+ export const MAX_FAILS_PER_NETWORK = 50;
13
+ export const FAIL_WINDOW_MS = 60 * 60 * 1_000;
14
+ const MAX_PENDING_PER_EMAIL = 5;
11
15
  const now = () => Date.now();
12
16
  const newId = (p) => `${p}_${randomBytes(9).toString("base64url")}`;
13
17
  const sha = (s) => createHash("sha256").update(s).digest("hex");
@@ -27,7 +31,12 @@ export class Accounts {
27
31
  db;
28
32
  constructor(dbPath) {
29
33
  if (dbPath !== ":memory:") {
30
- mkdirSync(dirname(dbPath), { recursive: true });
34
+ mkdirSync(dirname(dbPath), { recursive: true, mode: 0o700 });
35
+ try {
36
+ closeSync(openSync(dbPath, "a", 0o600));
37
+ chmodSync(dbPath, 0o600);
38
+ }
39
+ catch { }
31
40
  }
32
41
  let DB;
33
42
  try {
@@ -40,10 +49,12 @@ export class Accounts {
40
49
  this.db.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;");
41
50
  this.migrate();
42
51
  if (dbPath !== ":memory:") {
43
- try {
44
- chmodSync(dbPath, 0o600);
52
+ for (const f of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
53
+ try {
54
+ chmodSync(f, 0o600);
55
+ }
56
+ catch { }
45
57
  }
46
- catch { }
47
58
  }
48
59
  }
49
60
  migrate() {
@@ -65,6 +76,11 @@ export class Accounts {
65
76
  created_at INTEGER NOT NULL,
66
77
  last_seen INTEGER NOT NULL
67
78
  );
79
+ -- The table as 0.29.1 and earlier keep it, one code per email, left in
80
+ -- exactly that shape: a relay rolled back to one of them runs
81
+ -- INSERT ... ON CONFLICT(email) against it, which needs email to be the
82
+ -- key. This build keeps its codes in verifications_by_device below and
83
+ -- only drains this one (see migrate).
68
84
  CREATE TABLE IF NOT EXISTS verifications (
69
85
  email TEXT PRIMARY KEY,
70
86
  code_hash TEXT NOT NULL,
@@ -76,6 +92,23 @@ export class Accounts {
76
92
  attempts INTEGER NOT NULL DEFAULT 0,
77
93
  created_at INTEGER NOT NULL
78
94
  );
95
+ CREATE TABLE IF NOT EXISTS verifications_by_device (
96
+ email TEXT NOT NULL,
97
+ code_hash TEXT NOT NULL,
98
+ purpose TEXT NOT NULL,
99
+ pending_username TEXT,
100
+ pending_pubkey TEXT NOT NULL DEFAULT '',
101
+ pending_label TEXT,
102
+ expires_at INTEGER NOT NULL,
103
+ attempts INTEGER NOT NULL DEFAULT 0,
104
+ created_at INTEGER NOT NULL,
105
+ PRIMARY KEY (email, pending_pubkey)
106
+ );
107
+ CREATE TABLE IF NOT EXISTS verify_failures (
108
+ email TEXT PRIMARY KEY,
109
+ window_start INTEGER NOT NULL,
110
+ fails INTEGER NOT NULL
111
+ );
79
112
  CREATE TABLE IF NOT EXISTS sessions (
80
113
  id TEXT PRIMARY KEY,
81
114
  account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
@@ -128,6 +161,28 @@ export class Accounts {
128
161
  this.db.exec("ALTER TABLE sessions ADD COLUMN ended_at INTEGER");
129
162
  if (!scols.has("role"))
130
163
  this.db.exec("ALTER TABLE sessions ADD COLUMN role TEXT");
164
+ const spcols = new Set(this.db.prepare("PRAGMA table_info(spaces)").all().map((c) => c.name));
165
+ if (!spcols.has("owner_deleted_at"))
166
+ this.db.exec("ALTER TABLE spaces ADD COLUMN owner_deleted_at INTEGER");
167
+ const vcols = this.db.prepare("PRAGMA table_info(verifications)").all();
168
+ this.db.exec("BEGIN");
169
+ try {
170
+ this.db.exec(`INSERT OR IGNORE INTO verifications_by_device (email, code_hash, purpose, pending_username, pending_pubkey, pending_label, expires_at, attempts, created_at)
171
+ SELECT email, code_hash, purpose, pending_username, COALESCE(pending_pubkey, ''), pending_label, expires_at, attempts, created_at FROM verifications`);
172
+ if (vcols.some((c) => c.name === "pending_pubkey" && c.pk > 0)) {
173
+ this.db.exec(`DROP TABLE verifications;
174
+ CREATE TABLE verifications (email TEXT PRIMARY KEY, code_hash TEXT NOT NULL, purpose TEXT NOT NULL, pending_username TEXT,
175
+ pending_pubkey TEXT, pending_label TEXT, expires_at INTEGER NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL);`);
176
+ }
177
+ else {
178
+ this.db.exec("DELETE FROM verifications");
179
+ }
180
+ this.db.exec("COMMIT");
181
+ }
182
+ catch (e) {
183
+ this.db.exec("ROLLBACK");
184
+ throw e;
185
+ }
131
186
  }
132
187
  close() { this.db.close(); }
133
188
  accountByUsername(username) {
@@ -179,43 +234,91 @@ export class Accounts {
179
234
  if (!acct || acct.status !== "active")
180
235
  return { ok: false, reason: "no_such_account" };
181
236
  }
182
- const prev = this.db.prepare("SELECT created_at, expires_at, pending_pubkey FROM verifications WHERE email = ?").get(e);
183
- if (prev && now() - prev.created_at < CODE_RESEND_COOLDOWN_MS)
237
+ if (this.deviceOfOtherAccount(e, opts.pubkey))
238
+ return { ok: false, reason: "device_other_account" };
239
+ const key = opts.pubkey;
240
+ const t = now();
241
+ const acct0 = this.accountByEmail(e);
242
+ const ownKey = !!acct0 && this.deviceByPubkey(key)?.account_id === acct0.id;
243
+ const prev = this.db.prepare(ownKey ? "SELECT MAX(created_at) created_at FROM verifications_by_device WHERE email = ? AND pending_pubkey = ?" : "SELECT MAX(created_at) created_at FROM verifications_by_device WHERE email = ? AND ? IS NOT NULL")
244
+ .get(e, key);
245
+ if (prev?.created_at && t - prev.created_at < CODE_RESEND_COOLDOWN_MS)
184
246
  return { ok: false, reason: "too_soon" };
185
- if (prev && now() < prev.expires_at && prev.pending_pubkey && prev.pending_pubkey !== opts.pubkey) {
186
- return { ok: false, reason: "in_progress_elsewhere" };
247
+ if (this.failsOf(`key:${e}|${key}`, t) >= MAX_FAILS_PER_KEY)
248
+ return { ok: false, reason: "locked" };
249
+ this.db.prepare("DELETE FROM verifications_by_device WHERE email = ? AND expires_at < ?").run(e, t);
250
+ if (ownKey) {
251
+ for (const r of this.db.prepare("SELECT pending_pubkey FROM verifications_by_device WHERE email = ? AND pending_pubkey != ?").all(e, key)) {
252
+ if (this.deviceByPubkey(r.pending_pubkey)?.account_id !== acct0.id)
253
+ this.db.prepare("DELETE FROM verifications_by_device WHERE email = ? AND pending_pubkey = ?").run(e, r.pending_pubkey);
254
+ }
255
+ }
256
+ const others = this.db.prepare("SELECT pending_pubkey FROM verifications_by_device WHERE email = ? AND pending_pubkey != ? ORDER BY created_at ASC").all(e, key);
257
+ for (const r of others.slice(0, Math.max(0, others.length - (MAX_PENDING_PER_EMAIL - 1)))) {
258
+ this.db.prepare("DELETE FROM verifications_by_device WHERE email = ? AND pending_pubkey = ?").run(e, r.pending_pubkey);
187
259
  }
188
260
  const code = String(randomInt(0, 1_000_000)).padStart(6, "0");
189
261
  this.db.prepare(`
190
- INSERT INTO verifications (email, code_hash, purpose, pending_username, pending_pubkey, pending_label, expires_at, attempts, created_at)
262
+ INSERT INTO verifications_by_device (email, code_hash, purpose, pending_username, pending_pubkey, pending_label, expires_at, attempts, created_at)
191
263
  VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?)
192
- ON CONFLICT(email) DO UPDATE SET
264
+ ON CONFLICT(email, pending_pubkey) DO UPDATE SET
193
265
  code_hash = excluded.code_hash, purpose = excluded.purpose,
194
- pending_username = excluded.pending_username, pending_pubkey = excluded.pending_pubkey,
266
+ pending_username = excluded.pending_username,
195
267
  pending_label = excluded.pending_label, expires_at = excluded.expires_at,
196
268
  attempts = 0, created_at = excluded.created_at
197
- `).run(e, sha(code), purpose, purpose === "signup" ? normalizeUsername(opts.username) : null, opts.pubkey, opts.label, now() + CODE_TTL_MS, now());
269
+ `).run(e, sha(code), purpose, purpose === "signup" ? normalizeUsername(opts.username) : null, key, opts.label, t + CODE_TTL_MS, t);
198
270
  return { ok: true, code };
199
271
  }
200
- verify(email, code, ip, pubkey) {
272
+ failsOf(key, t = now()) {
273
+ const f = this.db.prepare("SELECT window_start, fails FROM verify_failures WHERE email = ?").get(key);
274
+ return f && t - f.window_start < FAIL_WINDOW_MS ? f.fails : 0;
275
+ }
276
+ noteFail(key, t = now()) {
277
+ const f = this.db.prepare("SELECT window_start FROM verify_failures WHERE email = ?").get(key);
278
+ if (f && t - f.window_start < FAIL_WINDOW_MS)
279
+ this.db.prepare("UPDATE verify_failures SET fails = fails + 1 WHERE email = ?").run(key);
280
+ else
281
+ this.db.prepare("INSERT INTO verify_failures (email, window_start, fails) VALUES (?, ?, 1) ON CONFLICT(email) DO UPDATE SET window_start = excluded.window_start, fails = 1").run(key, t);
282
+ }
283
+ verify(email, code, ip, pubkey, net = null) {
201
284
  const e = normalizeEmail(email);
202
- const v = this.db.prepare("SELECT * FROM verifications WHERE email = ?").get(e);
203
- if (!v)
204
- return { ok: false, reason: "no_pending" };
205
- if (now() > v.expires_at) {
206
- this.db.prepare("DELETE FROM verifications WHERE email = ?").run(e);
285
+ const t = now();
286
+ const rows = this.db.prepare("SELECT * FROM verifications_by_device WHERE email = ? ORDER BY created_at ASC").all(e);
287
+ let v;
288
+ if (pubkey) {
289
+ v = rows.find((r) => r.pending_pubkey === pubkey);
290
+ if (!v)
291
+ return { ok: false, reason: rows.some((r) => t <= r.expires_at) ? "other_device" : rows.length ? "expired" : "no_pending" };
292
+ }
293
+ else {
294
+ const live = rows.filter((r) => t <= r.expires_at);
295
+ if (live.length > 1)
296
+ return { ok: false, reason: "several_pending" };
297
+ v = live[0] ?? rows[rows.length - 1];
298
+ if (!v)
299
+ return { ok: false, reason: "no_pending" };
300
+ }
301
+ const where = "email = ? AND pending_pubkey = ?";
302
+ if (t > v.expires_at) {
303
+ this.db.prepare(`DELETE FROM verifications_by_device WHERE ${where}`).run(e, v.pending_pubkey);
207
304
  return { ok: false, reason: "expired" };
208
305
  }
209
306
  if (v.attempts >= MAX_ATTEMPTS)
210
307
  return { ok: false, reason: "too_many_attempts" };
211
- if (pubkey && v.pending_pubkey && pubkey !== v.pending_pubkey)
212
- return { ok: false, reason: "other_device" };
308
+ if (v.pending_pubkey && this.deviceOfOtherAccount(e, v.pending_pubkey))
309
+ return { ok: false, reason: "device_other_account" };
310
+ const keyId = `key:${e}|${v.pending_pubkey}`, netId = net ? `net:${net}` : null;
311
+ if (this.failsOf(keyId, t) >= MAX_FAILS_PER_KEY || (netId && this.failsOf(netId, t) >= MAX_FAILS_PER_NETWORK))
312
+ return { ok: false, reason: "locked" };
213
313
  const want = Buffer.from(v.code_hash, "hex");
214
314
  const got = Buffer.from(sha(code), "hex");
215
315
  const good = want.length === got.length && timingSafeEqual(want, got);
216
316
  if (!good) {
217
- this.db.prepare("UPDATE verifications SET attempts = attempts + 1 WHERE email = ?").run(e);
218
- return { ok: false, reason: "bad_code" };
317
+ this.db.prepare(`UPDATE verifications_by_device SET attempts = attempts + 1 WHERE ${where}`).run(e, v.pending_pubkey);
318
+ this.noteFail(keyId, t);
319
+ if (netId)
320
+ this.noteFail(netId, t);
321
+ return { ok: false, reason: "bad_code", tries_left: Math.max(0, MAX_ATTEMPTS - v.attempts - 1) };
219
322
  }
220
323
  const run = this.db.prepare.bind(this.db);
221
324
  this.db.exec("BEGIN");
@@ -243,7 +346,11 @@ export class Accounts {
243
346
  }
244
347
  const device = this.bindDevice(account.id, v.pending_pubkey, v.pending_label ?? "a device");
245
348
  const session = this.openSession(account.id, device.id, "", ip);
246
- run("DELETE FROM verifications WHERE email = ?").run(e);
349
+ if (v.purpose === "signup")
350
+ run("DELETE FROM verifications_by_device WHERE email = ?").run(e);
351
+ else
352
+ run(`DELETE FROM verifications_by_device WHERE ${where}`).run(e, v.pending_pubkey);
353
+ run("DELETE FROM verify_failures WHERE email = ?").run(keyId);
247
354
  this.db.exec("COMMIT");
248
355
  this.audit({ actor_account: account.id, event: v.purpose === "signup" ? "email_verified" : "login", target: device.fingerprint, ip });
249
356
  return { ok: true, account, session, device };
@@ -253,8 +360,22 @@ export class Accounts {
253
360
  throw err;
254
361
  }
255
362
  }
363
+ deviceOfOtherAccount(email, pubkey) {
364
+ const dev = this.deviceByPubkey(pubkey);
365
+ if (!dev)
366
+ return false;
367
+ return dev.account_id !== this.accountByEmail(email)?.id;
368
+ }
369
+ isDeviceOf(email, pubkey) {
370
+ if (!pubkey)
371
+ return false;
372
+ const dev = this.deviceByPubkey(pubkey);
373
+ return !!dev && dev.account_id === this.accountByEmail(email)?.id;
374
+ }
256
375
  bindDevice(accountId, pubkey, label) {
257
376
  const existing = this.deviceByPubkey(pubkey);
377
+ if (existing && existing.account_id !== accountId)
378
+ throw new Error("this device key is bound to a different account");
258
379
  if (existing) {
259
380
  this.db.prepare("UPDATE devices SET last_seen = ? WHERE id = ?").run(now(), existing.id);
260
381
  return existing;
@@ -318,6 +439,7 @@ export class Accounts {
318
439
  return false;
319
440
  this.db.exec("BEGIN");
320
441
  try {
442
+ this.db.prepare("UPDATE spaces SET owner_deleted_at = ? WHERE owner_account = ?").run(now(), accountId);
321
443
  this.db.prepare("DELETE FROM accounts WHERE id = ?").run(accountId);
322
444
  this.db.exec("COMMIT");
323
445
  }
@@ -382,7 +504,7 @@ export class Accounts {
382
504
  return this.db.prepare("SELECT * FROM blocks ORDER BY created_at DESC").all();
383
505
  }
384
506
  recentVerifications(limit = 50) {
385
- return this.db.prepare("SELECT email, purpose, created_at, expires_at, attempts FROM verifications ORDER BY created_at DESC LIMIT ?").all(limit);
507
+ return this.db.prepare("SELECT email, purpose, created_at, expires_at, attempts FROM verifications_by_device ORDER BY created_at DESC LIMIT ?").all(limit);
386
508
  }
387
509
  plans() {
388
510
  const row = this.db.prepare("SELECT json FROM settings WHERE key = 'plans'").get();
@@ -444,7 +566,7 @@ export class Accounts {
444
566
  }
445
567
  recordSpace(name, ownerAccount) {
446
568
  this.db.prepare("INSERT INTO spaces (name, owner_account, created_at, deleted_at) VALUES (?, ?, ?, NULL) " +
447
- "ON CONFLICT(name) DO UPDATE SET owner_account = excluded.owner_account, created_at = excluded.created_at, deleted_at = NULL WHERE spaces.deleted_at IS NOT NULL").run(name, ownerAccount, now());
569
+ "ON CONFLICT(name) DO UPDATE SET owner_account = excluded.owner_account, created_at = excluded.created_at, deleted_at = NULL, owner_deleted_at = NULL WHERE spaces.deleted_at IS NOT NULL").run(name, ownerAccount, now());
448
570
  }
449
571
  markSpaceDeleted(name) {
450
572
  this.db.prepare("UPDATE spaces SET deleted_at = ? WHERE name = ? AND deleted_at IS NULL").run(now(), name);
@@ -453,6 +575,9 @@ export class Accounts {
453
575
  const r = this.db.prepare("SELECT owner_account FROM spaces WHERE name = ? AND deleted_at IS NULL").get(name);
454
576
  return r?.owner_account ? this.accountById(r.owner_account) : null;
455
577
  }
578
+ spaceOrphaned(name) {
579
+ return !!this.db.prepare("SELECT 1 FROM spaces WHERE name = ? AND deleted_at IS NULL AND owner_account IS NULL AND owner_deleted_at IS NOT NULL").get(name);
580
+ }
456
581
  spacesOwned(accountId) {
457
582
  return this.db.prepare("SELECT name FROM spaces WHERE owner_account = ? AND deleted_at IS NULL ORDER BY created_at").all(accountId).map((r) => r.name);
458
583
  }