From f7af92a10e3251d7d7722557da1086e52895a8bb Mon Sep 17 00:00:00 2001 From: Sidney Marvin Fricke Date: Wed, 24 Jun 2026 12:36:51 +0200 Subject: [PATCH] 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. --- server/main.py | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/server/main.py b/server/main.py index 33233cd..314088e 100644 --- a/server/main.py +++ b/server/main.py @@ -7,6 +7,7 @@ import asyncio import json import logging import os +import pathlib import re import signal import subprocess @@ -247,6 +248,50 @@ TOOLS = [ "description": "List all known background jobs with their current status.", "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 ──────────────────────────────────────────────────────────── @@ -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}") @@ -410,6 +482,14 @@ def _get_pty(session_id: str) -> PtySession: 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: job = _jobs.get(job_id) if not job: