Infrastructure

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.

WhereDecidesVocabularyRuns
VisibilityToolProvider / gatedToolsWhat the model is offereda boolean predicateper tool, per iteration
VisibilityPermissionChecker + skills (9.11.0)Which skills the model is offeredallow / deny on skill:<id>per skill, per iteration
Permission gatePermissionCheckerWhether a call executesallow / deny / halt / gate_openper tool call, before execute; plus once per DECLARED capability
Middleware chain.toolMiddleware()Whether it executes, with what args, and whether a person is asked firstallow / deny / askper tool call, after the gate
Upstream — a gateway in front of the toolWhether the request ever reaches your processthe gateway's ownbefore 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 ceiling

Both 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:

CaseWhat the model reads
denydecision.tellLLM, or [permission denied: <rationale>]
haltdecision.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_skill menu the model reads;
  • activating it anyway is refused with the policy's own message (Skill 'payroll' is not available to the 'support' role.), before execute runs — so a surfaceMode: '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:

  1. a read-only role passing the tool-name allowlist and still failing the memory_write capability the tool declares — zero executions;
  2. an admin role clearing tool_call, memory_write and user_data — one;
  3. a checker throwing simulated authorizer outage — fail-closed, zero executions, the operator's error in the rationale, the model carrying on without the tool;
  4. a halt decision — the run ending with PolicyHaltError carrying 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 result arm. 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 ask

allow 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-agentcoreand 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…UseBecause
Keep a tool out of the model's sight in this contextgatedTools(inner, predicate)Cheaper than refusing it later, and it never appears in the prompt
Enforce a role allowlist you controlPermissionPolicy.fromRolesData-driven, fail-closed, one object for both surfaces
Enforce a rule that depends on the arguments.toolMiddleware() with denyThe gate sees the tool; the chain sees the call
Govern what a tool touches, not just its nameTool.capabilities + PermissionPolicy.fromRoles(…, { capabilities })Enforced where both sides declare; the framework never guesses a tool's reach
Show one role a smaller skill catalogPermissionPolicy.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 actionask, or checkIn on the toolask suspends the run with the exact operation; checkIn adds the evidence pack
End the run rather than continue after a refusala checker returning haltContinuing would itself be the incident
Enforce policy for callers you do not controla gateway in front of the toolIt is enforced before your process sees the request

Status

PieceDoorStatus
PermissionChecker port, PermissionPolicy.fromRolesagentfootprint/securityContract-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, extractSequenceagentfootprint/securityShipped
.toolMiddleware() / .messageMiddleware(), allow / deny / askagentfootprintShipped
gatedTools / skillScopedTools visibility layeragentfootprint/providersShipped
Tool.capabilities + PermissionChecker.governs + checkerGovernsagentfootprint / agentfootprint/securityShipped (9.11.0)
PermissionPolicy capability rules + skill rulesagentfootprint/securityShipped (9.11.0)
skill:<id> target convention (skillTarget / skillIdFromTarget)agentfootprint/securityShipped (9.11.0)
Permission gating of the memory pipelineNot built; memory is isolated by MemoryIdentity instead
Gateway-enforced policyNot a data-plane call; nothing to attach
agentCorePolicyagentfootprint/securityRetired 9.4.0 — refuses at construction

Next

On this page