namespace = 'agenticdaisy-firewall/v1'; $this->rest_base = 'pending'; } /** * Register routes. */ public function register_routes(): void { $id_arg = array( 'id' => array( 'type' => 'integer', 'required' => true, 'minimum' => 1, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ), ); register_rest_route( 'agenticdaisy-firewall/v1', '/' . $this->rest_base, array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_items' ), 'permission_callback' => array( $this, 'permissions_check' ), 'args' => $this->get_collection_params(), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); // Readable by the agent that queued the action, not only by an // administrator: the 202 tells the agent its action is held and to // check back, which was impossible while every route here required // manage_options. register_rest_route( 'agenticdaisy-firewall/v1', '/' . $this->rest_base . '/(?P\d+)/status', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_status' ), 'permission_callback' => array( $this, 'status_permissions_check' ), ), ) ); register_rest_route( 'agenticdaisy-firewall/v1', '/' . $this->rest_base . '/(?P\d+)/approve', array( array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'approve_item' ), 'permission_callback' => array( $this, 'permissions_check' ), 'args' => $id_arg, ), ) ); register_rest_route( 'agenticdaisy-firewall/v1', '/' . $this->rest_base . '/(?P\d+)/reject', array( array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'reject_item' ), 'permission_callback' => array( $this, 'permissions_check' ), 'args' => $id_arg, ), ) ); } /** * Who may ask about an action's status: an administrator, or an * authenticated agent asking about its own work. * * Ownership is not settled here - only that the caller is one or the * other. Which action they may see is decided in the callback, so a * mismatch can answer 404 rather than 403 and avoid confirming that an * id exists. * * @param WP_REST_Request $request Request. * @return true|\WP_Error */ public function status_permissions_check( $request ) { if ( current_user_can( 'manage_options' ) || null !== AgentContext::current() ) { return true; } return new \WP_Error( 'rest_forbidden', __( 'You are not allowed to read this action.', 'agentic-daisy-ai-agent-firewall' ), array( 'status' => rest_authorization_required_code() ) ); } /** * Report whether a held action has been decided. * * Deliberately minimal: status, whether it executed, and when it expires. * The queued payload is not echoed back - the agent sent it and does not * need it returned, and a status endpoint should not become a way to read * stored input. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|\WP_Error */ public function get_status( $request ) { $id = (int) $request->get_param( 'id' ); $action = $this->pending->find( $id ); // An agent may only see its own. A missing action and someone else's // are the same answer, so ids cannot be enumerated by response code. $context = AgentContext::current(); if ( null === $action || ( ! current_user_can( 'manage_options' ) && ( null === $context || $context->agent->id !== $action->agent_id ) ) ) { return new \WP_Error( 'adaf_not_found', __( 'No such held action.', 'agentic-daisy-ai-agent-firewall' ), array( 'status' => 404 ) ); } return new WP_REST_Response( array( 'action_id' => $action->id, 'status' => $action->status, 'executed' => 'approved' === $action->status, 'expires_at' => $action->expires_at->format( 'Y-m-d H:i:s' ), 'decided_at' => null === $action->decided_at ? null : $action->decided_at->format( 'Y-m-d H:i:s' ), ), 200 ); } /** * Admin capability gate; never __return_true (checklist §1.14). * * @param WP_REST_Request $request Request. * @return true|\WP_Error */ public function permissions_check( $request ) { if ( current_user_can( 'manage_options' ) ) { return true; } return new \WP_Error( 'rest_forbidden', __( 'You are not allowed to manage pending agent actions.', 'agentic-daisy-ai-agent-firewall' ), array( 'status' => rest_authorization_required_code() ) ); } /** * List queued actions. * * @param WP_REST_Request $request Request. */ public function get_items( $request ): WP_REST_Response { $status = (string) ( $request->get_param( 'status' ) ?? 'pending' ); $per_page = max( 1, min( 100, (int) ( $request->get_param( 'per_page' ) ?? 20 ) ) ); $page = max( 1, (int) ( $request->get_param( 'page' ) ?? 1 ) ); $total = $this->pending->count_by_status( $status ); $items = array_map( array( $this, 'prepare_action' ), $this->pending->list_by_status( $status, $per_page, ( $page - 1 ) * $per_page ) ); $response = new WP_REST_Response( $items, 200 ); // Sent on every listing, paged or not: the queue depth is what the // tab badge and the menu bubble both read. $response->header( 'X-WP-Total', (string) $total ); $response->header( 'X-WP-TotalPages', (string) (int) ceil( $total / $per_page ) ); return $response; } /** * Collection query params with sanitize/validate callbacks (checklist §1.14). * * @return array> */ public function get_collection_params(): array { return array( 'status' => array( 'type' => 'string', 'default' => 'pending', 'enum' => array( 'pending', 'approved', 'rejected', 'expired', 'drift_conflict' ), 'sanitize_callback' => 'sanitize_key', 'validate_callback' => 'rest_validate_request_arg', ), 'page' => array( 'type' => 'integer', 'default' => 1, 'minimum' => 1, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ), 'per_page' => array( 'type' => 'integer', 'default' => 20, 'minimum' => 1, 'maximum' => 100, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ), ); } /** * Approve a pending action. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|\WP_Error */ public function approve_item( $request ) { $result = $this->service->approve( (int) $request->get_param( 'id' ), get_current_user_id(), new DateTimeImmutable( 'now' ) ); if ( $result instanceof \WP_Error ) { return $result; } return new WP_REST_Response( array( 'status' => 'approved', 'result' => $result, ), 200 ); } /** * Reject a pending action. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|\WP_Error */ public function reject_item( $request ) { $id = (int) $request->get_param( 'id' ); $existing = $this->pending->find( $id ); if ( null === $existing ) { return new \WP_Error( 'adaf_not_found', __( 'Pending action not found.', 'agentic-daisy-ai-agent-firewall' ), array( 'status' => 404 ) ); } if ( ! $this->service->reject( $id, get_current_user_id(), new DateTimeImmutable( 'now' ) ) ) { return new \WP_Error( 'adaf_not_pending', __( 'This action is not awaiting approval.', 'agentic-daisy-ai-agent-firewall' ), array( 'status' => 409 ) ); } return new WP_REST_Response( array( 'status' => 'rejected' ), 200 ); } /** * Serialize a pending action. * * @param PendingAction $action Pending action. * @return array */ private function prepare_action( PendingAction $action ): array { return array( 'id' => $action->id, 'ledger_id' => $action->ledger_id, 'agent_id' => $action->agent_id, 'ability_name' => $action->ability_name, 'stored_input' => $action->stored_input, 'initial_entity_modified_at' => $action->initial_entity_modified_at?->format( 'Y-m-d H:i:s' ), 'expires_at' => $action->expires_at->format( 'Y-m-d H:i:s' ), 'status' => $action->status, 'decided_by' => $action->decided_by, 'decided_at' => $action->decided_at?->format( 'Y-m-d H:i:s' ), ); } /** * Item schema. * * @return array */ public function get_item_schema(): array { return array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'adaf_pending_action', 'type' => 'object', 'properties' => array( 'id' => array( 'type' => 'integer' ), 'ledger_id' => array( 'type' => 'integer' ), 'agent_id' => array( 'type' => 'integer' ), 'ability_name' => array( 'type' => 'string' ), 'stored_input' => array( 'type' => 'object' ), 'initial_entity_modified_at' => array( 'type' => array( 'string', 'null' ) ), 'expires_at' => array( 'type' => 'string' ), 'status' => array( 'type' => 'string' ), 'decided_by' => array( 'type' => array( 'integer', 'null' ) ), 'decided_at' => array( 'type' => array( 'string', 'null' ) ), ), ); } }