Real Tools · Tools That Do Real Work
Filesystem tools and safety
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
# 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.
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
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
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.
Sign in to keep your place and tick off lessons. Reading stays open either way.