diff --git a/wordpress-plugin/gk-block-mcp/gk-block-mcp.php b/wordpress-plugin/gk-block-mcp/gk-block-mcp.php index 925294a..f09a4a2 100644 --- a/wordpress-plugin/gk-block-mcp/gk-block-mcp.php +++ b/wordpress-plugin/gk-block-mcp/gk-block-mcp.php @@ -335,6 +335,11 @@ function init_agent() { // (priority 20) so it intercepts both wrong-password WP_Error results and // correctly-authenticated WP_User objects for the service account. add_filter( 'authenticate', array( __NAMESPACE__ . '\\Agent_Provisioner', 'block_agent_login' ), 30, 3 ); + // register_role() derives Agent_Provisioner::TEMPLATE_EDIT_CAP from this + // toggle; re-assert on save so grant/revoke is immediate rather than + // waiting for the next `init`. register_role() takes no required args, + // so WordPress's extra ($old_value) argument here is simply unused. + add_action( 'update_option_' . \GravityKit\BlockMCP\Template_Manager::ALLOW_TEMPLATE_EDITS_OPTION, array( __NAMESPACE__ . '\\Agent_Provisioner', 'register_role' ) ); } add_action( 'plugins_loaded', __NAMESPACE__ . '\\init_agent' ); diff --git a/wordpress-plugin/gk-block-mcp/includes/class-agent-provisioner.php b/wordpress-plugin/gk-block-mcp/includes/class-agent-provisioner.php index b6d1897..5365ab3 100644 --- a/wordpress-plugin/gk-block-mcp/includes/class-agent-provisioner.php +++ b/wordpress-plugin/gk-block-mcp/includes/class-agent-provisioner.php @@ -69,6 +69,22 @@ class Agent_Provisioner { */ const USER_ID_OPTION = 'gk_block_api_agent_user_id'; + /** + * Plugin-owned primitive cap that gates template writes (POST /template, + * POST /template/reset), managed here rather than granted via core's + * `edit_theme_options` — that cap also opens core's own + * `/wp/v2/templates`, `/wp/v2/template-parts`, `/wp/v2/navigation`, + * `/wp/v2/global-styles`, the Customizer, menus, and widgets, none of + * which the agent should ever reach. Deliberately NOT in + * forbidden_capabilities() — that denylist exists to strip caps this + * class does not grant; this one it grants and revokes on purpose, + * following the site's `gk_block_api_template_edits` toggle. + * + * @since 2.2.0 + * @var string + */ + const TEMPLATE_EDIT_CAP = 'gk_block_mcp_edit_templates'; + /** * Register the minimal block_mcp_agent role idempotently. * @@ -104,10 +120,12 @@ public static function register_role(): string { * * @param array $caps Map of capability name => granted, for the agent role. */ - $caps = apply_filters( - 'gk/block-mcp/agent/caps', - self::derive_capabilities() - ); + $caps = self::derive_capabilities(); + // The one entry in this map that register_role() below both adds AND + // removes on an existing role, tracking the toggle live rather than + // whatever was true when the role was first created. + $caps[ self::TEMPLATE_EDIT_CAP ] = Template_Manager::edits_enabled(); + $caps = apply_filters( 'gk/block-mcp/agent/caps', $caps ); /** * Run the AI agent on a role you control instead of the built-in one. @@ -157,6 +175,13 @@ public static function register_role(): string { $existing->remove_cap( $forbidden ); } } + // TEMPLATE_EDIT_CAP is the one cap this class both adds and + // removes: the additive loop above never takes it away, so a + // toggle flipped off needs this explicit revoke or the grant + // would outlive the setting that authorized it. + if ( ! $caps[ self::TEMPLATE_EDIT_CAP ] && $existing->has_cap( self::TEMPLATE_EDIT_CAP ) ) { + $existing->remove_cap( self::TEMPLATE_EDIT_CAP ); + } } } diff --git a/wordpress-plugin/gk-block-mcp/includes/class-rest-controller.php b/wordpress-plugin/gk-block-mcp/includes/class-rest-controller.php index 0d831b2..5b63ceb 100644 --- a/wordpress-plugin/gk-block-mcp/includes/class-rest-controller.php +++ b/wordpress-plugin/gk-block-mcp/includes/class-rest-controller.php @@ -1169,16 +1169,20 @@ public function check_create_pattern_permissions() { * Permission callback for template write endpoints (POST /template, * POST /template/reset). * - * Gated on the site toggle first, then a capability check. The - * dedicated agent role never holds `edit_theme_options` - * (`Agent_Provisioner::forbidden_capabilities()`), so `edit_posts` alone - * is enough for it once an operator opts in via the toggle; a "self" - * connection (a real admin's own Application Password) already carries - * `edit_theme_options` and needs no toggle-adjacent capability grant. - * The plugin performs the underlying post writes itself — `wp_insert_post()` - * / `wp_update_post()` do not enforce capabilities — so core's own - * `/wp/v2/templates`, the customizer, menus, and theme switching stay - * closed to the agent regardless of this toggle. + * Gated on the site toggle first, then a capability check. `edit_posts` + * alone is NOT sufficient here, unlike every other write route on this + * namespace: the plugin performs the underlying `wp_insert_post()` / + * `wp_update_post()` on `wp_template`/`wp_template_part` itself, which + * do not enforce capabilities, so an `edit_posts`-only actor (any + * contributor-or-above, or a leaked low-privilege Application Password) + * would otherwise be able to rewrite sitewide template chrome — header, + * footer, 404, archive, search — regardless of their own post-editing + * scope. The toggle grants `Agent_Provisioner::TEMPLATE_EDIT_CAP` + * specifically to the agent role instead of `edit_theme_options`, so + * turning it on never reopens core's own `/wp/v2/templates`, the + * Customizer, menus, or widgets to the agent's Application Password. + * A "self" connection (a real admin's own Application Password) already + * carries `edit_theme_options` and needs no toggle-adjacent grant. * * @since 2.2.0 * @@ -1192,7 +1196,7 @@ public function check_template_edit_permissions() { array( 'status' => 403 ) ); } - if ( ! current_user_can( 'edit_posts' ) && ! current_user_can( 'edit_theme_options' ) ) { + if ( ! current_user_can( Agent_Provisioner::TEMPLATE_EDIT_CAP ) && ! current_user_can( 'edit_theme_options' ) ) { return new \WP_Error( 'rest_forbidden', __( 'You do not have permission to edit templates.', 'gk-block-mcp' ), diff --git a/wordpress-plugin/gk-block-mcp/includes/class-settings-page.php b/wordpress-plugin/gk-block-mcp/includes/class-settings-page.php index f092561..773748d 100644 --- a/wordpress-plugin/gk-block-mcp/includes/class-settings-page.php +++ b/wordpress-plugin/gk-block-mcp/includes/class-settings-page.php @@ -1224,7 +1224,7 @@ function sync() {

- +

/> - + user->create( array( 'role' => 'editor' ) ) ); + + // edit_posts too, unlike the write-only test below: the get-template + // read-back is gated on the 'read' bucket, which checks edit_posts. + $role_name = 'gk_test_ability_persist_theme_options_only'; + add_role( $role_name, 'Theme Options Only', array( 'read' => true, 'edit_posts' => true, 'edit_theme_options' => true ) ); + wp_set_current_user( self::factory()->user->create( array( 'role' => $role_name ) ) ); $result = wp_get_ability( 'gk-block-mcp/update-template' )->execute( array( @@ -232,11 +237,37 @@ public function test_update_template_ability_persists_change_when_gate_on() { $this->assertGreaterThan( 0, $result['wp_id'] ); $read = wp_get_ability( 'gk-block-mcp/get-template' )->execute( array( 'id' => $this->theme . '//index' ) ); + + remove_role( $role_name ); + $this->assertNotWPError( $read ); $this->assertSame( 'custom', $read['source'] ); $this->assertStringContainsString( 'ABILITY-MARKER', $read['content'] ); } + /** + * With the toggle on, an editor (edit_posts, no dedicated cap, no + * edit_theme_options) is denied via the ability, matching REST-level + * coverage of the same fix — the Abilities surface delegates to the + * same check_template_edit_permissions() callback, so it can't be used + * to bypass the capability gate REST enforces. + */ + public function test_update_template_ability_denies_editor_even_with_gate_on() { + update_option( Template_Manager::ALLOW_TEMPLATE_EDITS_OPTION, '1' ); + wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) ); + + $this->setExpectedIncorrectUsage( 'WP_Ability::execute' ); + $result = wp_get_ability( 'gk-block-mcp/update-template' )->execute( + array( + 'id' => $this->theme . '//index', + 'content' => '

x

', + ) + ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + } + /** * With the toggle on, an actor holding neither edit_posts nor * edit_theme_options (a subscriber) is still denied — the toggle widens @@ -292,7 +323,12 @@ public function test_update_template_ability_succeeds_via_edit_theme_options_alo */ public function test_reset_template_ability_deletes_override_when_gate_on() { update_option( Template_Manager::ALLOW_TEMPLATE_EDITS_OPTION, '1' ); - wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) ); + + // edit_posts too: the get-template read-back is gated on the 'read' + // bucket, which checks edit_posts, not edit_theme_options. + $role_name = 'gk_test_ability_reset_theme_options_only'; + add_role( $role_name, 'Theme Options Only', array( 'read' => true, 'edit_posts' => true, 'edit_theme_options' => true ) ); + wp_set_current_user( self::factory()->user->create( array( 'role' => $role_name ) ) ); $created = wp_get_ability( 'gk-block-mcp/update-template' )->execute( array( @@ -310,6 +346,9 @@ public function test_reset_template_ability_deletes_override_when_gate_on() { $this->assertNull( get_post( $created['wp_id'] ) ); $read = wp_get_ability( 'gk-block-mcp/get-template' )->execute( array( 'id' => $this->theme . '//index' ) ); + + remove_role( $role_name ); + $this->assertNotWPError( $read ); $this->assertSame( 'theme', $read['source'] ); $this->assertNull( $read['wp_id'] ); diff --git a/wordpress-plugin/gk-block-mcp/tests/Connect/AgentProvisionerTest.php b/wordpress-plugin/gk-block-mcp/tests/Connect/AgentProvisionerTest.php index 149881a..c6fbf14 100644 --- a/wordpress-plugin/gk-block-mcp/tests/Connect/AgentProvisionerTest.php +++ b/wordpress-plugin/gk-block-mcp/tests/Connect/AgentProvisionerTest.php @@ -20,6 +20,7 @@ declare( strict_types=1 ); use GravityKit\BlockMCP\Agent_Provisioner; +use GravityKit\BlockMCP\Template_Manager; /** * Tests for Agent_Provisioner::ensure(). @@ -128,6 +129,45 @@ public function test_register_role_strips_forbidden_caps_from_existing_role() { $this->assertTrue( $role->has_cap( 'a_custom_operator_cap' ), 'operator-added caps must not be stripped' ); } + /** + * register_role() grants TEMPLATE_EDIT_CAP on a fresh role when the + * gk_block_api_template_edits toggle is on — the cap that gates + * POST /template, computed from the toggle rather than hardcoded. + */ + public function test_register_role_grants_template_edit_cap_when_toggle_on() { + update_option( Template_Manager::ALLOW_TEMPLATE_EDITS_OPTION, '1' ); + + Agent_Provisioner::register_role(); + + $role = get_role( Agent_Provisioner::ROLE ); + $this->assertNotNull( $role ); + $this->assertTrue( $role->has_cap( Agent_Provisioner::TEMPLATE_EDIT_CAP ) ); + } + + /** + * register_role() must REVOKE TEMPLATE_EDIT_CAP from an existing role + * when the toggle is later switched off — unlike every other capability + * in the map, the additive re-assert loop never removes this one, so a + * dedicated removal branch is required or a toggled-off grant would + * outlive the setting that authorized it. This is the one exception to + * "additive only" that test_register_role_strips_forbidden_caps_from_existing_role() + * (above) proves still holds for everything else. + */ + public function test_register_role_revokes_template_edit_cap_when_toggle_off() { + update_option( Template_Manager::ALLOW_TEMPLATE_EDITS_OPTION, '1' ); + Agent_Provisioner::register_role(); + $role = get_role( Agent_Provisioner::ROLE ); + $this->assertNotNull( $role ); + $this->assertTrue( $role->has_cap( Agent_Provisioner::TEMPLATE_EDIT_CAP ), 'setup: cap must be granted before it can be revoked' ); + + update_option( Template_Manager::ALLOW_TEMPLATE_EDITS_OPTION, '0' ); + Agent_Provisioner::register_role(); + + $role = get_role( Agent_Provisioner::ROLE ); + $this->assertNotNull( $role ); + $this->assertFalse( $role->has_cap( Agent_Provisioner::TEMPLATE_EDIT_CAP ) ); + } + /** * Calling ensure() twice must return the same user ID and not create a * second user with the same login. The resolved ID must be persisted in diff --git a/wordpress-plugin/gk-block-mcp/tests/Templates/TemplatesRestTest.php b/wordpress-plugin/gk-block-mcp/tests/Templates/TemplatesRestTest.php index 9ac7ea2..bef20db 100644 --- a/wordpress-plugin/gk-block-mcp/tests/Templates/TemplatesRestTest.php +++ b/wordpress-plugin/gk-block-mcp/tests/Templates/TemplatesRestTest.php @@ -6,6 +6,7 @@ * @package GravityKit\BlockMCP\Tests */ +use GravityKit\BlockMCP\Agent_Provisioner; use GravityKit\BlockMCP\Template_Manager; class TemplatesRestTest extends RestControllerTestCase { @@ -165,20 +166,34 @@ public function test_update_template_route_403_when_toggle_off() { } /** - * With the toggle on, an editor (edit_posts, no edit_theme_options) - * can create an override — the whole point of the toggle over relying - * on edit_theme_options alone. + * With the toggle on, a plain contributor (edit_posts, no dedicated + * cap, no edit_theme_options) must still be denied. The plugin performs + * the underlying wp_insert_post()/wp_update_post() itself — those don't + * enforce capabilities — so edit_posts alone would let any + * contributor-or-above account (or a leaked low-privilege Application + * Password) rewrite sitewide template chrome. */ - public function test_update_template_route_succeeds_for_editor_when_toggle_on() { + public function test_update_template_route_403_for_contributor_even_with_toggle_on() { + update_option( Template_Manager::ALLOW_TEMPLATE_EDITS_OPTION, '1' ); + wp_set_current_user( self::factory()->user->create( array( 'role' => 'contributor' ) ) ); + + $response = $this->dispatch( $this->update_request( $this->theme . '//index', '

x

' ) ); + + $this->assertSame( 403, $response->get_status() ); + } + + /** + * Same denial for an editor — edit_posts (plus the wider edit_others_posts/ + * publish_posts an editor also holds) is still not the dedicated + * gk_block_mcp_edit_templates cap, so an editor is denied exactly like a + * contributor. Proves the fix isn't just "block the weakest role." + */ + public function test_update_template_route_403_for_editor_even_with_toggle_on() { update_option( Template_Manager::ALLOW_TEMPLATE_EDITS_OPTION, '1' ); $response = $this->dispatch( $this->update_request( $this->theme . '//index', '

Via REST

' ) ); - $this->assertSame( 200, $response->get_status() ); - $data = $response->get_data(); - $this->assertTrue( $data['success'] ); - $this->assertTrue( $data['override_created'] ); - $this->assertGreaterThan( 0, $data['wp_id'] ); + $this->assertSame( 403, $response->get_status() ); } /** @@ -196,9 +211,9 @@ public function test_update_template_route_403_for_subscriber_even_with_toggle_o } /** - * An actor with edit_theme_options but NOT edit_posts (the other half - * of the "edit_posts OR edit_theme_options" gate) can also write — - * this is the path a "self" (human admin) connection uses. + * An actor with edit_theme_options but no dedicated cap (the other half + * of the gate) can still write — this is the path a "self" (human + * admin) connection uses. */ public function test_update_template_route_succeeds_via_edit_theme_options_alone() { update_option( Template_Manager::ALLOW_TEMPLATE_EDITS_OPTION, '1' ); @@ -214,6 +229,57 @@ public function test_update_template_route_succeeds_via_edit_theme_options_alone $this->assertSame( 200, $response->get_status() ); } + /** + * The dedicated agent role is the one actor the toggle is actually + * meant to empower: with the toggle on, register_role() grants it + * Agent_Provisioner::TEMPLATE_EDIT_CAP (confirmed at the capability + * level, not just behaviorally), and POST /template succeeds. + */ + public function test_update_template_route_succeeds_for_agent_role_when_toggle_on() { + update_option( Template_Manager::ALLOW_TEMPLATE_EDITS_OPTION, '1' ); + Agent_Provisioner::register_role(); + + $role = get_role( Agent_Provisioner::ROLE ); + $this->assertNotNull( $role ); + $this->assertTrue( $role->has_cap( Agent_Provisioner::TEMPLATE_EDIT_CAP ) ); + + wp_set_current_user( self::factory()->user->create( array( 'role' => Agent_Provisioner::ROLE ) ) ); + + $response = $this->dispatch( $this->update_request( $this->theme . '//index', '

Via agent role

' ) ); + + $this->assertSame( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertTrue( $data['success'] ); + $this->assertTrue( $data['override_created'] ); + } + + /** + * Turning the toggle back off and re-running register_role() revokes + * Agent_Provisioner::TEMPLATE_EDIT_CAP from the role (the additive + * re-assert loop never removes caps — this is the one deliberate + * exception) and the route 403s for the same agent-role user. With the + * toggle off the 403 is dominated by the toggle gate itself + * (template_edits_disabled), so the capability-level assertion is what + * actually proves the revoke happened. + */ + public function test_agent_role_loses_template_edit_cap_and_route_403s_when_toggle_off() { + update_option( Template_Manager::ALLOW_TEMPLATE_EDITS_OPTION, '1' ); + Agent_Provisioner::register_role(); + $user_id = self::factory()->user->create( array( 'role' => Agent_Provisioner::ROLE ) ); + + update_option( Template_Manager::ALLOW_TEMPLATE_EDITS_OPTION, '0' ); + Agent_Provisioner::register_role(); + + $role = get_role( Agent_Provisioner::ROLE ); + $this->assertNotNull( $role ); + $this->assertFalse( $role->has_cap( Agent_Provisioner::TEMPLATE_EDIT_CAP ), 'toggle-off must revoke the managed cap, not just leave it granted' ); + + wp_set_current_user( $user_id ); + $response = $this->dispatch( $this->update_request( $this->theme . '//index', '

x

' ) ); + + $this->assertSame( 403, $response->get_status() ); + } + /** * A legacy-tier block in `blocks` is rejected on the controller's own * handler. @@ -272,6 +338,11 @@ public function test_reset_template_route_403_when_toggle_off() { */ public function test_reset_template_route_deletes_override() { update_option( Template_Manager::ALLOW_TEMPLATE_EDITS_OPTION, '1' ); + + $role_name = 'gk_test_reset_theme_options_only'; + add_role( $role_name, 'Theme Options Only', array( 'read' => true, 'edit_theme_options' => true ) ); + wp_set_current_user( self::factory()->user->create( array( 'role' => $role_name ) ) ); + $created = $this->dispatch( $this->update_request( $this->theme . '//index', '

x

' ) ); $wp_id = $created->get_data()['wp_id']; @@ -279,6 +350,8 @@ public function test_reset_template_route_deletes_override() { $request->set_param( 'id', $this->theme . '//index' ); $response = $this->dispatch( $request ); + remove_role( $role_name ); + $this->assertSame( 200, $response->get_status() ); $this->assertSame( $wp_id, $response->get_data()['wp_id'] ); $this->assertNull( get_post( $wp_id ) );