mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-07-17 22:35:19 +00:00
The AI chat assistant scoped tool queries by company but ignored the per-user Bouncer abilities the rest of the app enforces, so any `use ai` holder could read customers, invoices, payments, and company financials their role couldn't otherwise see. Each AiTool now declares a required ability (entity-aligned); the registry hides unauthorized tools from the model and refuses to execute them as a backstop. Separately, admin/owner-supplied URLs were fetched server-side with no guard against private/reserved targets (SSRF): the AI base URL, the CurrencyConverter "DEDICATED" exchange-rate URL, and S3/Spaces file-disk endpoints. A shared PrivateNetworkGuard now backs a PublicHttpUrl validation rule (save-time) and runtime guards in each driver. - AiTool::requiredAbility() + mapping across all 12 tools - AiToolRegistry filters schemas() by ability and re-checks in execute() - PrivateNetworkGuard / BlockedUrlException / PublicHttpUrl rule (new) - Rule wired into AI config (service + 3 controllers), exchange-rate, and file-disk endpoints; runtime guards in OpenRouterDriver, CurrencyConverterDriver, and FileDiskService - Tests for ability filtering, the guard, the rule, and 422 rejections
75 lines
1.9 KiB
PHP
75 lines
1.9 KiB
PHP
<?php
|
|
|
|
namespace Tests\Support;
|
|
|
|
use App\Support\Ai\AiChatResponse;
|
|
use App\Support\Ai\AiDriver;
|
|
|
|
/**
|
|
* Test double for AiDriver that returns pre-queued responses from an array, so
|
|
* tests can script tool-call loops without hitting any real LLM API.
|
|
*
|
|
* It also records the last `messages` and `tools` payloads handed to the driver,
|
|
* letting tests assert which tool schemas were exposed to the model.
|
|
*
|
|
* Usage:
|
|
* ScriptedAiDriver::setResponses([
|
|
* new AiChatResponse(message: null, toolCalls: [...]),
|
|
* new AiChatResponse(message: 'Final answer'),
|
|
* ]);
|
|
*/
|
|
class ScriptedAiDriver extends AiDriver
|
|
{
|
|
/** @var array<int, AiChatResponse> */
|
|
public static array $responses = [];
|
|
|
|
public static int $callCount = 0;
|
|
|
|
/** @var array<int, array<string, mixed>> */
|
|
public static array $lastMessages = [];
|
|
|
|
/** @var array<int, array<string, mixed>> */
|
|
public static array $lastTools = [];
|
|
|
|
public static function reset(): void
|
|
{
|
|
self::$responses = [];
|
|
self::$callCount = 0;
|
|
self::$lastMessages = [];
|
|
self::$lastTools = [];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, AiChatResponse> $responses
|
|
*/
|
|
public static function setResponses(array $responses): void
|
|
{
|
|
self::$responses = $responses;
|
|
self::$callCount = 0;
|
|
}
|
|
|
|
public function chatCompletion(
|
|
array $messages,
|
|
string $model,
|
|
array $tools = [],
|
|
array $options = [],
|
|
): AiChatResponse {
|
|
self::$lastMessages = $messages;
|
|
self::$lastTools = $tools;
|
|
$response = self::$responses[self::$callCount] ?? new AiChatResponse(message: 'Default test reply');
|
|
self::$callCount++;
|
|
|
|
return $response;
|
|
}
|
|
|
|
public function textCompletion(string $prompt, string $model, array $options = []): string
|
|
{
|
|
return 'text completion test';
|
|
}
|
|
|
|
public function validateConnection(): array
|
|
{
|
|
return ['ok' => true];
|
|
}
|
|
}
|