fix(security): content-create divi follows the Divi group switches (audit M2)

The divi input of lit/content-create is refused with unsupported when
lit/divi-insert is disabled or the Divi group is disabled or read-only
(ExposurePolicy::allows as a cross-group gate, asked at execute time).
content-create without divi keeps working. Adds a registrar test that
read-only write tools are never registered, so execute-ability can't
reach them either.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-26 19:16:35 +02:00
co-authored by Claude Opus 5.5
parent 7d0837be6c
commit 5f596b8a17
10 changed files with 169 additions and 6 deletions
@@ -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();
}
}
@@ -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');
}
}
@@ -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());
}
}
@@ -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);