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.
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * seisin decides the policy; a sandbox enforces it. There are three ways in:
5
5
  *
6
- * - `srt` (built in, the default): @anthropic-ai/sandbox-runtime;
7
- * - `tartaros` (built in): reads the settings file itself (`--settings`);
6
+ * - `tartaros` (built in, the default, installed with seisin): reads the
7
+ * settings file itself (`--settings`);
8
+ * - `srt` (built in, installed apart): @anthropic-ai/sandbox-runtime;
8
9
  * - any name declared under `[sandbox.providers.<name>]`: a command that
9
10
  * takes the settings file (command.js, docs/sandboxes.md).
10
11
  *
@@ -23,17 +24,25 @@
23
24
  import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
24
25
  import { join } from "node:path";
25
26
  import { tmpdir } from "node:os";
26
- import { srt } from "./srt.js";
27
+ import { srt, SRT_PACKAGE, SRT_VERSION } from "./srt.js";
27
28
  import { machOf } from "../browsers.js";
28
29
  import { tartaros } from "./tartaros.js";
29
30
  import { commandSandbox } from "./command.js";
30
31
 
31
32
  export const BUILTIN_SANDBOXES = { srt, tartaros };
32
- export const DEFAULT_SANDBOX = "srt";
33
+ export const DEFAULT_SANDBOX = "tartaros";
34
+ /**
35
+ * Without `[sandbox] provider`: tartaros, which installs with seisin, then srt
36
+ * where it is installed apart (it is not a dependency since 0.7). A role
37
+ * tartaros cannot hold (a blanket local binding, a host's missing Landlock)
38
+ * runs under srt if it is there and holds all of it, and otherwise does not
39
+ * run, with what each one lacked.
40
+ */
41
+ export const DEFAULT_PROVIDERS = [DEFAULT_SANDBOX, "srt"];
33
42
 
34
43
  /** The sandboxes the config names, in its order, as provider objects. */
35
44
  export function sandboxesOf(config) {
36
- const names = config.sandbox?.provider ?? [DEFAULT_SANDBOX];
45
+ const names = config.sandbox?.provider ?? DEFAULT_PROVIDERS;
37
46
  return names.map((n) => BUILTIN_SANDBOXES[n] ?? commandSandbox(n, config.sandbox.providers[n].command, config.sandbox.providers[n].check, { tagsDenials: config.sandbox.providers[n].tagsDenials }));
38
47
  }
39
48
 
@@ -91,7 +100,15 @@ export function weigh(config, settings, { platform = process.platform, role = nu
91
100
  const ruled = requests.length && !sandbox.requestRules
92
101
  ? [`requests (${requests.map((q) => q.host).join(", ")}): ${sandbox.name} cannot limit the methods and paths of a host from its settings, tartaros can`]
93
102
  : [];
94
- return { sandbox, bin, unmet: [...(masking ? [masking] : []), ...unmet, ...browser, ...exemption, ...ruled], warnings, missing: bin ? null : sandbox.missing() };
103
+ // `control_files`: a family the policy hands a role that this sandbox
104
+ // protects whatever the settings say (tartaros's trust-handoff set holds
105
+ // `.vscode` at any depth), so the role could not do what it was given.
106
+ const families = role ? (config.roles?.[role]?.controlFiles ?? []) : [];
107
+ const kept = families.filter((f) => (sandbox.keepsControlFiles ?? []).includes(f));
108
+ const control = kept.length
109
+ ? [`control_files (${kept.join(", ")}): ${sandbox.name} protects those files at any depth whatever the policy says, so the role could not edit them`]
110
+ : [];
111
+ return { sandbox, bin, unmet: [...(masking ? [masking] : []), ...unmet, ...browser, ...exemption, ...ruled, ...control], warnings, missing: bin ? null : sandbox.missing() };
95
112
  });
96
113
  const chosen = options.find((o) => o.bin && o.unmet.length === 0) ?? null;
97
114
  return { chosen, options };
