diff --git a/CHANGELOG.md b/CHANGELOG.md index 36aab2df..5309df0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -615,6 +615,22 @@ `ToolCallRouter`, `ToolCallingRuntime`, and `PlanExecutionContext` so the resolver receives project-level execution environment values stored in `ContextConfig.execution_environment`. (#1080) +- Added three missing built-in resource tools from the specification: + `builtin/file-move` (move/rename files within sandbox boundaries), + `builtin/dir-create` (create directories with optional parent creation), + and `builtin/file-info` (retrieve comprehensive file metadata including + encoding detection). All tools follow the existing ToolSpec pattern with + proper JSON Schema definitions, capability metadata, and sandbox root + validation. Updated `ChangeSetCapture` to correctly track move operations + with source/destination paths and detect the `"move"` operation type. + Review fixes applied: `_file_hash` now skips directories (prevents + `IsADirectoryError` when `dir-create` is changeset-wrapped); `file-move` + rejects directory destinations and non-existent sources with clear error + messages; `dir-create` rejects paths that are existing regular files; + `file-info` uses `st_birthtime` with `st_ctime` fallback, returns + permission-only bits, and validates UTF-8 before reporting encoding; + changeset move detection uses tool name instead of input field names. + (#883) - Added BuiltinAdapter class and MCP automatic resource slot creation. BuiltinAdapter wraps register_file_tools/register_git_tools/register_subplan_tool into a unified adapter interface. McpAdapter.infer_resource_slots() analyzes diff --git a/benchmarks/bench_builtin_file_tools_new.py b/benchmarks/bench_builtin_file_tools_new.py new file mode 100644 index 00000000..3d83d88c --- /dev/null +++ b/benchmarks/bench_builtin_file_tools_new.py @@ -0,0 +1,109 @@ +"""ASV benchmarks for new built-in file tools (move, dir-create, file-info). + +Measures the performance of: +- File move operations +- Directory creation operations +- File info retrieval operations +""" + +from __future__ import annotations + +import shutil +import sys +import tempfile +from pathlib import Path + +try: + from cleveragents.tool.builtins import register_file_tools + from cleveragents.tool.registry import ToolRegistry + from cleveragents.tool.runner import ToolRunner +except ModuleNotFoundError: + sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + from cleveragents.tool.builtins import register_file_tools + from cleveragents.tool.registry import ToolRegistry + from cleveragents.tool.runner import ToolRunner + + +class FileMoveSuite: + """Benchmark built-in file move operations.""" + + def setup(self) -> None: + self.sandbox = tempfile.mkdtemp() + self.registry = ToolRegistry() + register_file_tools(self.registry) + self.runner = ToolRunner(self.registry) + self._counter = 0 + # Pre-create a pool of source files so file creation I/O is not + # included in benchmark timing. + self._pool_size = 500 + for i in range(self._pool_size): + (Path(self.sandbox) / f"move_src_{i}.txt").write_text("benchmark data") + + def teardown(self) -> None: + shutil.rmtree(self.sandbox, ignore_errors=True) + + def time_file_move(self) -> None: + idx = self._counter % self._pool_size + src = f"move_src_{idx}.txt" + dst = f"move_dst_{self._counter}.txt" + self._counter += 1 + # Recreate the source if it was consumed by a previous iteration. + src_path = Path(self.sandbox) / src + if not src_path.exists(): + src_path.write_text("benchmark data") + self.runner.execute( + "builtin/file-move", + { + "source_path": src, + "destination_path": dst, + "sandbox_root": self.sandbox, + }, + ) + + +class DirCreateSuite: + """Benchmark built-in directory creation operations.""" + + def setup(self) -> None: + self.sandbox = tempfile.mkdtemp() + self.registry = ToolRegistry() + register_file_tools(self.registry) + self.runner = ToolRunner(self.registry) + self._counter = 0 + + def teardown(self) -> None: + shutil.rmtree(self.sandbox, ignore_errors=True) + + def time_dir_create(self) -> None: + path = f"bench_dir_{self._counter}/sub/deep" + self._counter += 1 + self.runner.execute( + "builtin/dir-create", + {"path": path, "sandbox_root": self.sandbox}, + ) + + +class FileInfoSuite: + """Benchmark built-in file info operations.""" + + def setup(self) -> None: + self.sandbox = tempfile.mkdtemp() + self.registry = ToolRegistry() + register_file_tools(self.registry) + self.runner = ToolRunner(self.registry) + (Path(self.sandbox) / "info_test.txt").write_text("benchmark metadata\n" * 50) + + def teardown(self) -> None: + shutil.rmtree(self.sandbox, ignore_errors=True) + + def time_file_info(self) -> None: + self.runner.execute( + "builtin/file-info", + {"path": "info_test.txt", "sandbox_root": self.sandbox}, + ) + + def time_dir_info(self) -> None: + self.runner.execute( + "builtin/file-info", + {"path": ".", "sandbox_root": self.sandbox}, + ) diff --git a/features/builtin_adapter.feature b/features/builtin_adapter.feature index 94208a1d..a6f42abb 100644 --- a/features/builtin_adapter.feature +++ b/features/builtin_adapter.feature @@ -14,13 +14,13 @@ Feature: BuiltinAdapter and MCP resource slot inference And the discovered tools should include all file tools And the discovered tools should include all git tools And the discovered tools should include all subplan tools - And the total discovered tool count should be 11 + And the total discovered tool count should be 14 Scenario: BuiltinAdapter.register registers all tools in a ToolRegistry Given a builtin adapter And a tool registry When I call register on the builtin adapter - Then the registry should contain 11 tools + Then the registry should contain 14 tools And the registry should contain tool "builtin/file-read" And the registry should contain tool "builtin/git-status" And the registry should contain tool "builtin/plan-subplan" diff --git a/features/builtin_file_tools_new.feature b/features/builtin_file_tools_new.feature new file mode 100644 index 00000000..dc963bbe --- /dev/null +++ b/features/builtin_file_tools_new.feature @@ -0,0 +1,172 @@ +Feature: New Built-in File Tools (move, dir-create, file-info) + As a developer + I want additional built-in file operation tools + So that agents can move files, create directories, and inspect file metadata safely + + # ---- File Move ---- + + Scenario: Move a file successfully + Given a temporary sandbox directory + And a file "source.txt" with content "move me" + When I execute the "builtin/file-move" tool moving "source.txt" to "dest.txt" + Then the tool result should be successful + And the output "moved" should be boolean true + And the file "dest.txt" should exist in the sandbox + And the file "source.txt" should not exist in the sandbox + + Scenario: Move a file with destination path traversal is rejected + Given a temporary sandbox directory + And a file "innocent.txt" with content "data" + When I execute the "builtin/file-move" tool moving "innocent.txt" to "../../escaped.txt" + Then the tool result should not be successful + And the tool result error should mention "traversal" + + Scenario: Move a file with source path traversal is rejected + Given a temporary sandbox directory + When I execute the "builtin/file-move" tool moving "../../etc/passwd" to "stolen.txt" + Then the tool result should not be successful + And the tool result error should mention "traversal" + + Scenario: Move a file overwrites existing destination + Given a temporary sandbox directory + And a file "src.txt" with content "new content" + And a file "dst.txt" with content "old content" + When I execute the "builtin/file-move" tool moving "src.txt" to "dst.txt" + Then the tool result should be successful + And the file "dst.txt" should exist in the sandbox + And the file "src.txt" should not exist in the sandbox + And the file "dst.txt" in the sandbox should have exact content "new content" + + Scenario: Move a directory is rejected + Given a temporary sandbox directory + And a subdirectory "mydir" in the sandbox + When I execute the "builtin/file-move" tool moving "mydir" to "renamed" + Then the tool result should not be successful + And the tool result error should mention "not a file" + + Scenario: Move a non-existent source file fails + Given a temporary sandbox directory + When I execute the "builtin/file-move" tool moving "ghost.txt" to "dest.txt" + Then the tool result should not be successful + And the tool result error should mention "does not exist" + + Scenario: Move a file when destination is an existing directory is rejected + Given a temporary sandbox directory + And a file "src.txt" with content "data" + And a subdirectory "target_dir" in the sandbox + When I execute the "builtin/file-move" tool moving "src.txt" to "target_dir" + Then the tool result should not be successful + And the tool result error should mention "existing directory" + + Scenario: Move a file creating destination parent directories + Given a temporary sandbox directory + And a file "deep_src.txt" with content "nested move" + When I execute the "builtin/file-move" tool moving "deep_src.txt" to "a/b/deep_dst.txt" + Then the tool result should be successful + And the file "a/b/deep_dst.txt" should exist in the sandbox + And the file "deep_src.txt" should not exist in the sandbox + + # ---- File Move with ChangeSet ---- + + Scenario: File move through changeset-wrapped tools records correct entry + Given a temporary sandbox directory + And a file "cs_src.txt" with content "changeset move" + And a changeset capture with plan_id "plan-move" + When I move "cs_src.txt" to "cs_dst.txt" through changeset-wrapped tools + Then the changeset should have 1 entry + And the changeset entry operation should be "move" + And the changeset entry metadata should include a "source_path" key + + # ---- Directory Create with ChangeSet ---- + + Scenario: Dir create through changeset-wrapped tools records correct entry + Given a temporary sandbox directory + And a changeset capture with plan_id "plan-dircreate" + When I create directory "cs_newdir" through changeset-wrapped tools + Then the changeset should have 1 entry + And the changeset entry operation should be "create" + + # ---- Directory Create ---- + + Scenario: Create a directory with parents + Given a temporary sandbox directory + When I execute the "builtin/dir-create" tool with path "a/b/c" + Then the tool result should be successful + And the output "created" should be boolean true + And the directory "a/b/c" should exist in the sandbox + + Scenario: Create a directory without parents when parent missing fails + Given a temporary sandbox directory + When I execute the "builtin/dir-create" tool with path "x/y/z" without parents + Then the tool result should not be successful + And the tool result error should mention "FileNotFoundError" + + Scenario: Create a directory when path is an existing regular file fails + Given a temporary sandbox directory + And a file "blockfile.txt" with content "blocker" + When I execute the "builtin/dir-create" tool with path "blockfile.txt" + Then the tool result should not be successful + And the tool result error should mention "not a directory" + + Scenario: Create a directory that already exists returns created false + Given a temporary sandbox directory + And a subdirectory "existing" in the sandbox + When I execute the "builtin/dir-create" tool with path "existing" + Then the tool result should be successful + And the output "created" should be boolean false + + Scenario: Create a directory with path traversal is rejected + Given a temporary sandbox directory + When I execute the "builtin/dir-create" tool with path "../../escaped_dir" + Then the tool result should not be successful + And the tool result error should mention "traversal" + + # ---- File Info ---- + + Scenario: Get file info for a regular file + Given a temporary sandbox directory + And a file "info.txt" with content "metadata test" + When I execute the "builtin/file-info" tool with path "info.txt" + Then the tool result should be successful + And the output "is_file" should be boolean true + And the output "is_dir" should be boolean false + And the output "size" should be greater than 0 + And the output should contain a valid "modified" ISO timestamp + And the output should contain a valid "created" ISO timestamp + And the output should contain a valid "accessed" ISO timestamp + And the output "encoding" should equal "utf-8" + + Scenario: Get file info for a directory + Given a temporary sandbox directory + And a subdirectory "mydir" in the sandbox + When I execute the "builtin/file-info" tool with path "mydir" + Then the tool result should be successful + And the output "is_dir" should be boolean true + And the output "is_file" should be boolean false + And the output "encoding" should be null + + Scenario: Get file info for non-existent path fails + Given a temporary sandbox directory + When I execute the "builtin/file-info" tool with path "ghost.txt" + Then the tool result should not be successful + + Scenario: Get file info with path traversal is rejected + Given a temporary sandbox directory + When I execute the "builtin/file-info" tool with path "../../etc/passwd" + Then the tool result should not be successful + And the tool result error should mention "traversal" + + Scenario: Get file info for a binary file returns null encoding + Given a temporary sandbox directory + And a binary file "data.bin" with null bytes in the sandbox + When I execute the "builtin/file-info" tool with path "data.bin" + Then the tool result should be successful + And the output "is_file" should be boolean true + And the output "encoding" should be null + + Scenario: Get file info permissions use standard octal format + Given a temporary sandbox directory + And a file "perms.txt" with content "check permissions" + When I execute the "builtin/file-info" tool with path "perms.txt" + Then the tool result should be successful + And the output "permissions" should not contain "0o100" diff --git a/features/steps/builtin_file_tools_new_steps.py b/features/steps/builtin_file_tools_new_steps.py new file mode 100644 index 00000000..b7e3e115 --- /dev/null +++ b/features/steps/builtin_file_tools_new_steps.py @@ -0,0 +1,171 @@ +"""Step definitions for the new built-in file tools (move, dir-create, file-info).""" + +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +from typing import Any + +from behave import given, then, when + +from cleveragents.tool.builtins import register_file_tools_with_changeset +from cleveragents.tool.builtins.changeset import ChangeSetCapture +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.""" + from cleveragents.tool.builtins import register_file_tools + + registry = ToolRegistry() + register_file_tools(registry) + runner = ToolRunner(registry) + inputs["sandbox_root"] = context.sandbox_dir + context.tool_result = runner.execute(tool_name, inputs) + + +def _run_changeset_tool( + context: Any, + tool_name: str, + inputs: dict[str, Any], +) -> None: + """Execute a built-in file tool through a changeset-wrapped runner.""" + capture: ChangeSetCapture = context.changeset_capture + registry = ToolRegistry() + register_file_tools_with_changeset(registry, capture) + runner = ToolRunner(registry) + inputs["sandbox_root"] = context.sandbox_dir + context.tool_result = runner.execute(tool_name, inputs) + + +# --------------------------------------------------------------------------- +# Givens +# --------------------------------------------------------------------------- + + +@given('a binary file "{name}" with null bytes in the sandbox') +def step_given_binary_file_null_bytes(context: Any, name: str) -> None: + path = Path(context.sandbox_dir) / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"\x00\x01\x02\xff binary content \x00") + + +# --------------------------------------------------------------------------- +# Whens +# --------------------------------------------------------------------------- + + +@when('I execute the "builtin/file-move" tool moving "{source}" to "{destination}"') +def step_when_file_move(context: Any, source: str, destination: str) -> None: + _run_tool( + context, + "builtin/file-move", + {"source_path": source, "destination_path": destination}, + ) + + +@when('I execute the "builtin/dir-create" tool with path "{path}"') +def step_when_dir_create(context: Any, path: str) -> None: + _run_tool(context, "builtin/dir-create", {"path": path}) + + +@when('I execute the "builtin/dir-create" tool with path "{path}" without parents') +def step_when_dir_create_no_parents(context: Any, path: str) -> None: + _run_tool(context, "builtin/dir-create", {"path": path, "parents": False}) + + +@when('I execute the "builtin/file-info" tool with path "{path}"') +def step_when_file_info(context: Any, path: str) -> None: + _run_tool(context, "builtin/file-info", {"path": path}) + + +@when('I move "{source}" to "{dest}" through changeset-wrapped tools') +def step_when_move_changeset(context: Any, source: str, dest: str) -> None: + _run_changeset_tool( + context, + "builtin/file-move", + {"source_path": source, "destination_path": dest}, + ) + + +@when('I create directory "{path}" through changeset-wrapped tools') +def step_when_dir_create_changeset(context: Any, path: str) -> None: + _run_changeset_tool( + context, + "builtin/dir-create", + {"path": path}, + ) + + +# --------------------------------------------------------------------------- +# Thens +# --------------------------------------------------------------------------- + + +@then('the directory "{name}" should exist in the sandbox') +def step_then_directory_exists(context: Any, name: str) -> None: + path = Path(context.sandbox_dir) / name + assert path.is_dir(), f"Directory {path} does not exist" + + +@then('the output should contain a valid "{key}" ISO timestamp') +def step_then_output_valid_iso_timestamp(context: Any, key: str) -> None: + assert context.tool_result.success, f"Tool failed: {context.tool_result.error}" + value = context.tool_result.output[key] + assert isinstance(value, str), f"Expected string for {key}, got {type(value)}" + # Validate by parsing — raises ValueError if not a valid ISO timestamp + parsed = datetime.fromisoformat(value) + assert parsed.tzinfo is not None, ( + f"Expected timezone-aware ISO timestamp for {key}, got '{value}'" + ) + + +@then('the file "{name}" in the sandbox should contain "{expected}"') +def step_then_file_contains(context: Any, name: str, expected: str) -> None: + path = Path(context.sandbox_dir) / name + assert path.exists(), f"File {path} does not exist" + content = path.read_text() + assert expected in content, f"Expected '{expected}' in {path}, got '{content}'" + + +@then('the file "{name}" in the sandbox should have exact content "{expected}"') +def step_then_file_exact_content(context: Any, name: str, expected: str) -> None: + path = Path(context.sandbox_dir) / name + assert path.exists(), f"File {path} does not exist" + content = path.read_text() + assert content == expected, ( + f"Expected exact content '{expected}' in {path}, got '{content}'" + ) + + +@then('the output "{key}" should equal "{expected}"') +def step_then_output_equals(context: Any, key: str, expected: str) -> None: + assert context.tool_result.success, f"Tool failed: {context.tool_result.error}" + value = context.tool_result.output[key] + assert str(value) == expected, f"Expected {key}='{expected}', got '{value}'" + + +@then('the output "{key}" should be null') +def step_then_output_is_null(context: Any, key: str) -> None: + assert context.tool_result.success, f"Tool failed: {context.tool_result.error}" + value = context.tool_result.output[key] + assert value is None, f"Expected {key}=None, got '{value}'" + + +@then('the changeset entry metadata should include a "{key}" key') +def step_then_changeset_metadata_key(context: Any, key: str) -> None: + capture: ChangeSetCapture = context.changeset_capture + cs = capture.get_changeset() + assert cs.entries, "Changeset has no entries" + entry = cs.entries[0] + assert key in entry.metadata, ( + f"Expected '{key}' in metadata, got keys: {list(entry.metadata.keys())}" + ) diff --git a/features/tool_builtins.feature b/features/tool_builtins.feature index 7655d703..11f68ba5 100644 --- a/features/tool_builtins.feature +++ b/features/tool_builtins.feature @@ -194,19 +194,22 @@ Feature: Built-in File Tools Scenario: Register all file tools Given a tool registry When I register all file tools - Then the registry should contain 6 tools + Then the registry should contain 9 tools And the registry should contain tool "builtin/file-read" And the registry should contain tool "builtin/file-write" And the registry should contain tool "builtin/file-edit" And the registry should contain tool "builtin/file-delete" And the registry should contain tool "builtin/file-list" And the registry should contain tool "builtin/file-search" + And the registry should contain tool "builtin/file-move" + And the registry should contain tool "builtin/dir-create" + And the registry should contain tool "builtin/file-info" Scenario: Register file tools with changeset Given a tool registry And a changeset capture with plan_id "plan-reg" When I register file tools with changeset - Then the registry should contain 6 tools + Then the registry should contain 9 tools # ---- ChangeSet model fields ---- diff --git a/robot/builtin_file_tools_new.robot b/robot/builtin_file_tools_new.robot new file mode 100644 index 00000000..7497a8fd --- /dev/null +++ b/robot/builtin_file_tools_new.robot @@ -0,0 +1,33 @@ +*** Settings *** +Documentation Smoke tests for new built-in file tools (move, dir-create, file-info) +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER_SCRIPT} robot/helper_builtin_file_tools_new.py + +*** Test Cases *** +File Move Tool + [Documentation] Move a file using builtin/file-move + ${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} move cwd=${WORKSPACE} timeout=60s on_timeout=kill + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} file-move-ok + +Directory Create Tool + [Documentation] Create a directory using builtin/dir-create + ${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} dir_create cwd=${WORKSPACE} timeout=60s on_timeout=kill + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} dir-create-ok + +File Info Tool + [Documentation] Get file info using builtin/file-info + ${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} info cwd=${WORKSPACE} timeout=60s on_timeout=kill + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} file-info-ok + +File Move Path Traversal Prevention + [Documentation] Path traversal in move should be blocked + ${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} move_traversal cwd=${WORKSPACE} timeout=60s on_timeout=kill + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} move-traversal-ok diff --git a/robot/helper_builtin_file_tools_new.py b/robot/helper_builtin_file_tools_new.py new file mode 100644 index 00000000..2e4dcf4e --- /dev/null +++ b/robot/helper_builtin_file_tools_new.py @@ -0,0 +1,116 @@ +"""Helper script for Robot Framework new built-in file tools smoke tests.""" + +from __future__ import annotations + +import shutil +import sys +import tempfile +from pathlib import Path +from typing import Any + +# Ensure src is importable when run from workspace root +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from cleveragents.tool.builtins import register_file_tools +from cleveragents.tool.registry import ToolRegistry +from cleveragents.tool.runner import ToolRunner + + +def _make_runner() -> ToolRunner: + """Create a ToolRunner with all built-in file tools registered.""" + registry = ToolRegistry() + register_file_tools(registry) + return ToolRunner(registry) + + +def test_file_move() -> None: + sandbox = tempfile.mkdtemp() + try: + (Path(sandbox) / "moveme.txt").write_text("moving data") + runner = _make_runner() + result = runner.execute( + "builtin/file-move", + { + "source_path": "moveme.txt", + "destination_path": "moved.txt", + "sandbox_root": sandbox, + }, + ) + assert result.success + assert result.output["moved"] is True + assert (Path(sandbox) / "moved.txt").exists() + assert not (Path(sandbox) / "moveme.txt").exists() + print("file-move-ok") + finally: + shutil.rmtree(sandbox, ignore_errors=True) + + +def test_dir_create() -> None: + sandbox = tempfile.mkdtemp() + try: + runner = _make_runner() + result = runner.execute( + "builtin/dir-create", + {"path": "a/b/c", "sandbox_root": sandbox}, + ) + assert result.success + assert result.output["created"] is True + assert (Path(sandbox) / "a" / "b" / "c").is_dir() + print("dir-create-ok") + finally: + shutil.rmtree(sandbox, ignore_errors=True) + + +def test_file_info() -> None: + sandbox = tempfile.mkdtemp() + try: + (Path(sandbox) / "info.txt").write_text("metadata") + runner = _make_runner() + result = runner.execute( + "builtin/file-info", + {"path": "info.txt", "sandbox_root": sandbox}, + ) + assert result.success + assert result.output["is_file"] is True + assert result.output["is_dir"] is False + assert result.output["size"] > 0 + assert "T" in result.output["modified"] + print("file-info-ok") + finally: + shutil.rmtree(sandbox, ignore_errors=True) + + +def test_move_traversal() -> None: + sandbox = tempfile.mkdtemp() + try: + (Path(sandbox) / "safe.txt").write_text("data") + runner = _make_runner() + result = runner.execute( + "builtin/file-move", + { + "source_path": "safe.txt", + "destination_path": "../../escaped.txt", + "sandbox_root": sandbox, + }, + ) + assert not result.success + assert "traversal" in (result.error or "").lower() + print("move-traversal-ok") + finally: + shutil.rmtree(sandbox, ignore_errors=True) + + +if __name__ == "__main__": + cmd = sys.argv[1] if len(sys.argv) > 1 else "move" + dispatch: dict[str, Any] = { + "move": test_file_move, + "dir_create": test_dir_create, + "info": test_file_info, + "move_traversal": test_move_traversal, + } + fn = dispatch.get(cmd) + if fn: + fn() + else: + print(f"Unknown command: {cmd}", file=sys.stderr) + sys.exit(1) diff --git a/src/cleveragents/tool/builtins/__init__.py b/src/cleveragents/tool/builtins/__init__.py index 997139a4..f82bbc16 100644 --- a/src/cleveragents/tool/builtins/__init__.py +++ b/src/cleveragents/tool/builtins/__init__.py @@ -19,9 +19,12 @@ from cleveragents.tool.builtins.changeset import ( ) from cleveragents.tool.builtins.file_tools import ( ALL_FILE_TOOLS, + DIR_CREATE_SPEC, FILE_DELETE_SPEC, FILE_EDIT_SPEC, + FILE_INFO_SPEC, FILE_LIST_SPEC, + FILE_MOVE_SPEC, FILE_READ_SPEC, FILE_SEARCH_SPEC, FILE_WRITE_SPEC, @@ -45,7 +48,7 @@ from cleveragents.tool.registry import ToolRegistry def register_file_tools(registry: ToolRegistry) -> None: - """Register all 6 built-in file tools into *registry*.""" + """Register all 9 built-in file tools into *registry*.""" for spec in ALL_FILE_TOOLS: registry.register(spec) @@ -80,9 +83,12 @@ __all__ = [ "ALL_FILE_TOOLS", "ALL_GIT_TOOLS", "ALL_SUBPLAN_TOOLS", + "DIR_CREATE_SPEC", "FILE_DELETE_SPEC", "FILE_EDIT_SPEC", + "FILE_INFO_SPEC", "FILE_LIST_SPEC", + "FILE_MOVE_SPEC", "FILE_READ_SPEC", "FILE_SEARCH_SPEC", "FILE_WRITE_SPEC", diff --git a/src/cleveragents/tool/builtins/changeset.py b/src/cleveragents/tool/builtins/changeset.py index 64173a48..49158df4 100644 --- a/src/cleveragents/tool/builtins/changeset.py +++ b/src/cleveragents/tool/builtins/changeset.py @@ -116,10 +116,10 @@ class ChangeSet(BaseModel): def _file_hash(path_str: str, sandbox_root: str | None = None) -> str | None: - """Compute SHA-256 hash of a file, or None if missing.""" + """Compute SHA-256 hash of a file, or ``None`` if missing or a directory.""" root = Path(sandbox_root) if sandbox_root else Path.cwd() p = (root / path_str).resolve() - if not p.exists(): + if not p.exists() or p.is_dir(): return None return hashlib.sha256(p.read_bytes()).hexdigest() @@ -208,18 +208,28 @@ class ChangeSetCapture: def _wrapped_handler( inputs: dict[str, Any], ) -> Any: - path_str = inputs.get("path", "") + # Detect move tools by name (consistent with _detect_operation). + is_move = "move" in tool_spec.name + source_path = inputs.get("source_path", "") if is_move else "" + dest_path = inputs.get("destination_path", "") if is_move else "" + + path_str = source_path if is_move else inputs.get("path", "") sandbox = inputs.get("sandbox_root") or capture._sandbox_root before = _file_hash(path_str, sandbox) if path_str else None result = original_handler(inputs) - after = _file_hash(path_str, sandbox) if path_str else None + after_path = dest_path if is_move else path_str + after = _file_hash(after_path, sandbox) if after_path else None output = result if isinstance(result, dict) else {} operation = _detect_operation(tool_spec.name, before, after, output) resource_id = inputs.get("resource_id") or capture._resource_id - normalized = _normalize_path(path_str, sandbox) + normalized = _normalize_path(after_path if is_move else path_str, sandbox) + + metadata: dict[str, Any] = {"tool": tool_spec.name} + if is_move: + metadata["source_path"] = _normalize_path(source_path, sandbox) entry = ChangeSetEntry( operation=operation, @@ -228,7 +238,7 @@ class ChangeSetCapture: tool_name=tool_spec.name, before_hash=before, after_hash=after, - metadata={"tool": tool_spec.name}, + metadata=metadata, ) capture._entries.append(entry) @@ -302,11 +312,17 @@ def _detect_operation( """Detect the operation type from tool name and file state.""" if "delete" in tool_name: return "delete" + if "move" in tool_name: + return "move" if "edit" in tool_name: return "modify" - # file-write: created vs modified - if output.get("created", False): + # Explicit "created" flag from tool output (e.g. dir-create, file-write). + if output.get("created") is True: return "create" + # dir-create on an already-existing directory is a no-op; both hashes + # are None (directories have no content hash) and created == False. + if output.get("created") is False and before is None and after is None: + return "modify" if before is None and after is not None: return "create" return "modify" diff --git a/src/cleveragents/tool/builtins/file_tools.py b/src/cleveragents/tool/builtins/file_tools.py index 5ef58f55..df8da625 100644 --- a/src/cleveragents/tool/builtins/file_tools.py +++ b/src/cleveragents/tool/builtins/file_tools.py @@ -14,6 +14,9 @@ path traversal prevention. | ``builtin/file-delete`` | ``writes`` | Delete a file | | ``builtin/file-list`` | ``read_only`` | List directory contents | | ``builtin/file-search`` | ``read_only`` | Search files for content | +| ``builtin/file-move`` | ``writes`` | Move/rename a file | +| ``builtin/dir-create`` | ``writes`` | Create a directory | +| ``builtin/file-info`` | ``read_only`` | Get file metadata | ## Resource Slot Requirements @@ -62,6 +65,9 @@ from __future__ import annotations import fnmatch import re +import shutil +import stat as stat_module +from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -238,6 +244,92 @@ def _handle_file_search(inputs: dict[str, Any]) -> dict[str, Any]: return {"matches": matches} +def _handle_file_move(inputs: dict[str, Any]) -> dict[str, Any]: + """Move/rename a file within sandbox boundaries.""" + source = validate_path(inputs["source_path"], inputs.get("sandbox_root")) + destination = validate_path(inputs["destination_path"], inputs.get("sandbox_root")) + + if not source.exists(): + raise ValueError(f"Source file does not exist: {source}") + if not source.is_file(): + raise ValueError( + f"Source is not a file (is a directory or special file): {source}" + ) + if destination.is_dir(): + raise ValueError( + f"Destination is an existing directory: {destination}. " + "Provide a full file path as the destination." + ) + + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(source), str(destination)) + + return { + "source": str(source), + "destination": str(destination), + "moved": True, + } + + +def _handle_dir_create(inputs: dict[str, Any]) -> dict[str, Any]: + """Create directory with optional parents.""" + path = validate_path(inputs["path"], inputs.get("sandbox_root")) + parents: bool = inputs.get("parents", True) + + if path.exists() and not path.is_dir(): + raise ValueError(f"Path already exists and is not a directory: {path}") + + existed = path.is_dir() + path.mkdir(parents=parents, exist_ok=True) + + return { + "path": str(path), + "created": not existed, + } + + +def _handle_file_info(inputs: dict[str, Any]) -> dict[str, Any]: + """Get file metadata.""" + path = validate_path(inputs["path"], inputs.get("sandbox_root")) + + st = path.stat() + # Use st_birthtime (macOS/BSD, Python 3.12+ on some Linux) when + # available; fall back to st_ctime (inode change time on Linux). + created_ts: float = getattr(st, "st_birthtime", st.st_ctime) + return { + "path": str(path), + "size": st.st_size, + "is_dir": path.is_dir(), + "is_file": path.is_file(), + "permissions": oct(stat_module.S_IMODE(st.st_mode)), + "created": datetime.fromtimestamp(created_ts, tz=UTC).isoformat(), + "modified": datetime.fromtimestamp(st.st_mtime, tz=UTC).isoformat(), + "accessed": datetime.fromtimestamp(st.st_atime, tz=UTC).isoformat(), + "encoding": _detect_encoding(path), + } + + +def _detect_encoding(path: Path) -> str | None: + """Detect basic encoding for a file, or ``None`` for directories/binary. + + Returns ``"utf-8"`` only when the first 8 KiB of the file + successfully decodes as UTF-8 and contains no null bytes. + Returns ``None`` for directories, binary files, or files that + are not valid UTF-8. + """ + if path.is_dir(): + return None + try: + with path.open("rb") as fh: + chunk = fh.read(8192) + if b"\x00" in chunk: + return None # binary content + chunk.decode("utf-8") + return "utf-8" + except (OSError, UnicodeDecodeError): + return None + + # --------------------------------------------------------------------------- # Tool specs # --------------------------------------------------------------------------- @@ -453,6 +545,96 @@ FILE_SEARCH_SPEC = ToolSpec( ) +FILE_MOVE_SPEC = ToolSpec( + name="builtin/file-move", + description="Move/rename a file within sandbox boundaries", + input_schema={ + "type": "object", + "properties": { + "source_path": { + "type": "string", + "description": "Source file path to move", + }, + "destination_path": { + "type": "string", + "description": "Destination file path", + }, + }, + "required": ["source_path", "destination_path"], + }, + output_schema={ + "type": "object", + "properties": { + "source": {"type": "string"}, + "destination": {"type": "string"}, + "moved": {"type": "boolean"}, + }, + }, + capabilities=ToolCapability(writes=True), + handler=_handle_file_move, +) + +DIR_CREATE_SPEC = ToolSpec( + name="builtin/dir-create", + description="Create directory with optional parents", + input_schema={ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Directory path to create", + }, + "parents": { + "type": "boolean", + "default": True, + "description": "Create parent directories if needed", + }, + }, + "required": ["path"], + }, + output_schema={ + "type": "object", + "properties": { + "path": {"type": "string"}, + "created": {"type": "boolean"}, + }, + }, + capabilities=ToolCapability(writes=True), + handler=_handle_dir_create, +) + +FILE_INFO_SPEC = ToolSpec( + name="builtin/file-info", + description="Get file metadata", + input_schema={ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "File path to get info for", + }, + }, + "required": ["path"], + }, + output_schema={ + "type": "object", + "properties": { + "path": {"type": "string"}, + "size": {"type": "integer"}, + "is_dir": {"type": "boolean"}, + "is_file": {"type": "boolean"}, + "permissions": {"type": "string"}, + "created": {"type": "string"}, + "modified": {"type": "string"}, + "accessed": {"type": "string"}, + "encoding": {"type": ["string", "null"]}, + }, + }, + capabilities=ToolCapability(read_only=True), + handler=_handle_file_info, +) + + ALL_FILE_TOOLS: list[ToolSpec] = [ FILE_READ_SPEC, FILE_WRITE_SPEC, @@ -460,4 +642,7 @@ ALL_FILE_TOOLS: list[ToolSpec] = [ FILE_DELETE_SPEC, FILE_LIST_SPEC, FILE_SEARCH_SPEC, + FILE_MOVE_SPEC, + DIR_CREATE_SPEC, + FILE_INFO_SPEC, ]