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>
262 lines
8.7 KiB
PHP
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',
|
|
];
|
|
}
|