Strip the tool arguments your LLM made up, before the tool runs A developer published a dependency-free Python 3.8+ module, ghost_args.py, that validates LLM tool-call arguments against a tool's JSON schema at the tool boundary before the function executes. The checker strips and logs undeclared "ghost" arguments while blocking calls with missing required fields, mistyped values, or out-of-enum values, recursing into nested objects and explicitly rejecting booleans for integer and number types. The author argues the tool boundary is the one layer developers always control, since provider-side strict schemas don't cover local OpenAI-compatible servers, framework adapters, or MCP servers receiving arguments from arbitrary clients. 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. bash /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 bool is a subclass of int in Python, so reject it explicitly for integer/number 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"}}}, }, } label, arguments as the model emitted them 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?