diff --git a/src/Abilities/ExposurePolicy.php b/src/Abilities/ExposurePolicy.php index a79c149..8ce5614 100644 --- a/src/Abilities/ExposurePolicy.php +++ b/src/Abilities/ExposurePolicy.php @@ -196,6 +196,24 @@ final class ExposurePolicy )); } + /** + * Cross-group gate: whether the ability named $name is among $abilities and not disabled. + * lit/content-create's divi input follows lit/divi-insert this way (security M2), so the Divi + * group's "nur lesen"/disabled switch also stops Divi layouts written through another group. + * + * @param list $abilities + */ + public function allows(string $name, array $abilities): bool + { + foreach ($abilities as $ability) { + if ($ability->name() === $name) { + return $this->mode($ability) !== self::DISABLED; + } + } + + return false; + } + /** * @param list $abilities * @return list diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index 81f2a9e..616a338 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi; +use Closure; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility; @@ -58,14 +59,20 @@ final class DiviIntegration implements Integration return DiviModules::isAvailable(); } - /** Divi short form for lit/content-create; available() reports whether Divi 5 is loaded. */ - public function contentComposer(): ContentComposer + /** + * Divi short form for lit/content-create; available() reports whether Divi 5 is loaded, + * writable() asks $writable (the Tools-tab gate, security M2). + * + * @param (Closure(): bool)|null $writable + */ + public function contentComposer(?Closure $writable = null): ContentComposer { return new DiviContentComposer( $this->schema, new DiviComposition($this->modules, $this->design), new DiviSaver(), - $this->design + $this->design, + $writable ); } diff --git a/src/Integrations/Divi/Pages/DiviContentComposer.php b/src/Integrations/Divi/Pages/DiviContentComposer.php index 28fb54a..23df63a 100644 --- a/src/Integrations/Divi/Pages/DiviContentComposer.php +++ b/src/Integrations/Divi/Pages/DiviContentComposer.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi\Pages; +use Closure; use LIT\MCP\Abilities\Errors; use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\BlockTree\Document; @@ -34,12 +35,18 @@ use WP_Error; */ final class DiviContentComposer implements ContentComposer { + /** @var Closure(): bool */ + private Closure $writable; + + /** @param (Closure(): bool)|null $writable Tools-tab gate (Plugin: lit/divi-insert enabled); null = always. */ public function __construct( private ContentSchema $schema, private DiviComposition $composition, private DiviSaver $saver, - private DesignSystemReader $design + private DesignSystemReader $design, + ?Closure $writable = null ) { + $this->writable = $writable ?? static fn (): bool => true; } public function available(): bool @@ -47,6 +54,11 @@ final class DiviContentComposer implements ContentComposer return DiviModules::isAvailable(); } + public function writable(): bool + { + return ($this->writable)(); + } + public function check(string $postType, array $sections): ?WP_Error { if (! $this->schema->diviEnabled($postType)) { diff --git a/src/Integrations/WordPress/Content/ContentComposer.php b/src/Integrations/WordPress/Content/ContentComposer.php index df6cddb..555336c 100644 --- a/src/Integrations/WordPress/Content/ContentComposer.php +++ b/src/Integrations/WordPress/Content/ContentComposer.php @@ -21,6 +21,12 @@ interface ContentComposer /** Whether composing is possible on this site right now (e.g. Divi 5 active). */ public function available(): bool; + /** + * Whether the site owner lets LIT MCP write such content (Tools tab: for Divi, lit/divi-insert + * is not disabled and the Divi group is neither disabled nor "nur lesen" – security M2). + */ + public function writable(): bool; + /** * Validates the description for a new entry of $postType. Nothing is written. * diff --git a/src/Integrations/WordPress/Content/ContentCreateAbility.php b/src/Integrations/WordPress/Content/ContentCreateAbility.php index cf254cb..c5b78ab 100644 --- a/src/Integrations/WordPress/Content/ContentCreateAbility.php +++ b/src/Integrations/WordPress/Content/ContentCreateAbility.php @@ -118,6 +118,15 @@ final class ContentCreateAbility implements Ability ); } + if ($withDivi && ! $this->divi->writable()) { + return Errors::unsupported( + 'Divi-Inhalte zu schreiben ist in LIT MCP auf dieser Website abgeschaltet; nichts wurde angelegt.', + 'Die Seite ohne divi anlegen. Divi-Schreiben kann ein Administrator unter Einstellungen → LIT MCP ' + . '→ Tools freigeben (Gruppe Divi nicht „nur lesen“ oder deaktiviert, lit-divi-insert nicht ' + . 'deaktiviert).' + ); + } + $prepared = $this->writer->prepare($postType, $input, true); $problem = self::combined($prepared, $withDivi ? $this->checkDivi($postType, $input) : null); if ($problem !== null) { diff --git a/src/Plugin.php b/src/Plugin.php index 0956152..d695b0c 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -68,9 +68,18 @@ final class Plugin $schema = self::contentSchema(); $divi = new DiviIntegration($schema, $changeLog); + $registry = null; + $policy = null; + // Security M2: content-create's divi input follows lit/divi-insert (asked at execute time, + // when registry and policy exist), so the Divi group's switches stop every Divi layout write. + $diviWritable = static function () use (&$registry, &$policy): bool { + return $registry instanceof IntegrationRegistry + && $policy instanceof ExposurePolicy + && $policy->allows('lit/divi-insert', $registry->abilities()); + }; $registry = new IntegrationRegistry( new CoreIntegration(self::pluginDir() . '/docs/guides'), - new WordPressIntegration($schema, $changeLog, $divi->contentComposer()), + new WordPressIntegration($schema, $changeLog, $divi->contentComposer($diviWritable)), new MediaIntegration($schema, $changeLog, $limits), $divi ); diff --git a/tests/Unit/Abilities/AbilityRegistrarTest.php b/tests/Unit/Abilities/AbilityRegistrarTest.php index feaded8..331f30c 100644 --- a/tests/Unit/Abilities/AbilityRegistrarTest.php +++ b/tests/Unit/Abilities/AbilityRegistrarTest.php @@ -236,4 +236,28 @@ final class AbilityRegistrarTest extends TestCase (new AbilityRegistrar($registry, (new ExposurePolicy(['@group:divi' => 'disabled']))->withGroups($registry->groupMap()))) ->registerAbilities(); } + + /** + * Security M2 follow-up: a write tool of a "nur lesen" group is never registered, so + * mcp-adapter-execute-ability cannot reach it either (it resolves abilities via + * wp_get_ability()); the group's read tools stay. + */ + public function testReadonlyGroupWriteAbilitiesAreNotRegisteredForExecuteAbilityEither(): void + { + $write = Mockery::mock(Ability::class); + $write->allows('name')->andReturn('lit/divi-insert'); + $write->allows('isDirect')->andReturn(true); + $write->allows('annotations')->andReturn(['readonly' => false]); + $read = $this->ability(); + $read->allows('name')->andReturn('lit/test'); + $integration = Mockery::mock(Integration::class); + $integration->allows('id')->andReturn('divi'); + $integration->allows('isAvailable')->andReturn(true); + $integration->allows('abilities')->andReturn([$write, $read]); + $registry = new IntegrationRegistry($integration); + Functions\expect('wp_register_ability')->once()->with('lit/test', Mockery::type('array')); + + (new AbilityRegistrar($registry, (new ExposurePolicy(['@readonly:divi' => true]))->withGroups($registry->groupMap()))) + ->registerAbilities(); + } } diff --git a/tests/Unit/Abilities/ExposurePolicyTest.php b/tests/Unit/Abilities/ExposurePolicyTest.php index fb15188..7f8b8e1 100644 --- a/tests/Unit/Abilities/ExposurePolicyTest.php +++ b/tests/Unit/Abilities/ExposurePolicyTest.php @@ -124,6 +124,7 @@ final class ExposurePolicyTest extends TestCase 'lit/divi-outline' => 'divi', 'lit/divi-update' => 'divi', 'lit/divi-delete' => 'divi', + 'lit/divi-insert' => 'divi', 'lit/media-preview' => 'media', 'lit/media-download' => 'media', 'lit/media-get' => 'media', @@ -282,4 +283,32 @@ final class ExposurePolicyTest extends TestCase self::assertSame('direct', $policy->mode($this->ability('lit/site-status', true))); self::assertSame(1, $calls); } + + /** + * Cross-group gate (security M2): lit/content-create's divi input follows lit/divi-insert – off + * when that tool is disabled, its group is disabled or "nur lesen", or it does not exist. + */ + public function testAllowsFollowsTheNamedAbilitysMode(): void + { + $insert = $this->ability('lit/divi-insert', true, ['readonly' => false]); + $others = [$this->ability('lit/divi-outline', true, ['readonly' => true]), $insert]; + + self::assertTrue((new ExposurePolicy())->withGroups(self::GROUPS)->allows('lit/divi-insert', $others)); + self::assertTrue( + (new ExposurePolicy(['lit/divi-insert' => 'indirect']))->allows('lit/divi-insert', $others), + 'indirect still writes' + ); + foreach ( + [ + ['lit/divi-insert' => 'disabled'], + ['@group:divi' => 'disabled'], + ['@readonly:divi' => true, 'lit/divi-insert' => 'direct'], + ] as $option + ) { + $policy = (new ExposurePolicy($option))->withGroups(self::GROUPS); + self::assertFalse($policy->allows('lit/divi-insert', $others), (string) json_encode($option)); + } + + self::assertFalse((new ExposurePolicy())->allows('lit/divi-insert', []), 'Divi not available'); + } } diff --git a/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php b/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php index d46eb8b..7146b34 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php @@ -142,4 +142,19 @@ final class DiviContentComposerTest extends TestCase $this->expectException(RuntimeException::class); $this->composer()->write(42, 'page', self::sections()); } + + public function testWritableFollowsTheGateAndDefaultsToTrue(): void + { + self::assertTrue($this->composer()->writable()); + + $design = DiviWriterFixture::designReader(); + $gated = new DiviContentComposer( + DiviFixtures::schema(true), + DiviWriterFixture::composition($design), + $this->saver, + $design, + static fn (): bool => false + ); + self::assertFalse($gated->writable()); + } } diff --git a/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php b/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php index 85c76e5..7ee487f 100644 --- a/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php +++ b/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php @@ -56,10 +56,11 @@ final class ContentCreateDiviTest extends TestCase } /** @return ContentComposer&MockInterface */ - private function composer(bool $available = true): ContentComposer + private function composer(bool $available = true, bool $writable = true): ContentComposer { $composer = Mockery::mock(ContentComposer::class); $composer->allows('available')->andReturn($available); + $composer->allows('writable')->andReturn($writable); $composer->allows('check')->with('page', self::SECTIONS)->andReturn(null); return $composer; @@ -139,11 +140,39 @@ final class ContentCreateDiviTest extends TestCase self::assertArrayHasKey('content', self::errorFields($both)); } + /** + * Security M2: with the Divi group disabled or "nur lesen", or lit/divi-insert disabled, the + * divi input is refused before anything is created; content-create without divi keeps working. + */ + public function testDiviIsRefusedWhenDiviWritesAreSwitchedOff(): void + { + Functions\expect('wp_insert_post')->never(); + + $error = $this->ability($this->composer(true, false))->execute(self::INPUT); + + $payload = self::assertToolError($error, 'unsupported'); + self::assertStringContainsString('Tools', (string) $payload['hint']); + self::assertStringContainsString('ohne divi', (string) $payload['hint']); + } + + public function testWithoutDiviTheGateIsNotAsked(): void + { + $composer = Mockery::mock(ContentComposer::class); + $composer->expects('writable')->never(); + Functions\when('wp_insert_post')->justReturn(42); + $this->stubStoredDraft(); + + $result = $this->ability($composer)->execute(['post_type' => 'page', 'title' => 'Start']); + + self::assertTrue($result['ok']); + } + public function testEveryValidationErrorComesBackAndNothingIsCreated(): void { Functions\expect('wp_insert_post')->never(); $composer = Mockery::mock(ContentComposer::class); $composer->allows('available')->andReturn(true); + $composer->allows('writable')->andReturn(true); $composer->allows('check')->andReturn(Errors::invalidAttrPath(['divi[0].attrs.x' => 'unbekannt'])); $writer = Mockery::mock(PostWriter::class); $writer->allows('prepare')->andReturn(Errors::invalidInput('ungültig', ['fields.role' => 'Pflichtfeld fehlt.'])); @@ -238,6 +267,11 @@ final class ContentCreateDiviTest extends TestCase return true; } + public function writable(): bool + { + return true; + } + public function check(string $postType, array $sections): ?WP_Error { return $this->real->check($postType, $sections);