@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
package/dist/doctor.js CHANGED
@@ -1,17 +1,20 @@
1
+ import { parseJsonText, stripJsonc } from "./text-file.js";
1
2
  import { kiroDefaultIsPantheon, readKiroAgent, kiroAgentFile } from "./kiro-files.js";
2
3
  import { execFileSync } from "node:child_process";
3
4
  import { instructionBudget } from "./mcp-instructions.js";
4
5
  import { describeWindows, scanMcpWindows } from "./mcp-windows.js";
5
6
  import { grantsSeen, measureMcp, toolsReport, HOST_STARTUP_ALLOWANCE_MS, STARTUP_WARN_MS } from "./measure.js";
6
7
  import { FEEDBACK_ADDRESS } from "./feedback.js";
7
- import { loadProfile, profileInitialized } from "./profile.js";
8
+ import { loadProfile, profileInitialized, wakeMode } from "./profile.js";
9
+ import { isApprovedWaker, loadPolicy, relayKnowsAdmitters } from "./worker-policy.js";
10
+ import { commandState } from "./command-state.js";
8
11
  import { existsSync, readFileSync } from "node:fs";
9
12
  import { homedir, platform } from "node:os";
10
13
  import { join, resolve } from "node:path";
11
14
  import { PantheonClient } from "./client.js";
12
15
  import { load, resolveFor, loadAll, CONFIG_PATH } from "./config.js";
13
16
  import { fingerprint, signChallenge, verifyChallenge } from "./identity.js";
14
- import { approvalSummary, chosenApproval, onPath } from "./install.js";
17
+ import { approvalSummary, chosenApproval, copilotMcpConfigFile, kiroMcpConfigFile, onPath } from "./install.js";
15
18
  import { hookState, readIntent, agentHookStates } from "./notify.js";
16
19
  import { loadPersonas } from "./persona.js";
17
20
  import { workerProc, menubarProc, notifierProc } from "./machine.js";
@@ -33,7 +36,7 @@ const git = (args, cwd) => {
33
36
  };
