# Prevent Band v1 Implementation Plan

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

**Goal:** Build a deterministic, no-LLM pre-commit gate that runs the band-3 detectors (oracle/deps/headers) over staged files and BLOCKS confirmed bug classes before they land.

**Architecture:** A frozen detector contract (subprocess + SARIF-aligned JSON), a thin Python dispatcher (registry → resolver → runner → block policy), and a git pre-commit adapter. The 3 existing detectors gain a `--emit json` conformance mode; the oracle's cross-file catch reuses the VALIDATED `gate.run_oracle_set` via a thin Python shim. Daemon + pre-edit trigger are protocol-frozen but DEFERRED.

**Tech Stack:** Python 3 (deterministic orchestrator, no new deps, modules loaded by PATH — NO `__init__.py`), TypeScript oracle run by `bun`, `pnpm audit` for deps. SoT spec: `docs/specs/2026-06-18-prevent-band-design.md`.

---

## Wave Plan

| Wave | Tasks | Files touched | Safe to parallelize? |
|------|-------|---------------|----------------------|
| 1 | Task 1 | `prevent/contract.py`, `tests/test_prevent_contract.py` | single task |
| 2 | Task 2, Task 3, Task 4 | oracle / deps / headers detector dirs (disjoint) + per-detector test files | ✅ disjoint dirs + disjoint test files |
| 3 | Task 5 | `prevent/registry.py`, `tests/test_prevent_registry.py` | single task |
| 4 | Task 6 | `prevent/runner.py`, `tests/test_prevent_runner.py` | single task |
| 5 | Task 7 | `tests/test_prevent_block_cell.py` (read-only over existing files) | single task |
| 6 | Task 8 | `prevent/prevent.py`, `prevent/confirmed.json`, `.git/hooks/pre-commit`, `docs/validation/2026-06-18-prevent-band-v1.md` | single task |

Wave 2 file-overlap check: Task 2 touches only `domains/security/detectors/oracle/*` + `tests/test_oracle_emit.py`; Task 3 only `domains/security/detectors/deps/*` + `tests/test_deps_emit.py`; Task 4 only `domains/security/detectors/headers/*` + `tests/test_headers_emit.py`. Zero overlap. All three import the read-only `prevent/contract.py` from Wave 1. ✅

---

## Pre-Flight (read before Task 1)

- **Hooks on commit.** `.git/hooks/pre-commit` already has a `# slopgate-hook v1` block. It is a NO-OP unless `.slopgate/config.toml` exists. Run `test -f .slopgate/config.toml && echo PRESENT || echo absent`. If PRESENT and a commit is blocked, the slopgate finding is a real signal — fix it or justify it (no ignored signals); do NOT bypass with `--no-verify`.
- **The prevent hook is installed LAST (Task 8).** It is safe: production `prevent/confirmed.json` ratchets ONLY the real multideal `apps/web/.../service.ts` instance, which does not exist in this repo, so the deliberate-violation cells (`service_vuln.ts` etc.) produce WARNINGS, never BLOCKS, in production. Only the Task 7 test injects a cell-matching `confirmed` list.
- **Vocabulary (project doctrine, `CLAUDE.md`):** testing-native ONLY — detector, domain, detector contract, corpus, cell, canary, band, discriminator. NEVER module/layer/tier/seam in any file or commit message.
- **Run tests with `rtk proxy python3 -m pytest`** (RTK truncates routed pytest stdout). Tests load modules by PATH via `importlib.util.spec_from_file_location`.
- **Commits:** stage explicit paths only; terse caveman messages; NEVER co-author. Use `command git commit`.

---

## File Structure

```
prevent/                         (NEW — the gate; no __init__.py, modules loaded by PATH)
  contract.py                    Task 1 — frozen Finding/emit JSON shape + status derivation (no-false-clean)
  registry.py                    Task 5 — discover detector.json manifests + select by glob+trigger
  runner.py                      Task 6 — pure dispatcher: select → resolve deps → run → block policy
  prevent.py                     Task 8 — git pre-commit adapter (staged files → runner → exit code)
  confirmed.json                 Task 8 — the ratchet store (seeded with the #44 instance)
domains/security/detectors/
  oracle/oracle_emit.py          Task 2 — conformance shim: wraps gate.run_oracle_set, FLAGS→JSON
  oracle/detector.json           Task 2 — manifest
  deps/deps_audit.py             Task 3 — MODIFY: add --emit json
  deps/detector.json             Task 3 — manifest
  headers/headers_scan.py        Task 4 — MODIFY: add --emit json
  headers/detector.json          Task 4 — manifest
tests/
  test_prevent_contract.py       Task 1
  test_oracle_emit.py            Task 2
  test_deps_emit.py              Task 3
  test_headers_emit.py           Task 4
  test_prevent_registry.py       Task 5
  test_prevent_runner.py         Task 6 — mechanics via TEMP echo detectors (hermetic, no bun/pnpm)
  test_prevent_block_cell.py     Task 7 — the #42 HARD GATE (oracle cross-file catch end-to-end)
docs/validation/2026-06-18-prevent-band-v1.md   Task 8 — measured result
.git/hooks/pre-commit            Task 8 — APPEND prevent block AFTER slopgate block
```

The **contract JSON** (every detector emits this; the runner parses only this):
```json
{"detector":"oracle","status":"ok",
 "findings":[{"ruleId":"S9-self-deal-owner|referee","level":"warning","class":"S9",
   "message":"accrueAffiliateCommission guards but NOT {owner|referee} — incomplete mediation on same sink",
   "file":"…/service_vuln.ts","line":137,"symbol":"owner|referee"}],
 "coverage":{"scanned":["…/service_vuln.ts"],"unresolved":[]}}
```
- `level` ∈ `error`|`warning`|`note`. `status` ∈ `ok`|`degraded`|`error`. `degraded` ⇒ `coverage.unresolved` non-empty ⇒ runner prints COVERAGE-INCOMPLETE, never "clean".

---

### Task 1: contract.py — the frozen Finding shape + emit helper

**Wave:** 1
**Blocks:** Task 2, Task 3, Task 4, Task 6
**Blocked by:** —

**Files:**
- Create: `prevent/contract.py`
- Test: `tests/test_prevent_contract.py`

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

```python
# tests/test_prevent_contract.py
"""contract.py — the frozen Finding/emit JSON shape every detector speaks. Status auto-degrades when
coverage is incomplete (no-false-clean), so a partial scan can NEVER serialize as a clean 'ok'."""
import importlib.util, io, json, os, sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

def _load(name, path):
    spec = importlib.util.spec_from_file_location(name, path)
    mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod); return mod

contract = _load("sg_contract", os.path.join(ROOT, "prevent", "contract.py"))

def test_finding_shape_has_all_contract_keys():
    f = contract.finding("R1", "warning", "S9", "msg", "/a/b.ts", 12, "owner|referee")
    assert set(f) == {"ruleId", "level", "class", "message", "file", "line", "symbol"}
    assert f["level"] == "warning" and f["symbol"] == "owner|referee" and f["line"] == 12

def test_bad_level_rejected():
    try:
        contract.finding("R1", "blocker", "S9", "m", "/a", 0, "")
        assert False, "bad level must raise"
    except AssertionError as e:
        assert "level" in str(e)

def test_emit_ok_when_no_unresolved(capsys):
    contract.emit("deps", [], ["/repo"])
    out = json.loads(capsys.readouterr().out)
    assert out["status"] == "ok" and out["coverage"]["unresolved"] == []

def test_emit_degraded_when_unresolved_nonempty(capsys):
    # no-false-clean: any unresolved coverage forces degraded, regardless of findings
    contract.emit("oracle", [], ["/a.ts"], ["/a.ts"])
    out = json.loads(capsys.readouterr().out)
    assert out["status"] == "degraded" and out["coverage"]["unresolved"] == ["/a.ts"]
```

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

Run: `rtk proxy python3 -m pytest tests/test_prevent_contract.py -q`
Expected: FAIL — `No such file or directory: .../prevent/contract.py`

- [ ] **Step 3: Write the implementation**

```python
# prevent/contract.py
#!/usr/bin/env python3
"""contract.py — the frozen Finding/Report JSON shape every detector emits (SARIF-aligned).
audience: AI coding agents first. The runner speaks ONLY this JSON; every Python detector imports emit()
to print it. This file is the SINGLE source of truth for the finding shape AND status derivation —
no-false-clean lives here: a non-empty `unresolved` ALWAYS degrades status, so a partial scan can never
serialize as a clean 'ok'. Do NOT change this shape lightly; it is the one-way-door contract."""
import json, sys

LEVELS = ("error", "warning", "note")  # SARIF levels: error=blockable(precise); warning=surfaced; note=info

def finding(rule_id, level, cls, message, file, line=0, symbol=""):
    assert level in LEVELS, f"bad level {level!r} (must be one of {LEVELS})"
    return {"ruleId": rule_id, "level": level, "class": cls,
            "message": message, "file": file, "line": line, "symbol": symbol}

def emit(detector, findings, scanned, unresolved=(), status=None):
    """Print the contract JSON to stdout. `status` auto-degrades to 'degraded' whenever `unresolved`
    is non-empty (no-false-clean) unless an explicit status (e.g. 'error') is passed by the caller."""
    if status is None:
        status = "degraded" if unresolved else "ok"
    json.dump({"detector": detector, "status": status, "findings": list(findings),
               "coverage": {"scanned": list(scanned), "unresolved": list(unresolved)}}, sys.stdout)
    sys.stdout.write("\n")
```

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

Run: `rtk proxy python3 -m pytest tests/test_prevent_contract.py -q`
Expected: PASS (4 passed)

- [ ] **Step 5: Commit**

```bash
command git add prevent/contract.py tests/test_prevent_contract.py
command git commit -m "prevent: frozen Finding contract + emit (no-false-clean status)"
```

---

### Task 2: oracle conformance — oracle_emit.py + manifest

**Wave:** 2
**Blocks:** Task 7
**Blocked by:** Task 1

**Files:**
- Create: `domains/security/detectors/oracle/oracle_emit.py`
- Create: `domains/security/detectors/oracle/detector.json`
- Test: `tests/test_oracle_emit.py`

