seisin 0.4.2 → 0.5.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 (50) hide show
  1. package/README.md +142 -54
  2. package/package.json +3 -3
  3. package/src/cli.js +145 -46
  4. package/src/commands/check.js +3 -2
  5. package/src/commands/explain.js +34 -13
  6. package/src/commands/hook.js +14 -4
  7. package/src/commands/init.js +126 -38
  8. package/src/commands/log.js +48 -9
  9. package/src/commands/requests.js +109 -32
  10. package/src/commands/review.js +41 -16
  11. package/src/commands/run.js +159 -37
  12. package/src/commands/scan.js +18 -3
  13. package/src/commands/ui.js +18 -5
  14. package/src/commands/walls.js +25 -6
  15. package/src/commands/whose.js +7 -6
  16. package/src/commands/wire.js +156 -17
  17. package/src/config.js +187 -25
  18. package/src/controls.js +244 -0
  19. package/src/diagnose.js +35 -33
  20. package/src/env.js +11 -8
  21. package/src/grants.js +15 -7
  22. package/src/help.js +159 -0
  23. package/src/hook.js +49 -19
  24. package/src/index.js +4 -4
  25. package/src/inspect.js +137 -71
  26. package/src/intake.js +20 -15
  27. package/src/keys.js +13 -11
  28. package/src/kinds.js +207 -0
  29. package/src/log.js +236 -51
  30. package/src/mcp.js +42 -28
  31. package/src/notify.js +13 -8
  32. package/src/owners.js +198 -41
  33. package/src/paths.js +37 -1
  34. package/src/redact.js +19 -5
  35. package/src/render.js +68 -10
  36. package/src/requests.js +173 -72
  37. package/src/review.js +30 -17
  38. package/src/rundir.js +4 -6
  39. package/src/scan.js +177 -19
  40. package/src/serve.js +377 -229
  41. package/src/spool.js +9 -29
  42. package/src/srt.js +38 -21
  43. package/src/suggest.js +58 -0
  44. package/src/surface.js +191 -44
  45. package/src/uilink.js +10 -4
  46. package/src/views.js +289 -0
  47. package/src/violations.js +104 -16
  48. package/src/walls.js +19 -42
  49. package/src/worktree.js +3 -11
  50. package/ui/index.html +1356 -537
package/src/spool.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * ── Why this exists ──
5
5
  * `.seisin/` used to be in every role's allowWrite, because the hook runs
6
- * inside the box and has to record what it decided. That made the log and the
6
+ * inside the sandbox and has to record what it decided. That made the log and the
7
7
  * request queue writable by the process they are a record of: an agent could
8
8
  * truncate `log.jsonl`, rewrite a decision, or invent a request that had never
9
9
  * been refused. The files said "append-only" in their own comments and nothing
@@ -27,34 +27,14 @@
27
27
  * else did.
28
28
  */
29
29
  import { createServer, createConnection, Socket } from "node:net";
30
- import { unlinkSync, existsSync, mkdtempSync, chmodSync, openSync, writeSync, closeSync, constants } from "node:fs";
30
+ import { unlinkSync, existsSync, openSync, writeSync, closeSync, constants } from "node:fs";
31
31
  import { execFileSync } from "node:child_process";
32
- import { tmpdir } from "node:os";
33
- import { join, dirname } from "node:path";
34
32
 
35
33
  /** The env var a confined hook looks for. Absent means "write the file". */
36
34
  export const SOCK_ENV = "SEISIN_SPOOL";
37
35
 
