@crewhaus/tool-executor 0.7.0 → 0.8.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.
@@ -0,0 +1,1688 @@
1
+ import { climbsAfterName, holdsUnexpanded, linesRunBy, normalizePathLexically, programWords, readsShellOnInput, shellProgramWords, withShellPwd, } from "@crewhaus/tool-permission-matcher";
2
+ import { validateToolInput } from "@crewhaus/tool-validate";
3
+ const kindsAgree = true;
4
+ void kindsAgree;
5
+ /**
6
+ * Parse `rawInput` with the tool's schema and read its operative values. The
7
+ * input passed here must be exactly the one that will be handed to
8
+ * `executeTool`, so the rule and the tool see the same call.
9
+ */
10
+ export function preparePermissionSubject(tool, rawInput, opts = {}) {
11
+ const validation = validateToolInput(tool, rawInput);
12
+ if (!validation.ok)
13
+ return { ok: false, reason: validation.error.message };
14
+ const operativeValues = operativeValuesFor(tool, validation.value, opts);
15
+ return {
16
+ ok: true,
17
+ input: validation.value,
18
+ ...(operativeValues !== undefined ? { operativeValues } : {}),
19
+ };
20
+ }
21
+ /**
22
+ * The canonical operative values of an ALREADY PARSED input, or `undefined`
23
+ * when the tool declares no `operativeArgs`, or declares `[]` (the matcher
24
+ * then falls back to the input's string values).
25
+ */
26
+ export function operativeValuesFor(tool, parsedInput, opts = {}) {
27
+ return valuesOf(tool.operativeArgs, parsedInput, opts, serviceDefaultsOf(tool, parsedInput, opts.toolConfig));
28
+ }
29
+ /**
30
+ * {@link operativeValuesFor} from a declaration alone — for a caller that has
31
+ * a tool's `operativeArgs` as data (the builtin manifest) but not the tool.
32
+ * The values are read the same way, except that a call naming no API in a
33
+ * `service` field is read without an origin: which API it goes to is the
34
+ * tool's to say. The caller is responsible for passing an input the tool's
35
+ * schema has parsed, when it can.
36
+ */
37
+ export function operativeValuesOf(operativeArgs, parsedInput, opts = {}) {
38
+ return valuesOf(operativeArgs, parsedInput, opts, undefined);
39
+ }
40
+ /** A service the tool could not name: nothing an allow could grant. */
41
+ const UNPLACEABLE_SERVICE = { url: "", otherOrigins: [], hosts: [] };
42
+ function serviceDefaultsOf(tool, parsedInput, toolConfig) {
43
+ const { serviceDefaults } = tool;
44
+ if (serviceDefaults === undefined)
45
+ return undefined;
46
+ let read;
47
+ try {
48
+ read = serviceDefaults(parsedInput, toolConfig);
49
+ }
50
+ catch {
51
+ // The tool cannot say where the call goes (a config it cannot read,
52
+ // which fails the call too): read as a place no allow names.
53
+ return { unnamed: UNPLACEABLE_SERVICE };
54
+ }
55
+ if (read === undefined)
56
+ return undefined;
57
+ const home = serviceOf(read.home).origin;
58
+ return { unnamed: serviceOf(read.unnamed), ...(home !== undefined ? { home } : {}) };
59
+ }
60
+ function valuesOf(operativeArgs, parsedInput, opts, services) {
61
+ if (operativeArgs === undefined || operativeArgs.length === 0)
62
+ return undefined;
63
+ const canonicalizePath = opts.canonicalizePath ?? lexicalPathValues;
64
+ const values = [];
65
+ // The defaults of relocating fields the call left out. Whether an allow
66
+ // reads them depends on what else the call carries, so they are placed
67
+ // last (see `relocates` on OperativeArg).
68
+ const relocated = [];
69
+ for (const arg of operativeArgs) {
70
+ const from = values.length;
71
+ for (const { value: raw, words, runsIn, env, stdin, dir, leavesDir, unqualified, every, anyAfter, defaulted, anyQualifier, prefixOf, service, overlay, } of readField(parsedInput, arg, services)) {
72
+ if (arg.relocates === true && defaulted === true) {
73
+ if (arg.kind === "url")
74
+ relocated.push(canonicalUrl(raw));
75
+ else {
76
+ // A store the tool reads whole, whatever record the call names
77
+ // (DatasetMine's sessions directory), is read with what is in it.
78
+ const { beneath } = arg;
79
+ const read = canonicalizePath(raw);
80
+ relocated.push(...(beneath !== undefined ? read.map((v) => withBeneath(v, beneath)) : read), ...openedValues(raw, arg, canonicalizePath));
81
+ }
82
+ continue;
83
+ }
84
+ switch (arg.kind) {
85
+ case "path": {
86
+ if (every === true) {
87
+ values.push(...everyPathValues(dir ?? ".", canonicalizePath));
88
+ break;
89
+ }
90
+ if (leavesDir !== undefined) {
91
+ values.push(leavingValue(raw, leavesDir.written));
92
+ break;
93
+ }
94
+ const read = canonicalizePath(raw);
95
+ const { beneath } = arg;
96
+ if (arg.glob === true) {
97
+ // A pattern the tool lists the paths of: the matcher reads it as
98
+ // every path it can list.
99
+ values.push(...read.map((v) => (v.outsideWorkspace === true ? v : { ...v, globPattern: true })));
100
+ break;
101
+ }
102
+ // A walk that skips hidden names opens them too when the call sets
103
+ // the switch its declaration names (Tree's `includeHidden`). A tool
104
+ // that acts beneath the path only when the call sets a switch
105
+ // (TempDir's `clear`) otherwise acts on the path alone.
106
+ const walk = arg.beneathWhen !== undefined && !switchedOn(parsedInput, arg.beneathWhen)
107
+ ? undefined
108
+ : beneath === "visible" &&
109
+ arg.hiddenWhen !== undefined &&
110
+ switchedOn(parsedInput, arg.hiddenWhen)
111
+ ? "all"
112
+ : beneath;
113
+ values.push(...(walk !== undefined ? read.map((v) => withBeneath(v, walk)) : read));
114
+ values.push(...openedValues(raw, arg, canonicalizePath));
115
+ break;
116
+ }
117
+ case "url":
118
+ values.push(every === true
119
+ ? { kind: "url", canonical: [ANY_VALUE], standsForAny: [""] }
120
+ : service !== undefined
121
+ ? apiRootValue(service)
122
+ : canonicalUrl(raw));
123
+ break;
124
+ case "command": {
125
+ const dirValues = runsIn !== undefined ? canonicalizePath(runsIn) : undefined;
126
+ const elsewhere = runsIn !== undefined && dirValues !== undefined && !namesWorkspaceRoot(dirValues);
127
+ const setsEnv = env !== undefined && Object.keys(env).length > 0;
128
+ const argv = words ?? raw.split(/\s+/).filter((w) => w !== "");
129
+ // A shell parses a string it is handed; an argv reaches one only
130
+ // when the program it names is a shell, an eval or an env -S.
131
+ const shellLine = arg.shell === true && words === undefined;
132
+ const shell = shellLine ? { shell: true } : {};
133
+ // The lines an argv hands a shell: one it runs with `-c`, and the
134
+ // text the call puts on the input of a shell given no script.
135
+ const shellLines = words !== undefined
136
+ ? [
137
+ ...linesRunBy(words),
138
+ ...(stdin !== undefined && readsShellOnInput(words) ? [stdin] : []),
139
+ ]
140
+ : [];
141
+ const where = { runsIn, readProgramPath: opts.readProgramPath };
142
+ const read = commandSpellings(argv, elsewhere ? runDirSpellings(runsIn, dirValues) : [], setsEnv ? env : undefined, canonicalizePath, where);
143
+ // The words that name a file, read where the OS finds them: an
144
+ // argv's own, and those of each command of a line a shell runs
145
+ // (the Bash tool's, or one an argv hands to `sh -c`). A string that
146
+ // is neither, and runs nowhere in particular, is code for a sandboxed
147
+ // interpreter (`Python`'s `code`), not a command line.
148
+ const found = programWords(argv);
149
+ const own = shellLine
150
+ ? readShellPrograms([raw], where)
151
+ : words !== undefined || arg.within !== undefined
152
+ ? readCommand(argv, found, [{ runsIn, prefix: "" }], where, {
153
+ rewrite: true,
154
+ shell: false,
155
+ })
156
+ : NOTHING_READ;
157
+ // A line an argv hands a shell starts where the wrappers before
158
+ // the shell moved to (`env -C dir sh -c …`).
159
+ const handed = readShellPrograms(shellLines, where, chdirPrefix(found));
160
+ // A word that lands elsewhere than it reads is put in its place for
161
+ // an allow. A line an argv hands a shell cannot have it put in, so
162
+ // no scoped allow grants the argv; one the Bash tool runs is
163
+ // granted only by an allow that also names each command with it
164
+ // put in (`placed`, below).
165
+ const ungrantable = own.unplaceable ||
166
+ own.ungrantable ||
167
+ handed.unplaceable ||
168
+ handed.moved ||
169
+ handed.ungrantable;
170
+ const unplaceable = read.unreadable || own.unplaceable || handed.unplaceable;
171
+ const programSpellings = shellLine ? [] : own.spellings;
172
+ if (elsewhere || setsEnv || ungrantable) {
173
+ // Run in another directory, or with an environment the call set,
174
+ // the same words may be another program: nothing canonical for an
175
+ // allow to grant. A deny or ask reads what was written, and each
176
+ // word also as the file it names there — `release.sh` run in
177
+ // `scripts/` is `scripts/release.sh`, which a deny may name.
178
+ values.push({
179
+ kind: "command",
180
+ canonical: [],
181
+ spellings: [
182
+ ...new Set([raw, ...(words ?? []), ...read.spellings, ...programSpellings]),
183
+ ],
184
+ ...shell,
185
+ ...(unplaceable ? { outsideWorkspace: true } : {}),
186
+ ...(every === true ? { standsForAny: [""] } : {}),
187
+ });
188
+ }
189
+ else {
190
+ // A program word that lands elsewhere than it reads is read where
191
+ // it lands: `./scripts/../evil.sh` is `./evil.sh`, which
192
+ // `RunCommand(./scripts/**)` does not grant.
193
+ const canonical = own.rewritten?.join(" ") ?? raw;
194
+ const spellings = [
195
+ ...new Set([
196
+ ...(canonical !== raw ? [raw] : []),
197
+ ...(words ?? []),
198
+ ...read.spellings,
199
+ ...programSpellings,
200
+ ]),
201
+ ];
202
+ values.push({
203
+ kind: "command",
204
+ canonical: [canonical],
205
+ ...(spellings.length > 0 ? { spellings } : {}),
206
+ ...shell,
207
+ ...(anyAfter !== undefined ? { standsForAny: anyAfter } : {}),
208
+ });
209
+ // A word node would open elsewhere than the kernel walks to is
210
+ // read in both places: `RunCommand(node safe/**)` does not grant
211
+ // `node lnk/../x.mjs` (lnk → safe/sub), which runs `./x.mjs`.
212
+ if (own.rewrittenAsText !== undefined) {
213
+ values.push({ kind: "command", canonical: [own.rewrittenAsText.join(" ")] });
214
+ }
215
+ // A line a shell parses cannot have a word put in its place, so
216
+ // an allow reads each command whose words moved, with them put
217
+ // in, as well as the line: `Bash(./scripts/**)` does not grant
218
+ // `./scripts/lnk/run.sh` (lnk → ../evil), and `Bash(node **)`
219
+ // still grants a script through a linked package directory.
220
+ for (const command of new Set(own.placed)) {
221
+ values.push({ kind: "command", canonical: [command] });
222
+ }
223
+ }
224
+ values.push(...shellLineValues(shellLines));
225
+ // What a deny or ask reads of the program words in a line a shell
226
+ // parses, as plain text: read as a line again, a word with its
227
+ // quoting taken off could split into commands the line never runs.
228
+ values.push(...programPlaceValues(shellLine ? [...own.spellings, ...handed.spellings] : handed.spellings, [...own.patterns, ...handed.patterns]));
229
+ break;
230
+ }
231
+ default: {
232
+ if (arg.secretRef === true) {
233
+ values.push(...secretRefValues(raw, canonicalizePath));
234
+ break;
235
+ }
236
+ const spellings = [...(words ?? []), ...(unqualified ?? [])];
237
+ const read = {
238
+ kind: arg.kind,
239
+ canonical: [raw],
240
+ ...(spellings.length > 0 ? { spellings } : {}),
241
+ ...(HEX_ID.test(raw) ? { caseInsensitive: true } : {}),
242
+ ...(anyAfter !== undefined ? { standsForAny: anyAfter } : {}),
243
+ ...(anyQualifier === true ? { anyQualifier: true } : {}),
244
+ // A prefix stands for every value that starts with it, and a
245
+ // key may hold `/`: what follows is any run, not one segment.
246
+ ...(prefixOf !== undefined ? { standsForAnyRun: true } : {}),
247
+ };
248
+ // An owner's own copy of the value (a tenant's overlay of a pin).
249
+ const value = overlay !== undefined ? overlaidValue(read, overlay) : read;
250
+ // A value on a service is that service's: an allow names its
251
+ // origin, a deny also reads the URL as a URL.
252
+ if (service !== undefined) {
253
+ values.push(servedValue(value, service));
254
+ if (service.url !== "") {
255
+ values.push({ ...canonicalUrl(service.url), restrictOnly: true });
256
+ // The tool drops one trailing slash from an API root before it
257
+ // joins a request path, so `…/api/v3/` is the API `…/api/v3`:
258
+ // a deny or ask written on that one meets it too.
259
+ if (service.url.endsWith("/") && service.url.length > 1) {
260
+ values.push({ ...canonicalUrl(service.url.slice(0, -1)), restrictOnly: true });
261
+ }
262
+ }
263
+ }
264
+ else
265
+ values.push(value);
266
+ // The prefix alone, for a deny or ask written without the
267
+ // qualifier (`alwaysDeny KvGet(apikey)`, as for any `within`
268
+ // field). Read one segment on: with a run, a deny naming another
269
+ // namespace's key (`secrets/apikey`) would fire on every listing
270
+ // with no prefix, whatever namespace it lists.
271
+ if (prefixOf?.unqualified !== undefined) {
272
+ values.push({
273
+ kind: arg.kind,
274
+ canonical: [],
275
+ standsForAny: [prefixOf.unqualified],
276
+ restrictOnly: true,
277
+ });
278
+ }
279
+ }
280
+ }
281
+ }
282
+ // Where the tool takes a value from, or a field it ignores because the
283
+ // call names another: a deny or ask reads it, an allow is about where
284
+ // the tool acts (`source` and `ignoredWhen` on OperativeArg).
285
+ if (arg.source === true ||
286
+ (arg.ignoredWhen !== undefined && qualifierOf(parsedInput, arg.ignoredWhen) !== undefined)) {
287
+ for (let i = from; i < values.length; i++) {
288
+ values[i] = { ...values[i], restrictOnly: true };
289
+ }
290
+ }
291
+ }
292
+ // A relocating field's default is a place the call did not name. When the
293
+ // call names a record there, an allow is about the record and skips the
294
+ // default; a deny or ask still reads it. With nothing else to read, the
295
+ // default is the call's only place, and every rule reads it.
296
+ if (values.length > 0) {
297
+ for (const v of relocated)
298
+ values.push({ ...v, restrictOnly: true });
299
+ }
300
+ else {
301
+ values.push(...relocated);
302
+ }
303
+ return values;
304
+ }
305
+ /**
306
+ * The lines an argv hands to a shell (`["sh", "-c", "…"]`), as values only a
307
+ * deny or ask reads: no allow is widened or narrowed by them, and a deny
308
+ * reads each simple command in them.
309
+ */
310
+ function shellLineValues(lines) {
311
+ if (lines.length === 0)
312
+ return [];
313
+ return [
314
+ { kind: "command", canonical: [], spellings: [...lines], shell: true, restrictOnly: true },
315
+ ];
316
+ }
317
+ /**
318
+ * Where the words of a command that name code it runs land, as values only a
319
+ * deny or ask reads, never read as a shell line: see {@link readCommand}.
320
+ * A word the shell expands as a pattern is read as every path it can list.
321
+ */
322
+ function programPlaceValues(spellings, patterns) {
323
+ const out = [];
324
+ if (spellings.length > 0) {
325
+ out.push({
326
+ kind: "command",
327
+ canonical: [],
328
+ spellings: [...new Set(spellings)],
329
+ restrictOnly: true,
330
+ });
331
+ }
332
+ if (patterns.length > 0) {
333
+ out.push({
334
+ kind: "command",
335
+ canonical: [],
336
+ spellings: [...new Set(patterns)],
337
+ globPattern: true,
338
+ restrictOnly: true,
339
+ });
340
+ }
341
+ return out;
342
+ }
343
+ /**
344
+ * A secret reference (`secretRef` on its OperativeArg), read the way
345
+ * `@crewhaus/tool-secrets`' `parseRef` reads it: with the whitespace around
346
+ * it trimmed, and its `file:` and `envfile:` forms naming a workspace file.
347
+ * Such a reference is matched with the file's canonical path in it — where
348
+ * it lands, `./`, `..` and links resolved — so `file:./secrets/token`,
349
+ * `file:pub/../secrets/token` and `file:pub/lnk/token` (a link to
350
+ * `../secrets`) are all `file:secrets/token`, which an allow must name and a
351
+ * deny on `file:secrets/**` names. The reference as written and trimmed is
352
+ * a spelling a deny or ask also reads, and the file is a path value only a
353
+ * deny or ask reads (`alwaysDeny *(secrets/**)`). A file outside the
354
+ * workspace is outside: every deny or ask fires, no allow grants it. Any
355
+ * other form (`env:NAME`, `keychain:…`, `op://…`) is an id. A bare NAME is
356
+ * an id an allow names as written; it is also looked up in the files of the
357
+ * local chain ({@link secretChain}), which a deny or ask reads as those
358
+ * references and those files, so `alwaysDeny *(.env)` fires on `API_KEY`.
359
+ * An `env:NAME` is an id an allow names as written too, but the environment
360
+ * it reads holds the chain's env files in every harness — the supervisor
361
+ * merges `.env.local` and `.env` into the environment it spawns a harness
362
+ * with, and Bun loads them from the directory it starts in — so a deny or ask
363
+ * reads those two the same way: `alwaysDeny *(.env)` fires on `env:API_KEY`.
364
+ */
365
+ function secretRefValues(raw, canonicalizePath) {
366
+ const ref = raw.trim();
367
+ const place = secretRefPlace(ref);
368
+ if (place === undefined) {
369
+ const id = {
370
+ kind: "id",
371
+ canonical: [ref],
372
+ ...(ref !== raw ? { spellings: [raw] } : {}),
373
+ ...(HEX_ID.test(ref) ? { caseInsensitive: true } : {}),
374
+ };
375
+ const envName = ENV_SECRET_REF.exec(ref)?.[1];
376
+ const steps = BARE_SECRET_NAME.test(ref)
377
+ ? secretChain(ref)
378
+ : envName !== undefined
379
+ ? secretChain(envName).filter((step) => step.scheme === "envfile")
380
+ : [];
381
+ return [
382
+ id,
383
+ ...steps.flatMap((step) => placeValues(step, canonicalizePath).map((v) => ({ ...v, restrictOnly: true }))),
384
+ ];
385
+ }
386
+ // The reference as written and as trimmed are spellings a deny reads too.
387
+ return placeValues(place, canonicalizePath).map((v) => v.kind === "id" ? { ...v, spellings: [...new Set([raw, ref, ...(v.spellings ?? [])])] } : v);
388
+ }
389
+ /**
390
+ * The file one `file:` or `envfile:` reference opens, as the values a rule
391
+ * reads: the reference with the file's canonical path in it, which an allow
392
+ * names, and the file itself as a path only a deny or ask reads.
393
+ */
394
+ function placeValues(place, canonicalizePath) {
395
+ const named = (path) => `${place.scheme}:${path}${place.key}`;
396
+ const out = [];
397
+ for (const file of canonicalizePath(place.path)) {
398
+ const spellings = [...new Set([named(place.path), ...(file.spellings ?? []).map(named)])];
399
+ out.push(file.outsideWorkspace === true
400
+ ? { kind: "id", canonical: [], spellings, outsideWorkspace: true }
401
+ : { kind: "id", canonical: file.canonical.map(named), spellings });
402
+ out.push({ ...file, restrictOnly: true });
403
+ }
404
+ return out;
405
+ }
406
+ /** A command none of whose words is read as a place. */
407
+ const NOTHING_READ = {
408
+ moved: false,
409
+ unplaceable: false,
410
+ ungrantable: false,
411
+ spellings: [],
412
+ patterns: [],
413
+ placed: [],
414
+ };
415
+ /** The words a shell runs as `NAME=value`, which name no file. */
416
+ const ASSIGNMENT_WORD = /^[A-Za-z_][A-Za-z0-9_]*=/;
417
+ /**
418
+ * A reference to a directory the shell itself sets, `PWD` or `OLDPWD`, in
419
+ * any spelling: `${PWD%/*}`, `$OLDPWD`, `$(dirname "$PWD")`. Only a plain
420
+ * `$PWD` or `${PWD}` at the start of a word is put in ({@link withShellPwd});
421
+ * any other is the text of a directory a `cd` went through, a link's name
422
+ * and all, which the gate cannot place.
423
+ */
424
+ const SHELL_DIRECTORY_VARIABLE = /\$\{?[#!]?(?:OLD)?PWD(?![A-Za-z0-9_])/;
425
+ /**
426
+ * Read the words of one command that name a file where the OS finds them,
427
+ * from each directory it may run in ({@link WordDir}):
428
+ *
429
+ * - its programs, through any wrappers, when written with a `/` — and a bare
430
+ * one on each entry of a PATH a wrapper or the line sets (`env PATH=dir
431
+ * x`, `PATH=dir x`), as an argv's `envSet` PATH is read;
432
+ * - each word an interpreter at the end is handed (its script, and what
433
+ * else it loads);
434
+ * - and every other word with a `/`, or any word in a directory the command
435
+ * moved to: an argument, which another program may still run (`caffeinate
436
+ * x`, `find -exec x`). An argv's words are each a spelling a deny reads, so
437
+ * where one lands is too; a shell line's are not, and are only bound to
438
+ * where they land for an approval.
439
+ *
440
+ * Read through `readProgramPath`, each lands where the kernel takes it:
441
+ * every directory on the way followed and each `..` applied after the link
442
+ * before it. A program or script that lands elsewhere than it reads — a
443
+ * `..` after a name, or a symlinked directory on the way — is put in its
444
+ * place ({@link placeForms}), so an allow reads where it lands; a deny or ask
445
+ * also reads that place in each of its forms, and, when the word itself is a
446
+ * symlink, where it leads. A program named by an absolute path outside the
447
+ * workspace with no `..` after a name is not put in its place: the system's
448
+ * own links (`/bin → usr/bin`) are not the call's to move, and an allow
449
+ * written `/bin/sh` still reads `/bin/sh`.
450
+ *
451
+ * Not every program opens a word the way the kernel walks it. node, `bun
452
+ * run` and a script whose `#!` line names node fold its `..` as text first
453
+ * (`path.resolve`, then its links followed), so `node lnk/../x.mjs` with
454
+ * `lnk → safe/sub` runs `./x.mjs`, not the `safe/x.mjs` the kernel walks to.
455
+ * So a word with a `..` after a name is also read that way
456
+ * ({@link foldedAsText}), and where the two readings land apart a deny or
457
+ * ask reads both. An allow must name both too — the command with the word in
458
+ * each place — unless the word is the script of an interpreter measured to
459
+ * open it through the kernel (`kernelScript`: a shell, Python 3, Perl,
460
+ * Ruby). A program run by its path is always read both ways: the
461
+ * interpreter its `#!` line names may be node. A command with more than one
462
+ * word read apart may run them in any mix of places, and no scoped allow
463
+ * grants it. A word a shell started with `$PWD` that climbs out of it is
464
+ * folded against the text of `$PWD` (see the module doc).
465
+ *
466
+ * A word cannot be placed — every deny or ask fires, and only an allow
467
+ * naming every command grants it — when it has a `..` after a name and no
468
+ * reader can follow the links before it (the edge worker), or it is read
469
+ * from a directory that cannot be told; when a shell's word names its own
470
+ * directory in a spelling not put in ({@link SHELL_DIRECTORY_VARIABLE});
471
+ * when a script is a pattern with a wildcard before its last name; or when
472
+ * the reader cannot walk it. A word
473
+ * from a directory that cannot be told, or that a variable the line does
474
+ * not set or a `~` starts, is otherwise read as written.
475
+ */
476
+ function readCommand(words, found, dirs, where, opts) {
477
+ const spellings = new Set();
478
+ const patterns = new Set();
479
+ const placed = new Set();
480
+ let rewritten;
481
+ let rewrittenAsText;
482
+ let moved = false;
483
+ let unplaceable = false;
484
+ let ungrantable = false;
485
+ const programs = new Set(found.programs);
486
+ const operands = new Set(found.operands);
487
+ // Collapsed as text, once: a deny written against the call's own words.
488
+ for (const i of [...found.programs, ...found.operands]) {
489
+ const word = opts.shell ? withShellPwd(words[i]) : words[i];
490
+ if (programs.has(i) && !/[\\/]/.test(word))
491
+ continue;
492
+ if (word.includes("://"))
493
+ continue;
494
+ for (const c of collapsedWords(word))
495
+ spellings.add(c);
496
+ }
497
+ for (const dir of dirs) {
498
+ // The words with every placed word in one of its forms, per form, and
499
+ // whether one of those is a pattern.
500
+ const lines = new Map();
501
+ const put = (slot, i, form, pattern) => {
502
+ let line = lines.get(slot);
503
+ if (line === undefined) {
504
+ line = { words: [...words], pattern: false };
505
+ lines.set(slot, line);
506
+ }
507
+ line.words[i] = form;
508
+ line.pattern ||= pattern;
509
+ (pattern ? patterns : spellings).add(form);
510
+ };
511
+ // The words with each one that moved put in its place, for an allow.
512
+ const inPlace = [...words];
513
+ let movedHere = false;
514
+ // A bare program found on a PATH entry that moved: one line each.
515
+ const lookups = [];
516
+ // Each word an allow reads in two places, with where it lands folded as
517
+ // text: the command with it there is one more an allow must name.
518
+ const apart = [];
519
+ /** Read one word, or one PATH candidate for word `i`; see above. */
520
+ const readWord = (i, word, role, lookup, fromPwd = false) => {
521
+ const runs = role === "program";
522
+ const pattern = opts.shell && opts.patterns?.has(i) === true && !lookup;
523
+ // The directory it is read from: the command's, then each wrapper's.
524
+ let prefix = dir?.prefix;
525
+ for (const { from, dir: to } of found.chdirs) {
526
+ if (from > i || prefix === undefined)
527
+ continue;
528
+ const written = opts.shell ? withShellPwd(to) : to;
529
+ prefix = opts.shell && holdsUnexpanded(written) ? undefined : joinedDir(prefix, written);
530
+ }
531
+ const cut = Math.max(word.lastIndexOf("/"), word.lastIndexOf("\\")) + 1;
532
+ const ownDir = word.slice(0, cut);
533
+ const known = dir !== undefined &&
534
+ prefix !== undefined &&
535
+ !(opts.shell && holdsUnexpanded(ownDir)) &&
536
+ !(opts.shell && word.startsWith("~"));
537
+ const climbs = climbsAfterName(word);
538
+ const elsewhere = dir === undefined || dir.runsIn !== where.runsIn || prefix !== "";
539
+ // A bare argument names no directory of its own: on an argv, read it
540
+ // where a wrapper moved the command; in a shell line, where only an
541
+ // approval would be bound to it, not at all.
542
+ if (role === "argument" && !/[\\/]/.test(word) && (opts.shell || !elsewhere))
543
+ return;
544
+ if (!known) {
545
+ // With a `..` after a name, or under a directory the shell set
546
+ // (`cd lnk && node ${PWD%/*}/dl/x.mjs` runs dl/x.mjs from the root):
547
+ // it may land anywhere.
548
+ const shellDir = opts.shell && SHELL_DIRECTORY_VARIABLE.test(ownDir);
549
+ if (role !== "argument" && (climbs || shellDir))
550
+ unplaceable = true;
551
+ return;
552
+ }
553
+ if (pattern && (/[*?[]/.test(ownDir) || climbs)) {
554
+ // A wildcard before its last name may match a symlinked directory.
555
+ if (role === "script")
556
+ unplaceable = true;
557
+ return;
558
+ }
559
+ const absolute = isAbsolutePath(word);
560
+ if (where.readProgramPath === undefined) {
561
+ const anyDotDot = word.split(/[\\/]/).includes("..");
562
+ if (role !== "argument" && (climbs || (elsewhere && !absolute && anyDotDot))) {
563
+ unplaceable = true;
564
+ }
565
+ return;
566
+ }
567
+ const readOpts = {
568
+ ...(role === "argument" || lookup ? { argument: true } : {}),
569
+ ...(prefix !== "" && !absolute ? { prefix } : {}),
570
+ };
571
+ const place = where.readProgramPath(word, dir.runsIn, readOpts);
572
+ // And with its `..` folded as text first, as node and `bun run` read
573
+ // it. `$PWD` is the text of where the shell runs, the name of each
574
+ // link a `cd` went through and all, so one that climbs out of it is
575
+ // folded against that text: after `cd lnk`, node runs `$PWD/../x.mjs`
576
+ // as `./x.mjs`. That text is the root and the `cd`s the shell joined;
577
+ // after a wrapper's or a `cd -P`'s move, or in a call's directory
578
+ // field, it cannot be told.
579
+ const asText = foldedAsText(word);
580
+ const abovePwd = fromPwd && (asText === ".." || asText.startsWith("../"));
581
+ const pwdKnown = where.runsIn === undefined && prefix === "";
582
+ const folded = abovePwd
583
+ ? pwdKnown
584
+ ? where.readProgramPath(foldedAsText(joinedDir(dir.runsIn ?? "", word)), undefined, readOpts)
585
+ : undefined
586
+ : climbs
587
+ ? where.readProgramPath(asText, dir.runsIn, readOpts)
588
+ : undefined;
589
+ const readsAsText = climbs || abovePwd;
590
+ if (place === undefined || (readsAsText && folded === undefined && role !== "argument")) {
591
+ if (role !== "argument")
592
+ unplaceable = true;
593
+ return;
594
+ }
595
+ const forms = placeForms(place.at, place.inWorkspace, word, runs);
596
+ const system = absolute && place.inWorkspace === undefined && !place.writtenInWorkspace;
597
+ const lands = climbs || (place.at !== place.asWritten && !system);
598
+ const led = place.target !== undefined
599
+ ? placeForms(place.target.at, place.target.inWorkspace, word, runs)
600
+ : [];
601
+ // Where the two readings land apart, the folded one in its forms.
602
+ const split = folded !== undefined && folded.at !== place.at ? folded : undefined;
603
+ const textForms = split !== undefined ? placeForms(split.at, split.inWorkspace, word, runs) : undefined;
604
+ const asTextLed = split?.target !== undefined
605
+ ? placeForms(split.target.at, split.target.inWorkspace, word, runs)
606
+ : [];
607
+ // An allow names both unless the word is a script its interpreter
608
+ // opens through the kernel.
609
+ if (textForms !== undefined && role !== "argument" && (runs || i !== found.kernelScript)) {
610
+ if (lookup)
611
+ lookups.push([i, textForms[0]]);
612
+ else
613
+ apart.push([i, textForms[0]]);
614
+ }
615
+ if (role === "argument") {
616
+ // An argv's words are spellings a deny reads; where one lands is too.
617
+ if (opts.shell || !(lands || led.length > 0 || elsewhere))
618
+ return;
619
+ }
620
+ else if (lands) {
621
+ moved = true;
622
+ movedHere = true;
623
+ if (lookup)
624
+ lookups.push([i, forms[0]]);
625
+ else
626
+ inPlace[i] = forms[0];
627
+ if (opts.rewrite) {
628
+ // A word read where a wrapper moved the command, or found on a
629
+ // PATH: one place in the argv cannot say so.
630
+ if (prefix !== "" || lookup)
631
+ ungrantable = true;
632
+ else {
633
+ rewritten ??= [...words];
634
+ rewritten[i] = forms[0];
635
+ }
636
+ }
637
+ }
638
+ forms.forEach((form, k) => put(k, i, form, pattern));
639
+ led.forEach((form, k) => put(2 + k, i, form, pattern));
640
+ textForms?.forEach((form, k) => put(4 + k, i, form, pattern));
641
+ asTextLed.forEach((form, k) => put(6 + k, i, form, pattern));
642
+ };
643
+ for (let i = 0; i < words.length; i++) {
644
+ const raw = words[i];
645
+ if (raw === "" || raw.includes("://"))
646
+ continue;
647
+ const word = opts.shell ? withShellPwd(raw) : raw;
648
+ const fromPwd = word !== raw;
649
+ if (programs.has(i)) {
650
+ if (/[\\/]/.test(word))
651
+ readWord(i, word, "program", false, fromPwd);
652
+ else {
653
+ const lookUp = (candidate) => readWord(i, candidate, "program", true);
654
+ if (!readLookups(i, word, found, opts, lookUp))
655
+ unplaceable = true;
656
+ }
657
+ continue;
658
+ }
659
+ if (operands.has(i)) {
660
+ readWord(i, word, "script", false, fromPwd);
661
+ continue;
662
+ }
663
+ if (word.startsWith("-") || ASSIGNMENT_WORD.test(word))
664
+ continue;
665
+ readWord(i, word, "argument", false, fromPwd);
666
+ }
667
+ for (const line of lines.values()) {
668
+ (line.pattern ? patterns : spellings).add(line.words.join(" "));
669
+ }
670
+ if (movedHere)
671
+ placed.add(inPlace.join(" "));
672
+ for (const [i, form] of [...lookups, ...apart.slice(0, 1)]) {
673
+ const line = [...inPlace];
674
+ line[i] = form;
675
+ placed.add(line.join(" "));
676
+ }
677
+ // Two words read apart may be read in any mix of their places, which no
678
+ // list of commands an allow must name can cover.
679
+ if (apart.length > 1)
680
+ ungrantable = true;
681
+ const [one] = apart;
682
+ if (one !== undefined && opts.rewrite && rewritten !== undefined) {
683
+ rewrittenAsText = [...rewritten];
684
+ rewrittenAsText[one[0]] = one[1];
685
+ }
686
+ }
687
+ return {
688
+ ...(rewritten !== undefined ? { rewritten } : {}),
689
+ ...(rewrittenAsText !== undefined ? { rewrittenAsText } : {}),
690
+ moved,
691
+ unplaceable,
692
+ ungrantable,
693
+ spellings: [...spellings],
694
+ patterns: [...patterns],
695
+ placed: opts.shell ? [...placed] : [],
696
+ };
697
+ }
698
+ /**
699
+ * A bare program looked up on the PATH a wrapper or the line set for it
700
+ * (the last one before it), entry by entry: `read` is handed each entry
701
+ * joined to the program. An entry that a variable the line does not set or
702
+ * a `~` starts is the harness's own PATH, looked up as before. False for a
703
+ * PATH of more than {@link MAX_ENV_WORDS} entries, which cannot be read.
704
+ */
705
+ function readLookups(i, program, found, opts, read) {
706
+ const path = found.paths.findLast((p) => p.from <= i);
707
+ if (path === undefined)
708
+ return true;
709
+ const entries = path.path.split(":");
710
+ for (const raw of entries.slice(0, MAX_ENV_WORDS)) {
711
+ const trimmed = raw === "" ? "." : withoutTrailingSeparators(raw) || "/";
712
+ const entry = opts.shell ? withShellPwd(trimmed) : trimmed;
713
+ if (opts.shell && holdsUnexpanded(entry))
714
+ continue;
715
+ read(`${entry === "/" ? "" : entry}/${program}`);
716
+ }
717
+ return entries.length <= MAX_ENV_WORDS;
718
+ }
719
+ /** `word` joined after the directory `dir` (`""` for none), as text: an absolute `word` stands alone. */
720
+ function joinedDir(dir, word) {
721
+ if (dir === "" || isAbsolutePath(word))
722
+ return word;
723
+ if (word === "")
724
+ return dir;
725
+ return /[\\/]$/.test(dir) ? `${dir}${word}` : `${dir}/${word}`;
726
+ }
727
+ /**
728
+ * The directories a command of a shell line may run in, as
729
+ * {@link readCommand} reads them: each of `dirs` (relative to where the
730
+ * line starts) from where it starts — the call's directory field, then
731
+ * `prefix`, walked, when an argv hands the line to a shell after a wrapper
732
+ * moved (`env -C dir sh -c …`). One that leaves where the line started by a
733
+ * leading `..` cannot be told when the shell starts somewhere other than the
734
+ * root: its PWD is then the physical directory, which the text of the
735
+ * directory field does not name.
736
+ */
737
+ function wordDirsOf(dirs, where, prefix) {
738
+ if (dirs === undefined || prefix === undefined)
739
+ return [undefined];
740
+ return dirs.map((d) => {
741
+ if (prefix !== "" && !isAbsolutePath(d.logical)) {
742
+ // The shell's PWD is physical already: a `cd` is walked from it, but
743
+ // one whose `..` follows a name is folded as text by the shell.
744
+ if (climbsAfterName(d.logical))
745
+ return undefined;
746
+ return {
747
+ runsIn: where.runsIn,
748
+ prefix: joinedDir(joinedDir(prefix, d.logical), d.physical),
749
+ };
750
+ }
751
+ if (d.logical === "")
752
+ return { runsIn: where.runsIn, prefix: d.physical };
753
+ if (isAbsolutePath(d.logical))
754
+ return { runsIn: d.logical, prefix: d.physical };
755
+ if (where.runsIn !== undefined && normalizePathLexically(d.logical).escapes)
756
+ return undefined;
757
+ return {
758
+ runsIn: where.runsIn === undefined ? d.logical : joinedDir(where.runsIn, d.logical),
759
+ prefix: d.physical,
760
+ };
761
+ });
762
+ }
763
+ /**
764
+ * Where the wrappers of an argv moved before the shell at its end (`env -C
765
+ * dir sh -c …`): their directories joined, as text the kernel walks.
766
+ */
767
+ function chdirPrefix(found) {
768
+ let prefix = "";
769
+ for (const { dir } of found.chdirs)
770
+ prefix = joinedDir(prefix, dir);
771
+ return prefix;
772
+ }
773
+ /**
774
+ * {@link readCommand} for each simple command of each line a shell parses,
775
+ * at every level a deny reads (`shellProgramWords`), with their quoting
776
+ * taken off, from each directory it may run in. `prefix` is where a wrapper
777
+ * moved the argv that hands the line to a shell (`""` for nowhere,
778
+ * `undefined` when that cannot be told).
779
+ */
780
+ function readShellPrograms(lines, where, prefix = "") {
781
+ const spellings = [];
782
+ const patterns = [];
783
+ const placed = [];
784
+ let moved = false;
785
+ let unplaceable = false;
786
+ let ungrantable = false;
787
+ for (const line of lines) {
788
+ for (const command of shellProgramWords(line)) {
789
+ // A script an interpreter reads on its input (`sh < x.sh`) is read as
790
+ // the one it is handed, and the `<` kept in front of it. The shell that
791
+ // runs the line opens it, through the kernel, whatever reads it.
792
+ const words = command.stdinScript !== undefined
793
+ ? [...command.words, "<", command.stdinScript]
794
+ : command.words;
795
+ const found = command.stdinScript !== undefined
796
+ ? {
797
+ ...command.programs,
798
+ operands: [...command.programs.operands, words.length - 1],
799
+ kernelScript: words.length - 1,
800
+ }
801
+ : command.programs;
802
+ const read = readCommand(words, found, wordDirsOf(command.dirs, where, prefix), where, {
803
+ rewrite: false,
804
+ shell: true,
805
+ patterns: new Set(command.patterns),
806
+ });
807
+ moved ||= read.moved;
808
+ unplaceable ||= read.unplaceable;
809
+ ungrantable ||= read.ungrantable;
810
+ spellings.push(...read.spellings);
811
+ patterns.push(...read.patterns);
812
+ placed.push(...read.placed);
813
+ }
814
+ }
815
+ return { moved, unplaceable, ungrantable, spellings, patterns, placed };
816
+ }
817
+ /**
818
+ * The forms a rule reads of a place a command word lands at: first the one
819
+ * written the way the call wrote the word — absolute when the word was, or
820
+ * when the place is outside the workspace; else relative to the workspace
821
+ * root, with `./` when the word had one, or when it is a program and the
822
+ * place has no `/` left (`deploy.sh` alone would be looked up on PATH) —
823
+ * then the place relative to the root. The workspace's own absolute path is
824
+ * not one of them unless the call wrote it: `RunCommand(**prod**)` does not
825
+ * fire on `sh x.sh` because the workspace lives in `prod-agent/`.
826
+ */
827
+ function placeForms(at, inWorkspace, word, runs) {
828
+ if (inWorkspace === undefined)
829
+ return [at, at];
830
+ const dotted = startsWithDotSlash(word) || (runs && !inWorkspace.includes("/"));
831
+ const written = isAbsolutePath(word)
832
+ ? at
833
+ : dotted && inWorkspace !== "."
834
+ ? `./${inWorkspace}`
835
+ : inWorkspace;
836
+ return [written, inWorkspace];
837
+ }
838
+ /** `./` or `.\\` at the start: a word the call wrote relative to its directory. */
839
+ function startsWithDotSlash(word) {
840
+ return word.startsWith("./") || word.startsWith(".\\");
841
+ }
842
+ /** A bare reference: an environment-variable name, which the local chain looks up. */
843
+ const BARE_SECRET_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
844
+ /** An `env:NAME` reference, as `parseRef` accepts it; the name is group 1. */
845
+ const ENV_SECRET_REF = /^env:([A-Za-z_][A-Za-z0-9_]*)$/;
846
+ /**
847
+ * The files a bare NAME is looked up in after the environment, highest
848
+ * precedence first: `@crewhaus/tool-secrets`' `chainRefs` (the harness
849
+ * supervisor's `.env.local` and `.env`, then the secrets directory). That
850
+ * package's tests hold this list to its own.
851
+ */
852
+ function secretChain(name) {
853
+ return [
854
+ { scheme: "envfile", path: ".env.local", key: `#${name}` },
855
+ { scheme: "envfile", path: ".env", key: `#${name}` },
856
+ { scheme: "file", path: `.crewhaus/secrets/${name}`, key: "" },
857
+ ];
858
+ }
859
+ /**
860
+ * The file a trimmed `file:<path>` or `envfile:<path>#KEY` reference opens,
861
+ * split as `parseRef` splits it (an `envfile:` at its LAST `#`), with the
862
+ * rest of the reference around it; `undefined` for any other form, and for
863
+ * one the tool refuses whole (an empty path, no `#KEY`, a newline or NUL).
864
+ */
865
+ function secretRefPlace(ref) {
866
+ if (/[\0\n\r]/.test(ref))
867
+ return undefined;
868
+ if (ref.startsWith("file:")) {
869
+ const path = ref.slice("file:".length);
870
+ return path === "" ? undefined : { scheme: "file", path, key: "" };
871
+ }
872
+ if (ref.startsWith("envfile:")) {
873
+ const rest = ref.slice("envfile:".length);
874
+ const hash = rest.lastIndexOf("#");
875
+ return hash <= 0
876
+ ? undefined
877
+ : { scheme: "envfile", path: rest.slice(0, hash), key: rest.slice(hash) };
878
+ }
879
+ return undefined;
880
+ }
881
+ /** A value that ends in a `0x` (or `0X`) hex id, after any `<qualifier>/`. */
882
+ const HEX_ID = /(?:^|\/)0[xX][0-9a-fA-F]+$/;
883
+ /** Do these canonical values name the workspace root itself, and nothing else? */
884
+ function namesWorkspaceRoot(values) {
885
+ return (values.length > 0 && values.every((v) => v.outsideWorkspace !== true && v.canonical[0] === "."));
886
+ }
887
+ /**
888
+ * Every value of one declared field. Dots descend into objects; an array
889
+ * anywhere on the way is walked element by element — except a `command`
890
+ * field that IS an array of strings, which is one argv and is joined. A
891
+ * field declared `within` another comes back as `<qualifier>/<value>` — a
892
+ * `..` path too, so a row shows the directory it climbs from — and an
893
+ * absolute path as it is.
894
+ */
895
+ export function readOperativeField(input, arg) {
896
+ return readField(input, arg).map((r) => {
897
+ const value = r.overlay !== undefined ? `${r.overlay}/${r.value}` : r.value;
898
+ // A URL that is its own service is the API already.
899
+ if (isApiRoot(arg))
900
+ return value;
901
+ return r.service?.origin !== undefined ? `${r.service.origin}/${value}` : value;
902
+ });
903
+ }
904
+ /** A `url` declared as its own `service`: the API a call goes to, itself. */
905
+ function isApiRoot(arg) {
906
+ return arg.kind === "url" && arg.service !== undefined && arg.service === arg.field;
907
+ }
908
+ /**
909
+ * {@link readValues}, with the API the call goes to for a `service` field:
910
+ * the one it names, else the one the tool's config sends it to — the same
911
+ * for every value of the field, since it is a top-level one.
912
+ */
913
+ function readField(input, arg, services) {
914
+ const values = readValues(input, arg);
915
+ const owner = arg.overlay !== undefined ? qualifierOf(input, arg.overlay) : undefined;
916
+ const read = owner !== undefined ? values.map((r) => ({ ...r, overlay: owner })) : values;
917
+ if (arg.service === undefined)
918
+ return read;
919
+ const url = qualifierOf(input, arg.service);
920
+ const reading = url !== undefined ? serviceOf(url) : services?.unnamed;
921
+ if (reading === undefined)
922
+ return read;
923
+ const home = reading.origin !== undefined && reading.origin === services?.home;
924
+ const service = home ? { ...reading, home: true } : reading;
925
+ // The API itself, left out: the one the tool's config sends the call to
926
+ // stands in, so a rule on that host meets the call whichever field (or
927
+ // none) picked it.
928
+ if (isApiRoot(arg) && read.length === 0)
929
+ return [{ value: reading.url, service }];
930
+ return read.map((r) => ({ ...r, service }));
931
+ }
932
+ /** How a rule reads the service URL a call names: see {@link ServiceReading}. */
933
+ function serviceOf(raw) {
934
+ let url;
935
+ try {
936
+ url = new URL(raw);
937
+ }
938
+ catch {
939
+ return { url: raw, otherOrigins: [], hosts: [] };
940
+ }
941
+ if ((url.protocol !== "https:" && url.protocol !== "http:") ||
942
+ url.hostname === "" ||
943
+ url.username !== "" ||
944
+ url.password !== "") {
945
+ return { url: raw, otherOrigins: [], hosts: [] };
946
+ }
947
+ const names = new Set([url.hostname, withoutTrailingDots(url.hostname)]);
948
+ names.delete("");
949
+ const hosts = new Set();
950
+ for (const name of names) {
951
+ for (const port of new Set([url.port, ""]))
952
+ hosts.add(port !== "" ? `${name}:${port}` : name);
953
+ }
954
+ const schemes = url.protocol === "https:" ? ["https:", "http:"] : ["http:", "https:"];
955
+ const others = new Set();
956
+ for (const scheme of schemes) {
957
+ for (const host of hosts) {
958
+ const origin = `${scheme}//${host}`;
959
+ if (origin !== url.origin)
960
+ others.add(origin);
961
+ }
962
+ }
963
+ return { url: raw, origin: url.origin, otherOrigins: [...others], hosts: [...hosts] };
964
+ }
965
+ /** `host` without the root dots at its end, in one pass from the end. */
966
+ function withoutTrailingDots(host) {
967
+ let end = host.length;
968
+ while (end > 0 && host.charCodeAt(end - 1) === 0x2e)
969
+ end--;
970
+ return host.slice(0, end);
971
+ }
972
+ /**
973
+ * A value of a field declared `overlay`, read for a call that names whose
974
+ * copy it acts on: matched with the owner in front, so an allow must name it;
975
+ * a deny or ask also reads the value without it (the shared copy's name) and
976
+ * the owner alone.
977
+ */
978
+ function overlaidValue(value, owner) {
979
+ const at = (v) => `${owner}/${v}`;
980
+ const { standsForAny } = value;
981
+ return {
982
+ ...value,
983
+ canonical: value.canonical.map(at),
984
+ spellings: [...new Set([...value.canonical, ...(value.spellings ?? []), owner])],
985
+ ...(standsForAny !== undefined
986
+ ? { standsForAny: [...new Set([...standsForAny.map(at), ...standsForAny])] }
987
+ : {}),
988
+ };
989
+ }
990
+ /**
991
+ * A value of a field declared `service`, read for a call to that service:
992
+ * matched with the service's origin in front, so an allow must name it —
993
+ * unless the service is the tool's `home`, whose values are also matched
994
+ * without it, first. A deny or ask also reads the value without the origin
995
+ * and with the origin's other spellings. A URL that cannot be placed leaves
996
+ * nothing an allow could grant, and every deny or ask fires.
997
+ */
998
+ function servedValue(value, service) {
999
+ const own = [...value.canonical, ...(value.spellings ?? [])];
1000
+ const { origin } = service;
1001
+ if (origin === undefined) {
1002
+ return {
1003
+ kind: value.kind,
1004
+ canonical: [],
1005
+ spellings: [...own, ...(service.url !== "" ? [service.url] : [])],
1006
+ outsideWorkspace: true,
1007
+ };
1008
+ }
1009
+ const at = (o, v) => `${o}/${v}`;
1010
+ const others = [...service.otherOrigins, ...service.hosts];
1011
+ const { standsForAny } = value;
1012
+ return {
1013
+ ...value,
1014
+ canonical: [
1015
+ ...(service.home === true ? value.canonical : []),
1016
+ ...value.canonical.map((c) => at(origin, c)),
1017
+ ],
1018
+ spellings: [
1019
+ ...new Set([...others.flatMap((o) => value.canonical.map((c) => at(o, c))), ...own]),
1020
+ ],
1021
+ ...(standsForAny !== undefined
1022
+ ? {
1023
+ standsForAny: [
1024
+ ...new Set([
1025
+ ...[origin, ...others].flatMap((o) => standsForAny.map((p) => at(o, p))),
1026
+ ...standsForAny,
1027
+ ]),
1028
+ ],
1029
+ }
1030
+ : {}),
1031
+ };
1032
+ }
1033
+ /**
1034
+ * The API a call goes to, for a `url` declared as its own `service`
1035
+ * (RateLimitStatus's `baseUrl`): the one the call names or the one its
1036
+ * config sends it to, read the way the tool reaches it. The tool drops one
1037
+ * trailing slash and appends each request path as text, so `…/api/v3`,
1038
+ * `…/api/v3/`, `…/api/v3/.` and `…/api/v3/x/..` are one API, which is
1039
+ * matched with or without a slash at the end (as `https://example.com` and
1040
+ * `https://example.com/` are one URL). A deny or ask also reads it after the
1041
+ * origin's other spellings (no root dot, no port, the other of http and
1042
+ * https) and after the host alone (`ghe.example.com/api/v3`), as for a value
1043
+ * on a service, as written, and with each run of slashes read as one, as
1044
+ * many servers read a path: the tool reaches `…/api/v3//` as
1045
+ * `…/api/v3//rate_limit`, which an allow on `…/api/v3` does not grant and a
1046
+ * deny on it stops. A URL that cannot be placed, or that the tool refuses as
1047
+ * a root because it carries a query or a fragment, leaves nothing an allow
1048
+ * could grant, and every deny or ask fires.
1049
+ */
1050
+ function apiRootValue(service) {
1051
+ const { origin } = service;
1052
+ const root = origin !== undefined ? joinedApiRoot(service.url, origin) : undefined;
1053
+ if (origin === undefined || root === undefined) {
1054
+ return {
1055
+ kind: "url",
1056
+ canonical: [],
1057
+ spellings: service.url !== "" ? [service.url] : [],
1058
+ outsideWorkspace: true,
1059
+ };
1060
+ }
1061
+ const at = (path) => (o) => path === "" ? [`${o}/`, o] : [`${o}${path}`, `${o}${path}/`];
1062
+ const others = [...service.otherOrigins, ...service.hosts];
1063
+ const folded = root.replace(/\/{2,}/g, "/").replace(/\/$/, "");
1064
+ return {
1065
+ kind: "url",
1066
+ canonical: at(root)(origin),
1067
+ spellings: [
1068
+ ...new Set([
1069
+ service.url,
1070
+ ...others.flatMap(at(root)),
1071
+ ...(folded !== root ? [origin, ...others].flatMap(at(folded)) : []),
1072
+ ]),
1073
+ ],
1074
+ };
1075
+ }
1076
+ /**
1077
+ * The path of the API root at `raw` as a tool that drops one trailing slash
1078
+ * and appends its request paths as text reaches it, without its last `/`:
1079
+ * `/api/v3` for `https://h/api/v3/.`, `""` for the origin's own root.
1080
+ * Undefined when a path appended to it would land in a query or a fragment,
1081
+ * or on another origin.
1082
+ */
1083
+ function joinedApiRoot(raw, origin) {
1084
+ const text = raw.endsWith("/") ? raw.slice(0, -1) : raw;
1085
+ let url;
1086
+ try {
1087
+ url = new URL(`${text}/`);
1088
+ }
1089
+ catch {
1090
+ return undefined;
1091
+ }
1092
+ const { pathname } = url;
1093
+ if (url.origin !== origin || url.search !== "" || url.hash !== "" || !pathname.endsWith("/")) {
1094
+ return undefined;
1095
+ }
1096
+ return pathname.slice(0, -1);
1097
+ }
1098
+ function readValues(input, arg) {
1099
+ const segments = arg.field.split(".");
1100
+ const out = [];
1101
+ const topQualifier = arg.within !== undefined ? qualifierOf(input, arg.within) : undefined;
1102
+ // A command runs in its own object's directory field (a pipeline step's
1103
+ // `cwd`), else the top-level one.
1104
+ const runsInOf = (owner) => {
1105
+ if (arg.kind !== "command" || arg.within === undefined)
1106
+ return {};
1107
+ const dir = qualifierOf(owner, arg.within) ?? topQualifier;
1108
+ return dir !== undefined ? { runsIn: dir } : {};
1109
+ };
1110
+ // And with its own object's environment field, else the top-level one.
1111
+ const topEnv = arg.env !== undefined ? envMapOf(input, arg.env) : undefined;
1112
+ const envOf = (owner) => {
1113
+ if (arg.kind !== "command" || arg.env === undefined)
1114
+ return {};
1115
+ const env = envMapOf(owner, arg.env) ?? topEnv;
1116
+ return env !== undefined ? { env } : {};
1117
+ };
1118
+ // And with its own object's input text, else the top-level one.
1119
+ const topStdin = arg.stdin !== undefined ? qualifierOf(input, arg.stdin) : undefined;
1120
+ const stdinOf = (owner) => {
1121
+ if (arg.kind !== "command" || arg.stdin === undefined)
1122
+ return {};
1123
+ const text = (owner !== input ? qualifierOf(owner, arg.stdin) : undefined) ?? topStdin;
1124
+ return text !== undefined ? { stdin: text } : {};
1125
+ };
1126
+ const placed = (owner) => ({ ...runsInOf(owner), ...envOf(owner), ...stdinOf(owner) });
1127
+ const walk = (value, i, depth, owner) => {
1128
+ if (depth > 64)
1129
+ return;
1130
+ if (Array.isArray(value)) {
1131
+ if (i === segments.length &&
1132
+ arg.kind === "command" &&
1133
+ value.length > 0 &&
1134
+ value.every((v) => typeof v === "string")) {
1135
+ out.push({ value: value.join(" "), words: value, ...placed(owner) });
1136
+ return;
1137
+ }
1138
+ for (const element of value)
1139
+ walk(element, i, depth + 1, owner);
1140
+ return;
1141
+ }
1142
+ if (i === segments.length) {
1143
+ if (typeof value === "string")
1144
+ out.push({ value, ...placed(owner) });
1145
+ else if (arg.kind === "id" && typeof value === "number" && Number.isFinite(value)) {
1146
+ out.push({ value: String(value) });
1147
+ }
1148
+ return;
1149
+ }
1150
+ if (value === null || typeof value !== "object")
1151
+ return;
1152
+ const key = segments[i];
1153
+ if (!Object.hasOwn(value, key))
1154
+ return;
1155
+ walk(value[key], i + 1, depth + 1, value);
1156
+ };
1157
+ walk(input, 0, 0, input);
1158
+ if (arg.prefix === true && arg.kind !== "path" && arg.kind !== "url" && arg.kind !== "command") {
1159
+ return prefixReadings(out, arg, topQualifier);
1160
+ }
1161
+ // A default of `*` is "every value": EvmGetLogs without an `address`
1162
+ // reads every contract's logs.
1163
+ const every = out.length === 0 && arg.default === ANY_VALUE;
1164
+ if (out.length === 0 && arg.default !== undefined) {
1165
+ out.push({ value: arg.default, ...placed(input), defaulted: true });
1166
+ }
1167
+ const anyValue = (rs) => every ? rs.map((r) => ({ ...r, every: true, anyAfter: [""] })) : rs;
1168
+ // A command's `within` is where it runs, carried as `runsIn` above.
1169
+ if (arg.kind === "command")
1170
+ return anyValue(out);
1171
+ // A path whose directory the call left out is relative to the one the
1172
+ // tool uses then, when it declares one (TempDir's `.tmp`).
1173
+ const qualifier = topQualifier ?? (arg.kind === "path" ? arg.withinDefault : undefined);
1174
+ if (qualifier === undefined) {
1175
+ // A path within a directory the call left out is relative to the root,
1176
+ // and handed on as written all the same.
1177
+ if (arg.kind === "path" && arg.within !== undefined && !every) {
1178
+ return out.map((r) => leavesItsDirectory(r.value) ? { ...r, leavesDir: { written: r.value } } : r);
1179
+ }
1180
+ // The qualifier is left out as well: a code search with no owner reaches
1181
+ // every repository, so the value stands for every `<owner>/<repo>` too.
1182
+ const read = anyValue(out);
1183
+ return every && arg.within !== undefined
1184
+ ? read.map((r) => ({ ...r, anyQualifier: true }))
1185
+ : read;
1186
+ }
1187
+ return out.map((r) => {
1188
+ // A path relative to a directory field.
1189
+ if (arg.kind === "path") {
1190
+ if (every)
1191
+ return { ...r, value: `${qualifier}/${r.value}`, dir: qualifier, every: true };
1192
+ // A git command given no path acts on the whole repository, wherever
1193
+ // it runs: its default is the workspace root, not the directory.
1194
+ if (r.defaulted === true && arg.defaultAtRoot === true)
1195
+ return r;
1196
+ // One that leaves it is still shown joined to it — an approver needs to
1197
+ // see what `..` climbs from — but not read that way (`leavingValue`).
1198
+ if (leavesItsDirectory(r.value)) {
1199
+ const value = isAbsolutePath(r.value) ? r.value : `${qualifier}/${r.value}`;
1200
+ return { ...r, value, leavesDir: { written: r.value } };
1201
+ }
1202
+ return { ...r, value: `${qualifier}/${r.value}` };
1203
+ }
1204
+ return {
1205
+ ...r,
1206
+ value: `${qualifier}/${r.value}`,
1207
+ unqualified: [r.value, qualifier],
1208
+ ...(every ? { every: true, anyAfter: [`${qualifier}/`, ""] } : {}),
1209
+ };
1210
+ });
1211
+ }
1212
+ /** The declared default that stands for every value of its field. */
1213
+ const ANY_VALUE = "*";
1214
+ /**
1215
+ * The readings of a field declared `prefix` (KvList's `prefix`): each value
1216
+ * `p` stands for every value that starts with it, and one left out is the
1217
+ * empty prefix — every value. Qualified, `<qualifier>/<p>` is followed by any
1218
+ * run (a key may hold `/`), and `<p>` alone is kept for a deny written
1219
+ * without the qualifier. With the qualifier left out too, the value stands
1220
+ * for every `<qualifier>/<value>` there is: an allow must name every value.
1221
+ */
1222
+ function prefixReadings(out, arg, qualifier) {
1223
+ const prefixes = out.length > 0 ? out.map((r) => r.value) : [""];
1224
+ return prefixes.map((p) => {
1225
+ if (qualifier === undefined) {
1226
+ const anyQualifier = arg.within !== undefined;
1227
+ const start = anyQualifier ? "" : p;
1228
+ return {
1229
+ value: `${start}${ANY_VALUE}`,
1230
+ every: true,
1231
+ anyAfter: [start],
1232
+ prefixOf: {},
1233
+ ...(out.length === 0 ? { defaulted: true } : {}),
1234
+ };
1235
+ }
1236
+ return {
1237
+ value: `${qualifier}/${p}${ANY_VALUE}`,
1238
+ unqualified: [`${p}${ANY_VALUE}`, qualifier],
1239
+ every: true,
1240
+ anyAfter: [`${qualifier}/${p}`],
1241
+ prefixOf: { unqualified: p },
1242
+ ...(out.length === 0 ? { defaulted: true } : {}),
1243
+ };
1244
+ });
1245
+ }
1246
+ /**
1247
+ * Every path under `dir` — a path field left out whose default is `*` — as
1248
+ * the values a rule reads: for each place `dir` resolves to, its spellings
1249
+ * as prefixes (`sub/`, `./sub/`, `/abs/ws/sub/`; the workspace root is the
1250
+ * empty prefix and `./`) and `<prefix>*` as the canonical spelling an allow
1251
+ * must cover whole. A directory outside the workspace stays outside: every
1252
+ * deny or ask fires on it, and no allow does.
1253
+ */
1254
+ function everyPathValues(dir, canonicalizePath) {
1255
+ const under = (p) => (p === "." ? "" : p.endsWith("/") ? p : `${p}/`);
1256
+ return canonicalizePath(dir).map((v) => {
1257
+ if (v.outsideWorkspace === true)
1258
+ return v;
1259
+ return {
1260
+ kind: "path",
1261
+ canonical: v.canonical.map((c) => `${under(c)}${ANY_VALUE}`),
1262
+ standsForAny: underPrefixes([...v.canonical, ...(v.spellings ?? [])]),
1263
+ ...(v.caseInsensitive === true ? { caseInsensitive: true } : {}),
1264
+ };
1265
+ });
1266
+ }
1267
+ /**
1268
+ * Does a path declared `within` a directory leave it: is it absolute, or does
1269
+ * it have a `..` segment? Where such a path lands cannot be read off the
1270
+ * directory's values.
1271
+ *
1272
+ * - The tool hands the path on as written, to a program that runs in the
1273
+ * directory (git, a formatter, a test runner), and the OS follows each
1274
+ * symlink BEFORE it applies the `..` after it. Through a symlinked
1275
+ * directory (`cwd: "link"`, `link → secrets/deep`), `../x.ts` is
1276
+ * `secrets/x.ts`; through a link inside the path itself (`lnk/../x.ts`),
1277
+ * it climbs out of the link's target, which no join of the path to the
1278
+ * directory can see without a filesystem to ask. Read like every other
1279
+ * path, with `..` collapsed first, both were `x.ts`.
1280
+ * - An absolute path ignores the directory for a program that resolves it,
1281
+ * but not for `path.join`, which a tool may check it with: the two name
1282
+ * different files.
1283
+ *
1284
+ * `\` counts as a separator too, as in tool-git's pathspec check: splitting
1285
+ * at it can only flag more.
1286
+ */
1287
+ function leavesItsDirectory(value) {
1288
+ return isAbsolutePath(value) || value.split(/[\\/]/).includes("..");
1289
+ }
1290
+ /**
1291
+ * The value of a path that leaves its directory ({@link leavesItsDirectory}):
1292
+ * outside the workspace, so no allow rule names it and every deny or ask fires
1293
+ * on it. Every builtin that declares such a field refuses the path itself;
1294
+ * this keeps a rule about another place from granting it to a tool that does
1295
+ * not. The spellings are the path as written, and joined to the directory as
1296
+ * written (`joined`, the reading's value; the same string when the call gave
1297
+ * no directory or the path is absolute).
1298
+ */
1299
+ function leavingValue(joined, written) {
1300
+ const spellings = joined === written ? [written] : [written, joined];
1301
+ return { kind: "path", canonical: [], spellings, outsideWorkspace: true };
1302
+ }
1303
+ /**
1304
+ * A path the tool walks when it names a directory (`beneath` on its
1305
+ * OperativeArg): each of its spellings as a prefix a deny or ask reads with
1306
+ * anything after it. A path the canonicaliser found to be an existing
1307
+ * non-directory stands for itself alone, and one outside the workspace
1308
+ * already fires every deny.
1309
+ */
1310
+ function withBeneath(value, mode) {
1311
+ if (value.outsideWorkspace === true || value.notDirectory === true)
1312
+ return value;
1313
+ return {
1314
+ ...value,
1315
+ beneath: underPrefixes([...value.canonical, ...(value.spellings ?? [])]),
1316
+ ...(mode === "visible" ? { beneathSkipsHidden: true } : {}),
1317
+ };
1318
+ }
1319
+ /**
1320
+ * The prefixes what lies under each of these directory spellings starts
1321
+ * with. The root — `.`, or the empty path a call may write for it — is the
1322
+ * empty prefix, never `/`, which would read as the filesystem root. A
1323
+ * relative prefix is also given its `./` spelling, as a direct path value
1324
+ * carries one (`secrets/x` is also `./secrets/x`): without it a deny written
1325
+ * `./secrets/**` fired on `Tree ./` but not on `Tree .`, `Tree {}` or
1326
+ * `Tree src/..`, which walk the same place.
1327
+ */
1328
+ function underPrefixes(spellings) {
1329
+ const out = new Set();
1330
+ for (const p of spellings) {
1331
+ const under = p === "." || p === "" ? "" : p.endsWith("/") ? p : `${p}/`;
1332
+ out.add(under);
1333
+ if (!isAbsolutePath(under) && !under.startsWith("./"))
1334
+ out.add(`./${under}`);
1335
+ }
1336
+ return [...out];
1337
+ }
1338
+ /**
1339
+ * The fixed places a tool opens inside the directory a `path` names (`opens`
1340
+ * on its OperativeArg — ApprovalStatus's ledger, PermissionsSuggest's
1341
+ * session logs), as values only a deny or ask reads: each joined to the
1342
+ * directory as the call wrote it and canonicalised like any path, so a link
1343
+ * or a `..` on the way is followed, and read with what is beneath it when the
1344
+ * tool reads it whole. An allow reads the directory alone.
1345
+ */
1346
+ function openedValues(dir, arg, canonicalizePath) {
1347
+ const out = [];
1348
+ for (const opening of arg.opens ?? []) {
1349
+ for (const v of canonicalizePath(joinBelow(dir, opening.path))) {
1350
+ const read = opening.beneath !== undefined ? withBeneath(v, opening.beneath) : v;
1351
+ out.push({ ...read, restrictOnly: true });
1352
+ }
1353
+ }
1354
+ return out;
1355
+ }
1356
+ /** `rel` inside `dir`: the root (`.`, `./`, the empty path) adds nothing in front. */
1357
+ function joinBelow(dir, rel) {
1358
+ const trimmed = withoutTrailingSeparators(dir);
1359
+ if (trimmed === "")
1360
+ return dir === "" ? rel : `/${rel}`;
1361
+ return trimmed === "." ? rel : `${trimmed}/${rel}`;
1362
+ }
1363
+ /**
1364
+ * The most variables, words in one value, and characters in one value, of an
1365
+ * environment a command's call sets that a deny or ask reads one by one. A
1366
+ * call past any of them is flagged `outsideWorkspace` instead: which program
1367
+ * it runs could not be worked out, so every deny or ask fires on it.
1368
+ */
1369
+ export const MAX_ENV_VARS = 64;
1370
+ export const MAX_ENV_WORDS = 64;
1371
+ export const MAX_ENV_VALUE_CHARS = 8192;
1372
+ /**
1373
+ * The workspace-relative spellings of the directory a command runs in, and
1374
+ * the one the call wrote, without trailing slashes: `scripts/` and
1375
+ * `./scripts` as written, `scripts` as canonicalised, `lnk` for a symlink
1376
+ * the canonicaliser followed to `scripts`. The canonicaliser's absolute
1377
+ * spellings of the workspace root are left out — the call named none of
1378
+ * them, and joining them put every word the workspace's own path holds
1379
+ * (`prod-agent/…`, `/private/var/…`) in front of every command run in a
1380
+ * subdirectory. Its `./sub` spellings are left out too: a word the call did
1381
+ * not write with `./` is not read with one, so `RunCommand(./**)` does not
1382
+ * fire on `git status` run in `sub/`.
1383
+ */
1384
+ function runDirSpellings(runsIn, dirValues) {
1385
+ const out = new Set();
1386
+ const add = (d) => {
1387
+ const trimmed = withoutTrailingSeparators(d);
1388
+ if (trimmed !== "" && trimmed !== ".")
1389
+ out.add(trimmed);
1390
+ };
1391
+ add(runsIn);
1392
+ for (const v of dirValues) {
1393
+ for (const d of [...v.canonical, ...(v.spellings ?? [])]) {
1394
+ if (isAbsolutePath(d) || d.startsWith("./") || d === ".")
1395
+ continue;
1396
+ add(d);
1397
+ }
1398
+ }
1399
+ return [...out];
1400
+ }
1401
+ /**
1402
+ * `text` without its trailing `/` and `\`: what `text.replace(/[\\/]+$/, "")`
1403
+ * returned, in one pass from the end. That pattern tries every start inside
1404
+ * a run of separators that is not at the end and rescans the rest of the run
1405
+ * from each, so it was quadratic in the run's length, and a directory or a
1406
+ * PATH entry is whatever the call wrote.
1407
+ */
1408
+ export function withoutTrailingSeparators(text) {
1409
+ let end = text.length;
1410
+ for (; end > 0; end--) {
1411
+ const c = text.charCodeAt(end - 1);
1412
+ if (c !== 0x2f && c !== 0x5c)
1413
+ break;
1414
+ }
1415
+ return end === text.length ? text : text.slice(0, end);
1416
+ }
1417
+ /**
1418
+ * `dir/word`, as written and with `.`, `..` and doubled `/` collapsed. A join
1419
+ * that climbs out of the directory it starts in is kept only as written: its
1420
+ * collapsed form would drop the climb.
1421
+ */
1422
+ function joinedSpellings(dir, word) {
1423
+ const written = `${dir}/${word}`;
1424
+ const lexical = normalizePathLexically(written);
1425
+ return lexical.escapes || lexical.path === written ? [written] : [written, lexical.path];
1426
+ }
1427
+ /**
1428
+ * A word with separators, collapsed the way {@link joinedSpellings} does,
1429
+ * when that changes it — and again with the `./` the call wrote kept, so
1430
+ * `alwaysDeny RunCommand(./deploy.sh)` reads `./a/../deploy.sh` as well.
1431
+ */
1432
+ function collapsedWords(word) {
1433
+ if (!/[\\/]/.test(word))
1434
+ return [];
1435
+ const lexical = normalizePathLexically(word);
1436
+ if (lexical.escapes || lexical.path === word || lexical.path === ".")
1437
+ return [];
1438
+ const dotted = startsWithDotSlash(word) ? `./${lexical.path}` : undefined;
1439
+ return dotted !== undefined && dotted !== word ? [lexical.path, dotted] : [lexical.path];
1440
+ }
1441
+ /**
1442
+ * A command word with its `.` and `..` folded as text, as `path.resolve`
1443
+ * folds them before anything is opened — the way node and `bun run` read
1444
+ * the script they are handed, and a `#!/usr/bin/env node` script the path it
1445
+ * was run by. A `..` after a name takes the name off; one at the start of a
1446
+ * relative word stays, climbing from the directory the command runs in; one
1447
+ * at the root of an absolute word stays at the root. A relative result
1448
+ * without a `/` keeps a `./`, so it still names a file in that directory,
1449
+ * not a program to look up.
1450
+ */
1451
+ export function foldedAsText(word) {
1452
+ const drive = /^[A-Za-z]:[\\/]/.test(word) ? word.slice(0, 2) : "";
1453
+ const absolute = drive !== "" || word.startsWith("/") || word.startsWith("\\");
1454
+ const names = [];
1455
+ for (const segment of word.slice(drive.length).split(/[\\/]/)) {
1456
+ if (segment === "" || segment === ".")
1457
+ continue;
1458
+ if (segment !== "..")
1459
+ names.push(segment);
1460
+ else if (names.length > 0 && names[names.length - 1] !== "..")
1461
+ names.pop();
1462
+ else if (!absolute)
1463
+ names.push("..");
1464
+ }
1465
+ const joined = names.join("/");
1466
+ if (absolute)
1467
+ return `${drive}/${joined}`;
1468
+ if (joined === "")
1469
+ return ".";
1470
+ return joined.includes("/") || joined === ".." ? joined : `./${joined}`;
1471
+ }
1472
+ /**
1473
+ * The spellings a deny or ask reads for one command beyond the words as
1474
+ * written. Every spelling only widens what a deny or ask catches; none is
1475
+ * canonical, so no allow reads them.
1476
+ *
1477
+ * - Each word that names a file through `.`, `..` or a doubled `/`, with
1478
+ * them collapsed, and the command line with those words collapsed.
1479
+ * - For a command run in a directory other than the root (`dirs`, its
1480
+ * relative spellings): each word that may name a file joined to the
1481
+ * directory — except a bare program name, which PATH finds, not the
1482
+ * directory — and the command line with those words joined, once per
1483
+ * directory. So `scripts/**` fires on `sh x` run in `scripts/`. The
1484
+ * directory on its own is not a spelling: `alwaysDeny RunCommand(rm*)` must
1485
+ * not fire on `ls` run in `rmtemp/`.
1486
+ * - For a call that sets its child's environment (`env`): a bare program as
1487
+ * PATH finds it on each entry the call sets — a relative entry is looked
1488
+ * up from the workspace root and run from the child's directory (Bun's
1489
+ * spawn does both), so both are read, and an absolute entry inside the
1490
+ * workspace is read from the root — and every value the call sets, each of
1491
+ * its words that may name a file (split at spaces, `:`, `=` and `;`, so
1492
+ * `--require=./hook.js` and `a:b` are read) as written, collapsed, and
1493
+ * joined to the child's directory.
1494
+ */
1495
+ function commandSpellings(argv, dirs, env, canonicalizePath, where) {
1496
+ const out = new Set();
1497
+ const program = argv[0] ?? "";
1498
+ const bareProgram = program !== "" && !/[\\/]/.test(program);
1499
+ // Words collapsed where the call wrote a separator: once as collapsed, and
1500
+ // once with the `./` the call wrote kept.
1501
+ const collapsed = argv.map((w) => (namesAFile(w) ? collapsedWords(w) : []));
1502
+ if (collapsed.some((c) => c.length > 0)) {
1503
+ for (const c of collapsed)
1504
+ for (const form of c)
1505
+ out.add(form);
1506
+ for (const k of [0, 1]) {
1507
+ out.add(argv.map((w, i) => collapsed[i]?.[k] ?? collapsed[i]?.[0] ?? w).join(" "));
1508
+ }
1509
+ }
1510
+ // Words joined to the directory the command runs in.
1511
+ for (const d of dirs) {
1512
+ const line = argv.map((w, i) => {
1513
+ if ((i === 0 && bareProgram) || !namesAFile(w))
1514
+ return w;
1515
+ const joined = joinedSpellings(d, w);
1516
+ for (const j of joined)
1517
+ out.add(j);
1518
+ return joined[joined.length - 1];
1519
+ });
1520
+ out.add(line.join(" "));
1521
+ }
1522
+ if (env === undefined)
1523
+ return { spellings: [...out], unreadable: false };
1524
+ const entries = Object.entries(env);
1525
+ let unreadable = entries.length > MAX_ENV_VARS;
1526
+ // Where a relative path in a value resolves: the child's directory.
1527
+ const places = dirs.length > 0 ? dirs : ["."];
1528
+ for (const [name, value] of entries.slice(0, MAX_ENV_VARS)) {
1529
+ if (value.length > MAX_ENV_VALUE_CHARS) {
1530
+ unreadable = true;
1531
+ continue;
1532
+ }
1533
+ if (value !== "")
1534
+ out.add(value);
1535
+ const words = value.split(/[\s:=;]+/).filter((w) => w !== "");
1536
+ if (words.length > MAX_ENV_WORDS)
1537
+ unreadable = true;
1538
+ for (const w of words.slice(0, MAX_ENV_WORDS)) {
1539
+ if (!namesAFile(w))
1540
+ continue;
1541
+ out.add(w);
1542
+ for (const c of collapsedWords(w))
1543
+ out.add(c);
1544
+ for (const d of dirs)
1545
+ for (const j of joinedSpellings(d, w))
1546
+ out.add(j);
1547
+ }
1548
+ if (name.toUpperCase() !== "PATH" || !bareProgram)
1549
+ continue;
1550
+ const pathEntries = value.split(":");
1551
+ if (pathEntries.length > MAX_ENV_WORDS)
1552
+ unreadable = true;
1553
+ for (const raw of pathEntries.slice(0, MAX_ENV_WORDS)) {
1554
+ const entry = raw === "" ? "." : withoutTrailingSeparators(raw) || "/";
1555
+ const found = new Set();
1556
+ if (isAbsolutePath(entry)) {
1557
+ found.add(`${entry === "/" ? "" : entry}/${program}`);
1558
+ for (const v of canonicalizePath(entry)) {
1559
+ if (v.outsideWorkspace === true)
1560
+ continue;
1561
+ for (const c of [...v.canonical, ...(v.spellings ?? [])]) {
1562
+ if (isAbsolutePath(c) || c.startsWith("./"))
1563
+ continue;
1564
+ found.add(c === "." ? program : `${c}/${program}`);
1565
+ }
1566
+ }
1567
+ }
1568
+ else {
1569
+ for (const j of joinedSpellings(entry, program))
1570
+ found.add(j);
1571
+ for (const d of places) {
1572
+ for (const j of joinedSpellings(d === "." ? entry : `${d}/${entry}`, program))
1573
+ found.add(j);
1574
+ }
1575
+ }
1576
+ // And where the kernel finds it there, each link followed before the
1577
+ // `..` after it: `PATH=link/..` is the directory above link's target.
1578
+ // A script found there whose `#!` line names node is opened again by
1579
+ // node, with the `..` folded as text: read there too.
1580
+ if (where.readProgramPath !== undefined) {
1581
+ const candidate = `${entry === "/" ? "" : entry}/${program}`;
1582
+ const from = isAbsolutePath(entry) ? [undefined] : [undefined, where.runsIn];
1583
+ const asRead = climbsAfterName(candidate)
1584
+ ? [candidate, foldedAsText(candidate)]
1585
+ : [candidate];
1586
+ for (const runsIn of new Set(from)) {
1587
+ for (const word of asRead) {
1588
+ const place = where.readProgramPath(word, runsIn);
1589
+ if (place === undefined)
1590
+ continue;
1591
+ for (const f of placeForms(place.at, place.inWorkspace, candidate, true))
1592
+ found.add(f);
1593
+ if (place.target === undefined)
1594
+ continue;
1595
+ const { at, inWorkspace } = place.target;
1596
+ for (const f of placeForms(at, inWorkspace, candidate, true))
1597
+ found.add(f);
1598
+ }
1599
+ }
1600
+ }
1601
+ for (const f of found) {
1602
+ out.add(f);
1603
+ out.add([f, ...argv.slice(1)].join(" "));
1604
+ }
1605
+ }
1606
+ }
1607
+ return { spellings: [...out], unreadable };
1608
+ }
1609
+ /** The string-valued entries of the object at `field`, when the call carries one. */
1610
+ function envMapOf(input, field) {
1611
+ if (input === null || typeof input !== "object" || !Object.hasOwn(input, field)) {
1612
+ return undefined;
1613
+ }
1614
+ const value = input[field];
1615
+ if (value === null || typeof value !== "object" || Array.isArray(value))
1616
+ return undefined;
1617
+ const out = {};
1618
+ for (const [name, v] of Object.entries(value))
1619
+ if (typeof v === "string")
1620
+ out[name] = v;
1621
+ return out;
1622
+ }
1623
+ /**
1624
+ * Could this argv word name a file relative to the working directory? Not a
1625
+ * flag, not an absolute path, not a URL; anything else might be a program
1626
+ * or a script (`release.sh`, `./eslint`, `bin/tool`).
1627
+ */
1628
+ function namesAFile(word) {
1629
+ return word !== "" && !word.startsWith("-") && !isAbsolutePath(word) && !word.includes("://");
1630
+ }
1631
+ function isAbsolutePath(value) {
1632
+ return value.startsWith("/") || value.startsWith("\\") || /^[A-Za-z]:[\\/]/.test(value);
1633
+ }
1634
+ /** Does the call set the top-level boolean `field` to `true`? */
1635
+ function switchedOn(input, field) {
1636
+ return (input !== null &&
1637
+ typeof input === "object" &&
1638
+ Object.hasOwn(input, field) &&
1639
+ input[field] === true);
1640
+ }
1641
+ /** The top-level `within` field's value, when the call carries one. */
1642
+ function qualifierOf(input, field) {
1643
+ if (input === null || typeof input !== "object" || !Object.hasOwn(input, field)) {
1644
+ return undefined;
1645
+ }
1646
+ const value = input[field];
1647
+ if (typeof value === "string")
1648
+ return value;
1649
+ if (typeof value === "number" && Number.isFinite(value))
1650
+ return String(value);
1651
+ return undefined;
1652
+ }
1653
+ /**
1654
+ * The filesystem-free reading of a path: `..` and `.` collapsed, nothing
1655
+ * followed. A relative path that climbs above its start is flagged as
1656
+ * outside. Used where there is no workspace to ask (the edge worker); the
1657
+ * Node runtime passes a canonicaliser that also follows symlinks.
1658
+ */
1659
+ export function lexicalPathValues(raw) {
1660
+ const lexical = normalizePathLexically(raw);
1661
+ if (lexical.escapes) {
1662
+ return [
1663
+ { kind: "path", canonical: [], spellings: [raw, lexical.path], outsideWorkspace: true },
1664
+ ];
1665
+ }
1666
+ const p = lexical.path;
1667
+ const canonical = p.startsWith("/") || p === "." ? [p] : [p, `./${p}`];
1668
+ return [{ kind: "path", canonical, spellings: [raw] }];
1669
+ }
1670
+ function canonicalUrl(raw) {
1671
+ let url;
1672
+ try {
1673
+ url = new URL(raw);
1674
+ }
1675
+ catch {
1676
+ // Not a URL the tool could fetch. Nothing canonical to grant against; a
1677
+ // deny still reads what was written.
1678
+ return { kind: "url", canonical: [], spellings: [raw] };
1679
+ }
1680
+ const href = url.href;
1681
+ const canonical = [href];
1682
+ // `https://example.com` parses to `https://example.com/`. Both spell the
1683
+ // same request, so a rule written either way matches.
1684
+ if (url.pathname === "/" && url.search === "" && url.hash === "" && href.endsWith("/")) {
1685
+ canonical.push(href.slice(0, -1));
1686
+ }
1687
+ return { kind: "url", canonical, spellings: [raw] };
1688
+ }