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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ how they got here. Ordered within each group by severity.

### A. Honesty of reporting — a blank that reads as a clean answer

- [ ] **HIGH** `pipeline.py:152` — The spec-vs-code orphan comparison is fed framework route-registration lines, not paths, so `served` is empty and every endpoint the code demonstrably serves is reported as "declared in the spec but served by no route in the code" — while `code_only: []` renders as "no undeclared routes" having checked nothing
- [x] **HIGH** `pipeline.py:152` — The spec-vs-code orphan comparison is fed framework route-registration lines, not paths, so `served` is empty and every endpoint the code demonstrably serves is reported as "declared in the spec but served by no route in the code" — while `code_only: []` renders as "no undeclared routes" having checked nothing
- *Fails when:* The documented invocation `vpcopilot scan ./app --spec ./openapi.yaml` (docs/USAGE.md:96). `openapi.orphans(spec, repo_routes)` documents its input as lines like `"GET POST /users/v1/register"` — the shape `routes._openapi_paths` emits. But pipeline.py:152 hands it `route_ctx.splitlines()`, and for any repo that does not itself contain an OpenAPI/Swagger file, `collect_route_context` returns only
- *Repro:* `/Users/d.henley/demos/virtual-patch-copilot/.venv/bin/python /tmp/vpr1/repro.py (script replays pipeline.py:141 and :152 verbatim against a 3-route Flask app at /tmp/vpr1/app and`
- *Why the suite misses it:* tests/test_inputs_openapi.py:112-128 exercises `orphans` only with hand-written path-shaped lists (`["POST /api/pay", "GET /api/legacy"]`) — i.e. it invents the input instead of taking it from the real producer, `collect_route_context`. tests/test_routes.py tests `collect_route_context` in isolation
Expand All @@ -178,11 +178,11 @@ import json,sys,tempfile; sys.path.insert(0,'src')
from vpcopilot import ledger, impact, report
d=tempfile.mkdtemp`
- *Why the suite misses it:* Every ledger fixture in tests/test_impact.py gives the remediated/retired entries a `mitigation` block (`_seed` line 12-13, `test_controls_live_excludes_retired` line 50). The suite has no entry that reached `remediated` without first being `mitigated`, so `mitigated` and `controls_live` always agre
- [ ] **MEDIUM** `cli.py:70` — The scan input-path existence check exists only on MCP: `vpcopilot scan /does/not/exist` and POST /api/scan both run the pipeline to completion and write a clean-bill-of-health summary.json for a directory that was never read
- [x] **MEDIUM** `cli.py:70` — The scan input-path existence check exists only on MCP: `vpcopilot scan /does/not/exist` and POST /api/scan both run the pipeline to completion and write a clean-bill-of-health summary.json for a directory that was never read
- *Fails when:* mcp.py:290-294 checks every repo/spec/manifest path before starting the job, citing run_pipeline's own docstring: "a scan of nothing would write a summary saying nothing was found, which is not the same answer". cli.py:66-75 and console/app.py:847-854 validate the CVE-exclusivity rule and min_severity but never check that the paths exist, and `run_pipeline` (pipeline.py:80-88) validates only that
- *Repro:* `/Users/d.henley/demos/virtual-patch-copilot/.venv/bin/python /tmp/probe_surfaces_scan.py # calls scan_start / the typer CLI / POST /api/scan with repo=/does/not/exist/at/all; an`
- *Why the suite misses it:* tests/test_mcp.py:801 is the only test that asserts "does not exist" anywhere in the suite, and it drives the MCP frame path exclusively. The CLI scan tests and the console scan tests (test_inputs_openapi.py:168, test_inputs_manifest.py:955) assert the *accepting* half of the input rules — that a sp
- [ ] **MEDIUM** `cli.py:1017` — `_require_run_dir` — "a run directory that does not exist is not an empty one" — is enforced only in mcp.py: the CLI prints a green "no live band-aids" and the console returns all-zero impact for a run directory that isn't there
- [x] **MEDIUM** `cli.py:1017` — `_require_run_dir` — "a run directory that does not exist is not an empty one" — is enforced only in mcp.py: the CLI prints a green "no live band-aids" and the console returns all-zero impact for a run directory that isn't there
- *Fails when:* mcp.py:128-137 declines `patches_list`, `ledger` and `impact` for a missing `out`, with the docstring "A typo'd `out` would have produced the most reassuring possible answer." That exact answer is what the other two surfaces give: `vpcopilot patches-list --out <typo>` prints `no live band-aids` in green and exits 0 (cli.py:1016-1018), and the console — whose OUT global is set straight from the Sca
- *Repro:* `/Users/d.henley/demos/virtual-patch-copilot/.venv/bin/python -m vpcopilot.cli patches-list --out /nonexistent-run-dir; echo "exit=$?" # then the MCP contrast: .venv/bin/python -c`
- *Why the suite misses it:* The guard lives in mcp.py rather than in reconcile.list_patches / ledger.load / impact.impact, so the only test that can see it is tests/test_mcp.py:790 — the sole "no run directory" assertion in the suite, driven through MCP frames. tests/test_console_reconcile.py and the CLI tests always build a r
Expand Down
7 changes: 7 additions & 0 deletions src/vpcopilot/console/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -919,6 +919,13 @@ def start_scan(body: ScanReq):
# into the same out dir.
# * after validation: claiming first meant a request that then 400'd left the scanner marked
# running forever — one malformed request and the console can never scan again.
# On the REQUEST thread, before the worker is spawned — raising inside the pipeline would
# surface as a job error after this endpoint had already returned 200 "running".
from ..pipeline import validate_scan_inputs
try:
validate_scan_inputs(body.repo or None, body.spec or None, manifests)
except ValueError as e:
raise HTTPException(400, str(e)) from None
with _scan_lock:
if _scan["state"] == "running":
raise HTTPException(409, "a scan is already running")
Expand Down
51 changes: 45 additions & 6 deletions src/vpcopilot/inputs/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
"""
from __future__ import annotations

import re

from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -176,23 +178,60 @@ def _normalize(path: str) -> str:
return "/" + p.strip("/").lower()


# A quoted, absolute path inside a route-registration line: @app.route("/api/pay", …),
# path('/users/<uid>'), router.get(`/api/x`). Deliberately literal-only — a path built by
# concatenation is not evidence of what is served, and guessing at one would be worse than
# the gap it fills.
_QUOTED_PATH = re.compile(r"""['"`](/[^'"`\s]*)['"`]""")

def orphans(spec: dict, repo_routes: list[str]) -> dict:
"""Spec vs code, both directions. Pure comparison — no model, no network.

`spec_only`: declared but nothing serves it — dead documentation, or a shadow API someone
forgot to remove. `code_only`: served but undeclared — and this is the one that bites, because
an `api_schema` band-aid built from this spec would start rejecting those routes the moment it
is applied."""
`code_only`: served but undeclared — the one that bites, because an `api_schema` band-aid
built from this spec would start rejecting those routes the moment it is applied. This is a
PRESENCE claim and a source sweep can establish it.

`spec_unverified`: declared, and the sweep did not find it in the source. Deliberately NOT
called `spec_only` any more, and deliberately not phrased as "nothing serves this": that is an
ABSENCE claim, and a line-wise sweep cannot establish absence. File-based routing
(`app/api/pay/route.js`) declares a route with no line to match, and a dynamically mounted
router never appears literally. The old key asserted the strong reading and was consumed as
such — it raised a medium finding — so it is gone rather than left empty for someone to
reach for again.

`swept`: whether any route was extracted at all. A sweep that found nothing is not a spec
whose endpoints are all unserved, and the two used to be indistinguishable."""
declared = {_normalize(op["path"]): op["path"] for op in operations(spec)}
served = {}
for r in repo_routes or []:
# route context lines look like "GET POST /users/v1/register" or "/users/v1/register"
# Two shapes reach here and only one used to be handled:
# "GET POST /users/v1/register" <- an in-repo spec's paths
# ' app.py:2: @app.route("/api/pay", methods=[…])' <- a framework REGISTRATION line
# The old code took the last whitespace token and kept it if it began with "/". A
# registration line's last token is `methods=["POST"])`, so it kept NONE of them — meaning
# `served` was populated only from an in-repo OpenAPI file and NEVER from code, in any
# configuration. With no in-repo spec every declared endpoint became "declared but served
# by no route", and with one the comparison was spec-vs-spec, so a genuinely served
# undeclared route was reported nowhere. Verified against a 3-route Flask app.
parts = r.split()
path = parts[-1] if parts else ""
if path.startswith("/"):
served[_normalize(path)] = path
continue
for quoted in _QUOTED_PATH.findall(r):
served[_normalize(quoted)] = quoted

# `code_only` is a PRESENCE claim — "the sweep found this route in the source and the spec does
# not declare it" — and a source sweep can establish presence. `spec_only` is an ABSENCE claim
# — "nothing serves this" — and a line-wise sweep cannot establish that: file-based routing
# (app/api/pay/route.js) declares a route with no line to grep, and dynamically mounted routers
# never appear literally. So the unmatched declarations are returned as `spec_unverified`, NOT
# as orphans, whenever the sweep is the only evidence. Publishing them as orphans is a
# confident claim resting on evidence that cannot support it.
unmatched = sorted(declared[k] for k in declared.keys() - served.keys())
return {
"spec_only": sorted(declared[k] for k in declared.keys() - served.keys()),
"spec_unverified": unmatched,
"swept": bool(served),
"code_only": sorted(served[k] for k in served.keys() - declared.keys()),
"matched": sorted(declared[k] for k in declared.keys() & served.keys()),
}
Expand Down
51 changes: 44 additions & 7 deletions src/vpcopilot/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,27 @@ def _dedup_findings(findings, log, counter: dict | None = None):
return kept


def validate_scan_inputs(repo_path: str | None = None, spec_path: str | None = None,
manifest_paths: list[str] | None = None) -> None:
"""Every input path must exist. Raises ValueError naming the offender.

This check lived ONLY in mcp.py, so `vpcopilot scan /does/not/exist` and POST /api/scan both
ran to completion and wrote a clean-bill-of-health summary.json for a directory that was never
read — the exact behaviour `run_pipeline`'s own docstring calls "the failure mode not to
extend", extended to two of the three surfaces.

Public and separate from `run_pipeline` because the console starts scans on a worker thread:
raising inside the pipeline there would surface as a job error AFTER a 200, so the console
calls this on the request thread and refuses up front, as MCP already did.
"""
for label, p in (("repo", repo_path), ("spec", spec_path),
*[("manifest", m) for m in (manifest_paths or [])]):
if p and not Path(p).exists():
raise ValueError(
f"{label} path {p!r} does not exist — a scan of nothing would write a summary "
f"saying nothing was found, which is not the same answer")


def run_pipeline(
repo_path: str | None = None,
out_dir: str = "out",
Expand Down Expand Up @@ -86,6 +107,7 @@ def run_pipeline(
if not (repo_path or advisory or spec_path or manifest_paths):
raise ValueError("pass a repo path, an advisory id (--cve), an OpenAPI spec (--spec), "
"or a dependency manifest (--manifest)")
validate_scan_inputs(repo_path, spec_path, manifest_paths)
h = Harness(config_path)
h.warmup() # B6: warm instructor's mode registry before ANY fan-out — both inputs need it
t0, started = time.perf_counter(), runmeta.utc_now()
Expand Down Expand Up @@ -151,8 +173,18 @@ def run_pipeline(
if repo_path and route_ctx:
o = orphans(load_spec(spec_path), route_ctx.splitlines())
spec_orphans = o
log(f" spec vs code: {len(o['matched'])} matched, "
f"{len(o['spec_only'])} declared-but-unserved, {len(o['code_only'])} undeclared")
# Say what was ESTABLISHED and what was not. A sweep that matched nothing is not
# a spec whose endpoints are all unserved — it is a sweep that failed, and the two
# used to print identically.
if not o["swept"]:
log(" spec vs code: the route sweep found NO routes in the source, so the "
"comparison did not run. This is not 'no orphans' — file-based routing "
"and dynamically mounted routers leave no line to match.")
else:
log(f" spec vs code: {len(o['matched'])} matched, "
f"{len(o['code_only'])} served-but-undeclared, "
f"{len(o['spec_unverified'])} declared endpoint(s) the sweep did not find "
f"(NOT reported as orphaned — a source sweep cannot prove absence)")
if manifest_paths:
# H2 — a manifest yields findings the same way an advisory does (H1's resolve agent,
# H1's deterministic no_bandaid route, H1's OSV-sourced cure), so they are appended to
Expand Down Expand Up @@ -280,17 +312,22 @@ def _threshold(f):
else:
refuted += 1
log(f" verify {f.id}: refuted ({v.confidence:.2f})")
if spec_orphans and (spec_orphans["spec_only"] or spec_orphans["code_only"]):
if spec_orphans and spec_orphans.get("code_only"):
# Deterministic, no agent: this is a comparison of two documents. Both directions matter and
# they fail differently — a declared-but-unserved endpoint is dead documentation or a shadow
# API, while a served-but-undeclared route is what an api_schema band-aid built from this
# spec would start rejecting the moment it is applied.
from .schemas import Finding
so, co = spec_orphans["spec_only"], spec_orphans["code_only"]
# ONLY the served-but-undeclared direction raises a finding now. The other direction is a
# claim of ABSENCE — "nothing serves this endpoint" — and the route sweep is a line-wise
# grep that cannot establish absence: file-based routing (app/api/pay/route.js) declares a
# route with no line to match, and a dynamically mounted router never appears literally.
# This used to raise a medium finding asserting exactly that, on evidence that could not
# support it — and it bypasses verify, so nothing downstream ever challenged it. The
# unmatched declarations are still REPORTED, as `spec_unverified`, phrased as what they
# are: not checked.
so, co = [], spec_orphans["code_only"]
bits = []
if so:
bits.append("declared in the spec but served by no route in the code: "
+ ", ".join(so[:15]) + (f" (+{len(so) - 15} more)" if len(so) > 15 else ""))
if co:
bits.append("served by the code but absent from the spec: "
+ ", ".join(co[:15]) + (f" (+{len(co) - 15} more)" if len(co) > 15 else ""))
Expand Down
Loading
Loading