DEV Community

Dakota Liu
Dakota Liu

Posted on

Pin Agent Tools to a Checked-In Schema Before the First Call

If the model can name a tool that is not in your repo, you do not have an agent. You have a confused intern with root. Pin the tool list, hash it, and drop every call that does not match.

That is the whole article. The rest is a from-zero walkthrough you can run on a laptop. Want a remote box later? Fine. The proxy still lives next to your code.

Why start here? Because every flashy agent demo hides the same leak. The model invents a tool, a path, a “helpful” side effect. Your loop shrugs and calls it. I do not shrug. Do you?

What you will build

A tiny three-file loop:

  1. tools.schema.json — the only tools that exist.
  2. proxy.py — hashes that file, validates each call, then dispatches.
  3. test_proxy.py — four checks that must pass before any model sees a prompt.

The model’s job is narrow. It emits one JSON object. The proxy decides whether that object is real. If the schema file changes, the hash changes, and yesterday’s traces are no longer trusted. That is the point.

I am not wiring a full MCP stack. I am not explaining twenty agent terms. I am pinning a contract you can grep.

Stage 0: a scratch directory you can throw away

Do this in an empty folder. Not in your app repo. Not next to .env.

mkdir agent-tool-pin && cd agent-tool-pin
python3 -m venv .venv
. .venv/bin/activate
Enter fullscreen mode Exit fullscreen mode

Verification: which python points at .venv. If it does not, stop. You are about to install nothing, but habits still matter.

Stage 1: write the schema as data, not as a prompt paragraph

Prompts rot. Files get reviewed. Put the tools in JSON.

{
  "schema_version": 1,
  "tools": {
    "list_dir": {
      "args": {"path": "string"},
      "side_effect": "read",
      "roots": ["workspace"]
    },
    "read_file": {
      "args": {"path": "string", "max_bytes": "int"},
      "side_effect": "read",
      "roots": ["workspace"]
    },
    "write_file": {
      "args": {"path": "string", "content": "string"},
      "side_effect": "write",
      "roots": ["workspace"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Save it as tools.schema.json. Three tools. That is generous. Most chores need two.

Verification:

python3 -c "import json; json.load(open('tools.schema.json')); print('schema ok')"
Enter fullscreen mode Exit fullscreen mode

If that prints anything other than schema ok, you do not have a schema. You have a typo.

Stage 2: hash the file you actually ship

Do not hash a Python dict you rebuilt in memory. Hash the bytes on disk. Same file CI sees. Same file the proxy loads.

# hash_schema.py
from pathlib import Path
import hashlib

p = Path("tools.schema.json")
digest = hashlib.sha256(p.read_bytes()).hexdigest()
print(digest)
Path("tools.schema.sha256").write_text(digest + "\n")
Enter fullscreen mode Exit fullscreen mode

Run it:

python3 hash_schema.py
cat tools.schema.sha256
Enter fullscreen mode Exit fullscreen mode

Verification: the file contains 64 hex chars and a newline. Commit both files together. If someone “just adds a tool” and forgets the hash, the proxy must refuse to start. That refusal is the feature.

Stage 3: the proxy that fails closed

Here is the dispatcher I actually want in the loop. It does not talk to a model. It does not guess. It loads schema, checks hash, checks name, checks args, checks path roots, then runs one function.

# proxy.py
from __future__ import annotations

import hashlib
import json
from pathlib import Path
from typing import Any

ROOT = Path(__file__).resolve().parent
WORKSPACE = ROOT / "workspace"
SCHEMA_PATH = ROOT / "tools.schema.json"
HASH_PATH = ROOT / "tools.schema.sha256"

class ToolRefused(Exception):
    pass

def load_schema() -> dict[str, Any]:
    expected = HASH_PATH.read_text().strip()
    actual = hashlib.sha256(SCHEMA_PATH.read_bytes()).hexdigest()
    if actual != expected:
        raise ToolRefused(f"schema hash mismatch: {actual} != {expected}")
    return json.loads(SCHEMA_PATH.read_text())

def _safe_path(raw: str) -> Path:
    candidate = (WORKSPACE / raw).resolve()
    workspace = WORKSPACE.resolve()
    if workspace not in candidate.parents and candidate != workspace:
        raise ToolRefused(f"path escapes workspace: {raw}")
    return candidate

def list_dir(path: str) -> dict[str, Any]:
    target = _safe_path(path)
    if not target.exists():
        raise ToolRefused(f"missing: {path}")
    names = sorted(p.name for p in target.iterdir())
    return {"path": path, "entries": names}

def read_file(path: str, max_bytes: int) -> dict[str, Any]:
    if not isinstance(max_bytes, int) or max_bytes < 1 or max_bytes > 65536:
        raise ToolRefused("max_bytes out of range")
    target = _safe_path(path)
    data = target.read_bytes()[:max_bytes]
    return {"path": path, "bytes": len(data), "text": data.decode("utf-8", "replace")}

def write_file(path: str, content: str) -> dict[str, Any]:
    if not isinstance(content, str):
        raise ToolRefused("content must be a string")
    if len(content.encode("utf-8")) > 65536:
        raise ToolRefused("write too large")
    target = _safe_path(path)
    target.parent.mkdir(parents=True, exist_ok=True)
    target.write_text(content)
    return {"path": path, "wrote": True}

DISPATCH = {
    "list_dir": list_dir,
    "read_file": read_file,
    "write_file": write_file,
}

def run_tool_call(call: dict[str, Any]) -> dict[str, Any]:
    schema = load_schema()
    if set(call.keys()) != {"tool", "args"}:
        raise ToolRefused("call must be {tool, args} only")
    name = call["tool"]
    args = call["args"]
    if name not in schema["tools"]:
        raise ToolRefused(f"unknown tool: {name}")
    spec = schema["tools"][name]
    expected_args = spec["args"]
    if set(args.keys()) != set(expected_args.keys()):
        raise ToolRefused(f"args mismatch for {name}")
    # Type tags are labels, not a full validator. Still cheaper than hope.
    for key, kind in expected_args.items():
        value = args[key]
        if kind == "string" and not isinstance(value, str):
            raise ToolRefused(f"{key} must be string")
        if kind == "int" and not isinstance(value, int):
            raise ToolRefused(f"{key} must be int")
    fn = DISPATCH[name]
    return {"ok": True, "tool": name, "result": fn(**args)}
Enter fullscreen mode Exit fullscreen mode

Look at DISPATCH. If a name is in the schema but missing here, run_tool_call will blow up on the lookup. Good. Do not auto-generate the map from the schema. Two lists that must agree are better than one list that lies.

Verification:

mkdir -p workspace
echo 'hello' > workspace/note.txt
python3 - <<'PY'
from proxy import run_tool_call
print(run_tool_call({"tool": "list_dir", "args": {"path": "."}}))
PY
Enter fullscreen mode Exit fullscreen mode

You should see note.txt in entries. If you do not, the workspace path is wrong. Fix that before you invite a model.

Stage 4: prove the refusals, then maybe call a model

Tests first. Always. The model is the last guest you invite to this party. Why would you reverse that?

# test_proxy.py
import json
from pathlib import Path

import pytest
from proxy import ToolRefused, run_tool_call

def test_happy_read():
    Path("workspace/note.txt").write_text("hello\n")
    out = run_tool_call({"tool": "read_file", "args": {"path": "note.txt", "max_bytes": 32}})
    assert out["ok"] is True
    assert "hello" in out["result"]["text"]

def test_unknown_tool_is_dead():
    with pytest.raises(ToolRefused, match="unknown tool"):
        run_tool_call({"tool": "run_shell", "args": {"cmd": "id"}})

def test_extra_arg_is_dead():
    with pytest.raises(ToolRefused, match="args mismatch"):
        run_tool_call({"tool": "list_dir", "args": {"path": ".", "follow_symlinks": True}})

def test_path_escape_is_dead():
    with pytest.raises(ToolRefused, match="escapes workspace"):
        run_tool_call({"tool": "read_file", "args": {"path": "../proxy.py", "max_bytes": 16}})

def test_hash_mismatch_is_dead(tmp_path, monkeypatch):
    # This test is a reminder: if you edit the schema, update the hash in the same commit.
    schema = Path("tools.schema.json")
    original = schema.read_text()
    try:
        data = json.loads(original)
        data["tools"]["run_shell"] = {"args": {"cmd": "string"}, "side_effect": "exec", "roots": []}
        schema.write_text(json.dumps(data))
        with pytest.raises(ToolRefused, match="schema hash mismatch"):
            run_tool_call({"tool": "list_dir", "args": {"path": "."}})
    finally:
        schema.write_text(original)
Enter fullscreen mode Exit fullscreen mode

Install pytest in the venv and run it:

pip install pytest
pytest -q test_proxy.py
Enter fullscreen mode Exit fullscreen mode

Verification: four failures mean you do not ship. One skip means you got bored. Zero skips, all green, then you have a gate.

The run_shell case is the whole thesis. The model will ask for it. Your proxy will not grow a mouth.

Stage 5: give the model one shape, not a personality

Now the proposer. Keep it boring. The model returns JSON. You parse it. You do not “extract” it from a paragraph with regex and a prayer.

# propose.py
import json
from pathlib import Path

SCHEMA = Path("tools.schema.json").read_text()

SYSTEM = """You propose exactly one tool call.
Return a JSON object with keys tool and args.
No markdown. No commentary.
You may only use tools listed in the schema.
"""

def build_user_prompt(task: str) -> str:
    return (
        "schema:\n"
        + SCHEMA
        + "\n\ntask:\n"
        + task
        + "\n"
    )

def parse_call(raw: str) -> dict:
    data = json.loads(raw)
    if not isinstance(data, dict):
        raise ValueError("not an object")
    return data
Enter fullscreen mode Exit fullscreen mode

Wire the model however you already do. A local stub is enough to finish the tutorial:

# stub_model.py
def complete(_system: str, user: str) -> str:
    # Deterministic stand-in so the loop is testable offline.
    if "list the workspace" in user:
        return '{"tool": "list_dir", "args": {"path": "."}}'
    return '{"tool": "run_shell", "args": {"cmd": "echo pwned"}}'
Enter fullscreen mode Exit fullscreen mode

Then the loop:

# loop.py
from propose import SYSTEM, build_user_prompt, parse_call
from proxy import ToolRefused, run_tool_call
from stub_model import complete

def one_step(task: str) -> dict:
    raw = complete(SYSTEM, build_user_prompt(task))
    call = parse_call(raw)
    try:
        return run_tool_call(call)
    except ToolRefused as exc:
        return {"ok": False, "error": str(exc), "raw": call}

if __name__ == "__main__":
    print(one_step("list the workspace"))
    print(one_step("open a shell and look around"))
Enter fullscreen mode Exit fullscreen mode

Run it:

python3 loop.py
Enter fullscreen mode Exit fullscreen mode

Verification: first print has "ok": true. Second print has "ok": false and unknown tool. If the second one runs a shell, you are not following this article. You are following a demo.

Stage 6: when the proposer should not sit on your laptop

The proxy is local on purpose. The model call does not have to be. If you want the proposer off your machine, park that step on a free remote box and keep proxy.py where your files live.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. I use MonkeyCode’s free model access and free server option for the proposer hop when I do not want another API key on the laptop. The schema file, the hash, and the dispatcher still stay in the repo. The remote side only sees the schema text and the task string. It never gets a shell.

Copy tools.schema.json into the remote prompt builder. Do not copy workspace/. Do not copy secrets. Bring back one JSON object. Run run_tool_call at home. Same tests. Same refusals.

A sketch, labeled as a sketch, not as a vendor SDK:

[laptop] pytest test_proxy.py          # must be green
[laptop] send schema + task  ----->   [remote proposer]
[laptop] recv JSON object    <-----   [remote proposer]
[laptop] run_tool_call(call)          # fail closed
Enter fullscreen mode Exit fullscreen mode

If the remote box is gone tomorrow, the proxy still works with stub_model.py. That is the design. A free server is a convenience. It is not your trust boundary.

What this does not do

It does not stop a model from writing junk inside workspace. It does not replace code review. It does not validate semantic meaning, only names, types, and path roots. write_file can still clobber workspace/note.txt. That is allowed. That is why the workspace is a scratch dir.

It also does not make an “agent.” Three tools and a JSON blob are a function call with extra latency. If you need multi-step plans, add an explicit step counter and a human checkpoint. Do not add a hidden planner that can mint tools.

Skip this approach if you are driving a production deploy bot, anything with cloud credentials, or a checkout that is not disposable. Skip it if your schema includes exec, http_request, or sql. Those are not tools you pin with a weekend proxy. Those are products with audits.

The check I run before I trust a change

  1. Schema and hash in the same commit.
  2. pytest -q test_proxy.py green.
  3. Invented tool still refused.
  4. Path ../ still refused.
  5. Model output parsed as JSON, never as markdown.
  6. Remote proposer, if any, never mounts the workspace.

If step 2 is red, I do not “just try the model anyway.” That sentence is how you get a shell. You already knew that. The hash file is there so you cannot pretend you forgot.

Top comments (0)