seisin 0.5.0 → 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 (76) hide show
  1. package/README.md +385 -129
  2. package/package.json +13 -5
  3. package/src/browsers.js +52 -0
  4. package/src/canary.js +149 -0
  5. package/src/cli.js +47 -23
  6. package/src/commands/check.js +154 -3
  7. package/src/commands/explain.js +2 -5
  8. package/src/commands/hook.js +7 -3
  9. package/src/commands/init.js +57 -50
  10. package/src/commands/requests.js +31 -56
  11. package/src/commands/review.js +33 -6
  12. package/src/commands/run.js +332 -110
  13. package/src/commands/walls.js +39 -7
  14. package/src/commands/whose.js +2 -1
  15. package/src/config.js +321 -15
  16. package/src/controls.js +5 -5
  17. package/src/diagnose.js +67 -16
  18. package/src/env.js +3 -0
  19. package/src/grants.js +107 -2
  20. package/src/handoff.js +285 -10
  21. package/src/help.js +39 -19
  22. package/src/hook.js +59 -30
  23. package/src/index.d.ts +217 -0
  24. package/src/index.js +20 -12
  25. package/src/inspect/codex.js +64 -0
  26. package/src/inspect/credentials.js +48 -0
  27. package/src/inspect/enforce.js +24 -0
  28. package/src/inspect/files.js +71 -0
  29. package/src/inspect/home.js +43 -0
  30. package/src/inspect/inject.js +68 -0
  31. package/src/inspect/keys.js +74 -0
  32. package/src/inspect/layout.js +258 -0
  33. package/src/inspect/limits.js +98 -0
  34. package/src/inspect/machine.js +107 -0
  35. package/src/inspect/network.js +37 -0
  36. package/src/inspect/providers.js +71 -0
  37. package/src/inspect/read-territory.js +105 -0
  38. package/src/inspect/role-keys.js +136 -0
  39. package/src/inspect/shared.js +37 -0
  40. package/src/inspect/warnings.js +58 -0
  41. package/src/inspect.js +13 -815
  42. package/src/intake.js +131 -21
  43. package/src/keys.js +223 -67
  44. package/src/kinds.js +42 -0
  45. package/src/lines.js +26 -0
  46. package/src/log.js +7 -2
  47. package/src/mcp.js +28 -43
  48. package/src/nested.js +12 -3
  49. package/src/notify.js +53 -12
  50. package/src/owners.js +165 -3
  51. package/src/paths.js +17 -1
  52. package/src/provider-run.js +81 -0
  53. package/src/redact.js +34 -15
  54. package/src/render.js +35 -9
  55. package/src/requests.js +363 -45
  56. package/src/review.js +21 -9
  57. package/src/rundir.js +29 -8
  58. package/src/sandboxes/command.js +93 -0
  59. package/src/sandboxes/index.js +113 -0
  60. package/src/sandboxes/mach-profile.js +31 -0
  61. package/src/sandboxes/srt-mach.mjs +43 -0
  62. package/src/sandboxes/srt.js +163 -0
  63. package/src/sandboxes/tartaros.js +80 -0
  64. package/src/scan.js +7 -3
  65. package/src/serve.js +35 -17
  66. package/src/spool.js +8 -17
  67. package/src/srt.js +263 -33
  68. package/src/surface.js +129 -16
  69. package/src/territory.js +261 -0
  70. package/src/trace.js +33 -0
  71. package/src/uilink.js +15 -6
  72. package/src/views.js +40 -14
  73. package/src/violations.js +143 -7
  74. package/src/walls.js +62 -9
  75. package/ui/index.html +85 -13
  76. package/ui/favicon.svg +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seisin",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "A permission layer for AI coding agents: role-scoped folders and keys, enforced by the OS, and when it denies something it tells you whose file it was.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -13,7 +13,10 @@
13
13
  "codeowners",
14
14
  "rbac",
15
15
  "agent-permissions",
