agent harness initialize repo

This commit is contained in:
Roger Oriol
2026-07-19 20:13:54 +02:00
commit 42475d3249
53 changed files with 7391 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
from tools.registry import get_tool_registry, get_tool_schemas
from tools.sandbox import DockerSandbox, DockerSandboxError, ACTION_TOOLS
from tools.audit import AuditLog
from tools.validators import ToolValidator, bounded_schemas
__all__ = [
"get_tool_registry",
"get_tool_schemas",
"DockerSandbox",
"DockerSandboxError",
"ACTION_TOOLS",
"AuditLog",
"ToolValidator",
"bounded_schemas",
]

View File

@@ -0,0 +1,62 @@
"""Runs inside the sandbox container and dispatches a single tool call.
Invoked by the host as:
docker exec -i <container> python /agent_tools/_dispatch.py <tool_name>
The tool arguments are read from stdin as a JSON object; the result is
printed to stdout. Only the *action* tools (the ones that touch the
filesystem, shell, or network) are dispatched here — the in-memory
planning tools stay on the host.
"""
import json
import sys
import traceback
sys.path.insert(0, "/agent_tools")
import filesystem # noqa: E402
import shell # noqa: E402
import web # noqa: E402
REGISTRY = {
"read_file": filesystem.read_file,
"glob_files": filesystem.glob_files,
"grep": filesystem.grep,
"write_file": filesystem.write_file,
"edit_file": filesystem.edit_file,
"run_bash": shell.run_bash,
"webfetch": web.webfetch,
}
def main() -> None:
if len(sys.argv) < 2:
print("Error: dispatch requires a tool name argument.")
return
name = sys.argv[1]
if name not in REGISTRY:
print(f"Error: tool '{name}' is not available inside the container.")
return
raw = sys.stdin.read()
try:
args = json.loads(raw) if raw else {}
except json.JSONDecodeError as e:
print(f"Error: could not parse tool arguments as JSON: {e}")
return
try:
result = REGISTRY[name](**args)
except Exception as e:
result = f"Error executing tool '{
name}' in container: {e}\n" + traceback.format_exc()
# The tool result is the only thing on stdout; the host captures it.
sys.stdout.write(result if result is not None else "(no output)")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,189 @@
"""Append-only JSONL audit log for agent sessions.
Every event is written as one JSON object per line, with an auto-added
ISO timestamp and session id. The log is flushed after every record so
that a crash still leaves a complete trail. The file alone is enough
to reconstruct what the agent did, in order.
§2.4: tool results are truncated to ``MAX_RESULT_BYTES`` in the log to
prevent unbounded growth; a SHA-256 and full length are stored alongside
so the truncated entry is self-describing and tamper-evident.
§6.1: dedicated events for every decision step —
``log_permission_decision``, ``log_llm_request``, ``log_llm_response``,
``log_session_abort`` — give full forensic replay without guessing.
"""
import hashlib
import json
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
# Tool results larger than this are stored truncated in the audit log.
MAX_RESULT_BYTES = 8 * 1024
def _truncate_for_log(result: str) -> dict:
"""Return a dict describing *result*, truncating if large.
The dict always has ``size`` (bytes) and ``sha256``. If the result
is short enough it is included verbatim under ``result``; otherwise
only the first ``MAX_RESULT_BYTES`` are kept under ``result_truncated``.
"""
raw = result if isinstance(result, str) else str(result)
encoded = raw.encode("utf-8", errors="replace")
digest = hashlib.sha256(encoded).hexdigest()
size = len(encoded)
if size <= MAX_RESULT_BYTES:
return {"result": raw, "size": size, "sha256": digest}
truncated = encoded[:MAX_RESULT_BYTES].decode("utf-8", errors="replace")
return {
"result_truncated": truncated,
"truncated_from_size": size,
"sha256": digest,
}
class AuditLog:
"""Append-only JSONL audit log of agent activity."""
def __init__(self, log_dir: Path, session_id: str | None = None):
self.log_dir = Path(log_dir)
self.log_dir.mkdir(parents=True, exist_ok=True)
self.session_id = session_id or uuid.uuid4().hex[:12]
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
self.path = self.log_dir / f"audit-{self.session_id}-{ts}.jsonl"
self._fh = self.path.open("a", encoding="utf-8")
self.log("session_start", log_file=str(self.path))
def log(self, event: str, **fields: Any) -> None:
record = {
"ts": datetime.now(timezone.utc).isoformat(timespec="milliseconds"),
"session": self.session_id,
"event": event,
}
record.update(fields)
self._fh.write(json.dumps(record, ensure_ascii=False, default=str) + "\n")
self._fh.flush()
# -- convenience wrappers ------------------------------------------
def log_config(self, **fields: Any) -> None:
"""Record the session configuration (mode, sandbox root, etc.)."""
self.log("config", **fields)
def log_user_message(self, content: str) -> None:
self.log("user_message", content=content)
def log_assistant_message(self, content: str | None, tool_calls) -> None:
calls = []
if tool_calls:
for tc in tool_calls:
try:
args = json.loads(tc.function.arguments)
except Exception:
args = tc.function.arguments
calls.append({"id": tc.id, "name": tc.function.name, "args": args})
self.log("assistant_message", content=content, tool_calls=calls)
def log_tool_result(
self,
tool_call_id: str,
name: str,
args: dict,
permission_allowed: bool,
container_error: bool,
container_reason: str | None,
result: str,
intent_drift: bool = False,
intent_reason: str | None = None,
permission_reason: str | None = None,
) -> None:
self.log(
"tool_result",
tool_call_id=tool_call_id,
tool=name,
args=args,
permission_allowed=permission_allowed,
permission_reason=permission_reason,
container_error=container_error,
container_reason=container_reason,
intent_drift=intent_drift,
intent_reason=intent_reason,
**_truncate_for_log(result),
)
# -- §6.1 decision-step logging ----------------------------------
def log_permission_decision(
self,
tool: str,
args: dict,
mode: str,
allowed: bool,
reason: str | None = None,
) -> None:
"""Record a standalone permission decision (§6.1).
This is emitted *before* the tool runs (or is refused), so the
audit trail shows the decision and its reason even if the
subsequent tool execution crashes.
"""
self.log(
"permission_decision",
tool=tool,
args=args,
mode=mode,
allowed=allowed,
reason=reason,
)
def log_llm_request(
self,
model: str,
message_count: int,
token_estimate: int,
has_tools: bool,
) -> None:
"""Record that an LLM request is about to be sent (§6.1)."""
self.log(
"llm_request",
model=model,
message_count=message_count,
token_estimate=token_estimate,
has_tools=has_tools,
)
def log_llm_response(
self,
model: str,
finish_reason: str | None,
usage: dict | None = None,
message_hash: str | None = None,
tool_call_count: int = 0,
) -> None:
"""Record the LLM response metadata (§6.1).
``message_hash`` is a SHA-256 of the assistant message content
so the full conversation can be verified for forensic replay
without storing every token in the log.
"""
self.log(
"llm_response",
model=model,
finish_reason=finish_reason,
usage=usage,
message_hash=message_hash,
tool_call_count=tool_call_count,
)
def log_session_abort(self, reason: str) -> None:
"""Record that the session was aborted (§6.3)."""
self.log("session_abort", reason=reason)
def close(self) -> None:
self.log("session_end")
self._fh.close()

View File

@@ -0,0 +1,61 @@
import glob as glob_module
import re
from pathlib import Path
def read_file(path: str, offset: int = 1, limit: int = 200) -> str:
"""Read lines from a file, with optional offset and limit."""
p = Path(path)
if not p.exists():
return f"Error: file not found: {path}"
elif p.is_dir():
return f"Error: cannot read directory content using read_file for path: {path}"
else:
# Proceed with file reading logic
lines = p.read_text(errors="replace").splitlines()
selected = lines[offset - 1: offset - 1 + limit]
return "\n".join(f"{offset + i}: {line}" for i, line in enumerate(selected))
def glob_files(pattern: str, path: str = ".") -> str:
"""Find files matching a glob pattern inside a directory."""
matches = glob_module.glob(f"{path}/**/{pattern}", recursive=True)
matches += glob_module.glob(f"{path}/{pattern}")
unique = sorted(set(matches))
return "\n".join(unique) if unique else "(no matches)"
def grep(pattern: str, path: str = ".", include: str = "*") -> str:
"""Search file contents for a regex pattern, optionally filtering by filename glob."""
results = []
for filepath in glob_module.glob(f"{path}/**/{include}", recursive=True):
fp = Path(filepath)
if not fp.is_file():
continue
try:
for i, line in enumerate(fp.read_text(errors="replace").splitlines(), 1):
if re.search(pattern, line):
results.append(f"{filepath}:{i}: {line}")
except OSError:
pass
return "\n".join(results) if results else "(no matches)"
def write_file(path: str, content: str) -> str:
"""Write content to a file, creating it if it does not exist."""
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(content)
return f"Wrote {len(content)} bytes to {path}"
def edit_file(path: str, old_string: str, new_string: str) -> str:
"""Replace the first occurrence of old_string with new_string in a file."""
p = Path(path)
if not p.exists():
return f"Error: file not found: {path}"
original = p.read_text()
if old_string not in original:
return f"Error: string not found in {path}"
p.write_text(original.replace(old_string, new_string, 1))
return f"Edited {path}"

View File

@@ -0,0 +1,8 @@
def ask_question(question: str) -> str:
"""Ask the user a clarifying question and return their answer."""
print(f"\n [agent] {question}")
try:
answer = input(" Your answer: ").strip()
except EOFError:
return "(no answer — EOF)"
return answer if answer else "(no answer provided)"

View File

@@ -0,0 +1,282 @@
from tools.filesystem import read_file, glob_files, grep, write_file, edit_file
from tools.shell import run_bash
from tools.web import webfetch
from tools.todo import todo_append, todo_list, todo_update
from tools.scratchpad import read_scratchpad, write_scratchpad
from tools.interaction import ask_question
def get_tool_registry():
return {
"run_bash": run_bash,
"read_file": read_file,
"glob_files": glob_files,
"grep": grep,
"write_file": write_file,
"edit_file": edit_file,
"webfetch": webfetch,
"todo_append": todo_append,
"todo_list": todo_list,
"todo_update": todo_update,
"read_scratchpad": read_scratchpad,
"write_scratchpad": write_scratchpad,
"ask_question": ask_question,
}
def get_tool_schemas():
return [
{
"type": "function",
"function": {
"name": "run_bash",
"description": "Run a bash command on the user's machine and return the output.",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The bash command to execute.",
}
},
"required": ["command"],
},
},
},
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read lines from a file. Returns lines prefixed with line numbers.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Absolute or relative path to the file."},
"offset": {"type": "integer", "description": "First line to read (1-indexed). Defaults to 1."},
"limit": {"type": "integer", "description": "Maximum number of lines to return. Defaults to 200."},
},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "glob_files",
"description": "Find files matching a glob pattern (e.g. '**/*.py') inside a directory.",
"parameters": {
"type": "object",
"properties": {
"pattern": {"type": "string", "description": "Glob pattern to match against file names."},
"path": {"type": "string", "description": "Root directory to search in. Defaults to '.'."},
},
"required": ["pattern"],
},
},
},
{
"type": "function",
"function": {
"name": "grep",
"description": "Search file contents for a regex pattern and return matching lines with file paths and line numbers.",
"parameters": {
"type": "object",
"properties": {
"pattern": {"type": "string", "description": "Regular expression to search for."},
"path": {"type": "string", "description": "Directory to search in. Defaults to '.'."},
"include": {"type": "string", "description": "Filename glob to restrict which files are searched (e.g. '*.py'). Defaults to '*'."},
},
"required": ["pattern"],
},
},
},
{
"type": "function",
"function": {
"name": "write_file",
"description": "Write content to a file, creating it (and any missing parent directories) if it does not exist.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Path of the file to write."},
"content": {"type": "string", "description": "Full content to write to the file."},
},
"required": ["path", "content"],
},
},
},
{
"type": "function",
"function": {
"name": "edit_file",
"description": "Replace the first occurrence of a string in a file with a new string.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Path of the file to edit."},
"old_string": {"type": "string", "description": "Exact string to find and replace."},
"new_string": {"type": "string", "description": "String to replace it with."},
},
"required": ["path", "old_string", "new_string"],
},
},
},
{
"type": "function",
"function": {
"name": "webfetch",
"description": (
"Fetch a public URL (http/https only) and return its full plain-text content (up to 2 MB)."
),
"parameters": {
"type": "object",
"properties": {
"url": {"type": "string", "description": "The URL to fetch (http/https)."},
},
"required": ["url"],
},
},
},
{
"type": "function",
"function": {
"name": "todo_append",
"description": (
"Add a new item to the to-do list. "
"Use this to track a task you plan to work on."
),
"parameters": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Unique identifier for the item (e.g. '1', 'task-setup').",
},
"content": {
"type": "string",
"description": "Description of the task.",
},
"status": {
"type": "string",
"enum": ["pending", "in_progress", "done", "cancelled", "failed"],
"description": "Initial status of the item. Use 'pending' for new tasks.",
},
},
"required": ["id", "content", "status"],
},
},
},
{
"type": "function",
"function": {
"name": "todo_list",
"description": (
"Read the current to-do list. "
"By default shows all active items (pending, in_progress, failed). "
"Set include_completed=true to also see done and cancelled items. "
"Failed items display their retry count."
),
"parameters": {
"type": "object",
"properties": {
"include_completed": {
"type": "boolean",
"description": "If true, include done and cancelled items in the output. Defaults to false.",
},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "todo_update",
"description": (
"Update the content or status of an existing to-do item. "
"At least one of 'content' or 'status' must be provided. "
"Setting a failed item back to in_progress counts as a retry and "
"is tracked automatically. The response will warn when the retry "
"limit is reached."
),
"parameters": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "ID of the to-do item to update.",
},
"content": {
"type": "string",
"description": "New description for the item. Omit to leave unchanged.",
},
"status": {
"type": "string",
"enum": ["pending", "in_progress", "done", "cancelled", "failed"],
"description": "New status for the item. Omit to leave unchanged.",
},
},
"required": ["id"],
},
},
},
{
"type": "function",
"function": {
"name": "read_scratchpad",
"description": (
"Read the current contents of the in-memory scratchpad. "
"Returns '(empty)' if nothing has been written yet."
),
"parameters": {
"type": "object",
"properties": {},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "write_scratchpad",
"description": (
"Overwrite the entire contents of the in-memory scratchpad with new content. "
"The previous content is permanently replaced."
),
"parameters": {
"type": "object",
"properties": {
"content": {
"type": "string",
"description": "The new content to store in the scratchpad.",
},
},
"required": ["content"],
},
},
},
{
"type": "function",
"function": {
"name": "ask_question",
"description": (
"Ask the user a clarifying question and wait for their answer. "
"Use this when you are missing information required to complete the task "
"and cannot reasonably infer it from context. "
"Ask one focused question at a time. "
"Do not use this for progress updates or to confirm actions you can already "
"take — only ask when you are genuinely blocked."
),
"parameters": {
"type": "object",
"properties": {
"question": {
"type": "string",
"description": "The question to ask the user.",
},
},
"required": ["question"],
},
},
},
]

