Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions wordpress-plugin/gk-block-mcp/gk-block-mcp.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' ) );
Comment on lines +338 to +342

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reassert the role after toggle deletion too.

Settings reset deletes gk_block_api_template_edits, so this update-only hook does not run. The agent role can retain TEMPLATE_EDIT_CAP until the next request’s init, despite the effective toggle being off. Add a post-delete deleted_option handler scoped to this option, and cover delete_option()/reset with a regression test.

Proposed fix
 add_action( 'update_option_' . \GravityKit\BlockMCP\Template_Manager::ALLOW_TEMPLATE_EDITS_OPTION, array( __NAMESPACE__ . '\\Agent_Provisioner', 'register_role' ) );
+add_action(
+	'deleted_option',
+	static function ( $option ) {
+		$is_template_edits_option = \GravityKit\BlockMCP\Template_Manager::ALLOW_TEMPLATE_EDITS_OPTION === $option;
+		if ( ! $is_template_edits_option ) {
+			return;
+		}
+		Agent_Provisioner::register_role();
+	},
+	10,
+	1
+);

As per coding guidelines, every bug fix requires a regression test exercising the real mechanism.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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' ) );
// 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(
'deleted_option',
static function ( $option ) {
$is_template_edits_option = \GravityKit\BlockMCP\Template_Manager::ALLOW_TEMPLATE_EDITS_OPTION === $option;
if ( ! $is_template_edits_option ) {
return;
}
Agent_Provisioner::register_role();
},
10,
1
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@wordpress-plugin/gk-block-mcp/gk-block-mcp.php` around lines 338 - 342, The
option lifecycle currently reasserts the agent role only through the update
hook, so deletion during settings reset leaves stale TEMPLATE_EDIT_CAP until
init. In the hook registration near Agent_Provisioner::register_role, add a
post-delete handler scoped to ALLOW_TEMPLATE_EDITS_OPTION that invokes the same
role reassertion callback, and add a regression test using delete_option() or
the reset flow to verify the capability is revoked immediately.

Source: Coding guidelines

}
add_action( 'plugins_loaded', __NAMESPACE__ . '\\init_agent' );

Expand Down
33 changes: 29 additions & 4 deletions wordpress-plugin/gk-block-mcp/includes/class-agent-provisioner.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -104,10 +120,12 @@ public static function register_role(): string {
*
* @param array<string,bool> $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.
Expand Down Expand Up @@ -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 );
}
Comment on lines +178 to +184

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Avoid an undefined capability-map offset.

The public gk/block-mcp/agent/caps filter can unset TEMPLATE_EDIT_CAP; line 182 then reads a missing key. Capture both values before the condition.

Proposed fix
-				if ( ! $caps[ self::TEMPLATE_EDIT_CAP ] && $existing->has_cap( self::TEMPLATE_EDIT_CAP ) ) {
+				$template_edit_cap_granted = ! empty( $caps[ self::TEMPLATE_EDIT_CAP ] );
+				$role_has_template_edit_cap = $existing->has_cap( self::TEMPLATE_EDIT_CAP );
+				if ( ! $template_edit_cap_granted && $role_has_template_edit_cap ) {
 					$existing->remove_cap( self::TEMPLATE_EDIT_CAP );
 				}

As per coding guidelines, assign compound checks to named variables before using them in if conditions.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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 );
}
// 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.
$template_edit_cap_granted = ! empty( $caps[ self::TEMPLATE_EDIT_CAP ] );
$role_has_template_edit_cap = $existing->has_cap( self::TEMPLATE_EDIT_CAP );
if ( ! $template_edit_cap_granted && $role_has_template_edit_cap ) {
$existing->remove_cap( self::TEMPLATE_EDIT_CAP );
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@wordpress-plugin/gk-block-mcp/includes/class-agent-provisioner.php` around
lines 178 - 184, In the capability reconciliation logic around
TEMPLATE_EDIT_CAP, safely handle the public gk/block-mcp/agent/caps filter
removing that key by capturing the filtered capability value with an appropriate
default and the existing capability state in named variables before the
condition. Update the if statement to use those variables, preserving the
explicit remove_cap behavior and complying with the compound-check naming
guideline.

