🔒 Security fix — please upgrade
Severity: P1 (privilege escalation) — any authenticated user (including subscriber) could invoke any registered ability via mcp-adapter/execute-ability on the default MCP server, bypassing the Abilities-tab exposure gate.
What changed
Two mutually reinforcing bugs in PermissionOverrideProcessor::inject_override combined into a plugin-wide permission_callback bypass:
- Wrapper closure dropped its arguments. Declared as
static function () use ( ... )— zero parameters — the closure silently discarded every arg the caller passed. Downstream callbacks reading$input(notablyExecute::check_permissionlooking up$input['ability_name']) received an empty array and returnedWP_Error( 'missing_ability_name' ). WP_Errorreturn coerced to booleantrue.call_originaldidreturn (bool) call_user_func( $original );. In PHP,(bool) $any_objectis alwaystrue— so theWP_Errorobject becametrue, and the vendor'sif ( true !== $permission )check inToolsHandler::call_tool:148read that as "permission granted" and proceeded toexecute().
Net impact: reproducible live with a subscriber-level user invoking acrossai-abilities-manager/site-title-get even when the ability was mcp.public=false at registration AND the operator had explicitly disabled it (is_exposed=0) in the Abilities tab. Response was 200 with the site title returned.
Fix (PR #45)
- Closure now
static function ( ...$callback_args ) use ( ... )and forwards$callback_argstoself::call_original( $original, $callback_args )on every fall-through path. call_originalreturnsbool|\WP_Error—WP_Errorresults propagate unchanged; only scalar returns are coerced to bool.
The six defensive layers of DEC-F030-PERMISSION-CALLBACK-OPERATOR-OPT-IN-BYPASS are unchanged — only the fall-through path is affected.
Regression tests
Three new tests in PermissionOverrideProcessorTest.php:
test_closure_forwards_args_to_original_callback— asserts args flow to the original.test_wp_error_from_original_is_preserved_not_coerced_to_true— assertsWP_Errorsurvives the wrapper.test_wrapper_preserves_role_gated_denials—@dataProvideroversubscriber/contributor/author/editor/administrator; only administrator (withmanage_options) is allowed; every other role is correctly denied.
Durable memory
B40 / B-WRAPPER-CLOSURE-MUST-FORWARD-ARGS-AND-PRESERVE-WP-ERROR captured in docs/memory/BUGS.md — generalizable pattern for any closure wrapping a user callback (permission_callback, execute_callback, filter/action decorators, plugin bridges).
Follow-up work
Issue #46 — proposed F034 filter-time eligibility gate that would skip installing the wrapper entirely for abilities that could never satisfy F030's six defensive layers, eliminating the wrapper-bug class for the vast majority of abilities.
No breaking changes
Six-layer bypass semantics preserved. No schema changes. No admin-visible UI changes. Safe to upgrade in place.