# Phase 7d.1 — pip / PyPI Supply Chain Audit Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add `pip_supply_chain_audit` Check that scans operator-supplied `requirements.txt` paths against a bundled OSV-PyPI advisory DB, mirroring the 7d-npm pattern but routing lockfile paths through `ServerAuditConfig.pip_lock_paths` rather than `PlatformProfile`.

**Architecture:** Operator lists lockfile paths in `openclaw-target.yaml`; CLI dynamic-injects them into `CommandPolicy.allowed_paths` (kind=exact); Check reads each via `cat`, parses frozen `pkg==ver` lines, normalizes to PEP-503, matches against PEP-440 ranges from a bundled `osv_pypi.json`, emits per-(pkg, version, advisory) findings under TI umbrella `TI-OSV-PYPI-CATALOG`. Side-fix: `OsvNpmFetcher` `affected[0]` truncation (independent commit).

**Tech Stack:** Python 3.11+ · `packaging>=22` (PEP-440) · `httpx` (OSV fetch) · `zipfile` (OSV bundle) · `pydantic` (config) · `pytest` · `typer` (CLI)

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

---

## File Structure

**Create:**
- `src/agentsec/audit/checks/pip_supply_chain_audit.py` — Check + helpers (parser, matcher, PEP-503 norm)
- `src/agentsec/audit/ioc/osv_pypi.json` — bundled DB (empty `[]` first; populated in Task 16)
- `src/agentsec/audit/ioc_update/fetchers/osv_pypi.py` — OSV-PyPI fetcher
- `scripts/check_osv_pypi.py` — CI guard (valid JSON + ≤ 15 MB)
- `tests/audit/checks/test_pip_supply_chain_audit.py`
- `tests/audit/ioc_update/fetchers/test_osv_pypi.py`
- `tests/scripts/test_check_osv_pypi.py`
- `tests/fixtures/supply_chain_pypi/{requirements-clean.txt, requirements-vulnerable.txt, requirements-edge-cases.txt, osv_pypi_subset.json}`
- `tests/fixtures/osv/pypi/{normal.json, withdrawn.json, no-pypi.json, no-range.json, no-cvss.json, multi-affected.json}`
- `docs/adr/0013-phase-7d1-pip-supply-chain.md`

**Modify:**
- `src/agentsec/config.py` — `ServerAuditConfig.pip_lock_paths` + validator
- `src/agentsec/audit/checks/base.py` — `CheckContext.pip_lock_paths`
- `src/agentsec/audit/server_audit.py` — propagate `pip_lock_paths` to per-check ctx copy
- `src/agentsec/audit/command_policy.py` — `add_runtime_allowed_paths(paths, kind)` method
- `src/agentsec/audit/ssh_policy.yaml` — comment-only annotation noting dynamic injection
- `src/agentsec/audit/ioc/threat_intel.yaml` — append `TI-OSV-PYPI-CATALOG`
- `src/agentsec/audit/ioc_update/cli.py` — `_run_osv_pypi` parallel side pipeline
- `src/agentsec/audit/ioc_update/fetchers/osv_npm.py` — fix `affected[0]` truncation (independent commit, Task 15)
- `src/agentsec/cli.py` — register `PipSupplyChainAuditCheck` (2 sites) + wire `pip_lock_paths` injection
- `tests/audit/ioc_update/fetchers/test_osv_npm.py` — multi-affected case (Task 15)
- `tests/audit/test_command_policy.py` — runtime injection case
- `tests/audit/test_config.py` — `pip_lock_paths` validator cases
- `tests/audit/ioc_update/test_cli_offline.py` — `_run_osv_pypi` case
- `pyproject.toml` — version bump → 0.13.0
- `ROADMAP.md` / `CHANGELOG.md` / `README.md` / `CLAUDE.md` — docs sync (Task 18)
- `docs/samples/report-2026-04-28/**` — refresh via env-flag pytest (Task 14)

---

## Pre-flight

Open a terminal in the project root. Verify the baseline is clean:

```bash
cd /Users/roger/Work/AgentSec
git status   # must be clean
.venv/bin/pytest tests/ -q  # must be 1177 passing
```

If not green, stop and fix before starting tasks.

---

## Task 1: `ServerAuditConfig.pip_lock_paths` field + validator

**Files:**
- Modify: `src/agentsec/config.py` (after `LogReviewConfig` block, inside `ServerAuditConfig`)
- Test: `tests/audit/test_config.py`

- [ ] **Step 1: Write the failing tests**

Append to `tests/audit/test_config.py`:

```python
def test_pip_lock_paths_default_empty():
    sa = ServerAuditConfig(host="h", user="u", key=Path("/k"))
    assert sa.pip_lock_paths == []


def test_pip_lock_paths_accepts_absolute_and_tilde():
    sa = ServerAuditConfig(
        host="h", user="u", key=Path("/k"),
        pip_lock_paths=[
            "/srv/myservice/requirements.txt",
            "~/python-mcp/requirements-prod.txt",
            "/var/lib/foo/lock.in",
            "/opt/poetry-export.lock",
        ],
    )
    assert len(sa.pip_lock_paths) == 4


def test_pip_lock_paths_rejects_relative():
    with pytest.raises(ValidationError, match="must start with"):
        ServerAuditConfig(
            host="h", user="u", key=Path("/k"),
            pip_lock_paths=["./requirements.txt"],
        )


def test_pip_lock_paths_rejects_shell_metachar():
    with pytest.raises(ValidationError, match="shell metacharacter"):
        ServerAuditConfig(
            host="h", user="u", key=Path("/k"),
            pip_lock_paths=["/srv/$(whoami)/requirements.txt"],
        )


def test_pip_lock_paths_rejects_bad_suffix():
    with pytest.raises(ValidationError, match="suffix"):
        ServerAuditConfig(
            host="h", user="u", key=Path("/k"),
            pip_lock_paths=["/etc/shadow"],
        )
```

If the file lacks the imports, add at top:

```python
import pytest
from pathlib import Path
from pydantic import ValidationError
from agentsec.config import ServerAuditConfig
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `.venv/bin/pytest tests/audit/test_config.py -k pip_lock -v`
Expected: 5 FAIL — `pip_lock_paths` field doesn't exist yet.

- [ ] **Step 3: Add field + validator to `ServerAuditConfig`**

In `src/agentsec/config.py`, modify `ServerAuditConfig`:

```python
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator, model_validator

# ... existing imports ...

from .audit.metachar_guard import contains_shell_metachar


_PIP_LOCK_SUFFIXES = (".txt", ".lock", ".in")


def _has_allowed_suffix(path: str) -> bool:
    p = path.lower()
    if any(p.endswith(s) for s in _PIP_LOCK_SUFFIXES):
        return True
    # bare "requirements" or "requirements-prod" etc. without extension
    name = p.rsplit("/", 1)[-1]
    return name.startswith("requirements")


class ServerAuditConfig(BaseModel):
    model_config = _FORBID
    host: str
    user: str
    key: Path
    checks: Literal["all"] | list[str] = "all"
    ioc_db: Path = Path("./src/agentsec/audit/ioc/")
    ssh_audit_log: Path = Path("./audit-log.jsonl")
    ssh_policy: Path = Path("./src/agentsec/audit/ssh_policy.yaml")
    native_audit_argv: list[str] = Field(
        default_factory=lambda: ["openclaw", "security", "audit", "--json"]
    )
    log_review: LogReviewConfig = Field(default_factory=LogReviewConfig)
    pip_lock_paths: list[str] = Field(default_factory=list)

    @field_validator("pip_lock_paths")
    @classmethod
    def _validate_pip_lock_paths(cls, v: list[str]) -> list[str]:
        for p in v:
            if not (p.startswith("/") or p.startswith("~/")):
                raise ValueError(
                    f"pip_lock_paths entry {p!r} must start with '/' or '~/'"
                )
            if contains_shell_metachar([p]) is not None:
                raise ValueError(
                    f"pip_lock_paths entry {p!r} contains shell metacharacter"
                )
            if not _has_allowed_suffix(p):
                raise ValueError(
                    f"pip_lock_paths entry {p!r} must end with one of "
                    "{requirements*, *.txt, *.lock, *.in}"
                )
        return v
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `.venv/bin/pytest tests/audit/test_config.py -k pip_lock -v`
Expected: 5 PASS

- [ ] **Step 5: Run full config test module**

Run: `.venv/bin/pytest tests/audit/test_config.py -q`
Expected: all green, no regressions.

- [ ] **Step 6: Commit**

```bash
git add src/agentsec/config.py tests/audit/test_config.py
git commit -m "feat(7d.1): ServerAuditConfig.pip_lock_paths + validator

absolute or ~/-rooted paths; reject shell metachars + suffix not in
{requirements*, .txt, .lock, .in}.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```

---

## Task 2: `CheckContext.pip_lock_paths` field

**Files:**
- Modify: `src/agentsec/audit/checks/base.py`
- Test: `tests/audit/checks/test_base.py` (create if missing — minimal test only)

- [ ] **Step 1: Write the failing test**

Create or append `tests/audit/checks/test_base.py`:

```python
from pathlib import Path
from agentsec.audit.checks.base import CheckContext
from agentsec.audit.platform_profile import LINUX


def test_check_context_pip_lock_paths_default_empty():
    ctx = CheckContext(
        executor=object(), redactor=object(),
        ioc_dir=Path("/x"), profile=LINUX,
    )
    assert ctx.pip_lock_paths == ()


def test_check_context_pip_lock_paths_assignable():
    ctx = CheckContext(
        executor=object(), redactor=object(),
        ioc_dir=Path("/x"), profile=LINUX,
        pip_lock_paths=("/srv/req.txt",),
    )
    assert ctx.pip_lock_paths == ("/srv/req.txt",)
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `.venv/bin/pytest tests/audit/checks/test_base.py -v`
Expected: FAIL — `pip_lock_paths` not a field of `CheckContext`.

- [ ] **Step 3: Add field**

In `src/agentsec/audit/checks/base.py`, modify `CheckContext`:

```python
@dataclass
class CheckContext:
    executor: Any
    redactor: Any
    ioc_dir: Path | None
    profile: PlatformProfile
    status: CheckStatus = field(default_factory=CheckStatus)
    log_review_config: LogReviewConfig | None = None
    pip_lock_paths: tuple[str, ...] = ()
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `.venv/bin/pytest tests/audit/checks/test_base.py -v`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add src/agentsec/audit/checks/base.py tests/audit/checks/test_base.py
git commit -m "feat(7d.1): CheckContext.pip_lock_paths channel

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```

---

## Task 3: `server_audit.run_server_audit` propagates `pip_lock_paths`

**Files:**
- Modify: `src/agentsec/audit/server_audit.py:22-29`
- Test: `tests/audit/test_server_audit.py` (extend existing, or add)

- [ ] **Step 1: Find the existing test file**

Run: `ls tests/audit/test_server_audit.py 2>/dev/null || ls tests/test_server_audit.py 2>/dev/null`
If neither exists, create `tests/audit/test_server_audit.py`.

- [ ] **Step 2: Write the failing test**

Append:

```python
import asyncio
from pathlib import Path
from agentsec.audit.server_audit import run_server_audit
from agentsec.audit.checks.base import Check, CheckContext
from agentsec.audit.findings import Finding
from agentsec.audit.platform_profile import LINUX


class _CapturePipPathsCheck(Check):
    id = "capture_pip"

    def __init__(self):
        super().__init__()
        self.captured: tuple[str, ...] | None = None

    async def run(self, ctx: CheckContext) -> list[Finding]:
        self.captured = ctx.pip_lock_paths
        return []


def test_run_server_audit_propagates_pip_lock_paths():
    parent_ctx = CheckContext(
        executor=object(), redactor=object(),
        ioc_dir=Path("/x"), profile=LINUX,
        pip_lock_paths=("/srv/a.txt", "~/b.lock"),
    )
    cap = _CapturePipPathsCheck()
    asyncio.run(run_server_audit(checks=[cap], ctx=parent_ctx))
    assert cap.captured == ("/srv/a.txt", "~/b.lock")
```

- [ ] **Step 3: Run test to verify it fails**

Run: `.venv/bin/pytest tests/audit/test_server_audit.py::test_run_server_audit_propagates_pip_lock_paths -v`
Expected: FAIL — `cap.captured == ()` because `run_server_audit` doesn't copy the field.

- [ ] **Step 4: Modify `run_server_audit`**

In `src/agentsec/audit/server_audit.py`:

```python
async def run_server_audit(*, checks: list[Check], ctx: CheckContext) -> ServerAuditReport:
    report = ServerAuditReport()
    seen_fingerprints: set[str] = set()
    for check in checks:
        check_ctx = CheckContext(
            executor=ctx.executor,
            redactor=ctx.redactor,
            ioc_dir=ctx.ioc_dir,
            profile=ctx.profile,
            status=CheckStatus(),
            log_review_config=ctx.log_review_config,
            pip_lock_paths=ctx.pip_lock_paths,
        )
        try:
            findings = await check.run(check_ctx)
        except Exception as e:
            report.errors[check.id] = f"{type(e).__name__}: {e}"
            report.statuses[check.id] = check_ctx.status
            continue
        for f in findings:
            if f.fingerprint in seen_fingerprints:
                continue
            seen_fingerprints.add(f.fingerprint)
            report.findings.append(f)
        report.statuses[check.id] = check_ctx.status
    return report
```

- [ ] **Step 5: Run test to verify it passes**

Run: `.venv/bin/pytest tests/audit/test_server_audit.py -v`
Expected: PASS, no regression.

- [ ] **Step 6: Commit**

```bash
git add src/agentsec/audit/server_audit.py tests/audit/test_server_audit.py
git commit -m "feat(7d.1): run_server_audit propagates pip_lock_paths

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```

---

## Task 4: `CommandPolicy.add_runtime_allowed_paths` method

**Files:**
- Modify: `src/agentsec/audit/command_policy.py:83-87` (extend after `add_allowed_path_prefix`)
- Test: `tests/audit/test_command_policy.py`

- [ ] **Step 1: Write the failing test**

Append to `tests/audit/test_command_policy.py`:

```python
def test_add_runtime_allowed_paths_exact_kind():
    policy = CommandPolicy.from_yaml(
        Path("src/agentsec/audit/ssh_policy.yaml"),
        remote_home="/home/ubuntu",
    )
    target = "/srv/myservice/requirements.txt"
    # Before: cat target should be rejected (path not in allowed list)
    result = policy.validate(["cat", target])
    assert result.allowed is False

    policy.add_runtime_allowed_paths([target], kind="exact")

    result = policy.validate(["cat", target])
    assert result.allowed is True
```

(Use the actual `PolicyResult` field name; if it's `rejected` not `allowed`, invert. Verify by reading `command_policy.py` PolicyResult class first.)

- [ ] **Step 2: Verify field name and adjust test**

Run: `grep -n 'class PolicyResult\|allowed\|rejected' src/agentsec/audit/command_policy.py | head -20`

If the field is `rejected: bool`, change assertions to `assert result.rejected is True` then `assert result.rejected is False`.

- [ ] **Step 3: Run test to verify it fails**

Run: `.venv/bin/pytest tests/audit/test_command_policy.py::test_add_runtime_allowed_paths_exact_kind -v`
Expected: FAIL — `add_runtime_allowed_paths` not a method.

- [ ] **Step 4: Add method**

In `src/agentsec/audit/command_policy.py`, after `add_allowed_path_prefix`:

```python
def add_runtime_allowed_paths(
    self, paths: list[str], *, kind: str = "exact",
) -> None:
    """Append operator-supplied paths to allowed_paths at runtime.

    Used by Phase 7d.1 pip_supply_chain_audit to honor
    ServerAuditConfig.pip_lock_paths without editing ssh_policy.yaml.
    Operator's config entry == consent.

    `kind` must be one of {"exact", "prefix"}; "exact" recommended for
    individual file paths so a directory entry can't accidentally
    expand the read scope.
    """
    if kind not in ("exact", "prefix"):
        raise ValueError(f"kind must be exact|prefix, got {kind!r}")
    for p in paths:
        self._allowed_paths.append(AllowedPath(kind=kind, path=p))
    self._matcher = PathMatcher(
        self._allowed_paths, remote_home=self._remote_home,
    )
```

- [ ] **Step 5: Run test to verify it passes**

Run: `.venv/bin/pytest tests/audit/test_command_policy.py -v`
Expected: PASS, no regression.

- [ ] **Step 6: Annotate ssh_policy.yaml**

Append the following comment block at the end of the `allowed_paths:` list
in `src/agentsec/audit/ssh_policy.yaml` (purely documentary — no new static
entry; Phase 7d.1 paths are runtime-injected):

```yaml
  # Phase 7d.1 — pip supply chain audit; per-run dynamic injection.
  # Operator-supplied paths in ServerAuditConfig.pip_lock_paths are appended
  # at orchestrator startup via CommandPolicy.add_runtime_allowed_paths(...,
  # kind="exact"). No static path lives in ssh_policy.yaml — operator's
  # config entry == consent. See ADR-0013.
```

- [ ] **Step 7: Commit**

```bash
git add src/agentsec/audit/command_policy.py \
        tests/audit/test_command_policy.py \
        src/agentsec/audit/ssh_policy.yaml
git commit -m "feat(7d.1): CommandPolicy.add_runtime_allowed_paths

Phase 7d.1 dynamic injection of operator-supplied lockfile paths
into allowed_paths at runtime; exact-kind by default. ssh_policy.yaml
gets a comment-only annotation pointing at ADR-0013.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```

---

## Task 5: PEP-440 + PEP-503 helpers in new `pip_supply_chain_audit.py`

**Files:**
- Create: `src/agentsec/audit/checks/pip_supply_chain_audit.py`
- Create: `tests/audit/checks/test_pip_supply_chain_audit.py`

- [ ] **Step 1: Write the failing tests for matcher / normalizer**

Create `tests/audit/checks/test_pip_supply_chain_audit.py`:

```python
"""Tests for parser, matcher, normalizer in pip_supply_chain_audit."""

from agentsec.audit.checks.pip_supply_chain_audit import (
    _safe_parse_pep440,
    normalize_pep503,
    version_in_any_pep440_range,
)


# ---- normalize_pep503 ----

def test_normalize_underscore_to_hyphen_lower():
    assert normalize_pep503("Foo_Bar") == "foo-bar"
    assert normalize_pep503("Pillow") == "pillow"
    assert normalize_pep503("zope.interface") == "zope-interface"
    assert normalize_pep503("a.b_c-d") == "a-b-c-d"


# ---- _safe_parse_pep440 ----

def test_safe_parse_returns_none_on_garbage():
    assert _safe_parse_pep440("not a version") is None
    assert _safe_parse_pep440("git+https://x") is None


def test_safe_parse_accepts_local_version():
    assert _safe_parse_pep440("1.0.0+cuda118") is not None


# ---- version_in_any_pep440_range ----

def test_match_exact_inside_range():
    ranges = [{"introduced": "1.0", "fixed": "2.0"}]
    assert version_in_any_pep440_range("1.5", ranges) is True


def test_match_at_introduced_boundary():
    ranges = [{"introduced": "1.0", "fixed": "2.0"}]
    assert version_in_any_pep440_range("1.0", ranges) is True


def test_match_at_fixed_boundary_excluded():
    ranges = [{"introduced": "1.0", "fixed": "2.0"}]
    assert version_in_any_pep440_range("2.0", ranges) is False


def test_match_just_below_fixed():
    ranges = [{"introduced": "1.0", "fixed": "2.0"}]
    assert version_in_any_pep440_range("1.99", ranges) is True


def test_match_pre_release_in_range():
    ranges = [{"introduced": "0", "fixed": "1.0.0"}]
    assert version_in_any_pep440_range("1.0.0a1", ranges) is True


def test_match_local_version_ignored_in_compare():
    # PEP-440: local version is dropped in ordering for comparison
    ranges = [{"introduced": "0", "fixed": "1.0.0"}]
    assert version_in_any_pep440_range("1.0.0+cuda", ranges) is False


def test_match_unparseable_returns_false():
    ranges = [{"introduced": "1.0", "fixed": "2.0"}]
    assert version_in_any_pep440_range("garbage", ranges) is False


def test_match_multiple_ranges_or():
    ranges = [
        {"introduced": "1.0", "fixed": "1.5"},
        {"introduced": "2.0", "fixed": "2.5"},
    ]
    assert version_in_any_pep440_range("2.3", ranges) is True
    assert version_in_any_pep440_range("1.7", ranges) is False
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `.venv/bin/pytest tests/audit/checks/test_pip_supply_chain_audit.py -v`
Expected: ImportError — module doesn't exist yet.

- [ ] **Step 3: Create the helpers module**

Create `src/agentsec/audit/checks/pip_supply_chain_audit.py`:

```python
"""pip / PyPI 供应链漏洞审计（Phase 7d.1）。

Reads operator-configured requirements.txt files on the audited host,
parses frozen `pkg==ver` lines, normalizes package names per PEP-503,
and matches each (name, version) against a bundled OSV-PyPI advisory
subset using PEP-440 comparison. Emits one Finding per matching
(pkg, version, advisory) tuple.

DB schema (src/agentsec/audit/ioc/osv_pypi.json):
  [{"id": "GHSA-xxxx", "package": "django",
    "ranges": [{"introduced": "0", "fixed": "3.2.18"}],
    "severity": "critical|high|medium|low", "summary": "...",
    "url": "...", "aliases": ["CVE-..."]}, ...]
"""

from __future__ import annotations

import re
from typing import Literal

from packaging.version import InvalidVersion, Version

Severity = Literal["critical", "high", "medium", "low"]


def normalize_pep503(name: str) -> str:
    """PEP-503 name normalization: lowercase + collapse [-_.]+ to single hyphen."""
    return re.sub(r"[-_.]+", "-", name).lower()


def _safe_parse_pep440(s: str) -> Version | None:
    """Return packaging.version.Version or None on parse failure."""
    try:
        return Version(s)
    except (InvalidVersion, TypeError):
        return None


def version_in_any_pep440_range(version: str, ranges: list[dict]) -> bool:
    """True iff `version` falls into at least one OSV range entry.

    Range entry shape (PyPI subset):
      {"introduced": "0", "fixed": "3.2.18"}
      {"introduced": "1.0", "last_affected": "1.2.5"}

    Non-PEP-440 versions return False (caller treats as 'cannot match').
    pre-release versions are included by default; local-version segments
    (`1.0+cuda`) are dropped in comparison per PEP-440.
    """
    v = _safe_parse_pep440(version)
    if v is None:
        return False
    for r in ranges:
        intro = r.get("introduced", "0")
        fixed = r.get("fixed")
        last = r.get("last_affected")

        if intro != "0":
            intro_v = _safe_parse_pep440(intro)
            if intro_v is not None and v < intro_v:
                continue
        if fixed is not None:
            fixed_v = _safe_parse_pep440(fixed)
            if fixed_v is not None and v >= fixed_v:
                continue
        if last is not None:
            last_v = _safe_parse_pep440(last)
            if last_v is not None and v > last_v:
                continue
        return True
    return False
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `.venv/bin/pytest tests/audit/checks/test_pip_supply_chain_audit.py -v`
Expected: 11 PASS

- [ ] **Step 5: Commit**

```bash
git add src/agentsec/audit/checks/pip_supply_chain_audit.py \
        tests/audit/checks/test_pip_supply_chain_audit.py
git commit -m "feat(7d.1): PEP-440 matcher + PEP-503 normalizer

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```

---

## Task 6: `parse_pip_requirements` lockfile parser

**Files:**
- Modify: `src/agentsec/audit/checks/pip_supply_chain_audit.py`
- Modify: `tests/audit/checks/test_pip_supply_chain_audit.py`

- [ ] **Step 1: Write the failing parser tests**

Append to `tests/audit/checks/test_pip_supply_chain_audit.py`:

```python
from agentsec.audit.checks.pip_supply_chain_audit import parse_pip_requirements


def test_parse_simple_eq():
    assert parse_pip_requirements("django==4.2.0\n") == [("django", "4.2.0")]