@@ -114,7 +131,7 @@ export function chooseSandbox(config, settings, { platform = process.platform, r
114
131
  const { chosen, options } = weigh(config, settings, { platform, role, file });
115
132
  if (chosen) return { sandbox: chosen.sandbox, bin: chosen.bin, options };
116
133
  // The one sandbox there is, missing: said exactly as it always was.
117
- if (options.length === 1 && options[0].missing && options[0].sandbox.name === DEFAULT_SANDBOX)
134
+ if (options.length === 1 && options[0].missing)
118
135
  return { error: new Error(options[0].missing), options };
119
136
  return { error: new Error(refusal(role, options)), options };
120
137
  }
@@ -125,6 +142,6 @@ export function refusal(role, options) {
125
142
  : ` - ${o.sandbox.name} cannot enforce all of it:\n${o.unmet.map((u) => ` ${u}`).join("\n")}`);
126
143
  return `no sandbox in [sandbox] provider can hold ${role ? `role ${role}` : "this role"} as its policy is written, so it was not started:\n` +
127
144
  `${lines.join("\n")}\n` +
128
- ` A sandbox that enforces less than the policy is never used in its place. Add one that can (provider = [..., "srt"]), ` +
129
- `or change the role.`;
145
+ ` A sandbox that enforces less than the policy is never used in its place. Install one that can ` +
146
+ `(srt: npm install -g ${SRT_PACKAGE}@${SRT_VERSION}), name it in [sandbox] provider, or change the role.`;
130
147
  }
@@ -1,10 +1,15 @@
1
1
  /**
2
2
  * `srt`: @anthropic-ai/sandbox-runtime, the sandbox seisin was built on.
3
3
  *
4
- * The default, and the reference every other provider is measured against:
5
- * the settings seisin generates (srt.js, one level up) ARE this runtime's
6
- * settings file, so it enforces all of them by construction and `unmet` is
7
- * always empty. What a run hands it is the file and the command, nothing else.
4
+ * The reference every other provider is measured against: the settings seisin
5
+ * generates (srt-settings.js, one level up) ARE this runtime's settings file, so
6
+ * it enforces all of them by construction and `unmet` is always empty. What a
7
+ * run hands it is the file and the command, nothing else.
8
+ *
9
+ * Not a dependency since 0.7: tartaros is the default and installs with seisin.
10
+ * srt is used where it is installed apart (globally, or in the project), after
11
+ * tartaros or where `[sandbox] provider` names it, and the version it is tested
12
+ * with is the one in seisin's devDependencies.
8
13
  */
9
14
  import { existsSync, readFileSync, realpathSync } from "node:fs";
10
15
  import { join, dirname, delimiter } from "node:path";
@@ -47,6 +52,15 @@ export function resolveSrt({ from = HERE, path = process.env.PATH } = {}) {
47
52
  }
48
53
 
49
54
  const RUNTIME = "@anthropic-ai/sandbox-runtime";
55
+ export const SRT_PACKAGE = RUNTIME;
56
+ /** The srt seisin is tested with (its devDependency), for the install hint. */
57
+ export const SRT_VERSION = (() => {
58
+ try {
59
+ return JSON.parse(readFileSync(join(HERE, "..", "..", "package.json"), "utf8")).devDependencies?.[RUNTIME] ?? "latest";
60
+ } catch {
61
+ return "latest";
62
+ }
63
+ })();
50
64
 
51
65
  /**
52
66
  * The `srt` of the sandbox-runtime that resolves from `from`, or null.
@@ -148,11 +162,17 @@ export function withMachPreload(bin, file, inner) {
148
162
  export const srt = {
149
163
  name: "srt",
150
164
  locate: () => resolveSrt(),
151
- missing: () => `sandbox runtime not found. ${installHint()}`,
165
+ missing: () => `sandbox-runtime (srt) not found: it is not installed with seisin since 0.7. ` +
166
+ `Install it with: npm install -g ${RUNTIME}@${SRT_VERSION}`,
152
167
  unmet: () => [],
153
- // Masked credentials: on Linux; on macOS not yet (anthropics/sandbox-runtime#593).
168
+ // Masked credentials: on Linux. On macOS srt masks too, but it reads the real
169
+ // value from its own environment, and from inside its sandbox the agent can
170
+ // read that environment back (measured with 0.0.81).
171
+ // anthropics/sandbox-runtime#593 takes the value out of there.
154
172
  masks: (platform) => platform === "linux" ? null
155
- : `masked credentials (key_mode = "inject") on ${platform}: sandbox-runtime does not mask them there yet (anthropics/sandbox-runtime#593); tartaros does`,
173
+ : platform === "darwin"
174
+ ? `masked credentials (key_mode = "inject") on darwin: sandbox-runtime masks them, but holds the real value in its own environment, which the agent can read from inside the sandbox (anthropics/sandbox-runtime#593 takes it out of there); tartaros does not keep it there`
175
+ : `masked credentials (key_mode = "inject") on ${platform}: sandbox-runtime does not mask them there; tartaros does`,
156
176
  // `--` before the command, and it is not cosmetic. Without it the runtime's
157
177
  // own argument parser reaches into what the agent was invoked with and eats
158
178
  // anything resembling one of its flags: `claude --settings`, `claude -c`.
@@ -28,14 +28,13 @@ export const TARTAROS_PACKAGE = "@carlostapiacl/tartaros";
28
28
 
29
29
  /**
30
30
  * The tartaros seisin depends on first, one on PATH second, and nothing third:
31
- * the order srt is found in (srt.js), for the same reason. A tartaros built
31
+ * the order srt is found in (resolveSrt in sandboxes/srt.js), for the same reason. A tartaros built
32
32
  * once and left on PATH goes on answering after the pinned one has its fixes.
33
33
  *
34
- * The pinned one is an optional dependency: a package per platform with the
35
- * binary in it, and a main package whose binaryPath() finds that one whether
34
+ * The pinned one is a dependency: a main package whose binaryPath() finds the
35
+ * binary of this platform's package (an optional dependency of its own) whether
36
36
  * npm nested it under the main package (a global install, measured) or hoisted
37
- * it. An install that left it out (`--omit=optional`, a platform without a
38
- * binary) falls through to PATH.
37
+ * it. A platform without a binary, or `--omit=optional`, falls through to PATH.
39
38
  */
40
39
  export function resolveTartaros({ from = HERE, path = process.env.PATH } = {}) {
41
40
  const pinned = dependencyTartaros(from);
@@ -116,7 +115,10 @@ export function tartarosSettings(settings, { machRegister = [], handoffExempt =
116
115
  export const tartaros = {
117
116
  name: "tartaros",
118
117
  locate: () => resolveTartaros(),
119
- missing: () => `tartaros not found: neither ${TARTAROS_PACKAGE}, which installs with seisin unless optional dependencies are left out, nor tartaros on PATH.`,
118
+ missing: () => `tartaros not found: neither ${TARTAROS_PACKAGE}, which installs with seisin (it has no binary for ${process.platform}-${process.arch}), nor tartaros on PATH.`,
119
+ // Its trust-handoff set holds the editors' folders (`.vscode`, `.idea`) at any
120
+ // depth, whatever the settings say: a role given them cannot edit them here.
121
+ keepsControlFiles: ["ide"],
120
122
  asksFile: true,
121
123
  // Masked credentials: tartaros says whether it masks them, through explain
122
124
  // (0.1.0-alpha.16 and later do; earlier ones refuse the field).
package/src/serve.js CHANGED
@@ -16,10 +16,11 @@ import { readFileSync, existsSync, statSync } from "node:fs";
16
16
  import { join, dirname } from "node:path";
17
17
  import { fileURLToPath } from "node:url";
18
18
  import { loadConfig } from "./config.js";
19
+ import { decideRequest } from "./decide-request.js";
19
20
  import { read, logPath, logSegments } from "./log.js";
20
- import { settingsFor } from "./srt.js";
21
+ import { settingsFor } from "./srt-settings.js";
21
22
  import { describeHandoff } from "./handoff.js";
22
- import { pending, requestsPath, settle, applyGrant, refuseIfBarred, editPolicy, cleanReason, asFileGrant, lastAgentOf, fileOnlyNote } from "./requests.js";
23
+ import { pending, requestsPath, editPolicy, settle, cleanReason, lastAgentOf, fileOnlyNote } from "./requests.js";
23
24
  import { causesOf, describeWalls, parseSince, queue, verdicts, wallsByRole } from "./views.js";
24
25
  import { planEdit, hashOf } from "./controls.js";
25
26
  import { inspect } from "./inspect.js";
@@ -263,29 +264,17 @@ function decide(configPath, { key, decision, reason, scope = "folder" }) {
263
264
  const cfg = loadConfig(configPath);
264
265
  const file = requestsPath(cfg.root);
265
266
 
266
- /**
267
- * Looked up, applied and settled under one lock — the policy's, the one
268
- * every grant takes (editPolicy). The lookup used to happen before it and
269
- * the settle after it, so the console and `seisin grant` could both find the
270
- * same request pending and both act on it. A decline takes the lock as well,
271
- * though it writes no policy, for the same reason.
272
- *
273
- * The config is edited as text, so comments and order survive, and it is
274
- * written before the queue is settled: if the write throws, the request is
275
- * still open rather than marked done against a file that never changed.
276
- */
277
267
  let req = null;
278
268
  let result = null;
279
269
  try {
280
- result = editPolicy(cfg, (before) => {
281
- const current = loadConfig(configPath, before);
282
- req = pending(file, { keyDirs: current.keyDirs }).find((r) => r.key === key);
283
- if (!req) throw bad(`request ${key} is no longer pending — reload to see the queue as it is now`);
284
- if (decision !== "granted") return { toml: before, changed: false };
285
- if (scope === "file") req = asFileGrant(req);
286
- refuseIfBarred(current, req);
287
- return applyGrant(before, req, reason);
288
- }, { after: () => settle(file, req.key, decision, reason, { only: req.only }) });
270
+ ({ request: req, ...result } = decideRequest(cfg, {
271
+ decision, reason, only: scope === "file",
272
+ select: (current) => {
273
+ const request = pending(file, { keyDirs: current.keyDirs }).find((r) => r.key === key);
274
+ if (!request) throw bad(`request ${key} is no longer pending — reload to see the queue as it is now`);
275
+ return request;
276
+ },
277
+ }));
289
278
  } catch (e) {
290
279
  if (e.code === "ELOCKED") throw Object.assign(new Error("another grant is in progress — try again in a moment"), { status: 503 });
291
280
  throw e;
package/src/spool.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * request queue writable by the process they are a record of: an agent could
8
8
  * truncate `log.jsonl`, rewrite a decision, or invent a request that had never
9
9
  * been refused. The files said "append-only" in their own comments and nothing
10
- * enforced it. An external review named this and was right.
10
+ * enforced it. A review named this and was right.
11
11
  *
12
12
  * So the file moves out of reach and a socket takes its place:
13
13
  *
@@ -33,7 +33,7 @@ import {
33
33
  CREDENTIAL_HOMES, toWritePath, writePathsOf, otherAgentsCredentials,
34
34
  } from "./grants.js";
35
35
 
36
- // Re-exported: these were srt.js's exports before grants.js existed, and
36
+ // Re-exported: these were srt-settings.js's exports before grants.js existed, and
37
37
  // callers (the CLI, the tests, anyone embedding seisin) import them from here.
38
38
  export {
39
39
  expand, homeFits, roleHome, roleHomeRoot, CREDENTIAL_HOMES, RUNTIME_WRITES,
package/src/suggest.js CHANGED
@@ -41,12 +41,37 @@ export function unknownRole(config, role) {
41
41
  * `--verbsoe` is a question answered as if it had not been asked.
42
42
  * `values` are the flags that take the next word as their value.
43
43
  */
44
+ /**
45
+ * A count a person types (`--min 3`): a whole number from 1. Anything else is
46
+ * refused; `Number("banana")` made every comparison false and the answer
47
+ * "no walls" looked like a real one.
48
+ */
49
+ export function positiveInt(flag, value) {
50
+ if (!/^[1-9][0-9]*$/.test(String(value ?? ""))) {
51
+ const e = new Error(`${flag} takes a whole number from 1, not "${value ?? ""}"`);
52
+ e.usage = true;
53
+ throw e;
54
+ }
55
+ return Number(value);
56
+ }
57
+
44
58
  export function checkFlags(command, argv, known, values = []) {
45
59
  const all = [...known, ...values];
46
60
  for (let i = 0; i < argv.length; i++) {
47
61
  const a = argv[i];
48
62
  if (a === "--") break;
49
- if (values.includes(a)) { i++; continue; }
63
+ if (values.includes(a)) {
64
+ // A flag that takes a value and has none is refused: `log --role` read
65
+ // as no filter at all, and answered for every role.
66
+ const v = argv[i + 1];
67
+ if (v === undefined || v === "--" || (v.startsWith("-") && v !== "-")) {
68
+ const e = new Error(`${a} needs a value\n seisin ${command} --help lists its flags`);
69
+ e.usage = true;
70
+ throw e;
71
+ }
72
+ i++;
73
+ continue;
74
+ }
50
75
  if (!a.startsWith("-") || a === "-") continue;
51
76
  if (all.includes(a)) continue;
52
77
  const near = nearest(a, all);
package/src/territory.js CHANGED
@@ -50,7 +50,7 @@
50
50
  * nothing else in a data root is.
51
51
  */
52
52
  import { readdirSync, lstatSync } from "node:fs";
53
- import { homedir, tmpdir } from "node:os";
53
+ import { homedir, tmpdir, userInfo } from "node:os";
54
54
  import { realOrSelf, writePathsOf, expand } from "./grants.js";
55
55
  import { realAncestor } from "./paths.js";
56
56
 
@@ -64,9 +64,19 @@ export const AGENT_FILES = { claude: ["~/.claude.json"], codex: [] };
64
64
  * and that hold nothing about anybody. On macOS CoreFoundation reads
65
65
  * `~/.CFUserTextEncoding` (a text-encoding code, `0x1F5:0x0:0x0`); shut, it was
66
66
  * 278 of 409 denials in one orchestrator's runs, every one of them noise.
67
+ * The system reads it from the account's home, not from `$HOME`: an
68
+ * orchestrator that gives each run its own HOME still sees the real file read
69
+ * (measured), so these resolve against the account, see `accountHome`.
67
70
  */
68
71
  export const PLATFORM_FILES = { darwin: ["~/.CFUserTextEncoding"], linux: [] };
69
72
 
73
+ /** The home directory of the account, from the user database, whatever `$HOME` says. */
74
+ export function accountHome() {
75
+ try { return userInfo().homedir || homedir(); } catch { return homedir(); }
76
+ }
77
+
78
+ const expandAccount = (p) => (p.startsWith("~/") ? `${accountHome()}/${p.slice(2)}` : expand(p));
79
+
70
80
  /**
71
81
  * Where data lives, per platform. Whole roots: what is under them belongs to
72
82
  * somebody. `/opt/homebrew/var` and `/usr/local/var` hold the databases a
@@ -233,7 +243,7 @@ export function keptFor(config, role, { observe = false, agent, program } = {})
233
243
  // starts and says "Not logged in" (measured, 2026-10-09). A run of no
234
244
  // agent, or of another, does not get it.
235
245
  ...(agent ? (AGENT_FILES[agent] ?? []).map(expand) : []),
236
- ...(PLATFORM_FILES[process.platform] ?? []).map(expand),
246
+ ...(PLATFORM_FILES[process.platform] ?? []).map(expandAccount),
237
247
  ...writePathsOf(config, role, { observe, agent }),
238
248
  ...(role.reads ?? []).map((p) => readPath(config.root, p)),
239
249
  ...(role.toolchain ?? []).map((p) => readPath(config.root, p)),
package/src/toml.js ADDED
@@ -0,0 +1,250 @@
1
+ /**
2
+ * The TOML subset seisin.toml is written in: `[table.name]` headers, and
3
+ * `key = ["a", "b"]` / `key = "a"` values, with a line number on every error.
4
+ *
5
+ * Kept in-house so installing seisin pulls in no parser. If a config outgrows
6
+ * this subset, the config is doing too much.
7
+ */
8
+ import { CONFIG_NAME } from "./layout.js";
9
+
10
+ const RE_TABLE = /^\[([A-Za-z0-9_.\-]+)\]$/;
11
+ const RE_PAIR = /^([A-Za-z0-9_\-]+)\s*=\s*(.+)$/;
12
+
13
+ /**
14
+ * Names a table or key may not have.
15
+ *
16
+ * `[roles.__proto__]` does not show up in `Object.keys(roles)`, so `check`
17
+ * prints the roles that exist and says nothing — while every other role
18
+ * inherits whatever it declared. A config reading `[roles.frontend]` with no
19
+ * `writes` came out of the parser owning the entire repo and holding
20
+ * GITHUB_TOKEN, and no amount of reading the file would show it.
21
+ *
22
+ * The tables below are made with a null prototype, which stops the inheritance
23
+ * on its own. The names are refused as well, because a config containing them
24
+ * is not a config with a mistake in it.
25
+ */
26
+ export const FORBIDDEN = new Set(["__proto__", "prototype", "constructor"]);
27
+
28
+ /** A table with no prototype, so nothing is inherited into it. */
29
+ export function dict() {
30
+ return Object.create(null);
31
+ }
32
+
33
+ export const isTable = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
34
+
35
+ /** Read a property only if the object actually has it. */
36
+ export function own(obj, key) {
37
+ return obj && Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
38
+ }
39
+
40
+ /**
41
+ * Where each table and key of a parsed file was written, by dotted name.
42
+ *
43
+ * Kept beside the result rather than in it, so the object stays exactly the
44
+ * data that was written; loadConfig reads it to put a line number on a name it
45
+ * does not know.
46
+ */
47
+ export const LINES = new WeakMap();
48
+
49
+ /** Minimal TOML subset -> plain object. Throws with a line number on bad input. */
50
+ export function parseToml(text) {
51
+ const out = dict();
52
+ const where = new Map();
53
+ LINES.set(out, where);
54
+ let table = out;
55
+ let tableName = "";
56
+ // "Last one wins" is TOML-ish and wrong for a permission file. A policy can
57
+ // look restrictive at the top and be cancelled forty lines down, and the
58
+ // person reviewing it reads the first block. Both shapes are refused.
59
+ const seenTables = new Set();
60
+ const seenKeys = new Set();
61
+ const lines = text.split(/\r?\n/);
62
+
63
+ for (let i = 0; i < lines.length; i++) {
64
+ const raw = lines[i];
65
+ const line = stripComment(raw).trim();
66
+ if (!line) continue;
67
+
68
+ const header = RE_TABLE.exec(line);
69
+ if (header) {
70
+ tableName = header[1];
71
+ for (const part of tableName.split("."))
72
+ if (FORBIDDEN.has(part))
73
+ throw new Error(
74
+ `${CONFIG_NAME}:${i + 1}: "[${tableName}]" uses the reserved name "${part}". ` +
75
+ `A table named that would not appear as a role and would hand its settings to ` +
76
+ `every other one.`);
77
+ if (seenTables.has(tableName))
78
+ throw new Error(
79
+ `${CONFIG_NAME}:${i + 1}: [${tableName}] appears twice. ` +
80
+ `A later table would silently override the earlier one — put every setting for ` +
81
+ `${tableName} in one place.`);
82
+ seenTables.add(tableName);
83
+ tableName.split(".").forEach((_, k, parts) => {
84
+ const name = parts.slice(0, k + 1).join(".");
85
+ if (!where.has(name)) where.set(name, i + 1);
86
+ });
87
+ table = tableName.split(".").reduce((node, key, k, parts) => {
88
+ if (!Object.prototype.hasOwnProperty.call(node, key)) node[key] = dict();
89
+ else if (!isTable(node[key]))
90
+ throw new Error(
91
+ `${CONFIG_NAME}:${i + 1}: [${tableName}] — "${parts.slice(0, k + 1).join(".")}" is already ` +
92
+ `a value, not a table. One name cannot be both.`);
93
+ return node[key];
94
+ }, out);
95
+ continue;
96
+ }
97
+
98
+ const pair = RE_PAIR.exec(line);
99
+ if (!pair) throw new Error(`${CONFIG_NAME}:${i + 1}: cannot read "${raw.trim()}"`);
100
+
101
+ let [, key, value] = pair;
102
+ const at = i + 1;
103
+ // An array may span lines; keep pulling until the brackets balance. Only
104
+ // the new line is searched: searching the whole growing value made an
105
+ // unclosed array of 100k lines take 20 s to be refused. A `]` inside a
106
+ // quoted item does not close it — `["a]b",` used to end the array there.
107
+ if (value.startsWith("[") && !closesArray(value)) {
108
+ const parts = [value];
109
+ let closed = false;
110
+ while (!closed && i + 1 < lines.length) {
111
+ const next = stripComment(lines[++i]).trim();
112
+ parts.push(next);
113
+ closed = closesArray(next);
114
+ }
115
+ value = parts.join(" ");
116
+ }
117
+ if (FORBIDDEN.has(key))
118
+ throw new Error(`${CONFIG_NAME}:${i + 1}: "${key}" is a reserved name and cannot be a setting.`);
119
+ const seen = `${tableName}.${key}`;
120
+ if (seenKeys.has(seen))
121
+ throw new Error(
122
+ `${CONFIG_NAME}:${i + 1}: "${key}" is set twice in [${tableName || "the root"}]. ` +
123
+ `The second would win, which is not a thing a permission file should do quietly.`);
124
+ seenKeys.add(seen);
125
+ where.set(tableName ? `${tableName}.${key}` : key, at);
126
+ /**
127
+ * A key may not be a table that is declared elsewhere, in either order.
128
+ *
129
+ * `[roles.a]` with `network = []`, then `[roles]` and `a = true` forty lines
130
+ * down, used to replace the whole role with `true`: its `network = []` came
131
+ * back as "not set", which means the global allow list. `check` said
132
+ * nothing. It is the "last one wins" this parser refuses everywhere else,
133
+ * arriving by a different spelling.
134
+ */
135
+ if (Object.prototype.hasOwnProperty.call(table, key))
136
+ throw new Error(
137
+ `${CONFIG_NAME}:${i + 1}: "${key}" in [${tableName || "the root"}] is already the table ` +
138
+ `[${tableName ? tableName + "." : ""}${key}]. One name cannot be both — the value would replace ` +
139
+ `everything that table says.`);
140
+ table[key] = readValue(value, i + 1);
141
+ }
142
+ return out;
143
+ }
144
+
145
+ /** Is there a `]` on this line outside a quoted string? Strings do not span lines. */
146
+ export function closesArray(line) {
147
+ let quoted = false;
148
+ for (let i = 0; i < line.length; i++) {
149
+ const c = line[i];
150
+ if (c === '"') quoted = !quoted;
151
+ else if (c === "]" && !quoted) return true;
152
+ }
153
+ return false;
154
+ }
155
+
156
+ /** Strips a trailing `#` comment, but not one inside a quoted string. */
157
+ export function stripComment(line) {
158
+ let quoted = false;
159
+ for (let i = 0; i < line.length; i++) {
160
+ const c = line[i];
161
+ if (c === '"') quoted = !quoted;
162
+ else if (c === "#" && !quoted) return line.slice(0, i);
163
+ }
164
+ return line;
165
+ }
166
+
167
+ /**
168
+ * One value, read strictly.
169
+ *
170
+ * Strict is the whole point, and it was not before. The array reader used to
171
+ * pick quoted runs out of the line with a regex and ignore everything between
172
+ * them, so `["a" GARBAGE "b"]` parsed as `["a","b"]` and an unterminated string
173
+ * parsed as `""`. A permission file that is half-understood is worse than one
174
+ * that is rejected: the half that was dropped is the half you meant.
175
+ *
176
+ * So this scans rather than matches, and anything it does not recognise is an
177
+ * error with a line number. There are no escapes — a `"` ends the string. If a
178
+ * path of yours needs a quote in it, that is outside this subset and the
179
+ * message says so instead of guessing.
180
+ */
181
+ function readValue(value, lineNo) {
182
+ const v = value.trim();
183
+ const bad = (msg) => { throw new Error(`${CONFIG_NAME}:${lineNo}: ${msg}`); };
184
+
185
+ if (v === "true") return true;
186
+ if (v === "false") return false;
187
+
188
+ if (v.startsWith('"')) {
189
+ const end = v.indexOf('"', 1);
190
+ if (end === -1) bad(`unterminated string — no closing quote in ${v}`);
191
+ const rest = v.slice(end + 1).trim();
192
+ if (rest) bad(`unexpected ${JSON.stringify(rest)} after the string`);
193
+ return v.slice(1, end);
194
+ }
195
+
196
+ if (v.startsWith("[")) {
197
+ if (!v.endsWith("]")) bad("unterminated array — no closing bracket");
198
+ const items = [];
199
+ let i = 1;
200
+ const body = v.slice(0, -1); // everything up to the closing bracket
201
+ let expectItem = true; // a list alternates item, comma, item…
202
+
203
+ while (i < body.length) {
204
+ const c = body[i];
205
+ if (c === " " || c === "\t") { i++; continue; }
206
+
207
+ if (c === ",") {
208
+ if (expectItem) bad("empty array slot — two commas in a row, or a leading comma");
209
+ expectItem = true; i++; continue;
210
+ }
211
+ if (!expectItem) bad(`missing comma before ${JSON.stringify(body.slice(i, i + 12))}`);
212
+ // Whole numbers, for `local_ports`. Digits only: no sign, no decimal, no
213
+ // underscore — a port is none of those, and every other key still
214
+ // refuses a number through asArray.
215
+ const digits = /^\d+/.exec(body.slice(i));
216
+ if (digits) {
217
+ const after = body[i + digits[0].length];
218
+ if (after !== undefined && after !== "," && after !== " " && after !== "\t")
219
+ bad(`${JSON.stringify(body.slice(i, i + 12))} is not a whole number`);
220
+ items.push(Number(digits[0]));
221
+ i += digits[0].length;
222
+ expectItem = false;
223
+ continue;
224
+ }
225
+ if (c !== '"') bad(`array items must be double-quoted, found ${JSON.stringify(body.slice(i, i + 12))}`);
226
+
227
+ const end = body.indexOf('"', i + 1);
228
+ if (end === -1) bad("unterminated string inside the array");
229
+ // A backslash before the closing quote means somebody wrote `\"` and
230
+ // expected an escape. There are none — the quote just ended the string,
231
+ // and the error they get otherwise is about a missing comma somewhere in
232
+ // the middle of a shell pipeline, which names the symptom and hides the
233
+ // cause. Measured while writing the docs: every attempt at a one-line
234
+ // provider command landed here.
235
+ if (body[end - 1] === "\\")
236
+ bad(`there are no escapes in this format, so the \\" in ` +
237
+ `${JSON.stringify(body.slice(i, Math.min(end + 2, i + 40)))} ended the string early.\n` +
238
+ ` A value cannot contain a double quote. If this is a shell command, put it in a ` +
239
+ `script and name the script instead — which is also how it stops being unreadable.`);
240
+ items.push(body.slice(i + 1, end));
241
+ i = end + 1;
242
+ expectItem = false; // a trailing comma is fine; a trailing item is not
243
+ }
244
+ return items;
245
+ }
246
+
247
+ if (/^\d+$/.test(v)) return Number(v);
248
+
249
+ bad("value must be a string, an array of strings, a whole number, or a boolean");
250
+ }