seisin 0.4.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +506 -162
- package/package.json +14 -6
- package/src/browsers.js +52 -0
- package/src/canary.js +149 -0
- package/src/cli.js +175 -52
- package/src/commands/check.js +157 -5
- package/src/commands/explain.js +31 -13
- package/src/commands/hook.js +20 -6
- package/src/commands/init.js +183 -88
- package/src/commands/log.js +48 -9
- package/src/commands/requests.js +122 -70
- package/src/commands/review.js +73 -21
- package/src/commands/run.js +422 -78
- package/src/commands/scan.js +18 -3
- package/src/commands/ui.js +18 -5
- package/src/commands/walls.js +62 -11
- package/src/commands/whose.js +9 -7
- package/src/commands/wire.js +156 -17
- package/src/config.js +505 -37
- package/src/controls.js +244 -0
- package/src/diagnose.js +102 -49
- package/src/env.js +14 -8
- package/src/grants.js +122 -9
- package/src/handoff.js +285 -10
- package/src/help.js +179 -0
- package/src/hook.js +101 -42
- package/src/index.d.ts +217 -0
- package/src/index.js +24 -16
- package/src/inspect/codex.js +64 -0
- package/src/inspect/credentials.js +48 -0
- package/src/inspect/enforce.js +24 -0
- package/src/inspect/files.js +71 -0
- package/src/inspect/home.js +43 -0
- package/src/inspect/inject.js +68 -0
- package/src/inspect/keys.js +74 -0
- package/src/inspect/layout.js +258 -0
- package/src/inspect/limits.js +98 -0
- package/src/inspect/machine.js +107 -0
- package/src/inspect/network.js +37 -0
- package/src/inspect/providers.js +71 -0
- package/src/inspect/read-territory.js +105 -0
- package/src/inspect/role-keys.js +136 -0
- package/src/inspect/shared.js +37 -0
- package/src/inspect/warnings.js +58 -0
- package/src/inspect.js +20 -756
- package/src/intake.js +150 -35
- package/src/keys.js +231 -73
- package/src/kinds.js +249 -0
- package/src/lines.js +26 -0
- package/src/log.js +242 -52
- package/src/mcp.js +57 -58
- package/src/nested.js +12 -3
- package/src/notify.js +66 -20
- package/src/owners.js +358 -39
- package/src/paths.js +54 -2
- package/src/provider-run.js +81 -0
- package/src/redact.js +46 -13
- package/src/render.js +101 -17
- package/src/requests.js +506 -87
- package/src/review.js +50 -25
- package/src/rundir.js +33 -14
- package/src/sandboxes/command.js +93 -0
- package/src/sandboxes/index.js +113 -0
- package/src/sandboxes/mach-profile.js +31 -0
- package/src/sandboxes/srt-mach.mjs +43 -0
- package/src/sandboxes/srt.js +163 -0
- package/src/sandboxes/tartaros.js +80 -0
- package/src/scan.js +181 -19
- package/src/serve.js +396 -230
- package/src/spool.js +16 -45
- package/src/srt.js +296 -49
- package/src/suggest.js +58 -0
- package/src/surface.js +314 -54
- package/src/territory.js +261 -0
- package/src/trace.js +33 -0
- package/src/uilink.js +24 -9
- package/src/views.js +315 -0
- package/src/violations.js +247 -23
- package/src/walls.js +76 -46
- package/src/worktree.js +3 -11
- package/ui/index.html +1429 -538
- package/ui/favicon.svg +0 -1
package/src/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
|
|
|
@@ -31,7 +34,6 @@ export function findConfig(from = process.cwd()) {
|
|
|
31
34
|
const RE_TABLE = /^\[([A-Za-z0-9_.\-]+)\]$/;
|
|
32
35
|
const RE_PAIR = /^([A-Za-z0-9_\-]+)\s*=\s*(.+)$/;
|
|
33
36
|
|
|
34
|
-
/** Minimal TOML subset -> plain object. Throws with a line number on bad input. */
|
|
35
37
|
/**
|
|
36
38
|
* Names a table or key may not have.
|
|
37
39
|
*
|
|
@@ -59,8 +61,20 @@ export function own(obj, key) {
|
|
|
59
61
|
return obj && Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Where each table and key of a parsed file was written, by dotted name.
|
|
66
|
+
*
|
|
67
|
+
* Kept beside the result rather than in it, so the object stays exactly the
|
|
68
|
+
* data that was written; loadConfig reads it to put a line number on a name it
|
|
69
|
+
* does not know.
|
|
70
|
+
*/
|
|
71
|
+
const LINES = new WeakMap();
|
|
72
|
+
|
|
73
|
+
/** Minimal TOML subset -> plain object. Throws with a line number on bad input. */
|
|
62
74
|
export function parseToml(text) {
|
|
63
75
|
const out = dict();
|
|
76
|
+
const where = new Map();
|
|
77
|
+
LINES.set(out, where);
|
|
64
78
|
let table = out;
|
|
65
79
|
let tableName = "";
|
|
66
80
|
// "Last one wins" is TOML-ish and wrong for a permission file. A policy can
|
|
@@ -87,9 +101,13 @@ export function parseToml(text) {
|
|
|
87
101
|
if (seenTables.has(tableName))
|
|
88
102
|
throw new Error(
|
|
89
103
|
`${CONFIG_NAME}:${i + 1}: [${tableName}] appears twice. ` +
|
|
90
|
-
`A later
|
|
104
|
+
`A later table would silently override the earlier one — put every setting for ` +
|
|
91
105
|
`${tableName} in one place.`);
|
|
92
106
|
seenTables.add(tableName);
|
|
107
|
+
tableName.split(".").forEach((_, k, parts) => {
|
|
108
|
+
const name = parts.slice(0, k + 1).join(".");
|
|
109
|
+
if (!where.has(name)) where.set(name, i + 1);
|
|
110
|
+
});
|
|
93
111
|
table = tableName.split(".").reduce((node, key, k, parts) => {
|
|
94
112
|
if (!Object.prototype.hasOwnProperty.call(node, key)) node[key] = dict();
|
|
95
113
|
else if (!isTable(node[key]))
|
|
@@ -105,16 +123,18 @@ export function parseToml(text) {
|
|
|
105
123
|
if (!pair) throw new Error(`${CONFIG_NAME}:${i + 1}: cannot read "${raw.trim()}"`);
|
|
106
124
|
|
|
107
125
|
let [, key, value] = pair;
|
|
126
|
+
const at = i + 1;
|
|
108
127
|
// An array may span lines; keep pulling until the brackets balance. Only
|
|
109
128
|
// the new line is searched: searching the whole growing value made an
|
|
110
|
-
// unclosed array of 100k lines take 20 s to be refused.
|
|
111
|
-
|
|
129
|
+
// unclosed array of 100k lines take 20 s to be refused. A `]` inside a
|
|
130
|
+
// quoted item does not close it — `["a]b",` used to end the array there.
|
|
131
|
+
if (value.startsWith("[") && !closesArray(value)) {
|
|
112
132
|
const parts = [value];
|
|
113
133
|
let closed = false;
|
|
114
134
|
while (!closed && i + 1 < lines.length) {
|
|
115
135
|
const next = stripComment(lines[++i]).trim();
|
|
116
136
|
parts.push(next);
|
|
117
|
-
closed = next
|
|
137
|
+
closed = closesArray(next);
|
|
118
138
|
}
|
|
119
139
|
value = parts.join(" ");
|
|
120
140
|
}
|
|
@@ -126,6 +146,7 @@ export function parseToml(text) {
|
|
|
126
146
|
`${CONFIG_NAME}:${i + 1}: "${key}" is set twice in [${tableName || "the root"}]. ` +
|
|
127
147
|
`The second would win, which is not a thing a permission file should do quietly.`);
|
|
128
148
|
seenKeys.add(seen);
|
|
149
|
+
where.set(tableName ? `${tableName}.${key}` : key, at);
|
|
129
150
|
/**
|
|
130
151
|
* A key may not be a table that is declared elsewhere, in either order.
|
|
131
152
|
*
|
|
@@ -145,8 +166,19 @@ export function parseToml(text) {
|
|
|
145
166
|
return out;
|
|
146
167
|
}
|
|
147
168
|
|
|
169
|
+
/** Is there a `]` on this line outside a quoted string? Strings do not span lines. */
|
|
170
|
+
export function closesArray(line) {
|
|
171
|
+
let quoted = false;
|
|
172
|
+
for (let i = 0; i < line.length; i++) {
|
|
173
|
+
const c = line[i];
|
|
174
|
+
if (c === '"') quoted = !quoted;
|
|
175
|
+
else if (c === "]" && !quoted) return true;
|
|
176
|
+
}
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
|
|
148
180
|
/** Strips a trailing `#` comment, but not one inside a quoted string. */
|
|
149
|
-
function stripComment(line) {
|
|
181
|
+
export function stripComment(line) {
|
|
150
182
|
let quoted = false;
|
|
151
183
|
for (let i = 0; i < line.length; i++) {
|
|
152
184
|
const c = line[i];
|
|
@@ -241,14 +273,6 @@ function readValue(value, lineNo) {
|
|
|
241
273
|
bad("value must be a string, an array of strings, a whole number, or a boolean");
|
|
242
274
|
}
|
|
243
275
|
|
|
244
|
-
/**
|
|
245
|
-
* Turns the parsed file into the shape the rest of the program uses, and
|
|
246
|
-
* rejects anything ambiguous.
|
|
247
|
-
*
|
|
248
|
-
* Rejecting is the point. The sandbox runtime refuses to start on an invalid
|
|
249
|
-
* settings file rather than falling back to a permissive default, and seisin
|
|
250
|
-
* matches that: a permission tool that guesses is worse than no permission tool.
|
|
251
|
-
*/
|
|
252
276
|
/**
|
|
253
277
|
* `[runtime] isolate` — one setting, two levels, because they were one and
|
|
254
278
|
* should not have been.
|
|
@@ -281,6 +305,36 @@ function readValue(value, lineNo) {
|
|
|
281
305
|
* application and `security` is the application. That trades one hole for a
|
|
282
306
|
* larger one: the login keychain is every password you have.
|
|
283
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
|
+
|
|
284
338
|
export function readIsolate(v) {
|
|
285
339
|
if (v === true || v === "home") return "home";
|
|
286
340
|
if (v === "credentials") return "credentials";
|
|
@@ -290,8 +344,32 @@ export function readIsolate(v) {
|
|
|
290
344
|
`An unknown value cannot silently turn credential isolation off.`);
|
|
291
345
|
}
|
|
292
346
|
|
|
293
|
-
|
|
294
|
-
|
|
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
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Turns the parsed file into the shape the rest of the program uses, and
|
|
362
|
+
* rejects anything ambiguous.
|
|
363
|
+
*
|
|
364
|
+
* Rejecting is the point. The sandbox runtime refuses to start on an invalid
|
|
365
|
+
* settings file rather than falling back to a permissive default, and seisin
|
|
366
|
+
* matches that: a permission tool that guesses is worse than no permission tool.
|
|
367
|
+
*/
|
|
368
|
+
export function loadConfig(path, text = readFileSync(path, "utf8")) {
|
|
369
|
+
// `text` lets a caller validate an edit before writing it — the console
|
|
370
|
+
// previews a policy that does not exist on disk yet, against the real root.
|
|
371
|
+
const parsed = parseToml(text);
|
|
372
|
+
checkShape(parsed, SHAPE, "", path, LINES.get(parsed));
|
|
295
373
|
// Read only what the file actually declared. The parser refuses the names
|
|
296
374
|
// that make inheritance possible, and this is the second half of the same
|
|
297
375
|
// rule: if one of them ever gets through, a role still cannot pick up a
|
|
@@ -320,13 +398,14 @@ export function loadConfig(path) {
|
|
|
320
398
|
* bench died on it, and the surviving three were the model names with no dots
|
|
321
399
|
* in them.
|
|
322
400
|
*
|
|
323
|
-
* There is no ambiguity to preserve here — a nested table under
|
|
324
|
-
*
|
|
325
|
-
* 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.
|
|
326
404
|
*/
|
|
327
405
|
for (const name of Object.keys(roles)) {
|
|
328
406
|
const r = own(roles, name);
|
|
329
|
-
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]));
|
|
330
409
|
if (nested.length)
|
|
331
410
|
throw new Error(
|
|
332
411
|
`${path}: [roles.${name}.${nested[0]}] — a dot separates keys in TOML, so this ` +
|
|
@@ -353,7 +432,11 @@ export function loadConfig(path) {
|
|
|
353
432
|
// than one kind of secret, and making people flatten them to satisfy the tool
|
|
354
433
|
// is how a tool gets kept out.
|
|
355
434
|
const keyDir = own(own(parsed, "keys"), "dir");
|
|
356
|
-
|
|
435
|
+
// `.secrets/` and `.secrets` are one directory, and every consumer compares
|
|
436
|
+
// them as prefixes: with the slash kept, `scan` walked into the protected
|
|
437
|
+
// directory and a bare key resolved to `.secrets//x`, which matched nothing.
|
|
438
|
+
const keyDirs = (keyDir === undefined ? [] : asArray(keyDir, "keys.dir"))
|
|
439
|
+
.map((d) => d.replace(/\/+$/, "") || d);
|
|
357
440
|
const keyProviders = readProviders(own(own(parsed, "keys"), "providers"), path);
|
|
358
441
|
// `undefined` means "use the defaults"; an explicit empty array means "none".
|
|
359
442
|
// The difference matters: one is a user who has not thought about it, the
|
|
@@ -363,14 +446,28 @@ export function loadConfig(path) {
|
|
|
363
446
|
|
|
364
447
|
const out = {
|
|
365
448
|
root: dirname(path), path, keyDirs, keyProviders,
|
|
366
|
-
|
|
449
|
+
// Through asArray like every other list: `allow = "github.com"` was kept as
|
|
450
|
+
// a string, and a string iterated is its characters.
|
|
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`),
|
|
367
459
|
runtimeWrites: runtimeWrites === undefined ? undefined : asArray(runtimeWrites, "runtime.writes"),
|
|
368
460
|
// `[runtime] isolate` — how much of your home a role stops reaching. See
|
|
369
461
|
// srt.js for what each level emits, and readIsolate() below for why there
|
|
370
462
|
// are two of them.
|
|
371
463
|
isolate: readIsolate(own(runtime, "isolate")),
|
|
372
|
-
|
|
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),
|
|
468
|
+
redact: readBoolean(own(runtime, "redact"), `${path}: runtime.redact`),
|
|
373
469
|
scanIgnore: asArray(own(own(parsed, "scan"), "ignore"), "scan.ignore"),
|
|
470
|
+
protect: readProtect(own(parsed, "protect"), path),
|
|
374
471
|
roles: {},
|
|
375
472
|
};
|
|
376
473
|
|
|
@@ -420,9 +517,8 @@ export function loadConfig(path) {
|
|
|
420
517
|
checkNames({ name, keyEntries });
|
|
421
518
|
const keyMode = own(r, "key_mode");
|
|
422
519
|
if (keyMode !== undefined && !MODES.includes(keyMode))
|
|
423
|
-
throw
|
|
424
|
-
|
|
425
|
-
`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`);
|
|
426
522
|
const neverWrites = readNeverWrites(own(r, "never_writes"), `${path}: roles.${name}.never_writes`);
|
|
427
523
|
const localBinding = own(r, "local_binding");
|
|
428
524
|
const trustd = own(r, "trustd");
|
|
@@ -430,8 +526,20 @@ export function loadConfig(path) {
|
|
|
430
526
|
throw new Error(`${path}: roles.${name}.trustd must be true or false, not ${JSON.stringify(trustd)}`);
|
|
431
527
|
if (localBinding !== undefined && typeof localBinding !== "boolean")
|
|
432
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)}`);
|
|
433
532
|
const localPorts = readLocalPorts(own(r, "local_ports"), `${path}: roles.${name}.local_ports`);
|
|
533
|
+
const browsers = readBrowsers(own(r, "browsers"), `${path}: roles.${name}.browsers`);
|
|
434
534
|
const mcp = readMcp(own(r, "mcp"), `${path}: roles.${name}.mcp`);
|
|
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.`);
|
|
435
543
|
out.roles[name] = {
|
|
436
544
|
name,
|
|
437
545
|
/**
|
|
@@ -445,11 +553,21 @@ export function loadConfig(path) {
|
|
|
445
553
|
* kept so that regenerating a config writes back what a person typed
|
|
446
554
|
* rather than the expansion.
|
|
447
555
|
*/
|
|
448
|
-
writes: withSidecars(writes),
|
|
556
|
+
writes: withSidecars([...new Set([...writes, ...priv])]),
|
|
449
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,
|
|
450
567
|
keys,
|
|
451
568
|
keyEntries,
|
|
452
569
|
keyMode: keyMode ?? null,
|
|
570
|
+
injectHosts,
|
|
453
571
|
env: asArray(own(r, "env"), `roles.${name}.env`),
|
|
454
572
|
network: own(r, "network") === undefined ? null : asArray(own(r, "network"), `roles.${name}.network`),
|
|
455
573
|
/**
|
|
@@ -490,6 +608,12 @@ export function loadConfig(path) {
|
|
|
490
608
|
* go through the proxy, which HTTP clients do and a MySQL driver does not.
|
|
491
609
|
*/
|
|
492
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,
|
|
493
617
|
/**
|
|
494
618
|
* The MCP servers this role may load, by name, or null when the policy
|
|
495
619
|
* does not say. `[]` is "none". seisin declares this and does not
|
|
@@ -505,6 +629,42 @@ export function loadConfig(path) {
|
|
|
505
629
|
* the role says so. See srt.js for what it costs.
|
|
506
630
|
*/
|
|
507
631
|
trustd: trustd === true,
|
|
632
|
+
/**
|
|
633
|
+
* The families of control files this role may edit inside its own
|
|
634
|
+
* territory: "ide" (`.vscode/`, `.cursor/`, `.windsurf/`) and
|
|
635
|
+
* "instructions" (`CLAUDE.md`, `AGENTS.md`…). surface.js says what each
|
|
636
|
+
* is and why only these two can be handed out. Absent is none.
|
|
637
|
+
*/
|
|
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,
|
|
508
668
|
// Kept so `check` can name a misspelt key instead of ignoring it. An
|
|
509
669
|
// unknown key in a role table used to be dropped silently, and for a
|
|
510
670
|
// subtraction that is failing open: `never_write` would read as a rule
|
|
@@ -512,6 +672,8 @@ export function loadConfig(path) {
|
|
|
512
672
|
unknownKeys: Object.keys(r).filter((k) => !ROLE_KEYS.includes(k)),
|
|
513
673
|
};
|
|
514
674
|
}
|
|
675
|
+
checkPrivateOverlap(out.roles, path);
|
|
676
|
+
out.sandbox = readSandbox(own(parsed, "sandbox"), path);
|
|
515
677
|
out.notify = readNotify(own(parsed, "notify"), { path, root: out.root, keyDirs, roles: out.roles, own });
|
|
516
678
|
return out;
|
|
517
679
|
}
|
|
@@ -553,18 +715,85 @@ function readProviders(table, path) {
|
|
|
553
715
|
` Put {ref} where the reference goes: command = [${command.map((c) => `"${c}"`).join(", ")}, "{ref}"]`);
|
|
554
716
|
const mode = own(p, "mode");
|
|
555
717
|
if (mode !== undefined && !MODES.includes(mode))
|
|
556
|
-
throw
|
|
557
|
-
`${path}: [keys.providers.${name}] mode = "${mode}" is not a delivery mode. ` +
|
|
558
|
-
`Known: ${MODES.join(", ")}.`);
|
|
718
|
+
throw unknownMode(`${path}: [keys.providers.${name}]`, "mode", mode);
|
|
559
719
|
out[name] = { name, command, mode: mode ?? null };
|
|
560
720
|
}
|
|
561
721
|
return out;
|
|
562
722
|
}
|
|
563
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
|
+
|
|
564
793
|
/**
|
|
565
794
|
* A SQLite database is four files, and a policy should say it once.
|
|
566
795
|
*
|
|
567
|
-
* `
|
|
796
|
+
* `data/x/app.sqlite` is not the database — it is one of the files the
|
|
568
797
|
* database is made of. Writing to it in WAL mode also writes `-wal` and
|
|
569
798
|
* `-shm`; in rollback mode, `-journal`. So "this role writes this database"
|
|
570
799
|
* takes four lines, and a policy with eight databases and thirty-one roles
|
|
@@ -579,7 +808,7 @@ function readProviders(table, path) {
|
|
|
579
808
|
* it, or corrupt it. The sidecar is not a second resource — it is the same one.
|
|
580
809
|
*
|
|
581
810
|
* It is also not a glob. The kernel grants a path and everything under it, so
|
|
582
|
-
* `
|
|
811
|
+
* `app.sqlite*` is unenforceable and `seisin check` refuses it; these are
|
|
583
812
|
* emitted as literal paths, exactly as if they had been typed.
|
|
584
813
|
*
|
|
585
814
|
* **The suffix list is closed on purpose** and this is the line that keeps the
|
|
@@ -603,8 +832,124 @@ export function withSidecars(paths) {
|
|
|
603
832
|
return [...new Set(out)];
|
|
604
833
|
}
|
|
605
834
|
|
|
835
|
+
/**
|
|
836
|
+
* Every table and key a policy may have outside `[roles.*]`. `true` is a value,
|
|
837
|
+
* an object is a table, and `*` stands for any name (a provider's).
|
|
838
|
+
*
|
|
839
|
+
* Anything else is refused at load, with its line. The roles table reports an
|
|
840
|
+
* unknown key through `check` because a role's typo is usually one extra
|
|
841
|
+
* grant; here the settings are global, and the one that made this necessary
|
|
842
|
+
* was `isolaet = true` under [runtime] — read as nothing, so isolation stayed
|
|
843
|
+
* off for every role and nothing on screen said so. The same "fail open by
|
|
844
|
+
* typo" that was closed for roles, closed harder because it covers them all.
|
|
845
|
+
*/
|
|
846
|
+
const SHAPE = {
|
|
847
|
+
roles: null, // validated where roles are read
|
|
848
|
+
keys: { dir: true, providers: { "*": { command: true, mode: true } } },
|
|
849
|
+
network: { allow: true, deny_addresses: true },
|
|
850
|
+
runtime: { writes: true, isolate: true, redact: true, read: true },
|
|
851
|
+
scan: { ignore: true },
|
|
852
|
+
protect: { instructions: true },
|
|
853
|
+
notify: { url: true, url_file: true, format: true },
|
|
854
|
+
sandbox: { provider: true, providers: { "*": { command: true, check: true, tags_denials: true } } },
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
function checkShape(table, shape, prefix, path, lines) {
|
|
858
|
+
const known = Object.keys(shape).filter((k) => k !== "*");
|
|
859
|
+
for (const key of Object.keys(table)) {
|
|
860
|
+
const name = prefix ? `${prefix}.${key}` : key;
|
|
861
|
+
const at = `${path}:${lines?.get(name) ?? "?"}`;
|
|
862
|
+
const rule = Object.prototype.hasOwnProperty.call(shape, key) ? shape[key] : shape["*"];
|
|
863
|
+
if (rule === undefined) {
|
|
864
|
+
const near = closest(key, known);
|
|
865
|
+
throw new Error(
|
|
866
|
+
`${at}: ${prefix ? `[${prefix}] ` : ""}"${key}" is not a setting` +
|
|
867
|
+
`${prefix ? "" : " or a table"} seisin knows${near ? ` — did you mean "${near}"?` : "."}\n` +
|
|
868
|
+
` Known${prefix ? ` in [${prefix}]` : " at the top level"}: ${known.join(", ")}. ` +
|
|
869
|
+
`An unknown name is refused rather than ignored: ignored, a misspelt setting ` +
|
|
870
|
+
`reads as a rule and does nothing.`);
|
|
871
|
+
}
|
|
872
|
+
if (rule === null) continue;
|
|
873
|
+
const value = table[key];
|
|
874
|
+
if (rule === true && isTable(value))
|
|
875
|
+
throw new Error(`${at}: [${name}] is a table, but ${name} is a setting — write ${key} = … under [${prefix}].`);
|
|
876
|
+
if (typeof rule === "object" && !isTable(value))
|
|
877
|
+
throw new Error(`${at}: ${name} = ${JSON.stringify(value)} — ${name} is a table, [${name}], with its settings under it.`);
|
|
878
|
+
if (typeof rule === "object") checkShape(value, rule, name, path, lines);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/** The candidate within edit distance 3 of `word`, or null. Shared with `check`. */
|
|
883
|
+
export function closest(word, candidates) {
|
|
884
|
+
let best = null, bestD = 4;
|
|
885
|
+
for (const c of candidates) {
|
|
886
|
+
const d = distance(word.toLowerCase(), c);
|
|
887
|
+
if (d < bestD) { best = c; bestD = d; }
|
|
888
|
+
}
|
|
889
|
+
return best;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/** Levenshtein distance. */
|
|
893
|
+
function distance(a, b) {
|
|
894
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
895
|
+
for (let i = 1; i <= a.length; i++) {
|
|
896
|
+
let prev = row[0]; row[0] = i;
|
|
897
|
+
for (let j = 1; j <= b.length; j++) {
|
|
898
|
+
const cur = row[j];
|
|
899
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
900
|
+
prev = cur;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
return row[b.length];
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/** true, false, or absent (undefined). Anything else is a mistake, said as one. */
|
|
907
|
+
function readBoolean(value, where) {
|
|
908
|
+
if (value === undefined || typeof value === "boolean") return value;
|
|
909
|
+
throw new Error(`${where} must be true or false, not ${JSON.stringify(value)}`);
|
|
910
|
+
}
|
|
911
|
+
|
|
606
912
|
/** The keys a `[roles.<name>]` table can hold. Anything else is reported by `check`. */
|
|
607
|
-
export const ROLE_KEYS = ["writes", "keys", "key_mode", "env", "network", "never_writes", "local_binding", "local_ports", "mcp", "trustd"];
|
|
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"];
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* `control_files`: which families of control files a role may edit. Refused at
|
|
917
|
+
* load when it names anything else — `.claude`, git hooks and the rest are not
|
|
918
|
+
* on the list on purpose, and a word the reader does not know must not load as
|
|
919
|
+
* a permission it thinks it wrote.
|
|
920
|
+
*/
|
|
921
|
+
export const CONTROL_FAMILIES = ["ide", "instructions"];
|
|
922
|
+
|
|
923
|
+
function readControlFiles(value, where) {
|
|
924
|
+
if (value === undefined) return [];
|
|
925
|
+
const out = [];
|
|
926
|
+
for (const f of asArray(value, where)) {
|
|
927
|
+
if (!CONTROL_FAMILIES.includes(f))
|
|
928
|
+
throw new Error(`${where}: ${JSON.stringify(f)} is not a family of control files. ` +
|
|
929
|
+
`Known: ${CONTROL_FAMILIES.map((x) => JSON.stringify(x)).join(", ")}. ` +
|
|
930
|
+
`The others (.claude, git hooks, .mcp.json, .envrc) are never handed to a role.`);
|
|
931
|
+
if (!out.includes(f)) out.push(f);
|
|
932
|
+
}
|
|
933
|
+
return out;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* `[protect]` — what is protected beyond the defaults. One key today:
|
|
938
|
+
* `instructions = true` protects `CLAUDE.md`, `AGENTS.md` and their kin from
|
|
939
|
+
* every role that is not handed them. Anything else in the table is refused.
|
|
940
|
+
*/
|
|
941
|
+
function readProtect(value, path) {
|
|
942
|
+
if (value === undefined) return { instructions: false };
|
|
943
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
944
|
+
throw new Error(`${path}: [protect] must be a table`);
|
|
945
|
+
for (const k of Object.keys(value))
|
|
946
|
+
if (k !== "instructions")
|
|
947
|
+
throw new Error(`${path}: [protect] ${k} is not a setting. Known: instructions.`);
|
|
948
|
+
const v = own(value, "instructions");
|
|
949
|
+
if (v !== undefined && typeof v !== "boolean")
|
|
950
|
+
throw new Error(`${path}: [protect] instructions must be true or false, not ${JSON.stringify(v)}`);
|
|
951
|
+
return { instructions: v === true };
|
|
952
|
+
}
|
|
608
953
|
|
|
609
954
|
/**
|
|
610
955
|
* `mcp`: names of MCP servers, as the CLI's config calls them. Absent is null,
|
|
@@ -624,6 +969,23 @@ function readMcp(value, where) {
|
|
|
624
969
|
return out;
|
|
625
970
|
}
|
|
626
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
|
+
|
|
627
989
|
/**
|
|
628
990
|
* `local_ports`, refused rather than guessed when it is not a list of ports.
|
|
629
991
|
*
|
|
@@ -646,6 +1008,45 @@ function readLocalPorts(value, where) {
|
|
|
646
1008
|
return out;
|
|
647
1009
|
}
|
|
648
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
|
+
|
|
649
1050
|
/**
|
|
650
1051
|
* `never_writes`, refused rather than guessed when it cannot mean what it says.
|
|
651
1052
|
*
|
|
@@ -667,14 +1068,64 @@ function readNeverWrites(value, where) {
|
|
|
667
1068
|
return list;
|
|
668
1069
|
}
|
|
669
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
|
+
|
|
670
1121
|
/**
|
|
671
1122
|
* A territory is relative to the repo root. An absolute one would ask the kernel
|
|
672
1123
|
* for a path on the machine and make `explain` (which reads absolute as outside
|
|
673
1124
|
* the repo) disagree with what the profile grants — see the CODEOWNERS `/apps/`
|
|
674
1125
|
* case. `..` is allowed on purpose: a role legitimately writes a sibling
|
|
675
|
-
* directory (`../
|
|
676
|
-
* territory that climbs above it grants what it resolves to, by the
|
|
677
|
-
*
|
|
1126
|
+
* directory (`../shared/...`), and it is resolved relative to the root, so a
|
|
1127
|
+
* territory that climbs above it grants what it resolves to, by the choice of
|
|
1128
|
+
* whoever wrote the policy.
|
|
678
1129
|
*/
|
|
679
1130
|
function assertNotAbsolute(list, where) {
|
|
680
1131
|
for (const g of list) {
|
|
@@ -686,6 +1137,23 @@ function assertNotAbsolute(list, where) {
|
|
|
686
1137
|
return list;
|
|
687
1138
|
}
|
|
688
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
|
+
|
|
689
1157
|
function asArray(value, where) {
|
|
690
1158
|
if (value === undefined) return [];
|
|
691
1159
|
if (typeof value === "string") return [value];
|