AI Agents·8 min read·

Tool Schemas Models Cannot Easily Misuse

Design JSON tool schemas that block SSRF, glob shells, and extra keys so a model on your Linux VPS can call tools without inventing dangerous arguments.

NB

Netbay Infrastructure Team

Netbay Engineering

On this page

A tool schema is not documentation. It is the contract that stands between a next-token predictor and your production host. Models are good at filling in plausible arguments. Plausible is the problem. A string field named command will eventually contain rm -rf, curl to a metadata IP, or a pipe into bash. If your schema allows it, your agent will try it on a bad day.

This post is about making misuse expensive. You still need allowlists and a human gate for writes. Schema is the first filter, and it is the one you can unit test without spending API tokens.

Prefer Enums, Patterns, and Integers

Free-form strings are how tools go wrong. If there are four valid values, use enum. If the value must look like a unit name, use a pattern. If it is a count, use integer with minimum and maximum. The model can still pick a bad enum value. It cannot pick a URL you never listed.

python
RESTART_TOOL = {
    "type": "function",
    "function": {
        "name": "restart_unit",
        "description": "Restart one allowlisted systemd unit. Idempotent. No extra flags.",
        "parameters": {
            "type": "object",
            "additionalProperties": False,
            "properties": {
                "unit": {
                    "type": "string",
                    "enum": ["nginx.service", "caddy.service", "app.service"],
                },
                "dry_run": {"type": "boolean", "default": True},
            },
            "required": ["unit", "dry_run"],
        },
    },
}

FETCH_TOOL = {
    "type": "function",
    "function": {
        "name": "fetch_url",
        "description": "GET an allowlisted HTTPS URL. No redirects off-list. 4 KB cap.",
        "parameters": {
            "type": "object",
            "additionalProperties": False,
            "properties": {
                "name": {
                    "type": "string",
                    "enum": ["app_health", "app_ready", "status_page"],
                }
            },
            "required": ["name"],
        },
    },
}

URLS = {
    "app_health": "https://127.0.0.1:8443/health",
    "app_ready": "https://127.0.0.1:8443/ready",
    "status_page": "https://127.0.0.1:8443/status",
}

Notice fetch_url does not take a URL. It takes a name. Your process maps name to URL. That single change kills SSRF as a tool-calling bug. The model cannot interpolate an internal IP because it never sees a URL parameter.

Require dry_run explicitly. Default-true in JSON Schema is a hint to humans; many providers ignore defaults. If the field is required, the model must say true or false, and you can refuse false unless a human token is present.

Validate Again After the Model

Provider-side schema enforcement is incomplete. Some APIs coerce types. Some pass extra keys. Some return arguments as a truncated string. Validate in your process with a small checker you own.

python
import json
import re

def validate_args(schema_props, required, raw):
    try:
        args = json.loads(raw or "{}")
    except json.JSONDecodeError:
        raise ValueError("arguments are not JSON")
    if not isinstance(args, dict):
        raise ValueError("arguments must be an object")
    extra = set(args) - set(schema_props)
    if extra:
        raise ValueError("unexpected keys: " + ",".join(sorted(extra)))
    missing = [k for k in required if k not in args]
    if missing:
        raise ValueError("missing keys: " + ",".join(missing))
    unit = args.get("unit")
    if unit is not None:
        if "enum" in schema_props["unit"]:
            if unit not in schema_props["unit"]["enum"]:
                raise ValueError("unit not in enum")
        pattern = schema_props["unit"].get("pattern")
        if pattern and not re.match(pattern, unit):
            raise ValueError("unit fails pattern")
    return args

Keep this boring. You do not need a full JSON Schema library on day one. You need extra-key rejection, required fields, enum membership, and a size cap on the raw string before json.loads. A 2 MB arguments blob is an attack, not a tool call.

What Never Belongs in a Schema

Do not expose a shell. Do not expose a path that can walk with .. . Do not expose a URL. Do not expose an SQL string. Do not expose a regex the model wrote. Each of those is a language, and models are fluent in languages you wish they were not.

If you need to read a file, take a basename from an enum of known logs. If you need to query a database, take an id integer and run a parameterized statement you wrote. If you need network, take a name from URLS.

Write unit tests that throw the worst arguments you can think of: extra keys, nested objects, unicode slashes, env var names, JSON as a string inside a string. The test file is cheaper than a postmortem.

Treat additionalProperties as a security control, not a style nit. Models like to add explanation, timeout, and extra_flags because those keys appear in training data. If your checker only reads the keys it knows, those extras silently vanish and you think you are safe. If your checker rejects the whole call, the model gets an error observation and tries a legal shape. That feedback is the point of a tight schema.

Descriptions should say what the tool will not do. "Restart one allowlisted systemd unit. No extra flags. No glob." is better than "Restart a service." The model uses the description as a policy hint. It is not a guardrail, but it reduces how often you have to reject a call.

Run the validator in the same user as the agent. Store schemas next to the code on High-Speed SSD. Intel Xeon Platinum is idle during validation; the cost is in tokens, not CPU. Lucknow DC01 is close enough to your users that the HTTPS round trip to the model API dominates anyway. Keep a copy of the schema in git so a prompt tweak cannot silently widen an enum.

Schema tightness vs misuse Loose schema command: string url: string path: string model fills plausibly Tight schema unit: enum name: enum to URL map dry_run: required bool Local validation after the API Reject extra keys, missing required, bad enum, oversized JSON Never take command, url, SQL, or a filesystem path from the model

Takeaway

If a tool argument can be an enum or a name, it must not be a string language. Validate twice, refuse extra keys, and map names to URLs in your process. Follow along on a Netbay Ubuntu VPS in Lucknow — netbayhosts.in.

Keep reading

Follow along on a real VPS

Deploy Linux in under 60 seconds

These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.

Deploy an instance