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', ), // The administrator's confirmation that this agent // runs as an administrator (AF-25.15, ADR-18 §9): // the only way to link one to a user holding // manage_options. 'run_as_admin' => array( 'description' => 'Confirms the agent acts with full administrator rights. Required, and only allowed, when the linked user holds manage_options; the agent is then bound only by an extension.', 'type' => 'boolean', 'default' => false, 'sanitize_callback' => 'rest_sanitize_boolean', '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' ), 'args' => array( 'include_admins' => array( 'description' => 'Also list users holding manage_options, for an administrator-run agent (AF-25.15).', 'type' => 'boolean', 'default' => false, 'sanitize_callback' => 'rest_sanitize_boolean', 'validate_callback' => 'rest_validate_request_arg', ), ), ), ) ); 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). // // The one exception is a credential an extension binds that can only // ever run as an administrator, such as an in-site MCP plugin's key // (AF-25.15, ADR-18 §9). It needs an administrator's explicit // confirmation, and the agent then gets no firewall credential and // is guarded where no rule can loosen. $privileged = self::is_privileged( $wp_user_id ); $admin = self::is_administrator( $wp_user_id ); $run_as_admin = true === $request->get_param( 'run_as_admin' ); if ( $privileged && ! $run_as_admin ) { return new \WP_Error( 'adaf_privileged_user', __( 'Agents cannot be linked to an administrator, or to a user who could make themselves one by promoting users, editing an administrator, or installing or editing plugins. 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. Only a credential an add-on binds, which can only run as an administrator, may be linked to one, by confirming that the agent runs as an administrator.', 'agentic-daisy-ai-agent-firewall' ), array( 'status' => 400 ) ); } // A super administrator's rights reach every site on the network, so // only another super administrator may hand them to an agent. if ( $run_as_admin && is_multisite() && is_super_admin( $wp_user_id ) && ! is_super_admin() ) { return new \WP_Error( 'adaf_super_admin_link', __( 'This user is a network super administrator. Only a super administrator may link an agent to one, since the agent would act across every site on the network.', 'agentic-daisy-ai-agent-firewall' ), array( 'status' => 403 ) ); } // Running as an administrator means holding manage_options; a user // who could only reach it is refused above, never run as one. if ( $run_as_admin && ! $admin ) { return new \WP_Error( 'adaf_not_an_administrator', __( 'This user is not an administrator, so the agent does not run as one. Create it without confirming that it runs as an administrator.', '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, $run_as_admin ? get_current_user_id() : null ); if ( 0 === $agent->id ) { return new \WP_Error( 'adaf_agent_not_created', __( 'The agent could not be saved. If the plugin was just updated, reload the page so its database upgrade can finish, then try again.', 'agentic-daisy-ai-agent-firewall' ), array( 'status' => 500 ) ); } 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' ); $agent = $this->agents->find_by_id( $id ); if ( null === $agent ) { return $this->not_found(); } if ( $agent->runs_as_admin() ) { return self::no_admin_credential(); } $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 ); } /** * The refusal to give an administrator-run agent a firewall credential * (AF-25.15): the firewall never creates an administrator credential * itself, so only an extension binds such an agent. */ private static function no_admin_credential(): \WP_Error { return new \WP_Error( 'adaf_admin_agent_no_credential', __( 'This agent runs as an administrator, so it gets no firewall token or application password. Only the add-on whose credential it is binds it.', 'agentic-daisy-ai-agent-firewall' ), array( 'status' => 400 ) ); } /** * 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. Administrators are // listed, and marked, only when the screen offers an // administrator-run agent (AF-25.15). $admins = true === $request->get_param( 'include_admins' ); $query = array( 'orderby' => 'display_name', 'order' => 'ASC', 'number' => self::LINKABLE_LIMIT + 1, ); if ( ! $admins ) { $query['role__not_in'] = array( 'administrator' ); } $found = get_users( $query ); $truncated = count( $found ) > self::LINKABLE_LIMIT; $found = array_slice( $found, 0, self::LINKABLE_LIMIT ); $users = array(); foreach ( $found as $user ) { $id = (int) $user->ID; $privileged = self::is_privileged( $id ); // An administrator is listed only for an administrator-run agent; // a user who could merely reach manage_options, never. if ( $privileged && ! ( $admins && self::is_administrator( $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, 'administrator' => self::is_administrator( $id ), ); } // 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(); } if ( $agent->runs_as_admin() ) { return self::no_admin_credential(); } $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, or * could take it (Auth\Privilege, AF-26.2). * * Tests capabilities rather than the role name: a custom role or a * per-user grant carries them just as an administrator does, and they * decide what an agent could reach. * * @param int $wp_user_id WordPress user id. */ private static function is_privileged( int $wp_user_id ): bool { return \AgentFirewall\Auth\Privilege::user( $wp_user_id ); } /** * Whether a user holds manage_options itself: the only user an * administrator-run agent (AF-25.15) may act as. * * @param int $wp_user_id WordPress user id. */ private static function is_administrator( 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 ), // Confirmed by an administrator to run as one (AF-25.15): bound // only by an extension, and guarded where no rule can loosen. 'runs_as_admin' => $agent->runs_as_admin(), 'admin_confirmed_by' => $agent->admin_confirmed_by, 'admin_confirmed_at' => $agent->admin_confirmed_at?->format( 'Y-m-d H:i:s' ), ); } /** * 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: an administrator-run agent (AF-25.15), one linked before AF-12.14, or one whose user was promoted since.', 'type' => 'boolean', 'readonly' => true, ), 'runs_as_admin' => array( 'description' => 'Whether an administrator confirmed this agent runs as an administrator (AF-25.15).', 'type' => 'boolean', 'readonly' => true, ), 'admin_confirmed_by' => array( 'description' => 'The administrator who confirmed it, or null.', 'type' => array( 'integer', 'null' ), 'readonly' => true, ), 'admin_confirmed_at' => array( 'description' => 'When it was confirmed (UTC), or null.', 'type' => array( 'string', 'null' ), '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' ), ), ); } }