Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 178 additions & 0 deletions PRD.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
---
date: 2026-06-12
researcher: claude
git_commit: dfac68e
branch: install-vuln-gate
repository: cli
status: draft
tags: [jtbd, spec, install-gate, supply-chain, agents]
last_updated: 2026-06-12
last_updated_by: claude
---

# Gate Package Installs

**Date**: 2026-06-12
**Researcher**: claude
**Git Commit**: dfac68e
**Branch**: install-vuln-gate

## Job Statement

**Full JTBD**: When a coding agent or developer is about to install a package,
I want the install to pass through a gate that blocks vulnerable, malicious,
or suspiciously new versions and steers to a safe one, so I can trust that
development — especially autonomous, agent-driven development — never
introduces a supply-chain compromise.

**Trigger Context**: Agents install packages autonomously with nobody
watching. Developers add dependencies with no check at the decision moment.
Malware campaigns spread through registries in hours, faster than any CI
scan. Security teams have no control point at install time.

**Success Indicators**:
- An agent hits a block, reads the refusal, and installs the safe version —
no human in the loop.
- Gated installs catch real vulnerable/malicious packages; `--force` stays rare.
- Teams and agent sessions gate installs weekly — adoption, not a demo.
- End state: with a token, nothing unverifiable gets through (fail-closed).

## Why restart

The previous attempt (branch `install-vuln-gate`, 60+ commits, ~10.7k lines)
built the right thing but is unreviewable as one PR. Three things ate it:

1. **Scope creep to the full tree.** Named-target gating grew into resolving
the entire would-install set per manager — dry-runs, lockfiles, bare
installs — each with its own quirks.
2. **Edge-case hardening.** Fail-open/fail-closed modes, retries, yanked
releases, PEP 668, constraint files, JSON stdout purity — correctness
tails with no phase boundary to stop at.
3. **Test infra weight.** The vuln-api stub, integration harness, and
fixtures became a project inside the project, built incidentally.

The restart keeps the learnings and the proven modules, and lands the work as
one PR per phase, each with explicit exit criteria.

**Process rule**: one phase = one PR. A phase that can't land as a reviewable
PR is two phases.

## Phases

### Phase 0: Foundation — vuln-api contract + test harness

**Scope**: The vuln-api client and its versioned contract (clean / vulnerable
/ malware / unknown verdicts, remediation data). In-process test stub, gated
out of release builds. Shared integration-test scaffold. Deterministic
staging targets documented (`axios@0.21.0`, `minimist@0.0.8`,
`node-fetch@2.6.0`, `mezzanine==6.0.0`).

**Not Included**: Any user-facing command. Auth/token handling. Retries.

**Entry Criteria**: Staging worker (`cve-worker-staging`) serving
deterministic verdicts.

**Exit Criteria**: Contract tests green against both the stub and staging.
Another phase can write an integration test in under 20 lines of setup.

**Harvest**: `src/vuln_api/mod.rs`, `src/vuln_api_stub/mod.rs`,
`tests/common/mod.rs`, `tests/fixtures/vuln_api/`.

### Phase 1: Core gate (SHIP) — `corgea pip|npm install <named targets>`

**Scope**: Install-verb detection behind global flags. Named-spec parsing
(exact pins and ranges). Registry resolution (PyPI, npm). Two independent
blocks: recency (`-t`, default `2d`) and vuln verdict. Refusal output built
for agent self-correction: per-advisory `fixed in <version>` lines and a
`→ safe version: <name>@<version>` steer. `--force` (override everything),
`--no-fail` (demote recency only). Git/URL/path specs pass through with a
note, never blocked. Non-install subcommands pass straight through. Public
mode only: no token, lookup outages warn and continue (fail-open).
`skills/corgea/SKILL.md` section, including the limitations doc (wrapper, not
an enforcement boundary).

**Not Included**: Transitive/tree resolution. Bare installs, lockfiles,
`npm ci`. `-r requirements.txt` parsing (noted, not gated). `--json`. Token
auth and fail-closed mode. yarn/pnpm/uv. Retry logic.

**Entry Criteria**: Phase 0 merged.

**Exit Criteria**: Dogfood pass — a real agent session with the skill
installed hits a staging-target block and self-corrects to the safe version
unprompted. All deterministic staging targets block with exit 1.

**Harvest**: `src/precheck/parse.rs`, `detect.rs`, `verdict.rs`, `render.rs`
(trimmed to named-target paths), `src/verify_deps/registry.rs`.

