seisin 0.6.4 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/config.js CHANGED
@@ -1,36 +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";
19
18
 
20
- /** `a/b//` → `a/b`, in linear time (a `/\/+$/` regex backtracks on a long run of slashes). */
21
- const trimSlashes = (p) => {
22
- let end = p.length;
23
- while (end > 0 && p[end - 1] === "/") end--;
24
- return p.slice(0, end);
25
- };
26
19
 
27
- // The directory above a path's first glob character: "src/a*/b" gives "src", "a*" gives "".
28
- const beforeGlob = (p) => {
29
- const g = p.search(/[*?[\]{}]/);
30
- if (g < 0) return p;
31
- const slash = p.lastIndexOf("/", g);
32
- return slash < 0 ? "" : p.slice(0, slash);
33
- };
34
20
 
35
21
  export { CONFIG_NAME } from "./layout.js";
36
22
 
@@ -46,247 +32,6 @@ export function findConfig(from = process.cwd()) {
46
32
  }
47
33
  }
48
34
 
49
- const RE_TABLE = /^\[([A-Za-z0-9_.\-]+)\]$/;
50
- const RE_PAIR = /^([A-Za-z0-9_\-]+)\s*=\s*(.+)$/;
51
-
52
- /**
53
- * Names a table or key may not have.
54
- *
55
- * `[roles.__proto__]` does not show up in `Object.keys(roles)`, so `check`
56
- * prints the roles that exist and says nothing — while every other role
57
- * inherits whatever it declared. A config reading `[roles.frontend]` with no
58
- * `writes` came out of the parser owning the entire repo and holding
59
- * GITHUB_TOKEN, and no amount of reading the file would show it.
60
- *
61
- * The tables below are made with a null prototype, which stops the inheritance
62
- * on its own. The names are refused as well, because a config containing them
63
- * is not a config with a mistake in it.
64
- */
65
- const FORBIDDEN = new Set(["__proto__", "prototype", "constructor"]);
66
-
67
- /** A table with no prototype, so nothing is inherited into it. */
68
- function dict() {
69
- return Object.create(null);
70
- }
71
-
72
- const isTable = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
73
-
74
- /** Read a property only if the object actually has it. */
75
- export function own(obj, key) {
76
- return obj && Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
77
- }
78
-
79
- /**
80
- * Where each table and key of a parsed file was written, by dotted name.
81
- *
82
- * Kept beside the result rather than in it, so the object stays exactly the
83
- * data that was written; loadConfig reads it to put a line number on a name it
84
- * does not know.
85
- */
86
- const LINES = new WeakMap();
87
-
88
- /** Minimal TOML subset -> plain object. Throws with a line number on bad input. */
89
- export function parseToml(text) {
90
- const out = dict();
91
- const where = new Map();
92
- LINES.set(out, where);
93
- let table = out;
94
- let tableName = "";
95
- // "Last one wins" is TOML-ish and wrong for a permission file. A policy can
96
- // look restrictive at the top and be cancelled forty lines down, and the
97
- // person reviewing it reads the first block. Both shapes are refused.
98
- const seenTables = new Set();
99
- const seenKeys = new Set();
100
- const lines = text.split(/\r?\n/);
101
-
102
- for (let i = 0; i < lines.length; i++) {
103
- const raw = lines[i];
104
- const line = stripComment(raw).trim();
105
- if (!line) continue;
106
-
107
- const header = RE_TABLE.exec(line);
108
- if (header) {
109
- tableName = header[1];
110
- for (const part of tableName.split("."))
111
- if (FORBIDDEN.has(part))
112
- throw new Error(
113
- `${CONFIG_NAME}:${i + 1}: "[${tableName}]" uses the reserved name "${part}". ` +
114
- `A table named that would not appear as a role and would hand its settings to ` +
115
- `every other one.`);
116
- if (seenTables.has(tableName))
117
- throw new Error(
118
- `${CONFIG_NAME}:${i + 1}: [${tableName}] appears twice. ` +
119
- `A later table would silently override the earlier one — put every setting for ` +
120
- `${tableName} in one place.`);
121
- seenTables.add(tableName);
122
- tableName.split(".").forEach((_, k, parts) => {
123
- const name = parts.slice(0, k + 1).join(".");
124
- if (!where.has(name)) where.set(name, i + 1);
125
- });
126
- table = tableName.split(".").reduce((node, key, k, parts) => {
127
- if (!Object.prototype.hasOwnProperty.call(node, key)) node[key] = dict();
128
- else if (!isTable(node[key]))
129
- throw new Error(
130
- `${CONFIG_NAME}:${i + 1}: [${tableName}] — "${parts.slice(0, k + 1).join(".")}" is already ` +
131
- `a value, not a table. One name cannot be both.`);
132
- return node[key];
133
- }, out);
134
- continue;
135
- }
136
-
137
- const pair = RE_PAIR.exec(line);
138
- if (!pair) throw new Error(`${CONFIG_NAME}:${i + 1}: cannot read "${raw.trim()}"`);
139
-
140
- let [, key, value] = pair;
141
- const at = i + 1;
142
- // An array may span lines; keep pulling until the brackets balance. Only
143
- // the new line is searched: searching the whole growing value made an
144
- // unclosed array of 100k lines take 20 s to be refused. A `]` inside a
145
- // quoted item does not close it — `["a]b",` used to end the array there.
146
- if (value.startsWith("[") && !closesArray(value)) {
147
- const parts = [value];
148
- let closed = false;
149
- while (!closed && i + 1 < lines.length) {
150
- const next = stripComment(lines[++i]).trim();
151
- parts.push(next);
152
- closed = closesArray(next);
153
- }
154
- value = parts.join(" ");
155
- }
156
- if (FORBIDDEN.has(key))
157
- throw new Error(`${CONFIG_NAME}:${i + 1}: "${key}" is a reserved name and cannot be a setting.`);
158
- const seen = `${tableName}.${key}`;
159
- if (seenKeys.has(seen))
160
- throw new Error(
161
- `${CONFIG_NAME}:${i + 1}: "${key}" is set twice in [${tableName || "the root"}]. ` +
162
- `The second would win, which is not a thing a permission file should do quietly.`);
163
- seenKeys.add(seen);
164
- where.set(tableName ? `${tableName}.${key}` : key, at);
165
- /**
166
- * A key may not be a table that is declared elsewhere, in either order.
167
- *
168
- * `[roles.a]` with `network = []`, then `[roles]` and `a = true` forty lines
169
- * down, used to replace the whole role with `true`: its `network = []` came
170
- * back as "not set", which means the global allow list. `check` said
171
- * nothing. It is the "last one wins" this parser refuses everywhere else,
172
- * arriving by a different spelling.
173
- */
174
- if (Object.prototype.hasOwnProperty.call(table, key))
175
- throw new Error(
176
- `${CONFIG_NAME}:${i + 1}: "${key}" in [${tableName || "the root"}] is already the table ` +
177
- `[${tableName ? tableName + "." : ""}${key}]. One name cannot be both — the value would replace ` +
178
- `everything that table says.`);
179
- table[key] = readValue(value, i + 1);
180
- }
181
- return out;
182
- }
183
-
184
- /** Is there a `]` on this line outside a quoted string? Strings do not span lines. */
185
- export function closesArray(line) {
186
- let quoted = false;
187
- for (let i = 0; i < line.length; i++) {
188
- const c = line[i];
189
- if (c === '"') quoted = !quoted;
190
- else if (c === "]" && !quoted) return true;
191
- }
192
- return false;
193
- }
194
-
195
- /** Strips a trailing `#` comment, but not one inside a quoted string. */
196
- export function stripComment(line) {
197
- let quoted = false;
198
- for (let i = 0; i < line.length; i++) {
199
- const c = line[i];
200
- if (c === '"') quoted = !quoted;
201
- else if (c === "#" && !quoted) return line.slice(0, i);
202
- }
203
- return line;
204
- }
205
-
206
- /**
207
- * One value, read strictly.
208
- *
209
- * Strict is the whole point, and it was not before. The array reader used to
210
- * pick quoted runs out of the line with a regex and ignore everything between
211
- * them, so `["a" GARBAGE "b"]` parsed as `["a","b"]` and an unterminated string
212
- * parsed as `""`. A permission file that is half-understood is worse than one
213
- * that is rejected: the half that was dropped is the half you meant.
214
- *
215
- * So this scans rather than matches, and anything it does not recognise is an
216
- * error with a line number. There are no escapes — a `"` ends the string. If a
217
- * path of yours needs a quote in it, that is outside this subset and the
218
- * message says so instead of guessing.
219
- */
220
- function readValue(value, lineNo) {
221
- const v = value.trim();
222
- const bad = (msg) => { throw new Error(`${CONFIG_NAME}:${lineNo}: ${msg}`); };
223
-
224
- if (v === "true") return true;
225
- if (v === "false") return false;
226
-
227
- if (v.startsWith('"')) {
228
- const end = v.indexOf('"', 1);
229
- if (end === -1) bad(`unterminated string — no closing quote in ${v}`);
230
- const rest = v.slice(end + 1).trim();
231
- if (rest) bad(`unexpected ${JSON.stringify(rest)} after the string`);
232
- return v.slice(1, end);
233
- }
234
-
235
- if (v.startsWith("[")) {
236
- if (!v.endsWith("]")) bad("unterminated array — no closing bracket");
237
- const items = [];
238
- let i = 1;
239
- const body = v.slice(0, -1); // everything up to the closing bracket
240
- let expectItem = true; // a list alternates item, comma, item…
241
-
242
- while (i < body.length) {
243
- const c = body[i];
244
- if (c === " " || c === "\t") { i++; continue; }
245
-
246
- if (c === ",") {
247
- if (expectItem) bad("empty array slot — two commas in a row, or a leading comma");
248
- expectItem = true; i++; continue;
249
- }
250
- if (!expectItem) bad(`missing comma before ${JSON.stringify(body.slice(i, i + 12))}`);
251
- // Whole numbers, for `local_ports`. Digits only: no sign, no decimal, no
252
- // underscore — a port is none of those, and every other key still
253
- // refuses a number through asArray.
254
- const digits = /^\d+/.exec(body.slice(i));
255
- if (digits) {
256
- const after = body[i + digits[0].length];
257
- if (after !== undefined && after !== "," && after !== " " && after !== "\t")
258
- bad(`${JSON.stringify(body.slice(i, i + 12))} is not a whole number`);
259
- items.push(Number(digits[0]));
260
- i += digits[0].length;
261
- expectItem = false;
262
- continue;
263
- }
264
- if (c !== '"') bad(`array items must be double-quoted, found ${JSON.stringify(body.slice(i, i + 12))}`);
265
-
266
- const end = body.indexOf('"', i + 1);
267
- if (end === -1) bad("unterminated string inside the array");
268
- // A backslash before the closing quote means somebody wrote `\"` and
269
- // expected an escape. There are none — the quote just ended the string,
270
- // and the error they get otherwise is about a missing comma somewhere in
271
- // the middle of a shell pipeline, which names the symptom and hides the
272
- // cause. Measured while writing the docs: every attempt at a one-line
273
- // provider command landed here.
274
- if (body[end - 1] === "\\")
275
- bad(`there are no escapes in this format, so the \\" in ` +
276
- `${JSON.stringify(body.slice(i, Math.min(end + 2, i + 40)))} ended the string early.\n` +
277
- ` A value cannot contain a double quote. If this is a shell command, put it in a ` +
278
- `script and name the script instead — which is also how it stops being unreadable.`);
279
- items.push(body.slice(i + 1, end));
280
- i = end + 1;
281
- expectItem = false; // a trailing comma is fine; a trailing item is not
282
- }
283
- return items;
284
- }
285
-
286
- if (/^\d+$/.test(v)) return Number(v);
287
-
288
- bad("value must be a string, an array of strings, a whole number, or a boolean");
289
- }
290
35
 
291
36
  /**
292
37
  * `[runtime] isolate` — one setting, two levels, because they were one and
@@ -476,7 +221,7 @@ export function loadConfig(path, text = readFileSync(path, "utf8"), { root = nul
476
221
  deniedAddresses: readAddresses(own(own(parsed, "network"), "deny_addresses"), `${path}: network.deny_addresses`),
477
222
  runtimeWrites: runtimeWrites === undefined ? undefined : asArray(runtimeWrites, "runtime.writes"),
478
223
  // `[runtime] isolate` — how much of your home a role stops reaching. See
479
- // 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
480
225
  // are two of them.
481
226
  isolate: readIsolate(own(runtime, "isolate")),
482
227
  // `[runtime] read` — what a role may read. "all" is every file but the ones
@@ -653,7 +398,7 @@ export function loadConfig(path, text = readFileSync(path, "utf8"), { root = nul
653
398
  * every port on localhost (macOS), which includes whatever control API
654
399
  * is listening there without a password. This names the ports instead,
655
400
  * and the runtime's proxy enforces the list — the same allowlist the
656
- * 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
657
402
  * go through the proxy, which HTTP clients do and a MySQL driver does not.
658
403
  */
659
404
  localPorts,
@@ -675,7 +420,7 @@ export function loadConfig(path, text = readFileSync(path, "utf8"), { root = nul
675
420
  * Whether this role may reach com.apple.trustd.agent (macOS). Needed by
676
421
  * Dart/Flutter and by Go built before 1.27 to verify TLS at all; Go 1.27+
677
422
  * does without it through the SSL_CERT_FILE that env.js sets. Off unless
678
- * the role says so. See srt.js for what it costs.
423
+ * the role says so. See srt-settings.js for what it costs.
679
424
  */
680
425
  trustd: trustd === true,
681
426
  /**
@@ -781,7 +526,7 @@ const BUILTIN_SANDBOXES = ["srt", "tartaros"];
781
526
  * `[sandbox]`: which sandbox enforces the roles, and any declared by command.
782
527
  *
783
528
  * `provider` is one name or a list in order of preference; absent, it is
784
- * `srt`, as it always was. A name is one seisin ships or one declared under
529
+ * `["tartaros", "srt"]` (DEFAULT_PROVIDERS; `srt` alone before 0.7). A name is one seisin ships or one declared under
785
530
  * `[sandbox.providers.<name>]` — an unknown one is refused here, never read as
786
531
  * the default, because "the config asks for tartaros and the run used srt"
787
532
  * without a word is the silent substitution sandboxes/index.js exists to stop.
@@ -995,285 +740,3 @@ export function parseJsonPolicy(text, path = "policy.json") {
995
740
  if (!isTable(raw)) throw new Error(`${path}: the policy is an object`);
996
741
  return walk(raw, "");
997
742
  }
998
-
999
- /** The candidate within edit distance 3 of `word`, or null. Shared with `check`. */
1000
- export function closest(word, candidates) {
1001
- let best = null, bestD = 4;
1002
- for (const c of candidates) {
1003
- const d = distance(word.toLowerCase(), c);
1004
- if (d < bestD) { best = c; bestD = d; }
1005
- }
1006
- return best;
1007
- }
1008
-
1009
- /** Levenshtein distance. */
1010
- function distance(a, b) {
1011
- const row = Array.from({ length: b.length + 1 }, (_, i) => i);
1012
- for (let i = 1; i <= a.length; i++) {
1013
- let prev = row[0]; row[0] = i;
1014
- for (let j = 1; j <= b.length; j++) {
1015
- const cur = row[j];
1016
- row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
1017
- prev = cur;
1018
- }
1019
- }
1020
- return row[b.length];
1021
- }
1022
-
1023
- /** true, false, or absent (undefined). Anything else is a mistake, said as one. */
1024
- function readBoolean(value, where) {
1025
- if (value === undefined || typeof value === "boolean") return value;
1026
- throw new Error(`${where} must be true or false, not ${JSON.stringify(value)}`);
1027
- }
1028
-
1029
- /** The keys a `[roles.<name>]` table can hold. Anything else is reported by `check`. */
1030
- 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", "handoff_exempt", "requests"];
1031
-
1032
- /**
1033
- * `control_files`: which families of control files a role may edit. Refused at
1034
- * load when it names anything else — `.claude`, git hooks and the rest are not
1035
- * on the list on purpose, and a word the reader does not know must not load as
1036
- * a permission it thinks it wrote.
1037
- */
1038
- export const CONTROL_FAMILIES = ["ide", "instructions"];
1039
-
1040
- function readControlFiles(value, where) {
1041
- if (value === undefined) return [];
1042
- const out = [];
1043
- for (const f of asArray(value, where)) {
1044
- if (!CONTROL_FAMILIES.includes(f))
1045
- throw new Error(`${where}: ${JSON.stringify(f)} is not a family of control files. ` +
1046
- `Known: ${CONTROL_FAMILIES.map((x) => JSON.stringify(x)).join(", ")}. ` +
1047
- `The others (.claude, git hooks, .mcp.json, .envrc) are never handed to a role.`);
1048
- if (!out.includes(f)) out.push(f);
1049
- }
1050
- return out;
1051
- }
1052
-
1053
- /**
1054
- * `[protect]` — what is protected beyond the defaults. One key today:
1055
- * `instructions = true` protects `CLAUDE.md`, `AGENTS.md` and their kin from
1056
- * every role that is not handed them. Anything else in the table is refused.
1057
- */
1058
- function readProtect(value, path) {
1059
- if (value === undefined) return { instructions: false };
1060
- if (!value || typeof value !== "object" || Array.isArray(value))
1061
- throw new Error(`${path}: [protect] must be a table`);
1062
- for (const k of Object.keys(value))
1063
- if (k !== "instructions")
1064
- throw new Error(`${path}: [protect] ${k} is not a setting. Known: instructions.`);
1065
- const v = own(value, "instructions");
1066
- if (v !== undefined && typeof v !== "boolean")
1067
- throw new Error(`${path}: [protect] instructions must be true or false, not ${JSON.stringify(v)}`);
1068
- return { instructions: v === true };
1069
- }
1070
-
1071
- /**
1072
- * `mcp`: names of MCP servers, as the CLI's config calls them. Absent is null,
1073
- * not [], so a policy that never mentions MCP changes nothing for a launcher
1074
- * that reads it; `mcp = []` is the explicit "none".
1075
- */
1076
- function readMcp(value, where) {
1077
- if (value === undefined) return null;
1078
- const list = asArray(value, where);
1079
- const out = [];
1080
- for (const n of list) {
1081
- if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(n))
1082
- throw new Error(`${where}: ${JSON.stringify(n)} is not an MCP server name. ` +
1083
- `Use the name from the CLI's config, for example mcp = ["playwright"].`);
1084
- if (!out.includes(n)) out.push(n);
1085
- }
1086
- return out;
1087
- }
1088
-
1089
- /**
1090
- * `browsers`, refused rather than guessed: a name seisin has no preset for
1091
- * would read as a grant that was made, and grant nothing.
1092
- */
1093
- function readBrowsers(value, where) {
1094
- if (value === undefined) return [];
1095
- if (!Array.isArray(value) || value.some((v) => typeof v !== "string"))
1096
- throw new Error(`${where} is a list of browser names, for example browsers = ["chromium"].`);
1097
- for (const b of value)
1098
- if (!Object.hasOwn(BROWSERS, b)) {
1099
- const near = closest(b, Object.keys(BROWSERS));
1100
- throw new Error(`${where}: "${b}" is not a browser seisin has a preset for${near ? ` — did you mean "${near}"?` : ""} ` +
1101
- `Known: ${Object.keys(BROWSERS).join(", ")}.`);
1102
- }
1103
- return [...new Set(value)];
1104
- }
1105
-
1106
- /**
1107
- * `local_ports`, refused rather than guessed when it is not a list of ports.
1108
- *
1109
- * Refused at load for the same reason as `never_writes`: a port list that loads
1110
- * with a typo in it opens a port nobody meant, or silently fails to open the
1111
- * one they did. A string is accepted when it is only digits, because TOML
1112
- * written by hand says `"8001"` as often as `8001`.
1113
- */
1114
- function readLocalPorts(value, where) {
1115
- if (value === undefined) return [];
1116
- const list = Array.isArray(value) ? value : [value];
1117
- const out = [];
1118
- for (const v of list) {
1119
- const n = typeof v === "string" && /^\d+$/.test(v) ? Number(v) : v;
1120
- if (!Number.isInteger(n) || n < 1 || n > 65535)
1121
- throw new Error(`${where}: ${JSON.stringify(v)} is not a port. Write numbers from 1 to 65535, ` +
1122
- `for example local_ports = [8001, 8081].`);
1123
- if (!out.includes(n)) out.push(n);
1124
- }
1125
- return out;
1126
- }
1127
-
1128
- /**
1129
- * `inject_hosts`: for `key_mode = "inject"`, the hosts each masked credential
1130
- * is substituted into, keyed by the delivered variable name.
1131
- *
1132
- * [roles.frontend.inject_hosts]
1133
- * GH_TOKEN = ["api.github.com"]
1134
- *
1135
- * One list per credential and not one for the role, because the runtime gates
1136
- * substitution per credential: credential A's real value reaches only A's
1137
- * hosts, never B's. A host is an exact name — no wildcard — so `check` can say
1138
- * precisely which traffic is intercepted; the match against a wider wildcard in
1139
- * `allowedDomains` is computed later (srt.js), where the terminated set is.
1140
- */
1141
- function readInjectHosts(value, where) {
1142
- if (value === undefined) return null;
1143
- if (typeof value !== "object" || Array.isArray(value))
1144
- throw new Error(`${where}: inject_hosts is a table of VAR = ["host", …], ` +
1145
- `for example [roles.x.inject_hosts] GH_TOKEN = ["api.github.com"].`);
1146
- const out = {};
1147
- for (const [name, hosts] of Object.entries(value)) {
1148
- const list = asArray(hosts, `${where}.${name}`);
1149
- if (list.length === 0)
1150
- throw new Error(`${where}.${name}: a masked credential with no host injects nowhere. ` +
1151
- `List the host its token authenticates to, like ["api.github.com"].`);
1152
- for (const h of list) {
1153
- if (typeof h !== "string" || h.trim() === "")
1154
- throw new Error(`${where}.${name}: every host is a non-empty string`);
1155
- if (h.includes("*"))
1156
- throw new Error(`${where}.${name}: "${h}" is a wildcard. An inject host is an exact name, ` +
1157
- `so check can say which traffic is intercepted. A wildcard in allowedDomains still ` +
1158
- `covers it; that is reported, not declared here.`);
1159
- if (/^https?:\/\//.test(h) || h.includes("/"))
1160
- throw new Error(`${where}.${name}: "${h}" is not a host. Write the bare domain, like "api.github.com".`);
1161
- }
1162
- out[name] = [...new Set(list)];
1163
- }
1164
- return out;
1165
- }
1166
-
1167
- /**
1168
- * `never_writes`, refused rather than guessed when it cannot mean what it says.
1169
- *
1170
- * Refused at load, not warned about: this is a list of things a role must not
1171
- * do, and a malformed entry that loads anyway fails open. An absolute path or a
1172
- * `..` would point outside the repo the rest of the policy is written against,
1173
- * so it cannot be the subtraction the person meant.
1174
- */
1175
- function readNeverWrites(value, where) {
1176
- const list = asArray(value, where);
1177
- for (const g of list) {
1178
- if (typeof g !== "string" || g.trim() === "")
1179
- throw new Error(`${where}: every entry must be a non-empty string`);
1180
- if (g.startsWith("/"))
1181
- throw new Error(`${where}: "${g}" is absolute. never_writes is relative to the repo root, like writes.`);
1182
- if (g.split("/").includes(".."))
1183
- throw new Error(`${where}: "${g}" contains "..". never_writes names paths inside the repo.`);
1184
- }
1185
- return list;
1186
- }
1187
-
1188
- /**
1189
- * `private`: folders inside the repo, written `dir` or `dir/**`.
1190
- *
1191
- * A folder and not a glob, because what is enforced is a deny on one path for
1192
- * every other role: `notes/*.md` would have to be widened to `notes` or frozen
1193
- * to today's files. Not absolute and no `..`, because the denies are given
1194
- * against the repo the policy describes and `explain` answers in it. Not the
1195
- * repo itself: private to one role, the whole repo is shut to all the others,
1196
- * which is a policy of one role written in the long way.
1197
- */
1198
- function readPrivate(value, where) {
1199
- const list = value === undefined ? [] : asArray(value, where);
1200
- for (const p of list) {
1201
- const dir = trimSlashes(p.replace(/\/\*\*$/, "")).replace(/^\.\//, "");
1202
- if (p.startsWith("/"))
1203
- throw new Error(`${where}: "${p}" is absolute. A private folder is relative to the repo root, like writes.`);
1204
- if (dir.split("/").includes(".."))
1205
- throw new Error(`${where}: "${p}" contains "..". A private folder is inside the repo.`);
1206
- if (dir === "" || dir === "." || dir === "**")
1207
- throw new Error(`${where}: "${p}" is the whole repo. A private folder is a folder inside it — ` +
1208
- `"notes/**" — and every other role is denied it.`);
1209
- if (/[*?[\]{}]/.test(dir))
1210
- throw new Error(`${where}: "${p}" is a pattern. A private folder is one path and everything under it: ` +
1211
- `write "${beforeGlob(dir) || "dir"}/**".`);
1212
- }
1213
- return list;
1214
- }
1215
-
1216
- /** `private` with its `/**` and `./` taken off: the folder itself. */
1217
- export const privateDir = (p) => trimSlashes(p.replace(/\/\*\*$/, "")).replace(/^\.\//, "");
1218
-
1219
- /**
1220
- * One owner per private folder. Two roles naming the same folder, or one
1221
- * inside the other, would each be denied the other's — so neither could read
1222
- * its own. Refused at load, with both names.
1223
- */
1224
- function checkPrivateOverlap(roles, path) {
1225
- const all = Object.values(roles).flatMap((r) => (r.private ?? []).map((p) => ({ role: r.name, p, dir: privateDir(p) })));
1226
- const inside = (a, b) => a === b || a.startsWith(b + "/");
1227
- for (let i = 0; i < all.length; i++)
1228
- for (let j = i + 1; j < all.length; j++) {
1229
- const a = all[i], b = all[j];
1230
- if (a.role === b.role || !(inside(a.dir, b.dir) || inside(b.dir, a.dir))) continue;
1231
- throw new Error(
1232
- `${path}: roles.${a.role}.private "${a.p}" and roles.${b.role}.private "${b.p}" ` +
1233
- `${a.dir === b.dir ? "are the same folder" : "overlap"}. A private folder has one owner: every ` +
1234
- `other role is denied it, so each of these two would be denied its own. Give it to one of them.`);
1235
- }
1236
- }
1237
-
1238
- /**
1239
- * A territory is relative to the repo root. An absolute one would ask the kernel
1240
- * for a path on the machine and make `explain` (which reads absolute as outside
1241
- * the repo) disagree with what the profile grants — see the CODEOWNERS `/apps/`
1242
- * case. `..` is allowed on purpose: a role legitimately writes a sibling
1243
- * directory (`../shared/...`), and it is resolved relative to the root, so a
1244
- * territory that climbs above it grants what it resolves to, by the choice of
1245
- * whoever wrote the policy.
1246
- */
1247
- function assertNotAbsolute(list, where) {
1248
- for (const g of list) {
1249
- if (typeof g !== "string" || g.trim() === "")
1250
- throw new Error(`${where}: every entry must be a non-empty string`);
1251
- if (g.startsWith("/"))
1252
- throw new Error(`${where}: "${g}" is absolute. Territories are relative to the repo root.`);
1253
- }
1254
- return list;
1255
- }
1256
-
1257
- /**
1258
- * An IP address or a CIDR range: `10.0.0.0/8`, `192.168.1.10`, `fc00::/7`
1259
- * (IPv6 without brackets). Anything else is refused with what was written.
1260
- */
1261
- function readAddresses(value, where) {
1262
- const list = asArray(value, where);
1263
- for (const v of list) {
1264
- const [addr, prefix, extra] = v.split("/");
1265
- const family = isIP(addr);
1266
- const max = family === 6 ? 128 : 32;
1267
- const okPrefix = prefix === undefined || (/^\d+$/.test(prefix) && Number(prefix) <= max);
1268
- if (!family || !okPrefix || extra !== undefined)
1269
- throw new Error(`${where}: "${v}" is not an IP address or a CIDR range (e.g. "10.0.0.0/8", "fc00::/7")`);
1270
- }
1271
- return list;
1272
- }
1273
-
1274
- function asArray(value, where) {
1275
- if (value === undefined) return [];
1276
- if (typeof value === "string") return [value];
1277
- if (Array.isArray(value) && value.every((v) => typeof v === "string")) return value;
1278
- throw new Error(`${where}: expected a string or an array of strings`);
1279
- }
package/src/controls.js CHANGED
@@ -19,7 +19,7 @@
19
19
  */
20
20
  import { createHash } from "node:crypto";
21
21
  import { loadConfig, CONTROL_FAMILIES, stripComment, closesArray } from "./config.js";
22
- import { settingsFor } from "./srt.js";
22
+ import { settingsFor } from "./srt-settings.js";
23
23
  import { denyFor } from "./surface.js";
24
24
  import { cleanReason } from "./requests.js";
25
25
 
@@ -0,0 +1,37 @@
1
+ /** A person's decision, shared by the CLI and the console, never the MCP. */
2
+ import { loadConfig } from "./config.js";
3
+ import { editPolicy, requestsPath, asFileGrant, refuseIfBarred, applyGrant, settle } from "./requests.js";
4
+
5
+ /**
6
+ * Looked up, applied and settled under one lock — the policy's, the one
7
+ * every grant takes (editPolicy). The lookup used to happen before it and
8
+ * the settle after it, so the console and `seisin grant` could both find the
9
+ * same request pending and both act on it. A decline takes the lock as well,
10
+ * though it writes no policy, for the same reason.
11
+ *
12
+ * The config is edited as text, so comments and order survive, and it is
13
+ * written before the queue is settled: if the write throws, the request is
14
+ * still open rather than marked done against a file that never changed.
15
+ *
16
+ * Selection stays with the interface: the CLI accepts positions and decorates
17
+ * the queue; the console accepts only a stable id and has its own errors.
18
+ * `reload: false` preserves CLI decline's use of its already loaded config;
19
+ * the console reloads even for a decline, so malformed policy still refuses it.
20
+ * `includeOnly: false` keeps CLI declines free of file-grant metadata, even
21
+ * when a sibling request reopened after an earlier one-file grant.
22
+ */
23
+ export function decideRequest(config, { decision, reason, only = false, select, reload = true, includeOnly = true }) {
24
+ let request;
25
+ const result = editPolicy(config, (before) => {
26
+ const current = reload ? loadConfig(config.path, before) : config;
27
+ request = select(current);
28
+ if (decision !== "granted") return { toml: before, changed: false };
29
+ if (only) request = asFileGrant(request);
30
+ refuseIfBarred(current, request);
31
+ return applyGrant(before, request, reason);
32
+ }, {
33
+ // Keep settlement after the policy write, still under its lock.
34
+ after: () => settle(requestsPath(config.root), request.key, decision, reason, includeOnly ? { only: request.only } : undefined),
35
+ });
36
+ return { request, ...result };
37
+ }