@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 +107 -23
- package/dist/guard.d.ts +51 -2
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +204 -39
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/langchain.d.ts +4 -1
- package/dist/langchain.d.ts.map +1 -1
- package/dist/langchain.js +30 -12
- package/dist/langchain.js.map +1 -1
- package/dist/mastra.d.ts.map +1 -1
- package/dist/mastra.js +4 -9
- package/dist/mastra.js.map +1 -1
- package/dist/middleware.d.ts +10 -5
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +13 -48
- package/dist/middleware.js.map +1 -1
- package/dist/scanner.d.ts.map +1 -1
- package/dist/scanner.js +11 -2
- package/dist/scanner.js.map +1 -1
- package/dist/vault.d.ts +23 -3
- package/dist/vault.d.ts.map +1 -1
- package/dist/vault.js +169 -16
- package/dist/vault.js.map +1 -1
- package/package.json +1 -1
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/
|
|
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)
|
|
7
7
|
[](https://nodejs.org)
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
## The Problem
|
|
13
13
|
|
|
14
|
-
When
|
|
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
|
|
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
|
|
29
|
-
β (Local Middleware)
|
|
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
|
|
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
|
|
40
|
+
β AgentGuard SDK β (6) Restore: "Fill form with John, john@x.com"
|
|
41
41
|
ββββββββββββ¬ββββββββββββ
|
|
42
42
|
β
|
|
43
43
|
βΌ
|
|
44
44
|
ββββββββββββββββββββββββ
|
|
45
|
-
β Tool / Browser
|
|
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
|
-
| `
|
|
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://...`)
|
|
268
|
-
|
|
269
|
-
3. Set `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
β
|
|
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
|
-
- **
|
|
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
|
|
318
|
-
- **
|
|
319
|
-
- **
|
|
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 |
|
|
326
|
-
|
|
|
327
|
-
| Session isolation | Per session | Namespaced Redis keys per session
|
|
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
|
-
|
|
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
|
package/dist/guard.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"guard.d.ts","sourceRoot":"","sources":["../src/guard.ts"],"names":[],"mappings":"AAAA;;;;GAIG;
|
|
1
|
+
{"version":3,"file":"guard.d.ts","sourceRoot":"","sources":["../src/guard.ts"],"names":[],"mappings":"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"}
|
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
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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 |
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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 |
|
|
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 |
|
|
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
|