Loosen workflow push approval fingerprint to repo/branch/files identity

The prior fingerprint included head_sha and the full diff, which made
approval break on every rebase, amend, or stacked-PR branch switch. Now
approval is keyed to (repo, branch, base_sha, files) so the same workflow
change on the same branch stays approved across history edits.

If a prior approval exists for the same identity but the exact fingerprint
doesn't match (e.g. the diff changed after approval), we now surface an
explicit stale-approval message instead of silently creating a new pending
request.

Refs: #98
This commit is contained in:
amoussa1229 2026-07-01 18:48:07 +00:00
parent a52ebed77c
commit 37949c2471
4 changed files with 180 additions and 18 deletions

View file

@ -44,6 +44,28 @@ async def workflow_push_approved(thread_id: str, fingerprint: str) -> bool:
return approvals.get(fingerprint, {}).get("status") == WORKFLOW_APPROVAL_APPROVED
async def find_workflow_push_approval(
thread_id: str,
*,
repo: str,
branch: str,
files: list[str],
) -> dict[str, Any] | None:
"""Return the most recent approved record matching identity-level keys, if any."""
approvals = await get_workflow_push_approvals(thread_id)
identity = (repo, branch, tuple(sorted(files)))
matches = [
r
for r in approvals.values()
if r.get("status") == WORKFLOW_APPROVAL_APPROVED
and (r.get("repo"), r.get("branch"), tuple(sorted(r.get("files", [])))) == identity
]
if not matches:
return None
matches.sort(key=lambda r: str(r.get("decided_at", "")), reverse=True)
return matches[0]
async def ensure_workflow_push_pending(
thread_id: str,
*,

View file

@ -21,6 +21,7 @@ from langgraph.types import Command
from ..dashboard.workflow_approval import (
ensure_workflow_push_pending,
find_workflow_push_approval,
mark_workflow_push_notified,
workflow_push_approved,
)
@ -346,20 +347,14 @@ def _workflow_change_for_push(backend: Any, parsed: ParsedGitPush) -> WorkflowPu
fixed_args.append("--set-upstream")
fixed_args.extend([parsed.remote, fixed_refspec])
fixed_command = _git_command(root, " ".join(shlex.quote(arg) for arg in fixed_args))
payload = {
identity_payload = {
"repo": repo,
"branch": branch_name,
"base_sha": base_sha,
"head_sha": head,
"files": files,
"diff": diff.output,
"remote": parsed.remote,
"local_ref": parsed.local_ref,
"remote_ref": parsed.remote_ref,
"fixed_refspec": fixed_refspec,
}
return WorkflowPushChange(
fingerprint=_fingerprint(payload),
fingerprint=_fingerprint(identity_payload),
repo=repo,
branch=branch_name,
base_sha=base_sha,
@ -372,16 +367,27 @@ def _workflow_change_for_push(backend: Any, parsed: ParsedGitPush) -> WorkflowPu
)
def _blocked_message(change: WorkflowPushChange, *, already_rejected: bool = False) -> ToolMessage:
def _blocked_message(
change: WorkflowPushChange, *, already_rejected: bool = False, stale: bool = False
) -> ToolMessage:
status = "rejected" if already_rejected else "approval_required"
content = {
"status": "error",
"error_type": "WorkflowPushApprovalRequired",
"error": (
if stale:
error = (
"This git push includes GitHub workflow file changes. A previous approval "
"exists for the same branch and workflow files, but the workflow diff has "
"changed since that approval (for example, a rebase or amend). The thread "
"owner must re-approve the new fingerprint before Open SWE can push it."
)
else:
error = (
"This git push includes GitHub workflow file changes and requires human "
"approval before Open SWE can push it. Retry the same standalone git push "
"after the thread owner approves the workflow diff."
),
)
content = {
"status": "error",
"error_type": "WorkflowPushApprovalRequired",
"error": error,
"workflow_approval_status": status,
"fingerprint": change.fingerprint,
"files": change.files,
@ -416,8 +422,9 @@ def _approval_slack_message(change: WorkflowPushChange) -> str:
f"Open SWE is trying to push changes to GitHub workflow files in `{repo}` on `{branch}`.\n\n"
f"*Files:*\n{files}\n\n"
f"*Fingerprint:* `{change.fingerprint}`\n\n"
"Approve only if this exact workflow diff is expected. If the workflow files change, "
"a new fingerprint will be required."
"Approval covers the workflow files listed above on this branch, including future "
"rebases or amends of the same change. If the set of workflow files or the branch "
"changes, a new fingerprint will be required."
)
@ -454,6 +461,20 @@ async def _approval_state(request: ToolCallRequest, change: WorkflowPushChange)
try:
if await workflow_push_approved(thread_id, change.fingerprint):
return "approved"
# If the exact identity fingerprint is not approved, check whether a prior
# approval covers the same (repo, branch, files) identity. If so, the diff
# changed underneath the prior approval (rebase/amend/edit), so we surface a
# loud re-approval message rather than a fresh silent pending record.
prior = await find_workflow_push_approval(
thread_id,
repo=change.repo,
branch=change.branch,
files=change.files,
)
if prior is not None:
return "stale_approval"
record, _created = await ensure_workflow_push_pending(
thread_id,
fingerprint=change.fingerprint,
@ -520,8 +541,24 @@ class WorkflowPushGuardMiddleware(AgentMiddleware):
if state == "approved" and thread_id:
safe_request = _override_execute_command(request, change.fixed_command)
return await _run_with_workflow_token(thread_id, lambda: handler(safe_request))
if state == "stale_approval":
record, _created = await ensure_workflow_push_pending(
thread_id,
fingerprint=change.fingerprint,
repo=change.repo,
branch=change.branch,
base_sha=change.base_sha,
head_sha=change.head_sha,
files=change.files,
)
await _post_slack_approval_if_needed(request, change, record)
return _tool_message_for_request(
_blocked_message(change, already_rejected=state == "rejected"), request
_blocked_message(
change,
already_rejected=state == "rejected",
stale=state == "stale_approval",
),
request,
)
def wrap_tool_call(

View file

@ -317,7 +317,7 @@ Steps, in order:
**IMPORTANT: If `git push` or `gh` returns "403", "Permission denied", or another permanent authorization failure, do not retry. Report the error to the user immediately and stop.**
**IMPORTANT: Workflow files (`.github/workflows/`) may be changed only when explicitly requested. Any push that includes workflow-file changes requires human approval of the exact workflow diff fingerprint before it can proceed — do not attempt to bypass it.**
**IMPORTANT: Workflow files (`.github/workflows/`) may be changed only when explicitly requested. Any push that includes workflow-file changes requires human approval before it can proceed. Approval is keyed to the repo, branch, and workflow files being pushed, so rebases or amends of the same workflow change do not require a fresh approval; changing the branch or the set of workflow files does require a new approval. Do not attempt to bypass it.**
4. **Notify the source** immediately after pushing and, when applicable, PR creation/update succeeds. Include a brief summary plus the PR link or branch URL:
- Linear-triggered: use `linear_comment` with an `@mention` of the user who triggered the task

View file

@ -153,6 +153,9 @@ async def test_unapproved_workflow_push_blocks_and_posts_slack(
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
@ -168,6 +171,7 @@ async def test_unapproved_workflow_push_blocks_and_posts_slack(
posted["notified"] = fingerprint
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)
@ -204,7 +208,11 @@ async def test_approved_workflow_push_elevates_and_restores(
refreshed.append(dict(permissions))
return True
async def fake_find_approval(*args: Any, **kwargs: Any) -> dict[str, 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, "refresh_proxy_token", fake_refresh)
pushed_command = ""
@ -240,7 +248,11 @@ async def test_workflow_push_restoration_falls_back_when_actions_read_unavailabl
refreshed.append(dict(permissions))
return "actions" not in permissions
async def fake_find_approval(*args: Any, **kwargs: Any) -> dict[str, 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, "refresh_proxy_token", fake_refresh)
async def handler(_request: Any) -> ToolMessage:
@ -273,3 +285,94 @@ async def test_non_workflow_push_runs_without_approval(monkeypatch: pytest.Monke
assert called is True
assert isinstance(result, ToolMessage)
assert result.content == "pushed"
async def test_stale_workflow_approval_is_loud_and_blocks(
monkeypatch: pytest.MonkeyPatch,
) -> None:
guard.SANDBOX_BACKENDS["thread-1"] = _Backend()
posted: dict[str, Any] = {}
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 {"fingerprint": "old-fp", "status": "approved", "decided_at": "2024-01-01T00:00:00"}
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(
channel_id: str, thread_ts: str, message: str, **kwargs: Any
) -> tuple[str, None]:
posted.update(
channel_id=channel_id, thread_ts=thread_ts, message=message, blocks=kwargs["blocks"]
)
return "1700000000.000300", None
async def fake_notified(thread_id: str, fingerprint: str) -> None:
posted["notified"] = fingerprint
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 "changed since that approval" in payload["error"]
assert posted["channel_id"] == "C123"
async def test_rebased_workflow_push_uses_identity_fingerprint(
monkeypatch: pytest.MonkeyPatch,
) -> None:
backend = _Backend()
backend.head = "b" * 40
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["fingerprint"]
assert payload["files"] == [".github/workflows/ci.yml"]
# A second push with a different head but same branch/files should produce the same fingerprint.
backend.head = "c" * 40
result2 = await guard.WorkflowPushGuardMiddleware().awrap_tool_call(_Request(), handler)
assert isinstance(result2, ToolMessage)
payload2 = json.loads(str(result2.content))
assert payload2["fingerprint"] == payload["fingerprint"]