"""Step definitions for Safety Profile Enforcement tests. Tests that ToolRuntime._enforce_capabilities correctly enforces safety profile constraints: unsafe tool gating, skill category allow-lists, checkpoint requirements, sandbox requirements, human approval, cost limits, and retry limits from the safety profile. """ from __future__ import annotations from typing import Any from behave import given, then, when from behave.runner import Context from cleveragents.domain.models.core.safety_profile import SafetyProfile from cleveragents.domain.models.core.tool import ( Tool, ToolCapability, ToolSource, ) from cleveragents.infrastructure.events.reactive import ReactiveEventBus from cleveragents.tool.context import ToolExecutionContext from cleveragents.tool.lifecycle import ( ToolAccessDeniedError, ToolCheckpointRequiredError, ToolCostLimitExceededError, ToolDescriptor, ToolHumanApprovalRequiredError, ToolResult, ToolRetryLimitExceededError, ToolRuntime, ToolSafetyViolationError, ToolSandboxRequiredError, ) # --------------------------------------------------------------------------- # Helpers -- Stub ToolInstance # --------------------------------------------------------------------------- class _StubToolInstance: """Minimal ToolInstance for testing enforcement without real execution.""" def __init__(self, descriptor: ToolDescriptor) -> None: self._descriptor = descriptor def discover(self) -> ToolDescriptor: return self._descriptor def activate(self, ctx: ToolExecutionContext) -> None: pass def execute(self, params: dict[str, Any], ctx: ToolExecutionContext) -> ToolResult: return ToolResult(success=True, data={"ok": True}) def deactivate(self, ctx: ToolExecutionContext) -> None: pass def _make_tool( name: str, *, writes: bool = False, read_only: bool = False, unsafe: bool = False, checkpointable: bool = False, ) -> Tool: """Create a Tool domain model with given capability flags.""" return Tool( name=name, description=f"Test tool {name}", source=ToolSource.CUSTOM, code="pass", capability=ToolCapability( read_only=read_only, writes=writes, unsafe=unsafe, checkpointable=checkpointable, ), ) # --------------------------------------------------------------------------- # Background: register tools # --------------------------------------------------------------------------- @given('a registered tool "test/writer" that writes and is safe') def step_register_writer(context: Context) -> None: """Register a tool that writes but is not unsafe.""" context.enforcement_runtime = ToolRuntime(event_bus=ReactiveEventBus()) tool = _make_tool("test/writer", writes=True, unsafe=False) desc = ToolDescriptor( name=tool.name, description=tool.description, capability=tool.capability, ) instance = _StubToolInstance(desc) context.enforcement_runtime.register_tool(tool, instance) @given('a registered tool "test/unsafe-tool" that is unsafe') def step_register_unsafe(context: Context) -> None: """Register a tool marked as unsafe.""" runtime: ToolRuntime = context.enforcement_runtime tool = _make_tool("test/unsafe-tool", writes=False, unsafe=True) desc = ToolDescriptor( name=tool.name, description=tool.description, capability=tool.capability, ) instance = _StubToolInstance(desc) runtime.register_tool(tool, instance) @given('a registered tool "test/reader" that is read-only and safe') def step_register_reader(context: Context) -> None: """Register a read-only safe tool.""" runtime: ToolRuntime = context.enforcement_runtime tool = _make_tool("test/reader", read_only=True, unsafe=False) desc = ToolDescriptor( name=tool.name, description=tool.description, capability=tool.capability, ) instance = _StubToolInstance(desc) runtime.register_tool(tool, instance) # --------------------------------------------------------------------------- # Safety profile construction # --------------------------------------------------------------------------- @given("a safety profile with allow_unsafe_tools {val}") def step_safety_allow_unsafe(context: Context, val: str) -> None: """Create a safety profile with specific allow_unsafe_tools value.""" context.enforcement_safety = SafetyProfile( allow_unsafe_tools=val.lower() == "true", require_checkpoints=False, require_sandbox=False, ) @given('a safety profile with allowed_skill_categories "{cats}"') def step_safety_categories(context: Context, cats: str) -> None: """Create a safety profile with specific allowed skill categories.""" cat_list = [c.strip() for c in cats.split(",") if c.strip()] context.enforcement_safety = SafetyProfile( allowed_skill_categories=cat_list, require_checkpoints=False, require_sandbox=False, ) @given("a safety profile with empty allowed_skill_categories") def step_safety_empty_categories(context: Context) -> None: """Create a safety profile with empty allowed skill categories.""" context.enforcement_safety = SafetyProfile( allowed_skill_categories=[], require_checkpoints=False, require_sandbox=False, ) @given("a safety profile with require_checkpoints {val}") def step_safety_require_checkpoints(context: Context, val: str) -> None: """Create a safety profile with specific require_checkpoints value.""" context.enforcement_safety = SafetyProfile( require_checkpoints=val.lower() == "true", require_sandbox=False, ) @given('a combined safety profile blocking unsafe tools with categories "{cats}"') def step_safety_combined(context: Context, cats: str) -> None: """Create a safety profile blocking unsafe tools with category constraints.""" cat_list = [c.strip() for c in cats.split(",") if c.strip()] context.enforcement_safety = SafetyProfile( allow_unsafe_tools=False, allowed_skill_categories=cat_list, require_checkpoints=False, require_sandbox=False, ) # --------------------------------------------------------------------------- # Tool execution context construction # --------------------------------------------------------------------------- @given("a tool execution context with the safety profile") def step_ctx_with_safety(context: Context) -> None: """Create a ToolExecutionContext with the current safety profile.""" context.enforcement_ctx = ToolExecutionContext( plan_id="test-plan-001", safety_profile=context.enforcement_safety, ) @given("a tool execution context with the safety profile and require_checkpoints {val}") def step_ctx_with_safety_and_cp(context: Context, val: str) -> None: """Create a context with safety profile and explicit require_checkpoints.""" context.enforcement_ctx = ToolExecutionContext( plan_id="test-plan-001", require_checkpoints=val.lower() == "true", safety_profile=context.enforcement_safety, ) @given("a tool execution context without a safety profile") def step_ctx_without_safety(context: Context) -> None: """Create a ToolExecutionContext without a safety profile.""" context.enforcement_ctx = ToolExecutionContext( plan_id="test-plan-001", ) @given("a tool execution context that is read-only without a safety profile") def step_ctx_readonly(context: Context) -> None: """Create a read-only ToolExecutionContext without a safety profile.""" context.enforcement_ctx = ToolExecutionContext( plan_id="test-plan-001", plan_read_only=True, ) @given('the tool skill category is "{category}"') def step_set_tool_category(context: Context, category: str) -> None: """Set the tool_skill_category metadata on the execution context.""" context.enforcement_ctx.metadata["tool_skill_category"] = category # --------------------------------------------------------------------------- # Sandbox requirement context construction # --------------------------------------------------------------------------- @given("a safety profile with require_sandbox {val}") def step_safety_require_sandbox(context: Context, val: str) -> None: """Create a safety profile with specific require_sandbox value.""" context.enforcement_safety = SafetyProfile( require_sandbox=val.lower() == "true", require_checkpoints=False, ) @given("a tool execution context with the safety profile and no sandbox_id") def step_ctx_with_safety_no_sandbox(context: Context) -> None: """Create a context with safety profile and no sandbox_id.""" context.enforcement_ctx = ToolExecutionContext( plan_id="test-plan-001", safety_profile=context.enforcement_safety, sandbox_id=None, ) @given('a tool execution context with the safety profile and sandbox_id "{sandbox_id}"') def step_ctx_with_safety_and_sandbox(context: Context, sandbox_id: str) -> None: """Create a context with safety profile and a sandbox_id.""" context.enforcement_ctx = ToolExecutionContext( plan_id="test-plan-001", safety_profile=context.enforcement_safety, sandbox_id=sandbox_id, ) # --------------------------------------------------------------------------- # Human approval context construction # --------------------------------------------------------------------------- @given("a safety profile with require_human_approval {val}") def step_safety_require_human_approval(context: Context, val: str) -> None: """Create a safety profile with specific require_human_approval value.""" context.enforcement_safety = SafetyProfile( require_human_approval=val.lower() == "true", require_sandbox=False, require_checkpoints=False, ) @given("a tool execution context with the safety profile and no approval") def step_ctx_with_safety_no_approval(context: Context) -> None: """Create a context with safety profile but no human approval.""" context.enforcement_ctx = ToolExecutionContext( plan_id="test-plan-001", safety_profile=context.enforcement_safety, ) @given("a tool execution context with the safety profile and human approval granted") def step_ctx_with_safety_and_approval(context: Context) -> None: """Create a context with safety profile and human approval granted.""" context.enforcement_ctx = ToolExecutionContext( plan_id="test-plan-001", safety_profile=context.enforcement_safety, metadata={"human_approved": True}, ) # --------------------------------------------------------------------------- # Cost limit context construction # --------------------------------------------------------------------------- @given("a safety profile with max_cost_per_plan {val:g}") def step_safety_max_cost_per_plan(context: Context, val: float) -> None: """Create a safety profile with max_cost_per_plan.""" context.enforcement_safety = SafetyProfile( max_cost_per_plan=val, require_sandbox=False, require_checkpoints=False, ) @given("a tool execution context with the safety profile and accumulated_cost {val:g}") def step_ctx_with_safety_and_cost(context: Context, val: float) -> None: """Create a context with safety profile and accumulated cost.""" context.enforcement_ctx = ToolExecutionContext( plan_id="test-plan-001", safety_profile=context.enforcement_safety, accumulated_cost=val, ) @given("a safety profile with max_total_cost {val:g}") def step_safety_max_total_cost(context: Context, val: float) -> None: """Create a safety profile with max_total_cost.""" context.enforcement_safety = SafetyProfile( max_total_cost=val, require_sandbox=False, require_checkpoints=False, ) @given( "a tool execution context with the safety profile" " and total_accumulated_cost {val:g}" ) def step_ctx_with_safety_and_total_cost(context: Context, val: float) -> None: """Create a context with safety profile and total accumulated cost.""" context.enforcement_ctx = ToolExecutionContext( plan_id="test-plan-001", safety_profile=context.enforcement_safety, total_accumulated_cost=val, ) # --------------------------------------------------------------------------- # Retry limit context construction # --------------------------------------------------------------------------- @given("a safety profile with max_retries_per_step {val:d}") def step_safety_max_retries(context: Context, val: int) -> None: """Create a safety profile with max_retries_per_step.""" context.enforcement_safety = SafetyProfile( max_retries_per_step=val, require_sandbox=False, require_checkpoints=False, ) @given("a tool execution context with the safety profile and step_retry_count {val:d}") def step_ctx_with_safety_and_retries(context: Context, val: int) -> None: """Create a context with safety profile and step retry count.""" context.enforcement_ctx = ToolExecutionContext( plan_id="test-plan-001", safety_profile=context.enforcement_safety, step_retry_count=val, ) # --------------------------------------------------------------------------- # Missing metadata context construction # --------------------------------------------------------------------------- @given( "a tool execution context with the safety profile and no skill category metadata" ) def step_ctx_with_safety_no_category(context: Context) -> None: """Create a context with safety profile but no tool_skill_category.""" context.enforcement_ctx = ToolExecutionContext( plan_id="test-plan-001", safety_profile=context.enforcement_safety, ) # --------------------------------------------------------------------------- # Execution steps # --------------------------------------------------------------------------- @when('I enforce safety and try to run tool "{tool_name}"') def step_try_execute(context: Context, tool_name: str) -> None: """Try executing a tool through the safety-enforcing runtime, capturing errors.""" runtime: ToolRuntime = context.enforcement_runtime ctx: ToolExecutionContext = context.enforcement_ctx context.enforcement_error = None context.enforcement_result = None try: result = runtime.execute(tool_name, {}, ctx) context.enforcement_result = result except ( ToolSafetyViolationError, ToolAccessDeniedError, ToolCheckpointRequiredError, ToolSandboxRequiredError, ToolHumanApprovalRequiredError, ToolCostLimitExceededError, ToolRetryLimitExceededError, ) as exc: context.enforcement_error = exc @when('I enforce safety and run tool "{tool_name}"') def step_execute(context: Context, tool_name: str) -> None: """Execute a tool through the safety-enforcing runtime (expected to succeed).""" runtime: ToolRuntime = context.enforcement_runtime ctx: ToolExecutionContext = context.enforcement_ctx context.enforcement_result = runtime.execute(tool_name, {}, ctx) context.enforcement_error = None # --------------------------------------------------------------------------- # Assertions # --------------------------------------------------------------------------- @then("a ToolSafetyViolationError should be raised") def step_check_safety_violation(context: Context) -> None: """Verify a ToolSafetyViolationError was raised.""" assert context.enforcement_error is not None, ( "Expected ToolSafetyViolationError but no error was raised" ) assert isinstance(context.enforcement_error, ToolSafetyViolationError), ( f"Expected ToolSafetyViolationError, " f"got {type(context.enforcement_error).__name__}: " f"{context.enforcement_error}" ) @then("a safety ToolCheckpointRequiredError should be raised") def step_check_checkpoint_required(context: Context) -> None: """Verify a ToolCheckpointRequiredError was raised.""" assert context.enforcement_error is not None, ( "Expected ToolCheckpointRequiredError but no error was raised" ) assert isinstance(context.enforcement_error, ToolCheckpointRequiredError), ( f"Expected ToolCheckpointRequiredError, " f"got {type(context.enforcement_error).__name__}: " f"{context.enforcement_error}" ) @then("a safety ToolAccessDeniedError should be raised") def step_check_access_denied(context: Context) -> None: """Verify a ToolAccessDeniedError was raised.""" assert context.enforcement_error is not None, ( "Expected ToolAccessDeniedError but no error was raised" ) assert isinstance(context.enforcement_error, ToolAccessDeniedError), ( f"Expected ToolAccessDeniedError, " f"got {type(context.enforcement_error).__name__}: " f"{context.enforcement_error}" ) @then('the safety violation error should mention "{text}"') def step_check_violation_text(context: Context, text: str) -> None: """Check that the safety violation error message contains the expected text.""" error_str = str(context.enforcement_error) assert text in error_str, f"Expected error to mention '{text}', got: {error_str}" @then("a ToolSandboxRequiredError should be raised") def step_check_sandbox_required(context: Context) -> None: """Verify a ToolSandboxRequiredError was raised.""" assert context.enforcement_error is not None, ( "Expected ToolSandboxRequiredError but no error was raised" ) assert isinstance(context.enforcement_error, ToolSandboxRequiredError), ( f"Expected ToolSandboxRequiredError, " f"got {type(context.enforcement_error).__name__}: " f"{context.enforcement_error}" ) @then('the sandbox error should mention "{text}"') def step_check_sandbox_error_text(context: Context, text: str) -> None: """Check that the sandbox error message contains the expected text.""" error_str = str(context.enforcement_error) assert text in error_str, f"Expected error to mention '{text}', got: {error_str}" @then("a ToolHumanApprovalRequiredError should be raised") def step_check_human_approval_required(context: Context) -> None: """Verify a ToolHumanApprovalRequiredError was raised.""" assert context.enforcement_error is not None, ( "Expected ToolHumanApprovalRequiredError but no error was raised" ) assert isinstance(context.enforcement_error, ToolHumanApprovalRequiredError), ( f"Expected ToolHumanApprovalRequiredError, " f"got {type(context.enforcement_error).__name__}: " f"{context.enforcement_error}" ) @then('the approval error should mention "{text}"') def step_check_approval_error_text(context: Context, text: str) -> None: """Check that the approval error message contains the expected text.""" error_str = str(context.enforcement_error) assert text in error_str, f"Expected error to mention '{text}', got: {error_str}" @then("a ToolCostLimitExceededError should be raised") def step_check_cost_limit_exceeded(context: Context) -> None: """Verify a ToolCostLimitExceededError was raised.""" assert context.enforcement_error is not None, ( "Expected ToolCostLimitExceededError but no error was raised" ) assert isinstance(context.enforcement_error, ToolCostLimitExceededError), ( f"Expected ToolCostLimitExceededError, " f"got {type(context.enforcement_error).__name__}: " f"{context.enforcement_error}" ) @then('the cost error should mention "{text}"') def step_check_cost_error_text(context: Context, text: str) -> None: """Check that the cost error message contains the expected text.""" error_str = str(context.enforcement_error) assert text in error_str, f"Expected error to mention '{text}', got: {error_str}" @then("a ToolRetryLimitExceededError should be raised") def step_check_retry_limit_exceeded(context: Context) -> None: """Verify a ToolRetryLimitExceededError was raised.""" assert context.enforcement_error is not None, ( "Expected ToolRetryLimitExceededError but no error was raised" ) assert isinstance(context.enforcement_error, ToolRetryLimitExceededError), ( f"Expected ToolRetryLimitExceededError, " f"got {type(context.enforcement_error).__name__}: " f"{context.enforcement_error}" ) @then('the retry error should mention "{text}"') def step_check_retry_error_text(context: Context, text: str) -> None: """Check that the retry error message contains the expected text.""" error_str = str(context.enforcement_error) assert text in error_str, f"Expected error to mention '{text}', got: {error_str}" @then("the tool execution should succeed") def step_check_execution_success(context: Context) -> None: """Verify the tool execution succeeded.""" assert context.enforcement_result is not None, "Expected a tool result but got None" assert context.enforcement_result.success is True, ( f"Expected success=True, got: {context.enforcement_result}" )