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.
- minimalcode/__init__.py +3 -0
- minimalcode/__main__.py +3 -0
- minimalcode/agent.py +1084 -0
- minimalcode/cli.py +1520 -0
- minimalcode/clipboard.py +212 -0
- minimalcode/config.py +1065 -0
- minimalcode/defaults.json +491 -0
- minimalcode/errors.py +31 -0
- minimalcode/highlight/__init__.py +47 -0
- minimalcode/highlight/code.py +261 -0
- minimalcode/highlight/color.py +58 -0
- minimalcode/highlight/markdown.py +710 -0
- minimalcode/highlight/palette.py +73 -0
- minimalcode/highlight/sanitize.py +175 -0
- minimalcode/highlight/thinking.py +71 -0
- minimalcode/httpclient.py +278 -0
- minimalcode/importer.py +253 -0
- minimalcode/prompt.py +195 -0
- minimalcode/provider/__init__.py +28 -0
- minimalcode/provider/anthropic.py +805 -0
- minimalcode/provider/anthropic_env.py +161 -0
- minimalcode/provider/chat.py +394 -0
- minimalcode/provider/client.py +243 -0
- minimalcode/provider/common.py +176 -0
- minimalcode/provider/deepseek_web.py +1158 -0
- minimalcode/provider/factory.py +63 -0
- minimalcode/provider/responses.py +692 -0
- minimalcode/provider/sha3_wasm_bg.wasm +0 -0
- minimalcode/provider/transport.py +241 -0
- minimalcode/provider/types.py +54 -0
- minimalcode/sandbox/__init__.py +64 -0
- minimalcode/sandbox/audit.py +120 -0
- minimalcode/sandbox/backends.py +343 -0
- minimalcode/sandbox/execution.py +435 -0
- minimalcode/sandbox/network.py +793 -0
- minimalcode/sandbox/policy.py +253 -0
- minimalcode/sandbox/runner.py +347 -0
- minimalcode/sandbox/sensitive.py +286 -0
- minimalcode/session.py +465 -0
- minimalcode/shell.py +333 -0
- minimalcode/subagent.py +120 -0
- minimalcode/tools/__init__.py +46 -0
- minimalcode/tools/files.py +1139 -0
- minimalcode/tools/process.py +164 -0
- minimalcode/tools/remote.py +74 -0
- minimalcode/tools/runtime.py +219 -0
- minimalcode/tools/schemas.py +418 -0
- minimalcode/tools/search.py +521 -0
- minimalcode/tools/search_tools.py +583 -0
- minimalcode/tools/validation.py +99 -0
- minimalcode/vision.py +231 -0
- minimalcode/web.py +238 -0
- minimalcode-1.5.2.dist-info/METADATA +646 -0
- minimalcode-1.5.2.dist-info/RECORD +58 -0
- minimalcode-1.5.2.dist-info/WHEEL +5 -0
- minimalcode-1.5.2.dist-info/entry_points.txt +2 -0
- minimalcode-1.5.2.dist-info/licenses/LICENSE +201 -0
- 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
|