preflight-agent 1.0.1__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.
- clone_guard.py +627 -0
- fleet_audit.py +881 -0
- hook_doctor.py +860 -0
- preflight_agent-1.0.1.dist-info/METADATA +426 -0
- preflight_agent-1.0.1.dist-info/RECORD +12 -0
- preflight_agent-1.0.1.dist-info/WHEEL +5 -0
- preflight_agent-1.0.1.dist-info/entry_points.txt +2 -0
- preflight_agent-1.0.1.dist-info/licenses/LICENSE +21 -0
- preflight_agent-1.0.1.dist-info/top_level.txt +6 -0
- preflight_agent.py +412 -0
- secret_guard.py +877 -0
- skill_sprawl.py +973 -0
clone_guard.py
ADDED
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""clone-guard — scan a repo BEFORE you or your agent opens
|
|
3
|
+
it, and report the surfaces that execute code on open, on
|
|
4
|
+
install, or on git events.
|
|
5
|
+
|
|
6
|
+
Pure Python standard library. Local only: no network, no
|
|
7
|
+
uploads, no accounts. And read-only in the strongest sense:
|
|
8
|
+
the scanner opens the files it checks and reads them — it
|
|
9
|
+
never executes, installs, sources or modifies anything in
|
|
10
|
+
the folder it scans. (The test suite proves this by hashing
|
|
11
|
+
the whole scanned tree before and after a scan.)
|
|
12
|
+
|
|
13
|
+
Why this exists: in August 2026 a worm campaign planted
|
|
14
|
+
files in public repos that run code the moment a developer
|
|
15
|
+
— or an AI coding agent — opens the cloned folder, with no
|
|
16
|
+
install step at all. The vectors were editor/agent config
|
|
17
|
+
files, not the code itself. clone-guard checks exactly
|
|
18
|
+
those config surfaces, prints every command it finds
|
|
19
|
+
verbatim, and lets a human judge before anything opens the
|
|
20
|
+
folder.
|
|
21
|
+
|
|
22
|
+
What is checked (and ONLY this — ordinary project files
|
|
23
|
+
like Makefile, setup.py, *.sh, flake.nix are never flagged;
|
|
24
|
+
false positives would kill the tool's trust):
|
|
25
|
+
|
|
26
|
+
.claude/settings.json and settings.local.json
|
|
27
|
+
hooks: any "command" string under "hooks", per event
|
|
28
|
+
(SessionStart, PreToolUse, ...) — AUTO-RUN. Plus
|
|
29
|
+
"apiKeyHelper" — AUTO-RUN.
|
|
30
|
+
.vscode/tasks.json
|
|
31
|
+
tasks with runOptions.runOn == "folderOpen" —
|
|
32
|
+
AUTO-RUN.
|
|
33
|
+
.vscode/settings.json
|
|
34
|
+
"task.allowAutomaticTasks": "on" — PRESENT (it
|
|
35
|
+
removes the prompt that otherwise gates folderOpen
|
|
36
|
+
tasks).
|
|
37
|
+
.devcontainer/devcontainer.json
|
|
38
|
+
initializeCommand / postCreateCommand /
|
|
39
|
+
postStartCommand / postAttachCommand — AUTO-RUN
|
|
40
|
+
(initializeCommand runs on the HOST).
|
|
41
|
+
package.json
|
|
42
|
+
scripts preinstall / install / postinstall —
|
|
43
|
+
INSTALL-TIME.
|
|
44
|
+
.envrc
|
|
45
|
+
direnv executes it on cd — AUTO-RUN.
|
|
46
|
+
.git/hooks/ (files not ending in .sample)
|
|
47
|
+
git hooks — PRESENT. They do NOT travel with a fresh
|
|
48
|
+
clone, but they do travel in archives, tarballs and
|
|
49
|
+
copied folders — so a hook already sitting in a
|
|
50
|
+
"fresh clone" is itself a signal.
|
|
51
|
+
.husky/ (hook files, not the _ internals)
|
|
52
|
+
husky hooks — PRESENT (they fire on git events once
|
|
53
|
+
husky is installed).
|
|
54
|
+
|
|
55
|
+
Config files are JSON or JSON-with-comments in the wild,
|
|
56
|
+
so parsing is lenient: // and /* */ comments and trailing
|
|
57
|
+
commas are tolerated (string-aware — a // inside a URL in a
|
|
58
|
+
string is NOT treated as a comment). A checked file that
|
|
59
|
+
still cannot be parsed is reported as an UNPARSEABLE
|
|
60
|
+
finding — it could not be cleared — never a crash.
|
|
61
|
+
|
|
62
|
+
Verdicts say WHEN a thing runs, not WHETHER it is evil:
|
|
63
|
+
plenty of legitimate projects use hooks, devcontainers and
|
|
64
|
+
install scripts. The finding shows the exact command; the
|
|
65
|
+
human judges the command.
|
|
66
|
+
|
|
67
|
+
Exit codes: 0 = CLEAN (no findings) · 1 = FINDINGS (any
|
|
68
|
+
auto-run, install-time, present or unparseable finding) ·
|
|
69
|
+
2 = ERROR — clean message, never a traceback; with --json
|
|
70
|
+
the error is {"error": ...}. --json prints one JSON object
|
|
71
|
+
with the same facts and exit codes as the text view.
|
|
72
|
+
"""
|
|
73
|
+
import argparse
|
|
74
|
+
import json
|
|
75
|
+
import os
|
|
76
|
+
import sys
|
|
77
|
+
from pathlib import Path
|
|
78
|
+
|
|
79
|
+
__version__ = "0.1.0"
|
|
80
|
+
TOOL = "clone-guard"
|
|
81
|
+
|
|
82
|
+
AUTO_RUN = "AUTO-RUN"
|
|
83
|
+
INSTALL_TIME = "INSTALL-TIME"
|
|
84
|
+
PRESENT = "PRESENT"
|
|
85
|
+
UNPARSEABLE = "UNPARSEABLE"
|
|
86
|
+
_VERDICT_ORDER = [AUTO_RUN, INSTALL_TIME, PRESENT, UNPARSEABLE]
|
|
87
|
+
|
|
88
|
+
_GROUP_HEADERS = {
|
|
89
|
+
AUTO_RUN: "AUTO-RUN — executes on open, session start "
|
|
90
|
+
"or cd, with no install step",
|
|
91
|
+
INSTALL_TIME: "INSTALL-TIME — executes when "
|
|
92
|
+
"dependencies are installed",
|
|
93
|
+
PRESENT: "PRESENT — on disk; executes only via its "
|
|
94
|
+
"tool (git / husky) or by removing a prompt",
|
|
95
|
+
UNPARSEABLE: "UNPARSEABLE — could not be parsed, so "
|
|
96
|
+
"its contents could not be checked",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ToolError(Exception):
|
|
101
|
+
"""A clean, user-facing failure (printed as ERROR, exit 2)."""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# --------------------------------------------------------------------------
|
|
105
|
+
# Lenient JSON (JSONC): comments + trailing commas, string-aware
|
|
106
|
+
# --------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def _strip_comments(text):
|
|
109
|
+
"""Remove // line and /* */ block comments, honouring
|
|
110
|
+
string literals (a // inside a quoted URL is data)."""
|
|
111
|
+
out = []
|
|
112
|
+
i, n = 0, len(text)
|
|
113
|
+
in_str = esc = False
|
|
114
|
+
while i < n:
|
|
115
|
+
c = text[i]
|
|
116
|
+
if in_str:
|
|
117
|
+
out.append(c)
|
|
118
|
+
if esc:
|
|
119
|
+
esc = False
|
|
120
|
+
elif c == "\\":
|
|
121
|
+
esc = True
|
|
122
|
+
elif c == '"':
|
|
123
|
+
in_str = False
|
|
124
|
+
i += 1
|
|
125
|
+
continue
|
|
126
|
+
if c == '"':
|
|
127
|
+
in_str = True
|
|
128
|
+
out.append(c)
|
|
129
|
+
i += 1
|
|
130
|
+
elif c == "/" and i + 1 < n and text[i + 1] == "/":
|
|
131
|
+
while i < n and text[i] != "\n":
|
|
132
|
+
i += 1
|
|
133
|
+
elif c == "/" and i + 1 < n and text[i + 1] == "*":
|
|
134
|
+
i += 2
|
|
135
|
+
while i + 1 < n and not (text[i] == "*"
|
|
136
|
+
and text[i + 1] == "/"):
|
|
137
|
+
i += 1
|
|
138
|
+
i += 2
|
|
139
|
+
else:
|
|
140
|
+
out.append(c)
|
|
141
|
+
i += 1
|
|
142
|
+
return "".join(out)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _strip_trailing_commas(text):
|
|
146
|
+
"""Remove a comma whose next non-whitespace character is
|
|
147
|
+
} or ], outside string literals."""
|
|
148
|
+
out = []
|
|
149
|
+
i, n = 0, len(text)
|
|
150
|
+
in_str = esc = False
|
|
151
|
+
while i < n:
|
|
152
|
+
c = text[i]
|
|
153
|
+
if in_str:
|
|
154
|
+
out.append(c)
|
|
155
|
+
if esc:
|
|
156
|
+
esc = False
|
|
157
|
+
elif c == "\\":
|
|
158
|
+
esc = True
|
|
159
|
+
elif c == '"':
|
|
160
|
+
in_str = False
|
|
161
|
+
i += 1
|
|
162
|
+
continue
|
|
163
|
+
if c == '"':
|
|
164
|
+
in_str = True
|
|
165
|
+
out.append(c)
|
|
166
|
+
i += 1
|
|
167
|
+
continue
|
|
168
|
+
if c == ",":
|
|
169
|
+
j = i + 1
|
|
170
|
+
while j < n and text[j] in " \t\r\n":
|
|
171
|
+
j += 1
|
|
172
|
+
if j < n and text[j] in "}]":
|
|
173
|
+
i += 1
|
|
174
|
+
continue
|
|
175
|
+
out.append(c)
|
|
176
|
+
i += 1
|
|
177
|
+
return "".join(out)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def parse_lenient(text):
|
|
181
|
+
"""(obj, None) on success, (None, error message) on
|
|
182
|
+
failure. Tries strict JSON first, then the JSONC-tolerant
|
|
183
|
+
form (comments stripped, trailing commas stripped)."""
|
|
184
|
+
text = text.lstrip("\ufeff")
|
|
185
|
+
try:
|
|
186
|
+
return json.loads(text), None
|
|
187
|
+
except ValueError:
|
|
188
|
+
pass
|
|
189
|
+
try:
|
|
190
|
+
return json.loads(
|
|
191
|
+
_strip_trailing_commas(_strip_comments(text))), None
|
|
192
|
+
except ValueError as exc:
|
|
193
|
+
return None, f"could not be parsed as JSON: {exc}"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# --------------------------------------------------------------------------
|
|
197
|
+
# Findings
|
|
198
|
+
# --------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
def _finding(file, rule, verdict, trigger, command=None,
|
|
201
|
+
detail=None):
|
|
202
|
+
return {"file": file, "rule": rule, "verdict": verdict,
|
|
203
|
+
"trigger": trigger, "command": command,
|
|
204
|
+
"detail": detail}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _unparseable(rel, detail):
|
|
208
|
+
return _finding(
|
|
209
|
+
rel, "unparseable", UNPARSEABLE,
|
|
210
|
+
"this file is one clone-guard checks, but it could "
|
|
211
|
+
"not be parsed — its contents could not be checked",
|
|
212
|
+
detail=detail)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _load_json(d, rel, findings, probed):
|
|
216
|
+
"""Parse DIR/rel leniently. Returns the top-level dict,
|
|
217
|
+
or None when the file is absent. A file that exists but
|
|
218
|
+
cannot be read, decoded or parsed (or is not a JSON
|
|
219
|
+
object) adds an UNPARSEABLE finding and returns None."""
|
|
220
|
+
probed.append(rel)
|
|
221
|
+
p = d / rel
|
|
222
|
+
if not p.is_file():
|
|
223
|
+
return None
|
|
224
|
+
try:
|
|
225
|
+
text = p.read_bytes().decode("utf-8")
|
|
226
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
227
|
+
findings.append(_unparseable(
|
|
228
|
+
rel, f"could not be read as UTF-8 text: {exc}"))
|
|
229
|
+
return None
|
|
230
|
+
data, err = parse_lenient(text)
|
|
231
|
+
if data is None:
|
|
232
|
+
findings.append(_unparseable(rel, err))
|
|
233
|
+
return None
|
|
234
|
+
if not isinstance(data, dict):
|
|
235
|
+
findings.append(_unparseable(
|
|
236
|
+
rel, "the top level is not a JSON object"))
|
|
237
|
+
return None
|
|
238
|
+
return data
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _line_count(path):
|
|
242
|
+
try:
|
|
243
|
+
return len(path.read_bytes().splitlines())
|
|
244
|
+
except OSError:
|
|
245
|
+
return 0
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# --------------------------------------------------------------------------
|
|
249
|
+
# Rules
|
|
250
|
+
# --------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
def _collect_commands(node, out):
|
|
253
|
+
"""Every string value of a "command" key, anywhere in
|
|
254
|
+
the subtree (Claude hook groups nest commands two levels
|
|
255
|
+
deep, and shapes vary by version)."""
|
|
256
|
+
if isinstance(node, dict):
|
|
257
|
+
cmd = node.get("command")
|
|
258
|
+
if isinstance(cmd, str):
|
|
259
|
+
out.append(cmd)
|
|
260
|
+
for v in node.values():
|
|
261
|
+
_collect_commands(v, out)
|
|
262
|
+
elif isinstance(node, list):
|
|
263
|
+
for v in node:
|
|
264
|
+
_collect_commands(v, out)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def rule_claude(d, findings, probed):
|
|
268
|
+
for rel in (".claude/settings.json",
|
|
269
|
+
".claude/settings.local.json"):
|
|
270
|
+
data = _load_json(d, rel, findings, probed)
|
|
271
|
+
if data is None:
|
|
272
|
+
continue
|
|
273
|
+
hooks = data.get("hooks")
|
|
274
|
+
if isinstance(hooks, dict):
|
|
275
|
+
for event in sorted(hooks):
|
|
276
|
+
commands = []
|
|
277
|
+
_collect_commands(hooks[event], commands)
|
|
278
|
+
for cmd in dict.fromkeys(commands):
|
|
279
|
+
findings.append(_finding(
|
|
280
|
+
rel, "claude-hook", AUTO_RUN,
|
|
281
|
+
f"Claude Code {event} hook — runs "
|
|
282
|
+
f"automatically when Claude Code "
|
|
283
|
+
f"works in this folder",
|
|
284
|
+
command=cmd))
|
|
285
|
+
helper = data.get("apiKeyHelper")
|
|
286
|
+
if isinstance(helper, str):
|
|
287
|
+
findings.append(_finding(
|
|
288
|
+
rel, "claude-api-key-helper", AUTO_RUN,
|
|
289
|
+
"Claude Code apiKeyHelper — a command "
|
|
290
|
+
"Claude Code runs to obtain API credentials",
|
|
291
|
+
command=helper))
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def rule_vscode(d, findings, probed):
|
|
295
|
+
rel = ".vscode/tasks.json"
|
|
296
|
+
data = _load_json(d, rel, findings, probed)
|
|
297
|
+
if data is not None:
|
|
298
|
+
tasks = data.get("tasks")
|
|
299
|
+
if isinstance(tasks, list):
|
|
300
|
+
for task in tasks:
|
|
301
|
+
if not isinstance(task, dict):
|
|
302
|
+
continue
|
|
303
|
+
run_options = task.get("runOptions")
|
|
304
|
+
if not (isinstance(run_options, dict)
|
|
305
|
+
and run_options.get("runOn")
|
|
306
|
+
== "folderOpen"):
|
|
307
|
+
continue
|
|
308
|
+
label = task.get("label") or "(no label)"
|
|
309
|
+
command = task.get("command")
|
|
310
|
+
detail = None
|
|
311
|
+
if isinstance(command, str):
|
|
312
|
+
args = task.get("args")
|
|
313
|
+
if (isinstance(args, list) and args
|
|
314
|
+
and all(isinstance(a, str)
|
|
315
|
+
for a in args)):
|
|
316
|
+
command = command + " " + " ".join(args)
|
|
317
|
+
else:
|
|
318
|
+
command = None
|
|
319
|
+
detail = (f"task '{label}' declares no "
|
|
320
|
+
f"command of its own — it may "
|
|
321
|
+
f"chain other tasks via dependsOn")
|
|
322
|
+
findings.append(_finding(
|
|
323
|
+
rel, "vscode-folderopen-task", AUTO_RUN,
|
|
324
|
+
f"VS Code task '{label}' with runOn: "
|
|
325
|
+
f"folderOpen — runs when this folder is "
|
|
326
|
+
f"opened in VS Code",
|
|
327
|
+
command=command, detail=detail))
|
|
328
|
+
rel = ".vscode/settings.json"
|
|
329
|
+
data = _load_json(d, rel, findings, probed)
|
|
330
|
+
if data is not None:
|
|
331
|
+
if data.get("task.allowAutomaticTasks") == "on":
|
|
332
|
+
findings.append(_finding(
|
|
333
|
+
rel, "vscode-automatic-tasks", PRESENT,
|
|
334
|
+
"VS Code setting task.allowAutomaticTasks "
|
|
335
|
+
"is \"on\"",
|
|
336
|
+
detail="with automatic tasks on, VS Code "
|
|
337
|
+
"runs folderOpen tasks without "
|
|
338
|
+
"prompting first — the prompt is the "
|
|
339
|
+
"only gate on the tasks above"))
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
_DEVCONTAINER_TRIGGERS = {
|
|
343
|
+
"initializeCommand":
|
|
344
|
+
"devcontainer initializeCommand — runs on the "
|
|
345
|
+
"HOST when the folder is reopened in a container, "
|
|
346
|
+
"before the container exists",
|
|
347
|
+
"postCreateCommand":
|
|
348
|
+
"devcontainer postCreateCommand — runs inside "
|
|
349
|
+
"the container right after it is created",
|
|
350
|
+
"postStartCommand":
|
|
351
|
+
"devcontainer postStartCommand — runs inside "
|
|
352
|
+
"the container every time it starts",
|
|
353
|
+
"postAttachCommand":
|
|
354
|
+
"devcontainer postAttachCommand — runs inside "
|
|
355
|
+
"the container when an editor or tool attaches",
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _cmd_value(v):
|
|
360
|
+
if isinstance(v, str):
|
|
361
|
+
return v
|
|
362
|
+
if isinstance(v, list):
|
|
363
|
+
return " ".join(str(x) for x in v)
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _command_strings(value):
|
|
368
|
+
"""A devcontainer command may be a string, an argv
|
|
369
|
+
list, or an object of name -> command. Flatten to the
|
|
370
|
+
command strings."""
|
|
371
|
+
if isinstance(value, dict):
|
|
372
|
+
out = []
|
|
373
|
+
for v in value.values():
|
|
374
|
+
c = _cmd_value(v)
|
|
375
|
+
if c is not None:
|
|
376
|
+
out.append(c)
|
|
377
|
+
return out
|
|
378
|
+
c = _cmd_value(value)
|
|
379
|
+
return [c] if c is not None else []
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def rule_devcontainer(d, findings, probed):
|
|
383
|
+
rel = ".devcontainer/devcontainer.json"
|
|
384
|
+
data = _load_json(d, rel, findings, probed)
|
|
385
|
+
if data is None:
|
|
386
|
+
return
|
|
387
|
+
for key, trigger in _DEVCONTAINER_TRIGGERS.items():
|
|
388
|
+
if key in data:
|
|
389
|
+
for cmd in _command_strings(data[key]):
|
|
390
|
+
findings.append(_finding(
|
|
391
|
+
rel, "devcontainer-command", AUTO_RUN,
|
|
392
|
+
trigger, command=cmd))
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def rule_npm(d, findings, probed):
|
|
396
|
+
rel = "package.json"
|
|
397
|
+
data = _load_json(d, rel, findings, probed)
|
|
398
|
+
if data is None:
|
|
399
|
+
return
|
|
400
|
+
scripts = data.get("scripts")
|
|
401
|
+
if not isinstance(scripts, dict):
|
|
402
|
+
return
|
|
403
|
+
for key in ("preinstall", "install", "postinstall"):
|
|
404
|
+
cmd = scripts.get(key)
|
|
405
|
+
if isinstance(cmd, str):
|
|
406
|
+
findings.append(_finding(
|
|
407
|
+
rel, "npm-install-script", INSTALL_TIME,
|
|
408
|
+
f"npm \"{key}\" script — npm runs it "
|
|
409
|
+
f"automatically during npm install",
|
|
410
|
+
command=cmd))
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def rule_direnv(d, findings, probed):
|
|
414
|
+
rel = ".envrc"
|
|
415
|
+
probed.append(rel)
|
|
416
|
+
p = d / rel
|
|
417
|
+
if p.is_file():
|
|
418
|
+
findings.append(_finding(
|
|
419
|
+
rel, "direnv-envrc", AUTO_RUN,
|
|
420
|
+
"direnv file — direnv executes it automatically "
|
|
421
|
+
"when you cd into this folder (when direnv is "
|
|
422
|
+
"installed and the folder is allowed)",
|
|
423
|
+
detail=f"{_line_count(p)} line(s) of shell — "
|
|
424
|
+
f"direnv sources the whole file; open "
|
|
425
|
+
f"it to inspect"))
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def rule_git_hooks(d, findings, probed):
|
|
429
|
+
probed.append(".git/hooks")
|
|
430
|
+
hooks_dir = d / ".git" / "hooks"
|
|
431
|
+
if not hooks_dir.is_dir():
|
|
432
|
+
return
|
|
433
|
+
for entry in sorted(os.scandir(hooks_dir),
|
|
434
|
+
key=lambda e: e.name):
|
|
435
|
+
if entry.name.endswith(".sample"):
|
|
436
|
+
continue
|
|
437
|
+
if entry.is_dir(follow_symlinks=False):
|
|
438
|
+
continue
|
|
439
|
+
p = Path(entry.path)
|
|
440
|
+
executable = os.access(p, os.X_OK)
|
|
441
|
+
findings.append(_finding(
|
|
442
|
+
f".git/hooks/{entry.name}", "git-hook", PRESENT,
|
|
443
|
+
f"git hook '{entry.name}' — runs on git "
|
|
444
|
+
f"events when executable",
|
|
445
|
+
detail=f"{_line_count(p)} line(s); executable: "
|
|
446
|
+
f"{'yes' if executable else 'no'}. Git "
|
|
447
|
+
f"hooks do not travel with a fresh clone — "
|
|
448
|
+
f"but they do travel in archives, tarballs "
|
|
449
|
+
f"and copied folders, so a hook already "
|
|
450
|
+
f"here in a \"fresh clone\" is itself a "
|
|
451
|
+
f"signal. Open the file to inspect it"))
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def rule_husky(d, findings, probed):
|
|
455
|
+
probed.append(".husky")
|
|
456
|
+
husky = d / ".husky"
|
|
457
|
+
if not husky.is_dir():
|
|
458
|
+
return
|
|
459
|
+
for entry in sorted(os.scandir(husky),
|
|
460
|
+
key=lambda e: e.name):
|
|
461
|
+
if entry.name.startswith((".", "_")):
|
|
462
|
+
continue
|
|
463
|
+
if entry.is_dir(follow_symlinks=False):
|
|
464
|
+
continue
|
|
465
|
+
p = Path(entry.path)
|
|
466
|
+
findings.append(_finding(
|
|
467
|
+
f".husky/{entry.name}", "husky-hook", PRESENT,
|
|
468
|
+
f"husky hook '{entry.name}' — runs on git "
|
|
469
|
+
f"events once husky is installed (npm install "
|
|
470
|
+
f"wires it up)",
|
|
471
|
+
detail=f"{_line_count(p)} line(s); open the "
|
|
472
|
+
f"file to inspect it"))
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
_RULES = (rule_claude, rule_vscode, rule_devcontainer,
|
|
476
|
+
rule_npm, rule_direnv, rule_git_hooks, rule_husky)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def scan(d):
|
|
480
|
+
"""All findings for DIR, sorted by verdict severity,
|
|
481
|
+
then file, then trigger. Reads only — never writes,
|
|
482
|
+
never executes."""
|
|
483
|
+
findings, probed = [], []
|
|
484
|
+
for rule in _RULES:
|
|
485
|
+
rule(d, findings, probed)
|
|
486
|
+
rank = {v: i for i, v in enumerate(_VERDICT_ORDER)}
|
|
487
|
+
findings.sort(key=lambda f: (rank[f["verdict"]],
|
|
488
|
+
f["file"], f["trigger"],
|
|
489
|
+
f["command"] or ""))
|
|
490
|
+
summary = {"total": len(findings),
|
|
491
|
+
"auto_run": sum(1 for f in findings
|
|
492
|
+
if f["verdict"] == AUTO_RUN),
|
|
493
|
+
"install_time": sum(
|
|
494
|
+
1 for f in findings
|
|
495
|
+
if f["verdict"] == INSTALL_TIME),
|
|
496
|
+
"present": sum(1 for f in findings
|
|
497
|
+
if f["verdict"] == PRESENT),
|
|
498
|
+
"unparseable": sum(
|
|
499
|
+
1 for f in findings
|
|
500
|
+
if f["verdict"] == UNPARSEABLE)}
|
|
501
|
+
return {"findings": findings, "summary": summary,
|
|
502
|
+
"surfaces_probed": len(probed),
|
|
503
|
+
"clean": not findings}
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
# --------------------------------------------------------------------------
|
|
507
|
+
# CLI
|
|
508
|
+
# --------------------------------------------------------------------------
|
|
509
|
+
|
|
510
|
+
class _JsonAwareParser(argparse.ArgumentParser):
|
|
511
|
+
"""ArgumentParser that reports usage errors as JSON in
|
|
512
|
+
--json mode ({"error": ...} on stdout, same exit code 2)
|
|
513
|
+
— an agent driving this tool with --json never gets
|
|
514
|
+
plain text or a traceback. The flag lives on the class
|
|
515
|
+
so subparsers share it."""
|
|
516
|
+
|
|
517
|
+
json_mode = False
|
|
518
|
+
|
|
519
|
+
def error(self, message):
|
|
520
|
+
if self.json_mode:
|
|
521
|
+
print(json.dumps({"error": message}))
|
|
522
|
+
self.exit(2)
|
|
523
|
+
super().error(message)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def require_dir(raw):
|
|
527
|
+
d = Path(raw)
|
|
528
|
+
if not d.exists():
|
|
529
|
+
raise ToolError(f"no such folder: {raw}")
|
|
530
|
+
if not d.is_dir():
|
|
531
|
+
raise ToolError(f"not a folder: {raw}")
|
|
532
|
+
return d
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def cmd_scan(a):
|
|
536
|
+
d = require_dir(a.dir)
|
|
537
|
+
result = scan(d)
|
|
538
|
+
if a.json:
|
|
539
|
+
print(json.dumps(
|
|
540
|
+
{"tool": TOOL, "version": __version__,
|
|
541
|
+
"command": "scan", "dir": str(d),
|
|
542
|
+
"surfaces_probed": result["surfaces_probed"],
|
|
543
|
+
"findings": result["findings"],
|
|
544
|
+
"summary": result["summary"],
|
|
545
|
+
"clean": result["clean"]}, indent=2))
|
|
546
|
+
return 0 if result["clean"] else 1
|
|
547
|
+
s = result["summary"]
|
|
548
|
+
print(f"{TOOL} v{__version__} — scan · folder {d.name}")
|
|
549
|
+
print(f"{d.name} · {s['total']} finding(s) — "
|
|
550
|
+
f"{s['auto_run']} AUTO-RUN · "
|
|
551
|
+
f"{s['install_time']} INSTALL-TIME · "
|
|
552
|
+
f"{s['present']} PRESENT · "
|
|
553
|
+
f"{s['unparseable']} UNPARSEABLE")
|
|
554
|
+
if result["clean"]:
|
|
555
|
+
print()
|
|
556
|
+
print("CLEAN — none of the auto-execution surfaces "
|
|
557
|
+
"clone-guard checks were found (Claude / VS "
|
|
558
|
+
"Code / devcontainer configs, npm install "
|
|
559
|
+
"scripts, direnv, git and husky hooks).")
|
|
560
|
+
print("clone-guard only reads files: it never "
|
|
561
|
+
"executes, installs or modifies anything in "
|
|
562
|
+
"the scanned folder. CLEAN is not a guarantee "
|
|
563
|
+
"the code itself is safe — it means these "
|
|
564
|
+
"known open-time and install-time surfaces "
|
|
565
|
+
"are absent. Read the code before you run it.")
|
|
566
|
+
return 0
|
|
567
|
+
for verdict in _VERDICT_ORDER:
|
|
568
|
+
group = [f for f in result["findings"]
|
|
569
|
+
if f["verdict"] == verdict]
|
|
570
|
+
if not group:
|
|
571
|
+
continue
|
|
572
|
+
print()
|
|
573
|
+
print(f"{_GROUP_HEADERS[verdict]} ({len(group)}):")
|
|
574
|
+
for f in group:
|
|
575
|
+
print(f" {f['file']} — {f['trigger']}")
|
|
576
|
+
if f["command"] is not None:
|
|
577
|
+
print(f" command: {f['command']}")
|
|
578
|
+
if f["detail"] is not None:
|
|
579
|
+
print(f" {f['detail']}")
|
|
580
|
+
print()
|
|
581
|
+
print("VERDICT: FINDINGS — read every command above "
|
|
582
|
+
"before opening this folder in an editor or with "
|
|
583
|
+
"an agent.")
|
|
584
|
+
print("clone-guard only reads files: it never executes, "
|
|
585
|
+
"installs or modifies anything in the scanned "
|
|
586
|
+
"folder. Findings are auto-execution surfaces, "
|
|
587
|
+
"not proof of malice — legitimate projects use "
|
|
588
|
+
"hooks and install scripts too. The command text "
|
|
589
|
+
"is the evidence; judge it.")
|
|
590
|
+
return 1
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def main(argv=None):
|
|
594
|
+
argv = sys.argv[1:] if argv is None else list(argv)
|
|
595
|
+
_JsonAwareParser.json_mode = "--json" in argv
|
|
596
|
+
ap = _JsonAwareParser(
|
|
597
|
+
prog=TOOL,
|
|
598
|
+
description="Scan a repo BEFORE opening it: report "
|
|
599
|
+
"the config surfaces that execute code "
|
|
600
|
+
"on open, on install, or on git events "
|
|
601
|
+
"(Claude/VS Code/devcontainer configs, "
|
|
602
|
+
"npm install scripts, direnv, git and "
|
|
603
|
+
"husky hooks), with every command shown "
|
|
604
|
+
"verbatim. Read-only: never executes "
|
|
605
|
+
"anything. (stdlib only, local only).")
|
|
606
|
+
ap.add_argument("dir", help="the cloned repo / folder "
|
|
607
|
+
"to scan (not yet opened)")
|
|
608
|
+
ap.add_argument("--json", action="store_true",
|
|
609
|
+
help="print one JSON object instead of "
|
|
610
|
+
"text")
|
|
611
|
+
a = ap.parse_args(argv)
|
|
612
|
+
|
|
613
|
+
def fail(msg):
|
|
614
|
+
if getattr(a, "json", False):
|
|
615
|
+
print(json.dumps({"error": msg}))
|
|
616
|
+
else:
|
|
617
|
+
print(f"ERROR: {msg}")
|
|
618
|
+
return 2
|
|
619
|
+
|
|
620
|
+
try:
|
|
621
|
+
return cmd_scan(a)
|
|
622
|
+
except ToolError as exc:
|
|
623
|
+
return fail(str(exc))
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
if __name__ == "__main__":
|
|
627
|
+
sys.exit(main())
|