Security & audit
Defense in depth — script policy, behavioral analysis, OSV, provenance, typosquatting, the triage gate.
LPM'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 |
| Provenance + cooldown | Recently-published or publisher-identity-drift attacks | At install time |
| 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)
lpm install does not run lifecycle scripts (preinstall, install, postinstall) by default. The pipeline materializes the dep closure, then stops. To execute 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 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 — packages that declare any of these get flagged even if the current 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 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
Two install-time gates that catch attacks against the publisher identity itself. Both apply to lpm install and lpm install -g.
Minimum release age — newly-published versions can't install for 24 hours by default. Defends against the "race a malicious version into a maintained dep" attack pattern. 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. On -g the project-level tier is N/A (the synthesized package.json doesn't carry lpm.minimumReleaseAge); the chain collapses to CLI flag > ~/.lpm/config.toml > 24h default.
Provenance drift — when a previously-approved package is re-resolved, LPM 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.
--allow-new and --ignore-provenance-drift[-all] are independent — --allow-new only skips the cooldown; drift still applies unless you also waive it.
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 keeps the cooldown axis orthogonal to the script-tier axis — 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 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 runs typosquatting detection against a curated list of popular npm packages using Levenshtein distance. Warns, doesn't block — false positives are easy and the cost of a bad block is high.
Operates entirely offline.
Skill content (lpm.dev only)
Packages on lpm.dev 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 packages today (no per-tarball skills declaration yet).
CI integration
The recommended CI gate stack:
- run: lpm install --offline --strict-integrity
- 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 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 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 approve-scripts— script-trust workflowlpm trust— drift inspectionlpm install --policy— script policy overridepackage.json"lpm.scripts" — capability + sandbox config