You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The write-tool leakage that motivated #408/#411 is a symptom of a deeper architectural gap: agents-api has a complete capability-ceiling authorization primitive, but it is not enforced at ability/tool execution. For a Core-candidate substrate, capability-ceiling enforcement — not tool-list curation — is the correct security boundary for autonomous writes. This issue specs that enforcement.
What already exists (and works)
The substrate already ships the right primitives:
WP_Agent_Execution_Principal (src/Runtime/) — models who is acting: auth_source (user/agent_token/system/runtime/…), acting_user_id (0 = system/no-human), and carries a capability_ceiling.
WP_Agent_Capability_Ceiling (src/Auth/) — "the intersection of the acting user's WordPress capabilities and optional token/client capability restrictions." Has allows_capability(string $cap): bool and with_allowed_capabilities() to narrow.
WP_Agent_WordPress_Authorization_Policy::can($principal, $capability) (src/Auth/) — the correct enforcement function: returns true only if the ceiling allows the capability ANDuser_can($user_id, $capability). Textbook ceiling ∩ actual-WP-caps.
WordPress Core's Abilities API (WP_Ability::execute() in wp-includes/abilities-api/class-wp-ability.php) also already enforces each ability's own permission_callback before running it.
The gap
WP_Agent_WordPress_Authorization_Policy::can() is only wired to agent-level access (can_access_agent). It is not consulted at per-ability/tool execution. So:
The tool executor runs an ability-backed tool without checking the principal's capability ceiling against the ability's required capability.
Ability permission_callbacks, where they exist, check current_user_can(...) — which reflects the acting user's full caps, not a reduced autonomous ceiling. A pipeline running as an admin user therefore passes every write check.
This is exactly why the production incident happened: the pipeline principal was a privileged user, so nothing — not tool-listing, not permission_callback — distinguished "this admin is interactively editing" from "this admin's token is driving an unattended loop that shouldn't publish."
Proposed design (Core-worthy boundary)
1. Autonomous principals carry a reduced ceiling by default.
When a run is autonomous (auth_source ∈ {system, runtime, agent_token} and/or acting_user_id represents automation), the substrate should default its capability_ceiling to one that excludes content-mutating capabilities unless the host explicitly grants them via with_allowed_capabilities(). The ceiling primitive already supports this; what's missing is a safe default derivation for autonomous auth sources.
2. Enforce the ceiling at ability-backed tool execution.
Before executing an ability-backed tool, the tool executor consults WP_Agent_WordPress_Authorization_Policy::can($principal, $required_capability). This requires a way to know the ability's required capability — options:
Read it from the ability's declared metadata/category (e.g. abilities in a *-publishing/write class declare a required cap), or
Derive from the ability's permission_callback contract, or
A substrate convention: write-capable ability declarations name their required capability.
On failure, the tool call is denied with an auditable reason (mirroring WP_Agent_Consent_Decision shape) — the model is told "not permitted," not silently handed the tool.
3. permission_callback honesty remains the consumer's job.
Abilities must declare truthful capability requirements (not permissive stubs). The substrate can't fix a lying permission_callback, but with (1)+(2) an honest one becomes sufficient and a reduced ceiling makes even a privileged acting-user safe in autonomous mode.
feat: safe-by-default tool curation — write-capable tools opt-in for autonomous principals #411 (tool-list curation) stays as defense-in-depth: don't even offer write tools to an autonomous agent. It reduces prompt surface and stops the model trying. But it must be documented as curation, not the security boundary, and it should key off the principal's autonomy (acting_user_id/auth_source), NOT a mode/interactive string — the substrate already models autonomy precisely; a mode string is a weaker proxy.
This issue is the actual enforcement boundary beneath it.
Why this matters for Core
Shipping a tool-list curation gate as if it were the security boundary would give Core sites a false sense of safety: any consumer that offers a write tool by omission, or runs as a privileged user, is exposed. The capability-ceiling model is WordPress-native (it composes with user_can/map_meta_cap), already half-built in the substrate, and is the honest boundary. Getting this right before Core alignment is the point.
Scope note
This is a design/architecture issue. Implementation should be staged: (a) safe default ceiling derivation for autonomous auth sources, (b) ability→required-capability resolution, (c) enforcement hook at tool execution, (d) audit/telemetry. Each is separately reviewable.
Summary
The write-tool leakage that motivated #408/#411 is a symptom of a deeper architectural gap: agents-api has a complete capability-ceiling authorization primitive, but it is not enforced at ability/tool execution. For a Core-candidate substrate, capability-ceiling enforcement — not tool-list curation — is the correct security boundary for autonomous writes. This issue specs that enforcement.
What already exists (and works)
The substrate already ships the right primitives:
WP_Agent_Execution_Principal(src/Runtime/) — models who is acting:auth_source(user/agent_token/system/runtime/…),acting_user_id(0 = system/no-human), and carries acapability_ceiling.WP_Agent_Capability_Ceiling(src/Auth/) — "the intersection of the acting user's WordPress capabilities and optional token/client capability restrictions." Hasallows_capability(string $cap): boolandwith_allowed_capabilities()to narrow.WP_Agent_WordPress_Authorization_Policy::can($principal, $capability)(src/Auth/) — the correct enforcement function: returns true only if the ceiling allows the capability ANDuser_can($user_id, $capability). Textbookceiling ∩ actual-WP-caps.WordPress Core's Abilities API (
WP_Ability::execute()inwp-includes/abilities-api/class-wp-ability.php) also already enforces each ability's ownpermission_callbackbefore running it.The gap
WP_Agent_WordPress_Authorization_Policy::can()is only wired to agent-level access (can_access_agent). It is not consulted at per-ability/tool execution. So:permission_callbacks, where they exist, checkcurrent_user_can(...)— which reflects the acting user's full caps, not a reduced autonomous ceiling. A pipeline running as an admin user therefore passes every write check.This is exactly why the production incident happened: the pipeline principal was a privileged user, so nothing — not tool-listing, not
permission_callback— distinguished "this admin is interactively editing" from "this admin's token is driving an unattended loop that shouldn't publish."Proposed design (Core-worthy boundary)
1. Autonomous principals carry a reduced ceiling by default.
When a run is autonomous (
auth_source ∈ {system, runtime, agent_token}and/oracting_user_idrepresents automation), the substrate should default itscapability_ceilingto one that excludes content-mutating capabilities unless the host explicitly grants them viawith_allowed_capabilities(). The ceiling primitive already supports this; what's missing is a safe default derivation for autonomous auth sources.2. Enforce the ceiling at ability-backed tool execution.
Before executing an ability-backed tool, the tool executor consults
WP_Agent_WordPress_Authorization_Policy::can($principal, $required_capability). This requires a way to know the ability's required capability — options:*-publishing/write class declare a required cap), orpermission_callbackcontract, orOn failure, the tool call is denied with an auditable reason (mirroring
WP_Agent_Consent_Decisionshape) — the model is told "not permitted," not silently handed the tool.3.
permission_callbackhonesty remains the consumer's job.Abilities must declare truthful capability requirements (not permissive stubs). The substrate can't fix a lying
permission_callback, but with (1)+(2) an honest one becomes sufficient and a reduced ceiling makes even a privileged acting-user safe in autonomous mode.Relationship to #408 / #411
acting_user_id/auth_source), NOT a mode/interactive string — the substrate already models autonomy precisely; a mode string is a weaker proxy.Why this matters for Core
Shipping a tool-list curation gate as if it were the security boundary would give Core sites a false sense of safety: any consumer that offers a write tool by omission, or runs as a privileged user, is exposed. The capability-ceiling model is WordPress-native (it composes with
user_can/map_meta_cap), already half-built in the substrate, and is the honest boundary. Getting this right before Core alignment is the point.Scope note
This is a design/architecture issue. Implementation should be staged: (a) safe default ceiling derivation for autonomous auth sources, (b) ability→required-capability resolution, (c) enforcement hook at tool execution, (d) audit/telemetry. Each is separately reviewable.