Conversation
…reen Adds a full-screen, capability-gated admin screen behind its own experiment toggle. The screen renders an app shell only; conversation behaviour, REST routes, and streaming land in later units. The capability check is layered rather than single-point: the menu entry, the asset enqueue, and the render callback each gate on manage_options, so a direct call to the render callback cannot emit the app shell or its localized data to a user without the capability. Advances R1, R2, R3, R4 (U1). Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
Registers ai/search-content, a bounded full-text search over the post types exposed to the Abilities API. core/read-content is exact-match only and is kept byte-similar to core's copy, so this lands as a sibling rather than a change to it. Results are filtered at execute time against the requesting user's read permission, mirroring core/read-content's permission walk including the inherited-parent chain, so every ability consumer inherits the same filtering rather than relying on the query alone. The permission callback can only gate coarsely; the row filter is the authoritative check. Two query behaviours worth noting. per_page is capped at 20 in the schema and clamped again in the callback, so the cap holds on transports that skip schema validation. perm => 'readable' is passed only for a single-post-type query: WP_Query resolves a multi-type query to the placeholder capability read_private_multiple_post_types, which no role holds, silently hiding private posts from users who can read them. Registered from the workspace experiment rather than the Custom Abilities gated collection, so the workspace always has its search tool and every other ability consumer can still reach it. Advances R12, R13 (U2). Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
…overlay Adds an independently-gated `streaming` feature to SDK_Overlay, alongside `embeddings`, so environments whose bundled PHP AI Client predates streaming get the streaming types from a vendored copy. A spike established what the plan had wrong. WordPress core bundles the SDK without its vendor directory, so upstream PR #255's Guzzle-based streaming transport does not apply here; core supplies its own transporter over wp_safe_remote_request. Only the SDK-level types are useful, and a WordPress streaming transporter is required regardless of whether that PR merges. Vendored files are NOT verbatim, unlike the embeddings feature. Core prefixes its PSR and Nyholm dependencies under WordPress\AiClientDependencies, so every such import is rewritten; the unprefixed names do not resolve under a WordPress bootstrap. The README records the rewrite table. PromptBuilder and AiClient are deliberately excluded. PromptBuilder is 45KB of trunk-era code and the largest drift risk against the bundled 0.3.1, and the method it adds only validates, resolves the model, and delegates. Two further candidates were trimmed as unreferenced by the kept set. The sentinel is StreamedGenerativeAiResult rather than the streaming model interface: resolve() probes with class_exists(), which returns false for interfaces, so an interface sentinel would activate the feature even where the environment already ships streaming. A new test enforces that for every feature. Streaming is not yet reachable end to end: the transport and an Anthropic model implementing the streaming interface are still missing (U12). Adds U11 (new; the plan has no unit for this work). Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
Makes streaming reachable in PHP. A Streaming_Http_Transporter decorates the configured transporter: non-streaming calls delegate unchanged, and a streaming request is opened through fopen() with a stream context so the body is pulled lazily and wrapped as a PSR-7 stream the SDK's SSE parser reads unmodified. Because this path bypasses wp_safe_remote_request(), it also bypasses connector approval and request logging, so both are restored explicitly. Approval mirrors Http_Guard step for step -- same Connector_Key_Index lookup, same Caller_Identifier, same Approvals_Store check, same pending-approval record -- and runs before the opener is touched. A test asserts zero calls to the opener, which is the only egress point, so "before egress" is proven rather than implied. Logging wraps the whole body in a finally, so a refused request is logged as an error too. Two honest limits are documented on the class: a streamed entry records time-to-headers, not time-to-last-byte, and carries no token counts, because those arrive inside a body that has not been read yet. The same bypass loses that function's SSRF protection, so wp_http_validate_url() is applied before connecting and CRLF is stripped from header names and values. Anthropic's stream is mapped in its own class. Its SSE uses named events rather than a single delta shape, which is why upstream PR #255 does not cover it, and tool arguments arrive as input_json_delta fragments that must be concatenated across deltas before parsing -- parsing a fragment early yields plausible but wrong arguments. Interleaved fragments from concurrent tool calls are covered. The transporter is deliberately not installed globally and nothing constructs the streaming model in production code; ordering against Logging_Http_Transporter in the registry belongs to the turn endpoint that will drive it. phpstan.neon.dist excludes the two classes that implement or extend SDK and provider symbols PHPStan cannot resolve. Verified necessary: without it the run reports 15 errors that PHPStan itself marks unignorable. This follows the existing precedent for Logging_Http_Transporter; the mapper, opener and interface stay fully analysed. Adds U12 (new; the plan has no unit for this work). Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
The spike in U3 contradicted three assumptions KTD3 rested on, and its outcome added two units the plan did not contain. Brings the artifact back in line with the code so U4 is planned against what exists. - KTD3 records the corrected findings: upstream PR #255 streams via Guzzle, which core does not bundle, so a WordPress-side transport is needed either way; vendoring cannot be verbatim because core prefixes its PSR dependencies; and the guards are restorable by construction through the public transporter seam rather than bypassed. - U3 is marked complete with its outcome; U11 and U12 are added. - U2's file list pointed at the gated-abilities collection, which accepts only gated base classes and runs only when a different experiment is enabled. - U4's approach corrected on three counts found by review and confirmed in the code: the batch resolver call offers no per-call seam for provenance or logging; a null-input permission call cannot filter tools because this repo's content callbacks are input-dependent; and cancellation cannot rely on abort detection on a buffered turn. - Stop conditions and the streaming open question updated: a host that cannot stream now degrades to a buffered request instead of blocking the plan. Also adds the Anthropic provider to the wp-env configs, since Claude is the chosen provider and the environment previously loaded only Google and OpenAI. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
…l loop Runs a bounded, permission-filtered, logged tool-calling conversation behind POST ai/v1/workspace/messages, with a companion cancel route. Both gate on capability independently of nonce validation. Tool selection uses a coarse, input-free capability predicate. Filtering with a null-input permission check does not work here: this repository's content permission callbacks are input-dependent and return false without a post id or slug, so that filter would deny every tool to every user including administrators. Object-level authorization stays at execute time, where WP_Ability::execute() runs the ability's own callback. The loop iterates the assistant message's function-call parts and invokes the resolver's single-call execute_ability() per call. The batch form runs every call internally and exposes no hooks, leaving no seam for the provenance envelope or for one log row per invocation. Tool results are wrapped as provenance-tagged data before returning to the model, and every invocation writes exactly one log row -- allowed, denied, and failed alike. Denials are distinguishable from failures on the indexed status column without decoding context, and context.surface discriminates workspace rows from MCP rows sharing the same ability name. Cancellation is out of band: a separate route sets a marker the loop re-reads between rounds. Client-abort detection cannot serve here because PHP observes a disconnect only after writing output, which a buffered turn never does and the strict no-output test setting forbids. The marker is written by a different request, so the read drops its cache entries first -- otherwise the loop would answer from the value cached at request start and never observe a cancel. Conversation state lives in user-scoped transients: session-lifetime, regenerable, and expiring, which matters because it accumulates retrieved private and draft post bodies. Ownership is enforced twice -- the key is derived from the user id, and the stored owner is compared again on load -- so a second user holding the same capability cannot read another's conversation. A miss is a 404 rather than a silently-new conversation. Streaming is driven through U12's transport behind a seam, and every reference to the provider-dependent model is isolated so nothing autoloads where the Anthropic plugin is absent. A transport that cannot stream on this host falls back to a buffered request rather than failing. phpstan.neon.dist excludes one small driver file whose provider parent is unresolvable; the errors are non-ignorable and the surface was deliberately isolated so the rest of the loop stays fully analysed. Advances R6, R7, R8, R9, R10, R13, R18, R20, R21 (U4). Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
…mission Builds the conversational surface: transcript with tool steps, prompt input with send/stop/clear, the two context scopes, and the streaming client. Server side, Stream_Responder consumes the filter seam the turn endpoint left and emits SSE frames. Model output is rendered by a restricted-subset markdown renderer rather than a parser plus sanitizer. The renderer emits a node tree, never an HTML string, and the components map nodes to elements, so dangerouslySetInnerHTML appears nowhere in the unit. Dangerous constructs are impossible to emit rather than stripped after parsing. Links get the same treatment as images, which the plan understated. Blocking images alone leaves the exfiltration path half open: a model steered by injected post content can emit a link whose destination carries retrieved private content in its query string, behind anchor text that reads as a legitimate next step. A link is live only when it resolves to http or https on this host; everything else renders as text with its destination visible. Images are inert including reference-style syntax, which is not implemented at all. Streaming asks for SSE and branches on the response content type, so a host that cannot stream renders the identical turn arriving at once with a quiet line saying so -- not an error state. Headers are sent lazily on the first delta, so a turn that never streams falls through to the ordinary JSON body. Accessibility replaces the plan's loose wording: one polite visually-hidden live region updated at sentence and paragraph boundaries and on completion, with the transcript itself no longer aria-live. Announcing every chunk was the failure mode that wording invited. Retry resends the original prompt as a new turn rather than replacing the failed one. A turn that failed mid-stream may already have run tools, and replacing it in place would hide that it ran twice -- exactly the fact a person needs once writes exist. Only errors offer retry; round-cap and cancelled turns do not, because the model ended those, not the transport. ContextScope in the TypeScript types was 'site' | 'post-type' | 'selection', which matched neither R6 nor the endpoint's enum. Reconciled to 'site' | 'general'. Known gap, commented at the call site: on the first turn of a new conversation the client does not yet know the server-minted conversation id, so Stop closes the reader locally while that round finishes server-side. Later turns cancel properly. Closing it needs the turn endpoint to publish the id early. Advances R2, R6, R8, R9, R11, R19 (U5). Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
…the transcript Closes a gap the transcript could not work around: the turn route returned only tool invocation records, never the results, so no post list could reach the client at all. Each tool_calls entry now carries the ability's own return value on success, and null on a denial -- a refusal is not a result. The passthrough is deliberately narrow. Nothing is re-fetched, joined, or enriched, because re-fetching post data client-side would bypass the execute-time permission filtering the whole read path rests on. A test asserts the value the client receives is identical to direct ability execution for the same user and input, so the response cannot leak more than the tool did. The client then treats it as unknown and rebuilds each row from the fields the search ability declares, so a future ability with a wider payload cannot push unexpected fields into the table. The table is chrome-free by construction rather than by configuration: composing DataViews with an explicit Layout child means search, filters, view config and pagination are never mounted, since the component renders its default UI only when given no children. Per-field flags and controlled view state back that up. The plan's instruction to copy the DataViews stylesheet in webpack was unnecessary -- the copy plugin already emits it once to a fixed path outside the entry map -- so the config is untouched and the page enqueues the existing file conditionally, mirroring the request-logs page. R14 is only half-met by this unit: rows link to the post, not to its editor. The search ability returns no edit URL and nothing client-side may invent one, so the edit action is gated on a row-supplied URL and is currently never rendered. Completing it belongs on the ability that already owns the permission walk. Advances R14 (U6). Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
Completes R14. The transcript table already had an edit action gated on a row-supplied URL, but no ability emitted one, so the action never rendered and rows linked only to the published post. The URL doubles as the permission proof: it is present only when the current user can edit the post, so a consumer does not re-derive the capability and cannot construct an editor URL for a post it may only read. That also avoids guessing wp-admin/post.php, which breaks on non-standard admin paths. The explicit capability check is deliberate redundancy, and the docblock says so: get_edit_post_link() already returns nothing for a user who cannot edit, and a mutation run with the gate removed left the new tests green. The gate stays because the emptiness of this field is a permission guarantee consumers rely on, and that guarantee should not rest on the internals of a core function that is free to change. The tests lock the contract rather than the mechanism. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
…ally work Two bugs found by running the workspace against a real provider rather than against tests. Both were invisible because each degraded politely instead of failing. OptionEnum::FUNCTION_DECLARATIONS is not a constant. That call threw an undefined-constant Error, a catch-everything swallowed it, and the screen therefore always reported "no compatible model available" -- even though all eleven Anthropic models advertise function calling. The enum extends AbstractEnum, so the member is reached with isFunctionDeclarations(). The catch is now narrower in effect: an Error is re-thrown rather than swallowed, so a programming mistake in that method surfaces instead of presenting as a permanent, plausible-looking capability gap. An unreachable provider still degrades quietly, which is the case the catch is for. Throwable is still the caught type because the project's coding standard requires it over Exception. The streaming driver called GenerativeAiResultChunk::toText(), which does not exist -- the method is getDeltaText(). The Throwable guard turned that into "this host cannot stream", so every turn silently fell back to a buffered request and the UI truthfully reported a condition that was not true. Verified end to end afterwards: a turn now streams, and the fallback notice is gone. Only the visible delta text is emitted; getReasoningDeltaText() carries the model's thinking, which is not the reply. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
…hinking signatures Two defects found by running a two-turn conversation against the real provider. The model was whichever candidate the registry happened to return first, which on a live site resolved to the most capable and most expensive model available -- selected by array position, not intent. Selection now consults an ordered preference, filterable per site, and falls through cleanly when a preferred model is absent. That accidental choice also exposed the second defect. Always-on-thinking models return thinking blocks, and Anthropic rejects a replayed thinking block whose signature is missing, so the second turn of any conversation failed with "messages.1.content.0.thinking.signature: Field required". The signature arrives as its own signature_delta after the block's thinking text, so the mapper now records which block indices opened as thinking and carries the signature onto the part it belongs to, matched by index rather than arrival order. The storage round trip was never at fault: the SDK's MessagePart already carries a thought signature and Message::fromArray()/toArray() preserve it. The loss was ours, in the mapper, on the streaming path only -- which is why no test saw it and why it took a live second turn to surface. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
The model can propose drafts; only a person's confirmation writes them. The proposal is persisted server-side and the confirmation renders the stored resolved values, never the model's summary of them -- that summary is attacker-influenceable, which is the reason the requirement exists. There is no registered write ability. The plan called for one kept off the MCP surface, but that is an intention without a mechanism: a registered ability is reachable by the MCP surface, the Abilities Explorer, and any third-party caller, none of which have a confirm gate. Instead the writer is a plain class reachable only from the proposal controller, so the confirm gate is a structural property of the write path rather than a validation rule someone can route around. The model's only reach is a propose ability that writes nothing, is hidden from REST and MCP, and refuses outside an active turn context. Ownership is bound, not merely capability-checked. Capability is not identity: without this, a second user holding the same capability could execute another's proposal by id, and the values they approved on screen would not be the ones written. The store derives its key from the owner, compares the stored owner again on read, and compares the conversation at execution -- three checks independent of the capability re-check at write time. Verified by mutation: with the key no longer user-scoped, the stored-owner comparison still refuses a peer, so both layers hold on their own. Set approval is bounded. Proposals cap at twenty items, matching the search tool's row bound, and items start deselected so an item appended by injected content must be chosen deliberately rather than ridden in on a batch approval. A status the user cannot publish to fails the proposal instead of being downgraded silently. Partial failure is reported per item to both the person and the model, with no auto-retry, and re-executing a confirmed proposal creates nothing. Every write attempt is logged in the shape the tool-call rows already use, so writes and reads join on one conversation. Advances R15, R16, R17, R20 (U7). Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
An Options-menu action in the post editor opens the workspace with the current post in scope. It carries identity only -- post ID, status, type, title -- and never the body: the workspace reads content through the permission-checked tool path, so there is one enforcement path and nothing trusts a client-supplied body. A test asserts a marker in the post content never reaches the localized data. The seed is re-resolved server-side from the query argument and checked with read_post, so the URL cannot hand someone a post they may not read; a refusal renders an explanation rather than content. A post title is author-controlled text, so it is flattened to single spaces and clamped before it leaves PHP -- a title cannot smuggle a multi-line instruction block -- and it reaches the model only inside a message the person has seen and can edit. The composer is prefilled, never auto-sent. The residual surface is a person who sends a prefilled prompt without reading it; that is bounded to one clamped line with a human in the loop, and removing it entirely would mean not naming the post at all, which the available tools make useless. Mounted as a post-level Options-menu item rather than the block toolbar the plan named. This is a navigation action for the whole post: a block toolbar entry would either repeat on every block or be arbitrarily scoped to one block type the way content resizing is. The repository already has post-level plugin precedent. The webpack entry is part of this change. That build declares every bundle explicitly, and the asset loader bails silently on a missing asset file, so without the entry the action would never appear and nothing would report why. R5 is only half met and should not be read as complete: the handoff carries identity, but no read-full-body tool is in the workspace allowlist, so the assistant can find the post and see its excerpt while the body stays out of reach. Widening the allowlist is a security-relevant scope decision and was deliberately left alone. Advances R5, R18 (U8). Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
The workspace had no stylesheet and no visible identity: a screen-reader heading, three unstyled regions, and a one-sentence empty state. This adds the screen it needs to read as a place rather than a form. - Wrap every state, including the unavailable ones, in the admin-ui Page shell with the plugin's AI mark, so a person who lands here with no credentials still sees where they are. - Replace the bare empty state with four suggestions drawn from the workspace's capability classes. Choosing one fills the composer instead of sending it: two of the four go on to propose writes, and a prompt should be edited before it runs. - Move clearing the conversation into the header as "New topic". It acts on the whole transcript, not on the draft message, and it was the third button competing with Send and Stop. - Turn the context scope from a labelled select into a compact control in the composer that states the active scope on its face. Scope belongs to the message being written, so it stays visible while writing; each option now carries its own explanation rather than one help string describing both. - Add the stylesheet. A person's message is a contained bubble aligned to the end of the column and the assistant's answer runs its full width, which is what tells them apart without a label. The screen sits below the admin bar rather than taking the viewport, so its height subtracts the bar; without that the composer is pushed off-screen. Tests: npm run test:e2e for the three ai-workspace specs — 25 passed, including two new cases covering the suggestions and the scope control. connector-approval.spec.js fails in this environment, verified failing identically on the parent commit and unrelated to these files. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01PFmvYkGE1zujS7EkqmTe4h
The mark carries no width or height of its own and relies on a stylesheet to size it. The editor bundle ships no stylesheet, so in the post editor's Options menu it rendered at the viewBox's 236px -- a glyph bleeding out of the panel and a menu row tall enough to look like the item had landed in the wrong group. AIIcon now takes an optional size, defaulting to the existing behaviour so the content-resizing toolbar, which sizes it through its own stylesheet, is unaffected. The handoff passes 24, matching every other icon in that menu. Also fixes a prettier violation in the workspace e2e spec that arrived with the design shell and was failing lint:js on the branch. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
Two additions, one from the design canvas and one from running the workspace against a real provider. Retrieval is shown rather than hidden. The design boards put a single line above each answer naming what was searched and what was read in full, which turns the plan's own caps into something a person can see. It is also the honest place to report content a permission check removed: today filtering is silent, so a contributor and an editor get different answers with nothing explaining why, which is indistinguishable from a broken search. R7 only covered the case where no tool is available at all. The allowlist grows from search-only to search plus a capped, permission-filtered body read. Search returns titles and excerpts, so the assistant can find a post and not read it -- which is why the editor handoff half-works and why the gap analysis the problem frame leads with underdelivers. This is the largest increase in reachable content on this surface and is recorded as a Key Decision rather than buried in a unit, because it also widens what an injected instruction can reach: bodies are where a contributor's draft becomes instructions inside an editor's session. U14 carries the body-borne injection fixture the existing test does not cover, since that one only exercises excerpt-length content. Adds R24-R26, U13, U14, and their Definition of Done rows. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
The transcript could not be scrolled back to the start of a long answer. It sets min-height and overflow-y correctly, but bottom-aligned with `justify-content: flex-end`, and in a scrolling column that keyword pushes overflow past the top edge where no scrollbar reaches it -- the opening lines were gone for good. Bottom alignment now comes from an auto margin on the first child, which settles a short transcript against the composer and lets a tall one scroll from its true beginning. Code blocks put their horizontal scroll on the wrapper, which also holds the copy button, so a wide snippet carried its own copy control out of view -- hardest to reach on exactly the snippets that needed it. The pre scrolls now and the block does not. Verified in the browser: a transcript overflowing 1866px into a 578px viewport scrolls to the top, and a 600-character line scrolls inside a pre 820px wide while the button stays in place. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
… e2e The mock answered one complete response per call and matched on request-body substrings, so a multi-round tool-calling turn could not be driven offline at all. Scenarios now supply an ordered sequence of provider responses, consumed one per matching request, with the last entry repeating so an extra round terminates instead of falling through to the substring default. The counter lives in an option keyed by provider and scenario, because a turn's rounds all happen inside one request. Anthropic fixtures are new, and the models-endpoint mock is load-bearing rather than incidental: model ids are fetched live, so without it the provider reads as unconfigured and no workspace spec reaches a turn. Streaming is not mockable at this seam, and that is now proven rather than assumed. The streaming opener calls fopen() on the provider URL directly, so it never enters wp_safe_remote_request() and pre_http_request is never consulted -- a probe from the test container reached the real Anthropic API and came back 401. There is no injection point to attach a chunked fixture to. Scenarios therefore suppress the stream emitter and exercise the buffered path, while specs without a scenario still cover the streaming decision and the WordPress-to-browser responder, which is a separate and testable seam. One thing the investigation confirmed: with the emitter live, the streaming round was refused by connector approval and logged as an error before any egress, while the buffered call succeeded -- the buffered path escapes that check only because the mock returns from pre_http_request before the guard runs. The hand-rolled approval check on the streaming transport is doing its job. Advances R8, R10, R15 (U9). Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
Adds the experiment page, changelog and readme entries for what shipped, and a note in the testing docs about running both suites in one session. The page documents the feature as built rather than as planned. The plan specifies a retrieval trace and a read-full-body tool; neither exists, so the tool surface is described as the two-ability allowlist it actually is, and the gaps are listed under limitations rather than omitted. The screen is likewise documented as not truly full-screen: the body class is applied but nothing enqueues the stylesheet that acts on it. The safety posture is the part the security review found missing, so it is covered in prose rather than as a checklist: what the assistant may not do, why confirmation shows stored resolved values rather than the model's summary of them, that the tool surface is an allowlist rather than everything registered on the site, that retrieved content is treated as untrusted and rendered inert, and -- newly stated anywhere -- that site content is sent to the configured third-party provider, limited to what the requesting user could read and to the fields the tool returned. The test-ordering footgun goes in docs/TESTING.md rather than the experiment page, because it is repo-wide: any e2e run after the PHP suite hits it. The streaming-unmockable note stays on the experiment page, because it is a property of this feature's transport. Advances R1, R16, R17 (U10). Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
… and correct the test docs Two defects surfaced while documenting the experiment. The search ability's model-facing description told the assistant to "use core/read-content to read a post body". That ability is gated behind the Custom Abilities experiment and is not on the workspace allowlist, so the assistant was being told about a capability it will never be offered -- which is a plausible source of hedging about content it could not reach. The description now states plainly that the tool returns titles and excerpts and never full bodies. The testing docs recommended `composer test` and `vendor/bin/phpunit` directly, with no mention of the test environment. Run inside the default wp-env container that resolves DB_NAME to the development site's own database, and the WordPress test bootstrap reinstalls WordPress over it: plugins deactivated, posts deleted, options cleared including provider credentials. The suite passes while doing it, so nothing signals the loss. That guidance cost a working dev site more than once today, and it contradicted CONTRIBUTING.md, which already documents the scoped command. The section now names npm run test:php as the command and explains what the direct invocation destroys. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
`ai/search-content` deliberately returns titles and excerpts only, so the assistant could find a post but never read it. `core/read-content` exists but is registered behind the Custom Abilities experiment, which would make the reading path appear and disappear with an unrelated switch. Adds `ai/read-content-bodies`: at most five posts per call, named by ID, with every body filtered at execute time by the requesting user's own capabilities using the same read permission walk `ai/search-content` performs. A body the user could not otherwise read is never returned, and an unknown ID and an unreadable one are reported identically in `unavailable`. Password-protected bodies are withheld from anyone who cannot edit the post, with the post still listed and flagged `content_protected`. Bodies are returned with markup stripped: text to reason about, not markup to reproduce. The tests were written before the class existed and failed for its absence. After green, the permission walk was forced open and the clamp removed; five tests failed and were the right five, so the leakage assertions are load-bearing rather than decorative. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
…U13) The turn response records every tool invocation, but a consumer wanting to show what the assistant looked up had to narrow each ability's result shape itself. Adds a normalized `retrieval` summary per invocation: the kind of retrieval, the term searched for, how many items were returned, and how many were withheld because the person may not read them. The withheld count is measured, not derived. `ai/search-content` now reports it directly, counted as the permission walk drops rows from the page it builds. Deriving it instead as total minus returned rows would conflate two unrelated causes: page one of fifty matches would announce forty posts hidden by the person's role on a perfectly ordinary search, teaching people to distrust a control that is working. Pagination contributes zero. `ai/read-content-bodies` deliberately reports no count. It answers for IDs the caller named, so counting unknown and unreadable apart would say whether those exact posts exist. Null therefore means the ability reports no count, which is not the same as zero and must not render as "none withheld". The searched term is model-supplied and reaches the summary from the invocation arguments, flattened and clamped like other untrusted strings and documented as untrusted at both ends. Mutating the count back to subtraction fails `test_retrieval_summary_never_counts_paginated_rows_as_withheld` and `test_pagination_alone_reports_nothing_withheld` — written after the first withheld test proved insensitive, its totals being ones where subtraction happens to give the right answer. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
`develop` tracks no planning or Compound Engineering artifacts, so committing the workspace plan would have introduced 626 lines of a personal workflow document as a new project convention. It is excluded locally instead, which keeps it available to work from without asking every contributor to adopt it. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
…rror codes Plugin Check runs `WordPress.Security.EscapeOutput` and reported eleven errors across the streaming classes, each phrased as an unescaped output found in `Streaming_Exception`. The messages were never the problem: the sniff inspects every argument of an exception constructor, and what it rejects is the integer error code passed as the second one. It names `Streaming_Exception` only because that is the first token of `Streaming_Exception::CODE_MALFORMED`. The same throw without a code argument is clean, and `RuntimeException` with one is clean, which is why the plugin's existing throws never tripped it — they pass no code. An `int` error code cannot be output, so escaping it would be meaningless and would misdescribe the code for the next reader. Each site takes a narrowly scoped ignore with its own justification instead. Two sites are not error codes and say so: the mapper's `provider_error()` call is a factory that escapes the provider message itself, and the model's `getStatusCode()` is an HTTP status feeding an already-escaped `%1$d`. Comments only; no message, error code or behavior changed. The repository's own `lint` never covered this sniff, so it could only surface in CI. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
The guard asserting that vendored files import core's prefixed dependencies matched `Nyholm\` and `Psr\Http\` only. Core scopes more than PSR-7 — `Psr\EventDispatcher\` and `Psr\SimpleCache\` are prefixed the same way — so the pattern was narrower than the hazard it exists to prevent, and an unprefixed `Psr\EventDispatcher\EventDispatcherInterface` import had been sitting in `EmbeddingBuilder` since the embeddings feature was vendored. It stayed latent because the symbol appears only as a nullable typed property and constructor parameter, and PHP does not resolve such a type while the value is null. Passing a real dispatcher would have raised a TypeError: the prefixed interface does not satisfy the unprefixed name. Widening the pattern turns the suite red on that file, so the import is corrected here. The README's "Modifications applied" section recorded `embeddings` as having none, which is no longer true, and it now also records the one place first-party code is exempt from PHPStan — the three streaming bridge classes, whose unresolvable SDK and provider-plugin symbols produce non-ignorable errors — so the closing claim that first-party code is fully analysed is no longer read as unqualified. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
…real (U13) The turn response has carried a per-invocation retrieval summary since the server half of U13, but nothing rendered it. The transcript's tool disclosure opened with "Looked up site content (3 steps)", which counted invocations rather than saying what was retrieved. The trace now *is* that summary line — "Searched 20 posts · read 5 in full" — so what the assistant retrieved is always visible while the per-invocation detail stays one interaction away, rather than rendering the same facts twice. Counts come from the server's summary, never from arithmetic over a result set. A withheld count is reported only when an ability counted one: null reads as silence, not as "none withheld", because only a counting ability can make that claim. An ability reporting no summary falls back to naming the steps, so an unrecognised tool still discloses that it ran. Two defects surfaced while screenshotting the result. `is-fullscreen-mode` was on the body with nothing to act on it: the CSS lives inside editor bundles and `wp-interface` is not a registered handle, so the admin menu never collapsed. Enqueuing an editor bundle to collect three rules would import the editor's whole selector surface, so the rules are restated against core's contract — menu hidden, content gutter reclaimed, admin bar deliberately kept, which is what lets the app subtract only the bar's height. The results table was also being crushed to the width of the summary text, truncating every post title, because the disclosure sized itself to its own summary as a flex-start child. It now spans the message column while the summary keeps its chip shape. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
…rect the API key Two findings from the branch's code review, both in the streaming path. The availability guard called a static method on `Anthropic_Streaming_Text_Generation_Model`, which extends a class belonging to the separate Anthropic provider plugin. Calling it autoloaded the subclass and forced PHP to resolve that parent, so on a site without that plugin the guard raised a fatal instead of returning false — and it sat above the try, outside `stream()`'s try, with no catch further up. A site running streaming with only OpenAI or Google configured died on the turn. The guard now probes the parent class and the interface by name, `create_model()` runs entirely inside the existing `catch ( Throwable )`, and every bail-out reports through `fall_back()` so the reason is observable rather than a bare null. The stream opener validated only the initial URL and then set `follow_location`, so PHP replayed the provider credential to as many as five unvalidated targets — the file's own docblock claimed parity with `wp_safe_remote_request()`, which held for the first hop alone. Redirects are now refused outright and surfaced as a transport error: the messages endpoint does not redirect in normal operation, so a 3xx is a misconfiguration or an interception attempt, and the caller already degrades to the buffered path. Both guards are proven, not assumed. Restoring the old one-line availability check makes the new driver test fail with the real fatal (`Class "…AnthropicTextGenerationModel" not found`, exit 255); it runs in a subprocess because sibling tests load the provider autoloader, which would make any in-process check pass vacuously. Restoring `follow_location` makes the redirect test fail by recording the credential arriving at the redirect target, and a control test asserts the recorder works so the check cannot pass by never running. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
…tored A proposed draft's content and excerpt reached `wp_insert_post()` unfiltered. Because the workspace is gated on `manage_options`, the approver normally holds `unfiltered_html`, so WordPress's own `content_save_pre` pass never ran — and the text is model-generated from site content a lower-privileged author can write, then approved from behind a collapsed disclosure the person may never expand. Filtering happens in `Proposal_Store::normalize_item()`, not at write time. That placement is the point: the confirmation screen renders the stored values, so filtering at store time preserves the property that what a person approves is what gets written. Filtering in the writer would have broken it, showing one thing and storing another. `wp_kses_post()` is the right ruleset rather than a stricter one — it keeps block delimiter comments intact while removing scripts and event handlers, and it is what core would have applied to an author without `unfiltered_html`. The excerpt gets the same treatment for the same reason. Also covers the write-time publish recheck, which had no test: `publish_posts` revoked between proposing and confirming a publish-status item. Deleting that branch does not merely change a message — a published post is created by a user who can no longer publish — so the test now holds the guard down. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
…nnot support The trace exists so a person can tell when their own permissions kept content back. Two paths made it claim things that were not true. A post type dropped by the status gate never reached the per-row counting loop, so its rows were excluded for permission reasons while `withheld` still reported the integer 0 — which this code reserves for the positive claim that nothing was kept back. The search ability now reports `withheld` as null in that case. Null already means "no claim" end to end, and inventing a count for rows that were never queried would be false precision. Separately, "Searched N posts" rendered `returned`, the count after row-level filtering, so it silently excluded the very rows the withheld figure was reporting and the two could not be reconciled. The ability now reports `examined` — the rows the page was built from before the permission walk — and the trace renders that, so `examined - withheld == returned` holds. `examined` is read from the ability's own payload and never derived as `returned + withheld`, which would be wrong in exactly the case where withheld is null. Each guard is mutation-proven: reverting either one fails a test that names the specific wrong number it produces. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01BRSJEyQyYp3XTeum8fiq3L
…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) <[email protected]>
…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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
… 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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
#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) <[email protected]>
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) <[email protected]>
|
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. |
Every change on this branch already landed on feat/ai-workspace when #1004 was rebased: 52 commits as identical patches and 3 re-applied with conflict resolutions. Take feat/ai-workspace's tree as-is so the PR has no conflicts and merging it changes no code. Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
Closing as superseded by #1004. Every change in this PR is already on
Keeping it open would only leave a diverged branch in the #1004 stack. |
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