seisin 0.6.4 → 0.7.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 +158 -694
- package/package.json +4 -4
- package/src/canary.js +1 -1
- package/src/cli.js +5 -5
- package/src/commands/check.js +21 -13
- package/src/commands/log.js +21 -14
- package/src/commands/requests.js +10 -15
- package/src/commands/review.js +2 -1
- package/src/commands/run.js +275 -248
- package/src/commands/walls.js +12 -5
- package/src/config-fields.js +305 -0
- package/src/config.js +14 -551
- package/src/controls.js +1 -1
- package/src/decide-request.js +37 -0
- package/src/denial-attribution.js +338 -0
- package/src/grants.js +1 -1
- package/src/index.js +1 -1
- package/src/inspect/enforce.js +1 -1
- package/src/inspect/files.js +1 -1
- package/src/inspect/home.js +1 -1
- package/src/inspect/inject.js +6 -4
- package/src/intake.js +3 -2
- package/src/keys.js +6 -4
- package/src/layout.js +1 -1
- package/src/mcp.js +1 -1
- package/src/notify-config.js +58 -0
- package/src/notify.js +4 -52
- package/src/owners.js +2 -2
- package/src/rundir.js +2 -2
- package/src/sandboxes/command.js +0 -2
- package/src/sandboxes/index.js +26 -9
- package/src/sandboxes/srt.js +27 -7
- package/src/sandboxes/tartaros.js +8 -6
- package/src/serve.js +11 -22
- package/src/spool.js +1 -1
- package/src/{srt.js → srt-settings.js} +1 -1
- package/src/suggest.js +26 -1
- package/src/territory.js +12 -2
- package/src/toml.js +250 -0
- package/src/violations.js +16 -315
- package/src/walls.js +4 -2
- package/ui/index.html +27 -5
package/src/violations.js
CHANGED
|
@@ -42,9 +42,13 @@
|
|
|
42
42
|
* per run is a line nobody can act on — and `seisin check` says it, with the
|
|
43
43
|
* platform's other standing limits.
|
|
44
44
|
*/
|
|
45
|
-
import { spawn
|
|
45
|
+
import { spawn } from "node:child_process";
|
|
46
46
|
import { statSync } from "node:fs";
|
|
47
47
|
import { basename } from "node:path";
|
|
48
|
+
import { createDenialAttribution, processTree, TREE_MS } from "./denial-attribution.js";
|
|
49
|
+
|
|
50
|
+
// Keep the existing helper imports available to callers and tests.
|
|
51
|
+
export { shellSplit, isOurs, descends, processTree, underAnotherRun } from "./denial-attribution.js";
|
|
48
52
|
|
|
49
53
|
/** Only sandbox-runtime tags its violations this way. Everything else on the
|
|
50
54
|
* machine — Safari, mDNSResponder, Spotlight — is filtered out by the OS
|
|
@@ -197,134 +201,6 @@ function decode(b64) {
|
|
|
197
201
|
}
|
|
198
202
|
}
|
|
199
203
|
|
|
200
|
-
/**
|
|
201
|
-
* Split a POSIX shell word list, the way a shell would.
|
|
202
|
-
*
|
|
203
|
-
* This reads the runtime's attribution tag, which carries the wrapped command
|
|
204
|
-
* re-quoted for `sh -c` and truncated to 100 characters. Parsing it beats
|
|
205
|
-
* re-generating it: any correct quoting of the same argument list parses back to
|
|
206
|
-
* that list, so this keeps matching if the runtime changes how it quotes, which
|
|
207
|
-
* re-implementing its `quote()` would not.
|
|
208
|
-
*
|
|
209
|
-
* Truncation means the last word is usually a fragment and the closing quote is
|
|
210
|
-
* usually missing. That is expected, not an error — an unterminated word is
|
|
211
|
-
* returned as far as it got, and {@link isOurs} treats the final word as a
|
|
212
|
-
* prefix.
|
|
213
|
-
*/
|
|
214
|
-
export function shellSplit(line) {
|
|
215
|
-
const words = [];
|
|
216
|
-
let word = null;
|
|
217
|
-
let quote = null;
|
|
218
|
-
for (let i = 0; i < line.length; i++) {
|
|
219
|
-
const c = line[i];
|
|
220
|
-
if (quote) {
|
|
221
|
-
if (c === quote) quote = null;
|
|
222
|
-
else word += c;
|
|
223
|
-
continue;
|
|
224
|
-
}
|
|
225
|
-
if (c === "'" || c === '"') { quote = c; word ??= ""; continue; }
|
|
226
|
-
if (c === "\\" && i + 1 < line.length) { word = (word ?? "") + line[++i]; continue; }
|
|
227
|
-
if (/\s/.test(c)) { if (word !== null) { words.push(word); word = null; } continue; }
|
|
228
|
-
word = (word ?? "") + c;
|
|
229
|
-
}
|
|
230
|
-
if (word !== null) words.push(word);
|
|
231
|
-
return words;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Was this tagged command the one this run launched?
|
|
236
|
-
*
|
|
237
|
-
* The second of two anchors, and it exists because the first one is racy: a
|
|
238
|
-
* refused `sh -c` can be dead before `ps` runs, and then a process tree proves
|
|
239
|
-
* nothing. This one does not depend on the process still existing.
|
|
240
|
-
*
|
|
241
|
-
* Compared as a *prefix*, because the tag holds only the first 100 characters:
|
|
242
|
-
* every complete word must match exactly, and the last word — the one the
|
|
243
|
-
* truncation cut — only has to start the same way. A command that agrees with
|
|
244
|
-
* ours for 100 characters and then differs would be credited to us, which is a
|
|
245
|
-
* wrong attribution we accept: it requires another sandbox on the same machine
|
|
246
|
-
* launching a command that matches ours to the byte for 100 bytes.
|
|
247
|
-
*/
|
|
248
|
-
export function isOurs(tagged, argv) {
|
|
249
|
-
if (!tagged) return false;
|
|
250
|
-
const words = shellSplit(tagged);
|
|
251
|
-
if (words.length === 0 || words.length > argv.length) return false;
|
|
252
|
-
for (let i = 0; i < words.length - 1; i++) if (words[i] !== argv[i]) return false;
|
|
253
|
-
const last = words[words.length - 1];
|
|
254
|
-
return argv[words.length - 1]?.startsWith(last) ?? false;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
* Is `pid` a descendant of `root`?
|
|
259
|
-
*
|
|
260
|
-
* This is the anchor for attribution, and it is the reason a denial from
|
|
261
|
-
* somebody else's sandbox never lands in this repo's log. The alternative was
|
|
262
|
-
* matching the decoded command against the one we launched, which would mean
|
|
263
|
-
* reimplementing the runtime's shell quoting and inheriting its bugs; a process
|
|
264
|
-
* tree is a fact the OS already holds.
|
|
265
|
-
*
|
|
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`
|
|
268
|
-
* process. In practice that is one `ps` per run.
|
|
269
|
-
*/
|
|
270
|
-
export function descends(pid, root, snapshot) {
|
|
271
|
-
const parent = new Map(snapshot);
|
|
272
|
-
const seen = new Set();
|
|
273
|
-
let at = pid;
|
|
274
|
-
while (at && at !== 1 && !seen.has(at)) {
|
|
275
|
-
if (at === root) return true;
|
|
276
|
-
seen.add(at);
|
|
277
|
-
at = parent.get(at);
|
|
278
|
-
}
|
|
279
|
-
return false;
|
|
280
|
-
}
|
|
281
|
-
|
|
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
|
-
*/
|
|
291
|
-
export function processTree(run = execFileSync) {
|
|
292
|
-
const tree = new Map();
|
|
293
|
-
tree.roots = new Set();
|
|
294
|
-
try {
|
|
295
|
-
const out = run("/bin/ps", ["-Ao", "pid=,ppid=,args="], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
296
|
-
for (const line of out.split("\n")) {
|
|
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]));
|
|
301
|
-
}
|
|
302
|
-
} catch {
|
|
303
|
-
// No tree means nothing can be attributed, which the caller handles by
|
|
304
|
-
// recording nothing. A permission tool does not invent an owner.
|
|
305
|
-
}
|
|
306
|
-
return tree;
|
|
307
|
-
}
|
|
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
|
-
|
|
328
204
|
/**
|
|
329
205
|
* Did this refusal reach for content, or just walk past the door?
|
|
330
206
|
*
|
|
@@ -442,143 +318,15 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
|
|
|
442
318
|
close: () => Promise.resolve(stats),
|
|
443
319
|
};
|
|
444
320
|
|
|
445
|
-
let suffix = null; // learned once, exact from then on
|
|
446
|
-
let tree = null; // refreshed only while still learning
|
|
447
|
-
let treeAt = -Infinity; // when `tree` was taken
|
|
448
|
-
let root = pid; // the srt pid; not known until after spawn
|
|
449
|
-
const mine = new Set(); // pids already proven to be ours
|
|
450
|
-
const held = []; // denials seen before they could be attributed
|
|
451
|
-
const foreignSuffixes = new Set(); // other sandboxes, proven while they were alive
|
|
452
321
|
let recheck = null; // a deferred look, when a snapshot was refused
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
};
|
|
470
|
-
|
|
471
|
-
/**
|
|
472
|
-
* Two anchors, and either one is enough.
|
|
473
|
-
*
|
|
474
|
-
* Neither works alone. The process tree is exact while the process exists and
|
|
475
|
-
* proves nothing once it has exited — which, for the short commands that get
|
|
476
|
-
* refused most, is by the time the line arrives. The command tag survives the
|
|
477
|
-
* process but depends on the runtime's quoting staying recognisable.
|
|
478
|
-
*
|
|
479
|
-
* Whichever answers first hands over the session suffix, and from that point
|
|
480
|
-
* attribution is one string comparison: the suffix is generated once per `srt`
|
|
481
|
-
* process, so it separates this run from every other sandbox on the machine
|
|
482
|
-
* exactly. In practice the tag settles it on the first denial and the tree is
|
|
483
|
-
* never consulted.
|
|
484
|
-
*/
|
|
485
|
-
const attribute = (d) => {
|
|
486
|
-
if (suffix) return d.suffix === suffix;
|
|
487
|
-
if (argv && isOurs(d.command, argv)) {
|
|
488
|
-
if (d.suffix) suffix = d.suffix;
|
|
489
|
-
return true;
|
|
490
|
-
}
|
|
491
|
-
if (root === null) return false; // nothing to descend from yet
|
|
492
|
-
if (mine.has(d.pid)) return true;
|
|
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();
|
|
496
|
-
if (!descends(d.pid, root, tree)) {
|
|
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
|
-
}
|
|
514
|
-
}
|
|
515
|
-
mine.add(d.pid);
|
|
516
|
-
// The first proven denial hands us the suffix for every later one.
|
|
517
|
-
if (d.suffix) suffix = d.suffix;
|
|
518
|
-
return true;
|
|
519
|
-
};
|
|
520
|
-
|
|
521
|
-
/**
|
|
522
|
-
* The same refusal, reported twice.
|
|
523
|
-
*
|
|
524
|
-
* macOS emits a violation from `kernel` and, for some of them, a second
|
|
525
|
-
* extended report from `sandboxd` carrying the identical `Sandbox:` line.
|
|
526
|
-
* They are one event and the log has to say so — `review` counts lines, and a
|
|
527
|
-
* denial that shows as two is a policy that looks twice as wrong as it is.
|
|
528
|
-
* Measured on the first real run: 31 doubled events out of 730.
|
|
529
|
-
*
|
|
530
|
-
* Keyed on what the kernel said, not on when we read it: two deliveries of one
|
|
531
|
-
* event agree on pid, operation and path and disagree on arrival time.
|
|
532
|
-
*/
|
|
533
|
-
const recent = new Map();
|
|
534
|
-
const isRepeat = (d) => {
|
|
535
|
-
const key = `${d.pid}|${d.operation}|${d.path}`;
|
|
536
|
-
const now = Date.now();
|
|
537
|
-
for (const [k, t] of recent) if (now - t > REPEAT_MS) recent.delete(k); else break;
|
|
538
|
-
if (recent.has(key)) return true;
|
|
539
|
-
recent.set(key, now);
|
|
540
|
-
return false;
|
|
541
|
-
};
|
|
542
|
-
|
|
543
|
-
const consider = (d) => {
|
|
544
|
-
const verdict = attribute(d);
|
|
545
|
-
if (verdict === true) {
|
|
546
|
-
stats.attributed++;
|
|
547
|
-
onDeny(d);
|
|
548
|
-
// Learning the suffix makes every held line decidable. Without this,
|
|
549
|
-
// denials that arrived while the first process was being identified —
|
|
550
|
-
// which on a fast command is all of them — are lost.
|
|
551
|
-
if (suffix && held.length) revisit();
|
|
552
|
-
return;
|
|
553
|
-
}
|
|
554
|
-
// Not ours *yet*. Two reasons, both temporary: the run has not spawned the
|
|
555
|
-
// child whose pid anchors attribution, or the process died before `ps`
|
|
556
|
-
// could see it. Either way a foreign sandbox and one of ours look the same
|
|
557
|
-
// from here, so hold the line rather than credit or discard it.
|
|
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))) {
|
|
561
|
-
stats.unattributed++;
|
|
562
|
-
held.push(d);
|
|
563
|
-
// Bounded, because the common case on a busy machine is a run that is
|
|
564
|
-
// never refused anything: it never learns its own suffix, so every
|
|
565
|
-
// denial from every other sandbox lands here and nothing ever drains it.
|
|
566
|
-
// Several cells running at once for hours is exactly that shape, and an
|
|
567
|
-
// unbounded array is how an instrument becomes the leak.
|
|
568
|
-
//
|
|
569
|
-
// Dropping the oldest is the right end to drop. What is still worth
|
|
570
|
-
// replaying is whatever arrived near the run's own first refusal; a
|
|
571
|
-
// denial from an hour ago that is still unattributed is not ours.
|
|
572
|
-
while (held.length > HELD_MAX) { held.shift(); stats.unattributed--; stats.foreign++; }
|
|
573
|
-
return;
|
|
574
|
-
}
|
|
575
|
-
stats.foreign++;
|
|
576
|
-
};
|
|
577
|
-
|
|
578
|
-
/** Re-examine what was held, now that something new is known. */
|
|
579
|
-
const revisit = () => {
|
|
580
|
-
for (const h of held.splice(0)) { stats.unattributed--; consider(h); }
|
|
581
|
-
};
|
|
322
|
+
const attribution = createDenialAttribution(onDeny, {
|
|
323
|
+
pid, argv, treeFn, now, stats,
|
|
324
|
+
requestRecheck() {
|
|
325
|
+
if (recheck) return;
|
|
326
|
+
recheck = setTimeout(() => { recheck = null; attribution.revisitPending(); }, TREE_MS);
|
|
327
|
+
recheck.unref?.();
|
|
328
|
+
},
|
|
329
|
+
});
|
|
582
330
|
|
|
583
331
|
let child;
|
|
584
332
|
try {
|
|
@@ -607,12 +355,7 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
|
|
|
607
355
|
// in `buf`, which close() counts.
|
|
608
356
|
stats.dropped += droppedIn(chunk);
|
|
609
357
|
const d = parseChunk(chunk);
|
|
610
|
-
if (d)
|
|
611
|
-
// Deduplicated here and not in consider(), because a held denial goes
|
|
612
|
-
// through consider() a second time when it is finally attributed. Doing
|
|
613
|
-
// this there would drop every line that arrived before the child had a
|
|
614
|
-
// pid — which, on a short command, is all of them.
|
|
615
|
-
if (d && !isRepeat(d)) consider(d);
|
|
358
|
+
if (d) attribution.accept(d);
|
|
616
359
|
}
|
|
617
360
|
// A stream we cannot parse is not a leak. A flooded one is mostly markers
|
|
618
361
|
// with no tag between them, so they are counted before they go.
|
|
@@ -627,19 +370,8 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
|
|
|
627
370
|
reason: null,
|
|
628
371
|
stats,
|
|
629
372
|
|
|
630
|
-
/**
|
|
631
|
-
* Name the process whose descendants count as ours.
|
|
632
|
-
*
|
|
633
|
-
* Separate from the constructor because the stream has to be listening
|
|
634
|
-
* *before* the child starts. A short command — the kind an agent runs
|
|
635
|
-
* hundreds of times — is refused and gone in single-digit milliseconds, so
|
|
636
|
-
* a monitor started after the spawn misses precisely the denials it exists
|
|
637
|
-
* to catch. Measured: with the stream started afterwards, a one-line `sh -c`
|
|
638
|
-
* refusal recorded nothing at all.
|
|
639
|
-
*/
|
|
640
373
|
attributeTo(childPid) {
|
|
641
|
-
|
|
642
|
-
if (held.length) revisit();
|
|
374
|
+
attribution.attributeTo(childPid);
|
|
643
375
|
},
|
|
644
376
|
|
|
645
377
|
/**
|
|
@@ -652,6 +384,7 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
|
|
|
652
384
|
* nothing pending — costs one tick rather than the full window.
|
|
653
385
|
*/
|
|
654
386
|
close({ drain = 0 } = {}) {
|
|
387
|
+
// Cancel before draining without resetting the handle: preserve replay scheduling.
|
|
655
388
|
clearTimeout(recheck);
|
|
656
389
|
const stop = () => ended(child).then(() => { stats.dropped += droppedIn(buf); buf = ""; return stats; });
|
|
657
390
|
if (drain <= 0) return stop();
|
|
@@ -698,38 +431,6 @@ const END_MS = 1000;
|
|
|
698
431
|
*/
|
|
699
432
|
const QUIET_MS = 60;
|
|
700
433
|
|
|
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
|
-
|
|
713
|
-
/**
|
|
714
|
-
* How many undecided denials to keep while waiting to learn our own suffix.
|
|
715
|
-
*
|
|
716
|
-
* Generous enough to cover the startup window — where every denial arrives
|
|
717
|
-
* before there is anything to attribute it to — and small enough that a run
|
|
718
|
-
* which is never refused anything cannot accumulate another sandbox's traffic
|
|
719
|
-
* for the length of an agent session.
|
|
720
|
-
*/
|
|
721
|
-
const HELD_MAX = 500;
|
|
722
|
-
|
|
723
|
-
/**
|
|
724
|
-
* How long two reports of one refusal can be apart and still be one event.
|
|
725
|
-
*
|
|
726
|
-
* The second copy — `sandboxd`'s extended report — follows the kernel's within
|
|
727
|
-
* milliseconds. Kept short so that a role genuinely retrying the same path a
|
|
728
|
-
* second later is still recorded as a second attempt, which is what makes
|
|
729
|
-
* `asked 3×` mean anything.
|
|
730
|
-
*/
|
|
731
|
-
const REPEAT_MS = 1000;
|
|
732
|
-
|
|
733
434
|
/**
|
|
734
435
|
* Is the system log dropping messages right now, and who is writing the most?
|
|
735
436
|
*
|
package/src/walls.js
CHANGED
|
@@ -138,10 +138,12 @@ export function hitsOf(file, role, action, target, run = null) {
|
|
|
138
138
|
// the cut, so a cut on a boundary keeps it). Comparing the file's byte size
|
|
139
139
|
// with the text's UTF-16 length used to decide that, and with any multibyte
|
|
140
140
|
// character in the tail it kept the fragment of a line or dropped a whole one.
|
|
141
|
-
|
|
141
|
+
// The canary carries the run's label: the launcher's --run-id whole, or the
|
|
142
|
+
// first 8 characters of seisin's own id (older runs exported the whole id).
|
|
143
|
+
const id = run ? String(run) : null;
|
|
142
144
|
let total = 0, inRun = null;
|
|
143
145
|
for (const e of readEntries(file, { tail: TAIL_BYTES })) {
|
|
144
|
-
if (id && e.event === "canary" && e.role === role && e.run === id) inRun = 0;
|
|
146
|
+
if (id && e.event === "canary" && e.role === role && (e.run === id || e.run === id.slice(0, 8))) inRun = 0;
|
|
145
147
|
if (e.role === role && e.verdict === "denied" && e.action === action && e.target === target) {
|
|
146
148
|
total++;
|
|
147
149
|
if (inRun !== null) inRun++;
|
package/ui/index.html
CHANGED
|
@@ -2088,9 +2088,9 @@ table.runs tr.sys td{color:var(--dim)}
|
|
|
2088
2088
|
* that was gone or had a new token. After two failed polls in a row the page
|
|
2089
2089
|
* says which of the two it is, since when, and turns the writes off. */
|
|
2090
2090
|
var FAILS = 0, OFFLINE = null;
|
|
2091
|
-
function setOffline(kind){
|
|
2092
|
-
if (kind && OFFLINE && OFFLINE.kind === kind) return;
|
|
2093
|
-
OFFLINE = kind ? { kind: kind, since: (OFFLINE && OFFLINE.since) || new Date() } : null;
|
|
2091
|
+
function setOffline(kind, why){
|
|
2092
|
+
if (kind && OFFLINE && OFFLINE.kind === kind && OFFLINE.why === why) return;
|
|
2093
|
+
OFFLINE = kind ? { kind: kind, why: why || null, since: (OFFLINE && OFFLINE.since) || new Date() } : null;
|
|
2094
2094
|
var label = document.getElementById("source");
|
|
2095
2095
|
var ban = document.getElementById("off-banner");
|
|
2096
2096
|
if (!OFFLINE) {
|
|
@@ -2099,7 +2099,11 @@ table.runs tr.sys td{color:var(--dim)}
|
|
|
2099
2099
|
} else {
|
|
2100
2100
|
var t = pad(OFFLINE.since.getHours()) + ":" + pad(OFFLINE.since.getMinutes());
|
|
2101
2101
|
label.style.color = "var(--amber)";
|
|
2102
|
-
if (kind === "
|
|
2102
|
+
if (kind === "policy") {
|
|
2103
|
+
label.textContent = "seisin.toml cannot be read";
|
|
2104
|
+
ban.innerHTML = "<b>seisin.toml cannot be read</b> (since " + t + "): " + esc(OFFLINE.why) + " " +
|
|
2105
|
+
"What is on screen is the last policy that loaded. Decisions and saves are off until the file is fixed.";
|
|
2106
|
+
} else if (kind === "expired") {
|
|
2103
2107
|
label.textContent = "link expired — open the new one";
|
|
2104
2108
|
ban.innerHTML = "<b>This tab's link has expired</b> (since " + t + "): the console was restarted with a new one. " +
|
|
2105
2109
|
"Open the new link — run <code>seisin ui --link</code> in the repository. Decisions and saves are off in this tab.";
|
|
@@ -2184,7 +2188,13 @@ table.runs tr.sys td{color:var(--dim)}
|
|
|
2184
2188
|
if (!fresh && LAST.etag && LAST.url === url) h["if-none-match"] = LAST.etag;
|
|
2185
2189
|
var r = await fetch(url, { cache: "no-store", headers: h });
|
|
2186
2190
|
if (r.status === 304) return { same: true };
|
|
2187
|
-
|
|
2191
|
+
// A refusal carries the server's reason (a seisin.toml that no longer
|
|
2192
|
+
// parses): kept, so it is shown instead of reading as the server gone.
|
|
2193
|
+
if (!r.ok) {
|
|
2194
|
+
var why = null;
|
|
2195
|
+
try { why = (await r.json()).error || null; } catch (_) { /* no body */ }
|
|
2196
|
+
return { status: r.status, error: why };
|
|
2197
|
+
}
|
|
2188
2198
|
var s = await r.json();
|
|
2189
2199
|
if (s.error) return { status: r.status, error: s.error };
|
|
2190
2200
|
LAST = { url: url, etag: r.headers.get("etag") };
|
|
@@ -2311,6 +2321,16 @@ table.runs tr.sys td{color:var(--dim)}
|
|
|
2311
2321
|
'passes the example policy off as yours.</p></div>';
|
|
2312
2322
|
return;
|
|
2313
2323
|
}
|
|
2324
|
+
if (!s && got && got.error) {
|
|
2325
|
+
label.textContent = "seisin.toml cannot be read";
|
|
2326
|
+
label.style.color = "var(--amber)";
|
|
2327
|
+
document.querySelector("main").innerHTML =
|
|
2328
|
+
'<div class="box" style="padding:22px 24px;max-width:640px">' +
|
|
2329
|
+
'<h2 style="margin:0 0 8px;font-size:17px">The console cannot read this repository\'s policy</h2>' +
|
|
2330
|
+
'<p style="margin:0 0 8px" class="err">' + esc(got.error) + '</p>' +
|
|
2331
|
+
'<p style="margin:0;color:var(--mid)">Fix the file and reload. <code>seisin check</code> says the same in a terminal.</p></div>';
|
|
2332
|
+
return;
|
|
2333
|
+
}
|
|
2314
2334
|
if (!s) {
|
|
2315
2335
|
label.textContent = location.protocol.startsWith("http") && !TOKEN
|
|
2316
2336
|
? "no token — showing an example"
|
|
@@ -2632,6 +2652,8 @@ table.runs tr.sys td{color:var(--dim)}
|
|
|
2632
2652
|
if (got.s) applyState(got.s);
|
|
2633
2653
|
return;
|
|
2634
2654
|
}
|
|
2655
|
+
// The server answered, with the reason it cannot: said at once, not as an outage.
|
|
2656
|
+
if (got.error && got.status !== 401 && got.status !== 403) { FAILS = 0; setOffline("policy", got.error); return; }
|
|
2635
2657
|
FAILS++;
|
|
2636
2658
|
if (FAILS >= 2) setOffline(got.status === 401 || got.status === 403 ? "expired" : "down");
|
|
2637
2659
|
}
|