Skip to content

Tools

The student interacts with the environment through tools. Chalk serves them over the Model Context Protocol (MCP) from a server that runs inside the sandbox.

Built-in tools

Chalk ships these tools in chalk.tools:

Tool What it does
bash Runs a command in a persistent bash session as the student.
view_lines_in_file Returns a range of lines from a file.
replace_in_file Replaces an exact string in a file and returns a diff.
view_image_file Shows the model a .jpeg, .png, .gif or .webp image.

The file tools check permissions and read or write the file as the student, so the student can't use them to reach files it couldn't reach from bash.

Picking tools for a task

A task returns tool names from its tools property:

@property
def tools(self):
    return ["bash", "view_lines_in_file", "replace_in_file"]

CLI agents that bring their own shell and file tools use those instead of Chalk's.

Configuring tools

Tools that subclass ToolBase read a config when they start. Write one with ToolConfigWriter in the task's configure_tools(), which runs before the MCP server registers the task's tools:

from chalk import Task, ToolConfigWriter
from chalk.tools.bash import BashConfig


class MyTask(Task):
    def configure_tools(self) -> None:
        ToolConfigWriter().write("bash", BashConfig(default_timeout_s=600))

write() returns the writer, so calls chain. Configs are stored as JSON in ~/.config/chalk/tool_configs/ of the user running the run, which is root inside the sandbox.

Custom tools

Put a custom tool in src/environment/tools/<name>.py. Chalk automatically finds it by name; no need to register the tool. The module must define an object called <name> that is either:

  • an async or regular function, or
  • a class with a __call__ method. Chalk creates one instance per run, with no arguments.

The function or __call__ needs

  • a docstring, which becomes the tool description,
  • type annotations on every parameter,
  • and a return annotation of ToolResult.

Raise an exception to report an error; the model sees the error message. The builtin and external agents pass only the first content block of a result to the model.

A tool that records the student's answer and saves it in a place where the student can't change it:

# src/environment/tools/submit_answer.py
from fastmcp.tools.tool import ToolResult
from chalk import ProtectedStore


async def submit_answer(answer: str) -> ToolResult:
    """Submit your final answer."""
    ProtectedStore().write("answer", answer)
    return ToolResult(content="Answer recorded.")

To make a tool configurable, subclass ToolBase with a pydantic model as its config. The class name is the tool name, and self.config holds the config:

# src/environment/tools/run_tests.py
import asyncio

from fastmcp.tools.tool import ToolResult
from chalk import ToolBase
from chalk.subprocess import make_demote_fn, student_env
from pydantic import BaseModel


class RunTestsConfig(BaseModel):
    pytest_args: list[str] = []


class run_tests(ToolBase[RunTestsConfig]):
    config_schema = RunTestsConfig

    async def __call__(self, path: str) -> ToolResult:
        """Run the tests in `path` and return their output."""
        proc = await asyncio.create_subprocess_exec(
            "/workdir/.venv/bin/python",
            "-m",
            "pytest",
            *self.config.pytest_args,
            path,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.STDOUT,
            env=student_env(),
            preexec_fn=make_demote_fn(1),
        )
        stdout, _ = await proc.communicate()
        return ToolResult(content=stdout.decode())

A task then configures it with ToolConfigWriter().write("run_tests", RunTestsConfig(pytest_args=["-x"])).

A custom tool with the same name as a built-in one replaces it.

chalk check lists every tool it finds and fails if one doesn't meet these rules. Make sure that the tool's dependencies are in the root venv (see Python dependencies).

Running commands as the student

Warning

The MCP server runs as root. A custom tool that starts a subprocess starts it as root unless it drops privileges. The bash and file tools switch to the student user (uid and gid 1000 in the default template) first.

Pass make_demote_fn() from chalk.subprocess as preexec_fn to run the child as the student:

from chalk.subprocess import make_demote_fn, student_env

proc = await asyncio.create_subprocess_exec(
    "my-command",
    env=student_env(),
    preexec_fn=make_demote_fn(),
)

The child takes its uid and gid from CHALK_DEMOTE_ID.

Demotion changes only the uid and gid. The child still inherits the server's environment. student_env() returns that environment without secrets and with HOME, USER and LOGNAME set for the student.

Handing over pipes

If you give the child pipes (stdin, stdout or stderr set to PIPE), name the child fds they become so they are handed to the student too:

proc = await asyncio.create_subprocess_exec(
    "my-command",
    stdout=asyncio.subprocess.PIPE,
    stderr=asyncio.subprocess.PIPE,
    # fd 1 and fd 2 in the child are the pipes opened above.
    preexec_fn=make_demote_fn(1, 2),
)

A pipe created this way belongs to the MCP server, which is root. Without the handover the child can read and write the fds it inherited, but can't reopen them by path (/dev/stdin, /dev/stdout, /proc/self/fd/N), which is how many programs reach their own standard streams.

New sessions

make_preexec() takes the same arguments and also starts a new session (os.setsid()) before dropping privileges. Use it when you want to kill the child's whole process group later.