Files
temp/docs/reference/read_only_actions.md
freemo 48cff5cfe0 refactor(cli): rename plan lifecycle-list and lifecycle-apply to match specification
Renames `plan lifecycle-list` to `plan list` and `plan lifecycle-apply` to `plan apply` to align with the specification's canonical command names. Removes legacy V2 plan commands that occupied those names.

- Renamed CLI command registrations from lifecycle-list/lifecycle-apply to list/apply
- Removed legacy V2 apply and list commands (~200 lines)
- Updated apply shortcut in main.py to delegate to v3 lifecycle
- Added defensive null check for plan existence in apply command
- Updated 63+ test, doc, and benchmark files for consistency

Closes #881

Co-authored-by: Jeffrey Phillips Freeman <the@jeffreyfreeman.me>
Co-committed-by: Jeffrey Phillips Freeman <the@jeffreyfreeman.me>
2026-04-02 19:09:04 +00:00

107 lines
3.2 KiB
Markdown

# Security: Read-Only Action Enforcement
This document describes the multi-layer read-only enforcement in
CleverAgents, ensuring that plans marked `read_only=True` never
produce side-effects.
## Background
A **read-only plan** is created when the originating Action has
`read_only=True`. The flag propagates through the execution layers:
```
Plan.read_only -> CLI fail-fast guard (plan execute / plan apply)
-> ExecuteStubActor.execute(read_only=...) -> ChangeSetCapture
-> ToolRuntime._enforce_capabilities (via ToolExecutionContext)
-> SkillContext.enforce_write_guard
```
## Enforcement Layers
### 1. ToolRuntime._enforce_capabilities
Location: `src/cleveragents/tool/lifecycle.py`
When a tool is about to execute, `_enforce_capabilities()` checks:
```python
if ctx.plan_read_only and cap.writes:
raise ToolAccessDeniedError(
f"Tool '{tool.name}' has writes=True but plan "
f"'{ctx.plan_id}' is read-only"
)
```
Any tool with `ToolCapability.writes=True` is blocked regardless of
`cap.read_only`. The error always includes the tool name for
auditability.
### 2. SkillContext.enforce_write_guard
Location: `src/cleveragents/skills/context.py`
Skills call `enforce_write_guard(tool_name)` before executing write
operations. When `read_only=True`:
```python
raise SkillExecutionError(
SkillError(
error_type=SkillErrorType.PERMISSION_DENIED,
message=f"Write operation denied: tool '{tool_name}' cannot "
f"write in read-only context (plan={self.plan_id})",
...
)
)
```
### 3. ChangeSetCapture (wired via ExecuteStubActor)
Location: `src/cleveragents/tool/builtins/changeset.py`
`ChangeSetCapture` accepts a `read_only` keyword argument. When
`read_only=True`, `wrap_tool()` raises `ReadOnlyViolationError` for
any tool spec with `writes=True`:
```python
raise ReadOnlyViolationError(
f"Cannot wrap write-capable tool '{tool_spec.name}' "
f"in read-only ChangeSetCapture (plan={self._plan_id})"
)
```
Read-only tool specs pass through unchanged.
`ExecuteStubActor.execute()` propagates the plan's `read_only` flag
into `ChangeSetCapture`, and `PlanExecutor._run_execute_with_stub()`
reads `plan.read_only` from the plan model.
### 4. CLI Fail-Fast Guards
Location: `src/cleveragents/cli/commands/plan.py`
Both `plan execute` and `plan apply` check `plan.read_only`
before calling the lifecycle service. If the plan is read-only, the
CLI prints an error and aborts without making any state transitions.
## Error Types
| Layer | Error Class | Module |
|----------------|---------------------------|-----------------------------|
| ToolRuntime | `ToolAccessDeniedError` | `tool.lifecycle` |
| SkillContext | `SkillExecutionError` | `skills.context` |
| ChangeSet | `ReadOnlyViolationError` | `tool.builtins.changeset` |
| CLI | `typer.Abort` | `cli.commands.plan` |
## Testing
BDD scenarios covering all layers are in:
- `features/security_readonly.feature` (18 scenarios)
- `features/steps/security_readonly_steps.py`
Run with:
```bash
nox -s unit_tests
```