diff --git a/strix/fix/prepare.py b/strix/fix/prepare.py index 9f80df0d2..0beacb9ef 100644 --- a/strix/fix/prepare.py +++ b/strix/fix/prepare.py @@ -306,6 +306,29 @@ async def build_git_manifest( return entries, summary.decode(errors="replace").strip(), None +def _applied_hashes(workspace: Path, candidate: FixCandidateV1) -> dict[Path, str]: + """Content hashes of each edited file, captured right after the draft is applied.""" + applied: dict[Path, str] = {} + for edit in candidate.draft_edits: + path = (workspace / edit.file).resolve() + applied[path] = hashlib.sha256(path.read_bytes()).hexdigest() + return applied + + +def _change_extends_draft( + workspace: Path, + applied_sha256: dict[Path, str], + manifest: list[FileManifestEntry], +) -> bool: + """Whether the verified change set differs from the applied draft edits.""" + final_changes = { + (workspace / entry.path).resolve(): entry.resulting_sha256 for entry in manifest + } + return set(final_changes) != set(applied_sha256) or any( + resulting != applied_sha256[resolved] for resolved, resulting in final_changes.items() + ) + + async def _verify_source(context: PreparationContext) -> bool: identity = context.candidate.source_identity if identity is None: @@ -429,6 +452,7 @@ async def prepare_fix( ) context.candidate = anchored _apply_edits(workspace, context.candidate) + applied_sha256 = _applied_hashes(workspace, context.candidate) checks: list[CheckResult] = [] reproduction: CheckResult | None = None @@ -474,6 +498,8 @@ async def prepare_fix( for result in checks if not result.required and result.status is not CheckStatus.PASSED ) + if _change_extends_draft(workspace, applied_sha256, manifest): + gaps.append("The verified change extends beyond the recorded draft edits.") if reproduction is None and not verifier.reproduction_executed: gaps.append("No executable security reproduction was available.") elif reproduction is not None and reproduction.status is CheckStatus.UNAVAILABLE: diff --git a/tests/test_fix_preparation.py b/tests/test_fix_preparation.py index 1d10d4855..4eb222cb8 100644 --- a/tests/test_fix_preparation.py +++ b/tests/test_fix_preparation.py @@ -244,6 +244,30 @@ async def test_prepare_fix_returns_ready_with_manifest(tmp_path: Path) -> None: assert (workspace / "app.py").read_text(encoding="utf-8").endswith("return 'safe'\n") +@pytest.mark.asyncio +async def test_prepare_fix_demotes_ready_when_repair_exceeds_draft( + tmp_path: Path, +) -> None: + workspace, commit = _workspace(tmp_path) + + async def widening_repair( + _context: PreparationContext, + _checks: list[CheckResult], + ) -> None: + (workspace / "hardening.py").write_text("HELPER = True\n", encoding="utf-8") + + result = await prepare_fix( + _request(_candidate(commit)), + workspace, + repair=widening_repair, + verify=_verified, + ) + + assert result.state is PreparationState.READY_WITH_GAPS + assert any("beyond the recorded draft edits" in gap for gap in result.gaps) + assert result.candidate.digest() == result.candidate_digest + + @pytest.mark.asyncio async def test_prepare_fix_retries_failed_checks(tmp_path: Path) -> None: workspace, commit = _workspace(tmp_path)