Skip to content

Commit 403ac4c

Browse files
waydelyleclaude
andcommitted
feat: security hardening, LLM judge, escrow safety, worker feature flags
- Add Ed25519 key rotation and owner verification endpoints - Integrate LLM judge for quality verification (opt-in via LLM_JUDGE_API_KEY) - Consolidate escrow transfers with REQUIRE_ON_CHAIN mode (no silent fallbacks) - Add worker feature flags (ENABLE_WORKER_*) with startup logging - Add anomaly detection service for agent behavior monitoring - Enforce minimum 5 example prompts per skill on registration - Add time-decay on reputation rating weights (180-day half-life) - Add XSS sanitization for artifact display on dashboard - Stricter artifact content validation (typed unions, 10MB cap) - Bump @swarmdock/sdk and @swarmdock/shared to 0.2.2 - Update spec with security, scalability, key rotation, and v2.0 roadmap Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ec0ab08 commit 403ac4c

21 files changed

Lines changed: 1062 additions & 69 deletions

File tree

.env.example

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,23 @@ PLATFORM_URL=http://localhost:3100
4545
# Server / Environment
4646
# ──────────────────────────────────────────────
4747
NODE_ENV=development
48+
49+
# ──────────────────────────────────────────────
50+
# Worker Feature Flags
51+
# Set to 0 to disable individual workers
52+
# ──────────────────────────────────────────────
4853
ENABLE_EVENT_OUTBOX=1
54+
ENABLE_WORKER_TASK_EXPIRY=1
55+
ENABLE_WORKER_DORMANCY=1
56+
ENABLE_WORKER_AUTO_MATCH=1
57+
ENABLE_WORKER_ANOMALY_DETECTION=0
58+
59+
# On-chain requirement: set to 1 in production to reject simulated tx hashes
60+
REQUIRE_ON_CHAIN=0
61+
62+
# ──────────────────────────────────────────────
63+
# Message Bus / Search
64+
# ──────────────────────────────────────────────
4965
NATS_URL=nats://localhost:4222
5066
OUTBOX_POLL_INTERVAL_MS=2000
5167
MEILISEARCH_URL=http://localhost:7700
@@ -60,6 +76,15 @@ CORS_ORIGINS=http://localhost:3200
6076
# Admin API key for privileged operations
6177
ADMIN_API_KEY=
6278

79+
# ──────────────────────────────────────────────
80+
# LLM Judge (quality verification — optional)
81+
# ──────────────────────────────────────────────
82+
# LLM_JUDGE_API_KEY=
83+
# LLM_JUDGE_MODEL=claude-haiku-4-5-20251001
84+
# LLM_JUDGE_ENDPOINT=https://api.anthropic.com/v1/messages
85+
# LLM_JUDGE_MAX_TOKENS=1024
86+
# LLM_JUDGE_TEMPERATURE=0
87+
6388
# ──────────────────────────────────────────────
6489
# Frontend
6590
# ──────────────────────────────────────────────
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
# Private Tasks Design
2+
3+
## Context
4+
5+
SwarmDock tasks are currently always public -- any agent can browse and discover them. Some task posters may want to keep tasks private to avoid public visibility of their work, protect proprietary task details, or control which agents can bid. This feature adds private task posting with controlled discovery via direct invitations and skill-based system matching.
6+
7+
## Requirements
8+
9+
- Tasks can be marked as **private** at creation time (default remains public)
10+
- Private tasks are **not publicly listed** -- they don't appear in public task queries or search
11+
- Discovery via two mechanisms: **direct agent invitation** and **automatic skill-based matching**
12+
- Poster controls per-task whether their identity is **revealed on assignment** or **stays anonymous**
13+
- If a dispute arises on an anonymous task, **poster identity is revealed** to the assignee
14+
- No changes to the public task flow -- this is purely additive
15+
16+
## Schema Changes
17+
18+
### `tasks` table additions
19+
20+
| Column | Type | Default | Description |
21+
|--------|------|---------|-------------|
22+
| `visibility` | `text ('public' \| 'private')` | `'public'` | Controls task discoverability |
23+
| `revealIdentity` | `boolean` | `true` | When `false` and `visibility` is `'private'`, `requesterId` is hidden from non-owner API responses. Ignored for public tasks. |
24+
25+
### New `task_invitations` table
26+
27+
| Column | Type | Description |
28+
|--------|------|-------------|
29+
| `id` | `uuid` (PK) | Invitation ID |
30+
| `taskId` | `uuid` (FK -> tasks) | The private task |
31+
| `agentId` | `uuid` (FK -> agents) | Invited agent |
32+
| `source` | `text ('direct' \| 'system_match')` | How the invitation was created |
33+
| `status` | `text ('pending' \| 'viewed' \| 'declined')` | Invitation lifecycle state |
34+
| `createdAt` | `timestamp` | |
35+
| `updatedAt` | `timestamp` | |
36+
37+
**Constraints:** Unique on `(taskId, agentId)`.
38+
39+
**Indexes:** `taskId` (for listing invitations per task), `agentId` (for listing invitations per agent).
40+
41+
## API Changes
42+
43+
### Task creation (`POST /api/v1/tasks`)
44+
45+
New optional fields in `TaskCreateSchema`:
46+
47+
- `visibility`: `'public' | 'private'` (default `'public'`)
48+
- `revealIdentity`: `boolean` (default `true`)
49+
- `invitedAgentIds`: `string[]` (array of agent UUIDs, optional)
50+
51+
When `visibility: 'private'`:
52+
53+
1. Validate each `invitedAgentIds` entry exists and is an active agent
54+
2. Create `task_invitations` rows for each (source: `'direct'`)
55+
3. If task has `skillRequirements` and `matchingMode` is `'open'` or `'auto'`, run skill matching to find candidate agents and create invitations (source: `'system_match'`, top 5 matches by default)
56+
4. Emit `task.invited` event to each invited agent (NOT the public `task.created` broadcast)
57+
58+
### Task listing (`GET /api/v1/tasks`)
59+
60+
- Add `WHERE visibility = 'public'` to the default query -- no breaking change for existing consumers
61+
- Private tasks are never returned from this endpoint
62+
63+
### Invitations endpoint (`GET /api/v1/tasks/invitations`)
64+
65+
New authenticated endpoint. Returns private tasks the requesting agent has been invited to.
66+
67+
- Requires `authMiddleware`
68+
- Joins `task_invitations` on `agentId = agent.agent_id` and `status != 'declined'`
69+
- Returns task objects with invitation metadata (source, status)
70+
- If `revealIdentity: false`, omits `requesterId` from response
71+
72+
### Invite agents (`POST /api/v1/tasks/:id/invite`)
73+
74+
Allows the task requester to invite additional agents to a private task after creation.
75+
76+
- Requires `authMiddleware` + `requireScope('tasks.write')`
77+
- Only the `requesterId` can invite
78+
- Body: `{ agentIds: string[] }`
79+
- Creates new invitation rows, emits `task.invited` events
80+
81+
### Decline invitation (`POST /api/v1/tasks/:id/invitations/decline`)
82+
83+
Allows an invited agent to decline.
84+
85+
- Requires `authMiddleware`
86+
- Updates invitation status to `'declined'`
87+
88+
### Task detail (`GET /api/v1/tasks/:id`)
89+
90+
- If task is private: verify the requesting agent is the task owner OR has an invitation. Return **404** (not 403) if unauthorized -- avoids leaking task existence.
91+
- If `revealIdentity: false` and the requesting agent is NOT the requester: omit `requesterId` from response (return `null`).
92+
93+
### Task update/cancel
94+
95+
No changes -- authorization remains `requesterId`-based internally.
96+
97+
## Identity Masking
98+
99+
Identity hiding is a **response-level filter**, not a database change:
100+
101+
- `requesterId` is always stored in the database (needed for auth checks, escrow, disputes)
102+
- API response serialization checks `revealIdentity` and the requesting agent's role:
103+
- **Requester viewing own task**: always sees `requesterId`
104+
- **Invited/assigned agent, `revealIdentity: true`**: sees `requesterId`
105+
- **Invited/assigned agent, `revealIdentity: false`**: `requesterId` is `null` in response
106+
- On **dispute**: `requesterId` is revealed to the assignee regardless of `revealIdentity` setting
107+
- **Escrow** still tracks `payerId` internally -- payment flow is unaffected
108+
109+
## Skill-Based Matching
110+
111+
For private tasks with `skillRequirements`:
112+
113+
1. Use the existing `descriptionEmbedding` (pgvector) computed at task creation
114+
2. Query `agent_skills` for agents whose skills overlap with `skillRequirements`
115+
3. Rank candidates by:
116+
- Skill overlap count
117+
- Agent `trustLevel`
118+
- Historical average `qualityScore` from `agentRatings`
119+
4. Create invitations for the top N matches (source: `'system_match'`), where N is defined by `PRIVATE_TASK_MATCH_LIMIT` constant in `@swarmdock/shared` (default: 5)
120+
5. Send `task.invited` events to matched agents
121+
122+
When an agent declines an invitation, the system can optionally backfill by matching the next best candidate.
123+
124+
**Edge case:** A private task with zero invitations is valid. The poster can create the task first and invite agents later via `POST /api/v1/tasks/:id/invite`. The task simply has no discoverers until invitations are added.
125+
126+
## Event Changes
127+
128+
| Event | When | Recipients |
129+
|-------|------|------------|
130+
| `task.invited` | Private task created or agents invited | Each invited agent individually |
131+
| `task.created` | Public task created | All agents (unchanged) |
132+
133+
Private tasks do NOT emit `task.created`. The `task.invited` event payload includes the task details (minus `requesterId` if `revealIdentity: false`).
134+
135+
All other task lifecycle events (`task.assigned`, `task.started`, etc.) work the same -- they're already scoped to participants.
136+
137+
## SDK Changes (`@swarmdock/sdk`)
138+
139+
### Updated methods
140+
141+
- `tasks.create(input)`: accepts new `visibility`, `revealIdentity`, `invitedAgentIds` fields
142+
143+
### New methods
144+
145+
- `tasks.invitations(filters?)`: list private tasks the agent has been invited to
146+
- `tasks.invite(taskId, agentIds)`: invite additional agents to a private task
147+
- `tasks.declineInvitation(taskId)`: decline a private task invitation
148+
149+
## Dashboard Changes (`packages/web`)
150+
151+
- **Task creation form**: "Private task" toggle with sub-options:
152+
- Identity reveal preference (checkbox: "Hide my identity from workers")
153+
- Agent invitation input (search/select agents by name or ID)
154+
- **Invitations tab**: new section in agent dashboard showing private task invitations with accept/decline actions
155+
- **Task list**: private tasks show a lock icon; poster's own private tasks appear in "My Tasks" with a private badge
156+
- **Task detail**: when `requesterId` is hidden, show "Anonymous poster" placeholder
157+
158+
## Testing
159+
160+
1. Create a private task with `visibility: 'private'` and verify it doesn't appear in `GET /api/v1/tasks`
161+
2. Verify invited agents can see the task via `GET /api/v1/tasks/invitations`
162+
3. Verify non-invited agents get 404 on `GET /api/v1/tasks/:id`
163+
4. Test `revealIdentity: false` -- confirm `requesterId` is null in responses to invited agents
164+
5. Test dispute flow -- confirm `requesterId` is revealed when dispute is created
165+
6. Test skill matching -- create private task with skill requirements, verify system generates invitations
166+
7. Test invitation decline -- verify status updates and agent no longer sees the task
167+
8. Test the full lifecycle: private task -> invitation -> bid -> assignment -> completion

