Skip to content

feat: implement built-in file system tools (#18)#151

Merged
Aureliolo merged 4 commits intomainfrom
feat/file-system-tools
Mar 7, 2026
Merged

feat: implement built-in file system tools (#18)#151
Aureliolo merged 4 commits intomainfrom
feat/file-system-tools

Conversation

@Aureliolo
Copy link
Copy Markdown
Owner

Summary

  • PathValidator: Workspace-scoped path sandboxing with symlink resolution and containment checks
  • ReadFileTool: Bounded reads (1 MB cap), optional line-range selection, binary file detection
  • WriteFileTool: Create/overwrite files with optional parent directory creation, 10 MB size guard
  • EditFileTool: Search-and-replace with 1 MB size guard, noop detection, multi-occurrence warning
  • DeleteFileTool: TOCTOU-safe deletion with require_elevated flag for future permission system
  • ListDirectoryTool: Glob filtering, recursive listing, 1000-entry truncation, symlink annotation
  • BaseFileSystemTool: Shared ABC with _map_os_error helper for consistent error mapping
  • Observability: 5 new event constants (TOOL_FS_STAT_FAILED, TOOL_FS_WORKSPACE_INVALID, TOOL_FS_PARENT_NOT_FOUND, TOOL_FS_GLOB_REJECTED, TOOL_FS_NOOP)
  • Cross-platform: Explicit is_dir() checks for Windows compatibility (Windows raises PermissionError instead of IsADirectoryError)
  • Security: Unsafe glob pattern rejection to prevent workspace escape via .. traversal
  • Docs: Updated DESIGN_SPEC.md §15.3 (project structure) and §15.5 (tool sandboxing status), CLAUDE.md tools/ description

Test plan

  • 44 unit tests across 6 test files covering all tools + path validator
  • Path traversal rejection (relative ../ and absolute paths)
  • Symlink escape detection (symlinks pointing outside workspace)
  • Large file handling (bounded reads, size guards, truncation)
  • Binary file detection (UnicodeDecodeError handling)
  • Directory error handling (cross-platform: Windows PermissionError)
  • Glob pattern security (unsafe .. patterns rejected)
  • Edge cases: empty directories, noop edits, permission errors, OS errors
  • Full suite: 2124 passed, 3 skipped (symlink tests on Windows outside CI)
  • Coverage: 94.86% (well above 80% minimum)

Review coverage

Pre-reviewed by 10 agents, 28 findings consolidated and addressed:

  • code-reviewer, python-reviewer, pr-test-analyzer, silent-failure-hunter
  • comment-analyzer, type-design-analyzer, logging-audit, resilience-audit
  • security-reviewer, docs-consistency

Polish pass by code-simplifier agent (extracted shared _map_os_error, simplified return types, restructured try blocks for consistency).

Closes #18

Aureliolo and others added 2 commits March 7, 2026 08:56
Add 5 file system tools (read_file, write_file, edit_file,
list_directory, delete_file) as a tools/file_system/ subpackage.

- PathValidator enforces workspace-scoped path safety (symlink-aware)
- BaseFileSystemTool sets FILE_SYSTEM category + shared validator
- All tools use asyncio.to_thread for non-blocking file I/O
- ReadFileTool: line-range selection, 1MB size guard, binary detection
- WriteFileTool: create/overwrite with optional parent dir creation
- EditFileTool: first-occurrence search-and-replace with LLM context
- ListDirectoryTool: glob filtering, recursive listing, 1000-entry cap
- DeleteFileTool: files only, require_elevated=True for permission gating
- 10 structured observability event constants (TOOL_FS_*)
- 59 unit tests covering happy paths, error handling, and security

Closes #18

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Pre-reviewed by 10 agents, 28 findings addressed

- Add PathValidator for workspace-scoped path sandboxing with symlink resolution
- Implement ReadFileTool with bounded reads, line-range selection, binary detection
- Implement WriteFileTool with parent directory creation, size guard (10 MB)
- Implement EditFileTool with search-and-replace, size guard (1 MB), noop detection
- Implement DeleteFileTool with TOCTOU-safe deletion, elevated permission flag
- Implement ListDirectoryTool with glob filtering, recursive listing, truncation
- Add BaseFileSystemTool ABC with shared _map_os_error helper
- Add cross-platform is_dir() checks for Windows compatibility
- Add unsafe glob pattern rejection to prevent workspace escape
- Add 5 new observability event constants for filesystem operations
- Update DESIGN_SPEC.md §15.3 project structure and §15.5 tool sandboxing
- Update CLAUDE.md tools/ description to include built-in tools
- Comprehensive test coverage: 44 tests across 6 test files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 7, 2026 08:33
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 7, 2026

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 7, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 69fdd733-ce0c-441c-9f98-0cec2636d842

📥 Commits

Reviewing files that changed from the base of the PR and between ffffdb6 and 485d5ff.

📒 Files selected for processing (15)
  • CLAUDE.md
  • DESIGN_SPEC.md
  • src/ai_company/tools/__init__.py
  • src/ai_company/tools/file_system/_base_fs_tool.py
  • src/ai_company/tools/file_system/_path_validator.py
  • src/ai_company/tools/file_system/edit_file.py
  • src/ai_company/tools/file_system/list_directory.py
  • src/ai_company/tools/file_system/read_file.py
  • src/ai_company/tools/file_system/write_file.py
  • tests/unit/tools/file_system/test_delete_file.py
  • tests/unit/tools/file_system/test_edit_file.py
  • tests/unit/tools/file_system/test_list_directory.py
  • tests/unit/tools/file_system/test_path_validator.py
  • tests/unit/tools/file_system/test_read_file.py
  • tests/unit/tools/file_system/test_write_file.py

📝 Walkthrough

Summary by CodeRabbit

Release Notes

  • New Features

    • Added file system tools: read, write, edit, delete files, and list directories with workspace-scoped access control
    • Implemented path validation to prevent accessing files outside designated workspace
    • Added file size limits and safety guards for file operations
  • Documentation

    • Updated architecture documentation reflecting new file system tool organization
    • Documented partial implementation of tool sandboxing via path validation
  • Tests

    • Added comprehensive test coverage for file system tools and path validation

Walkthrough

Adds a new ai_company.tools.file_system package with a BaseFileSystemTool and PathValidator, implements five file-system tools (read, write, edit, list, delete) with workspace-bound path validation and observability events, and includes comprehensive unit tests and test fixtures. Documentation and package exports updated.

Changes

Cohort / File(s) Summary
Documentation & Design
CLAUDE.md, DESIGN_SPEC.md
Updated docs and design spec to reflect new file_system package layout, re-export changes, MCP bridge addition, and sandboxing status update.
Observability Events
src/ai_company/observability/events/tool.py
Added multiple TOOL_FS_* event constants (read/write/edit/list/delete, path violations, binary/size errors, etc.).
Public API / Exports
src/ai_company/tools/__init__.py, src/ai_company/tools/file_system/__init__.py
Re-exported file-system tool classes and PathValidator; removed single-file legacy export in favor of package exports.
Core Abstractions
src/ai_company/tools/file_system/_base_fs_tool.py, src/ai_company/tools/file_system/_path_validator.py
Added BaseFileSystemTool (abstract) and PathValidator that enforce workspace-scoped path resolution and map OS errors to user-facing messages and log keys.
File-system Tools
src/ai_company/tools/file_system/read_file.py, .../write_file.py, .../edit_file.py, .../list_directory.py, .../delete_file.py
Implemented ReadFileTool, WriteFileTool, EditFileTool, ListDirectoryTool, DeleteFileTool with size limits, atomic helpers, async-to-thread execution, detailed error handling, and observability logging.
Tests & Fixtures
tests/unit/tools/file_system/conftest.py, tests/unit/tools/file_system/test_*.py
Added fixtures and extensive unit tests covering normal flows, edge cases (large files, binary files, symlinks), permission and path-traversal protections, and metadata assertions.
Misc
manifest_file, requirements.txt, pyproject.toml (test/manifest edits)
Updated test/manifest files referenced by tests (small metadata/config changes).

Sequence Diagram(s)

sequenceDiagram
    participant Agent as Agent/User
    participant Tool as FileSystem Tool
    participant Validator as PathValidator
    participant Workspace as Workspace (root)
    participant FS as Filesystem
    participant Logger as Observability

    Agent->>Tool: execute(arguments)
    activate Tool

    Tool->>Validator: validate(user_path)
    activate Validator
    Validator->>Workspace: resolve & ensure containment
    alt Path valid
        Validator-->>Tool: resolved_path
    else Path escapes or parent missing
        Validator->>Logger: TOOL_FS_PATH_VIOLATION / TOOL_FS_PARENT_NOT_FOUND
        Validator-->>Tool: raise ValueError
    end
    deactivate Validator

    alt Validation failed
        Tool->>Logger: log TOOL_FS_ERROR
        Tool-->>Agent: ToolExecutionResult(error)
    else Validation succeeded
        Tool->>FS: perform operation (read/write/edit/list/delete)
        activate FS
        alt Success
            FS-->>Tool: result (content/metadata)
            Tool->>Logger: log TOOL_FS_* event with metadata
            Tool-->>Agent: ToolExecutionResult(success, metadata)
        else OS error / binary / size issue
            FS-->>Tool: OSError / UnicodeDecodeError
            Tool->>Logger: log TOOL_FS_ERROR / TOOL_FS_SIZE_EXCEEDED / TOOL_FS_BINARY_DETECTED
            Tool-->>Agent: ToolExecutionResult(error, message)
        end
        deactivate FS
    end
    deactivate Tool
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'feat: implement built-in file system tools (#18)' clearly and accurately summarizes the main change—implementing a complete set of filesystem tools as a new feature. It is concise, specific, and directly related to the primary objective of the changeset.
Description check ✅ Passed The PR description comprehensively relates to the changeset, detailing all major components (PathValidator, tool implementations, observability, cross-platform support) and test coverage. It provides context on the changes and validates alignment with the objectives.
Linked Issues check ✅ Passed All acceptance criteria from issue #18 are met: read_file, write_file, edit_file, list_directory, delete_file tools are implemented; tool interface is integrated; path validation and workspace boundary protection prevent escape attacks; comprehensive error handling is implemented; 44 unit tests cover normal and edge cases (empty files, large files, binary files, symlinks); design spec §11.1 requirements are satisfied.
Out of Scope Changes check ✅ Passed All code changes directly support the linked issue #18 objectives: file system tools, path validation, error handling, observability, documentation updates, and comprehensive testing. No unrelated functionality or scope creep is present in the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/file-system-tools
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch feat/file-system-tools

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist
Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly expands the agent's capabilities by integrating a robust set of built-in file system tools. These tools enable secure and controlled interactions with the workspace, providing functionalities like reading, writing, editing, listing, and deleting files. The implementation prioritizes security through strict path validation, ensures cross-platform compatibility, and includes comprehensive observability features to monitor file operations.

Highlights

  • New File System Tools: Introduced a comprehensive suite of built-in file system tools: PathValidator, ReadFileTool, WriteFileTool, EditFileTool, DeleteFileTool, and ListDirectoryTool.
  • Secure Path Validation: Implemented workspace-scoped path sandboxing using PathValidator, which includes symlink resolution and containment checks to prevent path traversal attacks.
  • Bounded File Operations: ReadFileTool supports bounded reads (1 MB cap) and optional line-range selection, while WriteFileTool and EditFileTool include size guards (10 MB and 1 MB respectively) to prevent excessive resource usage.
  • Robust Error Handling & Observability: Added specific event constants for file system operations and implemented cross-platform is_dir() checks for Windows compatibility, ensuring consistent error mapping and better logging.
  • Enhanced File Editing & Listing: EditFileTool provides search-and-replace functionality with noop detection and multi-occurrence warnings, and ListDirectoryTool offers glob filtering, recursive listing, and symlink annotation with a 1000-entry truncation limit.
  • Documentation & Testing: Updated DESIGN_SPEC.md and CLAUDE.md to reflect the new structure and sandboxing status, and added 44 unit tests across 6 files, achieving 94.86% code coverage.
Changelog
  • CLAUDE.md
    • Updated the project structure description to include the new file_system/ directory under tools/.
  • DESIGN_SPEC.md
    • Expanded the project structure section to detail the new file_system/ directory and its constituent files.
    • Revised the 'Tool sandboxing' section to reflect the partial implementation of sandboxing for file system tools using PathValidator.
  • src/ai_company/observability/events/tool.py
    • Added new event constants for file system tool operations, such as TOOL_FS_READ, TOOL_FS_WRITE, TOOL_FS_EDIT, TOOL_FS_DELETE, TOOL_FS_LIST, and various error-related events.
  • src/ai_company/tools/init.py
    • Updated imports to include the new file system tools and PathValidator from the file_system submodule.
    • Extended the __all__ export list to expose the new file system tools and PathValidator.
  • src/ai_company/tools/file_system/init.py
    • Added a new package initialization file to define the file_system module and export its core components.
  • src/ai_company/tools/file_system/_base_fs_tool.py
    • Added a new abstract base class, BaseFileSystemTool, providing common attributes and methods for all file system tools, including ToolCategory.FILE_SYSTEM and a shared PathValidator instance.
    • Implemented a helper function _map_os_error for consistent mapping of OS-level exceptions to user-friendly messages.
  • src/ai_company/tools/file_system/_path_validator.py
    • Added a new class, PathValidator, responsible for validating and resolving paths against a defined workspace root, preventing path traversal and symlink escapes.
  • src/ai_company/tools/file_system/delete_file.py
    • Added a new DeleteFileTool for securely deleting single files within the workspace, including checks for directories and handling of OS errors.
  • src/ai_company/tools/file_system/edit_file.py
    • Added a new EditFileTool for performing search-and-replace operations on file content, with safeguards for file size, binary content, and handling of multiple occurrences.
  • src/ai_company/tools/file_system/list_directory.py
    • Added a new ListDirectoryTool for listing directory contents, supporting glob patterns, recursive listing, truncation for large results, and annotation of symlinks.
  • src/ai_company/tools/file_system/read_file.py
    • Added a new ReadFileTool for reading file content, supporting optional line-range selection, bounded reads for large files, and detection of binary files.
  • src/ai_company/tools/file_system/write_file.py
    • Added a new WriteFileTool for creating or overwriting files, with options to create parent directories and size limits for content.
  • tests/unit/tools/file_system/conftest.py
    • Added a new pytest configuration file to provide shared fixtures for file system tool tests, including a temporary workspace and initialized tool instances.
  • tests/unit/tools/file_system/test_delete_file.py
    • Added new unit tests for DeleteFileTool, covering successful deletion, handling of non-existent files, directories, path traversal, empty files, and various OS errors.
  • tests/unit/tools/file_system/test_edit_file.py
    • Added new unit tests for EditFileTool, verifying text replacement, deletion, handling of text not found, multiple occurrences, identical text, file not found, path traversal, binary files, content preservation, directory errors, and large file rejection.
  • tests/unit/tools/file_system/test_list_directory.py
    • Added new unit tests for ListDirectoryTool, covering listing root and subdirectories, glob filtering, recursive listing, empty directories, non-directory paths, path traversal, metadata counts, file size display, truncation, unsafe glob patterns, and symlink handling.
  • tests/unit/tools/file_system/test_path_validator.py
    • Added new unit tests for PathValidator, validating its initialization, path resolution, containment checks, parent existence validation, and correct handling of symlinks.
  • tests/unit/tools/file_system/test_read_file.py
    • Added new unit tests for ReadFileTool, covering reading full files, empty files, nested files, line ranges, handling of non-existent files, path traversal, directory errors, binary files, and large file truncation.
  • tests/unit/tools/file_system/test_write_file.py
    • Added new unit tests for WriteFileTool, verifying file creation, overwriting, handling of missing parent directories, creating directories, path traversal, empty content, metadata, writing to directories, and rejection of overly large content.
Activity
  • The pull request underwent pre-review by 10 agents, resulting in 28 findings that were consolidated and addressed.
  • A code-simplifier agent performed a polish pass, leading to the extraction of a shared _map_os_error function, simplification of return types, and restructuring of try blocks for improved consistency.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a comprehensive and well-designed set of built-in file system tools. While the implementation includes robust security controls like PathValidator and generally enforces size limits, a potential Denial of Service (DoS) vulnerability was identified in ReadFileTool. Specifically, the 1 MB file size limit is bypassed when line ranges are requested, which could lead to memory exhaustion if the tool is used to read very large files. Additionally, I have a couple of suggestions to enhance structured logging and improve memory efficiency for large file reads.

Comment on lines +44 to +49
if start is not None or end is not None:
raw = resolved.read_text(encoding="utf-8")
lines = raw.splitlines(keepends=True)
s = (start - 1) if start is not None else 0
e = end if end is not None else len(lines)
return "".join(lines[s:e])
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation for reading a file with a line range (start_line/end_line) loads the entire file into memory using resolved.read_text(). This can lead to excessive memory consumption and a potential MemoryError if an agent requests a line range from a very large file (e.g., >1GB), even if the range itself is small. This appears to be an intentional design choice, but it presents a potential denial-of-service risk. For improved robustness, consider a more memory-efficient implementation that reads the file line-by-line, for example, using itertools.islice on the file handle.

Comment on lines +160 to +162
# When oversized and no line range, read only MAX_FILE_SIZE_BYTES
# to avoid loading the whole file into memory.
max_bytes = MAX_FILE_SIZE_BYTES if oversized and not has_line_range else None
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The ReadFileTool implements a 1 MB size limit (MAX_FILE_SIZE_BYTES) to prevent memory exhaustion. However, this limit is bypassed when a line range (start_line or end_line) is specified. In this case, the tool reads the entire file into memory using read_text() before performing line slicing. If a very large file is present in the workspace, an attacker could trigger a Denial of Service (DoS) by requesting a line range from it, leading to memory exhaustion. Additionally, the use of splitlines() on the full file content further increases memory consumption.

        # Enforce size limit even with line ranges to prevent DoS
        if oversized and has_line_range:
            return ToolExecutionResult(
                content=(
                    f"File too large to read with line range: {user_path} "
                    f"({size_bytes:,} bytes, max {MAX_FILE_SIZE_BYTES:,})"
                ),
                is_error=True,
            )

        # When oversized and no line range, read only MAX_FILE_SIZE_BYTES
        # to avoid loading the whole file into memory.
        max_bytes = MAX_FILE_SIZE_BYTES if oversized and not has_line_range else None

return "is_directory", f"Path is a directory, not a file: {user_path}"
if isinstance(exc, PermissionError):
return "permission_denied", f"Permission denied: {user_path}"
return str(exc), f"OS error {verb} file: {user_path}"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The fallback case in _map_os_error uses str(exc) as the log_key. This is not ideal for structured logging, as exception messages can be variable and are not suitable for aggregation. Using the exception's class name would provide a more consistent key for log analysis. Additionally, the user-facing message could include the exception details to be more informative.

Suggested change
return str(exc), f"OS error {verb} file: {user_path}"
return exc.__class__.__name__, f"OS error {verb} file '{user_path}': {exc}"

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Mar 7, 2026

Greptile Summary

This PR implements the full suite of built-in file system tools (ReadFileTool, WriteFileTool, EditFileTool, DeleteFileTool, ListDirectoryTool) backed by a shared BaseFileSystemTool ABC and a PathValidator that enforces workspace-scoped sandboxing via symlink resolution and containment checks. The implementation is generally high quality — atomic writes, size guards, cross-platform directory detection, and 44 unit tests at 94.86% coverage — with two logic issues in ListDirectoryTool that must be addressed before merge.

  • _UNSAFE_GLOB_RE misses trailing .. without separator (list_directory.py:27): the pattern subdir/.. is not matched because the regex requires a / or \ after ... On Python 3.12+, pathlib raises ValueError for .. in glob patterns, which is not caught by the OSError handler in execute() and would escape as an unhandled exception.
  • recursive=False does not block ** patterns (list_directory.py:54–60): passing pattern="**/*.py" with recursive=False still triggers a full recursive walk via Path.glob("**/*.py"), silently ignoring the caller's intent.

The ReadFileTool, WriteFileTool, EditFileTool, and DeleteFileTool implementations are solid with correct handling of line-range reads (now rejected for oversized files), empty-text edge cases (schema constraint + runtime guard), atomic writes, and size guards.

Confidence Score: 3/5

  • Safe to merge after fixing the two ListDirectoryTool logic issues.
  • The implementation is high quality overall with atomic writes, comprehensive error handling, cross-platform compatibility, and 94.86% test coverage. However, two logic-level bugs in ListDirectoryTool must be resolved: (1) the unsafe-glob regex gap that can allow Python 3.12+ to raise an unhandled ValueError, and (2) the silent ignorance of recursive=False when ** patterns are used. These are confined to a single file but are behavioral defects that affect correctness. The other five file-system tools are solid.
  • src/ai_company/tools/file_system/list_directory.py (fix unsafe-glob regex and recursive=False validation)

Last reviewed commit: 485d5ff

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements a first-class, workspace-scoped file system tool suite under ai_company.tools.file_system, including path sandboxing, consistent observability events, and a comprehensive unit test suite to support built-in read/write/edit/list/delete operations.

Changes:

  • Added PathValidator + BaseFileSystemTool to enforce workspace containment (incl. symlink resolution) across file system tools.
  • Implemented ReadFileTool, WriteFileTool, EditFileTool, ListDirectoryTool, and DeleteFileTool with size guards and structured observability events.
  • Added unit tests + updated docs/specs to reflect the new tool package and sandboxing status.

Reviewed changes

Copilot reviewed 19 out of 20 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/ai_company/tools/file_system/_base_fs_tool.py Introduces shared FS tool base + _map_os_error helper.
src/ai_company/tools/file_system/_path_validator.py Adds workspace-scoped path validation with symlink containment checks.
src/ai_company/tools/file_system/read_file.py Adds bounded file reads with optional line ranges + binary detection.
src/ai_company/tools/file_system/write_file.py Adds guarded writes with optional directory creation + size limit.
src/ai_company/tools/file_system/edit_file.py Adds first-occurrence search/replace with size guard and noop handling.
src/ai_company/tools/file_system/list_directory.py Adds listing with glob filtering, recursion, truncation, and symlink annotation.
src/ai_company/tools/file_system/delete_file.py Adds single-file deletion with elevated intent and TOCTOU-aware flow.
src/ai_company/tools/file_system/__init__.py Exposes file system tool public API via package exports.
src/ai_company/tools/__init__.py Re-exports file system tools at the top-level ai_company.tools package.
src/ai_company/observability/events/tool.py Adds file-system-related event constants.
tests/unit/tools/file_system/conftest.py Adds shared workspace + tool fixtures for FS tool tests.
tests/unit/tools/file_system/test_read_file.py Unit tests for read behavior, truncation, binary, and line ranges.
tests/unit/tools/file_system/test_write_file.py Unit tests for create/overwrite, parent handling, and size guard.
tests/unit/tools/file_system/test_edit_file.py Unit tests for replace/noop/not-found, binary, and size guard.
tests/unit/tools/file_system/test_list_directory.py Unit tests for listing modes, glob safety, recursion, truncation, symlinks.
tests/unit/tools/file_system/test_delete_file.py Unit tests for delete success/failure modes and error mapping.
DESIGN_SPEC.md Updates project structure + tool sandboxing status for FS tools.
CLAUDE.md Updates tools directory description to mention built-in FS tools.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +183 to +191
if oversized and not has_line_range:
content += (
f"\n\n[Truncated: file is {size_bytes:,} bytes, "
f"showing first {MAX_FILE_SIZE_BYTES:,}]"
)

line_count = content.count("\n") + (
1 if content and not content.endswith("\n") else 0
)
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line_count is computed after appending the truncation notice, so the metadata/logged line_count includes the extra notice lines rather than reflecting just the file content (or the returned slice). This can make observability and consumers relying on line_count misleading. Consider computing line_count before appending the truncation marker, or tracking separate fields (e.g., file_line_count vs returned_line_count).

Copilot uses AI. Check for mistakes.
Comment on lines +37 to +63
glob_pattern = pattern or "*"
if recursive:
raw_iter = resolved.rglob(glob_pattern)
elif pattern:
raw_iter = resolved.glob(glob_pattern)
else:
raw_iter = resolved.iterdir()

# Cap at MAX_ENTRIES + 1 to detect truncation without
# materialising the entire iterator.
entries = sorted(itertools.islice(raw_iter, MAX_ENTRIES + 1))

lines: list[str] = []
for entry in entries:
try:
display = str(entry.relative_to(resolved)) if recursive else entry.name

if entry.is_symlink():
target = entry.resolve()
if not target.is_relative_to(workspace_root):
lines.append(f"[SYMLINK] {display} -> (outside workspace)")
continue

if entry.is_dir():
lines.append(f"[DIR] {display}/")
else:
try:
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Symlink containment is only checked when the entry itself is a symlink. If the workspace contains a symlink directory pointing outside the workspace, glob() / rglob() can yield children under that symlink that are not symlinks themselves, and those entries will be treated as normal files/dirs (potentially listing outside-workspace content). Harden this by validating entry.resolve() is within workspace_root for every entry (and/or avoid descending into symlinked directories by using a custom walk with follow_symlinks=False).

Copilot uses AI. Check for mistakes.
Comment on lines +11 to +20
from ai_company.observability import get_logger
from ai_company.tools.base import BaseTool
from ai_company.tools.file_system._path_validator import PathValidator

if TYPE_CHECKING:
from pathlib import Path

logger = get_logger(__name__)


Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_base_fs_tool.py defines logger = get_logger(__name__) but never uses it. This will trip linting (unused import/variable). Remove the logger (and get_logger import) or use it for shared logging in this module.

Suggested change
from ai_company.observability import get_logger
from ai_company.tools.base import BaseTool
from ai_company.tools.file_system._path_validator import PathValidator
if TYPE_CHECKING:
from pathlib import Path
logger = get_logger(__name__)
from ai_company.tools.base import BaseTool
from ai_company.tools.file_system._path_validator import PathValidator
if TYPE_CHECKING:
from pathlib import Path

Copilot uses AI. Check for mistakes.
Comment on lines +85 to +95
"old_text": {
"type": "string",
"description": "Exact text to find",
},
"new_text": {
"type": "string",
"description": "Replacement text (empty string to delete)",
},
},
"required": ["path", "old_text", "new_text"],
"additionalProperties": False,
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

old_text is allowed to be an empty string (no runtime check and no minLength in the JSON schema). In that case content.count(old_text) becomes len(content)+1 and the replace will insert new_text at the start, which is almost certainly unintended and can massively rewrite files. Add validation that old_text is non-empty (and ideally enforce minLength: 1 in the parameters schema).

Copilot uses AI. Check for mistakes.
Comment on lines +44 to +50
if start is not None or end is not None:
raw = resolved.read_text(encoding="utf-8")
lines = raw.splitlines(keepends=True)
s = (start - 1) if start is not None else 0
e = end if end is not None else len(lines)
return "".join(lines[s:e])

Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When start_line/end_line is provided, _read_sync() reads the entire file into memory (resolved.read_text() + splitlines()), bypassing the size guard. For large files this can be a big memory/time spike even if the caller only needs a few lines. Consider streaming lines (e.g., iterate over the file and islice the requested range) so partial reads remain bounded.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/ai_company/tools/file_system/_path_validator.py`:
- Around line 60-86: The validate method in _path_validator.py currently allows
absolute inputs because (self._workspace_root / path) ignores the root when path
is absolute; update validate (the validate(self, path: str) function) to first
check whether the incoming path string is absolute (e.g.,
Path(path).is_absolute()) and immediately treat absolute inputs as violations:
log TOOL_FS_PATH_VIOLATION with user_path and raise ValueError("Absolute paths
not allowed" or similar) before attempting to resolve; only join and resolve
when the input is relative and then keep the existing containment check using
is_relative_to(self._workspace_root).

In `@src/ai_company/tools/file_system/delete_file.py`:
- Around line 71-79: The dispatcher currently ignores the delete_file Tool's
require_elevated flag; update the permission check in ToolInvoker (the
permission validation block that inspects tool.name and tool.category) to also
reject tools with tool.require_elevated=True unless the invoking agent has an
elevated/explicit-approval capability (e.g., an AgentPermission like "elevated"
or "can_execute_elevated_tools"); modify the permission logic in
ToolInvoker.check/authorize (or the method around lines 116–141) to consult
tool.require_elevated and the agent's permission set and return a
permission-denied result when the agent lacks elevated rights, or alternatively
add a clear comment and documentation stub if you choose to defer enforcement.

In `@src/ai_company/tools/file_system/edit_file.py`:
- Around line 78-96: The parameters_schema currently allows an empty "old_text",
which leads to destructive edits in _edit_sync; update validation to reject
empty search strings by adding "minLength": 1 to the "old_text" schema property
(and likewise to the similar schema referenced around the other block mentioned)
and also add a defensive check at the start of _edit_sync (and its async
counterpart if present) to raise/return a validation error when old_text == ""
to ensure system-boundary validation.
- Around line 28-39: _edit_sync currently writes directly to the target file
which can leave it corrupted on partial failure; change it to write the updated
content to a same-directory temporary file, flush and fsync the temp file, copy
the original file's permissions to the temp (use resolved.stat().st_mode and
os.chmod), then atomically replace the original with Path.replace(temp_path,
resolved) (or temp_path.replace(resolved)) and ensure the temp is removed on
error; keep the existing behavior of counting occurrences and replacing only one
occurrence with content.replace(old_text, new_text, 1), and return the original
count.
- Around line 189-194: The logger call in the edit_file error branch currently
emits the searched text (old_text_preview=old_text[:100]) at INFO and must not
leak content; change the logger invocation in the branch where count == 0 to use
logger.warning (or logger.error), remove the raw text preview, and instead log
non-sensitive metadata such as the length of old_text (e.g.,
old_text_len=len(old_text)) or a stable hash of old_text, while retaining
path=user_path and other context before returning is_error=True/raising; update
the message constant TOOL_FS_EDIT_NOT_FOUND usage accordingly in the surrounding
function (the block referencing count, user_path, old_text, and logger).

In `@src/ai_company/tools/file_system/list_directory.py`:
- Around line 166-176: The current code treats any non-directory the same,
collapsing missing paths and existing files into "Not a directory"; change the
branching after calling path_validator.validate(user_path) so you first check
whether the resolved path exists (e.g., resolved.exists()) and if it does not,
log with TOOL_FS_ERROR and error="not_found" and return a ToolExecutionResult
indicating the path was not found; otherwise (if it exists but not
resolved.is_dir()) keep the logger.warning with error="not_a_directory" and
return the existing "Not a directory" ToolExecutionResult. Ensure you use the
same symbols: path_validator.validate, resolved.exists()/resolved.is_dir(),
logger.warning, TOOL_FS_ERROR, and ToolExecutionResult.
- Around line 155-185: The code currently only rejects ".." traversal via
_UNSAFE_GLOB_RE but doesn't reject absolute/non-relative glob patterns (e.g.,
"/tmp/*"), which cause pathlib.Path.glob()/rglob() to raise NotImplementedError;
fix by validating the incoming pattern is relative before calling _list_sync: if
pattern is set and pathlib.Path(pattern).is_absolute() (or otherwise not a
relative path), log TOOL_FS_GLOB_REJECTED with pattern and return a
ToolExecutionResult with an "Unsafe glob pattern rejected" error message; add
this check alongside the existing _UNSAFE_GLOB_RE check (affecting the same
block that references pattern, _UNSAFE_GLOB_RE, TOOL_FS_GLOB_REJECTED, and the
call site _list_sync) so absolute patterns are rejected early and
NotImplementedError cannot propagate.

In `@src/ai_company/tools/file_system/read_file.py`:
- Around line 31-55: _read_sync currently bypasses the byte cap: when max_bytes
is set it uses fh.read(max_bytes) on a text stream (reads characters, not bytes)
and when start/end are provided it calls resolved.read_text() which materialises
the whole file; fix by performing byte-bounded reads in binary mode and decoding
safely to UTF-8 without breaking multibyte sequences (e.g., use a binary open +
incremental decoder or decode up to the last valid boundary) and for ranged
reads stream bytes line-by-line in binary until the requested start/end lines
are collected (stop once max_bytes is reached) instead of calling
resolved.read_text(); apply the same binary-byte-bounded approach to the
analogous code path elsewhere in this module.

In `@src/ai_company/tools/file_system/write_file.py`:
- Around line 23-34: The _write_sync function currently uses Path.write_text
which can truncate the target in-place; instead, write the content to a
temporary file in the same directory (use a unique temp name), ensure the parent
dir exists (respecting create_dirs), open the temp file and write bytes, call
os.fsync on the file descriptor to flush data, close it, then atomically replace
the destination with pathlib.Path.replace(temp_path) and finally fsync the
containing directory if possible; preserve the returned tuple of (bytes_written,
created) and reference _write_sync, Path.replace, and create_dirs when making
the change.

In `@tests/unit/tools/file_system/test_edit_file.py`:
- Around line 13-15: The TestEditFileExecution async unit tests are missing the
30-second timeout marker; add the pytest timeout marker to the test class
declaration so the class is decorated with pytest.mark.timeout(30) (i.e., apply
the marker to TestEditFileExecution) to enforce a 30-second per-test timeout and
prevent hung filesystem calls from stalling CI.

In `@tests/unit/tools/file_system/test_list_directory.py`:
- Around line 20-22: Add the missing 30-second timeout marker to the async
unit-test classes by decorating both TestListDirectoryExecution and the other
test class at lines 132-134 with `@pytest.mark.timeout`(30); ensure pytest is
imported and place the decorator directly above each class definition so each
test inherits the repository’s 30-second timeout guard.

In `@tests/unit/tools/file_system/test_path_validator.py`:
- Around line 29-33: The test uses Path.write_text without an explicit encoding
in test_file_as_workspace_raises (creating f and calling f.write_text("x")),
which is inconsistent with other tests; update the write_text call(s) in this
file (including the occurrences in test_file_as_workspace_raises and the other
tests that create tmp files) to pass encoding="utf-8" so all write_text
invocations are consistent; locate usages around the PathValidator-related tests
and change write_text(...) to write_text(..., encoding="utf-8").

In `@tests/unit/tools/file_system/test_read_file.py`:
- Around line 66-76: The test test_read_start_line_only is too loose—change its
assertions to verify the exact returned slice from ReadFileTool instead of using
"in"; after calling read_tool.execute with arguments {"path": "multi.txt",
"start_line": 2}, assert result.content equals the expected string (only the
lines starting at line 2, e.g. "b\nc\n") and that result.is_error is False.
Apply the same tightening to the other similar test referenced (lines 120-132)
so it asserts equality against the exact five-line slice rather than membership
checks.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9032b086-bef6-4185-9848-2cf65fd4abe6

📥 Commits

Reviewing files that changed from the base of the PR and between 833c190 and ffffdb6.

📒 Files selected for processing (20)
  • CLAUDE.md
  • DESIGN_SPEC.md
  • src/ai_company/observability/events/tool.py
  • src/ai_company/tools/__init__.py
  • src/ai_company/tools/file_system/__init__.py
  • src/ai_company/tools/file_system/_base_fs_tool.py
  • src/ai_company/tools/file_system/_path_validator.py
  • src/ai_company/tools/file_system/delete_file.py
  • src/ai_company/tools/file_system/edit_file.py
  • src/ai_company/tools/file_system/list_directory.py
  • src/ai_company/tools/file_system/read_file.py
  • src/ai_company/tools/file_system/write_file.py
  • tests/unit/tools/file_system/__init__.py
  • tests/unit/tools/file_system/conftest.py
  • tests/unit/tools/file_system/test_delete_file.py
  • tests/unit/tools/file_system/test_edit_file.py
  • tests/unit/tools/file_system/test_list_directory.py
  • tests/unit/tools/file_system/test_path_validator.py
  • tests/unit/tools/file_system/test_read_file.py
  • tests/unit/tools/file_system/test_write_file.py
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Agent
  • GitHub Check: Greptile Review
🧰 Additional context used
📓 Path-based instructions (4)
**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.py: Use Python 3.14+ with PEP 649 native lazy annotations
Do NOT use from __future__ import annotations—Python 3.14 has PEP 649
Use except A, B: syntax (no parentheses) for exception handling on Python 3.14—ruff enforces this
Add type hints to all public functions in Python; mypy strict mode is enforced
Use Google-style docstrings on all public classes and functions—ruff D rules enforce this
Create new objects instead of mutating existing ones; use copy.deepcopy() at construction for non-Pydantic internal collections and MappingProxyType wrapping for read-only enforcement
Use frozen Pydantic models for config/identity; use separate mutable-via-copy models (using model_copy(update=...)) for runtime state that evolves. Never mix static config fields with mutable runtime fields in one model.
Use Pydantic v2 with BaseModel, model_validator, computed_field, and ConfigDict
Use @computed_field for derived values instead of storing + validating redundant fields (e.g. TokenUsage.total_tokens)
Use NotBlankStr (from core.types) for all identifier/name fields—including optional (NotBlankStr | None) and tuple (tuple[NotBlankStr, ...]) variants—instead of manual whitespace validators
Prefer asyncio.TaskGroup for fan-out/fan-in parallel operations in new code (e.g. multiple tool invocations, parallel agent calls); prefer structured concurrency over bare create_task
Enforce line length of 88 characters (ruff enforces this)
Functions should be less than 50 lines, files less than 800 lines
Handle errors explicitly; never silently swallow errors in Python code
Validate at system boundaries (user input, external APIs, config files)

Files:

  • src/ai_company/observability/events/tool.py
  • tests/unit/tools/file_system/test_path_validator.py
  • tests/unit/tools/file_system/test_delete_file.py
  • src/ai_company/tools/file_system/write_file.py
  • tests/unit/tools/file_system/test_edit_file.py
  • tests/unit/tools/file_system/test_write_file.py
  • tests/unit/tools/file_system/conftest.py
  • src/ai_company/tools/file_system/_base_fs_tool.py
  • src/ai_company/tools/file_system/delete_file.py
  • src/ai_company/tools/file_system/__init__.py
  • src/ai_company/tools/file_system/_path_validator.py
  • tests/unit/tools/file_system/test_list_directory.py
  • src/ai_company/tools/file_system/read_file.py
  • src/ai_company/tools/file_system/list_directory.py
  • src/ai_company/tools/__init__.py
  • tests/unit/tools/file_system/test_read_file.py
  • src/ai_company/tools/file_system/edit_file.py
src/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

src/**/*.py: Every module with business logic MUST import from ai_company.observability import get_logger then logger = get_logger(__name__)
Never use import logging, logging.getLogger(), or print() in application code
Always use logger as the variable name for loggers (not _logger, not log)
Use event name constants from domain-specific modules under ai_company.observability.events (e.g. PROVIDER_CALL_START from events.provider, BUDGET_RECORD_ADDED from events.budget). Import directly: from ai_company.observability.events.<domain> import EVENT_CONSTANT
Always use structured logging with logger.info(EVENT, key=value) format—never logger.info('msg %s', val)
All error paths must log at WARNING or ERROR with context before raising
All state transitions must log at INFO level
DEBUG level logging should be used for object creation, internal flow, entry/exit of key functions
Pure data models, enums, and re-exports do NOT need logging

Files:

  • src/ai_company/observability/events/tool.py
  • src/ai_company/tools/file_system/write_file.py
  • src/ai_company/tools/file_system/_base_fs_tool.py
  • src/ai_company/tools/file_system/delete_file.py
  • src/ai_company/tools/file_system/__init__.py
  • src/ai_company/tools/file_system/_path_validator.py
  • src/ai_company/tools/file_system/read_file.py
  • src/ai_company/tools/file_system/list_directory.py
  • src/ai_company/tools/__init__.py
  • src/ai_company/tools/file_system/edit_file.py
{src/**/*.py,tests/**/*.py,src/**/*.yaml,src/**/*.yml,tests/**/*.yaml,tests/**/*.yml,examples/**/*.yaml,examples/**/*.yml}

📄 CodeRabbit inference engine (CLAUDE.md)

NEVER use real vendor names (Anthropic, OpenAI, Claude, GPT, etc.) in project-owned code, docstrings, comments, tests, or config examples. Use generic names: example-provider, example-large-001, example-medium-001, example-small-001, large/medium/small as aliases. Vendor names may only appear in: (1) DESIGN_SPEC.md provider list, (2) .claude/ skill/agent files, (3) third-party import paths/module names (e.g. litellm.types.llms.openai). Tests must use test-provider, test-small-001, etc.

Files:

  • src/ai_company/observability/events/tool.py
  • tests/unit/tools/file_system/test_path_validator.py
  • tests/unit/tools/file_system/test_delete_file.py
  • src/ai_company/tools/file_system/write_file.py
  • tests/unit/tools/file_system/test_edit_file.py
  • tests/unit/tools/file_system/test_write_file.py
  • tests/unit/tools/file_system/conftest.py
  • src/ai_company/tools/file_system/_base_fs_tool.py
  • src/ai_company/tools/file_system/delete_file.py
  • src/ai_company/tools/file_system/__init__.py
  • src/ai_company/tools/file_system/_path_validator.py
  • tests/unit/tools/file_system/test_list_directory.py
  • src/ai_company/tools/file_system/read_file.py
  • src/ai_company/tools/file_system/list_directory.py
  • src/ai_company/tools/__init__.py
  • tests/unit/tools/file_system/test_read_file.py
  • src/ai_company/tools/file_system/edit_file.py
tests/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

tests/**/*.py: Mark unit tests with @pytest.mark.unit, integration tests with @pytest.mark.integration, e2e tests with @pytest.mark.e2e, and slow tests with @pytest.mark.slow
Use asyncio_mode = 'auto' for pytest async tests—no manual @pytest.mark.asyncio needed
Set a 30-second timeout per test

Files:

  • tests/unit/tools/file_system/test_path_validator.py
  • tests/unit/tools/file_system/test_delete_file.py
  • tests/unit/tools/file_system/test_edit_file.py
  • tests/unit/tools/file_system/test_write_file.py
  • tests/unit/tools/file_system/conftest.py
  • tests/unit/tools/file_system/test_list_directory.py
  • tests/unit/tools/file_system/test_read_file.py
🧠 Learnings (1)
📚 Learning: 2026-03-06T21:51:55.175Z
Learnt from: CR
Repo: Aureliolo/ai-company PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-06T21:51:55.175Z
Learning: Applies to src/**/*.py : Use event name constants from domain-specific modules under `ai_company.observability.events` (e.g. `PROVIDER_CALL_START` from `events.provider`, `BUDGET_RECORD_ADDED` from `events.budget`). Import directly: `from ai_company.observability.events.<domain> import EVENT_CONSTANT`

Applied to files:

  • src/ai_company/observability/events/tool.py
🧬 Code graph analysis (13)
tests/unit/tools/file_system/test_path_validator.py (3)
src/ai_company/tools/file_system/_path_validator.py (4)
  • PathValidator (24-109)
  • workspace_root (56-58)
  • validate (60-86)
  • validate_parent_exists (88-109)
src/ai_company/tools/file_system/_base_fs_tool.py (1)
  • workspace_root (73-75)
tests/unit/tools/file_system/conftest.py (1)
  • workspace (18-25)
tests/unit/tools/file_system/test_delete_file.py (2)
src/ai_company/tools/file_system/delete_file.py (3)
  • DeleteFileTool (34-142)
  • require_elevated (72-79)
  • execute (81-142)
tests/unit/tools/file_system/conftest.py (2)
  • delete_tool (53-55)
  • workspace (18-25)
src/ai_company/tools/file_system/write_file.py (4)
src/ai_company/observability/_logger.py (1)
  • get_logger (8-28)
src/ai_company/tools/base.py (3)
  • ToolExecutionResult (24-53)
  • description (115-117)
  • parameters_schema (120-128)
src/ai_company/tools/file_system/_base_fs_tool.py (3)
  • BaseFileSystemTool (41-80)
  • workspace_root (73-75)
  • path_validator (78-80)
src/ai_company/tools/file_system/_path_validator.py (2)
  • workspace_root (56-58)
  • validate (60-86)
tests/unit/tools/file_system/test_edit_file.py (2)
src/ai_company/tools/file_system/edit_file.py (2)
  • EditFileTool (42-223)
  • execute (99-223)
tests/unit/tools/file_system/conftest.py (2)
  • workspace (18-25)
  • edit_tool (41-43)
tests/unit/tools/file_system/test_write_file.py (1)
src/ai_company/tools/file_system/write_file.py (2)
  • WriteFileTool (37-178)
  • execute (89-178)
src/ai_company/tools/file_system/_base_fs_tool.py (4)
src/ai_company/core/enums.py (1)
  • ToolCategory (213-227)
src/ai_company/observability/_logger.py (1)
  • get_logger (8-28)
src/ai_company/tools/base.py (4)
  • BaseTool (56-161)
  • description (115-117)
  • parameters_schema (120-128)
  • category (110-112)
src/ai_company/tools/file_system/_path_validator.py (2)
  • PathValidator (24-109)
  • workspace_root (56-58)
src/ai_company/tools/file_system/delete_file.py (3)
src/ai_company/tools/base.py (3)
  • ToolExecutionResult (24-53)
  • description (115-117)
  • parameters_schema (120-128)
src/ai_company/tools/file_system/_base_fs_tool.py (3)
  • BaseFileSystemTool (41-80)
  • workspace_root (73-75)
  • path_validator (78-80)
src/ai_company/tools/file_system/_path_validator.py (2)
  • workspace_root (56-58)
  • validate (60-86)
src/ai_company/tools/file_system/__init__.py (7)
src/ai_company/tools/file_system/_base_fs_tool.py (1)
  • BaseFileSystemTool (41-80)
src/ai_company/tools/file_system/_path_validator.py (1)
  • PathValidator (24-109)
src/ai_company/tools/file_system/delete_file.py (1)
  • DeleteFileTool (34-142)
src/ai_company/tools/file_system/edit_file.py (1)
  • EditFileTool (42-223)
src/ai_company/tools/file_system/list_directory.py (1)
  • ListDirectoryTool (85-231)
src/ai_company/tools/file_system/read_file.py (1)
  • ReadFileTool (58-207)
src/ai_company/tools/file_system/write_file.py (1)
  • WriteFileTool (37-178)
src/ai_company/tools/file_system/_path_validator.py (3)
src/ai_company/observability/_logger.py (1)
  • get_logger (8-28)
src/ai_company/tools/file_system/_base_fs_tool.py (1)
  • workspace_root (73-75)
src/ai_company/providers/routing/resolver.py (1)
  • resolve (125-152)
src/ai_company/tools/file_system/read_file.py (6)
src/ai_company/observability/_logger.py (1)
  • get_logger (8-28)
src/ai_company/tools/base.py (3)
  • ToolExecutionResult (24-53)
  • description (115-117)
  • parameters_schema (120-128)
src/ai_company/tools/file_system/_base_fs_tool.py (3)
  • BaseFileSystemTool (41-80)
  • workspace_root (73-75)
  • path_validator (78-80)
src/ai_company/tools/file_system/_path_validator.py (2)
  • workspace_root (56-58)
  • validate (60-86)
src/ai_company/tools/file_system/delete_file.py (1)
  • execute (81-142)
src/ai_company/tools/file_system/list_directory.py (1)
  • execute (137-231)
src/ai_company/tools/file_system/list_directory.py (4)
src/ai_company/observability/_logger.py (1)
  • get_logger (8-28)
src/ai_company/tools/base.py (3)
  • ToolExecutionResult (24-53)
  • description (115-117)
  • parameters_schema (120-128)
src/ai_company/tools/file_system/_base_fs_tool.py (3)
  • BaseFileSystemTool (41-80)
  • workspace_root (73-75)
  • path_validator (78-80)
src/ai_company/tools/file_system/_path_validator.py (2)
  • workspace_root (56-58)
  • validate (60-86)
src/ai_company/tools/__init__.py (9)
src/ai_company/tools/file_system/_base_fs_tool.py (1)
  • BaseFileSystemTool (41-80)
src/ai_company/tools/file_system/delete_file.py (1)
  • DeleteFileTool (34-142)
src/ai_company/tools/file_system/edit_file.py (1)
  • EditFileTool (42-223)
src/ai_company/tools/file_system/list_directory.py (1)
  • ListDirectoryTool (85-231)
src/ai_company/tools/file_system/_path_validator.py (1)
  • PathValidator (24-109)
src/ai_company/tools/file_system/read_file.py (1)
  • ReadFileTool (58-207)
src/ai_company/tools/file_system/write_file.py (1)
  • WriteFileTool (37-178)
src/ai_company/tools/invoker.py (1)
  • registry (100-102)
src/ai_company/tools/registry.py (1)
  • ToolRegistry (30-122)
src/ai_company/tools/file_system/edit_file.py (4)
src/ai_company/observability/_logger.py (1)
  • get_logger (8-28)
src/ai_company/tools/base.py (3)
  • ToolExecutionResult (24-53)
  • description (115-117)
  • parameters_schema (120-128)
src/ai_company/tools/file_system/_base_fs_tool.py (4)
  • BaseFileSystemTool (41-80)
  • _map_os_error (21-38)
  • workspace_root (73-75)
  • path_validator (78-80)
src/ai_company/tools/file_system/_path_validator.py (2)
  • workspace_root (56-58)
  • validate (60-86)
🔇 Additional comments (11)
CLAUDE.md (1)

57-57: LGTM!

The documentation accurately reflects the new file-system tools package structure.

src/ai_company/observability/events/tool.py (1)

26-41: LGTM!

The new file-system event constants follow the established naming conventions and cover all necessary operations and error conditions for the new tools.

tests/unit/tools/file_system/test_write_file.py (1)

1-104: LGTM!

Comprehensive test coverage for WriteFileTool including happy paths, error conditions, path traversal protection, and size limits. Tests properly use fixtures and follow project conventions.

tests/unit/tools/file_system/test_path_validator.py (1)

94-111: LGTM!

The symlink tests properly handle cross-platform considerations with the _SYMLINK_SUPPORTED check and appropriate skipif markers. Test logic correctly validates that symlinks pointing outside the workspace are rejected.

tests/unit/tools/file_system/conftest.py (1)

1-55: LGTM!

Well-structured test fixtures providing consistent workspace setup and tool instantiation across the test suite. The workspace fixture creates representative test files covering common scenarios.

src/ai_company/tools/__init__.py (2)

12-20: LGTM!

Clean import structure from the new file_system subpackage with all tool classes and PathValidator properly exposed at the top-level tools package.


25-44: LGTM!

The __all__ list is properly alphabetized and includes all new file-system exports.

tests/unit/tools/file_system/test_delete_file.py (1)

1-103: LGTM!

Comprehensive test coverage for DeleteFileTool including property verification, successful deletion scenarios, and proper error handling tests using monkeypatching for OS-level errors.

src/ai_company/tools/file_system/__init__.py (1)

1-23: LGTM!

Clean package initialization with proper docstring, explicit imports from internal modules (_base_fs_tool, _path_validator) and public tool modules, and an alphabetized __all__ list. This provides a well-defined public API surface for the file-system tools.

src/ai_company/tools/file_system/read_file.py (1)

144-147: This suggestion would introduce a syntax error and should not be implemented.

The code already uses the correct Python 3.14 exception syntax. When binding the exception object with as exc:, parentheses are mandatory in Python 3.14 (PEP 758). The syntax except A, B as exc: is a SyntaxError; the correct form is except (A, B) as exc:, which is what the code already uses.

The parenthesized form is required whenever exception binding is present. The unparenthesized except A, B: form is only valid without an as clause.

			> Likely an incorrect or invalid review comment.
src/ai_company/tools/file_system/edit_file.py (1)

150-153: The current exception syntax is correct for Python 3.14 and does not require changes.

PEP 758 allows omitting parentheses around exception lists only when as NAME is not used. Since both lines 150–153 and 184–187 bind the exception with as exc:, the parenthesized form except (...) as exc: is required and is the correct style for Python 3.14.

			> Likely an incorrect or invalid review comment.

Comment on lines +71 to +79
@property
def require_elevated(self) -> bool:
"""Whether this tool requires elevated permissions.

Indicates this tool requires explicit approval before execution
due to its destructive nature. Not yet consumed by the engine;
defined for forward-compatibility.
"""
return True
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's locate the DeleteFileTool class and understand its structure
cd /tmp && find . -name "delete_file.py" -type f 2>/dev/null | head -20

Repository: Aureliolo/ai-company

Length of output: 46


🏁 Script executed:

# Search for require_elevated usage across the codebase
rg "require_elevated" --type py -B 2 -A 2

Repository: Aureliolo/ai-company

Length of output: 1314


🏁 Script executed:

# Look for dispatcher or executor that might handle tool execution
rg "dispatcher\|executor\|execute_tool" --type py -l | head -20

Repository: Aureliolo/ai-company

Length of output: 46


🏁 Script executed:

# Search for issue `#18` references
rg "#18\|issue.*18" --type py --type md

Repository: Aureliolo/ai-company

Length of output: 46


🏁 Script executed:

# Look for Tool base class to understand the interface
rg "class.*Tool.*:" --type py -A 5 | grep -A 5 "require_elevated" | head -40

Repository: Aureliolo/ai-company

Length of output: 455


🏁 Script executed:

# Search for Tool base class definition and interface
find . -name "*.py" -type f -exec grep -l "class.*Tool" {} \; | grep -E "(base|core)" | head -10

Repository: Aureliolo/ai-company

Length of output: 302


🏁 Script executed:

# Look for tool execution in engine or dispatcher modules
fd "engine\|dispatch" --type f --extension py

Repository: Aureliolo/ai-company

Length of output: 46


🏁 Script executed:

# Search for any enforcement of elevated permissions
rg "elevated" --type py -B 3 -A 3

Repository: Aureliolo/ai-company

Length of output: 2261


🏁 Script executed:

# Check the delete_file.py file structure around require_elevated
cat -n src/ai_company/tools/file_system/delete_file.py | sed -n '1,100p'

Repository: Aureliolo/ai-company

Length of output: 3866


🏁 Script executed:

# Examine the Tool base class
cat -n src/ai_company/tools/base.py | head -100

Repository: Aureliolo/ai-company

Length of output: 4359


🏁 Script executed:

# Check if there's any agent or engine code that reads require_elevated
rg "require_elevated" -C 5

Repository: Aureliolo/ai-company

Length of output: 2703


🏁 Script executed:

# Look at the core/enums.py to understand permission levels
cat -n src/ai_company/core/enums.py | head -50

Repository: Aureliolo/ai-company

Length of output: 1516


🏁 Script executed:

# Search for any permission checking in agent or executor
rg "permission\|ELEVATED" --type py -B 3 -A 3 | head -80

Repository: Aureliolo/ai-company

Length of output: 46


🏁 Script executed:

# Let's check the agent.py to see if it handles tool execution
cat -n src/ai_company/core/agent.py | head -150

Repository: Aureliolo/ai-company

Length of output: 5383


🏁 Script executed:

# Search for tool invocation or dispatch code
rg "execute.*tool\|invoke.*tool\|dispatch.*tool" --type py -B 2 -A 5

Repository: Aureliolo/ai-company

Length of output: 46


🏁 Script executed:

# Check if there's any permission gating in BaseTool or subclasses
cat -n src/ai_company/tools/base.py | sed -n '56,150p'

Repository: Aureliolo/ai-company

Length of output: 3885


🏁 Script executed:

# Search for any "issue `#18`" or references to elevated permissions requirements
rg "issue\|#18\|elevated.*permission\|permission.*elevated" --type py --type md -B 2 -A 2

Repository: Aureliolo/ai-company

Length of output: 46


🏁 Script executed:

# Find ToolAccessLevel definition
rg "class ToolAccessLevel" --type py -A 15

Repository: Aureliolo/ai-company

Length of output: 1062


🏁 Script executed:

# Search for ToolPermissions usage to see if it actually gates execution
rg "ToolPermissions\|access_level" --type py -B 2 -A 5 | head -100

Repository: Aureliolo/ai-company

Length of output: 46


🏁 Script executed:

# Check if BaseTool has require_elevated property
rg "require_elevated" --type py --files-with-matches

Repository: Aureliolo/ai-company

Length of output: 160


🏁 Script executed:

# Let's look at the full BaseTool class
cat -n src/ai_company/tools/base.py | tail -100

Repository: Aureliolo/ai-company

Length of output: 4071


🏁 Script executed:

# Check BaseFileSystemTool
cat -n src/ai_company/tools/file_system/_base_fs_tool.py

Repository: Aureliolo/ai-company

Length of output: 3232


🏁 Script executed:

# Search for ToolInvoker or tool execution dispatcher
rg "ToolInvoker\|tool.*invoke\|invoke.*tool" --type py -B 2 -A 5 | head -150

Repository: Aureliolo/ai-company

Length of output: 46


🏁 Script executed:

# Look for ToolPermissionChecker mentioned in the ToolAccessLevel docstring
rg "ToolPermissionChecker" --type py -B 2 -A 15

Repository: Aureliolo/ai-company

Length of output: 41866


🏁 Script executed:

# Search for any code checking require_elevated or referencing elevated access
rg "\.require_elevated\|elevated" --type py -B 3 -A 3

Repository: Aureliolo/ai-company

Length of output: 46


🏁 Script executed:

# Check agent execute or run tool methods
rg "execute.*tool\|run.*tool" --type py -B 3 -A 8 | head -200

Repository: Aureliolo/ai-company

Length of output: 46


🏁 Script executed:

# Look at the full ToolInvoker implementation
cat -n src/ai_company/tools/invoker.py | head -200

Repository: Aureliolo/ai-company

Length of output: 7989


🏁 Script executed:

# Search for where require_elevated might be referenced
rg "require_elevated" -B 5 -A 5

Repository: Aureliolo/ai-company

Length of output: 2703


🏁 Script executed:

# Check if ToolInvoker has any code that checks require_elevated
cat -n src/ai_company/tools/invoker.py | tail -200

Repository: Aureliolo/ai-company

Length of output: 8532


require_elevated is not enforced by the dispatcher.

The ToolInvoker permission check (line 116–141 in src/ai_company/tools/invoker.py) only validates tool.name and tool.category against agent permissions; it does not check the require_elevated property. As a result, delete_file can be executed by any agent with FILE_SYSTEM access, even though the property explicitly signals it requires elevated approval. Before release, implement enforcement of this flag in the permission checker or document that per-tool elevated-access gating is deferred to a future release.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/ai_company/tools/file_system/delete_file.py` around lines 71 - 79, The
dispatcher currently ignores the delete_file Tool's require_elevated flag;
update the permission check in ToolInvoker (the permission validation block that
inspects tool.name and tool.category) to also reject tools with
tool.require_elevated=True unless the invoking agent has an
elevated/explicit-approval capability (e.g., an AgentPermission like "elevated"
or "can_execute_elevated_tools"); modify the permission logic in
ToolInvoker.check/authorize (or the method around lines 116–141) to consult
tool.require_elevated and the agent's permission set and return a
permission-denied result when the agent lacks elevated rights, or alternatively
add a clear comment and documentation stub if you choose to defer enforcement.

Comment on lines +96 to +104
try:
resolved = self.path_validator.validate(user_path)
except ValueError as exc:
return ToolExecutionResult(content=str(exc), is_error=True)

# Let _delete_sync handle all filesystem checks atomically
# rather than pre-checking exists()/is_dir() — avoids TOCTOU.
try:
size_bytes = await asyncio.to_thread(_delete_sync, resolved)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, examine the delete_file.py file to see the full context
cat -n src/ai_company/tools/file_system/delete_file.py

Repository: Aureliolo/ai-company

Length of output: 5787


🏁 Script executed:

# Check the _path_validator.py to understand what validate() returns
cat -n src/ai_company/tools/file_system/_path_validator.py

Repository: Aureliolo/ai-company

Length of output: 4540


🏁 Script executed:

# Find the _delete_sync function implementation
rg -n "_delete_sync" --type py -A 20

Repository: Aureliolo/ai-company

Length of output: 6646


Use fd-based deletion to eliminate symlink dereference and TOCTOU vulnerability.

PathValidator.validate() resolves symlinks to their targets (via .resolve()), so passing a symlink path will delete the target file rather than the link itself. Additionally, the TOCTOU race already documented in _path_validator.py (lines 72–77) remains: a concurrent process can swap a parent directory component for a symlink between validation and the unlink() call, redirecting deletion outside the workspace. Switching to fd-anchored operations (e.g., os.unlinkat with a directory file descriptor) eliminates both the symlink dereference and the race.

Comment on lines +78 to +96
parameters_schema={
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "File path relative to workspace",
},
"old_text": {
"type": "string",
"description": "Exact text to find",
},
"new_text": {
"type": "string",
"description": "Replacement text (empty string to delete)",
},
},
"required": ["path", "old_text", "new_text"],
"additionalProperties": False,
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reject empty old_text up front.

An empty search string is currently valid input. In _edit_sync(), content.count("") returns len(content) + 1 and replace("", new_text, 1) just prefixes the file, so this turns into a destructive edit instead of a validation error.

Suggested fix
                     "old_text": {
                         "type": "string",
+                        "minLength": 1,
                         "description": "Exact text to find",
                     },
@@
         old_text: str = arguments["old_text"]
         new_text: str = arguments["new_text"]

+        if old_text == "":
+            return ToolExecutionResult(
+                content="old_text must not be empty",
+                is_error=True,
+            )
+
         if old_text == new_text:

As per coding guidelines, "Validate at system boundaries (user input, external APIs, config files)".

Also applies to: 113-117

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/ai_company/tools/file_system/edit_file.py` around lines 78 - 96, The
parameters_schema currently allows an empty "old_text", which leads to
destructive edits in _edit_sync; update validation to reject empty search
strings by adding "minLength": 1 to the "old_text" schema property (and likewise
to the similar schema referenced around the other block mentioned) and also add
a defensive check at the start of _edit_sync (and its async counterpart if
present) to raise/return a validation error when old_text == "" to ensure
system-boundary validation.

Aureliolo and others added 2 commits March 7, 2026 09:58
…iewers

Security fixes:
- Block absolute paths in PathValidator before resolve (path traversal)
- Wrap resolve() in try/except OSError for malformed paths
- Check containment for ALL directory entries, not just symlinks
- Reject absolute glob patterns in list_directory
- Block oversized files from line-range reads (DoS prevention)
- Add minLength:1 + runtime check for empty old_text in edit (destructive no-op)

Reliability fixes:
- Atomic writes via tempfile+fsync+replace in edit_file and write_file
- Stable "os_error" log key + detailed user message in _map_os_error
- Distinguish missing path from non-directory in list_directory
- Validate start_line <= end_line in read_file
- Compute line_count before appending truncation notice

Information leak fix:
- Log old_text length instead of preview content in edit_file

Documentation:
- Add docstrings to _read_sync, _edit_sync, _write_sync, _list_sync
- Update DESIGN_SPEC.md file system tool category to include "list"
- Update list_directory class docstring with all type prefixes

Tests:
- Add pytestmark timeout(30) to all 6 test modules
- Add test_empty_old_text_rejected, test_start_line_greater_than_end_line
- Fix test assertions for new error messages and behavior
- Add encoding="utf-8" to write_text calls in path validator tests

Reviewed-by: CodeRabbit, Gemini, Greptile, Copilot
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 7, 2026 08:59
@Aureliolo Aureliolo merged commit 325ef98 into main Mar 7, 2026
10 checks passed
@Aureliolo Aureliolo deleted the feat/file-system-tools branch March 7, 2026 09:00
MAX_ENTRIES: Final[int] = 1000

# Reject glob patterns that could traverse above the workspace.
_UNSAFE_GLOB_RE = re.compile(r"(^|[/\\])\.\.[/\\]|^\.\.$")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_UNSAFE_GLOB_RE misses trailing .. without separator

The regex (^|[/\\])\.\.[/\\]|^\.\.$ requires a / or \ after .., so a pattern like subdir/.. (terminating with ..) is not rejected. The second operand ^\.\.$ only matches the bare .. string.

On Python 3.12+ pathlib raises ValueError: Pattern cannot contain '..' when .. appears anywhere in a glob argument. Because execute() (lines 234–245) only catches PermissionError and OSError, this ValueError would escape the tool boundary as an unhandled exception.

Suggested change
_UNSAFE_GLOB_RE = re.compile(r"(^|[/\\])\.\.[/\\]|^\.\.$")
_UNSAFE_GLOB_RE = re.compile(r"(^|[/\\])\.\.([/\\]|$)|^\.\.$")

This change adds $ as a valid end-anchor so that subdir/.. is caught. The existing containment check inside _list_sync still acts as a secondary defence, but the early rejection is cleaner and avoids the propagation risk entirely.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/ai_company/tools/file_system/list_directory.py
Line: 27

Comment:
`_UNSAFE_GLOB_RE` misses trailing `..` without separator

The regex `(^|[/\\])\.\.[/\\]|^\.\.$` requires a `/` or `\` _after_ `..`, so a pattern like `subdir/..` (terminating with `..`) is not rejected. The second operand `^\.\.$` only matches the bare `..` string.

On Python 3.12+ pathlib raises `ValueError: Pattern cannot contain '..'` when `..` appears anywhere in a glob argument. Because `execute()` (lines 234–245) only catches `PermissionError` and `OSError`, this `ValueError` would escape the tool boundary as an unhandled exception.

```suggestion
_UNSAFE_GLOB_RE = re.compile(r"(^|[/\\])\.\.([/\\]|$)|^\.\.$")
```

This change adds `$` as a valid end-anchor so that `subdir/..` is caught. The existing containment check inside `_list_sync` still acts as a secondary defence, but the early rejection is cleaner and avoids the propagation risk entirely.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +54 to +60
glob_pattern = pattern or "*"
if recursive:
raw_iter = resolved.rglob(glob_pattern)
elif pattern:
raw_iter = resolved.glob(glob_pattern)
else:
raw_iter = resolved.iterdir()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

recursive=False does not prevent recursion when pattern contains **

When pattern is something like "**/*.py", the elif pattern: branch (line 58) calls resolved.glob("**/*.py"), which still performs a full recursive walk — the ** wildcard is interpreted by pathlib as a glob-level recursion operator regardless of which method is used. A caller who passes recursive=False with a ** pattern gets recursive behaviour without any warning.

Consider detecting (and rejecting) ** in the pattern when recursive=False:

if pattern and not recursive and ("**" in pattern):
    return ToolExecutionResult(
        content="Use recursive=true with ** glob patterns.",
        is_error=True,
    )

Alternatively, strip or escape ** segments when recursive=False to enforce the caller's intent.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/ai_company/tools/file_system/list_directory.py
Line: 54-60

Comment:
`recursive=False` does not prevent recursion when pattern contains `**`

When `pattern` is something like `"**/*.py"`, the `elif pattern:` branch (line 58) calls `resolved.glob("**/*.py")`, which still performs a full recursive walk — the `**` wildcard is interpreted by pathlib as a glob-level recursion operator regardless of which method is used. A caller who passes `recursive=False` with a `**` pattern gets recursive behaviour without any warning.

Consider detecting (and rejecting) `**` in the pattern when `recursive=False`:

```python
if pattern and not recursive and ("**" in pattern):
    return ToolExecutionResult(
        content="Use recursive=true with ** glob patterns.",
        is_error=True,
    )
```

Alternatively, strip or escape `**` segments when `recursive=False` to enforce the caller's intent.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 19 out of 20 changed files in this pull request and generated 11 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +5 to +15
from typing import TYPE_CHECKING

import pytest

from ai_company.tools.file_system._path_validator import PathValidator

if TYPE_CHECKING:
from pathlib import Path

pytestmark = pytest.mark.timeout(30)

Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Path is only imported under TYPE_CHECKING, but it’s used in runtime annotations (tmp_path: Path). This will raise NameError during module import unless Path is imported at runtime (pytest evaluates annotations).

Suggested change
from typing import TYPE_CHECKING
import pytest
from ai_company.tools.file_system._path_validator import PathValidator
if TYPE_CHECKING:
from pathlib import Path
pytestmark = pytest.mark.timeout(30)
from pathlib import Path
import pytest
from ai_company.tools.file_system._path_validator import PathValidator
pytestmark = pytest.mark.timeout(30)

Copilot uses AI. Check for mistakes.
Comment on lines +44 to +67
max_bytes: When set, only the first *max_bytes* characters are
read via text-mode ``read()`` (approximate byte cap for
oversized files without a line range).

Returns:
The file content (possibly sliced or truncated).

Raises:
UnicodeDecodeError: If the file contains non-UTF-8 bytes.
FileNotFoundError: If the file does not exist.
PermissionError: If the process lacks read permission.
OSError: For other OS-level I/O failures.
"""
if start is not None or end is not None:
raw = resolved.read_text(encoding="utf-8")
lines = raw.splitlines(keepends=True)
s = (start - 1) if start is not None else 0
e = end if end is not None else len(lines)
return "".join(lines[s:e])

if max_bytes is not None:
with resolved.open(encoding="utf-8") as fh:
return fh.read(max_bytes)

Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

max_bytes is treated as a byte cap in the API/variable naming, but _read_sync uses text-mode fh.read(max_bytes) which limits characters, not bytes. For non-ASCII UTF-8, this can read several MB even when max_bytes=1_048_576, undermining the DoS/memory guard. Consider reading bytes in binary mode (e.g., fh.buffer.read(max_bytes)) and then decoding, so the cap is enforced in bytes.

Copilot uses AI. Check for mistakes.
Comment on lines +115 to +125
ValueError: If the resolved path escapes the workspace or
the parent directory does not exist.
"""
resolved = self.validate(path)
if not resolved.parent.exists():
logger.warning(
TOOL_FS_PARENT_NOT_FOUND,
path=path,
)
msg = f"Parent directory does not exist: {path}"
raise ValueError(msg)
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validate_parent_exists() only checks resolved.parent.exists(). If the parent path exists but is a file (e.g., writing to hello.txt/new.txt), validation passes and the write later fails with a generic OS error. Consider checking resolved.parent.is_dir() and returning a clearer error message (and event) for “parent is not a directory”.

Suggested change
ValueError: If the resolved path escapes the workspace or
the parent directory does not exist.
"""
resolved = self.validate(path)
if not resolved.parent.exists():
logger.warning(
TOOL_FS_PARENT_NOT_FOUND,
path=path,
)
msg = f"Parent directory does not exist: {path}"
raise ValueError(msg)
ValueError: If the resolved path escapes the workspace, the
parent directory does not exist, or the parent is not a
directory.
"""
resolved = self.validate(path)
parent = resolved.parent
if not parent.exists():
logger.warning(
TOOL_FS_PARENT_NOT_FOUND,
path=path,
)
msg = f"Parent directory does not exist: {path}"
raise ValueError(msg)
if not parent.is_dir():
logger.warning(
TOOL_FS_PARENT_NOT_FOUND,
path=path,
)
msg = f"Parent path is not a directory: {path}"
raise ValueError(msg)

Copilot uses AI. Check for mistakes.
Comment on lines +212 to +225
if not resolved.exists():
logger.warning(TOOL_FS_ERROR, path=user_path, error="not_found")
return ToolExecutionResult(
content=f"Path not found: {user_path}",
is_error=True,
)

if not resolved.is_dir():
logger.warning(TOOL_FS_ERROR, path=user_path, error="not_a_directory")
return ToolExecutionResult(
content=f"Not a directory: {user_path}",
is_error=True,
)

Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using Path.exists() / Path.is_dir() can silently return False on PermissionError, which means permission issues may be reported as “Path not found” or “Not a directory”. Consider using stat() (or iterdir() in a try block) and mapping PermissionError explicitly so the user gets an accurate error.

Copilot uses AI. Check for mistakes.
Comment on lines +3 to +13
from typing import TYPE_CHECKING

import pytest

from ai_company.core.enums import ToolCategory
from ai_company.tools.file_system.read_file import MAX_FILE_SIZE_BYTES, ReadFileTool

if TYPE_CHECKING:
from pathlib import Path

pytestmark = pytest.mark.timeout(30)
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Path is only imported under TYPE_CHECKING, but the test function signatures use Path annotations. Import Path from pathlib at runtime to avoid NameError during test module import (pytest evaluates annotations).

Copilot uses AI. Check for mistakes.
Comment on lines +72 to +79
def require_elevated(self) -> bool:
"""Whether this tool requires elevated permissions.

Indicates this tool requires explicit approval before execution
due to its destructive nature. Not yet consumed by the engine;
defined for forward-compatibility.
"""
return True
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DeleteFileTool.require_elevated is not currently enforced anywhere (no other references in the repo), and ToolPermissionChecker grants ToolCategory.FILE_SYSTEM even at SANDBOXED/RESTRICTED levels. As implemented, delete_file will be permitted by default for most agents, which conflicts with the “elevated access only” requirement. Consider integrating require_elevated into permission checking / invocation gating (or otherwise ensuring delete_file is denied unless explicitly allowed/elevated).

Copilot uses AI. Check for mistakes.
Comment on lines +3 to +15
from typing import TYPE_CHECKING

import pytest

from ai_company.tools.file_system.delete_file import DeleteFileTool
from ai_company.tools.file_system.edit_file import EditFileTool
from ai_company.tools.file_system.list_directory import ListDirectoryTool
from ai_company.tools.file_system.read_file import ReadFileTool
from ai_company.tools.file_system.write_file import WriteFileTool

if TYPE_CHECKING:
from pathlib import Path

Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Path is only imported under TYPE_CHECKING, but it’s used in runtime-evaluated annotations (pytest evaluates annotations). This will raise NameError at import time. Import Path from pathlib at runtime (see pattern in tests/unit/tools/git/conftest.py which uses # noqa: TC003).

Copilot uses AI. Check for mistakes.
Comment on lines +3 to +11
from typing import TYPE_CHECKING

import pytest

if TYPE_CHECKING:
from pathlib import Path

from ai_company.tools.file_system.write_file import WriteFileTool

Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Path is only imported under TYPE_CHECKING, but it’s referenced in runtime annotations (e.g., workspace: Path). Pytest evaluates these annotations, so this will fail with NameError at import time unless Path is imported at runtime.

Suggested change
from typing import TYPE_CHECKING
import pytest
if TYPE_CHECKING:
from pathlib import Path
from ai_company.tools.file_system.write_file import WriteFileTool
from pathlib import Path
import pytest
from ai_company.tools.file_system.write_file import WriteFileTool

Copilot uses AI. Check for mistakes.
Comment on lines +4 to +9

import pytest

if TYPE_CHECKING:
from pathlib import Path

Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Path is only imported under TYPE_CHECKING, but it’s used in runtime annotations for test args. This will raise NameError when importing the test module unless Path is imported at runtime.

Suggested change
import pytest
if TYPE_CHECKING:
from pathlib import Path
from pathlib import Path
import pytest
if TYPE_CHECKING:

Copilot uses AI. Check for mistakes.
Comment on lines +4 to +9

import pytest

if TYPE_CHECKING:
from pathlib import Path

Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Path is only imported under TYPE_CHECKING, but runtime-evaluated annotations reference it (e.g., workspace: Path). Import Path from pathlib at runtime to avoid NameError when pytest imports this file.

Suggested change
import pytest
if TYPE_CHECKING:
from pathlib import Path
from pathlib import Path
import pytest
if TYPE_CHECKING:

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement built-in tools: file system operations

2 participants