Hash workflow diff for fingerprint instead of per-file blobs

Using git show per changed file failed when a workflow file was deleted,
because the blob no longer exists at the pushed head. That caused the guard to
return None and skip approval for deletions. Hash the already-computed diff
output instead; it captures adds, mods, deletes, and renames and cannot fail on
a missing blob.

Also adds a test that a deleted workflow file still requires approval.

Refs: 98
This commit is contained in:
amoussa1229 2026-07-01 19:27:37 +00:00
parent 57e8afa19f
commit b0fa57fa48
2 changed files with 43 additions and 7 deletions

View file

@ -340,13 +340,7 @@ def _workflow_change_for_push(backend: Any, parsed: ParsedGitPush) -> WorkflowPu
if not diff.ok or not diff.output:
return None
content_parts: list[str] = []
for path in files:
blob = _run_git(backend, root, f"show {shlex.quote(head)}:{shlex.quote(path)}")
if not blob.ok:
return None
content_parts.append(blob.output)
content_hash = _fingerprint({"contents": content_parts})
content_hash = _fingerprint({"diff": diff.output})
remote = _run_git(backend, root, "config --get remote.origin.url")
repo = _normalize_remote(_first_line(remote.output)) if remote.ok else ""

View file

@ -407,6 +407,48 @@ async def test_rebased_workflow_push_keeps_fingerprint_for_same_diff(
assert payload3["fingerprint"] != payload["fingerprint"]
async def test_deleted_workflow_file_requires_approval(
monkeypatch: pytest.MonkeyPatch,
) -> None:
backend = _Backend(
workflow_files=".github/workflows/ci.yml",
diff_output="diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml\ndeleted file\n",
)
guard.SANDBOX_BACKENDS["thread-1"] = backend
async def fake_approved(thread_id: str, fingerprint: str) -> bool:
return False
async def fake_find_approval(*args: Any, **kwargs: Any) -> dict[str, Any] | None:
return None
async def fake_pending(thread_id: str, **kwargs: Any) -> tuple[dict[str, Any], bool]:
return {"fingerprint": kwargs["fingerprint"], "status": "pending", "notified": False}, True
async def fake_post(*args: Any, **kwargs: Any) -> tuple[str, None]:
return "1700000000.000300", None
async def fake_notified(*args: Any, **kwargs: Any) -> None:
return None
monkeypatch.setattr(guard, "workflow_push_approved", fake_approved)
monkeypatch.setattr(guard, "find_workflow_push_approval", fake_find_approval)
monkeypatch.setattr(guard, "ensure_workflow_push_pending", fake_pending)
monkeypatch.setattr(guard, "post_slack_thread_reply_with_ts", fake_post)
monkeypatch.setattr(guard, "mark_workflow_push_notified", fake_notified)
async def handler(_request: Any) -> ToolMessage:
return ToolMessage(content="pushed", tool_call_id="call-1")
result = await guard.WorkflowPushGuardMiddleware().awrap_tool_call(_Request(), handler)
assert isinstance(result, ToolMessage)
assert result.status == "error"
payload = json.loads(str(result.content))
assert payload["workflow_approval_status"] == "approval_required"
assert payload["files"] == [".github/workflows/ci.yml"]
async def test_approved_workflow_push_aborts_when_elevation_fails(
monkeypatch: pytest.MonkeyPatch,
) -> None: