All skills
wordpress avatar

/wp-abilities-verify

@20324d2 official
by wordpresswordpress/agent-skills2.2k stars
327

Verify a WordPress plugin's Abilities API registrations: enumerate abilities, check that callback behavior matches each annotation's claim (the adversarial readonly-but-writes detection), validate permissions and schemas, and validate audit documents produced by wp-abilities-audit.

Use this Skill: https://skilld.dev/gh/wordpress/agent-skills/wp-abilities-verify

This session only. Nothing lands on disk.

referencespermission-roundtrip.md

≈1.9k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Permission Roundtrip

Verify that the registered permission_callback on every ability actually gates on a real capability — statically (by source inspection) and, in runtime mode, by exercising the gate against unauthenticated, subscriber, and admin contexts.

Background — what permission_callback actually receives

When an ability is invoked, the registered permission_callback is called through WP_Ability::check_permissions( $input ), which dispatches to WP_Ability::invoke_callback( $callback, $input ) (both defined in class-wp-ability.php in WordPress core).

invoke_callback's contract:

  • If the ability declares a non-empty input_schema, the callback is invoked with one positional argument: the validated $input value (whatever the schema's root type produced — array, string, integer, boolean, etc.).
  • If input_schema is empty or absent, the callback is invoked with no arguments.

In particular, the callback never receives a WP_REST_Request, even when the ability is reached via the REST bridge. The bridge unwraps the request, runs schema validation, and passes the validated value down. Permission callbacks built around WP_REST_Request patterns (e.g. $request->get_method()) cannot work as-is when copied from a REST controller — flag any such usage as a static FAIL.

Static check — classify each callback's shape

Read each ability's permission_callback body and classify:

Shape Body Result Notes
A return current_user_can( 'cap' ); OK Preferred. Record the resolved capability.
B Branches on $input — different cap for different shapes OK with smell Two distinct user actions usually want two abilities, each with its own Shape A. See ../../wp-abilities-api/references/domain-vs-projection.md.
B-bad Branches on $request->get_method() or other WP_REST_Request calls FAIL The argument is the validated input value, not a request object. Almost always a copy from a REST controller without translation.
C '__return_true' WARN Deliberate public ability. Document the reason in code or in the audit doc's risks array.
D Delegates to a helper that resolves to current_user_can(...) OK Trace the helper. If it returns true unconditionally, treat as Shape C.
E return true; (literal) FAIL Functionally Shape C but harder to grep for. Change to '__return_true' or add a real cap check.
F return is_user_logged_in(); WARN Lets any authenticated user — including subscribers — call. Rarely intended. Document or tighten.

Record per ability: (shape, resolved_cap). Any Shape B-bad or Shape E → static FAIL. Shapes C and F → WARN. Shapes A, B, D → OK.

Runtime check

Exercise the gate against three user contexts using WP_Ability::check_permissions( $input ).

check_permissions() accepts an optional input value and passes it through to the registered permission_callback. Shape A callbacks (return current_user_can('cap')) don't read it. Shape B callbacks that branch on $input (the smell the static check flags) need a representative value to exercise the real gate; otherwise they receive null and the roundtrip result is misleading. The snippet below passes array() — the minimal safe input for object-typed schemas. For abilities with a non-object root schema, substitute a representative value of the declared root type.

<env-cli> wp --user=admin eval '
$ability = wp_get_ability( "<plugin>/<ability-name>" );
if ( ! $ability ) {
    echo "ability not registered" . PHP_EOL;
    exit( 1 );
}

$input   = array(); // representative input; substitute for non-object root schemas.
$results = array();

// Unauthenticated.
wp_set_current_user( 0 );
$results["anon"] = $ability->check_permissions( $input );

// Subscriber (create a fresh user).
$sub_login = "verify_sub_" . time();
$sub_id    = wp_create_user( $sub_login, "x", $sub_login . "@example.com" );
if ( ! is_wp_error( $sub_id ) ) {
    $sub_user = get_user_by( "id", $sub_id );
    $sub_user->set_role( "subscriber" );
    wp_set_current_user( $sub_id );
    $results["subscriber"] = $ability->check_permissions( $input );
}

// Admin.
wp_set_current_user( 1 );
$results["admin"] = $ability->check_permissions( $input );

foreach ( $results as $context => $result ) {
    if ( true === $result ) {
        $printable = "true";
    } elseif ( is_wp_error( $result ) ) {
        $printable = "WP_Error(" . $result->get_error_code() . ")";
    } else {
        $printable = var_export( $result, true );
    }
    echo $context . "=" . $printable . PHP_EOL;
}

// Cleanup the test subscriber so repeated harness runs don't accumulate users
// on shared dev environments. Already running as admin (line above), so the
// caller has the delete_users capability. On multisite, wp_delete_user() only
// removes the user from the current site's membership — wpmu_delete_user() in
// wp-admin/includes/ms.php is the network-wide delete.
if ( isset( $sub_id ) && ! is_wp_error( $sub_id ) ) {
    if ( is_multisite() ) {
        require_once ABSPATH . "wp-admin/includes/ms.php";
        wpmu_delete_user( $sub_id );
    } else {
        require_once ABSPATH . "wp-admin/includes/user.php";
        wp_delete_user( $sub_id );
    }
}
'

Notes on interpretation:

  • check_permissions() returns bool|WP_Error. Treat true as allowed; treat false or any WP_Error as denied.
  • A WP_Error with code ability_invalid_permission_callback means the registration didn't supply a valid callable — hard FAIL.
  • A WP_Error with code ability_callback_exception means the callback threw — hard FAIL; capture the underlying message.

Expected for a standard (non-public) ability:

anon=false
subscriber=false
admin=true

Expected for a deliberate public ability (Shape C):

anon=true
subscriber=true
admin=true

Any deviation → FAIL. Common causes: cap reference an admin doesn't hold, callback bug, or permission too permissive (Shape E or F when it should have been Shape A).

Audit cross-check

If an audit doc was provided, the audit's capability_gate (or each ability's permission.resolves_to) declares what the gate should be. Compare:

  • Audit and registration resolve to the same cap → OK.
  • Audit and registration disagree → FAIL. Either the audit is wrong or the registration drifted.
  • Audit declares a compound {read, write} gate, registration uses Shape B with both caps → OK.
  • Audit declares a compound gate, registration uses Shape A (single cap) → FAIL. Write paths would inherit the read gate (or vice versa), under- or over-authorizing.

See ../../wp-abilities-audit/references/capability-gate-tracing.md for the tracing mechanics; this skill re-derives the same trace and diffs.

Output format

## Permission gates

| Ability | Shape | Resolved cap(s) | anon | subscriber | admin | Audit match |
|---|---|---|---|---|---|---|
| <ability> | A | manage_options | false | false | true | OK |
| <ability> | B | edit_posts (read), delete_posts (destructive) | false | false | true | OK |
| <ability> | C | __return_true (public) | true | true | true | WARN |
| <ability> | E | (literal true) | true | true | true | FAIL |

Static-only mode caveats

Without runtime mode, only the source-inspection columns are populated:

Ability Shape Resolved cap(s) Audit match

Roundtrip columns are omitted rather than guessed. Flag in the section header: Permission gates (static inspection only).

Source: SKILL.md on GitHub

1 alert2mo3 checks · Risk HIGH
  • Gen Agent Trust Hub2mo

    This skill facilitates the verification of WordPress plugins but introduces a high-risk security vulnerability by instructing the agent to execute arbitrary shell commands defined within the target plugin's metadata files (AGENTS.md). This behavior allows a malicious repository to achieve remote code execution (RCE) on the agent's host system during the audit process. Additionally, the skill lacks sanitization for data ingested from audit documents used in environment seeding and execution.

  • Socket2mo

    No alerts

  • Snyk2mo

    Risk: LOW · No issues

Signed by skilld at 20324d2. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 days ago.

Activeupdated 3 months ago
Other metadata
compatibility
Targets WordPress 7.0+ plugins (PHP 7.4.0+). Requires a runnable environment (wp-env, docker-based dev stack, or equivalent) for runtime mode; static mode runs entirely from the plugin checkout with no env. Filesystem-based agent with bash + node.
  • Security
  • wordpress
  • php
  • abilities-api
  • verification
  • schema-validation
  • permissions
  • static-analysis

README badge

README badge for wordpress/agent-skills/wp-abilities-verify

Checks WordPress plugin Abilities API registrations for correctness: verifies readonly and destructive claims against callback behavior (catching writes hidden in readonly abilities), validates permission gates and input schemas, and confirms audit documents. Runs in static mode (source inspection only) or runtime mode (live environment execution with permission roundtrip and idempotency checks).

Generated from the current SKILL.md.

Does this skill check if a readonly ability actually writes to the database?
Yes. The adversarial correctness check reads callback bodies and flags readonly abilities that perform writes via $wpdb, update_option, or non-GET delegates—a critical security issue because agents plan actions based on ability annotations.
Can I run this skill without a WordPress environment?
Yes. Static mode runs entirely from the plugin checkout with no environment needed. Runtime mode requires a runnable environment (wp-env, Docker, or equivalent) and catches additional issues like permission roundtrips and idempotency regressions that static mode cannot.
What does this skill require as input?
The plugin checkout path, the mode (static or runtime), and optionally an audit document path and report output path. For runtime mode, you must also provide the env-up command from the plugin's AGENTS.md.
Can this skill validate audit documents?
Yes. If you provide an audit document path, the skill validates it against the canonical schema, checks for missing fields, and cross-references registered abilities against the audit's declared gates.
What output does this skill produce?
A structured markdown report with tables showing each ability's annotation correctness, permission gates, schema lints, and error-code vocabulary, ending with an overall PASS, WARN, or FAIL verdict.

Generated from the current SKILL.md. These answers refresh after source changes.