skill-vet-cli 1.0.0__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.
skill_vet.py ADDED
@@ -0,0 +1,1673 @@
1
+ #!/usr/bin/env python3
2
+ """skill-vet — a static, read-only pre-install audit of an
3
+ agent skill pack: does its SKILL.md parse, do the files it
4
+ references exist, and do its bundled scripts steal, spy, or
5
+ send in secret — found before you install it, not after.
6
+
7
+ Pure Python standard library. READ-ONLY and local only: it
8
+ reads the skill folder you point it at, edits nothing,
9
+ writes nothing, and NEVER executes anything in the pack —
10
+ every file is analysed as static text. The test suite
11
+ proves the claim: a fixture script that would write a
12
+ marker file if it ran never runs, and every fixture tree
13
+ comes out byte-identical after an audit.
14
+
15
+ A skill pack runs with your agent's permissions the
16
+ moment it is installed. Per-file linters check the
17
+ SKILL.md's formatting; nobody vets the whole directory —
18
+ the frontmatter, the referenced files, and above all the
19
+ scripts. The marketplace numbers say why this matters
20
+ (Snyk's ToxicSkills audit, Feb 2026: 1,467 of 3,984
21
+ marketplace skills carried malicious payloads; VirusTotal
22
+ analysed 35,878 skills and found 6,637 malicious, most of
23
+ them hiding the payload in plain sentences or concealment
24
+ mechanics rather than obvious malware).
25
+
26
+ THE DANGER MODEL (the owner's standing rule, 2026-10-30,
27
+ applied exactly): a pack is DANGEROUS only if it
28
+
29
+ 1. STEALS — reads credential stores, browser profiles,
30
+ SSH keys or environment secrets AND sends or copies
31
+ the results out;
32
+ 2. SPIES — captures screen, keyboard, microphone,
33
+ camera or browser history AND persists or sends the
34
+ capture, without disclosing the capture in its docs;
35
+ 3. SENDS IN SECRET — makes network calls whose payload
36
+ or destination is concealed: an encoded payload, the
37
+ contents of local files sent to an undisclosed
38
+ endpoint, or any send at all from a pack whose docs
39
+ claim it works offline.
40
+
41
+ A pattern hit alone is NEVER a danger verdict. Each test
42
+ needs its combination of evidence in the same file, and
43
+ the finding cites both halves (file + line for each).
44
+ Anything short of a combination is a warning with the
45
+ evidence stated: an undisclosed endpoint on its own is
46
+ UNDISCLOSED_NETWORK, a credential read with no egress is
47
+ SECRET_ACCESS, a capture API with no persistence or send
48
+ is CAPTURE_CAPABILITY. Scanner scores are context, never
49
+ a block by themselves; BLOCKED is reserved for a proven
50
+ three-test combination.
51
+
52
+ THE RULES (v1.0.0):
53
+
54
+ Structure (the pack as a document):
55
+ MISSING_SKILL_MD (ERROR) — no SKILL.md at the pack
56
+ root. SKILL_MD_CASE (WARN) — present, but under a
57
+ differently-cased name some loaders will miss.
58
+ FRONTMATTER_MISSING (ERROR) — SKILL.md has no ---
59
+ frontmatter block. FRONTMATTER_PARSE (ERROR) — a
60
+ frontmatter line the subset parser cannot read (the
61
+ line is cited). REQUIRED_FIELD (ERROR) — `name` or
62
+ `description` missing or empty.
63
+ NAME_DIR_MISMATCH (WARN) — normalised frontmatter name
64
+ differs from the folder name. NAME_FORMAT (WARN) —
65
+ the name is not a lowercase kebab-case slug.
66
+ BROAD_TOOL_GRANT (WARN) — an allowed-tools entry that
67
+ grants a whole tool (`*`, `Bash(*)`, a bare `Bash`, …).
68
+ BROKEN_REFERENCE (ERROR) — a local file referenced
69
+ from SKILL.md (link or backticked path) or from a
70
+ script (path literal) does not exist.
71
+ REFERENCE_OUTSIDE_PACK (WARN) — a reference that
72
+ resolves outside the pack folder.
73
+ LARGE_FILE (WARN) — a file over 1 MiB in a pack that
74
+ ships as text. BUNDLED_BINARY (WARN) — a binary file:
75
+ it cannot be statically vetted at all.
76
+ SYMLINK_OUT (INFO) — a symlink whose target leaves the
77
+ pack; its target is not scanned.
78
+
79
+ The three danger tests (ERROR severity, danger class;
80
+ any one of them makes the verdict BLOCKED):
81
+ DANGER_STEALING — secret-source read + (network send
82
+ or copy/move out of the pack) in the same file.
83
+ DANGER_SPYING — capture (screen / keyboard / audio /
84
+ camera / history) + (send or persistence) in the
85
+ same file, with the capture kind undisclosed in
86
+ SKILL.md / README.md.
87
+ DANGER_HIDDEN_SENDING — network send + concealment in
88
+ the same file: an encoded payload sent to an
89
+ undisclosed (or non-literal) endpoint, local file
90
+ contents read from outside the pack and sent to an
91
+ undisclosed endpoint, a send combined with writes
92
+ outside the pack to an undisclosed endpoint, or any
93
+ send at all while the docs claim the pack works
94
+ offline / local-only. (An encoded payload to a
95
+ DISCLOSED endpoint is ENCODED_PAYLOAD, a warning:
96
+ the destination is honest, the payload opaque.)
97
+
98
+ Short-of-danger security warnings (WARN):
99
+ SECRET_ACCESS — reads credential stores / env secrets,
100
+ no egress found in that file. UNDISCLOSED_NETWORK —
101
+ a network call whose endpoint appears nowhere in
102
+ SKILL.md / README.md, with no concealment combo.
103
+ DYNAMIC_ENDPOINT — a network call with no literal URL
104
+ to check against the docs. ENCODED_PAYLOAD — an
105
+ encoded payload sent to a DISCLOSED endpoint (the
106
+ destination is honest; what is sent is opaque).
107
+ DISCLOSED_CAPTURE — capture + persistence/send, but
108
+ the capture kind IS disclosed in the docs.
109
+ CAPTURE_CAPABILITY — a capture API present with no
110
+ persistence or send found.
111
+
112
+ Hygiene (WARN):
113
+ HYGIENE_SHELL_TRUE — subprocess with shell=True.
114
+ HYGIENE_DYNAMIC_EXEC — eval / exec / os.system /
115
+ os.popen / __import__ on computed values.
116
+ HYGIENE_DOWNLOAD_EXEC — downloaded content executed:
117
+ `curl … | sh`, or a download and an exec in one file.
118
+ HYGIENE_OBFUSCATION — a long base64 blob, a long hex
119
+ blob, or a run of \\x escapes in a text file.
120
+ CONCEALED_UNICODE — invisible characters (zero-width,
121
+ bidi controls, Unicode tag characters, soft hyphens)
122
+ in a text file, cited by name.
123
+ CONCEALED_BLANK_RUN — 50+ consecutive blank lines in a
124
+ markdown file with content after them.
125
+ HIDDEN_COMMENT — an HTML comment of 120+ characters in
126
+ a markdown file.
127
+ WRITES_OUTSIDE_PACK — a script writes to a path
128
+ outside the pack (absolute, home, /tmp, …).
129
+ INSTALL_HOOK — a file that runs at install time
130
+ (setup.py, a .pth file, an install script, a
131
+ package.json install hook): review it first.
132
+
133
+ VERDICT (printed on every report):
134
+ BLOCKED — at least one DANGER_* finding (a proven
135
+ three-test combination). Exit 1.
136
+ REVIEW — no danger finding, but at least one ERROR or
137
+ WARN. Exit 0 (exit 1 with --strict).
138
+ CLEAN — no ERROR or WARN findings (INFO may remain).
139
+ Exit 0.
140
+
141
+ WHAT IT DOES NOT CLAIM (stated plainly): static pattern
142
+ analysis reads evidence, it does not prove safety. A
143
+ CLEAN verdict means no pattern fired — a well-written
144
+ plain-sight social-engineering skill, a payload assembled
145
+ at runtime from innocuous pieces, or a binary's behaviour
146
+ can all pass. Vet is the first pass, not the verdict on
147
+ a pack's soul. Input errors (missing folder, not a
148
+ folder, bad arguments) exit 2 as a clean {"error": ...}
149
+ object in --json mode — never a traceback.
150
+ """
151
+
152
+ import argparse
153
+ import json
154
+ import os
155
+ import re
156
+ import sys
157
+
158
+ TOOL = "skill-vet"
159
+ VERSION = "1.0.0"
160
+
161
+ # ------------------------------------------------------------------
162
+ # Constants
163
+ # ------------------------------------------------------------------
164
+
165
+ LARGE_FILE_BYTES = 1_048_576 # 1 MiB
166
+ SCAN_CAP_BYTES = 2_097_152 # text scanning cap per file
167
+ BASE64_BLOB_MIN = 120 # chars in one token
168
+ HEX_BLOB_MIN = 120
169
+ ESCAPE_RUN_MIN = 8 # consecutive \xNN escapes
170
+ BLANK_RUN_MIN = 50 # consecutive blank lines
171
+ HIDDEN_COMMENT_MIN = 120 # chars inside <!-- ... -->
172
+
173
+ SKIP_DIRS = {".git", "__pycache__"}
174
+
175
+ SCRIPT_EXTS = {
176
+ ".py", ".sh", ".bash", ".zsh", ".ksh", ".js", ".mjs",
177
+ ".cjs", ".ts", ".rb", ".pl", ".php", ".ps1", ".bat",
178
+ ".cmd",
179
+ }
180
+ MARKDOWN_EXTS = {".md", ".markdown"}
181
+ BINARY_EXTS = {
182
+ ".exe", ".dll", ".so", ".dylib", ".bin", ".whl", ".jar",
183
+ ".class", ".o", ".a", ".node", ".wasm", ".com", ".msi",
184
+ ".dmg", ".pkg", ".deb", ".rpm", ".elf", ".pyc",
185
+ }
186
+ REF_EXTS = {
187
+ ".md", ".py", ".sh", ".json", ".txt", ".csv", ".yaml",
188
+ ".yml", ".toml", ".js", ".ts",
189
+ }
190
+ REF_PREFIX_DIRS = (
191
+ "scripts/", "references/", "assets/", "templates/",
192
+ "data/", "files/", "docs/",
193
+ )
194
+
195
+ VERDICT_RULE = (
196
+ "BLOCKED = at least one danger finding (a proven "
197
+ "stealing / spying / hidden-sending combination); "
198
+ "REVIEW = no danger finding, but at least one ERROR "
199
+ "or WARN; CLEAN = no ERROR or WARN findings (INFO "
200
+ "may remain). Exit 1 only for BLOCKED (or for REVIEW "
201
+ "with --strict); exit 2 is an input error."
202
+ )
203
+
204
+ LIMITS_LINE = (
205
+ "Static pattern analysis only: it reads evidence, it "
206
+ "cannot prove a pack safe. A CLEAN verdict means no "
207
+ "pattern fired — plain-sight social engineering, a "
208
+ "payload assembled at runtime, and bundled binaries "
209
+ "can all pass a static vet."
210
+ )
211
+
212
+
213
+ class ToolError(Exception):
214
+ """An input error: clean message, exit 2."""
215
+
216
+
217
+ # ------------------------------------------------------------------
218
+ # Findings
219
+ # ------------------------------------------------------------------
220
+
221
+ _SEVERITY_RANK = {"ERROR": 0, "WARN": 1, "INFO": 2}
222
+
223
+
224
+ def finding(rule, severity, file, line, message, fix,
225
+ danger=False):
226
+ return {
227
+ "rule": rule,
228
+ "severity": severity,
229
+ "danger": danger,
230
+ "file": file,
231
+ "line": line,
232
+ "message": message,
233
+ "fix": fix,
234
+ }
235
+
236
+
237
+ def sort_findings(findings):
238
+ def key(f):
239
+ return (
240
+ 0 if f["danger"] else 1,
241
+ _SEVERITY_RANK[f["severity"]],
242
+ f["file"] or "",
243
+ f["line"] or 0,
244
+ f["rule"],
245
+ )
246
+ return sorted(findings, key=key)
247
+
248
+
249
+ # ------------------------------------------------------------------
250
+ # Frontmatter (a deliberately small YAML subset)
251
+ # ------------------------------------------------------------------
252
+ # Supported: `key: value` scalars (quotes stripped),
253
+ # `key: [a, b]` inline lists, `key:` followed by `- item`
254
+ # block lists, and indented continuation lines folded into
255
+ # the previous scalar. Anything else is a parse error with
256
+ # the line cited — a frontmatter the subset cannot read
257
+ # is reported, never guessed at.
258
+
259
+ _KEY_LINE = re.compile(r"^([A-Za-z0-9_-]+):\s*(.*)$")
260
+ _LIST_ITEM = re.compile(r"^\s+-\s+(.*)$")
261
+
262
+
263
+ def _strip_quotes(value):
264
+ value = value.strip()
265
+ if len(value) >= 2 and value[0] == value[-1] and \
266
+ value[0] in ("'", '"'):
267
+ return value[1:-1]
268
+ return value
269
+
270
+
271
+ def parse_frontmatter(text):
272
+ """Return (fields, body, error, error_line).
273
+
274
+ fields maps key -> str | list[str]. error is None on
275
+ success; on a missing block error is "missing".
276
+ Line numbers are 1-based, counting the opening ---.
277
+ """
278
+ lines = text.splitlines()
279
+ if not lines or lines[0].strip() != "---":
280
+ return None, text, "missing", None
281
+ close = None
282
+ for i in range(1, min(len(lines), 400)):
283
+ if lines[i].strip() in ("---", "..."):
284
+ close = i
285
+ break
286
+ if close is None:
287
+ return None, text, "no closing --- for the " \
288
+ "frontmatter block", 1
289
+ fields = {}
290
+ current = None
291
+ for i in range(1, close):
292
+ raw = lines[i]
293
+ lineno = i + 1
294
+ if not raw.strip() or raw.strip().startswith("#"):
295
+ continue
296
+ item = _LIST_ITEM.match(raw)
297
+ if item and current is not None:
298
+ value = _strip_quotes(item.group(1))
299
+ if not isinstance(fields.get(current), list):
300
+ fields[current] = []
301
+ fields[current].append(value)
302
+ continue
303
+ if raw[0] in (" ", "\t") and current is not None and \
304
+ isinstance(fields.get(current), str):
305
+ fields[current] = (
306
+ fields[current] + " " + raw.strip()).strip()
307
+ continue
308
+ m = _KEY_LINE.match(raw)
309
+ if not m:
310
+ return None, text, "frontmatter line %d is not " \
311
+ "a key: value line, a list item, or a " \
312
+ "continuation: %r" % (lineno, raw.strip()), \
313
+ lineno
314
+ key, value = m.group(1), m.group(2).strip()
315
+ current = key
316
+ if value == "":
317
+ fields[key] = ""
318
+ elif value.startswith("[") and value.endswith("]"):
319
+ inner = value[1:-1].strip()
320
+ fields[key] = [
321
+ _strip_quotes(part)
322
+ for part in inner.split(",") if part.strip()
323
+ ] if inner else []
324
+ else:
325
+ fields[key] = _strip_quotes(value)
326
+ body = "\n".join(lines[close + 1:])
327
+ return fields, body, None, None
328
+
329
+
330
+ def field_text(fields, key):
331
+ """A frontmatter field as one string (lists join)."""
332
+ value = fields.get(key)
333
+ if value is None:
334
+ return None
335
+ if isinstance(value, list):
336
+ return ", ".join(value)
337
+ return value
338
+
339
+
340
+ def field_entries(fields, key):
341
+ """A frontmatter field as a list of entries."""
342
+ value = fields.get(key)
343
+ if value is None:
344
+ return []
345
+ if isinstance(value, list):
346
+ return list(value)
347
+ return [part.strip() for part in value.split(",")
348
+ if part.strip()]
349
+
350
+
351
+ # ------------------------------------------------------------------
352
+ # Inventory
353
+ # ------------------------------------------------------------------
354
+
355
+ def _is_binary(path, ext, head):
356
+ if ext in BINARY_EXTS:
357
+ return True
358
+ return b"\x00" in head
359
+
360
+
361
+ def inventory(pack_dir):
362
+ """Walk the pack. Returns a list of records:
363
+ {path, bytes, kind, binary, scanned, symlink_out}.
364
+ .git and __pycache__ directories are skipped.
365
+ """
366
+ records = []
367
+ for root, dirs, names in os.walk(pack_dir):
368
+ dirs[:] = sorted(
369
+ d for d in dirs if d not in SKIP_DIRS)
370
+ for name in sorted(names):
371
+ full = os.path.join(root, name)
372
+ rel = os.path.relpath(full, pack_dir)
373
+ rec = {
374
+ "path": rel.replace(os.sep, "/"),
375
+ "bytes": 0,
376
+ "kind": "other",
377
+ "binary": False,
378
+ "scanned": False,
379
+ "symlink_out": False,
380
+ }
381
+ try:
382
+ rec["bytes"] = os.path.getsize(full)
383
+ except OSError:
384
+ pass
385
+ if os.path.islink(full):
386
+ real = os.path.realpath(full)
387
+ if not real.startswith(
388
+ os.path.realpath(pack_dir) + os.sep):
389
+ rec["symlink_out"] = True
390
+ ext = os.path.splitext(name)[1].lower()
391
+ head = b""
392
+ if not rec["symlink_out"]:
393
+ try:
394
+ with open(full, "rb") as fh:
395
+ head = fh.read(8192)
396
+ except OSError:
397
+ head = b""
398
+ rec["binary"] = _is_binary(full, ext, head)
399
+ if ext in SCRIPT_EXTS or head.startswith(b"#!"):
400
+ rec["kind"] = "script"
401
+ elif ext in MARKDOWN_EXTS:
402
+ rec["kind"] = "markdown"
403
+ elif rec["binary"]:
404
+ rec["kind"] = "binary"
405
+ elif ext:
406
+ rec["kind"] = "text"
407
+ records.append(rec)
408
+ records.sort(key=lambda r: r["path"])
409
+ return records
410
+
411
+
412
+ def read_text(pack_dir, rel):
413
+ """Read one pack file as text (utf-8, lossy). Returns
414
+ None when unreadable. Capped at SCAN_CAP_BYTES."""
415
+ full = os.path.join(pack_dir, rel.replace("/", os.sep))
416
+ try:
417
+ with open(full, "rb") as fh:
418
+ data = fh.read(SCAN_CAP_BYTES)
419
+ except OSError:
420
+ return None
421
+ return data.decode("utf-8", errors="replace")
422
+
423
+
424
+ # ------------------------------------------------------------------
425
+ # Pattern tables (static text — data, never executed)
426
+ # ------------------------------------------------------------------
427
+ # Each entry: (label, compiled regex). Lines are matched
428
+ # one at a time; a "hit" records (line_number, label).
429
+
430
+ SECRET_PATTERNS = [
431
+ ("an SSH key or .ssh store path",
432
+ re.compile(r"\.ssh[/\\]|id_(rsa|dsa|ecdsa|ed25519)\b")),
433
+ ("an AWS credentials/config path",
434
+ re.compile(r"\.aws[/\\]|\.aws\b")),
435
+ ("a GnuPG store path", re.compile(r"\.gnupg\b")),
436
+ ("a .netrc credentials file", re.compile(r"\.netrc\b|_netrc\b")),
437
+ ("a stored-credentials file",
438
+ re.compile(r"\.git-credentials\b|\.docker[/\\]config\.json"
439
+ r"|\.kube[/\\]config\b")),
440
+ ("a browser credential store",
441
+ re.compile(r"Login Data|Local State|logins\.json|key4\.db"
442
+ r"|places\.sqlite|Cookies\.sqlite")),
443
+ ("a browser profile directory",
444
+ re.compile(r"User Data[/\\]|[/\\]Mozilla[/\\]|[/\\]Firefox[/\\]"
445
+ r"|profiles\.ini")),
446
+ ("a .env secrets file",
447
+ re.compile(r"[\"'][^\"']*\.env[\"']|(?:cat|open|read)\b"
448
+ r"[^\n]*\.env\b")),
449
+ ]
450
+
451
+ _ENV_ACCESS = re.compile(
452
+ r"os\.environ|os\.getenv|environ\.get|process\.env|ENV\["
453
+ r"|getenv\s*\(")
454
+ _ENV_SECRET_NAME = re.compile(
455
+ r"SECRET|TOKEN|PASSWORD|PASSWD|API_?KEY|PRIVATE_?KEY"
456
+ r"|CREDENTIAL|AWS_|GITHUB_|GH_TOKEN|OPENAI_|ANTHROPIC_"
457
+ r"|STRIPE_")
458
+ _ENV_WHOLE = re.compile(
459
+ r"dict\(\s*os\.environ|list\(\s*os\.environ"
460
+ r"|json\.dumps\([^)]*os\.environ|\{\*\*os\.environ"
461
+ r"|os\.environ\.items\(\)|os\.environ\.copy\(\)")
462
+
463
+ SEND_PATTERNS = [
464
+ ("an HTTP client call",
465
+ re.compile(r"requests\.(get|post|put|patch|delete|request"
466
+ r"|head)\s*\(|httpx\.(get|post|put|patch|delete"
467
+ r"|request|stream)\s*\(")),
468
+ ("a urllib request",
469
+ re.compile(r"urlopen\s*\(|urllib\.request")),
470
+ ("a raw HTTP connection",
471
+ re.compile(r"http\.client\.|HTTPSConnection|HTTPConnection")),
472
+ ("a socket connection",
473
+ re.compile(r"socket\.socket\s*\(|create_connection\s*\(")),
474
+ ("a browser-style fetch",
475
+ re.compile(r"\bfetch\s*\(|axios\.(get|post|put|delete"
476
+ r"|request)|XMLHttpRequest")),
477
+ ("a shell network tool",
478
+ re.compile(r"\bcurl\b|\bwget\b|\bnc\s+-|netcat\b"
479
+ r"|Invoke-WebRequest|Invoke-RestMethod")),
480
+ ("a mail/FTP/telnet client",
481
+ re.compile(r"smtplib\.|ftplib\.|telnetlib\.|websocket")),
482
+ ]
483
+
484
+ _URL_RE = re.compile(r"(?:https?|ftp|wss?)://([A-Za-z0-9.\-]+)")
485
+
486
+ CAPTURE_PATTERNS = {
487
+ "screen": [
488
+ ("a screenshot call",
489
+ re.compile(r"pyautogui\.screenshot|ImageGrab\.grab"
490
+ r"|\bmss\.mss|pyscreenshot|\bscreencapture\b"
491
+ r"|\bscrot\b")),
492
+ ],
493
+ "keyboard": [
494
+ ("a keyboard hook/listener",
495
+ re.compile(r"pynput|GetAsyncKeyState|pyxhook"
496
+ r"|keyboard\.(on_press|record|hook)"
497
+ r"|\bkeylogger?\b")),
498
+ ],
499
+ "audio": [
500
+ ("a microphone/audio capture",
501
+ re.compile(r"pyaudio|sounddevice|\barecord\b"
502
+ r"|Microphone\s*\(")),
503
+ ],
504
+ "camera": [
505
+ ("a camera capture",
506
+ re.compile(r"cv2\.VideoCapture|VideoCapture\s*\(")),
507
+ ],
508
+ }
509
+
510
+ CAPTURE_DISCLOSURE_WORDS = {
511
+ "screen": ("screenshot", "screen capture", "screen recording",
512
+ "captures the screen", "screen grab"),
513
+ "keyboard": ("keystroke", "keyboard", "key logging",
514
+ "keylogging"),
515
+ "audio": ("microphone", "audio recording", "records audio",
516
+ "voice recording"),
517
+ "camera": ("webcam", "camera", "video capture"),
518
+ "history": ("browser history", "browsing history", "history"),
519
+ }
520
+
521
+ _SQLITE_RE = re.compile(r"sqlite3|sqlite\b|\bSELECT\b")
522
+
523
+ PERSIST_PATTERNS = [
524
+ ("a file save/write",
525
+ re.compile(r"\.save\s*\(|open\s*\([^)]*[\"'][wa][\"']"
526
+ r"|\.write\s*\(")),
527
+ ("an endless loop",
528
+ re.compile(r"while\s+True\s*:|while\s*\(\s*true\s*\)"
529
+ r"|for\s*\(\s*;\s*;")),
530
+ ("an autostart/persistence mechanism",
531
+ re.compile(r"crontab|LaunchAgent|schtasks|autostart"
532
+ r"|systemd")),
533
+ ]
534
+
535
+ ENCODE_PATTERNS = [
536
+ ("a base64/hex/compression encoder",
537
+ re.compile(r"b64encode|b64decode|hexlify|unhexlify"
538
+ r"|codecs\.encode|zlib\.compress|gzip\.compress"
539
+ r"|bz2\.compress|lzma\.compress")),
540
+ ]
541
+
542
+ COPY_OUT_PATTERNS = [
543
+ ("a copy/move out",
544
+ re.compile(r"shutil\.(copy|copy2|copyfile|move)\b"
545
+ r"|os\.rename\s*\(|\bcp\s+|\bmv\s+|\bscp\b"
546
+ r"|\brsync\b")),
547
+ ]
548
+
549
+ OUTSIDE_TOKENS = (
550
+ "~/", "$HOME", "%USERPROFILE%", "/Users/", "/home/",
551
+ "/etc/", "/root/", "/tmp/", "/var/", "C:\\Users",
552
+ "C:/Users",
553
+ )
554
+
555
+ _READ_HINT = re.compile(
556
+ r"\bopen\s*\(|\bread\b|\bcat\s|Path\s*\(|expanduser"
557
+ r"|Path\.home")
558
+ _WRITE_HINT = re.compile(
559
+ r"open\s*\([^)]*[\"'][wa][\"']|\.write\s*\(|\.save\s*\("
560
+ r"|>\s*[\"']?(?:/|~|\$)")
561
+
562
+
563
+ def _is_comment_line(line, rel):
564
+ """A full-line comment, for the hygiene checks: a
565
+ comment MENTIONING shell=True or eval is documentation,
566
+ not code. (Danger ingredients deliberately still scan
567
+ comment lines — capability evidence is judged
568
+ conservatively.)"""
569
+ stripped = line.lstrip()
570
+ if stripped.startswith("#"):
571
+ return True
572
+ ext = os.path.splitext(rel)[1].lower()
573
+ if ext in (".js", ".mjs", ".cjs", ".ts", ".php"):
574
+ return stripped.startswith("//")
575
+ return False
576
+
577
+
578
+ def _hits(lines, patterns):
579
+ """All (line, label) hits for a pattern list."""
580
+ out = []
581
+ for lineno, line in enumerate(lines, 1):
582
+ for label, rx in patterns:
583
+ if rx.search(line):
584
+ out.append((lineno, label))
585
+ break
586
+ return out
587
+
588
+
589
+ def _first(hits):
590
+ return hits[0] if hits else None
591
+
592
+
593
+ class FileScan:
594
+ """The ingredient evidence found in one script file."""
595
+
596
+ def __init__(self, rel, lines):
597
+ self.rel = rel
598
+ self.lines = lines
599
+ self.secret = _hits(lines, SECRET_PATTERNS)
600
+ self.env_secret = [] # (line, label)
601
+ self.whole_env = [] # (line, label)
602
+ for lineno, line in enumerate(lines, 1):
603
+ if _ENV_ACCESS.search(line) and \
604
+ _ENV_SECRET_NAME.search(line):
605
+ self.env_secret.append(
606
+ (lineno, "an environment-secret read"))
607
+ if _ENV_WHOLE.search(line):
608
+ self.whole_env.append(
609
+ (lineno, "a whole-environment read"))
610
+ self.send = _hits(lines, SEND_PATTERNS)
611
+ self.hosts = [] # (host, line)
612
+ for lineno, line in enumerate(lines, 1):
613
+ for m in _URL_RE.finditer(line):
614
+ self.hosts.append((m.group(1), lineno))
615
+ self.captures = {} # kind -> [(line, label)]
616
+ for kind, pats in CAPTURE_PATTERNS.items():
617
+ hits = _hits(lines, pats)
618
+ if hits:
619
+ self.captures[kind] = hits
620
+ # browser-history scraping: a history store read
621
+ # plus a database read in the same file
622
+ hist_lines = [
623
+ (n, "a browser history store")
624
+ for n, line in enumerate(lines, 1)
625
+ if re.search(r"places\.sqlite|\bHistory\b", line)
626
+ ]
627
+ if hist_lines and any(
628
+ _SQLITE_RE.search(l) for l in lines):
629
+ self.captures["history"] = hist_lines
630
+ self.persist = _hits(lines, PERSIST_PATTERNS)
631
+ self.encode = _hits(lines, ENCODE_PATTERNS)
632
+ self.copy_out = []
633
+ for lineno, line in enumerate(lines, 1):
634
+ if any(rx.search(line)
635
+ for _, rx in COPY_OUT_PATTERNS) and \
636
+ _outside_token(line):
637
+ self.copy_out.append(
638
+ (lineno, "a copy/move to an outside path"))
639
+ self.read_outside = [] # (line, label)
640
+ self.write_outside = [] # (line, label)
641
+ for lineno, line in enumerate(lines, 1):
642
+ if _outside_token(line):
643
+ if _READ_HINT.search(line):
644
+ self.read_outside.append(
645
+ (lineno, "a read of a path outside "
646
+ "the pack"))
647
+ if _WRITE_HINT.search(line):
648
+ self.write_outside.append(
649
+ (lineno, "a write to a path outside "
650
+ "the pack"))
651
+
652
+ # -- send-segment analysis --------------------------------
653
+ def send_segments(self):
654
+ """Approximate call segments for each send hit:
655
+ the hit line plus following lines until the parens
656
+ balance. Used to see what actually flows out."""
657
+ segments = []
658
+ for lineno, _label in self.send:
659
+ chunk = []
660
+ depth = 0
661
+ started = False
662
+ for j in range(lineno - 1, min(len(self.lines),
663
+ lineno + 14)):
664
+ text = self.lines[j]
665
+ chunk.append(text)
666
+ depth += text.count("(") - text.count(")")
667
+ if "(" in text:
668
+ started = True
669
+ if started and depth <= 0:
670
+ break
671
+ segments.append((lineno, "\n".join(chunk)))
672
+ return segments
673
+
674
+ def encoded_payload_sent(self):
675
+ """(encode_line, send_line) when an encoded value
676
+ reaches a send: the encoder call inside the send
677
+ segment, a variable assigned from an encoder used
678
+ in the segment, or os.environ inside the segment
679
+ (whole-env exfil is judged under STEALING, not
680
+ here)."""
681
+ if not self.send or not self.encode:
682
+ return None
683
+ enc_vars = set()
684
+ assign = re.compile(
685
+ r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*.*"
686
+ r"(b64encode|hexlify|codecs\.encode|zlib\.compress"
687
+ r"|gzip\.compress|bz2\.compress|lzma\.compress)")
688
+ for line in self.lines:
689
+ m = assign.match(line)
690
+ if m:
691
+ enc_vars.add(m.group(1))
692
+ enc_line = self.encode[0][0]
693
+ for send_line, segment in self.send_segments():
694
+ if any(rx.search(segment)
695
+ for _, rx in ENCODE_PATTERNS):
696
+ return (enc_line, send_line)
697
+ for var in enc_vars:
698
+ if re.search(r"\b%s\b" % re.escape(var),
699
+ segment):
700
+ return (enc_line, send_line)
701
+ return None
702
+
703
+
704
+ def _outside_token(line):
705
+ return any(tok in line for tok in OUTSIDE_TOKENS)
706
+
707
+
708
+ # ------------------------------------------------------------------
709
+ # Disclosure (what the pack's own docs admit to)
710
+ # ------------------------------------------------------------------
711
+
712
+ def doc_hosts(doc_text):
713
+ return {m.group(1).lower()
714
+ for m in _URL_RE.finditer(doc_text)}
715
+
716
+
717
+ def host_disclosed(host, hosts):
718
+ host = host.lower()
719
+ return any(host == h or host.endswith("." + h)
720
+ for h in hosts)
721
+
722
+
723
+ _OFFLINE_CLAIM = re.compile(
724
+ r"(?i)\boffline\b|no network|never (sends|phones|connects"
725
+ r"|shares)|local[- ]only|works locally|does not (send"
726
+ r"|connect|phone|share)|nothing (is )?(sent|leaves|shared)")
727
+
728
+
729
+ def capture_disclosed(kind, doc_text_lower):
730
+ return any(word in doc_text_lower
731
+ for word in CAPTURE_DISCLOSURE_WORDS[kind])
732
+
733
+
734
+ # ------------------------------------------------------------------
735
+ # The danger tests + short-of-danger warnings (per script)
736
+ # ------------------------------------------------------------------
737
+
738
+ def scan_script(scan, docs_text, docs_lower, hosts_in_docs,
739
+ offline_claim):
740
+ out = []
741
+ rel = scan.rel
742
+
743
+ def cite(hit):
744
+ return hit[0] if hit else None
745
+
746
+ secret_hits = scan.secret + scan.env_secret + scan.whole_env
747
+ send_hit = _first(scan.send)
748
+ encoded = scan.encoded_payload_sent()
749
+ undisclosed_hosts = [
750
+ (host, line) for host, line in scan.hosts
751
+ if not host_disclosed(host, hosts_in_docs)
752
+ ]
753
+
754
+ # ---- Test 1: STEALING --------------------------------
755
+ egress = send_hit or _first(scan.copy_out)
756
+ stealing_fired = False
757
+ if secret_hits and egress:
758
+ stealing_fired = True
759
+ s_line, s_label = secret_hits[0]
760
+ e_line, e_label = egress
761
+ out.append(finding(
762
+ "DANGER_STEALING", "ERROR", rel, s_line,
763
+ "STEALING combination: this file reads secrets "
764
+ "(%s, line %d) and has an egress path (%s, line "
765
+ "%d). Credential material plus a way out is the "
766
+ "stealing test — the pack can take what it reads "
767
+ "off the machine." % (s_label, s_line, e_label,
768
+ e_line),
769
+ "do not install; if you maintain this pack, "
770
+ "remove the credential read or the egress — a "
771
+ "legitimate pack needs at most one of them",
772
+ danger=True))
773
+ elif secret_hits:
774
+ s_line, s_label = secret_hits[0]
775
+ out.append(finding(
776
+ "SECRET_ACCESS", "WARN", rel, s_line,
777
+ "this file reads secrets (%s, line %d) but no "
778
+ "send or copy-out was found in the same file — "
779
+ "short of the stealing combination, and still "
780
+ "worth asking why a skill needs it"
781
+ % (s_label, s_line),
782
+ "confirm the pack genuinely needs this access "
783
+ "before installing"))
784
+
785
+ # ---- Test 2: SPYING ----------------------------------
786
+ for kind, hits in sorted(scan.captures.items()):
787
+ c_line, c_label = hits[0]
788
+ persist_hit = _first(scan.persist)
789
+ if send_hit or persist_hit:
790
+ if capture_disclosed(kind, docs_lower):
791
+ out.append(finding(
792
+ "DISCLOSED_CAPTURE", "WARN", rel, c_line,
793
+ "this file captures %s (%s, line %d) and "
794
+ "persists or sends it — and the pack's "
795
+ "docs DO disclose %s capture, so this is "
796
+ "not the spying test; it is a capability "
797
+ "to accept knowingly"
798
+ % (kind, c_label, c_line, kind),
799
+ "install only if you want this capture "
800
+ "happening"))
801
+ else:
802
+ via = send_hit or persist_hit
803
+ out.append(finding(
804
+ "DANGER_SPYING", "ERROR", rel, c_line,
805
+ "SPYING combination: this file captures "
806
+ "%s (%s, line %d), persists or sends it "
807
+ "(%s, line %d), and the pack's docs "
808
+ "nowhere disclose %s capture — capture "
809
+ "plus a way to keep or move it, in "
810
+ "secret, is the spying test."
811
+ % (kind, c_label, c_line, via[1], via[0],
812
+ kind),
813
+ "do not install; undisclosed capture is "
814
+ "never a feature",
815
+ danger=True))
816
+ else:
817
+ out.append(finding(
818
+ "CAPTURE_CAPABILITY", "WARN", rel, c_line,
819
+ "this file can capture %s (%s, line %d) but "
820
+ "no persistence or send was found in the "
821
+ "same file — capability alone is short of "
822
+ "the spying combination"
823
+ % (kind, c_label, c_line),
824
+ "ask why the pack ships a capture API it "
825
+ "does not appear to use"))
826
+
827
+ # ---- Test 3: HIDDEN SENDING ---------------------------
828
+ # A send already judged as the egress half of STEALING
829
+ # in this file is not counted a second time here.
830
+ hidden_reason = None
831
+ if send_hit and not stealing_fired:
832
+ if offline_claim:
833
+ hidden_reason = (
834
+ "the pack's docs claim it works offline / "
835
+ "local-only, yet this file makes a network "
836
+ "call (%s, line %d) — a send the docs deny "
837
+ "exists" % (send_hit[1], send_hit[0]))
838
+ elif encoded and (undisclosed_hosts or not scan.hosts):
839
+ hidden_reason = (
840
+ "an encoded payload reaches the network "
841
+ "call (encoder at line %d, send at line %d) "
842
+ "to an endpoint the docs never disclose — "
843
+ "neither what is sent nor where it goes "
844
+ "can be read off the pack"
845
+ % encoded)
846
+ elif scan.read_outside and undisclosed_hosts:
847
+ hidden_reason = (
848
+ "this file reads local files from outside "
849
+ "the pack (line %d) and sends to an endpoint "
850
+ "the docs never mention (%s, line %d) — "
851
+ "local contents leaving to an unnamed place"
852
+ % (scan.read_outside[0][0],
853
+ undisclosed_hosts[0][0],
854
+ undisclosed_hosts[0][1]))
855
+ elif scan.write_outside and undisclosed_hosts:
856
+ hidden_reason = (
857
+ "this file writes outside the pack (line "
858
+ "%d) and sends to an endpoint the docs never "
859
+ "mention (%s, line %d)"
860
+ % (scan.write_outside[0][0],
861
+ undisclosed_hosts[0][0],
862
+ undisclosed_hosts[0][1]))
863
+ if hidden_reason:
864
+ out.append(finding(
865
+ "DANGER_HIDDEN_SENDING", "ERROR", rel,
866
+ send_hit[0],
867
+ "HIDDEN-SENDING combination: " + hidden_reason
868
+ + ". Concealment of what is sent, or to where, "
869
+ "is the hidden-sending test.",
870
+ "do not install until the send is disclosed "
871
+ "in the docs and the payload is readable in "
872
+ "the code",
873
+ danger=True))
874
+ elif send_hit:
875
+ # short-of-danger network warnings (suppressed when
876
+ # a danger finding in this file already covers the
877
+ # send)
878
+ covered = any(f["danger"] for f in out)
879
+ if encoded and not covered:
880
+ out.append(finding(
881
+ "ENCODED_PAYLOAD", "WARN", rel, encoded[1],
882
+ "an encoded payload reaches a network call "
883
+ "(encoder at line %d, send at line %d) to a "
884
+ "disclosed endpoint — the destination is "
885
+ "honest, what is sent is opaque"
886
+ % encoded,
887
+ "ask the pack's author what the encoded "
888
+ "payload contains"))
889
+ elif undisclosed_hosts and not covered:
890
+ host, h_line = undisclosed_hosts[0]
891
+ out.append(finding(
892
+ "UNDISCLOSED_NETWORK", "WARN", rel, h_line,
893
+ "this file calls %s (line %d), an endpoint "
894
+ "that appears nowhere in the pack's SKILL.md "
895
+ "/ README.md — undisclosed network use, "
896
+ "short of the hidden-sending combination "
897
+ "(no encoded payload, no local-file read, "
898
+ "no offline claim)" % (host, h_line),
899
+ "the pack should name every endpoint it "
900
+ "calls in its docs"))
901
+ elif not scan.hosts and not covered:
902
+ out.append(finding(
903
+ "DYNAMIC_ENDPOINT", "WARN", rel, send_hit[0],
904
+ "this file makes a network call (%s, line "
905
+ "%d) with no literal URL in the code — the "
906
+ "endpoint cannot be checked against the docs"
907
+ % (send_hit[1], send_hit[0]),
908
+ "read where the URL is built before "
909
+ "installing"))
910
+
911
+ # ---- Hygiene that lives in scripts --------------------
912
+ # (full-line comments are skipped here: a comment that
913
+ # mentions shell=True is documentation, not code)
914
+ code_lines = [(n, line) for n, line in
915
+ enumerate(scan.lines, 1)
916
+ if not _is_comment_line(line, rel)]
917
+ for lineno, line in code_lines:
918
+ if "shell=True" in line or "shell: true" in line:
919
+ out.append(finding(
920
+ "HYGIENE_SHELL_TRUE", "WARN", rel, lineno,
921
+ "subprocess runs with shell=True — command "
922
+ "strings become shell input, so any value "
923
+ "that reaches the command is an injection "
924
+ "path",
925
+ "use an argument list with shell=False"))
926
+ break
927
+ dyn = None
928
+ for lineno, line in code_lines:
929
+ if re.search(r"\beval\s*\(|\bexec\s*\(|os\.system\s*\("
930
+ r"|os\.popen\s*\(|__import__\s*\(", line):
931
+ dyn = lineno
932
+ break
933
+ if dyn is not None:
934
+ out.append(finding(
935
+ "HYGIENE_DYNAMIC_EXEC", "WARN", rel, dyn,
936
+ "dynamic execution (eval / exec / os.system / "
937
+ "os.popen / __import__) at line %d — code whose "
938
+ "behaviour cannot be fully read statically"
939
+ % dyn,
940
+ "review exactly what strings can reach it"))
941
+ pipe = None
942
+ for lineno, line in code_lines:
943
+ if re.search(r"(curl|wget)[^|\n]*\|\s*(sh|bash|zsh)\b",
944
+ line) or re.search(
945
+ r"(sh|bash)\s+<\(\s*(curl|wget)", line):
946
+ pipe = lineno
947
+ break
948
+ if pipe is not None or (scan.send and dyn is not None):
949
+ where = pipe if pipe is not None else dyn
950
+ out.append(finding(
951
+ "HYGIENE_DOWNLOAD_EXEC", "WARN", rel, where,
952
+ "downloaded content is executed (a download "
953
+ "piped to a shell, or a download and a dynamic "
954
+ "exec in the same file) — the pack's real "
955
+ "behaviour would live on a server, not in this "
956
+ "code",
957
+ "never install a pack that fetches its own "
958
+ "logic at run time"))
959
+ if scan.write_outside and not any(
960
+ f["rule"] == "DANGER_HIDDEN_SENDING" for f in out):
961
+ w_line, w_label = scan.write_outside[0]
962
+ out.append(finding(
963
+ "WRITES_OUTSIDE_PACK", "WARN", rel, w_line,
964
+ "%s (line %d) — a skill should keep its writes "
965
+ "inside its own folder or the user's explicit "
966
+ "workspace" % (w_label, w_line),
967
+ "confirm the destination is one you accept"))
968
+ return out
969
+
970
+
971
+ # ------------------------------------------------------------------
972
+ # Hygiene over text files (concealment + obfuscation)
973
+ # ------------------------------------------------------------------
974
+
975
+ _INVISIBLE = {
976
+ "\u200b": "ZERO WIDTH SPACE",
977
+ "\u200c": "ZERO WIDTH NON-JOINER",
978
+ "\u200d": "ZERO WIDTH JOINER",
979
+ "\u2060": "WORD JOINER",
980
+ "\ufeff": "ZERO WIDTH NO-BREAK SPACE",
981
+ "\u00ad": "SOFT HYPHEN",
982
+ "\u180e": "MONGOLIAN VOWEL SEPARATOR",
983
+ }
984
+ for _cp in list(range(0x202A, 0x202F)) + list(range(0x2066, 0x206A)):
985
+ _INVISIBLE[chr(_cp)] = "BIDI CONTROL U+%04X" % _cp
986
+
987
+
988
+ def _invisible_hits(text):
989
+ """(count, first_line, [names]) for invisible chars.
990
+ A leading BOM is a file signature, not concealment,
991
+ and is ignored. Unicode tag characters (U+E0000–
992
+ U+E007F) are counted by range."""
993
+ total = 0
994
+ first_line = None
995
+ names = []
996
+ body = text[1:] if text.startswith("\ufeff") else text
997
+ for lineno, line in enumerate(body.splitlines(), 1):
998
+ for ch in line:
999
+ name = _INVISIBLE.get(ch)
1000
+ if name is None and 0xE0000 <= ord(ch) <= 0xE007F:
1001
+ name = "UNICODE TAG CHARACTER U+%05X" % ord(ch)
1002
+ if name is not None:
1003
+ total += 1
1004
+ if first_line is None:
1005
+ first_line = lineno
1006
+ if name not in names and len(names) < 3:
1007
+ names.append(name)
1008
+ return total, first_line, names
1009
+
1010
+
1011
+ _B64_TOKEN = re.compile(r"[A-Za-z0-9+/=]{%d,}" % BASE64_BLOB_MIN)
1012
+ _HEX_TOKEN = re.compile(r"[0-9a-fA-F]{%d,}" % HEX_BLOB_MIN)
1013
+ _ESCAPE_RUN = re.compile(r"(?:\\x[0-9a-fA-F]{2}){%d,}"
1014
+ % ESCAPE_RUN_MIN)
1015
+
1016
+
1017
+ def scan_text_hygiene(rel, text, kind):
1018
+ out = []
1019
+ lines = text.splitlines()
1020
+ # obfuscation blobs (one finding per kind per file)
1021
+ found_kinds = set()
1022
+ for lineno, line in enumerate(lines, 1):
1023
+ for m in _ESCAPE_RUN.finditer(line):
1024
+ if "escape-run" not in found_kinds:
1025
+ found_kinds.add("escape-run")
1026
+ out.append(finding(
1027
+ "HYGIENE_OBFUSCATION", "WARN", rel, lineno,
1028
+ "a run of %d+ \\xNN escapes at line %d — "
1029
+ "code or text stored as escapes cannot be "
1030
+ "read at a glance, which is the point of "
1031
+ "storing it that way" % (ESCAPE_RUN_MIN,
1032
+ lineno),
1033
+ "decode it in the open or remove it"))
1034
+ for m in _B64_TOKEN.finditer(line):
1035
+ token = m.group(0)
1036
+ if all(c in "0123456789abcdefABCDEF"
1037
+ for c in token):
1038
+ kind_name = "hex-blob"
1039
+ else:
1040
+ kind_name = "base64-blob"
1041
+ if kind_name in found_kinds:
1042
+ continue
1043
+ if kind_name == "base64-blob" and not (
1044
+ any(c.isupper() for c in token)
1045
+ and any(c.islower() for c in token)
1046
+ and any(c.isdigit() or c in "+/"
1047
+ for c in token)):
1048
+ continue
1049
+ found_kinds.add(kind_name)
1050
+ out.append(finding(
1051
+ "HYGIENE_OBFUSCATION", "WARN", rel, lineno,
1052
+ "a %s of %d+ characters at line %d — content "
1053
+ "encoded as one opaque blob cannot be "
1054
+ "reviewed as written"
1055
+ % (kind_name.replace("-", " "),
1056
+ HEX_BLOB_MIN if kind_name == "hex-blob"
1057
+ else BASE64_BLOB_MIN, lineno),
1058
+ "keep pack content readable; encoded blobs "
1059
+ "belong in the open or not at all"))
1060
+ # invisible characters
1061
+ count, first_line, names = _invisible_hits(text)
1062
+ if count:
1063
+ out.append(finding(
1064
+ "CONCEALED_UNICODE", "WARN", rel, first_line,
1065
+ "%d invisible character(s) in this file (first "
1066
+ "at line %d): %s — invisible text can carry "
1067
+ "instructions a reader never sees"
1068
+ % (count, first_line, ", ".join(names)),
1069
+ "strip invisible characters from pack files"))
1070
+ if kind == "markdown":
1071
+ # long blank-line runs with content after them
1072
+ run = 0
1073
+ run_start = None
1074
+ for lineno, line in enumerate(lines, 1):
1075
+ if not line.strip():
1076
+ if run == 0:
1077
+ run_start = lineno
1078
+ run += 1
1079
+ else:
1080
+ if run >= BLANK_RUN_MIN:
1081
+ out.append(finding(
1082
+ "CONCEALED_BLANK_RUN", "WARN", rel,
1083
+ run_start,
1084
+ "%d consecutive blank lines starting "
1085
+ "at line %d, with content after them — "
1086
+ "a known place to hide instructions "
1087
+ "past the point a reviewer stops "
1088
+ "scrolling" % (run, run_start),
1089
+ "collapse the gap; keep the whole "
1090
+ "file in review range"))
1091
+ run = 0
1092
+ # long HTML comments
1093
+ for m in re.finditer(r"<!--(.*?)-->", text,
1094
+ re.DOTALL):
1095
+ if len(m.group(1)) >= HIDDEN_COMMENT_MIN:
1096
+ line_no = text[:m.start()].count("\n") + 1
1097
+ out.append(finding(
1098
+ "HIDDEN_COMMENT", "WARN", rel, line_no,
1099
+ "an HTML comment of %d characters at "
1100
+ "line %d — comments do not render, so "
1101
+ "long ones are a place to put text a "
1102
+ "reader of the rendered page never sees"
1103
+ % (len(m.group(1)), line_no),
1104
+ "move anything that matters into the "
1105
+ "visible text"))
1106
+ return out
1107
+
1108
+
1109
+ # ------------------------------------------------------------------
1110
+ # Install hooks
1111
+ # ------------------------------------------------------------------
1112
+
1113
+ _INSTALL_NAMES = {"setup.py", "install.py", "install.sh",
1114
+ "install.bash", "setup.sh"}
1115
+ _PKG_HOOK = re.compile(r'"(preinstall|install|postinstall)"'
1116
+ r'\s*:')
1117
+
1118
+
1119
+ def scan_install_hooks(pack_dir, records):
1120
+ out = []
1121
+ for rec in records:
1122
+ base = rec["path"].rsplit("/", 1)[-1]
1123
+ ext = os.path.splitext(base)[1].lower()
1124
+ reason = None
1125
+ if base in _INSTALL_NAMES:
1126
+ reason = "an install/setup script"
1127
+ elif ext == ".pth":
1128
+ reason = "a .pth file (its lines are executed " \
1129
+ "by the interpreter at startup)"
1130
+ elif base == "package.json" and not rec["binary"]:
1131
+ text = read_text(pack_dir, rec["path"]) or ""
1132
+ if _PKG_HOOK.search(text):
1133
+ reason = "a package.json with an install " \
1134
+ "hook (preinstall/install/" \
1135
+ "postinstall)"
1136
+ if reason:
1137
+ out.append(finding(
1138
+ "INSTALL_HOOK", "WARN", rec["path"], None,
1139
+ "this file runs at install time — %s. "
1140
+ "Install-time code executes before any "
1141
+ "review of the pack's normal flow"
1142
+ % reason,
1143
+ "read this file first, before installing"))
1144
+ return out
1145
+
1146
+
1147
+ # ------------------------------------------------------------------
1148
+ # References (SKILL.md links + script path literals)
1149
+ # ------------------------------------------------------------------
1150
+
1151
+ _MD_LINK = re.compile(
1152
+ r"!?\[[^\]]*\]\(\s*<?([^)\s>]+)>?(?:\s+\"[^\"]*\")?\s*\)")
1153
+ _BACKTICK = re.compile(r"`([^`\n]+)`")
1154
+ _SCRIPT_LITERAL = re.compile(r"[\"']([^\"'\n]{2,200})[\"']")
1155
+
1156
+
1157
+ def _clean_target(raw):
1158
+ target = raw.strip()
1159
+ if not target or "://" in target:
1160
+ return None
1161
+ if target.startswith(("#", "mailto:", "/", "~", "$",
1162
+ "<")):
1163
+ return None
1164
+ target = target.split("#", 1)[0].split("?", 1)[0]
1165
+ if not target:
1166
+ return None
1167
+ return target
1168
+
1169
+
1170
+ def _looks_like_path(target, from_backtick):
1171
+ if " " in target:
1172
+ return False
1173
+ ext = os.path.splitext(target)[1].lower()
1174
+ if "/" in target:
1175
+ return True
1176
+ if from_backtick:
1177
+ return ext in REF_EXTS
1178
+ return ext in REF_EXTS
1179
+
1180
+
1181
+ def _resolve(pack_dir, base_dir, target):
1182
+ """Returns "ok" | "missing" | "outside". Tries the
1183
+ base dir, then the pack root."""
1184
+ root = os.path.realpath(pack_dir)
1185
+ for base in (base_dir, pack_dir):
1186
+ cand = os.path.realpath(os.path.join(base, target))
1187
+ if cand == root or cand.startswith(root + os.sep):
1188
+ if os.path.exists(cand):
1189
+ return "ok"
1190
+ else:
1191
+ return "outside"
1192
+ return "missing"
1193
+
1194
+
1195
+ def scan_references(pack_dir, source_rel, text, is_script):
1196
+ out = []
1197
+ seen = set()
1198
+ targets = [] # (target, line)
1199
+ if is_script:
1200
+ for lineno, line in enumerate(text.splitlines(), 1):
1201
+ for m in _SCRIPT_LITERAL.finditer(line):
1202
+ raw = m.group(1)
1203
+ if raw.startswith(("./", "../")) or \
1204
+ raw.startswith(REF_PREFIX_DIRS) or \
1205
+ ("/" in raw and not raw.startswith(
1206
+ ("/", "~", "$")) and
1207
+ os.path.splitext(raw)[1].lower()
1208
+ in REF_EXTS):
1209
+ targets.append((raw, lineno))
1210
+ else:
1211
+ for lineno, line in enumerate(text.splitlines(), 1):
1212
+ for m in _MD_LINK.finditer(line):
1213
+ targets.append((m.group(1), lineno))
1214
+ for m in _BACKTICK.finditer(line):
1215
+ if _looks_like_path(m.group(1), True):
1216
+ targets.append((m.group(1), lineno))
1217
+ base_dir = os.path.join(
1218
+ pack_dir, os.path.dirname(source_rel.replace(
1219
+ "/", os.sep)))
1220
+ for raw, lineno in targets:
1221
+ target = _clean_target(raw)
1222
+ if target is None or (target, lineno) in seen:
1223
+ continue
1224
+ if not _looks_like_path(target, False):
1225
+ continue
1226
+ seen.add((target, lineno))
1227
+ status = _resolve(pack_dir, base_dir, target)
1228
+ if status == "missing":
1229
+ out.append(finding(
1230
+ "BROKEN_REFERENCE", "ERROR", source_rel,
1231
+ lineno,
1232
+ "referenced local file %r does not exist "
1233
+ "in the pack — the pack points at something "
1234
+ "that is not there" % target,
1235
+ "add the file, or fix the reference"))
1236
+ elif status == "outside":
1237
+ out.append(finding(
1238
+ "REFERENCE_OUTSIDE_PACK", "WARN",
1239
+ source_rel, lineno,
1240
+ "reference %r resolves outside the pack "
1241
+ "folder — the pack depends on, or points "
1242
+ "into, the wider filesystem" % target,
1243
+ "keep pack references inside the pack, or "
1244
+ "document the external dependency"))
1245
+ return out
1246
+
1247
+
1248
+ # ------------------------------------------------------------------
1249
+ # Structure checks
1250
+ # ------------------------------------------------------------------
1251
+
1252
+ _NAME_SLUG = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
1253
+ _TOOL_STAR = re.compile(r"^[A-Za-z][A-Za-z0-9_-]*\(\*\)$")
1254
+
1255
+
1256
+ def _norm_name(name):
1257
+ return name.strip().lower()
1258
+
1259
+
1260
+ def scan_structure(pack_dir, records, skill_rel, fields,
1261
+ parse_error, parse_line):
1262
+ out = []
1263
+ dirname = os.path.basename(os.path.realpath(pack_dir))
1264
+ if skill_rel is None:
1265
+ out.append(finding(
1266
+ "MISSING_SKILL_MD", "ERROR", None, None,
1267
+ "no SKILL.md at the pack root — whatever this "
1268
+ "folder is, a skill loader cannot load it as a "
1269
+ "skill",
1270
+ "a skill pack ships a SKILL.md at its root"))
1271
+ return out, None
1272
+ if os.path.basename(skill_rel) != "SKILL.md":
1273
+ out.append(finding(
1274
+ "SKILL_MD_CASE", "WARN", skill_rel, None,
1275
+ "the skill file is named %r, not SKILL.md — "
1276
+ "some loaders look for the exact name and will "
1277
+ "miss this pack" % os.path.basename(skill_rel),
1278
+ "rename it to SKILL.md"))
1279
+ if parse_error == "missing":
1280
+ out.append(finding(
1281
+ "FRONTMATTER_MISSING", "ERROR", skill_rel, 1,
1282
+ "SKILL.md has no --- frontmatter block — the "
1283
+ "name and description a loader registers the "
1284
+ "skill under are absent",
1285
+ "add a frontmatter block with name and "
1286
+ "description"))
1287
+ return out, None
1288
+ if parse_error:
1289
+ out.append(finding(
1290
+ "FRONTMATTER_PARSE", "ERROR", skill_rel,
1291
+ parse_line,
1292
+ "the frontmatter does not parse: %s — a loader "
1293
+ "reading this block will fail or guess"
1294
+ % parse_error,
1295
+ "fix the frontmatter line"))
1296
+ return out, fields
1297
+ name = field_text(fields, "name")
1298
+ description = field_text(fields, "description")
1299
+ if name is None or not name.strip():
1300
+ out.append(finding(
1301
+ "REQUIRED_FIELD", "ERROR", skill_rel, None,
1302
+ "frontmatter field 'name' is missing or empty "
1303
+ "— a skill is registered under its name",
1304
+ "add a name to the frontmatter"))
1305
+ if description is None or not description.strip():
1306
+ out.append(finding(
1307
+ "REQUIRED_FIELD", "ERROR", skill_rel, None,
1308
+ "frontmatter field 'description' is missing or "
1309
+ "empty — the description is how a loader "
1310
+ "decides when the skill applies",
1311
+ "add a description to the frontmatter"))
1312
+ if name and name.strip():
1313
+ if _norm_name(name) != _norm_name(dirname):
1314
+ out.append(finding(
1315
+ "NAME_DIR_MISMATCH", "WARN", skill_rel,
1316
+ None,
1317
+ "frontmatter name %r does not match the "
1318
+ "pack folder name %r — loaders and humans "
1319
+ "will call this pack two different things"
1320
+ % (name.strip(), dirname),
1321
+ "make the name and the folder agree"))
1322
+ if not _NAME_SLUG.match(name.strip()):
1323
+ out.append(finding(
1324
+ "NAME_FORMAT", "WARN", skill_rel, None,
1325
+ "skill name %r is not a lowercase "
1326
+ "kebab-case slug — some loaders reject or "
1327
+ "mangle names outside that shape"
1328
+ % name.strip(),
1329
+ "rename to lowercase words joined by "
1330
+ "hyphens"))
1331
+ for entry in field_entries(fields, "allowed-tools"):
1332
+ if entry == "*" or _TOOL_STAR.match(entry) or \
1333
+ entry == "Bash":
1334
+ out.append(finding(
1335
+ "BROAD_TOOL_GRANT", "WARN", skill_rel,
1336
+ None,
1337
+ "allowed-tools grants %r — the whole tool, "
1338
+ "unscoped. A grant that wide should be a "
1339
+ "deliberate, documented choice"
1340
+ % entry,
1341
+ "scope the grant (e.g. Bash(npm run *)), "
1342
+ "or document why the whole tool is needed"))
1343
+ return out, fields
1344
+
1345
+
1346
+ # ------------------------------------------------------------------
1347
+ # The audit driver
1348
+ # ------------------------------------------------------------------
1349
+
1350
+ def vet_pack(pack_dir):
1351
+ records = inventory(pack_dir)
1352
+ by_path = {r["path"]: r for r in records}
1353
+
1354
+ skill_rel = None
1355
+ for rec in records:
1356
+ base = rec["path"].rsplit("/", 1)[-1]
1357
+ if "/" not in rec["path"] and \
1358
+ base.lower() == "skill.md":
1359
+ if base == "SKILL.md":
1360
+ skill_rel = rec["path"]
1361
+ break
1362
+ skill_rel = skill_rel or rec["path"]
1363
+
1364
+ skill_text = None
1365
+ fields = None
1366
+ parse_error = None
1367
+ parse_line = None
1368
+ if skill_rel is not None:
1369
+ skill_text = read_text(pack_dir, skill_rel)
1370
+ if skill_text is None:
1371
+ skill_text = ""
1372
+ fields, _body, parse_error, parse_line = \
1373
+ parse_frontmatter(skill_text)
1374
+
1375
+ readme_text = ""
1376
+ for rec in records:
1377
+ base = rec["path"].rsplit("/", 1)[-1]
1378
+ if "/" not in rec["path"] and \
1379
+ base.lower() == "readme.md" and \
1380
+ not rec["binary"] and not rec["symlink_out"]:
1381
+ readme_text = read_text(pack_dir,
1382
+ rec["path"]) or ""
1383
+ rec["scanned"] = True
1384
+ break
1385
+
1386
+ docs_text = (skill_text or "") + "\n" + readme_text
1387
+ docs_lower = docs_text.lower()
1388
+ hosts_in_docs = doc_hosts(docs_text)
1389
+ offline_claim = bool(_OFFLINE_CLAIM.search(docs_text))
1390
+
1391
+ findings = []
1392
+ structure, _ = scan_structure(
1393
+ pack_dir, records, skill_rel, fields, parse_error,
1394
+ parse_line)
1395
+ findings.extend(structure)
1396
+
1397
+ for rec in records:
1398
+ if rec["bytes"] > LARGE_FILE_BYTES:
1399
+ findings.append(finding(
1400
+ "LARGE_FILE", "WARN", rec["path"], None,
1401
+ "this file is %d bytes — over the 1 MiB "
1402
+ "line for a pack that ships as reviewable "
1403
+ "text; bulk content hides bulk behaviour"
1404
+ % rec["bytes"],
1405
+ "split it, shrink it, or be ready to "
1406
+ "explain the bulk"))
1407
+ if rec["binary"]:
1408
+ findings.append(finding(
1409
+ "BUNDLED_BINARY", "WARN", rec["path"], None,
1410
+ "a binary file (%d bytes) — it cannot be "
1411
+ "statically vetted at all; its behaviour "
1412
+ "is whatever it does when run"
1413
+ % rec["bytes"],
1414
+ "install only if you trust the source "
1415
+ "enough to run unauditable code"))
1416
+ if rec["symlink_out"]:
1417
+ findings.append(finding(
1418
+ "SYMLINK_OUT", "INFO", rec["path"], None,
1419
+ "a symlink whose target leaves the pack — "
1420
+ "its target was not scanned; what it "
1421
+ "points at is outside this audit",
1422
+ "check the target before installing"))
1423
+
1424
+ findings.extend(scan_install_hooks(pack_dir, records))
1425
+
1426
+ for rec in records:
1427
+ if rec["binary"] or rec["symlink_out"]:
1428
+ continue
1429
+ if rec["kind"] not in ("script", "markdown", "text"):
1430
+ continue
1431
+ text = read_text(pack_dir, rec["path"])
1432
+ if text is None:
1433
+ continue
1434
+ rec["scanned"] = True
1435
+ findings.extend(
1436
+ scan_text_hygiene(rec["path"], text,
1437
+ rec["kind"]))
1438
+ if rec["kind"] == "script":
1439
+ lines = text.splitlines()
1440
+ scan = FileScan(rec["path"], lines)
1441
+ findings.extend(scan_script(
1442
+ scan, docs_text, docs_lower, hosts_in_docs,
1443
+ offline_claim))
1444
+ findings.extend(scan_references(
1445
+ pack_dir, rec["path"], text, True))
1446
+ if skill_rel is not None and \
1447
+ rec["path"] == skill_rel:
1448
+ findings.extend(scan_references(
1449
+ pack_dir, rec["path"], text, False))
1450
+
1451
+ findings = sort_findings(findings)
1452
+ summary = {
1453
+ "findings": len(findings),
1454
+ "errors": sum(1 for f in findings
1455
+ if f["severity"] == "ERROR"),
1456
+ "warnings": sum(1 for f in findings
1457
+ if f["severity"] == "WARN"),
1458
+ "info": sum(1 for f in findings
1459
+ if f["severity"] == "INFO"),
1460
+ "danger": sum(1 for f in findings if f["danger"]),
1461
+ }
1462
+ if summary["danger"]:
1463
+ verdict = "BLOCKED"
1464
+ elif summary["errors"] or summary["warnings"]:
1465
+ verdict = "REVIEW"
1466
+ else:
1467
+ verdict = "CLEAN"
1468
+ pack_name = field_text(fields, "name") if fields else None
1469
+ return {
1470
+ "tool": TOOL,
1471
+ "version": VERSION,
1472
+ "command": "vet",
1473
+ "pack": {
1474
+ "dir": pack_dir,
1475
+ "dirname": os.path.basename(
1476
+ os.path.realpath(pack_dir)),
1477
+ "name": pack_name,
1478
+ "skill_file": skill_rel,
1479
+ },
1480
+ "files": records,
1481
+ "findings": findings,
1482
+ "summary": summary,
1483
+ "verdict": verdict,
1484
+ "verdict_rule": VERDICT_RULE,
1485
+ "limits": LIMITS_LINE,
1486
+ }
1487
+
1488
+
1489
+ # ------------------------------------------------------------------
1490
+ # Rendering
1491
+ # ------------------------------------------------------------------
1492
+
1493
+ def _human_size(n):
1494
+ if n < 1024:
1495
+ return "%d B" % n
1496
+ if n < 1024 * 1024:
1497
+ return "%.1f KiB" % (n / 1024)
1498
+ return "%.1f MiB" % (n / (1024 * 1024))
1499
+
1500
+
1501
+ def _render_finding(f):
1502
+ head = "[%s] %s" % (f["rule"], f["file"] or "(pack)")
1503
+ if f["line"]:
1504
+ head += " · line %d" % f["line"]
1505
+ return "%s\n %s\n fix: %s" % (head, f["message"],
1506
+ f["fix"])
1507
+
1508
+
1509
+ def render_text(result):
1510
+ pack = result["pack"]
1511
+ files = result["files"]
1512
+ total = sum(r["bytes"] for r in files)
1513
+ lines = []
1514
+ lines.append(
1515
+ "%s v%s — vet · pack %s · %d files · %s total"
1516
+ % (TOOL, VERSION, pack["dirname"], len(files),
1517
+ _human_size(total)))
1518
+ if pack["name"]:
1519
+ lines.append("Frontmatter name: %s" % pack["name"])
1520
+ lines.append(
1521
+ "Read-only: it edits nothing, writes nothing, and "
1522
+ "never executes anything in the pack — every file "
1523
+ "is analysed as static text.")
1524
+ lines.append("Verdict rule: " + VERDICT_RULE)
1525
+ lines.append("Limits: " + LIMITS_LINE)
1526
+ lines.append("")
1527
+ lines.append("INVENTORY (%d files):" % len(files))
1528
+ for rec in files:
1529
+ lines.append(" %s — %s · %s"
1530
+ % (rec["path"], rec["kind"],
1531
+ _human_size(rec["bytes"])))
1532
+ findings = result["findings"]
1533
+ groups = [
1534
+ ("DANGER — proven stealing / spying / "
1535
+ "hidden-sending combinations",
1536
+ [f for f in findings if f["danger"]]),
1537
+ ("ERRORS — structure and references that do not "
1538
+ "work as shipped",
1539
+ [f for f in findings if f["severity"] == "ERROR"
1540
+ and not f["danger"]]),
1541
+ ("WARNINGS — short of danger, worth a human look",
1542
+ [f for f in findings if f["severity"] == "WARN"]),
1543
+ ("INFO — worth knowing",
1544
+ [f for f in findings if f["severity"] == "INFO"]),
1545
+ ]
1546
+ for title, group in groups:
1547
+ if not group:
1548
+ continue
1549
+ lines.append("")
1550
+ lines.append("%s (%d):" % (title, len(group)))
1551
+ for f in group:
1552
+ lines.append(_render_finding(f))
1553
+ s = result["summary"]
1554
+ lines.append("")
1555
+ if not findings:
1556
+ lines.append(
1557
+ "No findings — no structure errors, no danger "
1558
+ "combination, no hygiene warning fired. "
1559
+ "(Absence of findings is not proof of safety.)")
1560
+ lines.append(
1561
+ "Summary: %d findings · %d errors · %d warnings "
1562
+ "· %d info · %d danger"
1563
+ % (s["findings"], s["errors"], s["warnings"],
1564
+ s["info"], s["danger"]))
1565
+ verdict = result["verdict"]
1566
+ if verdict == "BLOCKED":
1567
+ lines.append(
1568
+ "Verdict: BLOCKED — %d proven danger "
1569
+ "combination(s) (stealing / spying / hidden "
1570
+ "sending). Do not install." % s["danger"])
1571
+ elif verdict == "REVIEW":
1572
+ lines.append(
1573
+ "Verdict: REVIEW — no danger combination "
1574
+ "found, but %d error(s) and %d warning(s) "
1575
+ "need a human look before installing."
1576
+ % (s["errors"], s["warnings"]))
1577
+ else:
1578
+ lines.append(
1579
+ "Verdict: CLEAN — no errors, no warnings, no "
1580
+ "danger combinations found by the static "
1581
+ "rules.")
1582
+ return "\n".join(lines)
1583
+
1584
+
1585
+ # ------------------------------------------------------------------
1586
+ # CLI
1587
+ # ------------------------------------------------------------------
1588
+
1589
+ class _JsonAwareParser(argparse.ArgumentParser):
1590
+ """ArgumentParser that reports usage errors as JSON in
1591
+ --json mode ({"error": ...} on stdout, same exit code
1592
+ 2), matching the house error contract."""
1593
+
1594
+ json_mode = False
1595
+
1596
+ def error(self, message):
1597
+ if self.json_mode:
1598
+ print(json.dumps({"error": message}))
1599
+ raise SystemExit(2)
1600
+ super().error(message)
1601
+
1602
+
1603
+ def cmd_vet(args):
1604
+ path = args.path
1605
+ if not os.path.exists(path):
1606
+ raise ToolError(
1607
+ "no such folder: %s" % path)
1608
+ if not os.path.isdir(path):
1609
+ raise ToolError(
1610
+ "not a folder: %s — point vet at the skill "
1611
+ "pack's root folder" % path)
1612
+ result = vet_pack(path)
1613
+ if args.json:
1614
+ print(json.dumps(result, indent=1))
1615
+ else:
1616
+ print(render_text(result))
1617
+ if result["verdict"] == "BLOCKED":
1618
+ return 1
1619
+ if result["verdict"] == "REVIEW" and args.strict:
1620
+ return 1
1621
+ return 0
1622
+
1623
+
1624
+ def main(argv=None):
1625
+ argv = sys.argv[1:] if argv is None else list(argv)
1626
+ _JsonAwareParser.json_mode = "--json" in argv
1627
+ ap = _JsonAwareParser(
1628
+ prog=TOOL,
1629
+ description="Statically vet an agent skill pack "
1630
+ "before installing it: frontmatter and "
1631
+ "structure, broken references, bundled "
1632
+ "binaries, and the three danger tests — "
1633
+ "stealing, spying, hidden sending — "
1634
+ "each proven only by combined evidence "
1635
+ "in the same file. Read-only, executes "
1636
+ "nothing (stdlib only, local only).")
1637
+ sub = ap.add_subparsers(dest="command", required=True,
1638
+ parser_class=_JsonAwareParser)
1639
+
1640
+ p_vet = sub.add_parser(
1641
+ "vet", help="vet one skill pack folder: exit 1 "
1642
+ "when the verdict is BLOCKED, 0 for "
1643
+ "CLEAN or REVIEW (1 for REVIEW with "
1644
+ "--strict), 2 for input errors")
1645
+ p_vet.add_argument(
1646
+ "path",
1647
+ help="the skill pack's root folder (the one "
1648
+ "holding SKILL.md)")
1649
+ p_vet.add_argument("--json", action="store_true",
1650
+ help="print one JSON object instead "
1651
+ "of text")
1652
+ p_vet.add_argument(
1653
+ "--strict", action="store_true",
1654
+ help="also exit 1 when the verdict is REVIEW")
1655
+ p_vet.set_defaults(func=cmd_vet)
1656
+
1657
+ a = ap.parse_args(argv)
1658
+
1659
+ def fail(msg):
1660
+ if getattr(a, "json", False):
1661
+ print(json.dumps({"error": msg}))
1662
+ else:
1663
+ print("ERROR: %s" % msg)
1664
+ return 2
1665
+
1666
+ try:
1667
+ return a.func(a)
1668
+ except ToolError as exc:
1669
+ return fail(str(exc))
1670
+
1671
+
1672
+ if __name__ == "__main__":
1673
+ sys.exit(main())