Back to Blog
A closed gate representing a validation checkpoint in front of AI agent tool execution

AI Agent Tool Validation: A Gate Before Execution

7 min read

A model can pick the right tool and still send the wrong arguments. A valid customer ID may belong to another tenant. A retry may submit the same refund twice. Treat every generated call as an untrusted proposal: the model proposes, and deterministic application code decides whether the call is complete, authorized and safe to run. This guide covers the gate that makes that decision, from parsing to audit, and the tests that prove rejected calls never reach a business API.

Why strict schemas are necessary but insufficient

Function tools give a model a name, description and argument shape. OpenAI's function-calling guide documents JSON Schema definitions and strict argument generation, and Anthropic's tool-use guide documents input schemas. Use them. A schema still only proves shape:

{
  "type": "object",
  "properties": {
    "invoice_id": { "type": "string" },
    "status": { "enum": ["approved", "rejected"] },
    "reason": { "type": "string" }
  },
  "required": ["invoice_id", "status"],
  "additionalProperties": false
}

A call can match this and still name an invoice that does not exist, belongs to another organization or is already paid. The employee may lack approval authority, and reason may carry text copied from an untrusted document.

Malformed output also survives strict mode in practice. OpenAI's structured outputs guide says a response can be incomplete when it reaches a max-tokens limit, and its examples check the response status for exactly that. A truncated argument string is not a repair candidate. Check the completion status before parsing, and if the call was cut off, re-request it rather than asking the model to patch half a payload. A Woo AI Manager issue shows the cheaper failure: an orchestration loop crashing on an unguarded json.loads.

Put one gate in front of every executor

Route every proposed call through one function that returns execute, repair, confirm or deny. Only the first reaches the tool adapter. The MCP tools specification asks servers to validate inputs and apply access controls, and recommends confirmation for sensitive operations, timeouts, result validation and audit logging on the client. Process each call in this order:

  1. Capture the raw proposal and its completion status.
  2. Parse arguments with a size limit.
  3. Validate against the registered schema.
  4. Attach identity and tenant from the authenticated session.
  5. Resolve referenced business objects under that scope.
  6. Authorize against current policy.
  7. Classify side effects and confirmation needs.
  8. Assign an idempotency key to retryable writes.
  9. Execute through an adapter with a timeout.
  10. Validate and sanitize the result before the model sees it.
  11. Record the disposition and outcome.

Keep generated arguments apart from trusted context

The model must never choose user_id, tenant_id, role or authorization scope. If it proposes "tenant_id": "tenant_blue" next to "invoice_id": "inv_481", reject the extra property, resolve the invoice with the tenant from the session, and answer an out-of-scope invoice with a generic denial that does not reveal who owns it. Omitting the approval tool from a viewer's tool list is a good second layer. It is not authorization, because tool lists go stale.

One more layer sits below the gate. OWASP's guidance on excessive agency says to enforce authorization in downstream systems instead of relying on an LLM to decide whether an action is allowed, and to run actions in the context of the specific user. A gate that lives only in the agent process protects nothing from the next service that calls the same API. Pass the user's identity and scopes to the downstream API as well, so a gate bug does not become a privilege escalation.

Implement the gate as ordinary code

def gate_tool_call(raw_call, session, registry, policy, store):
    tool = registry.get(raw_call.name)
    if tool is None:
        return Repair(code="unknown_tool", public_message="Choose an available tool.")
    if raw_call.truncated:
        return Retry(code="truncated_call")
    try:
        args = parse_json_limited(raw_call.arguments, max_bytes=16_384)
    except ParseError:
        return Repair(code="invalid_json", public_message="Return valid JSON arguments.")
    errors = tool.input_schema.validate(args)
    if errors:
        return Repair(code="invalid_arguments", public_message=format_schema_errors(errors))
    context = TrustedContext(
        subject=session.user_id,
        tenant=session.tenant_id,
        roles=session.roles,
        request_id=session.request_id,
    )
    objects = tool.resolve_objects(args, context)
    decision = policy.authorize(
        subject=context.subject,
        tenant=context.tenant,
        action=tool.action,
        objects=objects,
        arguments=args,
    )
    if not decision.allowed:
        return Deny(code="not_authorized", public_message="This action is not allowed.")
    if tool.requires_confirmation(args, objects):
        return Confirm(summary=tool.confirmation_summary(args, objects))
    command = tool.build_command(args=args, context=context, objects=objects)
    key = derive_idempotency_key(context.request_id, raw_call.id, command)
    return Execute(command=command, idempotency_key=key)

