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
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "seisin",
3
- "version": "0.4.2",
4
- "description": "A permission layer for AI coding agents: role-scoped folders and keys, enforced by the OS, and when it blocks it tells you whose file it was.",
3
+ "version": "0.6.0",
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",
7
7
  "claude-code",
@@ -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.77"
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
@@ -3,7 +3,7 @@
3
3
  * seisin — give each agent its own folders and its own keys.
4
4
  *
5
5
  * This file is dispatch and nothing else: parse argv, load the config, call one
6
- * command, turn a thrown error into an exit code. Every command lives in
6
+ * command, make a thrown error an exit code. Every command lives in
7
7
  * `commands/` and every decision lives in a module beside it, so that a change
8
8
  * to how something is printed cannot change what is allowed.
9
9
  *
@@ -19,21 +19,19 @@ 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, renderLimits } from "./help.js";
23
+ import { nearest, checkFlags } from "./suggest.js";
22
24
 
23
- import { run } from "./commands/run.js";
24
- import { check } from "./commands/check.js";
25
- import { explainCommand } from "./commands/explain.js";
26
- import { scanCommand } from "./commands/scan.js";
27
- import { init, initFromObservations } from "./commands/init.js";
28
- import { log, watch } from "./commands/log.js";
29
- import { hook } from "./commands/hook.js";
30
- import { ui } from "./commands/ui.js";
31
- import { requests, grant, deny } from "./commands/requests.js";
32
- import { whose } from "./commands/whose.js";
33
- import { wallsCommand } from "./commands/walls.js";
34
- import { reviewCommand } from "./commands/review.js";
35
- import { wire } from "./commands/wire.js";
36
- import { serveMcp } from "./mcp.js";
25
+ /**
26
+ * Each command is imported when it is the one being run, not before.
27
+ *
28
+ * All fifteen used to load up front — 42 of 47 modules, the MCP server and the
29
+ * HTTP console among them — and `seisin hook` paid for every one on every tool
30
+ * call of every agent: 218 ms measured, where the hook itself needs a fraction
31
+ * of that. A dispatcher that loads everything to run one thing charges the
32
+ * most frequent command for the rarest ones. So each entry in COMMANDS below
33
+ * does its own `import()`.
34
+ */
37
35
 
38
36
  const HERE = dirname(fileURLToPath(import.meta.url));
39
37
  const [, , command, ...argv] = process.argv;
@@ -43,23 +41,27 @@ ${C.b}seisin${C.off} — give each agent its own folders and its own keys
43
41
 
44
42
  seisin run <role> -- <command...> run a command as that role
45
43
  seisin run <role> --observe -- <cmd> open the repo and watch — the network stays shut
46
- seisin check [role] print the map, run nothing
47
- seisin explain <role> read|write <path>
48
- seisin whose <path> who owns it — safe to call from inside the box
49
- seisin scan find secrets outside the declared key dirs
50
- seisin review [--all] what the log says about the policy
44
+ seisin check [role] [--verbose] [--verify] [--parallel] [--system-log] --parallel is experimental; --system-log measures log loss (macOS)
45
+ seisin explain <role> read|write|mcp <path, key or server>
46
+ seisin whose <path> who owns it — safe to call from inside the sandbox
47
+ seisin scan [--all] find secrets outside the declared key dirs
48
+ seisin review [--all] [--since 7d] what the log says about the policy
51
49
  seisin walls <role> [--all] what that role keeps being denied, and what it cost
52
50
  seisin wire let the agent record what it does
53
51
  seisin requests what the agents asked for and cannot have
54
- seisin grant <n> [--reason "…"] approve one, with its provenance
55
- seisin decline <n> [--reason "…"] turn one down, and record why
56
- seisin log [--role r] [--verdict denied]
52
+ seisin grant <n> [--reason "…"] grant one: add it to ${CONFIG_NAME}, with its provenance
53
+ seisin decline <n> [--reason "…"] decline one, and record why
54
+ seisin log [--role r] [--verdict denied] [--limit n]
57
55
  seisin log verify does the log's hash chain hold
58
56
  seisin watch follow the log live
59
- seisin init [--from-observations] propose a ${CONFIG_NAME} for this repo
57
+ seisin init [--force] propose a ${CONFIG_NAME} for this repo
58
+ seisin init --from-observations write ${CONFIG_NAME}.observed from observed runs
60
59
  seisin ui [--port n] [--link] open the console, live (--link reopens a running one)
61
60
  seisin mcp an MCP server on stdio — read-only
62
61
 
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.
64
+
63
65
  Enforcement comes from @anthropic-ai/sandbox-runtime, which asks the OS.
64
66
  seisin decides what to ask for, and says whose file it was when the answer is no.
