ssh-mcp 2.11.0 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import { AWK_NAMES, readAwkInvocation } from './awk.js';
2
+ import { matchesGrammar } from './reader-grammar.js';
2
3
  /**
3
4
  * Anything through which the shell can start a second command.
4
5
  *
@@ -17,18 +18,525 @@ import { AWK_NAMES, readAwkInvocation } from './awk.js';
17
18
  * write: run-command still accepts it under policy.
18
19
  */
19
20
  const SHELL_CONTROL_CHARS = /[;&|<>`$(){}\n\r]/;
20
- const READ_ONLY_ALLOWLIST = new Set([
21
- 'ls', 'cat', 'grep', 'find', 'stat', 'df', 'du', 'head', 'tail', 'wc',
22
- 'ps', 'uname', 'uptime', 'hostname', 'id', 'who', 'whoami', 'date',
23
- 'printenv', 'pwd', 'echo', 'printf', 'test', 'true', 'false',
24
- 'which', 'whereis', 'file', 'readlink', 'realpath', 'basename', 'dirname',
25
- 'seq', 'sort', 'uniq', 'cut', 'tr', 'diff', 'comm',
26
- 'systemctl status', 'journalctl', 'docker ps', 'docker logs', 'docker inspect',
27
- 'docker stats', 'docker images', 'free', 'top', 'htop', 'iostat', 'vmstat',
28
- 'netstat', 'ss', 'ifconfig', 'ip addr', 'ip route', 'arp', 'dig', 'nslookup',
29
- 'host', 'ping', 'traceroute', 'git status', 'git log',
30
- 'git diff', 'git branch', 'git show', 'git remote',
31
- ]);
21
+ /**
22
+ * The first word in `command` carrying an unquoted, unescaped shell glob
23
+ * character — `*`, `?` or `[` — or null when no word does.
24
+ *
25
+ * A glob character is not a shell control character (it starts no command),
26
+ * so `SHELL_CONTROL_CHARS` rightly ignores it — but the host shell expands
27
+ * it before the binary sees argv, and the grammar matcher reads dequoted
28
+ * words, where `find -name '*.conf'` and `find -name *.conf` have become the
29
+ * same string. They are not the same command: the quoted word is one operand
30
+ * of data, the unquoted one expands to every matching filename and turns one
31
+ * counted operand into several real ones (`uniq /root/.ssh/id_ed25519*`
32
+ * counted one operand; measured on debian:12, the expansion made the `.pub`
33
+ * uniq's OUTFILE and it was overwritten with the private key). This scan
34
+ * keeps the tokeniser's own quote and escape state, so the judgement happens
35
+ * at the one layer that can still tell the two apart.
36
+ *
37
+ * Deliberately word-scoped and whole-command: a glob inside `$()` or
38
+ * backticks never reaches this helper's caller (those carry
39
+ * SHELL_CONTROL_CHARS and return early), and a glob in the command word
40
+ * itself makes the allowlist lookup miss, so the caller — the allowlist
41
+ * branch, which only runs when the command words matched a READERS key
42
+ * exactly — never sees one either.
43
+ */
44
+ function firstUnquotedGlobWord(command, honorQuotes = true) {
45
+ let quote = null;
46
+ let escaped = false;
47
+ let word = '';
48
+ let globInWord = false;
49
+ for (const ch of command) {
50
+ if (escaped) {
51
+ // A backslash-quoted glob character is data the shell passes through.
52
+ word += ch;
53
+ escaped = false;
54
+ continue;
55
+ }
56
+ if (quote) {
57
+ // Backslash is literal inside single quotes; inside double quotes it
58
+ // escapes — the same distinction the tokeniser makes.
59
+ if (ch === '\\' && quote === '"') {
60
+ escaped = true;
61
+ continue;
62
+ }
63
+ if (ch === quote)
64
+ quote = null;
65
+ else
66
+ word += ch;
67
+ continue;
68
+ }
69
+ if (ch === '\\') {
70
+ escaped = true;
71
+ continue;
72
+ }
73
+ if (honorQuotes && (ch === '"' || ch === "'")) {
74
+ quote = ch;
75
+ continue;
76
+ }
77
+ if (/\s/.test(ch)) {
78
+ if (globInWord)
79
+ return word;
80
+ word = '';
81
+ globInWord = false;
82
+ continue;
83
+ }
84
+ if (ch === '*' || ch === '?' || ch === '[')
85
+ globInWord = true;
86
+ word += ch;
87
+ }
88
+ // A quote left open at the end is not a command a shell would run, and the
89
+ // tokeniser re-reads such a string with quotes demoted to text (see
90
+ // `tokenizeSegmentsDetailed`). Judge it the same way here: in that reading
91
+ // the glob character is unquoted.
92
+ if (honorQuotes && quote !== null)
93
+ return firstUnquotedGlobWord(command, false);
94
+ return globInWord ? word : null;
95
+ }
96
+ /**
97
+ * The binaries this classifier will vouch for, and what it vouches for about them.
98
+ *
99
+ * Three questions, kept apart because one Set answering all of them is how #217
100
+ * turned the interpreter carrier scan off for two verbs by adding them to a list
101
+ * about classes:
102
+ *
103
+ * readOnly does this binary only read? -> decides the class
104
+ * operandsAreData can its operands hide a command? -> decides whether the
105
+ * carrier scan runs
106
+ * grammar which of its argv words does `matchesGrammar` accept? ->
107
+ * decides `read-only` versus `safe`
108
+ *
109
+ * `readOnly` and `operandsAreData` are `true` for every entry today. The value is
110
+ * not the contents but that the type will not let the next person add a name
111
+ * without answering all three.
112
+ *
113
+ * `grammar` decides only that one boundary, not the class outright: a command
114
+ * whose grammar rejects falls no lower than `safe`, and `DISQUALIFYING_ARGS`
115
+ * still runs before the allowlist branch, so it can still raise a rejected — or
116
+ * an accepted — reader past `safe` (`sort -o /tmp/x /etc/passwd` is
117
+ * `destructive` regardless of what `sort`'s grammar says). An `'any'` grammar
118
+ * accepts every word and carries an `audit` string saying why that trust is
119
+ * believed sound; every other entry lists the flags and operand shape its
120
+ * grammar accepts, and starts with none of either — see `reader-grammar.ts`.
121
+ *
122
+ * Two mechanisms answer the second question outside this table and are not
123
+ * affected by it — `DISQUALIFYING_ARGS` and `FIND_EXEC_FLAGS` — which is why
124
+ * `find` can carry `operandsAreData: true` while `find … -exec sudo id +` is
125
+ * still `privileged`.
126
+ *
127
+ * The two-word entries are looked up only for the class: `operandsAreData` reads
128
+ * a single word, so those rows never reach the second question. Their `grammar`
129
+ * reads the words after the two-word prefix (`classifyOuter` applies the offset).
130
+ */
131
+ const READERS = {
132
+ // arp: `-a`/`-e`/`-n`/`-v` only display; `-H`/`-t` (hardware type) and `-i`
133
+ // (interface) filter the display, not set anything. Excludes `-d` (deletes an
134
+ // entry), `-s`/`-S` (adds one — BSD man synopsis) and `-f` (loads entries from
135
+ // a file — net-tools arp(8)). Measured: busybox --help + `arp -a`; macOS man
136
+ // arp read; net-tools arp(8) documented only (die.net). Windows 11 build
137
+ // 26200 measurement: `arp.exe` accepts the slash form (`/a` measured),
138
+ // `/d`/`/s` inferred (not run — a state-changing form is never executed to
139
+ // confirm it) as delete/add. A `/X` switch is an operand under this POSIX
140
+ // grammar and cannot be told apart from a path like `/etc`; `each` refuses
141
+ // any single-segment `/word` operand, with or without a trailing slash
142
+ // (see the design's Windows section) — the trailing-slash tolerance of
143
+ // `arp /d/`-style forms cannot be measured without running a state-changing
144
+ // form, so it is refused fail-closed. The cost is that a legitimate
145
+ // slash-form display like `arp /a` also falls to `safe`.
146
+ "arp": { readOnly: true, operandsAreData: true, grammar: { args: 'getopt',
147
+ flags: ['-a', '-e', '-n', '-v'],
148
+ valueFlags: ['-H', '-t', '-i'],
149
+ operands: { max: 1, each: /^(?!\/[^/]*\/?$)/ } } },
150
+ "basename": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/BSD: pure string manipulation of its argument; no filesystem access at all' } },
151
+ "cat": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: "measured GNU/busybox/BSD: writes file contents to stdout; BSD's -l is a transient advisory lock on cat's own stdout fd, not a lasting write, and clears at exit" } },
152
+ "comm": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/BSD: compares two sorted files and prints the differences; no output-file operand or flag' } },
153
+ "cut": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: "measured GNU/BSD: extracts byte/field ranges to stdout; --output-delimiter only changes what's printed, not where" } },
154
+ // date: `-u`/`--utc`/`--universal` and `-R`/`--rfc-2822`/`--rfc-email` only
155
+ // change display format; `-r`/`--reference` reads a file's mtime (GNU) or an
156
+ // epoch value (BSD); `--rfc-3339` picks a display format; `-v` (BSD) only
157
+ // shows the adjusted time ("not actually set", man). `-I`/`--iso-8601` is
158
+ // optional-value (getopt's `::`): it IS listed, in `optionalValueFlags`, so a
159
+ // bare `-I`/`--iso-8601` or an attached form (`-Iseconds`, `--iso-8601=date`)
160
+ // stays read-only — the matcher just never consumes a following SEPARATE
161
+ // word as its value. A bare `+FMT` operand is accepted (`max: 1`, `each:
162
+ // /^\+/`); excludes `-s`/`--set` (sets the clock),
163
+ // `-d`/`--date` (read the same way on GNU/busybox but historically set the
164
+ // kernel DST flag on BSD — man HISTORY / FreeBSD "added and then removed
165
+ // again" — dropped because implementations disagree) and BSD-only
166
+ // `-D`/`-f`/`-j`/`-n`. Measured: GNU d12+trixie --help, busybox (`date -d
167
+ // 2020-01-01` displayed), macOS man; FreeBSD date(1) documented only.
168
+ "date": { readOnly: true, operandsAreData: true, grammar: { args: 'getopt',
169
+ flags: ['-u', '--utc', '--universal', '-R', '--rfc-2822', '--rfc-email'],
170
+ valueFlags: ['-r', '--reference', '--rfc-3339', '-v'],
171
+ optionalValueFlags: ['-I', '--iso-8601'],
172
+ operands: { max: 1, each: /^\+/ } } },
173
+ "df": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/busybox/BSD: reports space usage read from mounted filesystems; --output= selects columns to print, not a file' } },
174
+ // diff: comparison/output-format flags only, plus value flags that read or
175
+ // match (`-D`/`--ifdef` merges to stdout, not a file). `-C`/`-U` ARE
176
+ // required-value (`valueFlags`) — GNU, BSD and busybox all take the short
177
+ // form with a count, attached or as a separate word; only the long
178
+ // spellings `--context`/`--unified`/`--color` are optional-value
179
+ // (`optionalValueFlags`: bare or `=value`, never a separate word). Moved
180
+ // here from the design round's 'any' guess: `-l`/`--paginate` pipes output
181
+ // through
182
+ // `/usr/bin/pr` — measured on debian:12, removing `/usr/bin/pr` turns it into
183
+ // "subsidiary program '/usr/bin/pr' not found", and it runs the absolute path
184
+ // regardless of PATH, so it is excluded rather than listed. Measured: GNU
185
+ // --help (d12 + trixie 3.10) and the -l behaviour; BSD man (also has -l);
186
+ // busybox --help (no -l).
187
+ "diff": { readOnly: true, operandsAreData: true, grammar: { args: 'getopt',
188
+ flags: ['-q', '--brief', '-s', '--report-identical-files', '-c', '-u', '-e', '--ed', '-n', '--rcs',
189
+ '-y', '--side-by-side', '--left-column', '--suppress-common-lines', '-p', '--show-c-function',
190
+ '-t', '--expand-tabs', '-T', '--initial-tab', '--suppress-blank-empty', '-r', '--recursive',
191
+ '--no-dereference', '-N', '--new-file', '--unidirectional-new-file', '--ignore-file-name-case',
192
+ '--no-ignore-file-name-case', '-i', '--ignore-case', '-E', '--ignore-tab-expansion', '-b',
193
+ '--ignore-space-change', '-w', '--ignore-all-space', '-B', '--ignore-blank-lines', '-a', '--text',
194
+ '--strip-trailing-cr', '-d', '--minimal'],
195
+ valueFlags: ['-C', '-U', '-W', '--width', '--tabsize', '-F', '--show-function-line', '-I',
196
+ '--ignore-matching-lines', '-D', '--ifdef', '--label', '-x', '--exclude', '-X', '--exclude-from',
197
+ '-S', '--starting-file', '--from-file', '--to-file', '--line-format'],
198
+ optionalValueFlags: ['--context', '--unified', '--color'],
199
+ operands: { max: 2 } } },
200
+ "dig": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured BIND 9.10.6 (BSD): sends a DNS query and prints the answer; writes nothing locally (DNS egress tracked separately)' } },
201
+ "dirname": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/BSD: pure string manipulation of its argument; no filesystem access at all' } },
202
+ "docker images": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured docker CLI 29.7.2 --help: lists local images from the daemon; every option only changes what is listed, no state change' } }, // two-word: class only
203
+ "docker inspect": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: "measured docker CLI 29.7.2 --help: prints low-level JSON about an object; no state change" } }, // two-word: class only
204
+ "docker logs": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: "measured docker CLI 29.7.2 --help: prints a container's captured output; no state change" } }, // two-word: class only
205
+ "docker ps": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured docker CLI 29.7.2 --help: lists containers; no state change' } }, // two-word: class only
206
+ "docker stats": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured docker CLI 29.7.2 --help: streams live resource-usage numbers; no state change' } }, // two-word: class only
207
+ "du": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/busybox/BSD: walks a directory tree and reports disk usage; no write mode' } },
208
+ "echo": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/BSD: writes its arguments to stdout only; a file write needs shell redirection, which the outer gate already refuses' } },
209
+ "false": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU: exits 1 immediately; no I/O of any kind' } },
210
+ // file: display/format flags, plus value flags that name what to read (a list
211
+ // of files, a magic database, an exclusion). Excludes `-C`/`--compile`, which
212
+ // was **measured to write**: on the local macOS file 5.41, `file -C -m
213
+ // /usr/share/file/magic/magic` created `magic.mgc` (2256 bytes) in a
214
+ // throwaway directory. GNU is documented only (file(1), die.net 5.04: "-C …
215
+ // is the only option that creates a file") — GNU file is absent from every
216
+ // measured image (see spec Question 3). Also excludes `-z`/`-Z`
217
+ // (uncompress/uncompress-noreport — audit.md lists them as no-value):
218
+ // libmagic may run an external decompressor program when it isn't built
219
+ // against the matching decompression library (documented, not measured;
220
+ // the contract forbids executing another program), so they stay off this
221
+ // list even though audit.md's own per-entry table has them. No
222
+ // optional-value option in this set; `-p`/`--preserve-date` is excluded as
223
+ // unnecessary rather than unsafe.
224
+ "file": { readOnly: true, operandsAreData: true, grammar: { args: 'getopt',
225
+ flags: ['-b', '--brief', '-c', '--checking-printout', '-i', '--mime', '--mime-type', '--mime-encoding',
226
+ '-k', '--keep-going', '-L', '--dereference', '-h', '--no-dereference', '-n', '--no-buffer',
227
+ '-r', '--raw', '-s', '--special-files', '-0', '--print0'],
228
+ valueFlags: ['-f', '--files-from', '-m', '--magic-file', '-M', '-e', '--exclude', '-F', '--separator'] } },
229
+ // find: `exact` because find parses its own predicates rather than through
230
+ // getopt and recognises no clustering or abbreviation — measured, `find .
231
+ // -exe` is "unknown predicate `-exe`". No-value predicates/operators plus
232
+ // value predicates that read metadata to test against; `-printf` writes to
233
+ // stdout, not a file (its file-writing sibling `-fprintf` is excluded, see
234
+ // below). Excludes the writing/executing family — `-delete`, `-exec`,
235
+ // `-execdir`, `-ok`, `-okdir` (also caught by DISQUALIFYING_ARGS first),
236
+ // `-fprint`, `-fprint0`, `-fprintf`, `-fls` (write to a file) — and a handful
237
+ // of read-only-but-unnecessary predicates left out rather than
238
+ // exhaustively listed (`-files0-from`, `-quit`, `-prune`, `-fstype`,
239
+ // `-newerXY`, `-regextype`, `-D`). No operand rule: start paths and `(`/`)`
240
+ // pass through as operands, and the Windows `find.exe` measurement in the
241
+ // design round found it read-only, so no `each` rule is needed here.
242
+ // Measured: GNU d12 --help; busybox (`find /tmp/t -name '*.conf' -type f`
243
+ // ran; `-ls`/`-printf` are absent from busybox find, which refuses them
244
+ // itself, fail-closed); BSD man.
245
+ "find": { readOnly: true, operandsAreData: true, grammar: { args: 'exact',
246
+ flags: ['-H', '-L', '-P', '-print', '-print0', '-ls', '-not', '-a', '-and', '-o', '-or', '-true', '-false',
247
+ '-empty', '-xdev', '-mount', '-depth', '-daystart', '-executable', '-readable', '-writable'],
248
+ valueFlags: ['-name', '-iname', '-path', '-ipath', '-wholename', '-iwholename', '-regex', '-iregex',
249
+ '-type', '-perm', '-user', '-group', '-uid', '-gid', '-size', '-mtime', '-mmin', '-atime', '-amin',
250
+ '-ctime', '-cmin', '-newer', '-anewer', '-cnewer', '-links', '-inum', '-maxdepth', '-mindepth',
251
+ '-printf'] } },
252
+ "free": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured busybox; documented (procps free(1)): reports memory usage parsed from /proc; every option only changes units or format' } },
253
+ // git branch: listing/display flags plus optional-value flags kept out of
254
+ // valueFlags (rule 1; `--contains HEAD`'s operand is refused by `max: 0`
255
+ // anyway — an accepted cost). `--sort=<key>` is the one required-value flag.
256
+ // `max: 0` excludes the operand form: a bare `git branch NAME` CREATES a
257
+ // branch (measured, `refs/heads/side` was created), so no operand budget is
258
+ // safe to grant here. Excludes `-d`/`-D` (deletes; `git branch -D main`
259
+ // measured elsewhere in this advisory round), `-m`/`-M` (moves/renames —
260
+ // already blocked by `max: 0` since both need an operand) and
261
+ // `--edit-description` (opens an editor). Measured: git 2.55.0 on macOS
262
+ // (`git branch side` + `git for-each-ref`; `--help`).
263
+ "git branch": { readOnly: true, operandsAreData: true, grammar: { args: 'getopt',
264
+ flags: ['-a', '--all', '-r', '--remotes', '-v', '--verbose', '-q', '--quiet', '--list', '--show-current'],
265
+ valueFlags: ['--sort'],
266
+ optionalValueFlags: ['--color', '--contains', '--merged', '--no-merged'],
267
+ operands: { max: 0 } } },
268
+ // git diff: display/format flags, plus `-M`/`--find-renames`,
269
+ // `-C`/`--find-copies`, `-U`/`--unified`, `--color`, `--word-diff` and
270
+ // `--stat` as optional-value (`optionalValueFlags`): `-U5` and `--unified=5` both stay
271
+ // read-only (the attached value is fine); only a following SEPARATE word is
272
+ // never consumed as the value, which is why the plain `-u` still exists as
273
+ // the no-value form. `--diff-filter`/`-S`/`-G`/`-O` all read or search, never
274
+ // write. No operand
275
+ // rule (revision/path). Excludes `--output` — **measured to write**: on a
276
+ // scratch checkout, `git diff --output=diffout.txt` created a 93-byte file
277
+ // (the design round had this only as documented). Measured: git 2.55.0.
278
+ "git diff": { readOnly: true, operandsAreData: true, grammar: { args: 'getopt',
279
+ flags: ['-p', '-u', '--patch', '--name-only', '--name-status', '--cached', '--staged', '--raw', '-w',
280
+ '--ignore-all-space', '-b', '--ignore-space-change', '--summary', '--shortstat', '--exit-code',
281
+ '--quiet', '-R', '--no-color', '--first-parent'],
282
+ valueFlags: ['--diff-filter', '-S', '-G', '-O'],
283
+ optionalValueFlags: ['-M', '--find-renames', '-C', '--find-copies', '-U', '--unified', '--color',
284
+ '--word-diff', '--stat'] } },
285
+ // git log: display/format flags, plus optional-value flags kept out of
286
+ // valueFlags (rule 1). No operand rule (revision/path, including anything
287
+ // after `--`). Excludes `--output` — **measured to write**: `git log
288
+ // --output=out.txt HEAD` created a 113-byte file on a scratch checkout
289
+ // (`.git/index` unchanged). `numericShort: true` is required because the
290
+ // corpus needs `git log --oneline -20`: git's own `-<n>` revision-limit
291
+ // shortcut is scanned character by character by real getopt, so no
292
+ // `flags`/`valueFlags` entry can name the word `-20` itself — this is the
293
+ // matcher capability the design round's audit asked for, harmless for `git
294
+ // show` too. Measured: git 2.55.0 (`git log --oneline` called; index
295
+ // unchanged).
296
+ "git log": { readOnly: true, operandsAreData: true, grammar: { args: 'getopt',
297
+ flags: ['--oneline', '--graph', '--all', '-p', '-u', '--patch', '--name-only', '--name-status',
298
+ '--abbrev-commit', '--relative-date', '--reverse', '--topo-order', '--date-order', '--parents',
299
+ '--merges', '--no-merges'],
300
+ valueFlags: ['-n', '--max-count', '--since', '--until', '--author', '--grep', '--date', '-S', '-G',
301
+ '--skip'],
302
+ optionalValueFlags: ['--pretty', '--format', '--decorate', '--branches', '--tags', '--remotes',
303
+ '--stat', '-L'],
304
+ numericShort: true } },
305
+ // git remote: `-v`/`--verbose` and the two verbose-adjacent no-value flags
306
+ // only display. `first: ['get-url']`, `max: 2` — a bare `git remote`
307
+ // and `git remote -v` are both valid (0 operands). `show` was removed from
308
+ // `first` by the final review: `git remote show <URL>` accepts a URL
309
+ // (measured, git 2.55.0) and runs ssh / `git-remote-<helper>` against the
310
+ // caller-chosen target, so the subcommand is excluded and a viewer loses
311
+ // `git remote show origin` (priced cost). Excludes `add` (writes
312
+ // config — measured, `git remote add` wrote `[remote "origin"]` into
313
+ // `.git/config`), `rename`, `remove`/`rm`, `set-url`, `set-head` (all write),
314
+ // `prune` (deletes remote-tracking refs) and `update` (fetches — changes
315
+ // refs). Measured: git 2.55.0.
316
+ "git remote": { readOnly: true, operandsAreData: true, grammar: { args: 'getopt',
317
+ flags: ['-v', '--verbose', '-n', '--push'],
318
+ operands: { max: 2, first: ['get-url'] } } },
319
+ // git show: display/format flags, plus `--pretty`, `--format`, `--decorate`
320
+ // and `--stat` as optional-value (`optionalValueFlags`: bare or `=value`, a
321
+ // following separate word is never consumed) and `--date` as the one
322
+ // required-value flag. No operand rule (HEAD, commit, tag, …). Excludes
323
+ // `--output` — same write measured for `git log`/`git diff`, same option
324
+ // family. `numericShort: true` for the same `-<n>` shortcut `git log` needs
325
+ // (`git show -3`). Measured: git 2.55.0 (the sibling `--output` write was
326
+ // re-measured this round).
327
+ "git show": { readOnly: true, operandsAreData: true, grammar: { args: 'getopt',
328
+ flags: ['-s', '--no-patch', '--name-only', '--name-status', '--oneline', '--abbrev-commit', '--raw'],
329
+ valueFlags: ['--date'],
330
+ optionalValueFlags: ['--pretty', '--format', '--decorate', '--stat'],
331
+ numericShort: true } },
332
+ "git status": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured git 2.55.0: opportunistically refreshes cached stat data in .git/index when stale; writes no content, ref or config; host can disable via GIT_OPTIONAL_LOCKS=0' } }, // two-word: class only
333
+ "grep": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/busybox/BSD: searches input for a pattern and prints matches; no option writes a file or runs a program' } },
334
+ "head": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/busybox/BSD: prints the first lines of a file; no write mode' } },
335
+ "host": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured BIND 9.10.6 (BSD): sends a DNS query and prints the answer; writes nothing locally (DNS egress tracked separately)' } },
336
+ // hostname: display flags only, no value flags. `operands: { max: 0 }` — a
337
+ // bare `hostname NAME` SETS the hostname on every implementation (GNU --help
338
+ // usage line, busybox `[-sidf] [HOSTNAME | -F FILE]`, BSD man), so no operand
339
+ // budget above zero is safe. Excludes `-F`/`--file` (sets from a file),
340
+ // `-b`/`--boot` (sets a default hostname when none is configured — GNU
341
+ // --help) and `-y`/`--yp`/`--nis` (sets the NIS domain via an operand `max:
342
+ // 0` already refuses — left out as unnecessary rather than unsafe). Measured:
343
+ // GNU hostname 3.21/3.25 --help; busybox (`hostname -s`); macOS man (`[-f]
344
+ // [-s | -d]`).
345
+ "hostname": { readOnly: true, operandsAreData: true, grammar: { args: 'getopt',
346
+ flags: ['-a', '--alias', '-A', '--all-fqdns', '-d', '--domain', '-f', '--fqdn', '--long', '-i',
347
+ '--ip-address', '-I', '--all-ip-addresses', '-s', '--short'],
348
+ operands: { max: 0 } } },
349
+ "htop": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: "documented (htop(1) + FILES section, 2.x/3.x): no CLI option writes or executes; kill/renice are interactive keys (F7/F8/F9), unreachable by a read-only caller; ~/.config/htop/htoprc is written only after an interactive Setup change on clean exit; not measured — htop is absent from every image and from macOS" } },
350
+ "id": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: "measured GNU/BSD: prints the caller's uid/gid/groups; no write mode" } },
351
+ // ifconfig: display flags/filters only (BSD's `-l -m -d -u -v -L -r -C`
352
+ // format the listing), plus a BSD output-format value flag. `operands: { max:
353
+ // 1 }` — a bare `ifconfig eth0` only SHOWS that interface's status; a second
354
+ // operand (address, `up`/`down`, `netmask`, `hw`, `mtu`, `add`/`del`,
355
+ // `destroy`, …) configures it and is refused. `optionsBeforeOperands:
356
+ // true` (final-review fix): BSD ifconfig reads options only before the
357
+ // interface operand, and macOS accepts the same letters after it as
358
+ // interface settings — `ifconfig en0 -av`/`-dad` change settings from
359
+ // letters this grammar lists — so an option-looking word after the operand
360
+ // is refused rather than read as a listed flag. Measured: busybox --help +
361
+ // `ifconfig -a` (usage line is `[-a] [IFACE] [ADDRESS]` — everything from
362
+ // ADDRESS on is a write); BSD man synopsis; net-tools ifconfig(8) documented
363
+ // only ("Otherwise, it configures the interface").
364
+ "ifconfig": { readOnly: true, operandsAreData: true, grammar: { args: 'getopt',
365
+ flags: ['-a', '-s', '-l', '-m', '-d', '-u', '-v', '-L', '-r', '-C'],
366
+ valueFlags: ['-f'],
367
+ optionsBeforeOperands: true,
368
+ operands: { max: 1 } } },
369
+ "iostat": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured busybox; documented (sysstat iostat(1)): reports kernel I/O statistics; no write mode' } },
370
+ // ip addr: no options at all — a global flag like `-4`/`-6`/`-o` sits before
371
+ // the two-word prefix, so `ip -4 addr show` never even reaches the two-word
372
+ // match and is refused visibly (accepted cost). `first: ['show', 'list']`:
373
+ // "add"/"del"/"flush" cannot be the first operand; after `show`/`list`,
374
+ // operands like `dev eth0`, `to PREFIX`, `label`, `scope` are free, and a
375
+ // bare `ip addr` is valid (vacuous `first`). Abbreviations (`ip a`, `ip ad
376
+ // sh`) are documented in GNU but refused here since the grammar requires the
377
+ // full two words — accepted cost. Measured: busybox (`ip addr show`, `ip
378
+ // addr list`, bare `ip addr` all ran); GNU ip(8) documented only (man7: "show
379
+ // (or list)").
380
+ "ip addr": { readOnly: true, operandsAreData: true, grammar: { args: 'exact', operands: { first: ['show', 'list'] } } }, // two-word: class only
381
+ // ip route: `first: ['show', 'list', 'get']` — `get ADDR` is a read-only
382
+ // query (ip-route(8): "no packets are actually sent"). Excludes `add`,
383
+ // `del`, `change`, `append`, `replace`, `flush`, `restore` (all write) and
384
+ // `save` (dumps to stdout, but its sibling `restore` writes, so the verb pair
385
+ // is left out entirely rather than split). Measured: busybox (`ip route
386
+ // list`, `ip route show` ran; `ip route get 10.0.0.1` queried without
387
+ // writing); GNU ip(8) + ip-route(8) documented only.
388
+ "ip route": { readOnly: true, operandsAreData: true, grammar: { args: 'exact', operands: { first: ['show', 'list', 'get'] } } }, // two-word: class only
389
+ // journalctl: display/filter flags, plus `-n`/`--lines`, `-b`/`--boot` and
390
+ // `--facility` as optional-value (`optionalValueFlags`; journalctl(1): the
391
+ // argument may be omitted) — a bare word or an attached form (`-b1`,
392
+ // `--boot=2`) stays read-only, but the matcher never consumes a following
393
+ // SEPARATE word as the value, so `-b -1` still refuses on the unrecognised
394
+ // `-1` (an accepted cost, not a claim that every attached spelling is
395
+ // refused). No operand rule (unit names/match patterns — reads). Excludes
396
+ // the archive-mutating
397
+ // family — `--vacuum-time`/`--vacuum-size` (deletes archived journals;
398
+ // `--vacuum-time=1s` measured destructive elsewhere in this advisory round),
399
+ // `--rotate` (measured elsewhere too), `--flush`/`--sync`/`--relinquish-var`
400
+ // (redirect the daemon's disk writes), `--setup-keys` (generates FSS keys)
401
+ // and `--verify`/`--verify-key`/`--root` (left out as unnecessary). Entirely
402
+ // documented only — journalctl(1) (man7, systemd 262~devel) — journalctl is
403
+ // absent from every measured image and from macOS.
404
+ "journalctl": { readOnly: true, operandsAreData: true, grammar: { args: 'getopt',
405
+ flags: ['-f', '--follow', '-e', '--pager-end', '-r', '--reverse', '-k', '--dmesg', '-q', '--quiet',
406
+ '-x', '--catalog', '-a', '--all', '-l', '--full', '-m', '--merge', '--no-pager', '--no-hostname',
407
+ '--no-tail', '--disk-usage', '--header', '--list-boots', '--system', '--user'],
408
+ valueFlags: ['-u', '--unit', '--since', '--until', '-S', '-U', '-o', '--output', '-p', '--priority',
409
+ '-g', '--grep', '-t', '--identifier', '-D', '--directory'],
410
+ optionalValueFlags: ['-n', '--lines', '-b', '--boot', '--facility'] } },
411
+ "ls": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/busybox/BSD: lists directory entries; no write mode' } },
412
+ // netstat: ledger correction — moved from the design round's 'any' guess to
413
+ // a grammar; audit.md's main table still records this entry as 'any'
414
+ // ("every option is display"). The corrected reasons (progress.md ledger):
415
+ // FreeBSD netstat(1) documents `-z` as resetting counters (documented, not
416
+ // measured — excluded, not listed below), and `-p` is put in `flags` rather
417
+ // than `valueFlags` because it takes a value on BSD/Windows but takes none
418
+ // on net-tools (Linux) — listing it as a required-value flag would
419
+ // force-consume the next word on the implementation where it takes none.
420
+ // `-f`/`-I` are optional-value (`optionalValueFlags`). No operand rule
421
+ // needed. Measured: busybox + BSD (`netstat -rn`, etc.); net-tools
422
+ // netstat(8) documented only for anything beyond the measured set.
423
+ "netstat": { readOnly: true, operandsAreData: true, grammar: { args: 'getopt',
424
+ flags: ['-a', '-n', '-t', '-u', '-l', '-p', '-r', '-i', '-s', '-e', '-W', '-x', '-w', '-4', '-6',
425
+ '-v', '-b', '-d', '-m'],
426
+ optionalValueFlags: ['-f', '-I'] } },
427
+ "nslookup": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured busybox/BSD: sends a DNS query; interactive mode does nothing beyond querying, and interactive input is unreachable by design (out of scope)' } },
428
+ "ping": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured busybox; documented (BSD man): sends ICMP echo requests and prints replies; writes nothing locally (ICMP egress tracked separately)' } },
429
+ "printenv": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/BSD: prints environment variables; no write mode' } },
430
+ "printf": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/BSD: writes formatted text to stdout only; a file write needs shell redirection, which the outer gate already refuses' } },
431
+ "ps": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: "measured busybox; documented (procps ps(1): 'works by reading /proc'): lists processes; no write mode" } },
432
+ "pwd": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/BSD: prints the working directory; no write mode' } },
433
+ "readlink": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/BSD: resolves a symlink; no write mode' } },
434
+ "realpath": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/BSD: resolves a canonical path; no write mode' } },
435
+ "seq": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/BSD: prints a numeric sequence to stdout; no write mode' } },
436
+ // sort: display/comparison flags only, plus `--check` as optional-value
437
+ // (`optionalValueFlags`): a bare `--check`, or an attached
438
+ // `--check=diagnose-first`/`--check=silent`, both stay read-only — a
439
+ // following SEPARATE word is never consumed as the value, which is why the
440
+ // plain `-c` still exists as the no-value form. Value flags read a key
441
+ // spec, a field separator, a buffer size or a parallelism count. No
442
+ // operand-count limit (multiple input files are legitimate). Windows 11
443
+ // build 26200 measurement: `sort.exe /O FILE IN` writes FILE,
444
+ // case-insensitive (`/o`), abbreviations accepted (`/OU`, `/OUTPUT`) — `-O`
445
+ // is not a switch, so nothing here already excludes it. A `/X` switch is an
446
+ // operand under this POSIX grammar and cannot be told apart from a path
447
+ // like `/etc`; `each` refuses any single-segment `/word` operand, with or
448
+ // without a trailing slash — the trailing-slash form re-measured on the VM
449
+ // 2026-09-26 (build 10.0.26200.9550, confined to %TEMP%): `/O/`, `/o/`,
450
+ // `/OU/`, `/OUTPUT/`, `/T/`, `/A/` are all "Invalid switch" and write
451
+ // nothing (control `/O` writes), but the rule is shared with `arp`, whose
452
+ // `/d`/`/s` slash tolerance cannot be measured without running a
453
+ // state-changing form, so it stays fail-closed. The cost is that an
454
+ // operand naming a file directly under `/` (`sort /data`), or such a
455
+ // directory with a trailing slash (`sort /etc/`), is refused too. Excludes `-o`/`--output`
456
+ // (writes — this advisory's own finding), `--compress-program` (executes a
457
+ // program — measured 14,224 times in the same finding), `-T`/`--temporary-directory`
458
+ // (chooses a write location; a viewer has no use for it, decided in the
459
+ // friction table) and `--random-source`/`--files0-from` (read a file but are
460
+ // unnecessary). `DISQUALIFYING_ARGS`'s own sort rule still runs before this
461
+ // grammar and can still raise the class; the grammar only ever draws the
462
+ // read-only/safe line. Measured: GNU d12 + trixie --help; busybox --help
463
+ // (rejects sort's long options itself: `sort: unrecognized option:
464
+ // key=2`); BSD man (same option family; BSD's own `-o` never reaches this
465
+ // grammar since it is not listed).
466
+ "sort": { readOnly: true, operandsAreData: true, grammar: { args: 'getopt',
467
+ flags: ['-b', '-c', '-d', '-f', '-g', '-h', '-i', '-M', '-m', '-n', '-R', '-r', '-s', '-u', '-V', '-z',
468
+ '--ignore-leading-blanks', '--dictionary-order', '--ignore-case', '--general-numeric-sort',
469
+ '--human-numeric-sort', '--ignore-nonprinting', '--month-sort', '--merge', '--numeric-sort',
470
+ '--random-sort', '--reverse', '--stable', '--unique', '--version-sort', '--debug',
471
+ '--zero-terminated'],
472
+ valueFlags: ['-k', '--key', '-t', '--field-separator', '-S', '--buffer-size', '--sort', '--parallel'],
473
+ optionalValueFlags: ['--check'],
474
+ operands: { each: /^(?!\/[^/]*\/?$)/ } } },
475
+ // ss: entirely documented only — ss(8) (man7, iproute2); ss is absent from
476
+ // every measured image and from macOS. Display flags only, plus value flags
477
+ // that name a socket family/query/filter. Excludes `-K`/`--kill` (forcibly
478
+ // closes sockets), `-D`/`--diag` (dumps raw TCP info to a file), `-N`/`--net`
479
+ // (switches network namespace via setns) and `-b`/`--bpf` (admin); a handful
480
+ // of display-only-but-unnecessary flags left out rather than exhaustively
481
+ // listed (`-E`/`-T`/`-Q`/`-S`/`-M`/`-Z`/`-z`/`--tos`/`--cgroup`/…).
482
+ "ss": { readOnly: true, operandsAreData: true, grammar: { args: 'getopt',
483
+ flags: ['-H', '--no-header', '-O', '--oneline', '-n', '--numeric', '-r', '--resolve', '-a', '--all',
484
+ '-l', '--listening', '-o', '--options', '-e', '--extended', '-m', '--memory', '-p', '--processes',
485
+ '-i', '--info', '-s', '--summary', '-t', '--tcp', '-u', '--udp', '-d', '--dccp', '-w', '--raw',
486
+ '-x', '--unix', '-4', '-6', '-0', '--packet'],
487
+ valueFlags: ['-f', '--family', '-A', '--query', '--socket', '-F', '--filter'] } },
488
+ "stat": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/busybox/BSD: prints file or filesystem status; no write mode' } },
489
+ // systemctl status: ledger correction — moved from the design round's 'any'
490
+ // guess to a grammar; audit.md's main table still records this entry as
491
+ // 'any' ("only reports run state and log; subcommands that mutate state are
492
+ // outside the two-word match"). The corrected reason (progress.md ledger):
493
+ // systemctl(1) documents `-H`/`--host` as running the operation over SSH
494
+ // (executes another program on another host) and `-M`/`--machine` as
495
+ // targeting a container/VM — both excluded, not listed below. `-n`/
496
+ // `--lines` and `-o`/`--output` are optional-value (`optionalValueFlags`).
497
+ // Entirely documented only — systemctl(1) (man7, systemd 262~devel) —
498
+ // absent from every measured image and from macOS.
499
+ "systemctl status": { readOnly: true, operandsAreData: true, grammar: { args: 'getopt',
500
+ flags: ['-l', '--full', '--no-pager', '-a', '--all', '-q', '--quiet', '--user', '--system', '-r',
501
+ '--recursive', '--no-legend'],
502
+ optionalValueFlags: ['-n', '--lines', '-o', '--output'] } }, // two-word: class only
503
+ "tail": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/busybox/BSD: prints the last lines of a file; -f/-F only follow, they do not write' } },
504
+ "test": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/BSD: evaluates a conditional expression and sets exit status; no I/O of any kind' } },
505
+ "top": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: "measured busybox/BSD; documented (procps top(1)): no non-interactive write mode in any implementation — kill/renice/config-write keys are interactive-only, and batch mode ('top will not accept input') takes none of them" } },
506
+ "tr": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/BSD: translates or deletes characters from stdin to stdout; no write mode' } },
507
+ "traceroute": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured busybox; documented (BSD man): sends probe packets and prints the path; writes nothing locally (ICMP/UDP egress tracked separately)' } },
508
+ "true": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU: exits 0 immediately; no I/O of any kind' } },
509
+ "uname": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/BSD: prints system identification; no write mode' } },
510
+ // uniq: display flags only, plus `--all-repeated` kept optional-value out of
511
+ // valueFlags (rule 1). `operands: { max: 1 }` — INPUT only; a second operand
512
+ // is OUTFILE and is OVERWRITTEN (measured elsewhere in this advisory:
513
+ // `uniq /etc/passwd /root/.ssh/authorized_keys`; GNU's own usage line is
514
+ // `[INPUT [OUTPUT]]`, busybox's `[FILE [OUTFILE]]`). `optionsBeforeOperands:
515
+ // true` (final-review fix): macOS's optstring is `+`-prefixed (`+D::cdif:s:u`,
516
+ // POSIX order), so `uniq IN -c` does not read `-c` as the count flag there —
517
+ // it is the OUTFILE, and uniq overwrites it; GNU permutes, so the spelling
518
+ // is only harmless there, and the grammar must hold for every
519
+ // implementation. Excludes `--group` (optional-value, reads, but left out as
520
+ // unnecessary — not a minimal grammar addition). Measured: GNU d12 --help;
521
+ // busybox --help; BSD man (same option family plus `-D --all-repeated
522
+ // [septype]`).
523
+ "uniq": { readOnly: true, operandsAreData: true, grammar: { args: 'getopt',
524
+ flags: ['-c', '--count', '-d', '--repeated', '-u', '--unique', '-i', '--ignore-case', '-z',
525
+ '--zero-terminated', '-D'],
526
+ valueFlags: ['-f', '--skip-fields', '-s', '--skip-chars', '-w', '--check-chars'],
527
+ optionalValueFlags: ['--all-repeated'],
528
+ optionsBeforeOperands: true,
529
+ operands: { max: 1 } } },
530
+ "uptime": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured busybox/BSD: prints load average and boot time; no write mode' } },
531
+ "vmstat": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: "documented (procps vmstat(8): 'requires read access to /proc'): reports virtual-memory statistics; no write mode" } },
532
+ "wc": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/BSD: counts lines, words and bytes of input; no write mode' } },
533
+ "whereis": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/BSD: searches PATH directories for binaries, sources and manuals; no write mode' } },
534
+ "which": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/BSD: locates a binary on PATH; no write mode' } },
535
+ "who": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/BSD: lists logged-in sessions from utmp; no write mode' } },
536
+ "whoami": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/BSD: prints the effective username; no write mode' } },
537
+ };
538
+ /** The class half of READERS, as the shape its consumers already expect. */
539
+ const READ_ONLY_ALLOWLIST = new Set(Object.entries(READERS).filter(([, e]) => e.readOnly).map(([name]) => name));
32
540
  // Deliberately NOT read-only: `env`, because it is an exec wrapper. `env <cmd>`
33
541
  // runs <cmd>, so allowlisting the name `env` vouched for a command the
34
542
  // classifier never looked at — `env sudo rm -f /etc/passwd` classified
@@ -177,44 +685,123 @@ const CLASS_RANK = {
177
685
  destructive: 2,
178
686
  privileged: 3,
179
687
  };
688
+ const INTERPRETERS = Object.assign(Object.create(null), {
689
+ sh: { programBearingWords: ['-c'], readable: true },
690
+ bash: { programBearingWords: ['-c'], readable: true },
691
+ dash: { programBearingWords: ['-c'], readable: true },
692
+ zsh: { programBearingWords: ['-c'], readable: true },
693
+ ksh: { programBearingWords: ['-c'], readable: true },
694
+ ash: { programBearingWords: ['-c'], readable: true },
695
+ python: { programBearingWords: ['-c'], readable: false },
696
+ python2: { programBearingWords: ['-c'], readable: false },
697
+ python3: { programBearingWords: ['-c'], readable: false },
698
+ perl: { programBearingWords: ['-e', '-E'], readable: false },
699
+ ruby: { programBearingWords: ['-e'], readable: false },
700
+ // `-p`/`--print` evaluate exactly as `-e` does and then print the result.
701
+ node: { programBearingWords: ['-e', '--eval', '-p', '--print'], readable: false },
702
+ php: { programBearingWords: ['-r'], readable: false },
703
+ // Measured on 2.11.0: each of these classified `safe` while the identical
704
+ // attack through python3 -c classified `destructive` (GHSA-qmx6-47vm-3vf7).
705
+ // `readable: false` throughout, matching python/perl/node: the program is not
706
+ // shell text, so its presence is what counts rather than its content.
707
+ osascript: { programBearingWords: ['-e'], readable: false },
708
+ lua: { programBearingWords: ['-e'], readable: false },
709
+ Rscript: { programBearingWords: ['-e'], readable: false },
710
+ bun: { programBearingWords: ['-e'], readable: false },
711
+ // No program-bearing flag: real tclsh takes a script FILE as its positional
712
+ // argument or reads one from stdin, unlike sh/bash/python/pwsh, none of
713
+ // which have a `-c` of its own. An empty list still marks tclsh as an
714
+ // unreadable interpreter for `readsProgramFromStdin` (`echo … | tclsh`),
715
+ // which is the genuine carrier; it is simply never handed a program inline
716
+ // on its own command line, so there is no flag to name here.
717
+ tclsh: { programBearingWords: [], readable: false },
718
+ // `eval` is a subcommand, not a flag — the field is named for what it holds.
719
+ deno: { programBearingWords: ['eval'], readable: false },
720
+ pwsh: { programBearingWords: ['-c', '-Command', '-e', '-EncodedCommand'], readable: false },
721
+ powershell: { programBearingWords: ['-c', '-Command', '-e', '-EncodedCommand'], readable: false },
722
+ });
180
723
  /**
181
- * Interpreters, the flags that hand them a program, and whether this file can read it.
724
+ * Every `INTERPRETERS` entry, keyed by its name lower-cased.
182
725
  *
183
- * One table rather than three overlapping name lists: adding an interpreter here is the
184
- * whole change, and a shell list that drifts out of step with a readability list is how a
185
- * new shell ends up treated as an unreadable interpreter.
726
+ * A second index rather than lower-casing the lookup key against `INTERPRETERS`
727
+ * directly, because that table's own keys are not uniformly lower-case —
728
+ * `Rscript` is not `rscript` — so folding only the *input* would still miss it.
729
+ * Built once at module load: the table is fixed, so there is nothing to keep in
730
+ * sync.
731
+ */
732
+ const INTERPRETERS_BY_LOWERCASE_NAME = Object.assign(Object.create(null), Object.fromEntries(Object.entries(INTERPRETERS).map(([name, spec]) => [name.toLowerCase(), spec])));
733
+ /** A Windows executable suffix this table's keys never carry. */
734
+ const WINDOWS_EXE_SUFFIX = /\.(exe|cmd|bat)$/i;
735
+ /**
736
+ * Resolve an interpreter table entry for a command word already stripped of
737
+ * its path (`stripPath(unquote(word))`, as every call site already computes
738
+ * it for the exact-match case below).
186
739
  *
187
- * `readable` means the program is shell, so classifying it is this same job done again
188
- * and the class it earns is real. Python, Perl, Ruby, Node and PHP are not: their program
189
- * is a different language, and `os.system('sudo id')` carries an elevation the shell
190
- * classifier cannot see. Pretending to parse them would be a worse answer than admitting
191
- * we cannot, so an unreadable program makes the command `destructive` — "we cannot tell",
192
- * not "this is root", the same answer `hasUnnameableCommand` gives.
740
+ * The table's keys are exact, case-sensitive, extension-free spellings, so a
741
+ * direct lookup is tried first and is the whole cost for the common case —
742
+ * `sh`, `python3`, the bare lowercase `pwsh`. The fallback strips a trailing
743
+ * `.exe`/`.cmd`/`.bat` and folds case, which is what a Windows target's own
744
+ * shell hands back for the *same* binary: `powershell.exe`, `PWSH.EXE` and
745
+ * `PowerShell` all name the interpreter this table already lists under
746
+ * `powershell`/`pwsh`.
193
747
  *
194
- * `busybox` is not here: it takes an applet name, not `-c`, so `busybox sh -c …` is an
195
- * `sh` invocation behind a wrapper. It is in EXEC_WRAPPERS instead, which is what it is.
748
+ * Applied to every entry, not only pwsh/powershell. The measured bug is
749
+ * about those two, but the gap is not specific to them: a target running
750
+ * `python.exe` or `Node.EXE` carries the identical mismatch, and scoping the
751
+ * fix to two names would leave the rest of the table exactly as blind as it
752
+ * was. This is the binary-*name* question, answered once — it does not touch
753
+ * flag matching, which stays case-sensitive per interpreter (`perl -E` and
754
+ * `perl -e` are still two different flags) via the existing, separate
755
+ * `caseInsensitive` parameter threaded through `programAfterFlag` and
756
+ * friends.
196
757
  */
197
- // Null-prototype for the reason `mergePolicyRules` spells out (#172): this is indexed by
198
- // a command word, which is a free string, so on a plain object `INTERPRETERS['toString']`
199
- // resolves to a function and reading `.flags` off it throws inside the policy gate.
200
- const INTERPRETERS = Object.assign(Object.create(null), {
201
- sh: { flags: ['-c'], readable: true },
202
- bash: { flags: ['-c'], readable: true },
203
- dash: { flags: ['-c'], readable: true },
204
- zsh: { flags: ['-c'], readable: true },
205
- ksh: { flags: ['-c'], readable: true },
206
- ash: { flags: ['-c'], readable: true },
207
- python: { flags: ['-c'], readable: false },
208
- python2: { flags: ['-c'], readable: false },
209
- python3: { flags: ['-c'], readable: false },
210
- perl: { flags: ['-e', '-E'], readable: false },
211
- ruby: { flags: ['-e'], readable: false },
212
- // `-p`/`--print` evaluate exactly as `-e` does and then print the result.
213
- node: { flags: ['-e', '--eval', '-p', '--print'], readable: false },
214
- php: { flags: ['-r'], readable: false },
215
- });
758
+ function resolveInterpreter(word) {
759
+ const exact = INTERPRETERS[word];
760
+ if (exact !== undefined)
761
+ return exact;
762
+ return INTERPRETERS_BY_LOWERCASE_NAME[word.replace(WINDOWS_EXE_SUFFIX, '').toLowerCase()];
763
+ }
216
764
  /** `find … -exec <cmd> +` runs cmd. */
217
765
  const FIND_EXEC_FLAGS = new Set(['-exec', '-execdir', '-ok', '-okdir']);
766
+ /**
767
+ * GNU sort's own long-option table, spelled in full — every entry `sort
768
+ * --help` lists (coreutils 9.11, `docker exec ssh-mcp-ssh-admin-1 sort
769
+ * --help`). Used only to compute which prefixes of `--output` and
770
+ * `--compress-program` `getopt_long` treats as unambiguous.
771
+ *
772
+ * `getopt_long` resolves a `--` word to the option whose name it is an
773
+ * unambiguous prefix of: a prefix shared with another option name is
774
+ * refused outright ("option '--c' is ambiguous; possibilities: '--check'
775
+ * '--compress-program'") rather than run, so it must not be treated as a
776
+ * write. Measured against the binary: `sort --c=x k` exits 2 with no file
777
+ * created; `sort --co=x k` — the second letter is where `--check` and
778
+ * `--compress-program` diverge — runs.
779
+ */
780
+ const SORT_LONG_OPTIONS = [
781
+ 'ignore-leading-blanks', 'dictionary-order', 'ignore-case',
782
+ 'general-numeric-sort', 'ignore-nonprinting', 'month-sort',
783
+ 'human-numeric-sort', 'numeric-sort', 'random-sort', 'random-source',
784
+ 'reverse', 'sort', 'version-sort', 'batch-size', 'check',
785
+ 'compress-program', 'debug', 'files0-from', 'key', 'merge', 'output',
786
+ 'stable', 'buffer-size', 'field-separator', 'temporary-directory',
787
+ 'parallel', 'unique', 'zero-terminated', 'help', 'version',
788
+ ];
789
+ /**
790
+ * The regex-alternation source matching any prefix of `full` that names
791
+ * exactly one entry of `SORT_LONG_OPTIONS` — every prefix `getopt_long`
792
+ * would accept for it, from the shortest unambiguous one up to `full`
793
+ * itself — each optionally followed by `=value`.
794
+ */
795
+ function unambiguousLongOptionSource(full) {
796
+ const prefixes = [];
797
+ for (let len = 1; len <= full.length; len++) {
798
+ const prefix = full.slice(0, len);
799
+ if (SORT_LONG_OPTIONS.filter((opt) => opt.startsWith(prefix)).length === 1) {
800
+ prefixes.push(prefix);
801
+ }
802
+ }
803
+ return `--(?:${prefixes.join('|')})(?:=|$)`;
804
+ }
218
805
  /**
219
806
  * How deep a substitution may nest before we stop reading and refuse to guess.
220
807
  *
@@ -406,8 +993,22 @@ function matchesEitherForm(command, test) {
406
993
  * Four carriers, all of which a remote shell expands and runs:
407
994
  * `$(...)`, backticks, process substitution `<(...)` / `>(...)`, and a shell
408
995
  * given `-c`.
996
+ *
997
+ * @param speculativeOperands Whether the catch-all below — any multi-word,
998
+ * non-flag operand of a segment no more specific reader claimed — should be
999
+ * pushed. That catch-all is a guess about a binary this file does not
1000
+ * recognise; the four carriers above are not guesses, the shell really does
1001
+ * run what they hold, and are pushed regardless of this flag. Defaults to
1002
+ * `true` for `classifyCommand`'s own recursion, where a guess may raise a
1003
+ * command's *class* and leave role, tier and approval to weigh in.
1004
+ * `findForbiddenMatch` passes `false`: its recursion feeds
1005
+ * `FORBIDDEN_RULES`, the one unconditional denylist, and a guess must not
1006
+ * be able to produce a refusal nobody can override (the maintainer's
1007
+ * ruling — see the block comment above `findForbiddenMatch`). `$()`,
1008
+ * backticks and `sh -c` are certain carriers and keep reaching the
1009
+ * denylist either way.
409
1010
  */
