Skip to content

feat: enforce capability ceiling at ability-backed tool execution (Stages B+C)#414

Merged
chubes4 merged 1 commit into
Automattic:mainfrom
chubes4:ceiling-tool-enforcement
Jul 6, 2026
Merged

feat: enforce capability ceiling at ability-backed tool execution (Stages B+C)#414
chubes4 merged 1 commit into
Automattic:mainfrom
chubes4:ceiling-tool-enforcement

Conversation

@chubes4

@chubes4 chubes4 commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Part of #412 (Stages B+C).

Summary

Wires the existing enforcement engine into ability-backed tool execution so an ability whose required capability is not permitted by the execution principal's capability ceiling is denied before it runs. No new authorization machinery is introduced — the gap was purely that WP_Agent_WordPress_Authorization_Policy::can() was wired to can_access_agent but not to per-ability tool execution.

Stage B — Resolving the required capability (declarative)

A substrate-recognized, product-neutral convention for a tool declaration to name its required capability:

  • WP_Agent_Tool_Declaration::REQUIRED_CAPABILITY = 'required_capability' — a top-level declaration field.
  • WP_Agent_Tool_Declaration::requiredCapability( array ): string — the single resolver (trimmed capability name, or '' = no gate). Reads from normalized or raw declarations.
  • normalizeForServer() now validates/normalizes the field: a non-empty string is preserved; non-string/empty values are dropped. The declaration envelope stays clean.

This deliberately avoids per-consumer tool-name special-casing. The capability vocabulary is owned by WordPress core and the host; the substrate only carries the field and asks the policy. Client-executed tools keep the field via the existing extension-field preservation, but the host can only enforce it for host/ability tools (which is where it matters).

Stage C — Enforcement

The hook lives in WP_Agent_Ability_Tool_Executor::executeWP_Agent_Tool_Call() (src/Tools/class-wp-agent-ability-tool-executor.php), right after the ability name is resolved and before WP_Agent_Ability_Dispatcher::dispatch():

  1. Resolve required_capability from the tool declaration.
  2. If set, resolve the principal and authorization policy from the host runtime context:
    • policy: context['authorization_policy'] if it implements WP_Agent_Authorization_Policy, otherwise the substrate default new WP_Agent_WordPress_Authorization_Policy().
    • principal: context['principal'].
  3. Call the existing $policy->can( $principal, $required_capability ) — true iff the ceiling allows the cap and user_can( $acting_user, $cap ).
  4. On failure, return an auditable tool-error result (error_type => 'capability_denied') with a denial block mirroring the consent-decision shape (allowed, operation, reason, audit-safe principal metadata) plus redacted parameters. The model is told the tool was not permitted — it never receives the ability output.
  5. On success, or when no required_capability is declared, behavior is unchanged.

Threading the principal

The principal is available on WP_Agent_Conversation_Request::principal() but was not previously passed to the executor. WP_Agent_Conversation_Loop::run() now threads the resolved principal into the run context (context['principal']), which flows turn context → tool context → executor via the existing context-passing path. Denials fail closed: a tool that declares a required_capability but executes without a threaded principal is denied with reason principal_unavailable rather than silently bypassing the gate.

Works with either ceiling source

Enforcement depends only on the ceiling being present on the principal, not on how it was derived. With an explicit ceiling it intersects token restrictions ∩ WP user caps; with no ceiling restriction it falls back to pure WordPress user_can; with no principal at all it fails closed. This means Stage A (the autonomous-default ceiling) can land separately and this enforcement picks it up automatically.

Verify

composer smoke + composer phpstan (level max) are green. New tests/ability-tool-ceiling-smoke.php (wired into the smoke script) covers:

  • Declarative resolution + normalizeForServer validation (string preserved, non-string/empty dropped).
  • Ceiling denies the required capability → capability_denied, reason capability_not_permitted, ability never dispatched.
  • Ceiling permits + WordPress user allows → ability executes.
  • Ceiling permits but WordPress user lacks the cap → denied (ceiling ∩ user caps).
  • Required capability declared but no principal threaded → denied, reason principal_unavailable (fail closed).
  • No required capability → unchanged, executes without a principal.
  • Default substrate policy used when context omits one; host-supplied policy overrides it.
  • Principal with no ceiling restriction falls back to WordPress user capabilities.

Notes

  • Layer purity preserved: the substrate names no consumer orchestration modes and no consumer tool names.
  • No CHANGELOG.md/version edits (homeboy owns those).

…tomattic#412 B+C)

Resolve each ability-backed tool's required capability declaratively from
the tool declaration and gate dispatch through the existing
WP_Agent_WordPress_Authorization_Policy::can() before the ability runs.

- WP_Agent_Tool_Declaration: add required_capability field + resolver;
  validate/normalize on server declarations (absent = no gate).
- WP_Agent_Ability_Tool_Executor: consult the authorization policy before
  dispatch; deny with an auditable tool-error result when the principal's
  capability ceiling does not permit the capability.
- WP_Agent_Conversation_Loop: thread the resolved execution principal into
  the run context so it reaches tool execution.

Enforcement depends only on the ceiling being present on the principal, so
it works with either an explicit ceiling or the future autonomous default.

Part of Automattic#412 (Stages B+C).
@chubes4 chubes4 merged commit 4e534ab into Automattic:main Jul 6, 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.

1 participant