@eddacraft/anvil-kindling-integration 0.1.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/LICENSE +14 -0
- package/README.md +542 -0
- package/dist/adapter.d.ts +49 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +100 -0
- package/dist/config.d.ts +89 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +173 -0
- package/dist/emitters/action-emitter.d.ts +40 -0
- package/dist/emitters/action-emitter.d.ts.map +1 -0
- package/dist/emitters/action-emitter.js +52 -0
- package/dist/emitters/constraint-emitter.d.ts +32 -0
- package/dist/emitters/constraint-emitter.d.ts.map +1 -0
- package/dist/emitters/constraint-emitter.js +41 -0
- package/dist/emitters/error-emitter.d.ts +33 -0
- package/dist/emitters/error-emitter.d.ts.map +1 -0
- package/dist/emitters/error-emitter.js +50 -0
- package/dist/emitters/gate-emitter.d.ts +37 -0
- package/dist/emitters/gate-emitter.d.ts.map +1 -0
- package/dist/emitters/gate-emitter.js +53 -0
- package/dist/emitters/human-input-emitter.d.ts +30 -0
- package/dist/emitters/human-input-emitter.d.ts.map +1 -0
- package/dist/emitters/human-input-emitter.js +38 -0
- package/dist/emitters/index.d.ts +13 -0
- package/dist/emitters/index.d.ts.map +1 -0
- package/dist/emitters/index.js +19 -0
- package/dist/emitters/plan-emitter.d.ts +75 -0
- package/dist/emitters/plan-emitter.d.ts.map +1 -0
- package/dist/emitters/plan-emitter.js +116 -0
- package/dist/emitters/session-emitter.d.ts +57 -0
- package/dist/emitters/session-emitter.d.ts.map +1 -0
- package/dist/emitters/session-emitter.js +80 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +111 -0
- package/dist/kindling-service.d.ts +122 -0
- package/dist/kindling-service.d.ts.map +1 -0
- package/dist/kindling-service.js +203 -0
- package/dist/observation-contract.d.ts +561 -0
- package/dist/observation-contract.d.ts.map +1 -0
- package/dist/observation-contract.js +391 -0
- package/dist/query-contract.d.ts +463 -0
- package/dist/query-contract.d.ts.map +1 -0
- package/dist/query-contract.js +314 -0
- package/dist/query-limits.d.ts +40 -0
- package/dist/query-limits.d.ts.map +1 -0
- package/dist/query-limits.js +79 -0
- package/dist/query-service.d.ts +109 -0
- package/dist/query-service.d.ts.map +1 -0
- package/dist/query-service.js +140 -0
- package/dist/retention.d.ts +79 -0
- package/dist/retention.d.ts.map +1 -0
- package/dist/retention.js +81 -0
- package/dist/sensitive-data-validator.d.ts +47 -0
- package/dist/sensitive-data-validator.d.ts.map +1 -0
- package/dist/sensitive-data-validator.js +135 -0
- package/dist/status.d.ts +104 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.js +136 -0
- package/dist/utils/debug.d.ts +9 -0
- package/dist/utils/debug.d.ts.map +1 -0
- package/dist/utils/debug.js +55 -0
- package/package.json +114 -0
- package/src/adapter.ts +117 -0
- package/src/config.ts +202 -0
- package/src/emitters/action-emitter.ts +90 -0
- package/src/emitters/constraint-emitter.ts +73 -0
- package/src/emitters/error-emitter.ts +86 -0
- package/src/emitters/gate-emitter.ts +87 -0
- package/src/emitters/human-input-emitter.ts +71 -0
- package/src/emitters/index.ts +40 -0
- package/src/emitters/plan-emitter.ts +183 -0
- package/src/emitters/session-emitter.ts +131 -0
- package/src/index.ts +254 -0
- package/src/kindling-service.ts +272 -0
- package/src/malicious-ai.test.ts +949 -0
- package/src/observation-contract.ts +500 -0
- package/src/query-contract.ts +389 -0
- package/src/query-limits.ts +106 -0
- package/src/query-service.ts +217 -0
- package/src/retention.ts +153 -0
- package/src/sensitive-data-validator.ts +167 -0
- package/src/status.ts +221 -0
- package/src/utils/debug.ts +65 -0
|
@@ -0,0 +1,949 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Malicious AI Test Suite (KINDLING-011)
|
|
3
|
+
*
|
|
4
|
+
* Proves read-only enforcement by testing that:
|
|
5
|
+
* 1. No write/update/delete operations exist on query interfaces
|
|
6
|
+
* 2. Global queries (no scope/ID) are rejected
|
|
7
|
+
* 3. Queries exceeding limits are truncated
|
|
8
|
+
* 4. Observations with sensitive data are caught
|
|
9
|
+
* 5. Invalid scopes and free-text search are rejected
|
|
10
|
+
*
|
|
11
|
+
* These tests prove Kindling is LLM-safe by construction.
|
|
12
|
+
* If user AI wants memory, it must bring its own store.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect } from 'vitest';
|
|
16
|
+
import {
|
|
17
|
+
// Observation contract
|
|
18
|
+
validateObservation,
|
|
19
|
+
containsSensitiveData,
|
|
20
|
+
ObservationSchema,
|
|
21
|
+
type Observation,
|
|
22
|
+
|
|
23
|
+
// Query contract
|
|
24
|
+
validateQueryRequest,
|
|
25
|
+
validateQueryResponse,
|
|
26
|
+
QueryRequestSchema,
|
|
27
|
+
SessionQuerySchema,
|
|
28
|
+
PlanQuerySchema,
|
|
29
|
+
GateQuerySchema,
|
|
30
|
+
ActionQuerySchema,
|
|
31
|
+
type QueryRequest,
|
|
32
|
+
type QueryResponse,
|
|
33
|
+
} from './index.js';
|
|
34
|
+
|
|
35
|
+
// =============================================================================
|
|
36
|
+
// Test Helpers
|
|
37
|
+
// =============================================================================
|
|
38
|
+
|
|
39
|
+
const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000';
|
|
40
|
+
const VALID_TIMESTAMP = '2026-02-15T10:00:00.000Z';
|
|
41
|
+
|
|
42
|
+
function makeValidSessionQuery(): QueryRequest {
|
|
43
|
+
return {
|
|
44
|
+
scope: 'session',
|
|
45
|
+
session_id: VALID_UUID,
|
|
46
|
+
shape: 'timeline',
|
|
47
|
+
format: 'json',
|
|
48
|
+
max_results: 100,
|
|
49
|
+
max_payload_bytes: 1024 * 1024,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function makeValidQueryResponse(
|
|
54
|
+
overrides: Partial<{
|
|
55
|
+
result_count: number;
|
|
56
|
+
truncated: boolean;
|
|
57
|
+
truncation_reason: 'max_results' | 'max_payload_bytes' | 'none';
|
|
58
|
+
observations: unknown[];
|
|
59
|
+
}> = {}
|
|
60
|
+
): QueryResponse {
|
|
61
|
+
return {
|
|
62
|
+
metadata: {
|
|
63
|
+
query_id: VALID_UUID,
|
|
64
|
+
executed_at: VALID_TIMESTAMP,
|
|
65
|
+
contract_version: '1.0.0',
|
|
66
|
+
result_count: overrides.result_count ?? 0,
|
|
67
|
+
truncated: overrides.truncated ?? false,
|
|
68
|
+
truncation_reason: overrides.truncation_reason ?? 'none',
|
|
69
|
+
},
|
|
70
|
+
observations: (overrides.observations ?? []) as QueryResponse['observations'],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function makeValidSessionStartObservation(): Observation {
|
|
75
|
+
return {
|
|
76
|
+
kind: 'session_start',
|
|
77
|
+
session_id: VALID_UUID,
|
|
78
|
+
timestamp: VALID_TIMESTAMP,
|
|
79
|
+
context: {
|
|
80
|
+
working_directory: '/home/user/project',
|
|
81
|
+
anvil_version: '1.0.0',
|
|
82
|
+
command: 'anvil check',
|
|
83
|
+
args: ['--watch'],
|
|
84
|
+
environment: 'development',
|
|
85
|
+
},
|
|
86
|
+
} as Observation;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// =============================================================================
|
|
90
|
+
// 1. Read-Only Enforcement: No Write/Update/Delete Operations
|
|
91
|
+
// =============================================================================
|
|
92
|
+
|
|
93
|
+
describe('Read-only enforcement (anti-mutation)', () => {
|
|
94
|
+
it('QueryRequest schema has no write operation field', () => {
|
|
95
|
+
const queryWithWrite = {
|
|
96
|
+
...makeValidSessionQuery(),
|
|
97
|
+
operation: 'write',
|
|
98
|
+
};
|
|
99
|
+
// The extra field should be stripped or ignored by Zod,
|
|
100
|
+
// but the query should still only allow read semantics
|
|
101
|
+
const result = QueryRequestSchema.safeParse(queryWithWrite);
|
|
102
|
+
if (result.success) {
|
|
103
|
+
// Even if parsing succeeds, the result should not have 'operation'
|
|
104
|
+
expect((result.data as Record<string, unknown>)['operation']).toBeUndefined();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('QueryRequest does not accept write() method signature', () => {
|
|
109
|
+
// Attempt to disguise a write as a query
|
|
110
|
+
const maliciousQuery = {
|
|
111
|
+
scope: 'session',
|
|
112
|
+
session_id: VALID_UUID,
|
|
113
|
+
shape: 'timeline',
|
|
114
|
+
write: { kind: 'session_start', data: 'injected' },
|
|
115
|
+
};
|
|
116
|
+
const result = QueryRequestSchema.safeParse(maliciousQuery);
|
|
117
|
+
if (result.success) {
|
|
118
|
+
expect((result.data as Record<string, unknown>)['write']).toBeUndefined();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('QueryRequest does not accept update() method signature', () => {
|
|
123
|
+
const maliciousQuery = {
|
|
124
|
+
scope: 'session',
|
|
125
|
+
session_id: VALID_UUID,
|
|
126
|
+
shape: 'timeline',
|
|
127
|
+
update: { observation_id: VALID_UUID, payload: { modified: true } },
|
|
128
|
+
};
|
|
129
|
+
const result = QueryRequestSchema.safeParse(maliciousQuery);
|
|
130
|
+
if (result.success) {
|
|
131
|
+
expect((result.data as Record<string, unknown>)['update']).toBeUndefined();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('QueryRequest does not accept delete() method signature', () => {
|
|
136
|
+
const maliciousQuery = {
|
|
137
|
+
scope: 'session',
|
|
138
|
+
session_id: VALID_UUID,
|
|
139
|
+
shape: 'timeline',
|
|
140
|
+
delete: { observation_id: VALID_UUID },
|
|
141
|
+
};
|
|
142
|
+
const result = QueryRequestSchema.safeParse(maliciousQuery);
|
|
143
|
+
if (result.success) {
|
|
144
|
+
expect((result.data as Record<string, unknown>)['delete']).toBeUndefined();
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('QueryRequest does not accept annotate()', () => {
|
|
149
|
+
const maliciousQuery = {
|
|
150
|
+
scope: 'gate',
|
|
151
|
+
gate_eval_id: 'gate-001',
|
|
152
|
+
shape: 'entity',
|
|
153
|
+
annotate: { note: 'AI thinks this gate should have passed' },
|
|
154
|
+
};
|
|
155
|
+
const result = QueryRequestSchema.safeParse(maliciousQuery);
|
|
156
|
+
if (result.success) {
|
|
157
|
+
expect((result.data as Record<string, unknown>)['annotate']).toBeUndefined();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('QueryRequest does not accept tag()', () => {
|
|
162
|
+
const maliciousQuery = {
|
|
163
|
+
scope: 'action',
|
|
164
|
+
action_id: 'action-001',
|
|
165
|
+
shape: 'entity',
|
|
166
|
+
tag: ['important', 'review-needed'],
|
|
167
|
+
};
|
|
168
|
+
const result = QueryRequestSchema.safeParse(maliciousQuery);
|
|
169
|
+
if (result.success) {
|
|
170
|
+
expect((result.data as Record<string, unknown>)['tag']).toBeUndefined();
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('QueryRequest does not accept learn()', () => {
|
|
175
|
+
const maliciousQuery = {
|
|
176
|
+
scope: 'session',
|
|
177
|
+
session_id: VALID_UUID,
|
|
178
|
+
shape: 'timeline',
|
|
179
|
+
learn: { pattern: 'gate failures on Mondays', confidence: 0.8 },
|
|
180
|
+
};
|
|
181
|
+
const result = QueryRequestSchema.safeParse(maliciousQuery);
|
|
182
|
+
if (result.success) {
|
|
183
|
+
expect((result.data as Record<string, unknown>)['learn']).toBeUndefined();
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('QueryRequest does not accept embed()', () => {
|
|
188
|
+
const maliciousQuery = {
|
|
189
|
+
scope: 'session',
|
|
190
|
+
session_id: VALID_UUID,
|
|
191
|
+
shape: 'timeline',
|
|
192
|
+
embed: { model: 'text-embedding-3-large', store_embeddings: true },
|
|
193
|
+
};
|
|
194
|
+
const result = QueryRequestSchema.safeParse(maliciousQuery);
|
|
195
|
+
if (result.success) {
|
|
196
|
+
expect((result.data as Record<string, unknown>)['embed']).toBeUndefined();
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('QueryRequest does not accept infer()', () => {
|
|
201
|
+
const maliciousQuery = {
|
|
202
|
+
scope: 'session',
|
|
203
|
+
session_id: VALID_UUID,
|
|
204
|
+
shape: 'timeline',
|
|
205
|
+
infer: { prompt: 'What patterns do you see?', store_result: true },
|
|
206
|
+
};
|
|
207
|
+
const result = QueryRequestSchema.safeParse(maliciousQuery);
|
|
208
|
+
if (result.success) {
|
|
209
|
+
expect((result.data as Record<string, unknown>)['infer']).toBeUndefined();
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('Observation contract exports only validate + containsSensitiveData (no mutators)', () => {
|
|
214
|
+
// Verify the exported validation functions exist and work
|
|
215
|
+
expect(typeof validateObservation).toBe('function');
|
|
216
|
+
expect(typeof containsSensitiveData).toBe('function');
|
|
217
|
+
|
|
218
|
+
// Verify no write/update/delete functions are exported from the module
|
|
219
|
+
// We check by ensuring the contract is purely declarative
|
|
220
|
+
const obs = makeValidSessionStartObservation();
|
|
221
|
+
const validation = validateObservation(obs);
|
|
222
|
+
expect(validation.success).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// =============================================================================
|
|
227
|
+
// 2. Global Query Rejection (Mandatory Scoping)
|
|
228
|
+
// =============================================================================
|
|
229
|
+
|
|
230
|
+
describe('Global query rejection (mandatory scoping)', () => {
|
|
231
|
+
it('rejects query with no scope', () => {
|
|
232
|
+
const globalQuery = {
|
|
233
|
+
shape: 'list',
|
|
234
|
+
format: 'json',
|
|
235
|
+
max_results: 100,
|
|
236
|
+
};
|
|
237
|
+
const result = validateQueryRequest(globalQuery);
|
|
238
|
+
expect(result.success).toBe(false);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('rejects query with invalid scope "global"', () => {
|
|
242
|
+
const globalQuery = {
|
|
243
|
+
scope: 'global',
|
|
244
|
+
shape: 'list',
|
|
245
|
+
format: 'json',
|
|
246
|
+
max_results: 100,
|
|
247
|
+
};
|
|
248
|
+
const result = validateQueryRequest(globalQuery);
|
|
249
|
+
expect(result.success).toBe(false);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('rejects query with invalid scope "all"', () => {
|
|
253
|
+
const allQuery = {
|
|
254
|
+
scope: 'all',
|
|
255
|
+
shape: 'list',
|
|
256
|
+
format: 'json',
|
|
257
|
+
max_results: 100,
|
|
258
|
+
};
|
|
259
|
+
const result = validateQueryRequest(allQuery);
|
|
260
|
+
expect(result.success).toBe(false);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('rejects query with invalid scope "search"', () => {
|
|
264
|
+
const searchQuery = {
|
|
265
|
+
scope: 'search',
|
|
266
|
+
query: 'find all gate failures',
|
|
267
|
+
shape: 'list',
|
|
268
|
+
};
|
|
269
|
+
const result = validateQueryRequest(searchQuery);
|
|
270
|
+
expect(result.success).toBe(false);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('rejects session query without session_id', () => {
|
|
274
|
+
const noIdQuery = {
|
|
275
|
+
scope: 'session',
|
|
276
|
+
shape: 'timeline',
|
|
277
|
+
format: 'json',
|
|
278
|
+
};
|
|
279
|
+
const result = SessionQuerySchema.safeParse(noIdQuery);
|
|
280
|
+
expect(result.success).toBe(false);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('rejects plan query without plan_id', () => {
|
|
284
|
+
const noIdQuery = {
|
|
285
|
+
scope: 'plan',
|
|
286
|
+
shape: 'entity',
|
|
287
|
+
format: 'json',
|
|
288
|
+
};
|
|
289
|
+
const result = PlanQuerySchema.safeParse(noIdQuery);
|
|
290
|
+
expect(result.success).toBe(false);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('rejects gate query without gate_eval_id', () => {
|
|
294
|
+
const noIdQuery = {
|
|
295
|
+
scope: 'gate',
|
|
296
|
+
shape: 'entity',
|
|
297
|
+
format: 'json',
|
|
298
|
+
};
|
|
299
|
+
const result = GateQuerySchema.safeParse(noIdQuery);
|
|
300
|
+
expect(result.success).toBe(false);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('rejects action query without action_id', () => {
|
|
304
|
+
const noIdQuery = {
|
|
305
|
+
scope: 'action',
|
|
306
|
+
shape: 'entity',
|
|
307
|
+
format: 'json',
|
|
308
|
+
};
|
|
309
|
+
const result = ActionQuerySchema.safeParse(noIdQuery);
|
|
310
|
+
expect(result.success).toBe(false);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('rejects free-text search query', () => {
|
|
314
|
+
const freeTextQuery = {
|
|
315
|
+
scope: 'session',
|
|
316
|
+
session_id: VALID_UUID,
|
|
317
|
+
shape: 'timeline',
|
|
318
|
+
free_text: 'gate failures last week',
|
|
319
|
+
};
|
|
320
|
+
const result = QueryRequestSchema.safeParse(freeTextQuery);
|
|
321
|
+
if (result.success) {
|
|
322
|
+
// Even if it parses, the free_text field must not be in the result
|
|
323
|
+
expect((result.data as Record<string, unknown>)['free_text']).toBeUndefined();
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('rejects semantic search query', () => {
|
|
328
|
+
const semanticQuery = {
|
|
329
|
+
scope: 'session',
|
|
330
|
+
session_id: VALID_UUID,
|
|
331
|
+
shape: 'list',
|
|
332
|
+
semantic_search: 'similar gate failures',
|
|
333
|
+
embedding_model: 'text-embedding-3-large',
|
|
334
|
+
};
|
|
335
|
+
const result = QueryRequestSchema.safeParse(semanticQuery);
|
|
336
|
+
if (result.success) {
|
|
337
|
+
expect((result.data as Record<string, unknown>)['semantic_search']).toBeUndefined();
|
|
338
|
+
expect((result.data as Record<string, unknown>)['embedding_model']).toBeUndefined();
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('rejects cross-project query scope', () => {
|
|
343
|
+
const crossProjectQuery = {
|
|
344
|
+
scope: 'cross_project',
|
|
345
|
+
project_ids: ['project-1', 'project-2'],
|
|
346
|
+
shape: 'list',
|
|
347
|
+
};
|
|
348
|
+
const result = validateQueryRequest(crossProjectQuery);
|
|
349
|
+
expect(result.success).toBe(false);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('only allows 4 valid scopes: session, plan, gate, action', () => {
|
|
353
|
+
const validScopes = ['session', 'plan', 'gate', 'action'];
|
|
354
|
+
const invalidScopes = [
|
|
355
|
+
'global',
|
|
356
|
+
'all',
|
|
357
|
+
'search',
|
|
358
|
+
'cross_project',
|
|
359
|
+
'similarity',
|
|
360
|
+
'embedding',
|
|
361
|
+
'pattern',
|
|
362
|
+
'trend',
|
|
363
|
+
'anomaly',
|
|
364
|
+
];
|
|
365
|
+
|
|
366
|
+
for (const scope of invalidScopes) {
|
|
367
|
+
const query = { scope, shape: 'list', format: 'json' };
|
|
368
|
+
const result = validateQueryRequest(query);
|
|
369
|
+
expect(result.success).toBe(false);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Valid scopes need their required IDs
|
|
373
|
+
for (const scope of validScopes) {
|
|
374
|
+
const query = { scope, shape: 'list', format: 'json' };
|
|
375
|
+
// These will fail due to missing IDs, which is correct -- scope alone is not enough
|
|
376
|
+
const result = validateQueryRequest(query);
|
|
377
|
+
// session/gate/action should fail (missing required ID)
|
|
378
|
+
// plan might also fail (missing plan_id)
|
|
379
|
+
if (scope !== 'plan') {
|
|
380
|
+
expect(result.success).toBe(false);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// =============================================================================
|
|
387
|
+
// 3. Query Limits Enforcement (Anti-Vacuum-Cleaner)
|
|
388
|
+
// =============================================================================
|
|
389
|
+
|
|
390
|
+
describe('Query limits enforcement (anti-vacuum-cleaner)', () => {
|
|
391
|
+
it('max_results cannot exceed 1000', () => {
|
|
392
|
+
const query = {
|
|
393
|
+
scope: 'session',
|
|
394
|
+
session_id: VALID_UUID,
|
|
395
|
+
shape: 'timeline',
|
|
396
|
+
max_results: 10000,
|
|
397
|
+
};
|
|
398
|
+
const result = SessionQuerySchema.safeParse(query);
|
|
399
|
+
expect(result.success).toBe(false);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('max_results must be positive', () => {
|
|
403
|
+
const query = {
|
|
404
|
+
scope: 'session',
|
|
405
|
+
session_id: VALID_UUID,
|
|
406
|
+
shape: 'timeline',
|
|
407
|
+
max_results: 0,
|
|
408
|
+
};
|
|
409
|
+
const result = SessionQuerySchema.safeParse(query);
|
|
410
|
+
expect(result.success).toBe(false);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it('max_results must be an integer', () => {
|
|
414
|
+
const query = {
|
|
415
|
+
scope: 'session',
|
|
416
|
+
session_id: VALID_UUID,
|
|
417
|
+
shape: 'timeline',
|
|
418
|
+
max_results: 50.5,
|
|
419
|
+
};
|
|
420
|
+
const result = SessionQuerySchema.safeParse(query);
|
|
421
|
+
expect(result.success).toBe(false);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('max_payload_bytes cannot exceed 10MB', () => {
|
|
425
|
+
const query = {
|
|
426
|
+
scope: 'session',
|
|
427
|
+
session_id: VALID_UUID,
|
|
428
|
+
shape: 'timeline',
|
|
429
|
+
max_payload_bytes: 100 * 1024 * 1024, // 100MB
|
|
430
|
+
};
|
|
431
|
+
const result = SessionQuerySchema.safeParse(query);
|
|
432
|
+
expect(result.success).toBe(false);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('max_payload_bytes must be positive', () => {
|
|
436
|
+
const query = {
|
|
437
|
+
scope: 'session',
|
|
438
|
+
session_id: VALID_UUID,
|
|
439
|
+
shape: 'timeline',
|
|
440
|
+
max_payload_bytes: 0,
|
|
441
|
+
};
|
|
442
|
+
const result = SessionQuerySchema.safeParse(query);
|
|
443
|
+
expect(result.success).toBe(false);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('defaults max_results to 100 when not specified', () => {
|
|
447
|
+
const query = {
|
|
448
|
+
scope: 'session',
|
|
449
|
+
session_id: VALID_UUID,
|
|
450
|
+
shape: 'timeline',
|
|
451
|
+
};
|
|
452
|
+
const result = SessionQuerySchema.safeParse(query);
|
|
453
|
+
expect(result.success).toBe(true);
|
|
454
|
+
if (result.success) {
|
|
455
|
+
expect(result.data.max_results).toBe(100);
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('defaults max_payload_bytes to 1MB when not specified', () => {
|
|
460
|
+
const query = {
|
|
461
|
+
scope: 'session',
|
|
462
|
+
session_id: VALID_UUID,
|
|
463
|
+
shape: 'timeline',
|
|
464
|
+
};
|
|
465
|
+
const result = SessionQuerySchema.safeParse(query);
|
|
466
|
+
expect(result.success).toBe(true);
|
|
467
|
+
if (result.success) {
|
|
468
|
+
expect(result.data.max_payload_bytes).toBe(1024 * 1024);
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it('QueryResponse tracks truncation status', () => {
|
|
473
|
+
const truncatedResponse = makeValidQueryResponse({
|
|
474
|
+
result_count: 1000,
|
|
475
|
+
truncated: true,
|
|
476
|
+
truncation_reason: 'max_results',
|
|
477
|
+
});
|
|
478
|
+
const result = validateQueryResponse(truncatedResponse);
|
|
479
|
+
expect(result.success).toBe(true);
|
|
480
|
+
if (result.success && result.data) {
|
|
481
|
+
expect(result.data.metadata.truncated).toBe(true);
|
|
482
|
+
expect(result.data.metadata.truncation_reason).toBe('max_results');
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('QueryResponse tracks payload bytes truncation', () => {
|
|
487
|
+
const truncatedResponse = makeValidQueryResponse({
|
|
488
|
+
result_count: 50,
|
|
489
|
+
truncated: true,
|
|
490
|
+
truncation_reason: 'max_payload_bytes',
|
|
491
|
+
});
|
|
492
|
+
const result = validateQueryResponse(truncatedResponse);
|
|
493
|
+
expect(result.success).toBe(true);
|
|
494
|
+
if (result.success && result.data) {
|
|
495
|
+
expect(result.data.metadata.truncated).toBe(true);
|
|
496
|
+
expect(result.data.metadata.truncation_reason).toBe('max_payload_bytes');
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it('accepts queries at exactly the max_results limit (1000)', () => {
|
|
501
|
+
const query = {
|
|
502
|
+
scope: 'session',
|
|
503
|
+
session_id: VALID_UUID,
|
|
504
|
+
shape: 'timeline',
|
|
505
|
+
max_results: 1000,
|
|
506
|
+
};
|
|
507
|
+
const result = SessionQuerySchema.safeParse(query);
|
|
508
|
+
expect(result.success).toBe(true);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('accepts queries at exactly the max_payload_bytes limit (10MB)', () => {
|
|
512
|
+
const query = {
|
|
513
|
+
scope: 'session',
|
|
514
|
+
session_id: VALID_UUID,
|
|
515
|
+
shape: 'timeline',
|
|
516
|
+
max_payload_bytes: 10 * 1024 * 1024,
|
|
517
|
+
};
|
|
518
|
+
const result = SessionQuerySchema.safeParse(query);
|
|
519
|
+
expect(result.success).toBe(true);
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
// =============================================================================
|
|
524
|
+
// 4. Sensitive Data Detection
|
|
525
|
+
// =============================================================================
|
|
526
|
+
|
|
527
|
+
describe('Sensitive data detection in observations', () => {
|
|
528
|
+
it('detects passwords in observation payload', () => {
|
|
529
|
+
const obs: Observation = {
|
|
530
|
+
kind: 'action_executed',
|
|
531
|
+
session_id: VALID_UUID,
|
|
532
|
+
timestamp: VALID_TIMESTAMP,
|
|
533
|
+
action_id: 'action-001',
|
|
534
|
+
action_type: 'command',
|
|
535
|
+
details: {
|
|
536
|
+
command: 'mysql -u root --password=secret123',
|
|
537
|
+
working_directory: '/home/user',
|
|
538
|
+
},
|
|
539
|
+
outcome: 'success',
|
|
540
|
+
duration_ms: 100,
|
|
541
|
+
} as Observation;
|
|
542
|
+
|
|
543
|
+
const result = containsSensitiveData(obs);
|
|
544
|
+
expect(result.hasSensitiveData).toBe(true);
|
|
545
|
+
expect(result.issues.length).toBeGreaterThan(0);
|
|
546
|
+
expect(result.issues.some((i) => /password|token|key/i.test(i))).toBe(true);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it('detects API keys in observation payload', () => {
|
|
550
|
+
const obs: Observation = {
|
|
551
|
+
kind: 'action_executed',
|
|
552
|
+
session_id: VALID_UUID,
|
|
553
|
+
timestamp: VALID_TIMESTAMP,
|
|
554
|
+
action_id: 'action-002',
|
|
555
|
+
action_type: 'command',
|
|
556
|
+
details: {
|
|
557
|
+
command: 'curl -H "Authorization: Bearer sk-abc123api_key_here"',
|
|
558
|
+
working_directory: '/home/user',
|
|
559
|
+
},
|
|
560
|
+
outcome: 'success',
|
|
561
|
+
duration_ms: 50,
|
|
562
|
+
} as Observation;
|
|
563
|
+
|
|
564
|
+
const result = containsSensitiveData(obs);
|
|
565
|
+
expect(result.hasSensitiveData).toBe(true);
|
|
566
|
+
expect(result.issues.some((i) => /password|token|key/i.test(i))).toBe(true);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it('detects AWS credentials in observation payload', () => {
|
|
570
|
+
const obs: Observation = {
|
|
571
|
+
kind: 'action_executed',
|
|
572
|
+
session_id: VALID_UUID,
|
|
573
|
+
timestamp: VALID_TIMESTAMP,
|
|
574
|
+
action_id: 'action-003',
|
|
575
|
+
action_type: 'command',
|
|
576
|
+
details: {
|
|
577
|
+
command: 'export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE',
|
|
578
|
+
working_directory: '/home/user',
|
|
579
|
+
},
|
|
580
|
+
outcome: 'success',
|
|
581
|
+
duration_ms: 10,
|
|
582
|
+
} as Observation;
|
|
583
|
+
|
|
584
|
+
const result = containsSensitiveData(obs);
|
|
585
|
+
expect(result.hasSensitiveData).toBe(true);
|
|
586
|
+
expect(result.issues.some((i) => /AWS/i.test(i))).toBe(true);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it('detects email addresses in observation payload', () => {
|
|
590
|
+
const obs: Observation = {
|
|
591
|
+
kind: 'human_input',
|
|
592
|
+
session_id: VALID_UUID,
|
|
593
|
+
timestamp: VALID_TIMESTAMP,
|
|
594
|
+
input_type: 'approval',
|
|
595
|
+
context: {
|
|
596
|
+
prompt: 'Approve deployment?',
|
|
597
|
+
},
|
|
598
|
+
decision: 'approved',
|
|
599
|
+
user_identifier: 'admin@company.com',
|
|
600
|
+
} as Observation;
|
|
601
|
+
|
|
602
|
+
const result = containsSensitiveData(obs);
|
|
603
|
+
expect(result.hasSensitiveData).toBe(true);
|
|
604
|
+
expect(result.issues.some((i) => /email/i.test(i))).toBe(true);
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it('detects private keys in observation payload', () => {
|
|
608
|
+
const obs: Observation = {
|
|
609
|
+
kind: 'action_executed',
|
|
610
|
+
session_id: VALID_UUID,
|
|
611
|
+
timestamp: VALID_TIMESTAMP,
|
|
612
|
+
action_id: 'action-004',
|
|
613
|
+
action_type: 'command',
|
|
614
|
+
details: {
|
|
615
|
+
command: 'ssh -i ~/.ssh/private_key user@host',
|
|
616
|
+
working_directory: '/home/user',
|
|
617
|
+
},
|
|
618
|
+
outcome: 'success',
|
|
619
|
+
duration_ms: 5000,
|
|
620
|
+
} as Observation;
|
|
621
|
+
|
|
622
|
+
const result = containsSensitiveData(obs);
|
|
623
|
+
expect(result.hasSensitiveData).toBe(true);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it('passes clean observations without sensitive data', () => {
|
|
627
|
+
const obs: Observation = {
|
|
628
|
+
kind: 'gate_evaluated',
|
|
629
|
+
session_id: VALID_UUID,
|
|
630
|
+
timestamp: VALID_TIMESTAMP,
|
|
631
|
+
gate_eval_id: 'gate-001',
|
|
632
|
+
gate_id: 'architecture',
|
|
633
|
+
inputs: {
|
|
634
|
+
file_count: 5,
|
|
635
|
+
changed_files: ['src/index.ts', 'src/config.ts'],
|
|
636
|
+
},
|
|
637
|
+
outcome: 'pass',
|
|
638
|
+
rules_evaluated: ['no-circular-deps', 'layer-boundaries'],
|
|
639
|
+
enforcement: 'blocking',
|
|
640
|
+
duration_ms: 250,
|
|
641
|
+
} as Observation;
|
|
642
|
+
|
|
643
|
+
const result = containsSensitiveData(obs);
|
|
644
|
+
expect(result.hasSensitiveData).toBe(false);
|
|
645
|
+
expect(result.issues).toHaveLength(0);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it('detects secrets embedded in error messages', () => {
|
|
649
|
+
const obs: Observation = {
|
|
650
|
+
kind: 'error',
|
|
651
|
+
session_id: VALID_UUID,
|
|
652
|
+
timestamp: VALID_TIMESTAMP,
|
|
653
|
+
error_id: 'err-001',
|
|
654
|
+
error_type: 'command_failure',
|
|
655
|
+
context: {
|
|
656
|
+
component: 'deployment',
|
|
657
|
+
},
|
|
658
|
+
error_message: 'Failed to authenticate with token sk-live-abc123def456',
|
|
659
|
+
recoverable: false,
|
|
660
|
+
} as Observation;
|
|
661
|
+
|
|
662
|
+
const result = containsSensitiveData(obs);
|
|
663
|
+
expect(result.hasSensitiveData).toBe(true);
|
|
664
|
+
});
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
// =============================================================================
|
|
668
|
+
// 5. Observation Schema Validation (Correctness Boundary)
|
|
669
|
+
// =============================================================================
|
|
670
|
+
|
|
671
|
+
describe('Observation schema validation (correctness boundary)', () => {
|
|
672
|
+
it('validates a correct session_start observation', () => {
|
|
673
|
+
const obs = {
|
|
674
|
+
kind: 'session_start',
|
|
675
|
+
session_id: VALID_UUID,
|
|
676
|
+
timestamp: VALID_TIMESTAMP,
|
|
677
|
+
context: {
|
|
678
|
+
working_directory: '/home/user/project',
|
|
679
|
+
anvil_version: '1.0.0',
|
|
680
|
+
command: 'anvil check',
|
|
681
|
+
args: [],
|
|
682
|
+
environment: 'development',
|
|
683
|
+
},
|
|
684
|
+
};
|
|
685
|
+
const result = validateObservation(obs);
|
|
686
|
+
expect(result.success).toBe(true);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it('rejects observation with unknown kind', () => {
|
|
690
|
+
const obs = {
|
|
691
|
+
kind: 'ai_inference',
|
|
692
|
+
session_id: VALID_UUID,
|
|
693
|
+
timestamp: VALID_TIMESTAMP,
|
|
694
|
+
inference: 'I think the build will fail next time',
|
|
695
|
+
};
|
|
696
|
+
const result = validateObservation(obs);
|
|
697
|
+
expect(result.success).toBe(false);
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
it('rejects observation without session_id', () => {
|
|
701
|
+
const obs = {
|
|
702
|
+
kind: 'session_start',
|
|
703
|
+
timestamp: VALID_TIMESTAMP,
|
|
704
|
+
context: {
|
|
705
|
+
working_directory: '/home/user/project',
|
|
706
|
+
anvil_version: '1.0.0',
|
|
707
|
+
command: 'anvil check',
|
|
708
|
+
args: [],
|
|
709
|
+
environment: 'development',
|
|
710
|
+
},
|
|
711
|
+
};
|
|
712
|
+
const result = validateObservation(obs);
|
|
713
|
+
expect(result.success).toBe(false);
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
it('rejects observation with invalid timestamp format', () => {
|
|
717
|
+
const obs = {
|
|
718
|
+
kind: 'session_start',
|
|
719
|
+
session_id: VALID_UUID,
|
|
720
|
+
timestamp: 'yesterday',
|
|
721
|
+
context: {
|
|
722
|
+
working_directory: '/home/user/project',
|
|
723
|
+
anvil_version: '1.0.0',
|
|
724
|
+
command: 'anvil check',
|
|
725
|
+
args: [],
|
|
726
|
+
environment: 'development',
|
|
727
|
+
},
|
|
728
|
+
};
|
|
729
|
+
const result = validateObservation(obs);
|
|
730
|
+
expect(result.success).toBe(false);
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
it('validates all 11 observation kinds are accepted', () => {
|
|
734
|
+
const kinds = [
|
|
735
|
+
'session_start',
|
|
736
|
+
'session_end',
|
|
737
|
+
'plan_created',
|
|
738
|
+
'plan_edited',
|
|
739
|
+
'plan_approved',
|
|
740
|
+
'plan_rejected',
|
|
741
|
+
'action_executed',
|
|
742
|
+
'gate_evaluated',
|
|
743
|
+
'constraint_applied',
|
|
744
|
+
'human_input',
|
|
745
|
+
'error',
|
|
746
|
+
];
|
|
747
|
+
// Just verify the schema discriminator recognises all kinds
|
|
748
|
+
for (const kind of kinds) {
|
|
749
|
+
const obs = { kind };
|
|
750
|
+
const result = ObservationSchema.safeParse(obs);
|
|
751
|
+
// Will fail for missing fields, but should NOT fail for unknown kind
|
|
752
|
+
if (!result.success) {
|
|
753
|
+
const errorStr = JSON.stringify(result.error.format());
|
|
754
|
+
// The error should be about missing fields, not about the kind being invalid
|
|
755
|
+
expect(errorStr).not.toContain('Invalid discriminator value');
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
});
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
// =============================================================================
|
|
762
|
+
// 6. Query Shape and Format Validation
|
|
763
|
+
// =============================================================================
|
|
764
|
+
|
|
765
|
+
describe('Query shape and format validation', () => {
|
|
766
|
+
it('only allows valid shapes: timeline, list, entity', () => {
|
|
767
|
+
const invalidShapes = ['graph', 'tree', 'embedding_space', 'similarity_matrix'];
|
|
768
|
+
for (const shape of invalidShapes) {
|
|
769
|
+
const query = {
|
|
770
|
+
scope: 'session',
|
|
771
|
+
session_id: VALID_UUID,
|
|
772
|
+
shape,
|
|
773
|
+
};
|
|
774
|
+
const result = SessionQuerySchema.safeParse(query);
|
|
775
|
+
expect(result.success).toBe(false);
|
|
776
|
+
}
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
it('only allows valid formats: json, text', () => {
|
|
780
|
+
const invalidFormats = ['xml', 'csv', 'embedding', 'binary'];
|
|
781
|
+
for (const format of invalidFormats) {
|
|
782
|
+
const query = {
|
|
783
|
+
scope: 'session',
|
|
784
|
+
session_id: VALID_UUID,
|
|
785
|
+
shape: 'timeline',
|
|
786
|
+
format,
|
|
787
|
+
};
|
|
788
|
+
const result = SessionQuerySchema.safeParse(query);
|
|
789
|
+
expect(result.success).toBe(false);
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
it('defaults format to json when not specified', () => {
|
|
794
|
+
const query = {
|
|
795
|
+
scope: 'session',
|
|
796
|
+
session_id: VALID_UUID,
|
|
797
|
+
shape: 'timeline',
|
|
798
|
+
};
|
|
799
|
+
const result = SessionQuerySchema.safeParse(query);
|
|
800
|
+
expect(result.success).toBe(true);
|
|
801
|
+
if (result.success) {
|
|
802
|
+
expect(result.data.format).toBe('json');
|
|
803
|
+
}
|
|
804
|
+
});
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
// =============================================================================
|
|
808
|
+
// 7. Query Response Output Guarantees
|
|
809
|
+
// =============================================================================
|
|
810
|
+
|
|
811
|
+
describe('Query response output guarantees', () => {
|
|
812
|
+
it('response must include metadata with query_id and executed_at', () => {
|
|
813
|
+
const responseWithoutMeta = {
|
|
814
|
+
observations: [],
|
|
815
|
+
};
|
|
816
|
+
const result = validateQueryResponse(responseWithoutMeta);
|
|
817
|
+
expect(result.success).toBe(false);
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
it('response metadata must include contract_version', () => {
|
|
821
|
+
const responseNoVersion = {
|
|
822
|
+
metadata: {
|
|
823
|
+
query_id: VALID_UUID,
|
|
824
|
+
executed_at: VALID_TIMESTAMP,
|
|
825
|
+
result_count: 0,
|
|
826
|
+
truncated: false,
|
|
827
|
+
},
|
|
828
|
+
observations: [],
|
|
829
|
+
};
|
|
830
|
+
const result = validateQueryResponse(responseNoVersion);
|
|
831
|
+
expect(result.success).toBe(false);
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
it('response must include truncation status', () => {
|
|
835
|
+
const responseNoTruncation = {
|
|
836
|
+
metadata: {
|
|
837
|
+
query_id: VALID_UUID,
|
|
838
|
+
executed_at: VALID_TIMESTAMP,
|
|
839
|
+
contract_version: '1.0.0',
|
|
840
|
+
result_count: 0,
|
|
841
|
+
},
|
|
842
|
+
observations: [],
|
|
843
|
+
};
|
|
844
|
+
const result = validateQueryResponse(responseNoTruncation);
|
|
845
|
+
expect(result.success).toBe(false);
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
it('valid response passes validation', () => {
|
|
849
|
+
const response = makeValidQueryResponse();
|
|
850
|
+
const result = validateQueryResponse(response);
|
|
851
|
+
expect(result.success).toBe(true);
|
|
852
|
+
});
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
// =============================================================================
|
|
856
|
+
// 8. Time Bounds Validation
|
|
857
|
+
// =============================================================================
|
|
858
|
+
|
|
859
|
+
describe('Time bounds validation', () => {
|
|
860
|
+
it('accepts valid ISO8601 time_after', () => {
|
|
861
|
+
const query = {
|
|
862
|
+
scope: 'session',
|
|
863
|
+
session_id: VALID_UUID,
|
|
864
|
+
shape: 'timeline',
|
|
865
|
+
time_after: '2026-01-01T00:00:00.000Z',
|
|
866
|
+
};
|
|
867
|
+
const result = SessionQuerySchema.safeParse(query);
|
|
868
|
+
expect(result.success).toBe(true);
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
it('accepts valid ISO8601 time_before', () => {
|
|
872
|
+
const query = {
|
|
873
|
+
scope: 'session',
|
|
874
|
+
session_id: VALID_UUID,
|
|
875
|
+
shape: 'timeline',
|
|
876
|
+
time_before: '2026-12-31T23:59:59.999Z',
|
|
877
|
+
};
|
|
878
|
+
const result = SessionQuerySchema.safeParse(query);
|
|
879
|
+
expect(result.success).toBe(true);
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
it('rejects invalid time_after format', () => {
|
|
883
|
+
const query = {
|
|
884
|
+
scope: 'session',
|
|
885
|
+
session_id: VALID_UUID,
|
|
886
|
+
shape: 'timeline',
|
|
887
|
+
time_after: 'last week',
|
|
888
|
+
};
|
|
889
|
+
const result = SessionQuerySchema.safeParse(query);
|
|
890
|
+
expect(result.success).toBe(false);
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
it('rejects natural language time expressions', () => {
|
|
894
|
+
const query = {
|
|
895
|
+
scope: 'session',
|
|
896
|
+
session_id: VALID_UUID,
|
|
897
|
+
shape: 'timeline',
|
|
898
|
+
time_after: '3 days ago',
|
|
899
|
+
};
|
|
900
|
+
const result = SessionQuerySchema.safeParse(query);
|
|
901
|
+
expect(result.success).toBe(false);
|
|
902
|
+
});
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
// =============================================================================
|
|
906
|
+
// 9. Session ID Format Validation
|
|
907
|
+
// =============================================================================
|
|
908
|
+
|
|
909
|
+
describe('Session ID format validation', () => {
|
|
910
|
+
it('accepts valid UUID session_id', () => {
|
|
911
|
+
const query = {
|
|
912
|
+
scope: 'session',
|
|
913
|
+
session_id: VALID_UUID,
|
|
914
|
+
shape: 'timeline',
|
|
915
|
+
};
|
|
916
|
+
const result = SessionQuerySchema.safeParse(query);
|
|
917
|
+
expect(result.success).toBe(true);
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
it('rejects non-UUID session_id', () => {
|
|
921
|
+
const query = {
|
|
922
|
+
scope: 'session',
|
|
923
|
+
session_id: 'not-a-uuid',
|
|
924
|
+
shape: 'timeline',
|
|
925
|
+
};
|
|
926
|
+
const result = SessionQuerySchema.safeParse(query);
|
|
927
|
+
expect(result.success).toBe(false);
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
it('rejects wildcard session_id', () => {
|
|
931
|
+
const query = {
|
|
932
|
+
scope: 'session',
|
|
933
|
+
session_id: '*',
|
|
934
|
+
shape: 'timeline',
|
|
935
|
+
};
|
|
936
|
+
const result = SessionQuerySchema.safeParse(query);
|
|
937
|
+
expect(result.success).toBe(false);
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
it('rejects SQL injection in session_id', () => {
|
|
941
|
+
const query = {
|
|
942
|
+
scope: 'session',
|
|
943
|
+
session_id: "'; DROP TABLE observations; --",
|
|
944
|
+
shape: 'timeline',
|
|
945
|
+
};
|
|
946
|
+
const result = SessionQuerySchema.safeParse(query);
|
|
947
|
+
expect(result.success).toBe(false);
|
|
948
|
+
});
|
|
949
|
+
});
|