Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,14 @@ public static IServiceCollection AddApplicationAIDependencies(
services.AddSingleton(sp => new Services.Tools.FirstPartyToolLookup(
sp, new HashSet<string>(KeyedToolRegistrationKeys(services), StringComparer.Ordinal)));

// Single source of truth for "does this capability envelope grant toolName" (#626) — shared by
// EnvelopePermissionRuleProvider (Application.Core) and ToolInvocationGovernor's independent
// runtime re-confirmation, so a first-party tool's key/published-name divergence resolves the
// same way in both. See CapabilityEnvelopeGrantResolver's remarks.
services.AddSingleton(sp => new Services.Governance.CapabilityEnvelopeGrantResolver(
sp.GetRequiredService<Services.Tools.FirstPartyToolLookup>(),
sp.GetRequiredService<ILogger<Services.Governance.CapabilityEnvelopeGrantResolver>>()));

// Sandbox capability enforcement — profile resolution and enforcement. The resolver reads a
// tool's own ITool.RequiredCapabilities/MinimumIsolation declaration via the shared
// FirstPartyToolLookup (#387).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
using Application.AI.Common.Services.Tools;
using Domain.AI.Bundles;
using Microsoft.Extensions.Logging;

namespace Application.AI.Common.Services.Governance;

/// <summary>
/// The single source of truth for "does this capability envelope grant toolName", accounting for a
/// first-party tool's DI-registration-key vs. self-reported published-name divergence (#626) — shared
/// by every consumer that must agree on the answer.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="CapabilityEnvelope.GrantsTool"/> is a literal, case-insensitive membership test against
/// <see cref="CapabilityEnvelope.AllowedTools"/>. That is correct for an MCP tool grant (no
/// registration-key/published-name distinction exists for those) but incomplete for a first-party
/// tool: an operator can author a grant by the tool's DI registration key, while
/// <c>ThreePhasePermissionResolver.Matches</c> — and every other consumer that checks a tool's
/// identity at invocation — compares against the tool's self-reported <c>ITool.Name</c>, which can
/// legitimately disagree with its key.
/// </para>
/// <para>
/// <strong>Both halves of envelope enforcement must resolve this identically, by construction.</strong>
/// <c>EnvelopePermissionRuleProvider</c> builds permission rules from the envelope's grants,
/// and <c>ToolInvocationGovernor.EnvelopeGrantsToolWhenArmed</c> independently re-confirms a resolver
/// Allow against the same envelope as defence in depth — its own remarks state the two "must agree by
/// construction". Before this type existed, #626 gave the rule layer its own private published-name
/// expansion without updating the governor's check, so the rule layer would allow (or skip denying) a
/// key-authored grant, coverage the governor's raw, unexpanded check would still refuse — the fix was
/// runtime-inert for the case it was meant to fix, and correctness-review caught the mismatch this
/// method exists to close. Every consumer of "does the envelope grant this first-party tool" should go
/// through this type instead of calling <see cref="CapabilityEnvelope.GrantsTool"/> directly.
/// </para>
/// </remarks>
public sealed class CapabilityEnvelopeGrantResolver
{
private readonly FirstPartyToolLookup _firstPartyToolLookup;
private readonly ILogger<CapabilityEnvelopeGrantResolver> _logger;

/// <summary>Initializes a new instance of the <see cref="CapabilityEnvelopeGrantResolver"/> class.</summary>
/// <param name="firstPartyToolLookup">Resolves a first-party tool's self-reported published name.</param>
/// <param name="logger">
/// Logs a construction failure per <see cref="FirstPartyToolLookup.TryResolvePublishedName"/>'s
/// documented calling contract — that method is deliberately pure, so every caller must supply its
/// own one-line log-on-failure.
/// </param>
public CapabilityEnvelopeGrantResolver(
FirstPartyToolLookup firstPartyToolLookup, ILogger<CapabilityEnvelopeGrantResolver> logger)
{
ArgumentNullException.ThrowIfNull(firstPartyToolLookup);
ArgumentNullException.ThrowIfNull(logger);
_firstPartyToolLookup = firstPartyToolLookup;
_logger = logger;
}

/// <summary>
/// Wraps <see cref="FirstPartyToolLookup.TryResolvePublishedName"/> to honor its documented
/// calling contract: log a construction failure (not merely "no such first-party tool", which is
/// the normal, silent case for an MCP tool name).
/// </summary>
private bool TryResolvePublishedName(string toolKey, out string publishedName)
{
var resolved = _firstPartyToolLookup.TryResolvePublishedName(
toolKey, out publishedName, out var constructionError);

if (!resolved && constructionError is not null)
{
_logger.LogError(constructionError,
"Could not construct first-party tool '{ToolKey}' to learn its published name for a " +
"capability-envelope grant check — the raw grant entry still applies, but a caller " +
"invoking it under a self-reported name that disagrees with the key would not be covered.",
toolKey);
}

return resolved;
}

/// <summary>
/// Whether <paramref name="envelope"/> grants <paramref name="toolName"/> — checking
/// <paramref name="toolName"/>'s literal membership in <see cref="CapabilityEnvelope.AllowedTools"/>
/// first, then (only when that fails) whether any grant entry is a first-party tool's DI key whose
/// resolved published name matches <paramref name="toolName"/>.
/// </summary>
public bool Grants(CapabilityEnvelope envelope, string toolName)
{
ArgumentNullException.ThrowIfNull(envelope);

if (string.IsNullOrWhiteSpace(toolName))
return false;

if (envelope.GrantsTool(toolName))
return true;

foreach (var grant in envelope.AllowedTools)
{
if (string.IsNullOrWhiteSpace(grant))
continue;

if (TryResolvePublishedName(grant, out var publishedName)
&& string.Equals(publishedName, toolName, StringComparison.OrdinalIgnoreCase))
return true;
}

return false;
}

/// <summary>
/// Expands <paramref name="names"/> to also include each name's resolved, self-reported published
/// name when it names a first-party tool by DI key and the two disagree — flattened and
/// deduplicated case-insensitively, preserving first-seen order.
/// </summary>
public IReadOnlyList<string> ExpandWithPublishedNameCoverage(IReadOnlyCollection<string> names)
{
ArgumentNullException.ThrowIfNull(names);

var expanded = new List<string>(names.Count);
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

foreach (var name in names)
{
if (seen.Add(name))
expanded.Add(name);

if (TryResolvePublishedName(name, out var publishedName)
&& !string.Equals(publishedName, name, StringComparison.OrdinalIgnoreCase)
&& seen.Add(publishedName))
expanded.Add(publishedName);
}

return expanded;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ public sealed partial class ToolInvocationGovernor : IToolInvocationGovernor
private readonly IOptionsMonitor<PermissionsConfig> _permissionsConfig;
private readonly IOptionsMonitor<SandboxConfig> _sandboxConfig;
private readonly ILogger<ToolInvocationGovernor> _logger;
private readonly CapabilityEnvelopeGrantResolver _envelopeGrantResolver;

public ToolInvocationGovernor(
IAgentExecutionContext executionContext,
Expand All @@ -93,7 +94,8 @@ public ToolInvocationGovernor(
IOptionsMonitor<GovernanceConfig> governanceConfig,
IOptionsMonitor<PermissionsConfig> permissionsConfig,
IOptionsMonitor<SandboxConfig> sandboxConfig,
ILogger<ToolInvocationGovernor> logger)
ILogger<ToolInvocationGovernor> logger,
CapabilityEnvelopeGrantResolver envelopeGrantResolver)
{
_executionContext = executionContext;
_toolPermissionService = toolPermissionService;
Expand All @@ -110,6 +112,7 @@ public ToolInvocationGovernor(
_permissionsConfig = permissionsConfig;
_sandboxConfig = sandboxConfig;
_logger = logger;
_envelopeGrantResolver = envelopeGrantResolver;
}

/// <summary>
Expand Down Expand Up @@ -140,15 +143,20 @@ public ToolInvocationGovernor(
/// </para>
/// <para>
/// The two must agree by construction — the envelope's own rules are built from the same
/// <c>AllowedTools</c> list this reads, matched with the same case-insensitive comparer. A
/// disagreement therefore means the resolver reached Allow by a path that did not consult the
/// envelope, which is precisely the condition worth failing closed on.
/// <c>AllowedTools</c> list this reads, matched through the same
/// <see cref="CapabilityEnvelopeGrantResolver"/> that resolves a first-party tool's
/// key/published-name divergence (#626), not a raw <see cref="CapabilityEnvelope.GrantsTool"/>
/// check — a mismatch there previously meant a rule-layer Allow enabled by that resolution could
/// still be silently re-blocked here, and a rule-layer Deny it correctly skipped was not actually
/// covered here either. A disagreement after routing both sides through the shared resolver means
/// the resolver reached Allow by a path that did not consult the envelope, which is precisely the
/// condition worth failing closed on.
/// </para>
/// </remarks>
/// <param name="toolName">The tool the resolver has authorized.</param>
/// <returns>True when no envelope is armed, or when the armed envelope grants the tool.</returns>
private static bool EnvelopeGrantsToolWhenArmed(string toolName)
=> CapabilityEnvelopeAccessor.Current is not { } envelope || envelope.GrantsTool(toolName);
private bool EnvelopeGrantsToolWhenArmed(string toolName)
=> CapabilityEnvelopeAccessor.Current is not { } envelope || _envelopeGrantResolver.Grants(envelope, toolName);

/// <inheritdoc />
public async ValueTask<ToolInvocationDecision> AuthorizeAsync(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,34 @@ public FirstPartyToolLookup(
/// fail-closed response to an unverified plugin boundary needs every name to deny, not one).
/// </summary>
public IReadOnlySet<string> RegisteredFirstPartyToolKeys => _registeredFirstPartyToolKeys;

/// <summary>
/// Resolves <paramref name="toolKey"/>'s converted, self-reported <see cref="ITool.Name"/> — the
/// value a permission resolver actually matches a rule's pattern against at invocation, which can
/// legitimately disagree with a DI registration key a manifest (a plugin's <c>DeniedTools</c>, a
/// capability envelope's grant, a bundle's declared tools) names it by. Returns
/// <see langword="false"/>, with <paramref name="publishedName"/> set to <paramref name="toolKey"/>
/// itself, when the key names no known first-party tool (an MCP tool name, for which no
/// first-party resolution is possible or needed) OR constructing it throws.
/// </summary>
/// <remarks>
/// #626 code-review: originally duplicated near-verbatim between <c>PluginPermissionRuleProvider</c>
/// (#612) and <c>EnvelopePermissionRuleProvider</c> (#626) — exactly the anti-pattern this type's
/// own class remarks say it exists to prevent (#387: "found duplicated — twice"). Deliberately pure
/// (no logging): a construction failure is a caller-specific concern (each rule provider names a
/// different kind of manifest entry in its own log message), so each caller still wraps this with
/// its own one-line log-on-failure — only the resolve-or-fall-back-to-key logic itself is shared.
/// </remarks>
public bool TryResolvePublishedName(string toolKey, out string publishedName, out Exception? constructionError)
{
var tool = TryResolve(toolKey, out constructionError);
if (tool is not null)
{
publishedName = tool.Name;
return true;
}

publishedName = toolKey;
return false;
}
}
Loading
Loading