🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

pkgxray

Package Overview
Dependencies
Maintainers
1
Versions
28
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

pkgxray - npm Package Compare versions

Comparing version
1.0.4
to
1.0.5
+31
server.json
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.adamsjack711-ux/pkgxray",
"title": "pkgxray",
"description": "Pre-install security scans for npm packages, MCP servers, and AI agents with cited verdict evidence.",
"websiteUrl": "https://pkgxray.ca/",
"repository": {
"url": "https://github.com/adamsjack711-ux/pkgxray",
"source": "github",
"id": "1276320499"
},
"version": "1.0.5",
"packages": [
{
"registryType": "npm",
"registryBaseUrl": "https://registry.npmjs.org",
"identifier": "pkgxray",
"version": "1.0.5",
"runtimeHint": "npx",
"transport": {
"type": "stdio"
},
"packageArguments": [
{
"type": "positional",
"value": "mcp-server"
}
]
}
]
}
+45
-13

@@ -15,2 +15,3 @@ #!/usr/bin/env node

const cfg = require("../src/config");
const { version: PACKAGE_VERSION } = require("../package.json");

@@ -31,16 +32,9 @@ // Map a config-adjusted verdict (safe|review|block, from applyConfig, which has

[
"Usage:",
" pkgxray < evidence.json",
" pkgxray --format json < evidence.json",
" pkgxray --file evidence.json --format markdown",
"Usage: pkgxray <command> [options]",
"",
"Common commands — vet before you install or connect:",
" pkgxray guard <npm-package|npm:name@version|github:owner/repo[#ref]|./path> [--promote-to dir] [--no-source-scan] [--deps]",
" # vet a package before install (static; no package code runs).",
" # --deps also OSV-scans the package's DIRECT dependencies (transitive worm entry point)",
" pkgxray canary <ref> --yes-run-untrusted-code [--timeout ms] [--keep-sandbox] [--require-sandbox] # OPT-IN: run install",
" # scripts in a decoy-credential sandbox behind a capture proxy; confirms exfil behaviorally (cannot clear a pkg).",
" # --require-sandbox fails closed without an OS sandbox (bwrap/sandbox-exec). See docs/canary-threat-model.md",
" pkgxray audit <package-lock.json|yarn.lock|pnpm-lock.yaml|package.json> # batch OSV scan of every dep",
" pkgxray triage <lockfile> [--include-safe] [--auto allow|block] # interactive allow/block walkthrough",
" pkgxray triage --resume # resume interrupted triage",
" pkgxray recheck <lockfile> [--verbose] [--no-write] # re-evaluate pinned deps; diff verdict vs. stored baseline",
" [--no-version-drift] [--fail-on-available-updates] # + pre-vet newer versions (informational unless --fail-on-...)",
" pkgxray mcp [flags] <https-url | command [args...]> # enumerate an MCP server's tool manifest (read-only handshake)",

@@ -50,2 +44,18 @@ " [--package <ref>] [--no-package-scan] [--force] # package-scan-first: guard the ref BEFORE connecting; block halts",

" [--pin] [--recheck] [--lock <path>] # pin the approved manifest / diff live manifest vs. the pin (rug-pull)",
" pkgxray recheck <lockfile> [--verbose] [--no-write] # re-evaluate pinned deps; diff verdict vs. stored baseline",
" [--no-version-drift] [--fail-on-available-updates] # + pre-vet newer versions (informational unless --fail-on-...)",
"",
" Exit codes: 0 safe/allow · 2 block · 3 review.",
"",
"Evidence in / out (no acquisition, just render a verdict):",
" pkgxray < evidence.json",
" pkgxray --format json < evidence.json",
" pkgxray --file evidence.json --format markdown",
"",
"Advanced:",
" pkgxray canary <ref> --yes-run-untrusted-code [--timeout ms] [--keep-sandbox] [--require-sandbox] [--no-import-phase]",
" # OPT-IN: EXECUTES untrusted package code (install + import) inside the sandbox — the one path that",
" # runs the package. Decoy-credential HOME behind a capture proxy that never forwards egress; confirms",
" # exfil behaviorally (cannot clear a pkg). Requires --yes-run-untrusted-code (or PKGXRAY_ALLOW_EXECUTION=1).",
" # --require-sandbox fails closed without an OS sandbox (bwrap/sandbox-exec). See docs/canary-threat-model.md",
" pkgxray mcp-proxy [flags] [--] <command [args...]> # run a stdio MCP server behind a per-call runtime gate: every tools/call",

@@ -55,4 +65,8 @@ " [--policy strict|balanced|permissive] # is checked in-memory (µs), the manifest is re-audited on every",

" [--no-scan-results] [--timing] # and tool RESULTS are scanned for injection (use in host config)",
" pkgxray triage <lockfile> [--include-safe] [--auto allow|block] # interactive allow/block walkthrough",
" pkgxray triage --resume # resume interrupted triage",
" pkgxray mcp-server # run pkgxray ITSELF as a stdio MCP server (for MCP hosts /",
" # the MCP Registry entry: `npx -y pkgxray mcp-server`) # exposes audit/guard/lockfile tools. cf. `mcp` above, which audits.",
" pkgxray serve-mcp # alias for mcp-server (run the local stdio MCP server)",
" pkgxray --version",
"",

@@ -68,3 +82,9 @@ "Evidence JSON fields:",

const options = { command: "audit", format: "markdown", file: null };
if (argv[0] === "guard") {
if (argv[0] === "--version" || argv[0] === "-V") {
options.command = "version";
argv = [];
} else if (argv[0] === "serve-mcp") {
options.command = "serveMcp";
argv = argv.slice(1);
} else if (argv[0] === "guard") {
options.command = "guard";

@@ -210,2 +230,4 @@ options.reference = argv[1];

options.requireSandbox = true;
} else if (arg === "--no-import-phase") {
options.importPhase = false;
} else if (arg === "--timeout") {

@@ -277,2 +299,10 @@ options.timeoutMs = Number(argv[++i]);

const options = parseArgs(process.argv.slice(2));
if (options.command === "version") {
process.stdout.write(`${PACKAGE_VERSION}\n`);
return;
}
if (options.command === "serveMcp") {
require("./mcp-server").attachStdin();
return;
}
if (options.help) {

@@ -394,3 +424,4 @@ printUsage();

keepSandbox: options.keepSandbox,
requireSandbox: options.requireSandbox
requireSandbox: options.requireSandbox,
importPhase: options.importPhase
});

@@ -661,2 +692,3 @@ if (options.format === "json") {

`Isolation: ${sanitizeForTerminal(behavioral.isolation)} · run ${sanitizeForTerminal(behavioral.runId)}`,
`Phases detonated: install${behavioral.executed && behavioral.executed.importPhase && behavioral.executed.importPhase.attempted ? " + import" : " only"}`,
""

@@ -663,0 +695,0 @@ ];

@@ -5,2 +5,4 @@ #!/usr/bin/env node

const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const { auditEvidence, renderMarkdown } = require("../src/auditor");

@@ -66,4 +68,28 @@ const { guardExtension, decisionForReport } = require("../src/quarantine");

const HOME_DIR = require("node:os").homedir();
const HOME_DIR = os.homedir();
const ALLOWED_ROOTS_ENV = "PKGXRAY_MCP_ALLOWED_ROOTS";
// Filesystem-capable MCP tools are limited by the operator, never by a tool
// argument an LLM can set. The default is the server's startup directory.
// Multiple roots use the platform path delimiter (`:` on Unix, `;` on Windows).
function loadAllowedRoots() {
const configured = process.env[ALLOWED_ROOTS_ENV];
const values = configured === undefined
? [process.cwd()]
: configured.split(path.delimiter).filter(Boolean);
const roots = [];
for (const value of values) {
try {
roots.push(fs.realpathSync(path.resolve(value)));
} catch {
process.stderr.write(
`pkgxray: ignoring unreadable ${ALLOWED_ROOTS_ENV} entry\n`
);
}
}
return [...new Set(roots)];
}
const MCP_ALLOWED_ROOTS = loadAllowedRoots();
function send(message) {

@@ -80,4 +106,11 @@ process.stdout.write(`${JSON.stringify(message)}\n`);

name: AUDIT_TOOL_NAME,
title: "Audit supplied package evidence",
description:
"Audit evidence for an AI coding-agent extension, Codex plugin, Claude Code extension, or MCP server and return a conservative supply-chain security verdict. Pure static analysis — accepts caller-supplied npm metadata, GitHub metadata, source files, vulnerability list, and optional npm provenance attestation. Use when you already have evidence in hand; for live npm packages prefer guard_agent_extension_install.",
"Statically analyze caller-supplied package source and metadata. Returns a structured SAFE, REVIEW, or BLOCK report with cited findings; it does not read local files, install packages, or execute code. A SAFE result is defense in depth, not proof of harmlessness. For a live npm package, use guard_agent_extension_install.",
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false
},
inputSchema: {

@@ -138,4 +171,11 @@ type: "object",

name: GUARD_TOOL_NAME,
title: "Guard a package before installation",
description:
"Stage an agent extension or npm package in a local quarantine directory, audit it without installing or running it, and optionally promote it if policy allows. Performs OSV vuln pre-check, downloads the tarball, runs static heuristics, cross-checks GitHub metadata, and automatically pulls the npm provenance attestation. Use this for a single live package; for a whole project use audit_lockfile_supply_chain.",
"Fetch and stage one package reference (pin an exact version for reproducibility), then return a structured SAFE, REVIEW, or BLOCK report without running lifecycle scripts or package code. It performs network requests to npm, OSV, and optionally GitHub. promoteTo writes files only after policy allows; force may replace an existing destination. Local references and caller-supplied paths are confined to operator-approved roots.",
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
openWorldHint: true
},
inputSchema: {

@@ -148,7 +188,7 @@ type: "object",

description:
"Extension reference: npm package, npm:name@version, file:path, or local directory path."
"Exact package reference, preferably npm:name@version. Local file:path or directory references must resolve under an operator-approved root."
},
quarantineRoot: {
type: "string",
description: "Optional quarantine root. Defaults to the OS temp directory."
description: "Optional staging root under an operator-approved filesystem root. Omit to use an internally selected OS temporary directory."
},

@@ -158,3 +198,3 @@ promoteTo: {

description:
"Optional destination directory. The staged package is copied here only when policy allows."
"Optional destination under an operator-approved root. Files are copied only when policy allows; force can replace an existing destination."
},

@@ -209,4 +249,11 @@ policy: {

name: LOCKFILE_AUDIT_TOOL_NAME,
title: "Audit a dependency manifest",
description:
"Batch-scan every dependency in a package-lock.json, yarn.lock, pnpm-lock.yaml, or package.json against OSV. Returns one decision (safe / review / block) per unique name@version. Pre-existing .pkgxray.lock triage decisions next to the lockfile are honored. Use this to audit a project's full dependency tree in one shot.",
"Read a dependency manifest under an operator-approved root and query OSV for each resolved dependency. Returns structured SAFE, REVIEW, or BLOCK decisions and honors a sibling .pkgxray.lock; it does not install dependencies or execute package code. deep/deepAll add npm and GitHub network scans.",
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true
},
inputSchema: {

@@ -219,3 +266,3 @@ type: "object",

description:
"Absolute or relative path to a package-lock.json, npm-shrinkwrap.json, yarn.lock, pnpm-lock.yaml, or package.json. Must exist."
"Path to a package-lock.json, npm-shrinkwrap.json, yarn.lock, pnpm-lock.yaml, or package.json under an operator-approved root. Must be a readable regular file."
},

@@ -252,4 +299,11 @@ deep: {

name: LOCKFILE_TRIAGE_TOOL_NAME,
title: "Record dependency triage decisions",
description:
"Non-interactive triage of a lockfile — auto-mark every flagged dep as allow or block, persisted to a sibling .pkgxray.lock next to the lockfile. Subsequent audit_lockfile_supply_chain runs respect those decisions. Required for MCP because interactive TTY input is not available; choose mode='block' to record current OSV findings as suppressions or mode='allow' to accept them.",
"Read a manifest under an operator-approved root and write bulk allow or block decisions to its sibling .pkgxray.lock. This changes future audit policy for every selected dependency: auto=allow suppresses those findings, while auto=block preserves rejection. MCP has no interactive confirmation, so review the target and mode before calling.",
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
openWorldHint: true
},
inputSchema: {

@@ -262,3 +316,3 @@ type: "object",

description:
"Absolute or relative path to a package-lock.json, npm-shrinkwrap.json, yarn.lock, pnpm-lock.yaml, or package.json. Must exist."
"Path to a package-lock.json, npm-shrinkwrap.json, yarn.lock, pnpm-lock.yaml, or package.json under an operator-approved root. A sibling .pkgxray.lock will be written."
},

@@ -320,2 +374,49 @@ auto: {

function localReferencePath(reference) {
let value = reference.startsWith("file:") ? reference.slice(5) : reference;
if (value === "~") value = HOME_DIR;
else if (value.startsWith("~/")) value = path.join(HOME_DIR, value.slice(2));
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function isWithinAllowedRoot(candidate) {
return MCP_ALLOWED_ROOTS.some((root) => {
const relative = path.relative(root, candidate);
return relative === "" ||
(relative !== ".." &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative));
});
}
// Resolve symlinks in the existing prefix. This also handles destinations that
// do not exist yet without letting a symlinked parent escape an approved root.
function canonicalPath(candidate, mustExist) {
const absolute = path.resolve(candidate);
if (mustExist) return fs.realpathSync(absolute);
let existing = absolute;
while (!fs.existsSync(existing)) {
const parent = path.dirname(existing);
if (parent === existing) throw new Error("no readable parent");
existing = parent;
}
const resolvedParent = fs.realpathSync(existing);
return path.resolve(resolvedParent, path.relative(existing, absolute));
}
function resolveOperatorPath(candidate, { mustExist = true } = {}) {
try {
const resolved = canonicalPath(candidate, mustExist);
if (!isWithinAllowedRoot(resolved)) return null;
return resolved;
} catch {
return null;
}
}
// SECURITY: error messages from auditor / quarantine paths can include

@@ -352,5 +453,12 @@ // absolute filesystem paths. The MCP reply goes back to a possibly-hostile

}
if (isLocalReference(args.reference) && args.allowLocalReferences !== true) {
return "local-path references are disabled over MCP — set allowLocalReferences:true to opt in (intended for trusted CLI bridges only)";
if (args.allowLocalReferences !== undefined) {
return "allowLocalReferences is not supported; only the server operator can grant filesystem roots";
}
if (isLocalReference(args.reference)) {
const resolved = resolveOperatorPath(localReferencePath(args.reference));
if (!resolved) {
return "local reference is unreadable or outside the operator-approved filesystem roots";
}
args.reference = resolved;
}
for (const k of ["quarantineRoot", "promoteTo"]) {

@@ -360,4 +468,11 @@ if (args[k] !== undefined && (typeof args[k] !== "string" || args[k].includes("\0"))) {

}
if (args[k] !== undefined) {
const resolved = resolveOperatorPath(args[k], { mustExist: false });
if (!resolved) {
return `${k} is outside the operator-approved filesystem roots`;
}
args[k] = resolved;
}
}
for (const k of ["sourceScan", "vulnerabilityCheck", "githubMetadata", "githubDiff", "force", "deep", "allowLocalReferences"]) {
for (const k of ["sourceScan", "vulnerabilityCheck", "githubMetadata", "githubDiff", "force", "deep"]) {
if (args[k] !== undefined && typeof args[k] !== "boolean") {

@@ -387,2 +502,7 @@ return `${k} must be a boolean`;

}
const resolved = resolveOperatorPath(args.lockfilePath);
if (!resolved) {
return "lockfilePath is unreadable or outside the operator-approved filesystem roots";
}
args.lockfilePath = resolved;
if (toolName === LOCKFILE_TRIAGE_TOOL_NAME) {

@@ -854,3 +974,5 @@ if (args.auto !== "allow" && args.auto !== "block") {

CONFIG_TOOL_KEY,
startStdioServer: attachStdin
startStdioServer: attachStdin,
attachStdin,
resolveOperatorPath
};
{
"name": "pkgxray",
"version": "1.0.4",
"version": "1.0.5",
"mcpName": "io.github.adamsjack711-ux/pkgxray",
"description": "Zero-dep local CLI and MCP server that scans npm packages for supply-chain risk. OSV vuln pre-check, sandboxed quarantine, tarball-integrity verification, calibrated static heuristics, GitHub provenance cross-check.",
"description": "pkgxray — pre-install security for npm packages, MCP servers, and AI agents. Zero-dependency local static analysis with cited SAFE, REVIEW, or BLOCK verdicts.",
"license": "MIT",

@@ -17,2 +17,3 @@ "author": "Jack Adams-Lovell",

"src/",
"server.json",
"README.md",

@@ -24,6 +25,9 @@ "LICENSE"

"test": "node --test",
"test:docs": "node --test ./test/docs-smoke.test.js",
"benchmark": "node ./benchmark/run.js",
"validate:website": "node ./website/stats/build.mjs && node ./website/validate.mjs",
"audit:evidence": "node ./bin/audit.js",
"mcp": "node ./bin/mcp-server.js",
"cache": "node ./bin/pkgxray-cache.js"
"cache": "node ./bin/pkgxray-cache.js",
"verify:netns": "node ./scripts/verify-netns-confinement.js"
},

@@ -34,3 +38,3 @@ "repository": {

},
"homepage": "https://github.com/adamsjack711-ux/pkgxray#readme",
"homepage": "https://pkgxray.ca/",
"bugs": {

@@ -40,5 +44,14 @@ "url": "https://github.com/adamsjack711-ux/pkgxray/issues"

"keywords": [
"ai-agent-security",
"mcp",
"mcp-security",
"model-context-protocol",
"security",
"npm-security",
"package-security",
"package-scanner",
"pre-install",
"static-analysis",
"supply-chain",
"supply-chain-security",
"npm-audit",

@@ -45,0 +58,0 @@ "agent-extension"

+97
-224
<div align="center">
# pkgxray
# pkgxray — pre-install security for npm packages, MCP servers, and AI agents
**Supply-chain security for AI agents, npm packages, and Model Context Protocol (MCP) servers.**
**Inspect an npm package or MCP server before you install or connect to it, and
get a deterministic, evidence-backed `SAFE`, `REVIEW`, or `BLOCK` verdict.**
Local, zero-dependency static analysis — normal scans never execute package code.
Analyze packages *before* you install them. Zero-dependency Node, runs
entirely on your machine, never executes untrusted code.
[![npm version](https://img.shields.io/npm/v/pkgxray)](https://www.npmjs.com/package/pkgxray)

@@ -15,247 +14,124 @@ [![tests](https://github.com/adamsjack711-ux/pkgxray/actions/workflows/pkgxray-test.yml/badge.svg)](https://github.com/adamsjack711-ux/pkgxray/actions/workflows/pkgxray-test.yml)

**Static analysis** · **Supply-chain intelligence** · **Prompt-injection detection** ·
**MCP security** · `SAFE` / `REVIEW` / `BLOCK`
<img src="docs/demo/hero.gif" alt="pkgxray guard clearing express@4.21.0 with a SAFE A+ verdict, then blocking a trojaned sample with a BLOCK F verdict and a HIGH credential-access finding" width="820">
<img src="docs/demo/hero.gif" alt="pkgxray guard clearing express@4.21.0 with a SAFE A+ verdict, then blocking a trojaned sample with a BLOCK F verdict and a HIGH credential-access finding citing the wallet-read and exfiltration code" width="820">
<sub>Real runs: `guard` clears `express@4.21.0`, then blocks a sample modeled on
the 2024 `@solana/web3.js` compromise. **[▶ 60-second walkthrough](#demo)**</sub>
the 2024 `@solana/web3.js` compromise.</sub>
</div>
## Why
AI coding assistants install packages and connect to MCP servers at machine
speed, often without a human reading the code. Sonatype identified **more than
454,600 new malicious open-source packages across monitored ecosystems in
2025**, over 99% of them on npm
([Sonatype](https://www.sonatype.com/state-of-the-software-supply-chain/2026/open-source-malware)).
`npm audit` asks *does this have a known CVE?*; pkgxray also asks *what does the
code actually do* — before anything installs.
## Quick start
**1. Scan a known-benign package** (no install of pkgxray needed):
```bash
npm install -g pkgxray # or zero-install: npx pkgxray …
pkgxray guard npm:express@4.21.0
npx --yes pkgxray@1.0.5 guard npm:express@4.21.0
```
It stages the tarball in quarantine and runs the static and supply-chain checks
— no `npm install`, no lifecycle scripts, no package code executed.
```text
Decision: **SAFE**
Grade: **A+** (99/100)
Decision: SAFE Grade: A+ (99/100)
No high- or medium-risk indicators were found in the provided evidence.
Notes:
- **INFO npm-vs-github-clean** — npm tarball matches the linked GitHub repo
at the published version. (15/16 files match GitHub @4.21.0)
```
<sub>Real output, abridged. A `BLOCK` verdict instead lists every finding with
the file and evidence that produced it.</sub>
**2. Read the verdict:**
Point it at a package, get a verdict with cited evidence — before a single
line of that package runs. `guard` stages the package in a sandboxed
quarantine, audits the staged copy, and only promotes it when policy allows.
It never runs `npm install`, lifecycle scripts, build steps, or package code.
| Verdict | Exit | Meaning |
|---|---:|---|
| `SAFE` | `0` | No high- or medium-risk indicators; default policy permits promotion. |
| `REVIEW` | `3` | Evidence is incomplete or a privileged capability needs human review. |
| `BLOCK` | `2` | High-severity cited evidence — reject or investigate. |
## Why pkgxray?
`SAFE` is not a proof that a package is harmless; static analysis cannot see a
payload downloaded only at runtime. See the [threat model](docs/threat-model.md).
AI coding assistants install packages and connect to MCP servers at machine
speed, often without a human ever reading the code — and the registry they
pull from is under industrial-scale attack: roughly **455,000 malicious npm
packages were published in 2025**, one every ~20 seconds by Q4
([Sonatype](https://www.sonatype.com/blog/open-source-malware-index-q4-2025-automation-overwhelms-ecosystems)).
Traditional antivirus inspects what *executes*; **pkgxray inspects what gets
*installed***.
**3. See a BLOCK on the supplied inert fixture:**
`npm audit` and OSV-Scanner answer an essential question — *does this package
have a known CVE?* — and pkgxray asks it too (via OSV, before anything
downloads). But a freshly trojaned package has no CVE yet, so pkgxray also
analyzes **trust**: what the code actually does, whether the published npm
artifact matches the tagged GitHub source, whether the provenance attestation
is consistent with the claimed repository, and whether the docs carry a
prompt-injection payload aimed at the agent reading them.
```bash
npx --yes pkgxray@1.0.5 --file examples/onboarding-malicious.json --format markdown
```
It is intentionally conservative: verdicts come from deterministic heuristics
(no LLM in the verdict path, so injected text can't steer them), only
citable evidence is reported, and the **zero-heuristic-false-block calibration
on the top-1000 most-downloaded packages** is
[regression-gated in CI](docs/benchmark.md). That claim is scoped to the
most-installed set — it is *not* a claim of zero false blocks on every package;
the newer MCP/agent-tooling ecosystem is over-blocked and being reconciled
per-case ([details](docs/benchmark.md#scope-of-the-claim-read-this-first)).
The fixture is inert source text modeling a split-string SSH-key read and
exfiltration — **it is never executed**. It returns `BLOCK` (exit `2`) with the
cited file and evidence.
## What it catches
**4. Add it to your workflow** — [rechecks & CI](docs/reference.md#monitoring-pkgxray-recheck),
[MCP](docs/mcp.md#the-pkgxray-mcp-server), [Hookshot install gate](examples/hookshot/).
| Threat | Coverage | How pkgxray sees it |
|---|:-:|---|
| Credential theft | ✅ | reads of `.ssh` / `.aws` / `.npmrc` / `.env` / keychains / wallets, incl. split-fragment paths (`".s"+"sh"`) |
| Prompt injection | ✅ | tiered detection in docs, comments, metadata; deterministic verdict path can't be steered |
| Unicode smuggling | ✅ | invisible tag-block characters + Trojan Source bidi / zero-width |
| Base64 payloads | ✅ | encoded envelopes in docs/comments; blobs decoded into computed-arg `eval` / `new Function` / `child_process` |
| Exfiltration & loaders | ✅ | cross-file correlation: stage-2 loaders, `curl \| sh`, `process.env` harvesting near a network sink, EtherHiding |
| Persistence | ✅ | writes to shell rc files, cron, launch agents |
| Obfuscation | ✅ | packed blob + computed-arg execution; minification alone is deliberately *not* flagged |
| Known CVEs | ✅ | OSV batch pre-check before download; never mutable by config |
| Trojaned updates / maintainer takeover | ✅ | `recheck` verdict-drift + version-drift monitoring |
| Artifact divergence | ✅ | published npm tarball diffed against the tagged GitHub source |
| MCP capability abuse | ✅ | capability-surface mismatch in the manifest audit (a `get_weather` that also takes a `command`) |
| Runtime tool drift | ✅ | `mcp-proxy` re-audits on `tools/list_changed`; pinned-manifest drift is denied |
| Dependency confusion / typosquats | ◑ | callback beacons, repo-mismatch and provenance-mismatch signals; no name-similarity heuristic |
> **Two execution models.** Default `guard` and `audit` scans are **static** —
> package code is never executed. Enumerating an MCP server may spawn it and
> `mcp-proxy` runs it behind a gate; the opt-in
> [`canary`](docs/canary-threat-model.md) is the one deliberate exception that
> *executes* the package in a sandbox to confirm behavior — it can confirm
> malice but never prove a package safe. Full boundary: [SECURITY.md](SECURITY.md#scope).
<sub>✅ detected · ◑ partial / indirect</sub>
## What it catches
**Known blind spot:** pkgxray reasons about bytes in the tarball. A package
that downloads its real payload *after* install can ship a clean tree —
pkgxray flags the capability when its shape is unambiguous, but pair it with
runtime sandboxing when that risk matters. Full analysis:
[docs/threat-model.md](docs/threat-model.md).
Credential theft (incl. split-fragment paths), prompt injection, Unicode
smuggling, base64 payloads and stage-2 loaders, exfiltration, persistence,
obfuscated computed-arg execution, known CVEs (via OSV, before download),
npm↔GitHub artifact divergence, trojaned updates (`recheck`), and MCP
capability-surface abuse. Verdicts come from deterministic heuristics — no LLM
in the verdict path, so injected text can't steer them. Full matrix and the
known download-later blind spot: [docs/threat-model.md](docs/threat-model.md).
## Beyond detection
- **Continuous monitoring** — [`pkgxray recheck`](docs/reference.md#monitoring-pkgxray-recheck)
diffs installed deps against a stored verdict baseline and pre-vets newer versions
- **MCP vetting** — `pkgxray mcp` audits a server's tool manifest before you
connect; `--pin` / `--recheck` catch the rug-pull; `pkgxray-mcp` gives any
agent the audit tools directly
- **Runtime gate** — [`pkgxray mcp-proxy`](docs/mcp.md#per-call-runtime-gate-pkgxray-mcp-proxy)
wraps a live MCP server on the wire: denied tools stripped, ~0.05 µs per-call
verdict, injection scan of tool results
- **Install gate** — a [hookshot](https://github.com/CorridorSecurity/hookshot)
hook runs `guard` on every package an agent tries to install, across Claude
Code, Cursor, Windsurf, Factory Droid, and Codex ([`examples/hookshot/`](examples/hookshot/))
- **Policy engine** — one `.pkgxray.json` read by every surface; tighten
freely, every loosening is printed; CVEs can never be allowed away; fail closed
- **Opt-in behavioral canary** — [`pkgxray canary`](docs/canary-threat-model.md)
runs lifecycle scripts in an OS sandbox with decoy credentials; it can
*confirm* malice, never *clear* a package
## Verdicts
| Verdict | Meaning | You should |
|---|---|---|
| 🟢 `SAFE` | No high- or medium-risk indicators. | Install. Only `safe` promotes out of quarantine by default. |
| 🟡 `REVIEW` | Incomplete evidence, or a privileged capability that needs a human. | Inspect the quarantined copy before promoting. |
| 🔴 `BLOCK` | High-severity, cited evidence. | Do not install. Every finding names the file and evidence. |
Exit codes are stable and CI-friendly: **`0`** safe/allow · **`2`** block ·
**`3`** review. The full signal-to-severity mapping is in the
[severity policy](docs/reference.md#severity-policy-what-lands-in-block--review--info).
## Usage
**Vet an npm package before installing**
```bash
pkgxray guard npm:some-package@1.2.3 [--format json]
pkgxray guard ./ext --promote-to ./approved/ext # local dir, promote if policy allows
pkgxray guard npm:some-package@1.2.3 [--format json] # vet a package before install
pkgxray mcp --package npm:some-mcp-server@1.4.2 npx some-mcp-server # vet an MCP server; --recheck catches the rug-pull
pkgxray audit package-lock.json [--deep] # also: yarn.lock, pnpm-lock.yaml, package.json
pkgxray recheck package-lock.json # scheduled: non-zero only on a regression
```
**Vet an MCP server before connecting** — full guide: [docs/mcp.md](docs/mcp.md)
Exit codes are stable and CI-friendly: **`0`** safe/allow · **`2`** block ·
**`3`** review.
```bash
pkgxray mcp --package npm:some-mcp-server@1.4.2 npx some-mcp-server
pkgxray mcp --recheck npx some-mcp-server # catch the rug-pull
```
## Integrations
**Enforce in CI/CD**
One engine behind every entry point. "Works with" means a documented setup
guide, not a vendor-endorsed integration.
```bash
pkgxray audit package-lock.json [--deep] # also: yarn.lock, pnpm-lock.yaml, package.json
npx pkgxray recheck package-lock.json # scheduled: exits non-zero only on a regression
```
| Where | What it does | Guide |
|---|---|---|
| Coding agents — Codex, Claude Code, Cursor, Windsurf | Gate installs and expose the audit tools to the agent | [coding-agents.md](docs/integrations/coding-agents.md) |
| MCP clients | Vet a server before connect; run pkgxray itself as an MCP server | [mcp.md](docs/mcp.md) |
| GitHub Actions / CI | Fail a build when a dependency crosses policy | [github-actions.md](docs/integrations/github-actions.md) |
| Install gate — Hookshot | Run `guard` on every package an agent tries to install | [examples/hookshot/](examples/hookshot/) |
| Runtime MCP gate | Proxy a live MCP server and gate every tool call | [`mcp-proxy`](docs/mcp.md#per-call-runtime-gate-pkgxray-mcp-proxy) |
| Dependency monitoring | Re-vet installed deps and pre-vet upgrades on a schedule | [`recheck`](docs/reference.md#monitoring-pkgxray-recheck) |
A ready-made GitHub Actions workflow and the self-hostable cache server
(`PKGXRAY_CACHE_URL`) are in the [reference](docs/reference.md#monitoring-pkgxray-recheck).
**Guard AI coding agents**
pkgxray is published on the [MCP Registry](https://registry.modelcontextprotocol.io)
as `io.github.adamsjack711-ux/pkgxray`. Add it to any MCP client — locally
installed (`pkgxray-mcp`) or zero-install via `npx`:
```json
{ "mcpServers": { "pkgxray": { "command": "pkgxray-mcp" } } }
```
```json
{ "mcpServers": { "pkgxray": { "command": "npx", "args": ["-y", "pkgxray", "mcp-server"] } } }
```
Gate installs with the [hookshot integration](examples/hookshot/) and wrap MCP
servers with [`pkgxray mcp-proxy`](docs/mcp.md#per-call-runtime-gate-pkgxray-mcp-proxy).
## Configuration
One optional `.pkgxray.json`, read by every surface. Zero config means
maximum strictness.
```jsonc
{
"policy": "safe-only", // or "allow-review" (a loosening — warns)
"failOn": "review", // CI exit threshold
"scanErrorPolicy": "fail-closed", // a scan that errors → review, never safe
"allow": [
{ "pkg": "left-pad@1.3.0", "sha256": "e0b0…",
"reason": "reviewed 2026-07", "expires": "2026-10-01" }
]
}
```
Precedence, `mute` / `mcp` blocks, and enforced invariants:
One optional `.pkgxray.json`, read by every surface; zero config means maximum
strictness. CVEs can never be allowed away, every loosening is printed, and a
scan that errors fails closed to `review`. Schema and invariants:
[docs/configuration.md](docs/configuration.md) ·
[`.pkgxray.example.json`](.pkgxray.example.json)
[`.pkgxray.example.json`](.pkgxray.example.json).
## Demo
## Evidence
The 60-second walkthrough — the SAFE run, the blocked trojan with its exit
code, then a lockfile audit:
The **zero-heuristic-false-block calibration on the top-1000 most-downloaded
packages** is regression-gated in CI ([scope & methodology](docs/benchmark.md)),
and the published calibration runs live at <https://pkgxray.ca/stats>. That claim
is scoped to the most-installed set — not a claim of zero false blocks on every
package.
https://github.com/user-attachments/assets/b5a323b1-a9ec-4676-9601-1b284df81b6b
## How it compares
<sub>All captures are real runs — reproduction steps in
[`docs/screenshots/`](docs/screenshots/README.md), which also shows the
MCP proxy, hookshot install gate, and browser extension in action.</sub>
Run pkgxray *alongside* `npm audit` / OSV-Scanner, not instead of them. The
full behavioral-vetting comparison (Socket.dev, OpenSSF Package Analysis, Cisco
MCP Scanner) is in [docs/comparison.md](docs/comparison.md).
## Comparison
Designed to run *alongside* `npm audit` and
[OSV-Scanner](https://google.github.io/osv-scanner/), not replace them — they
match dependencies against known vulnerabilities; pkgxray adds the layers
they don't attempt:
| Capability | npm audit | OSV-Scanner | pkgxray |
|---|:-:|:-:|:-:|
| Known-CVE lookup | ✅ | ✅ | ✅ (OSV, blocks before download) |
| Lockfile / project scanning | ✅ | ✅ | ✅ |
| Registry signature / provenance verification | ✅ (`npm audit signatures`) | — | ✅ (sigstore/SLSA + repo cross-check) |
| Static analysis of package code behavior | — | — | ✅ |
| Prompt-injection & Unicode-smuggling detection | — | — | ✅ |
| npm ↔ GitHub artifact divergence | — | — | ✅ |
| Pre-install quarantine of a single package | — | — | ✅ |
| Verdict-drift monitoring vs. a stored baseline | — | — | ✅ |
| MCP server vetting & per-call runtime gating | — | — | ✅ |
<sub>Scoped to npm supply-chain vetting, per each tool's public docs.
OSV-Scanner covers many ecosystems beyond npm, which pkgxray does not.</sub>
## Architecture
<img src="docs/architecture.svg" alt="pkgxray architecture: inputs flow through the acquisition, quarantine, static-analysis and policy engines to a SAFE / REVIEW / BLOCK verdict" width="820">
Acquisition (OSV pre-check → fetch) → sandboxed quarantine → static analysis →
policy → verdict. The same engine backs every surface: CLI, MCP server,
runtime proxy, install hook, browser extension, and CI cache server.
Principles: never execute untrusted code · citable evidence only ·
minimize false positives · fail closed · zero runtime dependencies.
Details: [docs/architecture.md](docs/architecture.md) ·
[docs/design.md](docs/design.md)
## Performance
- **Local static analysis: ~25 ms** — a full guard of `express` is ~1.3–1.5 s
cold-cache, almost all network round-trips (Apple M1, Node 26)
- **Known-vulnerable packages block at the OSV pre-check**, before download
- **Calibration** (precision, recall, the 0-heuristic-false-block gate on the
top-1000 most-downloaded — [scope](docs/benchmark.md#scope-of-the-claim-read-this-first))
is measured by a committed [benchmark corpus](benchmark/) that fails CI when it regresses
Full numbers: [docs/reference.md#performance](docs/reference.md#performance) ·
methodology: [docs/benchmark.md](docs/benchmark.md)
## Documentation

@@ -265,13 +141,11 @@

|---|---|
| [architecture.md](docs/architecture.md) | Pipeline, surfaces, repo layout |
| [architecture.md](docs/architecture.md) · [design.md](docs/design.md) | Pipeline, surfaces, principles |
| [threat-model.md](docs/threat-model.md) | Scope, blind spots, prompt-injection stance |
| [mcp.md](docs/mcp.md) | MCP server, connect-time vetting, runtime proxy |
| [configuration.md](docs/configuration.md) | `.pkgxray.json` schema and invariants |
| [reference.md](docs/reference.md) | Severity policy, `recheck`, JSON output, cache server |
| [benchmark.md](docs/benchmark.md) | Calibration benchmark & real-world validation |
| [compatibility.md](docs/compatibility.md) | The 1.0 compatibility contract |
| [json-schema.md](docs/json-schema.md) | Full `--format json` schema |
| [mcp.md](docs/mcp.md) · [mcp-registry.md](docs/mcp-registry.md) | MCP vetting, runtime proxy, registry entry |
| [canary-threat-model.md](docs/canary-threat-model.md) | The opt-in behavioral canary |
| [configuration.md](docs/configuration.md) · [reference.md](docs/reference.md) | `.pkgxray.json`, severity policy, `recheck`, cache server |
| [benchmark.md](docs/benchmark.md) · [comparison.md](docs/comparison.md) | Calibration and how it compares |
| [compatibility.md](docs/compatibility.md) · [json-schema.md](docs/json-schema.md) | 1.0 contract, `--format json` schema |
Start at the [documentation index](docs/README.md). Longer-term plans:
[adoption playbook](docs/adoption.md) and GitHub issues.
Start at the [documentation index](docs/README.md).

@@ -283,11 +157,10 @@ ## Development

npm run benchmark # calibration corpus: precision/recall + 0-false-block gate
npm run build:browser # build the MV3 browser extension
npm run validate:website # regenerate + validate the calibration pages
```
## Security & license
Contributions welcome — read [CONTRIBUTING.md](CONTRIBUTING.md) and the
[Code of Conduct](CODE_OF_CONDUCT.md). Report vulnerabilities per
[SECURITY.md](SECURITY.md). Releases publish to npm with provenance, gated on
tests, the calibration benchmark, and pkgxray's own supply-chain guard.
Releases are published to npm with provenance (SLSA attestation), gated on the
test suite, the calibration benchmark, and pkgxray's own supply-chain guard.
To report a vulnerability in pkgxray itself, see [SECURITY.md](SECURITY.md).
[MIT](LICENSE)

@@ -45,4 +45,11 @@ "use strict";

if (!entry || !entry.version) continue;
// Real registry deps are installed under a `node_modules/` path. A bare
// key like "packages/foo" or "apps/bar" is one of the project's OWN
// workspace packages (first-party source, not supply chain) — skip it
// like the root. This also avoids indexing an empty segment list, which
// crashed on `.replace` of undefined when such an entry carried no `name`
// (e.g. a workspace app whose package.json omits "name").
// key looks like "node_modules/foo" or "node_modules/foo/node_modules/bar"
const segments = key.split("node_modules/").slice(1);
if (segments.length === 0) continue; // workspace/local package, not a registry dep
const name = entry.name || segments[segments.length - 1].replace(/\/$/, "");

@@ -49,0 +56,0 @@ add(deps, name, entry.version, [key]);

@@ -146,17 +146,14 @@ "use strict";

// Loopback HTTP/HTTPS capture proxy. Plaintext requests are read in full (URL,
// headers, body) and scanned for canary tokens; HTTPS CONNECTs record the
// target host only. NOTHING is forwarded — captured egress never leaves the
// machine, so the decoy tokens are safe even when the payload "sends" them.
function startCaptureProxy(tokenSet) {
const hits = [];
// Track live sockets so teardown can never hang. server.close()'s callback
// only fires once EVERY connection has ended; a payload that opens a
// keep-alive socket to the proxy and never closes it would otherwise wedge
// the run forever in the teardown await. We force-destroy any lingering
// sockets on a short timer so close() is guaranteed to complete.
// The capture HTTP/HTTPS server, factored out so the in-process proxy and the
// in-netns file-capture proxy (below) share ONE implementation of request
// parsing, token scanning, and CONNECT refusal. Plaintext requests are read in
// full (URL, headers, body) and scanned for canary tokens; HTTPS CONNECTs
// record the target host only. NOTHING is forwarded — captured egress never
// leaves the machine. `onHit(hit)` fires for every recorded hit; the socket set
// it returns lets the caller force-destroy lingering connections on teardown.
function createCaptureServer(tokenSet, onHit) {
const sockets = new Set();
// Precompute each token's encoded variants ONCE — they depend only on the
// token, never the request — so the capture hot path (every HTTP request and
// every CONNECT) doesn't re-encode all decoys on every hit.
// token, never the request — so the capture hot path doesn't re-encode all
// decoys on every hit.
const tokenIndex = Array.from(tokenSet, (token) => ({ token, variants: tokenVariants(token) }));

@@ -170,2 +167,4 @@ const scan = (haystack) => {

};
const record = (hit) => { try { onHit(hit); } catch { /* onHit must never break capture */ } };
const server = http.createServer((req, res) => {

@@ -196,3 +195,3 @@ const chunks = [];

}
hits.push({ transport: "http", method: req.method, host, url: req.url, tokensSeen, bodyBytes, truncated });
record({ transport: "http", method: req.method, host, url: req.url, tokensSeen, bodyBytes, truncated });
res.writeHead(204);

@@ -214,3 +213,3 @@ res.end();

const authTokens = scan(host);
hits.push({ transport: "https-connect", method: "CONNECT", host, url: `https://${host}`, tokensSeen: authTokens, bodyBytes: 0 });
record({ transport: "https-connect", method: "CONNECT", host, url: `https://${host}`, tokensSeen: authTokens, bodyBytes: 0 });
// No MITM: record the intended destination and refuse the tunnel so

@@ -227,29 +226,92 @@ // nothing actually egresses.

return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => {
const { port } = server.address();
resolve({
port,
hits,
close: () =>
new Promise((r) => {
let done = false;
const finish = () => { if (!done) { done = true; r(); } };
// After a short grace, force-destroy any socket still open so
// server.close() can fire its callback. Bounded so a lingering
// keep-alive connection can't wedge teardown.
const destroyTimer = setTimeout(() => {
for (const s of sockets) { try { s.destroy(); } catch { /* noop */ } }
}, 250);
if (destroyTimer.unref) destroyTimer.unref();
// Absolute backstop: resolve regardless if close() never calls back.
const hardTimer = setTimeout(finish, 1500);
if (hardTimer.unref) hardTimer.unref();
server.close(() => { clearTimeout(destroyTimer); clearTimeout(hardTimer); finish(); });
})
});
// Bounded teardown: force-destroy any socket still open so server.close()'s
// callback (which only fires once EVERY connection ends) can't be wedged by a
// payload holding a keep-alive socket open.
const close = () =>
new Promise((r) => {
let done = false;
const finish = () => { if (!done) { done = true; r(); } };
const destroyTimer = setTimeout(() => {
for (const s of sockets) { try { s.destroy(); } catch { /* noop */ } }
}, 250);
if (destroyTimer.unref) destroyTimer.unref();
const hardTimer = setTimeout(finish, 1500);
if (hardTimer.unref) hardTimer.unref();
server.close(() => { clearTimeout(destroyTimer); clearTimeout(hardTimer); finish(); });
});
return { server, close };
}
// Loopback HTTP/HTTPS capture proxy in the parent process. Listens on a TCP
// port (used by the env-only, sandbox-exec, and shared-net bwrap tiers, where
// the sandbox reaches the host loopback directly) and — when `unixPath` is
// given — ALSO on a Unix socket, which the netns tier's in-sandbox forwarder
// connects to across the network-namespace boundary (a path-based unix socket
// is filesystem-scoped, not netns-scoped, so it works where TCP loopback does
// not). Both listeners feed one shared `hits` array.
function startCaptureProxy(tokenSet, options = {}) {
const hits = [];
const tcp = createCaptureServer(tokenSet, (hit) => hits.push(hit));
const unix = options.unixPath ? createCaptureServer(tokenSet, (hit) => hits.push(hit)) : null;
return new Promise((resolve, reject) => {
tcp.server.listen(0, "127.0.0.1", () => {
const { port } = tcp.server.address();
const finish = () =>
resolve({
port,
unixPath: options.unixPath || null,
hits,
close: async () => { await tcp.close(); if (unix) await unix.close(); }
});
if (!unix) return finish();
unix.server.on("error", reject);
unix.server.listen(options.unixPath, finish);
});
});
}
// TCP→Unix forwarder, run INSIDE the sandbox's network namespace. It listens on
// loopback (127.0.0.1:innerPort) and pipes each connection, byte-for-byte, to
// the parent capture proxy's Unix socket. Because it is a dumb byte pipe, both
// plaintext HTTP and HTTPS CONNECT frames reach the capture proxy intact. The
// payload's HTTP(S)_PROXY points at innerPort; anything that bypasses the proxy
// and dials a real IP directly has no route out of the fresh netns and is
// refused by the kernel. Returns the server so a self-test can close it.
function startTcpToUnixForwarder(innerPort, sockPath, onReady) {
const net = require("node:net");
const server = net.createServer((down) => {
const up = net.connect(sockPath);
down.on("error", () => up.destroy());
up.on("error", () => down.destroy());
down.pipe(up);
up.pipe(down);
});
server.on("error", () => { /* surfaced to caller via not-ready */ });
server.listen(innerPort, "127.0.0.1", () => { if (onReady) onReady(); });
return server;
}
// Build the bootstrap shell that runs as the bwrap `--unshare-net` child: bring
// up loopback (a fresh netns starts with lo DOWN), start the forwarder and wait
// until it is listening, run the payload, then hold the forwarder open for the
// egress grace window so a delayed beacon is still forwarded before teardown.
// Pure string construction so it is unit-testable without a sandbox.
function buildNetnsBootstrap({ nodeBin, selfPath, innerPort, sockPath, readyFile, ipBin, graceMs, payloadCmd }) {
const q = shellQuote;
const graceS = Math.max(0, Math.ceil((Number(graceMs) || 0) / 1000));
return [
`${q(ipBin)} link set lo up 2>/dev/null`,
`${q(nodeBin)} ${q(selfPath)} __forwarder ${innerPort} ${q(sockPath)} ${q(readyFile)} &`,
`__fw=$!`,
// Wait (bounded ~5s) for the forwarder to signal ready, then run the payload.
`__i=0; while [ ! -f ${q(readyFile)} ] && [ $__i -lt 100 ]; do __i=$((__i+1)); sleep 0.05; done`,
payloadCmd,
`__status=$?`,
graceS > 0 ? `sleep ${graceS}` : `:`,
`kill $__fw 2>/dev/null`,
`exit $__status`
].join("\n");
}
// Escape a path for safe interpolation into an SBPL string literal so a path

@@ -298,38 +360,8 @@ // containing a quote or backslash can't break out of / corrupt the sandbox

// egress — so netConfined is false here; raw-socket egress can still leave).
// A tmpfs is stacked over the REAL home dir so the payload cannot read the
// operator's actual ~/.aws, ~/.npmrc, ~/.ssh, etc. through the ro-bind of /
// (HOME itself is repointed at the decoy tree via env). --die-with-parent
// guarantees no sandbox process outlives pkgxray, and --new-session detaches
// the controlling terminal (blocks TIOCSTI input-injection). All flags are
// long-standing.
// Mask the real home ONLY when it's a normal directory that does not contain
// the sandbox root. Guard the edge where os.homedir() is the filesystem root
// ("/", e.g. a misconfigured root account or a minimal container): `--tmpfs /`
// would shadow the ro-bind of everything — including the staged package — so
// the payload couldn't read its own package.json and the run would falsely
// read "not-observed" without executing anything.
const realHome = os.homedir();
const resolvedHome = realHome ? path.resolve(realHome) : "";
const resolvedRoot = path.resolve(sandboxRoot);
const homeIsFsRoot = resolvedHome !== "" && resolvedHome === path.parse(resolvedHome).root;
const sandboxUnderHome =
resolvedHome !== "" && (resolvedRoot === resolvedHome || resolvedRoot.startsWith(resolvedHome + path.sep));
const maskRealHome = resolvedHome !== "" && !homeIsFsRoot && !sandboxUnderHome ? ["--tmpfs", realHome] : [];
// The `bwrap+netns` tier (detectNetnsConfinement) upgrades this to
// netConfined:true when an unprivileged network namespace can be stood up.
return {
level: "bwrap",
netConfined: false,
wrap: (argv) => [
"bwrap",
"--ro-bind", "/", "/",
...maskRealHome,
"--bind", sandboxRoot, sandboxRoot,
"--dev", "/dev",
"--proc", "/proc",
"--unshare-pid",
"--unshare-ipc",
"--unshare-uts",
"--die-with-parent",
"--new-session",
...argv
]
wrap: (argv) => ["bwrap", ...bwrapBaseArgs(sandboxRoot), ...argv]
};

@@ -340,3 +372,191 @@ }

// The default (real) runner: execute the package's declared install lifecycle
// The bwrap arguments shared by the shared-net and netns tiers. A tmpfs is
// stacked over the REAL home dir so the payload cannot read the operator's
// actual ~/.aws, ~/.npmrc, ~/.ssh, etc. through the ro-bind of / (HOME itself
// is repointed at the decoy tree via env). --die-with-parent guarantees no
// sandbox process outlives pkgxray; --new-session detaches the controlling
// terminal (blocks TIOCSTI input-injection). All flags are long-standing.
// Mask the real home ONLY when it's a normal directory that does not contain
// the sandbox root — the `--tmpfs /` edge (os.homedir() === "/") would shadow
// the ro-bind of everything, including the staged package, making the payload
// unable to read its own package.json (a false "not-observed" without executing).
function bwrapBaseArgs(sandboxRoot) {
const realHome = os.homedir();
const resolvedHome = realHome ? path.resolve(realHome) : "";
const resolvedRoot = path.resolve(sandboxRoot);
const homeIsFsRoot = resolvedHome !== "" && resolvedHome === path.parse(resolvedHome).root;
const sandboxUnderHome =
resolvedHome !== "" && (resolvedRoot === resolvedHome || resolvedRoot.startsWith(resolvedHome + path.sep));
const maskRealHome = resolvedHome !== "" && !homeIsFsRoot && !sandboxUnderHome ? ["--tmpfs", realHome] : [];
return [
"--ro-bind", "/", "/",
...maskRealHome,
"--bind", sandboxRoot, sandboxRoot,
"--dev", "/dev",
"--proc", "/proc",
"--unshare-pid",
"--unshare-ipc",
"--unshare-uts",
"--die-with-parent",
"--new-session"
];
}
function hasCmd(cmd) {
try {
return spawnSync("sh", ["-c", `command -v ${cmd}`], { encoding: "utf8" }).status === 0;
} catch {
return false;
}
}
// Resolve the loopback-up tool. A fresh network namespace starts with `lo`
// DOWN, so a loopback-only egress path needs `ip link set lo up`. iproute2's
// `ip` is the only dependency; absent it, the netns tier is unavailable and we
// fall back to shared-net bwrap. (busybox `ifconfig` is intentionally not used —
// keeping one well-known tool keeps the self-test's guarantee legible.)
function resolveIpBin() {
return hasCmd("ip") ? "ip" : null;
}
// Module-level cache: the netns capability is a property of the HOST (kernel +
// tooling), not of a given run, so the self-test runs at most once per process.
// undefined = not yet probed; false = unavailable; true = self-test passed.
let _netnsCapable;
const NETNS_INNER_PORT = 18080; // arbitrary; inside an isolated netns, no conflict
// Decide whether real network-namespace confinement is available, and if so
// return a per-run descriptor. Engages ONLY when bwrap + ip are present AND a
// live self-test proves, using the exact same machinery a real run uses, that
// (a) proxied egress is still captured and (b) a direct dial to a non-loopback
// IP is refused by the kernel (ENETUNREACH). Any failure → null → the caller
// falls back to shared-net bwrap. This is the safety contract: netConfined:true
// is asserted only after it has been demonstrated in THIS environment, never
// assumed.
async function detectNetnsConfinement(sandboxRoot) {
if (process.platform !== "linux") return null;
const ipBin = resolveIpBin();
if (!hasCmd("bwrap") || !ipBin) return null;
if (_netnsCapable === undefined) {
_netnsCapable = await selfTestNetnsConfinement(ipBin).catch(() => false);
}
if (!_netnsCapable) return null;
const sockPath = path.join(sandboxRoot, "cap.sock");
const readyFile = path.join(sandboxRoot, "fw.ready");
return {
innerPort: NETNS_INNER_PORT,
sockPath,
// The final wrap depends on the run's grace window (the forwarder must
// outlive the payload long enough for the parent's egress-grace read), so
// the caller builds it once it knows graceMs.
build: (graceMs) => ({
level: "bwrap+netns",
netConfined: true,
wrap: (argv) => netnsWrap(argv, { sandboxRoot, innerPort: NETNS_INNER_PORT, sockPath, readyFile, ipBin, graceMs })
})
};
}
// Wrap the payload argv into a bwrap --unshare-net invocation whose child is the
// netns bootstrap (bring up lo, start the forwarder, run the payload). The base
// argv from execWithTimeout is ["sh","-c",<cmd>]; we rebuild <cmd> as the
// bootstrap wrapping the original command.
function netnsWrap(argv, opts) {
const cmd = argv[0] === "sh" && argv[1] === "-c" ? argv[2] : argv.join(" ");
const bootstrap = buildNetnsBootstrap({
nodeBin: process.execPath,
selfPath: __filename,
innerPort: opts.innerPort,
sockPath: opts.sockPath,
readyFile: opts.readyFile,
ipBin: opts.ipBin,
graceMs: opts.graceMs,
payloadCmd: cmd
});
return ["bwrap", ...bwrapBaseArgs(opts.sandboxRoot), "--unshare-net", "sh", "-c", bootstrap];
}
// Spawn a raw argv (no shell), capture output, enforce a timeout with a
// process-group kill. Used by the netns self-test.
function spawnCapture(argv, { env, timeoutMs }) {
return new Promise((resolve) => {
let child;
try {
child = spawn(argv[0], argv.slice(1), { env, stdio: ["ignore", "pipe", "pipe"], detached: true });
} catch (error) {
return resolve({ error: error.message, output: "", timedOut: false });
}
let out = "";
const cap = (c) => { if (out.length < 8192) out += c; };
child.stdout.on("data", cap);
child.stderr.on("data", cap);
let timedOut = false;
const timer = setTimeout(() => { timedOut = true; killProcessGroup(child, "SIGKILL"); }, timeoutMs);
child.on("error", (e) => { clearTimeout(timer); resolve({ error: e.message, output: out, timedOut }); });
child.on("close", (code) => { clearTimeout(timer); resolve({ exitCode: code, output: out, timedOut }); });
});
}
// The self-test: stand up the real machinery once and demand proof of BOTH
// confinement properties before ever reporting netConfined:true.
async function selfTestNetnsConfinement(ipBin) {
const root = await fsp.mkdtemp(path.join(os.tmpdir(), "npm-netns-selftest-"));
const token = `selftest-${crypto.randomBytes(8).toString("hex")}`;
let proxy;
try {
const sockPath = path.join(root, "cap.sock");
const readyFile = path.join(root, "fw.ready");
const hits = [];
const cap = createCaptureServer(new Set([token]), (h) => hits.push(h));
await new Promise((res, rej) => { cap.server.on("error", rej); cap.server.listen(sockPath, res); });
proxy = cap;
// Probe (runs inside the netns): (1) POST the token through the proxy — must
// be captured; (2) dial a non-loopback TEST-NET-3 (RFC5737) IP directly —
// the kernel must refuse it (ENETUNREACH/EHOSTUNREACH/ENETDOWN), proving
// there is no route out of the namespace. Prints markers we assert on.
const probeSrc =
"const http=require('node:http'),net=require('node:net');" +
"const p=new URL(process.env.HTTP_PROXY);" +
"const r=http.request({host:p.hostname,port:p.port,method:'POST',path:'http://selftest.local/x',headers:{host:'selftest.local'}});" +
"r.on('error',()=>{});r.end('t=' + process.env.SELFTEST_TOKEN);" +
"const s=net.connect({host:'203.0.113.1',port:80});" +
"s.setTimeout(2000);" +
"s.on('connect',()=>{console.log('DIRECT_OPEN');s.destroy();});" +
"s.on('timeout',()=>{console.log('DIRECT_TIMEOUT');s.destroy();});" +
"s.on('error',(e)=>{console.log('DIRECT_ERR_'+e.code);});";
const payloadCmd = `${shellQuote(process.execPath)} -e ${shellQuote(probeSrc)}`;
const bootstrap = buildNetnsBootstrap({
nodeBin: process.execPath, selfPath: __filename, innerPort: NETNS_INNER_PORT,
sockPath, readyFile, ipBin, graceMs: 500, payloadCmd
});
const argv = ["bwrap", ...bwrapBaseArgs(root), "--unshare-net", "sh", "-c", bootstrap];
const env = { ...process.env, HTTP_PROXY: `http://127.0.0.1:${NETNS_INNER_PORT}`, SELFTEST_TOKEN: token };
const res = await spawnCapture(argv, { env, timeoutMs: 10000 });
const captured = hits.some((h) => (h.tokensSeen || []).includes(token));
// Only a KERNEL-level refusal (unreachable / net down) proves isolation;
// a timeout would also occur on a shared net where the IP just doesn't
// answer, so it does NOT count as confinement.
const directBlocked = /DIRECT_ERR_(ENETUNREACH|EHOSTUNREACH|ENETDOWN|EADDRNOTAVAIL)/.test(res.output || "");
return captured && directBlocked;
} catch {
return false;
} finally {
if (proxy) await proxy.close().catch(() => {});
await fsp.rm(root, { recursive: true, force: true }).catch(() => {});
}
}
// __forwarder / __capture entrypoints, invoked as `node sandbox.js <mode> …`
// from inside the sandbox. Kept at module scope so bwrap's ro-bind of / makes
// this file reachable by absolute path.
function forwarderMain(argv) {
const [portStr, sockPath, readyFile] = argv;
startTcpToUnixForwarder(Number(portStr), sockPath, () => {
try { require("node:fs").writeFileSync(readyFile, "ready\n"); } catch { /* best effort */ }
});
}
// The install-phase runner: execute the package's declared install lifecycle
// scripts, in order, in the package dir with the scrubbed decoy env. This is

@@ -363,2 +583,84 @@ // exactly the install-time execution surface the TeamPCP / node-ipc families

// The loader that runs INSIDE the sandbox to detonate the import phase. It
// resolves the package's own entry point (respecting package.json `main` /
// `exports` / index.js), then loads it so any top-level side effect executes
// and is observed by the capture proxy — the flatmap-stream / malicious-on-first-
// require shape that a lifecycle-only run never triggers. `require` handles CJS;
// on ERR_REQUIRE_ESM it falls back to dynamic import(). Best-effort: a package
// whose entry require()s an uninstalled dependency throws before its payload
// runs — the same ceiling any without-install detonation faces (noted in the
// result `limits`). Errors are swallowed to stderr; the goal is to trigger and
// observe side effects, not to grade whether the module loaded cleanly.
const IMPORT_PROBE_SOURCE = `'use strict';
const { pathToFileURL } = require('node:url');
const dir = process.argv[2];
(async () => {
let entry;
try {
entry = require.resolve(dir);
} catch (e) {
process.stderr.write('import-phase: cannot resolve entry (' + (e && e.message || e) + ')');
return;
}
try {
require(entry);
} catch (e) {
if (e && e.code === 'ERR_REQUIRE_ESM') {
try { await import(pathToFileURL(entry).href); }
catch (e2) { process.stderr.write('import-phase(esm): ' + (e2 && e2.message || e2)); }
} else {
process.stderr.write('import-phase: ' + (e && e.message || e));
}
}
})();
`;
// The import-phase runner: load the package's entry point inside the SAME
// sandbox (decoy HOME, capture proxy, OS wrapper, rlimits, process-group kill)
// so import-time behavior is observed too — not just install scripts. The probe
// script is written into the sandbox root (writable, and never the package dir,
// so the staged tree stays pristine) and run with the same node that runs
// pkgxray. Skips cleanly when the staged package has no resolvable entry.
async function runImportPhase({ pkgDir, env, timeoutMs, wrapper, rlimits, sandboxRoot }) {
try {
await fsp.access(path.join(pkgDir, "package.json"));
} catch {
return { attempted: false, note: "no package.json in staged package" };
}
const probeDir = sandboxRoot || path.dirname(pkgDir);
const probePath = path.join(probeDir, `import-probe-${crypto.randomBytes(4).toString("hex")}.js`);
try {
await fsp.writeFile(probePath, IMPORT_PROBE_SOURCE, { mode: 0o600 });
} catch (error) {
return { attempted: false, note: `could not write import probe: ${error.message}` };
}
// Invoke node directly (not via a shell string) so a package path containing
// shell metacharacters can't break the command; execWithTimeout still wraps
// it in the OS sandbox and applies the process-group timeout kill.
const nodeBin = process.execPath;
const command = `${shellQuote(nodeBin)} ${shellQuote(probePath)} ${shellQuote(pkgDir)}`;
const outcome = await execWithTimeout(command, { cwd: pkgDir, env, timeoutMs, wrapper, rlimits });
await fsp.rm(probePath, { force: true }).catch(() => {});
return { attempted: true, entryDir: pkgDir, ...outcome };
}
// Single-quote a token for safe interpolation into an `sh -c` command. Wraps in
// single quotes and escapes any embedded single quote the POSIX way ('\'').
function shellQuote(s) {
return `'${String(s).replace(/'/g, "'\\''")}'`;
}
// The default (real) runner: detonate BOTH phases in the same sandbox —
// install-time lifecycle scripts, then the import of the package entry point.
// Import runs even if a lifecycle script failed, so a package that is benign at
// install but malicious on first require is still observed.
async function runInstallAndImport(ctx) {
const install = await runLifecycleScripts(ctx);
let importPhase = { attempted: false, note: "import phase disabled" };
if (ctx.importPhase !== false) {
importPhase = await runImportPhase(ctx);
}
return { ran: install.ran || [], note: install.note, importPhase };
}
// Best-effort resource caps for the untrusted child, applied via `ulimit` in the

@@ -488,3 +790,3 @@ // spawned POSIX shell. The timeout + process-group SIGKILL bound TIME; these

rationale:
`Install-time execution READ the decoy ${decoy} and transmitted its honeytoken to ${hit.host} (${hit.transport}). This is an observed credential-exfil, not a static inference.`
`Sandboxed execution (install + import) READ the decoy ${decoy} and transmitted its honeytoken to ${hit.host} (${hit.transport}). This is an observed credential-exfil, not a static inference.`
});

@@ -504,3 +806,3 @@ }

rationale:
`Install-time execution contacted ${hit.host} (${hit.transport}) — a known callback/exfil destination or a raw IP. Legitimate installs do not phone these.`
`Sandboxed execution (install + import) contacted ${hit.host} (${hit.transport}) — a known callback/exfil destination or a raw IP. Legitimate install/import does not phone these.`
});

@@ -523,3 +825,3 @@ }

rationale:
`Install-time execution made outbound network requests to: ${hostList.join(", ")}. Review whether this package should reach the network at install.`
`Sandboxed execution (install + import) made outbound network requests to: ${hostList.join(", ")}. Review whether this package should reach the network at install or import.`
});

@@ -548,3 +850,3 @@ }

rationale:
`Install-time execution accessed decoy credential file(s): ${readDecoys.join(", ")} (access-time tripwire; corroborating, since atime can be unreliable). Combined with any egress above this is a credential-harvest shape.`
`Sandboxed execution (install + import) accessed decoy credential file(s): ${readDecoys.join(", ")} (access-time tripwire; corroborating, since atime can be unreliable). Combined with any egress above this is a credential-harvest shape.`
});

@@ -583,3 +885,15 @@ }

// execute anything, rather than silently running with env-only isolation.
const wrapperInfo = detectSandboxWrapper(root);
let wrapperInfo = detectSandboxWrapper(root);
// Upgrade shared-net bwrap to netns confinement when this host can prove it
// (bwrap + ip + a passing self-test). The forwarder must outlive the payload
// long enough for the parent's egress-grace read, so it is built with the
// run's grace window. Opt out with importPhase-style options.netnsConfinement:false.
const graceMs = Number.isFinite(options.egressGraceMs) && options.egressGraceMs >= 0
? options.egressGraceMs
: DEFAULT_EGRESS_GRACE_MS;
let netns = null;
if (wrapperInfo.level === "bwrap" && options.netnsConfinement !== false && !options.runner) {
netns = await detectNetnsConfinement(root);
if (netns) wrapperInfo = netns.build(graceMs);
}
if (options.requireSandbox === true && wrapperInfo.level === "env-only") {

@@ -597,5 +911,11 @@ await fsp.rm(root, { recursive: true, force: true }).catch(() => {});

const canary = await seedCanaryFilesystem(home, runId);
const proxy = await startCaptureProxy(new Set(canary.tokens.keys()));
// Under netns, the sandbox cannot reach the host TCP loopback, so the capture
// proxy also opens a Unix socket the in-netns forwarder connects to, and the
// payload's proxy env points at the forwarder's inner loopback port instead.
const proxy = await startCaptureProxy(
new Set(canary.tokens.keys()),
netns ? { unixPath: netns.sockPath } : {}
);
const proxyUrl = `http://127.0.0.1:${proxy.port}`;
const proxyUrl = netns ? `http://127.0.0.1:${netns.innerPort}` : `http://127.0.0.1:${proxy.port}`;
// Scrubbed env: keep only what a script needs to run, repoint HOME at the

@@ -623,4 +943,14 @@ // decoy tree, and force every proxy variable at our capture server.

try {
const runner = options.runner || runLifecycleScripts;
execResult = await runner({ pkgDir, env, timeoutMs, wrapper: wrapperInfo.wrap, home, proxyPort: proxy.port, rlimits: options.rlimits });
const runner = options.runner || runInstallAndImport;
execResult = await runner({
pkgDir,
env,
timeoutMs,
wrapper: wrapperInfo.wrap,
home,
proxyPort: proxy.port,
rlimits: options.rlimits,
sandboxRoot: root,
importPhase: options.importPhase
});
} finally {

@@ -703,2 +1033,3 @@ // Keep the capture proxy alive for a short grace window after the runner

startCaptureProxy,
createCaptureServer,
evaluateTripwires,

@@ -710,2 +1041,11 @@ matchTokens,

DECOY_SPECS,
// exported for tests: the two phase runners + their composition
runLifecycleScripts,
runImportPhase,
runInstallAndImport,
// exported for tests: netns confinement — forwarder, bootstrap, detection
startTcpToUnixForwarder,
buildNetnsBootstrap,
detectNetnsConfinement,
bwrapBaseArgs,
// exported for tests: process-group kill + timeout runner + resource caps

@@ -716,1 +1056,13 @@ killProcessGroup,

};
// CLI entrypoints invoked from inside the sandbox: `node <this file> __forwarder …`.
// Guarded so require() of the module never triggers them.
if (require.main === module) {
const [, , mode, ...rest] = process.argv;
if (mode === "__forwarder") {
forwarderMain(rest);
} else {
process.stderr.write("src/sandbox.js is an internal module; no directly runnable command.\n");
process.exit(2);
}
}