Skip to content

git-agentic/pkg-registry

Sentinel

ci codeql license

An agent-auditable security layer for the npm ecosystem. Phase 1 is a transparent auditing proxy that sits in front of registry.npmjs.org: it resolves and serves real packages unchanged, but intercepts every tarball, scores its contents with a deterministic audit engine, and attaches a verdict — so an AI agent or a human can see the risk before install-time code runs.

Why: agents now npm install and execute untrusted code with zero risk signaling. npm can't retract bad releases, has no install-time permissions, and lets attackers squat names. Sentinel started as that audit-proxy wedge and has since grown the rest of the layer: signed per-enterprise policy and an install-time permission manifest, a deny-by-default capability sandbox (macOS Seatbelt / Linux bubblewrap) that enforces it, offline signature + Sigstore-provenance verification, known-malicious/known-vulnerable (GHSA/CVE) detection, supply-chain identity heuristics, whole-tree lockfile auditing with CycloneDX SBOM + signed VSA attestations, a CI-native GitHub Action, an agent-native MCP surface, durable history/observability, and a hardened network trust boundary + resource limits. See the phase log in CLAUDE.md and the decision log in docs/adr/.

See ARCHITECTURE.md for the full design (proxy, sync-vs-async audit placement, data model, npm hooks, stack justification).

Status

Pre-1.0. The auditing proxy, policy gate, deny-by-default install sandbox (macOS Seatbelt / Linux bubblewrap), CLI, MCP server, and GitHub Action work end-to-end and are covered by the full test suite (Linux CI on Node 22 and 24; macOS Seatbelt enforcement is exercised on maintainers' machines) — but this has not yet been hardened by production use, and APIs may change without notice. The complete phase-by-phase build log lives in docs/adr/ (one ADR per phase). No npm packages are published yet: build from source (Quickstart below). Threat model: sentinel-threat-model.md · Homepage: git-agentic.com/sentinel


Quickstart

npm install          # install workspace deps
npm run build        # compile all packages (tsc --build)
npm run fixtures     # pack the test fixtures into real .tgz tarballs
npm test             # engine + end-to-end proxy — see CLAUDE.md for the current count and skip breakdown
npm run demo         # self-contained malware-detection demo (no network)

npm run demo boots the proxy in-process against local fixtures and prints the pre-install verdict panels for a benign package and a trojaned patch release, ending with the 403 an installer would receive:

══ 3. Trojaned patch release — color-stream@1.4.1 (diff vs 1.4.0) ═
  color-stream@1.4.1
  ────────────────────────────────────────────────────────
  install    ⚠ runs lifecycle scripts
  score      ░░░░░░░░░░ 0/100
  verdict    BLOCK
  findings (7)
  critical [install-scripts] `postinstall` … reads environment variables, decodes an encoded blob.
  critical [secret-exfil]    Reads sensitive material (…~/.npmrc, AWS credentials…) and contains a network egress sink…
  high     [network-egress]  connects to a hardcoded IP address.
  high     [obfuscation]     uses eval().
  …
══ 4. What `npm install` sees when it fetches the bad tarball ════
  HTTP 403  x-sentinel-verdict: block  x-sentinel-score: 0

Run the proxy against real npm

npm run build
node packages/proxy/dist/index.js          # observe policy, npm upstream, :4873
# open the dashboard:
open http://localhost:4873/

Audit a real package before installing (no install, no execution):

node packages/cli/dist/index.js audit is-odd 3.0.1
#   is-odd@3.0.1  → score 100/100  ALLOW  (signed, no install scripts)

Route a real install through the proxy (every tarball in the tree is audited):

node packages/cli/dist/index.js install lodash
# == npm install --registry http://localhost:4873 lodash

Environment

Var Default Meaning
SENTINEL_UPSTREAM npm npm (real registry) or fixtures[:dir] (local, hermetic)
SENTINEL_POLICY observe observe = audit + serve + headers; block = 403 on a block verdict
SENTINEL_PORT 4873 proxy port (verdaccio's conventional local-registry port)
SENTINEL_REGISTRY https://registry.npmjs.org upstream registry when in npm mode
SENTINEL_STORE (memory only) path to a JSON file to persist the audit log
SENTINEL_VIOLATIONS (memory only) path to a JSON file to persist runtime-violation records (quarantine state)
SENTINEL_AUTO_QUARANTINE (unset ⇒ record-only) set to exactly 1 (any other value is treated as off) to let a confirmed runtime-violation report quarantine its integrity (force block at serve time); requires SENTINEL_AUTH_PUBKEY to also be set — a fatal error at startup otherwise, so auto-quarantine is only ever attributable to an authenticated caller (ADR-0040)
SENTINEL_APPROVAL_REQUESTS (memory only) path to a JSON file to persist pending approval requests (MCP sentinel_request_approval and any other caller)
SENTINEL_TRUSTED_ROOT (bundled root) path to a Sigstore trusted_root.json for provenance verification (fatal error on a bad path)
SENTINEL_NPM_ATTESTATION_KEYS (bundled keys) path to an npm publish-attestation keys JSON, used alongside SENTINEL_TRUSTED_ROOT
SENTINEL_AUTH_PUBKEY (unset ⇒ open) path to an Ed25519 public key PEM; when set, gates control-plane mutations behind signed role tokens (see below)
SENTINEL_AUTH_TOKEN (unset) signed role token attached as Authorization: Bearer by the MCP client and sentinel-script-shell on their POST calls
SENTINEL_HISTORY_DB (unset ⇒ disabled) path to a SQLite file (node:sqlite, built-in); enables durable audit/violation history, /-/metrics, /-/history, /-/violations/timeline, sentinel stats/history, and the dashboard's Observability section. Node 24 runs it unflagged; Node 22 needs --experimental-sqlite
SENTINEL_ADVISORIES (unset ⇒ bundled corpus only) path to a JSON Advisory[] file, loaded once at startup (fatal error on an unreadable path); merged with the bundled known-malicious corpus and checked on the public install audit path
SENTINEL_VULNERABILITIES (unset ⇒ bundled corpus only) path to a JSON VulnAdvisory[] file, loaded once at startup (fatal error on an unreadable path, or on a corrupt non-JSON/non-array file); merged with the bundled known-vulnerable-range corpus and checked on the public install audit path
SENTINEL_TARBALL_ORIGINS (registry origin only) comma-separated allowlist of extra bare http(s) origins tarball fetches may target, beyond SENTINEL_REGISTRY's own origin; validated once at startup (fatal error on a malformed entry), and a disallowed origin is never fetched (502)
SENTINEL_PUBLIC_BASE_URL (unset ⇒ loopback-derived) base URL used to rewrite dist.tarball links; when unset, only a loopback request Host (localhost, 127.0.0.0/8, [::1]) may derive it — any other Host is refused with 421
SENTINEL_MAX_TARBALL_BYTES 256 MB byte cap on a single tarball fetch (streamed, content-length + mid-stream enforced); over-cap ⇒ 502
SENTINEL_MAX_PACKUMENT_BYTES 128 MB byte cap on a single packument or attestation fetch; over-cap ⇒ 502 (attestations ⇒ null, fail-open)
SENTINEL_MAX_TREE_PACKAGES 5000 cap on distinct name@version coordinates in a single /-/audit-tree request; over-cap ⇒ 413, no silent truncation
SENTINEL_RATE_LIMIT_RPM (unset ⇒ disabled) requests-per-minute token-bucket cap, keyed by socket remote address, applied to POST /-/audit-tree, GET /-/explain/*, and POST /-/policy/preview; over-limit ⇒ 429 + Retry-After. Install-gate paths are never limited
SENTINEL_MAX_UNPACKED_BYTES 1 GiB cap on total decompressed bytes when extracting a tarball's contents; over-cap aborts extraction mid-stream and the current tarball's audit gets a critical resource-abuse finding (BLOCK)
SENTINEL_MAX_FILE_COUNT 100000 cap on the number of files unpacked from a tarball; over-cap aborts extraction mid-stream the same way as the byte cap

CLI

sentinel audit <pkg> [version]   pre-install verdict panel (exit 0 allow / 1 warn / 2 block)
sentinel explain <pkg> <version> per-finding remediation, a suggested last-known-good version, and a ready waiver
sentinel scan  <file.tgz>        audit a local tarball offline (no proxy)
sentinel audit-tree [lockfile]   audit an entire resolved tree (npm/yarn/pnpm); exit non-zero if gated
  --sbom <file>                    write a CycloneDX 1.6 SBOM of the audited tree
  --fail-on-error                  also gate when any package fails to audit (default off)
  --omit <type>                    omit a dependency group (only 'dev' is supported)
sentinel install [args…]         npm install routed through the proxy
sentinel npx     [args…]         npx routed through the proxy
sentinel violations              list runtime violations recorded by the proxy (quarantined builds)
sentinel stats                    durable audit/violation metrics (requires SENTINEL_HISTORY_DB on the proxy)
sentinel history [--verdict --name --limit]   list recorded audits (requires SENTINEL_HISTORY_DB)
sentinel policy init --out <file>                 scaffold a policy file from the built-in default
sentinel policy validate <file>                   parse + lint a policy (non-zero exit iff errors)
sentinel policy preview <file> [-p proxy]         replay audit history under a candidate policy (dry run)
sentinel policy keygen [--out <prefix>]           generate an Ed25519 keypair for signing policies
sentinel policy sign <file> --key <privkey>       write a detached signature over a policy file
sentinel policy verify <file> --pubkey <pubkey>   verify a policy's signature and print its summary
sentinel token keygen --out <prefix>              generate an Ed25519 keypair for control-plane auth
sentinel token mint --role --sub --ttl --key       mint a signed role token (prints to stdout)
sentinel token verify <token> --pubkey             verify a token, print role/sub/exp or the rejection reason
sentinel attest-keygen --out <prefix>              generate an Ed25519 keypair for attestation signing
sentinel attest [lockfile] --key --out [--sbom]    audit the tree, write an SBOM, sign a DSSE attestation over it
sentinel verify-attestation <att> --key [--sbom --policy-hash --require]   offline-verify an attestation (deploy gate)
  -p, --proxy <url>   proxy base URL (default http://localhost:4873)
  --json              raw JSON report

The exit codes make sentinel audit usable as an agent tool or a CI gate.

Whole-tree audit (Phase 7, ADR-0020; ecosystem breadth + SBOM, Phase 14, ADR-0027)

sentinel audit-tree [lockfile] audits every resolved package in a lockfile in one pass and exits non-zero if the aggregate verdict trips the policy's treeGate (default block). It auto-detects the format — package-lock.json/ npm-shrinkwrap.json (npm v2/v3), yarn.lock (v1 text or berry YAML), and pnpm-lock.yaml (v5/v6/v9) — by filename first, falling back to a content sniff.

  • --sbom <file> writes the audited tree as a CycloneDX 1.6 JSON BOM: one library component per package (purl: pkg:npm/<name>@<version>) carrying Sentinel's verdict/score/top-finding as sentinel:* properties. Written even when the tree is gated — it's informational output, not the gate itself.
  • The proxy cross-checks each lockfile-pinned integrity against the hash it actually recomputed from the served bytes (Phase 9); a mismatch force-blocks that row, surfaces a lockfile-integrity-mismatch finding, and is counted in the aggregate — but only when both sides are present and disagree (an absent integrity, e.g. yarn-berry's non-SRI checksum, never false-flags).
  • --fail-on-error opts the tree into gating on unresolvable-package rows too (default: error rows are surfaced but never gate, per ADR-0020's fail-open stance).
  • --omit dev skips dev dependencies where the lockfile format records them.

Explain & remediation (Phase 18, ADR-0031)

sentinel explain <package> <version> answers "how do I get green?" for a warn/block verdict:

$ sentinel explain color-stream 1.0.0

  color-stream@1.0.0  —  BLOCK  0/100

  block — 2 finding(s); see the actions below or waive with the recorded rationale.

  • secret-exfil (critical) — Reads credentials/tokens and may exfiltrate them.
      Do not install until reviewed. If this is a false positive, waive with a
      recorded rationale; otherwise remove the dependency.
  • network-egress (high) — Makes network connections.
      Confirm the egress is expected for this package's purpose; if not,
      remove it or pin to a version without it.

  ✓ suggested: pin to color-stream@0.9.0 — the most recent clean release (96/100).

  To waive after review:
      sentinel approve color-stream 1.0.0 --reason "<state your review rationale>"

It calls the proxy's GET /-/explain/:pkg/:version, which audits the version, runs the pure remediate() guidance mapping over the report, and walks back a bounded window (newest of ≤10 prior versions) for the last one that itself audits allow — the "suggested safe version" line. Prior versions come from the private store for a claimed namespace and from public npm otherwise (same isClaimed split as every other route — a claimed name never round-trips to public npm). The route is off the inline install-gate path, since it's expected to be slower than a plain audit — up to ~11 audits per call (integrity-cached, so repeats are cheap), so rate-limit or authenticate it if the proxy is reachable beyond a trusted network.

Remediation surfaces in two more places without a separate explain call:

  • The audit-tree PR comment gets a how to fix column next to each flagged package (remediationHint(ruleId)), plus a footer pointing at sentinel explain for the full detail.
  • The MCP server's sentinel_explain tool (see below) returns the same { report, remediation, lastKnownGood } shape for an agent host.

Advisory only — nothing here rewrites a lockfile, package.json, or auto-selects a version. sentinel explain and the PR-comment hint only ever suggest; a human (or an agent through the existing approval-request path) still decides. See ADR-0031.

Signed audit attestations (Phase 19, ADR-0032)

sentinel audit-tree gates a CI job; nothing survives past that job as a portable artifact a later, independent step can check offline. Phase 19 adds a signed, SLSA-VSA-flavored attestation over an audited tree, for a deploy-time gate:

# once, offline: generate a signing keypair (keep sentinel-attest.key.pem secret)
sentinel attest-keygen --out sentinel-attest

# in CI, after the tree passes audit-tree: produce an SBOM + a signed attestation over it
sentinel attest package-lock.json --key sentinel-attest.key.pem --out audit.att.json --sbom sbom.json

# later, offline, in a deploy pipeline — pin the *public* key, never the private one
sentinel verify-attestation audit.att.json --key sentinel-attest.pub.pem \
  --sbom sbom.json --require allow
# ✓ valid · verdict allow · policy <hash> · 2026-07-08T...
# (or) ✗ attestation rejected: verdict-block   → exits non-zero

The attestation is a DSSE envelope around an in-toto Statement v1: its subject is the SHA-256 digest of the CycloneDX SBOM written alongside it (--sbom, ADR-0027), and its predicate (https://sentinel.dev/attestation/audit-summary/v1) carries the verdict, gate decision, per-verdict counts, the scoring-time policy hash, and a timestamp — enough to gate a deploy without re-running the audit or re-fetching the full per-package report. Signing is Ed25519, done entirely in the CLI on whatever machine runs sentinel attest (typically CI); the proxy holds no signing key and gains no new mutating route — its only change is exposing the policyHash it already computed on the /-/audit-tree response, so --policy-hash can pin an attestation to the policy that produced it. verify-attestation is pure, offline, and fail-closed: a tampered envelope, a wrong SBOM, a policy-hash mismatch, or a verdict below --require all reject with a distinct reason and a non-zero exit — never a silent pass.

This is a VSA-style artifact (a DSSE/in-toto envelope, in the spirit of SLSA's Verification Summary Attestation) rather than a spec SLSA VSA: the predicate type is Sentinel-owned, so a generic DSSE/in-toto tool can check the signature, but a SLSA-aware verifier expecting the standard predicate shape won't recognize it without adaptation. Note the command is sentinel attest-keygen, not sentinel attest keygen — they're sibling top-level commands (a commander-15 quirk with nested requiredOptions made a true subcommand impractical). See ADR-0032.

Sandbox — default-deny (Phases 3–5, 25, 28, 29; macOS Seatbelt / Linux bubblewrap)

sentinel run-scripts <package-dir> [--approve network:host …] runs the package's lifecycle scripts under a kernel sandbox generated from its approved capabilities — createSandbox() selects Seatbelt on macOS and bubblewrap on Linux, same capability model and fail-closed contract. As of Phase 25 the sandbox is deny-by-default (ADR-0038):

  • Writes are denied outside a fixed floor (the install dir, the OS temp dir, /dev, and the node build caches ~/.node-gyp / ~/.cache/node-gyp / ~/.npm/_logs) plus operator-approved filesystem: grants — killing the persistence/tamper class, not an enumerated list.
  • $HOME reads are denied by default, re-allowing only what a lifecycle script needs: system paths, the node install prefix (so a node-under-$HOME nvm/fnm/volta runtime still loads its stdlib), the project root (so require() resolves), and the build caches — closing credential theft as a whole class. /etc/passwd//etc/shadow stay denied via the SENSITIVE_PATHS carve-out.
  • Network egress is denied unless a network capability is approved.
  • Environment secrets are fail-closed scrubbed (Phase 4): a credential-looking env var (NPM_TOKEN, AWS_SECRET_ACCESS_KEY, …) never reaches the script unless approved with an env:NAME capability. The secret-exfil audit rule additionally flags env reads at scoring time.
  • Exec (macOS, Phase 28) is denied outside a fixed floor — system dirs, the node prefix, the project tree, Apple/Homebrew toolchains — plus approved process: grants (process:curl lifts one tool's carve-out; process:/path opens a path; process:* lifts the carve-out only), and exfil-capable tools (curl, wget, nc, …) are re-denied inside the floor unless granted. A dropped binary in /tmp or a cache is kernel-denied; a binary the package writes into its own project tree can still exec there (the floor includes the project root), mitigated by the unscanned-content finding and process capability scoring, not kernel denial.
  • Exec (Linux, Phase 29) has no floor equivalent — bwrap cannot path-gate exec at all (no noexec mount option) — but the same exfil-capable tools (curl, wget, nc, …) are individually exec-denied by masking each with --ro-bind /dev/null <path> unless a process: Grant lifts it. A binary dropped into a writable location can still exec on Linux (still fs+network confined). A true cross-platform floor needs Landlock (a native syscall piece), deferred pre-1.0 — issue #8 stays open (ADR-0043).

A denied credential read surfaces as a confirmed runtime violation on Seatbelt (EPERM); on bubblewrap the read is contained (a --tmpfs mask yields ENOENT) but not classified — an accepted telemetry asymmetry (ADR-0023). Both backends contain; only the telemetry differs.

Runtime violation telemetry (Phase 10)

The sandbox has always contained a denied capability; it now also reports it. When an enforced lifecycle script's denied read or network egress surfaces as a process failure, sentinel-script-shell best-effort reports the detected violation to the proxy, which quarantines that exact tarball (by integrity) fleet-wide — every future serve of the same bytes comes back block, on top of the deterministic score, without ever mutating the cached audit. This is best-effort: a denial the package's own code silently swallows (process exits 0) leaves no signal for telemetry, but it is still denied at the kernel level exactly as before — containment is unaffected either way.

  • sentinel violations [--json] — list recorded violations and their quarantine state.
  • POST /-/violations — report a violation { name, version, integrity, kind, target, confidence, deniedResource, evidence }; confirmed quarantines and revokes any standing approval, suspected is record-only. Requires the integrity to already have an audited report.
  • GET /-/violations — the 50 most recent violation records.
  • DELETE /-/violations/:integrity — clear a quarantine.
  • x-sentinel-violations response header on every served tarball (1/0).

Durable history + observability (Phase 15)

Set SENTINEL_HISTORY_DB=<path> on the proxy to turn on a durable, queryable store of every audit and violation, over the built-in node:sqlite — no new dependency. It's opt-in: leave the var unset and nothing changes (no node:sqlite import, same in-memory-only behavior as before). The store write-throughs beside the existing in-memory cache; it's best-effort, so a history-store failure never breaks a record or the audit gate.

SENTINEL_HISTORY_DB=./sentinel-history.db node packages/proxy/dist/index.js
  • GET /-/metrics{ summary, trends, topFlagged } (verdict/signature/ provenance/violation/quarantine counts, a daily allow/warn/block trend, and the most-flagged package names).
  • GET /-/history?verdict=&name=&limit=&offset= — paginated, filterable audit rows.
  • GET /-/violations/timeline — the recorded violation stream, most recent first, with quarantine status.
  • All three return 501 { enabled: false } when SENTINEL_HISTORY_DB is unset, never a silent empty response.
  • sentinel stats and sentinel history [--verdict --name --limit] render the same data on the CLI; both print "history not enabled — set SENTINEL_HISTORY_DB on the proxy" if the endpoint 501s.
  • The dashboard's Observability section renders a verdict-trend chart, a top-flagged list, and a violation timeline, and degrades to a note when history isn't enabled.

Node version note: node:sqlite runs unflagged on Node 24. On Node 22 you need --experimental-sqlite if you set SENTINEL_HISTORY_DB; leaving it unset keeps Node 22 fully supported with no flag. See ADR-0028.

Policy authoring + impact preview (Phase 20)

Authoring an EnterprisePolicy is hand-edited JSON — Phase 20 adds a lint and a dry-run impact preview so an operator can catch a broken value and see what a candidate change does before signing it:

# 1. scaffold a starting point from the built-in default
sentinel policy init --out policy.json

# 2. edit weights/thresholds/namespaces, then lint it — CI-gate: non-zero exit iff there are errors
sentinel policy validate policy.json

# 3. see what the candidate would change against real audit history (requires
#    SENTINEL_HISTORY_DB on the proxy — see "Durable history" above)
sentinel policy preview policy.json -p http://localhost:4873

# 4. once it looks right, sign it as before
sentinel policy sign policy.json --key sentinel-policy.key.pem
  • sentinel policy validate <file> parses and runs lintPolicy: errors (a policy an operator should not sign — an inverted threshold, an invalid severity, a package in both allow and deny, …) fail the command; warnings (legal but suspicious — non-monotonic weights, an aggressively low hardBlockSeverity, …) print but exit 0, so validate is a clean CI gate that doesn't block on advisory noise.
  • sentinel policy preview <file> [-p proxy] POSTs the candidate to POST /-/policy/preview, which re-scores every audit in HistoryDb under the candidate through the same deterministic score() the live gate uses, and prints the verdict-transition counts (e.g. "3 allow→block, 1 warn→allow") plus the worst-affected packages. It's a dry run — the candidate is never applied to the live proxy, stored, or signed by this command. No SENTINEL_HISTORY_DB on the proxy ⇒ prints "history not enabled" instead of an error.
  • Preview is a read: it needs a running proxy but no auth token, same as every other read route.

See ADR-0033.

Control-plane authentication (Phase 12)

The control plane's mutating endpoints — approvals, violation reports and clears, and publish — are unauthenticated by default (open mode). Setting SENTINEL_AUTH_PUBKEY on the proxy turns on signed-role-token auth for those routes; everything else (every GET, tarball fetches, packument resolution, and POST /-/audit-tree) stays open in either mode.

Tokens are stateless, offline-verifiable Ed25519 signatures over { role, sub, iat, exp } — no server-side session store, no token database. Generate a keypair, mint a token, and verify it:

sentinel token keygen --out ./auth              # writes auth.pub.pem, auth.key.pem (0600)
sentinel token mint --role operator --sub alice --ttl 3600 --key ./auth.key.pem
# eyJyb2xlIjoib3BlcmF0b3IiLCJzdWIiOiJhbGljZSIsImlhdCI6...  .  c2ln...
sentinel token verify <token> --pubkey ./auth.pub.pem
# valid  role=operator  sub=alice  exp=2026-07-07T15:00:00.000Z

Run the proxy with auth enabled:

SENTINEL_AUTH_PUBKEY=./auth.pub.pem node packages/proxy/dist/index.js

Role → endpoint map (enforced only when SENTINEL_AUTH_PUBKEY is set):

Route Required role
POST /-/approvals operator
DELETE /-/approvals/:integrity operator
DELETE /-/violations/:integrity operator
POST /-/approval-requests agent
POST /-/violations agent
PUT /:pkg (publish) publisher

A missing/malformed/expired/badly-signed token is 401; a well-formed token with the wrong role for the route is 403. Reads are never gated.

Clients: sentinel-mcp (ProxyClient) and sentinel-script-shell both read SENTINEL_AUTH_TOKEN from the environment and attach it as Authorization: Bearer <token> on their POST calls (agent role) — reads stay unauthenticated even with a token set. The dashboard has an "operator token" field (persisted to localStorage) that attaches the same header to its Approve/Deny/Revoke actions, so a human operator can drive the gate when auth is enabled.

This is what makes ADR-0024's "the agent can request, only a human can grant" boundary a hard guarantee rather than an absent tool: with auth on, an agent-role token presented to POST /-/approvals now gets a 403, no matter which client sends the request. See ADR-0025.

MCP server (Phase 11)

sentinel-mcp is a stdio MCP server for agent hosts that speak MCP directly instead of shelling out to the CLI. It is a thin client to the running proxy — it audits nothing itself and does zero scoring; every tool call is a fetch to the proxy's /-/* endpoints, so the verdict an agent sees is byte-identical to what a real install would see. If the proxy is unreachable, a tool call fails explicitly — it never fabricates a verdict.

Point an agent host at it (e.g. in an MCP client config):

{
  "mcpServers": {
    "sentinel": {
      "command": "node",
      "args": ["packages/mcp/dist/index.js"],
      "env": { "SENTINEL_PROXY": "http://localhost:4873" }
    }
  }
}

Tools:

  • sentinel_audit — verdict/score/findings/capabilities/signature/provenance for a package version, plus whether it's quarantined by a runtime violation.
  • sentinel_audit_tree — audits every package in a package-lock.json and returns the aggregate verdict and gate state.
  • sentinel_capabilities — the capability manifest, delta vs. the prior version, and approval state.
  • sentinel_check_provenance — provenance status and, when verified, the attested build identity (repo/workflow/builder/commit).
  • sentinel_list_violations — recorded runtime violations and which builds are quarantined.
  • sentinel_explain (Phase 18) — per-finding remediation actions, a suggested last-known-good version, and a ready approval-request payload for a package version. Same { report, remediation, lastKnownGood } shape as sentinel explain/GET /-/explain; advisory only.
  • sentinel_request_approval — records a pending approval request; it never grants approval. Only a human can approve, via the dashboard's "Pending approval requests" panel or POST /-/approvals.

The privilege boundary is deliberate: the agent can request, never grant. There is no auto-approve or clear-quarantine tool, and none is planned — see ADR-0024.

  • POST /-/approval-requests — record a pending request { name, version, integrity, reason, requestedBy? }. Requires the integrity to already have an audited report.
  • GET /-/approval-requests — the 50 most recent pending requests.
  • A POST /-/approvals decision for an integrity auto-clears its pending request.

GitHub Action (Phase 17)

@sentinel/action (bin sentinel-ci) is a self-contained on-ramp into pull requests — it needs no separately-running proxy. runCi self-boots the proxy in-process against real npm, audits your lockfile through the same /-/audit-tree route the CLI uses, writes a CycloneDX SBOM, and posts the verdict to the PR.

# .github/workflows/sentinel.yml
name: Sentinel
on: { pull_request: {} }
permissions: { contents: read, pull-requests: write }
jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ./   # this action is not yet published to the Marketplace
        with:
          fail-on: block

Inputs:

Input Default Description
lockfile auto-detect Path to package-lock.json / yarn.lock / pnpm-lock.yaml
policy built-in DEFAULT_POLICY Path to a signed enterprise policy file
policy-pubkey Path to the policy signer's public key PEM (required with policy)
policy-sig <policy>.sig Path to the policy signature file
omit-dev false Omit dev dependencies from the audit
sbom-path sentinel-sbom.json Where to write the CycloneDX SBOM
fail-on block block | warn | none — the verdict level that fails the check
comment true Post/update a PR comment with the verdict
working-directory . Directory to audit

Outputs: verdict, gated, blocked, warned, errored, sbom-path.

Every run uploads the SBOM as a build artifact (if: always(), so it's attached even on a blocked run) and, on a pull_request event, posts or updates a single PR comment — found by a hidden <!-- sentinel-report --> marker so re-runs edit the same comment instead of piling up new ones — with the verdict, per-package findings table, and provenance summary.

Onboarding path: start with fail-on: none — the audit runs, the SBOM uploads, and the PR comment appears, but nothing blocks a merge — then move to fail-on: warn and finally the fail-on: block default once the team is ready to enforce. See ADR-0030.

Under fail-on: block/warn, a package that fails to resolve/audit (e.g. a transient npm outage) becomes an error row that gates the tree (fail-closed) — so a transient registry outage can fail the check; use fail-on: none (observe) to avoid this during onboarding.


How the malware demo works (and why it's synthetic)

The malicious fixture, fixtures/malicious/color-stream, reproduces the event-stream / ua-parser-js pattern: a previously-clean package (1.4.0) ships a patch (1.4.1) that adds a postinstall hook which harvests environment secrets and ~/.npmrc, decodes an obfuscated base64 blob, evals it, and exfiltrates over HTTPS to a hardcoded IP.

It is inert test data — never executed by the suite (the engine only reads it as text), the egress IP is in the RFC 5737 documentation range, and it carries a SYNTHETIC FIXTURE header. We use a synthetic payload on purpose: the real historical malware (flatmap-stream@0.1.1) was unpublished from npm after the incident (only a 0.0.1-security placeholder remains), so it can't be fetched — which is itself one of the problems Sentinel exists to address.

The engine was also validated against the live npm registry:

Package (real npm) Result
is-odd@3.0.1 100/100 ALLOW — signed, no install scripts
esbuild@0.19.0 BLOCK — flags its network-touching postinstall (a real, legitimate-but-reviewable install hook)
flatmap-stream@0.1.1 unresolvable — unpublished after the event-stream incident

esbuild is a deliberate example of a true positive that needs policy: its postinstall is legitimate, and Phase 2's per-enterprise allowlisting is how you'd clear it without weakening detection.


Project layout

packages/
  core/    @sentinel/core   audit engine — rules, scoring, data model, LLM adapter (no I/O, fully unit-tested)
  proxy/   @sentinel/proxy  Express registry proxy, pluggable upstream, audit store, dashboard
  cli/     @sentinel/cli    pre-install verdicts + registry-redirected npm/npx
  mcp/     @sentinel/mcp    sentinel-mcp: stdio MCP server, thin client to the proxy (Phase 11)
  action/  @sentinel/action sentinel-ci: self-boots the proxy for GitHub Actions (Phase 17)
fixtures/  benign + synthetic-malicious packages; make-fixtures.ts packs real .tgz tarballs
scripts/   make-fixtures.ts, demo.ts
ARCHITECTURE.md   full design   ·   CLAUDE.md   working agreement for this repo

Scoring

Deterministic: start at 100, subtract weighted penalties per finding, clamp [0,100]. ≥80 allow · 50–79 warn · <50 block; any critical finding forces block. Files changed in a release are weighted 1.6× (diff-audit). The score is produced entirely by the heuristic rules so it is reproducible in CI; the LLM adapter only adds human-readable context in the async-enrich phase, never the score. Weights live in one policy object (packages/core/src/score.ts).

Supply-chain identity signals (Phase 13)

Two name-only checks catch the older, non-code attack class — a malicious package published under a name close enough to trick a human or an automated install:

  • Typosquat detection — a pure rule flags a package name that's an edit-distance/homoglyph near-match of a name in a bundled, static popular-package corpus (e.g. expres vs express). medium severity.
  • Dependency-confusion detection — a score-time check flags a public package name that's a look-alike of one of your claimed privateNamespaces (the same field that gates private-store serving) — the signal only Sentinel can produce, since it's the only layer here that holds your namespace claims. high severity. Never flags the legitimate claimed package itself.

Both are weighted findings that raise the score, not automatic blocks — see ADR-0026.

Maintainer & release-anomaly signals (Phase 16)

Every rule through Phase 13 scores a release in isolation. Phase 16 adds cross-version context (a ReleaseContext derived from the packument's own time/maintainers history — no new network call) and four weighted metadata signals that compound with everything else:

  • Maintainer change — none of the previous version's maintainers remain ⇒ high (possible account/ownership takeover); the set changed but at least one previous maintainer remains ⇒ low.
  • Dormancy resurrection — the package was silent ≥365 days before this release ⇒ low.
  • New-package risk — a first-ever published version that already runs install scripts ⇒ medium.
  • Capability novelty — this release adds a network/process capability the immediately-previous version didn't have ⇒ medium.

All four are inert without the underlying packument history (e.g. a private-store package) and none is a standalone hard block — see ADR-0029.

Known-advisory (known-malicious) detection (Phase 21)

Every rule through Phase 16 infers risk from behavior, identity, or release history. Phase 21 adds the one signal none of them provide: a check against already-confirmed-malicious package versions.

  • Bundled corpuspackages/core/src/advisory-corpus.ts ships a static, offline snapshot of real, publicly-documented compromised npm releases (e.g. event-stream@3.3.6, ua-parser-js@0.7.29) with their GHSA advisory ids. Metadata only, never fetched at audit time.
  • known-advisory rule — an exact (name, version) match against the bundled corpus (or an operator-supplied advisory) emits a critical metadata finding by default, naming the advisory id — this hard-blocks under the default policy.
  • Bring your own advisory list — set SENTINEL_ADVISORIES to a path to a JSON Advisory[] file ({ name, version, id, severity?, reference? }[]) on the proxy; it's read once at startup and merged with the bundled corpus, so an unreadable file fails the process closed rather than silently running without your entries.
  • Refreshing the bundled corpus — the bundled snapshot goes stale as new malicious releases are discovered. npm run advisories -- --in <export.json> (scripts/make-advisories.ts) transforms a local OSV/GHSA "malicious-packages" export into a ready-to-paste KNOWN_ADVISORIES array; it does not fetch anything itself — see the script's header comment for the expected input shape and source pointers.

Exact-match only (no semver ranges yet); the one finding type Sentinel's own remediation guidance tells you not to waive — see ADR-0034.

Known-vulnerability (SCA) detection (Phase 22)

known-advisory (above) catches confirmed-malicious releases by exact version. Phase 22 adds the far more common case: a legitimate package with a publicly-disclosed CVE affecting a range of versions — full software composition analysis (SCA), not just malware detection.

  • Bundled corpuspackages/core/src/vuln-corpus.ts ships a static, offline snapshot of real npm CVEs (lodash, minimist, axios, node-fetch, ws) with their affected semver ranges, CVSS-derived severity, advisory id, and fixed version(s). Metadata only, never fetched at audit time.
  • known-vulnerability rule — matches the audited version against any range in the bundled corpus (or an operator-supplied vulnerability) via semver.satisfies. Each match emits a finding at the advisory's own faithful severity — a critical CVE hard-blocks under the default policy exactly like any other critical finding, tunable via the same hardBlockSeverity/allow/rule-disable/treeGate levers as everything else (no new policy field).
  • Bring your own vuln feed — set SENTINEL_VULNERABILITIES to a path to a JSON VulnAdvisory[] file ({ name, ranges, severity, id, fixedIn?, reference? }[]) on the proxy; it's read once at startup and merged with the bundled corpus — an unreadable or corrupt file fails the process closed rather than silently running without your entries.
  • Refreshing the bundled corpus — the bundled snapshot goes stale as new CVEs are disclosed. npm run vulns -- --in <export.json> (scripts/make-vulns.ts) transforms a local OSV/GHSA export into a ready-to-paste KNOWN_VULNERABILITIES array; it does not fetch anything itself — see the script's header comment for the expected input shape and source pointers.
  • audit-tree gains a vulnerabilities count of packages carrying a known-vulnerability finding, alongside its existing verdict/provenance/ integrity-mismatch counts.

See ADR-0035.

Phase log

The complete phase-by-phase log lives in CLAUDE.md and docs/adr/; highlights: Phase 1 is the transparent auditing proxy. Phase 2 adds the install-time permission manifest + approval gate, signed per-enterprise policy, and the private-namespace registry. Phases 3–6 add cross-platform sandbox enforcement (macOS Seatbelt, Linux bubblewrap) up through sentinel install --enforce, which sandboxes every lifecycle script in the tree. Phase 7 adds sentinel audit-tree, a whole-tree lockfile gate: it audits every package in a package-lock.json through the proxy and exits non-zero if the aggregate verdict trips the policy's treeGate. Phase 8 verifies the npm registry signature offline (ECDSA P-256/SHA-256/DER against a configured key set) and surfaces signature/provenance status on every audit; a policy can require a verified signature or present provenance for matching package names. Phase 9 deep-verifies build provenance: real Sigstore attestation bundles are checked offline against pinned trust material, provenance becomes verified|invalid|absent|unknown with subject-digest binding to the actual served bytes, and a policy can require a verified attestation from a specific repository, workflow, or builder for matching package names. Phase 10 turns the enforcing sandbox into a sensor: a denied capability that surfaces as a process failure is classified and reported to the proxy, which quarantines that exact tarball fleet-wide (sentinel violations, /-/violations) as a serve-time overlay — the cached, deterministic score is never touched, and a denial the package silently swallows is still contained, just not visible to telemetry. Phase 11 adds sentinel-mcp, a stdio MCP server that is a thin client to the running proxy: five read tools plus sentinel_request_approval, which only ever records a pending request (/-/approval-requests) for a human to approve — the agent requests, never grants. Phase 14 broadens audit-tree beyond npm: parseAnyLockfile also reads yarn.lock (v1 and berry) and pnpm-lock.yaml, --sbom <file> writes a CycloneDX 1.6 BOM of the audited tree, a lockfile-vs-served integrity cross-check force-blocks a coordinate whose pinned integrity disagrees with what's actually served, and --fail-on-error opts the tree into gating on unresolvable packages. Phase 18 adds actionable remediation: sentinel explain <package> <version> (and the MCP sentinel_explain tool, and a "how to fix" column on the PR comment) turns a warn/block verdict into per-finding guidance, a suggested last-known-good version, and a ready waiver — advisory only, never auto-fixing a lockfile. Phase 19 adds signed audit attestations: sentinel attest signs a DSSE/ in-toto envelope (Ed25519, VSA-style) over an audited tree's SBOM digest, and sentinel verify-attestation checks it offline against a pinned key — a portable, deploy-time gate that survives past the CI job that produced it. Signing is operator-side in the CLI; the proxy only exposes the scoring-time policy hash the attestation binds to. Phase 20 adds policy authoring + impact preview: a pure lintPolicy catches broken (errors) and suspicious (warnings) policy values before signing, and sentinel policy preview replays the proxy's audit history under a candidate policy through the same deterministic scorer to show the verdict deltas — a dry run, never applied or signed by the preview itself. Phase 21 adds known-advisory detection: a bundled, static corpus of publicly-documented known-malicious npm releases hard-blocks an exact version match by default, with an operator-supplied SENTINEL_ADVISORIES file merged in at proxy startup. Phase 22 adds known-vulnerability (semver-range CVE) detection over a bundled offline corpus. Phase 23 hardens the network trust boundary: outbound tarball fetches are pinned to allowlisted origins and packument tarball rewrites use a configured public base URL instead of trusting the Host header. Phase 24 adds resource robustness — fetch byte caps, audit-tree dedupe + a package cap, request coalescing, and an opt-in rate limiter. Phase 25 flips the sandbox to deny-by-default: writes and $HOME reads are denied unless a fixed floor or an approved capability grant re-opens them. See ARCHITECTURE.md for the full design and docs/adr/ for the decision log.

License

Apache-2.0.

About

Agent-auditable security layer for npm — auditing proxy, deny-by-default install sandbox, provenance & signature verification, SBOM + signed attestations, MCP surface.

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors