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
package/src/policy.ts CHANGED
@@ -21,6 +21,7 @@ export const DEFAULT_POLICY_RULES: PolicyRules = {
21
21
  { action_type: 'write', decision: 'REQUIRE_APPROVAL' },
22
22
  { action_type: 'financial', decision: 'REQUIRE_APPROVAL' },
23
23
  { action_type: 'admin', decision: 'BLOCK' },
24
+ { tool: 'mcp.list_tools', decision: 'ALLOW' },
24
25
  ],
25
26
  http: {
26
27
  allowedDomains: [],
@@ -28,19 +29,35 @@ export const DEFAULT_POLICY_RULES: PolicyRules = {
28
29
  blockList: [],
29
30
  },
30
31
  limits: {
31
- maxActionsPerHour: 100,
32
+ maxActionsPerHour: 100, // Enforced in gateway via check_rate_limit RPC
32
33
  },
33
34
  };
34
35
 
36
+ // Default HTTP rules when rules.http is not configured
37
+ const DEFAULT_HTTP_RULES = {
38
+ allowedDomains: [] as string[],
39
+ allowedMethods: ['GET', 'POST', 'PUT', 'DELETE'],
40
+ blockList: [] as string[],
41
+ };
42
+
35
43
  export function evaluatePolicy(
36
44
  action: AgentActionRequest,
37
45
  rules: PolicyRules
38
46
  ): PolicyEvaluationResult {
47
+ // Block unknown action types (defense-in-depth)
48
+ if (!(action.action_type in RISK_MAP)) {
49
+ return { decision: 'BLOCK', risk_level: 'critical', reason: `Unknown action type: ${action.action_type}` };
50
+ }
51
+
52
+ // SECURITY: Normalize tool name to lowercase to prevent case-sensitivity
53
+ // bypasses (e.g., "Http.get" skipping HTTP domain allowlist checks).
54
+ const normalizedTool = action.tool.toLowerCase();
55
+
39
56
  const risk_level = RISK_MAP[action.action_type] ?? 'medium';
40
57
 
41
58
  // Browser tools: browser.open always requires approval
42
- if (action.tool.startsWith('browser.')) {
43
- if (action.tool === 'browser.open') {
59
+ if (normalizedTool.startsWith('browser.')) {
60
+ if (normalizedTool === 'browser.open') {
44
61
  return {
45
62
  decision: 'REQUIRE_APPROVAL',
46
63
  risk_level: 'medium',
@@ -57,28 +74,32 @@ export function evaluatePolicy(
57
74
  };
58
75
  }
59
76
 
60
- // MCP tools: list_tools is a read (low risk), call_tool defers to action_type rules
61
- if (action.tool === 'mcp.list_tools') {
62
- return {
63
- decision: 'ALLOW',
64
- risk_level: 'low',
65
- reason: 'MCP tool discovery is read-only',
66
- };
67
- }
77
+ // MCP tools: list_tools handled via default rules (can be overridden by custom policies)
68
78
 
69
- if (action.tool.split('.')[0] === 'http' && rules.http) {
79
+ // HTTP tool checks: always enforce domain/method restrictions
80
+ const isHttpTool = normalizedTool.split('.')[0] === 'http';
81
+ if (isHttpTool) {
82
+ const httpRules = rules.http ?? DEFAULT_HTTP_RULES;
70
83
  const url = action.payload.url as string | undefined;
84
+ if (!url) {
85
+ // SECURITY: HTTP actions without a URL must be blocked at the policy level.
86
+ // The connector would also reject it, but policy should be the first gate.
87
+ return { decision: 'BLOCK', risk_level: 'critical', reason: 'HTTP actions require a URL in payload' };
88
+ }
71
89
  if (url) {
72
90
  try {
73
- const domain = new URL(url).hostname;
91
+ const domain = new URL(url).hostname.replace(/\.$/, '').toLowerCase();
74
92
  // Use exact match or proper subdomain match (preceded by a dot)
75
93
  // to prevent "not-trusted.com" from matching allowlist entry "trusted.com"
76
- const matchesDomain = (d: string, pattern: string) =>
77
- d === pattern || d.endsWith('.' + pattern);
78
- if (rules.http.blockList.some((b) => matchesDomain(domain, b))) {
94
+ // Both sides are normalized: lowercase + trailing-dot stripped.
95
+ const matchesDomain = (d: string, pattern: string) => {
96
+ const normalizedPattern = pattern.replace(/\.$/, '').toLowerCase();
97
+ return d === normalizedPattern || d.endsWith('.' + normalizedPattern);
98
+ };
99
+ if (httpRules.blockList.some((b) => matchesDomain(domain, b))) {
79
100
  return { decision: 'BLOCK', risk_level: 'critical', reason: `Domain ${domain} is in block list` };
80
101
  }
81
- if (rules.http.allowedDomains.length === 0) {
102
+ if (httpRules.allowedDomains.length === 0) {
82
103
  // No allowlist configured: safe default is REQUIRE_APPROVAL, not ALLOW.
83
104
  // This prevents agents from exfiltrating data to arbitrary domains.
84
105
  return {
@@ -87,7 +108,7 @@ export function evaluatePolicy(
87
108
  reason: 'HTTP allowlist not configured — approval required for all HTTP calls',
88
109
  };
89
110
  }
90
- if (!rules.http.allowedDomains.some((d) => matchesDomain(domain, d))) {
111
+ if (!httpRules.allowedDomains.some((d) => matchesDomain(domain, d))) {
91
112
  return { decision: 'BLOCK', risk_level, reason: `Domain ${domain} not in allowed list` };
92
113
  }
93
114
  } catch {
@@ -95,7 +116,7 @@ export function evaluatePolicy(
95
116
  }
96
117
  }
97
118
  const method = (action.payload.method as string | undefined)?.toUpperCase();
98
- if (method && !rules.http.allowedMethods.includes(method)) {
119
+ if (method && !httpRules.allowedMethods.includes(method)) {
99
120
  return { decision: 'BLOCK', risk_level, reason: `HTTP method ${method} not allowed` };
100
121
  }
101
122
  }
@@ -112,14 +133,19 @@ export function evaluatePolicy(
112
133
  };
113
134
  }
114
135
 
115
- // Most specific: tool-specific rule
116
- let matched = rules.rules.find((r) => r.tool === action.tool);
136
+ // Most specific: tool-specific rule (case-insensitive to prevent bypasses)
137
+ let matched = rules.rules.find((r) => r.tool !== undefined && r.tool.toLowerCase() === normalizedTool);
117
138
  // Then action-type rule
118
139
  if (!matched) matched = rules.rules.find((r) => r.action_type === action.action_type);
119
140
 
120
141
  if (matched) {
142
+ let finalDecision = matched.decision;
143
+ // SECURITY: Never auto-allow admin/financial actions even via explicit rules
144
+ if (finalDecision === 'ALLOW' && ['admin', 'financial'].includes(action.action_type)) {
145
+ finalDecision = 'REQUIRE_APPROVAL';
146
+ }
121
147
  return {
122
- decision: matched.decision,
148
+ decision: finalDecision,
123
149
  risk_level,
124
150
  reason: `Matched rule: ${matched.action_type ?? matched.tool}`,
125
151
  matched_rule: matched,
@@ -129,6 +155,11 @@ export function evaluatePolicy(
129
155
  const defaultDecision: PolicyDecision =
130
156
  rules.defaultMode === 'allow' ? 'ALLOW' : rules.defaultMode === 'block' ? 'BLOCK' : 'REQUIRE_APPROVAL';
131
157
 
158
+ // SECURITY: Never auto-allow high-risk actions via permissive default mode
159
+ if (defaultDecision === 'ALLOW' && ['admin', 'financial'].includes(action.action_type)) {
160
+ return { decision: 'REQUIRE_APPROVAL', risk_level, reason: 'High-risk action types always require approval even with permissive default policy' };
161
+ }
162
+
132
163
  return { decision: defaultDecision, risk_level, reason: 'Default policy' };
133
164
  }
134
165
 
@@ -138,10 +169,11 @@ export function buildActionPreview(action: AgentActionRequest): {
138
169
  impact?: string;
139
170
  cost_estimate?: number;
140
171
  } {
172
+ const normalizedTool = action.tool.toLowerCase();
141
173
  let summary = `${action.action_type.toUpperCase()} via ${action.tool}`;
142
174
  let target: string | undefined;
143
175
 
144
- if (action.tool.split('.')[0] === 'http') {
176
+ if (normalizedTool.split('.')[0] === 'http') {
145
177
  const url = action.payload.url as string | undefined;
146
178
  const method = action.payload.method as string | undefined;
147
179
  if (url) {
@@ -152,7 +184,7 @@ export function buildActionPreview(action: AgentActionRequest): {
152
184
  }
153
185
  summary = `${method?.toUpperCase() ?? 'HTTP'} request to ${target}`;
154
186
  }
155
- } else if (action.tool === 'browser.open') {
187
+ } else if (normalizedTool === 'browser.open') {
156
188
  const url = action.payload.url as string | undefined;
157
189
  if (url) {
158
190
  try {
@@ -164,17 +196,15 @@ export function buildActionPreview(action: AgentActionRequest): {
164
196
  } else {
165
197
  summary = 'Open browser session';
166
198
  }
167
- } else if (action.tool === 'mcp.list_tools') {
199
+ } else if (normalizedTool === 'mcp.list_tools') {
168
200
  const server = action.payload.server as string | undefined;
169
201
  target = server;
170
202
  summary = `List available tools on MCP server "${server ?? 'unknown'}"`;
171
- } else if (action.tool === 'mcp.call_tool') {
203
+ } else if (normalizedTool === 'mcp.call_tool') {
172
204
  const server = action.payload.server as string | undefined;
173
205
  const method = action.payload.method as string | undefined;
174
206
  target = server;
175
207
  summary = `Call "${method ?? 'unknown'}" on MCP server "${server ?? 'unknown'}"`;
176
- } else if (action.tool === 'demo') {
177
- summary = `Write to demo table: ${JSON.stringify(redact(action.payload)).slice(0, 80)}`;
178
208
  }
179
209
 
180
210
  return {
package/src/redact.ts CHANGED
@@ -52,7 +52,15 @@ const SECRET_SUBSTRINGS = [
52
52
  'connection_string',
53
53
  'database_url',
54
54
  'access_key',
55
- 'session_id',
55
+ 'auth_token',
56
+ 'refresh_key',
57
+ 'session_secret',
58
+ 'webhook_secret',
59
+ 'jwt',
60
+ 'oauth',
61
+ 'ssn',
62
+ 'credit_card',
63
+ 'routing_number',
56
64
  ];
57
65
 
58
66
  const REDACTED = '[REDACTED]';
@@ -73,6 +81,13 @@ const SECRET_VALUE_PATTERNS = [
73
81
  /^sk-ant-[a-zA-Z0-9_-]{20,}$/, // Anthropic API keys
74
82
  /^SG\.[a-zA-Z0-9_-]{20,}$/, // SendGrid API keys
75
83
  /^SK[a-f0-9]{32}$/, // Twilio API keys
84
+ /-----BEGIN\s+(RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/, // PEM private keys
85
+ /^DefaultEndpointsProtocol=/, // Azure connection strings
86
+ /^[A-Za-z0-9+\/]{43,}={0,2}$/, // Base64-encoded symmetric keys (32+ bytes)
87
+ /^[A-Za-z0-9_-]{43,}={0,2}$/, // URL-safe base64 encoded keys
88
+ /^whsec_[a-zA-Z0-9]{20,}$/, // Stripe webhook secrets
89
+ /^npm_[a-zA-Z0-9]{20,}$/, // npm tokens
90
+ /^vercel_[a-zA-Z0-9]{20,}$/, // Vercel tokens
76
91
  ];
77
92
 
78
93
  function isSecretField(key: string): boolean {
@@ -86,7 +101,9 @@ function isSecretValue(value: string): boolean {
86
101
  }
87
102
 
88
103
  export function redact(obj: unknown, depth = 0): unknown {
89
- if (depth > 10) return obj;
104
+ // SECURITY: At max depth, redact entirely rather than passing data through.
105
+ // Prevents secrets in deeply nested objects from bypassing redaction.
106
+ if (depth > 20) return REDACTED;
90
107
  if (obj === null || obj === undefined) return obj;
91
108
  if (typeof obj === 'string') return isSecretValue(obj) ? REDACTED : obj;
92
109
  if (typeof obj !== 'object') return obj;
@@ -102,7 +119,7 @@ export function redact(obj: unknown, depth = 0): unknown {
102
119
  export function redactHeaders(headers: Record<string, string>): Record<string, string> {
103
120
  const result: Record<string, string> = {};
104
121
  for (const [key, value] of Object.entries(headers)) {
105
- result[key] = isSecretField(key) ? REDACTED : value;
122
+ result[key] = (isSecretField(key) || isSecretValue(value)) ? REDACTED : value;
106
123
  }
107
124
  return result;
108
125
  }
package/src/schemas.ts CHANGED
@@ -1,53 +1,121 @@
1
- import { z } from 'zod';
2
-
3
- /** Max payload size: 64KB when serialized */
4
- const MAX_PAYLOAD_SIZE = 65_536;
5
-
6
- export const AgentActionRequestSchema = z.object({
7
- action_type: z.enum(['read', 'write', 'financial', 'admin']),
8
- tool: z.string().min(1).max(100).regex(/^[a-zA-Z0-9._\-:]+$/, 'Tool name must be alphanumeric with dots, dashes, underscores, or colons'),
9
- payload: z.record(z.unknown()).refine(
10
- (val) => JSON.stringify(val).length <= MAX_PAYLOAD_SIZE,
11
- { message: `Payload exceeds maximum size of ${MAX_PAYLOAD_SIZE} bytes` }
12
- ),
13
- idempotency_key: z.string().max(128).optional(),
14
- cost_estimate: z.number().optional(),
15
- });
16
-
17
- export const RegisterAgentSchema = z.object({
18
- name: z.string().min(1).max(100),
19
- environment: z.enum(['development', 'staging', 'production']).default('production'),
20
- public_key: z.string().min(40),
21
- allowed_tools: z.array(z.string()).default([]),
22
- });
23
-
24
- export const PolicyRulesSchema = z.object({
25
- defaultMode: z.enum(['allow', 'require_approval', 'block']),
26
- rules: z.array(
27
- z.object({
28
- action_type: z.enum(['read', 'write', 'financial', 'admin']).optional(),
29
- tool: z.string().optional(),
30
- domain: z.string().optional(),
31
- decision: z.enum(['ALLOW', 'REQUIRE_APPROVAL', 'BLOCK']),
32
- require_two_approvals: z.boolean().optional(),
33
- })
34
- ),
35
- http: z
36
- .object({
37
- allowedDomains: z.array(z.string()),
38
- allowedMethods: z.array(z.string()),
39
- blockList: z.array(z.string()),
40
- })
41
- .optional(),
42
- limits: z
43
- .object({
44
- maxCostPerAction: z.number().optional(),
45
- maxActionsPerHour: z.number().optional(),
46
- })
47
- .optional(),
48
- });
49
-
50
- export const ApproveRequestSchema = z.object({
51
- action: z.enum(['approve', 'deny']),
52
- reason: z.string().max(1000).optional(),
53
- });
1
+ import { z } from 'zod';
2
+
3
+ /** Max payload size: 64KB when serialized */
4
+ const MAX_PAYLOAD_SIZE = 65_536;
5
+
6
+ /** Maximum length for webhook URLs (standard URL length limit) */
7
+ const MAX_WEBHOOK_URL_LENGTH = 2048;
8
+
9
+ /**
10
+ * Reusable Zod schema for webhook URLs.
11
+ * Enforces: max length 2048, valid URL syntax, HTTPS-only,
12
+ * and rejects private/internal hostnames at parse time.
13
+ */
14
+ export const WebhookUrlSchema = z
15
+ .string()
16
+ .max(MAX_WEBHOOK_URL_LENGTH, `Webhook URL exceeds maximum length (${MAX_WEBHOOK_URL_LENGTH} characters)`)
17
+ .refine(
18
+ (val) => {
19
+ try {
20
+ const parsed = new URL(val);
21
+ return parsed.protocol === 'https:';
22
+ } catch {
23
+ return false;
24
+ }
25
+ },
26
+ { message: 'Webhook URL must be a valid HTTPS URL' }
27
+ )
28
+ .refine(
29
+ (val) => {
30
+ try {
31
+ const parsed = new URL(val);
32
+ const hostname = parsed.hostname;
33
+ const privatePatterns = [
34
+ /^127\./, /^10\./, /^172\.(1[6-9]|2\d|3[01])\./,
35
+ /^192\.168\./, /^169\.254\./, /^0\./,
36
+ /^localhost$/i, /\.local$/i, /\.internal$/i,
37
+ ];
38
+ return !privatePatterns.some((p) => p.test(hostname));
39
+ } catch {
40
+ return false;
41
+ }
42
+ },
43
+ { message: 'Webhook URL cannot target private or internal addresses' }
44
+ );
45
+
46
+ export const AgentActionRequestSchema = z.object({
47
+ action_type: z.enum(['read', 'write', 'financial', 'admin']),
48
+ tool: z.string().min(1).max(100).regex(/^[a-zA-Z0-9._\-:]+$/, 'Tool name must be alphanumeric with dots, dashes, underscores, or colons'),
49
+ payload: z.record(z.unknown()).refine(
50
+ (val) => JSON.stringify(val).length <= MAX_PAYLOAD_SIZE,
51
+ { message: `Payload exceeds maximum size of ${MAX_PAYLOAD_SIZE} bytes` }
52
+ ),
53
+ idempotency_key: z.string().max(128).optional(),
54
+ cost_estimate: z.number().nonnegative().optional(),
55
+ });
56
+
57
+ export const RegisterAgentSchema = z.object({
58
+ name: z.string().min(1).max(100),
59
+ environment: z.enum(['development', 'staging', 'production']).default('production'),
60
+ public_key: z.string().min(40),
61
+ allowed_tools: z.array(z.string()).default([]),
62
+ });
63
+
64
+ const DOMAIN_RE = /^(\*\.)?([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)*[a-zA-Z]{2,}$/;
65
+
66
+ export const PolicyRulesSchema = z.object({
67
+ defaultMode: z.enum(['allow', 'require_approval', 'block']),
68
+ rules: z.array(
69
+ z.object({
70
+ action_type: z.enum(['read', 'write', 'financial', 'admin']).optional(),
71
+ tool: z.string().max(100).regex(/^[a-zA-Z0-9._\-:]+$/, 'Tool name must be alphanumeric with dots, dashes, underscores, or colons').optional(),
72
+ domain: z.string().regex(DOMAIN_RE, 'Invalid domain format').optional(),
73
+ decision: z.enum(['ALLOW', 'REQUIRE_APPROVAL', 'BLOCK']),
74
+ require_two_approvals: z.boolean().optional(),
75
+ allowed_approvers: z.array(z.string().uuid()).optional(),
76
+ }).refine(r => r.action_type || r.tool, { message: 'Rule must specify action_type or tool' })
77
+ ).max(100),
78
+ http: z
79
+ .object({
80
+ allowedDomains: z.array(z.string().min(1).max(253).regex(DOMAIN_RE, 'Invalid domain format')),
81
+ allowedMethods: z.array(z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'])),
82
+ blockList: z.array(z.string().min(1).max(253).regex(DOMAIN_RE, 'Invalid domain format')),
83
+ })
84
+ .optional(),
85
+ limits: z
86
+ .object({
87
+ maxCostPerAction: z.number().nonnegative().optional(),
88
+ maxActionsPerHour: z.number().nonnegative().optional(),
89
+ })
90
+ .optional(),
91
+ });
92
+
93
+ export const ApproveRequestSchema = z.object({
94
+ action: z.enum(['approve', 'deny']),
95
+ reason: z.string().max(1000).optional(),
96
+ reply_message: z.string().max(2000).optional(),
97
+ /** Server-side biometric challenge token (mobile clients only) */
98
+ biometric_challenge: z.string().uuid().optional(),
99
+ });
100
+
101
+ /** Max metadata size: 8KB when serialized (prevents storage exhaustion) */
102
+ const MAX_METADATA_SIZE = 8_192;
103
+
104
+ export const SendMessageSchema = z.object({
105
+ content: z.string().min(1).max(4096),
106
+ thread_id: z.string().uuid().optional(),
107
+ expires_at: z.string().datetime().optional(),
108
+ metadata: z.record(z.unknown()).refine(
109
+ (val) => JSON.stringify(val).length <= MAX_METADATA_SIZE,
110
+ { message: `Metadata exceeds maximum size of ${MAX_METADATA_SIZE} bytes` }
111
+ ).optional(),
112
+ });
113
+
114
+ export const AgentSendMessageSchema = z.object({
115
+ content: z.string().min(1).max(4096),
116
+ thread_id: z.string().uuid(),
117
+ metadata: z.record(z.unknown()).refine(
118
+ (val) => JSON.stringify(val).length <= MAX_METADATA_SIZE,
119
+ { message: `Metadata exceeds maximum size of ${MAX_METADATA_SIZE} bytes` }
120
+ ).optional(),
121
+ });
package/src/types.ts CHANGED
@@ -84,6 +84,7 @@ export interface PolicyRule {
84
84
  domain?: string;
85
85
  decision: PolicyDecision;
86
86
  require_two_approvals?: boolean;
87
+ allowed_approvers?: string[];
87
88
  }
88
89
 
89
90
  export interface PolicyEvaluationResult {