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 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())