Security & audit
Defense in depth — script policy, behavioral analysis, OSV, provenance, typosquatting, the triage gate.
LPM CLI's security model is layered. No single check is the answer; instead, several independent gates each catch a different class of threat. This page is the conceptual overview — what each layer does, how they compose, and where to dig deeper.
The CLI surfaces are lpm audit (broad report), lpm query (precision selectors), lpm approve-scripts (script trust), and lpm trust (drift inspection). All of them read from the same underlying analysis pipeline.
The five layers
| Layer | What it catches | Where it runs |
|---|---|---|
| Lifecycle script gate | Postinstall script supply-chain attacks | At install time, blocks until approval |
| Behavioral analysis | Risky API usage, obfuscation, telemetry, license issues | At extract time (cached forever per (name, version)) |
| Vulnerability data | Known CVEs from OSV.dev + registry advisories | At lpm audit time |
| Publisher trust + cooldown | Recently-published direct versions, registry-signature failures, trust downgrades, or publisher-identity drift | At install time and via lpm audit signatures |
| Triage | Tiered automation for the script gate | At install time, per-package |
Each layer is independent — a package can pass one and fail another. Most attacks in the wild trip more than one.
Layer 1: Lifecycle script gate (default-deny)
Bare lpm install runs the root project's install lifecycle, including prepare. Dependency package lifecycle scripts (preinstall, install, postinstall) do not run by default. To execute dependency scripts, you either:
- Approve them explicitly via
lpm approve-scripts— the package gets recorded inpackage.json > lpm > trustedDependencies(project) or~/.lpm/global/trusted-dependencies.json(--global) with its integrity + script hash. - Opt out per-invocation with
lpm install --policy=allow(or--yolo) — runs everything, no gate. Works onlpm install -gtoo. - Opt out persistently with
package.json > lpm > scriptPolicy: "allow"or~/.lpm/config.toml > script-policy = "allow". The user-config tier applies to both project and global installs.
Approvals are strict: bound to {name, version, integrity, scriptHash}. A republished version with different bytes invalidates the approval and re-opens the package for review. This is what makes the deny-by-default model usable across long-running projects — trust is for a specific build, not a name.
The dependency lifecycle phases that actually run during lpm install's build pipeline are preinstall, install, postinstall. The broader BLOCKED set (which detection scans for) also includes preuninstall, uninstall, postuninstall, prepare, and prepublishOnly — dependency packages that declare any of these get flagged even if the current dependency pipeline doesn't execute them.
Layer 2: Behavioral analysis
The lpm-security crate's behavioral module performs static analysis on every extracted package. Results are cached in .lpm-security.json next to the package files — computed once per (name, version), forever.
Three groups of tags — source behavior (what the code does), supply-chain signals (what the artifact looks like), and manifest declarations (what package.json declares):
Source tags — what the source code does:
| Tag | Detects |
|---|---|
:eval | Use eval, Function(), or vm.runInThisContext |
:network | Make outbound HTTP / WS connections |
:fs | Touch the filesystem outside their own directory |
:shell | Spawn shells (spawn, exec, execSync) |
:child-process | Use child_process (any form) |
:native | Ship native modules (.node, .wasm) |
:crypto | Use cryptographic primitives |
:dynamic-require | Use dynamic require() (variable arg) |
:env | Read process.env |
:ws | Use WebSockets |
Supply-chain tags — what the artifact looks like:
| Tag | Detects |
|---|---|
:obfuscated | Show signs of code obfuscation |
:high-entropy | Contain high-entropy string blobs |
:minified | Ship minified-only source |
:telemetry | Make telemetry / analytics calls |
:url-strings | Contain URL string literals |
:trivial | Tiny — measured by AST node count |
:protestware | Match a curated list of known protest-license / sabotage packages |
Manifest tags — what the package.json declares:
| Tag | Detects |
|---|---|
:git-dep | Installed from a git URL |
:http-dep | Installed from an HTTP tarball URL |
:wildcard-dep | Declared with * or latest |
:copyleft | Copyleft license (GPL family) |
:no-license | No license field |
Plus state tags driven by other layers (:scripts, :built, :vulnerable, :deprecated, :lpm, :npm, :critical/:high/:medium/:info).
Performance design
RegexSet+OnceLockfor compile-once, single-pass matching across all behavioral patterns.- All regex uses Rust's
regexcrate (Thompson NFA, linear-time guarantees). Thefancy-regexcrate is banned for security analysis — backtracking against untrusted input is a ReDoS vector. - File extension filter before I/O (skip non-source files).
- Per-file size cap: 2MB. Per-package total: 50MB scanned. Per-package file count: 5,000.
- Shannon entropy pre-filter skips ~95% of files before the expensive extraction pass.
Target: < 100ms per typical 100-file package.
Querying the tags
lpm audit # broad report — all tags grouped
lpm query :eval # one tag
lpm query :scripts:not(:built) # combinator: scripted but never built
lpm query :critical --assert-none # CI gate: fail if any critical-tagged packagelpm query is the precision tool — exposes the same data the audit pipeline produces, with combinators for ad-hoc gates.
Layer 3: Vulnerability data (OSV)
lpm audit cross-references every installed package against OSV.dev (for npm packages) and any LPM.dev Registry-side advisories (for @lpm.dev packages). Findings are surfaced inline with the behavioral report. Severity ladder: critical > high > moderate (alias medium) > info (alias low).
lpm audit # all findings
lpm audit --level high # only high-severity vulnerabilities
lpm audit --fail-on vuln # CI: fail on confirmed vulns only (not behavior)The vulnerability check is online — it hits OSV/registry. Cache hits keep the cost low on repeat runs.
Layer 4: Provenance + cooldown
Install-time and audit-time gates that catch attacks against the publisher identity and registry metadata itself. They apply to both lpm install and lpm install -g unless noted.
Minimum release age — newly-published dependency versions are avoided for 24 hours by default. On project installs, the default policy checks direct/root dependencies only. For ranges on checked packages, the resolver skips too-new matching versions and picks the newest older candidate that still satisfies the range. Exact pins to a too-new version fail. Transitive dependencies of an allowed direct package are not separately halted by the default install cooldown. Opt into strict scope with package.json > lpm > minimumReleaseAgePolicy: "strict" or ~/.lpm/config.toml > release-age-policy = "strict" to check direct and transitive dependencies and revalidate lockfile replays from persisted publish timestamps. Commands that select one package version directly, such as lpm install -g, lpm dlx, lpm upgrade, and lpm outdated, apply the same policy to the package version being selected or reported. Configurable per-invocation (--min-release-age=<DUR>), per-project (package.json > lpm > minimumReleaseAge), or per-user (~/.lpm/config.toml > minimum-release-age-secs). Bypass for one install with --allow-new, or exempt exact canonical package names with --min-release-age-exclude <pkg>, package.json > lpm > minimumReleaseAgeExclude, or ~/.lpm/config.toml > minimum-release-age-exclude; exclude lists merge instead of replacing each other. On -g the project-level tier is N/A (the synthesized package.json doesn't carry lpm.minimumReleaseAge, lpm.minimumReleaseAgePolicy, or lpm.minimumReleaseAgeExclude); the cooldown window chain collapses to CLI flag > ~/.lpm/config.toml > 24h default.
Registry signatures — npm packages can carry detached registry signatures in dist.signatures. Run lpm audit signatures to verify the installed tree on demand. Install-time verification is disabled by default; enable it with:
lpm config signatures --set trueor for one process:
LPM_VERIFY_REGISTRY_SIGNATURES=1 lpm installWhen enabled on install, LPM CLI verifies registry signatures after resolution and before the install commits. A missing signature, missing integrity hash, missing signing key, expired key, or invalid signature fails the install for npm registry packages. @lpm.dev/* packages and non-registry sources are skipped by this specific check and still pass through the other layers.
Trust policy — optional no-downgrade policy for npm trust evidence:
lpm config trust-policy --set no-downgradeWhen enabled, LPM CLI enforces two history floors. During resolution, it refuses a release whose npm trusted-publisher or staged-publish evidence is weaker than an earlier published release; ranges can select an older acceptable version, while an exact downgraded pin fails. After selection, verified lockfile history adds an artifact-level floor: once any locked version has artifact-bound Sigstore evidence, a later selected version must also verify. An absent attestation, unavailable attestation, invalid bundle, frozen lockfile without matching evidence, or disabled/skipped verifier fails instead of allowing provenance to disappear. This artifact-level rule takes precedence over verify=warn, verify=off, per-package verification skips, and best-effort availability; disable trust-policy itself if you intentionally want to permit a downgrade. Registry attestation pointers alone count toward neither floor. The default remains off.
Provenance drift — when a previously-approved package is re-resolved, LPM CLI compares the candidate version's Sigstore provenance against the snapshot captured at approval time:
(now, approved)
(Some(n), Some(a)) if n == a → pass
(Some(_), Some(_)) → drift! identity changed
(None, Some(_)) → drift! provenance dropped
(Some(_), None) → OK — present now, wasn't at approval
(None, None) → never had it; layers 1/2/4 decideA drift event blocks the install. Re-approve via lpm approve-scripts (or lpm approve-scripts --global) to capture the new identity, or opt out per-package with --ignore-provenance-drift <pkg> / blanket-waive with --ignore-provenance-drift-all. The reference snapshot lives in package.json > lpm > trustedDependencies > <pkg>@<ver> > provenanceAtApproval for project trust and in ~/.lpm/global/trusted-dependencies.json for global trust.
The verifier binds the signed in-toto subject to the exact npm package URL (pkg:npm/<name>@<version>) and the package tarball's SHA-512 integrity. It also verifies the Sigstore certificate chain, transparency-log inclusion, publisher/workflow identity, and bundle digest. Registry metadata that merely points at an attestation is not proof.
Attestation URLs must be HTTPS and match the package registry origin. Redirects are checked at every hop and cannot change origin or downgrade HTTPS. The provenance cache stores the original bundle bytes, not a writable “verified” result; every cache hit reruns cryptographic verification and artifact binding. A malformed or locally modified cache entry is discarded and fetched again.
Verified evidence is written sparsely to lpm.lock, including the publisher/workflow snapshot, subject purl and SHA-512, transparency-log identity/index/time, and bundle SHA-256. Frozen and offline installs validate that evidence against the locked package name, version, source, and integrity before replay. The lockfile contains derived evidence rather than the original Sigstore bundle, so frozen replay cannot independently re-prove the publisher and transparency-log claims after a malicious lockfile edit. Treat lpm.lock as trusted, reviewable project input; online cache hits are different because the cache retains the original bundle and reruns verification.
To broaden or tighten verification explicitly:
lpm config sigstore --set scope=all
lpm config sigstore --set availability=strictscope=all checks every resolved package; the default scope=approved limits checks to packages with an approved provenance identity. availability=strict requires an attestation; the default best-effort preserves missing/temporarily unavailable attestations as absence or unknown without blocking. A present bundle that fails verification is still rejected by the default verify=deny posture.
--allow-new, registry signatures, trust-policy, and --ignore-provenance-drift[-all] are independent. --allow-new only skips the cooldown; signature verification and drift still apply unless their own controls are disabled or waived.
The cooldown threshold also gates the Layer 5 identity widening — a recent-publish package whose script body matches an identity-Green shape still stays Amber until the publish age clears the cooldown. The two axes are independently bypassable: --allow-new skips the cooldown halt, --policy=allow skips the script-tier review.
Layer 5: Triage
The tiered gate that lets some scripted packages auto-approve. Active under script-policy: "triage" (or lpm install --triage / --policy=triage).
| Tier | Behavior |
|---|---|
| Green | A curated catalog of shape patterns (node-gyp rebuild, tsc, prisma generate, husky install, etc.) plus identity-matched delegating installers — a node install.js / node postinstall.js / node scripts/install.js body (+ .cjs/.mjs variants) Greens when the package's repository URL or a bin entry plainly names the package. Auto-approves and runs in the filesystem sandbox. |
| Amber | Doesn't fit a green pattern, doesn't match a red. Defers to layers 2/3/4 (trust manifest, provenance + cooldown, optional LLM advisor). Network binary downloaders (puppeteer install, playwright install, cypress install) live here by design. |
| Red | Hand-curated blocklist (pipe-to-shell, base64-decode-to-execute, nested package-manager installs, etc.). Blocks unconditionally. Never reaches the advisor. |
Worst-wins reducer across phases — a package whose preinstall is green but postinstall is red is classified as red.
Identity widening is shape-keyed, not name-keyed: there is no per-package allowlist. The classifier looks at the script body's structure (exactly node <reserved>.{js,cjs,mjs}), reads the manifest's repository URL and bin entries, and matches by the package's base name (case-insensitive, with .git suffixes stripped). Generic / too-short base names (anything ≤ 2 chars, plus js, lib, core, node, src, util, utils) carry no identity payload and never widen.
Cooldown defense-in-depth. Identity widening refuses to fire when the configured minimum release age is above zero AND the package's publish age is below the threshold (or unknown). This classifier gate is separate from the direct/root install halt — lpm install --allow-new <pkg> bypasses the cooldown halt but a recent-publish package whose identity matches still stays Amber and surfaces in the script-tier review. To opt out of both, set minimum-release-age to 0 (universally disabling cooldown also disables the defense-in-depth check) or use --policy=allow.
The sandbox itself is Seatbelt on macOS, landlock on Linux, AppContainer on Windows. Scripts can read the project, write to their own package directory, and write to anything declared in package.json > lpm > scripts.sandboxWriteDirs. Anything else is denied. Capability-widening (extra env vars, full project read, looser rlimits) requires explicit approval.
Putting it together — script-policy × sandbox-mode
The two axes are independent. script-policy decides whether a script runs; sandbox-mode decides what it can do once running. Every combination is valid and configured through separate precedence chains.
| script-policy | sandbox-mode | What happens to a scripted package |
|---|---|---|
deny | default | Script blocked at the script-policy gate. After lpm approve-scripts: runs with filesystem + env containment, outbound network allowed. |
deny | strict | Same gate. After approval: runs with containment + outbound network denied. |
deny | none | Same gate. After approval: runs unsandboxed (full host access including credential env). |
triage | default | L1-L3 tier decides per package. Greens auto-run with containment + network allowed. Ambers go to the lpm approve-scripts queue. Reds blocked permanently. |
triage | strict | Same tiering. Whatever runs runs with containment + outbound network denied. |
triage | none | Same tiering. Whatever runs runs unsandboxed. |
allow | default | Every script runs with containment + network allowed. |
allow | strict | Every script runs with containment + outbound network denied. Trust-the-lockfile + deny-install-time-network shape. |
allow | none | Every script runs unsandboxed — equivalent to npm install. |
Each axis is configured independently:
script-policy—package.json > lpm > scriptPolicy(project),~/.lpm/config.toml > script-policy(user), CLI flag (--policy=<v>/--yolo/--triage), or thelpm config scriptswizard.sandbox-mode—./lpm.toml > [sandbox] mode(project) or~/.lpm/config.toml > [sandbox] mode(user), CLI flag (--strict-sandbox/--paranoid/--no-sandbox),LPM_STRICT_SANDBOX=1env, or thelpm config sandboxwizard.
lpm approve-scripts grants permission to run — it never escalates the sandbox. An approved script under sandbox-mode = strict still runs without network access; an approved script under sandbox-mode = none runs with full host access.
Optional LLM advisor
Layer 5 includes an opt-in advisor that uplifts Amber packages to AutoRun for the current install only. Configure via triage-advisor:
| Value | Provider |
|---|---|
"none" (default) | No advisor — portable layers 1-4 only. |
"claude-cli" | Claude CLI — local subprocess call. |
"codex" | OpenAI Codex CLI — local subprocess call. |
"ollama" | Ollama-served local model — local HTTP call. |
script-policy = "triage"
triage-advisor = "claude-cli"{ "lpm": { "scriptPolicy": "triage", "triageAdvisor": "claude-cli" } }The two keys are independent. script-policy: "triage" with triage-advisor: "none" is the portable L1-4 baseline. script-policy: "deny" with any advisor setting still blocks unconditionally — the advisor never runs outside triage.
Precedence for triage-advisor (highest first): package.json > lpm > triageAdvisor > ~/.lpm/config.toml > triage-advisor > default "none".
Properties:
- Ephemeral. Approvals never write to disk as trust state. A second
lpm installinvokes the advisor again — the verdict is non-deterministic and LPM CLI does not pretend otherwise. Strict bindings vialpm approve-scriptsexist for the "approve once, remember forever" use case. - Source-aware identity. Approvals key on
(name, version, integrity)so a registry copy of a package can never inherit approval from a workspace copy of the same coordinates. - Degrade-and-warn. Missing binary, timeout, unparseable response → install continues with one stderr warning. Never fails on the advisor itself.
- Prompt-injection defense. Untrusted script body is fenced with a per-call random nonce; the verdict parser strict-matches
Approveand loose-matches only the safe-default verdicts. - Worst-of-phases per package. Approve only if every amber phase classified Approve; one Manual or Abstain blocks the whole package.
Prompt context
The advisor sees a fixed template — role framing, verdict-format reminder, and a structured context block:
| Field | Source | Notes |
|---|---|---|
Package | name@version | Always present. |
Repository: | package.json > repository (URL or shorthand) | Emitted only when the manifest carries a non-empty value. Treated as untrusted hint — pairs with the body's actual behaviour, never approves on URL alone. |
Lifecycle phase | preinstall / install / postinstall | Always present. |
Script body | The phase's exact package.json value | Fenced with <<UNTRUSTED-SCRIPT-BEGIN-{nonce}>> markers using a per-call random nonce. |
Referenced files | Files the body delegates to (e.g. install.js when the body is node install.js) | Each file fenced with its own per-file nonce. Caps: depth 1 (no recursive require following), ≤ 32 KB per file (truncated with explicit marker), safe-relative paths only (no .., abs paths, env-var expansion), text-only (binary files fall back to no-context Amber). |
Verdict cache
Advisor verdicts are persisted at $LPM_HOME/cache/l4-verdicts.json. The next install of the same (name, version) with byte-identical script bodies skips the advisor round-trip and replays the cached verdict.
The cache key folds in the script identity, the prompt template hash, the provider slug, and the model version. Any of those changing produces a different key and the next invocation re-classifies — there is no manual invalidation step. Cached verdicts expire after 30 days by default. Hits return the cached Approve / Manual / Abstain value verbatim; the cache does not promote anything.
Tunables (env vars):
LPM_L4_CACHE=0— disable the cache entirely. Useful for measurement runs where the comparison must include the round-trip cost.LPM_L4_CACHE_PATH=<path>— override the cache file location.LPM_L4_CACHE_TTL_SECS=<n>— override the 30-day default TTL.
Set up interactively:
lpm config scripts # pick deny / triage / allow
lpm config triage # pick none / claude-cli / codex / ollamaBoth wizards accept --set <value> for non-interactive use.
Typosquatting
Independent of the other layers, lpm install and lpm add run typosquatting detection against a curated list of popular npm packages. The detector covers edit-distance typos, adjacent transpositions (axois → axios), delimiter variants (crossenv → cross-env), and flag-shaped package names after --.
The check fires only when a suspicious direct package name is newly entering the project. Existing lockfile replays, including CI=true frozen installs, do not fail because the name was already reviewed into lpm.lock. Interactive terminals can switch to the suggested package or write a committed lpm.toml allow-list entry; --json, CI, non-TTY, and --yes runs fail closed with error_code: "typosquat_suspected".
Operates entirely offline. See lpm.toml typosquat policy for intentional exceptions.
npm firewall verdicts
LPM Firewall is an opt-in verdict check at firewall.lpm.dev for selected public npm package versions. It does not proxy npm metadata or tarballs: LPM CLI still fetches from the normal npm registry path, then batches selected (name, version) checks with LPM Firewall before tarballs are allowed through.
LPM Firewall is an LPM.dev Registry Pro/Org feature. Active monitor and enforce modes send LPM.dev Registry auth, so run lpm login first. Entitlement failures are warnings in monitor mode and blocking failures in enforce mode.
Configure it globally:
lpm config firewall --set monitor # show what would block and continue
lpm config firewall --set enforce # block malicious verdictsoff is the default. monitor is useful while evaluating coverage; enforce is the blocking mode. The legacy string report is still accepted as an alias for monitor. Once monitor or enforce mode is part of the approved machine posture, disabling or downshifting it is a guarded weakening through lpm security.
LPM Firewall also carries policy metadata for configurable enforcement. Recommended enforcement blocks trusted public malicious advisories and LPM Firewall AI-confirmed malware; it warns for LPM Firewall AI-agent control-surface policy, critical vulnerabilities, and static-only suspicious signals. See the Firewall for npm guide for the full policy profile.
Skill content (LPM.dev Registry only)
Packages on LPM.dev Registry that ship agent skills get a separate LLM security scan against the skill content at publish time. Consumers receive attested skill markdown — drift in the skill body invalidates the attestation.
This layer doesn't exist for non-LPM.dev Registry packages today (no per-tarball skills declaration yet).
CI integration
The recommended CI gate stack:
- run: lpm install --offline --strict-integrity
- run: lpm audit signatures
- run: lpm trust diff --assert-none
- run: lpm audit --fail-on vuln # confirmed vulns
- run: lpm audit --secrets --fail-on secrets # hardcoded credentials
- run: lpm licenses --fail-on copyleft --deny GPL-3.0 # license policy
- run: lpm query ":vulnerable:not(:built)" --assert-none
- run: lpm query ":eval:scripts" --assert-none # eval + scripts is a yellow flaglpm audit --fail-on covers the broad-strokes gate; lpm audit signatures checks npm registry signatures; lpm licenses gates license compliance; lpm query --assert-none covers your project's specific concerns; lpm trust diff --assert-none gates reviewed script-trust drift directly. Together they're cheap and they catch most real classes of supply-chain trouble.
See also
lpm audit— broad report CLIlpm query— precision selectorslpm licenses— license inventory and policy gateslpm approve-scripts— script-trust workflowlpm trust— drift inspectionlpm install --policy— script policy overridepackage.json"lpm.scripts" — capability + sandbox config