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 installand 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).
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
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
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| 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 |
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.
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: onelibrarycomponent per package (purl: pkg:npm/<name>@<version>) carrying Sentinel's verdict/score/top-finding assentinel:*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-mismatchfinding, 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-erroropts the tree into gating on unresolvable-package rows too (default:errorrows are surfaced but never gate, per ADR-0020's fail-open stance).--omit devskips dev dependencies where the lockfile format records them.
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-treePR comment gets a how to fix column next to each flagged package (remediationHint(ruleId)), plus a footer pointing atsentinel explainfor the full detail. - The MCP server's
sentinel_explaintool (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.
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-zeroThe 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.
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-approvedfilesystem:grants — killing the persistence/tamper class, not an enumerated list. $HOMEreads are denied by default, re-allowing only what a lifecycle script needs: system paths, the node install prefix (so a node-under-$HOMEnvm/fnm/volta runtime still loads its stdlib), the project root (sorequire()resolves), and the build caches — closing credential theft as a whole class./etc/passwd//etc/shadowstay denied via theSENSITIVE_PATHScarve-out.- Network egress is denied unless a
networkcapability 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 anenv:NAMEcapability. Thesecret-exfilaudit 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:curllifts one tool's carve-out;process:/pathopens 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/tmpor 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 theunscanned-contentfinding andprocesscapability scoring, not kernel denial. - Exec (Linux, Phase 29) has no floor equivalent — bwrap cannot path-gate exec
at all (no
noexecmount option) — but the same exfil-capable tools (curl,wget,nc, …) are individually exec-denied by masking each with--ro-bind /dev/null <path>unless aprocess: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.
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 };confirmedquarantines and revokes any standing approval,suspectedis 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-violationsresponse header on every served tarball (1/0).
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.jsGET /-/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 }whenSENTINEL_HISTORY_DBis unset, never a silent empty response. sentinel statsandsentinel 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.
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.pemsentinel policy validate <file>parses and runslintPolicy: errors (a policy an operator should not sign — an inverted threshold, an invalid severity, a package in bothallowanddeny, …) fail the command; warnings (legal but suspicious — non-monotonic weights, an aggressively lowhardBlockSeverity, …) print but exit0, sovalidateis a clean CI gate that doesn't block on advisory noise.sentinel policy preview <file> [-p proxy]POSTs the candidate toPOST /-/policy/preview, which re-scores every audit inHistoryDbunder the candidate through the same deterministicscore()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. NoSENTINEL_HISTORY_DBon 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.
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.000ZRun the proxy with auth enabled:
SENTINEL_AUTH_PUBKEY=./auth.pub.pem node packages/proxy/dist/index.jsRole → 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.
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 apackage-lock.jsonand 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 assentinel 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 orPOST /-/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 /-/approvalsdecision for an integrity auto-clears its pending request.
@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: blockInputs:
| 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.
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.
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
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).
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.
expresvsexpress).mediumseverity. - 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.highseverity. Never flags the legitimate claimed package itself.
Both are weighted findings that raise the score, not automatic blocks — see ADR-0026.
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/processcapability 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.
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 corpus —
packages/core/src/advisory-corpus.tsships 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-advisoryrule — an exact(name, version)match against the bundled corpus (or an operator-supplied advisory) emits acriticalmetadatafinding by default, naming the advisory id — this hard-blocks under the default policy.- Bring your own advisory list — set
SENTINEL_ADVISORIESto a path to a JSONAdvisory[]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-pasteKNOWN_ADVISORIESarray; 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-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 corpus —
packages/core/src/vuln-corpus.tsships 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-vulnerabilityrule — matches the audited version against any range in the bundled corpus (or an operator-supplied vulnerability) viasemver.satisfies. Each match emits a finding at the advisory's own faithful severity — acriticalCVE hard-blocks under the default policy exactly like any other critical finding, tunable via the samehardBlockSeverity/allow/rule-disable/treeGatelevers as everything else (no new policy field).- Bring your own vuln feed — set
SENTINEL_VULNERABILITIESto a path to a JSONVulnAdvisory[]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-pasteKNOWN_VULNERABILITIESarray; it does not fetch anything itself — see the script's header comment for the expected input shape and source pointers. audit-treegains avulnerabilitiescount of packages carrying a known-vulnerability finding, alongside its existing verdict/provenance/ integrity-mismatch counts.
See ADR-0035.
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.
Apache-2.0.