### Phase 2: Depth (SHIP) — the full would-install set

**Scope**: pip tree via `pip install --dry-run`; npm tree via isolated
`npm install --package-lock-only` in a temp dir. Bare `npm install` gated
from the nearest `package.json`; `npm ci` gated from the lockfile.
Transitive findings labeled by provenance. Honest named-only fallback with a
printed warning when a dry-run fails or `--prefix`/`-g` redirects the root.
`-r requirements.txt` fallback parsing. Bounded verdict pool (fixed at 8).

**Not Included**: uv/yarn/pnpm. `--json`. Auth.

**Entry Criteria**: Phase 1 shipped and dogfooding.

**Exit Criteria**: A vulnerable transitive dep blocks the install. A
vulnerable lockfile blocks `npm ci`. Fallback warnings fire when and only
when the tree pass didn't run.

**Harvest**: `src/precheck/tree.rs`, `tests/cli_tree.rs`,
`tests/cli_bare_install.rs`, `tests/cli_npm_ci.rs`.

### Phase 3: Breadth + guarantee

**Scope**: Three independent lanes, each its own PR:
1. **uv** — `uv pip install`/`uv add`/`uv pip sync` via `uv pip compile`;
`uv sync` from `uv.lock`. yarn/pnpm named-only with honest ungated notes.
2. **Machine output** — `--json` (stdout purity, `verdict_mode`, `tree`
object, `remediation` field).
3. **Org guarantee** — authenticated fail-closed mode, custom-URL token
opt-in, transient-failure retries, PEP 668 refusal, yanked-release
handling.

**Not Included**: Org policy config, telemetry, registry allow-listing —
future work, separate PRD.

**Entry Criteria**: Phase 2 shipped.

**Exit Criteria**: Per lane. Lane 3's bar: with a token, an unverifiable
package or a vuln-api outage blocks the install.

**Harvest**: `src/precheck/uv.rs`, `tests/cli_uv_sync.rs`,
`tests/cli_verdict.rs` (auth modes), JSON paths in `render.rs`.

## Known dead ends — do not rebuild

Built and deliberately removed in the previous attempt:
- npm audit warn-only second opinion (`ccceb7a`)
- Steer re-verification pass (`e62399c`)
- `--concurrency` flag — fixed pool of 8 instead (`bfc8cf1`)
- Persisted `vuln_api_url` config — env var only (`204fb47`)
- Standalone vuln-api-stub binary — in-process stub instead (`b6c2e83`)

## Open Questions

- Recency default: `2d` carried over from the spike — validate against real
release cadence data before P1 ships.
- Does `--json` pull forward into P1 if agent dogfooding wants structured
output over refusal text?
- Staging worker is the current default vuln-api endpoint — when does the
production worker take over, and who owns its seed data?
- Telemetry (catch rates, `--force` usage) — needed to measure success
indicators, but where does it report? Separate PRD.

## References

