@nexart/ai-execution 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/README.md +353 -0
- package/dist/__tests__/fixtures.test.d.ts +2 -0
- package/dist/__tests__/fixtures.test.d.ts.map +1 -0
- package/dist/__tests__/fixtures.test.js +37 -0
- package/dist/__tests__/fixtures.test.js.map +1 -0
- package/dist/__tests__/vectors.test.d.ts +2 -0
- package/dist/__tests__/vectors.test.d.ts.map +1 -0
- package/dist/__tests__/vectors.test.js +261 -0
- package/dist/__tests__/vectors.test.js.map +1 -0
- package/dist/canonicalJson.d.ts +2 -0
- package/dist/canonicalJson.d.ts.map +1 -0
- package/dist/canonicalJson.js +38 -0
- package/dist/canonicalJson.js.map +1 -0
- package/dist/cer.d.ts +7 -0
- package/dist/cer.d.ts.map +1 -0
- package/dist/cer.js +61 -0
- package/dist/cer.js.map +1 -0
- package/dist/hash.d.ts +6 -0
- package/dist/hash.d.ts.map +1 -0
- package/dist/hash.js +32 -0
- package/dist/hash.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/providers/openai.d.ts +22 -0
- package/dist/providers/openai.d.ts.map +1 -0
- package/dist/providers/openai.js +62 -0
- package/dist/providers/openai.js.map +1 -0
- package/dist/snapshot.d.ts +4 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +100 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/types.d.ts +54 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/fixtures/vectors/vector-001.expected.json +6 -0
- package/fixtures/vectors/vector-001.snapshot.json +23 -0
- package/package.json +47 -0
package/README.md
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
# @nexart/ai-execution v0.1.0
|
|
2
|
+
|
|
3
|
+
Tamper-evident records and Certified Execution Records (CER) for AI operations.
|
|
4
|
+
|
|
5
|
+
## What This Does
|
|
6
|
+
|
|
7
|
+
This package creates integrity records for AI executions. Every time you call an AI model, it captures:
|
|
8
|
+
|
|
9
|
+
- What you sent (input + prompt)
|
|
10
|
+
- What you got back (output)
|
|
11
|
+
- The exact parameters used (temperature, model, etc.)
|
|
12
|
+
- SHA-256 hashes of everything for tamper detection
|
|
13
|
+
|
|
14
|
+
These records can be verified later to prove the execution happened as recorded.
|
|
15
|
+
|
|
16
|
+
**Important:** This does NOT promise that an AI model will produce the same output twice. LLMs are not deterministic. This package provides **integrity and auditability** — proof that a specific input produced a specific output at a specific time with specific parameters.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install @nexart/ai-execution
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quick Start
|
|
25
|
+
|
|
26
|
+
### Create a Snapshot Manually
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
import { createSnapshot, verifySnapshot, sealCer, verifyCer } from '@nexart/ai-execution';
|
|
30
|
+
|
|
31
|
+
// Create a snapshot of an AI execution
|
|
32
|
+
const snapshot = createSnapshot({
|
|
33
|
+
executionId: 'exec-001',
|
|
34
|
+
provider: 'openai',
|
|
35
|
+
model: 'gpt-4o',
|
|
36
|
+
modelVersion: '2026-01-01',
|
|
37
|
+
prompt: 'You are a helpful assistant.',
|
|
38
|
+
input: 'What is 2+2?',
|
|
39
|
+
parameters: {
|
|
40
|
+
temperature: 0.7,
|
|
41
|
+
maxTokens: 1024,
|
|
42
|
+
topP: null,
|
|
43
|
+
seed: null,
|
|
44
|
+
},
|
|
45
|
+
output: 'The answer is 4.',
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Verify the snapshot hashes are correct
|
|
49
|
+
const result = verifySnapshot(snapshot);
|
|
50
|
+
console.log(result.ok); // true
|
|
51
|
+
|
|
52
|
+
// Seal into a Certified Execution Record
|
|
53
|
+
const bundle = sealCer(snapshot);
|
|
54
|
+
console.log(bundle.certificateHash); // "sha256:..."
|
|
55
|
+
|
|
56
|
+
// Verify the entire bundle
|
|
57
|
+
const cerResult = verifyCer(bundle);
|
|
58
|
+
console.log(cerResult.ok); // true
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### With JSON Input/Output
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
const snapshot = createSnapshot({
|
|
65
|
+
executionId: 'exec-002',
|
|
66
|
+
provider: 'openai',
|
|
67
|
+
model: 'gpt-4o',
|
|
68
|
+
prompt: 'Extract entities',
|
|
69
|
+
input: { text: 'John lives in Paris', lang: 'en' },
|
|
70
|
+
parameters: {
|
|
71
|
+
temperature: 0,
|
|
72
|
+
maxTokens: 512,
|
|
73
|
+
topP: null,
|
|
74
|
+
seed: 42,
|
|
75
|
+
},
|
|
76
|
+
output: { entities: ['John', 'Paris'], count: 2 },
|
|
77
|
+
});
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### OpenAI Provider (Optional)
|
|
81
|
+
|
|
82
|
+
Convenience adapter only; the core value of this package is the snapshot + hashing format.
|
|
83
|
+
The OpenAI adapter wraps a single `chat/completions` call and returns a sealed CER bundle.
|
|
84
|
+
**Provider determinism is not guaranteed** — identical inputs may produce different outputs across calls.
|
|
85
|
+
|
|
86
|
+
If you have an OpenAI API key, the package includes a convenience adapter:
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
import { runOpenAIChatExecution } from '@nexart/ai-execution/providers/openai';
|
|
90
|
+
|
|
91
|
+
const result = await runOpenAIChatExecution({
|
|
92
|
+
prompt: 'You are a helpful assistant.',
|
|
93
|
+
input: 'What is the capital of France?',
|
|
94
|
+
model: 'gpt-4o',
|
|
95
|
+
parameters: {
|
|
96
|
+
temperature: 0.7,
|
|
97
|
+
maxTokens: 256,
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
console.log(result.output); // "The capital of France is Paris."
|
|
102
|
+
console.log(result.snapshot.inputHash); // "sha256:..."
|
|
103
|
+
console.log(result.bundle.certificateHash); // "sha256:..."
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Requires `OPENAI_API_KEY` environment variable or `apiKey` parameter.
|
|
107
|
+
|
|
108
|
+
## Snapshot Format (ai.execution.v1)
|
|
109
|
+
|
|
110
|
+
### Required vs Optional Fields
|
|
111
|
+
|
|
112
|
+
| Field | Required | Type | Notes |
|
|
113
|
+
|---|---|---|---|
|
|
114
|
+
| `executionId` | **Yes** | `string` | Caller-supplied unique ID |
|
|
115
|
+
| `provider` | **Yes** | `string` | e.g. `"openai"`, `"anthropic"` |
|
|
116
|
+
| `model` | **Yes** | `string` | e.g. `"gpt-4o"` |
|
|
117
|
+
| `prompt` | **Yes** | `string` | System prompt |
|
|
118
|
+
| `input` | **Yes** | `string \| object` | User input (text or structured) |
|
|
119
|
+
| `output` | **Yes** | `string \| object` | Model output (text or structured) |
|
|
120
|
+
| `parameters.temperature` | **Yes** | `number` | Must be finite |
|
|
121
|
+
| `parameters.maxTokens` | **Yes** | `number` | Must be finite |
|
|
122
|
+
| `timestamp` | Optional | `string` | ISO 8601; defaults to `new Date().toISOString()` |
|
|
123
|
+
| `modelVersion` | Optional | `string \| null` | Defaults to `null` |
|
|
124
|
+
| `parameters.topP` | Optional | `number \| null` | Defaults to `null` |
|
|
125
|
+
| `parameters.seed` | Optional | `number \| null` | Defaults to `null` |
|
|
126
|
+
| `sdkVersion` | Optional | `string \| null` | Defaults to package version (`"0.1.0"`) |
|
|
127
|
+
| `appId` | Optional | `string \| null` | Defaults to `null` |
|
|
128
|
+
|
|
129
|
+
The following fields are **auto-generated** by `createSnapshot` and must not be set manually:
|
|
130
|
+
|
|
131
|
+
| Field | Value |
|
|
132
|
+
|---|---|
|
|
133
|
+
| `type` | `"ai.execution.v1"` |
|
|
134
|
+
| `protocolVersion` | `"1.2.0"` |
|
|
135
|
+
| `executionSurface` | `"ai"` |
|
|
136
|
+
| `inputHash` | SHA-256 of input |
|
|
137
|
+
| `outputHash` | SHA-256 of output |
|
|
138
|
+
|
|
139
|
+
### Example Snapshot
|
|
140
|
+
|
|
141
|
+
```json
|
|
142
|
+
{
|
|
143
|
+
"type": "ai.execution.v1",
|
|
144
|
+
"protocolVersion": "1.2.0",
|
|
145
|
+
"executionSurface": "ai",
|
|
146
|
+
"executionId": "exec-001",
|
|
147
|
+
"timestamp": "2026-02-12T00:00:00.000Z",
|
|
148
|
+
"provider": "openai",
|
|
149
|
+
"model": "gpt-4o",
|
|
150
|
+
"modelVersion": "2026-01-01",
|
|
151
|
+
"prompt": "You are a helpful assistant.",
|
|
152
|
+
"input": "What is 2+2?",
|
|
153
|
+
"inputHash": "sha256:...",
|
|
154
|
+
"parameters": {
|
|
155
|
+
"temperature": 0.7,
|
|
156
|
+
"maxTokens": 1024,
|
|
157
|
+
"topP": null,
|
|
158
|
+
"seed": null
|
|
159
|
+
},
|
|
160
|
+
"output": "The answer is 4.",
|
|
161
|
+
"outputHash": "sha256:...",
|
|
162
|
+
"sdkVersion": "0.1.0",
|
|
163
|
+
"appId": null
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## CER Bundle Format
|
|
168
|
+
|
|
169
|
+
```json
|
|
170
|
+
{
|
|
171
|
+
"bundleType": "cer.ai.execution.v1",
|
|
172
|
+
"certificateHash": "sha256:...",
|
|
173
|
+
"createdAt": "2026-02-12T00:00:00.000Z",
|
|
174
|
+
"version": "0.1",
|
|
175
|
+
"snapshot": { ... },
|
|
176
|
+
"meta": {
|
|
177
|
+
"source": "my-app",
|
|
178
|
+
"tags": ["production"]
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### Certificate Hash Computation
|
|
184
|
+
|
|
185
|
+
The `certificateHash` is SHA-256 of the UTF-8 bytes of the canonical JSON of **exactly these four fields**:
|
|
186
|
+
|
|
187
|
+
```
|
|
188
|
+
{ bundleType, version, createdAt, snapshot }
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
- `meta` is **excluded** from the certificate hash. It is an informational envelope field that does not affect integrity verification.
|
|
192
|
+
- Key-ordering rules apply **recursively** — every nested object within `snapshot` is also sorted lexicographically by key.
|
|
193
|
+
- The bytes being hashed are the UTF-8 encoding of the canonical JSON string (no BOM, no trailing newline).
|
|
194
|
+
|
|
195
|
+
## Hashing Rules
|
|
196
|
+
|
|
197
|
+
- **String values**: SHA-256 of UTF-8 bytes of the raw string
|
|
198
|
+
- **Object/Array values**: SHA-256 of UTF-8 bytes of canonical JSON (sorted keys, no whitespace, stable array order)
|
|
199
|
+
- All hashes use the format `sha256:<hex>` (lowercase hex, 64 characters)
|
|
200
|
+
|
|
201
|
+
## Canonical JSON Constraints
|
|
202
|
+
|
|
203
|
+
Canonical JSON is a deterministic subset of JSON. The following rules apply:
|
|
204
|
+
|
|
205
|
+
1. **Object keys** are sorted lexicographically (Unicode codepoint order) at every nesting level.
|
|
206
|
+
2. **No whitespace** — no spaces, tabs, or newlines between tokens.
|
|
207
|
+
3. **Array order is preserved** — arrays are never re-sorted.
|
|
208
|
+
4. **`null`** is valid and serialized as `null`.
|
|
209
|
+
5. **Numbers** must be finite. `NaN`, `Infinity`, and `-Infinity` are **rejected** (throw).
|
|
210
|
+
6. **`undefined`** values in object properties are **omitted** (the key is dropped).
|
|
211
|
+
7. **`BigInt`**, **functions**, and **`Symbol`** are **not valid** JSON types and are **rejected** (throw).
|
|
212
|
+
8. Strings are JSON-escaped (e.g. `"` → `\"`).
|
|
213
|
+
|
|
214
|
+
These constraints ensure that the same logical value always produces the same byte sequence, which is essential for hash stability across implementations and languages.
|
|
215
|
+
|
|
216
|
+
## Interoperability (Test Vectors)
|
|
217
|
+
|
|
218
|
+
Cross-language implementations **must** match the test vectors exactly to be considered compatible. Vectors are committed as JSON fixtures at:
|
|
219
|
+
|
|
220
|
+
```
|
|
221
|
+
packages/ai-execution/fixtures/vectors/
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### Vector 001
|
|
225
|
+
|
|
226
|
+
**Input snapshot** (`vector-001.snapshot.json`):
|
|
227
|
+
|
|
228
|
+
```json
|
|
229
|
+
{
|
|
230
|
+
"type": "ai.execution.v1",
|
|
231
|
+
"protocolVersion": "1.2.0",
|
|
232
|
+
"executionSurface": "ai",
|
|
233
|
+
"executionId": "vec-001",
|
|
234
|
+
"timestamp": "2026-02-12T00:00:00.000Z",
|
|
235
|
+
"provider": "openai",
|
|
236
|
+
"model": "gpt-4o",
|
|
237
|
+
"modelVersion": "2026-01-01",
|
|
238
|
+
"prompt": "You are a helpful assistant.",
|
|
239
|
+
"input": "What is 2+2?",
|
|
240
|
+
"inputHash": "sha256:52cb6b5e4a038af1756708f98afb718a08c75b87b2f03dbee4dd9c8139c15c5e",
|
|
241
|
+
"parameters": {
|
|
242
|
+
"temperature": 0.7,
|
|
243
|
+
"maxTokens": 1024,
|
|
244
|
+
"topP": null,
|
|
245
|
+
"seed": null
|
|
246
|
+
},
|
|
247
|
+
"output": "The answer is 4.",
|
|
248
|
+
"outputHash": "sha256:ae758477f843049bd252ceb5498aa33f190326589ee92cbe5a1ab563f54bc05b",
|
|
249
|
+
"sdkVersion": "0.1.0",
|
|
250
|
+
"appId": "vector-test"
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
**Expected hashes** (`vector-001.expected.json`):
|
|
255
|
+
|
|
256
|
+
| Hash | Value |
|
|
257
|
+
|---|---|
|
|
258
|
+
| `inputHash` | `sha256:52cb6b5e4a038af1756708f98afb718a08c75b87b2f03dbee4dd9c8139c15c5e` |
|
|
259
|
+
| `outputHash` | `sha256:ae758477f843049bd252ceb5498aa33f190326589ee92cbe5a1ab563f54bc05b` |
|
|
260
|
+
| `certificateHash` | `sha256:86275d60d088483eefaf0bd31d79629b11342315816f3a1da26980e4a05352f4` |
|
|
261
|
+
|
|
262
|
+
The `certificateHash` is computed with `cerCreatedAt: "2026-02-12T00:00:00.000Z"`.
|
|
263
|
+
|
|
264
|
+
**Canonicalization rules for verification:**
|
|
265
|
+
1. Object keys sorted lexicographically at every level.
|
|
266
|
+
2. No whitespace between tokens.
|
|
267
|
+
3. Arrays preserve insertion order.
|
|
268
|
+
4. Hashes are SHA-256 of the UTF-8 bytes of the resulting string.
|
|
269
|
+
|
|
270
|
+
To verify compatibility, a cross-language implementation should:
|
|
271
|
+
1. Load `vector-001.snapshot.json`.
|
|
272
|
+
2. Recompute `inputHash` from `snapshot.input` and assert it matches `expected.inputHash`.
|
|
273
|
+
3. Recompute `outputHash` from `snapshot.output` and assert it matches `expected.outputHash`.
|
|
274
|
+
4. Construct a CER payload `{ bundleType: "cer.ai.execution.v1", version: "0.1", createdAt: "2026-02-12T00:00:00.000Z", snapshot }`, canonicalize it, hash it, and assert it matches `expected.certificateHash`.
|
|
275
|
+
|
|
276
|
+
## Threat Model
|
|
277
|
+
|
|
278
|
+
This package provides **integrity and auditability** for AI execution records. It is an auditing tool, not a security boundary.
|
|
279
|
+
|
|
280
|
+
### What it prevents
|
|
281
|
+
|
|
282
|
+
- **Output tampering** — any modification to the recorded output invalidates the `outputHash` and `certificateHash`.
|
|
283
|
+
- **Parameter laundering** — changing temperature, model, seed, or other parameters after the fact is detectable because they are included in the certificate hash.
|
|
284
|
+
- **Silent record edits** — any modification to a sealed CER bundle is detectable via `verifyCer()`.
|
|
285
|
+
|
|
286
|
+
### What it does NOT prevent
|
|
287
|
+
|
|
288
|
+
- **Provider lying** — if the AI provider returns fabricated output, this package faithfully records it. The record proves what the provider returned, not that the provider was honest.
|
|
289
|
+
- **Model drift** — models change over time. The same input may produce different output with the same model name on different dates. `modelVersion` helps track this but cannot prevent it.
|
|
290
|
+
- **Correctness of the answer** — this package records what was said, not whether it was right.
|
|
291
|
+
|
|
292
|
+
## Verification Best Practice
|
|
293
|
+
|
|
294
|
+
**Recommended flow:**
|
|
295
|
+
|
|
296
|
+
1. Call `createSnapshot(...)` immediately after receiving the AI response.
|
|
297
|
+
2. Call `sealCer(snapshot)` to produce the CER bundle.
|
|
298
|
+
3. Call `verifyCer(bundle)` **before** storing or transmitting the bundle — this catches any in-memory corruption.
|
|
299
|
+
4. Store the full bundle as an audit artifact. Treat it as immutable.
|
|
300
|
+
5. On retrieval, call `verifyCer(bundle)` again to confirm integrity.
|
|
301
|
+
|
|
302
|
+
```typescript
|
|
303
|
+
const snapshot = createSnapshot({ ... });
|
|
304
|
+
const bundle = sealCer(snapshot);
|
|
305
|
+
|
|
306
|
+
const check = verifyCer(bundle);
|
|
307
|
+
if (!check.ok) {
|
|
308
|
+
throw new Error(`CER integrity failure: ${check.errors.join('; ')}`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
await store(bundle); // persist as audit artifact
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
## Privacy & Data Handling
|
|
315
|
+
|
|
316
|
+
Snapshots include **raw prompt, input, and output by default**. This means:
|
|
317
|
+
|
|
318
|
+
- If the input contains PII, medical data, or other sensitive information, the snapshot will contain it verbatim.
|
|
319
|
+
- If the output contains sensitive content, it will be stored in full.
|
|
320
|
+
|
|
321
|
+
**Recommendations:**
|
|
322
|
+
|
|
323
|
+
- For sensitive workloads, consider redacting input/output before calling `createSnapshot()`, then verify the redacted versions.
|
|
324
|
+
- A hash-only mode (where only hashes are stored, not raw content) is **not implemented in v0.1.0**. It is planned for a future release.
|
|
325
|
+
- Treat CER bundles with the same access controls as the underlying data they contain.
|
|
326
|
+
|
|
327
|
+
## API Reference
|
|
328
|
+
|
|
329
|
+
| Function | Description |
|
|
330
|
+
|----------|-------------|
|
|
331
|
+
| `createSnapshot(params)` | Create an AI execution snapshot with computed hashes |
|
|
332
|
+
| `verifySnapshot(snapshot)` | Verify snapshot hashes and structure |
|
|
333
|
+
| `sealCer(snapshot, options?)` | Seal a snapshot into a CER bundle |
|
|
334
|
+
| `verifyCer(bundle)` | Verify a CER bundle (snapshot + certificate hash) |
|
|
335
|
+
| `computeInputHash(input)` | Compute hash for input (string or object) |
|
|
336
|
+
| `computeOutputHash(output)` | Compute hash for output (string or object) |
|
|
337
|
+
| `toCanonicalJson(value)` | Deterministic JSON serialization |
|
|
338
|
+
| `sha256Hex(data)` | Raw SHA-256 hex digest |
|
|
339
|
+
| `hashUtf8(value)` | Hash a UTF-8 string |
|
|
340
|
+
| `hashCanonicalJson(value)` | Hash canonical JSON of a value |
|
|
341
|
+
|
|
342
|
+
## How to Publish
|
|
343
|
+
|
|
344
|
+
```bash
|
|
345
|
+
cd packages/ai-execution
|
|
346
|
+
npm run build
|
|
347
|
+
npm run test
|
|
348
|
+
npm publish --access public
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
## License
|
|
352
|
+
|
|
353
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fixtures.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/fixtures.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { verifySnapshot } from '../snapshot.js';
|
|
7
|
+
import { sealCer, verifyCer } from '../cer.js';
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = dirname(__filename);
|
|
10
|
+
const fixturesDir = join(__dirname, '..', '..', 'fixtures', 'vectors');
|
|
11
|
+
function loadJson(filename) {
|
|
12
|
+
return JSON.parse(readFileSync(join(fixturesDir, filename), 'utf-8'));
|
|
13
|
+
}
|
|
14
|
+
describe('fixture: vector-001', () => {
|
|
15
|
+
const snapshot = loadJson('vector-001.snapshot.json');
|
|
16
|
+
const expected = loadJson('vector-001.expected.json');
|
|
17
|
+
it('snapshot inputHash matches expected', () => {
|
|
18
|
+
assert.equal(snapshot.inputHash, expected.inputHash);
|
|
19
|
+
});
|
|
20
|
+
it('snapshot outputHash matches expected', () => {
|
|
21
|
+
assert.equal(snapshot.outputHash, expected.outputHash);
|
|
22
|
+
});
|
|
23
|
+
it('snapshot passes verification', () => {
|
|
24
|
+
const result = verifySnapshot(snapshot);
|
|
25
|
+
assert.equal(result.ok, true, `verification errors: ${result.errors.join('; ')}`);
|
|
26
|
+
});
|
|
27
|
+
it('CER certificateHash matches expected', () => {
|
|
28
|
+
const bundle = sealCer(snapshot, { createdAt: expected.cerCreatedAt });
|
|
29
|
+
assert.equal(bundle.certificateHash, expected.certificateHash);
|
|
30
|
+
});
|
|
31
|
+
it('CER bundle passes verification', () => {
|
|
32
|
+
const bundle = sealCer(snapshot, { createdAt: expected.cerCreatedAt });
|
|
33
|
+
const result = verifyCer(bundle);
|
|
34
|
+
assert.equal(result.ok, true, `verification errors: ${result.errors.join('; ')}`);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
//# sourceMappingURL=fixtures.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fixtures.test.js","sourceRoot":"","sources":["../../src/__tests__/fixtures.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,MAAM,MAAM,oBAAoB,CAAC;AACxC,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACvC,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAG/C,MAAM,UAAU,GAAG,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAClD,MAAM,SAAS,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;AACtC,MAAM,WAAW,GAAG,IAAI,CAAC,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,UAAU,EAAE,SAAS,CAAC,CAAC;AAEvE,SAAS,QAAQ,CAAC,QAAgB;IAChC,OAAO,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,IAAI,CAAC,WAAW,EAAE,QAAQ,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC;AACxE,CAAC;AAED,QAAQ,CAAC,qBAAqB,EAAE,GAAG,EAAE;IACnC,MAAM,QAAQ,GAAG,QAAQ,CAAC,0BAA0B,CAA0B,CAAC;IAC/E,MAAM,QAAQ,GAAG,QAAQ,CAAC,0BAA0B,CAKnD,CAAC;IAEF,EAAE,CAAC,qCAAqC,EAAE,GAAG,EAAE;QAC7C,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,SAAS,EAAE,QAAQ,CAAC,SAAS,CAAC,CAAC;IACvD,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,sCAAsC,EAAE,GAAG,EAAE;QAC9C,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,UAAU,EAAE,QAAQ,CAAC,UAAU,CAAC,CAAC;IACzD,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,8BAA8B,EAAE,GAAG,EAAE;QACtC,MAAM,MAAM,GAAG,cAAc,CAAC,QAAQ,CAAC,CAAC;QACxC,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,EAAE,IAAI,EAAE,wBAAwB,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACpF,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,sCAAsC,EAAE,GAAG,EAAE;QAC9C,MAAM,MAAM,GAAG,OAAO,CAAC,QAAQ,EAAE,EAAE,SAAS,EAAE,QAAQ,CAAC,YAAY,EAAE,CAAC,CAAC;QACvE,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,eAAe,EAAE,QAAQ,CAAC,eAAe,CAAC,CAAC;IACjE,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,gCAAgC,EAAE,GAAG,EAAE;QACxC,MAAM,MAAM,GAAG,OAAO,CAAC,QAAQ,EAAE,EAAE,SAAS,EAAE,QAAQ,CAAC,YAAY,EAAE,CAAC,CAAC;QACvE,MAAM,MAAM,GAAG,SAAS,CAAC,MAAM,CAAC,CAAC;QACjC,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,EAAE,IAAI,EAAE,wBAAwB,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACpF,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"vectors.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/vectors.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { toCanonicalJson } from '../canonicalJson.js';
|
|
4
|
+
import { sha256Hex, hashUtf8, hashCanonicalJson, computeInputHash, computeOutputHash } from '../hash.js';
|
|
5
|
+
import { createSnapshot, verifySnapshot } from '../snapshot.js';
|
|
6
|
+
import { sealCer, verifyCer } from '../cer.js';
|
|
7
|
+
const FIXED_TIMESTAMP = '2026-02-12T00:00:00.000Z';
|
|
8
|
+
const FIXED_EXECUTION_ID = 'test-exec-001';
|
|
9
|
+
describe('canonicalJson', () => {
|
|
10
|
+
it('sorts object keys lexicographically', () => {
|
|
11
|
+
const result = toCanonicalJson({ z: 1, a: 2, m: 3 });
|
|
12
|
+
assert.equal(result, '{"a":2,"m":3,"z":1}');
|
|
13
|
+
});
|
|
14
|
+
it('does not sort arrays', () => {
|
|
15
|
+
const result = toCanonicalJson([3, 1, 2]);
|
|
16
|
+
assert.equal(result, '[3,1,2]');
|
|
17
|
+
});
|
|
18
|
+
it('handles nested objects', () => {
|
|
19
|
+
const result = toCanonicalJson({ b: { d: 1, c: 2 }, a: 3 });
|
|
20
|
+
assert.equal(result, '{"a":3,"b":{"c":2,"d":1}}');
|
|
21
|
+
});
|
|
22
|
+
it('handles null', () => {
|
|
23
|
+
assert.equal(toCanonicalJson(null), 'null');
|
|
24
|
+
});
|
|
25
|
+
it('handles strings with escapes', () => {
|
|
26
|
+
assert.equal(toCanonicalJson('hello "world"'), '"hello \\"world\\""');
|
|
27
|
+
});
|
|
28
|
+
it('rejects NaN', () => {
|
|
29
|
+
assert.throws(() => toCanonicalJson(NaN), /Non-finite/);
|
|
30
|
+
});
|
|
31
|
+
it('rejects Infinity', () => {
|
|
32
|
+
assert.throws(() => toCanonicalJson(Infinity), /Non-finite/);
|
|
33
|
+
});
|
|
34
|
+
it('omits undefined values in objects', () => {
|
|
35
|
+
const result = toCanonicalJson({ a: 1, b: undefined, c: 3 });
|
|
36
|
+
assert.equal(result, '{"a":1,"c":3}');
|
|
37
|
+
});
|
|
38
|
+
it('produces no whitespace', () => {
|
|
39
|
+
const result = toCanonicalJson({ key: 'value', arr: [1, 2] });
|
|
40
|
+
assert.ok(!result.includes(' '));
|
|
41
|
+
assert.ok(!result.includes('\n'));
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
describe('hashing', () => {
|
|
45
|
+
it('sha256Hex produces correct hex for empty string', () => {
|
|
46
|
+
const result = sha256Hex('');
|
|
47
|
+
assert.equal(result, 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855');
|
|
48
|
+
});
|
|
49
|
+
it('hashUtf8 prefixes with sha256:', () => {
|
|
50
|
+
const result = hashUtf8('hello');
|
|
51
|
+
assert.ok(result.startsWith('sha256:'));
|
|
52
|
+
assert.equal(result, 'sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824');
|
|
53
|
+
});
|
|
54
|
+
it('hashCanonicalJson is stable for same object', () => {
|
|
55
|
+
const obj = { b: 2, a: 1 };
|
|
56
|
+
const h1 = hashCanonicalJson(obj);
|
|
57
|
+
const h2 = hashCanonicalJson({ a: 1, b: 2 });
|
|
58
|
+
assert.equal(h1, h2);
|
|
59
|
+
});
|
|
60
|
+
it('computeInputHash for string uses hashUtf8', () => {
|
|
61
|
+
const result = computeInputHash('test input');
|
|
62
|
+
assert.equal(result, hashUtf8('test input'));
|
|
63
|
+
});
|
|
64
|
+
it('computeInputHash for object uses hashCanonicalJson', () => {
|
|
65
|
+
const obj = { role: 'user', content: 'hello' };
|
|
66
|
+
const result = computeInputHash(obj);
|
|
67
|
+
assert.equal(result, hashCanonicalJson(obj));
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
describe('snapshot', () => {
|
|
71
|
+
const baseParams = {
|
|
72
|
+
executionId: FIXED_EXECUTION_ID,
|
|
73
|
+
timestamp: FIXED_TIMESTAMP,
|
|
74
|
+
provider: 'openai',
|
|
75
|
+
model: 'gpt-4o',
|
|
76
|
+
modelVersion: '2026-01-01',
|
|
77
|
+
prompt: 'You are a helpful assistant.',
|
|
78
|
+
input: 'What is 2+2?',
|
|
79
|
+
parameters: {
|
|
80
|
+
temperature: 0.7,
|
|
81
|
+
maxTokens: 1024,
|
|
82
|
+
topP: null,
|
|
83
|
+
seed: null,
|
|
84
|
+
},
|
|
85
|
+
output: 'The answer is 4.',
|
|
86
|
+
sdkVersion: '0.1.0',
|
|
87
|
+
appId: null,
|
|
88
|
+
};
|
|
89
|
+
it('creates snapshot with correct type fields', () => {
|
|
90
|
+
const snap = createSnapshot(baseParams);
|
|
91
|
+
assert.equal(snap.type, 'ai.execution.v1');
|
|
92
|
+
assert.equal(snap.protocolVersion, '1.2.0');
|
|
93
|
+
assert.equal(snap.executionSurface, 'ai');
|
|
94
|
+
assert.equal(snap.executionId, FIXED_EXECUTION_ID);
|
|
95
|
+
assert.equal(snap.timestamp, FIXED_TIMESTAMP);
|
|
96
|
+
});
|
|
97
|
+
it('computes correct inputHash for text input', () => {
|
|
98
|
+
const snap = createSnapshot(baseParams);
|
|
99
|
+
const expected = computeInputHash('What is 2+2?');
|
|
100
|
+
assert.equal(snap.inputHash, expected);
|
|
101
|
+
});
|
|
102
|
+
it('computes correct outputHash for text output', () => {
|
|
103
|
+
const snap = createSnapshot(baseParams);
|
|
104
|
+
const expected = computeOutputHash('The answer is 4.');
|
|
105
|
+
assert.equal(snap.outputHash, expected);
|
|
106
|
+
});
|
|
107
|
+
it('computes correct hashes for JSON input/output', () => {
|
|
108
|
+
const jsonParams = {
|
|
109
|
+
...baseParams,
|
|
110
|
+
input: { messages: [{ role: 'user', content: 'hello' }] },
|
|
111
|
+
output: { result: 'world', confidence: 0.95 },
|
|
112
|
+
};
|
|
113
|
+
const snap = createSnapshot(jsonParams);
|
|
114
|
+
const expectedInput = computeInputHash(jsonParams.input);
|
|
115
|
+
const expectedOutput = computeOutputHash(jsonParams.output);
|
|
116
|
+
assert.equal(snap.inputHash, expectedInput);
|
|
117
|
+
assert.equal(snap.outputHash, expectedOutput);
|
|
118
|
+
});
|
|
119
|
+
it('verifySnapshot passes for valid snapshot', () => {
|
|
120
|
+
const snap = createSnapshot(baseParams);
|
|
121
|
+
const result = verifySnapshot(snap);
|
|
122
|
+
assert.equal(result.ok, true);
|
|
123
|
+
assert.equal(result.errors.length, 0);
|
|
124
|
+
});
|
|
125
|
+
it('verifySnapshot fails on tampered output', () => {
|
|
126
|
+
const snap = createSnapshot(baseParams);
|
|
127
|
+
snap.output = 'Tampered output!';
|
|
128
|
+
const result = verifySnapshot(snap);
|
|
129
|
+
assert.equal(result.ok, false);
|
|
130
|
+
assert.ok(result.errors.some(e => e.includes('outputHash mismatch')));
|
|
131
|
+
});
|
|
132
|
+
it('rejects non-finite temperature', () => {
|
|
133
|
+
assert.throws(() => createSnapshot({ ...baseParams, parameters: { ...baseParams.parameters, temperature: NaN } }), /temperature/);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
describe('cer', () => {
|
|
137
|
+
const baseParams = {
|
|
138
|
+
executionId: FIXED_EXECUTION_ID,
|
|
139
|
+
timestamp: FIXED_TIMESTAMP,
|
|
140
|
+
provider: 'openai',
|
|
141
|
+
model: 'gpt-4o',
|
|
142
|
+
modelVersion: '2026-01-01',
|
|
143
|
+
prompt: 'You are a helpful assistant.',
|
|
144
|
+
input: 'What is 2+2?',
|
|
145
|
+
parameters: {
|
|
146
|
+
temperature: 0.7,
|
|
147
|
+
maxTokens: 1024,
|
|
148
|
+
topP: null,
|
|
149
|
+
seed: null,
|
|
150
|
+
},
|
|
151
|
+
output: 'The answer is 4.',
|
|
152
|
+
sdkVersion: '0.1.0',
|
|
153
|
+
appId: null,
|
|
154
|
+
};
|
|
155
|
+
it('seals a CER bundle with correct fields', () => {
|
|
156
|
+
const snap = createSnapshot(baseParams);
|
|
157
|
+
const bundle = sealCer(snap, { createdAt: FIXED_TIMESTAMP });
|
|
158
|
+
assert.equal(bundle.bundleType, 'cer.ai.execution.v1');
|
|
159
|
+
assert.equal(bundle.version, '0.1');
|
|
160
|
+
assert.equal(bundle.createdAt, FIXED_TIMESTAMP);
|
|
161
|
+
assert.ok(bundle.certificateHash.startsWith('sha256:'));
|
|
162
|
+
assert.deepStrictEqual(bundle.snapshot, snap);
|
|
163
|
+
});
|
|
164
|
+
it('certificateHash is stable for same snapshot + createdAt', () => {
|
|
165
|
+
const snap = createSnapshot(baseParams);
|
|
166
|
+
const b1 = sealCer(snap, { createdAt: FIXED_TIMESTAMP });
|
|
167
|
+
const b2 = sealCer(snap, { createdAt: FIXED_TIMESTAMP });
|
|
168
|
+
assert.equal(b1.certificateHash, b2.certificateHash);
|
|
169
|
+
});
|
|
170
|
+
it('certificateHash changes when createdAt changes', () => {
|
|
171
|
+
const snap = createSnapshot(baseParams);
|
|
172
|
+
const b1 = sealCer(snap, { createdAt: FIXED_TIMESTAMP });
|
|
173
|
+
const b2 = sealCer(snap, { createdAt: '2026-02-13T00:00:00.000Z' });
|
|
174
|
+
assert.notEqual(b1.certificateHash, b2.certificateHash);
|
|
175
|
+
});
|
|
176
|
+
it('verifyCer passes for valid bundle', () => {
|
|
177
|
+
const snap = createSnapshot(baseParams);
|
|
178
|
+
const bundle = sealCer(snap, { createdAt: FIXED_TIMESTAMP });
|
|
179
|
+
const result = verifyCer(bundle);
|
|
180
|
+
assert.equal(result.ok, true);
|
|
181
|
+
assert.equal(result.errors.length, 0);
|
|
182
|
+
});
|
|
183
|
+
it('verifyCer fails on tampered certificateHash', () => {
|
|
184
|
+
const snap = createSnapshot(baseParams);
|
|
185
|
+
const bundle = sealCer(snap, { createdAt: FIXED_TIMESTAMP });
|
|
186
|
+
bundle.certificateHash = 'sha256:0000000000000000000000000000000000000000000000000000000000000000';
|
|
187
|
+
const result = verifyCer(bundle);
|
|
188
|
+
assert.equal(result.ok, false);
|
|
189
|
+
assert.ok(result.errors.some(e => e.includes('certificateHash mismatch')));
|
|
190
|
+
});
|
|
191
|
+
it('verifyCer fails on tampered snapshot output', () => {
|
|
192
|
+
const snap = createSnapshot(baseParams);
|
|
193
|
+
const bundle = sealCer(snap, { createdAt: FIXED_TIMESTAMP });
|
|
194
|
+
bundle.snapshot.output = 'Tampered!';
|
|
195
|
+
const result = verifyCer(bundle);
|
|
196
|
+
assert.equal(result.ok, false);
|
|
197
|
+
assert.ok(result.errors.some(e => e.includes('outputHash mismatch')));
|
|
198
|
+
});
|
|
199
|
+
it('includes meta when provided', () => {
|
|
200
|
+
const snap = createSnapshot(baseParams);
|
|
201
|
+
const bundle = sealCer(snap, {
|
|
202
|
+
createdAt: FIXED_TIMESTAMP,
|
|
203
|
+
meta: { source: 'test', tags: ['unit-test'] },
|
|
204
|
+
});
|
|
205
|
+
assert.deepStrictEqual(bundle.meta, { source: 'test', tags: ['unit-test'] });
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
describe('deterministic test vectors', () => {
|
|
209
|
+
it('text input/text output vector', () => {
|
|
210
|
+
const snap = createSnapshot({
|
|
211
|
+
executionId: 'vec-text-001',
|
|
212
|
+
timestamp: '2026-01-01T00:00:00.000Z',
|
|
213
|
+
provider: 'openai',
|
|
214
|
+
model: 'gpt-4o',
|
|
215
|
+
modelVersion: null,
|
|
216
|
+
prompt: 'system prompt',
|
|
217
|
+
input: 'hello world',
|
|
218
|
+
parameters: { temperature: 0, maxTokens: 100, topP: null, seed: null },
|
|
219
|
+
output: 'goodbye world',
|
|
220
|
+
sdkVersion: '0.1.0',
|
|
221
|
+
appId: null,
|
|
222
|
+
});
|
|
223
|
+
assert.equal(snap.inputHash, 'sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9');
|
|
224
|
+
assert.equal(snap.outputHash, 'sha256:9150e02727e29ca8522c29ad4aa5a8343c21ccf909b40f73c41bf478df7e6fc3');
|
|
225
|
+
const bundle = sealCer(snap, { createdAt: '2026-01-01T00:00:00.000Z' });
|
|
226
|
+
assert.ok(bundle.certificateHash.startsWith('sha256:'));
|
|
227
|
+
const v1 = verifySnapshot(snap);
|
|
228
|
+
assert.equal(v1.ok, true);
|
|
229
|
+
const v2 = verifyCer(bundle);
|
|
230
|
+
assert.equal(v2.ok, true);
|
|
231
|
+
});
|
|
232
|
+
it('JSON input/JSON output vector', () => {
|
|
233
|
+
const snap = createSnapshot({
|
|
234
|
+
executionId: 'vec-json-001',
|
|
235
|
+
timestamp: '2026-01-01T00:00:00.000Z',
|
|
236
|
+
provider: 'openai',
|
|
237
|
+
model: 'gpt-4o',
|
|
238
|
+
modelVersion: '2026-01-01',
|
|
239
|
+
prompt: 'structured',
|
|
240
|
+
input: { query: 'test', context: [1, 2, 3] },
|
|
241
|
+
parameters: { temperature: 0.5, maxTokens: 256, topP: 0.9, seed: 42 },
|
|
242
|
+
output: { answer: 'result', score: 0.99 },
|
|
243
|
+
sdkVersion: '0.1.0',
|
|
244
|
+
appId: 'myapp',
|
|
245
|
+
});
|
|
246
|
+
const expectedInputCanonical = '{"context":[1,2,3],"query":"test"}';
|
|
247
|
+
const expectedOutputCanonical = '{"answer":"result","score":0.99}';
|
|
248
|
+
assert.equal(toCanonicalJson(snap.input), expectedInputCanonical);
|
|
249
|
+
assert.equal(toCanonicalJson(snap.output), expectedOutputCanonical);
|
|
250
|
+
assert.equal(snap.inputHash, computeInputHash({ query: 'test', context: [1, 2, 3] }));
|
|
251
|
+
assert.equal(snap.outputHash, computeOutputHash({ answer: 'result', score: 0.99 }));
|
|
252
|
+
const v1 = verifySnapshot(snap);
|
|
253
|
+
assert.equal(v1.ok, true);
|
|
254
|
+
const bundle = sealCer(snap, { createdAt: '2026-01-01T00:00:00.000Z' });
|
|
255
|
+
const v2 = verifyCer(bundle);
|
|
256
|
+
assert.equal(v2.ok, true);
|
|
257
|
+
const bundle2 = sealCer(snap, { createdAt: '2026-01-01T00:00:00.000Z' });
|
|
258
|
+
assert.equal(bundle.certificateHash, bundle2.certificateHash);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
//# sourceMappingURL=vectors.test.js.map
|