@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
@@ -3,10 +3,11 @@ import { approvalSummary, installForUser } from "./install.js";
3
3
  import { execFileSync } from "node:child_process";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
- import { applyProfileInit, loadProfile, profilePath, saveProfile } from "./profile.js";
6
+ import { applyProfileInit, loadProfile, profilePath, saveProfile, wakeMode } from "./profile.js";
7
7
  import { installService as realInstall, uninstallService as realUninstall, serviceInstalled as realInstalled } from "./service.js";
8
8
  import { personaDir } from "./persona.js";
9
9
  import { hudBinaryPath } from "./notifier.js";
10
+ import { grant, inWokenRun, loadPolicy, revoke, savePolicy, unsandboxedAllowed } from "./worker-policy.js";
10
11
  export const DECISION_AREAS = [
11
12
  { id: "product", label: "Product — what we build, for whom, and what goes in a release" },
12
13
  { id: "engineering", label: "Engineering — architecture, and changes that cross team lines" },
@@ -19,8 +20,8 @@ export const DECISION_AREAS = [
19
20
  ];
20
21
  export const TITLE_OPTIONS = ["Founder", "Engineer", "Designer", "Product", "Operations", "Something else"];
21
22
  export const WAKE_OPTIONS = [
22
- { id: "anyone", label: "Yes — anyone on my teams (recommended). Invites you send grant it; you can revoke a person any time." },
23
- { id: "approved", label: "Only people I approve one by one — invites do not grant it; I run `pantheon admit <name> --may-wake` per person." },
23
+ { id: "anyone", label: "Yes — anyone on my teams (the default). Invites you send grant it; to stop one person later, switch to \"only people I approve\" and pantheon wake deny <name>." },
24
+ { id: "approved", label: "Only people I approve — I am asked the first time each one's request needs this machine, or approve ahead with pantheon wake allow <name>; invites never grant it." },
24
25
  { id: "no", label: "No — this machine only works while I am at it." },
25
26
  ];
26
27
  export const ALERT_OPTIONS = [
@@ -32,7 +33,7 @@ export const ALERT_OPTIONS = [
32
33
  export function guideDefaults(profile, cfg) {
33
34
  const n = profile.notifications ?? { decisions: true, deliveries: false, worker_done: false };
34
35
  return {
35
- wake: profile.agent.wake_default ?? "anyone",
36
+ wake: wakeMode(profile),
36
37
  commands: profile.agent.allow_bash ?? false,
37
38
  approval: profile.agent.approval ?? "approve",
38
39
  name: profile.human.display_name || cfg.display || cfg.member,
@@ -42,6 +43,7 @@ export function guideDefaults(profile, cfg) {
42
43
  persona: false,
43
44
  };
44
45
  }
46
+ const andList = (xs) => xs.length < 2 ? xs.join("") : `${xs.slice(0, -1).join(", ")} and ${xs[xs.length - 1]}`;
45
47
  export function optOutsFrom(argv) {
46
48
  return { worker: argv.includes("--no-worker"), notifications: argv.includes("--no-notifications") };
47
49
  }
@@ -67,10 +69,11 @@ export function questionnaire() {
67
69
  "Ask your human in plain words, then pass the answers as flags — to `pantheon setup --yes`, or straight to",
68
70
  "`pantheon start` / `pantheon join`, which apply them the moment the team exists.",
69
71
  "",
70
- "Only 3 and 4 need the person every time. Ask 2 whenever there is a repo (it is the one default people are",
71
- "surprised by). The rest have defaults you may take and say out loud:",
72
- " --wake anyone --decides none --alerts decisions --playbook yes --base <what origin calls its default> --approve-all",
73
- "Without a repo, --wake, --commands, --playbook and --base do nothing yet: skip them and say so.",
72
+ "Only 3 and 4 need the person. The rest have defaults you may take and say out loud:",
73
+ " --wake anyone --commands yes --decides none --alerts decisions --playbook yes --base <what origin calls its default> --approve-all",
74
+ "--wake anyone (the default) means any teammate the team lets wake workers can start a run here while they are away; --commands yes (the default in a repo) means that run may run tests, commit and push to its own branch.",
75
+ "Codex, Cursor and Copilot answer those requests too, so teammates are never kept waiting; --unsandboxed no makes them wait for the person instead.",
76
+ "--commands, --playbook and --base are about a repo: skip them where nothing bound here is one (a docs folder), and say so.",
74
77
  "",
75
78
  "1. While they're away, may teammates hand work to this machine? (their agent runs here on its own,",
76
79
  " in a separate copy of the repo, and delivers on a branch for review)",
@@ -98,7 +101,7 @@ export function questionnaire() {
98
101
  " These are switches: pass --approve-all or --ask-before-sending with no value. Either way the install",
99
102
  " output prints the exact line written to ~/.codex/config.toml, and pantheon leave removes it.",
100
103
  "",
101
- "Example: pantheon setup --yes --wake anyone --commands yes --name \"Praneet Sinha\" --title Founder \\",
104
+ "Example: pantheon setup --yes --wake approved --commands yes --name \"Praneet Sinha\" --title Founder \\",
102
105
  " --decides deployment --alerts decisions --playbook yes --base main",
103
106
  ].join("\n");
104
107
  }
@@ -120,6 +123,9 @@ export function parseSetupFlags(argv) {
120
123
  const commands = yn("commands");
121
124
  if (commands !== undefined)
122
125
  preset.commands = commands;
126
+ const unsandboxed = yn("unsandboxed");
127
+ if (unsandboxed !== undefined)
128
+ preset.unsandboxed = unsandboxed;
123
129
  const name = val("name");
124
130
  if (name !== undefined)
125
131
  preset.name = name.trim();
@@ -158,6 +164,7 @@ export function parseSetupFlags(argv) {
158
164
  }
159
165
  export async function runGuide(io, ctx, deps = {}, preset = {}) {
160
166
  const optOut = ctx.optOut ?? {};
167
+ const serves = ctx.project ? { path: ctx.project, repos: true } : ctx.serves ?? null;
161
168
  const os = deps.platform ?? process.platform;
162
169
  const mac = os === "darwin";
163
170
  const install = deps.installService ?? realInstall;
@@ -166,29 +173,27 @@ export async function runGuide(io, ctx, deps = {}, preset = {}) {
166
173
  const file = deps.profileFile ?? profilePath();
167
174
  const profile = loadProfile(ctx.cfg, file);
168
175
  const d = { ...guideDefaults(profile, ctx.cfg), ...preset };
176
+ if (profile.agent.allow_bash === undefined && preset.commands === undefined && serves?.repos)
177
+ d.commands = true;
169
178
  const a = { ...d };
170
179
  if (a.wake === "no") {
171
180
  a.commands = false;
172
181
  a.persona = false;
173
182
  }
174
183
  const applied = [], warnings = [];
184
+ const awayAgents = deps.unsandboxedAgents?.() ?? [];
175
185
  if (io) {
176
- io.say("Three questions, then one screen of defaults. Enter keeps the suggested answer.\n");
186
+ io.say("A few questions, then one screen of defaults. Enter keeps the suggested answer.\n");
177
187
  a.name = (await io.ask("Your name, as teammates will see it", d.name)).trim() || d.name;
178
188
  const known = TITLE_OPTIONS.findIndex((t) => t.toLowerCase() === d.title.toLowerCase());
179
- const t = await io.choose("Your role, in a word — it goes on the team directory", TITLE_OPTIONS, known >= 0 ? known : d.title ? TITLE_OPTIONS.length - 1 : 0);
189
+ const t = await io.choose("Your role, in a word — it goes on the team directory", TITLE_OPTIONS, known >= 0 ? known : d.title || !ctx.founder ? TITLE_OPTIONS.length - 1 : 0);
180
190
  a.title = t === TITLE_OPTIONS.length - 1 ? (await io.ask("Your role", d.title)).trim() || d.title || "Member" : TITLE_OPTIONS[t];
181
191
  const askCommands = async () => {
182
192
  const undecided = profile.agent.allow_bash === undefined && preset.commands === undefined;
183
- const c = await io.choose("Teammates can hand work to this machine while you're away. It runs in a separate copy of the repo and delivers on a branch you review. May a run also execute commands?", ["Yes — run tests, commit, and push work for review (recommended)",
193
+ const c = await io.choose("Teammates can hand work to this machine while you're away. It runs in a separate copy of the repo and delivers on a branch you review. May a run also run your tests, commit and push, so the work arrives ready to review?", ["Yes — run tests, commit, and push work for review (recommended)",
184
194
  "No — edit files and answer only; it cannot run tests, commit, or push"], undecided || a.commands ? 0 : 1);
185
195
  a.commands = c === 0;
186
196
  };
187
- let commandsAsked = false;
188
- if (a.wake !== "no" && ctx.project) {
189
- await askCommands();
190
- commandsAsked = true;
191
- }
192
197
  const detected = ctx.project && deps.setRepoBase ? detectBaseBranch(ctx.project) : null;
193
198
  const suggestedBase = detected ? (preset.base ?? detected) : undefined;
194
199
  if (a.wake !== "no" && ctx.project)
@@ -196,9 +201,18 @@ export async function runGuide(io, ctx, deps = {}, preset = {}) {
196
201
  const shortAlert = (id) => ALERT_OPTIONS.find((o) => o.id === id).label.split(" — ")[0].replace(/ \(recommended\)/, "");
197
202
  io.say("\nThe rest has defaults. Change them now, or any time with `pantheon setup`:");
198
203
  io.say(` • Wake: ${a.wake === "no" ? "off — this machine only works while you are at it"
199
- : !ctx.project ? "on once this machine has a repo (run pantheon setup from your project's repo)"
200
- : a.wake === "anyone" ? "teammates may hand work to this machine while you're away; invites you send grant it, and you can revoke anyone (pantheon admit <name> --revoke)"
201
- : "only people you approve by hand (pantheon admit <name> --may-wake)"}`);
204
+ : serves && !serves.repos ? (a.wake === "anyone"
205
+ ? "teammates can ask your worker about this folder while you're away — it reads it, never changes it"
206
+ : "teammates you approve can ask your worker about this folder while you're away — it reads it, never changes it; you are asked the first time each one needs it")
207
+ : !serves ? "nothing is bound to this team on this machine yet, so there is nothing to serve — bind a repo or a docs folder: pantheon use <team> --here"
208
+ : a.wake === "anyone" ? "your teammates can hand work to this machine while you're away, so nothing waits on you (to approve each one first: pantheon setup --wake approved)"
209
+ : "only people you approve — you are asked the first time a teammate's request needs this machine (Always / Just this once / No)"}`);
210
+ if (a.wake !== "no" && serves?.repos) {
211
+ io.say(` • Commands: ${a.commands ? "work runs your tests, commits, and pushes to its own branch, so it arrives ready for your review" : "edits and answers only — turn commands on to have work arrive tested and ready to review"}`);
212
+ if (awayAgents.length && unsandboxedAllowed(loadPolicy(deps.policyHome))) {
213
+ io.say(` • Agent: ${andList(awayAgents)} ${awayAgents.length === 1 ? "answers" : "answer"} teammates' requests while you're away, so nobody waits on you`);
214
+ }
215
+ }
202
216
  io.say(` • Alerts: ${shortAlert(a.notifications).toLowerCase()}`);
203
217
  io.say(` • Approval: ${a.approval === "approve"
204
218
  ? "your agent sends to teammates in your name without asking each time (Claude Code, Cursor, Codex, and Kiro's IDE and pantheon agent; Copilot asks once per tool per folder)"
@@ -220,9 +234,10 @@ export async function runGuide(io, ctx, deps = {}, preset = {}) {
220
234
  a.commands = false;
221
235
  a.persona = false;
222
236
  }
223
- else if (ctx.project && !commandsAsked) {
237
+ else if (serves?.repos) {
224
238
  await askCommands();
225
- a.persona = preset.persona ?? true;
239
+ if (ctx.project)
240
+ a.persona = preset.persona ?? true;
226
241
  }
227
242
  const picks = await io.chooseMany("Which of these do you decide personally? Agents — yours and your teammates' — will stop and ask you before acting on them. Pick any number, or none.", DECISION_AREAS.map((x) => x.label), a.decides.map((id) => DECISION_AREAS.findIndex((x) => x.id === id)).filter((i) => i >= 0));
228
243
  a.decides = [...new Set(picks.map((i) => DECISION_AREAS[i].id))];
@@ -305,22 +320,50 @@ export async function runGuide(io, ctx, deps = {}, preset = {}) {
305
320
  else
306
321
  applied.push("worker stays off");
307
322
  }
308
- else if (ctx.project) {
323
+ else if (serves) {
309
324
  try {
310
- install("worker", ctx.project, a.commands ? ["--allow-bash"] : []);
311
- applied.push(a.commands
312
- ? "worker on — a woken run may run tests and commit and push to its own branch"
313
- : "worker on — a woken run edits files and answers; it cannot commit or push (deliveries will need you)");
325
+ install("worker", serves.path, a.commands ? ["--allow-bash"] : []);
326
+ applied.push(!serves.repos
327
+ ? "worker on — a teammate's question is answered from your folder, which a run reads and never changes; anything it writes is a draft for you"
328
+ : a.commands
329
+ ? "worker on — a woken run may run tests and commit and push to its own branch"
330
+ : "worker on — a woken run edits files and answers; it cannot commit or push (deliveries will need you)");
314
331
  }
315
332
  catch (e) {
316
333
  warnings.push(`the worker service did not install: ${e.message}`);
317
334
  }
318
335
  applied.push(a.wake === "anyone"
319
- ? "wake permission: invites you send grant it (revoke one person: pantheon admit <name> --revoke)"
320
- : "wake permission: only people you grant by hand (pantheon admit <name> --may-wake)");
336
+ ? "wake permission: your teammates can hand you work while you're away, and invites you send include it (to approve each one first: pantheon setup --wake approved)"
337
+ : "wake permission: only people you approve on this machine — you are asked the first time each one needs it (approve ahead: pantheon wake allow <name>)");
321
338
  }
322
339
  else {
323
- applied.push("worker: run this again from your project's repo to switch it on there");
340
+ applied.push(`worker: nothing is bound to "${ctx.cfg.space}" on this machine yet — from the folder you work in (a repo, or a folder of docs): pantheon use ${ctx.cfg.space} --here, then pantheon setup`);
341
+ }
342
+ const names = awayAgents.length ? andList(awayAgents) : "Codex, Cursor and Copilot";
343
+ const one = awayAgents.length === 1;
344
+ if (a.unsandboxed !== undefined && !(a.unsandboxed && a.wake === "no")) {
345
+ if (inWokenRun())
346
+ warnings.push("a woken run cannot change what runs on this machine — a person has to answer this here");
347
+ else {
348
+ try {
349
+ const pol = loadPolicy(deps.policyHome);
350
+ if (a.unsandboxed)
351
+ grant(pol, "unsandboxed_runs", "always", "setup-answer");
352
+ else
353
+ revoke(pol, "unsandboxed_runs");
354
+ savePolicy(pol, deps.policyHome);
355
+ applied.push(a.unsandboxed
356
+ ? `${names} ${one ? "answers" : "answer"} teammates' requests while you're away, so nobody waits on you — to have them wait for you instead: pantheon setup --unsandboxed no`
357
+ : `${names} ${one ? "leaves" : "leave"} teammates' requests for you to pick up when you're back — to have ${names} answer them while you're away: pantheon setup --unsandboxed yes`);
358
+ }
359
+ catch (e) {
360
+ warnings.push(`could not save whether ${names} may take requests while you're away: ${e.message}`);
361
+ }
362
+ }
363
+ }
364
+ else if (awayAgents.length && a.wake !== "no" && serves?.repos) {
365
+ if (unsandboxedAllowed(loadPolicy(deps.policyHome)))
366
+ applied.push(`${names} ${one ? "answers" : "answer"} teammates' requests while you're away, so nobody waits on you — to have them wait for you instead: pantheon setup --unsandboxed no`);
324
367
  }
325
368
  const waitIn = mac ? "pantheon status, approvals, and the menu bar" : "pantheon status and pantheon approvals";
326
369
  const chosenAlerts = ALERT_OPTIONS.find((o) => o.id === a.notifications).label.split(" — ")[0].replace(/ \(recommended\)/, "");
@@ -403,8 +446,25 @@ ${never.map((n) => `- ${n}`).join("\n")}
403
446
  When in doubt, ask the teammate who woke you rather than ${who}; if only ${who} can answer, ESCALATE and finish.
404
447
  `;
405
448
  }
449
+ export function questionOrNull(rl, q) {
450
+ return new Promise((res) => {
451
+ let done = false;
452
+ const onClose = () => { if (!done) {
453
+ done = true;
454
+ res(null);
455
+ } };
456
+ rl.once?.("close", onClose);
457
+ try {
458
+ rl.question(q, (a) => { if (done)
459
+ return; done = true; rl.off?.("close", onClose); res(a.trim()); });
460
+ }
461
+ catch {
462
+ onClose();
463
+ }
464
+ });
465
+ }
406
466
  export function terminalIO(rl, out) {
407
- const ask = (q) => new Promise((res) => rl.question(q, (x) => res(x.trim())));
467
+ const ask = async (q) => (await questionOrNull(rl, q)) ?? "";
408
468
  return {
409
469
  say: out,
410
470
  async ask(question, def) {
package/dist/spend.js CHANGED
@@ -31,9 +31,13 @@ export function since(ms, home) {
31
31
  byPeer[r.triggeredBy] = (byPeer[r.triggeredBy] ?? 0) + 1;
32
32
  return { runs: runs.length, failed: runs.filter((r) => !r.ok).length, byPeer };
33
33
  }
34
- export function recentRuns(ms, home, limit = 12) {
34
+ export function recentRuns(ms, home, limit = 12, only) {
35
35
  const from = Date.now() - ms;
36
- return read(home).runs.filter((r) => r.at > from).sort((a, b) => b.at - a.at).slice(0, limit);
36
+ const teams = (only?.teams ?? []).filter((t) => !!t);
37
+ return read(home).runs
38
+ .filter((r) => r.at > from)
39
+ .filter((r) => !only || (!!r.space && teams.includes(r.space) && (!r.member || r.member === only.member)))
40
+ .sort((a, b) => b.at - a.at).slice(0, limit);
37
41
  }
38
42
  export function allowed(caps, home) {
39
43
  const hour = since(3_600_000, home).runs;
package/dist/store.js CHANGED
@@ -1,22 +1,86 @@
1
- import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "node:fs";
1
+ import { readFileSync, existsSync, mkdirSync, renameSync, openSync, writeSync, fsyncSync, closeSync, chmodSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { emptyRefusals } from "./admin-stats.js";
4
+ export function ensurePrivateDir(dir) {
5
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
6
+ try {
7
+ chmodSync(dir, 0o700);
8
+ }
9
+ catch { }
10
+ }
11
+ export function writeFileAtomic(file, data, mode = 0o600) {
12
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
13
+ const tmp = `${file}.tmp`;
14
+ const fd = openSync(tmp, "w", mode);
15
+ try {
16
+ writeSync(fd, typeof data === "string" ? Buffer.from(data) : data);
17
+ fsyncSync(fd);
18
+ }
19
+ finally {
20
+ closeSync(fd);
21
+ }
22
+ renameRetrying(tmp, file);
23
+ fsyncDir(dirname(file));
24
+ }
25
+ const sleepSync = (ms) => { try {
26
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
27
+ }
28
+ catch { } };
29
+ export function renameRetrying(from, to, o = {}) {
30
+ const platform = o.platform ?? process.platform;
31
+ const rename = o.rename ?? renameSync;
32
+ for (let i = 0;; i++) {
33
+ try {
34
+ rename(from, to);
35
+ return;
36
+ }
37
+ catch (e) {
38
+ if (platform !== "win32" || i >= 20 || !["EPERM", "EACCES", "EBUSY"].includes(e.code ?? ""))
39
+ throw e;
40
+ sleepSync(o.waitMs ?? 10);
41
+ }
42
+ }
43
+ }
44
+ export function fsyncDir(dir) {
45
+ let fd = null;
46
+ try {
47
+ fd = openSync(dir, "r");
48
+ fsyncSync(fd);
49
+ }
50
+ catch { }
51
+ finally {
52
+ if (fd !== null)
53
+ try {
54
+ closeSync(fd);
55
+ }
56
+ catch { }
57
+ }
58
+ }
4
59
  export class StateStore {
5
60
  file;
6
61
  timer = null;
7
62
  dirty = false;
8
63
  refusals = emptyRefusals();
64
+ headOf;
65
+ rebuilt = null;
66
+ extra = {};
9
67
  constructor(dir, space) {
10
68
  this.file = join(dir, `${space}.state.json`);
11
69
  }
12
- load() {
70
+ load(opts = {}) {
13
71
  const goals = new Map();
14
72
  const obligations = new Map();
15
73
  const profiles = new Map();
16
- if (!existsSync(this.file))
17
- return { goals, obligations, profiles };
74
+ if (!existsSync(this.file)) {
75
+ return { goals, obligations, profiles, unclean: opts.expectHistory ? "the state snapshot is missing but the ledger has history" : null };
76
+ }
18
77
  try {
19
78
  const snap = JSON.parse(readFileSync(this.file, "utf8"));
79
+ if (!snap || typeof snap !== "object" || Array.isArray(snap)
80
+ || (snap.goals !== undefined && !Array.isArray(snap.goals))
81
+ || (snap.obligations !== undefined && !Array.isArray(snap.obligations))) {
82
+ throw new Error("not a state snapshot");
83
+ }
20
84
  for (const g of snap.goals ?? [])
21
85
  goals.set(g.id, g);
22
86
  for (const o of snap.obligations ?? []) {
@@ -24,12 +88,32 @@ export class StateStore {
24
88
  }
25
89
  for (const p of snap.profiles ?? [])
26
90
  profiles.set(p.member, p);
91
+ const known = new Set(["v", "goals", "obligations", "profiles", "refusals", "ledger_head", "rebuilt", "saved_at"]);
92
+ this.extra = {};
93
+ for (const [k, v] of Object.entries(snap))
94
+ if (!known.has(k))
95
+ this.extra[k] = v;
27
96
  if (snap.refusals)
28
97
  this.refusals = { total: snap.refusals.total ?? 0, by_code: snap.refusals.by_code ?? {}, recent: snap.refusals.recent ?? [], stamps: snap.refusals.stamps ?? [] };
98
+ return {
99
+ goals, obligations, profiles, unclean: null,
100
+ ...(typeof snap.ledger_head === "number" && Number.isFinite(snap.ledger_head) ? { ledgerHead: snap.ledger_head } : {}),
101
+ ...(typeof snap.rebuilt === "string" && snap.rebuilt ? { rebuilt: snap.rebuilt } : {}),
102
+ };
29
103
  }
30
- catch {
104
+ catch (e) {
105
+ const why = `the state snapshot could not be read (${e.message})`;
106
+ let quarantined;
107
+ if (opts.quarantine) {
108
+ const aside = `${this.file}.corrupt-${Date.now()}`;
109
+ try {
110
+ renameSync(this.file, aside);
111
+ quarantined = aside;
112
+ }
113
+ catch { }
114
+ }
115
+ return { goals: new Map(), obligations: new Map(), profiles: new Map(), unclean: why, ...(quarantined ? { quarantined } : {}) };
31
116
  }
32
- return { goals, obligations, profiles };
33
117
  }
34
118
  save(goals, obligations, profiles) {
35
119
  this.dirty = true;
@@ -52,17 +136,17 @@ export class StateStore {
52
136
  onError;
53
137
  flush(goals, obligations, profiles) {
54
138
  const snap = {
139
+ ...this.extra,
55
140
  v: 2,
56
141
  goals: [...goals.values()],
57
142
  obligations: [...obligations.values()],
58
143
  profiles: [...profiles.values()],
59
144
  ...(this.refusals.total ? { refusals: this.refusals } : {}),
145
+ ...(this.headOf ? { ledger_head: this.headOf() } : {}),
146
+ ...(this.rebuilt ? { rebuilt: this.rebuilt } : {}),
60
147
  saved_at: Date.now(),
61
148
  };
62
- mkdirSync(dirname(this.file), { recursive: true });
63
- const tmp = `${this.file}.tmp`;
64
- writeFileSync(tmp, JSON.stringify(snap, null, 2) + "\n", { mode: 0o600 });
65
- renameSync(tmp, this.file);
149
+ writeFileAtomic(this.file, JSON.stringify(snap, null, 2) + "\n");
66
150
  }
67
151
  close(goals, obligations, profiles) {
68
152
  if (this.timer) {
@@ -0,0 +1,74 @@
1
+ export const stripBom = (text) => (text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
2
+ export const parseJsonText = (text) => JSON.parse(stripBom(text));
3
+ export function stripJsonc(text) {
4
+ let out = "";
5
+ let i = 0;
6
+ const n = text.length;
7
+ while (i < n) {
8
+ const ch = text[i];
9
+ if (ch === '"') {
10
+ let j = i + 1;
11
+ while (j < n && text[j] !== '"')
12
+ j += text[j] === "\\" ? 2 : 1;
13
+ out += text.slice(i, j + 1);
14
+ i = j + 1;
15
+ continue;
16
+ }
17
+ if (ch === "/" && text[i + 1] === "/") {
18
+ while (i < n && text[i] !== "\n")
19
+ i++;
20
+ continue;
21
+ }
22
+ if (ch === "/" && text[i + 1] === "*") {
23
+ const end = text.indexOf("*/", i + 2);
24
+ i = end < 0 ? n : end + 2;
25
+ continue;
26
+ }
27
+ if (ch === ",") {
28
+ let j = i + 1;
29
+ for (;;) {
30
+ while (j < n && /\s/.test(text[j]))
31
+ j++;
32
+ if (text[j] === "/" && text[j + 1] === "/") {
33
+ while (j < n && text[j] !== "\n")
34
+ j++;
35
+ continue;
36
+ }
37
+ if (text[j] === "/" && text[j + 1] === "*") {
38
+ const e = text.indexOf("*/", j + 2);
39
+ j = e < 0 ? n : e + 2;
40
+ continue;
41
+ }
42
+ break;
43
+ }
44
+ if (text[j] === "}" || text[j] === "]") {
45
+ i++;
46
+ continue;
47
+ }
48
+ }
49
+ out += ch;
50
+ i++;
51
+ }
52
+ return out;
53
+ }
54
+ export function isJsonc(text) {
55
+ const t = stripBom(text);
56
+ try {
57
+ JSON.parse(t);
58
+ return false;
59
+ }
60
+ catch { }
61
+ try {
62
+ JSON.parse(stripJsonc(t));
63
+ return true;
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ }
69
+ export function eolOf(text) {
70
+ const i = text.indexOf("\n");
71
+ return i > 0 && text[i - 1] === "\r" ? "\r\n" : "\n";
72
+ }
73
+ export const withEol = (text, eol) => eol === "\n" ? text : text.replace(/\r?\n/g, "\r\n");
74
+ //# sourceMappingURL=text-file.js.map
package/dist/tray.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { loadPolicy } from "./worker-policy.js";
1
2
  import { execFileSync } from "node:child_process";
2
3
  import { helperStale, recordHelperBuild } from "./helpers-build.js";
3
4
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -11,6 +12,7 @@ import { requireDeveloperTools } from "./notifier.js";
11
12
  import { plistPath, serviceInstalled, whichPantheon } from "./service.js";
12
13
  import { VERSION } from "./version.js";
13
14
  import { LIMITS } from "./protocol.js";
15
+ import { signedRead } from "./reads.js";
14
16
  import { allDecisionsPrompt, decisionPrompt, decisionTitle, owedPrompt, stripCeremony, waitingPrompt, workerSummary } from "./decision-ui.js";
15
17
  import { loadProfile, SURFACE_BUNDLE_IDS } from "./profile.js";
16
18
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -50,6 +52,16 @@ export function writeTrayCommands(home = CONFIG_DIR) {
50
52
  }
51
53
  return out;
52
54
  }
55
+ export function localChoices(ref) {
56
+ if (!ref)
57
+ return null;
58
+ const pol = loadPolicy();
59
+ if (pol.wakeAsks?.[ref])
60
+ return [{ label: "Always", answer: "allow always" }, { label: "Just this once", answer: "allow once" }, { label: "No", answer: "deny" }];
61
+ if (Object.values(pol.asks).some((a) => a?.act === ref))
62
+ return [{ label: "Allow always", answer: "allow always" }, { label: "Allow once", answer: "allow once" }, { label: "Don't allow", answer: "deny" }];
63
+ return null;
64
+ }
53
65
  function workerState(home = CONFIG_DIR) {
54
66
  try {
55
67
  const raw = JSON.parse(readFileSync(join(home, "worker-state.json"), "utf8"));
@@ -75,6 +87,15 @@ async function getJson(url) {
75
87
  clearTimeout(t);
76
88
  }
77
89
  }
90
+ async function signedObligations(cfg) {
91
+ try {
92
+ const res = await signedRead(cfg, "/obligations", {}, { timeoutMs: LIMITS.pendingTimeoutMs });
93
+ return res.ok ? await res.json() : null;
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ }
78
99
  export async function trayStatus(cfg = load(), f = loadAll()) {
79
100
  const ms = Object.values(f?.spaces ?? {});
80
101
  if (f && ms.length > 1) {
@@ -105,10 +126,9 @@ async function trayStatusOne(cfg) {
105
126
  if (!cfg)
106
127
  return { configured: false, version: VERSION };
107
128
  const base = cfg.relay.replace(/^ws/, "http").replace(/\/+$/, "");
108
- const q = `space=${encodeURIComponent(cfg.space)}&token=${encodeURIComponent(cfg.token)}`;
109
129
  const [health, obligations] = await Promise.all([
110
130
  getJson(`${base}/health?space=${encodeURIComponent(cfg.space)}`),
111
- getJson(`${base}/obligations?${q}`),
131
+ signedObligations(cfg),
112
132
  ]);
113
133
  const space = health
114
134
  ?.spaces?.find((s) => s.name === cfg.space);
@@ -131,7 +151,8 @@ async function trayStatusOne(cfg) {
131
151
  const items = [
132
152
  ...mineHuman.map((o) => {
133
153
  const from = senderOf(o);
134
- return { ...row(o, "decision", !from || from === cfg.member ? "your agent" : from, decisionPrompt(o.id, decisionTitle(o.body))), decider: cfg.member };
154
+ const choices = localChoices(o.ref);
155
+ return { ...row(o, "decision", !from || from === cfg.member ? "your agent" : from, decisionPrompt(o.id, decisionTitle(o.body))), decider: cfg.member, ...(choices ? { choices } : {}) };
135
156
  }),
136
157
  ...owed.map((o) => row(o, "owed", "you", owedPrompt(decisionTitle(o.body), o.goal))),
137
158
  ...waiting.map((o) => {
package/dist/untrusted.js CHANGED
@@ -1,16 +1,35 @@
1
+ const SECRET_NOUN = String.raw `(?:passwords?|passphrases?|pass ?codes?|pins?|secrets?|secret\s*keys?|(?:signing|webhook|client|app|master|root|encryption) (?:secrets?|keys?)|key_secret|secret_key|client_secret|access tokens?|personal access tokens?|session (?:cookies?|ids?|tokens?)|cookies?|dsns?|a-?p-?i[_ -]?keys?|private[_ -]?keys?|ssh[_ -]?keys?|deploy keys?|keys? for [\w.-]+|credentials?|creds|tokens?|otps?|one[- ]time (?:codes?|passwords?)|seed phrases?|recovery codes?|2fa codes?|clave(?: api)?|contraseñas?|passwort|mot de passe|senha)`;
2
+ const VALUE = String.raw `(?:\b(?:your|the|our|my|its|this|that|their|his|her|apna|apni)\s+(?:[\w'-]+\s+){0,3}${SECRET_NOUN}\b|\b[A-Z][A-Z0-9_]*_(?:KEY|SECRET|TOKEN|PASSWORD|PASS|CREDENTIALS?|URL|DSN|URI)\b|\b(?:TOKEN|SECRET|PASSWORD|API_KEY)\b|\$[A-Z_]{3,}|process\.env\.\w+|~\/\.(?:aws|ssh|config|netrc|npmrc|pantheon|docker|kube)\b|\.env\b|id_[er]sa|\.pem\b|\bcredentials file\b)`;
3
+ const HAND_OVER = String.raw `(?:bhej(?:o| do| dena)?|de do|dedo|batao|bata do|dikhao|paste karo|share karo|what(?:'s| is| are)|tell(?: me)?|let (?:me|us) know|give(?: me)?|send(?: me| over| it)?|share|paste|reveal|print|show|dump|echo|cat|read(?: out)?|include|put|drop|post|copy|grab|fetch|get|commit|push|upload|e-?mail|dm|reply with|respond with|need|type|enter|use|using|provide|leak|export)`;
4
+ const SECRET_ACTION = new RegExp(String.raw `\b${HAND_OVER}\b[^\n.?!]{0,60}${VALUE}|${VALUE}[^\n.?!]{0,40}\b(?:in|into|to|should go in|goes in|belongs in)\b[^\n.?!]{0,12}\b(?:reply|answer|message|response|output|commit|branch|repo|file|pr|pull request|issue|thread|description|chat|comment|gist|paste)\b|\b(?:its|the) value\b|\bvalue of (?:the |your )?${SECRET_NOUN}|${VALUE}[^\n?!]{0,40}\b(?:send(?: it)? over|send it|share it|paste it|give it|need it|let me know|bhej(?:o| do)?|de do|batao|dikhao|karo|kar do)\b|${SECRET_NOUN}[\s\S]{0,200}\b(?:print|paste|send|share|show|dump|echo|reveal|include|post|email|bhej(?:o| do)?)\s+(?:it|them|that|this|those)\b|\b(?:create|generate|make|set up|register|mint|issue)\b\s+(?:(?:a|an|the|your|new|brand-new|fresh|another)\s+)*(?:[\w-]+\s+){0,4}${SECRET_NOUN}\b(?!\s+(?:field|flow|screen|page|form|meter|policy|reset|rotation|storage|validation|check|rule|input|button|endpoint|api|feature|generator|manager|strength|expiry|hashing|column|table|model|schema|test|tests|ui|modal))`, "i");
5
+ const SIGNIN_ACTION = /(^|[.!?:;]\s*|\n\s*|\b(?:please|you|can you|could you|would you|go and|go|then|and|now|first|also)\s+)(sign in|sign into|log ?in(?:to)?|authenticate|create an account|join .{0,20}hub)(?:\s+(?:to|into|at|here|there|as|against|on)\b|\s*[.!]|$)|\b(?:visit|open|go to)\b[^\n]{0,40}https?:\/\/|\b(?:with|using) (?:the|our|my|your|an?|this) [\w -]{0,20}(?:account|login|creds|credentials|password)\b|\b(?:gh|npm|docker|aws|gcloud|az|vercel|railway|heroku|firebase) (?:auth )?login\b|(?:^|[.!?:;]\s*|\n\s*)(?:log ?into|sign into|log in to|sign in to)\b(?![^\n.?!]{0,50}\b(?:fails?|failing|broken|error|doesn'?t|not work|is slow))/im;
6
+ const ABOUT_PRODUCT = /\?|\b(?:why|how come|does|do|did|is|are|was|were|where|when|which)\b[^\n]{0,80}\b(?:fail\w*|break\w*|broken|work\w*|return\w*|call\w*|read\w*|stor\w*|handl\w*|show\w*|happen\w*|arriv\w*|expire\w*)|\b(?:fails?|failing|failed|broken|bug|error|errors|issue|crash\w*|doesn'?t work|not working|never arrives?|stopped working|is slow|can'?t|cannot|unable to|won'?t|isn'?t|aren'?t|expires? too|too early|too soon|times? out|timed out)\b/i;
7
+ const WORK_ON_PRODUCT = /(^|[.!?:;]\s*|\n\s*|\b(?:please|can you|could you|let's|we need to|we should)\s+)(?:add|implement|build|create|design|refactor|migrate|move|document|support|fix|update|improve|remove|rename|review|explain|describe|test|write tests for|validate|hash|encrypt|rotate|mask|hide|redact|make)\b/im;
8
+ const NOT_ENGLISH = /[^\x00-\x7F‐-‟… -ÿ]|\b(?:envíame|dame|mándame|schick|envoie|donne-moi|manda)\b/i;
9
+ function secretStops(body) {
10
+ if (SECRET_ACTION.test(body) || SIGNIN_ACTION.test(body))
11
+ return true;
12
+ if (NOT_ENGLISH.test(body) && new RegExp(SECRET_NOUN, "i").test(body))
13
+ return true;
14
+ return !(ABOUT_PRODUCT.test(body) || WORK_ON_PRODUCT.test(body));
15
+ }
1
16
  const PATTERNS = [
2
17
  { kind: "install", why: "asks you to install software", re: /\b(npm|pnpm|yarn)\s+(i|install|add)\s+(-g|--global)/i },
3
18
  { kind: "install", why: "asks you to install software", re: /\b(brew|apt-get|apt|pip3?|gem)\s+install\b/i },
4
19
  { kind: "pipe-to-shell", why: "pipes a download straight into a shell", re: /\bcurl\b[^\n|]*\|\s*(ba)?sh\b/i },
5
20
  { kind: "pipe-to-shell", why: "pipes a download straight into a shell", re: /\bwget\b[^\n|]*\|\s*(ba)?sh\b/i },
6
- { kind: "credential", why: "asks for a password, token or key", re: /\b(password|passphrase|secret\s*key|api[_ -]?key|private[_ -]?key|credential)s?\b/i },
7
- { kind: "credential", why: "names an env var that usually holds a secret", re: /\b[A-Z_]*(TOKEN|SECRET|PASSWORD|API_KEY)\b/ },
21
+ { kind: "credential", why: "asks for a password, token or key", re: new RegExp(String.raw `\b(?:password|passphrase|secret\s*key|signing secret|api[_ -]?key|a-p-i key|private[_ -]?key|ssh[_ -]?key|credential|creds|otp|seed phrase|recovery code|clave|contraseña|passwort|mot de passe|secret|token|cookie|dsn|(?:signing|webhook|client|app|master|root|encryption) key|key_secret|secret_key|client_secret)s?\b|process\.env\.\w+|\.env\b|~\/\.aws\b|\bthe (?:\w+ )?(?:secret|token|key for \w+)\b`, "i"), stopWhen: secretStops },
22
+ { kind: "credential", why: "names an env var that usually holds a secret", re: /\b[A-Z][A-Z0-9_]*(TOKEN|SECRET|PASSWORD|API_KEY|_KEY|_PASS|_CREDENTIALS?)\b|\b(TOKEN|SECRET|PASSWORD|API_KEY)\b/, stopWhen: secretStops },
8
23
  { kind: "exfiltration", why: "asks you to send file contents somewhere", re: /\b(cat|base64)\b[^\n]{0,40}\b(curl|POST|upload|send)\b/i },
9
24
  { kind: "exfiltration", why: "asks you to reveal the contents of a sensitive file",
10
- re: /\b(paste|show|print|read|send|share|output|dump)\b[^\n]{0,60}(~\/\.\w+|\.env\b|config\.json|id_[er]sa|\.ssh\/|credentials?\b|\.pem\b)/i },
25
+ re: /\b(paste|show|print|read|send|share|output|dump)\b[^\n]{0,60}(~\/\.\w+|\.env\b|config\.json|id_[er]sa|\.ssh\/|\/credentials?\b|credentials? file|\.pem\b)/i },
26
+ { kind: "exfiltration", why: "asks you to read out a file that holds secrets",
27
+ re: /\b(cat|type|less|more|head|tail|open|read|print|dump|include|paste|show|copy|send|upload|grep|base64)\b[^\n]{0,40}(~\/\.(aws|ssh|config|netrc|npmrc|docker|kube|pantheon|gnupg)\b|\.env\b|id_[er]sa|\.pem\b|\/credentials\b|\.git-credentials|google-services\.json|GoogleService-Info\.plist|service-account[\w.-]*\.json|\.p12\b|\.keystore\b|\.jks\b)/i },
28
+ { kind: "exfiltration", why: "asks you to dump the environment", re: /\b(printenv|env\s*\||export -p|set\s*\|)|`env`|\bprocess\.env\b[^\n]{0,20}\b(print|log|dump|send|paste|json)/i },
29
+ { kind: "exfiltration", why: "asks you to send something to an outside address", re: /\b(e-?mail|send|upload|post|forward|share|mail)\b[^\n]{0,80}\b(to|at)\s+(?:[\w.+-]+@[\w-]+\.[\w.-]+|https?:\/\/)/i },
11
30
  { kind: "exfiltration", why: "names a file that usually holds secrets",
12
31
  re: /(~\/\.pantheon\/config\.json|~\/\.ssh\/|\.env\.(local|production)|id_rsa\b)/i },
13
- { kind: "identity", why: "asks you to sign in or create an account", re: /\b(sign in|log ?in|authenticate|create an account|join .{0,20}hub)\b/i },
32
+ { kind: "identity", why: "asks you to sign in or create an account", re: /\b(sign in|sign into|log ?in(?:to)?|authenticate|create an account|join .{0,20}hub|auth login)\b/i, stopWhen: secretStops },
14
33
  { kind: "override", why: "tries to override the instructions you were given", re: /\b(ignore|disregard|forget)\b[^\n]{0,30}\b(previous|prior|earlier|above|your)\b[^\n]{0,20}\b(instruction|prompt|rule|direction)s?\b/i },
15
34
  { kind: "override", why: "claims a new operator or system directive", re: /\b(system|operator|admin)\s*(:|message|override|instruction)/i },
16
35
  ];
@@ -34,7 +53,7 @@ export function assess(body) {
34
53
  if (seen.has(key))
35
54
  continue;
36
55
  seen.add(key);
37
- out.push({ kind: p.kind, why: p.why, evidence: hit[0].slice(0, 80) });
56
+ out.push({ kind: p.kind, why: p.why, evidence: hit[0].slice(0, 80), stop: p.stopWhen ? p.stopWhen(body) : true });
38
57
  }
39
58
  return out;
40
59
  }
@@ -20,6 +20,8 @@ export function updateStatus(deps) {
20
20
  if (cache && deps.now() - cache.checkedAt < deps.throttleMs) {
21
21
  return { behind: cache.behind, refreshed: false, latest: cache.latest };
22
22
  }
23
+ if (process.env["PANTHEON_NO_UPDATE_CHECK"])
24
+ return { behind: cache?.behind ?? 0, refreshed: false, latest: cache?.latest };
23
25
  if (deps.isGit()) {
24
26
  try {
25
27
  const behind = deps.fetchBehind();