penthera 1.1.1 → 1.4.0
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.
- package/CHANGELOG.md +269 -3
- package/README.md +489 -286
- package/SECURITY.md +28 -6
- package/docs/METHODOLOGY.md +321 -0
- package/docs/compliance-nis2.md +166 -0
- package/docs/evidence.md +158 -0
- package/docs/examples/github-actions-deps.yml +105 -0
- package/docs/examples/github-actions-scan.yml +55 -0
- package/docs/examples/pr-dependency-review.yml +56 -0
- package/docs/funding/due-diligence-checklist.md +79 -0
- package/docs/funding/nlnet-ngi-zero-draft.md +273 -0
- package/docs/funding/partner-one-pager.md +84 -0
- package/docs/funding/sponsor-tiers.md +81 -0
- package/docs/index.md +15 -3
- package/docs/local-loop.md +276 -0
- package/docs/references.bib +539 -0
- package/docs/references.md +77 -0
- package/docs/release-plan.md +228 -0
- package/lib/adaptive-probes.js +8 -5
- package/lib/agents/discover.js +384 -0
- package/lib/agents/hidden-content.js +387 -0
- package/lib/agents/index.js +286 -0
- package/lib/agents/lock.js +115 -0
- package/lib/agents/owasp.js +83 -0
- package/lib/agents/rules.js +376 -0
- package/lib/crawler.js +5 -15
- package/lib/data/retire-jsrepository.json.gz +0 -0
- package/lib/data/retire-jsrepository.meta.json +7 -0
- package/lib/deps/capabilities.js +523 -0
- package/lib/deps/diff.js +84 -0
- package/lib/deps/index.js +416 -0
- package/lib/deps/lockfile.js +106 -0
- package/lib/deps/online.js +132 -0
- package/lib/deps/popular-packages.json +1 -0
- package/lib/deps/report.js +173 -0
- package/lib/deps/source.js +171 -0
- package/lib/deps/typosquat.js +113 -0
- package/lib/fix/apply.js +95 -0
- package/lib/fix/diff.js +199 -0
- package/lib/fix/index.js +129 -0
- package/lib/fix/model.js +108 -0
- package/lib/fix/recipes/cookies.js +130 -0
- package/lib/fix/recipes/cors.js +116 -0
- package/lib/fix/recipes/headers.js +250 -0
- package/lib/fix/recipes/rate-limit.js +211 -0
- package/lib/fix/recipes/secrets.js +110 -0
- package/lib/fix/util.js +231 -0
- package/lib/fuzzer.js +14 -8
- package/lib/injections.js +19 -15
- package/lib/install/index.js +271 -0
- package/lib/install/policy.js +117 -0
- package/lib/memory/history.js +52 -0
- package/lib/memory/index.js +111 -0
- package/lib/memory/repo-suppress.js +64 -0
- package/lib/memory/store.js +325 -0
- package/lib/memory/views.js +198 -0
- package/lib/params.js +4 -12
- package/lib/provenance.js +2 -0
- package/lib/recon.js +12 -20
- package/lib/references.js +31 -0
- package/lib/references.json +374 -0
- package/lib/remediation/index.js +28 -0
- package/lib/retirejs.js +50 -22
- package/lib/templates.js +7 -10
- package/lib/whitebox/frameworks.js +1 -1
- package/lib/whitebox/secrets.js +49 -19
- package/package.json +19 -9
- package/skills/penthera/SKILL.md +104 -23
- package/skills/penthera/references/output-and-ci.md +12 -50
- package/skills/penthera/references/troubleshooting.md +1 -1
- package/skills/penthera/scripts/preflight.sh +4 -4
- package/src/agent/git.js +87 -0
- package/src/agent/loop.js +519 -0
- package/src/agent/tests.js +48 -0
- package/src/cli/agent.js +183 -0
- package/src/cli/deps.js +84 -0
- package/src/cli/evidence.js +67 -0
- package/src/cli/fix.js +191 -0
- package/src/cli/install.js +225 -0
- package/src/cli/memory.js +146 -0
- package/src/cli/merge-results.js +2 -0
- package/src/cli/report-outputs.js +72 -0
- package/src/cli/run-scan.js +2 -0
- package/src/cli.js +152 -6
- package/src/report/audience.js +270 -0
- package/src/report/evidence.js +253 -0
- package/src/report/markdown.js +65 -2
- package/src/report/model.js +212 -0
- package/src/report/template.js +175 -0
- package/src/report/timeline.js +145 -0
- package/src/reporter.js +109 -5
- package/src/scan-url.js +31 -7
- package/src/utils/http.js +252 -11
- package/src/utils/url.js +23 -0
- package/scripts/package-skill.sh +0 -16
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# Release plan: v1.2.0 → v1.4.0
|
|
2
|
+
|
|
3
|
+
The engineering plan behind the [roadmap](../ROADMAP.md): what ships in each
|
|
4
|
+
release, in what order, what each piece depends on, and how we know it is done.
|
|
5
|
+
Dates are targets, not promises. Building is fast; review, real-world testing,
|
|
6
|
+
and the 2FA release approval set the pace.
|
|
7
|
+
|
|
8
|
+
Written 2026-10-19. **Status:** both milestones shipped together as 1.4.0 on 2026-10-19, a few weeks ahead of the targets below.
|
|
9
|
+
|
|
10
|
+
## The bet
|
|
11
|
+
|
|
12
|
+
Two releases that move Penthera from "a good scanner" to a system that competes
|
|
13
|
+
with commercial supply-chain and AppSec platforms on their core value, while
|
|
14
|
+
keeping what they cannot copy: local, deterministic, explainable, no telemetry.
|
|
15
|
+
|
|
16
|
+
| Commercial platforms sell | Penthera answers with | Release |
|
|
17
|
+
|---------------------------|-----------------------|---------|
|
|
18
|
+
| Dependency capability alerts and upgrade diffs | `penthera deps` and `deps --diff` | v1.3.0 |
|
|
19
|
+
| Skill, extension, and MCP scanning | `penthera agents` and `penthera.lock` | v1.3.0 |
|
|
20
|
+
| PR comments on dependency changes | GitHub Action that comments the capability diff | v1.4.0 |
|
|
21
|
+
| An install firewall | `penthera install`: check before install scripts run | v1.4.0 |
|
|
22
|
+
| Auto-fix PRs | A local scan → fix → verify → learn loop that never pushes | v1.4.0 |
|
|
23
|
+
| Proof that it works | Published methodology, verified citations, a benchmark | v1.3.0 |
|
|
24
|
+
|
|
25
|
+
## Timeline at a glance
|
|
26
|
+
|
|
27
|
+
| When | Milestone |
|
|
28
|
+
|------|-----------|
|
|
29
|
+
| Oct 19–20 | v1.2.0 live on npm (2FA approval), old npm token deleted |
|
|
30
|
+
| Oct 20–22 | v1.3.0 PRs merged in order, release staged |
|
|
31
|
+
| Oct 23 | v1.3.0 live, launch write-up published |
|
|
32
|
+
| Oct 23–30 | v1.4.0 wave 1: HTTP client and egress guard, learning store, fix recipes, install firewall |
|
|
33
|
+
| Oct 30–Nov 6 | v1.4.0 wave 2: agent loop, custom reports, PR-comment Action |
|
|
34
|
+
| Nov 6–10 | Release gate: real-app test, benchmark diff, security review |
|
|
35
|
+
| Nov 10 | v1.4.0 live |
|
|
36
|
+
| From Nov 16 | Internal testing audit, landing page (see "Scheduled for later") |
|
|
37
|
+
| December | v1.5.0 (staged on the roadmap) |
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## v1.2.0: finish the release
|
|
42
|
+
|
|
43
|
+
| Step | Owner | Done when |
|
|
44
|
+
|------|-------|-----------|
|
|
45
|
+
| Approve the staged version on npmjs.com (Staged Packages, 2FA) | Maintainer | `npm view penthera version` is 1.2.0 with provenance |
|
|
46
|
+
| Delete the `NPM_TOKEN` repository secret | Maintainer | Secret gone; publishing is OIDC-only |
|
|
47
|
+
| Decide Dependabot #11 (`setup-node` v6 → v7) | Maintainer + CI | v7 changes how `NODE_AUTH_TOKEN` is handled. With no token left this is likely safe, but merge it only with a dry-run of `publish.yml` (`npm stage publish --dry-run`) on a branch first |
|
|
48
|
+
|
|
49
|
+
## v1.3.0 "AI toolchain": target Oct 22, live Oct 23
|
|
50
|
+
|
|
51
|
+
All four pieces are already built or in progress. The work is review and merge
|
|
52
|
+
order, because three of them touch `src/cli.js`, the README, and the CHANGELOG.
|
|
53
|
+
|
|
54
|
+
**Merge order:**
|
|
55
|
+
|
|
56
|
+
1. **#12 `penthera agents`.** Offline audit of `.mcp.json`, `~/.claude`,
|
|
57
|
+
`~/.cursor`, hooks, plugins, skills, and rule files. Includes the
|
|
58
|
+
hidden-content engine, `penthera.lock`, an A–F grade, and OWASP MCP / Agentic
|
|
59
|
+
mapping. Rebase on main, review, merge.
|
|
60
|
+
2. **Research and methodology.** `docs/METHODOLOGY.md`, verified
|
|
61
|
+
`references.bib`, per-finding citations in Markdown and SARIF,
|
|
62
|
+
`CITATION.cff`, an offline benchmark with precision and recall. Rebase
|
|
63
|
+
after #12 (both touch the reporters).
|
|
64
|
+
3. **`penthera deps`.** Static capability detection on `node_modules`,
|
|
65
|
+
`--diff` between lockfiles, typosquat check against a vendored list, and an
|
|
66
|
+
opt-in `--online` registry check. Rebase after the research PR.
|
|
67
|
+
4. **Funding pack.** Docs only, any order.
|
|
68
|
+
|
|
69
|
+
**Also in v1.3.0:**
|
|
70
|
+
|
|
71
|
+
| Item | Why | Size |
|
|
72
|
+
|------|-----|------|
|
|
73
|
+
| Bump to 1.3.0 everywhere, CHANGELOG, tag, approve | Release | S |
|
|
74
|
+
| Launch write-up: an offline audit of popular public skills and MCP servers with `penthera agents` | The post that earns attention. Read-only analysis of public repos, findings reported to authors privately first | M |
|
|
75
|
+
|
|
76
|
+
**Release gate:** `npm test` green, benchmark numbers in `bench/RESULTS.md` no
|
|
77
|
+
worse than v1.2.0, `penthera agents` and `penthera deps` run cleanly on this
|
|
78
|
+
repo, every citation verified.
|
|
79
|
+
|
|
80
|
+
## v1.4.0 "Local loop + competitive": target Nov 10
|
|
81
|
+
|
|
82
|
+
### Wave 1 (parallel, Oct 23–30)
|
|
83
|
+
|
|
84
|
+
Each workstream owns different files, so they can be built side by side.
|
|
85
|
+
|
|
86
|
+
**W1. One HTTP client, `--offline` egress guard, User-Agent, inconclusive probes**
|
|
87
|
+
- Every network call goes through `src/utils/http.js`. Today `crawler`,
|
|
88
|
+
`injections`, `fuzzer`, `templates`, `recon`, `params`, `retirejs`, and
|
|
89
|
+
`adaptive-probes` each build their own fetch wrapper.
|
|
90
|
+
- `--offline`: the client refuses any host other than the scan target. Recon
|
|
91
|
+
sources (crt.sh, Wayback, OTX) become explicit opt-ins.
|
|
92
|
+
- Vendor a Retire.js database snapshot, refreshed by a release script, so JS
|
|
93
|
+
CVE checks work offline.
|
|
94
|
+
- Send `penthera/<version> (+repo url)` as the User-Agent (closes #7).
|
|
95
|
+
- Probe errors are recorded and reported as "inconclusive" instead of passing
|
|
96
|
+
silently (closes #8).
|
|
97
|
+
- While rewriting `lib/injections.js` and `lib/fuzzer.js`: an honest header,
|
|
98
|
+
and payload strings moved into a clearly named data file. Socket's AI judge
|
|
99
|
+
flagged Penthera as malware for containing test payloads; this gives any
|
|
100
|
+
reviewer the context a snippet lacks. Then ask Socket to review the alert.
|
|
101
|
+
- **Done when:** a test that blocks all egress except the target passes for
|
|
102
|
+
every profile, and the vendored database matches upstream at release time.
|
|
103
|
+
|
|
104
|
+
**W2. Local learning store**
|
|
105
|
+
- `~/.penthera/memory/<project-hash>/events.jsonl`: append-only; each event
|
|
106
|
+
carries the hash of the previous one.
|
|
107
|
+
- Events: scan summary, fix applied (recipe, diff hash), fix verified or
|
|
108
|
+
failed, false positive suppressed (fingerprint, reason), probe yield per
|
|
109
|
+
stack.
|
|
110
|
+
- Derived views: recipe success rate per framework, active suppressions,
|
|
111
|
+
probe ranking. Used only to re-rank and suppress; never executes anything.
|
|
112
|
+
- `penthera memory show | verify | forget`. `verify` detects any edit to the
|
|
113
|
+
chain.
|
|
114
|
+
- Never read from the scanned repo. A suppression committed to a repo is shown
|
|
115
|
+
in the report as "suppressed by repo file" so a reviewer sees it.
|
|
116
|
+
- **Done when:** tampering with any line fails `verify`, and suppressions and
|
|
117
|
+
re-ranking are covered by tests.
|
|
118
|
+
|
|
119
|
+
**W3. Fix recipes**
|
|
120
|
+
- `lib/fix/`: deterministic codemods that produce a unified diff for the most
|
|
121
|
+
common findings:
|
|
122
|
+
- security headers for Next.js, Express (helmet), Fastify, and Hono;
|
|
123
|
+
- cookie flags;
|
|
124
|
+
- a CORS allowlist;
|
|
125
|
+
- a rate-limit stub on auth routes;
|
|
126
|
+
- moving a hardcoded secret to an environment variable, with "rotate it" as
|
|
127
|
+
an owner action.
|
|
128
|
+
- Framework detection reuses `lib/whitebox/frameworks.js`.
|
|
129
|
+
- Optional local model adapter (Ollama on localhost, enforced by W1) for
|
|
130
|
+
findings without a recipe. Its output is labelled "model-suggested,
|
|
131
|
+
needs-human-review".
|
|
132
|
+
- **Done when:** each recipe has before/after fixtures, and every produced diff
|
|
133
|
+
applies cleanly and makes its finding disappear on re-scan in the fixture app.
|
|
134
|
+
|
|
135
|
+
**W4. Install firewall**
|
|
136
|
+
- `penthera install [pkg...]`: resolve the dependency tree with scripts
|
|
137
|
+
disabled, run the `deps` capability diff against the current lockfile, show
|
|
138
|
+
what is new (install scripts, network, shell, env), and only then run the
|
|
139
|
+
real install, either after a prompt or under a policy file
|
|
140
|
+
(`penthera.policy.json`).
|
|
141
|
+
- **Done when:** a fixture package that gains an install script is stopped
|
|
142
|
+
before its script runs.
|
|
143
|
+
|
|
144
|
+
### Wave 2 (Oct 30–Nov 6)
|
|
145
|
+
|
|
146
|
+
**W5. Agent loop: `penthera agent --loop`** (depends on W1, W2, W3)
|
|
147
|
+
- State machine: scan → triage (ranked by memory) → plan (recipe or model
|
|
148
|
+
suggestion) → patch on a local branch `penthera/fix-<date>` → verify
|
|
149
|
+
(re-scan, plus the project's tests if present) → learn → repeat.
|
|
150
|
+
- Stops when nothing fixable remains or `--max-iterations` is reached.
|
|
151
|
+
- Every diff needs approval by default. `--auto low` auto-applies only
|
|
152
|
+
recipes with a verified success history in memory.
|
|
153
|
+
- Hard guarantees, each with a test:
|
|
154
|
+
- never pushes, never opens pull requests, never calls a remote;
|
|
155
|
+
- egress is limited to the target;
|
|
156
|
+
- the working tree must be clean before it starts.
|
|
157
|
+
- **Done when:** on the fixture app the loop takes the findings to zero in a
|
|
158
|
+
bounded number of iterations, and a second run is faster because memory
|
|
159
|
+
re-ranked the recipes.
|
|
160
|
+
|
|
161
|
+
**W6. Custom reports and evidence bundle** (depends on W2)
|
|
162
|
+
- Audience templates: developer, owner, auditor. User templates via
|
|
163
|
+
`--report-template file.md` with a small placeholder syntax that evaluates
|
|
164
|
+
no code.
|
|
165
|
+
- Loop timeline: found → fixed → verified, with time-to-fix from memory.
|
|
166
|
+
- `--evidence <dir>`: reports plus a `manifest.json` with the SHA-256 of
|
|
167
|
+
every artifact. Optional signing with a local SSH key
|
|
168
|
+
(`ssh-keygen -Y sign`), no new dependency.
|
|
169
|
+
- **Done when:** an auditor can verify a bundle with one documented command.
|
|
170
|
+
|
|
171
|
+
**W7. PR-comment GitHub Action** (depends on `deps` from v1.3.0)
|
|
172
|
+
- A composite action published from this repo: on PRs that change a lockfile
|
|
173
|
+
it runs `penthera deps --diff` against the base branch, posts or updates a
|
|
174
|
+
single comment ("2 packages gained network access, 1 new install script"),
|
|
175
|
+
and uploads SARIF.
|
|
176
|
+
- Pinned by SHA in the docs; no secrets needed beyond the default token.
|
|
177
|
+
- **Done when:** it runs on a test PR in this repo and the comment updates in
|
|
178
|
+
place on a new push.
|
|
179
|
+
|
|
180
|
+
**W8. Skill and docs**
|
|
181
|
+
- SKILL.md workflow "scan and fix" uses `penthera agent`.
|
|
182
|
+
- `docs/local-loop.md` explains the threat model: why nothing leaves the
|
|
183
|
+
machine, why the store is hash-chained, why rules never update at runtime.
|
|
184
|
+
|
|
185
|
+
### Release gate (Nov 6–10)
|
|
186
|
+
|
|
187
|
+
- Run the loop on one real app the maintainer owns, not only fixtures.
|
|
188
|
+
- Benchmark: precision and recall no worse than v1.3.0.
|
|
189
|
+
- Self-scan with `penthera --repo .`, `penthera agents`, and `penthera deps`:
|
|
190
|
+
no new high findings.
|
|
191
|
+
- Security review of the diff since v1.3.0, focused on the loop and the
|
|
192
|
+
firewall (they run commands and write code).
|
|
193
|
+
- Then bump to 1.4.0, tag, approve.
|
|
194
|
+
|
|
195
|
+
## Risks
|
|
196
|
+
|
|
197
|
+
| Risk | Mitigation |
|
|
198
|
+
|------|------------|
|
|
199
|
+
| Three PRs conflict in `src/cli.js` | Strict merge order, rebase each after the previous one lands. Consider splitting subcommands into `src/commands/` early in v1.4.0 |
|
|
200
|
+
| Fix recipes break someone's app | Diffs only, approval by default, verify by re-scan and tests, a local branch only |
|
|
201
|
+
| The learning store becomes an attack surface | Home directory only, hash chain, re-rank and suppress only, nothing executable |
|
|
202
|
+
| Install firewall false positives annoy users | Show the capability diff, not a verdict; a policy file to allow known packages |
|
|
203
|
+
| Another AI-judge false positive on new modules | Honest headers, payloads in data files, Socket review requested |
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
## Scheduled for later (from Nov 16)
|
|
208
|
+
|
|
209
|
+
**Internal testing audit.** A standing quality gate run before every release:
|
|
210
|
+
- Penthera against itself and a fixed set of deliberately vulnerable apps
|
|
211
|
+
(mock server, OWASP Juice Shop, crAPI).
|
|
212
|
+
- `agents` and `deps` against fixture configs.
|
|
213
|
+
- A diff of results against the previous release, with false positives and
|
|
214
|
+
false negatives triaged into issues.
|
|
215
|
+
- A scheduled weekly run, so drift shows up between releases.
|
|
216
|
+
- The results feed `bench/RESULTS.md`.
|
|
217
|
+
|
|
218
|
+
**Landing page.** A one-page site: what it does, the 30-second install, the
|
|
219
|
+
trust story (offline, staged releases, methodology, benchmark), and links to
|
|
220
|
+
the docs. Static, hosted on GitHub Pages, with no tracking scripts, in keeping
|
|
221
|
+
with the no-telemetry principle.
|
|
222
|
+
|
|
223
|
+
## v1.5.0 and beyond
|
|
224
|
+
|
|
225
|
+
Staged on the [roadmap](../ROADMAP.md): LLM endpoint probes, OWASP LLM
|
|
226
|
+
mapping, the CRA Article 14 helper, the AI-BOM, a pre-commit hook, license
|
|
227
|
+
policy, and signed rule packs. After that: the local dashboard (`penthera ui`)
|
|
228
|
+
and a team pack.
|
package/lib/adaptive-probes.js
CHANGED
|
@@ -8,15 +8,20 @@
|
|
|
8
8
|
* Results feed into the SecurityKnowledgeGraph for chain discovery.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { request } from "../src/utils/http.js";
|
|
12
|
+
|
|
11
13
|
const DEFAULT_TIMEOUT = 10_000;
|
|
12
14
|
|
|
13
15
|
async function probe(base, path, opts = {}) {
|
|
14
16
|
const url = `${base}${path}`;
|
|
15
|
-
const controller = new AbortController();
|
|
16
|
-
const timeout = setTimeout(() => controller.abort(), opts.timeout || DEFAULT_TIMEOUT);
|
|
17
17
|
const start = Date.now();
|
|
18
18
|
try {
|
|
19
|
-
const res = await
|
|
19
|
+
const res = await request(url, {
|
|
20
|
+
timeout: DEFAULT_TIMEOUT,
|
|
21
|
+
probe: "adaptive",
|
|
22
|
+
redirect: "follow",
|
|
23
|
+
...opts,
|
|
24
|
+
});
|
|
20
25
|
const elapsed = Date.now() - start;
|
|
21
26
|
let body = null;
|
|
22
27
|
const ct = res.headers.get("content-type") || "";
|
|
@@ -36,8 +41,6 @@ async function probe(base, path, opts = {}) {
|
|
|
36
41
|
elapsed: Date.now() - start, ok: false,
|
|
37
42
|
error: err.name === "AbortError" ? "timeout" : err.message,
|
|
38
43
|
};
|
|
39
|
-
} finally {
|
|
40
|
-
clearTimeout(timeout);
|
|
41
44
|
}
|
|
42
45
|
}
|
|
43
46
|
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discover AI developer-tooling configuration in a repo and (opt-in) in the
|
|
3
|
+
* user's home directory: MCP servers, hooks, permissions, skills, plugins,
|
|
4
|
+
* marketplaces, and instruction files (AGENTS.md, CLAUDE.md, rules).
|
|
5
|
+
*
|
|
6
|
+
* Read-only. Nothing here writes, executes, or touches the network.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync, readdirSync, statSync, existsSync, realpathSync } from "node:fs";
|
|
9
|
+
import { join, relative, dirname, basename, sep, resolve } from "node:path";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import YAML from "yaml";
|
|
12
|
+
|
|
13
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", ".venv", "venv", "__pycache__", "dist", "build", ".next"]);
|
|
14
|
+
export const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
|
15
|
+
const MAX_WALK_FILES = 5000;
|
|
16
|
+
|
|
17
|
+
// ── Small helpers ─────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/** JSON.parse that tolerates // and /* *\/ comments and trailing commas (JSONC). */
|
|
20
|
+
export function parseJsonc(text) {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(text);
|
|
23
|
+
} catch {
|
|
24
|
+
let out = "";
|
|
25
|
+
let inStr = false;
|
|
26
|
+
for (let i = 0; i < text.length; i++) {
|
|
27
|
+
const ch = text[i];
|
|
28
|
+
if (inStr) {
|
|
29
|
+
out += ch;
|
|
30
|
+
if (ch === "\\") { out += text[++i] ?? ""; continue; }
|
|
31
|
+
if (ch === '"') inStr = false;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (ch === '"') { inStr = true; out += ch; continue; }
|
|
35
|
+
if (ch === "/" && text[i + 1] === "/") { while (i < text.length && text[i] !== "\n") i++; out += "\n"; continue; }
|
|
36
|
+
if (ch === "/" && text[i + 1] === "*") { i += 2; while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++; i++; continue; }
|
|
37
|
+
out += ch;
|
|
38
|
+
}
|
|
39
|
+
return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1"));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isFile(p) {
|
|
44
|
+
try { return statSync(p).isFile(); } catch { return false; }
|
|
45
|
+
}
|
|
46
|
+
function isDir(p) {
|
|
47
|
+
try { return statSync(p).isDirectory(); } catch { return false; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function readText(p) {
|
|
51
|
+
try {
|
|
52
|
+
const st = statSync(p);
|
|
53
|
+
if (!st.isFile()) return null;
|
|
54
|
+
if (st.size > MAX_FILE_BYTES) return { tooLarge: true, size: st.size };
|
|
55
|
+
return readFileSync(p, "utf-8");
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Parse YAML frontmatter from a Markdown file. Returns {} when absent or invalid. */
|
|
62
|
+
export function parseFrontmatter(text) {
|
|
63
|
+
const m = /^?---\r?\n([\s\S]*?)\r?\n---\r?(?:\n|$)/.exec(text || "");
|
|
64
|
+
if (!m) return {};
|
|
65
|
+
try {
|
|
66
|
+
const fm = YAML.parse(m[1]);
|
|
67
|
+
return fm && typeof fm === "object" ? fm : {};
|
|
68
|
+
} catch {
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Walk a directory without following symlinks. Returns files and symlinks
|
|
75
|
+
* (relative to `dir`), bounded so a hostile tree can't hang the scan.
|
|
76
|
+
*/
|
|
77
|
+
export function walk(dir, { maxDepth = 12, maxFiles = MAX_WALK_FILES } = {}) {
|
|
78
|
+
const files = [];
|
|
79
|
+
const symlinks = [];
|
|
80
|
+
let truncated = false;
|
|
81
|
+
const visit = (d, depth) => {
|
|
82
|
+
if (files.length >= maxFiles) { truncated = true; return; }
|
|
83
|
+
let entries;
|
|
84
|
+
try { entries = readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
85
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
86
|
+
for (const e of entries) {
|
|
87
|
+
if (files.length >= maxFiles) { truncated = true; return; }
|
|
88
|
+
const full = join(d, e.name);
|
|
89
|
+
if (e.isSymbolicLink()) { symlinks.push(full); continue; }
|
|
90
|
+
if (e.isDirectory()) {
|
|
91
|
+
if (SKIP_DIRS.has(e.name) || depth >= maxDepth) continue;
|
|
92
|
+
visit(full, depth + 1);
|
|
93
|
+
} else if (e.isFile()) {
|
|
94
|
+
files.push(full);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
visit(dir, 0);
|
|
99
|
+
return { files, symlinks, truncated };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Inventory ─────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
function createInventory(root, homeDir, includeHome) {
|
|
105
|
+
const display = (p) => {
|
|
106
|
+
const abs = resolve(p);
|
|
107
|
+
if (abs === root || abs.startsWith(root + sep)) return relative(root, abs) || ".";
|
|
108
|
+
if (homeDir && (abs === homeDir || abs.startsWith(homeDir + sep))) return "~/" + relative(homeDir, abs).split(sep).join("/");
|
|
109
|
+
return abs;
|
|
110
|
+
};
|
|
111
|
+
return {
|
|
112
|
+
root,
|
|
113
|
+
homeDir: includeHome ? homeDir : null,
|
|
114
|
+
display,
|
|
115
|
+
configFiles: [], // every config file read: { path, display }
|
|
116
|
+
instructionFiles: [], // Markdown/rule files a model reads: { path, display, scope }
|
|
117
|
+
skills: [], // { name, dir, display, skillFile, frontmatter, scope }
|
|
118
|
+
mcpServers: [], // { name, def, file, scope }
|
|
119
|
+
hooks: [], // { event, matcher, hook, file, key, baseDir }
|
|
120
|
+
permissions: [], // { rule, list, file }
|
|
121
|
+
settings: [], // { file, defaultMode, raw }
|
|
122
|
+
marketplaces: [], // { name, source, file }
|
|
123
|
+
plugins: [], // { id, installPath, version, gitCommitSha, file, enabled }
|
|
124
|
+
pluginSources: [], // plugin entries listed in a marketplace.json: { name, source, file }
|
|
125
|
+
errors: [], // { file, message }
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function loadJson(inv, path) {
|
|
130
|
+
const text = readText(path);
|
|
131
|
+
if (text == null) return null;
|
|
132
|
+
if (typeof text === "object") {
|
|
133
|
+
inv.errors.push({ file: inv.display(path), message: `larger than ${MAX_FILE_BYTES} bytes, not parsed` });
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const json = parseJsonc(text);
|
|
138
|
+
inv.configFiles.push({ path, display: inv.display(path) });
|
|
139
|
+
return json;
|
|
140
|
+
} catch (e) {
|
|
141
|
+
inv.errors.push({ file: inv.display(path), message: `invalid JSON: ${e.message}` });
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function addMcpServers(inv, servers, file, scope) {
|
|
147
|
+
if (!servers || typeof servers !== "object") return;
|
|
148
|
+
for (const [name, def] of Object.entries(servers)) {
|
|
149
|
+
if (def && typeof def === "object") inv.mcpServers.push({ name, def, file, scope });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function addMcpFile(inv, path, scope) {
|
|
154
|
+
const json = loadJson(inv, path);
|
|
155
|
+
if (!json) return;
|
|
156
|
+
// Claude/Cursor/Desktop use `mcpServers`; VS Code uses `servers`.
|
|
157
|
+
addMcpServers(inv, json.mcpServers || json.servers, inv.display(path), scope);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function addHooks(inv, hooksObj, file, baseDir) {
|
|
161
|
+
if (!hooksObj || typeof hooksObj !== "object") return;
|
|
162
|
+
for (const [event, groups] of Object.entries(hooksObj)) {
|
|
163
|
+
if (!Array.isArray(groups)) continue;
|
|
164
|
+
groups.forEach((group, gi) => {
|
|
165
|
+
const matcher = group?.matcher ?? "";
|
|
166
|
+
const list = Array.isArray(group?.hooks) ? group.hooks : [group];
|
|
167
|
+
list.forEach((hook, hi) => {
|
|
168
|
+
if (!hook || typeof hook !== "object") return;
|
|
169
|
+
inv.hooks.push({
|
|
170
|
+
event,
|
|
171
|
+
matcher,
|
|
172
|
+
hook,
|
|
173
|
+
file,
|
|
174
|
+
baseDir,
|
|
175
|
+
key: `hook:${file}#${event}[${matcher || "*"}]/${gi}.${hi}`,
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function addSettingsFile(inv, path, scope) {
|
|
183
|
+
const json = loadJson(inv, path);
|
|
184
|
+
if (!json) return;
|
|
185
|
+
const file = inv.display(path);
|
|
186
|
+
const projectDir = scope === "repo" ? inv.root : dirname(dirname(path));
|
|
187
|
+
inv.settings.push({ file, defaultMode: json.permissions?.defaultMode ?? null, raw: json });
|
|
188
|
+
addHooks(inv, json.hooks, file, projectDir);
|
|
189
|
+
for (const list of ["allow", "ask", "deny"]) {
|
|
190
|
+
const rules = json.permissions?.[list];
|
|
191
|
+
if (Array.isArray(rules)) for (const rule of rules) inv.permissions.push({ rule: String(rule), list, file });
|
|
192
|
+
}
|
|
193
|
+
addMcpServers(inv, json.mcpServers, file, scope);
|
|
194
|
+
const mk = json.extraKnownMarketplaces;
|
|
195
|
+
if (mk && typeof mk === "object") {
|
|
196
|
+
for (const [name, v] of Object.entries(mk)) inv.marketplaces.push({ name, source: v?.source ?? v, file });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function addInstructionFile(inv, path, scope) {
|
|
201
|
+
if (!isFile(path)) return;
|
|
202
|
+
inv.instructionFiles.push({ path, display: inv.display(path), scope });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function addMarkdownDir(inv, dir, scope) {
|
|
206
|
+
if (!isDir(dir)) return;
|
|
207
|
+
for (const f of walk(dir, { maxDepth: 6 }).files) {
|
|
208
|
+
if (/\.(?:md|mdc|markdown|txt)$/i.test(f)) addInstructionFile(inv, f, scope);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function addSkillsUnder(inv, dir, scope) {
|
|
213
|
+
if (!isDir(dir)) return;
|
|
214
|
+
const seen = new Set(inv.skills.map((s) => s.dir));
|
|
215
|
+
for (const f of walk(dir, { maxDepth: 6 }).files) {
|
|
216
|
+
if (basename(f).toLowerCase() !== "skill.md") continue;
|
|
217
|
+
const skillDir = dirname(f);
|
|
218
|
+
if (seen.has(skillDir)) continue;
|
|
219
|
+
seen.add(skillDir);
|
|
220
|
+
const text = readText(f);
|
|
221
|
+
const fm = typeof text === "string" ? parseFrontmatter(text) : {};
|
|
222
|
+
inv.skills.push({
|
|
223
|
+
name: typeof fm.name === "string" ? fm.name : basename(skillDir),
|
|
224
|
+
dir: skillDir,
|
|
225
|
+
display: inv.display(skillDir),
|
|
226
|
+
skillFile: f,
|
|
227
|
+
frontmatter: fm,
|
|
228
|
+
scope,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function addPluginDir(inv, pluginDir, scope) {
|
|
234
|
+
addSkillsUnder(inv, join(pluginDir, "skills"), scope);
|
|
235
|
+
addMarkdownDir(inv, join(pluginDir, "commands"), scope);
|
|
236
|
+
addMarkdownDir(inv, join(pluginDir, "agents"), scope);
|
|
237
|
+
const hooksFile = join(pluginDir, "hooks", "hooks.json");
|
|
238
|
+
if (isFile(hooksFile)) {
|
|
239
|
+
const json = loadJson(inv, hooksFile);
|
|
240
|
+
if (json) addHooks(inv, json.hooks || json, inv.display(hooksFile), pluginDir);
|
|
241
|
+
}
|
|
242
|
+
const mcp = join(pluginDir, ".mcp.json");
|
|
243
|
+
if (isFile(mcp)) addMcpFile(inv, mcp, scope);
|
|
244
|
+
const manifest = join(pluginDir, ".claude-plugin", "plugin.json");
|
|
245
|
+
if (isFile(manifest)) {
|
|
246
|
+
const json = loadJson(inv, manifest);
|
|
247
|
+
if (json) {
|
|
248
|
+
if (json.mcpServers && typeof json.mcpServers === "object") addMcpServers(inv, json.mcpServers, inv.display(manifest), scope);
|
|
249
|
+
if (json.hooks && typeof json.hooks === "object") addHooks(inv, json.hooks.hooks || json.hooks, inv.display(manifest), pluginDir);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function addMarketplaceManifest(inv, path) {
|
|
255
|
+
const json = loadJson(inv, path);
|
|
256
|
+
if (!json || !Array.isArray(json.plugins)) return;
|
|
257
|
+
for (const p of json.plugins) {
|
|
258
|
+
if (p && typeof p === "object") inv.pluginSources.push({ name: p.name || "(unnamed)", source: p.source, file: inv.display(path) });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ── Repo ──────────────────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
const SKILL_ROOTS = ["skills", ".claude/skills", ".cursor/skills", ".agents/skills", ".codex/skills"];
|
|
265
|
+
const INSTRUCTION_FILES = [
|
|
266
|
+
"AGENTS.md", "CLAUDE.md", "CLAUDE.local.md", ".claude/CLAUDE.md", "GEMINI.md",
|
|
267
|
+
".cursorrules", ".windsurfrules", ".clinerules",
|
|
268
|
+
".github/copilot-instructions.md",
|
|
269
|
+
];
|
|
270
|
+
|
|
271
|
+
function discoverRepo(inv) {
|
|
272
|
+
const r = inv.root;
|
|
273
|
+
for (const f of [".mcp.json", ".cursor/mcp.json", ".vscode/mcp.json"]) {
|
|
274
|
+
if (isFile(join(r, f))) addMcpFile(inv, join(r, f), "repo");
|
|
275
|
+
}
|
|
276
|
+
for (const f of [".claude/settings.json", ".claude/settings.local.json"]) {
|
|
277
|
+
if (isFile(join(r, f))) addSettingsFile(inv, join(r, f), "repo");
|
|
278
|
+
}
|
|
279
|
+
for (const f of INSTRUCTION_FILES) addInstructionFile(inv, join(r, f), "repo");
|
|
280
|
+
if (isDir(join(r, ".clinerules"))) addMarkdownDir(inv, join(r, ".clinerules"), "repo");
|
|
281
|
+
addMarkdownDir(inv, join(r, ".cursor", "rules"), "repo");
|
|
282
|
+
addMarkdownDir(inv, join(r, ".github", "instructions"), "repo");
|
|
283
|
+
addMarkdownDir(inv, join(r, ".claude", "commands"), "repo");
|
|
284
|
+
addMarkdownDir(inv, join(r, ".claude", "agents"), "repo");
|
|
285
|
+
for (const s of SKILL_ROOTS) addSkillsUnder(inv, join(r, s), "repo");
|
|
286
|
+
// Plugins developed in this repo: plugins/<name>/{skills,hooks,...}
|
|
287
|
+
if (isDir(join(r, "plugins"))) {
|
|
288
|
+
for (const e of readdirSync(join(r, "plugins"), { withFileTypes: true })) {
|
|
289
|
+
if (e.isDirectory()) addPluginDir(inv, join(r, "plugins", e.name), "repo");
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (isFile(join(r, ".claude-plugin", "marketplace.json"))) addMarketplaceManifest(inv, join(r, ".claude-plugin", "marketplace.json"));
|
|
293
|
+
if (isFile(join(r, ".claude-plugin", "plugin.json"))) addPluginDir(inv, r, "repo");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ── Home ──────────────────────────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
export function claudeDesktopConfigPaths(homeDir, platform = process.platform) {
|
|
299
|
+
const out = [];
|
|
300
|
+
if (platform === "darwin") out.push(join(homeDir, "Library", "Application Support", "Claude", "claude_desktop_config.json"));
|
|
301
|
+
if (platform === "win32" && process.env.APPDATA) out.push(join(process.env.APPDATA, "Claude", "claude_desktop_config.json"));
|
|
302
|
+
out.push(join(homeDir, ".config", "Claude", "claude_desktop_config.json"));
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function discoverHome(inv, homeDir) {
|
|
307
|
+
const claude = join(homeDir, ".claude");
|
|
308
|
+
for (const f of ["settings.json", "settings.local.json"]) {
|
|
309
|
+
if (isFile(join(claude, f))) addSettingsFile(inv, join(claude, f), "home");
|
|
310
|
+
}
|
|
311
|
+
addInstructionFile(inv, join(claude, "CLAUDE.md"), "home");
|
|
312
|
+
addSkillsUnder(inv, join(claude, "skills"), "home");
|
|
313
|
+
addSkillsUnder(inv, join(homeDir, ".agents", "skills"), "home");
|
|
314
|
+
addSkillsUnder(inv, join(homeDir, ".cursor", "skills"), "home");
|
|
315
|
+
addMarkdownDir(inv, join(claude, "commands"), "home");
|
|
316
|
+
addMarkdownDir(inv, join(claude, "agents"), "home");
|
|
317
|
+
|
|
318
|
+
// User-scoped MCP servers for Claude Code live in ~/.claude.json, top level
|
|
319
|
+
// and per project. Only the scanned project's entry is read.
|
|
320
|
+
const userJsonPath = join(homeDir, ".claude.json");
|
|
321
|
+
if (isFile(userJsonPath)) {
|
|
322
|
+
const json = loadJson(inv, userJsonPath);
|
|
323
|
+
if (json) {
|
|
324
|
+
addMcpServers(inv, json.mcpServers, inv.display(userJsonPath), "home");
|
|
325
|
+
const proj = json.projects?.[inv.root];
|
|
326
|
+
if (proj?.mcpServers) addMcpServers(inv, proj.mcpServers, `${inv.display(userJsonPath)} (project ${basename(inv.root)})`, "home");
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (isFile(join(homeDir, ".cursor", "mcp.json"))) addMcpFile(inv, join(homeDir, ".cursor", "mcp.json"), "home");
|
|
331
|
+
for (const p of claudeDesktopConfigPaths(homeDir)) {
|
|
332
|
+
if (isFile(p)) addMcpFile(inv, p, "home");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Plugins and the marketplaces they come from.
|
|
336
|
+
const pluginsDir = join(claude, "plugins");
|
|
337
|
+
const known = isFile(join(pluginsDir, "known_marketplaces.json")) ? loadJson(inv, join(pluginsDir, "known_marketplaces.json")) : null;
|
|
338
|
+
if (known && typeof known === "object") {
|
|
339
|
+
const file = inv.display(join(pluginsDir, "known_marketplaces.json"));
|
|
340
|
+
for (const [name, v] of Object.entries(known)) {
|
|
341
|
+
if (!inv.marketplaces.some((m) => m.name === name)) inv.marketplaces.push({ name, source: v?.source ?? v, file });
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
const enabled = new Set();
|
|
345
|
+
for (const s of inv.settings) {
|
|
346
|
+
const ep = s.raw?.enabledPlugins;
|
|
347
|
+
if (ep && typeof ep === "object") for (const [id, on] of Object.entries(ep)) if (on) enabled.add(id);
|
|
348
|
+
}
|
|
349
|
+
const installed = isFile(join(pluginsDir, "installed_plugins.json")) ? loadJson(inv, join(pluginsDir, "installed_plugins.json")) : null;
|
|
350
|
+
const pluginMap = installed?.plugins && typeof installed.plugins === "object" ? installed.plugins : {};
|
|
351
|
+
const file = inv.display(join(pluginsDir, "installed_plugins.json"));
|
|
352
|
+
for (const [id, entries] of Object.entries(pluginMap)) {
|
|
353
|
+
for (const e of Array.isArray(entries) ? entries : [entries]) {
|
|
354
|
+
if (!e || typeof e !== "object") continue;
|
|
355
|
+
inv.plugins.push({
|
|
356
|
+
id,
|
|
357
|
+
installPath: e.installPath || null,
|
|
358
|
+
version: e.version || null,
|
|
359
|
+
gitCommitSha: e.gitCommitSha || null,
|
|
360
|
+
file,
|
|
361
|
+
enabled: enabled.has(id),
|
|
362
|
+
});
|
|
363
|
+
if (e.installPath && isDir(e.installPath)) addPluginDir(inv, e.installPath, `plugin:${id}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Discover agent configuration.
|
|
370
|
+
* @param {string} root - repo root
|
|
371
|
+
* @param {object} [opts] { home: boolean, homeDir: string }
|
|
372
|
+
*/
|
|
373
|
+
export function discoverAgentConfig(root, opts = {}) {
|
|
374
|
+
const absRoot = resolve(root);
|
|
375
|
+
let canonical = absRoot;
|
|
376
|
+
try { canonical = realpathSync(absRoot); } catch { /* keep as given */ }
|
|
377
|
+
const homeDir = resolve(opts.homeDir || homedir());
|
|
378
|
+
const inv = createInventory(canonical, homeDir, !!opts.home);
|
|
379
|
+
if (existsSync(canonical)) discoverRepo(inv);
|
|
380
|
+
if (opts.home) discoverHome(inv, homeDir);
|
|
381
|
+
return inv;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export { isFile, isDir };
|