65
67
  `;
@@ -71,6 +73,12 @@ function config() {
71
73
  return loadConfig(path);
72
74
  }
73
75
 
76
+ /** The policy here, or null — for a command that can answer without one. */
77
+ function maybeConfig() {
78
+ const path = findConfig();
79
+ return path ? loadConfig(path) : null;
80
+ }
81
+
74
82
  /**
75
83
  * Commands that need a config get it; commands that make one do not.
76
84
  *
@@ -78,42 +86,58 @@ function config() {
78
86
  * the exceptions — they hand the process over and never come back here.
79
87
  */
80
88
  const COMMANDS = {
81
- run: async () => await run(config(), argv),
89
+ run: async () => await (await import("./commands/run.js")).run(config(), argv),
82
90
  // Non-zero when the policy cannot be enforced as written, so `seisin check`
83
91
  // composes in a pre-commit hook or CI the way `explain` and `scan` already do.
84
92
  // Warnings about a config that WILL work still exit 0 — failing on those
85
93
  // would make the command unusable within a week.
86
- check: () => (check(config(), argv).warnings.some((w) => w.kind === "cannot-be-enforced") ? 1 : 0),
87
- explain: () => (explainCommand(config(), argv).allowed ? 0 : 1),
88
- scan: () => (scanCommand(config()).certain.length ? 1 : 0),
89
- log: () => void log(config(), argv),
90
- watch: () => void watch(config()),
91
- init: () => void (argv.includes("--from-observations") ? initFromObservations(config()) : init()),
92
- ui: () => ui(config(), argv),
93
- whose: () => void whose(config(), argv),
94
- wire: () => void wire(config()),
95
- review: () => (reviewCommand(config(), argv).friction.length ? 1 : 0),
96
- walls: () => (wallsCommand(config(), argv).length ? 1 : 0),
97
- requests: () => (requests(config()).length ? 1 : 0),
98
- grant: () => void grant(config(), argv),
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
+ },
98
+ explain: async () => ((await import("./commands/explain.js")).explainCommand(config(), argv).allowed ? 0 : 1),
99
+ scan: async () => ((await import("./commands/scan.js")).scanCommand(config(), argv).certain.length ? 1 : 0),
100
+ log: async () => void (await import("./commands/log.js")).log(config(), argv),
101
+ watch: async () => void (await import("./commands/log.js")).watch(config()),
102
+ init: async () => {
103
+ const { init, initFromObservations } = await import("./commands/init.js");
104
+ void (argv.includes("--from-observations") ? initFromObservations(config()) : init(process.cwd(), { force: argv.includes("--force") }));
105
+ },
106
+ // --link only reads a record; it answers without a policy here too, and
107
+ // says so when the console it finds serves another one.
108
+ ui: async () => (await import("./commands/ui.js")).ui(argv.includes("--link") ? maybeConfig() : config(), argv),
109
+ whose: async () => void (await import("./commands/whose.js")).whose(config(), argv),
110
+ wire: async () => void (await import("./commands/wire.js")).wire(config()),
111
+ review: async () => ((await import("./commands/review.js")).reviewCommand(config(), argv).friction.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),
114
+ grant: async () => void (await import("./commands/requests.js")).grant(config(), argv),
99
115
  /**
100
116
  * A person DECLINES a request; the boundary DENIES a write. One word each,
101
- * 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
102
118
  * entry said `first refused on …` (the kernel) and the answer said
103
- * `refused frontend ✕ …` (you).
104
- *
105
- * `deny` stays and is not documented. Renaming a published command over a
106
- * word is not worth breaking somebody's script; teaching the new one is.
119
+ * `refused frontend ✕ …` (you). The old command is gone (see RETIRED).
107
120
  */
108
- decline: () => void deny(config(), argv),
109
- deny: () => void deny(config(), argv),
121
+ decline: async () => void (await import("./commands/requests.js")).decline(config(), argv),
110
122
  mcp: async () => {
111
- await serveMcp(version());
123
+ await (await import("./mcp.js")).serveMcp(version());
112
124
  return 0;
113
125
  },
126
+ // Always 0. Claude Code reads a non-zero exit from a PreToolUse hook as
127
+ // "block", so an internal error here would stop the agent's tool call — and
128
+ // the hook explains, it never enforces. The error goes to stderr instead.
114
129
  hook: async () => {
115
- const decision = await hook();
116
- if (decision?.hookSpecificOutput) out(JSON.stringify(decision) + "\n");
130
+ try {
131
+ const decision = await (await import("./commands/hook.js")).hook();
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");
138
+ } catch (e) {
139
+ err(`seisin hook: ${e?.message ?? e} — no decision, the tool call goes ahead\n`);
140
+ }
117
141
  return 0;
118
142
  },
119
143
  "--version": () => void out(version() + "\n"),
@@ -124,16 +148,115 @@ function version() {
124
148
  return JSON.parse(readFileSync(join(HERE, "..", "package.json"), "utf8")).version;
125
149
  }
126
150
 
127
- const chosen = COMMANDS[command];
151
+ /**
152
+ * `--help` is a question, never an instruction to do the thing.
153
+ *
154
+ * No command looked for it, so each one took it as an argument: `ui --help`
155
+ * started a server on 4178, `wire --help` wrote .claude/settings.json, `watch
156
+ * --help` followed the log until ctrl-c, `mcp --help` sat waiting on stdin, and
157
+ * `seisin --help` printed the usage and exited 2. Answered here, before any
158
+ * command is loaded or any policy read, so it works in a directory with none.
159
+ *
160
+ * Only seisin's own part of the line is searched: after `--` the words are the
161
+ * agent's, and so is everything after `run <role> <command>` without one —
162
+ * `seisin run dev -- ls --help` asks `ls`. The value of a flag that takes one
163
+ * (`--reason -h`) is a value.
164
+ */
165
+ const HELP = new Set(["--help", "-h"]);
166
+ function asksHelp(name, args) {
167
+ const takesValue = new Set(flagsOf(name)?.values ?? []);
168
+ const split = args.indexOf("--");
169
+ let ours = split === -1 ? args : args.slice(0, split);
170
+ if (name === "run" && split === -1) {
171
+ let i = 1;
172
+ while (i < args.length && args[i].startsWith("-")) i += takesValue.has(args[i]) ? 2 : 1;
173
+ ours = args.slice(0, i);
174
+ }
175
+ for (let i = 0; i < ours.length; i++) {
176
+ if (takesValue.has(ours[i])) i++;
177
+ else if (HELP.has(ours[i])) return true;
178
+ }
179
+ return false;
180
+ }
181
+
182
+ /** One command's help: its flags, examples and exit codes, or the whole usage. */
183
+ function helpFor(name) {
184
+ if (COMMAND_HELP[name]) return renderHelp(name, C);
185
+ const lines = USAGE.split("\n").filter((l) => l.startsWith(` seisin ${name} `) || l === ` seisin ${name}`);
186
+ if (!lines.length) return USAGE;
187
+ return `\nusage:\n${lines.join("\n")}\n\n ${C.dim}seisin --help for every command${C.off}\n\n`;
188
+ }
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
+ }
195
+ if (command === undefined || command === "help" || HELP.has(command)) {
196
+ const about = command === "help" && COMMANDS[argv[0]] && !argv[0].startsWith("-") ? argv[0] : null;
197
+ out(about ? helpFor(about) : USAGE);
198
+ process.exit(0);
199
+ }
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
+
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
+ }
128
216
  if (!chosen) {
129
- out(USAGE);
130
- process.exit(command ? 2 : 0);
217
+ // One line, and the nearest command. The whole usage, printed for a typo,
218
+ // pushed the one line that mattered off the screen.
219
+ const near = command.startsWith("-") ? nearest(command, ["--help", "--version"])
220
+ : nearest(command, Object.keys(COMMANDS).filter((c) => !c.startsWith("-")));
221
+ err(`${C.red}seisin:${C.off} unknown ${command.startsWith("-") ? "option" : "command"} "${command}"` +
222
+ `${near ? ` — did you mean "${near}"?` : ""}\n seisin --help lists every command\n`);
223
+ process.exit(2);
224
+ }
225
+ if (!command.startsWith("-") && asksHelp(command, argv)) {
226
+ out(helpFor(command));
227
+ process.exit(0);
131
228
  }
132
229
 
133
230
  try {
231
+ refuseUnknownFlags(command, argv);
134
232
  const code = await chosen();
135
233
  if (typeof code === "number") process.exit(code);
136
234
  } catch (e) {
137
235
  err(`${C.red}seisin:${C.off} ${e.message}\n`);
138
- process.exit(2);
236
+ // A command that was not found is 127 wherever a shell runs it; everything
237
+ // else seisin refuses is a usage or policy error.
238
+ process.exit(e.exitCode ?? 2);
239
+ }
240
+
241
+ /**
242
+ * A flag a command does not take is an error, never ignored: `check --verbsoe`
243
+ * printed the short map as if nothing had been asked. For `run`, only seisin's
244
+ * own part of the line — before `--`, or the leading options — is checked.
245
+ */
246
+ function refuseUnknownFlags(name, args) {
247
+ const f = flagsOf(name);
248
+ if (!f || name === "hook" || name === "mcp") return;
249
+ let ours = args;
250
+ if (name === "run") {
251
+ const split = args.indexOf("--");
252
+ if (split !== -1) ours = args.slice(1, split);
253
+ else {
254
+ let i = 1;
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;
258
+ ours = args.slice(1, i);
259
+ }
260
+ }
261
+ checkFlags(name, ours, f.known, f.values);
139
262
  }