scamgate 0.3.3__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.
- scamgate/__init__.py +11 -0
- scamgate/asklog.py +153 -0
- scamgate/bench.py +77 -0
- scamgate/cli.py +209 -0
- scamgate/config/rules.yaml +218 -0
- scamgate/config.py +62 -0
- scamgate/data/blocklist.txt +9 -0
- scamgate/domainage.py +153 -0
- scamgate/engine.py +145 -0
- scamgate/entry.py +19 -0
- scamgate/feeds.py +377 -0
- scamgate/guard.py +116 -0
- scamgate/harden.py +393 -0
- scamgate/hook.py +1055 -0
- scamgate/hook_entry.py +15 -0
- scamgate/hostnorm.py +60 -0
- scamgate/integrity.py +85 -0
- scamgate/models.py +77 -0
- scamgate/overrides.py +90 -0
- scamgate/payees.py +141 -0
- scamgate/paygate.py +228 -0
- scamgate/plugin.py +222 -0
- scamgate/plugin.yaml +24 -0
- scamgate/redact.py +93 -0
- scamgate/refresh.py +233 -0
- scamgate/registry.py +66 -0
- scamgate/rules/__init__.py +1 -0
- scamgate/rules/payment.py +127 -0
- scamgate/rules/url.py +446 -0
- scamgate/server.py +75 -0
- scamgate/state.py +32 -0
- scamgate/tools.py +94 -0
- scamgate-0.3.3.dist-info/METADATA +544 -0
- scamgate-0.3.3.dist-info/RECORD +37 -0
- scamgate-0.3.3.dist-info/WHEEL +4 -0
- scamgate-0.3.3.dist-info/entry_points.txt +3 -0
- scamgate-0.3.3.dist-info/licenses/LICENSE +21 -0
scamgate/harden.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""Extra gate checks found while fitting scamgate to Hermes.
|
|
2
|
+
|
|
3
|
+
The hook already scores links and payments. This module covers what that scan never saw:
|
|
4
|
+
|
|
5
|
+
- Hermes safety switches (approvals off, /yolo, uninstalling the gate).
|
|
6
|
+
- Reading the signing key.
|
|
7
|
+
- A script the agent writes, then runs, including one cron will run later with no agent.
|
|
8
|
+
- The body of a file another tool will run (Makefile, package.json, git hooks, CI).
|
|
9
|
+
|
|
10
|
+
Quiet tools (notes, chat) are not scanned: quoting a scam link in a report is not a visit.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
_SAFETY_BLOCK = re.compile(
|
|
20
|
+
r"""(?ix)
|
|
21
|
+
hermes\s+config\s+set\s+(?:approvals\b|plugins\.(?:hook_callback_timeout|disabled|entries)\b)
|
|
22
|
+
| hermes\s+config\s+unset\s+approvals\b
|
|
23
|
+
| hermes\s+config\s+edit\b
|
|
24
|
+
| hermes(?:['\"\],\s]+)config(?:['\"\],\s]+)set(?:['\"\],\s]+)['\"]?approvals
|
|
25
|
+
| hermes\s+plugins\s+(?:uninstall|remove|disable)\b
|
|
26
|
+
| (?<![A-Za-z0-9_-])--yolo\b
|
|
27
|
+
| HERMES_YOLO_MODE\s*=
|
|
28
|
+
| HERMES_YOLO_MODE['\"]\s*\]\s*=
|
|
29
|
+
| \$env:HERMES_YOLO_MODE\b
|
|
30
|
+
| (?<![A-Za-z0-9_-])--safe-mode\b
|
|
31
|
+
| HERMES_SAFE_MODE\s*=
|
|
32
|
+
| (?<![A-Za-z0-9_-])--ignore-user-config\b
|
|
33
|
+
| (?<![A-Za-z0-9_-])--dangerously-skip-permissions\b
|
|
34
|
+
| --permission-mode\s+bypassPermissions\b
|
|
35
|
+
| --dangerously-bypass-approvals-and-sandbox\b
|
|
36
|
+
| set_config_value\s*\([^)]*approvals
|
|
37
|
+
"""
|
|
38
|
+
)
|
|
39
|
+
_SAFETY_ASK = re.compile(
|
|
40
|
+
r"""(?ix)
|
|
41
|
+
hermes\s+plugins\s+(?:install|enable)\b
|
|
42
|
+
| \bhermes\b[^\n]{0,120}\s(?:-p|--profile)\s+\S
|
|
43
|
+
"""
|
|
44
|
+
)
|
|
45
|
+
_SECRET = re.compile(r"(?i)state\.key|payees\.salt|\.local[/\\]+state[/\\]+scamgate\b")
|
|
46
|
+
# Claude Code: disabling or removing a plugin (scamgate is one) or its marketplace, and starting a nested
|
|
47
|
+
# claude session that skips hooks (bare mode, or settings with disableAllHooks). The other bypass flags are
|
|
48
|
+
# in _SAFETY_BLOCK.
|
|
49
|
+
_CLAUDE_BIN = r"\bclaude(?:-code)?(?:\.exe|\.cmd)?"
|
|
50
|
+
_CLAUDE_SAFETY_BLOCK = re.compile(
|
|
51
|
+
rf"""(?ix)
|
|
52
|
+
{_CLAUDE_BIN}\s+plugins?\s+(?:disable|uninstall|remove|rm)\b
|
|
53
|
+
| {_CLAUDE_BIN}\s+plugins?\s+marketplace\s+(?:remove|rm)\b
|
|
54
|
+
| {_CLAUDE_BIN}\b[^\n|;&]{{0,300}}?(?<![A-Za-z0-9_-])--bare\b
|
|
55
|
+
| {_CLAUDE_BIN}\b[^\n|;&]{{0,300}}?/plugins?\s+(?:disable|uninstall|remove|rm)\b
|
|
56
|
+
| (?<![A-Za-z0-9_-])--allow-dangerously-skip-permissions\b
|
|
57
|
+
| disableAllHooks['"\s]*[:=]\s*['"]?true\b
|
|
58
|
+
"""
|
|
59
|
+
)
|
|
60
|
+
# Claude Code: changing which plugins or settings a session loads.
|
|
61
|
+
_CLAUDE_SAFETY_ASK = re.compile(
|
|
62
|
+
rf"""(?ix)
|
|
63
|
+
{_CLAUDE_BIN}\s+plugins?\s+(?:install|i|enable|configure)\b
|
|
64
|
+
| {_CLAUDE_BIN}\b[^\n|;&]{{0,300}}?(?<![A-Za-z0-9_-])--setting-sources\b
|
|
65
|
+
| {_CLAUDE_BIN}\b[^\n|;&]{{0,300}}?(?<![A-Za-z0-9_-])--settings\b
|
|
66
|
+
"""
|
|
67
|
+
)
|
|
68
|
+
# Exactly scamgate's own tools: Claude Code MCP, Claude Code plugin MCP, Hermes MCP, Hermes plugin. Not any
|
|
69
|
+
# name containing them.
|
|
70
|
+
_OWN_CHECK = re.compile(
|
|
71
|
+
r"(?i)^(?:mcp__scamgate__|mcp__plugin_scamgate_scamgate__|mcp_scamgate_|scamgate_)check_(?:url|payment)$")
|
|
72
|
+
_SCRIPT_EXT = re.compile(r"(?i)\.(?:sh|bash|py|js|mjs|cjs|ps1|bat|cmd)$")
|
|
73
|
+
_ENV_ASSIGN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
|
74
|
+
_RUNNABLE = re.compile(
|
|
75
|
+
r"(?i)(?:^|/)(?:makefile|package\.json|conftest\.py|[^/]+\.(?:sh|bash|py|js|mjs|ps1|bat|cmd|ya?ml))$"
|
|
76
|
+
)
|
|
77
|
+
_CONTENT_KEYS = ("content", "new_string", "new_text", "text", "body", "patch")
|
|
78
|
+
_PATH_KEYS = ("path", "file_path", "filepath", "notebook_path", "target_file")
|
|
79
|
+
_SKIP = {"sudo", "command", "time", "nohup", "builtin"}
|
|
80
|
+
_SHELLS = {"bash", "sh", "dash", "zsh", "ksh", "fish"}
|
|
81
|
+
_PY = {"python", "python3", "py", "python.exe", "python3.exe"}
|
|
82
|
+
_NODE = {"node", "nodejs", "node.exe"}
|
|
83
|
+
_PWSH = {"pwsh", "powershell", "pwsh.exe", "powershell.exe"}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def own_check(tool: str) -> bool:
|
|
87
|
+
"""scamgate's own check tools. Asking 'is this link bad?' must not be blocked for naming it."""
|
|
88
|
+
return bool(_OWN_CHECK.match((tool or "").strip()))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def gate_extra(tool: str, tool_input: dict):
|
|
92
|
+
"""Strongest extra decision, or None when these checks have nothing to say."""
|
|
93
|
+
from .hook import QUIET_TOOLS, Result, _RANK, _canon_tool, _norm_path, _strings
|
|
94
|
+
|
|
95
|
+
t = _canon_tool(tool)
|
|
96
|
+
quiet = t in QUIET_TOOLS
|
|
97
|
+
found = Result("none")
|
|
98
|
+
|
|
99
|
+
def take(new):
|
|
100
|
+
nonlocal found
|
|
101
|
+
if new is not None and _RANK[new.decision] > _RANK[found.decision]:
|
|
102
|
+
found = new
|
|
103
|
+
|
|
104
|
+
for key in _PATH_KEYS:
|
|
105
|
+
val = tool_input.get(key)
|
|
106
|
+
if isinstance(val, str) and _secret_path(val):
|
|
107
|
+
take(_block(f"it would read scamgate's signing state ({val})"))
|
|
108
|
+
|
|
109
|
+
if not quiet:
|
|
110
|
+
blob = "\n".join(_strings(tool_input))
|
|
111
|
+
if _SECRET.search(blob) or _mentions_state_dir(blob):
|
|
112
|
+
take(_block("it would read scamgate's signing key or payee history"))
|
|
113
|
+
if m := _SAFETY_BLOCK.search(blob) or _CLAUDE_SAFETY_BLOCK.search(blob):
|
|
114
|
+
take(_block(f"it would switch a safety control off ('{m.group(0).strip()[:80]}')"))
|
|
115
|
+
elif m := _SAFETY_ASK.search(blob) or _CLAUDE_SAFETY_ASK.search(blob):
|
|
116
|
+
take(_ask(f"it changes which agent or plugins are in force ('{m.group(0).strip()[:80]}'). "
|
|
117
|
+
"Approve only if you meant to install or switch."))
|
|
118
|
+
if t in {"write_file", "patch", "write", "edit", "multiedit", "notebookedit", "str_replace_editor"}:
|
|
119
|
+
path = next((tool_input.get(k) for k in _PATH_KEYS if isinstance(tool_input.get(k), str)), "")
|
|
120
|
+
body = "\n".join(tool_input.get(k) for k in _CONTENT_KEYS if isinstance(tool_input.get(k), str))
|
|
121
|
+
if path and body:
|
|
122
|
+
take(_judge_written(path, body))
|
|
123
|
+
|
|
124
|
+
if t == "cronjob_manage" or "cronjob" in t:
|
|
125
|
+
take(_cron_scripts(tool_input))
|
|
126
|
+
if not quiet:
|
|
127
|
+
take(_ran_scripts(tool, tool_input))
|
|
128
|
+
|
|
129
|
+
return None if found.decision == "none" else found
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _block(why: str):
|
|
133
|
+
from .hook import Result
|
|
134
|
+
|
|
135
|
+
return Result("block", f"scamgate blocked this because {why}. Agents can't weaken the scam gate or read "
|
|
136
|
+
"its signing key. Don't retry it another way; tell the user to do it themselves, "
|
|
137
|
+
"outside the agent.")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _ask(why: str):
|
|
141
|
+
from .hook import Result
|
|
142
|
+
|
|
143
|
+
return Result("approve", f"scamgate: {why}", "scamgate:safety")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _secret_path(path: str) -> bool:
|
|
147
|
+
from .hook import _norm_path
|
|
148
|
+
from .state import state_dir
|
|
149
|
+
|
|
150
|
+
p = _norm_path(path)
|
|
151
|
+
root = _norm_path(str(state_dir()))
|
|
152
|
+
if root and (p == root or p.startswith(root + "/")):
|
|
153
|
+
return True
|
|
154
|
+
return bool(_SECRET.search(p))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _mentions_state_dir(text: str) -> bool:
|
|
158
|
+
from .hook import _expand, _norm_path
|
|
159
|
+
from .state import state_dir
|
|
160
|
+
|
|
161
|
+
root = _norm_path(str(state_dir()))
|
|
162
|
+
if len(root) < 8:
|
|
163
|
+
return False
|
|
164
|
+
return root in _norm_path(_expand(text))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _runnable(path: str) -> bool:
|
|
168
|
+
p = path.replace("\\", "/").lower()
|
|
169
|
+
if "/.git/hooks/" in p or "/.github/workflows/" in p:
|
|
170
|
+
return True
|
|
171
|
+
if _under_hermes_scripts(path):
|
|
172
|
+
return True
|
|
173
|
+
return bool(_RUNNABLE.search(p))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _shellish(path: str) -> bool:
|
|
177
|
+
p = path.replace("\\", "/").lower()
|
|
178
|
+
if p.endswith(("/makefile", "makefile", ".sh", ".bash", ".ps1", ".bat", ".cmd")):
|
|
179
|
+
return True
|
|
180
|
+
if "/.git/hooks/" in p or "/.github/workflows/" in p:
|
|
181
|
+
return True
|
|
182
|
+
return _under_hermes_scripts(path)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _under_hermes_scripts(path: str) -> bool:
|
|
186
|
+
from .hook import _norm_path
|
|
187
|
+
|
|
188
|
+
home = os.environ.get("HERMES_HOME")
|
|
189
|
+
if not home:
|
|
190
|
+
return False
|
|
191
|
+
root = _norm_path(str(Path(home) / "scripts"))
|
|
192
|
+
p = _norm_path(path)
|
|
193
|
+
return p == root or p.startswith(root + "/")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _judge_written(path: str, body: str):
|
|
197
|
+
from .hook import Result, _DECODE_RUN, _INDIRECT_RE, _NET_RE, _RANK, _URL_RE, _evaluate, _repair_schemes
|
|
198
|
+
from .hook import Gate
|
|
199
|
+
|
|
200
|
+
if not _runnable(path):
|
|
201
|
+
return None
|
|
202
|
+
if m := _SAFETY_BLOCK.search(body):
|
|
203
|
+
return _block(f"the file would switch a safety control off ('{m.group(0).strip()[:80]}')")
|
|
204
|
+
g = Gate(tool="write_file")
|
|
205
|
+
g.urls = _URL_RE.findall(_repair_schemes(body))
|
|
206
|
+
found = _evaluate(g) if g.urls else Result("none")
|
|
207
|
+
if _DECODE_RUN.search(body):
|
|
208
|
+
ask = Result("approve", "scamgate: this file decodes something and runs it, so scamgate can't see what "
|
|
209
|
+
"it will do. Approve only if you wrote it.", "scamgate:write-decode")
|
|
210
|
+
if _RANK[ask.decision] > _RANK[found.decision]:
|
|
211
|
+
found = ask
|
|
212
|
+
if _shellish(path) and _NET_RE.search(body) and _INDIRECT_RE.search(body) and found.decision != "block":
|
|
213
|
+
ask = Result("approve", "scamgate: this script can reach the network, but the destination isn't written "
|
|
214
|
+
"out, so scamgate can't check it. Approve only if you know where it goes.",
|
|
215
|
+
"scamgate:write-opaque")
|
|
216
|
+
if _RANK[ask.decision] > _RANK[found.decision]:
|
|
217
|
+
found = ask
|
|
218
|
+
return None if found.decision == "none" else found
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _cron_scripts(tool_input: dict):
|
|
222
|
+
from .hook import Result
|
|
223
|
+
|
|
224
|
+
home = os.environ.get("HERMES_HOME")
|
|
225
|
+
base = Path(home) / "scripts" if home else None
|
|
226
|
+
worst = None
|
|
227
|
+
for key in ("script", "monitor_script"):
|
|
228
|
+
name = tool_input.get(key)
|
|
229
|
+
if not isinstance(name, str) or not name.strip():
|
|
230
|
+
continue
|
|
231
|
+
path = Path(name)
|
|
232
|
+
if not path.is_absolute():
|
|
233
|
+
path = (base / name) if base else path
|
|
234
|
+
got = _read(path)
|
|
235
|
+
if got is None:
|
|
236
|
+
worst = worst or Result("approve", "scamgate: this scheduled job names a script scamgate can't read, "
|
|
237
|
+
"so it can't check what the job will do later. Approve only if you "
|
|
238
|
+
"know that script.", "scamgate:cron-unread")
|
|
239
|
+
continue
|
|
240
|
+
if got == "big":
|
|
241
|
+
return Result("approve", "scamgate: this scheduled script is too large to read. Approve only if you "
|
|
242
|
+
"know what it does.", "scamgate:cron-big")
|
|
243
|
+
judged = _judge_script(got, "cronjob_manage")
|
|
244
|
+
if judged and (worst is None or judged.decision == "block" or worst.decision != "block"):
|
|
245
|
+
worst = judged
|
|
246
|
+
return worst
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _ran_scripts(tool: str, tool_input: dict):
|
|
250
|
+
from .hook import CODE_KEYS, CODE_TOOLS, NET_TOOL_HINT, Result, _canon_tool, _segments
|
|
251
|
+
|
|
252
|
+
t = _canon_tool(tool)
|
|
253
|
+
if t not in CODE_TOOLS and not NET_TOOL_HINT.search(t):
|
|
254
|
+
return None
|
|
255
|
+
command = "\n".join(str(tool_input.get(k)) for k in CODE_KEYS if isinstance(tool_input.get(k), str))
|
|
256
|
+
if not command.strip():
|
|
257
|
+
return None
|
|
258
|
+
workdir = tool_input.get("workdir") if isinstance(tool_input.get("workdir"), str) else ""
|
|
259
|
+
cwd = workdir
|
|
260
|
+
worst = None
|
|
261
|
+
seen: set[str] = set()
|
|
262
|
+
for seg in _segments(command):
|
|
263
|
+
cd = re.match(r"(?i)^cd\s+(.+)$", seg.strip())
|
|
264
|
+
if cd and "&&" not in seg and ";" not in seg:
|
|
265
|
+
dest = cd.group(1).strip().strip("'\"")
|
|
266
|
+
cwd = dest if Path(dest).is_absolute() or not cwd else str(Path(cwd) / dest)
|
|
267
|
+
continue
|
|
268
|
+
for token in _script_tokens(seg):
|
|
269
|
+
path = _resolve(token, cwd)
|
|
270
|
+
hit = _follow(path, seen, 0)
|
|
271
|
+
if hit is None:
|
|
272
|
+
continue
|
|
273
|
+
if isinstance(hit, Result) and (worst is None or hit.decision == "block" or worst.decision != "block"):
|
|
274
|
+
worst = hit
|
|
275
|
+
return worst
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _follow(path: Path, seen: set[str], depth: int):
|
|
279
|
+
from .hook import MAX_SCRIPT_DEPTH, Result, _segments
|
|
280
|
+
|
|
281
|
+
key = str(path)
|
|
282
|
+
if key in seen or depth > MAX_SCRIPT_DEPTH:
|
|
283
|
+
return None
|
|
284
|
+
seen.add(key)
|
|
285
|
+
got = _read(path)
|
|
286
|
+
if got is None or got == "big":
|
|
287
|
+
if got == "big":
|
|
288
|
+
return Result("approve", f"scamgate: '{path.name}' is too large to read, so scamgate can't check what "
|
|
289
|
+
"it will do. Approve only if you know that script.", "scamgate:script-big")
|
|
290
|
+
return None
|
|
291
|
+
judged = _judge_script(got, "terminal")
|
|
292
|
+
if judged and judged.decision == "block":
|
|
293
|
+
return judged
|
|
294
|
+
nested = judged
|
|
295
|
+
for seg in _segments(got):
|
|
296
|
+
for token in _script_tokens(seg):
|
|
297
|
+
child = _follow(_resolve(token, str(path.parent)), seen, depth + 1)
|
|
298
|
+
if child is not None and (nested is None or child.decision == "block"):
|
|
299
|
+
nested = child
|
|
300
|
+
return nested
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _judge_script(text: str, tool: str):
|
|
304
|
+
from . import paygate
|
|
305
|
+
from .hook import Result, _RANK, _TAMPER_CMD, _evaluate, _scan_code
|
|
306
|
+
from .hook import Gate
|
|
307
|
+
|
|
308
|
+
if m := _SAFETY_BLOCK.search(text):
|
|
309
|
+
return _block(f"the script would switch a safety control off ('{m.group(0).strip()[:80]}')")
|
|
310
|
+
if m := _TAMPER_CMD.search(text):
|
|
311
|
+
return _block(f"the script would weaken or remove the scam gate ('{m.group(0).strip()[:80]}')")
|
|
312
|
+
g = Gate(tool=tool)
|
|
313
|
+
_scan_code(text, g)
|
|
314
|
+
found = _evaluate(g) if (g.urls or g.opaque_network) else Result("none")
|
|
315
|
+
if cp := paygate.scan_code(text):
|
|
316
|
+
ask = Result("approve", f"scamgate: this script makes a payment ({cp.detail}), and scamgate can't read "
|
|
317
|
+
"who gets paid or how much. Approve once, only if you started this payment.",
|
|
318
|
+
"scamgate:script-pay")
|
|
319
|
+
if _RANK[ask.decision] >= _RANK[found.decision]:
|
|
320
|
+
found = ask
|
|
321
|
+
return None if found.decision == "none" else found
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _read(path: Path):
|
|
325
|
+
from .hook import MAX_SCRIPT_BYTES
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
if not path.is_file():
|
|
329
|
+
return None
|
|
330
|
+
if path.stat().st_size > MAX_SCRIPT_BYTES:
|
|
331
|
+
return "big"
|
|
332
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
333
|
+
except OSError:
|
|
334
|
+
return "big"
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _resolve(token: str, cwd: str) -> Path:
|
|
338
|
+
token = os.path.expanduser(token.strip().strip("'\""))
|
|
339
|
+
path = Path(token)
|
|
340
|
+
if not path.is_absolute():
|
|
341
|
+
path = (Path(cwd) if cwd else Path.cwd()) / token
|
|
342
|
+
return path
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _script_tokens(segment: str) -> list[str]:
|
|
346
|
+
from .hook import _words # shlex's words in one pass: shlex itself is superlinear on long tokens
|
|
347
|
+
|
|
348
|
+
toks = _words(segment)
|
|
349
|
+
if toks is None:
|
|
350
|
+
return []
|
|
351
|
+
i = 0
|
|
352
|
+
while i < len(toks) and _ENV_ASSIGN.match(toks[i]) and "/" not in toks[i] and "\\" not in toks[i]:
|
|
353
|
+
i += 1
|
|
354
|
+
while i < len(toks) and Path(toks[i]).name.lower() in _SKIP:
|
|
355
|
+
i += 1
|
|
356
|
+
while i < len(toks) and toks[i].startswith("-"):
|
|
357
|
+
i += 1
|
|
358
|
+
if i >= len(toks):
|
|
359
|
+
return []
|
|
360
|
+
if toks[i] in ("&", "."):
|
|
361
|
+
return [toks[i + 1]] if i + 1 < len(toks) else []
|
|
362
|
+
cmd = Path(toks[i]).name.lower()
|
|
363
|
+
rest = toks[i + 1:]
|
|
364
|
+
if cmd in _SHELLS or cmd == "source":
|
|
365
|
+
return _first_file(rest, inline_flags={"-c", "-lc"})
|
|
366
|
+
if cmd in _PY or cmd in _NODE:
|
|
367
|
+
return _first_file(rest, inline_flags={"-c", "-m"})
|
|
368
|
+
if cmd == "uv" and rest and rest[0] == "run":
|
|
369
|
+
return _first_file(rest[1:], inline_flags=set())
|
|
370
|
+
if cmd in _PWSH:
|
|
371
|
+
if "-File" in rest:
|
|
372
|
+
k = rest.index("-File")
|
|
373
|
+
return [rest[k + 1]] if k + 1 < len(rest) else []
|
|
374
|
+
if rest and rest[0].lower().endswith(".ps1"):
|
|
375
|
+
return [rest[0]]
|
|
376
|
+
return []
|
|
377
|
+
token = toks[i]
|
|
378
|
+
if token.startswith("./") or token.startswith(".\\") or _SCRIPT_EXT.search(token):
|
|
379
|
+
return [token]
|
|
380
|
+
return []
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _first_file(args: list[str], inline_flags: set[str]) -> list[str]:
|
|
384
|
+
j = 0
|
|
385
|
+
while j < len(args):
|
|
386
|
+
arg = args[j]
|
|
387
|
+
if arg in inline_flags:
|
|
388
|
+
return []
|
|
389
|
+
if arg.startswith("-"):
|
|
390
|
+
j += 1
|
|
391
|
+
continue
|
|
392
|
+
return [arg]
|
|
393
|
+
return []
|