16
- "code-ownership"
16
+ "code-ownership",
17
+ "mcp",
18
+ "security",
19
+ "developer-tools"
17
20
  ],
18
21
  "license": "MIT",
19
22
  "author": "Carlos Tapia",
@@ -31,7 +34,7 @@
31
34
  },
32
35
  "files": [
33
36
  "src",
34
- "ui",
37
+ "ui/index.html",
35
38
  "README.md",
36
39
  "LICENSE"
37
40
  ],
@@ -39,15 +42,20 @@
39
42
  "node": ">=18"
40
43
  },
41
44
  "dependencies": {
42
- "@anthropic-ai/sandbox-runtime": "0.0.78"
45
+ "@anthropic-ai/sandbox-runtime": "0.0.81"
43
46
  },
44
47
  "scripts": {
45
48
  "test": "node --test test/*.test.js",
49
+ "build:ui": "node ui/build.js",
46
50
  "prepublishOnly": "npm test"
47
51
  },
48
52
  "main": "./src/index.js",
53
+ "types": "./src/index.d.ts",
49
54
  "exports": {
50
- ".": "./src/index.js",
55
+ ".": {
56
+ "types": "./src/index.d.ts",
57
+ "import": "./src/index.js"
58
+ },
51
59
  "./package.json": "./package.json"
52
60
  }
53
61
  }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * `browsers = ["chromium"]`: what a headless browser needs from the sandbox
