patdown 0.1.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.
- patdown/__init__.py +8 -0
- patdown/__main__.py +4 -0
- patdown/baseline.py +45 -0
- patdown/cli.py +299 -0
- patdown/discover.py +286 -0
- patdown/engine.py +95 -0
- patdown/findings.py +68 -0
- patdown/mcp/__init__.py +2 -0
- patdown/mcp/client.py +293 -0
- patdown/mcp/lockfile.py +120 -0
- patdown/mcp/scan.py +186 -0
- patdown/patterns.py +62 -0
- patdown/report/__init__.py +5 -0
- patdown/report/jsonout.py +19 -0
- patdown/report/sarif.py +67 -0
- patdown/report/terminal.py +90 -0
- patdown/rules/__init__.py +60 -0
- patdown/rules/base.py +149 -0
- patdown/rules/encoded.py +117 -0
- patdown/rules/exfil.py +228 -0
- patdown/rules/hidden_html.py +115 -0
- patdown/rules/hooks.py +134 -0
- patdown/rules/injection.py +186 -0
- patdown/rules/mcp_config.py +132 -0
- patdown/rules/secrets.py +80 -0
- patdown/rules/skills.py +101 -0
- patdown/rules/unicode_rules.py +283 -0
- patdown/textutil.py +189 -0
- patdown-0.1.0.dist-info/METADATA +254 -0
- patdown-0.1.0.dist-info/RECORD +34 -0
- patdown-0.1.0.dist-info/WHEEL +5 -0
- patdown-0.1.0.dist-info/entry_points.txt +2 -0
- patdown-0.1.0.dist-info/licenses/LICENSE +21 -0
- patdown-0.1.0.dist-info/top_level.txt +1 -0
patdown/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""patdown: frisk everything your AI agent reads.
|
|
2
|
+
|
|
3
|
+
Scans skills, rules files, hook configs and MCP servers for prompt injection,
|
|
4
|
+
hidden Unicode, exfiltration channels, leaked secrets and poisoned tool
|
|
5
|
+
descriptions. Zero dependencies.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
patdown/__main__.py
ADDED
patdown/baseline.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Baseline files: accept the findings you have reviewed, fail only on new ones."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
from patdown import __version__
|
|
9
|
+
from patdown.findings import Finding
|
|
10
|
+
|
|
11
|
+
BASELINE_FILENAME = ".patdown-baseline.json"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_baseline(path: str) -> set[str]:
|
|
15
|
+
if not os.path.isfile(path):
|
|
16
|
+
return set()
|
|
17
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
18
|
+
try:
|
|
19
|
+
data = json.load(fh)
|
|
20
|
+
except json.JSONDecodeError:
|
|
21
|
+
return set()
|
|
22
|
+
items = data.get("findings", []) if isinstance(data, dict) else []
|
|
23
|
+
return {str(i.get("fingerprint")) for i in items if isinstance(i, dict) and i.get("fingerprint")}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def write_baseline(path: str, findings: list[Finding]) -> None:
|
|
27
|
+
payload = {
|
|
28
|
+
"tool": "patdown",
|
|
29
|
+
"version": __version__,
|
|
30
|
+
"generated": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
31
|
+
"findings": [
|
|
32
|
+
{"fingerprint": f.fingerprint, "rule": f.rule, "path": f.path, "line": f.line, "message": f.message}
|
|
33
|
+
for f in findings
|
|
34
|
+
],
|
|
35
|
+
}
|
|
36
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
37
|
+
json.dump(payload, fh, indent=2, ensure_ascii=False)
|
|
38
|
+
fh.write("\n")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def apply_baseline(findings: list[Finding], baseline: set[str]) -> tuple[list[Finding], int]:
|
|
42
|
+
if not baseline:
|
|
43
|
+
return findings, 0
|
|
44
|
+
kept = [f for f in findings if f.fingerprint not in baseline]
|
|
45
|
+
return kept, len(findings) - len(kept)
|
patdown/cli.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from patdown import __version__
|
|
9
|
+
from patdown.baseline import BASELINE_FILENAME, apply_baseline, load_baseline, write_baseline
|
|
10
|
+
from patdown.discover import ScanOptions, home_targets, iter_documents
|
|
11
|
+
from patdown.engine import Scanner, dedupe
|
|
12
|
+
from patdown.findings import Finding, Severity
|
|
13
|
+
from patdown.report import render_json, render_sarif, render_terminal
|
|
14
|
+
from patdown.report.terminal import want_color
|
|
15
|
+
from patdown.rules import ALL_RULES, rule_by_id
|
|
16
|
+
from patdown.rules.base import KIND_MCP_CONFIG
|
|
17
|
+
from patdown.textutil import is_stealth_char
|
|
18
|
+
|
|
19
|
+
SUBCOMMANDS = ("scan", "mcp", "rules", "explain", "baseline", "strip")
|
|
20
|
+
EXIT_CLEAN, EXIT_FINDINGS, EXIT_ERROR = 0, 1, 2
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _add_output_args(p: argparse.ArgumentParser) -> None:
|
|
24
|
+
g = p.add_argument_group("output")
|
|
25
|
+
g.add_argument("--json", action="store_true", help="emit JSON instead of the terminal report")
|
|
26
|
+
g.add_argument("--sarif", action="store_true", help="emit SARIF 2.1.0 (GitHub code scanning)")
|
|
27
|
+
g.add_argument("-o", "--output", metavar="FILE", help="write the report to FILE instead of stdout")
|
|
28
|
+
g.add_argument("--fail-on", default="high", metavar="LEVEL", help="exit 1 if any finding is at or above LEVEL (critical/high/medium/low/info/never). Default: high")
|
|
29
|
+
g.add_argument("--min-severity", default="low", metavar="LEVEL", help="hide findings below LEVEL. Default: low")
|
|
30
|
+
g.add_argument("--ignore", action="append", default=[], metavar="RULE", help="disable a rule id (repeatable, comma-separated ok)")
|
|
31
|
+
g.add_argument("--only", action="append", default=[], metavar="RULE", help="run only these rule ids")
|
|
32
|
+
g.add_argument("--baseline", metavar="FILE", help=f"suppress findings listed in FILE (default: {BASELINE_FILENAME} if present)")
|
|
33
|
+
g.add_argument("--no-color", action="store_true", help="disable ANSI colours")
|
|
34
|
+
g.add_argument("--color", action="store_true", help="force ANSI colours")
|
|
35
|
+
g.add_argument("-q", "--quiet", action="store_true", help="print nothing but the summary line")
|
|
36
|
+
g.add_argument("-v", "--verbose", action="store_true", help="print progress to stderr")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _add_scan_args(p: argparse.ArgumentParser) -> None:
|
|
40
|
+
p.add_argument("paths", nargs="*", help="files or directories to scan (default: current directory)")
|
|
41
|
+
p.add_argument("--home", action="store_true", help="also scan your user-level agent config (~/.claude, ~/.cursor, ~/.codex, Claude Desktop, ...)")
|
|
42
|
+
p.add_argument("--exclude", action="append", default=[], metavar="GLOB", help="skip paths matching GLOB (repeatable)")
|
|
43
|
+
p.add_argument("--no-default-excludes", action="store_true", help="also scan node_modules, .git, build dirs, ...")
|
|
44
|
+
p.add_argument("--max-file-size", type=int, default=2 * 1024 * 1024, metavar="BYTES", help="skip files larger than this (default 2 MiB)")
|
|
45
|
+
p.add_argument("--follow-symlinks", action="store_true", help="follow symbolic links while walking")
|
|
46
|
+
p.add_argument("--mcp", action="store_true", help="also connect to every MCP server found in scanned config files and inspect its tools")
|
|
47
|
+
p.add_argument("--timeout", type=float, default=20.0, help="seconds to wait for an MCP server (with --mcp)")
|
|
48
|
+
p.add_argument("--lockfile", metavar="FILE", help="MCP lockfile to compare against (default: patdown.lock in the current directory if present)")
|
|
49
|
+
p.add_argument("--lock", action="store_true", help="write/update the MCP lockfile after scanning (with --mcp)")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
53
|
+
parser = argparse.ArgumentParser(
|
|
54
|
+
prog="patdown",
|
|
55
|
+
description="Frisk everything your AI agent reads: skills, rules files, hooks and MCP servers.",
|
|
56
|
+
epilog="Run `patdown` with no arguments to scan the current directory. Exit code 1 means findings at or above --fail-on.",
|
|
57
|
+
)
|
|
58
|
+
parser.add_argument("--version", action="version", version=f"patdown {__version__}")
|
|
59
|
+
sub = parser.add_subparsers(dest="cmd")
|
|
60
|
+
|
|
61
|
+
scan = sub.add_parser("scan", help="scan files and directories (default command)")
|
|
62
|
+
_add_scan_args(scan)
|
|
63
|
+
_add_output_args(scan)
|
|
64
|
+
|
|
65
|
+
mcp = sub.add_parser("mcp", help="connect to MCP servers and inspect their tools live")
|
|
66
|
+
mcp.add_argument("configs", nargs="*", help="MCP config files (default: project configs in the current directory)")
|
|
67
|
+
mcp.add_argument("--home", action="store_true", help="also use user-level MCP configs (Claude Desktop, Cursor, Codex, ...)")
|
|
68
|
+
mcp.add_argument("--server", action="append", default=[], metavar="CMD", help="ad-hoc stdio server command line, e.g. --server \"npx -y some-mcp\" (repeatable)")
|
|
69
|
+
mcp.add_argument("--url", action="append", default=[], metavar="URL", help="ad-hoc streamable-HTTP server URL (repeatable)")
|
|
70
|
+
mcp.add_argument("--timeout", type=float, default=20.0, help="seconds to wait for each server (default 20)")
|
|
71
|
+
mcp.add_argument("--lockfile", metavar="FILE", default=None, help="lockfile path (default: patdown.lock)")
|
|
72
|
+
mcp.add_argument("--lock", action="store_true", help="write/update the lockfile with the current tool set")
|
|
73
|
+
mcp.add_argument("--no-lock", action="store_true", help="do not read any lockfile")
|
|
74
|
+
_add_output_args(mcp)
|
|
75
|
+
|
|
76
|
+
sub.add_parser("rules", help="list all rules")
|
|
77
|
+
|
|
78
|
+
explain = sub.add_parser("explain", help="describe a rule")
|
|
79
|
+
explain.add_argument("rule")
|
|
80
|
+
|
|
81
|
+
base = sub.add_parser("baseline", help=f"write {BASELINE_FILENAME} accepting all current findings")
|
|
82
|
+
_add_scan_args(base)
|
|
83
|
+
base.add_argument("--baseline-out", default=BASELINE_FILENAME, metavar="FILE", help="where to write the baseline")
|
|
84
|
+
base.add_argument("--ignore", action="append", default=[], metavar="RULE")
|
|
85
|
+
base.add_argument("--only", action="append", default=[], metavar="RULE")
|
|
86
|
+
base.add_argument("-v", "--verbose", action="store_true")
|
|
87
|
+
|
|
88
|
+
strip = sub.add_parser("strip", help="remove invisible / bidi / tag characters from files")
|
|
89
|
+
strip.add_argument("files", nargs="+")
|
|
90
|
+
strip.add_argument("--write", action="store_true", help="modify files in place (default: report only)")
|
|
91
|
+
return parser
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _split_ids(values: list[str]) -> list[str]:
|
|
95
|
+
out: list[str] = []
|
|
96
|
+
for v in values:
|
|
97
|
+
out.extend(x.strip() for x in v.split(",") if x.strip())
|
|
98
|
+
return out
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _severity(value: str) -> Severity | None:
|
|
102
|
+
if value.lower() in ("never", "none", "off"):
|
|
103
|
+
return None
|
|
104
|
+
return Severity.parse(value)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _emit(text: str, output: str | None) -> None:
|
|
108
|
+
if output:
|
|
109
|
+
with open(output, "w", encoding="utf-8") as fh:
|
|
110
|
+
fh.write(text + "\n")
|
|
111
|
+
else:
|
|
112
|
+
try:
|
|
113
|
+
sys.stdout.write(text + "\n")
|
|
114
|
+
except UnicodeEncodeError:
|
|
115
|
+
sys.stdout.buffer.write((text + "\n").encode("utf-8", "replace"))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _finish(args: argparse.Namespace, findings: list[Finding], stats: dict[str, object]) -> int:
|
|
119
|
+
min_sev = Severity.parse(args.min_severity)
|
|
120
|
+
shown = [f for f in findings if f.severity >= min_sev]
|
|
121
|
+
fail_on = _severity(args.fail_on)
|
|
122
|
+
if args.sarif:
|
|
123
|
+
_emit(render_sarif(shown, stats), args.output)
|
|
124
|
+
elif args.json:
|
|
125
|
+
_emit(render_json(shown, stats), args.output)
|
|
126
|
+
else:
|
|
127
|
+
color = want_color(True if args.color else (False if args.no_color or args.output else None))
|
|
128
|
+
text = render_terminal([] if args.quiet else shown, stats, color, args.verbose)
|
|
129
|
+
if args.quiet:
|
|
130
|
+
text = text.splitlines()[-2] if shown else text
|
|
131
|
+
_emit(text, args.output)
|
|
132
|
+
if fail_on is not None and any(f.severity >= fail_on for f in shown):
|
|
133
|
+
return EXIT_FINDINGS
|
|
134
|
+
return EXIT_CLEAN
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _scan_paths(args: argparse.Namespace, scanner: Scanner) -> tuple[list[Finding], int, list[str]]:
|
|
138
|
+
roots = list(args.paths) or ["."]
|
|
139
|
+
if args.home:
|
|
140
|
+
roots.extend(home_targets())
|
|
141
|
+
opts = ScanOptions(
|
|
142
|
+
excludes=list(args.exclude), default_excludes=not args.no_default_excludes,
|
|
143
|
+
max_bytes=args.max_file_size, follow_symlinks=args.follow_symlinks,
|
|
144
|
+
)
|
|
145
|
+
findings: list[Finding] = []
|
|
146
|
+
files = 0
|
|
147
|
+
mcp_configs: list[str] = []
|
|
148
|
+
for doc in iter_documents(roots, opts):
|
|
149
|
+
files += 1
|
|
150
|
+
if args.verbose:
|
|
151
|
+
print(f" scanning {doc.path} [{doc.kind}]", file=sys.stderr)
|
|
152
|
+
if doc.kind == KIND_MCP_CONFIG and doc.abspath:
|
|
153
|
+
mcp_configs.append(doc.abspath)
|
|
154
|
+
findings.extend(scanner.scan_document(doc))
|
|
155
|
+
return findings, files, mcp_configs
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def cmd_scan(args: argparse.Namespace) -> int:
|
|
159
|
+
t0 = time.perf_counter()
|
|
160
|
+
scanner = Scanner(ignore_rules=_split_ids(args.ignore), only_rules=_split_ids(args.only) or None)
|
|
161
|
+
findings, files, mcp_configs = _scan_paths(args, scanner)
|
|
162
|
+
stats: dict[str, object] = {"files": files}
|
|
163
|
+
if args.mcp:
|
|
164
|
+
from patdown.mcp.scan import run_mcp_scan, specs_from_file
|
|
165
|
+
specs = [s for p in mcp_configs for s in specs_from_file(p)]
|
|
166
|
+
lock_path = args.lockfile or ("patdown.lock" if os.path.isfile("patdown.lock") or args.lock else None)
|
|
167
|
+
mcp_findings, inventories = run_mcp_scan(specs, scanner, args.timeout, lock_path, args.lock)
|
|
168
|
+
findings.extend(mcp_findings)
|
|
169
|
+
stats["servers"] = len(inventories)
|
|
170
|
+
baseline_path = args.baseline or (BASELINE_FILENAME if os.path.isfile(BASELINE_FILENAME) else None)
|
|
171
|
+
if baseline_path:
|
|
172
|
+
findings, suppressed = apply_baseline(findings, load_baseline(baseline_path))
|
|
173
|
+
stats["baselined"] = suppressed
|
|
174
|
+
stats["seconds"] = round(time.perf_counter() - t0, 3)
|
|
175
|
+
return _finish(args, dedupe(findings), stats)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def cmd_mcp(args: argparse.Namespace) -> int:
|
|
179
|
+
from patdown.mcp.scan import discover_config_files, run_mcp_scan, spec_from_command, specs_from_file
|
|
180
|
+
from patdown.mcp.client import ServerSpec
|
|
181
|
+
|
|
182
|
+
t0 = time.perf_counter()
|
|
183
|
+
scanner = Scanner(ignore_rules=_split_ids(args.ignore), only_rules=_split_ids(args.only) or None)
|
|
184
|
+
configs = list(args.configs) or discover_config_files(args.home)
|
|
185
|
+
specs = [s for p in configs for s in specs_from_file(p)]
|
|
186
|
+
for i, cmd in enumerate(args.server, 1):
|
|
187
|
+
specs.append(spec_from_command(cmd, i))
|
|
188
|
+
for i, url in enumerate(args.url, 1):
|
|
189
|
+
specs.append(ServerSpec(name=f"url{i}", url=url, source="--url"))
|
|
190
|
+
if not specs:
|
|
191
|
+
print("patdown: no MCP servers found. Pass a config file, --server \"cmd args\", --url, or --home.", file=sys.stderr)
|
|
192
|
+
return EXIT_ERROR
|
|
193
|
+
if args.verbose:
|
|
194
|
+
for s in specs:
|
|
195
|
+
print(f" connecting to {s.name}: {s.describe()}", file=sys.stderr)
|
|
196
|
+
lock_path = None if args.no_lock else (args.lockfile or "patdown.lock")
|
|
197
|
+
if lock_path and not args.lock and not os.path.isfile(lock_path):
|
|
198
|
+
lock_path = None
|
|
199
|
+
findings, inventories = run_mcp_scan(specs, scanner, args.timeout, args.lockfile or "patdown.lock" if args.lock else lock_path, args.lock)
|
|
200
|
+
stats: dict[str, object] = {
|
|
201
|
+
"servers": len(inventories),
|
|
202
|
+
"tools": sum(len(i.tools) for i in inventories),
|
|
203
|
+
"seconds": round(time.perf_counter() - t0, 3),
|
|
204
|
+
}
|
|
205
|
+
if args.lock:
|
|
206
|
+
print(f"patdown: lockfile written to {args.lockfile or 'patdown.lock'}", file=sys.stderr)
|
|
207
|
+
baseline_path = args.baseline or (BASELINE_FILENAME if os.path.isfile(BASELINE_FILENAME) else None)
|
|
208
|
+
if baseline_path:
|
|
209
|
+
findings, suppressed = apply_baseline(findings, load_baseline(baseline_path))
|
|
210
|
+
stats["baselined"] = suppressed
|
|
211
|
+
return _finish(args, findings, stats)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def cmd_rules(args: argparse.Namespace) -> int:
|
|
215
|
+
width = max(len(r.name) for r in ALL_RULES) + 2
|
|
216
|
+
for r in ALL_RULES:
|
|
217
|
+
print(f"{r.id} {r.name.ljust(width)}{r.severity.name.ljust(9)} {r.description}")
|
|
218
|
+
print("\nPD090-PD096 mcp-* live MCP checks: lockfile drift, cross-server references, name collisions")
|
|
219
|
+
return EXIT_CLEAN
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def cmd_explain(args: argparse.Namespace) -> int:
|
|
223
|
+
r = rule_by_id(args.rule)
|
|
224
|
+
if r is None:
|
|
225
|
+
print(f"patdown: unknown rule {args.rule!r}; run `patdown rules`", file=sys.stderr)
|
|
226
|
+
return EXIT_ERROR
|
|
227
|
+
kinds = "all file kinds" if r.kinds is None else ", ".join(sorted(r.kinds))
|
|
228
|
+
print(f"{r.id} {r.name}\n default severity: {r.severity.name}\n applies to: {kinds}\n\n {r.description}")
|
|
229
|
+
if r.kind_severity:
|
|
230
|
+
print("\n severity by kind:")
|
|
231
|
+
for k, s in sorted(r.kind_severity.items()):
|
|
232
|
+
print(f" {k.ljust(14)} {s.name}")
|
|
233
|
+
print(f"\n suppress on one line with a `patdown:ignore {r.id}` comment, or a whole file with `patdown:ignore-file`.")
|
|
234
|
+
return EXIT_CLEAN
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def cmd_baseline(args: argparse.Namespace) -> int:
|
|
238
|
+
scanner = Scanner(ignore_rules=_split_ids(args.ignore), only_rules=_split_ids(args.only) or None)
|
|
239
|
+
findings, files, _ = _scan_paths(args, scanner)
|
|
240
|
+
write_baseline(args.baseline_out, findings)
|
|
241
|
+
print(f"patdown: baseline with {len(findings)} finding(s) from {files} file(s) written to {args.baseline_out}")
|
|
242
|
+
return EXIT_CLEAN
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def cmd_strip(args: argparse.Namespace) -> int:
|
|
246
|
+
total = 0
|
|
247
|
+
for path in args.files:
|
|
248
|
+
try:
|
|
249
|
+
with open(path, "rb") as fh:
|
|
250
|
+
raw = fh.read()
|
|
251
|
+
text = raw.decode("utf-8")
|
|
252
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
253
|
+
print(f"patdown: skipping {path}: {exc}", file=sys.stderr)
|
|
254
|
+
continue
|
|
255
|
+
cleaned = (text[0] if text and ord(text[0]) == 0xFEFF else "") + "".join(
|
|
256
|
+
ch for i, ch in enumerate(text) if not (is_stealth_char(ord(ch)) and not (i == 0 and ord(ch) == 0xFEFF))
|
|
257
|
+
)
|
|
258
|
+
removed = len(text) - len(cleaned)
|
|
259
|
+
total += removed
|
|
260
|
+
if removed and args.write:
|
|
261
|
+
with open(path, "wb") as fh:
|
|
262
|
+
fh.write(cleaned.encode("utf-8"))
|
|
263
|
+
print(f"{path}: {removed} stealth character(s) {'removed' if args.write else 'found'}")
|
|
264
|
+
if total and not args.write:
|
|
265
|
+
print("re-run with --write to remove them", file=sys.stderr)
|
|
266
|
+
return EXIT_CLEAN
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def main(argv: list[str] | None = None) -> int:
|
|
270
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
271
|
+
if not argv or (argv[0] not in SUBCOMMANDS and argv[0] not in ("-h", "--help", "--version")):
|
|
272
|
+
argv.insert(0, "scan")
|
|
273
|
+
parser = build_parser()
|
|
274
|
+
args = parser.parse_args(argv)
|
|
275
|
+
try:
|
|
276
|
+
if args.cmd == "scan":
|
|
277
|
+
return cmd_scan(args)
|
|
278
|
+
if args.cmd == "mcp":
|
|
279
|
+
return cmd_mcp(args)
|
|
280
|
+
if args.cmd == "rules":
|
|
281
|
+
return cmd_rules(args)
|
|
282
|
+
if args.cmd == "explain":
|
|
283
|
+
return cmd_explain(args)
|
|
284
|
+
if args.cmd == "baseline":
|
|
285
|
+
return cmd_baseline(args)
|
|
286
|
+
if args.cmd == "strip":
|
|
287
|
+
return cmd_strip(args)
|
|
288
|
+
except ValueError as exc:
|
|
289
|
+
print(f"patdown: {exc}", file=sys.stderr)
|
|
290
|
+
return EXIT_ERROR
|
|
291
|
+
except KeyboardInterrupt:
|
|
292
|
+
print("patdown: interrupted", file=sys.stderr)
|
|
293
|
+
return EXIT_ERROR
|
|
294
|
+
parser.print_help()
|
|
295
|
+
return EXIT_ERROR
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
if __name__ == "__main__": # pragma: no cover
|
|
299
|
+
raise SystemExit(main())
|
patdown/discover.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any, Iterator
|
|
10
|
+
|
|
11
|
+
from patdown.rules.base import (
|
|
12
|
+
KIND_CONFIG, KIND_MARKDOWN, KIND_MCP_CONFIG, KIND_PLUGIN, KIND_RULES,
|
|
13
|
+
KIND_SCRIPT, KIND_SETTINGS, KIND_SKILL, KIND_SKILL_SCRIPT, KIND_TEXT, Document,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
DEFAULT_EXCLUDE_DIRS = {
|
|
17
|
+
".git", ".hg", ".svn", "node_modules", ".venv", "venv", "__pycache__", ".tox",
|
|
18
|
+
".nox", ".mypy_cache", ".pytest_cache", ".ruff_cache", "dist", "build", "target",
|
|
19
|
+
".next", ".nuxt", ".turbo", "coverage", ".gradle", ".terraform", "site-packages",
|
|
20
|
+
".eggs", ".cache", ".parcel-cache",
|
|
21
|
+
}
|
|
22
|
+
SKIP_FILENAMES = {
|
|
23
|
+
"package-lock.json", "yarn.lock", "pnpm-lock.yaml", "poetry.lock", "Cargo.lock",
|
|
24
|
+
"uv.lock", "Gemfile.lock", "composer.lock", "go.sum", "bun.lockb", "flake.lock",
|
|
25
|
+
".patdown-baseline.json", "patdown.lock",
|
|
26
|
+
}
|
|
27
|
+
BINARY_EXT = {
|
|
28
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".bmp", ".tiff", ".psd", ".pdf",
|
|
29
|
+
".woff", ".woff2", ".ttf", ".otf", ".eot", ".zip", ".gz", ".tgz", ".bz2", ".xz",
|
|
30
|
+
".7z", ".rar", ".tar", ".jar", ".war", ".exe", ".dll", ".so", ".dylib", ".bin",
|
|
31
|
+
".pyc", ".pyo", ".class", ".o", ".a", ".wasm", ".mp3", ".mp4", ".mov", ".avi",
|
|
32
|
+
".wav", ".ogg", ".flac", ".sqlite", ".db", ".parquet", ".npy", ".npz", ".pkl",
|
|
33
|
+
".min.js", ".map", ".lockb",
|
|
34
|
+
}
|
|
35
|
+
SCRIPT_EXT = {
|
|
36
|
+
".sh", ".bash", ".zsh", ".fish", ".ps1", ".psm1", ".bat", ".cmd", ".py", ".js",
|
|
37
|
+
".mjs", ".cjs", ".ts", ".rb", ".pl", ".php", ".lua", ".go", ".rs", ".java",
|
|
38
|
+
".kt", ".swift", ".c", ".cc", ".cpp", ".cs", ".scala", ".exs", ".ex", ".r",
|
|
39
|
+
".applescript", ".vbs",
|
|
40
|
+
}
|
|
41
|
+
MARKDOWN_EXT = {".md", ".mdx", ".mdc", ".markdown", ".txt", ".rst", ".adoc"}
|
|
42
|
+
CONFIG_EXT = {".json", ".jsonc", ".json5", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".env"}
|
|
43
|
+
|
|
44
|
+
RULES_FILENAMES = {
|
|
45
|
+
"claude.md", "claude.local.md", "agents.md", "agent.md", "gemini.md", "codex.md",
|
|
46
|
+
".cursorrules", ".windsurfrules", ".clinerules", ".roomodes", ".goosehints",
|
|
47
|
+
"copilot-instructions.md", "conventions.md", "llms.txt", "llms-full.txt",
|
|
48
|
+
"instructions.md", "system.md", "prompt.md", "prompts.md",
|
|
49
|
+
}
|
|
50
|
+
RULES_DIR_PARTS = (
|
|
51
|
+
"/.cursor/rules/", "/.clinerules/", "/.roo/rules", "/.github/instructions/",
|
|
52
|
+
"/.windsurf/rules/", "/.claude/commands/", "/.claude/agents/", "/.codex/prompts/",
|
|
53
|
+
"/.gemini/commands/", "/.continue/rules/", "/.kiro/steering/", "/.amazonq/rules/",
|
|
54
|
+
"/.trae/rules/", "/commands/", "/agents/",
|
|
55
|
+
)
|
|
56
|
+
MCP_CONFIG_FILENAMES = {
|
|
57
|
+
".mcp.json", "mcp.json", "claude_desktop_config.json", "mcp_config.json",
|
|
58
|
+
"mcp-config.json", "mcp_servers.json", "mcp-servers.json", "opencode.json",
|
|
59
|
+
"opencode.jsonc", ".claude.json",
|
|
60
|
+
}
|
|
61
|
+
SETTINGS_FILENAMES = {"settings.json", "settings.local.json", "hooks.json"}
|
|
62
|
+
SETTINGS_DIR_PARTS = ("/.claude/", "/.cursor/", "/.gemini/", "/hooks/", "/.claude-plugin/", "/.codex/", "/.kiro/", "/.windsurf/", "/.copilot/")
|
|
63
|
+
PLUGIN_FILENAMES = {"plugin.json", "marketplace.json"}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class ScanOptions:
|
|
68
|
+
excludes: list[str] = field(default_factory=list)
|
|
69
|
+
default_excludes: bool = True
|
|
70
|
+
max_bytes: int = 2 * 1024 * 1024
|
|
71
|
+
follow_symlinks: bool = False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def norm(path: str) -> str:
|
|
75
|
+
return path.replace("\\", "/")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def classify(path: str, skill_dirs: set[str] | None = None) -> str:
|
|
79
|
+
p = norm(path)
|
|
80
|
+
name = os.path.basename(p)
|
|
81
|
+
low = name.lower()
|
|
82
|
+
ext = os.path.splitext(low)[1]
|
|
83
|
+
parts = "/" + p.strip("/") + "/"
|
|
84
|
+
|
|
85
|
+
in_skill = False
|
|
86
|
+
if skill_dirs:
|
|
87
|
+
d = os.path.dirname(p)
|
|
88
|
+
while d:
|
|
89
|
+
if d in skill_dirs:
|
|
90
|
+
in_skill = True
|
|
91
|
+
break
|
|
92
|
+
nd = os.path.dirname(d)
|
|
93
|
+
if nd == d:
|
|
94
|
+
break
|
|
95
|
+
d = nd
|
|
96
|
+
if low == "skill.md":
|
|
97
|
+
return KIND_SKILL
|
|
98
|
+
if in_skill:
|
|
99
|
+
if ext in MARKDOWN_EXT:
|
|
100
|
+
return KIND_SKILL
|
|
101
|
+
if ext in SCRIPT_EXT or ext == "":
|
|
102
|
+
return KIND_SKILL_SCRIPT
|
|
103
|
+
return KIND_SKILL
|
|
104
|
+
if low in MCP_CONFIG_FILENAMES:
|
|
105
|
+
return KIND_MCP_CONFIG
|
|
106
|
+
if low == "config.toml" and "/.codex/" in parts:
|
|
107
|
+
return KIND_MCP_CONFIG
|
|
108
|
+
if low == "hooks.json":
|
|
109
|
+
return KIND_SETTINGS
|
|
110
|
+
if low in SETTINGS_FILENAMES and any(seg in parts for seg in SETTINGS_DIR_PARTS):
|
|
111
|
+
return KIND_SETTINGS
|
|
112
|
+
if low in PLUGIN_FILENAMES and "/.claude-plugin/" in parts:
|
|
113
|
+
return KIND_PLUGIN
|
|
114
|
+
if low in RULES_FILENAMES:
|
|
115
|
+
return KIND_RULES
|
|
116
|
+
if low.endswith((".instructions.md", ".prompt.md", ".agent.md", ".chatmode.md")):
|
|
117
|
+
return KIND_RULES
|
|
118
|
+
if ext in MARKDOWN_EXT and any(seg in parts for seg in RULES_DIR_PARTS):
|
|
119
|
+
return KIND_RULES
|
|
120
|
+
if ext in MARKDOWN_EXT:
|
|
121
|
+
return KIND_MARKDOWN
|
|
122
|
+
if ext in SCRIPT_EXT:
|
|
123
|
+
return KIND_SCRIPT
|
|
124
|
+
if ext in CONFIG_EXT or low.startswith(".env"):
|
|
125
|
+
return KIND_CONFIG
|
|
126
|
+
return KIND_TEXT
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
_JSONC_COMMENT = re.compile(r"//[^\n]*|/\*.*?\*/", re.S)
|
|
130
|
+
_TRAILING_COMMA = re.compile(r",(\s*[}\]])")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def parse_structured(text: str, path: str) -> Any:
|
|
134
|
+
"""Best-effort parse for JSON / JSONC / TOML config files."""
|
|
135
|
+
low = path.lower()
|
|
136
|
+
body = text.lstrip("\ufeff")
|
|
137
|
+
if low.endswith(".toml"):
|
|
138
|
+
try:
|
|
139
|
+
import tomllib # py3.11+
|
|
140
|
+
return tomllib.loads(body)
|
|
141
|
+
except Exception:
|
|
142
|
+
return None
|
|
143
|
+
if low.endswith((".json", ".jsonc", ".json5")):
|
|
144
|
+
try:
|
|
145
|
+
return json.loads(body)
|
|
146
|
+
except Exception:
|
|
147
|
+
pass
|
|
148
|
+
try:
|
|
149
|
+
stripped = _TRAILING_COMMA.sub(r"\1", _JSONC_COMMENT.sub("", body))
|
|
150
|
+
return json.loads(stripped)
|
|
151
|
+
except Exception:
|
|
152
|
+
return None
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
STRUCTURED_KINDS = {KIND_MCP_CONFIG, KIND_SETTINGS, KIND_PLUGIN, KIND_CONFIG}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def read_document(abspath: str, display: str, kind: str, max_bytes: int) -> Document | None:
|
|
160
|
+
try:
|
|
161
|
+
size = os.path.getsize(abspath)
|
|
162
|
+
if size > max_bytes or size == 0:
|
|
163
|
+
return None
|
|
164
|
+
with open(abspath, "rb") as fh:
|
|
165
|
+
raw = fh.read()
|
|
166
|
+
except OSError:
|
|
167
|
+
return None
|
|
168
|
+
if b"\x00" in raw[:8192]:
|
|
169
|
+
return None
|
|
170
|
+
try:
|
|
171
|
+
text = raw.decode("utf-8")
|
|
172
|
+
except UnicodeDecodeError:
|
|
173
|
+
if raw[:2] in (b"\xff\xfe", b"\xfe\xff"):
|
|
174
|
+
try:
|
|
175
|
+
text = raw.decode("utf-16")
|
|
176
|
+
except UnicodeDecodeError:
|
|
177
|
+
return None
|
|
178
|
+
else:
|
|
179
|
+
return None
|
|
180
|
+
data = parse_structured(text, abspath) if kind in STRUCTURED_KINDS else None
|
|
181
|
+
return Document(path=display, text=text, kind=kind, data=data, abspath=abspath)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _excluded(rel: str, name: str, opts: ScanOptions) -> bool:
|
|
185
|
+
for pat in opts.excludes:
|
|
186
|
+
if fnmatch.fnmatch(rel, pat) or fnmatch.fnmatch(name, pat):
|
|
187
|
+
return True
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def display_path(abspath: str) -> str:
|
|
192
|
+
try:
|
|
193
|
+
cwd = os.path.abspath(os.getcwd())
|
|
194
|
+
if os.path.commonpath([cwd, abspath]) == cwd:
|
|
195
|
+
return norm(os.path.relpath(abspath, cwd))
|
|
196
|
+
except ValueError:
|
|
197
|
+
pass
|
|
198
|
+
return norm(abspath)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def iter_documents(roots: list[str], opts: ScanOptions) -> Iterator[Document]:
|
|
202
|
+
for root in roots:
|
|
203
|
+
root = os.path.abspath(root)
|
|
204
|
+
if os.path.isfile(root):
|
|
205
|
+
doc = read_document(root, display_path(root), classify(root, _skill_dirs_for_file(root)), opts.max_bytes)
|
|
206
|
+
if doc:
|
|
207
|
+
yield doc
|
|
208
|
+
continue
|
|
209
|
+
if not os.path.isdir(root):
|
|
210
|
+
print(f"patdown: warning: {root} is not a file or directory", file=sys.stderr)
|
|
211
|
+
continue
|
|
212
|
+
skill_dirs = _find_skill_dirs(root, opts)
|
|
213
|
+
for dirpath, dirnames, filenames in os.walk(root, followlinks=opts.follow_symlinks):
|
|
214
|
+
rel_dir = norm(os.path.relpath(dirpath, root))
|
|
215
|
+
if rel_dir == ".":
|
|
216
|
+
rel_dir = ""
|
|
217
|
+
dirnames[:] = sorted(
|
|
218
|
+
d for d in dirnames
|
|
219
|
+
if not (opts.default_excludes and _default_excluded_dir(d))
|
|
220
|
+
and not _excluded(norm(os.path.join(rel_dir, d)), d, opts)
|
|
221
|
+
)
|
|
222
|
+
for fn in sorted(filenames):
|
|
223
|
+
if fn in SKIP_FILENAMES:
|
|
224
|
+
continue
|
|
225
|
+
low = fn.lower()
|
|
226
|
+
if any(low.endswith(e) for e in BINARY_EXT):
|
|
227
|
+
continue
|
|
228
|
+
abspath = os.path.join(dirpath, fn)
|
|
229
|
+
rel = norm(os.path.join(rel_dir, fn))
|
|
230
|
+
if _excluded(rel, fn, opts):
|
|
231
|
+
continue
|
|
232
|
+
if not opts.follow_symlinks and os.path.islink(abspath):
|
|
233
|
+
continue
|
|
234
|
+
kind = classify(norm(abspath), skill_dirs)
|
|
235
|
+
doc = read_document(abspath, display_path(abspath), kind, opts.max_bytes)
|
|
236
|
+
if doc:
|
|
237
|
+
yield doc
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _default_excluded_dir(name: str) -> bool:
|
|
241
|
+
"""Dependency, VCS and build-artifact directories skipped unless --no-default-excludes."""
|
|
242
|
+
return name in DEFAULT_EXCLUDE_DIRS or name.endswith((".egg-info", ".dist-info"))
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _find_skill_dirs(root: str, opts: ScanOptions) -> set[str]:
|
|
246
|
+
out: set[str] = set()
|
|
247
|
+
for dirpath, dirnames, filenames in os.walk(root, followlinks=opts.follow_symlinks):
|
|
248
|
+
dirnames[:] = [d for d in dirnames if not (opts.default_excludes and _default_excluded_dir(d))]
|
|
249
|
+
if any(f.lower() == "skill.md" for f in filenames):
|
|
250
|
+
out.add(norm(dirpath))
|
|
251
|
+
return out
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _skill_dirs_for_file(abspath: str) -> set[str]:
|
|
255
|
+
d = os.path.dirname(abspath)
|
|
256
|
+
try:
|
|
257
|
+
if any(f.lower() == "skill.md" for f in os.listdir(d)):
|
|
258
|
+
return {norm(d)}
|
|
259
|
+
except OSError:
|
|
260
|
+
pass
|
|
261
|
+
return set()
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def home_targets() -> list[str]:
|
|
265
|
+
"""User-level locations where agents keep instructions, hooks and MCP configs."""
|
|
266
|
+
home = os.path.expanduser("~")
|
|
267
|
+
cands = [
|
|
268
|
+
"~/.claude", "~/.claude.json", "~/.cursor", "~/.codex", "~/.gemini",
|
|
269
|
+
"~/.windsurf", "~/.continue", "~/.kiro", "~/.config/opencode",
|
|
270
|
+
"~/.config/Claude/claude_desktop_config.json",
|
|
271
|
+
"~/Library/Application Support/Claude/claude_desktop_config.json",
|
|
272
|
+
"~/.config/github-copilot", "~/.copilot", "~/.aider.conf.yml", "~/.goosehints",
|
|
273
|
+
"~/.config/goose",
|
|
274
|
+
]
|
|
275
|
+
appdata = os.environ.get("APPDATA")
|
|
276
|
+
if appdata:
|
|
277
|
+
cands.append(os.path.join(appdata, "Claude", "claude_desktop_config.json"))
|
|
278
|
+
cands.append(os.path.join(appdata, "Code", "User", "mcp.json"))
|
|
279
|
+
cands.append(os.path.join(home, ".config", "Code", "User", "mcp.json"))
|
|
280
|
+
cands.append(os.path.join(home, "Library", "Application Support", "Code", "User", "mcp.json"))
|
|
281
|
+
out = []
|
|
282
|
+
for c in cands:
|
|
283
|
+
p = os.path.expanduser(c)
|
|
284
|
+
if os.path.exists(p):
|
|
285
|
+
out.append(p)
|
|
286
|
+
return out
|