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
@@ -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
  /**
@@ -91,8 +134,7 @@ export async function run(config, argv) {
91
134
  cmd = argv.slice(i);
92
135
  }
93
136
  if (!role || cmd.length === 0) throw new Error("usage: seisin run <role> -- <command...>");
94
- if (!config.roles[role])
95
- throw new Error(`unknown role "${role}". Known: ${Object.keys(config.roles).join(", ")}`);
137
+ if (!config.roles[role]) throw unknownRole(config, role);
96
138
 
97
139
  /**
98
140
  * seisin does not run inside seisin, and says so here rather than later.
@@ -101,27 +143,27 @@ export async function run(config, argv) {
101
143
  * than the outer one: the inner run dies in the runtime with rc=13 and a Node
102
144
  * warning about an unsettled await — no role named, no boundary named, and it
103
145
  * reads as seisin crashing rather than as a refusal. Before that it failed
104
- * even earlier, on a $TMPDIR the outer box had already reshaped.
146
+ * even earlier, on a $TMPDIR the outer sandbox had already reshaped.
105
147
  *
106
148
  * This is the refusal, not a fix, because the shape it is usually reached for
107
- * is wrong anyway: a dispatcher that starts roles from inside one role's box
108
- * 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
109
151
  * a dispatcher belongs above the roles and gets asked, rather than running
110
152
  * inside one of them.
111
153
  */
112
154
  const outer = process.env.SEISIN_ROLE;
113
155
  if (outer)
114
156
  throw new Error(
115
- `already inside the box as "${outer}" — seisin does not nest.\n` +
157
+ `already inside a seisin sandbox as "${outer}" — seisin does not nest.\n` +
116
158
  ` Starting "${role}" from in here does not give it its own territory: the run dies in the\n` +
117
- ` 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` +
118
160
  ` Run roles as siblings from outside instead. Something that needs to start roles belongs\n` +
119
161
  ` above them — asked by the role that wants the work done, not run inside it.`,
120
162
  );
121
163
 
122
164
  /**
123
165
  * The audit spool: this process holds the log and the queue, and the hook
124
- * 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
125
167
  * reason `.seisin/` is no longer in anybody's allowWrite — see spool.js.
126
168
  *
127
169
  * Entries arrive with their own `at` already set by the sender, so what lands
@@ -129,11 +171,36 @@ export async function run(config, argv) {
129
171
  */
130
172
  const observe = mine.includes("--observe");
131
173
  // Built before the first request can arrive; the POST leaves from here, the
132
- // parent, never from inside the box (notify.js).
174
+ // parent, never from inside the sandbox (notify.js).
133
175
  const notify = notifier(config);
134
176
  // Everything that belongs to this run alone — socket, settings, scratch keys
135
177
  // — in one private directory, with one id. See rundir.js.
136
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));
137
204
  const sockPath = theRun.sock;
138
205
  const settings = settingsFor(config, role, sockPath, observe);
139
206
  // Per run, never per role: `.seisin/<role>.json` was one file for every run
@@ -143,10 +210,10 @@ export async function run(config, argv) {
143
210
  // What arrives from the hook and from the kernel, checked against the policy
144
211
  // this run started with and written with the run's id on it. See intake.js.
145
212
  const take = intake({ config, role, runId: theRun.id, observe, settings, notify });
146
- const audit = await spool(take.fromHook, sockPath);
213
+ audit = await spool(take.fromHook, sockPath);
147
214
 
148
215
  const srt = resolveSrt();
149
- 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()}`);
150
217
 
151
218
  // The hook runs inside the child and has to know which role it is. These are
152
219
  // the only variables seisin injects, and none carries a secret.
@@ -160,7 +227,7 @@ export async function run(config, argv) {
160
227
  *
161
228
  * The ordering is the security property, not a convenience. The provider
162
229
  * command holds the vault's own credential — the keychain prompt, the
163
- * 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
164
231
  * credential in there too, which is the thing this feature exists to avoid.
165
232
  * The confined side cannot reach the parent; the parent can reach the vault;
166
233
  * so the parent resolves and hands over the result.
@@ -176,7 +243,7 @@ export async function run(config, argv) {
176
243
  if (mode === "env") { env[entry.name] = value; continue; }
177
244
  // `scratch`: the value lands in the per-run directory this process already
178
245
  // makes for the spool and already removes on exit, so its lifetime is the
179
- // 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.
180
247
  // The variable carries the PATH, which is the convention every tool that
181
248
  // wants a secret from a file already reads (`_FILE`), and it means the
182
249
  // value itself is not in the environment of a process that may print it.
@@ -233,9 +300,27 @@ export async function run(config, argv) {
233
300
  if (observe) env.SEISIN_OBSERVE = "1";
234
301
  if (mine.includes("--debug-env")) err(`${C.dim}seisin: dropped ${dropped.join(" ")}${C.off}\n`);
235
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);
236
321
  err(
237
- `${C.dim}seisin: ${role} · writes ${settings.filesystem.allowWrite.length} path(s) · ` +
238
- `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"} · ` +
239
324
  `env ${Object.keys(env).length} kept, ${dropped.length} dropped` +
240
325
  `${observe ? ` · ${C.yellow}OBSERVING — the repo is writable, the network is NOT${C.off}${C.dim}` : ""}${C.off}\n`
241
326
  );
@@ -267,11 +352,11 @@ export async function run(config, argv) {
267
352
  // `claude -c`, `claude --debug`. Found by passing --settings to claude and
268
353
  // watching the sandbox reject it as its own malformed config.
269
354
  /**
270
- * The kernel's own refusals, alongside the hook's.
355
+ * The kernel's own denials, alongside the hook's.
271
356
  *
272
357
  * Started BEFORE the spawn, and that ordering is the whole difference between
273
358
  * this working and this recording nothing. `log stream` is a separate process
274
- * 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
275
360
  * milliseconds. Started after the spawn, the first version of this caught
276
361
  * zero denials on exactly the short commands an agent runs most.
277
362
  *
@@ -281,12 +366,12 @@ export async function run(config, argv) {
281
366
  *
282
367
  * What each denial becomes — a log line, a request, or a count — is decided
283
368
  * in intake.js, beside the hook's lines: the kernel is the thing that
284
- * 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.
285
370
  */
286
- // 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
287
372
  // watcher recognises a denial by comparing the runtime's command tag with this
288
373
  // argv; handed `cmd` while `srt` was handed `env NO_PROXY=… cmd`, it recognised
289
- // 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
290
375
  // — writes included — left no line (measured: 0 of 5, and 5 of 5 without the key).
291
376
  //
292
377
  // And a nonce in front, because the tag alone does not tell runs apart: an
@@ -297,7 +382,7 @@ export async function run(config, argv) {
297
382
  // path (measured: 3 lines and 3 requests for one write by one of 3 roles).
298
383
  // The nonce makes each run's command unique, so the tag names one run.
299
384
  const boxed = ["env", `SEISIN_RUN_ID=${theRun.id}`, ...loopbackVia(config.roles[role], cmd)];
300
- const denials = watchDenials(take.fromKernel, { argv: boxed });
385
+ denials = watchDenials(take.fromKernel, { argv: boxed });
301
386
 
302
387
  // Deliberately NOT announced here. On Linux this branch is taken every time,
303
388
  // so saying it per run puts a line the reader cannot act on in front of every
@@ -372,8 +457,11 @@ export async function run(config, argv) {
372
457
  * Seen once in a full suite run under load; by the end of the drains below
373
458
  * the pending signal has been handled.
374
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.
375
463
  const statusOf = (code, signal) => stoppedBy ? 128 + osConstants.signals[stoppedBy]
376
- : signal ? 1
464
+ : signal ? 128 + (osConstants.signals[signal] ?? 0)
377
465
  : interrupted && !code ? 130
378
466
  : code ?? 0;
379
467
 
@@ -387,18 +475,18 @@ export async function run(config, argv) {
387
475
  // one entry behind is how a request goes unnoticed for a day.
388
476
  audit.close();
389
477
  // Same reason, one layer further out: the kernel writes its line through a
390
- // 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
391
479
  // here. This is the only wait the mechanism adds and it is per run — it
392
- // 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
393
481
  // nothing pays a tick.
394
482
  await denials.close({ drain: DRAIN_MS });
395
483
  // The run made this directory, so the run removes it.
396
- theRun.close();
484
+ cleanup();
397
485
 
398
486
  // Say what was dropped rather than only what was kept. A filter nobody can
399
487
  // see is indistinguishable from a monitor that is not working, and this one
400
488
  // drops the majority of what the kernel says on a busy run.
401
- // 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
402
490
  // filtered out has nothing to contextualise, and `0 recorded, 1 outside the
403
491
  // policy's paths` is a line that answers a question nobody asked — the
404
492
  // counts exist to explain a filter, not to announce that one ran.
@@ -411,8 +499,12 @@ export async function run(config, argv) {
411
499
  `${offPolicy ? `, ${offPolicy} outside the policy's paths` : ""}` +
412
500
  `${foreign ? `, ${foreign} from other sandboxes` : ""}${C.off}\n`);
413
501
 
414
- const queue = pending(requestsPath(config.root));
415
- 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));
416
508
  const failed = await notify.settle();
417
509
  if (failed.length) err(`${C.yellow}seisin: could not notify about a new request (${failed[0]})${C.off}\n`);
418
510
  // One more turn of the loop, so a signal that arrived with the child's exit
@@ -433,5 +525,35 @@ export async function run(config, argv) {
433
525
  clearTimeout(guard);
434
526
  process.exit(status);
435
527
  });
436
- 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`;
437
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.