View File

@@ -0,0 +1,203 @@
"""Docker-based sandbox for tool execution.
Instead of confining tools with in-process path checks and a command
denylist (which is only as strong as the checks we remember to write),
the action tools run inside a long-lived Docker container. The user's
project is bind-mounted into the container; everything outside that
mount is the container's own minimal filesystem and is invisible or
read-only to the tool. Network egress can be disabled entirely with
``--network none``.
The container is started once per session and reused for every action
tool call (via ``docker exec``) to avoid per-call startup latency.
In-memory planning tools (todo, scratchpad, ask_question) are **not**
run in the container — their state would not survive between separate
``docker exec`` processes — so they stay in-process on the host.
This module requires the Docker CLI on the host. On first use the
``agent-security-runner`` image is built automatically from the
``Dockerfile`` next to this package.
"""
import json
import os
import subprocess
import uuid
from pathlib import Path
class DockerSandboxError(Exception):
"""Raised when the sandbox container cannot be used to run a tool."""
# Tools that touch the outside world and therefore run in the container.
ACTION_TOOLS = {
"read_file",
"glob_files",
"grep",
"write_file",
"edit_file",
"run_bash",
"webfetch",
}
DEFAULT_IMAGE = "agent-security-runner"
# §4.3 Default per-tool-call timeout. 30 minutes was far too generous
# and let a hanging command block the whole session. Lowered to 120 s
# with a --tool-timeout CLI override.
EXEC_TIMEOUT_S = 120
def _docker_available() -> bool:
return subprocess.run(
["docker", "info"], capture_output=True
).returncode == 0
class DockerSandbox:
"""Manage a long-lived container that executes action tool calls."""
def __init__(
self,
project_root: Path,
tools_dir: Path,
network: str = "bridge",
image: str = DEFAULT_IMAGE,
build_context: Path | None = None,
exec_timeout: float = EXEC_TIMEOUT_S,
container_env: dict | None = None,
):
if not _docker_available():
raise DockerSandboxError(
"Docker is not available on the host. Install Docker (or "
"Podman aliased as docker) and ensure the daemon is running."
)
self.project_root = Path(project_root).resolve()
if not self.project_root.is_dir():
raise DockerSandboxError(
f"Project root is not a directory: {self.project_root}"
)
self.tools_dir = Path(tools_dir).resolve()
self.build_context = Path(build_context or self.tools_dir.parent).resolve()
self.image = image
self.network = network
self.exec_timeout = float(exec_timeout)
# §5.2: the harness — not the model — controls the container env.
# Only an allowlist of vars is inherited from the host; secret-
# looking env vars are stripped before the container ever starts.
self.container_env = container_env or {}
self.container = f"agent-sandbox-{uuid.uuid4().hex[:8]}"
self._ensure_image()
self._start_container()
# -- image ----------------------------------------------------------
def _ensure_image(self) -> None:
inspect = subprocess.run(
["docker", "image", "inspect", self.image],
capture_output=True,
)
if inspect.returncode == 0:
return
dockerfile = self.build_context / "Dockerfile"
if not dockerfile.exists():
raise DockerSandboxError(
f"Cannot build sandbox image: Dockerfile not found at {dockerfile}."
)
print(f" [sandbox] building image '{self.image}' (one-time)...")
build = subprocess.run(
["docker", "build", "-t", self.image, str(self.build_context)],
)
if build.returncode != 0:
raise DockerSandboxError(
f"Failed to build sandbox image '{self.image}' "
f"(docker build exited {build.returncode})."
)
# -- container lifecycle -------------------------------------------
def _start_container(self) -> None:
uid = os.getuid() if hasattr(os, "getuid") else 0
gid = os.getgid() if hasattr(os, "getgid") else 0
cmd = [
"docker", "run", "-d",
"--name", self.container,
"--network", self.network,
"--user", f"{uid}:{gid}",
# Mount the project at the same absolute path so paths the
# agent reports match between host and container.
"-v", f"{self.project_root}:{self.project_root}",
# Mount the tool implementations read-only.
"-v", f"{self.tools_dir}:/agent_tools:ro",
"-w", str(self.project_root),
]
# §5.2 Credential injection at the harness level: only the
# allowlisted env vars (set by the harness, never by the model)
# are passed to the container. Host credentials are stripped.
for name, value in self.container_env.items():
cmd.extend(["-e", f"{name}={value}"])
cmd.extend([
"--rm",
self.image,
"sleep", "infinity",
])
run = subprocess.run(cmd, capture_output=True, text=True)
if run.returncode != 0:
raise DockerSandboxError(
f"Could not start sandbox container: {run.stderr.strip() or run.stdout.strip()}"
)
# Sanity check: confirm the container is actually running.
ps = subprocess.run(
["docker", "inspect", "-f", "{{.State.Running}}", self.container],
capture_output=True, text=True,
)
if ps.returncode != 0 or ps.stdout.strip() != "true":
raise DockerSandboxError(
f"Sandbox container '{self.container}' is not running after start."
)
# -- tool execution ------------------------------------------------
def run_tool(self, name: str, args: dict) -> str:
"""Execute *name* with *args* inside the container, return its output.
§4.3: enforces a per-call timeout (``self.exec_timeout``). A
timeout is reported back as a ``DockerSandboxError`` with a
clear message so the LLM knows not to retry blindly.
"""
try:
proc = subprocess.run(
[
"docker", "exec", "-i",
self.container,
"python", "/agent_tools/_dispatch.py", name,
],
input=json.dumps(args),
capture_output=True,
text=True,
timeout=self.exec_timeout,
)
except subprocess.TimeoutExpired:
raise DockerSandboxError(
f"Tool '{name}' timed out after {self.exec_timeout:.0f}s. "
"The command did not finish in the allowed time. Do not "
"retry the same call — adjust the approach or ask the user."
)
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "").strip()
raise DockerSandboxError(
f"Container exec for '{name}' failed (exit {proc.returncode}): {err}"
)
return proc.stdout
def close(self) -> None:
subprocess.run(
["docker", "rm", "-f", self.container], capture_output=True
)

View File

@@ -0,0 +1,31 @@
class Scratchpad:
"""Read and write from a in-memory scratchpad"""
def __init__(self):
self._content = ""
def read(self) -> str:
if self._content == "":
return "(empty)"
return self._content
def write(self, content: str) -> str:
self._content = str(content).strip()
return self._content
scratchpad = Scratchpad()
def read_scratchpad():
"""Read the contents of the scratchpad"""
return scratchpad.read()
def write_scratchpad(content: str):
"""
Write into the scratchpad. The previous content
will be overwritten.
"""
scratchpad.write(content)
return "Successfully written content into scratchpad"

View File

@@ -0,0 +1,12 @@
import subprocess
def run_bash(command: str) -> str:
"""Run a bash command and return its output."""
result = subprocess.run(
command, shell=True, text=True, capture_output=True
)
output = result.stdout
if result.stderr:
output += f"\nSTDERR:\n{result.stderr}"
return output or "(no output)"

View File

@@ -0,0 +1,116 @@
RETRY_LIMIT = 3
class ToDoList:
"""
Helper class to hold a to-do list in memory
"""
statuses = ["pending", "in_progress", "done", "cancelled", "failed"]
def __init__(self):
self._items = []
def read(self, include_completed=False):
"""Read the to-do list"""
if include_completed:
return [item.copy() for item in self._items]
else:
return [item.copy() for item in self._items
if item["status"] != "done" and item["status"] != "cancelled"]
def append(self, id, content, status):
if status not in ToDoList.statuses:
raise Exception(f"Invalid status {status}. "
"Valid to-do statuses: pending, in_progress, done, "
"cancelled, failed")
if self.contains(id):
raise Exception(f"To do item {id} already exists!")
new_item = {"id": id, "content": content,
"status": status, "retries": 0}
self._items.append(new_item)
return new_item.copy()
def contains(self, id) -> bool:
"""Check if the to do list contains an item with a specific id"""
for item in self._items:
if item["id"] == id:
return True
return False
def update(self, id, content, status):
if status is not None and status not in ToDoList.statuses:
raise Exception(f"Invalid status {status}. "
"Valid to-do statuses: pending, in_progress, done, "
"cancelled, failed")
idx = 0
while idx < len(self._items):
if self._items[idx]["id"] == id:
if content is not None:
self._items[idx]["content"] = content
if status is not None:
prev_status = self._items[idx]["status"]
self._items[idx]["status"] = status
# A failed task being set back to in_progress is a retry attempt.
if prev_status == "failed" and status == "in_progress":
self._items[idx]["retries"] += 1
return self._items[idx].copy()
idx += 1
raise Exception(f"To do item with id {id} not found")
todo_store = ToDoList()
def todo_append(id, content, status) -> str:
"""Append a new to do item to the to do list"""
id_str = str(id)
content_str = str(content)
status_str = str(status)
try:
todo_store.append(id_str, content_str, status_str)
return f"Successfully appended to do item {id_str} in to do list!"
except Exception as e:
return f"Failed to append to do item: {e}"
def todo_list(include_completed=False) -> str:
"""List all the items in the to do list"""
items = todo_store.read(include_completed)
result = f"To Do List ({len(items)} items)\n"
for status in ToDoList.statuses:
count = sum(1 for i in items if i["status"] == status)
result += f"{count} {status} items\n"
result += "-----\n"
for item in items:
retry_note = f", {item['retries']
} retries" if item["retries"] > 0 else ""
result += f"- [{item['id']}] {item['content']
} ({item['status']}{retry_note})\n"
return result
def todo_update(id, content=None, status=None) -> str:
if content is None and status is None:
return "No content or status was given to update. Nothing to do."
try:
item = todo_store.update(id, content, status)
retries = item["retries"]
if item["status"] == "in_progress" and retries > 0:
if retries >= RETRY_LIMIT:
return (
f"Updated to do item {id} to in_progress — "
f"but this is retry {retries} of {
RETRY_LIMIT} (retry limit reached). "
f"Do not retry again. Escalate to the user instead."
)
return (
f"Successfully updated to do item {id}! "
f"Retry attempt {retries} of {RETRY_LIMIT}."
)
return f"Successfully updated to do item {id}!"
except Exception as e:
return f"Failed to update to do item {id}: {e}"

