@adastracomputing/ink 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -0
- package/CODE_OF_CONDUCT.md +42 -0
- package/LICENSE-APACHE +201 -0
- package/LICENSE-MIT +21 -0
- package/README.md +133 -0
- package/SECURITY.md +57 -0
- package/docs/key-rotation-rule.md +108 -0
- package/docs/logo.svg +8 -0
- package/docs/maturity.md +81 -0
- package/docs/threat-model.md +150 -0
- package/package.json +72 -0
- package/specs/ink-agent-containment-and-governance-extension-spec.md +508 -0
- package/specs/ink-auditability.md +652 -0
- package/specs/ink-authorization-chain.md +242 -0
- package/specs/ink-compatibility-policy.md +263 -0
- package/specs/ink-compliance-checklist.md +309 -0
- package/specs/ink-containment-phase1-implementation-spec.md +593 -0
- package/specs/ink-introduction-receipts-extension.md +501 -0
- package/specs/ink-key-rotation-spec.md +535 -0
- package/src/crypto/ink.ts +902 -0
- package/src/crypto/keys.ts +211 -0
- package/src/crypto/multi-key-verify.ts +170 -0
- package/src/crypto/sign.ts +155 -0
- package/src/crypto/verify.ts +1 -0
- package/src/discovery/agent-card.ts +508 -0
- package/src/index.ts +59 -0
- package/src/ink/checkpoint.ts +75 -0
- package/src/ink/discovery-gating.ts +147 -0
- package/src/ink/handshake-budget.ts +413 -0
- package/src/ink/receipts.ts +114 -0
- package/src/ink/transport-auth.ts +96 -0
- package/src/middleware/ink-auth.ts +263 -0
- package/src/models/agent-card.ts +63 -0
- package/src/models/ink-audit.ts +205 -0
- package/src/models/ink-handshake.ts +123 -0
- package/src/models/intent.ts +201 -0
- package/src/models/key-entry.ts +52 -0
- package/src/models/profile.ts +31 -0
- package/test-vectors/README.md +129 -0
- package/test-vectors/encryption.json +90 -0
- package/test-vectors/handshake.json +482 -0
- package/test-vectors/jcs.json +30 -0
- package/test-vectors/key-rotation.json +101 -0
- package/test-vectors/keys.json +32 -0
- package/test-vectors/receipts-and-audit.json +142 -0
- package/test-vectors/replay.json +88 -0
- package/test-vectors/signing.json +61 -0
- package/test-vectors/witness.json +394 -0
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
# INK Containment Phase 1, Implementation Spec
|
|
2
|
+
|
|
3
|
+
## Status
|
|
4
|
+
Draft
|
|
5
|
+
|
|
6
|
+
## Purpose
|
|
7
|
+
|
|
8
|
+
Narrow implementation spec for Phase 1 of the [Agent Containment and Governance Extension](ink-agent-containment-and-governance-extension-spec.md). Covers three slices:
|
|
9
|
+
|
|
10
|
+
1. **Transport-bound authorization**, delegation tokens scoped to specific transports
|
|
11
|
+
2. **Capability-gated Agent Card discovery**, redacted cards for unauthenticated peers
|
|
12
|
+
3. **Handshake flood resistance**, per-correlation budgets, typed rejections, backoff hints
|
|
13
|
+
|
|
14
|
+
These three were chosen because they are independently shippable, require no fleet infrastructure, and harden the protocol surface that already exists.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Slice 1: Transport-Bound Authorization
|
|
19
|
+
|
|
20
|
+
### Problem
|
|
21
|
+
|
|
22
|
+
A delegation token issued for INK HTTP use can currently be replayed via extension callbacks, voice workflows, or other channels. The authorization chain spec defines `constraints` with `intentTypes`, `targetAgents`, `expiresAt`, and `maxMessages`, but not which transport the token is valid on.
|
|
23
|
+
|
|
24
|
+
### Wire Changes
|
|
25
|
+
|
|
26
|
+
#### 1.1 Add `allowedTransports` to delegation hop constraints
|
|
27
|
+
|
|
28
|
+
Extend `DelegationHopSchema.constraints`:
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
constraints: z.object({
|
|
32
|
+
intentTypes: z.array(IntentTypeSchema).optional(),
|
|
33
|
+
targetAgents: z.array(z.string()).optional(),
|
|
34
|
+
expiresAt: z.string().datetime(),
|
|
35
|
+
maxMessages: z.number().int().positive().optional(),
|
|
36
|
+
// NEW
|
|
37
|
+
allowedTransports: z.array(InkTransportSchema).optional(),
|
|
38
|
+
}),
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Transport identifiers:
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
const InkTransportSchema = z.enum([
|
|
45
|
+
"ink_http", // Standard INK HTTP endpoints
|
|
46
|
+
"ink_ws", // WebSocket INK transport (future)
|
|
47
|
+
"extension_api", // Product API calls from extensions
|
|
48
|
+
"voice", // In-app voice (CF Calls / WebRTC)
|
|
49
|
+
"line_phone", // PSTN phone calls via Telnyx
|
|
50
|
+
"human_review_queue", // Escalation queue for human review
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
type InkTransport = z.infer<typeof InkTransportSchema>;
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
#### 1.2 Default behavior and legacy migration
|
|
57
|
+
|
|
58
|
+
If `allowedTransports` is **omitted**, the effective default depends on a version gate:
|
|
59
|
+
|
|
60
|
+
- **Tokens issued after this spec ships (v0.3+):** omitted `allowedTransports` defaults to `["ink_http"]`. Implementations MUST NOT treat omission as "all transports allowed."
|
|
61
|
+
- **Legacy tokens (pre-v0.3, no `allowedTransports` field):** during the migration window (90 days from deploy), omitted `allowedTransports` defaults to `["ink_http", "extension_api", "voice", "line_phone"]`, matching the set of transports that existed before transport scoping was introduced. After the migration window closes, legacy tokens without `allowedTransports` fall back to `["ink_http"]` only.
|
|
62
|
+
|
|
63
|
+
**Version gate mechanism:** the delegation token issuance endpoint stamps a `tokenVersion` field (string, e.g. `"0.3"`) on newly issued tokens. Tokens without `tokenVersion` are legacy. The migration window end date is stored as a deployment config constant, not hardcoded in the protocol.
|
|
64
|
+
|
|
65
|
+
**Migration plan:**
|
|
66
|
+
1. Deploy transport scoping with the legacy-permissive default
|
|
67
|
+
2. Update all token issuance paths (extension install, delegation grant) to include `allowedTransports` explicitly
|
|
68
|
+
3. Monitor audit logs for `transport_scope_violation` events from legacy tokens
|
|
69
|
+
4. After 90 days (or when audit logs show zero legacy-token violations for 14 consecutive days), flip the default to `["ink_http"]` only
|
|
70
|
+
|
|
71
|
+
This avoids breaking existing extension, voice, and phone delegation flows on deploy day while providing a clear path to strict-by-default.
|
|
72
|
+
|
|
73
|
+
#### 1.3 Verification rule
|
|
74
|
+
|
|
75
|
+
When verifying a delegation chain, the verifier MUST:
|
|
76
|
+
|
|
77
|
+
1. Determine the current invocation transport (from request context, not from the message itself)
|
|
78
|
+
2. For each hop in the chain, check that the current transport is in that hop's `allowedTransports`
|
|
79
|
+
3. If any hop does not include the current transport, reject with reason `transport_scope_violation`
|
|
80
|
+
|
|
81
|
+
Each hop's `allowedTransports` MUST be a subset of the previous hop's (same attenuation rule as permissions and autonomy tiers). A child delegation cannot add transports the parent didn't allow.
|
|
82
|
+
|
|
83
|
+
#### 1.4 Rejection reason
|
|
84
|
+
|
|
85
|
+
Add to the typed rejection enum:
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
"transport_scope_violation"
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
#### 1.5 Audit event
|
|
92
|
+
|
|
93
|
+
Add audit event type:
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
"transport_scope_violation"
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Event payload:
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
{
|
|
103
|
+
messageId: string;
|
|
104
|
+
correlationId: string;
|
|
105
|
+
fromDid: string;
|
|
106
|
+
claimedTransport?: string;
|
|
107
|
+
actualTransport: string;
|
|
108
|
+
allowedTransports: string[];
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Implementation
|
|
113
|
+
|
|
114
|
+
#### Files modified
|
|
115
|
+
|
|
116
|
+
| File | Change |
|
|
117
|
+
|------|--------|
|
|
118
|
+
| `src/models/ink-handshake.ts` | Add `InkTransportSchema`, extend `constraints` |
|
|
119
|
+
| `src/crypto/ink.ts` | Chain validation: check transport attenuation |
|
|
120
|
+
| `src/middleware/ink-auth.ts` | Tag request context with transport identifier |
|
|
121
|
+
| `src/ink/chain-verifier.ts` | Add transport check to `verifyDelegationChain()` |
|
|
122
|
+
| `src/models/ink-audit.ts` | Add `transport_scope_violation` event type |
|
|
123
|
+
| Agent state/orchestration layer | Pass transport context through to chain verification |
|
|
124
|
+
|
|
125
|
+
#### Transport identification
|
|
126
|
+
|
|
127
|
+
The invocation transport is determined by the receiver, not claimed by the sender:
|
|
128
|
+
|
|
129
|
+
| Context | Transport value |
|
|
130
|
+
|---------|----------------|
|
|
131
|
+
| Request to `/ink/v1/*` endpoints | `ink_http` |
|
|
132
|
+
| Extension API call via product routes | `extension_api` |
|
|
133
|
+
| Voice session action | `voice` |
|
|
134
|
+
| PSTN call handler | `line_phone` |
|
|
135
|
+
| Human review queue resolution | `human_review_queue` |
|
|
136
|
+
|
|
137
|
+
This MUST be set by the routing layer before delegation chain verification runs. It is never parsed from the inbound message.
|
|
138
|
+
|
|
139
|
+
#### Backward compatibility
|
|
140
|
+
|
|
141
|
+
See §1.2 for the version-gated migration plan. During the 90-day migration window, legacy tokens without `allowedTransports` or `tokenVersion` are treated as `["ink_http", "extension_api", "voice", "line_phone"]` to avoid breaking existing flows. New tokens issued after deploy always include explicit `allowedTransports` and `tokenVersion: "0.3"`.
|
|
142
|
+
|
|
143
|
+
### Tests
|
|
144
|
+
|
|
145
|
+
| Test | Description |
|
|
146
|
+
|------|-------------|
|
|
147
|
+
| Token with `allowedTransports: ["ink_http"]` accepted on INK HTTP | Happy path |
|
|
148
|
+
| Token with `allowedTransports: ["ink_http"]` rejected on `extension_api` | Transport mismatch |
|
|
149
|
+
| v0.3 token with omitted `allowedTransports` defaults to `["ink_http"]` | Strict default for new tokens |
|
|
150
|
+
| Legacy token (no `tokenVersion`) defaults to permissive set during migration window | Legacy compat |
|
|
151
|
+
| Legacy token defaults to `["ink_http"]` only after migration window closes | Migration complete |
|
|
152
|
+
| Token with `["ink_http", "voice"]` accepted on voice | Multi-transport grant |
|
|
153
|
+
| Child hop cannot add transport parent didn't allow | Attenuation check |
|
|
154
|
+
| Child hop can narrow parent's transport list | Subset OK |
|
|
155
|
+
| New tokens include `tokenVersion: "0.3"` | Version stamping |
|
|
156
|
+
| Rejection includes `transport_scope_violation` reason | Typed error |
|
|
157
|
+
| Audit event emitted on transport violation | Observability |
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## Slice 2: Capability-Gated Agent Card Discovery
|
|
162
|
+
|
|
163
|
+
### Problem
|
|
164
|
+
|
|
165
|
+
Agent Cards at `GET /ink/v1/{agentId}/agent.json` currently return full capability and endpoint details to any requester. This expands the probing surface: an attacker can enumerate capabilities, accepted intents, scheduling endpoints, delegation support, and extension hooks without authentication.
|
|
166
|
+
|
|
167
|
+
### Wire Changes
|
|
168
|
+
|
|
169
|
+
#### 2.1 Agent Card `visibility` field
|
|
170
|
+
|
|
171
|
+
Add to `TulpaAgentCard`:
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
visibility: "public" | "network_only" | "capability_gated" | "private"
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
This replaces the existing `"public" | "network_only" | "private"` enum by adding `capability_gated`.
|
|
178
|
+
|
|
179
|
+
#### 2.2 Redacted Agent Card
|
|
180
|
+
|
|
181
|
+
When visibility is `capability_gated`, unauthenticated requests to the Agent Card endpoint receive a redacted response:
|
|
182
|
+
|
|
183
|
+
```typescript
|
|
184
|
+
interface RedactedAgentCard {
|
|
185
|
+
type: "tulpa.agent.card";
|
|
186
|
+
version: "1.0";
|
|
187
|
+
agentId: string;
|
|
188
|
+
displayName?: string;
|
|
189
|
+
visibility: "capability_gated";
|
|
190
|
+
supportsInk: true;
|
|
191
|
+
discoveryMode: "authenticate_for_details";
|
|
192
|
+
updatedAt: string;
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Fields NOT included in the redacted card:
|
|
197
|
+
- `capabilities`
|
|
198
|
+
- `openness`
|
|
199
|
+
- `communicationModes`
|
|
200
|
+
- `phoneCard`
|
|
201
|
+
- `voiceProfileSummary`
|
|
202
|
+
- `ownerLink` (if owner has restricted linkage visibility)
|
|
203
|
+
- Any governance fields from the containment spec
|
|
204
|
+
|
|
205
|
+
#### 2.3 Authenticated Agent Card query
|
|
206
|
+
|
|
207
|
+
New endpoint:
|
|
208
|
+
|
|
209
|
+
```
|
|
210
|
+
POST /ink/v1/{agentId}/agent-card-query
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Request body:
|
|
214
|
+
|
|
215
|
+
```typescript
|
|
216
|
+
{
|
|
217
|
+
protocol: "ink/0.1";
|
|
218
|
+
type: "network.tulpa.agent_card_query";
|
|
219
|
+
from: string; // requester DID
|
|
220
|
+
nonce: string; // replay protection
|
|
221
|
+
timestamp: string; // freshness
|
|
222
|
+
requestedFields?: string[]; // optional: specific fields requested
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Authentication: standard INK `Authorization: INK-Ed25519 <sig>` header. Signature base follows the same format as all INK requests (protocol + method + path + recipientDid + JCS(body) + timestamp).
|
|
227
|
+
|
|
228
|
+
Response (if authorized):
|
|
229
|
+
|
|
230
|
+
```typescript
|
|
231
|
+
{
|
|
232
|
+
protocol: "ink/0.1";
|
|
233
|
+
type: "network.tulpa.agent_card_response";
|
|
234
|
+
card: TulpaAgentCard; // full or field-filtered
|
|
235
|
+
grantedFields: string[];
|
|
236
|
+
timestamp: string;
|
|
237
|
+
}
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Response (if denied):
|
|
241
|
+
|
|
242
|
+
```typescript
|
|
243
|
+
{
|
|
244
|
+
protocol: "ink/0.1";
|
|
245
|
+
type: "network.tulpa.agent_card_denied";
|
|
246
|
+
reason: "unknown_requester" | "insufficient_trust" | "not_connected";
|
|
247
|
+
timestamp: string;
|
|
248
|
+
}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
#### 2.4 Access policy
|
|
252
|
+
|
|
253
|
+
The agent owner configures which peers see which fields. Recommended tiers:
|
|
254
|
+
|
|
255
|
+
| Requester relationship | Card detail level |
|
|
256
|
+
|----------------------|------------------|
|
|
257
|
+
| Not connected, unknown | Redacted card only |
|
|
258
|
+
| Known peer (has exchanged at least one message) | Capabilities and openness |
|
|
259
|
+
| Connected (mutual connection) | Full card |
|
|
260
|
+
| Fleet-managed same-org peer | Full card + governance fields |
|
|
261
|
+
|
|
262
|
+
The exact policy is implementation-defined. The protocol defines the query mechanism and response shapes; the access decision is local.
|
|
263
|
+
|
|
264
|
+
#### 2.5 Audit events
|
|
265
|
+
|
|
266
|
+
Add event types:
|
|
267
|
+
|
|
268
|
+
```typescript
|
|
269
|
+
"discovery_query_received"
|
|
270
|
+
"discovery_query_granted"
|
|
271
|
+
"discovery_query_denied"
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Payload:
|
|
275
|
+
|
|
276
|
+
```typescript
|
|
277
|
+
{
|
|
278
|
+
requesterDid: string;
|
|
279
|
+
grantedFields?: string[];
|
|
280
|
+
denyReason?: string;
|
|
281
|
+
}
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### Implementation
|
|
285
|
+
|
|
286
|
+
#### Files modified
|
|
287
|
+
|
|
288
|
+
| File | Change |
|
|
289
|
+
|------|--------|
|
|
290
|
+
| `src/models/ink-handshake.ts` | Add `AgentCardQuerySchema`, `AgentCardResponseSchema`, `AgentCardDeniedSchema` |
|
|
291
|
+
| `src/models/ink-audit.ts` | Add discovery audit event types |
|
|
292
|
+
| INK route handlers | Add `POST /ink/v1/:agentId/agent-card-query` handler |
|
|
293
|
+
| INK route handlers | Modify `GET /ink/v1/:agentId/agent.json` to return redacted card when `capability_gated` |
|
|
294
|
+
| Agent state/orchestration layer | Store and serve visibility setting, access policy evaluation |
|
|
295
|
+
|
|
296
|
+
#### Agent Card endpoint behavior by visibility
|
|
297
|
+
|
|
298
|
+
Since `GET /ink/v1/{agentId}/agent.json` is unauthenticated, "INK peers only" is not enforceable at the GET layer. The visibility modes therefore define what the unauthenticated GET returns and whether authenticated query is available:
|
|
299
|
+
|
|
300
|
+
| Visibility | `GET /ink/v1/{agentId}/agent.json` | `POST /ink/v1/{agentId}/agent-card-query` |
|
|
301
|
+
|-----------|-----------------------------------|------------------------------------------|
|
|
302
|
+
| `public` | Full card | Not needed, but MAY respond with full card |
|
|
303
|
+
| `network_only` | **Redacted card** (identity + `supportsInk` + displayName only) | Full card for any authenticated INK peer with valid signature |
|
|
304
|
+
| `capability_gated` | Redacted card | Full or filtered card based on access policy (connection tier) |
|
|
305
|
+
| `private` | 404 | Responds only to connected peers |
|
|
306
|
+
|
|
307
|
+
The key difference between `network_only` and `capability_gated`:
|
|
308
|
+
- `network_only`: any peer that can produce a valid INK signature gets the full card. The gate is "are you a real INK agent?", authentication only, no authorization.
|
|
309
|
+
- `capability_gated`: authenticated peers get filtered results based on relationship tier (unknown → known → connected → same-org). The gate is "what is your relationship to this agent?", authentication plus authorization.
|
|
310
|
+
|
|
311
|
+
Both modes return the same redacted card on unauthenticated GET. The difference is in the authenticated query's access policy.
|
|
312
|
+
|
|
313
|
+
#### Recommended default
|
|
314
|
+
|
|
315
|
+
Per the governance spec:
|
|
316
|
+
- Self-sovereign / public-network deployments: `network_only`
|
|
317
|
+
- Enterprise / internal deployments: `capability_gated`
|
|
318
|
+
|
|
319
|
+
The default for new Tulpa agents: `network_only`. This is a **behavior change** from current (which returns a full card on unauthenticated GET). The change is safe because:
|
|
320
|
+
- No external INK peers exist yet, there are no consumers of the unauthenticated full card
|
|
321
|
+
- The authenticated query endpoint ships in the same deploy, so any future peer can get full details by signing the request
|
|
322
|
+
- The redacted card still confirms the agent exists and supports INK, which is sufficient for initial discovery
|
|
323
|
+
|
|
324
|
+
### Tests
|
|
325
|
+
|
|
326
|
+
| Test | Description |
|
|
327
|
+
|------|-------------|
|
|
328
|
+
| `public` visibility returns full card on GET | Existing behavior preserved |
|
|
329
|
+
| `network_only` returns redacted card on unauthenticated GET | Probing surface closed |
|
|
330
|
+
| `network_only` returns full card on authenticated query from any INK peer | Auth-only gate |
|
|
331
|
+
| `capability_gated` returns redacted card on GET | Core redaction |
|
|
332
|
+
| `capability_gated` authenticated query from connected peer returns full card | Relationship-gated |
|
|
333
|
+
| `capability_gated` authenticated query from unknown peer returns denied | Access control |
|
|
334
|
+
| Redacted card includes only safe fields (no capabilities, openness, endpoints) | No capability/endpoint leak |
|
|
335
|
+
| Query with invalid/missing signature returns 401 | Auth enforcement |
|
|
336
|
+
| Query replay (same nonce) rejected | Replay protection |
|
|
337
|
+
| Audit events emitted for query granted/denied | Observability |
|
|
338
|
+
| `private` visibility returns 404 on GET | No discovery |
|
|
339
|
+
|
|
340
|
+
---
|
|
341
|
+
|
|
342
|
+
## Slice 3: Handshake Flood Resistance
|
|
343
|
+
|
|
344
|
+
### Problem
|
|
345
|
+
|
|
346
|
+
The INK handshake is deterministic: intent → challenge → resolution (or rejection). An attacker can:
|
|
347
|
+
- Send many valid challenges to exhaust processing budget
|
|
348
|
+
- Loop challenge/resolution cycles on the same correlationId
|
|
349
|
+
- Flood rejections to suppress legitimate handshakes
|
|
350
|
+
- Exploit predictable retry behavior
|
|
351
|
+
|
|
352
|
+
### Wire Changes
|
|
353
|
+
|
|
354
|
+
#### 3.1 Per-correlation handshake budgets
|
|
355
|
+
|
|
356
|
+
For each `correlationId`, recipients MUST enforce:
|
|
357
|
+
|
|
358
|
+
| Counter | Limit | Description |
|
|
359
|
+
|---------|-------|-------------|
|
|
360
|
+
| Challenges received | 3 | Max challenges from a single counterparty on one correlationId |
|
|
361
|
+
| Rejections received | 1 | Terminal, no further messages accepted |
|
|
362
|
+
| Resolutions received | 1 | Terminal, no further messages accepted |
|
|
363
|
+
| Total state transitions | 5 | Hard cap on all handshake messages per correlationId |
|
|
364
|
+
| Handshake TTL | Intent's `expiresAt` or 24h, whichever is shorter | After expiry, no further messages accepted |
|
|
365
|
+
|
|
366
|
+
When any limit is hit:
|
|
367
|
+
- **First violation** on a given correlationId/sender pair: return a typed rejection with the appropriate reason (`handshake_budget_exhausted`, `sender_rate_limited`) and an optional backoff hint. This tells a well-behaved sender what happened.
|
|
368
|
+
- **Subsequent violations** after a typed rejection has already been sent for that correlationId/sender pair: silent drop (no response). This prevents amplification from an attacker who keeps sending after being told to stop.
|
|
369
|
+
|
|
370
|
+
The budget tracker records whether a typed rejection has been sent per correlationId/sender pair. This is the canonical rule, there is no scenario where a first violation is silently dropped.
|
|
371
|
+
|
|
372
|
+
#### 3.2 Backoff hints
|
|
373
|
+
|
|
374
|
+
Add optional metadata to challenge and rejection responses:
|
|
375
|
+
|
|
376
|
+
```typescript
|
|
377
|
+
const InkBackoffHintSchema = z.object({
|
|
378
|
+
retryAfterSeconds: z.number().int().positive().optional(),
|
|
379
|
+
cooldownUntil: z.string().datetime().optional(),
|
|
380
|
+
backoffClass: z.enum(["sender", "intent_ref", "counterparty"]).optional(),
|
|
381
|
+
});
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
Semantics:
|
|
385
|
+
- `retryAfterSeconds`: sender SHOULD wait at least this many seconds before retrying
|
|
386
|
+
- `cooldownUntil`: absolute timestamp after which sender MAY retry
|
|
387
|
+
- `backoffClass`:
|
|
388
|
+
- `sender`: this sender is rate-limited (all intents)
|
|
389
|
+
- `intent_ref`: this specific intent/correlationId is rate-limited
|
|
390
|
+
- `counterparty`: the recipient is broadly rate-limiting (overloaded)
|
|
391
|
+
|
|
392
|
+
Backoff hints are advisory. A sender that ignores them risks harder rejection or silent drops.
|
|
393
|
+
|
|
394
|
+
#### 3.3 New typed rejection reasons
|
|
395
|
+
|
|
396
|
+
Add to the rejection reason enum:
|
|
397
|
+
|
|
398
|
+
```typescript
|
|
399
|
+
"handshake_budget_exhausted" // per-correlation budget hit
|
|
400
|
+
"counterparty_cooldown" // recipient is rate-limiting broadly
|
|
401
|
+
"sender_rate_limited" // this sender is sending too much
|
|
402
|
+
"delegation_budget_exhausted" // delegation issuance limit hit
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
These are in addition to existing rejection reasons. The `proof_of_work_required` reason from the governance spec is intentionally NOT implemented in Phase 1.
|
|
406
|
+
|
|
407
|
+
#### 3.4 Per-sender rate limits
|
|
408
|
+
|
|
409
|
+
Beyond per-correlation budgets, recipients SHOULD enforce per-sender limits:
|
|
410
|
+
|
|
411
|
+
| Window | Limit | Scope |
|
|
412
|
+
|--------|-------|-------|
|
|
413
|
+
| Per minute | 10 new intents | Per sender DID |
|
|
414
|
+
| Per hour | 60 new intents | Per sender DID |
|
|
415
|
+
| Per minute | 30 handshake messages (all types) | Per sender DID |
|
|
416
|
+
|
|
417
|
+
These are recommended defaults. Implementations MAY adjust based on trust tier (connected peers get higher limits).
|
|
418
|
+
|
|
419
|
+
#### 3.5 Audit events
|
|
420
|
+
|
|
421
|
+
Add event types:
|
|
422
|
+
|
|
423
|
+
```typescript
|
|
424
|
+
"handshake_rate_limited"
|
|
425
|
+
"handshake_budget_exhausted"
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
Payload:
|
|
429
|
+
|
|
430
|
+
```typescript
|
|
431
|
+
{
|
|
432
|
+
correlationId: string;
|
|
433
|
+
fromDid: string;
|
|
434
|
+
messageType: string; // challenge, rejection, resolution
|
|
435
|
+
limitType: "per_correlation" | "per_sender_minute" | "per_sender_hour";
|
|
436
|
+
currentCount: number;
|
|
437
|
+
limit: number;
|
|
438
|
+
}
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
### Implementation
|
|
442
|
+
|
|
443
|
+
#### Files modified
|
|
444
|
+
|
|
445
|
+
| File | Change |
|
|
446
|
+
|------|--------|
|
|
447
|
+
| `src/models/ink-handshake.ts` | Add `InkBackoffHintSchema`, new rejection reasons, budget constants |
|
|
448
|
+
| `src/models/ink-audit.ts` | Add rate-limit audit event types |
|
|
449
|
+
| `src/ink/handshake-budget.ts` | Per-correlation and per-sender budget tracking |
|
|
450
|
+
| `src/middleware/ink-auth.ts` | Wire budget checks before handshake processing |
|
|
451
|
+
| Agent state/orchestration layer | Initialize budget tracker, connect to handshake pipeline |
|
|
452
|
+
|
|
453
|
+
#### Budget tracker design
|
|
454
|
+
|
|
455
|
+
```typescript
|
|
456
|
+
interface HandshakeBudgetTracker {
|
|
457
|
+
/** Check and record a handshake message. Returns null if allowed, rejection reason if blocked. */
|
|
458
|
+
checkAndRecord(params: {
|
|
459
|
+
correlationId: string;
|
|
460
|
+
fromDid: string;
|
|
461
|
+
messageType: "intent" | "challenge" | "rejection" | "resolution";
|
|
462
|
+
intentExpiresAt?: string;
|
|
463
|
+
}): {
|
|
464
|
+
allowed: boolean;
|
|
465
|
+
reason?: string;
|
|
466
|
+
backoffHint?: InkBackoffHint;
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
/** Prune expired correlation state. Call from maintenance. */
|
|
470
|
+
pruneExpired(): void;
|
|
471
|
+
}
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
Storage: in-memory within the per-agent state container. Correlation state is keyed by `correlationId` and tracks message counts and timestamps. Per-sender state is keyed by `fromDid` with sliding window counters.
|
|
475
|
+
|
|
476
|
+
Memory bounds: max 10,000 active correlations tracked. Oldest entries evicted on overflow (LRU). Per-sender windows use fixed-size circular buffers (60 slots for per-minute, 60 slots for per-hour).
|
|
477
|
+
|
|
478
|
+
#### Where budget checks run
|
|
479
|
+
|
|
480
|
+
Budget checks run **after** signature verification but **before** handshake processing:
|
|
481
|
+
|
|
482
|
+
1. Verify INK auth signature (existing)
|
|
483
|
+
2. Parse message type and correlationId
|
|
484
|
+
3. `budgetTracker.checkAndRecord(...)`, if rejected, return typed rejection with backoff hint
|
|
485
|
+
4. Proceed to handshake processing (existing)
|
|
486
|
+
|
|
487
|
+
This ordering ensures:
|
|
488
|
+
- Unsigned/forged messages never consume budget (no amplification)
|
|
489
|
+
- Valid but excessive messages get typed rejections
|
|
490
|
+
- Processing resources are protected
|
|
491
|
+
|
|
492
|
+
#### Silent drop vs. typed rejection
|
|
493
|
+
|
|
494
|
+
The canonical rule is defined in §3.1: first violation returns a typed rejection with backoff hint; subsequent violations are silent drops. The budget tracker maintains a `rejectionSent: Set<string>` keyed by `${correlationId}:${fromDid}` to distinguish first from subsequent violations.
|
|
495
|
+
|
|
496
|
+
### Tests
|
|
497
|
+
|
|
498
|
+
| Test | Description |
|
|
499
|
+
|------|-------------|
|
|
500
|
+
| 3 challenges on same correlationId accepted | Within budget |
|
|
501
|
+
| 4th challenge on same correlationId rejected | Budget exhausted |
|
|
502
|
+
| Rejection is terminal, no further messages accepted | Terminal state |
|
|
503
|
+
| Resolution is terminal, no further messages accepted | Terminal state |
|
|
504
|
+
| Total state transitions capped at 5 | Hard cap |
|
|
505
|
+
| Expired handshake rejects new messages | TTL enforcement |
|
|
506
|
+
| Per-sender minute rate limit triggers after 10 intents | Sender limit |
|
|
507
|
+
| Per-sender hour rate limit triggers after 60 intents | Sender limit |
|
|
508
|
+
| Backoff hint included in rejection response | Advisory signaling |
|
|
509
|
+
| Second violation after rejection is silent drop | No amplification |
|
|
510
|
+
| Budget tracker prunes expired correlations | Memory management |
|
|
511
|
+
| LRU eviction at 10k correlations | Memory bounds |
|
|
512
|
+
| Audit event emitted on rate limit | Observability |
|
|
513
|
+
| Connected peers get higher limits (if configured) | Trust-tier awareness |
|
|
514
|
+
|
|
515
|
+
---
|
|
516
|
+
|
|
517
|
+
## Cross-Cutting: Agent Card Governance Fields
|
|
518
|
+
|
|
519
|
+
All three slices benefit from advertising governance capabilities in the Agent Card. Add to the Agent Card schema:
|
|
520
|
+
|
|
521
|
+
```typescript
|
|
522
|
+
interface InkGovernanceCapabilities {
|
|
523
|
+
maxAcceptedDelegationDepth?: number;
|
|
524
|
+
supportedTransports?: InkTransport[];
|
|
525
|
+
supportsCapabilityGatedDiscovery?: boolean;
|
|
526
|
+
handshakeBudget?: {
|
|
527
|
+
maxChallengesPerCorrelation?: number;
|
|
528
|
+
maxIntentsPerMinute?: number;
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
This is added as an optional `governance` field on `TulpaAgentCard`:
|
|
534
|
+
|
|
535
|
+
```typescript
|
|
536
|
+
interface TulpaAgentCard {
|
|
537
|
+
// ... existing fields ...
|
|
538
|
+
governance?: InkGovernanceCapabilities;
|
|
539
|
+
}
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
Senders can read the recipient's governance fields to pre-filter behavior (e.g., don't send a delegation chain deeper than `maxAcceptedDelegationDepth`, respect `supportedTransports`).
|
|
543
|
+
|
|
544
|
+
---
|
|
545
|
+
|
|
546
|
+
## Implementation Order
|
|
547
|
+
|
|
548
|
+
```
|
|
549
|
+
Step 1: Schema changes, add all new types, enums, audit events
|
|
550
|
+
Step 2: Transport-bound authorization (tests first)
|
|
551
|
+
2a. InkTransportSchema, constraints extension
|
|
552
|
+
2b. Transport context tagging in middleware
|
|
553
|
+
2c. Chain verifier transport check
|
|
554
|
+
2d. Audit event emission
|
|
555
|
+
Step 3: Handshake flood resistance (tests first)
|
|
556
|
+
3a. HandshakeBudgetTracker with per-correlation and per-sender limits
|
|
557
|
+
3b. Wire into handshake pipeline (after auth, before processing)
|
|
558
|
+
3c. Typed rejections with backoff hints
|
|
559
|
+
3d. Silent drop on repeated violations
|
|
560
|
+
3e. Audit event emission
|
|
561
|
+
Step 4: Capability-gated Agent Card discovery (tests first)
|
|
562
|
+
4a. Redacted card generation
|
|
563
|
+
4b. GET endpoint conditional response
|
|
564
|
+
4c. POST agent-card-query endpoint
|
|
565
|
+
4d. Access policy evaluation
|
|
566
|
+
4e. Audit event emission
|
|
567
|
+
Step 5: Agent Card governance fields
|
|
568
|
+
Step 6: Integration tests, all three slices together
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
Transport-bound authorization is first because it is the simplest wire change and immediately closes the confused-deputy gap. Handshake flood resistance is second because it protects the existing handshake surface. Discovery gating is third because it has more moving parts (new endpoint, access policy) but lower urgency.
|
|
572
|
+
|
|
573
|
+
---
|
|
574
|
+
|
|
575
|
+
## Verification
|
|
576
|
+
|
|
577
|
+
- `npx vitest run test/ink-transport-auth.test.ts`
|
|
578
|
+
- `npx vitest run test/ink-handshake-budget.test.ts`
|
|
579
|
+
- `npx vitest run test/ink-discovery-gating.test.ts`
|
|
580
|
+
- `npx vitest run`, all existing tests still pass
|
|
581
|
+
- `npx tsc --noEmit`, no new type errors
|
|
582
|
+
- Manual: issue delegation token with `allowedTransports: ["ink_http"]`, verify it is rejected on extension API call
|
|
583
|
+
- Manual: set agent visibility to `capability_gated`, verify GET returns redacted card, authenticated query returns full card
|
|
584
|
+
- Manual: send 4 challenges on same correlationId, verify 4th is rejected with `handshake_budget_exhausted`
|
|
585
|
+
|
|
586
|
+
---
|
|
587
|
+
|
|
588
|
+
## Dependencies
|
|
589
|
+
|
|
590
|
+
- No external dependencies required
|
|
591
|
+
- No new npm packages
|
|
592
|
+
- All crypto uses existing `@noble/ed25519` and JCS canonicalization
|
|
593
|
+
- Budget tracker is pure in-memory (per-agent state)
|