namespace = 'agenticdaisy-firewall/v1'; $this->rest_base = 'agents'; } /** * 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, 'permissions_check' ), ), array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_item' ), 'permission_callback' => array( $this, 'permissions_check' ), 'args' => array( 'name' => array( 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => 'rest_validate_request_arg', ), 'agent_slug' => array( 'description' => 'Stable, unique, URL-safe identifier for this agent.', 'type' => 'string', 'pattern' => '^[a-z0-9-]+$', 'maxLength' => 64, 'sanitize_callback' => 'sanitize_key', 'validate_callback' => 'rest_validate_request_arg', ), // The name this field had in every release from 1.0.0. // Still accepted, so anything already scripted against // the API keeps working: this renames what the field is // called, not what it does. Neither may be marked // required, or supplying the other one would fail. 'agent_key' => array( 'description' => 'Deprecated alias of agent_slug.', 'type' => 'string', 'pattern' => '^[a-z0-9-]+$', 'maxLength' => 64, 'sanitize_callback' => 'sanitize_key', 'validate_callback' => 'rest_validate_request_arg', ), 'wp_user_id' => array( 'type' => 'integer', 'required' => true, 'minimum' => 1, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ), 'scopes' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', ), ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( 'agenticdaisy-firewall/v1', '/' . $this->rest_base . '/linkable-users', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_linkable_users' ), 'permission_callback' => array( $this, 'permissions_check' ), ), ) ); register_rest_route( 'agenticdaisy-firewall/v1', '/' . $this->rest_base . '/(?P\d+)', array( array( 'methods' => 'PUT', 'callback' => array( $this, 'update_item' ), 'permission_callback' => array( $this, 'permissions_check' ), 'args' => array( 'status' => array( 'type' => 'string', 'required' => true, 'enum' => array( 'active', 'disabled' ), 'sanitize_callback' => 'sanitize_key', 'validate_callback' => 'rest_validate_request_arg', ), ), ), ) ); register_rest_route( 'agenticdaisy-firewall/v1', '/' . $this->rest_base . '/(?P\d+)/application-passwords', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_application_passwords' ), 'permission_callback' => array( $this, 'permissions_check' ), ), ) ); register_rest_route( 'agenticdaisy-firewall/v1', '/' . $this->rest_base . '/(?P\d+)/application-password', array( array( 'methods' => 'PUT', 'callback' => array( $this, 'bind_application_password' ), 'permission_callback' => array( $this, 'permissions_check' ), 'args' => array( 'uuid' => array( 'description' => 'Uuid of an application password belonging to the agent\'s linked user.', 'type' => 'string', 'required' => true, 'pattern' => '^[0-9a-fA-F-]{36}$', 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => 'rest_validate_request_arg', ), ), ), array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'unbind_application_password' ), 'permission_callback' => array( $this, 'permissions_check' ), ), ) ); register_rest_route( 'agenticdaisy-firewall/v1', '/' . $this->rest_base . '/(?P\d+)/token', array( array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'issue_token' ), 'permission_callback' => array( $this, 'permissions_check' ), ), array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'revoke_token' ), 'permission_callback' => array( $this, 'permissions_check' ), ), ) ); } /** * 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 agents.', 'agentic-daisy-ai-agent-firewall' ), array( 'status' => rest_authorization_required_code() ) ); } /** * List agents. * * @param WP_REST_Request $request Request. */ public function get_items( $request ): WP_REST_Response { return new WP_REST_Response( array_map( array( $this, 'prepare_agent' ), $this->agents->all() ), 200 ); } /** * Create an agent. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|\WP_Error */ public function create_item( $request ) { $agent_key = (string) ( $request->get_param( 'agent_slug' ) ?? '' ); $wp_user_id = (int) $request->get_param( 'wp_user_id' ); if ( '' === $agent_key ) { $agent_key = (string) ( $request->get_param( 'agent_key' ) ?? '' ); } // Enforced here rather than by the schema: `required` on either field // would reject a request that correctly supplied the other one. if ( '' === $agent_key ) { return new \WP_Error( 'adaf_missing_slug', __( 'An agent slug is required.', 'agentic-daisy-ai-agent-firewall' ), array( 'status' => 400 ) ); } if ( null !== $this->agents->find_by_key( $agent_key ) ) { return new \WP_Error( 'adaf_agent_key_taken', __( 'An agent with this slug already exists.', 'agentic-daisy-ai-agent-firewall' ), array( 'status' => 409 ) ); } if ( ! get_user_by( 'id', $wp_user_id ) ) { return new \WP_Error( 'adaf_invalid_user', __( 'The linked WordPress user does not exist.', 'agentic-daisy-ai-agent-firewall' ), array( 'status' => 400 ) ); } // An agent inherits its user's capabilities, so linking one to an // administrator hands it manage_options - the capability governing // this plugin. Refused outright rather than warned about: a security // tool that documents the safe choice but accepts the unsafe one has // only moved the problem into the release notes (AF-12.14). if ( self::is_privileged( $wp_user_id ) ) { return new \WP_Error( 'adaf_privileged_user', __( 'Agents cannot be linked to an administrator. An agent inherits its user\'s capabilities, which would let it manage the firewall itself. Link it to a user with only the capabilities the agent needs - if none exists yet, create one first.', 'agentic-daisy-ai-agent-firewall' ), array( 'status' => 400 ) ); } $scopes = $request->get_param( 'scopes' ); $scopes = is_array( $scopes ) ? array_values( array_map( 'sanitize_text_field', array_map( 'strval', $scopes ) ) ) : array(); $agent = $this->agents->create( $agent_key, (string) $request->get_param( 'name' ), $wp_user_id, null, new DateTimeImmutable( 'now' ), $scopes ); return new WP_REST_Response( $this->prepare_agent( $agent ), 201 ); } /** * Update an agent's status. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|\WP_Error */ public function update_item( $request ) { $id = (int) $request->get_param( 'id' ); if ( null === $this->agents->find_by_id( $id ) ) { return $this->not_found(); } $this->agents->update_status( $id, (string) $request->get_param( 'status' ) ); $updated = $this->agents->find_by_id( $id ); assert( $updated instanceof Agent ); return new WP_REST_Response( $this->prepare_agent( $updated ), 200 ); } /** * Issue (or rotate) the agent's token; plaintext returned exactly once. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|\WP_Error */ public function issue_token( $request ) { $id = (int) $request->get_param( 'id' ); if ( null === $this->agents->find_by_id( $id ) ) { return $this->not_found(); } $token = $this->tokens->issue( $id ); return new WP_REST_Response( array( 'token' => $token, 'message' => __( 'Store this token now; it cannot be shown again.', 'agentic-daisy-ai-agent-firewall' ), ), 201 ); } /** * Revoke the agent's token. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|\WP_Error */ public function revoke_token( $request ) { $id = (int) $request->get_param( 'id' ); if ( null === $this->agents->find_by_id( $id ) ) { return $this->not_found(); } $this->tokens->revoke( $id ); return new WP_REST_Response( array( 'revoked' => true ), 200 ); } /** * 404 helper. */ private function not_found(): \WP_Error { return new \WP_Error( 'adaf_not_found', __( 'Agent not found.', 'agentic-daisy-ai-agent-firewall' ), array( 'status' => 404 ) ); } /** * Users an agent may be linked to. * * Exists so the administrator refusal in create_item() is unreachable * through the UI rather than merely explained by it: asking a human for a * numeric user id was already a poor question - WordPress surfaces those * almost nowhere - and it became an unanswerable one once some ids started * being refused (AF-12.16). * * @param WP_REST_Request $request Request. */ public function get_linkable_users( $request ): WP_REST_Response { // role__not_in trims the common case in SQL; the capability test below // then catches a custom role or a per-user grant, which is what // creation actually enforces. Filtering on the role name alone would // offer users that creation goes on to refuse. $found = get_users( array( 'role__not_in' => array( 'administrator' ), 'orderby' => 'display_name', 'order' => 'ASC', 'number' => self::LINKABLE_LIMIT + 1, ) ); $truncated = count( $found ) > self::LINKABLE_LIMIT; $found = array_slice( $found, 0, self::LINKABLE_LIMIT ); $users = array(); foreach ( $found as $user ) { $id = (int) $user->ID; if ( self::is_privileged( $id ) ) { continue; } $roles = array_values( (array) $user->roles ); $slug = isset( $roles[0] ) ? (string) $roles[0] : ''; $users[] = array( // display_name is what an administrator recognizes; the login // is the fallback for a user who has never set one. 'name' => '' !== (string) $user->display_name ? (string) $user->display_name : (string) $user->user_login, 'id' => $id, 'role' => $slug, 'role_name' => self::role_name( $slug ), 'login' => (string) $user->user_login, ); } // Reported rather than silent: a list that quietly stops short looks // identical to a site that simply has no other users, and the one // user somebody is looking for would be missing with no explanation. return new WP_REST_Response( array( 'users' => $users, 'truncated' => $truncated, ), 200 ); } /** * List the linked user's application passwords for the picker (AF-13.2). * * Marks the ones another agent already claims rather than hiding them: * a password missing from the list with no explanation looks identical * to one that was never created, and the administrator would go hunting * in the user profile for something that is there. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|\WP_Error */ public function get_application_passwords( $request ) { $agent = $this->agents->find_by_id( (int) $request->get_param( 'id' ) ); if ( null === $agent ) { return $this->not_found(); } $claims = array(); foreach ( $this->agents->all() as $other ) { if ( null !== $other->app_password_uuid && $other->id !== $agent->id ) { $claims[ $other->app_password_uuid ] = $other->name; } } $passwords = array(); foreach ( self::user_application_passwords( $agent->wp_user_id ) as $item ) { $uuid = (string) ( $item['uuid'] ?? '' ); if ( '' === $uuid ) { continue; } $passwords[] = array( 'uuid' => $uuid, 'name' => (string) ( $item['name'] ?? '' ), 'created' => isset( $item['created'] ) ? (int) $item['created'] : null, // isset() already excludes the null core stores for a password // that has never authenticated anything. 'last_used' => isset( $item['last_used'] ) ? (int) $item['last_used'] : null, // The agent's own binding reads as free: the picker has to // offer the current selection as selectable. 'bound_to' => $claims[ $uuid ] ?? null, ); } return new WP_REST_Response( array( 'passwords' => $passwords, 'user' => self::prepare_user( $agent->wp_user_id ), ), 200 ); } /** * Bind an application password to this agent (AF-13.2). * * @param WP_REST_Request $request Request. * @return WP_REST_Response|\WP_Error */ public function bind_application_password( $request ) { $id = (int) $request->get_param( 'id' ); $agent = $this->agents->find_by_id( $id ); if ( null === $agent ) { return $this->not_found(); } $uuid = (string) $request->get_param( 'uuid' ); // Ownership is checked against the linked user's own passwords, not // merely that the uuid exists somewhere: an agent must never answer // to a credential belonging to a user whose capabilities it does not // have. The authentication side refuses the mismatch too, but a // binding that could only ever fail should not be storable. if ( ! self::user_owns_application_password( $agent->wp_user_id, $uuid ) ) { return new \WP_Error( 'adaf_password_not_owned', __( 'That application password does not belong to this agent\'s linked user. Create one on that user\'s profile, then bind it here.', 'agentic-daisy-ai-agent-firewall' ), array( 'status' => 400 ) ); } $holder = $this->agents->find_by_app_password_uuid( $uuid ); if ( null !== $holder && $holder->id !== $agent->id ) { return new \WP_Error( 'adaf_password_taken', sprintf( /* translators: %s: agent name. */ __( 'That application password is already bound to the agent "%s". One password identifies one agent; unbind it there first, or create a second password.', 'agentic-daisy-ai-agent-firewall' ), $holder->name ), array( 'status' => 409 ) ); } $this->agents->update_app_password_uuid( $id, $uuid ); return $this->reread( $id ); } /** * Unbind whatever application password this agent holds. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|\WP_Error */ public function unbind_application_password( $request ) { $id = (int) $request->get_param( 'id' ); if ( null === $this->agents->find_by_id( $id ) ) { return $this->not_found(); } $this->agents->update_app_password_uuid( $id, null ); return $this->reread( $id ); } /** * Re-read and serialize an agent after a write. * * @param int $id Agent id. */ private function reread( int $id ): WP_REST_Response { $updated = $this->agents->find_by_id( $id ); assert( $updated instanceof Agent ); return new WP_REST_Response( $this->prepare_agent( $updated ), 200 ); } /** * A user's application passwords, or an empty list where the feature is * unavailable (it can be disabled site-wide, and over plain HTTP core * refuses to issue them at all). * * @param int $wp_user_id WordPress user id. * @return list> */ private static function user_application_passwords( int $wp_user_id ): array { if ( ! class_exists( '\WP_Application_Passwords' ) ) { return array(); } $items = \WP_Application_Passwords::get_user_application_passwords( $wp_user_id ); return is_array( $items ) ? array_values( $items ) : array(); } /** * Whether a uuid names one of this user's application passwords. * * @param int $wp_user_id WordPress user id. * @param string $uuid Application password uuid. */ private static function user_owns_application_password( int $wp_user_id, string $uuid ): bool { foreach ( self::user_application_passwords( $wp_user_id ) as $item ) { if ( isset( $item['uuid'] ) && hash_equals( (string) $item['uuid'], $uuid ) ) { return true; } } return false; } /** * Resolve an agent's bound application password for display. * * @param Agent $agent Agent. * @return array|null */ private static function prepare_application_password( Agent $agent ): ?array { if ( null === $agent->app_password_uuid ) { return null; } foreach ( self::user_application_passwords( $agent->wp_user_id ) as $item ) { if ( isset( $item['uuid'] ) && (string) $item['uuid'] === $agent->app_password_uuid ) { return array( 'uuid' => $agent->app_password_uuid, 'name' => (string) ( $item['name'] ?? '' ), 'exists' => true, ); } } // Revoked on the profile screen, which this plugin cannot prevent. // Reported rather than hidden: the agent still holds a binding that // no longer authenticates anything, and only a read can surface it. return array( 'uuid' => $agent->app_password_uuid, 'name' => '', 'exists' => false, ); } /** * Whether a user holds the capability that governs this plugin. * * Tests the capability rather than the role name: a custom role or a * per-user grant carries manage_options just as an administrator does, * and it is the capability that decides what an agent could reach. * * @param int $wp_user_id WordPress user id. */ private static function is_privileged( int $wp_user_id ): bool { return user_can( $wp_user_id, 'manage_options' ); } /** * Serialize an agent. Never exposes the token or its hash. * * @param Agent $agent Agent. * @return array */ private function prepare_agent( Agent $agent ): array { return array( 'id' => $agent->id, 'agent_slug' => $agent->agent_key, // Kept alongside agent_slug so a caller reading the old name is // not broken by the rename. The column is still agent_key: it is // implementation detail, and renaming it would cost a migration // for no one's benefit. 'agent_key' => $agent->agent_key, 'name' => $agent->name, 'wp_user_id' => $agent->wp_user_id, 'status' => $agent->status, 'has_token' => null !== $agent->token_hash, 'scopes' => $agent->scopes, 'created_at' => $agent->created_at->format( 'Y-m-d H:i:s' ), // Reported rather than assumed false: creation refuses privileged // users now, but a site upgrading into the rule can still hold an // agent linked to one, and only a read can surface it. 'privileged' => self::is_privileged( $agent->wp_user_id ), 'user' => self::prepare_user( $agent->wp_user_id ), // Null for the ordinary agent, which answers to a bearer token // alone. Present when a client that can only send Basic auth has // been given an identity to arrive with (AF-13.2). 'app_password' => self::prepare_application_password( $agent ), ); } /** * A role's name, as WordPress shows it everywhere else (AF-16.9). * * The slug is what the plugin stores and compares; this is what a person * reads. Core roles hide the difference - "editor" is close enough to * "Editor" that nobody notices - but a custom role prints as * `content_manager`, or as whatever vendor prefix its author chose, in a * column sitting next to "Editor". * * `wp_roles()` and `translate_user_role()` both live in wp-includes, so * neither needs an admin include on a REST request. * * The fallback to the slug is a guard, not a live path: WP_User keeps only * the capability keys WP_Roles::is_role() accepts, so a role whose plugin * has gone is dropped from $user->roles entirely rather than surviving as * an unregistered slug. It stays because a future caller passing a slug * from anywhere else should get something readable back, not an empty * string. * * @param string $slug Role slug. */ private static function role_name( string $slug ): string { if ( '' === $slug ) { return ''; } $names = wp_roles()->role_names; return isset( $names[ $slug ] ) && is_string( $names[ $slug ] ) ? translate_user_role( $names[ $slug ] ) : $slug; } /** * Resolve the linked user for display. * * The id alone identifies nobody: WordPress surfaces user ids almost * nowhere, so a column reading "#2" asked the reader to go and look it up * (AF-12.19). The role travels with the name because on this screen * "which user" and "what may this agent do" are the same question - the * linked user's capabilities are the agent's ceiling. * * @param int $wp_user_id WordPress user id. * @return array */ private static function prepare_user( int $wp_user_id ): array { $user = get_userdata( $wp_user_id ); // A deleted user leaves the agent linked to nothing, which is a broken // agent. The id is all that survives, so it is all that is reported - // the screen says so rather than printing it as if it still resolved. if ( ! $user instanceof \WP_User ) { return array( 'id' => $wp_user_id, 'name' => '', 'login' => '', 'role' => '', 'role_name' => '', 'edit_link' => null, 'exists' => false, ); } $roles = array_values( (array) $user->roles ); // get_edit_user_link() applies the edit_user capability itself and // returns an empty string when it fails: this screen demands // manage_options, which a custom role can hold without edit_users. $edit_link = (string) get_edit_user_link( $wp_user_id ); return array( 'id' => $wp_user_id, // display_name is what an administrator recognizes; the login is // the fallback for a user who has never had one set. 'name' => '' !== (string) $user->display_name ? (string) $user->display_name : (string) $user->user_login, 'login' => (string) $user->user_login, 'role' => isset( $roles[0] ) ? (string) $roles[0] : '', 'role_name' => self::role_name( isset( $roles[0] ) ? (string) $roles[0] : '' ), 'edit_link' => '' !== $edit_link ? $edit_link : null, 'exists' => true, ); } /** * Item schema. * * @return array */ public function get_item_schema(): array { return array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'adaf_agent', 'type' => 'object', 'properties' => array( 'id' => array( 'type' => 'integer' ), 'agent_slug' => array( 'type' => 'string' ), 'agent_key' => array( 'description' => 'Deprecated alias of agent_slug, carried for callers written before the rename.', 'type' => 'string', ), 'name' => array( 'type' => 'string' ), 'wp_user_id' => array( 'type' => 'integer' ), 'app_password' => array( 'description' => 'The application password bound to this agent, or null. A false exists flag means the binding survives a password that has been revoked.', 'type' => array( 'object', 'null' ), 'readonly' => true, 'properties' => array( 'uuid' => array( 'type' => 'string' ), 'name' => array( 'type' => 'string' ), 'exists' => array( 'type' => 'boolean' ), ), ), 'privileged' => array( 'description' => 'Whether the linked user holds manage_options. Always false for agents created after AF-12.14; true only for one linked before the rule existed.', 'type' => 'boolean', 'readonly' => true, ), 'user' => array( 'description' => 'The linked WordPress user, resolved for display. Absent name and a false exists flag mean the user has been deleted.', 'type' => 'object', 'readonly' => true, 'properties' => array( 'id' => array( 'type' => 'integer' ), 'name' => array( 'type' => 'string' ), 'login' => array( 'type' => 'string' ), 'role' => array( 'type' => 'string' ), 'role_name' => array( 'description' => "The role's display name; the slug when no role by that name is registered.", 'type' => 'string', ), 'edit_link' => array( 'type' => array( 'string', 'null' ) ), 'exists' => array( 'type' => 'boolean' ), ), ), 'status' => array( 'type' => 'string' ), 'has_token' => array( 'type' => 'boolean' ), 'scopes' => array( 'type' => 'array' ), 'created_at' => array( 'type' => 'string' ), ), ); } }