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 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
@@ -0,0 +1,4 @@
1
+ from patdown.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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