Skip to content

Commit a7832e7

Browse files
waydelyleclaude
andcommitted
feat: governance & safety — anomaly persistence, auto-suspension, escalation, trust boost
Anomaly detection: - New anomaly_events table to persist detection results - Auto-suspend agents on high-severity anomalies (rapid bidding, rating manipulation) - suspendAgent()/unsuspendAgent() with audit trail and SSE notification Admin endpoints: - GET /admin/anomalies — list anomaly events with filters - GET /admin/agents/:id/risk — aggregate risk profile - POST /admin/agents/:id/unsuspend — lift suspension Auth guard: - Specific 403 message for suspended agents - getAgentStatus() replaces isAgentActive() for richer status handling Human escalation: - Disputes >$100 or repeated tribunal failures auto-escalate - Webhook + email (via Resend) notification service - Escalation env vars: ESCALATION_WEBHOOK_URL, ESCALATION_EMAIL, RESEND_API_KEY Owner verification trust boost: - Verified owners (ownerDid set) get L1 with no tasks, L2 with 1+ task - verify-owner endpoint now recalculates and returns trust level Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 432a9bd commit a7832e7

9 files changed

Lines changed: 301 additions & 24 deletions

File tree

.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,14 @@ CORS_ORIGINS=http://localhost:3200
8686
# Admin API key for privileged operations
8787
ADMIN_API_KEY=
8888

89+
# ──────────────────────────────────────────────
90+
# Escalation Notifications (optional)
91+
# Triggered for disputes >$100 or repeated tribunal failures
92+
# ──────────────────────────────────────────────
93+
# ESCALATION_WEBHOOK_URL=https://hooks.slack.com/services/...
94+
# ESCALATION_EMAIL=admin@swarmdock.ai
95+
# RESEND_API_KEY=
96+
8997
# ──────────────────────────────────────────────
9098
# LLM Judge (quality verification — optional)
9199
# ──────────────────────────────────────────────

packages/api/src/db/schema.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,24 @@ export const agentWallets = pgTable('agent_wallets', {
340340
uniqueIndex('agent_wallet_unique').on(table.agentId),
341341
]);
342342

343+
// ============================================
344+
// ANOMALY EVENTS (governance detection results)
345+
// ============================================
346+
347+
export const anomalyEvents = pgTable('anomaly_events', {
348+
id: uuid('id').primaryKey().defaultRandom(),
349+
agentId: uuid('agent_id').references(() => agents.id, { onDelete: 'cascade' }).notNull(),
350+
type: text('type').notNull(), // rapid_bidding, rating_manipulation, dormancy_evasion
351+
severity: text('severity').notNull(), // low, medium, high
352+
details: text('details').notNull(),
353+
actionTaken: text('action_taken').default('none').notNull(), // none, warned, suspended
354+
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
355+
}, (table) => [
356+
index('idx_anomaly_events_agent').on(table.agentId),
357+
index('idx_anomaly_events_type').on(table.type),
358+
index('idx_anomaly_events_severity').on(table.severity),
359+
]);
360+
343361
// ============================================
344362
// CHALLENGES (auth challenge-response)
345363
// ============================================

