# ADR-0013: Phase 7d.1 — pip / PyPI supply chain audit

**Status:** Accepted
**Date:** 2026-05-11
**Related:** ADR-0012 (Phase 7d npm supply chain), ADR-0011 (Phase 7e.2 firewall), ADR-0010 (Phase 7f container)

## Context

Phase 7d (v0.12.0) shipped the first supply-chain audit Check
(`supply_chain_audit`) targeting npm. The same template applies to
other ecosystems but each one has its own "where do we look" question.
Unlike OpenClaw's npm install (a known fixed location reachable via
`PlatformProfile.paths["npm_root"]`), Python footprint on a typical
audited host has no canonical location: OpenClaw itself is npm-based,
operator-deployed Python services live wherever the operator put them,
and the host's system Python is a maze of distro-managed packages.

Spec: `docs/superpowers/specs/2026-05-11-phase-7d1-pip-supply-chain-design.md`.

## Decisions

### D1. Lockfile paths come from operator config, not `PlatformProfile`

Operator lists each `requirements.txt` to scan in
`ServerAuditConfig.pip_lock_paths` (or repeats `--pip-lock-path` on the
standalone `agentsec server-audit` command). Empty list →
`pip_supply_chain_audit` reports `not_applicable`. No path is hardcoded.

**Considered alternatives:**
- (a) Add `pip_root` key to `PlatformProfile` like `npm_root` —
  presupposes a canonical Python location which doesn't exist.
- (b) Scan the host's system pip with `pip list --format=json` —
  expands attack surface (executes `pip` CLI; CLI maintainer could
  introduce vulnerable subcommands; output format drifts).

**Rationale:** Operator config = consent. Zero-config defaults to no
findings, no false positives, no surprises. Operators who want pip
coverage explicitly enumerate the lockfiles they trust.

### D2. Format = `requirements.txt` only (frozen / `==`-pinned)

Parser only recognizes `pkg==version` lines. Skips `-e` editable, `-r`
recursive, `pkg @ git+url` VCS pins, `~=` / `>=` / `<` non-exact
specifiers, comments, blanks.

**Considered alternatives:**
- (a) Native `Pipfile.lock` / `poetry.lock` / `uv.lock` parsers —
  three more parsers + format-drift maintenance.
- (b) Auto-detect lockfile format by filename extension — high design
  cost without clear leverage.

**Rationale:** Every higher-level lockfile (poetry / pipenv / uv / pdm)
exports to `requirements.txt` (`poetry export`, `pipenv requirements`,
`uv export`, `pdm export`). YAGNI on multi-format until 7d.2 / 7d.3
land and there's evidence operators want it.

### D3. Don't extract `_supply_chain_common.py` yet (rule of three)

The 7d-npm and 7d.1-pip Checks share `severity_from_cvss_score`,
DB-load-and-index pattern, and the 5-axis filter chain. But the version
parser (semver vs PEP-440), range-event extraction (npm allows SEMVER
type only, PyPI uses ECOSYSTEM with PEP-440), and lockfile parser
shapes are all ecosystem-specific.

**Considered alternatives:**
- (a) Extract shared helpers now — locks in a 2-point abstraction
  before the third call site reveals the right interface.

**Rationale:** Rule of three. When 7d.2 (go) lands, the actual common
surface will be visible, and the extraction will reflect three real
implementations rather than two-and-a-guess. CLAUDE.md's Phase 7d
section already promises this trigger.

### D4. Filename suffix whitelist = `{requirements*, .txt, .lock, .in}`

