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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions digitizer-site-worker/includes/class-aura-worker-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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() );
Comment on lines +666 to +668

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Bump the advertised plugin version before release

This release identifies the new behavior as 2.20.0, but the plugin header, AURA_WORKER_VERSION, and readme.txt stable tag all remain at 2.19.3. Consequently, an existing 2.19.3 installation cannot receive this build through the normal Aura self-update path: class-aura-worker-updater.php explicitly rejects a package whose version equals the installed version before installing it. Update all three version declarations so this release can actually be deployed and reported as 2.20.0.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Declined for this PR, by procedure: the version bump to 2.20.0 (plugin header, AURA_WORKER_VERSION, readme stable tag, changelog) ships in the separate release PR opened after this feature PR merges — as stated in the PR body. Nothing is deployed from this branch.


return rest_ensure_response( $status );
}

Expand Down
198 changes: 194 additions & 4 deletions digitizer-site-worker/includes/class-aura-worker-rules.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
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' );
Expand Down Expand Up @@ -411,7 +422,8 @@ public static function is_expired( array $rule, $now ) {
* @return array<string,true> 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;
Expand All @@ -438,6 +450,25 @@ 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 ) {
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 — `[]`,
Expand All @@ -462,6 +493,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.
}
Expand All @@ -486,6 +520,161 @@ 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<string,true> $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 ) {
// 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 ] );
}
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:*'] );
}

/**
* 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;

/**
* 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|bool $has_css_touches See $fork_css_touches_for_tests.
*/
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 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
*/
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';
}
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';
}

/**
* 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 */
/* ------------------------------------------------------------------ */
Expand Down Expand Up @@ -2342,7 +2531,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 );
}
Expand Down
9 changes: 9 additions & 0 deletions digitizer-site-worker/includes/class-aura-worker-tools.php
Original file line number Diff line number Diff line change
Expand Up @@ -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[] }
Expand Down
Loading
Loading