Skip to main content

Tool authorization

A tool call in AgentArea passes through several checks, and they are not the same check. Composition decides what the agent physically has. Policy decides what it may use. Disclosure decides what the model is even told about. Understanding which layer rejected a call is the difference between fixing a policy rule and fixing an agent configuration. The relationship between them is a nesting:
Composition is candidacy, not permission. Policy is the authorization. Disclosure is what actually reaches the prompt.

The problem

The failure this design exists to prevent is disclosure and enforcement disagreeing. If the model is offered a tool the gate will reject, the agent spends a turn calling it, receives an error, and often retries — burning budget on a call that could never succeed and polluting the context with a capability it does not have. The second failure is many enforcement points each answering the question their own way. A tool reached through the workflow, through the MCP proxy and through a direct activity invocation must get the same verdict, or the strictest path is decorative.

The layers a call clears

1. Composition

What the agent is equipped with: its MCP server instances’ exposed tools, its skills, its code tools, its delegation targets, plus anything added at task creation. This is not an authorization decision. A tool the agent is not composed with is absent from the run entirely — there is nothing to deny.

2. Disclosure

Before each LLM call the workflow runs filter_disclosed_tools over the composed list. It drops every tool the policy decision returns DENY for and keeps the ones needing approval, because those escalate to a human rather than fail. A fixed set of control-flow tools is always disclosed regardless of policy: completion, task_complete, request_user_input, recall_history, read_tool_output, activate_tool_source and load_tools. They reach no external system and are never gated on execution. Without completion an agent could never finish, and without request_user_input it could never ask — so a restrictive policy must not be able to strand a run.

3. The workflow gate

_gate_tool_call runs the policy decision again before every capability tool call, and it covers all three execution paths: MCP and code tools, skill activation, and agent delegation. On DENY it appends a tool message saying the call was denied and emits a tool.result event carrying denied_by_policy: true, so the model sees the outcome and the event stream records it. On REQUIRE_APPROVAL it hands off to the approval flow. This is the only enforcement point that can pause. Everything downstream of it runs inside a Temporal activity, and an activity cannot wait for a human.

4. The activity re-check

execute_mcp_tool_activity calls the same predicate a third time before touching anything. A DENY returns a denial result. A REQUIRE_APPROVAL at this point is also a denial — the reason string says the approval must be resolved before activity execution — because reaching the activity with an unresolved escalation means the workflow gate was bypassed.

5. The MCP proxy

Tool calls that arrive over HTTP rather than through a workflow — an external MCP client talking to POST /v1/mcp/{instance_id}/mcp — clear the same predicate. _authorize_mcp_tool_calls parses the JSON-RPC body, extracts every tools/call method, resolves the workspace and user policy layers at request time (there is no task snapshot here), and returns HTTP 403 for anything not allowed.

6. The interceptor pipeline

Independently of the policy decision, the Temporal activity interceptor runs the governance pipeline around the tool activity: These are pattern matchers over the tool arguments, not policy decisions. They apply to every task regardless of what the effective policy says.

What the policy decision actually does

All the enforcement points above call one function, decide_tool_policy, over the resolved policy snapshot. It returns ALLOW, DENY or REQUIRE_APPROVAL in this order:
  1. If the tool name matches a denied pattern (glob), deny.
  2. If an allowed allowlist is non-empty and the tool does not match it, deny.
  3. If requires_human_approval is true, or the tool is listed in escalation_rules, require approval.
  4. Otherwise allow.
Both keys in step 3 are read from the resolved policy snapshot. The identically named requires_human_approval field on TaskCreate is a different thing at a different layer and reaches no decision point — see approvals. This is default-allow, and that is deliberate. The function is only ever asked about a tool the agent is already composed with, so composition is the grant and policy subtracts from it. An absent or empty allowlist means “no allowlist in use”, not “deny everything”. Restriction is expressed by composing fewer tools or by writing deny rules. That posture is the single most important thing to understand about tool authorization here, because it inverts the usual expectation. A workspace with no policy rules does not deny tool calls; it allows every tool its agents are equipped with.

Why not put tool grants in the OpenFGA graph

An earlier design modelled tool invocation in the relationship graph: a tool type with can_call and callers relations, a session type, per-session extra grants, and an approval flag on the tool object. That design was reversed and the graph types were removed. The reason is that tool invocation is not a relationship question. It is a policy question over composed candidacy, and the platform already has a policy engine that resolves ceilings across four scopes with a proven monotonic merge. Putting grants in the graph as well would create a second grant store beside it, with two places to write an intent, two places to audit, and no defined precedence when they disagree. The tradeoff is that tool authorization gives up what the graph is good at. There is no reverse lookup — you cannot ask the store “which users may call send_email” — and there is no per-object scoping of a tool call, so “may call read_file on this file but not that one” is not expressible. Argument level authorization is not part of the decision at all: decide_tool_policy receives the tool name and matches globs against it, and the tool arguments reach only the pattern-matching gates.

Limits

  • Default-allow, not deny-by-default. Described above. Any statement anywhere that tool invocation is fail-closed and requires a graph grant describes a design that was reverted; the graph is not consulted on the tool path at all.
  • The decision is name-based. Patterns are matched with fnmatch against the tool name. Two different MCP server instances exposing a tool with the same name are indistinguishable to policy — denying create_issue denies it everywhere.
  • The tool activity runs under a system context. execute_mcp_tool_activity builds its database context from the workspace id only, without the invoking user. The policy verdict is computed with the user’s snapshot, but the work downstream of it is not user-scoped. Anything that depends on the acting user’s identity inside the tool execution path does not see it.
  • SemanticGuard escalation is a failure, not a pause. Its medium-severity patterns return ESCALATE, which the Temporal bridge turns into an EscalationRequired exception. Because the interceptor sits at the activity boundary it cannot pause and wait for a human — the activity fails. Only the workflow-level approval path pauses.
  • The pattern gates are regular expressions. SemanticGuard’s deny list covers a specific set of literal SQL and shell patterns. It is a guardrail against an obvious accident, not a defence against a model that is trying to get around it. Do not treat it as a sandbox boundary.
  • A crashing gate fails open. The pipeline catches exceptions from an interceptor, logs the traceback and continues with the next one.
  • No rate or concurrency limit on tool calls. Frequency is bounded only by the budget and iteration ceilings.