34
37
  function claudeLocalOverride(projectDir) {
35
38
  try {
36
- const doc = JSON.parse(readFileSync(join(homedir(), ".claude.json"), "utf8"));
39
+ const doc = parseJsonText(readFileSync(join(homedir(), ".claude.json"), "utf8"));
37
40
  const norm = (x) => resolve(x).replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
38
41
  const want = norm(projectDir);
39
42
  return Object.entries(doc.projects ?? {}).some(([k, v]) => norm(k) === want && !!v?.mcpServers?.["pantheon"]);
@@ -42,6 +45,46 @@ function claudeLocalOverride(projectDir) {
42
45
  return false;
43
46
  }
44
47
  }
48
+ export function userScopeWiring(home = homedir(), present = onPathNamed, env = process.env) {
49
+ const json = (file, pick) => {
50
+ if (!existsSync(file))
51
+ return "missing";
52
+ try {
53
+ return pick(parseJsonText(stripJsonc(readFileSync(file, "utf8")))) ? "wired" : "missing";
54
+ }
55
+ catch {
56
+ return "invalid";
57
+ }
58
+ };
59
+ const server = (key) => (d) => !!d[key]?.["pantheon"];
60
+ const out = [];
61
+ if (present("claude")) {
62
+ const f = join(home, ".claude.json");
63
+ out.push({ agent: "Claude Code", file: f, state: json(f, server("mcpServers")) });
64
+ }
65
+ if (present("cursor-agent") || existsSync(join(home, ".cursor"))) {
66
+ const f = join(home, ".cursor", "mcp.json");
67
+ out.push({ agent: "Cursor", file: f, state: json(f, server("mcpServers")) });
68
+ }
69
+ if (present("codex")) {
70
+ const f = join(home, ".codex", "config.toml");
71
+ let state = "missing";
72
+ try {
73
+ state = /^\s*\[mcp_servers\.pantheon\]/m.test(readFileSync(f, "utf8")) ? "wired" : "missing";
74
+ }
75
+ catch { }
76
+ out.push({ agent: "Codex", file: f, state });
77
+ }
78
+ if (present("copilot")) {
79
+ const f = copilotMcpConfigFile(home, env);
80
+ out.push({ agent: "Copilot", file: f, state: json(f, server("mcpServers")) });
81
+ }
82
+ if (present("kiro-cli")) {
83
+ const f = kiroMcpConfigFile(home, env);
84
+ out.push({ agent: "Kiro", file: f, state: json(f, server("mcpServers")) });
85
+ }
86
+ return out;
87
+ }
45
88
  function isPrivate(host) {
46
89
  const h = host.replace(/:\d+$/, "");
47
90
  if (/^(localhost|127\.0\.0\.1|\[::1\]|::1)$/.test(h))
@@ -62,13 +105,16 @@ function isPrivate(host) {
62
105
  return true;
63
106
  return false;
64
107
  }
108
+ export function nodeSupported(version) {
109
+ const [major = 0, minor = 0] = version.replace(/^v/, "").split(".").map(Number);
110
+ return major > 23 || (major === 23 && minor >= 4) || (major === 22 && minor >= 13);
111
+ }
65
112
  export async function doctor(projectDir) {
66
113
  const out = [];
67
114
  const add = (level, name, detail, fix, share) => out.push({ level, name, detail, ...(fix ? { fix } : {}), ...(share ? { share } : {}) });
68
- const major = Number(process.versions.node.split(".")[0]);
69
- major >= 22
115
+ nodeSupported(process.versions.node)
70
116
  ? add("pass", "node", `v${process.versions.node}`, undefined, `v${process.versions.node}`)
71
- : add("fail", "node", `v${process.versions.node} is too old`, "pantheon needs Node 22 or newer (the relay uses node:sqlite) — install it and re-run", `v${process.versions.node}, too old`);
117
+ : add("fail", "node", `v${process.versions.node} is too old`, "pantheon needs Node 22.13 or newer — node:sqlite, which the relay uses, is behind a flag through 22.12 — install a current Node LTS and re-run", `v${process.versions.node}, too old`);
72
118
  const host = hostEnv();
73
119
  if (host.ephemeral) {
74
120
  add("warn", "machine", `${platformName(host.platform)} in ${host.kind === "container" ? "a container" : "a cloud workspace"} — ${host.evidence.join("; ")}`, `this is not the computer you work on, which is why it reports ${platformName(host.platform)} to someone who may be sitting at a Mac. The team and your account are fine here, but the menu bar, desktop alerts and the worker belong on your own machine — run the setup from a terminal there.`, `${host.platform} in ${host.kind === "container" ? "a container" : "a cloud workspace"}`);
@@ -117,12 +163,12 @@ export async function doctor(projectDir) {
117
163
  }
118
164
  const mcpFile = join(projectDir, ".mcp.json");
119
165
  if (!existsSync(mcpFile)) {
120
- add("warn", "mcp entry", "no .mcp.json in this project", "run `pantheon install`", "no .mcp.json in this project");
166
+ add("pass", "mcp entry", "no .mcp.json in this project — your agents are wired for your user (see below), so there is nothing to commit", undefined, "none in the project (user scope)");
121
167
  }
122
168
  else {
123
169
  let entry = {};
124
170
  try {
125
- entry = (JSON.parse(readFileSync(mcpFile, "utf8")).mcpServers ?? {}).pantheon ?? {};
171
+ entry = (parseJsonText(stripJsonc(readFileSync(mcpFile, "utf8"))).mcpServers ?? {}).pantheon ?? {};
126
172
  }
127
173
  catch { }
128
174
  if (!entry.command) {
@@ -143,6 +189,21 @@ export async function doctor(projectDir) {
143
189
  add("fail", "mcp entry", `hard-codes this machine: ${entry.command}`, "make sure `pantheon` is on your PATH, then run `pantheon install` again — it writes the portable `pantheon mcp` entry. Committed as-is it silently fails on the other machine: the MCP server never starts and no error is shown.", "hard-codes a path on this machine");
144
190
  }
145
191
  }
192
+ {
193
+ const rows = userScopeWiring();
194
+ const wired = rows.filter((r) => r.state === "wired");
195
+ const bad = rows.filter((r) => r.state !== "wired");
196
+ if (rows.length && !bad.length) {
197
+ add("pass", "user wiring", `pantheon is wired at user scope for ${wired.map((r) => r.agent).join(", ")}`, undefined, `wired for ${wired.length} agent${wired.length === 1 ? "" : "s"}`);
198
+ }
199
+ else if (rows.length) {
200
+ const said = bad.map((r) => `${r.agent}: ${r.state === "invalid" ? `${r.file} does not parse` : `no pantheon server in ${r.file}`}`).join("; ");
201
+ const fix = bad.some((r) => r.state === "invalid")
202
+ ? "fix the file that does not parse (setup never overwrites it), then run `pantheon install --user`"
203
+ : "run `pantheon install --user` — and if it says failed, the line it prints is why";
204
+ add(wired.length || existsSync(mcpFile) ? "warn" : "fail", "user wiring", said, fix, `${bad.length} of ${rows.length} agent${rows.length === 1 ? "" : "s"} not wired at user scope`);
205
+ }
206
+ }
146
207
  onPath()
147
208
  ? add("pass", "PATH", "`pantheon` resolves by name", undefined, "pantheon resolves by name")
148
209
  : add("warn", "PATH", "`pantheon` is not on PATH", "install it globally (`npm install -g @join-pantheon/cli`) and make sure your npm global bin dir (`npm bin -g`) is on PATH", "pantheon is not on PATH");
@@ -245,11 +306,17 @@ export async function doctor(projectDir) {
245
306
  try {
246
307
  const res = await fetch(health, { signal: AbortSignal.timeout(5000) });
247
308
  const body = (await res.json());
248
- if (!body.ok) {
309
+ if (!body.ok && body.storage) {
310
+ const since = body.storage.at ? ` (since ${new Date(body.storage.at).toLocaleString()})` : "";
311
+ add("fail", "relay", `the relay cannot write to its disk: ${body.storage.error ?? "unknown error"}${since}`, hosted ? `this is on the hosted relay's side — nothing on this machine can fix it; tell us at ${FEEDBACK_ADDRESS}` : "free space or fix permissions on the relay host's data folder, then restart the relay", `cannot write to its disk (${where})`);
312
+ }
313
+ else if (!body.ok) {
249
314
  add("fail", "relay", `answered but not healthy: ${JSON.stringify(body)}`, undefined, `answered but not healthy (${where})`);
250
315
  }
251
316
  else {
252
317
  add("pass", "relay", `reachable at ${health}`, undefined, `reachable (${where})`);
318
+ }
319
+ if (body.ok || body.storage) {
253
320
  if (body.pkg && body.pkg !== VERSION) {
254
321
  add("warn", "version", `this machine is ${VERSION}, the relay is ${body.pkg}`, "run `pantheon upgrade` on the older side — features land on both halves of the wire, and the protocol version cannot tell you they are missing", `this machine ${VERSION}, relay ${body.pkg}`);
255
322
  }
@@ -298,18 +365,32 @@ export async function doctor(projectDir) {
298
365
  .map((p) => `${p.member}[${online.has(p.member) ? "online" : "no session open"}]`).join(" "), undefined, `${admittedPeers.length} admitted, ${peersOnline} with a session open`)
299
366
  : add("warn", "peers", "nobody else is admitted to this space yet", "send your teammate an invite from `pantheon invite` (or the one `pantheon start` printed)", "nobody else admitted yet");
300
367
  if (pending.length) {
301
- add("warn", "pending", pending.map((p) => `${p.member} (${p.fingerprint})`).join(" "), `confirm the fingerprint by voice, then: pantheon admit ${pending[0].member} (add --may-wake only if they should be able to wake this machine)`, `${pending.length} waiting to be admitted`);
368
+ add("warn", "pending", pending.map((p) => `${p.member} (${p.fingerprint})`).join(" "), `confirm the fingerprint by voice, then: pantheon admit ${pending[0].member} (add --may-wake only if the team should let them wake workers; each of you then allows them on your own machine: pantheon wake allow <name>)`, `${pending.length} waiting to be admitted`);
302
369
  }
303
370
  const wakers = client.members.filter((m) => m.admitted && m.may_wake && m.member !== cfg.member);
304
371
  const hookStates = agentHookStates(projectDir);
305
372
  const hook = hookStates[0] ?? hookState(projectDir);
306
373
  const reportsActivity = hookStates.some((s) => s.events?.some((e) => /prompt|UserPromptSubmit|beforeSubmitPrompt/i.test(e))) || (hook.events?.includes("UserPromptSubmit") ?? false);
307
374
  const workerUp = workerProc().running;
308
- if (!wakers.length) {
309
- add("warn", "wake rights", "no peer may wake a worker on this machine", "that is a safe default; grant it with `pantheon admit <them> --may-wake` when you want unattended replies", `no peer may wake; worker ${workerUp ? "running" : "not running"}`);
375
+ const mode = wakeMode(loadProfile(cfg));
376
+ const policy = loadPolicy();
377
+ const approvedHere = admittedPeers.filter((m) => isApprovedWaker(policy, [client.spaceId ?? cfg.spaceId, cfg.space], m.member));
378
+ const teamKey = client.spaceId ?? cfg.spaceId ?? cfg.space;
379
+ const notCarried = mode === "approved" && !policy.wakersCarried?.[teamKey] && !policy.wakersCarried?.[cfg.space] && !relayKnowsAdmitters(client.members, cfg.member);
380
+ const effective = mode === "anyone" || notCarried ? wakers : mode === "no" ? [] : approvedHere.filter((m) => m.may_wake);
381
+ const blockedByTeam = mode === "approved" ? approvedHere.filter((m) => !m.may_wake) : [];
382
+ if (!effective.length) {
383
+ const why = mode === "no" ? "this machine is set not to be woken"
384
+ : mode === "anyone" ? "you chose \"anyone\", and the team lets nobody wake workers"
385
+ : blockedByTeam.length ? `approved here: ${blockedByTeam.map((m) => m.member).join(", ")}, but the team does not let them wake workers yet`
386
+ : "nobody is approved on this machine";
387
+ add("warn", "wake rights", `no teammate can wake a worker on this machine — ${why}`, mode === "no" ? "that is what you chose; pantheon setup to change it"
388
+ : blockedByTeam.length ? `a member runs: pantheon admit ${blockedByTeam[0].member} --may-wake`
389
+ : "that is a safe default; let one teammate wake this machine with `pantheon wake allow <name>` when you want unattended replies", `nobody can wake this machine (${mode}); worker ${workerUp ? "running" : "not running"}`);
310
390
  }
311
391
  else {
312
- const who = `${wakers.map((m) => m.member).join(", ")} may wake a worker (space-wide grant)`;
392
+ const who = `${effective.map((m) => m.member).join(", ")} can wake a worker here (${mode === "anyone" ? "you chose \"anyone\"" : notCarried ? "the team's may_wake decides until this relay is 0.30 or newer" : "approved on this machine"})`;
393
+ const wakers = effective;
313
394
  if (workerUp) {
314
395
  add("pass", "wake rights", `${who} — worker is running`, undefined, `${wakers.length} may wake; worker running`);
315
396
  }
@@ -320,6 +401,15 @@ export async function doctor(projectDir) {
320
401
  : "start one with `pantheon service install worker --project <a git repo>`; until then a peer's act waits for you to type", `${wakers.length} may wake; worker not running`);
321
402
  }
322
403
  }
404
+ {
405
+ const cs = commandState({ project: projectDir });
406
+ add("pass", "commands", `woken runs: ${cs.summary}`, undefined, cs.level === "off" ? "woken runs edit files only" : `woken runs may run commands (${cs.allowBash ? "--allow-bash" : "granted here"})`);
407
+ if (cs.level !== "off" && host.platform === "linux") {
408
+ const missing = ["bwrap", "socat"].filter((b) => !onPathNamed(b, "linux"));
409
+ if (missing.length)
410
+ add("warn", "sandbox", `${missing.join(" and ")} not found — Claude Code's sandbox cannot start, so every woken command run will be refused`, "install them: sudo apt install bubblewrap socat (Debian/Ubuntu) or sudo dnf install bubblewrap socat (Fedora)", `sandbox tools missing: ${missing.join(", ")}`);
411
+ }
412
+ }
323
413
  if (host.desktop) {
324
414
  if (!serviceInstalled("menubar")) {
325
415
  add("warn", "menu bar", "not installed on a Mac that can run it", "install it with: pantheon service install menubar (needs the Xcode Command Line Tools — xcode-select --install — and a setup that ran on this computer, not in a cloud agent). --no-menubar at setup also leaves it off; that is fine if it was your choice", "not installed");
package/dist/grants.js CHANGED
@@ -18,7 +18,7 @@ function readJson(file) {
18
18
  if (!existsSync(file))
19
19
  return null;
20
20
  try {
21
- return JSON.parse(readFileSync(file, "utf8"));
21
+ return JSON.parse(readFileSync(file, "utf8").replace(/^\uFEFF/, ""));
22
22
  }
23
23
  catch {
24
24
  return "invalid";
@@ -34,7 +34,8 @@ export function resolveRef(acts, ref, act, goal, me) {
34
34
  }
35
35
  const others = acts.filter((a) => a.from !== me && a.act !== "JOIN" && a.act !== "LEAVE" && inGoal(a));
36
36
  if (ref === "last") {
37
- const hit = others[others.length - 1];
37
+ const can = REPLIES_TO[act];
38
+ const hit = (can ? [...others].reverse().find((a) => can.includes(a.act)) : undefined) ?? others[others.length - 1];
38
39
  return hit ? { id: hit.id, goal: hit.goal ?? goal, note: `replying to [${hit.seq}] ${hit.act} from ${hit.from}` } : { id: null, goal, error: "nothing from a peer to reply to" };
39
40
  }
40
41
  if (ref)
@@ -0,0 +1,210 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { parseJsonText } from "./text-file.js";
5
+ export const POLICY_FILE = "worker-policy.json";
6
+ export const PROFILE_FILE = "profile.json";
7
+ export const HUD_FILE = join("bin", ".hud-action.json");
8
+ export const CONFIG_FILE = "config.json";
9
+ export const GUARDED_FILES = [POLICY_FILE, CONFIG_FILE, PROFILE_FILE, HUD_FILE];
10
+ const sha = (b) => createHash("sha256").update(b).digest("hex");
11
+ const read = (f) => { try {
12
+ return readFileSync(f);
13
+ }
14
+ catch {
15
+ return null;
16
+ } };
17
+ const parse = (b) => {
18
+ try {
19
+ const v = b ? parseJsonText(b.toString("utf8")) : undefined;
20
+ return v && typeof v === "object" && !Array.isArray(v) ? v : undefined;
21
+ }
22
+ catch {
23
+ return undefined;
24
+ }
25
+ };
26
+ const trusted = new Map();
27
+ export function trustWrite(file) {
28
+ const b = read(file);
29
+ const k = resolve(file);
30
+ const set = trusted.get(k) ?? new Set();
31
+ set.add(b ? sha(b) : "absent");
32
+ if (set.size > 50)
33
+ set.delete(set.values().next().value);
34
+ trusted.set(k, set);
35
+ }
36
+ export function snapshotHome(home, files = GUARDED_FILES) {
37
+ return { home, files: files.map((rel) => { const b = read(join(home, rel)); return { rel, bytes: b, hash: b ? sha(b) : null }; }) };
38
+ }
39
+ export function changedKeys(a, b, prefix = "", depth = 0) {
40
+ const isObj = (x) => !!x && typeof x === "object" && !Array.isArray(x);
41
+ if (!isObj(a) || !isObj(b) || depth >= 4)
42
+ return JSON.stringify(a) === JSON.stringify(b) ? [] : [prefix || "(the whole file)"];
43
+ const out = [];
44
+ for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) {
45
+ const p = prefix ? `${prefix}.${k}` : k;
46
+ if (!(k in a))
47
+ out.push(`${p} added`);
48
+ else if (!(k in b))
49
+ out.push(`${p} removed`);
50
+ else
51
+ out.push(...changedKeys(a[k], b[k], p, depth + 1));
52
+ }
53
+ return out;
54
+ }
55
+ const obj = (x) => (x && typeof x === "object" && !Array.isArray(x) ? x : {});
56
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
57
+ export function narrowPolicy(before, now) {
58
+ const b = obj(before);
59
+ const keys = [];
60
+ const out = { ...now };
61
+ const bg = obj(b.grants), ng = obj(now.grants);
62
+ const grants = {};
63
+ for (const [cap, g] of Object.entries(ng)) {
64
+ if (!(cap in bg)) {
65
+ keys.push(`grants.${cap} added`);
66
+ continue;
67
+ }
68
+ if (!same(bg[cap], g)) {
69
+ keys.push(`grants.${cap} changed`);
70
+ grants[cap] = bg[cap];
71
+ continue;
72
+ }
73
+ grants[cap] = g;
74
+ }
75
+ out.grants = grants;
76
+ const bw = obj(b.wakers), nw = obj(now.wakers);
77
+ const wakers = {};
78
+ for (const [team, list] of Object.entries(nw)) {
79
+ const had = new Set(Array.isArray(bw[team]) ? bw[team].filter((x) => typeof x === "string") : []);
80
+ const keep = (Array.isArray(list) ? list : []).filter((m) => typeof m === "string" && had.has(m));
81
+ const added = (Array.isArray(list) ? list : []).filter((m) => typeof m === "string" && !had.has(m));
82
+ if (added.length)
83
+ keys.push(`wakers.${team} added ${added.length}`);
84
+ if (keep.length)
85
+ wakers[team] = keep;
86
+ }
87
+ if (Object.keys(wakers).length || "wakers" in now)
88
+ out.wakers = wakers;
89
+ const bd = obj(b.denied), nd = obj(now.denied);
90
+ const denied = { ...nd };
91
+ for (const [cap, at] of Object.entries(bd))
92
+ if (!(cap in nd)) {
93
+ denied[cap] = at;
94
+ keys.push(`denied.${cap} removed`);
95
+ }
96
+ out.denied = denied;
97
+ return { policy: out, keys };
98
+ }
99
+ export function narrowConfig(before, now) {
100
+ const keys = [];
101
+ const out = { ...now };
102
+ if (typeof before.relay === "string" && now.relay !== before.relay) {
103
+ out.relay = before.relay;
104
+ keys.push("relay changed");
105
+ }
106
+ const bs = obj(before.spaces), ns = obj(now.spaces);
107
+ const spaces = { ...ns };
108
+ for (const [id, m] of Object.entries(ns)) {
109
+ const was = obj(bs[id]);
110
+ if (typeof was.relay === "string" && obj(m).relay !== was.relay) {
111
+ spaces[id] = { ...obj(m), relay: was.relay };
112
+ keys.push(`spaces.${id}.relay changed`);
113
+ }
114
+ }
115
+ if ("spaces" in now)
116
+ out.spaces = spaces;
117
+ return { config: out, keys };
118
+ }
119
+ const WAKE_ORDER = { no: 0, approved: 1, anyone: 2 };
120
+ export function narrowProfile(before, now) {
121
+ const bw = obj(obj(before).agent).wake_default, nw = obj(now.agent).wake_default;
122
+ const rank = (v) => (typeof v === "string" && v in WAKE_ORDER ? WAKE_ORDER[v] : WAKE_ORDER.approved);
123
+ if (rank(nw) <= rank(bw))
124
+ return { profile: now, keys: [] };
125
+ const agent = { ...obj(now.agent) };
126
+ if (bw === undefined)
127
+ delete agent.wake_default;
128
+ else
129
+ agent.wake_default = bw;
130
+ return { profile: { ...now, agent }, keys: [`agent.wake_default loosened to ${String(nw)}`] };
131
+ }
132
+ export function restoreHome(s) {
133
+ const out = [];
134
+ for (const f of s.files) {
135
+ const file = join(s.home, f.rel);
136
+ const now = read(file);
137
+ const hashNow = now ? sha(now) : null;
138
+ if (hashNow === f.hash)
139
+ continue;
140
+ if (trusted.get(resolve(file))?.has(hashNow ?? "absent"))
141
+ continue;
142
+ try {
143
+ if (f.rel === HUD_FILE) {
144
+ if (now) {
145
+ rmSync(file, { force: true });
146
+ out.push({ rel: f.rel, what: "removed", keys: [] });
147
+ }
148
+ continue;
149
+ }
150
+ const a = parse(f.bytes), b = parse(now);
151
+ if (f.rel === POLICY_FILE) {
152
+ if (!b) {
153
+ if (f.bytes) {
154
+ writeFileSync(file, f.bytes, { mode: 0o600 });
155
+ out.push({ rel: f.rel, what: "reverted", keys: ["(the whole file)"] });
156
+ }
157
+ continue;
158
+ }
159
+ const n = narrowPolicy(a, b);
160
+ if (n.keys.length) {
161
+ writeFileSync(file, JSON.stringify(n.policy, null, 2) + "\n", { mode: 0o600 });
162
+ trustWrite(file);
163
+ out.push({ rel: f.rel, what: "reverted", keys: n.keys });
164
+ }
165
+ continue;
166
+ }
167
+ if (f.rel === PROFILE_FILE) {
168
+ if (!b)
169
+ continue;
170
+ const n = narrowProfile(a, b);
171
+ if (n.keys.length) {
172
+ writeFileSync(file, JSON.stringify(n.profile, null, 2) + "\n", { mode: 0o600 });
173
+ trustWrite(file);
174
+ out.push({ rel: f.rel, what: "reverted", keys: n.keys });
175
+ }
176
+ continue;
177
+ }
178
+ if (a && b) {
179
+ const n = narrowConfig(a, b);
180
+ if (n.keys.length) {
181
+ writeFileSync(file, JSON.stringify(n.config, null, 2) + "\n", { mode: 0o600 });
182
+ trustWrite(file);
183
+ out.push({ rel: f.rel, what: "reverted", keys: n.keys });
184
+ }
185
+ const rest = changedKeys(a, n.config).filter((k) => !/privateKey|token/i.test(k)).slice(0, 20);
186
+ if (rest.length)
187
+ out.push({ rel: f.rel, what: "reported", keys: rest });
188
+ }
189
+ else if (a && !b && now) {
190
+ writeFileSync(file, f.bytes, { mode: 0o600 });
191
+ trustWrite(file);
192
+ out.push({ rel: f.rel, what: "reverted", keys: ["(the whole file)"] });
193
+ }
194
+ else
195
+ out.push({ rel: f.rel, what: "reported", keys: [] });
196
+ }
197
+ catch {
198
+ out.push({ rel: f.rel, what: "reported", keys: ["(could not be checked)"] });
199
+ }
200
+ }
201
+ return out;
202
+ }
203
+ export function describeChange(c) {
204
+ if (c.what === "removed")
205
+ return `${c.rel} was written while the run could write it — removed, not acted on`;
206
+ if (c.what === "reported")
207
+ return `${c.rel} changed while the run was going${c.keys.length ? ` (${c.keys.join(", ")})` : ""} — left as it is; if you did not change it, check it`;
208
+ return `${c.rel}: what the run widened was taken back (${c.keys.join(", ")}) — if you did that yourself during the run, do it again`;
209
+ }
210
+ //# sourceMappingURL=home-guard.js.map
@@ -3,6 +3,7 @@ import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import { MAX_CONSECUTIVE_BLOCKS, isOurHookCommand } from "./notify.js";
5
5
  import { copilotHome, kiroHome } from "./agents.js";
6
+ import { parseJsonText } from "./text-file.js";
6
7
  import { KIRO_AGENT_NAME, kiroAgentFile, kiroAgentDoc, readKiroAgent, writeKiroAgent, kiroDefaultIsPantheon, gitTracks } from "./kiro-files.js";
7
8
  function ours(cmd) {
8
9
  return isOurHookCommand(cmd);
@@ -11,7 +12,7 @@ function readJson(file) {
11
12
  if (!existsSync(file))
12
13
  return null;
13
14
  try {
14
- return JSON.parse(readFileSync(file, "utf8"));
15
+ return parseJsonText(readFileSync(file, "utf8"));
15
16
  }
16
17
  catch {
17
18
  return null;
package/dist/identity.js CHANGED
@@ -64,6 +64,30 @@ export function verifyAccount(publicKeyB64, method, path, params, ts, signatureB
64
64
  return false;
65
65
  }
66
66
  }
67
+ function readPayload(method, path, canonical) {
68
+ return Buffer.from(`pantheon-read/1\n${method}\n${path}\n${canonical}`, "utf8");
69
+ }
70
+ export function canonicalQuery(params) {
71
+ return Object.entries(params)
72
+ .filter(([k, v]) => v !== undefined && k !== "sig")
73
+ .map(([k, v]) => [encodeURIComponent(k), encodeURIComponent(String(v))])
74
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
75
+ .map(([k, v]) => `${k}=${v}`)
76
+ .join("&");
77
+ }
78
+ export function signRead(privateKeyB64, method, path, params) {
79
+ const key = { key: Buffer.from(privateKeyB64, "base64"), format: "der", type: "pkcs8" };
80
+ return sign(null, readPayload(method, path, canonicalQuery(params)), key).toString("base64");
81
+ }
82
+ export function verifyRead(publicKeyB64, method, path, params, signatureB64) {
83
+ try {
84
+ const key = createPublicKey({ key: Buffer.from(publicKeyB64, "base64"), format: "der", type: "spki" });
85
+ return verify(null, readPayload(method, path, canonicalQuery(params)), key, Buffer.from(signatureB64, "base64"));
86
+ }
87
+ catch {
88
+ return false;
89
+ }
90
+ }
67
91
  export function fingerprint(publicKeyB64) {
68
92
  const raw = Buffer.from(publicKeyB64, "base64").subarray(-16).toString("hex");
69
93
  return (raw.match(/.{4}/g) ?? []).join("-");