Compare commits

...

1 Commits

Author SHA1 Message Date
CoreRasurae 07d104f831 feat(tool): add move_file, create_directory, and get_file_info built-in tools
CI / lint (pull_request) Failing after 2s
CI / typecheck (pull_request) Failing after 1s
CI / coverage (pull_request) Has been skipped
CI / security (pull_request) Failing after 2s
CI / quality (pull_request) Failing after 2s
CI / unit_tests (pull_request) Failing after 1s
CI / docker (pull_request) Has been skipped
CI / integration_tests (pull_request) Failing after 2s
CI / e2e_tests (pull_request) Failing after 2s
CI / build (pull_request) Failing after 2s
CI / helm (pull_request) Failing after 1s
CI / status-check (pull_request) Failing after 1s
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Has been skipped
Implemented three missing built-in resource tools from the specification:

- builtin/file-move: Move/rename files within sandbox boundaries using
  shutil.move with path traversal protection. Validates source exists
  and is a regular file, rejects directory destinations with clear
  error messages. Creates destination parent directories automatically.
- builtin/dir-create: Create directories with optional parent creation
  using Path.mkdir with sandbox validation. Returns accurate "created"
  field (false when directory already existed). Rejects paths that are
  existing regular files with a descriptive error.
- builtin/file-info: Retrieve comprehensive file metadata (size,
  permissions, timestamps, type, encoding) with ISO 8601 formatted dates.
  Uses st_birthtime when available (macOS/BSD) with st_ctime fallback
  (Linux). Permissions return only permission bits via stat.S_IMODE.
  Encoding detection validates UTF-8 decoding before reporting "utf-8";
  returns null for binary, non-UTF-8, or directory paths.

All tools follow the existing ToolSpec pattern with proper JSON Schema
definitions, capability metadata, and sandbox root validation. Added
to ALL_FILE_TOOLS and registration helpers.

Updated ChangeSetCapture to correctly track move operations: the wrapped
handler now detects move tools by tool name (consistent with
_detect_operation) and computes before/after hashes from the correct
paths. Added "move" detection to _detect_operation so file-move entries
get operation="move" (mapped to ChangeOperation.RENAME) instead of the
incorrect "modify" fallback. Fixed _file_hash to skip directories
(returns None) preventing IsADirectoryError when dir-create is wrapped
by ChangeSetCapture during plan execution.

Removed sandbox_root from new tool input_schemas to align with the 6
existing file tools which also omit it from their schemas.

Tests: 22 Behave BDD scenarios (up from 7), Robot integration tests
with on_timeout=kill, ASV benchmarks. Added path traversal tests for
all three tools, overwrite behavior test, existing directory test,
directory move rejection test, non-existent source test, directory
destination rejection test, changeset integration tests for file-move
and dir-create, binary file encoding test, permissions format test,
and destination parent directory creation test.

Review fixes applied (PR #1070 review):
| # | Finding | Resolution |
|---|---------|------------|
| P1-1 | _file_hash crashes on directories | Added p.is_dir() guard |
| P2-1 | file-move incorrect dest for dir targets | Reject directory destinations |
| P2-2 | Changeset move detection via input fields | Use tool_spec.name instead |
| P2-3 | st_ctime mislabeled as "created" on Linux | Use st_birthtime with fallback |
| P2-4 | _detect_operation returns "modify" for no-op | Handle created=False + null hashes |
| P2-5 | permissions includes file type bits | Use stat.S_IMODE() |
| P2-6 | _detect_encoding reports utf-8 for non-UTF-8 | Validate with chunk.decode("utf-8") |
| P3-1 | dir-create FileExistsError on regular file | Explicit pre-check with ValueError |
| P3-2 | Misleading error for non-existent source | Separate exists/is_file checks |

ISSUES CLOSED: #883
2026-04-02 08:11:22 +00:00
11 changed files with 840 additions and 13 deletions
+16
View File
@@ -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
+109
View File
@@ -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},
)
+2 -2
View File
@@ -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"
+172
View File
@@ -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"
@@ -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())}"
)
+5 -2
View File
@@ -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 ----
+33
View File
@@ -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
+116
View File
@@ -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)
+7 -1
View File
@@ -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",
+24 -8
View File
@@ -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"
@@ -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,
]