Skip to content
Blog

Tool Permission Profiles: Least Privilege for Function Calls

Define named permission profiles, map every tool to a class, and enforce them outside the model with guardrails, approvals, and narrowed schemas.

Published on • October 16, 2026

AI Assistant

The model picks which tool to call; that is a capability decision, not an authorization decision. Asking an LLM to “only use destructive tools when necessary” is a wish, and prompts leak, jailbreak, and get overridden by a well-worded user. Real least privilege for function calls means the permission lives in your runtime: named profiles, a declared mapping from tool to profile, and a check that runs before the tool body does - defaulting to deny for anything you never declared.

Four profiles, declared up front

Keep the taxonomy small enough to reason about in a review:

ProfileMay doExamples
readfetch, search, listsearch_orders, get_customer_profile, web search
writemutate your own systemupdate_ticket, append_row
destructiveremove or irreversibly changedelete_project, cancel_subscription
externalleave your boundarysend_email, issue_refund, post_to_slack

Anything not mapped to a profile is denied by construction. That default-deny rule is the whole design: the allowlist is data, and the enforcement code does not care what the model believes.

# permissions.yaml
profiles:
  read:        { requires_approval: false, audit: minimal }
  write:       { requires_approval: false, audit: full }
  destructive: { requires_approval: true,  audit: full }
  external:    { requires_approval: true,  audit: full }

tools:
  search_orders:      read
  get_customer_profile: read
  update_ticket:      write
  delete_project:     destructive
  issue_refund:       external
  send_email:         external

Load it once, build a dict[str, str], and treat a missing key as a hard denial, not a fallback to read.

Enforce with tool guardrails, before the body runs

In the OpenAI Agents SDK, checks attached to an agent only fire at the chain’s boundaries: input guardrails run for the first agent in a chain, output guardrails for the agent that produces the final output. For per-call enforcement in a workflow with handoffs or delegated specialists, the docs say to use tool guardrails instead.

Input tool guardrails run before the tool executes and can allow, replace the output, or raise a tripwire:

import json
from agents import Agent, Runner, ToolGuardrailFunctionOutput
from agents.decorators import tool, tool_input_guardrail, tool_output_guardrail

PERMISSIONS = {"search_orders": "read", "update_ticket": "write",
               "delete_project": "destructive", "issue_refund": "external"}
ACTIVE_PROFILE = {"agent": "support_tier1"}   # comes from tenant/agent scope, not the model

@tool_input_guardrail
def enforce_profile(data):
    ctx = data.context
    args = json.loads(ctx.tool_arguments or "{}")
    name = getattr(ctx.tool, "name", "unknown")
    profile = PERMISSIONS.get(name)            # default-deny
    decision = {
        "tool": name, "profile": profile,
        "profile_active": ACTIVE_PROFILE["agent"],
        "args_hash": hash(json.dumps(args, sort_keys=True)),
        "verdict": "deny" if profile is None else "allow",
    }
    audit_log.append(decision)                 # append-only, queryable
    if profile is None:
        return ToolGuardrailFunctionOutput.reject_content(
            f"Tool {name} is not registered in any permission profile."
        )
    if profile in ("destructive", "external") and not ctx.context.get("preapproved"):
        return ToolGuardrailFunctionOutput.reject_content(
            f"{profile} tool {name} requires an explicit user approval."
        )
    return ToolGuardrailFunctionOutput.allow()

@tool(tool_input_guardrails=[enforce_profile])
def delete_project(project_id: str) -> str:
    """Irreversibly delete a project and all of its data."""
    return repository.delete_project(project_id)

A denial raises a ToolInputGuardrailTripwireTriggered exception and stops the run; completed guardrail results stay available on the exception’s run_data for your audit trail.

Approval for destructive and external calls

Approval is how privilege gets escalated by a human rather than by the model. Set needs_approval to True or to a callable that decides per call:

from agents import Agent, Runner
from agents.decorators import tool

def refund_policy(_ctx, params, _call_id) -> bool:
    """Approve only small refunds automatically; everything else pauses."""
    return params.get("amount_cents", 0) > 500

@tool(needs_approval=refund_policy)
async def issue_refund(order_id: str, amount_cents: int) -> str:
    """Refund a paid order."""
    return f"refunded {amount_cents} on {order_id}"

agent = Agent(name="Support", instructions="Handle tickets.", tools=[issue_refund])

The approval rules fail closed: if the SDK cannot safely inspect the arguments - missing, empty, malformed JSON, not an object, or containing non-standard constants - the callable is not invoked and the call requires manual approval.

