Conversation
✅ WordPress Plugin Check Report
📊 ReportAll checks passed! No errors or warnings found. 🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## feat/ai-workspace #1014 +/- ##
=======================================================
+ Coverage 81.94% 82.33% +0.39%
- Complexity 3670 3799 +129
=======================================================
Files 147 148 +1
Lines 14043 14399 +356
=======================================================
+ Hits 11508 11856 +348
- Misses 2535 2543 +8
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…ssor WordPress 7.1 lets wp_get_abilities() filter the registry. WordPress 7.0's takes no parameters, and PHP discards extra arguments to userland functions without a word, so asking 7.0 to filter returns everything. A tool policy that trusted the call would be default-allow on the plugin's own minimum. Tool_Policy answers two questions and nothing else: whether filtered discovery is available here, and whether an ability has declared itself fit for a conversational surface. Both fail closed. The declaration key stays private and provisional while issue #354 settles its public shape. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…loor The workspace chose its tools from a hand-maintained list of three. An ability can now earn a place by declaring itself fit for a conversational surface, and the list stops being the only way in. Default-deny alone would have emptied the surface: nothing declares yet, and this does not retrofit the abilities that ship here. So the curated three are a floor the policy adds to, never a fallback it replaces. A test pins that by name, because it is the property a change to merge order breaks silently. Two ways in are refused. Core's ability filters are site-wide and fire on every call including ours, so a plugin hooking one could hand the model an ability nobody declared; the query is a candidate set and every row is re-checked here. And the candidates filter, which may skip the declaration, may not skip the effect class -- otherwise a tool that writes reaches a model that has been told it cannot write. Admission requires readonly, not destructive, and not open-world, each asserted explicitly. Core defaults all three to null, and an absent open-world hint means the ability may reach outside the site, so silence is refused rather than assumed. The curated floor is exempt: ai/propose-drafts writes through the confirm gate and would otherwise strip itself out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The system instruction promised two things it had no way to know: that the model cannot write to the site, and that a proposal tool is there to call. Both were literals. Once the surface can grow or be narrowed, a literal is a claim the code cannot keep. Each sentence is now earned. The proposal paragraph appears only when the proposal tool was declared, so the model is never pointed at a tool it does not have. The write denial appears only while nothing declared can change the site on its own -- ai/propose-drafts does not count against it, because it stores values a person has to approve rather than writing them. The warning that tool results are untrusted stays unconditional. It does not describe the surface, it describes what to do with anything that comes back, and that is true whatever is admitted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The policy decided the surface and nobody could see it. The Abilities Explorer now says, per ability, whether the assistant holds it and why not when it does not -- told apart properly, so an author who declared correctly but writes is told that, and is not sent back to re-check a declaration that was already right. Six reasons, and the report enumerates the whole registry to produce them. The admission query cannot: it only returns what already matched, so it can never explain a non-match. The owner can take any ability off the surface, including a curated one, and switch the policy off entirely. Both persist as options that uninstall already cleans by prefix. The mutation checks a nonce and manage_options, because a control that reshapes what an assistant may call is worth a CSRF. The removal filter is registered from the experiment bootstrap, not from a constructor. Hooking on construction would have made the owner's removal depend on something happening to build a policy first, so a candidate read that did not would quietly serve a tool the owner took away. Two tests proving removal works were relying on exactly that. Descriptions are shown escaped. They are third-party text rendered into wp-admin, and the point is that the owner reads the same words the model reads -- not that wp-admin renders someone else's markup. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The experiment doc still said three abilities ship in an allowlist and left it there. Three still ship, but they are a floor now, and the interesting half is what an ability has to do to stand beside them: declare itself, and say plainly that it only reads, adds nothing destructive, and stays inside the site. Two opt-ins, which is worth stating, because an author who adds the declaration and stops is not admitted. Also records what a reader would otherwise have to find out by trying: the key is private until #354 settles it, so nothing changes yet; WordPress 7.0 has no filtering to do this with and gets the floor; and the Explorer is where an owner sees the surface and takes something off it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review found the safety story was not true. The declaration key is a private constant, and the docs said that meant no third-party ability could opt in yet -- but private hides a name from callers, not from an author who reads the source of an open-source plugin. Admission was live on merge. So admission is now off by default, behind a temporary switch that goes away when #354 settles the key's public shape. A declared ability shows in the Explorer as eligible and waits there. The docs say this plainly instead of claiming a barrier that was never one. The owner's removals no longer ride on a filter the workspace bootstrap installs. The Abilities Explorer is a separate experiment that can run while the workspace does not, and in that configuration nothing registered the filter -- so a removed ability was shown as held, beside a button offering to return it. Exclusions apply inside the candidate build now, and a test pins that without any bootstrap. Reasons stop guessing. An ability removed by site code, dropped for its effect class, or waiting on the gate each says so, instead of all three arriving as "withheld by your capabilities" and sending the owner to look at roles. A surface the owner emptied no longer reports that nothing is registered. The docblock claimed two independent opt-ins. They are two keys in one meta array written by one hand. Both are self-attestation; the owner's controls and the permission callback are the boundaries that hold. One correction to what the last commit said: core discards a meta-mismatched ability before wp_get_abilities_item_include fires, so that filter cannot re-widen the query. wp_get_abilities_result can, and the re-verification loop is what stops it. The test now proves the smuggle before proving the defense. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tool_Selector said "admission decides what the model is told exists" twice, in two halves of the same docblock, and named wp_get_abilities_item_include as the filter a third party could re-widen the query with. Core discards a meta-mismatched ability before that filter fires; wp_get_abilities_result is the one that can inject, and the one the re-verification loop is for. The rest is wording: inverted openers straightened out, a binary contrast stated directly, and the em dashes thinned. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A full tool description in every row made two rows fill the screen, so the column meant to show what the assistant can reach was the reason the table could not be read. The state is a badge now, an icon beside a short word. The icon is decorative and marked so; the word carries the meaning, because a column whose whole purpose is being read should not depend on being seen. The description moved into a details element, closed by default. It stays there rather than moving to a tooltip or a title attribute: it is the exact string handed to the model, the owner is the only person who can judge whether it is honest, and judging it means reading and selecting long-form prose. Two tests asserted on the old wording. They assert on the state class now, so copy can change without a test pretending something broke, and a new one holds the description in the cell -- collapsing it is the point, losing it is not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The column asked "is this on the assistant", which made every other row a negative and told the owner nothing. An ability can be exposed over REST, over MCP, to the assistant, or any combination, and until now there was nowhere to see those three answers together. So the column is "Exposed in" and carries a badge per surface, read from the same meta each consumer reads for itself. core/get-site-info turns out to be REST only, ai/get-post-terms MCP only, and an ability can be eligible for the assistant while exposed nowhere else. That spread is the argument in issue #354. Three consumers invented three flags, and this column is the first place the cost of that shows up as something an owner looks at rather than something a developer reads about. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… flag WordPress 7.1 added meta.public as the general exposure flag, and a channel resolves as meta[channel] ?? meta.public ?? the channel's own default. Core applies that to show_in_rest at registration and writes the answer back, so reading that key gives the resolved value. Nothing applies it to mcp.public. Reading that key alone reported an ability as absent from MCP when the general flag had put it there, so the column was telling the owner an ability was less exposed than it is -- the wrong direction for a screen whose job is showing reach. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WordPress 7.1 added meta.public as the general exposure flag, and the precedence every channel resolves by: its own key, then that flag, then the channel's default. The workspace was reading a bespoke key of its own, which is the fragmentation issue #354 is about. It reads ai-workspace.public ?? public ?? false now. An ability marked public is eligible here without naming this surface, and naming it overrides in either direction. Two things follow, neither of them cosmetic. The discovery query lost its meta condition. Core matches meta exactly and cannot express the fallback, so querying the channel key would have silently skipped every ability eligible only through the general flag. Resolution moved per item, and a test holds the query empty so nobody optimises it back. Two exclusion reasons became one. Core writes meta.public onto every ability at registration and validates it as a boolean, so "the author declared nothing" is not a state that exists any more -- an ability with no opinion is eligible-false by core's default. Reporting it as undeclared would send an author to add a key already there with the value they meant. Inheriting widens who is eligible, so the effect class is what keeps that from meaning every public ability can be called by a model that has been talked into it. Five cases cover that: not readonly, destructive, open-world, the hint absent, and no annotations at all. has_declaration() is gone. Since core seeds the key, its fallback branch could never return false, and nothing outside tests called it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Inheriting meta.public cost a lock nobody was counting. core/get-user-info ships public, read-only and not destructive, so the only thing keeping a reader of people's personal data off the assistant was an absent open_world hint -- and open_world => false would be a correct, tidy thing for core to add. One good-faith cleanup upstream and a PII tool lands on a surface reachable by instructions embedded in content someone else wrote. The effect class cannot catch that. It asks whether an ability writes or reaches outside the site, not whether handing it to a model is a bad idea. So a short list answers the second question for the four core abilities where it is plainly yes: user info, users, settings, environment. WordPress registers those, not this plugin, so a list here is the only place the decision can live. It is filterable, because a site with code access deciding otherwise is different from a default deciding for them, and it is checked before the curated floor so it holds on every route in. A test registers a fixture that is declared, annotated impeccably, and on the list, then asserts it never reaches the model -- so the refusal is provably the list rather than something the fixture got wrong. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fifteen rows said "Not public, and has not opted in to the assistant" under a badge already reading "not the assistant", which buried the three rows where the reason is the whole point: held back for reading personal data, refused on effect class, or waiting on the admission gate. The commonest reason is left unsaid now. The rest still print, because those are the ones an owner or an author can act on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#1002 renamed core/read-users to core/users-query and kept the old name as a deprecated alias. Both are registered, and the withheld list named only the old one -- so the ability it exists to hold back was reachable under the name core now prefers. Not reachable in practice yet: core/users-query is not public and does not assert an admissible effect class. But the point of the list is to hold when those change, and it was not holding. Both names are listed, because the alias is a real registration rather than a redirect. A rename upstream is a hole here until someone adds the new name, which the docblock now says. PHPStan goes with it. The composer script pinned 1G, and this branch pushed analysis past it once develop merged in, so the documented command crashed. 2G clears it. CI never used that script -- it runs phpstan with no limit at all -- so this is the local command catching up to what CI already did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
f2ac240 to
3467f15
Compare
#1002 renamed core/read-content to core/content-query. Nine docblock and doc references here still pointed at the old name, which now resolves to a deprecated alias -- accurate enough to pass review and wrong enough to send the next reader somewhere that will be removed. The withheld list keeps naming both sides of the users rename, and the docs now say why: the alias is a real registration that copies the replacement's meta, so exposure changes reach both names at once and holding back only one holds back nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message. To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
What?
Closes #1003
The AI Workspace picks its tools from a hand-maintained list of three abilities. This lets an ability earn a place by being exposed to the surface, with the three that ship becoming a floor rather than the whole story.
Stacked on #1004 — review that one first.
Why?
The plugin's premise is that the Abilities API is the extension point and the workspace is its first conversational consumer. Today a site that registers a WooCommerce ability, a custom CPT ability, or an internal tool gets no benefit from it. #1003 has the longer argument.
How?
WordPress 7.1 added filtering to
wp_get_abilities()(Trac #64990) and a unified exposure flag, so there was no primitive to build and no vocabulary to invent — only a policy to write on top of both.Exposure follows core's precedence for every channel:
meta['ai-workspace']['public'] ?? meta['public'] ?? false. An ability marked public is eligible without naming this surface, and naming it overrides either way. On top of that it must annotatereadonly: true,destructive: false,open_world: false, all three present and explicit — core defaults them tonull, and an absentopen_worldmeans the ability may reach outside the site, so silence is refused.Five things worth a reviewer's attention:
The three curated abilities are a floor, not a fallback. Default-deny on its own would have left the workspace with no tools at all on 7.1. A test asserts the surface by name, because that is what a change to merge order breaks silently.
Admission is off by default.
private consthides a name from callers, not from an author reading an open-source repo, and inheriting frommeta.publicmeans an ability can be eligible without naming the channel at all.wpai_workspace_tool_admission_enabledturns it on; it goes away when #354 settles the channel name.Four core abilities are never admitted.
core/get-user-infoships public, read-only and not destructive, so the only thing keeping a reader of people's personal data off the surface was an absentopen_worldhint — which it would be correct for core to add. The effect class asks whether an ability writes or reaches outside, not whether handing it to a model is a bad idea.wpai_workspace_withheld_abilitiesanswers the second question for user info, users, settings and environment, and is filterable.On WordPress 7.0 the policy does not run.
wp_get_abilities()there takes no parameters and PHP discards extra arguments without a word, so asking it to filter returns everything. The probe reads the signature instead of trusting a call. The owner's off switch resolves to the same branch, so the fallback is exercised on every version rather than only the oldest.The candidates filter may skip exposure, never the effect class. Otherwise a tool that writes reaches a model that has been told it cannot write.
Exposure and the annotations are both the author's self-attestation, written in one
metaarray. The effect class is declared intent, not something this code enforces. The boundaries that hold are the withheld list, the owner's controls, and the execute-timepermission_callback.Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5
Used for: Implementation, tests, and documentation, from a plan I reviewed and scoped. A six-reviewer automated code review ran against the result and its findings are applied in 9d6d9e7b, including the default-off gate and a defect where owner removals were not enforced if the Abilities Explorer ran without the AI Workspace. gziolo's note on #354 that core already ships a unified exposure flag prompted e7ffb84 and b891df7.
Testing Instructions
npm run test:php— 1844 tests.npm run test:php:multisite.add_filter( 'wpai_workspace_tool_admission_enabled', '__return_true' );, then register an ability with'meta' => array( 'public' => true, 'annotations' => array( 'readonly' => true, 'destructive' => false, 'open_world' => false ) )and confirm it appears. It never names the channel — that is the inheritance working.core/get-user-infostays off the surface with admission enabled, and that the Abilities Explorer says it is held back rather than blaming its annotations.Screenshots or screencast
The Abilities Explorer gains an Exposed in column showing, per ability, which surfaces hold it — REST, MCP, the assistant — plus the description text the model receives, why it is excluded, per-row remove/restore links, and a policy on/off switch above the table.
Every state in one view.
demo/*are throwaway fixtures registered locally toproduce the eligible and refused cases; the plugin ships none of them.
core/read-settingsandcore/read-usersare held back whatever they declare.demo/read-shipping-zonesis eligible by naming the channel;demo/read-store-hoursby
meta.publicalone, never naming it.ai/search-contentis on the surface withits model-facing description and a remove link.
demo/update-inventoryis public andMCP-exposed, and refused because it writes.
Changelog Entry