{"slug": "strip-the-tool-arguments-your-llm-made-up-before-the-tool-runs", "title": "Strip the tool arguments your LLM made up, before the tool runs", "summary": "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.", "body_md": "``Your tool schema says five fields. Nothing stops the model from sending six.\n\nTool-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.\n\nThere are three shapes I care about:\n\n`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.\nHow 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.\n\nUse 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.\n\nStrip 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.\n\nSave as `ghost_args.py`. Python 3.8+, no dependencies.\n\n``` bash\n#!/usr/bin/env python3\n\"\"\"Check LLM tool-call arguments against the tool's JSON schema before the tool runs.\n\nUnknown keys are stripped and logged. Missing, mistyped or out-of-enum values block the call.\nStdlib only, Python 3.8+.\n\"\"\"\nimport json\n\nTYPES = {\"string\": str, \"integer\": int, \"number\": (int, float), \"boolean\": bool,\n         \"object\": dict, \"array\": list}\n\ndef check(schema, args, path=\"\"):\n    \"\"\"Return (clean_args, stripped_keys, errors) for one object level, recursing into objects.\"\"\"\n    props = schema.get(\"properties\", {})\n    clean, stripped, errors = {}, [], []\n    for key in args:\n        if key not in props:\n            stripped.append(path + key)\n    for key in schema.get(\"required\", []):\n        if key not in args:\n            errors.append(f\"missing required '{path}{key}'\")\n    for key, spec in props.items():\n        if key not in args:\n            continue\n        val, want = args[key], spec.get(\"type\")\n        py = TYPES.get(want)\n        # bool is a subclass of int in Python, so reject it explicitly for integer/number\n        bad_bool = isinstance(val, bool) and want in (\"integer\", \"number\")\n        if py and (not isinstance(val, py) or bad_bool):\n            errors.append(f\"'{path}{key}' should be {want}, got {type(val).__name__}\")\n            continue\n        if \"enum\" in spec and val not in spec[\"enum\"]:\n            errors.append(f\"'{path}{key}'={val!r} not in {spec['enum']}\")\n            continue\n        if want == \"object\":\n            sub, s2, e2 = check(spec, val, path + key + \".\")\n            clean[key], stripped, errors = sub, stripped + s2, errors + e2\n        else:\n            clean[key] = val\n    return clean, stripped, errors\n\nSEND_EMAIL = {\n    \"type\": \"object\",\n    \"required\": [\"to\", \"subject\", \"body\"],\n    \"properties\": {\n        \"to\": {\"type\": \"string\"},\n        \"subject\": {\"type\": \"string\"},\n        \"body\": {\"type\": \"string\"},\n        \"priority\": {\"type\": \"string\", \"enum\": [\"low\", \"normal\", \"high\"]},\n        \"options\": {\"type\": \"object\", \"properties\": {\"track_opens\": {\"type\": \"boolean\"}}},\n    },\n}\n\n# (label, arguments as the model emitted them)\nCALLS = [\n    (\"clean call\", {\"to\": \"a@example.com\", \"subject\": \"Q3\", \"body\": \"attached\"}),\n    (\"invented cc\", {\"to\": \"a@example.com\", \"subject\": \"Q3\", \"body\": \"attached\",\n                     \"cc\": \"someone@example.org\"}),\n    (\"invented nested key\", {\"to\": \"a@example.com\", \"subject\": \"Q3\", \"body\": \"x\",\n                             \"options\": {\"track_opens\": False, \"bcc_all\": True}}),\n    (\"wrong type\", {\"to\": [\"a@example.com\"], \"subject\": \"Q3\", \"body\": \"x\"}),\n    (\"enum drift\", {\"to\": \"a@example.com\", \"subject\": \"Q3\", \"body\": \"x\",\n                    \"priority\": \"urgent\"}),\n    (\"missing field\", {\"to\": \"a@example.com\", \"body\": \"x\"}),\n]\n\ndef main():\n    allowed = blocked = stripped_total = 0\n    print(f\"{'call':<22}{'verdict':<9}{'stripped':<22}errors\")\n    for label, raw in CALLS:\n        clean, stripped, errors = check(SEND_EMAIL, raw)\n        verdict = \"BLOCK\" if errors else \"ALLOW\"\n        blocked += bool(errors)\n        allowed += not errors\n        stripped_total += len(stripped)\n        print(f\"{label:<22}{verdict:<9}{','.join(stripped) or '-':<22}{'; '.join(errors) or '-'}\")\n        if not errors and stripped:\n            print(f\"{'':<22}runs with {json.dumps(clean, sort_keys=True)}\")\n    print(f\"\\n{allowed} allowed, {blocked} blocked, {stripped_total} invented keys stripped\")\n    return 0\n\nif __name__ == \"__main__\":\n    raise SystemExit(main())\n\n## Run it\n\n$ python3 ghost_args.py\ncall                  verdict  stripped              errors\nclean call            ALLOW    -                     -\ninvented cc           ALLOW    cc                    -\n                      runs with {\"body\": \"attached\", \"subject\": \"Q3\", \"to\": \"a@example.com\"}\ninvented nested key   ALLOW    options.bcc_all       -\n                      runs with {\"body\": \"x\", \"options\": {\"track_opens\": false}, \"subject\": \"Q3\", \"to\": \"a@example.com\"}\nwrong type            BLOCK    -                     'to' should be string, got list\nenum drift            BLOCK    -                     'priority'='urgent' not in ['low', 'normal', 'high']\nmissing field         BLOCK    -                     missing required 'subject'\n```\n\n3 allowed, 3 blocked, 2 invented keys stripped\n\nThe 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.\n\nThe 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.\n\nOne Python detail bit me while writing this: `isinstance(True, int)` is `True`. Without the `bad_bool` line, `{\"count\": true}` would pass as an integer.\n\nIf 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.\n\nIt 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.\n\nPut `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.\n\nIf you run tools behind an LLM today, do you strip invented arguments, block them, or let them through?", "url": "https://wpnews.pro/news/strip-the-tool-arguments-your-llm-made-up-before-the-tool-runs", "canonical_source": "https://dev.to/sattyamjjain/strip-the-tool-arguments-your-llm-made-up-before-the-tool-runs-3k5m", "published_at": "2026-10-21 07:31:19+00:00", "updated_at": "2026-10-21 07:40:01.406219+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "large-language-models"], "entities": ["vLLM", "OpenAI", "MCP"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/strip-the-tool-arguments-your-llm-made-up-before-the-tool-runs", "markdown": "https://wpnews.pro/news/strip-the-tool-arguments-your-llm-made-up-before-the-tool-runs.md", "text": "https://wpnews.pro/news/strip-the-tool-arguments-your-llm-made-up-before-the-tool-runs.txt", "jsonld": "https://wpnews.pro/news/strip-the-tool-arguments-your-llm-made-up-before-the-tool-runs.jsonld"}}