*/ private static array $wrapped = array(); /** * Ability calls already decided as the MCP adapter's execute-ability * call that runs them (AF-25.17), each [name, input], passed once. * * @var list}> */ private static array $passing = array(); /** * Forget the calls passing (tests only; a real request starts with * none). */ public static function reset_passing(): void { self::$passing = array(); } /** * Forget which abilities are wrapped (tests only; a real request starts * with an empty registry anyway). */ public static function reset_wrapped(): void { self::$wrapped = array(); } /** * Constructor. * * @param InterceptionPipeline $pipeline Shared interception pipeline. */ public function __construct( private readonly InterceptionPipeline $pipeline ) { } /** * Hook ability registration and execution. */ public function register(): void { add_filter( 'wp_register_ability_args', array( $this, 'wrap_ability_args' ), 10, 2 ); // Harmless before 7.1: older cores never fire it. add_filter( 'wp_pre_execute_ability', array( $this, 'pre_execute' ), 10, 3 ); } /** * Judge an invocation of an ability whose callback was never wrapped. * * Returning $pre unchanged is core's signal to carry on with normalization, * validation, permissions, and the ability's own callback. Any other value * is returned to the caller as-is, which is how DENY and REQUIRE_APPROVAL * stop the action here. Like the REST door, policy is evaluated ahead of * core's permission check. * * @param mixed $pre Sentinel meaning "not short-circuited". * @param string $ability_name Namespaced ability name. * @param mixed $input Raw ability input. * @return mixed */ public function pre_execute( $pre, string $ability_name, $input = null ) { if ( isset( self::$wrapped[ $ability_name ] ) ) { // The wrapper is this ability's door; judging here as well would // double-ledger the call and spend two rate-limit slots. return $pre; } $context = AgentContext::current(); if ( null === $context || self::passes( $ability_name, $input ) ) { return $pre; } $action = self::describe( $ability_name, $input ); $outcome = $this->pipeline->process( $context->agent, $action ); if ( Decision::DENY === $outcome->decision ) { return DenialResponse::for_reason( $outcome->reason, $outcome->retry_after ); } if ( ! $outcome->may_execute() ) { return $this->held_response( $outcome->pending_id ); } // Allowed: core runs the ability. An execute-ability call allowed // here lets nothing pass: this door hands control back, so there is // no window to close, and the ability it runs is decided again on // its own (AF-25.17). The result is not recorded from here // because this door hands control back rather than executing itself. return $pre; } /** * An invocation as the firewall reads it: the MCP adapter's * execute-ability call as the ability it runs (AF-25.17), anything else * as itself. * * @param string $ability_name Namespaced ability name. * @param mixed $input Ability input. */ private static function describe( string $ability_name, $input ): ActionDescriptor { $input = is_array( $input ) ? $input : array(); return McpAdapterCall::unwrap( $ability_name, $input ) ?? ActionDescriptor::from_ability( $ability_name, $input ); } /** * Once an execute-ability call is allowed as the ability it runs, that * ability's own invocation with the same input is the same decision: * judging it again would ledger the call twice and spend two rate-limit * slots. A different input is judged as usual. Only while the wrapped * callback runs: intercept() drops the entry when it returns, so a * decision never outlives the call that made it. * * @param string $ability_name The ability invoked. * @param ActionDescriptor $action How it was read. * @return array{0: string, 1: array}|null The entry passed, if any. */ private static function let_pass( string $ability_name, ActionDescriptor $action ): ?array { if ( null === $action->ability_name || $action->ability_name === $ability_name ) { return null; } $entry = array( $action->ability_name, $action->input ); self::$passing[] = $entry; return $entry; } /** * Whether this invocation was decided as the execute-ability call that * runs it, consuming that decision. * * @param string $ability_name Namespaced ability name. * @param mixed $input Ability input. */ private static function passes( string $ability_name, $input ): bool { $entry = array( $ability_name, is_array( $input ) ? $input : array() ); $index = array_search( $entry, self::$passing, true ); if ( false === $index ) { return false; } array_splice( self::$passing, (int) $index, 1 ); return true; } /** * Wrap the execute callback of an ability being registered. * * @param array $args Ability registration args. * @param string $ability_name Namespaced ability name. * @return array */ public function wrap_ability_args( array $args, string $ability_name ): array { if ( ! isset( $args['execute_callback'] ) || ! is_callable( $args['execute_callback'] ) ) { return $args; } self::$wrapped[ $ability_name ] = true; $original = $args['execute_callback']; $args['execute_callback'] = function ( $input = null ) use ( $original, $ability_name ) { return $this->intercept( $original, $ability_name, $input ); }; return $args; } /** * Run one ability invocation through the firewall. * * @param callable $original Original execute callback. * @param string $ability_name Namespaced ability name. * @param mixed $input Ability input. * @return mixed */ private function intercept( callable $original, string $ability_name, $input ) { $context = AgentContext::current(); if ( null === $context || self::passes( $ability_name, $input ) ) { return $original( $input ); } $action = self::describe( $ability_name, $input ); $outcome = $this->pipeline->process( $context->agent, $action ); if ( Decision::DENY === $outcome->decision ) { return DenialResponse::for_reason( $outcome->reason, $outcome->retry_after ); } if ( ! $outcome->may_execute() ) { return $this->held_response( $outcome->pending_id ); } $passing = self::let_pass( $ability_name, $action ); try { $result = $original( $input ); } finally { // Not run by the adapter after all: nothing else passes on it. if ( null !== $passing ) { self::$passing = array_values( array_filter( self::$passing, static fn ( array $entry ): bool => $entry !== $passing ) ); } } $this->pipeline->record_result( $outcome->ledger_id, array( 'result' => is_scalar( $result ) || is_array( $result ) ? $result : gettype( $result ) ) ); return $result; } /** * The response an agent gets when its action is queued for a human. * * A WP_Error rather than an array, and that is load-bearing. Core * validates whatever an execute callback returns against the ability's * declared `output_schema`, and a held-for-approval payload matches no * ability's declared output - so an array reached the agent as * `ability_invalid_output`, which is indistinguishable from a broken * tool and carries no id to poll. A WP_Error is handed back untouched: * * $result = $this->do_execute( $input ); * if ( is_wp_error( $result ) ) { return $result; } * $is_valid = $this->validate_output( $result ); * * Every WooCommerce ability declares an output schema, which is how this * was found (AF-17.3). * * The machine-readable marker is the error code, as it already is for a * denial. `status` is 202 so core's REST conversion answers the abilities * run route with the same Accepted status the REST door returns. * * @param int|null $pending_id Pending queue id. */ private function held_response( ?int $pending_id ): WP_Error { return new WP_Error( 'adaf_pending_approval', 'Action held for human review. It has not executed. Poll GET /agenticdaisy-firewall/v1/pending/{action_id}/status with this token until it reports approved or rejected; do not resubmit, which only queues a duplicate.', array( 'status' => 202, 'action_id' => $pending_id, 'retryable' => false, ) ); } }