Files
cleveragents-core/features/steps/tool_builtins_steps.py
T
HAL9000 e18ac5f23c
CI / lint (pull_request) Successful in 20s
CI / quality (pull_request) Successful in 21s
CI / push-validation (pull_request) Successful in 20s
CI / build (pull_request) Successful in 24s
CI / typecheck (pull_request) Successful in 50s
CI / security (pull_request) Successful in 59s
CI / helm (pull_request) Successful in 44s
CI / integration_tests (pull_request) Successful in 4m41s
CI / unit_tests (pull_request) Successful in 5m24s
CI / docker (pull_request) Successful in 52s
CI / coverage (pull_request) Successful in 7m38s
CI / e2e_tests (pull_request) Successful in 2m14s
CI / status-check (pull_request) Successful in 1s
CI / benchmark-regression (push) Failing after 0s
CI / benchmark-publish (push) Failing after 0s
CI / lint (push) Successful in 18s
CI / quality (push) Successful in 40s
CI / typecheck (push) Successful in 41s
CI / security (push) Successful in 42s
CI / build (push) Successful in 25s
CI / push-validation (push) Successful in 30s
CI / helm (push) Successful in 56s
CI / e2e_tests (push) Successful in 3m3s
CI / unit_tests (push) Successful in 4m3s
CI / integration_tests (push) Successful in 4m8s
CI / docker (push) Successful in 52s
CI / coverage (push) Successful in 7m22s
CI / status-check (push) Successful in 1s
fix(security): replace startswith sandbox check with Path.relative_to() in validate_path #7558
The validate_path() function in file_tools.py used str.startswith() to
verify that a resolved path stays within the sandbox root. This allowed
sibling directories whose names share a string prefix with the sandbox
(e.g. /tmp/sandbox-escape/ bypassing /tmp/sandbox/) to escape the
containment check.

Replace the string prefix check with Path.relative_to(root), which
performs a proper OS-level path prefix comparison using path separators.
Add a Behave regression scenario tagged @tdd_issue @tdd_issue_7558 that
exercises the prefix-collision bypass to prevent regressions.

ISSUES CLOSED: #7558
2026-04-17 04:50:55 +00:00

452 lines
16 KiB
Python

"""Step definitions for the Built-in File Tools feature."""
from __future__ import annotations
import fnmatch
import shutil
import tempfile
from pathlib import Path
from typing import Any
from behave import given, then, when
from cleveragents.tool.builtins import (
register_file_tools,
register_file_tools_with_changeset,
)
from cleveragents.tool.builtins.changeset import (
ChangeSetCapture,
ChangeSetEntry,
_detect_operation,
)
from cleveragents.tool.registry import ToolRegistry
from cleveragents.tool.runner import ToolRunner
__all__: list[str] = []
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _run_tool(context: Any, tool_name: str, inputs: dict[str, Any]) -> None:
"""Execute a built-in file tool through the runner."""
registry = ToolRegistry()
if hasattr(context, "changeset_capture") and context.changeset_capture is not None:
register_file_tools_with_changeset(registry, context.changeset_capture)
else:
register_file_tools(registry)
runner = ToolRunner(registry)
inputs["sandbox_root"] = context.sandbox_dir
context.tool_result = runner.execute(tool_name, inputs)
# ---------------------------------------------------------------------------
# Givens
# ---------------------------------------------------------------------------
@given("a temporary sandbox directory")
def step_given_sandbox(context: Any) -> None:
context.sandbox_dir = tempfile.mkdtemp()
context._cleanup_handlers.append(
lambda: shutil.rmtree(context.sandbox_dir, ignore_errors=True)
)
@given('a file "{name}" with content "{content}"')
def step_given_file_with_content(context: Any, name: str, content: str) -> None:
path = Path(context.sandbox_dir) / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
@given('a file "{name}" with lines "{csv_lines}"')
def step_given_file_with_lines(context: Any, name: str, csv_lines: str) -> None:
lines = csv_lines.split(",")
path = Path(context.sandbox_dir) / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join(lines) + "\n")
@given('a changeset capture with plan_id "{plan_id}"')
def step_given_changeset_capture(context: Any, plan_id: str) -> None:
context.changeset_capture = ChangeSetCapture(plan_id=plan_id)
@given('a subdirectory "{name}" in the sandbox')
def step_given_subdirectory(context: Any, name: str) -> None:
path = Path(context.sandbox_dir) / name
path.mkdir(parents=True, exist_ok=True)
@given("a sibling directory with a name that is a prefix of the sandbox name")
def step_given_sibling_prefix_dir(context: Any) -> None:
"""Create a sibling directory whose name is a string prefix of the sandbox dir.
For example, if the sandbox is /tmp/abc123, this creates /tmp/abc123-escape.
This exercises the path traversal bypass where str.startswith() would
incorrectly allow /tmp/abc123-escape to pass a /tmp/abc123 sandbox check.
The step stores the relative escape path on context for use in When steps.
"""
sandbox_path = Path(context.sandbox_dir)
sibling_name = sandbox_path.name + "-escape"
sibling_path = sandbox_path.parent / sibling_name
sibling_path.mkdir(parents=True, exist_ok=True)
context._cleanup_handlers.append(
lambda: shutil.rmtree(str(sibling_path), ignore_errors=True)
)
context.sibling_escape_dir = str(sibling_path)
# Relative path from sandbox root that resolves into the sibling directory
context.sibling_escape_rel_path = f"../{sibling_name}/secret.txt"
# ---------------------------------------------------------------------------
# Whens
# ---------------------------------------------------------------------------
@when('I execute the "builtin/file-read" tool with path "{path}"')
def step_when_file_read(context: Any, path: str) -> None:
_run_tool(context, "builtin/file-read", {"path": path})
@when("I attempt to read a file in the sibling escape directory")
def step_when_read_sibling_escape(context: Any) -> None:
"""Attempt to read a file using the dynamic sibling-escape relative path.
Uses the path stored by the 'sibling directory with a name that is a prefix'
Given step to exercise the prefix-collision path traversal bypass.
"""
_run_tool(context, "builtin/file-read", {"path": context.sibling_escape_rel_path})
@when(
'I execute the "builtin/file-read" tool with path "{path}" offset {offset:d} limit {limit:d}'
)
def step_when_file_read_offset_limit(
context: Any, path: str, offset: int, limit: int
) -> None:
_run_tool(
context, "builtin/file-read", {"path": path, "offset": offset, "limit": limit}
)
@when(
'I execute the "builtin/file-write" tool with path "{path}" and content "{content}"'
)
def step_when_file_write(context: Any, path: str, content: str) -> None:
_run_tool(context, "builtin/file-write", {"path": path, "content": content})
@when('I execute the "builtin/file-edit" tool replacing "{old}" with "{new}"')
def step_when_file_edit(context: Any, old: str, new: str) -> None:
_run_tool(
context,
"builtin/file-edit",
{"path": "edit.txt", "old_text": old, "new_text": new},
)
@when('I execute the "builtin/file-edit" tool replacing all "{old}" with "{new}"')
def step_when_file_edit_all(context: Any, old: str, new: str) -> None:
_run_tool(
context,
"builtin/file-edit",
{"path": "edit.txt", "old_text": old, "new_text": new, "replace_all": True},
)
@when('I execute the "builtin/file-delete" tool with path "{path}"')
def step_when_file_delete(context: Any, path: str) -> None:
_run_tool(context, "builtin/file-delete", {"path": path})
@when('I list directory "{path}" with the file-list tool')
def step_when_file_list(context: Any, path: str) -> None:
_run_tool(context, "builtin/file-list", {"path": path})
@when('I list directory "{path}" filtering by pattern "{pattern}"')
def step_when_file_list_pattern(context: Any, path: str, pattern: str) -> None:
_run_tool(context, "builtin/file-list", {"path": path, "pattern": pattern})
@when('I list directory "{path}" recursively with the file-list tool')
def step_when_file_list_recursive(context: Any, path: str) -> None:
_run_tool(context, "builtin/file-list", {"path": path, "recursive": True})
@when('I search directory "{path}" for pattern "{pattern}"')
def step_when_file_search(context: Any, path: str, pattern: str) -> None:
_run_tool(context, "builtin/file-search", {"path": path, "pattern": pattern})
@when(
'I search directory "{path}" for pattern "{pattern}" with max_results {max_results:d}'
)
def step_when_file_search_max(
context: Any, path: str, pattern: str, max_results: int
) -> None:
_run_tool(
context,
"builtin/file-search",
{"path": path, "pattern": pattern, "max_results": max_results},
)
@when("I write a file through changeset-wrapped tools")
def step_when_write_changeset(context: Any) -> None:
_run_tool(
context, "builtin/file-write", {"path": "cs-new.txt", "content": "changeset"}
)
@when("I edit a file through changeset-wrapped tools")
def step_when_edit_changeset(context: Any) -> None:
_run_tool(
context,
"builtin/file-edit",
{"path": "cs-edit.txt", "old_text": "hello", "new_text": "goodbye"},
)
@when("I delete a file through changeset-wrapped tools")
def step_when_delete_changeset(context: Any) -> None:
_run_tool(context, "builtin/file-delete", {"path": "cs-delete.txt"})
@when("I perform multiple changeset-tracked operations")
def step_when_multiple_changeset_ops(context: Any) -> None:
_run_tool(context, "builtin/file-write", {"path": "multi1.txt", "content": "a"})
_run_tool(context, "builtin/file-write", {"path": "multi2.txt", "content": "b"})
# Edit multi1.txt
_run_tool(
context,
"builtin/file-edit",
{"path": "multi1.txt", "old_text": "a", "new_text": "c"},
)
@when("I clear the changeset")
def step_when_clear_changeset(context: Any) -> None:
context.changeset_capture.clear()
@when("I register all file tools")
def step_when_register_all(context: Any) -> None:
register_file_tools(context.registry)
@when("I register file tools with changeset")
def step_when_register_with_changeset(context: Any) -> None:
register_file_tools_with_changeset(context.registry, context.changeset_capture)
@when('I edit "{filename}" through changeset-wrapped tools')
def step_when_edit_specific_changeset(context: Any, filename: str) -> None:
_run_tool(
context,
"builtin/file-edit",
{"path": filename, "old_text": "original", "new_text": "modified"},
)
@when("I write a new file through raw changeset capture")
def step_when_raw_capture_write_new(context: Any) -> None:
"""Test _detect_operation fallback when before is None and after exists."""
# Simulate: before=None, after=hash, output has no 'created' key
op = _detect_operation("builtin/file-write", None, "abc123hash", {})
context.changeset_capture._entries.append(
ChangeSetEntry(
operation=op,
path="new-fallback.txt",
before_hash=None,
after_hash="abc123hash",
metadata={"tool": "builtin/file-write"},
)
)
@when("I overwrite a file through raw changeset capture")
def step_when_raw_capture_overwrite(context: Any) -> None:
"""Test _detect_operation fallback when before and after both exist."""
# Simulate: before=hash, after=different_hash, output has no 'created' key
op = _detect_operation("builtin/file-write", "hash1", "hash2", {})
context.changeset_capture._entries.append(
ChangeSetEntry(
operation=op,
path="existing-cs.txt",
before_hash="hash1",
after_hash="hash2",
metadata={"tool": "builtin/file-write"},
)
)
# ---------------------------------------------------------------------------
# Thens
# ---------------------------------------------------------------------------
@then('the output "{key}" should be "{value}"')
def step_then_output_str(context: Any, key: str, value: str) -> None:
assert context.tool_result.success, f"Tool failed: {context.tool_result.error}"
assert str(context.tool_result.output[key]) == value, (
f"Expected {key}={value}, got {context.tool_result.output[key]}"
)
@then('the output "{key}" should be greater than {value:d}')
def step_then_output_gt(context: Any, key: str, value: int) -> None:
assert context.tool_result.output[key] > value
@then('the output "{key}" should be boolean true')
def step_then_output_true(context: Any, key: str) -> None:
assert context.tool_result.output[key] is True
@then('the output "{key}" should be boolean false')
def step_then_output_false(context: Any, key: str) -> None:
assert context.tool_result.output[key] is False
@then('the output "{key}" should equal {value:d}')
def step_then_output_int(context: Any, key: str, value: int) -> None:
assert context.tool_result.output[key] == value, (
f"Expected {key}={value}, got {context.tool_result.output[key]}"
)
@then('the output "{key}" should contain "{text}"')
def step_then_output_contains(context: Any, key: str, text: str) -> None:
assert text in context.tool_result.output[key], (
f"Expected '{text}' in {key}: {context.tool_result.output[key]}"
)
@then('the output "{key}" should not contain "{text}"')
def step_then_output_not_contains(context: Any, key: str, text: str) -> None:
assert text not in context.tool_result.output[key]
@then('the file "{name}" should exist in the sandbox')
def step_then_file_exists(context: Any, name: str) -> None:
path = Path(context.sandbox_dir) / name
assert path.exists(), f"File {path} does not exist"
@then('the file "{name}" should not exist in the sandbox')
def step_then_file_not_exists(context: Any, name: str) -> None:
path = Path(context.sandbox_dir) / name
assert not path.exists(), f"File {path} still exists"
@then("the output files list should contain at least {count:d} entries")
def step_then_files_count(context: Any, count: int) -> None:
assert context.tool_result.success
files = context.tool_result.output["files"]
assert len(files) >= count, f"Expected >= {count} files, got {len(files)}"
@then('all listed files should match pattern "{pattern}"')
def step_then_files_match_pattern(context: Any, pattern: str) -> None:
assert context.tool_result.success
files = context.tool_result.output["files"]
for f in files:
assert fnmatch.fnmatch(f["name"], pattern), (
f"File {f['name']} does not match pattern {pattern}"
)
@then("the search matches should contain at least {count:d} result")
def step_then_search_min(context: Any, count: int) -> None:
assert context.tool_result.success
matches = context.tool_result.output["matches"]
assert len(matches) >= count, f"Expected >= {count} matches, got {len(matches)}"
@then("the search matches should contain exactly {count:d} results")
def step_then_search_exact(context: Any, count: int) -> None:
assert context.tool_result.success
matches = context.tool_result.output["matches"]
assert len(matches) == count, f"Expected {count} matches, got {len(matches)}"
@then("the changeset should have {count:d} entry")
def step_then_changeset_count_singular(context: Any, count: int) -> None:
cs = context.changeset_capture.get_changeset()
assert len(cs.entries) == count, f"Expected {count} entries, got {len(cs.entries)}"
@then("the changeset should have {count:d} entries")
def step_then_changeset_count_plural(context: Any, count: int) -> None:
cs = context.changeset_capture.get_changeset()
assert len(cs.entries) == count, f"Expected {count} entries, got {len(cs.entries)}"
@then('the changeset entry operation should be "{operation}"')
def step_then_changeset_operation(context: Any, operation: str) -> None:
cs = context.changeset_capture.get_changeset()
assert len(cs.entries) > 0, "No changeset entries"
assert cs.entries[-1].operation == operation, (
f"Expected operation '{operation}', got '{cs.entries[-1].operation}'"
)
@then("the changeset summary should mention the total count")
def step_then_summary_count(context: Any) -> None:
cs = context.changeset_capture.get_changeset()
summary = cs.summary()
count = len(cs.entries)
assert str(count) in summary, f"Expected count {count} in summary: {summary}"
@then("the changeset summary should mention operation types")
def step_then_summary_ops(context: Any) -> None:
cs = context.changeset_capture.get_changeset()
summary = cs.summary()
assert "create" in summary or "modify" in summary or "delete" in summary, (
f"No operation types in summary: {summary}"
)
@then('the changeset summary should be "{expected}"')
def step_then_summary_exact(context: Any, expected: str) -> None:
cs = context.changeset_capture.get_changeset()
assert cs.summary() == expected, f"Expected '{expected}', got '{cs.summary()}'"
@then("the registry should contain {count:d} tools")
def step_then_registry_count(context: Any, count: int) -> None:
tools = context.registry.list_tools()
assert len(tools) == count, f"Expected {count} tools, got {len(tools)}"
@then('the registry should contain tool "{name}"')
def step_then_registry_has_tool(context: Any, name: str) -> None:
assert context.registry.get(name) is not None, (
f"Tool '{name}' not found in registry"
)
@then("the changeset entry should have a before_hash")
def step_then_has_before_hash(context: Any) -> None:
cs = context.changeset_capture.get_changeset()
assert cs.entries[-1].before_hash is not None
@then("the changeset entry should have an after_hash")
def step_then_has_after_hash(context: Any) -> None:
cs = context.changeset_capture.get_changeset()
assert cs.entries[-1].after_hash is not None
@then("the before_hash and after_hash should differ")
def step_then_hashes_differ(context: Any) -> None:
cs = context.changeset_capture.get_changeset()
entry = cs.entries[-1]
assert entry.before_hash != entry.after_hash, "Hashes should differ after edit"