"""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}" )