packages/api/src/lib/notify.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* Notification service for escalated disputes and critical governance events.
3+
* Supports webhook (primary) and email via Resend (optional).
4+
*/
5+
6+
export interface EscalationPayload {
7+
disputeId: string;
8+
taskId: string;
9+
amount: string;
10+
reason: string;
11+
raisedBy: string;
12+
against: string | null;
13+
}
14+
15+
/**
16+
* Send escalation notification via configured channels.
17+
* Fails silently — escalation persistence is handled by the caller.
18+
*/
19+
export async function sendEscalationNotification(payload: EscalationPayload): Promise<void> {
20+
const webhookUrl = process.env.ESCALATION_WEBHOOK_URL;
21+
const email = process.env.ESCALATION_EMAIL;
22+
const resendKey = process.env.RESEND_API_KEY;
23+
24+
if (webhookUrl) {
25+
try {
26+
await fetch(webhookUrl, {
27+
method: 'POST',
28+
headers: { 'content-type': 'application/json' },
29+
body: JSON.stringify({
30+
event: 'dispute.escalated',
31+
...payload,
32+
timestamp: new Date().toISOString(),
33+
}),
34+
});
35+
console.log(`[NOTIFY] Escalation webhook sent for dispute ${payload.disputeId}`);
36+
} catch (err) {
37+
console.error('[NOTIFY] Escalation webhook failed:', err);
38+
}
39+
}
40+
41+
if (email && resendKey) {
42+
try {
43+
await fetch('https://api.resend.com/emails', {
44+
method: 'POST',
45+
headers: {
46+
'content-type': 'application/json',
47+
authorization: `Bearer ${resendKey}`,
48+
},
49+
body: JSON.stringify({
50+
from: 'SwarmDock <noreply@swarmdock.ai>',
51+
to: email,
52+
subject: `[SwarmDock] Dispute escalated — $${(parseInt(payload.amount) / 1_000_000).toFixed(2)} USDC`,
53+
text: [
54+
`Dispute ${payload.disputeId} has been escalated for manual review.`,
55+
'',
56+
`Task: ${payload.taskId}`,
57+
`Amount: $${(parseInt(payload.amount) / 1_000_000).toFixed(2)} USDC`,
58+
`Reason: ${payload.reason}`,
59+
`Raised by: ${payload.raisedBy}`,
60+
`Against: ${payload.against ?? 'N/A'}`,
61+
'',
62+
'Review at: https://swarmdock-api.onrender.com/api/v1/admin/disputes',
63+
].join('\n'),
64+
}),
65+
});
66+
console.log(`[NOTIFY] Escalation email sent for dispute ${payload.disputeId}`);
67+
} catch (err) {
68+
console.error('[NOTIFY] Escalation email failed:', err);
69+
}
70+
}
71+
72+
if (!webhookUrl && !(email && resendKey)) {
73+
console.warn(`[NOTIFY] No escalation channels configured for dispute ${payload.disputeId}`);
74+
}
75+
}

packages/api/src/middleware/auth.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,10 @@ import type { Scope, AATPayload } from '@swarmdock/shared';
99

1010
const AGENT_STATUS_CACHE_TTL = 60; // seconds
1111

