Files
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

164 lines
6.3 KiB
Markdown

# Safety Profiles
Safety profiles control hard safety constraints for plan execution. Unlike
autonomy thresholds (which are confidence-dependent), safety constraints are
binary invariants enforced regardless of the system's confidence level.
## Overview
A `SafetyProfile` is a composed sub-model of `AutomationProfile` that groups
all hard safety constraints. It can also be attached directly to an `Action`
when only safety constraints are needed without full autonomy thresholds.
## Fields
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `require_sandbox` | `bool` | `true` | Require sandbox isolation for execution |
| `require_checkpoints` | `bool` | `true` | Require checkpointing before writes |
| `allow_unsafe_tools` | `bool` | `false` | Allow tools marked `unsafe` |
| `require_human_approval` | `bool` | `false` | Require human approval per action step |
| `allowed_skill_categories` | `list[str]` | `[]` | Skill categories allowed (empty = all) |
| `max_cost_per_plan` | `float \| None` | `None` | Max cost in USD per plan |
| `max_retries_per_step` | `int` | `3` | Max retries per action step (0-100) |
| `max_total_cost` | `float \| None` | `None` | Max total cost across all plans |
## Resolution Precedence
Safety profiles are resolved at `plan use` time using this precedence
(highest to lowest):
1. **Plan-level** -- set via `--automation-profile` on `agents plan use`
2. **Action-level** -- set on the action via `--automation-profile`
3. **Project-level** -- set via `agents config set core.automation-profile`
4. **Global-level** -- set via `agents config set core.automation-profile`
Once resolved, the profile is **locked to the plan** -- subsequent changes
to project or global profiles do not affect running plans.
When all levels are `None`, the `DEFAULT_SAFETY_PROFILE` is returned with
`GLOBAL` provenance.
```python
from cleveragents.domain.models.core.safety_profile import (
SafetyProfile,
resolve_safety_profile,
)
plan_safety = SafetyProfile(allow_unsafe_tools=True, require_sandbox=False)
resolved, provenance = resolve_safety_profile(plan_profile=plan_safety)
# provenance == SafetyProfileProvenance.PLAN
```
## Enforcement
Safety profile constraints are enforced at tool activation and execution
time in `ToolRuntime._enforce_capabilities()`. The checks are evaluated
in order; the first failing check raises the corresponding error.
### 1. Read-Only Plans
Read-only plan enforcement (`plan_read_only`) is independent of the safety
profile and always applies: tools with `writes=True` are blocked with
`ToolAccessDeniedError`.
### 2. Checkpoint Requirements
When `require_checkpoints=True` (from either the context flag or the safety
profile), tools without `capability.checkpointable=True` are blocked with
a `ToolCheckpointRequiredError`.
### 3. Unsafe Tool Gating
Tools with `capability.unsafe=True` are blocked unless the safety profile
has `allow_unsafe_tools=True`. When blocked, a `ToolSafetyViolationError`
is raised.
### 4. Skill Category Allow-List
When `allowed_skill_categories` is non-empty, only tools belonging to a
listed category may execute. The tool's skill category is carried in
`ToolExecutionContext.metadata["tool_skill_category"]`. An empty list means
all categories are allowed. If the metadata key is missing when the
allow-list is non-empty, a `ToolSafetyViolationError` is raised with a
clear message indicating the missing metadata.
### 5. Sandbox Requirement
When `require_sandbox=True`, tools with `capability.writes=True` require a
`sandbox_id` to be set on the `ToolExecutionContext`. If no sandbox is
available, a `ToolSandboxRequiredError` is raised. Read-only tools are not
affected by this check since they do not modify resources.
### 6. Human Approval
When `require_human_approval=True`, all tool executions require that
`ToolExecutionContext.metadata["human_approved"]` is set to `True`. If
approval has not been recorded, a `ToolHumanApprovalRequiredError` is
raised. The higher-level orchestrator is responsible for obtaining approval
and setting this metadata before tool execution.
### 7. Cost Limits
When `max_cost_per_plan` is set, tools are blocked with a
`ToolCostLimitExceededError` if `ToolExecutionContext.accumulated_cost`
meets or exceeds the limit. Similarly, `max_total_cost` is checked against
`ToolExecutionContext.total_accumulated_cost`. The caller is responsible
for updating these cost fields after each tool execution.
### 8. Retry Limit
When `max_retries_per_step` is set, tools are blocked with a
`ToolRetryLimitExceededError` if `ToolExecutionContext.step_retry_count`
exceeds the limit. The caller is responsible for incrementing the retry
count for each retry attempt.
## Error Hierarchy
```
ToolRuntimeError
|-- ToolAccessDeniedError (read-only plan violation)
|-- ToolCheckpointRequiredError (checkpoint requirement violation)
|-- ToolSafetyViolationError (unsafe tool or category violation)
|-- ToolSandboxRequiredError (sandbox requirement violation)
|-- ToolHumanApprovalRequiredError (human approval not granted)
|-- ToolCostLimitExceededError (cost limit exceeded)
|-- ToolRetryLimitExceededError (retry limit exceeded)
|-- ToolNotActivatedError
|-- ToolActivationError
|-- ToolExecutionError
+-- ToolDeactivationError
```
## Backward Compatibility
When no `SafetyProfile` is attached to the `ToolExecutionContext` (i.e.,
`safety_profile=None`), the safety-profile-specific checks (unsafe tool
gating, skill category, sandbox requirement, human approval, cost limits,
and retry limits) are skipped. Only the pre-existing `plan_read_only` and
`require_checkpoints` context flags apply.
## Default Profile
The `DEFAULT_SAFETY_PROFILE` constant provides sensible defaults matching
the specification:
```python
DEFAULT_SAFETY_PROFILE = SafetyProfile(
allowed_skill_categories=[],
require_sandbox=True,
require_checkpoints=True,
require_human_approval=False,
allow_unsafe_tools=False,
max_cost_per_plan=None,
max_retries_per_step=3,
max_total_cost=None,
)
```
## Related
- [ADR-041: Safety Profile Extraction](../adr/ADR-041-safety-profile-extraction.md)
- [Specification: Automation Profiles](../specification.md) -- Profile Precedence section
- [Specification: Tool Capability Metadata](../specification.md) -- `unsafe` field