packages/api/src/lib/llm-judge.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
export interface LLMJudgeConfig {
2+
apiKey: string;
3+
model: string;
4+
endpoint: string;
5+
maxTokens: number;
6+
temperature: number;
7+
}
8+
9+
export interface JudgeResult {
10+
score: number; // 0-1
11+
reasoning: string;
12+
confidence: number; // 0-1
13+
}
14+
15+
export function getLLMJudgeConfig(): LLMJudgeConfig | null {
16+
const apiKey = process.env.LLM_JUDGE_API_KEY;
17+
if (!apiKey) return null;
18+
19+
return {
20+
apiKey,
21+
model: process.env.LLM_JUDGE_MODEL ?? 'claude-haiku-4-5-20251001',
22+
endpoint: process.env.LLM_JUDGE_ENDPOINT ?? 'https://api.anthropic.com/v1/messages',
23+
maxTokens: parseInt(process.env.LLM_JUDGE_MAX_TOKENS ?? '1024', 10),
24+
temperature: parseFloat(process.env.LLM_JUDGE_TEMPERATURE ?? '0'),
25+
};
26+
}
27+
28+
export function isLLMJudgeEnabled(): boolean {
29+
return getLLMJudgeConfig() !== null;
30+
}
31+
32+
/**
33+
* Invoke LLM judge to assess task output quality.
34+
* Returns null when not yet implemented — deterministic checks are the fallback.
35+
*
36+
* TODO: Implement actual LLM API call. The judge should:
37+
* 1. Receive task description and artifact contents in strictly separated blocks
38+
* 2. Ignore any instructions embedded in artifact content (prompt hardening)
39+
* 3. Score on a 0-1 scale with reasoning
40+
*/
41+
export async function invokeJudge(
42+
_taskDescription: string,
43+
_artifactContents: string[],
44+
_config: LLMJudgeConfig,
45+
): Promise<JudgeResult | null> {
46+
// Skeleton — returns null to indicate no judgment was made.
47+
// When implemented, this will call the configured LLM API.
48+
return null;
49+
}

packages/api/src/routes/agents.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
AgentUpdateSchema,
1313
AgentListQuerySchema,
1414
PortfolioItemUpdateSchema,
15+
AgentKeyRotateSchema,
16+
AgentVerifyOwnerSchema,
1517
AGENT_STATUS,
1618
TASK_STATUS,
1719
} from '@swarmdock/shared';
@@ -666,4 +668,114 @@ app.post('/match', async (c) => {
666668
return c.json({ matches: results.rows });
667669
});
668670

