,

With Abilities, the last filter decides who gets in

Abilities don’t give a plugin one new attack surface. They give it several, and some belong to other plugins. Which doors are left open depends on whichever filter runs last.

Three paths reach a permission check that denies the request. Two later filters pass the denial along, and the last one turns it into an allow that reaches the ability.

When I published “In WordPress 7.1, a ‘public’ ability is not a permitted one,” the discussion that followed on LinkedIn pushed it somewhere I hadn’t considered: e-commerce. Stores, customers, and plugins that don’t know about each other may be especially challenging to keep secure once the Abilities API opens them to genAI agents.

A former colleague at Multidots, Nimesh Patel, has been thinking about this. Nimesh wrote that every WooCommerce plugin exposing abilities to agents “now owns a new security surface,” and asked which channel worried me most: REST, WP-CLI or the MCP Adapter.1 That made me start counting the surfaces and thinking through the answer.

Andrey Ovsiannikov of SEO Expert made a related point, which is pretty much where I ended up:2

  • Remember that showing an ability to an outside client and letting it run are two different things.
  • Watch the new permission-result filter closely, because another extension can change the outcome of an ability’s own permission check.
  • Test the same ability “across different execution channels and permission contexts.”

Not one new surface but several

The Abilities API is new, but the logic behind any given ability usually isn’t. Whether it’s Woo, another e-commerce plugin, or something else entirely, a “get order” type of ability will typically wrap code the plugin already had. What’s new is that the code becomes discoverable, described by a schema, and callable through more than one channel.

So a plugin that registers abilities takes on several surfaces at once:

  • Three channels with three exposure rules. Core REST uses show_in_rest, falling back to public. MCP Adapter 0.7.0 uses meta.mcp.public, falling back to public, and ignores show_in_rest. WP-CLI’s wp ability command has no exposure check at all.3 Greater exposure lowers the cost of finding and probing plugins for vulnerabilities.
  • A filter chain the plugin doesn’t control. Any active plugin or theme can hook wp_ability_permission_result and widen access, or narrow it.
  • Discovery. The REST list endpoint requires a logged-in user with read, but it isn’t filtered by permission. Any such user sees every REST-exposed ability, including the ones they can’t run.
  • Audit gaps. Denied REST and MCP requests are rejected before WP_Ability::execute() is called, so they never reach wp_ability_invoked.4 A log built on that hook misses the remote attempts you’d most want to see.
  • The agent itself. This is the truly new surface. An agent working as a store manager reads text that customers wrote: order notes, reviews, contact form messages. Any of that can carry instructions meant to steer the agent into calling abilities on the manager’s behalf. Site owners should treat an agent’s account like any staff account: give it only the access it needs, and assume it can be talked into using all of it.

If I had to rank the channels by risk, I’d worry about REST most for how exposed it is. It’s the only one where my tests got an anonymous request to run an admin-only ability, once a permission-result filter returned true. MCP comes next, for misconfiguration and agent abuse. WP-CLI comes last. Anyone who can run it already has shell access.

“Logged in” isn’t much of a lock on a store

The REST list endpoint returns 401 to anonymous visitors, which sounds reassuring. On a site where anyone can register, though, “logged in with read” covers nearly everyone. WooCommerce stores, membership sites, and course platforms all let the public create accounts.

The same goes for permission callbacks that only check is_user_logged_in(), and for core’s own core/get-user-info, which any logged-in user can call for their own account. On a brochure site, those checks keep strangers out. On a store, the strangers have accounts.

How far can I get with a customer account?

I tested that question on a disposable local store running WordPress 7.1.3, WooCommerce 11.2.1, and MCP Adapter 0.7.0. I enabled registration on My Account, signed up through the actual form, and used the new customer’s session to create an Application Password through WordPress’s REST API. WooCommerce didn’t prevent it. With that password and no login cookie, the customer could connect to the default MCP server, list its tools, and discover abilities, including WooCommerce’s order abilities and an admin-only test ability they couldn’t run.

