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.
Files changed (42) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +57 -15
  3. package/dist/__tests__/crypto.test.js +137 -47
  4. package/dist/__tests__/crypto.test.js.map +1 -1
  5. package/dist/__tests__/messaging.test.d.ts +2 -0
  6. package/dist/__tests__/messaging.test.d.ts.map +1 -0
  7. package/dist/__tests__/messaging.test.js +75 -0
  8. package/dist/__tests__/messaging.test.js.map +1 -0
  9. package/dist/__tests__/policy.test.js +124 -7
  10. package/dist/__tests__/policy.test.js.map +1 -1
  11. package/dist/__tests__/signing.test (# Edit conflict 2026-04-01 z3etfmC #).js +51 -0
  12. package/dist/__tests__/signing.test.js (# Edit conflict 2026-04-01 4rndy9C #).map +1 -0
  13. package/dist/crypto.d.ts +36 -0
  14. package/dist/crypto.d.ts.map +1 -1
  15. package/dist/crypto.js +150 -5
  16. package/dist/crypto.js.map +1 -1
  17. package/dist/plans.d.ts +4 -0
  18. package/dist/plans.d.ts.map +1 -1
  19. package/dist/plans.js +16 -0
  20. package/dist/plans.js.map +1 -1
  21. package/dist/policy.d.ts.map +1 -1
  22. package/dist/policy.js +54 -29
  23. package/dist/policy.js.map +1 -1
  24. package/dist/redact.d.ts.map +1 -1
  25. package/dist/redact.js +21 -4
  26. package/dist/redact.js.map +1 -1
  27. package/dist/schemas.d.ts +72 -11
  28. package/dist/schemas.d.ts.map +1 -1
  29. package/dist/schemas.js +62 -10
  30. package/dist/schemas.js.map +1 -1
  31. package/dist/types.d.ts +1 -0
  32. package/dist/types.d.ts.map +1 -1
  33. package/package.json +1 -1
  34. package/src/__tests__/crypto.test.ts +169 -0
  35. package/src/__tests__/messaging.test.ts +83 -0
  36. package/src/__tests__/policy.test.ts +141 -7
  37. package/src/crypto.ts +153 -5
  38. package/src/plans.ts +20 -0
  39. package/src/policy.ts +58 -28
  40. package/src/redact.ts +20 -3
  41. package/src/schemas.ts +121 -53
  42. 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
- const action: AgentActionRequest = { action_type: 'read', tool: 'http', payload: {} };
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 ALLOW admin actions when defaultMode is allow and no matching rule', () => {
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('ALLOW');
57
- expect(result.reason).toBe('Default policy');
57
+ expect(result.decision).toBe('REQUIRE_APPROVAL');
58
58
  });
59
59
 
60
- it('should ALLOW financial actions when defaultMode is allow and no matching rule', () => {
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('ALLOW');
65
- expect(result.reason).toBe('Default policy');
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 (!message) {
27
- throw new Error('Decryption failed: invalid key or corrupted data');
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
- return new TextDecoder().decode(message);
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