trace2mcp / tests /test_runtime_guard.py
War Enterprise
Release Trace2MCP 0.2.2
1dc3b5a verified
Raw History Blame Contribute Delete
12.3 kB
import asyncio
from collections.abc import Callable
from typing import Any
import pytest
from trace2mcp.compiler import compile_trace
from trace2mcp.hol_guard import (
HolGuardUnavailableError,
_normalize_decision,
hol_guard_from_environment,
)
from trace2mcp.models import PolicyViolationError, Trace, TraceEvent
from trace2mcp.policy import ToolContract
from trace2mcp.runtime import (
ToolRegistry,
WorkflowExecutionError,
execute_workflow,
invoke_capability,
)
from trace2mcp.runtime_guard import (
RuntimeGuardAction,
RuntimeGuardDecision,
RuntimeGuardRequest,
)
class RecordingGuard:
def __init__(
self,
decide: Callable[[RuntimeGuardRequest], RuntimeGuardDecision],
) -> None:
self.decide = decide
self.requests: list[RuntimeGuardRequest] = []
async def authorize(self, request: RuntimeGuardRequest) -> RuntimeGuardDecision:
self.requests.append(request)
return self.decide(request)
def event(
tool: str,
arguments: dict[str, Any],
result: Any,
*,
side_effect: str = "read",
) -> TraceEvent:
return TraceEvent(
id=tool,
tool=tool,
arguments=arguments,
result=result,
side_effect=side_effect,
idempotency="idempotent",
)
def contract(tool: str, side_effect: str = "read") -> ToolContract:
return ToolContract(
tool,
side_effect=side_effect,
idempotency="idempotent",
reviewed=True,
)
def strict_registry(
handlers: dict[str, Callable[..., Any]],
contracts: dict[str, ToolContract],
) -> ToolRegistry:
return ToolRegistry(
handlers,
contracts=contracts,
require_contracts=True,
require_reviewed_contracts=True,
)
def test_guard_denied_mutating_call_has_zero_side_effect() -> None:
state = {"handler_calls": 0, "writes": 0}
async def mutate(command: str) -> str:
del command
state["handler_calls"] += 1
state["writes"] += 1
return "changed"
workflow = compile_trace(
Trace(
inputs={},
events=[
event(
"bash",
{"command": "rm -rf ./data"},
"changed",
side_effect="external_side_effect",
)
],
),
allow_side_effects=True,
)
guard = RecordingGuard(
lambda request: RuntimeGuardDecision(
RuntimeGuardAction.DENY,
"mutation denied",
source="test_guard",
)
)
registry = strict_registry(
{"bash": mutate},
{"bash": contract("bash", "external_side_effect")},
)
with pytest.raises(WorkflowExecutionError, match="mutation denied"):
asyncio.run(
execute_workflow(
workflow,
registry,
approvals={"bash"},
runtime_guard=guard,
)
)
assert len(guard.requests) == 1
assert state == {"handler_calls": 0, "writes": 0}
def test_guard_allow_executes_exact_handler_once() -> None:
calls = 0
async def lookup(value: int) -> int:
nonlocal calls
calls += 1
return value + 1
workflow = compile_trace(Trace(inputs={}, events=[event("lookup", {"value": 1}, 2)]))
guard = RecordingGuard(
lambda request: RuntimeGuardDecision(RuntimeGuardAction.ALLOW, "allowed")
)
result = asyncio.run(
execute_workflow(
workflow,
strict_registry({"lookup": lookup}, {"lookup": contract("lookup")}),
runtime_guard=guard,
)
)
assert result.output == 2
assert calls == 1
assert guard.requests[0].tool_name == "lookup"
def test_trace2mcp_denial_wins_before_external_guard_allow() -> None:
calls = 0
async def destroy() -> None:
nonlocal calls
calls += 1
workflow = compile_trace(
Trace(inputs={}, events=[event("destroy", {}, None, side_effect="delete")]),
allow_side_effects=True,
)
guard = RecordingGuard(
lambda request: RuntimeGuardDecision(RuntimeGuardAction.ALLOW, "external allow")
)
with pytest.raises(PolicyViolationError, match="denied"):
asyncio.run(
execute_workflow(
workflow,
strict_registry(
{"destroy": destroy},
{"destroy": contract("destroy", "delete")},
),
approvals={"destroy"},
runtime_guard=guard,
)
)
assert calls == 0
assert guard.requests == []
def test_reviewed_contract_classification_reaches_guard_not_trace_metadata() -> None:
async def send(message: str) -> str:
return message
workflow = compile_trace(
Trace(inputs={}, events=[event("send", {"message": "hello"}, "hello")])
)
guard = RecordingGuard(
lambda request: RuntimeGuardDecision(RuntimeGuardAction.ALLOW, "allowed")
)
registry = strict_registry(
{"send": send},
{"send": contract("send", "external_side_effect")},
)
assert (
asyncio.run(
execute_workflow(
workflow,
registry,
approvals={"send"},
runtime_guard=guard,
)
).output
== "hello"
)
request = guard.requests[0]
assert request.side_effect == "external_side_effect"
assert request.operation_classification == "external_side_effect"
assert request.approval_satisfied is True
assert request.contract_reviewed is True
def test_nested_managed_invocation_traverses_same_guard_and_is_denied() -> None:
state = {"nested_calls": 0}
async def inner(command: str) -> str:
del command
state["nested_calls"] += 1
return "changed"
async def outer() -> str:
return await invoke_capability("bash", command="rm -rf ./data")
workflow = compile_trace(Trace(inputs={}, events=[event("outer", {}, "changed")]))
guard = RecordingGuard(
lambda request: RuntimeGuardDecision(
RuntimeGuardAction.DENY if request.tool_name == "bash" else RuntimeGuardAction.ALLOW,
"nested deny" if request.tool_name == "bash" else "outer allow",
)
)
registry = strict_registry(
{"outer": outer, "bash": inner},
{"outer": contract("outer"), "bash": contract("bash")},
)
with pytest.raises(WorkflowExecutionError, match="nested deny"):
asyncio.run(execute_workflow(workflow, registry, runtime_guard=guard))
assert [request.tool_name for request in guard.requests] == ["outer", "bash"]
assert guard.requests[1].nested is True
assert state["nested_calls"] == 0
def test_registry_proxy_indirect_call_traverses_same_guard() -> None:
state = {"inner_calls": 0}
registry: ToolRegistry
async def inner() -> str:
state["inner_calls"] += 1
return "inner"
async def outer() -> str:
capability = registry.get("inner")
return await capability()
workflow = compile_trace(Trace(inputs={}, events=[event("outer", {}, "inner")]))
guard = RecordingGuard(
lambda request: RuntimeGuardDecision(
RuntimeGuardAction.DENY if request.tool_name == "inner" else RuntimeGuardAction.ALLOW,
"indirect denied" if request.tool_name == "inner" else "outer allowed",
)
)
registry = strict_registry(
{"outer": outer, "inner": inner},
{"outer": contract("outer"), "inner": contract("inner")},
)
with pytest.raises(WorkflowExecutionError, match="indirect denied"):
asyncio.run(execute_workflow(workflow, registry, runtime_guard=guard))
assert [request.tool_name for request in guard.requests] == ["outer", "inner"]
assert state["inner_calls"] == 0
def test_guard_approval_decision_pauses_before_handler() -> None:
calls = 0
async def handler() -> str:
nonlocal calls
calls += 1
return "ok"
workflow = compile_trace(Trace(inputs={}, events=[event("handler", {}, "ok")]))
guard = RecordingGuard(
lambda request: RuntimeGuardDecision(
RuntimeGuardAction.REQUIRE_APPROVAL,
"HOL Guard review required",
)
)
registry = strict_registry({"handler": handler}, {"handler": contract("handler")})
with pytest.raises(WorkflowExecutionError, match="requires approval"):
asyncio.run(execute_workflow(workflow, registry, runtime_guard=guard))
assert calls == 0
def test_guard_exception_fails_closed_before_handler() -> None:
calls = 0
async def handler() -> str:
nonlocal calls
calls += 1
return "ok"
class BrokenGuard:
async def authorize(self, request: RuntimeGuardRequest) -> RuntimeGuardDecision:
del request
raise RuntimeError("guard crashed")
workflow = compile_trace(Trace(inputs={}, events=[event("handler", {}, "ok")]))
registry = strict_registry({"handler": handler}, {"handler": contract("handler")})
with pytest.raises(WorkflowExecutionError, match="managed execution denied"):
asyncio.run(execute_workflow(workflow, registry, runtime_guard=BrokenGuard()))
assert calls == 0
def test_guard_receives_immutable_argument_snapshot() -> None:
async def handler(payload: dict[str, list[int]]) -> str:
return "ok"
class MutationGuard:
async def authorize(self, request: RuntimeGuardRequest) -> RuntimeGuardDecision:
with pytest.raises(TypeError):
request.arguments["payload"] = {} # type: ignore[index]
with pytest.raises(TypeError):
request.arguments["payload"]["values"] = () # type: ignore[index]
return RuntimeGuardDecision(RuntimeGuardAction.ALLOW, "immutable")
workflow = compile_trace(
Trace(
inputs={},
events=[event("handler", {"payload": {"values": [1, 2]}}, "ok")],
)
)
registry = strict_registry({"handler": handler}, {"handler": contract("handler")})
assert (
asyncio.run(execute_workflow(workflow, registry, runtime_guard=MutationGuard())).output
== "ok"
)
def test_handler_swap_during_guard_is_rejected_before_any_handler() -> None:
state = {"original_calls": 0, "replacement_calls": 0}
async def original() -> str:
state["original_calls"] += 1
return "original"
async def replacement() -> str:
state["replacement_calls"] += 1
return "replacement"
workflow = compile_trace(Trace(inputs={}, events=[event("handler", {}, "original")]))
registry = strict_registry({"handler": original}, {"handler": contract("handler")})
class SwappingGuard:
async def authorize(self, request: RuntimeGuardRequest) -> RuntimeGuardDecision:
del request
registry.register("handler", replacement)
return RuntimeGuardDecision(RuntimeGuardAction.ALLOW, "attempted swap")
with pytest.raises(WorkflowExecutionError, match="changed during guard authorization"):
asyncio.run(execute_workflow(workflow, registry, runtime_guard=SwappingGuard()))
assert state == {"original_calls": 0, "replacement_calls": 0}
def test_hol_guard_actions_are_normalized_fail_closed() -> None:
assert _normalize_decision({"policy_action": "allow"}).action is RuntimeGuardAction.ALLOW
assert _normalize_decision({"policy_action": "warn"}).action is RuntimeGuardAction.ALLOW
assert (
_normalize_decision({"policy_action": "review"}).action
is RuntimeGuardAction.REQUIRE_APPROVAL
)
assert _normalize_decision({"policy_action": "block"}).action is RuntimeGuardAction.DENY
with pytest.raises(Exception, match="Unsupported HOL Guard"):
_normalize_decision({"policy_action": "future-action"})
def test_missing_hol_guard_is_clear_only_when_explicitly_enabled() -> None:
assert hol_guard_from_environment({}) is None
with pytest.raises(HolGuardUnavailableError, match="pipx install hol-guard"):
hol_guard_from_environment(
{"TRACE2MCP_HOL_GUARD_EXECUTABLE": "definitely-missing-hol-guard-binary"}
)