mirror of
https://github.com/usestrix/strix.git
synced 2026-10-01 02:03:55 +00:00
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:
parent
2b413da544
commit
be1c2e4767
2 changed files with 50 additions and 0 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue