[a-zA-Z0-9\-/]+?)/run$#'; /** * Core's batch envelope. Every bundled sub-request fires * rest_pre_dispatch on its own and is judged individually, so evaluating * the envelope as well would add a meaningless "create unknown" row to * the audit trail and spend rate-limit budget on the wrapper. */ private const BATCH_ENVELOPE = '#^/batch/v1$#i'; /** * Strict mode: refuse writes that authenticate with an application * password no agent claims. Exposed through the settings endpoint * (AF-13.2) - it shipped in AF-10.5 as a bare option, which meant the * only people who could use it were the ones who read the source. */ public const OPTION_BLOCK_APP_PASSWORD_WRITES = 'adaf_block_app_password_writes'; /** * MCP envelope recognition and per-call gating (AF-13.1). */ private readonly McpTransport $mcp; /** * Requests this gatekeeper evaluated, so a later error from their own * handler is not mistaken for core refusing them. Weak, because a * persistent worker would otherwise keep every request alive. * * @var \WeakMap */ private \WeakMap $evaluated; /** * Constructor. * * @param InterceptionPipeline $pipeline Shared interception pipeline. */ public function __construct( private readonly InterceptionPipeline $pipeline ) { $this->mcp = new McpTransport( $pipeline ); $this->evaluated = new \WeakMap(); } /** * Hook the three stages of REST dispatch (ADR-13). */ public function register(): void { add_filter( 'rest_pre_dispatch', array( $this, 'pre_dispatch' ), 10, 3 ); // First in line, so a plugin that answers a write itself in this // filter can only do so after the firewall has let it through. add_filter( 'rest_dispatch_request', array( $this, 'dispatch_request' ), PHP_INT_MIN, 4 ); // Late, to observe the response core actually sends. add_filter( 'rest_request_after_callbacks', array( $this, 'after_callbacks' ), 999, 3 ); } /** * Refuse an agent the control plane, before core does anything. * * Nothing else is judged here: core has not yet matched the route, * validated the input or checked the linked user's permissions, and a * write judged before that could be one core will refuse (ADR-13). * * @param mixed $result Response to replace the requested version with. * @param mixed $server REST server instance (unused). * @param \WP_REST_Request $request The request. * @return mixed Null to continue dispatch; a response to halt. */ public function pre_dispatch( $result, $server, $request ) { $context = AgentContext::current(); if ( null !== $result || null === $context ) { return $result; } // For reads as well as writes: an agent never reaches the control // plane. Its linked user's capabilities are beside the point - an // agent linked to a privileged user would otherwise issue itself // tokens, rewrite the policies judging it, approve its own held // actions or read the ledger recording them, leaving the firewall // gating everything except its own controls. $route = $request->get_route(); $poll = 1 === preg_match( self::AGENT_STATUS_POLL, $route ); if ( self::is_control_plane( $route ) && ! $poll ) { // Recorded, because an agent reaching for the controls that // govern it is the clearest probing signal (AF-24.1). As a // refusal: no rate budget is spent, and RefusalThrottle caps // the rows per agent. $params = $request->get_params(); $this->pipeline->record_refusal( $context->agent, ActionDescriptor::from_rest( strtoupper( $request->get_method() ), $route, is_array( $params ) ? $params : array() ), 'control_plane', 'adaf_control_plane' ); return DenialResponse::rest_response( Evaluation::REASON_CONTROL_PLANE, 'adaf_control_plane' ); } // The kill switch stops reads too, as it promises: writes reach the // pipeline at dispatch, but a read is otherwise never evaluated, so // it is sent there now to be refused and recorded. The agent may // still poll its own held action, which is how it learns the // outcome once the switch is released. $method = strtoupper( $request->get_method() ); if ( ! self::is_write( $method ) && ! $poll && $this->pipeline->locked_down() ) { $params = $request->get_params(); $outcome = $this->pipeline->process( $context->agent, ActionDescriptor::from_rest( $method, $route, is_array( $params ) ? $params : array() ) ); return DenialResponse::rest_response( $outcome->reason, 'adaf_denied', null, $outcome->retry_after ); } return $result; } /** * Evaluate a write once core has matched it and let the linked user * make it, immediately before the route handler would run. * * @param mixed $result Dispatch result from an earlier filter. * @param \WP_REST_Request $request The request. * @param string $route Matched route pattern (unused). * @param array $handler Matched handler (unused). * @return mixed Null to run the handler; a response to halt. */ public function dispatch_request( $result, $request, $route, $handler ) { if ( null !== $result ) { return $result; } // An approval re-sending a held request (AF-18.2). It was evaluated // when the agent sent it; the approver's own credential - an // application password, say - must not re-file it as untracked // automation or refuse it in strict mode. Only the exact request // object ActionExecutor is replaying qualifies: anything the // replayed write dispatches in turn is evaluated as usual, and // nothing a request carries can claim the exemption. if ( ActionExecutor::is_replaying( $request ) ) { return $result; } $method = strtoupper( $request->get_method() ); if ( ! self::is_write( $method ) ) { return $result; } $context = AgentContext::current(); if ( null === $context ) { return $this->guard_untracked_automation( $result, $request ); } $route = $request->get_route(); if ( $this->is_exempt( $route ) ) { return $result; } $this->evaluated[ $request ] = true; $params = $request->get_params(); $action = ActionDescriptor::from_rest( $method, $route, is_array( $params ) ? $params : array() ) ->with_replay( self::replay_envelope( $method, $route, $request ) ); // An MCP envelope is one POST to one route whatever it carries, so // the verb/path descriptor cannot see inside it: classify per // JSON-RPC call instead (AF-13.1). The door only opens for POST (the // JSON-RPC carrier verb) on routes REST classification does not // recognize - a JSON-RPC-shaped body aimed at a known REST entity is // smuggling, evaluated as the REST write the handler would actually // perform, and an Mcp-* header on a PUT or DELETE reroutes nothing. if ( 'POST' === $method && 'unknown' === $action->entity_type && McpTransport::is_mcp( $request ) ) { return $this->mcp->process( $context->agent, $request ); } $outcome = $this->pipeline->process( $context->agent, $action ); if ( Decision::DENY === $outcome->decision ) { return DenialResponse::rest_response( $outcome->reason, 'adaf_denied', null, $outcome->retry_after ); } if ( ! $outcome->may_execute() ) { return new \WP_REST_Response( array( 'status' => 'pending_approval', 'action_id' => $outcome->pending_id, 'message' => '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.', 'retryable' => false, ), 202 ); } return null; } /** * Record an agent write core refused before the firewall evaluated it, * or one another plugin answered before the firewall could. * * The firewall now judges writes only after core's permission check and * input validation (ADR-13), so a write refused at either never reaches * the pipeline. It is still an attempt worth seeing - probing looks * exactly like this - so it is ledgered as refused, with the stage and * core's code, the way the ability door records its rejections. It spends * no rate budget: nothing was attempted against the site. * * @param mixed $response Response or error core is about to send. * @param array $handler Matched handler (unused). * @param \WP_REST_Request $request The request. * @return mixed The response, unchanged. */ public function after_callbacks( $response, $handler, $request ) { $context = AgentContext::current(); $method = strtoupper( $request->get_method() ); if ( null === $context || ! self::is_write( $method ) || isset( $this->evaluated[ $request ] ) ) { return $response; } $route = $request->get_route(); if ( $this->is_exempt( $route ) ) { return $response; } $params = $request->get_params(); $action = ActionDescriptor::from_rest( $method, $route, is_array( $params ) ? $params : array() ); // Succeeded without ever being evaluated: a filter ahead of the // firewall on rest_dispatch_request answered it - a plugin tying // PHP_INT_MIN and registered first. No priority prevents that, so // it is recorded rather than left unseen. if ( ! $response instanceof \WP_Error ) { $this->pipeline->record_bypass( $context->agent, $action ); return $response; } $stage = self::refusal_stage( $response ); if ( null !== $stage ) { $this->pipeline->record_refusal( $context->agent, $action, $stage, (string) $response->get_error_code() ); } return $response; } /** * Which of core's checks refused a request, from the status it answered * with: input for 400, permission for 401 or 403. Null for anything else, * which is not a refusal - a missing post, a server error. * * @param \WP_Error $error Error core is sending. */ private static function refusal_stage( \WP_Error $error ): ?string { $data = $error->get_error_data(); $status = is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 0; return match ( $status ) { 400 => 'input', 401, 403 => 'permission', default => null, }; } /** * Whether an HTTP method writes. * * @param string $method Upper-cased HTTP method. */ private static function is_write( string $method ): bool { return in_array( $method, array( 'POST', 'PUT', 'PATCH', 'DELETE' ), true ); } /** * The request as sent, for re-sending on approval (AF-18.2). * * Built from the request line and each parameter source separately, so a * query-string `force` stays a query param and a JSON body stays JSON. * Nothing here is read from the agent's parameters by name, so an agent * cannot choose the route its request is replayed to. A file upload's * bytes are not kept - too large to queue, and binary - so such a request * is marked rather than stored half-complete. * * @param string $method Upper-cased HTTP method. * @param string $route REST route. * @param \WP_REST_Request $request Incoming request. * @return array */ private static function replay_envelope( string $method, string $route, $request ): array { $query = $request->get_query_params(); $body = $request->get_body_params(); $json = $request->get_json_params(); $envelope = array( 'method' => $method, 'route' => $route, 'query' => is_array( $query ) ? $query : array(), 'body' => is_array( $body ) ? $body : array(), 'json' => is_array( $json ) ? $json : array(), ); if ( self::carries_file( $request, $json ) ) { $envelope['file'] = true; } return $envelope; } /** * Whether the request uploads a file: multipart file params, or a raw * body that is neither JSON nor a form (core's media upload shape). * * @param \WP_REST_Request $request Incoming request. * @param mixed $json Parsed JSON params, null when not JSON. */ private static function carries_file( $request, $json ): bool { $files = $request->get_file_params(); if ( is_array( $files ) && array() !== $files ) { return true; } if ( null !== $json || '' === (string) $request->get_body() ) { return false; } $type = strtolower( (string) $request->get_header( 'content-type' ) ); return ! str_contains( $type, 'application/x-www-form-urlencoded' ) && ! str_contains( $type, 'multipart/form-data' ); } /** * Whether a route is one of this plugin's own controls, closed to agents. * * @param string $route REST route. */ public static function is_control_plane( string $route ): bool { // Case-insensitive, as WordPress's own route matching is: any casing // of a firewall route reaches the same controller (found 2026-10-16). return 1 === preg_match( self::OWN_ROUTES, $route ); } /** * Whether a route is handled elsewhere and must not be gated here. * * An ability-run route only earns the exemption when it names an ability * that is actually registered, because only then does AbilityInterceptor * wrap it. A route squatting the namespace with no ability behind it has * nothing downstream to catch it. * * @param string $route REST route. */ private function is_exempt( string $route ): bool { if ( self::is_control_plane( $route ) ) { return true; } if ( 1 === preg_match( self::BATCH_ENVELOPE, $route ) ) { return true; } if ( 1 === preg_match( self::ABILITY_RUN_ROUTE, $route, $matches ) ) { return wp_has_ability( $matches['name'] ); } return false; } /** * Shadow automation guard (PRD STORY-9.5, MCP09). * * A write authenticated via an application password no agent claims * carries no agent identity and would silently bypass agent gating. * Default: surface it via the adaf_shadow_automation event and let it * proceed. Strict mode: reject it, so automation must arrive either with * an agent token or with a password bound to an agent (AF-13.2). * * A bound password never reaches here: the binding establishes an * AgentContext, so pre_dispatch() has already taken the agent path. * * @param mixed $result Current dispatch result (null). * @param \WP_REST_Request $request The request. * @return mixed */ private function guard_untracked_automation( $result, $request ) { if ( 0 === did_action( 'application_password_did_authenticate' ) ) { return $result; } $route = $request->get_route(); if ( $this->is_exempt( $route ) ) { return $result; } // An envelope carrying only MCP plumbing - a handshake, a ping, a // capability listing - performs no write; the POST is merely its // transport. The agent door has always passed these unevaluated, // and treating them as writes here was wrong in both directions: // it filed each as an untracked "create unknown", and strict mode // would refuse a health probe without preventing any mutation. // The tools/call inside such an envelope still arrives as a write. if ( McpTransport::is_protocol_only( $request ) ) { return $result; } if ( '1' === (string) get_option( self::OPTION_BLOCK_APP_PASSWORD_WRITES, '0' ) ) { return DenialResponse::rest_response( 'untracked_automation', 'adaf_untracked_automation', __( 'Denied: writes authenticated with an application password are blocked, because they carry no agent identity and cannot be governed by policy. Retrying will not help. Ask a site administrator to issue this integration an agent token.', 'agentic-daisy-ai-agent-firewall' ) ); } $params = $request->get_params(); /** * Fires when a write bypasses agent identity via an application * password (shadow automation, MCP09). ShadowAutomationRecorder * listens and appends the write to the ledger as 'untracked'. * * @param string $route REST route. * @param int $wp_user_id Authenticated user. * @param string $method HTTP method. * @param array $params Request parameters. */ do_action( 'adaf_shadow_automation', $route, get_current_user_id(), strtoupper( $request->get_method() ), is_array( $params ) ? $params : array() ); return $result; } }