Skip to content

Sticky-dep UX: say when the existing tree, not this command, blocks an install#105

Merged
juangaitanv merged 1 commit into
install-vuln-gatefrom
ivg/u8-refusal-context
Jun 11, 2026
Merged

Sticky-dep UX: say when the existing tree, not this command, blocks an install#105
juangaitanv merged 1 commit into
install-vuln-gatefrom
ivg/u8-refusal-context

Conversation

@juangaitanv

Copy link
Copy Markdown
Contributor

What

When corgea <pm> install blocks and every vulnerable finding lives in the resolved tree beyond the named targets — and no named target is unverifiable — the refusal line now reads:

Refusing to run install: your existing dependency tree has known-vulnerable packages (none were added by this command). Fix them or pass --force.

A finding on a named target (vulnerable or unverifiable, fail-closed) keeps the generic refusal. The text summary line also attributes tree findings: 1 vulnerable (1 from existing tree).

Why

Dogfood friction: installing a clean package on top of a lockfile with a pre-existing vulnerable transitive dep produced a refusal that implied the package the user just typed was at fault.

How

  • PrecheckReport gains named_vulnerable_count / named_unverifiable_count / tree_vulnerable_count / tree_unverifiable_count; the existing totals are now sums of those. No new structs, no provenance labels — detection is named outcomes vs tree findings.
  • print_refusal picks the message; should_block_install semantics (force/no-fail, fail-closed) are untouched, as is the exit code and JSON output.
  • Summary line is byte-identical to before whenever the tree contributed no findings.

Tests

New hermetic e2e file tests/cli_refusal_context.rs (fake pip on a private PATH + inline pypi registry stub + in-crate vuln-api stub):

  • transitive-only vulnerable → new refusal text + summary attribution, pip never runs
  • named vulnerable → generic refusal, no tree attribution
  • named unverifiable (503) + transitive vulnerable → generic refusal (the command's own target is part of the block)

./harness check clean: clippy strict, fmt, 255 tests, deps-skill drift. Staging smoke skipped per unit recipe (covered by stub tests).

When every vulnerable finding sits in the resolved tree beyond the named
targets (and no named target is unverifiable), the refusal now says the
existing dependency tree is the problem instead of implying the package
the user typed is at fault. The text summary line gains a
"(N from existing tree)" parenthetical on the vulnerable/unverifiable
counts when the tree contributed findings. Messaging only —
should_block_install semantics are unchanged.
Comment thread src/precheck/mod.rs
let named_findings = report.named_vulnerable_count() + report.named_unverifiable_count();
if report.vulnerable_count() > 0 && named_findings == 0 {
eprintln!(
"Refusing to run install: your existing dependency tree has known-vulnerable packages (none were added by this command). Fix them or pass --force."

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This message is not backed by the data the tree pass carries. TreeReport::Full is populated from the resolver’s full would-install set, and apply_verdicts classifies every package that does not match a parsed named (name, version) as transitive (src/precheck/mod.rs:557-588), not as pre-existing. Pip -r makes this worse: tree::covers_input allows the tree pass with no parsed targets (src/precheck/tree.rs:20-23), so outcomes is empty and every package from the requirements file is counted as tree-only even though the command is adding it. The new refusal and summary_segment text can therefore say “existing dependency tree” / “none were added by this command” / “from existing tree” for packages introduced by the install itself. Impact: supported installs get misleading remediation and users may chase unrelated existing dependencies. Fix by either tracking actual pre-existing packages (for example by comparing against the current lock/environment or preserving resolver metadata that distinguishes requested/new/existing) before using this wording, or soften the copy to something the current data supports, such as “resolved dependency tree” / “not a named command-line target.” Add coverage for pip install -r ... and for a vulnerable newly introduced transitive dependency.

Comment thread src/precheck/mod.rs
/// stays with `should_block_install`.
fn print_refusal(report: &PrecheckReport) {
let named_findings = report.named_vulnerable_count() + report.named_unverifiable_count();
if report.vulnerable_count() > 0 && named_findings == 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The guard ignores named recency blockers. should_block_install still blocks on (!opts.no_fail && report.recent_count() > 0) (src/precheck/mod.rs:620-626), but print_refusal only excludes named vulnerable/unverifiable findings. If the named package is recent and the tree also has a vulnerable finding, this branch emits the “existing tree” refusal even though the command’s own target is part of the block, and “Fix them or pass --force” omits that --no-fail or waiting out the threshold is also needed after fixing the vuln. Fix by passing opts into print_refusal and treating a blocking recent_count as a named/command-caused finding, or render an explicit combined message. Add a regression with a recent named target plus a tree vulnerability.

@juangaitanv juangaitanv merged commit d51d165 into install-vuln-gate Jun 11, 2026
17 checks passed
@juangaitanv juangaitanv deleted the ivg/u8-refusal-context branch June 11, 2026 07:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant