# Agent Tool Security (/docs/ai/agent-tool-security)



If an agent is a <Term name="Worker" />, this is the governance that makes it
safe to let one take work: least-privilege access to exactly the tools its
manifest allows, and a decision receipt for every call. Handing an agent the full
tool catalog is the same mistake as handing a service account every scope. The
**agent tool exposure** engine is the authorization layer that prevents it: on
every `tools/list` it builds a per-session **exposure manifest** from server-owned
security descriptors and a tenant policy, and every `tools/call` must present that
manifest's lease and echo the exact tool it was granted. Anything that does not
line up is **denied with a redacted decision receipt** — no partial trust, no
guessing. Those signed manifests and decision receipts are also the evidence a
<Term name="Receipt" /> draws on to prove what an agent did.

This engine sits under both the external [MCP server](/docs/ai/mcp-server) and the
in-product [durable agent runtime](/docs/ai/durable-agent-runtime); they draw from the
same canonical tool metadata and enforce the same policies.

## The manifest handshake [#the-manifest-handshake]

When exposure is enabled, `tools/list` no longer returns the raw catalog. It returns
a manifest scoped to the caller's tenant (plus optional environment, actor, preset,
session, and entity hints) along with a set of identifiers the client must carry
forward:

| Field                   | Prefix | What it is                                        |
| ----------------------- | ------ | ------------------------------------------------- |
| `agent_tool_session_id` | `ats_` | The session lease the subsequent calls run under. |
| `manifest_id`           | `atm_` | The manifest that was built for this list.        |
| `manifest_hash`         | —      | A hash binding the call to this exact manifest.   |
| `receipt_id`            | `atr_` | The decision receipt recorded for the list.       |

Each tool in the list also carries a per-tool `schemaHash` and an `exposureMode`. The
`schemaHash` is the fingerprint of the *advertised* schema the agent must echo when it
calls that tool.

```mermaid
flowchart TD
  L["tools/list"] --> B["Build manifest"]
  B --> M["Manifest<br/>ats_ · atm_ · hash · atr_<br/>tools w/ schemaHash"]
  M --> C["tools/call<br/>echo tool_version + expected_schema_hash<br/>present session + manifest lease"]
  C --> A{"AuthorizeCall<br/>match?"}
  A -->|match| E["Execute<br/>(result redacted per grant)"]
  A -->|"mismatch / drift / scope / not-callable"| D["DENY + receipt (atr_)"]
```

## What a call must carry, and what gets denied [#what-a-call-must-carry-and-what-gets-denied]

Every `tools/call` under an exposed session must echo `tool_version` and
`expected_schema_hash` (the values from the manifest) and present the session and
manifest identifiers. Actor identity is derived from the authenticated principal;
an `actor_id` supplied by a client is only an equality assertion and cannot choose
another identity. The engine denies the call — returning a redacted receipt
rather than executing — when any of these hold:

* The echoed `expected_schema_hash` is missing or does not match the pinned hash.
* The manifest has **drifted** (the tool's advertised schema changed since the grant).
* The tool's required **scope** is not satisfied.
* The tool is **not visible** or **not callable** in this manifest.
* The presented session/manifest lease does not match (manifest-binding mismatch).
* The authenticated actor does not match the actor pinned into the session and
  manifest.
* The arguments do not validate against the exact narrowed schema advertised in the
  grant.

<Callout type="info" title="The schema hash binds to grant time, not call time">
  The `expected_schema_hash` check binds the call to the hash that was pinned in the
  manifest when the session was granted. It does not recompute the tool's live schema
  at execution. That is what makes a schema change detectable as drift instead of
  silently accepted.
</Callout>

## Policies [#policies]

A tenant policy decides how candidate tools map to exposure modes. The **default**
policy, `builtin_safe_read`, is deliberately narrow: it exposes only **read,
non-mutating** tools and hides everything else. Write, destructive, and unknown tools
require an explicit policy that opts them in — least privilege is the default, not a
configuration you have to remember to turn on.

Policies are richer than an allow-list. A policy carries:

* **Rules** — selectors that match tools by name, prefix, family, or risk label, each
  assigning an exposure mode (`allow`, `hide`, `deny`, `dry_run_only`,
  `approval_required`, `confirmation_required`, `async_job_only`, `staged_change`)
  and a reason.
* **Redaction profiles** — summary mode, a max-bytes cap, and allowed destinations
  (`llm`, `mcp`, `transcript`, `logs`) applied to tool output before the agent sees
  it. The built-in safe profile summarizes to at most 8 KB.
* **Invocation policy** — fixed arguments, allowed enum values, and a **schema
  overlay** that *narrows* the advertised schema so the agent literally cannot request
  fields the policy forbids. A pinned `connection_mode` becomes a server-fixed
  argument; `requires_idempotency` adds and enforces a non-empty
  `idempotency_key`; `max_duration_millis` sets the execution deadline;
  `max_result_bytes` rejects oversized output before it reaches the journal, model,
  or MCP client; and `expires_after_seconds` shortens the manifest lease.

Policy fields are not accepted as decoration. A negative limit is invalid, a
ceiling too large to enforce is refused, and a `rate_bucket` without a distributed
limiter is rejected when the policy is written. A `max_cost_cents` ceiling is
accepted — it is enforceable against the price connector pricing resolves before
provider I/O — but a deployment that has not wired the spend gate refuses
invocations under a capped rule rather than running them uncapped. That refusal
lives at execution time, because validation cannot see runtime wiring. Either way
a policy never appears stricter than the runtime can actually enforce.

## Runtime authorization binding [#runtime-authorization-binding]

For a Ductor-hosted agent, manifest authorization is only the first half of the
boundary. Before a tool is executed, the durable runtime records an invocation bound
to the tenant, agent session, turn, invocation id, canonical tool name and version,
schema hash, manifest and policy receipt, and a hash of the normalized arguments.
The execution receipt also snapshots effective arguments, execution mode, duration
and result-size limits, and the output-redaction policy. It is attached to the
resulting journal event.

This prevents a worker from substituting another tool, schema, tenant, or argument set
between authorization and execution. If policy requires approval, that exact binding
is parked in an `input_required` event. The approval response can release only that
invocation; it cannot authorize a later model proposal or a modified payload.

Authorization also pins the server-resolved execution target: environment,
deployment, connector connection or remote MCP instance, region, endpoint-policy
id, and normalized endpoint fingerprint. A model or browser cannot supply a URL.
Rotation or target drift between authorization and execution requires explicit
reauthorization or reconciliation; the executor never silently retargets an approved
call. Multi-request operations reuse the same target and credential context.

For Ductor-hosted chat, request-context RBAC is evaluated before the durable
authorization receipt is journaled. A recovery worker executes the exact bound
receipt without depending on an expired browser or HTTP context; it does not silently
re-authorize as a different actor after restart.

Rejected approvals are represented as durable, protocol-valid tool results. The model
can explain or choose a safer path without the original mutation being executed.

## API and scopes [#api-and-scopes]

The exposure engine is managed through the `AgentToolExposureService` (Connect/gRPC,
also mounted at REST under `/api/v2/agent-tool-exposure/…`):

| Operation                                                                                        | Purpose                                                             |
| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------- |
| `CreateAgentToolExposurePolicy` / `UpdateAgentToolExposurePolicy`                                | Author a policy (server resolves the security descriptors).         |
| `GetAgentToolExposurePolicy` / `ListAgentToolExposurePolicies` / `DeleteAgentToolExposurePolicy` | Manage policies.                                                    |
| `CreateAgentToolSession`                                                                         | Build a manifest + session lease for a scope.                       |
| `RefreshAgentToolSession`                                                                        | Rebuild the manifest and revoke the old session — the drift remedy. |
| `RevokeAgentToolSession`                                                                         | Invalidate a session lease.                                         |
| `GetToolExposureManifest` / `ExplainToolExposureManifest`                                        | Read a manifest; explain expiry and repair actions.                 |
| `PreviewAgentToolExposure`                                                                       | Dry-run a scope against the policy without minting a live lease.    |
| `ListToolExposureReceipts`                                                                       | Audit the allow/deny decisions.                                     |

Access is gated by the scopes `agent_tool_exposure:read`, `:write`, `:run`, and
`:admin`.

Sessions live in the `default` environment when none is given, and a session lease
expires after **15 minutes**.

<Callout type="warn" title="A schema mismatch means refresh, not retry">
  When a call is denied for a schema-hash mismatch, the manifest has drifted. Do not
  retry with a guessed hash — that will keep failing. Refresh the session
  (`RefreshAgentToolSession`, or a fresh `tools/list`) to obtain the current manifest
  and its pinned hashes, then call with those. `ExplainToolExposureManifest` reports
  when a manifest has expired and lists the repair action.
</Callout>

## Related [#related]

<Cards>
  <Card title="MCP Server" href="/docs/ai/mcp-server">
    Where tools/list and tools/call are served.
  </Card>

  <Card title="Authorization" href="/docs/auth/authorization">
    How scopes and the authorizer gate every request.
  </Card>

  <Card title="Hardening" href="/docs/auth/hardening">
    The production posture the exposure engine complements.
  </Card>
</Cards>
