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 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
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.
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
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.