forked from HAL9000/cleveragents-core
1e987a2009
Co-authored-by: Luis Mendes <luis.mendes@cleverthis.com> Co-committed-by: Luis Mendes <luis.mendes@cleverthis.com>
566 lines
21 KiB
Python
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}"
|
|
)
|