diff --git a/agents/core-logic/agent.py b/agents/core-logic/agent.py index 4480cd8..8979047 100644 --- a/agents/core-logic/agent.py +++ b/agents/core-logic/agent.py @@ -232,9 +232,10 @@ class A2H2ATicket(BaseModel): context: Context severity: str governance: Governance + dry_run: bool = False def create_a2h2a_ticket(ticket: A2H2ATicket) -> dict: - """Create a new A2H2A ticket for a sensitive operation.""" + """Create a new A2H2A ticket for a sensitive operation. If dry_run is True, a preview is returned and no ticket is created.""" return _call_tool("create_a2h2a_ticket", ticket.dict()) @@ -296,6 +297,12 @@ _INSTRUCTION_TEMPLATE = ( "Når brukaren ber om data — KALL ALLTID rett tool før du svarar. " "Svar på norsk (bokmål) med mindre annet er bedt om. " "HITL: ikkje kjør terraform/deploy utan godkjenning frå Chris." + "### A2H2A dry-run safety rule\n" + "If a user requests a dry run, preview, draft, utkast, \"ikke opprett sak\", \"ikke lagre\", \"do not create\", or \"do not save\":\n" + "- call create_a2h2a_ticket with dry_run=true;\n" + "- return the preview only;\n" + "- never claim a ticket was created.\n" + "Set dry_run=false only when the user explicitly asks to create a real ticket." ) diff --git a/agents/core-logic/opax_mcp_client.py b/agents/core-logic/opax_mcp_client.py index 506bee5..3066461 100644 --- a/agents/core-logic/opax_mcp_client.py +++ b/agents/core-logic/opax_mcp_client.py @@ -209,6 +209,7 @@ class A2H2ATicket(BaseModel): context: Context severity: str governance: Governance + dry_run: bool = False # --------------------------------------------------------------------------- # A2H2A (Human-in-the-Loop) diff --git a/agents/core-logic/test_agent.py b/agents/core-logic/test_agent.py new file mode 100644 index 0000000..a003f53 --- /dev/null +++ b/agents/core-logic/test_agent.py @@ -0,0 +1,49 @@ +from unittest.mock import patch + +from agent import ( + A2H2ATicket, + Context, + Governance, + ProposedAction, + TicketSource, + create_a2h2a_ticket, +) + + +@patch("agent._call_tool") +def test_create_a2h2a_ticket_forwards_dry_run(mock_call_tool): + dry_run_ticket = A2H2ATicket( + dry_run=True, + source=TicketSource( + reporter="test", + trigger="test", + affected_service="test", + project_id="test", + ), + proposed_action=ProposedAction( + execution_tool="test", + tool_parameters={}, + ), + context=Context( + summary="test", + user_justification="test", + conversation_history=[], + ), + severity="LOW", + governance=Governance( + approval_status="PENDING", + authorized_approver="test", + requires_mfa=True, + timeout_minutes=15, + ), + ) + + create_a2h2a_ticket(dry_run_ticket) + + mock_call_tool.assert_called_once() + tool_name, payload = mock_call_tool.call_args.args + + assert tool_name == "create_a2h2a_ticket" + assert payload["dry_run"] is True + assert payload["source"]["reporter"] == "test" + assert payload["proposed_action"]["execution_tool"] == "test" \ No newline at end of file diff --git a/opax-mcp/server.py b/opax-mcp/server.py index 92ab2ef..949b51f 100644 --- a/opax-mcp/server.py +++ b/opax-mcp/server.py @@ -372,7 +372,22 @@ async def list_open_issues(p): return await _gitea_get(f"/repos/{p.g async def create_issue(p): return await _gitea_post(f"/repos/{p.get('repo', GITEA_REPO)}/issues", {"title": p.get("title"), "body": p.get("body", "")}) async def create_a2h2a_ticket(p: dict) -> dict: - """Creates an A2H2A governance ticket.""" + preview = { + "source": p.get("source"), + "proposed_action": p.get("proposed_action"), + "context": p.get("context"), + "severity": p.get("severity"), + "governance": p.get("governance"), + "dry_run": bool(p.get("dry_run")), + } + + if preview["dry_run"]: + return { + "dry_run": True, + "message": "Ticket preview only; no ticket was created.", + "proposed_ticket": preview, + } + from google.cloud import firestore import uuid, hashlib, hmac, os diff --git a/opax-mcp/test_dry_run.py b/opax-mcp/test_dry_run.py new file mode 100644 index 0000000..5921dd5 --- /dev/null +++ b/opax-mcp/test_dry_run.py @@ -0,0 +1,57 @@ +import pytest +from unittest.mock import patch, MagicMock + +# Mock the firestore client before it's imported by the server +@patch("google.cloud.firestore.Client") +def test_create_a2h2a_ticket_dry_run(mock_firestore_client): + from server import create_a2h2a_ticket + import asyncio + + mock_db = MagicMock() + mock_firestore_client.return_value = mock_db + + dry_run_payload = { + "dry_run": True, + "source": {"reporter": "test", "trigger": "test", "affected_service": "test", "project_id": "test"}, + "proposed_action": {"execution_tool": "test", "tool_parameters": {}}, + "context": {"summary": "test", "user_justification": "test", "conversation_history": []}, + "severity": "LOW", + "governance": {"approval_status": "PENDING", "authorized_approver": "test", "requires_mfa": True, "timeout_minutes": 15} + } + + result = asyncio.run(create_a2h2a_ticket(dry_run_payload)) + + assert result["dry_run"] is True + assert result["message"] == "Ticket preview only; no ticket was created." + assert "proposed_ticket" in result + mock_db.collection.assert_not_called() + +@patch("google.cloud.firestore.Client") +def test_create_a2h2a_ticket_live_run(mock_firestore_client): + from server import create_a2h2a_ticket + import asyncio + + mock_db = MagicMock() + mock_firestore_client.return_value = mock_db + mock_collection = MagicMock() + mock_db.collection.return_value = mock_collection + mock_document = MagicMock() + mock_collection.document.return_value = mock_document + + live_run_payload = { + "dry_run": False, + "source": {"reporter": "test", "trigger": "test", "affected_service": "test", "project_id": "test"}, + "proposed_action": {"execution_tool": "test", "tool_parameters": {}}, + "context": {"summary": "test", "user_justification": "test", "conversation_history": []}, + "severity": "LOW", + "governance": {"approval_status": "PENDING", "authorized_approver": "test", "requires_mfa": True, "timeout_minutes": 15} + } + + result = asyncio.run(create_a2h2a_ticket(live_run_payload)) + + assert result["status"] == "created" + assert "ticket_id" in result + assert "review_url" in result + mock_db.collection.assert_called_once_with("a2h2a_tickets") + mock_collection.document.assert_called_once() + mock_document.set.assert_called_once() \ No newline at end of file