The flow itself: the runner evaluates the rule, pauses the run, and surfaces ToolApprovalItem entries in result.interruptions with the agent name, tool name, and arguments. You convert to a durable state, decide, and resume:

result = await Runner.run(agent, "Refund order A-4471 for 2500 cents")

while result.interruptions:
    state = result.to_state()
    for interruption in result.interruptions:
        if reviewer_says_yes(interruption):      # authenticated human, not the model
            state.approve(interruption)          # always_approve=True only if you mean it
        else:
            state.reject(interruption, rejection_message="Refund denied by policy.")
    result = await Runner.run(agent, state)

Two escalation traps to close. First, always_approve=True makes a sticky decision for the rest of the run - that is a deliberate temporary grant, so log it as one. Second, serialized RunState contains approval decisions and tool arguments, and RunState.from_json() does not authenticate the snapshot. For a browser or mobile approval UI, keep the full snapshot in server storage, send the reviewer only opaque identifiers, authenticate the reviewer server-side, authorize them against that run, validate submitted decisions against the server’s pending items, and never accept replacement tool calls or arguments from the client.

Narrow schemas so misuse is impossible

The cheapest denial is a call the model cannot express. The SDK builds each tool’s JSON schema from the function signature, docstrings, and Pydantic, and Field constraints flow into both the schema and validation:

from typing import Literal, Annotated
from pydantic import BaseModel, Field
from agents.decorators import tool

class RefundArgs(BaseModel):
    order_id: Annotated[str, Field(pattern=r"^A-\d{3,6}$")]
    amount_cents: Annotated[int, Field(ge=1, le=5000)]
    reason: Literal["damaged", "not_delivered", "duplicate"]

@tool
def issue_refund(args: RefundArgs) -> str:
    """Issue a refund. Amounts are capped at 5000 cents."""
    return payments.refund(args.order_id, args.amount_cents, args.reason)

Enums remove the free-text field, bounds remove the million-cent refund, and the pattern removes arbitrary identifiers. Add per-call timeouts (@tool(timeout=...) on async tools) so a stuck external call cannot pin a run open.

Scope profiles per agent and per tenant

Profiles are not global. Attach a profile set per agent (a research agent gets read only; a support agent gets read, write, and a small external slice) and resolve the active profile from run context - your RunContextWrapper.context - so it is bound to the authenticated tenant, never to a value the model produced. Keep enforcement on the tool pipeline rather than on agent-level guardrails, since agent input/output guardrails only run at the first and last agent of a chain. If your checks must land before an approval pause even begins, set RunConfig.tool_execution to ToolExecutionConfig(pre_approval_tool_input_guardrails=True); calls still get re-checked after approval.

Audit every decision, and know the coverage gaps

Append one record per pre-call check: tool, profile, active profile, argument hash, verdict, approver identity, and timestamp. Denials matter as much as allows - a spike of denials means either an attacker or a misdeclared tool.

Know where the SDK’s guardrail pipeline does not reach: hosted tools (WebSearchTool, FileSearchTool, HostedMCPTool, CodeInterpreterTool, ImageGenerationTool) and built-in execution tools (ComputerTool, ShellTool, ApplyPatchTool, LocalShellTool) do not run through the tool-guardrail pipeline, Agent.as_tool() does not expose tool-guardrail options directly, and handoffs bypass the function-tool pipeline. Those surfaces need their own gate: needs_approval on local shell and apply-patch tools, require_approval on local MCP servers, tool_config={"require_approval": "always"} on hosted MCP, and approval on Agent.as_tool(..., needs_approval=...) for delegated agents. Enforce permissions where the call actually executes, then let the guardrail layer be the second lock.

Key Takeaways

  • Model output is not authorization: declare named profiles (read, write, destructive, external) in config and default-deny anything unmapped.
  • Enforce per call with tool input guardrails; agent-level guardrails only cover the first and last agent of a chain.
  • Escalate privilege only through needs_approval and an authenticated human; approval rules fail closed on unparseable arguments.
  • Treat serialized RunState as untrusted on the client: keep snapshots server-side and validate decisions against server-owned pending items.
  • Narrow schemas (enums, Field bounds, patterns, timeouts) make misuse unrepresentable before any check runs.
  • Audit allows, denials, and approvals, and remember the gaps: hosted tools, built-in execution tools, handoffs, and Agent.as_tool() need separate gates.

References: