@wytness/sdk 0.2.0 → 0.2.3
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 +46 -7
- package/dist/index.cjs +129 -52
- package/dist/index.d.cts +11 -2
- package/dist/index.d.ts +11 -2
- package/dist/index.js +130 -54
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -33,8 +33,8 @@ await sendEmail("team@company.com", "Report", "Weekly summary");
|
|
|
33
33
|
- **Cryptographic signing** — every event is signed with Ed25519 (tweetnacl)
|
|
34
34
|
- **Hash chaining** — tamper-evident chain of events per agent session
|
|
35
35
|
- **Automatic redaction** — secrets in parameters are automatically redacted
|
|
36
|
-
- **
|
|
37
|
-
- **File fallback** — events are saved locally if the
|
|
36
|
+
- **HTTP transport** — stream events to api.wytness.dev over HTTPS
|
|
37
|
+
- **File fallback** — events are saved locally if the API is unreachable
|
|
38
38
|
|
|
39
39
|
## HTTP Emitter (Recommended)
|
|
40
40
|
|
|
@@ -58,17 +58,54 @@ const client = new AuditClient({
|
|
|
58
58
|
| `agentId` | `string` | required | Unique identifier for the agent |
|
|
59
59
|
| `agentVersion` | `string` | `"0.1.0"` | Version of the agent |
|
|
60
60
|
| `humanOperatorId` | `string` | `"unknown"` | ID of the human overseeing the agent |
|
|
61
|
-
| `
|
|
62
|
-
| `
|
|
63
|
-
| `kafkaTopic` | `string` | `"wytness-events"` | Kafka topic name |
|
|
64
|
-
| `httpEndpoint` | `string\|undefined` | `undefined` | HTTP API endpoint URL |
|
|
61
|
+
| `signingKey` | `string\|undefined` | `undefined` | Base64-encoded Ed25519 private key (for serverless) |
|
|
62
|
+
| `signingKeyPath` | `string` | `"./keys/signing.key"` | Path to Ed25519 private key. Ignored when `signingKey` is set. |
|
|
65
63
|
| `httpApiKey` | `string\|undefined` | `undefined` | API key for HTTP endpoint |
|
|
64
|
+
| `httpEndpoint` | `string` | `"https://api.wytness.dev"` | HTTP API endpoint URL |
|
|
66
65
|
| `fallbackLogPath` | `string` | `"./audit_fallback.jsonl"` | Local fallback file path |
|
|
67
66
|
|
|
68
67
|
### `auditTool(client, fn, options)`
|
|
69
68
|
|
|
70
69
|
Wraps a function to automatically log audit events. Captures function name, parameters, execution duration, success/failure status, cryptographic signature, and hash chain link.
|
|
71
70
|
|
|
71
|
+
### `client.sessionId`
|
|
72
|
+
|
|
73
|
+
Read-only property returning the UUID for this client instance.
|
|
74
|
+
|
|
75
|
+
### `client.flush()`
|
|
76
|
+
|
|
77
|
+
Returns a Promise that resolves when all pending HTTP requests complete. Call before process exit in serverless/short-lived environments.
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
await client.flush();
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### `hashValue(value)`
|
|
84
|
+
|
|
85
|
+
SHA-256 hash of any value. Use when recording events manually to compute `inputs_hash` / `outputs_hash`.
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
import { hashValue } from "@wytness/sdk";
|
|
89
|
+
|
|
90
|
+
const result = await myTool(data);
|
|
91
|
+
const outputsHash = hashValue(result);
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### `AuditEventSchema`
|
|
95
|
+
|
|
96
|
+
A Zod schema that validates and provides defaults for audit events. Use it when recording events manually:
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
import { AuditEventSchema } from "@wytness/sdk";
|
|
100
|
+
|
|
101
|
+
const event = AuditEventSchema.parse({
|
|
102
|
+
agent_id: "my-agent",
|
|
103
|
+
tool_name: "my_tool",
|
|
104
|
+
// ... other required fields
|
|
105
|
+
// Optional fields get sensible defaults (event_id, timestamp, status, etc.)
|
|
106
|
+
});
|
|
107
|
+
```
|
|
108
|
+
|
|
72
109
|
## Exports
|
|
73
110
|
|
|
74
111
|
```typescript
|
|
@@ -76,6 +113,7 @@ import {
|
|
|
76
113
|
AuditClient,
|
|
77
114
|
AuditClientOptions,
|
|
78
115
|
auditTool,
|
|
116
|
+
hashValue,
|
|
79
117
|
AuditEvent,
|
|
80
118
|
AuditEventSchema,
|
|
81
119
|
generateKeypair,
|
|
@@ -83,7 +121,8 @@ import {
|
|
|
83
121
|
verifyEvent,
|
|
84
122
|
computeEventHash,
|
|
85
123
|
verifyChain,
|
|
86
|
-
|
|
124
|
+
createHttpEmitter,
|
|
125
|
+
createFileEmitter,
|
|
87
126
|
} from "@wytness/sdk";
|
|
88
127
|
```
|
|
89
128
|
|
package/dist/index.cjs
CHANGED
|
@@ -37,6 +37,7 @@ __export(src_exports, {
|
|
|
37
37
|
createFileEmitter: () => createFileEmitter,
|
|
38
38
|
createHttpEmitter: () => createHttpEmitter,
|
|
39
39
|
generateKeypair: () => generateKeypair,
|
|
40
|
+
hashValue: () => hashValue,
|
|
40
41
|
signEvent: () => signEvent,
|
|
41
42
|
verifyChain: () => verifyChain,
|
|
42
43
|
verifyEvent: () => verifyEvent
|
|
@@ -143,30 +144,79 @@ function fileEmit(path, eventDict) {
|
|
|
143
144
|
(0, import_fs.mkdirSync)((0, import_path.dirname)(path), { recursive: true });
|
|
144
145
|
(0, import_fs.appendFileSync)(path, JSON.stringify(eventDict) + "\n");
|
|
145
146
|
}
|
|
147
|
+
var pendingRequests = /* @__PURE__ */ new Set();
|
|
148
|
+
var REPLAY_COOLDOWN_MS = 1e4;
|
|
149
|
+
async function sendOne(apiUrl, apiKey, body) {
|
|
150
|
+
const resp = await fetch(`${apiUrl}/ingest`, {
|
|
151
|
+
method: "POST",
|
|
152
|
+
headers: {
|
|
153
|
+
"Content-Type": "application/json",
|
|
154
|
+
"X-API-Key": apiKey
|
|
155
|
+
},
|
|
156
|
+
body,
|
|
157
|
+
signal: AbortSignal.timeout(1e4)
|
|
158
|
+
});
|
|
159
|
+
if (resp.status !== 201) {
|
|
160
|
+
throw new Error(`HTTP ${resp.status}`);
|
|
161
|
+
}
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
146
164
|
function createHttpEmitter(apiUrl, apiKey, fallbackPath) {
|
|
165
|
+
let replaying = false;
|
|
166
|
+
let lastReplayAttempt = 0;
|
|
167
|
+
const tryReplay = async () => {
|
|
168
|
+
const now = Date.now();
|
|
169
|
+
if (now - lastReplayAttempt < REPLAY_COOLDOWN_MS)
|
|
170
|
+
return;
|
|
171
|
+
if (replaying)
|
|
172
|
+
return;
|
|
173
|
+
replaying = true;
|
|
174
|
+
try {
|
|
175
|
+
lastReplayAttempt = now;
|
|
176
|
+
if (!(0, import_fs.existsSync)(fallbackPath))
|
|
177
|
+
return;
|
|
178
|
+
const stat = (0, import_fs.statSync)(fallbackPath);
|
|
179
|
+
if (stat.size === 0)
|
|
180
|
+
return;
|
|
181
|
+
const lines = (0, import_fs.readFileSync)(fallbackPath, "utf-8").split("\n").filter((l) => l.trim());
|
|
182
|
+
if (lines.length === 0)
|
|
183
|
+
return;
|
|
184
|
+
const remaining = [];
|
|
185
|
+
for (const line of lines) {
|
|
186
|
+
try {
|
|
187
|
+
await sendOne(apiUrl, apiKey, line);
|
|
188
|
+
} catch {
|
|
189
|
+
remaining.push(line);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (remaining.length > 0) {
|
|
193
|
+
(0, import_fs.writeFileSync)(fallbackPath, remaining.join("\n") + "\n");
|
|
194
|
+
} else {
|
|
195
|
+
(0, import_fs.unlinkSync)(fallbackPath);
|
|
196
|
+
}
|
|
197
|
+
const replayed = lines.length - remaining.length;
|
|
198
|
+
if (replayed > 0) {
|
|
199
|
+
console.log(`wytness: replayed ${replayed} fallback events`);
|
|
200
|
+
}
|
|
201
|
+
} catch {
|
|
202
|
+
} finally {
|
|
203
|
+
replaying = false;
|
|
204
|
+
}
|
|
205
|
+
};
|
|
147
206
|
return (eventDict) => {
|
|
148
207
|
const doSend = async () => {
|
|
149
208
|
try {
|
|
150
209
|
const body = JSON.stringify(eventDict);
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
headers: {
|
|
154
|
-
"Content-Type": "application/json",
|
|
155
|
-
"X-API-Key": apiKey
|
|
156
|
-
},
|
|
157
|
-
body,
|
|
158
|
-
signal: AbortSignal.timeout(1e4)
|
|
159
|
-
});
|
|
160
|
-
if (resp.status !== 201) {
|
|
161
|
-
console.error(`HTTP ingest failed: ${resp.status}`);
|
|
162
|
-
fileEmit(fallbackPath, eventDict);
|
|
163
|
-
}
|
|
210
|
+
await sendOne(apiUrl, apiKey, body);
|
|
211
|
+
tryReplay();
|
|
164
212
|
} catch (e) {
|
|
165
213
|
console.error(`HTTP ingest error: ${e}`);
|
|
166
214
|
fileEmit(fallbackPath, eventDict);
|
|
167
215
|
}
|
|
168
216
|
};
|
|
169
|
-
|
|
217
|
+
const p = doSend();
|
|
218
|
+
pendingRequests.add(p);
|
|
219
|
+
p.finally(() => pendingRequests.delete(p));
|
|
170
220
|
};
|
|
171
221
|
}
|
|
172
222
|
function createFileEmitter(fallbackPath) {
|
|
@@ -174,6 +224,9 @@ function createFileEmitter(fallbackPath) {
|
|
|
174
224
|
fileEmit(fallbackPath, eventDict);
|
|
175
225
|
};
|
|
176
226
|
}
|
|
227
|
+
async function flushPending() {
|
|
228
|
+
await Promise.allSettled([...pendingRequests]);
|
|
229
|
+
}
|
|
177
230
|
|
|
178
231
|
// src/client.ts
|
|
179
232
|
var import_crypto3 = require("crypto");
|
|
@@ -192,15 +245,19 @@ var AuditClient = class {
|
|
|
192
245
|
this.agentVersion = options.agentVersion ?? "0.1.0";
|
|
193
246
|
this.humanOperatorId = options.humanOperatorId ?? "unknown";
|
|
194
247
|
this._sessionId = (0, import_crypto3.randomUUID)();
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const raw = (0, import_fs2.readFileSync)(keyPath);
|
|
198
|
-
this._secretKey = new Uint8Array(raw);
|
|
248
|
+
if (options.signingKey) {
|
|
249
|
+
this._secretKey = new Uint8Array(Buffer.from(options.signingKey, "base64"));
|
|
199
250
|
} else {
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
251
|
+
const keyPath = options.signingKeyPath ?? "./keys/signing.key";
|
|
252
|
+
if ((0, import_fs2.existsSync)(keyPath)) {
|
|
253
|
+
const raw = (0, import_fs2.readFileSync)(keyPath);
|
|
254
|
+
this._secretKey = new Uint8Array(raw);
|
|
255
|
+
} else {
|
|
256
|
+
const kp = generateKeypair();
|
|
257
|
+
this._secretKey = kp.secretKey;
|
|
258
|
+
(0, import_fs2.mkdirSync)((0, import_path2.dirname)(keyPath), { recursive: true });
|
|
259
|
+
(0, import_fs2.writeFileSync)(keyPath, Buffer.from(kp.secretKey));
|
|
260
|
+
}
|
|
204
261
|
}
|
|
205
262
|
const fallback = options.fallbackLogPath ?? "./audit_fallback.jsonl";
|
|
206
263
|
if (options.httpApiKey) {
|
|
@@ -216,12 +273,25 @@ var AuditClient = class {
|
|
|
216
273
|
get sessionId() {
|
|
217
274
|
return this._sessionId;
|
|
218
275
|
}
|
|
276
|
+
/** Record an audit event. Never throws — errors are logged and swallowed. */
|
|
219
277
|
record(event) {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
278
|
+
try {
|
|
279
|
+
const d = { ...event };
|
|
280
|
+
d["prev_event_hash"] = this._prevEventHash;
|
|
281
|
+
d["signature"] = signEvent(d, this._secretKey);
|
|
282
|
+
this._prevEventHash = computeEventHash(d);
|
|
283
|
+
this._emit(d);
|
|
284
|
+
} catch (e) {
|
|
285
|
+
console.error(`wytness: failed to record event: ${e}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Wait for all pending HTTP requests to complete.
|
|
290
|
+
* Call this before process exit in serverless/short-lived environments
|
|
291
|
+
* to ensure all events are delivered.
|
|
292
|
+
*/
|
|
293
|
+
async flush() {
|
|
294
|
+
await flushPending();
|
|
225
295
|
}
|
|
226
296
|
};
|
|
227
297
|
|
|
@@ -238,41 +308,47 @@ function sanitise(params) {
|
|
|
238
308
|
function hashValue(value) {
|
|
239
309
|
return (0, import_crypto4.createHash)("sha256").update(JSON.stringify(value, null, 0)).digest("hex");
|
|
240
310
|
}
|
|
311
|
+
function recordEvent(client, toolName, taskId, args, start, status, errorCode, result) {
|
|
312
|
+
const params = {};
|
|
313
|
+
args.forEach((arg, i) => {
|
|
314
|
+
params[`arg${i}`] = arg;
|
|
315
|
+
});
|
|
316
|
+
const event = AuditEventSchema.parse({
|
|
317
|
+
agent_id: client.agentId,
|
|
318
|
+
agent_version: client.agentVersion,
|
|
319
|
+
human_operator_id: client.humanOperatorId,
|
|
320
|
+
task_id: taskId,
|
|
321
|
+
session_id: client.sessionId,
|
|
322
|
+
tool_name: toolName,
|
|
323
|
+
tool_parameters: sanitise(params),
|
|
324
|
+
inputs_hash: hashValue(args),
|
|
325
|
+
outputs_hash: result != null ? hashValue(result) : "",
|
|
326
|
+
status,
|
|
327
|
+
error_code: errorCode,
|
|
328
|
+
duration_ms: Math.round(performance.now() - start)
|
|
329
|
+
});
|
|
330
|
+
client.record(event);
|
|
331
|
+
}
|
|
241
332
|
function wrapFn(client, fn, toolName, taskId) {
|
|
242
333
|
const wrapped = function(...args) {
|
|
243
334
|
const start = performance.now();
|
|
244
|
-
let status = "success";
|
|
245
|
-
let errorCode = null;
|
|
246
335
|
let result;
|
|
247
|
-
const inputsHash = hashValue(args);
|
|
248
336
|
try {
|
|
249
337
|
result = fn.apply(this, args);
|
|
250
338
|
} catch (e) {
|
|
251
|
-
|
|
252
|
-
errorCode = e.constructor.name;
|
|
339
|
+
recordEvent(client, toolName, taskId, args, start, "failure", e.constructor.name, null);
|
|
253
340
|
throw e;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
})
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
agent_version: client.agentVersion,
|
|
263
|
-
human_operator_id: client.humanOperatorId,
|
|
264
|
-
task_id: taskId,
|
|
265
|
-
session_id: client.sessionId,
|
|
266
|
-
tool_name: toolName,
|
|
267
|
-
tool_parameters: sanitise(params),
|
|
268
|
-
inputs_hash: inputsHash,
|
|
269
|
-
outputs_hash: result != null ? hashValue(result) : "",
|
|
270
|
-
status,
|
|
271
|
-
error_code: errorCode,
|
|
272
|
-
duration_ms: durationMs
|
|
341
|
+
}
|
|
342
|
+
if (result && typeof result.then === "function") {
|
|
343
|
+
return result.then((resolved) => {
|
|
344
|
+
recordEvent(client, toolName, taskId, args, start, "success", null, resolved);
|
|
345
|
+
return resolved;
|
|
346
|
+
}).catch((e) => {
|
|
347
|
+
recordEvent(client, toolName, taskId, args, start, "failure", e.constructor.name, null);
|
|
348
|
+
throw e;
|
|
273
349
|
});
|
|
274
|
-
client.record(event);
|
|
275
350
|
}
|
|
351
|
+
recordEvent(client, toolName, taskId, args, start, "success", null, result);
|
|
276
352
|
return result;
|
|
277
353
|
};
|
|
278
354
|
Object.defineProperty(wrapped, "name", { value: toolName });
|
|
@@ -301,6 +377,7 @@ function auditTool(client, fnOrTaskId, options) {
|
|
|
301
377
|
createFileEmitter,
|
|
302
378
|
createHttpEmitter,
|
|
303
379
|
generateKeypair,
|
|
380
|
+
hashValue,
|
|
304
381
|
signEvent,
|
|
305
382
|
verifyChain,
|
|
306
383
|
verifyEvent
|
package/dist/index.d.cts
CHANGED
|
@@ -104,6 +104,7 @@ interface AuditClientOptions {
|
|
|
104
104
|
agentId: string;
|
|
105
105
|
agentVersion?: string;
|
|
106
106
|
humanOperatorId?: string;
|
|
107
|
+
signingKey?: string;
|
|
107
108
|
signingKeyPath?: string;
|
|
108
109
|
fallbackLogPath?: string;
|
|
109
110
|
httpApiKey?: string;
|
|
@@ -119,15 +120,23 @@ declare class AuditClient {
|
|
|
119
120
|
private _emit;
|
|
120
121
|
constructor(options: AuditClientOptions);
|
|
121
122
|
get sessionId(): string;
|
|
123
|
+
/** Record an audit event. Never throws — errors are logged and swallowed. */
|
|
122
124
|
record(event: AuditEvent): void;
|
|
125
|
+
/**
|
|
126
|
+
* Wait for all pending HTTP requests to complete.
|
|
127
|
+
* Call this before process exit in serverless/short-lived environments
|
|
128
|
+
* to ensure all events are delivered.
|
|
129
|
+
*/
|
|
130
|
+
flush(): Promise<void>;
|
|
123
131
|
}
|
|
124
132
|
|
|
133
|
+
declare function hashValue(value: unknown): string;
|
|
125
134
|
interface AuditToolOptions {
|
|
126
135
|
toolName?: string;
|
|
127
136
|
taskId?: string;
|
|
128
137
|
}
|
|
129
138
|
/**
|
|
130
|
-
* Wrap a function with audit logging.
|
|
139
|
+
* Wrap a function with audit logging. Works with both sync and async functions.
|
|
131
140
|
*
|
|
132
141
|
* Supports two calling styles:
|
|
133
142
|
*
|
|
@@ -140,4 +149,4 @@ interface AuditToolOptions {
|
|
|
140
149
|
*/
|
|
141
150
|
declare function auditTool(client: AuditClient, fnOrTaskId?: ((...args: unknown[]) => unknown) | string, options?: AuditToolOptions | string): any;
|
|
142
151
|
|
|
143
|
-
export { AuditClient, type AuditClientOptions, type AuditEvent, AuditEventSchema, auditTool, computeEventHash, createFileEmitter, createHttpEmitter, generateKeypair, signEvent, verifyChain, verifyEvent };
|
|
152
|
+
export { AuditClient, type AuditClientOptions, type AuditEvent, AuditEventSchema, auditTool, computeEventHash, createFileEmitter, createHttpEmitter, generateKeypair, hashValue, signEvent, verifyChain, verifyEvent };
|
package/dist/index.d.ts
CHANGED
|
@@ -104,6 +104,7 @@ interface AuditClientOptions {
|
|
|
104
104
|
agentId: string;
|
|
105
105
|
agentVersion?: string;
|
|
106
106
|
humanOperatorId?: string;
|
|
107
|
+
signingKey?: string;
|
|
107
108
|
signingKeyPath?: string;
|
|
108
109
|
fallbackLogPath?: string;
|
|
109
110
|
httpApiKey?: string;
|
|
@@ -119,15 +120,23 @@ declare class AuditClient {
|
|
|
119
120
|
private _emit;
|
|
120
121
|
constructor(options: AuditClientOptions);
|
|
121
122
|
get sessionId(): string;
|
|
123
|
+
/** Record an audit event. Never throws — errors are logged and swallowed. */
|
|
122
124
|
record(event: AuditEvent): void;
|
|
125
|
+
/**
|
|
126
|
+
* Wait for all pending HTTP requests to complete.
|
|
127
|
+
* Call this before process exit in serverless/short-lived environments
|
|
128
|
+
* to ensure all events are delivered.
|
|
129
|
+
*/
|
|
130
|
+
flush(): Promise<void>;
|
|
123
131
|
}
|
|
124
132
|
|
|
133
|
+
declare function hashValue(value: unknown): string;
|
|
125
134
|
interface AuditToolOptions {
|
|
126
135
|
toolName?: string;
|
|
127
136
|
taskId?: string;
|
|
128
137
|
}
|
|
129
138
|
/**
|
|
130
|
-
* Wrap a function with audit logging.
|
|
139
|
+
* Wrap a function with audit logging. Works with both sync and async functions.
|
|
131
140
|
*
|
|
132
141
|
* Supports two calling styles:
|
|
133
142
|
*
|
|
@@ -140,4 +149,4 @@ interface AuditToolOptions {
|
|
|
140
149
|
*/
|
|
141
150
|
declare function auditTool(client: AuditClient, fnOrTaskId?: ((...args: unknown[]) => unknown) | string, options?: AuditToolOptions | string): any;
|
|
142
151
|
|
|
143
|
-
export { AuditClient, type AuditClientOptions, type AuditEvent, AuditEventSchema, auditTool, computeEventHash, createFileEmitter, createHttpEmitter, generateKeypair, signEvent, verifyChain, verifyEvent };
|
|
152
|
+
export { AuditClient, type AuditClientOptions, type AuditEvent, AuditEventSchema, auditTool, computeEventHash, createFileEmitter, createHttpEmitter, generateKeypair, hashValue, signEvent, verifyChain, verifyEvent };
|
package/dist/index.js
CHANGED
|
@@ -92,36 +92,85 @@ function verifyChain(events) {
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
// src/emitter.ts
|
|
95
|
-
import { mkdirSync, appendFileSync } from "fs";
|
|
95
|
+
import { mkdirSync, appendFileSync, existsSync, readFileSync, unlinkSync, writeFileSync, statSync } from "fs";
|
|
96
96
|
import { dirname } from "path";
|
|
97
97
|
function fileEmit(path, eventDict) {
|
|
98
98
|
mkdirSync(dirname(path), { recursive: true });
|
|
99
99
|
appendFileSync(path, JSON.stringify(eventDict) + "\n");
|
|
100
100
|
}
|
|
101
|
+
var pendingRequests = /* @__PURE__ */ new Set();
|
|
102
|
+
var REPLAY_COOLDOWN_MS = 1e4;
|
|
103
|
+
async function sendOne(apiUrl, apiKey, body) {
|
|
104
|
+
const resp = await fetch(`${apiUrl}/ingest`, {
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: {
|
|
107
|
+
"Content-Type": "application/json",
|
|
108
|
+
"X-API-Key": apiKey
|
|
109
|
+
},
|
|
110
|
+
body,
|
|
111
|
+
signal: AbortSignal.timeout(1e4)
|
|
112
|
+
});
|
|
113
|
+
if (resp.status !== 201) {
|
|
114
|
+
throw new Error(`HTTP ${resp.status}`);
|
|
115
|
+
}
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
101
118
|
function createHttpEmitter(apiUrl, apiKey, fallbackPath) {
|
|
119
|
+
let replaying = false;
|
|
120
|
+
let lastReplayAttempt = 0;
|
|
121
|
+
const tryReplay = async () => {
|
|
122
|
+
const now = Date.now();
|
|
123
|
+
if (now - lastReplayAttempt < REPLAY_COOLDOWN_MS)
|
|
124
|
+
return;
|
|
125
|
+
if (replaying)
|
|
126
|
+
return;
|
|
127
|
+
replaying = true;
|
|
128
|
+
try {
|
|
129
|
+
lastReplayAttempt = now;
|
|
130
|
+
if (!existsSync(fallbackPath))
|
|
131
|
+
return;
|
|
132
|
+
const stat = statSync(fallbackPath);
|
|
133
|
+
if (stat.size === 0)
|
|
134
|
+
return;
|
|
135
|
+
const lines = readFileSync(fallbackPath, "utf-8").split("\n").filter((l) => l.trim());
|
|
136
|
+
if (lines.length === 0)
|
|
137
|
+
return;
|
|
138
|
+
const remaining = [];
|
|
139
|
+
for (const line of lines) {
|
|
140
|
+
try {
|
|
141
|
+
await sendOne(apiUrl, apiKey, line);
|
|
142
|
+
} catch {
|
|
143
|
+
remaining.push(line);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (remaining.length > 0) {
|
|
147
|
+
writeFileSync(fallbackPath, remaining.join("\n") + "\n");
|
|
148
|
+
} else {
|
|
149
|
+
unlinkSync(fallbackPath);
|
|
150
|
+
}
|
|
151
|
+
const replayed = lines.length - remaining.length;
|
|
152
|
+
if (replayed > 0) {
|
|
153
|
+
console.log(`wytness: replayed ${replayed} fallback events`);
|
|
154
|
+
}
|
|
155
|
+
} catch {
|
|
156
|
+
} finally {
|
|
157
|
+
replaying = false;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
102
160
|
return (eventDict) => {
|
|
103
161
|
const doSend = async () => {
|
|
104
162
|
try {
|
|
105
163
|
const body = JSON.stringify(eventDict);
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
headers: {
|
|
109
|
-
"Content-Type": "application/json",
|
|
110
|
-
"X-API-Key": apiKey
|
|
111
|
-
},
|
|
112
|
-
body,
|
|
113
|
-
signal: AbortSignal.timeout(1e4)
|
|
114
|
-
});
|
|
115
|
-
if (resp.status !== 201) {
|
|
116
|
-
console.error(`HTTP ingest failed: ${resp.status}`);
|
|
117
|
-
fileEmit(fallbackPath, eventDict);
|
|
118
|
-
}
|
|
164
|
+
await sendOne(apiUrl, apiKey, body);
|
|
165
|
+
tryReplay();
|
|
119
166
|
} catch (e) {
|
|
120
167
|
console.error(`HTTP ingest error: ${e}`);
|
|
121
168
|
fileEmit(fallbackPath, eventDict);
|
|
122
169
|
}
|
|
123
170
|
};
|
|
124
|
-
|
|
171
|
+
const p = doSend();
|
|
172
|
+
pendingRequests.add(p);
|
|
173
|
+
p.finally(() => pendingRequests.delete(p));
|
|
125
174
|
};
|
|
126
175
|
}
|
|
127
176
|
function createFileEmitter(fallbackPath) {
|
|
@@ -129,10 +178,13 @@ function createFileEmitter(fallbackPath) {
|
|
|
129
178
|
fileEmit(fallbackPath, eventDict);
|
|
130
179
|
};
|
|
131
180
|
}
|
|
181
|
+
async function flushPending() {
|
|
182
|
+
await Promise.allSettled([...pendingRequests]);
|
|
183
|
+
}
|
|
132
184
|
|
|
133
185
|
// src/client.ts
|
|
134
186
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
135
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync as mkdirSync2 } from "fs";
|
|
187
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "fs";
|
|
136
188
|
import { dirname as dirname2 } from "path";
|
|
137
189
|
var AuditClient = class {
|
|
138
190
|
agentId;
|
|
@@ -147,15 +199,19 @@ var AuditClient = class {
|
|
|
147
199
|
this.agentVersion = options.agentVersion ?? "0.1.0";
|
|
148
200
|
this.humanOperatorId = options.humanOperatorId ?? "unknown";
|
|
149
201
|
this._sessionId = randomUUID2();
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const raw = readFileSync(keyPath);
|
|
153
|
-
this._secretKey = new Uint8Array(raw);
|
|
202
|
+
if (options.signingKey) {
|
|
203
|
+
this._secretKey = new Uint8Array(Buffer.from(options.signingKey, "base64"));
|
|
154
204
|
} else {
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
205
|
+
const keyPath = options.signingKeyPath ?? "./keys/signing.key";
|
|
206
|
+
if (existsSync2(keyPath)) {
|
|
207
|
+
const raw = readFileSync2(keyPath);
|
|
208
|
+
this._secretKey = new Uint8Array(raw);
|
|
209
|
+
} else {
|
|
210
|
+
const kp = generateKeypair();
|
|
211
|
+
this._secretKey = kp.secretKey;
|
|
212
|
+
mkdirSync2(dirname2(keyPath), { recursive: true });
|
|
213
|
+
writeFileSync2(keyPath, Buffer.from(kp.secretKey));
|
|
214
|
+
}
|
|
159
215
|
}
|
|
160
216
|
const fallback = options.fallbackLogPath ?? "./audit_fallback.jsonl";
|
|
161
217
|
if (options.httpApiKey) {
|
|
@@ -171,12 +227,25 @@ var AuditClient = class {
|
|
|
171
227
|
get sessionId() {
|
|
172
228
|
return this._sessionId;
|
|
173
229
|
}
|
|
230
|
+
/** Record an audit event. Never throws — errors are logged and swallowed. */
|
|
174
231
|
record(event) {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
232
|
+
try {
|
|
233
|
+
const d = { ...event };
|
|
234
|
+
d["prev_event_hash"] = this._prevEventHash;
|
|
235
|
+
d["signature"] = signEvent(d, this._secretKey);
|
|
236
|
+
this._prevEventHash = computeEventHash(d);
|
|
237
|
+
this._emit(d);
|
|
238
|
+
} catch (e) {
|
|
239
|
+
console.error(`wytness: failed to record event: ${e}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Wait for all pending HTTP requests to complete.
|
|
244
|
+
* Call this before process exit in serverless/short-lived environments
|
|
245
|
+
* to ensure all events are delivered.
|
|
246
|
+
*/
|
|
247
|
+
async flush() {
|
|
248
|
+
await flushPending();
|
|
180
249
|
}
|
|
181
250
|
};
|
|
182
251
|
|
|
@@ -193,41 +262,47 @@ function sanitise(params) {
|
|
|
193
262
|
function hashValue(value) {
|
|
194
263
|
return createHash2("sha256").update(JSON.stringify(value, null, 0)).digest("hex");
|
|
195
264
|
}
|
|
265
|
+
function recordEvent(client, toolName, taskId, args, start, status, errorCode, result) {
|
|
266
|
+
const params = {};
|
|
267
|
+
args.forEach((arg, i) => {
|
|
268
|
+
params[`arg${i}`] = arg;
|
|
269
|
+
});
|
|
270
|
+
const event = AuditEventSchema.parse({
|
|
271
|
+
agent_id: client.agentId,
|
|
272
|
+
agent_version: client.agentVersion,
|
|
273
|
+
human_operator_id: client.humanOperatorId,
|
|
274
|
+
task_id: taskId,
|
|
275
|
+
session_id: client.sessionId,
|
|
276
|
+
tool_name: toolName,
|
|
277
|
+
tool_parameters: sanitise(params),
|
|
278
|
+
inputs_hash: hashValue(args),
|
|
279
|
+
outputs_hash: result != null ? hashValue(result) : "",
|
|
280
|
+
status,
|
|
281
|
+
error_code: errorCode,
|
|
282
|
+
duration_ms: Math.round(performance.now() - start)
|
|
283
|
+
});
|
|
284
|
+
client.record(event);
|
|
285
|
+
}
|
|
196
286
|
function wrapFn(client, fn, toolName, taskId) {
|
|
197
287
|
const wrapped = function(...args) {
|
|
198
288
|
const start = performance.now();
|
|
199
|
-
let status = "success";
|
|
200
|
-
let errorCode = null;
|
|
201
289
|
let result;
|
|
202
|
-
const inputsHash = hashValue(args);
|
|
203
290
|
try {
|
|
204
291
|
result = fn.apply(this, args);
|
|
205
292
|
} catch (e) {
|
|
206
|
-
|
|
207
|
-
errorCode = e.constructor.name;
|
|
293
|
+
recordEvent(client, toolName, taskId, args, start, "failure", e.constructor.name, null);
|
|
208
294
|
throw e;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
})
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
agent_version: client.agentVersion,
|
|
218
|
-
human_operator_id: client.humanOperatorId,
|
|
219
|
-
task_id: taskId,
|
|
220
|
-
session_id: client.sessionId,
|
|
221
|
-
tool_name: toolName,
|
|
222
|
-
tool_parameters: sanitise(params),
|
|
223
|
-
inputs_hash: inputsHash,
|
|
224
|
-
outputs_hash: result != null ? hashValue(result) : "",
|
|
225
|
-
status,
|
|
226
|
-
error_code: errorCode,
|
|
227
|
-
duration_ms: durationMs
|
|
295
|
+
}
|
|
296
|
+
if (result && typeof result.then === "function") {
|
|
297
|
+
return result.then((resolved) => {
|
|
298
|
+
recordEvent(client, toolName, taskId, args, start, "success", null, resolved);
|
|
299
|
+
return resolved;
|
|
300
|
+
}).catch((e) => {
|
|
301
|
+
recordEvent(client, toolName, taskId, args, start, "failure", e.constructor.name, null);
|
|
302
|
+
throw e;
|
|
228
303
|
});
|
|
229
|
-
client.record(event);
|
|
230
304
|
}
|
|
305
|
+
recordEvent(client, toolName, taskId, args, start, "success", null, result);
|
|
231
306
|
return result;
|
|
232
307
|
};
|
|
233
308
|
Object.defineProperty(wrapped, "name", { value: toolName });
|
|
@@ -255,6 +330,7 @@ export {
|
|
|
255
330
|
createFileEmitter,
|
|
256
331
|
createHttpEmitter,
|
|
257
332
|
generateKeypair,
|
|
333
|
+
hashValue,
|
|
258
334
|
signEvent,
|
|
259
335
|
verifyChain,
|
|
260
336
|
verifyEvent
|
package/package.json
CHANGED