seisin 0.6.3 → 0.6.5

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.
Files changed (50) hide show
  1. package/README.md +143 -686
  2. package/package.json +2 -2
  3. package/src/canary.js +1 -1
  4. package/src/cli.js +47 -3
  5. package/src/commands/check.js +68 -12
  6. package/src/commands/hook.js +1 -1
  7. package/src/commands/log.js +21 -14
  8. package/src/commands/requests.js +10 -15
  9. package/src/commands/review.js +2 -1
  10. package/src/commands/run.js +331 -245
  11. package/src/commands/walls.js +12 -5
  12. package/src/config-fields.js +305 -0
  13. package/src/config.js +114 -534
  14. package/src/controls.js +1 -1
  15. package/src/decide-request.js +37 -0
  16. package/src/denial-attribution.js +338 -0
  17. package/src/grants.js +1 -1
  18. package/src/help.js +6 -4
  19. package/src/index.js +1 -1
  20. package/src/inspect/enforce.js +1 -1
  21. package/src/inspect/files.js +1 -1
  22. package/src/inspect/home.js +1 -1
  23. package/src/inspect/inject.js +1 -1
  24. package/src/intake.js +3 -2
  25. package/src/keys.js +2 -2
  26. package/src/kinds.js +19 -0
  27. package/src/layout.js +1 -1
  28. package/src/log.js +3 -2
  29. package/src/mcp.js +1 -1
  30. package/src/notify-config.js +58 -0
  31. package/src/notify.js +4 -52
  32. package/src/owners.js +2 -2
  33. package/src/requests.js +9 -4
  34. package/src/rundir.js +2 -2
  35. package/src/runs.js +29 -10
  36. package/src/sandboxes/command.js +0 -2
  37. package/src/sandboxes/index.js +15 -2
  38. package/src/sandboxes/srt.js +1 -1
  39. package/src/sandboxes/tartaros.js +34 -6
  40. package/src/serve.js +11 -22
  41. package/src/spool.js +1 -1
  42. package/src/{srt.js → srt-settings.js} +5 -2
  43. package/src/state.js +25 -0
  44. package/src/suggest.js +26 -1
  45. package/src/surface.js +3 -2
  46. package/src/territory.js +20 -1
  47. package/src/toml.js +250 -0
  48. package/src/violations.js +16 -315
  49. package/src/walls.js +4 -2
  50. package/ui/index.html +35 -9
package/src/config.js CHANGED
@@ -1,21 +1,22 @@
1
1
  /**
2
- * Reads and validates seisin.toml.
3
- *
4
- * The parser covers a deliberate subset of TOML: `[table.name]` headers, and
5
- * `key = ["a", "b"]` / `key = "a"` values. That is the whole config language,
6
- * and keeping it in-house is why installing seisin pulls in exactly one
7
- * dependency (the sandbox runtime) instead of a parser too.
8
- *
9
- * If your config outgrows this subset, the config is doing too much.
2
+ * Reads and validates seisin.toml (or a JSON policy): the shape, every role's
3
+ * fields, the sandbox and key providers. The TOML subset is in toml.js.
10
4
  */
11
5
  import { createHash } from "node:crypto";
12
6
  import { readFileSync, existsSync } from "node:fs";
13
7
  import { resolve, dirname, join } from "node:path";
14
- import { isIP } from "node:net";
15
8
  import { CONFIG_NAME } from "./layout.js";
16
9
  import { parseKey, MODES, checkNames, BUILTIN, unknownMode } from "./keys.js";
17
- import { readNotify } from "./notify.js";
18
- import { BROWSERS } from "./browsers.js";
10
+ import { readNotify } from "./notify-config.js";
11
+ import { FORBIDDEN, dict, isTable, own, LINES, parseToml } from "./toml.js";
12
+
13
+ export { own, parseToml, closesArray, stripComment } from "./toml.js";
14
+ import {
15
+ closest, readBoolean, ROLE_KEYS, readControlFiles, readProtect, readMcp, readBrowsers, readLocalPorts, readInjectHosts, readNeverWrites, readPrivate, checkPrivateOverlap, assertNotAbsolute, readAddresses, asArray, trimSlashes, beforeGlob,
16
+ } from "./config-fields.js";
17
+ export { closest, ROLE_KEYS, CONTROL_FAMILIES, privateDir } from "./config-fields.js";
18
+
19
+
19
20
 
20
21
  export { CONFIG_NAME } from "./layout.js";
21
22
 
@@ -31,247 +32,6 @@ export function findConfig(from = process.cwd()) {
31
32
  }
32
33
  }
33
34
 
34
- const RE_TABLE = /^\[([A-Za-z0-9_.\-]+)\]$/;
35
- const RE_PAIR = /^([A-Za-z0-9_\-]+)\s*=\s*(.+)$/;
36
-
37
- /**
38
- * Names a table or key may not have.
39
- *
40
- * `[roles.__proto__]` does not show up in `Object.keys(roles)`, so `check`
41
- * prints the roles that exist and says nothing — while every other role
42
- * inherits whatever it declared. A config reading `[roles.frontend]` with no
43
- * `writes` came out of the parser owning the entire repo and holding
44
- * GITHUB_TOKEN, and no amount of reading the file would show it.
45
- *
46
- * The tables below are made with a null prototype, which stops the inheritance
47
- * on its own. The names are refused as well, because a config containing them
48
- * is not a config with a mistake in it.
49
- */
50
- const FORBIDDEN = new Set(["__proto__", "prototype", "constructor"]);
51
-
52
- /** A table with no prototype, so nothing is inherited into it. */
53
- function dict() {
54
- return Object.create(null);
55
- }
56
-
57
- const isTable = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
58
-
59
- /** Read a property only if the object actually has it. */
60
- export function own(obj, key) {
61
- return obj && Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
62
- }
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. */
74
- export function parseToml(text) {
75
- const out = dict();
76
- const where = new Map();
77
- LINES.set(out, where);
78
- let table = out;
79
- let tableName = "";
80
- // "Last one wins" is TOML-ish and wrong for a permission file. A policy can
81
- // look restrictive at the top and be cancelled forty lines down, and the
82
- // person reviewing it reads the first block. Both shapes are refused.
83
- const seenTables = new Set();
84
- const seenKeys = new Set();
85
- const lines = text.split(/\r?\n/);
86
-
87
- for (let i = 0; i < lines.length; i++) {
88
- const raw = lines[i];
89
- const line = stripComment(raw).trim();
90
- if (!line) continue;
91
-
92
- const header = RE_TABLE.exec(line);
93
- if (header) {
94
- tableName = header[1];
95
- for (const part of tableName.split("."))
96
- if (FORBIDDEN.has(part))
97
- throw new Error(
98
- `${CONFIG_NAME}:${i + 1}: "[${tableName}]" uses the reserved name "${part}". ` +
99
- `A table named that would not appear as a role and would hand its settings to ` +
100
- `every other one.`);
101
- if (seenTables.has(tableName))
102
- throw new Error(
103
- `${CONFIG_NAME}:${i + 1}: [${tableName}] appears twice. ` +
104
- `A later table would silently override the earlier one — put every setting for ` +
105
- `${tableName} in one place.`);
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
- });
111
- table = tableName.split(".").reduce((node, key, k, parts) => {
112
- if (!Object.prototype.hasOwnProperty.call(node, key)) node[key] = dict();
113
- else if (!isTable(node[key]))
114
- throw new Error(
115
- `${CONFIG_NAME}:${i + 1}: [${tableName}] — "${parts.slice(0, k + 1).join(".")}" is already ` +
116
- `a value, not a table. One name cannot be both.`);
117
- return node[key];
118
- }, out);
119
- continue;
120
- }
121
-
122
- const pair = RE_PAIR.exec(line);
123
- if (!pair) throw new Error(`${CONFIG_NAME}:${i + 1}: cannot read "${raw.trim()}"`);
124
-
125
- let [, key, value] = pair;
126
- const at = i + 1;
127
- // An array may span lines; keep pulling until the brackets balance. Only
128
- // the new line is searched: searching the whole growing value made an
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)) {
132
- const parts = [value];
133
- let closed = false;
134
- while (!closed && i + 1 < lines.length) {
135
- const next = stripComment(lines[++i]).trim();
136
- parts.push(next);
137
- closed = closesArray(next);
138
- }
139
- value = parts.join(" ");
140
- }
141
- if (FORBIDDEN.has(key))
142
- throw new Error(`${CONFIG_NAME}:${i + 1}: "${key}" is a reserved name and cannot be a setting.`);
143
- const seen = `${tableName}.${key}`;
144
- if (seenKeys.has(seen))
145
- throw new Error(
146
- `${CONFIG_NAME}:${i + 1}: "${key}" is set twice in [${tableName || "the root"}]. ` +
147
- `The second would win, which is not a thing a permission file should do quietly.`);
148
- seenKeys.add(seen);
149
- where.set(tableName ? `${tableName}.${key}` : key, at);
150
- /**
151
- * A key may not be a table that is declared elsewhere, in either order.
152
- *
153
- * `[roles.a]` with `network = []`, then `[roles]` and `a = true` forty lines
154
- * down, used to replace the whole role with `true`: its `network = []` came
155
- * back as "not set", which means the global allow list. `check` said
156
- * nothing. It is the "last one wins" this parser refuses everywhere else,
157
- * arriving by a different spelling.
158
- */
159
- if (Object.prototype.hasOwnProperty.call(table, key))
160
- throw new Error(
161
- `${CONFIG_NAME}:${i + 1}: "${key}" in [${tableName || "the root"}] is already the table ` +
162
- `[${tableName ? tableName + "." : ""}${key}]. One name cannot be both — the value would replace ` +
163
- `everything that table says.`);
164
- table[key] = readValue(value, i + 1);
165
- }
166
- return out;
167
- }
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
-
180
- /** Strips a trailing `#` comment, but not one inside a quoted string. */
181
- export function stripComment(line) {
182
- let quoted = false;
183
- for (let i = 0; i < line.length; i++) {
184
- const c = line[i];
185
- if (c === '"') quoted = !quoted;
186
- else if (c === "#" && !quoted) return line.slice(0, i);
187
- }
188
- return line;
189
- }
190
-
191
- /**
192
- * One value, read strictly.
193
- *
194
- * Strict is the whole point, and it was not before. The array reader used to
195
- * pick quoted runs out of the line with a regex and ignore everything between
196
- * them, so `["a" GARBAGE "b"]` parsed as `["a","b"]` and an unterminated string
197
- * parsed as `""`. A permission file that is half-understood is worse than one
198
- * that is rejected: the half that was dropped is the half you meant.
199
- *
200
- * So this scans rather than matches, and anything it does not recognise is an
201
- * error with a line number. There are no escapes — a `"` ends the string. If a
202
- * path of yours needs a quote in it, that is outside this subset and the
203
- * message says so instead of guessing.
204
- */
205
- function readValue(value, lineNo) {
206
- const v = value.trim();
207
- const bad = (msg) => { throw new Error(`${CONFIG_NAME}:${lineNo}: ${msg}`); };
208
-
209
- if (v === "true") return true;
210
- if (v === "false") return false;
211
-
212
- if (v.startsWith('"')) {
213
- const end = v.indexOf('"', 1);
214
- if (end === -1) bad(`unterminated string — no closing quote in ${v}`);
215
- const rest = v.slice(end + 1).trim();
216
- if (rest) bad(`unexpected ${JSON.stringify(rest)} after the string`);
217
- return v.slice(1, end);
218
- }
219
-
220
- if (v.startsWith("[")) {
221
- if (!v.endsWith("]")) bad("unterminated array — no closing bracket");
222
- const items = [];
223
- let i = 1;
224
- const body = v.slice(0, -1); // everything up to the closing bracket
225
- let expectItem = true; // a list alternates item, comma, item…
226
-
227
- while (i < body.length) {
228
- const c = body[i];
229
- if (c === " " || c === "\t") { i++; continue; }
230
-
231
- if (c === ",") {
232
- if (expectItem) bad("empty array slot — two commas in a row, or a leading comma");
233
- expectItem = true; i++; continue;
234
- }
235
- if (!expectItem) bad(`missing comma before ${JSON.stringify(body.slice(i, i + 12))}`);
236
- // Whole numbers, for `local_ports`. Digits only: no sign, no decimal, no
237
- // underscore — a port is none of those, and every other key still
238
- // refuses a number through asArray.
239
- const digits = /^\d+/.exec(body.slice(i));
240
- if (digits) {
241
- const after = body[i + digits[0].length];
242
- if (after !== undefined && after !== "," && after !== " " && after !== "\t")
243
- bad(`${JSON.stringify(body.slice(i, i + 12))} is not a whole number`);
244
- items.push(Number(digits[0]));
245
- i += digits[0].length;
246
- expectItem = false;
247
- continue;
248
- }
249
- if (c !== '"') bad(`array items must be double-quoted, found ${JSON.stringify(body.slice(i, i + 12))}`);
250
-
251
- const end = body.indexOf('"', i + 1);
252
- if (end === -1) bad("unterminated string inside the array");
253
- // A backslash before the closing quote means somebody wrote `\"` and
254
- // expected an escape. There are none — the quote just ended the string,
255
- // and the error they get otherwise is about a missing comma somewhere in
256
- // the middle of a shell pipeline, which names the symptom and hides the
257
- // cause. Measured while writing the docs: every attempt at a one-line
258
- // provider command landed here.
259
- if (body[end - 1] === "\\")
260
- bad(`there are no escapes in this format, so the \\" in ` +
261
- `${JSON.stringify(body.slice(i, Math.min(end + 2, i + 40)))} ended the string early.\n` +
262
- ` A value cannot contain a double quote. If this is a shell command, put it in a ` +
263
- `script and name the script instead — which is also how it stops being unreadable.`);
264
- items.push(body.slice(i + 1, end));
265
- i = end + 1;
266
- expectItem = false; // a trailing comma is fine; a trailing item is not
267
- }
268
- return items;
269
- }
270
-
271
- if (/^\d+$/.test(v)) return Number(v);
272
-
273
- bad("value must be a string, an array of strings, a whole number, or a boolean");
274
- }
275
35
 
