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.
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.
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.
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 argsKeep 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.
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