View File

@@ -0,0 +1,205 @@
"""Lightweight JSON-Schema validator for tool inputs (checklist §3.1, §3.3).
We deliberately avoid a third-party dependency (``jsonschema`` /
``pydantic``) so the host-side validation needs no new install and no
Docker image rebuild. This validator implements the small subset of
JSON-Schema Draft 7 actually used by ``tools/registry.get_tool_schemas``:
- ``type`` (object, string, integer, boolean)
- ``required``
- ``properties``
- ``enum``
- ``minimum`` / ``maximum``
- ``minLength`` / ``maxLength``
§3.3 (limit output scope) is enforced by the same schemas: bounds on
``offset``, ``limit``, ``command`` length, ``content`` length, and a
rejection of absolute-path glob patterns are baked into the schemas and
therefore checked here.
The validator returns ``(ok, errors)`` where ``errors`` is a list of
human-readable strings suitable for surfacing back to the LLM.
"""
from __future__ import annotations
import json
from typing import Any
class ValidationError(Exception):
"""Raised when a tool's args fail schema validation."""
def __init__(self, errors: list[str]):
super().__init__("; ".join(errors))
self.errors = errors
def _check_type(value: Any, expected: str) -> str | None:
if expected == "object":
if not isinstance(value, dict):
return f"expected object, got {type(value).__name__}"
elif expected == "string":
if not isinstance(value, str):
return f"expected string, got {type(value).__name__}"
elif expected == "integer":
# bool is a subclass of int — reject it explicitly.
if isinstance(value, bool) or not isinstance(value, int):
return f"expected integer, got {type(value).__name__}"
elif expected == "boolean":
if not isinstance(value, bool):
return f"expected boolean, got {type(value).__name__}"
else:
return f"unknown type '{expected}'"
return None
def validate_args(args: dict[str, Any], schema: dict) -> tuple[bool, list[str]]:
"""Validate *args* against a tool's JSON-Schema function spec.
``schema`` is the inner ``{"type": "object", "properties": ...}``
dict — i.e. ``tool["function"]["parameters"]``.
Returns ``(ok, errors)``.
"""
errs: list[str] = []
# Top-level type check.
if "type" in schema and schema["type"] != "object":
msg = _check_type(args, schema["type"])
if msg:
return False, [msg]
# required fields.
required = schema.get("required", [])
for field in required:
if field not in args:
errs.append(f"missing required field '{field}'")
properties = schema.get("properties", {})
for name, value in args.items():
if name not in properties:
# Extra unknown fields are reported (strict mode). The LLM
# should not invent parameters the schema doesn't list.
errs.append(f"unknown field '{name}'")
continue
errs.extend(_validate_value(value, properties[name], name))
return (len(errs) == 0), errs
# ---------------------------------------------------------------------------
# Bounded schemas (§3.3 limit output scope)
# ---------------------------------------------------------------------------
def bounded_schemas(raw_schemas: list[dict]) -> list[dict]:
"""Return a copy of *raw_schemas* with §3.3 bounds injected.
We mutate copies of the per-tool parameter schemas to add:
- ``read_file.offset``: minimum 1, maximum 1000000
- ``read_file.limit``: minimum 1, maximum 2000
- ``write_file.content``: maxLength 1_048_576 (1 MB)
- ``edit_file.old_string`` / ``new_string``: maxLength 1_048_576
- ``run_bash.command``: minLength 1, maxLength 4096
- ``webfetch.url``: maxLength 4096
- ``glob_files.pattern``: reject absolute paths (pattern check
implemented in ``_validate_value`` via a custom constraint —
here we add ``format: relative-path`` which our validator
treats specially).
The bounds are conservative defaults; they can be tuned without
touching the validator.
"""
out: list[dict] = []
for entry in raw_schemas:
fn = entry["function"]
params = json.loads(json.dumps(fn["parameters"])) # deep copy
name = fn["name"]
props = params.setdefault("properties", {})
if name == "read_file":
props.setdefault("offset", {}).setdefault("minimum", 1)
props["offset"]["maximum"] = 1000000
props.setdefault("limit", {}).setdefault("minimum", 1)
props["limit"]["maximum"] = 2000
elif name in ("write_file", "edit_file"):
for field in ("content", "old_string", "new_string"):
if field in props:
props[field]["maxLength"] = 1_048_576 # 1 MB
elif name == "run_bash":
props.setdefault("command", {}).setdefault("minLength", 1)
props["command"]["maxLength"] = 4096
elif name == "webfetch":
props.setdefault("url", {}).setdefault("maxLength", 4096)
elif name == "glob_files":
# Custom constraint enforced by the validator: reject
# patterns that start with "/" (absolute path) since those
# would escape the working-dir scoping.
props.setdefault("pattern", {})["format"] = "relative-path"
out.append({"type": "function", "function": {**fn, "parameters": params}})
return out
# ---------------------------------------------------------------------------
# Validator registry
# ---------------------------------------------------------------------------
class ToolValidator:
"""Validate tool call arguments against their schemas."""
def __init__(self, schemas: list[dict]):
self._params: dict[str, dict] = {}
for entry in schemas:
fn = entry["function"]
self._params[fn["name"]] = fn["parameters"]
def validate(self, tool_name: str, args: dict[str, Any]) -> tuple[bool, list[str]]:
schema = self._params.get(tool_name)
if schema is None:
return False, [f"unknown tool '{tool_name}'"]
return validate_args(args, schema)
def _validate_value(value: Any, schema: dict, path: str) -> list[str]:
"""Validate a single value against its property schema."""
errs: list[str] = []
if "type" in schema:
msg = _check_type(value, schema["type"])
if msg:
errs.append(f"{path}: {msg}")
return errs
if "enum" in schema and value not in schema["enum"]:
errs.append(f"{path}: '{value}' is not one of {schema['enum']}")
if schema.get("type") == "string":
if "minLength" in schema and len(value) < schema["minLength"]:
errs.append(f"{path}: length {len(value)} < minLength {schema['minLength']}")
if "maxLength" in schema and len(value) > schema["maxLength"]:
errs.append(f"{path}: length {len(value)} > maxLength {schema['maxLength']}")
# §3.3 custom format: relative-path (reject absolute glob patterns).
if schema.get("format") == "relative-path" and value.startswith("/"):
errs.append(f"{path}: absolute paths are not allowed here (must be relative)")
if schema.get("type") == "integer":
if "minimum" in schema and value < schema["minimum"]:
errs.append(f"{path}: {value} < minimum {schema['minimum']}")
if "maximum" in schema and value > schema["maximum"]:
errs.append(f"{path}: {value} > maximum {schema['maximum']}")
return errs
__all__ = [
"ValidationError",
"validate_args",
"bounded_schemas",
"ToolValidator",
]

View File

@@ -0,0 +1,41 @@
import re
import urllib.request
from urllib.parse import urlparse
from bs4 import BeautifulSoup
def webfetch(url: str) -> str:
"""Fetch a URL and return its full plain-text content (up to 2 MB)."""
try:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
return f"Error fetching {url}: unsupported scheme '{parsed.scheme}'. Only http and https are allowed."
max_bytes = 2 * 1024 * 1024
req = urllib.request.Request(url, headers={"User-Agent": "agent/1.0"})
with urllib.request.urlopen(req, timeout=15) as resp:
content_type = resp.headers.get_content_type()
if content_type and content_type not in (
"text/html",
"text/plain",
"application/xhtml+xml",
):
return f"Error fetching {url}: unsupported content type '{content_type}'."
charset = resp.headers.get_content_charset() or "utf-8"
raw_chunks = []
total = 0
while True:
chunk = resp.read(65536)
if not chunk:
break
total += len(chunk)
if total > max_bytes:
raw_chunks.append(chunk[: max_bytes - (total - len(chunk))])
break
raw_chunks.append(chunk)
raw = b"".join(raw_chunks).decode(charset, errors="replace")
soup = BeautifulSoup(raw, "html.parser")
text = soup.get_text(separator="\n", strip=True)
return re.sub(r"\n{3,}", "\n\n", text).strip()
except Exception as e:
return f"Error fetching {url}: {e}"