,

Self-Declared Safety Isn’t Safety

Every AI agent that touches your WordPress site now reads a label before it acts.

The label says whether an action is safe.
The label is written by the plugin author.
Nobody checks if it’s true.

This isn’t a flaw in WordPress.
It’s a pattern we have seen before, wearing a new name.

I should admit something before I go further.

For years I wrote permission_callback => '__return_true' on public facing endpoints without thinking hard about it.
Sometimes I skipped the argument entirely.

Not because I didn’t care about security.
The endpoint felt public, and __return_true felt like the honest way to say so.

That habit was normal at the time.
WordPress 5.5 changed it, by throwing a _doing_it_wrong() notice when the argument was missing.
Plugin Check flags it now too, so a plugin heading for the directory gets caught before a reviewer ever reads the code.

Those guardrails are good.

They also taught me something that took embarrassingly long to land.
A permission check I wrote without thinking is still a permission check the whole system trusts completely.

That’s the same shape of mistake the Abilities API makes newly available, one layer up.

Two Layers, One Illusion of Safety

WordPress 6.9 shipped the Abilities API in December 2025.

It gives plugins, themes, and core a standard way to register what they can do.
Each ability has a name, a schema, a permission check, and a set of annotations describing its behaviour.

Here’s the part that trips people up.

The annotations and the permission check are two completely separate systems.
One describes intent.
The other enforces it.

They live next to each other in the same array.
That proximity makes them feel like one thing.
They are not.

What Annotations Actually Are, and Aren’t

An ability can carry three behavioural annotations: readonly, destructive, and idempotent.
The full spec sits in the official Abilities API PHP docs.

Before an ability can register at all, its category has to exist.

add_action( 'wp_abilities_api_categories_init', function() {
    wp_register_ability_category( 'comment-moderation', array(
        'label'       => __( 'Comment Moderation', 'my-plugin' ),
        'description' => __( 'Abilities for managing comments.', 'my-plugin' ),
    ) );
} );

Only then does the registration itself go through.
label, description, category, execute_callback, and output_schema are all required.
Leave one out and wp_register_ability() returns null, so the ability simply doesn’t exist.

add_action( 'wp_abilities_api_init', function() {
    wp_register_ability( 'my-plugin/delete-comment', array(
        'label'               => __( 'Delete Comment', 'my-plugin' ),
        'description'         => __( 'Permanently deletes a comment by ID.', 'my-plugin' ),
        'category'            => 'comment-moderation',
        'output_schema'       => array(
            'type'       => 'object',
            'properties' => array( 'deleted' => array( 'type' => 'boolean' ) ),
        ),
        'permission_callback' => function() {
            return current_user_can( 'moderate_comments' );
        },
        'execute_callback'    => 'my_plugin_delete_comment',
        'meta'                => array(
            'annotations' => array(
                'readonly'    => false,
                'destructive' => true,
                'idempotent'  => true,
            ),
        ),
    ) );
} );

That annotations block is metadata.
It’s a description the plugin author writes about their own code.

Nothing in WordPress core verifies that destructive: true matches what the callback does.
You could set destructive: false on a function that truncates a database table, and the registry would accept it without complaint, as long as every required field is present.

Annotations are documentation with a schema.
They are not a contract WordPress enforces.

Where the Real Boundary Lives

The actual security boundary was never the annotations.

It’s the same place it has always been in WordPress.
The permission check that runs before execution.

'permission_callback' => function() {
    return current_user_can( 'delete_posts' );
},

If this check is missing, wrong, or too permissive, the annotation next to it doesn’t matter at all.
A perfectly honest destructive: true label on a function with a broken permission check still lets an unauthorised request through.

The label describes danger.
Only the callback prevents it.

Why the Defaults Are Deliberately Paranoid

One detail in the spec is worth appreciating.
It’s the kind of quiet, correct decision that’s easy to miss.

If you don’t set the annotations, WordPress doesn’t assume the safest case.
It assumes the worst one.

The documented defaults are readonly: false, destructive: true, idempotent: false.
An ability with no annotations at all is treated as though it modifies data, may delete something, and isn’t safe to retry.

That’s the right instinct.
Assume danger until proven otherwise, never the reverse.

It also means a careful plugin author gets real value from annotating honestly.
Agents move faster and skip unnecessary confirmation prompts on genuinely safe operations, once those operations are labelled as such.

The Gap Between the Label and the Lock

Here’s where the two layers can quietly separate from each other, even with good intentions.

The MCP Adapter exposes Abilities to AI clients.
It maps these annotations directly onto MCP’s own hints, so readonly becomes readOnlyHint, destructive becomes destructiveHint, and idempotent becomes idempotentHint.
That mapping is documented in the Abilities API REST docs.

Say a plugin ships this registration early on, when the ability really was a simple lookup.

wp_register_ability( 'my-plugin/reset-usage-counter', array(
    'label'               => __( 'Reset Usage Counter', 'my-plugin' ),
    'description'         => __( 'Resets the stored usage counter to zero.', 'my-plugin' ),
    'category'            => 'comment-moderation',
    'output_schema'       => array(
        'type'       => 'object',
        'properties' => array( 'reset' => array( 'type' => 'boolean' ) ),
    ),
    'permission_callback' => function() {
        return current_user_can( 'manage_options' );
    },
    'execute_callback'    => 'my_plugin_reset_usage_counter',
    'meta' => array(
        'show_in_rest' => true,
        'annotations'  => array(
            'readonly'    => true,
            'destructive' => false,
            'idempotent'  => true,
        ),
    ),
) );

Six months later, my_plugin_reset_usage_counter() gets rewritten to also wipe related log entries.
That made sense at the time to whoever shipped it.
The annotation block above never gets touched.

Here’s a trimmed version of what an agent sees when it discovers that ability at /wp-abilities/v1/abilities.

{
  "name": "my-plugin/reset-usage-counter",
  "label": "Reset Usage Counter",
  "meta": {
    "annotations": {
      "readonly": true,
      "destructive": false,
      "idempotent": true
    }
  }
}

Nothing about that JSON is false in a way WordPress can detect.
It’s syntactically perfect.
It’s also lying about what the function underneath it now does.

An MCP client reading destructiveHint: false has no reason to pause or confirm before calling it.
The permission_callback is the only thing standing between that stale label and an unwanted action, and here it happens to hold, because manage_options was set correctly from day one.

Change one thing in that example.
Drop the permission check to something looser, and the label stops being the only thing that’s wrong.

This is the mechanism worth internalising.
Annotations shape agent behaviour.
Permission callbacks shape what’s actually possible.

We Have Been Here Before

None of this is a new category of problem for WordPress.
It’s the same one, one layer up.

The REST API shipped its own permission callback pattern back in WordPress 4.7.
The CVE history since then reads like a slow motion lesson in the same mistake, repeated.

CVE-2017-5487 let unauthenticated visitors pull a list of usernames off any WordPress 4.7 site through the /wp-json/wp/v2/users endpoint.
The controller didn’t properly restrict which post authors it listed.
Medium severity, fixed in 4.7.1, and still one of the most scanned for WordPress issues years later.

Four years after that, security researchers were still documenting plugins that implemented the permission callback incorrectly, or skipped it entirely.

In July 2026 the same class of issue showed up again at a much larger scale.
A route and validation confusion in the REST API batch endpoint, chained with SQL injection, let unauthenticated attackers reach privilege escalation and remote code execution.
WordPress shipped 6.9.5 and 7.0.2 to fix it and force updated affected installs.

In the same window, CVE-2026-15015 hit an MCP connector plugin for WordPress directly.
An unauthenticated authorisation bypass, scored 9.8, close to the maximum possible.

Different endpoint, different decade, same root cause.
A capability existed, and the check standing in front of it wasn’t doing its job.

The Abilities API didn’t invent this risk.
It inherited it, and gave it a cleaner interface than ever before.
That’s exactly why the old lesson matters more now, not less.

Writing Abilities That Don’t Lie

None of this is a reason to be nervous about the Abilities API.
If anything, it’s a reason to like it.

For the first time WordPress has a standard place to declare both the danger level of an action and the check that guards it, in the same registration call.
No more scattered endpoints with inconsistent conventions.

That’s progress.
It just comes with one job that’s entirely ours.

Treat annotations as a promise, not a suggestion

If an ability can delete something, destructive says true.
Always.
Even when it feels like overkill.

Never let the permission check default to convenience

__return_true and is_user_logged_in() are not authorisation when a specific capability exists.
Being logged in and being allowed are different questions.

This is the exact mistake I made for years without a tool to catch me.

Plugin Check will flag a missing permission callback.
Nothing flags a lazy one, and nothing at all checks whether your annotations tell the truth.

The guardrails catch absence.
They don’t catch carelessness.

Update the label in the same commit as the code

An ability that started as read only and later grew a write path needs its annotation changed right then.
Not sometime after.
Sometime after is how every stale label in history got made.

Know which doors you opened

Through WordPress 7.0, show_in_rest and mcp.public were fully separate settings.
An ability could reach REST without reaching MCP, or the reverse, with no shared default at all.

WordPress 7.1 changed that, adding a single public flag that every channel falls back to when it hasn’t been given an explicit value of its own.

That’s a real fix.
It exists precisely because the old fragmentation was a genuine footgun.

It also means precedence now matters.
An explicit show_in_rest or mcp.public still wins if you set one.
Otherwise the ability inherits whatever public says.

Set public => true on an ability meant for a REST only admin dashboard, without thinking about MCP at all, and it inherits that exposure to every connected agent too.

The fragmentation problem got smaller in 7.1.
The habit of reading what you actually exposed, and to whom, still has to be yours.

The Line Worth Remembering

The annotation tells the agent what to expect.
The permission callback decides what actually happens.

Everything worth worrying about lives in the gap between those two sentences.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *