Skip to content

Securing Tool Calls in an Agent

5 min read · updated August 3, 2026

Every enforced control in agent security lands in the same place: the code that runs between the model deciding to call a tool and the tool doing anything. If that code is a switch statement over tool names, you have no perimeter.

The tool layer is the perimeter

The model is not a security component. It cannot be, because its input contains attacker-authored text and its output is a probability draw. What it produces is a request — a name and a JSON object — and the useful mental model is that this request arrives from the public internet, unauthenticated, with the user’s session attached.

Framed that way, the requirements are the ones you already know from any API: authenticate the caller, authorise the specific action, validate every argument against a schema and against business rules, scope the credential, log the attempt, and rate limit. None of that is novel. What is novel is how easy it is to skip, because the caller looks like your own code.

OWASP calls the failure LLM06, Excessive Agency, and splits it usefully into three: excessive functionality (the tool does more than the task needs), excessive permissions (the credential is broader than the task), and excessive autonomy (the action happens without approval that the impact warrants).

Tools you did not write deserve a separate line in that analysis. An MCP server, a plugin or a vendor integration is remote code executing with whatever credential you handed it, and its tool descriptions are text that lands in your context window — which makes a third-party tool catalogue an injection surface as well as a dependency. Pin the version, review what the descriptions say, and give each integration its own scoped credential rather than a shared one, so that removing a compromised integration is a configuration change instead of a rotation event.

Classify by blast radius

Before writing policy, classify every tool by what it costs if it fires with attacker-chosen arguments. This is the taxonomy that decides everything else, and it should be a field on the tool definition rather than tribal knowledge.

TierDescription
read-internalReads data the user is already entitled to. Runs freely, scoped to the session. Risk is disclosure into a context that may later exfiltrate — so the trifecta rule still applies.
read-externalFetches content you do not control. Cheap, and it is the main way untrusted content enters. Allowlist hosts, cap size, and mark everything it returns as untrusted.
write-reversibleCreates a draft, a comment, a branch, a staged change. Can run without a click if it is genuinely reversible and visible. Requires an audit trail.
write-irreversibleSends, publishes, deletes, pays, grants, deploys. Always a human who sees the resolved arguments. No exceptions for trusted users, because the user is not the adversary here.

The classification exercise is worth more than it looks. Teams commonly discover a tool they thought was read-only which writes an audit record, sets a read flag, or triggers a notification — and a notification is a message, which is an exfiltration channel.

A policy gate

One function that every tool call passes through, that fails closed, and that no tool implementation can bypass because it is the only thing holding the handlers:

type Tier = "read-internal" | "read-external" | "write-reversible" | "write-irreversible";

type ToolDef<A> = {
  name: string;
  tier: Tier;
  parse: (raw: unknown) => A;        // schema validation, e.g. zod
  scopes: string[];                  // required session scopes
  run: (args: A, ctx: Ctx) => Promise<unknown>;
};

export async function callTool(
  def: ToolDef<unknown>,
  raw: unknown,
  ctx: Ctx,
): Promise<ToolResult> {
  // 1. Authorise against the SESSION, never against the prompt.
  for (const s of def.scopes) {
    if (!ctx.session.scopes.includes(s)) {
      return deny(def, "missing_scope", { scope: s }, ctx);
    }
  }

  // 2. Validate. A parse failure is a denial, not a retry loop --
  //    repeated malformed calls are themselves a signal.
  let args: unknown;
  try {
    args = def.parse(raw);
  } catch (e) {
    return deny(def, "invalid_arguments", { error: String(e) }, ctx);
  }

  // 3. Budget: tool calls, tokens and money are all exhaustible.
  if (!ctx.budget.tryConsume(def.name)) {
    return deny(def, "budget_exhausted", {}, ctx);
  }

  // 4. Approval, sized by blast radius. The human sees RESOLVED args.
  if (def.tier === "write-irreversible") {
    const ok = await ctx.approvals.request({
      tool: def.name,
      args,                       // exactly what will run
      requestedBy: ctx.session.userId,
      traceId: ctx.traceId,
    });
    if (!ok) return deny(def, "not_approved", {}, ctx);
  }

  // 5. Execute with a credential minted for THIS user and THIS action.
  const cred = await ctx.credentials.mintScoped(def.name, ctx.session);
  const out = await def.run(args, { ...ctx, cred });

  ctx.audit.record({ tool: def.name, args, ctx, outcome: "ok" });

  // 6. Everything coming back is untrusted input from here on.
  return { ok: true, untrusted: out };
}

The structure carries most of the argument. Authorisation reads the session and never the prompt, so no wording can widen it. Validation happens before anything is used. The budget check is in the same place as authorisation because exhaustion is an attack. Approval is keyed to the declared tier rather than to a per-call judgement. The credential is minted per call rather than held by the agent. And the return value is labelled untrusted in the type, which is the cheapest way to stop the next developer from concatenating it into a prompt without thinking.

Arguments are attacker-controlled

Schema validation proves the shape, not the safety. Each of these has been a real class of bug in ordinary APIs long before agents existed, and the model will happily produce all of them because a document told it to:

  • Path traversal in any file argument. Resolve, then check the result is inside the permitted root — never string-match before resolving.
  • Server-side request forgery in any URL argument. Allowlist hosts, resolve DNS and reject private ranges and link-local addresses, and disable redirects or re-check after each hop.
  • Injection downstream — SQL, shell, template. Use parameterised queries and argument arrays, and never build a command string from model output.
  • Identifier substitution. An account id in the arguments must be checked against the session’s entitlements, not trusted because it arrived in a well-formed object.
  • Quantities. Bound every numeric argument. A limit of 10,000,000 rows is a denial-of-service tool with a friendly name.

Confirmation that survives fatigue

A confirmation dialog is an enforced control only while the human is reading it. Three rules keep it that way: gate on irreversibility rather than on activity, so the prompts stay rare; show the resolved arguments rather than a summary the model wrote, because a summary is model output and model output is what you are checking; and make the expensive path the explicit one — batch approvals and “approve all for this session” convert your last enforced control into a single click made before the attack arrived.

Securing Tool Calls in an Agent · Multigrid