pkgxray 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jack Adams-Lovell
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.
package/README.md ADDED
@@ -0,0 +1,187 @@
1
+ # pkgxray
2
+
3
+ Local CLI + MCP server for triaging whether an AI coding-agent extension, Codex
4
+ plugin, Claude Code extension, or npm package is safe to install — from supplied
5
+ evidence or by fetching a real npm tarball into a sandboxed quarantine.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g pkgxray
11
+ # or use one-shot via npx:
12
+ npx pkgxray guard npm:some-package@1.2.3
13
+ ```
14
+
15
+ It is intentionally conservative. It only reports evidence it can cite from
16
+ metadata or source text, and it returns one of:
17
+
18
+ - `safe`: no high- or medium-risk indicators in the provided evidence
19
+ - `review`: incomplete evidence or privileged capability needing manual review
20
+ - `block`: high-severity indicators such as prompt injection, credential access,
21
+ persistence, obfuscation plus execution, or likely exfiltration
22
+
23
+ ## CLI
24
+
25
+ ```bash
26
+ pkgxray --file examples/evidence.json
27
+ pkgxray --format json --file examples/evidence.json
28
+ ```
29
+
30
+ Guard an extension before handing it to an agent:
31
+
32
+ ```bash
33
+ pkgxray guard ./some-local-extension
34
+ pkgxray guard npm:some-mcp-server@1.2.3 --format json
35
+ pkgxray guard ./some-local-extension --promote-to ./approved/some-local-extension
36
+ pkgxray guard npm:is-number@7.0.0 --no-source-scan --format json
37
+ ```
38
+
39
+ The guard flow stages the extension in a private quarantine directory, audits
40
+ that staged copy, and only promotes it when policy allows. It does not run
41
+ `npm install`, package lifecycle scripts, build steps, or extension code.
42
+
43
+ For npm references, guard order is:
44
+
45
+ 1. Resolve package metadata from the npm registry.
46
+ 2. Query OSV for the exact package/version.
47
+ 3. If OSV reports vulnerabilities, block before tarball download.
48
+ 4. If no vulnerabilities are reported, download and extract the tarball into
49
+ quarantine.
50
+ 5. Collect source evidence and run the static audit unless `--no-source-scan`
51
+ is set.
52
+
53
+ The JSON output includes timing fields:
54
+
55
+ - `stageMs`: local copy or npm metadata resolution
56
+ - `vulnerabilityPrecheckMs`: OSV lookup time
57
+ - `downloadMs`: tarball download, hash, and extraction time
58
+ - `sourceCollectionMs`: capped source-file collection time
59
+ - `auditMs`: static audit time
60
+
61
+ Guard decisions:
62
+
63
+ - `allow`: safe verdict; promotion can happen
64
+ - `review`: do not promote by default; a human should inspect the quarantine
65
+ - `block`: high-severity evidence; do not install
66
+
67
+ By default, only `safe` promotes. Use `--policy allow-review` only when you want
68
+ review-grade packages copied into the destination for manual handling.
69
+
70
+ Input JSON:
71
+
72
+ ```json
73
+ {
74
+ "packageName": "example-extension",
75
+ "npmMetadata": {},
76
+ "githubMetadata": {},
77
+ "webPresence": {},
78
+ "sourceFiles": {
79
+ "package.json": "{\"name\":\"example-extension\"}",
80
+ "index.js": "module.exports = {}"
81
+ }
82
+ }
83
+ ```
84
+
85
+ ## MCP Server
86
+
87
+ Use the stdio server from any MCP-capable agent:
88
+
89
+ ```json
90
+ {
91
+ "mcpServers": {
92
+ "pkgxray": {
93
+ "command": "pkgxray-mcp"
94
+ }
95
+ }
96
+ }
97
+ ```
98
+
99
+ The server exposes three tools:
100
+
101
+ - `audit_agent_extension_supply_chain` — zero-dep static heuristics
102
+ - `guard_agent_extension_install` — stage, vuln-check, audit a real package
103
+ - `reason_about_extension_supply_chain` — Claude-powered authoritative verdict (requires `ANTHROPIC_API_KEY`)
104
+
105
+ Tool arguments:
106
+
107
+ - `packageName`: optional package or extension name
108
+ - `npmMetadata`: optional npm metadata object or text
109
+ - `githubMetadata`: optional GitHub metadata object or text
110
+ - `webPresence`: optional web presence object or text
111
+ - `sourceFiles`: required map of file path to source text, or array of file objects
112
+ - `outputFormat`: `markdown` or `json`
113
+
114
+ `guard_agent_extension_install` accepts `reference`, optional `quarantineRoot`,
115
+ optional `promoteTo`, `policy`, `force`, and `outputFormat`.
116
+
117
+ `reason_about_extension_supply_chain` accepts the same evidence shape as
118
+ `audit_agent_extension_supply_chain`, plus optional `model` (default
119
+ `claude-opus-4-7`) and `maxFiles` (default 200). It returns a JSON verdict per
120
+ the prompt's schema (`verdict`, `summary`, `findings`, `evidenceGaps`,
121
+ `promotable`) plus `usage` and `latencyMs`.
122
+
123
+ ## Reasoning mode (`--reason`)
124
+
125
+ Layer an LLM-powered authoritative verdict on top of the static heuristics.
126
+ Supports Anthropic (Claude), OpenAI (GPT), and Google (Gemini).
127
+
128
+ ```bash
129
+ # Anthropic (default)
130
+ export ANTHROPIC_API_KEY=sk-ant-...
131
+ npm install -g @anthropic-ai/sdk
132
+ pkgxray --reason --file evidence.json
133
+ pkgxray guard npm:some-pkg --reason --format json
134
+
135
+ # OpenAI
136
+ export OPENAI_API_KEY=sk-...
137
+ npm install -g openai
138
+ pkgxray guard npm:some-pkg --reason --reason-provider openai
139
+
140
+ # Gemini
141
+ export GEMINI_API_KEY=...
142
+ npm install -g @google/generative-ai
143
+ pkgxray guard npm:some-pkg --reason --reason-model gemini-2.5-pro
144
+ ```
145
+
146
+ Provider is selected by `--reason-provider <anthropic|openai|gemini>` or
147
+ auto-detected from the model prefix (`claude-*` → anthropic, `gpt-*`/`o*` →
148
+ openai, `gemini-*` → gemini). Defaults: `claude-opus-4-7`, `gpt-5`,
149
+ `gemini-2.5-pro` — overridable with `--reason-model`.
150
+
151
+ The Anthropic path uses adaptive thinking, `effort: "high"`, and caches the
152
+ system prompt for 5-minute TTL (~90% cheaper on prompt tokens for repeated
153
+ calls in the window). OpenAI uses strict structured outputs against the same
154
+ JSON Schema. Gemini uses JSON-mode responses.
155
+
156
+ Source files are capped at 200 files / 32 KB each / 500 KB total before
157
+ sending — override with `--reason-max-files`.
158
+
159
+ The reasoning verdict supersedes the static decision when `--reason` is used.
160
+ Exit codes: `0` = safe, `2` = block, `3` = review.
161
+
162
+ ## Browser Extension
163
+
164
+ The `browser-extension/` folder is a Chrome-compatible Manifest V3 unpacked
165
+ extension. It runs entirely locally and requests no browser permissions.
166
+
167
+ Load the `browser-extension/` folder from a checkout of this repo as an
168
+ unpacked extension.
169
+
170
+ In Chrome:
171
+
172
+ 1. Open `chrome://extensions`.
173
+ 2. Enable Developer Mode.
174
+ 3. Choose Load unpacked.
175
+ 4. Select the `browser-extension` folder above.
176
+
177
+ In Dia, try the same flow if Dia exposes Chromium extension management. If Dia
178
+ does not currently allow unpacked extensions, use Chrome for testing and keep the
179
+ MCP/CLI version for agent workflows.
180
+
181
+ ## Local Development
182
+
183
+ ```bash
184
+ npm run build:browser
185
+ npm test
186
+ npm run audit:evidence -- --file examples/evidence.json
187
+ ```
package/bin/audit.js ADDED
@@ -0,0 +1,240 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const fs = require("node:fs");
5
+ const { auditEvidence, renderMarkdown } = require("../src/auditor");
6
+ const { guardExtension } = require("../src/quarantine");
7
+ const { reasonAbout } = require("../src/reasoner");
8
+
9
+ function printUsage() {
10
+ process.stderr.write(
11
+ [
12
+ "Usage:",
13
+ " pkgxray < evidence.json",
14
+ " pkgxray --format json < evidence.json",
15
+ " pkgxray --file evidence.json --format markdown",
16
+ " pkgxray --reason --file evidence.json",
17
+ " pkgxray guard <npm-package|npm:name@version|./path> [--reason] [--promote-to dir] [--no-source-scan]",
18
+ "",
19
+ "Evidence JSON fields:",
20
+ " packageName, npmMetadata, githubMetadata, webPresence, sourceFiles",
21
+ "",
22
+ "--reason consults an LLM as an authoritative verdict on top of the static",
23
+ "heuristics. Provider auto-detected from --reason-model, or pass",
24
+ "--reason-provider <anthropic|openai|gemini>. Defaults: anthropic +",
25
+ "claude-opus-4-7. Each provider needs its own env key (ANTHROPIC_API_KEY,",
26
+ "OPENAI_API_KEY, GEMINI_API_KEY) and SDK installed.",
27
+ ""
28
+ ].join("\n")
29
+ );
30
+ }
31
+
32
+ function parseArgs(argv) {
33
+ const options = { command: "audit", format: "markdown", file: null };
34
+ if (argv[0] === "guard") {
35
+ options.command = "guard";
36
+ options.reference = argv[1];
37
+ argv = argv.slice(2);
38
+ }
39
+
40
+ for (let i = 0; i < argv.length; i += 1) {
41
+ const arg = argv[i];
42
+ if (arg === "--help" || arg === "-h") {
43
+ options.help = true;
44
+ } else if (arg === "--format") {
45
+ options.format = argv[++i];
46
+ } else if (arg === "--file") {
47
+ options.file = argv[++i];
48
+ } else if (arg === "--quarantine-root") {
49
+ options.quarantineRoot = argv[++i];
50
+ } else if (arg === "--promote-to") {
51
+ options.promoteTo = argv[++i];
52
+ } else if (arg === "--policy") {
53
+ options.policy = argv[++i];
54
+ } else if (arg === "--force") {
55
+ options.force = true;
56
+ } else if (arg === "--no-source-scan") {
57
+ options.sourceScan = false;
58
+ } else if (arg === "--no-vulnerability-check") {
59
+ options.vulnerabilityCheck = false;
60
+ } else if (arg === "--reason") {
61
+ options.reason = true;
62
+ } else if (arg === "--reason-model") {
63
+ options.reasonModel = argv[++i];
64
+ } else if (arg === "--reason-provider") {
65
+ options.reasonProvider = argv[++i];
66
+ } else if (arg === "--reason-max-files") {
67
+ options.reasonMaxFiles = Number(argv[++i]);
68
+ } else {
69
+ throw new Error(`Unknown argument: ${arg}`);
70
+ }
71
+ }
72
+ if (!["json", "markdown"].includes(options.format)) {
73
+ throw new Error("--format must be json or markdown");
74
+ }
75
+ return options;
76
+ }
77
+
78
+ function readInput(file) {
79
+ if (file) {
80
+ return fs.readFileSync(file, "utf8");
81
+ }
82
+ return fs.readFileSync(0, "utf8");
83
+ }
84
+
85
+ async function maybeReason(evidence, options) {
86
+ if (!options.reason) return null;
87
+ try {
88
+ return await reasonAbout(evidence, {
89
+ provider: options.reasonProvider,
90
+ model: options.reasonModel,
91
+ maxFiles: options.reasonMaxFiles
92
+ });
93
+ } catch (error) {
94
+ return { error: { code: error.code || "REASONER_ERROR", message: error.message } };
95
+ }
96
+ }
97
+
98
+ function reasoningExitCode(reasoning) {
99
+ if (!reasoning || reasoning.error) return null;
100
+ if (reasoning.verdict === "block") return 2;
101
+ if (reasoning.verdict === "review") return 3;
102
+ return 0;
103
+ }
104
+
105
+ async function main() {
106
+ const options = parseArgs(process.argv.slice(2));
107
+ if (options.help) {
108
+ printUsage();
109
+ return;
110
+ }
111
+
112
+ if (options.command === "guard") {
113
+ if (!options.reference) {
114
+ throw new Error("guard requires an extension reference");
115
+ }
116
+ const result = await guardExtension(options.reference, options);
117
+ if (options.reason) {
118
+ const evidenceForReason = {
119
+ packageName: result.resolved && result.resolved.packageName,
120
+ npmMetadata: result.resolved && result.resolved.npmMetadata,
121
+ githubMetadata: null,
122
+ webPresence: null,
123
+ sourceFiles: result.sourceFiles || {}
124
+ };
125
+ const reasoning = await maybeReason(evidenceForReason, options);
126
+ result.reasoning = reasoning;
127
+ if (reasoning && !reasoning.error && reasoning.verdict) {
128
+ result.decision = reasoning.verdict === "block"
129
+ ? "block"
130
+ : reasoning.verdict === "safe"
131
+ ? "allow"
132
+ : "review";
133
+ }
134
+ }
135
+ if (options.format === "json") {
136
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
137
+ } else {
138
+ process.stdout.write(`${renderGuardMarkdown(result)}\n`);
139
+ }
140
+ process.exitCode = result.decision === "block" ? 2 : result.decision === "review" ? 3 : 0;
141
+ return;
142
+ }
143
+
144
+ const raw = readInput(options.file).trim();
145
+ if (!raw) {
146
+ throw new Error("No evidence JSON provided");
147
+ }
148
+
149
+ const evidence = JSON.parse(raw);
150
+ const report = auditEvidence(evidence);
151
+ const reasoning = await maybeReason(evidence, options);
152
+
153
+ const payload = reasoning ? { report, reasoning } : report;
154
+
155
+ if (options.format === "json") {
156
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
157
+ } else if (reasoning) {
158
+ process.stdout.write(`${renderMarkdown(report)}\n\n---\n\n${renderReasoningMarkdown(reasoning)}\n`);
159
+ } else {
160
+ process.stdout.write(`${renderMarkdown(report)}\n`);
161
+ }
162
+
163
+ const exitFromReason = reasoningExitCode(reasoning);
164
+ if (exitFromReason !== null) {
165
+ process.exitCode = exitFromReason;
166
+ }
167
+ }
168
+
169
+ function renderGuardMarkdown(result) {
170
+ const lines = [
171
+ `Decision: **${result.decision.toUpperCase()}**`,
172
+ `Reference: \`${result.reference}\``,
173
+ `Quarantine: \`${result.quarantinePath}\``,
174
+ ""
175
+ ];
176
+
177
+ if (result.promotedPath) {
178
+ lines.push(`Promoted to: \`${result.promotedPath}\``, "");
179
+ }
180
+
181
+ lines.push(renderMarkdown(result.report));
182
+
183
+ if (result.reasoning) {
184
+ lines.push("", "---", "", renderReasoningMarkdown(result.reasoning));
185
+ }
186
+
187
+ return lines.join("\n");
188
+ }
189
+
190
+ function renderReasoningMarkdown(reasoning) {
191
+ if (reasoning.error) {
192
+ return `Reasoning: **unavailable** (${reasoning.error.code}: ${reasoning.error.message})`;
193
+ }
194
+ const lines = [
195
+ `Reasoning verdict: **${(reasoning.verdict || "?").toUpperCase()}**`,
196
+ `Provider: \`${reasoning.provider || "?"}\` · Model: \`${reasoning.model}\` · latency: ${reasoning.latencyMs} ms`,
197
+ "",
198
+ reasoning.summary || "",
199
+ ""
200
+ ];
201
+ if (reasoning.usage) {
202
+ const u = reasoning.usage;
203
+ const parts = [
204
+ `in=${u.input_tokens ?? "?"}`,
205
+ `out=${u.output_tokens ?? "?"}`,
206
+ `cache_read=${u.cache_read_input_tokens ?? 0}`,
207
+ `cache_write=${u.cache_creation_input_tokens ?? 0}`
208
+ ];
209
+ lines.push(`Tokens: ${parts.join(" · ")}`, "");
210
+ }
211
+ if (reasoning.findings && reasoning.findings.length > 0) {
212
+ lines.push("Findings:");
213
+ for (const finding of reasoning.findings) {
214
+ lines.push(
215
+ `- **${finding.severity.toUpperCase()} - ${finding.category}**: ${finding.reasoning}`,
216
+ ` Evidence: \`${finding.evidence}\``
217
+ );
218
+ }
219
+ lines.push("");
220
+ } else {
221
+ lines.push("Findings: none reported.", "");
222
+ }
223
+ if (reasoning.evidenceGaps && reasoning.evidenceGaps.length > 0) {
224
+ lines.push("Evidence gaps:");
225
+ for (const gap of reasoning.evidenceGaps) {
226
+ lines.push(`- ${gap}`);
227
+ }
228
+ }
229
+ return lines.join("\n");
230
+ }
231
+
232
+ try {
233
+ main().catch((error) => {
234
+ process.stderr.write(`pkgxray: ${error.message}\n`);
235
+ process.exitCode = 1;
236
+ });
237
+ } catch (error) {
238
+ process.stderr.write(`pkgxray: ${error.message}\n`);
239
+ process.exitCode = 1;
240
+ }