From f9973fda77150a1f507a1bb215138d9caea6eed1 Mon Sep 17 00:00:00 2001 From: Molecule AI Backend Engineer Date: Sat, 18 Apr 2026 03:10:26 +0000 Subject: [PATCH] =?UTF-8?q?fix(hitl):=20emit=20log=5Fevent()=20on=20approv?= =?UTF-8?q?al=20grant=20and=20denial=20=E2=80=94=20Art.=2014=20audit=20gap?= =?UTF-8?q?=20(closes=20#893)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The @requires_approval decorator and request_approval() call executed the approval gate correctly but never wrote the outcome to the activity log. EU AI Act Article 14 requires documented evidence that HITL measures were exercised — the missing log_event() calls meant GET /workspaces/:id/activity could not surface HITL gate outcomes. Add log_event() at both resolution points in the requires_approval wrapper: - Denial: event_type="hitl", action="approve", outcome="denied", actor=decided_by - Grant: event_type="hitl", action="approve", outcome="granted", actor=decided_by Both calls follow the existing try/except pattern used for audit calls elsewhere in hitl.py so a missing audit module never blocks the approval flow. Tests: TestRequiresApproval.test_logs_hitl_denied_event and test_logs_hitl_approved_event verify log_event is called with the correct outcome on each resolution path. Co-Authored-By: Claude Sonnet 4.6 --- workspace-template/builtin_tools/hitl.py | 30 +++++++++ workspace-template/tests/test_hitl.py | 83 ++++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/workspace-template/builtin_tools/hitl.py b/workspace-template/builtin_tools/hitl.py index d7bccc2d..3a03e627 100644 --- a/workspace-template/builtin_tools/hitl.py +++ b/workspace-template/builtin_tools/hitl.py @@ -385,6 +385,21 @@ def requires_approval( } if not approval_result.get("approved"): + # Art. 14 audit: log the denial outcome so the activity log + # contains evidence that the human oversight gate was exercised. + try: + from builtin_tools.audit import log_event + log_event( + event_type="hitl", + action="approve", + resource=action, + outcome="denied", + actor=approval_result.get("decided_by"), + approval_id=approval_result.get("approval_id"), + reason=reason, + ) + except Exception: + pass return { "success": False, "error": ( @@ -394,6 +409,21 @@ def requires_approval( "approval_id": approval_result.get("approval_id"), } + # Art. 14 audit: log the approval grant before running the function. + try: + from builtin_tools.audit import log_event + log_event( + event_type="hitl", + action="approve", + resource=action, + outcome="granted", + actor=approval_result.get("decided_by"), + approval_id=approval_result.get("approval_id"), + reason=reason, + ) + except Exception: + pass + # --- Approved — run the original function ------------------------ return await fn(*args, **kwargs) diff --git a/workspace-template/tests/test_hitl.py b/workspace-template/tests/test_hitl.py index 78fe49ce..c3650b6f 100644 --- a/workspace-template/tests/test_hitl.py +++ b/workspace-template/tests/test_hitl.py @@ -352,6 +352,89 @@ class TestRequiresApproval: assert result["success"] is False assert "error" in result + @pytest.mark.asyncio + async def test_logs_hitl_denied_event(self, monkeypatch): + """Art. 14 audit: denial outcome must be logged to activity_logs (#893).""" + mod = _load_hitl(monkeypatch) + + audit_mock = MagicMock() + audit_mock.log_event = MagicMock(return_value="trace-id") + monkeypatch.setitem(sys.modules, "builtin_tools.audit", audit_mock) + + approval_mock = MagicMock() + approval_mock.ainvoke = AsyncMock(return_value={ + "approved": False, + "approval_id": "appr-deny-123", + "decided_by": "human-reviewer", + "message": "Denied by human", + }) + monkeypatch.setitem(sys.modules, "builtin_tools.approval", + MagicMock(request_approval=approval_mock)) + + @mod.requires_approval("Delete production DB") + async def delete_db(): + return {"done": True} + + result = await delete_db() + assert result["success"] is False + + # log_event must have been called with the denial outcome. + log_calls = audit_mock.log_event.call_args_list + denial_calls = [ + c for c in log_calls + if c.kwargs.get("outcome") == "denied" + or (c.args and len(c.args) >= 3 and c.args[2] == "denied") + ] + assert denial_calls, ( + "log_event(outcome='denied') was not called — Art. 14 audit gap (issue #893)" + ) + # Verify the call carries the expected resource / actor. + dc = denial_calls[0] + assert dc.kwargs.get("event_type") == "hitl" or "hitl" in str(dc) + assert dc.kwargs.get("outcome") == "denied" + + @pytest.mark.asyncio + async def test_logs_hitl_approved_event(self, monkeypatch): + """Art. 14 audit: approval grant outcome must be logged to activity_logs (#893).""" + mod = _load_hitl(monkeypatch) + + audit_mock = MagicMock() + audit_mock.log_event = MagicMock(return_value="trace-id") + monkeypatch.setitem(sys.modules, "builtin_tools.audit", audit_mock) + + approval_mock = MagicMock() + approval_mock.ainvoke = AsyncMock(return_value={ + "approved": True, + "approval_id": "appr-ok-456", + "decided_by": "human-reviewer", + }) + monkeypatch.setitem(sys.modules, "builtin_tools.approval", + MagicMock(request_approval=approval_mock)) + + executed = [] + + @mod.requires_approval("Run migration") + async def run_migration(table: str): + executed.append(table) + return {"done": True} + + result = await run_migration(table="users") + assert result == {"done": True} + assert executed == ["users"] + + # log_event must have been called with the granted outcome. + log_calls = audit_mock.log_event.call_args_list + granted_calls = [ + c for c in log_calls + if c.kwargs.get("outcome") == "granted" + ] + assert granted_calls, ( + "log_event(outcome='granted') was not called — Art. 14 audit gap (issue #893)" + ) + gc = granted_calls[0] + assert gc.kwargs.get("event_type") == "hitl" + assert gc.kwargs.get("outcome") == "granted" + # ============================================================================ # HITLConfig loading