410
- export function nestedCommands(command) {
1011
+ export function nestedCommands(command, speculativeOperands = true) {
411
1012
  const found = [];
412
1013
  // `$(...)`, `<(...)`, `>(...)` — scanned rather than matched, because a regex
413
1014
  // cannot balance parentheses and `echo $(echo $(sudo id))` is the case that
@@ -468,13 +1069,86 @@ export function nestedCommands(command) {
468
1069
  for (const inner of awk?.pipedInto ?? [])
469
1070
  found.push(inner);
470
1071
  if (!operandsAreData(words)) {
1072
+ // Which operand *indices* an interpreter actually consumed as a program —
1073
+ // not whether the segment's head happens to be a name in the table, and not
1074
+ // a single segment-wide flag either. A set of indices rather than a boolean:
1075
+ // `unknownbin sh -c true 'sudo id'` has `sh -c` consume `true` (harmless,
1076
+ // pushed below) and leave `'sudo id'` untouched, and a boolean here defused
1077
+ // the catch-all for that second operand too — measured, it classified
1078
+ // `safe`. Only the specific word an interpreter actually read should be
1079
+ // excluded from the catch-all; every other operand in the segment is still
1080
+ // this binary's own, unclaimed by anything more specific.
1081
+ //
1082
+ // A binary being recognised is also not the same as this segment's
1083
+ // invocation of it being one the loop could parse:
1084
+ // `pwsh -ExecutionPolicy Bypass -Command 'sudo id'` has a recognised head
1085
+ // and a program on the line, but `-ExecutionPolicy` takes a value
1086
+ // (`Bypass`) that isn't itself a flag, and `programAfterFlag` gives up
1087
+ // rather than guess past it. Gating the catch-all below on "head is in the
1088
+ // table" excluded exactly the binaries this file just learned, and for
1089
+ // precisely the invocations its own flag-walk cannot follow — a net
1090
+ // regression, not a wash.
1091
+ const consumedOperands = new Set();
471
1092
  for (let i = 0; i < words.length; i++) {
472
- const spec = INTERPRETERS[stripPath(unquote(words[i]))];
473
- if (spec === undefined || isFlagValue(words, i, spec.flags))
1093
+ const spec = resolveInterpreter(stripPath(unquote(words[i])));
1094
+ // pwsh/powershell's own parameter binder resolves `-ENC`/`-Enc`/`-enc` to the
1095
+ // same parameter; every other interpreter's flags are exact letters (`-E` and
1096
+ // `-e` are different flags to perl). `-EncodedCommand` only ever appears on
1097
+ // the two entries whose own parameter binder works this way, so its presence
1098
+ // is the signal for which interpreter this is, not a hardcoded name check.
1099
+ const caseInsensitive = spec?.programBearingWords.includes('-EncodedCommand') ?? false;
1100
+ if (spec === undefined || isFlagValue(words, i, spec.programBearingWords, caseInsensitive))
474
1101
  continue;
475
- const program = programAfterFlag(words, i, spec.flags);
476
- if (program !== null)
477
- found.push(program);
1102
+ // Same signal, second job: pwsh/powershell's argument parser also does not stop
1103
+ // at an unrecognised option's bare value (`-ExecutionPolicy Bypass`) the way a
1104
+ // POSIX interpreter stops at its first positional argument. See
1105
+ // `programAfterFlag`'s `tolerateUnknownWordsAtHead` for why this is scoped to
1106
+ // `i === 0` there rather than here.
1107
+ const result = programAfterFlag(words, i, spec.programBearingWords, caseInsensitive, caseInsensitive);
1108
+ if (result !== null) {
1109
+ consumedOperands.add(result.index);
1110
+ found.push(result.program);
1111
+ // An encoded program is opaque to every text scan until it is decoded. Gated on
1112
+ // the word `programAfterFlag` actually matched — the table's own canonical
1113
+ // spelling, so every case-folded or clustered form of `-EncodedCommand`/`-e`
1114
+ // reaches this the same way a literal `-EncodedCommand` does — not a scan of
1115
+ // the whole segment for one long spelling.
1116
+ if (caseInsensitive && (result.flag === '-EncodedCommand' || result.flag === '-e')) {
1117
+ const decoded = decodedPowerShellCommand(result.program);
1118
+ if (decoded !== null)
1119
+ found.push(decoded);
1120
+ }
1121
+ }
1122
+ }
1123
+ // An operand of a binary nothing more specific has read is classified as a
1124
+ // command in its own right, rather than scanned as text.
1125
+ //
1126
+ // The gate is the awk reader's own result, and now which specific operands
1127
+ // an interpreter actually consumed, rather than a list of names: a name
1128
+ // list here would be the defect this change exists to fix
1129
+ // (GHSA-qmx6-47vm-3vf7), and "the head is a name in the table" turned out
1130
+ // to be one too — it answers a different question than "did this
1131
+ // invocation's program-bearing word actually resolve". `awk` is already
1132
+ // null for every non-awk segment.
1133
+ //
1134
+ // Whitespace is what separates an operand worth classifying from one that is
1135
+ // not: a single token is a path, a flag value or a subcommand, while a
1136
+ // multi-word operand has the shape of a command. Flags are skipped.
1137
+ //
1138
+ // Deliberately NOT a text scan for `sudo`. That version asserted elevations
1139
+ // the awk reader and the variable-command-word logic refuse to assert — both
1140
+ // cap at `destructive` on purpose — and it out-ranked the nested
1141
+ // classification that names the elevated binary, reporting `awk` where `id`
1142
+ // was correct.
1143
+ if (speculativeOperands && awk === null) {
1144
+ for (let i = 1; i < words.length; i++) {
1145
+ if (consumedOperands.has(i))
1146
+ continue;
1147
+ if (words[i].startsWith('-'))
1148
+ continue;
1149
+ if (/\s/.test(words[i]))
1150
+ found.push(words[i]);
1151
+ }
478
1152
  }
479
1153
  }
480
1154
  // `-exec` is a flag rather than a command word, so this one is still a scan.
@@ -541,7 +1215,58 @@ const EXEC_WRAPPERS = new Set([
541
1215
  * the `+` terminator carries none.
542
1216
  */
543
1217
  // Null-prototype for the same reason as INTERPRETERS: indexed by the command word.
544
- const DISQUALIFYING_ARGS = Object.assign(Object.create(null), { find: /^-(exec|execdir|ok|okdir|delete|fprintf?|fls)$/ });
1218
+ const DISQUALIFYING_ARGS = Object.assign(Object.create(null), {
1219
+ find: /^-(exec|execdir|ok|okdir|delete|fprintf?|fls)$/,
1220
+ // GNU sort execs this for every temporary file it spills, so a reader
1221
+ // becomes a launcher. Measured against coreutils 9.11: an attacker-named
1222
+ // script ran 14,224 times for one 200k-line input, and the whole command
1223
+ // classified `read-only` — which a `readOnly` viewer is allowed to run,
1224
+ // while running that same program directly is denied.
1225
+ //
1226
+ // `-o FILE` / `--output=FILE` is the same shape of bug with a plainer
1227
+ // payoff: it creates and truncates FILE, which is a write a `readOnly`
1228
+ // profile must never reach through a command classified `read-only`.
1229
+ // Measured on HEAD before this rule: `sort -o /root/.ssh/authorized_keys
1230
+ // /tmp/key.pub` classified `read-only`.
1231
+ //
1232
+ // Targeted round (2026-09-24, R2+R3): the version above closed the exact
1233
+ // spellings `-o`, `--output` and `--compress-program` and left the CLASS
1234
+ // open — GNU sort's own option grammar has two escapes past a fixed
1235
+ // spelling, both measured against the real binary:
1236
+ //
1237
+ // - A short-option cluster is scanned left to right, and any of sort's
1238
+ // own *argument-less* short flags may sit ahead of `-o` without
1239
+ // consuming it: `sort -mo out in` writes `out` exactly as `sort -o out
1240
+ // in` does. `-m` (merge) is itself argument-less, and its absence from
1241
+ // the char class below is exactly what let `sort -mo …` through as
1242
+ // `read-only`.
1243
+ // - `getopt_long` resolves a `--` word by unambiguous-prefix matching:
1244
+ // `--o`, `--ou`, `--out`, `--outp`, `--outpu` all mean `--output`
1245
+ // (nothing else starts with `o`), and `--co` through
1246
+ // `--compress-progra` all mean `--compress-program` (`--c` alone is
1247
+ // ambiguous with `--check` and sort refuses to run at all —
1248
+ // `unambiguousLongOptionSource` excludes it for exactly that reason,
1249
+ // computed from sort's own option table rather than hand-picked).
1250
+ //
1251
+ // The cluster branch is still deliberately narrower than "any `o` in a
1252
+ // dash word": `-[bcCdfghiMmnRrsuVz]*o` only allows GNU sort's own
1253
+ // argument-less short flags ahead of the `o`, so it stops at the first
1254
+ // flag that takes a value of its own. Without that, `-tofile` — `-t`
1255
+ // (field separator) with its value attached, not `-o` — would be
1256
+ // misread as a write. `-t`, `-k`, `-S` and `-T` are exactly the short
1257
+ // flags this excludes, because each consumes the rest of a clustered
1258
+ // word as its own argument, so a following `o` is that argument's text,
1259
+ // not `-o` invoked.
1260
+ //
1261
+ // `find`'s entry above does not get the same prefix-abbreviation
1262
+ // treatment: `find` parses its predicates itself rather than through
1263
+ // `getopt_long`, and accepts no abbreviated spelling of `-exec` — `find .
1264
+ // -exe` is "unknown predicate `-exe`", measured. There is no class to
1265
+ // close there.
1266
+ sort: new RegExp(`^(?:${unambiguousLongOptionSource('compress-program')}`
1267
+ + `|${unambiguousLongOptionSource('output')}`
1268
+ + '|-[bcCdfghiMmnRrsuVz]*o)'),
1269
+ });
545
1270
  /** A leading `NAME=value`, which a shell treats as an assignment, not a command. */
546
1271
  const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
547
1272
  /** `timeout 5s`, `nice 10` — a bare argument some wrappers take before the command. */
@@ -673,11 +1398,26 @@ function elevatedBinaryOf(command) {
673
1398
  * behind a wrapper, behind an assignment, or after a separator all reached root
674
1399
  * with the command classified `safe` or `read-only`.
675
1400
  */
676
- /** An allowlisted binary carrying a flag that makes it write or execute. */
1401
+ /**
1402
+ * An allowlisted binary carrying a flag that makes it write or execute.
1403
+ *
1404
+ * Keyed on `effectiveCommandIndex`, not `parseSegments`. `parseSegments`
1405
+ * (`parseWords`) only steps over a privilege prefix (`sudo`, `su`, …), not an
1406
+ * exec wrapper (`env`, `nohup`, `timeout`, …), so `env sort
1407
+ * --compress-program=X` read `head` as `"env"` — not in `DISQUALIFYING_ARGS`
1408
+ * — and the flag went unnoticed. `effectiveCommandIndex` already reads past
1409
+ * both, which is what `operandsAreData` and `readsProgramFromStdin` use it
1410
+ * for. Deliberately not fixed by changing `parseWords` itself:
1411
+ * `invokedWords` also reads it, and widening what counts as "the command"
1412
+ * there is a different, larger change this fix does not take on.
1413
+ */
677
1414
  function hasDisqualifyingArgs(command) {
678
- return parseSegments(command).some(({ head, args }) => {
679
- const rule = DISQUALIFYING_ARGS[head];
680
- return rule !== undefined && args.some((arg) => rule.test(arg));
1415
+ return tokenizeSegments(command).some((words) => {
1416
+ const idx = effectiveCommandIndex(words);
1417
+ if (idx === -1)
1418
+ return false;
1419
+ const rule = DISQUALIFYING_ARGS[stripPath(words[idx])];
1420
+ return rule !== undefined && words.slice(idx + 1).some((arg) => rule.test(arg));
681
1421
  });
682
1422
  }
683
1423
  function invokedWords(command) {
@@ -769,9 +1509,21 @@ export function findForbiddenMatch(command, depth = 0) {
769
1509
  // They still classified `destructive`, which on the `prod` tier degrades an
770
1510
  // absolute `deny` into `require-approval`: a rule that answers "never" became
771
1511
  // one a human can click through.
1512
+ //
1513
+ // `speculativeOperands: false` — the maintainer's ruling. `nestedCommands`'
1514
+ // catch-all is a guess about an unrecognised binary's operands, and this loop
1515
+ // feeds the one unconditional rule in the policy: a guess may raise a
1516
+ // command's class, which role, tier and approval still get to weigh in on,
1517
+ // but it must not manufacture a refusal nobody can override. Measured before
1518
+ // this parameter existed: `git commit -m 'reboot the worker pool'` was a hard
1519
+ // deny on an admin profile with `approvalPolicy: 'auto'`, because the catch-all
1520
+ // read the quoted commit message as an operand starting with a forbidden word.
1521
+ // `$()`, backticks and `sh -c` are certain carriers, not guesses — the shell
1522
+ // really does run what they hold — and are unaffected: they are pushed by
1523
+ // `nestedCommands` regardless of this flag.
772
1524
  if (depth >= MAX_NESTING_DEPTH)
773
1525
  return null;
774
- for (const inner of nestedCommands(command)) {
1526
+ for (const inner of nestedCommands(command, false)) {
775
1527
  const match = findForbiddenMatch(inner, depth + 1);
776
1528
  if (match !== null)
777
1529
  return match;
@@ -874,7 +1626,9 @@ function effectiveCommandIndex(words) {
874
1626
  */
875
1627
  function operandsAreData(words) {
876
1628
  const idx = effectiveCommandIndex(words);
877
- return idx !== -1 && READ_ONLY_ALLOWLIST.has(stripPath(unquote(words[idx])));
1629
+ if (idx === -1)
1630
+ return false;
1631
+ return READERS[stripPath(unquote(words[idx]))]?.operandsAreData === true;
878
1632
  }
879
1633
  /**
880
1634
  * Whether an interpreter name is the value of the flag before it rather than a command.
@@ -904,59 +1658,169 @@ function operandsAreData(words) {
904
1658
  function skippableBetweenFlags(word) {
905
1659
  return word === '' || word.startsWith('-');
906
1660
  }
907
- function isFlagValue(words, i, flags) {
1661
+ /**
1662
+ * Whether `word` is a spelling of `flag`, honouring case-insensitivity when asked.
1663
+ *
1664
+ * Scoped per call, never globally: pwsh/powershell's own parameter binder resolves
1665
+ * `-ENC`, `-Enc` and `-enc` to the same parameter, but `-E` and `-e` are two different
1666
+ * flags to perl. Every caller here is told explicitly, per invocation, whether the
1667
+ * interpreter it is matching against is one of the case-insensitive ones — folding case
1668
+ * is never the default.
1669
+ */
1670
+ function sameFlag(word, flag, caseInsensitive) {
1671
+ return caseInsensitive ? word.toLowerCase() === flag.toLowerCase() : word === flag;
1672
+ }
1673
+ function isFlagValue(words, i, flags, caseInsensitive = false) {
908
1674
  if (i === 0)
909
1675
  return false;
910
1676
  const previous = words[i - 1];
911
- return previous.length > 1 && previous.startsWith('-') && flags.includes(previous);
1677
+ if (!(previous.length > 1 && previous.startsWith('-')))
1678
+ return false;
1679
+ return flags.some((f) => sameFlag(previous, f, caseInsensitive));
912
1680
  }
913
1681
  /**
914
1682
  * The program an interpreter was handed on its command line, or null.
915
1683
  *
916
1684
  * Only flags may sit between the interpreter and its flag; anything else means this was
917
1685
  * not that kind of invocation, which is what keeps `python3 script.py` — a program this
918
- * cannot read either, but one every deployment runs — out of the gate.
1686
+ * cannot read either, but one every deployment runs — out of the gate. That rule holds
1687
+ * for every interpreter here except pwsh/powershell (see `tolerateUnknownWordsAtHead`):
1688
+ * real POSIX interpreters stop parsing their own options at the first positional
1689
+ * argument — `python3 script.py -c 'evil'` hands `-c evil` to the script as `argv`, not
1690
+ * to python — so treating a bare word as "not this kind of invocation" is correct for
1691
+ * them, not merely convenient.
1692
+ *
1693
+ * Returns which of `flags` matched alongside the program, not just the program text, so
1694
+ * a caller can tell `-EncodedCommand` from its `-e` abbreviation apart from every other
1695
+ * program-bearing word — decoding is specific to that one flag, and both spellings reach
1696
+ * here as an ordinary match.
1697
+ *
1698
+ * @param tolerateUnknownWordsAtHead pwsh/powershell's own argument parser walks the
1699
+ * whole command line looking for named parameters it recognises and does not stop at
1700
+ * an unrecognised one's value (`-ExecutionPolicy Bypass`) the way a POSIX interpreter
1701
+ * stops at its first positional argument. Only takes effect when `from` is the
1702
+ * segment's *effective* command word — `effectiveCommandIndex(words)`, which reads
1703
+ * past a privilege prefix or an exec wrapper (`env`, `nohup`, `timeout`, …), not merely
1704
+ * position 0. `from === 0` missed exactly the shape those wrappers exist to describe:
1705
+ * `env pwsh -ExecutionPolicy Bypass -EncodedCommand …` put `pwsh` at index 1, the
1706
+ * tolerance never engaged, and the interpreter loop's own flag-walk gave up at
1707
+ * `Bypass` before ever reaching `-EncodedCommand` — classified `safe`. Still unambiguous
1708
+ * about *which* word is being invoked, not a value or a search term this file has no
1709
+ * business reinterpreting (`grep -e perl -e python` never reaches here with this set,
1710
+ * because `perl` is not the segment's effective command word and isn't pwsh-family
1711
+ * regardless; nor is `customtool --search pwsh …`, where `customtool` is). Structural,
1712
+ * not a list of pwsh's value-taking flags: this file does not need to know
1713
+ * `-ExecutionPolicy` exists to stop being confused by it.
919
1714
  */
920
- function programAfterFlag(words, from, flags) {
1715
+ function programAfterFlag(words, from, flags, caseInsensitive = false, tolerateUnknownWordsAtHead = false) {
1716
+ const tolerateUnknownWords = tolerateUnknownWordsAtHead && from === effectiveCommandIndex(words);
921
1717
  for (let j = from + 1; j < words.length; j++) {
922
1718
  const word = words[j];
923
- if (flags.includes(word))
924
- return words[j + 1] ?? null;
1719
+ const exact = flags.find((f) => sameFlag(word, f, caseInsensitive));
1720
+ if (exact !== undefined) {
1721
+ // A subcommand — a program-bearing word that is not itself a `-` flag, such as
1722
+ // `deno`'s `eval` — may still have its own options before the code: `deno eval
1723
+ // --unstable <code>` is one invocation, not two. A real flag like `-c` or
1724
+ // `-Command` never has anything of its own between it and the program, so this
1725
+ // only widens the subcommand case.
1726
+ let k = j + 1;
1727
+ if (!exact.startsWith('-')) {
1728
+ while (k < words.length && skippableBetweenFlags(words[k]))
1729
+ k++;
1730
+ }
1731
+ const program = words[k];
1732
+ return program === undefined ? null : { program, flag: exact, index: k };
1733
+ }
1734
+ // Deliberately case-SENSITIVE even for pwsh/powershell: this branch exists for a
1735
+ // value glued directly onto a short flag with no separating space (`-csudo id`).
1736
+ // Folding case here as well made `-ExecutionPolicy` — a real pwsh option this file
1737
+ // does not otherwise track — case-insensitively start with `-e` and get read as
1738
+ // `-e` plus an attached `xecutionPolicy`, which swallowed `-ExecutionPolicy Bypass
1739
+ // -Command 'sudo id'` into `destructive` and, worse, made `foundProgram` true so
1740
+ // the catch-all below never ran. Every case-insensitive spelling the table needs
1741
+ // to accept (`-enc`, `-ec`, `-EC`, `-ENC`, `-E`, `-EncodedCOMMAND`, `-COMMAND`,
1742
+ // `-C`, …) is already reached through the exact match above or the cluster match
1743
+ // below, so this branch does not need to fold case to cover them.
925
1744
  const attached = flags.find((f) => word.startsWith(f) && word.length > f.length);
926
1745
  if (attached !== undefined) {
927
1746
  const rest = word.slice(attached.length);
928
1747
  // `sh -c'sudo id'` tokenises to `-csudo id`, so the program is attached. `bash -cx`
929
1748
  // is a flag cluster and the program is the next word. A space, or the `=` of
930
1749
  // `--eval=…`, is what tells them apart: a cluster is letters only.
931
- if (/\s/.test(rest) || rest.startsWith('='))
932
- return rest.replace(/^=/, '');
933
- return words[j + 1] ?? null;
1750
+ if (/\s/.test(rest) || rest.startsWith('=')) {
1751
+ return { program: rest.replace(/^=/, ''), flag: attached, index: j };
1752
+ }
1753
+ const program = words[j + 1];
1754
+ return program === undefined ? null : { program, flag: attached, index: j + 1 };
934
1755
  }
935
1756
  // The program flag need not lead the cluster: `bash -xc 'sudo id'` runs exactly what
936
1757
  // `bash -cx 'sudo id'` runs, and a prefix test saw the second and missed the first.
937
- if (clusterCarriesFlag(word, flags))
938
- return words[j + 1] ?? null;
939
- if (!skippableBetweenFlags(word))
1758
+ const clusterFlag = clusterCarriesFlag(word, flags, caseInsensitive);
1759
+ if (clusterFlag !== null) {
1760
+ const program = words[j + 1];
1761
+ return program === undefined ? null : { program, flag: clusterFlag, index: j + 1 };
1762
+ }
1763
+ if (!skippableBetweenFlags(word) && !tolerateUnknownWords)
940
1764
  return null;
941
1765
  }
942
1766
  return null;
943
1767
  }
1768
+ /** How much base64 is worth decoding before the answer stops changing. */
1769
+ const MAX_ENCODED_CHARS = 64 * 1024;
944
1770
  /**
945
- * What follows a short flag inside a single-dash cluster, or null if it holds none.
1771
+ * The command inside `-EncodedCommand`, or null.
1772
+ *
1773
+ * PowerShell encodes UTF-16LE, so decoding as utf8 yields text with a NUL between
1774
+ * every character and no pattern matches it. `Buffer.from(x, 'base64')` never
1775
+ * throws — it drops characters outside the alphabet — so malformed input produces
1776
+ * a wrong answer rather than an exception, and the caller must treat null and
1777
+ * nonsense alike: the flag alone has already made the command `destructive`.
1778
+ *
1779
+ * Reads a bounded, 4-aligned PREFIX rather than refusing outright past the limit. A
1780
+ * refusal is a downgrade path: it turns "pad the payload past 64 KiB" into a way to
1781
+ * trade `privileged` for `destructive`, which is strictly better for whoever is padding
1782
+ * it. 4-aligned because base64 decodes in groups of four characters to three bytes;
1783
+ * truncating mid-group corrupts the last partial character instead of just dropping
1784
+ * trailing content the decode was never going to reach anyway.
1785
+ */
1786
+ function decodedPowerShellCommand(operand) {
1787
+ const prefix = operand.length > MAX_ENCODED_CHARS
1788
+ ? operand.slice(0, MAX_ENCODED_CHARS - (MAX_ENCODED_CHARS % 4))
1789
+ : operand;
1790
+ const decoded = Buffer.from(prefix, 'base64').toString('utf16le');
1791
+ return decoded.includes('�') || decoded.trim() === '' ? null : decoded;
1792
+ }
1793
+ /**
1794
+ * What follows a short flag inside a single-dash cluster, or the `flags` entry it
1795
+ * carries, or null if it carries none.
946
1796
  *
947
1797
  * Clusters only — a run of single letters after one dash. Long flags and attached values
948
- * are handled before this is reached.
1798
+ * are handled before this is reached. Returns the canonical flag rather than a boolean
1799
+ * for the same reason `programAfterFlag` does: a caller deciding whether to decode
1800
+ * compares against the table's own spellings, not against whatever letters happened to
1801
+ * be clustered together.
949
1802
  */
950
- function clusterCarriesFlag(word, flags) {
1803
+ function clusterCarriesFlag(word, flags, caseInsensitive = false) {
951
1804
  // Three letters at most. `/^-[A-Za-z]+$/` alone also matches every single-dash long
952
1805
  // option, and `find`'s predicates are full of them: `-type` contains perl's `-e`, so
953
1806
  // `find . -name perl -type f` read as a carrier and asked for approval.
954
1807
  if (!/^-[A-Za-z]{2,3}$/.test(word))
955
- return false;
956
- const body = word.slice(1);
1808
+ return null;
1809
+ const body = caseInsensitive ? word.slice(1).toLowerCase() : word.slice(1);
1810
+ const shortFlags = flags.filter((flag) => flag.length === 2);
957
1811
  // A cluster is letters only, so whatever follows the program flag is more flags — the
958
1812
  // program itself is always the next word.
959
- return flags.some((flag) => flag.length === 2 && body.includes(flag[1]));
1813
+ //
1814
+ // Scanned by the BODY's own character order, not the table's order: `-ec` must
1815
+ // resolve to `-e` (so a caller deciding whether to decode sees `-e`), not to `-c`
1816
+ // just because `-c` happens to sit first in pwsh's `programBearingWords`. The first
1817
+ // letter in the cluster is the one the invocation leads with.
1818
+ for (const letter of body) {
1819
+ const match = shortFlags.find((flag) => (caseInsensitive ? flag[1].toLowerCase() : flag[1]) === letter);
1820
+ if (match !== undefined)
1821
+ return match;
1822
+ }
1823
+ return null;
960
1824
  }
961
1825
  /** Spellings of "the program is on standard input" that look like a file operand. */
962
1826
  const STDIN_PATHS = new Set(['-', '/dev/stdin', '/dev/fd/0', '/proc/self/fd/0']);
@@ -975,10 +1839,10 @@ function readsProgramFromStdin(words) {
975
1839
  const bin = stripPath(words[idx]);
976
1840
  // Interpreters only. awk's one program-from-stdin form is `awk -f -`, already gated as a
977
1841
  // file flag; every other piped awk reads data, not a program.
978
- const spec = INTERPRETERS[bin];
1842
+ const spec = resolveInterpreter(bin);
979
1843
  if (spec === undefined)
980
1844
  return false;
981
- const flags = spec.flags;
1845
+ const flags = spec.programBearingWords;
982
1846
  let sawStdinFlag = false;
983
1847
  for (let j = idx + 1; j < words.length; j++) {
984
1848
  const word = words[j];
@@ -1074,12 +1938,16 @@ function hasUnreadableProgram(command) {
1074
1938
  return true;
1075
1939
  if (!operandsAreData(words)) {
1076
1940
  for (let j = 0; j < words.length; j++) {
1077
- const spec = INTERPRETERS[stripPath(unquote(words[j]))];
1941
+ const spec = resolveInterpreter(stripPath(unquote(words[j])));
1078
1942
  if (spec === undefined || spec.readable)
1079
1943
  continue;
1080
- if (isFlagValue(words, j, spec.flags))
1944
+ // Same case-insensitivity as `nestedCommands`'s interpreter loop, and for the
1945
+ // same reason: pwsh/powershell's own parameter binder does not distinguish
1946
+ // `-EC` from `-ec`, so neither should this.
1947
+ const caseInsensitive = spec.programBearingWords.includes('-EncodedCommand');
1948
+ if (isFlagValue(words, j, spec.programBearingWords, caseInsensitive))
1081
1949
  continue;
1082
- if (programAfterFlag(words, j, spec.flags) !== null)
1950
+ if (programAfterFlag(words, j, spec.programBearingWords, caseInsensitive, caseInsensitive) !== null)
1083
1951
  return true;
1084
1952
  }
1085
1953
  }
@@ -1190,7 +2058,19 @@ export function classifyCommand(command, depth = 0) {
1190
2058
  if (CLASS_RANK[parsed.class] > CLASS_RANK[highest.class])
1191
2059
  highest = parsed;
1192
2060
  }
1193
- return { binary: highest.binary, fullCommand: trimmed, class: highest.class };
2061
+ // `readOnlyRejection` only ever describes the outer command's own grammar
2062
+ // rejection, and only means anything when that is also what decided the
2063
+ // final class. `highest === outer` (reference equality) is exactly that
2064
+ // condition: the synthetic floor and the nested-command loop above only
2065
+ // ever replace `highest` with something of strictly higher rank than
2066
+ // `outer`'s, so if either one fired, `highest` is no longer `outer` and the
2067
+ // rejection — which was never a fact about the synthetic verb or the nested
2068
+ // command anyway — is correctly left off.
2069
+ const result = { binary: highest.binary, fullCommand: trimmed, class: highest.class };
2070
+ if (highest === outer && outer.readOnlyRejection) {
2071
+ result.readOnlyRejection = outer.readOnlyRejection;
2072
+ }
2073
+ return result;
1194
2074
  }
1195
2075
  /** The class of the command itself, reading none of what it carries. */
1196
2076
  function classifyOuter(trimmed) {
@@ -1218,14 +2098,74 @@ function classifyOuter(trimmed) {
1218
2098
  if (hasUnnameableCommand(trimmed)) {
1219
2099
  return { binary, fullCommand, class: 'destructive' };
1220
2100
  }
1221
- const twoWordPrefix = (tokenizeSegments(fullCommand)[0] ?? []).slice(0, 2).join(' ');
2101
+ const words = tokenizeSegments(fullCommand)[0] ?? [];
2102
+ const twoWordPrefix = words.slice(0, 2).join(' ');
1222
2103
  if (READ_ONLY_ALLOWLIST.has(binary) || READ_ONLY_ALLOWLIST.has(twoWordPrefix)
1223
2104
  || READ_ONLY_SYNTHETIC.has(binary)) {
1224
2105
  if (SHELL_CONTROL_CHARS.test(trimmed)) {
1225
2106
  return { binary, fullCommand, class: 'safe' };
1226
2107
  }
2108
+ // The SFTP verbs are synthesised by the tool layer, never typed by a caller,
2109
+ // so there is no argv for a grammar to read — READ_ONLY_SYNTHETIC stays
2110
+ // exactly as it was.
2111
+ if (READ_ONLY_SYNTHETIC.has(binary)) {
2112
+ return { binary, fullCommand, class: 'read-only' };
2113
+ }
2114
+ // Whichever key matched decides both which entry answers and how many
2115
+ // leading words are the command name rather than an argument: a two-word
2116
+ // entry's grammar reads words[2..], a one-word entry's reads words[1..].
2117
+ const matchedTwoWord = READ_ONLY_ALLOWLIST.has(twoWordPrefix);
2118
+ const readerName = matchedTwoWord ? twoWordPrefix : binary;
2119
+ const entry = READERS[readerName];
2120
+ const argWords = words.slice(matchedTwoWord ? 2 : 1);
2121
+ // An unquoted glob word is not a word this grammar can vouch for: the
2122
+ // shell replaces it with however many filenames it matches, and the
2123
+ // grammar's operand count — the thing it was about to prove — is false
2124
+ // before the binary runs. `'any'` entries are exempt: their claim is that
2125
+ // no option or operand of the binary writes or executes, so extra
2126
+ // operands from an expansion are still data.
2127
+ if (entry.grammar.args !== 'any') {
2128
+ const globWord = firstUnquotedGlobWord(fullCommand);
2129
+ if (globWord !== null) {
2130
+ return {
2131
+ binary,
2132
+ fullCommand,
2133
+ class: 'safe',
2134
+ readOnlyRejection: { binary: readerName, word: globWord },
2135
+ };
2136
+ }
2137
+ }
2138
+ const match = matchesGrammar(argWords, entry.grammar);
2139
+ if (!match.ok) {
2140
+ return {
2141
+ binary,
2142
+ fullCommand,
2143
+ class: 'safe',
2144
+ readOnlyRejection: { binary: readerName, word: match.word },
2145
+ };
2146
+ }
1227
2147
  return { binary, fullCommand, class: 'read-only' };
1228
2148
  }
1229
2149
  return { binary, fullCommand, class: 'safe' };
1230
2150
  }
1231
- export { READ_ONLY_ALLOWLIST, READ_ONLY_SYNTHETIC, isDestructive };
2151
+ /**
2152
+ * The sentence a refusal appends when it carries a `readOnlyRejection`, worded
2153
+ * so the reason names the binary that would have qualified and the argv word
2154
+ * that cost it: "is not accepted there" rather than "is not one of them",
2155
+ * because most rejections are operand rules (`hostname NAME`, `sort /data`) or
2156
+ * a listed value flag missing its value, where "is not one of them" would be
2157
+ * false — it may be a perfectly good flag name, just not one bare or in this
2158
+ * position. One function so the engine's role-binding denial and
2159
+ * `read-command`'s `enforceClass` refusal never drift into two spellings of
2160
+ * the same sentence.
2161
+ */
2162
+ function formatReadOnlyRejection(rejection) {
2163
+ // The rejected word can be the empty string — a quoted empty operand
2164
+ // (`hostname ""`) — and two bare backticks would render as a blank where
2165
+ // the word that cost the class should be. `""` is the shell's own spelling
2166
+ // of that operand.
2167
+ const word = rejection.word === '' ? '""' : rejection.word;
2168
+ return `\`${rejection.binary}\` is read-only only with the options and operands its grammar lists; ` +
2169
+ `\`${word}\` is not accepted there.`;
2170
+ }
2171
+ export { READ_ONLY_ALLOWLIST, READERS, READ_ONLY_SYNTHETIC, isDestructive, formatReadOnlyRejection, };