Files
cleveragents-core/docs/reference/read_only_actions.md
T
khyari hamza 493e5cf8a1
CI / benchmark-publish (pull_request) Has been skipped
CI / quality (pull_request) Successful in 20s
CI / lint (pull_request) Successful in 20s
CI / build (pull_request) Successful in 28s
CI / security (pull_request) Successful in 33s
CI / typecheck (pull_request) Successful in 34s
CI / integration_tests (pull_request) Successful in 3m38s
CI / benchmark-regression (pull_request) Successful in 26m8s
CI / unit_tests (pull_request) Successful in 29m12s
CI / docker (pull_request) Successful in 1m2s
CI / coverage (pull_request) Successful in 50m23s
fix(security): wire read_only through ExecuteStubActor and replace stubs with real tests
- 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
2026-02-26 20:33:20 +00:00

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