@join-pantheon/cli 0.29.1 → 0.30.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 (46) hide show
  1. package/README.md +45 -22
  2. package/bin/pantheon.js +12 -0
  3. package/dist/accounts.js +151 -26
  4. package/dist/agents.js +197 -14
  5. package/dist/claude-confine.js +283 -0
  6. package/dist/cli.js +725 -120
  7. package/dist/client.js +37 -2
  8. package/dist/command-state.js +79 -0
  9. package/dist/config.js +211 -10
  10. package/dist/doctor.js +103 -13
  11. package/dist/grants.js +1 -1
  12. package/dist/history-view.js +2 -1
  13. package/dist/home-guard.js +210 -0
  14. package/dist/hooks-install.js +2 -1
  15. package/dist/identity.js +24 -0
  16. package/dist/install.js +76 -26
  17. package/dist/kiro-files.js +2 -2
  18. package/dist/ledger.js +111 -28
  19. package/dist/machine.js +494 -32
  20. package/dist/mcp-supervisor.js +25 -6
  21. package/dist/mcp-windows.js +47 -25
  22. package/dist/mcp.js +25 -10
  23. package/dist/network.js +39 -8
  24. package/dist/notify.js +17 -6
  25. package/dist/pending.js +8 -14
  26. package/dist/persona.js +21 -0
  27. package/dist/profile.js +4 -1
  28. package/dist/quickstart.js +82 -14
  29. package/dist/reads.js +30 -0
  30. package/dist/refusal.js +14 -1
  31. package/dist/relay.js +1143 -180
  32. package/dist/roster-file.js +169 -23
  33. package/dist/run-failure.js +83 -3
  34. package/dist/secret-guard.js +141 -0
  35. package/dist/service.js +84 -34
  36. package/dist/setup-guide.js +28 -9
  37. package/dist/spend.js +6 -2
  38. package/dist/store.js +94 -10
  39. package/dist/text-file.js +74 -0
  40. package/dist/tray.js +11 -2
  41. package/dist/update-check.js +2 -0
  42. package/dist/which.js +69 -12
  43. package/dist/worker-policy.js +137 -3
  44. package/dist/worker.js +702 -81
  45. package/helper/macos/menubar.swift +15 -1
  46. package/package.json +2 -2
@@ -1,8 +1,19 @@
1
- import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "node:fs";
1
+ import { readFileSync, existsSync } from "node:fs";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { createHash, timingSafeEqual } from "node:crypto";
4
- import { dirname, join } from "node:path";
4
+ import { join } from "node:path";
5
5
  import { fingerprint } from "./identity.js";
6
+ import { writeFileAtomic } from "./store.js";
7
+ export class RosterUnreadable extends Error {
8
+ file;
9
+ constructor(file, detail) {
10
+ super(`the member list ${file} cannot be read (${detail})`);
11
+ this.file = file;
12
+ this.name = "RosterUnreadable";
13
+ }
14
+ }
15
+ export const PENDING_CAP = 50;
16
+ export const PENDING_TTL_MS = 7 * 24 * 3_600_000;
6
17
  function hashToken(token) {
7
18
  return createHash("sha256").update(token).digest("hex");
8
19
  }
