agent/php/Mcp/Services/QueryAuditService.php
Snider 83df8ad71a fix(agent): address CodeRabbit + SonarCloud findings on PR #6
20+ CHANGES_REQUESTED dispositions across PHP MCP services, Go pkg/agentic,
hermes_runner_mcp Python server, plugin shell scripts.

Highlights:
- DatabaseSchema.php: identifier quoting
- AwardCredits.php: task row locking order
- CreditTransaction.php: fail-fast row decoding
- OpenApiGenerator.php: YAML parse handling + uri query params
- CaptureDispatchResultJob.php: AgentProfile namespace fix
- CreditsController.php: missing workspace_id fail-closed
- QueryAuditService.php: prose query false positives + unbounded aggregation
- McpHealthService.php: proc_close after timeout + env var resolution
- CreditLedger.php + FleetOverview.php: workspace agent + dispatch target validation
- McpAgentServerCommand.php: quota burn on failed tool calls
- McpMetricsService.php: N-day window consistency
- hermes_runner_mcp: API key off command line + invalid method+id + run_id encoding
- CircuitBreaker.php: extracted CircuitOpenException class with autoload-correct placement
- pkg/agentic + brain + flow: SonarCloud sendMessage/fetchLoopRepoRefs/commitWorkspace/Connect annotations
- shell scripts: removed [[ usage for portability

43 files modified, 1 new (CircuitOpenException.php).

Verification: gofmt -w + php -l + python3 -m py_compile + bash -n all clean.
Touched-package go test passes (pkg/lib/flow, pkg/lib).
Full go test ./... blocked by pre-existing dappco.re module graph drift, out of scope.

Parked for separate work:
- Mantis #1062: go.mod local replace removal (cross-repo architectural)
- Mantis #1063: Sonar residual line-length / duplication quality-gate cluster

Closes findings on https://github.com/dAppCore/agent/pull/6

Co-authored-by: Codex <noreply@openai.com>
2026-04-27 13:39:24 +01:00

262 lines
8.7 KiB
PHP

<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Services;
use Carbon\CarbonImmutable;
use Carbon\CarbonInterface;
use Core\Mod\Agentic\Mcp\Data\AuditEntry;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
use InvalidArgumentException;
use RuntimeException;
final class QueryAuditService
{
private const TABLE = 'mcp_audit_entries';
public function isSafe(string $query): bool
{
$trimmedQuery = ltrim($query);
$startsWithWriteStatement = preg_match(
'/^(?:--[^\n]*\n\s*)*(?:drop|delete|truncate|alter|create|insert|update)\b/i',
$trimmedQuery,
) === 1;
$callsDangerousFunction = preg_match('/(?:exec|system|passthru)\s*\(/i', $query) === 1;
return ! $startsWithWriteStatement && ! $callsDangerousFunction;
}
public function exceedsLimit(array $result, int $limitBytes = 1000000): bool
{
return strlen((string) json_encode($result, JSON_INVALID_UTF8_SUBSTITUTE)) > $limitBytes;
}
public function log(string $query, array $context = []): AuditEntry
{
$this->ensureTableExists();
$recordedAt = $this->resolveRecordedAt($context['recorded_at'] ?? null);
$entry = McpAuditEntry::query()->create([
'workspace_id' => isset($context['workspace_id']) ? (string) $context['workspace_id'] : null,
'tool_name' => isset($context['tool_name']) ? (string) $context['tool_name'] : null,
'query_text' => $query,
'query_hash' => hash('sha256', $query),
'is_safe' => $this->isSafe($query),
'result_count' => isset($context['result_count']) ? (int) $context['result_count'] : null,
'duration_ms' => isset($context['duration_ms']) ? (int) $context['duration_ms'] : null,
'metadata' => (array) ($context['metadata'] ?? []),
'created_at' => $recordedAt,
'updated_at' => $recordedAt,
]);
return AuditEntry::fromModel($entry);
}
/**
* @return Collection<int, AuditEntry>
*/
public function query(array $filters = []): Collection
{
$this->ensureTableExists();
$limit = (int) ($filters['limit'] ?? 100);
if ($limit < 1) {
throw new InvalidArgumentException('Query filters require limit to be at least 1.');
}
$builder = McpAuditEntry::query()->orderByDesc('created_at');
if (array_key_exists('workspace_id', $filters)) {
$builder->where('workspace_id', (string) $filters['workspace_id']);
}
if (array_key_exists('workspace', $filters)) {
$builder->where('workspace_id', (string) $filters['workspace']);
}
if (array_key_exists('tool_name', $filters)) {
$builder->where('tool_name', (string) $filters['tool_name']);
}
if (array_key_exists('tool', $filters)) {
$builder->where('tool_name', (string) $filters['tool']);
}
if (array_key_exists('safe', $filters)) {
$builder->where('is_safe', (bool) $filters['safe']);
}
if (array_key_exists('is_safe', $filters)) {
$builder->where('is_safe', (bool) $filters['is_safe']);
}
if (array_key_exists('search', $filters)) {
$builder->where('query_text', 'like', '%'.(string) $filters['search'].'%');
}
if (array_key_exists('from', $filters)) {
$builder->where('created_at', '>=', $this->resolveRecordedAt($filters['from']));
}
if (array_key_exists('until', $filters)) {
$builder->where('created_at', '<=', $this->resolveRecordedAt($filters['until']));
}
return $builder->limit($limit)->get()->map(
static fn (McpAuditEntry $entry): AuditEntry => AuditEntry::fromModel($entry),
);
}
/**
* @return array<string, array<int, array<string, int|string>>>
*/
public function aggregate(array $periods = ['day']): array
{
$this->ensureTableExists();
$resolvedPeriods = $periods === [] ? ['day'] : array_values(array_unique($periods));
$aggregates = [];
foreach ($resolvedPeriods as $period) {
$resolvedPeriod = $this->resolvePeriod((string) $period);
$aggregates[$resolvedPeriod] = [];
}
McpAuditEntry::query()
->orderBy('id')
->chunkById(250, function (Collection $entries) use (&$aggregates, $resolvedPeriods): void {
foreach ($entries as $entry) {
$timestamp = $this->entryTimestamp($entry);
foreach ($resolvedPeriods as $resolvedPeriod) {
$bucket = $this->bucketFor($timestamp, $resolvedPeriod);
if (! isset($aggregates[$resolvedPeriod][$bucket])) {
$aggregates[$resolvedPeriod][$bucket] = [
'bucket' => $bucket,
'total' => 0,
'safe' => 0,
'unsafe' => 0,
'duration_total' => 0,
'result_count' => 0,
];
}
$aggregates[$resolvedPeriod][$bucket]['total']++;
$aggregates[$resolvedPeriod][$bucket][$entry->is_safe ? 'safe' : 'unsafe']++;
$aggregates[$resolvedPeriod][$bucket]['duration_total'] += (int) ($entry->duration_ms ?? 0);
$aggregates[$resolvedPeriod][$bucket]['result_count'] += (int) ($entry->result_count ?? 0);
}
}
});
foreach ($aggregates as $period => $buckets) {
ksort($buckets);
$aggregates[$period] = array_values(array_map(
static function (array $bucket): array {
$total = max((int) $bucket['total'], 1);
return [
'bucket' => (string) $bucket['bucket'],
'total' => (int) $bucket['total'],
'safe' => (int) $bucket['safe'],
'unsafe' => (int) $bucket['unsafe'],
'average_duration_ms' => (int) round(((int) $bucket['duration_total']) / $total),
'result_count' => (int) $bucket['result_count'],
];
},
$buckets,
));
}
return $aggregates;
}
private function ensureTableExists(): void
{
if (! Schema::hasTable(self::TABLE)) {
throw new RuntimeException('The mcp_audit_entries table is required for QueryAuditService.');
}
}
private function resolveRecordedAt(mixed $value): CarbonImmutable
{
if ($value instanceof CarbonImmutable) {
return $value;
}
if ($value instanceof CarbonInterface) {
return CarbonImmutable::instance($value);
}
if ($value === null || $value === '') {
return CarbonImmutable::now();
}
return CarbonImmutable::parse((string) $value);
}
private function resolvePeriod(string $period): string
{
if (! in_array($period, ['minute', 'hour', 'day'], true)) {
throw new InvalidArgumentException(sprintf(
'Unsupported aggregation period [%s].',
$period,
));
}
return $period;
}
private function bucketFor(CarbonImmutable $timestamp, string $period): string
{
return match ($period) {
'minute' => $timestamp->format('Y-m-d H:i'),
'hour' => $timestamp->format('Y-m-d H:00'),
'day' => $timestamp->format('Y-m-d'),
};
}
private function entryTimestamp(McpAuditEntry $entry): CarbonImmutable
{
if ($entry->created_at instanceof CarbonInterface) {
return CarbonImmutable::instance($entry->created_at);
}
return CarbonImmutable::parse((string) ($entry->created_at ?? 'now'));
}
}
class McpAuditEntry extends Model
{
protected $table = 'mcp_audit_entries';
protected $fillable = [
'workspace_id',
'tool_name',
'query_text',
'query_hash',
'is_safe',
'result_count',
'duration_ms',
'metadata',
'created_at',
'updated_at',
];
protected $casts = [
'is_safe' => 'bool',
'result_count' => 'int',
'duration_ms' => 'int',
'metadata' => 'array',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}