Skip to content

feat: add wp_agent_can_access_agent host access filter for list/check symmetry#418

Merged
chubes4 merged 1 commit into
mainfrom
access-decision-filter
Jul 8, 2026
Merged

feat: add wp_agent_can_access_agent host access filter for list/check symmetry#418
chubes4 merged 1 commit into
mainfrom
access-decision-filter

Conversation

@chubes4

@chubes4 chubes4 commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Problem

agents/can-access-agent and agents/list-accessible-agents resolve access exclusively through the WP_Agent_Access_Store / WP_Agent_Principal_Access_Store contracts (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:

  1. The host's own filter/policy surface (applied on the host's internal paths).
  2. The substrate's store-only path (applied by every consumer that goes through the abilities).

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_agent filter on the check path

WP_Agent_WordPress_Authorization_Policy::can_access_agent() now wraps its final decision — including the $principal->effective_agent_id === $agent_id short-circuit — in a filter:

$allowed = (bool) apply_filters(
    'wp_agent_can_access_agent',
    $allowed,        // store-derived decision (incl. short-circuit)
    $principal,      // WP_Agent_Execution_Principal
    $agent_id,       // string
    $minimum_role,   // string
    $context         // array<string,mixed>
);

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 (see WP_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 (via wp_get_agents()) through the same can_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_agents on 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 bulk get_agent_ids_for_user query. The number of registered agents is typically small (single digits to low dozens), so the per-agent cost is negligible.

3. Behavior changes

  • No behavior change for hosts that don't hook the filter. All existing smoke tests pass unmodified.
  • Edge case: an audience principal whose effective_agent_id matches a registered agent will now see that agent in the list (via the short-circuit in can_access_agent). Previously, the list path explicitly excluded effective_agent_id for audience principals. This is a correctness fix: the check path already returned true for 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:

  • Default behavior unchanged when nothing hooks the filter (store grants work, non-granted agents denied).
  • Filter grants access with no store row — a hooked filter returns true for an agent with no grant; both check and list reflect it.
  • Filter denies despite a store grant — a hooked filter returns false for an agent with a grant; both check and list reflect it.
  • List/check agreement in both directions — for every registered agent, can_access_agent result matches list membership.
  • Short-circuit tightening — the filter can deny the effective_agent_id short-circuit; list excludes the denied effective agent.
  • Ability functionsagents_can_access_agent and agents_list_accessible_agents agree through the full ability path.

Verification

  • composer test — all smoke tests pass (including existing agents-access-ability-smoke and authorization-smoke with 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

… 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
@chubes4 chubes4 merged commit c1183c4 into main Jul 8, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Access ability path bypasses host authorization policy — no seam for host-level access decisions beyond the grant store

1 participant