fix(agents): enforce diagnostics self-scope and validation

This commit is contained in:
Nyk 2026-03-05 12:12:32 +07:00
parent 047216dbe2
commit 0f8f0a87e4
4 changed files with 275 additions and 3 deletions

View File

@ -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.

View File

@ -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": [

View File

@ -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<DiagnosticsSection>; 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<DiagnosticsSection>;
const now = Math.floor(Date.now() / 1000);
const since = now - hours * 3600;

View File

@ -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)
})
})