12-
async function isAgentActive(agentId: string): Promise<boolean> {
12+
async function getAgentStatus(agentId: string): Promise<string> {
1313
const cacheKey = `agent:status:${agentId}`;
1414
const cached = await redisGet(cacheKey);
15-
if (cached !== null) {
16-
return cached === 'active';
17-
}
15+
if (cached !== null) return cached;
1816

1917
const [agent] = await db
2018
.select({ status: agents.status })
@@ -24,8 +22,7 @@ async function isAgentActive(agentId: string): Promise<boolean> {
2422

2523
const status = agent?.status ?? 'unknown';
2624
await redisSet(cacheKey, status, AGENT_STATUS_CACHE_TTL);
27-
28-
return status === 'active';
25+
return status;
2926
}
3027

3128
export type AuthContext = {
@@ -47,9 +44,12 @@ export const authMiddleware = createMiddleware<AuthContext>(async (c, next) => {
4744
const payload = await verifyAAT(token);
4845

4946
// Verify agent is still active (Redis-cached, 60s TTL)
50-
const active = await isAgentActive(payload.agent_id);
51-
if (!active) {
52-
throw new HTTPException(403, { message: 'Agent account is suspended or deregistered' });
47+
const status = await getAgentStatus(payload.agent_id);
48+
if (status === 'suspended') {
49+
throw new HTTPException(403, { message: 'Account suspended — contact admin for review' });
50+
}
51+
if (status !== 'active') {
52+
throw new HTTPException(403, { message: 'Agent account is not active' });
5353
}
5454

5555
c.set('agent', payload);
@@ -68,8 +68,8 @@ export const optionalAuthMiddleware = createMiddleware<AuthContext>(async (c, ne
6868
try {
6969
const token = authHeader.slice(7);
7070
const payload = await verifyAAT(token);
71-
const active = await isAgentActive(payload.agent_id);
72-
if (active) {
71+
const status = await getAgentStatus(payload.agent_id);
72+
if (status === 'active') {
7373
c.set('agent', payload);
7474
}
7575
} catch {

packages/api/src/routes/admin.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Hono } from 'hono';
22
import { db } from '../db/client.js';
3-
import { agents, tasks, escrowTransactions, transactions, agentRatings, disputes } from '../db/schema.js';
3+
import { agents, tasks, escrowTransactions, transactions, agentRatings, disputes, anomalyEvents, agentReputation } from '../db/schema.js';
44
import { eq, sql, count, desc, and } from 'drizzle-orm';
55
import { createMiddleware } from 'hono/factory';
66
import { HTTPException } from 'hono/http-exception';
@@ -16,6 +16,7 @@ import {
1616
} from '@swarmdock/shared';
1717
import { releaseEscrow, refundEscrow } from '../services/escrow.js';
1818
import { selectTribunalJudges } from '../services/tribunal.js';
19+
import { unsuspendAgent } from '../services/anomaly.js';
1920
import { eventBus } from '../lib/events.js';
2021

2122
const adminAuth = createMiddleware(async (c, next) => {
@@ -262,4 +263,88 @@ app.post('/disputes/:id/resolve', adminAuth, async (c) => {
262263
});
263264
});
264265

266+
// GET /api/v1/admin/anomalies — List anomaly events
267+
app.get('/anomalies', adminAuth, async (c) => {
268+
const type = c.req.query('type');
269+
const severity = c.req.query('severity');
270+
const agentId = c.req.query('agentId');
271+
const limit = Math.max(1, Math.min(parseInt(c.req.query('limit') ?? '50', 10) || 50, 200));
272+
const offset = Math.max(0, parseInt(c.req.query('offset') ?? '0', 10) || 0);
273+
274+
const conditions = [];
275+
if (type) conditions.push(eq(anomalyEvents.type, type));
276+
if (severity) conditions.push(eq(anomalyEvents.severity, severity));
277+
if (agentId) conditions.push(eq(anomalyEvents.agentId, agentId));
278+
279+
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
280+
281+
const rows = await db
282+
.select()
283+
.from(anomalyEvents)
284+
.where(whereClause)
285+
.orderBy(desc(anomalyEvents.createdAt))
286+
.limit(limit)
287+
.offset(offset);
288+
289+
const [{ total }] = await db.select({ total: count() }).from(anomalyEvents).where(whereClause);
290+
291+
return c.json({ anomalies: rows, limit, offset, total });
292+
});
293+
294+
// GET /api/v1/admin/agents/:id/risk — Agent risk profile
295+
app.get('/agents/:id/risk', adminAuth, async (c) => {
296+
const id = c.req.param('id');
297+
298+
const [agent] = await db
299+
.select({ id: agents.id, displayName: agents.displayName, status: agents.status, trustLevel: agents.trustLevel })
300+
.from(agents)
301+
.where(eq(agents.id, id))
302+
.limit(1);
303+
304+
if (!agent) return c.json({ error: 'Agent not found' }, 404);
305+
306+
const anomalies = await db
307+
.select()
308+
.from(anomalyEvents)
309+
.where(eq(anomalyEvents.agentId, id))
310+
.orderBy(desc(anomalyEvents.createdAt))
311+
.limit(20);
312+
313+
const reputation = await db
314+
.select()
315+
.from(agentReputation)
316+
.where(eq(agentReputation.agentId, id));
317+
318+
const highCount = anomalies.filter((a) => a.severity === 'high').length;
319+
const mediumCount = anomalies.filter((a) => a.severity === 'medium').length;
320+
321+
return c.json({
322+
agent: { id: agent.id, displayName: agent.displayName, status: agent.status, trustLevel: agent.trustLevel },
323+
anomalySummary: { total: anomalies.length, high: highCount, medium: mediumCount },
324+
recentAnomalies: anomalies,
325+
reputation,
326+
});
327+
});
328+
329+
// POST /api/v1/admin/agents/:id/unsuspend — Lift suspension
330+
app.post('/agents/:id/unsuspend', adminAuth, async (c) => {
331+
const id = c.req.param('id');
332+
333+
const [agent] = await db
334+
.select({ status: agents.status })
335+
.from(agents)
336+
.where(eq(agents.id, id))
337+
.limit(1);
338+
339+
if (!agent) return c.json({ error: 'Agent not found' }, 404);
340+
if (agent.status !== AGENT_STATUS.SUSPENDED) {
341+
return c.json({ error: 'Agent is not suspended' }, 400);
342+
}
343+
344+
const body = await c.req.json().catch(() => ({})) as { note?: string };
345+
await unsuspendAgent(id, body.note ?? 'Admin unsuspend');
346+
347+
return c.json({ unsuspended: true, agentId: id });
348+
});
349+
265350
export default app;

packages/api/src/routes/agents.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { eventBus } from '../lib/events.js';
2323
import { getRatingsSummary } from '../services/ratings.js';
2424
import { provisionAgentWallet } from '../services/wallet.js';
2525
import { getAgentCardById } from '../services/agent-card.js';
26+
import { updateTrustLevel } from '../services/reputation.js';
2627
import { getAgentPortfolio, createPortfolioItem, updatePortfolioItem, deletePortfolioItem } from '../services/portfolio.js';
2728
import { fetchOrderedRowsByIds, searchAgentsIndex } from '../services/search.js';
2829

@@ -775,7 +776,10 @@ app.post('/:id/verify-owner', authMiddleware, async (c) => {
775776
updatedAt: new Date(),
776777
}).where(eq(agents.id, id));
777778

778-
return c.json({ verified: true, ownerDid });
779+
// Recalculate trust level with owner verification boost
780+
const newTrustLevel = await updateTrustLevel(id);
781+
782+
return c.json({ verified: true, ownerDid, trustLevel: newTrustLevel });
779783
});
780784

781785
export default app;

packages/api/src/routes/tasks.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import {
1313
import { eventBus } from '../lib/events.js';
1414
import { releaseEscrow, refundEscrow } from '../services/escrow.js';
1515
import { verifyTaskOutput } from '../services/quality.js';
16+
import { shouldEscalate } from '../services/tribunal.js';
17+
import { sendEscalationNotification } from '../lib/notify.js';
1618
import { safeAppendAuditLog } from '../services/audit.js';
1719
import { embed } from '../services/embeddings.js';
1820
import { persistTaskSubmission } from '../services/storage.js';
@@ -840,6 +842,20 @@ app.post('/:id/dispute', authMiddleware, async (c) => {
840842
payload: { disputeId: dispute.id, reason: parsed.data.reason },
841843
});
842844

845+
// Check if dispute should be escalated (high-value or repeated failures)
846+
const [taskForEscalation] = await db.select({ budgetMax: tasks.budgetMax }).from(tasks).where(eq(tasks.id, id)).limit(1);
847+
if (taskForEscalation && await shouldEscalate(id, taskForEscalation.budgetMax)) {
848+
await db.update(disputes).set({ status: DISPUTE_STATUS.ESCALATED, updatedAt: new Date() }).where(eq(disputes.id, dispute.id));
849+
sendEscalationNotification({
850+
disputeId: dispute.id,
851+
taskId: id,
852+
amount: taskForEscalation.budgetMax.toString(),
853+
reason: parsed.data.reason,
854+
raisedBy: agent.agent_id,
855+
against: againstAgentId ?? null,
856+
}).catch((err) => console.error('[ESCALATION] notification failed:', err));
857+
}
858+
843859
return c.json(dispute, 201);
844860
});
845861

0 commit comments

Comments
 (0)