fix(agents): enforce diagnostics self-scope and validation
This commit is contained in:
parent
047216dbe2
commit
0f8f0a87e4
18
README.md
18
README.md
|
|
@ -418,6 +418,24 @@ pnpm test:e2e # Playwright E2E
|
||||||
pnpm quality:gate # All checks
|
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
|
## 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.
|
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.
|
||||||
|
|
|
||||||
120
openapi.json
120
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": {
|
"/api/agents/message": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,43 @@ import { getDatabase } from '@/lib/db';
|
||||||
import { requireRole } from '@/lib/auth';
|
import { requireRole } from '@/lib/auth';
|
||||||
import { logger } from '@/lib/logger';
|
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
|
* GET /api/agents/[id]/diagnostics - Agent Self-Diagnostics API
|
||||||
*
|
*
|
||||||
|
|
@ -48,9 +85,30 @@ export async function GET(
|
||||||
}
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const hours = Math.min(Math.max(parseInt(searchParams.get('hours') || '24', 10) || 24, 1), 720);
|
const requesterAgentName = (request.headers.get('x-agent-name') || '').trim();
|
||||||
const sectionParam = searchParams.get('section') || 'summary,tasks,errors,activity,trends,tokens';
|
const privileged = searchParams.get('privileged') === '1';
|
||||||
const sections = new Set(sectionParam.split(',').map(s => s.trim()));
|
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 now = Math.floor(Date.now() / 1000);
|
||||||
const since = now - hours * 3600;
|
const since = now - hours * 3600;
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue