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.
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.
services.AddKeyedSingleton<ITool>("file_system", /* impl */);
A skill's SKILL.md frontmatter declares which of those keyed tools the skill is
allowed to use:
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.
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. |
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:
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.
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.
|
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:
rules.Add(new ToolPermissionRule(
denied, null, PermissionBehaviorType.Deny,
PermissionRuleSource.PluginDeclaration,
Priority: 1, IsBypassImmune: true));
Two of those arguments do all the work:
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.
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.
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.