@@ -10,35 +21,109 @@ export class Allowlist {
10
21
  entries = new Map();
11
22
  invites = new Map();
12
23
  tokenHash = null;
24
+ retiredHashes = [];
25
+ removedAt = null;
13
26
  id = null;
27
+ founderName = null;
28
+ founderGuessed = null;
29
+ name = null;
30
+ extra = {};
14
31
  repos = new Map();
15
32
  file;
16
33
  constructor(dir, space) {
17
34
  this.file = join(dir, `${space}.members.json`);
18
35
  if (existsSync(this.file)) {
19
- const raw = JSON.parse(readFileSync(this.file, "utf8"));
20
- const doc = Array.isArray(raw) ? { members: raw, invites: [] } : raw;
21
- for (const e of doc.members ?? [])
22
- this.entries.set(e.member, e);
36
+ let raw;
37
+ try {
38
+ raw = JSON.parse(readFileSync(this.file, "utf8"));
39
+ }
40
+ catch (e) {
41
+ throw new RosterUnreadable(this.file, e.message);
42
+ }
43
+ if (!raw || typeof raw !== "object")
44
+ throw new RosterUnreadable(this.file, "not a member list");
45
+ const doc = (Array.isArray(raw) ? { members: raw, invites: [] } : raw);
46
+ if ((doc.members !== undefined && !Array.isArray(doc.members)) || (doc.invites !== undefined && !Array.isArray(doc.invites))) {
47
+ throw new RosterUnreadable(this.file, "not a member list");
48
+ }
23
49
  for (const i of doc.invites ?? [])
24
50
  this.invites.set(i.token, i);
51
+ let legacyRemoval = false;
52
+ for (const e of doc.members ?? []) {
53
+ if (!e.admitted && e.admitted_by && !e.revoked_at) {
54
+ e.revoked_at = Date.now();
55
+ e.may_wake = false;
56
+ legacyRemoval = true;
57
+ }
58
+ this.entries.set(e.member, e);
59
+ }
60
+ if (legacyRemoval) {
61
+ for (const e of this.entries.values())
62
+ if (e.revoked_at && !e.admitted)
63
+ this.cancelInvitesBy(e.member);
64
+ }
25
65
  this.tokenHash = doc.tokenHash ?? null;
66
+ if ("retiredTokenHashes" in doc && Array.isArray(doc.retiredTokenHashes))
67
+ this.retiredHashes = doc.retiredTokenHashes.filter((h) => typeof h === "string");
68
+ if ("removed_at" in doc && typeof doc.removed_at === "number")
69
+ this.removedAt = doc.removed_at;
70
+ if (legacyRemoval && this.removedAt === null)
71
+ this.removedAt = Date.now();
72
+ const known = new Set(["members", "invites", "tokenHash", "retiredTokenHashes", "removed_at", "id", "name", "repos", "founder", "founder_guessed"]);
73
+ if (!Array.isArray(raw))
74
+ for (const [k, v] of Object.entries(raw))
75
+ if (!known.has(k))
76
+ this.extra[k] = v;
26
77
  this.id = doc.id ?? null;
78
+ this.name = typeof doc.name === "string" && doc.name ? doc.name : null;
27
79
  for (const r of doc.repos ?? [])
28
80
  this.repos.set(r.fingerprint, r);
81
+ if (typeof doc.founder === "string" && doc.founder) {
82
+ this.founderName = doc.founder;
83
+ const g = raw.founder_guessed;
84
+ if (typeof g === "string" && g)
85
+ this.founderGuessed = g;
86
+ }
87
+ else if (this.entries.size) {
88
+ const d = Allowlist.deriveFounderHow([...this.entries.values()]);
89
+ this.founderName = d?.member ?? null;
90
+ if (d?.how === "earliest")
91
+ this.founderGuessed = "no member was admitted by themselves, so the earliest member to connect was taken";
92
+ if (this.founderName) {
93
+ try {
94
+ this.flush();
95
+ }
96
+ catch { }
97
+ }
98
+ }
29
99
  }
30
100
  }
101
+ static deriveFounder(entries) {
102
+ return Allowlist.deriveFounderHow(entries)?.member ?? null;
103
+ }
104
+ static deriveFounderHow(entries) {
105
+ const byAge = [...entries].sort((a, b) => a.first_seen - b.first_seen);
106
+ const self = byAge.find((e) => e.admitted && (e.admitted_by === e.member || e.admitted_by === undefined));
107
+ if (self)
108
+ return { member: self.member, how: "self" };
109
+ const m = byAge.find((e) => e.admitted_by !== undefined || e.admitted)?.member;
110
+ return m ? { member: m, how: "earliest" } : null;
111
+ }
112
+ founderGuess() { return this.founderGuessed; }
31
113
  flush() {
32
- mkdirSync(dirname(this.file), { recursive: true });
33
- const tmp = `${this.file}.tmp`;
34
- writeFileSync(tmp, JSON.stringify({
114
+ writeFileAtomic(this.file, JSON.stringify({
115
+ ...this.extra,
35
116
  ...(this.id ? { id: this.id } : {}),
117
+ ...(this.founderName ? { founder: this.founderName } : {}),
118
+ ...(this.founderName && this.founderGuessed ? { founder_guessed: this.founderGuessed } : {}),
119
+ ...(this.name ? { name: this.name } : {}),
36
120
  members: [...this.entries.values()],
37
121
  invites: [...this.invites.values()],
38
122
  ...(this.tokenHash ? { tokenHash: this.tokenHash } : {}),
123
+ ...(this.retiredHashes.length ? { retiredTokenHashes: this.retiredHashes } : {}),
124
+ ...(this.removedAt !== null ? { removed_at: this.removedAt } : {}),
39
125
  ...(this.repos.size ? { repos: [...this.repos.values()] } : {}),
40
- }, null, 2) + "\n", { mode: 0o600 });
41
- renameSync(tmp, this.file);
126
+ }, null, 2) + "\n");
42
127
  }
43
128
  peekId() { return this.id; }
44
129
  spaceId() {
@@ -48,6 +133,13 @@ export class Allowlist {
48
133
  }
49
134
  return this.id;
50
135
  }
136
+ recordedName() { return this.name; }
137
+ recordName(name) {
138
+ if (this.name || !name)
139
+ return;
140
+ this.name = name;
141
+ this.flush();
142
+ }
51
143
  repoList() { return [...this.repos.values()]; }
52
144
  repoCount() { return this.repos.size; }
53
145
  hasRepo(fingerprint) { return this.repos.has(fingerprint); }
@@ -80,6 +172,42 @@ export class Allowlist {
80
172
  this.tokenHash = h;
81
173
  this.flush();
82
174
  }
175
+ tokenRetired(token) {
176
+ if (!token)
177
+ return false;
178
+ const a = Buffer.from(hashToken(token), "hex");
179
+ return this.retiredHashes.some((h) => { const b = Buffer.from(h, "hex"); return a.length === b.length && timingSafeEqual(a, b); });
180
+ }
181
+ tokenHashes() {
182
+ return [...(this.tokenHash ? [this.tokenHash] : []), ...this.retiredHashes];
183
+ }
184
+ static RETIRED_KEPT = 64;
185
+ rotateToken(next) {
186
+ if (this.tokenHash) {
187
+ this.retiredHashes.push(this.tokenHash);
188
+ if (this.retiredHashes.length > Allowlist.RETIRED_KEPT)
189
+ this.retiredHashes = this.retiredHashes.slice(-Allowlist.RETIRED_KEPT);
190
+ }
191
+ this.tokenHash = hashToken(next);
192
+ for (const i of this.invites.values())
193
+ if (!i.used_by && i.space_token)
194
+ i.space_token = next;
195
+ this.flush();
196
+ }
197
+ currentTokenFromInvites() {
198
+ for (const i of this.invites.values()) {
199
+ if (i.space_token && this.tokenMatches(i.space_token) === true)
200
+ return i.space_token;
201
+ }
202
+ return null;
203
+ }
204
+ noteRemoval(at = Date.now()) {
205
+ this.removedAt = at;
206
+ this.flush();
207
+ }
208
+ removalRecorded() {
209
+ return this.removedAt !== null || [...this.entries.values()].some((e) => !!e.revoked_at);
210
+ }
83
211
  get hasToken() {
84
212
  return this.tokenHash !== null;
85
213
  }
@@ -108,18 +236,17 @@ export class Allowlist {
108
236
  if (existing && existing.publicKey !== publicKey) {
109
237
  return {
110
238
  ok: false,
111
- why: `"${member}" is registered here with a different key, so this machine cannot claim that name`,
239
+ why: `"${member}" is registered here with a different key — the name is taken in this team`,
112
240
  fix: [
113
- `If you still have the original ~/.pantheon/config.json, restore it — the key is in there and nothing else needs to change.`,
114
- ` Otherwise an admitted member retires the old key, after checking your new fingerprint with you out of band:`,
115
- ` pantheon admit ${member} --forget`,
116
- ` Then redeem this same invite again; it is still unused.`,
117
- ` Joining as --me <another-name> also works, but you would leave behind every goal and obligation held by "${member}".`,
241
+ `If you are not "${member}", join under your own name: --me <your-name> (this same invite is still unused).`,
242
+ ` If you ARE "${member}" on a new machine: restore your original ~/.pantheon/config.json if you still have it — the key is in it.`,
243
+ ` Failing that, a teammate who has confirmed it is you retires the old key (this removes whoever holds "${member}" now): pantheon admit ${member} --forget`,
244
+ ` Then redeem this same invite again.`,
118
245
  ].join("\n"),
119
246
  };
120
247
  }
121
248
  const entry = {
122
- member, publicKey, admitted: true, admitted_by: inv.created_by,
249
+ member, publicKey, admitted: true, admitted_by: this.founderName === member ? member : inv.created_by,
123
250
  first_seen: existing?.first_seen ?? Date.now(), may_wake: inv.may_wake,
124
251
  };
125
252
  this.entries.set(member, entry);
@@ -168,9 +295,9 @@ export class Allowlist {
168
295
  return true;
169
296
  }
170
297
  admittedCount() { return [...this.entries.values()].filter((e) => e.admitted).length; }
298
+ pendingCount() { return [...this.entries.values()].filter((e) => !e.admitted && !e.revoked_at && !e.admitted_by).length; }
171
299
  founder() {
172
- const f = [...this.entries.values()].filter((e) => e.admitted && (e.admitted_by === e.member || e.admitted_by === undefined)).sort((a, b) => a.first_seen - b.first_seen)[0];
173
- return f?.member ?? null;
300
+ return this.founderName;
174
301
  }
175
302
  get(member) {
176
303
  return this.entries.get(member);
@@ -181,15 +308,30 @@ export class Allowlist {
181
308
  get empty() {
182
309
  return this.entries.size === 0;
183
310
  }
184
- present(member, publicKey) {
311
+ present(member, publicKey, now = Date.now()) {
185
312
  const cur = this.entries.get(member);
186
313
  if (!cur) {
187
314
  const founder = this.empty;
315
+ if (!founder) {
316
+ let expired = false;
317
+ for (const [k, e] of this.entries) {
318
+ if (!e.admitted && !e.revoked_at && !e.admitted_by && now - e.first_seen > PENDING_TTL_MS) {
319
+ this.entries.delete(k);
320
+ expired = true;
321
+ }
322
+ }
323
+ if (expired)
324
+ this.flush();
325
+ if (this.pendingCount() >= PENDING_CAP)
326
+ return "pending-full";
327
+ }
188
328
  this.entries.set(member, {
189
329
  member, publicKey, admitted: founder,
190
330
  ...(founder ? { admitted_by: member } : {}),
191
331
  first_seen: Date.now(), may_wake: founder,
192
332
  });
333
+ if (founder)
334
+ this.founderName = member;
193
335
  this.flush();
194
336
  return founder ? "founder" : "new";
195
337
  }
@@ -201,8 +343,9 @@ export class Allowlist {
201
343
  const e = this.entries.get(member);
202
344
  if (!e)
203
345
  return null;
346
+ if (!e.admitted || e.admitted_by === undefined)
347
+ e.admitted_by = by;
204
348
  e.admitted = true;
205
- e.admitted_by = by;
206
349
  e.may_wake = mayWake;
207
350
  delete e.revoked_at;
208
351
  delete e.revoked_by;
@@ -221,8 +364,10 @@ export class Allowlist {
221
364
  forget(member) {
222
365
  const gone = this.entries.delete(member);
223
366
  this.cancelInvitesBy(member);
224
- if (gone)
367
+ if (gone) {
368
+ this.removedAt = Date.now();
225
369
  this.flush();
370
+ }
226
371
  return gone;
227
372
  }
228
373
  revoke(member, by) {
@@ -232,6 +377,7 @@ export class Allowlist {
232
377
  e.admitted = false;
233
378
  e.may_wake = false;
234
379
  e.revoked_at = Date.now();
380
+ this.removedAt = e.revoked_at;
235
381
  if (by)
236
382
  e.revoked_by = by;
237
383
  this.cancelInvitesBy(member);
@@ -1,4 +1,6 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import { checkRepoCommand } from "./agents.js";
3
+ import { bashRuleMatches, GIT_DENY_RULES } from "./claude-confine.js";
2
4
  export const AUTH_RE = /not (?:logged|signed) in|not authenticated|no (?:github )?(?:authentication|credentials?|token) (?:found|available)|login required|please (?:log|sign) in|run .*login|invalid api key|unauthori[sz]ed|authentication (?:failed|required)|\b401\b|expired (?:token|session)/i;
3
5
  export const SIGN_IN_FIX = {
4
6
  "claude-code": "run `claude`, then /login",
@@ -58,13 +60,20 @@ export function diagnoseRun(f, probe = gitProbe) {
58
60
  fix: `Sign in once and the next run works: ${fix}. Until then every woken run on this machine fails the same way.`,
59
61
  };
60
62
  }
63
+ if (f.allowBash && (f.agent ?? "claude-code") === "claude-code" && (f.os ?? process.platform) === "linux" && SANDBOX_RE.test(f.detail)) {
64
+ return {
65
+ cause: "sandbox_unavailable",
66
+ says: "The run could not run commands: Claude Code's sandbox did not start on this machine, and a command run never goes without it.",
67
+ fix: "On Linux the sandbox needs bubblewrap and socat. Install them (Debian/Ubuntu: sudo apt install bubblewrap socat; Fedora: sudo dnf install bubblewrap socat), then the next run works.",
68
+ };
69
+ }
61
70
  if (!f.exited) {
62
71
  return {
63
72
  cause: "crashed",
64
73
  says: `The agent did not finish — ${exitLine(f.detail)}.`,
65
74
  fix: `Read the transcript at ${f.runLog}. If the agent CLI is broken or missing, pantheon doctor names it.`,
66
75
  dir: f.worktree,
67
- lines: [`cat ${f.runLog}`],
76
+ lines: [`cat ${shq(f.runLog)}`],
68
77
  };
69
78
  }
70
79
  if (!f.branch) {
@@ -84,6 +93,55 @@ export function diagnoseRun(f, probe = gitProbe) {
84
93
  capability: "run_commands",
85
94
  };
86
95
  }
96
+ const claimed = blockedCommands(f.transcript ?? f.detail);
97
+ const allowed = (c) => !!f.allowedRules?.some((r) => ruleAllows(r, c));
98
+ const denied = (c) => GIT_DENY_RULES.find((r) => bashRuleMatches(r, c));
99
+ const byDeny = f.allowedRules ? claimed.filter((c) => allowed(c) && denied(c)) : [];
100
+ if (f.allowBash && byDeny.length) {
101
+ return {
102
+ cause: "command_not_listed",
103
+ says: `The run needed \`${byDeny.join("`, `")}\`, which a woken run is never given (${denied(byDeny[0])}): it would move or delete a branch the person's own checkout shares, or run a program through git.`,
104
+ fix: "That is by design and no list entry changes it. If the work needs it, do that step yourself, or ask the run for the part that does not need it.",
105
+ };
106
+ }
107
+ const blocked = f.allowedRules ? claimed.filter((c) => !allowed(c)) : claimed;
108
+ const wronglyBlocked = f.allowedRules ? claimed.filter((c) => allowed(c) && !denied(c)) : [];
109
+ if (f.allowBash && blocked.length) {
110
+ const cmd = blocked[0];
111
+ const base = f.baseRef ? f.baseRef.replace(/^origin\//, "") : "the base branch";
112
+ const says = `The run needed \`${blocked.join("`, `")}\`, which is not on the list of commands a woken run may use, so it was refused.`;
113
+ const why = checkRepoCommand(cmd);
114
+ if (why || !/^[A-Za-z0-9 ._\/:=@+,%-]+$/.test(cmd)) {
115
+ return {
116
+ cause: "command_not_listed", says,
117
+ fix: why
118
+ ? `It cannot be added to ${REPO_COMMANDS_FILE_NAME}: ${why}. If the run needs it, give it a fixed form the list accepts (a script path such as ./node_modules/.bin/vitest, or a make/npm script), or run it yourself.`
119
+ : `Check the command in the transcript at ${f.runLog}; if it is safe to run unattended, add it to ${REPO_COMMANDS_FILE_NAME} on ${base} yourself.`,
120
+ dir: f.project,
121
+ lines: [],
122
+ };
123
+ }
124
+ const onBase = !!f.baseRef && ask(() => probe.branchOf(f.project), "") === base;
125
+ return {
126
+ cause: "command_not_listed", says,
127
+ fix: `If it is safe to run unattended, add it to ${REPO_COMMANDS_FILE_NAME} on ${base} (one command per line) and get that onto ${base} the way your team changes it. ` +
128
+ `The next run reads the list from the tip of ${base} after it fetches — not from its own worktree, so committing it on the run's branch does nothing.`,
129
+ dir: f.project,
130
+ lines: onBase ? [
131
+ `mkdir -p .pantheon && printf '%s\\n' ${shq(cmd)} >> ${REPO_COMMANDS_FILE_NAME}`,
132
+ `git add ${REPO_COMMANDS_FILE_NAME} && git commit -m ${shq(`let woken runs use ${cmd.split(/\s+/).slice(0, 2).join(" ")}`)}`,
133
+ ] : [],
134
+ };
135
+ }
136
+ if (f.allowBash && wronglyBlocked.length) {
137
+ return {
138
+ cause: "command_failed",
139
+ says: `The run reported \`${wronglyBlocked.join("`, `")}\` as refused, but it is allowed — most likely it failed inside the command (a failing test, a missing dependency, the sandbox refusing a file or host).`,
140
+ fix: `Read the transcript at ${f.runLog} for what the command printed. Adding it to ${REPO_COMMANDS_FILE_NAME} would change nothing.`,
141
+ dir: f.worktree,
142
+ lines: [`cat ${shq(f.runLog)}`],
143
+ };
144
+ }
87
145
  if (!ask(() => probe.hasRemote(f.project), true)) {
88
146
  return {
89
147
  cause: "no_remote",
@@ -101,17 +159,39 @@ export function diagnoseRun(f, probe = gitProbe) {
101
159
  says: `The work is uncommitted in your own checkout${on ? ` on ${on}` : ""} — a run never sees that, it starts from what origin has.`,
102
160
  fix: "Commit and push it yourself, then the run (or your teammate) can see it. A worker deliberately never touches your working tree.",
103
161
  dir: f.project,
104
- lines: [`git status --short # ${list(dirty)}`, "git add -A", `git commit -m "…"`, `git push${on ? ` -u origin ${on}` : ""}`],
162
+ lines: [`git status --short # ${list(dirty).replace(/[\r\n]+/g, " ")}`, "git add -A", `git commit -m "…"`, `git push${on ? ` -u origin ${shq(on)}` : ""}`],
105
163
  };
106
164
  }
107
165
  return silent(f);
108
166
  }
167
+ export function ruleAllows(rule, command) {
168
+ const m = /^Bash\((.*)\)$/s.exec(rule);
169
+ if (!m)
170
+ return false;
171
+ const pat = m[1];
172
+ const cmd = command.trim();
173
+ if ((pat.match(/\*/g) ?? []).length === 1 && pat.endsWith(" *") && cmd === pat.slice(0, -2))
174
+ return true;
175
+ return new RegExp(`^${pat.split("*").map((x) => x.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\s\\S]*")}$`).test(cmd);
176
+ }
177
+ const REPO_COMMANDS_FILE_NAME = ".pantheon/commands";
178
+ const SANDBOX_RE = /\b(?:bubblewrap|bwrap)\b[^\n]*(?:not found|not installed|missing|unavailable|required)|sandbox[^\n]{0,80}(?:is unavailable|not available|could not (?:be )?start|failed to start)/i;
179
+ const shq = (v) => /^[A-Za-z0-9._\/:=@+,%-]+$/.test(v) ? v : `'${v.replace(/'/g, "'\\''")}'`;
180
+ export function blockedCommands(text) {
181
+ const out = [];
182
+ for (const m of (text ?? "").matchAll(/^\s*BLOCKED-COMMAND:\s*`?([^`\n]{1,200}?)`?\s*$/gm)) {
183
+ const c = m[1].trim();
184
+ if (c && !out.includes(c))
185
+ out.push(c);
186
+ }
187
+ return out.slice(0, 5);
188
+ }
109
189
  const silent = (f) => ({
110
190
  cause: "silent",
111
191
  says: "The run finished cleanly but sent nothing back, so the request is still open.",
112
192
  fix: `Read what it did at ${f.runLog}. If it was right to stay silent, answer the teammate yourself; if not, the transcript says where it stopped.`,
113
193
  dir: f.worktree,
114
- lines: [`cat ${f.runLog}`],
194
+ lines: [`cat ${shq(f.runLog)}`],
115
195
  });
116
196
  export const BLOCKED_RE = /(?:permission|approval)[^\n]{0,40}\b(?:denied|required|needed|to use (?:the )?(?:bash|shell))|\bnot (?:allowed|permitted) to (?:run|execute|use (?:the )?(?:bash|shell))|\b(?:bash|shell|command|tool)\b[^\n]{0,30}\b(?:was |is )?(?:blocked|denied|not allowed|rejected)\b|read-only sandbox|sandbox[^\n]{0,20}read-only/i;
117
197
  export function workNeedsCommands(kind, trigger, triggerAct) {
@@ -0,0 +1,141 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ const MIN_EXACT = 12;
5
+ const PEM = /-----BEGIN [A-Z0-9 ]*(?:PRIVATE KEY|PRIVATE KEY BLOCK)-----|-----BEGIN OPENSSH PRIVATE KEY-----/;
6
+ const TOKEN_SHAPES = [
7
+ { re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/, kind: "a GitHub token" },
8
+ { re: /\bgithub_pat_[A-Za-z0-9_]{40,}\b/, kind: "a GitHub token" },
9
+ { re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/, kind: "an AWS access key id" },
10
+ { re: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/, kind: "a Slack token" },
11
+ { re: /\bnpm_[A-Za-z0-9]{36}\b/, kind: "an npm token" },
12
+ { re: /\bglpat-[A-Za-z0-9_-]{20,}\b/, kind: "a GitLab token" },
13
+ { re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/, kind: "an Anthropic API key" },
14
+ ];
15
+ function derPrivateKey(b64) {
16
+ let b;
17
+ try {
18
+ b = Buffer.from(b64.replace(/[-_]/g, (c) => (c === "-" ? "+" : "/")), "base64");
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ if (b.length < 32)
24
+ return false;
25
+ if (b.subarray(0, 15).toString("latin1") === "openssh-key-v1\0")
26
+ return true;
27
+ if (b[0] !== 0x30)
28
+ return false;
29
+ let i = 1;
30
+ const l = b[i];
31
+ if (l & 0x80)
32
+ i += 1 + (l & 0x7f);
33
+ else
34
+ i += 1;
35
+ return b[i] === 0x02 && b[i + 1] === 0x01 && (b[i + 2] === 0x00 || b[i + 2] === 0x01);
36
+ }
37
+ export function secretFileValues(home = homedir()) {
38
+ const out = [];
39
+ const take = (file, re) => {
40
+ let text = "";
41
+ try {
42
+ text = readFileSync(join(home, file), "utf8");
43
+ }
44
+ catch {
45
+ return;
46
+ }
47
+ if (text.length > 1 << 20)
48
+ return;
49
+ for (const m of text.matchAll(re)) {
50
+ const v = (m[1] ?? "").trim().replace(/^["']|["']$/g, "");
51
+ if (v.length >= MIN_EXACT)
52
+ out.push({ value: v, file: `~/${file}` });
53
+ }
54
+ };
55
+ take(".aws/credentials", /^\s*(?:aws_secret_access_key|aws_session_token)\s*=\s*(\S+)/gim);
56
+ take(".config/gh/hosts.yml", /^\s*oauth_token:\s*(\S+)/gim);
57
+ take("AppData/Roaming/GitHub CLI/hosts.yml", /^\s*oauth_token:\s*(\S+)/gim);
58
+ take(".netrc", /\bpassword\s+(\S+)/gi);
59
+ take(".git-credentials", /^[a-z][a-z0-9+.-]*:\/\/[^:\s/]+:([^@\s]+)@/gim);
60
+ take(".docker/config.json", /"(?:auth|identitytoken)"\s*:\s*"([^"]+)"/gi);
61
+ take(".npmrc", /_(?:authToken|auth|password)\s*=\s*(\S+)/gi);
62
+ return out;
63
+ }
64
+ const squash = (t) => t.replace(/[^A-Za-z0-9+/]/g, "");
65
+ function decodedRuns(text) {
66
+ const out = [];
67
+ const flat = text.replace(/\s+/g, "");
68
+ for (const m of flat.matchAll(/(?:[0-9a-fA-F]{2}){12,}/g)) {
69
+ if (out.length > 50)
70
+ break;
71
+ out.push(Buffer.from(m[0], "hex").toString("latin1"));
72
+ }
73
+ for (const m of flat.matchAll(/[A-Za-z0-9+/_-]{16,}={0,2}/g)) {
74
+ if (out.length > 100)
75
+ break;
76
+ try {
77
+ out.push(Buffer.from(m[0].replace(/[-_]/g, (c) => (c === "-" ? "+" : "/")), "base64").toString("latin1"));
78
+ }
79
+ catch { }
80
+ }
81
+ return out;
82
+ }
83
+ export function secretGuard(own, home = homedir()) {
84
+ const exact = [];
85
+ if (own.privateKey && own.privateKey.length >= MIN_EXACT)
86
+ exact.push({ value: own.privateKey, kind: "this machine's private key" });
87
+ for (const t of own.tokens ?? [])
88
+ if (t && t.length >= MIN_EXACT && !exact.some((e) => e.value === t))
89
+ exact.push({ value: t, kind: "a team token this machine holds" });
90
+ let files = [];
91
+ let readAt = 0;
92
+ return {
93
+ check(text) {
94
+ if (!text)
95
+ return null;
96
+ for (const e of exact)
97
+ if (text.includes(e.value))
98
+ return { kind: e.kind };
99
+ if (exact.length) {
100
+ const flat = squash(text);
101
+ const decoded = decodedRuns(text);
102
+ for (const e of exact) {
103
+ const v = squash(e.value);
104
+ if (v.length >= MIN_EXACT && (flat.includes(v) || decoded.some((d) => d.includes(e.value) || squash(d).includes(v))))
105
+ return { kind: e.kind };
106
+ }
107
+ }
108
+ if (PEM.test(text))
109
+ return { kind: "a private key (a PEM block)" };
110
+ for (const s of TOKEN_SHAPES)
111
+ if (s.re.test(text))
112
+ return { kind: s.kind };
113
+ for (const m of text.matchAll(/[A-Za-z0-9+/_-]{60,}={0,2}/g))
114
+ if (derPrivateKey(m[0]))
115
+ return { kind: "something shaped like a private key (base64 key material)" };
116
+ if (Date.now() - readAt > 60_000) {
117
+ files = secretFileValues(home);
118
+ readAt = Date.now();
119
+ }
120
+ for (const f of files)
121
+ if (text.includes(f.value))
122
+ return { kind: `a secret from ${f.file}` };
123
+ return null;
124
+ },
125
+ };
126
+ }
127
+ export function refuseSecrets(guard, fields) {
128
+ for (const [name, v] of Object.entries(fields)) {
129
+ for (const s of Array.isArray(v) ? v : [v]) {
130
+ if (typeof s !== "string")
131
+ continue;
132
+ const hit = guard.check(s);
133
+ if (hit) {
134
+ return `pantheon: NOT sent — the ${name} contains ${hit.kind}. Secrets never go on the ledger: every act is stored on the relay and shown to the whole team. ` +
135
+ `Take it out and send again. If a teammate asked for it, say that you cannot share it and that their human should ask yours directly.`;
136
+ }
137
+ }
138
+ }
139
+ return null;
140
+ }
141
+ //# sourceMappingURL=secret-guard.js.map