feat: add wp_agent_can_access_agent host access filter for list/check symmetry#418
Merged
Conversation
… symmetry Add a generic host seam to the agent access authorization path so hosts that derive access from live state (capabilities, roles, memberships) can grant or deny without materializing grant rows. can_access_agent() now wraps its final decision — including the effective-agent short-circuit — in the wp_agent_can_access_agent filter: apply_filters( 'wp_agent_can_access_agent', , , , , ) list_accessible_agents_for_principal() is rewritten to iterate every registered agent through the same can_access_agent() decision. This guarantees list/check symmetry by construction: the list is literally the set of agents for which the check returns true. Hosts hook one filter and both abilities agree. Design choice: per-agent reuse of the decision filter (not a list-level filter). A list-level filter would require hosts to hook two filters consistently, recreating the exact divergence bug this fixes. Fixes #417
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
agents/can-access-agentandagents/list-accessible-agentsresolve access exclusively through theWP_Agent_Access_Store/WP_Agent_Principal_Access_Storecontracts (explicit grant rows). There is no seam for the host to contribute an access decision that isn't materialized as a grant row.Hosts that derive agent access from live state — a WordPress capability, a role, an org membership, a subscription — cannot express that through the substrate. They end up with two divergent access systems:
Consumers of the abilities (chat widgets, MCP clients, REST callers) silently get answer #2, which disagrees with answer #1.
Production impact
On a live site, the host bridges WordPress capability checks into its own access filter, and an integration plugin grants a team role access to an agent through it. The frontend chat widget resolves visibility via
agents/list-accessible-agents→ store-only. Result: of ~47 team members who should see the agent, only the 3 with explicit grant rows ever did. The capability bridge was dead code on every ability-driven surface, and nobody noticed for weeks because both paths exist and both look authoritative.Solution
1.
wp_agent_can_access_agentfilter on the check pathWP_Agent_WordPress_Authorization_Policy::can_access_agent()now wraps its final decision — including the$principal->effective_agent_id === $agent_idshort-circuit — in a filter:The store-derived logic is extracted into
resolve_default_access_decision()(private). Input validation (empty agent_id, invalid role) remains an early return — not filtered — since an invalid input is not a meaningful access decision for a host to override.The filter wraps the short-circuit so hosts can tighten as well as widen: a host can deny access to the effective agent itself, not just override store-grant results.
Guarded with
function_exists( 'apply_filters' )following the existing codebase pattern (seeWP_Agent_Access::get_store(),WP_Agent_Access::access_principals_for()).2. List path: per-agent reuse of the same decision filter
WP_Agent_Access::list_accessible_agents_for_principal()is rewritten to iterate every registered agent (viawp_get_agents()) through the samecan_access_agent()decision used by the check path. This guarantees list/check symmetry by construction: the list is literally the set of agents for which the check returns true.Design choice: per-agent reuse, not a list-level filter.
A list-level filter (
wp_agent_accessible_agentson the resolved array) would require hosts to hook two filters consistently — one for check, one for list. If they hook only one (or hook them differently), list and check diverge. That is the exact class of bug this PR fixes. Per-agent reuse means the host hooks one filter and both abilities agree, always.The candidate set is all registered agents (from the in-memory registry, no DB cost). For each,
can_access_agent()runs the store lookup + filter. When no filter is hooked,can_access_agent()returns the store-derived decision, so only store-granted agents pass — same result as before, just resolved per-agent instead of via a bulkget_agent_ids_for_userquery. The number of registered agents is typically small (single digits to low dozens), so the per-agent cost is negligible.3. Behavior changes
effective_agent_idmatches a registered agent will now see that agent in the list (via the short-circuit incan_access_agent). Previously, the list path explicitly excludedeffective_agent_idfor audience principals. This is a correctness fix: the check path already returnedtruefor that agent (short-circuit), so the list was disagreeing with the check. The list now agrees.Tests
New smoke test
tests/access-decision-filter-smoke.php(35 assertions) covers:truefor an agent with no grant; both check and list reflect it.falsefor an agent with a grant; both check and list reflect it.can_access_agentresult matches list membership.effective_agent_idshort-circuit; list excludes the denied effective agent.agents_can_access_agentandagents_list_accessible_agentsagree through the full ability path.Verification
composer test— all smoke tests pass (including existingagents-access-ability-smokeandauthorization-smokewith no regressions).composer phpstan— clean (0 errors).no-product-imports-smoke— passes (no product vocabulary, no host/plugin/vendor references).homeboy review— audit and lint stages pass. (Test stage failed due to a WP Codebox CLI infrastructure issue — missing Node module — not a code issue; GitHub Actions CI is the real gate.)Fixes #417