3
+ * beyond files and the network, per role and only when the role says so.
4
+ *
5
+ * Measured on macOS with Playwright's Chromium headless shell (build 1208), as
6
+ * the kernel saw it under the default profile:
7
+ *
8
+ * - the browser process starts by registering a Mach service named
9
+ * `org.chromium.Chromium.MachPortRendezvousServer.<its pid>`, and dies at
10
+ * once when that is refused (`bootstrap_check_in ... Permission denied
11
+ * (1100)`, FATAL in base/apple/mach_port_rendezvous_mac.cc);
12
+ * - every child it starts (GPU, network, renderer) then looks that same
13
+ * service up to receive its ports, and loops forever when the lookup is
14
+ * refused: over a thousand refusals in forty seconds, no page.
15
+ *
16
+ * Those two, with that one prefix, are all it took: with them the browser
17
+ * started, rendered and navigated through seisin's proxy, and the other Mach
18
+ * services it reached for (configd, the window server, analytics) stayed
19
+ * refused without stopping it. So that is all a preset grants. Never a bare
20
+ * `mach-register`: a name is registered in the bootstrap namespace other
21
+ * processes look names up in, and an open grant would let the role stand up a
22
+ * service under any name.
23
+ *
24
+ * The prefix is the browser's bundle id, which is why the preset is named for
25
+ * the build. Chrome and Chrome for Testing use their own (`com.google.Chrome`,
26
+ * `com.google.chrome.for.testing`) and also need a unix socket for their
27
+ * process singleton, measured and not granted here: they are not presets yet.
28
+ */
29
+ export const BROWSERS = {
30
+ chromium: {
31
+ // The browser registers it; its children look it up.
32
+ register: ["org.chromium.Chromium.MachPortRendezvousServer."],
33
+ lookup: ["org.chromium.Chromium.MachPortRendezvousServer."],
34
+ },
35
+ };
36
+
37
+ /** A Mach name prefix as the profile may carry it: no quotes, no spaces, ends in a dot. */
38
+ export const MACH_PREFIX = /^[A-Za-z0-9][A-Za-z0-9.-]*\.$/;
39
+
40
+ /** The Mach names a role's browsers need registered and looked up, de-duplicated. */
41
+ export function machOf(role) {
42
+ const names = (role?.browsers ?? []).map((b) => BROWSERS[b]).filter(Boolean);
43
+ return {
44
+ register: [...new Set(names.flatMap((b) => b.register))],
45
+ lookup: [...new Set(names.flatMap((b) => b.lookup))],
46
+ };
47
+ }
48
+
49
+ /** The preset whose register prefix this Mach name falls under, or null. */
50
+ export function browserFor(name) {
51
+ return Object.keys(BROWSERS).find((b) => BROWSERS[b].register.some((p) => name.startsWith(p))) ?? null;
52
+ }
package/src/canary.js ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * The canary: before the agent starts, the policy is tried from inside.
3
+ *
4
+ * The settings are generated by software, and software breaks. A runtime
5
+ * upgrade, a path that turned out to be a symlink, an `isolate` level combined
6
+ * with something it was not measured with — any of them can leave a door open,
7
+ * and without this the first anyone hears of it is after something went
8
+ * through it. So every run starts with a short process under the SAME runtime,
9
+ * the SAME settings file and the SAME environment as the agent, and the agent
10
+ * starts only if every answer is the expected one.
11
+ *
12
+ * Judged by effect, never by exit code: a write "denied" is a file that is not
13
+ * there afterwards; a read "denied" is a nonce that never came out; a
14
+ * connection "refused" is a nonce the decoy never delivered. And each denial
15
+ * has a positive control beside it — the sandbox ran at all, and a place the
16
+ * role may write took the write — because a sandbox that runs nothing denies
17
+ * everything too.
18
+ *
19
+ * **Fails closed, including on a missing tool.** In an orchestrator we compared against, the network
20
+ * probe passed whenever `nc` was absent: no tool, no connection, "refused".
21
+ * Here a probe that cannot be made is a failed canary.
22
+ *
23
+ * **No flag skips it.** A flag becomes an alias, and the alias becomes the way
24
+ * every run starts. It costs one extra start of the runtime, a few tenths of a
25
+ * second, against a run that lasts minutes.
26
+ */
27
+ import { spawn } from "node:child_process";
28
+ import { createServer } from "node:net";
29
+ import { randomBytes } from "node:crypto";
30
+ import { writeFileSync, readFileSync, rmSync, existsSync, statSync } from "node:fs";
31
+ import { join, dirname } from "node:path";
32
+
33
+ /** Where sandbox-runtime's shared temp directory is on this host. */
34
+ const SHARED_TMP_DIR = "/tmp/claude";
35
+
36
+ /** The exit code of a run the canary stopped: not the agent's, and not one a shell uses. */
37
+ export const CANARY_EXIT = 86;
38
+
39
+ const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
40
+
41
+ /**
42
+ * Runs the canary and resolves to `{ ok, failed: [reasons], ms }`.
43
+ *
44
+ * `runDir` is this run's directory and `runsRoot` the one above it; the
45
+ * secret is planted in the second, which the settings deny to every run but
46
+ * the run's own directory. `role.localBinding` skips the network probe and
47
+ * says so: that role was granted every localhost port on purpose.
48
+ */
49
+ export async function runCanary({ srt, wrap = null, settingsFile, settings, env, cwd, runDir, runsRoot, role, granted, timeoutMs = 20_000 }) {
50
+ // How the probe is started: under the run's sandbox, exactly as the agent
51
+ // will be (sandboxes/). `srt` alone is the runtime's own command line, kept
52
+ // for callers from before there was more than one sandbox.
53
+ const boxed = wrap ?? ((inner) => [srt, "--settings", settingsFile, "--", ...inner]);
54
+ const t0 = Date.now();
55
+ const nonce = randomBytes(8).toString("hex");
56
+ const failed = [];
57
+
58
+ const secret = join(runsRoot, `canary-${nonce}.secret`);
59
+ const outside = join(runDir, `canary-${nonce}.out`);
60
+ // The first directory the POLICY grants that exists — taken from the
61
+ // policy and not from the settings, so settings that lost the grant fail
62
+ // here instead of skipping the check. Absent (a role with nothing to
63
+ // write), the write control is skipped and "the sandbox ran" stands alone.
64
+ // Outside the repo first (the runtime's scratch, the role's home): a file
65
+ // appearing and vanishing in a watched tree restarts a dev server.
66
+ const candidates = (granted ?? settings.filesystem.allowWrite ?? []).filter((p) => isDir(p));
67
+ const writable = candidates.find((p) => !(cwd && (p === cwd || p.startsWith(cwd + "/") || cwd.startsWith(p + "/")))) ?? candidates[0];
68
+ const inside = writable ? join(writable, `.seisin-canary-${nonce}`) : null;
69
+ // The runtime's shared temp directory (srt.js, SHARED_TMP): granted by the
70
+ // runtime to every sandbox, taken back by seisin. Probed where it exists;
71
+ // where it does not, there is nothing beside which to write.
72
+ const shared = isDir(SHARED_TMP_DIR) ? join(SHARED_TMP_DIR, `.seisin-canary-${nonce}`) : null;
73
+ const policy = settingsFile;
74
+
75
+ writeFileSync(secret, `SECRET-${nonce}\n`, { mode: 0o600 });
76
+
77
+ let server = null;
78
+ let port = null;
79
+ if (!role.localBinding) {
80
+ server = createServer((s) => s.end(`HTTP/1.0 200 OK\r\nContent-Type: text/plain\r\n\r\nNET-${nonce}\n`));
81
+ await new Promise((res, rej) => server.listen(0, "127.0.0.1", res).on("error", rej));
82
+ port = server.address().port;
83
+ }
84
+
85
+ const q = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
86
+ const script = [
87
+ `echo ALIVE-${nonce}`,
88
+ `(echo x > ${q(outside)}) 2>/dev/null`,
89
+ `cat ${q(secret)} 2>/dev/null`,
90
+ `cat ${q(policy)} >/dev/null 2>&1 && echo POLICY-READ-${nonce}`,
91
+ inside ? `(echo IN-${nonce} > ${q(inside)}) 2>/dev/null` : ":",
92
+ shared ? `(echo x > ${q(shared)}) 2>/dev/null` : ":",
93
+ // curl when there is one; otherwise the node seisin itself runs on, which
94
+ // every machine with seisin has — a slim image without curl would
95
+ // otherwise never start anything. Neither: a failed canary, not a pass.
96
+ port ? `if command -v curl >/dev/null 2>&1; then curl -s -m 3 -w PROBED-${nonce} http://127.0.0.1:${port}/ 2>/dev/null; ` +
97
+ `elif [ -x ${q(process.execPath)} ]; then ${q(process.execPath)} -e ${q(`process.stdout.write("PROBED-${nonce}");const s=require("net").connect(${port},"127.0.0.1");s.on("data",d=>process.stdout.write(d));s.on("error",()=>{});setTimeout(()=>process.exit(0),3000)`)} 2>/dev/null; ` +
98
+ `else echo NO-PROBE-${nonce}; fi` : ":",
99
+ ].join("; ");
100
+
101
+ let output = "";
102
+ let stderr = "";
103
+ let timedOut = false;
104
+ try {
105
+ const status = await new Promise((resolve) => {
106
+ const [file, ...args] = boxed(["/bin/sh", "-c", script]);
107
+ const child = spawn(file, args, {
108
+ cwd, env, stdio: ["ignore", "pipe", "pipe"],
109
+ });
110
+ const timer = setTimeout(() => { timedOut = true; try { child.kill("SIGKILL"); } catch {} }, timeoutMs);
111
+ child.stdout.on("data", (d) => { output += d; });
112
+ child.stderr.on("data", (d) => { stderr = (stderr + d).slice(-2000); });
113
+ child.on("error", () => { clearTimeout(timer); resolve(null); });
114
+ child.on("close", (code) => { clearTimeout(timer); resolve(code); });
115
+ });
116
+ void status;
117
+ } finally {
118
+ server?.close();
119
+ }
120
+
121
+ if (timedOut) failed.push(`the canary did not finish in ${timeoutMs / 1000}s`);
122
+ if (!output.includes(`ALIVE-${nonce}`)) {
123
+ // The runtime's own words, when it said any: a sandbox that refuses to
124
+ // start usually says why (measured: a path the runtime read as a broken
125
+ // glob), and without this the reason was thrown away with the stream.
126
+ const said = stderr.trim().split("\n").filter(Boolean).slice(-3).join(" / ");
127
+ failed.push(`the sandbox did not run the canary at all${said ? ` — the runtime said: ${said}` : ""}`);
128
+ }
129
+ if (existsSync(outside)) failed.push(`a write outside the territory went through (${outside})`);
130
+ if (shared && existsSync(shared)) failed.push(`a write into the runtime's shared temp directory went through (${shared})`);
131
+ if (output.includes(`SECRET-${nonce}`)) failed.push(`a planted secret was readable (${secret})`);
132
+ if (output.includes(`POLICY-READ-${nonce}`)) failed.push(`the run's own settings were readable (${policy})`);
133
+ if (inside) {
134
+ let wrote = false;
135
+ try { wrote = readFileSync(inside, "utf8").trim() === `IN-${nonce}`; } catch {}
136
+ if (!wrote) failed.push(`a write the role is granted did not go through (${dirname(inside)}) — the positive control`);
137
+ }
138
+ if (port) {
139
+ // The probe has to say it ran. Silence is not "unreachable": a node the
140
+ // sandbox will not execute prints nothing, and nothing would read as a pass.
141
+ if (!output.includes(`PROBED-${nonce}`) && !output.includes(`NO-PROBE-${nonce}`))
142
+ failed.push("the network probe did not run — a probe that cannot be made is not a pass");
143
+ if (output.includes(`NO-PROBE-${nonce}`)) failed.push("neither curl nor node can run inside the sandbox to probe the network with — a probe that cannot be made is not a pass");
144
+ if (output.includes(`NET-${nonce}`)) failed.push(`a loopback port outside the policy was reachable (127.0.0.1:${port})`);
145
+ }
146
+
147
+ for (const p of [secret, outside, inside, shared]) if (p) try { rmSync(p, { force: true }); } catch {}
148
+ return { ok: failed.length === 0, failed, ms: Date.now() - t0 };
149
+ }
package/src/cli.js CHANGED
@@ -19,7 +19,7 @@ import { fileURLToPath } from "node:url";
19
19
  import { findConfig, loadConfig } from "./config.js";
