
Wp Abilities Audit
- 1.2k installs
- 2k repo stars
- Updated August 3, 2026
- wordpress/agent-skills
wp-abilities-audit is an agent skill that audits a WordPress plugin REST surface and produces a standardized Abilities API registration plan for developers targeting WordPress 6.9+ who need agent-readable capability mapp
About
wp-abilities-audit is an agent skill from wordpress/agent-skills that audits any WordPress plugin's REST API surface and produces a standardized audit document proposing Abilities API registrations grouped by semantic intent. The deliverable is a markdown doc with YAML schema and prose sections consumable by both humans and coding agents when planning a registration rollout. The skill targets WordPress 6.9+ on PHP 7.2.24+ and operates as a filesystem-based agent using bash and Node against a plugin checkout; WP-CLI helps some workflows but is not required. Developers reach for wp-abilities-audit when exposing plugin capabilities to AI agents through WordPress Abilities API rather than ad hoc REST wrappers. The audit bridges existing REST routes to structured ability registrations before implementation begins.
- wp-abilities-audit
Wp Abilities Audit by the numbers
- 1,188 all-time installs (skills.sh)
- +121 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #362 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/wordpress/agent-skills --skill wp-abilities-auditAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.2k |
|---|---|
| repo stars | ★ 2k |
| Last updated | August 3, 2026 |
| Repository | wordpress/agent-skills ↗ |
How do you audit WordPress plugin REST for Abilities API?
Use wp-abilities-audit for development tasks
Who is it for?
WordPress plugin developers on 6.9+ planning Abilities API registrations who need a structured REST-to-ability audit before rollout.
Skip if: Teams building non-WordPress APIs or plugins that do not expose REST endpoints requiring Abilities API migration.
When should I use this skill?
The user asks to audit a WordPress plugin REST surface, plan Abilities API registrations, or produce an agent-consumable capability map for a WP plugin.
What you get
Standardized markdown audit document with YAML schema and prose sections proposing Abilities API registrations grouped by semantic intent.
- Markdown Abilities API audit document
- YAML schema for proposed registrations
By the numbers
- Targets WordPress 6.9+ with PHP 7.2.24+ compatibility
- Output includes YAML schema and prose audit sections
Files
WP Abilities Audit
Produce a standardized audit document for a WordPress plugin's REST surface, proposing a set of Abilities API registrations grouped by semantic intent. The audit doc is a planning artifact for implementers — humans, agents, or both — that captures the controller inventory, capability gates, and proposed ability shapes in a structured form. A reviewer reading the doc can scope the work without re-deriving the survey.
This skill works on any plugin that exposes a REST surface. Plugin classification (for purposes of the optional plugin_family annotation) is the user's call; the workflow itself is plugin-agnostic.
When to use
- The task is "register Abilities API abilities for a WP plugin" and no audit
doc exists yet.
- Planning participation in a multi-plugin abilities rollout and need a
shareable, standardized audit artifact.
- Pre-flight checking a plugin's agent-readiness before implementing abilities.
- A PM or non-implementer wants to scope the work before engineering picks it up.
Inputs required
1. Plugin checkout path — working tree of the plugin to audit. 2. Triage output — run wp-project-triage first if not already done. The audit consumes signals.usesAbilitiesApi, versions.wordpress, and project.kind from the report. 3. Auditor identity — name and team or context, recorded in the audit's auditor field. 4. Output path — where the audit doc should land. Default explicit over implicit; ask if not provided rather than writing into the plugin worktree.
Prerequisites
wp-project-triagehas run successfully and classified the plugin.- The plugin has at least one REST controller. If enumeration finds zero
controllers, the audit doesn't apply — see "Failure modes" below.
Procedure
1. Enumerate REST controllers
Read references/controller-enumeration.md now — it covers the two observed enumeration paths (glob for standard layouts, grep as the universal fallback) and when to use each.
Record every controller class + file + REST base + routes in a "Controller Inventory" table. The inventory is exhaustive even though only a subset becomes proposed abilities.
2. For each controller, extract the backing fields
For every controller found, extract the fields the audit schema requires: class, file, HTTP method, route, route-registration line number, callback name, callback line number, permission callback, whether the callback takes a WP_REST_Request argument or is zero-arg, and the return type.
Read references/audit-schema.md now for the exact field list and the shape of proposed_abilities entries. Line-number fields may be null for inherited callbacks — the schema allows this and pairs it with an optional inherited_from field.
3. Confirm capability gate(s)
Trace each controller's permission_callback to its current_user_can() call (or to the post-type capability machinery if the controller extends a post-type-backed base).
Read references/capability-gate-tracing.md now — it documents the two common mechanisms (direct check_permission() vs post-type-backed wc_rest_check_post_permissions()) and how to represent each in the schema. Note explicitly whether read and write gates differ: compound gates are represented as a {read, write} object, not a single string.
4. Propose abilities using semantic-intent grouping
Do NOT atomize one ability per HTTP method. Apply the semantic-intent grouping heuristic — it's the only grouping rule this skill uses.
Read ../wp-abilities-api/references/grouping-heuristic.md now — do NOT re-derive the rules here. Short version: one ability per real-world question or state transition, with filter parameters in input_schema collapsing N variants into 1.
Apply the use-case sanity check before populating any candidate. Per ../wp-abilities-api/references/domain-vs-projection.md's use-case-contract test: would a human or agent intentionally perform this behavior through a supported plugin workflow? If yes, the candidate is a real ability — proceed to fill in fields. If no, the route is internal transport plumbing (cache invalidation, scheduler ticks, bookkeeping endpoints, debug introspection) — keep it in the Controller Inventory section for completeness, but do NOT promote it to proposed_abilities. The route may be useful to inventory; the proposed ability must represent a real user/operator question or action.
For each proposed ability that passes the sanity check, fill in every field in the proposed_abilities schema: name, intent, backing, permission, return_type, effort (S/M/L), annotations (readonly/destructive/idempotent), notes, risks, use_case_fit, side_effects, seed_data_needs.
The last three are the implementation-readiness facts the implementer and the verify-mode tooling both need: which human/agent workflow this ability serves (use_case_fit), what the backing path emits on every call (side_effects — empty array is a fact, not a missing value), and what representative data must exist in the test environment for the ability to execute through the public boundary (seed_data_needs).
5. Surface gaps and deferred items
Three buckets:
- `excluded_from_mvp` — candidates intentionally deferred for risk reasons
(real-money writes, irreversible state changes, or prerequisite design work). Each entry gets a one-sentence reason.
- `surfaced_gaps` — MVP candidates with no backing endpoint (ability with
backing: null), plus high-value endpoints discovered during enumeration that aren't in the MVP list but would be easy future wins.
- Risks per ability — anything about a backing endpoint that the
implementer must handle (no idempotency key, two-phase behavior, state-transition caveats, zero-arg endpoints registered with permission_callback => '__return_true' that must NOT copy that into the ability registration).
6. Write the audit doc
Write to the explicit output path collected in "Inputs required". The document structure must match references/audit-schema.md exactly:
1. Last updated: YYYY-MM-DD HH:MM header. 2. YAML block with all required top-level metadata + proposed_abilities, excluded_from_mvp, surfaced_gaps. 3. "Controller Inventory" table. 4. "Notes and Surprises" prose section.
A copy-pasteable minimal example showing the full shape lives in references/audit-schema.md under "Minimal valid example" — start there when authoring a new audit.
7. (Optional) Designate a reference implementation ability
Set reference_ability: true on the first ability an implementer should land — typically the smallest, safest, highest-leverage read. This gives downstream workflows a deterministic starting point.
Verification
- The audit conforms to
references/audit-schema.md(all required top-level
fields present, at least one entry in proposed_abilities, annotations complete on every ability).
capability_gateis a string for single-cap plugins or a{read, write}
object for post-type-backed plugins.
- Every ability with
backing: nullalso appears insurfaced_gaps. - The doc round-trips through the validator in
audit-schema.md"Known
limitations" without errors.
Failure modes / debugging
- Plugin has no REST controllers — audit doesn't apply. Consider
hooks/filters-based abilities (out of scope for this skill's current version) or skip abilities adoption for this plugin.
- Plugin inherits controllers from another repo (common for plugins
extending core post-type-backed controllers like WP_REST_Posts_Controller, or extension plugins built on a parent's REST classes) — capture with backing.inherited_from: "<parent FQCN>". Line-number fields may be null per the schema.
- Compound capability gate (distinct read/write caps) — use the
structured {read, write} form documented in references/capability-gate-tracing.md. Don't smuggle a /-separated string into a field typed as a single cap.
- Ambiguous grouping — route to
../wp-abilities-api/references/grouping-heuristic.md. Do not invent alternative grouping rules in the audit doc.
- Zero-arg endpoints with `permission_callback => '__return_true'` —
legal at the REST layer, but the ability's own permission_callback must match the plugin's merchant gate. Never promote '__return_true' into an ability registration. Note this in the ability's risks.
- Output path defaults to plugin worktree — always ask the user for an
explicit output directory (e.g. their vault plans/). Writing the audit into the plugin's own git history pollutes the worktree and buries the artifact.
Escalation
- If the plugin uses an enumeration convention not covered by
references/controller-enumeration.md (neither the standard glob nor the grep fallback produces a complete inventory), update that reference with the new convention and open a PR so future audits cover it deterministically.
- If capability tracing hits a mechanism not covered by
references/capability-gate-tracing.md, extend that file rather than encoding the new case in the audit's "Notes and Surprises" only.
Audit Document Schema
The canonical schema for an abilities audit doc. Every audit produced by wp-abilities-audit must conform to this schema so downstream tooling (humans reviewing, agents implementing, or validators like wp-abilities-verify) can consume it without parsing surprises.
A copy-pasteable minimal example with both a read ability and a write ability lives under "Minimal valid example" below.
File layout
<output-dir>/<YYYY-MM-DD>-abilities-audit-<plugin-slug>.md<output-dir> is explicit — collected from the user, not inferred. Typical values are the user's vault plans/ directory or a dedicated audit repo. Writing into the plugin worktree is discouraged (pollutes git history).
The body has two parts:
1. A fenced `yaml block holding structured fields (top-level metadata + proposed_abilities, excluded_from_mvp, surfaced_gaps). 2. Prose sections below: "Controller Inventory" table + "Notes and Surprises".
A Last updated: YYYY-MM-DD HH:MM header sits above everything.
Top-level fields (all required)
| Field | Type | Description |
|---|---|---|
plugin | string | Plugin slug (e.g. my-plugin, tasks-plugin, notifications). |
repo | string | Owner/Repository. |
branch_audited | string | Git branch the audit was run against. |
audited_at | string | ISO date (YYYY-MM-DD). |
auditor | string | Human auditor name + team or context (e.g. Your Name (Your Team)). |
baseline_abilities | integer | Count of abilities already registered by the plugin at audit time. Usually 0. |
capability_gate | string OR object | The capability gate the base controller resolves to. Accept either a single string (single-cap plugins) OR a {read, write} object (post-type-backed or otherwise compound gates). See capability-gate-tracing.md for the mechanisms. |
plugin_family | string (optional) | Free-form classification when useful to downstream readers (e.g. core-post-type, forms-engine, or a project-specific family name). Optional and user-supplied — no canonical enum. Downstream consumers treat unknown values as opaque rather than erroring. |
capability_gate representations
Two legal shapes, both consumed by downstream tooling:
# Single-cap plugin (one capability across every controller)
capability_gate: manage_options # confirmed at includes/admin/class-my-plugin-rest-controller.php line 64# Compound read/write (post-type-backed plugins typically need this shape;
# read and write resolve to different capabilities)
capability_gate:
read: read_private_pages
write: edit_others_pages
confirmed: true
verified_at: "custom_post_type capability_type='page' → core post-type cap map (wp-includes/post.php map_meta_cap)"Plugin-specific capabilities (e.g. WooCommerce's manage_woocommerce, edit_shop_orders) are equally valid — substitute your plugin's caps. The shape is the contract; the literal cap names are project-specific.
A legacy compound-string form exists in the wild ("<read_cap> / <write_cap>") and is accepted for backwards compatibility, but the structured form above is the preferred representation for new audits.
proposed_abilities — array
Each entry:
| Field | Type | Description |
|---|---|---|
name | string | Kebab-case <plugin-slug>/<ability>. |
intent | string | One sentence, user-question framed. |
backing | object or null | See below. null marks an ability with no backing endpoint (a known gap). |
permission | object or null | See below. null when backing is null. |
return_type | string | Short description (e.g. WP_REST_Response (wrapping array)). Hint-only; not machine-parsed. |
effort | enum | S, M, or L. |
annotations | object | { readonly: bool, destructive: bool, idempotent: bool }. All three required. |
notes | array of strings | Implementer-facing detail (filter params, edge cases, alternative backings). |
risks | array of strings | Anything the implementer must handle (missing idempotency key, two-phase behavior, permission_callback => '__return_true' at the REST layer that must not copy into the ability, etc.). |
use_case_fit | string | One sentence naming the human or agent workflow this ability serves. The use-case-contract check (see wp-abilities-api/references/domain-vs-projection.md): if no human would intentionally do this through a supported UI or workflow, the entry probably belongs in excluded_from_mvp instead. |
side_effects | array of strings | Side effects the backing path emits on every call: telemetry hooks, audit-log rows, notifications, cache writes. One short line per effect. Empty array ([]) when the backing is a pure data-fetch — that is itself a load-bearing fact: it is what unlocks the conditional delegation shortcut in wp-abilities-api/references/shared-core-service.md. A non-empty array tells the implementer (and downstream verify-mode tooling) that this ability needs the shared-service shape, not the delegate-through-REST shortcut. |
seed_data_needs | string OR null | One line describing what representative data must exist in the test environment for the ability to execute through the public boundary and return something meaningful (e.g. "at least one entity in the plugin's primary table", "no seed required"). null when the auditor has not yet identified the seed shape; downstream verify-mode tooling treats null as "ask the implementer" rather than guessing. |
reference_ability | bool (optional) | If true, marks this ability as the reference implementation — the first one an implementer should land (smallest, safest, highest-leverage read). Exactly zero or one ability per audit may set this. |
backing: null semantics
An ability with backing: null is a known gap (the auditor identified a valuable ability that has no backing endpoint yet). The schema permits this as a warning, not an error:
- The ability MUST also appear in
surfaced_gapswith a one-line rationale. - Implementers pause for resolution rather than guessing a backing.
- The audit is still valid;
backing: nullis intentional output, not
missing data.
backing object
| Field | Type | Description |
|---|---|---|
kind | enum (optional, default rest_controller) | The implementation path the ability should use or inspect. One of rest_controller, service, helper, data_store. When omitted, defaults to rest_controller for backwards compatibility with audits authored before this field landed. The kind tells downstream tooling whether the delegation pattern from wp-abilities-api/references/shared-core-service.md applies (only rest_controller is a candidate; the others select the shared-service shape from the start). |
class | string | Fully-qualified PHP class name (controller class for rest_controller; service / helper / data-store class for the other kinds). May be omitted when kind: data_store and the backing is a bare option key, post-meta key, or table without an owning class. |
file | string | Path relative to plugin root. |
method | enum (required when kind: rest_controller) | HTTP method: GET, POST, PUT, DELETE, PATCH. Not applicable when kind is service, helper, or data_store. |
route | string (required when kind: rest_controller) | Full REST route path. Not applicable to non-REST kinds. |
route_registration_line | integer OR null | For kind: rest_controller: line number of the register_rest_route( call, or null when inherited. Omit for other kinds. |
callback | string | For kind: rest_controller: controller method name that handles the route. For kind: service / helper: method name on the service / helper class. For kind: data_store: the operation name (get_option, get_post_meta) or the table-read pattern; may be omitted. |
callback_line | integer OR null | Line number of the callback or method definition, or null when inherited or not applicable. |
inherited_from | string (optional) | Fully-qualified parent class name when the route and/or callback is inherited from a class outside this plugin's repo (e.g. WP_REST_Posts_Controller from WordPress core, or another plugin's REST base class for extension plugins). Pair with null line numbers. Lets downstream tooling skip the re-grep step cleanly. Primarily relevant for kind: rest_controller. |
permission object
| Field | Type | Description |
|---|---|---|
source | enum (optional, default rest_controller) | Where the canonical permission for this behavior lives — not always the REST controller's permission_callback. One of rest_controller, admin_action, service, domain_policy, post_type_map, none. When omitted, defaults to rest_controller for backwards compatibility. admin_action for behaviors gated by check_admin_referer / current_user_can on an admin handler; service when a shared method enforces the cap; domain_policy for plugins with a policy / authorization layer; post_type_map for capabilities resolved through map_meta_cap on a post-type cap shadow; none for genuinely public behavior. Tells the implementer whether the ability's permission_callback can mirror the REST callback or must consult a different source of truth. |
callback | string | The method or function name that enforces the cap at the recorded source. For source: rest_controller, this is the permission_callback value. For source: admin_action, the admin handler function or method. For source: service, the service method that performs the cap check. |
resolves_to | string | The current_user_can() call(s) it ultimately resolves to. For compound gates, include both (e.g. "current_user_can('read_private_pages') for read; current_user_can('edit_others_pages') for write"). |
confirmed | bool | true if verified against source; false if inferred. |
excluded_from_mvp — array
Abilities intentionally deferred for risk reasons. Each entry:
| Field | Type | Description |
|---|---|---|
name | string | Proposed ability name (kebab-case). |
reason | string | One sentence why it's deferred. |
surfaced_gaps — array
MVP candidates with no backing endpoint (paired with backing: null above), plus high-value endpoints discovered during enumeration that aren't in MVP but would make future follow-up work. Each entry:
| Field | Type | Description |
|---|---|---|
name | string | Proposed ability name. |
one_line_rationale | string | Why it would be high-leverage. |
Prose sections (required)
Controller Inventory
A Markdown table with columns Class | File | REST Base | Routes. Must list every controller enumeration found, even ones that aren't backing any MVP ability. This gives reviewers a full picture and catches "why isn't X in the MVP?" questions.
Notes and Surprises
Free-form prose capturing anything that didn't fit the structured schema: capability-gate mismatches between controllers, hardcoded route paths, dual controllers with different output shapes, two-phase endpoint semantics, and any judgment calls the auditor made.
Minimal valid example
Copy-pasteable starting point for a new audit:
````markdown --- Last updated: 2026-04-20 14:30 ---
Example Plugin Abilities — Phase 1 Audit
plugin: example-plugin
repo: Owner/example-plugin
branch_audited: feat/abilities-example-plugin
audited_at: 2026-04-20
auditor: Your Name (Your Team)
baseline_abilities: 0
capability_gate: manage_options # confirmed at includes/rest-api/class-example-rest-controller.php line 32
proposed_abilities:
- name: example-plugin/get-items
intent: "List items with filters (status, owner, date range) so an agent can answer 'which items need attention?' in one call."
backing:
kind: rest_controller
class: Example_REST_Items_Controller
file: includes/rest-api/class-example-rest-items-controller.php
method: GET
route: /example/v1/items
route_registration_line: 26
callback: get_items
callback_line: 52
permission:
source: rest_controller
callback: check_permission
resolves_to: "current_user_can('manage_options')"
confirmed: true
return_type: "WP_REST_Response (wrapping array)"
effort: S
annotations: { readonly: true, destructive: false, idempotent: true }
notes:
- "get_items(WP_REST_Request $request) requires a WP_REST_Request; construct one in the ability execute_callback."
risks: []
use_case_fit: "Agent answers 'which items need attention right now?' in a single call without paging through a UI."
side_effects: []
seed_data_needs: "at least one item exists in any non-trashed status"
reference_ability: true
- name: example-plugin/close-item
intent: "Close a single item — terminal state transition, non-reversible."
backing:
kind: service
class: Example_Items_Service
file: src/Service/class-items-service.php
callback: close
callback_line: 88
permission:
source: service
callback: Example_Items_Service::assert_can_close
resolves_to: "current_user_can('manage_options')"
confirmed: true
return_type: "WP_REST_Response (updated item object)"
effort: M
annotations: { readonly: false, destructive: true, idempotent: false }
notes:
- "Close is terminal — no reopen endpoint exists."
risks:
- "No idempotency key on the backing endpoint; duplicate POSTs may produce inconsistent audit trails."
use_case_fit: "User or agent closes a stale item from a workflow that surfaces stale items (admin list view, daily-digest agent)."
side_effects:
- "fires action `example_plugin/item_closed` (downstream listeners may dispatch email)"
- "writes audit-log row to `example_plugin_audit_log`"
seed_data_needs: "one open item to close; the test must capture the item id before invocation"
excluded_from_mvp:
- name: example-plugin/delete-item
reason: "Hard delete is irreversible and lacks an undo endpoint; defer until soft-delete is designed."
surfaced_gaps:
- name: example-plugin/get-overview
one_line_rationale: "A zero-arg overview endpoint answering 'what's the current state of all items?' would be the highest-leverage ability but no backing endpoint exists yet."Controller Inventory
| Class | File | REST Base | Routes |
|---|---|---|---|
| Example_REST_Items_Controller | includes/rest-api/class-example-rest-items-controller.php | example/v1/items | GET /example/v1/items, POST /example/v1/items/{id}/close |
Notes and Surprises
Capability gate is uniform
Every controller inherits Example_Base_REST_Controller and uses check_permission verbatim as the permission_callback. No per-route overrides. Safe to treat manage_options as the single gate. ````
Known limitations
Documented so downstream skills have an explicit contract:
- `capability_gate` string-with-inline-comment form loses data when parsed
by strict YAML parsers (comments are dropped). The structured object form is preferred; string form is accepted for backwards compatibility.
- Legacy compound-string `capability_gate` — the
"<read_cap> / <write_cap>"
form predates the structured {read, write} object and is still accepted for backwards compatibility. Validators (e.g. wp-abilities-verify) emit WARN on this form to nudge migration to the structured shape; they do NOT FAIL. New audits should use the object form.
- `return_type` is hint-only. Prose for the human auditor; not
machine-parseable. Downstream skills use runtime is_wp_error(...) and instanceof WP_REST_Response checks regardless of what this field says.
- Line numbers drift.
route_registration_lineandcallback_lineare
captured at audit time and may bit-rot. Downstream skills re-locate routes by (class, callback) and do not rely on exact line numbers.
- `inherited_from` + `null` line numbers are the canonical way to
represent routes/callbacks defined in a parent class that lives outside the plugin repo.
- `backing: null` invariant. Abilities with
backing: nullare intentional
gaps and MUST also appear in surfaced_gaps by name. Validators FAIL audits where this invariant is violated (a null backing without a matching surfaced_gaps entry indicates inconsistent audit output).
- Implementation-readiness fields added 2026-05-21.
use_case_fit,
side_effects, and seed_data_needs are required in the per-ability schema as of this date. Audits authored against an earlier revision of this schema will be missing the three fields. Validators (e.g. wp-abilities-verify) emit WARN on missing implementation-readiness fields to nudge backfill the next time the audit is touched; they do NOT FAIL, mirroring the legacy capability_gate posture above. New audits MUST populate all three.
- `backing.kind` and `permission.source` added 2026-05-21. Both
are optional with default rest_controller so older audits validate as-is. New audits SHOULD populate both explicitly — backing.kind to record whether the ability backs a REST controller, a shared service, a helper, or a data store (because the answer drives the delegate-vs-extract-service decision in wp-abilities-api/references/ shared-core-service.md); permission.source to record where the canonical permission lives (REST callback is the common case, but admin actions, service methods, domain policies, and post-type cap maps each happen). Validators treat a missing field as the default, not as an error.
Capability-Gate Tracing
How to resolve the actual capability (or capabilities) a plugin's REST controllers gate on. The audit's capability_gate field and each ability's permission.resolves_to field need to reflect reality, not what the controller docblock says.
Two common mechanisms cover most plugins. Document both explicitly so the auditor doesn't hard-code one plugin family's assumptions.
Mechanism A — Direct (check_permission() returning a single cap)
The base REST controller declares a check_permission() (or permissions_check()) method that calls current_user_can('<some_cap>') once. Every route in the controller uses that method as permission_callback.
Identifying signs
- The base controller has a method like:
public function check_permission() {
return current_user_can( 'manage_options' );
}- Controllers extend the plugin's own base, not a WordPress core
post-type-backed class.
- The grep
grep -n 'current_user_can' <base-controller>.phpyields one hit.
How to trace
# Locate the base controller (usually the parent of every REST controller).
grep -rn 'extends .*REST_Controller' includes/ | head
# Read its permission_callback implementation.
grep -n 'check_permission\|permissions_check' <base-controller>.phpTrace once: the single current_user_can() call is the plugin's gate.
How to represent in the audit
capability_gate: manage_options # confirmed at includes/admin/class-<plugin>-rest-controller.php line 64Plugin-specific capabilities (e.g. WooCommerce's manage_woocommerce for shop-aware contexts, Jetpack Forms' edit_pages) substitute for manage_options cleanly — the shape stays the same.
Mechanism B — Post-type-backed (core CPT capability machinery)
The controller extends a WordPress core post-type-backed class that dispatches to the post-type capability map. There is no local check_permission() — the permission callback resolves dynamically at request time based on the request context (read vs write) and the post type's cap object.
Identifying signs
- The controller's base class is one of:
WP_REST_Posts_Controller— the core post-type REST base.- A subclass of it, in or out of this plugin's repo.
- No local
check_permission()— permission callbacks are inherited. - The post type is registered with
capability_type => '<cpt_or_shadow>',
and the cap map is resolved by core's map_meta_cap().
How to trace
# Find the post-type registration.
grep -rn "register_post_type\s*(\s*['\"]<cpt_name>['\"]" .
# Read the registration block. The relevant fields are:
# - capability_type: the type whose cap map this post type uses.
# A custom post type can either declare its own caps or shadow another
# type's (e.g. capability_type => 'page' to reuse Pages' caps).
# - capabilities: optional explicit cap-string overrides.
# - map_meta_cap: whether meta caps (read_post, edit_post) get mapped to
# primitive caps (read_private_<type>s, edit_others_<type>s).Dynamic resolution typically lands at:
- Read context (GET list / GET item):
current_user_can('read_private_<type>s')orcurrent_user_can('read_<type>', $id). - Write context (POST / PUT / DELETE):
current_user_can('edit_<type>s'),current_user_can('edit_others_<type>s'), orcurrent_user_can('delete_<type>s', $id).
The two often differ — post-type-backed plugins routinely have distinct read and write caps.
How to represent in the audit
Use the structured {read, write} form from audit-schema.md:
capability_gate:
read: read_private_pages
write: edit_others_pages
confirmed: true
verified_at: "custom_post_type capability_type='page' → core map_meta_cap (wp-includes/post.php) → primitive page caps"In each ability's permission block, spell out both calls:
permission:
callback: get_items_permissions_check
resolves_to: "WP_REST_Posts_Controller::get_items_permissions_check (inherited) → current_user_can('read_private_pages')"
confirmed: trueExample A — generic plugin shadowing core Pages caps. A custom post type registered with capability_type='page' inherits the Pages cap map, so reads gate on read_private_pages and writes gate on edit_others_pages.
Example B — WooCommerce-style sidebar. WooCommerce's shop_subscription is registered with capability_type='shop_order', so reads gate on read_private_shop_orders and writes gate on edit_shop_orders. Mechanically identical to Example A; the cap names are project-specific. WooCommerce also exposes a helper wc_rest_check_post_permissions() that wraps the same core machinery — the helper is convenience; the underlying mechanism is core's map_meta_cap().
Compound-string form (accepted, not preferred)
Some earlier audits encoded compound gates as a single string with a / separator:
capability_gate: read_private_pages / edit_others_pagesThis is accepted for backwards compatibility, but:
- Downstream consumers have to heuristically split on
/. - YAML comments after the string are silently dropped by strict parsers, so
provenance gets lost.
- The
{read, write}object form is machine-parseable and carries
confirmed and verified_at in-band.
Prefer the structured form for any new audit.
Procedure — trace the permission source for each proposed behavior
The ability's permission should match the plugin's intended gate for the proposed behavior, not necessarily the REST route. Often the REST controller's permission_callback is the right source of truth, but in some plugins the canonical permission lives elsewhere — an admin-action handler with its own check_admin_referer + current_user_can block, a service / helper method that performs the check before doing the work, a domain-policy / authorization layer, or a post-type cap shadow resolved through core's map_meta_cap. The audit should preserve where the permission canonically lives so the implementer doesn't silently drift to whichever source the REST layer happens to expose.
For each proposed ability, walk the chain once:
1. Identify the behavior the ability surfaces, then locate where the plugin enforces the cap for that behavior. Check the REST controller's permission_callback first; if the REST callback is '__return_true', delegates entirely, or doesn't match the behavior's intended gate, look for the canonical source in an admin handler, a shared service method, a domain-policy class, or a post-type cap map. 2. Record where the gate lives in the ability's permission.source field per audit-schema.md: one of rest_controller, admin_action, service, domain_policy, post_type_map, none. Default rest_controller; pick another value when the canonical source is elsewhere. 3. Determine whether the gate is Mechanism A (local method, single cap) or Mechanism B (inherited, post-type-backed, dynamic). 4. Resolve to the actual current_user_can() call(s). For Mechanism B, resolve BOTH read and write if the ability crosses contexts. 5. Record in the ability's permission.resolves_to field verbatim — the string should read as an actual trace, not a best-guess summary. 6. Add a risk note when the canonical permission source diverges from the REST controller's callback: the ability's permission_callback must consult the canonical source (or replicate its check), not copy the REST callback by reflex. 7. If every behavior in the plugin resolves to the same cap (or same {read, write} pair) at the same source, hoist it into the top-level capability_gate. If any behavior diverges in cap OR in source, record the divergence in "Notes and Surprises".
Common pitfall — permission_callback => '__return_true'
Zero-arg public endpoints sometimes declare permission_callback => '__return_true' at the REST layer (e.g. status lookups, enumerated lists that are safe to expose). The audit still needs a gate:
- Record the REST-layer value as-is (`resolves_to:
"__return_true (public)"`) so the auditor isn't hiding reality.
- Add a risk note: the ability registration must NOT copy
'__return_true' — the ability's own permission_callback must match the plugin's intended user gate (e.g. manage_options, edit_pages, or whatever your plugin uses). The ability layer is the agent-facing surface and needs that gate even when the underlying REST route is public.
Controller Enumeration
How to produce an exhaustive list of a plugin's REST controllers — the first step of every audit. Plugin family classification is handled separately by wp-project-triage; this reference covers the mechanics of finding controller classes inside whatever layout the plugin happens to use.
Two enumeration paths
Observed across the plugins audited to date, there are exactly two paths that together cover every layout seen in the wild:
| Path | When it works | How it works |
|---|---|---|
| Glob | Plugins that follow the standard includes/admin/class-*-rest-*-controller.php layout (WooCommerce core extensions, classic WooPayments). | Fast, deterministic, easy to script. Returns a complete list in one shell call. |
| Grep | Any non-standard layout — includes/api/, includes/rest-api/, src/rest/, monorepo package directories, or anything else. | Universal fallback: grep every PHP file under the plugin root for register_rest_route( call sites, then collect the enclosing class for each hit. |
Default order
1. Try glob first — it's faster and produces a cleaner inventory. 2. Fall back to grep if glob returns zero hits (or clearly undercounts against what you see in the plugin's public documentation / admin UI).
Running both and de-duplicating is legal; it catches monorepos that have some controllers under the standard layout and others under a package directory.
Glob — standard layout
# From the plugin root:
ls includes/admin/class-*-rest-*-controller.php 2>/dev/null
ls includes/reports/class-*-rest-*-controller.php 2>/dev/nullWhat you'll see in repos that match this convention:
- WooPayments (
Automattic/woocommerce-payments) — every controller under
includes/admin/class-wc-rest-payments-*-controller.php plus some under includes/reports/.
- WooCommerce core's internal REST controllers use the same pattern.
If glob returns 5+ hits, it's almost always the complete inventory. If it returns 0-2, fall through to grep.
Grep — universal fallback
# From the plugin root:
grep -rn --include='*.php' 'register_rest_route(' .For each hit:
1. Open the file. 2. Walk up to the enclosing class declaration. 3. Record (class, file, route, callback, permission_callback).
This path matters because it's the only one that finds controllers in non-standard locations:
- WooCommerce Subscriptions — controllers live under
includes/api/and
includes/api/legacy/. The standard WooPayments glob returns zero; grep is mandatory.
- Jetpack Forms (and most Jetpack packages) — controllers live under
projects/packages/<name>/src/ with no conventional filename. Grep is again mandatory.
- Custom plugin layouts — anything with
src/Rest/,lib/rest/,
api/v1/, etc. Grep catches them all.
Inherited routes
A controller can extend a base class in a different repo — typically the parent plugin (for extensions built on top of another plugin) or WordPress core itself (for plugins extending WP_REST_Posts_Controller or other core REST bases). The parent::register_routes() dispatch appears in the extending plugin's source, but the literal register_rest_route( call lives in the parent. WooCommerce extensions extending WC_REST_Orders_Controller, plugins built on Jetpack package REST classes, and CPT plugins inheriting from WP_REST_Posts_Controller all hit this pattern.
Handling:
- Record the route on the child class (that's where the plugin's REST surface
actually exposes it).
- Set
backing.route_registration_line: nulland
backing.callback_line: null in the audit schema.
- Add
backing.inherited_from: "<parent FQCN>"so downstream skills can tell
the inheritance case from a plain missing line number.
- Consider running grep against the parent repo too when you need to confirm
the callback's request handling — inherited callbacks behave as whatever the parent defines, not what the plugin repo documents.
See audit-schema.md for the exact field shapes.
Exhaustiveness is the goal
The "Controller Inventory" table in the audit doc must list every controller the enumeration found — not just ones backing proposed abilities. A reviewer asking "why isn't controller X in the MVP?" should be able to point at the inventory and see the explicit answer (usually: "excluded from MVP because…" or "surfaced as a gap because…").
If your inventory has 3 entries and the plugin clearly exposes more, either the enumeration is incomplete (re-run grep with broader patterns) or you're filtering the inventory instead of the proposal list. Fix the inventory first; filter after.
Escalation
If neither glob nor grep produces a complete inventory — for example a plugin that registers routes dynamically from config or via a factory that does not contain a literal register_rest_route( string — document the enumeration gap in "Notes and Surprises", and extend this reference with the new pattern once understood.
Related skills
How it compares
Pick this over generic API audit skills when the target is WordPress Abilities API registration planning from an existing plugin REST surface.
FAQ
What WordPress version does wp-abilities-audit target?
wp-abilities-audit targets WordPress 6.9 or newer with PHP 7.2.24 or higher. The skill audits plugin REST surfaces from a filesystem checkout using bash and Node, with optional WP-CLI assistance.
What does wp-abilities-audit produce?
wp-abilities-audit produces a standardized markdown audit document with a YAML schema and prose sections. The document proposes Abilities API registrations grouped by semantic intent for humans and agents planning rollout.
Does wp-abilities-audit require WP-CLI?
wp-abilities-audit does not require WP-CLI. The skill works as a filesystem-based agent with bash and Node against a plugin checkout, though some workflows benefit from WP-CLI when available.