From 0f8f0a87e481e4e450e32c967e9d86322f01b6e8 Mon Sep 17 00:00:00 2001 From: Nyk <0xnykcd@googlemail.com> Date: Thu, 5 Mar 2026 12:12:32 +0700 Subject: [PATCH] fix(agents): enforce diagnostics self-scope and validation --- README.md | 18 +++ openapi.json | 120 +++++++++++++++++++ src/app/api/agents/[id]/diagnostics/route.ts | 64 +++++++++- tests/agent-diagnostics.spec.ts | 76 ++++++++++++ 4 files changed, 275 insertions(+), 3 deletions(-) create mode 100644 tests/agent-diagnostics.spec.ts diff --git a/README.md b/README.md index dc4ab10..d58be10 100644 --- a/README.md +++ b/README.md @@ -418,6 +418,24 @@ pnpm test:e2e # Playwright E2E pnpm quality:gate # All checks ``` +## Agent Diagnostics Contract + +`GET /api/agents/{id}/diagnostics` is self-scoped by default. + +- Self access: + - Session user where `username === agent.name`, or + - API-key request with `x-agent-name` matching `{id}` agent name +- Cross-agent access: + - Allowed only with explicit `?privileged=1` and admin auth +- Query validation: + - `hours` must be an integer between `1` and `720` + - `section` must be a comma-separated subset of `summary,tasks,errors,activity,trends,tokens` + +Trend alerts in the `trends.alerts` response are derived from current-vs-previous window comparisons: + +- `warning`: error spikes or severe activity drop +- `info`: throughput drops or potential stall patterns + ## Roadmap See [open issues](https://github.com/builderz-labs/mission-control/issues) for planned work and the [v1.0.0 release notes](https://github.com/builderz-labs/mission-control/releases/tag/v1.0.0) for what shipped. diff --git a/openapi.json b/openapi.json index e66e163..5963bff 100644 --- a/openapi.json +++ b/openapi.json @@ -1362,6 +1362,126 @@ } } }, + "/api/agents/{id}/diagnostics": { + "get": { + "tags": [ + "Agents" + ], + "summary": "Get self diagnostics for an agent", + "description": "Self-scoped diagnostics by default. Cross-agent access requires `privileged=1` with admin credentials. Trend alerts are informational signals derived from current-vs-previous window deltas (error spikes, throughput drops, activity stalls).", + "operationId": "getAgentDiagnostics", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Agent numeric ID or name." + }, + { + "name": "hours", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 720, + "default": 24 + }, + "description": "Diagnostics window in hours." + }, + { + "name": "section", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Comma-separated sections: summary,tasks,errors,activity,trends,tokens." + }, + { + "name": "privileged", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "1" + ] + }, + "description": "Set to `1` to allow explicit admin cross-agent diagnostics access." + } + ], + "responses": { + "200": { + "description": "Diagnostics payload", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "agent": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "role": { "type": "string" }, + "status": { "type": "string" } + } + }, + "timeframe": { + "type": "object", + "properties": { + "hours": { "type": "integer" }, + "since": { "type": "integer" }, + "until": { "type": "integer" } + } + }, + "summary": { "type": "object" }, + "tasks": { "type": "object" }, + "errors": { "type": "object" }, + "activity": { "type": "object" }, + "trends": { + "type": "object", + "properties": { + "current_period": { "type": "object" }, + "previous_period": { "type": "object" }, + "change": { "type": "object" }, + "alerts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "level": { "type": "string", "enum": ["info", "warning"] }, + "message": { "type": "string" } + } + } + } + } + }, + "tokens": { "type": "object" } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, "/api/agents/message": { "post": { "tags": [ diff --git a/src/app/api/agents/[id]/diagnostics/route.ts b/src/app/api/agents/[id]/diagnostics/route.ts index 567d5fa..4810843 100644 --- a/src/app/api/agents/[id]/diagnostics/route.ts +++ b/src/app/api/agents/[id]/diagnostics/route.ts @@ -3,6 +3,43 @@ import { getDatabase } from '@/lib/db'; import { requireRole } from '@/lib/auth'; import { logger } from '@/lib/logger'; +const ALLOWED_SECTIONS = ['summary', 'tasks', 'errors', 'activity', 'trends', 'tokens'] as const; +type DiagnosticsSection = (typeof ALLOWED_SECTIONS)[number]; + +function parseHoursParam(raw: string | null): { value?: number; error?: string } { + if (raw === null) return { value: 24 }; + const parsed = Number(raw); + if (!Number.isInteger(parsed)) { + return { error: 'hours must be an integer between 1 and 720' }; + } + if (parsed < 1 || parsed > 720) { + return { error: 'hours must be between 1 and 720' }; + } + return { value: parsed }; +} + +function parseSectionsParam(raw: string | null): { value?: Set; error?: string } { + if (!raw || raw.trim().length === 0) { + return { value: new Set(ALLOWED_SECTIONS) }; + } + + const requested = raw + .split(',') + .map((section) => section.trim()) + .filter(Boolean); + + if (requested.length === 0) { + return { error: 'section must include at least one valid value' }; + } + + const invalid = requested.filter((section) => !ALLOWED_SECTIONS.includes(section as DiagnosticsSection)); + if (invalid.length > 0) { + return { error: `Invalid section value(s): ${invalid.join(', ')}` }; + } + + return { value: new Set(requested as DiagnosticsSection[]) }; +} + /** * GET /api/agents/[id]/diagnostics - Agent Self-Diagnostics API * @@ -48,9 +85,30 @@ export async function GET( } const { searchParams } = new URL(request.url); - const hours = Math.min(Math.max(parseInt(searchParams.get('hours') || '24', 10) || 24, 1), 720); - const sectionParam = searchParams.get('section') || 'summary,tasks,errors,activity,trends,tokens'; - const sections = new Set(sectionParam.split(',').map(s => s.trim())); + const requesterAgentName = (request.headers.get('x-agent-name') || '').trim(); + const privileged = searchParams.get('privileged') === '1'; + const isSelfRequest = (requesterAgentName || auth.user.username) === agent.name; + + // Self-only by default. Cross-agent access requires explicit privileged override. + if (!isSelfRequest && !(privileged && auth.user.role === 'admin')) { + return NextResponse.json( + { error: 'Diagnostics are self-scoped. Use privileged=1 with admin role for cross-agent access.' }, + { status: 403 } + ); + } + + const parsedHours = parseHoursParam(searchParams.get('hours')); + if (parsedHours.error) { + return NextResponse.json({ error: parsedHours.error }, { status: 400 }); + } + + const parsedSections = parseSectionsParam(searchParams.get('section')); + if (parsedSections.error) { + return NextResponse.json({ error: parsedSections.error }, { status: 400 }); + } + + const hours = parsedHours.value as number; + const sections = parsedSections.value as Set; const now = Math.floor(Date.now() / 1000); const since = now - hours * 3600; diff --git a/tests/agent-diagnostics.spec.ts b/tests/agent-diagnostics.spec.ts new file mode 100644 index 0000000..03f8075 --- /dev/null +++ b/tests/agent-diagnostics.spec.ts @@ -0,0 +1,76 @@ +import { test, expect } from '@playwright/test' +import { API_KEY_HEADER, createTestAgent, deleteTestAgent } from './helpers' + +test.describe('Agent Diagnostics API', () => { + const cleanup: number[] = [] + + test.afterEach(async ({ request }) => { + for (const id of cleanup) { + await deleteTestAgent(request, id).catch(() => {}) + } + cleanup.length = 0 + }) + + test('self access is allowed with x-agent-name', async ({ request }) => { + const { id, name } = await createTestAgent(request) + cleanup.push(id) + + const res = await request.get(`/api/agents/${name}/diagnostics?section=summary`, { + headers: { ...API_KEY_HEADER, 'x-agent-name': name }, + }) + + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.agent.name).toBe(name) + expect(body.summary).toBeDefined() + }) + + test('cross-agent access is denied by default', async ({ request }) => { + const a = await createTestAgent(request) + const b = await createTestAgent(request) + cleanup.push(a.id, b.id) + + const res = await request.get(`/api/agents/${a.name}/diagnostics?section=summary`, { + headers: { ...API_KEY_HEADER, 'x-agent-name': b.name }, + }) + + expect(res.status()).toBe(403) + }) + + test('cross-agent access is allowed with privileged=1 for admin', async ({ request }) => { + const a = await createTestAgent(request) + const b = await createTestAgent(request) + cleanup.push(a.id, b.id) + + const res = await request.get(`/api/agents/${a.name}/diagnostics?section=summary&privileged=1`, { + headers: { ...API_KEY_HEADER, 'x-agent-name': b.name }, + }) + + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.agent.name).toBe(a.name) + expect(body.summary).toBeDefined() + }) + + test('invalid section query is rejected', async ({ request }) => { + const { id, name } = await createTestAgent(request) + cleanup.push(id) + + const res = await request.get(`/api/agents/${name}/diagnostics?section=summary,invalid`, { + headers: { ...API_KEY_HEADER, 'x-agent-name': name }, + }) + + expect(res.status()).toBe(400) + }) + + test('invalid hours query is rejected', async ({ request }) => { + const { id, name } = await createTestAgent(request) + cleanup.push(id) + + const res = await request.get(`/api/agents/${name}/diagnostics?hours=0`, { + headers: { ...API_KEY_HEADER, 'x-agent-name': name }, + }) + + expect(res.status()).toBe(400) + }) +})