def test_parse_with_extras():
    assert parse_pip_requirements("requests[security,socks]==2.31.0\n") == [
        ("requests", "2.31.0"),
    ]


def test_parse_with_marker():
    src = 'django==4.2.0 ; python_version >= "3.10"\n'
    assert parse_pip_requirements(src) == [("django", "4.2.0")]


def test_parse_continuation_hash():
    src = (
        "django==4.2.0 \\\n"
        "    --hash=sha256:abcdef \\\n"
        "    --hash=sha256:123456\n"
    )
    assert parse_pip_requirements(src) == [("django", "4.2.0")]


def test_parse_pep503_keeps_raw_name():
    # Parser keeps the raw name; normalization happens at lookup time
    assert parse_pip_requirements("Pillow==10.0.0\n") == [("Pillow", "10.0.0")]


def test_parse_local_version():
    assert parse_pip_requirements("torch==2.0.0+cu118\n") == [
        ("torch", "2.0.0+cu118"),
    ]


def test_parse_skip_editable():
    src = "-e .\n-e git+https://github.com/x/y\n"
    assert parse_pip_requirements(src) == []


def test_parse_skip_vcs_url():
    src = "django @ git+https://github.com/django/django\n"
    assert parse_pip_requirements(src) == []


def test_parse_skip_recursive_and_constraint():
    src = "-r other.txt\n-c constraints.txt\n"
    assert parse_pip_requirements(src) == []


def test_parse_silently_skips_non_eq():
    src = "django>=4.0\nrequests~=2.30\nflask<3.0\n"
    assert parse_pip_requirements(src) == []


def test_parse_skips_comments_and_blanks():
    src = "# top\n\ndjango==4.2.0\n# trailing\n"
    assert parse_pip_requirements(src) == [("django", "4.2.0")]
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `.venv/bin/pytest tests/audit/checks/test_pip_supply_chain_audit.py -k parse -v`
Expected: ImportError on `parse_pip_requirements`.

- [ ] **Step 3: Add the parser**

Append to `src/agentsec/audit/checks/pip_supply_chain_audit.py`:

```python
# ---- requirements.txt parser ----

# `pkg[extras]==version` — extras / version-specifier splits before marker
_REQ_LINE_RE = re.compile(
    r"^([A-Za-z0-9][A-Za-z0-9._-]*)"   # 1: package name
    r"(?:\[[^\]]*\])?"                  # optional extras (discarded)
    r"\s*==\s*"                         # operator (only `==` accepted)
    r"([^\s;]+)"                        # 2: version (up to whitespace or `;` marker)
)


def parse_pip_requirements(stdout: str) -> list[tuple[str, str]]:
    """Return [(pkg_name, version)] from frozen requirements.txt content.

    Recognizes only `pkg==version` lines (with optional [extras] and
    `; marker`). Silently skips:
      - comments / blank lines
      - `-e ...` editable installs
      - `-r ...` / `-c ...` recursive includes
      - `pkg @ url` direct VCS / file references
      - `pkg>=`, `~=`, `<` non-exact specifiers
      - any line that doesn't match the canonical form

    Continuation lines (`\\` at EOL) are joined; subsequent `--hash=...`
    lines belong to the previous requirement and are absorbed.
    """
    # Join continuation lines first
    joined: list[str] = []
    buf = ""
    for raw in stdout.splitlines():
        if buf:
            line = buf + " " + raw.lstrip()
        else:
            line = raw
        if line.rstrip().endswith("\\"):
            buf = line.rstrip()[:-1].rstrip()
            continue
        joined.append(line)
        buf = ""
    if buf:
        joined.append(buf)

    out: list[tuple[str, str]] = []
    for line in joined:
        s = line.strip()
        if not s or s.startswith("#"):
            continue
        if s.startswith(("-e ", "-r ", "-c ", "--")):
            continue
        if " @ " in s:   # direct URL form
            continue
        m = _REQ_LINE_RE.match(s)
        if not m:
            continue
        pkg, version = m.group(1), m.group(2)
        out.append((pkg, version))
    return out
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `.venv/bin/pytest tests/audit/checks/test_pip_supply_chain_audit.py -k parse -v`
Expected: 11 PASS (10 parser + 1 already from Task 5 keep passing too)

- [ ] **Step 5: Run full test module to confirm no regression**

Run: `.venv/bin/pytest tests/audit/checks/test_pip_supply_chain_audit.py -v`
Expected: 22 PASS total

- [ ] **Step 6: Commit**

```bash
git add src/agentsec/audit/checks/pip_supply_chain_audit.py \
        tests/audit/checks/test_pip_supply_chain_audit.py
git commit -m "feat(7d.1): parse_pip_requirements (frozen .txt only)

Recognizes pkg==ver with optional extras and markers; joins
backslash continuations + absorbs --hash lines; silently skips
editable/recursive/VCS/non-exact lines.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```

---

## Task 7: `PipSupplyChainAuditCheck` class + DB load + run()

**Files:**
- Modify: `src/agentsec/audit/checks/pip_supply_chain_audit.py`
- Create: `src/agentsec/audit/ioc/osv_pypi.json` (empty list `[]`)
- Create: `tests/fixtures/supply_chain_pypi/osv_pypi_subset.json`
- Modify: `tests/audit/checks/test_pip_supply_chain_audit.py`

- [ ] **Step 1: Create the empty bundled DB placeholder**

```bash
echo '[]' > src/agentsec/audit/ioc/osv_pypi.json
```

The real content lands in Task 16. Empty `[]` lets the Check class load without exceptions during the rest of testing.

- [ ] **Step 2: Create the test fixture**

Create `tests/fixtures/supply_chain_pypi/osv_pypi_subset.json`:

```json
[
  {
    "id": "GHSA-xxxx-yyyy-zzzz",
    "package": "django",
    "ranges": [{"introduced": "3.0", "fixed": "3.2.18"}],
    "severity": "high",
    "summary": "SQL injection in QuerySet.annotate",
    "url": "https://github.com/advisories/GHSA-xxxx-yyyy-zzzz",
    "aliases": ["CVE-2023-12345"]
  },
  {
    "id": "GHSA-aaaa-bbbb-cccc",
    "package": "requests",
    "ranges": [{"introduced": "0", "fixed": "2.31.1"}],
    "severity": "critical",
    "summary": "Cert verification bypass under malformed proxy URL",
    "url": "https://github.com/advisories/GHSA-aaaa-bbbb-cccc",
    "aliases": ["CVE-2023-99999"]
  }
]
```

- [ ] **Step 3: Write the failing Check end-to-end tests**

Append to `tests/audit/checks/test_pip_supply_chain_audit.py`:

```python
import asyncio
from dataclasses import dataclass
from pathlib import Path

from agentsec.audit.checks.base import CheckContext
from agentsec.audit.checks.pip_supply_chain_audit import PipSupplyChainAuditCheck
from agentsec.audit.platform_profile import LINUX

_FIXTURE_DB = Path("tests/fixtures/supply_chain_pypi/osv_pypi_subset.json")


@dataclass
class _ExecResult:
    stdout: str = ""
    stderr: str = ""
    exit_code: int = 0
    rejected: bool = False
    reject_reason: str = ""


class _StubExecutor:
    def __init__(self, files: dict[str, str], rejected_paths: set[str] = frozenset()):
        self._files = files
        self._rejected = rejected_paths

    def run(self, argv: list[str]) -> _ExecResult:
        # Only `cat <path>` form; argv == ["cat", path]
        assert argv[0] == "cat"
        path = argv[1]
        if path in self._rejected:
            return _ExecResult(rejected=True, reject_reason="not in allowed_paths")
        if path not in self._files:
            return _ExecResult(stderr="No such file", exit_code=1)
        return _ExecResult(stdout=self._files[path])


class _NoOpRedactor:
    def redact(self, s: str) -> str:
        return s


def _ctx(*, pip_lock_paths: tuple[str, ...], executor) -> CheckContext:
    return CheckContext(
        executor=executor, redactor=_NoOpRedactor(),
        ioc_dir=Path("/x"), profile=LINUX,
        pip_lock_paths=pip_lock_paths,
    )


def test_check_empty_config_not_applicable():
    check = PipSupplyChainAuditCheck(db_path=_FIXTURE_DB)
    ctx = _ctx(pip_lock_paths=(), executor=_StubExecutor({}))
    findings = asyncio.run(check.run(ctx))
    assert findings == []
    assert ctx.status.is_not_applicable
    assert "未配置" in ctx.status.not_applicable_reason


def test_check_single_lockfile_one_hit():
    check = PipSupplyChainAuditCheck(db_path=_FIXTURE_DB)
    ctx = _ctx(
        pip_lock_paths=("/srv/req.txt",),
        executor=_StubExecutor({"/srv/req.txt": "django==3.2.0\n"}),
    )
    findings = asyncio.run(check.run(ctx))
    assert len(findings) == 1
    f = findings[0]
    assert f.check == "pip_supply_chain_audit"
    assert f.severity == "high"
    assert "django@3.2.0" in f.title
    assert f.evidence["advisory_id"] == "GHSA-xxxx-yyyy-zzzz"
    assert f.evidence["fixed_in"] == "3.2.18"
    assert f.evidence["lock_path"] == "/srv/req.txt"
    assert f.threat_refs == ["TI-OSV-PYPI-CATALOG"]


def test_check_multi_lockfile_mixed():
    check = PipSupplyChainAuditCheck(db_path=_FIXTURE_DB)
    ctx = _ctx(
        pip_lock_paths=("/a.txt", "/b.txt", "/missing.txt"),
        executor=_StubExecutor({
            "/a.txt": "requests==2.30.0\n",
            "/b.txt": "django==4.2.0\n",  # > 3.2.18, no hit
        }),
    )
    findings = asyncio.run(check.run(ctx))
    assert len(findings) == 1
    assert findings[0].evidence["lock_path"] == "/a.txt"
    assert findings[0].severity == "critical"


def test_check_all_paths_unreadable_skipped():
    check = PipSupplyChainAuditCheck(db_path=_FIXTURE_DB)
    ctx = _ctx(
        pip_lock_paths=("/x.txt", "/y.txt"),
        executor=_StubExecutor({}, rejected_paths={"/x.txt", "/y.txt"}),
    )
    findings = asyncio.run(check.run(ctx))
    assert findings == []
    assert ctx.status.is_skipped
    assert "n=2" in ctx.status.skip_reason


def test_check_unparseable_pep440_low_finding():
    check = PipSupplyChainAuditCheck(db_path=_FIXTURE_DB)
    ctx = _ctx(
        pip_lock_paths=("/req.txt",),
        executor=_StubExecutor({"/req.txt": "weird-pkg==not-a-version\n"}),
    )
    findings = asyncio.run(check.run(ctx))
    assert len(findings) == 1
    assert findings[0].severity == "low"
    assert "无法解析 PEP-440" in findings[0].title


def test_check_non_linux_not_applicable():
    from agentsec.audit.platform_profile import MACOS
    check = PipSupplyChainAuditCheck(db_path=_FIXTURE_DB)
    ctx = CheckContext(
        executor=_StubExecutor({}), redactor=_NoOpRedactor(),
        ioc_dir=Path("/x"), profile=MACOS,
        pip_lock_paths=("/x.txt",),
    )
    findings = asyncio.run(check.run(ctx))
    assert findings == []
    assert ctx.status.is_not_applicable
    assert "非 Linux" in ctx.status.not_applicable_reason
```

- [ ] **Step 4: Run tests to verify they fail**

Run: `.venv/bin/pytest tests/audit/checks/test_pip_supply_chain_audit.py -k 'check_' -v`
Expected: FAIL — `PipSupplyChainAuditCheck` doesn't exist.

- [ ] **Step 5: Add the Check class**

Append to `src/agentsec/audit/checks/pip_supply_chain_audit.py`:

```python
import json
from pathlib import Path

from ..findings import Finding
from .base import Check, CheckContext


_OSV_DB_PATH = Path(__file__).parent.parent / "ioc" / "osv_pypi.json"


def severity_from_cvss_score(score: float | None) -> Severity:
    """CVSS v3 base-score → AgentSec severity label.

    Duplicated from supply_chain_audit.py rather than imported (rule-of-three:
    extract _supply_chain_common.py when 7d.2 go ecosystem lands).
    None → 'medium' (conservative; not 'low' so unscored entries aren't
    underreported).
    """
    if score is None:
        return "medium"
    if score >= 9.0:
        return "critical"
    if score >= 7.0:
        return "high"
    if score >= 4.0:
        return "medium"
    return "low"


def _extract_severity_label(adv: dict) -> Severity:
    sev = adv.get("severity")
    if sev in ("critical", "high", "medium", "low"):
        return sev
    return "medium"


class PipSupplyChainAuditCheck(Check):
    id = "pip_supply_chain_audit"

    def __init__(self, db_path: Path | None = None):
        super().__init__()
        path = db_path if db_path is not None else _OSV_DB_PATH
        raw = json.loads(path.read_text())
        self._advisories_by_pkg: dict[str, list[dict]] = {}
        for adv in raw:
            self._advisories_by_pkg.setdefault(adv["package"], []).append(adv)

    async def run(self, ctx: CheckContext) -> list[Finding]:
        if ctx.profile.name not in ("linux", "linux_user_mode"):
            ctx.status.not_applicable(f"{self.id}：非 Linux profile")
            return []
        if not ctx.pip_lock_paths:
            ctx.status.not_applicable(f"{self.id}：未配置 pip_lock_paths")
            return []

        findings: list[Finding] = []
        n_skipped = 0
        for lock_path in ctx.pip_lock_paths:
            result = ctx.executor.run(["cat", lock_path])
            if result.rejected:
                n_skipped += 1
                continue
            if result.exit_code != 0:
                n_skipped += 1
                continue
            if not result.stdout.strip():
                n_skipped += 1
                continue
            findings.extend(self._scan_lockfile(lock_path, result.stdout))
        if not findings and n_skipped == len(ctx.pip_lock_paths):
            ctx.status.skipped(
                f"{self.id}：所有 pip_lock_paths 均不可读 (n={n_skipped})"
            )
        return findings

    def _scan_lockfile(self, lock_path: str, stdout: str) -> list[Finding]:
        pkgs = parse_pip_requirements(stdout)
        findings: list[Finding] = []
        for pkg, version in pkgs:
            norm = normalize_pep503(pkg)
            if _safe_parse_pep440(version) is None:
                findings.append(self._unparseable_finding(pkg, version, lock_path))
                continue
            for adv in self._advisories_by_pkg.get(norm, []):
                if version_in_any_pep440_range(version, adv["ranges"]):
                    findings.append(
                        self._advisory_finding(pkg, version, adv, lock_path)
                    )
        return findings

    def _advisory_finding(
        self, pkg: str, version: str, adv: dict, lock_path: str,
    ) -> Finding:
        sev = _extract_severity_label(adv)
        adv_id = adv["id"]
        fixed_in = next(
            (r["fixed"] for r in adv["ranges"] if r.get("fixed")),
            "(no fixed version)",
        )
        return Finding(
            check=self.id, severity=sev,
            title=f"pip {pkg}@{version} 命中 {adv_id}（severity={sev}）",
            description=adv.get("summary", "")[:200],
            evidence={
                "package": pkg,
                "version": version,
                "advisory_id": adv_id,
                "aliases": adv.get("aliases", []),
                "fixed_in": fixed_in,
                "url": adv.get("url", ""),
                "lock_path": lock_path,
            },
            remediation=f"pip install -U {pkg}=={fixed_in}",
            threat_refs=["TI-OSV-PYPI-CATALOG"],
        )

    def _unparseable_finding(
        self, pkg: str, version: str, lock_path: str,
    ) -> Finding:
        return Finding(
            check=self.id, severity="low",
            title=f"无法解析 PEP-440: {pkg}@{version}",
            description="非标准版本字符串（如 git URL pin），无法对照 OSV 数据库",
            evidence={
                "package": pkg, "version": version, "lock_path": lock_path,
            },
            remediation="改为 PEP-440 合规版本（pip 发布版）",
            threat_refs=["TI-OSV-PYPI-CATALOG"],
        )
```

- [ ] **Step 6: Run tests to verify they pass**

Run: `.venv/bin/pytest tests/audit/checks/test_pip_supply_chain_audit.py -v`
Expected: all PASS (22 from earlier + 6 new = 28)

- [ ] **Step 7: Commit**

```bash
git add src/agentsec/audit/checks/pip_supply_chain_audit.py \
        src/agentsec/audit/ioc/osv_pypi.json \
        tests/audit/checks/test_pip_supply_chain_audit.py \
        tests/fixtures/supply_chain_pypi/osv_pypi_subset.json
git commit -m "feat(7d.1): PipSupplyChainAuditCheck

Reads ctx.pip_lock_paths, cats each lockfile via executor, parses
frozen requirements.txt, matches against bundled osv_pypi.json
(empty placeholder; populated in Task 16). Per-(pkg, version,
advisory) findings under TI-OSV-PYPI-CATALOG.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```

---

## Task 8: TI umbrella `TI-OSV-PYPI-CATALOG`

**Files:**
- Modify: `src/agentsec/audit/ioc/threat_intel.yaml` (append)

- [ ] **Step 1: Append the TI entry**

Append to `src/agentsec/audit/ioc/threat_intel.yaml` (after `TI-OSV-NPM-CATALOG`):

```yaml
- id: TI-OSV-PYPI-CATALOG
  source: osv
  url: https://osv.dev/list?ecosystem=PyPI
  observed_at: '2026-05-11'
  claim: OSV.dev PyPI ecosystem advisory catalog — pip_supply_chain_audit Check 引用根
  confidence: high
  mapped_tests: []
  auto_refresh: false
  note: 真实 advisory 索引在 src/agentsec/audit/ioc/osv_pypi.json；本 TI 仅作为 pip_supply_chain finding 的 threat_refs 目标，避免破坏 strict TI 校验
```

- [ ] **Step 2: Verify CI cross-checks pass**

Run: `.venv/bin/python scripts/check_threat_refs.py`
Expected: exit 0.

Run: `.venv/bin/python scripts/build_cve_db.py --check`
Expected: exit 0 (no CVE id on this stub TI; cve_database.json unchanged).

- [ ] **Step 3: Commit**

```bash
git add src/agentsec/audit/ioc/threat_intel.yaml
git commit -m "data(7d.1): add TI-OSV-PYPI-CATALOG umbrella entry

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```

---

## Task 9: `scripts/check_osv_pypi.py` CI guard

**Files:**
- Create: `scripts/check_osv_pypi.py`
- Create: `tests/scripts/test_check_osv_pypi.py`

- [ ] **Step 1: Look at the npm precedent**

Run: `cat scripts/check_osv_npm.py`

This sets the template; mirror its CLI / exit-code convention.

- [ ] **Step 2: Write the failing tests**

Create `tests/scripts/test_check_osv_pypi.py`:

```python
import json
import subprocess
import sys
from pathlib import Path


def _run_script(*args, cwd: Path) -> subprocess.CompletedProcess:
    return subprocess.run(
        [sys.executable, "scripts/check_osv_pypi.py", *args],
        cwd=cwd, capture_output=True, text=True, check=False,
    )


def test_check_valid_json_passes(tmp_path):
    db = tmp_path / "osv_pypi.json"
    db.write_text(json.dumps([
        {"id": "GHSA-x", "package": "django", "ranges": [{"fixed": "1.0"}],
         "severity": "high", "summary": "x", "url": "u", "aliases": []},
    ]))
    p = _run_script("--db", str(db), cwd=Path.cwd())
    assert p.returncode == 0, p.stderr