- Previous attempt: branch `install-vuln-gate` (head `dfac68e`) — harvest
source and design reference.
- Agent contract: `skills/corgea/SKILL.md` (limitations section at
`dfac68e`).
- Staging verdicts: `https://cve-worker-staging.corgea.workers.dev`
(source: `/Users/juan/Code/corgea/vuln-api`).
- Flag validation pattern to mirror: `src/main.rs` (blast-only flag
rejection).
38 changes: 26 additions & 12 deletions skills/corgea/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ Agent environments default to compact TSV; force output with `--format human|age
Notes: `deps scan --out-format table|json|sarif` is the report/export selector; do not combine it with `deps scan --format`.
<!-- END GENERATED CORGEA DEPS SKILL -->

### Install Wrappers — `corgea pip|npm <args...>`
### Install Wrappers — `corgea pip|npm|yarn|pnpm|uv <args...>`

Run a package manager through Corgea's install gate. Install commands with
named targets are resolved against the public registry first, then gated
Expand All @@ -148,19 +148,27 @@ finds it — nearest ancestor) is gated too: the full lockfile-resolved tree is
verdicted, so a vulnerable lockfile blocks. `npm ci` (and aliases) is gated
from the project lockfile directly.

The vuln check covers the **full would-install set**, not just the named
targets: `pip` and `npm` resolve the complete tree (named + transitive) via a
safe dry-run (`pip install --dry-run …`; an isolated
`npm install --package-lock-only` in a temp dir, never touching your
lockfile); every resolved package is verdicted, so a flagged **transitive**
dependency blocks the install too, labeled by provenance (`(transitive)`,
`(from requirements)`, `(already in package.json)`, `(locked)`). Whenever a
dry-run fails or an npm flag redirects the project root (`--prefix`, `-g`),
the gate falls back to named-only and prints
The vuln check covers the **full would-install set** where the manager has a
safe resolver, not just the named targets: `pip` and `npm` resolve the
complete tree (named + transitive) via a safe dry-run
(`pip install --dry-run …`; an isolated `npm install --package-lock-only` in
a temp dir, never touching your lockfile), and `uv pip install` / `uv add` /
`uv pip sync` resolve theirs via `uv pip compile`; every resolved package is
verdicted, so a flagged **transitive** dependency blocks the install too,
labeled by provenance (`(transitive)`, `(from requirements)`,
`(already in package.json)`, `(locked)`). `uv sync` is gated from `uv.lock`
(found like uv finds it — nearest ancestor). `yarn` and `pnpm` have no safe
dry-run, so they verify the named targets only; bare `yarn`/`pnpm` installs
run unchecked after a stderr note
(`note: bare '<pm> <sub>' is not gated …`). Whenever a dry-run fails or an
npm flag redirects the project root (`--prefix`, `-g`), the gate falls back
to named-only and prints
`warning: transitive dependencies not checked (…); only named packages were verified.`
— for pip, entries of `-r requirements.txt` files are still parsed and
— for pip/uv, entries of `-r requirements.txt` files are still parsed and
verified in that fallback. Verdict requests run in a bounded pool
(8 parallel).
(8 parallel). Running the wrong manager for a project (npm in a pnpm
project, pip in a uv project, …) is refused with a
`Did you mean `corgea …`?` suggestion; `--force` bypasses that guard too.

Wrapper flags (`--force`, `--no-fail`, `-t`) are read between the manager
name and the install verb (`corgea npm --force install x`); flags after the
Expand Down Expand Up @@ -201,6 +209,12 @@ The gate is a wrapper, not an enforcement boundary. By design it cannot catch:
- **Named-only fallback** — when a dry-run fails (old pip, broken resolution)
or `--prefix`/`-g` redirects npm's root, transitive dependencies install
unchecked behind the printed warning.
- **Ungated managers** — bare `yarn`/`pnpm` installs run unchecked (see the
bare-install note above); only their named targets are verified.
- **Ungated uv/yarn subcommands** — `uv run` (project sync on first run,
`--with` packages), `uv tool install`/`uv tool run`, and
`yarn global add` install packages without a gate; each prints an
ungated note instead of passing silently.

Hard enforcement needs org-level controls — lockfile review, registry
allow-listing — alongside the wrapper.
Expand Down
15 changes: 15 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,8 +201,14 @@ enum Commands {
},
/// Wrap `npm` commands: gate install targets on recency + vuln verdicts, then run npm.
Npm(InstallWrapArgs),
/// Wrap `yarn` commands: gate install targets on recency + vuln verdicts, then run yarn.
Yarn(InstallWrapArgs),
/// Wrap `pnpm` commands: gate install targets on recency + vuln verdicts, then run pnpm.
Pnpm(InstallWrapArgs),
/// Wrap `pip` commands: gate install targets on recency + vuln verdicts, then run pip.
Pip(InstallWrapArgs),
/// Wrap `uv` commands: gate install targets on recency + vuln verdicts, then run uv.
Uv(InstallWrapArgs),
}

/// Shared flags for the install-wrapper subcommands (`corgea npm|pip`).
Expand Down Expand Up @@ -560,9 +566,18 @@ fn main() {
Some(Commands::Npm(args)) => {
run_install_wrap_command(corgea::precheck::PackageManager::Npm, args)
}
Some(Commands::Yarn(args)) => {
run_install_wrap_command(corgea::precheck::PackageManager::Yarn, args)
}
Some(Commands::Pnpm(args)) => {
run_install_wrap_command(corgea::precheck::PackageManager::Pnpm, args)
}
Some(Commands::Pip(args)) => {
run_install_wrap_command(corgea::precheck::PackageManager::Pip, args)
}
Some(Commands::Uv(args)) => {
run_install_wrap_command(corgea::precheck::PackageManager::Uv, args)
}
None => {
if let Some(message) = corgea::precheck::pip3_alias_message(&cli.args) {
eprintln!("{message}");
Expand Down
Loading
Loading