Add Handbook.md benchmark tasks

This commit is contained in:
DerekSurge
2026-06-24 12:44:34 -07:00
commit 25c9eda5ca
1800 changed files with 323575 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
"""Proxy auth token capture for core.
The proxy injects ``MCP_PROXY_TOKEN`` into core's env so the viewer's
``ProxyTokenMiddleware`` can authenticate inbound non-MCP requests.
This code captures the token at import time, makes it available via ``get_proxy_token()``,
and pops it from ``os.environ`` with ``@capture_proxy_token``, so it doesn't reach the agent's bash/python.
"""
from __future__ import annotations
import os
from collections.abc import Callable
from functools import wraps
_TOKEN: str = ""
def capture_proxy_token[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
"""Decorator: before running ``fn``, pop ``MCP_PROXY_TOKEN`` from
``os.environ`` and stash it for later reads via :func:`get_proxy_token`.
Apply this to ``core.server.main()`` — the first thing that runs
when core starts. The pop happens before any tool can be invoked
so subprocesses can't recover the token from ``/proc/$PPID/environ``.
"""
@wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
global _TOKEN
_TOKEN = os.environ.pop("MCP_PROXY_TOKEN", "")
return fn(*args, **kwargs)
return wrapper
def get_proxy_token() -> str:
"""Return the captured proxy auth token, or ``""`` if not yet captured."""
return _TOKEN
@@ -0,0 +1,59 @@
"""Boot-time guard: every registered MCP tool must be async.
Sync (`def`) tools make FastMCP run pydantic argument validation in an anyio
worker threadpool. Under concurrent calls the shared pydantic-core validator is
re-entered across threads and panics with ``pyo3_runtime.PanicException:
dictionary changed size during iteration``, which tears down the
StreamableHTTP task group and turns every later request into a 500. Async
(`async def`) tools validate on the single-threaded event loop and are safe.
This guard runs at server boot (and therefore during ``mcp-proxy gen``, which
boots every server): a non-conformant package fails fast instead of shipping a
server that can crash under load. The same check is intentionally duplicated in
each package because the bundled prod images install only that package's own
dependencies, so there is no shared runtime module to import.
"""
from __future__ import annotations
import asyncio
import inspect
from functools import partial
from typing import Any
def _unwrap(fn: Any) -> Any:
while isinstance(fn, partial):
fn = fn.func
return fn
def find_sync_tools(mcp: Any) -> list[str]:
"""Return the names of registered tools whose function is not a coroutine."""
# mcp.server.fastmcp.FastMCP exposes a synchronous tool manager.
manager = getattr(mcp, "_tool_manager", None)
if manager is not None and hasattr(manager, "list_tools"):
return sorted(t.name for t in manager.list_tools() if not inspect.iscoroutinefunction(_unwrap(t.fn)))
# fastmcp.FastMCP exposes an async API.
async def _collect() -> list[str]:
sync: list[str] = []
for descriptor in await mcp.list_tools():
tool = await mcp.get_tool(descriptor.name)
if not inspect.iscoroutinefunction(_unwrap(tool.fn)):
sync.append(descriptor.name)
return sorted(sync)
return asyncio.run(_collect())
def assert_tools_async(mcp: Any) -> None:
"""Raise if any registered tool is synchronous."""
sync = find_sync_tools(mcp)
if sync:
raise RuntimeError(
"MCP tools must be async (`async def`). Sync tools run pydantic argument "
"validation in a worker threadpool and can trigger a pydantic-core "
"'dictionary changed size during iteration' panic under concurrent calls, "
"which kills the server. Make these tools async: " + ", ".join(sync)
)
+95
View File
@@ -0,0 +1,95 @@
"""Workdir/ownership helpers for core's root-server + per-exec-drop model.
Configured via ``packages/core/mcp.json``'s ``env`` block:
"env": {"SETUID": "1000", "SETGID": "1000", "HOME": "/home/model"}
**The core server process stays root.** Sibling servers (slack, jira,
google_mail, grading, …) also run as root because their tools just call mocked
APIs over HTTP — no shell, no file IO, no adversarial surface. Core is
different: it hands the agent ``bash``, so each of *those commands* is dropped
to ``SETUID``/``SETGID`` at spawn time
(``core.tools.sandbox._privilege_drop_kwargs``), while the server itself
keeps root. Keeping the server root (rather than dropping the whole process to
uid 1000, as it used to) is what lets the Dockerfile close ``/opt/venv`` to uid
1000: the server reads its venv as root, and the agent — a *different* effective
uid per command — cannot. ``/app`` stays sealed to ``0700 root:root`` and the
venv lives outside ``/app`` at ``/opt/venv``. See ``docker/base/Dockerfile`` and
the README's "Sandbox model" section.
This module keeps the bits that still run in the root server: provisioning
``WORKDIR`` and restoring ownership of files copied out of the root-owned source
tree into the model-owned workdir. The actual privilege drop now happens
per-command in ``sandbox`` — there is intentionally no function here that drops
the whole process.
When the process isn't root (local dev, CI, tests) these helpers are no-ops:
they can't ``mkdir`` at ``/`` or ``chown``, and the same ``mcp.json`` ships
``SETUID``/``SETGID`` everywhere harmlessly.
"""
from __future__ import annotations
import os
from core.tools import sandbox
def _target_ids() -> tuple[int, int] | None:
"""Return ``(uid, gid)`` from ``SETUID``/``SETGID`` env, or ``None`` if neither is set.
A missing one becomes ``-1`` (the ``os.chown`` sentinel for "leave unchanged").
"""
setuid = os.environ.get("SETUID")
setgid = os.environ.get("SETGID")
if setuid is None and setgid is None:
return None
uid = int(setuid) if setuid is not None else -1
gid = int(setgid) if setgid is not None else -1
return uid, gid
def ensure_workdir() -> None:
"""Make sure WORKDIR exists with the right owner BEFORE we drop privileges.
Some deploy environments don't pre-provision /workdir (the Dockerfile +
start.sh do, but bundle launchers / Modal workflows may not). The setup
hook needs to write into it, and the server needs to chdir there — so we
create it as root and chown to SETUID/SETGID before the drop, since uid
1000 can't mkdir at /.
Non-root callers (CI, local dev, tests) can't mkdir at / and can't chown,
so this is a no-op — WORKDIR there is either pre-provisioned (Docker, test
fixtures pointing at tmp dirs) or genuinely absent, in which case any
caller that actually needs the dir will fail loudly on its own.
"""
if os.geteuid() != 0:
return
workdir = sandbox.WORKDIR
os.makedirs(workdir, exist_ok=True)
ids = _target_ids()
if ids is None:
return
os.chown(workdir, *ids)
def chown_tree_to_target(path: str | os.PathLike[str]) -> None:
"""Recursively chown ``path`` to ``SETUID``/``SETGID``. No-op when not root.
Used by the setup hook after copying root-owned source files into
model-owned WORKDIR — ``shutil.copytree`` runs as root (so it can read
locked-down sources) and the new entries inherit root ownership; this
restores them to the unprivileged target user so the model can edit them.
Symlinks are chowned without following.
"""
if os.geteuid() != 0:
return
ids = _target_ids()
if ids is None:
return
uid, gid = ids
root_str = os.fspath(path)
os.chown(root_str, uid, gid, follow_symlinks=False)
for root, dirs, files in os.walk(root_str):
for name in dirs + files:
os.chown(os.path.join(root, name), uid, gid, follow_symlinks=False)
+56
View File
@@ -0,0 +1,56 @@
"""Core standalone MCP server entry point.
This module exposes core's tools as an MCP server that can be launched as a
subprocess and proxied by mcp_proxy or any other MCP proxy.
"""
import os
from fastmcp import FastMCP
from fastmcp.tools.function_tool import FunctionTool
import core.tools as tools
from core._token import capture_proxy_token
from core.async_tool_guard import assert_tools_async
from core.privilege import ensure_workdir
from core.tools import sandbox
from core.viewer import run_http_server
def build_app() -> FastMCP:
"""Build a FastMCP app exposing all core tools."""
app = FastMCP("core")
for tool_name in sorted(tools.__all__):
tool_fn = getattr(tools, tool_name, None)
if callable(tool_fn):
# output_schema=None matches the shipped mcp-tools.generated.json,
# which has never advertised output schemas.
app.add_tool(FunctionTool.from_function(fn=tool_fn, name=tool_name, output_schema=None))
return app
@capture_proxy_token
def main() -> None:
# The server intentionally keeps running as root: it needs to read the
# locked-down /app tree, and a root server lets us close /opt/venv to uid
# 1000. Privilege is dropped per agent command instead — see
# core.tools.sandbox._privilege_drop_kwargs (the chokepoint every
# bash/file-tool subprocess flows through).
ensure_workdir()
# Land the running server in the agent's workdir so any tool that doesn't
# pass cwd= explicitly resolves relative paths under /workdir. Skip when
# the dir doesn't exist (CI / local dev where /workdir isn't provisioned).
if os.path.isdir(sandbox.WORKDIR):
os.chdir(sandbox.WORKDIR)
app = build_app()
assert_tools_async(app)
port = os.environ.get("PORT")
if port:
run_http_server(app, int(port))
else:
app.run(show_banner=False)
if __name__ == "__main__":
main()
+130
View File
@@ -0,0 +1,130 @@
"""Copy uploaded context files into the agent's workdir.
Lookup order for the source directory:
1. Unified bundle: $BUNDLEDIR/files/ (when a trajectory bundle is mounted)
2. Task-specific: {WORLDBENCH_ROOT}/tasks/setup_data/{WORLDBENCH_TASK_ID}/
3. Generic: {WORLDBENCH_ROOT}/setup_data/
BUNDLEDIR is set by the parent process (``scripts/start.sh`` for local
dev, the production harness in production) and points at the unpacked bundle root.
For (2)/(3) the source files live under a ``files/`` subdirectory; for (1)
the bundle root already has ``files/`` as a peer of ``services/``, ``memory/``,
and ``meta/``. In all cases, the contents of ``files/`` are copied into
``sandbox.WORKDIR`` (``/workdir``).
"""
import os
import shutil
import sys
from pathlib import Path
from core.privilege import chown_tree_to_target, ensure_workdir
from core.tools import sandbox
def _resolve_files_dir(world_root: Path, task_id: str | None) -> Path | None:
"""Return the source ``files/`` directory, or None if no setup data exists."""
bundle_dir = os.environ.get("BUNDLEDIR")
if bundle_dir:
bundle_files = Path(bundle_dir) / "files"
if bundle_files.is_dir():
return bundle_files
task_setup_dir = world_root / "tasks" / "setup_data" / task_id if task_id else None
if task_setup_dir and task_setup_dir.exists():
setup_dir = task_setup_dir
else:
setup_dir = world_root / "setup_data"
if not setup_dir.exists():
return None
files_dir = setup_dir / "files"
return files_dir if files_dir.is_dir() else None
def main() -> None:
# Setup runs entirely as root: the source tree (e.g. /app/setup_data/files/,
# extracted by the production harness as root) is not readable by uid 1000, so we copy
# before dropping privileges and then chown the result to SETUID/SETGID so
# the model user can modify it. This script then exits — the actual MCP
# server is a separate process invocation that does its own privilege drop.
ensure_workdir()
task_id = os.environ.get("WORLDBENCH_TASK_ID")
world_root = Path(os.environ.get("WORLDBENCH_ROOT", os.getcwd()))
files_dir = _resolve_files_dir(world_root, task_id)
if files_dir is None:
print("No setup data found, skipping")
sys.exit(0)
# Refuse symlinks and hardlinks anywhere in the source tree, and refuse
# symlinks already present at the destination. We run as root here (so
# we can read locked-down sources the model user can't see) and chown
# the destination to uid 1000 afterwards. Three ways the protected
# tree could be leaked back to the model otherwise:
# 1. Source symlink `files/rubrics -> /app/tasks` — shutil.copytree's
# default `symlinks=False` dereferences it and materializes the
# target into /workdir.
# 2. Source hardlink `files/foo` to `/app/packages/grading/x.py` —
# copytree sees a regular file with the same inode and writes a
# new copy into /workdir.
# 3. Destination symlink `/workdir/context -> /app/packages/grading`
# planted by a prior model run — copytree's `dirs_exist_ok=True`
# follows it and writes bundle contents *into* the protected
# directory as root.
workdir = Path(sandbox.WORKDIR)
_reject_unsafe_source(files_dir)
workdir.mkdir(parents=True, exist_ok=True)
_reject_dest_symlinks(workdir)
shutil.copytree(files_dir, workdir, dirs_exist_ok=True)
chown_tree_to_target(workdir)
print(f"Copied files from {files_dir} to {workdir}")
def _reject_unsafe_source(root: Path) -> None:
"""Exit non-zero if any entry under ``root`` is a symlink or a hardlink.
Hardlinks are detected via ``st_nlink > 1`` on regular files. Files in
the bundle should be unique copies; multi-link inodes mean the bundle
points at something we didn't create, which might be a protected path
on the same filesystem.
"""
if root.is_symlink():
print(f"Refusing to copy symlinked setup root: {root}", file=sys.stderr)
sys.exit(1)
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
for name in dirnames + filenames:
entry = Path(dirpath) / name
if entry.is_symlink():
print(f"Refusing to copy symlink in setup data: {entry}", file=sys.stderr)
sys.exit(1)
for name in filenames:
entry = Path(dirpath) / name
# lstat — don't follow symlinks (already rejected above, but be defensive).
st = entry.lstat()
if st.st_nlink > 1:
print(f"Refusing to copy hardlinked file in setup data: {entry}", file=sys.stderr)
sys.exit(1)
def _reject_dest_symlinks(root: Path) -> None:
"""Exit non-zero if any entry under destination ``root`` is a symlink.
A symlink planted in /workdir by a prior model run would let
``copytree(..., dirs_exist_ok=True)`` write through it as root, into
paths the model user can't normally write — or, after chown, read.
"""
if root.is_symlink():
print(f"Refusing to copy into symlinked workdir: {root}", file=sys.stderr)
sys.exit(1)
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
for name in dirnames + filenames:
entry = Path(dirpath) / name
if entry.is_symlink():
print(f"Refusing to copy over symlink in workdir: {entry}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
+27
View File
@@ -0,0 +1,27 @@
"""State codec for core.
Syntara on main is file-sandbox-backed — no database entities to snapshot.
``export_state``/``import_state`` exist for uniformity with the other MCP
servers (the proxy validates every server has them); round-trip is
trivially empty.
"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, ConfigDict
class SyntaraState(BaseModel):
"""Empty state — core has no DB to snapshot."""
model_config = ConfigDict(extra="allow")
def state_to_json() -> dict[str, Any]:
return {}
def state_from_json(data: dict[str, Any]) -> None:
_ = data
@@ -0,0 +1,22 @@
from .bash import bash
from .echo import echo
from .list_files import listFiles
from .prepare_grading_context import prepareGradingContext
from .read_file import readFile
from .read_media import readMedia
from .read_pdf import readPDF
from .state import export_state, import_state
from .write_file import writeFile
__all__ = [
"bash",
"echo",
"export_state",
"import_state",
"listFiles",
"prepareGradingContext",
"readFile",
"readMedia",
"readPDF",
"writeFile",
]
+25
View File
@@ -0,0 +1,25 @@
import asyncio
from typing import Annotated, Any
from pydantic import Field
from core.tools.sandbox import DEFAULT_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS, run_in_sandbox
async def bash(
command: Annotated[str, Field(description="The bash command to execute")],
timeout_seconds: Annotated[
int | None,
Field(
description=f"Timeout in seconds (default {DEFAULT_TIMEOUT_SECONDS}, max {MAX_TIMEOUT_SECONDS})",
ge=1,
le=MAX_TIMEOUT_SECONDS,
),
] = None,
) -> dict[str, Any]:
"""Execute a bash command in an isolated directory."""
timeout = timeout_seconds or DEFAULT_TIMEOUT_SECONDS
# run_in_sandbox blocks (subprocess.run) for up to `timeout` seconds; offload
# to a worker thread so a slow command doesn't stall the event loop and block
# other concurrent tool calls. Validation already ran on the loop.
return await asyncio.to_thread(run_in_sandbox, ["bash", "-c", command], timeout)
+8
View File
@@ -0,0 +1,8 @@
from typing import Annotated
from pydantic import Field
async def echo(message: Annotated[str, Field(description="The message to echo")]) -> str:
"""Echoes a message"""
return message + message
@@ -0,0 +1,80 @@
import asyncio
from typing import Annotated, Any
from pydantic import Field
from core.tools.sandbox import DEFAULT_TIMEOUT_SECONDS, run_in_sandbox
# Emit NUL-terminated records "d <name>\0" or "f <name>\0". NUL separation
# preserves filenames containing newlines, which are valid POSIX. dotglob
# includes hidden files (matching os.listdir); nullglob makes empty dirs
# produce no output.
_LIST_SCRIPT = (
"shopt -s dotglob nullglob; "
'if [ ! -e "$1" ]; then printf "No such directory: %s" "$1" >&2; exit 1; fi; '
'if [ ! -d "$1" ]; then printf "Not a directory: %s" "$1" >&2; exit 1; fi; '
'cd -- "$1" || exit 1; '
"for e in *; do "
'if [ -d "$e" ]; then printf "d %s\\0" "$e"; '
'else printf "f %s\\0" "$e"; fi; '
"done"
)
async def listFiles(
directory: Annotated[
str,
Field(
description=(
"Directory to list, relative to the sandbox root. Use /workdir/ prefix or an "
"absolute path within the sandbox to be explicit. Defaults to the sandbox root."
),
),
] = ".",
) -> dict[str, Any]:
"""List files and subdirectories in a directory inside the sandbox."""
# Binary mode so non-UTF-8 filenames don't raise UnicodeDecodeError before
# we get a chance to handle them. We decode each name individually below.
# Offload the blocking subprocess call to a worker thread so it doesn't stall
# the event loop; the fast result parsing below stays on the loop.
result = await asyncio.to_thread(
run_in_sandbox,
["bash", "-c", _LIST_SCRIPT, "--", directory],
DEFAULT_TIMEOUT_SECONDS,
text=False,
)
stderr_bytes = result.get("stderr") or b""
stderr_str = stderr_bytes.decode("utf-8", errors="replace") if isinstance(stderr_bytes, bytes) else stderr_bytes
if result["returncode"] != 0:
return {
"directory": directory,
"files": [],
"directories": [],
"returncode": result["returncode"],
"stderr": stderr_str or result.get("error", ""),
}
files: list[str] = []
directories: list[str] = []
for entry in result["stdout"].split(b"\x00"):
if not entry:
continue
kind, _, name_bytes = entry.partition(b" ")
name = name_bytes.decode("utf-8", errors="replace")
if kind == b"d":
directories.append(name)
elif kind == b"f":
files.append(name)
files.sort()
directories.sort()
return {
"directory": directory,
"files": files,
"directories": directories,
"returncode": 0,
"stderr": "",
}
@@ -0,0 +1,538 @@
"""Prepare grading context for LLM-based rubric evaluation.
Collects file evidence from the sandbox, formats as XML, and assembles
the full text_for_grading string by prepending file evidence to the
agent's final output.
"""
import asyncio
import io
import logging
import os
from core.tools import sandbox
logger = logging.getLogger(__name__)
# Default cap on the number of files included as evidence. Sized to comfortably
# cover a task's read-only input documents PLUS the agent's deliverables: the
# walk is flat + alphabetical, so input files can otherwise consume every slot
# and silently drop the agent-created files the rubric actually grades. When the
# cap IS hit, the dropped files are surfaced both in a log line and as a
# <files_omitted_due_to_max_files> marker inside the <workspace_files> XML, so an
# empty or partial listing is never mistaken for "the agent created nothing."
DEFAULT_MAX_FILES = 100
# Per-file content budget that ends up in the grading prompt. The ceiling on the
# whole evidence block is DEFAULT_MAX_FILES * this, so the constraint isn't memory
# but the grader's context window and cost — every byte here is fed to the rubric
# LLM. 250 KB gives large multi-tab spreadsheets and multi-slide decks room to
# show every tab/slide (the per-sheet/per-slide budgeting below distributes it),
# while keeping the worst case bounded. Realistic tasks have a handful of
# substantive files, so actual prompts stay far below the 100-file ceiling.
DEFAULT_MAX_CONTENT_BYTES = 250_000
TEXT_EXTENSIONS = {
"txt",
"csv",
"json",
"jsonl",
"py",
"js",
"ts",
"md",
"html",
"xml",
"yaml",
"yml",
"toml",
"cfg",
"ini",
"log",
"sh",
"bash",
"sql",
"r",
"rb",
"java",
"c",
"cpp",
"h",
"hpp",
"css",
"scss",
"tex",
"rst",
}
SPECIAL_EXTENSIONS = {"pdf", "docx", "xlsx", "pptx"}
ALL_SUPPORTED_EXTENSIONS = TEXT_EXTENSIONS | SPECIAL_EXTENSIONS
# Special formats (PDF/Office) can't be truncated without corrupting the
# container, so extraction needs the whole file in memory — but an unbounded
# read lets one giant agent-generated file OOM grading before the rubric runs.
# Cap the raw read: files over this size are recorded as evidence (so the rubric
# still knows they exist) but not extracted. Plain text is already bounded by
# max_content_bytes, so it needs no separate cap.
MAX_SPECIAL_FILE_BYTES = 50 * 1024 * 1024
DEFAULT_EXCLUDE_PATTERNS = {
"__pycache__",
".git",
".venv",
"node_modules",
".mypy_cache",
".pytest_cache",
".ruff_cache",
}
def _is_excluded(relative_path: str, exclude_patterns: set[str]) -> bool:
parts = relative_path.split(os.sep)
return any(part in exclude_patterns for part in parts)
def _within_workdir(path: str, workdir_root: str) -> bool:
"""True if ``path`` resolves to ``workdir_root`` or somewhere beneath it.
``realpath`` collapses ``..`` and follows symlinks, so a symlink inside the
workdir pointing at ``/app`` resolves outside ``workdir_root`` and is
rejected. The core server runs as root, so this is what stops
``prepareGradingContext`` from being turned into a read oracle for the
locked-down ``/app`` tree.
"""
canonical = os.path.realpath(path)
return canonical == workdir_root or canonical.startswith(workdir_root + os.sep)
def _get_extension(file_path: str) -> str:
ext = os.path.splitext(file_path)[1].lower()
return ext.lstrip(".")
def _extract_text(raw: bytes, file_size: int, max_bytes: int) -> tuple[str | None, str, bool]:
try:
truncated = file_size > max_bytes
content = raw[:max_bytes].decode("utf-8", errors="replace")
if truncated:
content += f"\n\n[... truncated at {max_bytes:,} bytes, total size: {file_size:,} bytes]"
return content, "read", truncated
except Exception:
return None, "read", False
def _extract_pdf(raw: bytes, max_bytes: int) -> tuple[str | None, str, bool]:
try:
from pypdf import PdfReader
reader = PdfReader(io.BytesIO(raw))
parts: list[str] = []
total_bytes = 0
truncated = False
for page in reader.pages:
text = page.extract_text() or ""
text_bytes = len(text.encode("utf-8"))
if total_bytes + text_bytes > max_bytes:
remaining = max_bytes - total_bytes
parts.append(text[:remaining])
truncated = True
break
parts.append(text)
total_bytes += text_bytes
content = "\n".join(parts)
if truncated:
content += f"\n\n[... truncated at {max_bytes:,} bytes]"
return content, "pypdf", truncated
except Exception:
return None, "pypdf", False
def _docx_table_lines(table) -> list[str]:
"""Return one tab-separated line per row, recursing into nested tables.
A cell can hold both paragraphs and further tables, so we join the cell's
own paragraph text and then descend into any tables it nests — `cell.text`
alone flattens only the direct paragraphs and silently drops nested tables.
"""
lines: list[str] = []
for row in table.rows:
cells: list[str] = []
nested: list[str] = []
for cell in row.cells:
cells.append("\n".join(p.text for p in cell.paragraphs))
for inner in cell.tables:
nested.extend(_docx_table_lines(inner))
lines.append("\t".join(cells))
lines.extend(nested)
return lines
def _extract_docx(raw: bytes, max_bytes: int) -> tuple[str | None, str, bool]:
try:
from docx import Document
doc = Document(io.BytesIO(raw))
lines = [p.text for p in doc.paragraphs]
# Paragraphs alone miss text inside tables, which agents routinely use
# for structured output (reports, spreadsheets exported as docx, etc.).
for table in doc.tables:
lines.extend(_docx_table_lines(table))
text = "\n".join(lines)
text_bytes = len(text.encode("utf-8"))
truncated = text_bytes > max_bytes
if truncated:
content = text[:max_bytes]
content += f"\n\n[... truncated at {max_bytes:,} bytes]"
else:
content = text
return content, "python-docx", truncated
except Exception:
return None, "python-docx", False
def _extract_xlsx(raw: bytes, max_bytes: int) -> tuple[str | None, str, bool]:
try:
from openpyxl import load_workbook
wb = load_workbook(io.BytesIO(raw), read_only=True, data_only=True)
parts: list[str] = []
total_bytes = 0
truncated = False
# Budget the bytes PER SHEET instead of letting the first sheet drain the
# whole allowance. A multi-tab workbook used to truncate mid-first-sheet
# and never emit the later tabs at all — so a grader couldn't even tell
# those sheets existed. We give each not-yet-visited sheet an equal slice
# of the remaining budget, recomputed after every sheet so unused bytes
# from small/empty sheets roll forward to later ones.
sheet_names = wb.sheetnames
for index, sheet_name in enumerate(sheet_names):
ws = wb[sheet_name]
# Always emit the header so every tab is visible, even one whose rows
# get fully truncated away.
header = f"--- Sheet: {sheet_name} ---"
parts.append(header)
total_bytes += len(header.encode("utf-8")) + 1
remaining_sheets = len(sheet_names) - index
sheet_budget = max(0, (max_bytes - total_bytes) // remaining_sheets)
sheet_used = 0
sheet_truncated = False
for row in ws.iter_rows(values_only=True):
line = "\t".join(str(cell) if cell is not None else "" for cell in row)
# Skip fully-empty rows: openpyxl's read-only dimensions can be
# inflated, so a sheet may report thousands of blank trailing rows
# that would otherwise burn the budget producing only tabs.
if not line.strip():
continue
line_bytes = len(line.encode("utf-8")) + 1 # +1 for newline
if sheet_used + line_bytes > sheet_budget:
sheet_truncated = True
truncated = True
break
parts.append(line)
sheet_used += line_bytes
total_bytes += line_bytes
if sheet_truncated:
parts.append(f"[... '{sheet_name}' truncated at {sheet_budget:,} bytes for this sheet]")
wb.close()
content = "\n".join(parts)
if truncated:
content += f"\n\n[... some sheets truncated; per-sheet budget of ~{max_bytes:,} total bytes reached]"
return content, "openpyxl", truncated
except Exception:
return None, "openpyxl", False
def _pptx_shape_lines(shapes) -> list[str]:
"""Return text lines for a collection of shapes, recursing into groups.
Pulls text frames and table cells, descending into grouped shapes.
"""
lines: list[str] = []
for shape in shapes:
if shape.shape_type == 6: # MSO_SHAPE_TYPE.GROUP
lines.extend(_pptx_shape_lines(shape.shapes))
continue
if shape.has_text_frame:
text = shape.text_frame.text
if text.strip():
lines.append(text)
if shape.has_table:
for row in shape.table.rows:
cells = [cell.text for cell in row.cells]
lines.append("\t".join(cells))
return lines
def _extract_pptx(raw: bytes, max_bytes: int) -> tuple[str | None, str, bool]:
try:
from pptx import Presentation
prs = Presentation(io.BytesIO(raw))
parts: list[str] = []
total_bytes = 0
truncated = False
for index, slide in enumerate(prs.slides, start=1):
# Per-slide marker so the grader sees the slide count, which is often
# itself part of the rubric.
header = f"--- Slide {index} ---"
lines = _pptx_shape_lines(slide.shapes)
if slide.has_notes_slide:
notes = slide.notes_slide.notes_text_frame.text
if notes.strip():
lines.append(f"[Notes] {notes}")
block = "\n".join([header, *lines])
block_bytes = len(block.encode("utf-8")) + 1
if total_bytes + block_bytes > max_bytes:
remaining = max_bytes - total_bytes
if remaining > 0:
parts.append(block[:remaining])
truncated = True
break
parts.append(block)
total_bytes += block_bytes
content = "\n".join(parts)
if truncated:
content += f"\n\n[... truncated at {max_bytes:,} bytes]"
return content, "python-pptx", truncated
except Exception:
return None, "python-pptx", False
def _walk_dir(directory: str) -> list[str]:
results: list[str] = []
for root, _dirs, files in os.walk(directory):
for name in files:
results.append(os.path.join(root, name))
results.sort()
return results
def _format_evidence_as_xml(
evidence: list[dict],
directory: str | None = None,
dropped_paths: list[str] | None = None,
) -> str:
# Emit the container even with no included files when some were dropped by
# the cap, so the grader sees the truncation notice rather than a bare
# final_output. Only an empty listing AND nothing dropped means "no files."
if not evidence and not dropped_paths:
return ""
dir_attr = f' directory="{directory}"' if directory else ""
truncated_attr = ""
if dropped_paths:
truncated_attr = f' truncated_to_max_files="true" dropped_file_count="{len(dropped_paths)}"'
parts: list[str] = [f"<workspace_files{dir_attr}{truncated_attr}>"]
for item in evidence:
attrs = f'path="{item["path"]}" type="{item["extension"]}" size_bytes="{item["size_bytes"]}"'
if item.get("extraction_method"):
attrs += f' extraction_method="{item["extraction_method"]}"'
if item.get("truncated"):
attrs += ' truncated="true"'
if item["content"] is not None:
parts.append(f" <file {attrs}>")
parts.append(item["content"])
parts.append(" </file>")
else:
parts.append(f" <file {attrs} />")
if dropped_paths:
# Surface the names of files that were walked but excluded by the cap.
# An absent file here is genuinely absent; a file listed here exists but
# its contents were not included, so a rubric must not conclude it is
# missing from the workspace.
parts.append(f' <files_omitted_due_to_max_files count="{len(dropped_paths)}">')
for path in dropped_paths:
parts.append(f' <omitted_file path="{path}" />')
parts.append(" </files_omitted_due_to_max_files>")
parts.append("</workspace_files>")
return "\n".join(parts)
async def prepareGradingContext(
final_output: str,
directory: str | None = None,
extensions: list[str] | None = None,
exclude_patterns: list[str] | None = None,
max_files: int = DEFAULT_MAX_FILES,
max_content_bytes: int = DEFAULT_MAX_CONTENT_BYTES,
) -> str:
"""Prepare the text_for_grading string for rubric evaluation.
Collects file evidence from the sandbox, formats as XML,
and prepends to the agent's final_output.
Args:
final_output: The agent's final response text.
directory: Root directory to search. Defaults to the sandbox directory.
extensions: Only include files with these extensions (without dot).
exclude_patterns: Directory/file name patterns to skip.
max_files: Maximum number of files to include as evidence. Eligible files
beyond this cap are omitted but reported (logged and listed in the
XML under <files_omitted_due_to_max_files>) rather than dropped
silently. Defaults to DEFAULT_MAX_FILES.
max_content_bytes: Maximum bytes of content to read per file.
Returns:
The assembled text_for_grading string.
"""
# Walks the sandbox tree and reads/parses files (text/PDF/docx) — all
# blocking — so run the whole body in a worker thread to free the loop.
return await asyncio.to_thread(
_prepare_grading_context_sync,
final_output,
directory,
extensions,
exclude_patterns,
max_files,
max_content_bytes,
)
def _prepare_grading_context_sync(
final_output: str,
directory: str | None = None,
extensions: list[str] | None = None,
exclude_patterns: list[str] | None = None,
max_files: int = DEFAULT_MAX_FILES,
max_content_bytes: int = DEFAULT_MAX_CONTENT_BYTES,
) -> str:
if directory is None:
directory = sandbox.WORKDIR
if not os.path.isdir(directory):
return final_output
# Grading only ever needs the agent's deliverables, which live under the
# sandbox workdir. The server runs as root, so confine the walk to the
# workdir — anything outside it (a caller passing /app, or a symlink that
# escapes) is refused so this can't exfiltrate the locked-down tree.
workdir_root = os.path.realpath(sandbox.WORKDIR)
if not _within_workdir(directory, workdir_root):
logger.warning(
"prepareGradingContext: directory %r is outside the sandbox workdir %r; refusing to read it",
directory,
sandbox.WORKDIR,
)
return final_output
excl = set(exclude_patterns) if exclude_patterns else DEFAULT_EXCLUDE_PATTERNS
ext_filter: set[str] | None = None
if extensions:
ext_filter = {e.lower().lstrip(".") for e in extensions}
unsupported = ext_filter - ALL_SUPPORTED_EXTENSIONS
if unsupported:
raise ValueError(
f"Unsupported file extension(s): {', '.join('.' + e for e in sorted(unsupported))}. "
f"Supported extensions: {', '.join('.' + e for e in sorted(ALL_SUPPORTED_EXTENSIONS))}"
)
all_files = _walk_dir(directory)
evidence: list[dict] = []
# Files that passed every filter (would have been included) but fell outside
# the max_files cap. Tracked so the cap is observable rather than silent: a
# rubric seeing one of these must not conclude the file is absent.
dropped_paths: list[str] = []
for file_path in all_files:
# Skip symlinks (and any other entry) whose real target escapes the
# workdir — they'd otherwise let a root read reach outside the sandbox.
if not _within_workdir(file_path, workdir_root):
continue
relative_path = os.path.relpath(file_path, directory)
if _is_excluded(relative_path, excl):
continue
ext = _get_extension(file_path)
if ext not in ALL_SUPPORTED_EXTENSIONS:
continue
if ext_filter and ext not in ext_filter:
continue
# Cap is checked AFTER filtering so it counts only eligible files, and
# records the overflow instead of silently breaking out of the walk.
if len(evidence) >= max_files:
dropped_paths.append(file_path)
continue
# Read bytes AS THE SANDBOX USER. The server is root, so opening the file
# in-process would both bypass /app's permissions and be a symlink-swap
# TOCTOU oracle; reading as uid 1000 closes both. Office/PDF formats can't
# be truncated (zip/PDF bytes corrupt) so extraction needs the whole file;
# plain text is bounded by max_content_bytes.
read_limit = MAX_SPECIAL_FILE_BYTES if ext in SPECIAL_EXTENSIONS else max_content_bytes
# For special formats, probe the size first (limit=0 reads no bytes) so an
# oversized file is recorded as evidence and skipped — without streaming
# MAX_SPECIAL_FILE_BYTES through the pipe just to discard it, which would
# let a few huge agent files waste GBs and risk OOMing grading.
if ext in SPECIAL_EXTENSIONS:
try:
size_bytes, _h, _r, _s = sandbox.agent_read_window(file_path, offset=0, limit=0, sniff=0)
except sandbox.AgentReadError:
continue
if size_bytes > MAX_SPECIAL_FILE_BYTES:
evidence.append(
{
"path": file_path,
"extension": ext,
"size_bytes": size_bytes,
"content": None,
"truncated": True,
"extraction_method": (
f"skipped: {size_bytes:,} bytes exceeds the {MAX_SPECIAL_FILE_BYTES:,}-byte grading read cap"
),
}
)
continue
try:
size_bytes, _header, raw, _start = sandbox.agent_read_window(file_path, offset=0, limit=read_limit, sniff=0)
except sandbox.AgentReadError:
continue
if ext == "pdf":
content, method, truncated = _extract_pdf(raw, max_content_bytes)
elif ext == "docx":
content, method, truncated = _extract_docx(raw, max_content_bytes)
elif ext == "xlsx":
content, method, truncated = _extract_xlsx(raw, max_content_bytes)
elif ext == "pptx":
content, method, truncated = _extract_pptx(raw, max_content_bytes)
else:
content, method, truncated = _extract_text(raw, size_bytes, max_content_bytes)
item: dict = {
"path": file_path,
"extension": ext,
"size_bytes": size_bytes,
"content": content,
"truncated": truncated,
}
if ext in SPECIAL_EXTENSIONS:
item["extraction_method"] = method
evidence.append(item)
if dropped_paths:
logger.warning(
"prepareGradingContext hit max_files=%d in %s: included %d file(s), "
"omitted %d eligible file(s) from grading evidence: %s",
max_files,
directory,
len(evidence),
len(dropped_paths),
dropped_paths,
)
xml = _format_evidence_as_xml(evidence, directory, dropped_paths)
if xml:
return f"{xml}\n\n{final_output}"
return final_output
@@ -0,0 +1,65 @@
import asyncio
import os
from typing import Annotated
from pydantic import Field
from core.tools import sandbox
from read_file_safe import (
DEFAULT_READ_LIMIT_BYTES,
HEADER_SNIFF_BYTES,
ReadFileSafeResult,
assemble_read_result,
error_result,
)
async def readFile(
file_path: Annotated[
str,
Field(
description="Path to the file. Use /workdir/ prefix for sandbox files, or an absolute path within the sandbox."
),
],
offset: Annotated[
int,
Field(
description="Byte offset to start reading from. Use 0 for the beginning or a previous call's next_offset to continue.",
ge=0,
),
] = 0,
limit: Annotated[
int | None,
Field(
description=(
f"Maximum number of bytes to read. Defaults to {DEFAULT_READ_LIMIT_BYTES}. "
"Pass null to read from offset to end of file."
),
ge=1,
),
] = DEFAULT_READ_LIMIT_BYTES,
) -> ReadFileSafeResult:
"""Read a file from the sandbox, with optional offset/limit (bytes) for paginating large files."""
workdir = os.path.realpath(sandbox.WORKDIR)
# os.path.join discards the workdir prefix if file_path is absolute (e.g.
# "/etc/hostname" or "/workdir/foo" in production where /workdir is real),
# so absolute paths pass through to the host and FS permissions enforce
# access.
resolved_path = os.path.join(workdir, file_path)
# The core server runs as root, so we must NOT open() agent-supplied
# paths in-process — that would read /app, /opt/venv, etc. Read the bytes as
# the unprivileged sandbox user (filesystem perms gate access, TOCTOU-safe),
# then run the normal decode/binary-sniff/pagination on what came back.
# The read spawns a subprocess and blocks; offload to a worker thread to
# keep the event loop free.
def _read() -> ReadFileSafeResult:
try:
total_bytes, header, raw, start = sandbox.agent_read_window(
resolved_path, offset=offset, limit=limit, sniff=HEADER_SNIFF_BYTES
)
except sandbox.AgentReadError as e:
return error_result(file_path, offset, str(e))
return assemble_read_result(file_path, total_bytes, header, raw, start, limit)
return await asyncio.to_thread(_read)
@@ -0,0 +1,461 @@
import asyncio
import base64
import io
import mimetypes
import os
import re
from typing import Annotated
from urllib.parse import quote
from fastmcp.tools import ToolResult
from mcp.types import AudioContent, BlobResourceContents, EmbeddedResource, ImageContent, TextContent
from PIL import Image, ImageOps
from pydantic import AnyUrl, Field
from pypdf import PdfReader, PdfWriter
from core.tools import sandbox
MAX_MEDIA_FILE_BYTES = 20 * 1024 * 1024
# PDFs pass through unmodified, and Anthropic caps the total request at 32 MB
# base64 — beyond 10 MiB a single document plus history risks killing the run.
MAX_PDF_FILE_BYTES = 10 * 1024 * 1024
# Larger PDFs/page counts must be read in page ranges (mirrors Claude Code's Read tool).
MAX_PDF_PAGES_PER_READ = 20
# Anthropic rejects images over 2000px on either edge once a request carries
# >20 images, and downscales to ~2576px server-side anyway.
MAX_IMAGE_DIMENSION_PX = 2000
# Keeps the base64 form under the strictest per-image provider cap (5 MB).
MAX_ENCODED_IMAGE_BYTES = int(3.75 * 1024 * 1024)
JPEG_QUALITY_LADDER = (85, 70, 55)
SUPPORTED_IMAGE_MIME_TYPES = {
"image/gif",
"image/jpeg",
"image/png",
"image/webp",
}
PDF_MIME_TYPE = "application/pdf"
# Pinned per extension to Gemini's documented inlineData mimes — Python's
# mimetypes would guess non-canonical types like audio/x-wav, video/quicktime.
AV_MIME_TYPES_BY_EXTENSION = {
".wav": "audio/wav",
".mp3": "audio/mp3",
".aif": "audio/aiff",
".aiff": "audio/aiff",
".aac": "audio/aac",
".ogg": "audio/ogg",
".flac": "audio/flac",
".mp4": "video/mp4",
".mpeg": "video/mpeg",
".mpg": "video/mpeg",
".mov": "video/mov",
".avi": "video/avi",
".flv": "video/x-flv",
".webm": "video/webm",
".wmv": "video/wmv",
".3gp": "video/3gpp",
".3gpp": "video/3gpp",
}
SUPPORTED_AUDIO_MIME_TYPES = {m for m in AV_MIME_TYPES_BY_EXTENSION.values() if m.startswith("audio/")}
SUPPORTED_MEDIA_MIME_TYPES = SUPPORTED_IMAGE_MIME_TYPES | {PDF_MIME_TYPE} | set(AV_MIME_TYPES_BY_EXTENSION.values())
WORKDIR_PREFIX = "/workdir/"
def _error_result(*, file_path: str, mime_type: str | None, stderr: str) -> ToolResult:
label = mime_type or "unknown"
return ToolResult(
content=[
TextContent(
type="text",
text=f"readMedia failed for {file_path} ({label}): {stderr}",
)
]
)
def _media_mime_type(file_path: str) -> str | None:
_root, ext = os.path.splitext(file_path)
av_mime = AV_MIME_TYPES_BY_EXTENSION.get(ext.lower())
if av_mime is not None:
return av_mime
mime_type, _encoding = mimetypes.guess_type(file_path)
if mime_type in SUPPORTED_MEDIA_MIME_TYPES:
return mime_type
return None
def _resolve_read_path(file_path: str, workdir: str) -> str:
if file_path.startswith(WORKDIR_PREFIX):
# lstrip("/") handles "/workdir//foo" — without it, the leading slash on
# the remainder makes os.path.join discard workdir and escape the sandbox.
return os.path.join(workdir, file_path.removeprefix(WORKDIR_PREFIX).lstrip("/"))
if file_path == "/workdir":
return workdir
# os.path.join discards the workdir prefix if file_path is absolute (e.g.
# "/etc/hostname"), so absolute paths pass through to the host and FS
# permissions enforce access.
return os.path.join(workdir, file_path)
def _workdir_relative_path(resolved_path: str, workdir: str) -> str | None:
# realpath resolves "../" segments so paths that escape workdir don't get
# treated as inside it via a raw commonpath check.
canonical = os.path.realpath(resolved_path)
try:
if os.path.commonpath([canonical, workdir]) != workdir:
return None
except ValueError:
return None
rel_path = os.path.relpath(canonical, workdir)
return "" if rel_path == "." else rel_path
def _display_path(file_path: str, resolved_path: str, workdir: str) -> str:
rel_path = _workdir_relative_path(resolved_path, workdir)
if rel_path is not None:
return "/workdir" if rel_path == "" else f"/workdir/{rel_path}"
return file_path
def _resource_uri(display_path: str) -> AnyUrl:
return AnyUrl(f"file://{quote(display_path, safe='/')}")
def _has_alpha(img: Image.Image) -> bool:
if img.mode in ("RGBA", "LA", "PA"):
return True
return img.mode == "P" and "transparency" in img.info
def _encode_image(img: Image.Image, output_format: str, quality: int) -> bytes:
buf = io.BytesIO()
if output_format == "JPEG":
img.save(buf, format="JPEG", quality=quality)
else:
img.save(buf, format="PNG", optimize=True)
return buf.getvalue()
def _parse_page_range(pages: str) -> tuple[int, int] | None:
"""Parse a 1-indexed page range like "3" or "1-10". Returns None if malformed."""
match = re.fullmatch(r"(\d+)(?:-(\d+))?", pages.strip())
if not match:
return None
start = int(match.group(1))
end = int(match.group(2)) if match.group(2) else start
if start < 1 or end < start:
return None
return start, end
def _normalize_image(raw: bytes) -> tuple[bytes, str, list[str]] | str:
"""Make image bytes safe to send to a model provider.
Detects the real format from the bytes (a mime type that mismatches the
content is a fatal provider error), downscales anything over
MAX_IMAGE_DIMENSION_PX on the long edge, and re-encodes oversized payloads
under MAX_ENCODED_IMAGE_BYTES. Returns (bytes, mime_type, notes) on success
or an error message string.
"""
try:
img = Image.open(io.BytesIO(raw))
width, height = img.size
actual_format = img.format
# Force a full decode now: Image.open() is lazy, so a valid header over a
# truncated/corrupt body otherwise slips through the pass-through branch
# below and emits ImageContent the model later rejects. load() raises here
# on a bad body, turning it into a graceful parse error.
img.load()
except Exception:
return "file content is not a parseable image (corrupt file, or extension does not match content?)"
actual_mime = Image.MIME.get(actual_format or "", None)
notes: list[str] = []
# Phone photos carry an EXIF Orientation tag that providers honor only on the
# original bytes; once we re-encode, that metadata is dropped, so bake the
# rotation into the pixels. Values 2-8 mean a transform is needed (1/absent is
# a no-op); when one applies we must re-encode to emit the rotated result
# rather than passing the unrotated raw bytes through.
needs_orientation_fix = img.getexif().get(0x0112, 1) not in (0, 1)
if needs_orientation_fix:
img = ImageOps.exif_transpose(img)
width, height = img.size
notes.append("applied EXIF orientation")
needs_resize = max(width, height) > MAX_IMAGE_DIMENSION_PX
needs_reencode = (
needs_resize
or needs_orientation_fix
or actual_mime not in SUPPORTED_IMAGE_MIME_TYPES
or len(raw) > MAX_ENCODED_IMAGE_BYTES
)
if not needs_reencode:
assert actual_mime is not None
return raw, actual_mime, notes
try:
if getattr(img, "is_animated", False):
img.seek(0)
img.load()
notes.append("animated image flattened to its first frame")
if needs_resize:
img.thumbnail((MAX_IMAGE_DIMENSION_PX, MAX_IMAGE_DIMENSION_PX), Image.Resampling.LANCZOS)
notes.append(f"resized from {width}x{height} to {img.width}x{img.height} to fit model image limits")
# Lossless sources stay PNG when they fit the byte budget (keeps text
# crisp for transcription); photos and anything oversized go to JPEG.
lossless_source = actual_format in ("PNG", "GIF", "BMP", "TIFF")
encoded: bytes | None = None
output_mime = "image/png"
if lossless_source:
png_img = img
encoded = _encode_image(png_img, "PNG", 0)
if len(encoded) > MAX_ENCODED_IMAGE_BYTES:
encoded = None
if encoded is None:
jpeg_img = img
if _has_alpha(jpeg_img):
# JPEG has no alpha channel; composite onto white.
background = Image.new("RGB", jpeg_img.size, (255, 255, 255))
background.paste(jpeg_img.convert("RGBA"), mask=jpeg_img.convert("RGBA").split()[-1])
jpeg_img = background
elif jpeg_img.mode != "RGB":
jpeg_img = jpeg_img.convert("RGB")
for quality in JPEG_QUALITY_LADDER:
encoded = _encode_image(jpeg_img, "JPEG", quality)
if len(encoded) <= MAX_ENCODED_IMAGE_BYTES:
break
while encoded is not None and len(encoded) > MAX_ENCODED_IMAGE_BYTES and min(jpeg_img.size) > 200:
jpeg_img = jpeg_img.resize(
(max(1, int(jpeg_img.width * 0.75)), max(1, int(jpeg_img.height * 0.75))),
Image.Resampling.LANCZOS,
)
encoded = _encode_image(jpeg_img, "JPEG", JPEG_QUALITY_LADDER[-1])
output_mime = "image/jpeg"
if encoded is None or len(encoded) > MAX_ENCODED_IMAGE_BYTES:
return "could not compress image under the per-image size limit"
except Exception as e:
return f"failed to convert image for model consumption: {e}"
if output_mime != actual_mime:
notes.append(f"re-encoded from {actual_mime or 'unknown format'} to {output_mime}")
return encoded, output_mime, notes
async def readMedia(
file_path: Annotated[
str,
Field(
description=(
"Path to an image (gif/jpeg/png/webp), PDF, audio (wav/mp3/aiff/aac/ogg/flac), or video "
"(mp4/mpeg/mov/avi/flv/webm/wmv/3gpp) file. "
"Use /workdir/ prefix for sandbox files, or an absolute path within the sandbox. "
"Returns multimodal MCP content; images are automatically downscaled/re-encoded to fit "
"model limits (max 2000px on the long edge). Audio/video require a model with native "
"audio/video support (e.g. Gemini) — other models see only a text placeholder. "
"Use readFile for text and readPDF for PDF text extraction."
)
),
],
pages: Annotated[
str | None,
Field(
description=(
"PDF page range to read, 1-indexed, e.g. '3' or '1-10' (max 20 pages per read). "
"Required for PDFs over 20 pages or 10 MiB. Ignored for images."
)
),
] = None,
) -> ToolResult:
"""Read an image, PDF, audio, or video file and return it as multimodal MCP content (base64-encoded, capped at 20 MiB; images auto-resized to model limits, large PDFs read in page ranges)."""
# File read, base64 encode, and image/PDF re-encoding all block; run in a worker thread.
return await asyncio.to_thread(_read_media_sync, file_path, pages)
def _read_pdf_sync(*, file_path: str, display_path: str, raw: bytes, file_size: int, pages: str | None) -> ToolResult:
try:
reader = PdfReader(io.BytesIO(raw))
# is_encrypted is true even for owner-restricted PDFs that carry only
# permission flags (no-print etc.) and open silently in any viewer.
# decrypt("") succeeds for those; only a real user password fails it.
if reader.is_encrypted and not reader.decrypt(""):
return _error_result(
file_path=file_path,
mime_type=PDF_MIME_TYPE,
stderr="PDF is password-protected (requires a password to open)",
)
total_pages = len(reader.pages)
except Exception:
return _error_result(file_path=file_path, mime_type=PDF_MIME_TYPE, stderr="file content is not a valid PDF")
if pages is None:
if file_size > MAX_PDF_FILE_BYTES or total_pages > MAX_PDF_PAGES_PER_READ:
return _error_result(
file_path=file_path,
mime_type=PDF_MIME_TYPE,
stderr=(
f"PDF is too large to attach whole ({total_pages} pages, {file_size} bytes; "
f"limits: {MAX_PDF_PAGES_PER_READ} pages, {MAX_PDF_FILE_BYTES} bytes). "
f"Pass pages='1-{min(total_pages, MAX_PDF_PAGES_PER_READ)}' to read a page range, "
"or use readPDF to extract the text."
),
)
blob = raw
page_note = ""
else:
page_range = _parse_page_range(pages)
if page_range is None:
return _error_result(
file_path=file_path,
mime_type=PDF_MIME_TYPE,
stderr=f"Invalid pages value {pages!r}. Use a 1-indexed page or range, e.g. '3' or '1-10'.",
)
start, end = page_range
if start > total_pages:
return _error_result(
file_path=file_path,
mime_type=PDF_MIME_TYPE,
stderr=f"Page {start} is out of range: the PDF has {total_pages} pages.",
)
end = min(end, total_pages)
if end - start + 1 > MAX_PDF_PAGES_PER_READ:
return _error_result(
file_path=file_path,
mime_type=PDF_MIME_TYPE,
stderr=f"Page range {start}-{end} spans more than {MAX_PDF_PAGES_PER_READ} pages. Read it in smaller chunks.",
)
try:
writer = PdfWriter()
for index in range(start - 1, end):
writer.add_page(reader.pages[index])
buf = io.BytesIO()
writer.write(buf)
blob = buf.getvalue()
except Exception as e:
return _error_result(
file_path=file_path, mime_type=PDF_MIME_TYPE, stderr=f"failed to extract pages {start}-{end}: {e}"
)
if len(blob) > MAX_PDF_FILE_BYTES:
return _error_result(
file_path=file_path,
mime_type=PDF_MIME_TYPE,
stderr=(
f"Pages {start}-{end} are still {len(blob)} bytes (limit {MAX_PDF_FILE_BYTES}). "
"Read a smaller range, or use readPDF to extract the text."
),
)
page_note = f" (pages {start}-{end} of {total_pages})"
return ToolResult(
content=[
TextContent(
type="text",
text=f"Read {display_path} as {PDF_MIME_TYPE} ({len(blob)} bytes){page_note}.",
),
EmbeddedResource(
type="resource",
resource=BlobResourceContents(
uri=_resource_uri(display_path),
mimeType=PDF_MIME_TYPE,
blob=base64.b64encode(blob).decode("ascii"),
),
),
]
)
def _read_media_sync(file_path: str, pages: str | None = None) -> ToolResult:
workdir = os.path.realpath(sandbox.WORKDIR)
resolved_path = _resolve_read_path(file_path, workdir)
mime_type = _media_mime_type(file_path)
if mime_type is None:
return _error_result(
file_path=file_path,
mime_type=None,
stderr=f"Unsupported media type. Supported: {sorted(SUPPORTED_MEDIA_MIME_TYPES)}",
)
# Read the bytes through the unprivileged sandbox reader so the open() runs
# as the sandbox user (the core server itself is root). Filesystem
# permissions — not the server's uid — gate what the agent can reach, so an
# absolute path to /app, /opt/venv, /__modal, etc. fails with EACCES exactly
# as it would in the agent's own shell. The reader also rejects non-regular
# files (a FIFO/device under /workdir would otherwise block a worker until
# the timeout) via an O_NONBLOCK + S_ISREG check on the opened fd, and
# limit=MAX+1 bounds the read so an oversized file isn't slurped whole.
try:
file_size, _header, raw, _start = sandbox.agent_read_window(
resolved_path, offset=0, limit=MAX_MEDIA_FILE_BYTES + 1, sniff=0
)
except sandbox.AgentReadError as e:
return _error_result(file_path=file_path, mime_type=mime_type, stderr=str(e))
if file_size > MAX_MEDIA_FILE_BYTES:
return _error_result(
file_path=file_path,
mime_type=mime_type,
stderr=(
f"File too large for media read: {file_size} bytes exceeds {MAX_MEDIA_FILE_BYTES} bytes. "
"Shrink it first (e.g. downscale the image, or for a PDF use readPDF / render individual pages)."
),
)
display_path = _display_path(file_path, resolved_path, workdir)
if mime_type in SUPPORTED_IMAGE_MIME_TYPES:
normalized = _normalize_image(raw)
if isinstance(normalized, str):
return _error_result(file_path=file_path, mime_type=mime_type, stderr=normalized)
data, actual_mime, notes = normalized
suffix = f" ({'; '.join(notes)})" if notes else ""
text = TextContent(
type="text",
text=f"Read {display_path} as {actual_mime} ({len(data)} bytes).{suffix}",
)
return ToolResult(
content=[
text,
ImageContent(type="image", data=base64.b64encode(data).decode("ascii"), mimeType=actual_mime),
]
)
encoded = base64.b64encode(raw).decode("ascii")
text = TextContent(
type="text",
text=f"Read {display_path} as {mime_type} ({file_size} bytes).",
)
if mime_type in SUPPORTED_AUDIO_MIME_TYPES:
return ToolResult(
content=[
text,
AudioContent(type="audio", data=encoded, mimeType=mime_type),
]
)
if mime_type == PDF_MIME_TYPE:
return _read_pdf_sync(file_path=file_path, display_path=display_path, raw=raw, file_size=file_size, pages=pages)
# Video: MCP has no dedicated content type, so embed as a blob
# resource; runners forward supported mime types as native model content.
return ToolResult(
content=[
text,
EmbeddedResource(
type="resource",
resource=BlobResourceContents(
uri=_resource_uri(display_path),
mimeType=mime_type,
blob=encoded,
),
),
]
)
@@ -0,0 +1,75 @@
import asyncio
import io
from typing import Any
from pypdf import PdfReader
from core.tools.sandbox import (
DEFAULT_TIMEOUT_SECONDS,
run_in_sandbox,
)
async def readPDF(file_path: str) -> dict[str, Any]:
"""Extract text from a PDF file inside the sandbox."""
# The subprocess read and pypdf parsing both block; run the whole body in a
# worker thread so the event loop stays free for other tool calls.
return await asyncio.to_thread(_read_pdf_sync, file_path)
def _read_pdf_sync(file_path: str) -> dict[str, Any]:
result = run_in_sandbox(["cat", "--", file_path], DEFAULT_TIMEOUT_SECONDS, text=False)
if result["returncode"] != 0:
stderr = result.get("stderr", b"")
if isinstance(stderr, bytes):
stderr = stderr.decode("utf-8", errors="replace")
return {
"pages": [],
"page_count": 0,
"stderr": stderr or result.get("error", ""),
"returncode": result["returncode"],
"file_path": file_path,
}
try:
reader = PdfReader(io.BytesIO(result["stdout"]))
if reader.is_encrypted:
try:
reader.decrypt("")
except Exception:
return {
"pages": [],
"page_count": 0,
"stderr": "Encrypted PDF: unable to decrypt",
"returncode": 1,
"file_path": file_path,
}
if reader.is_encrypted:
return {
"pages": [],
"page_count": 0,
"stderr": "Encrypted PDF: unable to decrypt",
"returncode": 1,
"file_path": file_path,
}
pages: list[str] = []
for page in reader.pages:
text = page.extract_text() or ""
pages.append(text)
return {
"pages": pages,
"page_count": len(pages),
"stderr": "",
"returncode": 0,
"file_path": file_path,
}
except Exception as e:
return {
"pages": [],
"page_count": 0,
"stderr": str(e),
"returncode": 1,
"file_path": file_path,
}
+381
View File
@@ -0,0 +1,381 @@
import os
import subprocess
from collections.abc import Iterator
from typing import Any, cast
DEFAULT_TIMEOUT_SECONDS = 120
MAX_TIMEOUT_SECONDS = 1800
# Agent's working directory. Created in docker/base/Dockerfile and scripts/start.sh,
# owned by the model user. Tests reassign this module attribute to point at a temp
# dir, so the annotation is `str` rather than the inferred `Literal["/workdir"]`.
WORKDIR: str = "/workdir"
# MCP_PROXY_TOKEN is injected directly into core's env by the proxy (see
# mcp_proxy/service.py:start_service) so core's own HTTP server can
# authenticate inbound requests on non-MCP routes. The credential-name filter
# in mcp_proxy does NOT catch this, so we strip it here to avoid leaking it to
# the agent's bash/python.
#
# FAKETIME_SHARED is exported by libfaketime when the proxy runs services under
# the `faketime` wrapper for --current-time. It names a /dev/shm semaphore +
# shared-memory segment that libfaketime created as root (mode 0600), used for
# cross-process monotonic-time coordination. The agent command runs as the
# model uid, so a child it spawns (bash) re-initializes libfaketime,
# can't open that root-owned semaphore, and *hangs* on the very first clock read
# (`date`, time.time(), ...). Stripping it makes libfaketime run per-process —
# the clock is still faked (LD_PRELOAD + FAKETIME are kept); we only lose the
# cross-process "time never goes backward" guarantee, which a sandbox doesn't
# need.
_PROXY_INTERNAL_ENV_KEYS = frozenset({"MCP_PROXY_TOKEN", "FAKETIME_SHARED"})
# Stripped from every agent-spawned subprocess so the server venv (/opt/venv)
# and repo (/app) can't leak into the model's shell.
_AGENT_DEFAULT_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
_AGENT_PATH_STRIP_PREFIXES = ("/opt/venv", "/app")
_AGENT_ENV_DROP = (
"VIRTUAL_ENV",
"UV_PROJECT_ENVIRONMENT",
"PYTHONPATH",
"PYTHONHOME",
"SETUID",
"SETGID",
)
# Harness-internal vars that point at the locked-down /app tree, name the task,
# carry the run seed, or expose internal service ports. The agent's shell has no
# legitimate use for them, and leaking them advertises the eval layout (paths
# like INPUTDIR=/app/setup_data/…), the seed, and the viewer/proxy ports even
# though those resources are themselves locked down. Matched by PREFIX so a
# newly-added WORLDBENCH_*/BUNDLE* var is dropped automatically rather than
# silently leaking until someone remembers to list it.
_AGENT_ENV_DROP_PREFIXES = ("WORLDBENCH_", "BUNDLE")
# Exact harness vars without a shared prefix.
_AGENT_ENV_DROP_EXACT = ("INPUTDIR", "OUTPUTDIR", "PORT", "VIEWER_PORT")
# Survives the prefix sweep: the fake clock must reach the agent's shell.
_AGENT_ENV_KEEP = frozenset({"WORLDBENCH_CURRENT_TIME"})
def _sanitize_path(path: str) -> str:
"""Drop PATH entries under /opt/venv or /app, preserving the rest in order
(e.g. a derived image's /opt/conda/bin). Falls back to a default if empty."""
def _stripped(entry: str) -> bool:
norm = os.path.normpath(entry)
return any(norm == p or norm.startswith(p + os.sep) for p in _AGENT_PATH_STRIP_PREFIXES)
kept = [entry for entry in path.split(os.pathsep) if entry and not _stripped(entry)]
return os.pathsep.join(kept) if kept else _AGENT_DEFAULT_PATH
def _agent_env() -> dict[str, str]:
"""Inherited env with server-side venv pointers, harness-internal paths, and
proxy-internal credentials stripped — the single chokepoint every
agent command spawn flows through. Copies os.environ so the shell
keeps a real /home/<user>."""
env = os.environ.copy()
for var in (*_AGENT_ENV_DROP, *_AGENT_ENV_DROP_EXACT, *_PROXY_INTERNAL_ENV_KEYS):
env.pop(var, None)
for key in list(env):
if key not in _AGENT_ENV_KEEP and key.startswith(_AGENT_ENV_DROP_PREFIXES):
del env[key]
env["PATH"] = _sanitize_path(env.get("PATH", ""))
return env
def _privilege_drop_kwargs() -> dict[str, Any]:
"""``subprocess`` kwargs that drop an agent command to the unprivileged
sandbox user, or ``{}`` when there's nothing to drop.
core's server process stays **root** in production so it can read the
locked-down ``/app`` tree and keep ``/opt/venv`` closed to uid 1000. Every
agent-facing command (``bash``/file tools) MUST
therefore drop to ``SETUID``/``SETGID`` itself — a command left running as
root is a sandbox escape (it could read ``/app``, the grading data, the
venv, ``/__modal``).
We use subprocess's ``user``/``group``/``extra_groups`` kwargs rather than a
``preexec_fn``: they perform the ``setgroups``/``setgid``/``setuid`` in C
between fork and exec, which is async-signal-safe and avoids the
multithreaded-fork deadlock ``preexec_fn`` is prone to under the async MCP
server.
* **Not root** (local dev, CI, tests): nothing to drop to — return ``{}``.
* **Root, both env vars set**: return the drop kwargs.
* **Root, env vars missing/partial**: raise. Fail closed rather than exec
the agent's command as root.
"""
if os.geteuid() != 0:
return {}
setuid = os.environ.get("SETUID")
setgid = os.environ.get("SETGID")
if setuid is None or setgid is None:
raise RuntimeError(
"core is running as root but SETUID/SETGID are not both set "
f"(SETUID={setuid!r}, SETGID={setgid!r}); refusing to run an agent command as root"
)
# extra_groups=[] clears supplementary groups so the dropped command can't
# inherit any group membership root happened to carry.
return {"user": int(setuid), "group": int(setgid), "extra_groups": []}
# Stdlib-only reader run AS THE SANDBOX USER (uid 1000) via run_in_sandbox. The
# open() happens in the dropped subprocess, so filesystem permissions — not the
# root server — gate access: a path under /app, /opt/venv, /__modal, etc. fails
# with EACCES exactly as in the agent's own shell, and a symlink swap can't
# escalate a root read (the resolve+open are one atomic op as uid 1000, so this
# is TOCTOU-safe). Emits one JSON line so the privileged caller can reuse its
# normal in-memory processing on bytes it never opened itself.
_AGENT_READ_WINDOW_SNIPPET = r"""
import sys, os, stat, base64, json
path, offset, limit, sniff = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]), int(sys.argv[4])
try:
# O_NONBLOCK + S_ISREG on the opened fd: refuse FIFOs/devices/sockets, whose
# open()/read() can block on a writer that never comes (TOCTOU-safe — the
# check is on the fd, not the path).
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
st = os.fstat(fd)
if not stat.S_ISREG(st.st_mode):
os.close(fd)
raise OSError("not a regular file")
size = st.st_size
with os.fdopen(fd, "rb") as f:
header = f.read(min(sniff, size)) if sniff > 0 else b""
start = min(offset, size) if offset >= 0 else 0
f.seek(start)
raw = f.read() if limit < 0 else f.read(limit)
sys.stdout.write(json.dumps({
"ok": True, "size": size, "start": start,
"header": base64.b64encode(header).decode(),
"raw": base64.b64encode(raw).decode(),
}))
except Exception as e:
sys.stdout.write(json.dumps({"ok": False, "error": str(e)}))
"""
class AgentReadError(Exception):
"""A read performed as the sandbox user failed (missing file, EACCES, …)."""
def agent_read_window(
path: str,
*,
offset: int = 0,
limit: int | None = None,
sniff: int = 0,
) -> tuple[int, bytes, bytes, int]:
"""Read ``path`` AS THE SANDBOX USER and return ``(size, header, raw, start)``.
``raw`` is the ``limit`` bytes (all, if ``None``) at ``offset``; ``header`` is
the first ``sniff`` bytes (for binary detection), or empty when ``sniff`` is
0. Use this anywhere the (now root) server would otherwise ``open()`` an
agent-supplied path in-process. Raises :class:`AgentReadError` on failure.
"""
lim = -1 if limit is None else int(limit)
result = run_in_sandbox(
["python3", "-c", _AGENT_READ_WINDOW_SNIPPET, path, str(offset), str(lim), str(sniff)],
DEFAULT_TIMEOUT_SECONDS,
)
if result["returncode"] != 0:
raise AgentReadError(result.get("stderr") or result.get("error") or "agent read failed")
import json as _json
try:
data = _json.loads(result["stdout"])
except (ValueError, TypeError) as e:
raise AgentReadError(f"agent reader returned invalid output: {e}") from e
if not data.get("ok"):
raise AgentReadError(data.get("error", "agent read failed"))
import base64 as _b64
return data["size"], _b64.b64decode(data["header"]), _b64.b64decode(data["raw"]), data["start"]
# Raw byte-copier run AS THE SANDBOX USER. Unlike _AGENT_READ_WINDOW_SNIPPET it
# does NOT base64/JSON-wrap the payload — it copies the file straight to stdout
# so the parent can stream it without inflating or buffering the whole thing.
_AGENT_STREAM_SNIPPET = r"""
import sys, os, stat
path = sys.argv[1]
try:
# O_NONBLOCK so opening a FIFO/socket with no writer can't block, and reject
# any non-regular file (FIFO, device, socket, dir): reading one could hang
# forever waiting on a writer. The check is on the opened fd (fstat), so it's
# TOCTOU-safe against a path swapped after open.
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
try:
if not stat.S_ISREG(os.fstat(fd).st_mode):
sys.stderr.write("not a regular file")
sys.exit(1)
while True:
chunk = os.read(fd, 1 << 16)
if not chunk:
break
sys.stdout.buffer.write(chunk)
finally:
os.close(fd)
except Exception as e:
sys.stderr.write(str(e))
sys.exit(1)
"""
def agent_stream_file(
path: str,
*,
chunk_size: int = 1 << 16,
timeout: int = DEFAULT_TIMEOUT_SECONDS,
) -> Iterator[bytes]:
"""Yield ``path``'s bytes in ``chunk_size`` blocks, read AS THE SANDBOX USER,
without ever holding the whole file in the root server.
Same isolation model as :func:`agent_read_window` — a uid-1000 subprocess
does the ``open()`` (filesystem perms gate access, TOCTOU-safe) — but the
bytes are streamed straight through instead of materialized. Use for
whole-file reads where buffering the entire payload would be wasteful (e.g.
viewer downloads of large ``/workdir`` files).
Raises :class:`AgentReadError` if the read fails, whether ``open()`` fails
before any bytes are produced or the subprocess dies mid-stream. An empty
file yields nothing and is not an error. The subprocess is always reaped
(killed if the consumer stops iterating early).
"""
os.makedirs(WORKDIR, exist_ok=True)
# python3 (not sys.executable): the agent's subprocess runs as uid 1000 and
# must use the system interpreter on the sanitized PATH — /opt/venv is locked
# to root. Same partial-path resolution as run_in_sandbox.
command = ["python3", "-c", _AGENT_STREAM_SNIPPET, path]
# cast: the **Any privilege-drop kwargs make the type checker pick Popen's
# text-mode overload, but we pass no text/encoding so this is binary — the
# pipes carry bytes.
proc = cast(
"subprocess.Popen[bytes]",
subprocess.Popen(
command,
cwd=WORKDIR,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=_agent_env(),
**_privilege_drop_kwargs(),
),
)
assert proc.stdout is not None
assert proc.stderr is not None
try:
while True:
chunk = proc.stdout.read(chunk_size)
if not chunk:
break
yield chunk
returncode = proc.wait(timeout=timeout)
if returncode != 0:
err = proc.stderr.read().decode("utf-8", errors="replace").strip()
raise AgentReadError(err or "agent read failed")
finally:
if proc.poll() is None:
proc.kill()
proc.wait()
_AGENT_LIST_DIR_SNIPPET = r"""
import sys, os, json
path = sys.argv[1]
try:
entries = []
with os.scandir(path) as it:
for e in it:
try:
is_dir = e.is_dir()
size = None if is_dir else e.stat().st_size
except OSError:
continue
entries.append({"name": e.name, "is_dir": is_dir, "size": size})
sys.stdout.write(json.dumps({"ok": True, "entries": entries}))
except Exception as e:
sys.stdout.write(json.dumps({"ok": False, "error": str(e)}))
"""
def agent_list_dir(path: str) -> list[dict[str, Any]]:
"""List ``path`` AS THE SANDBOX USER, returning ``[{name, is_dir, size}, …]``.
Companion to :func:`agent_read_window` for the (root) server's directory
listings — the ``scandir`` runs as uid 1000 so it can't enumerate /app etc.
Raises :class:`AgentReadError` on failure.
"""
result = run_in_sandbox(["python3", "-c", _AGENT_LIST_DIR_SNIPPET, path], DEFAULT_TIMEOUT_SECONDS)
if result["returncode"] != 0:
raise AgentReadError(result.get("stderr") or result.get("error") or "agent list failed")
import json as _json
try:
data = _json.loads(result["stdout"])
except (ValueError, TypeError) as e:
raise AgentReadError(f"agent lister returned invalid output: {e}") from e
if not data.get("ok"):
raise AgentReadError(data.get("error", "agent list failed"))
return data["entries"]
def run_in_sandbox(
command: list[str],
timeout: int,
input: str | bytes | None = None,
*,
text: bool = True,
) -> dict[str, Any]:
"""
Run a command in the sandbox environment.
Args:
command: The command to run as a list of arguments.
timeout: Timeout in seconds.
input: Optional string or bytes to pass to stdin. Must be bytes when
text=False.
text: If True (default), stdout/stderr are decoded as UTF-8 strings.
If False, they are returned as raw bytes — useful for binary file
payloads.
Returns:
Dict with stdout, stderr, and returncode.
"""
workdir = WORKDIR
os.makedirs(workdir, exist_ok=True)
env = _agent_env()
drop_kwargs = _privilege_drop_kwargs()
try:
result = subprocess.run(
command,
cwd=workdir,
capture_output=True,
text=text,
timeout=timeout,
input=input,
env=env,
**drop_kwargs,
)
except subprocess.TimeoutExpired as e:
if text:
stdout = (
(e.stdout or b"").decode("utf-8", errors="replace") if isinstance(e.stdout, bytes) else (e.stdout or "")
)
stderr = (
(e.stderr or b"").decode("utf-8", errors="replace") if isinstance(e.stderr, bytes) else (e.stderr or "")
)
else:
stdout = e.stdout or b""
stderr = e.stderr or b""
return {
"stdout": stdout,
"stderr": stderr,
"returncode": -1,
"error": f"Command timed out after {timeout} seconds",
}
return {
"stdout": result.stdout,
"stderr": result.stderr,
"returncode": result.returncode,
}
+29
View File
@@ -0,0 +1,29 @@
"""Standard state tools: export_state and import_state.
Delegates to the shared state codec in ``core.state_codec`` so the MCP tools
and any future file-based loader read/write the same canonical format.
"""
from __future__ import annotations
from typing import Any
from core import state_codec
async def export_state() -> dict[str, list[dict[str, Any]]]:
"""Export the full core state as JSON.
Round-trips with import_state. State is keyed by snake_case entity-class
name (e.g. ``project``, ``employee``, ``slack_message``).
"""
return state_codec.state_to_json()
async def import_state(state: state_codec.SyntaraState) -> dict:
"""Replace the full core state with the provided JSON.
For synthetic-data injection and test setup. Round-trips with export_state.
"""
state_codec.state_from_json(state.model_dump(exclude_unset=True))
return {"ok": True}
@@ -0,0 +1,26 @@
import asyncio
from typing import Any
from core.tools.sandbox import (
DEFAULT_TIMEOUT_SECONDS,
run_in_sandbox,
)
_WRITE_SCRIPT = 'd=$(dirname -- "$1"); mkdir -p -- "${d:-.}" && tee -- "$1" >/dev/null'
async def writeFile(file_path: str, content: str) -> dict[str, Any]:
"""Write content to a file in an isolated sandbox."""
# Offload the blocking subprocess write to a worker thread.
result = await asyncio.to_thread(
run_in_sandbox,
["bash", "-c", _WRITE_SCRIPT, "--", file_path],
DEFAULT_TIMEOUT_SECONDS,
input=content,
)
return {
"stdout": result.get("stdout", ""),
"stderr": result.get("stderr") or result.get("error", ""),
"returncode": result["returncode"],
"file_path": file_path,
}
+398
View File
@@ -0,0 +1,398 @@
"""Syntara viewer — read-only filesystem browser for the agent's sandbox workdir.
Serves:
GET /api/tree?path=<rel> — directory listing
GET /api/file?path=<rel> — file contents (text; binary files flagged)
GET / — viewer HTML (single-page app)
All paths are relative to ``sandbox.WORKDIR`` (what readFile/writeFile see).
Requests are rejected if the resolved path escapes that root.
All non-MCP routes require the X-Proxy-Token header.
"""
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
import uvicorn
from starlette.applications import Starlette
from starlette.concurrency import run_in_threadpool
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
from starlette.routing import Route
from core._token import get_proxy_token
from core.tools import sandbox
MAX_FILE_BYTES = 1_000_000
BINARY_SNIFF_BYTES = 4096
class ProxyTokenMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if request.url.path.startswith("/mcp"):
return await call_next(request)
token = get_proxy_token()
if token and request.headers.get("x-proxy-token") != token:
return Response("Forbidden: invalid proxy token", status_code=403)
return await call_next(request)
def _get_root() -> Path:
root = Path(sandbox.WORKDIR).resolve()
root.mkdir(parents=True, exist_ok=True)
return root
def _resolve(rel_path: str) -> Path | None:
root = _get_root()
candidate = (root / rel_path.lstrip("/")).resolve()
try:
candidate.relative_to(root)
except ValueError:
return None
return candidate
def _looks_binary(data: bytes) -> bool:
if b"\x00" in data:
return True
try:
data.decode("utf-8")
return False
except UnicodeDecodeError:
return True
# NOTE: the core server runs as root, so every filesystem access below goes
# through the sandbox.agent_* helpers, which perform the read/scandir as the
# unprivileged sandbox user (uid 1000). _resolve() confines paths to WORKDIR,
# but reading as uid 1000 is what actually prevents this (token-gated, but
# proxied) endpoint from being a root read of /app via a symlink/TOCTOU race.
async def api_tree(request: Request) -> JSONResponse:
rel = request.query_params.get("path", "")
target = _resolve(rel)
if target is None:
return JSONResponse({"error": "path outside sandbox"}, status_code=400)
try:
raw_entries = sandbox.agent_list_dir(str(target))
except sandbox.AgentReadError as e:
return JSONResponse({"error": str(e)}, status_code=404)
entries = [
{"name": e["name"], "type": "dir" if e["is_dir"] else "file", "size": e["size"]}
for e in sorted(raw_entries, key=lambda e: (not e["is_dir"], e["name"].lower()))
]
root = _get_root()
return JSONResponse(
{
"path": str(target.relative_to(root)) if target != root else "",
"entries": entries,
}
)
async def api_file(request: Request) -> JSONResponse:
rel = request.query_params.get("path", "")
target = _resolve(rel)
if target is None:
return JSONResponse({"error": "path outside sandbox"}, status_code=400)
try:
size, header, raw, _start = sandbox.agent_read_window(
str(target), offset=0, limit=MAX_FILE_BYTES, sniff=BINARY_SNIFF_BYTES
)
except sandbox.AgentReadError as e:
return JSONResponse({"error": str(e)}, status_code=404)
if _looks_binary(header):
return JSONResponse({"path": rel, "size": size, "binary": True, "content": None})
return JSONResponse(
{
"path": rel,
"size": size,
"binary": False,
"truncated": size > MAX_FILE_BYTES,
"content": raw.decode("utf-8", errors="replace"),
}
)
async def api_download(request: Request) -> Response:
rel = request.query_params.get("path", "")
target = _resolve(rel)
if target is None:
return JSONResponse({"error": "path outside sandbox"}, status_code=400)
# Stream the file from a uid-1000 reader straight to the client so a large
# /workdir file is never buffered whole in the (root) server. Prime the
# first chunk up front (off the event loop) so a missing/denied file
# surfaces as a 404 rather than a streamed 200 that aborts mid-body.
chunks = sandbox.agent_stream_file(str(target))
try:
first = await run_in_threadpool(lambda: next(chunks, None))
except sandbox.AgentReadError as e:
return JSONResponse({"error": str(e)}, status_code=404)
def body() -> Iterator[bytes]:
if first is not None:
yield first
yield from chunks
return StreamingResponse(
body(),
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{target.name}"'},
)
async def viewer_html(request: Request) -> HTMLResponse:
return HTMLResponse(VIEWER_HTML)
def create_core_viewer_app() -> Starlette:
routes = [
Route("/", viewer_html),
Route("/api/tree", api_tree),
Route("/api/file", api_file),
Route("/api/download", api_download),
]
return Starlette(
routes=routes,
middleware=[Middleware(ProxyTokenMiddleware)],
)
def run_http_server(mcp_app, port: int) -> None:
"""Run combined MCP + viewer HTTP server."""
fastmcp_asgi = mcp_app.http_app(
transport="streamable-http",
path="/mcp",
)
viewer = create_core_viewer_app()
async def combined_app(scope, receive, send):
if scope["type"] == "lifespan":
await fastmcp_asgi(scope, receive, send)
return
path = scope.get("path", "")
if path.startswith("/mcp"):
await fastmcp_asgi(scope, receive, send)
else:
await viewer(scope, receive, send)
uvicorn.run(
combined_app,
host="127.0.0.1",
port=port,
log_level="warning",
)
VIEWER_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Syntara — Sandbox</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; display: flex; height: 100vh; overflow: hidden; }
.sidebar { width: 320px; min-width: 320px; background: #1e293b; border-right: 1px solid #334155; display: flex; flex-direction: column; overflow: hidden; }
.sidebar-header { padding: 16px; border-bottom: 1px solid #334155; }
.sidebar-header h1 { font-size: 14px; font-weight: 600; color: #f8fafc; letter-spacing: -0.2px; }
.sidebar-header .subtitle { font-size: 11px; color: #64748b; margin-top: 2px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; word-break: break-all; }
.tree { flex: 1; padding: 8px 4px; overflow-y: auto; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; }
.tree-node { user-select: none; }
.tree-row { display: flex; align-items: center; gap: 4px; padding: 3px 6px; border-radius: 4px; cursor: pointer; color: #cbd5e1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tree-row:hover { background: #0f172a; }
.tree-row.active { background: #1d4ed8; color: #fff; }
.tree-row .chev { width: 12px; color: #64748b; flex-shrink: 0; font-size: 10px; }
.tree-row .icon { width: 14px; flex-shrink: 0; opacity: 0.8; }
.tree-row .name { overflow: hidden; text-overflow: ellipsis; }
.tree-row.active .chev { color: #cbd5e1; }
.tree-children { padding-left: 14px; display: none; }
.tree-children.open { display: block; }
.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.file-header { padding: 12px 20px; border-bottom: 1px solid #334155; background: #1e293b; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: #94a3b8; display: flex; justify-content: space-between; align-items: center; }
.file-header .path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; }
.file-header .meta { color: #64748b; font-size: 11px; flex-shrink: 0; margin-left: 12px; }
.file-header .actions { flex-shrink: 0; margin-left: 12px; }
.download-btn { background: #334155; color: #e2e8f0; border: 1px solid #475569; border-radius: 4px; padding: 4px 10px; font-size: 11px; font-family: inherit; cursor: pointer; text-decoration: none; display: inline-block; }
.download-btn:hover { background: #475569; color: #fff; }
.download-btn[disabled], .download-btn.hidden { display: none; }
.file-body { flex: 1; overflow: auto; padding: 16px 20px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; line-height: 1.55; color: #e2e8f0; white-space: pre; tab-size: 4; }
.placeholder { color: #475569; font-size: 13px; padding: 24px; font-family: -apple-system, BlinkMacSystemFont, sans-serif; white-space: normal; }
.warn { color: #fb923c; }
</style>
</head>
<body>
<div class="sidebar">
<div class="sidebar-header">
<h1>Sandbox</h1>
<div class="subtitle" id="root-path">/workdir</div>
</div>
<div class="tree" id="tree"><div class="placeholder">Loading…</div></div>
</div>
<div class="main">
<div class="file-header">
<div class="path" id="file-path">No file selected</div>
<div class="meta" id="file-meta"></div>
<div class="actions">
<a class="download-btn hidden" id="download-btn" href="#" download>⬇ Download</a>
</div>
</div>
<div class="file-body" id="file-body"><div class="placeholder">Select a file from the tree on the left.</div></div>
</div>
<script>
const base = window.location.pathname.replace(/\\/$/, '');
let activeFile = null;
async function fetchJSON(path) {
const r = await fetch(base + path);
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}
function esc(s) {
if (s === null || s === undefined) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function formatSize(n) {
if (n === null || n === undefined) return '';
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
return (n / (1024 * 1024)).toFixed(1) + ' MB';
}
function joinPath(dir, name) {
if (!dir) return name;
return dir.replace(/\\/$/, '') + '/' + name;
}
async function renderRoot() {
const treeEl = document.getElementById('tree');
try {
const data = await fetchJSON('/api/tree?path=');
treeEl.innerHTML = '';
const node = buildNode('', data.entries, 0);
treeEl.appendChild(node);
} catch (e) {
treeEl.innerHTML = '<div class="placeholder">Failed to load: ' + esc(e.message) + '</div>';
}
}
function buildNode(parentPath, entries, depth) {
const frag = document.createDocumentFragment();
if (!entries.length) {
const empty = document.createElement('div');
empty.className = 'placeholder';
empty.style.padding = '8px 12px';
empty.textContent = '(empty)';
frag.appendChild(empty);
return frag;
}
for (const entry of entries) {
const fullPath = joinPath(parentPath, entry.name);
const node = document.createElement('div');
node.className = 'tree-node';
const row = document.createElement('div');
row.className = 'tree-row';
row.dataset.path = fullPath;
row.dataset.type = entry.type;
const chev = document.createElement('span');
chev.className = 'chev';
chev.textContent = entry.type === 'dir' ? '' : '';
const icon = document.createElement('span');
icon.className = 'icon';
icon.textContent = entry.type === 'dir' ? '📁' : '📄';
const name = document.createElement('span');
name.className = 'name';
name.textContent = entry.name;
row.appendChild(chev);
row.appendChild(icon);
row.appendChild(name);
node.appendChild(row);
if (entry.type === 'dir') {
const children = document.createElement('div');
children.className = 'tree-children';
node.appendChild(children);
row.addEventListener('click', async () => {
const isOpen = children.classList.contains('open');
if (isOpen) {
children.classList.remove('open');
chev.textContent = '';
} else {
if (!children.dataset.loaded) {
children.innerHTML = '<div class="placeholder" style="padding:4px 12px;">…</div>';
try {
const data = await fetchJSON('/api/tree?path=' + encodeURIComponent(fullPath));
children.innerHTML = '';
children.appendChild(buildNode(fullPath, data.entries, depth + 1));
children.dataset.loaded = '1';
} catch (e) {
children.innerHTML = '<div class="placeholder" style="padding:4px 12px;">Error</div>';
}
}
children.classList.add('open');
chev.textContent = '';
}
});
} else {
row.addEventListener('click', () => openFile(fullPath, row));
}
frag.appendChild(node);
}
return frag;
}
async function openFile(path, rowEl) {
document.querySelectorAll('.tree-row.active').forEach(el => el.classList.remove('active'));
if (rowEl) rowEl.classList.add('active');
activeFile = path;
const pathEl = document.getElementById('file-path');
const metaEl = document.getElementById('file-meta');
const bodyEl = document.getElementById('file-body');
const dlBtn = document.getElementById('download-btn');
pathEl.textContent = path;
metaEl.textContent = '';
bodyEl.innerHTML = '<div class="placeholder">Loading…</div>';
dlBtn.href = base + '/api/download?path=' + encodeURIComponent(path);
dlBtn.classList.remove('hidden');
try {
const data = await fetchJSON('/api/file?path=' + encodeURIComponent(path));
metaEl.textContent = formatSize(data.size) + (data.truncated ? ' (truncated — use Download for full file)' : '');
if (data.binary) {
bodyEl.innerHTML = '<div class="placeholder warn">Binary file — not rendered. Use Download to fetch the raw bytes.</div>';
} else {
bodyEl.textContent = data.content;
}
} catch (e) {
bodyEl.innerHTML = '<div class="placeholder">Failed to load: ' + esc(e.message) + '</div>';
}
}
renderRoot();
</script>
</body>
</html>"""
@@ -0,0 +1,297 @@
{
"schema_version": "v1",
"tools": [
{
"name": "bash",
"description": "Execute a bash command in an isolated directory.",
"inputSchema": {
"additionalProperties": false,
"properties": {
"command": {
"description": "The bash command to execute",
"type": "string"
},
"timeout_seconds": {
"anyOf": [
{
"maximum": 1800,
"minimum": 1,
"type": "integer"
},
{
"type": "null"
}
],
"default": null,
"description": "Timeout in seconds (default 120, max 1800)"
}
},
"required": [
"command"
],
"type": "object"
}
},
{
"name": "echo",
"description": "Echoes a message",
"inputSchema": {
"additionalProperties": false,
"properties": {
"message": {
"description": "The message to echo",
"type": "string"
}
},
"required": [
"message"
],
"type": "object"
}
},
{
"name": "export_state",
"description": "Export the full core state as JSON.\n\nRound-trips with import_state. State is keyed by snake_case entity-class\nname (e.g. ``project``, ``employee``, ``slack_message``).",
"inputSchema": {
"additionalProperties": false,
"properties": {},
"type": "object"
}
},
{
"name": "import_state",
"description": "Replace the full core state with the provided JSON.\n\nFor synthetic-data injection and test setup. Round-trips with export_state.",
"inputSchema": {
"additionalProperties": false,
"properties": {
"state": {
"additionalProperties": true,
"description": "Empty state \u2014 core has no DB to snapshot.",
"properties": {},
"type": "object"
}
},
"required": [
"state"
],
"type": "object"
}
},
{
"name": "listFiles",
"description": "List files and subdirectories in a directory inside the sandbox.",
"inputSchema": {
"additionalProperties": false,
"properties": {
"directory": {
"default": ".",
"description": "Directory to list, relative to the sandbox root. Use /workdir/ prefix or an absolute path within the sandbox to be explicit. Defaults to the sandbox root.",
"type": "string"
}
},
"type": "object"
}
},
{
"name": "prepareGradingContext",
"description": "Prepare the text_for_grading string for rubric evaluation.\n\nCollects file evidence from the sandbox, formats as XML,\nand prepends to the agent's final_output.\n\nArgs:\n final_output: The agent's final response text.\n directory: Root directory to search. Defaults to the sandbox directory.\n extensions: Only include files with these extensions (without dot).\n exclude_patterns: Directory/file name patterns to skip.\n max_files: Maximum number of files to include as evidence. Eligible files\n beyond this cap are omitted but reported (logged and listed in the\n XML under <files_omitted_due_to_max_files>) rather than dropped\n silently. Defaults to DEFAULT_MAX_FILES.\n max_content_bytes: Maximum bytes of content to read per file.\n\nReturns:\n The assembled text_for_grading string.",
"inputSchema": {
"additionalProperties": false,
"properties": {
"final_output": {
"type": "string"
},
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null
},
"extensions": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"default": null
},
"exclude_patterns": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"default": null
},
"max_files": {
"default": 100,
"type": "integer"
},
"max_content_bytes": {
"default": 250000,
"type": "integer"
}
},
"required": [
"final_output"
],
"type": "object"
}
},
{
"name": "readFile",
"description": "Read a file from the sandbox, with optional offset/limit (bytes) for paginating large files.",
"inputSchema": {
"additionalProperties": false,
"properties": {
"file_path": {
"description": "Path to the file. Use /workdir/ prefix for sandbox files, or an absolute path within the sandbox.",
"type": "string"
},
"offset": {
"default": 0,
"description": "Byte offset to start reading from. Use 0 for the beginning or a previous call's next_offset to continue.",
"minimum": 0,
"type": "integer"
},
"limit": {
"anyOf": [
{
"minimum": 1,
"type": "integer"
},
{
"type": "null"
}
],
"default": 200000,
"description": "Maximum number of bytes to read. Defaults to 200000. Pass null to read from offset to end of file."
}
},
"required": [
"file_path"
],
"type": "object"
}
},
{
"name": "readMedia",
"description": "Read an image, PDF, audio, or video file and return it as multimodal MCP content (base64-encoded, capped at 20 MiB; images auto-resized to model limits, large PDFs read in page ranges).",
"inputSchema": {
"additionalProperties": false,
"properties": {
"file_path": {
"description": "Path to an image (gif/jpeg/png/webp), PDF, audio (wav/mp3/aiff/aac/ogg/flac), or video (mp4/mpeg/mov/avi/flv/webm/wmv/3gpp) file. Use /workdir/ prefix for sandbox files, or an absolute path within the sandbox. Returns multimodal MCP content; images are automatically downscaled/re-encoded to fit model limits (max 2000px on the long edge). Audio/video require a model with native audio/video support (e.g. Gemini) \u2014 other models see only a text placeholder. Use readFile for text and readPDF for PDF text extraction.",
"type": "string"
},
"pages": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "PDF page range to read, 1-indexed, e.g. '3' or '1-10' (max 20 pages per read). Required for PDFs over 20 pages or 10 MiB. Ignored for images."
}
},
"required": [
"file_path"
],
"type": "object"
}
},
{
"name": "readPDF",
"description": "Extract text from a PDF file inside the sandbox.",
"inputSchema": {
"additionalProperties": false,
"properties": {
"file_path": {
"type": "string"
}
},
"required": [
"file_path"
],
"type": "object"
}
},
{
"name": "writeFile",
"description": "Write content to a file in an isolated sandbox.",
"inputSchema": {
"additionalProperties": false,
"properties": {
"file_path": {
"type": "string"
},
"content": {
"type": "string"
}
},
"required": [
"file_path",
"content"
],
"type": "object"
}
}
],
"toolsets": {
"read": [
"echo",
"listFiles",
"readFile",
"readPDF"
],
"read_multimodal": [
"echo",
"listFiles",
"readFile",
"readMedia",
"readPDF"
],
"write": [
"bash",
"writeFile"
],
"debug": [
"bash",
"echo"
],
"ds_all": [
"bash",
"listFiles",
"readFile",
"readPDF",
"writeFile"
],
"state": [
"export_state",
"import_state"
],
"grading": [
"prepareGradingContext"
]
}
}
+28
View File
@@ -0,0 +1,28 @@
{
"run": {
"command": "python",
"args": ["-m", "core.server"],
"env": { "SETUID": "1000", "SETGID": "1000", "HOME": "/home/model", "USER": "model", "LOGNAME": "model" }
},
"setup": {
"command": "python",
"args": ["-m", "core.setup"],
"env": { "SETUID": "1000", "SETGID": "1000", "HOME": "/home/model", "USER": "model", "LOGNAME": "model" }
},
"namespaced": false,
"toolsets": {
"read": ["echo", "listFiles", "readFile", "readPDF"],
"read_multimodal": ["echo", "listFiles", "readFile", "readMedia", "readPDF"],
"write": ["bash", "writeFile"],
"debug": ["bash", "echo"],
"ds_all": [
"bash",
"listFiles",
"readFile",
"readPDF",
"writeFile"
],
"state": ["export_state", "import_state"],
"grading": ["prepareGradingContext"]
}
}
+38
View File
@@ -0,0 +1,38 @@
[build-system]
build-backend = "uv_build"
requires = [ "uv-build>=0.11,<0.12" ]
[project]
name = "core"
version = "0.0.1"
description = "MCP Server"
requires-python = ">=3.13"
classifiers = [
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
]
dependencies = [
"fastmcp>=3.2,<4",
"json5>=0.9",
"mcp[cli]>=1.19",
"openpyxl>=3.1",
"pillow>=10",
"pypdf>=4.2",
"python-dateutil>=2.8",
"python-docx>=1.1",
"python-pptx>=0.6",
"read-file-safe",
]
optional-dependencies.dev = [ "pytest>=8", "pytest-snapshot>=0.9" ]
scripts.core-mcp = "core.server:main"
[tool.uv]
environments = [
"sys_platform == 'darwin' and platform_machine == 'arm64'",
"sys_platform == 'linux' and platform_machine == 'x86_64'",
]
build-backend.module-root = ""
[tool.pytest]
ini_options.pythonpath = [ "." ]
@@ -0,0 +1,305 @@
"""Pin `sandbox._agent_env`: the single chokepoint that hides /opt/venv from
agent-spawned subprocesses (bash)."""
from __future__ import annotations
import os
from unittest import mock
import pytest
from core.tools import sandbox
def _polluted_environ() -> dict[str, str]:
return {
"HOME": "/home/model",
"USER": "model",
"LANG": "C.UTF-8",
"PATH": "/opt/venv/bin:/app/scripts:/usr/local/bin:/usr/bin:/bin",
"VIRTUAL_ENV": "/opt/venv",
"UV_PROJECT_ENVIRONMENT": "/opt/venv",
"PYTHONPATH": "/app:/opt/venv/lib/python3.13/site-packages",
"PYTHONHOME": "/opt/venv",
"SETUID": "1000",
"SETGID": "1000",
}
def test_strips_opt_venv_from_path():
with mock.patch.dict(os.environ, _polluted_environ(), clear=True):
env = sandbox._agent_env()
assert "/opt/venv" not in env["PATH"], env["PATH"]
def test_strips_app_from_path():
with mock.patch.dict(os.environ, _polluted_environ(), clear=True):
env = sandbox._agent_env()
assert "/app" not in env["PATH"], env["PATH"]
def test_drops_venv_pointers():
with mock.patch.dict(os.environ, _polluted_environ(), clear=True):
env = sandbox._agent_env()
for var in ("VIRTUAL_ENV", "UV_PROJECT_ENVIRONMENT", "PYTHONPATH", "PYTHONHOME"):
assert var not in env, f"{var!r} leaked: {env.get(var)!r}"
def test_drops_privilege_control_vars():
with mock.patch.dict(os.environ, _polluted_environ(), clear=True):
env = sandbox._agent_env()
assert "SETUID" not in env
assert "SETGID" not in env
def test_preserves_user_facing_vars():
with mock.patch.dict(os.environ, _polluted_environ(), clear=True):
env = sandbox._agent_env()
assert env["HOME"] == "/home/model"
assert env["USER"] == "model"
assert env["LANG"] == "C.UTF-8"
def test_path_is_a_concrete_default_not_absent():
with mock.patch.dict(os.environ, _polluted_environ(), clear=True):
env = sandbox._agent_env()
assert env.get("PATH"), "agent PATH unexpectedly empty"
assert "/usr/bin" in env["PATH"]
assert "/usr/local/bin" in env["PATH"]
# Regression guard for the P1: derived images (docker/stem-*) append dirs like
# /opt/conda/bin; sanitising must strip only /opt/venv and /app, never the rest.
def _derived_image_environ() -> dict[str, str]:
env = _polluted_environ()
env["PATH"] = "/opt/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/opt/conda/bin"
return env
def test_preserves_derived_image_path_additions():
with mock.patch.dict(os.environ, _derived_image_environ(), clear=True):
env = sandbox._agent_env()
entries = env["PATH"].split(os.pathsep)
assert "/opt/conda/bin" in entries, env["PATH"]
assert "/opt/venv/bin" not in entries, env["PATH"]
def test_preserves_path_entry_order():
with mock.patch.dict(os.environ, _derived_image_environ(), clear=True):
env = sandbox._agent_env()
entries = env["PATH"].split(os.pathsep)
assert entries == ["/usr/local/sbin", "/usr/local/bin", "/usr/sbin", "/usr/bin", "/sbin", "/bin", "/opt/conda/bin"]
def test_strips_only_venv_and_app_prefixes_not_lookalikes():
polluted = _polluted_environ()
polluted["PATH"] = "/opt/venv/bin:/opt/venvtools/bin:/application/bin:/app/scripts:/usr/bin"
with mock.patch.dict(os.environ, polluted, clear=True):
env = sandbox._agent_env()
entries = env["PATH"].split(os.pathsep)
assert entries == ["/opt/venvtools/bin", "/application/bin", "/usr/bin"], env["PATH"]
def test_falls_back_to_default_when_only_server_entries():
polluted = _polluted_environ()
polluted["PATH"] = "/opt/venv/bin:/app/scripts"
with mock.patch.dict(os.environ, polluted, clear=True):
env = sandbox._agent_env()
assert env["PATH"] == sandbox._AGENT_DEFAULT_PATH
def test_falls_back_to_default_when_path_unset():
polluted = _polluted_environ()
del polluted["PATH"]
with mock.patch.dict(os.environ, polluted, clear=True):
env = sandbox._agent_env()
assert env["PATH"] == sandbox._AGENT_DEFAULT_PATH
def test_parent_env_unchanged():
# Sanitiser must not mutate os.environ — core itself relies on those
# vars for its own subsequent imports.
polluted = _polluted_environ()
with mock.patch.dict(os.environ, polluted, clear=True):
sandbox._agent_env()
assert os.environ["PATH"] == polluted["PATH"]
assert os.environ["VIRTUAL_ENV"] == polluted["VIRTUAL_ENV"]
assert os.environ["UV_PROJECT_ENVIRONMENT"] == polluted["UV_PROJECT_ENVIRONMENT"]
assert os.environ["SETUID"] == polluted["SETUID"]
def test_drops_harness_internal_vars():
polluted = _polluted_environ()
polluted.update(
{
"WORLDBENCH_ROOT": "/app",
"WORLDBENCH_SEED": "deadbeef",
"WORLDBENCH_TOOL_SETS": "core_debug",
# A harness var nobody listed explicitly — the prefix sweep must catch it.
"WORLDBENCH_SOME_FUTURE_VAR": "/app/whatever",
"INPUTDIR": "/app/setup_data/entities/core",
"OUTPUTDIR": "/app/output_data/core",
"BUNDLE_OUTPUT_DIR": "/app/output_data/services",
"BUNDLE_INPUT_DIR": "/app/bundle/services/core",
"BUNDLEDIR": "/app/bundle",
"PORT": "41984",
"VIEWER_PORT": "8123",
}
)
with mock.patch.dict(os.environ, polluted, clear=True):
env = sandbox._agent_env()
for var in (
"WORLDBENCH_ROOT",
"WORLDBENCH_SEED",
"WORLDBENCH_TOOL_SETS",
"WORLDBENCH_SOME_FUTURE_VAR",
"INPUTDIR",
"OUTPUTDIR",
"BUNDLE_OUTPUT_DIR",
"BUNDLE_INPUT_DIR",
"BUNDLEDIR",
"PORT",
"VIEWER_PORT",
):
assert var not in env, f"{var!r} leaked to the agent: {env.get(var)!r}"
# /app must not survive anywhere in the agent env (paths or otherwise).
assert not any("/app" in v for v in env.values()), env
def test_preserves_clock_var():
# The fake clock must reach the agent's shell; it is NOT a harness leak.
polluted = _polluted_environ()
polluted["WORLDBENCH_CURRENT_TIME"] = "2026-01-02T03:04:05Z"
with mock.patch.dict(os.environ, polluted, clear=True):
env = sandbox._agent_env()
assert env["WORLDBENCH_CURRENT_TIME"] == "2026-01-02T03:04:05Z"
def test_privilege_drop_kwargs_noop_when_not_root():
# Tests/CI run unprivileged: nothing to drop to, so subprocess gets no
# user/group kwargs and runs as the invoking user.
with mock.patch("core.tools.sandbox.os.geteuid", return_value=1000):
assert sandbox._privilege_drop_kwargs() == {}
def test_privilege_drop_kwargs_drops_when_root():
polluted = _polluted_environ() # has SETUID/SETGID = 1000
with (
mock.patch("core.tools.sandbox.os.geteuid", return_value=0),
mock.patch.dict(os.environ, polluted, clear=True),
):
kwargs = sandbox._privilege_drop_kwargs()
assert kwargs == {"user": 1000, "group": 1000, "extra_groups": []}
def test_privilege_drop_kwargs_fails_closed_when_root_without_ids():
# Root but no SETUID/SETGID: refuse rather than run the agent's command as
# root (a sandbox escape).
env = {k: v for k, v in _polluted_environ().items() if k not in ("SETUID", "SETGID")}
with (
mock.patch("core.tools.sandbox.os.geteuid", return_value=0),
mock.patch.dict(os.environ, env, clear=True),
pytest.raises(RuntimeError, match="SETUID/SETGID are not both set"),
):
sandbox._privilege_drop_kwargs()
# ---------------------------------------------------------------------------
# agent_stream_file — the unprivileged whole-file reader the viewer streams from
# ---------------------------------------------------------------------------
def test_agent_stream_file_streams_whole_file(tmp_path, monkeypatch):
monkeypatch.setattr(sandbox, "WORKDIR", str(tmp_path))
target = tmp_path / "data.bin"
payload = bytes(range(256)) * 1000 # 256 KB, spans many read() calls
target.write_bytes(payload)
chunks = list(sandbox.agent_stream_file(str(target), chunk_size=4096))
assert b"".join(chunks) == payload
# Actually streamed in pieces rather than buffered as one blob.
assert len(chunks) > 1
def test_agent_stream_file_empty_file_yields_nothing(tmp_path, monkeypatch):
monkeypatch.setattr(sandbox, "WORKDIR", str(tmp_path))
target = tmp_path / "empty.bin"
target.write_bytes(b"")
assert list(sandbox.agent_stream_file(str(target))) == []
def test_agent_stream_file_missing_file_raises(tmp_path, monkeypatch):
monkeypatch.setattr(sandbox, "WORKDIR", str(tmp_path))
with pytest.raises(sandbox.AgentReadError):
list(sandbox.agent_stream_file(str(tmp_path / "nope.bin")))
def test_agent_stream_file_rejects_fifo(tmp_path, monkeypatch):
# A named pipe with no writer would block a reader forever; the helper must
# refuse non-regular files instead of hanging. (No writer is opened here, so
# this test would deadlock if the guard regressed.)
monkeypatch.setattr(sandbox, "WORKDIR", str(tmp_path))
fifo = tmp_path / "pipe"
os.mkfifo(fifo)
with pytest.raises(sandbox.AgentReadError):
list(sandbox.agent_stream_file(str(fifo)))
def test_agent_read_window_rejects_fifo(tmp_path, monkeypatch):
# Same guard on the windowed reader (backs readFile / viewer / grading).
monkeypatch.setattr(sandbox, "WORKDIR", str(tmp_path))
fifo = tmp_path / "pipe"
os.mkfifo(fifo)
with pytest.raises(sandbox.AgentReadError):
sandbox.agent_read_window(str(fifo))
def test_agent_read_window_roundtrips(tmp_path):
sandbox.WORKDIR = str(tmp_path)
p = tmp_path / "data.bin"
p.write_bytes(b"0123456789abcdef")
size, header, raw, start = sandbox.agent_read_window(str(p), offset=4, limit=5, sniff=3)
assert size == 16
assert header == b"012"
assert raw == b"45678"
assert start == 4
def test_agent_read_window_raises_on_missing(tmp_path):
sandbox.WORKDIR = str(tmp_path)
with pytest.raises(sandbox.AgentReadError):
sandbox.agent_read_window(str(tmp_path / "nope.bin"))
def test_agent_list_dir(tmp_path):
sandbox.WORKDIR = str(tmp_path)
(tmp_path / "a.txt").write_text("hi")
(tmp_path / "sub").mkdir()
entries = {e["name"]: e for e in sandbox.agent_list_dir(str(tmp_path))}
assert entries["a.txt"]["is_dir"] is False
assert entries["a.txt"]["size"] == 2
assert entries["sub"]["is_dir"] is True
def test_run_in_sandbox_passes_sanitised_env(tmp_path):
# End-to-end: catches the failure mode where _agent_env exists but
# run_in_sandbox forgot to call it.
sandbox.WORKDIR = str(tmp_path)
polluted = _polluted_environ()
with mock.patch.dict(os.environ, polluted, clear=True):
result = sandbox.run_in_sandbox(
[
"sh",
"-c",
"echo PATH=$PATH; echo VENV=${VIRTUAL_ENV:-}; echo UV=${UV_PROJECT_ENVIRONMENT:-}; echo SU=${SETUID:-}",
],
timeout=10,
)
assert result["returncode"] == 0, result
out = result["stdout"]
assert "/opt/venv" not in out, out
assert "/app" not in out, out
assert "VENV=\n" in out or out.rstrip().endswith("VENV="), out
assert "UV=\n" in out or ("UV=" in out and "/opt/venv" not in out), out
assert "SU=\n" in out or out.rstrip().endswith("SU="), out
@@ -0,0 +1,52 @@
"""Verify HiddenToolFilter: hidden tools excluded from list_tools but still callable."""
import asyncio
from fastmcp import Client, FastMCP
from fastmcp.server.middleware import Middleware
from fastmcp.tools.function_tool import FunctionTool
class HiddenToolFilter(Middleware):
async def on_list_tools(self, context, call_next):
tool_list = await call_next(context)
return [t for t in tool_list if "hidden" not in (t.tags or set())]
def _build_app() -> FastMCP:
app = FastMCP("test")
def visible_tool() -> str:
"""A normal tool."""
return "visible"
def hidden_tool() -> str:
"""A hidden tool."""
return "hidden"
app.add_tool(FunctionTool.from_function(fn=visible_tool, name="visible_tool"))
app.add_tool(FunctionTool.from_function(fn=hidden_tool, name="hidden_tool", tags={"hidden"}))
app.add_middleware(HiddenToolFilter())
return app
async def main():
app = _build_app()
async with Client(app) as client:
# 1. Hidden tool should NOT appear in list_tools
tools = await client.list_tools()
tool_names = [t.name for t in tools]
assert "visible_tool" in tool_names, f"visible_tool missing from {tool_names}"
assert "hidden_tool" not in tool_names, f"hidden_tool should be hidden but found in {tool_names}"
print("PASS: hidden tool excluded from list_tools")
# 2. Hidden tool should still be callable
result = await client.call_tool("hidden_tool", {})
assert result.content[0].text == "hidden", f"unexpected result: {result}"
print("PASS: hidden tool still callable via call_tool")
print("\nAll checks passed.")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,524 @@
import os
import pathlib
import shutil
import tempfile
import unittest
from unittest import mock
import pytest
from core.tools import sandbox
from core.tools.prepare_grading_context import (
_extract_docx,
_extract_pptx,
_extract_text,
_extract_xlsx,
_format_evidence_as_xml,
_get_extension,
_is_excluded,
_walk_dir,
prepareGradingContext,
)
class TestHelpers(unittest.TestCase):
def test_get_extension(self):
assert _get_extension("/foo/bar.txt") == "txt"
assert _get_extension("/foo/bar.CSV") == "csv"
assert _get_extension("/foo/bar") == ""
# Dotfiles like .hidden have no extension per os.path.splitext
assert _get_extension("/foo/.hidden") == ""
def test_is_excluded(self):
excl = {"__pycache__", ".git"}
assert _is_excluded("__pycache__/foo.pyc", excl)
assert _is_excluded("src/__pycache__/foo.pyc", excl)
assert _is_excluded(".git/config", excl)
assert not _is_excluded("src/main.py", excl)
def test_walk_dir_sorted(self):
with tempfile.TemporaryDirectory() as d:
os.makedirs(os.path.join(d, "b"))
open(os.path.join(d, "b", "z.txt"), "w").close()
open(os.path.join(d, "b", "a.txt"), "w").close()
open(os.path.join(d, "c.txt"), "w").close()
results = _walk_dir(d)
names = [os.path.basename(r) for r in results]
assert names == ["a.txt", "z.txt", "c.txt"] or results == sorted(results)
class TestExtractText(unittest.TestCase):
# _extract_text now takes (raw_bytes, file_size, max_bytes) — the actual file
# read happens earlier, as the sandbox user, in prepareGradingContext.
def test_extract_text_normal(self):
content, method, truncated = _extract_text(b"hello world", 11, 50_000)
assert content == "hello world"
assert method == "read"
assert not truncated
def test_extract_text_truncated(self):
raw = b"a" * 1000
content, _method, truncated = _extract_text(raw, len(raw), 100)
assert truncated
assert content is not None
assert "[... truncated at 100 bytes" in content
assert "total size: 1,000 bytes]" in content
def test_extract_text_decodes_non_utf8_lossily(self):
# Invalid UTF-8 bytes are replaced, never raise.
content, _method, _truncated = _extract_text(b"\xff\xfe bad bytes", 12, 50_000)
assert content is not None
class TestFormatEvidenceAsXml(unittest.TestCase):
def test_empty_evidence(self):
assert _format_evidence_as_xml([]) == ""
def test_single_file_with_content(self):
evidence = [
{
"path": "/tmp/test.txt",
"extension": "txt",
"size_bytes": 11,
"content": "hello world",
"truncated": False,
}
]
xml = _format_evidence_as_xml(evidence, "/tmp")
assert '<workspace_files directory="/tmp">' in xml
assert 'path="/tmp/test.txt"' in xml
assert 'type="txt"' in xml
assert 'size_bytes="11"' in xml
assert "hello world" in xml
assert "</workspace_files>" in xml
def test_null_content_self_closing(self):
evidence = [
{
"path": "/tmp/test.bin",
"extension": "bin",
"size_bytes": 100,
"content": None,
"truncated": False,
}
]
xml = _format_evidence_as_xml(evidence)
assert "/>" in xml
assert "</file>" not in xml
def test_special_extension_extraction_method(self):
evidence = [
{
"path": "/tmp/test.pdf",
"extension": "pdf",
"size_bytes": 5000,
"content": "pdf text",
"extraction_method": "pypdf",
"truncated": False,
}
]
xml = _format_evidence_as_xml(evidence)
assert 'extraction_method="pypdf"' in xml
def test_truncated_attribute(self):
evidence = [
{
"path": "/tmp/big.txt",
"extension": "txt",
"size_bytes": 100000,
"content": "partial...",
"truncated": True,
}
]
xml = _format_evidence_as_xml(evidence)
assert 'truncated="true"' in xml
class TestPrepareGradingContext(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.temp_dir = tempfile.mkdtemp()
self.original_workdir = sandbox.WORKDIR
# prepareGradingContext now confines reads to the sandbox workdir, so
# point WORKDIR at the dir under test (mirrors production, where grading
# collects the agent's deliverables from WORKDIR).
sandbox.WORKDIR = self.temp_dir
def tearDown(self):
shutil.rmtree(self.temp_dir, ignore_errors=True)
sandbox.WORKDIR = self.original_workdir
def _write(self, rel_path: str, content: str = "test") -> str:
full = os.path.join(self.temp_dir, rel_path)
os.makedirs(os.path.dirname(full), exist_ok=True)
with open(full, "w") as f:
f.write(content)
return full
async def test_returns_xml_prepended_to_final_output(self):
self._write("hello.txt", "hello world")
result = await prepareGradingContext(final_output="agent response", directory=self.temp_dir)
assert result.startswith("<workspace_files")
assert result.endswith("agent response")
assert "\n\nagent response" in result
async def test_no_files_returns_just_final_output(self):
result = await prepareGradingContext(final_output="agent response", directory=self.temp_dir)
assert result == "agent response"
async def test_nonexistent_directory_returns_final_output(self):
result = await prepareGradingContext(final_output="agent response", directory="/nonexistent/path")
assert result == "agent response"
async def test_default_workdir(self):
workdir = tempfile.mkdtemp()
sandbox.WORKDIR = workdir
with open(os.path.join(workdir, "file.txt"), "w") as f:
f.write("test")
result = await prepareGradingContext(final_output="output")
assert "<workspace_files" in result
assert result.endswith("output")
shutil.rmtree(workdir)
async def test_extension_filter(self):
self._write("hello.txt", "text")
self._write("hello.py", "print('hi')")
self._write("hello.csv", "a,b")
result = await prepareGradingContext(
final_output="output",
directory=self.temp_dir,
extensions=["txt", "csv"],
)
assert 'type="txt"' in result
assert 'type="csv"' in result
assert 'type="py"' not in result
async def test_unsupported_extension_raises(self):
with pytest.raises(ValueError) as ctx:
await prepareGradingContext(
final_output="output",
directory=self.temp_dir,
extensions=["exe"],
)
assert "Unsupported" in str(ctx.value)
async def test_exclude_patterns(self):
self._write("src/main.py", "code")
self._write("__pycache__/cache.py", "cached")
self._write(".git/config", "git stuff")
result = await prepareGradingContext(final_output="output", directory=self.temp_dir)
assert "main.py" in result
assert "cache.py" not in result
assert "config" not in result
async def test_max_files_limit(self):
for i in range(10):
self._write(f"file{i:02d}.txt", f"content {i}")
result = await prepareGradingContext(final_output="output", directory=self.temp_dir, max_files=3)
assert result.count("<file ") == 3
async def test_max_files_overflow_is_reported_not_silent(self):
# The walk is flat + alphabetical, so a low cap can drop later files.
# Those must be surfaced (count + names), not silently dropped, so a
# rubric does not wrongly conclude an existing file is missing.
for i in range(5):
self._write(f"file{i:02d}.txt", f"content {i}")
result = await prepareGradingContext(final_output="output", directory=self.temp_dir, max_files=2)
assert result.count("<file ") == 2
assert 'truncated_to_max_files="true"' in result
assert 'dropped_file_count="3"' in result
assert "<files_omitted_due_to_max_files" in result
# The dropped files are the alphabetically-later ones.
assert "file04.txt" in result
assert result.count("<omitted_file ") == 3
async def test_no_overflow_has_no_truncation_marker(self):
for i in range(3):
self._write(f"file{i:02d}.txt", f"content {i}")
result = await prepareGradingContext(final_output="output", directory=self.temp_dir, max_files=10)
assert "truncated_to_max_files" not in result
assert "files_omitted_due_to_max_files" not in result
async def test_overflow_marker_emitted_even_when_no_files_fit(self):
# max_files=0: nothing is included, but the dir is non-empty. The grader
# must still see that files exist (and were omitted), not a bare output.
self._write("only.txt", "content")
result = await prepareGradingContext(final_output="output", directory=self.temp_dir, max_files=0)
assert result.startswith("<workspace_files")
assert 'dropped_file_count="1"' in result
assert "only.txt" in result
async def test_unsupported_files_skipped(self):
self._write("image.png", "binary")
self._write("doc.txt", "text")
result = await prepareGradingContext(final_output="output", directory=self.temp_dir)
assert 'type="txt"' in result
# Check for the unsupported file's marker tokens specifically — the
# tempdir path itself can contain "png" as a random substring.
assert "image.png" not in result
assert 'type="png"' not in result
async def test_oversized_special_file_recorded_not_extracted(self):
# A special file over the raw-read cap must be surfaced as evidence (so
# the rubric knows it exists) but NOT extracted — extracting truncated
# PDF/Office bytes would corrupt, and reading it whole could OOM grading.
self._write("big.pdf", "x" * 100)
with mock.patch("core.tools.prepare_grading_context.MAX_SPECIAL_FILE_BYTES", 10):
result = await prepareGradingContext(final_output="output", directory=self.temp_dir)
assert 'type="pdf"' in result
assert 'size_bytes="100"' in result
assert "exceeds" in result and "grading read cap" in result
assert 'truncated="true"' in result
# content is None -> self-closing tag, and the raw bytes never appear.
assert "<big.pdf" not in result
async def test_special_file_under_cap_is_extracted(self):
# A small docx under the cap still flows through normal extraction — the
# cap must not block legitimate files.
from docx import Document
path = os.path.join(self.temp_dir, "report.docx")
doc = Document()
doc.add_paragraph("under-cap evidence")
doc.save(path)
result = await prepareGradingContext(final_output="output", directory=self.temp_dir)
assert 'extraction_method="python-docx"' in result
assert "under-cap evidence" in result
async def test_returns_plain_string(self):
self._write("test.txt", "content")
result = await prepareGradingContext(final_output="my output", directory=self.temp_dir)
assert isinstance(result, str)
# Should NOT be JSON
assert not result.startswith("{")
assert not result.startswith("[")
async def test_empty_directory_returns_final_output(self):
result = await prepareGradingContext(final_output="my output", directory=self.temp_dir)
assert result == "my output"
class TestExtractDocx(unittest.TestCase):
def _make_docx(self, path: str) -> None:
from docx import Document
doc = Document()
doc.add_paragraph("Quarterly summary: revenue up 20%.")
table = doc.add_table(rows=2, cols=2)
table.cell(0, 0).text = "Metric"
table.cell(0, 1).text = "Value"
table.cell(1, 0).text = "Revenue"
table.cell(1, 1).text = "1000"
doc.save(path)
def test_extracts_paragraphs(self):
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "report.docx")
self._make_docx(path)
content, method, truncated = _extract_docx(pathlib.Path(path).read_bytes(), 50_000)
assert method == "python-docx"
assert not truncated
assert content is not None
assert "revenue up 20%" in content
def test_includes_table_cells(self):
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "report.docx")
self._make_docx(path)
content, _method, _truncated = _extract_docx(pathlib.Path(path).read_bytes(), 50_000)
# Regression: table cell text used to be dropped (paragraphs only).
assert content is not None
assert "Metric" in content
assert "Revenue" in content
assert "1000" in content
def test_includes_nested_table_cells(self):
from docx import Document
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "nested.docx")
doc = Document()
outer = doc.add_table(rows=1, cols=1)
cell = outer.cell(0, 0)
cell.paragraphs[0].text = "OuterCell"
inner = cell.add_table(rows=1, cols=2)
inner.cell(0, 0).text = "InnerA"
inner.cell(0, 1).text = "InnerB"
doc.save(path)
content, _method, _truncated = _extract_docx(pathlib.Path(path).read_bytes(), 50_000)
assert content is not None
# cell.text flattens only direct paragraphs; nested tables must recurse.
assert "OuterCell" in content
assert "InnerA" in content
assert "InnerB" in content
def test_missing_file_returns_none(self):
content, method, truncated = _extract_docx(b"not a real docx", 50_000)
assert content is None
assert method == "python-docx"
assert not truncated
class TestExtractXlsx(unittest.TestCase):
def _make_xlsx(self, path: str) -> None:
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "Data"
ws.append(["Name", "Value"])
ws.append(["foo", 42])
wb.save(path)
def test_extracts_rows(self):
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "sheet.xlsx")
self._make_xlsx(path)
content, method, truncated = _extract_xlsx(pathlib.Path(path).read_bytes(), 50_000)
assert method == "openpyxl"
assert not truncated
assert content is not None
assert "--- Sheet: Data ---" in content
assert "Name\tValue" in content
assert "foo\t42" in content
def test_missing_file_returns_none(self):
content, method, truncated = _extract_xlsx(b"not a real xlsx", 50_000)
assert content is None
assert method == "openpyxl"
assert not truncated
def test_every_sheet_appears_even_when_earlier_sheet_truncated(self):
# Regression: a big first sheet used to drain the whole budget, so later
# tabs never appeared. The budget is now per-sheet.
from openpyxl import Workbook
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "big.xlsx")
wb = Workbook()
ws1 = wb.active
ws1.title = "TB Current"
for i in range(2000):
ws1.append([f"row{i}", "Trial Balance as of 12/31/2025", i, i * 2, "padding text"])
ws2 = wb.create_sheet("TB Prior")
ws2.append(["Trial Balance as of 11/30/2025"])
ws2.append(["Prior period data", 123])
wb.save(path)
content, _method, truncated = _extract_xlsx(pathlib.Path(path).read_bytes(), 2000)
assert truncated
assert content is not None
assert "--- Sheet: TB Current ---" in content
assert "--- Sheet: TB Prior ---" in content
# The starved later sheet's data survives, and truncation is attributed.
assert "11/30/2025" in content
assert "Prior period data\t123" in content
assert "'TB Current' truncated" in content
def test_empty_trailing_rows_do_not_consume_budget(self):
# openpyxl read-only dimensions can be inflated; fully-empty rows must be
# skipped so they don't burn the budget producing only blank tab output.
from openpyxl import Workbook
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "sparse.xlsx")
wb = Workbook()
ws = wb.active
ws.title = "Sparse"
ws.append(["header", "value"])
ws.append(["real", 1])
# Force a large inflated dimension with empty cells far below.
ws.cell(row=5000, column=1, value=None)
wb.save(path)
content, _method, truncated = _extract_xlsx(pathlib.Path(path).read_bytes(), 50_000)
assert content is not None
assert not truncated
assert "header\tvalue" in content
assert "real\t1" in content
# No run of empty tab-joined rows should appear.
assert "\t\t\t" not in content
class TestExtractPptx(unittest.TestCase):
def _make_pptx(self, path: str) -> None:
from pptx import Presentation
from pptx.util import Inches
prs = Presentation()
s1 = prs.slides.add_slide(prs.slide_layouts[5])
s1.shapes.title.text = "Helix Pricing SLT Deck"
# Slide 2: a textbox, a table, and speaker notes.
s2 = prs.slides.add_slide(prs.slide_layouts[6])
tb = s2.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1))
tb.text_frame.text = "Recommended price: $499"
table = s2.shapes.add_table(2, 2, Inches(1), Inches(3), Inches(4), Inches(1)).table
table.cell(0, 0).text = "Tier"
table.cell(0, 1).text = "Price"
table.cell(1, 0).text = "Pro"
table.cell(1, 1).text = "499"
s2.notes_slide.notes_text_frame.text = "Emphasize ROI in the meeting"
prs.save(path)
def test_extracts_slides_tables_and_notes(self):
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "deck.pptx")
self._make_pptx(path)
content, method, truncated = _extract_pptx(pathlib.Path(path).read_bytes(), 50_000)
assert method == "python-pptx"
assert not truncated
assert content is not None
assert "--- Slide 1 ---" in content
assert "--- Slide 2 ---" in content
assert "Helix Pricing SLT Deck" in content
assert "Recommended price: $499" in content
assert "Tier\tPrice" in content
assert "Pro\t499" in content
assert "[Notes] Emphasize ROI in the meeting" in content
def test_missing_file_returns_none(self):
content, method, truncated = _extract_pptx(b"not a real pptx", 50_000)
assert content is None
assert method == "python-pptx"
assert not truncated
class TestPrepareGradingContextBinary(unittest.IsolatedAsyncioTestCase):
async def test_docx_and_xlsx_evidence_assembled(self):
from docx import Document
from openpyxl import Workbook
original_workdir = sandbox.WORKDIR
with tempfile.TemporaryDirectory() as d:
sandbox.WORKDIR = d # reads are confined to WORKDIR
self.addCleanup(setattr, sandbox, "WORKDIR", original_workdir)
doc = Document()
doc.add_paragraph("agent wrote this in a docx")
doc.save(os.path.join(d, "out.docx"))
wb = Workbook()
wb.active.append(["col", "val"])
wb.save(os.path.join(d, "out.xlsx"))
from pptx import Presentation
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[5])
slide.shapes.title.text = "agent wrote this in a pptx"
prs.save(os.path.join(d, "out.pptx"))
result = await prepareGradingContext(final_output="FINAL", directory=d)
assert 'type="docx"' in result
assert 'extraction_method="python-docx"' in result
assert "agent wrote this in a docx" in result
assert 'type="xlsx"' in result
assert 'extraction_method="openpyxl"' in result
# Regression: a .pptx deliverable used to be silently skipped.
assert 'type="pptx"' in result
assert 'extraction_method="python-pptx"' in result
assert "agent wrote this in a pptx" in result
assert result.endswith("FINAL")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,136 @@
"""Tests for ``core.privilege`` — the workdir-ownership helpers
(``ensure_workdir``, ``chown_tree_to_target``) used by ``core.setup``.
The per-command privilege drop lives in ``core.tools.sandbox`` now (the
server stays root and drops each agent command instead of the whole process);
its tests are in ``test_agent_env.py``.
"""
from __future__ import annotations
from unittest import mock
from core import privilege
def _patch_root(is_root: bool):
return mock.patch("core.privilege.os.geteuid", return_value=0 if is_root else 1000)
# ---------------------------------------------------------------------------
# ensure_workdir
# ---------------------------------------------------------------------------
def test_ensure_workdir_creates_and_chowns_when_root(tmp_path, monkeypatch):
monkeypatch.setenv("SETUID", "1000")
monkeypatch.setenv("SETGID", "1000")
target = tmp_path / "workdir-new"
with (
_patch_root(True),
mock.patch("core.privilege.os.chown") as chown,
mock.patch("core.privilege.sandbox.WORKDIR", str(target)),
):
privilege.ensure_workdir()
assert target.is_dir()
chown.assert_called_once_with(str(target), 1000, 1000)
def test_ensure_workdir_noop_when_not_root(tmp_path):
"""Non-root can't mkdir at / and can't chown, so the whole function is a
no-op — WORKDIR must already exist (or the caller will fail loudly)."""
target = tmp_path / "workdir-missing"
with (
_patch_root(False),
mock.patch("core.privilege.os.chown") as chown,
mock.patch("core.privilege.sandbox.WORKDIR", str(target)),
):
privilege.ensure_workdir()
assert not target.exists()
chown.assert_not_called()
def test_ensure_workdir_skips_chown_when_env_vars_unset(tmp_path, monkeypatch):
monkeypatch.delenv("SETUID", raising=False)
monkeypatch.delenv("SETGID", raising=False)
target = tmp_path / "workdir"
with (
_patch_root(True),
mock.patch("core.privilege.os.chown") as chown,
mock.patch("core.privilege.sandbox.WORKDIR", str(target)),
):
privilege.ensure_workdir()
assert target.is_dir()
chown.assert_not_called()
# ---------------------------------------------------------------------------
# chown_tree_to_target
# ---------------------------------------------------------------------------
def _make_tree(root):
"""Build a small tree: root/a.txt, root/sub/b.txt, root/sub/c.txt."""
root.mkdir(parents=True, exist_ok=True)
(root / "a.txt").write_text("a")
(root / "sub").mkdir()
(root / "sub" / "b.txt").write_text("b")
(root / "sub" / "c.txt").write_text("c")
def test_chown_tree_recursively_when_root(tmp_path, monkeypatch):
monkeypatch.setenv("SETUID", "1000")
monkeypatch.setenv("SETGID", "1000")
target = tmp_path / "workdir"
_make_tree(target)
with _patch_root(True), mock.patch("core.privilege.os.chown") as chown:
privilege.chown_tree_to_target(target)
paths = sorted(call.args[0] for call in chown.call_args_list)
expected = sorted(
[
str(target),
str(target / "a.txt"),
str(target / "sub"),
str(target / "sub" / "b.txt"),
str(target / "sub" / "c.txt"),
]
)
assert paths == expected
for call in chown.call_args_list:
assert call.args[1:] == (1000, 1000)
assert call.kwargs == {"follow_symlinks": False}
def test_chown_tree_noop_when_not_root(tmp_path, monkeypatch):
"""Local dev / CI: same env vars, but no real privilege to chown — skip."""
monkeypatch.setenv("SETUID", "1000")
monkeypatch.setenv("SETGID", "1000")
target = tmp_path / "workdir"
_make_tree(target)
with _patch_root(False), mock.patch("core.privilege.os.chown") as chown:
privilege.chown_tree_to_target(target)
chown.assert_not_called()
def test_chown_tree_noop_when_env_vars_unset(tmp_path, monkeypatch):
monkeypatch.delenv("SETUID", raising=False)
monkeypatch.delenv("SETGID", raising=False)
target = tmp_path / "workdir"
_make_tree(target)
with _patch_root(True), mock.patch("core.privilege.os.chown") as chown:
privilege.chown_tree_to_target(target)
chown.assert_not_called()
def test_chown_tree_uses_minus_one_for_missing_uid_or_gid(tmp_path, monkeypatch):
"""When only one of SETUID/SETGID is set, the other side is left unchanged
via the chown ``-1`` sentinel."""
monkeypatch.setenv("SETUID", "1000")
monkeypatch.delenv("SETGID", raising=False)
target = tmp_path / "workdir"
target.mkdir()
(target / "f.txt").write_text("x")
with _patch_root(True), mock.patch("core.privilege.os.chown") as chown:
privilege.chown_tree_to_target(target)
for call in chown.call_args_list:
assert call.args[1:] == (1000, -1)
File diff suppressed because it is too large Load Diff
+190
View File
@@ -0,0 +1,190 @@
"""Tests for core.setup — copies uploaded context files into the workdir."""
import os
import shutil
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from core import setup as setup_mod # aliased to avoid clash with pytest's xunit setup_module fixture
from core.tools import sandbox
class SetupTests(unittest.TestCase):
def setUp(self):
self.temp_dir = Path(tempfile.mkdtemp())
self.world_root = self.temp_dir / "world"
self.workdir = self.temp_dir / "workdir"
self.world_root.mkdir()
self._original_workdir = sandbox.WORKDIR
sandbox.WORKDIR = str(self.workdir)
def tearDown(self):
sandbox.WORKDIR = self._original_workdir
shutil.rmtree(self.temp_dir, ignore_errors=True)
def _run(self, task_id: str | None = None) -> int:
"""Invoke setup.main() in-process and return its exit code (0 on normal return)."""
env = {"WORLDBENCH_ROOT": str(self.world_root)}
if task_id is not None:
env["WORLDBENCH_TASK_ID"] = task_id
with mock.patch.dict(os.environ, env, clear=False):
os.environ.pop("BUNDLEDIR", None)
if task_id is None:
os.environ.pop("WORLDBENCH_TASK_ID", None)
try:
setup_mod.main()
return 0
except SystemExit as e:
return int(e.code or 0)
def test_copies_task_specific_files_into_workdir(self):
"""Task-specific setup_data/{task_id}/files/ is copied into WORKDIR."""
task_id = "task-abc"
files_dir = self.world_root / "tasks" / "setup_data" / task_id / "files"
files_dir.mkdir(parents=True)
(files_dir / "james.txt").write_text("james is the CEO")
(files_dir / "nested").mkdir()
(files_dir / "nested" / "notes.md").write_text("# notes")
assert self._run(task_id=task_id) == 0
assert (self.workdir / "james.txt").read_text() == "james is the CEO"
assert (self.workdir / "nested" / "notes.md").read_text() == "# notes"
def test_falls_back_to_generic_setup_data_when_task_dir_missing(self):
"""If no task-specific dir exists, copies from {world_root}/setup_data/files/."""
generic_files = self.world_root / "setup_data" / "files"
generic_files.mkdir(parents=True)
(generic_files / "shared.txt").write_text("shared context")
assert self._run(task_id="unknown-task") == 0
assert (self.workdir / "shared.txt").read_text() == "shared context"
def test_uses_generic_when_no_task_id(self):
"""With no WORLDBENCH_TASK_ID set, generic setup_data/files/ is used."""
generic_files = self.world_root / "setup_data" / "files"
generic_files.mkdir(parents=True)
(generic_files / "default.txt").write_text("default")
assert self._run(task_id=None) == 0
assert (self.workdir / "default.txt").read_text() == "default"
def test_merges_into_existing_workdir(self):
"""Existing workdir contents are preserved; new files are added alongside."""
self.workdir.mkdir(parents=True)
(self.workdir / "preexisting.txt").write_text("keep me")
generic_files = self.world_root / "setup_data" / "files"
generic_files.mkdir(parents=True)
(generic_files / "new.txt").write_text("new file")
assert self._run(task_id=None) == 0
assert (self.workdir / "preexisting.txt").read_text() == "keep me"
assert (self.workdir / "new.txt").read_text() == "new file"
def test_no_setup_data_exits_cleanly(self):
"""If neither task-specific nor generic setup_data exists, exit 0 without error."""
assert self._run(task_id="whatever") == 0
def test_setup_data_without_files_subdir_is_noop(self):
"""setup_data/ with no files/ subdirectory is a no-op (does not error).
Workdir IS created (ensure_workdir runs unconditionally so subsequent
tools have something to chdir into), but no files are copied.
"""
(self.world_root / "setup_data").mkdir()
assert self._run(task_id=None) == 0
def test_rejects_symlink_in_setup_files(self):
"""Symlinks in the source tree are refused — they'd let a bundle
materialize an arbitrary host path (e.g. /app/tasks) into /workdir
and then chown it to the model user."""
files_dir = self.world_root / "setup_data" / "files"
files_dir.mkdir(parents=True)
(files_dir / "real.txt").write_text("ok")
# Target doesn't need to exist for the check to fire.
os.symlink("/app/tasks", str(files_dir / "rubrics"))
assert self._run(task_id=None) == 1
assert not (self.workdir / "rubrics").exists()
assert not (self.workdir / "real.txt").exists() # copytree never ran
def test_rejects_symlinked_files_dir_root(self):
"""A symlinked files/ root is rejected too."""
real_dir = self.world_root / "setup_data" / "real_files"
real_dir.mkdir(parents=True)
(real_dir / "x.txt").write_text("x")
os.symlink(str(real_dir), str(self.world_root / "setup_data" / "files"))
assert self._run(task_id=None) == 1
assert not (self.workdir / "x.txt").exists()
def test_rejects_hardlink_in_setup_files(self):
"""Hardlinked files in the source tree are refused — they could
point at a locked-down inode (e.g. /app/packages/grading/*) and
slip past the symlink guard."""
files_dir = self.world_root / "setup_data" / "files"
files_dir.mkdir(parents=True)
# Two paths sharing one inode: the "target" simulates a protected
# file outside the bundle; the entry inside files/ is its hardlink.
protected = self.temp_dir / "fake-protected.py"
protected.write_text("rubric body")
os.link(str(protected), str(files_dir / "rubric.py"))
assert self._run(task_id=None) == 1
assert not (self.workdir / "rubric.py").exists()
def test_rejects_preexisting_workdir_symlink(self):
"""A symlink planted in /workdir before setup runs is refused —
otherwise copytree(dirs_exist_ok=True) would write through it as
root, into paths the model can't normally write."""
files_dir = self.world_root / "setup_data" / "files"
files_dir.mkdir(parents=True)
(files_dir / "context").mkdir()
(files_dir / "context" / "doc.txt").write_text("payload")
# Plant a model-controlled symlink in /workdir before setup.
self.workdir.mkdir(parents=True)
protected_target = self.temp_dir / "fake-protected-dir"
protected_target.mkdir()
os.symlink(str(protected_target), str(self.workdir / "context"))
assert self._run(task_id=None) == 1
# The bundle file must not have been written through the symlink.
assert not (protected_target / "doc.txt").exists()
def test_chowns_workdir_to_target_uid_gid_when_root(self):
"""Under (mocked) root, copied files are chowned back to SETUID:SETGID
so the unprivileged model user can modify them."""
generic_files = self.world_root / "setup_data" / "files"
generic_files.mkdir(parents=True)
(generic_files / "a.txt").write_text("hello")
(generic_files / "sub").mkdir()
(generic_files / "sub" / "b.txt").write_text("world")
env = {"WORLDBENCH_ROOT": str(self.world_root), "SETUID": "1000", "SETGID": "1000"}
with (
mock.patch.dict(os.environ, env, clear=False),
mock.patch("core.privilege.os.geteuid", return_value=0),
mock.patch("core.privilege.os.chown") as chown,
):
os.environ.pop("BUNDLEDIR", None)
os.environ.pop("WORLDBENCH_TASK_ID", None)
setup_mod.main()
chowned = sorted(call.args[0] for call in chown.call_args_list)
# ensure_workdir chowns WORKDIR; chown_tree_to_target chowns it again plus
# every entry under it. We only assert the post-copy entries are present.
assert str(self.workdir / "a.txt") in chowned
assert str(self.workdir / "sub") in chowned
assert str(self.workdir / "sub" / "b.txt") in chowned
for call in chown.call_args_list:
assert call.args[1:] == (1000, 1000)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,120 @@
"""Tests for the unified-bundle layout in core.setup.
When ``$BUNDLEDIR/files/`` is mounted (the production harness unpacks the unified
trajectory bundle there), core.setup should copy from that path instead
of the legacy ``setup_data/files/`` location. We point ``BUNDLEDIR`` at a
temp path so the test doesn't depend on the production mount path.
"""
import os
import shutil
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from core import setup as setup_mod # aliased to avoid clash with pytest's xunit setup_module fixture
from core.tools import sandbox
class BundleLayoutTests(unittest.TestCase):
def setUp(self):
self.temp_dir = Path(tempfile.mkdtemp())
self.world_root = self.temp_dir / "world"
self.workdir = self.temp_dir / "workdir"
self.bundle_dir = self.temp_dir / "bundle"
self.world_root.mkdir()
self._env_patch = mock.patch.dict(
os.environ,
{
"WORLDBENCH_ROOT": str(self.world_root),
"BUNDLEDIR": str(self.bundle_dir),
},
clear=False,
)
self._env_patch.start()
os.environ.pop("WORLDBENCH_TASK_ID", None)
self._original_workdir = sandbox.WORKDIR
sandbox.WORKDIR = str(self.workdir)
def tearDown(self):
sandbox.WORKDIR = self._original_workdir
self._env_patch.stop()
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_bundle_files_take_precedence_over_legacy_setup_data(self):
"""When $BUNDLEDIR/files/ exists, it's used instead of setup_data/files/."""
bundle_files = self.bundle_dir / "files"
bundle_files.mkdir(parents=True)
(bundle_files / "from_bundle.txt").write_text("from bundle")
legacy_files = self.world_root / "setup_data" / "files"
legacy_files.mkdir(parents=True)
(legacy_files / "from_legacy.txt").write_text("from legacy")
setup_mod.main()
assert (self.workdir / "from_bundle.txt").read_text() == "from bundle"
assert not (self.workdir / "from_legacy.txt").exists()
def test_falls_back_to_legacy_when_bundle_dir_absent(self):
"""When BUNDLEDIR points at a path that doesn't exist on disk, the
legacy setup_data path still works."""
# bundle_dir intentionally not created
legacy_files = self.world_root / "setup_data" / "files"
legacy_files.mkdir(parents=True)
(legacy_files / "from_legacy.txt").write_text("from legacy")
setup_mod.main()
assert (self.workdir / "from_legacy.txt").read_text() == "from legacy"
def test_falls_back_to_legacy_when_bundledir_unset(self):
"""When BUNDLEDIR isn't set at all (common local-dev path), the
legacy setup_data path is used."""
os.environ.pop("BUNDLEDIR", None)
legacy_files = self.world_root / "setup_data" / "files"
legacy_files.mkdir(parents=True)
(legacy_files / "from_legacy.txt").write_text("from legacy")
setup_mod.main()
assert (self.workdir / "from_legacy.txt").read_text() == "from legacy"
def test_bundle_and_legacy_produce_equivalent_workdir(self):
"""Same files served via either layout yield the same WORKDIR contents."""
contents = {"a.txt": "alpha", "nested/b.md": "# beta"}
# First run: bundle layout
bundle_files = self.bundle_dir / "files"
for rel, body in contents.items():
p = bundle_files / rel
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(body)
setup_mod.main()
bundle_snapshot = {
str(p.relative_to(self.workdir)): p.read_text() for p in self.workdir.rglob("*") if p.is_file()
}
# Second run: same files via legacy layout, fresh workdir
shutil.rmtree(self.bundle_dir)
shutil.rmtree(self.workdir, ignore_errors=True)
legacy_files = self.world_root / "setup_data" / "files"
for rel, body in contents.items():
p = legacy_files / rel
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(body)
setup_mod.main()
legacy_snapshot = {
str(p.relative_to(self.workdir)): p.read_text() for p in self.workdir.rglob("*") if p.is_file()
}
assert bundle_snapshot == legacy_snapshot
if __name__ == "__main__":
unittest.main()
+55
View File
@@ -0,0 +1,55 @@
"""Tests for core's sandbox viewer app — focused on the download path, which
streams file bytes from a uid-1000 reader instead of buffering them whole."""
import os
import pytest
from starlette.testclient import TestClient
from core import viewer
from core.tools import sandbox
@pytest.fixture
def client(tmp_path, monkeypatch):
# Point WORKDIR at a temp dir so reads stay confined to it, and run the
# viewer with no proxy token so the middleware lets requests through.
monkeypatch.setattr(sandbox, "WORKDIR", str(tmp_path))
monkeypatch.setattr(viewer, "get_proxy_token", lambda: None)
return TestClient(viewer.create_core_viewer_app())
def test_download_streams_file_contents(client, tmp_path):
payload = bytes(range(256)) * 1000 # 256 KB — larger than one read() chunk
(tmp_path / "data.bin").write_bytes(payload)
resp = client.get("/api/download", params={"path": "data.bin"})
assert resp.status_code == 200
assert resp.content == payload
assert resp.headers["content-type"] == "application/octet-stream"
assert 'filename="data.bin"' in resp.headers["content-disposition"]
def test_download_empty_file(client, tmp_path):
(tmp_path / "empty.bin").write_bytes(b"")
resp = client.get("/api/download", params={"path": "empty.bin"})
assert resp.status_code == 200
assert resp.content == b""
def test_download_missing_file_returns_404(client):
resp = client.get("/api/download", params={"path": "nope.bin"})
assert resp.status_code == 404
def test_download_path_escape_rejected(client):
resp = client.get("/api/download", params={"path": "../../etc/passwd"})
assert resp.status_code == 400
def test_download_fifo_returns_404_not_hang(client, tmp_path):
# An agent-created named pipe must not block a request worker waiting for a
# writer — it's rejected as a non-regular file. (No writer is opened, so a
# regression here would hang the test.)
os.mkfifo(tmp_path / "pipe")
resp = client.get("/api/download", params={"path": "pipe"})
assert resp.status_code == 404