def test_check_oversize_fails(tmp_path):
    db = tmp_path / "osv_pypi.json"
    # 16 MB blob
    db.write_text("[" + ",".join(['"x"'] * (16 * 1024 * 1024 // 4)) + "]")
    p = _run_script("--db", str(db), cwd=Path.cwd())
    assert p.returncode != 0
    assert "MB" in (p.stderr + p.stdout)


def test_check_invalid_json_fails(tmp_path):
    db = tmp_path / "osv_pypi.json"
    db.write_text("{not json")
    p = _run_script("--db", str(db), cwd=Path.cwd())
    assert p.returncode != 0
```

- [ ] **Step 3: Run tests to verify they fail**

Run: `.venv/bin/pytest tests/scripts/test_check_osv_pypi.py -v`
Expected: FAIL — script doesn't exist.

- [ ] **Step 4: Write the script**

Create `scripts/check_osv_pypi.py` (mirrors `check_osv_npm.py` shape):

```python
#!/usr/bin/env python3
"""CI guard for src/agentsec/audit/ioc/osv_pypi.json.

Validates JSON well-formedness and a 15 MB hard cap on file size.
Exits 0 on success; 1 on any failure.
"""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

_DEFAULT_DB = Path("src/agentsec/audit/ioc/osv_pypi.json")
_MAX_BYTES = 15 * 1024 * 1024  # 15 MB


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--db", type=Path, default=_DEFAULT_DB)
    parser.add_argument(
        "--check", action="store_true",
        help="(no-op flag for parity with check_osv_npm.py)",
    )
    args = parser.parse_args()

    db: Path = args.db
    if not db.exists():
        print(f"ERR: {db} does not exist", file=sys.stderr)
        return 1

    size = db.stat().st_size
    if size > _MAX_BYTES:
        size_mb = size / (1024 * 1024)
        print(
            f"ERR: {db} is {size_mb:.1f} MB, exceeds 15 MB cap",
            file=sys.stderr,
        )
        return 1

    try:
        data = json.loads(db.read_text())
    except json.JSONDecodeError as e:
        print(f"ERR: {db} is not valid JSON: {e}", file=sys.stderr)
        return 1

    if not isinstance(data, list):
        print(f"ERR: {db} top-level must be a JSON list", file=sys.stderr)
        return 1

    print(f"OK: {db} ({size / 1024:.1f} KB, {len(data)} entries)")
    return 0


if __name__ == "__main__":
    sys.exit(main())
```

Make it executable:

```bash
chmod +x scripts/check_osv_pypi.py
```

- [ ] **Step 5: Run tests to verify they pass**

Run: `.venv/bin/pytest tests/scripts/test_check_osv_pypi.py -v`
Expected: 3 PASS

- [ ] **Step 6: Run script against committed DB**

Run: `.venv/bin/python scripts/check_osv_pypi.py`
Expected: `OK: src/agentsec/audit/ioc/osv_pypi.json (... KB, 0 entries)` (still empty list)

- [ ] **Step 7: Commit**

```bash
git add scripts/check_osv_pypi.py tests/scripts/test_check_osv_pypi.py
git commit -m "build(7d.1): scripts/check_osv_pypi.py CI guard

Validates JSON + 15 MB hard cap, mirroring check_osv_npm.py.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```

---

## Task 10: `OsvPyPIFetcher.normalize_osv_pypi_entry` (offline filter chain)

**Files:**
- Create: `src/agentsec/audit/ioc_update/fetchers/osv_pypi.py`
- Create: `tests/audit/ioc_update/fetchers/test_osv_pypi.py`
- Create: `tests/fixtures/osv/pypi/{normal,withdrawn,no-pypi,no-range,no-cvss,multi-affected}.json`

- [ ] **Step 1: Look at the npm fetcher template**

Run: `head -180 src/agentsec/audit/ioc_update/fetchers/osv_npm.py`

Note `normalize_osv_entry`, `_extract_severity`, `_extract_semver_ranges`, the 5-axis filter chain.

- [ ] **Step 2: Create the fixture files**

Create `tests/fixtures/osv/pypi/normal.json` (real-shape OSV record, scored CVSS_V3, in-date, single PyPI affected):

```json
{
  "id": "GHSA-norm-aaaa-bbbb",
  "published": "2025-06-01T00:00:00Z",
  "modified": "2025-06-02T00:00:00Z",
  "summary": "SSRF in normal-test-pkg",
  "affected": [{
    "package": {"ecosystem": "PyPI", "name": "Normal_Pkg"},
    "ranges": [{
      "type": "ECOSYSTEM",
      "events": [{"introduced": "0"}, {"fixed": "2.0.0"}]
    }]
  }],
  "severity": [{"type": "CVSS_V3", "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"}],
  "aliases": ["CVE-2025-99001"]
}
```

Create `tests/fixtures/osv/pypi/withdrawn.json`:

```json
{
  "id": "GHSA-with-aaaa-bbbb",
  "withdrawn": "2025-01-01T00:00:00Z",
  "affected": [{"package": {"ecosystem": "PyPI", "name": "x"},
                "ranges": [{"type": "ECOSYSTEM", "events": [{"introduced": "0"}, {"fixed": "1.0"}]}]}],
  "severity": [{"type": "CVSS_V3", "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"}]
}
```

Create `tests/fixtures/osv/pypi/no-pypi.json` (only npm affected):

```json
{
  "id": "GHSA-cross-aaaa-bbbb",
  "affected": [{
    "package": {"ecosystem": "npm", "name": "lodash"},
    "ranges": [{"type": "SEMVER", "events": [{"introduced": "0"}, {"fixed": "4.17.21"}]}]
  }],
  "severity": [{"type": "CVSS_V3", "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"}]
}
```

Create `tests/fixtures/osv/pypi/no-range.json`:

```json
{
  "id": "GHSA-norang-aaaa-bbbb",
  "affected": [{"package": {"ecosystem": "PyPI", "name": "foo"}, "ranges": []}],
  "severity": [{"type": "CVSS_V3", "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"}]
}
```

Create `tests/fixtures/osv/pypi/no-cvss.json`:

```json
{
  "id": "GHSA-noscore-aaaa-bbbb",
  "affected": [{
    "package": {"ecosystem": "PyPI", "name": "foo"},
    "ranges": [{"type": "ECOSYSTEM", "events": [{"introduced": "0"}, {"fixed": "1.0"}]}]
  }],
  "severity": []
}
```

Create `tests/fixtures/osv/pypi/multi-affected.json` (2 PyPI packages in one entry):

```json
{
  "id": "GHSA-multi-aaaa-bbbb",
  "published": "2025-06-01T00:00:00Z",
  "summary": "Affects two forks",
  "affected": [
    {
      "package": {"ecosystem": "PyPI", "name": "fork-a"},
      "ranges": [{"type": "ECOSYSTEM", "events": [{"introduced": "0"}, {"fixed": "1.0"}]}]
    },
    {
      "package": {"ecosystem": "PyPI", "name": "fork-b"},
      "ranges": [{"type": "ECOSYSTEM", "events": [{"introduced": "0"}, {"fixed": "2.0"}]}]
    }
  ],
  "severity": [{"type": "CVSS_V3", "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"}]
}
```

- [ ] **Step 3: Write the failing fetcher tests**

Create `tests/audit/ioc_update/fetchers/test_osv_pypi.py`:

```python
import json
from datetime import UTC, datetime
from pathlib import Path

from agentsec.audit.ioc_update.fetchers.osv_pypi import (
    normalize_osv_pypi_entry,
)

_FIX = Path("tests/fixtures/osv/pypi")


def _load(name: str) -> dict:
    return json.loads((_FIX / name).read_text())


def test_normalize_normal_entry():
    out = normalize_osv_pypi_entry(_load("normal.json"))
    assert out is not None
    assert isinstance(out, list)
    assert len(out) == 1
    rec = out[0]
    assert rec["id"] == "GHSA-norm-aaaa-bbbb"
    assert rec["package"] == "normal-pkg"   # PEP-503 normalized
    assert rec["severity"] == "critical"
    assert rec["ranges"] == [{"introduced": "0", "fixed": "2.0.0"}]
    assert rec["aliases"] == ["CVE-2025-99001"]


def test_normalize_filters_withdrawn():
    assert normalize_osv_pypi_entry(_load("withdrawn.json")) is None


def test_normalize_filters_no_pypi_affected():
    assert normalize_osv_pypi_entry(_load("no-pypi.json")) is None


def test_normalize_filters_no_range():
    assert normalize_osv_pypi_entry(_load("no-range.json")) is None


def test_normalize_filters_no_cvss():
    assert normalize_osv_pypi_entry(_load("no-cvss.json")) is None


def test_normalize_drops_old_medium_low():
    entry = _load("normal.json")
    entry["severity"] = [{
        "type": "CVSS_V3",
        "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N",
    }]
    entry["published"] = "2020-01-01T00:00:00Z"
    assert normalize_osv_pypi_entry(entry) is None


def test_normalize_keeps_old_critical():
    entry = _load("normal.json")
    entry["published"] = "2020-01-01T00:00:00Z"
    out = normalize_osv_pypi_entry(entry)
    assert out is not None and len(out) == 1
    assert out[0]["severity"] == "critical"


def test_normalize_multi_affected_expansion():
    out = normalize_osv_pypi_entry(_load("multi-affected.json"))
    assert out is not None
    assert len(out) == 2
    pkgs = sorted(r["package"] for r in out)
    assert pkgs == ["fork-a", "fork-b"]
    # Same advisory id replicated across packages
    assert {r["id"] for r in out} == {"GHSA-multi-aaaa-bbbb"}
```

- [ ] **Step 4: Run tests to verify they fail**

Run: `.venv/bin/pytest tests/audit/ioc_update/fetchers/test_osv_pypi.py -v`
Expected: FAIL — module doesn't exist.

- [ ] **Step 5: Write the fetcher module (offline path only)**

Create `src/agentsec/audit/ioc_update/fetchers/osv_pypi.py`:

```python
"""OSV-PyPI full ecosystem fetcher (Phase 7d.1).

Pulls https://osv-vulnerabilities.storage.googleapis.com/PyPI/all.zip
and normalizes the included OSV entries to the compact schema
consumed by PipSupplyChainAuditCheck. Returns a single FetchResult
keyed under '_osv_pypi_full' for the cli.py orchestrator.
"""

from __future__ import annotations

import asyncio
import io
import json
import zipfile

import httpx

from agentsec.audit.checks.pip_supply_chain_audit import (
    normalize_pep503,
    severity_from_cvss_score,
)

from ..watchlist import WatchlistEntry
from .base import Fetcher, FetcherContext, FetchResult, hash_response

OSV_PYPI_URL = (
    "https://osv-vulnerabilities.storage.googleapis.com/PyPI/all.zip"
)
RETRY_BACKOFFS = [2, 4, 8]
_HTTP_TIMEOUT = 60

MEDIUM_LOW_MAX_AGE_DAYS = 2 * 365


def _is_within_age(modified_iso: str, max_age_days: int, now=None) -> bool:
    """Mirror osv_npm._is_within_age. Unparseable timestamps return True."""
    from datetime import UTC, datetime, timedelta  # noqa: PLC0415
    if not modified_iso:
        return True
    try:
        ts = datetime.fromisoformat(modified_iso.replace("Z", "+00:00"))
    except (ValueError, TypeError):
        return True
    if now is None:
        now = datetime.now(UTC)
    return (now - ts) <= timedelta(days=max_age_days)


def _parse_cvss_score(vector: str) -> float | None:
    try:
        from cvss import CVSS3  # noqa: PLC0415
        c = CVSS3(vector)
        return float(c.base_score) if c.base_score is not None else None
    except Exception:  # noqa: BLE001
        return None


def _extract_severity(severity_list: list[dict]) -> tuple[str, bool]:
    """Prefer CVSS_V3. Returns (label, had_cvss). CVSS_V4 ignored for now."""
    for item in severity_list:
        if item.get("type") == "CVSS_V3":
            score = _parse_cvss_score(item.get("score", ""))
            if score is not None:
                return severity_from_cvss_score(score), True
    return severity_from_cvss_score(None), False


def _extract_pep440_ranges(ranges: list[dict]) -> list[dict]:
    """OSV PyPI uses ECOSYSTEM type with PEP-440 strings; SEMVER is rare."""
    out: list[dict] = []
    for r in ranges:
        if r.get("type") not in ("SEMVER", "ECOSYSTEM"):
            continue
        intro: str | None = None
        fixed: str | None = None
        last: str | None = None
        for ev in r.get("events", []):
            if "introduced" in ev:
                intro = ev["introduced"]
            elif "fixed" in ev:
                fixed = ev["fixed"]
            elif "last_affected" in ev:
                last = ev["last_affected"]
        if intro is None and fixed is None and last is None:
            continue
        entry: dict[str, str] = {}
        if intro is not None:
            entry["introduced"] = intro
        if fixed is not None:
            entry["fixed"] = fixed
        if last is not None:
            entry["last_affected"] = last
        out.append(entry)
    return out


def normalize_osv_pypi_entry(osv_entry: dict) -> list[dict] | None:
    """Return [compact-schema dict, ...] (one per PyPI affected pkg),
    or None if the entry is unusable / filtered out by bundle-size policy.

    Filter rules (5 axes):
      - withdrawn → drop
      - no PyPI affected → drop
      - no semver/ecosystem range → drop
      - no CVSS_V3 scoring → drop
      - severity in {medium, low} AND published > 2 years old → drop
    Multiple PyPI packages in `affected[]` expand to multiple records.
    """
    if osv_entry.get("withdrawn"):
        return None
    affected_pypi = [
        a for a in osv_entry.get("affected", [])
        if (a.get("package") or {}).get("ecosystem") == "PyPI"
    ]
    if not affected_pypi:
        return None

    severity, had_cvss = _extract_severity(osv_entry.get("severity", []))
    if not had_cvss:
        return None

    if severity in ("medium", "low") and not _is_within_age(
        osv_entry.get("published", "") or osv_entry.get("modified", ""),
        MEDIUM_LOW_MAX_AGE_DAYS,
    ):
        return None

    out: list[dict] = []
    for aff in affected_pypi:
        ranges = _extract_pep440_ranges(aff.get("ranges", []))
        if not ranges:
            continue
        pkg = normalize_pep503(aff["package"]["name"])
        out.append({
            "id": osv_entry["id"],
            "package": pkg,
            "ranges": ranges,
            "severity": severity,
            "summary": osv_entry.get("summary", "")[:200],
            "url": f"https://github.com/advisories/{osv_entry['id']}",
            "aliases": osv_entry.get("aliases", []),
        })
    return out if out else None
```

- [ ] **Step 6: Run tests to verify they pass**

Run: `.venv/bin/pytest tests/audit/ioc_update/fetchers/test_osv_pypi.py -v`
Expected: 8 PASS (the 7 listed + the normal-entry one)

- [ ] **Step 7: Commit**

```bash
git add src/agentsec/audit/ioc_update/fetchers/osv_pypi.py \
        tests/audit/ioc_update/fetchers/test_osv_pypi.py \
        tests/fixtures/osv/pypi/
git commit -m "feat(7d.1): OsvPyPIFetcher.normalize_osv_pypi_entry (offline)

5-axis filter chain mirroring osv_npm; PyPI ecosystem only;
PEP-503 package-name normalization; multi-affected expands to
multiple records.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```

---

## Task 11: `OsvPyPIFetcher` HTTP path (zip pull + retry)

**Files:**
- Modify: `src/agentsec/audit/ioc_update/fetchers/osv_pypi.py`
- Modify: `tests/audit/ioc_update/fetchers/test_osv_pypi.py`

- [ ] **Step 1: Write the failing tests with MockTransport**

Append to `tests/audit/ioc_update/fetchers/test_osv_pypi.py`:

```python
import asyncio
import io
import zipfile

import httpx

from agentsec.audit.ioc_update.fetchers.osv_pypi import OsvPyPIFetcher
from agentsec.audit.ioc_update.fetchers.base import FetcherContext


def _make_zip(entries: dict[str, dict]) -> bytes:
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w") as zf:
        for name, payload in entries.items():
            zf.writestr(name, json.dumps(payload))
    return buf.getvalue()


def test_fetch_success_one_zip():
    payload = _make_zip({
        "GHSA-norm-aaaa-bbbb.json": _load("normal.json"),
        "GHSA-with-aaaa-bbbb.json": _load("withdrawn.json"),  # filtered
    })

    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(200, content=payload)

    fetcher = OsvPyPIFetcher()
    fetcher._client_factory = lambda: httpx.AsyncClient(
        transport=httpx.MockTransport(handler),
    )

    ctx = FetcherContext(
        offline=False, user_agent="test", api_keys={}, now=datetime.now(UTC),
    )
    results = asyncio.run(fetcher.run(ctx, []))
    assert "_osv_pypi_full" in results
    r = results["_osv_pypi_full"]
    assert r.records is not None
    assert len(r.records) == 1
    assert r.records[0]["id"] == "GHSA-norm-aaaa-bbbb"


def test_fetch_retry_exhaustion_degraded():
    call_count = {"n": 0}

    def handler(request: httpx.Request) -> httpx.Response:
        call_count["n"] += 1
        return httpx.Response(503)

    fetcher = OsvPyPIFetcher()
    fetcher._client_factory = lambda: httpx.AsyncClient(
        transport=httpx.MockTransport(handler),
    )
    fetcher._sleep = lambda _s: asyncio.sleep(0)  # skip backoff in test

    ctx = FetcherContext(
        offline=False, user_agent="test", api_keys={}, now=datetime.now(UTC),
    )
    results = asyncio.run(fetcher.run(ctx, []))
    r = results["_osv_pypi_full"]
    assert r.records is None  # degraded
    assert "exhausted" in r.degraded_reason
    # 1 initial + 3 retries = 4 calls
    assert call_count["n"] == 4
```

(Confirm `FetcherContext` constructor signature by reading
`src/agentsec/audit/ioc_update/fetchers/base.py` first; adjust kwargs if
the field names differ.)

- [ ] **Step 2: Run tests to verify they fail**

Run: `.venv/bin/pytest tests/audit/ioc_update/fetchers/test_osv_pypi.py -k fetch -v`
Expected: FAIL — `OsvPyPIFetcher` not defined yet.

- [ ] **Step 3: Add the Fetcher class**

Append to `src/agentsec/audit/ioc_update/fetchers/osv_pypi.py`:

```python
class OsvPyPIFetcher(Fetcher):
    """Single-source full-ecosystem fetcher for OSV-PyPI.

    Doesn't take per-entry watchlist input — PyPI is one ecosystem and
    we pull everything in one shot. Returns a single FetchResult keyed
    under '_osv_pypi_full' for cli.py orchestration.
    """

    name = "osv_pypi"

    def __init__(self) -> None:
        self._client_factory = lambda: httpx.AsyncClient(
            timeout=httpx.Timeout(
                connect=10, read=_HTTP_TIMEOUT, write=10, pool=10,
            ),
        )
        self._sleep = asyncio.sleep

    async def run(
        self,
        ctx: FetcherContext,
        entries: list[WatchlistEntry],   # ignored — full ecosystem fetch
    ) -> dict[str, FetchResult]:
        if ctx.offline:
            return {
                "_osv_pypi_full": FetchResult.from_degraded(
                    reason="offline mode without OSV-PyPI fixture support",
                    fetcher=self.name,
                )
            }
        try:
            return await self._fetch_and_normalize(ctx)
        except (httpx.HTTPError, zipfile.BadZipFile) as e:
            return {
                "_osv_pypi_full": FetchResult.from_degraded(
                    reason=f"OSV-PyPI fetch failed: {e}",
                    fetcher=self.name,
                )
            }

    async def _fetch_and_normalize(
        self, ctx: FetcherContext,
    ) -> dict[str, FetchResult]:
        body: bytes | None = None
        last_status: int | None = None
        async with self._client_factory() as client:
            for delay in [0, *RETRY_BACKOFFS]:
                if delay:
                    await self._sleep(delay)
                resp = await client.get(
                    OSV_PYPI_URL, headers={"User-Agent": ctx.user_agent},
                )
                last_status = resp.status_code
                if resp.status_code == 200:
                    body = resp.content
                    break
        if body is None:
            return {
                "_osv_pypi_full": FetchResult.from_degraded(
                    reason="OSV-PyPI: all retries exhausted",
                    fetcher=self.name,
                    http_status=last_status,
                )
            }

        normalized: list[dict] = []
        seen_keys: set[tuple[str, str]] = set()
        with zipfile.ZipFile(io.BytesIO(body)) as zf:
            for name in zf.namelist():
                if not name.endswith(".json"):
                    continue
                try:
                    raw = json.loads(zf.read(name))
                except (json.JSONDecodeError, KeyError):
                    continue
                norm = normalize_osv_pypi_entry(raw)
                if norm is None:
                    continue
                for rec in norm:
                    key = (rec["id"], rec["package"])
                    if key in seen_keys:
                        continue
                    seen_keys.add(key)
                    normalized.append(rec)
        normalized.sort(key=lambda x: (x["id"], x["package"]))
        return {
            "_osv_pypi_full": FetchResult.ok(
                records=normalized,
                http_status=200,
                response_sha256=hash_response(body),
                raw_record_count=len(normalized),
            ),
        }
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `.venv/bin/pytest tests/audit/ioc_update/fetchers/test_osv_pypi.py -v`
Expected: 10 PASS (8 from Task 10 + 2 HTTP)

- [ ] **Step 5: Commit**

```bash
git add src/agentsec/audit/ioc_update/fetchers/osv_pypi.py \
        tests/audit/ioc_update/fetchers/test_osv_pypi.py
git commit -m "feat(7d.1): OsvPyPIFetcher HTTP path

httpx + zipfile + retry backoff [2,4,8]s; (id, package) dedup so
multi-affected expansions don't double-insert.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```

---

## Task 12: `_run_osv_pypi` parallel side pipeline in ioc-update CLI

**Files:**
- Modify: `src/agentsec/audit/ioc_update/cli.py`
- Create: `tests/test_ioc_update_osv_pypi_wiring.py` (new file mirroring `test_ioc_update_osv_npm_wiring.py`)

- [ ] **Step 1: Read existing `_run_osv_npm` to mirror structure**

```bash
cat src/agentsec/audit/ioc_update/cli.py | grep -n -A 30 'def _run_osv_npm'
```

Note: `_run_osv_npm(ctx, cache_dir) -> Path | None`; returns the proposed
file path on success and None on degraded fetch.

- [ ] **Step 2: Write the failing tests**

Create `tests/test_ioc_update_osv_pypi_wiring.py` (mirror of the npm wiring test):

```python
"""ioc-update CLI must invoke OsvPyPIFetcher and write proposed-osv_pypi.json (Phase 7d.1)."""

from __future__ import annotations

import json
from pathlib import Path
from unittest.mock import patch

import pytest

from agentsec.audit.ioc_update.cli import _run_osv_pypi
from agentsec.audit.ioc_update.fetchers.base import FetcherContext, FetchResult


@pytest.mark.asyncio
async def test_run_osv_pypi_writes_proposed_file(tmp_path: Path):
    fake_records = [
        {"id": "GHSA-y", "package": "django",
         "ranges": [{"introduced": "0", "fixed": "3.2.18"}],
         "severity": "high", "summary": "x", "url": "u", "aliases": []}
    ]
    with patch(
        "agentsec.audit.ioc_update.cli.OsvPyPIFetcher"
    ) as MockFetcher:
        async def fake_run(ctx, entries):
            return {"_osv_pypi_full": FetchResult.ok(records=fake_records)}
        MockFetcher.return_value.run.side_effect = fake_run

        ctx = FetcherContext(offline=False)
        out_path = await _run_osv_pypi(ctx, tmp_path)

    assert out_path == tmp_path / "proposed-osv_pypi.json"
    assert out_path.exists()
    payload = json.loads(out_path.read_text())
    assert payload == fake_records


@pytest.mark.asyncio
async def test_run_osv_pypi_handles_degraded_fetch(tmp_path: Path):
    with patch(
        "agentsec.audit.ioc_update.cli.OsvPyPIFetcher"
    ) as MockFetcher:
        async def fake_run(ctx, entries):
            return {"_osv_pypi_full": FetchResult.from_degraded(
                reason="HTTP 503", fetcher="osv_pypi",
            )}
        MockFetcher.return_value.run.side_effect = fake_run

        ctx = FetcherContext(offline=False)
        out_path = await _run_osv_pypi(ctx, tmp_path)

    assert out_path is None
```

- [ ] **Step 3: Run tests to verify they fail**

Run: `.venv/bin/pytest tests/test_ioc_update_osv_pypi_wiring.py -v`
Expected: ImportError on `_run_osv_pypi` (not defined yet) or `OsvPyPIFetcher` not exported from cli.

- [ ] **Step 4: Add `_run_osv_pypi` and wire it parallel**

In `src/agentsec/audit/ioc_update/cli.py`:

a. Add import near `from .fetchers.osv_npm import OsvNpmFetcher`:

```python
from .fetchers.osv_pypi import OsvPyPIFetcher
```

b. Add the function after `_run_osv_npm`:

```python
async def _run_osv_pypi(ctx: FetcherContext, cache_dir: Path) -> Path | None:
    """Side pipeline: pull OSV-PyPI, write proposed-osv_pypi.json.

    Mirrors _run_osv_npm. Runs in parallel with _run_osv_npm via
    asyncio.gather in the main entry point.
    """
    fetcher = OsvPyPIFetcher()
    results = await fetcher.run(ctx, [])
    result = results.get("_osv_pypi_full")
    if result is None or result.records is None:
        return None
    out_path = cache_dir / "proposed-osv_pypi.json"
    out_path.write_text(json.dumps(result.records, indent=2, sort_keys=True))
    return out_path
```

c. Where `_run_osv_npm(...)` is awaited in the main entry, change to
`asyncio.gather`:

```python
osv_npm_path, osv_pypi_path = await asyncio.gather(
    _run_osv_npm(fctx, out_dir),
    _run_osv_pypi(fctx, out_dir),
)
if osv_pypi_path is not None:
    log_event({
        "event": "osv_pypi_written",
        "path": str(osv_pypi_path),
        "records": len(json.loads(osv_pypi_path.read_text())),
    })
else:
    log_event({
        "event": "osv_pypi_degraded",
        "note": "OSV-PyPI fetch failed or skipped; existing osv_pypi.json unchanged",
    })
```

(Use the actual `log_event` shape from the surrounding code; if the
existing block uses a different logger, mirror that.)

- [ ] **Step 5: Run tests to verify they pass**

Run: `.venv/bin/pytest tests/test_ioc_update_osv_pypi_wiring.py tests/test_ioc_update_osv_npm_wiring.py -v`
Expected: 4 PASS (2 npm pre-existing + 2 new pypi).

- [ ] **Step 6: Commit**

```bash
git add src/agentsec/audit/ioc_update/cli.py \
        tests/test_ioc_update_osv_pypi_wiring.py
git commit -m "feat(7d.1): _run_osv_pypi parallel side pipeline

Runs alongside _run_osv_npm via asyncio.gather; degraded one
half doesn't block the other.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```

---

## Task 13: Register `PipSupplyChainAuditCheck` in CLI + wire `pip_lock_paths` injection

**Files:**
- Modify: `src/agentsec/cli.py` (4 spots: 2 imports + register at lines ~534/837 + injection in `_evaluate_server` + injection in standalone `server_audit`)

- [ ] **Step 1: Add import**

Modify `src/agentsec/cli.py:36` (after the `SupplyChainAuditCheck` import):

```python
from .audit.checks.supply_chain_audit import SupplyChainAuditCheck
from .audit.checks.pip_supply_chain_audit import PipSupplyChainAuditCheck
```

- [ ] **Step 2: Register the Check in standalone `server_audit` (line ~534)**

Locate the `checks = [...]` list around `cli.py:534` and append:

```python
        SupplyChainAuditCheck(),
        PipSupplyChainAuditCheck(),
    ]
```

- [ ] **Step 3: Wire `pip_lock_paths` for the standalone path**

The standalone `server_audit` typer command (around lines 425-567) does
not currently accept `pip_lock_paths` from a config. Add a CLI option:

Add near other `typer.Option(...)` declarations of the standalone
`server_audit` function:

```python
    pip_lock_paths: list[str] = typer.Option(
        [], "--pip-lock-path",
        help="Repeat for each requirements.txt to scan; absolute or ~/-rooted.",
    ),
```

Then where `policy = CommandPolicy.from_yaml(policy_path, remote_home=remote_home)` is set up (around line 481), add right after:

```python
    if pip_lock_paths:
        # Validate via the same Pydantic validator as evaluate-mode
        from .config import ServerAuditConfig
        ServerAuditConfig.model_validate({
            "host": "validation-only",
            "user": "validation-only",
            "key": "/dev/null",
            "pip_lock_paths": pip_lock_paths,
        })
        policy.add_runtime_allowed_paths(
            [_expand_tilde(p, remote_home) for p in pip_lock_paths],
            kind="exact",
        )
```

Where `_expand_tilde` is a small helper (add at module top or inline):

```python
def _expand_tilde(path: str, remote_home: str) -> str:
    if path.startswith("~/"):
        return f"{remote_home.rstrip('/')}/{path[2:]}"
    return path
```

Then where `ctx = CheckContext(...)` is built around line 537, add the
field:

```python
    ctx = CheckContext(
        executor=executor, redactor=redactor,
        ioc_dir=Path(__file__).parent / "audit" / "ioc",
        profile=profile,
        log_review_config=LogReviewConfig(),
        pip_lock_paths=tuple(_expand_tilde(p, remote_home) for p in pip_lock_paths),
    )
```

- [ ] **Step 4: Register the Check in `_evaluate_server` (line ~837)**

Locate the `checks = [...]` list around `cli.py:837` and append:

```python
        SupplyChainAuditCheck(),
        PipSupplyChainAuditCheck(),
    ]
```

- [ ] **Step 5: Wire `pip_lock_paths` for the evaluate path**

In `_evaluate_server` (around line 786 after `policy = CommandPolicy.from_yaml(sa.ssh_policy, remote_home=remote_home)`):

```python
    if sa.pip_lock_paths:
        policy.add_runtime_allowed_paths(
            [_expand_tilde(p, remote_home) for p in sa.pip_lock_paths],
            kind="exact",
        )
```

Then where `ctx = CheckContext(...)` is built around line 839:

```python
    ctx = CheckContext(
        executor=executor, redactor=redactor,
        ioc_dir=sa.ioc_db, profile=profile,
        log_review_config=sa.log_review,
        pip_lock_paths=tuple(_expand_tilde(p, remote_home) for p in sa.pip_lock_paths),
    )
```

- [ ] **Step 6: Run full suite**

Run: `.venv/bin/pytest tests/ -q`
Expected: 1177 + new tests so far (Tasks 1-12 added ~38 tests) ≈ 1215 PASS, no regression.

The CLI wiring is exercised end-to-end by Task 14 (sample report refresh,
which runs the integration test and round-trips `evaluate`).

- [ ] **Step 7: Commit**

```bash
git add src/agentsec/cli.py
git commit -m "cli(7d.1): register PipSupplyChainAuditCheck + wire pip_lock_paths

Standalone server-audit gains --pip-lock-path repeatable flag;
evaluate flow reads ServerAuditConfig.pip_lock_paths; both inject
paths into CommandPolicy via add_runtime_allowed_paths(kind=exact)
and propagate to CheckContext.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```

---

## Task 14: Sample report refresh

**Files:**
- Refresh: `docs/samples/report-2026-04-28/**`

- [ ] **Step 1: Refresh the committed sample**

Run: `AGENTSEC_REFRESH_SAMPLE=1 .venv/bin/pytest tests/integration/test_openclaw_mock.py::test_openclaw_mock_end_to_end -v`
Expected: PASS, sample dir updated.

- [ ] **Step 2: Inspect the diff**

Run: `git diff docs/samples/report-2026-04-28/`
Expected: only additions of `pip_supply_chain_audit: not_applicable (未配置 pip_lock_paths)` lines + matching report entries.

If anything else changed unexpectedly, read the diff carefully — that's a regression signal to investigate before committing.

- [ ] **Step 3: Commit**

```bash
git add docs/samples/report-2026-04-28/
git commit -m "test(7d.1): refresh mock sample report (pip Check not_applicable)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```

---

## Task 15: Side-fix — `OsvNpmFetcher.normalize_osv_entry` `affected[0]` truncation

**Files:**
- Modify: `src/agentsec/audit/ioc_update/fetchers/osv_npm.py`
- Modify: `tests/audit/ioc_update/fetchers/test_osv_npm.py`

This is an **independent commit** decoupled from the 7d.1 main path.

- [ ] **Step 1: Find the existing test file**

Run: `ls tests/audit/ioc_update/fetchers/test_osv_npm.py 2>/dev/null || find tests -name 'test_osv_npm*' 2>/dev/null`

If the file doesn't exist (npm test in a different file), search:

Run: `grep -rln "normalize_osv_entry" tests/`

Use whichever file already covers the npm fetcher.

- [ ] **Step 2: Write the failing multi-affected test**

Append to that file (adapt fixture loading to its style):

```python
def test_normalize_osv_npm_multi_affected_expansion():
    entry = {
        "id": "GHSA-multi-npm-aaaa",
        "published": "2025-06-01T00:00:00Z",
        "summary": "Affects two npm packages",
        "affected": [
            {
                "package": {"ecosystem": "npm", "name": "pkg-a"},
                "ranges": [{"type": "SEMVER",
                           "events": [{"introduced": "0"}, {"fixed": "1.0.0"}]}],
            },
            {
                "package": {"ecosystem": "npm", "name": "pkg-b"},
                "ranges": [{"type": "SEMVER",
                           "events": [{"introduced": "0"}, {"fixed": "2.0.0"}]}],
            },
        ],
        "severity": [{"type": "CVSS_V3",
                      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"}],
    }
    out = normalize_osv_entry(entry)
    # Now expects a list (post-fix) instead of single dict
    assert isinstance(out, list)
    assert len(out) == 2
    pkgs = sorted(r["package"] for r in out)
    assert pkgs == ["pkg-a", "pkg-b"]
```

- [ ] **Step 3: Run test to verify it fails**

Run: `.venv/bin/pytest <path>::test_normalize_osv_npm_multi_affected_expansion -v`
Expected: FAIL — current code returns a single dict, not a list.

- [ ] **Step 4: Refactor `normalize_osv_entry` to return `list[dict] | None`**

In `src/agentsec/audit/ioc_update/fetchers/osv_npm.py`, modify
`normalize_osv_entry`:

```python
def normalize_osv_entry(osv_entry: dict) -> list[dict] | None:
    """Return [compact-schema dict, ...], one per npm affected pkg, or
    None if filtered out by bundle-size policy.

    Pre-fix: returned a single dict from affected_npm[0]; multi-pkg
    advisories were truncated. Post-fix: each PyPI affected pkg
    expands to its own record (mirrors osv_pypi behavior).
    """
    if osv_entry.get("withdrawn"):
        return None
    affected_npm = [
        a for a in osv_entry.get("affected", [])
        if (a.get("package") or {}).get("ecosystem") == "npm"
    ]
    if not affected_npm:
        return None

    severity, had_cvss = _extract_severity(osv_entry.get("severity", []))
    if not had_cvss:
        return None
    if severity in ("medium", "low") and not _is_within_age(
        osv_entry.get("published", "") or osv_entry.get("modified", ""),
        MEDIUM_LOW_MAX_AGE_DAYS,
    ):
        return None

    out: list[dict] = []
    for aff in affected_npm:
        ranges = _extract_semver_ranges(aff.get("ranges", []))
        if not ranges:
            continue
        pkg = aff["package"]["name"]
        out.append({
            "id": osv_entry["id"],
            "package": pkg,
            "ranges": ranges,
            "severity": severity,
            "summary": osv_entry.get("summary", "")[:200],
            "url": f"https://github.com/advisories/{osv_entry['id']}",
            "aliases": osv_entry.get("aliases", []),
        })
    return out if out else None
```

Update the caller in the same file (the `_fetch_and_normalize` loop):

```python
                norm = normalize_osv_entry(raw)
                if norm is None:
                    continue
                for rec in norm:
                    key = (rec["id"], rec["package"])
                    if key in seen_keys:
                        continue
                    seen_keys.add(key)
                    normalized.append(rec)
```

(Replace the existing `seen_ids` with `seen_keys: set[tuple[str, str]]`.)

- [ ] **Step 5: Update existing tests that asserted single-dict shape**

Run: `grep -n "normalize_osv_entry" tests/`

For each test that asserts on the dict directly (not a list), wrap the
assertion: `out = normalize_osv_entry(entry); assert isinstance(out, list); rec = out[0]; ...`

- [ ] **Step 6: Run full test suite**

Run: `.venv/bin/pytest tests/ -q`
Expected: all green, including the new multi-affected case.

- [ ] **Step 7: Commit**

```bash
git add src/agentsec/audit/ioc_update/fetchers/osv_npm.py \
        tests/audit/ioc_update/fetchers/test_osv_npm.py
git commit -m "fix(7d): OsvNpmFetcher emits one record per affected npm pkg

Pre-fix took affected_npm[0] only, truncating multi-pkg advisories.
Refactored to return list[dict] mirroring 7d.1 OSV-PyPI shape.
Caller dedups on (id, package) instead of bare id.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```

---

## Task 16: Populate `osv_pypi.json` with first real bundle

**Files:**
- Modify: `src/agentsec/audit/ioc/osv_pypi.json`
- Possibly modify: `src/agentsec/audit/ioc/osv_npm.json` (regenerate after Task 15 fix)

- [ ] **Step 1: Run ioc-update**

Ensure secrets are loaded:

```bash
source ~/.config/agentsec/env
GITHUB_TOKEN="$(gh auth token)" .venv/bin/agentsec ioc-update
```

The CLI will write `proposed-osv_pypi.json` (and refresh `proposed-osv_npm.json`) to `.cache/ioc-update-2026-05-11/` (the date stamp will match today).

- [ ] **Step 2: Inspect the proposed PyPI bundle**

Run: `wc -c .cache/ioc-update-*/proposed-osv_pypi.json`
Expected: < 15 MB (likely ~1 MB based on spec estimate).

Run: `.venv/bin/python -c 'import json,sys; d=json.load(open(".cache/ioc-update-'"$(date +%Y-%m-%d)"'/proposed-osv_pypi.json")); print(f"{len(d)} entries"); print({k:sum(1 for r in d if r["severity"]==k) for k in ["critical","high","medium","low"]})'`
Expected: ~3000-5000 entries with reasonable severity distribution.

- [ ] **Step 3: Copy to live location**

```bash
DATE=$(date +%Y-%m-%d)
cp .cache/ioc-update-$DATE/proposed-osv_pypi.json src/agentsec/audit/ioc/osv_pypi.json
```

- [ ] **Step 4: Verify CI guard passes**

Run: `.venv/bin/python scripts/check_osv_pypi.py`
Expected: `OK: src/agentsec/audit/ioc/osv_pypi.json (... KB, NNNN entries)`.

- [ ] **Step 5: Check if osv_npm.json needs refresh too**

After Task 15's fix, the npm bundle may have new records (multi-pkg advisories
that were previously truncated):

Run: `diff <(jq 'length' src/agentsec/audit/ioc/osv_npm.json) <(jq 'length' .cache/ioc-update-$DATE/proposed-osv_npm.json)`

If different, copy:
```bash
cp .cache/ioc-update-$DATE/proposed-osv_npm.json src/agentsec/audit/ioc/osv_npm.json
.venv/bin/python scripts/check_osv_npm.py   # verify still ≤ 15 MB
```

- [ ] **Step 6: Commit**

```bash
git add src/agentsec/audit/ioc/osv_pypi.json
# include osv_npm.json if it changed in Step 5
git diff --cached --stat src/agentsec/audit/ioc/osv_npm.json && \
    git add src/agentsec/audit/ioc/osv_npm.json

git commit -m "data(7d.1): populate osv_pypi.json with $(date +%Y-%m-%d) OSV-PyPI snapshot

Initial bundle from OSV-PyPI ecosystem feed via ioc-update;
filter chain shrinks ~XK entries → NNNN entries / ~M.M MB.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```

(Replace XK / NNNN / M.M with actual numbers from Step 2.)

---

## Task 17: ADR-0013

**Files:**
- Create: `docs/adr/0013-phase-7d1-pip-supply-chain.md`

- [ ] **Step 1: Read the ADR template**

Run: `cat docs/adr/_template.md`

- [ ] **Step 2: Write the ADR**

Open spec §13 (decision table) in another window — every D-row maps to one ADR section.

Create `docs/adr/0013-phase-7d1-pip-supply-chain.md` with this exact skeleton, then for each section copy the spec §13 row's "决策" cell as the section opening line, "备选" cell as a "Considered alternatives" subsection, and "选择理由" cell as a "Rationale" subsection:

```markdown
# ADR-0013 — Phase 7d.1 pip / PyPI 供应链审计

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

## Context

7d-npm（v0.12.0）落地了 npm 生态供应链审计。Phase 7d.1 把同一能力扩到
PyPI 生态。OpenClaw 本身是 npm 项目，主机上不一定有稳定 Python footprint，
因此 "审什么" 需要新的来源模型。同时借此机会修了 7d-npm 一处 affected[0]
截断的 pre-existing 漏洞。

## Decisions

### D1 — 路径来自 operator config，不进 PlatformProfile

**决策**：在 `ServerAuditConfig.pip_lock_paths` 收集 lockfile 路径列表，
不在 `PlatformProfile.paths` 加 `pip_root`。

**Considered alternatives**：(a) profile 加 `pip_root` 默认值；(b) 主机扫
`pip list`。

**Rationale**：OpenClaw 无稳定 Python footprint；operator config 即 consent。

### D2 — 仅支持 requirements.txt
（按上面格式，从 spec §13 D2 行展开）

### D3 — 不抽 `_supply_chain_common.py`
（spec §13 D3）

### D4 — 后缀白名单 4 项
（spec §13 D4）

### D5 — PEP-440 + pre-release 包含 + local-version 忽略
（spec §13 D5）

### D6 — exact-kind 动态注入 allowed_paths
（spec §13 D6）

### D7 — 顺手修 npm `affected[0]`
（spec §13 D7）

### D8 — OSV-PyPI 5-轴 filter chain 与 npm 同
（spec §13 D8）

### D9 — TI 单 umbrella `TI-OSV-PYPI-CATALOG`
（spec §13 D9）

### D10 — Check 通道走 `CheckContext.pip_lock_paths`
（spec §13 D10）

## Consequences

- pip 供应链审计需要 operator 在 openclaw-target.yaml 显式配置；零配置默认 not_applicable
- 7d.2 (go) 落地时统一抽 `_supply_chain_common.py`（rule of three 触发）
- npm 多包 advisory 自此不再被截断（D7 顺手修）

## References

- Spec: `docs/superpowers/specs/2026-05-11-phase-7d1-pip-supply-chain-design.md`
- ADR-0012: 前置 7d-npm 决策
```

For each "（spec §13 D# 行）" placeholder, expand using the same three-subsection pattern shown for D1 — pull "决策" / "备选" / "选择理由" verbatim from spec §13 D# row.

- [ ] **Step 3: Commit**

```bash
git add docs/adr/0013-phase-7d1-pip-supply-chain.md
git commit -m "docs(adr): ADR-0013 Phase 7d.1 pip supply chain decisions

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```

---

## Task 18: Docs sync + version bump → v0.13.0

**Files:**
- Modify: `pyproject.toml` (version)
- Modify: `ROADMAP.md`
- Modify: `CHANGELOG.md`
- Modify: `README.md`
- Modify: `CLAUDE.md`

- [ ] **Step 1: Bump version**

In `pyproject.toml`, change `version = "0.12.0"` → `version = "0.13.0"`.

- [ ] **Step 2: Update ROADMAP.md**

Find Phase 7 section. Change:

```
- [ ] Phase 7d.1+ — Skills / Plugins 深化的剩余面（pip / go / rust 生态 + plugin_static 静态扫描扩展）
```

to:

```
- [x] Phase 7d.1 — pip 供应链 Check（OSV-PyPI bundled DB，TI-OSV-PYPI-CATALOG）— 2026-05-11
- [ ] Phase 7d.2+ — Skills / Plugins 深化的剩余面（go / rust 生态 + plugin_static 静态扫描扩展）
```

- [ ] **Step 3: Update CHANGELOG.md**

Add a new section at the top:

```markdown
## v0.13.0 — 2026-05-11

### Added
- **Phase 7d.1 — pip / PyPI supply chain audit Check** (`pip_supply_chain_audit`)
  - Reads operator-supplied `requirements.txt` paths from `ServerAuditConfig.pip_lock_paths`
  - Matches frozen `pkg==ver` lines against bundled `osv_pypi.json` (NNNN advisories at 2026-05-11)
  - PEP-440 + PEP-503 compliant matcher / normalizer
  - Dynamic injection into `CommandPolicy.allowed_paths` via new `add_runtime_allowed_paths(kind=exact)`
  - Standalone `agentsec server-audit` accepts repeatable `--pip-lock-path`
  - Side fetcher `OsvPyPIFetcher` runs parallel to OSV-npm in `agentsec ioc-update`
  - CI guard `scripts/check_osv_pypi.py` (≤ 15 MB hard cap)
  - TI umbrella `TI-OSV-PYPI-CATALOG`
- **server-audit 25 类 → 26 类 Check**

### Fixed
- `OsvNpmFetcher.normalize_osv_entry` no longer truncates multi-pkg advisories
  to `affected_npm[0]`; now emits one record per affected npm package
  (deduped on `(id, package)`)

### Refresh
- `osv_npm.json` regenerated to include records previously dropped by the truncation bug
- `osv_pypi.json` first bundle: NNNN entries / M.M MB

### Internal
- `CheckContext.pip_lock_paths: tuple[str, ...]` field
- `ServerAuditConfig.pip_lock_paths: list[str]` config field with validator
  (absolute / `~/`-rooted, no shell metachars, suffix in {requirements*, .txt, .lock, .in})
- Test count 1177 → ~1218 passing
```

(Fill NNNN / M.M from Task 16 actual numbers.)

- [ ] **Step 4: Update README.md**

Find the section that lists Check counts (search for "25 类" or
"server-audit"). Change to "26 类" and add `pip_supply_chain_audit` in
the appropriate table.

Run: `grep -n '25 类' README.md`

- [ ] **Step 5: Update CLAUDE.md**

Find the Phase 7d / Phase 7f sections that list Check counts. Update
"25 类" → "26 类" and add a paragraph after the existing Phase 7d
section:

```markdown
## pip supply chain audit (Phase 7d.1, v0.13.0)

- **`PipSupplyChainAuditCheck`** in `src/agentsec/audit/checks/pip_supply_chain_audit.py` reads operator-configured `requirements.txt` paths from `CheckContext.pip_lock_paths` (sourced from `ServerAuditConfig.pip_lock_paths`), parses frozen `pkg==ver` lines, normalizes per PEP-503, and matches against the bundled `osv_pypi.json` using PEP-440 ranges. Emits one Finding per (pkg, version, advisory) match; `threat_refs=["TI-OSV-PYPI-CATALOG"]`.
- **Dynamic policy injection** — `CommandPolicy.add_runtime_allowed_paths(paths, kind="exact")` is called by `_evaluate_server` and the standalone `server-audit` to honor `pip_lock_paths` without editing `ssh_policy.yaml`. Operator's config entry == consent.
- **Path validation** — `ServerAuditConfig.pip_lock_paths` validator: must start with `/` or `~/`; no shell metachars (via `metachar_guard.contains_shell_metachar`); filename suffix in {`requirements*`, `.txt`, `.lock`, `.in`}.
- **`OsvPyPIFetcher`** mirrors `OsvNpmFetcher` (5-axis filter chain) but pulls `https://osv-vulnerabilities.storage.googleapis.com/PyPI/all.zip`. Runs as a side pipeline (`_run_osv_pypi`, parallel to `_run_osv_npm` via `asyncio.gather`).
- **`scripts/check_osv_pypi.py --check`** enforces valid JSON + ≤ 15 MB cap.
- **Bundled DB**: `src/agentsec/audit/ioc/osv_pypi.json` (NNNN advisories, M.M MB at 2026-05-11).
- **Adding a new ecosystem (e.g., go in 7d.2)**: at this point pull common helpers (`severity_from_cvss_score`, `_is_within_age`, `_extract_severity`) into `src/agentsec/audit/ioc_update/fetchers/_osv_common.py` and `_supply_chain_common.py` for the Check side (rule of three).
```

Also update the "Total checks" line in the existing 7d block to `26 类`.

- [ ] **Step 6: Run pre-commit verification**

```bash
.venv/bin/ruff check src/ tests/ scripts/
.venv/bin/pyright src/agentsec/
.venv/bin/python scripts/check_owasp_coverage.py
.venv/bin/python scripts/check_threat_refs.py
.venv/bin/python scripts/build_cve_db.py --check
.venv/bin/python scripts/check_osv_npm.py
.venv/bin/python scripts/check_osv_pypi.py
```

All must exit 0.

- [ ] **Step 7: Run full test suite**

```bash
.venv/bin/pytest tests/ -q
```

Expected: ~1218 passing.

- [ ] **Step 8: Commit**

```bash
git add pyproject.toml ROADMAP.md CHANGELOG.md README.md CLAUDE.md
git commit -m "docs(7d.1): bump 0.13.0 + ROADMAP/CHANGELOG/README/CLAUDE.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```

---

## Task 19: Pre-release verification

**Files:** none (verification only)

- [ ] **Step 1: Re-run all CI gates from scratch**

```bash
.venv/bin/pytest tests/ -q
.venv/bin/ruff check src/ tests/ scripts/
.venv/bin/pyright src/agentsec/
.venv/bin/python scripts/check_owasp_coverage.py
.venv/bin/python scripts/check_threat_refs.py
.venv/bin/python scripts/build_cve_db.py --check
.venv/bin/python scripts/check_osv_npm.py
.venv/bin/python scripts/check_osv_pypi.py
```

All exit 0; pytest reports ~1218 passing.

- [ ] **Step 2: Build the wheel locally and inspect**

```bash
rm -rf dist/
.venv/bin/python -m build
ls -lah dist/
```

Verify `agentsec_eval-0.13.0-py3-none-any.whl` exists; size should be roughly the v0.12.0 wheel + ~1 MB (for `osv_pypi.json`).

- [ ] **Step 3: Smoke test the wheel in a throwaway venv**

```bash
python3.12 -m venv /tmp/smoke-7d1 && /tmp/smoke-7d1/bin/pip install dist/agentsec_eval-0.13.0-py3-none-any.whl
/tmp/smoke-7d1/bin/agentsec --help | grep server-audit
/tmp/smoke-7d1/bin/agentsec server-audit --help | grep pip-lock-path
```

The `--pip-lock-path` flag should be visible.

- [ ] **Step 4: Hand off to operator for release**

The user (operator) decides when to push tags + upload to PyPI. **Stop here**
and report:

```
v0.13.0 ready locally. Next steps for operator:
  1. git push origin main
  2. git tag -a v0.13.0 -m 'Phase 7d.1 — pip supply chain audit'
  3. git push origin v0.13.0
  4. twine upload dist/agentsec_eval-0.13.0*
```

---

## Self-Review Checklist (run after writing the plan)

- [ ] Spec §1 (Config schema): Task 1 ✓
- [ ] Spec §2 (CheckContext): Task 2 ✓
- [ ] Spec §3.5 run() template: Task 7 ✓
- [ ] Spec §4 Finding shape: Task 7 ✓
- [ ] Spec §5 dynamic injection: Tasks 4 + 13 ✓
- [ ] Spec §6 parser: Task 6 ✓
- [ ] Spec §7 PEP-440 matcher: Task 5 ✓
- [ ] Spec §8 OSV-PyPI fetcher: Tasks 10 + 11 ✓
- [ ] Spec §9 ioc-update CLI: Task 12 ✓
- [ ] Spec §10 tests: distributed across all tasks; total = 41 expected
- [ ] Spec §11 npm affected[0] fix: Task 15 ✓
- [ ] Spec §12 CI / Release: Tasks 9 + 18 + 19 ✓
- [ ] Spec §13 ADR: Task 17 ✓
- [ ] Spec §14 Out of scope: respected (no poetry.lock parser, no `_supply_chain_common.py` extraction, no macOS)

If any checkbox above is missing — go back, add the corresponding task, then re-verify.
