bash-classify 0.1.0__py3-none-any.whl
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.
- bash_classify/__init__.py +14 -0
- bash_classify/__main__.py +3 -0
- bash_classify/classifier.py +244 -0
- bash_classify/cli.py +110 -0
- bash_classify/commands/apt.yaml +23 -0
- bash_classify/commands/awk.yaml +5 -0
- bash_classify/commands/basename.yaml +5 -0
- bash_classify/commands/bash.yaml +18 -0
- bash_classify/commands/brew.yaml +25 -0
- bash_classify/commands/builtin.yaml +6 -0
- bash_classify/commands/cargo.yaml +29 -0
- bash_classify/commands/cat.yaml +5 -0
- bash_classify/commands/chgrp.yaml +8 -0
- bash_classify/commands/chmod.yaml +8 -0
- bash_classify/commands/chown.yaml +8 -0
- bash_classify/commands/column.yaml +5 -0
- bash_classify/commands/comm.yaml +5 -0
- bash_classify/commands/command.yaml +10 -0
- bash_classify/commands/cp.yaml +5 -0
- bash_classify/commands/curl.yaml +28 -0
- bash_classify/commands/cut.yaml +5 -0
- bash_classify/commands/date.yaml +8 -0
- bash_classify/commands/df.yaml +5 -0
- bash_classify/commands/diff.yaml +5 -0
- bash_classify/commands/dig.yaml +5 -0
- bash_classify/commands/dirname.yaml +5 -0
- bash_classify/commands/docker.yaml +152 -0
- bash_classify/commands/du.yaml +5 -0
- bash_classify/commands/echo.yaml +5 -0
- bash_classify/commands/env.yaml +14 -0
- bash_classify/commands/expand.yaml +5 -0
- bash_classify/commands/false.yaml +5 -0
- bash_classify/commands/file.yaml +5 -0
- bash_classify/commands/find.yaml +27 -0
- bash_classify/commands/fmt.yaml +5 -0
- bash_classify/commands/fold.yaml +5 -0
- bash_classify/commands/free.yaml +5 -0
- bash_classify/commands/gawk.yaml +5 -0
- bash_classify/commands/git.yaml +441 -0
- bash_classify/commands/go.yaml +38 -0
- bash_classify/commands/grep.yaml +5 -0
- bash_classify/commands/groups.yaml +5 -0
- bash_classify/commands/gunzip.yaml +5 -0
- bash_classify/commands/gzip.yaml +5 -0
- bash_classify/commands/head.yaml +5 -0
- bash_classify/commands/helm.yaml +127 -0
- bash_classify/commands/hostname.yaml +5 -0
- bash_classify/commands/id.yaml +5 -0
- bash_classify/commands/install.yaml +5 -0
- bash_classify/commands/join.yaml +5 -0
- bash_classify/commands/journalctl.yaml +12 -0
- bash_classify/commands/jq.yaml +5 -0
- bash_classify/commands/kill.yaml +5 -0
- bash_classify/commands/killall.yaml +5 -0
- bash_classify/commands/kubectl.yaml +190 -0
- bash_classify/commands/less.yaml +5 -0
- bash_classify/commands/ln.yaml +5 -0
- bash_classify/commands/ls.yaml +5 -0
- bash_classify/commands/make.yaml +19 -0
- bash_classify/commands/md5sum.yaml +5 -0
- bash_classify/commands/mkdir.yaml +5 -0
- bash_classify/commands/more.yaml +5 -0
- bash_classify/commands/mv.yaml +5 -0
- bash_classify/commands/netstat.yaml +5 -0
- bash_classify/commands/nice.yaml +9 -0
- bash_classify/commands/nl.yaml +5 -0
- bash_classify/commands/node.yaml +5 -0
- bash_classify/commands/nohup.yaml +6 -0
- bash_classify/commands/npm.yaml +65 -0
- bash_classify/commands/nslookup.yaml +5 -0
- bash_classify/commands/paste.yaml +5 -0
- bash_classify/commands/ping.yaml +5 -0
- bash_classify/commands/pip.yaml +29 -0
- bash_classify/commands/pip3.yaml +29 -0
- bash_classify/commands/pkill.yaml +5 -0
- bash_classify/commands/poetry.yaml +74 -0
- bash_classify/commands/printenv.yaml +5 -0
- bash_classify/commands/printf.yaml +5 -0
- bash_classify/commands/ps.yaml +5 -0
- bash_classify/commands/pwd.yaml +5 -0
- bash_classify/commands/python.yaml +5 -0
- bash_classify/commands/python3.yaml +5 -0
- bash_classify/commands/read.yaml +5 -0
- bash_classify/commands/readlink.yaml +5 -0
- bash_classify/commands/realpath.yaml +5 -0
- bash_classify/commands/rev.yaml +5 -0
- bash_classify/commands/rm.yaml +5 -0
- bash_classify/commands/rsync.yaml +10 -0
- bash_classify/commands/scp.yaml +5 -0
- bash_classify/commands/sed.yaml +12 -0
- bash_classify/commands/seq.yaml +5 -0
- bash_classify/commands/sh.yaml +12 -0
- bash_classify/commands/sha1sum.yaml +5 -0
- bash_classify/commands/sha256sum.yaml +5 -0
- bash_classify/commands/sort.yaml +8 -0
- bash_classify/commands/ss.yaml +5 -0
- bash_classify/commands/ssh.yaml +39 -0
- bash_classify/commands/stat.yaml +5 -0
- bash_classify/commands/strace.yaml +18 -0
- bash_classify/commands/sudo.yaml +22 -0
- bash_classify/commands/systemctl.yaml +56 -0
- bash_classify/commands/tac.yaml +5 -0
- bash_classify/commands/tail.yaml +5 -0
- bash_classify/commands/tar.yaml +22 -0
- bash_classify/commands/tee.yaml +5 -0
- bash_classify/commands/terraform.yaml +68 -0
- bash_classify/commands/test.yaml +5 -0
- bash_classify/commands/time.yaml +6 -0
- bash_classify/commands/timeout.yaml +15 -0
- bash_classify/commands/top.yaml +5 -0
- bash_classify/commands/touch.yaml +5 -0
- bash_classify/commands/tr.yaml +5 -0
- bash_classify/commands/traceroute.yaml +5 -0
- bash_classify/commands/true.yaml +5 -0
- bash_classify/commands/type.yaml +5 -0
- bash_classify/commands/uname.yaml +5 -0
- bash_classify/commands/unexpand.yaml +5 -0
- bash_classify/commands/uniq.yaml +5 -0
- bash_classify/commands/unzip.yaml +7 -0
- bash_classify/commands/uptime.yaml +5 -0
- bash_classify/commands/uv.yaml +117 -0
- bash_classify/commands/wc.yaml +5 -0
- bash_classify/commands/wget.yaml +5 -0
- bash_classify/commands/whereis.yaml +5 -0
- bash_classify/commands/which.yaml +5 -0
- bash_classify/commands/whoami.yaml +5 -0
- bash_classify/commands/xargs.yaml +25 -0
- bash_classify/commands/xmllint.yaml +5 -0
- bash_classify/commands/yes.yaml +5 -0
- bash_classify/commands/yq.yaml +8 -0
- bash_classify/commands/zip.yaml +5 -0
- bash_classify/commands/zsh.yaml +12 -0
- bash_classify/database.py +257 -0
- bash_classify/matcher.py +702 -0
- bash_classify/models.py +148 -0
- bash_classify/parser.py +525 -0
- bash_classify/py.typed +0 -0
- bash_classify-0.1.0.dist-info/METADATA +147 -0
- bash_classify-0.1.0.dist-info/RECORD +142 -0
- bash_classify-0.1.0.dist-info/WHEEL +4 -0
- bash_classify-0.1.0.dist-info/entry_points.txt +2 -0
- bash_classify-0.1.0.dist-info/licenses/LICENSE +21 -0
bash_classify/matcher.py
ADDED
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
"""Command matching and classification against the database."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
|
|
9
|
+
from .models import (
|
|
10
|
+
Classification,
|
|
11
|
+
CommandDef,
|
|
12
|
+
CommandInvocation,
|
|
13
|
+
CommandResult,
|
|
14
|
+
DelegationConfig,
|
|
15
|
+
DelegationMode,
|
|
16
|
+
InnerCommandResult,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Shell builtins that are special-cased (not from database)
|
|
20
|
+
_BUILTIN_DIRECTORY_COMMANDS = {"cd", "pushd", "popd"}
|
|
21
|
+
_BUILTIN_READONLY_COMMANDS = {"[", "[[", "test"}
|
|
22
|
+
_BUILTIN_DANGEROUS_COMMANDS = {"eval", "source", ".", "exec"}
|
|
23
|
+
|
|
24
|
+
# Regex for KEY=VALUE assignments (used by strip_assignments)
|
|
25
|
+
_ASSIGNMENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def match_command(
|
|
29
|
+
invocation: CommandInvocation,
|
|
30
|
+
database: Mapping[str, CommandDef],
|
|
31
|
+
) -> CommandResult:
|
|
32
|
+
"""Match a parsed CommandInvocation against the command database.
|
|
33
|
+
|
|
34
|
+
Performs binary lookup, global option stripping, subcommand matching,
|
|
35
|
+
option classification, and delegation handling.
|
|
36
|
+
"""
|
|
37
|
+
argv = invocation.argv
|
|
38
|
+
if not argv:
|
|
39
|
+
return CommandResult(
|
|
40
|
+
command=[],
|
|
41
|
+
argv=[],
|
|
42
|
+
classification=Classification.UNKNOWN,
|
|
43
|
+
matched_rule=None,
|
|
44
|
+
inner_commands=[],
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
binary = argv[0]
|
|
48
|
+
|
|
49
|
+
# Step 0: Handle special builtins
|
|
50
|
+
if binary in _BUILTIN_DIRECTORY_COMMANDS:
|
|
51
|
+
return _handle_directory_builtin(invocation)
|
|
52
|
+
|
|
53
|
+
if binary in _BUILTIN_READONLY_COMMANDS:
|
|
54
|
+
return _handle_readonly_builtin(invocation)
|
|
55
|
+
|
|
56
|
+
if binary in _BUILTIN_DANGEROUS_COMMANDS:
|
|
57
|
+
return _handle_dangerous_builtin(invocation)
|
|
58
|
+
|
|
59
|
+
# Step 1: Binary lookup (try exact match first, then basename as fallback)
|
|
60
|
+
command_def = database.get(binary)
|
|
61
|
+
if command_def is None and (os.sep in binary or "/" in binary):
|
|
62
|
+
basename = os.path.basename(binary)
|
|
63
|
+
command_def = database.get(basename)
|
|
64
|
+
if command_def is not None:
|
|
65
|
+
binary = basename
|
|
66
|
+
if command_def is None:
|
|
67
|
+
return CommandResult(
|
|
68
|
+
command=[binary],
|
|
69
|
+
argv=list(argv),
|
|
70
|
+
classification=Classification.UNKNOWN,
|
|
71
|
+
matched_rule=None,
|
|
72
|
+
inner_commands=[],
|
|
73
|
+
classification_reason="command not in database",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
remaining = argv[1:]
|
|
77
|
+
|
|
78
|
+
# Step 2: Strip global options
|
|
79
|
+
remaining, ignored_options, global_directories = _strip_global_options(remaining, command_def)
|
|
80
|
+
|
|
81
|
+
# Step 3: Subcommand matching
|
|
82
|
+
matched_def, command_chain, remaining = _match_subcommand(remaining, command_def)
|
|
83
|
+
|
|
84
|
+
# Step 4: Option classification
|
|
85
|
+
(
|
|
86
|
+
known_options,
|
|
87
|
+
unknown_options,
|
|
88
|
+
overrides,
|
|
89
|
+
option_directories,
|
|
90
|
+
option_delegations,
|
|
91
|
+
remaining_positional,
|
|
92
|
+
) = _classify_options(remaining, matched_def)
|
|
93
|
+
|
|
94
|
+
all_directories = global_directories + option_directories
|
|
95
|
+
|
|
96
|
+
# Build command path
|
|
97
|
+
full_command = [binary, *command_chain]
|
|
98
|
+
matched_rule = ".".join(full_command) if command_chain else binary
|
|
99
|
+
|
|
100
|
+
# Step 5: Determine classification
|
|
101
|
+
base_classification = matched_def.classification
|
|
102
|
+
if base_classification is None:
|
|
103
|
+
base_classification = Classification.READONLY
|
|
104
|
+
|
|
105
|
+
# Find the highest override — overrides REPLACE the base classification
|
|
106
|
+
classification_reason: str | None = None
|
|
107
|
+
overriding_option: str | None = None
|
|
108
|
+
|
|
109
|
+
if overrides:
|
|
110
|
+
# When any option has an override, the final classification is the max
|
|
111
|
+
# of all overrides (ignoring the base). This is a true override/replace.
|
|
112
|
+
final_classification = overrides[0][1]
|
|
113
|
+
overriding_option = overrides[0][0]
|
|
114
|
+
for opt_name, override_class in overrides[1:]:
|
|
115
|
+
if override_class.severity() > final_classification.severity():
|
|
116
|
+
final_classification = override_class
|
|
117
|
+
overriding_option = opt_name
|
|
118
|
+
classification_reason = f"overridden by option {overriding_option} to {final_classification.value}"
|
|
119
|
+
else:
|
|
120
|
+
final_classification = base_classification
|
|
121
|
+
classification_reason = f"base classification from rule {matched_rule}"
|
|
122
|
+
|
|
123
|
+
# Strict mode: unrecognized options -> UNKNOWN
|
|
124
|
+
if matched_def.strict and unknown_options:
|
|
125
|
+
final_classification = Classification.max_severity(final_classification, Classification.UNKNOWN)
|
|
126
|
+
if final_classification == Classification.UNKNOWN:
|
|
127
|
+
classification_reason = f"unrecognized option {unknown_options[0]} in strict mode"
|
|
128
|
+
|
|
129
|
+
# Step 6: Handle delegation
|
|
130
|
+
inner_commands: list[InnerCommandResult] = []
|
|
131
|
+
|
|
132
|
+
# Command-level delegation
|
|
133
|
+
if matched_def.delegates_to is not None:
|
|
134
|
+
inner_commands.extend(
|
|
135
|
+
_handle_delegation(
|
|
136
|
+
remaining_positional,
|
|
137
|
+
matched_def.delegates_to,
|
|
138
|
+
database,
|
|
139
|
+
argv,
|
|
140
|
+
matched_def,
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Option-level delegation (e.g., find -exec)
|
|
145
|
+
for opt_name, delegation_config, delegation_tokens in option_delegations:
|
|
146
|
+
inner_results = _handle_option_delegation(opt_name, delegation_config, delegation_tokens, database)
|
|
147
|
+
inner_commands.extend(inner_results)
|
|
148
|
+
|
|
149
|
+
# Inner command classifications affect the parent
|
|
150
|
+
for inner in inner_commands:
|
|
151
|
+
if inner.classification.severity() > final_classification.severity():
|
|
152
|
+
final_classification = Classification.max_severity(final_classification, inner.classification)
|
|
153
|
+
classification_reason = "elevated by inner command"
|
|
154
|
+
|
|
155
|
+
# Build remaining_options output
|
|
156
|
+
remaining_opts_output = list(unknown_options)
|
|
157
|
+
|
|
158
|
+
return CommandResult(
|
|
159
|
+
command=full_command,
|
|
160
|
+
argv=list(argv),
|
|
161
|
+
classification=final_classification,
|
|
162
|
+
matched_rule=matched_rule,
|
|
163
|
+
inner_commands=inner_commands,
|
|
164
|
+
ignored_options=ignored_options if ignored_options else None,
|
|
165
|
+
remaining_options=remaining_opts_output if remaining_opts_output else None,
|
|
166
|
+
classification_reason=classification_reason,
|
|
167
|
+
overriding_option=overriding_option,
|
|
168
|
+
directories=all_directories if all_directories else None,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _handle_directory_builtin(invocation: CommandInvocation) -> CommandResult:
|
|
173
|
+
"""Handle cd, pushd, popd builtins."""
|
|
174
|
+
return CommandResult(
|
|
175
|
+
command=[invocation.argv[0]],
|
|
176
|
+
argv=list(invocation.argv),
|
|
177
|
+
classification=Classification.READONLY,
|
|
178
|
+
matched_rule=None,
|
|
179
|
+
inner_commands=[],
|
|
180
|
+
classification_reason=f"shell builtin (always {Classification.READONLY.value})",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _handle_readonly_builtin(invocation: CommandInvocation) -> CommandResult:
|
|
185
|
+
"""Handle [, [[, test builtins — always READONLY."""
|
|
186
|
+
return CommandResult(
|
|
187
|
+
command=[invocation.argv[0]],
|
|
188
|
+
argv=list(invocation.argv),
|
|
189
|
+
classification=Classification.READONLY,
|
|
190
|
+
matched_rule=None,
|
|
191
|
+
inner_commands=[],
|
|
192
|
+
classification_reason=f"shell builtin (always {Classification.READONLY.value})",
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _handle_dangerous_builtin(invocation: CommandInvocation) -> CommandResult:
|
|
197
|
+
"""Handle eval, source, ., exec builtins."""
|
|
198
|
+
return CommandResult(
|
|
199
|
+
command=[invocation.argv[0]],
|
|
200
|
+
argv=list(invocation.argv),
|
|
201
|
+
classification=Classification.DANGEROUS,
|
|
202
|
+
matched_rule=None,
|
|
203
|
+
inner_commands=[],
|
|
204
|
+
classification_reason=f"shell builtin (always {Classification.DANGEROUS.value})",
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _strip_global_options(
|
|
209
|
+
argv: list[str],
|
|
210
|
+
command_def: CommandDef,
|
|
211
|
+
) -> tuple[list[str], list[str], list[str]]:
|
|
212
|
+
"""Strip global options from argv, only before the first subcommand.
|
|
213
|
+
|
|
214
|
+
Global options are consumed from the front of argv. Once a non-option token
|
|
215
|
+
is encountered (potential subcommand or positional arg), stripping stops and
|
|
216
|
+
all remaining tokens are passed through.
|
|
217
|
+
|
|
218
|
+
Returns (remaining_argv, ignored_options, directories).
|
|
219
|
+
"""
|
|
220
|
+
if not command_def.global_options:
|
|
221
|
+
return list(argv), [], []
|
|
222
|
+
|
|
223
|
+
remaining: list[str] = []
|
|
224
|
+
ignored: list[str] = []
|
|
225
|
+
directories: list[str] = []
|
|
226
|
+
i = 0
|
|
227
|
+
|
|
228
|
+
while i < len(argv):
|
|
229
|
+
token = argv[i]
|
|
230
|
+
|
|
231
|
+
# If the token doesn't start with -, it's a subcommand or positional arg.
|
|
232
|
+
# Stop stripping global options; pass the rest through.
|
|
233
|
+
if not token.startswith("-"):
|
|
234
|
+
remaining.extend(argv[i:])
|
|
235
|
+
break
|
|
236
|
+
|
|
237
|
+
# Check --key=value form
|
|
238
|
+
if "=" in token:
|
|
239
|
+
key = token.split("=", 1)[0]
|
|
240
|
+
opt_def = command_def.global_options.get(key)
|
|
241
|
+
if opt_def is not None:
|
|
242
|
+
ignored.append(token)
|
|
243
|
+
if opt_def.captures_directory:
|
|
244
|
+
directories.append(token.split("=", 1)[1])
|
|
245
|
+
i += 1
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
# Check exact match
|
|
249
|
+
opt_def = command_def.global_options.get(token)
|
|
250
|
+
if opt_def is not None:
|
|
251
|
+
ignored.append(token)
|
|
252
|
+
if opt_def.takes_value and i + 1 < len(argv):
|
|
253
|
+
i += 1
|
|
254
|
+
ignored.append(argv[i])
|
|
255
|
+
if opt_def.captures_directory:
|
|
256
|
+
directories.append(argv[i])
|
|
257
|
+
i += 1
|
|
258
|
+
continue
|
|
259
|
+
|
|
260
|
+
# Unknown option before subcommand — keep it and continue
|
|
261
|
+
remaining.append(token)
|
|
262
|
+
i += 1
|
|
263
|
+
|
|
264
|
+
return remaining, ignored, directories
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _match_subcommand(
|
|
268
|
+
argv: list[str],
|
|
269
|
+
command_def: CommandDef,
|
|
270
|
+
) -> tuple[CommandDef, list[str], list[str]]:
|
|
271
|
+
"""Match subcommand chain from argv.
|
|
272
|
+
|
|
273
|
+
Returns (matched_def, command_chain, remaining_argv).
|
|
274
|
+
"""
|
|
275
|
+
matched_def = command_def
|
|
276
|
+
command_chain: list[str] = []
|
|
277
|
+
remaining = list(argv)
|
|
278
|
+
|
|
279
|
+
while remaining:
|
|
280
|
+
token = remaining[0]
|
|
281
|
+
|
|
282
|
+
# Don't try to match options as subcommands
|
|
283
|
+
if token.startswith("-"):
|
|
284
|
+
break
|
|
285
|
+
|
|
286
|
+
if token in matched_def.subcommands:
|
|
287
|
+
command_chain.append(token)
|
|
288
|
+
matched_def = matched_def.subcommands[token]
|
|
289
|
+
remaining = remaining[1:]
|
|
290
|
+
else:
|
|
291
|
+
break
|
|
292
|
+
|
|
293
|
+
return matched_def, command_chain, remaining
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _classify_options(
|
|
297
|
+
argv: list[str],
|
|
298
|
+
command_def: CommandDef,
|
|
299
|
+
) -> tuple[
|
|
300
|
+
list[str], # known_options
|
|
301
|
+
list[str], # unknown_options
|
|
302
|
+
list[tuple[str, Classification]], # overrides: (option_name, classification)
|
|
303
|
+
list[str], # directories
|
|
304
|
+
list[tuple[str, DelegationConfig, list[str]]], # option_delegations
|
|
305
|
+
list[str], # remaining_positional (non-option tokens)
|
|
306
|
+
]:
|
|
307
|
+
"""Classify options in remaining argv against the matched command's options."""
|
|
308
|
+
known: list[str] = []
|
|
309
|
+
unknown: list[str] = []
|
|
310
|
+
overrides: list[tuple[str, Classification]] = []
|
|
311
|
+
directories: list[str] = []
|
|
312
|
+
delegations: list[tuple[str, DelegationConfig, list[str]]] = []
|
|
313
|
+
positional: list[str] = []
|
|
314
|
+
options = command_def.options
|
|
315
|
+
|
|
316
|
+
# For rest_are_argv delegation: once we hit the first positional arg,
|
|
317
|
+
# everything from that point is the inner command (not our options).
|
|
318
|
+
stop_at_first_positional = (
|
|
319
|
+
command_def.delegates_to is not None and command_def.delegates_to.mode == DelegationMode.REST_ARE_ARGV
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
i = 0
|
|
323
|
+
end_of_options = False
|
|
324
|
+
|
|
325
|
+
while i < len(argv):
|
|
326
|
+
token = argv[i]
|
|
327
|
+
|
|
328
|
+
# End of options marker
|
|
329
|
+
if token == "--":
|
|
330
|
+
end_of_options = True
|
|
331
|
+
positional.append(token)
|
|
332
|
+
i += 1
|
|
333
|
+
# Everything after -- is positional
|
|
334
|
+
while i < len(argv):
|
|
335
|
+
positional.append(argv[i])
|
|
336
|
+
i += 1
|
|
337
|
+
break
|
|
338
|
+
|
|
339
|
+
if end_of_options or not token.startswith("-"):
|
|
340
|
+
if stop_at_first_positional:
|
|
341
|
+
# Everything from here is the inner command
|
|
342
|
+
positional.extend(argv[i:])
|
|
343
|
+
break
|
|
344
|
+
positional.append(token)
|
|
345
|
+
i += 1
|
|
346
|
+
continue
|
|
347
|
+
|
|
348
|
+
# Handle --key=value form
|
|
349
|
+
if token.startswith("--") and "=" in token:
|
|
350
|
+
key = token.split("=", 1)[0]
|
|
351
|
+
value = token.split("=", 1)[1]
|
|
352
|
+
opt_def = options.get(key)
|
|
353
|
+
if opt_def is not None:
|
|
354
|
+
known.append(token)
|
|
355
|
+
if opt_def.overrides is not None:
|
|
356
|
+
overrides.append((key, opt_def.overrides))
|
|
357
|
+
if opt_def.captures_directory:
|
|
358
|
+
directories.append(value)
|
|
359
|
+
else:
|
|
360
|
+
unknown.append(token)
|
|
361
|
+
i += 1
|
|
362
|
+
continue
|
|
363
|
+
|
|
364
|
+
# Handle long options (--flag or --key value)
|
|
365
|
+
if token.startswith("--"):
|
|
366
|
+
opt_def = options.get(token)
|
|
367
|
+
if opt_def is not None:
|
|
368
|
+
known.append(token)
|
|
369
|
+
if opt_def.overrides is not None:
|
|
370
|
+
overrides.append((token, opt_def.overrides))
|
|
371
|
+
if opt_def.delegates_to is not None:
|
|
372
|
+
delegation_tokens = _extract_delegation_tokens(argv, i, opt_def.delegates_to)
|
|
373
|
+
delegations.append((token, opt_def.delegates_to, delegation_tokens))
|
|
374
|
+
# Skip past the delegation tokens
|
|
375
|
+
i = _skip_delegation_tokens(argv, i, opt_def.delegates_to)
|
|
376
|
+
continue
|
|
377
|
+
if opt_def.takes_value and i + 1 < len(argv):
|
|
378
|
+
i += 1
|
|
379
|
+
value = argv[i]
|
|
380
|
+
known.append(value)
|
|
381
|
+
if opt_def.captures_directory:
|
|
382
|
+
directories.append(value)
|
|
383
|
+
else:
|
|
384
|
+
unknown.append(token)
|
|
385
|
+
i += 1
|
|
386
|
+
continue
|
|
387
|
+
|
|
388
|
+
# Handle short options (single char like -f, or combined -abc, or -fvalue)
|
|
389
|
+
# First try exact match for the full token (e.g., -it, -delete, -exec)
|
|
390
|
+
opt_def = options.get(token)
|
|
391
|
+
if opt_def is not None:
|
|
392
|
+
known.append(token)
|
|
393
|
+
if opt_def.overrides is not None:
|
|
394
|
+
overrides.append((token, opt_def.overrides))
|
|
395
|
+
if opt_def.delegates_to is not None:
|
|
396
|
+
delegation_tokens = _extract_delegation_tokens(argv, i, opt_def.delegates_to)
|
|
397
|
+
delegations.append((token, opt_def.delegates_to, delegation_tokens))
|
|
398
|
+
i = _skip_delegation_tokens(argv, i, opt_def.delegates_to)
|
|
399
|
+
continue
|
|
400
|
+
if opt_def.takes_value and i + 1 < len(argv):
|
|
401
|
+
i += 1
|
|
402
|
+
value = argv[i]
|
|
403
|
+
known.append(value)
|
|
404
|
+
if opt_def.captures_directory:
|
|
405
|
+
directories.append(value)
|
|
406
|
+
i += 1
|
|
407
|
+
continue
|
|
408
|
+
|
|
409
|
+
# Try single-character short option prefix match
|
|
410
|
+
# e.g., -n5 where -n takes a value
|
|
411
|
+
if len(token) >= 2 and token[0] == "-" and token[1] != "-":
|
|
412
|
+
short_flag = f"-{token[1]}"
|
|
413
|
+
opt_def = options.get(short_flag)
|
|
414
|
+
|
|
415
|
+
if opt_def is not None and opt_def.takes_value and len(token) > 2:
|
|
416
|
+
# Joined short option with value: -fvalue
|
|
417
|
+
known.append(token)
|
|
418
|
+
value = token[2:]
|
|
419
|
+
if opt_def.overrides is not None:
|
|
420
|
+
overrides.append((short_flag, opt_def.overrides))
|
|
421
|
+
if opt_def.captures_directory:
|
|
422
|
+
directories.append(value)
|
|
423
|
+
i += 1
|
|
424
|
+
continue
|
|
425
|
+
|
|
426
|
+
if opt_def is not None and not opt_def.takes_value and len(token) > 2:
|
|
427
|
+
# Combined short options: -abc -> -a -b -c
|
|
428
|
+
# If a middle flag takes_value, remaining chars are its joined value.
|
|
429
|
+
all_known = True
|
|
430
|
+
pending_overrides: list[tuple[str, Classification]] = []
|
|
431
|
+
for j in range(1, len(token)):
|
|
432
|
+
char_flag = f"-{token[j]}"
|
|
433
|
+
char_def = options.get(char_flag)
|
|
434
|
+
if char_def is None:
|
|
435
|
+
all_known = False
|
|
436
|
+
break
|
|
437
|
+
if char_def.takes_value:
|
|
438
|
+
# This flag takes a value: remaining chars are the joined value
|
|
439
|
+
if char_def.overrides is not None:
|
|
440
|
+
pending_overrides.append((char_flag, char_def.overrides))
|
|
441
|
+
remaining_chars = token[j + 1 :]
|
|
442
|
+
if remaining_chars:
|
|
443
|
+
# Joined value from remaining characters
|
|
444
|
+
if char_def.captures_directory:
|
|
445
|
+
directories.append(remaining_chars)
|
|
446
|
+
else:
|
|
447
|
+
# Consume next argv token as the value
|
|
448
|
+
if i + 1 < len(argv):
|
|
449
|
+
i += 1
|
|
450
|
+
known.append(argv[i])
|
|
451
|
+
if char_def.captures_directory:
|
|
452
|
+
directories.append(argv[i])
|
|
453
|
+
break
|
|
454
|
+
if char_def.overrides is not None:
|
|
455
|
+
pending_overrides.append((char_flag, char_def.overrides))
|
|
456
|
+
|
|
457
|
+
if all_known:
|
|
458
|
+
known.append(token)
|
|
459
|
+
overrides.extend(pending_overrides)
|
|
460
|
+
i += 1
|
|
461
|
+
continue
|
|
462
|
+
|
|
463
|
+
# Single char short option with separate value
|
|
464
|
+
if opt_def is not None:
|
|
465
|
+
known.append(token)
|
|
466
|
+
if opt_def.overrides is not None:
|
|
467
|
+
overrides.append((short_flag, opt_def.overrides))
|
|
468
|
+
if opt_def.takes_value and i + 1 < len(argv):
|
|
469
|
+
i += 1
|
|
470
|
+
value = argv[i]
|
|
471
|
+
known.append(value)
|
|
472
|
+
if opt_def.captures_directory:
|
|
473
|
+
directories.append(value)
|
|
474
|
+
i += 1
|
|
475
|
+
continue
|
|
476
|
+
|
|
477
|
+
# Unknown option
|
|
478
|
+
unknown.append(token)
|
|
479
|
+
i += 1
|
|
480
|
+
|
|
481
|
+
return known, unknown, overrides, directories, delegations, positional
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _is_terminator(token: str, terminator: str | None) -> bool:
|
|
485
|
+
"""Check if a token matches a terminator, accounting for backslash-escaped forms.
|
|
486
|
+
|
|
487
|
+
Tree-sitter preserves backslash escapes, so \\; in the parsed argv
|
|
488
|
+
should match a terminator of ";".
|
|
489
|
+
"""
|
|
490
|
+
if terminator is None:
|
|
491
|
+
return False
|
|
492
|
+
if token == terminator:
|
|
493
|
+
return True
|
|
494
|
+
# Handle backslash-escaped form: \; matches ;
|
|
495
|
+
return token == "\\" + terminator
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _extract_delegation_tokens(
|
|
499
|
+
argv: list[str],
|
|
500
|
+
option_index: int,
|
|
501
|
+
delegation: DelegationConfig,
|
|
502
|
+
) -> list[str]:
|
|
503
|
+
"""Extract the tokens that form the inner command for option-level delegation."""
|
|
504
|
+
if delegation.mode == DelegationMode.TERMINATED_ARGV:
|
|
505
|
+
# Tokens between the option and the terminator
|
|
506
|
+
tokens: list[str] = []
|
|
507
|
+
i = option_index + 1
|
|
508
|
+
while i < len(argv):
|
|
509
|
+
if _is_terminator(argv[i], delegation.terminator):
|
|
510
|
+
break
|
|
511
|
+
tokens.append(argv[i])
|
|
512
|
+
i += 1
|
|
513
|
+
return tokens
|
|
514
|
+
return []
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _skip_delegation_tokens(
|
|
518
|
+
argv: list[str],
|
|
519
|
+
option_index: int,
|
|
520
|
+
delegation: DelegationConfig,
|
|
521
|
+
) -> int:
|
|
522
|
+
"""Return the index past the delegation tokens (including terminator)."""
|
|
523
|
+
if delegation.mode == DelegationMode.TERMINATED_ARGV:
|
|
524
|
+
i = option_index + 1
|
|
525
|
+
while i < len(argv):
|
|
526
|
+
if _is_terminator(argv[i], delegation.terminator):
|
|
527
|
+
return i + 1
|
|
528
|
+
i += 1
|
|
529
|
+
return len(argv)
|
|
530
|
+
return option_index + 1
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _handle_delegation(
|
|
534
|
+
remaining_positional: list[str],
|
|
535
|
+
delegation: DelegationConfig,
|
|
536
|
+
database: Mapping[str, CommandDef],
|
|
537
|
+
full_argv: list[str],
|
|
538
|
+
command_def: CommandDef,
|
|
539
|
+
) -> list[InnerCommandResult]:
|
|
540
|
+
"""Handle command-level delegation (rest_are_argv, after_separator, flag_value_is_expression)."""
|
|
541
|
+
results: list[InnerCommandResult] = []
|
|
542
|
+
|
|
543
|
+
if delegation.mode == DelegationMode.REST_ARE_ARGV:
|
|
544
|
+
inner_argv = list(remaining_positional)
|
|
545
|
+
|
|
546
|
+
if delegation.strip_assignments:
|
|
547
|
+
# Strip leading KEY=VALUE tokens
|
|
548
|
+
while inner_argv and _ASSIGNMENT_RE.match(inner_argv[0]):
|
|
549
|
+
inner_argv = inner_argv[1:]
|
|
550
|
+
|
|
551
|
+
if inner_argv:
|
|
552
|
+
result = _match_inner_command(
|
|
553
|
+
inner_argv,
|
|
554
|
+
database,
|
|
555
|
+
delegation_mode="rest_are_argv",
|
|
556
|
+
delegation_source=command_def.command,
|
|
557
|
+
min_classification=delegation.min_classification,
|
|
558
|
+
)
|
|
559
|
+
results.append(result)
|
|
560
|
+
|
|
561
|
+
elif delegation.mode == DelegationMode.AFTER_SEPARATOR:
|
|
562
|
+
separator = delegation.separator or "--"
|
|
563
|
+
# Find separator in remaining positional args
|
|
564
|
+
# But we need to look at full_argv to find separator
|
|
565
|
+
# since positional args include everything after options
|
|
566
|
+
sep_index = None
|
|
567
|
+
for idx, token in enumerate(remaining_positional):
|
|
568
|
+
if token == separator:
|
|
569
|
+
sep_index = idx
|
|
570
|
+
break
|
|
571
|
+
|
|
572
|
+
if sep_index is not None:
|
|
573
|
+
inner_argv = remaining_positional[sep_index + 1 :]
|
|
574
|
+
if inner_argv:
|
|
575
|
+
result = _match_inner_command(
|
|
576
|
+
inner_argv,
|
|
577
|
+
database,
|
|
578
|
+
delegation_mode="after_separator",
|
|
579
|
+
delegation_source=separator,
|
|
580
|
+
min_classification=delegation.min_classification,
|
|
581
|
+
)
|
|
582
|
+
results.append(result)
|
|
583
|
+
|
|
584
|
+
elif delegation.mode == DelegationMode.FLAG_VALUE_IS_EXPRESSION:
|
|
585
|
+
flag = delegation.flag
|
|
586
|
+
if flag is None:
|
|
587
|
+
return results
|
|
588
|
+
|
|
589
|
+
# Find the flag value in the original argv
|
|
590
|
+
expression_value = _find_flag_value(full_argv, flag)
|
|
591
|
+
if expression_value is not None:
|
|
592
|
+
# Parse the expression recursively
|
|
593
|
+
from .parser import parse_expression
|
|
594
|
+
|
|
595
|
+
inner_invocations, _warnings = parse_expression(expression_value)
|
|
596
|
+
for inv in inner_invocations:
|
|
597
|
+
result = _match_inner_command(
|
|
598
|
+
inv.argv,
|
|
599
|
+
database,
|
|
600
|
+
delegation_mode="flag_value_is_expression",
|
|
601
|
+
delegation_source=flag,
|
|
602
|
+
min_classification=delegation.min_classification,
|
|
603
|
+
)
|
|
604
|
+
results.append(result)
|
|
605
|
+
|
|
606
|
+
return results
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _handle_option_delegation(
|
|
610
|
+
option_name: str,
|
|
611
|
+
delegation: DelegationConfig,
|
|
612
|
+
delegation_tokens: list[str],
|
|
613
|
+
database: Mapping[str, CommandDef],
|
|
614
|
+
) -> list[InnerCommandResult]:
|
|
615
|
+
"""Handle option-level delegation (e.g., find -exec)."""
|
|
616
|
+
if delegation.mode == DelegationMode.TERMINATED_ARGV:
|
|
617
|
+
# Strip {} placeholders
|
|
618
|
+
inner_argv = [t for t in delegation_tokens if t != "{}"]
|
|
619
|
+
if inner_argv:
|
|
620
|
+
result = _match_inner_command(
|
|
621
|
+
inner_argv,
|
|
622
|
+
database,
|
|
623
|
+
delegation_mode="terminated_argv",
|
|
624
|
+
delegation_source=option_name,
|
|
625
|
+
min_classification=delegation.min_classification,
|
|
626
|
+
)
|
|
627
|
+
return [result]
|
|
628
|
+
return []
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _match_inner_command(
|
|
632
|
+
argv: list[str],
|
|
633
|
+
database: Mapping[str, CommandDef],
|
|
634
|
+
*,
|
|
635
|
+
delegation_mode: str,
|
|
636
|
+
delegation_source: str,
|
|
637
|
+
min_classification: Classification | None = None,
|
|
638
|
+
) -> InnerCommandResult:
|
|
639
|
+
"""Recursively match an inner command and return an InnerCommandResult."""
|
|
640
|
+
# Create a synthetic invocation for the inner command
|
|
641
|
+
inner_invocation = CommandInvocation(
|
|
642
|
+
argv=argv,
|
|
643
|
+
redirects=[],
|
|
644
|
+
position_in_pipeline=0,
|
|
645
|
+
pipeline_length=1,
|
|
646
|
+
context="toplevel",
|
|
647
|
+
operator_before=None,
|
|
648
|
+
is_background=False,
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
# Recursively match
|
|
652
|
+
inner_result = match_command(inner_invocation, database)
|
|
653
|
+
|
|
654
|
+
classification = inner_result.classification
|
|
655
|
+
if min_classification is not None:
|
|
656
|
+
classification = Classification.max_severity(classification, min_classification)
|
|
657
|
+
|
|
658
|
+
return InnerCommandResult(
|
|
659
|
+
delegation_mode=delegation_mode,
|
|
660
|
+
delegation_source=delegation_source,
|
|
661
|
+
command=inner_result.command,
|
|
662
|
+
argv=list(argv),
|
|
663
|
+
classification=classification,
|
|
664
|
+
matched_rule=inner_result.matched_rule,
|
|
665
|
+
inner_commands=inner_result.inner_commands,
|
|
666
|
+
ignored_options=inner_result.ignored_options,
|
|
667
|
+
remaining_options=inner_result.remaining_options,
|
|
668
|
+
overriding_option=inner_result.overriding_option,
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _find_flag_value(
|
|
673
|
+
argv: list[str],
|
|
674
|
+
flag: str,
|
|
675
|
+
) -> str | None:
|
|
676
|
+
"""Find the value of a flag in argv."""
|
|
677
|
+
value: str | None = None
|
|
678
|
+
for i, token in enumerate(argv):
|
|
679
|
+
if token == flag and i + 1 < len(argv):
|
|
680
|
+
value = argv[i + 1]
|
|
681
|
+
break
|
|
682
|
+
# Handle --flag=value form
|
|
683
|
+
if token.startswith(flag + "="):
|
|
684
|
+
value = token[len(flag) + 1 :]
|
|
685
|
+
break
|
|
686
|
+
|
|
687
|
+
if value is not None:
|
|
688
|
+
value = _strip_quotes(value)
|
|
689
|
+
|
|
690
|
+
return value
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _strip_quotes(s: str) -> str:
|
|
694
|
+
"""Strip surrounding quotes from a string.
|
|
695
|
+
|
|
696
|
+
Tree-sitter preserves shell quotes in parsed tokens, but when we need to
|
|
697
|
+
recursively parse an expression (e.g. the value of sh -c "..."), we must
|
|
698
|
+
remove the outer quotes first.
|
|
699
|
+
"""
|
|
700
|
+
if len(s) >= 2 and ((s[0] == '"' and s[-1] == '"') or (s[0] == "'" and s[-1] == "'")):
|
|
701
|
+
return s[1:-1]
|
|
702
|
+
return s
|