Agent Tool Security
The least-privilege exposure engine that decides which tools an agent session may see and call — per-session manifests, schema-hash binding, tenant policies, redaction, and decision receipts.
If an agent is a 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 an exposure receipt for each evaluated call. Handing an
agent the full tool catalog increases context size; authorization still depends
on its grants.
The agent tool exposure engine decides which registered tools the agent may
see and call: on every governed tools/list it builds a per-session
exposure manifest from the requested candidates, server-owned security
descriptors, and a tenant policy. Every governed tools/call must present that
manifest's lease and echo the exact tool it was granted. Anything that does not
line up is denied. Exposure decisions produce redacted decision receipts;
resource-permission refusals can occur before exposure evaluation without one.
Those content-hashed manifests and decision receipts are also the evidence a
Receipt draws on to prove what an agent did.
This engine sits under both the external MCP server and the in-product durable agent runtime; they draw from the same canonical tool metadata and enforce the same policies.
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.
An MCP client may supply tool_names on tools/list to narrow the candidate
set before policy evaluation. Exact names are sorted and deduplicated, with a
limit of 64 raw entries and 256 bytes per name. Supplied null, empty, invalid,
unknown, or unwired selections are refused before a manifest is created. An
omitted selector retains full discovery. Selection never overrides policy:
denied tools stay denied, and a tool omitted from this manifest cannot be
called with its lease. All session, expiry, actor, scope, schema, and version
checks continue to apply. See the
buyer tool recipe.
Ductor-hosted model-only agents also retain governance. With no callable tools, admission persists an empty manifest and policy evidence; it does not inherit the full registry. Tool proposals outside that empty grant cannot execute.
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_hashis 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.
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.
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_modebecomes a server-fixed argument;requires_idempotencyadds and enforces a non-emptyidempotency_key;max_duration_millissets the execution deadline;max_result_bytesrejects oversized output before it reaches the journal, model, or MCP client; andexpires_after_secondsshortens 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
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
Server-owned mandatory permissions supplement exposure policy. In particular,
bespoke work_receipt.get and work_receipt.verify require work_receipt:read
on each direct or governed call. Their server and operational backend use the
same canonical explicit-scope, resource-wildcard, and role-bundle authorizer,
with a current nonanonymous principal and authenticated tenant. An expired or
unauthorized cross-tenant principal cannot reach private lookup through an older
manifest. An administrator's tenant-local role cannot cross a tenant boundary;
the deployment-issued platform-administrator rule retains its intentional scope.
Receipt read permission is separate from buyer market observation. Seller bids,
declines, and amendments use routing_market_bid:write; market management uses
routing_market:write. Built-in operator and administrator bundles intentionally
grant both writes. Exposure grants and confirmation rules do not grant missing
resource permissions, worker ownership, or environment authority.
The nine bespoke market tools enforce the current principal and authenticated tenant before application lookup or effects on each direct or governed call:
| Tool group | Required permission |
|---|---|
routing.market.preview, routing.market.get, routing.market.replay | routing_market:read |
routing.market.open, routing.market.solicit, routing.market.close, routing.market.commit, routing.market.cancel | routing_market:write |
routing.market.submit_bid | routing_market_bid:write |
They require an explicit environment_id in their own arguments. Resolution
checks current environment authority and persisted session scope; changing the
argument cannot move a stored market to another environment. Receipt reads
resolve stored scalar environment authority before decoding private signed
payloads, and list filtering occurs before pagination. An exposure lease is
additional authority evidence, not a replacement for these checks.
Descriptor-generated tools inject environment from top-level invocation scope
and reject it inside arguments; retain that distinction when constructing calls.
See the MCP clearing contract.
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.
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.
Award execution and final acceptance evidence
An RFQ award's admission-era tool pin is derived from its source definition;
it is not an independently observed execution manifest. An award-bound
AI_AGENT session runs through this same governed broker and records its actual
manifest and policy receipt. Acceptance evidence signs those runtime facts
alongside the original source definition and the distinct governed execution
definition. A caller cannot supply inline tool/model overrides to that step.
See Clear AI work and
Verify a receipt.