dsh-cmd-guard 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,337 @@
1
+ import { effectiveTokens, plainToken, stemVerb, isHuntVerb, WRAPPERS } from './normalize.js';
2
+ import { HUNT_VERBS, SHELL_KEYWORDS } from './verbs.js';
3
+ /**
4
+ * Heads whose remaining tokens are data and which cannot execute a payload.
5
+ *
6
+ * Deliberately narrower than HUNT_STOP: that set also holds `python`, `node`
7
+ * and `npm`, whose tails are usually data but which can execute on request
8
+ * (`python -c`). Those are judged, not exempted.
9
+ *
10
+ * Membership is a claim that the tail cannot change the machine. `sort`,
11
+ * `less`, `rg`, `ag` and `man` were added and then removed on measurement:
12
+ * sort -o writes a file, less -o writes a log, rg --pre and ag --pager run a
13
+ * program, man -P runs the pager. Each of those passed with a system target.
14
+ * `tee`,
15
+ * `touch`, `env`, `date`, `hostname`, `ipconfig` and `ifconfig` all failed
16
+ * that claim: each writes a file, a variable or host state, and each was
17
+ * measured carrying a system target through this layer untouched. The
18
+ * exemption set is an attack surface of its own, so it is now read-only only.
19
+ */
20
+ const DATA_TAIL = new Set([
21
+ 'echo',
22
+ 'printf',
23
+ 'which',
24
+ 'where',
25
+ 'type',
26
+ 'help',
27
+ 'grep',
28
+ 'cat',
29
+ 'ls',
30
+ 'dir',
31
+ 'head',
32
+ 'tail',
33
+ 'more',
34
+ 'uniq',
35
+ 'wc',
36
+ 'diff',
37
+ 'whoami',
38
+ 'pwd',
39
+ 'ping',
40
+ 'nslookup',
41
+ 'whereis',
42
+ 'findstr',
43
+ ]);
44
+ /** Build, packaging and VCS tools: their capability is argued by the why layer. */
45
+ const TOOLCHAINS = new Set([
46
+ 'git',
47
+ 'npm',
48
+ 'npx',
49
+ 'pnpm',
50
+ 'yarn',
51
+ 'cargo',
52
+ 'go',
53
+ 'dotnet',
54
+ 'make',
55
+ 'gradle',
56
+ 'mvn',
57
+ 'tsc',
58
+ 'eslint',
59
+ 'prettier',
60
+ 'code',
61
+ ]);
62
+ /** Everything this layer declines to judge, for the reasons stated per set. */
63
+ const EXEMPT = new Set([
64
+ ...DATA_TAIL,
65
+ ...TOOLCHAINS,
66
+ ...WRAPPERS,
67
+ ...HUNT_VERBS,
68
+ ...SHELL_KEYWORDS,
69
+ // Policy heads are already judged by the rules layer; repeating them here
70
+ // would only add a second, weaker verdict for the same command.
71
+ 'reg',
72
+ 'sc',
73
+ 'netsh',
74
+ 'wmic',
75
+ 'bcdedit',
76
+ 'diskpart',
77
+ 'shutdown',
78
+ 'reboot',
79
+ 'taskkill',
80
+ 'format',
81
+ ]);
82
+ /**
83
+ * Where a command is pointed, as opposed to what it is called.
84
+ *
85
+ * Anchored on purpose: an argument that merely mentions a system path inside a
86
+ * sentence ("fix /etc/passwd handling") must not match, or every commit message
87
+ * that names a file becomes an ask.
88
+ */
89
+ const SENSITIVE_TARGET = [
90
+ /^[a-z]:\\windows(?:\\|$)/i,
91
+ /^[a-z]:\\program files(?: \(x86\))?(?:\\|$)/i,
92
+ /^[a-z]:\\programdata(?:\\|$)/i,
93
+ /^[a-z]:\\users\\?$/i,
94
+ /^[a-z]:\\?$/i,
95
+ // `-v C:\:C:\` names the drive root as the volume source.
96
+ /^[a-z]:\\:/i,
97
+ /^\/{1,3}$/,
98
+ // `docker run -v /:/host` mounts the machine into the container: the colon
99
+ // splits source from target, so the root is the part before it.
100
+ /^\/:/,
101
+ /^\/(?:etc|bin|sbin|boot|usr|var|lib|lib64|root|home|opt|dev)(?:\/|$)/i,
102
+ /^hk(?:lm|cr|u|cc)\\/i,
103
+ /^\\\\\?\\/,
104
+ ];
105
+ /** Files whose disclosure or loss is not recoverable by a later step. */
106
+ const CREDENTIAL_TARGET = /(?:^|[\\/])(?:shadow|sudoers|gshadow|passwd|id_rsa|id_ed25519|\.git-credentials|credentials|ntds\.dit|\.bash_history)$/i;
107
+ /** Registry hives and the local account database: readable, not ours to read. */
108
+ const SECRET_STORE = /(?:^|[\\/])config[\\/](?:sam|system|security|default|ntuser\.dat)$/i;
109
+ /** Bulk-mutation flag families. Common alone, not together. */
110
+ const RECURSIVE_FLAG = [
111
+ /^-[a-z]*r[a-z]*$/i,
112
+ /^--recursive(?:=.*)?$/i,
113
+ /^\/s$/i,
114
+ /^-recurse$/i,
115
+ ];
116
+ const FORCE_FLAG = [
117
+ /^-[a-z]*f[a-z]*$/i,
118
+ /^--force(?:=.*)?$/i,
119
+ /^-(?:fo|force)$/i,
120
+ /^\/[fqy]$/i,
121
+ ];
122
+ /** Subcommands that remove the resource they name. */
123
+ const REMOVE_SUBCOMMAND = /^(?:delete|remove|rm|rmi|destroy|drop|purge|prune|uninstall|wipe|evict|terminate)$/i;
124
+ /**
125
+ * Heads with no scoped use at all.
126
+ *
127
+ * `ntdsutil` was measured passing with no argument: the head was unfamiliar
128
+ * and there was nothing to look at. It is an interactive editor for the
129
+ * directory database, so being invoked is the whole signal.
130
+ */
131
+ const ALWAYS_SUSPICIOUS = new Set([
132
+ 'ntdsutil',
133
+ 'dcpromo',
134
+ 'adsi',
135
+ 'dsacls',
136
+ 'vssadmin',
137
+ ]);
138
+ /**
139
+ * Hosts that exist to run a file someone else wrote.
140
+ *
141
+ * `wscript payload.vbs` and `mofcomp payload.mof` have no scoped, benign
142
+ * invocation to fall back on, and neither name nor shape matched anything, so
143
+ * both passed. Being invoked is the signal.
144
+ */
145
+ const SCRIPT_HOSTS = new Set([
146
+ 'wscript',
147
+ 'cscript',
148
+ 'installutil',
149
+ 'mofcomp',
150
+ 'msxsl',
151
+ 'regasm',
152
+ 'regsvcs',
153
+ ]);
154
+ /** Flags whose value is a destination, not an option. */
155
+ const DEST_FLAG = /^(?:-C|-d|--directory|--prefix|--root|--target-dir|--output-dir|-v|--volume|--mount|-o|--output|--output-file|-O|--out)(?:=|$)/;
156
+ /**
157
+ * Flags whose value is a program to run rather than a file to read.
158
+ *
159
+ * `rg --pre "sh -c id"`, `ag --pager "rm -rf /"` and `man -P "rm -rf /"` all
160
+ * passed: the head was exempt and the value looked like any other argument.
161
+ */
162
+ const EXEC_FLAG = /^(?:--pre|--pager|-P)(?:=|$)/;
163
+ /** Flags that pull a payload from the network rather than name a file. */
164
+ const DOWNLOAD_FLAG = /^-(?:urlcache|outfile|outputpath|encodedcommand|enc|e|w|windowstyle|downloadstring|downloadfile)(?:=.*)?$/i;
165
+ /**
166
+ * Heads that change host identity, clock, volume layout or service state.
167
+ *
168
+ * A head is only suspicious when it is followed by an argument that matches,
169
+ * so the bare forms (`hostname`, `date`) stay silent. Stated as a capability
170
+ * rather than an intent: renaming a host changes what every later operation
171
+ * on this machine is talking to.
172
+ */
173
+ const HOST_STATE = new Map([
174
+ ['hostname', /./],
175
+ ['hostnamectl', /^set-/i],
176
+ ['date', /^(?:-s|--set|-d)$/i],
177
+ ['timedatectl', /^set-/i],
178
+ ['w32tm', /^\/config/i],
179
+ ['tzutil', /^\/s(?:$|\s)/i],
180
+ ['format-volume', /./],
181
+ ['clear-disk', /./],
182
+ ['initialize-disk', /./],
183
+ ['new-partition', /./],
184
+ ['remove-partition', /./],
185
+ ['resize-partition', /./],
186
+ ['new-service', /./],
187
+ ['set-service', /./],
188
+ ['remove-service', /./],
189
+ ['new-localuser', /./],
190
+ ['add-localgroupmember', /./],
191
+ ['wevtutil', /^(?:cl|clear-log)/i],
192
+ ['auditpol', /^(?:\/set|\/clear|\/remove)/i],
193
+ ['logman', /^(?:create|delete|update|start|stop)/i],
194
+ ['mshta', /./],
195
+ ['rundll32', /./],
196
+ ['regsvr32', /./],
197
+ // Service state and scheduled-task removal. `net stop winmgmt` and
198
+ // `schtasks /delete` were exempt on the argument that the rules layer owns
199
+ // these heads. It owns the create/add forms; stopping a service and
200
+ // removing a task are the same class of state change and were unowned.
201
+ ['net', /^(?:stop|start|pause|continue|config|share|accounts)$/i],
202
+ ['schtasks', /^\/?(?:delete|change|run|end)$/i],
203
+ // Persistent environment and file attributes. `setx FOO bar` writes user
204
+ // environment for every later process; `attrib +s +h` hides a file from
205
+ // ordinary listings. Both read as harmless and neither had a signal.
206
+ ['setx', /./],
207
+ ['attrib', /^\+[a-z]+$/i],
208
+ ]);
209
+ /** Flags whose next argument is code, not a file. */
210
+ const INLINE_FLAG = [
211
+ /^-[ce]$/i,
212
+ /^-r$/i,
213
+ /^--eval(?:=.*)?$/i,
214
+ /^-(?:command|encodedcommand|exec|execdir|eval)$/i,
215
+ /^\/c$/i,
216
+ ];
217
+ /**
218
+ * Strip an option's leading `--name=` so the value is compared, not the flag.
219
+ * `--prefix=/etc/x` writes under /etc; `/^\/(?:etc)/` never sees it otherwise.
220
+ */
221
+ function valueOf(token) {
222
+ return token.replace(/^-{1,2}[A-Za-z][A-Za-z0-9_-]*=/, '');
223
+ }
224
+ function isSensitive(token) {
225
+ const v = valueOf(token);
226
+ if (v.length === 0)
227
+ return false;
228
+ if (CREDENTIAL_TARGET.test(v))
229
+ return true;
230
+ return SENSITIVE_TARGET.some((re) => re.test(v));
231
+ }
232
+ export function check(input, _state) {
233
+ const raw = effectiveTokens(input.cmd);
234
+ const first = raw[0];
235
+ if (first === undefined)
236
+ return null;
237
+ const head = stemVerb(first);
238
+ if (head.length === 0)
239
+ return null;
240
+ // Reading a credential file is worth a question whether or not the head is
241
+ // exempt: `cat /etc/shadow` is a read, and the read is the whole problem.
242
+ // This check sits before the exemption return on purpose.
243
+ const plain = raw.map(plainToken).filter((t) => t.length > 0);
244
+ const secret = plain.find((t) => {
245
+ const v = valueOf(t);
246
+ return SECRET_STORE.test(v) || CREDENTIAL_TARGET.test(v);
247
+ });
248
+ if (secret !== undefined) {
249
+ return {
250
+ layer: 'capability',
251
+ verdict: 'ask',
252
+ code: 'UNKNOWN_CAPABILITY',
253
+ reason: `"${head}" reads "${secret}", a credential store. Reading it is the disclosure.`,
254
+ };
255
+ }
256
+ if (EXEMPT.has(head))
257
+ return null;
258
+ // A token that came out of quotes keeps its spaces; one that did not is a
259
+ // word the shell would pass as a single argument. Sentences are not targets.
260
+ const tokens = raw.map(plainToken).filter((t) => t.length > 0 && !/\s/.test(t));
261
+ if (tokens.length === 0)
262
+ return null;
263
+ const signals = [];
264
+ const target = tokens.find(isSensitive);
265
+ if (target !== undefined) {
266
+ signals.push(`points at "${target}", a system, volume or credential location`);
267
+ }
268
+ const recursive = tokens.some((t) => RECURSIVE_FLAG.some((re) => re.test(t)));
269
+ const force = tokens.some((t) => FORCE_FLAG.some((re) => re.test(t)));
270
+ if (recursive && force)
271
+ signals.push('carries recursive and force flags together');
272
+ const inlineAt = tokens.findIndex((t) => INLINE_FLAG.some((re) => re.test(t)));
273
+ if (inlineAt >= 0 && tokens.length > inlineAt + 1) {
274
+ signals.push('takes code as an argument rather than a file');
275
+ }
276
+ // A destination flag: the target is the flag's value, not any token.
277
+ // `tar -xf a.tar -C /etc` and `docker run -v /:/host` were both passing,
278
+ // because no token in them looks like a path on its own.
279
+ for (let i = 0; i < tokens.length; i += 1) {
280
+ const flag = tokens[i] ?? '';
281
+ const m = DEST_FLAG.exec(flag);
282
+ if (m === null)
283
+ continue;
284
+ const inline = flag.slice(m[0].length);
285
+ const value = inline.length > 0 ? inline : (tokens[i + 1] ?? '');
286
+ const cleaned = valueOf(plainToken(value));
287
+ if (cleaned.length > 0 && isSensitive(cleaned)) {
288
+ signals.push(`writes to "${cleaned}", a location outside the workspace`);
289
+ break;
290
+ }
291
+ }
292
+ if (tokens.some((t) => DOWNLOAD_FLAG.test(t))) {
293
+ signals.push('takes a download or an encoded payload');
294
+ }
295
+ // Host identity, clock and volume layout. These heads read as harmless and
296
+ // `hostname ATTACKER`, `date -s ...` and `w32tm /config ...` all passed.
297
+ const statePattern = HOST_STATE.get(head);
298
+ if (statePattern !== undefined) {
299
+ const hit = tokens.slice(1).find((t) => statePattern.test(plainToken(t)));
300
+ if (hit !== undefined)
301
+ signals.push(`changes machine state with "${hit}"`);
302
+ }
303
+ // Tools whose destructive act is spelled as a subcommand rather than a
304
+ // verb in head position: `kubectl delete ns kube-system` passed, because
305
+ // the head was unfamiliar and no token looked like a path. The subcommand
306
+ // is what the tool is about to remove, so it is judged on its own.
307
+ const sub = tokens[1];
308
+ if (sub !== undefined && REMOVE_SUBCOMMAND.test(plainToken(sub))) {
309
+ signals.push(`runs "${plainToken(sub)}", which removes whatever it names`);
310
+ }
311
+ // Tools that open a privileged console and change machine-wide identity or
312
+ // the directory database the machine authenticates against. There is no
313
+ // scoped invocation of these, so the head alone is enough.
314
+ if (ALWAYS_SUSPICIOUS.has(head)) {
315
+ signals.push('edits the machine directory database or partition table directly');
316
+ }
317
+ if (SCRIPT_HOSTS.has(head)) {
318
+ signals.push('exists to run a script file rather than to do one job');
319
+ }
320
+ // A flag whose value is another program. The value is what runs, and the
321
+ // value is what nothing was looking at.
322
+ const execAt = tokens.findIndex((t) => EXEC_FLAG.test(t));
323
+ if (execAt >= 0 && tokens.length > execAt + 1) {
324
+ signals.push(`runs "${tokens[execAt + 1]}" as a subprocess`);
325
+ }
326
+ if (tokens.slice(1).some((t) => isHuntVerb(t))) {
327
+ signals.push('carries a destructive verb as an argument');
328
+ }
329
+ if (signals.length === 0)
330
+ return null;
331
+ return {
332
+ layer: 'capability',
333
+ verdict: 'ask',
334
+ code: 'UNKNOWN_CAPABILITY',
335
+ reason: `"${head}" is not a known tool, and it ${signals.join('; ')}. An unfamiliar program is judged by what it can do to the machine, not by its name.`,
336
+ };
337
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Layer 0: tool identity.
3
+ *
4
+ * This layer exists because of one concrete near-miss. The first draft assumed
5
+ * the command tool was called `run_command` and took `cmd`. The shipped tools
6
+ * are `bash` and `pwsh`, and they take `command`. A guard written against the
7
+ * assumed names installs cleanly, activates cleanly, and intercepts nothing -
8
+ * which is worse than failing to install, because it looks like it is working.
9
+ *
10
+ * So: anything that is not a known execution tool, but is passed a
11
+ * command-shaped string, is an ask. Not a deny - a new shell tool is a
12
+ * legitimate thing for the harness to grow. Just not something this parser
13
+ * has any evidence about.
14
+ */
15
+ import type { Finding, GuardInput, GuardState } from './types.js';
16
+ /**
17
+ * Tool names DSH dispatches commands through.
18
+ *
19
+ * Verified against the shipped type declarations of 0.2.x: the POSIX shell
20
+ * tool registers as `bash`, the Windows one as `pwsh`, and the persistent
21
+ * variant reuses `bash` (same registered name, so name matching covers it).
22
+ *
23
+ * The rest of the set is deliberate over-inclusion. A tool that runs a command
24
+ * and is named something obvious here should be treated as a command source,
25
+ * even if this build never registers it.
26
+ */
27
+ export declare const EXEC_TOOLS: ReadonlySet<string>;
28
+ /**
29
+ * Keys the command legitimately arrives under, per tool.
30
+ *
31
+ * `command` is the shipped key for the shell tools. Anything outside the set
32
+ * for that tool is drift and gets surfaced rather than trusted.
33
+ */
34
+ export declare function primaryKeysFor(tool: string): ReadonlySet<string>;
35
+ /** Keys every execution tool may carry without it being drift. */
36
+ export declare const PRIMARY_COMMAND_KEYS: ReadonlySet<string>;
37
+ /**
38
+ * Keys whose value is document or payload data, never a command line.
39
+ *
40
+ * Two independent reasons this lives here rather than only in the mount:
41
+ *
42
+ * 1. The unknown-tool scan reads every string argument. Without an exclusion,
43
+ * `write_file` with a README that mentions `del /s /q` was surfaced as a
44
+ * command, and so was writing this package's own test corpus. With no
45
+ * approval UI composed, ask degrades to deny, which made `write_file`
46
+ * effectively unusable.
47
+ * 2. `verify()` is exported and tests call it directly with a `commandSource`.
48
+ * The exclusion has to hold at the layer, not only at the seam, or the same
49
+ * false positive comes back through the library entry point.
50
+ *
51
+ * This is a name-based exclusion and therefore a residual risk: a tool that
52
+ * genuinely executes the string in `content` would be skipped. Stated in the
53
+ * README, not papered over.
54
+ */
55
+ export declare const DATA_KEYS: ReadonlySet<string>;
56
+ export declare function check(input: GuardInput, _state: GuardState): Finding | null;
@@ -0,0 +1,133 @@
1
+ import { looksLikeCommandString } from './shape.js';
2
+ /**
3
+ * Tool names DSH dispatches commands through.
4
+ *
5
+ * Verified against the shipped type declarations of 0.2.x: the POSIX shell
6
+ * tool registers as `bash`, the Windows one as `pwsh`, and the persistent
7
+ * variant reuses `bash` (same registered name, so name matching covers it).
8
+ *
9
+ * The rest of the set is deliberate over-inclusion. A tool that runs a command
10
+ * and is named something obvious here should be treated as a command source,
11
+ * even if this build never registers it.
12
+ */
13
+ export const EXEC_TOOLS = new Set([
14
+ 'bash',
15
+ 'sh',
16
+ 'zsh',
17
+ 'fish',
18
+ 'pwsh',
19
+ 'powershell',
20
+ 'powershell.exe',
21
+ 'pwsh.exe',
22
+ 'cmd',
23
+ 'cmd.exe',
24
+ 'shell',
25
+ 'terminal',
26
+ 'run_command',
27
+ 'exec',
28
+ ]);
29
+ /**
30
+ * Tools that historically carried the command under `cmd`.
31
+ *
32
+ * Only these tolerate the short key. `bash` and `pwsh` are verified to take
33
+ * `command`; accepting `cmd` for them would mean a call that renames the key
34
+ * - the exact silent-rename failure this layer exists to catch - slides
35
+ * through as if nothing happened. Measured: `pwsh` handed `{cmd: "git status"}`
36
+ * passed, because `cmd` was globally tolerated.
37
+ */
38
+ const LEGACY_CMD_TOOLS = new Set(['run_command', 'exec']);
39
+ /**
40
+ * Keys the command legitimately arrives under, per tool.
41
+ *
42
+ * `command` is the shipped key for the shell tools. Anything outside the set
43
+ * for that tool is drift and gets surfaced rather than trusted.
44
+ */
45
+ export function primaryKeysFor(tool) {
46
+ return LEGACY_CMD_TOOLS.has(tool.toLowerCase())
47
+ ? new Set(['command', 'cmd'])
48
+ : new Set(['command']);
49
+ }
50
+ /** Keys every execution tool may carry without it being drift. */
51
+ export const PRIMARY_COMMAND_KEYS = new Set(['command', 'cmd']);
52
+ /**
53
+ * Keys whose value is document or payload data, never a command line.
54
+ *
55
+ * Two independent reasons this lives here rather than only in the mount:
56
+ *
57
+ * 1. The unknown-tool scan reads every string argument. Without an exclusion,
58
+ * `write_file` with a README that mentions `del /s /q` was surfaced as a
59
+ * command, and so was writing this package's own test corpus. With no
60
+ * approval UI composed, ask degrades to deny, which made `write_file`
61
+ * effectively unusable.
62
+ * 2. `verify()` is exported and tests call it directly with a `commandSource`.
63
+ * The exclusion has to hold at the layer, not only at the seam, or the same
64
+ * false positive comes back through the library entry point.
65
+ *
66
+ * This is a name-based exclusion and therefore a residual risk: a tool that
67
+ * genuinely executes the string in `content` would be skipped. Stated in the
68
+ * README, not papered over.
69
+ */
70
+ export const DATA_KEYS = new Set([
71
+ 'content',
72
+ 'contents',
73
+ 'body',
74
+ 'text',
75
+ 'new_string',
76
+ 'old_string',
77
+ 'new_str',
78
+ 'old_str',
79
+ 'patch',
80
+ 'diff',
81
+ 'message',
82
+ 'query',
83
+ 'prompt',
84
+ 'note',
85
+ 'summary',
86
+ 'explanation',
87
+ 'description',
88
+ 'why',
89
+ 'reason',
90
+ 'intent',
91
+ 'purpose',
92
+ ]);
93
+ function ask(code, reason) {
94
+ return { layer: 'entry', verdict: 'ask', code, reason };
95
+ }
96
+ export function check(input, _state) {
97
+ // The string came from a payload key, so it is a document, not a command
98
+ // line - whatever the tool name is. Checked before the tool-name branch on
99
+ // purpose: `write_file` must not be flagged for what its `content` says.
100
+ const source = input.commandSource;
101
+ if (source !== undefined && DATA_KEYS.has(source.toLowerCase()))
102
+ return null;
103
+ // Shape, not identity: how many command keys a call carries does not
104
+ // depend on whether a tool name is set. This sat after the tool guard, so a
105
+ // direct verify() call carrying both \`cmd\` and \`command\` was never checked.
106
+ // Two command keys in one call: the parser read one of them, and the
107
+ // tool may run the other. This is not drift, it is divergence, and no
108
+ // token analysis of a single string can resolve it. Checked before the
109
+ // tool-name branch because an unfamiliar tool returned early and the
110
+ // comparison never happened.
111
+ if (input.commandKeys !== undefined && input.commandKeys.length > 1) {
112
+ return ask('ENTRY_COMMAND_KEY_AMBIGUOUS', `The call carries more than one command key (${input.commandKeys.join(', ')}). The string below was read from "${source ?? 'an unnamed key'}", but which one runs is not something this parser can establish.`);
113
+ }
114
+ // Direct calls (tests, the exported verify()) carry no tool name. Layer 0
115
+ // has nothing to say about those, and inventing an opinion would make the
116
+ // pipeline unusable as a library.
117
+ if (input.tool === undefined)
118
+ return null;
119
+ const tool = input.tool.toLowerCase();
120
+ if (!EXEC_TOOLS.has(tool)) {
121
+ // Not every bare string argument is a command. `read_file` carries a path,
122
+ // and with no approval UI composed ask degrades to deny, so a shape-free
123
+ // scan made every file read impossible. Measured false positive; the shape
124
+ // heuristic now decides instead of the argument's existence.
125
+ if (!looksLikeCommandString(input.cmd))
126
+ return null;
127
+ return ask('ENTRY_UNKNOWN_EXEC_TOOL', `"${input.tool}" is not a shell tool this parser knows, yet it was handed a command-shaped argument. A new or renamed execution tool fails open, so it is surfaced instead.`);
128
+ }
129
+ if (source !== undefined && !primaryKeysFor(tool).has(source.toLowerCase())) {
130
+ return ask('ENTRY_COMMAND_KEY_DRIFT', `The command arrived under "${source}", which is not a key "${tool}" takes. Either the tool schema changed or a wrapper is reshaping arguments; both invalidate the assumption that the string below is the whole command.`);
131
+ }
132
+ return null;
133
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Layer 5: interpreters.
3
+ *
4
+ * The plugin performs no IO and never opens the script. "The file exists" is
5
+ * therefore not an audit, and a rule based on existence would hand out a
6
+ * complete bypass: write a file, then run it. The only defensible line is
7
+ * where the path comes from.
8
+ *
9
+ * Two identity rules here are not cosmetic. Both were measured bypasses
10
+ * against the first draft:
11
+ *
12
+ * - `INTERPRETERS.has(head)` compared the entire first token, so
13
+ * `C:\Python39\python.exe`, `python3.11`, `node18` and `/usr/bin/python3`
14
+ * all fell through as if they were ordinary programs.
15
+ * - `INLINE_FLAGS` was an exact-match set, so bundled short flags (`-uc`,
16
+ * `-pe`, `-ep`) read as an ordinary script call with no script.
17
+ */
18
+ import type { Finding, GuardInput, GuardState } from './types.js';
19
+ /**
20
+ * Resolve the interpreter behind a token: strip the path, the `.exe` suffix
21
+ * and a version suffix, then look for a known name.
22
+ *
23
+ * `C:\Python39\python.exe` -> python
24
+ * `python3.11` -> python3
25
+ * `node18` -> node
26
+ * `py` -> py
27
+ */
28
+ export declare function interpreterOf(token: string): string | undefined;
29
+ /**
30
+ * Bundled short flags. `-uc` is `-u -c`, `-pe` is `-p -e`. Only `c` and `e`
31
+ * matter: they are the ones that carry a program body. `-Bu` carries none, so
32
+ * treating every bundle as inline would be a false positive on normal runs.
33
+ */
34
+ export declare function isInlineFlag(token: string): boolean;
35
+ export declare function check(input: GuardInput, state: GuardState): Finding | null;