Source: Coding guidelines

}
}

Expand Down
26 changes: 15 additions & 11 deletions wordpress-plugin/gk-block-mcp/includes/class-rest-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand All @@ -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' ),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1224,7 +1224,7 @@ function sync() {

<h2><?php esc_html_e( 'Template editing', 'gk-block-mcp' ); ?></h2>
<p class="description">
<?php esc_html_e( 'Let the assistant edit theme templates and template parts. Edits create database overrides; Appearance → Editor can revert them.', 'gk-block-mcp' ); ?>
<?php esc_html_e( 'Let the assistant edit theme templates and template parts. This grants the Block MCP agent account permission to change the theme layer that wraps every page (header, footer, archives). Edits create database overrides; Appearance → Editor or reset_template can revert them.', 'gk-block-mcp' ); ?>
</p>
<?php
// Belt-and-braces: emit '0' even when the box is unchecked so
Expand All @@ -1240,7 +1240,7 @@ function sync() {
value="1"
<?php checked( $templates_enabled ); ?>
/>
<?php esc_html_e( 'Let the assistant edit theme templates and template parts', 'gk-block-mcp' ); ?>
<?php esc_html_e( 'Let the assistant edit theme templates and template parts (header, footer, archives)', 'gk-block-mcp' ); ?>
</label>
<?php
// Surface filter-driven overrides so admins aren't confused
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,14 +210,19 @@ public function test_update_template_ability_denies_when_filter_forces_off() {
}

/**
* With the toggle on, an editor (edit_posts, no edit_theme_options) can
* write via the ability — matching the REST route's two-part permission
* callback (toggle ON and edit_posts OR edit_theme_options) — and the
* change round-trips through get-template.
* With the toggle on, an actor holding edit_theme_options can write via
* the ability — matching the REST route's permission callback (toggle
* ON and the dedicated cap OR edit_theme_options) — and the change
* round-trips through get-template.
*/
public function test_update_template_ability_persists_change_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, 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(
Expand All @@ -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' => '<!-- wp:paragraph --><p>x</p><!-- /wp:paragraph -->',
)
);

$this->assertWPError( $result );
$this->assertSame( 'ability_invalid_permissions', $result->get_error_code() );
}
Comment on lines +248 to +269

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Cover the dedicated-capability allow path through Abilities.

These tests prove the edit_theme_options allow branch and editor denial, but not that an agent role granted TEMPLATE_EDIT_CAP can execute gk-block-mcp/update-template. Add an Ability-level test that enables the toggle, calls Agent_Provisioner::register_role(), assigns that role, and asserts successful execution.

As per coding guidelines, regression tests must exercise relevant capability and API facets.

🧰 Tools
🪛 PHPStan (2.2.5)

[error] 257-257: Call to an undefined static method TemplateAbilitiesTest::factory().

(staticMethod.notFound)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@wordpress-plugin/gk-block-mcp/tests/Abilities/TemplateAbilitiesTest.php`
around lines 248 - 269, The Abilities tests lack coverage for the dedicated
template-edit capability allow path. In TemplateAbilitiesTest, add a test for
gk-block-mcp/update-template that enables the template-edit toggle, invokes
Agent_Provisioner::register_role(), assigns the provisioned role to the current
user, executes the ability with valid template data, and asserts successful
execution rather than a WP_Error.

Source: Coding guidelines


/**
* With the toggle on, an actor holding neither edit_posts nor
* edit_theme_options (a subscriber) is still denied — the toggle widens
Expand Down Expand Up @@ -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(
Expand All @@ -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'] );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
declare( strict_types=1 );

use GravityKit\BlockMCP\Agent_Provisioner;
use GravityKit\BlockMCP\Template_Manager;

/**
* Tests for Agent_Provisioner::ensure().
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading