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.0
to
1.0.1
+1
-1
bin/audit.js

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

// CLEAR a package. A quiet run is not a safe package.
if (behavioral.verdict === "safe") {
if (behavioral.verdict === "not-observed") {
lines.push(

@@ -625,0 +625,0 @@ "> No malicious behavior was OBSERVED in this run. This does NOT clear the package:",

{
"name": "pkgxray",
"version": "1.0.0",
"version": "1.0.1",
"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.",

@@ -5,0 +5,0 @@ "license": "MIT",

+317
-345

@@ -5,7 +5,15 @@ <div align="center">

**Analyze packages before you install them.**
**Supply-chain security for AI agents, npm packages, and Model Context Protocol (MCP) servers.**
Local supply-chain security for AI agents & npm packages.
Zero-dependency Node, runs entirely on your machine, never executes untrusted 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)
[![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)
[![calibration benchmark](https://github.com/adamsjack711-ux/pkgxray/actions/workflows/pkgxray-benchmark.yml/badge.svg)](https://github.com/adamsjack711-ux/pkgxray/actions/workflows/pkgxray-benchmark.yml)
[![license: MIT](https://img.shields.io/npm/l/pkgxray)](LICENSE)
**Static analysis** · **Supply-chain intelligence** · **Prompt-injection detection** ·
**MCP security** · **Zero dependencies** · **Evidence-based verdicts** · `SAFE` / `REVIEW` / `BLOCK`
<img src="docs/banner.png" alt="pkgxray — a package under an x-ray scan beam next to the SAFE / REVIEW / BLOCK verdict chips" width="820">

@@ -15,429 +23,391 @@

## Quick start
```bash
npm install -g pkgxray
npm install -g pkgxray # or zero-install: npx pkgxray …
pkgxray guard npm:some-package@1.2.3
pkgxray guard npm:express@4.21.0
```
Point it at a package, get a `SAFE` / `REVIEW` / `BLOCK` verdict with cited
evidence — before a single line of that package runs.
```text
Decision: **SAFE**
---
Verdict: **SAFE**
Grade: **A+** (99/100)
## Why pkgxray exists
No high- or medium-risk indicators were found in the provided evidence.
AI coding assistants increasingly install packages automatically, often without
a human ever reading the code. Traditional antivirus inspects what *executes*;
**pkgxray inspects what gets *installed*** — evidence-based static analysis on a
package's metadata, source, provenance, and published artifact before it reaches
your machine.
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)
It's intentionally conservative: it only reports evidence it can cite, and
stages everything in a sandboxed quarantine that never runs install scripts or
package code. Triage takes ~1 s/package with no execution risk.
Parameter grades:
- `knownVulnerabilities`: A+ (100/100) - `provenance`: A+ (100/100)
- `dataAccess`: A+ (100/100) - `persistence`: A+ (100/100)
- `obfuscation`: A+ (100/100) - `injectionResistance`: A+ (100/100)
```
---
<sub>Real output, abridged. A `BLOCK` verdict instead lists every finding with
the file and evidence that produced it.</sub>
## Detection Engine
Point it at a package, get a `SAFE` / `REVIEW` / `BLOCK` verdict with cited
evidence — before a single line of that package runs. The guard flow 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.
**Supply-chain intelligence** — known CVEs (OSV, blocks *before* download),
sigstore/SLSA provenance, npm↔GitHub artifact divergence, registry metadata.
<img src="docs/screenshots/cli-guard-block.png" alt="pkgxray guard blocking a malicious sample: BLOCK verdict, grade F, HIGH credential-access finding citing a wallet read exfiltrated to an attacker endpoint, exit code 2" width="820">
**Static code analysis** — credential/secret access (`.ssh`, `.aws`, `.npmrc`,
`.env`, keychains, wallets), persistence writes (shell rc, cron, launch agents),
obfuscation + execution (a packed blob decoded into `eval`/`new Function`/`vm`,
split-string paths), Trojan Source (bidi/zero-width Unicode), and **tiered
prompt-injection** detection in docs, *code comments*, and *`package.json`
metadata* (`description`/`keywords`/`author`) — reworded steering, chat/role
scaffolding (`<|im_start|>`, `<<SYS>>`, `[INST]`), and identity reassignment, not
just verbatim phrases.
<sub>`guard` blocking a malicious sample from the [calibration corpus](benchmark/)
(modeled on the 2024 `@solana/web3.js` compromise) — the HIGH finding cites the
exact wallet-read + exfiltration code. Real run; see
[how each screenshot was made](docs/screenshots/README.md).</sub>
**Concealment & encoding** — injection is unsolvable by matching the attacker's
*wording* (paraphrase defeats it), but it has to be *delivered*, and the
delivery tells are high-signal and low-FP. pkgxray detects instructions
**smuggled in invisible characters** (the Unicode tag block — "ASCII smuggling")
and **base64-encoded prompts** hidden in docs/comments that a human can't read
but an agent decodes. It detects the envelope, not the message — so it
generalizes past rewording. (Emoji subdivision flags and benign/binary base64
are excluded.) See [solving prompt injection](#on-prompt-injection).
## Why pkgxray?
**Behavioral correlation** — cross-file exfiltration, stage-2 loaders, download→
execute (`curl | sh`), `process.env` harvesting near a network sink, **on-chain
command channels** (EtherHiding — a chain-read like `eth_getTransactionByHash` /
TronGrid / Aptos co-located with a code executor, so the payload lives on a
blockchain the repo never has to change), and **hidden self-`node -e` execution**
(a detached, `windowsHide`, `stdio:'ignore'` inline-eval subprocess — stage-2
that outlives and escapes the process being scanned).
AI coding assistants increasingly install packages automatically, often
without a human ever reading the code. Traditional antivirus inspects what
*executes*; **pkgxray inspects what gets *installed***.
Every signal resolves to one verdict:
Vulnerability scanners like `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**:
| Verdict | Meaning |
|---|---|
| 🟢 `safe` | no high- or medium-risk indicators |
| 🟡 `review` | incomplete evidence or a privileged capability needing a human |
| 🔴 `block` | high-severity (prompt injection, credential access, persistence, obfuscation + execution, likely exfiltration) |
- What does the code actually *do* — read credentials? persist? phone home?
- Does the published npm artifact match the tagged GitHub source?
- Is the provenance attestation consistent with the claimed repository?
- Is there a prompt-injection payload aimed at the AI agent reading the docs?
---
It is intentionally conservative: it only reports evidence it can cite, its
verdicts come from deterministic heuristics (no LLM in the verdict path, so
injected text can't steer them), and its zero-false-block calibration is
[regression-gated in CI](docs/benchmark.md).
## Architecture
> [!NOTE]
> pkgxray is designed to run *alongside* `npm audit` and OSV-Scanner, not
> replace them. See the [comparison table](#-comparison) below.
<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">
## Key features
**Design principles:** never execute untrusted code · report only citable
evidence · explainability over black-box scoring · minimize false positives ·
operate offline whenever possible · zero runtime dependencies.
### Supply-chain intelligence
---
- **Known-CVE pre-check** — batch OSV query that blocks *before* download
- **Provenance verification** — sigstore / SLSA attestations, cross-checked
against the claimed repository
- **Artifact divergence** — the published npm tarball diffed against the
tagged GitHub source
- **Registry metadata signals** — nonexistent or mismatched repos,
attestation/repo inconsistencies (typosquat and impersonation indicators)
- **Continuous monitoring** — [`pkgxray recheck`](docs/reference.md#monitoring-pkgxray-recheck)
diffs installed deps against a stored verdict baseline and pre-vets newer
versions, catching the maintainer-takeover / trojaned-update case
## Threat model
### Static behavior analysis
Malicious npm packages · compromised maintainer accounts · typosquatting &
dependency confusion · credential theft · malicious lifecycle scripts ·
supply-chain tampering (npm artifact ≠ tagged source) · provenance spoofing ·
AI prompt injection in package docs.
- **Credential & secret access** — `.ssh`, `.aws`, `.npmrc`, `.env`,
keychains, wallets — including paths assembled from split fragments
(`".s"+"sh"`)
- **Persistence** — writes to shell rc files, cron, launch agents
- **Obfuscation + execution** — a packed blob decoded into `eval` /
`new Function` / `vm`
- **Behavioral correlation** — cross-file exfiltration, stage-2 loaders,
download→execute (`curl | sh`), `process.env` harvesting near a network
sink, on-chain command channels (EtherHiding), hidden self-`node -e`
- **Trojan Source** — bidi / zero-width Unicode attacks
- **Opt-in behavioral canary** — [`pkgxray canary`](docs/canary-threat-model.md)
executes a package's lifecycle scripts in an OS sandbox with decoy
credentials. It can *confirm* malice; by design it never *clears* a package.
**Known blind spot:** pkgxray reasons about bytes in the tarball. A package that
downloads and runs its real payload *after* install can ship a clean tree.
pkgxray flags the *capability* when its shape is unambiguous, but pair it with
runtime/install-time sandboxing when that risk matters.
### Prompt-injection detection
**Why few false positives:** validated against the 47 most-installed npm
packages with **0 false blocks**. READMEs run only the prompt-injection check
(never read as code); test/fixture/example files downgrade to `review`;
npm↔GitHub divergence is `review`, not auto-block (can't tell a build step from
tampering); URL shorteners count only when co-located with a capability. And
**minification is not obfuscation** — `eval`/`new Function` on a *string
literal* (a bundler's `eval-source-map` module wrapper, a `new Function("return
this")` globalThis probe) is recorded as info, not flagged; only `eval` on a
*computed* argument (`eval(atob(blob))`) gates. That keeps heavily-bundled
frontend packages out of the review pile.
- **Tiered detection** in docs, code comments, and `package.json` metadata
- **Delivery-envelope matching** — instructions smuggled in invisible Unicode
tag characters ("ASCII smuggling") or base64-encoded in docs/comments.
Detecting the *envelope*, not the wording, generalizes past rewording.
- **Injection-proof by construction** — verdicts are deterministic; no model
reads the package, so injected text can't steer the scanner. Full stance:
[threat model — on prompt injection](docs/threat-model.md#on-prompt-injection).
### On prompt injection
### MCP security
Prompt injection isn't "solved" by a scanner, and pkgxray doesn't claim to. The
durable defense is *architectural*, and pkgxray's design reflects three honest
layers:
- **MCP server** — `pkgxray-mcp` gives any MCP-capable agent four audit tools
- **Connect-time vetting** — `pkgxray mcp` performs a read-only handshake and
audits the tool manifest: injection in tool descriptions, concealed
envelopes, and **capability-surface mismatch** (a `get_weather` that also
takes a `command`)
- **Pin & recheck** — `--pin` fingerprints an approved manifest;
`--recheck` catches the rug-pull
1. **pkgxray is injection-proof by construction.** Its verdict is computed by
deterministic heuristics, not by an LLM reading the package — so injected text
*cannot steer a pkgxray verdict*. There is no model in the decision path to
hijack.
2. **Detection targets the delivery, not the wording.** Matching an attacker's
phrasing is a treadmill (paraphrase wins). Matching *how injection is
delivered* — concealed in invisible characters, base64-encoded, hidden in a
code comment — generalizes past rewording and has near-zero false positives,
because legitimate package text doesn't smuggle. The tiered phrase matcher
catches the rest and routes uncertainty to `review`, never a false `block`.
3. **The real fix lives in the consuming agent.** An agent is only harmed by
injection if it can also act (install, exfiltrate) on what it read — the
"lethal trifecta." pkgxray's job is to **quarantine and label** the untrusted
package so the agent's *capability controls*, the actual security boundary,
can do theirs. pkgxray reduces exposure; it does not replace least-privilege.
### Runtime protection
---
- **Per-call 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 from listings,
~0.05 µs per-call verdict lookup, immediate re-audit on manifest change,
injection scan of tool *results*, drift-after-pin denial
- **Install gate** — a [hookshot](https://github.com/CorridorSecurity/hookshot)
hook binary intercepts an agent's shell command and runs `pkgxray guard` on
every package about to be installed, across Claude Code, Cursor, Windsurf
Cascade, Factory Droid, and OpenAI Codex
([`examples/hookshot/`](examples/hookshot/))
## Quick start
### Policy engine
```bash
# Guard an npm package before it reaches your machine
pkgxray guard npm:some-package@1.2.3
pkgxray guard npm:some-mcp-server@1.2.3 --format json
- **One policy file, every surface** — the CLI, MCP server, and proxy read the
same `.pkgxray.json` through the same loader; policy can't drift
- **Tighten freely, loosen loudly** — stricter without limit; every loosening
is explicit and printed in the report
- **Enforced invariants** — an `allow` must be pinned to `name@version` +
`sha256`; a published CVE can never be muted or allowed away
- **Fail closed** — zero config means maximum strictness; a scan that errors
becomes `review`, never `safe`
# Guard a local extension and promote it only if policy allows
pkgxray guard ./ext --promote-to ./approved/ext
## Architecture
# Audit a whole project's lockfile (batch OSV query)
pkgxray audit package-lock.json # also: yarn.lock, pnpm-lock.yaml, package.json
pkgxray audit package-lock.json --deep # full static/GitHub layer on each blocked dep
<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">
# Audit supplied evidence directly
pkgxray --file examples/evidence.json --format json
<!-- Architecture diagram -->
# Re-check already-installed deps against *current* intelligence (monitoring)
pkgxray recheck package-lock.json # diff verdicts vs. stored baseline
pkgxray recheck package-lock.json --format json # machine-readable, for CI cron
```
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.
The guard flow stages the extension in a private quarantine, audits the staged
copy, and only promotes it when policy allows — it never runs `npm install`,
lifecycle scripts, build steps, or extension code. For npm references: resolve
metadata → query OSV → block before download if vulnerable → otherwise extract
into quarantine and run the static audit.
**Design principles:** never execute untrusted code · report only citable
evidence · explainability over black-box scoring · minimize false positives ·
operate offline whenever possible · zero runtime dependencies.
Decisions: `allow` (promotion ok), `review` (inspect quarantine first), `block`
(do not install). Only `safe` promotes by default; `--policy allow-review` also
promotes review-grade. Exit codes: `0` safe/allow, `2` block, `3` review.
Details: [docs/architecture.md](docs/architecture.md) ·
[docs/design.md](docs/design.md)
---
## Verdicts
## Configuration: `.pkgxray.json`
Every signal resolves to one of three verdicts:
Every surface — the CLI, the MCP server, and the proxy — reads **one** optional
policy file, `.pkgxray.json`, through the same loader, so your policy can never
drift between them. Zero config is fully safe: an absent file means maximum
strictness. You never have to write one.
| 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 — install scripts, computed `eval`, a lone callback domain, npm↔GitHub divergence. | Inspect the quarantined copy before promoting. `--policy allow-review` promotes review-grade if you accept that. |
| 🔴 `BLOCK` | High-severity, cited evidence — prompt injection, credential access, persistence, obfuscation + execution, likely exfiltration, or a known CVE. | Do not install. Every finding names the file and evidence. |
The governing rule is **tighten freely, loosen loudly**. You may make the policy
stricter without limit; you may loosen it (allow a package, mute a check) only
*explicitly*, and every loosening is printed in the report — never silent. Two
rules are enforced in code, not by convention:
Exit codes are stable and CI-friendly: **`0`** safe/allow · **`2`** block ·
**`3`** review. The exact mapping of every signal to `block` / `review` /
`info` is specified in the [severity policy](docs/reference.md#severity-policy-what-lands-in-block--review--info).
1. **An `allow` entry must be pinned** to `name@version` **and** a `sha256`. A
bare name would blanket-trust every future version — exactly how a trojaned
update gets in — so un-pinned allows are dropped with a warning, and a pin
only applies when the scanned artifact's digest matches.
2. **A published vulnerability can never be muted or allowed away.** OSV
`known-vulnerability` findings always surface. You can vouch for a package's
code; you cannot vouch away a CVE.
## Who is this for?
```jsonc
{
"policy": "safe-only", // or "allow-review" (a loosening — warns)
"failOn": "review", // CI exit threshold: fail at review (exit 3) / block (exit 2)
"scanErrorPolicy": "fail-closed", // a scan that errors → review, never silently safe
- **AI developers** — building agents that install packages or connect to MCP
servers
- **Security engineers** — vetting third-party code with citable evidence
- **DevSecOps** — enforcing supply-chain policy in CI with stable exit codes
and additive-only JSON
- **Open-source maintainers** — verifying their own dependency trees and
release provenance
- **Organizations adopting AI coding assistants** — putting a deterministic
gate between the agent and the registry
// loosen ONLY explicitly — each is shown in every report
"allow": [
{ "pkg": "left-pad@1.3.0", "sha256": "e0b0…",
"reason": "reviewed 2026-07", "expires": "2026-10-01" }
],
"mute": [
{ "check": "lonely-maintainer", "scope": "@myorg/*", "reason": "internal registry" }
],
## Use cases
// the MCP server reads the same file, but the agent deployment starts stricter
"mcp": { "tools": ["audit", "recheck"], "packageScanFirst": true, "timeoutMs": 15000 }
}
### Vet an npm package before installing
```bash
pkgxray guard npm:some-package@1.2.3
pkgxray guard npm:some-package@1.2.3 --format json
# Guard a local extension and promote it only if policy allows
pkgxray guard ./ext --promote-to ./approved/ext
```
Precedence (lowest → highest): built-in safe defaults → project `.pkgxray.json`
→ local `.pkgxray.local.json` (gitignored, personal) → `PKGXRAY_*` env vars →
CLI flags. `allow`/`mute` lists concatenate across layers. See
[`.pkgxray.example.json`](.pkgxray.example.json) and [`docs/config.md`](docs/config.md)
for the full schema.
### Vet an MCP server before connecting
---
```bash
# Static package scan FIRST, then read-only manifest audit
pkgxray mcp --package npm:some-mcp-server@1.4.2 npx some-mcp-server
## Monitoring: `pkgxray recheck`
pkgxray mcp https://mcp.example.com/mcp # HTTP server
pkgxray mcp --pin --package npm:some-mcp-server@1.4.2 npx some-mcp-server
pkgxray mcp --recheck npx some-mcp-server # catch the rug-pull
```
`guard` and `audit` give a point-in-time verdict *at install*. `recheck` answers
the follow-up they can't: **has anything I already depend on become unsafe since
I installed it?** — the maintainer-takeover / trojaned-update case.
Full MCP guide (server, adapter, runtime proxy): [docs/mcp.md](docs/mcp.md)
It walks a lockfile, re-runs the guard evaluation (OSV / provenance / divergence)
for each pinned `name@version`, and diffs the fresh verdict against the baseline
stored in `.pkgxray.lock` (written by `triage`/`guard`). It reports a **diff, not
a full report**:
### Enforce in CI/CD
- **regressed** — verdict got worse since `checkedAt` (`allow/safe → review/block`).
The actionable signal: you may already be exposed.
- **improved** — verdict got better (informational).
- **unchanged** — hidden unless `--verbose`.
- **no-baseline** / **unknown** — never-vetted, or the recheck itself errored (its
stored verdict is left untouched — never a false allow).
```bash
pkgxray audit package-lock.json # also: yarn.lock, pnpm-lock.yaml, package.json
pkgxray audit package-lock.json --deep # full static/GitHub layer on each blocked dep
```bash
pkgxray recheck package-lock.json # human diff
pkgxray recheck package-lock.json --verbose # also list unchanged deps
pkgxray recheck package-lock.json --no-write # don't update stored baselines
pkgxray recheck package-lock.json --format json # machine-readable diff
# Scheduled: has anything I already depend on become unsafe since install?
npx pkgxray recheck package-lock.json --format json
```
**Exit codes** key off the worst *regression*, so CI cron jobs consume `recheck`
exactly as they do `guard`: `0` nothing regressed, `2` a dep regressed to
**block**, `3` a dep regressed to **review**. A dep that was `block` at install
and is *still* `block` is **not a new regression** and does not fail the run.
Available newer-version updates never affect the exit code on their own (see
version drift below) unless you pass `--fail-on-available-updates`.
`recheck` exits non-zero only on a *regression* (a dep whose verdict got
worse), which makes it a clean scheduled job — a ready-made GitHub Actions
workflow is in the [reference](docs/reference.md#monitoring-pkgxray-recheck).
Point `PKGXRAY_CACHE_URL` at the
[self-hostable cache server](docs/reference.md#self-hostable-cache-server) to
collapse duplicate fetches across runners.
Set `PKGXRAY_CACHE_URL` so a large tree shares `guard`'s warm cache instead of
re-fetching everything cold.
### Guard AI coding agents
### Version drift — pre-vet newer versions before you upgrade
```json
{ "mcpServers": { "pkgxray": { "command": "pkgxray-mcp" } } }
```
Alongside verdict drift, `recheck` also asks the registry whether a **newer
version** exists for each dep and guards it, so you see the security verdict
*before* upgrading — the trojaned-update catch:
Give the agent the audit tools directly (above), gate its installs with the
[hookshot integration](examples/hookshot/), and wrap its MCP servers with
[`pkgxray mcp-proxy`](docs/mcp.md#per-call-runtime-gate-pkgxray-mcp-proxy).
- **update-available-safe** — a newer version exists and guards clean.
- **update-available-flagged** — a newer version exists but is `review`/`block`.
Don't blind-upgrade into it.
### Security reviews
To keep registry/OSV cost sane, at most two candidates are vetted per dep: the
**latest** published stable version and the **latest within the pinned major**
(an approximation of your install range), when they differ. Prereleases are
skipped unless the pinned version is itself a prerelease.
```bash
pkgxray --file examples/evidence.json --format json # audit supplied evidence
```
Version drift is **informational** — an available flagged update you haven't
installed isn't an active exposure, so it never changes the exit code on its
own. Pass `--fail-on-available-updates` to make a flagged update count, or
`--no-version-drift` to skip the registry pass entirely.
Every verdict is a structured, citable report (`schemaVersion: 1`,
additive-only — [schema](docs/json-schema.md)), and the quarantined copy is
left on disk for manual inspection on `review`.
### Scheduled CI job (GitHub Actions)
## Configuration
Run `recheck` against the committed lockfile on a schedule; the job fails the
moment a dependency you already ship regresses:
One optional `.pkgxray.json`, read by every surface. Zero config is fully
safe — an absent file means maximum strictness.
```yaml
# .github/workflows/pkgxray-recheck.yml
name: pkgxray recheck
on:
schedule:
- cron: "0 6 * * *" # daily 06:00 UTC
workflow_dispatch:
jobs:
recheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npx pkgxray recheck package-lock.json --format json
# exit 2 (regressed→block) or 3 (regressed→review) fails the build;
# commit the updated .pkgxray.lock back if you want the baseline to move.
```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, the `mute` / `mcp` blocks, and the enforced invariants:
[docs/configuration.md](docs/configuration.md) ·
[`.pkgxray.example.json`](.pkgxray.example.json)
## MCP Server
## Screenshots
Use the stdio server from any MCP-capable agent:
All captures are real runs — reproduction steps for each are in
[`docs/screenshots/`](docs/screenshots/README.md).
```json
{ "mcpServers": { "pkgxray": { "command": "pkgxray-mcp" } } }
```
**CLI — `pkgxray guard` clearing `express`, with the npm↔GitHub cross-check**
Tools: `audit_agent_extension_supply_chain` (static heuristics on supplied
evidence), `guard_agent_extension_install` (stage + vuln-check + audit a real
package, auto-fetches provenance), `audit_lockfile_supply_chain` (batch OSV scan
a lockfile), `triage_lockfile_supply_chain` (record each flagged dep as
`allow`/`block` into a sibling `.pkgxray.lock`).
<img src="docs/screenshots/cli-guard-safe.png" alt="pkgxray guard on express@4.21.0: SAFE verdict, grade A+, npm tarball matches the linked GitHub repo, per-parameter grades" width="820">
---
**MCP proxy — a live session against a malicious demo server**
## MCP servers it connects to: `pkgxray mcp`
<img src="docs/screenshots/mcp-proxy.png" alt="pkgxray mcp-proxy stripping two tools from tools/list (capability mismatch and injection in the description) and denying a tools/call with an isError result" width="820">
An agent pulls untrusted things in from outside two ways — packages it installs
(covered by guard/hook/proxy) and MCP servers it connects to. `pkgxray mcp`
covers the second with the same engine: it connects to a server (stdio or
streamable HTTP), performs the read-only handshake, enumerates the tool
manifest via `tools/list` — and never calls a tool, reads a resource, or
invokes a prompt.
<sub>Two tools stripped at `tools/list` — one for capability-surface mismatch,
one for injection in its description; the denied `tools/call` never reaches the
server.</sub>
```bash
# Vet the server package statically FIRST, then connect and audit the manifest
pkgxray mcp --package npm:some-mcp-server@1.4.2 npx some-mcp-server
**hookshot install gate — an agent's `npm install` denied with cited evidence**
# An HTTP server
pkgxray mcp https://mcp.example.com/mcp
<img src="docs/screenshots/hookshot.png" alt="the hookshot guard hook answering a Claude Code PreToolUse event for npm install lodash@4.17.11 with permissionDecision deny and pkgxray's cited known-vulnerability evidence" width="820">
# Approve what you just reviewed (pins per-tool fingerprints into .pkgxray.lock)
pkgxray mcp --pin --package npm:some-mcp-server@1.4.2 npx some-mcp-server
**Browser extension — the local MV3 popup blocking a risky sample**
# Later / in CI: catch the rug-pull — descriptions, tools, or schemas that
# changed since approval. Exits 3 on unapproved drift, 2 on a verdict regression.
pkgxray mcp --recheck npx some-mcp-server
```
<img src="docs/screenshots/browser-extension.png" alt="the Supply Chain Auditor extension popup showing a BLOCK verdict, grade F, per-parameter grades, and HIGH injection-attempt and network-exfil-or-loader findings" width="640">
What the manifest audit looks for, all with the existing engine: prompt
injection in tool descriptions and the server's `instructions` blurb (the same
tiered matcher used on READMEs), instructions concealed in invisible Unicode
tag characters or base64 envelopes, and one MCP-specific check —
**capability-surface mismatch**, a tool whose stated purpose is narrow but
whose input schema takes a general-execution parameter (a `get_weather` that
accepts a `command`). Calibrated like the rest of pkgxray: a file reader
taking a `path`, an HTTP tool taking a `url`, a DB tool taking a `query`, or
an honest `execute_shell` tool are not findings.
## Comparison
**The one caveat, stated plainly:** everything else pkgxray does is static —
it never executes what it inspects. Enumerating an MCP server is not. There
is no manifest without a connection, and for a **stdio server that means
spawning and running it**. `pkgxray mcp` narrows the risk the way guard
isolates a tarball — the child gets an allowlist-scrubbed environment (no
inherited secrets), a hard timeout, bounded output, and its process group is
killed after the listing — but the safe order is **package-scan first**: pass
`--package <ref>` so the static, no-execution scan clears the server before
anything connects to it. A `block` halts the connect step (`--force` to
override); skipping the scan entirely requires the explicit
`--no-package-scan`.
`npm audit` and [OSV-Scanner](https://google.github.io/osv-scanner/) are
excellent at what they target — matching your dependencies against known
vulnerabilities. pkgxray overlaps with them on that layer and adds the layers
they don't attempt:
### Per-call runtime gate: `pkgxray mcp-proxy`
| 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 | — | — | ✅ |
`pkgxray mcp` is connect-time: it answers "should this server be registered?"
and then gets out of the request path. Two attacks only exist *inside* a live
session, where a connect-time check can never see them: a manifest that
changes after approval (`notifications/tools/list_changed` — the rug-pull
moving in real time), and poisoned tool **output** steering the model. And a
separate probe can't watch a running stdio server either — it would spawn a
different instance than the one the agent is talking to. The only seam that
sees the actual session is the wire itself, so `mcp-proxy` sits on it: point
the host's server config at the proxy and it launches the real server as its
child, relaying every JSON-RPC frame through the gate.
<sub>Scoped to npm supply-chain vetting; based on each tool's public
documentation at time of writing. OSV-Scanner covers many ecosystems beyond
npm, which pkgxray does not.</sub>
```jsonc
// .mcp.json — wrap the real launcher
{
"mcpServers": {
"some-server": {
"command": "pkgxray",
"args": ["mcp-proxy", "--", "npx", "some-mcp-server"]
}
}
}
```
## Threat coverage
What the gate does, and what each piece costs:
| Threat | Coverage | How pkgxray sees it |
|---|:-:|---|
| Credential theft | ✅ | reads of `.ssh` / `.aws` / `.npmrc` / `.env` / keychains / wallets, incl. split-fragment paths |
| Prompt injection | ✅ | tiered detection in docs, comments, metadata; deterministic verdict path can't be steered by injected text |
| Unicode smuggling | ✅ | invisible tag-block characters ("ASCII smuggling") + Trojan Source bidi / zero-width |
| Base64 payloads | ✅ | encoded envelopes in docs/comments; blobs decoded into computed-arg `eval` / `new Function` / `child_process` |
| 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 |
| Dependency confusion | ◑ | the out-of-band callback beacons confusion payloads use are flagged; registry resolution itself belongs to your package manager |
| Typosquatting | ◑ | surfaced via repo-mismatch (package.json → nonexistent/mismatched repo) and provenance-mismatch signals; no name-similarity heuristic |
| MCP capability abuse | ✅ | capability-surface mismatch in the manifest audit |
| Runtime tool drift | ✅ | `mcp-proxy` re-audits on `tools/list_changed`; pinned-manifest drift is denied |
| Moment | Check | Cost |
|---|---|---|
| first `tools/list` | full static manifest audit (same engine as `pkgxray mcp`), tools that would be denied are **stripped from the listing** so the model never reads their descriptions | ~1 ms per 30 tools, no network |
| every `tools/call` | in-memory verdict lookup against the last verified manifest; unknown / blocked tools denied | **~0.05 µs** (p95 ~0.1 µs) |
| `tools/list_changed` | immediate re-list + re-audit through the same session; calls arriving mid-verification are **held**, then decided against the fresh manifest (denied if the server won't answer — fail closed) | one manifest audit |
| every `tools/call` result | doc-typed injection scan of the result text (tiered prompt-injection, unicode-tag smuggling, base64 envelopes), capped at 512 KiB | ~0.06 ms for a 2 KB result, ~13 ms worst-case at the cap; `--no-scan-results` to disable |
| after `--pin` | fresh manifest diffed against the pinned per-tool fingerprints; **drifted tools are denied under strict/balanced** until re-approved with `pkgxray mcp --pin` | one lock-file read per verification |
<sub>✅ detected · ◑ partial / indirect</sub>
Policies mirror the hookshot gate: `block` denies everywhere; `review` denies
under `--policy strict`, passes with a logged warning under `balanced`
(default) and `permissive`. A denied call never reaches the server — the
agent gets an `isError` tool result naming the reason, so the model can
explain instead of hanging. The proxy's own diagnostics go to stderr only;
stdout stays protocol-clean. On session close it prints a summary
(`N calls gated, M denied, per-call gate p50/p95`).
> [!IMPORTANT]
> **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/install-time sandboxing when that risk matters. Full analysis:
> [docs/threat-model.md](docs/threat-model.md).
One deliberate difference from `pkgxray mcp`: the proxy is the production
conduit, not an enumerator — the child inherits the full environment the host
configured for it. Trust decisions here are about frames, not the child's env.
HTTP servers aren't wrapped (the host connects to them directly); vet those
with connect-time `pkgxray mcp <url>` + `--pin`/`--recheck`.
## Performance
---
- **Local static analysis: ~25 ms.** Almost all of `guard`'s wall-clock is
network round-trips — a full guard of `express` / `chalk` / `commander` is
**~1.3–1.5 s** cold-cache (Apple M1, Node 26).
- **Known-vulnerable packages block at the OSV pre-check**, before download.
- **`mcp-proxy` overhead:** ~0.05 µs per `tools/call` decision; a full
manifest re-audit (~1 ms per 30 tools) runs only when the manifest changes.
- Calibration — precision, recall, and the **0-false-block** gate — is
measured by a committed benchmark corpus that fails CI when it regresses.
## Integrations
Full numbers: [docs/reference.md#performance](docs/reference.md#performance) ·
methodology: [docs/benchmark.md](docs/benchmark.md)
**hookshot** — a [hookshot](https://github.com/CorridorSecurity/hookshot) hook
binary that guards installs across Claude Code, Cursor, Windsurf Cascade, Factory
Droid, and OpenAI Codex: it intercepts an agent's shell command, runs
`pkgxray guard` on every package about to be installed, and denies on a `BLOCK`
verdict with pkgxray's cited evidence returned to the agent. See
[`examples/hookshot/`](examples/hookshot/).
## Roadmap
---
- [ ] List the MCP server in the public MCP registries
- [ ] Ship a reusable GitHub Action wrapping `audit` / `recheck`
- [ ] Publish the browser extension to the Chrome Web Store (today it loads
unpacked)
- [ ] Replay documented known-malicious npm corpora against the engine and
publish the results
- [ ] A `--report` evidence bundle for one-command false-block / missed-threat
reports
## Reference
<!-- Roadmap: additional planned work is tracked in GitHub issues -->
Detailed reference lives in [`docs/reference.md`](docs/reference.md):
The longer-form plan lives in the [adoption playbook](docs/adoption.md).
- **[Severity policy](docs/reference.md#severity-policy-what-lands-in-block--review--info)** — exactly what lands in `block` / `review` / `info`.
- **[Performance](docs/reference.md#performance)** — `guard` timings and `mcp-proxy` gate overhead.
- **[JSON output](docs/reference.md#json-output)** — top-level fields per command (full schema: [json-schema.md](docs/json-schema.md)).
- **[Browser extension](docs/reference.md#browser-extension)** — the local MV3 unpacked extension.
- **[Self-hostable cache server](docs/reference.md#self-hostable-cache-server)** — collapse duplicate CI fetches.
## 📖 Documentation
Other docs: **[compatibility & stability tiers](docs/compatibility.md)** ·
**[JSON schema](docs/json-schema.md)** · **[configuration schema](docs/config.md)** ·
**[canary threat model](docs/canary-threat-model.md)** · **[design notes](docs/design/)** ·
**[adoption playbook](docs/adoption.md)**.
| Doc | What it covers |
|---|---|
| [docs/architecture.md](docs/architecture.md) | Pipeline, surfaces, design principles, repo layout |
| [docs/threat-model.md](docs/threat-model.md) | Scope, the known blind spot, false-positive philosophy, prompt-injection stance |
| [docs/mcp.md](docs/mcp.md) | MCP server, connect-time vetting, per-call runtime proxy |
| [docs/configuration.md](docs/configuration.md) | `.pkgxray.json` schema, precedence, invariants |
| [docs/reference.md](docs/reference.md) | Severity policy, `recheck` monitoring, performance, JSON output, browser extension, cache server |
| [docs/benchmark.md](docs/benchmark.md) | Calibration benchmark & real-world validation |
| [docs/compatibility.md](docs/compatibility.md) | The 1.0 compatibility contract & stability tiers |
| [docs/json-schema.md](docs/json-schema.md) | Full `--format json` schema |
| [docs/canary-threat-model.md](docs/canary-threat-model.md) | Threat model for the opt-in `canary` surface |
| [docs/design.md](docs/design.md) · [docs/design/](docs/design/) | Design principles & internal working notes |
---
Start at the [documentation index](docs/README.md).

@@ -449,15 +419,17 @@ ## Development

npm run benchmark # calibration corpus: precision/recall + 0-false-block gate
npm run build:browser
npm run build:browser # build the MV3 browser extension
npm run audit:evidence -- --file examples/evidence.json
```
The [calibration benchmark](benchmark/) runs a labelled corpus of malicious and
benign fixtures through the real engine and fails on a false block or a missed
detection — the reproducible form of the "0 false blocks" claim. See
[`benchmark/README.md`](benchmark/README.md).
The [calibration benchmark](benchmark/) runs a labelled corpus of malicious
and benign fixtures through the real engine and fails on a false block or a
missed detection. Repo layout is described in
[docs/architecture.md](docs/architecture.md#repository-layout).
```
src/ analysis engines bin/ CLI entrypoints browser-extension/ MV3 ext
docs/ architecture examples/ sample evidence test/ node --test suites
benchmark/ calibration corpus + runner
```
## Security & license
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)

@@ -113,7 +113,22 @@ "use strict";

// Which canary tokens appear anywhere in a captured blob.
// Encoded forms a naive exfil path might apply to a stolen secret before
// sending it. A payload that base64/hex/url-encodes the token would defeat a
// plain substring match, so we also probe the common REVERSIBLE encodings and
// still attribute the leak to the original token. (Compression or encryption of
// the body still defeats this — reported honestly in the result `limits`.)
function tokenVariants(token) {
const variants = [token];
try { variants.push(Buffer.from(token, "utf8").toString("base64")); } catch { /* noop */ }
try { variants.push(Buffer.from(token, "utf8").toString("base64url")); } catch { /* noop */ }
try { variants.push(Buffer.from(token, "utf8").toString("hex")); } catch { /* noop */ }
try { variants.push(encodeURIComponent(token)); } catch { /* noop */ }
return Array.from(new Set(variants.filter(Boolean)));
}
// Which canary tokens appear — verbatim OR in a common reversible encoding —
// anywhere in a captured blob.
function matchTokens(haystack, tokenSet) {
const seen = [];
for (const token of tokenSet) {
if (haystack.includes(token)) seen.push(token);
if (tokenVariants(token).some((v) => haystack.includes(v))) seen.push(token);
}

@@ -138,12 +153,38 @@ return seen;

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.
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.
const tokenIndex = Array.from(tokenSet, (token) => ({ token, variants: tokenVariants(token) }));
const scan = (haystack) => {
const seen = [];
for (const { token, variants } of tokenIndex) {
if (variants.some((v) => haystack.includes(v))) seen.push(token);
}
return seen;
};
const server = http.createServer((req, res) => {
let body = "";
const chunks = [];
let bodyBytes = 0;
let truncated = false;
req.on("data", (chunk) => {
if (body.length < MAX_CAPTURED_BODY) body += chunk;
else truncated = true;
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
if (bodyBytes < MAX_CAPTURED_BODY) {
chunks.push(buf);
bodyBytes += buf.length;
} else {
truncated = true;
}
});
req.on("end", () => {
// latin1 preserves bytes 1:1, so an ASCII-encoded (base64/hex/url) token
// inside an otherwise-binary body survives intact for matchTokens.
const body = Buffer.concat(chunks).toString("latin1");
const haystack = `${req.url}\n${JSON.stringify(req.headers)}\n${body}`;
const tokensSeen = matchTokens(haystack, tokenSet);
const tokensSeen = scan(haystack);
let host = req.headers.host || "?";

@@ -155,3 +196,3 @@ try {

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

@@ -163,5 +204,12 @@ res.end();

server.on("connection", (socket) => {
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
});
server.on("connect", (req, clientSocket) => {
sockets.add(clientSocket);
clientSocket.on("close", () => sockets.delete(clientSocket));
const host = req.url; // host:port
const authTokens = matchTokens(host, tokenSet);
const authTokens = scan(host);
hits.push({ transport: "https-connect", method: "CONNECT", host, url: `https://${host}`, tokensSeen: authTokens, bodyBytes: 0 });

@@ -185,3 +233,18 @@ // No MITM: record the intended destination and refuse the tunnel so

hits,
close: () => new Promise((r) => server.close(() => r()))
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(); });
})
});

@@ -192,6 +255,15 @@ });

// Escape a path for safe interpolation into an SBPL string literal so a path
// containing a quote or backslash can't break out of / corrupt the sandbox
// profile (a malformed profile makes sandbox-exec fail, which fails the run —
// still fail-closed, but this keeps the policy well-formed).
function sbplLiteral(p) {
return `"${String(p).replace(/(["\\])/g, "\\$1")}"`;
}
// Detect a best-effort OS sandbox wrapper. We never REQUIRE one (the decoy HOME
// + capture proxy are the primary controls), but if the platform ships one we
// use it to confine filesystem writes to the sandbox root while keeping
// loopback access to the proxy.
// use it to confine filesystem writes AND real network egress while keeping
// loopback access to the capture proxy. `netConfined` reports whether the OS
// boundary — not just the proxy env vars — blocks non-loopback egress.
function detectSandboxWrapper(sandboxRoot) {

@@ -206,10 +278,17 @@ const has = (cmd) => {

if (process.platform === "darwin" && has("sandbox-exec")) {
// Deny writes outside the sandbox root; allow everything else (incl.
// loopback network to the proxy). Read is allowed so the payload can reach
// the decoy HOME.
// Deny writes outside the sandbox root, AND deny real network egress except
// loopback. Denying non-loopback network at the OS boundary means a payload
// that opens a raw socket / connects to a direct IP (bypassing the proxy
// env vars) is BLOCKED here instead of silently escaping — while the capture
// proxy on 127.0.0.1 stays reachable so proxy-respecting egress is still
// observed. Reads stay allowed so the payload can reach the decoy HOME.
const profile =
"(version 1)(allow default)" +
`(deny file-write* (subpath "${os.homedir()}"))` +
`(allow file-write* (subpath "${sandboxRoot}") (subpath "/private/tmp") (subpath "/tmp"))`;
return { level: "sandbox-exec", wrap: (argv) => ["sandbox-exec", "-p", profile, ...argv] };
`(deny file-write* (subpath ${sbplLiteral(os.homedir())}))` +
`(allow file-write* (subpath ${sbplLiteral(sandboxRoot)}) (subpath "/private/tmp") (subpath "/tmp"))` +
"(deny network*)" +
'(allow network-outbound (remote ip "localhost:*"))' +
'(allow network-inbound (local ip "localhost:*"))' +
'(allow network-bind (local ip "localhost:*"))';
return { level: "sandbox-exec", netConfined: true, wrap: (argv) => ["sandbox-exec", "-p", profile, ...argv] };
}

@@ -220,10 +299,29 @@ if (process.platform === "linux" && has("bwrap")) {

// works (the capture proxy, not the network namespace, is what denies real
// egress). --die-with-parent guarantees no sandbox process outlives pkgxray,
// and --new-session detaches the controlling terminal (blocks TIOCSTI
// input-injection back into the parent). All flags are long-standing.
// 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] : [];
return {
level: "bwrap",
netConfined: false,
wrap: (argv) => [
"bwrap",
"--ro-bind", "/", "/",
...maskRealHome,
"--bind", sandboxRoot, sandboxRoot,

@@ -241,3 +339,3 @@ "--dev", "/dev",

}
return { level: "env-only", wrap: (argv) => argv };
return { level: "env-only", netConfined: false, wrap: (argv) => argv };
}

@@ -249,3 +347,3 @@

// use — and the part gated behind allowExecution.
async function runLifecycleScripts({ pkgDir, env, timeoutMs, wrapper }) {
async function runLifecycleScripts({ pkgDir, env, timeoutMs, wrapper, rlimits }) {
let pkg;

@@ -262,3 +360,3 @@ try {

if (typeof command !== "string" || !command.trim()) continue;
const outcome = await execWithTimeout(command, { cwd: pkgDir, env, timeoutMs, wrapper });
const outcome = await execWithTimeout(command, { cwd: pkgDir, env, timeoutMs, wrapper, rlimits });
ran.push({ hook, command, ...outcome });

@@ -269,2 +367,44 @@ }

// Best-effort resource caps for the untrusted child, applied via `ulimit` in the
// spawned POSIX shell. The timeout + process-group SIGKILL bound TIME; these
// bound BLAST RADIUS during that window: CPU spin, disk-fill, fork-bomb, core
// dumps. `ulimit` can only LOWER a limit, so if the host's is already stricter
// the call is a harmless no-op (errors swallowed with `2>/dev/null`). We use
// `;` not `&&` so a limit the host refuses to set can't abort the payload run
// (that would turn hardening into a false "benign" verdict). Disable with
// rlimits:false. win32 has no ulimit and is skipped by the caller.
const DEFAULT_RLIMITS = {
cpuSeconds: null, // null → derived from timeoutMs (wall-clock) + headroom
fileSizeBlocks: 524288, // cap single-file writes (~256MB at 512B blocks)
// maxProcs (ulimit -u) is OFF by default. On macOS/BSD RLIMIT_NPROC is
// per-real-UID (it counts ALL the operator's processes, not just the sandbox
// subtree), so a low cap on a busy workstation can starve the PAYLOAD's own
// shell — no fork → no execution → a false "not-observed" that suppresses the
// very detection this sandbox exists for. dash also rejects `-u` entirely.
// The wall-clock timeout + process-group SIGKILL already bound a fork bomb in
// TIME, so the backstop isn't worth the false-negative risk. Opt in explicitly
// (rlimits:{maxProcs:N}) on a host where per-UID semantics are acceptable.
maxProcs: null,
coreDumps: 0 // no core dumps (they can leak the decoy HOME to disk)
};
function buildRlimitPrefix(timeoutMs, rlimits) {
if (rlimits === false || process.platform === "win32") return "";
const r = { ...DEFAULT_RLIMITS, ...(rlimits && typeof rlimits === "object" ? rlimits : {}) };
const wall = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
const cpu = Number.isFinite(r.cpuSeconds) && r.cpuSeconds > 0
? Math.floor(r.cpuSeconds)
: Math.ceil(wall / 1000) + 10;
// Each limit is a SEPARATE, individually error-guarded `ulimit` call. Shells
// differ in which options they support — Ubuntu's `/bin/sh` is dash, whose
// `ulimit` rejects `-u` (max procs) — and a single combined `ulimit -t … -u …`
// aborts ALL limits on the first unsupported flag. Separate `; `-joined calls
// apply every supported limit and silently skip the rest. `ulimit` can only
// lower a limit, so a stricter host limit is preserved.
const stmts = [`ulimit -t ${cpu}`, `ulimit -c ${Math.max(0, Math.floor(r.coreDumps))}`];
if (Number.isFinite(r.fileSizeBlocks) && r.fileSizeBlocks > 0) stmts.push(`ulimit -f ${Math.floor(r.fileSizeBlocks)}`);
if (Number.isFinite(r.maxProcs) && r.maxProcs > 0) stmts.push(`ulimit -u ${Math.floor(r.maxProcs)}`);
return `${stmts.map((s) => `${s} 2>/dev/null`).join("; ")}; `;
}
// Kill the whole process GROUP of a detached child, not just the direct shell.

@@ -292,5 +432,10 @@ // `sh -c "(sleep 5; curl ...) &"` backgrounds a grandchild; killing only the

function execWithTimeout(command, { cwd, env, timeoutMs, wrapper }) {
function execWithTimeout(command, { cwd, env, timeoutMs, wrapper, rlimits }) {
return new Promise((resolve) => {
const baseArgv = process.platform === "win32" ? ["cmd", "/c", command] : ["sh", "-c", command];
// On POSIX, prepend `ulimit` caps inside the shell so they bound the whole
// process tree (backgrounded grandchildren inherit them). win32 has no
// ulimit, so the command runs unwrapped there.
const shellCommand =
process.platform === "win32" ? command : `${buildRlimitPrefix(timeoutMs, rlimits)}${command}`;
const baseArgv = process.platform === "win32" ? ["cmd", "/c", command] : ["sh", "-c", shellCommand];
const argv = wrapper ? wrapper(baseArgv) : baseArgv;

@@ -478,3 +623,3 @@ let child;

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

@@ -501,2 +646,6 @@ // Keep the capture proxy alive for a short grace window after the runner

// "not-observed" (NOT "safe"): a clean behavioral run can never clear a
// package, only fail to catch it this time. The verdict vocabulary reflects
// that — callers compare against "block"/"review" and treat anything else as
// inconclusive, never as a pass.
const verdict = findings.some((f) => f.severity === "high")

@@ -506,9 +655,16 @@ ? "block"

? "review"
: "safe";
: "not-observed";
return {
schemaVersion: 1,
schemaVersion: 2,
runId,
isolation: wrapperInfo.level,
isolationRequired: options.requireSandbox === true,
netConfined: wrapperInfo.netConfined === true,
// Honest about OUTCOME, not just intent: the ulimit caps are injected only by
// the default lifecycle runner (`execWithTimeout`). A custom `options.runner`
// (the injectable seam) spawns the child itself and applies none, so we
// don't claim caps were installed then. Even when true, the caps are
// best-effort (a shell may silently reject an unsupported `ulimit`).
resourceLimited: !options.runner && options.rlimits !== false && process.platform !== "win32",
sandboxRoot: options.keepSandbox ? root : null,

@@ -522,7 +678,7 @@ timeoutMs,

// NEVER clear a package. Sandbox-aware malware evades observation and fires
// only on a real developer's machine. Callers must treat a "safe" verdict as
// "nothing observed this run", not "safe".
// only on a real developer's machine. Callers must treat a "not-observed"
// verdict as "nothing observed this run", not "safe".
confirmsButCannotClear: true,
caveat:
verdict === "safe"
verdict === "not-observed"
? "No malicious behavior was OBSERVED in this run. This does NOT clear the package. " +

@@ -539,3 +695,4 @@ "Sandbox-aware malware stays dormant when it detects analysis and activates only on a real target, by fingerprinting: " +

limits:
"HTTPS bodies are not inspected (CONNECT destination host recorded, no MITM); raw-socket/dgram/non-proxied egress is not captured; " +
"HTTPS bodies are not inspected (CONNECT destination host recorded, no MITM); plaintext/base64/hex/url-encoded canary tokens are matched but compressed or encrypted exfil bodies are not; " +
`${wrapperInfo.netConfined !== true ? "raw-socket/dgram/non-proxied egress can still leave (net shared to keep the proxy reachable)" : "non-loopback egress is denied at the OS boundary (raw-socket egress blocked, not just unobserved)"}; ` +
`process isolation level: ${wrapperInfo.level}. Absence of a finding is not evidence of safety.`

@@ -551,8 +708,10 @@ };

matchTokens,
tokenVariants,
detectSandboxWrapper,
makeRunId,
DECOY_SPECS,
// exported for tests: process-group kill + timeout runner
// exported for tests: process-group kill + timeout runner + resource caps
killProcessGroup,
execWithTimeout
execWithTimeout,
buildRlimitPrefix
};