Keep the public error separate from the internal one. The model needs to learn that a field is missing or an enum value is wrong. It does not need a stack trace, a hidden policy condition or the identity of a protected object. The OpenAI Agents SDK guardrails give you a hook for input, output and tool checks, and the policy and object logic should still be plain code you can test without a model.

Repair, deny or confirm

Repair bounded, non-sensitive errors the model can fix without guessing: bad JSON, an unsupported enum, an unknown tool name, a missing field already stated in the conversation. Cap the attempts. Deny authorization failures, inaccessible objects and hard business limits, and keep the denial stable across retries unless trusted context changes. Ask for confirmation when the caller is authorized but the action is consequential or ambiguous. Show the normalized action, target and effect. Execute only after every check passes, with an idempotency key on writes so a timeout does not cause a second side effect. A timeout is also not proof that nothing happened, so query by the key or operation ID before retrying a write.

A confirmation must bind to the content, not the call

Confirmation has a gap that schema checks never cover. If the call references a mutable object, such as a draft ID, the user can approve one version and the executor can run another. Close it by building the approval card from the actual draft (recipients, subject, body) and storing a SHA-256 digest of the full content with the approval record. When someone presses Approve, rebuild the preview from the live draft and compare it with the stored one. If they differ, send nothing and tell the user: "The message changed after the approval card was posted. It was not sent; review a new card." Hash the arguments plus the resolved preview for other write tools as well, so a card whose text was clipped for display still binds to everything behind it. Writes should expire after a short window, such as 30 minutes.

Treat tool output as untrusted too, which is step ten of the list above. Wrap every tool result in delimiters, neutralize any closing delimiter inside it so the content cannot break out of the untrusted region, and scan it for injection patterns before the model sees it. A hit replaces the result with "tool output withheld". A pattern scan only catches the lazy attacks, so it supports the other controls and does not replace them. One schema gotcha comes from Anthropic's tool-definition guidance: a parameter that asks the model for its thinking or step-by-step reasoning might lead to a reasoning_extraction refusal, so ask for a short explanation or the supporting evidence instead (Anthropic, define tools).

Audit and ceilings

Record request, run and call IDs, authenticated subject and tenant, tool name and schema version, policy version and disposition, referenced object IDs, who confirmed and when, the idempotency key, and the outcome. Store a normalized, redacted form instead of raw arguments, and a bounded hash for payloads that failed to parse. Set hard ceilings for argument size, repair attempts, tool calls per run, execution time and response size, so one malformed proposal cannot grow into an expensive loop.

Test the gate with negative cases

For each consequential tool, build a fixture matrix and assert the adapter was called only when the expected disposition was execute. Cover malformed and truncated JSON, missing and unexpected properties, wrong types, a nonexistent object, a valid object owned by another tenant, a read-only user attempting a write, a high-impact action without confirmation, a draft edited after confirmation, a duplicate write with the same key, a downstream timeout after an ambiguous commit, and oversized tool output. For every denial, assert two things: the adapter received zero calls, and the model-facing text revealed no protected object or policy detail. Run this suite without a live model. Then add a small model-backed suite with ambiguous requests and prompt-injected documents to measure how often proposals land in each disposition.

Start with your riskiest write tool. Remove identity fields from its schema, add those fixtures, and connect the next write tool only after every denied path leaves the first adapter untouched.

References


About Fire In Belly: Independent senior engineering from Tallinn, Estonia. We design and build AI workflow automation with the typed execution gates, trusted-context separation, and negative-case testing described above, at published fixed prices. Schedule a call to discuss your next project.