@join-pantheon/cli 0.29.0 → 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.
- package/README.md +45 -22
- package/bin/pantheon.js +12 -0
- package/dist/accounts.js +151 -26
- package/dist/agents.js +197 -14
- package/dist/claude-confine.js +283 -0
- package/dist/cli.js +725 -119
- package/dist/client.js +37 -2
- package/dist/command-state.js +79 -0
- package/dist/config.js +211 -10
- package/dist/doctor.js +103 -13
- package/dist/grants.js +1 -1
- package/dist/history-view.js +2 -1
- package/dist/home-guard.js +210 -0
- package/dist/hooks-install.js +2 -1
- package/dist/identity.js +24 -0
- package/dist/install.js +76 -26
- package/dist/kiro-files.js +2 -2
- package/dist/ledger.js +111 -28
- package/dist/machine.js +507 -38
- package/dist/mcp-supervisor.js +25 -6
- package/dist/mcp-windows.js +47 -25
- package/dist/mcp.js +25 -10
- package/dist/network.js +39 -8
- package/dist/notify.js +17 -6
- package/dist/pending.js +8 -14
- package/dist/persona.js +21 -0
- package/dist/profile.js +4 -1
- package/dist/quickstart.js +88 -15
- package/dist/reads.js +30 -0
- package/dist/refusal.js +14 -1
- package/dist/relay.js +1193 -185
- package/dist/roster-file.js +169 -23
- package/dist/run-failure.js +83 -3
- package/dist/secret-guard.js +141 -0
- package/dist/service.js +105 -36
- package/dist/setup-guide.js +28 -9
- package/dist/spend.js +6 -2
- package/dist/store.js +94 -10
- package/dist/text-file.js +74 -0
- package/dist/tray.js +11 -2
- package/dist/update-check.js +2 -0
- package/dist/which.js +69 -12
- package/dist/worker-policy.js +137 -3
- package/dist/worker.js +702 -81
- package/helper/macos/menubar.swift +15 -1
- package/package.json +2 -2
package/dist/roster-file.js
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
|
-
import { readFileSync,
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { createHash, timingSafeEqual } from "node:crypto";
|
|
4
|
-
import {
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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"
|
|
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
|
|
239
|
+
why: `"${member}" is registered here with a different key — the name is taken in this team`,
|
|
112
240
|
fix: [
|
|
113
|
-
`If you
|
|
114
|
-
`
|
|
115
|
-
`
|
|
116
|
-
` Then redeem this same invite again
|
|
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
|
-
|
|
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);
|
package/dist/run-failure.js
CHANGED
|
@@ -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
|