Files
temp/features/steps/safety_profile_enforcement_steps.py
2026-04-02 16:53:03 +00:00

566 lines
21 KiB
Python

"""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}"
)