seisin 0.1.1 → 0.3.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.
@@ -8,20 +8,25 @@
8
8
  * Everything in here is ordering that was wrong once. The comments say which.
9
9
  */
10
10
  import { spawn } from "node:child_process";
11
- import { mkdirSync, writeFileSync, existsSync, readFileSync, rmSync } from "node:fs";
11
+ import { finished } from "node:stream/promises";
12
+ import { constants as osConstants } from "node:os";
13
+ import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
12
14
  import { join, dirname, delimiter } from "node:path";
13
15
  import { fileURLToPath } from "node:url";
14
- import { settingsFor, roleHome } from "../srt.js";
16
+ import { settingsFor, roleHome, loopbackVia } from "../srt.js";
15
17
  import { buildEnv } from "../env.js";
16
- import { spool, spoolPath, SOCK_ENV } from "../spool.js";
18
+ import { resolveKeys } from "../keys.js";
19
+ import { nestedSandboxWarning } from "../nested.js";
20
+ import { spool, SOCK_ENV } from "../spool.js";
21
+ import { openRun } from "../rundir.js";
17
22
  import { secretsOf, redactor } from "../redact.js";
18
- import { STATE_DIR } from "../layout.js";
19
23
  import { C, err } from "../render.js";
20
- import { pending, record, requestsPath } from "../requests.js";
21
- import { ownersOf, explain } from "../owners.js";
22
- import { append, logPath } from "../log.js";
24
+ import { pending, requestsPath, markStale } from "../requests.js";
25
+ import { notifier } from "../notify.js";
26
+ import { logPath, read } from "../log.js";
23
27
  import { renderQueue } from "./requests.js";
24
- import { watchDenials, inScope, scopeOf, reachedForContent } from "../violations.js";
28
+ import { watchDenials } from "../violations.js";
29
+ import { intake } from "../intake.js";
25
30
 
26
31
  const HERE = dirname(fileURLToPath(import.meta.url));
27
32
 
@@ -58,14 +63,6 @@ export function resolveSrt() {
58
63
  return null;
59
64
  }
60
65
 
61
- /** Writes `.seisin/<role>.json` and returns its path. */
62
- export function writeSettings(config, role, sock = null, observe = false) {
63
- const dir = join(config.root, STATE_DIR);
64
- mkdirSync(dir, { recursive: true });
65
- const file = join(dir, `${role}.json`);
66
- writeFileSync(file, JSON.stringify(settingsFor(config, role, sock, observe), null, 2) + "\n");
67
- return file;
68
- }
69
66
 
70
67
  export async function run(config, argv) {
71
68
  /**
@@ -118,79 +115,22 @@ export async function run(config, argv) {
118
115
  * on disk is when the decision happened, not when the parent got around to it.
119
116
  */
120
117
  const observe = mine.includes("--observe");
121
- const sockPath = spoolPath();
122
- const audit = await spool((to, entry) => {
123
- /**
124
- * Nothing from inside the box is taken at its word.
125
- *
126
- * The sender is the process being recorded, so every field it supplies is
127
- * a claim. Two of them matter. `role` decides whose request this is — left
128
- * alone, a frontend agent could file one as backend and wait for a human to
129
- * approve it. `owners` decides who the queue says it belongs to, and the
130
- * parent can work that out itself from the policy.
131
- *
132
- * So the role is overwritten with the role of this run, the owners are
133
- * recomputed, and anything shaped wrong is dropped. Forging a *log* line is
134
- * noise. Forging a *request* is a sentence placed in front of a person for
135
- * approval, and that is a different thing entirely.
136
- */
137
- if (!entry || typeof entry !== "object") return;
138
-
139
- if (to === "log") {
140
- // The same treatment the queue already got. The role was overwritten but
141
- // everything else was passed through, so a process inside the box could
142
- // write "allowed" lines for paths it never touched — which would not move
143
- // the boundary, but would move `review`, and `init --from-observations`
144
- // builds a policy out of exactly this.
145
- if (entry.action !== "read" && entry.action !== "write") return;
146
- if (!["allowed", "denied", "observed"].includes(entry.verdict)) return;
147
- if (typeof entry.target !== "string" || !entry.target.trim()) return;
148
- return void append(logPath(config.root), {
149
- at: entry.at,
150
- role,
151
- tool: String(entry.tool ?? "").slice(0, 40),
152
- action: entry.action,
153
- kind: entry.kind === "key" ? "key" : "file",
154
- target: entry.target.slice(0, 1000),
155
- verdict: entry.verdict,
156
- owners: ownersOf(config, entry.target), // recomputed, never taken
157
- reason: String(entry.reason ?? "").slice(0, 500),
158
- });
159
- }
160
-
161
- if (entry.action !== "read" && entry.action !== "write") return;
162
- if (typeof entry.target !== "string" || !entry.target.trim()) return;
163
-
164
- /**
165
- * The policy has to actually refuse it, or it does not belong in a queue.
166
- *
167
- * Nothing checked. A process inside the box could file a request for a path
168
- * its own role already owns, and the queue would print *first refused on
169
- * …* about something that was never refused — a sentence placed in front of
170
- * a person for approval, describing an event that did not happen.
171
- *
172
- * Same treatment as `owners` one branch up: the claim is recomputed here
173
- * rather than believed. It is the policy answering a question about itself,
174
- * which is arithmetic, and it is the parent asking — the one process the
175
- * confined side cannot reach.
176
- *
177
- * This does not require the agent to have *tried*. Asking for a permission
178
- * before reaching for it is a reasonable thing to do, and the wording says
179
- * "asked" rather than "refused" for exactly that case. What it rules out is
180
- * a request for something that is not refused at all.
181
- */
182
- if (explain(config, role, entry.action, entry.target).allowed) return;
183
-
184
- record(requestsPath(config.root), {
185
- role,
186
- action: entry.action,
187
- target: entry.target,
188
- owners: ownersOf(config, entry.target),
189
- });
190
- }, sockPath);
191
-
118
+ // Built before the first request can arrive; the POST leaves from here, the
119
+ // parent, never from inside the box (notify.js).
120
+ const notify = notifier(config);
121
+ // Everything that belongs to this run alone — socket, settings, scratch keys
122
+ // — in one private directory, with one id. See rundir.js.
123
+ const theRun = openRun();
124
+ const sockPath = theRun.sock;
192
125
  const settings = settingsFor(config, role, sockPath, observe);
193
- const file = writeSettings(config, role, sockPath, observe);
126
+ // Per run, never per role: `.seisin/<role>.json` was one file for every run
127
+ // of that role, and two at once wrote each other's socket path into it.
128
+ const file = theRun.writeSettings(settings);
129
+
130
+ // What arrives from the hook and from the kernel, checked against the policy
131
+ // this run started with and written with the run's id on it. See intake.js.
132
+ const take = intake({ config, role, runId: theRun.id, observe, settings, notify });
133
+ const audit = await spool(take.fromHook, sockPath);
194
134
 
195
135
  const srt = resolveSrt();
196
136
  if (!srt) throw new Error("sandbox runtime not found. Install it with: npm i -g @anthropic-ai/sandbox-runtime");
@@ -202,6 +142,54 @@ export async function run(config, argv) {
202
142
  env.SEISIN_CONFIG = config.path;
203
143
  env[SOCK_ENV] = sockPath;
204
144
 
145
+ /**
146
+ * Reference keys: resolved here, by the parent, before anything is spawned.
147
+ *
148
+ * The ordering is the security property, not a convenience. The provider
149
+ * command holds the vault's own credential — the keychain prompt, the
150
+ * 1Password session — and running it inside the box would mean putting that
151
+ * credential in there too, which is the thing this feature exists to avoid.
152
+ * The confined side cannot reach the parent; the parent can reach the vault;
153
+ * so the parent resolves and hands over the result.
154
+ *
155
+ * A failure here stops the run. Not an empty value, not the file of the same
156
+ * name, not a skipped key: a role that starts without a credential it
157
+ * declared fails later, somewhere else, looking like something it is not.
158
+ */
159
+ const resolved = resolveKeys(config, config.roles[role]);
160
+ const scratchKeys = theRun.keys;
161
+ if (resolved.some((r) => r.mode === "scratch")) mkdirSync(scratchKeys, { recursive: true, mode: 0o700 });
162
+ for (const { entry, mode, value } of resolved) {
163
+ if (mode === "env") { env[entry.name] = value; continue; }
164
+ // `scratch`: the value lands in the per-run directory this process already
165
+ // 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.
167
+ // The variable carries the PATH, which is the convention every tool that
168
+ // wants a secret from a file already reads (`_FILE`), and it means the
169
+ // value itself is not in the environment of a process that may print it.
170
+ const file = join(scratchKeys, entry.name);
171
+ writeFileSync(file, value, { mode: 0o600 });
172
+ /**
173
+ * Both names, because the world has two conventions and neither is ours.
174
+ *
175
+ * `<NAME>_FILE` is the Docker-secrets shape, read by tools that normally
176
+ * take the value in `<NAME>`. But a large family of tools already expects
177
+ * a PATH in the plain variable — `KUBECONFIG`,
178
+ * `GOOGLE_APPLICATION_CREDENTIALS`, `AWS_SHARED_CREDENTIALS_FILE` — and
179
+ * for those, `KUBECONFIG_FILE` is a name nothing reads.
180
+ *
181
+ * Found by running `kubectl` against it rather than reasoning about it:
182
+ * the file was there and correct, and the variable it was announced under
183
+ * was one kubectl has never heard of.
184
+ *
185
+ * Setting both costs nothing and leaks nothing: in `scratch` mode the
186
+ * value is deliberately not in the environment, so both variables hold a
187
+ * path and neither holds a secret.
188
+ */
189
+ env[entry.name] = file;
190
+ env[`${entry.name}_FILE`] = file;
191
+ }
192
+
205
193
  /**
206
194
  * Isolated mode: point the toolchain at a home of this role's own.
207
195
  *
@@ -247,7 +235,15 @@ export async function run(config, argv) {
247
235
  const canRedact = wantRedact && !process.stdout.isTTY;
248
236
  if (wantRedact && !canRedact) err(`${C.dim}seisin: redaction off — interactive terminal${C.off}\n`);
249
237
 
250
- const secrets = canRedact ? secretsOf(settings, readFileSync) : [];
238
+ // Resolved references are masked alongside the key files. They never touched
239
+ // the disk under a path `secretsOf` could find, so without this the one kind
240
+ // of key that is supposed to be better looked after would be the one kind
241
+ // that sails through the redactor into a log.
242
+ const secrets = canRedact
243
+ ? [...secretsOf(settings, readFileSync), ...resolved.map((r) => r.value)]
244
+ .filter((v) => v.length >= 8)
245
+ .sort((a, b) => b.length - a.length)
246
+ : [];
251
247
  const outStream = secrets.length ? redactor(secrets) : null;
252
248
  const errStream = secrets.length ? redactor(secrets) : null;
253
249
  if (outStream) { outStream.pipe(process.stdout); errStream.pipe(process.stderr); }
@@ -270,55 +266,25 @@ export async function run(config, argv) {
270
266
  * there is one — `attributeTo` below — and anything that arrived in between is
271
267
  * held and re-examined rather than credited to this role on faith.
272
268
  *
273
- * These lines go through the same `append()` the spool does, with `owners`
274
- * recomputed here exactly as they are for a hook line. The rule that nothing
275
- * is taken at its word still applies; what changes is who is claiming. The
276
- * hook is the process being recorded, and the kernel is the thing that
277
- * actually refused — so when the two disagree, this is the one that is right.
269
+ * What each denial becomes — a log line, a request, or a count — is decided
270
+ * 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.
278
272
  */
279
- const scope = scopeOf(settings, config.root);
280
- const keyDirs = (config.keyDirs ?? []).map((d) => (d.startsWith("/") ? d : join(config.root, d)));
281
- let offPolicy = 0; // refused, but about nothing the policy names
282
- let walks = 0; // a recursive search reaching a closed door
283
- const denials = watchDenials((d) => {
284
- if (!inScope(d.path, scope)) { offPolicy++; return; }
285
- if (!reachedForContent(d)) { walks++; return; }
286
- // Relative inside the repo, absolute outside it. A role can be refused at
287
- // ~/.ssh under `isolate`, and "../../../.ssh/id_rsa" would be a worse
288
- // answer to "what was refused" than the path itself.
289
- const rel = d.path.startsWith(config.root + "/") ? d.path.slice(config.root.length + 1) : d.path;
290
-
291
- /**
292
- * A refusal the hook never saw still leaves a request behind.
293
- *
294
- * Without this the two halves of the record disagree in the worst
295
- * direction: `review` would show a role stopped repeatedly on a directory
296
- * while the queue held nothing to approve, so the one refusal a person most
297
- * needed to see — the one that escaped the hook — would be the one with no
298
- * way to act on it.
299
- *
300
- * There is no double counting to avoid. When the hook catches something it
301
- * denies the tool call outright and the command never runs, so the kernel
302
- * never sees it. These are the ones that got past it, which is the whole
303
- * reason this exists. Inside the repo only: `~/.ssh` is refused on purpose
304
- * under `isolate` and is not a territory anyone is meant to ask for.
305
- */
306
- if (rel !== d.path)
307
- record(requestsPath(config.root), { role, action: d.action, target: rel, owners: ownersOf(config, rel) });
308
-
309
- append(logPath(config.root), {
310
- at: new Date().toISOString(),
311
- role,
312
- tool: "kernel",
313
- source: "kernel",
314
- action: d.action,
315
- kind: keyDirs.some((k) => d.path === k || d.path.startsWith(k + "/")) ? "key" : "file",
316
- target: rel,
317
- verdict: "denied",
318
- owners: ownersOf(config, rel),
319
- reason: d.operation,
320
- });
321
- }, { argv: cmd });
273
+ // What actually runs inside the box, computed once and used by both ends. The
274
+ // watcher recognises a denial by comparing the runtime's command tag with this
275
+ // 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
277
+ // — writes included — left no line (measured: 0 of 5, and 5 of 5 without the key).
278
+ //
279
+ // And a nonce in front, because the tag alone does not tell runs apart: an
280
+ // orchestrator starts several roles with the SAME command, the kernel tags
281
+ // each denial with that command, and every watcher listening recognised it
282
+ // as its own. One write outside territory became one line per role running,
283
+ // one request per role, and a denial logged against the role that owns the
284
+ // path (measured: 3 lines and 3 requests for one write by one of 3 roles).
285
+ // The nonce makes each run's command unique, so the tag names one run.
286
+ const boxed = ["env", `SEISIN_RUN_ID=${theRun.id}`, ...loopbackVia(config.roles[role], cmd)];
287
+ const denials = watchDenials(take.fromKernel, { argv: boxed });
322
288
 
323
289
  // Deliberately NOT announced here. On Linux this branch is taken every time,
324
290
  // so saying it per run puts a line the reader cannot act on in front of every
@@ -327,11 +293,51 @@ export async function run(config, argv) {
327
293
  // platform, so it belongs with the other standing limits, in `seisin check`.
328
294
  void denials.available;
329
295
 
330
- const child = spawn(srt, ["--settings", file, "--", ...cmd], {
296
+ /**
297
+ * An agent that sandboxes itself has to be told about, before it starts.
298
+ *
299
+ * Said here and not by `check`, because `check` never sees the command. And
300
+ * said rather than fixed: passing somebody's bypass flag for them is exactly
301
+ * the kind of quiet widening this tool exists to refuse.
302
+ */
303
+ const nested = nestedSandboxWarning(cmd);
304
+ if (nested) err(`${C.yellow}seisin: ${nested}${C.off}\n`);
305
+
306
+ const child = spawn(srt, ["--settings", file, "--", ...boxed], {
331
307
  stdio: outStream ? ["inherit", "pipe", "pipe"] : "inherit",
332
308
  env,
333
309
  });
334
310
  denials.attributeTo(child.pid);
311
+
312
+ /**
313
+ * Signals: seisin must not die of one, and must not double one.
314
+ *
315
+ * Without handlers Node dies on SIGTERM and SIGINT at once: the agent kept
316
+ * running with nobody holding its audit channel, and the run's directory
317
+ * stayed behind (74 had piled up on one machine). An orchestrator's timeout
318
+ * is a SIGTERM to seisin's pid alone, so SIGTERM and SIGHUP are passed on,
319
+ * and the run exits 128 + the signal — the runtime itself ends 0 when told
320
+ * to stop, which reported success for a killed run.
321
+ *
322
+ * SIGINT is NOT passed on. It comes from the terminal, which sends it to the
323
+ * whole foreground group: the runtime gets it too and forwards it to the
324
+ * agent. Measured with one Ctrl-C: the agent received 2 SIGINTs before any
325
+ * of this (the runtime already duplicates), and 3 when seisin forwarded as
326
+ * well — and two in a row is how Claude Code tells "cancel" from "quit". So
327
+ * seisin only stops dying of it.
328
+ *
329
+ * And an interrupted run is never reported as a success. The runtime exits 0
330
+ * whenever its child dies of a signal (measured: SIGINT and SIGTERM, to the
331
+ * group or to its pid, all 0), so from here "the agent caught the Ctrl-C and
332
+ * finished" and "the agent was killed by it" look the same. The run exits
333
+ * 130 in both until the runtime passes the status through; the other choice
334
+ * reports a killed run as done, which is the one an orchestrator acts on.
335
+ */
336
+ let stoppedBy = null;
337
+ let interrupted = false;
338
+ process.on("SIGINT", () => { interrupted = true; });
339
+ for (const sig of ["SIGTERM", "SIGHUP"])
340
+ process.on(sig, () => { stoppedBy ??= sig; try { child.kill(sig); } catch {} });
335
341
  if (outStream) { child.stdout.pipe(outStream); child.stderr.pipe(errStream); }
336
342
 
337
343
  // The exit code is the child's, and the output has to be all the way out
@@ -339,15 +345,26 @@ export async function run(config, argv) {
339
345
  //
340
346
  // - Calling exit() the moment the child exits discards whatever is still in
341
347
  // the redaction stream, so the command looks like it produced nothing.
342
- // - Waiting for `finish` but subscribing AFTER end() misses the event when
343
- // it fires synchronously, so nothing ever calls exit and the process
344
- // drifts out of the event loop with status 0 — every failure reported as
345
- // a success. A battery of seven sandbox tests came back green on that.
348
+ // - Waiting for `finish` but subscribing AFTER it fired left nothing to call
349
+ // exit: first the process drifted out with status 0 (every failure
350
+ // reported as a success), later the 2-second guard below caught it and
351
+ // every keyed run paid two seconds. `finished()` has no such window.
346
352
  //
347
- // Subscribe first, end second, and keep a real timer (not unref'd, or it
348
- // cannot save anything) as the floor.
353
+ // Keep a real timer (not unref'd, or it cannot save anything) as the ceiling.
354
+ /**
355
+ * The status is worked out when the run is about to leave, not when the
356
+ * child exits. A Ctrl-C reaches the runtime and seisin at once; when the
357
+ * runtime is quicker, the child's `exit` is handled before seisin's own
358
+ * SIGINT, `interrupted` is still false, and an interrupted run left with 0.
359
+ * Seen once in a full suite run under load; by the end of the drains below
360
+ * the pending signal has been handled.
361
+ */
362
+ const statusOf = (code, signal) => stoppedBy ? 128 + osConstants.signals[stoppedBy]
363
+ : signal ? 1
364
+ : interrupted && !code ? 130
365
+ : code ?? 0;
366
+
349
367
  child.on("exit", async (code, signal) => {
350
- const status = signal ? 1 : code ?? 0;
351
368
  // The notice rides on what you are already looking at. A queue nobody opens
352
369
  // is not human-in-the-loop, and this is the terminal that just showed you
353
370
  // the denial — so it goes to stderr, beside it, not into the agent's stdout
@@ -363,7 +380,7 @@ export async function run(config, argv) {
363
380
  // nothing pays a tick.
364
381
  await denials.close({ drain: DRAIN_MS });
365
382
  // The run made this directory, so the run removes it.
366
- try { rmSync(dirname(sockPath), { recursive: true, force: true }); } catch {}
383
+ theRun.close();
367
384
 
368
385
  // Say what was dropped rather than only what was kept. A filter nobody can
369
386
  // see is indistinguishable from a monitor that is not working, and this one
@@ -373,6 +390,7 @@ export async function run(config, argv) {
373
390
  // policy's paths` is a line that answers a question nobody asked — the
374
391
  // counts exist to explain a filter, not to announce that one ran.
375
392
  const { attributed, foreign } = denials.stats;
393
+ const { offPolicy, walks } = take.stats;
376
394
  const recorded = attributed - offPolicy - walks;
377
395
  if (recorded > 0)
378
396
  err(`${C.dim}seisin: ${recorded} kernel denial(s) recorded` +
@@ -381,15 +399,26 @@ export async function run(config, argv) {
381
399
  `${foreign ? `, ${foreign} from other sandboxes` : ""}${C.off}\n`);
382
400
 
383
401
  const queue = pending(requestsPath(config.root));
384
- if (queue.length) err(renderQueue(queue));
402
+ if (queue.length) err(renderQueue(markStale(queue, read(logPath(config.root)))));
403
+ const failed = await notify.settle();
404
+ if (failed.length) err(`${C.yellow}seisin: could not notify about a new request (${failed[0]})${C.off}\n`);
405
+ // One more turn of the loop, so a signal that arrived with the child's exit
406
+ // has run its handler before the status is decided.
407
+ await new Promise((r) => setImmediate(r));
408
+ const status = statusOf(code, signal);
385
409
  if (!outStream) return process.exit(status);
386
- let left = 2;
410
+ // `finished()` and not `once("finish")`, because the event is usually over
411
+ // before this line runs: `child.stdout.pipe(outStream)` ends the redactor
412
+ // itself when the child's stdout closes, so the listener was attached to an
413
+ // event that had already fired and the 2-second guard was the only way out.
414
+ // Every run with a key paid it — measured 3.0 s against 1.2 s without keys.
415
+ // `finished()` settles at once for a stream that is already done.
387
416
  const guard = setTimeout(() => process.exit(status), 2000);
388
- const tick = () => { if (--left > 0) return; clearTimeout(guard); process.exit(status); };
389
- outStream.once("finish", tick);
390
- errStream.once("finish", tick);
391
417
  outStream.end();
392
418
  errStream.end();
419
+ await Promise.all([finished(outStream), finished(errStream)]).catch(() => {});
420
+ clearTimeout(guard);
421
+ process.exit(status);
393
422
  });
394
423
  child.on("error", (e) => { throw new Error(`could not start the sandbox: ${e.message}`); });
395
424
  }
@@ -16,11 +16,25 @@ export async function ui(config, argv = []) {
16
16
  const port = i === -1 ? 4178 : Number(argv[i + 1]);
17
17
 
18
18
  const server = await serve(config.path, port);
19
- const url = `http://127.0.0.1:${port}`;
19
+ const bound = server.address().port;
20
+ // The token rides in the fragment, which the browser keeps to itself: no
21
+ // request to this server, or to anything else, ever carries it. The page
22
+ // moves it into the tab's storage and strips it from the address bar.
23
+ const url = `http://127.0.0.1:${bound}/#t=${server.seisinToken}`;
24
+ // local_binding opens every port (macOS); local_ports opens the ones it
25
+ // names, on every platform — so only a role that names THIS port counts.
26
+ const reach = Object.values(config.roles)
27
+ .filter((r) => (r.localBinding && process.platform === "darwin") || (r.localPorts ?? []).includes(bound))
28
+ .map((r) => r.name);
20
29
 
21
30
  out(
22
31
  `\n ${C.b}${url}${C.off}\n` +
23
- ` ${C.dim}reading ${relative(process.cwd(), config.path)} and the log, live. ctrl-c to stop.${C.off}\n\n`
32
+ ` ${C.dim}reading ${relative(process.cwd(), config.path)} and the log, live. ctrl-c to stop.${C.off}\n` +
33
+ ` ${C.dim}the link carries this run's token: open it here, do not paste it anywhere.${C.off}\n` +
34
+ (reach.length
35
+ ? ` ${C.yellow}${reach.join(", ")} can reach this port on localhost: they can load this page, ` +
36
+ `but not the token, which only travels in the link above.${C.off}\n`
37
+ : "") + "\n"
24
38
  );
25
39
 
26
40
  const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
@@ -0,0 +1,30 @@
1
+ /** `seisin walls <role>` — what that role keeps being refused, and what it cost. */
2
+ import { walls, render } from "../walls.js";
3
+ import { STALE_RUNS } from "../requests.js";
4
+ import { logPath } from "../log.js";
5
+ import { out } from "../render.js";
6
+
7
+ export function wallsCommand(config, argv = []) {
8
+ const role = argv[0];
9
+ 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(", ")}`);
12
+ const at = (flag, fallback) => {
13
+ const i = argv.indexOf(flag);
14
+ return i === -1 ? fallback : argv[i + 1];
15
+ };
16
+ const every = walls(config, role, {
17
+ file: logPath(config.root),
18
+ min: Number(at("--min", 2)),
19
+ since: at("--since", null),
20
+ limit: 0,
21
+ });
22
+ const all = argv.includes("--all");
23
+ const list = (all ? every : every.filter((w) => !w.stale)).slice(0, 6);
24
+ const old = every.filter((w) => w.stale).length;
25
+ // Nothing to say prints nothing, the way `scan` does when a repo is clean.
26
+ // A command that always speaks is one whose output stops being read.
27
+ if (list.length) out(render(list));
28
+ if (old && !all) out(` (${old} older wall(s) not hit in the last ${STALE_RUNS}+ runs: seisin walls ${role} --all)\n`);
29
+ return list;
30
+ }
@@ -16,6 +16,7 @@
16
16
  * worth attacking, because unlike everything else in this tool it is meant to
17
17
  * be called *from inside* the box.
18
18
  */
19
+ import { toRepoRelative } from "../paths.js";
19
20
  import { ownersOf, keyHolders } from "../owners.js";
20
21
  import { twinsOf, whereIs } from "../worktree.js";
21
22
  import { C, out } from "../render.js";
@@ -24,7 +25,7 @@ export function whose(config, argv = []) {
24
25
  const target = argv[0];
25
26
  if (!target) throw new Error("usage: seisin whose <path>");
26
27
 
27
- const rel = target.startsWith(config.root + "/") ? target.slice(config.root.length + 1) : target;
28
+ const rel = toRepoRelative(config, target);
28
29
  const isKey = (config.keyDirs ?? []).some((d) => rel === d || rel.startsWith(d + "/"));
29
30
  const owners = isKey ? keyHolders(config, rel) : ownersOf(config, rel);
30
31
 
@@ -20,10 +20,29 @@ import { C, out } from "../render.js";
20
20
  const SETTINGS = join(".claude", "settings.json");
21
21
 
22
22
  /** Where the PreToolUse entry lives, and what it has to say. */
23
- export function hookEntry() {
23
+ export function hookEntry(command = "seisin hook") {
24
24
  return {
25
25
  matcher: "*",
26
- hooks: [{ type: "command", command: "seisin hook" }],
26
+ hooks: [{ type: "command", command }],
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Every event `seisin hook` answers, and the matcher each one needs.
32
+ *
33
+ * PreToolUse explains before an attempt; the other three explain what only the
34
+ * kernel saw (PostToolUseFailure, and PostToolUse for Bash, whose failed
35
+ * command is a result rather than a failure) and hand the agent its map when a
36
+ * session starts, resumes or is compacted. Exported so a harness that writes
37
+ * its own settings per role — rather than the project's — can install the same.
38
+ */
39
+ export function hookEntries(command = "seisin hook") {
40
+ const h = [{ type: "command", command }];
41
+ return {
42
+ PreToolUse: [{ matcher: "*", hooks: h }],
43
+ PostToolUse: [{ matcher: "Bash", hooks: h }],
44
+ PostToolUseFailure: [{ matcher: "*", hooks: h }],
45
+ SessionStart: [{ matcher: "startup|resume|compact", hooks: h }],
27
46
  };
28
47
  }
29
48
 
@@ -32,7 +51,11 @@ export function wired(root) {
32
51
  const file = join(root, SETTINGS);
33
52
  if (!existsSync(file)) return false;
34
53
  try {
35
- return JSON.stringify(JSON.parse(readFileSync(file, "utf8"))).includes("seisin hook");
54
+ // All four events, not any one: a repo wired before the after-the-fact and
55
+ // session-start hooks existed has only PreToolUse, and "already wired" there
56
+ // would keep it from ever getting the rest.
57
+ const hooks = JSON.parse(readFileSync(file, "utf8")).hooks ?? {};
58
+ return Object.keys(hookEntries()).every((ev) => JSON.stringify(hooks[ev] ?? []).includes("seisin hook"));
36
59
  } catch {
37
60
  return false; // unreadable settings is not "wired"
38
61
  }
@@ -58,8 +81,10 @@ export function wire(config) {
58
81
  }
59
82
 
60
83
  settings.hooks ??= {};
61
- settings.hooks.PreToolUse ??= [];
62
- settings.hooks.PreToolUse.push(hookEntry());
84
+ for (const [ev, entries] of Object.entries(hookEntries())) {
85
+ settings.hooks[ev] ??= [];
86
+ if (!JSON.stringify(settings.hooks[ev]).includes("seisin hook")) settings.hooks[ev].push(...entries);
87
+ }
63
88
 
64
89
  mkdirSync(join(config.root, ".claude"), { recursive: true });
65
90
  writeFileSync(file, JSON.stringify(settings, null, 2) + "\n");