Validator in `ServerAuditConfig.pip_lock_paths` rejects any path whose
filename does not match: ends with `.txt`, `.lock`, `.in`, **OR**
basename starts with `requirements` and has no extension (so
`requirements`, `requirements-prod`, etc.; **but not** `requirements.cfg`
which is pip's own config file and may carry credentials).

**Considered alternatives:**
- (a) No suffix whitelist — rely entirely on operator + metachar guard.
  Operator typo (`/etc/shadow` instead of `~/.../requirements.txt`)
  silently exfils a sensitive file.
- (b) Allow operator to extend suffix list via
  `pip_lock_path_extra_suffixes` config field — complexity with no
  clear demand.

**Rationale:** Defense in depth. Even with operator consent, a typo
shouldn't expose `/etc/shadow`. Four suffixes cover all known lockfile
naming conventions (`pip-tools` writes `.txt`, `poetry export` to `.txt`,
`pdm export` to `.txt`, `pip-compile -o requirements.in` for input).

### D5. PEP-440 matcher includes pre-releases; ignores local-version segments

`packaging.version.Version` is the comparator.
`Version("1.0.0a1") < Version("1.0.0")` so pre-release versions
included in any range that includes their stable. Local-version segments
(`1.0.0+cuda118`) drop in PEP-440 ordering — `1.0.0+cuda` compares
equal to `1.0.0`, so an advisory `fixed: 1.0.0` excludes
`1.0.0+cuda` (the local-tagged build inherits the fix).

**Considered alternatives:**
- (a) Only include pre-release if `introduced` explicitly carries a pre
  marker — operators pinning to alpha/beta versions of vulnerable
  packages would silently miss findings.

**Rationale:** Match `pip`'s own resolution semantics + OSV's intent
(advisories cover all versions in the range; pre-releases are not
implicitly excluded).

### D6. Dynamic `add_runtime_allowed_paths(kind="exact")` injection

CLI calls `policy.add_runtime_allowed_paths(operator_paths,
kind="exact")` after `materialize_paths`. Each operator-supplied path
becomes its own `AllowedPath(kind="exact", path=...)` entry valid only
for the current process lifetime.

**Considered alternatives:**
- (a) Static entries in `ssh_policy.yaml` — operator must hand-edit
  the install-bundled file every time they add a lockfile. Friction
  + drift risk.
- (b) `kind="prefix"` injection — directory entry expands read scope
  beyond the lockfile (e.g., `/srv/myservice/` exposes `secrets.env`
  in the same directory).

**Rationale:** `exact` minimizes blast radius (only the named file
readable). Dynamic injection respects the operator-config-as-consent
model: operator's listed entry IS the consent grant, no second-step.
Persisted audit-log entry on startup keeps the action traceable.

### D7. Side-fix: `OsvNpmFetcher.normalize_osv_entry` `affected[0]` truncation

Pre-existing bug in 7d-npm fetcher: only the first `affected[]` npm
package was kept per OSV entry. Multi-package advisories silently
truncated. Fixed in this Phase as an independent commit (`fix(7d):`),
returning `list[dict] | None` and deduping caller-side on `(id, package)`
instead of bare `id`.

**Rationale:** Discovered while writing the PyPI sibling, which always
fans out per affected package. Fixing both sides now keeps
ecosystem-fetcher behavior consistent and avoids "PyPI vs npm has
mysterious different shape" footguns for future maintainers.

### D8. OSV-PyPI 5-axis filter chain identical to npm

Drop entries that are: withdrawn / no PyPI affected / no
ecosystem-typed range / no CVSS_V3 score / scored medium-low and
`published > 2 years` ago. Critical/high always kept regardless of age.

**Rationale:** Same heuristics as npm worked (3437 entries down from
208K raw). PyPI raw is smaller (~50K) but the same filter shape applies
cleanly.

### D9. TI = single umbrella `TI-OSV-PYPI-CATALOG`

One `threat_intel.yaml` row covers every PyPI advisory. Per-CVE TI rows
would balloon the file by 3000+ entries.

**Rationale:** Same logic as `TI-OSV-NPM-CATALOG`. The actual CVE/GHSA
id surfaces as `evidence.advisory_id` in each Finding, fully traceable.

### D10. Check-side channel = `CheckContext.pip_lock_paths` field

New `tuple[str, ...]` field on `CheckContext` propagated through
`run_server_audit` from a parent ctx populated by the CLI.

**Considered alternatives:**
- (a) Generic `extra_config: dict[str, object]` — loses static typing,
  no IDE help.
- (b) Pull from a global `OpenClawTargetConfig` accessor — breaks the
  Check layer's isolation from top-level config.

**Rationale:** Adding one explicit field per phase is mechanical and
type-safe. Six existing config-shaped fields (`log_review_config`)
already follow this pattern.

## Consequences

- pip supply-chain audit needs operator config; zero-config installs
  see `not_applicable` — true to spec but operators must read docs to
  enable it.
- 7d.2 (go) and 7d.3 (rust) will trigger `_supply_chain_common.py`
  extraction (rule of three). Plan: pull `severity_from_cvss_score`,
  `_is_within_age`, `_extract_severity` into a shared module then.
- npm `affected[]` multi-pkg advisories now expand correctly — the
  bundled `osv_npm.json` regenerated from this fix grew by 236 records
  (3437 → 3673), which means the previous bundle was missing those
  package coverages.
- The wheel grew by ~1 MB (osv_pypi.json bundled). Total wheel size
  sits around 7 MB; cap of 15 MB per CI guard remains comfortable.

## References

- Spec: `docs/superpowers/specs/2026-05-11-phase-7d1-pip-supply-chain-design.md`
- Plan: `docs/superpowers/plans/2026-05-11-phase-7d1-pip-supply-chain.md`
- ADR-0012: 前置 7d-npm 决策（被本 ADR 的 D7 顺手修一个 affected[0] 截断遗留）
