seisin 0.4.0 → 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 +151 -67
  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 -30
  8. package/src/commands/log.js +55 -10
  9. package/src/commands/requests.js +124 -39
  10. package/src/commands/review.js +41 -16
  11. package/src/commands/run.js +174 -39
  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 +261 -27
  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 +24 -16
  27. package/src/keys.js +13 -11
  28. package/src/kinds.js +207 -0
  29. package/src/log.js +312 -55
  30. package/src/mcp.js +96 -43
  31. package/src/notify.js +15 -10
  32. package/src/owners.js +323 -41
  33. package/src/paths.js +37 -1
  34. package/src/redact.js +19 -5
  35. package/src/render.js +81 -12
  36. package/src/requests.js +250 -72
  37. package/src/review.js +42 -21
  38. package/src/rundir.js +4 -6
  39. package/src/scan.js +177 -19
  40. package/src/serve.js +395 -205
  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 +203 -42
  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 +1366 -529
@@ -21,10 +21,10 @@ import { spool, SOCK_ENV } from "../spool.js";
21
21
  import { openRun } from "../rundir.js";
22
22
  import { secretsOf, redactor } from "../redact.js";
23
23
  import { C, err } from "../render.js";
24
- import { pending, requestsPath, markStale } from "../requests.js";
24
+ import { pending, requestsPath, withDeclarers } from "../requests.js";
25
25
  import { notifier } from "../notify.js";
26
- import { logPath, read } from "../log.js";
27
- import { renderQueue } from "./requests.js";
26
+ import { renderAdded } from "./requests.js";
27
+ import { unknownRole } from "../suggest.js";
28
28
  import { watchDenials } from "../violations.js";
29
29
  import { intake } from "../intake.js";
30
30
 
@@ -41,21 +41,32 @@ const HERE = dirname(fileURLToPath(import.meta.url));
41
41
  const DRAIN_MS = 250;
42
42
 
43
43
  /**
44
- * The bundled runtime first, a global one second, and nothing third.
44
+ * The runtime seisin depends on first, one on PATH second, and nothing third.
45
+ *
46
+ * "The one seisin depends on" is found the way Node finds a dependency — up
47
+ * from this file, through every `node_modules` — not at one spelled-out path.
48
+ * `<seisin>/node_modules/.bin/srt` is only where it lands in a checkout. In a
49
+ * project that installs seisin as a dependency, npm hoists the runtime to the
50
+ * project's own `node_modules`, and that path does not exist. Measured with
51
+ * 0.5.0 packed and installed into a project: `npx seisin` still worked, because
52
+ * npx puts the project's `.bin` on PATH; `./node_modules/.bin/seisin` and
53
+ * `node node_modules/seisin/src/cli.js` did not — they said "not found", or,
54
+ * with a global srt 0.0.75 installed, ran under THAT one instead of the 0.0.78
55
+ * this version pins, with none of its fixes.
45
56
  *
46
57
  * There is deliberately no fallback to running unsandboxed. A permission tool
47
58
  * that quietly becomes a no-op when its enforcer is missing is worse than one
48
59
  * that refuses, because you keep trusting it.
49
60
  */
50
- export function resolveSrt() {
51
- const bundled = join(HERE, "..", "..", "node_modules", ".bin", "srt");
52
- if (existsSync(bundled)) return bundled;
61
+ export function resolveSrt({ from = HERE, path = process.env.PATH } = {}) {
62
+ const local = dependencySrt(from);
63
+ if (local) return local;
53
64
 
54
65
  // Walk PATH rather than asking a shell. `spawnSync(..., { shell: true })`
55
66
  // prints a deprecation warning on every single run — Node's DEP0190 — which
56
67
  // is a line of noise in front of every user for the sake of finding one file.
57
68
  // It is also the concatenation hazard the warning is about.
58
- for (const dir of (process.env.PATH ?? "").split(delimiter)) {
69
+ for (const dir of (path ?? "").split(delimiter)) {
59
70
  if (!dir) continue;
60
71
  const candidate = join(dir, "srt");
61
72
  if (existsSync(candidate)) return candidate;
@@ -63,6 +74,38 @@ export function resolveSrt() {
63
74
  return null;
64
75
  }
65
76
 
77
+ const RUNTIME = "@anthropic-ai/sandbox-runtime";
78
+
79
+ /**
80
+ * The `srt` of the sandbox-runtime that resolves from `from`, or null.
81
+ *
82
+ * The `.bin/srt` npm linked beside the package when there is one — that is the
83
+ * file npm made executable — and otherwise the package's own `bin` entry.
84
+ * The package's `package.json` is found by walking up rather than through
85
+ * `require.resolve`, which a package's `exports` map is free to refuse.
86
+ */
87
+ export function dependencySrt(from = HERE) {
88
+ let dir = from;
89
+ for (;;) {
90
+ const nm = join(dir, "node_modules");
91
+ const pkg = join(nm, RUNTIME, "package.json");
92
+ if (existsSync(pkg)) {
93
+ const linked = join(nm, ".bin", "srt");
94
+ if (existsSync(linked)) return linked;
95
+ try {
96
+ const bin = JSON.parse(readFileSync(pkg, "utf8")).bin;
97
+ const rel = typeof bin === "string" ? bin : bin?.srt;
98
+ if (rel) {
99
+ const target = join(nm, RUNTIME, rel);
100
+ if (existsSync(target)) return target;
101
+ }
102
+ } catch { /* an unreadable package.json is not a runtime */ }
103
+ }
104
+ const up = dirname(dir);
105
+ if (up === dir) return null;
106
+ dir = up;
107
+ }
108
+ }
66
109
 
67
110
  export async function run(config, argv) {
68
111
  /**
@@ -75,11 +118,23 @@ export async function run(config, argv) {
75
118
  */
76
119
  const split = argv.indexOf("--");
77
120
  const role = argv[0];
78
- const mine = split === -1 ? argv.slice(1) : argv.slice(1, split);
79
- const cmd = split === -1 ? argv.slice(1) : argv.slice(split + 1);
121
+ let mine, cmd;
122
+ if (split !== -1) {
123
+ mine = argv.slice(1, split);
124
+ cmd = argv.slice(split + 1);
125
+ } else {
126
+ // No `--`: seisin's own flags are the leading `-options` only. From the
127
+ // first token that is not an option, everything is the command — its own
128
+ // flags are its business. `seisin run x echo hi --observe` used to read
129
+ // `--observe` as seisin's and put the run in observe mode; now that flag
130
+ // belongs to `echo`, and observe needs `seisin run x --observe -- echo hi`.
131
+ let i = 1;
132
+ while (i < argv.length && argv[i].startsWith("-")) i++;
133
+ mine = argv.slice(1, i);
134
+ cmd = argv.slice(i);
135
+ }
80
136
  if (!role || cmd.length === 0) throw new Error("usage: seisin run <role> -- <command...>");
81
- if (!config.roles[role])
82
- throw new Error(`unknown role "${role}". Known: ${Object.keys(config.roles).join(", ")}`);
137
+ if (!config.roles[role]) throw unknownRole(config, role);
83
138
 
84
139
  /**
85
140
  * seisin does not run inside seisin, and says so here rather than later.
@@ -88,27 +143,27 @@ export async function run(config, argv) {
88
143
  * than the outer one: the inner run dies in the runtime with rc=13 and a Node
89
144
  * warning about an unsettled await — no role named, no boundary named, and it
90
145
  * reads as seisin crashing rather than as a refusal. Before that it failed
91
- * even earlier, on a $TMPDIR the outer box had already reshaped.
146
+ * even earlier, on a $TMPDIR the outer sandbox had already reshaped.
92
147
  *
93
148
  * This is the refusal, not a fix, because the shape it is usually reached for
94
- * is wrong anyway: a dispatcher that starts roles from inside one role's box
95
- * makes every worker a descendant of the box that should hold the least. Such
149
+ * is wrong anyway: a dispatcher that starts roles from inside one role's sandbox
150
+ * makes every worker a descendant of the sandbox that should hold the least. Such
96
151
  * a dispatcher belongs above the roles and gets asked, rather than running
97
152
  * inside one of them.
98
153
  */
99
154
  const outer = process.env.SEISIN_ROLE;
100
155
  if (outer)
101
156
  throw new Error(
102
- `already inside the box as "${outer}" — seisin does not nest.\n` +
157
+ `already inside a seisin sandbox as "${outer}" — seisin does not nest.\n` +
103
158
  ` Starting "${role}" from in here does not give it its own territory: the run dies in the\n` +
104
- ` sandbox runtime, and what territory a second box would even hold is undefined.\n` +
159
+ ` sandbox runtime, and what territory a second sandbox would even hold is undefined.\n` +
105
160
  ` Run roles as siblings from outside instead. Something that needs to start roles belongs\n` +
106
161
  ` above them — asked by the role that wants the work done, not run inside it.`,
107
162
  );
108
163
 
109
164
  /**
110
165
  * The audit spool: this process holds the log and the queue, and the hook
111
- * inside the box gets a socket instead of a directory. That is the whole
166
+ * inside the sandbox gets a socket instead of a directory. That is the whole
112
167
  * reason `.seisin/` is no longer in anybody's allowWrite — see spool.js.
113
168
  *
114
169
  * Entries arrive with their own `at` already set by the sender, so what lands
@@ -116,11 +171,36 @@ export async function run(config, argv) {
116
171
  */
117
172
  const observe = mine.includes("--observe");
118
173
  // Built before the first request can arrive; the POST leaves from here, the
119
- // parent, never from inside the box (notify.js).
174
+ // parent, never from inside the sandbox (notify.js).
120
175
  const notify = notifier(config);
121
176
  // Everything that belongs to this run alone — socket, settings, scratch keys
122
177
  // — in one private directory, with one id. See rundir.js.
123
178
  const theRun = openRun();
179
+ /**
180
+ * From here on, every way out removes what this run made.
181
+ *
182
+ * The happy path closed the spool, the kernel watcher and the directory; the
183
+ * failures did not. A key provider that failed, a spool that would not bind,
184
+ * a runtime that would not start — each left the directory behind with its
185
+ * socket and, past the key step, its `scratch` keys on disk until some later
186
+ * run swept it, and the `log stream` process running. An `exit` handler,
187
+ * because every one of those ends in `process.exit`, from cli.js or from here,
188
+ * and everything it has to do is synchronous. Idempotent: the normal exit
189
+ * below runs it first and the handler finds nothing left.
190
+ */
191
+ let audit = null;
192
+ let denials = null;
193
+ let closed = false;
194
+ const cleanup = () => {
195
+ if (closed) return;
196
+ closed = true;
197
+ try { audit?.close(); } catch {}
198
+ try { denials?.close(); } catch {}
199
+ theRun.close();
200
+ };
201
+ process.once("exit", cleanup);
202
+ // What was already waiting, so the end of the run can say what it added.
203
+ const before = new Set(pending(requestsPath(config.root)).map((q) => q.key));
124
204
  const sockPath = theRun.sock;
125
205
  const settings = settingsFor(config, role, sockPath, observe);
126
206
  // Per run, never per role: `.seisin/<role>.json` was one file for every run
@@ -130,10 +210,10 @@ export async function run(config, argv) {
130
210
  // What arrives from the hook and from the kernel, checked against the policy
131
211
  // this run started with and written with the run's id on it. See intake.js.
132
212
  const take = intake({ config, role, runId: theRun.id, observe, settings, notify });
133
- const audit = await spool(take.fromHook, sockPath);
213
+ audit = await spool(take.fromHook, sockPath);
134
214
 
135
215
  const srt = resolveSrt();
136
- if (!srt) throw new Error("sandbox runtime not found. Install it with: npm i -g @anthropic-ai/sandbox-runtime");
216
+ if (!srt) throw new Error(`sandbox runtime not found. ${installHint()}`);
137
217
 
138
218
  // The hook runs inside the child and has to know which role it is. These are
139
219
  // the only variables seisin injects, and none carries a secret.
@@ -147,7 +227,7 @@ export async function run(config, argv) {
147
227
  *
148
228
  * The ordering is the security property, not a convenience. The provider
149
229
  * command holds the vault's own credential — the keychain prompt, the
150
- * 1Password session — and running it inside the box would mean putting that
230
+ * 1Password session — and running it inside the sandbox would mean putting that
151
231
  * credential in there too, which is the thing this feature exists to avoid.
152
232
  * The confined side cannot reach the parent; the parent can reach the vault;
153
233
  * so the parent resolves and hands over the result.
@@ -163,7 +243,7 @@ export async function run(config, argv) {
163
243
  if (mode === "env") { env[entry.name] = value; continue; }
164
244
  // `scratch`: the value lands in the per-run directory this process already
165
245
  // makes for the spool and already removes on exit, so its lifetime is the
166
- // turn's by construction rather than by a cleanup somebody has to remember.
246
+ // run's by construction rather than by a cleanup somebody has to remember.
167
247
  // The variable carries the PATH, which is the convention every tool that
168
248
  // wants a secret from a file already reads (`_FILE`), and it means the
169
249
  // value itself is not in the environment of a process that may print it.
@@ -220,9 +300,27 @@ export async function run(config, argv) {
220
300
  if (observe) env.SEISIN_OBSERVE = "1";
221
301
  if (mine.includes("--debug-env")) err(`${C.dim}seisin: dropped ${dropped.join(" ")}${C.off}\n`);
222
302
 
303
+ /**
304
+ * A command the role cannot find, said as that — before the sandbox starts.
305
+ *
306
+ * It used to reach the runtime, which runs it through `env`, and the only
307
+ * word on screen was `env: nosuchcmd: No such file or directory`: seisin's
308
+ * own wrapper named as the thing that failed. 127, the shell's code for it.
309
+ */
310
+ if (!onPath(cmd[0], env.PATH)) {
311
+ const e = new Error(`"${cmd[0]}" not found on the role's PATH`);
312
+ e.exitCode = 127;
313
+ throw e;
314
+ }
315
+
316
+ // What the role gets, in the policy's own words: `writes 8 path(s)` counted
317
+ // the runtime's scratch directories as territory and named none of it.
318
+ const r = config.roles[role];
319
+ const territory = observe ? "the whole repo" : r.writes.length ? r.writes.join(" ") : "nothing";
320
+ const scratch = settings.filesystem.allowWrite.length - (observe ? 1 : r.writes.length);
223
321
  err(
224
- `${C.dim}seisin: ${role} · writes ${settings.filesystem.allowWrite.length} path(s) · ` +
225
- `reads ${settings.filesystem.allowRead.length} key(s) · ` +
322
+ `${C.dim}seisin: ${role} · writes ${territory}${scratch > 0 ? ` (+${scratch} scratch)` : ""} · ` +
323
+ `keys ${r.keys.length ? r.keys.join(" ") : "none"} · ` +
226
324
  `env ${Object.keys(env).length} kept, ${dropped.length} dropped` +
227
325
  `${observe ? ` · ${C.yellow}OBSERVING — the repo is writable, the network is NOT${C.off}${C.dim}` : ""}${C.off}\n`
228
326
  );
@@ -254,11 +352,11 @@ export async function run(config, argv) {
254
352
  // `claude -c`, `claude --debug`. Found by passing --settings to claude and
255
353
  // watching the sandbox reject it as its own malformed config.
256
354
  /**
257
- * The kernel's own refusals, alongside the hook's.
355
+ * The kernel's own denials, alongside the hook's.
258
356
  *
259
357
  * Started BEFORE the spawn, and that ordering is the whole difference between
260
358
  * this working and this recording nothing. `log stream` is a separate process
261
- * with its own startup; a `sh -c` that gets refused is over in single-digit
359
+ * with its own startup; a `sh -c` that gets denied is over in single-digit
262
360
  * milliseconds. Started after the spawn, the first version of this caught
263
361
  * zero denials on exactly the short commands an agent runs most.
264
362
  *
@@ -268,12 +366,12 @@ export async function run(config, argv) {
268
366
  *
269
367
  * What each denial becomes — a log line, a request, or a count — is decided
270
368
  * in intake.js, beside the hook's lines: the kernel is the thing that
271
- * actually refused, so when the two disagree, it is the one that is right.
369
+ * actually denied, so when the two disagree, it is the one that is right.
272
370
  */
273
- // What actually runs inside the box, computed once and used by both ends. The
371
+ // What actually runs inside the sandbox, computed once and used by both ends. The
274
372
  // watcher recognises a denial by comparing the runtime's command tag with this
275
373
  // argv; handed `cmd` while `srt` was handed `env NO_PROXY=… cmd`, it recognised
276
- // nothing for a role with `local_ports`, and every short refusal of those roles
374
+ // nothing for a role with `local_ports`, and every short denial of those roles
277
375
  // — writes included — left no line (measured: 0 of 5, and 5 of 5 without the key).
278
376
  //
279
377
  // And a nonce in front, because the tag alone does not tell runs apart: an
@@ -284,7 +382,7 @@ export async function run(config, argv) {
284
382
  // path (measured: 3 lines and 3 requests for one write by one of 3 roles).
285
383
  // The nonce makes each run's command unique, so the tag names one run.
286
384
  const boxed = ["env", `SEISIN_RUN_ID=${theRun.id}`, ...loopbackVia(config.roles[role], cmd)];
287
- const denials = watchDenials(take.fromKernel, { argv: boxed });
385
+ denials = watchDenials(take.fromKernel, { argv: boxed });
288
386
 
289
387
  // Deliberately NOT announced here. On Linux this branch is taken every time,
290
388
  // so saying it per run puts a line the reader cannot act on in front of every
@@ -359,8 +457,11 @@ export async function run(config, argv) {
359
457
  * Seen once in a full suite run under load; by the end of the drains below
360
458
  * the pending signal has been handled.
361
459
  */
460
+ // A runtime that itself dies of a signal (SIGKILL, the OOM killer) exits
461
+ // the shell's way, 128 + n, the same as a run seisin was told to stop — it
462
+ // was reported as 1, a plain failure, which hides that it was killed.
362
463
  const statusOf = (code, signal) => stoppedBy ? 128 + osConstants.signals[stoppedBy]
363
- : signal ? 1
464
+ : signal ? 128 + (osConstants.signals[signal] ?? 0)
364
465
  : interrupted && !code ? 130
365
466
  : code ?? 0;
366
467
 
@@ -374,18 +475,18 @@ export async function run(config, argv) {
374
475
  // one entry behind is how a request goes unnoticed for a day.
375
476
  audit.close();
376
477
  // Same reason, one layer further out: the kernel writes its line through a
377
- // separate process, so the last refusal of a run can still be in flight
478
+ // separate process, so the last denial of a run can still be in flight
378
479
  // here. This is the only wait the mechanism adds and it is per run — it
379
- // returns as soon as the stream goes quiet, so a run that was refused
480
+ // returns as soon as the stream goes quiet, so a run that was denied
380
481
  // nothing pays a tick.
381
482
  await denials.close({ drain: DRAIN_MS });
382
483
  // The run made this directory, so the run removes it.
383
- theRun.close();
484
+ cleanup();
384
485
 
385
486
  // Say what was dropped rather than only what was kept. A filter nobody can
386
487
  // see is indistinguishable from a monitor that is not working, and this one
387
488
  // drops the majority of what the kernel says on a busy run.
388
- // Only when something was actually recorded. A run where every refusal was
489
+ // Only when something was actually recorded. A run where every denial was
389
490
  // filtered out has nothing to contextualise, and `0 recorded, 1 outside the
390
491
  // policy's paths` is a line that answers a question nobody asked — the
391
492
  // counts exist to explain a filter, not to announce that one ran.
@@ -398,8 +499,12 @@ export async function run(config, argv) {
398
499
  `${offPolicy ? `, ${offPolicy} outside the policy's paths` : ""}` +
399
500
  `${foreign ? `, ${foreign} from other sandboxes` : ""}${C.off}\n`);
400
501
 
401
- const queue = pending(requestsPath(config.root));
402
- if (queue.length) err(renderQueue(markStale(queue, read(logPath(config.root)))));
502
+ // Only what this run added, with the numbers `grant` takes, and one line
503
+ // for the rest. The whole queue after every run buried the new request
504
+ // under the ones already seen; nothing new, nothing said.
505
+ const queue = withDeclarers(config, pending(requestsPath(config.root), { keyDirs: config.keyDirs }));
506
+ const added = queue.map((q, i) => ({ ...q, n: i + 1 })).filter((q) => !before.has(q.key));
507
+ if (added.length) err(renderAdded(added, queue.length - added.length));
403
508
  const failed = await notify.settle();
404
509
  if (failed.length) err(`${C.yellow}seisin: could not notify about a new request (${failed[0]})${C.off}\n`);
405
510
  // One more turn of the loop, so a signal that arrived with the child's exit
@@ -420,5 +525,35 @@ export async function run(config, argv) {
420
525
  clearTimeout(guard);
421
526
  process.exit(status);
422
527
  });
423
- child.on("error", (e) => { throw new Error(`could not start the sandbox: ${e.message}`); });
528
+ // Not a `throw`: in a listener it is an uncaught exception — exit 1, a
529
+ // stack trace, and none of the cleanup. Said the way cli.js says any other
530
+ // failure, with its code; the `exit` handler above removes the run.
531
+ child.on("error", (e) => {
532
+ err(`${C.red}seisin:${C.off} could not start the sandbox: ${e.message}\n`);
533
+ process.exit(2);
534
+ });
535
+ }
536
+
537
+ /** Is `name` a program the role can start with this PATH? */
538
+ function onPath(name, path) {
539
+ if (!name) return false;
540
+ if (name.includes("/")) return existsSync(name);
541
+ return (path ?? "").split(delimiter).some((d) => d && existsSync(join(d, name)));
542
+ }
543
+
544
+ /**
545
+ * How to put the runtime back, for the way this seisin was installed.
546
+ *
547
+ * It is a dependency, so it is missing only from a broken install — and the
548
+ * fix differs: a project's own node_modules, a global install, or a checkout.
549
+ * `npm i -g` was suggested to everyone, which for a project-local seisin
550
+ * installs a second runtime somewhere seisin does not look first.
551
+ */
552
+ export function installHint(from = HERE) {
553
+ const at = from.split("/node_modules/seisin/")[0];
554
+ if (at === from) return `Install it in this checkout with: npm install (in ${dirname(dirname(from))})`;
555
+ // npm's global layout is <prefix>/lib/node_modules on unix, …/npm/node_modules on Windows.
556
+ if (/\/lib$/.test(at) || /[\\/]npm$/.test(at))
557
+ return "seisin is installed globally; reinstall it with: npm install -g seisin";
558
+ return `seisin is installed in ${at}; reinstall it there with: npm install seisin`;
424
559
  }
@@ -3,18 +3,33 @@
3
3
  *
4
4
  * Exits 1 only on a `certain` finding. A `review` line is a prompt to look, and
5
5
  * failing a build on those would make the command unusable in CI within a week.
6
+ *
7
+ * Where it looks: the repository the policy is in, or — when the policy sits
8
+ * in a folder of repositories — the repositories its territories live in (see
9
+ * territoryRoots). `--all` walks the whole folder either way.
6
10
  */
7
- import { scan } from "../scan.js";
11
+ import { relative } from "node:path";
12
+ import { scan, insideRepo, territoryRoots } from "../scan.js";
13
+ import { ownersOf } from "../owners.js";
8
14
  import { renderScan, out } from "../render.js";
9
15
 
10
- export function scanCommand(config) {
11
- const { hits, skipped, truncated } = scan(config.root, config.keyDirs, config.scanIgnore);
16
+ export function scanCommand(config, argv = []) {
17
+ const all = argv.includes("--all");
18
+ const roots = all || insideRepo(config.root) ? null : territoryRoots(config);
19
+ const { hits, skipped, nestedPaths, truncated, omitted } =
20
+ scan(config.root, config.keyDirs, config.scanIgnore, 500, { roots });
21
+ // Whose territory a finding is in: the role that will come across it first.
22
+ for (const h of hits) if (h.level !== "link") h.owners = ownersOf(config, h.file);
12
23
  const result = {
13
24
  certain: hits.filter((h) => h.level === "certain"),
25
+ named: hits.filter((h) => h.level === "named"),
14
26
  review: hits.filter((h) => h.level === "review"),
15
27
  links: hits.filter((h) => h.level === "link"),
16
28
  skipped,
29
+ nestedPaths,
17
30
  truncated,
31
+ omitted,
32
+ scope: roots ? roots.map((r) => relative(config.root, r) || ".") : null,
18
33
  };
19
34
  out(renderScan(result, config.keyDirs));
20
35
  return result;
@@ -14,7 +14,6 @@
14
14
  * failing. See uilink.js.
15
15
  */
16
16
  import { spawnSync } from "node:child_process";
17
- import { relative } from "node:path";
18
17
  import { serve } from "../serve.js";
19
18
  import { writeUiLink, readUiLink, clearUiLink } from "../uilink.js";
20
19
  import { C, out } from "../render.js";
@@ -33,7 +32,8 @@ export async function ui(config, argv = []) {
33
32
  if (argv.includes("--link")) {
34
33
  const found = readUiLink(port);
35
34
  if (found && found.alive !== false) {
36
- out(`\n ${C.b}${found.url}${C.off}\n ${C.dim}the link a seisin ui on ${port} is serving. open it here, do not paste it anywhere.${C.off}\n\n`);
35
+ out(`\n ${C.b}${found.url}${C.off}\n ${C.dim}the link a seisin ui on ${port} is serving. open it here, do not paste it anywhere.${C.off}\n` +
36
+ servesWhich(found.policy, config?.path) + "\n");
37
37
  open(found.url);
38
38
  return;
39
39
  }
@@ -53,7 +53,8 @@ export async function ui(config, argv = []) {
53
53
  if (found && found.alive !== false) {
54
54
  out(
55
55
  `\n ${C.b}${found.url}${C.off}\n` +
56
- ` ${C.dim}a seisin ui is already running on ${port}; reopening its link. ctrl-c that terminal to stop it.${C.off}\n\n`
56
+ ` ${C.dim}a seisin ui is already running on ${port}; reopening its link. ctrl-c that terminal to stop it.${C.off}\n` +
57
+ servesWhich(found.policy, config.path) + "\n"
57
58
  );
58
59
  open(found.url);
59
60
  return;
@@ -69,7 +70,7 @@ export async function ui(config, argv = []) {
69
70
  const bound = server.address().port;
70
71
  const url = `http://127.0.0.1:${bound}/#t=${server.seisinToken}`;
71
72
  // Recorded before we print, so `--link` and a second `seisin ui` can find it.
72
- writeUiLink(bound, url);
73
+ writeUiLink(bound, url, undefined, config.path);
73
74
 
74
75
  // local_binding opens every port (macOS); local_ports opens the ones it
75
76
  // names, on every platform — so only a role that names THIS port counts.
@@ -79,7 +80,7 @@ export async function ui(config, argv = []) {
79
80
 
80
81
  out(
81
82
  `\n ${C.b}${url}${C.off}\n` +
82
- ` ${C.dim}reading ${relative(process.cwd(), config.path)} and the log, live. ctrl-c to stop.${C.off}\n` +
83
+ ` ${C.dim}reading ${config.path} and the log, live. ctrl-c to stop.${C.off}\n` +
83
84
  ` ${C.dim}the link carries this run's token: open it here, do not paste it anywhere.${C.off}\n` +
84
85
  ` ${C.dim}lost it? this terminal has it above, or run: seisin ui --link${bound === 4178 ? "" : ` --port ${bound}`}${C.off}\n` +
85
86
  (reach.length
@@ -95,3 +96,15 @@ export async function ui(config, argv = []) {
95
96
  process.on("SIGTERM", stop);
96
97
  return server;
97
98
  }
99
+
100
+ /**
101
+ * Which policy a running console serves, and a warning when it is not the one
102
+ * this directory would use. `here` is null when there is no policy here.
103
+ */
104
+ export function servesWhich(served, here) {
105
+ if (!served) return ` ${C.dim}(started by an older seisin: which seisin.toml it serves is not recorded)${C.off}\n`;
106
+ if (here && served !== here)
107
+ return ` ${C.yellow}it serves ${served} — not ${here}, the policy for this directory.${C.off}\n` +
108
+ ` ${C.dim}for this one, start another: seisin ui --port <n>${C.off}\n`;
109
+ return ` ${C.dim}serving ${served}${C.off}\n`;
110
+ }
@@ -1,14 +1,14 @@
1
- /** `seisin walls <role>` — what that role keeps being refused, and what it cost. */
2
- import { walls, render } from "../walls.js";
1
+ /** `seisin walls <role>` — what that role keeps being denied, and what it cost. */
2
+ import { unknownRole } from "../suggest.js";
3
+ import { walls, wasted } from "../walls.js";
3
4
  import { STALE_RUNS } from "../requests.js";
4
5
  import { logPath } from "../log.js";
5
- import { out } from "../render.js";
6
+ import { C, out, safe } from "../render.js";
6
7
 
7
8
  export function wallsCommand(config, argv = []) {
8
9
  const role = argv[0];
9
10
  if (!role) throw new Error("usage: seisin walls <role> [--since <iso>] [--min <n>] [--all]");
10
- if (!config.roles[role])
11
- throw new Error(`unknown role "${role}". Known: ${Object.keys(config.roles).join(", ")}`);
11
+ if (!config.roles[role]) throw unknownRole(config, role);
12
12
  const at = (flag, fallback) => {
13
13
  const i = argv.indexOf(flag);
14
14
  return i === -1 ? fallback : argv[i + 1];
@@ -24,7 +24,26 @@ export function wallsCommand(config, argv = []) {
24
24
  const old = every.filter((w) => w.stale).length;
25
25
  // Nothing to say prints nothing, the way `scan` does when a repo is clean.
26
26
  // A command that always speaks is one whose output stops being read.
27
- if (list.length) out(render(list));
27
+ if (list.length) out(renderForPerson(role, list));
28
28
  if (old && !all) out(` (${old} older wall(s) not hit in the last ${STALE_RUNS}+ runs: seisin walls ${role} --all)\n`);
29
29
  return list;
30
30
  }
31
+
32
+ /**
33
+ * The same walls, said to the person at the terminal.
34
+ *
35
+ * `render` speaks to the agent — "you have already been denied these… do not
36
+ * retry" — because the hook and the MCP server put it in a prompt. Printed at
37
+ * a terminal, that voice addressed the person reading it as if they were the agent.
38
+ */
39
+ export function renderForPerson(role, list) {
40
+ const lines = [`\n ${C.b}${safe(role)}${C.off} keeps hitting:\n\n`];
41
+ for (const w of list) {
42
+ const why = w.owners?.length ? `belongs to ${w.owners.map(safe).join(", ")}` : safe(w.reason);
43
+ lines.push(` ${C.yellow}${String(w.times).padStart(3)}×${C.off} ${w.action} ${safe(w.target)} ${C.dim}${why}${C.off}\n`);
44
+ }
45
+ const n = wasted(list);
46
+ if (n > 0) lines.push(`\n ${C.dim}${n} of its calls went into retrying these.${C.off}\n`);
47
+ lines.push("\n");
48
+ return lines.join("");
49
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `seisin whose <path>` — the lookup a confined process is allowed to run.
3
3
  *
4
- * The README's opening claim is that a refusal names an owner. That is true
4
+ * The README's opening claim is that a denial names an owner. That is true
5
5
  * through the hook, and false for anyone who only wraps a process: the kernel
6
6
  * carries no reason, so the child sees `Operation not permitted` and guesses.
7
7
  * Measured in the field twice — an agent diagnosed a territory denial as
@@ -14,9 +14,9 @@
14
14
  *
15
15
  * It reads and prints. There is deliberately nothing here that could make it
16
16
  * worth attacking, because unlike everything else in this tool it is meant to
17
- * be called *from inside* the box.
17
+ * be called *from inside* the sandbox.
18
18
  */
19
- import { toRepoRelative } from "../paths.js";
19
+ import { toRepoRelative, fromCwd } from "../paths.js";
20
20
  import { ownersOf, keyHolders } from "../owners.js";
21
21
  import { twinsOf, whereIs } from "../worktree.js";
22
22
  import { C, out } from "../render.js";
@@ -25,7 +25,8 @@ export function whose(config, argv = []) {
25
25
  const target = argv[0];
26
26
  if (!target) throw new Error("usage: seisin whose <path>");
27
27
 
28
- const rel = toRepoRelative(config, target);
28
+ // Relative to where you stand, like explain — see fromCwd.
29
+ const rel = toRepoRelative(config, fromCwd(config, target));
29
30
  const isKey = (config.keyDirs ?? []).some((d) => rel === d || rel.startsWith(d + "/"));
30
31
  const owners = isKey ? keyHolders(config, rel) : ownersOf(config, rel);
31
32
 
@@ -51,7 +52,7 @@ export function whose(config, argv = []) {
51
52
  out(
52
53
  `\n ${C.b}${rel}${C.off} ${verb} ${C.b}${owners.join(", ")}${C.off}\n` +
53
54
  (mine
54
- ? ` ${C.green}that is you${C.off}${C.dim} — if it was refused, the reason is not ownership.${C.off}\n\n`
55
+ ? ` ${C.green}that is you${C.off}${C.dim} — if it was denied, the reason is not ownership.${C.off}\n\n`
55
56
  : asking
56
57
  ? ` ${C.dim}you are ${asking}. Hand it over rather than working around it.${C.off}\n\n`
57
58
  : "\n")
@@ -64,7 +65,7 @@ export function whose(config, argv = []) {
64
65
  * The same file in the other checkouts of this repo, when it is owned
65
66
  * differently there.
66
67
  *
67
- * This is the line the EPERM was missing. An agent refused inside a worktree
68
+ * This is the line the EPERM was missing. An agent denied inside a worktree
68
69
  * asks whose the file is, hears "nobody", and concludes the map has a hole —
69
70
  * when the map is fine and names the canonical checkout. A twin owned exactly
70
71
  * the same way says nothing new and gets no line.