seisin 0.6.4 → 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.
- package/README.md +143 -686
- package/package.json +1 -1
- package/src/canary.js +1 -1
- package/src/cli.js +2 -2
- package/src/commands/check.js +19 -11
- package/src/commands/log.js +21 -14
- package/src/commands/requests.js +10 -15
- package/src/commands/review.js +2 -1
- package/src/commands/run.js +275 -248
- package/src/commands/walls.js +12 -5
- package/src/config-fields.js +305 -0
- package/src/config.js +13 -550
- package/src/controls.js +1 -1
- package/src/decide-request.js +37 -0
- package/src/denial-attribution.js +338 -0
- package/src/grants.js +1 -1
- package/src/index.js +1 -1
- package/src/inspect/enforce.js +1 -1
- package/src/inspect/files.js +1 -1
- package/src/inspect/home.js +1 -1
- package/src/inspect/inject.js +1 -1
- package/src/intake.js +3 -2
- package/src/keys.js +1 -1
- package/src/layout.js +1 -1
- package/src/mcp.js +1 -1
- package/src/notify-config.js +58 -0
- package/src/notify.js +4 -52
- package/src/owners.js +2 -2
- package/src/rundir.js +2 -2
- package/src/sandboxes/command.js +0 -2
- package/src/sandboxes/srt.js +1 -1
- package/src/sandboxes/tartaros.js +1 -1
- package/src/serve.js +11 -22
- package/src/spool.js +1 -1
- package/src/{srt.js → srt-settings.js} +1 -1
- package/src/suggest.js +26 -1
- package/src/territory.js +12 -2
- package/src/toml.js +250 -0
- package/src/violations.js +16 -315
- package/src/walls.js +4 -2
- package/ui/index.html +27 -5
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,
|
|
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 =
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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.
|
|
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)) {
|
|
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(
|
|
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
|
+
}
|