nyk commited on
Commit
cec279d
Β·
unverified Β·
1 Parent(s): 5388bb8

fix(security): enforce server-side actor identity (#224)

Browse files

* fix(security): enforce server-side actor identity

* test: align message schema assertion with actor hardening

* fix(security): enforce server actor identity in chat and broadcast

* feat(auth): add scoped agent API keys with expiry and revocation

src/app/api/agents/[id]/diagnostics/route.ts CHANGED
@@ -85,7 +85,7 @@ export async function GET(
85
  }
86
 
87
  const { searchParams } = new URL(request.url);
88
- const requesterAgentName = (request.headers.get('x-agent-name') || '').trim();
89
  const privileged = searchParams.get('privileged') === '1';
90
  const isSelfRequest = (requesterAgentName || auth.user.username) === agent.name;
91
 
 
85
  }
86
 
87
  const { searchParams } = new URL(request.url);
88
+ const requesterAgentName = auth.user.agent_name?.trim() || '';
89
  const privileged = searchParams.get('privileged') === '1';
90
  const isSelfRequest = (requesterAgentName || auth.user.username) === agent.name;
91
 
src/app/api/agents/[id]/keys/route.ts ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createHash, randomBytes } from 'crypto'
2
+ import { NextRequest, NextResponse } from 'next/server'
3
+ import { requireRole } from '@/lib/auth'
4
+ import { getDatabase } from '@/lib/db'
5
+ import { logger } from '@/lib/logger'
6
+
7
+ const ALLOWED_SCOPES = new Set([
8
+ 'viewer',
9
+ 'operator',
10
+ 'admin',
11
+ 'agent:self',
12
+ 'agent:diagnostics',
13
+ 'agent:attribution',
14
+ 'agent:heartbeat',
15
+ 'agent:messages',
16
+ ])
17
+
18
+ interface AgentRow {
19
+ id: number
20
+ name: string
21
+ workspace_id: number
22
+ }
23
+
24
+ interface AgentKeyRow {
25
+ id: number
26
+ name: string
27
+ key_prefix: string
28
+ scopes: string
29
+ created_by: string | null
30
+ expires_at: number | null
31
+ revoked_at: number | null
32
+ last_used_at: number | null
33
+ created_at: number
34
+ updated_at: number
35
+ }
36
+
37
+ function hashApiKey(rawKey: string): string {
38
+ return createHash('sha256').update(rawKey).digest('hex')
39
+ }
40
+
41
+ function resolveAgent(db: ReturnType<typeof getDatabase>, idParam: string, workspaceId: number): AgentRow | null {
42
+ if (/^\d+$/.test(idParam)) {
43
+ return (db
44
+ .prepare(`SELECT id, name, workspace_id FROM agents WHERE id = ? AND workspace_id = ?`)
45
+ .get(Number(idParam), workspaceId) as AgentRow | undefined) || null
46
+ }
47
+
48
+ return (db
49
+ .prepare(`SELECT id, name, workspace_id FROM agents WHERE name = ? AND workspace_id = ?`)
50
+ .get(idParam, workspaceId) as AgentRow | undefined) || null
51
+ }
52
+
53
+ function parseScopes(rawScopes: unknown): string[] {
54
+ const fallback = ['viewer', 'agent:self']
55
+ if (!Array.isArray(rawScopes)) return fallback
56
+
57
+ const scopes = rawScopes
58
+ .map((scope) => String(scope).trim())
59
+ .filter((scope) => scope.length > 0 && ALLOWED_SCOPES.has(scope))
60
+
61
+ if (scopes.length === 0) return fallback
62
+ return Array.from(new Set(scopes))
63
+ }
64
+
65
+ function parseExpiry(body: any): number | null {
66
+ if (body?.expires_at != null) {
67
+ const value = Number(body.expires_at)
68
+ if (!Number.isInteger(value) || value <= 0) throw new Error('expires_at must be a future unix timestamp')
69
+ return value
70
+ }
71
+
72
+ if (body?.expires_in_days != null) {
73
+ const days = Number(body.expires_in_days)
74
+ if (!Number.isFinite(days) || days <= 0 || days > 3650) {
75
+ throw new Error('expires_in_days must be between 1 and 3650')
76
+ }
77
+ return Math.floor(Date.now() / 1000) + Math.floor(days * 24 * 60 * 60)
78
+ }
79
+
80
+ return null
81
+ }
82
+
83
+ export async function GET(
84
+ request: NextRequest,
85
+ { params }: { params: Promise<{ id: string }> },
86
+ ) {
87
+ const auth = requireRole(request, 'admin')
88
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
89
+
90
+ try {
91
+ const db = getDatabase()
92
+ const resolved = await params
93
+ const workspaceId = auth.user.workspace_id ?? 1
94
+ const agent = resolveAgent(db, resolved.id, workspaceId)
95
+ if (!agent) return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
96
+
97
+ const rows = db
98
+ .prepare(`
99
+ SELECT id, name, key_prefix, scopes, created_by, expires_at, revoked_at, last_used_at, created_at, updated_at
100
+ FROM agent_api_keys
101
+ WHERE agent_id = ? AND workspace_id = ?
102
+ ORDER BY created_at DESC, id DESC
103
+ `)
104
+ .all(agent.id, workspaceId) as AgentKeyRow[]
105
+
106
+ return NextResponse.json({
107
+ agent: { id: agent.id, name: agent.name },
108
+ keys: rows.map((row) => ({
109
+ ...row,
110
+ scopes: (() => {
111
+ try {
112
+ const parsed = JSON.parse(row.scopes)
113
+ return Array.isArray(parsed) ? parsed : []
114
+ } catch {
115
+ return []
116
+ }
117
+ })(),
118
+ })),
119
+ })
120
+ } catch (error) {
121
+ logger.error({ err: error }, 'GET /api/agents/[id]/keys error')
122
+ return NextResponse.json({ error: 'Failed to list agent API keys' }, { status: 500 })
123
+ }
124
+ }
125
+
126
+ export async function POST(
127
+ request: NextRequest,
128
+ { params }: { params: Promise<{ id: string }> },
129
+ ) {
130
+ const auth = requireRole(request, 'admin')
131
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
132
+
133
+ try {
134
+ const db = getDatabase()
135
+ const resolved = await params
136
+ const workspaceId = auth.user.workspace_id ?? 1
137
+ const agent = resolveAgent(db, resolved.id, workspaceId)
138
+ if (!agent) return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
139
+
140
+ const body = await request.json().catch(() => ({}))
141
+ const name = String(body?.name || 'default').trim().slice(0, 128)
142
+ if (!name) return NextResponse.json({ error: 'name is required' }, { status: 400 })
143
+
144
+ let expiresAt: number | null = null
145
+ try {
146
+ expiresAt = parseExpiry(body)
147
+ } catch (error) {
148
+ return NextResponse.json({ error: (error as Error).message }, { status: 400 })
149
+ }
150
+
151
+ const scopes = parseScopes(body?.scopes)
152
+ const now = Math.floor(Date.now() / 1000)
153
+ const rawKey = `mca_${randomBytes(24).toString('hex')}`
154
+ const keyHash = hashApiKey(rawKey)
155
+ const keyPrefix = rawKey.slice(0, 12)
156
+
157
+ const result = db
158
+ .prepare(`
159
+ INSERT INTO agent_api_keys (
160
+ agent_id, workspace_id, name, key_hash, key_prefix, scopes, expires_at, created_by, created_at, updated_at
161
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
162
+ `)
163
+ .run(
164
+ agent.id,
165
+ workspaceId,
166
+ name,
167
+ keyHash,
168
+ keyPrefix,
169
+ JSON.stringify(scopes),
170
+ expiresAt,
171
+ auth.user.username,
172
+ now,
173
+ now,
174
+ )
175
+
176
+ return NextResponse.json(
177
+ {
178
+ key: {
179
+ id: Number(result.lastInsertRowid),
180
+ name,
181
+ key_prefix: keyPrefix,
182
+ scopes,
183
+ expires_at: expiresAt,
184
+ created_at: now,
185
+ },
186
+ api_key: rawKey,
187
+ },
188
+ { status: 201 },
189
+ )
190
+ } catch (error) {
191
+ logger.error({ err: error }, 'POST /api/agents/[id]/keys error')
192
+ return NextResponse.json({ error: 'Failed to create agent API key' }, { status: 500 })
193
+ }
194
+ }
195
+
196
+ export async function DELETE(
197
+ request: NextRequest,
198
+ { params }: { params: Promise<{ id: string }> },
199
+ ) {
200
+ const auth = requireRole(request, 'admin')
201
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
202
+
203
+ try {
204
+ const db = getDatabase()
205
+ const resolved = await params
206
+ const workspaceId = auth.user.workspace_id ?? 1
207
+ const agent = resolveAgent(db, resolved.id, workspaceId)
208
+ if (!agent) return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
209
+
210
+ const body = await request.json().catch(() => ({}))
211
+ const keyId = Number(body?.key_id)
212
+ if (!Number.isInteger(keyId) || keyId <= 0) {
213
+ return NextResponse.json({ error: 'key_id must be a positive integer' }, { status: 400 })
214
+ }
215
+
216
+ const now = Math.floor(Date.now() / 1000)
217
+ const result = db
218
+ .prepare(`
219
+ UPDATE agent_api_keys
220
+ SET revoked_at = ?, updated_at = ?
221
+ WHERE id = ? AND agent_id = ? AND workspace_id = ? AND revoked_at IS NULL
222
+ `)
223
+ .run(now, now, keyId, agent.id, workspaceId)
224
+
225
+ if (result.changes < 1) {
226
+ return NextResponse.json({ error: 'Active key not found for this agent' }, { status: 404 })
227
+ }
228
+
229
+ return NextResponse.json({ success: true, key_id: keyId, revoked_at: now })
230
+ } catch (error) {
231
+ logger.error({ err: error }, 'DELETE /api/agents/[id]/keys error')
232
+ return NextResponse.json({ error: 'Failed to revoke agent API key' }, { status: 500 })
233
+ }
234
+ }
src/app/api/agents/message/route.ts CHANGED
@@ -16,7 +16,8 @@ export async function POST(request: NextRequest) {
16
  try {
17
  const result = await validateBody(request, createMessageSchema)
18
  if ('error' in result) return result.error
19
- const { from, to, message } = result.data
 
20
 
21
  const db = getDatabase()
22
  const workspaceId = auth.user.workspace_id ?? 1;
 
16
  try {
17
  const result = await validateBody(request, createMessageSchema)
18
  if ('error' in result) return result.error
19
+ const { to, message } = result.data
20
+ const from = auth.user.display_name || auth.user.username || 'system'
21
 
22
  const db = getDatabase()
23
  const workspaceId = auth.user.workspace_id ?? 1;
src/app/api/chat/messages/route.ts CHANGED
@@ -177,7 +177,8 @@ export async function GET(request: NextRequest) {
177
 
178
  /**
179
  * POST /api/chat/messages - Send a new message
180
- * Body: { from, to, content, message_type, conversation_id, metadata }
 
181
  */
182
  export async function POST(request: NextRequest) {
183
  const auth = requireRole(request, 'operator')
@@ -188,16 +189,16 @@ export async function POST(request: NextRequest) {
188
  const workspaceId = auth.user.workspace_id ?? 1
189
  const body = await request.json()
190
 
191
- const from = (body.from || '').trim()
192
  const to = body.to ? (body.to as string).trim() : null
193
  const content = (body.content || '').trim()
194
  const message_type = body.message_type || 'text'
195
  const conversation_id = body.conversation_id || `conv_${Date.now()}`
196
  const metadata = body.metadata || null
197
 
198
- if (!from || !content) {
199
  return NextResponse.json(
200
- { error: '"from" and "content" are required' },
201
  { status: 400 }
202
  )
203
  }
 
177
 
178
  /**
179
  * POST /api/chat/messages - Send a new message
180
+ * Body: { to, content, message_type, conversation_id, metadata }
181
+ * Sender identity is always resolved server-side from authenticated user.
182
  */
183
  export async function POST(request: NextRequest) {
184
  const auth = requireRole(request, 'operator')
 
189
  const workspaceId = auth.user.workspace_id ?? 1
190
  const body = await request.json()
191
 
192
+ const from = auth.user.display_name || auth.user.username || 'system'
193
  const to = body.to ? (body.to as string).trim() : null
194
  const content = (body.content || '').trim()
195
  const message_type = body.message_type || 'text'
196
  const conversation_id = body.conversation_id || `conv_${Date.now()}`
197
  const metadata = body.metadata || null
198
 
199
+ if (!content) {
200
  return NextResponse.json(
201
+ { error: '"content" is required' },
202
  { status: 400 }
203
  )
204
  }
src/app/api/tasks/[id]/broadcast/route.ts CHANGED
@@ -16,7 +16,7 @@ export async function POST(
16
  const taskId = parseInt(resolvedParams.id)
17
  const body = await request.json()
18
  const workspaceId = auth.user.workspace_id ?? 1;
19
- const author = (body.author || 'system') as string
20
  const message = (body.message || '').trim()
21
 
22
  if (isNaN(taskId)) {
 
16
  const taskId = parseInt(resolvedParams.id)
17
  const body = await request.json()
18
  const workspaceId = auth.user.workspace_id ?? 1;
19
+ const author = auth.user.display_name || auth.user.username || 'system'
20
  const message = (body.message || '').trim()
21
 
22
  if (isNaN(taskId)) {
src/app/api/tasks/[id]/comments/route.ts CHANGED
@@ -109,7 +109,8 @@ export async function POST(
109
 
110
  const result = await validateBody(request, createCommentSchema);
111
  if ('error' in result) return result.error;
112
- const { content, author = 'system', parent_id } = result.data;
 
113
 
114
  // Verify task exists
115
  const task = db
 
109
 
110
  const result = await validateBody(request, createCommentSchema);
111
  if ('error' in result) return result.error;
112
+ const { content, parent_id } = result.data;
113
+ const author = auth.user.display_name || auth.user.username || 'system';
114
 
115
  // Verify task exists
116
  const task = db
src/app/api/tasks/route.ts CHANGED
@@ -161,6 +161,7 @@ export async function POST(request: NextRequest) {
161
  const body = validated.data;
162
 
163
  const user = auth.user
 
164
  const {
165
  title,
166
  description,
@@ -168,7 +169,6 @@ export async function POST(request: NextRequest) {
168
  priority = 'medium',
169
  project_id,
170
  assigned_to,
171
- created_by = user?.username || 'system',
172
  due_date,
173
  estimated_hours,
174
  actual_hours,
@@ -231,7 +231,7 @@ export async function POST(request: NextRequest) {
231
  resolvedProjectId,
232
  row.ticket_counter,
233
  assigned_to,
234
- created_by,
235
  now,
236
  now,
237
  due_date,
@@ -254,7 +254,7 @@ export async function POST(request: NextRequest) {
254
  const taskId = createTaskTx()
255
 
256
  // Log activity
257
- db_helpers.logActivity('task_created', 'task', taskId, created_by, `Created task: ${title}`, {
258
  title,
259
  status: normalizedStatus,
260
  priority,
@@ -262,18 +262,18 @@ export async function POST(request: NextRequest) {
262
  ...(outcome ? { outcome } : {})
263
  }, workspaceId);
264
 
265
- if (created_by) {
266
- db_helpers.ensureTaskSubscription(taskId, created_by, workspaceId)
267
  }
268
 
269
  for (const recipient of mentionResolution.recipients) {
270
  db_helpers.ensureTaskSubscription(taskId, recipient, workspaceId);
271
- if (recipient === created_by) continue;
272
  db_helpers.createNotification(
273
  recipient,
274
  'mention',
275
  'You were mentioned in a task description',
276
- `${created_by} mentioned you in task "${title}"`,
277
  'task',
278
  taskId,
279
  workspaceId
 
161
  const body = validated.data;
162
 
163
  const user = auth.user
164
+ const actor = user.display_name || user.username || 'system'
165
  const {
166
  title,
167
  description,
 
169
  priority = 'medium',
170
  project_id,
171
  assigned_to,
 
172
  due_date,
173
  estimated_hours,
174
  actual_hours,
 
231
  resolvedProjectId,
232
  row.ticket_counter,
233
  assigned_to,
234
+ actor,
235
  now,
236
  now,
237
  due_date,
 
254
  const taskId = createTaskTx()
255
 
256
  // Log activity
257
+ db_helpers.logActivity('task_created', 'task', taskId, actor, `Created task: ${title}`, {
258
  title,
259
  status: normalizedStatus,
260
  priority,
 
262
  ...(outcome ? { outcome } : {})
263
  }, workspaceId);
264
 
265
+ if (actor) {
266
+ db_helpers.ensureTaskSubscription(taskId, actor, workspaceId)
267
  }
268
 
269
  for (const recipient of mentionResolution.recipients) {
270
  db_helpers.ensureTaskSubscription(taskId, recipient, workspaceId);
271
+ if (recipient === actor) continue;
272
  db_helpers.createNotification(
273
  recipient,
274
  'mention',
275
  'You were mentioned in a task description',
276
+ `${actor} mentioned you in task "${title}"`,
277
  'task',
278
  taskId,
279
  workspaceId
src/components/panels/agent-detail-tabs.tsx CHANGED
@@ -89,7 +89,6 @@ export function OverviewTab({
89
  loadingHeartbeat: boolean
90
  onPerformHeartbeat: () => Promise<void>
91
  }) {
92
- const [messageFrom, setMessageFrom] = useState('system')
93
  const [directMessage, setDirectMessage] = useState('')
94
  const [messageStatus, setMessageStatus] = useState<string | null>(null)
95
 
@@ -102,7 +101,6 @@ export function OverviewTab({
102
  method: 'POST',
103
  headers: { 'Content-Type': 'application/json' },
104
  body: JSON.stringify({
105
- from: messageFrom || 'system',
106
  to: agent.name,
107
  message: directMessage
108
  })
@@ -155,15 +153,6 @@ export function OverviewTab({
155
  <div className="text-xs text-foreground/80 mb-2">{messageStatus}</div>
156
  )}
157
  <form onSubmit={handleSendMessage} className="space-y-2">
158
- <div>
159
- <label className="block text-xs text-muted-foreground mb-1">From</label>
160
- <input
161
- type="text"
162
- value={messageFrom}
163
- onChange={(e) => setMessageFrom(e.target.value)}
164
- className="w-full bg-surface-1 text-foreground rounded px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary/50"
165
- />
166
- </div>
167
  <div>
168
  <label className="block text-xs text-muted-foreground mb-1">Message</label>
169
  <textarea
 
89
  loadingHeartbeat: boolean
90
  onPerformHeartbeat: () => Promise<void>
91
  }) {
 
92
  const [directMessage, setDirectMessage] = useState('')
93
  const [messageStatus, setMessageStatus] = useState<string | null>(null)
94
 
 
101
  method: 'POST',
102
  headers: { 'Content-Type': 'application/json' },
103
  body: JSON.stringify({
 
104
  to: agent.name,
105
  message: directMessage
106
  })
 
153
  <div className="text-xs text-foreground/80 mb-2">{messageStatus}</div>
154
  )}
155
  <form onSubmit={handleSendMessage} className="space-y-2">
 
 
 
 
 
 
 
 
 
156
  <div>
157
  <label className="block text-xs text-muted-foreground mb-1">Message</label>
158
  <textarea
src/lib/__tests__/validation.test.ts CHANGED
@@ -242,7 +242,8 @@ describe('createMessageSchema', () => {
242
  })
243
  expect(result.success).toBe(true)
244
  if (result.success) {
245
- expect(result.data.from).toBe('system')
 
246
  }
247
  })
248
 
 
242
  })
243
  expect(result.success).toBe(true)
244
  if (result.success) {
245
+ expect(result.data.to).toBe('bob')
246
+ expect(result.data.message).toBe('Hello')
247
  }
248
  })
249
 
src/lib/auth.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { randomBytes, timingSafeEqual } from 'crypto'
2
  import { getDatabase } from './db'
3
  import { hashPassword, verifyPassword } from './password'
4
 
@@ -33,6 +33,12 @@ export interface User {
33
  last_login_at: number | null
34
  /** Agent name when request is made on behalf of a specific agent (via X-Agent-Name header) */
35
  agent_name?: string | null
 
 
 
 
 
 
36
  }
37
 
38
  export interface UserSession {
@@ -78,6 +84,18 @@ interface UserQueryRow {
78
  password_hash: string
79
  }
80
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  // Session management
82
  const SESSION_DURATION = 7 * 24 * 60 * 60 // 7 days in seconds
83
 
@@ -278,13 +296,19 @@ export function getUserFromRequest(request: Request): User | null {
278
  const sessionToken = parseCookie(cookieHeader, 'mc-session')
279
  if (sessionToken) {
280
  const user = validateSession(sessionToken)
281
- if (user) return { ...user, agent_name: agentName }
282
  }
283
 
284
- // Check API key - return synthetic user
285
- const configuredApiKey = (process.env.API_KEY || '').trim()
286
  const apiKey = extractApiKeyFromHeaders(request.headers)
287
- if (configuredApiKey && apiKey && safeCompare(apiKey, configuredApiKey)) {
 
 
 
 
 
 
 
 
288
  return {
289
  id: 0,
290
  username: 'api',
@@ -295,12 +319,85 @@ export function getUserFromRequest(request: Request): User | null {
295
  updated_at: 0,
296
  last_login_at: null,
297
  agent_name: agentName,
 
 
 
298
  }
299
  }
300
 
301
  return null
302
  }
303
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
  function extractApiKeyFromHeaders(headers: Headers): string | null {
305
  const direct = (headers.get('x-api-key') || '').trim()
306
  if (direct) return direct
 
1
+ import { createHash, randomBytes, timingSafeEqual } from 'crypto'
2
  import { getDatabase } from './db'
3
  import { hashPassword, verifyPassword } from './password'
4
 
 
33
  last_login_at: number | null
34
  /** Agent name when request is made on behalf of a specific agent (via X-Agent-Name header) */
35
  agent_name?: string | null
36
+ /** Auth principal kind used for this request */
37
+ principal_type?: 'user' | 'system_api_key' | 'agent_api_key'
38
+ /** Agent id when authenticated via dedicated agent API key */
39
+ agent_id?: number | null
40
+ /** Scopes resolved for API key principals */
41
+ auth_scopes?: string[] | null
42
  }
43
 
44
  export interface UserSession {
 
84
  password_hash: string
85
  }
86
 
87
+ interface AgentApiKeyRow {
88
+ id: number
89
+ agent_id: number
90
+ workspace_id: number
91
+ name: string
92
+ scopes: string
93
+ expires_at: number | null
94
+ revoked_at: number | null
95
+ key_hash: string
96
+ agent_name: string
97
+ }
98
+
99
  // Session management
100
  const SESSION_DURATION = 7 * 24 * 60 * 60 // 7 days in seconds
101
 
 
296
  const sessionToken = parseCookie(cookieHeader, 'mc-session')
297
  if (sessionToken) {
298
  const user = validateSession(sessionToken)
299
+ if (user) return { ...user, agent_name: agentName, principal_type: 'user', auth_scopes: null, agent_id: null }
300
  }
301
 
 
 
302
  const apiKey = extractApiKeyFromHeaders(request.headers)
303
+ if (!apiKey) return null
304
+
305
+ // Check dedicated agent API key first.
306
+ const agentPrincipal = validateAgentApiKey(apiKey)
307
+ if (agentPrincipal) return agentPrincipal
308
+
309
+ // Check system API key - return synthetic user.
310
+ const configuredApiKey = (process.env.API_KEY || '').trim()
311
+ if (configuredApiKey && safeCompare(apiKey, configuredApiKey)) {
312
  return {
313
  id: 0,
314
  username: 'api',
 
319
  updated_at: 0,
320
  last_login_at: null,
321
  agent_name: agentName,
322
+ principal_type: 'system_api_key',
323
+ auth_scopes: ['admin'],
324
+ agent_id: null,
325
  }
326
  }
327
 
328
  return null
329
  }
330
 
331
+ function hashApiKey(rawKey: string): string {
332
+ return createHash('sha256').update(rawKey).digest('hex')
333
+ }
334
+
335
+ function toRoleFromScopes(scopes: string[]): User['role'] {
336
+ if (scopes.includes('admin')) return 'admin'
337
+ if (scopes.includes('operator')) return 'operator'
338
+ return 'viewer'
339
+ }
340
+
341
+ function parseScopes(raw: string): string[] {
342
+ try {
343
+ const parsed = JSON.parse(raw)
344
+ if (!Array.isArray(parsed)) return ['viewer']
345
+ const scopes = parsed.map((value) => String(value).trim()).filter(Boolean)
346
+ return scopes.length > 0 ? scopes : ['viewer']
347
+ } catch {
348
+ return ['viewer']
349
+ }
350
+ }
351
+
352
+ function validateAgentApiKey(rawApiKey: string): User | null {
353
+ try {
354
+ const db = getDatabase()
355
+ const now = Math.floor(Date.now() / 1000)
356
+ const keyHash = hashApiKey(rawApiKey)
357
+
358
+ const row = db.prepare(`
359
+ SELECT k.id, k.agent_id, k.workspace_id, k.name, k.scopes, k.expires_at, k.revoked_at, k.key_hash, a.name as agent_name
360
+ FROM agent_api_keys k
361
+ JOIN agents a ON a.id = k.agent_id
362
+ WHERE k.key_hash = ?
363
+ AND k.revoked_at IS NULL
364
+ AND (k.expires_at IS NULL OR k.expires_at > ?)
365
+ AND a.workspace_id = k.workspace_id
366
+ LIMIT 1
367
+ `).get(keyHash, now) as AgentApiKeyRow | undefined
368
+
369
+ if (!row) return null
370
+ if (!safeCompare(keyHash, row.key_hash)) return null
371
+
372
+ const scopes = parseScopes(row.scopes)
373
+ const role = toRoleFromScopes(scopes)
374
+
375
+ // Authentication should not fail if best-effort key usage bookkeeping hits a transient lock.
376
+ try {
377
+ db.prepare(`UPDATE agent_api_keys SET last_used_at = ?, updated_at = ? WHERE id = ?`).run(now, now, row.id)
378
+ } catch {
379
+ // no-op
380
+ }
381
+
382
+ return {
383
+ id: row.agent_id,
384
+ username: row.agent_name,
385
+ display_name: row.agent_name,
386
+ role,
387
+ workspace_id: row.workspace_id,
388
+ created_at: 0,
389
+ updated_at: 0,
390
+ last_login_at: null,
391
+ agent_name: row.agent_name,
392
+ principal_type: 'agent_api_key',
393
+ auth_scopes: scopes,
394
+ agent_id: row.agent_id,
395
+ }
396
+ } catch {
397
+ return null
398
+ }
399
+ }
400
+
401
  function extractApiKeyFromHeaders(headers: Headers): string | null {
402
  const direct = (headers.get('x-api-key') || '').trim()
403
  if (direct) return direct
src/lib/config.ts CHANGED
@@ -2,7 +2,11 @@ import fs from 'node:fs'
2
  import os from 'node:os'
3
  import path from 'node:path'
4
 
5
- const defaultDataDir = path.join(process.cwd(), '.data')
 
 
 
 
6
  const defaultOpenClawStateDir = path.join(os.homedir(), '.openclaw')
7
  const explicitOpenClawConfigPath =
8
  process.env.OPENCLAW_CONFIG_PATH ||
 
2
  import os from 'node:os'
3
  import path from 'node:path'
4
 
5
+ const runtimeCwd = process.cwd()
6
+ const normalizedCwd = runtimeCwd.endsWith(path.join('.next', 'standalone'))
7
+ ? path.resolve(runtimeCwd, '..', '..')
8
+ : runtimeCwd
9
+ const defaultDataDir = path.join(normalizedCwd, '.data')
10
  const defaultOpenClawStateDir = path.join(os.homedir(), '.openclaw')
11
  const explicitOpenClawConfigPath =
12
  process.env.OPENCLAW_CONFIG_PATH ||
src/lib/migrations.ts CHANGED
@@ -797,6 +797,39 @@ const migrations: Migration[] = [
797
  db.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_completed_at ON tasks(completed_at)`)
798
  db.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_workspace_outcome ON tasks(workspace_id, outcome, completed_at)`)
799
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
800
  }
801
  ]
802
 
 
797
  db.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_completed_at ON tasks(completed_at)`)
798
  db.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_workspace_outcome ON tasks(workspace_id, outcome, completed_at)`)
799
  }
800
+ },
801
+ {
802
+ id: '027_agent_api_keys',
803
+ up: (db) => {
804
+ const hasAgents = db
805
+ .prepare(`SELECT 1 as ok FROM sqlite_master WHERE type = 'table' AND name = 'agents'`)
806
+ .get() as { ok?: number } | undefined
807
+ if (!hasAgents?.ok) return
808
+
809
+ db.exec(`
810
+ CREATE TABLE IF NOT EXISTS agent_api_keys (
811
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
812
+ agent_id INTEGER NOT NULL,
813
+ workspace_id INTEGER NOT NULL DEFAULT 1,
814
+ name TEXT NOT NULL,
815
+ key_hash TEXT NOT NULL UNIQUE,
816
+ key_prefix TEXT NOT NULL,
817
+ scopes TEXT NOT NULL DEFAULT '["viewer"]',
818
+ expires_at INTEGER,
819
+ revoked_at INTEGER,
820
+ created_by TEXT,
821
+ last_used_at INTEGER,
822
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
823
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
824
+ FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE
825
+ );
826
+ `)
827
+
828
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_agent_api_keys_agent_id ON agent_api_keys(agent_id)`)
829
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_agent_api_keys_workspace_id ON agent_api_keys(workspace_id)`)
830
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_agent_api_keys_expires_at ON agent_api_keys(expires_at)`)
831
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_agent_api_keys_revoked_at ON agent_api_keys(revoked_at)`)
832
+ }
833
  }
834
  ]
835
 
src/lib/validation.ts CHANGED
@@ -33,7 +33,6 @@ export const createTaskSchema = z.object({
33
  priority: z.enum(['critical', 'high', 'medium', 'low']).default('medium'),
34
  project_id: z.number().int().positive().optional(),
35
  assigned_to: z.string().max(100).optional(),
36
- created_by: z.string().max(100).optional(),
37
  due_date: z.number().optional(),
38
  estimated_hours: z.number().min(0).optional(),
39
  actual_hours: z.number().min(0).optional(),
@@ -124,14 +123,12 @@ export const createWorkflowSchema = z.object({
124
  export const createCommentSchema = z.object({
125
  task_id: z.number().optional(),
126
  content: z.string().min(1, 'Comment content is required'),
127
- author: z.string().optional(),
128
  parent_id: z.number().optional(),
129
  })
130
 
131
  export const createMessageSchema = z.object({
132
  to: z.string().min(1, 'Recipient is required'),
133
  message: z.string().min(1, 'Message is required'),
134
- from: z.string().optional().default('system'),
135
  })
136
 
137
  export const updateSettingsSchema = z.object({
 
33
  priority: z.enum(['critical', 'high', 'medium', 'low']).default('medium'),
34
  project_id: z.number().int().positive().optional(),
35
  assigned_to: z.string().max(100).optional(),
 
36
  due_date: z.number().optional(),
37
  estimated_hours: z.number().min(0).optional(),
38
  actual_hours: z.number().min(0).optional(),
 
123
  export const createCommentSchema = z.object({
124
  task_id: z.number().optional(),
125
  content: z.string().min(1, 'Comment content is required'),
 
126
  parent_id: z.number().optional(),
127
  })
128
 
129
  export const createMessageSchema = z.object({
130
  to: z.string().min(1, 'Recipient is required'),
131
  message: z.string().min(1, 'Message is required'),
 
132
  })
133
 
134
  export const updateSettingsSchema = z.object({
src/proxy.ts CHANGED
@@ -117,7 +117,10 @@ export function proxy(request: NextRequest) {
117
  const configuredApiKey = (process.env.API_KEY || '').trim()
118
  const apiKey = extractApiKeyFromRequest(request)
119
  const hasValidApiKey = Boolean(configuredApiKey && apiKey && safeCompare(apiKey, configuredApiKey))
120
- if (sessionToken || hasValidApiKey) {
 
 
 
121
  return applySecurityHeaders(NextResponse.next())
122
  }
123
 
 
117
  const configuredApiKey = (process.env.API_KEY || '').trim()
118
  const apiKey = extractApiKeyFromRequest(request)
119
  const hasValidApiKey = Boolean(configuredApiKey && apiKey && safeCompare(apiKey, configuredApiKey))
120
+ // Dedicated agent API keys are validated in route auth against DB.
121
+ // Proxy only permits them to pass through to avoid coupling middleware to DB access.
122
+ const hasAgentApiKeyCandidate = apiKey.startsWith('mca_')
123
+ if (sessionToken || hasValidApiKey || hasAgentApiKeyCandidate) {
124
  return applySecurityHeaders(NextResponse.next())
125
  }
126
 
tests/actor-identity-hardening.spec.ts ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { test, expect } from '@playwright/test'
2
+ import { API_KEY_HEADER, createTestAgent, createTestTask, deleteTestAgent, deleteTestTask } from './helpers'
3
+
4
+ test.describe('Actor Identity Hardening', () => {
5
+ const taskCleanup: number[] = []
6
+ const agentCleanup: number[] = []
7
+
8
+ test.afterEach(async ({ request }) => {
9
+ while (taskCleanup.length > 0) {
10
+ const id = taskCleanup.pop()!
11
+ await deleteTestTask(request, id)
12
+ }
13
+ while (agentCleanup.length > 0) {
14
+ const id = agentCleanup.pop()!
15
+ await deleteTestAgent(request, id)
16
+ }
17
+ })
18
+
19
+ test('POST /api/chat/messages ignores client-supplied from and uses authenticated actor', async ({ request }) => {
20
+ const res = await request.post('/api/chat/messages', {
21
+ headers: API_KEY_HEADER,
22
+ data: {
23
+ from: 'spoofed-user',
24
+ content: 'identity hardening check',
25
+ conversation_id: `identity-check-${Date.now()}`,
26
+ },
27
+ })
28
+
29
+ expect(res.status()).toBe(201)
30
+ const body = await res.json()
31
+ expect(body.message.from_agent).toBe('API Access')
32
+ expect(body.message.from_agent).not.toBe('spoofed-user')
33
+ })
34
+
35
+ test('POST /api/tasks/[id]/broadcast ignores client-supplied author', async ({ request }) => {
36
+ const { id: taskId } = await createTestTask(request)
37
+ taskCleanup.push(taskId)
38
+
39
+ const { id: agentId, name: agentName } = await createTestAgent(request)
40
+ agentCleanup.push(agentId)
41
+
42
+ const commentRes = await request.post(`/api/tasks/${taskId}/comments`, {
43
+ headers: API_KEY_HEADER,
44
+ data: { content: `Mentioning @${agentName} for subscription` },
45
+ })
46
+ expect(commentRes.status()).toBe(201)
47
+
48
+ const broadcastRes = await request.post(`/api/tasks/${taskId}/broadcast`, {
49
+ headers: API_KEY_HEADER,
50
+ data: {
51
+ author: agentName,
52
+ message: 'hardening broadcast test',
53
+ },
54
+ })
55
+
56
+ expect(broadcastRes.status()).toBe(200)
57
+ const body = await broadcastRes.json()
58
+ expect(body.sent + body.skipped).toBe(1)
59
+ expect(body.skipped).toBe(1)
60
+ })
61
+ })
tests/agent-api-keys.spec.ts ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from '@playwright/test'
2
+ import { API_KEY_HEADER, createTestAgent, deleteTestAgent } from './helpers'
3
+
4
+ test.describe('Agent API keys', () => {
5
+ const cleanup: number[] = []
6
+
7
+ test.afterEach(async ({ request }) => {
8
+ for (const id of cleanup.splice(0)) {
9
+ await deleteTestAgent(request, id).catch(() => {})
10
+ }
11
+ })
12
+
13
+ test('supports scoped agent auth without x-agent-name and allows revoke', async ({ request }) => {
14
+ const primary = await createTestAgent(request)
15
+ const other = await createTestAgent(request)
16
+ cleanup.push(primary.id, other.id)
17
+
18
+ const createKeyRes = await request.post(`/api/agents/${primary.id}/keys`, {
19
+ headers: API_KEY_HEADER,
20
+ data: {
21
+ name: 'diag-key',
22
+ scopes: ['viewer', 'agent:self', 'agent:diagnostics'],
23
+ expires_in_days: 1,
24
+ },
25
+ })
26
+ expect(createKeyRes.status()).toBe(201)
27
+ const createKeyBody = await createKeyRes.json()
28
+ expect(createKeyBody.api_key).toMatch(/^mca_/)
29
+
30
+ const agentKeyHeader = { 'x-api-key': createKeyBody.api_key as string }
31
+
32
+ const selfRes = await request.get(`/api/agents/${primary.id}/diagnostics?section=summary`, {
33
+ headers: agentKeyHeader,
34
+ })
35
+ expect(selfRes.status()).toBe(200)
36
+
37
+ const crossRes = await request.get(`/api/agents/${other.id}/diagnostics?section=summary`, {
38
+ headers: agentKeyHeader,
39
+ })
40
+ expect(crossRes.status()).toBe(403)
41
+
42
+ const listRes = await request.get(`/api/agents/${primary.id}/keys`, { headers: API_KEY_HEADER })
43
+ expect(listRes.status()).toBe(200)
44
+ const listBody = await listRes.json()
45
+ const storedKey = listBody.keys.find((entry: any) => entry.id === createKeyBody.key.id)
46
+ expect(storedKey).toBeDefined()
47
+ expect(storedKey.key_prefix).toBe(createKeyBody.key.key_prefix)
48
+
49
+ const revokeRes = await request.delete(`/api/agents/${primary.id}/keys`, {
50
+ headers: API_KEY_HEADER,
51
+ data: { key_id: createKeyBody.key.id },
52
+ })
53
+ expect(revokeRes.status()).toBe(200)
54
+
55
+ const afterRevoke = await request.get(`/api/agents/${primary.id}/diagnostics?section=summary`, {
56
+ headers: agentKeyHeader,
57
+ })
58
+ expect(afterRevoke.status()).toBe(401)
59
+ })
60
+
61
+ test('rejects expired agent keys', async ({ request }) => {
62
+ const primary = await createTestAgent(request)
63
+ cleanup.push(primary.id)
64
+
65
+ const createKeyRes = await request.post(`/api/agents/${primary.id}/keys`, {
66
+ headers: API_KEY_HEADER,
67
+ data: {
68
+ name: 'expired-key',
69
+ scopes: ['viewer', 'agent:self'],
70
+ expires_at: Math.floor(Date.now() / 1000) - 5,
71
+ },
72
+ })
73
+ expect(createKeyRes.status()).toBe(201)
74
+
75
+ const { api_key } = await createKeyRes.json()
76
+
77
+ const expiredRes = await request.get(`/api/agents/${primary.id}/attribution?section=identity`, {
78
+ headers: { 'x-api-key': api_key },
79
+ })
80
+ expect(expiredRes.status()).toBe(401)
81
+ })
82
+ })
tests/task-comments.spec.ts CHANGED
@@ -69,6 +69,21 @@ test.describe('Task Comments', () => {
69
  expect(replyBody.comment.parent_id).toBe(parentId)
70
  })
71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  // ── GET /api/tasks/[id]/comments ─────────────
73
 
74
  test('GET returns comments array for task', async ({ request }) => {
 
69
  expect(replyBody.comment.parent_id).toBe(parentId)
70
  })
71
 
72
+ test('POST ignores client-supplied author and uses authenticated actor', async ({ request }) => {
73
+ const { id } = await createTestTask(request)
74
+ cleanup.push(id)
75
+
76
+ const res = await request.post(`/api/tasks/${id}/comments`, {
77
+ headers: API_KEY_HEADER,
78
+ data: { content: 'Author spoof check', author: 'spoofed-author' },
79
+ })
80
+
81
+ expect(res.status()).toBe(201)
82
+ const body = await res.json()
83
+ expect(body.comment.author).not.toBe('spoofed-author')
84
+ expect(body.comment.author).toBe('API Access')
85
+ })
86
+
87
  // ── GET /api/tasks/[id]/comments ─────────────
88
 
89
  test('GET returns comments array for task', async ({ request }) => {
tests/tasks-crud.spec.ts CHANGED
@@ -44,6 +44,23 @@ test.describe('Tasks CRUD', () => {
44
  expect(body.task.metadata).toEqual({ source: 'e2e' })
45
  })
46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  test('POST rejects empty title', async ({ request }) => {
48
  const res = await request.post('/api/tasks', {
49
  headers: API_KEY_HEADER,
 
44
  expect(body.task.metadata).toEqual({ source: 'e2e' })
45
  })
46
 
47
+ test('POST ignores client-supplied created_by and uses authenticated actor', async ({ request }) => {
48
+ const title = `e2e-task-actor-${Date.now()}`
49
+ const res = await request.post('/api/tasks', {
50
+ headers: API_KEY_HEADER,
51
+ data: {
52
+ title,
53
+ created_by: 'spoofed-agent',
54
+ },
55
+ })
56
+ expect(res.status()).toBe(201)
57
+ const body = await res.json()
58
+ const id = Number(body.task.id)
59
+ cleanup.push(id)
60
+ expect(body.task.created_by).not.toBe('spoofed-agent')
61
+ expect(body.task.created_by).toBe('API Access')
62
+ })
63
+
64
  test('POST rejects empty title', async ({ request }) => {
65
  const res = await request.post('/api/tasks', {
66
  headers: API_KEY_HEADER,