seisin 0.4.2 → 0.6.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 (82) hide show
  1. package/README.md +506 -162
  2. package/package.json +14 -6
  3. package/src/browsers.js +52 -0
  4. package/src/canary.js +149 -0
  5. package/src/cli.js +175 -52
  6. package/src/commands/check.js +157 -5
  7. package/src/commands/explain.js +31 -13
  8. package/src/commands/hook.js +20 -6
  9. package/src/commands/init.js +183 -88
  10. package/src/commands/log.js +48 -9
  11. package/src/commands/requests.js +122 -70
  12. package/src/commands/review.js +73 -21
  13. package/src/commands/run.js +422 -78
  14. package/src/commands/scan.js +18 -3
  15. package/src/commands/ui.js +18 -5
  16. package/src/commands/walls.js +62 -11
  17. package/src/commands/whose.js +9 -7
  18. package/src/commands/wire.js +156 -17
  19. package/src/config.js +505 -37
  20. package/src/controls.js +244 -0
  21. package/src/diagnose.js +102 -49
  22. package/src/env.js +14 -8
  23. package/src/grants.js +122 -9
  24. package/src/handoff.js +285 -10
  25. package/src/help.js +179 -0
  26. package/src/hook.js +101 -42
  27. package/src/index.d.ts +217 -0
  28. package/src/index.js +24 -16
  29. package/src/inspect/codex.js +64 -0
  30. package/src/inspect/credentials.js +48 -0
  31. package/src/inspect/enforce.js +24 -0
  32. package/src/inspect/files.js +71 -0
  33. package/src/inspect/home.js +43 -0
  34. package/src/inspect/inject.js +68 -0
  35. package/src/inspect/keys.js +74 -0
  36. package/src/inspect/layout.js +258 -0
  37. package/src/inspect/limits.js +98 -0
  38. package/src/inspect/machine.js +107 -0
  39. package/src/inspect/network.js +37 -0
  40. package/src/inspect/providers.js +71 -0
  41. package/src/inspect/read-territory.js +105 -0
  42. package/src/inspect/role-keys.js +136 -0
  43. package/src/inspect/shared.js +37 -0
  44. package/src/inspect/warnings.js +58 -0
  45. package/src/inspect.js +20 -756
  46. package/src/intake.js +150 -35
  47. package/src/keys.js +231 -73
  48. package/src/kinds.js +249 -0
  49. package/src/lines.js +26 -0
  50. package/src/log.js +242 -52
  51. package/src/mcp.js +57 -58
  52. package/src/nested.js +12 -3
  53. package/src/notify.js +66 -20
  54. package/src/owners.js +358 -39
  55. package/src/paths.js +54 -2
  56. package/src/provider-run.js +81 -0
  57. package/src/redact.js +46 -13
  58. package/src/render.js +101 -17
  59. package/src/requests.js +506 -87
  60. package/src/review.js +50 -25
  61. package/src/rundir.js +33 -14
  62. package/src/sandboxes/command.js +93 -0
  63. package/src/sandboxes/index.js +113 -0
  64. package/src/sandboxes/mach-profile.js +31 -0
  65. package/src/sandboxes/srt-mach.mjs +43 -0
  66. package/src/sandboxes/srt.js +163 -0
  67. package/src/sandboxes/tartaros.js +80 -0
  68. package/src/scan.js +181 -19
  69. package/src/serve.js +396 -230
  70. package/src/spool.js +16 -45
  71. package/src/srt.js +296 -49
  72. package/src/suggest.js +58 -0
  73. package/src/surface.js +314 -54
  74. package/src/territory.js +261 -0
  75. package/src/trace.js +33 -0
  76. package/src/uilink.js +24 -9
  77. package/src/views.js +315 -0
  78. package/src/violations.js +247 -23
  79. package/src/walls.js +76 -46
  80. package/src/worktree.js +3 -11
  81. package/ui/index.html +1429 -538
  82. package/ui/favicon.svg +0 -1
@@ -1,9 +1,161 @@
1
- /** `seisin check` — print the map and every way it does not hold. Runs nothing. */
1
+ /** `seisin check` — print the map and every way it does not hold. Runs nothing, unless `--verify`. */
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import { fileURLToPath } from "node:url";
2
4
  import { inspect } from "../inspect.js";
3
- import { renderReport, out } from "../render.js";
5
+ import { renderReport, out, C } from "../render.js";
6
+ import { measureSystemLog } from "../violations.js";
7
+ import { join } from "node:path";
8
+ import { settingsFor } from "../srt.js";
9
+ import { weigh } from "../sandboxes/index.js";
10
+ import { runsRoot } from "../rundir.js";
11
+ import { channelName } from "../spool.js";
4
12
 
5
- export function check(config, argv = []) {
6
- const report = inspect(config, argv[0] ?? null, config.path);
7
- out(renderReport(report));
13
+ export async function check(config, argv = []) {
14
+ if (argv.includes("--parallel") && !argv.includes("--verify"))
15
+ throw new Error("--parallel is experimental and requires --verify");
16
+ const role = argv.find((a) => !a.startsWith("-")) ?? null;
17
+ const report = inspect(config, role, config.path);
18
+ out(renderReport(report, { verbose: argv.includes("--verbose") }));
19
+ if (config.sandbox?.provider) report.sandboxes = sandboxes(config, role);
20
+ if (argv.includes("--verify")) report.verify = await verify(config, role, argv.includes("--parallel"));
21
+ if (argv.includes("--system-log")) report.systemLog = await systemLog();
8
22
  return report;
9
23
  }
24
+
25
+ /**
26
+ * `--system-log`: is the log a run reads denials from dropping messages now?
27
+ *
28
+ * A flooded unified log loses lines before they are filtered, and a kernel
29
+ * denial lost there files nothing: the run says so afterwards, and this says
30
+ * it before, with who is writing the most — the only lead to a fix. A flag,
31
+ * because it takes seconds and `check` otherwise answers at once.
32
+ */
33
+ async function systemLog() {
34
+ const m = await measureSystemLog({ seconds: SYSTEM_LOG_SECONDS });
35
+ if (!m.available) {
36
+ out(`\n${C.dim}system log: not measured — ${m.reason}${C.off}\n`);
37
+ return m;
38
+ }
39
+ const top = m.top.map((t) => `${t.who} ${t.perSecond}/s`).join(", ");
40
+ out(m.dropped
41
+ ? `\n${C.yellow}⚠ system log: ${m.dropped} batch(es) of messages dropped in ${m.seconds} s — kernel denials can go unrecorded now.${C.off}\n` +
42
+ ` ${m.perSecond} messages/s; most from ${top}. Stop or quiet the busiest writer, then measure again.\n`
43
+ : `\n${C.green}✓${C.off} system log: nothing dropped in ${m.seconds} s (${m.perSecond} messages/s; most from ${top})\n`);
44
+ return m;
45
+ }
46
+
47
+ const SYSTEM_LOG_SECONDS = 3;
48
+
49
+ /**
50
+ * `[sandbox] provider`: which sandbox each role would get, and why the others not.
51
+ *
52
+ * Said before anything starts, because the alternative is finding out at
53
+ * `seisin run` that a role cannot be held by the sandbox the config asked for.
54
+ * The settings are the ones a run would build, down to a channel in the runs
55
+ * root, so a sandbox that cannot re-open this run's directory is caught here
56
+ * too. Printed only when `[sandbox] provider` is set: without it every role
57
+ * runs under srt, as it always has, and there is nothing to choose.
58
+ */
59
+ export function sandboxes(config, only = null, { platform = process.platform } = {}) {
60
+ const sock = join(runsRoot(), "check", channelName(platform));
61
+ const rows = [];
62
+ out(`\n${C.b}sandboxes${C.off} (${config.sandbox.provider.join(" → ")})\n`);
63
+ for (const r of Object.values(config.roles)) {
64
+ if (only && r.name !== only) continue;
65
+ let settings;
66
+ try { settings = settingsFor(config, r.name, sock); } catch (e) { rows.push({ role: r.name, chosen: null, error: e.message }); continue; }
67
+ const { chosen, options } = weigh(config, settings, { platform, role: r.name });
68
+ rows.push({ role: r.name, chosen: chosen?.sandbox.name ?? null,
69
+ options: options.map((o) => ({ name: o.sandbox.name, missing: o.missing, unmet: o.unmet, warnings: o.warnings })) });
70
+ out(chosen ? ` ${C.green}✓${C.off} ${r.name}: ${chosen.sandbox.name}\n` : ` ${C.red}✗${C.off} ${r.name}: no sandbox can hold it\n`);
71
+ // Held, but not the way the role may expect: the sandbox's own words.
72
+ for (const w of chosen?.warnings ?? []) out(` ${C.yellow}⚠ ${chosen.sandbox.name}: ${w}${C.off}\n`);
73
+ // Recorded nowhere otherwise: a run under it would still be held, and say nothing of what it refused.
74
+ if (chosen && !chosen.sandbox.tagsDenials && platform === "darwin")
75
+ out(` ${C.yellow}⚠ ${chosen.sandbox.name} does not tag its denials, so none of the kernel's denials under it are recorded${C.off}\n`);
76
+ for (const o of options) {
77
+ if (o === chosen) break;
78
+ if (o.missing) out(` ${o.sandbox.name}: ${o.missing}\n`);
79
+ else for (const u of o.unmet) out(` ${o.sandbox.name}: ${u}\n`);
80
+ }
81
+ }
82
+ return rows;
83
+ }
84
+
85
+ /**
86
+ * The exit status: 1 when a role cannot be enforced as written, or when what a
87
+ * flag asked for failed. `--system-log` is asked for like `--verify` and fails
88
+ * the same way — a log dropping messages now is a run that would lose denials
89
+ * now, and a CI step has only the status to go on.
90
+ */
91
+ export function checkStatus(report) {
92
+ return report.warnings.some((w) => w.kind === "cannot-be-enforced") || report.verify?.some((v) => !v.ok) ||
93
+ report.sandboxes?.some((s) => !s.chosen) ||
94
+ report.systemLog?.dropped > 0 ? 1 : 0;
95
+ }
96
+
97
+ /**
98
+ * `--verify`: each role's `verify` command, run inside that role's sandbox.
99
+ *
100
+ * Through `seisin run` itself and not a copy of its setup, so what passes here
101
+ * is what the agent will get: the same settings, environment and PATH. With
102
+ * `--agent none`, because the question is the toolchain, not the agent's home.
103
+ * In the repo root, where a test command expects to start.
104
+ *
105
+ * The one thing `check` executes, which is why it is a flag: without it
106
+ * `check` still runs nothing.
107
+ */
108
+ async function verify(config, only, parallel = false) {
109
+ const cli = fileURLToPath(new URL("../cli.js", import.meta.url));
110
+ const roles = Object.values(config.roles).filter((r) => (only ? r.name === only : true) && r.verify);
111
+ const results = [];
112
+ if (!roles.length) out(`\n${C.yellow}verify: no role ${only ? `"${only}" ` : ""}declares a verify command${C.off}\n`);
113
+ const run = async (r) => {
114
+ const res = parallel
115
+ ? await runParallel(process.execPath, [cli, "run", r.name, "--agent", "none", "--", ...r.verify], config.root)
116
+ : spawnSync(process.execPath, [cli, "run", r.name, "--agent", "none", "--", ...r.verify], {
117
+ cwd: config.root, encoding: "utf8", timeout: 300_000, env: process.env,
118
+ });
119
+ const ok = res.status === 0;
120
+ const result = { role: r.name, ok, status: res.status };
121
+ const output = `${res.stderr ?? ""}${res.stdout ?? ""}`.trim().split("\n");
122
+ const tail = output.slice(-20).join("\n ");
123
+ const denied = output.filter((line) => /denied|Operation not permitted|Permission denied|not readable/i.test(line));
124
+ const suggestion = denied.some((line) => (r.reads ?? []).some((p) => line.includes(p)))
125
+ ? "The command reached a declared reads path; check that the entry names the needed subtree and exists."
126
+ : `If the missing path is data, add the narrow path to reads; if it is an interpreter, runtime or library, add its stack to toolchain${r.toolchain?.length ? ` (currently ${r.toolchain.join(", ")})` : ""}.`;
127
+ out(`\n${ok ? C.green + "✓" : C.red + "✗"} verify ${r.name}${C.off}: ${r.verify.join(" ")}` +
128
+ (ok ? "\n" : ` — exit ${res.status ?? res.signal}\n ${tail}\n` +
129
+ ` This role cannot run its own checks inside its sandbox. ${suggestion}\n`));
130
+ return result;
131
+ };
132
+ if (parallel) {
133
+ out(`\n${C.yellow}experimental: verify commands are running concurrently; shared builds, caches, ports, or generated files may race. Use plain --verify for serial checks.${C.off}\n`);
134
+ results.push(...await Promise.all(roles.map(run)));
135
+ } else {
136
+ for (const r of roles) results.push(await run(r));
137
+ }
138
+ return results;
139
+ }
140
+
141
+ function runParallel(command, args, cwd) {
142
+ return new Promise((resolve) => {
143
+ const child = spawn(command, args, { cwd, env: process.env });
144
+ let stdout = "", stderr = "", timedOut = false, forceTimer;
145
+ const timer = setTimeout(() => {
146
+ timedOut = true;
147
+ child.kill("SIGTERM");
148
+ forceTimer = setTimeout(() => child.kill("SIGKILL"), 2_000);
149
+ }, 300_000);
150
+ child.stdout.setEncoding("utf8").on("data", (s) => { stdout += s; });
151
+ child.stderr.setEncoding("utf8").on("data", (s) => { stderr += s; });
152
+ child.on("error", (error) => {
153
+ clearTimeout(timer); clearTimeout(forceTimer);
154
+ resolve({ status: null, signal: null, stdout, stderr: `${stderr}${error.message}\n` });
155
+ });
156
+ child.on("close", (status, signal) => {
157
+ clearTimeout(timer); clearTimeout(forceTimer);
158
+ resolve({ status: timedOut ? null : status, signal: timedOut ? "SIGTERM (timeout)" : signal, stdout, stderr });
159
+ });
160
+ });
161
+ }
@@ -5,9 +5,10 @@
5
5
  * That is the only reason the exit code is not always 0 — a denial is an answer,
6
6
  * not a failure, but a shell has one channel for both.
7
7
  */
8
- import { toRepoRelative } from "../paths.js";
8
+ import { toRepoRelative, fromCwd } from "../paths.js";
9
9
  import { isAbsolute } from "node:path";
10
- import { explain } from "../owners.js";
10
+ import { explain, readTarget } from "../owners.js";
11
+ import { unknownRole } from "../suggest.js";
11
12
  import { twinsOf, whereIs } from "../worktree.js";
12
13
  import { renderVerdict, C, out } from "../render.js";
13
14
 
@@ -15,7 +16,11 @@ export function explainCommand(config, argv = []) {
15
16
  const [role, action, target] = argv;
16
17
  if (!role || !action || !target)
17
18
  throw new Error("usage: seisin explain <role> <read|write|mcp> <path, key or MCP server>");
18
- if (!config.roles[role]) throw new Error(`unknown role "${role}"`);
19
+ if (!config.roles[role]) throw unknownRole(config, role);
20
+ // Anything else used to be answered as a write, confidently, to a question
21
+ // nobody asked. An unknown action is a usage error, like an unknown flag.
22
+ if (!["read", "write", "mcp"].includes(action))
23
+ throw new Error(`unknown action "${action}" — use read, write or mcp`);
19
24
 
20
25
  if (action === "mcp") {
21
26
  const verdict = explain(config, role, "mcp", target);
@@ -23,7 +28,14 @@ export function explainCommand(config, argv = []) {
23
28
  return { ...verdict, worktree: [] };
24
29
  }
25
30
 
26
- const verb = action.startsWith("r") ? "read" : "write";
31
+ const verb = action;
32
+
33
+ if (verb === "read") {
34
+ const asked = readTarget(config, target);
35
+ const verdict = explain(config, role, "read", target, process.cwd());
36
+ out(renderVerdict(role, verb, asked.target, verdict));
37
+ return { ...verdict, worktree: [] };
38
+ }
27
39
 
28
40
  /**
29
41
  * An absolute path inside the repo is the same question as the relative one.
@@ -36,17 +48,23 @@ export function explainCommand(config, argv = []) {
36
48
  * check a boundary was the one that was wrong.
37
49
  *
38
50
  * Wrong in the direction that matters, too: it reported a path as unowned and
39
- * refused while the kernel allowed it — see "Refusing beats widening" in
51
+ * denied while the kernel allowed it — see "Refusing beats widening" in
40
52
  * docs/decisions.md. A path outside the root is left exactly as written,
41
53
  * because that is a question about somewhere else and it still deserves its
42
54
  * honest "no owner".
43
55
  */
44
- const asked = toRepoRelative(config, target);
56
+ //
57
+ // A relative path is relative to where you are standing, as in every other
58
+ // command line tool: from frontend/src, `app.js` is frontend/src/app.js. It
59
+ // was read against the policy's root, so the same question asked from a
60
+ // subdirectory came back "no owner". The answer prints the path the policy
61
+ // was asked about, so the reader sees which file was meant.
62
+ const asked = toRepoRelative(config, fromCwd(config, target));
45
63
 
46
64
  const verdict = explain(config, role, verb, asked);
47
- out(renderVerdict(role, verb, target, verdict));
65
+ out(renderVerdict(role, verb, asked, verdict));
48
66
 
49
- const worktree = verb === "write" ? elsewhere(config, role, target, verdict) : [];
67
+ const worktree = elsewhere(config, role, isAbsolute(target) ? target : asked, verdict);
50
68
  for (const w of worktree) out(renderElsewhere(w));
51
69
  return { ...verdict, worktree };
52
70
  }
@@ -55,7 +73,7 @@ export function explainCommand(config, argv = []) {
55
73
  * The same question, asked about the same file in the other checkouts.
56
74
  *
57
75
  * Only a twin that gets a DIFFERENT answer is worth a line. A worktree where
58
- * the role is equally allowed, or equally refused, changes nothing about what
76
+ * the role is equally allowed, or equally denied, changes nothing about what
59
77
  * was just printed, and a diagnosis that mentions it anyway is the noise this
60
78
  * tool keeps having to remove.
61
79
  *
@@ -73,7 +91,7 @@ function elsewhere(config, role, target, verdict) {
73
91
 
74
92
  /**
75
93
  * Three lines: where the other checkout is, what the answer is there, and
76
- * which of the two the policy names — the sentence the refusal never carried.
94
+ * which of the two the policy names — the sentence the denial never carried.
77
95
  */
78
96
  function renderElsewhere(t) {
79
97
  const there = t.verdict.allowed
@@ -82,12 +100,12 @@ function renderElsewhere(t) {
82
100
  const named = t.asked.allowed ? t.here : t;
83
101
  const other = t.asked.allowed ? t : t.here;
84
102
  // "allowed" is the answer that misleads, so it is the one that gets told
85
- // where the refusal is. After "denied" that clause would only repeat it.
86
- const refused = t.asked.allowed ? ` — a write in ${kind(other)} is refused` : "";
103
+ // where the denial is. After "denied" that clause would only repeat it.
104
+ const denied = t.asked.allowed ? ` — a write in ${kind(other)} is denied` : "";
87
105
  return (
88
106
  ` ${C.yellow}worktree${C.off} ${t.where}\n` +
89
107
  ` ${C.dim} the same file there is ${C.off}${C.b}${t.rel}${C.off}${C.dim}, and it ${there}.\n` +
90
- ` The policy names ${kind(named)}, not ${kind(other)}${refused}.${C.off}\n\n`
108
+ ` The policy names ${kind(named)}, not ${kind(other)}${denied}.${C.off}\n\n`
91
109
  );
92
110
  }
93
111
 
@@ -4,7 +4,7 @@
4
4
  * Claude Code runs this once per tool call, so every path through it is short
5
5
  * and every failure is silent-and-permissive. That asymmetry is deliberate: the
6
6
  * hook explains, the kernel enforces. A hook that crashes should cost you an
7
- * explanation, never a blocked turn — and it cannot cost you a boundary,
7
+ * explanation, never a stopped tool call — and it cannot cost you a boundary,
8
8
  * because it was never the boundary.
9
9
  */
10
10
  import { findConfig, loadConfig } from "../config.js";
@@ -12,10 +12,11 @@ import { decide } from "../hook.js";
12
12
  import { afterTool, atSessionStart } from "../diagnose.js";
13
13
  import { logPath } from "../log.js";
14
14
  import { flush } from "../spool.js";
15
+ import { chainFromEnv } from "../handoff.js";
15
16
 
16
17
  export async function hook(stdin = process.stdin, env = process.env) {
17
18
  const role = env.SEISIN_ROLE;
18
- if (!role) return null; // not launched by seisin: say nothing, block nothing
19
+ if (!role) return null; // not launched by seisin: say nothing, stop nothing
19
20
 
20
21
  let raw = "";
21
22
  for await (const chunk of stdin) raw += chunk;
@@ -24,7 +25,7 @@ export async function hook(stdin = process.stdin, env = process.env) {
24
25
  try {
25
26
  event = JSON.parse(raw || "{}");
26
27
  } catch {
27
- return null; // an unreadable event is not grounds to block work
28
+ return null; // an unreadable event is not grounds to stop work
28
29
  }
29
30
 
30
31
  let config;
@@ -34,17 +35,30 @@ export async function hook(stdin = process.stdin, env = process.env) {
34
35
  return null;
35
36
  }
36
37
  if (!config.roles[role]) return null;
38
+ // Set by `seisin run --handoff`; it words the denial and decides nothing
39
+ // (handoff.js, CHAIN_ENV).
40
+ const chain = chainFromEnv(env);
37
41
 
38
42
  // One command for every event it is wired to, so `seisin wire` adds one name.
39
43
  // The after-the-fact and session-start halves only explain; neither decides.
40
44
  const event_ = event.hook_event_name;
41
45
  if (event_ === "PostToolUse" || event_ === "PostToolUseFailure") {
42
- try { return await afterTool(config, role, event, { file: logPath(config.root) }); } catch { return null; }
46
+ try { return await afterTool(config, role, event, { file: logPath(config.root), chain }); } catch { return null; }
43
47
  }
44
48
  if (event_ === "SessionStart") {
45
- try { return atSessionStart(config, role, event, { file: logPath(config.root) }); } catch { return null; }
49
+ try { return atSessionStart(config, role, event, { file: logPath(config.root), chain }); } catch { return null; }
50
+ }
51
+ // Caught like the two halves above, and for a sharper reason: PreToolUse is
52
+ // the one event where a crash is not silent. cli.js turns a throw into exit
53
+ // 2, and Claude Code reads exit 2 from this hook as "block the tool". A bug
54
+ // here would stop the agent's work — the one thing this command promises
55
+ // never to do. Said on stderr so the bug is still findable.
56
+ let decision = null;
57
+ try {
58
+ decision = decide(config, role, event, { observe: env.SEISIN_OBSERVE === "1", chain });
59
+ } catch (e) {
60
+ process.stderr.write(`seisin hook: ${e?.message ?? e} — no decision, the tool call goes ahead\n`);
46
61
  }
47
- const decision = decide(config, role, event, { observe: env.SEISIN_OBSERVE === "1" });
48
62
  // The entries went to a socket, and cli.js exits as soon as we return. An
49
63
  // exit does not drain a socket, so the record would be lost precisely on the
50
64
  // turns that produced one.