|
36 | 36 |
|
37 | 37 | import contextlib |
38 | 38 | import hashlib |
| 39 | +import io |
39 | 40 | import json |
40 | 41 | import os |
41 | 42 | import shutil |
42 | 43 | import subprocess |
43 | 44 | import sys |
| 45 | +import tarfile |
44 | 46 | import tempfile |
45 | 47 | import time |
46 | 48 | from pathlib import Path |
@@ -1641,7 +1643,12 @@ def reviewer_input_paths(d: Path) -> list[Path]: |
1641 | 1643 | "allows), observe, and report; only where it genuinely can't be driven, hand the human " |
1642 | 1644 | "concrete runnable steps, not a bare 'needs manual check'. If a verdict turns on an " |
1643 | 1645 | "investigation, run it and show the result directly — don't ask whether to investigate. " |
1644 | | - "Ground every cited path:line on the target source at $PDCA_TARGET (read-only); " |
| 1646 | + "Ground every cited path:line on the target source at $PDCA_TARGET. When the bundle " |
| 1647 | + "carries a patch, $PDCA_TARGET is a DISPOSABLE git-self-contained copy — the base as " |
| 1648 | + "one local commit, patch.diff applied uncommitted on top — so the independent " |
| 1649 | + "red→green re-run is executable in place: `git stash` restores the pre-fix tree, " |
| 1650 | + "`git stash pop` re-applies the patch, and no write of yours can reach the real " |
| 1651 | + "checkout. Otherwise treat $PDCA_TARGET as read-only. " |
1645 | 1652 | "if $PDCA_TARGET is unset, ground against patch.diff alone — do NOT search other " |
1646 | 1653 | "checkouts on the machine. If $PDCA_TARGET is SET yet stale or unreadable (its base " |
1647 | 1654 | "lags what the patch was built/verified against — a dependent/stacked cycle's base " |
@@ -1686,6 +1693,85 @@ def _reviewer_target(d: Path, cfg: Config) -> Path | None: |
1686 | 1693 | return None |
1687 | 1694 |
|
1688 | 1695 |
|
| 1696 | +def _reviewer_repo(d: Path, target: Path, sandbox: Path) -> Path | None: |
| 1697 | + """A DISPOSABLE, git-self-contained copy of ``target`` inside the reviewer sandbox — |
| 1698 | + the tree the reviewer may re-run the red→green on (issue #419). |
| 1699 | +
|
| 1700 | + The review contract asks the reviewer to independently re-verify C4 against |
| 1701 | + ``$PDCA_TARGET``: restore the pre-fix state, run the bundle's test, re-apply |
| 1702 | + (``git stash`` / ``git stash pop``). The tree :func:`_reviewer_target` resolves cannot |
| 1703 | + host that inside the reviewer's confinement: a linked worktree's git metadata — its |
| 1704 | + index included — lives under the PRIMARY checkout's ``.git/worktrees/<name>/`` |
| 1705 | + (its ``.git`` is an absolute pointer, ``worktree.py:14-16``), and stash writes objects |
| 1706 | + into the shared ``.git/objects`` — both outside the granted dir and read-only to the |
| 1707 | + leaf. So every index-writing git op failed and the C4 verification claim landed in §6 |
| 1708 | + NEEDS-HUMAN on every cycle instead of being mechanically re-checked. |
| 1709 | +
|
| 1710 | + Shape: ``<sandbox>/target`` holding the target's base tree as ONE local commit with |
| 1711 | + the bundle's ``patch.diff`` applied UNCOMMITTED on top — exactly the state the |
| 1712 | + reviewer must stash away, and the state the lane worktree itself carries (base |
| 1713 | + checked out, patch applied uncommitted), so ``HEAD`` of the source IS the pre-fix |
| 1714 | + tree. The copy's whole ``.git`` lives inside the sandbox cwd, so the pre-fix restore |
| 1715 | + + re-apply write nothing anywhere near the primary checkout's git metadata; the |
| 1716 | + source repo is only ever READ (``git archive`` / ``rev-parse``). Identity and signing |
| 1717 | + are pinned in the copy's local config so ``git stash`` (which commits) cannot depend |
| 1718 | + on the operator's global git config. |
| 1719 | +
|
| 1720 | + Only for a bundle WITH a patch: with nothing to stash there is no re-run, and |
| 1721 | + read-only grounding on the real checkout serves citations better (full history). |
| 1722 | + **Best-effort**, mirroring :func:`_seed_sandbox_gate_logs`: any failure — a non-git |
| 1723 | + target, an archive/extract/apply error — degrades to None with a stderr note and the |
| 1724 | + caller falls back to grounding on ``target`` directly; never an aborted Check. |
| 1725 | + """ |
| 1726 | + patch = d / "patch.diff" |
| 1727 | + try: |
| 1728 | + patch_text = patch.read_text(encoding="utf-8") if patch.is_file() else "" |
| 1729 | + except (OSError, UnicodeDecodeError): |
| 1730 | + patch_text = "" |
| 1731 | + if not patch_text.strip() or not (target / ".git").exists(): |
| 1732 | + return None |
| 1733 | + dest = sandbox / "target" |
| 1734 | + |
| 1735 | + def _run(repo: Path, *args: str) -> subprocess.CompletedProcess: |
| 1736 | + return subprocess.run(["git", "-C", str(repo), *args], capture_output=True) |
| 1737 | + |
| 1738 | + try: |
| 1739 | + # READ-ONLY against the source: export the tree at HEAD (the pre-fix base — the |
| 1740 | + # patch sits uncommitted on top of it in the lane) without touching its index. |
| 1741 | + archive = _run(target, "archive", "--format=tar", "HEAD") |
| 1742 | + if archive.returncode != 0: |
| 1743 | + raise OSError(archive.stderr.decode(errors="replace").strip() |
| 1744 | + or "git archive failed") |
| 1745 | + base = _run(target, "rev-parse", "HEAD").stdout.decode(errors="replace").strip() |
| 1746 | + dest.mkdir() |
| 1747 | + with tarfile.open(fileobj=io.BytesIO(archive.stdout)) as tf: |
| 1748 | + try: |
| 1749 | + tf.extractall(dest, filter="data") |
| 1750 | + except TypeError: # Python 3.11.0–3.11.3: no filter= yet (PEP 706 backport) |
| 1751 | + tf.extractall(dest) |
| 1752 | + for args in (("init", "-q"), |
| 1753 | + # stash COMMITS: pin identity + signing in the copy's own config so |
| 1754 | + # the re-run cannot depend on the operator's global git config. |
| 1755 | + ("config", "user.name", "pdca-reviewer"), |
| 1756 | + ("config", "user.email", "pdca-reviewer@localhost"), |
| 1757 | + ("config", "commit.gpgsign", "false"), |
| 1758 | + # -f: a tracked-but-gitignored file in the base must not drop out. |
| 1759 | + ("add", "-A", "-f"), |
| 1760 | + ("commit", "-q", "--allow-empty", "-m", f"pre-fix base {base}"), |
| 1761 | + ("apply", str(patch.resolve()))): |
| 1762 | + done = _run(dest, *args) |
| 1763 | + if done.returncode != 0: |
| 1764 | + raise OSError( |
| 1765 | + f"git {args[0]}: {done.stderr.decode(errors='replace').strip()}") |
| 1766 | + return dest |
| 1767 | + except Exception as exc: # noqa: BLE001 — materialization is best-effort, never fatal |
| 1768 | + print(f"leaves: could not materialize a git-writable reviewer copy of {target} " |
| 1769 | + f"({exc}); the leaf grounds on the target read-only and the red→green " |
| 1770 | + "re-run may land in §6", file=sys.stderr) |
| 1771 | + shutil.rmtree(dest, ignore_errors=True) |
| 1772 | + return None |
| 1773 | + |
| 1774 | + |
1689 | 1775 | def run_review(d: Path, cfg: Config) -> None: |
1690 | 1776 | inputs = reviewer_input_paths(d) |
1691 | 1777 | assert (d / "build-notes.md") not in inputs, "independence contract violated" |
@@ -2085,13 +2171,24 @@ def _run_review_sandboxed(d: Path, cfg: Config) -> None: |
2085 | 2171 | # earn an automated red→green at Check. |
2086 | 2172 | seeded = _seed_sandbox_settings(cfg, sandbox, profile) |
2087 | 2173 | # Ground citations on the brief's target checkout (#75): name it via $PDCA_TARGET |
2088 | | - # so the reviewer doesn't wander into unrelated checkouts, and grant read access |
2089 | | - # via the family's grounding flag (claude: --add-dir). Independence holds — the |
2090 | | - # target is the upstream source, not build-notes.md. |
| 2174 | + # so the reviewer doesn't wander into unrelated checkouts. For a bundle WITH a |
| 2175 | + # patch, what is handed is a disposable git-self-contained copy INSIDE the |
| 2176 | + # sandbox (#419): the lane worktree's git index/objects live under the PRIMARY |
| 2177 | + # checkout's .git (worktree.py:14-16) — outside any granted dir and read-only to |
| 2178 | + # the leaf — so the contract's own pre-fix restore (`git stash`) could never run |
| 2179 | + # against it. The copy's .git is sandbox-local: stash/unstash work, and the |
| 2180 | + # primary checkout's git metadata sees no writes. Independence holds — the copy |
| 2181 | + # is materialized from the target source + patch.diff, never build-notes.md. |
2091 | 2182 | target = _reviewer_target(d, cfg) |
2092 | | - env = {"PDCA_TARGET": str(target)} if target else None |
| 2183 | + repo = _reviewer_repo(d, target, sandbox) if target is not None else None |
| 2184 | + grounded = repo if repo is not None else target |
| 2185 | + env = {"PDCA_TARGET": str(grounded)} if grounded else None |
| 2186 | + # The grounding grant (claude: --add-dir) is only needed for a target OUTSIDE |
| 2187 | + # the sandbox cwd. When the sandbox-local copy is handed, granting the real |
| 2188 | + # checkout too would hand a read+write family (codex --add-dir, |
| 2189 | + # families.py:112-113) the shared lane worktree for no reviewer need. |
2093 | 2190 | extra_argv = ([profile.grounding_flag, str(target)] |
2094 | | - if target and profile.grounding_flag else []) |
| 2191 | + if repo is None and target and profile.grounding_flag else []) |
2095 | 2192 | # The confinement flag rides on `seeded` (a file that is not there must not cost |
2096 | 2193 | # the leaf its ambient sandbox, #290); the codex network grant does not (#291). |
2097 | 2194 | extra_argv += _sandbox_argv(cfg, profile, seeded=seeded) |
@@ -2418,10 +2515,16 @@ def _run_advisory_sandboxed(d: Path, cfg: Config, leaf: LeafConfig, spec: dict, |
2418 | 2515 | # (#261) — without it a loopback-socket runtime test can't bind, so it can never |
2419 | 2516 | # earn an automated red→green at Check. |
2420 | 2517 | seeded = _seed_sandbox_settings(cfg, sandbox, profile) |
| 2518 | + # Same #419 shape as _run_review_sandboxed: a bundle with a patch gets a |
| 2519 | + # disposable git-self-contained copy inside the sandbox (the lane worktree's git |
| 2520 | + # metadata is read-only to the leaf, so stash/unstash could never run there); |
| 2521 | + # the grounding grant is withheld for the sandbox-local copy. |
2421 | 2522 | target = _reviewer_target(d, cfg) |
2422 | | - env = {"PDCA_TARGET": str(target)} if target else None |
| 2523 | + repo = _reviewer_repo(d, target, sandbox) if target is not None else None |
| 2524 | + grounded = repo if repo is not None else target |
| 2525 | + env = {"PDCA_TARGET": str(grounded)} if grounded else None |
2423 | 2526 | extra = ([profile.grounding_flag, str(target)] |
2424 | | - if target and profile.grounding_flag else []) |
| 2527 | + if repo is None and target and profile.grounding_flag else []) |
2425 | 2528 | extra += _sandbox_argv(cfg, profile, seeded=seeded) # see _run_review_sandboxed |
2426 | 2529 | out = sandbox / f"check-advisory-{leaf_id}.md" |
2427 | 2530 | error_log = advisory_error_log(d, leaf_id) |
|
0 commit comments