@twinsearth/w2m-dsh-plugin 0.5.8 → 0.6.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.
@@ -8,15 +8,58 @@
8
8
  * ends drift apart, and a pre-flight that is *stricter* than the gate is the worse direction: the
9
9
  * agent would have run the command, and the dispatch never happened.
10
10
  *
11
- * The comparison is deliberately token-wise and not executable-only. An entry is a **prefix
12
- * sequence**: `"node --test"` means `['node','--test']`, which allows `['node','--test','--reporter=tap']`
13
- * and refuses `['node','-e','...']` and a bare `['node']`. Comparing only `argv[0]`, as the plugin's
14
- * pre-flight used to, can never match a multi-token entry at all -- the documented config
15
- * `['node --test','git status --porcelain']` would refuse `["node","--test"]`.
11
+ * ## What an entry means (read this before trusting an allow-list)
12
+ *
13
+ * An entry is a **prefix sequence**, and it grants *everything the tool can be told to do with the
14
+ * arguments that follow it*:
15
+ *
16
+ * * `"node --test"` means `['node','--test']`, which allows `['node','--test','--test-name-pattern=x']`
17
+ * and refuses `['node','-e','...']` and a bare `['node']`.
18
+ * * It also allows `['node','--test','some/file.mjs']` -- and the test runner *loads and executes*
19
+ * whatever file it is handed. So `node --test` is a grant of arbitrary code execution from any
20
+ * file this machine can read. **A prefix entry is not a sandbox.** It says which tool may run,
21
+ * not what that tool will do; the only way to get "this exact command line" semantics is an entry
22
+ * that names the whole line, and even then the named file's contents are the caller's to choose.
23
+ *
24
+ * Two things narrow that grant, and neither closes it:
25
+ *
26
+ * 1. {@link REPARSING_ARGUMENTS} refuses a named set of *trailing* arguments that make a tool load
27
+ * code the entry never mentioned (`--test-reporter=file:///…evil.mjs`, `--import`, `python -c`,
28
+ * `git --exec-path`, …). The set is per-tool, explicit, and deliberately incomplete: PowerShell
29
+ * abbreviates its own parameter names, `npx`/`xargs`/`sh -c`/`make` exist to run *another*
30
+ * program, and a tool nobody listed here gets no argument control at all. When an entry does
31
+ * need one of these arguments, the remedy is to name it **in the entry** -- the entry is the
32
+ * explicit grant, and this matcher only reads entries.
33
+ * 2. The executable is compared by **identity**, not by spelling: see `executableIdentity` below.
34
+ *
35
+ * ## The executable
36
+ *
37
+ * `argv[0]` is admitted in exactly three shapes:
38
+ *
39
+ * * a **bare name** (`node`, `node.exe`, `git`) matching the entry's own bare name, extension and
40
+ * (on Windows) case aside. PATH decides which file that is, and the executor resolves it through
41
+ * the same search order the capability probe uses (PATH, then DSH's bundled runtime). What this
42
+ * leaves open is PATH itself: an earlier directory, or a writable entry in the bundled runtime,
43
+ * can put a different `node` in front of the intended one. That is a machine-configuration
44
+ * question, and it is why the honest reading of a bare-name entry is "whatever this machine
45
+ * currently calls `node`".
46
+ * * an **absolute path** that resolves (through `realpath`) to the *same file* the entry's
47
+ * executable resolves to, so `C:\Program Files\nodejs\node.exe` is admitted for the entry `node`
48
+ * only when that is the file `node` resolves to, and `E:\Temp\evil\node.exe` -- a different file
49
+ * that merely shares the name -- is refused with `COMMAND_EXECUTABLE_PATH_MISMATCH`.
50
+ * * nothing else. A **relative** path (`./node.exe`, `bin\node.exe`) is refused outright with
51
+ * `COMMAND_EXECUTABLE_RELATIVE_PATH`: it names a different file in every working directory, and
52
+ * the working directory is chosen by the offer, not by this machine.
53
+ *
54
+ * Names are compared case-insensitively on Windows and case-sensitively elsewhere, and the
55
+ * extension is ignored on **both** sides, so the entry `Node.exe` and the argv `node` agree on
56
+ * Windows but not on a POSIX machine, where those are two different files.
16
57
  */
17
58
 
18
59
  import { basename, extname } from 'node:path';
19
60
 
61
+ import { hasPathSeparator, isRelativePath, resolveCommand, sameFile } from './resolve.mjs';
62
+
20
63
  /**
21
64
  * Parse `--allowed-commands`.
22
65
  *
@@ -87,22 +130,472 @@ function sameName(a, b) {
87
130
  }
88
131
 
89
132
  /**
90
- * Default-deny prefix match.
133
+ * The trailing arguments a matched prefix does **not** cover, and why each one is refused.
134
+ *
135
+ * Every entry here is an argument that makes its tool run code the allow-list entry never named --
136
+ * either by loading a module or file, by taking source text directly, or by delegating to another
137
+ * program. They are refused because the alternative is worse in a way that is invisible: the matcher
138
+ * would say `allowed: true` for a command line whose *effect* the entry does not describe, and the
139
+ * operator would read that as "the list covers this".
140
+ *
141
+ * Three honest limits, because a list like this invites more trust than it can carry:
142
+ *
143
+ * * It is keyed by executable **family** ({@link EXECUTABLE_FAMILY}), so an executable nobody
144
+ * listed gets no argument control at all -- and listing every executable that can run another
145
+ * program is not a finite job (`npx`, `xargs`, `make`, `docker run`, `sh -c`, …). Those are
146
+ * grants of *another* program by construction; an entry naming one is a grant of it.
147
+ * * A tool that abbreviates its own options cannot be covered by tokens: PowerShell accepts any
148
+ * unambiguous prefix (`-com`, `-comm`, `-Command`), so the list is a floor for `pwsh`, not a
149
+ * guarantee.
150
+ * * It matches the arguments *as written*. Nothing here re-parses a command line, because
151
+ * re-parsing is the technique being refused; `--test-reporter` is caught, `--test-report`+`er`
152
+ * split across two arguments is not a thing a tool accepts either.
153
+ *
154
+ * The escape hatch is deliberate and is the point: an entry that names the argument is an explicit
155
+ * grant, so `["node --test --test-reporter=tap"]` allows exactly that and nothing more.
156
+ */
157
+ const REPARSING_ARGUMENTS = Object.freeze({
158
+ node: Object.freeze([
159
+ { token: '--test-reporter', why: 'its value is a module specifier or URL, and node imports and runs it as the test reporter' },
160
+ { token: '--import', why: 'it loads an ES module before the program runs' },
161
+ { token: '--require', why: 'it loads a CommonJS module before the program runs' },
162
+ { token: '-r', why: 'it is the short spelling of --require, which loads a CommonJS module before the program runs' },
163
+ { token: '--experimental-loader', why: 'it installs a module loader hook: code that decides what every later import resolves to' },
164
+ { token: '--loader', why: 'it is the older spelling of --experimental-loader' },
165
+ { token: '--eval', why: 'it runs its value as source instead of a file' },
166
+ { token: '-e', why: 'it is the short spelling of --eval, which runs its value as source' },
167
+ { token: '--print', why: 'it runs its value as source and prints the result' },
168
+ { token: '-p', why: 'it is the short spelling of --print, which runs its value as source' },
169
+ // `-c` / `--check` is deliberately absent: it parses a file without executing it.
170
+ ]),
171
+ python: Object.freeze([
172
+ { token: '-c', why: 'it runs its value as Python source' },
173
+ { token: '-m', why: 'it runs a module as __main__, imported from sys.path - which includes the working directory the offer chose' },
174
+ ]),
175
+ perl: Object.freeze([
176
+ { token: '-e', why: 'it runs its value as source' },
177
+ { token: '-E', why: 'it runs its value as source, with optional features enabled' },
178
+ { token: '-M', why: 'it loads a module at startup, and -MModule=args calls into it' },
179
+ { token: '-m', why: 'it loads a module at startup, as -M does' },
180
+ { token: '-d', why: 'it runs under a debugger whose value can name a module to load (-d:Module)' },
181
+ { token: '-S', why: 'it runs a script looked up on PATH, so PATH chooses the file that executes' },
182
+ ]),
183
+ ruby: Object.freeze([
184
+ { token: '-e', why: 'it runs its value as source' },
185
+ { token: '-r', why: 'it requires a library before the program runs' },
186
+ { token: '-S', why: 'it runs a script looked up on PATH, so PATH chooses the file that executes' },
187
+ ]),
188
+ php: Object.freeze([
189
+ { token: '-r', why: 'it runs its value as PHP source' },
190
+ { token: '-B', why: 'it runs its value as source before the input loop' },
191
+ { token: '-R', why: 'it runs its value as source for every input line' },
192
+ { token: '-E', why: 'it runs its value as source after the input loop' },
193
+ { token: '-F', why: 'it runs a file for every input line' },
194
+ { token: '-d', why: 'it overrides any php.ini directive, including auto_prepend_file/auto_append_file, which include a file before the script runs' },
195
+ ]),
196
+ deno: Object.freeze([
197
+ { token: '--import-map', why: 'it remaps module specifiers, so an import can be pointed at a different file' },
198
+ { token: '--config', why: 'it names a config file whose imports/scopes remap module specifiers, as --import-map does' },
199
+ ]),
200
+ bun: Object.freeze([
201
+ { token: '-e', why: 'it runs its value as source' },
202
+ { token: '--eval', why: 'it is the long spelling of bun -e, which runs its value as source' },
203
+ { token: '-r', why: 'it imports a module before the program runs' },
204
+ { token: '--preload', why: 'it is the long spelling of bun -r, which imports a module before the program runs' },
205
+ { token: '--require', why: 'it imports a CommonJS module before the program runs' },
206
+ ]),
207
+ shell: Object.freeze([
208
+ { token: '-c', why: 'it runs its value as a command line' },
209
+ { token: '--command', why: 'it is the long spelling of -c' },
210
+ { token: '-Command', why: "it is PowerShell's -Command, which runs its value as a script block" },
211
+ { token: '-EncodedCommand', why: "it is PowerShell's -EncodedCommand, which runs its base64 value as a script block" },
212
+ { token: '--init-file', why: "it is bash's --init-file, which runs the named file instead of the usual startup file" },
213
+ { token: '--rcfile', why: "it is bash's --rcfile, which runs the named file instead of ~/.bashrc" },
214
+ { token: '/c', why: "it is cmd.exe's /c, which runs the rest of the command line as a command" },
215
+ { token: '/k', why: "it is cmd.exe's /k, which runs the rest of the command line and then keeps the shell open" },
216
+ ]),
217
+ java: Object.freeze([
218
+ { token: '-javaagent', why: 'it names a jar whose premain runs inside the JVM before main' },
219
+ { token: '-agentlib', why: 'it loads a native agent library whose Agent_OnLoad runs before main' },
220
+ { token: '-agentpath', why: 'it loads a native agent library by path, as -agentlib does' },
221
+ ]),
222
+ git: Object.freeze([
223
+ { token: '-c', why: 'it sets any config for this one run: core.pager, core.sshCommand, core.editor and alias.* = !<shell command> each make git run a program the entry never named' },
224
+ { token: '--config-env', why: 'it is -c with the value taken from an environment variable' },
225
+ { token: '--exec-path', why: 'it moves the directory git takes its own subprograms (git-remote-*, git-upload-pack) from' },
226
+ { token: '--upload-pack', why: 'it names the program the remote side runs to serve the pack' },
227
+ { token: '--receive-pack', why: 'it is the push-side spelling of --upload-pack' },
228
+ ]),
229
+ make: Object.freeze([
230
+ { token: '-f', why: 'it reads a different makefile, whose recipes are commands make runs' },
231
+ { token: '--file', why: 'it is a long spelling of make -f' },
232
+ { token: '--makefile', why: 'it is another long spelling of make -f' },
233
+ { token: '--eval', why: 'it evaluates its value as makefile text before any makefile is read' },
234
+ ]),
235
+ tar: Object.freeze([
236
+ { token: '-I', why: 'it names the compression program tar executes' },
237
+ { token: '--use-compress-program', why: 'it is the long spelling of tar -I' },
238
+ { token: '--to-command', why: 'it pipes every extracted member through a command of the caller\'s choosing' },
239
+ { token: '--checkpoint-action', why: 'it runs a command at a checkpoint (--checkpoint-action=exec=...)' },
240
+ { token: '-F', why: 'it runs its value as a script at every checkpoint' },
241
+ { token: '--info-script', why: 'it is the long spelling of tar -F' },
242
+ ]),
243
+ ssh: Object.freeze([
244
+ { token: '-o', why: 'it sets any ssh_config option for this run, including ProxyCommand and LocalCommand, which are shell commands' },
245
+ { token: '-F', why: 'it reads an alternate config file, which can itself set ProxyCommand' },
246
+ ]),
247
+ scp: Object.freeze([
248
+ { token: '-S', why: 'it names the program scp uses for the encrypted connection' },
249
+ { token: '-o', why: 'it sets any ssh_config option for this run, including ProxyCommand, which is a shell command' },
250
+ { token: '-F', why: 'it reads an alternate config file, which can itself set ProxyCommand' },
251
+ ]),
252
+ rsync: Object.freeze([
253
+ { token: '-e', why: 'it names the remote shell rsync runs' },
254
+ { token: '--rsh', why: 'it is the long spelling of rsync -e' },
255
+ { token: '--rsync-path', why: 'it names the rsync program the remote side runs' },
256
+ ]),
257
+ find: Object.freeze([
258
+ { token: '-exec', why: 'it runs a command for every match' },
259
+ { token: '-execdir', why: "it runs a command in each match's own directory" },
260
+ { token: '-ok', why: 'it runs a command for every match, asking first' },
261
+ { token: '-okdir', why: "it runs a command in each match's own directory, asking first" },
262
+ ]),
263
+ env: Object.freeze([
264
+ { token: '-S', why: 'it splits its value into a new command line, so the arguments this matcher just read would be re-parsed by env' },
265
+ { token: '--split-string', why: 'it is the long spelling of env -S' },
266
+ ]),
267
+ });
268
+
269
+ /**
270
+ * Which argument list applies to an executable name.
271
+ *
272
+ * Family names, not exact names: `python3.12` and `python` must land on one list, and so must
273
+ * `node.exe` and `node`. The trailing-version strip is how `python3.12`/`perl5`/`node20` are
274
+ * recognised without enumerating every release ever installed.
275
+ */
276
+ const EXECUTABLE_FAMILY = Object.freeze({
277
+ node: 'node', nodejs: 'node',
278
+ python: 'python', pythonw: 'python', py: 'python',
279
+ perl: 'perl',
280
+ ruby: 'ruby',
281
+ php: 'php',
282
+ deno: 'deno',
283
+ bun: 'bun',
284
+ sh: 'shell', bash: 'shell', dash: 'shell', zsh: 'shell', ksh: 'shell', fish: 'shell',
285
+ pwsh: 'shell', powershell: 'shell', cmd: 'shell',
286
+ java: 'java', javaw: 'java',
287
+ git: 'git',
288
+ make: 'make',
289
+ tar: 'tar',
290
+ ssh: 'ssh', sftp: 'ssh',
291
+ scp: 'scp',
292
+ rsync: 'rsync',
293
+ find: 'find',
294
+ env: 'env',
295
+ });
296
+
297
+ /**
298
+ * The deny-list entries that apply to `executable`, or `[]` when its family is not covered.
299
+ *
300
+ * @param {string} executable
301
+ * @returns {ReadonlyArray<{token: string, why: string}>}
302
+ */
303
+ function reparsingArgumentsFor(executable) {
304
+ const name = execName(String(executable)).toLowerCase();
305
+ const versionless = name.replace(/[0-9.]+$/, '');
306
+ for (const candidate of [name, versionless]) {
307
+ if (candidate !== '' && Object.hasOwn(EXECUTABLE_FAMILY, candidate)) {
308
+ return REPARSING_ARGUMENTS[EXECUTABLE_FAMILY[candidate]] ?? [];
309
+ }
310
+ }
311
+ return [];
312
+ }
313
+
314
+ /**
315
+ * Does one trailing argument name a denied token?
316
+ *
317
+ * Three shapes, because tools accept three:
318
+ *
319
+ * * **exact** (`-e`, `--import`), and -- for tokens that take a value -- the value attached with
320
+ * `=` or `:` (`--import=file:///x`, `--test-reporter=spec`, `-javaagent:C:\evil.jar`) or written
321
+ * straight after a single-letter token;
322
+ * * a **bundle** of short options, where a denied single letter is one of them: `node -pe '…'` is
323
+ * `--print --eval`, `python -uc '…'` is `-u -c`, `bash -lc '…'` is `-l -c`. Only single-letter
324
+ * tokens take part, which is the conservative direction -- `find . -name x` must not read as
325
+ * `-n -a -m -e`;
326
+ * * the `/c` and `/k` forms cmd.exe uses, exact only.
327
+ *
328
+ * Comparison is case-insensitive: the family lists are small, the flags they name are the ones that
329
+ * load code, and refusing `-C` for `python` (a different spelling of the same thing on the platform
330
+ * where the entry was probably written) is the direction that keeps the two ends agreeing.
331
+ *
332
+ * @param {string} argument
333
+ * @param {ReadonlyArray<{token: string, why: string}>} denied
334
+ * @returns {{token: string, why: string, argument: string}|null}
335
+ */
336
+ function deniedArgument(argument, denied) {
337
+ const token = String(argument);
338
+ const lower = token.toLowerCase();
339
+ // The *given* token decides which shapes are possible. Without this, `...\not-a-test-name.mjs`
340
+ // would be read as a bundle of short options and refused for containing an `e`.
341
+ const isLong = lower.startsWith('--');
342
+ const isShort = !isLong && lower.startsWith('-') && lower.length > 1;
343
+ const isSlash = lower.startsWith('/') && lower.length > 1;
344
+ for (const entry of denied) {
345
+ const deniedToken = entry.token.toLowerCase();
346
+ if (deniedToken.startsWith('--')) {
347
+ if (!isLong) continue;
348
+ if (lower === deniedToken || lower.startsWith(`${deniedToken}=`) || lower.startsWith(`${deniedToken}:`)) {
349
+ return { ...entry, argument: token };
350
+ }
351
+ continue;
352
+ }
353
+ if (deniedToken.startsWith('/')) {
354
+ // cmd.exe's `/c` and `/k`: exact, or with the command attached (`cmd /cecho hi`).
355
+ if (isSlash && (lower === deniedToken || lower.startsWith(deniedToken))) {
356
+ return { ...entry, argument: token };
357
+ }
358
+ continue;
359
+ }
360
+ if (!isShort) continue;
361
+ // Exact, or with the value attached (`-javaagent:C:\evil.jar`, `-MModule=args`).
362
+ if (lower.startsWith(deniedToken)) return { ...entry, argument: token };
363
+ const flag = deniedToken.slice(1);
364
+ if (flag.length === 1 && token.length > 2 && lower.slice(1).includes(flag)) {
365
+ return { ...entry, argument: token };
366
+ }
367
+ }
368
+ return null;
369
+ }
370
+
371
+ /**
372
+ * The stable codes this matcher refuses with.
373
+ *
374
+ * `COMMAND_NOT_ALLOWED` is deliberately the **same literal** the agent writes into a result
375
+ * envelope's `refusal_reason` (`REFUSAL.COMMAND_NOT_ALLOWED` in `src/agent/agent.mjs`) and the same
376
+ * one the plugin's dispatch pre-flight reports: the whole reason this matcher is shared is that the
377
+ * two ends must not be able to disagree about *why* a command was refused, and two spellings of one
378
+ * refusal would be a disagreement of exactly that kind. The three codes below it travel the same
379
+ * way -- the agent writes whichever code the matcher returned into its envelope, and the plugin puts
380
+ * the same one in its refusal value -- so a refusal code has exactly one definition, here.
381
+ *
382
+ * `COMMAND_ARGV_EMPTY` is the matcher's own precondition: no argv at all, or an argv carrying an
383
+ * entry that is not a string. It is a separate code because it is a separate fault -- a caller that
384
+ * passed something incomparable has a bug, not a policy problem -- and collapsing it into
385
+ * `COMMAND_NOT_ALLOWED` would send the reader to the allow-list to fix a caller. (A non-string entry
386
+ * used to reach `path.basename` and throw out of this function; the agent's catch-all turned that
387
+ * into a `crashed` envelope, which is a lot of noise for a caller bug and no refusal at all.)
388
+ *
389
+ * The three v0.5.10 codes are separate for the same reason, one remedy each:
390
+ *
391
+ * * `COMMAND_REPARSING_ARGUMENT` -- the entry matched, and one of its *trailing* arguments is on
392
+ * {@link REPARSING_ARGUMENTS}. The remedy is to name that argument in the entry (or drop it).
393
+ * * `COMMAND_EXECUTABLE_PATH_MISMATCH` -- `argv[0]` is a path, and it is not the file the entry's
394
+ * executable resolves to. The remedy is the other path, or a bare name.
395
+ * * `COMMAND_EXECUTABLE_RELATIVE_PATH` -- `argv[0]` is a relative path, whose meaning is chosen by
396
+ * the offer's working directory. The remedy is a bare name or an absolute path.
397
+ */
398
+ export const MATCH_REFUSAL = Object.freeze({
399
+ ARGV_EMPTY: 'COMMAND_ARGV_EMPTY',
400
+ NOT_ALLOWED: 'COMMAND_NOT_ALLOWED',
401
+ REPARSING_ARGUMENT: 'COMMAND_REPARSING_ARGUMENT',
402
+ EXECUTABLE_PATH_MISMATCH: 'COMMAND_EXECUTABLE_PATH_MISMATCH',
403
+ EXECUTABLE_RELATIVE_PATH: 'COMMAND_EXECUTABLE_RELATIVE_PATH',
404
+ });
405
+
406
+ /**
407
+ * Why this argv is not covered by this list, and what would change it.
408
+ *
409
+ * The entries are echoed because they are the *only* thing that would make the same call succeed,
410
+ * and a caller reading this deserves them without a second call. They are configuration, never
411
+ * credentials: the parse path refuses a secret-looking key before a list ever reaches here.
412
+ *
413
+ * @param {string[]} argv
414
+ * @param {string[][]} prefixes
415
+ * @returns {string}
416
+ */
417
+ function allowListMissDetail(argv, prefixes) {
418
+ if (prefixes.length === 0) {
419
+ return (
420
+ 'the configured allow-list is empty, so every command is refused; add an entry naming what ' +
421
+ `this machine may run - an entry of \`${argv[0]}\` would allow \`${argv.join(' ')}\``
422
+ );
423
+ }
424
+ const entries = prefixes.map((prefix) => prefix.join(' ')).join(', ');
425
+ return (
426
+ `no configured entry is a prefix of this argv; the allow-list entries are ${entries}. Add one ` +
427
+ `that covers \`${argv.join(' ')}\`, or run a command the list already covers`
428
+ );
429
+ }
430
+
431
+ /**
432
+ * Can `argv[0]` be the executable this entry names?
433
+ *
434
+ * The three admissible shapes are described in the file header. The comparison is on the **file**,
435
+ * never on the spelling: `realpath` equality for paths (through {@link sameFile}), and name equality
436
+ * for bare names, where PATH is what turns a name into a file.
437
+ *
438
+ * @param {string} argv0
439
+ * @param {string} prefix0
440
+ * @param {object} options Forwarded to `resolveCommand` (`env`, `extraDirs`).
441
+ * @returns {{kind: 'match'|'no-match'|'path-mismatch', detail: string|null}}
442
+ */
443
+ function executableIdentity(argv0, prefix0, options) {
444
+ const argvIsPath = hasPathSeparator(argv0);
445
+ const prefixIsPath = hasPathSeparator(prefix0);
446
+
447
+ // Both bare names: the name decides, and PATH decides which file the name means. This is the
448
+ // behaviour every existing configuration was written against, and it is the shape the header
449
+ // calls out as trusting PATH.
450
+ if (!argvIsPath && !prefixIsPath) {
451
+ return sameName(execName(argv0), execName(prefix0))
452
+ ? { kind: 'match', detail: null }
453
+ : { kind: 'no-match', detail: null };
454
+ }
455
+
456
+ // One side is a path, so a file has to be identified: the entry's executable is resolved the same
457
+ // way the executor resolves it (PATH, then DSH's bundled runtime), and argv[0] -- a path the
458
+ // caller wrote -- is compared to it by realpath.
459
+ const resolvedPrefix = prefixIsPath ? prefix0 : resolveCommand(prefix0, options);
460
+ if (!prefixIsPath && !hasPathSeparator(resolvedPrefix)) {
461
+ return {
462
+ kind: 'path-mismatch',
463
+ detail:
464
+ `\`${argv0}\` is a path, but the entry's executable \`${prefix0}\` could not be found on ` +
465
+ 'this machine\'s PATH (or in DSH\'s bundled runtime), so there is no file for a path to be ' +
466
+ `the same as; install it, or use the bare name \`${prefix0}\``,
467
+ };
468
+ }
469
+
470
+ const resolvedArgv = argvIsPath ? argv0 : resolveCommand(argv0, options);
471
+ if (!argvIsPath && !hasPathSeparator(resolvedArgv)) {
472
+ // A bare argv[0] against a path entry, and the bare name resolves to nothing: that is an
473
+ // ordinary "the entry does not cover this name" miss, not a claim about a file.
474
+ return { kind: 'no-match', detail: null };
475
+ }
476
+ if (sameFile(resolvedArgv, resolvedPrefix)) return { kind: 'match', detail: null };
477
+ return {
478
+ kind: 'path-mismatch',
479
+ detail:
480
+ `\`${argv0}\` is a path, and it is not the executable this entry names: \`${prefix0}\` resolves ` +
481
+ `to \`${resolvedPrefix}\`, a different file. An absolute path is admitted only when it *is* the ` +
482
+ `file the entry's executable resolves to, because a copy of a program under an allowed name is ` +
483
+ `a different program; use \`${resolvedPrefix}\`, or the bare name \`${execName(prefix0)}\``,
484
+ };
485
+ }
486
+
487
+ /**
488
+ * The first trailing argument that makes this match mean more than the entry says, or `null`.
489
+ *
490
+ * Only arguments **after** the matched prefix are examined: an entry that names a code-loading
491
+ * argument is the operator's explicit grant (`["python -m pytest"]` runs pytest), and re-refusing it
492
+ * here would make such an entry unusable rather than safer.
493
+ *
494
+ * @param {string[]} argv
495
+ * @param {string[]} prefix
496
+ * @returns {{token: string, why: string, argument: string}|null}
497
+ */
498
+ function trailingReparsingArgument(argv, prefix) {
499
+ const denied = reparsingArgumentsFor(prefix[0]);
500
+ if (denied.length === 0) return null;
501
+ for (let i = prefix.length; i < argv.length; i += 1) {
502
+ const hit = deniedArgument(argv[i], denied);
503
+ if (hit) return hit;
504
+ }
505
+ return null;
506
+ }
507
+
508
+ /**
509
+ * Why this trailing argument was refused, in terms of the entry that let the command this far.
510
+ *
511
+ * @param {{token: string, why: string, argument: string}} hit
512
+ * @param {string[]} prefix
513
+ * @returns {string}
514
+ */
515
+ function reparsingDetail(hit, prefix) {
516
+ const entry = prefix.join(' ');
517
+ return (
518
+ `\`${hit.argument}\` is a trailing argument of the entry \`${entry}\`, and it is refused because ` +
519
+ `${hit.why}. A prefix entry grants the command, not the arguments after it, so this control ` +
520
+ `cannot tell \`${hit.argument}\` from the one that loads someone else's code. Name the argument ` +
521
+ `in an entry of its own if this machine is meant to run it (for example \`${entry} ${hit.token}=...\`), ` +
522
+ 'or run the command without it'
523
+ );
524
+ }
525
+
526
+ /**
527
+ * Default-deny prefix match, executable identity check, and trailing-argument deny-list.
91
528
  *
92
529
  * `['node','--test']` is allowed by the prefix `node --test`, and so is
93
- * `['node','--test','--reporter=tap']`; `['node','-e','...']` and a bare
94
- * `['node']` are refused.
530
+ * `['node','--test','--test-name-pattern=x']`; `['node','-e','...']`, a bare `['node']`,
531
+ * `['node','--test','--test-reporter=file:///evil.mjs']` and a copy of some other program at
532
+ * `E:\Temp\evil\node.exe` are all refused.
533
+ *
534
+ * A refusal is a **named** one: `reason_code` is a stable, greppable code (see
535
+ * {@link MATCH_REFUSAL}) and `detail` says what would change it. `allowed` and `prefix` keep the
536
+ * shape this function has always returned, because both callers read exactly those two fields and
537
+ * the codes are additive.
95
538
  *
96
539
  * @param {string[]} argv
97
540
  * @param {string[][]} prefixes
98
- * @returns {{allowed: boolean, prefix: string[]|null}}
541
+ * @param {object} [options] `env`/`extraDirs` for executable resolution (defaults: this process).
542
+ * @returns {{allowed: boolean, prefix: string[]|null, reason_code: string|null, detail: string|null}}
99
543
  */
100
- export function matchAllowedCommand(argv, prefixes) {
101
- if (!Array.isArray(argv) || argv.length === 0) return { allowed: false, prefix: null };
102
- for (const prefix of prefixes ?? []) {
544
+ export function matchAllowedCommand(argv, prefixes, options = {}) {
545
+ const entries = Array.isArray(prefixes) ? prefixes : [];
546
+ if (!Array.isArray(argv) || argv.length === 0) {
547
+ return {
548
+ allowed: false,
549
+ prefix: null,
550
+ reason_code: MATCH_REFUSAL.ARGV_EMPTY,
551
+ detail:
552
+ 'there is no argv to compare, and this matcher is default-deny; pass a non-empty array of ' +
553
+ 'strings whose first element is the executable',
554
+ };
555
+ }
556
+ for (const part of argv) {
557
+ if (typeof part !== 'string') {
558
+ return {
559
+ allowed: false,
560
+ prefix: null,
561
+ reason_code: MATCH_REFUSAL.ARGV_EMPTY,
562
+ detail:
563
+ 'every argv entry must be a string; a non-string entry cannot be compared without ' +
564
+ 'coercing it, and coercion is a re-parse',
565
+ };
566
+ }
567
+ }
568
+
569
+ // A relative executable is refused before any entry is consulted, because no entry can pin it:
570
+ // `./node.exe` is a different file in every working directory, and the working directory comes
571
+ // from the offer.
572
+ if (isRelativePath(argv[0])) {
573
+ return {
574
+ allowed: false,
575
+ prefix: null,
576
+ reason_code: MATCH_REFUSAL.EXECUTABLE_RELATIVE_PATH,
577
+ detail:
578
+ `\`${argv[0]}\` is a relative path, and the working directory it would be resolved against ` +
579
+ 'is chosen by the offer, not by this machine - so this names a different file in every ' +
580
+ 'directory and the allow-list cannot say which program would run. Use the bare command name ' +
581
+ `(\`${execName(argv[0])}\`) or an absolute path to the executable the entry names`,
582
+ };
583
+ }
584
+
585
+ /** The first refused trailing argument, kept while other entries are still tried on their merits. */
586
+ let reparsing = null;
587
+ /** The first path-identity mismatch, reported only if no entry matches at all. */
588
+ let mismatch = null;
589
+
590
+ for (const prefix of entries) {
103
591
  if (!Array.isArray(prefix) || prefix.length === 0) continue;
104
592
  if (argv.length < prefix.length) continue;
105
- if (!sameName(argv[0], prefix[0]) && !sameName(execName(argv[0]), prefix[0])) continue;
593
+ const identity = executableIdentity(argv[0], prefix[0], options);
594
+ if (identity.kind === 'no-match') continue;
595
+ if (identity.kind === 'path-mismatch') {
596
+ if (mismatch === null) mismatch = identity.detail;
597
+ continue;
598
+ }
106
599
  let ok = true;
107
600
  for (let i = 1; i < prefix.length; i += 1) {
108
601
  if (argv[i] !== prefix[i]) {
@@ -110,7 +603,38 @@ export function matchAllowedCommand(argv, prefixes) {
110
603
  break;
111
604
  }
112
605
  }
113
- if (ok) return { allowed: true, prefix };
606
+ if (!ok) continue;
607
+ // The prefix matched. A trailing argument on the deny-list is *not* an immediate refusal: an
608
+ // entry may name it explicitly, and entries are tried in the order they were configured, so
609
+ // `["node --test","node --test --test-reporter=tap"]` must still admit the second one.
610
+ const hit = trailingReparsingArgument(argv, prefix);
611
+ if (hit) {
612
+ if (reparsing === null) reparsing = { hit, prefix };
613
+ continue;
614
+ }
615
+ return { allowed: true, prefix, reason_code: null, detail: null };
616
+ }
617
+
618
+ if (reparsing !== null) {
619
+ return {
620
+ allowed: false,
621
+ prefix: null,
622
+ reason_code: MATCH_REFUSAL.REPARSING_ARGUMENT,
623
+ detail: reparsingDetail(reparsing.hit, reparsing.prefix),
624
+ };
625
+ }
626
+ if (mismatch !== null) {
627
+ return {
628
+ allowed: false,
629
+ prefix: null,
630
+ reason_code: MATCH_REFUSAL.EXECUTABLE_PATH_MISMATCH,
631
+ detail: mismatch,
632
+ };
114
633
  }
115
- return { allowed: false, prefix: null };
634
+ return {
635
+ allowed: false,
636
+ prefix: null,
637
+ reason_code: MATCH_REFUSAL.NOT_ALLOWED,
638
+ detail: allowListMissDetail(argv, entries.filter((prefix) => Array.isArray(prefix) && prefix.length > 0)),
639
+ };
116
640
  }