mirror of
https://github.com/usestrix/strix.git
synced 2026-08-25 04:12:37 +02:00
feat(safety): default to guarded review with TUI approvals
This commit is contained in:
@@ -4,6 +4,7 @@ import argparse
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -327,9 +328,9 @@ async def test_stop_rejects_terminal_agents(status: str) -> None:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[str] = []
|
||||
|
||||
async def cancel_descendants_graceful(self, agent_id: str) -> bool:
|
||||
async def cancel_descendants_graceful(self, agent_id: str) -> list[str]:
|
||||
self.calls.append(agent_id)
|
||||
return True
|
||||
return [agent_id]
|
||||
|
||||
coordinator = Coordinator()
|
||||
controller = TuiController(args(), coordinator=coordinator)
|
||||
@@ -349,9 +350,9 @@ async def test_stop_allows_active_agents(status: str) -> None:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[str] = []
|
||||
|
||||
async def cancel_descendants_graceful(self, agent_id: str) -> bool:
|
||||
async def cancel_descendants_graceful(self, agent_id: str) -> list[str]:
|
||||
self.calls.append(agent_id)
|
||||
return True
|
||||
return [agent_id]
|
||||
|
||||
coordinator = Coordinator()
|
||||
controller = TuiController(args(), coordinator=coordinator)
|
||||
@@ -364,11 +365,41 @@ async def test_stop_allows_active_agents(status: str) -> None:
|
||||
assert coordinator.calls == ["agent-1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stopping_agent_denies_pending_approvals_for_its_subtree() -> None:
|
||||
class Coordinator:
|
||||
async def cancel_descendants_graceful(self, agent_id: str) -> list[str]:
|
||||
return ["agent-child", agent_id]
|
||||
|
||||
controller = TuiController(args(), coordinator=Coordinator())
|
||||
controller.set_runtime(scan_loop=asyncio.get_running_loop())
|
||||
controller.live_view.upsert_agent("agent-1", name="Agent", status="running")
|
||||
approvals = [
|
||||
asyncio.create_task(
|
||||
controller.safety_approval_callback(
|
||||
{
|
||||
"request_id": f"approval-{agent_id}",
|
||||
"agent_id": agent_id,
|
||||
"action": "Run action",
|
||||
"reason": "Ambiguous effect",
|
||||
}
|
||||
)
|
||||
)
|
||||
for agent_id in ("agent-1", "agent-child")
|
||||
]
|
||||
await asyncio.sleep(0)
|
||||
|
||||
await controller.handle("agent.stop", {"agent_id": "agent-1"})
|
||||
|
||||
assert await asyncio.gather(*approvals) == ["cancelled", "cancelled"]
|
||||
assert controller.snapshot()["pending_approval"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_handles_coordinator_rejection_after_stale_active_projection() -> None:
|
||||
class Coordinator:
|
||||
async def cancel_descendants_graceful(self, _agent_id: str) -> bool:
|
||||
return False
|
||||
async def cancel_descendants_graceful(self, _agent_id: str) -> list[str]:
|
||||
return []
|
||||
|
||||
controller = TuiController(args(), coordinator=Coordinator())
|
||||
controller.set_runtime(scan_loop=asyncio.get_running_loop())
|
||||
@@ -385,6 +416,147 @@ async def test_unknown_command_is_rejected() -> None:
|
||||
await controller.handle("nope", {})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_safety_approvals_queue_and_resolve_in_order() -> None:
|
||||
controller = TuiController(args())
|
||||
first = asyncio.create_task(
|
||||
controller.safety_approval_callback(
|
||||
{"request_id": "approval-1", "action": "Run exploit", "reason": "Mutates state"}
|
||||
)
|
||||
)
|
||||
second = asyncio.create_task(
|
||||
controller.safety_approval_callback(
|
||||
SimpleNamespace(
|
||||
request_id="approval-2",
|
||||
action="Write a file",
|
||||
reason="Changes the workspace",
|
||||
)
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert controller.snapshot()["pending_approval"] == {
|
||||
"request_id": "approval-1",
|
||||
"action": "Run exploit",
|
||||
"reason": "Mutates state",
|
||||
"agent_id": "",
|
||||
"tool_name": "",
|
||||
"digest": "",
|
||||
"risk": "",
|
||||
}
|
||||
with pytest.raises(ValueError, match="duplicate safety approval request_id"):
|
||||
await controller.safety_approval_callback(
|
||||
{"request_id": "approval-1", "action": "Duplicate", "reason": "Duplicate"}
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="stale or unknown"):
|
||||
await controller.handle("safety.resolve", {"request_id": "approval-2", "approved": True})
|
||||
|
||||
assert await controller.handle(
|
||||
"safety.resolve", {"request_id": "approval-1", "approved": True}
|
||||
) == {"request_id": "approval-1", "approved": True}
|
||||
assert await first is True
|
||||
assert controller.snapshot()["pending_approval"]["request_id"] == "approval-2"
|
||||
|
||||
with pytest.raises(RuntimeError, match="stale or unknown"):
|
||||
await controller.handle("safety.resolve", {"request_id": "approval-1", "approved": False})
|
||||
await controller.handle("safety.resolve", {"request_id": "approval-2", "approved": False})
|
||||
assert await second is False
|
||||
assert controller.snapshot()["pending_approval"] is None
|
||||
with pytest.raises(RuntimeError, match="No safety approval is pending"):
|
||||
await controller.handle("safety.resolve", {"request_id": "approval-2", "approved": False})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_safety_approval_validates_response_and_sanitizes_display() -> None:
|
||||
controller = TuiController(args())
|
||||
pending = asyncio.create_task(
|
||||
controller.safety_approval_callback(
|
||||
{
|
||||
"request_id": "approval-safe",
|
||||
"action": "run\x1b]52;c;Y2xpcA==\x07 command\x85",
|
||||
"reason": "needs\x1b[31m review\x1b[0m\x7f",
|
||||
}
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert controller.snapshot()["pending_approval"] == {
|
||||
"request_id": "approval-safe",
|
||||
"action": "run command",
|
||||
"reason": "needs review",
|
||||
"agent_id": "",
|
||||
"tool_name": "",
|
||||
"digest": "",
|
||||
"risk": "",
|
||||
}
|
||||
with pytest.raises(TypeError, match="approved must be a boolean"):
|
||||
await controller.handle(
|
||||
"safety.resolve", {"request_id": "approval-safe", "approved": "yes"}
|
||||
)
|
||||
with pytest.raises(ValueError, match="request_id must be a non-empty string"):
|
||||
await controller.handle("safety.resolve", {"request_id": "", "approved": False})
|
||||
|
||||
await controller.handle("safety.resolve", {"request_id": "approval-safe", "approved": False})
|
||||
assert await pending is False
|
||||
|
||||
assert (
|
||||
await controller.safety_approval_callback(
|
||||
{"request_id": "approval-long", "action": "x" * 513, "reason": "Too long"}
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert controller.snapshot()["pending_approval"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelled_safety_request_is_removed_and_reveals_next() -> None:
|
||||
controller = TuiController(args())
|
||||
first = asyncio.create_task(
|
||||
controller.safety_approval_callback(
|
||||
{"request_id": "approval-1", "action": "First", "reason": "First reason"}
|
||||
)
|
||||
)
|
||||
second = asyncio.create_task(
|
||||
controller.safety_approval_callback(
|
||||
{"request_id": "approval-2", "action": "Second", "reason": "Second reason"}
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
first.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await first
|
||||
|
||||
assert controller.snapshot()["pending_approval"]["request_id"] == "approval-2"
|
||||
await controller.handle("safety.resolve", {"request_id": "approval-2", "approved": False})
|
||||
assert await second is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quit_denies_all_pending_and_future_safety_approvals() -> None:
|
||||
controller = TuiController(args())
|
||||
requests = [
|
||||
asyncio.create_task(
|
||||
controller.safety_approval_callback(
|
||||
{"request_id": f"approval-{index}", "action": "Action", "reason": "Reason"}
|
||||
)
|
||||
)
|
||||
for index in range(2)
|
||||
]
|
||||
await asyncio.sleep(0)
|
||||
|
||||
await controller.handle("app.quit", {})
|
||||
|
||||
assert await asyncio.gather(*requests) == ["cancelled", "cancelled"]
|
||||
assert controller.snapshot()["pending_approval"] is None
|
||||
assert (
|
||||
await controller.safety_approval_callback(
|
||||
{"request_id": "approval-late", "action": "Late", "reason": "Late reason"}
|
||||
)
|
||||
== "cancelled"
|
||||
)
|
||||
|
||||
|
||||
def test_messages_are_sanitized_and_agents_are_collection_only() -> None:
|
||||
controller = TuiController(args())
|
||||
controller.add_message("replace\x1b]52;c;Y2xpcA==\x07 key\x85")
|
||||
|
||||
Reference in New Issue
Block a user