ssh-mcp 2.12.0 → 2.14.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,19 +18,106 @@ 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]/;
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, dialect = 'posix') {
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 (dialect === 'posix' && 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 (dialect === 'posix' && 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, dialect);
94
+ return globInWord ? word : null;
95
+ }
20
96
  /**
21
97
  * The binaries this classifier will vouch for, and what it vouches for about them.
22
98
  *
23
- * Two questions, kept apart because one Set answering both is how #217 turned the
24
- * interpreter carrier scan off for two verbs by adding them to a list about
25
- * classes:
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:
26
102
  *
27
103
  * readOnly does this binary only read? -> decides the class
28
104
  * operandsAreData can its operands hide a command? -> decides whether the
29
105
  * carrier scan runs
30
- *
31
- * Both are `true` for every entry today. The value is not the contents but that
32
- * the type will not let the next person add a name without answering both.
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`.
33
121
  *
34
122
  * Two mechanisms answer the second question outside this table and are not
35
123
  * affected by it — `DISQUALIFYING_ARGS` and `FIND_EXEC_FLAGS` — which is why
@@ -37,77 +125,415 @@ const SHELL_CONTROL_CHARS = /[;&|<>`$(){}\n\r]/;
37
125
  * still `privileged`.
38
126
  *
39
127
  * The two-word entries are looked up only for the class: `operandsAreData` reads
40
- * a single word, so those rows never reach the second question.
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).
41
130
  */
42
131
  const READERS = {
43
- "arp": { readOnly: true, operandsAreData: true },
44
- "basename": { readOnly: true, operandsAreData: true },
45
- "cat": { readOnly: true, operandsAreData: true },
46
- "comm": { readOnly: true, operandsAreData: true },
47
- "cut": { readOnly: true, operandsAreData: true },
48
- "date": { readOnly: true, operandsAreData: true },
49
- "df": { readOnly: true, operandsAreData: true },
50
- "diff": { readOnly: true, operandsAreData: true },
51
- "dig": { readOnly: true, operandsAreData: true },
52
- "dirname": { readOnly: true, operandsAreData: true },
53
- "docker images": { readOnly: true, operandsAreData: true }, // two-word: class only
54
- "docker inspect": { readOnly: true, operandsAreData: true }, // two-word: class only
55
- "docker logs": { readOnly: true, operandsAreData: true }, // two-word: class only
56
- "docker ps": { readOnly: true, operandsAreData: true }, // two-word: class only
57
- "docker stats": { readOnly: true, operandsAreData: true }, // two-word: class only
58
- "du": { readOnly: true, operandsAreData: true },
59
- "echo": { readOnly: true, operandsAreData: true },
60
- "false": { readOnly: true, operandsAreData: true },
61
- "file": { readOnly: true, operandsAreData: true },
62
- "find": { readOnly: true, operandsAreData: true },
63
- "free": { readOnly: true, operandsAreData: true },
64
- "git branch": { readOnly: true, operandsAreData: true }, // two-word: class only
65
- "git diff": { readOnly: true, operandsAreData: true }, // two-word: class only
66
- "git log": { readOnly: true, operandsAreData: true }, // two-word: class only
67
- "git remote": { readOnly: true, operandsAreData: true }, // two-word: class only
68
- "git show": { readOnly: true, operandsAreData: true }, // two-word: class only
69
- "git status": { readOnly: true, operandsAreData: true }, // two-word: class only
70
- "grep": { readOnly: true, operandsAreData: true },
71
- "head": { readOnly: true, operandsAreData: true },
72
- "host": { readOnly: true, operandsAreData: true },
73
- "hostname": { readOnly: true, operandsAreData: true },
74
- "htop": { readOnly: true, operandsAreData: true },
75
- "id": { readOnly: true, operandsAreData: true },
76
- "ifconfig": { readOnly: true, operandsAreData: true },
77
- "iostat": { readOnly: true, operandsAreData: true },
78
- "ip addr": { readOnly: true, operandsAreData: true }, // two-word: class only
79
- "ip route": { readOnly: true, operandsAreData: true }, // two-word: class only
80
- "journalctl": { readOnly: true, operandsAreData: true },
81
- "ls": { readOnly: true, operandsAreData: true },
82
- "netstat": { readOnly: true, operandsAreData: true },
83
- "nslookup": { readOnly: true, operandsAreData: true },
84
- "ping": { readOnly: true, operandsAreData: true },
85
- "printenv": { readOnly: true, operandsAreData: true },
86
- "printf": { readOnly: true, operandsAreData: true },
87
- "ps": { readOnly: true, operandsAreData: true },
88
- "pwd": { readOnly: true, operandsAreData: true },
89
- "readlink": { readOnly: true, operandsAreData: true },
90
- "realpath": { readOnly: true, operandsAreData: true },
91
- "seq": { readOnly: true, operandsAreData: true },
92
- "sort": { readOnly: true, operandsAreData: true },
93
- "ss": { readOnly: true, operandsAreData: true },
94
- "stat": { readOnly: true, operandsAreData: true },
95
- "systemctl status": { readOnly: true, operandsAreData: true }, // two-word: class only
96
- "tail": { readOnly: true, operandsAreData: true },
97
- "test": { readOnly: true, operandsAreData: true },
98
- "top": { readOnly: true, operandsAreData: true },
99
- "tr": { readOnly: true, operandsAreData: true },
100
- "traceroute": { readOnly: true, operandsAreData: true },
101
- "true": { readOnly: true, operandsAreData: true },
102
- "uname": { readOnly: true, operandsAreData: true },
103
- "uniq": { readOnly: true, operandsAreData: true },
104
- "uptime": { readOnly: true, operandsAreData: true },
105
- "vmstat": { readOnly: true, operandsAreData: true },
106
- "wc": { readOnly: true, operandsAreData: true },
107
- "whereis": { readOnly: true, operandsAreData: true },
108
- "which": { readOnly: true, operandsAreData: true },
109
- "who": { readOnly: true, operandsAreData: true },
110
- "whoami": { readOnly: true, operandsAreData: true },
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' } },
111
537
  };
112
538
  /** The class half of READERS, as the shape its consumers already expect. */
113
539
  const READ_ONLY_ALLOWLIST = new Set(Object.entries(READERS).filter(([, e]) => e.readOnly).map(([name]) => name));
@@ -396,9 +822,11 @@ const MAX_NESTING_DEPTH = 8;
396
822
  * Not a shell parser. Variable expansion, arithmetic and here-documents are out of scope —
397
823
  * `hasUnnameableCommand` is what refuses a command word this cannot resolve.
398
824
  */
399
- function tokenizeSegments(command) {
400
- return tokenizeSegmentsDetailed(command).map((segment) => segment.words);
825
+ function tokenizeSegments(command, dialect = 'posix') {
826
+ return tokenizeSegmentsDetailed(command, true, dialect).map((segment) => segment.words);
401
827
  }
828
+ /** Inside double quotes, the characters POSIX lets a backslash escape. */
829
+ const DOUBLE_QUOTE_ESCAPABLE = new Set(['$', '`', '"', '\\', '\n']);
402
830
  /**
403
831
  * The same split, keeping the separator that introduced each segment.
404
832
  *
@@ -406,7 +834,7 @@ function tokenizeSegments(command) {
406
834
  * and `&` merely sequence, and a rule about pipes must not fire on a rule about sequences.
407
835
  * Deriving both views from one tokeniser is what stops the two from drifting apart.
408
836
  */
409
- function tokenizeSegmentsDetailed(command, honorQuotes = true) {
837
+ function tokenizeSegmentsDetailed(command, honorQuotes = true, dialect = 'posix') {
410
838
  const segments = [];
411
839
  let pending = '';
412
840
  let words = [];
@@ -441,7 +869,8 @@ function tokenizeSegmentsDetailed(command, honorQuotes = true) {
441
869
  words = [];
442
870
  pending = sep;
443
871
  };
444
- for (const ch of command) {
872
+ for (let i = 0; i < command.length; i++) {
873
+ const ch = command[i];
445
874
  if (escaped) {
446
875
  // A backslash quotes the next character, so `\reboot` runs reboot. Keeping the
447
876
  // character and dropping the backslash is what the shell does.
@@ -451,8 +880,18 @@ function tokenizeSegmentsDetailed(command, honorQuotes = true) {
451
880
  }
452
881
  if (quote) {
453
882
  // Backslash is literal inside single quotes; inside double quotes it escapes.
454
- if (ch === '\\' && quote === '"') {
455
- escaped = true;
883
+ if (dialect === 'posix' && ch === '\\' && quote === '"') {
884
+ const next = command[i + 1];
885
+ // POSIX keeps `\x` before an ordinary character: only $ ` " \ and a
886
+ // newline are escapable, so the backslash survives into the word
887
+ // (GHSA-972x-g47g-3922's second finding — dropping it misread both
888
+ // dialects at once). At end of string there is no next character to
889
+ // protect, and the open quote sends the whole scan to the fallback.
890
+ if (next === undefined || DOUBLE_QUOTE_ESCAPABLE.has(next)) {
891
+ escaped = true;
892
+ continue;
893
+ }
894
+ current += ch;
456
895
  continue;
457
896
  }
458
897
  if (ch === quote)
@@ -461,7 +900,7 @@ function tokenizeSegmentsDetailed(command, honorQuotes = true) {
461
900
  current += ch;
462
901
  continue;
463
902
  }
464
- if (ch === '\\') {
903
+ if (dialect === 'posix' && ch === '\\') {
465
904
  escaped = true;
466
905
  continue;
467
906
  }
@@ -487,7 +926,7 @@ function tokenizeSegmentsDetailed(command, honorQuotes = true) {
487
926
  // `privileged` before this tokeniser and `safe` after. So fall back to the scan that
488
927
  // treats the quote character as ordinary text, which splits and still sees `sudo id`.
489
928
  if (quote !== null)
490
- return tokenizeSegmentsDetailed(command, false);
929
+ return tokenizeSegmentsDetailed(command, false, dialect);
491
930
  return segments;
492
931
  }
493
932
  /**
@@ -496,20 +935,26 @@ function tokenizeSegmentsDetailed(command, honorQuotes = true) {
496
935
  * Segments are rejoined with `; ` so a pattern cannot match across two commands that the
497
936
  * shell would run separately.
498
937
  */
499
- let lastNormalizedInput = null;
500
- let lastNormalizedOutput = '';
501
- function normalizeCommand(command) {
938
+ const normalizedCache = new Map();
939
+ function normalizeCommand(command, dialect) {
502
940
  // One evaluate asks for this about nineteen times, once per regex rule, and each ask
503
941
  // re-ran the tokeniser over the whole command. The asks arrive in a row on the same
504
- // input, so a one-entry cache removes almost all of it.
505
- if (command === lastNormalizedInput)
506
- return lastNormalizedOutput;
507
- lastNormalizedInput = command;
508
- lastNormalizedOutput = normalizeUncached(command);
509
- return lastNormalizedOutput;
942
+ // input, so a small cache, evicting the oldest inserted entry, removes almost
943
+ // all of it. The key carries the dialect because both readings of one command
944
+ // are asked about in the same row.
945
+ const key = `${dialect}\u0000${command}`;
946
+ const hit = normalizedCache.get(key);
947
+ if (hit !== undefined)
948
+ return hit;
949
+ const value = normalizeUncached(command, dialect);
950
+ normalizedCache.set(key, value);
951
+ if (normalizedCache.size > 8) {
952
+ normalizedCache.delete(normalizedCache.keys().next().value);
953
+ }
954
+ return value;
510
955
  }
511
- function normalizeUncached(command) {
512
- return tokenizeSegments(command)
956
+ function normalizeUncached(command, dialect) {
957
+ return tokenizeSegments(command, dialect)
513
958
  .map((words) => words.map((w) => (QUOTED_CONTENT.test(w) ? PLACEHOLDER : w)).join(' '))
514
959
  .join('; ');
515
960
  }
@@ -550,8 +995,8 @@ const PLACEHOLDER = '\u0000';
550
995
  * inside a quoted argument into a real one — `grep -E "warn|reboot" syslog` became an
551
996
  * unconditional refusal, which is the mention-vs-invocation bug of #91 all over again.
552
997
  */
553
- function matchesEitherForm(command, test) {
554
- return test(command) || test(normalizeCommand(command));
998
+ function matchesEitherForm(command, test, dialect) {
999
+ return test(command) || test(normalizeCommand(command, dialect));
555
1000
  }
556
1001
  /**
557
1002
  * The commands hiding inside a command.
@@ -582,7 +1027,7 @@ function matchesEitherForm(command, test) {
582
1027
  * backticks and `sh -c` are certain carriers and keep reaching the
583
1028
  * denylist either way.
584
1029
  */
585
- export function nestedCommands(command, speculativeOperands = true) {
1030
+ export function nestedCommands(command, speculativeOperands = true, dialect = 'posix') {
586
1031
  const found = [];
587
1032
  // `$(...)`, `<(...)`, `>(...)` — scanned rather than matched, because a regex
588
1033
  // cannot balance parentheses and `echo $(echo $(sudo id))` is the case that
@@ -620,7 +1065,7 @@ export function nestedCommands(command, speculativeOperands = true) {
620
1065
  // scan above does not see it. Read each segment's command word rather than scanning
621
1066
  // every word: matching a mention rather than an invocation is #91, and
622
1067
  // `cat /usr/bin/python3` names an interpreter without running one.
623
- const segments = tokenizeSegmentsDetailed(command);
1068
+ const segments = tokenizeSegmentsDetailed(command, true, dialect);
624
1069
  for (const { words } of segments) {
625
1070
  // Every word, not just the command word. Stopping at the head lost the carrier behind
626
1071
  // any wrapper this file does not list: `xargs -I {} sh -c 'sudo id'` stops on `{}`, and
@@ -851,9 +1296,16 @@ const BARE_NUMERIC = /^\d+(\.\d+)?[smhd]?$/;
851
1296
  * `\sudo`, `'sudo'` and `"sudo"` all execute sudo — the backslash only
852
1297
  * suppresses alias expansion — but a verbatim string comparison sees three
853
1298
  * different words. Without this, a one-character edit walks around the check.
1299
+ *
1300
+ * One surrounding quote pair, and nothing else: the scanner has already
1301
+ * resolved escapes by the time a word reaches this, in both the
1302
+ * quote-honouring scan and the `honorQuotes = false` fallback, so a second
1303
+ * pass here could only remove backslashes the dialect meant to keep — the
1304
+ * literal ones inside single quotes, and the POSIX-retained ones inside
1305
+ * double quotes (GHSA-972x-g47g-3922).
854
1306
  */
855
1307
  function unquote(word) {
856
- return word.replace(/^(['"])(.*)\1$/, '$2').replace(/\\(.)/g, '$1');
1308
+ return word.replace(/^(['"])(.*)\1$/, '$2');
857
1309
  }
858
1310
  /**
859
1311
  * Privilege-prefix flags that consume the next argument, so `sudo -u root
@@ -865,14 +1317,14 @@ const PREFIX_VALUE_FLAGS = new Set([
865
1317
  '--user', '--group', '--prompt', '--close-from', '--host', '--role', '--type',
866
1318
  '--other-user', '--command',
867
1319
  ]);
868
- /** `/sbin/reboot` and `reboot` are the same invocation. */
1320
+ /** `/sbin/reboot`, `C:\Windows\reboot.exe` and `reboot` are the same invocation. */
869
1321
  function stripPath(word) {
870
- const slash = word.lastIndexOf('/');
871
- return slash === -1 ? word : word.slice(slash + 1);
1322
+ const cut = Math.max(word.lastIndexOf('/'), word.lastIndexOf('\\'));
1323
+ return cut === -1 ? word : word.slice(cut + 1);
872
1324
  }
873
- function parseSegments(command) {
1325
+ function parseSegments(command, dialect = 'posix') {
874
1326
  const segments = [];
875
- for (const words of tokenizeSegments(command)) {
1327
+ for (const words of tokenizeSegments(command, dialect)) {
876
1328
  const segment = parseWords(words);
877
1329
  if (segment !== null)
878
1330
  segments.push(segment);
@@ -957,8 +1409,8 @@ function elevatedBinary(words) {
957
1409
  * holding, and `echo hi; sudo id` recorded `binary: "echo"` against a
958
1410
  * privileged decision — in the audit log, the OTel span and OPA's input (#134).
959
1411
  */
960
- function elevatedBinaryOf(command) {
961
- for (const words of tokenizeSegments(command)) {
1412
+ function elevatedBinaryOf(command, dialect = 'posix') {
1413
+ for (const words of tokenizeSegments(command, dialect)) {
962
1414
  const found = elevatedBinary(words);
963
1415
  if (found !== null)
964
1416
  return found;
@@ -985,8 +1437,8 @@ function elevatedBinaryOf(command) {
985
1437
  * `invokedWords` also reads it, and widening what counts as "the command"
986
1438
  * there is a different, larger change this fix does not take on.
987
1439
  */
988
- function hasDisqualifyingArgs(command) {
989
- return tokenizeSegments(command).some((words) => {
1440
+ function hasDisqualifyingArgs(command, dialect = 'posix') {
1441
+ return tokenizeSegments(command, dialect).some((words) => {
990
1442
  const idx = effectiveCommandIndex(words);
991
1443
  if (idx === -1)
992
1444
  return false;
@@ -994,9 +1446,9 @@ function hasDisqualifyingArgs(command) {
994
1446
  return rule !== undefined && words.slice(idx + 1).some((arg) => rule.test(arg));
995
1447
  });
996
1448
  }
997
- function invokedWords(command) {
1449
+ function invokedWords(command, dialect = 'posix') {
998
1450
  const invoked = [];
999
- for (const { head, args } of parseSegments(command)) {
1451
+ for (const { head, args } of parseSegments(command, dialect)) {
1000
1452
  invoked.push(head);
1001
1453
  // `systemctl reboot` restarts the host. Reading a unit that happens to be
1002
1454
  // named after a power action is rare enough that erring towards refusal
@@ -1018,11 +1470,11 @@ const DOWNLOADERS = new Set(['curl', 'wget']);
1018
1470
  * runs a shell when the download fails, which is not meaningfully safer than
1019
1471
  * running one when it succeeds.
1020
1472
  */
1021
- function pipesDownloadIntoShell(command) {
1473
+ function pipesDownloadIntoShell(command, dialect = 'posix') {
1022
1474
  // One head per pipe stage. Splitting on every separator would make `curl -O x;
1023
1475
  // bash build.sh` — download, then run a local script — match a rule whose label says
1024
1476
  // "piping a download into a shell", on a list that cannot be switched off.
1025
- const segments = tokenizeSegmentsDetailed(command);
1477
+ const segments = tokenizeSegmentsDetailed(command, true, dialect);
1026
1478
  const heads = segments
1027
1479
  .filter((segment, i) => i === 0 || segment.sep === '|')
1028
1480
  .map((segment) => parseWords(segment.words)?.head);
@@ -1032,8 +1484,8 @@ function pipesDownloadIntoShell(command) {
1032
1484
  return heads.slice(firstDownload + 1).some((h) => h !== undefined && SHELLS.has(h));
1033
1485
  }
1034
1486
  /** `dd … of=/dev/sda` — writing an image straight onto a block device. */
1035
- function writesToDevice(command) {
1036
- return parseSegments(command).some(({ head, args }) => head === 'dd' && args.some((a) => a.startsWith('of=/dev/')));
1487
+ function writesToDevice(command, dialect = 'posix') {
1488
+ return parseSegments(command, dialect).some(({ head, args }) => head === 'dd' && args.some((a) => a.startsWith('of=/dev/')));
1037
1489
  }
1038
1490
  /**
1039
1491
  * `chown -R … /` — a recursive chown whose target is the filesystem root.
@@ -1041,8 +1493,8 @@ function writesToDevice(command) {
1041
1493
  * The last non-flag argument is the target; `chown -R app:app /srv/app` is
1042
1494
  * ordinary and stays allowed.
1043
1495
  */
1044
- function chownsRoot(command) {
1045
- return parseSegments(command).some(({ head, args }) => {
1496
+ function chownsRoot(command, dialect = 'posix') {
1497
+ return parseSegments(command, dialect).some(({ head, args }) => {
1046
1498
  if (head !== 'chown' || !args.includes('-R'))
1047
1499
  return false;
1048
1500
  const positional = args.filter((a) => !a.startsWith('-'));
@@ -1052,18 +1504,18 @@ function chownsRoot(command) {
1052
1504
  const FORBIDDEN_RULES = [
1053
1505
  ...FORBIDDEN_PATTERNS.map((re) => ({
1054
1506
  label: String(re),
1055
- test: (c) => matchesEitherForm(c, (form) => re.test(form)),
1507
+ test: (c, dialect) => matchesEitherForm(c, (form) => re.test(form), dialect),
1056
1508
  })),
1057
1509
  {
1058
1510
  label: 'invoking a power-state command (shutdown, reboot, halt, poweroff) or eval',
1059
- test: (command) => invokedWords(command).some((w) => FORBIDDEN_INVOCATIONS.has(w)),
1511
+ test: (command, dialect) => invokedWords(command, dialect).some((w) => FORBIDDEN_INVOCATIONS.has(w)),
1060
1512
  },
1061
1513
  {
1062
1514
  label: 'piping a download into a shell (curl or wget into sh, bash or zsh)',
1063
- test: pipesDownloadIntoShell,
1515
+ test: (c, dialect) => pipesDownloadIntoShell(c, dialect),
1064
1516
  },
1065
- { label: 'dd writing to a block device (of=/dev/…)', test: writesToDevice },
1066
- { label: 'a recursive chown of the filesystem root', test: chownsRoot },
1517
+ { label: 'dd writing to a block device (of=/dev/…)', test: (c, dialect) => writesToDevice(c, dialect) },
1518
+ { label: 'a recursive chown of the filesystem root', test: (c, dialect) => chownsRoot(c, dialect) },
1067
1519
  ];
1068
1520
  /**
1069
1521
  * Which never-allowed rule this command trips, or null. The single entry point:
@@ -1072,8 +1524,21 @@ const FORBIDDEN_RULES = [
1072
1524
  * permit `sudo reboot`.
1073
1525
  */
1074
1526
  export function findForbiddenMatch(command, depth = 0) {
1527
+ // The engine calls this directly, so it is a decision point of its own and
1528
+ // reads both dialects like `classifyCommand` does: a refusal under either
1529
+ // reading is a refusal. The destructive-class consultation *inside* a
1530
+ // dialect pass (`isDestructive`) stays in-dialect — that pass is already one
1531
+ // reading of the whole string.
1532
+ if (command.includes('\\')) {
1533
+ const windows = findForbiddenMatchInDialect(command, depth, 'windows');
1534
+ if (windows !== null)
1535
+ return windows;
1536
+ }
1537
+ return findForbiddenMatchInDialect(command, depth, 'posix');
1538
+ }
1539
+ function findForbiddenMatchInDialect(command, depth, dialect) {
1075
1540
  for (const rule of FORBIDDEN_RULES) {
1076
- if (rule.test(command))
1541
+ if (rule.test(command, dialect))
1077
1542
  return rule.label;
1078
1543
  }
1079
1544
  // The same carriers the class scan reads, for the same reason. This list is the
@@ -1097,15 +1562,15 @@ export function findForbiddenMatch(command, depth = 0) {
1097
1562
  // `nestedCommands` regardless of this flag.
1098
1563
  if (depth >= MAX_NESTING_DEPTH)
1099
1564
  return null;
1100
- for (const inner of nestedCommands(command, false)) {
1101
- const match = findForbiddenMatch(inner, depth + 1);
1565
+ for (const inner of nestedCommands(command, false, dialect)) {
1566
+ const match = findForbiddenMatchInDialect(inner, depth + 1, dialect);
1102
1567
  if (match !== null)
1103
1568
  return match;
1104
1569
  }
1105
1570
  return null;
1106
1571
  }
1107
- export function isForbidden(command) {
1108
- return findForbiddenMatch(command) !== null;
1572
+ export function isForbidden(command, dialect = 'posix') {
1573
+ return findForbiddenMatchInDialect(command, 0, dialect) !== null;
1109
1574
  }
1110
1575
  /**
1111
1576
  * Commands that are destructive but legitimate under approval — e.g.
@@ -1122,9 +1587,9 @@ const DESTRUCTIVE_PATTERNS = [
1122
1587
  * reading a log that mentions `reboot` is no longer classified destructive
1123
1588
  * either.
1124
1589
  */
1125
- function isDestructive(command) {
1126
- return (isForbidden(command) ||
1127
- matchesEitherForm(command, (form) => DESTRUCTIVE_PATTERNS.some((re) => re.test(form))));
1590
+ function isDestructive(command, dialect = 'posix') {
1591
+ return (isForbidden(command, dialect) ||
1592
+ matchesEitherForm(command, (form) => DESTRUCTIVE_PATTERNS.some((re) => re.test(form)), dialect));
1128
1593
  }
1129
1594
  const LEADING_PRIVILEGE_PREFIXES = [
1130
1595
  /^\s*sudo\b/,
@@ -1132,13 +1597,13 @@ const LEADING_PRIVILEGE_PREFIXES = [
1132
1597
  /^\s*doas\b/,
1133
1598
  /^\s*pkexec\b/,
1134
1599
  ];
1135
- export function extractBinary(command) {
1600
+ export function extractBinary(command, dialect = 'posix') {
1136
1601
  // The first segment's words, so quoting is resolved — `s"u"do id` used to report
1137
1602
  // `s"u"do` in the audit record and the refusal message. Reading the normalised whole
1138
1603
  // command instead would be wrong in the other direction: it rejoins segments with
1139
1604
  // `; `, so `ls | grep x` would report `ls;`, and this name reaches the audit record,
1140
1605
  // the OTel span and OPA's input (#134). A separator is not a binary.
1141
- let cmd = (tokenizeSegments(command)[0] ?? []).join(' ').trim();
1606
+ let cmd = (tokenizeSegments(command, dialect)[0] ?? []).join(' ').trim();
1142
1607
  for (const prefix of LEADING_PRIVILEGE_PREFIXES) {
1143
1608
  cmd = cmd.replace(prefix, '').trim();
1144
1609
  }
@@ -1484,8 +1949,8 @@ function awkFindings(words) {
1484
1949
  * `awk 'BEGIN{system("sudo id")}'` comes out `privileged` rather than flattened
1485
1950
  * to the `destructive` this function reports.
1486
1951
  */
1487
- function hasDangerousAwk(command) {
1488
- for (const { words } of tokenizeSegmentsDetailed(command)) {
1952
+ function hasDangerousAwk(command, dialect = 'posix') {
1953
+ for (const { words } of tokenizeSegmentsDetailed(command, true, dialect)) {
1489
1954
  const findings = awkFindings(words);
1490
1955
  if (findings === null)
1491
1956
  continue;
@@ -1497,15 +1962,15 @@ function hasDangerousAwk(command) {
1497
1962
  // prints into it, and that is assembled at run time. So it is the same
1498
1963
  // "we cannot tell" this module reports for `-f progfile`.
1499
1964
  for (const target of findings.pipedInto) {
1500
- const stage = tokenizeSegmentsDetailed(target)[0];
1965
+ const stage = tokenizeSegmentsDetailed(target, true, dialect)[0];
1501
1966
  if (stage !== undefined && readsProgramFromStdin(stage.words))
1502
1967
  return true;
1503
1968
  }
1504
1969
  }
1505
1970
  return false;
1506
1971
  }
1507
- function hasUnreadableProgram(command) {
1508
- const segments = tokenizeSegmentsDetailed(command);
1972
+ function hasUnreadableProgram(command, dialect = 'posix') {
1973
+ const segments = tokenizeSegmentsDetailed(command, true, dialect);
1509
1974
  for (let i = 0; i < segments.length; i++) {
1510
1975
  const { words, sep } = segments[i];
1511
1976
  if (sep === '|' && readsProgramFromStdin(words))
@@ -1545,8 +2010,8 @@ function hasUnreadableProgram(command) {
1545
2010
  * Only the command word, never the arguments. `echo $HOME` names a command we know;
1546
2011
  * promoting that would put a prompt on most ordinary shell usage.
1547
2012
  */
1548
- function hasUnnameableCommand(command) {
1549
- for (const words of tokenizeSegments(command)) {
2013
+ function hasUnnameableCommand(command, dialect = 'posix') {
2014
+ for (const words of tokenizeSegments(command, dialect)) {
1550
2015
  const head = effectiveCommandWord(words);
1551
2016
  if (head !== null && /[$`]/.test(head))
1552
2017
  return true;
@@ -1592,8 +2057,8 @@ const SYNTHETIC_CLASSES = Object.assign(Object.create(null), {
1592
2057
  'session:open': 'destructive',
1593
2058
  });
1594
2059
  /** The first word, tokenised — the synthetic verb when there is one. */
1595
- function syntheticVerb(command) {
1596
- return tokenizeSegments(command)[0]?.[0] ?? '';
2060
+ function syntheticVerb(command, dialect = 'posix') {
2061
+ return tokenizeSegments(command, dialect)[0]?.[0] ?? '';
1597
2062
  }
1598
2063
  /**
1599
2064
  * The class of a command, and of everything it carries.
@@ -1607,8 +2072,20 @@ function syntheticVerb(command) {
1607
2072
  * Taking the maximum is what makes the scan unable to lower anything.
1608
2073
  */
1609
2074
  export function classifyCommand(command, depth = 0) {
2075
+ // One string, two dialects, and the class is the worse of the two readings:
2076
+ // the classifier cannot know whether the host shell reads a backslash as an
2077
+ // escape or as a path separator, so policy must hold on whichever host
2078
+ // receives the command (GHSA-972x-g47g-3922). A command without a backslash
2079
+ // is byte-identical under both dialects, so it keeps the single pass.
2080
+ if (!command.includes('\\'))
2081
+ return classifyCommandInDialect(command, depth, 'posix');
2082
+ const posix = classifyCommandInDialect(command, depth, 'posix');
2083
+ const windows = classifyCommandInDialect(command, depth, 'windows');
2084
+ return CLASS_RANK[windows.class] > CLASS_RANK[posix.class] ? windows : posix;
2085
+ }
2086
+ function classifyCommandInDialect(command, depth, dialect) {
1610
2087
  const trimmed = command.trim();
1611
- const outer = classifyOuter(trimmed);
2088
+ const outer = classifyOuter(trimmed, dialect);
1612
2089
  if (depth >= MAX_NESTING_DEPTH) {
1613
2090
  // Nesting this deep is not something an operator writes, and we have stopped
1614
2091
  // reading. Refusing to guess is the only answer consistent with the rest of this
@@ -1619,34 +2096,46 @@ export function classifyCommand(command, depth = 0) {
1619
2096
  // A floor, not a verdict. Returning the synthetic class outright would put it above
1620
2097
  // the elevation and never-allowed checks, so `sftp:upload /tmp/x; sudo id` would
1621
2098
  // record `destructive` where the command is `privileged`.
1622
- const verb = syntheticVerb(trimmed);
2099
+ const verb = syntheticVerb(trimmed, dialect);
1623
2100
  const floor = SYNTHETIC_CLASSES[verb];
1624
2101
  if (floor !== undefined && CLASS_RANK[floor] > CLASS_RANK[highest.class]) {
1625
2102
  highest = { binary: verb, fullCommand: trimmed, class: floor };
1626
2103
  }
1627
- for (const inner of nestedCommands(trimmed)) {
1628
- const parsed = classifyCommand(inner, depth + 1);
2104
+ for (const inner of nestedCommands(trimmed, true, dialect)) {
2105
+ const parsed = classifyCommandInDialect(inner, depth + 1, dialect);
1629
2106
  // `binary` follows the winning side deliberately: it is what the audit record and
1630
2107
  // the refusal message name, and naming the outer `echo` would describe the wrong
1631
2108
  // process as the one that ran as root.
1632
2109
  if (CLASS_RANK[parsed.class] > CLASS_RANK[highest.class])
1633
2110
  highest = parsed;
1634
2111
  }
1635
- return { binary: highest.binary, fullCommand: trimmed, class: highest.class };
2112
+ // `readOnlyRejection` only ever describes the outer command's own grammar
2113
+ // rejection, and only means anything when that is also what decided the
2114
+ // final class. `highest === outer` (reference equality) is exactly that
2115
+ // condition: the synthetic floor and the nested-command loop above only
2116
+ // ever replace `highest` with something of strictly higher rank than
2117
+ // `outer`'s, so if either one fired, `highest` is no longer `outer` and the
2118
+ // rejection — which was never a fact about the synthetic verb or the nested
2119
+ // command anyway — is correctly left off.
2120
+ const result = { binary: highest.binary, fullCommand: trimmed, class: highest.class };
2121
+ if (highest === outer && outer.readOnlyRejection) {
2122
+ result.readOnlyRejection = outer.readOnlyRejection;
2123
+ }
2124
+ return result;
1636
2125
  }
1637
2126
  /** The class of the command itself, reading none of what it carries. */
1638
- function classifyOuter(trimmed) {
1639
- const binary = extractBinary(trimmed);
2127
+ function classifyOuter(trimmed, dialect) {
2128
+ const binary = extractBinary(trimmed, dialect);
1640
2129
  const fullCommand = trimmed;
1641
2130
  // `binary` names the subject of the class. For everything below it is the
1642
2131
  // leading command; here it is the one that runs as root, which are the same
1643
2132
  // thing only when the prefix leads.
1644
- const elevated = elevatedBinaryOf(trimmed);
2133
+ const elevated = elevatedBinaryOf(trimmed, dialect);
1645
2134
  if (elevated !== null) {
1646
2135
  return { binary: elevated, fullCommand, class: 'privileged' };
1647
2136
  }
1648
- if (hasUnreadableProgram(trimmed) || hasDangerousAwk(trimmed)
1649
- || isDestructive(trimmed) || hasDisqualifyingArgs(trimmed)) {
2137
+ if (hasUnreadableProgram(trimmed, dialect) || hasDangerousAwk(trimmed, dialect)
2138
+ || isDestructive(trimmed, dialect) || hasDisqualifyingArgs(trimmed, dialect)) {
1650
2139
  return { binary, fullCommand, class: 'destructive' };
1651
2140
  }
1652
2141
  // Below this point every branch assumes the command word names something we
@@ -1657,17 +2146,77 @@ function classifyOuter(trimmed) {
1657
2146
  // `destructive` rather than `privileged`: this is "we cannot tell", not "this is
1658
2147
  // root". It gates on approval instead of refusing outright, which keeps
1659
2148
  // `$PREFIX/bin/tool` usable for a role that holds `destructive` on the tier.
1660
- if (hasUnnameableCommand(trimmed)) {
2149
+ if (hasUnnameableCommand(trimmed, dialect)) {
1661
2150
  return { binary, fullCommand, class: 'destructive' };
1662
2151
  }
1663
- const twoWordPrefix = (tokenizeSegments(fullCommand)[0] ?? []).slice(0, 2).join(' ');
2152
+ const words = tokenizeSegments(fullCommand, dialect)[0] ?? [];
2153
+ const twoWordPrefix = words.slice(0, 2).join(' ');
1664
2154
  if (READ_ONLY_ALLOWLIST.has(binary) || READ_ONLY_ALLOWLIST.has(twoWordPrefix)
1665
2155
  || READ_ONLY_SYNTHETIC.has(binary)) {
1666
2156
  if (SHELL_CONTROL_CHARS.test(trimmed)) {
1667
2157
  return { binary, fullCommand, class: 'safe' };
1668
2158
  }
2159
+ // The SFTP verbs are synthesised by the tool layer, never typed by a caller,
2160
+ // so there is no argv for a grammar to read — READ_ONLY_SYNTHETIC stays
2161
+ // exactly as it was.
2162
+ if (READ_ONLY_SYNTHETIC.has(binary)) {
2163
+ return { binary, fullCommand, class: 'read-only' };
2164
+ }
2165
+ // Whichever key matched decides both which entry answers and how many
2166
+ // leading words are the command name rather than an argument: a two-word
2167
+ // entry's grammar reads words[2..], a one-word entry's reads words[1..].
2168
+ const matchedTwoWord = READ_ONLY_ALLOWLIST.has(twoWordPrefix);
2169
+ const readerName = matchedTwoWord ? twoWordPrefix : binary;
2170
+ const entry = READERS[readerName];
2171
+ const argWords = words.slice(matchedTwoWord ? 2 : 1);
2172
+ // An unquoted glob word is not a word this grammar can vouch for: the
2173
+ // shell replaces it with however many filenames it matches, and the
2174
+ // grammar's operand count — the thing it was about to prove — is false
2175
+ // before the binary runs. `'any'` entries are exempt: their claim is that
2176
+ // no option or operand of the binary writes or executes, so extra
2177
+ // operands from an expansion are still data.
2178
+ if (entry.grammar.args !== 'any') {
2179
+ const globWord = firstUnquotedGlobWord(fullCommand, true, dialect);
2180
+ if (globWord !== null) {
2181
+ return {
2182
+ binary,
2183
+ fullCommand,
2184
+ class: 'safe',
2185
+ readOnlyRejection: { binary: readerName, word: globWord },
2186
+ };
2187
+ }
2188
+ }
2189
+ const match = matchesGrammar(argWords, entry.grammar);
2190
+ if (!match.ok) {
2191
+ return {
2192
+ binary,
2193
+ fullCommand,
2194
+ class: 'safe',
2195
+ readOnlyRejection: { binary: readerName, word: match.word },
2196
+ };
2197
+ }
1669
2198
  return { binary, fullCommand, class: 'read-only' };
1670
2199
  }
1671
2200
  return { binary, fullCommand, class: 'safe' };
1672
2201
  }
1673
- export { READ_ONLY_ALLOWLIST, READERS, READ_ONLY_SYNTHETIC, isDestructive };
2202
+ /**
2203
+ * The sentence a refusal appends when it carries a `readOnlyRejection`, worded
2204
+ * so the reason names the binary that would have qualified and the argv word
2205
+ * that cost it: "is not accepted there" rather than "is not one of them",
2206
+ * because most rejections are operand rules (`hostname NAME`, `sort /data`) or
2207
+ * a listed value flag missing its value, where "is not one of them" would be
2208
+ * false — it may be a perfectly good flag name, just not one bare or in this
2209
+ * position. One function so the engine's role-binding denial and
2210
+ * `read-command`'s `enforceClass` refusal never drift into two spellings of
2211
+ * the same sentence.
2212
+ */
2213
+ function formatReadOnlyRejection(rejection) {
2214
+ // The rejected word can be the empty string — a quoted empty operand
2215
+ // (`hostname ""`) — and two bare backticks would render as a blank where
2216
+ // the word that cost the class should be. `""` is the shell's own spelling
2217
+ // of that operand.
2218
+ const word = rejection.word === '' ? '""' : rejection.word;
2219
+ return `\`${rejection.binary}\` is read-only only with the options and operands its grammar lists; ` +
2220
+ `\`${word}\` is not accepted there.`;
2221
+ }
2222
+ export { READ_ONLY_ALLOWLIST, READERS, READ_ONLY_SYNTHETIC, isDestructive, formatReadOnlyRejection, };