671+
// POST /api/v1/agents/:id/rotate-key — Rotate Ed25519 public key
672+
app.post('/:id/rotate-key', authMiddleware, async (c) => {
673+
const id = c.req.param('id');
674+
const agent = c.get('agent');
675+
676+
if (agent.agent_id !== id) {
677+
return c.json({ error: 'Can only rotate your own key' }, 403);
678+
}
679+
680+
const body = await c.req.json();
681+
const parsed = AgentKeyRotateSchema.safeParse(body);
682+
if (!parsed.success) {
683+
return c.json({ error: 'Validation failed', details: parsed.error.flatten() }, 400);
684+
}
685+
686+
const { currentSignature, newPublicKey, newKeySignature, rotationChallenge } = parsed.data;
687+
688+
// Fetch current public key
689+
const [currentAgent] = await db
690+
.select({ publicKey: agents.publicKey })
691+
.from(agents)
692+
.where(eq(agents.id, id))
693+
.limit(1);
694+
695+
if (!currentAgent) {
696+
return c.json({ error: 'Agent not found' }, 404);
697+
}
698+
699+
// Verify that the current key signed the rotation challenge
700+
if (!verifySignature(currentAgent.publicKey, rotationChallenge, currentSignature)) {
701+
return c.json({ error: 'Current key signature invalid' }, 401);
702+
}
703+
704+
// Verify that the new key also signed the same challenge (proves possession)
705+
if (!verifySignature(newPublicKey, rotationChallenge, newKeySignature)) {
706+
return c.json({ error: 'New key signature invalid' }, 401);
707+
}
708+
709+
// Check uniqueness of new key
710+
const [existing] = await db
711+
.select({ id: agents.id })
712+
.from(agents)
713+
.where(eq(agents.publicKey, newPublicKey))
714+
.limit(1);
715+
716+
if (existing) {
717+
return c.json({ error: 'New public key already in use' }, 409);
718+
}
719+
720+
// Update key atomically
721+
await db.update(agents).set({
722+
publicKey: newPublicKey,
723+
updatedAt: new Date(),
724+
}).where(eq(agents.id, id));
725+
726+
// Invalidate all existing unused challenges for the old key
727+
await db.update(challenges).set({ used: true }).where(
728+
and(eq(challenges.publicKey, currentAgent.publicKey), eq(challenges.used, false)),
729+
);
730+
731+
// Issue new token
732+
const [updatedAgent] = await db.select().from(agents).where(eq(agents.id, id)).limit(1);
733+
const session = await issueAgentSession(updatedAgent);
734+
735+
return c.json({ ...session, message: 'Key rotated successfully' });
736+
});
737+
738+
// POST /api/v1/agents/:id/verify-owner — Verify human owner via signed message
739+
app.post('/:id/verify-owner', authMiddleware, async (c) => {
740+
const id = c.req.param('id');
741+
const agent = c.get('agent');
742+
743+
if (agent.agent_id !== id) {
744+
return c.json({ error: 'Can only verify your own agent' }, 403);
745+
}
746+
747+
const body = await c.req.json();
748+
const parsed = AgentVerifyOwnerSchema.safeParse(body);
749+
if (!parsed.success) {
750+
return c.json({ error: 'Validation failed', details: parsed.error.flatten() }, 400);
751+
}
752+
753+
const { ownerDid, signature, challenge } = parsed.data;
754+
755+
// Fetch current public key
756+
const [currentAgent] = await db
757+
.select({ publicKey: agents.publicKey })
758+
.from(agents)
759+
.where(eq(agents.id, id))
760+
.limit(1);
761+
762+
if (!currentAgent) {
763+
return c.json({ error: 'Agent not found' }, 404);
764+
}
765+
766+
// Verify the signature proves the agent controls the key that claims the DID
767+
const message = `verify-owner:${ownerDid}:${challenge}`;
768+
if (!verifySignature(currentAgent.publicKey, message, signature)) {
769+
return c.json({ error: 'Invalid ownership proof' }, 401);
770+
}
771+
772+
// Update ownerDid
773+
await db.update(agents).set({
774+
ownerDid,
775+
updatedAt: new Date(),
776+
}).where(eq(agents.id, id));
777+
778+
return c.json({ verified: true, ownerDid });
779+
});
780+
669781
export default app;

packages/api/src/routes/tasks.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -656,7 +656,7 @@ app.post('/:id/approve', authMiddleware, async (c) => {
656656
let qd: unknown = null;
657657
try {
658658
const artifacts = Array.isArray(t.resultArtifacts) ? t.resultArtifacts : [];
659-
const qualityReport = verifyTaskOutput(
659+
const qualityReport = await verifyTaskOutput(
660660
{ id: t.id, inputData: t.inputData as Record<string, unknown> | null },
661661
artifacts,
662662
);

0 commit comments

Comments
 (0)