Governance & policy
Four places a tool call can be stopped — visibility, the in-process permission gate, the middleware chain, and a gateway upstream where this library has nothing to attach. What enforces what, the exact refusal each one hands the model, and why agentCorePolicy was retired.
"Can the agent do this?" is not one question, and answering it in one place is how a governance story quietly stops being true. There are four enforcement points, they live at different layers, and only three of them are yours.
| Where | Decides | Vocabulary | Runs |
|---|---|---|---|
Visibility — ToolProvider / gatedTools | What the model is offered | a boolean predicate | per tool, per iteration |
Visibility — PermissionChecker + skills (9.11.0) | Which skills the model is offered | allow / deny on skill:<id> | per skill, per iteration |
Permission gate — PermissionChecker | Whether a call executes | allow / deny / halt / gate_open | per tool call, before execute; plus once per DECLARED capability |
Middleware chain — .toolMiddleware() | Whether it executes, with what args, and whether a person is asked first | allow / deny / ask | per tool call, after the gate |
| Upstream — a gateway in front of the tool | Whether the request ever reaches your process | the gateway's own | before you see it |
The gate decides what the model is shown; the checker decides what actually runs; and they compose without knowing about each other.
Ordering, once, so it is not a guess
For a single tool call:
permission (tool_call, then each DECLARED capability the checker governs)
→ MIDDLEWARE CHAIN → arg validation → skill gate (read_skill only)
→ check-in → credentials → execute
→ THE CHAIN AGAIN, BACKWARDS (onToolResult) → the result ceilingBoth 9.11.0 steps sit where they do for a reason. Capability checks are part of
the gate, so an existing PermissionChecker still decides before any
middleware runs. The skill gate is after arg validation — so the id it judges is
the one that will actually run, a middleware may have rewritten it — and before
execute, so a refused skill's body is never computed. Neither step exists
unless both sides declared it.
1. PermissionPolicy.fromRoles — the in-process rule you own
import { PermissionPolicy } from 'agentfootprint/security';
const policy = PermissionPolicy.fromRoles(
{
viewer: ['search_docs', 'read_ticket'],
agent: ['search_docs', 'read_ticket', 'update_ticket'],
admin: ['search_docs', 'read_ticket', 'update_ticket', 'delete_ticket'],
},
'agent',
);
const agent = Agent.create({ provider, model, permissionChecker: policy }).build();One object satisfies both surfaces: the async PermissionChecker interface
(check(request)) and a sync isAllowed(toolId) predicate you can hand straight
to gatedTools. So the same allowlist can shape what the model sees and what
is permitted to run, with no second source of truth.
withActiveRole(role) returns a new policy for a different caller — the
per-identity swap — and allowedToolIds() reports the active role's set.
Where it runs, and how often. Inside the toolCalls stage of the ReAct loop,
once per tool call, per iteration, before tool.execute. Every check emits
agentfootprint.permission.check carrying the decision, so the trace records
what was allowed as well as what was refused.
Fail-closed, twice over. Missing role membership is denied by design — an
unknown tool is not an implicitly permitted one. An activeRole that is not in
the map throws at construction, not at the first denied call. And a checker
that threw is treated as deny-by-default:
something that did not answer did not say yes.
Refusal as data
A denial is not an exception. The tool does not run, and the model receives a synthetic tool result it can read and adapt to:
| Case | What the model reads |
|---|---|
deny | decision.tellLLM, or [permission denied: <rationale>] |
halt | decision.tellLLM, or Tool 'x' is not available in this context. |
| The checker threw | [permission denied: Tool 'x' could not be authorized. This will not change during this run — do not call it again. Continue without it, or say what you are unable to do.] |
Three deliberate choices in that table:
The halt default is generic on purpose. It never falls back to reason,
which is a telemetry tag like 'security:exfiltration' — leaking it to the model
teaches it the shape of the rule space.
The fail-closed refusal reads as terminal (9.4.0). It used to hand the model
the checker's own error text. Those messages are written for operators and read
as weather — "not available right now", "ECONNREFUSED", "timed out" — so a
model does the reasonable thing and retries. In a real deployment one did exactly
that, burned every iteration to maxIterations, and returned the empty string;
nobody could see why, because the reason never left the tool result. The refusal
now says it will not change during this run. The operator's fact still exists —
on the typed event's rationale.
A denial is visible in history but was never dispatched. The framework writes
SYNTHETIC_DENY_PREFIX ('[permission denied:') on synthetic results, so
extractSequence can report what actually ran rather than what was attempted.
Capabilities: what the vocabulary enforces, and what it does not (9.11.0)
PermissionRequest.capability has carried five values since v2.4 —
'tool_call', 'memory_read', 'memory_write', 'external_net',
'user_data'. Until 9.11.0 only the first was ever sent. Every construction
site in the library passed 'tool_call', and PermissionPolicy read the field
only as a fallback target id, which a tool call never reaches. Four fifths of the
vocabulary was defined and dead.
It is enforced now, and the rule is deliberately narrow:
Enforced when both sides speak. A tool DECLARES what it touches; a checker DECLARES what it governs. Either side silent and nothing extra is asked, nothing extra is refused — byte-identical to every earlier release.
const fetchInvoice = defineTool({
name: 'fetch_invoice',
description: 'Fetch an invoice PDF from the billing service',
capabilities: ['external_net', 'user_data'], // the tool's half
inputSchema: { /* … */ },
execute: async ({ id }, ctx) => { /* … */ },
});
const policy = PermissionPolicy.fromRoles(
{ support: ['fetch_invoice'], admin: ['fetch_invoice', 'delete_invoice'] },
'support',
{ capabilities: { support: ['user_data'] } }, // the policy's half
);fetch_invoice passes the tool allowlist, then the gate asks once per declared
capability. 'user_data' is listed for support; 'external_net' is not, so the
call is refused and the model reads the policy's rationale. Configuring
capabilities for any role makes the policy declare it governs all four —
governing only what some role happened to list would let an unlisted capability
pass unasked, which is a fail-open hole in a fail-closed primitive.
Three types name this vocabulary, and the split between them is the point.
ToolCapability is the four things a tool can honestly say about itself
('memory_read', 'memory_write', 'external_net', 'user_data').
PermissionCapability is the full set a request may carry — those four plus
'tool_call' and 'skill_read', which are the framework's own words for "a tool
was dispatched" and "a skill was activated" and are therefore not something a
tool declares. PermissionPolicyRules is the optional third argument to
fromRoles ({ capabilities?, skills? }), whose skills map has type
RoleIdRules — role → the ids that role may reach. Ask the framework's own
question with checkerGoverns(checker, capability).
Two things stated plainly, because the alternative is a governance story that quietly stops being true:
The framework never classifies a tool. A tool's capabilities are not knowable from its name, its schema or its description. Guessing would rest a policy decision on a heuristic, so an undeclared tool is asked about nothing.
The memory pipeline is NOT gated by this port. No recall or write stage
builds a PermissionRequest, so 'memory_read' / 'memory_write' reach a
checker only for a TOOL that declared them. What isolates memory is
MemoryIdentity — tenant / principal / conversation scoping on the store — which
is a different mechanism, not this one under another name.
Per-role skill visibility (9.11.0)
The same composition, applied to the skill catalog. When a checker declares it
governs 'skill_read', the agent asks it about each skill — target
skill:<id>, one owner for the spelling (skillTarget(id) /
skillIdFromTarget(target) over the SKILL_TARGET_PREFIX constant, all on
agentfootprint/security):
const policy = PermissionPolicy.fromRoles(roles, 'support', {
skills: { support: ['refunds', 'lookup'], hr: ['payroll'] },
});A refused skill is governed at both ends of one rule:
- its row disappears from the
read_skillmenu the model reads; - activating it anyway is refused with the policy's own message
(
Skill 'payroll' is not available to the 'support' role.), beforeexecuteruns — so asurfaceMode: 'tool-only'skill's body is never even computed.
Three design points worth the ink:
Hidden means unnamed. The skill-graph offer lists unreachable skills as "not reachable from here", because a cursor can move and naming them lets the model route in one step. A hidden skill is about who is asking: no cursor move makes it available, and naming it would tell one role about another role's capabilities. So it appears in neither section.
The enum stays the full catalog. toolArgValidation defaults to 'enforce'
and runs before the gate, so narrowing the enum would turn a policy refusal
into a generic schema error and the model would never read the policy's own
message — the same reasoning 8.5.0 recorded for the graph offer.
Opt-in on both sides, again. A PermissionPolicy built without skills
declares no governs, so nothing is asked and every skill stays visible. This
matters: a skill target is not a tool name, so a fail-closed allowlist asked
about skill:refunds would hide an entire catalogue nobody asked to hide.
Scope note: this governs the read_skill surface the Agent mounts. A
list_skills tool you register yourself (from SkillRegistry.toTools()) is your
own catalog — the gate governs its dispatch by tool name, not its contents.
halt vs deny
deny refuses one call and the loop continues. halt refuses the call and
ends the run: the synthetic result lands, the halt event fires, state is
committed, then $break — and agent.run() raises PolicyHaltError at the API
boundary. Use halt when continuing would itself be the incident.
Two vocabularies on one decision — reason and tellLLM
A refusal has two audiences and they need different words:
return {
result: 'halt',
policyRuleId: 'no-bulk-export-v3', // WHICH rule decided
reason: 'security:exfiltration', // MACHINE — routes the alert
rationale: 'bulk write outside an approved workflow', // human, on the event
tellLLM: 'That action needs a human approver. Stopping here.', // the MODEL reads this
};reason is a stable telemetry tag: it rides
agentfootprint.permission.halt and PolicyHaltError.reason, so
'security:*' can page somebody while 'cost:*' goes to a channel. tellLLM
is the synthetic tool_result the model is shown, on deny and halt alike.
tellLLM never falls back to reason. A routing tag is telemetry, not an
explanation, and a model handed 'security:exfiltration' as its tool result
will try to reason about a string that was never written for it. Omit tellLLM
and the model gets a deliberately generic
"Tool '<name>' is not available in this context."
The runnable file
examples/features/03-permissions.ts
runs all four shapes on this page in one deterministic, key-free file — and they
are the shapes an independent reviewer exercised on 2026-08-13:
- a read-only role passing the tool-name allowlist and still failing the
memory_writecapability the tool declares — zero executions; - an admin role clearing
tool_call,memory_writeanduser_data— one; - a checker throwing
simulated authorizer outage— fail-closed, zero executions, the operator's error in therationale, the model carrying on without the tool; - a
haltdecision — the run ending withPolicyHaltErrorcarrying both vocabularies above.
Independently reproduced against a local harness — the gate is an execution guard, 2026-08-13
PermissionChecker-as-execution-guard is contract-shaped and tested —
independently reproduced against a local harness, 2026-08-13. A tool declaring
memory_write and user_data was put in front of two roles: the read-only role
passed the tool-name allowlist, failed the memory_write check, and the tool
executed zero times; the admin role passed all three checks and executed
once. A separate checker that threw simulated authorizer outage failed
closed — a denial with the operator error in its rationale, the model free to
continue without the tool, and again zero executions.
The reviewer's own summary is worth keeping verbatim: "The permission checker is a real execution guard, not merely tool hiding." Its caveat is kept too — that holds when the policy inputs and the role binding are themselves trustworthy, which is a fact about your deployment rather than about this port.
And the rung is stated rather than rounded up. That run was deterministic and
local: the model was a mock and the "authorizer outage" was a thrown Error
(its own note — "local and deterministic, so they consumed no GCP credit").
No external authorization service has answered a check() from this
repository, so this is not field-validated; a real IAM / OPA / entitlements
backend, with its latency and its partial outages, is still unexercised. What the
run does buy is that the guard is not the author's own claim.
2. ToolMiddleware — the separate layer
Middleware is not the permission gate with more features. It is a different layer with a different power, and one thing it deliberately cannot do:
A middleware cannot answer for the tool. The outcome union has three arms — allow, deny, ask — and no
resultarm. That is not a convention a reviewer has to enforce; it is the absence of a field.
type ToolOutcome = AllowOutcome<Record<string, unknown>> | DenyOutcome | AskOutcome;
type MessageOutcome = AllowOutcome<string> | DenyOutcome; // no ask
type ToolResultOutcome = AllowOutcome<unknown> | DenyOutcome; // no askallow may carry a transformed value — rewritten args on the way in, a
scrubbed result on the way out. deny's reason reaches the model verbatim as the
tool's result and the loop continues.
ask — a denial is final; an ask suspends the run
ask puts the exact operation in front of a person:
Suspend the run and put the question to a person. Tool dispatch only.
The answer is a decision, not a result: approve and the chain continues and the REAL tool runs; decline and it becomes a denial the model reads. A middleware never gets to write the answer itself.
The transformed args ride the checkpoint — a person approves what the chain
produced, not what the model originally proposed. The caller sees
outcome.ask: MiddlewareAsk ({ question, detail?, middleware }) and answers
with the same vocabulary a check-in uses, checkInApproved() /
checkInDeclined(), because a person approving is a person approving.
Three places ask is deliberately absent, each for a stated reason:
- The message boundary — it is a plain stage, and inventing a second pause to give it one would be a worse answer than not offering it.
- After the tool ran (
onToolResult) — the tool has already run. A person woken to answer a question about a side effect that already happened cannot prevent it. - Across
mcpServe— MCP is request/response and there is no pause to carry the question, so a middleware that asks answers the client with a tool error naming it, rather than executing ungoverned.
Deep dive: Middleware. For consent that carries its evidence pack, see Check in with the receipts.
3. Upstream policy — enforced where you cannot attach
A managed gateway can enforce policy in front of the tool, before a request
reaches your process. There is nothing to attach on this side, and that is the
correct amount of machinery: a denial comes back as an MCP error on the tool
call made through mcpClient(...), lands in the loop as that tool's result, and
the model reads it and adapts — the same shape as a local refusal.
agentCorePolicy is retired (9.4.0)
agentCorePolicy() tried to pre-evaluate the same rule in-process. It dispatched
EvaluatePolicyCommand against @aws-sdk/client-bedrock-agentcore — and that
command does not exist in any version of that package. AgentCore has no
data-plane "evaluate this permission" operation at all: policy is authored on the
control plane and enforced at the Gateway.
So every check() it ever ran ended in the catch, and the default is
fail-closed — which means it denied every tool call and reported the policy
engine as unreachable. It compiled, it passed its tests (all of which injected
_client past the SDK), and it could never have worked.
The export remains and refuses at construction with
AgentCorePolicyRetiredError (code: 'ERR_AGENTCORE_POLICY_RETIRED'), carrying
the whole explanation. Removal is deferred to 10.0. Its row stays in the AWS
command pin with commands: [], so re-adding an SDK call there is a decision
somebody has to record.
For rules you own, the answer is PermissionPolicy.fromRoles(...) as the
permissionChecker, or .toolMiddleware() for conditional ones.
Choose-when
| You want to… | Use | Because |
|---|---|---|
| Keep a tool out of the model's sight in this context | gatedTools(inner, predicate) | Cheaper than refusing it later, and it never appears in the prompt |
| Enforce a role allowlist you control | PermissionPolicy.fromRoles | Data-driven, fail-closed, one object for both surfaces |
| Enforce a rule that depends on the arguments | .toolMiddleware() with deny | The gate sees the tool; the chain sees the call |
| Govern what a tool touches, not just its name | Tool.capabilities + PermissionPolicy.fromRoles(…, { capabilities }) | Enforced where both sides declare; the framework never guesses a tool's reach |
| Show one role a smaller skill catalog | PermissionPolicy.fromRoles(…, { skills }) | The row disappears from the menu AND the activation is refused, from one rule |
| Rewrite arguments or scrub a result | .toolMiddleware() with allow({ value }) | The only layer allowed to transform — and still not to answer |
| Require a person before a consequential action | ask, or checkIn on the tool | ask suspends the run with the exact operation; checkIn adds the evidence pack |
| End the run rather than continue after a refusal | a checker returning halt | Continuing would itself be the incident |
| Enforce policy for callers you do not control | a gateway in front of the tool | It is enforced before your process sees the request |
Status
| Piece | Door | Status |
|---|---|---|
PermissionChecker port, PermissionPolicy.fromRoles | agentfootprint/security | Contract-shaped and tested — independently reproduced against a local harness, 2026-08-13: a declared capability refused a tool the allowlist had already passed, zero executions; a throwing checker failed closed with the operator error in its rationale. Deterministic and local — no external authorization service has answered a check() from this repository, so not field-validated |
halt + PolicyHaltError, SYNTHETIC_DENY_PREFIX, extractSequence | agentfootprint/security | Shipped |
.toolMiddleware() / .messageMiddleware(), allow / deny / ask | agentfootprint | Shipped |
gatedTools / skillScopedTools visibility layer | agentfootprint/providers | Shipped |
Tool.capabilities + PermissionChecker.governs + checkerGoverns | agentfootprint / agentfootprint/security | Shipped (9.11.0) |
PermissionPolicy capability rules + skill rules | agentfootprint/security | Shipped (9.11.0) |
skill:<id> target convention (skillTarget / skillIdFromTarget) | agentfootprint/security | Shipped (9.11.0) |
| Permission gating of the memory pipeline | — | Not built; memory is isolated by MemoryIdentity instead |
| Gateway-enforced policy | — | Not a data-plane call; nothing to attach |
agentCorePolicy | agentfootprint/security | Retired 9.4.0 — refuses at construction |
Next
- Middleware — the chain, its three verbs, and the law
- Check in with the receipts — consent that carries evidence
- Security — the three surfaces end to end
- Tools & gateways — where a remote tool comes from
- Identity & credentials — the other half of the question
- Observability sinks — who the run was for, on every event
Identity & credentials
The CredentialProvider port — one method. A tool declares what it needs; the framework resolves it before the tool runs and hands it over as ctx.credential. Machine vs user is decided per REQUEST, consent pauses by default, and a vended token never enters the trace.
Hosting & runtime
The ports between an agent and the place it runs — AgentHost, ConversationHost and SessionLifecycle, none of which names a cloud. Plus httpHost/HttpWire for a second HTTP adapter, three reply terminals, three durability modes with stated crash semantics, the human-in-the-loop resume loop, and the three ways to serve many people at once.
