minimalcode 1.5.2__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 (58) hide show
  1. minimalcode/__init__.py +3 -0
  2. minimalcode/__main__.py +3 -0
  3. minimalcode/agent.py +1084 -0
  4. minimalcode/cli.py +1520 -0
  5. minimalcode/clipboard.py +212 -0
  6. minimalcode/config.py +1065 -0
  7. minimalcode/defaults.json +491 -0
  8. minimalcode/errors.py +31 -0
  9. minimalcode/highlight/__init__.py +47 -0
  10. minimalcode/highlight/code.py +261 -0
  11. minimalcode/highlight/color.py +58 -0
  12. minimalcode/highlight/markdown.py +710 -0
  13. minimalcode/highlight/palette.py +73 -0
  14. minimalcode/highlight/sanitize.py +175 -0
  15. minimalcode/highlight/thinking.py +71 -0
  16. minimalcode/httpclient.py +278 -0
  17. minimalcode/importer.py +253 -0
  18. minimalcode/prompt.py +195 -0
  19. minimalcode/provider/__init__.py +28 -0
  20. minimalcode/provider/anthropic.py +805 -0
  21. minimalcode/provider/anthropic_env.py +161 -0
  22. minimalcode/provider/chat.py +394 -0
  23. minimalcode/provider/client.py +243 -0
  24. minimalcode/provider/common.py +176 -0
  25. minimalcode/provider/deepseek_web.py +1158 -0
  26. minimalcode/provider/factory.py +63 -0
  27. minimalcode/provider/responses.py +692 -0
  28. minimalcode/provider/sha3_wasm_bg.wasm +0 -0
  29. minimalcode/provider/transport.py +241 -0
  30. minimalcode/provider/types.py +54 -0
  31. minimalcode/sandbox/__init__.py +64 -0
  32. minimalcode/sandbox/audit.py +120 -0
  33. minimalcode/sandbox/backends.py +343 -0
  34. minimalcode/sandbox/execution.py +435 -0
  35. minimalcode/sandbox/network.py +793 -0
  36. minimalcode/sandbox/policy.py +253 -0
  37. minimalcode/sandbox/runner.py +347 -0
  38. minimalcode/sandbox/sensitive.py +286 -0
  39. minimalcode/session.py +465 -0
  40. minimalcode/shell.py +333 -0
  41. minimalcode/subagent.py +120 -0
  42. minimalcode/tools/__init__.py +46 -0
  43. minimalcode/tools/files.py +1139 -0
  44. minimalcode/tools/process.py +164 -0
  45. minimalcode/tools/remote.py +74 -0
  46. minimalcode/tools/runtime.py +219 -0
  47. minimalcode/tools/schemas.py +418 -0
  48. minimalcode/tools/search.py +521 -0
  49. minimalcode/tools/search_tools.py +583 -0
  50. minimalcode/tools/validation.py +99 -0
  51. minimalcode/vision.py +231 -0
  52. minimalcode/web.py +238 -0
  53. minimalcode-1.5.2.dist-info/METADATA +646 -0
  54. minimalcode-1.5.2.dist-info/RECORD +58 -0
  55. minimalcode-1.5.2.dist-info/WHEEL +5 -0
  56. minimalcode-1.5.2.dist-info/entry_points.txt +2 -0
  57. minimalcode-1.5.2.dist-info/licenses/LICENSE +201 -0
  58. minimalcode-1.5.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,793 @@
1
+ """Fail-closed network heuristics for shell commands.
2
+
3
+ The local backend has no network namespace, so a command string is inspected
4
+ before execution. Backends that can isolate the network do it for real; this
5
+ gate is what makes the default ``deny`` meaningful on every platform.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import binascii
12
+ import re
13
+ from urllib.parse import urlsplit
14
+
15
+ _NETWORK_COMMANDS = frozenset(
16
+ {
17
+ "curl",
18
+ "wget",
19
+ "aria2c",
20
+ "nc",
21
+ "ncat",
22
+ "netcat",
23
+ "socat",
24
+ "ssh",
25
+ "scp",
26
+ "sftp",
27
+ "rsync",
28
+ "ftp",
29
+ "telnet",
30
+ "ping",
31
+ "dig",
32
+ "nslookup",
33
+ "tracert",
34
+ "traceroute",
35
+ "iwr",
36
+ "irm",
37
+ "invoke-webrequest",
38
+ "invoke-restmethod",
39
+ "start-bitstransfer",
40
+ "bitsadmin",
41
+ # `npx`/`bunx` always resolve a package before running it, which reaches
42
+ # the registry even though neither name looks like a download client.
43
+ "npx",
44
+ "bunx",
45
+ }
46
+ )
47
+ # Programs whose subcommand, not name, decides whether they reach the network.
48
+ # Options before the subcommand are skipped, so `pip -q install x` and
49
+ # `git -C . clone x` are still recognized instead of hiding the subcommand.
50
+ _NETWORK_SUBCOMMANDS: dict[str, frozenset[str]] = {
51
+ "git": frozenset({"clone", "fetch", "pull", "push", "ls-remote", "submodule"}),
52
+ "pip": frozenset({"install", "download", "search", "index"}),
53
+ "pip3": frozenset({"install", "download", "search", "index"}),
54
+ "npm": frozenset({"install", "add", "i", "ci", "exec", "x"}),
55
+ "pnpm": frozenset({"install", "add", "i", "dlx", "update"}),
56
+ "yarn": frozenset({"install", "add", "dlx", "upgrade"}),
57
+ "cargo": frozenset({"install", "add", "update"}),
58
+ "gem": frozenset({"install", "update"}),
59
+ "go": frozenset({"get", "install"}),
60
+ "composer": frozenset({"install", "add", "require", "update"}),
61
+ "dotnet": frozenset({"add", "restore", "tool"}),
62
+ "apt": frozenset({"install", "update", "upgrade", "dist-upgrade"}),
63
+ "apt-get": frozenset({"install", "update", "upgrade", "dist-upgrade"}),
64
+ "brew": frozenset({"install", "update", "upgrade", "tap"}),
65
+ "dnf": frozenset({"install", "update", "upgrade"}),
66
+ "yum": frozenset({"install", "update", "upgrade"}),
67
+ "apk": frozenset({"add", "update", "upgrade"}),
68
+ "docker": frozenset({"pull", "push", "login", "run", "build"}),
69
+ "uv": frozenset({"add", "sync", "install", "run"}),
70
+ "poetry": frozenset({"add", "install", "update"}),
71
+ "openssl": frozenset({"s_client", "s_server"}),
72
+ }
73
+ # Options that consume the following token. Only the common ones are listed;
74
+ # an unknown option is skipped alone, which is enough for the subcommand to
75
+ # surface after at most a few tokens.
76
+ _SUBCOMMAND_OPTION_VALUES: dict[str, frozenset[str]] = {
77
+ "git": frozenset(
78
+ {
79
+ "-C",
80
+ "-c",
81
+ "--git-dir",
82
+ "--work-tree",
83
+ "--namespace",
84
+ "--exec-path",
85
+ "--config-env",
86
+ }
87
+ ),
88
+ "pip": frozenset(
89
+ {
90
+ "-r",
91
+ "--requirement",
92
+ "-e",
93
+ "--editable",
94
+ "-t",
95
+ "--target",
96
+ "-c",
97
+ "--constraint",
98
+ "-i",
99
+ "--index-url",
100
+ "--extra-index-url",
101
+ "-f",
102
+ "--find-links",
103
+ "--trusted-host",
104
+ "--python-version",
105
+ }
106
+ ),
107
+ "npm": frozenset(
108
+ {
109
+ "--prefix",
110
+ "--registry",
111
+ "--cache",
112
+ "--userconfig",
113
+ "--globalconfig",
114
+ "--workspace",
115
+ "-w",
116
+ }
117
+ ),
118
+ "pnpm": frozenset({"--dir", "-C", "--filter", "-F"}),
119
+ "yarn": frozenset({"--cwd", "--modules-folder"}),
120
+ "uv": frozenset({"--directory", "--project", "--cache-dir"}),
121
+ "docker": frozenset({"-H", "--host", "--context", "-c"}),
122
+ }
123
+ _SUBCOMMAND_OPTION_VALUES["pip3"] = _SUBCOMMAND_OPTION_VALUES["pip"]
124
+ # Interpreter-only checks. `match` (not `search`) keeps `echo git clone` and
125
+ # `rg "pip install"` from matching.
126
+ _COMMAND_SEQUENCES = (
127
+ re.compile(
128
+ # `py -3 -m pip`, `python3 -mpip`, and `python -m pip3` all reach the
129
+ # index; the version selector and the attached `-m` spelling were the
130
+ # gaps that let a deny policy install packages.
131
+ r"\b(?:python[0-9.]*|py(?:\.exe)?)(?:\s+-[0-9.]+)*\s+-\s*m\s*pip[0-9.]*\b"
132
+ r"[^|;&\n]*?\b(?:install|download|search|index)\b",
133
+ re.IGNORECASE,
134
+ ),
135
+ re.compile(r"\bpython[0-9.]*\s+-m\s+(?:http\.server|requests)\b", re.IGNORECASE),
136
+ )
137
+ # Code that reaches the network from inside an inline `python -c`/`node -e`
138
+ # script. It is searched only in nested command strings, never in the outer
139
+ # command, so `rg "urllib.request"` is not misread as a network call.
140
+ _NESTED_NETWORK_API = re.compile(
141
+ r"\b(?:requests|urllib3?|httpx|http\.client|ftplib|socket|urlopen|urlretrieve"
142
+ # PowerShell's download stubs: `(New-Object Net.WebClient).DownloadString()`
143
+ # is the common Windows one-liner, and it names no network client at all.
144
+ r"|webclient|downloadstring|downloadfile"
145
+ r")\b",
146
+ re.IGNORECASE,
147
+ )
148
+ # The PowerShell download stub at the outer command position. `_NESTED_NETWORK_API`
149
+ # only runs on nested payloads, so `(New-Object Net.WebClient).DownloadString(...)`
150
+ # typed directly into the shell (which *is* PowerShell on Windows) slipped past
151
+ # the gate. The pattern stays narrow: it needs `New-Object ...WebClient` or a
152
+ # `DownloadString(`/`DownloadFile(` call.
153
+ _POWERSHELL_DOWNLOAD_RE = re.compile(
154
+ r"new-object\s+(?:system\.)?(?:net\.)?webclient"
155
+ r"|(?:downloadstring|downloadfile)\s*\(",
156
+ re.IGNORECASE,
157
+ )
158
+ _UNC_PATTERN = re.compile(
159
+ # `\\?\UNC\host\share` is the extended-length spelling of the same SMB
160
+ # request. The `(?![\\?])` guard only exists to keep local `\\?\C:\...`
161
+ # paths out, so the UNC prefix needs a branch of its own.
162
+ r"\\\\\?\\UNC\\(?P<ext_host>[A-Za-z0-9._-]+)"
163
+ r"|\\\\(?P<host>[A-Za-z0-9._-]+)\\(?![\\?])"
164
+ )
165
+ _URL_PATTERN = re.compile(
166
+ r"\b(?:https?|ftps?)://(?:[^/@\s'\"`?#]*@)?"
167
+ r"(\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9._-]+)",
168
+ re.IGNORECASE,
169
+ )
170
+ # curl options that can redirect a request away from the URL host: the
171
+ # allowlist must see the resolver/proxy target as well, not just the URL.
172
+ _HOST_OPTION_RE = re.compile(
173
+ # The separator may be `=`, whitespace, or nothing at all: curl also accepts
174
+ # the attached short form `-xhost:port`, and without that branch a request
175
+ # to an allowed host could be routed through any proxy.
176
+ r"(?:^|\s)(?P<flag>--resolve|--connect-to|--proxy|--preproxy|--socks5|"
177
+ r"--socks5-hostname|--url|-x)(?:=|\s+|(?<=-x))(?P<value>\S+)",
178
+ re.IGNORECASE,
179
+ )
180
+ _POWERSHELL_KINDS = frozenset({"powershell", "pwsh"})
181
+ _FIND_COMMAND_FLAGS = frozenset({"-exec", "-execdir", "-ok", "-okdir"})
182
+ # Control operators that end a command. They are only separators outside quotes:
183
+ # splitting the raw string turned `rg 'nc|netcat'` into two commands and denied
184
+ # an ordinary source search.
185
+ _SEGMENT_CHARS = frozenset("|;&\n\r(){}`")
186
+ # Prefixes that do not change which program runs next: `sudo curl ...` still
187
+ # starts curl, so skipping them keeps the head token meaningful.
188
+ _PREFIX_WORDS = frozenset(
189
+ {
190
+ "!",
191
+ "busybox",
192
+ "command",
193
+ "do",
194
+ "doas",
195
+ "elif",
196
+ "else",
197
+ "env",
198
+ "exec",
199
+ "for",
200
+ "if",
201
+ "iex",
202
+ "invoke-expression",
203
+ "caffeinate",
204
+ "nohup",
205
+ "setsid",
206
+ "start",
207
+ "start-process",
208
+ "sudo",
209
+ "then",
210
+ "time",
211
+ "toybox",
212
+ "unbuffer",
213
+ "until",
214
+ "while",
215
+ }
216
+ )
217
+ # Shells take the command as a quoted argument instead of as a head token;
218
+ # without this, `pwsh -Command "iwr https://x"` would look harmless. Only real
219
+ # interpreters count: `grep -c curl` must not be read as running curl.
220
+ _INLINE_COMMAND_FLAGS = frozenset(
221
+ {"-c", "-e", "-command", "-commandwithargs", "/c", "/k", "--command"}
222
+ )
223
+ _INLINE_COMMAND_SHELLS = frozenset(
224
+ {
225
+ "bash",
226
+ "cmd",
227
+ "dash",
228
+ "ksh",
229
+ "node",
230
+ "perl",
231
+ "powershell",
232
+ "pwsh",
233
+ # The Windows launcher runs the same inline scripts as `python`.
234
+ "py",
235
+ "python",
236
+ "python3",
237
+ "ruby",
238
+ "runuser",
239
+ "sh",
240
+ "su",
241
+ "zsh",
242
+ }
243
+ )
244
+ # Versioned interpreter names (`python3.12`, `node18`) run the same inline code
245
+ # as the bare name, but the name set is exact and they slipped past the scan.
246
+ _INTERPRETER_VERSION_RE = re.compile(r"[0-9][0-9.]*$")
247
+
248
+
249
+ def _is_inline_shell(name: str) -> bool:
250
+ """Return True when ``name`` is an interpreter that takes code via a flag."""
251
+ if name in _INLINE_COMMAND_SHELLS:
252
+ return True
253
+ stripped = _INTERPRETER_VERSION_RE.sub("", name)
254
+ return stripped != name and stripped in _INLINE_COMMAND_SHELLS
255
+
256
+
257
+ # Wrappers run another program. The options that take a separate value must be
258
+ # skipped with that value; otherwise `sudo -u root curl ...` treats `-u` as the
259
+ # command and never sees curl.
260
+ _WRAPPER_ARGUMENTS: dict[str, dict[str, int]] = {
261
+ "sudo": {"-u": 1, "-g": 1, "-p": 1, "-C": 1, "-h": 1, "-U": 1, "-r": 1},
262
+ "timeout": {"-s": 1, "-k": 1, "--signal": 1, "--kill-after": 1},
263
+ "env": {"-u": 1, "-C": 1, "-S": 1, "--unset": 1, "--chdir": 1},
264
+ "nice": {"-n": 1, "--adjustment": 1},
265
+ "xargs": {"-I": 1, "-n": 1, "-P": 1, "-d": 1, "-E": 1, "-L": 1, "-s": 1},
266
+ # Process launchers and tracers: the program they run is the real command.
267
+ "watch": {"-n": 1, "--interval": 1, "-d": 0, "-q": 0, "-t": 0, "-b": 0, "-e": 0},
268
+ "strace": {
269
+ "-e": 1,
270
+ "-o": 1,
271
+ "-p": 1,
272
+ "-s": 1,
273
+ "-u": 1,
274
+ "-a": 1,
275
+ "-b": 1,
276
+ "-I": 1,
277
+ "-X": 1,
278
+ },
279
+ "ltrace": {"-e": 1, "-o": 1, "-p": 1, "-s": 1, "-u": 1, "-a": 1},
280
+ "stdbuf": {"-i": 1, "-o": 1, "-e": 1},
281
+ "systemd-run": {
282
+ "--unit": 1,
283
+ "--description": 1,
284
+ "--property": 1,
285
+ "-p": 1,
286
+ "-E": 1,
287
+ },
288
+ "taskset": {"-c": 1, "-p": 1},
289
+ "ionice": {"-c": 1, "-n": 1, "-p": 1},
290
+ }
291
+ _WRAPPERS = frozenset(_WRAPPER_ARGUMENTS)
292
+ _MAX_NESTED_COMMANDS = 8
293
+ _ESCAPE_CHARS = str.maketrans("", "", "\\`'\"^")
294
+
295
+
296
+ def _shell_words(segment: str) -> list[tuple[str, int, int]]:
297
+ """Split one segment into words, removing quotes and concatenating parts.
298
+
299
+ A shell treats ``c"url"`` and ``c\\url`` as the single command ``curl``.
300
+ Tokenizing on alphanumeric runs split those into harmless pieces, so this
301
+ small scanner keeps quoted fragments attached to their neighbours while
302
+ still returning the source offsets used to slice an inline command.
303
+ """
304
+ words: list[tuple[str, int, int]] = []
305
+ buffer: list[str] = []
306
+ start: int | None = None
307
+ quote: str | None = None
308
+ for index, char in enumerate(segment):
309
+ if quote is not None:
310
+ if char == quote:
311
+ quote = None
312
+ else:
313
+ buffer.append(char)
314
+ continue
315
+ if char in {"'", '"'}:
316
+ if start is None:
317
+ start = index
318
+ quote = char
319
+ continue
320
+ if char.isspace():
321
+ if start is not None:
322
+ words.append(("".join(buffer), start, index))
323
+ buffer = []
324
+ start = None
325
+ continue
326
+ if start is None:
327
+ start = index
328
+ buffer.append(char)
329
+ if start is not None:
330
+ words.append(("".join(buffer), start, len(segment)))
331
+ return words
332
+
333
+
334
+ def _env_split_command(tokens: list[str], index: int) -> str | None:
335
+ """Return the command string ``env -S``/``--split-string`` will execute.
336
+
337
+ The value is a command, not an ordinary option argument: skipping it made
338
+ ``env -S 'curl http://x'`` look like a harmless ``env`` call and slipped
339
+ past the network gate entirely.
340
+ """
341
+ position = index + 1
342
+ arguments = _WRAPPER_ARGUMENTS["env"]
343
+ while position < len(tokens):
344
+ token = tokens[position]
345
+ lowered = token.lower()
346
+ if lowered in {"-s", "--split-string"}:
347
+ return tokens[position + 1] if position + 1 < len(tokens) else None
348
+ if lowered.startswith("--split-string="):
349
+ return token.split("=", 1)[1] or None
350
+ if not lowered.startswith("-"):
351
+ return None
352
+ position += 1 + arguments.get(token, 0)
353
+ return None
354
+
355
+
356
+ def _skip_wrapper(tokens: list[str], index: int, wrapper: str) -> int:
357
+ """Advance past a wrapper and its options to the command it wraps."""
358
+ index += 1
359
+ arguments = _WRAPPER_ARGUMENTS[wrapper]
360
+ seen_duration = False
361
+ while index < len(tokens):
362
+ token = tokens[index]
363
+ if token.startswith("-"):
364
+ count = arguments.get(token, 0)
365
+ index += 1 + count
366
+ continue
367
+ if wrapper == "timeout" and not seen_duration:
368
+ # The first positional argument is the duration, not a command.
369
+ seen_duration = True
370
+ index += 1
371
+ continue
372
+ return index
373
+ return index
374
+
375
+
376
+ def _is_inline_flag(token: str) -> bool:
377
+ lowered = token.lower()
378
+ if lowered in _INLINE_COMMAND_FLAGS:
379
+ return True
380
+ # Combined short flags such as `-lc`, `-ec`, or `-ic` still end in `c`,
381
+ # which is what makes the interpreter read the next argument as a command.
382
+ return (
383
+ lowered.startswith("-")
384
+ and not lowered.startswith("--")
385
+ and len(lowered) > 1
386
+ and lowered.endswith("c")
387
+ )
388
+
389
+
390
+ def _is_encoded_command_flag(token: str) -> bool:
391
+ """Return True for PowerShell's ``-EncodedCommand`` and its abbreviations."""
392
+ if not token.startswith("-"):
393
+ return False
394
+ name = token.lower().lstrip("-")
395
+ if not name:
396
+ return False
397
+ # pwsh accepts any prefix of `-EncodedCommand` (even `-e`) plus the
398
+ # initialism `-ec`; every other spelling is left to the inline flags.
399
+ return "encodedcommand".startswith(name) or name == "ec"
400
+
401
+
402
+ def _inline_command_text(
403
+ segment: str, words: list[tuple[str, int, int]], position: int
404
+ ) -> str:
405
+ """Return one inline command argument, keeping its quoted body intact.
406
+
407
+ The argument runs to the end of the segment because PowerShell's
408
+ ``-Command`` takes the rest of the line, but the quoted head must be
409
+ unquoted: slicing up to the closing quote left the quotes in place, so
410
+ ``sh -c "iwr http://x" arg0`` reduced to the host ``x`` instead of the
411
+ ``iwr`` head and the gate never saw the client.
412
+ """
413
+ _value, start, end = words[position]
414
+ head = _strip_outer_quotes(segment[start:end])
415
+ return (head + segment[end:]).strip()
416
+
417
+
418
+ def _segments(command: str) -> list[str]:
419
+ """Split a command on unquoted control operators.
420
+
421
+ Quoted operators are data: `rg -n 'nc|netcat'` searches for that alternation
422
+ instead of running netcat, so the split must not create a new command head
423
+ inside a quoted argument.
424
+ """
425
+ segments: list[str] = []
426
+ current: list[str] = []
427
+ quote = ""
428
+ for char in command:
429
+ if quote:
430
+ current.append(char)
431
+ if char == quote:
432
+ quote = ""
433
+ continue
434
+ if char in "'\"":
435
+ quote = char
436
+ current.append(char)
437
+ continue
438
+ if char in _SEGMENT_CHARS:
439
+ segments.append("".join(current))
440
+ current = []
441
+ continue
442
+ current.append(char)
443
+ segments.append("".join(current))
444
+ return segments
445
+
446
+
447
+ _WINDOWS_SCRIPT_SUFFIXES = (".exe", ".cmd", ".bat", ".com", ".ps1")
448
+
449
+
450
+ def _head_name(token: str) -> str:
451
+ """Return a bare, lowercase executable name from a command token."""
452
+ name = token.replace("\\", "/").rsplit("/", 1)[-1].lower()
453
+ for suffix in _WINDOWS_SCRIPT_SUFFIXES:
454
+ if name.endswith(suffix):
455
+ # `curl.cmd` and `wget.ps1` resolve to the same client as `curl`.
456
+ return name[: -len(suffix)]
457
+ return name
458
+
459
+
460
+ def _head_variants(token: str) -> list[str]:
461
+ """Return the executable names a token can denote after shell escapes.
462
+
463
+ Bash/PowerShell allow ``c\\url``, ``c"url"``, and ``c`url`` to invoke
464
+ ``curl``. The raw spelling is kept first so ordinary Windows paths still
465
+ resolve, and a quote/escape-free spelling is added when it differs.
466
+ """
467
+ raw = _head_name(token)
468
+ name = _head_name(token.translate(_ESCAPE_CHARS))
469
+ return [raw] if name == raw else [raw, name]
470
+
471
+
472
+ def _decode_powershell_command(value: str) -> str | None:
473
+ """Decode a PowerShell -EncodedCommand value, or None when it is opaque."""
474
+ compact = value.strip()
475
+ if not compact:
476
+ return None
477
+ try:
478
+ raw = base64.b64decode(compact + "=" * (-len(compact) % 4), validate=True)
479
+ except (binascii.Error, ValueError):
480
+ return None
481
+ try:
482
+ return raw.decode("utf-16-le")
483
+ except UnicodeDecodeError:
484
+ return None
485
+
486
+
487
+ def _scan_commands(command: str) -> tuple[list[str], list[str], bool]:
488
+ """Return heads, nested inline command strings, and an opaque flag.
489
+
490
+ A string nested in ``sh -c``/``pwsh -Command`` is sliced from the original
491
+ segment instead of being rebuilt from tokens: joining tokens dropped the
492
+ ``;``/``&`` separators, so ``sh -c 'true; curl x'`` looked like one
493
+ harmless ``true`` command.
494
+ """
495
+ heads: list[str] = []
496
+ inline: list[str] = []
497
+ opaque = False
498
+ for segment in _segments(command):
499
+ words = _shell_words(segment)
500
+ tokens = [word for word, _start, _end in words]
501
+ index = 0
502
+ while index < len(tokens):
503
+ token = tokens[index]
504
+ lowered = token.lower()
505
+ if "=" in token and not token.startswith("-"):
506
+ index += 1 # VAR=value prefix
507
+ continue
508
+ if lowered in _WRAPPERS:
509
+ if lowered == "env":
510
+ split_command = _env_split_command(tokens, index)
511
+ if split_command:
512
+ inline.append(split_command)
513
+ index = _skip_wrapper(tokens, index, lowered)
514
+ continue
515
+ if lowered in _PREFIX_WORDS:
516
+ index += 1
517
+ continue
518
+ if token.startswith("-"):
519
+ # An option at command position belongs to a wrapper such as
520
+ # Start-Process (``Start-Process -FilePath curl``).
521
+ index += 1
522
+ continue
523
+ heads.append(token)
524
+ program_names = list(_head_variants(token))
525
+ program = program_names[0]
526
+ if program == "find":
527
+ # `find . -exec curl ... +` runs a command from a token that is
528
+ # not at the start of the segment (a `\;` variant already splits
529
+ # into its own segment).
530
+ for position in range(index + 1, len(tokens) - 1):
531
+ if tokens[position].lower() not in _FIND_COMMAND_FLAGS:
532
+ continue
533
+ heads.append(tokens[position + 1])
534
+ inner = _inline_command_text(segment, words, position + 1)
535
+ if inner:
536
+ inline.append(inner)
537
+ break
538
+ if any(_is_inline_shell(name) for name in program_names):
539
+ is_powershell = any(name in _POWERSHELL_KINDS for name in program_names)
540
+ for position in range(index + 1, len(tokens)):
541
+ flag = tokens[position].lower()
542
+ encoded = is_powershell and _is_encoded_command_flag(flag)
543
+ if not encoded and not _is_inline_flag(tokens[position]):
544
+ continue
545
+ if position + 1 >= len(tokens):
546
+ if encoded:
547
+ opaque = True
548
+ break
549
+ value = tokens[position + 1]
550
+ if encoded:
551
+ decoded = _decode_powershell_command(value)
552
+ if decoded is None:
553
+ opaque = True
554
+ elif decoded.strip():
555
+ inline.append(decoded)
556
+ else:
557
+ # The inline command is rescanned on its own, so adding
558
+ # its first token as a head only made `sh -c "rm ./curl"`
559
+ # look like a call to curl.
560
+ inner = _inline_command_text(segment, words, position + 1)
561
+ if inner:
562
+ inline.append(inner)
563
+ break
564
+ break
565
+ return heads, inline, opaque
566
+
567
+
568
+ def _strip_outer_quotes(text: str) -> str:
569
+ """Drop one pair of quotes that wrapped the whole inline command."""
570
+ stripped = text.strip()
571
+ if len(stripped) >= 2 and stripped[0] == stripped[-1] and stripped[0] in "'\"":
572
+ return stripped[1:-1]
573
+ return stripped
574
+
575
+
576
+ def _command_tokens(segment: str) -> list[str]:
577
+ """Return tokens starting at the command position of one segment."""
578
+ tokens = [word for word, _start, _end in _shell_words(segment)]
579
+ index = 0
580
+ while index < len(tokens):
581
+ token = tokens[index]
582
+ lowered = token.lower()
583
+ if "=" in token and not token.startswith("-"):
584
+ index += 1
585
+ continue
586
+ if lowered in _WRAPPERS:
587
+ index = _skip_wrapper(tokens, index, lowered)
588
+ continue
589
+ if lowered in _PREFIX_WORDS:
590
+ index += 1
591
+ continue
592
+ if token.startswith("-"):
593
+ index += 1
594
+ continue
595
+ break
596
+ return tokens[index:]
597
+
598
+
599
+ def _network_subcommand(program: str, arguments: list[str]) -> bool:
600
+ """Return True when a package/git-style program runs a network subcommand."""
601
+ subcommands = _NETWORK_SUBCOMMANDS.get(program)
602
+ if not subcommands:
603
+ return False
604
+ valued = _SUBCOMMAND_OPTION_VALUES.get(program, frozenset())
605
+ remaining = list(arguments)
606
+ # A few option/value pairs can precede the subcommand (`git -C . clone`,
607
+ # `npm --prefix x install`). Six probes cover them without scanning the
608
+ # whole command line and mistaking a later argument for a subcommand.
609
+ for _ in range(6):
610
+ while remaining and remaining[0].startswith("-"):
611
+ option = remaining.pop(0).lower()
612
+ if option in valued and remaining:
613
+ remaining.pop(0)
614
+ if not remaining:
615
+ return False
616
+ token = remaining.pop(0).lower()
617
+ if token in subcommands:
618
+ return True
619
+ if program == "uv" and token == "pip":
620
+ continue
621
+ return False
622
+ return False
623
+
624
+
625
+ def _sequence_detected(command: str, depth: int = 0) -> bool:
626
+ """Check network subcommands at command positions, including `sh -c`."""
627
+ if depth > _MAX_NESTED_COMMANDS:
628
+ # A command nested past the budget is opaque, not clean: failing open
629
+ # here let one extra `sh -c` layer hide a download.
630
+ return True
631
+ for segment in _segments(command):
632
+ tokens = _command_tokens(segment)
633
+ if not tokens:
634
+ continue
635
+ programs = _head_variants(tokens[0])
636
+ if any(_network_subcommand(program, tokens[1:]) for program in programs):
637
+ return True
638
+ for program in programs:
639
+ candidate = " ".join([program, *tokens[1:]])
640
+ if any(pattern.match(candidate) for pattern in _COMMAND_SEQUENCES):
641
+ return True
642
+ _heads, inline, opaque = _scan_commands(command)
643
+ if opaque:
644
+ return True
645
+ for inner in inline:
646
+ if _NESTED_NETWORK_API.search(inner):
647
+ return True
648
+ if _sequence_detected(inner, depth + 1):
649
+ return True
650
+ return False
651
+
652
+
653
+ def _all_command_heads(command: str, depth: int = 0) -> list[str]:
654
+ """Return every program name the command starts, including inline shells.
655
+
656
+ Only command positions count. Scanning every token (the earlier behaviour)
657
+ made `git log --grep=curl` and `rg curl` look like downloads, so the gate
658
+ refused ordinary source searches for the word "curl".
659
+ """
660
+ heads, inline, _opaque = _scan_commands(command)
661
+ if depth >= _MAX_NESTED_COMMANDS:
662
+ return heads
663
+ for inner in inline:
664
+ heads.extend(_all_command_heads(inner, depth + 1))
665
+ return heads
666
+
667
+
668
+ def _split_host_option(value: str) -> list[str]:
669
+ """Split a curl host option value, keeping bracketed IPv6 literals intact."""
670
+ parts: list[str] = []
671
+ current: list[str] = []
672
+ depth = 0
673
+ for char in value:
674
+ if char == "[":
675
+ depth += 1
676
+ elif char == "]":
677
+ depth = max(0, depth - 1)
678
+ if char == ":" and depth == 0:
679
+ parts.append("".join(current))
680
+ current = []
681
+ continue
682
+ current.append(char)
683
+ parts.append("".join(current))
684
+ return [part.strip("[]") for part in parts]
685
+
686
+
687
+ def _host_from_value(value: str) -> str:
688
+ """Reduce a URL or host[:port] option value to a bare hostname."""
689
+ text = value.strip().strip("'\"")
690
+ if "://" in text:
691
+ try:
692
+ return (urlsplit(text).hostname or "").rstrip(".").lower()
693
+ except ValueError:
694
+ return ""
695
+ authority = text.split("/", 1)[0].rsplit("@", 1)[-1]
696
+ if authority.startswith("["):
697
+ return authority[1 : authority.find("]")].lower() if "]" in authority else ""
698
+ return authority.split(":", 1)[0].rstrip(".").lower()
699
+
700
+
701
+ def _network_hosts(command: str) -> list[str]:
702
+ """Return every host a command can contact, including resolver overrides."""
703
+ hosts: list[str] = []
704
+
705
+ def add(candidate: str) -> None:
706
+ host = candidate.rstrip(".").lower()
707
+ if host and host not in hosts:
708
+ hosts.append(host)
709
+
710
+ for match in _URL_PATTERN.finditer(command):
711
+ authority = match.group(1)
712
+ if authority.startswith("["):
713
+ add(authority[1:].split("]", 1)[0])
714
+ else:
715
+ add(authority.split(":", 1)[0])
716
+ for match in _HOST_OPTION_RE.finditer(command):
717
+ flag = match.group("flag").lower()
718
+ value = match.group("value")
719
+ if flag in {"--resolve", "--connect-to"}:
720
+ parts = _split_host_option(value)
721
+ # HOST:PORT:ADDR (--resolve) and HOST1:PORT1:HOST2:PORT2
722
+ # (--connect-to) both put the real target in the third field.
723
+ for candidate in parts[0:1] + parts[2:3]:
724
+ add(candidate)
725
+ else:
726
+ add(_host_from_value(value))
727
+ for match in _UNC_PATTERN.finditer(command):
728
+ add(match.group("host") or match.group("ext_host") or "")
729
+ return hosts
730
+
731
+
732
+ def network_signals(command: str) -> tuple[bool, tuple[str, ...]]:
733
+ """Detect obvious network use and the hosts named on the command line.
734
+
735
+ The detection is intentionally conservative: a shell string is opaque, so
736
+ this is a policy gate for the local baseline rather than a proof. Hard
737
+ isolation is provided by the container backends.
738
+ """
739
+ # `cmd` escapes with `^` (`cu^rl`) and PowerShell uses a backtick for the
740
+ # same purpose, so the analysis runs on an escape-free copy as well. Only
741
+ # command positions are inspected, so a literal escape in a quoted pattern
742
+ # stays harmless.
743
+ normalized = (
744
+ command.replace("^", "").replace("`", "")
745
+ if "^" in command or "`" in command
746
+ else command
747
+ )
748
+ hosts = _network_hosts(normalized)
749
+ # An SMB share is a network request with no client name and no scheme.
750
+ detected = _UNC_PATTERN.search(normalized) is not None
751
+ for head in _all_command_heads(normalized):
752
+ # Compare bare executable names so `/usr/bin/curl`, `curl.exe`, and
753
+ # `c\url` all match the same entry.
754
+ if any(name in _NETWORK_COMMANDS for name in _head_variants(head)):
755
+ detected = True
756
+ break
757
+ if not detected:
758
+ # `(New-Object Net.WebClient).DownloadString(...)` names no client and
759
+ # no scheme; the outer command is scanned because it is the payload the
760
+ # local shell actually runs.
761
+ detected = _POWERSHELL_DOWNLOAD_RE.search(normalized) is not None
762
+ if not detected:
763
+ detected = _sequence_detected(normalized)
764
+ return detected, tuple(hosts)
765
+
766
+
767
+ def _normalize_host(value: str) -> str:
768
+ """Reduce a configured entry to a bare lowercase hostname."""
769
+ text = value.strip().lower()
770
+ if "://" in text:
771
+ try:
772
+ text = (urlsplit(text).hostname or "").lower()
773
+ except ValueError:
774
+ return ""
775
+ text = text.lstrip(".")
776
+ if text.startswith("[") and "]" in text:
777
+ text = text[1 : text.index("]")]
778
+ elif text.count(":") == 1:
779
+ # host:port, but leave bare IPv6 literals alone.
780
+ text = text.split(":", 1)[0]
781
+ return text.rstrip(".")
782
+
783
+
784
+ def host_allowed(host: str, allowed: tuple[str, ...]) -> bool:
785
+ """Return True when ``host`` matches an allowlist entry or its subdomain."""
786
+ normalized_host = _normalize_host(host)
787
+ for entry in allowed:
788
+ candidate = _normalize_host(entry)
789
+ if not candidate:
790
+ continue
791
+ if normalized_host == candidate or normalized_host.endswith("." + candidate):
792
+ return True
793
+ return False