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