``Your tool schema says five fields. Nothing stops the model from sending six.
Tool-call arguments are JSON the model generates. The schema you pass is a very strong hint, and most of the time the model follows it. The rest of the time you get a key nobody declared, a list where you wanted a string, or a value that is close to your enum but not in it. This post builds a small stdlib Python check that sits at the tool boundary and decides what happens to each of those before your function runs.
There are three shapes I care about:
cc, force, bcc_all or similar. I call these ghost arguments."to": ["a@example.com"] when the schema says string."priority": "urgent" when the schema allows low, normal and high.
How a tool wrapper reacts decides the damage. If it calls fn(**args) on a strict signature, an invented key raises TypeError and the agent loop retries, which costs a call. If the function takes **kwargs and passes them on to an HTTP client or an SDK, the invented key can reach a real API. That second case is the one I worry about.
Use it where you have it. Provider-side strict schemas help a lot. They don't cover everything you run, though. A local OpenAI-compatible server may not pass strict through at all (there is a vLLM forum thread titled "The OpenAI endpoint doesn't support function strict setting"), framework adapters translate schemas between providers, and an MCP server receives arguments from whichever client connects to it. The tool boundary is the one place you always own, so the check goes there.
Strip versus block for unknown keys is a judgment call. I feel stripping is right for read-only tools. For tools that send, delete or pay, you can argue for blocking instead, and the code below makes that a one line change.
Save as ghost_args.py. Python 3.8+, no dependencies.
#!/usr/bin/env python3
"""Check LLM tool-call arguments against the tool's JSON schema before the tool runs.
Unknown keys are stripped and logged. Missing, mistyped or out-of-enum values block the call.
Stdlib only, Python 3.8+.
"""
import json
TYPES = {"string": str, "integer": int, "number": (int, float), "boolean": bool,
"object": dict, "array": list}
def check(schema, args, path=""):
"""Return (clean_args, stripped_keys, errors) for one object level, recursing into objects."""
props = schema.get("properties", {})
clean, stripped, errors = {}, [], []
for key in args:
if key not in props:
stripped.append(path + key)
for key in schema.get("required", []):
if key not in args:
errors.append(f"missing required '{path}{key}'")
for key, spec in props.items():
if key not in args:
continue
val, want = args[key], spec.get("type")
py = TYPES.get(want)
bad_bool = isinstance(val, bool) and want in ("integer", "number")
if py and (not isinstance(val, py) or bad_bool):
errors.append(f"'{path}{key}' should be {want}, got {type(val).__name__}")
continue
if "enum" in spec and val not in spec["enum"]:
errors.append(f"'{path}{key}'={val!r} not in {spec['enum']}")
continue
if want == "object":
sub, s2, e2 = check(spec, val, path + key + ".")
clean[key], stripped, errors = sub, stripped + s2, errors + e2
else:
clean[key] = val
return clean, stripped, errors
SEND_EMAIL = {
"type": "object",
"required": ["to", "subject", "body"],
"properties": {
"to": {"type": "string"},
"subject": {"type": "string"},
"body": {"type": "string"},
"priority": {"type": "string", "enum": ["low", "normal", "high"]},
"options": {"type": "object", "properties": {"track_opens": {"type": "boolean"}}},
},
}
CALLS = [
("clean call", {"to": "a@example.com", "subject": "Q3", "body": "attached"}),
("invented cc", {"to": "a@example.com", "subject": "Q3", "body": "attached",
"cc": "someone@example.org"}),
("invented nested key", {"to": "a@example.com", "subject": "Q3", "body": "x",
"options": {"track_opens": False, "bcc_all": True}}),
("wrong type", {"to": ["a@example.com"], "subject": "Q3", "body": "x"}),
("enum drift", {"to": "a@example.com", "subject": "Q3", "body": "x",
"priority": "urgent"}),
("missing field", {"to": "a@example.com", "body": "x"}),
]
def main():
allowed = blocked = stripped_total = 0
print(f"{'call':<22}{'verdict':<9}{'stripped':<22}errors")
for label, raw in CALLS:
clean, stripped, errors = check(SEND_EMAIL, raw)
verdict = "BLOCK" if errors else "ALLOW"
blocked += bool(errors)
allowed += not errors
stripped_total += len(stripped)
print(f"{label:<22}{verdict:<9}{','.join(stripped) or '-':<22}{'; '.join(errors) or '-'}")
if not errors and stripped:
print(f"{'':<22}runs with {json.dumps(clean, sort_keys=True)}")
print(f"\n{allowed} allowed, {blocked} blocked, {stripped_total} invented keys stripped")
return 0
if __name__ == "__main__":
raise SystemExit(main())
## Run it
$ python3 ghost_args.py
call verdict stripped errors
clean call ALLOW - -
invented cc ALLOW cc -
runs with {"body": "attached", "subject": "Q3", "to": "a@example.com"}
invented nested key ALLOW options.bcc_all -
runs with {"body": "x", "options": {"track_opens": false}, "subject": "Q3", "to": "a@example.com"}
wrong type BLOCK - 'to' should be string, got list
enum drift BLOCK - 'priority'='urgent' not in ['low', 'normal', 'high']
missing field BLOCK - missing required 'subject'
3 allowed, 3 blocked, 2 invented keys stripped
The two invented keys never reach send_email, and the log names both of them. The nested one is the case people miss. A check that only compares top-level keys against the schema would pass options.bcc_all straight through, because options itself is declared.
The three blocked calls fail before any side effect, with a message the model can read. Feed that error string back as the tool result and most models fix the call on the next turn, which is cheaper than letting the function throw somewhere deep inside an SDK.
One Python detail bit me while writing this: isinstance(True, int) is True. Without the bad_bool line, {"count": true} would pass as an integer.
If you want unknown keys to block on write tools, change the first loop to append to errors instead of stripped when the tool is marked as having side effects. That is the one line I mentioned.
It handles type, required, enum and nested objects. It does not check array items, oneOf, string formats or lengths. In production I'd use a real validator, either the jsonschema package or a Pydantic model with extra="forbid", and keep the same policy on top: strip or block, never coerce. I maintain agent-airlock, which does ghost-argument stripping and type validation as a decorator on the tool function, and the policy in this post is the same one it follows.
Put check() in front of your tool dispatcher and count stripped keys per tool per day. That number is a cheap signal. When it jumps, something changed, a prompt, a model version, or a schema someone edited without telling the agent.
If you run tools behind an LLM today, do you strip invented arguments, block them, or let them through?