namespace = 'agenticdaisy-firewall/v1'; $this->rest_base = 'ledger'; } /** * Register routes. */ public function register_routes(): void { 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, 'get_items_permissions_check' ), 'args' => $this->get_collection_params(), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( 'agenticdaisy-firewall/v1', '/' . $this->rest_base . '/retention', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_retention' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), ), array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'set_retention' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), 'args' => array( 'days' => array( 'description' => 'Days of history to keep; 0 keeps everything.', 'type' => 'integer', 'required' => true, 'minimum' => 0, 'maximum' => RetentionService::MAX_DAYS, 'validate_callback' => 'rest_validate_request_arg', ), ), ), ) ); } /** * Read the retention window. * * @param WP_REST_Request $request Request. */ public function get_retention( $request ): WP_REST_Response { return new WP_REST_Response( array( 'days' => $this->retention->retention_days(), 'max_days' => RetentionService::MAX_DAYS, ), 200 ); } /** * Set the retention window. * * Out-of-range values are refused by the arg schema rather than clamped * here: an operator who asked for fifty years of history should be told * the request was not honored, not left believing a number the plugin * quietly replaced. * * @param WP_REST_Request $request Request. */ public function set_retention( $request ): WP_REST_Response { $this->retention->set_retention_days( (int) $request->get_param( 'days' ) ); return $this->get_retention( $request ); } /** * Admin capability gate; never __return_true (checklist §1.14). * * @param WP_REST_Request $request Request. * @return true|\WP_Error */ public function get_items_permissions_check( $request ) { if ( current_user_can( 'manage_options' ) ) { return true; } return new \WP_Error( 'rest_forbidden', __( 'You are not allowed to view the agent ledger.', 'agentic-daisy-ai-agent-firewall' ), array( 'status' => rest_authorization_required_code() ) ); } /** * List ledger entries. * * @param WP_REST_Request $request Request. */ public function get_items( $request ): WP_REST_Response { $filters = array(); foreach ( array( 'agent_id', 'entity_id' ) as $int_filter ) { if ( null !== $request->get_param( $int_filter ) ) { $filters[ $int_filter ] = (int) $request->get_param( $int_filter ); } } foreach ( array( 'status', 'entity_type' ) as $string_filter ) { if ( null !== $request->get_param( $string_filter ) ) { $filters[ $string_filter ] = (string) $request->get_param( $string_filter ); } } // One parameter covers both "this policy" and "no policy at all": a // separate integer would make an empty value indistinguishable from a // request for entries the defaults decided, once absint has run. $decided_by = $request->get_param( 'decided_by' ); if ( is_string( $decided_by ) && '' !== $decided_by ) { if ( 'defaults' === $decided_by ) { $filters['default_decision'] = true; } else { $filters['policy_id'] = (int) $decided_by; } } foreach ( array( 'since', 'until' ) as $date_filter ) { $value = $request->get_param( $date_filter ); if ( is_string( $value ) && '' !== $value ) { $filters[ $date_filter ] = new DateTimeImmutable( $value ); } } $per_page = max( 1, min( 100, (int) ( $request->get_param( 'per_page' ) ?? 20 ) ) ); $page = max( 1, (int) ( $request->get_param( 'page' ) ?? 1 ) ); $total = $this->ledger->count( $filters ); $filters['limit'] = $per_page; $filters['offset'] = ( $page - 1 ) * $per_page; $entries = $this->ledger->query( $filters ); // Resolved for the whole page at once, before anything is serialized: // per-row lookups would turn a hundred-row page into a hundred // queries (AF-12.10). $current = $this->current_names( $entries ); $items = array_map( fn ( LedgerEntry $entry ): array => $this->prepare_entry( $entry, $current ), $entries ); $response = new WP_REST_Response( $items, 200 ); $response->header( 'X-WP-Total', (string) $total ); $response->header( 'X-WP-TotalPages', (string) (int) ceil( $total / $per_page ) ); return $response; } /** * Serialize a ledger entry for the API. * * @param LedgerEntry $entry Ledger entry. * @param array $current_names Current names by "type:id". * @return array */ private function prepare_entry( LedgerEntry $entry, array $current_names = array() ): array { $action = new ActionDescriptor( $entry->source, $entry->action_cat, $entry->entity_type, $entry->entity_id, $entry->ability_name, $entry->input ?? array() ); $current = null === $entry->entity_id ? null : ( $current_names[ $entry->entity_type . ':' . $entry->entity_id ] ?? null ); return array( 'id' => $entry->id, 'ts' => $entry->ts->format( 'Y-m-d H:i:s' ), 'agent_id' => $entry->agent_id, 'wp_user_id' => $entry->wp_user_id, 'source' => $entry->source, 'action_cat' => $entry->action_cat, 'entity_type' => $entry->entity_type, 'entity_id' => $entry->entity_id, // What it was called when the action was judged, and what it is // called now. Both, when they differ: somebody renamed it, and // that is worth seeing rather than hiding behind either one. 'entity_label' => $entry->entity_label, 'entity_current_label' => $current, // Offered only once the entity is known to exist, so the screen // never shows a link into a 404. 'entity_edit_link' => null === $current || null === $entry->entity_id || null === $this->labeler ? null : $this->labeler->edit_link( $entry->entity_type, $entry->entity_id ), 'ability_name' => $entry->ability_name, 'status' => $entry->status, 'input' => $entry->input, 'result' => $entry->result, // Which rule decided; null means the built-in defaults applied. 'policy_id' => $entry->policy_id, 'rule_index' => $entry->rule_index, 'undoable' => 'allowed' === $entry->status && $this->snapshots->is_undoable( $action ), ); } /** * Current names for every target on a page, keyed "type:id". * * @param list $entries Rows about to be serialized. * @return array */ private function current_names( array $entries ): array { if ( null === $this->labeler ) { return array(); } $by_type = array(); foreach ( $entries as $entry ) { if ( null === $entry->entity_id ) { continue; } $by_type[ $entry->entity_type ][] = $entry->entity_id; } $names = array(); foreach ( $by_type as $entity_type => $ids ) { foreach ( $this->labeler->current_names( (string) $entity_type, $ids ) as $id => $name ) { $names[ $entity_type . ':' . $id ] = $name; } } return $names; } /** * Collection query params with sanitize/validate callbacks (checklist §1.14). * * @return array> */ public function get_collection_params(): array { return array( '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', ), 'agent_id' => array( 'type' => 'integer', 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ), 'entity_id' => array( 'type' => 'integer', 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ), 'status' => array( 'type' => 'string', 'enum' => array( 'allowed', 'denied', 'pending', 'undone', 'untracked', 'refused', 'drifted' ), 'sanitize_callback' => 'sanitize_key', 'validate_callback' => 'rest_validate_request_arg', ), 'decided_by' => array( 'description' => 'Restrict to entries decided by a policy id, or by "defaults" for those no rule matched.', 'type' => 'string', 'pattern' => '^(defaults|[1-9][0-9]*)$', 'sanitize_callback' => 'sanitize_key', 'validate_callback' => 'rest_validate_request_arg', ), 'entity_type' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_key', 'validate_callback' => 'rest_validate_request_arg', ), 'since' => array( 'type' => 'string', 'format' => 'date-time', 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => 'rest_validate_request_arg', ), 'until' => array( 'type' => 'string', 'format' => 'date-time', 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => 'rest_validate_request_arg', ), ); } /** * Item schema. * * @return array */ public function get_item_schema(): array { return array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'adaf_ledger_entry', 'type' => 'object', 'properties' => array( 'id' => array( 'type' => 'integer' ), 'ts' => array( 'type' => 'string' ), 'agent_id' => array( 'type' => 'integer' ), 'wp_user_id' => array( 'type' => 'integer' ), 'source' => array( 'type' => 'string' ), 'action_cat' => array( 'type' => 'string' ), 'entity_type' => array( 'type' => 'string' ), 'entity_id' => array( 'type' => array( 'integer', 'null' ) ), 'ability_name' => array( 'type' => array( 'string', 'null' ) ), 'status' => array( 'type' => 'string' ), 'input' => array( 'type' => array( 'object', 'null' ) ), 'result' => array( 'type' => array( 'object', 'null' ) ), 'entity_label' => array( 'type' => array( 'string', 'null' ) ), 'entity_current_label' => array( 'type' => array( 'string', 'null' ) ), 'entity_edit_link' => array( 'type' => array( 'string', 'null' ) ), 'policy_id' => array( 'type' => array( 'integer', 'null' ) ), 'rule_index' => array( 'type' => array( 'integer', 'null' ) ), 'undoable' => array( 'type' => 'boolean' ), ), ); } }