agentlock-shared 0.1.0 → 0.2.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/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +57 -15
- package/dist/__tests__/crypto.test.js +137 -47
- package/dist/__tests__/crypto.test.js.map +1 -1
- package/dist/__tests__/messaging.test.d.ts +2 -0
- package/dist/__tests__/messaging.test.d.ts.map +1 -0
- package/dist/__tests__/messaging.test.js +75 -0
- package/dist/__tests__/messaging.test.js.map +1 -0
- package/dist/__tests__/policy.test.js +124 -7
- package/dist/__tests__/policy.test.js.map +1 -1
- package/dist/__tests__/signing.test (# Edit conflict 2026-04-01 z3etfmC #).js +51 -0
- package/dist/__tests__/signing.test.js (# Edit conflict 2026-04-01 4rndy9C #).map +1 -0
- package/dist/crypto.d.ts +36 -0
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js +150 -5
- package/dist/crypto.js.map +1 -1
- package/dist/plans.d.ts +4 -0
- package/dist/plans.d.ts.map +1 -1
- package/dist/plans.js +16 -0
- package/dist/plans.js.map +1 -1
- package/dist/policy.d.ts.map +1 -1
- package/dist/policy.js +54 -29
- package/dist/policy.js.map +1 -1
- package/dist/redact.d.ts.map +1 -1
- package/dist/redact.js +21 -4
- package/dist/redact.js.map +1 -1
- package/dist/schemas.d.ts +72 -11
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +62 -10
- package/dist/schemas.js.map +1 -1
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/crypto.test.ts +169 -0
- package/src/__tests__/messaging.test.ts +83 -0
- package/src/__tests__/policy.test.ts +141 -7
- package/src/crypto.ts +153 -5
- package/src/plans.ts +20 -0
- package/src/policy.ts +58 -28
- package/src/redact.ts +20 -3
- package/src/schemas.ts +121 -53
- package/src/types.ts +1 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { SendMessageSchema, AgentSendMessageSchema, ApproveRequestSchema } from '../schemas';
|
|
3
|
+
|
|
4
|
+
describe('SendMessageSchema', () => {
|
|
5
|
+
it('accepts valid message with content only', () => {
|
|
6
|
+
const result = SendMessageSchema.safeParse({ content: 'Hello agent' });
|
|
7
|
+
expect(result.success).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('accepts message with thread_id and expires_at', () => {
|
|
11
|
+
const result = SendMessageSchema.safeParse({
|
|
12
|
+
content: 'Do this task',
|
|
13
|
+
thread_id: '550e8400-e29b-41d4-a716-446655440000',
|
|
14
|
+
expires_at: '2026-04-01T12:00:00Z',
|
|
15
|
+
});
|
|
16
|
+
expect(result.success).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('rejects empty content', () => {
|
|
20
|
+
const result = SendMessageSchema.safeParse({ content: '' });
|
|
21
|
+
expect(result.success).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('rejects content over 4096 chars', () => {
|
|
25
|
+
const result = SendMessageSchema.safeParse({ content: 'x'.repeat(4097) });
|
|
26
|
+
expect(result.success).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('rejects invalid thread_id', () => {
|
|
30
|
+
const result = SendMessageSchema.safeParse({ content: 'hi', thread_id: 'not-a-uuid' });
|
|
31
|
+
expect(result.success).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('AgentSendMessageSchema', () => {
|
|
36
|
+
it('requires thread_id', () => {
|
|
37
|
+
const result = AgentSendMessageSchema.safeParse({ content: 'Reply' });
|
|
38
|
+
expect(result.success).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('accepts valid reply', () => {
|
|
42
|
+
const result = AgentSendMessageSchema.safeParse({
|
|
43
|
+
content: 'I found 3 files',
|
|
44
|
+
thread_id: '550e8400-e29b-41d4-a716-446655440000',
|
|
45
|
+
});
|
|
46
|
+
expect(result.success).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('ApproveRequestSchema with reply_message', () => {
|
|
51
|
+
it('accepts approve without reply', () => {
|
|
52
|
+
const result = ApproveRequestSchema.safeParse({ action: 'approve' });
|
|
53
|
+
expect(result.success).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('accepts approve with reply_message', () => {
|
|
57
|
+
const result = ApproveRequestSchema.safeParse({
|
|
58
|
+
action: 'approve',
|
|
59
|
+
reply_message: 'Only delete files older than 7 days',
|
|
60
|
+
});
|
|
61
|
+
expect(result.success).toBe(true);
|
|
62
|
+
if (result.success) {
|
|
63
|
+
expect(result.data.reply_message).toBe('Only delete files older than 7 days');
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('accepts deny with reason and reply_message', () => {
|
|
68
|
+
const result = ApproveRequestSchema.safeParse({
|
|
69
|
+
action: 'deny',
|
|
70
|
+
reason: 'Too risky',
|
|
71
|
+
reply_message: 'Try a safer approach',
|
|
72
|
+
});
|
|
73
|
+
expect(result.success).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('rejects reply_message over 2000 chars', () => {
|
|
77
|
+
const result = ApproveRequestSchema.safeParse({
|
|
78
|
+
action: 'approve',
|
|
79
|
+
reply_message: 'x'.repeat(2001),
|
|
80
|
+
});
|
|
81
|
+
expect(result.success).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -4,7 +4,8 @@ import type { AgentActionRequest } from '../types.js';
|
|
|
4
4
|
|
|
5
5
|
describe('Policy Engine', () => {
|
|
6
6
|
it('should ALLOW read actions by default', () => {
|
|
7
|
-
|
|
7
|
+
// Use a non-http tool to test the default read policy (http without URL is now correctly blocked)
|
|
8
|
+
const action: AgentActionRequest = { action_type: 'read', tool: 'demo.list', payload: {} };
|
|
8
9
|
expect(evaluatePolicy(action, DEFAULT_POLICY_RULES).decision).toBe('ALLOW');
|
|
9
10
|
});
|
|
10
11
|
|
|
@@ -49,20 +50,40 @@ describe('Policy Engine', () => {
|
|
|
49
50
|
expect(evaluatePolicy(action, rules).decision).toBe('BLOCK');
|
|
50
51
|
});
|
|
51
52
|
|
|
52
|
-
it('should
|
|
53
|
+
it('should REQUIRE_APPROVAL for admin actions even when defaultMode is allow', () => {
|
|
53
54
|
const action: AgentActionRequest = { action_type: 'admin', tool: 'system', payload: {} };
|
|
54
55
|
const rules = { ...DEFAULT_POLICY_RULES, defaultMode: 'allow' as const, rules: [] };
|
|
55
56
|
const result = evaluatePolicy(action, rules);
|
|
56
|
-
expect(result.decision).toBe('
|
|
57
|
-
expect(result.reason).toBe('Default policy');
|
|
57
|
+
expect(result.decision).toBe('REQUIRE_APPROVAL');
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
-
it('should
|
|
60
|
+
it('should REQUIRE_APPROVAL for financial actions even when defaultMode is allow', () => {
|
|
61
61
|
const action: AgentActionRequest = { action_type: 'financial', tool: 'stripe', payload: {} };
|
|
62
62
|
const rules = { ...DEFAULT_POLICY_RULES, defaultMode: 'allow' as const, rules: [] };
|
|
63
63
|
const result = evaluatePolicy(action, rules);
|
|
64
|
-
expect(result.decision).toBe('
|
|
65
|
-
|
|
64
|
+
expect(result.decision).toBe('REQUIRE_APPROVAL');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should REQUIRE_APPROVAL for admin action even with explicit ALLOW rule', () => {
|
|
68
|
+
const action: AgentActionRequest = { action_type: 'admin', tool: 'admin.delete_user', payload: {} };
|
|
69
|
+
const rules = {
|
|
70
|
+
...DEFAULT_POLICY_RULES,
|
|
71
|
+
defaultMode: 'allow' as const,
|
|
72
|
+
rules: [{ action_type: 'admin' as const, decision: 'ALLOW' as const }],
|
|
73
|
+
};
|
|
74
|
+
const result = evaluatePolicy(action, rules);
|
|
75
|
+
expect(result.decision).toBe('REQUIRE_APPROVAL');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should REQUIRE_APPROVAL for financial action even with explicit ALLOW rule', () => {
|
|
79
|
+
const action: AgentActionRequest = { action_type: 'financial', tool: 'stripe.charge', payload: {} };
|
|
80
|
+
const rules = {
|
|
81
|
+
...DEFAULT_POLICY_RULES,
|
|
82
|
+
defaultMode: 'allow' as const,
|
|
83
|
+
rules: [{ action_type: 'financial' as const, decision: 'ALLOW' as const }],
|
|
84
|
+
};
|
|
85
|
+
const result = evaluatePolicy(action, rules);
|
|
86
|
+
expect(result.decision).toBe('REQUIRE_APPROVAL');
|
|
66
87
|
});
|
|
67
88
|
|
|
68
89
|
it('should still respect explicit rules for admin even with defaultMode allow', () => {
|
|
@@ -85,4 +106,117 @@ describe('Policy Engine', () => {
|
|
|
85
106
|
const rules = { ...DEFAULT_POLICY_RULES, limits: { maxCostPerAction: 100 } };
|
|
86
107
|
expect(evaluatePolicy(action, rules).decision).toBe('BLOCK');
|
|
87
108
|
});
|
|
109
|
+
|
|
110
|
+
// --- Case-sensitivity bypass tests ---
|
|
111
|
+
|
|
112
|
+
it('should enforce HTTP domain allowlist even with mixed-case tool name', () => {
|
|
113
|
+
const action: AgentActionRequest = {
|
|
114
|
+
action_type: 'read',
|
|
115
|
+
tool: 'Http.get',
|
|
116
|
+
payload: { url: 'https://evil.com/data', method: 'GET' },
|
|
117
|
+
};
|
|
118
|
+
const rules = {
|
|
119
|
+
...DEFAULT_POLICY_RULES,
|
|
120
|
+
http: { allowedDomains: ['trusted.com'], allowedMethods: ['GET'], blockList: [] },
|
|
121
|
+
};
|
|
122
|
+
expect(evaluatePolicy(action, rules).decision).toBe('BLOCK');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should match tool-specific rules case-insensitively', () => {
|
|
126
|
+
const action: AgentActionRequest = {
|
|
127
|
+
action_type: 'read',
|
|
128
|
+
tool: 'MCP.list_tools',
|
|
129
|
+
payload: {},
|
|
130
|
+
};
|
|
131
|
+
const result = evaluatePolicy(action, DEFAULT_POLICY_RULES);
|
|
132
|
+
expect(result.decision).toBe('ALLOW');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// --- URL edge cases ---
|
|
136
|
+
|
|
137
|
+
it('should not match subdomain of blocklisted domain (e.g., notevil.com vs evil.com)', () => {
|
|
138
|
+
const action: AgentActionRequest = {
|
|
139
|
+
action_type: 'read',
|
|
140
|
+
tool: 'http.get',
|
|
141
|
+
payload: { url: 'https://notevil.com/data', method: 'GET' },
|
|
142
|
+
};
|
|
143
|
+
const rules = {
|
|
144
|
+
...DEFAULT_POLICY_RULES,
|
|
145
|
+
http: { allowedDomains: ['notevil.com', 'trusted.com'], allowedMethods: ['GET'], blockList: ['evil.com'] },
|
|
146
|
+
};
|
|
147
|
+
expect(evaluatePolicy(action, rules).decision).not.toBe('BLOCK');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should BLOCK HTTP tool with no URL in payload', () => {
|
|
151
|
+
const action: AgentActionRequest = {
|
|
152
|
+
action_type: 'read',
|
|
153
|
+
tool: 'http.get',
|
|
154
|
+
payload: {},
|
|
155
|
+
};
|
|
156
|
+
const rules = {
|
|
157
|
+
...DEFAULT_POLICY_RULES,
|
|
158
|
+
http: { allowedDomains: ['trusted.com'], allowedMethods: ['GET'], blockList: [] },
|
|
159
|
+
};
|
|
160
|
+
expect(evaluatePolicy(action, rules).decision).toBe('BLOCK');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// --- Browser tool policy ---
|
|
164
|
+
|
|
165
|
+
it('should REQUIRE_APPROVAL for browser.open', () => {
|
|
166
|
+
const action: AgentActionRequest = {
|
|
167
|
+
action_type: 'write',
|
|
168
|
+
tool: 'browser.open',
|
|
169
|
+
payload: { url: 'https://example.com' },
|
|
170
|
+
};
|
|
171
|
+
const result = evaluatePolicy(action, DEFAULT_POLICY_RULES);
|
|
172
|
+
expect(result.decision).toBe('REQUIRE_APPROVAL');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should BLOCK browser.* actions without a session (reaching policy engine)', () => {
|
|
176
|
+
const action: AgentActionRequest = {
|
|
177
|
+
action_type: 'write',
|
|
178
|
+
tool: 'browser.click',
|
|
179
|
+
payload: {},
|
|
180
|
+
};
|
|
181
|
+
const result = evaluatePolicy(action, DEFAULT_POLICY_RULES);
|
|
182
|
+
expect(result.decision).toBe('BLOCK');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// --- Unknown action_type ---
|
|
186
|
+
|
|
187
|
+
it('should BLOCK unknown action types', () => {
|
|
188
|
+
const action = {
|
|
189
|
+
action_type: 'delete' as 'read',
|
|
190
|
+
tool: 'custom.tool',
|
|
191
|
+
payload: {},
|
|
192
|
+
};
|
|
193
|
+
const result = evaluatePolicy(action, DEFAULT_POLICY_RULES);
|
|
194
|
+
expect(result.decision).toBe('BLOCK');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// --- cost_estimate omission ---
|
|
198
|
+
|
|
199
|
+
it('should not enforce budget check when cost_estimate is omitted', () => {
|
|
200
|
+
const action: AgentActionRequest = {
|
|
201
|
+
action_type: 'write',
|
|
202
|
+
tool: 'demo',
|
|
203
|
+
payload: {},
|
|
204
|
+
// cost_estimate intentionally omitted
|
|
205
|
+
};
|
|
206
|
+
const rules = { ...DEFAULT_POLICY_RULES, limits: { maxCostPerAction: 100 } };
|
|
207
|
+
// Should match action_type 'write' rule, not be BLOCK-ed by cost limit
|
|
208
|
+
expect(evaluatePolicy(action, rules).decision).toBe('REQUIRE_APPROVAL');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// --- HTTP allowlist not configured ---
|
|
212
|
+
|
|
213
|
+
it('should REQUIRE_APPROVAL when HTTP allowlist is empty (safe default)', () => {
|
|
214
|
+
const action: AgentActionRequest = {
|
|
215
|
+
action_type: 'read',
|
|
216
|
+
tool: 'http.get',
|
|
217
|
+
payload: { url: 'https://any-site.com/data', method: 'GET' },
|
|
218
|
+
};
|
|
219
|
+
const result = evaluatePolicy(action, DEFAULT_POLICY_RULES);
|
|
220
|
+
expect(result.decision).toBe('REQUIRE_APPROVAL');
|
|
221
|
+
});
|
|
88
222
|
});
|
package/src/crypto.ts
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
import nacl from 'tweetnacl';
|
|
2
2
|
import { encodeBase64, decodeBase64 } from 'tweetnacl-util';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Envelope encryption prefix. Data encrypted with envelopeEncrypt() is
|
|
6
|
+
* stored as a single string: "env1:<base64-wrappedDEK>:<base64-payload>".
|
|
7
|
+
* The decrypt() function detects this prefix and automatically unwraps
|
|
8
|
+
* using the DEK, maintaining backward compatibility with legacy data
|
|
9
|
+
* encrypted directly with MASTER_KEY.
|
|
10
|
+
*/
|
|
11
|
+
const ENVELOPE_PREFIX = 'env1:';
|
|
12
|
+
|
|
4
13
|
export function generateKey(): Uint8Array {
|
|
5
14
|
return nacl.randomBytes(32);
|
|
6
15
|
}
|
|
7
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Low-level symmetric encryption using a provided key.
|
|
19
|
+
* Callers that need envelope encryption (per-data DEK) should use
|
|
20
|
+
* envelopeEncrypt() instead.
|
|
21
|
+
*/
|
|
8
22
|
export function encrypt(data: string, key: Uint8Array): string {
|
|
9
23
|
const nonce = nacl.randomBytes(nacl.secretbox.nonceLength);
|
|
10
24
|
const message = new TextEncoder().encode(data);
|
|
@@ -17,17 +31,125 @@ export function encrypt(data: string, key: Uint8Array): string {
|
|
|
17
31
|
return encodeBase64(combined);
|
|
18
32
|
}
|
|
19
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Low-level symmetric decryption. Handles both envelope-encrypted data
|
|
36
|
+
* (prefixed with "env1:") and legacy direct-key encrypted data.
|
|
37
|
+
*
|
|
38
|
+
* For envelope data: unwraps the per-data DEK using the master key,
|
|
39
|
+
* then decrypts the payload with the DEK.
|
|
40
|
+
*
|
|
41
|
+
* For legacy data: decrypts directly with the provided key, falling
|
|
42
|
+
* back to MASTER_KEY_PREVIOUS for key rotation support.
|
|
43
|
+
*/
|
|
20
44
|
export function decrypt(encryptedData: string, key: Uint8Array): string {
|
|
45
|
+
// Detect envelope-encrypted format and handle transparently
|
|
46
|
+
if (encryptedData.startsWith(ENVELOPE_PREFIX)) {
|
|
47
|
+
return envelopeDecryptInternal(encryptedData, key);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Legacy format: data encrypted directly with MASTER_KEY
|
|
21
51
|
const combined = decodeBase64(encryptedData);
|
|
22
52
|
const nonce = combined.slice(0, nacl.secretbox.nonceLength);
|
|
23
53
|
const box = combined.slice(nacl.secretbox.nonceLength);
|
|
24
54
|
|
|
25
55
|
const message = nacl.secretbox.open(box, nonce, key);
|
|
26
|
-
if (
|
|
27
|
-
|
|
56
|
+
if (message) {
|
|
57
|
+
return new TextDecoder().decode(message);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Try previous master key for rotation support
|
|
61
|
+
const prevKey = getPreviousMasterKey();
|
|
62
|
+
if (prevKey) {
|
|
63
|
+
const message2 = nacl.secretbox.open(box, nonce, prevKey);
|
|
64
|
+
if (message2) {
|
|
65
|
+
return new TextDecoder().decode(message2);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
throw new Error('Decryption failed: invalid key or corrupted data');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Encrypt arbitrary data using a per-data DEK (envelope encryption).
|
|
74
|
+
* Generates a fresh 256-bit DEK, encrypts the data with it, then wraps
|
|
75
|
+
* the DEK with the provided master key. Returns a single string in the
|
|
76
|
+
* format "env1:<wrappedDEK>:<encryptedPayload>" so it fits in a single
|
|
77
|
+
* database column and is backward-compatible with decrypt().
|
|
78
|
+
*
|
|
79
|
+
* Use this instead of encrypt() for all data at rest. Each encrypted
|
|
80
|
+
* value gets its own DEK, so compromising one ciphertext does not
|
|
81
|
+
* expose other data -- the attacker still needs the master key to
|
|
82
|
+
* unwrap any individual DEK.
|
|
83
|
+
*/
|
|
84
|
+
export function envelopeEncrypt(data: string, masterKey: Uint8Array): string {
|
|
85
|
+
const dek = generateKey();
|
|
86
|
+
try {
|
|
87
|
+
const wrappedDEK = encrypt(encodeBase64(dek), masterKey);
|
|
88
|
+
const encryptedPayload = encrypt(data, dek);
|
|
89
|
+
return `${ENVELOPE_PREFIX}${wrappedDEK}:${encryptedPayload}`;
|
|
90
|
+
} finally {
|
|
91
|
+
// Zero DEK from memory after use (defense-in-depth against heap dumps)
|
|
92
|
+
dek.fill(0);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Decrypt data produced by envelopeEncrypt(). Expects the "env1:" prefixed
|
|
98
|
+
* format. For general use, call decrypt() which auto-detects the format.
|
|
99
|
+
*/
|
|
100
|
+
export function envelopeDecrypt(encryptedData: string, masterKey: Uint8Array): string {
|
|
101
|
+
if (!encryptedData.startsWith(ENVELOPE_PREFIX)) {
|
|
102
|
+
throw new Error('Not an envelope-encrypted value (missing env1: prefix)');
|
|
103
|
+
}
|
|
104
|
+
return envelopeDecryptInternal(encryptedData, masterKey);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Internal envelope decryption. Parses the "env1:wrappedDEK:payload" format,
|
|
109
|
+
* unwraps the DEK, and decrypts the payload.
|
|
110
|
+
*/
|
|
111
|
+
function envelopeDecryptInternal(encryptedData: string, masterKey: Uint8Array): string {
|
|
112
|
+
// Format: "env1:<base64-wrappedDEK>:<base64-encryptedPayload>"
|
|
113
|
+
// The wrapped DEK itself is a base64 string produced by encrypt(), which
|
|
114
|
+
// can contain '+', '/', '=' but never ':'. So we split on ':' after the prefix.
|
|
115
|
+
const withoutPrefix = encryptedData.slice(ENVELOPE_PREFIX.length);
|
|
116
|
+
const separatorIndex = withoutPrefix.lastIndexOf(':');
|
|
117
|
+
if (separatorIndex <= 0) {
|
|
118
|
+
throw new Error('Invalid envelope format: missing DEK/payload separator');
|
|
28
119
|
}
|
|
120
|
+
const wrappedDEK = withoutPrefix.slice(0, separatorIndex);
|
|
121
|
+
const encryptedPayload = withoutPrefix.slice(separatorIndex + 1);
|
|
29
122
|
|
|
30
|
-
|
|
123
|
+
if (!wrappedDEK || !encryptedPayload) {
|
|
124
|
+
throw new Error('Invalid envelope format: empty DEK or payload');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Unwrap the DEK using the master key (with key-rotation fallback)
|
|
128
|
+
let dekBase64: string;
|
|
129
|
+
try {
|
|
130
|
+
dekBase64 = decrypt(wrappedDEK, masterKey);
|
|
131
|
+
} catch {
|
|
132
|
+
throw new Error('Envelope decryption failed: could not unwrap DEK');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const dek = decodeBase64(dekBase64);
|
|
136
|
+
try {
|
|
137
|
+
// Decrypt the payload using the per-data DEK
|
|
138
|
+
// Use low-level decryption here (not decrypt()) to avoid infinite
|
|
139
|
+
// recursion on the envelope prefix check -- the inner payload is
|
|
140
|
+
// always in legacy format.
|
|
141
|
+
const combined = decodeBase64(encryptedPayload);
|
|
142
|
+
const nonce = combined.slice(0, nacl.secretbox.nonceLength);
|
|
143
|
+
const box = combined.slice(nacl.secretbox.nonceLength);
|
|
144
|
+
const message = nacl.secretbox.open(box, nonce, dek);
|
|
145
|
+
if (!message) {
|
|
146
|
+
throw new Error('Envelope decryption failed: payload decryption failed');
|
|
147
|
+
}
|
|
148
|
+
return new TextDecoder().decode(message);
|
|
149
|
+
} finally {
|
|
150
|
+
// Zero DEK from memory after use
|
|
151
|
+
dek.fill(0);
|
|
152
|
+
}
|
|
31
153
|
}
|
|
32
154
|
|
|
33
155
|
export function encryptDEK(dek: Uint8Array, masterKey: Uint8Array): string {
|
|
@@ -44,11 +166,37 @@ export function decryptDEK(encryptedDEK: string, masterKey: Uint8Array): Uint8Ar
|
|
|
44
166
|
let _cachedMasterKey: Uint8Array | null = null;
|
|
45
167
|
|
|
46
168
|
export function getMasterKey(): Uint8Array {
|
|
47
|
-
if (_cachedMasterKey) return _cachedMasterKey;
|
|
169
|
+
if (_cachedMasterKey) return _cachedMasterKey.slice();
|
|
48
170
|
const key = process.env.MASTER_KEY;
|
|
49
171
|
if (!key) throw new Error('MASTER_KEY environment variable not set');
|
|
50
172
|
_cachedMasterKey = decodeBase64(key);
|
|
51
|
-
return _cachedMasterKey;
|
|
173
|
+
return _cachedMasterKey.slice();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Support MASTER_KEY rotation: try previous key when current key fails
|
|
177
|
+
let _cachedPrevKey: Uint8Array | null = null;
|
|
178
|
+
let _prevKeyChecked = false;
|
|
179
|
+
|
|
180
|
+
export function getPreviousMasterKey(): Uint8Array | null {
|
|
181
|
+
if (_prevKeyChecked) return _cachedPrevKey ? _cachedPrevKey.slice() : null;
|
|
182
|
+
_prevKeyChecked = true;
|
|
183
|
+
const key = process.env.MASTER_KEY_PREVIOUS;
|
|
184
|
+
if (!key) return null;
|
|
185
|
+
_cachedPrevKey = decodeBase64(key);
|
|
186
|
+
return _cachedPrevKey.slice();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Zero and release cached master keys. Call on graceful shutdown. */
|
|
190
|
+
export function clearCachedKeys(): void {
|
|
191
|
+
if (_cachedMasterKey) {
|
|
192
|
+
_cachedMasterKey.fill(0);
|
|
193
|
+
_cachedMasterKey = null;
|
|
194
|
+
}
|
|
195
|
+
if (_cachedPrevKey) {
|
|
196
|
+
_cachedPrevKey.fill(0);
|
|
197
|
+
_cachedPrevKey = null;
|
|
198
|
+
}
|
|
199
|
+
_prevKeyChecked = false;
|
|
52
200
|
}
|
|
53
201
|
|
|
54
202
|
export function generateMasterKey(): string {
|
package/src/plans.ts
CHANGED
|
@@ -5,9 +5,13 @@ export interface PlanLimits {
|
|
|
5
5
|
agents: number;
|
|
6
6
|
credentials: number;
|
|
7
7
|
members: number;
|
|
8
|
+
policies: number;
|
|
8
9
|
timelineHistoryDays: number;
|
|
9
10
|
undoEnabled: boolean;
|
|
10
11
|
browserSessions: number;
|
|
12
|
+
messagesPerMonth: number;
|
|
13
|
+
messageHistoryDays: number;
|
|
14
|
+
threadsPerAgent: number;
|
|
11
15
|
}
|
|
12
16
|
|
|
13
17
|
export interface PlanDefinition extends PlanLimits {
|
|
@@ -31,9 +35,13 @@ export const PLANS: Record<PlanId, PlanDefinition> = {
|
|
|
31
35
|
agents: 3,
|
|
32
36
|
credentials: 2,
|
|
33
37
|
members: 1,
|
|
38
|
+
policies: 3,
|
|
34
39
|
timelineHistoryDays: 14,
|
|
35
40
|
undoEnabled: false,
|
|
36
41
|
browserSessions: 0,
|
|
42
|
+
messagesPerMonth: 100,
|
|
43
|
+
messageHistoryDays: 7,
|
|
44
|
+
threadsPerAgent: 5,
|
|
37
45
|
},
|
|
38
46
|
pro: {
|
|
39
47
|
id: 'pro',
|
|
@@ -46,9 +54,13 @@ export const PLANS: Record<PlanId, PlanDefinition> = {
|
|
|
46
54
|
agents: 10,
|
|
47
55
|
credentials: 25,
|
|
48
56
|
members: 5,
|
|
57
|
+
policies: 10,
|
|
49
58
|
timelineHistoryDays: 90,
|
|
50
59
|
undoEnabled: true,
|
|
51
60
|
browserSessions: 2,
|
|
61
|
+
messagesPerMonth: 5000,
|
|
62
|
+
messageHistoryDays: 30,
|
|
63
|
+
threadsPerAgent: 50,
|
|
52
64
|
},
|
|
53
65
|
team: {
|
|
54
66
|
id: 'team',
|
|
@@ -61,9 +73,13 @@ export const PLANS: Record<PlanId, PlanDefinition> = {
|
|
|
61
73
|
agents: 50,
|
|
62
74
|
credentials: Infinity,
|
|
63
75
|
members: 25,
|
|
76
|
+
policies: 50,
|
|
64
77
|
timelineHistoryDays: 365,
|
|
65
78
|
undoEnabled: true,
|
|
66
79
|
browserSessions: 5,
|
|
80
|
+
messagesPerMonth: Infinity,
|
|
81
|
+
messageHistoryDays: 90,
|
|
82
|
+
threadsPerAgent: Infinity,
|
|
67
83
|
},
|
|
68
84
|
} as const;
|
|
69
85
|
|
|
@@ -75,9 +91,13 @@ export function getPlanLimits(plan: string): PlanLimits {
|
|
|
75
91
|
agents: def.agents,
|
|
76
92
|
credentials: def.credentials,
|
|
77
93
|
members: def.members,
|
|
94
|
+
policies: def.policies,
|
|
78
95
|
timelineHistoryDays: def.timelineHistoryDays,
|
|
79
96
|
undoEnabled: def.undoEnabled,
|
|
80
97
|
browserSessions: def.browserSessions,
|
|
98
|
+
messagesPerMonth: def.messagesPerMonth,
|
|
99
|
+
messageHistoryDays: def.messageHistoryDays,
|
|
100
|
+
threadsPerAgent: def.threadsPerAgent,
|
|
81
101
|
};
|
|
82
102
|
}
|
|
83
103
|
|