Design note (deviation from spec manifest, intentional): the spec sketch had `exec: ["bun", "oracle2.ts"]`. `oracle2.ts` scans ONE file and prints prose; the VALIDATED cross-file catch is `gate.run_oracle_set` (per-in-scope-file, #42/#44). So the oracle's contract entry is a thin Python shim `oracle_emit.py` that wraps `run_oracle_set` and maps FLAGS prose → contract JSON. This keeps the prose parser INSIDE the detector (the runner stays language-agnostic — honors "extend the detector, do NOT wrap in a runner-side parser") AND makes `run_oracle_set` the asserted engine (#42 hard gate, Task 7).

- [ ] **Step 0: VERIFIED oracle FLAGS bytes — the parser is derived from these, NOT from memory**

The four regexes below were captured-then-derived against the REAL oracle output (`bun domains/security/detectors/oracle/oracle2.ts domains/security/detectors/oracle/cells/service_vuln.ts`, captured 2026-06-18, bun present). A FLAGS→JSON parser reconstructed from memory is a silent-false-clean risk: a phrasing mismatch makes `parse()` return zero findings, which is byte-indistinguishable from a safe file. This is the load-bearing path — these are the actual bytes, do NOT re-guess them:

```
=== FLAGS (asymmetry — hand to human triage) ===
  [INTRA-SINK GAP] accrueAffiliateCommission :: affiliate_commission @L137
      guards {owner|referrer} but NOT {referee|referrer} — incomplete mediation on same sink
  [INTRA-SINK GAP] accrueAffiliateCommission :: affiliate_commission @L137
      guards {owner|referrer} but NOT {owner|referee} — incomplete mediation on same sink

value-positive sinks: 1   flags: 2   flags/sink: 2.00   unresolved-imports: 0
```

Regex conformance against these exact bytes (MEASURED match):
- `FN=\]\s*([A-Za-z_]\w*)\s*::` → captures `accrueAffiliateCommission` from `] accrueAffiliateCommission ::` ✓
- `LINE=@L(\d+)` → captures `137` from `@L137` ✓
- `GAP=but NOT\s*\{([^}]*)\}` → captures `referee|referrer` (flag 1) and `owner|referee` (flag 2) ✓
- `UNRES=unresolved-imports:\s*(\d+)` → captures `0` ✓

Two FLAGS → two findings. The over-flag `referee|referrer` (mediated upstream cross-file by `isSelfReferral`) → WARN forever (never confirmed). The real C02 `owner|referee` → ratcheted in `confirmed.json` → BLOCK. If a future oracle build changes this phrasing, this Step 0 (not memory) is the source of truth — re-capture and re-derive.

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

```python
# tests/test_oracle_emit.py
"""oracle_emit.py — the oracle's contract entry. Wraps the validated gate.run_oracle_set and maps the
FLAGS prose to contract JSON. RED service_vuln.ts → owner|referee finding (warning, imprecise oracle);
GREEN service_safe.ts → no owner|referee finding (mediated → not in FLAGS). Skips where bun is absent."""
import json, os, shutil, subprocess
import pytest

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
EMIT = os.path.join(ROOT, "domains", "security", "detectors", "oracle", "oracle_emit.py")
CELLS = os.path.join(ROOT, "domains", "security", "detectors", "oracle", "cells")
pytestmark = pytest.mark.skipif(shutil.which("bun") is None, reason="oracle runtime `bun` not installed")

def _emit(*files):
    p = subprocess.run(["python3", EMIT, *files], capture_output=True, text=True, timeout=180)
    return json.loads(p.stdout)

def test_red_helper_emits_owner_referee_warning():
    out = _emit(os.path.join(CELLS, "service_vuln.ts"))
    assert out["detector"] == "oracle"
    hits = [f for f in out["findings"] if f["symbol"].lower() == "owner|referee"]
    assert hits, f"expected owner|referee finding; got {out}"
    assert hits[0]["level"] == "warning" and hits[0]["class"] == "S9"
    assert out["coverage"]["scanned"] == [os.path.join(CELLS, "service_vuln.ts")]

def test_green_helper_no_owner_referee_finding():
    out = _emit(os.path.join(CELLS, "service_safe.ts"))
    assert not any(f["symbol"].lower() == "owner|referee" for f in out["findings"]), \
        f"safe helper must not flag owner|referee (mediated → EXTRACTED, not FLAGS); got {out}"
```

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

Run: `rtk proxy python3 -m pytest tests/test_oracle_emit.py -q`
Expected: FAIL — `No such file or directory: .../oracle/oracle_emit.py`

- [ ] **Step 3: Write oracle_emit.py**

```python
# domains/security/detectors/oracle/oracle_emit.py
#!/usr/bin/env python3
"""oracle_emit.py — conformance entry for the oracle detector (S9/C02/C09 complete-mediation).
audience: AI coding agents first. Wraps the VALIDATED cross-file helper gate.run_oracle_set (per-in-scope
file; #42/#44) and maps its FLAGS prose to contract Finding JSON. The FLAGS→JSON parser lives HERE (in the
detector) so the runner never parses oracle prose and stays language-agnostic. argv = the in-scope files
(target + the resolver-inlined dep files) the runner passes; the oracle is imprecise → every finding is a
`warning` (it blocks only via the runner's ratchet, never raw). no-false-clean: a file the oracle could not
fully resolve (unresolved-imports > 0) is reported in coverage.unresolved → the contract degrades it."""
import importlib.util, os, re, sys

HERE = os.path.dirname(os.path.abspath(__file__))
# HERE = .../security-gate/domains/security/detectors/oracle  → ROOT is 4 dirs up
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(HERE))))
sys.path.insert(0, os.path.join(ROOT, "prevent"))
import contract  # noqa: E402

def _load(name, path):
    spec = importlib.util.spec_from_file_location(name, path)
    mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod); return mod

gate = _load("sg_gate", os.path.join(ROOT, "orchestrator", "gate.py"))
ORACLE_TS = os.path.join(HERE, "oracle2.ts")

# A gap header line:  "[INTRA-SINK GAP] accrueAffiliateCommission :: affiliate_commission @L137"
# then the gap line:  "    guards {owner|referrer} but NOT {owner|referee} — incomplete mediation on same sink"
FN   = re.compile(r"\]\s*([A-Za-z_]\w*)\s*::")
LINE = re.compile(r"@L(\d+)")
GAP  = re.compile(r"but NOT\s*\{([^}]*)\}", re.I)   # the MISSING principal pair = the symbol
UNRES = re.compile(r"unresolved-imports:\s*(\d+)", re.I)

def parse(file, out):
    """FLAGS prose for ONE file → ([finding,...], unresolved_bool)."""
    findings, unresolved = [], False
    m = UNRES.search(out)
    if (m and int(m.group(1)) > 0) or ("unresolved predicate" in out.lower()):
        unresolved = True  # no-false-clean: a guard may hide in an import the oracle could not load
    fn, line = "?", 0
    for ln in out.splitlines():
        fm = FN.search(ln)
        if fm:
            fn = fm.group(1)
        lm = LINE.search(ln)
        if lm:
            line = int(lm.group(1))
        gm = GAP.search(ln)
        if gm:
            sym = gm.group(1).strip()
            findings.append(contract.finding(
                rule_id=f"S9-self-deal-{sym}", level="warning", cls="S9",
                message=f"{fn} guards but NOT {{{sym}}} — incomplete mediation on same sink",
                file=file, line=line, symbol=sym))
    return findings, unresolved

def main():
    files = sys.argv[1:]
    all_findings, scanned, unresolved = [], [], []
    for f, out in gate.run_oracle_set(ORACLE_TS, "bun", files):
        scanned.append(f)
        fs, unres = parse(f, out)
        all_findings += fs
        if unres:
            unresolved.append(f)
    contract.emit("oracle", all_findings, scanned, unresolved)

if __name__ == "__main__":
    main()
```

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

```json
// domains/security/detectors/oracle/detector.json
{"id":"oracle",
 "exec":["python3","domains/security/detectors/oracle/oracle_emit.py"],
 "scope":"per-file","scope_globs":["**/*.ts","**/*.tsx"],
 "needs_context":"deps","triggers":["pre-commit"],
 "precision":"imprecise","class":"S9"}
```

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

Run: `rtk proxy python3 -m pytest tests/test_oracle_emit.py -q`
Expected: PASS (2 passed) — or SKIP if `bun` is absent on this machine.

- [ ] **Step 6: Commit**

```bash
command git add domains/security/detectors/oracle/oracle_emit.py domains/security/detectors/oracle/detector.json tests/test_oracle_emit.py
command git commit -m "prevent: oracle conformance — wrap run_oracle_set, FLAGS->contract JSON + manifest"
```

---

### Task 3: deps conformance — --emit json + manifest

**Wave:** 2
**Blocks:** —
**Blocked by:** Task 1

**Files:**
- Modify: `domains/security/detectors/deps/deps_audit.py`
- Create: `domains/security/detectors/deps/detector.json`
- Test: `tests/test_deps_emit.py`

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

```python
# tests/test_deps_emit.py
"""deps_audit.py --emit json — maps audit_findings strings to contract JSON. deps is PRECISE → level=error
(blocks directly via the runner's authorization gate). Hermetic via the pinned audit cell (no network)."""
import importlib.util, json, os, subprocess

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DEPS = os.path.join(ROOT, "domains", "security", "detectors", "deps", "deps_audit.py")
CELLS = os.path.join(ROOT, "domains", "security", "detectors", "deps", "cells")

def _emit(audit_json):
    p = subprocess.run(["python3", DEPS, "--emit", "json", "--audit-json", audit_json],
                       capture_output=True, text=True, timeout=60)
    return json.loads(p.stdout)

def test_red_audit_emits_error_finding_with_module_symbol():
    out = _emit(os.path.join(CELLS, "audit_vuln.json"))
    assert out["detector"] == "deps"
    hits = [f for f in out["findings"] if "url-regex" in f["symbol"].lower()]
    assert hits, f"expected url-regex finding; got {out}"
    assert hits[0]["level"] == "error" and hits[0]["class"] == "S11"

def test_green_audit_emits_no_findings():
    out = _emit(os.path.join(CELLS, "audit_safe.json"))
    assert out["findings"] == [] and out["status"] == "ok", f"clean audit must be empty/ok; got {out}"
```

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

Run: `rtk proxy python3 -m pytest tests/test_deps_emit.py -q`
Expected: FAIL — argparse rejects `--emit` (unrecognized argument).

- [ ] **Step 3: Add --emit json to deps_audit.py**

In `domains/security/detectors/deps/deps_audit.py`, add the `--emit` argument and an emit branch. After the existing `ap.add_argument("--fail-on", ...)` line, add:

```python
    ap.add_argument("--emit", choices=["text", "json"], default="text",
                    help="json = contract Finding JSON for the prevent runner (deps is precise → error)")
```

Then, immediately after `findings = audit_findings(audit, a.min_severity)` and BEFORE the `print("=== DEPS AUDIT (pnpm) ===")` line, insert:

```python
    if a.emit == "json":
        # deps is PRECISE (pnpm reports actual CVEs; GREEN cell = clean lockfile) → level=error → blocks.
        import os as _os, sys as _sys
        _sys.path.insert(0, _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "..", "..", "..", "..", "prevent"))
        import contract
        src = a.project_dir or a.audit_json or "."
        fs = []
        for line in findings:  # "[sev] module@range :: title (ids) url"
            module = line.split("]", 1)[1].strip().split("@", 1)[0].strip() if "]" in line else "?"
            fs.append(contract.finding(f"S11-deps-{module}", "error", "S11", line, src, 0, module))
        contract.emit("deps", fs, [src])
        return 0
```

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

```json
// domains/security/detectors/deps/detector.json
{"id":"deps",
 "exec":["python3","domains/security/detectors/deps/deps_audit.py","--emit","json"],
 "scope":"repo","scope_globs":[],"trigger_globs":["**/pnpm-lock.yaml","**/package.json"],
 "needs_context":"none","triggers":["pre-commit"],
 "precision":"precise","class":"S11"}
```

Note: `scope:repo` ⇒ the runner appends the repo root as the single argv. `deps_audit.py <repo_root> --emit json` runs LIVE `pnpm audit`. The hermetic `--audit-json` path is for the cell test only.

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

Run: `rtk proxy python3 -m pytest tests/test_deps_emit.py -q`
Expected: PASS (2 passed)

- [ ] **Step 6: Verify the legacy text mode still works (no regression)**

Run: `rtk proxy python3 -m pytest tests/test_s11_band3.py -q`
Expected: PASS (existing deps/headers cell tests unchanged)

- [ ] **Step 7: Commit**

```bash
command git add domains/security/detectors/deps/deps_audit.py domains/security/detectors/deps/detector.json tests/test_deps_emit.py
command git commit -m "prevent: deps --emit json (precise->error) + manifest"
```

---

### Task 4: headers conformance — --emit json + manifest

**Wave:** 2
**Blocks:** —
**Blocked by:** Task 1

**Files:**
- Modify: `domains/security/detectors/headers/headers_scan.py`
- Create: `domains/security/detectors/headers/detector.json`
- Test: `tests/test_headers_emit.py`

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

```python
# tests/test_headers_emit.py
"""headers_scan.py --emit json — maps missing_headers strings to contract JSON. headers is IMPRECISE
(precision depends on aiming it at the real header-config file) → level=warning (never blocks raw)."""
import json, os, subprocess

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
HDR = os.path.join(ROOT, "domains", "security", "detectors", "headers", "headers_scan.py")
CELLS = os.path.join(ROOT, "domains", "security", "detectors", "headers", "cells")

def _emit(f):
    p = subprocess.run(["python3", HDR, "--emit", "json", f], capture_output=True, text=True, timeout=60)
    return json.loads(p.stdout)

def test_red_missing_headers_emit_warnings():
    out = _emit(os.path.join(CELLS, "headers_vuln.ts"))
    assert out["detector"] == "headers"
    assert out["findings"], f"vuln config must emit missing-header findings; got {out}"
    assert all(f["level"] == "warning" and f["class"] == "S11" for f in out["findings"])
    assert any("missing security response header" in f["symbol"].lower() for f in out["findings"])

def test_green_all_headers_present_emit_empty():
    out = _emit(os.path.join(CELLS, "headers_safe.ts"))
    assert out["findings"] == [], f"safe config must emit nothing; got {out}"
```

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

Run: `rtk proxy python3 -m pytest tests/test_headers_emit.py -q`
Expected: FAIL — argparse rejects `--emit`.

- [ ] **Step 3: Add --emit json to headers_scan.py**

In `domains/security/detectors/headers/headers_scan.py`, after the existing `ap.add_argument("--fail-on-missing", ...)` line add:

```python
    ap.add_argument("--emit", choices=["text", "json"], default="text",
                    help="json = contract Finding JSON for the prevent runner (headers is imprecise → warning)")
```

Then, immediately after `findings = missing_headers(text)` and BEFORE the `print("=== HEADERS SCAN ===")` line, insert:

```python
    if a.emit == "json":
        # headers is IMPRECISE (it cannot tell "should set headers, doesn't" from "not this file's job")
        # → level=warning; it blocks only when a repo wires scope_globs to its header-config + ratchets.
        import os as _os, sys as _sys
        _sys.path.insert(0, _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "..", "..", "..", "..", "prevent"))
        import contract
        fs = []
        for line in findings:  # "[high] missing security response header: <Name>"
            name = line.split(":", 1)[1].strip() if ":" in line else line
            fs.append(contract.finding(f"S11-header-{name}", "warning", "S11", line, a.file, 0,
                                       f"missing security response header: {name}"))
        contract.emit("headers", fs, [a.file])
        return 0
```

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

```json
// domains/security/detectors/headers/detector.json
{"id":"headers",
 "exec":["python3","domains/security/detectors/headers/headers_scan.py","--emit","json"],
 "scope":"per-file","scope_globs":[],"trigger_globs":[],
 "needs_context":"none","triggers":["pre-commit","pre-edit"],
 "precision":"imprecise","class":"S11"}
```

Note: `scope_globs:[]` matches NOTHING by design — headers never false-blocks an arbitrary `.ts`. A deploying repo wires `scope_globs` to its real header-config path AND adds a GREEN cell to earn precise→block. v1 default = no-false-block. (The cell test invokes the detector directly, not through the runner's selection.)

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

Run: `rtk proxy python3 -m pytest tests/test_headers_emit.py -q`
Expected: PASS (2 passed)

- [ ] **Step 6: Commit**

```bash
command git add domains/security/detectors/headers/headers_scan.py domains/security/detectors/headers/detector.json tests/test_headers_emit.py
command git commit -m "prevent: headers --emit json (imprecise->warning) + manifest"
```

---

### Task 5: registry.py — manifest discovery + selection

**Wave:** 3
**Blocks:** Task 6
**Blocked by:** Task 2, Task 3, Task 4

**Files:**
- Create: `prevent/registry.py`
- Test: `tests/test_prevent_registry.py`

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

```python
# tests/test_prevent_registry.py
"""registry.py — discover domains/*/detectors/*/detector.json and select which run for a (file, trigger).
Selection rule: per-file detector runs iff (file matches a scope_glob) AND (trigger ∈ triggers); a repo
detector runs iff (any changed file matches a trigger_glob) AND (trigger ∈ triggers)."""
import importlib.util, json, os

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

def _load(name, path):
    spec = importlib.util.spec_from_file_location(name, path)
    mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod); return mod

registry = _load("sg_registry", os.path.join(ROOT, "prevent", "registry.py"))

def test_load_finds_the_three_v1_detectors():
    ids = {d["id"] for d in registry.load(ROOT)}
    assert {"oracle", "deps", "headers"} <= ids, f"missing detectors; got {ids}"

def test_glob_match_handles_double_star_and_top_level():
    assert registry.glob_match("apps/web/src/x.ts", "**/*.ts")
    assert registry.glob_match("x.ts", "**/*.ts")          # top-level file must match
    assert not registry.glob_match("x.md", "**/*.ts")
    assert registry.glob_match("pnpm-lock.yaml", "**/pnpm-lock.yaml")

def test_per_file_selects_oracle_on_ts_precommit_only():
    dets = registry.load(ROOT)
    on_ts = {d["id"] for d in registry.applicable_per_file(dets, "a/b.ts", "pre-commit")}
    assert "oracle" in on_ts                                 # .ts at pre-commit → oracle
    assert "deps" not in on_ts                               # deps is repo-scope, not per-file
    none_md = registry.applicable_per_file(dets, "README.md", "pre-commit")
    assert none_md == []                                     # .md → no per-file detector
    # the cross-file oracle declares pre-commit ONLY — never selected at pre-edit (unsound there)
    on_edit = {d["id"] for d in registry.applicable_per_file(dets, "a/b.ts", "pre-edit")}
    assert "oracle" not in on_edit

def test_repo_selects_deps_on_lockfile_change():
    dets = registry.load(ROOT)
    on_lock = {d["id"] for d in registry.applicable_repo(dets, ["pnpm-lock.yaml"], "pre-commit")}
    assert "deps" in on_lock
    on_ts = {d["id"] for d in registry.applicable_repo(dets, ["a/b.ts"], "pre-commit")}
    assert "deps" not in on_ts                               # no lockfile staged → deps not triggered
```

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

Run: `rtk proxy python3 -m pytest tests/test_prevent_registry.py -q`
Expected: FAIL — `No such file or directory: .../prevent/registry.py`

- [ ] **Step 3: Write registry.py**

```python
# prevent/registry.py
#!/usr/bin/env python3
"""registry.py — discover detector manifests and select which run for a (changed file, trigger).
audience: AI coding agents first. Same glob discipline as bench.py: domains/*/detectors/*/detector.json.
A malformed manifest is SKIPPED (the runner surfaces the reduced coverage — a missing detector is never a
false clean). With hundreds registered, each commit runs only the handful whose globs match."""
import fnmatch, glob, json, os

def load(domains_root):
    """domains/*/detectors/*/detector.json → [manifest dict + injected '_dir']."""
    out = []
    for mf in sorted(glob.glob(os.path.join(domains_root, "domains", "*", "detectors", "*", "detector.json"))):
        try:
            m = json.load(open(mf, encoding="utf-8"))
        except (OSError, ValueError):
            continue  # malformed → skipped; reduced coverage, never a false clean
        m["_dir"] = os.path.dirname(mf)
        out.append(m)
    return out

def glob_match(path, pattern):
    """Match a repo-relative path against a scope/trigger glob. fnmatch's '*' crosses '/', so a leading
    '**/' must ALSO match a top-level file (no '/'): match the basename against the tail OR the full path."""
    path = path.replace(os.sep, "/")
    if pattern.startswith("**/"):
        tail = pattern[3:]
        return fnmatch.fnmatch(os.path.basename(path), tail) or fnmatch.fnmatch(path, pattern)
    return fnmatch.fnmatch(path, pattern)

def applicable_per_file(detectors, changed_file, trigger):
    return [d for d in detectors
            if d.get("scope") == "per-file"
            and trigger in d.get("triggers", [])
            and any(glob_match(changed_file, g) for g in d.get("scope_globs", []))]

def applicable_repo(detectors, changed_files, trigger):
    out = []
    for d in detectors:
        if d.get("scope") != "repo" or trigger not in d.get("triggers", []):
            continue
        tg = d.get("trigger_globs", [])
        if any(glob_match(cf, g) for cf in changed_files for g in tg):
            out.append(d)
    return out
```

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

Run: `rtk proxy python3 -m pytest tests/test_prevent_registry.py -q`
Expected: PASS (5 passed)

- [ ] **Step 5: Commit**

```bash
command git add prevent/registry.py tests/test_prevent_registry.py
command git commit -m "prevent: registry — manifest discovery + glob/trigger selection"
```

---

### Task 6: runner.py — dispatcher + block policy

**Wave:** 4
**Blocks:** Task 7, Task 8
**Blocked by:** Task 1, Task 5

**Files:**
- Create: `prevent/runner.py`
- Test: `tests/test_prevent_runner.py`

The runner imports `registry` + `contract` (siblings, via sys.path) and `gate.py` (by PATH, for `collect_deps` / `build_workspace_aliases`). The mechanics test uses TEMP echo detectors written into `tmp_path` (hermetic — no `bun`/`pnpm`), so it proves selection, repo-scope single-invocation, JSON parsing, and the block policy without any real detector.

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

```python
# tests/test_prevent_runner.py
"""runner.py — the pure dispatcher. Mechanics proven with TEMP echo detectors (hermetic): selection,
repo-scope single invocation, contract-JSON parsing, and the block policy ladder (error→BLOCK;
warning+confirmed→BLOCK; else WARN; degraded→COVERAGE-INCOMPLETE, non-blocking)."""
import importlib.util, json, os, textwrap

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

def _load(name, path):
    spec = importlib.util.spec_from_file_location(name, path)
    mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod); return mod

runner = _load("sg_runner", os.path.join(ROOT, "prevent", "runner.py"))

def _echo_detector(tmp_path, did, scope, level, precise, *, with_green=True, unresolved=False):
    """Create a temp detector that prints one canned contract finding. Absolute exec path."""
    ddir = tmp_path / "domains" / "sec" / "detectors" / did
    ddir.mkdir(parents=True)
    if with_green:
        (ddir / "cells").mkdir()
        (ddir / "cells" / "x_safe.json").write_text("{}")
    script = ddir / f"{did}.py"
    unres = "['/x']" if unresolved else "[]"
    script.write_text(textwrap.dedent(f"""
        import json, sys
        f = {{"ruleId":"{did}-1","level":"{level}","class":"S11","message":"m","file":sys.argv[1] if len(sys.argv)>1 else "?","line":0,"symbol":"{did}-sym"}}
        print(json.dumps({{"detector":"{did}","status":("degraded" if {unres} else "ok"),
            "findings":[f],"coverage":{{"scanned":[f["file"]],"unresolved":{unres}}}}}))
    """))
    manifest = {"id": did, "exec": ["python3", str(script)], "scope": scope,
                "scope_globs": (["**/*.ts"] if scope == "per-file" else []),
                "trigger_globs": (["**/*.lock"] if scope == "repo" else []),
                "needs_context": "none", "triggers": ["pre-commit"],
                "precision": ("precise" if precise else "imprecise"), "class": "S11"}
    (ddir / "detector.json").write_text(json.dumps(manifest))
    return ddir

def test_precise_error_blocks(tmp_path):
    _echo_detector(tmp_path, "p1", "per-file", "error", precise=True)
    reg = _load("reg6a", os.path.join(ROOT, "prevent", "registry.py"))
    dets = reg.load(str(tmp_path))
    rep = runner.run(["a.ts"], "pre-commit", dets, str(tmp_path))
    assert rep["exit_code"] == 1 and len(rep["blocking"]) == 1

def test_imprecise_warning_does_not_block(tmp_path):
    _echo_detector(tmp_path, "w1", "per-file", "warning", precise=False)
    reg = _load("reg6b", os.path.join(ROOT, "prevent", "registry.py"))
    dets = reg.load(str(tmp_path))
    rep = runner.run(["a.ts"], "pre-commit", dets, str(tmp_path))
    assert rep["exit_code"] == 0 and rep["warnings"] and not rep["blocking"]

def test_ratcheted_warning_blocks(tmp_path):
    _echo_detector(tmp_path, "w2", "per-file", "warning", precise=False)
    reg = _load("reg6c", os.path.join(ROOT, "prevent", "registry.py"))
    dets = reg.load(str(tmp_path))
    confirmed = [{"class": "S11", "file": "a.ts", "symbol": "w2-sym"}]
    rep = runner.run(["a.ts"], "pre-commit", dets, str(tmp_path), confirmed)
    assert rep["exit_code"] == 1 and len(rep["blocking"]) == 1

def test_precise_without_green_cell_demoted_to_warn(tmp_path):
    # admission gate: precision=precise but NO green cell → NOT block-authorized → error demoted to warn
    _echo_detector(tmp_path, "p2", "per-file", "error", precise=True, with_green=False)
    reg = _load("reg6d", os.path.join(ROOT, "prevent", "registry.py"))
    dets = reg.load(str(tmp_path))
    rep = runner.run(["a.ts"], "pre-commit", dets, str(tmp_path))
    assert rep["exit_code"] == 0 and rep["warnings"] and not rep["blocking"]

def test_repo_scope_runs_once_with_repo_root(tmp_path):
    _echo_detector(tmp_path, "r1", "repo", "warning", precise=False)
    reg = _load("reg6e", os.path.join(ROOT, "prevent", "registry.py"))
    dets = reg.load(str(tmp_path))
    rep = runner.run(["deps.lock", "deps.lock"], "pre-commit", dets, str(tmp_path))  # listed twice
    assert len(rep["warnings"]) == 1  # repo detector runs ONCE, not per changed file
    assert rep["warnings"][0]["file"] == str(tmp_path)  # argv = repo root

def test_degraded_detector_surfaces_coverage_incomplete(tmp_path):
    _echo_detector(tmp_path, "d1", "per-file", "warning", precise=False, unresolved=True)
    reg = _load("reg6f", os.path.join(ROOT, "prevent", "registry.py"))
    dets = reg.load(str(tmp_path))
    rep = runner.run(["a.ts"], "pre-commit", dets, str(tmp_path))
    assert rep["incomplete"] and rep["exit_code"] == 0  # incomplete is surfaced but NEVER blocks
```

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

Run: `rtk proxy python3 -m pytest tests/test_prevent_runner.py -q`
Expected: FAIL — `No such file or directory: .../prevent/runner.py`

- [ ] **Step 3: Write runner.py**

```python
# prevent/runner.py
#!/usr/bin/env python3
"""runner.py — the deterministic dispatcher (the core). Pure: select detectors, build cross-file context
via the VALIDATED resolver, run each as a subprocess, apply the block policy. NO LLM is ever invoked
(it imports ONLY gate.py's deterministic helpers, never its one_roll/union_rolls path).
audience: AI coding agents first.

Block policy ladder (stop at first that holds), per finding from a staged file:
  1. level==error AND the detector is block-AUTHORIZED (precise + ships a GREEN cell) → BLOCK
  2. (file-suffix, symbol) ∈ confirmed.json → BLOCK   (ratchet: a human confirmed this exact instance)
  3. else → WARN
Plus: any detector status degraded/error with unresolved coverage → COVERAGE-INCOMPLETE (surfaced, never
blocks — a broken detector must not wedge every commit, which would train devs to bypass the gate)."""
import importlib.util, json, os, subprocess, sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import registry  # noqa: E402

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))  # security-gate install root
DEPTH, MAX_SCOPE, TIMEOUT = 1, 60, 10  # depth=1 = the VALIDATED band-2 shape; 10s per detector (pre-commit)

def _load(name, path):
    spec = importlib.util.spec_from_file_location(name, path)
    mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod); return mod

_gate = _load("sg_gate", os.path.join(ROOT, "orchestrator", "gate.py"))

def is_ratcheted(f, confirmed):
    """A warning whose (file-suffix, symbol) a human confirmed → precise per-instance → block."""
    fsym = (f.get("symbol") or "").lower()
    ffile = (f.get("file") or "").replace(os.sep, "/").lower()
    for c in confirmed:
        csym = (c.get("symbol") or "").lower()
        cfile = (c.get("file") or "").replace(os.sep, "/").lower()
        if csym and csym == fsym and cfile and ffile.endswith(cfile):
            return True
    return False

def block_authorized(detector):
    """precise→block authorized ONLY if the detector ships a GREEN cell (spec admission gate). A detector
    that DECLARES precise but proves no zero-false-positive GREEN cell is demoted to warn (no false-block)."""
    if detector.get("precision") != "precise":
        return False
    cells = os.path.join(detector.get("_dir", ""), "cells")
    if not os.path.isdir(cells):
        return False
    return any("safe" in name.lower() for name in os.listdir(cells))

def _run_detector(detector, files, repo_root, trigger):
    cmd = list(detector["exec"])
    cmd[1] = os.path.join(ROOT, cmd[1])  # resolve a repo-relative script path; an absolute one is preserved
    try:
        p = subprocess.run(cmd + list(files), capture_output=True, text=True, timeout=TIMEOUT,
                           input=json.dumps({"trigger": trigger, "repo_root": repo_root}))
        return json.loads(p.stdout)
    except Exception as e:  # noqa: BLE001 -- crash/timeout/non-JSON → fail-open + COVERAGE-INCOMPLETE
        return {"detector": detector.get("id", "?"), "status": "error", "findings": [],
                "coverage": {"scanned": [], "unresolved": [f"detector error: {e}"]}}

def run(changed_files, trigger, detectors, repo_root, confirmed=()):
    aliases = _gate.build_workspace_aliases(repo_root)
    results, incomplete = [], []
    for cf in changed_files:
        abs_cf = cf if os.path.isabs(cf) else os.path.join(repo_root, cf)
        for d in registry.applicable_per_file(detectors, cf, trigger):
            files = [abs_cf]
            if d.get("needs_context") == "deps":
                deps, dropped = _gate.collect_deps(abs_cf, aliases, DEPTH, MAX_SCOPE)
                files += [x[0] for x in deps]
                if dropped:  # resolver partial → dependent detector coverage incomplete (no-false-clean)
                    incomplete.append(f"{d['id']}: {len(dropped)} dep(s) not inlined (budget) for {cf}")
            results.append((d, _run_detector(d, files, repo_root, trigger)))
    seen_repo = set()
    for d in registry.applicable_repo(detectors, changed_files, trigger):
        if d["id"] in seen_repo:
            continue
        seen_repo.add(d["id"])
        results.append((d, _run_detector(d, [repo_root], repo_root, trigger)))

    blocking, warnings = [], []
    for d, res in results:
        if res.get("status") in ("degraded", "error") and res.get("coverage", {}).get("unresolved"):
            incomplete.append(f"{res.get('detector', d['id'])}: {res['status']} — "
                              f"{res['coverage']['unresolved']}")
        for f in res.get("findings", []):
            if f.get("level") == "error" and block_authorized(d):
                blocking.append(f)
            elif is_ratcheted(f, confirmed):
                blocking.append(f)
            else:
                warnings.append(f)
    return {"blocking": blocking, "warnings": warnings, "incomplete": incomplete,
            "exit_code": 1 if blocking else 0}
```

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

Run: `rtk proxy python3 -m pytest tests/test_prevent_runner.py -q`
Expected: PASS (6 passed)

- [ ] **Step 5: Commit**

```bash
command git add prevent/runner.py tests/test_prevent_runner.py
command git commit -m "prevent: runner — dispatch + block policy (precise/ratchet/coverage-incomplete)"
```

---

### Task 7: oracle cross-file block cell — the #42 HARD GATE

**Wave:** 5
**Blocks:** Task 8
**Blocked by:** Task 2, Task 6

**Files:**
- Test: `tests/test_prevent_block_cell.py`

This is the load-bearing end-to-end proof: a staged caller that holds NO sink must BLOCK because the runner resolves the imported helper, `run_oracle_set` fires on the RESOLVED DEP file, and the canonical `owner|referee` pair (ratcheted) promotes to a block. Reuses the #44 cross-file cells. No production source change — the test injects its own cell-matching `confirmed` list (production `confirmed.json`, Task 8, does NOT list the cell, so production never blocks the fixture).

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

```python
# tests/test_prevent_block_cell.py
"""The #42 HARD GATE: the prevent runner reproduces the oracle cross-file CATCH end-to-end.
RED: stage caller_vuln.ts (NO sink) → collect_deps resolves ./service_vuln → run_oracle_set fires on the
RESOLVED DEP → owner|referee (ratcheted) → BLOCK, exit 1, and the blocking finding's file is the DEP
(service_vuln.ts), proving resolution — NOT the caller — surfaced it (wiring-verified, not assumed).
GREEN: caller_safe.ts → mediated → exit 0. Skips where `bun` is absent."""
import importlib.util, os, shutil
import pytest

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CELLS = os.path.join(ROOT, "domains", "security", "detectors", "oracle", "cells")
pytestmark = pytest.mark.skipif(shutil.which("bun") is None, reason="oracle runtime `bun` not installed")

def _load(name, path):
    spec = importlib.util.spec_from_file_location(name, path)
    mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod); return mod

runner = _load("sg_runner7", os.path.join(ROOT, "prevent", "runner.py"))
registry = _load("sg_registry7", os.path.join(ROOT, "prevent", "registry.py"))
DETECTORS = registry.load(ROOT)
# the test's OWN ratchet — matches the cell, NOT the production confirmed.json (real multideal path)
CONFIRMED = [{"class": "S9", "file": "service_vuln.ts", "symbol": "owner|referee"}]

def test_red_caller_blocks_via_xfile_resolved_dep():
    cf = os.path.relpath(os.path.join(CELLS, "caller_vuln.ts"), ROOT)
    rep = runner.run([cf], "pre-commit", DETECTORS, ROOT, CONFIRMED)
    hits = [f for f in rep["blocking"]
            if f["symbol"].lower() == "owner|referee" and f["file"].endswith("service_vuln.ts")]
    assert hits, f"expected owner|referee BLOCK on the resolved dep service_vuln.ts; got {rep}"
    assert rep["exit_code"] == 1

def test_green_caller_allows():
    cf = os.path.relpath(os.path.join(CELLS, "caller_safe.ts"), ROOT)
    rep = runner.run([cf], "pre-commit", DETECTORS, ROOT, CONFIRMED)
    assert rep["exit_code"] == 0, f"safe caller must pass; got {rep}"
    assert not any(f["symbol"].lower() == "owner|referee" for f in rep["blocking"]), \
        f"owner|referee must be mediated in GREEN; got {rep}"
```

- [ ] **Step 2: Run the test**

Run: `rtk proxy python3 -m pytest tests/test_prevent_block_cell.py -q`
Expected: PASS (2 passed) — or SKIP if `bun` is absent. If it FAILS, the cross-file wiring (collect_deps → oracle_emit → run_oracle_set → ratchet) is broken; fix the wiring, do NOT weaken the assertion.

- [ ] **Step 3: Commit**

```bash
command git add tests/test_prevent_block_cell.py
command git commit -m "prevent: #42 hard gate — oracle xfile block cell end-to-end (run_oracle_set fires on resolved dep)"
```

---

### Task 8: CLI adapter + ratchet seed + hook wiring + dogfood + validation doc

**Wave:** 6
**Blocks:** —
**Blocked by:** Task 6, Task 7

**Files:**
- Create: `prevent/prevent.py`
- Create: `prevent/confirmed.json`
- Modify: `.git/hooks/pre-commit`
- Create: `docs/validation/2026-06-18-prevent-band-v1.md`

- [ ] **Step 1: Write prevent.py (the git pre-commit adapter)**

```python
# prevent/prevent.py
#!/usr/bin/env python3
"""prevent.py — git pre-commit adapter for the deterministic gate. Reads staged files, runs the runner,
prints warnings + COVERAGE-INCOMPLETE + blocks, exits 0 (allow) or 1 (block). NEVER prints 'clean' when
coverage is incomplete (no-false-clean). NO LLM is ever invoked. audience: AI coding agents first."""
import argparse, json, os, subprocess, sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import registry, runner  # noqa: E402

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

def staged_files(repo_root):
    p = subprocess.run(["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"],
                       cwd=repo_root, capture_output=True, text=True)
    return [l for l in p.stdout.splitlines() if l.strip()]

def load_confirmed():
    p = os.path.join(os.path.dirname(os.path.abspath(__file__)), "confirmed.json")
    try:
        return json.load(open(p, encoding="utf-8"))
    except (OSError, ValueError):
        return []

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--trigger", default="pre-commit", choices=["pre-commit", "pre-edit"])
    a = ap.parse_args()
    repo_root = subprocess.run(["git", "rev-parse", "--show-toplevel"],
                               capture_output=True, text=True).stdout.strip() or ROOT
    files = staged_files(repo_root)
    if not files:
        print("prevent-band: no staged files")
        return 0
    detectors = registry.load(ROOT)
    rep = runner.run(files, a.trigger, detectors, repo_root, load_confirmed())
    for f in rep["warnings"]:
        print(f"prevent-band WARN  [{f['class']}] {f['message']} ({f['file']})")
    if rep["incomplete"]:
        print("prevent-band COVERAGE-INCOMPLETE (NOT a clean pass):")
        for i in rep["incomplete"]:
            print(f"  - {i}")
    for f in rep["blocking"]:
        print(f"prevent-band BLOCK [{f['class']}] {f['message']} ({f['file']}:{f.get('line', 0)})")
    if rep["exit_code"]:
        print(f"prevent-band: commit BLOCKED — {len(rep['blocking'])} finding(s)")
    elif not rep["warnings"] and not rep["incomplete"]:
        print("prevent-band: no blocking findings")
    return rep["exit_code"]

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

- [ ] **Step 2: Write the ratchet seed (the #44 instance, verbatim from spec)**

```json
// prevent/confirmed.json
[{"class":"S9","file":"apps/web/src/server/referrals/service.ts","symbol":"owner|referee","confirmed":"2026-06-18","ref":"#44"}]
```

- [ ] **Step 3: Smoke-test the adapter on a clean tree**

Run: `rtk proxy python3 prevent/prevent.py --trigger pre-commit`
Expected: prints `prevent-band: no staged files` (nothing staged) OR runs over staged files and exits 0. Exit code 0.

- [ ] **Step 4: Dogfood — confirm the adapter BLOCKS a staged vuln caller (manual, reverted)**

This proves the hook works end-to-end before installing it. Stage the RED cell, run the adapter with a temp ratchet that matches the cell, observe a BLOCK, then unstage. (The production `confirmed.json` does NOT list the cell, so this temporary block is forced only by the temp ratchet — confirming the mechanism.)

```bash
# only run where `bun` is present
command git add -f domains/security/detectors/oracle/cells/caller_vuln.ts
SG_TMP=$(mktemp); printf '[{"class":"S9","file":"service_vuln.ts","symbol":"owner|referee"}]' > "$SG_TMP"
cp prevent/confirmed.json prevent/confirmed.json.bak && cp "$SG_TMP" prevent/confirmed.json
rtk proxy python3 prevent/prevent.py --trigger pre-commit; echo "exit=$?"
mv prevent/confirmed.json.bak prevent/confirmed.json
command git restore --staged domains/security/detectors/oracle/cells/caller_vuln.ts
```
Expected: a `prevent-band BLOCK [S9] ... owner|referee ... (.../service_vuln.ts:...)` line and `exit=1`. Records that the gate blocks a real cross-file self-deal. (If `bun` is absent: the oracle is SKIPPED — note it; the mechanism is already proven hermetically by Task 6 + the oracle path by Task 7.)

- [ ] **Step 5: Install the hook block (APPEND after the slopgate block)**

Read `.git/hooks/pre-commit`, confirm the `# slopgate-hook v1 END` line is present, then append (do NOT overwrite the slopgate block):

```bash
# prevent-band v1 BEGIN
PREVENT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
if [ -n "$PREVENT_ROOT" ] && [ -f "$PREVENT_ROOT/prevent/prevent.py" ]; then
  python3 "$PREVENT_ROOT/prevent/prevent.py" --trigger pre-commit || exit 1
fi
# prevent-band v1 END
```

Verify both blocks coexist: `grep -c "v1 BEGIN" .git/hooks/pre-commit` → expect `2`.

- [ ] **Step 6: Write the validation doc**

Create `docs/validation/2026-06-18-prevent-band-v1.md` (audience: AI coding agents first; BLUF). It MUST state, verbatim:
- BLUF: the Prevent band v1 ships — a deterministic no-LLM pre-commit gate over the 3 band-3 detectors, BLOCK on precise-error or ratcheted instance, WARN otherwise, COVERAGE-INCOMPLETE never "clean".
- MEASURED: the #42 hard-gate test (`test_prevent_block_cell.py`) PASSES — a staged caller with no sink BLOCKS via the resolved imported helper (`run_oracle_set` fires on `service_vuln.ts`, `owner|referee` ratcheted). Cite the exit code from Step 4.
- **A bun-absent SKIP is NOT a pass (no-false-coverage).** The doc MUST cite the hard-gate test (`test_prevent_block_cell.py`) and `test_oracle_emit.py` actually RUNNING, not skipped — `bun` is present on this machine (Step 0 captured live oracle FLAGS bytes, proving it). State the pytest line shows `passed`, not `skipped`, for those two. **The "v1 ships" claim is VOID if the #42 hard gate skipped** — a skipped block-cell proves nothing about the catch. If a future environment lacks `bun`, say "v1 mechanism proven, oracle CATCH unverified here" — never "ships".
- **Detector liveness in THIS repo (no-false-coverage).** Only the oracle is live in v1: security-gate is a Python repo, so `deps` never fires (no `pnpm-lock.yaml`/`package.json` gets staged → trigger_globs never match) and `headers` never fires (`scope_globs:[]` matches nothing by design). State plainly: **"1 of 3 detectors live (oracle); deps + headers are registered-but-dormant — they activate only in a repo that stages a lockfile / wires a header-config glob."** Do NOT overstate to "three working detectors".
- The block policy ladder + the per-instance ratchet shape, and WHY it is per-instance not promote-all-warnings (cite `docs/validation/2026-06-18-oracle-log-llm-interpretation-k3.md`: the `referee|referrer` over-flag stays a WARN forever because it is never confirmed — a blanket promotion would hard-block clean commits and the gate would be bypassed).
- Test count: `oracle_emit` 2, `deps_emit` 2, `headers_emit` 2, `registry` 5, `runner` 6, `block_cell` 2, `contract` 4 (state pass/skip per file; `oracle_emit` + `block_cell` MUST be `passed` here, not `skipped` — see the bun item above).
- NOT-covered (copy the spec's deferred list, see below).

- [ ] **Step 7: Run the FULL suite**

Run: `rtk proxy python3 -m pytest -q`
Expected: all prevent-band tests PASS (oracle/block-cell SKIP only if `bun` absent); the pre-existing suite (`test_oracle_xfile.py`, `test_s11_band3.py`, `test_gate.py`, `test_bench.py`, `test_mapper.py`) still PASSES (zero regression).

- [ ] **Step 8: Commit (the hook lives in `.git/`, not tracked — commit the code + doc)**

```bash
command git add prevent/prevent.py prevent/confirmed.json docs/validation/2026-06-18-prevent-band-v1.md
command git commit -m "prevent: v1 ships — pre-commit adapter + ratchet seed + hook wired + validation"
```

Note: `.git/hooks/pre-commit` is NOT a tracked file — the hook install (Step 5) is environment state, recorded in the validation doc, not committed. When THIS commit runs, the freshly-installed prevent hook fires on the staged set (`.py`/`.json`/`.md` only — no `.ts`/lockfile matches any detector glob → exit 0). slopgate (if `.slopgate/config.toml` present) also fires — address any real finding, never `--no-verify`.

---

## NOT covered / deferred (honest no-ops in v1 — copy into the validation doc)

- **Daemon + pre-edit trigger** — protocol frozen in the spec, build gated on a latency-sensitive single-file detector existing.
- **#13 oracle payment-field lexicon hole** — an off-name money-move can slip the oracle; until fixed the oracle reports its lexicon scope so an unmatched money-move surfaces as COVERAGE-INCOMPLETE, never a silent clean. Fast-follow.
- **Auto-deriving a NEW general detector from a confirmed find** — v1 ratchet only regression-LOCKS a confirmed `(file, symbol)`; generalizing a find into a fresh detector is the full Find→Prevent learning loop, deferred.
- **Rust dispatcher** — reserved for a MEASURED dispatcher bottleneck against the frozen protocol; never Rust-on-faith.
- **npm / yarn workspaces resolution** — the resolver inherits #40 (pnpm-first; npm/yarn best-effort).
- **Cross-file PRECISION at n≥3** — the `referee|referrer` over-flag is a known precision item; measuring/raising cross-file precision is the open problem this band makes visible, not one it closes.
- **Sound staged-blob scanning** — v1 scans working-tree content on disk (the resolver's read path); under partial staging (`git add -p`) the committed blob can differ. Materializing staged blobs (`git show :0:<file>`) is deferred; surfaced as a caveat, never a silent clean.
- **Rename-robust ratchet key** — the ratchet pins `(file-suffix, symbol)`; a renamed/refactored confirmed instance silently drops its auto-BLOCK promotion (the deterministic detector still WARNs). A content-hash/AST-anchor key is deferred.
- **Cross-repo deployment** — v1 dogfoods on security-gate's own repo (manifests + detectors resolve relative to the install root == the committing repo). Installing the gate into a foreign repo (separating install root from target repo root) is future.

---

## Self-Review

**1. Spec coverage:**
- Detector contract (subprocess + SARIF JSON) → contract.py (Task 1) + manifests (Tasks 2–4). ✓
- Conformance `--emit json` per detector → Tasks 2 (oracle shim), 3 (deps), 4 (headers). ✓
- Registry (glob + trigger selection) → Task 5. ✓
- Resolver reused by PATH (collect_deps/run_oracle_set, never LLM main) → runner.py imports gate.py helpers (Task 6); oracle_emit wraps run_oracle_set (Task 2). ✓
- Runner pure function + block policy ladder → Task 6. ✓
- Ratchet folded into runner (`is_ratcheted` reading confirmed.json) → Task 6 + seed Task 8. ✓
- Block-AUTHORIZING gate (precise requires GREEN cell) → `block_authorized` (Task 6, `test_precise_without_green_cell_demoted_to_warn`). ✓
- pre-commit adapter + hook coexist with slopgate → Task 8. ✓
- Testing: runner block cell (#44 reuse, #42 hard gate) Task 7; selection test Task 5; block-policy tests Task 6; no-false-clean test Task 6 (`test_degraded...`); precise-detector tests Tasks 3/6. ✓
- Daemon protocol test → DEFERRED (in NOT-covered). ✓
- no-false-clean (degraded never "clean") → contract.py status derivation (Task 1) + prevent.py print logic (Task 8) + runner incomplete surfacing (Task 6). ✓

**2. Placeholder scan:** No TBD/TODO/"handle edge cases"/"similar to Task N". Every code + test step has complete code. ✓

**3. Type/name consistency:** `finding(rule_id, level, cls, message, file, line, symbol)`, `emit(detector, findings, scanned, unresolved, status)`, `registry.load`/`glob_match`/`applicable_per_file`/`applicable_repo`, `runner.run(changed_files, trigger, detectors, repo_root, confirmed)` returning `{blocking, warnings, incomplete, exit_code}`, `is_ratcheted`/`block_authorized` — names identical across all tasks. The contract JSON keys (`detector/status/findings/coverage{scanned,unresolved}` + finding `ruleId/level/class/message/file/line/symbol`) are identical in contract.py, all 3 emitters, the echo-detector fixture, and the runner parser. ✓

**4. Wave plan check:** Every task has Wave/Blocks/Blocked-by. Wave 2's three tasks touch disjoint detector dirs + disjoint test files (verified above). Task 6 (runner) uses registry (Task 5) + contract (Task 1) → later wave. Task 7 uses runner (Task 6) + oracle conformance (Task 2) → Wave 5. Task 8 uses runner (Task 6) → Wave 6. No same-wave file overlap. ✓
