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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user