f2f7aa5dc9
Implement multiple Stage A/B/E/SEC milestones for the v3 lifecycle system: - Stage A5.3+A5.4: Add LifecycleActionModel and LifecyclePlanModel SQLAlchemy models with to_domain()/from_domain() conversion methods - Stage A5.6: Implement ActionRepository with full CRUD, namespace/state queries, referential integrity checks, and retry decorator - Stage E1: Add subplan domain models (ExecutionMode, SubplanMergeStrategy, SubplanConfig, SubplanStatus, SubplanAttempt, SubplanFailureHandler) with computed properties on Plan (is_subplan, is_root_plan, depth, has_subplans) - Stage A6: Add AutomationLevel enum (MANUAL, REVIEW_BEFORE_APPLY, FULL_AUTOMATION), settings integration, PlanLifecycleService auto-progression, pause/resume, and CLI commands (--automation-level, set-automation-level) - Stage SEC1: Remove eval()/exec() from stream_router.py, replace with named operation and transform registries; code blocks and unregistered transforms now raise StreamRoutingError - Add langchain-anthropic dependency - Update BDD tests for security changes and relax ADR directory requirement
289 lines
11 KiB
Python
289 lines
11 KiB
Python
"""Step definitions for non-sandboxed resource coverage scenarios.
|
|
|
|
Covers uncovered lines in ``no_sandbox.py``:
|
|
- Empty resource_id / original_path validation (lines 51, 53)
|
|
- Context property before create (line 76)
|
|
- Empty plan_id validation (line 94)
|
|
- get_path state guard (lines 136-137)
|
|
- Path traversal guard (line 142)
|
|
- CREATED -> ACTIVE transition on first path resolution (line 146-148)
|
|
- commit state guard (line 169)
|
|
- Idempotent cleanup (line 212)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
from behave import given, then, when
|
|
|
|
from cleveragents.infrastructure.sandbox.no_sandbox import NoSandbox
|
|
from cleveragents.infrastructure.sandbox.protocol import (
|
|
SandboxRollbackError,
|
|
SandboxStateError,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Givens
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given('a non-sandboxed resource for "{resource_id}" at "{path}"')
|
|
def given_nosandbox_instance(context, resource_id: str, path: str):
|
|
"""Create a NoSandbox instance with valid identifiers."""
|
|
context.nosandbox = NoSandbox(resource_id=resource_id, original_path=path)
|
|
context.nosandbox_resource_id = resource_id
|
|
context.nosandbox_path = path
|
|
|
|
|
|
@given('another non-sandboxed resource for "{resource_id}" at "{path}"')
|
|
def given_another_nosandbox_instance(context, resource_id: str, path: str):
|
|
"""Create a second NoSandbox instance for identity comparison."""
|
|
context.nosandbox_other = NoSandbox(resource_id=resource_id, original_path=path)
|
|
|
|
|
|
@given('the non-sandboxed resource has been created for plan "{plan_id}"')
|
|
def given_nosandbox_created(context, plan_id: str):
|
|
"""Move the NoSandbox through the create step."""
|
|
context.nosandbox.create(plan_id)
|
|
|
|
|
|
@given('the file "{filename}" has been resolved against the non-sandboxed resource')
|
|
def given_nosandbox_file_resolved(context, filename: str):
|
|
"""Resolve a file path so the sandbox transitions to ACTIVE."""
|
|
context.nosandbox.get_path(filename)
|
|
|
|
|
|
@given("the non-sandboxed resource has been created and committed and cleaned up")
|
|
def given_nosandbox_cleaned_up(context):
|
|
"""Drive the NoSandbox through a full lifecycle ending in CLEANED_UP."""
|
|
context.nosandbox.create("01JKXYZ1234567890ABCDEFGH")
|
|
context.nosandbox.commit()
|
|
context.nosandbox.cleanup()
|
|
|
|
|
|
@given("the non-sandboxed resource has been committed")
|
|
def given_nosandbox_committed(context):
|
|
"""Commit the NoSandbox (assumes it was already created)."""
|
|
context.nosandbox.commit()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Whens
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("a non-sandboxed resource is prepared with an empty identifier")
|
|
def when_nosandbox_empty_resource_id(context):
|
|
"""Attempt to create a NoSandbox with an empty resource_id."""
|
|
context.nosandbox_error = None
|
|
try:
|
|
NoSandbox(resource_id="", original_path="/some/path")
|
|
except ValueError as exc:
|
|
context.nosandbox_error = exc
|
|
|
|
|
|
@when("a non-sandboxed resource is prepared with an empty path")
|
|
def when_nosandbox_empty_path(context):
|
|
"""Attempt to create a NoSandbox with an empty original_path."""
|
|
context.nosandbox_error = None
|
|
try:
|
|
NoSandbox(resource_id="valid-id", original_path="")
|
|
except ValueError as exc:
|
|
context.nosandbox_error = exc
|
|
|
|
|
|
@when("the non-sandboxed resource is created with an empty plan identifier")
|
|
def when_nosandbox_create_empty_plan(context):
|
|
"""Attempt to create a sandbox with an empty plan_id."""
|
|
context.nosandbox_error = None
|
|
try:
|
|
context.nosandbox.create("")
|
|
except ValueError as exc:
|
|
context.nosandbox_error = exc
|
|
|
|
|
|
@when('the non-sandboxed resource is created for plan "{plan_id}"')
|
|
def when_nosandbox_create(context, plan_id: str):
|
|
"""Create the NoSandbox with the given plan_id."""
|
|
context.nosandbox_context = context.nosandbox.create(plan_id)
|
|
|
|
|
|
@when("a file path is resolved against the non-sandboxed resource")
|
|
def when_nosandbox_resolve_path(context):
|
|
"""Attempt to resolve a path (may fail if state is wrong)."""
|
|
context.nosandbox_error = None
|
|
try:
|
|
context.nosandbox_resolved = context.nosandbox.get_path("any/file.txt")
|
|
except (SandboxStateError, ValueError) as exc:
|
|
context.nosandbox_error = exc
|
|
|
|
|
|
@when("a file path containing directory traversal is resolved")
|
|
def when_nosandbox_resolve_traversal(context):
|
|
"""Attempt to resolve a path containing '..'."""
|
|
context.nosandbox_error = None
|
|
try:
|
|
context.nosandbox_resolved = context.nosandbox.get_path("../etc/passwd")
|
|
except ValueError as exc:
|
|
context.nosandbox_error = exc
|
|
|
|
|
|
@when('the file "{filename}" is resolved against the non-sandboxed resource')
|
|
def when_nosandbox_resolve_specific_file(context, filename: str):
|
|
"""Resolve a specific file path against the NoSandbox."""
|
|
context.nosandbox_resolved = context.nosandbox.get_path(filename)
|
|
|
|
|
|
@when("the non-sandboxed resource is committed")
|
|
def when_nosandbox_commit(context):
|
|
"""Attempt to commit the NoSandbox."""
|
|
context.nosandbox_error = None
|
|
try:
|
|
context.nosandbox_commit_result = context.nosandbox.commit()
|
|
except SandboxStateError as exc:
|
|
context.nosandbox_error = exc
|
|
|
|
|
|
@when("the non-sandboxed resource is rolled back")
|
|
def when_nosandbox_rollback(context):
|
|
"""Attempt to rollback the NoSandbox."""
|
|
context.nosandbox_error = None
|
|
try:
|
|
context.nosandbox.rollback()
|
|
except SandboxRollbackError as exc:
|
|
context.nosandbox_error = exc
|
|
|
|
|
|
@when("the non-sandboxed resource is cleaned up again")
|
|
def when_nosandbox_cleanup_again(context):
|
|
"""Call cleanup on an already cleaned-up NoSandbox."""
|
|
context.nosandbox.cleanup()
|
|
|
|
|
|
@when("the non-sandboxed resource is cleaned up")
|
|
def when_nosandbox_cleanup(context):
|
|
"""Clean up the NoSandbox."""
|
|
context.nosandbox.cleanup()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Thens
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("the non-sandboxed setup should fail with a resource identifier error")
|
|
def then_nosandbox_error_resource_id(context):
|
|
"""Verify a ValueError was raised about resource_id."""
|
|
assert context.nosandbox_error is not None, "Expected a ValueError"
|
|
assert isinstance(context.nosandbox_error, ValueError)
|
|
assert "resource_id" in str(context.nosandbox_error)
|
|
|
|
|
|
@then("the non-sandboxed setup should fail with a path error")
|
|
def then_nosandbox_error_path(context):
|
|
"""Verify a ValueError was raised about original_path."""
|
|
assert context.nosandbox_error is not None, "Expected a ValueError"
|
|
assert isinstance(context.nosandbox_error, ValueError)
|
|
assert "original_path" in str(context.nosandbox_error)
|
|
|
|
|
|
@then("the non-sandboxed resource should have no context")
|
|
def then_nosandbox_no_context(context):
|
|
"""Verify context is None before create() is called."""
|
|
assert context.nosandbox.context is None
|
|
|
|
|
|
@then("the non-sandboxed setup should fail with a plan identifier error")
|
|
def then_nosandbox_error_plan_id(context):
|
|
"""Verify a ValueError was raised about plan_id."""
|
|
assert context.nosandbox_error is not None, "Expected a ValueError"
|
|
assert isinstance(context.nosandbox_error, ValueError)
|
|
assert "plan_id" in str(context.nosandbox_error)
|
|
|
|
|
|
@then('the non-sandboxed context should reference plan "{plan_id}"')
|
|
def then_nosandbox_context_plan(context, plan_id: str):
|
|
"""Verify the context references the expected plan."""
|
|
assert context.nosandbox_context.plan_id == plan_id
|
|
|
|
|
|
@then('the non-sandboxed context should reference resource "{resource_id}"')
|
|
def then_nosandbox_context_resource(context, resource_id: str):
|
|
"""Verify the context references the expected resource."""
|
|
assert context.nosandbox_context.resource_id == resource_id
|
|
|
|
|
|
@then("the non-sandboxed context should use the original path as the sandbox path")
|
|
def then_nosandbox_context_path_matches(context):
|
|
"""Verify sandbox_path == original_path (no isolation)."""
|
|
ctx = context.nosandbox_context
|
|
assert ctx.sandbox_path == ctx.original_path
|
|
|
|
|
|
@then('the non-sandboxed context should have strategy metadata "{strategy}"')
|
|
def then_nosandbox_context_metadata(context, strategy: str):
|
|
"""Verify the metadata includes the expected strategy."""
|
|
assert context.nosandbox_context.metadata.get("strategy") == strategy
|
|
|
|
|
|
@then("the non-sandboxed path resolution should be rejected as premature")
|
|
def then_nosandbox_path_state_error(context):
|
|
"""Verify a SandboxStateError was raised for path resolution."""
|
|
assert context.nosandbox_error is not None, "Expected a SandboxStateError"
|
|
assert isinstance(context.nosandbox_error, SandboxStateError)
|
|
|
|
|
|
@then("the non-sandboxed path resolution should be rejected as unsafe")
|
|
def then_nosandbox_path_traversal_error(context):
|
|
"""Verify a ValueError was raised for directory traversal."""
|
|
assert context.nosandbox_error is not None, "Expected a ValueError"
|
|
assert isinstance(context.nosandbox_error, ValueError)
|
|
assert "traversal" in str(context.nosandbox_error).lower()
|
|
|
|
|
|
@then('the non-sandboxed resource should be in the "{status}" state')
|
|
def then_nosandbox_status(context, status: str):
|
|
"""Verify the NoSandbox is in the expected status."""
|
|
assert context.nosandbox.status.value == status
|
|
|
|
|
|
@then("the resolved non-sandboxed path should combine the original path and file")
|
|
def then_nosandbox_resolved_path(context):
|
|
"""Verify the resolved path is os.path.join(original_path, file)."""
|
|
expected = os.path.join(context.nosandbox_path, "src/main.py")
|
|
assert context.nosandbox_resolved == expected
|
|
|
|
|
|
@then("the non-sandboxed commit should be rejected as premature")
|
|
def then_nosandbox_commit_state_error(context):
|
|
"""Verify a SandboxStateError was raised for commit."""
|
|
assert context.nosandbox_error is not None, "Expected a SandboxStateError"
|
|
assert isinstance(context.nosandbox_error, SandboxStateError)
|
|
|
|
|
|
@then("the non-sandboxed commit result should indicate success")
|
|
def then_nosandbox_commit_success(context):
|
|
"""Verify the commit returned a successful result."""
|
|
assert context.nosandbox_commit_result.success is True
|
|
|
|
|
|
@then("the non-sandboxed rollback should be rejected as impossible")
|
|
def then_nosandbox_rollback_error(context):
|
|
"""Verify a SandboxRollbackError was raised."""
|
|
assert context.nosandbox_error is not None, "Expected a SandboxRollbackError"
|
|
assert isinstance(context.nosandbox_error, SandboxRollbackError)
|
|
assert "not possible" in str(context.nosandbox_error).lower()
|
|
|
|
|
|
@then('the non-sandboxed resource should remain in the "{status}" state')
|
|
def then_nosandbox_status_remains(context, status: str):
|
|
"""Verify the NoSandbox stayed in the expected status after idempotent op."""
|
|
assert context.nosandbox.status.value == status
|
|
|
|
|
|
@then("the two non-sandboxed resources should have different identifiers")
|
|
def then_nosandbox_unique_ids(context):
|
|
"""Verify two NoSandbox instances have distinct sandbox_ids."""
|
|
assert context.nosandbox.sandbox_id != context.nosandbox_other.sandbox_id
|