$input Action input payload. * @param string|null $entity_slug Trailing path identifier for targets * WordPress names with a string rather * than a number - themes, plugins, * templates, widgets (AF-12.12). * Additive: policy matching ignores it. * @param bool|null $destructive What the ability declares about * itself through * `meta.annotations.destructive` * (AF-17.1). Three states: declared * destructive, declared not, and * null for undeclared - which is * never a declaration of safety. * @param array|null $replay The REST request as sent - * method, route and each * parameter source - so an * approval can re-send it * (AF-18.2). Set only by the * REST gatekeeper from the * request itself, never from * the agent's params. * @param string|null $route The REST route, for an * extension's route classifier * (AF-25.1). Set only by * from_rest(); policy matching * ignores it. * @param array $facts Facts extensions computed for * this action, keyed * `namespace.name`, values * scalars or lists of scalars * (AF-25.2). Computed from * store state by trusted code, * never taken from the agent. */ public function __construct( public readonly string $source, public readonly string $action_cat, public readonly string $entity_type, public readonly ?int $entity_id, public readonly ?string $ability_name, public readonly array $input, public readonly ?string $entity_slug = null, public readonly ?bool $destructive = null, public readonly ?array $replay = null, public readonly ?string $route = null, public readonly array $facts = array() ) { } /** * This descriptor carrying a replay envelope. * * @param array $replay Envelope built from the request line. */ public function with_replay( array $replay ): self { return new self( $this->source, $this->action_cat, $this->entity_type, $this->entity_id, $this->ability_name, $this->input, $this->entity_slug, $this->destructive, $replay, $this->route, $this->facts ); } /** * This action as a ledger row gives it back: source, category, target, * ability, input and facts, without the slug, the destructive * declaration, the replay envelope or the route. Entity providers are * handed this form when an action is held as when it is approved or * undone, so they read the same action each time (AF-25.4). */ public function as_stored(): self { return new self( $this->source, $this->action_cat, $this->entity_type, $this->entity_id, $this->ability_name, $this->input, null, null, null, null, $this->facts ); } /** * This descriptor carrying facts. * * @param array $facts Facts, keyed `namespace.name`. */ public function with_facts( array $facts ): self { return new self( $this->source, $this->action_cat, $this->entity_type, $this->entity_id, $this->ability_name, $this->input, $this->entity_slug, $this->destructive, $this->replay, $this->route, $facts ); } /** * Build a descriptor from a REST request. * * @param string $method HTTP method. * @param string $route REST route (e.g. /wp/v2/posts/10). * @param array $input Request parameters. */ public static function from_rest( string $method, string $route, array $input ): self { $entity_id = self::trailing_id( $route ); $entity = self::entity_from_route( $route ); switch ( strtoupper( $method ) ) { case 'DELETE': $category = 'delete'; break; case 'PUT': case 'PATCH': $category = 'update'; break; case 'POST': $category = self::targets_item( $route, $entity_id ) ? 'update' : 'create'; // Singleton routes have no per-item path at all; a POST to // them modifies existing state and must not be classified as // a create (BUG-2: /wp/v2/settings, where user rules keyed on // update/option silently missed the POST transport). if ( 'create' === $category && 'option' === $entity ) { $category = 'update'; } break; default: $category = 'read'; } return new self( 'rest', $category, $entity, $entity_id, null, $input, self::trailing_slug( $route ), null, null, $route ); } /** * Whether a route addresses one item rather than a collection. * * Core's wp/v2 item routes are not always numeric - plugins, themes, * sidebars and templates address items by slug or path - so for known * core routes any path beyond the collection name means an item. Other * namespaces keep the numeric-id heuristic; guessing at their shape * would misclassify more than it fixes. * * @param string $route REST route. * @param int|null $entity_id Trailing numeric id when present. */ private static function targets_item( string $route, ?int $entity_id ): bool { if ( null !== $entity_id ) { return true; } return 1 === preg_match( '#^/wp/v2/[a-z_-]+/.#i', $route ); } /** * Build a descriptor from an Abilities API invocation. * * @param string $ability_name Ability identifier (e.g. wp/delete_post). * @param array $input Ability input. */ public static function from_ability( string $ability_name, array $input ): self { list( $category, $entity ) = self::parse_verb_noun( $ability_name ); $annotations = self::annotations_for( $ability_name ); $category = self::apply_readonly( $category, $annotations['readonly'] ); return new self( 'ability', $category, $entity, self::extract_entity_id( $entity, $input ), $ability_name, $input, null, $annotations['destructive'] ); } /** * What an ability declares about itself. * * Core standardized `meta.annotations` in the Abilities API - `readonly`, * `destructive` and `idempotent`, each bool|null - and it is present on * 6.9 as well as 7.1, so the whole supported floor is covered without a * version guard. A declaration beats this plugin's reading of the name: * the name is a guess, the annotation is the author's statement. * * Anything unresolvable - the registry not loaded, the ability not * registered, a malformed annotations array - returns nulls and leaves * classification to the name, because a lookup failure must never be * louder than a missing declaration. * * @param string $ability_name Namespaced ability name. * @return array{readonly: bool|null, destructive: bool|null} */ private static function annotations_for( string $ability_name ): array { $undeclared = array( 'readonly' => null, 'destructive' => null, ); // Ask whether it is registered before asking for it. Core reports a // lookup of an unregistered name as incorrect usage, and the // unregistered name is the ordinary fallback path here rather than a // mistake - fetching first would fill a WP_DEBUG log with notices for // every action whose ability core does not know. Both calls are // unguarded, per ADR-3. if ( ! wp_has_ability( $ability_name ) ) { return $undeclared; } $annotations = wp_get_ability( $ability_name )?->get_meta_item( 'annotations' ); if ( ! is_array( $annotations ) ) { return $undeclared; } return array( 'readonly' => isset( $annotations['readonly'] ) && is_bool( $annotations['readonly'] ) ? $annotations['readonly'] : null, 'destructive' => isset( $annotations['destructive'] ) && is_bool( $annotations['destructive'] ) ? $annotations['destructive'] : null, ); } /** * Reconcile the name-derived category with a `readonly` declaration. * * Declared read-only wins outright: that is what stops a store query * sitting in the approval queue. The opposite conflict matters more - * an ability named like a read that declares itself *not* read-only does * write something, and calling it a read would ALLOW it. The name's * category is discarded there and the action falls to unknown, which the * built-in defaults hold. * * @param string $category Category parsed from the name. * @param bool|null $readonly Declared readonly annotation. */ private static function apply_readonly( string $category, ?bool $readonly ): string { if ( true === $readonly ) { return 'read'; } if ( false === $readonly && 'read' === $category ) { return 'unknown'; } return $category; } /** * JSON-RPC methods that read server state. Everything outside this list * that is not a tools/call classifies unknown and fails safe: an * unrecognized method reaching a mutation is exactly what must not be * waved through. */ private const MCP_READ_METHODS = array( 'initialize', 'ping', 'tools/list', 'prompts/list', 'prompts/get', 'resources/list', 'resources/read', 'resources/templates/list', 'completion/complete', ); /** * Build a descriptor from one decoded JSON-RPC call in an MCP envelope * (AF-13.1). The HTTP verb is always POST and the route never varies, so * classification must come from the call itself: protocol plumbing is a * read, tools/call parses its tool name through the same verb-noun * convention as abilities, and anything unparseable stays unknown, which * the built-in defaults hold for approval. * * @param array $call Decoded JSON-RPC request object. */ public static function from_mcp( array $call ): self { $method = isset( $call['method'] ) && is_string( $call['method'] ) ? $call['method'] : ''; if ( in_array( $method, self::MCP_READ_METHODS, true ) || str_starts_with( $method, 'notifications/' ) ) { return new self( 'mcp', 'read', 'unknown', null, null, array() ); } if ( 'tools/call' !== $method ) { // The unparsed call rides along as input so the ledger row holds // the evidence even when classification could not. return new self( 'mcp', 'unknown', 'unknown', null, null, $call ); } $params = isset( $call['params'] ) && is_array( $call['params'] ) ? $call['params'] : array(); $name = isset( $params['name'] ) && is_string( $params['name'] ) ? $params['name'] : null; $arguments = isset( $params['arguments'] ) && is_array( $params['arguments'] ) ? $params['arguments'] : array(); list( $category, $entity ) = null === $name ? array( 'unknown', 'unknown' ) : self::parse_verb_noun( $name ); return new self( 'mcp', $category, $entity, self::extract_entity_id( $entity, $arguments ), $name, $arguments ); } /** * Parse a tool or ability name into its action category and entity noun. * * Core enforces hyphenated ability names (adaf/update-post); the PRD's * underscore examples and MCP tool names (delete_event) parse the same. * * @param string $name Namespaced or bare verb-noun name. * @return array{string, string} Category and entity, unknown when unparseable. */ private static function parse_verb_noun( string $name ): array { $local = str_contains( $name, '/' ) ? substr( $name, (int) strrpos( $name, '/' ) + 1 ) : $name; $split = preg_split( '/[-_]+/', $local ); $tokens = is_array( $split ) ? $split : array(); foreach ( $tokens as $position => $token ) { $category = self::verb_category( (string) $token ); if ( 'unknown' === $category ) { continue; } if ( 0 === $position ) { // Verb-first (delete-post): the rest names the target, and // is left exactly as 1.7.0 produced it - including plurals, // because entity_type rules on live sites were written // against those. $entity = implode( '_', array_slice( $tokens, 1 ) ); return array( $category, '' === $entity ? 'unknown' : $entity ); } // Noun-first (product-delete) or infix (order-update-status): // what precedes the verb names the target. return array( $category, self::singular( implode( '_', array_slice( $tokens, 0, $position ) ) ) ); } return array( 'unknown', 'unknown' ); } /** * Singularize a noun-first target so one rule covers a plural query and * its singular writes: entity_type "product" should match both * products-query and product-delete. * * Deliberately narrow. Only a trailing "s" is dropped, and never after * ss/us/is, so address, status and analysis survive intact. Anything * cleverer would need a dictionary and would mint entity types nobody * could predict when writing a rule. * * @param string $noun Target noun as it appeared in the name. */ private static function singular( string $noun ): string { if ( strlen( $noun ) > 3 && str_ends_with( $noun, 's' ) && 1 !== preg_match( '/(ss|us|is)$/', $noun ) ) { return substr( $noun, 0, -1 ); } return $noun; } /** * Extract a target entity id from conventional input keys. * * @param string $entity Parsed entity noun. * @param array $input Action input payload. */ private static function extract_entity_id( string $entity, array $input ): ?int { foreach ( array( $entity . '_id', 'post_id', 'id' ) as $key ) { if ( isset( $input[ $key ] ) && is_numeric( $input[ $key ] ) ) { return (int) $input[ $key ]; } } return null; } /** * Ability verb → action category. Keys mirror the verb alternation in * from_ability()'s regex, so every captured verb resolves. */ private const VERB_MAP = array( 'create' => 'create', 'add' => 'create', 'insert' => 'create', 'update' => 'update', 'edit' => 'update', 'set' => 'update', 'delete' => 'delete', 'remove' => 'delete', 'trash' => 'delete', 'get' => 'read', 'list' => 'read', 'read' => 'read', 'query' => 'read', ); /** * Map an ability verb to an action category. * * @param string $verb Leading verb of the ability's local name. */ private static function verb_category( string $verb ): string { return self::VERB_MAP[ $verb ] ?? 'unknown'; } /** * Classify the target entity from a core REST route. * * The firewall's own routes are `firewall` (AF-24.1). They are closed to * agents and never evaluated, so no policy sees this; it labels the * refusal recorded when an agent tries one, in any casing, as WordPress * matches routes. * * @param string $route REST route. */ private static function entity_from_route( string $route ): string { if ( 1 === preg_match( '#^/agenticdaisy-firewall/#i', $route ) ) { return 'firewall'; } // Case-insensitive, as WordPress matches routes: /WP/v2/Users // reaches the users controller and must be typed as a user write. if ( 1 !== preg_match( '#^/wp/v2/([a-z_-]+)#i', $route, $matches ) ) { return 'unknown'; } switch ( strtolower( $matches[1] ) ) { case 'posts': return 'post'; case 'pages': return 'page'; case 'media': return 'media'; case 'users': return 'user'; case 'comments': return 'comment'; case 'categories': case 'tags': return 'term'; case 'settings': return 'option'; // Site-editor and appearance routes (BUG-3): mapped so policy // rules can target them; anything still unmapped stays unknown // and is held by the built-in defaults. case 'global-styles': return 'style'; case 'templates': case 'template-parts': return 'template'; case 'navigation': return 'navigation'; case 'menus': return 'menu'; case 'menu-items': return 'menu_item'; case 'widgets': case 'sidebars': return 'widget'; case 'blocks': return 'block'; case 'plugins': return 'plugin'; case 'themes': return 'theme'; default: return 'unknown'; } } /** * Extract a trailing numeric id from a route. * * @param string $route REST route. */ private static function trailing_id( string $route ): ?int { if ( 1 === preg_match( '#/(\d+)/?$#', $route, $matches ) ) { return (int) $matches[1]; } return null; } /** * The string identifier a route addresses, when it has one. * * Everything after the collection segment, kept whole: plugin files * (`akismet/akismet`) and template ids (`mytheme//home`) contain slashes, * and truncating at the first one would name a different plugin, or none. * Numeric routes return null - `entity_id` already carries those. * * @param string $route REST route. */ private static function trailing_slug( string $route ): ?string { if ( 1 !== preg_match( '#^/wp/v2/[a-z_-]+/(.+?)/?$#i', $route, $matches ) ) { return null; } $slug = $matches[1]; return preg_match( '#^\d+$#', $slug ) ? null : $slug; } }