*/ private array $tools = []; public function __construct(private Policy $policy, private Clock $clock, private string $secret, private ConfirmationStore $used = new ArrayConfirmationStore()) { if (strlen($secret) < 32) { throw new \InvalidArgumentException('Confirmation secret too short.'); } } public function register(Tool $tool): void { if (!preg_match('/^[a-z][a-z0-9_.]{2,63}$/', $tool->name())) { throw new \InvalidArgumentException('Invalid tool name.'); } $this->tools[$tool->name()] = $tool; } /** * @param list $enabledForBot tool names an administrator enabled for this bot * @param array $args */ public function run(Principal $principal, array $enabledForBot, string $name, array $args, ?string $confirmation = null): ToolResult { $tool = $this->tools[$name] ?? null; if ($tool === null) { return ToolResult::denied('unknown_tool'); } if (!in_array($name, $enabledForBot, true)) { return ToolResult::denied('tool_not_enabled'); } $decision = $this->policy->check($principal, $tool->operation()); if (!$decision->allowed) { return ToolResult::denied('policy_' . $decision->code); } $error = $this->validate($tool->schema(), $args); if ($error !== null) { return ToolResult::denied('invalid_arguments: ' . $error); } if (!$tool->authorizeObject($principal, $args)) { return ToolResult::denied('object_not_authorized'); } if ($tool->mutates()) { if ($confirmation === null || !$this->verifyConfirmation($confirmation, $principal, $name, $args)) { return ToolResult::needsConfirmation($this->issueConfirmation($principal, $name, $args)); } [$exp, $mac] = explode('.', $confirmation, 2); if (!$this->used->consume($mac, (int) $exp)) { return ToolResult::denied('confirmation_already_used'); } } return ToolResult::ok($tool->execute($principal, $args)); } /** Issued to the human interface (never to the model) for an exact proposed action. */ public function issueConfirmation(Principal $principal, string $tool, array $args, int $ttl = 300): string { $exp = $this->clock->now() + $ttl; // A random nonce makes each issued token distinct, so single-use applies per issuance. $nonce = bin2hex(random_bytes(8)); return $exp . '.' . $nonce . '.' . hash_hmac('sha256', $this->binding($principal, $tool, $args, $exp) . '|' . $nonce, $this->secret); } private function verifyConfirmation(string $token, Principal $principal, string $tool, array $args): bool { [$exp, $nonce, $mac] = array_pad(explode('.', $token, 3), 3, ''); if (!ctype_digit($exp) || (int) $exp < $this->clock->now() || !ctype_xdigit($nonce)) { return false; } return hash_equals(hash_hmac('sha256', $this->binding($principal, $tool, $args, (int) $exp) . '|' . $nonce, $this->secret), $mac); } private function binding(Principal $principal, string $tool, array $args, int $exp): string { ksort($args); return implode('|', ['site:' . $principal->siteId, $principal->actorRef(), $tool, json_encode($args), $exp]); } /** @param array}> $schema */ private function validate(array $schema, array $args): ?string { foreach (array_keys($args) as $k) { if (!isset($schema[$k])) { return "unexpected argument \"$k\""; } } foreach ($schema as $k => $rule) { if (!array_key_exists($k, $args)) { if (!empty($rule['optional'])) { continue; } return "missing \"$k\""; } $v = $args[$k]; $ok = match ($rule['type']) { 'int' => is_int($v) && $v >= ($rule['min'] ?? PHP_INT_MIN) && $v <= ($rule['max'] ?? PHP_INT_MAX), 'string' => is_string($v) && mb_strlen($v) <= ($rule['max'] ?? 500) && (!isset($rule['enum']) || in_array($v, $rule['enum'], true)), default => false, }; if (!$ok) { return "invalid \"$k\""; } } return null; } }