276
36
  /**
277
37
  * `[runtime] isolate` — one setting, two levels, because they were one and
@@ -329,7 +89,7 @@ function readReadList(v, at) {
329
89
  if (/[*?[\]{}]/.test(above) || p === "**")
330
90
  throw new Error(
331
91
  `${at} = "${p}" cannot be enforced as written.\n` +
332
- ` A read grant is a path and everything under it. Write "${above.replace(/\/?[^/]*[*?[\]{}].*$/, "") || "."}/**" ` +
92
+ ` A read grant is a path and everything under it. Write "${beforeGlob(above) || "."}/**" ` +
333
93
  `for the subtree, or name the paths.`);
334
94
  }
335
95
  return list;
@@ -365,10 +125,10 @@ export function policyId(config) {
365
125
  * settings file rather than falling back to a permissive default, and seisin
366
126
  * matches that: a permission tool that guesses is worse than no permission tool.
367
127
  */
368
- export function loadConfig(path, text = readFileSync(path, "utf8")) {
128
+ export function loadConfig(path, text = readFileSync(path, "utf8"), { root = null } = {}) {
369
129
  // `text` lets a caller validate an edit before writing it — the console
370
130
  // previews a policy that does not exist on disk yet, against the real root.
371
- const parsed = parseToml(text);
131
+ const parsed = String(path).endsWith(".json") ? parseJsonPolicy(text, path) : parseToml(text);
372
132
  checkShape(parsed, SHAPE, "", path, LINES.get(parsed));
373
133
  // Read only what the file actually declared. The parser refuses the names
374
134
  // that make inheritance possible, and this is the second half of the same
@@ -436,7 +196,7 @@ export function loadConfig(path, text = readFileSync(path, "utf8")) {
436
196
  // them as prefixes: with the slash kept, `scan` walked into the protected
437
197
  // directory and a bare key resolved to `.secrets//x`, which matched nothing.
438
198
  const keyDirs = (keyDir === undefined ? [] : asArray(keyDir, "keys.dir"))
439
- .map((d) => d.replace(/\/+$/, "") || d);
199
+ .map((d) => trimSlashes(d) || d);
440
200
  const keyProviders = readProviders(own(own(parsed, "keys"), "providers"), path);
441
201
  // `undefined` means "use the defaults"; an explicit empty array means "none".
442
202
  // The difference matters: one is a user who has not thought about it, the
@@ -445,7 +205,10 @@ export function loadConfig(path, text = readFileSync(path, "utf8")) {
445
205
  const runtimeWrites = own(runtime, "writes");
446
206
 
447
207
  const out = {
448
- root: dirname(path), path, keyDirs, keyProviders,
208
+ // The root is the policy's folder, unless the policy was read from elsewhere
209
+ // (`--policy`): then it is the folder the command runs in, and the policy
210
+ // stays outside the repo it governs.
211
+ root: root ? resolve(root) : dirname(path), path, keyDirs, keyProviders,
449
212
  // Through asArray like every other list: `allow = "github.com"` was kept as
450
213
  // a string, and a string iterated is its characters.
451
214
  allowedDomains: asArray(own(own(parsed, "network"), "allow"), "network.allow"),
@@ -458,7 +221,7 @@ export function loadConfig(path, text = readFileSync(path, "utf8")) {
458
221
  deniedAddresses: readAddresses(own(own(parsed, "network"), "deny_addresses"), `${path}: network.deny_addresses`),
459
222
  runtimeWrites: runtimeWrites === undefined ? undefined : asArray(runtimeWrites, "runtime.writes"),
460
223
  // `[runtime] isolate` — how much of your home a role stops reaching. See
461
- // srt.js for what each level emits, and readIsolate() below for why there
224
+ // srt-settings.js for what each level emits, and readIsolate() below for why there
462
225
  // are two of them.
463
226
  isolate: readIsolate(own(runtime, "isolate")),
464
227
  // `[runtime] read` — what a role may read. "all" is every file but the ones
@@ -537,6 +300,37 @@ export function loadConfig(path, text = readFileSync(path, "utf8")) {
537
300
  const toolchain = readReadList(own(r, "toolchain"), `${path}: roles.${name}.toolchain`);
538
301
  const priv = readPrivate(own(r, "private"), `${path}: roles.${name}.private`);
539
302
  const verify = own(r, "verify") === undefined ? null : asArray(own(r, "verify"), `roles.${name}.verify`);
303
+ /**
304
+ * `handoff_exempt`: folders where the trust-handoff set is not protected, for a
305
+ * setup phase that fills a dependency tree whose packages ship their own
306
+ * `.gitattributes`, `.github/` or `.git/` (`composer install`, measured: 326
307
+ * denials). Relative to the repo, no `..`, no globs. Only a sandbox that can
308
+ * open exactly that (tartaros) is handed the role.
309
+ */
310
+ const handoffExempt = own(r, "handoff_exempt") === undefined ? [] : asArray(own(r, "handoff_exempt"), `roles.${name}.handoff_exempt`);
311
+ /**
312
+ * `requests`: the only requests the role may send to an allowed host,
313
+ * `"METHODS host[/path]"` (METHODS: `GET,POST` or `*`). An allowed host is
314
+ * otherwise all of it: a token for api.github.com pushes to any repository
315
+ * it reaches. The host must be in `network`. Only tartaros enforces it
316
+ * (from 0.1.0-alpha.18): it terminates that host and refuses anything else.
317
+ */
318
+ const requests = own(r, "requests") === undefined ? [] : asArray(own(r, "requests"), `roles.${name}.requests`).map((spec) => {
319
+ const m = /^(\*|[A-Z]+(?:,[A-Z]+)*)\s+([A-Za-z0-9.-]+)(\/\S*)?$/.exec(String(spec).trim());
320
+ if (!m || (m[3] ?? "").split("/").some((seg) => seg === "." || seg === ".."))
321
+ throw new Error(`${path}: roles.${name}.requests "${spec}": "METHODS host[/path]", e.g. "GET api.github.com" or "POST,PATCH api.github.com/repos/acme/"`);
322
+ return { host: m[2], methods: m[1] === "*" ? [] : m[1].split(","), paths: m[3] ? [m[3]] : [] };
323
+ });
324
+ // A rule narrows a host the role may reach; on a host it may not, it says nothing.
325
+ const reach = own(r, "network") === undefined ? [] : asArray(own(r, "network"), `roles.${name}.network`);
326
+ for (const q of requests) {
327
+ const covered = reach.some((h) => h === q.host || (h.startsWith("*.") && q.host.endsWith(h.slice(1))));
328
+ if (!covered)
329
+ throw new Error(`${path}: roles.${name}.requests: ${q.host} is not in roles.${name}.network, so the role cannot reach it at all`);
330
+ }
331
+ for (const e of handoffExempt)
332
+ if (e.startsWith("/") || e.startsWith("~") || e.split("/").includes("..") || /[*?[\]]/.test(e) || !e.trim())
333
+ throw new Error(`${path}: roles.${name}.handoff_exempt "${e}": a folder in the repo, relative, with no ".." and no globs`);
540
334
  if (verify && verify.length === 0)
541
335
  throw new Error(`${path}: roles.${name}.verify is empty. It is the command that proves the role can check ` +
542
336
  `its own work — ["python", "-m", "pytest", "--version"] — or leave it out.`);
@@ -604,7 +398,7 @@ export function loadConfig(path, text = readFileSync(path, "utf8")) {
604
398
  * every port on localhost (macOS), which includes whatever control API
605
399
  * is listening there without a password. This names the ports instead,
606
400
  * and the runtime's proxy enforces the list — the same allowlist the
607
- * domains go through. See srt.js for what that costs: the client has to
401
+ * domains go through. See srt-settings.js for what that costs: the client has to
608
402
  * go through the proxy, which HTTP clients do and a MySQL driver does not.
609
403
  */
610
404
  localPorts,
@@ -626,7 +420,7 @@ export function loadConfig(path, text = readFileSync(path, "utf8")) {
626
420
  * Whether this role may reach com.apple.trustd.agent (macOS). Needed by
627
421
  * Dart/Flutter and by Go built before 1.27 to verify TLS at all; Go 1.27+
628
422
  * does without it through the SSL_CERT_FILE that env.js sets. Off unless
629
- * the role says so. See srt.js for what it costs.
423
+ * the role says so. See srt-settings.js for what it costs.
630
424
  */
631
425
  trustd: trustd === true,
632
426
  /**
@@ -644,6 +438,10 @@ export function loadConfig(path, text = readFileSync(path, "utf8")) {
644
438
  * another owner or to a person instead of starting it.
645
439
  */
646
440
  handoff: handoff !== false,
441
+ /** Folders, relative to the root, where trust-handoff is not protected (tartaros only). */
442
+ handoffExempt,
443
+ /** The only requests to an allowed host: `{ host, methods, paths }` (tartaros only). */
444
+ requests,
647
445
  /**
648
446
  * What this role reads beyond its repo, under `read = "territory"`.
649
447
  *
@@ -879,284 +677,66 @@ function checkShape(table, shape, prefix, path, lines) {
879
677
  }
880
678
  }
881
679
 
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
-
912
- /** The keys a `[roles.<name>]` table can hold. Anything else is reported by `check`. */
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
- }
953
-
954
- /**
955
- * `mcp`: names of MCP servers, as the CLI's config calls them. Absent is null,
956
- * not [], so a policy that never mentions MCP changes nothing for a launcher
957
- * that reads it; `mcp = []` is the explicit "none".
958
- */
959
- function readMcp(value, where) {
960
- if (value === undefined) return null;
961
- const list = asArray(value, where);
962
- const out = [];
963
- for (const n of list) {
964
- if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(n))
965
- throw new Error(`${where}: ${JSON.stringify(n)} is not an MCP server name. ` +
966
- `Use the name from the CLI's config, for example mcp = ["playwright"].`);
967
- if (!out.includes(n)) out.push(n);
968
- }
969
- return out;
970
- }
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
-
989
680
  /**
990
- * `local_ports`, refused rather than guessed when it is not a list of ports.
681
+ * A policy written as JSON (`--policy run.json`), for a launcher that generates
682
+ * one: JSON can hold a quote in a value, which the TOML subset cannot, and a
683
+ * program writing the policy should not have to avoid them.
991
684
  *
992
- * Refused at load for the same reason as `never_writes`: a port list that loads
993
- * with a typo in it opens a port nobody meant, or silently fails to open the
994
- * one they did. A string is accepted when it is only digits, because TOML
995
- * written by hand says `"8001"` as often as `8001`.
685
+ * The same data and the same strictness as the TOML reader: tables, strings,
686
+ * whole numbers, booleans, and arrays of strings or whole numbers, nothing
687
+ * else; the forbidden names refused; every table with a null prototype; and a
688
+ * key written twice in one object refused, because JSON.parse keeps the last
689
+ * one, and a policy that is restrictive at the top and cancelled further down
690
+ * is exactly what the TOML reader refuses too. A JSON policy is not edited by
691
+ * `grant`: it is generated, so it is changed where it is generated.
996
692
  */
997
- function readLocalPorts(value, where) {
998
- if (value === undefined) return [];
999
- const list = Array.isArray(value) ? value : [value];
1000
- const out = [];
1001
- for (const v of list) {
1002
- const n = typeof v === "string" && /^\d+$/.test(v) ? Number(v) : v;
1003
- if (!Number.isInteger(n) || n < 1 || n > 65535)
1004
- throw new Error(`${where}: ${JSON.stringify(v)} is not a port. Write numbers from 1 to 65535, ` +
1005
- `for example local_ports = [8001, 8081].`);
1006
- if (!out.includes(n)) out.push(n);
1007
- }
1008
- return out;
1009
- }
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".`);
693
+ export function parseJsonPolicy(text, path = "policy.json") {
694
+ let raw;
695
+ try { raw = JSON.parse(text); } catch (e) { throw new Error(`${path}: not JSON — ${e.message}`); }
696
+ // Duplicate keys, which JSON.parse folds away: one pass over the text, which
697
+ // JSON.parse has just said is well formed.
698
+ const stack = [];
699
+ for (let i = 0; i < text.length; i++) {
700
+ const c = text[i];
701
+ if (c === "{") stack.push({ obj: true, keys: new Set(), key: true });
702
+ else if (c === "[") stack.push({ obj: false });
703
+ else if (c === "}" || c === "]") stack.pop();
704
+ else if (c === "," && stack.at(-1)?.obj) stack.at(-1).key = true;
705
+ else if (c === '"') {
706
+ let j = i + 1;
707
+ while (text[j] !== '"') j += text[j] === "\\" ? 2 : 1;
708
+ const top = stack.at(-1);
709
+ if (top?.obj && top.key) {
710
+ const k = JSON.parse(text.slice(i, j + 1));
711
+ if (top.keys.has(k)) throw new Error(`${path}: "${k}" is written twice in one object; the last would silently win.`);
712
+ top.keys.add(k);
713
+ top.key = false;
714
+ }
715
+ i = j;
1044
716
  }
1045
- out[name] = [...new Set(list)];
1046
- }
1047
- return out;
1048
- }
1049
-
1050
- /**
1051
- * `never_writes`, refused rather than guessed when it cannot mean what it says.
1052
- *
1053
- * Refused at load, not warned about: this is a list of things a role must not
1054
- * do, and a malformed entry that loads anyway fails open. An absolute path or a
1055
- * `..` would point outside the repo the rest of the policy is written against,
1056
- * so it cannot be the subtraction the person meant.
1057
- */
1058
- function readNeverWrites(value, where) {
1059
- const list = asArray(value, where);
1060
- for (const g of list) {
1061
- if (typeof g !== "string" || g.trim() === "")
1062
- throw new Error(`${where}: every entry must be a non-empty string`);
1063
- if (g.startsWith("/"))
1064
- throw new Error(`${where}: "${g}" is absolute. never_writes is relative to the repo root, like writes.`);
1065
- if (g.split("/").includes(".."))
1066
- throw new Error(`${where}: "${g}" contains "..". never_writes names paths inside the repo.`);
1067
717
  }
1068
- return list;
1069
- }
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.`);
718
+ const walk = (v, name) => {
719
+ if (typeof v === "string" || typeof v === "boolean") return v;
720
+ if (typeof v === "number") {
721
+ if (!Number.isInteger(v) || v < 0) throw new Error(`${path}: ${name} = ${v} — only whole numbers`);
722
+ return v;
1118
723
  }
1119
- }
1120
-
1121
- /**
1122
- * A territory is relative to the repo root. An absolute one would ask the kernel
1123
- * for a path on the machine and make `explain` (which reads absolute as outside
1124
- * the repo) disagree with what the profile grants — see the CODEOWNERS `/apps/`
1125
- * case. `..` is allowed on purpose: a role legitimately writes a sibling
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.
1129
- */
1130
- function assertNotAbsolute(list, where) {
1131
- for (const g of list) {
1132
- if (typeof g !== "string" || g.trim() === "")
1133
- throw new Error(`${where}: every entry must be a non-empty string`);
1134
- if (g.startsWith("/"))
1135
- throw new Error(`${where}: "${g}" is absolute. Territories are relative to the repo root.`);
1136
- }
1137
- return list;
1138
- }
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
-
1157
- function asArray(value, where) {
1158
- if (value === undefined) return [];
1159
- if (typeof value === "string") return [value];
1160
- if (Array.isArray(value) && value.every((v) => typeof v === "string")) return value;
1161
- throw new Error(`${where}: expected a string or an array of strings`);
724
+ if (Array.isArray(v)) {
725
+ return v.map((x, i) => {
726
+ if (typeof x === "string" || (typeof x === "number" && Number.isInteger(x) && x >= 0)) return x;
727
+ throw new Error(`${path}: ${name}[${i}] — array items are strings or whole numbers`);
728
+ });
729
+ }
730
+ if (isTable(v)) {
731
+ const t = dict();
732
+ for (const [k, x] of Object.entries(v)) {
733
+ if (FORBIDDEN.has(k)) throw new Error(`${path}: "${k}" is not a name a table or key may have`);
734
+ t[k] = walk(x, name ? `${name}.${k}` : k);
735
+ }
736
+ return t;
737
+ }
738
+ throw new Error(`${path}: ${name} = ${JSON.stringify(v)} — a value is a string, a whole number, a boolean, an array or a table`);
739
+ };
740
+ if (!isTable(raw)) throw new Error(`${path}: the policy is an object`);
741
+ return walk(raw, "");
1162
742
  }