The permission checks still mattered. The customer could run core/get-user-info and a test ability requiring read. A test ability requiring manage_options was denied, as was WooCommerce’s orders-query ability.5 Anonymous access got a 401. The denied ability calls returned HTTP 200 with an MCP error, so checking the HTTP status alone would have missed the denial.

That answers the question for this configuration: a customer account can be enough to get into MCP. It doesn’t give that customer store-manager powers, though. This was a local test with no security plugin restricting Application Passwords, and it used the REST endpoint rather than a customer-facing password-management screen. I didn’t test other customers’ orders or how third-party add-ons handle ownership.

What does “permitted” mean?

The dev note warns against treating public as a security boundary, and that warning is clear.6 Similar pitfalls get no warning. An ability can be public and hidden from REST but still exposed over MCP. Nothing flags that combination.

The deeper problem is that “permitted” is never defined, and its meaning shifts with context. A permission_callback can check ownership, and an object-specific capability check can account for it too. But a callback that only checks a broad capability may never ask whether this user may access this particular object.

That assumption was never sound, but it gets costly once customers or members create accounts and own records. Picture a get-order ability whose permission callback checks read and whose input takes any order_id. Every customer can now read every order. That’s an insecure direct object reference, a textbook case of broken access control, the category OWASP ranks first among web application security risks.7 WooCommerce core checks who owns an order. An add-on that wraps an order lookup in an ability has to enforce object-level access for every ID it accepts, either in the permission callback or through the shared service it calls. That includes legitimate access by shop managers and by the customer who owns the order.

The last filter to run decides

Those are single-developer, single-plugin mistakes, and code review can catch them. The harder problem is several plugins hooking the same abilities with their own filters.

Each plugin’s code can be secure on its own. Then another plugin hooks wp_ability_permission_result and overrides a denial you were counting on. The final returned value is what counts. A later filter can preserve an earlier decision or override it; your original permission callback cannot guarantee the result. This was Andrey’s warning, and I agree it’s the one to take most seriously.

This isn’t new. user_has_cap and map_meta_cap have worked this way for years, and plugin conflicts over capabilities are a familiar headache. What’s changed is who acts on the result. Once agents can change settings, edit orders, and manage users, it matters much more to have a global view of the access controls actually in effect. That means the result after every filter has run, not the intent written into any one plugin.

I’ve run into this before. Keel, my plugin for safer WordPress defaults, sets most of its policies through filters that return a single value. That’s how a site can have two plugins set to disable comments and still have comments enabled. So Keel reports collisions in two ways. It names any other plugin registered on a hook Keel uses, which shows an overlap but not a disagreement. It also checks what the filter chain actually returned, and flags a setting that isn’t taking effect.

Some callbacks can’t be traced to any plugin. If a plugin hooks a filter with one of core’s helper functions, the callback belongs to WordPress, and nothing is pointing back to whoever added it. add_filter( 'wp_ability_permission_result', '__return_true' ) is that case exactly: one line that returns true for every ability permission check it reaches. A later filter can change that result, and transport authentication and other execution checks still apply. A runtime report can’t say which plugin registered the helper.

Ability filters are also harder to check than Keel’s settings. A permission result depends on the ability, the user, the input, and the channel, so there’s no single value to compare. Tooling can list who has hooked these filters, but it can’t prove what they’ll allow.

I’ve added a Site Health check to Keel, which will ship in the next release. It inventories callbacks on wp_ability_permission_result and wp_pre_execute_ability, names their source where it can, and describes what known core helpers do without running them. It reports registrations, not a verdict on what any particular customer can do. (The pull request has the details.)

So whatever the channel, I think the root risk is a footgun. People install several plugins, each one reasonable on its own, then turn agents loose with no idea which doors are open at any given time. Eventually that will be exploited, or it will let users and agents do damage nobody intended, from leaking private data to worse.

Safety engineering recommendations

For plugin developers

  • Enforce object-level access for every ID the ability accepts, using an appropriate permission callback or shared service. A broad capability check alone isn’t enough.
  • Set exposure for each channel on purpose. If an ability shouldn’t reach MCP clients, setting show_in_rest => false won’t hide it. Leave public unset, or set meta.mcp.public to false.
  • Test each ability the way Andrey suggests: through REST, MCP, and WP-CLI, as an anonymous visitor, as a customer, as a shop manager, and as an administrator.
  • If you hook wp_ability_permission_result, scope it to your own abilities and document why.
  • Check MCP results for isError, not the HTTP status. Denied calls came back as HTTP 200.

For site owners

  • Run the checks in control 11.4 of the WordPress Security Benchmark. Search wp-content for wp_ability_permission_result and wp_pre_execute_ability, and list every ability with its exposure flags.
  • Re-run those checks whenever you add or update a plugin. The filter chain changes with the plugins installed, not with your own code.
  • Give agents their own least-privilege account (control 11.3), never an administrator’s Application Password.
  • Decide whether customers need Application Passwords at all. Core’s wp_is_application_passwords_available_for_user filter can limit them to staff accounts.
  • Assume anything a customer can write will eventually be read by an agent.

Notes

  1. Nimesh Patel, comment on Dan Knauss, “WordPress 7.1 adds a public flag to the Abilities API,” LinkedIn, October 2026, https://www.linkedin.com/feed/update/urn:li:activity:7519050812543795202/. ↩︎
  2. Andrey Ovsiannikov, comment on Dan Knauss, “WordPress 7.1 adds a public flag to the Abilities API,” LinkedIn, October 2026, https://www.linkedin.com/feed/update/urn:li:activity:7519050812543795202/. He wrote that “testing the same ability across different execution channels and permission contexts seems essential.” ↩︎
  3. The three rules are in WordPress 7.1.3’s WP_Ability constructor, MCP Adapter 0.7.0’s McpAbilityExposure::is_public(), and the wp ability run subcommand at commit c6112cc, which calls execute() directly. ↩︎
  4. Core’s REST run route checks permissions in its permission_callback, before execute(), where wp_ability_invoked fires. MCP Adapter 0.7.0’s tools handler returns an error result when the permission check fails, also before calling the ability. ↩︎
  5. Darren Ethier, “Introducing Canonical WooCommerce Abilities for Products and Orders,” WooCommerce Developer Blog, May 12, 2026, https://developer.woocommerce.com/2026/05/12/mcp-abilities-api-10-9/. ↩︎
  6. Milana Cap, “A Unified Public Exposure Flag for Abilities in WordPress 7.1,” Make WordPress Core, August 4, 2026, https://make.wordpress.org/core/2026/08/04/a-unified-public-exposure-flag-for-abilities-in-wordpress-7-1/: “Developers should not treat public, show_in_rest, or any other exposure flag as a security boundary.” ↩︎
  7. OWASP Foundation, “A01:2025 – Broken Access Control,” OWASP Top 10:2025, https://top10.owasp.org/2025/A01_2025-Broken_Access_Control/, accessed October 25, 2026. Its examples include “permitting viewing or editing someone else’s account by providing its unique identifier (insecure direct object references),” mapped to CWE-639, and it reports that “100% of the applications tested were found to have some form of broken access control.” ↩︎

Sources


How this article was researched & written

This follows up on testing done October 7, 2026, against WordPress 7.1.3, the wp-cli/ability-command package at commit c6112cc, and MCP Adapter 0.7.0. The customer Application Password and MCP findings come from an additional disposable WooCommerce lab on October 22, 2026, using WooCommerce 11.2.1 and the same WordPress and adapter versions. MCP Adapter 0.7.1, released October 16, doesn’t change the exposure or permission code these findings rely on. The other WooCommerce attack scenarios remain reasoning, not demonstrated exploits. I worked through the discussion in dialogue with Claude, which also helped with drafting, copy editing and checking the claims against the WordPress, MCP Adapter and WP-CLI source. Codex ran the follow-up customer lab and updated that section. The judgement calls, the framing, and the errors that remain are mine.

Leave a Reply

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