From 76396a29075136394df434832d895848828b7668 Mon Sep 17 00:00:00 2001 From: Ben Kalsky Date: Fri, 25 Sep 2026 19:19:11 +0300 Subject: [PATCH 1/4] =?UTF-8?q?feat(rules):=20preview=5Fmatch()=20?= =?UTF-8?q?=E2=80=94=20enforce()'s=20decision=20without=20its=20side=20eff?= =?UTF-8?q?ects=20(Aura#586)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- .../includes/class-aura-worker-rules.php | 21 ++++- tests/unit/RulesPreviewMatchTest.php | 76 +++++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 tests/unit/RulesPreviewMatchTest.php diff --git a/digitizer-site-worker/includes/class-aura-worker-rules.php b/digitizer-site-worker/includes/class-aura-worker-rules.php index 8c894567..cd13e572 100644 --- a/digitizer-site-worker/includes/class-aura-worker-rules.php +++ b/digitizer-site-worker/includes/class-aura-worker-rules.php @@ -2525,14 +2525,31 @@ public static function enforceable_match( array $touches, array $rules, $now = n return $rule; } + /** + * The rule enforce() would apply to this call — the same widening, the + * same accessor, the same site identity — with none of enforce()'s side + * effects: no expiry sweep, no per-dispatch record, no hook. The preview + * paths ask this (Aura spec 2026-09-25 §5.2), so what the gateway shows + * before approval is what the very next request decides. + * + * @since 2.21.0 + * @param array $touches What the call declares it touches. + * @param string $tool_name Calling tool (an elementor-mcp/… ability name widens on an old fork). + * @param int|null $now Unix time; injected for tests. + * @return array|null The deciding rule (block or warn), or null. + */ + public static function preview_match( array $touches, $tool_name, $now = null ) { + $touches = self::widen_for_old_fork( $touches, $tool_name ); + return self::enforceable_match( $touches, self::rules(), $now, self::site_ref() ); + } + public static function enforce( array $touches, $tool_name, $now = null ) { self::note_expired( $now ); // Judged in THIS site's identity (self::site_ref(), the one accessor — // the preview path asks the same question of the same record, so the // two can never disagree). The fork inherits this through enforce(), // so its governance wrapper needs no change of its own. - $touches = self::widen_for_old_fork( $touches, $tool_name ); - $rule = self::enforceable_match( $touches, self::rules(), $now, self::site_ref() ); + $rule = self::preview_match( $touches, $tool_name, $now ); if ( null === $rule ) { return array( 'effect' => null ); } diff --git a/tests/unit/RulesPreviewMatchTest.php b/tests/unit/RulesPreviewMatchTest.php new file mode 100644 index 00000000..66e3b6fc --- /dev/null +++ b/tests/unit/RulesPreviewMatchTest.php @@ -0,0 +1,76 @@ + 'x.y', + 'seq' => 1, + 'issued_at' => '2026-09-25T00:00:00Z', + 'received_at' => time(), + 'rules' => $rules, + ); + } + + private function rule( string $effect, string $type, ?string $id ): array { + return array( 'key' => 'rule/' . $effect, 'effect' => $effect, 'target' => array( 'type' => $type, 'id' => $id ), 'reason' => 'r', 'until' => null ); + } + + private function page7(): array { + return array( array( 'type' => 'post', 'id' => '7' ), array( 'type' => 'page', 'id' => '7' ) ); + } + + public function effects(): array { + return array( + 'block' => array( 'block' ), + 'warn' => array( 'warn' ), + 'allow' => array( 'allow' ), + ); + } + + /** @dataProvider effects */ + public function test_it_is_the_decision_enforce_applies( string $effect ): void { + $this->store( array( $this->rule( $effect, 'page', '7' ) ) ); + $rule = Aura_Worker_Rules::preview_match( $this->page7(), 'elementor-mcp/update-element' ); + $verdict = Aura_Worker_Rules::enforce( $this->page7(), 'elementor-mcp/update-element' ); + if ( 'allow' === $effect ) { + $this->assertNull( $rule ); + $this->assertNull( $verdict['effect'] ); + } else { + $this->assertSame( $effect, $rule['effect'] ); + $this->assertSame( $rule, $verdict['rule'] ); + } + } + + public function test_it_widens_an_old_forks_page_write_as_enforce_does(): void { + Aura_Worker_Rules::_set_fork_version_for_tests( '1.36.1' ); + $this->store( array( $this->rule( 'block', 'custom_css', '7' ) ) ); + $this->assertSame( 'block', Aura_Worker_Rules::preview_match( $this->page7(), 'elementor-mcp/update-element' )['effect'] ); + $this->assertNull( Aura_Worker_Rules::preview_match( $this->page7(), 'update_post' ), 'only the fork is widened' ); + } + + public function test_it_records_nothing_and_fires_no_hook(): void { + $this->store( array( $this->rule( 'block', 'page', '7' ), $this->rule( 'warn', 'site', null ) ) ); + Aura_Worker_Rules::preview_match( $this->page7(), 'elementor-mcp/update-element' ); + $this->assertEmpty( array_filter( $GLOBALS['_did_actions'], static function ( $a ) { + return in_array( $a['tag'], array( 'aura_worker_rule_blocked', 'aura_worker_rule_warned' ), true ); + } ) ); + Aura_Worker_Rules::enforce( $this->page7(), 'elementor-mcp/update-element' ); + $blocked = array_filter( $GLOBALS['_did_actions'], static function ( $a ) { + return 'aura_worker_rule_blocked' === $a['tag']; + } ); + $this->assertCount( 1, $blocked, 'the preview must not have consumed the dispatch\'s first record' ); + } +} From 229e5a3c7c8fd834d42061e70c30cb3b3c56b2b1 Mon Sep 17 00:00:00 2001 From: Ben Kalsky Date: Fri, 25 Sep 2026 19:30:17 +0300 Subject: [PATCH 2/4] feat(preview): a queued elementor-mcp write is previewed by the fork's declare_touches() (Aura#586) Also updates AuditRulesTest::test_the_preview_path_passes_the_identity_through, a source-scanning test that asserted the literal old Aura_Worker_Rules::enforceable_match(...) call preview_tool() made directly; it now asserts the preview_match() call this task replaces it with, which carries the same identity-threading guarantee through the shared accessor. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../includes/class-aura-worker-tools.php | 135 ++++++++++++- tests/bootstrap.php | 3 + tests/unit/AuditRulesTest.php | 8 +- tests/unit/PreviewForkTest.php | 185 ++++++++++++++++++ 4 files changed, 319 insertions(+), 12 deletions(-) create mode 100644 tests/unit/PreviewForkTest.php diff --git a/digitizer-site-worker/includes/class-aura-worker-tools.php b/digitizer-site-worker/includes/class-aura-worker-tools.php index ce5ed17e..01347c24 100644 --- a/digitizer-site-worker/includes/class-aura-worker-tools.php +++ b/digitizer-site-worker/includes/class-aura-worker-tools.php @@ -20,6 +20,15 @@ class Aura_Worker_Tools { */ private $tools = array(); + /** + * Test-only override of the fork's touch declarer: null = real detection, + * false = no fork, a callable = the fork (see fork_declarer()). + * + * @since 2.21.0 + * @var null|false|callable + */ + private static $fork_declarer_for_tests = null; + /** * Constructor — loads the base class and all tool files, then registers them. */ @@ -220,14 +229,10 @@ public function execute_tool( $name, $params ) { * verdict, planned command, file diff, SQL) at approval time. Tools that do * not declare supports_preview return `supported: false` with a null preview. * - * Old-fork CSS widening (2.20.0) is NOT applied here: it lives in - * Aura_Worker_Rules::enforce(), which widens an `elementor-mcp/*` - * ability's page/site touches into custom_css ones before matching, - * while this preview asks enforceable_match() directly. That is safe - * today because only SiteAgent's own tools reach preview_tool() and none - * is named `elementor-mcp/…`, so widening would be a no-op here. If a - * fork ability ever reaches this path, widen here too, or the preview - * and enforce() disagree about the same call. + * Both paths — SiteAgent's own tools and the elementor-mcp fork's + * abilities (2.21.0, preview_fork_tool()) — decide through + * Aura_Worker_Rules::preview_match(), the function enforce() decides + * through, so the preview and the call it previews cannot disagree. * * @param string $name Tool name. * @param array $params Parameters to preview. @@ -237,6 +242,10 @@ public function preview_tool( $name, $params ) { $tool = $this->get_tool( $name ); if ( null === $tool ) { + $fork = $this->preview_fork_tool( (string) $name, is_array( $params ) ? $params : array() ); + if ( null !== $fork ) { + return $fork; + } return array( 'success' => false, 'error' => "Unknown tool: $name", @@ -267,7 +276,7 @@ public function preview_tool( $name, $params ) { // tools path already defaults to the approval queue — was reported as // a verdict the very next request never applies. Either way the two // disagree about the same call. - $rule = empty( $touches ) ? null : Aura_Worker_Rules::enforceable_match( $touches, Aura_Worker_Rules::rules(), null, Aura_Worker_Rules::site_ref() ); + $rule = empty( $touches ) ? null : Aura_Worker_Rules::preview_match( $touches, $name ); $rule_match = null === $rule ? null : array( 'key' => isset( $rule['key'] ) ? (string) $rule['key'] : 'rule/?', 'effect' => (string) $rule['effect'], @@ -299,4 +308,112 @@ public function preview_tool( $name, $params ) { ); } } + + /** + * A queued elementor-mcp fork write, previewed by the fork itself (Aura + * spec 2026-09-25 §5). The fork's abilities are not in this registry; + * Elementor_MCP_Governance::declare_touches() (elementor-mcp 1.38.0) + * answers with exactly the touches its early rules gate judges, and the + * rule is decided here the way enforce() decides the fork's run. + * + * Null means "not the fork's either" — the caller answers Unknown tool, + * exactly as before: no fork, an older fork, a name the fork does not + * govern, a throw, or anything malformed (never a partial list). + * + * @since 2.21.0 + * @param string $name The published MCP tool name. + * @param array $params The call's params, as queued. + * @return array|null + */ + private function preview_fork_tool( $name, array $params ) { + $declarer = self::fork_declarer(); + if ( null === $declarer ) { + return null; + } + // Aura's routing key; its executor strips it before the fork sees the + // input, so the fork must not declare from it here either. + unset( $params['_mcpPath'] ); + try { + $answer = call_user_func( $declarer, $name, $params ); + } catch ( \Throwable $e ) { + return null; + } + if ( ! is_array( $answer ) || ! isset( $answer['ability'], $answer['touches'] ) || ! is_string( $answer['ability'] ) || ! is_array( $answer['touches'] ) ) { + return null; + } + $touches = self::validate_fork_touches( $answer['touches'] ); + if ( null === $touches ) { + return null; + } + $rule = empty( $touches ) ? null : Aura_Worker_Rules::preview_match( $touches, $answer['ability'] ); + return array( + 'success' => true, + 'supported' => false, + 'preview' => null, + 'touches' => $touches, + 'rule_match' => null === $rule ? null : array( + 'key' => isset( $rule['key'] ) ? (string) $rule['key'] : 'rule/?', + 'effect' => (string) $rule['effect'], + 'reason' => isset( $rule['reason'] ) ? (string) $rule['reason'] : '', + ), + ); + } + + /** + * Every touch `{type: string, id: string}`; `precise` / `css_only` kept + * only as the literal true and only on custom_css (the evidence an allow + * may use — spec 2026-09-24 §3). One malformed touch → null. + * + * @since 2.21.0 + * @param array $touches The fork's touches. + * @return array|null + */ + private static function validate_fork_touches( array $touches ) { + $out = array(); + foreach ( $touches as $t ) { + if ( ! is_array( $t ) || ! isset( $t['type'], $t['id'] ) || ! is_string( $t['type'] ) || ! is_string( $t['id'] ) ) { + return null; + } + $clean = array( 'type' => $t['type'], 'id' => $t['id'] ); + if ( 'custom_css' === $t['type'] ) { + if ( isset( $t['precise'] ) && true === $t['precise'] ) { + $clean['precise'] = true; + } + if ( isset( $t['css_only'] ) && true === $t['css_only'] ) { + $clean['css_only'] = true; + } + } + $out[] = $clean; + } + return $out; + } + + /** + * The fork's touch declarer, or null when there is none. + * + * @since 2.21.0 + * @return callable|null + */ + private static function fork_declarer() { + if ( false === self::$fork_declarer_for_tests ) { + return null; + } + if ( null !== self::$fork_declarer_for_tests ) { + return self::$fork_declarer_for_tests; + } + if ( class_exists( 'Elementor_MCP_Governance' ) && method_exists( 'Elementor_MCP_Governance', 'declare_touches' ) ) { + return array( 'Elementor_MCP_Governance', 'declare_touches' ); + } + return null; + } + + /** + * Test-only: override fork detection. + * + * @since 2.21.0 + * @param null|false|callable $declarer See $fork_declarer_for_tests. + */ + public static function _set_fork_declarer_for_tests( $declarer ) { + self::$fork_declarer_for_tests = $declarer; + } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 2509dd51..b3c02297 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -5399,6 +5399,9 @@ function sa_reset_state(): void { Aura_Worker_Rules::$cookie_auth_override = null; Aura_Worker_Rules::_set_fork_version_for_tests( null ); } + if ( class_exists( 'Aura_Worker_Tools' ) ) { + Aura_Worker_Tools::_set_fork_declarer_for_tests( null ); + } if ( class_exists( 'Aura_Worker_Door_Log' ) ) { // Ruling S7's test seam: a test that fakes a 32-bit build and forgets // to clear it would otherwise poison every later test's door version diff --git a/tests/unit/AuditRulesTest.php b/tests/unit/AuditRulesTest.php index 27d8f0fe..c12a2153 100644 --- a/tests/unit/AuditRulesTest.php +++ b/tests/unit/AuditRulesTest.php @@ -473,11 +473,13 @@ public function test_the_preview_path_passes_the_identity_through(): void { // The call site itself — a preview that dropped the argument would // silently fall back to the unknown identity and warn about blocks // that never fire — and it goes through the accessor enforce() judges - // by, never match() directly, or an `allow` winner enforcement - // discards would be shown to the operator as a verdict. + // by (2.21.0: Aura_Worker_Rules::preview_match(), which threads the + // identity through enforceable_match() itself), never match() + // directly, or an `allow` winner enforcement discards would be shown + // to the operator as a verdict. $src = file_get_contents( __DIR__ . '/../../digitizer-site-worker/includes/class-aura-worker-tools.php' ); $this->assertStringContainsString( - 'Aura_Worker_Rules::enforceable_match( $touches, Aura_Worker_Rules::rules(), null, Aura_Worker_Rules::site_ref() )', + 'Aura_Worker_Rules::preview_match( $touches, $name )', $src ); $this->assertStringNotContainsString( 'Aura_Worker_Rules::match(', $src, 'the preview never asks match() for itself' ); diff --git a/tests/unit/PreviewForkTest.php b/tests/unit/PreviewForkTest.php new file mode 100644 index 00000000..66154634 --- /dev/null +++ b/tests/unit/PreviewForkTest.php @@ -0,0 +1,185 @@ +asked = array(); + } + + private function store( array $rules ): void { + $GLOBALS['_options'][ Aura_Worker_Rules::OPTION ] = array( + 'envelope' => 'x.y', + 'seq' => 1, + 'issued_at' => '2026-09-25T00:00:00Z', + 'received_at' => time(), + 'rules' => $rules, + ); + } + + private function rule( string $effect, string $type, ?string $id ): array { + return array( 'key' => 'rule/' . $effect, 'effect' => $effect, 'target' => array( 'type' => $type, 'id' => $id ), 'reason' => 'r', 'until' => null ); + } + + /** A fake fork answering $answer (or running it, if callable). */ + private function fork( $answer ): void { + Aura_Worker_Tools::_set_fork_declarer_for_tests( + function ( string $mcp_tool, array $input ) use ( $answer ) { + $this->asked[] = array( $mcp_tool, $input ); + return is_callable( $answer ) ? $answer() : $answer; + } + ); + } + + private function page7_css(): array { + return array( + array( 'type' => 'post', 'id' => '7' ), + array( 'type' => 'page', 'id' => '7' ), + array( 'type' => 'custom_css', 'id' => '7', 'precise' => true, 'css_only' => true ), + ); + } + + private function preview( array $params = array( 'post_id' => 7 ) ): array { + return ( new Aura_Worker_Tools() )->preview_tool( 'elementor-mcp-update-page-settings', $params ); + } + + private function assert_unknown( array $res ): void { + $this->assertSame( array( 'success' => false, 'error' => 'Unknown tool: elementor-mcp-update-page-settings' ), $res ); + } + + public function effects(): array { + return array( 'block' => array( 'block' ), 'warn' => array( 'warn' ) ); + } + + /** @dataProvider effects */ + public function test_a_fork_write_gets_its_touches_and_the_rule_enforcement_would_apply( string $effect ): void { + $this->store( array( $this->rule( $effect, 'custom_css', '7' ) ) ); + $this->fork( array( 'ability' => 'elementor-mcp/update-page-settings', 'touches' => $this->page7_css() ) ); + $res = $this->preview(); + $this->assertSame( + array( + 'success' => true, + 'supported' => false, + 'preview' => null, + 'touches' => $this->page7_css(), + 'rule_match' => array( 'key' => 'rule/' . $effect, 'effect' => $effect, 'reason' => 'r' ), + ), + $res + ); + $this->assertEmpty( array_filter( $GLOBALS['_did_actions'], static function ( $a ) { + return in_array( $a['tag'], array( 'aura_worker_rule_blocked', 'aura_worker_rule_warned' ), true ); + } ), 'a preview fired a rule hook' ); + } + + public function test_an_allow_winner_is_no_rule_as_enforcement_ignores_it(): void { + $this->store( array( $this->rule( 'allow', 'custom_css', '7' ) ) ); + $this->fork( array( 'ability' => 'elementor-mcp/update-page-settings', 'touches' => $this->page7_css() ) ); + $res = $this->preview(); + $this->assertTrue( $res['success'] ); + $this->assertNull( $res['rule_match'] ); + } + + public function test_the_fork_path_widens_by_the_ability_name(): void { + Aura_Worker_Rules::_set_fork_version_for_tests( '1.36.1' ); + $this->store( array( $this->rule( 'block', 'custom_css', '7' ) ) ); + $this->fork( array( 'ability' => 'elementor-mcp/update-element', 'touches' => array( array( 'type' => 'post', 'id' => '7' ), array( 'type' => 'page', 'id' => '7' ) ) ) ); + $this->assertSame( 'block', $this->preview()['rule_match']['effect'] ); + } + + public function test_the_routing_key_never_reaches_the_fork(): void { + $this->fork( array( 'ability' => 'elementor-mcp/update-page-settings', 'touches' => array() ) ); + $this->preview( array( 'post_id' => 7, '_mcpPath' => '/wp-json/mcp/elementor-mcp-server', 'settings' => array( 'custom_css' => 'a{}' ) ) ); + $this->assertSame( + array( array( 'elementor-mcp-update-page-settings', array( 'post_id' => 7, 'settings' => array( 'custom_css' => 'a{}' ) ) ) ), + $this->asked + ); + } + + public function test_a_dry_run_declares_nothing_and_matches_nothing(): void { + $this->store( array( $this->rule( 'block', 'site', null ) ) ); + $this->fork( array( 'ability' => 'elementor-mcp/generate-meta-tags', 'touches' => array() ) ); + $res = $this->preview(); + $this->assertTrue( $res['success'] ); + $this->assertSame( array(), $res['touches'] ); + $this->assertNull( $res['rule_match'] ); + } + + public function test_evidence_fields_survive_only_as_literal_true(): void { + $this->fork( array( + 'ability' => 'elementor-mcp/update-page-settings', + 'touches' => array( + array( 'type' => 'custom_css', 'id' => '7', 'precise' => 'true', 'css_only' => 1 ), + array( 'type' => 'page', 'id' => '7', 'precise' => true ), + ), + ) ); + $this->assertSame( + array( array( 'type' => 'custom_css', 'id' => '7' ), array( 'type' => 'page', 'id' => '7' ) ), + $this->preview()['touches'] + ); + } + + public function malformed(): array { + return array( + 'null' => array( null ), + 'not an array' => array( 'yes' ), + 'no ability' => array( array( 'touches' => array() ) ), + 'ability not string' => array( array( 'ability' => 7, 'touches' => array() ) ), + 'touches not array' => array( array( 'ability' => 'elementor-mcp/x', 'touches' => 'page:7' ) ), + 'touch not array' => array( array( 'ability' => 'elementor-mcp/x', 'touches' => array( 'page:7' ) ) ), + 'id not string' => array( array( 'ability' => 'elementor-mcp/x', 'touches' => array( array( 'type' => 'page', 'id' => 7 ) ) ) ), + 'type missing' => array( array( 'ability' => 'elementor-mcp/x', 'touches' => array( array( 'id' => '7' ) ) ) ), + 'one bad of two' => array( array( 'ability' => 'elementor-mcp/x', 'touches' => array( array( 'type' => 'page', 'id' => '7' ), array( 'type' => 'post' ) ) ) ), + ); + } + + /** @dataProvider malformed */ + public function test_a_malformed_answer_is_unknown( $answer ): void { + $this->fork( $answer ); + $this->assert_unknown( $this->preview() ); + } + + public function test_a_throwing_fork_is_unknown(): void { + $this->fork( static function () { + throw new RuntimeException( 'boom' ); + } ); + $this->assert_unknown( $this->preview() ); + } + + public function test_no_fork_is_todays_answer(): void { + Aura_Worker_Tools::_set_fork_declarer_for_tests( false ); + $this->assert_unknown( $this->preview() ); + } + + public function test_the_real_detection_finds_no_fork_in_this_harness(): void { + Aura_Worker_Tools::_set_fork_declarer_for_tests( null ); + $this->assertFalse( class_exists( 'Elementor_MCP_Governance' ), 'the harness must not define the fork class' ); + $this->assert_unknown( $this->preview() ); + } + + public function test_a_native_tool_never_asks_the_fork(): void { + $this->fork( array( 'ability' => 'elementor-mcp/x', 'touches' => array() ) ); + ( new Aura_Worker_Tools() )->preview_tool( 'test_double_tool', array( 'target' => 'x' ) ); + $this->assertSame( array(), $this->asked ); + } + + public function test_the_rest_handler_returns_200_for_a_fork_answer(): void { + $this->fork( array( 'ability' => 'elementor-mcp/update-page-settings', 'touches' => $this->page7_css() ) ); + $req = new WP_REST_Request(); + $req->set_param( 'tool', 'elementor-mcp-update-page-settings' ); + $req->set_param( 'params', array( 'post_id' => 7 ) ); + $resp = ( new Aura_Worker_MCP( new Aura_Worker_Security() ) )->preview_tool( $req ); + $this->assertSame( 200, $resp->get_status() ); + $this->assertSame( $this->page7_css(), $resp->get_data()['touches'] ); + } +} From d773084b343c05bdf3009a46acac658137381ebb Mon Sep 17 00:00:00 2001 From: Ben Kalsky Date: Fri, 25 Sep 2026 19:35:09 +0300 Subject: [PATCH 3/4] =?UTF-8?q?test(preview):=20review=20fixes=20=E2=80=94?= =?UTF-8?q?=20static=20providers,=20stale=20accessor=20comments=20(Aura#58?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- .../includes/class-aura-worker-tools.php | 2 +- tests/unit/AuditRulesTest.php | 8 ++++---- tests/unit/PreviewForkTest.php | 4 ++-- tests/unit/RulesPreviewMatchTest.php | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/digitizer-site-worker/includes/class-aura-worker-tools.php b/digitizer-site-worker/includes/class-aura-worker-tools.php index 01347c24..65a97352 100644 --- a/digitizer-site-worker/includes/class-aura-worker-tools.php +++ b/digitizer-site-worker/includes/class-aura-worker-tools.php @@ -268,7 +268,7 @@ public function preview_tool( $name, $params ) { $annotations = $tool->get_annotations(); $touches = Aura_Worker_Call_Context::tool_needs_grant( $annotations ) ? $tool->touches( $params ) : array(); // In THIS site's identity (2.12.0), through the SAME accessor - // Aura_Worker_Rules::enforce() judges by (`enforceable_match()`): the + // Aura_Worker_Rules::enforce() judges by (`preview_match()`): the // preview is what the gateway shows before approval, so it must name // the rule enforcement would actually apply. Without the identity // every scoped rule reads as applying here; without the shared diff --git a/tests/unit/AuditRulesTest.php b/tests/unit/AuditRulesTest.php index c12a2153..7e908f97 100644 --- a/tests/unit/AuditRulesTest.php +++ b/tests/unit/AuditRulesTest.php @@ -455,14 +455,14 @@ public function test_the_identity_accessor_reads_the_record_defensively(): void public function test_the_preview_and_enforcement_agree_about_a_foreign_scoped_rule(): void { // The preview path is `Aura_Worker_Tools::preview_tool()`, which calls - // `enforceable_match()` — the accessor enforce() judges by. Asserted - // at the seam the two share: the same rules, the same identity, the - // same verdict. + // `Aura_Worker_Rules::preview_match()` — the accessor enforce() judges + // by. Asserted at the seam the two share: the same rules, the same + // identity, the same verdict. $rules = array( array( 'key' => 'rule/elsewhere', 'effect' => 'block', 'target' => array( 'type' => 'site' ), 'reason' => 'r', 'sites' => array( 'res_A' ) ) ); $this->store_record( $rules, 'res_B' ); $touches = array( array( 'type' => 'post', 'id' => '7' ) ); - $previewed = Aura_Worker_Rules::enforceable_match( $touches, Aura_Worker_Rules::rules(), null, Aura_Worker_Rules::site_ref() ); + $previewed = Aura_Worker_Rules::preview_match( $touches, 'x' ); $enforced = Aura_Worker_Rules::enforce( $touches, 'x', 1800000000 ); $this->assertNull( $previewed, 'the preview reported a rule enforcement skips' ); diff --git a/tests/unit/PreviewForkTest.php b/tests/unit/PreviewForkTest.php index 66154634..5f66ccd3 100644 --- a/tests/unit/PreviewForkTest.php +++ b/tests/unit/PreviewForkTest.php @@ -58,7 +58,7 @@ private function assert_unknown( array $res ): void { $this->assertSame( array( 'success' => false, 'error' => 'Unknown tool: elementor-mcp-update-page-settings' ), $res ); } - public function effects(): array { + public static function effects(): array { return array( 'block' => array( 'block' ), 'warn' => array( 'warn' ) ); } @@ -129,7 +129,7 @@ public function test_evidence_fields_survive_only_as_literal_true(): void { ); } - public function malformed(): array { + public static function malformed(): array { return array( 'null' => array( null ), 'not an array' => array( 'yes' ), diff --git a/tests/unit/RulesPreviewMatchTest.php b/tests/unit/RulesPreviewMatchTest.php index 66e3b6fc..0ee7ac82 100644 --- a/tests/unit/RulesPreviewMatchTest.php +++ b/tests/unit/RulesPreviewMatchTest.php @@ -32,7 +32,7 @@ private function page7(): array { return array( array( 'type' => 'post', 'id' => '7' ), array( 'type' => 'page', 'id' => '7' ) ); } - public function effects(): array { + public static function effects(): array { return array( 'block' => array( 'block' ), 'warn' => array( 'warn' ), From b78d614478dc4dd9e18011ef49fdf719b552ae92 Mon Sep 17 00:00:00 2001 From: Ben Kalsky Date: Fri, 25 Sep 2026 19:42:15 +0300 Subject: [PATCH 4/4] fix(preview): a fork declaration of nothing is unknown, not clean (Aura#586) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../includes/class-aura-worker-tools.php | 12 ++++++++++-- tests/unit/PreviewForkTest.php | 11 ++++++----- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/digitizer-site-worker/includes/class-aura-worker-tools.php b/digitizer-site-worker/includes/class-aura-worker-tools.php index 65a97352..8fd0e6d1 100644 --- a/digitizer-site-worker/includes/class-aura-worker-tools.php +++ b/digitizer-site-worker/includes/class-aura-worker-tools.php @@ -318,7 +318,8 @@ public function preview_tool( $name, $params ) { * * Null means "not the fork's either" — the caller answers Unknown tool, * exactly as before: no fork, an older fork, a name the fork does not - * govern, a throw, or anything malformed (never a partial list). + * govern, a throw, anything malformed (never a partial list), or a + * declaration of nothing (a dry run). * * @since 2.21.0 * @param string $name The published MCP tool name. @@ -345,7 +346,14 @@ private function preview_fork_tool( $name, array $params ) { if ( null === $touches ) { return null; } - $rule = empty( $touches ) ? null : Aura_Worker_Rules::preview_match( $touches, $answer['ability'] ); + // A declaration of nothing (the fork's dry run — rules never see one) + // is not reported as coverage: Aura reads an empty set as the unknown + // sentinel, which every site/page rule matches, so `[]` would show a + // call the site runs unchecked as blocked. Unknown is the honest answer. + if ( empty( $touches ) ) { + return null; + } + $rule = Aura_Worker_Rules::preview_match( $touches, $answer['ability'] ); return array( 'success' => true, 'supported' => false, diff --git a/tests/unit/PreviewForkTest.php b/tests/unit/PreviewForkTest.php index 5f66ccd3..7b03c7f3 100644 --- a/tests/unit/PreviewForkTest.php +++ b/tests/unit/PreviewForkTest.php @@ -106,13 +106,14 @@ public function test_the_routing_key_never_reaches_the_fork(): void { ); } - public function test_a_dry_run_declares_nothing_and_matches_nothing(): void { + public function test_a_dry_run_is_unknown_not_clean(): void { + // An empty declaration is not reported as coverage: Aura's matcher + // reads `touches: []` as the unknown sentinel, which every site/page + // rule matches — so `[]` would show a call the site runs unchecked as + // blocked. Unknown tool is the honest answer here, same as no fork. $this->store( array( $this->rule( 'block', 'site', null ) ) ); $this->fork( array( 'ability' => 'elementor-mcp/generate-meta-tags', 'touches' => array() ) ); - $res = $this->preview(); - $this->assertTrue( $res['success'] ); - $this->assertSame( array(), $res['touches'] ); - $this->assertNull( $res['rule_match'] ); + $this->assert_unknown( $this->preview() ); } public function test_evidence_fields_survive_only_as_literal_true(): void {