@crewhaus/tool-executor 0.7.0 → 0.8.1

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