seisin 0.4.2 → 0.6.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 +506 -162
- package/package.json +14 -6
- package/src/browsers.js +52 -0
- package/src/canary.js +149 -0
- package/src/cli.js +175 -52
- package/src/commands/check.js +157 -5
- package/src/commands/explain.js +31 -13
- package/src/commands/hook.js +20 -6
- package/src/commands/init.js +183 -88
- package/src/commands/log.js +48 -9
- package/src/commands/requests.js +122 -70
- package/src/commands/review.js +73 -21
- package/src/commands/run.js +422 -78
- package/src/commands/scan.js +18 -3
- package/src/commands/ui.js +18 -5
- package/src/commands/walls.js +62 -11
- package/src/commands/whose.js +9 -7
- package/src/commands/wire.js +156 -17
- package/src/config.js +505 -37
- package/src/controls.js +244 -0
- package/src/diagnose.js +102 -49
- package/src/env.js +14 -8
- package/src/grants.js +122 -9
- package/src/handoff.js +285 -10
- package/src/help.js +179 -0
- package/src/hook.js +101 -42
- package/src/index.d.ts +217 -0
- package/src/index.js +24 -16
- package/src/inspect/codex.js +64 -0
- package/src/inspect/credentials.js +48 -0
- package/src/inspect/enforce.js +24 -0
- package/src/inspect/files.js +71 -0
- package/src/inspect/home.js +43 -0
- package/src/inspect/inject.js +68 -0
- package/src/inspect/keys.js +74 -0
- package/src/inspect/layout.js +258 -0
- package/src/inspect/limits.js +98 -0
- package/src/inspect/machine.js +107 -0
- package/src/inspect/network.js +37 -0
- package/src/inspect/providers.js +71 -0
- package/src/inspect/read-territory.js +105 -0
- package/src/inspect/role-keys.js +136 -0
- package/src/inspect/shared.js +37 -0
- package/src/inspect/warnings.js +58 -0
- package/src/inspect.js +20 -756
- package/src/intake.js +150 -35
- package/src/keys.js +231 -73
- package/src/kinds.js +249 -0
- package/src/lines.js +26 -0
- package/src/log.js +242 -52
- package/src/mcp.js +57 -58
- package/src/nested.js +12 -3
- package/src/notify.js +66 -20
- package/src/owners.js +358 -39
- package/src/paths.js +54 -2
- package/src/provider-run.js +81 -0
- package/src/redact.js +46 -13
- package/src/render.js +101 -17
- package/src/requests.js +506 -87
- package/src/review.js +50 -25
- package/src/rundir.js +33 -14
- package/src/sandboxes/command.js +93 -0
- package/src/sandboxes/index.js +113 -0
- package/src/sandboxes/mach-profile.js +31 -0
- package/src/sandboxes/srt-mach.mjs +43 -0
- package/src/sandboxes/srt.js +163 -0
- package/src/sandboxes/tartaros.js +80 -0
- package/src/scan.js +181 -19
- package/src/serve.js +396 -230
- package/src/spool.js +16 -45
- package/src/srt.js +296 -49
- package/src/suggest.js +58 -0
- package/src/surface.js +314 -54
- package/src/territory.js +261 -0
- package/src/trace.js +33 -0
- package/src/uilink.js +24 -9
- package/src/views.js +315 -0
- package/src/violations.js +247 -23
- package/src/walls.js +76 -46
- package/src/worktree.js +3 -11
- package/ui/index.html +1429 -538
- package/ui/favicon.svg +0 -1
package/src/violations.js
CHANGED
|
@@ -38,16 +38,37 @@
|
|
|
38
38
|
* reads paths out of the traced process's memory — its own comment calls those
|
|
39
39
|
* events attacker-controlled and racy. That is not a stream an outside process
|
|
40
40
|
* can attach to, and it is not one to reimplement. On Linux this returns an
|
|
41
|
-
* unavailable watcher with the reason
|
|
41
|
+
* unavailable watcher with the reason. `seisin run` does not print it — once
|
|
42
|
+
* per run is a line nobody can act on — and `seisin check` says it, with the
|
|
43
|
+
* platform's other standing limits.
|
|
42
44
|
*/
|
|
43
45
|
import { spawn, execFileSync } from "node:child_process";
|
|
44
46
|
import { statSync } from "node:fs";
|
|
47
|
+
import { basename } from "node:path";
|
|
45
48
|
|
|
46
49
|
/** Only sandbox-runtime tags its violations this way. Everything else on the
|
|
47
50
|
* machine — Safari, mDNSResponder, Spotlight — is filtered out by the OS
|
|
48
51
|
* before a byte reaches us, which is why the predicate is not a grep. */
|
|
49
52
|
const PREDICATE = 'eventMessage ENDSWITH "_SBX"';
|
|
50
53
|
|
|
54
|
+
/**
|
|
55
|
+
* What `log stream` prints in place of the messages it could not keep up with.
|
|
56
|
+
*
|
|
57
|
+
* The one way this instrument loses a denial without anything else noticing:
|
|
58
|
+
* the kernel refuses, the agent sees EPERM, and the line never arrives. It
|
|
59
|
+
* happens when the unified log is flooded — measured, a network filter's
|
|
60
|
+
* kernel extension writing ~6,700 lines a second while it dropped two
|
|
61
|
+
* sessions' connections — and the predicate does not help, because the stream
|
|
62
|
+
* is dropped before it is filtered. The marker carries no count and no names:
|
|
63
|
+
* one line per batch lost, which is all there is to report.
|
|
64
|
+
*/
|
|
65
|
+
const DROPPED = /^=== Messages dropped during live streaming\b/gm;
|
|
66
|
+
|
|
67
|
+
/** How many batches `log stream` says it dropped in this text. */
|
|
68
|
+
export function droppedIn(text) {
|
|
69
|
+
return (String(text).match(DROPPED) ?? []).length;
|
|
70
|
+
}
|
|
71
|
+
|
|
51
72
|
/** `Sandbox: <proc>(<pid>) deny(<n>) <operation> <detail>` */
|
|
52
73
|
const DENY = /Sandbox:\s+(\S+)\((\d+)\)\s+deny\(\d+\)\s+(\S+)\s+(.+?)\s*$/;
|
|
53
74
|
|
|
@@ -82,9 +103,25 @@ export function actionOf(operation) {
|
|
|
82
103
|
if (operation.startsWith("file-write")) return "write";
|
|
83
104
|
if (operation === "file-read-data") return "read";
|
|
84
105
|
if (operation === "network-outbound") return "connect";
|
|
106
|
+
if (SYSTEM.test(operation)) return "system";
|
|
85
107
|
return null;
|
|
86
108
|
}
|
|
87
109
|
|
|
110
|
+
/**
|
|
111
|
+
* The operations that are neither a file nor a connection — `mach-register`,
|
|
112
|
+
* `mach-lookup`, `sysctl-read`, `iokit-open`… — and that used to be dropped.
|
|
113
|
+
*
|
|
114
|
+
* They were dropped as the sandbox doing its job against the machine, and most
|
|
115
|
+
* of them are: a browser asks for the window server and carries on. But one of
|
|
116
|
+
* them was the whole failure of a headless Chromium — a refused
|
|
117
|
+
* `mach-register` that killed the browser at start — and from seisin's record
|
|
118
|
+
* there was no way to know: five file reads in the log, and a crash. So they
|
|
119
|
+
* are recorded now, by operation and name, once per run each (intake.js), and
|
|
120
|
+
* never as a request: there is no territory to hand them to. What grants one
|
|
121
|
+
* is a preset like `browsers`, not `seisin grant`.
|
|
122
|
+
*/
|
|
123
|
+
const SYSTEM = /^(mach|sysctl|iokit|ipc|signal|process|system|user-preference|darwin|nvram|lsopen|appleevent)-/;
|
|
124
|
+
|
|
88
125
|
/**
|
|
89
126
|
* The target of a refused connection, or null when it is not one worth a line.
|
|
90
127
|
*
|
|
@@ -98,7 +135,7 @@ export function actionOf(operation) {
|
|
|
98
135
|
* - `remote:*:<port>` — a TCP dial the profile refused. The kernel does not
|
|
99
136
|
* name the host, so the target is the port and nothing more.
|
|
100
137
|
* - a unix socket path — except under /var/run, where the system daemons
|
|
101
|
-
* live: every DNS lookup inside the
|
|
138
|
+
* live: every DNS lookup inside the sandbox is a refused connect to
|
|
102
139
|
* mDNSResponder, and recording those would bury everything else.
|
|
103
140
|
* - anything else (an empty detail, which the kernel also writes) — dropped.
|
|
104
141
|
*/
|
|
@@ -139,6 +176,13 @@ export function parseChunk(chunk) {
|
|
|
139
176
|
if (!target) return null;
|
|
140
177
|
return { ...deny, action, path: target, suffix: tag?.suffix ?? null, command: tag?.command ?? null };
|
|
141
178
|
}
|
|
179
|
+
// The name as the kernel gave it ("com.apple.tsm.portname (per-pid)"); an
|
|
180
|
+
// operation with no name is not a line anyone can act on.
|
|
181
|
+
if (action === "system") {
|
|
182
|
+
const name = deny.detail.trim();
|
|
183
|
+
if (!name) return null;
|
|
184
|
+
return { ...deny, action, path: name, suffix: tag?.suffix ?? null, command: tag?.command ?? null };
|
|
185
|
+
}
|
|
142
186
|
// Only an absolute path is a territory question. A relative or malformed
|
|
143
187
|
// detail means the line was not what it looked like; dropping beats guessing.
|
|
144
188
|
if (!deny.detail.startsWith("/")) return null;
|
|
@@ -219,8 +263,8 @@ export function isOurs(tagged, argv) {
|
|
|
219
263
|
* reimplementing the runtime's shell quoting and inheriting its bugs; a process
|
|
220
264
|
* tree is a fact the OS already holds.
|
|
221
265
|
*
|
|
222
|
-
*
|
|
223
|
-
* that the suffix alone is exact, because it is generated once per `srt`
|
|
266
|
+
* At most one `ps` per TREE_MS, and only until the session suffix is learned —
|
|
267
|
+
* after that the suffix alone is exact, because it is generated once per `srt`
|
|
224
268
|
* process. In practice that is one `ps` per run.
|
|
225
269
|
*/
|
|
226
270
|
export function descends(pid, root, snapshot) {
|
|
@@ -235,14 +279,25 @@ export function descends(pid, root, snapshot) {
|
|
|
235
279
|
return false;
|
|
236
280
|
}
|
|
237
281
|
|
|
238
|
-
/**
|
|
282
|
+
/**
|
|
283
|
+
* pid → ppid for every process this user can see, and in `tree.roots` the
|
|
284
|
+
* pids that are a sandbox run's root: an `srt`, or a `seisin run`.
|
|
285
|
+
*
|
|
286
|
+
* The roots are what makes "not ours" provable. A process outside our tree is
|
|
287
|
+
* someone else's only if its ancestry reaches another run; one whose ancestry
|
|
288
|
+
* ends at init was orphaned — `cmd &`, a build daemon, git's fsmonitor — and
|
|
289
|
+
* may well be ours, reparented when the shell that started it exited.
|
|
290
|
+
*/
|
|
239
291
|
export function processTree(run = execFileSync) {
|
|
240
292
|
const tree = new Map();
|
|
293
|
+
tree.roots = new Set();
|
|
241
294
|
try {
|
|
242
|
-
const out = run("/bin/ps", ["-Ao", "pid=,ppid="], { encoding: "utf8" });
|
|
295
|
+
const out = run("/bin/ps", ["-Ao", "pid=,ppid=,args="], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
243
296
|
for (const line of out.split("\n")) {
|
|
244
|
-
const m = line.trim().match(/^(\d+)\s+(\d+)
|
|
245
|
-
if (m)
|
|
297
|
+
const m = line.trim().match(/^(\d+)\s+(\d+)(?:\s+(.*))?$/);
|
|
298
|
+
if (!m) continue;
|
|
299
|
+
tree.set(Number(m[1]), Number(m[2]));
|
|
300
|
+
if (m[3] && RUN_ROOT.test(m[3])) tree.roots.add(Number(m[1]));
|
|
246
301
|
}
|
|
247
302
|
} catch {
|
|
248
303
|
// No tree means nothing can be attributed, which the caller handles by
|
|
@@ -251,6 +306,25 @@ export function processTree(run = execFileSync) {
|
|
|
251
306
|
return tree;
|
|
252
307
|
}
|
|
253
308
|
|
|
309
|
+
/** The command line of an `srt` (however it was installed) or of `seisin run`. */
|
|
310
|
+
const RUN_ROOT = /(?:^|[\s/])srt(?:\s|$)|sandbox-runtime\/dist\/cli\.js(?:\s|$)|(?:^|[\s/])(?:seisin|cli\.js)\s+run\s/;
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Does `pid` descend from a run root other than `root`?
|
|
314
|
+
*
|
|
315
|
+
* A table without command lines (`roots` absent) cannot tell an orphan from a
|
|
316
|
+
* stranger, and answers as before: alive and not ours is someone else's.
|
|
317
|
+
*/
|
|
318
|
+
export function underAnotherRun(pid, root, snapshot) {
|
|
319
|
+
if (!snapshot.roots) return true;
|
|
320
|
+
const seen = new Set();
|
|
321
|
+
for (let at = pid; at && at !== 1 && !seen.has(at); at = snapshot.get(at)) {
|
|
322
|
+
if (at !== root && snapshot.roots.has(at)) return true;
|
|
323
|
+
seen.add(at);
|
|
324
|
+
}
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
|
|
254
328
|
/**
|
|
255
329
|
* Did this refusal reach for content, or just walk past the door?
|
|
256
330
|
*
|
|
@@ -336,8 +410,8 @@ export function scopeOf(settings, root) {
|
|
|
336
410
|
* without `log` looks like — and the caller says so once rather than silently
|
|
337
411
|
* logging less than it used to.
|
|
338
412
|
*/
|
|
339
|
-
export function watchDenials(onDeny, { pid = null, argv = null, platform = process.platform, spawnFn = spawn, treeFn = processTree } = {}) {
|
|
340
|
-
const stats = { attributed: 0, foreign: 0, unattributed: 0 };
|
|
413
|
+
export function watchDenials(onDeny, { pid = null, argv = null, platform = process.platform, spawnFn = spawn, treeFn = processTree, now = () => performance.now() } = {}) {
|
|
414
|
+
const stats = { attributed: 0, foreign: 0, unattributed: 0, dropped: 0 };
|
|
341
415
|
|
|
342
416
|
/**
|
|
343
417
|
* Unavailable is a watcher that does nothing, NOT a different shape.
|
|
@@ -361,7 +435,7 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
|
|
|
361
435
|
return {
|
|
362
436
|
available: false,
|
|
363
437
|
reason: platform === "linux"
|
|
364
|
-
? "kernel denials are not readable from outside the runtime on Linux — see docs/upstream/cli-violations.md"
|
|
438
|
+
? "kernel denials are not readable from outside the runtime on Linux — see https://github.com/carlostapiacl/seisin/blob/main/docs/upstream/cli-violations.md"
|
|
365
439
|
: `no kernel denial stream on ${platform}`,
|
|
366
440
|
stats,
|
|
367
441
|
attributeTo() {},
|
|
@@ -370,9 +444,29 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
|
|
|
370
444
|
|
|
371
445
|
let suffix = null; // learned once, exact from then on
|
|
372
446
|
let tree = null; // refreshed only while still learning
|
|
447
|
+
let treeAt = -Infinity; // when `tree` was taken
|
|
373
448
|
let root = pid; // the srt pid; not known until after spawn
|
|
374
449
|
const mine = new Set(); // pids already proven to be ours
|
|
375
450
|
const held = []; // denials seen before they could be attributed
|
|
451
|
+
const foreignSuffixes = new Set(); // other sandboxes, proven while they were alive
|
|
452
|
+
let recheck = null; // a deferred look, when a snapshot was refused
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* One `ps` per TREE_MS at most, and a later look instead of a skipped one.
|
|
456
|
+
*
|
|
457
|
+
* `ps` is synchronous and it was taken for every denial from another sandbox
|
|
458
|
+
* while this run had not learned its own suffix — which, for a run that is
|
|
459
|
+
* never refused anything, is the whole run. Several cells at once is exactly
|
|
460
|
+
* that: 83 ms of blocked event loop per foreign line, measured. A line whose
|
|
461
|
+
* process may be younger than the last snapshot is not decided on it; it is
|
|
462
|
+
* held, and looked at again once the window has passed.
|
|
463
|
+
*/
|
|
464
|
+
const snapshot = () => { tree = treeFn(); treeAt = now(); };
|
|
465
|
+
const later = () => {
|
|
466
|
+
if (recheck) return;
|
|
467
|
+
recheck = setTimeout(() => { recheck = null; if (!suffix && held.length) revisit(); }, TREE_MS);
|
|
468
|
+
recheck.unref?.();
|
|
469
|
+
};
|
|
376
470
|
|
|
377
471
|
/**
|
|
378
472
|
* Two anchors, and either one is enough.
|
|
@@ -396,12 +490,27 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
|
|
|
396
490
|
}
|
|
397
491
|
if (root === null) return false; // nothing to descend from yet
|
|
398
492
|
if (mine.has(d.pid)) return true;
|
|
399
|
-
|
|
493
|
+
// A suffix already proven foreign needs no `ps`: the suffix is per `srt`.
|
|
494
|
+
if (d.suffix && foreignSuffixes.has(d.suffix)) return false;
|
|
495
|
+
if (tree === null) snapshot();
|
|
400
496
|
if (!descends(d.pid, root, tree)) {
|
|
401
|
-
// A pid
|
|
402
|
-
// Refresh once, then believe the answer
|
|
403
|
-
|
|
404
|
-
|
|
497
|
+
// A pid the snapshot does not have may simply be younger than it.
|
|
498
|
+
// Refresh once, then believe the answer — unless the last refresh was
|
|
499
|
+
// moments ago, and then look again later rather than refresh now. A pid
|
|
500
|
+
// it does have is answered: its parents do not change into ours.
|
|
501
|
+
if (treeAt < d.seen && !tree.has(d.pid)) {
|
|
502
|
+
if (now() - treeAt < TREE_MS) { later(); return LATER; }
|
|
503
|
+
snapshot();
|
|
504
|
+
}
|
|
505
|
+
if (!descends(d.pid, root, tree)) {
|
|
506
|
+
// Alive, not ours by either anchor, and under another run — the tag
|
|
507
|
+
// did not match above. Every later line from that `srt` is someone
|
|
508
|
+
// else's without asking. Alive but under no run at all is an orphan
|
|
509
|
+
// (`cmd &`, a daemon): possibly ours, so its suffix stays undecided
|
|
510
|
+
// rather than turning this run's own later lines into strangers.
|
|
511
|
+
if (d.suffix && tree.has(d.pid) && underAnotherRun(d.pid, root, tree)) foreignSuffixes.add(d.suffix);
|
|
512
|
+
return false;
|
|
513
|
+
}
|
|
405
514
|
}
|
|
406
515
|
mine.add(d.pid);
|
|
407
516
|
// The first proven denial hands us the suffix for every later one.
|
|
@@ -432,7 +541,8 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
|
|
|
432
541
|
};
|
|
433
542
|
|
|
434
543
|
const consider = (d) => {
|
|
435
|
-
|
|
544
|
+
const verdict = attribute(d);
|
|
545
|
+
if (verdict === true) {
|
|
436
546
|
stats.attributed++;
|
|
437
547
|
onDeny(d);
|
|
438
548
|
// Learning the suffix makes every held line decidable. Without this,
|
|
@@ -445,7 +555,9 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
|
|
|
445
555
|
// child whose pid anchors attribution, or the process died before `ps`
|
|
446
556
|
// could see it. Either way a foreign sandbox and one of ours look the same
|
|
447
557
|
// from here, so hold the line rather than credit or discard it.
|
|
448
|
-
|
|
558
|
+
// A line put off to the next snapshot is held with or without a suffix:
|
|
559
|
+
// the look it is waiting for is the only thing that can decide it.
|
|
560
|
+
if (verdict === LATER || (!suffix && d.suffix && !foreignSuffixes.has(d.suffix))) {
|
|
449
561
|
stats.unattributed++;
|
|
450
562
|
held.push(d);
|
|
451
563
|
// Bounded, because the common case on a busy machine is a run that is
|
|
@@ -473,7 +585,13 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
|
|
|
473
585
|
child = spawnFn("/usr/bin/log", ["stream", "--predicate", PREDICATE, "--style", "compact"],
|
|
474
586
|
{ stdio: ["ignore", "pipe", "ignore"] });
|
|
475
587
|
} catch (e) {
|
|
476
|
-
|
|
588
|
+
// The same shape as the unavailable watcher above, for the same reason:
|
|
589
|
+
// `run` calls attributeTo() and awaits close() unconditionally.
|
|
590
|
+
return {
|
|
591
|
+
available: false, reason: `could not start the log stream: ${e.message}`, stats,
|
|
592
|
+
attributeTo() {},
|
|
593
|
+
close: () => Promise.resolve(stats),
|
|
594
|
+
};
|
|
477
595
|
}
|
|
478
596
|
|
|
479
597
|
let buf = "";
|
|
@@ -485,14 +603,20 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
|
|
|
485
603
|
while ((cut = buf.indexOf("_SBX")) !== -1) {
|
|
486
604
|
const chunk = buf.slice(0, cut + 4);
|
|
487
605
|
buf = buf.slice(cut + 4);
|
|
606
|
+
// A marker never contains the tag, so it is whole in one chunk or still
|
|
607
|
+
// in `buf`, which close() counts.
|
|
608
|
+
stats.dropped += droppedIn(chunk);
|
|
488
609
|
const d = parseChunk(chunk);
|
|
610
|
+
if (d) d.seen = now();
|
|
489
611
|
// Deduplicated here and not in consider(), because a held denial goes
|
|
490
612
|
// through consider() a second time when it is finally attributed. Doing
|
|
491
613
|
// this there would drop every line that arrived before the child had a
|
|
492
614
|
// pid — which, on a short command, is all of them.
|
|
493
615
|
if (d && !isRepeat(d)) consider(d);
|
|
494
616
|
}
|
|
495
|
-
|
|
617
|
+
// A stream we cannot parse is not a leak. A flooded one is mostly markers
|
|
618
|
+
// with no tag between them, so they are counted before they go.
|
|
619
|
+
if (buf.length > 64_000) { stats.dropped += droppedIn(buf); buf = ""; }
|
|
496
620
|
});
|
|
497
621
|
// A monitor that cannot run must never take the run down with it. Same rule
|
|
498
622
|
// as append(): the instrument is allowed to fail, the agent is not.
|
|
@@ -528,11 +652,12 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
|
|
|
528
652
|
* nothing pending — costs one tick rather than the full window.
|
|
529
653
|
*/
|
|
530
654
|
close({ drain = 0 } = {}) {
|
|
531
|
-
|
|
532
|
-
|
|
655
|
+
clearTimeout(recheck);
|
|
656
|
+
const stop = () => ended(child).then(() => { stats.dropped += droppedIn(buf); buf = ""; return stats; });
|
|
657
|
+
if (drain <= 0) return stop();
|
|
533
658
|
return new Promise((resolve) => {
|
|
534
659
|
let quiet = null;
|
|
535
|
-
const settle = () => { clearTimeout(quiet); clearTimeout(cap); stop()
|
|
660
|
+
const settle = () => { clearTimeout(quiet); clearTimeout(cap); resolve(stop()); };
|
|
536
661
|
const cap = setTimeout(settle, drain);
|
|
537
662
|
const bump = () => { clearTimeout(quiet); quiet = setTimeout(settle, QUIET_MS); };
|
|
538
663
|
child.stdout?.on("data", bump);
|
|
@@ -542,6 +667,28 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
|
|
|
542
667
|
};
|
|
543
668
|
}
|
|
544
669
|
|
|
670
|
+
/**
|
|
671
|
+
* Stop a `log stream` and wait for what it was still holding.
|
|
672
|
+
*
|
|
673
|
+
* SIGINT, not SIGTERM. Writing to a pipe, `log` buffers its own notices — a
|
|
674
|
+
* denial's line arrives at once, but the "messages dropped" markers were buffered
|
|
675
|
+
* 8 KB at a time and came out together, measured twelve seconds after the flood
|
|
676
|
+
* began. SIGINT makes it flush them before it exits; SIGTERM threw them away
|
|
677
|
+
* (18 markers against 0 in the same flood). Bounded, so a stream that will not
|
|
678
|
+
* exit cannot hold up the end of a run.
|
|
679
|
+
*/
|
|
680
|
+
function ended(child, cap = END_MS) {
|
|
681
|
+
return new Promise((resolve) => {
|
|
682
|
+
if (child.exitCode !== null || child.signalCode !== null) return resolve();
|
|
683
|
+
const timer = setTimeout(() => { try { child.kill("SIGKILL"); } catch {} resolve(); }, cap);
|
|
684
|
+
child.once("close", () => { clearTimeout(timer); resolve(); });
|
|
685
|
+
try { child.kill("SIGINT"); } catch { clearTimeout(timer); resolve(); }
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** How long a stopped `log stream` gets to flush and exit. */
|
|
690
|
+
const END_MS = 1000;
|
|
691
|
+
|
|
545
692
|
/**
|
|
546
693
|
* How long the stream has to stay silent before a drain gives up on it.
|
|
547
694
|
*
|
|
@@ -551,6 +698,18 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
|
|
|
551
698
|
*/
|
|
552
699
|
const QUIET_MS = 60;
|
|
553
700
|
|
|
701
|
+
/**
|
|
702
|
+
* The shortest time between two `ps` snapshots while attribution is learning.
|
|
703
|
+
*
|
|
704
|
+
* Long enough that a burst of foreign denials costs one snapshot, not one
|
|
705
|
+
* each; short enough that a line held for a younger process is decided before
|
|
706
|
+
* anyone reads the log.
|
|
707
|
+
*/
|
|
708
|
+
const TREE_MS = 250;
|
|
709
|
+
|
|
710
|
+
/** What `attribute` answers for a line it has put off to the next snapshot. */
|
|
711
|
+
const LATER = "later";
|
|
712
|
+
|
|
554
713
|
/**
|
|
555
714
|
* How many undecided denials to keep while waiting to learn our own suffix.
|
|
556
715
|
*
|
|
@@ -570,3 +729,68 @@ const HELD_MAX = 500;
|
|
|
570
729
|
* `asked 3×` mean anything.
|
|
571
730
|
*/
|
|
572
731
|
const REPEAT_MS = 1000;
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Is the system log dropping messages right now, and who is writing the most?
|
|
735
|
+
*
|
|
736
|
+
* For `seisin check --system-log`. Two streams for `seconds`: one with the run's
|
|
737
|
+
* own predicate, counting the batches it loses — the number a run would have
|
|
738
|
+
* seen — and one unfiltered, counting messages by sender, because a flood
|
|
739
|
+
* comes from somebody and naming them is the only fix there is. A kernel
|
|
740
|
+
* message is named by its first word, which is the function that wrote it
|
|
741
|
+
* (`cfil_inp_log`): "kernel" alone names nothing.
|
|
742
|
+
*
|
|
743
|
+
* Reads the log and starts no sandbox. Not available off macOS, where denials
|
|
744
|
+
* are not read from a log at all.
|
|
745
|
+
*/
|
|
746
|
+
export function measureSystemLog({ seconds = 3, platform = process.platform, spawnFn = spawn } = {}) {
|
|
747
|
+
if (platform !== "darwin") return Promise.resolve({ available: false, reason: `no system log stream on ${platform}` });
|
|
748
|
+
const streams = [];
|
|
749
|
+
const start = (args) => {
|
|
750
|
+
const child = spawnFn("/usr/bin/log", ["stream", ...args], { stdio: ["ignore", "pipe", "ignore"] });
|
|
751
|
+
child.on("error", () => {});
|
|
752
|
+
streams.push(child);
|
|
753
|
+
return child;
|
|
754
|
+
};
|
|
755
|
+
return new Promise((resolve) => {
|
|
756
|
+
let dropped = 0, lines = 0, tail = "", rest = "";
|
|
757
|
+
const by = new Map();
|
|
758
|
+
let ours, all;
|
|
759
|
+
try {
|
|
760
|
+
ours = start(["--predicate", PREDICATE, "--style", "compact"]);
|
|
761
|
+
all = start(["--style", "ndjson"]);
|
|
762
|
+
} catch (e) {
|
|
763
|
+
for (const c of streams) try { c.kill("SIGTERM"); } catch {}
|
|
764
|
+
return resolve({ available: false, reason: `could not start the log stream: ${e.message}` });
|
|
765
|
+
}
|
|
766
|
+
ours.stdout?.on("data", (d) => {
|
|
767
|
+
const text = tail + d.toString();
|
|
768
|
+
const cut = text.lastIndexOf("\n");
|
|
769
|
+
dropped += droppedIn(text.slice(0, cut + 1));
|
|
770
|
+
tail = text.slice(cut + 1);
|
|
771
|
+
});
|
|
772
|
+
all.stdout?.on("data", (d) => {
|
|
773
|
+
const parts = (rest + d.toString()).split("\n");
|
|
774
|
+
rest = parts.pop();
|
|
775
|
+
for (const line of parts) {
|
|
776
|
+
if (!line.startsWith("{")) continue;
|
|
777
|
+
let e;
|
|
778
|
+
try { e = JSON.parse(line); } catch { continue; }
|
|
779
|
+
lines++;
|
|
780
|
+
const proc = basename(String(e.processImagePath ?? "?"));
|
|
781
|
+
const who = e.subsystem
|
|
782
|
+
? `${proc} · ${e.subsystem}`
|
|
783
|
+
: proc === "kernel" ? `kernel · ${String(e.eventMessage ?? "").split(/[\s:]/)[0] || "?"}` : proc;
|
|
784
|
+
by.set(who, (by.get(who) ?? 0) + 1);
|
|
785
|
+
}
|
|
786
|
+
if (rest.length > 1_000_000) rest = "";
|
|
787
|
+
});
|
|
788
|
+
setTimeout(async () => {
|
|
789
|
+
await Promise.all(streams.map((c) => ended(c)));
|
|
790
|
+
dropped += droppedIn(tail);
|
|
791
|
+
const top = [...by].sort((a, b) => b[1] - a[1]).slice(0, 3)
|
|
792
|
+
.map(([who, n]) => ({ who, perSecond: Math.round(n / seconds) }));
|
|
793
|
+
resolve({ available: true, seconds, dropped, perSecond: Math.round(lines / seconds), top });
|
|
794
|
+
}, seconds * 1000);
|
|
795
|
+
});
|
|
796
|
+
}
|
package/src/walls.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* seisin answers *whose is this* at the moment of the refusal, once, in the
|
|
5
5
|
* middle of a turn, and then the sentence is gone. Nothing carries it forward,
|
|
6
6
|
* so the agent tries again — and the measurement that made this module exist
|
|
7
|
-
* says how often: of 345 denials
|
|
8
|
-
* something that same role had already been
|
|
7
|
+
* says how often: of 345 denials in a multi-repo workspace, **88 (25%) were a repeat of
|
|
8
|
+
* something that same role had already been denied**. One role spent 37 calls
|
|
9
9
|
* on two walls, hitting the same one nineteen times.
|
|
10
10
|
*
|
|
11
11
|
* A block that repeats nineteen times is not a boundary working. It is a
|
|
@@ -31,12 +31,12 @@
|
|
|
31
31
|
* its wall disappear on the next turn instead of two days later.
|
|
32
32
|
*
|
|
33
33
|
* A window is still accepted, and still useful for a different reason: a
|
|
34
|
-
*
|
|
34
|
+
* denial from three weeks ago is noise even if it is still denied today.
|
|
35
35
|
*/
|
|
36
|
-
import {
|
|
37
|
-
import { read } from "./log.js";
|
|
36
|
+
import { read, readEntries } from "./log.js";
|
|
38
37
|
import { explain } from "./owners.js";
|
|
39
38
|
import { STALE_RUNS, RUN_GAP_MS, runsAfter } from "./requests.js";
|
|
39
|
+
import { expectedOf } from "./kinds.js";
|
|
40
40
|
|
|
41
41
|
/** A wall is something you hit more than once. Once is information; twice is a pattern. */
|
|
42
42
|
export const MIN_HITS = 2;
|
|
@@ -49,12 +49,14 @@ export const MIN_HITS = 2;
|
|
|
49
49
|
* different repositories are two walls, and merging them hides which one was
|
|
50
50
|
* hit.
|
|
51
51
|
*/
|
|
52
|
-
export function walls(config, role, { file, entries = null, min = MIN_HITS, since = null, limit = 6, fresh = false } = {}) {
|
|
52
|
+
export function walls(config, role, { file, entries = null, min = MIN_HITS, since = null, limit = 6, fresh = false, ask = explain } = {}) {
|
|
53
53
|
// `entries`: the log already read, for a caller asking about every role at
|
|
54
54
|
// once (the console asks for 32 on a real policy) — one read instead of 32.
|
|
55
|
-
|
|
55
|
+
// Less the caller's own probes (`seisin run --expect-denied`): denied as
|
|
56
|
+
// whoever started the run asked, and not a wall the role keeps hitting.
|
|
57
|
+
const denied = (entries
|
|
56
58
|
? entries.filter((e) => e.role === role && e.verdict === "denied" && (!since || e.at >= since))
|
|
57
|
-
: read(file, { role, verdict: "denied", ...(since ? { since } : {}) });
|
|
59
|
+
: read(file, { role, verdict: "denied", ...(since ? { since } : {}) })).filter((e) => e.expected !== true);
|
|
58
60
|
|
|
59
61
|
const byKey = new Map();
|
|
60
62
|
for (const e of denied) {
|
|
@@ -70,9 +72,14 @@ export function walls(config, role, { file, entries = null, min = MIN_HITS, sinc
|
|
|
70
72
|
if (w.times < min) continue;
|
|
71
73
|
// The policy as it stands, not as it stood. A granted wall is not a wall,
|
|
72
74
|
// and the whole point of saying this to an agent is that it is true now.
|
|
73
|
-
|
|
75
|
+
// `ask` is explain, or a remembered one (views.js) for a caller asking
|
|
76
|
+
// about every role at once.
|
|
77
|
+
const verdict = ask(config, role, w.action, w.target);
|
|
74
78
|
if (verdict.allowed) continue;
|
|
75
|
-
|
|
79
|
+
// Refused correctly and nothing to do about it (kinds.js expectedOf): kept,
|
|
80
|
+
// and marked, so a screen says it apart instead of as a role stuck.
|
|
81
|
+
const expected = expectedOf(w.target);
|
|
82
|
+
out.push({ ...w, reason: verdict.reason, owners: verdict.owners, ...(expected && { expected }) });
|
|
76
83
|
}
|
|
77
84
|
// A wall the role stopped hitting — it ran STALE_RUNS times since, refused
|
|
78
85
|
// elsewhere but not here — is still true and no longer worth saying. Same
|
|
@@ -89,17 +96,18 @@ export function walls(config, role, { file, entries = null, min = MIN_HITS, sinc
|
|
|
89
96
|
/**
|
|
90
97
|
* How much of the log `timesHit` will look at.
|
|
91
98
|
*
|
|
92
|
-
* The log is append-only and
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* call, which is nothing, and 300 ms at 37 MB, which is not, on every
|
|
96
|
-
* of every
|
|
99
|
+
* The log is append-only and a segment only rotates at 8 MiB, so "read the
|
|
100
|
+
* file" is O(everything since the last rotation), on a path that runs inside
|
|
101
|
+
* the hook. Measured on a 372 KB log: 3 ms per
|
|
102
|
+
* call, which is nothing, and 300 ms at 37 MB, which is not, on every denial
|
|
103
|
+
* of every session forever.
|
|
97
104
|
*
|
|
98
105
|
* So it reads the tail. That makes the count **recent repetition** rather than
|
|
99
106
|
* lifetime repetition, and recent is the one the sentence is about anyway: an
|
|
100
107
|
* agent being told "you have hit this three times" means this session, not
|
|
101
108
|
* last month. 4 MB is thousands of entries — far more than a turn produces,
|
|
102
|
-
* and bounded.
|
|
109
|
+
* and bounded. Right after a rotation the tail is short, and so is the count:
|
|
110
|
+
* the current segment only, on purpose.
|
|
103
111
|
*/
|
|
104
112
|
const TAIL_BYTES = 4 * 1024 * 1024;
|
|
105
113
|
|
|
@@ -113,39 +121,56 @@ const TAIL_BYTES = 4 * 1024 * 1024;
|
|
|
113
121
|
* call.
|
|
114
122
|
*/
|
|
115
123
|
export function timesHit(file, role, action, target) {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
124
|
+
return hitsOf(file, role, action, target).total;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The same count, and how much of it is this run's.
|
|
129
|
+
*
|
|
130
|
+
* Denial lines carry no run id; each run's canary does, and comes first. So
|
|
131
|
+
* `inRun` counts from the canary of `run` (SEISIN_RUN_ID) onwards, and is null
|
|
132
|
+
* when that canary is not in the tail — outside `seisin run`, or a run older
|
|
133
|
+
* than the tail. Measured: a fresh run told "denied 5 times now" about its
|
|
134
|
+
* first try read as a counter gone wrong; the five were from earlier runs.
|
|
135
|
+
*/
|
|
136
|
+
export function hitsOf(file, role, action, target, run = null) {
|
|
137
|
+
// The line the tail cut lands in is dropped (readLines checks the byte before
|
|
138
|
+
// the cut, so a cut on a boundary keeps it). Comparing the file's byte size
|
|
139
|
+
// with the text's UTF-16 length used to decide that, and with any multibyte
|
|
140
|
+
// character in the tail it kept the fragment of a line or dropped a whole one.
|
|
141
|
+
const id = run ? String(run).slice(0, 8) : null;
|
|
142
|
+
let total = 0, inRun = null;
|
|
143
|
+
for (const e of readEntries(file, { tail: TAIL_BYTES })) {
|
|
144
|
+
if (id && e.event === "canary" && e.role === role && e.run === id) inRun = 0;
|
|
145
|
+
if (e.role === role && e.verdict === "denied" && e.action === action && e.target === target) {
|
|
146
|
+
total++;
|
|
147
|
+
if (inRun !== null) inRun++;
|
|
127
148
|
}
|
|
128
|
-
} catch {
|
|
129
|
-
return 0; // no log yet is not an error; it is a first turn
|
|
130
|
-
}
|
|
131
|
-
const lines = text.split("\n");
|
|
132
|
-
// The first line of a tail read is usually half an entry. Dropping it costs
|
|
133
|
-
// one count at most and keeps a truncated line from being parsed as if it
|
|
134
|
-
// were whole.
|
|
135
|
-
if (from0(text, file)) lines.shift();
|
|
136
|
-
let n = 0;
|
|
137
|
-
for (const line of lines) {
|
|
138
|
-
if (!line.trim()) continue;
|
|
139
|
-
let e;
|
|
140
|
-
try { e = JSON.parse(line); } catch { continue; }
|
|
141
|
-
if (e.role === role && e.verdict === "denied" && e.action === action && e.target === target) n++;
|
|
142
149
|
}
|
|
143
|
-
return
|
|
150
|
+
return { total, inRun };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The sentence about having been denied this before, or "".
|
|
155
|
+
*
|
|
156
|
+
* `times` and `inRun` include the attempt being answered. Said about this run
|
|
157
|
+
* when the run is known, and earlier runs are named as earlier runs. Silent on
|
|
158
|
+
* a first time anywhere.
|
|
159
|
+
*/
|
|
160
|
+
export function againWords({ times, inRun = null }) {
|
|
161
|
+
if (inRun === null)
|
|
162
|
+
return times > 1 ? ` You have been denied this ${times} times now; it is not going to work on the ${ordinal(times + 1)} try.` : "";
|
|
163
|
+
const earlier = times - inRun;
|
|
164
|
+
if (inRun > 1)
|
|
165
|
+
return ` You have been denied this ${inRun} times in this run` + (earlier > 0 ? ` (and ${earlier} in earlier runs)` : "") +
|
|
166
|
+
`; it is not going to work on the ${ordinal(inRun + 1)} try.`;
|
|
167
|
+
return earlier > 0 ? ` It was denied ${earlier} time(s) in earlier runs too; this run gets the same answer.` : "";
|
|
144
168
|
}
|
|
145
169
|
|
|
146
|
-
/**
|
|
147
|
-
function
|
|
148
|
-
|
|
170
|
+
/** 4 -> "fourth". Past a handful the numeral reads better than the word. */
|
|
171
|
+
export function ordinal(n) {
|
|
172
|
+
const words = ["", "first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth", "ninth", "tenth"];
|
|
173
|
+
return words[n] ?? `${n}th`;
|
|
149
174
|
}
|
|
150
175
|
|
|
151
176
|
/**
|
|
@@ -155,7 +180,9 @@ function from0(text, file) {
|
|
|
155
180
|
* one after that is the cost of not having been able to remember.
|
|
156
181
|
*/
|
|
157
182
|
export function wasted(list) {
|
|
158
|
-
|
|
183
|
+
// An expected refusal is the agent program's own housekeeping, not a call
|
|
184
|
+
// the agent spent retrying.
|
|
185
|
+
return list.reduce((n, w) => n + (w.expected ? 0 : w.times - 1), 0);
|
|
159
186
|
}
|
|
160
187
|
|
|
161
188
|
/**
|
|
@@ -166,6 +193,9 @@ export function wasted(list) {
|
|
|
166
193
|
* whole section — and then the one turn it matters, it is skipped too.
|
|
167
194
|
*/
|
|
168
195
|
export function render(list) {
|
|
196
|
+
// Not the agent's to stop retrying: Claude Code's plugin updater is not a
|
|
197
|
+
// tool call, so telling the agent about it is a line it can do nothing with.
|
|
198
|
+
list = list.filter((w) => !w.expected);
|
|
169
199
|
if (!list.length) return "";
|
|
170
200
|
const lines = [
|
|
171
201
|
"WALLS — you have already been denied these, and the policy still denies them.",
|
package/src/worktree.js
CHANGED
|
@@ -30,9 +30,10 @@
|
|
|
30
30
|
* and no dependency — a tool people install to shrink their attack surface
|
|
31
31
|
* should not shell out to find out where it is.
|
|
32
32
|
*/
|
|
33
|
-
import { statSync, readFileSync, readdirSync
|
|
33
|
+
import { statSync, readFileSync, readdirSync } from "node:fs";
|
|
34
34
|
import { dirname, join, resolve, relative, isAbsolute } from "node:path";
|
|
35
35
|
import { findConfig } from "./config.js";
|
|
36
|
+
import { realOrSelf } from "./grants.js";
|
|
36
37
|
|
|
37
38
|
const GITDIR = /^gitdir:\s*(.+?)\s*$/;
|
|
38
39
|
const WORKTREE = /^(.+)\/\.git\/worktrees\/[^/]+$/;
|
|
@@ -63,18 +64,9 @@ function statOf(p) {
|
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
/** The path with symlinks followed, or the path itself when it is not on disk. */
|
|
67
|
-
function real(p) {
|
|
68
|
-
try {
|
|
69
|
-
return realpathSync(p);
|
|
70
|
-
} catch {
|
|
71
|
-
return p;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
67
|
/** Two spellings of one place — `/tmp` and `/private/tmp` — compare equal. */
|
|
76
68
|
function same(a, b) {
|
|
77
|
-
return
|
|
69
|
+
return realOrSelf(a) === realOrSelf(b);
|
|
78
70
|
}
|
|
79
71
|
|
|
80
72
|
/**
|