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.
- package/README.md +287 -339
- package/package.json +7 -6
- package/src/cli.js +17 -3
- package/src/commands/explain.js +9 -2
- package/src/commands/hook.js +11 -0
- package/src/commands/init.js +1 -1
- package/src/commands/log.js +27 -1
- package/src/commands/requests.js +8 -3
- package/src/commands/review.js +33 -5
- package/src/commands/run.js +180 -151
- package/src/commands/ui.js +16 -2
- package/src/commands/walls.js +30 -0
- package/src/commands/whose.js +2 -1
- package/src/commands/wire.js +30 -5
- package/src/config.js +310 -5
- package/src/diagnose.js +172 -0
- package/src/env.js +54 -1
- package/src/grants.js +239 -0
- package/src/hook.js +45 -5
- package/src/index.js +12 -0
- package/src/inspect.js +332 -9
- package/src/intake.js +215 -0
- package/src/keys.js +585 -0
- package/src/log.js +96 -2
- package/src/mcp.js +87 -11
- package/src/nested.js +86 -0
- package/src/notify.js +146 -0
- package/src/owners.js +208 -6
- package/src/paths.js +50 -0
- package/src/render.js +63 -1
- package/src/requests.js +88 -0
- package/src/review.js +9 -4
- package/src/rundir.js +113 -0
- package/src/serve.js +263 -19
- package/src/spool.js +98 -20
- package/src/srt.js +148 -203
- package/src/surface.js +518 -0
- package/src/violations.js +37 -5
- package/src/walls.js +184 -0
- package/ui/favicon.svg +1 -1
- package/ui/index.html +585 -56
package/src/commands/run.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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,
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
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
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
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
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
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
|
-
//
|
|
348
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/commands/ui.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
}
|
package/src/commands/whose.js
CHANGED
|
@@ -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 =
|
|
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
|
|
package/src/commands/wire.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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");
|