Chapter 04 · Access Control

Tools & Permissions

The previous page set how much an agent may do without a human. This page is the next ring in: layer 3, which specific tools an agent may invoke right now, resolved fresh on every call. No tool runs until a deterministic resolver says it may — and the answer is recomputed for every single tool call, not cached at startup.

Three narrowings, in order

"Can this agent use this tool?" is never one yes/no check. It is three filters applied in sequence, each strictly smaller than the last. By the time the resolver returns, a tool has had to survive all three.

1 · Declared tools (what the agent even knows about)
A skill declares which tools it may use. The model is only ever shown the schemas for those tools, so it physically cannot name a tool it was not given. This is the widest cut.
2 · Tier defaults (the baseline posture)
The agent's autonomy tier sets a default behavior for any tool — Allow, Ask, or Deny — before any tool-specific rule is consulted.
3 · Explicit rules (the per-tool verdict)
Specific rules from the tier, from per-tool overrides, and from plugins are merged, sorted, and evaluated. The first matching rule wins. This is the narrowest cut and the subject of most of this page.

Read top to bottom, the model can only request tools it was declared (narrowing 1); of those, its tier sets a default disposition (narrowing 2); and the final verdict for the exact call is the first matching explicit rule (narrowing 3). Below we walk each one.

Narrowing 1 — keyed DI and declared tools

Every tool in the harness is registered in dependency injection under a string key. The key is the tool's name; the value is the implementation.

csharp
services.AddKeyedSingleton<ITool>("file_system", /* impl */);

A skill's SKILL.md frontmatter declares which of those keyed tools the skill is allowed to use:

yaml
allowed-tools: ["Read", "Write"]

Only the tools named in that list are resolvable for the skill, and the LLM is only offered the schemas for the tools it is allowed to use. This is lazy resolution: the model sees a menu, and the menu only contains what the skill declared. It is the first and cheapest narrowing — an agent literally cannot name a tool it was not given, because the name never reaches its context.

i
Why "lazy" matters for security, not just cost

Lazy resolution is usually sold as a token-budget optimization — don't ship 200 tool schemas when the skill uses two. The security payoff is the same mechanism viewed differently: a tool the model never sees is a tool the model cannot be tricked, jailbroken, or injection-prompted into calling. Narrowing the menu narrows the attack surface.

Narrowing 3 — the three-phase permission resolver

Once the model picks a declared tool, the call goes to the three-phase permission resolver (ThreePhasePermissionResolver, implementing IToolPermissionService) at src/Content/Infrastructure/Infrastructure.AI/Permissions/ThreePhasePermissionResolver.cs. Every tool call passes through it. Rules from every source are gathered into one list, sorted by Priority ascending, and evaluated in ordered phases. The first matching rule wins — evaluation stops there.

Phase Name What it does
0 Rate limit IDenialTracker auto-denies a tool once it crosses the DenialRateLimitThreshold. Stops a tool that keeps getting denied from being retried forever — a runaway agent can't spin on a forbidden call.
1 Deny / Safety Safety gates are checked first and are bypass-immune. Then Deny rules, in lowest-Priority-first order. If anything here matches, the call is refused and no later phase runs.
2 Ask The first matching Ask rule wins → a human is prompted to approve the call.
3 Allow The first matching Allow rule wins → the call proceeds.
Default Nothing matched? The verdict is Ask. Closed-by-default — the resolver never silently Allows.
The order is the whole point: Deny before Ask before Allow

Because the phases run in that order and the first match wins, a Deny is evaluated before any Ask or Allow could match. A Deny can therefore never be overridden by a more permissive rule, no matter how that permissive rule was configured. And because the default when nothing matches is Ask, an unconfigured tool fails safe toward a human, never toward silent execution.

Where the rules come from

The resolver does not invent rules; it merges them from providers, then sorts the combined list by Priority. Two providers matter here:

AutonomyTierRuleProvider
src/Content/Application/Application.Core/Permissions/AutonomyTierRuleProvider.cs. Emits the agent's autonomy-tier baseline at Priority 0 (the lowest, so it is considered earliest within its phase) plus any per-tool overrides at Priority 10. This is the bridge from Autonomy & Governance — the tier you set there becomes concrete rules here.
PluginPermissionRuleProvider
src/Content/Application/Application.Core/Permissions/PluginPermissionRuleProvider.cs. Emits a plugin's own autonomy baseline plus a Deny rule for every entry in the plugin's DeniedTools list. This provider is where the page's headline rule lives — covered below.

Plugin boundary governance

A plugin is a declared bundle of skills and MCP servers an agent can load. Because a plugin extends the agent's capabilities, it ships its own boundary. The boundary is declared on PluginDeclaration at src/Content/Domain/Domain.Common/Config/AI/Plugins/PluginDeclaration and has three knobs:

Knob Type Effect
AllowedTools Whitelist Applied first — the plugin may only touch tools on this list.
DeniedTools Blacklist Wins on any conflict — a tool here is refused even if it also appears in the whitelist.
AutonomyLevel Tier Overrides the tier policy for the plugin's own tools. The provider enumerates the real tool names the plugin's skills declare (their allowed-tools / tool declarations) and emits one authoritative baseline rule per name — Autonomous → Allow (can loosen a stricter default), Supervised/Restricted → Ask (tightens). The baseline runs after the Deny phase, so a DeniedTools entry still wins over it.
i
Scope: DeniedTools is plugin-wide and turn-global, not per-skill

AllowedTools, DeniedTools, and AutonomyLevel are declared on the plugin, so they apply to every skill the plugin contributes — there is no per-skill knob inside a plugin. A tool listed in a plugin's DeniedTools is enforced two ways: it is filtered out of that plugin's own resolved tool set at build time (per-skill provisioning), and it emits a bypass-immune Deny rule keyed on the tool name. Because the resolver matches Deny rules by name across the whole turn, that name stays denied even if a different (non-plugin) skill surfaces a tool of the same name — the deny is cross-skill, not confined to the plugin that declared it. Scope a denial narrowly by naming a tool only that plugin exposes.

!
Autonomous auto-approves agent-wide — pair it with DeniedTools

An Autonomous plugin baseline resolves an authoritative Allow by tool name, so the named tools auto-approve for the whole turn — for every caller, not just the plugin. To keep this from becoming a privilege-escalation path, the provider only emits the baseline for names on the plugin's own tool surface: a name that resolves to a globally-registered keyed-DI tool (a shared harness tool such as file_system or a shell) is excluded, even if the plugin's SKILL.md lists it. The plugin may still use that tool — it just cannot auto-approve it. When you mark a plugin Autonomous, treat the named tools as auto-approved agent-wide and add any sensitive global tool to that plugin's DeniedTools (bypass-immune) to be certain it can never be auto-run.

The bypass-immune DeniedTools rule

This is the most important behavior on the page, and the most counter-intuitive: a tool in a plugin's DeniedTools stays denied even for an Autonomous-tier agent whose default is Allow, and even under an auto-approve mode. Here is exactly why.

For each entry in DeniedTools, PluginPermissionRuleProvider constructs this rule:

csharp
rules.Add(new ToolPermissionRule(
    denied, null, PermissionBehaviorType.Deny,
    PermissionRuleSource.PluginDeclaration,
    Priority: 1, IsBypassImmune: true));

Two of those arguments do all the work:

PermissionBehaviorType.Deny
Makes this a Deny rule, so it is evaluated in Phase 1 — before any Ask (Phase 2) or Allow (Phase 3) rule could possibly match. The first-match-wins ordering means a Deny short-circuits the whole resolution.
Priority: 1
A very low priority number = sorted near the front = checked early within Phase 1. Even against other Deny rules, this one is considered first.
IsBypassImmune: true
The bypass flag. It blocks any downstream auto-approve mechanism from overriding the rule. An auto-approve / Autonomous mode can flip Ask into Allow elsewhere — it cannot touch a rule marked bypass-immune. This is the flag that makes the denial absolute.
PermissionRuleSource.PluginDeclaration
Records provenance — the verdict's audit trail shows it came from the plugin's own declaration, not from the tier or an operator override.
!
Common mistake: "Autonomous / auto-approve overrides everything"

It does not. An auto-approve mode (and the Autonomous tier's Allow default) turns Ask verdicts into Allow — that is its entire job. DeniedTools and safety gates are precisely the exceptions: they are Deny-phase, bypass-immune rules, so the auto-approve machinery never gets a chance to act on them. If you put a tool in DeniedTools expecting Autonomous mode to "win," you have the model backwards — the Deny wins, every time.

Attack scenario: a compromised plugin under auto-approve

Suppose a plugin you installed is compromised — a supply-chain attacker has published a malicious update. The agent is running in Autonomous tier with auto-approve on, so by default every tool call sails through as Allow. The poisoned plugin instructs the agent to call a tool the operator had listed in that plugin's DeniedTools — say, a shell or a credential-reading tool.

The call still fails. The resolver gathers rules, sorts by priority, and hits the bypass-immune Deny at Priority 1 in Phase 1 — long before the Allow default in Phase 3 is ever consulted, and with the auto-approve override unable to touch it. The agent's elevated autonomy bought the attacker nothing for that tool. The blast radius of the compromise is capped at exactly the tools the operator chose to forbid.

Where this runs on the tool path

The resolver is invoked from the live tool-execution path, not the MediatR request pipeline. Every agent tool is wrapped by GovernedAIFunction, whose InvokeCoreAsync calls IToolInvocationGovernor.AuthorizeAsync (src/Content/Application/Application.AI.Common/Services/Governance/ToolInvocationGovernor.cs), which in turn calls _toolPermissionService.ResolvePermissionAsync — the ThreePhasePermissionResolver covered above. Because the wrapper sits on the execution path, there is no way to dispatch a tool call that skips it. Once the governor authorizes the call, two more ambient gates run before the tool executes:

# Gate Concern
1 IToolInvocationGovernor May this tool be called at all? Permission resolution + autonomy-tier policy (this page and page 03)
2 IToolClassificationGate Does the data classification allow it? Purview DLP (page 08)
3 IProgressEvaluator Is the agent spinning on repeated identical calls? (spin guard)

Two earlier MediatR behaviors named ToolPermissionBehavior and GovernancePolicyBehavior were removed as dead code — they keyed on a request marker nothing in production implemented, so they never fired. Permission resolution lives on the tool path described here.

i
Permission is a verdict, not an execution

Everything on this page decides whether a tool may be called. It says nothing about what that tool's code is allowed to touch once it runs — the filesystem, the network, CPU, memory. That is a separate layer with its own controls. An Allow verdict here hands the call to the next ring of defense, not to an unbounded process.

So an allowed tool that executes code still has to run inside the sandbox — the closed-by-default capability box that decides what the tool's process may actually do. That is layer 4, and it is the next page: Sandbox & Execution.