$postarr Post data. * @return bool */ public function guard_post_write( $maybe_empty, $postarr ) { $postarr = is_array( $postarr ) ? $postarr : array(); $id = isset( $postarr['ID'] ) && (int) $postarr['ID'] > 0 ? (int) $postarr['ID'] : null; $blocked = $this->blocked( null === $id ? 'create' : 'update', 'post', $id, array( 'hook' => 'wp_insert_post_empty_content', 'post_type' => $postarr['post_type'] ?? null, 'post_status' => $postarr['post_status'] ?? null, ) ); return $blocked ? true : $maybe_empty; } /** * Guard deletions. A non-null return short-circuits wp_delete_post(); * a value another filter already short-circuited with is not ours to * second-guess. * * @param mixed $delete Whether to go forward with deletion (null = proceed). * @param object $post Post being deleted. * @return mixed */ public function guard_post_delete( $delete, $post ) { if ( null !== $delete ) { return $delete; } $id = is_object( $post ) && isset( $post->ID ) ? (int) $post->ID : null; if ( $this->blocked( 'delete', 'post', $id, array( 'hook' => 'pre_delete_post' ) ) ) { return false; } return null; } /** * Guard option updates. Returning the old value turns the update into a * no-op. * * @param mixed $value New option value. * @param string $option Option name. * @param mixed $old_value Current option value. * @return mixed */ public function guard_option_update( $value, $option, $old_value ) { // Transients are cache churn, not governable state: evaluating them // would break any cache an agent request happens to warm (including // this plugin's own rate-limit and alert-throttle transients) and // spray the ledger with denials for writes nobody ordered. if ( str_starts_with( (string) $option, '_transient_' ) || str_starts_with( (string) $option, '_site_transient_' ) ) { return $value; } $blocked = $this->blocked( 'update', 'option', null, array( 'hook' => 'pre_update_option', 'option' => (string) $option, ) ); return $blocked ? $old_value : $value; } /** * Guard user inserts and updates. An empty return makes wp_insert_user() * fail with a WP_Error - unlike user_register, which fires only after * the row already exists. * * @param array $data User data about to be written. * @param bool $update Whether this is an update. * @param int|null $user_id User id on update, null on create. * @return array */ public function guard_user_write( $data, $update, $user_id ) { $blocked = $this->blocked( $update ? 'update' : 'create', 'user', null === $user_id ? null : (int) $user_id, array( 'hook' => 'wp_pre_insert_user_data', 'user_login' => is_array( $data ) && isset( $data['user_login'] ) ? (string) $data['user_login'] : null, ) ); return $blocked ? array() : $data; } /** * Guard role and capability grants, which are user-meta writes under the * hood ({prefix}capabilities, {prefix}user_level). A non-null return * short-circuits the meta update. Unrelated meta keys pass without * evaluation. * * @param mixed $check Short-circuit value (null = proceed). * @param int $object_id User id. * @param string $meta_key Meta key being written. * @param mixed $meta_value New value (unused; the grant itself is the action). * @return mixed */ public function guard_role_write( $check, $object_id, $meta_key, $meta_value ) { global $wpdb; if ( null !== $check ) { return $check; } $role_keys = array( $wpdb->prefix . 'capabilities', $wpdb->prefix . 'user_level' ); if ( ! in_array( (string) $meta_key, $role_keys, true ) ) { return $check; } $blocked = $this->blocked( 'update', 'role', (int) $object_id, array( 'hook' => 'update_user_metadata', 'meta_key' => (string) $meta_key, ) ); return $blocked ? false : $check; } /** * Evaluate an unclassified agent write; true means block it. * * The two early returns are the design (AF-13.3 acceptance criteria): * no AgentContext means this is ordinary site activity and none of our * business; a stamped marker means whichever transport classified the * request already evaluated it, and double-counting would duplicate * ledger rows and spend rate budget twice. Both checks are constant-time * so the sitewide hook cost stays within the < 1ms policy budget. * * Evaluation is unqueueable: the write is already in flight, so * REQUIRE_APPROVAL degrades to a denial inside the pipeline. * * @param string $category Action category. * @param string $entity Entity type. * @param int|null $entity_id Target entity id when known. * @param array $input Evidence payload (chokepoint hook included). */ private function blocked( string $category, string $entity, ?int $entity_id, array $input ): bool { $context = AgentContext::current(); if ( null === $context ) { return false; } if ( InterceptionPipeline::request_already_evaluated() ) { return false; } $outcome = $this->pipeline->process( $context->agent, new ActionDescriptor( 'unclassified', $category, $entity, $entity_id, null, $input ), null, false ); return ! $outcome->may_execute(); } }