|null Calls held while a module registers. */ private ?array $held = null; /** @var array Tool ids held by the module currently being registered. */ private array $heldToolNames = []; private bool $booted = false; private bool $booting = false; /** @var list */ private array $manifests = []; /** @var array, execute: callable, source: string}> */ private array $tools = []; /** @var list */ private array $rules = []; public function __construct( protected Definitions $definitions, protected DefinitionStore $store, protected ModuleRegistry $modules, protected ?ToolRegistrationLedger $toolRegistrations = null, ) { $this->toolRegistrations ??= new ToolRegistrationLedger(); } /** * Gather everything (once). * * @since 0.1.0 */ public function boot(): void { if ($this->booted || $this->booting) { return; } $this->booting = true; $this->definitions->addFile(dirname(__DIR__, 2) . '/resources/manifest/maze-graph.json', Definitions::BUILT_IN); $this->modules->boot($this, true); $this->source = 'plugin'; try { do_action('maze_graph_register', $this); } catch (\Throwable $e) { $this->definitions->report('plugin', 'a maze_graph_register callback failed: ' . mb_substr(wp_strip_all_tags($e->getMessage()), 0, 200)); } $this->modules->boot($this, false); foreach ($this->manifestPaths() as $path => $source) { $this->definitions->addFile($path, $source); } foreach ($this->store->all() as $i => $definition) { $this->definitions->add((string) $definition['kind'], $definition, Definitions::STORED, '/' . (string) ($definition['kind']) . '/' . (string) ($definition['id'] ?? $i)); } $this->booting = false; $this->booted = true; } /** * Hold calls from now on under `$source` (a module registering). * * @since 0.1.0 */ public function begin(string $source): void { $this->source = $source; $this->held = []; $this->heldToolNames = []; } /** * Apply the held calls. * * @since 0.1.0 */ public function commit(): void { $held = $this->held ?? []; $this->held = null; foreach ($held as $call) { $call(); } $this->heldToolNames = []; } /** * Drop the held calls (the module failed). * * @since 0.1.0 */ public function discard(): void { $this->held = null; $this->heldToolNames = []; } public function manifest(string $path): void { $this->accept(function () use ($path): void { $this->manifests[] = $path; }); } public function definition(array $definition): void { $source = $this->source; $this->accept(function () use ($definition, $source): void { $this->definitions->add(is_string($definition['kind'] ?? null) ? $definition['kind'] : '', $definition, $source); }); } public function agent(array $agent): void { $this->definition(['kind' => 'agent'] + $agent); } public function template(array $template): void { $this->definition(['kind' => 'template'] + $template); } public function starter(array $starter): void { $this->definition(['kind' => 'starter'] + $starter); } public function policy(array $rule): void { $this->definition(['kind' => 'policy'] + $rule); } public function context(string $line): void { $source = $this->source; $this->accept(function () use ($line, $source): void { $this->definitions->addContext($line, $source); }); } public function tool(string $name, array $args, callable $execute): void { $source = $this->source; $problem = self::toolProblem($name, $args); if ($problem !== '') { if ($this->held !== null) { // The module registry catches and sanitizes this message as a diagnostic; it is not rendered directly. // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped throw new \InvalidArgumentException($problem); } $this->definitions->report($source, $problem, '/tools/' . $name); return; } if ($this->held !== null) { if (isset($this->tools[$name]) || isset($this->heldToolNames[$name])) { // The module registry catches and sanitizes this message as a diagnostic; it is not rendered directly. // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped throw new \InvalidArgumentException(sprintf('tool "%s" collides with another registered tool', $name)); } $this->heldToolNames[$name] = true; $this->held[] = function () use ($name, $args, $execute, $source): void { $this->tools[$name] = ['args' => $args, 'execute' => $execute, 'source' => $source]; }; return; } $this->accept(function () use ($name, $args, $execute, $source): void { if (isset($this->tools[$name])) { $this->definitions->report($source, sprintf('tool "%s" is already registered by %s', $name, $this->tools[$name]['source']), '/tools/' . $name); return; } $this->tools[$name] = ['args' => $args, 'execute' => $execute, 'source' => $source]; }); } public function rule(PolicyRule $rule): void { $source = $this->source; $this->accept(function () use ($rule, $source): void { $this->rules[] = ['rule' => $rule, 'source' => $source]; }); } public function module(Module $module): void { $source = $this->source; $this->accept(function () use ($module, $source): void { $problem = $this->modules->add($module); if ($problem !== '') { $this->definitions->report($source, $problem, '/modules'); } }); } /** * Register the read-only tools added through `tool()` as WordPress abilities (on `wp_abilities_api_init`). * * @since 0.1.0 */ public function registerTools(): void { $this->boot(); $groups = []; foreach ($this->tools as $name => $tool) { $groups[$tool['source']][$name] = $tool; } foreach ($groups as $source => $tools) { $moduleId = str_starts_with((string) $source, 'module:') ? substr((string) $source, strlen('module:')) : ''; $registeredNames = []; $failed = false; foreach ($tools as $name => $tool) { $ability = strtolower($name); if ($ability === '' || $ability === '0') { continue; } $args = $tool['args']; $execute = $tool['execute']; $cap = is_string($args['capability'] ?? null) ? $args['capability'] : 'manage_options'; $input = is_array($args['input'] ?? null) && $args['input'] !== [] ? $args['input'] : ['type' => 'object', 'properties' => [], 'additionalProperties' => false]; $schema = array_replace($input, ['default' => []]); $executeCallback = static fn ($input = []) => $execute(is_array($input) ? $input : []); $permissionCallback = static fn (): bool => current_user_can($cap); $version = Tool::normalizeVersion($args['version'] ?? '') ?: Tool::normalizeVersion($args['pack_version'] ?? ''); $mazeGraphMeta = ['untrusted' => true, 'source' => $source, 'domain' => (string) ($args['domain'] ?? '')]; if ($version !== '') { $mazeGraphMeta['version'] = $version; } if (function_exists('wp_has_ability') && wp_has_ability($ability)) { $this->definitions->report((string) $source, sprintf('tool "%s" collides with an Ability already registered by another source', $name), '/tools/' . $name); $failed = $moduleId !== ''; if ($failed) { break; } continue; } try { $registered = wp_register_ability($ability, [ 'label' => (string) $args['label'], 'description' => (string) $args['description'], 'category' => self::TOOL_CATEGORY, 'input_schema' => $schema, 'execute_callback' => $executeCallback, 'permission_callback' => $permissionCallback, 'meta' => [ 'annotations' => ['readonly' => true, 'destructive' => false, 'idempotent' => true], 'show_in_rest' => false, 'mcp' => ['public' => false, 'type' => 'tool'], 'maze_graph' => $mazeGraphMeta, ], ]); if ($registered === null) { $this->definitions->report((string) $source, 'tool "' . $name . '" could not be registered; the Ability API rejected it', '/tools/' . $name); $failed = $moduleId !== ''; if ($failed) { break; } continue; } $registeredNames[] = $ability; $owner = ToolRegistrationLedger::ownerFor($execute); $this->toolRegistrations?->record( $ability, (string) $source, $owner, is_string($args['domain'] ?? null) ? sanitize_key($args['domain']) : '', $version, Tool::RISK_READ, $executeCallback, $permissionCallback, $schema, is_string($args['cost_hint'] ?? null) ? $args['cost_hint'] : 'normal', ); } catch (\Throwable $e) { $this->definitions->report((string) $source, 'tool "' . $name . '" could not be registered: ' . mb_substr(wp_strip_all_tags($e->getMessage()), 0, 200), '/tools'); $failed = $moduleId !== ''; if ($failed) { break; } } } if ($moduleId !== '' && $failed) { Features::quarantineAbilities($registeredNames); foreach ($registeredNames as $name) { Features::unregisterAbilityIfSupported($name); $this->toolRegistrations?->forget($name); } $message = 'The module tool pack could not register atomically; its abilities were removed.'; $this->modules->failRegistration($moduleId, $message); $this->definitions->report((string) $source, $message, '/tools'); } } } /** * @since 0.1.0 * @return list */ public function toolNames(): array { $this->boot(); return array_keys($this->tools); } /** * Rules added in PHP, and one rule holding every policy definition. * * @since 0.1.0 * @return list */ public function rules(): array { $this->boot(); $policies = array_map(static fn (array $item): array => $item['data'] + ['source' => $item['source']], $this->definitions->all('policy')); return [...array_column($this->rules, 'rule'), new \MazeAi\Engines\Policy\Rules\DefinedRules($policies)]; } /** * Sources of the PHP rules, for policy reporting. * * @since 0.1.0 * @return list */ public function ruleSources(): array { $this->boot(); return array_map(static fn (array $item): array => ['class' => $item['rule']::class, 'source' => $item['source']], $this->rules); } /** * @since 0.1.0 */ public function definitions(): Definitions { $this->boot(); return $this->definitions; } /** * @since 0.1.0 */ public function modules(): ModuleRegistry { $this->boot(); return $this->modules; } /** * Agent definitions as arrays, for the agent registry. * * @since 0.1.0 * @return list> */ public function agents(): array { return array_map(static fn (array $item): array => $item['data'], $this->definitions()->all('agent')); } /** * Context lines for the system prompt, from definitions and `maze_graph_context_notes`. * * @since 0.1.0 * @return list */ public function contextNotes(): array { $lines = array_column($this->definitions()->context(), 'text'); $lines = (array) apply_filters('maze_graph_context_notes', $lines); $clean = []; foreach ($lines as $line) { $line = is_string($line) ? trim((string) preg_replace('/\s+/u', ' ', wp_strip_all_tags($line))) : ''; if ($line !== '') { $clean[] = mb_substr($line, 0, 300); } } return array_slice(array_values(array_unique($clean)), 0, Definitions::MAX_CONTEXT); } /** * Run a call now, or hold it while a module registers. Calls after the gathering are refused. * * @param \Closure(): void $call */ private function accept(\Closure $call): void { if ($this->booted) { $this->definitions->report($this->source, 'was called after MazeGraph gathered its extensions; use the maze_graph_register action'); return; } if ($this->held !== null) { $this->held[] = $call; return; } $call(); } /** * Definition files and their labels. * * @return array */ private function manifestPaths(): array { $paths = []; $themeDirectories = [ get_stylesheet_directory() => 'theme:' . sanitize_key(get_stylesheet()), get_template_directory() => 'theme:' . sanitize_key(get_template()), ]; foreach (array_keys($themeDirectories) as $theme) { if (is_string($theme) && $theme !== '' && is_file($theme . '/maze-graph.json')) { $paths[] = $theme . '/maze-graph.json'; } } $paths = (array) apply_filters('maze_graph_manifest_paths', [...$paths, ...$this->manifests]); $out = []; foreach ($paths as $path) { if (is_string($path) && str_ends_with($path, '.json')) { $pathForMatch = realpath($path) ?: $path; $normalizedPath = wp_normalize_path($pathForMatch); foreach ($themeDirectories as $theme => $label) { $realTheme = realpath($theme) ?: $theme; $normalizedTheme = rtrim(wp_normalize_path($realTheme), '/'); if ($normalizedTheme !== '' && str_starts_with($normalizedPath, $normalizedTheme . '/')) { $out[$path] = $label . substr($normalizedPath, strlen($normalizedTheme)); continue 2; } } $out[$path] = basename(dirname($path)) . '/' . basename($path); } } return $out; } /** * Why a tool cannot be added, or ''. * * @param array $args */ private static function toolProblem(string $name, array $args): string { if (!preg_match('#^[a-z0-9][a-z0-9-]{0,40}/[a-z0-9][a-z0-9-]{0,60}$#', $name)) { return sprintf('tool name "%s" must be vendor/name', $name); } if (str_starts_with($name, 'maze-graph/') || str_starts_with($name, 'core/')) { return sprintf('tool name "%s" is reserved', $name); } if (!is_string($args['label'] ?? null) || $args['label'] === '' || mb_strlen($args['label']) > 120 || !is_string($args['description'] ?? null) || $args['description'] === '' || mb_strlen($args['description']) > 500) { return sprintf('tool "%s" needs a label and a description', $name); } if (isset($args['input']) && (!is_array($args['input']) || !Tool::schemaIsClosedAndBounded($args['input']))) { return sprintf('tool "%s": input must be a closed, bounded object schema in the supported subset', $name); } if (isset($args['capability']) && (!is_string($args['capability']) || !preg_match('/^[A-Za-z0-9_:-]{1,100}$/', $args['capability']))) { return sprintf('tool "%s": capability must be one WordPress capability name', $name); } if (isset($args['domain']) && (!is_string($args['domain']) || strlen($args['domain']) > 64 || ($args['domain'] !== '' && sanitize_key($args['domain']) === ''))) { return sprintf('tool "%s": domain must be a short identifier', $name); } foreach (['version', 'pack_version'] as $versionField) { if (isset($args[$versionField]) && !Tool::validVersion($args[$versionField])) { return sprintf('tool "%s": %s must be a short version string', $name, $versionField); } } if (isset($args['cost_hint']) && (!is_string($args['cost_hint']) || !in_array($args['cost_hint'], ['low', 'normal', 'high'], true))) { return sprintf('tool "%s": cost_hint must be low, normal or high', $name); } if (($args['read_only'] ?? true) !== true) { return sprintf('tool "%s": only read-only tools can be added in this version; write tools need an undo contract (plan 009)', $name); } return ''; } }