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 +18 -1
- package/build/policy/classifier.js +576 -78
- package/build/policy/engine.js +42 -20
- package/build/policy/reader-grammar.js +247 -0
- package/build/tools/pipeline.js +10 -1
- package/package.json +1 -1
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
|
-
*
|
|
24
|
-
* interpreter carrier scan off for two verbs by adding them to a list
|
|
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
|
-
*
|
|
32
|
-
*
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
"
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
"
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
"
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
"
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
"
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
"
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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, };
|
package/build/policy/engine.js
CHANGED
|
@@ -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
|
|
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,
|
|
406
|
+
explainRoleDenial(profile, parsed) {
|
|
407
|
+
const commandClass = parsed.class;
|
|
408
|
+
let reason;
|
|
397
409
|
if (profile.readOnly) {
|
|
398
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/build/tools/pipeline.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|