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
21 changes: 19 additions & 2 deletions digitizer-site-worker/includes/class-aura-worker-rules.php
Original file line number Diff line number Diff line change
Expand Up @@ -2525,14 +2525,31 @@ public static function enforceable_match( array $touches, array $rules, $now = n
return $rule;
}

/**
* The rule enforce() would apply to this call — the same widening, the
* same accessor, the same site identity — with none of enforce()'s side
* effects: no expiry sweep, no per-dispatch record, no hook. The preview
* paths ask this (Aura spec 2026-09-25 §5.2), so what the gateway shows
* before approval is what the very next request decides.
*
* @since 2.21.0
* @param array $touches What the call declares it touches.
* @param string $tool_name Calling tool (an elementor-mcp/… ability name widens on an old fork).
* @param int|null $now Unix time; injected for tests.
* @return array|null The deciding rule (block or warn), or null.
*/
public static function preview_match( array $touches, $tool_name, $now = null ) {
$touches = self::widen_for_old_fork( $touches, $tool_name );
return self::enforceable_match( $touches, self::rules(), $now, self::site_ref() );
}

public static function enforce( array $touches, $tool_name, $now = null ) {
self::note_expired( $now );
// Judged in THIS site's identity (self::site_ref(), the one accessor —
// the preview path asks the same question of the same record, so the
// two can never disagree). The fork inherits this through enforce(),
// so its governance wrapper needs no change of its own.
$touches = self::widen_for_old_fork( $touches, $tool_name );
$rule = self::enforceable_match( $touches, self::rules(), $now, self::site_ref() );
$rule = self::preview_match( $touches, $tool_name, $now );
if ( null === $rule ) {
return array( 'effect' => null );
}
Expand Down
145 changes: 135 additions & 10 deletions digitizer-site-worker/includes/class-aura-worker-tools.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ class Aura_Worker_Tools {
*/
private $tools = array();

/**
* Test-only override of the fork's touch declarer: null = real detection,
* false = no fork, a callable = the fork (see fork_declarer()).
*
* @since 2.21.0
* @var null|false|callable
*/
private static $fork_declarer_for_tests = null;

/**
* Constructor — loads the base class and all tool files, then registers them.
*/
Expand Down Expand Up @@ -220,14 +229,10 @@ public function execute_tool( $name, $params ) {
* verdict, planned command, file diff, SQL) at approval time. Tools that do
* not declare supports_preview return `supported: false` with a null preview.
*
* Old-fork CSS widening (2.20.0) is NOT applied here: it lives in
* Aura_Worker_Rules::enforce(), which widens an `elementor-mcp/*`
* ability's page/site touches into custom_css ones before matching,
* while this preview asks enforceable_match() directly. That is safe
* today because only SiteAgent's own tools reach preview_tool() and none
* is named `elementor-mcp/…`, so widening would be a no-op here. If a
* fork ability ever reaches this path, widen here too, or the preview
* and enforce() disagree about the same call.
* Both paths — SiteAgent's own tools and the elementor-mcp fork's
* abilities (2.21.0, preview_fork_tool()) — decide through
* Aura_Worker_Rules::preview_match(), the function enforce() decides
* through, so the preview and the call it previews cannot disagree.
*
* @param string $name Tool name.
* @param array $params Parameters to preview.
Expand All @@ -237,6 +242,10 @@ public function preview_tool( $name, $params ) {
$tool = $this->get_tool( $name );

if ( null === $tool ) {
$fork = $this->preview_fork_tool( (string) $name, is_array( $params ) ? $params : array() );
if ( null !== $fork ) {
return $fork;
}
return array(
'success' => false,
'error' => "Unknown tool: $name",
Expand All @@ -259,15 +268,15 @@ public function preview_tool( $name, $params ) {
$annotations = $tool->get_annotations();
$touches = Aura_Worker_Call_Context::tool_needs_grant( $annotations ) ? $tool->touches( $params ) : array();
// In THIS site's identity (2.12.0), through the SAME accessor
// Aura_Worker_Rules::enforce() judges by (`enforceable_match()`): the
// Aura_Worker_Rules::enforce() judges by (`preview_match()`): the
// preview is what the gateway shows before approval, so it must name
// the rule enforcement would actually apply. Without the identity
// every scoped rule reads as applying here; without the shared
// accessor an `allow` winner — which enforce() discards, because the
// tools path already defaults to the approval queue — was reported as
// a verdict the very next request never applies. Either way the two
// disagree about the same call.
$rule = empty( $touches ) ? null : Aura_Worker_Rules::enforceable_match( $touches, Aura_Worker_Rules::rules(), null, Aura_Worker_Rules::site_ref() );
$rule = empty( $touches ) ? null : Aura_Worker_Rules::preview_match( $touches, $name );
$rule_match = null === $rule ? null : array(
'key' => isset( $rule['key'] ) ? (string) $rule['key'] : 'rule/?',
'effect' => (string) $rule['effect'],
Expand Down Expand Up @@ -299,4 +308,120 @@ public function preview_tool( $name, $params ) {
);
}
}

/**
* A queued elementor-mcp fork write, previewed by the fork itself (Aura
* spec 2026-09-25 §5). The fork's abilities are not in this registry;
* Elementor_MCP_Governance::declare_touches() (elementor-mcp 1.38.0)
* answers with exactly the touches its early rules gate judges, and the
* rule is decided here the way enforce() decides the fork's run.
*
* Null means "not the fork's either" — the caller answers Unknown tool,
* exactly as before: no fork, an older fork, a name the fork does not
* govern, a throw, anything malformed (never a partial list), or a
* declaration of nothing (a dry run).
*
* @since 2.21.0
* @param string $name The published MCP tool name.
* @param array $params The call's params, as queued.
* @return array|null
*/
private function preview_fork_tool( $name, array $params ) {
$declarer = self::fork_declarer();
if ( null === $declarer ) {
return null;
}
// Aura's routing key; its executor strips it before the fork sees the
// input, so the fork must not declare from it here either.
unset( $params['_mcpPath'] );
try {
$answer = call_user_func( $declarer, $name, $params );
} catch ( \Throwable $e ) {
return null;
}
if ( ! is_array( $answer ) || ! isset( $answer['ability'], $answer['touches'] ) || ! is_string( $answer['ability'] ) || ! is_array( $answer['touches'] ) ) {
return null;
}
$touches = self::validate_fork_touches( $answer['touches'] );
if ( null === $touches ) {
return null;
}
// A declaration of nothing (the fork's dry run — rules never see one)
// is not reported as coverage: Aura reads an empty set as the unknown
// sentinel, which every site/page rule matches, so `[]` would show a
// call the site runs unchecked as blocked. Unknown is the honest answer.
if ( empty( $touches ) ) {
return null;
}
$rule = Aura_Worker_Rules::preview_match( $touches, $answer['ability'] );
return array(
'success' => true,
'supported' => false,
'preview' => null,
'touches' => $touches,
'rule_match' => null === $rule ? null : array(
'key' => isset( $rule['key'] ) ? (string) $rule['key'] : 'rule/?',
'effect' => (string) $rule['effect'],
'reason' => isset( $rule['reason'] ) ? (string) $rule['reason'] : '',
),
);
}

/**
* Every touch `{type: string, id: string}`; `precise` / `css_only` kept
* only as the literal true and only on custom_css (the evidence an allow
* may use — spec 2026-09-24 §3). One malformed touch → null.
*
* @since 2.21.0
* @param array $touches The fork's touches.
* @return array|null
*/
private static function validate_fork_touches( array $touches ) {
$out = array();
foreach ( $touches as $t ) {
if ( ! is_array( $t ) || ! isset( $t['type'], $t['id'] ) || ! is_string( $t['type'] ) || ! is_string( $t['id'] ) ) {
return null;
}
$clean = array( 'type' => $t['type'], 'id' => $t['id'] );
if ( 'custom_css' === $t['type'] ) {
if ( isset( $t['precise'] ) && true === $t['precise'] ) {
$clean['precise'] = true;
}
if ( isset( $t['css_only'] ) && true === $t['css_only'] ) {
$clean['css_only'] = true;
}
}
$out[] = $clean;
}
return $out;
}

/**
* The fork's touch declarer, or null when there is none.
*
* @since 2.21.0
* @return callable|null
*/
private static function fork_declarer() {
if ( false === self::$fork_declarer_for_tests ) {
return null;
}
if ( null !== self::$fork_declarer_for_tests ) {
return self::$fork_declarer_for_tests;
}
if ( class_exists( 'Elementor_MCP_Governance' ) && method_exists( 'Elementor_MCP_Governance', 'declare_touches' ) ) {
return array( 'Elementor_MCP_Governance', 'declare_touches' );
}
return null;
}

/**
* Test-only: override fork detection.
*
* @since 2.21.0
* @param null|false|callable $declarer See $fork_declarer_for_tests.
*/
public static function _set_fork_declarer_for_tests( $declarer ) {
self::$fork_declarer_for_tests = $declarer;
}
}
3 changes: 3 additions & 0 deletions tests/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -5399,6 +5399,9 @@ function sa_reset_state(): void {
Aura_Worker_Rules::$cookie_auth_override = null;
Aura_Worker_Rules::_set_fork_version_for_tests( null );
}
if ( class_exists( 'Aura_Worker_Tools' ) ) {
Aura_Worker_Tools::_set_fork_declarer_for_tests( null );
}
if ( class_exists( 'Aura_Worker_Door_Log' ) ) {
// Ruling S7's test seam: a test that fakes a 32-bit build and forgets
// to clear it would otherwise poison every later test's door version
Expand Down
16 changes: 9 additions & 7 deletions tests/unit/AuditRulesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -455,14 +455,14 @@ public function test_the_identity_accessor_reads_the_record_defensively(): void

public function test_the_preview_and_enforcement_agree_about_a_foreign_scoped_rule(): void {
// The preview path is `Aura_Worker_Tools::preview_tool()`, which calls
// `enforceable_match()` — the accessor enforce() judges by. Asserted
// at the seam the two share: the same rules, the same identity, the
// same verdict.
// `Aura_Worker_Rules::preview_match()` — the accessor enforce() judges
// by. Asserted at the seam the two share: the same rules, the same
// identity, the same verdict.
$rules = array( array( 'key' => 'rule/elsewhere', 'effect' => 'block', 'target' => array( 'type' => 'site' ), 'reason' => 'r', 'sites' => array( 'res_A' ) ) );
$this->store_record( $rules, 'res_B' );
$touches = array( array( 'type' => 'post', 'id' => '7' ) );

$previewed = Aura_Worker_Rules::enforceable_match( $touches, Aura_Worker_Rules::rules(), null, Aura_Worker_Rules::site_ref() );
$previewed = Aura_Worker_Rules::preview_match( $touches, 'x' );
$enforced = Aura_Worker_Rules::enforce( $touches, 'x', 1800000000 );

$this->assertNull( $previewed, 'the preview reported a rule enforcement skips' );
Expand All @@ -473,11 +473,13 @@ public function test_the_preview_path_passes_the_identity_through(): void {
// The call site itself — a preview that dropped the argument would
// silently fall back to the unknown identity and warn about blocks
// that never fire — and it goes through the accessor enforce() judges
// by, never match() directly, or an `allow` winner enforcement
// discards would be shown to the operator as a verdict.
// by (2.21.0: Aura_Worker_Rules::preview_match(), which threads the
// identity through enforceable_match() itself), never match()
// directly, or an `allow` winner enforcement discards would be shown
// to the operator as a verdict.
$src = file_get_contents( __DIR__ . '/../../digitizer-site-worker/includes/class-aura-worker-tools.php' );
$this->assertStringContainsString(
'Aura_Worker_Rules::enforceable_match( $touches, Aura_Worker_Rules::rules(), null, Aura_Worker_Rules::site_ref() )',
'Aura_Worker_Rules::preview_match( $touches, $name )',
$src
);
$this->assertStringNotContainsString( 'Aura_Worker_Rules::match(', $src, 'the preview never asks match() for itself' );
Expand Down
Loading
Loading