# Filesystem tools and safety

Web page: https://academy.memberpress.ai/courses/tool-use/l6/

Lesson 6 of 9 in [Tool Use — From Calculator to Agent](https://academy.memberpress.ai/courses/tool-use/), 18 min.

Video: https://www.youtube.com/watch?v=Y9A7LD46EKY

A bad SQL query returns wrong data. A bad filesystem write can destroy data, expose it, or execute it. The model does not know this. It will pass `"../../etc/passwd"` as a path if nothing stops it — not maliciously, but because it constructs paths from the inputs it received without understanding what lies outside the sandbox. Path validation belongs in the handler, not in the tool description, and it must run before any file operation.

**Why filesystem tools are different**

The blast radius of a filesystem tool scales with what the model can reach. Read-only tools with a confined root are low risk. Write tools with no path validation are not. A single `os.path.join(SANDBOX, user_path)` without `realpath` can be escaped with `"../../../home/user/.ssh/authorized_keys"`. That call succeeds silently. You will not see it in the tool result.

**The sandbox function**

```python
# scripts/fs_tools.py
import os, json, logging, anthropic
from datetime import datetime, timezone

SANDBOX = os.path.realpath(os.path.join(os.path.dirname(__file__), "../workspace"))
os.makedirs(SANDBOX, exist_ok=True)

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
logger = logging.getLogger("fs_tools")

def safe_path(user_path: str) -> str:
    """Resolve user_path relative to SANDBOX. Raise ValueError if it escapes."""
    resolved = os.path.realpath(os.path.join(SANDBOX, user_path))
    # The trailing sep prevents /var/app/workspace-evil from matching /var/app/workspace
    if not resolved.startswith(SANDBOX + os.sep) and resolved != SANDBOX:
        raise ValueError(f"Path escapes sandbox: {user_path!r} → {resolved!r}")
    return resolved
```

The `os.path.realpath` call resolves symlinks, `..` components, and `.` — all of the ways a path can be constructed to escape a directory while looking innocent. Call it before any read or write. Never call `os.path.join(SANDBOX, user_path)` without `realpath`. The trailing `os.sep` in the `startswith` check is also non-optional — without it, a sandbox at `/var/app/workspace` would pass paths rooted at `/var/app/workspace-evil`.

**The tool definitions**

Expose read and write as separate tools. This gives you independent permission control, and it makes the model's intent explicit in the call record. If the model calls `write_file`, you know it intended to write — you do not have to infer it from a generic `fs_op` tool with an `action` parameter.

```python
tools = [
    {
        "name": "read_file",
        "description": (
            "Read the contents of a file in the workspace. "
            "Paths are relative to the workspace root. "
            "Returns the file contents as a string, or an error if not found."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "Relative path, e.g. 'notes/draft.txt'"}
            },
            "required": ["path"]
        }
    },
    {
        "name": "write_file",
        "description": (
            "Write text content to a file in the workspace. "
            "Creates the file if it does not exist, overwrites if it does. "
            "Paths are relative to the workspace root. Do not use absolute paths."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "Relative path, e.g. 'output/result.txt'"},
                "content": {"type": "string", "description": "Text to write"}
            },
            "required": ["path", "content"]
        }
    }
]
```

**The handlers**

```python
def handle_read_file(path: str, tool_use_id: str = "") -> dict:
    try:
        resolved = safe_path(path)
        with open(resolved, "r", encoding="utf-8") as f:
            content = f.read()
        logger.info("READ  id=%s path=%s bytes=%d", tool_use_id, resolved, len(content))
        return {"content": content, "bytes": len(content)}
    except ValueError as e:
        return {"error": str(e)}
    except FileNotFoundError:
        return {"error": f"File not found: {path}"}
    except Exception as e:
        return {"error": f"Read failed: {e}"}

def handle_write_file(path: str, content: str, tool_use_id: str = "") -> dict:
    try:
        resolved = safe_path(path)
        os.makedirs(os.path.dirname(resolved), exist_ok=True)
        with open(resolved, "w", encoding="utf-8") as f:
            f.write(content)
        byte_count = len(content.encode("utf-8"))
        logger.info(
            "WRITE id=%s path=%s bytes=%d ts=%s",
            tool_use_id, resolved, byte_count,
            datetime.now(timezone.utc).isoformat()
        )
        return {"written": byte_count, "path": path}
    except ValueError as e:
        return {"error": str(e)}
    except Exception as e:
        return {"error": f"Write failed: {e}"}
```

Every write logs four things: the `tool_use_id` (so you can correlate to the conversation turn), the resolved path (the actual file that changed on disk), the byte count, and the UTC timestamp. If the model writes something unexpected, this log tells you exactly when and where. The `tool_use_id` links back to the specific assistant turn in the message history.

**Connecting handlers to the dispatch table**

```python
client = anthropic.Anthropic()

HANDLERS = {
    "read_file": handle_read_file,
    "write_file": handle_write_file
}

def run(question: str):
    messages = [{"role": "user", "content": question}]
    while True:
        r = client.messages.create(
            model="claude-opus-4-5", max_tokens=1024,
            tools=tools, messages=messages
        )
        messages.append({"role": "assistant", "content": r.content})
        if r.stop_reason == "end_turn":
            return r.content[0].text
        results = []
        for block in r.content:
            if block.type == "tool_use":
                result = HANDLERS[block.name](**block.input, tool_use_id=block.id)
                results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": json.dumps(result)
                })
        messages.append({"role": "user", "content": results})

print(run("Write a short poem about Oslo to output/oslo.txt, then read it back and confirm."))
```

**If you get stuck**

*`safe_path` raises for a path that should be valid:* Check whether your `SANDBOX` string ends with a path separator before the check. The expression `SANDBOX + os.sep` is correct. If `SANDBOX` already ends with `/`, you get `//` which can produce false positives on some systems. Use `os.path.realpath` when setting `SANDBOX` and you will not see this.

*Model constructs an absolute path instead of a relative one:* The description says "Do not use absolute paths" — if the model still passes `/workspace/output/file.txt`, strip leading slashes in the handler before calling `safe_path`. `user_path.lstrip("/")` is sufficient for this case.

*Model writes a file and the byte count is unexpectedly small:* The model may have passed only a fragment of the content it intended to write. Check the call log — the `tool_use_id` links the write to the conversation turn, and you can inspect what `content` the model actually passed.

**Cross-reference**

The dispatch table pattern (`HANDLERS` dict) is formalized in l7 with error handling, an iteration cap, and a `dispatch()` wrapper function. The audit log format here — `tool_use_id`, resolved path, timestamp — is the basis for the `CallRecord` instrumentation in l8.

**Deliverable**

`scripts/fs_tools.py` — sandbox function, two handlers with logging, dispatch loop. Test it by asking the model to write a file, read it back, and append a second sentence. Confirm the log shows two writes with different byte counts and that the second write's byte count is larger than the first.

## Try these prompts

- Write the safe_path function in Python that resolves a user-supplied path relative to a SANDBOX directory and raises ValueError if it escapes. Explain why os.path.realpath is required.
- Write the tool definitions for read_file and write_file as separate tools, including the description and input_schema for each. Explain why they should be separate rather than one fs_op tool.
- Write the four fields that every file write should log — tool_use_id, resolved path, byte count, and UTC timestamp — and explain which field links the log entry back to the conversation turn.

Next: [A small agent that uses three tools](https://academy.memberpress.ai/courses/tool-use/l7/)
