mod-audit 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 hao li
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,190 @@
1
+ Metadata-Version: 2.4
2
+ Name: mod-audit
3
+ Version: 0.1.0
4
+ Summary: Static supply-chain auditor for Claude Code Mods — local, offline, stdlib-only
5
+ Author: hao li
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/hahahahahahahahah6/mod-audit
8
+ Keywords: claude-code,mods,supply-chain,security,static-analysis,audit
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Security
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Dynamic: license-file
23
+
24
+ # mod-audit
25
+
26
+ Static supply-chain auditor for **Claude Code Mods** — local, offline, stdlib-only.
27
+
28
+ Claude Code Mods are TypeScript plugin packages that usually live under
29
+ `~/.claude/plugins/` and run **lifecycle hooks with your shell privileges**.
30
+ A single trojanized mod update can pipe `curl | sh` straight into your
31
+ machine. mod-audit scans a mod before you install it, snapshots the trusted
32
+ state, and diffs later updates against that snapshot — so a pin-swap style
33
+ update hijack lights up instead of slipping through.
34
+
35
+ Zero third-party dependencies. Python >= 3.9. No network calls, ever.
36
+
37
+ ## Why this exists
38
+
39
+ The agent supply chain is getting hit, repeatedly, in public:
40
+
41
+ - **AIR SkillJacking** — 925 skills hijacked, reaching an estimated 134k agents.
42
+ - **Plugin4Shell** — pin-swap attacks bypass SHA-pinning on plugin updates,
43
+ swapping trusted code for malicious code between the pin check and install.
44
+ - **Pwn2Own Ireland** — a Codex argument-injection flaw worth $40k showed how
45
+ agent tooling becomes a shell-execution primitive.
46
+ - **SKILLCLOAK** — cloaking techniques that bypass 90%+ of existing scanners.
47
+
48
+ Most defenses are either cloud-based scanners (your mod source leaves your
49
+ machine) or metadata-only reviewers that never look at the TypeScript that
50
+ actually runs. mod-audit does the opposite: it runs on your machine, offline,
51
+ and reads the code.
52
+
53
+ ## How it differs
54
+
55
+ | | mod-audit | ClawSecure Watchtower | rad-security AgentKeeper |
56
+ |---|---|---|---|
57
+ | Where it runs | Local / offline | Cloud scan | Cloud scan |
58
+ | Audits Mod TypeScript source | Yes | Partial | No — plugin/skill metadata only |
59
+ | Lifecycle hook analysis | Yes (shell patterns) | Generic | Metadata-level |
60
+ | Trojanized-update diffing | Yes (`snapshot`/`diff`) | No | No |
61
+ | Dependencies | Zero (stdlib only) | SaaS | SaaS |
62
+
63
+ Positioning: **local + offline + Mod TypeScript code specialist**. It does not
64
+ replace a metadata/policy reviewer — it covers the layer those tools skip:
65
+ the code that actually executes on your box.
66
+
67
+ ## Install
68
+
69
+ ```bash
70
+ pip install mod-audit
71
+ ```
72
+
73
+ ## Quick start
74
+
75
+ ```bash
76
+ # 1. Audit a mod before installing it
77
+ mod-audit scan ~/.claude/plugins/some-mod
78
+
79
+ # 2. Snapshot the trusted state right after a clean install
80
+ mod-audit snapshot ~/.claude/plugins/some-mod --out ~/snapshots/some-mod.snapshot
81
+
82
+ # 3. After every update, diff against the snapshot
83
+ mod-audit diff ~/.claude/plugins/some-mod --against ~/snapshots/some-mod.snapshot
84
+ ```
85
+
86
+ JSON output for scripting:
87
+
88
+ ```bash
89
+ mod-audit scan ./my-mod --format json
90
+ ```
91
+
92
+ ## What it checks
93
+
94
+ ### 1. Dangerous lifecycle hooks (`plugin.json` / `hooks.json` / `package.json`)
95
+
96
+ | Rule | Severity | What it catches |
97
+ |---|---|---|
98
+ | `HOOK-PIPED-DOWNLOAD` | high | `curl … \| sh`, `wget … \| bash` in hooks |
99
+ | `HOOK-B64-EXEC` | high | base64 decode piped into execution |
100
+ | `HOOK-EXFIL` | high | `curl --data` exfiltrating data from a hook |
101
+ | `HOOK-REVERSE-SHELL` | critical | `nc -e`, `/dev/tcp/` reverse shells |
102
+ | `HOOK-SUDO` | high | privilege escalation in hooks |
103
+ | `HOOK-RM-RF` | high | destructive recursive deletes |
104
+ | `HOOK-CHMOD-EXEC` | medium | flipping files executable at install time |
105
+ | `HOOK-SHELL-EXEC` | medium | any other shell hook (runs as you) |
106
+
107
+ ### 2. Shell-execution patterns in `.ts`/`.js` source
108
+
109
+ | Rule | Severity | What it catches |
110
+ |---|---|---|
111
+ | `TS-SHELL-TRUE` | high | `exec/spawn` with `shell: true` |
112
+ | `TS-EXEC-CONCAT` | high | concatenated/interpolated command strings |
113
+ | `TS-EXEC` | medium | `child_process` usage to review |
114
+ | `TS-EVAL` | high | `eval()` / `new Function()` |
115
+ | `TS-DYN-IMPORT` | medium | dynamic `require()`/`import()` with non-literal specifiers |
116
+ | `TS-PERSISTENCE` | high | cron/launchd persistence references |
117
+ | `TS-DOTFILE-WRITE` | medium | writes derived from `$HOME`/`$PATH` |
118
+
119
+ ### 3. Env / API-key exfiltration
120
+
121
+ | Rule | Severity | What it catches |
122
+ |---|---|---|
123
+ | `ENV-EXFIL` | high | `process.env.*(API_KEY\|TOKEN\|SECRET\|PRIVATE)` within a few lines of a network sink (`fetch`, `axios`, `http.request`, …) |
124
+
125
+ ### 4. Trojanized-update diff (`snapshot` / `diff`)
126
+
127
+ | Rule | Severity | What it catches |
128
+ |---|---|---|
129
+ | `DIFF-NEW-FILE` | medium | files that appeared since the snapshot |
130
+ | `DIFF-CHANGED-FILE` | medium | files whose hash changed |
131
+ | `DIFF-REMOVED-FILE` | low | files that disappeared |
132
+ | `DIFF-HOOK-CHANGED` | high | hook commands added or swapped since the snapshot |
133
+ | `DIFF-HOOK-REMOVED` | low | hook commands removed |
134
+
135
+ New and changed files are re-scanned with all content rules during `diff`.
136
+
137
+ ### 5. Permissions manifest review
138
+
139
+ | Rule | Severity | What it catches |
140
+ |---|---|---|
141
+ | `PERM-SHELL-OVERGRANT` | high | shell granted without per-action confirmation |
142
+ | `PERM-TOOL-SHELL` | high | shell-capable tool granted without constraints |
143
+ | `PERM-NETWORK-OVERGRANT` | medium | unrestricted network access |
144
+ | `PERM-FS-OVERGRANT` | medium | broad filesystem write access |
145
+
146
+ Every finding includes the rule id, severity, `file:line`, an explanation,
147
+ and a concrete fix.
148
+
149
+ ## CI integration
150
+
151
+ mod-audit is CI-ready: it exits `1` when any finding meets `--fail-on`
152
+ (default `high`), `0` when clean, `2` on usage errors.
153
+
154
+ ```yaml
155
+ # .github/workflows/mod-audit.yml
156
+ name: mod-audit
157
+ on: [push, pull_request]
158
+ jobs:
159
+ audit:
160
+ runs-on: ubuntu-latest
161
+ steps:
162
+ - uses: actions/checkout@v4
163
+ - uses: actions/setup-python@v5
164
+ with:
165
+ python-version: "3.12"
166
+ - run: pip install mod-audit
167
+ - run: mod-audit scan ./my-mod --format json
168
+ ```
169
+
170
+ Gate updates in a scheduled job:
171
+
172
+ ```bash
173
+ mod-audit diff ~/.claude/plugins/my-mod --against ~/snapshots/my-mod.snapshot --fail-on medium
174
+ ```
175
+
176
+ ## Limitations
177
+
178
+ - **Offline heuristics, not a sandbox.** Rules are pattern-based and can miss
179
+ obfuscated code or flag benign code. Treat findings as triage signals.
180
+ - **No execution.** The tool never runs mod code, which is the point — but it
181
+ also means runtime-only behavior (e.g. payloads fetched at runtime) is out
182
+ of scope.
183
+ - **Snapshot trust.** `diff` is only as trustworthy as the snapshot: take it
184
+ from a clean install and store it where the mod updater cannot modify it.
185
+ - **TypeScript via regex, not a parser.** Keeps the tool stdlib-only and fast;
186
+ heavily minified or dynamically generated code may need manual review.
187
+
188
+ ## License
189
+
190
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,167 @@
1
+ # mod-audit
2
+
3
+ Static supply-chain auditor for **Claude Code Mods** — local, offline, stdlib-only.
4
+
5
+ Claude Code Mods are TypeScript plugin packages that usually live under
6
+ `~/.claude/plugins/` and run **lifecycle hooks with your shell privileges**.
7
+ A single trojanized mod update can pipe `curl | sh` straight into your
8
+ machine. mod-audit scans a mod before you install it, snapshots the trusted
9
+ state, and diffs later updates against that snapshot — so a pin-swap style
10
+ update hijack lights up instead of slipping through.
11
+
12
+ Zero third-party dependencies. Python >= 3.9. No network calls, ever.
13
+
14
+ ## Why this exists
15
+
16
+ The agent supply chain is getting hit, repeatedly, in public:
17
+
18
+ - **AIR SkillJacking** — 925 skills hijacked, reaching an estimated 134k agents.
19
+ - **Plugin4Shell** — pin-swap attacks bypass SHA-pinning on plugin updates,
20
+ swapping trusted code for malicious code between the pin check and install.
21
+ - **Pwn2Own Ireland** — a Codex argument-injection flaw worth $40k showed how
22
+ agent tooling becomes a shell-execution primitive.
23
+ - **SKILLCLOAK** — cloaking techniques that bypass 90%+ of existing scanners.
24
+
25
+ Most defenses are either cloud-based scanners (your mod source leaves your
26
+ machine) or metadata-only reviewers that never look at the TypeScript that
27
+ actually runs. mod-audit does the opposite: it runs on your machine, offline,
28
+ and reads the code.
29
+
30
+ ## How it differs
31
+
32
+ | | mod-audit | ClawSecure Watchtower | rad-security AgentKeeper |
33
+ |---|---|---|---|
34
+ | Where it runs | Local / offline | Cloud scan | Cloud scan |
35
+ | Audits Mod TypeScript source | Yes | Partial | No — plugin/skill metadata only |
36
+ | Lifecycle hook analysis | Yes (shell patterns) | Generic | Metadata-level |
37
+ | Trojanized-update diffing | Yes (`snapshot`/`diff`) | No | No |
38
+ | Dependencies | Zero (stdlib only) | SaaS | SaaS |
39
+
40
+ Positioning: **local + offline + Mod TypeScript code specialist**. It does not
41
+ replace a metadata/policy reviewer — it covers the layer those tools skip:
42
+ the code that actually executes on your box.
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ pip install mod-audit
48
+ ```
49
+
50
+ ## Quick start
51
+
52
+ ```bash
53
+ # 1. Audit a mod before installing it
54
+ mod-audit scan ~/.claude/plugins/some-mod
55
+
56
+ # 2. Snapshot the trusted state right after a clean install
57
+ mod-audit snapshot ~/.claude/plugins/some-mod --out ~/snapshots/some-mod.snapshot
58
+
59
+ # 3. After every update, diff against the snapshot
60
+ mod-audit diff ~/.claude/plugins/some-mod --against ~/snapshots/some-mod.snapshot
61
+ ```
62
+
63
+ JSON output for scripting:
64
+
65
+ ```bash
66
+ mod-audit scan ./my-mod --format json
67
+ ```
68
+
69
+ ## What it checks
70
+
71
+ ### 1. Dangerous lifecycle hooks (`plugin.json` / `hooks.json` / `package.json`)
72
+
73
+ | Rule | Severity | What it catches |
74
+ |---|---|---|
75
+ | `HOOK-PIPED-DOWNLOAD` | high | `curl … \| sh`, `wget … \| bash` in hooks |
76
+ | `HOOK-B64-EXEC` | high | base64 decode piped into execution |
77
+ | `HOOK-EXFIL` | high | `curl --data` exfiltrating data from a hook |
78
+ | `HOOK-REVERSE-SHELL` | critical | `nc -e`, `/dev/tcp/` reverse shells |
79
+ | `HOOK-SUDO` | high | privilege escalation in hooks |
80
+ | `HOOK-RM-RF` | high | destructive recursive deletes |
81
+ | `HOOK-CHMOD-EXEC` | medium | flipping files executable at install time |
82
+ | `HOOK-SHELL-EXEC` | medium | any other shell hook (runs as you) |
83
+
84
+ ### 2. Shell-execution patterns in `.ts`/`.js` source
85
+
86
+ | Rule | Severity | What it catches |
87
+ |---|---|---|
88
+ | `TS-SHELL-TRUE` | high | `exec/spawn` with `shell: true` |
89
+ | `TS-EXEC-CONCAT` | high | concatenated/interpolated command strings |
90
+ | `TS-EXEC` | medium | `child_process` usage to review |
91
+ | `TS-EVAL` | high | `eval()` / `new Function()` |
92
+ | `TS-DYN-IMPORT` | medium | dynamic `require()`/`import()` with non-literal specifiers |
93
+ | `TS-PERSISTENCE` | high | cron/launchd persistence references |
94
+ | `TS-DOTFILE-WRITE` | medium | writes derived from `$HOME`/`$PATH` |
95
+
96
+ ### 3. Env / API-key exfiltration
97
+
98
+ | Rule | Severity | What it catches |
99
+ |---|---|---|
100
+ | `ENV-EXFIL` | high | `process.env.*(API_KEY\|TOKEN\|SECRET\|PRIVATE)` within a few lines of a network sink (`fetch`, `axios`, `http.request`, …) |
101
+
102
+ ### 4. Trojanized-update diff (`snapshot` / `diff`)
103
+
104
+ | Rule | Severity | What it catches |
105
+ |---|---|---|
106
+ | `DIFF-NEW-FILE` | medium | files that appeared since the snapshot |
107
+ | `DIFF-CHANGED-FILE` | medium | files whose hash changed |
108
+ | `DIFF-REMOVED-FILE` | low | files that disappeared |
109
+ | `DIFF-HOOK-CHANGED` | high | hook commands added or swapped since the snapshot |
110
+ | `DIFF-HOOK-REMOVED` | low | hook commands removed |
111
+
112
+ New and changed files are re-scanned with all content rules during `diff`.
113
+
114
+ ### 5. Permissions manifest review
115
+
116
+ | Rule | Severity | What it catches |
117
+ |---|---|---|
118
+ | `PERM-SHELL-OVERGRANT` | high | shell granted without per-action confirmation |
119
+ | `PERM-TOOL-SHELL` | high | shell-capable tool granted without constraints |
120
+ | `PERM-NETWORK-OVERGRANT` | medium | unrestricted network access |
121
+ | `PERM-FS-OVERGRANT` | medium | broad filesystem write access |
122
+
123
+ Every finding includes the rule id, severity, `file:line`, an explanation,
124
+ and a concrete fix.
125
+
126
+ ## CI integration
127
+
128
+ mod-audit is CI-ready: it exits `1` when any finding meets `--fail-on`
129
+ (default `high`), `0` when clean, `2` on usage errors.
130
+
131
+ ```yaml
132
+ # .github/workflows/mod-audit.yml
133
+ name: mod-audit
134
+ on: [push, pull_request]
135
+ jobs:
136
+ audit:
137
+ runs-on: ubuntu-latest
138
+ steps:
139
+ - uses: actions/checkout@v4
140
+ - uses: actions/setup-python@v5
141
+ with:
142
+ python-version: "3.12"
143
+ - run: pip install mod-audit
144
+ - run: mod-audit scan ./my-mod --format json
145
+ ```
146
+
147
+ Gate updates in a scheduled job:
148
+
149
+ ```bash
150
+ mod-audit diff ~/.claude/plugins/my-mod --against ~/snapshots/my-mod.snapshot --fail-on medium
151
+ ```
152
+
153
+ ## Limitations
154
+
155
+ - **Offline heuristics, not a sandbox.** Rules are pattern-based and can miss
156
+ obfuscated code or flag benign code. Treat findings as triage signals.
157
+ - **No execution.** The tool never runs mod code, which is the point — but it
158
+ also means runtime-only behavior (e.g. payloads fetched at runtime) is out
159
+ of scope.
160
+ - **Snapshot trust.** `diff` is only as trustworthy as the snapshot: take it
161
+ from a clean install and store it where the mod updater cannot modify it.
162
+ - **TypeScript via regex, not a parser.** Keeps the tool stdlib-only and fast;
163
+ heavily minified or dynamically generated code may need manual review.
164
+
165
+ ## License
166
+
167
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "mod-audit"
7
+ version = "0.1.0"
8
+ description = "Static supply-chain auditor for Claude Code Mods — local, offline, stdlib-only"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = {text = "MIT"}
12
+ authors = [{name = "hao li"}]
13
+ keywords = ["claude-code", "mods", "supply-chain", "security", "static-analysis", "audit"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.9",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Topic :: Security",
25
+ ]
26
+
27
+ [project.scripts]
28
+ mod-audit = "mod_audit.cli:main"
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/hahahahahahahahah6/mod-audit"
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,20 @@
1
+ """mod-audit: static supply-chain auditor for Claude Code Mods.
2
+
3
+ Local, offline, stdlib-only. Scans lifecycle hooks, TypeScript/JavaScript
4
+ source, env/API-key exfiltration patterns, and permissions manifests;
5
+ snapshots a trusted state and diffs installed mods against it to catch
6
+ trojanized updates.
7
+ """
8
+
9
+ __version__ = "0.1.0"
10
+
11
+ from .rules import Finding, scan_mod, scan_ts_source, scan_hook_commands, scan_permissions
12
+
13
+ __all__ = [
14
+ "__version__",
15
+ "Finding",
16
+ "scan_mod",
17
+ "scan_ts_source",
18
+ "scan_hook_commands",
19
+ "scan_permissions",
20
+ ]
@@ -0,0 +1,148 @@
1
+ """mod-audit CLI: scan, snapshot, diff for Claude Code Mods."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import datetime as _dt
6
+ import json
7
+ import os
8
+ import sys
9
+
10
+ from . import __version__
11
+ from .rules import (
12
+ SEVERITIES,
13
+ build_snapshot,
14
+ diff_against_snapshot,
15
+ scan_mod,
16
+ severity_at_least,
17
+ )
18
+
19
+
20
+ def _format_text(findings) -> str:
21
+ if not findings:
22
+ return "mod-audit: no findings. Mod looks clean.\n"
23
+ lines = []
24
+ for f in findings:
25
+ loc = f"{f.file}:{f.line}" if f.line else f.file
26
+ lines.append(f"[{f.severity.upper():8}] {f.rule_id} {loc}")
27
+ lines.append(f" {f.message}")
28
+ lines.append(f" Fix: {f.fix}")
29
+ summary = {}
30
+ for f in findings:
31
+ summary[f.severity] = summary.get(f.severity, 0) + 1
32
+ lines.append("")
33
+ lines.append(
34
+ "Findings: %d (%s)"
35
+ % (len(findings), ", ".join(f"{k}={v}" for k, v in sorted(summary.items())))
36
+ )
37
+ return "\n".join(lines) + "\n"
38
+
39
+
40
+ def _emit(findings, fmt: str) -> int:
41
+ if fmt == "json":
42
+ print(json.dumps([f.to_dict() for f in findings], indent=2))
43
+ else:
44
+ sys.stdout.write(_format_text(findings))
45
+ return 0
46
+
47
+
48
+ def cmd_scan(args) -> int:
49
+ root = os.path.abspath(args.path)
50
+ if not os.path.isdir(root):
51
+ print(f"mod-audit: not a directory: {args.path}", file=sys.stderr)
52
+ return 2
53
+ findings = scan_mod(root)
54
+ _emit(findings, args.format)
55
+ if any(severity_at_least(f.severity, args.fail_on) for f in findings):
56
+ return 1
57
+ return 0
58
+
59
+
60
+ def cmd_snapshot(args) -> int:
61
+ root = os.path.abspath(args.path)
62
+ if not os.path.isdir(root):
63
+ print(f"mod-audit: not a directory: {args.path}", file=sys.stderr)
64
+ return 2
65
+ manifest = build_snapshot(root)
66
+ manifest["generated_at"] = _dt.datetime.now(_dt.timezone.utc).isoformat()
67
+ manifest["root"] = root
68
+
69
+ out = args.out or os.path.join(
70
+ os.getcwd(), os.path.basename(root.rstrip(os.sep)) + ".snapshot"
71
+ )
72
+ os.makedirs(out, exist_ok=True)
73
+ path = os.path.join(out, "manifest.json")
74
+ with open(path, "w", encoding="utf-8") as fh:
75
+ json.dump(manifest, fh, indent=2, sort_keys=True)
76
+ print(f"mod-audit: snapshot written to {path}")
77
+ print(f" files: {len(manifest['files'])}, hooks: {len(manifest['hooks'])}")
78
+ print("Keep this snapshot somewhere the mod updater cannot modify.")
79
+ return 0
80
+
81
+
82
+ def cmd_diff(args) -> int:
83
+ root = os.path.abspath(args.installed_dir)
84
+ snap_dir = os.path.abspath(args.against)
85
+ manifest_path = (
86
+ snap_dir
87
+ if snap_dir.endswith(".json")
88
+ else os.path.join(snap_dir, "manifest.json")
89
+ )
90
+ if not os.path.isdir(root):
91
+ print(f"mod-audit: not a directory: {args.installed_dir}", file=sys.stderr)
92
+ return 2
93
+ try:
94
+ with open(manifest_path, "r", encoding="utf-8") as fh:
95
+ snapshot = json.load(fh)
96
+ except (OSError, ValueError) as exc:
97
+ print(f"mod-audit: cannot read snapshot {manifest_path}: {exc}", file=sys.stderr)
98
+ return 2
99
+ findings = diff_against_snapshot(root, snapshot)
100
+ _emit(findings, args.format)
101
+ if any(severity_at_least(f.severity, args.fail_on) for f in findings):
102
+ return 1
103
+ return 0
104
+
105
+
106
+ def build_parser() -> argparse.ArgumentParser:
107
+ p = argparse.ArgumentParser(
108
+ prog="mod-audit",
109
+ description="Static supply-chain auditor for Claude Code Mods (local, offline, stdlib-only).",
110
+ )
111
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
112
+ sub = p.add_subparsers(dest="command", required=True)
113
+
114
+ s = sub.add_parser("scan", help="Audit a mod directory for supply-chain risks.")
115
+ s.add_argument("path", help="Path to the mod directory (e.g. ~/.claude/plugins/foo).")
116
+ s.add_argument("--format", choices=("text", "json"), default="text")
117
+ s.add_argument(
118
+ "--fail-on",
119
+ choices=SEVERITIES,
120
+ default="high",
121
+ help="Exit 1 if any finding meets this severity (default: high).",
122
+ )
123
+ s.set_defaults(func=cmd_scan)
124
+
125
+ s = sub.add_parser("snapshot", help="Save a trusted snapshot of a mod.")
126
+ s.add_argument("path", help="Path to the mod directory.")
127
+ s.add_argument("--out", help="Snapshot directory (default: ./<mod-name>.snapshot).")
128
+ s.set_defaults(func=cmd_snapshot)
129
+
130
+ s = sub.add_parser("diff", help="Diff an installed mod against a trusted snapshot.")
131
+ s.add_argument("installed_dir", help="Path to the installed mod directory.")
132
+ s.add_argument(
133
+ "--against", required=True, help="Snapshot directory or manifest.json from `snapshot`."
134
+ )
135
+ s.add_argument("--format", choices=("text", "json"), default="text")
136
+ s.add_argument("--fail-on", choices=SEVERITIES, default="high")
137
+ s.set_defaults(func=cmd_diff)
138
+ return p
139
+
140
+
141
+ def main(argv=None) -> int:
142
+ parser = build_parser()
143
+ args = parser.parse_args(argv)
144
+ return args.func(args)
145
+
146
+
147
+ if __name__ == "__main__":
148
+ raise SystemExit(main())