38
36
  /**
39
- * Where the socket lives.
40
- *
41
- * Not under the repo: the point is a path the role's territory does not cover.
42
- * Kept short because a unix socket path is capped near 104 bytes on macOS and
43
- * the failure when it is too long is an unhelpful EINVAL.
44
- */
45
- export function spoolPath() {
46
- // A random directory, 0700, rather than a name anyone can guess from the pid.
47
- // The socket itself is already denyWrite so the agent cannot unlink it, but
48
- // any other process running as this user could otherwise connect and add
49
- // lines. That does not let them erase anything — forgery is the part that
50
- // stays open — but a predictable path invites it for free.
51
- const dir = mkdtempSync(join(tmpdir(), "seisin-"));
52
- chmodSync(dir, 0o700);
53
- return join(dir, "spool.sock");
54
- }
55
-
56
- /**
57
- * Listens for lines from inside the box and hands each to `sink`.
37
+ * Listens for lines from inside the sandbox and hands each to `sink`.
58
38
  *
59
39
  * One line is one JSON object with a `to` field naming which file it belongs
60
40
  * in. The parent decides what that means; the sender does not get to pick a
@@ -63,7 +43,7 @@ export function spoolPath() {
63
43
  /**
64
44
  * Which channel this platform can use: a unix socket, or a FIFO.
65
45
  *
66
- * On Linux the runtime blocks creating ANY unix socket inside the box with a
46
+ * On Linux the runtime blocks creating ANY unix socket inside the sandbox with a
67
47
  * seccomp filter — it cannot filter by path, so `allowUnixSockets` is ignored
68
48
  * there (its README says so). The socket this file was built on therefore
69
49
  * never carried a line on Linux: measured in Docker on 2026-09-23, with the
@@ -96,7 +76,7 @@ function lineReader(sink, onTooLong) {
96
76
  const msg = JSON.parse(line);
97
77
  if (msg && (msg.to === "log" || msg.to === "requests")) sink(msg.to, msg.entry);
98
78
  } catch {
99
- // Garbage from inside the box is not the parent's emergency.
79
+ // Garbage from inside the sandbox is not the parent's emergency.
100
80
  }
101
81
  }
102
82
  };
@@ -125,7 +105,7 @@ function fifoSpool(sink, path) {
125
105
  });
126
106
  }
127
107
 
128
- export function spool(sink, path = spoolPath()) {
108
+ export function spool(sink, path) {
129
109
  if (path.endsWith(FIFO_SUFFIX)) return fifoSpool(sink, path);
130
110
  if (existsSync(path)) unlinkSync(path);
131
111
 
@@ -143,7 +123,7 @@ export function spool(sink, path = spoolPath()) {
143
123
  * too long for a unix socket, a directory that is not there — left an awaited
144
124
  * promise pending forever. What the user saw was Node's "Detected unsettled
145
125
  * top-level await" and rc=13: no error, no path, nothing naming the spool.
146
- * Measured while running seisin inside seisin, where the outer box refuses
126
+ * Measured while running seisin inside seisin, where the outer sandbox denies
147
127
  * the inner bind; but nothing about it is particular to that case, which is
148
128
  * the reason it is fixed here rather than beside the nesting check.
149
129
  *
@@ -161,11 +141,11 @@ export function spool(sink, path = spoolPath()) {
161
141
  close() {
162
142
  server.close();
163
143
  // Only the socket. The first version removed `dirname(path)` too,
164
- // reasoning that spoolPath() had just made that directory — but a
144
+ // reasoning that the spool had just made that directory — but a
165
145
  // caller passing its own path makes dirname the system temp directory,
166
146
  // and closing the spool deleted it. That is a caller's whole scratch
167
147
  // space gone, for a cleanup. Whoever created the directory removes it:
168
- // see `run`, which owns the one spoolPath() makes.
148
+ // see `run`, whose run directory (rundir.js) holds the socket.
169
149
  try { unlinkSync(path); } catch {}
170
150
  },
171
151
  });
package/src/srt.js CHANGED
@@ -13,6 +13,8 @@
13
13
  * back to its defaults. Emitting the whole object removes that class of
14
14
  * error, which is most of what this file is for.
15
15
  */
16
+ import { unknownRole } from "./suggest.js";
17
+ import { existsSync, mkdirSync } from "node:fs";
16
18
  import { join, dirname } from "node:path";
17
19
  import { STATE_DIR, CONFIG_NAME } from "./layout.js";
18
20
  import { entriesOf } from "./keys.js";
@@ -79,7 +81,7 @@ export const NO_PROXY_WITHOUT_LOOPBACK = "169.254.0.0/16,10.0.0.0/8,172.16.0.0/1
79
81
  *
80
82
  * An `env` in front and not a variable in the environment seisin passes: the
81
83
  * runtime sets NO_PROXY itself, after seisin's environment, so the only place
82
- * that wins is inside the box. Clients that ignore HTTP_PROXY — a MySQL driver,
84
+ * that wins is inside the sandbox. Clients that ignore HTTP_PROXY — a MySQL driver,
83
85
  * Chromium, which exempts loopback on its own — still get refused. They need
