@wytness/sdk 0.3.1 → 0.5.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 CHANGED
@@ -30,9 +30,11 @@ await sendEmail("team@company.com", "Report", "Weekly summary");
30
30
 
31
31
  ## Features
32
32
 
33
+ - **Response capture** — AI agent responses are automatically captured, truncated to 5K chars, and PII-redacted
34
+ - **PII redaction** — emails, SSN, TFN, credit cards, and phone numbers are automatically redacted from responses and parameter values
33
35
  - **Cryptographic signing** — every event is signed with Ed25519 (tweetnacl)
34
36
  - **Hash chaining** — tamper-evident chain of events per agent session
35
- - **Automatic redaction** — secrets in parameters are automatically redacted
37
+ - **Automatic secret redaction** — secrets in parameter names are automatically redacted
36
38
  - **HTTP transport** — stream events to api.wytness.dev over HTTPS
37
39
  - **File fallback** — events are saved locally if the API is unreachable
38
40
 
@@ -66,7 +68,7 @@ const client = new AuditClient({
66
68
 
67
69
  ### `auditTool(client, fn, options)`
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
+ Wraps a function to automatically log audit events. Captures function name, parameters, **response text** (truncated to 5K chars, PII-redacted), execution duration, success/failure status, cryptographic signature, and hash chain link.
70
72
 
71
73
  ### `client.sessionId`
72
74
 
@@ -114,6 +116,7 @@ import {
114
116
  AuditClientOptions,
115
117
  auditTool,
116
118
  hashValue,
119
+ redactPii,
117
120
  AuditEvent,
118
121
  AuditEventSchema,
119
122
  generateKeypair,
package/dist/index.cjs CHANGED
@@ -32,12 +32,14 @@ var src_exports = {};
32
32
  __export(src_exports, {
33
33
  AuditClient: () => AuditClient,
34
34
  AuditEventSchema: () => AuditEventSchema,
35
+ SessionTokenizer: () => SessionTokenizer,
35
36
  auditTool: () => auditTool,
36
37
  computeEventHash: () => computeEventHash,
37
38
  createFileEmitter: () => createFileEmitter,
38
39
  createHttpEmitter: () => createHttpEmitter,
39
40
  generateKeypair: () => generateKeypair,
40
41
  hashValue: () => hashValue,
42
+ redactPii: () => redactPii,
41
43
  signEvent: () => signEvent,
42
44
  verifyChain: () => verifyChain,
43
45
  verifyEvent: () => verifyEvent
@@ -72,6 +74,7 @@ var AuditEventSchema = import_zod.z.object({
72
74
  inputs_source: import_zod.z.string().default(""),
73
75
  outputs_hash: import_zod.z.string().default(""),
74
76
  outputs_destination: import_zod.z.string().default(""),
77
+ response: import_zod.z.string().default(""),
75
78
  // Layer 5: Outcome
76
79
  status: import_zod.z.string().default("success"),
77
80
  error_code: import_zod.z.string().nullable().default(null),
@@ -79,7 +82,10 @@ var AuditEventSchema = import_zod.z.object({
79
82
  retry_count: import_zod.z.number().int().default(0),
80
83
  // Chain integrity
81
84
  prev_event_hash: import_zod.z.string().default(""),
82
- signature: import_zod.z.string().default("")
85
+ signature: import_zod.z.string().default(""),
86
+ // Pseudonymization
87
+ encrypted_token_map: import_zod.z.string().default(""),
88
+ pseudonymization_version: import_zod.z.string().default("")
83
89
  });
84
90
 
85
91
  // src/signing.ts
@@ -230,9 +236,150 @@ async function flushPending() {
230
236
  }
231
237
 
232
238
  // src/client.ts
233
- var import_crypto3 = require("crypto");
239
+ var import_crypto4 = require("crypto");
234
240
  var import_fs2 = require("fs");
235
241
  var import_path2 = require("path");
242
+
243
+ // src/tokenizer.ts
244
+ var import_crypto3 = require("crypto");
245
+ var import_tweetnacl2 = __toESM(require("tweetnacl"), 1);
246
+ var import_tweetnacl_util2 = require("tweetnacl-util");
247
+ var SECRET_PATTERN = /key|secret|token|password|passwd|pwd|credential|auth/i;
248
+ var PII_PATTERNS = [
249
+ [/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, "EMAIL"],
250
+ [/\b\d{3}[-.\s]?\d{2}[-.\s]?\d{4}\b/g, "SSN"],
251
+ [/\b\d{3}[-.\s]?\d{3}[-.\s]?\d{3}\b/g, "TFN"],
252
+ [/\b(?:\d{4}[-\s]?){3}\d{4}\b/g, "CARD"],
253
+ [/\b(?:\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g, "PHONE"]
254
+ ];
255
+ function hmacPseudonym(value, secret, piiType = "PII") {
256
+ const digest = (0, import_crypto3.createHmac)("sha256", secret).update(value, "utf-8").digest("hex");
257
+ return `${piiType}_${digest.slice(0, 8)}`;
258
+ }
259
+ var SessionTokenizer = class {
260
+ _hmacSecret;
261
+ _encryptionPub;
262
+ _piiFields;
263
+ _tokenMap = {};
264
+ // pseudonym → original
265
+ _reverseMap = {};
266
+ // original → pseudonym
267
+ /**
268
+ * @param hmacSecret 32-byte secret for deterministic pseudonyms.
269
+ * @param encryptionPublicKey 32-byte X25519 public key for encrypting the token map.
270
+ * @param piiFields Parameter names whose values should be fully pseudonymized.
271
+ */
272
+ constructor(hmacSecret, encryptionPublicKey, piiFields = []) {
273
+ this._hmacSecret = hmacSecret;
274
+ this._encryptionPub = encryptionPublicKey;
275
+ this._piiFields = piiFields;
276
+ }
277
+ get tokenMap() {
278
+ return { ...this._tokenMap };
279
+ }
280
+ addMapping(original, pseudonym) {
281
+ this._tokenMap[pseudonym] = original;
282
+ this._reverseMap[original] = pseudonym;
283
+ }
284
+ pseudonymizeValue(value, fieldPath = "") {
285
+ if (!value || !value.trim())
286
+ return value;
287
+ if (this._piiFields.includes(fieldPath)) {
288
+ if (this._reverseMap[value])
289
+ return this._reverseMap[value];
290
+ const pseudonym = hmacPseudonym(value, this._hmacSecret);
291
+ this.addMapping(value, pseudonym);
292
+ return pseudonym;
293
+ }
294
+ return value;
295
+ }
296
+ pseudonymizeText(text) {
297
+ if (!text)
298
+ return text;
299
+ const known = Object.keys(this._reverseMap).sort(
300
+ (a, b) => b.length - a.length
301
+ );
302
+ for (const original of known) {
303
+ if (text.includes(original)) {
304
+ text = text.split(original).join(this._reverseMap[original]);
305
+ }
306
+ }
307
+ for (const [pattern, piiType] of PII_PATTERNS) {
308
+ pattern.lastIndex = 0;
309
+ text = text.replace(pattern, (matched) => {
310
+ if (this._reverseMap[matched])
311
+ return this._reverseMap[matched];
312
+ const pseudonym = hmacPseudonym(matched, this._hmacSecret, piiType);
313
+ this.addMapping(matched, pseudonym);
314
+ return pseudonym;
315
+ });
316
+ }
317
+ return text;
318
+ }
319
+ pseudonymizeParams(params) {
320
+ const result = {};
321
+ for (const [k, v] of Object.entries(params)) {
322
+ if (SECRET_PATTERN.test(k)) {
323
+ result[k] = "[REDACTED]";
324
+ } else if (this._piiFields.includes(k)) {
325
+ const val = String(v).slice(0, 500);
326
+ result[k] = this.pseudonymizeValue(val, k);
327
+ } else {
328
+ const val = String(v).slice(0, 500);
329
+ result[k] = this.pseudonymizeText(val);
330
+ }
331
+ }
332
+ return result;
333
+ }
334
+ /**
335
+ * Encrypt the token map with the customer's X25519 public key.
336
+ *
337
+ * Format: base64([ephemeral_pub 32B][nonce 24B][ciphertext])
338
+ * Uses NaCl box (X25519 + XSalsa20-Poly1305).
339
+ */
340
+ encryptTokenMap() {
341
+ const plaintext = Buffer.from(
342
+ JSON.stringify(this._tokenMap),
343
+ "utf-8"
344
+ );
345
+ const ephemeral = import_tweetnacl2.default.box.keyPair();
346
+ const nonce = import_tweetnacl2.default.randomBytes(24);
347
+ const ciphertext = import_tweetnacl2.default.box(
348
+ plaintext,
349
+ nonce,
350
+ this._encryptionPub,
351
+ ephemeral.secretKey
352
+ );
353
+ if (!ciphertext) {
354
+ throw new Error("Encryption failed");
355
+ }
356
+ const packed = new Uint8Array(32 + 24 + ciphertext.length);
357
+ packed.set(ephemeral.publicKey, 0);
358
+ packed.set(nonce, 32);
359
+ packed.set(ciphertext, 56);
360
+ return (0, import_tweetnacl_util2.encodeBase64)(packed);
361
+ }
362
+ /**
363
+ * Decrypt a token map using the customer's X25519 private key.
364
+ *
365
+ * @param encryptedB64 Base64-encoded encrypted blob from encryptTokenMap().
366
+ * @param privateKey 32-byte X25519 private (secret) key.
367
+ * @returns The token map (pseudonym → original).
368
+ */
369
+ static decryptTokenMap(encryptedB64, privateKey) {
370
+ const packed = (0, import_tweetnacl_util2.decodeBase64)(encryptedB64);
371
+ const ephemeralPub = packed.slice(0, 32);
372
+ const nonce = packed.slice(32, 56);
373
+ const ciphertext = packed.slice(56);
374
+ const plaintext = import_tweetnacl2.default.box.open(ciphertext, nonce, ephemeralPub, privateKey);
375
+ if (!plaintext) {
376
+ throw new Error("Decryption failed \u2014 wrong key or corrupted data");
377
+ }
378
+ return JSON.parse(Buffer.from(plaintext).toString("utf-8"));
379
+ }
380
+ };
381
+
382
+ // src/client.ts
236
383
  var AuditClient = class {
237
384
  agentId;
238
385
  agentVersion;
@@ -241,11 +388,12 @@ var AuditClient = class {
241
388
  _sessionId;
242
389
  _secretKey;
243
390
  _emit;
391
+ _tokenizer = null;
244
392
  constructor(options) {
245
393
  this.agentId = options.agentId;
246
394
  this.agentVersion = options.agentVersion ?? "0.1.0";
247
395
  this.humanOperatorId = options.humanOperatorId ?? "unknown";
248
- this._sessionId = (0, import_crypto3.randomUUID)();
396
+ this._sessionId = (0, import_crypto4.randomUUID)();
249
397
  if (options.signingKey) {
250
398
  this._secretKey = new Uint8Array(Buffer.from(options.signingKey, "base64"));
251
399
  } else {
@@ -270,10 +418,18 @@ var AuditClient = class {
270
418
  } else {
271
419
  this._emit = createFileEmitter(fallback);
272
420
  }
421
+ if (options.piiHmacSecret && options.encryptionPublicKey) {
422
+ const hmacBytes = new Uint8Array(Buffer.from(options.piiHmacSecret, "base64"));
423
+ const pubBytes = new Uint8Array(Buffer.from(options.encryptionPublicKey, "base64"));
424
+ this._tokenizer = new SessionTokenizer(hmacBytes, pubBytes, options.piiFields ?? []);
425
+ }
273
426
  }
274
427
  get sessionId() {
275
428
  return this._sessionId;
276
429
  }
430
+ get tokenizer() {
431
+ return this._tokenizer;
432
+ }
277
433
  /** Record an audit event. Never throws — errors are logged and swallowed. */
278
434
  record(event) {
279
435
  try {
@@ -297,39 +453,87 @@ var AuditClient = class {
297
453
  };
298
454
 
299
455
  // src/decorator.ts
300
- var import_crypto4 = require("crypto");
301
- var SECRET_PATTERN = /key|secret|token|password|passwd|pwd|credential|auth/i;
456
+ var import_crypto5 = require("crypto");
457
+ var SECRET_PATTERN2 = /key|secret|token|password|passwd|pwd|credential|auth/i;
458
+ var PII_PATTERNS2 = [
459
+ [/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, "[EMAIL_REDACTED]"],
460
+ [/\b\d{3}[-.\s]?\d{2}[-.\s]?\d{4}\b/g, "[SSN_REDACTED]"],
461
+ [/\b\d{3}[-.\s]?\d{3}[-.\s]?\d{3}\b/g, "[TFN_REDACTED]"],
462
+ [/\b(?:\d{4}[-\s]?){3}\d{4}\b/g, "[CARD_REDACTED]"],
463
+ [/\b(?:\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g, "[PHONE_REDACTED]"]
464
+ ];
465
+ var RESPONSE_MAX_CHARS = 5e3;
466
+ function redactPii(text) {
467
+ for (const [pattern, replacement] of PII_PATTERNS2) {
468
+ pattern.lastIndex = 0;
469
+ text = text.replace(pattern, replacement);
470
+ }
471
+ return text;
472
+ }
302
473
  function sanitise(params) {
303
474
  const result = {};
304
475
  for (const [k, v] of Object.entries(params)) {
305
- result[k] = SECRET_PATTERN.test(k) ? "[REDACTED]" : String(v).slice(0, 500);
476
+ if (SECRET_PATTERN2.test(k)) {
477
+ result[k] = "[REDACTED]";
478
+ } else {
479
+ result[k] = redactPii(String(v).slice(0, 500));
480
+ }
306
481
  }
307
482
  return result;
308
483
  }
309
484
  function hashValue(value) {
310
- return (0, import_crypto4.createHash)("sha256").update(JSON.stringify(value, null, 0)).digest("hex");
485
+ return (0, import_crypto5.createHash)("sha256").update(JSON.stringify(value, null, 0)).digest("hex");
311
486
  }
312
487
  function recordEvent(client, toolName, taskId, prompt, args, start, status, errorCode, result) {
313
488
  const params = {};
314
489
  args.forEach((arg, i) => {
315
490
  params[`arg${i}`] = arg;
316
491
  });
317
- const event = AuditEventSchema.parse({
318
- agent_id: client.agentId,
319
- agent_version: client.agentVersion,
320
- human_operator_id: client.humanOperatorId,
321
- task_id: taskId,
322
- session_id: client.sessionId,
323
- tool_name: toolName,
324
- tool_parameters: sanitise(params),
325
- prompt,
326
- inputs_hash: hashValue(args),
327
- outputs_hash: result != null ? hashValue(result) : "",
328
- status,
329
- error_code: errorCode,
330
- duration_ms: Math.round(performance.now() - start)
331
- });
332
- client.record(event);
492
+ const outputsHash = result != null ? hashValue(result) : "";
493
+ const tokenizer = client.tokenizer;
494
+ if (tokenizer) {
495
+ const pseudonymizedParams = tokenizer.pseudonymizeParams(params);
496
+ const pseudonymizedPrompt = prompt ? tokenizer.pseudonymizeText(prompt) : "";
497
+ const response = result != null ? tokenizer.pseudonymizeText(String(result).slice(0, RESPONSE_MAX_CHARS)) : "";
498
+ const event = AuditEventSchema.parse({
499
+ agent_id: client.agentId,
500
+ agent_version: client.agentVersion,
501
+ human_operator_id: client.humanOperatorId,
502
+ task_id: taskId,
503
+ session_id: client.sessionId,
504
+ tool_name: toolName,
505
+ tool_parameters: pseudonymizedParams,
506
+ prompt: pseudonymizedPrompt,
507
+ inputs_hash: hashValue(args),
508
+ outputs_hash: outputsHash,
509
+ response,
510
+ status,
511
+ error_code: errorCode,
512
+ duration_ms: Math.round(performance.now() - start),
513
+ encrypted_token_map: tokenizer.encryptTokenMap(),
514
+ pseudonymization_version: "1.0"
515
+ });
516
+ client.record(event);
517
+ } else {
518
+ const response = result != null ? redactPii(String(result).slice(0, RESPONSE_MAX_CHARS)) : "";
519
+ const event = AuditEventSchema.parse({
520
+ agent_id: client.agentId,
521
+ agent_version: client.agentVersion,
522
+ human_operator_id: client.humanOperatorId,
523
+ task_id: taskId,
524
+ session_id: client.sessionId,
525
+ tool_name: toolName,
526
+ tool_parameters: sanitise(params),
527
+ prompt,
528
+ inputs_hash: hashValue(args),
529
+ outputs_hash: outputsHash,
530
+ response,
531
+ status,
532
+ error_code: errorCode,
533
+ duration_ms: Math.round(performance.now() - start)
534
+ });
535
+ client.record(event);
536
+ }
333
537
  }
334
538
  function wrapFn(client, fn, toolName, taskId, prompt) {
335
539
  const wrapped = function(...args) {
@@ -375,12 +579,14 @@ function auditTool(client, fnOrTaskId, options) {
375
579
  0 && (module.exports = {
376
580
  AuditClient,
377
581
  AuditEventSchema,
582
+ SessionTokenizer,
378
583
  auditTool,
379
584
  computeEventHash,
380
585
  createFileEmitter,
381
586
  createHttpEmitter,
382
587
  generateKeypair,
383
588
  hashValue,
589
+ redactPii,
384
590
  signEvent,
385
591
  verifyChain,
386
592
  verifyEvent
package/dist/index.d.cts CHANGED
@@ -21,12 +21,15 @@ declare const AuditEventSchema: z.ZodObject<{
21
21
  inputs_source: z.ZodDefault<z.ZodString>;
22
22
  outputs_hash: z.ZodDefault<z.ZodString>;
23
23
  outputs_destination: z.ZodDefault<z.ZodString>;
24
+ response: z.ZodDefault<z.ZodString>;
24
25
  status: z.ZodDefault<z.ZodString>;
25
26
  error_code: z.ZodDefault<z.ZodNullable<z.ZodString>>;
26
27
  duration_ms: z.ZodDefault<z.ZodNumber>;
27
28
  retry_count: z.ZodDefault<z.ZodNumber>;
28
29
  prev_event_hash: z.ZodDefault<z.ZodString>;
29
30
  signature: z.ZodDefault<z.ZodString>;
31
+ encrypted_token_map: z.ZodDefault<z.ZodString>;
32
+ pseudonymization_version: z.ZodDefault<z.ZodString>;
30
33
  }, "strip", z.ZodTypeAny, {
31
34
  event_id: string;
32
35
  agent_id: string;
@@ -49,11 +52,14 @@ declare const AuditEventSchema: z.ZodObject<{
49
52
  inputs_source: string;
50
53
  outputs_hash: string;
51
54
  outputs_destination: string;
55
+ response: string;
52
56
  error_code: string | null;
53
57
  duration_ms: number;
54
58
  retry_count: number;
55
59
  prev_event_hash: string;
56
60
  signature: string;
61
+ encrypted_token_map: string;
62
+ pseudonymization_version: string;
57
63
  }, {
58
64
  agent_id: string;
59
65
  agent_version: string;
@@ -76,11 +82,14 @@ declare const AuditEventSchema: z.ZodObject<{
76
82
  inputs_source?: string | undefined;
77
83
  outputs_hash?: string | undefined;
78
84
  outputs_destination?: string | undefined;
85
+ response?: string | undefined;
79
86
  error_code?: string | null | undefined;
80
87
  duration_ms?: number | undefined;
81
88
  retry_count?: number | undefined;
82
89
  prev_event_hash?: string | undefined;
83
90
  signature?: string | undefined;
91
+ encrypted_token_map?: string | undefined;
92
+ pseudonymization_version?: string | undefined;
84
93
  }>;
85
94
  type AuditEvent = z.infer<typeof AuditEventSchema>;
86
95
 
@@ -103,6 +112,47 @@ type EmitFn = (eventDict: Record<string, unknown>) => void;
103
112
  declare function createHttpEmitter(apiUrl: string, apiKey: string, fallbackPath: string): EmitFn;
104
113
  declare function createFileEmitter(fallbackPath: string): EmitFn;
105
114
 
115
+ /**
116
+ * Zero-knowledge PII pseudonymization with encrypted token map.
117
+ *
118
+ * Replaces PII with deterministic HMAC-based pseudonyms and encrypts the
119
+ * original-to-pseudonym mapping with the customer's X25519 public key.
120
+ * Wytness never sees the real PII — only the customer can decrypt the map.
121
+ */
122
+ declare class SessionTokenizer {
123
+ private _hmacSecret;
124
+ private _encryptionPub;
125
+ private _piiFields;
126
+ private _tokenMap;
127
+ private _reverseMap;
128
+ /**
129
+ * @param hmacSecret 32-byte secret for deterministic pseudonyms.
130
+ * @param encryptionPublicKey 32-byte X25519 public key for encrypting the token map.
131
+ * @param piiFields Parameter names whose values should be fully pseudonymized.
132
+ */
133
+ constructor(hmacSecret: Uint8Array, encryptionPublicKey: Uint8Array, piiFields?: string[]);
134
+ get tokenMap(): Record<string, string>;
135
+ private addMapping;
136
+ pseudonymizeValue(value: string, fieldPath?: string): string;
137
+ pseudonymizeText(text: string): string;
138
+ pseudonymizeParams(params: Record<string, unknown>): Record<string, unknown>;
139
+ /**
140
+ * Encrypt the token map with the customer's X25519 public key.
141
+ *
142
+ * Format: base64([ephemeral_pub 32B][nonce 24B][ciphertext])
143
+ * Uses NaCl box (X25519 + XSalsa20-Poly1305).
144
+ */
145
+ encryptTokenMap(): string;
146
+ /**
147
+ * Decrypt a token map using the customer's X25519 private key.
148
+ *
149
+ * @param encryptedB64 Base64-encoded encrypted blob from encryptTokenMap().
150
+ * @param privateKey 32-byte X25519 private (secret) key.
151
+ * @returns The token map (pseudonym → original).
152
+ */
153
+ static decryptTokenMap(encryptedB64: string, privateKey: Uint8Array): Record<string, string>;
154
+ }
155
+
106
156
  interface AuditClientOptions {
107
157
  agentId: string;
108
158
  agentVersion?: string;
@@ -112,6 +162,9 @@ interface AuditClientOptions {
112
162
  fallbackLogPath?: string;
113
163
  httpApiKey?: string;
114
164
  httpEndpoint?: string;
165
+ piiHmacSecret?: string;
166
+ encryptionPublicKey?: string;
167
+ piiFields?: string[];
115
168
  }
116
169
  declare class AuditClient {
117
170
  readonly agentId: string;
@@ -121,8 +174,10 @@ declare class AuditClient {
121
174
  private _sessionId;
122
175
  private _secretKey;
123
176
  private _emit;
177
+ private _tokenizer;
124
178
  constructor(options: AuditClientOptions);
125
179
  get sessionId(): string;
180
+ get tokenizer(): SessionTokenizer | null;
126
181
  /** Record an audit event. Never throws — errors are logged and swallowed. */
127
182
  record(event: AuditEvent): void;
128
183
  /**
@@ -133,6 +188,7 @@ declare class AuditClient {
133
188
  flush(): Promise<void>;
134
189
  }
135
190
 
191
+ declare function redactPii(text: string): string;
136
192
  declare function hashValue(value: unknown): string;
137
193
  interface AuditToolOptions {
138
194
  toolName?: string;
@@ -153,4 +209,4 @@ interface AuditToolOptions {
153
209
  */
154
210
  declare function auditTool(client: AuditClient, fnOrTaskId?: ((...args: unknown[]) => unknown) | string, options?: AuditToolOptions | string): any;
155
211
 
156
- export { AuditClient, type AuditClientOptions, type AuditEvent, AuditEventSchema, auditTool, computeEventHash, createFileEmitter, createHttpEmitter, generateKeypair, hashValue, signEvent, verifyChain, verifyEvent };
212
+ export { AuditClient, type AuditClientOptions, type AuditEvent, AuditEventSchema, SessionTokenizer, auditTool, computeEventHash, createFileEmitter, createHttpEmitter, generateKeypair, hashValue, redactPii, signEvent, verifyChain, verifyEvent };
package/dist/index.d.ts CHANGED
@@ -21,12 +21,15 @@ declare const AuditEventSchema: z.ZodObject<{
21
21
  inputs_source: z.ZodDefault<z.ZodString>;
22
22
  outputs_hash: z.ZodDefault<z.ZodString>;
23
23
  outputs_destination: z.ZodDefault<z.ZodString>;
24
+ response: z.ZodDefault<z.ZodString>;
24
25
  status: z.ZodDefault<z.ZodString>;
25
26
  error_code: z.ZodDefault<z.ZodNullable<z.ZodString>>;
26
27
  duration_ms: z.ZodDefault<z.ZodNumber>;
27
28
  retry_count: z.ZodDefault<z.ZodNumber>;
28
29
  prev_event_hash: z.ZodDefault<z.ZodString>;
29
30
  signature: z.ZodDefault<z.ZodString>;
31
+ encrypted_token_map: z.ZodDefault<z.ZodString>;
32
+ pseudonymization_version: z.ZodDefault<z.ZodString>;
30
33
  }, "strip", z.ZodTypeAny, {
31
34
  event_id: string;
32
35
  agent_id: string;
@@ -49,11 +52,14 @@ declare const AuditEventSchema: z.ZodObject<{
49
52
  inputs_source: string;
50
53
  outputs_hash: string;
51
54
  outputs_destination: string;
55
+ response: string;
52
56
  error_code: string | null;
53
57
  duration_ms: number;
54
58
  retry_count: number;
55
59
  prev_event_hash: string;
56
60
  signature: string;
61
+ encrypted_token_map: string;
62
+ pseudonymization_version: string;
57
63
  }, {
58
64
  agent_id: string;
59
65
  agent_version: string;
@@ -76,11 +82,14 @@ declare const AuditEventSchema: z.ZodObject<{
76
82
  inputs_source?: string | undefined;
77
83
  outputs_hash?: string | undefined;
78
84
  outputs_destination?: string | undefined;
85
+ response?: string | undefined;
79
86
  error_code?: string | null | undefined;
80
87
  duration_ms?: number | undefined;
81
88
  retry_count?: number | undefined;
82
89
  prev_event_hash?: string | undefined;
83
90
  signature?: string | undefined;
91
+ encrypted_token_map?: string | undefined;
92
+ pseudonymization_version?: string | undefined;
84
93
  }>;
85
94
  type AuditEvent = z.infer<typeof AuditEventSchema>;
86
95
 
@@ -103,6 +112,47 @@ type EmitFn = (eventDict: Record<string, unknown>) => void;
103
112
  declare function createHttpEmitter(apiUrl: string, apiKey: string, fallbackPath: string): EmitFn;
104
113
  declare function createFileEmitter(fallbackPath: string): EmitFn;
105
114
 
115
+ /**
116
+ * Zero-knowledge PII pseudonymization with encrypted token map.
117
+ *
118
+ * Replaces PII with deterministic HMAC-based pseudonyms and encrypts the
119
+ * original-to-pseudonym mapping with the customer's X25519 public key.
120
+ * Wytness never sees the real PII — only the customer can decrypt the map.
121
+ */
122
+ declare class SessionTokenizer {
123
+ private _hmacSecret;
124
+ private _encryptionPub;
125
+ private _piiFields;
126
+ private _tokenMap;
127
+ private _reverseMap;
128
+ /**
129
+ * @param hmacSecret 32-byte secret for deterministic pseudonyms.
130
+ * @param encryptionPublicKey 32-byte X25519 public key for encrypting the token map.
131
+ * @param piiFields Parameter names whose values should be fully pseudonymized.
132
+ */
133
+ constructor(hmacSecret: Uint8Array, encryptionPublicKey: Uint8Array, piiFields?: string[]);
134
+ get tokenMap(): Record<string, string>;
135
+ private addMapping;
136
+ pseudonymizeValue(value: string, fieldPath?: string): string;
137
+ pseudonymizeText(text: string): string;
138
+ pseudonymizeParams(params: Record<string, unknown>): Record<string, unknown>;
139
+ /**
140
+ * Encrypt the token map with the customer's X25519 public key.
141
+ *
142
+ * Format: base64([ephemeral_pub 32B][nonce 24B][ciphertext])
143
+ * Uses NaCl box (X25519 + XSalsa20-Poly1305).
144
+ */
145
+ encryptTokenMap(): string;
146
+ /**
147
+ * Decrypt a token map using the customer's X25519 private key.
148
+ *
149
+ * @param encryptedB64 Base64-encoded encrypted blob from encryptTokenMap().
150
+ * @param privateKey 32-byte X25519 private (secret) key.
151
+ * @returns The token map (pseudonym → original).
152
+ */
153
+ static decryptTokenMap(encryptedB64: string, privateKey: Uint8Array): Record<string, string>;
154
+ }
155
+
106
156
  interface AuditClientOptions {
107
157
  agentId: string;
108
158
  agentVersion?: string;
@@ -112,6 +162,9 @@ interface AuditClientOptions {
112
162
  fallbackLogPath?: string;
113
163
  httpApiKey?: string;
114
164
  httpEndpoint?: string;
165
+ piiHmacSecret?: string;
166
+ encryptionPublicKey?: string;
167
+ piiFields?: string[];
115
168
  }
116
169
  declare class AuditClient {
117
170
  readonly agentId: string;
@@ -121,8 +174,10 @@ declare class AuditClient {
121
174
  private _sessionId;
122
175
  private _secretKey;
123
176
  private _emit;
177
+ private _tokenizer;
124
178
  constructor(options: AuditClientOptions);
125
179
  get sessionId(): string;
180
+ get tokenizer(): SessionTokenizer | null;
126
181
  /** Record an audit event. Never throws — errors are logged and swallowed. */
127
182
  record(event: AuditEvent): void;
128
183
  /**
@@ -133,6 +188,7 @@ declare class AuditClient {
133
188
  flush(): Promise<void>;
134
189
  }
135
190
 
191
+ declare function redactPii(text: string): string;
136
192
  declare function hashValue(value: unknown): string;
137
193
  interface AuditToolOptions {
138
194
  toolName?: string;
@@ -153,4 +209,4 @@ interface AuditToolOptions {
153
209
  */
154
210
  declare function auditTool(client: AuditClient, fnOrTaskId?: ((...args: unknown[]) => unknown) | string, options?: AuditToolOptions | string): any;
155
211
 
156
- export { AuditClient, type AuditClientOptions, type AuditEvent, AuditEventSchema, auditTool, computeEventHash, createFileEmitter, createHttpEmitter, generateKeypair, hashValue, signEvent, verifyChain, verifyEvent };
212
+ export { AuditClient, type AuditClientOptions, type AuditEvent, AuditEventSchema, SessionTokenizer, auditTool, computeEventHash, createFileEmitter, createHttpEmitter, generateKeypair, hashValue, redactPii, signEvent, verifyChain, verifyEvent };
package/dist/index.js CHANGED
@@ -26,6 +26,7 @@ var AuditEventSchema = z.object({
26
26
  inputs_source: z.string().default(""),
27
27
  outputs_hash: z.string().default(""),
28
28
  outputs_destination: z.string().default(""),
29
+ response: z.string().default(""),
29
30
  // Layer 5: Outcome
30
31
  status: z.string().default("success"),
31
32
  error_code: z.string().nullable().default(null),
@@ -33,7 +34,10 @@ var AuditEventSchema = z.object({
33
34
  retry_count: z.number().int().default(0),
34
35
  // Chain integrity
35
36
  prev_event_hash: z.string().default(""),
36
- signature: z.string().default("")
37
+ signature: z.string().default(""),
38
+ // Pseudonymization
39
+ encrypted_token_map: z.string().default(""),
40
+ pseudonymization_version: z.string().default("")
37
41
  });
38
42
 
39
43
  // src/signing.ts
@@ -187,6 +191,147 @@ async function flushPending() {
187
191
  import { randomUUID as randomUUID2 } from "crypto";
188
192
  import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "fs";
189
193
  import { dirname as dirname2 } from "path";
194
+
195
+ // src/tokenizer.ts
196
+ import { createHmac } from "crypto";
197
+ import nacl2 from "tweetnacl";
198
+ import { encodeBase64 as encodeBase642, decodeBase64 as decodeBase642 } from "tweetnacl-util";
199
+ var SECRET_PATTERN = /key|secret|token|password|passwd|pwd|credential|auth/i;
200
+ var PII_PATTERNS = [
201
+ [/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, "EMAIL"],
202
+ [/\b\d{3}[-.\s]?\d{2}[-.\s]?\d{4}\b/g, "SSN"],
203
+ [/\b\d{3}[-.\s]?\d{3}[-.\s]?\d{3}\b/g, "TFN"],
204
+ [/\b(?:\d{4}[-\s]?){3}\d{4}\b/g, "CARD"],
205
+ [/\b(?:\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g, "PHONE"]
206
+ ];
207
+ function hmacPseudonym(value, secret, piiType = "PII") {
208
+ const digest = createHmac("sha256", secret).update(value, "utf-8").digest("hex");
209
+ return `${piiType}_${digest.slice(0, 8)}`;
210
+ }
211
+ var SessionTokenizer = class {
212
+ _hmacSecret;
213
+ _encryptionPub;
214
+ _piiFields;
215
+ _tokenMap = {};
216
+ // pseudonym → original
217
+ _reverseMap = {};
218
+ // original → pseudonym
219
+ /**
220
+ * @param hmacSecret 32-byte secret for deterministic pseudonyms.
221
+ * @param encryptionPublicKey 32-byte X25519 public key for encrypting the token map.
222
+ * @param piiFields Parameter names whose values should be fully pseudonymized.
223
+ */
224
+ constructor(hmacSecret, encryptionPublicKey, piiFields = []) {
225
+ this._hmacSecret = hmacSecret;
226
+ this._encryptionPub = encryptionPublicKey;
227
+ this._piiFields = piiFields;
228
+ }
229
+ get tokenMap() {
230
+ return { ...this._tokenMap };
231
+ }
232
+ addMapping(original, pseudonym) {
233
+ this._tokenMap[pseudonym] = original;
234
+ this._reverseMap[original] = pseudonym;
235
+ }
236
+ pseudonymizeValue(value, fieldPath = "") {
237
+ if (!value || !value.trim())
238
+ return value;
239
+ if (this._piiFields.includes(fieldPath)) {
240
+ if (this._reverseMap[value])
241
+ return this._reverseMap[value];
242
+ const pseudonym = hmacPseudonym(value, this._hmacSecret);
243
+ this.addMapping(value, pseudonym);
244
+ return pseudonym;
245
+ }
246
+ return value;
247
+ }
248
+ pseudonymizeText(text) {
249
+ if (!text)
250
+ return text;
251
+ const known = Object.keys(this._reverseMap).sort(
252
+ (a, b) => b.length - a.length
253
+ );
254
+ for (const original of known) {
255
+ if (text.includes(original)) {
256
+ text = text.split(original).join(this._reverseMap[original]);
257
+ }
258
+ }
259
+ for (const [pattern, piiType] of PII_PATTERNS) {
260
+ pattern.lastIndex = 0;
261
+ text = text.replace(pattern, (matched) => {
262
+ if (this._reverseMap[matched])
263
+ return this._reverseMap[matched];
264
+ const pseudonym = hmacPseudonym(matched, this._hmacSecret, piiType);
265
+ this.addMapping(matched, pseudonym);
266
+ return pseudonym;
267
+ });
268
+ }
269
+ return text;
270
+ }
271
+ pseudonymizeParams(params) {
272
+ const result = {};
273
+ for (const [k, v] of Object.entries(params)) {
274
+ if (SECRET_PATTERN.test(k)) {
275
+ result[k] = "[REDACTED]";
276
+ } else if (this._piiFields.includes(k)) {
277
+ const val = String(v).slice(0, 500);
278
+ result[k] = this.pseudonymizeValue(val, k);
279
+ } else {
280
+ const val = String(v).slice(0, 500);
281
+ result[k] = this.pseudonymizeText(val);
282
+ }
283
+ }
284
+ return result;
285
+ }
286
+ /**
287
+ * Encrypt the token map with the customer's X25519 public key.
288
+ *
289
+ * Format: base64([ephemeral_pub 32B][nonce 24B][ciphertext])
290
+ * Uses NaCl box (X25519 + XSalsa20-Poly1305).
291
+ */
292
+ encryptTokenMap() {
293
+ const plaintext = Buffer.from(
294
+ JSON.stringify(this._tokenMap),
295
+ "utf-8"
296
+ );
297
+ const ephemeral = nacl2.box.keyPair();
298
+ const nonce = nacl2.randomBytes(24);
299
+ const ciphertext = nacl2.box(
300
+ plaintext,
301
+ nonce,
302
+ this._encryptionPub,
303
+ ephemeral.secretKey
304
+ );
305
+ if (!ciphertext) {
306
+ throw new Error("Encryption failed");
307
+ }
308
+ const packed = new Uint8Array(32 + 24 + ciphertext.length);
309
+ packed.set(ephemeral.publicKey, 0);
310
+ packed.set(nonce, 32);
311
+ packed.set(ciphertext, 56);
312
+ return encodeBase642(packed);
313
+ }
314
+ /**
315
+ * Decrypt a token map using the customer's X25519 private key.
316
+ *
317
+ * @param encryptedB64 Base64-encoded encrypted blob from encryptTokenMap().
318
+ * @param privateKey 32-byte X25519 private (secret) key.
319
+ * @returns The token map (pseudonym → original).
320
+ */
321
+ static decryptTokenMap(encryptedB64, privateKey) {
322
+ const packed = decodeBase642(encryptedB64);
323
+ const ephemeralPub = packed.slice(0, 32);
324
+ const nonce = packed.slice(32, 56);
325
+ const ciphertext = packed.slice(56);
326
+ const plaintext = nacl2.box.open(ciphertext, nonce, ephemeralPub, privateKey);
327
+ if (!plaintext) {
328
+ throw new Error("Decryption failed \u2014 wrong key or corrupted data");
329
+ }
330
+ return JSON.parse(Buffer.from(plaintext).toString("utf-8"));
331
+ }
332
+ };
333
+
334
+ // src/client.ts
190
335
  var AuditClient = class {
191
336
  agentId;
192
337
  agentVersion;
@@ -195,6 +340,7 @@ var AuditClient = class {
195
340
  _sessionId;
196
341
  _secretKey;
197
342
  _emit;
343
+ _tokenizer = null;
198
344
  constructor(options) {
199
345
  this.agentId = options.agentId;
200
346
  this.agentVersion = options.agentVersion ?? "0.1.0";
@@ -224,10 +370,18 @@ var AuditClient = class {
224
370
  } else {
225
371
  this._emit = createFileEmitter(fallback);
226
372
  }
373
+ if (options.piiHmacSecret && options.encryptionPublicKey) {
374
+ const hmacBytes = new Uint8Array(Buffer.from(options.piiHmacSecret, "base64"));
375
+ const pubBytes = new Uint8Array(Buffer.from(options.encryptionPublicKey, "base64"));
376
+ this._tokenizer = new SessionTokenizer(hmacBytes, pubBytes, options.piiFields ?? []);
377
+ }
227
378
  }
228
379
  get sessionId() {
229
380
  return this._sessionId;
230
381
  }
382
+ get tokenizer() {
383
+ return this._tokenizer;
384
+ }
231
385
  /** Record an audit event. Never throws — errors are logged and swallowed. */
232
386
  record(event) {
233
387
  try {
@@ -251,39 +405,87 @@ var AuditClient = class {
251
405
  };
252
406
 
253
407
  // src/decorator.ts
254
- import { createHash as createHash2 } from "crypto";
255
- var SECRET_PATTERN = /key|secret|token|password|passwd|pwd|credential|auth/i;
408
+ import { createHash as createHash3 } from "crypto";
409
+ var SECRET_PATTERN2 = /key|secret|token|password|passwd|pwd|credential|auth/i;
410
+ var PII_PATTERNS2 = [
411
+ [/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, "[EMAIL_REDACTED]"],
412
+ [/\b\d{3}[-.\s]?\d{2}[-.\s]?\d{4}\b/g, "[SSN_REDACTED]"],
413
+ [/\b\d{3}[-.\s]?\d{3}[-.\s]?\d{3}\b/g, "[TFN_REDACTED]"],
414
+ [/\b(?:\d{4}[-\s]?){3}\d{4}\b/g, "[CARD_REDACTED]"],
415
+ [/\b(?:\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g, "[PHONE_REDACTED]"]
416
+ ];
417
+ var RESPONSE_MAX_CHARS = 5e3;
418
+ function redactPii(text) {
419
+ for (const [pattern, replacement] of PII_PATTERNS2) {
420
+ pattern.lastIndex = 0;
421
+ text = text.replace(pattern, replacement);
422
+ }
423
+ return text;
424
+ }
256
425
  function sanitise(params) {
257
426
  const result = {};
258
427
  for (const [k, v] of Object.entries(params)) {
259
- result[k] = SECRET_PATTERN.test(k) ? "[REDACTED]" : String(v).slice(0, 500);
428
+ if (SECRET_PATTERN2.test(k)) {
429
+ result[k] = "[REDACTED]";
430
+ } else {
431
+ result[k] = redactPii(String(v).slice(0, 500));
432
+ }
260
433
  }
261
434
  return result;
262
435
  }
263
436
  function hashValue(value) {
264
- return createHash2("sha256").update(JSON.stringify(value, null, 0)).digest("hex");
437
+ return createHash3("sha256").update(JSON.stringify(value, null, 0)).digest("hex");
265
438
  }
266
439
  function recordEvent(client, toolName, taskId, prompt, args, start, status, errorCode, result) {
267
440
  const params = {};
268
441
  args.forEach((arg, i) => {
269
442
  params[`arg${i}`] = arg;
270
443
  });
271
- const event = AuditEventSchema.parse({
272
- agent_id: client.agentId,
273
- agent_version: client.agentVersion,
274
- human_operator_id: client.humanOperatorId,
275
- task_id: taskId,
276
- session_id: client.sessionId,
277
- tool_name: toolName,
278
- tool_parameters: sanitise(params),
279
- prompt,
280
- inputs_hash: hashValue(args),
281
- outputs_hash: result != null ? hashValue(result) : "",
282
- status,
283
- error_code: errorCode,
284
- duration_ms: Math.round(performance.now() - start)
285
- });
286
- client.record(event);
444
+ const outputsHash = result != null ? hashValue(result) : "";
445
+ const tokenizer = client.tokenizer;
446
+ if (tokenizer) {
447
+ const pseudonymizedParams = tokenizer.pseudonymizeParams(params);
448
+ const pseudonymizedPrompt = prompt ? tokenizer.pseudonymizeText(prompt) : "";
449
+ const response = result != null ? tokenizer.pseudonymizeText(String(result).slice(0, RESPONSE_MAX_CHARS)) : "";
450
+ const event = AuditEventSchema.parse({
451
+ agent_id: client.agentId,
452
+ agent_version: client.agentVersion,
453
+ human_operator_id: client.humanOperatorId,
454
+ task_id: taskId,
455
+ session_id: client.sessionId,
456
+ tool_name: toolName,
457
+ tool_parameters: pseudonymizedParams,
458
+ prompt: pseudonymizedPrompt,
459
+ inputs_hash: hashValue(args),
460
+ outputs_hash: outputsHash,
461
+ response,
462
+ status,
463
+ error_code: errorCode,
464
+ duration_ms: Math.round(performance.now() - start),
465
+ encrypted_token_map: tokenizer.encryptTokenMap(),
466
+ pseudonymization_version: "1.0"
467
+ });
468
+ client.record(event);
469
+ } else {
470
+ const response = result != null ? redactPii(String(result).slice(0, RESPONSE_MAX_CHARS)) : "";
471
+ const event = AuditEventSchema.parse({
472
+ agent_id: client.agentId,
473
+ agent_version: client.agentVersion,
474
+ human_operator_id: client.humanOperatorId,
475
+ task_id: taskId,
476
+ session_id: client.sessionId,
477
+ tool_name: toolName,
478
+ tool_parameters: sanitise(params),
479
+ prompt,
480
+ inputs_hash: hashValue(args),
481
+ outputs_hash: outputsHash,
482
+ response,
483
+ status,
484
+ error_code: errorCode,
485
+ duration_ms: Math.round(performance.now() - start)
486
+ });
487
+ client.record(event);
488
+ }
287
489
  }
288
490
  function wrapFn(client, fn, toolName, taskId, prompt) {
289
491
  const wrapped = function(...args) {
@@ -328,12 +530,14 @@ function auditTool(client, fnOrTaskId, options) {
328
530
  export {
329
531
  AuditClient,
330
532
  AuditEventSchema,
533
+ SessionTokenizer,
331
534
  auditTool,
332
535
  computeEventHash,
333
536
  createFileEmitter,
334
537
  createHttpEmitter,
335
538
  generateKeypair,
336
539
  hashValue,
540
+ redactPii,
337
541
  signEvent,
338
542
  verifyChain,
339
543
  verifyEvent
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wytness/sdk",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "TypeScript SDK for Wytness — audit logging for AI agents with cryptographic signing and chain integrity",
5
5
  "license": "MIT",
6
6
  "author": "Wytness <hello@wytness.dev>",