20
20
  import { CONFIG_NAME } from "./layout.js";
21
21
  import { C, out, err } from "./render.js";
22
- import { COMMAND_HELP, flagsOf, renderHelp } from "./help.js";
22
+ import { COMMAND_HELP, flagsOf, renderHelp, renderLimits } from "./help.js";
23
23
  import { nearest, checkFlags } from "./suggest.js";
24
24
 
25
25
  /**
@@ -41,11 +41,11 @@ ${C.b}seisin${C.off} — give each agent its own folders and its own keys
41
41
 
42
42
  seisin run <role> -- <command...> run a command as that role
43
43
  seisin run <role> --observe -- <cmd> open the repo and watch — the network stays shut
44
- seisin check [role] [--verbose] print the map, run nothing
44
+ seisin check [role] [--verbose] [--verify] [--parallel] [--system-log] --parallel is experimental; --system-log measures log loss (macOS)
45
45
  seisin explain <role> read|write|mcp <path, key or server>
46
46
  seisin whose <path> who owns it — safe to call from inside the sandbox
47
47
  seisin scan [--all] find secrets outside the declared key dirs
48
- seisin review [--all] what the log says about the policy
48
+ seisin review [--all] [--since 7d] what the log says about the policy
49
49
  seisin walls <role> [--all] what that role keeps being denied, and what it cost
50
50
  seisin wire let the agent record what it does
51
51
  seisin requests what the agents asked for and cannot have
@@ -60,6 +60,7 @@ ${C.b}seisin${C.off} — give each agent its own folders and its own keys
60
60
  seisin mcp an MCP server on stdio — read-only
61
61
 
62
62
  seisin <command> --help lists a command's flags, examples and exit codes.
63
+ seisin help limits says what seisin cannot do, in any repo.
63
64
 
64
65
  Enforcement comes from @anthropic-ai/sandbox-runtime, which asks the OS.
65
66
  seisin decides what to ask for, and says whose file it was when the answer is no.
@@ -90,7 +91,10 @@ const COMMANDS = {
90
91
  // composes in a pre-commit hook or CI the way `explain` and `scan` already do.
91
92
  // Warnings about a config that WILL work still exit 0 — failing on those
92
93
  // would make the command unusable within a week.
93
- check: async () => ((await import("./commands/check.js")).check(config(), argv).warnings.some((w) => w.kind === "cannot-be-enforced") ? 1 : 0),
94
+ check: async () => {
95
+ const report = await (await import("./commands/check.js")).check(config(), argv);
96
+ return (await import("./commands/check.js")).checkStatus(report);
97
+ },
94
98
  explain: async () => ((await import("./commands/explain.js")).explainCommand(config(), argv).allowed ? 0 : 1),
95
99
  scan: async () => ((await import("./commands/scan.js")).scanCommand(config(), argv).certain.length ? 1 : 0),
96
100
  log: async () => void (await import("./commands/log.js")).log(config(), argv),
@@ -105,20 +109,16 @@ const COMMANDS = {
105
109
  whose: async () => void (await import("./commands/whose.js")).whose(config(), argv),
106
110
  wire: async () => void (await import("./commands/wire.js")).wire(config()),
107
111
  review: async () => ((await import("./commands/review.js")).reviewCommand(config(), argv).friction.length ? 1 : 0),
108
- walls: async () => ((await import("./commands/walls.js")).wallsCommand(config(), argv).length ? 1 : 0),
109
- requests: async () => ((await import("./commands/requests.js")).requests(config()).length ? 1 : 0),
112
+ walls: async () => ((await import("./commands/walls.js")).wallsCommand(config(), argv).some((w) => !w.expected) ? 1 : 0),
113
+ requests: async () => ((await import("./commands/requests.js")).requests(config(), argv).length ? 1 : 0),
110
114
  grant: async () => void (await import("./commands/requests.js")).grant(config(), argv),
111
115
  /**
112
116
  * A person DECLINES a request; the boundary DENIES a write. One word each,
113
- * because they are opposite events and `deny` used to name both — a queue
117
+ * because they are opposite events and one word used to name both — a queue
114
118
  * entry said `first refused on …` (the kernel) and the answer said
115
- * `refused frontend ✕ …` (you).
116
- *
117
- * `deny` stays and is not documented. Renaming a published command over a
118
- * word is not worth breaking somebody's script; teaching the new one is.
119
+ * `refused frontend ✕ …` (you). The old command is gone (see RETIRED).
119
120
  */
