@join-pantheon/cli 0.29.1 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +142 -40
  2. package/bin/pantheon.js +12 -0
  3. package/dist/accounts.js +151 -26
  4. package/dist/agents.js +224 -17
  5. package/dist/claude-confine.js +299 -0
  6. package/dist/cli.js +1116 -137
  7. package/dist/client.js +294 -10
  8. package/dist/command-state.js +102 -0
  9. package/dist/config.js +287 -14
  10. package/dist/continued.js +42 -10
  11. package/dist/doctor.js +191 -23
  12. package/dist/file-lock.js +197 -0
  13. package/dist/grants.js +1 -1
  14. package/dist/history-view.js +2 -1
  15. package/dist/home-guard.js +565 -0
  16. package/dist/hooks-install.js +2 -1
  17. package/dist/identity.js +101 -1
  18. package/dist/install.js +76 -26
  19. package/dist/kiro-files.js +2 -2
  20. package/dist/ledger.js +111 -28
  21. package/dist/machine.js +494 -32
  22. package/dist/mcp-supervisor.js +29 -7
  23. package/dist/mcp-windows.js +47 -25
  24. package/dist/mcp.js +99 -36
  25. package/dist/network.js +39 -8
  26. package/dist/nonces.js +130 -0
  27. package/dist/notify.js +26 -9
  28. package/dist/pending.js +121 -22
  29. package/dist/persona.js +21 -0
  30. package/dist/pins.js +161 -0
  31. package/dist/profile.js +22 -1
  32. package/dist/protocol.js +40 -1
  33. package/dist/quickstart.js +173 -24
  34. package/dist/reads.js +37 -0
  35. package/dist/refusal.js +14 -1
  36. package/dist/relay.js +1341 -207
  37. package/dist/repo-find.js +127 -0
  38. package/dist/repo-route.js +70 -0
  39. package/dist/roster-file.js +190 -25
  40. package/dist/run-failure.js +99 -3
  41. package/dist/secret-guard.js +141 -0
  42. package/dist/service.js +91 -35
  43. package/dist/setup-guide.js +91 -31
  44. package/dist/spend.js +6 -2
  45. package/dist/store.js +94 -10
  46. package/dist/text-file.js +74 -0
  47. package/dist/tray.js +24 -3
  48. package/dist/untrusted.js +24 -5
  49. package/dist/update-check.js +2 -0
  50. package/dist/upgrade.js +9 -5
  51. package/dist/verify-act.js +102 -0
  52. package/dist/which.js +69 -12
  53. package/dist/worker-fleet.js +30 -9
  54. package/dist/worker-policy.js +231 -7
  55. package/dist/worker.js +1717 -130
  56. package/helper/macos/menubar.swift +63 -2
  57. package/package.json +2 -2
package/dist/upgrade.js CHANGED
@@ -70,7 +70,7 @@ export function defaultDeps() {
70
70
  catch { }
71
71
  return up;
72
72
  },
73
- npmUpgrade: () => npm(["install", "-g", `${PKG_NAME}@latest`, "--no-audit", "--no-fund"], { stdio: ["ignore", "pipe", "pipe"] }),
73
+ npmUpgrade: (version) => npm(["install", "-g", `${PKG_NAME}@${version ?? "latest"}`, "--no-audit", "--no-fund"], { stdio: ["ignore", "pipe", "pipe"] }),
74
74
  npmLatest: () => {
75
75
  try {
76
76
  return npm(["view", `${PKG_NAME}`, "version"], { stdio: ["ignore", "pipe", "ignore"], timeout: 4000 }).trim() || null;
@@ -81,18 +81,22 @@ export function defaultDeps() {
81
81
  },
82
82
  };
83
83
  }
