# 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 lifecycle-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 lifecycle-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
```
