From 7f1a37b02eec0fde42bd50cee191bc91b90f6a34 Mon Sep 17 00:00:00 2001 From: Ben Kalsky Date: Thu, 24 Sep 2026 18:23:15 +0300 Subject: [PATCH 01/13] =?UTF-8?q?feat(rules):=20custom=5Fcss=20target=20?= =?UTF-8?q?=E2=80=94=20block/warn=20over-block,=20allow=20needs=20precise?= =?UTF-8?q?=20CSS-only=20evidence=20(Aura#576)?= 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 | 76 +++++++++++- tests/unit/RulesCustomCssMatchTest.php | 113 ++++++++++++++++++ 2 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 tests/unit/RulesCustomCssMatchTest.php diff --git a/digitizer-site-worker/includes/class-aura-worker-rules.php b/digitizer-site-worker/includes/class-aura-worker-rules.php index 2faf5ba3..959a84f5 100644 --- a/digitizer-site-worker/includes/class-aura-worker-rules.php +++ b/digitizer-site-worker/includes/class-aura-worker-rules.php @@ -230,8 +230,19 @@ public static function count_24h( $prefix, $now = null ) { return $sum; } - /** The only resource types a rule may name. Anything else never matches. */ - const TYPES = array( 'site', 'page', 'post', 'plugin', 'design_system', 'page_create' ); + /** The only resource types a rule may name. Anything else never matches. `custom_css` since 2.20.0. */ + const TYPES = array( 'site', 'page', 'post', 'plugin', 'design_system', 'page_create', 'custom_css' ); + + /** + * Normalised-set key prefix for a `custom_css` touch that carries BOTH + * evidence fields as literal `true` on a concrete (digits) id — the only + * touch an `allow custom_css` rule may match (spec 2026-09-24 §3). Not a + * type: an operator can never name it, and nothing outside this class + * reads it. + * + * @since 2.20.0 + */ + const CSS_EXACT_PREFIX = 'custom_css!exact:'; /** Target types that carry no id — a rule on them names the whole category. */ const ID_LESS_TYPES = array( 'site', 'design_system', 'page_create' ); @@ -438,6 +449,16 @@ private static function normalize_touches( array $touches ) { continue; } $set[ $type . ':' . $id ] = true; + // Evidence fields (2.20.0): on a custom_css touch only, each only + // as the literal true, and only on a concrete id. Anything else is + // read as absent — the conservative reading (spec §3). + if ( 'custom_css' === $type + && ctype_digit( $id ) + && isset( $t['precise'], $t['css_only'] ) + && true === $t['precise'] + && true === $t['css_only'] ) { + $set[ self::CSS_EXACT_PREFIX . $id ] = true; + } } if ( empty( $set ) ) { // A declaration that survives normalisation as nothing — `[]`, @@ -462,6 +483,9 @@ private static function rule_touches( array $rule, array $touched ) { if ( ! in_array( $type, self::TYPES, true ) ) { return false; } + if ( 'custom_css' === $type ) { + return self::css_rule_touches( $rule, $touched ); + } if ( isset( $touched[ self::UNKNOWN . ':*' ] ) ) { return true; // Undeclared: every live rule applies. } @@ -486,6 +510,54 @@ private static function rule_touches( array $rule, array $touched ) { return isset( $touched[ $type . ':' . $id ] ); } + /** + * The custom_css arm (spec 2026-09-24 §3). Effect-aware, because + * conservative matching exists to over-BLOCK: an `allow` that matched a + * wildcard, a conservative declaration or `unknown:*` would over-PERMIT. + * + * @since 2.20.0 + * + * @param array $rule Rule (type already known to be custom_css). + * @param array $touched Normalised set. + * @return bool + */ + private static function css_rule_touches( array $rule, array $touched ) { + $target = isset( $rule['target'] ) && is_array( $rule['target'] ) ? $rule['target'] : array(); + $raw = array_key_exists( 'id', $target ) ? $target['id'] : null; + $any = ( null === $raw || '*' === $raw ); + $id = $any ? '' : (string) $raw; + if ( ! $any && '' === $id ) { + return false; // an empty id names nothing — never site-wide + } + $effect = isset( $rule['effect'] ) ? (string) $rule['effect'] : ''; + + if ( 'allow' === $effect ) { + if ( ! $any ) { + return isset( $touched[ self::CSS_EXACT_PREFIX . $id ] ); + } + foreach ( $touched as $key => $unused ) { + if ( 0 === strpos( $key, self::CSS_EXACT_PREFIX ) ) { + return true; + } + } + return false; + } + + // block / warn: evidence fields do not matter; silence over-blocks. + if ( isset( $touched[ self::UNKNOWN . ':*' ] ) ) { + return true; + } + if ( $any ) { + foreach ( $touched as $key => $unused ) { + if ( 0 === strpos( $key, 'custom_css:' ) ) { + return true; + } + } + return false; + } + return isset( $touched[ 'custom_css:' . $id ] ) || isset( $touched['custom_css:*'] ); + } + /* ------------------------------------------------------------------ */ /* The store — option-backed, signed, monotonic */ /* ------------------------------------------------------------------ */ diff --git a/tests/unit/RulesCustomCssMatchTest.php b/tests/unit/RulesCustomCssMatchTest.php new file mode 100644 index 00000000..0b491f23 --- /dev/null +++ b/tests/unit/RulesCustomCssMatchTest.php @@ -0,0 +1,113 @@ + 'rule/css', + 'effect' => $effect, + 'target' => array( 'type' => 'custom_css', 'id' => $id ), + 'reason' => 'no agent CSS', + 'until' => null, + ); + } + + private function css( string $id, array $extra = array() ): array { + return array_merge( array( 'type' => 'custom_css', 'id' => $id ), $extra ); + } + + private function exact( string $id ): array { + return $this->css( $id, array( 'precise' => true, 'css_only' => true ) ); + } + + public function test_block_with_an_id_matches_that_id_and_the_create_wildcard_only(): void { + $rule = $this->rule( 'block', '42' ); + $this->assertNotNull( Aura_Worker_Rules::match( array( $this->css( '42' ) ), array( $rule ) ) ); + $this->assertNotNull( Aura_Worker_Rules::match( array( $this->css( '*' ) ), array( $rule ) ), 'create-time CSS may land anywhere' ); + $this->assertNull( Aura_Worker_Rules::match( array( $this->css( '43' ) ), array( $rule ) ) ); + } + + public function test_an_id_less_or_star_rule_matches_any_css_touch(): void { + foreach ( array( null, '*' ) as $id ) { + $rule = $this->rule( 'warn', $id ); + foreach ( array( '42', '*', '7' ) as $t ) { + $this->assertNotNull( Aura_Worker_Rules::match( array( $this->css( $t ) ), array( $rule ) ), "id-less rule missed custom_css:{$t}" ); + } + } + } + + public function test_an_empty_string_id_never_becomes_site_wide(): void { + $this->assertNull( Aura_Worker_Rules::match( array( $this->css( '42' ) ), array( $this->rule( 'block', '' ) ) ) ); + } + + public function test_a_css_rule_does_not_match_a_plain_page_edit(): void { + // Additive: the page edit declares page:42 only; a CSS rule must not bite it. + $this->assertNull( Aura_Worker_Rules::match( array( array( 'type' => 'page', 'id' => '42' ) ), array( $this->rule( 'block', '42' ) ) ) ); + } + + public function test_a_page_rule_still_matches_a_css_write_through_its_page_touch(): void { + $page = array( 'key' => 'rule/p', 'effect' => 'block', 'target' => array( 'type' => 'page', 'id' => '42' ), 'reason' => 'x', 'until' => null ); + $hit = Aura_Worker_Rules::match( array( $this->css( '42' ), array( 'type' => 'page', 'id' => '42' ) ), array( $page ) ); + $this->assertSame( 'rule/p', $hit['key'] ); + } + + public function test_unknown_matches_css_block_and_warn_but_never_css_allow(): void { + $unknown = array( array( 'type' => 'unknown', 'id' => '*' ) ); + $this->assertNotNull( Aura_Worker_Rules::match( $unknown, array( $this->rule( 'block', '42' ) ) ) ); + $this->assertNotNull( Aura_Worker_Rules::match( $unknown, array( $this->rule( 'warn' ) ) ) ); + $this->assertNull( Aura_Worker_Rules::match( $unknown, array( $this->rule( 'allow', '42' ) ) ) ); + $this->assertNull( Aura_Worker_Rules::match( $unknown, array( $this->rule( 'allow' ) ) ) ); + } + + public function test_allow_with_an_id_needs_a_precise_css_only_touch_on_that_id(): void { + $rule = $this->rule( 'allow', '42' ); + $this->assertNotNull( Aura_Worker_Rules::match( array( $this->exact( '42' ) ), array( $rule ) ) ); + $this->assertNull( Aura_Worker_Rules::match( array( $this->css( '42' ) ), array( $rule ) ), 'conservative touch' ); + $this->assertNull( Aura_Worker_Rules::match( array( $this->css( '42', array( 'precise' => true ) ) ), array( $rule ) ), 'mixed call' ); + $this->assertNull( Aura_Worker_Rules::match( array( $this->exact( '43' ) ), array( $rule ) ) ); + $this->assertNull( Aura_Worker_Rules::match( array( $this->css( '*', array( 'precise' => true, 'css_only' => true ) ) ), array( $rule ) ), '* is never precise' ); + } + + public function test_id_less_allow_admits_a_precise_css_only_touch_on_any_concrete_id(): void { + foreach ( array( null, '*' ) as $id ) { + $rule = $this->rule( 'allow', $id ); + $this->assertNotNull( Aura_Worker_Rules::match( array( $this->exact( '42' ) ), array( $rule ) ) ); + $this->assertNull( Aura_Worker_Rules::match( array( $this->css( '*', array( 'precise' => true, 'css_only' => true ) ) ), array( $rule ) ) ); + $this->assertNull( Aura_Worker_Rules::match( array( $this->css( '42' ) ), array( $rule ) ) ); + } + } + + public function test_evidence_fields_count_only_as_literal_true(): void { + $rule = $this->rule( 'allow', '42' ); + foreach ( array( 'true', 1, '1', null, false, array( true ) ) as $bad ) { + $this->assertNull( Aura_Worker_Rules::match( array( $this->css( '42', array( 'precise' => $bad, 'css_only' => true ) ) ), array( $rule ) ), 'precise=' . var_export( $bad, true ) ); + $this->assertNull( Aura_Worker_Rules::match( array( $this->css( '42', array( 'precise' => true, 'css_only' => $bad ) ) ), array( $rule ) ), 'css_only=' . var_export( $bad, true ) ); + } + // Fields on a non-custom_css touch are ignored: a precise PAGE touch is just page:42. + $page_allow = array( 'key' => 'rule/pa', 'effect' => 'allow', 'target' => array( 'type' => 'page', 'id' => '42' ), 'reason' => 'x', 'until' => null ); + $this->assertNotNull( Aura_Worker_Rules::match( array( array( 'type' => 'page', 'id' => '42', 'precise' => true, 'css_only' => true ) ), array( $page_allow ) ) ); + $this->assertNull( Aura_Worker_Rules::match( array( array( 'type' => 'page', 'id' => '42', 'precise' => true, 'css_only' => true ) ), array( $rule ) ) ); + } + + public function test_a_non_digit_id_is_never_precise(): void { + $this->assertNull( Aura_Worker_Rules::match( array( $this->css( 'abc', array( 'precise' => true, 'css_only' => true ) ) ), array( $this->rule( 'allow' ) ) ) ); + } + + public function test_block_still_outranks_a_css_allow(): void { + $hit = Aura_Worker_Rules::match( array( $this->exact( '42' ) ), array( $this->rule( 'allow', '42' ), $this->rule( 'block' ) ) ); + $this->assertSame( 'block', $hit['effect'] ); + } + + public function test_a_site_freeze_still_catches_a_css_write(): void { + $freeze = array( 'key' => 'rule/freeze', 'effect' => 'block', 'target' => array( 'type' => 'site', 'id' => null ), 'reason' => 'x', 'until' => null ); + $this->assertNotNull( Aura_Worker_Rules::match( array( $this->exact( '42' ) ), array( $freeze ) ) ); + } +} From 202fbbd01f6a4150e258c77324a99e3955eaf90a Mon Sep 17 00:00:00 2001 From: Ben Kalsky Date: Thu, 24 Sep 2026 18:28:56 +0300 Subject: [PATCH 02/13] fix(rules): allow custom_css fails closed at the set level, not per-touch (review r1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An allow admits a call only when every custom_css touch it declared is precise — a single exact touch riding alongside a conservative, create-time (custom_css:*) or unknown (unknown:*) declaration no longer buys the whole call an allow verdict (controller ruling overrides the brief allow branch). CSS_EXACT_PREFIX is now private const. Adds coverage for the three over-permit cases, a still-admitted mixed page/post touch, a target with no id key at all, and enforceable_match() on a CSS allow winner. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../includes/class-aura-worker-rules.php | 15 +++++- tests/unit/RulesCustomCssMatchTest.php | 46 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/digitizer-site-worker/includes/class-aura-worker-rules.php b/digitizer-site-worker/includes/class-aura-worker-rules.php index 959a84f5..49f5359c 100644 --- a/digitizer-site-worker/includes/class-aura-worker-rules.php +++ b/digitizer-site-worker/includes/class-aura-worker-rules.php @@ -242,7 +242,7 @@ public static function count_24h( $prefix, $now = null ) { * * @since 2.20.0 */ - const CSS_EXACT_PREFIX = 'custom_css!exact:'; + private const CSS_EXACT_PREFIX = 'custom_css!exact:'; /** Target types that carry no id — a rule on them names the whole category. */ const ID_LESS_TYPES = array( 'site', 'design_system', 'page_create' ); @@ -532,6 +532,19 @@ private static function css_rule_touches( array $rule, array $touched ) { $effect = isset( $rule['effect'] ) ? (string) $rule['effect'] : ''; if ( 'allow' === $effect ) { + // Fail closed at the SET level (review #1): an allow admits a call + // only when EVERY custom_css touch it declared is precise. One + // exact touch riding alongside a conservative, create-time or + // unknown declaration must not buy the whole call an allow. + if ( isset( $touched[ self::UNKNOWN . ':*' ] ) || isset( $touched['custom_css:*'] ) ) { + return false; + } + foreach ( $touched as $key => $unused ) { + if ( 0 === strpos( $key, 'custom_css:' ) + && ! isset( $touched[ self::CSS_EXACT_PREFIX . substr( $key, strlen( 'custom_css:' ) ) ] ) ) { + return false; + } + } if ( ! $any ) { return isset( $touched[ self::CSS_EXACT_PREFIX . $id ] ); } diff --git a/tests/unit/RulesCustomCssMatchTest.php b/tests/unit/RulesCustomCssMatchTest.php index 0b491f23..970a989f 100644 --- a/tests/unit/RulesCustomCssMatchTest.php +++ b/tests/unit/RulesCustomCssMatchTest.php @@ -110,4 +110,50 @@ public function test_a_site_freeze_still_catches_a_css_write(): void { $freeze = array( 'key' => 'rule/freeze', 'effect' => 'block', 'target' => array( 'type' => 'site', 'id' => null ), 'reason' => 'x', 'until' => null ); $this->assertNotNull( Aura_Worker_Rules::match( array( $this->exact( '42' ) ), array( $freeze ) ) ); } + + // Review round 1, Important #1 (controller ruling): an allow admits a + // call only when EVERY custom_css touch it declared is precise — one + // exact touch riding alongside a conservative, create-time or unknown + // declaration must not buy the whole call an allow. + + public function test_allow_never_admits_a_set_that_also_carries_the_create_wildcard(): void { + $touches = array( $this->exact( '42' ), $this->css( '*' ) ); + $this->assertNull( Aura_Worker_Rules::match( $touches, array( $this->rule( 'allow', '42' ) ) ), 'id rule' ); + $this->assertNull( Aura_Worker_Rules::match( $touches, array( $this->rule( 'allow' ) ) ), 'id-less rule' ); + } + + public function test_allow_never_admits_a_set_that_also_carries_a_conservative_touch_on_another_id(): void { + $touches = array( $this->exact( '42' ), $this->css( '43' ) ); + $this->assertNull( Aura_Worker_Rules::match( $touches, array( $this->rule( 'allow', '42' ) ) ), 'id rule' ); + $this->assertNull( Aura_Worker_Rules::match( $touches, array( $this->rule( 'allow' ) ) ), 'id-less rule' ); + } + + public function test_allow_never_admits_a_set_that_also_carries_unknown(): void { + $touches = array( $this->exact( '42' ), array( 'type' => 'unknown', 'id' => '*' ) ); + $this->assertNull( Aura_Worker_Rules::match( $touches, array( $this->rule( 'allow', '42' ) ) ), 'id rule' ); + $this->assertNull( Aura_Worker_Rules::match( $touches, array( $this->rule( 'allow' ) ) ), 'id-less rule' ); + } + + public function test_allow_still_admits_a_precise_css_touch_alongside_its_own_page_and_post_touches(): void { + $touches = array( $this->exact( '42' ), array( 'type' => 'page', 'id' => '42' ), array( 'type' => 'post', 'id' => '42' ) ); + $this->assertNotNull( Aura_Worker_Rules::match( $touches, array( $this->rule( 'allow', '42' ) ) ) ); + } + + // Review round 1, Minor #3. + + public function test_a_rule_target_with_no_id_key_at_all_behaves_as_id_less(): void { + $rule = array( + 'key' => 'rule/css', + 'effect' => 'warn', + 'target' => array( 'type' => 'custom_css' ), // no 'id' key at all + 'reason' => 'x', + 'until' => null, + ); + $this->assertNotNull( Aura_Worker_Rules::match( array( $this->css( '42' ) ), array( $rule ) ) ); + } + + public function test_enforceable_match_treats_a_css_allow_winner_as_no_rule(): void { + $rule = $this->rule( 'allow', '42' ); + $this->assertNull( Aura_Worker_Rules::enforceable_match( array( $this->exact( '42' ) ), array( $rule ) ) ); + } } From 3b727f60e9d9cb7ed1448c9fb12b6b4506cc43a7 Mon Sep 17 00:00:00 2001 From: Ben Kalsky Date: Thu, 24 Sep 2026 18:35:43 +0300 Subject: [PATCH 03/13] feat(rules): an elementor-mcp below 1.37.0 counts its page writes as possible CSS for block/warn (Aura#576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 2 of Plan A (SiteAgent 2.20.0): Aura_Worker_Rules::fork_css_state() reads ELEMENTOR_MCP_VERSION and reports precise/widened/absent; a new private widen_for_old_fork(), called first in enforce(), synthesises a custom_css: (or custom_css:* for a site write) touch alongside a widened fork's own page/post/site touches — never for other tools, never carrying evidence fields, so no allow rule can be satisfied by it. Test seam _set_fork_version_for_tests() added for fork_css_state(); wired into tests/bootstrap.php's sa_reset_state(). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../includes/class-aura-worker-rules.php | 65 +++++++++++++- tests/bootstrap.php | 1 + tests/unit/RulesOldForkWideningTest.php | 85 +++++++++++++++++++ 3 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 tests/unit/RulesOldForkWideningTest.php diff --git a/digitizer-site-worker/includes/class-aura-worker-rules.php b/digitizer-site-worker/includes/class-aura-worker-rules.php index 49f5359c..bf830e53 100644 --- a/digitizer-site-worker/includes/class-aura-worker-rules.php +++ b/digitizer-site-worker/includes/class-aura-worker-rules.php @@ -571,6 +571,68 @@ private static function css_rule_touches( array $rule, array $touched ) { return isset( $touched[ 'custom_css:' . $id ] ) || isset( $touched['custom_css:*'] ); } + /** Test seam: null = read the real constant; false = fork absent; string = that version. @var null|false|string */ + private static $fork_version_for_tests = null; + + /** + * @since 2.20.0 + * @param null|false|string $version See the property. + */ + public static function _set_fork_version_for_tests( $version ) { + self::$fork_version_for_tests = $version; + } + + /** + * Can the loaded elementor-mcp say whether a write carries CSS? + * `precise` — 1.37.0 or newer (css_touches); `widened` — an older or + * unreadable version, so its page writes count as possible CSS; + * `absent` — no fork. Reported on /status as css_rules.fork. + * + * @since 2.20.0 + * @return string + */ + public static function fork_css_state() { + $v = self::$fork_version_for_tests; + if ( null === $v ) { + $v = defined( 'ELEMENTOR_MCP_VERSION' ) ? (string) ELEMENTOR_MCP_VERSION : false; + } + if ( false === $v ) { + return 'absent'; + } + if ( ! preg_match( '/^\d+\.\d+\.\d+$/', (string) $v ) ) { + return 'widened'; + } + return version_compare( (string) $v, '1.37.0', '>=' ) ? 'precise' : 'widened'; + } + + /** + * An old fork's page writes, read as possible CSS (spec §4.2). Only the + * fork's own abilities; never evidence fields, so no allow can use them. + * + * @since 2.20.0 + * @param array $touches Declared touches. + * @param string $tool_name Calling tool. + * @return array + */ + private static function widen_for_old_fork( array $touches, $tool_name ) { + if ( 0 !== strpos( (string) $tool_name, 'elementor-mcp/' ) || 'widened' !== self::fork_css_state() ) { + return $touches; + } + $extra = array(); + foreach ( $touches as $t ) { + if ( ! is_array( $t ) || ! isset( $t['type'], $t['id'] ) ) { + continue; + } + $type = (string) $t['type']; + if ( 'page' === $type || 'post' === $type ) { + $extra[ (string) $t['id'] ] = array( 'type' => 'custom_css', 'id' => (string) $t['id'] ); + } elseif ( 'site' === $type ) { + $extra['*'] = array( 'type' => 'custom_css', 'id' => '*' ); + } + } + return array_merge( $touches, array_values( $extra ) ); + } + /* ------------------------------------------------------------------ */ /* The store — option-backed, signed, monotonic */ /* ------------------------------------------------------------------ */ @@ -2427,7 +2489,8 @@ public static function enforce( array $touches, $tool_name, $now = null ) { // 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. - $rule = self::enforceable_match( $touches, self::rules(), $now, self::site_ref() ); + $touches = self::widen_for_old_fork( $touches, $tool_name ); + $rule = self::enforceable_match( $touches, self::rules(), $now, self::site_ref() ); if ( null === $rule ) { return array( 'effect' => null ); } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index a9e6bd6b..4796412c 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -5392,6 +5392,7 @@ function sa_reset_state(): void { // than failing the test that actually left it set. Aura_Worker_Rules::$rest_request_override = null; Aura_Worker_Rules::$cookie_auth_override = null; + Aura_Worker_Rules::_set_fork_version_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 diff --git a/tests/unit/RulesOldForkWideningTest.php b/tests/unit/RulesOldForkWideningTest.php new file mode 100644 index 00000000..b630d531 --- /dev/null +++ b/tests/unit/RulesOldForkWideningTest.php @@ -0,0 +1,85 @@ + 'x.y', + 'seq' => 1, + 'issued_at' => '2026-08-21T00:00:00Z', + 'received_at' => time(), + 'rules' => $rules, + ); + } + + private function css_rule( string $effect, ?string $id ): array { + return array( 'key' => 'rule/css', 'effect' => $effect, 'target' => array( 'type' => 'custom_css', 'id' => $id ), 'reason' => 'no CSS', 'until' => null ); + } + + public function test_fork_state_follows_the_loaded_version(): void { + Aura_Worker_Rules::_set_fork_version_for_tests( false ); + $this->assertSame( 'absent', Aura_Worker_Rules::fork_css_state() ); + Aura_Worker_Rules::_set_fork_version_for_tests( '1.36.1' ); + $this->assertSame( 'widened', Aura_Worker_Rules::fork_css_state() ); + Aura_Worker_Rules::_set_fork_version_for_tests( '1.37.0' ); + $this->assertSame( 'precise', Aura_Worker_Rules::fork_css_state() ); + Aura_Worker_Rules::_set_fork_version_for_tests( 'not-a-version' ); + $this->assertSame( 'widened', Aura_Worker_Rules::fork_css_state(), 'unreadable counts as old' ); + } + + public function test_an_old_fork_page_edit_is_blocked_by_a_css_block(): void { + Aura_Worker_Rules::_set_fork_version_for_tests( '1.36.1' ); + $this->store( array( $this->css_rule( 'block', '42' ) ) ); + $v = Aura_Worker_Rules::enforce( array( array( 'type' => 'page', 'id' => '42' ), array( 'type' => 'post', 'id' => '42' ) ), 'elementor-mcp/update-element' ); + $this->assertSame( 'block', $v['effect'] ); + } + + public function test_an_old_fork_site_write_meets_an_id_rule_through_the_wildcard(): void { + Aura_Worker_Rules::_set_fork_version_for_tests( '1.36.1' ); + $this->store( array( $this->css_rule( 'warn', '42' ) ) ); + $v = Aura_Worker_Rules::enforce( array( array( 'type' => 'site', 'id' => '*' ) ), 'elementor-mcp/build-page' ); + $this->assertSame( 'warn', $v['effect'] ); + } + + public function test_a_current_fork_is_not_widened(): void { + Aura_Worker_Rules::_set_fork_version_for_tests( '1.37.0' ); + $this->store( array( $this->css_rule( 'block', '42' ) ) ); + $v = Aura_Worker_Rules::enforce( array( array( 'type' => 'page', 'id' => '42' ) ), 'elementor-mcp/update-element' ); + $this->assertNull( $v['effect'] ); + } + + public function test_only_the_forks_own_abilities_are_widened(): void { + Aura_Worker_Rules::_set_fork_version_for_tests( '1.36.1' ); + $this->store( array( $this->css_rule( 'block', '42' ) ) ); + foreach ( array( 'update_page_block', 'elementor/manage-elements', 'content__update_post' ) as $tool ) { + $v = Aura_Worker_Rules::enforce( array( array( 'type' => 'page', 'id' => '42' ) ), $tool ); + $this->assertNull( $v['effect'], "{$tool} must not be widened" ); + } + } + + public function test_widening_never_speaks_for_allow(): void { + Aura_Worker_Rules::_set_fork_version_for_tests( '1.36.1' ); + $this->store( array( $this->css_rule( 'allow', '42' ) ) ); + $v = Aura_Worker_Rules::enforce( array( array( 'type' => 'page', 'id' => '42' ) ), 'elementor-mcp/update-element' ); + $this->assertNull( $v['effect'] ); + } +} From 46a4f4d6aa8475c75b24908c8d4bc0ed852c3aee Mon Sep 17 00:00:00 2001 From: Ben Kalsky Date: Thu, 24 Sep 2026 18:52:34 +0300 Subject: [PATCH 04/13] =?UTF-8?q?feat(door):=20Elementor's=20own=20door=20?= =?UTF-8?q?declares=20custom=5Fcss=20=E2=80=94=20precise=20from=20named=20?= =?UTF-8?q?arguments,=20conservative=20from=20compositions=20(Aura#576)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- .../class-elementor-door-governor.php | 153 ++++++++++++++++++ tests/unit/ElementorDoorCssTouchesTest.php | 107 ++++++++++++ 2 files changed, 260 insertions(+) create mode 100644 tests/unit/ElementorDoorCssTouchesTest.php diff --git a/digitizer-site-worker/includes/class-elementor-door-governor.php b/digitizer-site-worker/includes/class-elementor-door-governor.php index 2134dad6..3b5a8ba0 100644 --- a/digitizer-site-worker/includes/class-elementor-door-governor.php +++ b/digitizer-site-worker/includes/class-elementor-door-governor.php @@ -2897,12 +2897,165 @@ public static function actor() { ); } + /** + * How each WRITE_TABLE slug relates to custom CSS (spec 2026-09-24 §4.2, + * plan rulings R1/R2). `precise` — CSS is read from named arguments; + * `conservative` — CSS may be inside content this class does not parse. + * Every WRITE_TABLE slug is in exactly one of CSS_PRODUCERS / NO_CSS + * (ElementorDoorCssClassificationTest). + * + * @since 2.20.0 + */ + const CSS_PRODUCERS = array( + 'elementor/update-page-settings' => 'precise', + 'elementor/manage-elements' => 'precise', + 'elementor/build-composition' => 'conservative', + 'elementor/manage-component' => 'conservative', + ); + + /** + * Writes that carry no custom CSS, each with the reason. A `css`-named + * property listed under `exempt` is design-system CSS (global classes, + * tag defaults) — plan ruling R1 — and is the ONLY CSS-capable property + * the schema guard tolerates for that slug. + * + * @since 2.20.0 + */ + const NO_CSS = array( + 'elementor/publish-document' => array( 'reason' => 'promotes an already-judged autosave', 'exempt' => array() ), + 'elementor/create-preview-link' => array( 'reason' => 'mints a preview URL; writes no content', 'exempt' => array() ), + 'elementor/create-page' => array( 'reason' => 'creates an empty document', 'exempt' => array() ), + 'elementor/manage-classes' => array( 'reason' => 'global-class CSS — design_system (R1)', 'exempt' => array( 'operations[].css' ) ), + 'elementor/manage-default-styles' => array( 'reason' => 'tag default styles — design_system (R1)', 'exempt' => array( 'operations[].css' ) ), + 'elementor/reorder-classes' => array( 'reason' => 'reorders class ids; no style content', 'exempt' => array() ), + 'elementor/manage-global-variable' => array( 'reason' => 'a variable value, not a stylesheet', 'exempt' => array() ), + ); + + /** + * CSS value classification. `null` / whitespace string = clearing (not CSS); + * a non-empty string = CSS read from the argument; anything else non-empty = + * CSS of unknown shape. + * + * @since 2.20.0 + * @param mixed $v Value. + * @return string 'none'|'css'|'unknown' + */ + private static function css_value( $v ) { + if ( null === $v ) { + return 'none'; + } + if ( is_string( $v ) ) { + return '' === trim( $v ) ? 'none' : 'css'; + } + return empty( $v ) && false !== $v && 0 !== $v ? 'none' : 'unknown'; + } + + /** + * The custom_css touches a door write declares, in addition to its + * page/post/design_system ones. Pure. + * + * @since 2.20.0 + * @param string $slug Ability. + * @param array $input Input. + * @param string $id Resolved post id, or '*'. + * @return array + */ + public static function css_touches_for( $slug, array $input, $id ) { + $id = (string) $id; + $kind = isset( self::CSS_PRODUCERS[ $slug ] ) ? self::CSS_PRODUCERS[ $slug ] : null; + if ( null === $kind ) { + return array(); + } + if ( 'conservative' === $kind ) { + return array( array( 'type' => 'custom_css', 'id' => $id ) ); + } + $found = 'none'; // none | css | unknown + $css_only = true; + if ( 'elementor/update-page-settings' === $slug ) { + $settings = isset( $input['settings'] ) && is_array( $input['settings'] ) ? $input['settings'] : array(); + $found = array_key_exists( 'custom_css', $settings ) ? self::css_value( $settings['custom_css'] ) : 'none'; + // The WHOLE input, not just `settings` (Codex r4): any other + // top-level field is an effect this allow never looked at. + $css_only = array( 'custom_css' ) === array_keys( $settings ) + && array() === array_diff( array_keys( $input ), array( 'post_id', 'settings' ) ); + } elseif ( 'elementor/manage-elements' === $slug ) { + $ops = isset( $input['operations'] ) ? $input['operations'] : null; + if ( array() !== array_diff( array_keys( $input ), array( 'post_id', 'operations' ) ) ) { + $css_only = false; // an unknown top-level field is an unreviewed effect (Codex r4) + } + if ( ! is_array( $ops ) ) { + return array( array( 'type' => 'custom_css', 'id' => $id ) ); + } + foreach ( $ops as $op ) { + if ( ! is_array( $op ) ) { + $found = 'unknown'; + $css_only = false; + continue; + } + $op_css = 'none'; + if ( array_key_exists( 'style', $op ) ) { + $op_css = self::css_value( $op['style'] ); + } + $settings = isset( $op['settings'] ) && is_array( $op['settings'] ) ? $op['settings'] : array(); + if ( array_key_exists( 'custom_css', $settings ) ) { + $s = self::css_value( $settings['custom_css'] ); + $op_css = 'unknown' === $s || 'unknown' === $op_css ? 'unknown' : ( 'css' === $s ? 'css' : $op_css ); + } + if ( 'unknown' === $op_css || ( 'css' === $op_css && 'unknown' !== $found ) ) { + $found = 'unknown' === $op_css ? 'unknown' : 'css'; + } + $only_css_keys = ( isset( $op['action'] ) && 'update' === $op['action'] ) + && array() === array_diff( array_keys( $op ), array( 'action', 'element_id', 'style', 'style_apply_mode', 'settings' ) ) + && array() === array_diff( array_keys( $settings ), array( 'custom_css' ) ) + && 'none' !== $op_css; + if ( ! $only_css_keys ) { + $css_only = false; + } + } + } + if ( 'none' === $found ) { + return array(); + } + $touch = array( 'type' => 'custom_css', 'id' => $id ); + if ( 'css' === $found && ctype_digit( $id ) ) { + $touch['precise'] = true; + if ( $css_only ) { + $touch['css_only'] = true; + } + } + return array( $touch ); + } + /** * @param string $slug Ability. * @param array $input Input. * @return array|WP_Error touches, or aura_target_unattributed. */ public static function touches_for( $slug, array $input ) { + $base = self::base_touches_for( $slug, $input ); + if ( is_wp_error( $base ) ) { + return $base; + } + // The CSS declaration rides EVERY kind (spec 2026-09-24 §4.2): a + // `page`-kind write names its post (the FIRST touch base_touches_for() + // returns for that kind); every other kind is site-wide — including a + // class deletion, whose extra page touches are collateral, not the + // write's own target. + $kind = isset( self::WRITE_TABLE[ $slug ] ) ? self::WRITE_TABLE[ $slug ] : null; + $id = ( 'page' === $kind && isset( $base[0]['id'] ) ) ? (string) $base[0]['id'] : '*'; + return array_merge( $base, self::css_touches_for( $slug, $input, $id ) ); + } + + /** + * The target/collateral touches for a governed write, before the CSS + * declaration `touches_for()` appends (spec 2026-09-24 §4.2). + * + * @since 2.20.0 + * @param string $slug Ability. + * @param array $input Input. + * @return array|WP_Error touches, or aura_target_unattributed. + */ + private static function base_touches_for( $slug, array $input ) { $kind = isset( self::WRITE_TABLE[ $slug ] ) ? self::WRITE_TABLE[ $slug ] : null; switch ( $kind ) { case 'page': diff --git a/tests/unit/ElementorDoorCssTouchesTest.php b/tests/unit/ElementorDoorCssTouchesTest.php new file mode 100644 index 00000000..deac158e --- /dev/null +++ b/tests/unit/ElementorDoorCssTouchesTest.php @@ -0,0 +1,107 @@ +assertSame( + array( array( 'type' => 'custom_css', 'id' => '42', 'precise' => true, 'css_only' => true ) ), + $this->t( 'elementor/update-page-settings', array( 'post_id' => 42, 'settings' => array( 'custom_css' => 'body{color:red}' ) ) ) + ); + } + + public function test_page_settings_with_css_and_a_title_is_precise_but_mixed(): void { + $this->assertSame( + array( array( 'type' => 'custom_css', 'id' => '42', 'precise' => true ) ), + $this->t( 'elementor/update-page-settings', array( 'post_id' => 42, 'settings' => array( 'custom_css' => 'a{}', 'post_title' => 'x' ) ) ) + ); + } + + public function test_css_value_shapes(): void { + foreach ( array( null, '', ' ', "\n\t" ) as $clear ) { + $this->assertSame( array(), $this->t( 'elementor/update-page-settings', array( 'settings' => array( 'custom_css' => $clear ) ) ), 'clearing is not CSS: ' . var_export( $clear, true ) ); + } + foreach ( array( array( 'color:red' ), 123, true ) as $odd ) { + $this->assertSame( + array( array( 'type' => 'custom_css', 'id' => '42' ) ), + $this->t( 'elementor/update-page-settings', array( 'settings' => array( 'custom_css' => $odd ) ) ), + 'unknown shape is conservative: ' . var_export( $odd, true ) + ); + } + } + + public function test_an_extra_top_level_field_makes_the_call_mixed(): void { + $this->assertSame( + array( array( 'type' => 'custom_css', 'id' => '42', 'precise' => true ) ), + $this->t( 'elementor/update-page-settings', array( 'post_id' => 42, 'settings' => array( 'custom_css' => 'a{}' ), 'status' => 'publish' ) ) + ); + $this->assertSame( + array( array( 'type' => 'custom_css', 'id' => '42', 'precise' => true ) ), + $this->t( 'elementor/manage-elements', array( 'post_id' => 42, 'operations' => array( array( 'action' => 'update', 'element_id' => 'a', 'style' => 'x' ) ), 'publish' => true ) ) + ); + } + + public function test_page_settings_without_css_declares_nothing(): void { + $this->assertSame( array(), $this->t( 'elementor/update-page-settings', array( 'settings' => array( 'post_title' => 'x' ) ) ) ); + } + + public function test_manage_elements_style_only_batch_is_css_only(): void { + $in = array( 'operations' => array( + array( 'action' => 'update', 'element_id' => 'a1', 'style' => 'color:red' ), + array( 'action' => 'update', 'element_id' => 'b2', 'settings' => array( 'custom_css' => 'selector{}' ), 'style_apply_mode' => 'patch' ), + ) ); + $this->assertSame( array( array( 'type' => 'custom_css', 'id' => '42', 'precise' => true, 'css_only' => true ) ), $this->t( 'elementor/manage-elements', $in ) ); + } + + public function test_manage_elements_css_plus_a_delete_is_mixed(): void { + $in = array( 'operations' => array( + array( 'action' => 'update', 'element_id' => 'a1', 'style' => 'color:red' ), + array( 'action' => 'delete', 'element_id' => 'b2' ), + ) ); + $this->assertSame( array( array( 'type' => 'custom_css', 'id' => '42', 'precise' => true ) ), $this->t( 'elementor/manage-elements', $in ) ); + } + + public function test_manage_elements_css_plus_a_title_setting_is_mixed(): void { + $in = array( 'operations' => array( array( 'action' => 'update', 'element_id' => 'a1', 'settings' => array( 'custom_css' => 'x{}', 'title' => 'Hi' ) ) ) ); + $this->assertSame( array( array( 'type' => 'custom_css', 'id' => '42', 'precise' => true ) ), $this->t( 'elementor/manage-elements', $in ) ); + } + + public function test_manage_elements_without_css_declares_nothing(): void { + $in = array( 'operations' => array( array( 'action' => 'move', 'element_id' => 'a1', 'new_parent_id' => 'document' ), array( 'action' => 'update', 'element_id' => 'b', 'style' => ' ' ) ) ); + $this->assertSame( array(), $this->t( 'elementor/manage-elements', $in ) ); + } + + public function test_manage_elements_with_a_non_array_operations_is_conservative(): void { + $this->assertSame( array( array( 'type' => 'custom_css', 'id' => '42' ) ), $this->t( 'elementor/manage-elements', array( 'operations' => 'garbage' ) ) ); + } + + public function test_build_composition_is_a_conservative_producer(): void { + $this->assertSame( array( array( 'type' => 'custom_css', 'id' => '42' ) ), $this->t( 'elementor/build-composition', array( 'post_id' => 42, 'xml_structure' => '' ) ) ); + } + + public function test_manage_component_is_a_conservative_site_wide_producer(): void { + $this->assertSame( array( array( 'type' => 'custom_css', 'id' => '*' ) ), $this->t( 'elementor/manage-component', array( 'action' => 'create' ), '*' ) ); + } + + public function test_design_system_and_other_writes_declare_no_css(): void { + foreach ( array( 'elementor/manage-classes', 'elementor/manage-default-styles', 'elementor/reorder-classes', 'elementor/manage-global-variable', 'elementor/publish-document', 'elementor/create-preview-link', 'elementor/create-page' ) as $slug ) { + $this->assertSame( array(), $this->t( $slug, array( 'operations' => array( array( 'css' => 'a{}' ) ) ) ), $slug ); + } + } +} From 1a574b6ee1471d8d7457793e31c5a2b994bf781c Mon Sep 17 00:00:00 2001 From: Ben Kalsky Date: Thu, 24 Sep 2026 19:06:38 +0300 Subject: [PATCH 05/13] test(door): every write is classified for CSS; NO_CSS is checked against its schema, at test and at run time (Aura#576) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../class-elementor-door-governor.php | 113 +++++++++- .../fixtures/elementor-4.3-write-schemas.json | 195 ++++++++++++++++++ .../ElementorDoorCssClassificationTest.php | 140 +++++++++++++ tests/unit/ElementorDoorCssTouchesTest.php | 7 + 4 files changed, 454 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/elementor-4.3-write-schemas.json create mode 100644 tests/unit/ElementorDoorCssClassificationTest.php diff --git a/digitizer-site-worker/includes/class-elementor-door-governor.php b/digitizer-site-worker/includes/class-elementor-door-governor.php index 3b5a8ba0..6fff963e 100644 --- a/digitizer-site-worker/includes/class-elementor-door-governor.php +++ b/digitizer-site-worker/includes/class-elementor-door-governor.php @@ -202,6 +202,7 @@ public static function reset_for_tests() { self::$request = null; self::$active = null; self::$seq_lease = null; + self::$schema_reader = null; Aura_Worker_Door_Log::forget_live_identity(); // $GLOBALS['_sa_force_door'] — active()'s test override, standing in // for the module class this suite cannot define — is reset by @@ -2931,6 +2932,99 @@ public static function actor() { 'elementor/manage-global-variable' => array( 'reason' => 'a variable value, not a stylesheet', 'exempt' => array() ), ); + /** CSS-capable property names (spec §4.1 guard). @since 2.20.0 */ + const CSS_PROPERTY_NAMES = array( 'custom_css', 'css', 'style', 'settings', 'page_settings', 'elements', 'structure', 'xml_structure', 'element_config' ); + + /** Forwarded opaque strings — markup or code that may embed a stylesheet (Codex r3). @since 2.20.0 */ + const OPAQUE_PROPERTY_PATTERN = '/css|style|content|html|markup|template|code/i'; // also CSS-named variants: extra_css, inline_style (Codex r5) + + /** + * The input paths each precise producer's handler actually reads for CSS + * (Codex r3). A live schema that grows any OTHER CSS-capable path makes the + * producer conservative until the handler learns it. + * + * @since 2.20.0 + */ + const PRODUCER_HANDLED_PATHS = array( + 'elementor/update-page-settings' => array( 'settings' ), + 'elementor/manage-elements' => array( 'operations[].settings', 'operations[].style', 'operations[].style_apply_mode' ), // style_apply_mode is the patch|replace merge mode, matched by the name net's "style" substring — never CSS text (controller ruling). + ); + + /** @var callable|null test seam: fn( string $slug ): ?array @since 2.20.0 */ + private static $schema_reader = null; + + /** @since 2.20.0 */ + public static function _set_schema_reader_for_tests( $fn ) { + self::$schema_reader = $fn; + } + + /** + * Paths through which a JSON schema can carry CSS: a property with a + * CSS-capable name, or an object open to any property. Arrays recurse + * into `items` as `name[].child`. + * + * @since 2.20.0 + * @param array $schema Schema. + * @param string $prefix Path so far. + * @return string[] + */ + public static function css_capable_paths( array $schema, $prefix = '' ) { + return array_values( array_unique( self::css_capable_paths_raw( $schema, $prefix ) ) ); + } + + /** @since 2.20.0 @param array $schema Schema. @param string $prefix Path so far. @return string[] (may repeat — an open named container is reached twice) */ + private static function css_capable_paths_raw( array $schema, $prefix ) { + $out = array(); + // The node ITSELF may be open (Codex r1 on the plan): a root, or an + // array item, that accepts any property can carry CSS under any name. + // Explicit `true` or a schema-valued `additionalProperties` counts; + // an absent key does not (WP schemas omit it everywhere). + if ( isset( $schema['additionalProperties'] ) && ( true === $schema['additionalProperties'] || is_array( $schema['additionalProperties'] ) ) ) { + $out[] = '' === $prefix ? '(root)' : $prefix; + } + $props = isset( $schema['properties'] ) && is_array( $schema['properties'] ) ? $schema['properties'] : array(); + foreach ( $props as $name => $sub ) { + $path = '' === $prefix ? (string) $name : $prefix . '.' . $name; + $sub = is_array( $sub ) ? $sub : array(); + if ( in_array( (string) $name, self::CSS_PROPERTY_NAMES, true ) || preg_match( self::OPAQUE_PROPERTY_PATTERN, (string) $name ) ) { + $out[] = $path; + // Keep descending (Codex r4): a container already on a handled + // list can still grow a CSS child (`settings.css`) the handler + // does not read — that child must surface as its own path. + } + if ( isset( $sub['items'] ) && is_array( $sub['items'] ) ) { + foreach ( self::css_capable_paths_raw( $sub['items'], '' ) as $child ) { + $out[] = '(root)' === $child ? $path . '[]' : $path . '[].' . $child; + } + } elseif ( isset( $sub['properties'] ) || isset( $sub['additionalProperties'] ) ) { + $out = array_merge( $out, self::css_capable_paths_raw( $sub, $path ) ); + } + } + return $out; + } + + /** + * The ability's live input schema, or null when unreadable. + * + * @since 2.20.0 + * @param string $slug Ability. + * @return array|null + */ + private static function live_input_schema( $slug ) { + if ( null !== self::$schema_reader ) { + return call_user_func( self::$schema_reader, $slug ); + } + if ( ! function_exists( 'wp_get_ability' ) ) { + return null; + } + $ability = wp_get_ability( $slug ); + if ( ! $ability || ! method_exists( $ability, 'get_input_schema' ) ) { + return null; + } + $schema = $ability->get_input_schema(); + return is_array( $schema ) ? $schema : null; + } + /** * CSS value classification. `null` / whitespace string = clearing (not CSS); * a non-empty string = CSS read from the argument; anything else non-empty = @@ -2961,7 +3055,18 @@ private static function css_value( $v ) { * @return array */ public static function css_touches_for( $slug, array $input, $id ) { - $id = (string) $id; + $id = (string) $id; + if ( isset( self::NO_CSS[ $slug ] ) ) { + // A NO_CSS entry is checked, not trusted: if the LIVE schema grew a + // CSS-capable property beyond the recorded exemptions (an Elementor + // release after 4.3.0-beta3), declare conservatively rather than + // let a custom_css block rule be bypassed. + $schema = self::live_input_schema( $slug ); + if ( is_array( $schema ) && array() !== array_diff( self::css_capable_paths( $schema ), self::NO_CSS[ $slug ]['exempt'] ) ) { + return array( array( 'type' => 'custom_css', 'id' => (string) $id ) ); + } + return array(); + } $kind = isset( self::CSS_PRODUCERS[ $slug ] ) ? self::CSS_PRODUCERS[ $slug ] : null; if ( null === $kind ) { return array(); @@ -2969,6 +3074,12 @@ public static function css_touches_for( $slug, array $input, $id ) { if ( 'conservative' === $kind ) { return array( array( 'type' => 'custom_css', 'id' => $id ) ); } + // A precise producer whose LIVE schema grew a CSS-capable path its + // handler does not read is conservative until it does (Codex r3). + $live = self::live_input_schema( $slug ); + if ( is_array( $live ) && array() !== array_diff( self::css_capable_paths( $live ), isset( self::PRODUCER_HANDLED_PATHS[ $slug ] ) ? self::PRODUCER_HANDLED_PATHS[ $slug ] : array() ) ) { + return array( array( 'type' => 'custom_css', 'id' => $id ) ); + } $found = 'none'; // none | css | unknown $css_only = true; if ( 'elementor/update-page-settings' === $slug ) { diff --git a/tests/fixtures/elementor-4.3-write-schemas.json b/tests/fixtures/elementor-4.3-write-schemas.json new file mode 100644 index 00000000..1823ed8a --- /dev/null +++ b/tests/fixtures/elementor-4.3-write-schemas.json @@ -0,0 +1,195 @@ +{ + "source": "Elementor 4.3.0-beta3 (Digitizers/references elementor-core-mcp), input schemas", + "files": { + "elementor/publish-document": "publish-document-ability.php", + "elementor/manage-elements": "manage-elements-ability.php", + "elementor/update-page-settings": "update-settings-ability.php", + "elementor/build-composition": "build-composition-ability.php", + "elementor/create-preview-link": "create-preview-link-ability.php", + "elementor/manage-component": "manage-component-ability.php", + "elementor/manage-default-styles": "manage-default-styles-ability.php", + "elementor/manage-classes": "manage-classes-ability.php", + "elementor/reorder-classes": "reorder-classes-ability.php", + "elementor/manage-global-variable": "manage-variable-ability.php", + "elementor/create-page": "create-page-ability.php" + }, + "schemas": { + "elementor/publish-document": { + "type": "object", + "required": ["post_id"], + "properties": { + "post_id": { "type": "integer" } + } + }, + "elementor/manage-elements": { + "type": "object", + "required": ["post_id", "operations"], + "properties": { + "post_id": { "type": "integer" }, + "operations": { + "type": "array", + "items": { + "type": "object", + "required": ["action", "element_id"], + "properties": { + "action": { "type": "string", "enum": ["update", "delete", "move", "duplicate"] }, + "element_id": { "type": "string" }, + "settings": { "type": "object" }, + "style": { "type": "string" }, + "style_apply_mode": { "type": "string", "enum": ["patch", "replace"] }, + "classes": { "type": "array", "items": { "type": "string" } }, + "interactions": { "type": "array", "items": { "type": "object" } }, + "new_parent_id": { "type": "string" }, + "index": { "type": ["integer", "null"] } + } + } + } + } + }, + "elementor/update-page-settings": { + "type": "object", + "required": ["post_id", "settings"], + "properties": { + "post_id": { "type": "integer" }, + "settings": { "type": "object", "additionalProperties": true } + } + }, + "elementor/build-composition": { + "type": "object", + "required": ["post_id", "xml_structure"], + "properties": { + "post_id": { "type": "integer" }, + "xml_structure": { "type": "string" }, + "element_config": { "type": "object" }, + "style": { "type": "object", "additionalProperties": { "type": "string" } }, + "classes": { + "type": "object", + "additionalProperties": { "type": "array", "items": { "type": "string" } } + }, + "interactions": { + "type": "object", + "additionalProperties": { "type": "array", "items": { "type": "object" } } + }, + "parent_id": { "type": "string" }, + "dry_run": { "type": "boolean" }, + "mode": { "type": "string", "enum": ["append", "replace_children"] } + } + }, + "elementor/create-preview-link": { + "type": "object", + "required": ["post_id"], + "properties": { + "post_id": { "type": "integer" } + } + }, + "elementor/manage-component": { + "type": "object", + "required": ["action"], + "properties": { + "action": { "type": "string", "enum": ["create", "update", "rename", "archive", "publish"] }, + "title": { "type": "string" }, + "source_post_id": { "type": "integer" }, + "element_id": { "type": "string" }, + "xml_structure": { "type": "string" }, + "element_config": { "type": "object" }, + "classes": { "type": "object" }, + "style": { "type": "object" }, + "interactions": { "type": "object" }, + "overridable_props": { "type": "object" }, + "publish_status": { "type": "string", "enum": ["publish", "draft"] }, + "component_id": { "type": "integer" }, + "component_ids": { "type": "array", "items": { "type": "integer" } } + } + }, + "elementor/manage-default-styles": { + "type": "object", + "required": ["operations"], + "properties": { + "operations": { + "type": "array", + "items": { + "type": "object", + "required": ["action", "tag"], + "properties": { + "action": { "type": "string", "enum": ["update", "delete"] }, + "tag": { "type": "string" }, + "css": { "type": "string" }, + "mode": { "type": "string", "enum": ["patch", "replace"] } + } + } + } + } + }, + "elementor/manage-classes": { + "type": "object", + "required": ["operations"], + "properties": { + "operations": { + "type": "array", + "items": { + "type": "object", + "required": ["action"], + "properties": { + "action": { "type": "string", "enum": ["create", "update", "delete"] }, + "id": { "type": "string" }, + "label": { "type": "string" }, + "css": { "type": "string" }, + "mode": { "type": "string", "enum": ["patch", "replace"] } + } + } + } + } + }, + "elementor/reorder-classes": { + "type": "object", + "properties": { + "moves": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "position"], + "properties": { + "id": { "type": "string" }, + "position": { "type": "string", "enum": ["before", "after", "start", "end"] }, + "ref": { "type": "string" } + } + } + }, + "order": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "elementor/manage-global-variable": { + "type": "object", + "required": ["operations"], + "properties": { + "operations": { + "type": "array", + "items": { + "type": "object", + "required": ["action"], + "properties": { + "action": { "type": "string", "enum": ["create", "update", "delete"] }, + "id": { "type": "string" }, + "type": { + "type": "string", + "enum": ["global-color-variable", "global-font-variable", "global-size-variable", "global-custom-size-variable"] + }, + "label": { "type": "string" }, + "value": { "type": "string" } + } + } + } + } + }, + "elementor/create-page": { + "type": "object", + "properties": { + "title": { "type": "string" }, + "post_type": { "type": "string" } + } + } + } +} diff --git a/tests/unit/ElementorDoorCssClassificationTest.php b/tests/unit/ElementorDoorCssClassificationTest.php new file mode 100644 index 00000000..c6b9699b --- /dev/null +++ b/tests/unit/ElementorDoorCssClassificationTest.php @@ -0,0 +1,140 @@ +assertSame( 1, $in, "{$slug} must be classified exactly once" ); + } + $this->assertSame( array(), array_diff( array_keys( Aura_Worker_Elementor_Door::CSS_PRODUCERS ), array_keys( Aura_Worker_Elementor_Door::WRITE_TABLE ) ) ); + $this->assertSame( array(), array_diff( array_keys( Aura_Worker_Elementor_Door::NO_CSS ), array_keys( Aura_Worker_Elementor_Door::WRITE_TABLE ) ) ); + } + + public function test_the_fixture_covers_every_write(): void { + $this->assertEqualsCanonicalizing( array_keys( Aura_Worker_Elementor_Door::WRITE_TABLE ), array_keys( $this->schemas() ) ); + } + + public function test_a_no_css_entry_cannot_carry_css_beyond_its_exemptions(): void { + foreach ( Aura_Worker_Elementor_Door::NO_CSS as $slug => $entry ) { + $paths = Aura_Worker_Elementor_Door::css_capable_paths( $this->schemas()[ $slug ] ); + $this->assertSame( array(), array_values( array_diff( $paths, $entry['exempt'] ) ), "{$slug} can carry CSS — move it to CSS_PRODUCERS" ); + } + } + + public function test_css_capable_paths_finds_named_and_open_properties(): void { + $schema = array( 'type' => 'object', 'properties' => array( + 'post_id' => array( 'type' => 'integer' ), + 'settings' => array( 'type' => 'object' ), + 'operations' => array( 'type' => 'array', 'items' => array( 'type' => 'object', 'properties' => array( 'style' => array( 'type' => 'string' ), 'label' => array( 'type' => 'string' ) ) ) ), + 'meta' => array( 'type' => 'object', 'additionalProperties' => true ), + ) ); + $this->assertEqualsCanonicalizing( array( 'settings', 'operations[].style', 'meta' ), Aura_Worker_Elementor_Door::css_capable_paths( $schema ) ); + } + + public function test_an_open_root_or_open_array_item_is_css_capable(): void { + $this->assertSame( array( '(root)' ), Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object', 'additionalProperties' => true ) ) ); + $this->assertSame( array( '(root)' ), Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object', 'additionalProperties' => array( 'type' => 'string' ) ) ) ); + $this->assertSame( + array( 'ops[]' ), + Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object', 'properties' => array( 'ops' => array( 'type' => 'array', 'items' => array( 'type' => 'object', 'additionalProperties' => true ) ) ) ) ) + ); + $this->assertSame( array(), Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object', 'additionalProperties' => false, 'properties' => array( 'id' => array( 'type' => 'integer' ) ) ) ) ); + } + + public function test_a_live_schema_open_at_the_root_makes_a_no_css_write_conservative(): void { + Aura_Worker_Elementor_Door::_set_schema_reader_for_tests( function () { + return array( 'type' => 'object', 'additionalProperties' => true ); + } ); + $this->assertSame( array( array( 'type' => 'custom_css', 'id' => '42' ) ), Aura_Worker_Elementor_Door::css_touches_for( 'elementor/publish-document', array(), '42' ) ); + } + + public function test_a_live_schema_that_grew_css_makes_a_no_css_write_conservative(): void { + Aura_Worker_Elementor_Door::_set_schema_reader_for_tests( function ( $slug ) { + return 'elementor/publish-document' === $slug + ? array( 'type' => 'object', 'properties' => array( 'post_id' => array( 'type' => 'integer' ), 'custom_css' => array( 'type' => 'string' ) ) ) + : null; + } ); + $this->assertSame( array( array( 'type' => 'custom_css', 'id' => '42' ) ), Aura_Worker_Elementor_Door::css_touches_for( 'elementor/publish-document', array( 'post_id' => 42 ), '42' ) ); + } + + public function test_a_css_child_under_a_named_container_surfaces(): void { + $this->assertEqualsCanonicalizing( + array( 'settings', 'settings.css' ), + Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object', 'properties' => array( 'settings' => array( 'type' => 'object', 'properties' => array( 'css' => array( 'type' => 'string' ), 'title' => array( 'type' => 'string' ) ) ) ) ) ) + ); + } + + public function test_opaque_forwarded_strings_are_css_capable(): void { + $this->assertEqualsCanonicalizing( + array( 'content', 'ops[].markup' ), + Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object', 'properties' => array( + 'content' => array( 'type' => 'string' ), + 'ops' => array( 'type' => 'array', 'items' => array( 'type' => 'object', 'properties' => array( 'markup' => array( 'type' => 'string' ), 'id' => array( 'type' => 'string' ) ) ) ), + ) ) ) + ); + } + + public function test_a_precise_producer_whose_schema_grew_an_unhandled_path_turns_conservative(): void { + Aura_Worker_Elementor_Door::_set_schema_reader_for_tests( function ( $slug ) { + return 'elementor/update-page-settings' === $slug + ? array( 'type' => 'object', 'properties' => array( 'post_id' => array( 'type' => 'integer' ), 'settings' => array( 'type' => 'object' ), 'extra_css' => array( 'type' => 'string' ) ) ) + : null; + } ); + $this->assertSame( + array( array( 'type' => 'custom_css', 'id' => '42' ) ), + Aura_Worker_Elementor_Door::css_touches_for( 'elementor/update-page-settings', array( 'settings' => array( 'custom_css' => 'a{}' ) ), '42' ) + ); + } + + public function test_the_handled_paths_match_the_fixture_schemas(): void { + foreach ( Aura_Worker_Elementor_Door::PRODUCER_HANDLED_PATHS as $slug => $paths ) { + $this->assertEqualsCanonicalizing( $paths, Aura_Worker_Elementor_Door::css_capable_paths( $this->schemas()[ $slug ] ), "{$slug}: handled paths drifted from the 4.3 schema" ); + } + } + + public function test_a_live_schema_with_only_exempt_css_stays_no_css(): void { + Aura_Worker_Elementor_Door::_set_schema_reader_for_tests( function ( $slug ) { + return array( 'type' => 'object', 'properties' => array( 'operations' => array( 'type' => 'array', 'items' => array( 'type' => 'object', 'properties' => array( 'css' => array( 'type' => 'string' ) ) ) ) ) ); + } ); + $this->assertSame( array(), Aura_Worker_Elementor_Door::css_touches_for( 'elementor/manage-classes', array(), '*' ) ); + } + + /** + * The runtime guard rides `touches_for()`, not just `css_touches_for()` + * (Controller ruling on Task 4): a `design_system` write whose LIVE + * schema grew a CSS-capable path beyond its exemptions must still carry + * its design_system touch(es) — the conservative custom_css touch is + * additive, never a replacement, for a call the door would otherwise + * let through as `design_system:*` alone. + */ + public function test_a_design_system_write_with_a_grown_live_schema_declares_both_design_system_and_custom_css(): void { + Aura_Worker_Elementor_Door::_set_schema_reader_for_tests( function () { + return array( 'type' => 'object', 'additionalProperties' => true ); + } ); + $input = array( 'operations' => array( array( 'action' => 'create', 'label' => 'foo', 'css' => 'color:red' ) ) ); + $result = Aura_Worker_Elementor_Door::touches_for( 'elementor/manage-classes', $input ); + $this->assertIsArray( $result ); + $this->assertContains( array( 'type' => 'design_system', 'id' => '*' ), $result ); + $this->assertContains( array( 'type' => 'custom_css', 'id' => '*' ), $result ); + } +} diff --git a/tests/unit/ElementorDoorCssTouchesTest.php b/tests/unit/ElementorDoorCssTouchesTest.php index deac158e..98727db8 100644 --- a/tests/unit/ElementorDoorCssTouchesTest.php +++ b/tests/unit/ElementorDoorCssTouchesTest.php @@ -13,6 +13,13 @@ final class ElementorDoorCssTouchesTest extends TestCase { protected function setUp(): void { sa_reset_state(); require_once dirname( __DIR__, 2 ) . '/digitizer-site-worker/includes/class-elementor-door-governor.php'; + // This is a pure producer-classification test: the runtime schema + // guard (Task 4) has its own suite. Pin the reader to "unreadable" so + // a bootstrap wp_get_ability() stub never makes a NO_CSS/precise- + // producer case here start exercising that guard instead. + Aura_Worker_Elementor_Door::_set_schema_reader_for_tests( function () { + return null; + } ); } private function t( string $slug, array $input, string $id = '42' ): array { From 0960807474cdc88c4c8bc2cb01470f4c7a6ed0d4 Mon Sep 17 00:00:00 2001 From: Ben Kalsky Date: Thu, 24 Sep 2026 19:11:42 +0300 Subject: [PATCH 06/13] =?UTF-8?q?feat(status):=20css=5Frules.fork=20?= =?UTF-8?q?=E2=80=94=20precise,=20widened=20or=20absent=20(Aura#576)?= 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-api.php | 4 +++ tests/unit/StatusCssRulesTest.php | 31 +++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 tests/unit/StatusCssRulesTest.php diff --git a/digitizer-site-worker/includes/class-aura-worker-api.php b/digitizer-site-worker/includes/class-aura-worker-api.php index 7ddd6d4a..d0123a93 100644 --- a/digitizer-site-worker/includes/class-aura-worker-api.php +++ b/digitizer-site-worker/includes/class-aura-worker-api.php @@ -663,6 +663,10 @@ public function get_status( $request ) { ); } + // Whether this site's elementor-mcp can say a write carried CSS (2.20.0, + // Aura spec 2026-09-24 §4.2). An OBJECT on the wire, like `unbound`. + $status['css_rules'] = (object) array( 'fork' => Aura_Worker_Rules::fork_css_state() ); + return rest_ensure_response( $status ); } diff --git a/tests/unit/StatusCssRulesTest.php b/tests/unit/StatusCssRulesTest.php new file mode 100644 index 00000000..71788224 --- /dev/null +++ b/tests/unit/StatusCssRulesTest.php @@ -0,0 +1,31 @@ +get_status( new WP_REST_Request( 'GET', '/aura/v1/status' ) )->get_data(); + } + + public function test_css_rules_is_an_object_naming_the_fork_state(): void { + foreach ( array( array( false, 'absent' ), array( '1.36.1', 'widened' ), array( '1.37.0', 'precise' ) ) as $case ) { + list( $v, $want ) = $case; + Aura_Worker_Rules::_set_fork_version_for_tests( $v ); + $body = $this->body(); + $this->assertIsObject( $body['css_rules'] ); + $this->assertSame( '{"fork":"' . $want . '"}', wp_json_encode( $body['css_rules'] ) ); + } + } +} From f687dd084f09816b7e484477c292750a5148aada Mon Sep 17 00:00:00 2001 From: Ben Kalsky Date: Thu, 24 Sep 2026 19:30:47 +0300 Subject: [PATCH 07/13] fix(rules): precise needs Elementor_MCP_Rules::css_touches(), not just 1.37.0 (final review) A version number is not a capability. fork_css_state() now returns 'precise' only when ELEMENTOR_MCP_VERSION >= 1.37.0 AND the fork ships Elementor_MCP_Rules::css_touches(); a loaded fork failing either is 'widened', so a 1.37.0 released without the matcher keeps its page writes counted as possible CSS instead of silently escaping block custom_css. The test seam takes an optional capability flag (null = method_exists). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../includes/class-aura-worker-rules.php | 41 +++++++++++++++---- tests/unit/RulesOldForkWideningTest.php | 34 +++++++++++++-- tests/unit/StatusCssRulesTest.php | 14 +++++-- 3 files changed, 76 insertions(+), 13 deletions(-) diff --git a/digitizer-site-worker/includes/class-aura-worker-rules.php b/digitizer-site-worker/includes/class-aura-worker-rules.php index bf830e53..ce938dae 100644 --- a/digitizer-site-worker/includes/class-aura-worker-rules.php +++ b/digitizer-site-worker/includes/class-aura-worker-rules.php @@ -574,19 +574,39 @@ private static function css_rule_touches( array $rule, array $touched ) { /** Test seam: null = read the real constant; false = fork absent; string = that version. @var null|false|string */ private static $fork_version_for_tests = null; + /** + * Test seam: null = ask the loaded code (method_exists); bool = pretend + * Elementor_MCP_Rules::css_touches() is (true) or is not (false) there. + * + * @since 2.20.0 + * @var null|bool + */ + private static $fork_css_touches_for_tests = null; + /** * @since 2.20.0 - * @param null|false|string $version See the property. + * @param null|false|string $version See the property. + * @param null|bool $has_css_touches See $fork_css_touches_for_tests. */ - public static function _set_fork_version_for_tests( $version ) { - self::$fork_version_for_tests = $version; + public static function _set_fork_version_for_tests( $version, $has_css_touches = null ) { + self::$fork_version_for_tests = $version; + self::$fork_css_touches_for_tests = $has_css_touches; } /** * Can the loaded elementor-mcp say whether a write carries CSS? - * `precise` — 1.37.0 or newer (css_touches); `widened` — an older or - * unreadable version, so its page writes count as possible CSS; - * `absent` — no fork. Reported on /status as css_rules.fork. + * `precise` — 1.37.0 or newer AND it ships Elementor_MCP_Rules::css_touches(); + * `widened` — the fork is loaded but fails either test, so its page + * writes count as possible CSS; `absent` — no fork. Reported on /status + * as css_rules.fork. + * + * Why both: a version number is not a capability (final review I1). The + * 1.37.0 floor assumes that release is the one Plan B ships with the + * public static Elementor_MCP_Rules::css_touches(); if any other change + * went out as 1.37.0 first, a version-only check would stop widening a + * fork that declares no custom_css touches, and every `block custom_css` + * would silently stop matching its writes. Requiring the method too + * fails toward over-blocking, never under. * * @since 2.20.0 * @return string @@ -602,7 +622,14 @@ public static function fork_css_state() { if ( ! preg_match( '/^\d+\.\d+\.\d+$/', (string) $v ) ) { return 'widened'; } - return version_compare( (string) $v, '1.37.0', '>=' ) ? 'precise' : 'widened'; + if ( ! version_compare( (string) $v, '1.37.0', '>=' ) ) { + return 'widened'; + } + $capable = self::$fork_css_touches_for_tests; + if ( null === $capable ) { + $capable = class_exists( 'Elementor_MCP_Rules' ) && method_exists( 'Elementor_MCP_Rules', 'css_touches' ); + } + return true === $capable ? 'precise' : 'widened'; } /** diff --git a/tests/unit/RulesOldForkWideningTest.php b/tests/unit/RulesOldForkWideningTest.php index b630d531..d9702950 100644 --- a/tests/unit/RulesOldForkWideningTest.php +++ b/tests/unit/RulesOldForkWideningTest.php @@ -40,12 +40,40 @@ public function test_fork_state_follows_the_loaded_version(): void { $this->assertSame( 'absent', Aura_Worker_Rules::fork_css_state() ); Aura_Worker_Rules::_set_fork_version_for_tests( '1.36.1' ); $this->assertSame( 'widened', Aura_Worker_Rules::fork_css_state() ); - Aura_Worker_Rules::_set_fork_version_for_tests( '1.37.0' ); + Aura_Worker_Rules::_set_fork_version_for_tests( '1.37.0', true ); $this->assertSame( 'precise', Aura_Worker_Rules::fork_css_state() ); - Aura_Worker_Rules::_set_fork_version_for_tests( 'not-a-version' ); + Aura_Worker_Rules::_set_fork_version_for_tests( 'not-a-version', true ); $this->assertSame( 'widened', Aura_Worker_Rules::fork_css_state(), 'unreadable counts as old' ); } + /** + * Final review I1: `precise` needs the capability, not just the number. + * A 1.37.0 that ships no Elementor_MCP_Rules::css_touches() is widened; + * so is an older fork that somehow has it. + */ + public function test_precise_needs_css_touches_as_well_as_the_version(): void { + Aura_Worker_Rules::_set_fork_version_for_tests( '1.37.0', false ); + $this->assertSame( 'widened', Aura_Worker_Rules::fork_css_state() ); + Aura_Worker_Rules::_set_fork_version_for_tests( '1.36.1', true ); + $this->assertSame( 'widened', Aura_Worker_Rules::fork_css_state() ); + Aura_Worker_Rules::_set_fork_version_for_tests( '1.38.0', true ); + $this->assertSame( 'precise', Aura_Worker_Rules::fork_css_state() ); + } + + public function test_absent_capability_derives_from_the_loaded_code(): void { + // null = ask method_exists(); this process defines no Elementor_MCP_Rules. + $this->assertFalse( class_exists( 'Elementor_MCP_Rules', false ) ); + Aura_Worker_Rules::_set_fork_version_for_tests( '1.37.0' ); + $this->assertSame( 'widened', Aura_Worker_Rules::fork_css_state() ); + } + + public function test_a_1_37_fork_without_css_touches_is_still_widened(): void { + Aura_Worker_Rules::_set_fork_version_for_tests( '1.37.0', false ); + $this->store( array( $this->css_rule( 'block', '42' ) ) ); + $v = Aura_Worker_Rules::enforce( array( array( 'type' => 'page', 'id' => '42' ) ), 'elementor-mcp/update-element' ); + $this->assertSame( 'block', $v['effect'] ); + } + public function test_an_old_fork_page_edit_is_blocked_by_a_css_block(): void { Aura_Worker_Rules::_set_fork_version_for_tests( '1.36.1' ); $this->store( array( $this->css_rule( 'block', '42' ) ) ); @@ -61,7 +89,7 @@ public function test_an_old_fork_site_write_meets_an_id_rule_through_the_wildcar } public function test_a_current_fork_is_not_widened(): void { - Aura_Worker_Rules::_set_fork_version_for_tests( '1.37.0' ); + Aura_Worker_Rules::_set_fork_version_for_tests( '1.37.0', true ); $this->store( array( $this->css_rule( 'block', '42' ) ) ); $v = Aura_Worker_Rules::enforce( array( array( 'type' => 'page', 'id' => '42' ) ), 'elementor-mcp/update-element' ); $this->assertNull( $v['effect'] ); diff --git a/tests/unit/StatusCssRulesTest.php b/tests/unit/StatusCssRulesTest.php index 71788224..6eeb1133 100644 --- a/tests/unit/StatusCssRulesTest.php +++ b/tests/unit/StatusCssRulesTest.php @@ -20,9 +20,17 @@ private function body(): array { } public function test_css_rules_is_an_object_naming_the_fork_state(): void { - foreach ( array( array( false, 'absent' ), array( '1.36.1', 'widened' ), array( '1.37.0', 'precise' ) ) as $case ) { - list( $v, $want ) = $case; - Aura_Worker_Rules::_set_fork_version_for_tests( $v ); + $cases = array( + array( false, null, 'absent' ), + array( '1.36.1', false, 'widened' ), + array( '1.37.0', true, 'precise' ), + // A version number is not a capability (final review I1). + array( '1.37.0', false, 'widened' ), + array( '1.36.1', true, 'widened' ), + ); + foreach ( $cases as $case ) { + list( $v, $cap, $want ) = $case; + Aura_Worker_Rules::_set_fork_version_for_tests( $v, $cap ); $body = $this->body(); $this->assertIsObject( $body['css_rules'] ); $this->assertSame( '{"fork":"' . $want . '"}', wp_json_encode( $body['css_rules'] ) ); From 2d0b86037454416b754a6cd144d3c68455b245ee Mon Sep 17 00:00:00 2001 From: Ben Kalsky Date: Thu, 24 Sep 2026 19:33:25 +0300 Subject: [PATCH 08/13] fix(door): non-array settings, odd CSS values and unread schema shapes fail closed (final review) - update-page-settings `settings` and a manage-elements op's `settings` that are non-null and not an array are CSS of unknown shape, never css_only (M1). - css_value(): an empty array clears; every other non-string non-null value (false, 0, 0.0, numbers, non-empty arrays, objects) is unknown. - css_capable_paths(): tuple `items`, anyOf/oneOf/allOf and patternProperties count as CSS-capable at their node; nested arrays are descended. A bare {type: object} stays closed (controller ruling). The 4.3 fixture classification is unchanged. - Test WP_Ability stub gains get_input_schema(), so the production live schema read is covered; tests for the manage-elements producer guard, a null live schema, and the CSS touch as touches_for() returns it. - Drop a redundant (string) cast. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../class-elementor-door-governor.php | 74 ++++++++---- tests/bootstrap.php | 5 + .../ElementorDoorCssClassificationTest.php | 105 ++++++++++++++++++ tests/unit/ElementorDoorCssTouchesTest.php | 73 ++++++++++++ 4 files changed, 235 insertions(+), 22 deletions(-) diff --git a/digitizer-site-worker/includes/class-elementor-door-governor.php b/digitizer-site-worker/includes/class-elementor-door-governor.php index 6fff963e..6b662a79 100644 --- a/digitizer-site-worker/includes/class-elementor-door-governor.php +++ b/digitizer-site-worker/includes/class-elementor-door-governor.php @@ -2974,13 +2974,36 @@ public static function css_capable_paths( array $schema, $prefix = '' ) { /** @since 2.20.0 @param array $schema Schema. @param string $prefix Path so far. @return string[] (may repeat — an open named container is reached twice) */ private static function css_capable_paths_raw( array $schema, $prefix ) { - $out = array(); + $out = array(); + $here = '' === $prefix ? '(root)' : $prefix; // The node ITSELF may be open (Codex r1 on the plan): a root, or an // array item, that accepts any property can carry CSS under any name. // Explicit `true` or a schema-valued `additionalProperties` counts; - // an absent key does not (WP schemas omit it everywhere). + // an absent key does not (WP schemas omit it everywhere), so a bare + // {type: object} stays closed (controller ruling, final review M2). if ( isset( $schema['additionalProperties'] ) && ( true === $schema['additionalProperties'] || is_array( $schema['additionalProperties'] ) ) ) { - $out[] = '' === $prefix ? '(root)' : $prefix; + $out[] = $here; + } + // Shapes this walker does not read into are CSS-capable as a whole — + // fail closed rather than silently pass (final review, Task 4 minor): + // keys matched by pattern, and combinators whose branches may differ. + if ( ! empty( $schema['patternProperties'] ) ) { + $out[] = $here; + } + foreach ( array( 'anyOf', 'oneOf', 'allOf' ) as $combinator ) { + if ( isset( $schema[ $combinator ] ) ) { + $out[] = $here; + } + } + // Arrays: a single item schema is descended as `name[].child` (nested + // arrays as `name[][]…`); a tuple — a list of item schemas — is flagged. + if ( isset( $schema['items'] ) && is_array( $schema['items'] ) ) { + $items = $schema['items']; + if ( array() !== $items && array_keys( $items ) === range( 0, count( $items ) - 1 ) ) { + $out[] = $here; + } else { + $out = array_merge( $out, self::css_capable_paths_raw( $items, ( '' === $prefix ? '' : $prefix ) . '[]' ) ); + } } $props = isset( $schema['properties'] ) && is_array( $schema['properties'] ) ? $schema['properties'] : array(); foreach ( $props as $name => $sub ) { @@ -2992,13 +3015,7 @@ private static function css_capable_paths_raw( array $schema, $prefix ) { // list can still grow a CSS child (`settings.css`) the handler // does not read — that child must surface as its own path. } - if ( isset( $sub['items'] ) && is_array( $sub['items'] ) ) { - foreach ( self::css_capable_paths_raw( $sub['items'], '' ) as $child ) { - $out[] = '(root)' === $child ? $path . '[]' : $path . '[].' . $child; - } - } elseif ( isset( $sub['properties'] ) || isset( $sub['additionalProperties'] ) ) { - $out = array_merge( $out, self::css_capable_paths_raw( $sub, $path ) ); - } + $out = array_merge( $out, self::css_capable_paths_raw( $sub, $path ) ); } return $out; } @@ -3026,9 +3043,10 @@ private static function live_input_schema( $slug ) { } /** - * CSS value classification. `null` / whitespace string = clearing (not CSS); - * a non-empty string = CSS read from the argument; anything else non-empty = - * CSS of unknown shape. + * CSS value classification. `null` / whitespace string / empty array = + * clearing (not CSS); a non-empty string = CSS read from the argument; + * every other value — false, 0, 0.0, true, numbers, non-empty arrays, + * objects — is CSS of unknown shape (final review: one rule, fail closed). * * @since 2.20.0 * @param mixed $v Value. @@ -3041,7 +3059,7 @@ private static function css_value( $v ) { if ( is_string( $v ) ) { return '' === trim( $v ) ? 'none' : 'css'; } - return empty( $v ) && false !== $v && 0 !== $v ? 'none' : 'unknown'; + return array() === $v ? 'none' : 'unknown'; } /** @@ -3063,7 +3081,7 @@ public static function css_touches_for( $slug, array $input, $id ) { // let a custom_css block rule be bypassed. $schema = self::live_input_schema( $slug ); if ( is_array( $schema ) && array() !== array_diff( self::css_capable_paths( $schema ), self::NO_CSS[ $slug ]['exempt'] ) ) { - return array( array( 'type' => 'custom_css', 'id' => (string) $id ) ); + return array( array( 'type' => 'custom_css', 'id' => $id ) ); } return array(); } @@ -3083,12 +3101,20 @@ public static function css_touches_for( $slug, array $input, $id ) { $found = 'none'; // none | css | unknown $css_only = true; if ( 'elementor/update-page-settings' === $slug ) { - $settings = isset( $input['settings'] ) && is_array( $input['settings'] ) ? $input['settings'] : array(); - $found = array_key_exists( 'custom_css', $settings ) ? self::css_value( $settings['custom_css'] ) : 'none'; - // The WHOLE input, not just `settings` (Codex r4): any other - // top-level field is an effect this allow never looked at. - $css_only = array( 'custom_css' ) === array_keys( $settings ) - && array() === array_diff( array_keys( $input ), array( 'post_id', 'settings' ) ); + if ( isset( $input['settings'] ) && ! is_array( $input['settings'] ) ) { + // An object or scalar `settings` (in-process PHP callers; JSON + // decodes to arrays) is CSS of unknown shape, never "no CSS" + // (final review M1). + $found = 'unknown'; + $css_only = false; + } else { + $settings = isset( $input['settings'] ) ? $input['settings'] : array(); + $found = array_key_exists( 'custom_css', $settings ) ? self::css_value( $settings['custom_css'] ) : 'none'; + // The WHOLE input, not just `settings` (Codex r4): any other + // top-level field is an effect this allow never looked at. + $css_only = array( 'custom_css' ) === array_keys( $settings ) + && array() === array_diff( array_keys( $input ), array( 'post_id', 'settings' ) ); + } } elseif ( 'elementor/manage-elements' === $slug ) { $ops = isset( $input['operations'] ) ? $input['operations'] : null; if ( array() !== array_diff( array_keys( $input ), array( 'post_id', 'operations' ) ) ) { @@ -3108,7 +3134,11 @@ public static function css_touches_for( $slug, array $input, $id ) { $op_css = self::css_value( $op['style'] ); } $settings = isset( $op['settings'] ) && is_array( $op['settings'] ) ? $op['settings'] : array(); - if ( array_key_exists( 'custom_css', $settings ) ) { + if ( isset( $op['settings'] ) && ! is_array( $op['settings'] ) ) { + // Not an array: CSS of unknown shape (final review M1). + $op_css = 'unknown'; + $css_only = false; + } elseif ( array_key_exists( 'custom_css', $settings ) ) { $s = self::css_value( $settings['custom_css'] ); $op_css = 'unknown' === $s || 'unknown' === $op_css ? 'unknown' : ( 'css' === $s ? 'css' : $op_css ); } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 4796412c..2509dd51 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -4714,14 +4714,19 @@ class WP_Ability { protected $execute_callback; protected $permission_callback; protected $meta = array(); + /** Set via $args['input_schema']; null (unreadable) when not given, so existing tests see no schema. */ + protected $input_schema = null; public function __construct( string $name, array $args ) { $this->name = $name; $this->execute_callback = $args['execute_callback'] ?? null; $this->permission_callback = $args['permission_callback'] ?? null; $this->meta = is_array( $args['meta'] ?? null ) ? $args['meta'] : array(); + $this->input_schema = $args['input_schema'] ?? null; } public function get_name(): string { return $this->name; } public function get_meta(): array { return $this->meta; } + /** Core's getter (class-wp-ability.php), reduced: the door's live CSS schema guard reads it. */ + public function get_input_schema() { return $this->input_schema; } public function execute( $input = null ) { if ( ! is_callable( $this->execute_callback ) ) { return new WP_Error( 'ability_invalid_execute_callback', 'no callback' ); diff --git a/tests/unit/ElementorDoorCssClassificationTest.php b/tests/unit/ElementorDoorCssClassificationTest.php index c6b9699b..3dc37c01 100644 --- a/tests/unit/ElementorDoorCssClassificationTest.php +++ b/tests/unit/ElementorDoorCssClassificationTest.php @@ -137,4 +137,109 @@ public function test_a_design_system_write_with_a_grown_live_schema_declares_bot $this->assertContains( array( 'type' => 'design_system', 'id' => '*' ), $result ); $this->assertContains( array( 'type' => 'custom_css', 'id' => '*' ), $result ); } + + /* ---- final review: fail-closed schema shapes (Task 4 minors) ---- */ + + public function test_a_tuple_items_list_is_css_capable(): void { + $this->assertSame( + array( 'pair' ), + Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object', 'properties' => array( 'pair' => array( 'type' => 'array', 'items' => array( array( 'type' => 'string' ), array( 'type' => 'integer' ) ) ) ) ) ) + ); + } + + public function test_a_combinator_is_css_capable(): void { + foreach ( array( 'anyOf', 'oneOf', 'allOf' ) as $k ) { + $this->assertSame( + array( 'value' ), + Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object', 'properties' => array( 'value' => array( $k => array( array( 'type' => 'string' ), array( 'type' => 'integer' ) ) ) ) ) ), + $k + ); + $this->assertSame( array( '(root)' ), Aura_Worker_Elementor_Door::css_capable_paths( array( $k => array( array( 'type' => 'object' ) ) ) ), "{$k} at the root" ); + } + } + + public function test_pattern_properties_are_css_capable(): void { + $this->assertSame( array( '(root)' ), Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object', 'patternProperties' => array( '^x_' => array( 'type' => 'string' ) ) ) ) ); + $this->assertSame( + array( 'meta' ), + Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object', 'properties' => array( 'meta' => array( 'type' => 'object', 'patternProperties' => array( '.*' => array( 'type' => 'string' ) ) ) ) ) ) + ); + } + + public function test_nested_arrays_are_descended(): void { + $this->assertSame( + array( 'grid[][].css' ), + Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object', 'properties' => array( 'grid' => array( 'type' => 'array', 'items' => array( 'type' => 'array', 'items' => array( 'type' => 'object', 'properties' => array( 'css' => array( 'type' => 'string' ), 'n' => array( 'type' => 'integer' ) ) ) ) ) ) ) ) + ); + $this->assertSame( + array( 'grid[][]' ), + Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object', 'properties' => array( 'grid' => array( 'type' => 'array', 'items' => array( 'type' => 'array', 'items' => array( 'type' => 'object', 'additionalProperties' => true ) ) ) ) ) ) + ); + } + + /** Controller ruling (final review M2): a bare {type: object} stays CLOSED. */ + public function test_a_bare_object_stays_closed(): void { + $this->assertSame( array(), Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object', 'properties' => array( 'interactions' => array( 'type' => 'array', 'items' => array( 'type' => 'object' ) ), 'meta' => array( 'type' => 'object' ) ) ) ) ); + $this->assertSame( array(), Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object' ) ) ); + } + + /** Task 4 minor: the precise-producer guard, on manage-elements. */ + public function test_manage_elements_whose_schema_grew_extra_css_turns_conservative(): void { + $schema = $this->schemas()['elementor/manage-elements']; + $this->assertArrayHasKey( 'operations', $schema['properties'] ); + $schema['properties']['operations']['items']['properties']['extra_css'] = array( 'type' => 'string' ); + Aura_Worker_Elementor_Door::_set_schema_reader_for_tests( function ( $slug ) use ( $schema ) { + return 'elementor/manage-elements' === $slug ? $schema : null; + } ); + $in = array( 'post_id' => 42, 'operations' => array( array( 'action' => 'update', 'element_id' => 'a1', 'style' => 'color:red' ) ) ); + $this->assertSame( array( array( 'type' => 'custom_css', 'id' => '42' ) ), Aura_Worker_Elementor_Door::css_touches_for( 'elementor/manage-elements', $in, '42' ) ); + } + + /** Task 4 minor: the 4.3 schema itself leaves manage-elements precise. */ + public function test_manage_elements_with_its_fixture_schema_stays_precise(): void { + $schema = $this->schemas()['elementor/manage-elements']; + Aura_Worker_Elementor_Door::_set_schema_reader_for_tests( function () use ( $schema ) { + return $schema; + } ); + $in = array( 'post_id' => 42, 'operations' => array( array( 'action' => 'update', 'element_id' => 'a1', 'style' => 'color:red' ) ) ); + $this->assertSame( array( array( 'type' => 'custom_css', 'id' => '42', 'precise' => true, 'css_only' => true ) ), Aura_Worker_Elementor_Door::css_touches_for( 'elementor/manage-elements', $in, '42' ) ); + } + + /** Task 4 minor: an unreadable (null) live schema leaves a precise producer precise. */ + public function test_a_null_live_schema_leaves_a_precise_producer_precise(): void { + Aura_Worker_Elementor_Door::_set_schema_reader_for_tests( function () { + return null; + } ); + $this->assertSame( + array( array( 'type' => 'custom_css', 'id' => '42', 'precise' => true, 'css_only' => true ) ), + Aura_Worker_Elementor_Door::css_touches_for( 'elementor/update-page-settings', array( 'post_id' => 42, 'settings' => array( 'custom_css' => 'a{}' ) ), '42' ) + ); + $this->assertSame( + array( array( 'type' => 'custom_css', 'id' => '42', 'precise' => true, 'css_only' => true ) ), + Aura_Worker_Elementor_Door::css_touches_for( 'elementor/manage-elements', array( 'operations' => array( array( 'action' => 'update', 'element_id' => 'a1', 'style' => 'x' ) ) ), '42' ) + ); + } + + /** + * Task 4 minor: the PRODUCTION read (no seam) goes through + * wp_get_ability()->get_input_schema(). An ability whose registered + * schema is open makes a NO_CSS slug conservative. + */ + public function test_the_production_schema_read_uses_the_registered_ability(): void { + sa_register_ability( 'elementor/publish-document', array( + 'execute_callback' => function () { + return true; + }, + 'input_schema' => array( 'type' => 'object', 'additionalProperties' => true ), + ) ); + $this->assertSame( array( array( 'type' => 'custom_css', 'id' => '42' ) ), Aura_Worker_Elementor_Door::css_touches_for( 'elementor/publish-document', array( 'post_id' => 42 ), '42' ) ); + // And a closed one does not. + sa_register_ability( 'elementor/create-preview-link', array( + 'execute_callback' => function () { + return true; + }, + 'input_schema' => array( 'type' => 'object', 'properties' => array( 'post_id' => array( 'type' => 'integer' ) ) ), + ) ); + $this->assertSame( array(), Aura_Worker_Elementor_Door::css_touches_for( 'elementor/create-preview-link', array( 'post_id' => 42 ), '42' ) ); + } } diff --git a/tests/unit/ElementorDoorCssTouchesTest.php b/tests/unit/ElementorDoorCssTouchesTest.php index 98727db8..30d892cf 100644 --- a/tests/unit/ElementorDoorCssTouchesTest.php +++ b/tests/unit/ElementorDoorCssTouchesTest.php @@ -111,4 +111,77 @@ public function test_design_system_and_other_writes_declare_no_css(): void { $this->assertSame( array(), $this->t( $slug, array( 'operations' => array( array( 'css' => 'a{}' ) ) ) ), $slug ); } } + + /* ---- final review: fail-closed value shapes (M1, Task 3 minors) ---- */ + + /** + * css_value(): empty array is a clearing; every other non-string, + * non-null value is CSS of unknown shape — false, 0 and 0.0 included. + */ + public function test_non_string_css_values_are_unknown_except_an_empty_array(): void { + $this->assertSame( array(), $this->t( 'elementor/update-page-settings', array( 'settings' => array( 'custom_css' => array() ) ) ), 'array() clears' ); + foreach ( array( 'false' => false, 'int 0' => 0, 'float 0.0' => 0.0, 'float 1.5' => 1.5, 'stdClass' => new stdClass() ) as $label => $odd ) { + $this->assertSame( + array( array( 'type' => 'custom_css', 'id' => '42' ) ), + $this->t( 'elementor/update-page-settings', array( 'settings' => array( 'custom_css' => $odd ) ) ), + 'unknown shape is conservative: ' . $label + ); + } + } + + /** M1: `settings` that is not an array is CSS of unknown shape, never "no CSS". */ + public function test_page_settings_that_are_not_an_array_are_unknown_css(): void { + foreach ( array( 'stdClass' => (object) array( 'custom_css' => 'body{}' ), 'string' => 'custom_css=body{}', 'int' => 7 ) as $label => $settings ) { + $this->assertSame( + array( array( 'type' => 'custom_css', 'id' => '42' ) ), + $this->t( 'elementor/update-page-settings', array( 'post_id' => 42, 'settings' => $settings ) ), + $label + ); + } + // Null settings still declare nothing (nothing is being written). + $this->assertSame( array(), $this->t( 'elementor/update-page-settings', array( 'post_id' => 42, 'settings' => null ) ) ); + } + + /** M1 / Task 3 minor: an op's non-array `settings` is unknown CSS and never css_only. */ + public function test_manage_elements_op_settings_that_are_not_an_array_are_unknown_css(): void { + foreach ( array( 'stdClass' => (object) array( 'custom_css' => 'x{}' ), 'string' => 'custom_css' ) as $label => $settings ) { + $in = array( 'operations' => array( + array( 'action' => 'update', 'element_id' => 'a1', 'style' => 'color:red' ), + array( 'action' => 'update', 'element_id' => 'b2', 'settings' => $settings ), + ) ); + $this->assertSame( array( array( 'type' => 'custom_css', 'id' => '42' ) ), $this->t( 'elementor/manage-elements', $in ), $label ); + } + } + + /* ---- final review: the CSS touch as touches_for() returns it (Task 3 minor) ---- */ + + public function test_touches_for_appends_a_precise_css_touch_after_the_page(): void { + $GLOBALS['_posts'][42] = (object) array( 'ID' => 42, 'post_type' => 'page', 'post_status' => 'draft', 'post_content' => '' ); + $this->assertSame( + array( + array( 'type' => 'page', 'id' => '42' ), + array( 'type' => 'custom_css', 'id' => '42', 'precise' => true, 'css_only' => true ), + ), + Aura_Worker_Elementor_Door::touches_for( 'elementor/update-page-settings', array( 'post_id' => 42, 'settings' => array( 'custom_css' => 'body{}' ) ) ) + ); + } + + public function test_touches_for_a_component_write_is_design_system_plus_wildcard_css(): void { + $this->assertSame( + array( + array( 'type' => 'design_system', 'id' => '*' ), + array( 'type' => 'custom_css', 'id' => '*' ), + ), + Aura_Worker_Elementor_Door::touches_for( 'elementor/manage-component', array( 'action' => 'create' ) ) + ); + } + + public function test_touches_for_a_no_css_design_system_write_carries_no_css_touch(): void { + $touches = Aura_Worker_Elementor_Door::touches_for( 'elementor/manage-classes', array( 'operations' => array( array( 'action' => 'create', 'label' => 'foo', 'css' => 'color:red' ) ) ) ); + $this->assertIsArray( $touches ); + $this->assertContains( array( 'type' => 'design_system', 'id' => '*' ), $touches ); + foreach ( $touches as $t ) { + $this->assertNotSame( 'custom_css', $t['type'] ); + } + } } From 92c07cb3389da99c6092e4b9eff0992cd5c56a03 Mon Sep 17 00:00:00 2001 From: Ben Kalsky Date: Thu, 24 Sep 2026 19:34:33 +0300 Subject: [PATCH 09/13] test(rules): widening never speaks for allow, proven at match(); seam and preview docs (final review) test_widening_never_speaks_for_allow now asks match() directly with the touch set enforce() builds (a precise control admits, the widened touch does not) and pairs the allow with a warn under enforce(). The fork version seam gains @since 2.20.0, and preview_tool() documents why it does not apply old-fork widening (no elementor-mcp ability reaches it). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../includes/class-aura-worker-rules.php | 7 +++++- .../includes/class-aura-worker-tools.php | 9 +++++++ tests/unit/RulesOldForkWideningTest.php | 24 +++++++++++++++++-- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/digitizer-site-worker/includes/class-aura-worker-rules.php b/digitizer-site-worker/includes/class-aura-worker-rules.php index ce938dae..7b2500ef 100644 --- a/digitizer-site-worker/includes/class-aura-worker-rules.php +++ b/digitizer-site-worker/includes/class-aura-worker-rules.php @@ -571,7 +571,12 @@ private static function css_rule_touches( array $rule, array $touched ) { return isset( $touched[ 'custom_css:' . $id ] ) || isset( $touched['custom_css:*'] ); } - /** Test seam: null = read the real constant; false = fork absent; string = that version. @var null|false|string */ + /** + * Test seam: null = read the real constant; false = fork absent; string = that version. + * + * @since 2.20.0 + * @var null|false|string + */ private static $fork_version_for_tests = null; /** diff --git a/digitizer-site-worker/includes/class-aura-worker-tools.php b/digitizer-site-worker/includes/class-aura-worker-tools.php index 9c9d5cb9..ce5ed17e 100644 --- a/digitizer-site-worker/includes/class-aura-worker-tools.php +++ b/digitizer-site-worker/includes/class-aura-worker-tools.php @@ -220,6 +220,15 @@ 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. + * * @param string $name Tool name. * @param array $params Parameters to preview. * @return array { success: bool, supported?: bool, preview?: mixed, error?: string, errors?: string[] } diff --git a/tests/unit/RulesOldForkWideningTest.php b/tests/unit/RulesOldForkWideningTest.php index d9702950..c82640d3 100644 --- a/tests/unit/RulesOldForkWideningTest.php +++ b/tests/unit/RulesOldForkWideningTest.php @@ -104,10 +104,30 @@ public function test_only_the_forks_own_abilities_are_widened(): void { } } + /** + * Non-vacuous (Task 2 review minor 1): enforce() drops every allow + * winner anyway, so this asks match() itself — the matcher the door + * uses for allow. The widened touch set is the one enforce() builds. + */ public function test_widening_never_speaks_for_allow(): void { Aura_Worker_Rules::_set_fork_version_for_tests( '1.36.1' ); - $this->store( array( $this->css_rule( 'allow', '42' ) ) ); + $widen = new ReflectionMethod( 'Aura_Worker_Rules', 'widen_for_old_fork' ); + $widened = $widen->invoke( null, array( array( 'type' => 'page', 'id' => '42' ) ), 'elementor-mcp/update-element' ); + $this->assertContains( array( 'type' => 'custom_css', 'id' => '42' ), $widened, 'the old fork IS widened' ); + + $allow = array( $this->css_rule( 'allow', '42' ) ); + // Control: a precise CSS-only touch does satisfy this allow… + $this->assertSame( 'allow', Aura_Worker_Rules::match( array( array( 'type' => 'page', 'id' => '42' ), array( 'type' => 'custom_css', 'id' => '42', 'precise' => true, 'css_only' => true ) ), $allow )['effect'] ); + // …the widened touch never does. + $this->assertNull( Aura_Worker_Rules::match( $widened, $allow ) ); + + // Paired with a warn: the widened touch reaches the warn, never the allow. + $this->store( array( $this->css_rule( 'allow', '42' ), array( 'key' => 'rule/css-warn', 'effect' => 'warn', 'target' => array( 'type' => 'custom_css', 'id' => '42' ), 'reason' => 'careful', 'until' => null ) ) ); $v = Aura_Worker_Rules::enforce( array( array( 'type' => 'page', 'id' => '42' ) ), 'elementor-mcp/update-element' ); - $this->assertNull( $v['effect'] ); + $this->assertSame( 'warn', $v['effect'] ); + $this->assertSame( 'rule/css-warn', $v['rule']['key'] ); + Aura_Worker_Rules::reset_records(); + Aura_Worker_Rules::_set_fork_version_for_tests( '1.37.0', true ); + $this->assertNull( Aura_Worker_Rules::enforce( array( array( 'type' => 'page', 'id' => '42' ) ), 'elementor-mcp/update-element' )['effect'], 'a current fork declares nothing here' ); } } From 786d4fedb00e1821e16e21e54924d83076114266 Mon Sep 17 00:00:00 2001 From: Ben Kalsky Date: Thu, 24 Sep 2026 20:22:14 +0300 Subject: [PATCH 10/13] fix(rules): a custom_css id is exact only if every touch on it is (Codex r1 on #135) normalize_touches() minted custom_css!exact: from any one exact touch, so an exact and a conservative touch on the same id read as exact and allow custom_css: admitted the call. Exactness is now tracked per id: one inexact touch on an id removes its exact key. Block/warn unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../includes/class-aura-worker-rules.php | 24 ++++++++++++----- tests/unit/RulesCustomCssMatchTest.php | 27 +++++++++++++++++++ 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/digitizer-site-worker/includes/class-aura-worker-rules.php b/digitizer-site-worker/includes/class-aura-worker-rules.php index 7b2500ef..8c894567 100644 --- a/digitizer-site-worker/includes/class-aura-worker-rules.php +++ b/digitizer-site-worker/includes/class-aura-worker-rules.php @@ -422,7 +422,8 @@ public static function is_expired( array $rule, $now ) { * @return array Set of "type:id". */ private static function normalize_touches( array $touches ) { - $set = array(); + $set = array(); + $inexact = array(); // custom_css ids with at least one touch lacking evidence. foreach ( $touches as $t ) { if ( ! is_array( $t ) || ! isset( $t['type'], $t['id'] ) ) { continue; @@ -452,14 +453,23 @@ private static function normalize_touches( array $touches ) { // Evidence fields (2.20.0): on a custom_css touch only, each only // as the literal true, and only on a concrete id. Anything else is // read as absent — the conservative reading (spec §3). - if ( 'custom_css' === $type - && ctype_digit( $id ) - && isset( $t['precise'], $t['css_only'] ) - && true === $t['precise'] - && true === $t['css_only'] ) { - $set[ self::CSS_EXACT_PREFIX . $id ] = true; + if ( 'custom_css' === $type ) { + if ( ctype_digit( $id ) + && isset( $t['precise'], $t['css_only'] ) + && true === $t['precise'] + && true === $t['css_only'] ) { + $set[ self::CSS_EXACT_PREFIX . $id ] = true; + } else { + $inexact[ $id ] = true; + } } } + // Exactness is per id and needs EVERY touch on that id (Codex r1 on + // #135): one conservative touch on 42 beside an exact one must not be + // erased by it, or `allow custom_css:42` would admit the call. + foreach ( $inexact as $id => $unused ) { + unset( $set[ self::CSS_EXACT_PREFIX . $id ] ); + } if ( empty( $set ) ) { // A declaration that survives normalisation as nothing — `[]`, // entries with no type or no id, or entries naming types outside diff --git a/tests/unit/RulesCustomCssMatchTest.php b/tests/unit/RulesCustomCssMatchTest.php index 970a989f..93601bd0 100644 --- a/tests/unit/RulesCustomCssMatchTest.php +++ b/tests/unit/RulesCustomCssMatchTest.php @@ -156,4 +156,31 @@ public function test_enforceable_match_treats_a_css_allow_winner_as_no_rule(): v $rule = $this->rule( 'allow', '42' ); $this->assertNull( Aura_Worker_Rules::enforceable_match( array( $this->exact( '42' ) ), array( $rule ) ) ); } + + /** + * Codex r1 on #135: exactness is per id and needs EVERY touch on that id. + * A conservative twin on the same id must not be erased by an exact one, + * in either order. + */ + public function test_an_exact_and_a_conservative_touch_on_the_same_id_is_not_exact(): void { + foreach ( array( array( $this->exact( '42' ), $this->css( '42' ) ), array( $this->css( '42' ), $this->exact( '42' ) ) ) as $i => $touches ) { + $this->assertNull( Aura_Worker_Rules::match( $touches, array( $this->rule( 'allow', '42' ) ) ), "id rule, order {$i}" ); + $this->assertNull( Aura_Worker_Rules::match( $touches, array( $this->rule( 'allow' ) ) ), "id-less rule, order {$i}" ); + $this->assertSame( 'block', Aura_Worker_Rules::match( $touches, array( $this->rule( 'block', '42' ) ) )['effect'], "block, order {$i}" ); + } + // Evidence that is not literal true counts as inexact too. + $touches = array( $this->exact( '42' ), $this->css( '42', array( 'precise' => true, 'css_only' => 'yes' ) ) ); + $this->assertNull( Aura_Worker_Rules::match( $touches, array( $this->rule( 'allow', '42' ) ) ) ); + } + + public function test_two_exact_touches_on_the_same_id_stay_exact(): void { + $touches = array( $this->exact( '42' ), $this->exact( '42' ) ); + $this->assertSame( 'allow', Aura_Worker_Rules::match( $touches, array( $this->rule( 'allow', '42' ) ) )['effect'] ); + $this->assertSame( 'allow', Aura_Worker_Rules::match( $touches, array( $this->rule( 'allow' ) ) )['effect'] ); + } + + public function test_exact_on_one_id_and_conservative_on_another_is_still_refused(): void { + $touches = array( $this->exact( '42' ), $this->css( '43' ) ); + $this->assertNull( Aura_Worker_Rules::match( $touches, array( $this->rule( 'allow', '42' ) ) ) ); + } } From ddad64a9e4e34e56e2e55ff2e0c6d5d24b2f691b Mon Sep 17 00:00:00 2001 From: Ben Kalsky Date: Thu, 24 Sep 2026 20:23:24 +0300 Subject: [PATCH 11/13] fix(door): an unresolved schema shape is never absorbed by a handled or exempt path (Codex r1 on #135) A combinator, patternProperties or tuple items at a path already on PRODUCER_HANDLED_PATHS or a NO_CSS exempt list (settings -> oneOf with an extra_css branch) emitted only the plain path, so the drift check passed and the new branch was never read. Such nodes now surface as `{unresolved}`, which no list names, so the write turns conservative. The 4.3 fixture has none of these shapes. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../class-elementor-door-governor.php | 19 ++++++-- .../ElementorDoorCssClassificationTest.php | 48 +++++++++++++++++-- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/digitizer-site-worker/includes/class-elementor-door-governor.php b/digitizer-site-worker/includes/class-elementor-door-governor.php index 6b662a79..577e9eec 100644 --- a/digitizer-site-worker/includes/class-elementor-door-governor.php +++ b/digitizer-site-worker/includes/class-elementor-door-governor.php @@ -2950,6 +2950,16 @@ public static function actor() { 'elementor/manage-elements' => array( 'operations[].settings', 'operations[].style', 'operations[].style_apply_mode' ), // style_apply_mode is the patch|replace merge mode, matched by the name net's "style" substring — never CSS text (controller ruling). ); + /** + * Suffix for a node whose shape the walker does not resolve (combinator, + * patternProperties, tuple items). No handled or exempt list names it, so + * such a node always fails the drift check — even at a path that is + * itself handled or exempt (Codex r1 on #135). + * + * @since 2.20.0 + */ + const UNRESOLVED_SUFFIX = '{unresolved}'; + /** @var callable|null test seam: fn( string $slug ): ?array @since 2.20.0 */ private static $schema_reader = null; @@ -2987,12 +2997,15 @@ private static function css_capable_paths_raw( array $schema, $prefix ) { // Shapes this walker does not read into are CSS-capable as a whole — // fail closed rather than silently pass (final review, Task 4 minor): // keys matched by pattern, and combinators whose branches may differ. + // They surface under a distinct marker, never the plain path, so a + // handled or exempt entry for that path cannot absorb them (Codex r1 + // on #135). Branches are not descended: the marker is the answer. if ( ! empty( $schema['patternProperties'] ) ) { - $out[] = $here; + $out[] = $here . self::UNRESOLVED_SUFFIX; } foreach ( array( 'anyOf', 'oneOf', 'allOf' ) as $combinator ) { if ( isset( $schema[ $combinator ] ) ) { - $out[] = $here; + $out[] = $here . self::UNRESOLVED_SUFFIX; } } // Arrays: a single item schema is descended as `name[].child` (nested @@ -3000,7 +3013,7 @@ private static function css_capable_paths_raw( array $schema, $prefix ) { if ( isset( $schema['items'] ) && is_array( $schema['items'] ) ) { $items = $schema['items']; if ( array() !== $items && array_keys( $items ) === range( 0, count( $items ) - 1 ) ) { - $out[] = $here; + $out[] = $here . self::UNRESOLVED_SUFFIX; } else { $out = array_merge( $out, self::css_capable_paths_raw( $items, ( '' === $prefix ? '' : $prefix ) . '[]' ) ); } diff --git a/tests/unit/ElementorDoorCssClassificationTest.php b/tests/unit/ElementorDoorCssClassificationTest.php index 3dc37c01..1294bbe6 100644 --- a/tests/unit/ElementorDoorCssClassificationTest.php +++ b/tests/unit/ElementorDoorCssClassificationTest.php @@ -142,7 +142,7 @@ public function test_a_design_system_write_with_a_grown_live_schema_declares_bot public function test_a_tuple_items_list_is_css_capable(): void { $this->assertSame( - array( 'pair' ), + array( 'pair{unresolved}' ), Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object', 'properties' => array( 'pair' => array( 'type' => 'array', 'items' => array( array( 'type' => 'string' ), array( 'type' => 'integer' ) ) ) ) ) ) ); } @@ -150,18 +150,18 @@ public function test_a_tuple_items_list_is_css_capable(): void { public function test_a_combinator_is_css_capable(): void { foreach ( array( 'anyOf', 'oneOf', 'allOf' ) as $k ) { $this->assertSame( - array( 'value' ), + array( 'value{unresolved}' ), Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object', 'properties' => array( 'value' => array( $k => array( array( 'type' => 'string' ), array( 'type' => 'integer' ) ) ) ) ) ), $k ); - $this->assertSame( array( '(root)' ), Aura_Worker_Elementor_Door::css_capable_paths( array( $k => array( array( 'type' => 'object' ) ) ) ), "{$k} at the root" ); + $this->assertSame( array( '(root){unresolved}' ), Aura_Worker_Elementor_Door::css_capable_paths( array( $k => array( array( 'type' => 'object' ) ) ) ), "{$k} at the root" ); } } public function test_pattern_properties_are_css_capable(): void { - $this->assertSame( array( '(root)' ), Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object', 'patternProperties' => array( '^x_' => array( 'type' => 'string' ) ) ) ) ); + $this->assertSame( array( '(root){unresolved}' ), Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object', 'patternProperties' => array( '^x_' => array( 'type' => 'string' ) ) ) ) ); $this->assertSame( - array( 'meta' ), + array( 'meta{unresolved}' ), Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object', 'properties' => array( 'meta' => array( 'type' => 'object', 'patternProperties' => array( '.*' => array( 'type' => 'string' ) ) ) ) ) ) ); } @@ -242,4 +242,42 @@ public function test_the_production_schema_read_uses_the_registered_ability(): v ) ); $this->assertSame( array(), Aura_Worker_Elementor_Door::css_touches_for( 'elementor/create-preview-link', array( 'post_id' => 42 ), '42' ) ); } + + /** + * Codex r1 on #135: an unresolved shape at a path that is ALREADY handled + * or exempt must not be satisfied by that entry — it surfaces as a + * distinct `{unresolved}` path no list names. + */ + public function test_an_unresolved_shape_on_a_handled_path_is_not_handled(): void { + $this->assertEqualsCanonicalizing( + array( 'settings', 'settings{unresolved}' ), + Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object', 'properties' => array( 'settings' => array( 'oneOf' => array( array( 'type' => 'object' ), array( 'type' => 'object', 'properties' => array( 'extra_css' => array( 'type' => 'string' ) ) ) ) ) ) ) ) + ); + $this->assertEqualsCanonicalizing( + array( 'ops[]{unresolved}' ), + Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object', 'properties' => array( 'ops' => array( 'type' => 'array', 'items' => array( 'type' => 'object', 'patternProperties' => array( '.*' => array( 'type' => 'string' ) ) ) ) ) ) ) + ); + } + + public function test_a_producer_whose_handled_settings_became_one_of_is_conservative(): void { + $schema = $this->schemas()['elementor/update-page-settings']; + $schema['properties']['settings'] = array( 'oneOf' => array( array( 'type' => 'object' ), array( 'type' => 'object', 'properties' => array( 'extra_css' => array( 'type' => 'string' ) ) ) ) ); + Aura_Worker_Elementor_Door::_set_schema_reader_for_tests( function ( $slug ) use ( $schema ) { + return 'elementor/update-page-settings' === $slug ? $schema : null; + } ); + $this->assertSame( + array( array( 'type' => 'custom_css', 'id' => '42' ) ), + Aura_Worker_Elementor_Door::css_touches_for( 'elementor/update-page-settings', array( 'post_id' => 42, 'settings' => array( 'custom_css' => 'a{}' ) ), '42' ) + ); + } + + public function test_a_no_css_slug_whose_exempt_path_became_any_of_is_conservative(): void { + $schema = $this->schemas()['elementor/manage-classes']; + $this->assertArrayHasKey( 'css', $schema['properties']['operations']['items']['properties'] ); + $schema['properties']['operations']['items']['properties']['css'] = array( 'anyOf' => array( array( 'type' => 'string' ), array( 'type' => 'object' ) ) ); + Aura_Worker_Elementor_Door::_set_schema_reader_for_tests( function ( $slug ) use ( $schema ) { + return 'elementor/manage-classes' === $slug ? $schema : null; + } ); + $this->assertSame( array( array( 'type' => 'custom_css', 'id' => '*' ) ), Aura_Worker_Elementor_Door::css_touches_for( 'elementor/manage-classes', array(), '*' ) ); + } } From 45a552ce5d1ee70bbcda76837a5f4114b7498e7f Mon Sep 17 00:00:00 2001 From: Ben Kalsky Date: Thu, 24 Sep 2026 20:31:11 +0300 Subject: [PATCH 12/13] fix(door): a CSS-named key in an open settings container is CSS of unknown shape (Codex r2 on #135) update-page-settings `settings` is additionalProperties: true in the 4.3 schema, so Elementor could start honouring e.g. `extra_css` there with no schema drift the scanner would see. In page settings and in each manage-elements op's settings, a non-empty key matching /css/i other than custom_css now makes the write conservative (no evidence, not css_only). `_css_classes` and the `css_filters*` control group stay ordinary keys; `style` is not matched (element settings are full of *_style controls). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../class-elementor-door-governor.php | 66 +++++++++++++++++++ tests/unit/ElementorDoorCssTouchesTest.php | 24 +++++++ 2 files changed, 90 insertions(+) diff --git a/digitizer-site-worker/includes/class-elementor-door-governor.php b/digitizer-site-worker/includes/class-elementor-door-governor.php index 577e9eec..68251af2 100644 --- a/digitizer-site-worker/includes/class-elementor-door-governor.php +++ b/digitizer-site-worker/includes/class-elementor-door-governor.php @@ -2950,6 +2950,36 @@ public static function actor() { 'elementor/manage-elements' => array( 'operations[].settings', 'operations[].style', 'operations[].style_apply_mode' ), // style_apply_mode is the patch|replace merge mode, matched by the name net's "style" substring — never CSS text (controller ruling). ); + /** + * A key inside an open `settings` container (update-page-settings, and + * each manage-elements op) whose name matches this is CSS of unknown + * shape, unless it is `custom_css` itself (read precisely) or one of the + * exceptions below (Codex r2 on #135). The 4.3 schema declares page + * `settings` with additionalProperties: true, so Elementor could start + * honouring e.g. `extra_css` there with no schema change the drift check + * would see. `style` is deliberately NOT matched: element settings are + * full of `*_style` controls, and matching them would over-block. + * + * @since 2.20.0 + */ + const SETTINGS_CSS_KEY_PATTERN = '/css/i'; + + /** + * CSS-named settings keys that are ordinary controls, not stylesheets: + * `_css_classes` holds class names. They only make a call mixed. + * + * @since 2.20.0 + */ + const SETTINGS_CSS_KEY_EXCEPTIONS = array( '_css_classes' ); + + /** + * Prefix of Elementor's CSS-filter control group (`css_filters_blur`, + * …): structured controls, not custom CSS. + * + * @since 2.20.0 + */ + const SETTINGS_CSS_KEY_EXCEPTION_PREFIX = 'css_filters'; + /** * Suffix for a node whose shape the walker does not resolve (combinator, * patternProperties, tuple items). No handled or exempt list names it, so @@ -3075,6 +3105,32 @@ private static function css_value( $v ) { return array() === $v ? 'none' : 'unknown'; } + /** + * 'unknown' when a settings container carries a non-empty CSS-named key + * other than `custom_css` (see SETTINGS_CSS_KEY_PATTERN); else 'none'. + * The value is read like custom_css: null / whitespace / empty array + * clears and declares nothing. + * + * @since 2.20.0 + * @param array $settings Settings container. + * @return string 'none'|'unknown' + */ + private static function other_css_keys( array $settings ) { + foreach ( $settings as $key => $value ) { + $key = (string) $key; + if ( 'custom_css' === $key + || in_array( $key, self::SETTINGS_CSS_KEY_EXCEPTIONS, true ) + || 0 === strpos( $key, self::SETTINGS_CSS_KEY_EXCEPTION_PREFIX ) + || ! preg_match( self::SETTINGS_CSS_KEY_PATTERN, $key ) ) { + continue; + } + if ( 'none' !== self::css_value( $value ) ) { + return 'unknown'; + } + } + return 'none'; + } + /** * The custom_css touches a door write declares, in addition to its * page/post/design_system ones. Pure. @@ -3127,6 +3183,11 @@ public static function css_touches_for( $slug, array $input, $id ) { // top-level field is an effect this allow never looked at. $css_only = array( 'custom_css' ) === array_keys( $settings ) && array() === array_diff( array_keys( $input ), array( 'post_id', 'settings' ) ); + // Another CSS-named key: CSS this handler does not read (Codex r2). + if ( 'unknown' === self::other_css_keys( $settings ) ) { + $found = 'unknown'; + $css_only = false; + } } } elseif ( 'elementor/manage-elements' === $slug ) { $ops = isset( $input['operations'] ) ? $input['operations'] : null; @@ -3155,6 +3216,11 @@ public static function css_touches_for( $slug, array $input, $id ) { $s = self::css_value( $settings['custom_css'] ); $op_css = 'unknown' === $s || 'unknown' === $op_css ? 'unknown' : ( 'css' === $s ? 'css' : $op_css ); } + if ( 'unknown' === self::other_css_keys( $settings ) ) { + // Another CSS-named key: CSS this handler does not read (Codex r2). + $op_css = 'unknown'; + $css_only = false; + } if ( 'unknown' === $op_css || ( 'css' === $op_css && 'unknown' !== $found ) ) { $found = 'unknown' === $op_css ? 'unknown' : 'css'; } diff --git a/tests/unit/ElementorDoorCssTouchesTest.php b/tests/unit/ElementorDoorCssTouchesTest.php index 30d892cf..c57e19d8 100644 --- a/tests/unit/ElementorDoorCssTouchesTest.php +++ b/tests/unit/ElementorDoorCssTouchesTest.php @@ -184,4 +184,28 @@ public function test_touches_for_a_no_css_design_system_write_carries_no_css_tou $this->assertNotSame( 'custom_css', $t['type'] ); } } + + /* ---- Codex r2 on #135: CSS-named keys in an open settings container ---- */ + + public function test_a_css_named_page_setting_is_css_of_unknown_shape(): void { + $conservative = array( array( 'type' => 'custom_css', 'id' => '42' ) ); + $this->assertSame( $conservative, $this->t( 'elementor/update-page-settings', array( 'post_id' => 42, 'settings' => array( 'extra_css' => 'a{}' ) ) ) ); + $this->assertSame( $conservative, $this->t( 'elementor/update-page-settings', array( 'post_id' => 42, 'settings' => array( 'custom_css' => 'a{}', 'extra_css' => 'b{}' ) ) ), 'no precise evidence' ); + $this->assertSame( $conservative, $this->t( 'elementor/update-page-settings', array( 'post_id' => 42, 'settings' => array( 'Page_CSS' => array( 'a' => 'b' ) ) ) ), 'case-insensitive, any non-empty shape' ); + } + + public function test_css_named_exceptions_and_clearing_declare_nothing(): void { + foreach ( array( array( '_css_classes' => 'x' ), array( 'css_filters_blur' => 3 ), array( 'css_filters_css_filter' => 'custom' ), array( 'extra_css' => ' ' ), array( 'extra_css' => null ), array( 'button_style' => 'a{}' ) ) as $settings ) { + $this->assertSame( array(), $this->t( 'elementor/update-page-settings', array( 'post_id' => 42, 'settings' => $settings ) ), wp_json_encode( $settings ) ); + } + } + + public function test_a_css_named_op_setting_is_css_of_unknown_shape(): void { + $in = array( 'operations' => array( array( 'action' => 'update', 'element_id' => 'a1', 'settings' => array( 'my_css' => 'a{}' ) ) ) ); + $this->assertSame( array( array( 'type' => 'custom_css', 'id' => '42' ) ), $this->t( 'elementor/manage-elements', $in ) ); + $in = array( 'operations' => array( array( 'action' => 'update', 'element_id' => 'a1', 'style' => 'color:red', 'settings' => array( 'my_css' => 'a{}' ) ) ) ); + $this->assertSame( array( array( 'type' => 'custom_css', 'id' => '42' ) ), $this->t( 'elementor/manage-elements', $in ), 'beside precise CSS' ); + $in = array( 'operations' => array( array( 'action' => 'update', 'element_id' => 'a1', 'settings' => array( '_css_classes' => 'x', 'css_filters_blur' => 2 ) ) ) ); + $this->assertSame( array(), $this->t( 'elementor/manage-elements', $in ), 'exceptions' ); + } } From c2483fe3ff6b3edfceeb5423852f6e4c014083fb Mon Sep 17 00:00:00 2001 From: Ben Kalsky Date: Thu, 24 Sep 2026 20:36:31 +0300 Subject: [PATCH 13/13] test(rules): reflection on PHP 7.4 needs setAccessible (CI on #135) Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/unit/RulesOldForkWideningTest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/unit/RulesOldForkWideningTest.php b/tests/unit/RulesOldForkWideningTest.php index c82640d3..1c8f2db0 100644 --- a/tests/unit/RulesOldForkWideningTest.php +++ b/tests/unit/RulesOldForkWideningTest.php @@ -112,6 +112,9 @@ public function test_only_the_forks_own_abilities_are_widened(): void { public function test_widening_never_speaks_for_allow(): void { Aura_Worker_Rules::_set_fork_version_for_tests( '1.36.1' ); $widen = new ReflectionMethod( 'Aura_Worker_Rules', 'widen_for_old_fork' ); + if ( PHP_VERSION_ID < 80100 ) { + $widen->setAccessible( true ); // a no-op since 8.1, deprecated in 8.5 + } $widened = $widen->invoke( null, array( array( 'type' => 'page', 'id' => '42' ) ), 'elementor-mcp/update-element' ); $this->assertContains( array( 'type' => 'custom_css', 'id' => '42' ), $widened, 'the old fork IS widened' );