2b09568cfa
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
134 lines
4.2 KiB
Python
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]]()
|