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.
- package/README.md +142 -54
- package/package.json +3 -3
- package/src/cli.js +145 -46
- package/src/commands/check.js +3 -2
- package/src/commands/explain.js +34 -13
- package/src/commands/hook.js +14 -4
- package/src/commands/init.js +126 -38
- package/src/commands/log.js +48 -9
- package/src/commands/requests.js +109 -32
- package/src/commands/review.js +41 -16
- package/src/commands/run.js +159 -37
- package/src/commands/scan.js +18 -3
- package/src/commands/ui.js +18 -5
- package/src/commands/walls.js +25 -6
- package/src/commands/whose.js +7 -6
- package/src/commands/wire.js +156 -17
- package/src/config.js +187 -25
- package/src/controls.js +244 -0
- package/src/diagnose.js +35 -33
- package/src/env.js +11 -8
- package/src/grants.js +15 -7
- package/src/help.js +159 -0
- package/src/hook.js +49 -19
- package/src/index.js +4 -4
- package/src/inspect.js +137 -71
- package/src/intake.js +20 -15
- package/src/keys.js +13 -11
- package/src/kinds.js +207 -0
- package/src/log.js +236 -51
- package/src/mcp.js +42 -28
- package/src/notify.js +13 -8
- package/src/owners.js +198 -41
- package/src/paths.js +37 -1
- package/src/redact.js +19 -5
- package/src/render.js +68 -10
- package/src/requests.js +173 -72
- package/src/review.js +30 -17
- package/src/rundir.js +4 -6
- package/src/scan.js +177 -19
- package/src/serve.js +377 -229
- package/src/spool.js +9 -29
- package/src/srt.js +38 -21
- package/src/suggest.js +58 -0
- package/src/surface.js +191 -44
- package/src/uilink.js +10 -4
- package/src/views.js +289 -0
- package/src/violations.js +104 -16
- package/src/walls.js +19 -42
- package/src/worktree.js +3 -11
- package/ui/index.html +1356 -537
package/src/commands/run.js
CHANGED
|
@@ -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,
|
|
24
|
+
import { pending, requestsPath, withDeclarers } from "../requests.js";
|
|
25
25
|
import { notifier } from "../notify.js";
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
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
|
|
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
|
|
52
|
-
if (
|
|
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 (
|
|
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
|
|
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
|
|
108
|
-
* makes every worker a descendant of the
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
213
|
+
audit = await spool(take.fromHook, sockPath);
|
|
147
214
|
|
|
148
215
|
const srt = resolveSrt();
|
|
149
|
-
if (!srt) throw new Error(
|
|
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
|
|
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
|
-
//
|
|
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 ${
|
|
238
|
-
`
|
|
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
|
|
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
|
|
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
|
|
369
|
+
* actually denied, so when the two disagree, it is the one that is right.
|
|
285
370
|
*/
|
|
286
|
-
// What actually runs inside 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
|
|
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
|
-
|
|
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 ?
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
415
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/commands/scan.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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;
|
package/src/commands/ui.js
CHANGED
|
@@ -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
|
|
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
|
|
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 ${
|
|
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
|
+
}
|
package/src/commands/walls.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
/** `seisin walls <role>` — what that role keeps being
|
|
2
|
-
import {
|
|
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(
|
|
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
|
+
}
|
package/src/commands/whose.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|