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.
Files changed (142) hide show
  1. bash_classify/__init__.py +14 -0
  2. bash_classify/__main__.py +3 -0
  3. bash_classify/classifier.py +244 -0
  4. bash_classify/cli.py +110 -0
  5. bash_classify/commands/apt.yaml +23 -0
  6. bash_classify/commands/awk.yaml +5 -0
  7. bash_classify/commands/basename.yaml +5 -0
  8. bash_classify/commands/bash.yaml +18 -0
  9. bash_classify/commands/brew.yaml +25 -0
  10. bash_classify/commands/builtin.yaml +6 -0
  11. bash_classify/commands/cargo.yaml +29 -0
  12. bash_classify/commands/cat.yaml +5 -0
  13. bash_classify/commands/chgrp.yaml +8 -0
  14. bash_classify/commands/chmod.yaml +8 -0
  15. bash_classify/commands/chown.yaml +8 -0
  16. bash_classify/commands/column.yaml +5 -0
  17. bash_classify/commands/comm.yaml +5 -0
  18. bash_classify/commands/command.yaml +10 -0
  19. bash_classify/commands/cp.yaml +5 -0
  20. bash_classify/commands/curl.yaml +28 -0
  21. bash_classify/commands/cut.yaml +5 -0
  22. bash_classify/commands/date.yaml +8 -0
  23. bash_classify/commands/df.yaml +5 -0
  24. bash_classify/commands/diff.yaml +5 -0
  25. bash_classify/commands/dig.yaml +5 -0
  26. bash_classify/commands/dirname.yaml +5 -0
  27. bash_classify/commands/docker.yaml +152 -0
  28. bash_classify/commands/du.yaml +5 -0
  29. bash_classify/commands/echo.yaml +5 -0
  30. bash_classify/commands/env.yaml +14 -0
  31. bash_classify/commands/expand.yaml +5 -0
  32. bash_classify/commands/false.yaml +5 -0
  33. bash_classify/commands/file.yaml +5 -0
  34. bash_classify/commands/find.yaml +27 -0
  35. bash_classify/commands/fmt.yaml +5 -0
  36. bash_classify/commands/fold.yaml +5 -0
  37. bash_classify/commands/free.yaml +5 -0
  38. bash_classify/commands/gawk.yaml +5 -0
  39. bash_classify/commands/git.yaml +441 -0
  40. bash_classify/commands/go.yaml +38 -0
  41. bash_classify/commands/grep.yaml +5 -0
  42. bash_classify/commands/groups.yaml +5 -0
  43. bash_classify/commands/gunzip.yaml +5 -0
  44. bash_classify/commands/gzip.yaml +5 -0
  45. bash_classify/commands/head.yaml +5 -0
  46. bash_classify/commands/helm.yaml +127 -0
  47. bash_classify/commands/hostname.yaml +5 -0
  48. bash_classify/commands/id.yaml +5 -0
  49. bash_classify/commands/install.yaml +5 -0
  50. bash_classify/commands/join.yaml +5 -0
  51. bash_classify/commands/journalctl.yaml +12 -0
  52. bash_classify/commands/jq.yaml +5 -0
  53. bash_classify/commands/kill.yaml +5 -0
  54. bash_classify/commands/killall.yaml +5 -0
  55. bash_classify/commands/kubectl.yaml +190 -0
  56. bash_classify/commands/less.yaml +5 -0
  57. bash_classify/commands/ln.yaml +5 -0
  58. bash_classify/commands/ls.yaml +5 -0
  59. bash_classify/commands/make.yaml +19 -0
  60. bash_classify/commands/md5sum.yaml +5 -0
  61. bash_classify/commands/mkdir.yaml +5 -0
  62. bash_classify/commands/more.yaml +5 -0
  63. bash_classify/commands/mv.yaml +5 -0
  64. bash_classify/commands/netstat.yaml +5 -0
  65. bash_classify/commands/nice.yaml +9 -0
  66. bash_classify/commands/nl.yaml +5 -0
  67. bash_classify/commands/node.yaml +5 -0
  68. bash_classify/commands/nohup.yaml +6 -0
  69. bash_classify/commands/npm.yaml +65 -0
  70. bash_classify/commands/nslookup.yaml +5 -0
  71. bash_classify/commands/paste.yaml +5 -0
  72. bash_classify/commands/ping.yaml +5 -0
  73. bash_classify/commands/pip.yaml +29 -0
  74. bash_classify/commands/pip3.yaml +29 -0
  75. bash_classify/commands/pkill.yaml +5 -0
  76. bash_classify/commands/poetry.yaml +74 -0
  77. bash_classify/commands/printenv.yaml +5 -0
  78. bash_classify/commands/printf.yaml +5 -0
  79. bash_classify/commands/ps.yaml +5 -0
  80. bash_classify/commands/pwd.yaml +5 -0
  81. bash_classify/commands/python.yaml +5 -0
  82. bash_classify/commands/python3.yaml +5 -0
  83. bash_classify/commands/read.yaml +5 -0
  84. bash_classify/commands/readlink.yaml +5 -0
  85. bash_classify/commands/realpath.yaml +5 -0
  86. bash_classify/commands/rev.yaml +5 -0
  87. bash_classify/commands/rm.yaml +5 -0
  88. bash_classify/commands/rsync.yaml +10 -0
  89. bash_classify/commands/scp.yaml +5 -0
  90. bash_classify/commands/sed.yaml +12 -0
  91. bash_classify/commands/seq.yaml +5 -0
  92. bash_classify/commands/sh.yaml +12 -0
  93. bash_classify/commands/sha1sum.yaml +5 -0
  94. bash_classify/commands/sha256sum.yaml +5 -0
  95. bash_classify/commands/sort.yaml +8 -0
  96. bash_classify/commands/ss.yaml +5 -0
  97. bash_classify/commands/ssh.yaml +39 -0
  98. bash_classify/commands/stat.yaml +5 -0
  99. bash_classify/commands/strace.yaml +18 -0
  100. bash_classify/commands/sudo.yaml +22 -0
  101. bash_classify/commands/systemctl.yaml +56 -0
  102. bash_classify/commands/tac.yaml +5 -0
  103. bash_classify/commands/tail.yaml +5 -0
  104. bash_classify/commands/tar.yaml +22 -0
  105. bash_classify/commands/tee.yaml +5 -0
  106. bash_classify/commands/terraform.yaml +68 -0
  107. bash_classify/commands/test.yaml +5 -0
  108. bash_classify/commands/time.yaml +6 -0
  109. bash_classify/commands/timeout.yaml +15 -0
  110. bash_classify/commands/top.yaml +5 -0
  111. bash_classify/commands/touch.yaml +5 -0
  112. bash_classify/commands/tr.yaml +5 -0
  113. bash_classify/commands/traceroute.yaml +5 -0
  114. bash_classify/commands/true.yaml +5 -0
  115. bash_classify/commands/type.yaml +5 -0
  116. bash_classify/commands/uname.yaml +5 -0
  117. bash_classify/commands/unexpand.yaml +5 -0
  118. bash_classify/commands/uniq.yaml +5 -0
  119. bash_classify/commands/unzip.yaml +7 -0
  120. bash_classify/commands/uptime.yaml +5 -0
  121. bash_classify/commands/uv.yaml +117 -0
  122. bash_classify/commands/wc.yaml +5 -0
  123. bash_classify/commands/wget.yaml +5 -0
  124. bash_classify/commands/whereis.yaml +5 -0
  125. bash_classify/commands/which.yaml +5 -0
  126. bash_classify/commands/whoami.yaml +5 -0
  127. bash_classify/commands/xargs.yaml +25 -0
  128. bash_classify/commands/xmllint.yaml +5 -0
  129. bash_classify/commands/yes.yaml +5 -0
  130. bash_classify/commands/yq.yaml +8 -0
  131. bash_classify/commands/zip.yaml +5 -0
  132. bash_classify/commands/zsh.yaml +12 -0
  133. bash_classify/database.py +257 -0
  134. bash_classify/matcher.py +702 -0
  135. bash_classify/models.py +148 -0
  136. bash_classify/parser.py +525 -0
  137. bash_classify/py.typed +0 -0
  138. bash_classify-0.1.0.dist-info/METADATA +147 -0
  139. bash_classify-0.1.0.dist-info/RECORD +142 -0
  140. bash_classify-0.1.0.dist-info/WHEEL +4 -0
  141. bash_classify-0.1.0.dist-info/entry_points.txt +2 -0
  142. bash_classify-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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