[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$#'; /** * 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; /** * Constructor. * * @param InterceptionPipeline $pipeline Shared interception pipeline. */ public function __construct( private readonly InterceptionPipeline $pipeline ) { $this->mcp = new McpTransport( $pipeline ); } /** * Hook REST dispatch. */ public function register(): void { add_filter( 'rest_pre_dispatch', array( $this, 'pre_dispatch' ), 10, 3 ); } /** * Evaluate an agent write request before the route handler runs. * * @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/error to halt. */ public function pre_dispatch( $result, $server, $request ) { if ( null !== $result ) { return $result; } $method = strtoupper( $request->get_method() ); $is_write = in_array( $method, array( 'POST', 'PUT', 'PATCH', 'DELETE' ), true ); $context = AgentContext::current(); if ( null === $context ) { if ( $is_write ) { return $this->guard_untracked_automation( $result, $request ); } return $result; } $route = $request->get_route(); // Before anything else, and 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. if ( 1 === preg_match( self::OWN_ROUTES, $route ) && 1 !== preg_match( self::AGENT_STATUS_POLL, $route ) ) { return DenialResponse::rest_response( Evaluation::REASON_CONTROL_PLANE, 'adaf_control_plane' ); } if ( ! $is_write ) { return $result; } if ( $this->is_exempt( $route ) ) { return $result; } $params = $request->get_params(); $action = ActionDescriptor::from_rest( $method, $route, is_array( $params ) ? $params : array() ); // 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; } /** * 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 ( 1 === preg_match( self::OWN_ROUTES, $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; } 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; } }