ssh-mcp 2.12.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.
package/README.md CHANGED
@@ -526,11 +526,28 @@ never widen. Widening happens here or not at all.
526
526
 
527
527
  Every command is classified before execution:
528
528
 
529
- - **read-only**: Allowlisted commands (`ls`, `cat`, `grep`, `df`, `stat`, `systemctl status`, ...)
529
+ - **read-only**: Allowlisted commands (`ls`, `cat`, `grep`, `df`, `stat`, `systemctl status`, ...),
530
+ and only when every argument is provably data under a grammar declared for that binary — see
531
+ below
530
532
  - **safe**: Non-destructive mutations (`npm install`, `git pull`, ...)
531
533
  - **destructive**: mutations that need approval (`rm -rf /tmp/build`, ...)
532
534
  - **privileged**: `sudo`, `su`, `doas`, `pkexec`
533
535
 
536
+ **`read-only` is granted by argument grammar, not by binary name alone.** An allowlisted
537
+ binary's argument list is checked word by word against a grammar declared for it
538
+ (`src/policy/reader-grammar.ts`); an option the grammar does not list, an abbreviation of one
539
+ it does, a short-option cluster with an unlisted letter, an argument carrying an unquoted
540
+ shell glob (`*`, `?`, `[`), or an operand shaped like something
541
+ the binary would write to, all fall the whole command to `safe` instead. This is what a
542
+ `readOnly` profile is confined to, what `viewer` holds on prod and staging (on dev the viewer
543
+ holds `safe` too), and what `read-command`
544
+ requires — all of these refuse `safe` except the viewer on dev, so a command that used to
545
+ run under the old, name-only
546
+ rule can now be refused. The refusal names the rejected word (for example, `` `journalctl` is
547
+ read-only only with the options and operands its grammar lists; `--foo` is not accepted
548
+ there. ``), so the caller can see which spelling of the same command still works. See
549
+ [SECURITY.md](./SECURITY.md) for the residual risks this leaves open.
550
+
534
551
  A separate **forbidden** list is never allowed, whatever the role or approval
535
552
  policy: `rm -rf /`, `mkfs`, `dd of=/dev/`, `shutdown`, `curl|sh`, fork bombs,
536
553
  writes to `/etc/cron`, `/etc/systemd` or `authorized_keys`, `iptables -F`, and
@@ -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) {
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
+ }
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));
@@ -1632,7 +2058,19 @@ export function classifyCommand(command, depth = 0) {
1632
2058
  if (CLASS_RANK[parsed.class] > CLASS_RANK[highest.class])
1633
2059
  highest = parsed;
1634
2060
  }
1635
- 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;
1636
2074
  }
1637
2075
  /** The class of the command itself, reading none of what it carries. */
1638
2076
  function classifyOuter(trimmed) {
@@ -1660,14 +2098,74 @@ function classifyOuter(trimmed) {
1660
2098
  if (hasUnnameableCommand(trimmed)) {
1661
2099
  return { binary, fullCommand, class: 'destructive' };
1662
2100
  }
1663
- const twoWordPrefix = (tokenizeSegments(fullCommand)[0] ?? []).slice(0, 2).join(' ');
2101
+ const words = tokenizeSegments(fullCommand)[0] ?? [];
2102
+ const twoWordPrefix = words.slice(0, 2).join(' ');
1664
2103
  if (READ_ONLY_ALLOWLIST.has(binary) || READ_ONLY_ALLOWLIST.has(twoWordPrefix)
1665
2104
  || READ_ONLY_SYNTHETIC.has(binary)) {
1666
2105
  if (SHELL_CONTROL_CHARS.test(trimmed)) {
1667
2106
  return { binary, fullCommand, class: 'safe' };
1668
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
+ }
1669
2147
  return { binary, fullCommand, class: 'read-only' };
1670
2148
  }
1671
2149
  return { binary, fullCommand, class: 'safe' };
1672
2150
  }
1673
- export { READ_ONLY_ALLOWLIST, READERS, 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, };
@@ -1,4 +1,4 @@
1
- import { classifyCommand, findForbiddenMatch } from './classifier.js';
1
+ import { classifyCommand, findForbiddenMatch, formatReadOnlyRejection } from './classifier.js';
2
2
  import { OperatorError } from '../errors.js';
3
3
  /**
4
4
  * How long to wait for the OPA sidecar before treating it as unavailable.
@@ -230,6 +230,11 @@ export class PolicyEngine {
230
230
  const parsed = classifyCommand(command);
231
231
  const allowedClasses = this.getAllowedClasses(profile);
232
232
  const classAllowed = allowedClasses.includes(parsed.class);
233
+ // Copied onto every branch below rather than just the role-binding one:
234
+ // `read-command`'s `enforceClass` check reads it off an `allow` decision,
235
+ // since a grammar-rejected reader is allowed as `safe` for most roles and
236
+ // only refused one layer up, in the tool itself.
237
+ const readOnlyRejection = parsed.readOnlyRejection;
233
238
  const denied = this.findDenyMatch(command);
234
239
  if (denied) {
235
240
  return {
@@ -238,6 +243,7 @@ export class PolicyEngine {
238
243
  binary: parsed.binary,
239
244
  ruleId: 'denylist',
240
245
  reason: denied,
246
+ readOnlyRejection,
241
247
  };
242
248
  }
243
249
  const needsApproval = parsed.class === 'destructive' || parsed.class === 'privileged';
@@ -248,7 +254,8 @@ export class PolicyEngine {
248
254
  commandClass: parsed.class,
249
255
  binary: parsed.binary,
250
256
  ruleId: 'role-binding',
251
- reason: this.explainRoleDenial(profile, parsed.class),
257
+ reason: this.explainRoleDenial(profile, parsed),
258
+ readOnlyRejection,
252
259
  };
253
260
  }
254
261
  if (approvalRequired) {
@@ -261,6 +268,7 @@ export class PolicyEngine {
261
268
  binary: parsed.binary,
262
269
  ruleId: 'approval-policy',
263
270
  reason: `Profile "${profile.name}" denies ${parsed.class} commands (approvalPolicy = "deny")`,
271
+ readOnlyRejection,
264
272
  };
265
273
  }
266
274
  return {
@@ -269,6 +277,7 @@ export class PolicyEngine {
269
277
  binary: parsed.binary,
270
278
  ruleId: 'approval-policy',
271
279
  reason: `Profile "${profile.name}" requires approval for ${parsed.class} commands`,
280
+ readOnlyRejection,
272
281
  };
273
282
  }
274
283
  return {
@@ -276,6 +285,7 @@ export class PolicyEngine {
276
285
  commandClass: parsed.class,
277
286
  binary: parsed.binary,
278
287
  ruleId: 'default',
288
+ readOnlyRejection,
279
289
  };
280
290
  }
281
291
  async evaluateWithOpa(command, profile, toolName) {
@@ -393,28 +403,40 @@ export class PolicyEngine {
393
403
  * guessed from its name, so the reason is something the operator never wrote
394
404
  * down anywhere and cannot see.
395
405
  */
396
- explainRoleDenial(profile, commandClass) {
406
+ explainRoleDenial(profile, parsed) {
407
+ const commandClass = parsed.class;
408
+ let reason;
397
409
  if (profile.readOnly) {
398
- return `Profile "${profile.name}" is read-only, so "${commandClass}" commands are refused. ` +
410
+ reason = `Profile "${profile.name}" is read-only, so "${commandClass}" commands are refused. ` +
399
411
  `Clear readOnly on the profile to allow them.`;
400
412
  }
401
- const group = resolveProfileGroup(profile);
402
- const inferred = !profile.group;
403
- const allowed = this.getAllowedClasses(profile).join(', ');
404
- let reason = `Role "${profile.role}" on host group "${group}" cannot run "${commandClass}" commands ` +
405
- `(allowed: ${allowed}).`;
406
- if (inferred) {
407
- // Not "defaulted to the most restrictive tier": the name is matched
408
- // against prod/staging/dev first, so a profile called "staging-web" lands
409
- // on staging and the old wording described a fallback that did not
410
- // happen. What the operator needs to know is that nothing they wrote
411
- // chose this tier.
412
- reason +=
413
- ` No group is set for profile "${profile.name}", so the tier was inferred from its name as "${group}".` +
414
- ` Set group explicitly on the profile, or pass --group, if that is not where this host belongs.`;
415
- }
416
413
  else {
417
- reason += ` Change the profile's group, or grant the class to this role in the policy's roleBindings.`;
414
+ const group = resolveProfileGroup(profile);
415
+ const inferred = !profile.group;
416
+ const allowed = this.getAllowedClasses(profile).join(', ');
417
+ reason =
418
+ `Role "${profile.role}" on host group "${group}" cannot run "${commandClass}" commands ` +
419
+ `(allowed: ${allowed}).`;
420
+ if (inferred) {
421
+ // Not "defaulted to the most restrictive tier": the name is matched
422
+ // against prod/staging/dev first, so a profile called "staging-web" lands
423
+ // on staging and the old wording described a fallback that did not
424
+ // happen. What the operator needs to know is that nothing they wrote
425
+ // chose this tier.
426
+ reason +=
427
+ ` No group is set for profile "${profile.name}", so the tier was inferred from its name as "${group}".` +
428
+ ` Set group explicitly on the profile, or pass --group, if that is not where this host belongs.`;
429
+ }
430
+ else {
431
+ reason += ` Change the profile's group, or grant the class to this role in the policy's roleBindings.`;
432
+ }
433
+ }
434
+ // A `safe` command refused here is often not a hard "no" — it is one
435
+ // argv word away from `read-only`, and without this the reader concludes
436
+ // the binary is forbidden outright and never learns which spelling would
437
+ // have worked.
438
+ if (parsed.readOnlyRejection) {
439
+ reason += ` ${formatReadOnlyRejection(parsed.readOnlyRejection)}`;
418
440
  }
419
441
  return reason;
420
442
  }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Deciding whether a reader's arguments are provably data, not more commands.
3
+ *
4
+ * `READERS` in the classifier grants `read-only` to any command whose first
5
+ * word is allowlisted, without looking at the words after it. That is safe
6
+ * only because a later stage rejects shell metacharacters, but `cat -- "$x"`
7
+ * and `cat $(id)` both clear that gate today; only the outer shell-control
8
+ * scan (or the caller's own audit) stands between an allowlisted reader and
9
+ * an argument that isn't data. This module is the piece that closes that gap
10
+ * for readers whose flags are simple enough to describe with a grammar: given
11
+ * the argv words that follow the command name and a description of what the
12
+ * binary accepts, say whether every word is an option this grammar
13
+ * recognises, a value an option consumes, or an operand that satisfies the
14
+ * `operands` shape. Nothing here decides *class* — that stays the caller's
15
+ * job, together with the correctness conditions in the design doc (an
16
+ * optional-argument flag never goes in `valueFlags`; an option is listed only
17
+ * if every implementation the command name can resolve to reads it the same
18
+ * way).
19
+ *
20
+ * Pure and side-effect free: no filesystem, no `classifier.ts` import. The
21
+ * table that wires this into a command's class is a later task.
22
+ */
23
+ /**
24
+ * Does every word in `words` come from what `grammar` describes?
25
+ *
26
+ * The scan is word-by-word, left to right, mirroring how getopt (or, for
27
+ * `exact`, the binary's own hand-rolled parser) actually reads argv: a lone
28
+ * `-` is always an operand (stdin), and everything left over after options
29
+ * are stripped is checked against `operands`. `--` ends option parsing for
30
+ * every later word under `getopt` only — an `exact` parser keeps evaluating
31
+ * its option words after `--` (measured: GNU `find -- d -fprint /tmp/x`
32
+ * wrote the file), so there `--` is refused as an unrecognised word rather
33
+ * than treated as end-of-checks.
34
+ */
35
+ export function matchesGrammar(words, grammar) {
36
+ if (grammar.args === 'any') {
37
+ return { ok: true };
38
+ }
39
+ const flags = new Set(grammar.flags ?? []);
40
+ const valueFlags = new Set(grammar.valueFlags ?? []);
41
+ const optionalValueFlags = new Set(grammar.optionalValueFlags ?? []);
42
+ const operands = [];
43
+ let optionsEnded = false;
44
+ for (let i = 0; i < words.length; i++) {
45
+ const word = words[i];
46
+ // A lone '-' is stdin, not an option — and once '--' has been seen,
47
+ // nothing looks like an option any more.
48
+ const looksLikeOption = !optionsEnded && word.length > 1 && word.startsWith('-');
49
+ // POSIX option order, opt-in per entry — and ahead of the getopt `--`
50
+ // branch below on purpose: `--` is itself an option-looking word, and an
51
+ // implementation that does not permute (macOS uniq's `+`-prefixed
52
+ // optstring, BSD ifconfig) has already stopped option scanning at the
53
+ // first operand, so it reads `--` as an operand — uniq's is the OUTFILE
54
+ // it overwrites (a file literally named `--`), ifconfig's is an interface
55
+ // configuration word. A permuting implementation (GNU) would treat the
56
+ // same word as the terminator it spells, so the word cannot be judged one
57
+ // way for both: `optionsBeforeOperands` refuses it, and only entries
58
+ // where some implementation does not permute set the flag.
59
+ if (grammar.optionsBeforeOperands && operands.length > 0 && looksLikeOption) {
60
+ return { ok: false, word };
61
+ }
62
+ // getopt only. An `exact` parser never hands `--` this meaning: find
63
+ // keeps reading primaries after it, so accepting `--` there would end
64
+ // the checks at exactly the word an attacker can hide behind.
65
+ if (grammar.args === 'getopt' && !optionsEnded && word === '--') {
66
+ optionsEnded = true; // every later word is an operand, `--` itself is not one
67
+ continue;
68
+ }
69
+ if (!looksLikeOption) {
70
+ operands.push(word);
71
+ continue;
72
+ }
73
+ // git's `-<n>` revision limit (`-20`): a whole word of digits, accepted
74
+ // as a no-value option before any cluster or long-option parsing sees it.
75
+ // `-2x` is not all digits, so it falls through to the cluster path below
76
+ // and is refused unless its characters are listed. getopt style only.
77
+ //
78
+ // An explicit entry always wins over this shortcut, but only the leading
79
+ // `-<digit>` *character* is effective: the cluster parser below reads
80
+ // getopt's way, one character at a time, and never the whole word. An
81
+ // entry naming just `-20` therefore does not make `-20` pass — the
82
+ // shortcut stands down (the word is "explicitly listed") and the cluster
83
+ // parser then refuses it unless `-2` is listed too. Fail-closed, which is
84
+ // the right failure for a listing the binary would not read that way.
85
+ // Ambiguity is refused here as well, the same as every other path —
86
+ // otherwise a name the caller listed in more than one list would be
87
+ // silently accepted through this shortcut instead of being caught.
88
+ if (grammar.args === 'getopt' && grammar.numericShort && /^-\d+$/.test(word)) {
89
+ const shortChar = word.slice(0, 2); // '-' + first digit
90
+ if (isAmbiguousName(word, flags, valueFlags, optionalValueFlags) ||
91
+ isAmbiguousName(shortChar, flags, valueFlags, optionalValueFlags)) {
92
+ return { ok: false, word };
93
+ }
94
+ const explicitlyListed = flags.has(word) ||
95
+ valueFlags.has(word) ||
96
+ optionalValueFlags.has(word) ||
97
+ flags.has(shortChar) ||
98
+ valueFlags.has(shortChar) ||
99
+ optionalValueFlags.has(shortChar);
100
+ if (!explicitlyListed) {
101
+ continue; // no explicit entry claims this digit — take the numeric shortcut
102
+ }
103
+ // An explicit entry claims this word or its leading digit character:
104
+ // fall through to ordinary short-cluster parsing below, which applies
105
+ // it correctly (including consuming a separate value word for a
106
+ // `valueFlags` entry, which this shortcut must never do).
107
+ }
108
+ if (grammar.args === 'getopt' && !word.startsWith('--')) {
109
+ // Short cluster: read character by character, the way getopt does.
110
+ const cluster = matchShortCluster(word, words, i, flags, valueFlags, optionalValueFlags);
111
+ if (!cluster.ok)
112
+ return cluster;
113
+ i = cluster.nextIndex; // the `for` loop's own i++ then lands on the next unread word
114
+ continue;
115
+ }
116
+ // A long option (getopt) or any '-'-prefixed word (exact — there is no
117
+ // separate short-cluster path, so this is also where '-n' is checked).
118
+ // Only getopt splits '=value' off the name; exact matches the raw word,
119
+ // so '--name=value' matches only if that whole string was listed.
120
+ const eqIndex = grammar.args === 'getopt' ? word.indexOf('=') : -1;
121
+ const name = eqIndex === -1 ? word : word.slice(0, eqIndex);
122
+ // A name in more than one of the three lists is a grammar bug, not a
123
+ // call this matcher can make on the binary's behalf — accepting it would
124
+ // have to pick a side (does it take a value, an optional value, or
125
+ // none?), and picking wrong is exactly the hole the correctness
126
+ // conditions warn about. Refuse rather than guess.
127
+ if (isAmbiguousName(name, flags, valueFlags, optionalValueFlags)) {
128
+ return { ok: false, word };
129
+ }
130
+ if (valueFlags.has(name)) {
131
+ if (eqIndex !== -1) {
132
+ continue; // '--name=value': the value is attached, nothing left to consume
133
+ }
134
+ if (i + 1 >= words.length) {
135
+ return { ok: false, word: name }; // a value flag with nothing after it
136
+ }
137
+ i++; // consume the next word as the value
138
+ continue;
139
+ }
140
+ if (optionalValueFlags.has(name)) {
141
+ // Bare, or (getopt) '--name=value' with the value already attached to
142
+ // this same word via `name`/`eqIndex` above. Either way there is
143
+ // nothing left to consume, and the next word is never touched.
144
+ continue;
145
+ }
146
+ if (flags.has(name) && eqIndex === -1) {
147
+ continue; // a no-value flag never accepts an attached '=value'
148
+ }
149
+ return { ok: false, word };
150
+ }
151
+ return checkOperands(operands, grammar.operands);
152
+ }
153
+ /**
154
+ * True when `name` appears in more than one of the three option lists — the
155
+ * ambiguity the correctness conditions require every path to refuse.
156
+ */
157
+ function isAmbiguousName(name, flags, valueFlags, optionalValueFlags) {
158
+ const inFlags = flags.has(name);
159
+ const inValueFlags = valueFlags.has(name);
160
+ const inOptionalValueFlags = optionalValueFlags.has(name);
161
+ return ((inFlags && inValueFlags) || (inFlags && inOptionalValueFlags) || (inValueFlags && inOptionalValueFlags));
162
+ }
163
+ /**
164
+ * Reads one getopt-style short-option cluster (`-rn5`) character by
165
+ * character. `flags` characters just continue the scan; a `valueFlags`
166
+ * character takes the rest of the word as its value, or the next word if
167
+ * the rest is empty; an `optionalValueFlags` character takes the rest of the
168
+ * word as its value if there is any, never the next word, and either way
169
+ * ends the scan of this word; anything else refuses the whole cluster.
170
+ *
171
+ * Returns the index the outer loop's own `i++` should land past — unchanged
172
+ * when the value was attached, optional, or there was none to take,
173
+ * `index + 1` only when a required value flag consumed the next word.
174
+ */
175
+ function matchShortCluster(word, words, index, flags, valueFlags, optionalValueFlags) {
176
+ for (let c = 1; c < word.length; c++) {
177
+ const ch = '-' + word[c];
178
+ // Same ambiguity refusal as the long/exact path, checked first so a
179
+ // char present in more than one list is refused here too, not silently
180
+ // treated as belonging to just one of them (which the long/exact path
181
+ // would not do either).
182
+ if (isAmbiguousName(ch, flags, valueFlags, optionalValueFlags))
183
+ return { ok: false, word };
184
+ if (flags.has(ch))
185
+ continue;
186
+ if (valueFlags.has(ch)) {
187
+ const rest = word.slice(c + 1);
188
+ if (rest.length > 0)
189
+ return { ok: true, nextIndex: index };
190
+ if (index + 1 >= words.length)
191
+ return { ok: false, word: ch };
192
+ return { ok: true, nextIndex: index + 1 };
193
+ }
194
+ if (optionalValueFlags.has(ch)) {
195
+ // The rest of the word (if any) is this option's value; the scan of
196
+ // this word ends here either way, and the next word is never consumed.
197
+ return { ok: true, nextIndex: index };
198
+ }
199
+ return { ok: false, word };
200
+ }
201
+ return { ok: true, nextIndex: index };
202
+ }
203
+ /**
204
+ * Checks the operands collected from a pass over `words` against `rules`.
205
+ * `first` is checked only against `operands[0]` — a reader with no operands
206
+ * at all has nothing to violate it with — `each` against every operand, and
207
+ * `max` last, since going over budget is a property of the whole list rather
208
+ * than of any one word.
209
+ */
210
+ function checkOperands(operands, rules) {
211
+ if (!rules)
212
+ return { ok: true };
213
+ for (let i = 0; i < operands.length; i++) {
214
+ const operand = operands[i];
215
+ if (i === 0 && rules.first && !rules.first.includes(operand)) {
216
+ return { ok: false, word: operand };
217
+ }
218
+ if (rules.each) {
219
+ // A caller's /g or /y regex makes RegExp#test stateful: lastIndex
220
+ // carries over from the previous call, so without resetting it here
221
+ // one operand's match position can leak into the next — accepting an
222
+ // operand that should be refused, or the reverse. Reset before every
223
+ // test so each operand is checked fresh, the way a plain (non-sticky,
224
+ // non-global) pattern already behaves.
225
+ rules.each.lastIndex = 0;
226
+ if (!rules.each.test(operand)) {
227
+ return { ok: false, word: operand };
228
+ }
229
+ }
230
+ }
231
+ if (rules.max !== undefined) {
232
+ // A `max` that isn't a non-negative integer (NaN, 1.5, -1, ...) can't be
233
+ // compared against a count safely: `length > NaN` is always false,
234
+ // which would accept any number of operands, and a fractional max reads
235
+ // back out of the operands array at a non-existent index. Treat any
236
+ // such value as the strictest possible bound — no operand is safe to
237
+ // let through under a limit that couldn't be checked — rather than let
238
+ // a malformed grammar entry accept (or silently under-report) unbounded
239
+ // operands.
240
+ const validMax = Number.isInteger(rules.max) && rules.max >= 0;
241
+ const limit = validMax ? rules.max : 0;
242
+ if (operands.length > limit) {
243
+ return { ok: false, word: operands[limit] };
244
+ }
245
+ }
246
+ return { ok: true };
247
+ }
@@ -3,6 +3,7 @@ import { sanitizeCommand } from '../guard/sanitizer.js';
3
3
  import { requestApproval } from '../guard/elicitation.js';
4
4
  import { commandOutput } from './results.js';
5
5
  import { CommandQuota } from '../policy/quota.js';
6
+ import { formatReadOnlyRejection } from '../policy/classifier.js';
6
7
  import { ApprovalGrants } from '../guard/approval-grants.js';
7
8
  /** Policy never ran — the input was rejected at the boundary. */
8
9
  function rejectedEvaluation(commandClass) {
@@ -169,7 +170,15 @@ export function createPipeline({ server, registry, policy, audit, approvalGrantT
169
170
  const { conn, evaluation, approver } = await checkPolicyAndApprove(effective, profileName, opts.toolName);
170
171
  state.evaluation = evaluation;
171
172
  if (opts.enforceClass && evaluation.commandClass !== opts.enforceClass) {
172
- throw new Error(`${opts.toolName} only accepts ${opts.enforceClass} commands, got: ${evaluation.commandClass}`);
173
+ // A command that fell to `safe` because a reader's grammar rejected one
174
+ // of its argv words is one spelling away from the class this tool
175
+ // demands — say which word, or the caller only learns that the tool
176
+ // refused it, not why the reader that ran the exact same binary
177
+ // would not have.
178
+ const rejectionSuffix = evaluation.readOnlyRejection
179
+ ? ` ${formatReadOnlyRejection(evaluation.readOnlyRejection)}`
180
+ : '';
181
+ throw new Error(`${opts.toolName} only accepts ${opts.enforceClass} commands, got: ${evaluation.commandClass}${rejectionSuffix}`);
173
182
  }
174
183
  // Counted after the policy allowed it and before it runs: a denied
175
184
  // command should not burn quota, and an allowed one should be counted
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ssh-mcp",
3
3
  "license": "MIT",
4
- "version": "2.12.0",
4
+ "version": "2.13.0",
5
5
  "description": "MCP server exposing policy-gated, audited SSH access for Linux and Windows hosts via the Model Context Protocol.",
6
6
  "mcpName": "io.github.tufantunc/ssh-mcp",
7
7
  "type": "module",