84
86
  * to be pointed at the proxy, or the port needs `local_binding`, which opens
85
87
  * every port.
@@ -91,12 +93,12 @@ export function loopbackVia(role, cmd) {
91
93
 
92
94
  export function settingsFor(config, roleName, spool = null, observe = false) {
93
95
  const role = config.roles[roleName];
94
- if (!role) throw new Error(`unknown role "${roleName}". Known: ${Object.keys(config.roles).join(", ")}`);
96
+ if (!role) throw unknownRole(config, roleName);
95
97
 
96
98
  const abs = (p) => (p.startsWith("/") ? p : join(config.root, p));
97
99
  // "credentials" closes the places credentials live; "home" does that and
98
100
  // gives the role a home of its own. Only the second one needs a new HOME,
99
- // and only the second one signs every CLI in the box out. See config.js.
101
+ // and only the second one signs every CLI in the sandbox out. See config.js.
100
102
  //
101
103
  // `true` is normalised here as well as in the config reader, because a caller
102
104
  // embedding the library builds this object itself and `isolate: true` has
@@ -104,6 +106,30 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
104
106
  const level = config.isolate === true ? "home" : config.isolate;
105
107
  const shielded = level === "credentials" || level === "home";
106
108
  const isolated = level === "home";
109
+ /**
110
+ * `sandbox-runtime` silently makes these writable as conveniences even when
111
+ * they are absent from `allowWrite`. That gives every role a shared host
112
+ * directory outside its territory. A denyRead makes the runtime drop its
113
+ * implicit grant; denyWrite also wins over Seisin's explicit `~/.claude`
114
+ * runtime scratch grant for the nested debug directory.
115
+ *
116
+ * On macOS both are named whether they exist or not: the profile is a set of
117
+ * patterns, and a missing directory costs nothing. On Linux only the ones
118
+ * that exist are. bubblewrap denies a missing path by placing something on
119
+ * the host for the length of the run, in the user's home, where other tools
120
+ * see it — and a run that is killed leaves it behind. So a missing one is
121
+ * either not needed or made first:
122
+ * - its parent is not one a role may write (`~/.npm`): nothing can create
123
+ * it inside the sandbox, and there is nothing to deny;
124
+ * - its parent is (`~/.claude`, the runtime scratch every role gets): a role
125
+ * could create it, so it is created here, outside the sandbox, and denied like
126
+ * any directory that exists. It is the directory Claude Code itself logs
127
+ * to; making it empty changes nothing for anyone.
128
+ */
129
+ const scratch = writePathsOf(config, config.roles[roleName] ?? { name: roleName, writes: [] });
130
+ const runtimeConvenience = ["~/.npm/_logs", "~/.claude/debug"]
131
+ .map(expand)
132
+ .filter((p) => process.platform !== "linux" || existsSync(p) || madeFirst(p, scratch));
107
133
 
108
134
  /**
109
135
  * Refuse before the runtime does, because the runtime's refusal says nothing.
@@ -127,6 +153,7 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
127
153
  const home = isolated ? roleHome(config, roleName) : null;
128
154
  const denyRead = [];
129
155
  const allowRead = [];
156
+ denyRead.push(...runtimeConvenience);
130
157
 
131
158
  // Every declared directory is denied, then each key the role names is
132
159
  // re-allowed. A key written without a directory resolves against the first
@@ -301,6 +328,7 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
301
328
  * relying on someone else's implementation detail staying put.
302
329
  */
303
330
  denyWrite: [
331
+ ...runtimeConvenience,
304
332
  /**
305
333
  * The role's own subtractions, `never_writes`.
306
334
  *
@@ -318,7 +346,7 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
318
346
  .flatMap((p) => { const r = realAncestor(p); return r === p ? [p] : [p, r]; }),
319
347
  /**
320
348
  * Everything the parent reads or executes, and every file that tells a
321
- * program outside the box what to run — the policy, `.seisin/`, the key
349
+ * program outside the sandbox what to run — the policy, `.seisin/`, the key
322
350
  * directories, the key providers, `file://` targets, programs on PATH
323
351
  * installed where a role writes, and each project's hooks and settings.
324
352
  * One list with a reason per entry, built in surface.js, which says why
@@ -343,27 +371,16 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
343
371
  * verify through the Security framework — Go before 1.27 (gh, kubectl,
344
372
  * terraform) and every Dart/Flutter. The runtime ships it as
345
373
  * `enableWeakerNetworkIsolation` and warns it is an exfiltration path:
346
- * trustd fetches, outside the box, the URLs a certificate carries. Per role
374
+ * trustd fetches, outside the sandbox, the URLs a certificate carries. Per role
347
375
  * and off by default, so the answer is written next to the role that needs it.
348
376
  */
349
377
  ...(role.trustd === true ? { enableWeakerNetworkIsolation: true } : {}),
350
378
  };
351
379
  }
352
380
 
353
- /**
354
- * The provider executables that live at a path, absolute.
355
- *
356
- * A command with no separator (`security`, `op`, `gpg`) is resolved by the OS
357
- * from PATH and is deliberately left alone: denying "wherever gpg happens to
358
- * be" would mean writing a rule about a machine rather than about a repo.
359
- */
360
- export function providerPaths(config) {
361
- const out = [];
362
- for (const p of Object.values(config.keyProviders ?? {})) {
363
- const cmd = p.command?.[0];
364
- if (!cmd || !cmd.includes("/")) continue;
365
- out.push(cmd.startsWith("/") ? cmd : join(config.root ?? ".", cmd));
366
- }
367
- return [...new Set(out)];
381
+ /** Creates `p` when its parent exists and a role may write there; says whether it now exists. */
382
+ function madeFirst(p, writable) {
383
+ const parent = dirname(p);
384
+ if (!existsSync(parent) || !writable.some((w) => parent === w || parent.startsWith(w + "/"))) return false;
385
+ try { mkdirSync(p, { recursive: true }); return true; } catch { return false; }
368
386
  }
369
-
package/src/suggest.js ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * "Did you mean" — for a command, a role, a flag.
3
+ *
4
+ * One place, so every command answers a typo the same way: the name that was
5
+ * not found, what exists, and the nearest one when it is near enough to be a
6
+ * typo rather than a different word.
7
+ */
8
+
9
+ /** Edit distance, small strings only. */
10
+ function distance(a, b) {
11
+ const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
12
+ for (let j = 1; j <= b.length; j++) d[0][j] = j;
13
+ for (let i = 1; i <= a.length; i++)
14
+ for (let j = 1; j <= b.length; j++)
15
+ d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
16
+ return d[a.length][b.length];
17
+ }
18
+
19
+ /** The nearest of `known` to `word`, or null when nothing is close. */
20
+ export function nearest(word, known) {
21
+ let best = null, score = Infinity;
22
+ for (const k of known) {
23
+ const n = distance(String(word).toLowerCase(), k.toLowerCase());
24
+ if (n < score) { best = k; score = n; }
25
+ }
26
+ const room = Math.max(1, Math.floor(String(word).length / 3));
27
+ return score <= room || (best && best.startsWith(word) && word.length >= 2) ? best : null;
28
+ }
29
+
30
+ /** The error for a role the policy does not have, always with the list. */
31
+ export function unknownRole(config, role) {
32
+ const known = Object.keys(config.roles ?? {});
33
+ const near = nearest(role, known);
34
+ return new Error(
35
+ `unknown role "${role}"${near ? ` — did you mean "${near}"?` : ""}` +
36
+ `\n known roles: ${known.join(", ") || "none — add a [roles.<name>] section"}`);
37
+ }
38
+
39
+ /**
40
+ * Flags a command does not take are refused, never ignored. An ignored
41
+ * `--verbsoe` is a question answered as if it had not been asked.
42
+ * `values` are the flags that take the next word as their value.
43
+ */
44
+ export function checkFlags(command, argv, known, values = []) {
45
+ const all = [...known, ...values];
46
+ for (let i = 0; i < argv.length; i++) {
47
+ const a = argv[i];
48
+ if (a === "--") break;
49
+ if (values.includes(a)) { i++; continue; }
50
+ if (!a.startsWith("-") || a === "-") continue;
51
+ if (all.includes(a)) continue;
52
+ const near = nearest(a, all);
53
+ const e = new Error(`unknown flag "${a}" for ${command}${near ? ` — did you mean "${near}"?` : ""}` +
54
+ `\n seisin ${command} --help lists its flags`);
55
+ e.usage = true;
56
+ throw e;
57
+ }
58
+ }