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
- 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
322 lines
11 KiB
Python
322 lines
11 KiB
Python
"""Step definitions for read-only action enforcement (security_readonly.feature).
|
|
|
|
Tests the multi-layer read-only enforcement:
|
|
- ToolRuntime._enforce_capabilities
|
|
- SkillContext.enforce_write_guard
|
|
- ChangeSetCapture read-only guard
|
|
- CLI plan apply fail-fast
|
|
- Plan -> ToolExecutionContext propagation
|
|
- Action -> SkillMetadata compatibility check
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
|
|
from cleveragents.application.services.plan_executor import ExecuteStubActor
|
|
from cleveragents.domain.models.core.tool import (
|
|
Tool,
|
|
ToolCapability,
|
|
ToolSource,
|
|
ToolType,
|
|
)
|
|
from cleveragents.skills.context import SkillContext, SkillExecutionError
|
|
from cleveragents.skills.protocol import SkillErrorType
|
|
from cleveragents.tool.builtins.changeset import (
|
|
ChangeSetCapture,
|
|
ReadOnlyViolationError,
|
|
)
|
|
from cleveragents.tool.context import ToolExecutionContext
|
|
from cleveragents.tool.lifecycle import ToolAccessDeniedError, ToolRuntime
|
|
from cleveragents.tool.runtime import ToolSpec
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_tool(name: str, writes: bool, read_only: bool) -> Tool:
|
|
"""Build a minimal Tool domain model for testing."""
|
|
return Tool(
|
|
name=name,
|
|
description=f"Test tool {name}",
|
|
source=ToolSource.BUILTIN,
|
|
tool_type=ToolType.TOOL,
|
|
timeout=300,
|
|
capability=ToolCapability(
|
|
read_only=read_only,
|
|
writes=writes,
|
|
),
|
|
)
|
|
|
|
|
|
def _make_tool_spec(name: str, writes: bool, read_only: bool) -> ToolSpec:
|
|
"""Build a minimal ToolSpec for ChangeSetCapture wrapping."""
|
|
return ToolSpec(
|
|
name=name,
|
|
description=f"Test spec {name}",
|
|
capabilities=ToolCapability(
|
|
read_only=read_only,
|
|
writes=writes,
|
|
),
|
|
handler=lambda inputs: {"ok": True},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Background
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("the read-only enforcement modules are available")
|
|
def step_modules_available(context: Context) -> None:
|
|
"""Verify the key modules import without error."""
|
|
assert ToolRuntime is not None
|
|
assert SkillContext is not None
|
|
assert ChangeSetCapture is not None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ToolRuntime._enforce_capabilities steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given('a tool "{name}" with writes={writes} and read_only={ro}')
|
|
def step_given_tool(context: Context, name: str, writes: str, ro: str) -> None:
|
|
context.tool = _make_tool(name, writes == "True", ro == "True")
|
|
|
|
|
|
@given('a tool execution context with plan_id "{pid}" and read_only {ro}')
|
|
def step_given_exec_ctx(context: Context, pid: str, ro: str) -> None:
|
|
context.exec_ctx = ToolExecutionContext(
|
|
plan_id=pid,
|
|
plan_read_only=(ro == "True"),
|
|
)
|
|
|
|
|
|
@when("I enforce capabilities on the tool")
|
|
def step_enforce_caps(context: Context) -> None:
|
|
context.enforce_error = None
|
|
try:
|
|
ToolRuntime._enforce_capabilities(context.tool, context.exec_ctx)
|
|
except ToolAccessDeniedError as exc:
|
|
context.enforce_error = exc
|
|
|
|
|
|
@then("a ToolAccessDeniedError should be raised for the tool")
|
|
def step_check_access_denied(context: Context) -> None:
|
|
assert context.enforce_error is not None, (
|
|
"Expected ToolAccessDeniedError but none was raised"
|
|
)
|
|
assert isinstance(context.enforce_error, ToolAccessDeniedError)
|
|
|
|
|
|
@then('the access-denied message should contain "{text}"')
|
|
def step_access_denied_msg_contains(context: Context, text: str) -> None:
|
|
msg = str(context.enforce_error)
|
|
assert text in msg, f"Expected '{text}' in access-denied message: {msg}"
|
|
|
|
|
|
@then("no enforcement error should be raised")
|
|
def step_no_enforcement_error(context: Context) -> None:
|
|
assert context.enforce_error is None, (
|
|
f"Expected no enforcement error but got: {context.enforce_error}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SkillContext.enforce_write_guard steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given('a read-only SkillContext for plan "{pid}"')
|
|
def step_ro_skill_ctx(context: Context, pid: str) -> None:
|
|
context.skill_ctx = SkillContext(
|
|
plan_id=pid,
|
|
project_id="proj-test",
|
|
sandbox_path=Path("/tmp/sandbox"),
|
|
read_only=True,
|
|
)
|
|
|
|
|
|
@given('a writable SkillContext for plan "{pid}"')
|
|
def step_rw_skill_ctx(context: Context, pid: str) -> None:
|
|
context.skill_ctx = SkillContext(
|
|
plan_id=pid,
|
|
project_id="proj-test",
|
|
sandbox_path=Path("/tmp/sandbox"),
|
|
read_only=False,
|
|
)
|
|
|
|
|
|
@when('I call enforce_write_guard with tool_name "{tname}"')
|
|
def step_enforce_write_guard(context: Context, tname: str) -> None:
|
|
context.write_guard_error = None
|
|
try:
|
|
context.skill_ctx.enforce_write_guard(tname)
|
|
except SkillExecutionError as exc:
|
|
context.write_guard_error = exc
|
|
|
|
|
|
@then("a SkillExecutionError should be raised for the guard")
|
|
def step_skill_exec_error(context: Context) -> None:
|
|
assert context.write_guard_error is not None, (
|
|
"Expected SkillExecutionError but none was raised"
|
|
)
|
|
|
|
|
|
@then('the write-guard error message should contain "{text}"')
|
|
def step_write_guard_msg_contains(context: Context, text: str) -> None:
|
|
msg = str(context.write_guard_error)
|
|
assert text in msg, f"Expected '{text}' in write-guard error: {msg}"
|
|
|
|
|
|
@then("the write-guard error type should be PERMISSION_DENIED")
|
|
def step_write_guard_err_type(context: Context) -> None:
|
|
assert context.write_guard_error.error_type == SkillErrorType.PERMISSION_DENIED
|
|
|
|
|
|
@then("no write-guard error should be raised")
|
|
def step_no_write_guard_error(context: Context) -> None:
|
|
assert context.write_guard_error is None, (
|
|
f"Expected no write-guard error but got: {context.write_guard_error}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ChangeSetCapture read-only enforcement steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given('a ChangeSetCapture with plan_id "{pid}" and read_only {ro}')
|
|
def step_changeset_capture(context: Context, pid: str, ro: str) -> None:
|
|
context.capture = ChangeSetCapture(
|
|
plan_id=pid,
|
|
read_only=(ro == "True"),
|
|
)
|
|
|
|
|
|
@when("I try to wrap a write-capable tool spec")
|
|
def step_wrap_write_spec(context: Context) -> None:
|
|
context.capture_error = None
|
|
spec = _make_tool_spec("test/writer", writes=True, read_only=False)
|
|
try:
|
|
context.capture.wrap_tool(spec)
|
|
except ReadOnlyViolationError as exc:
|
|
context.capture_error = exc
|
|
|
|
|
|
@then("a ReadOnlyViolationError should be raised for the capture")
|
|
def step_ro_violation(context: Context) -> None:
|
|
assert context.capture_error is not None, (
|
|
"Expected ReadOnlyViolationError but none was raised"
|
|
)
|
|
|
|
|
|
@then('the capture error message should contain "{text}"')
|
|
def step_capture_err_msg(context: Context, text: str) -> None:
|
|
msg = str(context.capture_error)
|
|
assert text in msg, f"Expected '{text}' in capture error: {msg}"
|
|
|
|
|
|
@then("no capture error should be raised")
|
|
def step_no_capture_error(context: Context) -> None:
|
|
assert context.capture_error is None, (
|
|
f"Expected no capture error but got: {context.capture_error}"
|
|
)
|
|
|
|
|
|
@when("I wrap a read-only tool spec")
|
|
def step_wrap_ro_spec(context: Context) -> None:
|
|
context.capture_error = None
|
|
spec = _make_tool_spec("test/reader", writes=False, read_only=True)
|
|
try:
|
|
context.wrapped_spec = context.capture.wrap_tool(spec)
|
|
except ReadOnlyViolationError as exc:
|
|
context.capture_error = exc
|
|
|
|
|
|
@then("the wrapped tool spec should be returned unchanged")
|
|
def step_spec_unchanged(context: Context) -> None:
|
|
assert context.capture_error is None
|
|
assert context.wrapped_spec.name == "test/reader"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ExecuteStubActor read-only propagation steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given('a Plan domain model with read_only={ro} and plan_id "{pid}"')
|
|
def step_plan_model(context: Context, ro: str, pid: str) -> None:
|
|
context.plan_read_only = ro == "True"
|
|
context.plan_id = pid
|
|
|
|
|
|
@when("I execute via ExecuteStubActor with read_only {ro}")
|
|
def step_execute_stub_actor(context: Context, ro: str) -> None:
|
|
"""Execute via ExecuteStubActor, capturing read_only passed to ChangeSetCapture."""
|
|
read_only = ro == "True"
|
|
actor = ExecuteStubActor()
|
|
context.captured_read_only = None
|
|
|
|
original_init = ChangeSetCapture.__init__
|
|
|
|
def _spy_init(self: ChangeSetCapture, *args: object, **kwargs: object) -> None:
|
|
context.captured_read_only = kwargs.get("read_only", False)
|
|
original_init(self, *args, **kwargs) # type: ignore[arg-type]
|
|
|
|
with patch.object(ChangeSetCapture, "__init__", _spy_init):
|
|
actor.execute(
|
|
plan_id="plan-stub-ro",
|
|
decisions=[],
|
|
read_only=read_only,
|
|
)
|
|
|
|
|
|
@then("the stub actor should create a read-only ChangeSetCapture")
|
|
def step_stub_readonly_capture(context: Context) -> None:
|
|
assert context.captured_read_only is True, (
|
|
f"Expected read_only=True but got {context.captured_read_only}"
|
|
)
|
|
|
|
|
|
@then("the stub actor should create a writable ChangeSetCapture")
|
|
def step_stub_writable_capture(context: Context) -> None:
|
|
assert context.captured_read_only is False, (
|
|
f"Expected read_only=False but got {context.captured_read_only}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# (Action-Skill compatibility tests removed — the SkillContext.enforce_write_guard
|
|
# steps above already exercise the real production code path for write blocking.
|
|
# Action→Skill binding validation is not yet implemented in production code.)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Plan -> ToolExecutionContext propagation steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I create a ToolExecutionContext from the plan")
|
|
def step_create_ctx_from_plan(context: Context) -> None:
|
|
context.propagated_ctx = ToolExecutionContext(
|
|
plan_id=context.plan_id,
|
|
plan_read_only=context.plan_read_only,
|
|
)
|
|
|
|
|
|
@then("the propagated context plan_read_only should be {val}")
|
|
def step_propagated_ctx_plan_ro(context: Context, val: str) -> None:
|
|
expected = val == "True"
|
|
assert context.propagated_ctx.plan_read_only == expected, (
|
|
f"Expected plan_read_only={expected} "
|
|
f"but got {context.propagated_ctx.plan_read_only}"
|
|
)
|