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/commands/walls.js
CHANGED
|
@@ -3,30 +3,56 @@ import { unknownRole } from "../suggest.js";
|
|
|
3
3
|
import { walls, wasted } from "../walls.js";
|
|
4
4
|
import { STALE_RUNS } from "../requests.js";
|
|
5
5
|
import { logPath } from "../log.js";
|
|
6
|
+
import { standingOf } from "../owners.js";
|
|
7
|
+
import { kindsOf } from "../kinds.js";
|
|
8
|
+
import { parseSince } from "../views.js";
|
|
6
9
|
import { C, out, safe } from "../render.js";
|
|
7
10
|
|
|
8
11
|
export function wallsCommand(config, argv = []) {
|
|
9
12
|
const role = argv[0];
|
|
10
|
-
if (!role) throw new Error("usage: seisin walls <role> [--since <
|
|
13
|
+
if (!role) throw new Error("usage: seisin walls <role> [--since <date|duration>] [--min <n>] [--all]");
|
|
11
14
|
if (!config.roles[role]) throw unknownRole(config, role);
|
|
12
15
|
const at = (flag, fallback) => {
|
|
13
16
|
const i = argv.indexOf(flag);
|
|
14
17
|
return i === -1 ? fallback : argv[i + 1];
|
|
15
18
|
};
|
|
19
|
+
// The same reading of `--since` as review and the MCP: a date or `7d`.
|
|
20
|
+
const since = argv.includes("--since") ? parseSince(at("--since", null)) : null;
|
|
21
|
+
if (argv.includes("--since") && !since)
|
|
22
|
+
throw new Error(`--since takes a date (2026-10-01) or a duration back from now (7d, 12h, 2w), not "${safe(at("--since", "") ?? "")}"`);
|
|
16
23
|
const every = walls(config, role, {
|
|
17
24
|
file: logPath(config.root),
|
|
18
25
|
min: Number(at("--min", 2)),
|
|
19
|
-
since
|
|
26
|
+
since,
|
|
20
27
|
limit: 0,
|
|
21
28
|
});
|
|
22
29
|
const all = argv.includes("--all");
|
|
23
30
|
const list = (all ? every : every.filter((w) => !w.stale)).slice(0, 6);
|
|
24
31
|
const old = every.filter((w) => w.stale).length;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if (
|
|
32
|
+
const shown = withKinds(config, list);
|
|
33
|
+
if (shown.length) out(renderForPerson(role, shown));
|
|
34
|
+
else if (!every.length)
|
|
35
|
+
out(`\n ${C.dim}${safe(role)} has no repeated denials in the recorded runs. A path becomes a wall after ${at("--min", 2)} denials.${C.off}\n\n`);
|
|
36
|
+
else if (!all)
|
|
37
|
+
out(`\n ${C.dim}${safe(role)} has no recent walls; older ones are hidden. Use seisin walls ${safe(role)} --all.${C.off}\n\n`);
|
|
28
38
|
if (old && !all) out(` (${old} older wall(s) not hit in the last ${STALE_RUNS}+ runs: seisin walls ${role} --all)\n`);
|
|
29
|
-
return
|
|
39
|
+
return shown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* An unowned path gets the kind and hint the console gives it (kinds.js).
|
|
44
|
+
*
|
|
45
|
+
* The bare reason said "has no owner — no role can write it until one claims
|
|
46
|
+
* it" about `dist/` and `.env` alike: an invitation to give a build folder or a
|
|
47
|
+
* credential an owner, while the console said what kind of thing each was and
|
|
48
|
+
* what to do instead. Same classifier, same sentence, on both screens.
|
|
49
|
+
*/
|
|
50
|
+
function withKinds(config, list) {
|
|
51
|
+
const stand = standingOf(config);
|
|
52
|
+
const unowned = list.filter((w) => w.action === "write" && !w.owners?.length &&
|
|
53
|
+
stand({ kind: "file", target: w.target }).kind === "unowned");
|
|
54
|
+
const natures = kindsOf(unowned.map((w) => w.target), { keyDirs: config.keyDirs ?? [] });
|
|
55
|
+
return list.map((w) => (unowned.includes(w) ? { ...w, ...natures.get(w.target) } : w));
|
|
30
56
|
}
|
|
31
57
|
|
|
32
58
|
/**
|
|
@@ -39,7 +65,13 @@ export function wallsCommand(config, argv = []) {
|
|
|
39
65
|
export function renderForPerson(role, list) {
|
|
40
66
|
const lines = [`\n ${C.b}${safe(role)}${C.off} keeps hitting:\n\n`];
|
|
41
67
|
for (const w of list) {
|
|
42
|
-
|
|
68
|
+
if (w.expected) {
|
|
69
|
+
lines.push(` ${C.dim}${String(w.times).padStart(3)}× ${w.action} ${safe(w.target)} expected · ${safe(w.expected.hint)}${C.off}\n`);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const why = w.owners?.length ? `belongs to ${w.owners.map(safe).join(", ")}`
|
|
73
|
+
: w.kind ? `unowned · ${w.kind === "territory" ? "ownable" : w.kind}: ${safe(w.hint)}`
|
|
74
|
+
: safe(w.reason);
|
|
43
75
|
lines.push(` ${C.yellow}${String(w.times).padStart(3)}×${C.off} ${w.action} ${safe(w.target)} ${C.dim}${why}${C.off}\n`);
|
|
44
76
|
}
|
|
45
77
|
const n = wasted(list);
|
package/src/commands/whose.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { toRepoRelative, fromCwd } from "../paths.js";
|
|
20
20
|
import { ownersOf, keyHolders } from "../owners.js";
|
|
21
|
+
import { decideHandoff, nextStep, chainFromEnv } from "../handoff.js";
|
|
21
22
|
import { twinsOf, whereIs } from "../worktree.js";
|
|
22
23
|
import { C, out } from "../render.js";
|
|
23
24
|
|
|
@@ -54,7 +55,7 @@ export function whose(config, argv = []) {
|
|
|
54
55
|
(mine
|
|
55
56
|
? ` ${C.green}that is you${C.off}${C.dim} — if it was denied, the reason is not ownership.${C.off}\n\n`
|
|
56
57
|
: asking
|
|
57
|
-
? ` ${C.dim}you are ${asking}.
|
|
58
|
+
? ` ${C.dim}you are ${asking}. ${nextStep(decideHandoff({ request: { role: asking, action: isKey ? "read" : "write", target: rel }, config, chain: chainFromEnv() }), asking)}${C.off}\n\n`
|
|
58
59
|
: "\n")
|
|
59
60
|
);
|
|
60
61
|
for (const w of worktree) out(renderElsewhere(w, asking));
|
package/src/config.js
CHANGED
|
@@ -8,11 +8,14 @@
|
|
|
8
8
|
*
|
|
9
9
|
* If your config outgrows this subset, the config is doing too much.
|
|
10
10
|
*/
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
11
12
|
import { readFileSync, existsSync } from "node:fs";
|
|
12
13
|
import { resolve, dirname, join } from "node:path";
|
|
14
|
+
import { isIP } from "node:net";
|
|
13
15
|
import { CONFIG_NAME } from "./layout.js";
|
|
14
|
-
import { parseKey, MODES, checkNames, BUILTIN } from "./keys.js";
|
|
16
|
+
import { parseKey, MODES, checkNames, BUILTIN, unknownMode } from "./keys.js";
|
|
15
17
|
import { readNotify } from "./notify.js";
|
|
18
|
+
import { BROWSERS } from "./browsers.js";
|
|
16
19
|
|
|
17
20
|
export { CONFIG_NAME } from "./layout.js";
|
|
18
21
|
|
|
@@ -302,6 +305,36 @@ function readValue(value, lineNo) {
|
|
|
302
305
|
* application and `security` is the application. That trades one hole for a
|
|
303
306
|
* larger one: the login keychain is every password you have.
|
|
304
307
|
*/
|
|
308
|
+
/** `[runtime] read`: "all" (default) or "territory". An unknown value is refused, never read as "all". */
|
|
309
|
+
export function readReadMode(v, path = "seisin.toml") {
|
|
310
|
+
if (v === undefined || v === "all") return "all";
|
|
311
|
+
if (v === "territory") return "territory";
|
|
312
|
+
throw new Error(
|
|
313
|
+
`${path}: runtime.read must be "all" or "territory", not ${JSON.stringify(v)}. ` +
|
|
314
|
+
`An unknown value cannot silently leave every file readable.`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* `reads` and `toolchain`: paths, absolute, `~/…`, or relative to the repo.
|
|
319
|
+
*
|
|
320
|
+
* Same rule as `writes`, for the same reason: the sandbox opens a path and
|
|
321
|
+
* everything under it, so a literal path or a `dir/**` subtree is all that can
|
|
322
|
+
* be said. `docs/*.md` would have to be widened to `docs` or frozen to today's
|
|
323
|
+
* files; either way the policy would say something the kernel does not.
|
|
324
|
+
*/
|
|
325
|
+
function readReadList(v, at) {
|
|
326
|
+
const list = v === undefined ? [] : asArray(v, at);
|
|
327
|
+
for (const p of list) {
|
|
328
|
+
const above = p.endsWith("/**") ? p.slice(0, -3) : p;
|
|
329
|
+
if (/[*?[\]{}]/.test(above) || p === "**")
|
|
330
|
+
throw new Error(
|
|
331
|
+
`${at} = "${p}" cannot be enforced as written.\n` +
|
|
332
|
+
` A read grant is a path and everything under it. Write "${above.replace(/\/?[^/]*[*?[\]{}].*$/, "") || "."}/**" ` +
|
|
333
|
+
`for the subtree, or name the paths.`);
|
|
334
|
+
}
|
|
335
|
+
return list;
|
|
336
|
+
}
|
|
337
|
+
|
|
305
338
|
export function readIsolate(v) {
|
|
306
339
|
if (v === true || v === "home") return "home";
|
|
307
340
|
if (v === "credentials") return "credentials";
|
|
@@ -311,6 +344,19 @@ export function readIsolate(v) {
|
|
|
311
344
|
`An unknown value cannot silently turn credential isolation off.`);
|
|
312
345
|
}
|
|
313
346
|
|
|
347
|
+
/**
|
|
348
|
+
* A hash of the policy file as it is now, short: it names a version, it proves
|
|
349
|
+
* nothing. Every log line carries the one its run started with (intake.js), and
|
|
350
|
+
* every handoff decision the one it was made against (requests.js, queueOf).
|
|
351
|
+
*/
|
|
352
|
+
export function policyId(config) {
|
|
353
|
+
try {
|
|
354
|
+
return createHash("sha256").update(readFileSync(config.path)).digest("hex").slice(0, 12);
|
|
355
|
+
} catch {
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
314
360
|
/**
|
|
315
361
|
* Turns the parsed file into the shape the rest of the program uses, and
|
|
316
362
|
* rejects anything ambiguous.
|
|
@@ -352,13 +398,14 @@ export function loadConfig(path, text = readFileSync(path, "utf8")) {
|
|
|
352
398
|
* bench died on it, and the surviving three were the model names with no dots
|
|
353
399
|
* in them.
|
|
354
400
|
*
|
|
355
|
-
* There is no ambiguity to preserve here — a nested table under
|
|
356
|
-
*
|
|
357
|
-
* written and what it became.
|
|
401
|
+
* There is almost no ambiguity to preserve here — a nested table under
|
|
402
|
+
* [roles] has one meaning in this tool, `inject_hosts`, and any other is an
|
|
403
|
+
* error that names both what was written and what it became.
|
|
358
404
|
*/
|
|
359
405
|
for (const name of Object.keys(roles)) {
|
|
360
406
|
const r = own(roles, name);
|
|
361
|
-
const nested = Object.keys(r ?? {})
|
|
407
|
+
const nested = Object.keys(r ?? {})
|
|
408
|
+
.filter((k) => k !== "inject_hosts" && r[k] && typeof r[k] === "object" && !Array.isArray(r[k]));
|
|
362
409
|
if (nested.length)
|
|
363
410
|
throw new Error(
|
|
364
411
|
`${path}: [roles.${name}.${nested[0]}] — a dot separates keys in TOML, so this ` +
|
|
@@ -402,11 +449,22 @@ export function loadConfig(path, text = readFileSync(path, "utf8")) {
|
|
|
402
449
|
// Through asArray like every other list: `allow = "github.com"` was kept as
|
|
403
450
|
// a string, and a string iterated is its characters.
|
|
404
451
|
allowedDomains: asArray(own(own(parsed, "network"), "allow"), "network.allow"),
|
|
452
|
+
// `[network] deny_addresses` — where an allowed name may not lead. An allowed
|
|
453
|
+
// domain whose DNS somebody else controls can be pointed at a host on your
|
|
454
|
+
// LAN; the runtime denies loopback, link-local and metadata addresses by
|
|
455
|
+
// itself and leaves private ranges to the policy, because an intranet name
|
|
456
|
+
// is a legitimate thing to allow. Checked here, with the line, rather than
|
|
457
|
+
// by the sandbox at start.
|
|
458
|
+
deniedAddresses: readAddresses(own(own(parsed, "network"), "deny_addresses"), `${path}: network.deny_addresses`),
|
|
405
459
|
runtimeWrites: runtimeWrites === undefined ? undefined : asArray(runtimeWrites, "runtime.writes"),
|
|
406
460
|
// `[runtime] isolate` — how much of your home a role stops reaching. See
|
|
407
461
|
// srt.js for what each level emits, and readIsolate() below for why there
|
|
408
462
|
// are two of them.
|
|
409
463
|
isolate: readIsolate(own(runtime, "isolate")),
|
|
464
|
+
// `[runtime] read` — what a role may read. "all" is every file but the ones
|
|
465
|
+
// denied by name; "territory" denies the places data lives and re-opens
|
|
466
|
+
// only what the role needs. See territory.js.
|
|
467
|
+
read: readReadMode(own(runtime, "read"), path),
|
|
410
468
|
redact: readBoolean(own(runtime, "redact"), `${path}: runtime.redact`),
|
|
411
469
|
scanIgnore: asArray(own(own(parsed, "scan"), "ignore"), "scan.ignore"),
|
|
412
470
|
protect: readProtect(own(parsed, "protect"), path),
|
|
@@ -459,9 +517,8 @@ export function loadConfig(path, text = readFileSync(path, "utf8")) {
|
|
|
459
517
|
checkNames({ name, keyEntries });
|
|
460
518
|
const keyMode = own(r, "key_mode");
|
|
461
519
|
if (keyMode !== undefined && !MODES.includes(keyMode))
|
|
462
|
-
throw
|
|
463
|
-
|
|
464
|
-
`Known: ${MODES.join(", ")}.`);
|
|
520
|
+
throw unknownMode(`${path}: roles.${name}:`, "key_mode", keyMode);
|
|
521
|
+
const injectHosts = readInjectHosts(own(r, "inject_hosts"), `${path}: roles.${name}.inject_hosts`);
|
|
465
522
|
const neverWrites = readNeverWrites(own(r, "never_writes"), `${path}: roles.${name}.never_writes`);
|
|
466
523
|
const localBinding = own(r, "local_binding");
|
|
467
524
|
const trustd = own(r, "trustd");
|
|
@@ -469,9 +526,20 @@ export function loadConfig(path, text = readFileSync(path, "utf8")) {
|
|
|
469
526
|
throw new Error(`${path}: roles.${name}.trustd must be true or false, not ${JSON.stringify(trustd)}`);
|
|
470
527
|
if (localBinding !== undefined && typeof localBinding !== "boolean")
|
|
471
528
|
throw new Error(`${path}: roles.${name}.local_binding must be true or false, not ${JSON.stringify(localBinding)}`);
|
|
529
|
+
const handoff = own(r, "handoff");
|
|
530
|
+
if (handoff !== undefined && typeof handoff !== "boolean")
|
|
531
|
+
throw new Error(`${path}: roles.${name}.handoff must be true or false, not ${JSON.stringify(handoff)}`);
|
|
472
532
|
const localPorts = readLocalPorts(own(r, "local_ports"), `${path}: roles.${name}.local_ports`);
|
|
533
|
+
const browsers = readBrowsers(own(r, "browsers"), `${path}: roles.${name}.browsers`);
|
|
473
534
|
const mcp = readMcp(own(r, "mcp"), `${path}: roles.${name}.mcp`);
|
|
474
535
|
const controlFiles = readControlFiles(own(r, "control_files"), `${path}: roles.${name}.control_files`);
|
|
536
|
+
const reads = readReadList(own(r, "reads"), `${path}: roles.${name}.reads`);
|
|
537
|
+
const toolchain = readReadList(own(r, "toolchain"), `${path}: roles.${name}.toolchain`);
|
|
538
|
+
const priv = readPrivate(own(r, "private"), `${path}: roles.${name}.private`);
|
|
539
|
+
const verify = own(r, "verify") === undefined ? null : asArray(own(r, "verify"), `roles.${name}.verify`);
|
|
540
|
+
if (verify && verify.length === 0)
|
|
541
|
+
throw new Error(`${path}: roles.${name}.verify is empty. It is the command that proves the role can check ` +
|
|
542
|
+
`its own work — ["python", "-m", "pytest", "--version"] — or leave it out.`);
|
|
475
543
|
out.roles[name] = {
|
|
476
544
|
name,
|
|
477
545
|
/**
|
|
@@ -485,11 +553,21 @@ export function loadConfig(path, text = readFileSync(path, "utf8")) {
|
|
|
485
553
|
* kept so that regenerating a config writes back what a person typed
|
|
486
554
|
* rather than the expansion.
|
|
487
555
|
*/
|
|
488
|
-
writes: withSidecars(writes),
|
|
556
|
+
writes: withSidecars([...new Set([...writes, ...priv])]),
|
|
489
557
|
writesDeclared: writes,
|
|
558
|
+
/**
|
|
559
|
+
* Folders only this role reads and writes. Each is also part of its
|
|
560
|
+
* territory (added to `writes` above, so `explain`, `whose` and the
|
|
561
|
+
* settings agree it is this role's), and every OTHER role is given a
|
|
562
|
+
* denyRead and a denyWrite on it — in either read mode, isolated or not.
|
|
563
|
+
* Deny-only: an allow nested in a deny breaks on one platform or the
|
|
564
|
+
* other unless it is handled the way territory.js handles its own.
|
|
565
|
+
*/
|
|
566
|
+
private: priv,
|
|
490
567
|
keys,
|
|
491
568
|
keyEntries,
|
|
492
569
|
keyMode: keyMode ?? null,
|
|
570
|
+
injectHosts,
|
|
493
571
|
env: asArray(own(r, "env"), `roles.${name}.env`),
|
|
494
572
|
network: own(r, "network") === undefined ? null : asArray(own(r, "network"), `roles.${name}.network`),
|
|
495
573
|
/**
|
|
@@ -530,6 +608,12 @@ export function loadConfig(path, text = readFileSync(path, "utf8")) {
|
|
|
530
608
|
* go through the proxy, which HTTP clients do and a MySQL driver does not.
|
|
531
609
|
*/
|
|
532
610
|
localPorts,
|
|
611
|
+
/**
|
|
612
|
+
* The headless browsers this role may start (browsers.js): the Mach
|
|
613
|
+
* services each needs, by name, and nothing else. `[]` unless the role
|
|
614
|
+
* says so — every other role keeps the profile as it was.
|
|
615
|
+
*/
|
|
616
|
+
browsers,
|
|
533
617
|
/**
|
|
534
618
|
* The MCP servers this role may load, by name, or null when the policy
|
|
535
619
|
* does not say. `[]` is "none". seisin declares this and does not
|
|
@@ -552,6 +636,35 @@ export function loadConfig(path, text = readFileSync(path, "utf8")) {
|
|
|
552
636
|
* is and why only these two can be handed out. Absent is none.
|
|
553
637
|
*/
|
|
554
638
|
controlFiles,
|
|
639
|
+
/**
|
|
640
|
+
* Whether this role takes work handed to it (handoff.js). True unless the
|
|
641
|
+
* role says `handoff = false`: a role that is a pipeline phase rather
|
|
642
|
+
* than a worker — it prepares the tree, it is not someone to pass a
|
|
643
|
+
* file to — still owns its territory, and a write denied there goes to
|
|
644
|
+
* another owner or to a person instead of starting it.
|
|
645
|
+
*/
|
|
646
|
+
handoff: handoff !== false,
|
|
647
|
+
/**
|
|
648
|
+
* What this role reads beyond its repo, under `read = "territory"`.
|
|
649
|
+
*
|
|
650
|
+
* `reads` is data — a sibling checkout, a shared docs folder. `toolchain`
|
|
651
|
+
* is what runs — a venv, `~/.nvm`, `~/.cargo` — kept apart because `check`
|
|
652
|
+
* treats the two differently: a toolchain that does not exist is the
|
|
653
|
+
* reason a role cannot verify itself, and that is worth a finding. Both
|
|
654
|
+
* are read grants and nothing else; neither is ever writable. Ignored
|
|
655
|
+
* (and said so by `check`) under `read = "all"`, where everything is
|
|
656
|
+
* readable already.
|
|
657
|
+
*/
|
|
658
|
+
reads,
|
|
659
|
+
toolchain,
|
|
660
|
+
/**
|
|
661
|
+
* The command that proves this role can check its own work, run by
|
|
662
|
+
* `seisin check --verify` inside the role's sandbox. Measured in
|
|
663
|
+
* an agent orchestrator (2026-10-09): an agent asked to verify with nothing to verify
|
|
664
|
+
* with cost 80% more per task and verified nothing; the same agent with
|
|
665
|
+
* its toolchain ran the suite every time at the original cost.
|
|
666
|
+
*/
|
|
667
|
+
verify,
|
|
555
668
|
// Kept so `check` can name a misspelt key instead of ignoring it. An
|
|
556
669
|
// unknown key in a role table used to be dropped silently, and for a
|
|
557
670
|
// subtraction that is failing open: `never_write` would read as a rule
|
|
@@ -559,6 +672,8 @@ export function loadConfig(path, text = readFileSync(path, "utf8")) {
|
|
|
559
672
|
unknownKeys: Object.keys(r).filter((k) => !ROLE_KEYS.includes(k)),
|
|
560
673
|
};
|
|
561
674
|
}
|
|
675
|
+
checkPrivateOverlap(out.roles, path);
|
|
676
|
+
out.sandbox = readSandbox(own(parsed, "sandbox"), path);
|
|
562
677
|
out.notify = readNotify(own(parsed, "notify"), { path, root: out.root, keyDirs, roles: out.roles, own });
|
|
563
678
|
return out;
|
|
564
679
|
}
|
|
@@ -600,14 +715,81 @@ function readProviders(table, path) {
|
|
|
600
715
|
` Put {ref} where the reference goes: command = [${command.map((c) => `"${c}"`).join(", ")}, "{ref}"]`);
|
|
601
716
|
const mode = own(p, "mode");
|
|
602
717
|
if (mode !== undefined && !MODES.includes(mode))
|
|
603
|
-
throw
|
|
604
|
-
`${path}: [keys.providers.${name}] mode = "${mode}" is not a delivery mode. ` +
|
|
605
|
-
`Known: ${MODES.join(", ")}.`);
|
|
718
|
+
throw unknownMode(`${path}: [keys.providers.${name}]`, "mode", mode);
|
|
606
719
|
out[name] = { name, command, mode: mode ?? null };
|
|
607
720
|
}
|
|
608
721
|
return out;
|
|
609
722
|
}
|
|
610
723
|
|
|
724
|
+
/** The sandboxes seisin ships (sandboxes/index.js). Named here too, so config.js imports no runtime code. */
|
|
725
|
+
const BUILTIN_SANDBOXES = ["srt", "tartaros"];
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* `[sandbox]`: which sandbox enforces the roles, and any declared by command.
|
|
729
|
+
*
|
|
730
|
+
* `provider` is one name or a list in order of preference; absent, it is
|
|
731
|
+
* `srt`, as it always was. A name is one seisin ships or one declared under
|
|
732
|
+
* `[sandbox.providers.<name>]` — an unknown one is refused here, never read as
|
|
733
|
+
* the default, because "the config asks for tartaros and the run used srt"
|
|
734
|
+
* without a word is the silent substitution sandboxes/index.js exists to stop.
|
|
735
|
+
*/
|
|
736
|
+
function readSandbox(table, path) {
|
|
737
|
+
if (table === undefined) return null;
|
|
738
|
+
const providers = Object.create(null);
|
|
739
|
+
const declared = own(table, "providers");
|
|
740
|
+
for (const name of declared === undefined ? [] : Object.keys(declared)) {
|
|
741
|
+
if (BUILTIN_SANDBOXES.includes(name))
|
|
742
|
+
throw new Error(`${path}: [sandbox.providers.${name}] — "${name}" is built in and cannot be redefined. ` +
|
|
743
|
+
`Give your provider another name.`);
|
|
744
|
+
const command = own(own(declared, name), "command");
|
|
745
|
+
if (!Array.isArray(command) || command.length === 0 || command.some((c) => typeof c !== "string" || c === ""))
|
|
746
|
+
throw new Error(`${path}: [sandbox.providers.${name}] needs a command, as an array of strings:\n` +
|
|
747
|
+
` command = ["mybox", "run", "--policy", "{policy}", "--"]`);
|
|
748
|
+
if (!command.slice(1).some((c) => c.includes("{policy}")))
|
|
749
|
+
throw new Error(`${path}: [sandbox.providers.${name}] has no {policy} in its command, so the sandbox would never ` +
|
|
750
|
+
`see the policy it is meant to enforce.\n Put {policy} where the settings file goes: ` +
|
|
751
|
+
`command = [${[...command, "{policy}"].map((c) => `"${c}"`).join(", ")}]`);
|
|
752
|
+
// Optional: asks the sandbox, before anything runs, whether it can hold a
|
|
753
|
+
// policy. Exit 0 is yes; anything else is no, and the last line it prints says why.
|
|
754
|
+
// Without it a declared sandbox is only ever found out by the canary, so a
|
|
755
|
+
// list like ["mybox", "srt"] would stop at exit 86 instead of moving on.
|
|
756
|
+
const check = own(own(declared, name), "check");
|
|
757
|
+
if (check !== undefined) {
|
|
758
|
+
if (!Array.isArray(check) || check.length === 0 || check.some((c) => typeof c !== "string" || c === ""))
|
|
759
|
+
throw new Error(`${path}: [sandbox.providers.${name}] check must be a command, as an array of strings:\n` +
|
|
760
|
+
` check = ["mybox", "check", "--policy", "{policy}"]`);
|
|
761
|
+
if (!check.slice(1).some((c) => c.includes("{policy}")))
|
|
762
|
+
throw new Error(`${path}: [sandbox.providers.${name}] has no {policy} in its check, so it would answer ` +
|
|
763
|
+
`without seeing the policy it is asked about.\n Put {policy} where the settings file goes: ` +
|
|
764
|
+
`check = [${[...check, "{policy}"].map((c) => `"${c}"`).join(", ")}]`);
|
|
765
|
+
}
|
|
766
|
+
// Declared, because it cannot be detected: its Seatbelt denials carry
|
|
767
|
+
// sandbox-runtime's tag, so the kernel's refusals under it can be recorded.
|
|
768
|
+
const tags = own(own(declared, name), "tags_denials");
|
|
769
|
+
if (tags !== undefined && typeof tags !== "boolean")
|
|
770
|
+
throw new Error(`${path}: [sandbox.providers.${name}] tags_denials is true or false, not ${JSON.stringify(tags)}.`);
|
|
771
|
+
providers[name] = { name, command, check: check ?? null, tagsDenials: tags ?? false };
|
|
772
|
+
}
|
|
773
|
+
const raw = own(table, "provider");
|
|
774
|
+
if (raw === undefined)
|
|
775
|
+
return { provider: null, providers };
|
|
776
|
+
const names = typeof raw === "string" ? [raw] : raw;
|
|
777
|
+
if (!Array.isArray(names) || names.length === 0 || names.some((n) => typeof n !== "string"))
|
|
778
|
+
throw new Error(`${path}: sandbox.provider = ${JSON.stringify(raw)} — a name, or a list of names in order of preference: ` +
|
|
779
|
+
`provider = ["tartaros", "srt"]`);
|
|
780
|
+
for (const n of names) {
|
|
781
|
+
if (BUILTIN_SANDBOXES.includes(n) || providers[n]) continue;
|
|
782
|
+
const known = [...BUILTIN_SANDBOXES, ...Object.keys(providers)];
|
|
783
|
+
const near = closest(n, known);
|
|
784
|
+
throw new Error(`${path}: sandbox.provider names "${n}", which is not a sandbox seisin knows` +
|
|
785
|
+
`${near ? ` — did you mean "${near}"?` : "."}\n Built in: ${BUILTIN_SANDBOXES.join(", ")}. ` +
|
|
786
|
+
`Any other is declared with a command: [sandbox.providers.${n}] command = ["…", "{policy}"].`);
|
|
787
|
+
}
|
|
788
|
+
if (new Set(names).size !== names.length)
|
|
789
|
+
throw new Error(`${path}: sandbox.provider lists a sandbox twice (${JSON.stringify(names)}).`);
|
|
790
|
+
return { provider: names, providers };
|
|
791
|
+
}
|
|
792
|
+
|
|
611
793
|
/**
|
|
612
794
|
* A SQLite database is four files, and a policy should say it once.
|
|
613
795
|
*
|
|
@@ -664,11 +846,12 @@ export function withSidecars(paths) {
|
|
|
664
846
|
const SHAPE = {
|
|
665
847
|
roles: null, // validated where roles are read
|
|
666
848
|
keys: { dir: true, providers: { "*": { command: true, mode: true } } },
|
|
667
|
-
network: { allow: true },
|
|
668
|
-
runtime: { writes: true, isolate: true, redact: true },
|
|
849
|
+
network: { allow: true, deny_addresses: true },
|
|
850
|
+
runtime: { writes: true, isolate: true, redact: true, read: true },
|
|
669
851
|
scan: { ignore: true },
|
|
670
852
|
protect: { instructions: true },
|
|
671
853
|
notify: { url: true, url_file: true, format: true },
|
|
854
|
+
sandbox: { provider: true, providers: { "*": { command: true, check: true, tags_denials: true } } },
|
|
672
855
|
};
|
|
673
856
|
|
|
674
857
|
function checkShape(table, shape, prefix, path, lines) {
|
|
@@ -727,7 +910,7 @@ function readBoolean(value, where) {
|
|
|
727
910
|
}
|
|
728
911
|
|
|
729
912
|
/** The keys a `[roles.<name>]` table can hold. Anything else is reported by `check`. */
|
|
730
|
-
export const ROLE_KEYS = ["writes", "keys", "key_mode", "env", "network", "never_writes", "local_binding", "local_ports", "mcp", "trustd", "control_files"];
|
|
913
|
+
export const ROLE_KEYS = ["writes", "private", "keys", "key_mode", "inject_hosts", "env", "network", "never_writes", "local_binding", "local_ports", "browsers", "mcp", "trustd", "control_files", "reads", "toolchain", "verify", "handoff"];
|
|
731
914
|
|
|
732
915
|
/**
|
|
733
916
|
* `control_files`: which families of control files a role may edit. Refused at
|
|
@@ -786,6 +969,23 @@ function readMcp(value, where) {
|
|
|
786
969
|
return out;
|
|
787
970
|
}
|
|
788
971
|
|
|
972
|
+
/**
|
|
973
|
+
* `browsers`, refused rather than guessed: a name seisin has no preset for
|
|
974
|
+
* would read as a grant that was made, and grant nothing.
|
|
975
|
+
*/
|
|
976
|
+
function readBrowsers(value, where) {
|
|
977
|
+
if (value === undefined) return [];
|
|
978
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== "string"))
|
|
979
|
+
throw new Error(`${where} is a list of browser names, for example browsers = ["chromium"].`);
|
|
980
|
+
for (const b of value)
|
|
981
|
+
if (!Object.hasOwn(BROWSERS, b)) {
|
|
982
|
+
const near = closest(b, Object.keys(BROWSERS));
|
|
983
|
+
throw new Error(`${where}: "${b}" is not a browser seisin has a preset for${near ? ` — did you mean "${near}"?` : ""} ` +
|
|
984
|
+
`Known: ${Object.keys(BROWSERS).join(", ")}.`);
|
|
985
|
+
}
|
|
986
|
+
return [...new Set(value)];
|
|
987
|
+
}
|
|
988
|
+
|
|
789
989
|
/**
|
|
790
990
|
* `local_ports`, refused rather than guessed when it is not a list of ports.
|
|
791
991
|
*
|
|
@@ -808,6 +1008,45 @@ function readLocalPorts(value, where) {
|
|
|
808
1008
|
return out;
|
|
809
1009
|
}
|
|
810
1010
|
|
|
1011
|
+
/**
|
|
1012
|
+
* `inject_hosts`: for `key_mode = "inject"`, the hosts each masked credential
|
|
1013
|
+
* is substituted into, keyed by the delivered variable name.
|
|
1014
|
+
*
|
|
1015
|
+
* [roles.frontend.inject_hosts]
|
|
1016
|
+
* GH_TOKEN = ["api.github.com"]
|
|
1017
|
+
*
|
|
1018
|
+
* One list per credential and not one for the role, because the runtime gates
|
|
1019
|
+
* substitution per credential: credential A's real value reaches only A's
|
|
1020
|
+
* hosts, never B's. A host is an exact name — no wildcard — so `check` can say
|
|
1021
|
+
* precisely which traffic is intercepted; the match against a wider wildcard in
|
|
1022
|
+
* `allowedDomains` is computed later (srt.js), where the terminated set is.
|
|
1023
|
+
*/
|
|
1024
|
+
function readInjectHosts(value, where) {
|
|
1025
|
+
if (value === undefined) return null;
|
|
1026
|
+
if (typeof value !== "object" || Array.isArray(value))
|
|
1027
|
+
throw new Error(`${where}: inject_hosts is a table of VAR = ["host", …], ` +
|
|
1028
|
+
`for example [roles.x.inject_hosts] GH_TOKEN = ["api.github.com"].`);
|
|
1029
|
+
const out = {};
|
|
1030
|
+
for (const [name, hosts] of Object.entries(value)) {
|
|
1031
|
+
const list = asArray(hosts, `${where}.${name}`);
|
|
1032
|
+
if (list.length === 0)
|
|
1033
|
+
throw new Error(`${where}.${name}: a masked credential with no host injects nowhere. ` +
|
|
1034
|
+
`List the host its token authenticates to, like ["api.github.com"].`);
|
|
1035
|
+
for (const h of list) {
|
|
1036
|
+
if (typeof h !== "string" || h.trim() === "")
|
|
1037
|
+
throw new Error(`${where}.${name}: every host is a non-empty string`);
|
|
1038
|
+
if (h.includes("*"))
|
|
1039
|
+
throw new Error(`${where}.${name}: "${h}" is a wildcard. An inject host is an exact name, ` +
|
|
1040
|
+
`so check can say which traffic is intercepted. A wildcard in allowedDomains still ` +
|
|
1041
|
+
`covers it; that is reported, not declared here.`);
|
|
1042
|
+
if (/^https?:\/\//.test(h) || h.includes("/"))
|
|
1043
|
+
throw new Error(`${where}.${name}: "${h}" is not a host. Write the bare domain, like "api.github.com".`);
|
|
1044
|
+
}
|
|
1045
|
+
out[name] = [...new Set(list)];
|
|
1046
|
+
}
|
|
1047
|
+
return out;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
811
1050
|
/**
|
|
812
1051
|
* `never_writes`, refused rather than guessed when it cannot mean what it says.
|
|
813
1052
|
*
|
|
@@ -829,6 +1068,56 @@ function readNeverWrites(value, where) {
|
|
|
829
1068
|
return list;
|
|
830
1069
|
}
|
|
831
1070
|
|
|
1071
|
+
/**
|
|
1072
|
+
* `private`: folders inside the repo, written `dir` or `dir/**`.
|
|
1073
|
+
*
|
|
1074
|
+
* A folder and not a glob, because what is enforced is a deny on one path for
|
|
1075
|
+
* every other role: `notes/*.md` would have to be widened to `notes` or frozen
|
|
1076
|
+
* to today's files. Not absolute and no `..`, because the denies are given
|
|
1077
|
+
* against the repo the policy describes and `explain` answers in it. Not the
|
|
1078
|
+
* repo itself: private to one role, the whole repo is shut to all the others,
|
|
1079
|
+
* which is a policy of one role written in the long way.
|
|
1080
|
+
*/
|
|
1081
|
+
function readPrivate(value, where) {
|
|
1082
|
+
const list = value === undefined ? [] : asArray(value, where);
|
|
1083
|
+
for (const p of list) {
|
|
1084
|
+
const dir = p.replace(/\/\*\*$/, "").replace(/\/+$/, "").replace(/^\.\//, "");
|
|
1085
|
+
if (p.startsWith("/"))
|
|
1086
|
+
throw new Error(`${where}: "${p}" is absolute. A private folder is relative to the repo root, like writes.`);
|
|
1087
|
+
if (dir.split("/").includes(".."))
|
|
1088
|
+
throw new Error(`${where}: "${p}" contains "..". A private folder is inside the repo.`);
|
|
1089
|
+
if (dir === "" || dir === "." || dir === "**")
|
|
1090
|
+
throw new Error(`${where}: "${p}" is the whole repo. A private folder is a folder inside it — ` +
|
|
1091
|
+
`"notes/**" — and every other role is denied it.`);
|
|
1092
|
+
if (/[*?[\]{}]/.test(dir))
|
|
1093
|
+
throw new Error(`${where}: "${p}" is a pattern. A private folder is one path and everything under it: ` +
|
|
1094
|
+
`write "${dir.replace(/\/?[^/]*[*?[\]{}].*$/, "") || "dir"}/**".`);
|
|
1095
|
+
}
|
|
1096
|
+
return list;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/** `private` with its `/**` and `./` taken off: the folder itself. */
|
|
1100
|
+
export const privateDir = (p) => p.replace(/\/\*\*$/, "").replace(/\/+$/, "").replace(/^\.\//, "");
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
* One owner per private folder. Two roles naming the same folder, or one
|
|
1104
|
+
* inside the other, would each be denied the other's — so neither could read
|
|
1105
|
+
* its own. Refused at load, with both names.
|
|
1106
|
+
*/
|
|
1107
|
+
function checkPrivateOverlap(roles, path) {
|
|
1108
|
+
const all = Object.values(roles).flatMap((r) => (r.private ?? []).map((p) => ({ role: r.name, p, dir: privateDir(p) })));
|
|
1109
|
+
const inside = (a, b) => a === b || a.startsWith(b + "/");
|
|
1110
|
+
for (let i = 0; i < all.length; i++)
|
|
1111
|
+
for (let j = i + 1; j < all.length; j++) {
|
|
1112
|
+
const a = all[i], b = all[j];
|
|
1113
|
+
if (a.role === b.role || !(inside(a.dir, b.dir) || inside(b.dir, a.dir))) continue;
|
|
1114
|
+
throw new Error(
|
|
1115
|
+
`${path}: roles.${a.role}.private "${a.p}" and roles.${b.role}.private "${b.p}" ` +
|
|
1116
|
+
`${a.dir === b.dir ? "are the same folder" : "overlap"}. A private folder has one owner: every ` +
|
|
1117
|
+
`other role is denied it, so each of these two would be denied its own. Give it to one of them.`);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
832
1121
|
/**
|
|
833
1122
|
* A territory is relative to the repo root. An absolute one would ask the kernel
|
|
834
1123
|
* for a path on the machine and make `explain` (which reads absolute as outside
|
|
@@ -848,6 +1137,23 @@ function assertNotAbsolute(list, where) {
|
|
|
848
1137
|
return list;
|
|
849
1138
|
}
|
|
850
1139
|
|
|
1140
|
+
/**
|
|
1141
|
+
* An IP address or a CIDR range: `10.0.0.0/8`, `192.168.1.10`, `fc00::/7`
|
|
1142
|
+
* (IPv6 without brackets). Anything else is refused with what was written.
|
|
1143
|
+
*/
|
|
1144
|
+
function readAddresses(value, where) {
|
|
1145
|
+
const list = asArray(value, where);
|
|
1146
|
+
for (const v of list) {
|
|
1147
|
+
const [addr, prefix, extra] = v.split("/");
|
|
1148
|
+
const family = isIP(addr);
|
|
1149
|
+
const max = family === 6 ? 128 : 32;
|
|
1150
|
+
const okPrefix = prefix === undefined || (/^\d+$/.test(prefix) && Number(prefix) <= max);
|
|
1151
|
+
if (!family || !okPrefix || extra !== undefined)
|
|
1152
|
+
throw new Error(`${where}: "${v}" is not an IP address or a CIDR range (e.g. "10.0.0.0/8", "fc00::/7")`);
|
|
1153
|
+
}
|
|
1154
|
+
return list;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
851
1157
|
function asArray(value, where) {
|
|
852
1158
|
if (value === undefined) return [];
|
|
853
1159
|
if (typeof value === "string") return [value];
|
package/src/controls.js
CHANGED
|
@@ -80,8 +80,8 @@ function tableOf(lines, name) {
|
|
|
80
80
|
return { header, end, keys };
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
function stamp(note) {
|
|
84
|
-
return `# set
|
|
83
|
+
function stamp(note, source = "in the console") {
|
|
84
|
+
return `# set ${source} ${new Date().toISOString().slice(0, 10)}` +
|
|
85
85
|
(note ? ` · «${cleanReason(note)}»` : "");
|
|
86
86
|
}
|
|
87
87
|
|
|
@@ -92,7 +92,7 @@ function stamp(note) {
|
|
|
92
92
|
* value being replaced, and the stamp now describes the new one. Every other
|
|
93
93
|
* line, comments included, stays byte for byte.
|
|
94
94
|
*/
|
|
95
|
-
function setKey(text, table, key, value, note) {
|
|
95
|
+
export function setKey(text, table, key, value, note, source) {
|
|
96
96
|
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
97
97
|
const lines = text.split("\n");
|
|
98
98
|
const cr = eol === "\r\n" ? "\r" : "";
|
|
@@ -105,14 +105,14 @@ function setKey(text, table, key, value, note) {
|
|
|
105
105
|
const was = lines.slice(at.from, at.to + 1).map((l) => stripComment(l).trim()).join(" ");
|
|
106
106
|
if (was.slice(was.indexOf("=") + 1).replace(/\s+/g, "").replace(/,\]$/, "]") === value.replace(/\s+/g, "")) return text;
|
|
107
107
|
const indent = /^\s*/.exec(lines[at.from])[0];
|
|
108
|
-
const line = `${indent}${key} = ${value} ${stamp(note)}${cr}`;
|
|
108
|
+
const line = `${indent}${key} = ${value} ${stamp(note, source)}${cr}`;
|
|
109
109
|
lines.splice(at.from, at.to - at.from + 1, line);
|
|
110
110
|
} else {
|
|
111
111
|
// After the last key, not the last line: a comment above the next table
|
|
112
112
|
// belongs to that table, and a line inserted under it would read as the
|
|
113
113
|
// next role's.
|
|
114
114
|
const after = t.keys.length ? t.keys[t.keys.length - 1].to : t.header;
|
|
115
|
-
lines.splice(after + 1, 0, `${key} = ${value} ${stamp(note)}${cr}`);
|
|
115
|
+
lines.splice(after + 1, 0, `${key} = ${value} ${stamp(note, source)}${cr}`);
|
|
116
116
|
}
|
|
117
117
|
return lines.join("\n");
|
|
118
118
|
}
|