- Add read_only kwarg to ExecuteStubActor.execute(), propagated to ChangeSetCapture; PlanExecutor._run_execute_with_stub passes plan.read_only through the execution path (P1 #2 fix) - Replace CLI fail-fast and Action-Skill stub scenarios with real ExecuteStubActor spy test and SkillContext.enforce_write_guard integration tests (P2 #1 fix) - Update docs: remove unimplemented Layer 5 (Action-Skill), add ExecuteStubActor wiring description, fix test command (P2 #2 fix) ISSUES CLOSED: #322
3.2 KiB
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:
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:
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:
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:
nox -s unit_tests