seisin 0.5.0 → 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 +385 -129
- package/package.json +13 -5
- package/src/browsers.js +52 -0
- package/src/canary.js +149 -0
- package/src/cli.js +47 -23
- package/src/commands/check.js +154 -3
- package/src/commands/explain.js +2 -5
- package/src/commands/hook.js +7 -3
- package/src/commands/init.js +57 -50
- package/src/commands/requests.js +31 -56
- package/src/commands/review.js +33 -6
- package/src/commands/run.js +332 -110
- package/src/commands/walls.js +39 -7
- package/src/commands/whose.js +2 -1
- package/src/config.js +321 -15
- package/src/controls.js +5 -5
- package/src/diagnose.js +67 -16
- package/src/env.js +3 -0
- package/src/grants.js +107 -2
- package/src/handoff.js +285 -10
- package/src/help.js +39 -19
- package/src/hook.js +59 -30
- package/src/index.d.ts +217 -0
- package/src/index.js +20 -12
- 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 +13 -815
- package/src/intake.js +131 -21
- package/src/keys.js +223 -67
- package/src/kinds.js +42 -0
- package/src/lines.js +26 -0
- package/src/log.js +7 -2
- package/src/mcp.js +28 -43
- package/src/nested.js +12 -3
- package/src/notify.js +53 -12
- package/src/owners.js +165 -3
- package/src/paths.js +17 -1
- package/src/provider-run.js +81 -0
- package/src/redact.js +34 -15
- package/src/render.js +35 -9
- package/src/requests.js +363 -45
- package/src/review.js +21 -9
- package/src/rundir.js +29 -8
- 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 +7 -3
- package/src/serve.js +35 -17
- package/src/spool.js +8 -17
- package/src/srt.js +263 -33
- package/src/surface.js +129 -16
- package/src/territory.js +261 -0
- package/src/trace.js +33 -0
- package/src/uilink.js +15 -6
- package/src/views.js +40 -14
- package/src/violations.js +143 -7
- package/src/walls.js +62 -9
- package/ui/index.html +85 -13
- package/ui/favicon.svg +0 -1
package/src/views.js
CHANGED
|
@@ -9,16 +9,22 @@
|
|
|
9
9
|
import { createHash } from "node:crypto";
|
|
10
10
|
import { readFileSync } from "node:fs";
|
|
11
11
|
import { explain, standingOf } from "./owners.js";
|
|
12
|
-
import { kindsOf, KINDS } from "./kinds.js";
|
|
12
|
+
import { kindsOf, KINDS, expectedOf } from "./kinds.js";
|
|
13
13
|
import { walls } from "./walls.js";
|
|
14
|
-
import {
|
|
14
|
+
import { queueOf } from "./requests.js";
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* An ISO date from what a person or an agent typed, or null for no filter.
|
|
18
|
-
*
|
|
19
|
-
*
|
|
18
|
+
*
|
|
19
|
+
* A date (`2026-10-01`, any ISO time) or a duration back from `now`: `7d`,
|
|
20
|
+
* `12h`, `2w`, `30m` (minutes — a month is not a fixed length). Anything that
|
|
21
|
+
* does not parse is null; the MCP ignores it rather than refusing (a bad filter
|
|
22
|
+
* should show everything, not an error), and the CLI says so instead.
|
|
20
23
|
*/
|
|
21
|
-
|
|
24
|
+
const UNIT_MS = { m: 60_000, h: 3_600_000, d: 86_400_000, w: 7 * 86_400_000 };
|
|
25
|
+
export function parseSince(raw, now = Date.now()) {
|
|
26
|
+
const d = /^\s*(\d+)\s*([mhdw])\s*$/i.exec(String(raw ?? ""));
|
|
27
|
+
if (d) return new Date(now - Number(d[1]) * UNIT_MS[d[2].toLowerCase()]).toISOString();
|
|
22
28
|
const t = raw ? Date.parse(raw) : NaN;
|
|
23
29
|
return Number.isNaN(t) ? null : new Date(t).toISOString();
|
|
24
30
|
}
|
|
@@ -69,7 +75,7 @@ export function verdicts(cfg, { now = Date.now() } = {}) {
|
|
|
69
75
|
* hundred identical rows instead of one sentence. Grouped, the shape of the
|
|
70
76
|
* day is visible at a glance and it is almost never a territory dispute.
|
|
71
77
|
*
|
|
72
|
-
* `
|
|
78
|
+
* `stillDenied` is recomputed against the policy as it stands, not read out
|
|
73
79
|
* of the log — a cause that has been granted since is history, not friction,
|
|
74
80
|
* and leaving it on the page sends somebody to fix what is already fixed.
|
|
75
81
|
*/
|
|
@@ -77,6 +83,9 @@ export function causesOf(cfg, entries, { ask = explain, limit = 12 } = {}) {
|
|
|
77
83
|
const by = new Map();
|
|
78
84
|
for (const e of entries) {
|
|
79
85
|
if (e.verdict !== "denied" || !e.target || !e.action) continue;
|
|
86
|
+
// A probe the caller of the run asked to be denied (`--expect-denied`):
|
|
87
|
+
// in the log, and not a cause of anything.
|
|
88
|
+
if (e.expected === true) continue;
|
|
80
89
|
const k = `${e.action}\u0000${e.target}`;
|
|
81
90
|
const g = by.get(k) ?? { action: e.action, target: e.target, kind: e.kind, times: 0, roles: new Set(), ownersThen: e.owners ?? [] };
|
|
82
91
|
g.times++;
|
|
@@ -176,6 +185,19 @@ export function causesOf(cfg, entries, { ask = explain, limit = 12 } = {}) {
|
|
|
176
185
|
* shows keys, network and tools apart from files: "no role owns this" is a
|
|
177
186
|
* sentence about paths, and said of a port or a tool it is simply wrong.
|
|
178
187
|
*/
|
|
188
|
+
/**
|
|
189
|
+
* Refusals that are expected (kinds.js expectedOf) — Claude Code's plugin
|
|
190
|
+
* updater — counted apart so the page can say them as the boundary working
|
|
191
|
+
* rather than leave them at the top of the list as something to grant.
|
|
192
|
+
*/
|
|
193
|
+
const expected = { paths: 0, denials: 0 };
|
|
194
|
+
for (const g of by.values()) {
|
|
195
|
+
g.expected = expectedOf(g.target);
|
|
196
|
+
if (!g.expected) continue;
|
|
197
|
+
expected.paths++;
|
|
198
|
+
expected.denials += g.times;
|
|
199
|
+
}
|
|
200
|
+
|
|
179
201
|
const about = Object.fromEntries(ABOUT.map((k) => [k, { paths: 0, denials: 0 }]));
|
|
180
202
|
for (const g of by.values()) {
|
|
181
203
|
const a = aboutOf(g.kind);
|
|
@@ -188,6 +210,7 @@ export function causesOf(cfg, entries, { ask = explain, limit = 12 } = {}) {
|
|
|
188
210
|
unowned: piles.unowned.paths,
|
|
189
211
|
about,
|
|
190
212
|
standing: piles,
|
|
213
|
+
expected,
|
|
191
214
|
// The real number of distinct causes, not the length of the list below.
|
|
192
215
|
// The page says "over N distinct paths" and the list is capped at twelve,
|
|
193
216
|
// so taking N from the list reported the cap as if it were the count —
|
|
@@ -208,19 +231,20 @@ export function causesOf(cfg, entries, { ask = explain, limit = 12 } = {}) {
|
|
|
208
231
|
...(g.standing.why && { why: g.standing.why }),
|
|
209
232
|
// Only on an unowned cause: what kind of thing it is, and what to do.
|
|
210
233
|
...(g.nature && { kind: g.nature.kind, hint: g.nature.hint }),
|
|
234
|
+
...(g.expected && { expected: g.expected }),
|
|
211
235
|
owners: g.standing.owners,
|
|
212
236
|
ownersThen: g.ownersThen,
|
|
213
237
|
// How many of the roles that hit this would still hit it. `some` and
|
|
214
238
|
// not `every`: two roles out of three still blocked is still friction,
|
|
215
239
|
// and requiring all of them would quietly retire a live cause the day
|
|
216
240
|
// one role got a grant.
|
|
217
|
-
|
|
241
|
+
stillDenied: [...g.roles].filter((r) => cfg.roles[r] && !ask(cfg, r, g.action, g.target).allowed).length,
|
|
218
242
|
})),
|
|
219
243
|
};
|
|
220
244
|
}
|
|
221
245
|
|
|
222
246
|
/** What a log line was about. Lines from before the field existed are files. */
|
|
223
|
-
const ABOUT = ["file", "key", "network", "tool"];
|
|
247
|
+
const ABOUT = ["file", "key", "network", "system", "tool"];
|
|
224
248
|
function aboutOf(kind) {
|
|
225
249
|
return ABOUT.includes(kind) ? kind : "file";
|
|
226
250
|
}
|
|
@@ -245,7 +269,8 @@ export function describeWalls(cfg, byRole, entries) {
|
|
|
245
269
|
for (const [role, list] of Object.entries(byRole)) {
|
|
246
270
|
out[role] = list.map((w) => {
|
|
247
271
|
const about = aboutOf(kindOfLine.get(`${role}\u0000${w.action}\u0000${w.target}`));
|
|
248
|
-
|
|
272
|
+
// An expected wall (walls.js) is not the agent retrying anything.
|
|
273
|
+
const x = { ...w, about, retried: w.expected ? 0 : w.times - 1 };
|
|
249
274
|
// Every file wall, owned or not: a protected path can also lie inside
|
|
250
275
|
// someone's territory, and "owned" must not hide "never granted". The
|
|
251
276
|
// owners are today's, as Denied's are.
|
|
@@ -280,10 +305,11 @@ export function wallsByRole(cfg, entries, { ask } = {}) {
|
|
|
280
305
|
}
|
|
281
306
|
|
|
282
307
|
/**
|
|
283
|
-
* The request queue as a person should read it: pending, with
|
|
284
|
-
* the role has stopped asking for marked. `history`
|
|
285
|
-
* verdict — a run that was only allowed things is
|
|
308
|
+
* The request queue as a person should read it: pending, each with its handoff
|
|
309
|
+
* decided, and the requests the role has stopped asking for marked. `history`
|
|
310
|
+
* is the whole log, every verdict — a run that was only allowed things is
|
|
311
|
+
* still a run.
|
|
286
312
|
*/
|
|
287
|
-
export function queue(
|
|
288
|
-
return
|
|
313
|
+
export function queue(cfg, history, { readOnly = false } = {}) {
|
|
314
|
+
return queueOf(cfg, history, { readOnly });
|
|
289
315
|
}
|
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
|
*
|
|
@@ -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;
|
|
@@ -367,7 +411,7 @@ export function scopeOf(settings, root) {
|
|
|
367
411
|
* logging less than it used to.
|
|
368
412
|
*/
|
|
369
413
|
export function watchDenials(onDeny, { pid = null, argv = null, platform = process.platform, spawnFn = spawn, treeFn = processTree, now = () => performance.now() } = {}) {
|
|
370
|
-
const stats = { attributed: 0, foreign: 0, unattributed: 0 };
|
|
414
|
+
const stats = { attributed: 0, foreign: 0, unattributed: 0, dropped: 0 };
|
|
371
415
|
|
|
372
416
|
/**
|
|
373
417
|
* Unavailable is a watcher that does nothing, NOT a different shape.
|
|
@@ -391,7 +435,7 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
|
|
|
391
435
|
return {
|
|
392
436
|
available: false,
|
|
393
437
|
reason: platform === "linux"
|
|
394
|
-
? "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"
|
|
395
439
|
: `no kernel denial stream on ${platform}`,
|
|
396
440
|
stats,
|
|
397
441
|
attributeTo() {},
|
|
@@ -559,6 +603,9 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
|
|
|
559
603
|
while ((cut = buf.indexOf("_SBX")) !== -1) {
|
|
560
604
|
const chunk = buf.slice(0, cut + 4);
|
|
561
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);
|
|
562
609
|
const d = parseChunk(chunk);
|
|
563
610
|
if (d) d.seen = now();
|
|
564
611
|
// Deduplicated here and not in consider(), because a held denial goes
|
|
@@ -567,7 +614,9 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
|
|
|
567
614
|
// pid — which, on a short command, is all of them.
|
|
568
615
|
if (d && !isRepeat(d)) consider(d);
|
|
569
616
|
}
|
|
570
|
-
|
|
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 = ""; }
|
|
571
620
|
});
|
|
572
621
|
// A monitor that cannot run must never take the run down with it. Same rule
|
|
573
622
|
// as append(): the instrument is allowed to fail, the agent is not.
|
|
@@ -604,11 +653,11 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
|
|
|
604
653
|
*/
|
|
605
654
|
close({ drain = 0 } = {}) {
|
|
606
655
|
clearTimeout(recheck);
|
|
607
|
-
const stop = () =>
|
|
608
|
-
if (drain <= 0)
|
|
656
|
+
const stop = () => ended(child).then(() => { stats.dropped += droppedIn(buf); buf = ""; return stats; });
|
|
657
|
+
if (drain <= 0) return stop();
|
|
609
658
|
return new Promise((resolve) => {
|
|
610
659
|
let quiet = null;
|
|
611
|
-
const settle = () => { clearTimeout(quiet); clearTimeout(cap); stop()
|
|
660
|
+
const settle = () => { clearTimeout(quiet); clearTimeout(cap); resolve(stop()); };
|
|
612
661
|
const cap = setTimeout(settle, drain);
|
|
613
662
|
const bump = () => { clearTimeout(quiet); quiet = setTimeout(settle, QUIET_MS); };
|
|
614
663
|
child.stdout?.on("data", bump);
|
|
@@ -618,6 +667,28 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
|
|
|
618
667
|
};
|
|
619
668
|
}
|
|
620
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
|
+
|
|
621
692
|
/**
|
|
622
693
|
* How long the stream has to stay silent before a drain gives up on it.
|
|
623
694
|
*
|
|
@@ -658,3 +729,68 @@ const HELD_MAX = 500;
|
|
|
658
729
|
* `asked 3×` mean anything.
|
|
659
730
|
*/
|
|
660
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
|
@@ -31,11 +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
36
|
import { read, readEntries } from "./log.js";
|
|
37
37
|
import { explain } from "./owners.js";
|
|
38
38
|
import { STALE_RUNS, RUN_GAP_MS, runsAfter } from "./requests.js";
|
|
39
|
+
import { expectedOf } from "./kinds.js";
|
|
39
40
|
|
|
40
41
|
/** A wall is something you hit more than once. Once is information; twice is a pattern. */
|
|
41
42
|
export const MIN_HITS = 2;
|
|
@@ -51,9 +52,11 @@ export const MIN_HITS = 2;
|
|
|
51
52
|
export function walls(config, role, { file, entries = null, min = MIN_HITS, since = null, limit = 6, fresh = false, ask = explain } = {}) {
|
|
52
53
|
// `entries`: the log already read, for a caller asking about every role at
|
|
53
54
|
// once (the console asks for 32 on a real policy) — one read instead of 32.
|
|
54
|
-
|
|
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
|
|
55
58
|
? entries.filter((e) => e.role === role && e.verdict === "denied" && (!since || e.at >= since))
|
|
56
|
-
: read(file, { role, verdict: "denied", ...(since ? { since } : {}) });
|
|
59
|
+
: read(file, { role, verdict: "denied", ...(since ? { since } : {}) })).filter((e) => e.expected !== true);
|
|
57
60
|
|
|
58
61
|
const byKey = new Map();
|
|
59
62
|
for (const e of denied) {
|
|
@@ -73,7 +76,10 @@ export function walls(config, role, { file, entries = null, min = MIN_HITS, sinc
|
|
|
73
76
|
// about every role at once.
|
|
74
77
|
const verdict = ask(config, role, w.action, w.target);
|
|
75
78
|
if (verdict.allowed) continue;
|
|
76
|
-
|
|
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 }) });
|
|
77
83
|
}
|
|
78
84
|
// A wall the role stopped hitting — it ran STALE_RUNS times since, refused
|
|
79
85
|
// elsewhere but not here — is still true and no longer worth saying. Same
|
|
@@ -115,14 +121,56 @@ const TAIL_BYTES = 4 * 1024 * 1024;
|
|
|
115
121
|
* call.
|
|
116
122
|
*/
|
|
117
123
|
export function timesHit(file, role, action, target) {
|
|
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) {
|
|
118
137
|
// The line the tail cut lands in is dropped (readLines checks the byte before
|
|
119
138
|
// the cut, so a cut on a boundary keeps it). Comparing the file's byte size
|
|
120
139
|
// with the text's UTF-16 length used to decide that, and with any multibyte
|
|
121
140
|
// character in the tail it kept the fragment of a line or dropped a whole one.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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++;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
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.` : "";
|
|
168
|
+
}
|
|
169
|
+
|
|
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`;
|
|
126
174
|
}
|
|
127
175
|
|
|
128
176
|
/**
|
|
@@ -132,7 +180,9 @@ export function timesHit(file, role, action, target) {
|
|
|
132
180
|
* one after that is the cost of not having been able to remember.
|
|
133
181
|
*/
|
|
134
182
|
export function wasted(list) {
|
|
135
|
-
|
|
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);
|
|
136
186
|
}
|
|
137
187
|
|
|
138
188
|
/**
|
|
@@ -143,6 +193,9 @@ export function wasted(list) {
|
|
|
143
193
|
* whole section — and then the one turn it matters, it is skipped too.
|
|
144
194
|
*/
|
|
145
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);
|
|
146
199
|
if (!list.length) return "";
|
|
147
200
|
const lines = [
|
|
148
201
|
"WALLS — you have already been denied these, and the policy still denies them.",
|