120
- decline: async () => void (await import("./commands/requests.js")).deny(config(), argv),
121
- deny: async () => void (await import("./commands/requests.js")).deny(config(), argv),
121
+ decline: async () => void (await import("./commands/requests.js")).decline(config(), argv),
122
122
  mcp: async () => {
123
123
  await (await import("./mcp.js")).serveMcp(version());
124
124
  return 0;
@@ -129,7 +129,12 @@ const COMMANDS = {
129
129
  hook: async () => {
130
130
  try {
131
131
  const decision = await (await import("./commands/hook.js")).hook();
132
- if (decision?.hookSpecificOutput) out(JSON.stringify(decision) + "\n");
132
+ // Only what the hook protocol defines. `decision` and `logged` are for the
133
+ // callers inside seisin; Codex validates a hook's output against a schema
134
+ // that admits no other field (and `decision` only as approve|block), and
135
+ // drops the whole answer when it does not fit — measured 2026-09-30 with
136
+ // codex 0.150.1: the deny was ignored and the command ran into the kernel.
137
+ if (decision?.hookSpecificOutput) out(JSON.stringify({ hookSpecificOutput: decision.hookSpecificOutput }) + "\n");
133
138
  } catch (e) {
134
139
  err(`seisin hook: ${e?.message ?? e} — no decision, the tool call goes ahead\n`);
135
140
  }
@@ -158,18 +163,17 @@ function version() {
158
163
  * (`--reason -h`) is a value.
159
164
  */
160
165
  const HELP = new Set(["--help", "-h"]);
161
- const TAKES_VALUE = new Set(["--port", "--role", "--verdict", "--limit", "--min", "--since", "--reason"]);
162
-
163
166
  function asksHelp(name, args) {
167
+ const takesValue = new Set(flagsOf(name)?.values ?? []);
164
168
  const split = args.indexOf("--");
165
169
  let ours = split === -1 ? args : args.slice(0, split);
166
170
  if (name === "run" && split === -1) {
167
171
  let i = 1;
168
- while (i < args.length && args[i].startsWith("-")) i++;
172
+ while (i < args.length && args[i].startsWith("-")) i += takesValue.has(args[i]) ? 2 : 1;
169
173
  ours = args.slice(0, i);
170
174
  }
171
175
  for (let i = 0; i < ours.length; i++) {
172
- if (TAKES_VALUE.has(ours[i])) i++;
176
+ if (takesValue.has(ours[i])) i++;
173
177
  else if (HELP.has(ours[i])) return true;
174
178
  }
175
179
  return false;
@@ -178,24 +182,42 @@ function asksHelp(name, args) {
178
182
  /** One command's help: its flags, examples and exit codes, or the whole usage. */
179
183
  function helpFor(name) {
180
184
  if (COMMAND_HELP[name]) return renderHelp(name, C);
181
- const alias = name === "deny" ? "decline" : name;
182
- const lines = USAGE.split("\n").filter((l) => l.startsWith(` seisin ${alias} `) || l === ` seisin ${alias}`);
185
+ const lines = USAGE.split("\n").filter((l) => l.startsWith(` seisin ${name} `) || l === ` seisin ${name}`);
183
186
  if (!lines.length) return USAGE;
184
187
  return `\nusage:\n${lines.join("\n")}\n\n ${C.dim}seisin --help for every command${C.off}\n\n`;
185
188
  }
186
189
 
190
+ // The standing limits: the same for every repo, so they are help, not `check`.
191
+ if (command === "help" && argv[0] === "limits") {
192
+ out(renderLimits(C));
193
+ process.exit(0);
194
+ }
187
195
  if (command === undefined || command === "help" || HELP.has(command)) {
188
196
  const about = command === "help" && COMMANDS[argv[0]] && !argv[0].startsWith("-") ? argv[0] : null;
189
197
  out(about ? helpFor(about) : USAGE);
190
198
  process.exit(0);
191
199
  }
192
200
 
201
+ /**
202
+ * Commands that were renamed, and what to type now. Not aliases: the old name
203
+ * runs nothing, so a script that calls it stops and says what changed rather
204
+ * than doing the right thing under the wrong word for ever.
205
+ */
206
+ const RETIRED = {
207
+ deny: `"seisin deny" was removed in 0.6.0: a person declines, the boundary denies.\n` +
208
+ ` Use seisin decline <n|id> [--reason "…"] — same arguments, same effect.`,
209
+ };
210
+
193
211
  const chosen = Object.hasOwn(COMMANDS, command) ? COMMANDS[command] : null;
212
+ if (!chosen && Object.hasOwn(RETIRED, command)) {
213
+ err(`${C.red}seisin:${C.off} ${RETIRED[command]}\n`);
214
+ process.exit(2);
215
+ }
194
216
  if (!chosen) {
195
217
  // One line, and the nearest command. The whole usage, printed for a typo,
196
218
  // pushed the one line that mattered off the screen.
197
219
  const near = command.startsWith("-") ? nearest(command, ["--help", "--version"])
198
- : nearest(command, Object.keys(COMMANDS).filter((c) => !c.startsWith("-") && c !== "deny"));
220
+ : nearest(command, Object.keys(COMMANDS).filter((c) => !c.startsWith("-")));
199
221
  err(`${C.red}seisin:${C.off} unknown ${command.startsWith("-") ? "option" : "command"} "${command}"` +
200
222
  `${near ? ` — did you mean "${near}"?` : ""}\n seisin --help lists every command\n`);
201
223
  process.exit(2);
@@ -230,9 +252,11 @@ function refuseUnknownFlags(name, args) {
230
252
  if (split !== -1) ours = args.slice(1, split);
231
253
  else {
232
254
  let i = 1;
233
- while (i < args.length && args[i].startsWith("-")) i++;
255
+ // Every flag that takes a value, from the one table: listing them here by
256
+ // hand left `--handoff` out, so its id was checked as a flag of its own.
257
+ while (i < args.length && args[i].startsWith("-")) i += f.values.includes(args[i]) ? 2 : 1;
234
258
  ours = args.slice(1, i);
235
259
  }
236
260
  }
237
- checkFlags(name === "deny" ? "decline" : name, ours, f.known, f.values);
261
+ checkFlags(name, ours, f.known, f.values);
238
262
  }
@@ -1,10 +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 = []) {
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");
6
16
  const role = argv.find((a) => !a.startsWith("-")) ?? null;
7
17
  const report = inspect(config, role, config.path);
8
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();
9
22
  return report;
10
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
+ }
@@ -7,8 +7,7 @@
7
7
  */
8
8
  import { toRepoRelative, fromCwd } from "../paths.js";
9
9
  import { isAbsolute } from "node:path";
10
- import { explain, explainFileRead, readTarget } from "../owners.js";
11
- import { CREDENTIAL_HOMES, expand } from "../grants.js";
10
+ import { explain, readTarget } from "../owners.js";
12
11
  import { unknownRole } from "../suggest.js";
13
12
  import { twinsOf, whereIs } from "../worktree.js";
14
13
  import { renderVerdict, C, out } from "../render.js";
@@ -33,9 +32,7 @@ export function explainCommand(config, argv = []) {
33
32
 
34
33
  if (verb === "read") {
35
34
  const asked = readTarget(config, target);
36
- const verdict = asked.key
37
- ? explain(config, role, "read", asked.target)
38
- : explainFileRead(config, role, asked.target, CREDENTIAL_HOMES.map(expand));
35
+ const verdict = explain(config, role, "read", target, process.cwd());
39
36
  out(renderVerdict(role, verb, asked.target, verdict));
40
37
  return { ...verdict, worktree: [] };
41
38
  }
@@ -12,6 +12,7 @@ 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;
@@ -34,15 +35,18 @@ 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; }
46
50
  }
47
51
  // Caught like the two halves above, and for a sharper reason: PreToolUse is
48
52
  // the one event where a crash is not silent. cli.js turns a throw into exit
@@ -51,7 +55,7 @@ export async function hook(stdin = process.stdin, env = process.env) {
51
55
  // never to do. Said on stderr so the bug is still findable.
52
56
  let decision = null;
53
57
  try {
54
- decision = decide(config, role, event, { observe: env.SEISIN_OBSERVE === "1" });
58
+ decision = decide(config, role, event, { observe: env.SEISIN_OBSERVE === "1", chain });
55
59
  } catch (e) {
56
60
  process.stderr.write(`seisin hook: ${e?.message ?? e} — no decision, the tool call goes ahead\n`);
57
61
  }