Files
cleveragents-core/robot/helper_security_readonly.py
khyari hamza 2b09568cfa feat(security): enforce read-only actions
Tighten ToolRuntime._enforce_capabilities() to block ANY tool with
writes=True when plan_read_only is set, removing the not-cap.read_only
loophole that allowed certain write tools through. Tool name is now
always included in the ToolAccessDeniedError message.

Add read_only flag to ChangeSetCapture with ReadOnlyViolationError
raised when write-capable tools are wrapped on a read-only plan.

Add CLI fail-fast guards on plan execute and plan apply commands that
abort before calling the service layer if plan.read_only is True.

SkillContext.enforce_write_guard() already included tool name
correctly and required no changes.

Includes 18 Behave scenarios (90 steps), Robot integration tests,
ASV benchmarks, and docs/reference/read_only_actions.md.

ISSUES CLOSED: #322
2026-02-26 20:33:20 +00:00

134 lines
4.2 KiB
Python

#!/usr/bin/env python3
"""Helper script for security_readonly Robot Framework smoke tests.
Each sub-command exercises one enforcement layer and prints a sentinel
on success so the .robot file can assert via ``Should Contain``.
"""
from __future__ import annotations
import sys
from pathlib import Path
# Ensure local src is importable
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from cleveragents.domain.models.core.tool import ( # noqa: E402
Tool,
ToolCapability,
ToolSource,
ToolType,
)
from cleveragents.skills.context import SkillContext, SkillExecutionError # noqa: E402
from cleveragents.tool.builtins.changeset import ( # noqa: E402
ChangeSetCapture,
ReadOnlyViolationError,
)
from cleveragents.tool.context import ToolExecutionContext # noqa: E402
from cleveragents.tool.lifecycle import ToolAccessDeniedError, ToolRuntime # noqa: E402
from cleveragents.tool.runtime import ToolSpec # noqa: E402
def _make_tool(name: str, writes: bool, read_only: bool) -> Tool:
return Tool(
name=name,
description=f"Test {name}",
source=ToolSource.BUILTIN,
tool_type=ToolType.TOOL,
timeout=300,
capability=ToolCapability(read_only=read_only, writes=writes),
)
def _make_spec(name: str, writes: bool, read_only: bool) -> ToolSpec:
return ToolSpec(
name=name,
description=f"Test spec {name}",
capabilities=ToolCapability(read_only=read_only, writes=writes),
handler=lambda inputs: {"ok": True},
)
def runtime_blocks_write() -> None:
tool = _make_tool("builtin/file-write", writes=True, read_only=False)
ctx = ToolExecutionContext(plan_id="plan-ro", plan_read_only=True)
try:
ToolRuntime._enforce_capabilities(tool, ctx)
print("FAIL: expected ToolAccessDeniedError")
sys.exit(1)
except ToolAccessDeniedError:
print("runtime-blocks-write-ok")
def runtime_allows_readonly() -> None:
tool = _make_tool("builtin/search", writes=False, read_only=True)
ctx = ToolExecutionContext(plan_id="plan-ro", plan_read_only=True)
ToolRuntime._enforce_capabilities(tool, ctx)
print("runtime-allows-readonly-ok")
def skillctx_blocks_write() -> None:
sctx = SkillContext(
plan_id="plan-ro",
project_id="proj",
sandbox_path=Path("/tmp/sandbox"),
read_only=True,
)
try:
sctx.enforce_write_guard("skill/writer")
print("FAIL: expected SkillExecutionError")
sys.exit(1)
except SkillExecutionError:
print("skillctx-blocks-write-ok")
def changeset_rejects_write() -> None:
cap = ChangeSetCapture(plan_id="plan-ro", read_only=True)
spec = _make_spec("test/writer", writes=True, read_only=False)
try:
cap.wrap_tool(spec)
print("FAIL: expected ReadOnlyViolationError")
sys.exit(1)
except ReadOnlyViolationError:
print("changeset-rejects-write-ok")
def changeset_passes_readonly() -> None:
cap = ChangeSetCapture(plan_id="plan-ro", read_only=True)
spec = _make_spec("test/reader", writes=False, read_only=True)
result = cap.wrap_tool(spec)
assert result.name == "test/reader"
print("changeset-passes-readonly-ok")
def error_contains_toolname() -> None:
tool = _make_tool("git/push", writes=True, read_only=False)
ctx = ToolExecutionContext(plan_id="plan-ro", plan_read_only=True)
try:
ToolRuntime._enforce_capabilities(tool, ctx)
print("FAIL: expected ToolAccessDeniedError")
sys.exit(1)
except ToolAccessDeniedError as exc:
msg = str(exc)
assert "git/push" in msg, f"Tool name missing from error: {msg}"
print("error-contains-toolname-ok")
_COMMANDS = {
"runtime-blocks-write": runtime_blocks_write,
"runtime-allows-readonly": runtime_allows_readonly,
"skillctx-blocks-write": skillctx_blocks_write,
"changeset-rejects-write": changeset_rejects_write,
"changeset-passes-readonly": changeset_passes_readonly,
"error-contains-toolname": error_contains_toolname,
}
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>")
sys.exit(1)
_COMMANDS[sys.argv[1]]()