Withhold automatic fixes when repairs exceed the recorded draft

Repair can change files beyond the candidate's draft edits while the
manifest still verifies. Comparing the applied draft hashes against the
final manifest now demotes the result to ready_with_gaps, so SARIF and
other auto-apply consumers never offer a fix that omits verified
changes.
This commit is contained in:
yoni 2026-09-25 07:15:11 +00:00
parent 2b413da544
commit be1c2e4767
2 changed files with 50 additions and 0 deletions

View file

@ -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:

View file

@ -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)