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