fix: resolve envelope grant/declared-tool published names (#626) - #653
Conversation
EnvelopePermissionRuleProvider built its Deny and autonomy-ceiling baseline rules straight from an envelope's declared AllowedTools grants and a bundle's declared tool names, without ever resolving a tool's self-reported published name -- the value ThreePhasePermissionResolver.Matches actually compares a rule's pattern against at invocation. An operator-authored grant naming a tool by its DI key (rather than its published name) silently never matched at invocation, so the tool fell through to the closing catch-all Deny despite being "granted" in config -- a real functional defect masked as fail-closed-by-accident. Mirrors #612's fix for PluginPermissionRuleProvider's DeniedTools: each grant and declared-tool name is expanded to also include its resolved published name (via the existing FirstPartyToolLookup) when it differs. Mutation testing caught a real bug in the first cut of this fix: the declared-tools Deny-check tested each expanded name-form independently against the granted set, so a tool declared by key but granted by its published name (or vice versa) was denied under whichever form wasn't the literal grant string -- even though the tool IS genuinely granted under the other form. Fixed by deciding grant membership once per original declared tool (checking whether ANY of its forms is granted) before emitting a Deny for any of them. #625 (the same pattern suggested for PluginPermissionRuleProvider's autonomy-baseline rules) was investigated and closed as not applicable: that method's own "own-surface constraint" already excludes any name resolvable via keyed DI before it reaches the rule loop, so the exact divergence this fix resolves cannot occur there -- confirmed by a test that produced zero rules before being reverted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Aao7Y3hU22v6VH1RdiSxYu
…ync (#626 code-review) Code-review on the #626 fix found two clean, bounded issues: - TryResolvePublishedName/WithPublishedNameForms logic was duplicated near-verbatim between EnvelopePermissionRuleProvider and PluginPermissionRuleProvider — the exact anti-pattern FirstPartyToolLookup itself exists to prevent (#387). Moved the resolve-or-fall-back-to-key logic onto FirstPartyToolLookup.TryResolvePublishedName; both callers now wrap it with only their own context-specific log message. - EnvelopePermissionRuleProvider.GetRulesAsync had grown to ~83 lines. Extracted its three rule-emission blocks into named helper methods. Two other findings (no caching on GetRulesAsync; permission-summary duplicate-entry display for a tool covered by both key and published-name forms) need their own design pass and are tracked as #651 and #652. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Aao7Y3hU22v6VH1RdiSxYu
Grader verdict — PR #653Intent: Fix #626 — an envelope's Check-by-check
Holes
Standards check
Bottom lineLOOKS GOOD. The fix is narrowly scoped, mirrors the established #612 pattern, includes a mutation-tested regression for the grouping bug it found along the way, and correctly declines to bundle the reverted #625. The one thing the human Checker should verify before merging: that CI's own |
🔐 Security review — PR #653Bottom line: PASS — no HIGH findings. Two MEDIUM/LOW advisories below; neither blocks. Scope reviewed: Why the widening is not exploitable
That governor check is what carries the PASS: it is strictly narrower than the resolver after this change, so every newly-granted name that the operator did not literally write is still blocked at invocation. MEDIUM ·
|
Correctness review — PR #653Bottom line: CORRECT. No blocking defects. Verified locally: Blocking defectsNone. What I checked and cleared
Advisory (not blocking)
The single thing a human should look at: advisory 1 — the deliberate no-cache/log-every-call divergence from |
…n published names (#626) CI's correctness-review gate blocked the prior commit with a real finding: the published-name expansion #626 added to EnvelopePermissionRuleProvider only changed the RULE layer's decision. ToolInvocationGovernor independently re-confirms every resolver Allow against the envelope's raw AllowedTools list (CapabilityEnvelope.GrantsTool, a literal string match) as defence in depth. Since that check never learned about published-name expansion: - An Allow the rule layer enabled via a key/published-name match still got silently re-blocked by the governor's raw check — the #626 fix was inert for the exact case it was meant to fix. - The declared-tool Deny loop's grouping fix (which correctly stops emitting a redundant Deny when a tool IS granted under its other name-form) dropped real bypass-immune coverage, because the governor's independent check didn't actually treat that tool as granted either. Fix: extracted the shared "does this envelope grant toolName" resolution (including the key/published-name expansion) onto a new CapabilityEnvelopeGrantResolver, used by BOTH EnvelopePermissionRuleProvider and ToolInvocationGovernor.EnvelopeGrantsToolWhenArmed. The two layers now agree by construction, closing the gap the reviewer found. Added governor-level regression tests (ToolInvocationGovernorEnvelopeTests) proving the fix actually changes the real enforcement outcome, not just the rule set — mutation-tested by temporarily swapping in a resolver with no published-name coverage and confirming the new test fails without the fix. Full solution test suites re-run clean: Application.AI.Common.Tests (2716), Application.Core.Tests (1215), Infrastructure.AI.Tests (3517/3523, 6 pre- existing skips). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Aao7Y3hU22v6VH1RdiSxYu
…solver Two independent /code-review passes caught the same regression: extracting the shared resolver dropped the ERROR log EnvelopePermissionRuleProvider used to emit when a first-party tool's constructor throws while resolving its published name. FirstPartyToolLookup.TryResolvePublishedName is deliberately pure (no logging) specifically so every caller supplies its own log-on-failure — both call sites here were passing `out _`, discarding it entirely with no substitute anywhere in the new type. Added ILogger<CapabilityEnvelopeGrantResolver> and a private wrapper that logs only the genuine construction-failure case (not the normal "not a first-party tool" case), used by both Grants() and ExpandWithPublishedNameCoverage(). Full suites re-run clean: Application.AI.Common.Tests (2716), Application.Core.Tests (1215), envelope-scoped Infrastructure.AI.Tests (19). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Aao7Y3hU22v6VH1RdiSxYu
Summary
Fixes #626: a capability envelope's grants and a bundle's declared tools can name a first-party tool
by its DI registration key, but the runtime permission resolver matches rules against the tool's
self-reported published name at invocation. When the two disagree — a legitimate, tested scenario
(see
ToolCatalogTests.Catalog_ToolWhoseNameDisagreesWithItsKey_...) — a key-named grant silentlynever matched, and a key-named declared tool was silently denied even when actually granted under its
published name.
This mirrors #612's fix for
PluginPermissionRuleProvider.DeniedTools, applied toEnvelopePermissionRuleProvider's two rule kinds (declared-but-ungranted Deny, and theautonomy-ceiling baseline for granted tools).
What's included
EnvelopePermissionRuleProvider: expands both the envelope's grants and a bundle's declared toolsto also cover each name's resolved published name when it differs.
originally checked each name-form independently against the granted set, which could deny a tool
declared by one form but granted by the other. Fixed by deciding once per declared tool across all
its forms.
PluginPermissionRuleProvider's autonomy-baselinerules) was investigated and found to be based on a false premise — verified with a test before
shipping, then reverted and closed as not planned. Full reasoning in the issue's closing comment.
TryResolvePublishedNamelogic (which had beencopy-pasted between
PluginPermissionRuleProviderand this provider) ontoFirstPartyToolLookup,and split
GetRulesAsyncinto three named helper methods to bring it under the repo's 50-lineguideline.
(no caching on
GetRulesAsync, unlike the plugin provider'sStateVersion-keyed cache) and PermissionRulesSectionProvider.FormatRules shows duplicate entries for a tool covered by both key and published-name forms #652(the LLM-facing permission summary doesn't dedupe a tool covered by both its key and published-name
forms).
Test plan
dotnet build src/AgenticHarness.slnx— cleanApplication.Core.Testspermission-provider suite (45 tests) — passInfrastructure.AI.Testsenvelope-enforcement + sub-plan-confinement integration tests (19tests) — pass
declared-by-key-granted-by-published-name grouping fix (mutation-tested against the original bug)
run-gates.shwas killed twice by genuine host memory pressure (not a testfailure) — pushed with the sanctioned
RAILS_SKIP_REVIEW_GATE=1bypass, deferring to CI'sindependent gates
🤖 Generated with Claude Code
https://claude.ai/code/session_01Aao7Y3hU22v6VH1RdiSxYu