$inputSchema */ public function __construct( public readonly string $name, public readonly string $label, public readonly string $description, public readonly bool $readOnly, public readonly bool $portable = true, public readonly bool $untrusted = false, public readonly bool $undoable = false, public readonly bool $hasInput = false, public readonly array $inputSchema = [], /** True only for trusted first-party tools explicitly marked common and non-destructive. */ public readonly bool $autoApprovable = false, public readonly ?string $riskLevel = null, /** Source facts are explicit for production descriptors; the empty value preserves older internal callers. */ public readonly string $sourceType = '', public readonly string $owner = '', public readonly string $domain = '', public readonly string $version = '', public readonly string $provenance = '', public readonly string $permissionStrategy = 'ability_callback', public readonly bool $permissionRequired = true, public readonly string $outputTaint = '', public readonly int $maxItems = 100, public readonly int $maxBytes = ResultShaper::BUDGET, public readonly string $availabilityReason = '', public readonly string $identityFingerprint = '', public readonly string $sourceId = '', public readonly int $descriptorVersion = 1, public readonly string $costHint = 'normal', public readonly bool $previewAvailable = false, public readonly bool $idempotent = false, ) { } /** * Normalize optional display-only version metadata. It is never a compatibility or trust signal. */ public static function normalizeVersion(mixed $version): string { if (!is_string($version)) { return ''; } $version = trim($version); return strlen($version) <= 40 && preg_match('/^[A-Za-z0-9][A-Za-z0-9.+_-]*$/', $version) === 1 ? $version : ''; } public static function validVersion(mixed $version): bool { return self::normalizeVersion($version) !== ''; } /** * Whether an ability's input schema is accepted by every provider. Anthropic rejects a schema that has `oneOf`, * `anyOf` or `allOf` at the top level, and one rejected tool fails the whole request. * * @since 0.1.0 */ public static function schemaIsPortable(mixed $schema): bool { return !is_array($schema) || array_intersect(['oneOf', 'anyOf', 'allOf'], array_keys($schema)) === []; } /** * Whether an input schema is a closed, bounded object in the subset we can safely declare to providers. * Empty schemas are allowed for operations that accept no input. * * @param array $schema */ public static function schemaIsClosedAndBounded(array $schema): bool { if ($schema === []) { return true; } if (($schema['type'] ?? null) !== 'object') { return false; } if (array_key_exists('default', $schema)) { if ($schema['default'] !== []) { return false; } unset($schema['default']); } $encoded = json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); if (!is_string($encoded) || strlen($encoded) > 16384) { return false; } return self::boundedNode($schema, 0); } /** * Whether a publisher Ability has a closed schema that Maze can bound at execution time. Publisher schemas may * use scalar defaults, common string formats, and arrays without a declared maxItems; AbilityToolRegistry applies * a stricter runtime input budget before permission checks and execution. PHP packs keep the stricter validator above. * * @param array $schema */ public static function publisherSchemaIsRuntimeBounded(array $schema): bool { if ($schema === []) { return true; } if (($schema['type'] ?? null) !== 'object') { return false; } if (array_key_exists('default', $schema)) { if ($schema['default'] !== []) { return false; } unset($schema['default']); } $encoded = json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); if (!is_string($encoded) || strlen($encoded) > 16384) { return false; } return self::publisherNode($schema, 0); } /** * `read` or `write`, for policy and the interface. * * @since 0.1.0 */ public function risk(): string { if (in_array($this->riskLevel ?? '', [self::RISK_READ, self::RISK_WRITE, self::RISK_UNKNOWN], true)) { return (string) $this->riskLevel; } return $this->readOnly ? self::RISK_READ : self::RISK_WRITE; } /** * The ability's namespace, e.g. `core` for `core/get-site-info`. * * @since 0.1.0 */ public function source(): string { return $this->sourceId !== '' ? $this->sourceId : (strstr($this->name, '/', true) ?: $this->name); } /** * Explicit source types are used by production policy; legacy descriptors retain namespace-based defaults. */ public function normalizedSourceType(): string { if ($this->sourceType !== '') { return $this->sourceType; } return match ($this->source()) { 'core' => 'core_ability', 'maze-graph' => 'maze_authored', default => 'publisher_ability', }; } /** * Whether this descriptor is Maze-authored code. This is populated from callback provenance in production. */ public function mazeOwned(): bool { return in_array($this->normalizedSourceType(), ['maze_authored', 'maze_pack'], true); } /** * @param array $node */ private static function boundedNode(array $node, int $depth): bool { $allowed = ['type', 'properties', 'required', 'additionalProperties', 'enum', 'const', 'description', 'minimum', 'maximum', 'minLength', 'maxLength', 'minItems', 'maxItems', 'items', 'pattern']; if ($depth > 4 || array_diff(array_keys($node), $allowed) !== []) { return false; } $type = $node['type'] ?? null; if (!in_array($type, ['object', 'array', 'string', 'integer', 'number', 'boolean'], true)) { return false; } if (isset($node['description']) && (!is_string($node['description']) || mb_strlen($node['description']) > 200)) { return false; } if (isset($node['enum'])) { if (!is_array($node['enum']) || $node['enum'] === [] || count($node['enum']) > 50) { return false; } foreach ($node['enum'] as $value) { if (!self::enumValueMatchesType($value, $type)) { return false; } } } if (isset($node['const']) && !self::enumValueMatchesType($node['const'], $type)) { return false; } if ($type === 'object') { if (array_intersect(['enum', 'const', 'minimum', 'maximum', 'minLength', 'maxLength', 'minItems', 'maxItems', 'items', 'pattern'], array_keys($node)) !== []) { return false; } if (($node['additionalProperties'] ?? null) !== false) { return false; } $properties = $node['properties'] ?? []; if (!is_array($properties) || count($properties) > 32) { return false; } $required = $node['required'] ?? []; if (!is_array($required) || count($required) > count($properties)) { return false; } foreach ($required as $key) { if (!is_string($key) || !array_key_exists($key, $properties)) { return false; } } foreach ($properties as $key => $property) { if (!is_string($key) || strlen($key) > 64 || !preg_match('/^[a-zA-Z0-9_-]+$/', $key) || !is_array($property) || !self::boundedNode($property, $depth + 1)) { return false; } } return true; } if ($type === 'string') { if (array_intersect(['properties', 'required', 'additionalProperties', 'minimum', 'maximum', 'minItems', 'maxItems', 'items'], array_keys($node)) !== []) { return false; } if (isset($node['pattern']) && (!is_string($node['pattern']) || mb_strlen($node['pattern']) > 256 || @preg_match('~' . str_replace('~', '\\~', $node['pattern']) . '~u', '') === false)) { return false; } if (isset($node['enum'])) { return true; } return isset($node['maxLength']) && is_int($node['maxLength']) && $node['maxLength'] >= 0 && $node['maxLength'] <= 2000 && (!isset($node['minLength']) || (is_int($node['minLength']) && $node['minLength'] >= 0 && $node['minLength'] <= $node['maxLength'])); } if ($type === 'array') { if (array_intersect(['properties', 'required', 'additionalProperties', 'enum', 'const', 'minimum', 'maximum', 'minLength', 'maxLength', 'pattern'], array_keys($node)) !== []) { return false; } return isset($node['maxItems'], $node['items']) && is_int($node['maxItems']) && $node['maxItems'] >= 0 && $node['maxItems'] <= 100 && (!isset($node['minItems']) || (is_int($node['minItems']) && $node['minItems'] >= 0 && $node['minItems'] <= $node['maxItems'])) && is_array($node['items']) && self::boundedNode($node['items'], $depth + 1); } if (in_array($type, ['integer', 'number'], true)) { if (array_intersect(['properties', 'required', 'additionalProperties', 'minLength', 'maxLength', 'minItems', 'maxItems', 'items', 'pattern'], array_keys($node)) !== []) { return false; } return isset($node['minimum'], $node['maximum']) && (is_int($node['minimum']) || is_float($node['minimum'])) && (is_int($node['maximum']) || is_float($node['maximum'])) && $node['maximum'] >= $node['minimum']; } if (array_intersect(['properties', 'required', 'additionalProperties', 'minimum', 'maximum', 'minLength', 'maxLength', 'minItems', 'maxItems', 'items', 'pattern'], array_keys($node)) !== []) { return false; } return true; } /** * @param array $node */ private static function publisherNode(array $node, int $depth, string $propertyName = ''): bool { $allowed = ['type', 'properties', 'required', 'additionalProperties', 'enum', 'const', 'description', 'minimum', 'maximum', 'minLength', 'maxLength', 'minItems', 'maxItems', 'items', 'pattern', 'format', 'default']; if ($depth > 4 || array_diff(array_keys($node), $allowed) !== []) { return false; } $type = $node['type'] ?? null; if (!in_array($type, ['object', 'array', 'string', 'integer', 'number', 'boolean'], true)) { return false; } if (isset($node['description']) && (!is_string($node['description']) || mb_strlen($node['description']) > 200)) { return false; } if (isset($node['enum'])) { if (!is_array($node['enum']) || $node['enum'] === [] || count($node['enum']) > 50) { return false; } foreach ($node['enum'] as $value) { if (!self::enumValueMatchesType($value, (string) $type)) { return false; } } } if (array_key_exists('const', $node) && !self::enumValueMatchesType($node['const'], (string) $type)) { return false; } if (isset($node['enum']) && array_key_exists('const', $node) && !in_array($node['const'], $node['enum'], true)) { return false; } if (array_key_exists('default', $node)) { $default = $node['default']; $validDefault = match ($type) { 'object' => is_array($default) && $default === [], 'array' => is_array($default) && array_is_list($default) && $default === [], default => self::enumValueMatchesType($default, (string) $type), }; if (!$validDefault || (isset($node['enum']) && !in_array($default, $node['enum'], true))) { return false; } if (array_key_exists('const', $node) && $default !== $node['const']) { return false; } if ($type === 'string') { $defaultLength = mb_strlen($default); if (strlen($default) > 4096 || $defaultLength < ($node['minLength'] ?? 0) || $defaultLength > ($node['maxLength'] ?? 4096)) { return false; } } if (($type === 'array' && ($node['minItems'] ?? 0) > 0) || ($type === 'object' && ($node['required'] ?? []) !== [])) { return false; } if (in_array($type, ['integer', 'number'], true)) { $numericDefault = (float) $default; if (!is_finite($numericDefault) || abs($numericDefault) > 2147483647 || ($propertyName === 'page' && $numericDefault > 10000) || (isset($node['minimum']) && $numericDefault < $node['minimum']) || (isset($node['maximum']) && $numericDefault > $node['maximum'])) { return false; } } } if ($type === 'object') { if (array_intersect(['enum', 'const', 'minimum', 'maximum', 'minLength', 'maxLength', 'minItems', 'maxItems', 'items', 'pattern', 'format'], array_keys($node)) !== []) { return false; } if (($node['additionalProperties'] ?? null) !== false) { return false; } $properties = $node['properties'] ?? []; if (!is_array($properties) || count($properties) > 32) { return false; } $required = $node['required'] ?? []; if (!is_array($required) || !array_is_list($required) || count($required) > count($properties)) { return false; } foreach ($required as $key) { if (!is_string($key) || !array_key_exists($key, $properties)) { return false; } } foreach ($properties as $key => $property) { if (!is_string($key) || strlen($key) > 64 || !preg_match('/^[a-zA-Z0-9_-]+$/', $key) || !is_array($property) || !self::publisherNode($property, $depth + 1, $key)) { return false; } } return true; } if ($type === 'array') { if (array_intersect(['properties', 'required', 'additionalProperties', 'enum', 'const', 'minimum', 'maximum', 'minLength', 'maxLength', 'pattern', 'format'], array_keys($node)) !== []) { return false; } if (!is_array($node['items'] ?? null)) { return false; } $maxItems = $node['maxItems'] ?? 100; $minItems = $node['minItems'] ?? 0; return is_int($maxItems) && $maxItems >= 0 && $maxItems <= 100 && is_int($minItems) && $minItems >= 0 && $minItems <= $maxItems && self::publisherNode($node['items'], $depth + 1); } if ($type === 'string') { if (array_intersect(['properties', 'required', 'additionalProperties', 'minimum', 'maximum', 'minItems', 'maxItems', 'items'], array_keys($node)) !== []) { return false; } $maxLength = $node['maxLength'] ?? 4096; $minLength = $node['minLength'] ?? 0; if (!is_int($maxLength) || $maxLength < 0 || $maxLength > 4096 || !is_int($minLength) || $minLength < 0 || $minLength > $maxLength) { return false; } if (isset($node['pattern']) && (!is_string($node['pattern']) || mb_strlen($node['pattern']) > 256 || @preg_match('~' . str_replace('~', '\\~', $node['pattern']) . '~u', '') === false)) { return false; } if (isset($node['format']) && !in_array($node['format'], ['date', 'date-time', 'email', 'time'], true)) { return false; } return true; } if (in_array($type, ['integer', 'number'], true)) { if (array_intersect(['properties', 'required', 'additionalProperties', 'minLength', 'maxLength', 'minItems', 'maxItems', 'items', 'pattern', 'format'], array_keys($node)) !== []) { return false; } foreach (['minimum', 'maximum'] as $bound) { if (isset($node[$bound]) && (!is_int($node[$bound]) && !is_float($node[$bound]) || !is_finite((float) $node[$bound]))) { return false; } } return !isset($node['minimum'], $node['maximum']) || $node['maximum'] >= $node['minimum']; } return array_intersect(['properties', 'required', 'additionalProperties', 'minimum', 'maximum', 'minLength', 'maxLength', 'minItems', 'maxItems', 'items', 'pattern', 'format'], array_keys($node)) === []; } private static function enumValueMatchesType(mixed $value, string $type): bool { return match ($type) { 'string' => is_string($value) && mb_strlen($value) <= 200, 'integer' => is_int($value), 'number' => is_int($value) || is_float($value), 'boolean' => is_bool($value), default => false, }; } }