@princeverma130104/agentguard 1.0.1 β†’ 1.0.2

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
@@ -1,7 +1,7 @@
1
1
  # πŸ›‘οΈ AgentGuard
2
2
 
3
3
  > **Privacy & Context Redaction Middleware for AI Agents**
4
- > Detect, tokenise, and restore PII/credentials in real time β€” sensitive values are kept in your configured Redis vault and are not sent to the LLM.
4
+ > Detect, tokenise, and restore supported PII/credential patterns. Detected values are kept in your configured Redis vault and are not sent to the LLM.
5
5
 
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
7
7
  [![Node.js](https://img.shields.io/badge/node-%3E%3D18.0.0-brightgreen)](https://nodejs.org)
@@ -11,7 +11,7 @@
11
11
 
12
12
  ## The Problem
13
13
 
14
- When you build AI agents that handle user data β€” resumes, emails, SSNs, API keys β€” that data flows raw into your LLM provider's servers. This violates GDPR, SOC2, and HIPAA, and exposes your users to data breaches.
14
+ When AI agents handle user data β€” such as resumes, emails, SSNs, or API keys β€” sensitive values may be sent to an LLM provider unless the application protects them.
15
15
 
16
16
  ## The Solution
17
17
 
@@ -19,30 +19,30 @@ AgentGuard acts as an invisible middleware layer between your agent framework an
19
19
 
20
20
  ```
21
21
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
22
- β”‚ Agent Framework β”‚ (1) Raw Prompt: "Register John at john@x.com"
22
+ β”‚ Agent Framework β”‚ (1) Raw Prompt: "Register John at john@x.com"
23
23
  β”‚ (Mastra / LangChain)β”‚
24
24
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
25
25
  β”‚
26
26
  β–Ό
27
27
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
28
- β”‚ AgentGuard SDK β”‚ (2) Detect PII with Regex/NLP patterns
29
- β”‚ (Local Middleware) β”‚ (3) Store in Redis: {[EMAIL_1]: john@x.com}
28
+ β”‚ AgentGuard SDK β”‚ (2) Detect supported PII with patterns
29
+ β”‚ (Local Middleware) β”‚ (3) Store in Redis: {[EMAIL_1]: john@x.com}
30
30
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
31
31
  β”‚ (4) Sanitized: "Register [NAME_1] at [EMAIL_1]"
32
32
  β–Ό
33
33
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
34
- β”‚ LLM Provider β”‚ ← Never sees real PII
35
- β”‚ (OpenAI / Claude) β”‚
34
+ β”‚ LLM Provider β”‚ ← Does not receive detected values
35
+ β”‚ (OpenAI / Claude) β”‚
36
36
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
37
37
  β”‚ (5) Response: "Fill form with [NAME_1], [EMAIL_1]"
38
38
  β–Ό
39
39
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
40
- β”‚ AgentGuard SDK β”‚ (6) Restore: "Fill form with John, john@x.com"
40
+ β”‚ AgentGuard SDK β”‚ (6) Restore: "Fill form with John, john@x.com"
41
41
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
42
42
  β”‚
43
43
  β–Ό
44
44
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
45
- β”‚ Tool / Browser β”‚ ← Gets real values for execution
45
+ β”‚ Tool / Browser β”‚ ← Gets real values for execution
46
46
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
47
47
  ```
48
48
 
@@ -110,6 +110,56 @@ await guard.destroySession(sessionId);
110
110
  await guard.close();
111
111
  ```
112
112
 
113
+ ### Fail-closed outbound calls and approval
114
+
115
+ Route model calls through `sendToLLM` so scanning and vault writes finish before
116
+ the outbound callback is invoked. Add an approval callback when your application
117
+ requires a policy or human decision:
118
+
119
+ ```typescript
120
+ const { result, sessionId } = await guard.sendToLLM(
121
+ (sanitizedPrompt) => llm.generate(sanitizedPrompt),
122
+ userPrompt,
123
+ {
124
+ approve: async ({ sanitizedPrompt, redactionSummary }) =>
125
+ approvalPolicy.allow({ sanitizedPrompt, redactionSummary }),
126
+ },
127
+ );
128
+ ```
129
+
130
+ Only a literal `true` approves the call. A denial, thrown error, or sanitizer
131
+ failure prevents the LLM callback from running. The approval callback receives
132
+ sanitized text and entity counts onlyβ€”not the original prompt or detected
133
+ values. Without an `approve` callback this method still sanitizes, but sends
134
+ without an approval decision; that is the default for compatibility and ease
135
+ of use.
136
+
137
+ Construct the prompt with only the minimum context needed before calling this
138
+ API. AgentGuard cannot know which facts a user considers sensitive, certify
139
+ arbitrary text as safe, or prevent an application from bypassing this method.
140
+ Ensure every provider call uses the gate, and do not let the send callback
141
+ ignore its `sanitizedPrompt` argument or close over the original prompt.
142
+
143
+ ### Structured JSON
144
+
145
+ Use the core API when your model accepts structured input or when you are not
146
+ using Express middleware. It traverses nested arrays and objects, redacts string
147
+ values and property names, and uses one session for the complete structure:
148
+
149
+ ```typescript
150
+ const { sanitizedValue, sessionId } = await guard.anonymizeObject({
151
+ customer: { email: 'jane@example.com' },
152
+ messages: ['Call me at 415-555-0123'],
153
+ });
154
+
155
+ // Send sanitizedValue to the model, then restore only for an authorized use:
156
+ const restored = await guard.deanonymizeObject(modelOutput, sessionId);
157
+ ```
158
+
159
+ Structured methods accept JSON-compatible values only, enforce the same text
160
+ size limit per string, and reject cyclic, excessively deep, or unsupported
161
+ objects. Keep and authorize `sessionId` as a bearer capability.
162
+
113
163
  ---
114
164
 
115
165
  ## Mastra Integration
@@ -198,6 +248,9 @@ app.post('/chat', async (req, res) => {
198
248
  const guard = new AgentGuard({
199
249
  redisUrl: 'redis://localhost:6379', // default
200
250
  ttlSeconds: 900, // 15 minutes default
251
+ maxTokensPerSession: 10_000, // cap vault growth per session
252
+ maxTextLength: 1_000_000, // maximum characters per text input
253
+ vaultEncryptionKey: process.env.AGENTGUARD_VAULT_KEY, // optional 32-byte hex key
201
254
  verbose: false, // console logging (no PII)
202
255
  });
203
256
  ```
@@ -206,7 +259,17 @@ const guard = new AgentGuard({
206
259
  |---|---|---|---|
207
260
  | `redisUrl` | `string` | `redis://localhost:6379` | Redis connection URL |
208
261
  | `ttlSeconds` | `number` | `900` | Session TTL in seconds |
209
- | `verbose` | `boolean` | `false` | Log operation metadata (never PII) |
262
+ | `maxTokensPerSession` | `number` | `10000` | Maximum distinct vault values per session |
263
+ | `maxTextLength` | `number` | `1000000` | Maximum UTF-16 code units in a text input |
264
+ | `vaultEncryptionKey` | `string` | unset | Optional 32-byte hex key for AES-256-GCM encryption of Redis vault values |
265
+ | `verbose` | `boolean` | `false` | Log operation metadata and a truncated session hash (never raw session IDs or PII) |
266
+
267
+ The Express-compatible middleware handles string bodies and nested JSON values.
268
+ It also scans JSON property names, not just values. Configure a strict request
269
+ body-size limit in your HTTP framework before AgentGuard middleware; the
270
+ `maxTextLength` limit applies to each string passed to the core guard.
271
+ The middleware factory returns an array-compatible stack with a `close()` method;
272
+ call it during graceful shutdown to release its Redis connection.
210
273
 
211
274
  ---
212
275
 
@@ -264,16 +327,21 @@ vault must be reachable from that service.
264
327
  Redis service with TLS for production; do not expose the local Docker Redis
265
328
  port to the public internet.
266
329
  2. Set `REDIS_URL` in the application host's secret/environment settings. Use
267
- the provider's TLS URL (`rediss://...`) when offered, and never commit
268
- credentials in `.env` or source control.
269
- 3. Set `AGENTGUARD_TTL_SECONDS` if the default 900 seconds is not appropriate.
330
+ the provider's TLS URL (`rediss://...`), authentication, and least-privilege
331
+ ACLs. Never commit credentials or encryption keys to source control.
332
+ 3. Set `AGENTGUARD_VAULT_KEY` to a secret 64-character hex value and pass it as
333
+ `vaultEncryptionKey` for application-level vault encryption. Keep it stable
334
+ across instances; wait for existing sessions to expire before rotation.
335
+ 4. Set `AGENTGUARD_TTL_SECONDS` if the default 900 seconds is not appropriate.
270
336
  Configure your app to pass `REDIS_URL` and the desired TTL to `AgentGuard`.
271
- 4. Install the published package in the application, deploy the service using
337
+ 5. Install the published package in the application, deploy the service using
272
338
  its normal Node.js deployment process, and verify Redis connectivity with
273
339
  `await guard.healthCheck()` before accepting traffic.
274
- 5. Ensure your Redis provider's persistence, backups, access controls, and
340
+ 6. Ensure your Redis provider's persistence, backups, access controls, and
275
341
  retention settings meet your privacy requirements. AgentGuard cannot disable
276
342
  persistence on an externally managed Redis service.
343
+ 7. If using `createGuardMiddleware`, call `await stack.close()` during graceful
344
+ application shutdown to close its Redis connection.
277
345
 
278
346
  The included `docker-compose.yml` is for local development only. It binds Redis
279
347
  to localhost and disables RDB snapshots and AOF persistence; it is not a
@@ -298,7 +366,9 @@ agentguard/
298
366
  β”œβ”€β”€ tests/
299
367
  β”‚ β”œβ”€β”€ scanner.test.ts # Scanner unit tests
300
368
  β”‚ β”œβ”€β”€ vault.test.ts # Vault unit tests
301
- β”‚ └── guard.test.ts # Integration tests
369
+ β”‚ β”œβ”€β”€ guard.test.ts # Integration tests
370
+ β”‚ β”œβ”€β”€ structured.test.ts # Structured data API tests
371
+ β”‚ └── outbound.test.ts # Outbound gate tests
302
372
  β”œβ”€β”€ examples/
303
373
  β”‚ β”œβ”€β”€ basic-usage.ts # Standalone example
304
374
  β”‚ └── mastra-quickstart.ts
@@ -312,19 +382,33 @@ agentguard/
312
382
 
313
383
  ## Security & Compliance
314
384
 
315
- - **Ephemeral vault**: Raw PII is stored in Redis and is not sent to the LLM; configure Redis persistence and access controls to match your privacy requirements
385
+ - **Important limitation**: Detection is pattern-based and can miss sensitive information. Integrate and test AgentGuard for your application; it does not by itself guarantee security or regulatory compliance.
386
+ - **Vault contents**: Configure `vaultEncryptionKey` from a secret manager to encrypt vault values with AES-256-GCM and HMAC the reverse index. Without it, values are stored unencrypted for compatibility. Use the same key across application instances; rotating it makes existing encrypted sessions unreadable, so wait for the maximum TTL or migrate before rotation. Also use private Redis with TLS (`rediss://`), authentication, least-privilege ACLs, network restrictions, and provider-controlled encryption/backup retention.
387
+ - **Enabling encryption on an existing deployment**: Existing plaintext Redis entries remain readable during their TTL. Destroy those sessions or wait for expiration before treating the Redis dataset as fully encrypted.
316
388
  - **TTL enforcement**: All session data expires automatically (default 15 min)
317
- - **Session isolation**: Each session has its own Redis key namespace β€” no cross-session leakage
318
- - **Audit log**: All operations are logged with timestamps and entity types only β€” never raw PII values
319
- - **Pipeline batching**: Vault reads/writes use Redis pipelines to minimise latency overhead
389
+ - **Session isolation**: Each session has its own Redis key namespace; isolation assumes callers use the correct session ID and does not replace authorization.
390
+ - **Session authorization**: Session IDs are bearer capabilities for restoring values. Keep them out of client-visible responses, prompts, logs, and URLs; authorize callers before reusing one. AgentGuard does not authenticate users or authorize access to sessions.
391
+ - **Audit log**: Operations are logged with timestamps, entity types, counts, and a truncated SHA-256 session hash; raw PII and full session IDs are not logged.
392
+ - **Atomic allocation and limits**: Redis Lua scripts allocate tokens atomically and enforce a per-session vault size cap.
393
+ - **Middleware session handling**: The response-restoration middleware keeps session IDs in a weak map rather than attaching bearer IDs to request/response objects.
394
+ - **Outbound gate**: `sendToLLM` awaits sanitization before sending and supports an optional fail-closed approval callback.
395
+
396
+ Only restore tokens for an authorized destination. Validate LLM output and tool
397
+ arguments independently; redaction is not authorization, prompt-injection
398
+ protection, or a safe-execution mechanism. Never let a model choose privileged
399
+ actions solely because AgentGuard restored a token. Treat custom regex patterns
400
+ as trusted code; do not load patterns from users or other untrusted sources.
401
+ Test each framework integration against the exact framework/model versions in
402
+ your application to verify the sanitized content is what actually reaches the
403
+ provider.
320
404
 
321
405
  ### NFR Compliance
322
406
 
323
407
  | Requirement | Target | Implementation |
324
408
  |---|---|---|
325
- | Latency overhead | < 15ms | Redis pipeline batching |
326
- | Zero persistence | No disk writes | Local Compose disables RDB/AOF; production Redis persistence is provider-configured |
327
- | Session isolation | Per session | Namespaced Redis keys per session UUID |
409
+ | Latency overhead | Not benchmarked | Depends on text size, regex patterns, and Redis |
410
+ | Redis persistence | Deployment-configured | Local Compose disables RDB/AOF; production Redis persistence is provider-configured |
411
+ | Session isolation | Per session | Namespaced Redis keys per session identifier |
328
412
  | Scalability | Horizontal | Stateless middleware + shared Redis |
329
413
 
330
414
  ---
package/dist/guard.d.ts CHANGED
@@ -16,6 +16,8 @@ export interface GuardOptions extends VaultOptions {
16
16
  * Defaults to false.
17
17
  */
18
18
  verbose?: boolean;
19
+ /** Maximum number of UTF-16 code units accepted in a single text input. Defaults to 1,000,000. */
20
+ maxTextLength?: number;
19
21
  }
20
22
  export interface AnonymizeResult {
21
23
  /** The sanitized text with all PII replaced by placeholder tokens */
@@ -25,19 +27,47 @@ export interface AnonymizeResult {
25
27
  /** Summary of what was redacted (types and counts only β€” no raw values) */
26
28
  redactionSummary: Record<string, number>;
27
29
  }
30
+ export type JsonValue = string | number | boolean | null | JsonValue[] | {
31
+ [key: string]: JsonValue;
32
+ };
33
+ export interface AnonymizeObjectResult {
34
+ sanitizedValue: JsonValue;
35
+ sessionId: string;
36
+ redactionSummary: Record<string, number>;
37
+ }
28
38
  export interface AuditEntry {
29
39
  timestamp: string;
30
- sessionId: string;
31
- operation: 'anonymize' | 'deanonymize' | 'session_destroy';
40
+ sessionIdHash: string;
41
+ operation: 'anonymize' | 'deanonymize' | 'session_destroy' | 'outbound_approved' | 'outbound_rejected';
32
42
  entityTypesRedacted?: string[];
33
43
  totalTokensRestored?: number;
34
44
  }
45
+ export interface OutboundApprovalRequest {
46
+ /** Sanitized content only. Raw input is never passed to the approval callback. */
47
+ sanitizedPrompt: string;
48
+ /** Entity types detected in this prompt; no matched values are included. */
49
+ redactionSummary: Readonly<Record<string, number>>;
50
+ }
51
+ export interface OutboundCallOptions {
52
+ sessionId?: string;
53
+ /** Return true to permit the outbound call. Any other result blocks it. */
54
+ approve?: (request: OutboundApprovalRequest) => boolean | Promise<boolean>;
55
+ }
56
+ export interface OutboundCallResult<T> {
57
+ result: T;
58
+ sessionId: string;
59
+ redactionSummary: Record<string, number>;
60
+ }
61
+ export declare class OutboundApprovalError extends Error {
62
+ constructor();
63
+ }
35
64
  export declare class AgentGuard {
36
65
  private readonly options;
37
66
  private readonly vault;
38
67
  private readonly verbose;
39
68
  private readonly auditLog;
40
69
  constructor(options?: GuardOptions);
70
+ private readonly maxTextLength;
41
71
  /**
42
72
  * Scans input text, replaces all detected PII with deterministic tokens,
43
73
  * and stores the (token β†’ rawValue) mapping in the ephemeral Redis vault.
@@ -60,6 +90,22 @@ export declare class AgentGuard {
60
90
  * @returns Text with all tokens replaced by original values
61
91
  */
62
92
  deanonymize(tokenizedText: string, sessionId: string): Promise<string>;
93
+ /**
94
+ * Anonymizes strings throughout a JSON-compatible value, including property
95
+ * names, and reuses one vault session for the complete structure.
96
+ */
97
+ anonymizeObject(value: unknown, sessionId?: string): Promise<AnonymizeObjectResult>;
98
+ /**
99
+ * Restores tokens throughout a JSON-compatible value using one session.
100
+ * Only call this for a session authorized by the consuming application.
101
+ */
102
+ deanonymizeObject(value: unknown, sessionId: string): Promise<JsonValue>;
103
+ /**
104
+ * Anonymizes a prompt before invoking the caller's outbound LLM function.
105
+ * If supplied, approval receives sanitized text only; any result other than
106
+ * literal true blocks the call. Sanitization or approval errors fail closed.
107
+ */
108
+ sendToLLM<T>(send: (sanitizedPrompt: string) => Promise<T>, prompt: string, options?: OutboundCallOptions): Promise<OutboundCallResult<T>>;
63
109
  /**
64
110
  * Wraps an async function, automatically anonymizing its string input
65
111
  * and deanonymizing the string output. Useful for wrapping LLM calls.
@@ -92,5 +138,8 @@ export declare class AgentGuard {
92
138
  */
93
139
  close(): Promise<void>;
94
140
  private recordAudit;
141
+ private hashSessionId;
142
+ private validateSessionId;
143
+ private mapJsonValue;
95
144
  }
96
145
  //# sourceMappingURL=guard.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"guard.d.ts","sourceRoot":"","sources":["../src/guard.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH,OAAO,EAAc,YAAY,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAExC,MAAM,WAAW,YAAa,SAAQ,YAAY;IAChD;;;OAGG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,UAAU,EAAE,MAAM,MAAM,CAAC,CAAC,CAAC;IAC3D;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,eAAe;IAC9B,qEAAqE;IACrE,aAAa,EAAE,MAAM,CAAC;IACtB,4EAA4E;IAC5E,SAAS,EAAE,MAAM,CAAC;IAClB,2EAA2E;IAC3E,gBAAgB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC1C;AAED,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,WAAW,GAAG,aAAa,GAAG,iBAAiB,CAAC;IAC3D,mBAAmB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC/B,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED,qBAAa,UAAU;IAKT,OAAO,CAAC,QAAQ,CAAC,OAAO;IAJpC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAa;IACnC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAU;IAClC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAoB;gBAEhB,OAAO,GAAE,YAAiB;IAQvD;;;;;;;;;;OAUG;IACG,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC;IAsE3E;;;;;;;;OAQG;IACG,WAAW,CAAC,aAAa,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IA4B5E;;;;;;OAMG;IACG,QAAQ,CAAC,CAAC,EACd,EAAE,EAAE,CAAC,cAAc,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,EAC1C,QAAQ,EAAE,MAAM,EAChB,SAAS,CAAC,EAAE,MAAM,EAClB,eAAe,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,MAAM,EACvC,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,EAAE,QAAQ,EAAE,MAAM,KAAK,CAAC,GAClD,OAAO,CAAC;QAAE,MAAM,EAAE,CAAC,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC;IAa5C;;;OAGG;IACG,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAYtD;;OAEG;IACH,WAAW,IAAI,aAAa,CAAC,UAAU,CAAC;IAIxC;;OAEG;IACG,WAAW,IAAI,OAAO,CAAC;QAAE,MAAM,EAAE,IAAI,GAAG,UAAU,CAAC;QAAC,KAAK,EAAE,OAAO,CAAA;KAAE,CAAC;IAK3E;;OAEG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAI5B,OAAO,CAAC,WAAW;CAOpB"}
1
+ {"version":3,"file":"guard.d.ts","sourceRoot":"","sources":["../src/guard.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAKH,OAAO,EAAc,YAAY,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAExC,MAAM,WAAW,YAAa,SAAQ,YAAY;IAChD;;;OAGG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,UAAU,EAAE,MAAM,MAAM,CAAC,CAAC,CAAC;IAC3D;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,kGAAkG;IAClG,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,eAAe;IAC9B,qEAAqE;IACrE,aAAa,EAAE,MAAM,CAAC;IACtB,4EAA4E;IAC5E,SAAS,EAAE,MAAM,CAAC;IAClB,2EAA2E;IAC3E,gBAAgB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC1C;AAED,MAAM,MAAM,SAAS,GACjB,MAAM,GACN,MAAM,GACN,OAAO,GACP,IAAI,GACJ,SAAS,EAAE,GACX;IAAE,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAAA;CAAE,CAAC;AAEjC,MAAM,WAAW,qBAAqB;IACpC,cAAc,EAAE,SAAS,CAAC;IAC1B,SAAS,EAAE,MAAM,CAAC;IAClB,gBAAgB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC1C;AAED,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EACL,WAAW,GACX,aAAa,GACb,iBAAiB,GACjB,mBAAmB,GACnB,mBAAmB,CAAC;IACxB,mBAAmB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC/B,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED,MAAM,WAAW,uBAAuB;IACtC,kFAAkF;IAClF,eAAe,EAAE,MAAM,CAAC;IACxB,4EAA4E;IAC5E,gBAAgB,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CACpD;AAED,MAAM,WAAW,mBAAmB;IAClC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,2EAA2E;IAC3E,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,uBAAuB,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CAC5E;AAED,MAAM,WAAW,kBAAkB,CAAC,CAAC;IACnC,MAAM,EAAE,CAAC,CAAC;IACV,SAAS,EAAE,MAAM,CAAC;IAClB,gBAAgB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC1C;AAED,qBAAa,qBAAsB,SAAQ,KAAK;;CAK/C;AAED,qBAAa,UAAU;IAKT,OAAO,CAAC,QAAQ,CAAC,OAAO;IAJpC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAa;IACnC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAU;IAClC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAoB;gBAEhB,OAAO,GAAE,YAAiB;IAevD,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IAEvC;;;;;;;;;;OAUG;IACG,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC;IAmE3E;;;;;;;;OAQG;IACG,WAAW,CAAC,aAAa,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAoD5E;;;OAGG;IACG,eAAe,CACnB,KAAK,EAAE,OAAO,EACd,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAAC,qBAAqB,CAAC;IAwBjC;;;OAGG;IACG,iBAAiB,CAAC,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC;IAW9E;;;;OAIG;IACG,SAAS,CAAC,CAAC,EACf,IAAI,EAAE,CAAC,eAAe,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,EAC7C,MAAM,EAAE,MAAM,EACd,OAAO,GAAE,mBAAwB,GAChC,OAAO,CAAC,kBAAkB,CAAC,CAAC,CAAC,CAAC;IA2CjC;;;;;;OAMG;IACG,QAAQ,CAAC,CAAC,EACd,EAAE,EAAE,CAAC,cAAc,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,EAC1C,QAAQ,EAAE,MAAM,EAChB,SAAS,CAAC,EAAE,MAAM,EAClB,eAAe,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,MAAM,EACvC,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,EAAE,QAAQ,EAAE,MAAM,KAAK,CAAC,GAClD,OAAO,CAAC;QAAE,MAAM,EAAE,CAAC,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC;IAa5C;;;OAGG;IACG,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAYtD;;OAEG;IACH,WAAW,IAAI,aAAa,CAAC,UAAU,CAAC;IAIxC;;OAEG;IACG,WAAW,IAAI,OAAO,CAAC;QAAE,MAAM,EAAE,IAAI,GAAG,UAAU,CAAC;QAAC,KAAK,EAAE,OAAO,CAAA;KAAE,CAAC;IAK3E;;OAEG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAI5B,OAAO,CAAC,WAAW;IAQnB,OAAO,CAAC,aAAa;IAIrB,OAAO,CAAC,iBAAiB;YAMX,YAAY;CAmE3B"}
package/dist/guard.js CHANGED
@@ -5,19 +5,34 @@
5
5
  * Orchestrates scanning, tokenization, vault storage, and deanonymization.
6
6
  */
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
- exports.AgentGuard = void 0;
8
+ exports.AgentGuard = exports.OutboundApprovalError = void 0;
9
9
  const uuid_1 = require("uuid");
10
+ const crypto_1 = require("crypto");
10
11
  const scanner_1 = require("./scanner");
11
12
  const vault_1 = require("./vault");
13
+ class OutboundApprovalError extends Error {
14
+ constructor() {
15
+ super('Outbound LLM request was not approved.');
16
+ this.name = 'OutboundApprovalError';
17
+ }
18
+ }
19
+ exports.OutboundApprovalError = OutboundApprovalError;
12
20
  class AgentGuard {
13
21
  constructor(options = {}) {
14
22
  this.options = options;
15
23
  this.auditLog = [];
24
+ const maxTextLength = options.maxTextLength ?? 1000000;
25
+ if (!Number.isSafeInteger(maxTextLength) || maxTextLength <= 0) {
26
+ throw new RangeError('maxTextLength must be a positive safe integer.');
27
+ }
16
28
  this.vault = new vault_1.TokenVault({
17
29
  redisUrl: options.redisUrl,
18
30
  ttlSeconds: options.ttlSeconds,
31
+ maxTokensPerSession: options.maxTokensPerSession,
32
+ vaultEncryptionKey: options.vaultEncryptionKey,
19
33
  });
20
34
  this.verbose = options.verbose ?? false;
35
+ this.maxTextLength = maxTextLength;
21
36
  }
22
37
  /**
23
38
  * Scans input text, replaces all detected PII with deterministic tokens,
@@ -31,6 +46,15 @@ class AgentGuard {
31
46
  * @returns Sanitized text, session ID, and a redaction summary
32
47
  */
33
48
  async anonymize(text, sessionId) {
49
+ if (typeof text !== 'string') {
50
+ throw new TypeError('text must be a string.');
51
+ }
52
+ if (text.length > this.maxTextLength) {
53
+ throw new RangeError(`text exceeds the configured maximum of ${this.maxTextLength} characters.`);
54
+ }
55
+ if (sessionId !== undefined && (typeof sessionId !== 'string' || sessionId.length === 0 || sessionId.length > 512)) {
56
+ throw new TypeError('sessionId must be a non-empty string of at most 512 characters.');
57
+ }
34
58
  const activeSession = sessionId ?? (0, uuid_1.v4)();
35
59
  const scan = (0, scanner_1.scanText)(text, this.options.customPatterns);
36
60
  if (!scan.hasSensitiveData) {
@@ -41,46 +65,32 @@ class AgentGuard {
41
65
  };
42
66
  }
43
67
  const uniqueValues = (0, scanner_1.extractUniqueValues)(scan);
44
- const typeCounters = {};
45
68
  const redactedCounts = {};
46
- const existingVault = await this.vault.getVault(activeSession);
47
- for (const token of Object.keys(existingVault)) {
48
- const match = /^\[([A-Z_]+)_(\d+)\]$/.exec(token);
49
- if (match) {
50
- typeCounters[match[1]] = Math.max(typeCounters[match[1]] ?? 0, Number(match[2]));
51
- }
52
- }
53
- let sanitizedText = text;
69
+ const tokenByValue = new Map();
54
70
  for (const [rawValue, entityType] of uniqueValues.entries()) {
55
- // Check if this value already has a token in the session (deduplication)
56
- const existingToken = await this.vault.lookupToken(activeSession, rawValue);
57
- let token;
58
- if (existingToken) {
59
- token = existingToken;
60
- }
61
- else {
62
- typeCounters[entityType] = (typeCounters[entityType] ?? 0) + 1;
63
- token = `[${entityType}_${typeCounters[entityType]}]`;
64
- while (Object.prototype.hasOwnProperty.call(existingVault, token)) {
65
- typeCounters[entityType]++;
66
- token = `[${entityType}_${typeCounters[entityType]}]`;
67
- }
68
- await this.vault.store(activeSession, token, rawValue);
69
- existingVault[token] = rawValue;
71
+ const { token, created } = await this.vault.getOrStore(activeSession, entityType, rawValue);
72
+ tokenByValue.set(rawValue, token);
73
+ if (created) {
70
74
  redactedCounts[entityType] = (redactedCounts[entityType] ?? 0) + 1;
71
75
  }
72
- // Replace ALL occurrences of rawValue in the text
73
- sanitizedText = sanitizedText.split(rawValue).join(token);
74
76
  }
77
+ let sanitizedText = '';
78
+ let cursor = 0;
79
+ for (const entity of scan.entities) {
80
+ sanitizedText += text.slice(cursor, entity.startIndex);
81
+ sanitizedText += tokenByValue.get(entity.value);
82
+ cursor = entity.endIndex;
83
+ }
84
+ sanitizedText += text.slice(cursor);
75
85
  const redactionSummary = redactedCounts;
76
86
  this.recordAudit({
77
87
  timestamp: new Date().toISOString(),
78
- sessionId: activeSession,
88
+ sessionIdHash: this.hashSessionId(activeSession),
79
89
  operation: 'anonymize',
80
90
  entityTypesRedacted: Object.keys(redactionSummary),
81
91
  });
82
92
  if (this.verbose) {
83
- console.log(`[AgentGuard] Anonymized | session=${activeSession} | types=${JSON.stringify(redactionSummary)}`);
93
+ console.log(`[AgentGuard] Anonymized | sessionHash=${this.hashSessionId(activeSession)} | types=${JSON.stringify(redactionSummary)}`);
84
94
  }
85
95
  return { sanitizedText, sessionId: activeSession, redactionSummary };
86
96
  }
@@ -94,26 +104,122 @@ class AgentGuard {
94
104
  * @returns Text with all tokens replaced by original values
95
105
  */
96
106
  async deanonymize(tokenizedText, sessionId) {
107
+ this.validateSessionId(sessionId);
108
+ if (typeof tokenizedText !== 'string') {
109
+ throw new TypeError('tokenizedText must be a string.');
110
+ }
111
+ if (tokenizedText.length > this.maxTextLength) {
112
+ throw new RangeError(`tokenizedText exceeds the configured maximum of ${this.maxTextLength} characters.`);
113
+ }
97
114
  const vault = await this.vault.getVault(sessionId);
98
- let restoredText = tokenizedText;
99
115
  let restoredCount = 0;
100
- for (const [token, rawValue] of Object.entries(vault)) {
101
- if (restoredText.includes(token)) {
102
- restoredText = restoredText.split(token).join(rawValue);
103
- restoredCount++;
116
+ let outputLength = 0;
117
+ let cursor = 0;
118
+ const parts = [];
119
+ const tokenPattern = /\[[A-Z_]+_\d+\]/g;
120
+ let match;
121
+ while ((match = tokenPattern.exec(tokenizedText)) !== null) {
122
+ const token = match[0];
123
+ const replacement = Object.prototype.hasOwnProperty.call(vault, token)
124
+ ? vault[token]
125
+ : token;
126
+ outputLength += match.index - cursor + replacement.length;
127
+ if (outputLength > this.maxTextLength) {
128
+ throw new RangeError(`restored text exceeds the configured maximum of ${this.maxTextLength} characters.`);
104
129
  }
130
+ parts.push(tokenizedText.slice(cursor, match.index), replacement);
131
+ cursor = match.index + token.length;
132
+ if (Object.prototype.hasOwnProperty.call(vault, token))
133
+ restoredCount++;
105
134
  }
135
+ const tail = tokenizedText.slice(cursor);
136
+ if (outputLength + tail.length > this.maxTextLength) {
137
+ throw new RangeError(`restored text exceeds the configured maximum of ${this.maxTextLength} characters.`);
138
+ }
139
+ parts.push(tail);
140
+ const restoredText = parts.join('');
106
141
  this.recordAudit({
107
142
  timestamp: new Date().toISOString(),
108
- sessionId,
143
+ sessionIdHash: this.hashSessionId(sessionId),
109
144
  operation: 'deanonymize',
110
145
  totalTokensRestored: restoredCount,
111
146
  });
112
147
  if (this.verbose) {
113
- console.log(`[AgentGuard] Deanonymized | session=${sessionId} | tokensRestored=${restoredCount}`);
148
+ console.log(`[AgentGuard] Deanonymized | sessionHash=${this.hashSessionId(sessionId)} | tokensRestored=${restoredCount}`);
114
149
  }
115
150
  return restoredText;
116
151
  }
152
+ /**
153
+ * Anonymizes strings throughout a JSON-compatible value, including property
154
+ * names, and reuses one vault session for the complete structure.
155
+ */
156
+ async anonymizeObject(value, sessionId) {
157
+ const initial = await this.anonymize('', sessionId);
158
+ const redactionSummary = {};
159
+ const sanitizedValue = await this.mapJsonValue(value, async (text) => {
160
+ const result = await this.anonymize(text, initial.sessionId);
161
+ for (const [entityType, count] of Object.entries(result.redactionSummary)) {
162
+ redactionSummary[entityType] = (redactionSummary[entityType] ?? 0) + count;
163
+ }
164
+ return result.sanitizedText;
165
+ }, new WeakSet(), 0, 'Request');
166
+ return {
167
+ sanitizedValue,
168
+ sessionId: initial.sessionId,
169
+ redactionSummary,
170
+ };
171
+ }
172
+ /**
173
+ * Restores tokens throughout a JSON-compatible value using one session.
174
+ * Only call this for a session authorized by the consuming application.
175
+ */
176
+ async deanonymizeObject(value, sessionId) {
177
+ this.validateSessionId(sessionId);
178
+ return this.mapJsonValue(value, (text) => this.deanonymize(text, sessionId), new WeakSet(), 0, 'Response');
179
+ }
180
+ /**
181
+ * Anonymizes a prompt before invoking the caller's outbound LLM function.
182
+ * If supplied, approval receives sanitized text only; any result other than
183
+ * literal true blocks the call. Sanitization or approval errors fail closed.
184
+ */
185
+ async sendToLLM(send, prompt, options = {}) {
186
+ if (typeof send !== 'function') {
187
+ throw new TypeError('send must be a function.');
188
+ }
189
+ if (options.approve !== undefined && typeof options.approve !== 'function') {
190
+ throw new TypeError('approve must be a function when provided.');
191
+ }
192
+ const { sanitizedText, sessionId, redactionSummary } = await this.anonymize(prompt, options.sessionId);
193
+ if (options.approve) {
194
+ let approved;
195
+ try {
196
+ approved = await options.approve({
197
+ sanitizedPrompt: sanitizedText,
198
+ redactionSummary: { ...redactionSummary },
199
+ });
200
+ }
201
+ catch (error) {
202
+ this.recordAudit({
203
+ timestamp: new Date().toISOString(),
204
+ sessionIdHash: this.hashSessionId(sessionId),
205
+ operation: 'outbound_rejected',
206
+ entityTypesRedacted: Object.keys(redactionSummary),
207
+ });
208
+ throw error;
209
+ }
210
+ this.recordAudit({
211
+ timestamp: new Date().toISOString(),
212
+ sessionIdHash: this.hashSessionId(sessionId),
213
+ operation: approved === true ? 'outbound_approved' : 'outbound_rejected',
214
+ entityTypesRedacted: Object.keys(redactionSummary),
215
+ });
216
+ if (approved !== true) {
217
+ throw new OutboundApprovalError();
218
+ }
219
+ }
220
+ const result = await send(sanitizedText);
221
+ return { result, sessionId, redactionSummary };
222
+ }
117
223
  /**
118
224
  * Wraps an async function, automatically anonymizing its string input
119
225
  * and deanonymizing the string output. Useful for wrapping LLM calls.
@@ -139,18 +245,18 @@ class AgentGuard {
139
245
  await this.vault.destroySession(sessionId);
140
246
  this.recordAudit({
141
247
  timestamp: new Date().toISOString(),
142
- sessionId,
248
+ sessionIdHash: this.hashSessionId(sessionId),
143
249
  operation: 'session_destroy',
144
250
  });
145
251
  if (this.verbose) {
146
- console.log(`[AgentGuard] Session destroyed | session=${sessionId}`);
252
+ console.log(`[AgentGuard] Session destroyed | sessionHash=${this.hashSessionId(sessionId)}`);
147
253
  }
148
254
  }
149
255
  /**
150
256
  * Returns the in-memory audit log (no PII, only metadata).
151
257
  */
152
258
  getAuditLog() {
153
- return this.auditLog;
259
+ return this.auditLog.map((entry) => ({ ...entry }));
154
260
  }
155
261
  /**
156
262
  * Checks Redis connectivity. Use in health-check endpoints.
@@ -172,6 +278,65 @@ class AgentGuard {
172
278
  this.auditLog.shift();
173
279
  }
174
280
  }
281
+ hashSessionId(sessionId) {
282
+ return (0, crypto_1.createHash)('sha256').update(sessionId).digest('hex').slice(0, 16);
283
+ }
284
+ validateSessionId(sessionId) {
285
+ if (typeof sessionId !== 'string' || sessionId.length === 0 || sessionId.length > 512) {
286
+ throw new TypeError('sessionId must be a non-empty string of at most 512 characters.');
287
+ }
288
+ }
289
+ async mapJsonValue(value, mapText, ancestors, depth, label) {
290
+ if (depth > 100) {
291
+ throw new RangeError(`${label} body exceeds the maximum nesting depth of 100.`);
292
+ }
293
+ if (typeof value === 'string')
294
+ return mapText(value);
295
+ if (value === null ||
296
+ typeof value === 'boolean' ||
297
+ (typeof value === 'number' && Number.isFinite(value))) {
298
+ return value;
299
+ }
300
+ if (typeof value !== 'object') {
301
+ throw new TypeError(`${label} body must contain only JSON-compatible values.`);
302
+ }
303
+ if (ancestors.has(value)) {
304
+ throw new TypeError(`${label} body must not contain circular references.`);
305
+ }
306
+ if (!Array.isArray(value) &&
307
+ Object.getPrototypeOf(value) !== Object.prototype &&
308
+ Object.getPrototypeOf(value) !== null) {
309
+ throw new TypeError(`${label} body must contain only JSON-compatible objects.`);
310
+ }
311
+ ancestors.add(value);
312
+ try {
313
+ if (Array.isArray(value)) {
314
+ const result = [];
315
+ for (const item of value) {
316
+ result.push(await this.mapJsonValue(item, mapText, ancestors, depth + 1, label));
317
+ }
318
+ return result;
319
+ }
320
+ const result = {};
321
+ for (const [key, item] of Object.entries(value)) {
322
+ const mappedKey = await mapText(key);
323
+ const mappedValue = await this.mapJsonValue(item, mapText, ancestors, depth + 1, label);
324
+ if (Object.prototype.hasOwnProperty.call(result, mappedKey)) {
325
+ throw new TypeError(`${label} property names collide after anonymization.`);
326
+ }
327
+ Object.defineProperty(result, mappedKey, {
328
+ configurable: true,
329
+ enumerable: true,
330
+ value: mappedValue,
331
+ writable: true,
332
+ });
333
+ }
334
+ return result;
335
+ }
336
+ finally {
337
+ ancestors.delete(value);
338
+ }
339
+ }
175
340
  }
176
341
  exports.AgentGuard = AgentGuard;
177
342
  //# sourceMappingURL=guard.js.map