84
- export function upgrade(deps = defaultDeps()) {
84
+ export function upgrade(deps = defaultDeps(), opts = {}) {
85
+ const spec = opts.version && /^\d+\.\d+\.\d+$/.test(opts.version) ? opts.version : "latest";
85
86
  const { root } = deps;
86
87
  const from = deps.version(root);
87
88
  const steps = [];
88
89
  const stop = (name, detail) => ({ root, from, to: from, steps: [...steps, { name, ok: false, detail }], ok: false, alreadyCurrent: false, restart: [] });
89
90
  if (!deps.isGit(root)) {
90
91
  try {
91
- deps.npmUpgrade();
92
- steps.push({ name: "npm", ok: true, detail: `installed ${PKG_NAME}@latest` });
92
+ deps.npmUpgrade(spec === "latest" ? undefined : spec);
93
+ steps.push({ name: "npm", ok: true, detail: `installed ${PKG_NAME}@${spec}` });
93
94
  }
94
95
  catch (e) {
95
- return stop("npm", `\`npm install -g ${PKG_NAME}@latest\` failed: ${e.message.split("\n")[0]}. ` +
96
+ if (spec !== "latest" && /ETARGET|No matching version|notarget/i.test(String(e.stderr ?? "") + e.message)) {
97
+ return { root, from, to: from, steps: [...steps, { name: "npm", ok: true, detail: `${PKG_NAME}@${spec} is not on npm yet` }], ok: true, alreadyCurrent: true, restart: [] };
98
+ }
99
+ return stop("npm", `\`npm install -g ${PKG_NAME}@${spec}\` failed: ${e.message.split("\n")[0]}. ` +
96
100
  `You may need to re-run it with the right permissions (or your Node version manager's global scope).`);
97
101
  }
98
102
  let wired = [];
@@ -0,0 +1,102 @@
1
+ import { actPayload, canonicalJson, canonicalKey, fingerprint, goalPayload, verifyBytes, verifyDelegate } from "./identity.js";
2
+ export const RELAY_META_KEYS = new Set(["from_repo", "authority", "unverified_domains", "defers_to", "affects"]);
3
+ export function relayComposed(rec) {
4
+ if (rec.from === "relay" || rec.from === "pantheon")
5
+ return true;
6
+ if (rec.act === "JOIN" || rec.act === "LEAVE")
7
+ return true;
8
+ const m = rec.meta ?? {};
9
+ if (rec.act === "NOTE" && ("goal_members" in m || "organization_profile" in m || "notice" in m))
10
+ return true;
11
+ if (rec.act === "NOTE" && typeof rec.body === "string" && rec.body.startsWith("repo registered with this space: "))
12
+ return true;
13
+ return false;
14
+ }
15
+ const same = (a, b) => canonicalJson(a ?? null) === canonicalJson(b ?? null);
16
+ const teamList = (t) => [...new Set((typeof t === "string" ? [t] : t).filter((x) => !!x))];
17
+ export function verifyAct(rec, teams, pinnedFp, memberKey, at = Date.now()) {
18
+ const sig = rec.sig;
19
+ if (!sig)
20
+ return relayComposed(rec) ? { trust: "relay", why: "recorded by the relay" } : { trust: "unsigned", why: `not signed — sent by a Pantheon before 0.31, or the signature was dropped on the way` };
21
+ if (sig.v !== 1 || typeof sig.s !== "string" || typeof sig.pub !== "string")
22
+ return { trust: "invalid", why: "a signature in a form this version does not know" };
23
+ if (!canonicalKey(sig.pub))
24
+ return { trust: "invalid", why: "signed with a key that is not a plain ed25519 key" };
25
+ if (typeof sig.ts !== "number" || sig.ts > Date.now() + 86_400_000)
26
+ return { trust: "invalid", why: "signed with a date more than a day ahead" };
27
+ const senderMeta = (sig.meta ?? {});
28
+ const meta = (rec.meta ?? {});
29
+ for (const [k, v] of Object.entries(senderMeta))
30
+ if (!same(meta[k], v))
31
+ return { trust: "invalid", why: `meta.${k} is not what the sender wrote` };
32
+ for (const k of Object.keys(meta))
33
+ if (!(k in senderMeta) && !RELAY_META_KEYS.has(k))
34
+ return { trust: "invalid", why: `meta.${k} was added on the way` };
35
+ if (typeof meta.from_repo === "string" && meta.from_repo !== (sig.repo ?? null))
36
+ return { trust: "invalid", why: "from_repo is not the repo the sender signed" };
37
+ const list = teamList(teams);
38
+ const team = list.find((t) => verifyBytes(sig.pub, actPayload({
39
+ id: rec.id, team: t, member: rec.from, keyRole: sig.role, nonce: sig.nonce, ts: sig.ts,
40
+ act: rec.act, goal: rec.goal ?? null, ref: rec.ref ?? null, to: rec.to ?? null,
41
+ defers_to: typeof meta.defers_to === "string" ? meta.defers_to : null,
42
+ affects: Array.isArray(meta.affects) ? meta.affects : null,
43
+ repo: sig.repo ?? null,
44
+ summary: rec.summary ?? null, body: rec.body, meta: senderMeta,
45
+ }), sig.s));
46
+ if (!team)
47
+ return { trust: "invalid", why: "the signature does not match what arrived — altered on the way, or not written by its sender" };
48
+ return signer(sig, team, rec.from, pinnedFp, memberKey, Math.max(sig.ts, rec.ts || 0), "act");
49
+ }
50
+ function signer(sig, team, member, pinnedFp, memberKey, at, what) {
51
+ if (!pinnedFp)
52
+ return { trust: "unknown-key", why: `no key for ${member} is known on this machine yet` };
53
+ const pinnedKey = memberKey && canonicalKey(memberKey) && fingerprint(memberKey) === pinnedFp ? memberKey : undefined;
54
+ if (sig.role === "member") {
55
+ const same = pinnedKey ? sig.pub === pinnedKey : fingerprint(sig.pub) === pinnedFp;
56
+ if (!same)
57
+ return { trust: "key-changed", why: `signed by a key that is not the one this machine knows for ${member} (${fingerprint(sig.pub)}; known: ${pinnedFp})` };
58
+ return { trust: "verified", why: `signed by ${member}` };
59
+ }
60
+ const cert = sig.cert;
61
+ if (!cert)
62
+ return { trust: "invalid", why: `a run's ${what} with no certificate` };
63
+ if (cert.pub !== sig.pub)
64
+ return { trust: "invalid", why: "the run key is not the one its certificate names" };
65
+ if (!pinnedKey)
66
+ return { trust: "unknown-key", why: `${member}'s own key is needed to check their run's certificate, and this machine does not have it yet` };
67
+ const memberKey2 = pinnedKey;
68
+ if (!verifyDelegate(memberKey2, team, member, cert, at)) {
69
+ return { trust: "invalid", why: `the run certificate does not verify for ${member} in this team, or it had expired` };
70
+ }
71
+ if (sig.ts > cert.expires + 10 * 60_000)
72
+ return { trust: "invalid", why: "signed after its run certificate expired" };
73
+ return { trust: "verified-run", why: `signed by ${member}'s unattended run` };
74
+ }
75
+ export function verifyGoal(g, teams, pinnedFp, memberKey, at = Date.now()) {
76
+ const sig = g.sig;
77
+ if (!sig)
78
+ return { trust: "unsigned", why: "the goal was opened by a Pantheon before 0.31, or its signature was dropped" };
79
+ if (!canonicalKey(sig.pub))
80
+ return { trust: "invalid", why: "signed with a key that is not a plain ed25519 key" };
81
+ if (typeof sig.ts !== "number" || sig.ts > Date.now() + 86_400_000)
82
+ return { trust: "invalid", why: "signed with a date more than a day ahead" };
83
+ const team = teamList(teams).find((t) => verifyBytes(sig.pub, goalPayload({
84
+ id: g.id, team: t, member: g.opened_by, keyRole: sig.role, nonce: sig.nonce, ts: sig.ts,
85
+ title: g.title, acceptance: g.acceptance, invite: sig.invite,
86
+ budget: sig.budget ?? null, deadline: sig.deadline ?? null, kind: sig.kind ?? null,
87
+ }), sig.s));
88
+ if (!team)
89
+ return { trust: "invalid", why: "the goal's title, acceptance or invitations are not what its opener wrote" };
90
+ at = Math.max(sig.ts, g.opened_at || 0);
91
+ return signer(sig, team, g.opened_by, pinnedFp, memberKey, at, "goal");
92
+ }
93
+ export const trusted = (t) => t === "verified" || t === "verified-run";
94
+ export function trustLabel(r, from) {
95
+ switch (r.trust) {
96
+ case "verified": return `signed by ${from}`;
97
+ case "verified-run": return `signed by ${from}'s unattended run`;
98
+ case "relay": return from === "relay" || from === "pantheon" ? "written by the relay" : `written by the relay, not signed by ${from} — NOT VERIFIED as theirs`;
99
+ default: return `NOT VERIFIED — ${r.why}`;
100
+ }
101
+ }
102
+ //# sourceMappingURL=verify-act.js.map
package/dist/which.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
2
3
  import { accessSync, constants, existsSync, readFileSync, statSync } from "node:fs";
3
4
  import { dirname, join, posix, resolve, win32 } from "node:path";
4
5
  import { platform } from "node:os";
@@ -40,35 +41,82 @@ export function whichWindows(name, env = process.env, exists = runnable) {
40
41
  }
41
42
  return null;
42
43
  }
43
- export function which(name, os = platform(), env = process.env) {
44
+ export function which(name, os = platform(), env = process.env, opts = {}) {
44
45
  if (isWindows(os))
45
46
  return whichWindows(name, env);
46
- return loginShellWhich(name) ?? posixOnPath(name, env);
47
+ const ok = opts.runnable ?? runnable;
48
+ const wsl = os === "linux" && (opts.wsl ?? onWsl(env));
49
+ const usable = (p) => ok(p) && !(wsl && isWslWindowsShim(p));
50
+ const login = (opts.login ?? loginShellLookup)(name);
51
+ if (login?.found && usable(login.found))
52
+ return login.found;
53
+ if (login?.found && login.path) {
54
+ const hit = posixOnPath(name, { PATH: login.path }, usable);
55
+ if (hit)
56
+ return hit;
57
+ }
58
+ return posixOnPath(name, env, usable);
47
59
  }
48
- function posixOnPath(name, env) {
60
+ export const isWslWindowsShim = (p) => /^\/mnt\/[a-z]\//i.test(p) && !/\.exe$/i.test(p);
61
+ function onWsl(env) {
62
+ if (env.WSL_DISTRO_NAME || env.WSL_INTEROP)
63
+ return true;
64
+ try {
65
+ return /microsoft/i.test(readFileSync("/proc/version", "utf8"));
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ function posixOnPath(name, env, ok = runnable) {
49
72
  if (!name)
50
73
  return null;
51
74
  if (name.includes("/"))
52
- return runnable(name) ? name : null;
75
+ return ok(name) ? name : null;
53
76
  for (const dir of (env.PATH ?? "").split(":")) {
54
77
  if (!dir)
55
78
  continue;
56
79
  const full = posix.join(dir, name);
57
- if (runnable(full))
80
+ if (ok(full))
58
81
  return full;
59
82
  }
60
83
  return null;
61
84
  }
62
- function loginShellWhich(name) {
85
+ export function loginShellLookup(name, run = runLoginShell) {
86
+ if (!name)
87
+ return null;
88
+ const mark = `__pantheon_which_${randomBytes(6).toString("hex")}__`;
89
+ let out;
63
90
  try {
64
- const out = execFileSync("sh", ["-lc", `command -v ${name}`], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
65
- if (!out)
66
- return null;
67
- return out.startsWith("/") && !runnable(out) ? null : out;
91
+ out = run(`printf '\\n%s\\n' "$1"; command -v "$2"; printf '\\n%s\\n' "$1"; printf '%s' "$PATH"`, [mark, name]);
68
92
  }
69
93
  catch {
70
94
  return null;
71
95
  }
96
+ return parseLoginAnswer(out, mark);
97
+ }
98
+ export function parseLoginAnswer(out, mark) {
99
+ const open = `\n${mark}\n`;
100
+ const first = out.indexOf(open);
101
+ if (first < 0)
102
+ return null;
103
+ const rest = out.slice(first + open.length);
104
+ const second = rest.indexOf(open);
105
+ const said = (second < 0 ? rest : rest.slice(0, second)).split("\n").map((l) => l.trim()).filter(Boolean);
106
+ const path = second < 0 ? null : rest.slice(second + open.length);
107
+ const found = said.length === 1 && said[0].startsWith("/") ? said[0] : null;
108
+ return { found, path };
109
+ }
110
+ function runLoginShell(script, args) {
111
+ try {
112
+ return execFileSync("sh", ["-lc", script, "sh", ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 15_000 });
113
+ }
114
+ catch (e) {
115
+ const out = e.stdout;
116
+ if (typeof out === "string" && out)
117
+ return out;
118
+ throw e;
119
+ }
72
120
  }
73
121
  export const onPathNamed = (name, os = platform(), env = process.env) => which(name, os, env) !== null;
74
122
  export function spawnShape(binary, os = platform()) {
@@ -112,8 +160,11 @@ catch {
112
160
  const dir = dirname(shim);
113
161
  const re = /"%(?:~dp0|dp0%)\\?([^"%\r\n]+)"[ \t]*%\*[ \t]*\)?[ \t]*\r?$/gm;
114
162
  let rel = null;
115
- for (let m = re.exec(text); m; m = re.exec(text))
163
+ let before = "";
164
+ for (let m = re.exec(text); m; m = re.exec(text)) {
116
165
  rel = m[1];
166
+ before = text.slice(text.lastIndexOf("\n", m.index) + 1, m.index);
167
+ }
117
168
  if (!rel)
118
169
  return { why: "was read but not recognised as a package manager's shim" };
119
170
  const parts = rel.split(/[\\/]+/).filter(Boolean);
@@ -134,7 +185,13 @@ catch {
134
185
  return { why: `was read, but it runs ${target}, which is not something Pantheon can start without cmd.exe` };
135
186
  }
136
187
  const localNode = join(dir, "node.exe");
137
- return { command: exists(localNode) ? localNode : process.execPath, args: [target] };
188
+ return { command: exists(localNode) ? localNode : process.execPath, args: [...shimNodeFlags(before), target] };
189
+ }
190
+ export function shimNodeFlags(linePrefix) {
191
+ const m = /(?:"%_prog%"|"%~?dp0%?\\?node\.exe"|(?:^|[\s&|(])node(?:\.exe)?)((?:[ \t]+-[^\s"%]+)*)[ \t]*$/i.exec(linePrefix);
192
+ if (!m || !m[1])
193
+ return [];
194
+ return m[1].trim().split(/[ \t]+/).filter((f) => /^--?[A-Za-z0-9][\w.-]*(?:=[^\s"%^&|<>!]*)?$/.test(f));
138
195
  }
139
196
  export function resolveCmdShim(shim, read, exists) {
140
197
  const r = inspectCmdShim(shim, read, exists);
@@ -1,9 +1,11 @@
1
1
  import { existsSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { join, resolve, sep } from "node:path";
3
+ import { holdsRepo } from "./repo-find.js";
3
4
  import { CONFIG_DIR, configFor, loadAll, repoInfo } from "./config.js";
4
- import { Worker } from "./worker.js";
5
+ import { Worker, workspaceOf } from "./worker.js";
5
6
  export class WorkerFleet {
6
7
  opts;
8
+ dupSaid = new Set();
7
9
  slots = new Map();
8
10
  stopped = false;
9
11
  constructor(opts) {
@@ -18,14 +20,23 @@ export class WorkerFleet {
18
20
  const wanted = new Map();
19
21
  if (f) {
20
22
  const several = Object.keys(f.spaces ?? {}).length > 1;
21
- for (const b of Object.values(f.bindings ?? {})) {
22
- const m = f.spaces?.[b.space];
23
- if (!m || !existsSync(join(b.path, ".git")))
23
+ const idCount = new Map();
24
+ for (const m of Object.values(f.spaces ?? {}))
25
+ idCount.set(m.id, (idCount.get(m.id) ?? 0) + 1);
26
+ for (const m of Object.values(f.spaces ?? {})) {
27
+ if ((idCount.get(m.id) ?? 0) > 1) {
28
+ if (!this.dupSaid.has(m.id)) {
29
+ this.dupSaid.add(m.id);
30
+ this.opts.log(`fleet: two entries in config.json claim team ${m.id} — serving neither until one is removed (pantheon spaces)`);
31
+ }
24
32
  continue;
25
- if (wanted.has(m.id))
33
+ }
34
+ const ws = teamWorkspaces(f, m.id);
35
+ const home = ws.find((w) => w.kind === "repo") ?? ws[0];
36
+ if (!home)
26
37
  continue;
27
- const repo = repoInfo(b.path);
28
- wanted.set(m.id, { path: b.path, cfg: configFor(f, m, repo) });
38
+ const repo = home.kind === "repo" ? repoInfo(home.path) : null;
39
+ wanted.set(m.id, { path: home.path, cfg: configFor(f, m, repo) });
29
40
  void several;
30
41
  }
31
42
  for (const [id, w] of wanted) {
@@ -36,6 +47,7 @@ export class WorkerFleet {
36
47
  const wopts = {
37
48
  ...rest,
38
49
  project: w.path,
50
+ workspaces: () => teamWorkspaces((this.opts.readConfig ?? loadAll)(), id),
39
51
  scratch: join(home, "worktrees", encodeURIComponent(id)),
40
52
  home,
41
53
  requirePersonaSpaces: several,
@@ -66,7 +78,7 @@ export class WorkerFleet {
66
78
  for (const s of stopped)
67
79
  this.opts.log(`fleet: worker for "${s}" stopped — binding gone`);
68
80
  if (!this.slots.size && !started.length)
69
- this.opts.log(`fleet: no bound repos on this machine — bind one with \`pantheon use <team> --here\` from its checkout`);
81
+ this.opts.log(`fleet: nothing bound on this machine — bind a repo or a docs folder with \`pantheon use <team> --here\` from inside it`);
70
82
  await new Promise((r) => setTimeout(r, poll));
71
83
  if (this.opts.once && this.slots.size === 0)
72
84
  break;
@@ -81,4 +93,13 @@ export class WorkerFleet {
81
93
  s.worker.stop();
82
94
  }
83
95
  }
96
+ export function teamWorkspaces(f, spaceId) {
97
+ const bound = Object.entries(f?.bindings ?? {}).filter(([, b]) => b.space === spaceId && existsSync(b.path));
98
+ const repos = bound.filter(([k, b]) => !k.startsWith("d_") && existsSync(join(b.path, ".git"))).map(([, b]) => workspaceOf(b.path));
99
+ const all = Object.values(f?.bindings ?? {}).map((b) => resolve(b.path));
100
+ const folders = bound.filter(([k]) => k.startsWith("d_")).map(([, b]) => workspaceOf(b.path))
101
+ .filter((w) => w.kind === "folder" && !all.some((p) => p !== resolve(w.path) && p.startsWith(`${resolve(w.path)}${sep}`)) && !holdsRepo(w.path));
102
+ const seen = new Set();
103
+ return [...repos, ...folders].filter((w) => (seen.has(w.path) ? false : (seen.add(w.path), true)));
104
+ }
84
105
  //# sourceMappingURL=worker-fleet.js.map
@@ -1,6 +1,37 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { CONFIG_PATH } from "./config.js";
4
+ import { trustWrite } from "./home-guard.js";
5
+ import { parseJsonText } from "./text-file.js";
6
+ export const WAKE_NO_MS = 30 * 24 * 60 * 60 * 1_000;
7
+ export const wakeKey = (team, member) => `${team}|${member}`;
8
+ export function mayAskWake(p, teams, member, now = Date.now()) {
9
+ const ts = teams.filter((t) => !!t);
10
+ if (isApprovedWaker(p, ts, member))
11
+ return false;
12
+ if (ts.some((t) => (p.wakeNo?.[wakeKey(t, member)] ?? 0) > now - WAKE_NO_MS))
13
+ return false;
14
+ return !Object.values(p.wakeAsks ?? {}).some((a) => ts.includes(a.team) && a.member === member && now - a.at < ASK_EXPIRY_MS);
15
+ }
16
+ export function takeWakeOnce(p, teams, member) {
17
+ for (const t of teams) {
18
+ if (!t)
19
+ continue;
20
+ const k = wakeKey(t, member);
21
+ const n = p.wakeOnce?.[k] ?? 0;
22
+ if (n > 0) {
23
+ if (n > 1)
24
+ p.wakeOnce[k] = n - 1;
25
+ else
26
+ delete p.wakeOnce[k];
27
+ return true;
28
+ }
29
+ }
30
+ return false;
31
+ }
32
+ export function hasWakeOnce(p, teams, member) {
33
+ return teams.some((t) => !!t && (p.wakeOnce?.[wakeKey(t, member)] ?? 0) > 0);
34
+ }
4
35
  export const ASK_COOLDOWN_MS = 24 * 60 * 60 * 1_000;
5
36
  export const ASK_EXPIRY_MS = 14 * 24 * 60 * 60 * 1_000;
6
37
  export function policyHome(home) {
@@ -9,19 +40,150 @@ export function policyHome(home) {
9
40
  export function policyPath(home) {
10
41
  return join(policyHome(home), "worker-policy.json");
11
42
  }
43
+ const isRecord = (x) => !!x && typeof x === "object" && !Array.isArray(x);
44
+ function readWakers(x) {
45
+ if (!isRecord(x))
46
+ return undefined;
47
+ const out = {};
48
+ for (const [team, v] of Object.entries(x)) {
49
+ if (!Array.isArray(v))
50
+ continue;
51
+ const ids = v.filter((m) => typeof m === "string" && m.length > 0);
52
+ if (ids.length)
53
+ out[team] = ids;
54
+ }
55
+ return out;
56
+ }
12
57
  export function loadPolicy(home) {
13
58
  try {
14
- const raw = JSON.parse(readFileSync(policyPath(home), "utf8"));
15
- return { grants: raw.grants ?? {}, asks: raw.asks ?? {}, denied: raw.denied ?? {}, attempts: raw.attempts ?? {} };
59
+ const raw = parseJsonText(readFileSync(policyPath(home), "utf8"));
60
+ const obj = (v) => (isRecord(v) ? v : {});
61
+ const wakers = readWakers(raw.wakers);
62
+ return {
63
+ grants: obj(raw.grants), asks: obj(raw.asks), denied: obj(raw.denied), attempts: obj(raw.attempts),
64
+ ...(wakers ? { wakers } : {}),
65
+ ...(isRecord(raw.wakersCarried) ? { wakersCarried: raw.wakersCarried } : {}),
66
+ ...(isRecord(raw.wakersDeferred) ? { wakersDeferred: raw.wakersDeferred } : {}),
67
+ ...(isRecord(raw.wakeAsks) ? { wakeAsks: Object.fromEntries(Object.entries(raw.wakeAsks).filter(([, v]) => isRecord(v) && typeof v.team === "string" && typeof v.member === "string" && typeof v.at === "number")) } : {}),
68
+ ...(isRecord(raw.wakeOnce) ? { wakeOnce: Object.fromEntries(Object.entries(raw.wakeOnce).filter(([, v]) => typeof v === "number" && v > 0 && v < 100)) } : {}),
69
+ ...(isRecord(raw.wakeNo) ? { wakeNo: Object.fromEntries(Object.entries(raw.wakeNo).filter(([, v]) => typeof v === "number")) } : {}),
70
+ ...(isRecord(raw.wakeTeamFlag) ? { wakeTeamFlag: Object.fromEntries(Object.entries(raw.wakeTeamFlag).filter(([, v]) => typeof v === "number")) } : {}),
71
+ };
16
72
  }
17
73
  catch {
18
74
  return { grants: {}, asks: {}, denied: {}, attempts: {} };
19
75
  }
20
76
  }
77
+ export function setWaker(p, team, member, approved) {
78
+ const w = (p.wakers ??= {});
79
+ const cur = new Set(w[team] ?? []);
80
+ if (approved)
81
+ cur.add(member);
82
+ else
83
+ cur.delete(member);
84
+ if (cur.size)
85
+ w[team] = [...cur].sort();
86
+ else
87
+ delete w[team];
88
+ }
89
+ export const CARRY_REASON = {
90
+ "you-admitted": "you approved them",
91
+ anyone: "you had chosen that anyone may wake this machine",
92
+ "old-default": "wake was never chosen here, and before 0.30 that meant anyone the team lets",
93
+ founder: "the team's founder, and wake was never restricted here",
94
+ };
95
+ export function carryOverWakers(p, team, me, members, choice, now = Date.now()) {
96
+ if (!team || p.wakersCarried?.[team])
97
+ return null;
98
+ const all = members.filter((m) => m.admitted);
99
+ const others = all.filter((m) => m.member !== me);
100
+ const anyone = choice === "anyone" || choice === "unset-before-0.30";
101
+ if (!relayKnowsAdmitters(members, me) && !anyone)
102
+ return "deferred";
103
+ const founder = (all.find((m) => m.founder) ?? all.find((m) => m.admitted_by === m.member))?.member;
104
+ const carried = [];
105
+ for (const m of others) {
106
+ if (!m.may_wake)
107
+ continue;
108
+ const why = m.admitted_by === me ? "you-admitted"
109
+ : choice === "anyone" ? "anyone"
110
+ : choice === "unset-before-0.30" ? "old-default"
111
+ : choice === undefined && m.member === founder ? "founder"
112
+ : null;
113
+ if (why)
114
+ carried.push({ member: m.member, why });
115
+ }
116
+ carried.sort((a, b) => a.member.localeCompare(b.member));
117
+ for (const c of carried)
118
+ setWaker(p, team, c.member, true);
119
+ (p.wakersCarried ??= {})[team] = now;
120
+ if (p.wakersDeferred) {
121
+ delete p.wakersDeferred[team];
122
+ if (!Object.keys(p.wakersDeferred).length)
123
+ delete p.wakersDeferred;
124
+ }
125
+ return carried;
126
+ }
127
+ export const V030_AT = Date.parse("2026-09-25T06:00:00Z");
128
+ export const OLD_RELAY_WAKE = "this relay is older than 0.30, so who may wake this machine is still decided by the team (may_wake) — until it upgrades; then the approvals you had are carried over, and anyone else needs: pantheon wake allow <name>";
129
+ export function relayKnowsAdmitters(members, me) {
130
+ const all = members.filter((m) => m.admitted);
131
+ return !all.some((m) => m.member !== me) || all.some((m) => m.admitted_by !== undefined);
132
+ }
133
+ export function setLocalWaker(teams, member, approved, home, env = process.env) {
134
+ if (inWokenRun(env))
135
+ return { ok: false, why: "a woken run cannot change who may wake this machine — a person has to run this here" };
136
+ const keys = [...new Set(teams.filter((t) => !!t))];
137
+ if (!keys.length || !member)
138
+ return { ok: false, why: "no team to record it under" };
139
+ const p = loadPolicy(home);
140
+ const was = isApprovedWaker(p, keys, member);
141
+ if (approved) {
142
+ if (!was)
143
+ setWaker(p, keys[0], member, true);
144
+ }
145
+ else
146
+ for (const k of keys)
147
+ setWaker(p, k, member, false);
148
+ if (was === approved)
149
+ return { ok: true, changed: false };
150
+ savePolicy(p, home);
151
+ return { ok: true, changed: true };
152
+ }
153
+ export function markTeamFlagOnYes(team, member, home, env = process.env) {
154
+ if (inWokenRun(env))
155
+ return;
156
+ const p = loadPolicy(home);
157
+ const k = wakeKey(team, member);
158
+ (p.wakeTeamFlag ??= {})[k] = Date.now();
159
+ savePolicy(p, home);
160
+ }
161
+ export function takeTeamFlagOnYes(p, teams, member) {
162
+ for (const t of teams) {
163
+ if (!t)
164
+ continue;
165
+ const k = wakeKey(t, member);
166
+ const at = p.wakeTeamFlag?.[k];
167
+ if (at !== undefined) {
168
+ delete p.wakeTeamFlag[k];
169
+ return Date.now() - at < 7 * 24 * 3600_000;
170
+ }
171
+ }
172
+ return false;
173
+ }
174
+ export function isApprovedWaker(p, teams, member) {
175
+ return teams.some((t) => { const list = t ? p.wakers?.[t] : undefined; return Array.isArray(list) && list.includes(member); });
176
+ }
21
177
  export function savePolicy(p, home) {
22
178
  const file = policyPath(home);
23
179
  mkdirSync(dirname(file), { recursive: true });
180
+ let prev = null;
181
+ try {
182
+ prev = readFileSync(file);
183
+ }
184
+ catch { }
24
185
  writeFileSync(file, JSON.stringify(p, null, 2) + "\n", { mode: 0o600 });
186
+ trustWrite(file, prev);
25
187
  }
26
188
  export function useGrant(p, cap) {
27
189
  const g = p.grants[cap];
@@ -38,6 +200,11 @@ export function grant(p, cap, mode, by) {
38
200
  }
39
201
  export function revoke(p, cap) {
40
202
  delete p.grants[cap];
203
+ if (cap === "unsandboxed_runs")
204
+ p.denied[cap] = Date.now();
205
+ }
206
+ export function unsandboxedAllowed(p) {
207
+ return !!p.grants.unsandboxed_runs || p.denied.unsandboxed_runs === undefined;
41
208
  }
42
209
  export function mayAsk(p, cap, now = Date.now()) {
43
210
  if (p.grants[cap])
@@ -52,15 +219,66 @@ export function recordAsk(p, cap, escalateActId) {
52
219
  p.asks[cap] = { act: escalateActId, at: Date.now() };
53
220
  }
54
221
  export function parseGrantAnswer(body) {
55
- const t = body.trim().toLowerCase();
56
- if (/^(allow[\s-]?always|always[\s-]?allow|yes[\s-]?always)\b/.test(t))
222
+ const t = body.trim().toLowerCase().replace(/[.!]+$/, "").replace(/\s+/g, " ").replace(/’/g, "'");
223
+ if (/^(always|yes always|yes, always|always allow)$/.test(t) || /^(allow[\s-]always)\b/.test(t))
57
224
  return "allow-always";
58
- if (/^(allow([\s-]?once)?|yes([\s-]?once)?|approve[d]?)\b/.test(t))
225
+ if (/^(once|just this once|this once|yes once|yes, once|yes|allow|approve|approved)$/.test(t) || /^(allow[\s-](it[\s-])?once)\b/.test(t))
59
226
  return "allow-once";
60
- if (/^(deny|no|reject(ed)?|don'?t|do not)\b/.test(t))
227
+ if (/^(deny|denied|no|reject|rejected|don't allow|do not allow|don't|do not|never)\b/.test(t))
61
228
  return "deny";
62
229
  return null;
63
230
  }
231
+ export const WOKEN_RUN_ENV = "PANTHEON_WOKEN_RUN";
232
+ export const inWokenRun = (env = process.env) => !!env[WOKEN_RUN_ENV];
233
+ export const CAPABILITY_ARGS = { self_upgrade: "self-upgrade", run_commands: "run-commands", unsandboxed_runs: "unsandboxed-runs" };
234
+ export function applyLocalAnswer(refActId, body, home, env = process.env) {
235
+ if (!refActId)
236
+ return null;
237
+ const p = loadPolicy(home);
238
+ const wa = p.wakeAsks?.[refActId];
239
+ if (wa) {
240
+ if (inWokenRun(env))
241
+ return { applied: false, why: "a woken run cannot let anyone wake this machine — a person has to answer it here" };
242
+ const verdict = parseGrantAnswer(body);
243
+ if (!verdict)
244
+ return null;
245
+ const k = wakeKey(wa.team, wa.member);
246
+ if (verdict === "allow-always")
247
+ setWaker(p, wa.team, wa.member, true);
248
+ else if (verdict === "allow-once")
249
+ (p.wakeOnce ??= {})[k] = Math.min(99, (p.wakeOnce[k] ?? 0) + 1);
250
+ else
251
+ (p.wakeNo ??= {})[k] = Date.now();
252
+ if (verdict !== "deny" && wa.teamBlocked)
253
+ (p.wakeTeamFlag ??= {})[k] = Date.now();
254
+ delete p.wakeAsks[refActId];
255
+ savePolicy(p, home);
256
+ return { applied: true, wake: { team: wa.team, member: wa.member }, verdict };
257
+ }
258
+ if (!Object.values(p.asks).some((a) => a?.act === refActId))
259
+ return null;
260
+ if (inWokenRun(env))
261
+ return { applied: false, why: "a woken run cannot grant this machine anything — a person has to answer it here" };
262
+ const r = applyAnswer(p, refActId, body);
263
+ if (!r)
264
+ return null;
265
+ savePolicy(p, home);
266
+ return { applied: true, ...r };
267
+ }
268
+ export function describeLocalAnswer(g) {
269
+ if (!g.applied)
270
+ return `not granted: ${g.why}`;
271
+ if ("wake" in g) {
272
+ const who = g.wake.member;
273
+ return g.verdict === "allow-always" ? `${who} may start work on this machine from now on — undo: pantheon wake deny ${who}`
274
+ : g.verdict === "allow-once" ? `${who} may start work on this machine this once — their request runs now`
275
+ : `${who} will not start work on this machine; their requests wait for you (not asked again for a month; pantheon wake allow ${who} to change it)`;
276
+ }
277
+ const name = CAPABILITY_ARGS[g.cap];
278
+ if (g.verdict === "deny")
279
+ return `denied ${name} on this machine — the worker will not ask again for a day`;
280
+ return `granted ${name} on this machine${g.verdict === "allow-once" ? " for its next use" : ""} — revoke: pantheon policy revoke ${name}`;
281
+ }
64
282
  export function applyAnswer(p, refActId, body) {
65
283
  const entry = Object.entries(p.asks).find(([, a]) => a.act === refActId);
66
284
  if (!entry)
@@ -73,9 +291,15 @@ export function applyAnswer(p, refActId, body) {
73
291
  if (verdict === "deny")
74
292
  p.denied[cap] = Date.now();
75
293
  else
76
- grant(p, cap, verdict === "allow-always" ? "always" : "once", "answered-request");
294
+ grant(p, cap, verdict === "allow-always" ? "always" : "once", "answered-here");
77
295
  return { cap, verdict };
78
296
  }
297
+ export function dropLedgerGrants(p) {
298
+ const gone = Object.keys(p.grants).filter((c) => p.grants[c]?.by === "answered-request");
299
+ for (const c of gone)
300
+ delete p.grants[c];
301
+ return gone;
302
+ }
79
303
  export const UPGRADE_RETRY_MS = 60 * 60 * 1_000;
80
304
  export function needsSelfUpgrade(localPkg, relayPkg, lastAttempt, now = Date.now()) {
81
305
  if (!relayPkg || relayPkg === localPkg)