add file_read, file_write, file_list, file_delete tools

Agents can now read, write, create, and delete files directly in
/workspace without going through exec. Path traversal outside
/workspace is rejected.
This commit is contained in:
Sidney Marvin Fricke
2026-06-24 12:36:51 +02:00
parent 181ea1eaf6
commit f7af92a10e
+80
View File
@@ -7,6 +7,7 @@ import asyncio
import json import json
import logging import logging
import os import os
import pathlib
import re import re
import signal import signal
import subprocess import subprocess
@@ -247,6 +248,50 @@ TOOLS = [
"description": "List all known background jobs with their current status.", "description": "List all known background jobs with their current status.",
"inputSchema": {"type": "object", "properties": {}}, "inputSchema": {"type": "object", "properties": {}},
}, },
{
"name": "file_read",
"description": "Read the content of a file inside /workspace.",
"inputSchema": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Path relative to /workspace"},
},
"required": ["path"],
},
},
{
"name": "file_write",
"description": "Write (create or overwrite) a file inside /workspace.",
"inputSchema": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Path relative to /workspace"},
"content": {"type": "string", "description": "File content to write"},
},
"required": ["path", "content"],
},
},
{
"name": "file_list",
"description": "List files and directories inside /workspace (optionally a subdirectory).",
"inputSchema": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Subdirectory relative to /workspace (default: root)", "default": ""},
},
},
},
{
"name": "file_delete",
"description": "Delete a file inside /workspace.",
"inputSchema": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Path relative to /workspace"},
},
"required": ["path"],
},
},
] ]
# ─── Tool dispatch ──────────────────────────────────────────────────────────── # ─── Tool dispatch ────────────────────────────────────────────────────────────
@@ -337,6 +382,33 @@ async def _dispatch(name: str, args: dict) -> dict:
] ]
} }
elif name == "file_read":
p = _workspace_path(args["path"])
log.info("file_read: %s", p)
return {"path": args["path"], "content": p.read_text(encoding="utf-8")}
elif name == "file_write":
p = _workspace_path(args["path"])
log.info("file_write: %s (%d bytes)", p, len(args.get("content", "")))
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(args.get("content", ""), encoding="utf-8")
return {"path": args["path"], "written": len(args.get("content", ""))}
elif name == "file_list":
p = _workspace_path(args.get("path", ""))
log.info("file_list: %s", p)
entries = [
{"name": e.name, "type": "dir" if e.is_dir() else "file", "size": e.stat().st_size if e.is_file() else None}
for e in sorted(p.iterdir(), key=lambda e: (e.is_file(), e.name))
]
return {"path": args.get("path", ""), "entries": entries}
elif name == "file_delete":
p = _workspace_path(args["path"])
log.info("file_delete: %s", p)
p.unlink()
return {"deleted": args["path"]}
raise ValueError(f"Unknown tool: {name}") raise ValueError(f"Unknown tool: {name}")
@@ -410,6 +482,14 @@ def _get_pty(session_id: str) -> PtySession:
return sess return sess
def _workspace_path(rel: str) -> pathlib.Path:
"""Resolve a relative path inside WORKSPACE, reject path traversal."""
p = (pathlib.Path(WORKSPACE) / rel).resolve()
if not str(p).startswith(str(pathlib.Path(WORKSPACE).resolve())):
raise ValueError(f"Path escapes workspace: {rel}")
return p
def _get_job(job_id: str) -> BackgroundJob: def _get_job(job_id: str) -> BackgroundJob:
job = _jobs.get(job_id) job = _jobs.get(job_id)
if not job: if not job: