Integrations name their own admin group (Integration::label, registry labels()); ToolsTab no longer hard-codes ids and explains why it re-applies withGroups. AccessTab and LogTab share AdminPostAction (guard, redirect, terminate seam). ExposurePolicy parses option keys in one place, has one isRequired() check and a clear $groups docblock (null vs []). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
329 lines
10 KiB
PHP
329 lines
10 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Abilities;
|
||
|
||
/**
|
||
* Decides per ability whether it is a direct MCP tool, only reachable through the
|
||
* adapter's meta-tools, or disabled (spec §4.3). Ability::isDirect() is the default.
|
||
*
|
||
* Abilities are grouped by the integration they come from. Resolution: tool override ›
|
||
* group override › tool default. A group's "nur lesen" switch disables every ability of the
|
||
* group that is not annotated readonly, whatever the modes say. REQUIRED abilities always
|
||
* keep their default. Group entries live in the same option under keys that start with "@"
|
||
* (ability names always contain "/", so they cannot collide).
|
||
*/
|
||
final class ExposurePolicy
|
||
{
|
||
public const OPTION = 'lit_mcp_exposure';
|
||
public const DIRECT = 'direct';
|
||
public const INDIRECT = 'indirect';
|
||
public const DISABLED = 'disabled';
|
||
public const MODES = [self::DIRECT, self::INDIRECT, self::DISABLED];
|
||
|
||
/** Option key prefix of a group's mode: "@group:<integration id>" => mode. */
|
||
public const GROUP_PREFIX = '@group:';
|
||
|
||
/** Option key prefix of a group's read-only switch: "@readonly:<integration id>" => true. */
|
||
public const READONLY_PREFIX = '@readonly:';
|
||
|
||
/**
|
||
* Image/embedded-resource results only work for direct tools (ruling Q6 / task 6
|
||
* dispatch): lit/media-preview (ImageContent) and lit/media-download (embedded
|
||
* resource) can be direct or disabled, but never indirect.
|
||
*/
|
||
public const DIRECT_ONLY = ['lit/media-preview', 'lit/media-download'];
|
||
|
||
/** Entry point for every conversation – must always stay a direct tool. */
|
||
public const REQUIRED = ['lit/guide'];
|
||
|
||
/** @var array<string, string> Ability name => mode. */
|
||
private array $tools = [];
|
||
|
||
/** @var array<string, string> Group id => mode. */
|
||
private array $groupModes = [];
|
||
|
||
/** @var array<string, true> Group ids switched to "nur lesen". */
|
||
private array $readonlyGroups = [];
|
||
|
||
/**
|
||
* Ability name => group id. [] = no groups known (withGroups() not called: group entries have
|
||
* no effect); null = a lazy $groupSource is set and not resolved yet (groupOf() resolves it).
|
||
*
|
||
* @var array<string, string>|null
|
||
*/
|
||
private ?array $groups = [];
|
||
|
||
/** @var (callable(): array<string, string>)|null Lazy source of $groups. */
|
||
private $groupSource = null;
|
||
|
||
/**
|
||
* @param array<string, string|bool> $overrides Option content: ability name => mode, plus
|
||
* "@group:<id>" => mode and "@readonly:<id>" => true.
|
||
*/
|
||
public function __construct(array $overrides = [])
|
||
{
|
||
foreach ($overrides as $key => $value) {
|
||
['kind' => $kind, 'id' => $id] = self::parseKey((string) $key);
|
||
if ($kind === 'group') {
|
||
if (is_string($value) && in_array($value, self::MODES, true)) {
|
||
$this->groupModes[$id] = $value;
|
||
}
|
||
} elseif ($kind === 'readonly') {
|
||
if ($value === true) {
|
||
$this->readonlyGroups[$id] = true;
|
||
}
|
||
} elseif (is_string($value)) {
|
||
$this->tools[$id] = $value;
|
||
}
|
||
}
|
||
}
|
||
|
||
public static function fromOption(): self
|
||
{
|
||
return new self(self::sanitize(get_option(self::OPTION, [])));
|
||
}
|
||
|
||
/**
|
||
* Copy of this policy that knows which group (integration id) each ability belongs to.
|
||
* Without it, group entries have no effect. A callable is resolved on first use – boot runs
|
||
* on plugins_loaded, before the theme (Divi) is loaded, so the map must not be built there.
|
||
*
|
||
* @param array<string, string>|(callable(): array<string, string>) $groups Ability name => group id.
|
||
*/
|
||
public function withGroups(array|callable $groups): self
|
||
{
|
||
$clone = clone $this;
|
||
if (is_array($groups)) {
|
||
$clone->groups = $groups;
|
||
$clone->groupSource = null;
|
||
} else {
|
||
$clone->groups = null;
|
||
$clone->groupSource = $groups;
|
||
}
|
||
|
||
return $clone;
|
||
}
|
||
|
||
public function mode(Ability $ability): string
|
||
{
|
||
if (self::isRequired($ability->name())) {
|
||
return $this->defaultMode($ability);
|
||
}
|
||
|
||
if ($this->disabledByReadonly($ability)) {
|
||
return self::DISABLED;
|
||
}
|
||
|
||
return $this->toolOverride($ability->name()) ?? $this->inheritedMode($ability);
|
||
}
|
||
|
||
/** Mode the ability gets without an override of its own: group override › tool default. */
|
||
public function inheritedMode(Ability $ability): string
|
||
{
|
||
$name = $ability->name();
|
||
$groupMode = $this->groupModeOf($name);
|
||
if ($groupMode === null || self::isRequired($name)) {
|
||
return $this->defaultMode($ability);
|
||
}
|
||
|
||
return self::isAllowed($name, $groupMode) ? $groupMode : self::DIRECT;
|
||
}
|
||
|
||
/** True if the group's mode is not allowed for this ability and it falls back to "direct". */
|
||
public function groupModeAdjusted(Ability $ability): bool
|
||
{
|
||
$name = $ability->name();
|
||
$groupMode = $this->groupModeOf($name);
|
||
|
||
return $groupMode !== null
|
||
&& ! self::isRequired($name)
|
||
&& ! self::isAllowed($name, $groupMode);
|
||
}
|
||
|
||
/** True if the group's "nur lesen" switch disables this (writing) ability. */
|
||
public function disabledByReadonly(Ability $ability): bool
|
||
{
|
||
$group = $this->groupOf($ability->name());
|
||
if ($group === null || ! isset($this->readonlyGroups[$group])) {
|
||
return false;
|
||
}
|
||
|
||
if (self::isRequired($ability->name())) {
|
||
return false;
|
||
}
|
||
|
||
return ($ability->annotations()['readonly'] ?? false) !== true;
|
||
}
|
||
|
||
public function hasToolOverride(string $name): bool
|
||
{
|
||
return $this->toolOverride($name) !== null;
|
||
}
|
||
|
||
public function toolOverride(string $name): ?string
|
||
{
|
||
$mode = $this->tools[$name] ?? null;
|
||
|
||
return $mode !== null && self::isAllowed($name, $mode) ? $mode : null;
|
||
}
|
||
|
||
public function groupMode(string $group): ?string
|
||
{
|
||
return $this->groupModes[$group] ?? null;
|
||
}
|
||
|
||
public function isReadonly(string $group): bool
|
||
{
|
||
return isset($this->readonlyGroups[$group]);
|
||
}
|
||
|
||
public function defaultMode(Ability $ability): string
|
||
{
|
||
return $ability->isDirect() ? self::DIRECT : self::INDIRECT;
|
||
}
|
||
|
||
/**
|
||
* @param list<Ability> $abilities
|
||
* @return list<Ability>
|
||
*/
|
||
public function enabled(array $abilities): array
|
||
{
|
||
return array_values(array_filter(
|
||
$abilities,
|
||
fn (Ability $ability): bool => $this->mode($ability) !== self::DISABLED
|
||
));
|
||
}
|
||
|
||
/**
|
||
* @param list<Ability> $abilities
|
||
* @return list<string>
|
||
*/
|
||
public function directNames(array $abilities): array
|
||
{
|
||
$names = [];
|
||
foreach ($abilities as $ability) {
|
||
if ($this->mode($ability) === self::DIRECT) {
|
||
$names[] = $ability->name();
|
||
}
|
||
}
|
||
|
||
return $names;
|
||
}
|
||
|
||
public static function isAllowed(string $name, string $mode): bool
|
||
{
|
||
if (! in_array($mode, self::MODES, true)) {
|
||
return false;
|
||
}
|
||
|
||
if (self::isRequired($name)) {
|
||
return $mode === self::DIRECT;
|
||
}
|
||
|
||
if (in_array($name, self::DIRECT_ONLY, true)) {
|
||
return $mode !== self::INDIRECT;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Settings API sanitize callback for the option. Keeps valid ability entries (name => allowed
|
||
* mode), "@group:<id>" => mode and "@readonly:<id>" => true; drops everything else. With
|
||
* $groupIds, only those group ids are accepted.
|
||
*
|
||
* @param list<string>|null $groupIds Known group (integration) ids; null = any well-formed id.
|
||
* @return array<string, string|true>
|
||
*/
|
||
public static function sanitize(mixed $input, ?array $groupIds = null): array
|
||
{
|
||
if (! is_array($input)) {
|
||
return [];
|
||
}
|
||
|
||
$clean = [];
|
||
foreach ($input as $key => $value) {
|
||
if (! is_string($key)) {
|
||
continue;
|
||
}
|
||
|
||
['kind' => $kind, 'id' => $id] = self::parseKey($key);
|
||
if ($kind === 'group') {
|
||
if (
|
||
self::isGroupId($id, $groupIds)
|
||
&& is_string($value)
|
||
&& in_array($value, self::MODES, true)
|
||
) {
|
||
$clean[$key] = $value;
|
||
}
|
||
} elseif ($kind === 'readonly') {
|
||
if (
|
||
self::isGroupId($id, $groupIds)
|
||
&& ($value === true || $value === '1' || $value === 1)
|
||
) {
|
||
$clean[$key] = true;
|
||
}
|
||
} elseif (
|
||
is_string($value)
|
||
&& preg_match('#^[a-z0-9-]+/[a-z0-9-]+$#D', $key) === 1
|
||
&& self::isAllowed($key, $value)
|
||
) {
|
||
$clean[$key] = $value;
|
||
}
|
||
}
|
||
ksort($clean);
|
||
|
||
return $clean;
|
||
}
|
||
|
||
/**
|
||
* What an option key names: "@group:<id>" a group's mode, "@readonly:<id>" a group's
|
||
* read-only switch, anything else an ability (id = the whole key).
|
||
*
|
||
* @return array{kind: 'group'|'readonly'|'tool', id: string}
|
||
*/
|
||
private static function parseKey(string $key): array
|
||
{
|
||
foreach (['group' => self::GROUP_PREFIX, 'readonly' => self::READONLY_PREFIX] as $kind => $prefix) {
|
||
if (str_starts_with($key, $prefix)) {
|
||
return ['kind' => $kind, 'id' => substr($key, strlen($prefix))];
|
||
}
|
||
}
|
||
|
||
return ['kind' => 'tool', 'id' => $key];
|
||
}
|
||
|
||
private static function isRequired(string $name): bool
|
||
{
|
||
return in_array($name, self::REQUIRED, true);
|
||
}
|
||
|
||
/** @param list<string>|null $groupIds */
|
||
private static function isGroupId(string $id, ?array $groupIds): bool
|
||
{
|
||
if (preg_match('#^[a-z0-9-]+$#D', $id) !== 1) {
|
||
return false;
|
||
}
|
||
|
||
return $groupIds === null || in_array($id, $groupIds, true);
|
||
}
|
||
|
||
private function groupOf(string $name): ?string
|
||
{
|
||
if ($this->groups === null) {
|
||
$this->groups = $this->groupSource !== null ? ($this->groupSource)() : [];
|
||
}
|
||
|
||
return $this->groups[$name] ?? null;
|
||
}
|
||
|
||
private function groupModeOf(string $name): ?string
|
||
{
|
||
$group = $this->groupOf($name);
|
||
|
||
return $group === null ? null : ($this->groupModes[$group] ?? null);
|
||
}
|
||
}
|