diff --git a/README.md b/README.md index dc4ab10..7385b22 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,7 @@ All endpoints require authentication unless noted. Full reference below. | `GET` | `/api/agents` | viewer | List agents with task stats | | `POST` | `/api/agents` | operator | Register/update agent | | `GET` | `/api/agents/[id]` | viewer | Agent details | +| `GET` | `/api/agents/[id]/attribution` | viewer | Self-scope attribution/audit/cost report (`?privileged=1` admin override) | | `POST` | `/api/agents/sync` | operator | Sync agents from openclaw.json | | `GET/PUT` | `/api/agents/[id]/soul` | operator | Agent SOUL content (reads from workspace, writes to both) | | `GET/POST` | `/api/agents/comms` | operator | Agent inter-agent communication | @@ -217,6 +218,14 @@ All endpoints require authentication unless noted. Full reference below. +### Attribution Contract (`/api/agents/[id]/attribution`) + +- Self-scope by default: requester identity must match target agent via `x-agent-name` (or matching authenticated username). +- Admin override requires explicit `?privileged=1`. +- Query params: + - `hours`: integer window `1..720` (default `24`) + - `section`: comma-separated subset of `identity,audit,mutations,cost` (default all) +
Monitoring diff --git a/openapi.json b/openapi.json index e66e163..50105d5 100644 --- a/openapi.json +++ b/openapi.json @@ -1016,6 +1016,86 @@ } } }, + "/api/agents/{id}/attribution": { + "get": { + "tags": [ + "Agents" + ], + "summary": "Get attribution report for an agent", + "description": "Self-scope by default. Requester must match target agent (`x-agent-name` or username), unless admin uses `?privileged=1`.", + "operationId": "getAgentAttribution", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "hours", + "in": "query", + "required": false, + "description": "Time window in hours, integer range 1..720. Defaults to 24.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 720, + "default": 24 + } + }, + { + "name": "section", + "in": "query", + "required": false, + "description": "Comma-separated subset of identity,audit,mutations,cost. Defaults to all.", + "schema": { + "type": "string", + "example": "identity,audit" + } + }, + { + "name": "privileged", + "in": "query", + "required": false, + "description": "Set to 1 for admin override of self-scope checks.", + "schema": { + "type": "string", + "enum": [ + "1" + ] + } + }, + { + "name": "x-agent-name", + "in": "header", + "required": false, + "description": "Attribution identity header used for self-scope authorization.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Attribution report" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, "/api/agents/{id}/heartbeat": { "get": { "tags": [ diff --git a/src/app/api/agents/[id]/attribution/route.ts b/src/app/api/agents/[id]/attribution/route.ts index c1e2509..53f3456 100644 --- a/src/app/api/agents/[id]/attribution/route.ts +++ b/src/app/api/agents/[id]/attribution/route.ts @@ -3,6 +3,8 @@ import { getDatabase } from '@/lib/db'; import { requireRole } from '@/lib/auth'; import { logger } from '@/lib/logger'; +const ALLOWED_SECTIONS = new Set(['identity', 'audit', 'mutations', 'cost']); + /** * GET /api/agents/[id]/attribution - Agent-Level Identity & Attribution * @@ -46,9 +48,28 @@ 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') || 'identity,audit,mutations,cost'; - const sections = new Set(sectionParam.split(',').map(s => s.trim())); + const privileged = searchParams.get('privileged') === '1'; + const isSelfByHeader = auth.user.agent_name === agent.name; + const isSelfByUsername = auth.user.username === agent.name; + const isSelf = isSelfByHeader || isSelfByUsername; + const isPrivileged = auth.user.role === 'admin' && privileged; + if (!isSelf && !isPrivileged) { + return NextResponse.json( + { error: 'Forbidden: attribution is self-scope by default. Admin can use ?privileged=1 override.' }, + { status: 403 } + ); + } + + const hoursRaw = searchParams.get('hours'); + const hours = parseHours(hoursRaw); + if (!hours) { + return NextResponse.json({ error: 'Invalid hours. Expected integer 1..720.' }, { status: 400 }); + } + + const sections = parseSections(searchParams.get('section')); + if ('error' in sections) { + return NextResponse.json({ error: sections.error }, { status: 400 }); + } const now = Math.floor(Date.now() / 1000); const since = now - hours * 3600; @@ -56,21 +77,22 @@ export async function GET( const result: Record = { agent_name: agent.name, timeframe: { hours, since, until: now }, + access_scope: isSelf ? 'self' : 'privileged', }; - if (sections.has('identity')) { + if (sections.sections.has('identity')) { result.identity = buildIdentity(db, agent, workspaceId); } - if (sections.has('audit')) { + if (sections.sections.has('audit')) { result.audit = buildAuditTrail(db, agent.name, workspaceId, since); } - if (sections.has('mutations')) { + if (sections.sections.has('mutations')) { result.mutations = buildMutations(db, agent.name, workspaceId, since); } - if (sections.has('cost')) { + if (sections.sections.has('cost')) { result.cost = buildCostAttribution(db, agent.name, workspaceId, since); } @@ -83,7 +105,7 @@ export async function GET( /** Agent identity and profile info */ function buildIdentity(db: any, agent: any, workspaceId: number) { - const config = agent.config ? JSON.parse(agent.config) : {}; + const config = safeParseJson(agent.config, {}); // Count total tasks ever assigned const taskStats = db.prepare(` @@ -155,11 +177,11 @@ function buildAuditTrail(db: any, agentName: string, workspaceId: number, since: by_type: byType, activities: activities.map(a => ({ ...a, - data: a.data ? JSON.parse(a.data) : null, + data: safeParseJson(a.data, null), })), audit_log_entries: auditEntries.map(e => ({ ...e, - detail: e.detail ? JSON.parse(e.detail) : null, + detail: safeParseJson(e.detail, null), })), }; } @@ -201,16 +223,16 @@ function buildMutations(db: any, agentName: string, workspaceId: number, since: return { task_mutations: taskMutations.map(m => ({ ...m, - data: m.data ? JSON.parse(m.data) : null, + data: safeParseJson(m.data, null), })), comments: comments.map(c => ({ ...c, - mentions: c.mentions ? JSON.parse(c.mentions) : [], + mentions: safeParseJson(c.mentions, []), content_preview: c.content?.substring(0, 200) || '', })), status_changes: statusChanges.map(s => ({ ...s, - data: s.data ? JSON.parse(s.data) : null, + data: safeParseJson(s.data, null), })), summary: { task_mutations_count: taskMutations.length, @@ -294,3 +316,41 @@ function buildCostAttribution(db: any, agentName: string, workspaceId: number, s return { by_model: [], total: { input_tokens: 0, output_tokens: 0, requests: 0 }, daily_trend: [] }; } } + +function parseHours(hoursRaw: string | null): number | null { + if (!hoursRaw || hoursRaw.trim() === '') return 24; + if (!/^\d+$/.test(hoursRaw)) return null; + const hours = Number(hoursRaw); + if (!Number.isInteger(hours) || hours < 1 || hours > 720) return null; + return hours; +} + +function parseSections( + sectionRaw: string | null +): { sections: Set } | { error: string } { + const value = (sectionRaw || 'identity,audit,mutations,cost').trim(); + const parsed = value + .split(',') + .map((section) => section.trim()) + .filter(Boolean); + + if (parsed.length === 0) { + return { error: 'Invalid section. Expected one or more of identity,audit,mutations,cost.' }; + } + + const invalid = parsed.filter((section) => !ALLOWED_SECTIONS.has(section)); + if (invalid.length > 0) { + return { error: `Invalid section value(s): ${invalid.join(', ')}` }; + } + + return { sections: new Set(parsed) }; +} + +function safeParseJson(raw: string | null | undefined, fallback: T): T { + if (!raw) return fallback; + try { + return JSON.parse(raw) as T; + } catch { + return fallback; + } +} diff --git a/tests/agent-attribution.spec.ts b/tests/agent-attribution.spec.ts new file mode 100644 index 0000000..30af759 --- /dev/null +++ b/tests/agent-attribution.spec.ts @@ -0,0 +1,77 @@ +import { expect, test } from '@playwright/test' +import { API_KEY_HEADER, createTestAgent, deleteTestAgent } from './helpers' + +test.describe('Agent Attribution API', () => { + const cleanup: number[] = [] + + test.afterEach(async ({ request }) => { + for (const id of cleanup) { + await deleteTestAgent(request, id).catch(() => {}) + } + cleanup.length = 0 + }) + + test('allows self-scope access using x-agent-name attribution header', async ({ request }) => { + const { id, name } = await createTestAgent(request) + cleanup.push(id) + + const res = await request.get(`/api/agents/${id}/attribution`, { + 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.access_scope).toBe('self') + }) + + test('denies cross-agent attribution access by default', async ({ request }) => { + const primary = await createTestAgent(request) + const other = await createTestAgent(request) + cleanup.push(primary.id, other.id) + + const res = await request.get(`/api/agents/${primary.id}/attribution`, { + headers: { ...API_KEY_HEADER, 'x-agent-name': other.name }, + }) + + expect(res.status()).toBe(403) + }) + + test('allows privileged override for admin caller', async ({ request }) => { + const primary = await createTestAgent(request) + const other = await createTestAgent(request) + cleanup.push(primary.id, other.id) + + const res = await request.get(`/api/agents/${primary.id}/attribution?privileged=1`, { + headers: { ...API_KEY_HEADER, 'x-agent-name': other.name }, + }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.access_scope).toBe('privileged') + }) + + test('validates section parameter and timeframe hours', async ({ request }) => { + const { id, name } = await createTestAgent(request) + cleanup.push(id) + + const sectionRes = await request.get(`/api/agents/${id}/attribution?section=identity&hours=48`, { + headers: { ...API_KEY_HEADER, 'x-agent-name': name }, + }) + expect(sectionRes.status()).toBe(200) + const sectionBody = await sectionRes.json() + expect(sectionBody.timeframe.hours).toBe(48) + expect(sectionBody.identity).toBeDefined() + expect(sectionBody.audit).toBeUndefined() + expect(sectionBody.mutations).toBeUndefined() + expect(sectionBody.cost).toBeUndefined() + + const invalidSection = await request.get(`/api/agents/${id}/attribution?section=unknown`, { + headers: { ...API_KEY_HEADER, 'x-agent-name': name }, + }) + expect(invalidSection.status()).toBe(400) + + const invalidHours = await request.get(`/api/agents/${id}/attribution?hours=0`, { + headers: { ...API_KEY_HEADER, 'x-agent-name': name }, + }) + expect(invalidHours.status()).toBe(400) + }) +})