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
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
171 lines
5.7 KiB
Python
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)
|