Files
cleveragents-core/robot/helper_safety_profile.py
T
Luis Mendes b4b96d213c
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 15s
CI / build (pull_request) Successful in 15s
CI / quality (pull_request) Successful in 29s
CI / security (pull_request) Successful in 34s
CI / typecheck (pull_request) Successful in 39s
CI / unit_tests (pull_request) Successful in 2m19s
CI / docker (pull_request) Successful in 40s
CI / integration_tests (pull_request) Successful in 3m1s
CI / coverage (pull_request) Successful in 7m20s
CI / lint (push) Successful in 13s
CI / quality (push) Successful in 16s
CI / build (push) Successful in 18s
CI / security (push) Successful in 31s
CI / typecheck (push) Successful in 35s
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Successful in 2m10s
CI / docker (push) Successful in 39s
CI / integration_tests (push) Successful in 3m4s
CI / coverage (push) Successful in 4m45s
CI / benchmark-publish (push) Successful in 14m51s
CI / benchmark-regression (pull_request) Successful in 26m49s
feat(security): add safety profile enforcement
Implement safety profile resolution and enforcement in the tool
execution pipeline, replacing the NotImplementedError stub with
working precedence logic and runtime safety checks.

Core changes:
- resolve_safety_profile() now resolves plan > action > project >
  global precedence, returning the highest-priority non-None profile
  (or DEFAULT_SAFETY_PROFILE with GLOBAL provenance when all None)
- ToolExecutionContext gains an optional safety_profile field
- ToolRuntime._enforce_capabilities() extended with three new checks:
  * Unsafe tool gating: blocks tools with unsafe=True when profile
    has allow_unsafe_tools=False (ToolSafetyViolationError)
  * Skill category allow-list: blocks tools whose skill category
    is not in allowed_skill_categories (ToolSafetyViolationError)
  * Checkpoint requirement: OR-combines ctx.require_checkpoints
    with safety_profile.require_checkpoints
- New ToolSafetyViolationError in tool error hierarchy

Test coverage:
- 30 updated BDD scenarios in safety_profile.feature (resolve
  precedence replaces NotImplementedError stub test)
- 24 new BDD scenarios in safety_profile_enforcement.feature
- 9 Robot Framework integration smoke tests
- 4 ASV benchmark suites (construction, serialization, resolution,
  provenance enum)

All nox sessions pass (typecheck 0 errors, unit_tests 7735 scenarios
0 failures, coverage 97%, integration_tests 9/9 passed, benchmarks
complete).

ISSUES CLOSED: #345
2026-03-03 22:21:14 +00:00

171 lines
5.7 KiB
Python

"""Helper script for Robot Framework safety profile smoke tests."""
from __future__ import annotations
import json
import sys
import tempfile
from pathlib import Path
from typing import Any
# Ensure src is importable when run from workspace root
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cleveragents.domain.models.core.safety_profile import (
DEFAULT_SAFETY_PROFILE,
SafetyProfile,
SafetyProfileProvenance,
SafetyProfileRef,
resolve_safety_profile,
)
def _test_default() -> None:
"""Load default safety profile and verify."""
profile = DEFAULT_SAFETY_PROFILE
assert isinstance(profile, SafetyProfile)
assert profile.require_sandbox is True
assert profile.require_checkpoints is True
assert profile.require_human_approval is False
assert profile.allow_unsafe_tools is False
assert profile.max_retries_per_step == 3
assert profile.max_cost_per_plan is None
assert profile.max_total_cost is None
assert profile.allowed_skill_categories == []
print(
f"safety-default-ok "
f"(sandbox={profile.require_sandbox}, "
f"checkpoints={profile.require_checkpoints}, "
f"approval={profile.require_human_approval}, "
f"unsafe_tools={profile.allow_unsafe_tools})"
)
def _test_from_config() -> None:
"""Load safety profile from config dict."""
config = {
"require_sandbox": False,
"require_checkpoints": False,
"require_human_approval": True,
"allow_unsafe_tools": True,
"max_cost_per_plan": 100.0,
"max_retries_per_step": 5,
"max_total_cost": 500.0,
"allowed_skill_categories": ["code", "test", "deploy"],
}
profile = SafetyProfile.from_config(config)
assert profile.require_sandbox is False
assert profile.require_checkpoints is False
assert profile.require_human_approval is True
assert profile.allow_unsafe_tools is True
assert profile.max_cost_per_plan == 100.0
assert profile.max_retries_per_step == 5
assert profile.max_total_cost == 500.0
assert len(profile.allowed_skill_categories) == 3
print(f"safety-from-config-ok (categories={profile.allowed_skill_categories})")
def _test_from_yaml() -> None:
"""Load safety profile from YAML file."""
yaml_content = (
"require_sandbox: false\n"
"require_checkpoints: true\n"
"require_human_approval: false\n"
"max_cost_per_plan: 50.0\n"
"max_retries_per_step: 2\n"
"allowed_skill_categories:\n"
" - code\n"
" - test\n"
)
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
f.write(yaml_content)
yaml_path = f.name
try:
profile = SafetyProfile.from_yaml(yaml_path)
assert profile.require_sandbox is False
assert profile.require_checkpoints is True
assert profile.max_cost_per_plan == 50.0
assert profile.max_retries_per_step == 2
assert len(profile.allowed_skill_categories) == 2
serialized = profile.model_dump_json(indent=2)
print(serialized)
print("safety-from-yaml-ok")
finally:
Path(yaml_path).unlink(missing_ok=True)
def _test_serialize() -> None:
"""Serialize safety profile and verify output."""
profile = SafetyProfile(
require_sandbox=True,
require_checkpoints=False,
max_cost_per_plan=75.0,
allowed_skill_categories=["code"],
)
data = profile.model_dump(mode="json")
assert data["require_sandbox"] is True
assert data["require_checkpoints"] is False
assert data["max_cost_per_plan"] == 75.0
json_str = profile.model_dump_json(indent=2)
parsed = json.loads(json_str)
assert parsed["max_cost_per_plan"] == 75.0
print(json_str)
print("safety-serialize-ok")
def _test_ref() -> None:
"""Create and verify SafetyProfileRef."""
ref = SafetyProfileRef(
profile_name="strict",
provenance=SafetyProfileProvenance.PLAN,
)
assert ref.profile_name == "strict"
assert ref.provenance == SafetyProfileProvenance.PLAN
print(f"safety-ref-ok (name={ref.profile_name}, prov={ref.provenance})")
def _test_summary() -> None:
"""Print summary of safety profile capabilities."""
default = DEFAULT_SAFETY_PROFILE
print(f" Default profile: sandbox={default.require_sandbox}")
print(f" Checkpoints: {default.require_checkpoints}")
print(f" Human approval: {default.require_human_approval}")
print(f" Unsafe tools: {default.allow_unsafe_tools}")
print(f" Max retries: {default.max_retries_per_step}")
print(f" Max cost/plan: {default.max_cost_per_plan}")
print(f" Max total cost: {default.max_total_cost}")
print(f" Categories: {default.allowed_skill_categories}")
# Verify resolve_safety_profile returns default with GLOBAL provenance
# when all levels are None.
resolved, provenance = resolve_safety_profile()
assert resolved is DEFAULT_SAFETY_PROFILE, (
f"Expected DEFAULT_SAFETY_PROFILE, got {resolved}"
)
assert provenance == SafetyProfileProvenance.GLOBAL, (
f"Expected GLOBAL provenance, got {provenance}"
)
print(f" resolve_safety_profile: {provenance.value} (expected)")
print("summary-ok")
if __name__ == "__main__":
cmd = sys.argv[1] if len(sys.argv) > 1 else "summary"
dispatch: dict[str, Any] = {
"default": _test_default,
"from-config": _test_from_config,
"from-yaml": _test_from_yaml,
"serialize": _test_serialize,
"ref": _test_ref,
"summary": _test_summary,
}
fn = dispatch.get(cmd)
if fn:
fn()
else:
print(f"Unknown command: {cmd}", file=sys.stderr)
sys.exit(1)