agentlock-shared 0.1.0 → 0.3.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 (171) hide show
  1. package/dist/__tests__/billing.test.d.ts +2 -0
  2. package/dist/__tests__/billing.test.d.ts.map +1 -0
  3. package/dist/__tests__/billing.test.js +31 -0
  4. package/dist/__tests__/billing.test.js.map +1 -0
  5. package/dist/__tests__/crypto.test.js +137 -47
  6. package/dist/__tests__/crypto.test.js.map +1 -1
  7. package/dist/__tests__/dns-pinning.test.d.ts +2 -0
  8. package/dist/__tests__/dns-pinning.test.d.ts.map +1 -0
  9. package/dist/__tests__/dns-pinning.test.js +33 -0
  10. package/dist/__tests__/dns-pinning.test.js.map +1 -0
  11. package/dist/__tests__/llm-classifier-cache-store.test.d.ts +2 -0
  12. package/dist/__tests__/llm-classifier-cache-store.test.d.ts.map +1 -0
  13. package/dist/__tests__/llm-classifier-cache-store.test.js +65 -0
  14. package/dist/__tests__/llm-classifier-cache-store.test.js.map +1 -0
  15. package/dist/__tests__/llm-classifier-cache.test.d.ts +2 -0
  16. package/dist/__tests__/llm-classifier-cache.test.d.ts.map +1 -0
  17. package/dist/__tests__/llm-classifier-cache.test.js +44 -0
  18. package/dist/__tests__/llm-classifier-cache.test.js.map +1 -0
  19. package/dist/__tests__/llm-classifier.test.d.ts +2 -0
  20. package/dist/__tests__/llm-classifier.test.d.ts.map +1 -0
  21. package/dist/__tests__/llm-classifier.test.js +167 -0
  22. package/dist/__tests__/llm-classifier.test.js.map +1 -0
  23. package/dist/__tests__/messaging.test.d.ts +2 -0
  24. package/dist/__tests__/messaging.test.d.ts.map +1 -0
  25. package/dist/__tests__/messaging.test.js +75 -0
  26. package/dist/__tests__/messaging.test.js.map +1 -0
  27. package/dist/__tests__/plans-classifier-limits.test.d.ts +2 -0
  28. package/dist/__tests__/plans-classifier-limits.test.d.ts.map +1 -0
  29. package/dist/__tests__/plans-classifier-limits.test.js +22 -0
  30. package/dist/__tests__/plans-classifier-limits.test.js.map +1 -0
  31. package/dist/__tests__/policy-category-floor.test.d.ts +2 -0
  32. package/dist/__tests__/policy-category-floor.test.d.ts.map +1 -0
  33. package/dist/__tests__/policy-category-floor.test.js +46 -0
  34. package/dist/__tests__/policy-category-floor.test.js.map +1 -0
  35. package/dist/__tests__/policy-claude-bash.test.d.ts +2 -0
  36. package/dist/__tests__/policy-claude-bash.test.d.ts.map +1 -0
  37. package/dist/__tests__/policy-claude-bash.test.js +401 -0
  38. package/dist/__tests__/policy-claude-bash.test.js.map +1 -0
  39. package/dist/__tests__/policy-llm-floor.test.d.ts +2 -0
  40. package/dist/__tests__/policy-llm-floor.test.d.ts.map +1 -0
  41. package/dist/__tests__/policy-llm-floor.test.js +107 -0
  42. package/dist/__tests__/policy-llm-floor.test.js.map +1 -0
  43. package/dist/__tests__/policy-ssh-e2e.test.d.ts +2 -0
  44. package/dist/__tests__/policy-ssh-e2e.test.d.ts.map +1 -0
  45. package/dist/__tests__/policy-ssh-e2e.test.js +89 -0
  46. package/dist/__tests__/policy-ssh-e2e.test.js.map +1 -0
  47. package/dist/__tests__/policy-ssh-sessions.test.d.ts +2 -0
  48. package/dist/__tests__/policy-ssh-sessions.test.d.ts.map +1 -0
  49. package/dist/__tests__/policy-ssh-sessions.test.js +139 -0
  50. package/dist/__tests__/policy-ssh-sessions.test.js.map +1 -0
  51. package/dist/__tests__/policy-ssh.test.d.ts +2 -0
  52. package/dist/__tests__/policy-ssh.test.d.ts.map +1 -0
  53. package/dist/__tests__/policy-ssh.test.js +180 -0
  54. package/dist/__tests__/policy-ssh.test.js.map +1 -0
  55. package/dist/__tests__/policy.test.js +522 -7
  56. package/dist/__tests__/policy.test.js.map +1 -1
  57. package/dist/__tests__/redact.test.js +76 -0
  58. package/dist/__tests__/redact.test.js.map +1 -1
  59. package/dist/__tests__/signing.test.js +89 -0
  60. package/dist/__tests__/signing.test.js.map +1 -1
  61. package/dist/__tests__/ssh-fingerprint.test.d.ts +2 -0
  62. package/dist/__tests__/ssh-fingerprint.test.d.ts.map +1 -0
  63. package/dist/__tests__/ssh-fingerprint.test.js +19 -0
  64. package/dist/__tests__/ssh-fingerprint.test.js.map +1 -0
  65. package/dist/__tests__/vpn-route.test.d.ts +2 -0
  66. package/dist/__tests__/vpn-route.test.d.ts.map +1 -0
  67. package/dist/__tests__/vpn-route.test.js +72 -0
  68. package/dist/__tests__/vpn-route.test.js.map +1 -0
  69. package/dist/__tests__/wireguard.test.d.ts +2 -0
  70. package/dist/__tests__/wireguard.test.d.ts.map +1 -0
  71. package/dist/__tests__/wireguard.test.js +114 -0
  72. package/dist/__tests__/wireguard.test.js.map +1 -0
  73. package/dist/billing.d.ts +12 -0
  74. package/dist/billing.d.ts.map +1 -0
  75. package/dist/billing.js +41 -0
  76. package/dist/billing.js.map +1 -0
  77. package/dist/crypto.d.ts +41 -0
  78. package/dist/crypto.d.ts.map +1 -1
  79. package/dist/crypto.js +208 -6
  80. package/dist/crypto.js.map +1 -1
  81. package/dist/dns-pinning.d.ts +28 -0
  82. package/dist/dns-pinning.d.ts.map +1 -0
  83. package/dist/dns-pinning.js +113 -0
  84. package/dist/dns-pinning.js.map +1 -0
  85. package/dist/index.d.ts +6 -0
  86. package/dist/index.d.ts.map +1 -1
  87. package/dist/index.js +9 -0
  88. package/dist/index.js.map +1 -1
  89. package/dist/llm-classifier-cache-store.d.ts +49 -0
  90. package/dist/llm-classifier-cache-store.d.ts.map +1 -0
  91. package/dist/llm-classifier-cache-store.js +63 -0
  92. package/dist/llm-classifier-cache-store.js.map +1 -0
  93. package/dist/llm-classifier-cache.d.ts +6 -0
  94. package/dist/llm-classifier-cache.d.ts.map +1 -0
  95. package/dist/llm-classifier-cache.js +52 -0
  96. package/dist/llm-classifier-cache.js.map +1 -0
  97. package/dist/llm-classifier.d.ts +29 -0
  98. package/dist/llm-classifier.d.ts.map +1 -0
  99. package/dist/llm-classifier.js +191 -0
  100. package/dist/llm-classifier.js.map +1 -0
  101. package/dist/observability.d.ts +36 -0
  102. package/dist/observability.d.ts.map +1 -0
  103. package/dist/observability.js +75 -0
  104. package/dist/observability.js.map +1 -0
  105. package/dist/plans.d.ts +21 -0
  106. package/dist/plans.d.ts.map +1 -1
  107. package/dist/plans.js +52 -14
  108. package/dist/plans.js.map +1 -1
  109. package/dist/policy.d.ts +173 -3
  110. package/dist/policy.d.ts.map +1 -1
  111. package/dist/policy.js +951 -58
  112. package/dist/policy.js.map +1 -1
  113. package/dist/redact.d.ts.map +1 -1
  114. package/dist/redact.js +104 -7
  115. package/dist/redact.js.map +1 -1
  116. package/dist/regex-safety.d.ts +21 -0
  117. package/dist/regex-safety.d.ts.map +1 -0
  118. package/dist/regex-safety.js +49 -0
  119. package/dist/regex-safety.js.map +1 -0
  120. package/dist/sanitize.d.ts +31 -0
  121. package/dist/sanitize.d.ts.map +1 -0
  122. package/dist/sanitize.js +54 -0
  123. package/dist/sanitize.js.map +1 -0
  124. package/dist/schemas.d.ts +267 -14
  125. package/dist/schemas.d.ts.map +1 -1
  126. package/dist/schemas.js +152 -10
  127. package/dist/schemas.js.map +1 -1
  128. package/dist/signing.d.ts +15 -0
  129. package/dist/signing.d.ts.map +1 -1
  130. package/dist/signing.js +53 -4
  131. package/dist/signing.js.map +1 -1
  132. package/dist/ssh-fingerprint.d.ts +10 -0
  133. package/dist/ssh-fingerprint.d.ts.map +1 -0
  134. package/dist/ssh-fingerprint.js +52 -0
  135. package/dist/ssh-fingerprint.js.map +1 -0
  136. package/dist/ssrf.d.ts +36 -0
  137. package/dist/ssrf.d.ts.map +1 -0
  138. package/dist/ssrf.js +140 -0
  139. package/dist/ssrf.js.map +1 -0
  140. package/dist/types.d.ts +131 -0
  141. package/dist/types.d.ts.map +1 -1
  142. package/dist/wireguard.d.ts +63 -0
  143. package/dist/wireguard.d.ts.map +1 -0
  144. package/dist/wireguard.js +226 -0
  145. package/dist/wireguard.js.map +1 -0
  146. package/package.json +42 -29
  147. package/.turbo/turbo-build.log +0 -4
  148. package/.turbo/turbo-test.log +0 -34
  149. package/dist/__tests__/content-crypto.test.d.ts +0 -2
  150. package/dist/__tests__/content-crypto.test.d.ts.map +0 -1
  151. package/dist/__tests__/content-crypto.test.js +0 -117
  152. package/dist/__tests__/content-crypto.test.js.map +0 -1
  153. package/dist/content-crypto.d.ts +0 -24
  154. package/dist/content-crypto.d.ts.map +0 -1
  155. package/dist/content-crypto.js +0 -58
  156. package/dist/content-crypto.js.map +0 -1
  157. package/src/__tests__/policy.test.ts +0 -88
  158. package/src/__tests__/redact.test.ts +0 -41
  159. package/src/__tests__/signing.test.ts +0 -55
  160. package/src/crypto.ts +0 -87
  161. package/src/index.ts +0 -8
  162. package/src/mcp-catalog.ts +0 -181
  163. package/src/plans.ts +0 -96
  164. package/src/policy.ts +0 -186
  165. package/src/redact.ts +0 -114
  166. package/src/schemas.ts +0 -53
  167. package/src/signing.ts +0 -120
  168. package/src/types.ts +0 -212
  169. package/test-gateway.mjs +0 -47
  170. package/tsconfig.json +0 -10
  171. package/vitest.config.ts +0 -8
package/dist/policy.js CHANGED
@@ -1,22 +1,203 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DEFAULT_POLICY_RULES = void 0;
3
+ exports.compileClaudeBashPattern = exports.DEFAULT_POLICY_RULES = void 0;
4
+ exports.maxCategory = maxCategory;
5
+ exports.getCategoryFloor = getCategoryFloor;
6
+ exports.compileSshPattern = compileSshPattern;
7
+ exports.evaluateClaudeBashRules = evaluateClaudeBashRules;
4
8
  exports.evaluatePolicy = evaluatePolicy;
5
9
  exports.buildActionPreview = buildActionPreview;
6
- const redact_js_1 = require("./redact.js");
10
+ exports.suggestClaudeBashPattern = suggestClaudeBashPattern;
11
+ exports.resolveVpnRoute = resolveVpnRoute;
12
+ exports.hostnameFromUrl = hostnameFromUrl;
13
+ const regex_safety_js_1 = require("./regex-safety.js");
7
14
  const RISK_MAP = {
8
15
  read: 'low',
9
16
  write: 'medium',
10
17
  financial: 'high',
11
18
  admin: 'critical',
12
19
  };
20
+ // ---------------------------------------------------------------------------
21
+ // Category floor — anti-downgrade defense
22
+ // ---------------------------------------------------------------------------
23
+ // The agent self-declares `action_type` in every request. A malicious or
24
+ // prompt-injected agent could simply label every action as `read` to bypass
25
+ // policy restrictions. To prevent this, the server computes an independent
26
+ // "category floor" from the tool name and payload, and uses the higher of the
27
+ // two: effective = max(declared, floor).
28
+ //
29
+ // The floor is deliberately conservative — unknown tools fall back to `read`
30
+ // (trust declared). The goal is not to classify every tool perfectly, but to
31
+ // catch obvious downgrades like `stripe.charge` labeled as read.
32
+ const CATEGORY_ORDER = {
33
+ read: 0,
34
+ write: 1,
35
+ financial: 2,
36
+ admin: 3,
37
+ };
38
+ /** Returns the more restrictive of two categories. */
39
+ function maxCategory(a, b) {
40
+ return CATEGORY_ORDER[a] >= CATEGORY_ORDER[b] ? a : b;
41
+ }
42
+ // HTTP methods that only read data — everything else is considered a write.
43
+ const READ_ONLY_HTTP_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
44
+ // Browser tools that only observe page state.
45
+ const READ_ONLY_BROWSER_TOOLS = new Set([
46
+ 'browser.snapshot',
47
+ 'browser.screenshot',
48
+ 'browser.scroll',
49
+ 'browser.close',
50
+ ]);
51
+ // Substring patterns that promote a tool to `financial`. Matched against the
52
+ // lowercased tool name and — for MCP `call_tool` — the downstream method name.
53
+ const FINANCIAL_PATTERNS = [
54
+ 'stripe.',
55
+ 'paypal.',
56
+ 'payment',
57
+ 'billing',
58
+ 'invoice',
59
+ 'checkout',
60
+ 'subscription',
61
+ 'charge',
62
+ 'refund',
63
+ 'transfer',
64
+ 'payout',
65
+ 'wire',
66
+ 'ach',
67
+ 'sepa',
68
+ ];
69
+ // Substring patterns that promote a tool to `admin`. Matched against the
70
+ // lowercased tool name, lowercased URL, and — for MCP `call_tool` — the
71
+ // downstream method name. Both tool-name forms (`admin.`) and URL-path forms
72
+ // (`/admin/`, `/admin?`) are included so the same list works for all sources.
73
+ const ADMIN_PATTERNS = [
74
+ 'admin.',
75
+ '/admin/',
76
+ '/admin?',
77
+ 'delete_user',
78
+ 'remove_user',
79
+ 'rotate_key',
80
+ 'revoke_key',
81
+ 'grant_role',
82
+ 'revoke_role',
83
+ 'change_permission',
84
+ 'drop_table',
85
+ 'drop_database',
86
+ 'truncate',
87
+ 'destroy',
88
+ 'disable_mfa',
89
+ 'reset_password',
90
+ ];
91
+ function matchesAny(haystack, needles) {
92
+ return needles.some((n) => haystack.includes(n));
93
+ }
94
+ /**
95
+ * Normalise an agent-supplied tool identifier. Lowercases (case-insensitive
96
+ * match) and maps the legacy / SDK short form `http` to the canonical
97
+ * `http.request` so per-tool policy rules authored via the Policies UI
98
+ * (which use `http.request`) match real-world SDK traffic.
99
+ */
100
+ function normalizeToolName(tool) {
101
+ const lower = tool.toLowerCase();
102
+ if (lower === 'http')
103
+ return 'http.request';
104
+ return lower;
105
+ }
106
+ /**
107
+ * Derive the minimum (floor) category for a given tool + payload, independent
108
+ * of what the agent declared. See comment above for rationale.
109
+ *
110
+ * Returns `undefined` when no opinion — caller treats that as "trust declared".
111
+ */
112
+ function getCategoryFloor(tool, payload) {
113
+ const normalizedTool = normalizeToolName(tool);
114
+ // --- SSH: remote command execution is always admin risk ---------------
115
+ // ssh.open establishes a long-lived authenticated session — same risk
116
+ // tier as ssh.run (both grant remote-shell access). ssh.close releases
117
+ // resources the agent already owns; treat it as `write` so the policy
118
+ // engine doesn't BLOCK it under the default `admin → BLOCK` rule —
119
+ // session-ownership is enforced separately in the gateway.
120
+ if (normalizedTool === 'ssh.run' || normalizedTool === 'ssh.open')
121
+ return 'admin';
122
+ if (normalizedTool === 'ssh.close')
123
+ return 'write';
124
+ // --- HTTP: method drives read vs write ---------------------------------
125
+ if (normalizedTool.split('.')[0] === 'http') {
126
+ const method = String(payload.method ?? 'GET').toUpperCase();
127
+ // Still check URL for financial/admin keywords (e.g. .../payments/charge)
128
+ const url = typeof payload.url === 'string' ? payload.url.toLowerCase() : '';
129
+ if (url && matchesAny(url, ADMIN_PATTERNS))
130
+ return 'admin';
131
+ if (url && matchesAny(url, FINANCIAL_PATTERNS))
132
+ return 'financial';
133
+ return READ_ONLY_HTTP_METHODS.has(method) ? 'read' : 'write';
134
+ }
135
+ // --- Browser: most interactions mutate page state ----------------------
136
+ if (normalizedTool.startsWith('browser.')) {
137
+ return READ_ONLY_BROWSER_TOOLS.has(normalizedTool) ? 'read' : 'write';
138
+ }
139
+ // --- MCP: list_tools is read; call_tool inspects the downstream method -
140
+ if (normalizedTool === 'mcp.list_tools')
141
+ return 'read';
142
+ if (normalizedTool === 'mcp.call_tool') {
143
+ const method = typeof payload.method === 'string' ? payload.method.toLowerCase() : '';
144
+ const server = typeof payload.server === 'string' ? payload.server.toLowerCase() : '';
145
+ const combined = `${server}.${method}`;
146
+ if (matchesAny(combined, ADMIN_PATTERNS))
147
+ return 'admin';
148
+ if (matchesAny(combined, FINANCIAL_PATTERNS))
149
+ return 'financial';
150
+ // Unknown MCP method — floor is write (MCP calls almost always mutate
151
+ // something; if they only read, a dedicated tool would be clearer).
152
+ return 'write';
153
+ }
154
+ // --- Direct tool-name pattern matching ---------------------------------
155
+ if (matchesAny(normalizedTool, ADMIN_PATTERNS))
156
+ return 'admin';
157
+ if (matchesAny(normalizedTool, FINANCIAL_PATTERNS))
158
+ return 'financial';
159
+ // Unknown tool — fail closed. We cannot vouch that an unrecognised tool is a
160
+ // safe read, so floor it to `write`: with `max(declared, floor)` a tool we
161
+ // don't recognise (even one the agent declared `read`) no longer rides the
162
+ // `read → ALLOW` default but is sent for approval. Same posture as the
163
+ // `mcp.call_tool` fallback above — a fall-through requires a human, not an
164
+ // auto-allow.
165
+ return 'write';
166
+ }
13
167
  exports.DEFAULT_POLICY_RULES = {
14
168
  defaultMode: 'require_approval',
15
169
  rules: [
170
+ // Action-type defaults
16
171
  { action_type: 'read', decision: 'ALLOW' },
17
172
  { action_type: 'write', decision: 'REQUIRE_APPROVAL' },
18
173
  { action_type: 'financial', decision: 'REQUIRE_APPROVAL' },
19
174
  { action_type: 'admin', decision: 'BLOCK' },
175
+ // Browser tools (browser.open always requires approval via policy engine hardcode)
176
+ { tool: 'browser.snapshot', decision: 'ALLOW' },
177
+ { tool: 'browser.screenshot', decision: 'ALLOW' },
178
+ { tool: 'browser.close', decision: 'ALLOW' },
179
+ { tool: 'browser.click', decision: 'REQUIRE_APPROVAL' },
180
+ { tool: 'browser.type', decision: 'REQUIRE_APPROVAL' },
181
+ { tool: 'browser.fill_credentials', decision: 'REQUIRE_APPROVAL' },
182
+ { tool: 'browser.navigate', decision: 'REQUIRE_APPROVAL' },
183
+ { tool: 'browser.press_key', decision: 'REQUIRE_APPROVAL' },
184
+ { tool: 'browser.select', decision: 'REQUIRE_APPROVAL' },
185
+ { tool: 'browser.scroll', decision: 'ALLOW' },
186
+ // HTTP tool (domain/method restrictions enforced separately)
187
+ { tool: 'http.request', decision: 'REQUIRE_APPROVAL' },
188
+ // MCP tools
189
+ { tool: 'mcp.list_tools', decision: 'ALLOW' },
190
+ { tool: 'mcp.call_tool', decision: 'REQUIRE_APPROVAL' },
191
+ // Approval-only permission requests from local coding agents. The
192
+ // connector is a no-op after approval; Claude Code executes locally.
193
+ { tool: 'permission.claude_code', decision: 'REQUIRE_APPROVAL' },
194
+ // SSH tools — one-shot run + persistent session (open/close)
195
+ { tool: 'ssh.run', decision: 'REQUIRE_APPROVAL' },
196
+ { tool: 'ssh.open', decision: 'REQUIRE_APPROVAL' },
197
+ // ssh.close is owner-only (gateway validates session belongs to caller)
198
+ // so it's safe to allow without per-call approval. Rejecting close would
199
+ // strand sessions until TTL expiry, blowing up the per-plan limit.
200
+ { tool: 'ssh.close', decision: 'ALLOW' },
20
201
  ],
21
202
  http: {
22
203
  allowedDomains: [],
@@ -24,68 +205,520 @@ exports.DEFAULT_POLICY_RULES = {
24
205
  blockList: [],
25
206
  },
26
207
  limits: {
27
- maxActionsPerHour: 100,
208
+ maxActionsPerHour: 100, // Enforced in gateway via check_rate_limit RPC
28
209
  },
29
210
  };
30
- function evaluatePolicy(action, rules) {
31
- const risk_level = RISK_MAP[action.action_type] ?? 'medium';
32
- // Browser tools: browser.open always requires approval
33
- if (action.tool.startsWith('browser.')) {
34
- if (action.tool === 'browser.open') {
211
+ // Default HTTP rules when rules.http is not configured
212
+ const DEFAULT_HTTP_RULES = {
213
+ allowedDomains: [],
214
+ allowedMethods: ['GET', 'POST', 'PUT', 'DELETE'],
215
+ blockList: [],
216
+ };
217
+ function canonicalizeCommand(cmd) {
218
+ return cmd.trim().replace(/\s+/g, ' ');
219
+ }
220
+ // A pattern is treated as a raw regex if it looks like one: explicit
221
+ // /…/ wrapper, a leading `^`/trailing `$`, or it contains characters that
222
+ // would otherwise be literal in shell commands but meaningful in regex.
223
+ // Anything else is interpreted as a glob with `*` (any run) and `?` (any
224
+ // single char) — escape every other regex metachar and anchor it. This
225
+ // keeps older, regex-style rules working without migration.
226
+ const REGEX_HINT_RE = /[\^\$\\\[\]{}()|+]/;
227
+ function compileSshPattern(raw) {
228
+ const trimmed = raw.trim();
229
+ if (trimmed.length >= 2 && trimmed.startsWith('/') && trimmed.endsWith('/')) {
230
+ return new RegExp(trimmed.slice(1, -1));
231
+ }
232
+ if (REGEX_HINT_RE.test(trimmed)) {
233
+ return new RegExp(trimmed);
234
+ }
235
+ const escaped = trimmed
236
+ .replace(/[.+\-]/g, '\\$&')
237
+ .replace(/\*/g, '.*')
238
+ .replace(/\?/g, '.');
239
+ return new RegExp(`^${escaped}$`);
240
+ }
241
+ /**
242
+ * Compile a user-supplied Claude Code Bash pattern into a RegExp.
243
+ *
244
+ * Same semantics as the SSH `commandRules` evaluator (single source of
245
+ * truth — `compileSshPattern`), so admins only learn one pattern dialect.
246
+ * Default: full-string match with `*` and `?` as glob wildcards.
247
+ *
248
+ * - `grep` → exact match only ("grep" with no args)
249
+ * - `grep *` → grep followed by a space and any args
250
+ * - `grep /home/*`→ grep with args starting with `/home/`
251
+ * - `^git push` → starts-with-regex (regex hint chars trigger raw mode)
252
+ * - `/.../` → fully raw regex (escape hatch for advanced cases)
253
+ *
254
+ * The exact-by-default behaviour is the safer choice: forgetting the `*`
255
+ * means your rule is narrower than expected, not wider.
256
+ */
257
+ exports.compileClaudeBashPattern = compileSshPattern;
258
+ // Shell metacharacters whose presence in a command (but not the pattern)
259
+ // turns a glob ALLOW into a potential bypass — `ALLOW: git status *`
260
+ // matched against `git status ; rm -rf /` is the textbook case. Escalating
261
+ // in that case keeps the user's intent ("trust this prefix with normal
262
+ // args") without granting the implicit "and any compound suffix" they
263
+ // probably didn't want. Regex-style patterns (`/.../` or anything with
264
+ // regex hint chars like `^`, `$`, `(`, `|`) opt out: an admin who wrote
265
+ // a regex understands what they're matching.
266
+ //
267
+ // Includes `\n` and `\r`: most shells treat a literal newline as a command
268
+ // terminator, so `git status\ncurl evil.com` is two commands — the second
269
+ // would otherwise sneak past an `ALLOW: git status *` rule because the
270
+ // canonical-form regex test ignores what's after the matched prefix.
271
+ //
272
+ // Includes single `&`: in POSIX shells `cmd1 & cmd2` runs cmd1 in the
273
+ // background AND immediately runs cmd2, so it's a separator, not just an
274
+ // async marker. The two-char `&&` is matched by this same alternation
275
+ // (the boolean test only cares whether ANY meta is present, not which).
276
+ // `\$` (any dollar) covers command substitution `$(...)`, parameter/brace
277
+ // expansion `${IFS}`, and bare `$VAR` — all of which inject or separate
278
+ // commands at runtime (notably `${IFS}` expands to whitespace incl. a newline,
279
+ // which acts as a command separator). Matching only `$(` left `${IFS}`/`$VAR`
280
+ // able to ride an ALLOW glob.
281
+ const SHELL_CONTROL_RE = /(;|&&|\|\||\||&|`|\$|>|<|[\n\r])/;
282
+ function isAdvancedPattern(pattern) {
283
+ const t = pattern.trim();
284
+ if (t.length >= 2 && t.startsWith('/') && t.endsWith('/'))
285
+ return true;
286
+ return REGEX_HINT_RE.test(t);
287
+ }
288
+ /**
289
+ * Evaluate a Bash command against the workspace's user-defined Claude Code
290
+ * rules. Returns the first matching rule's outcome, or — when no rule
291
+ * matches — the policy's `defaultDecision`. Returns `null` to signal "no
292
+ * opinion" (caller should fall back to the hardcoded classifier) when the
293
+ * rules block is undefined or the rules array is empty AND no
294
+ * `defaultDecision` is set.
295
+ *
296
+ * Rules are evaluated in order; the FIRST match wins. ReDoS-suspect
297
+ * patterns and patterns that fail to compile are silently skipped (the
298
+ * Zod schema rejects them at save time, but we re-check at evaluation
299
+ * to stay safe against rules that pre-date the validation).
300
+ *
301
+ * Safety gates (only applied to ALLOW outcomes — BLOCK and
302
+ * REQUIRE_APPROVAL are always honoured as written so admins keep the
303
+ * ability to stop a command unconditionally):
304
+ *
305
+ * 1. **Shell-meta escalation** — if the command contains shell-control
306
+ * chars (`;`, `&&`, `||`, `|`, `` ` ``, `$()`, `>`, `<`) and the
307
+ * matched pattern is a literal prefix (not `/.../`), the rule is
308
+ * escalated to `REQUIRE_APPROVAL`. Keeps `ALLOW: git status` from
309
+ * silently approving `git status; rm -rf /` — the admin almost
310
+ * certainly didn't mean to grant an arbitrary suffix.
311
+ *
312
+ * Admin-class commands (`rm -rf`, `git push`, `kubectl apply`, …) are
313
+ * NOT auto-escalated. If an admin writes `ALLOW: git push *`, the rule
314
+ * fires as written. The policy editor's linter still flags such ALLOWs
315
+ * as a recommendation to double-check, but runtime respects the explicit
316
+ * intent — silent escalation surprised users more than it protected them.
317
+ */
318
+ function evaluateClaudeBashRules(command, rules, options = {}) {
319
+ if (!rules)
320
+ return null;
321
+ const ruleList = rules.rules ?? [];
322
+ if (ruleList.length === 0 && !rules.defaultDecision)
323
+ return null;
324
+ const canonical = canonicalizeCommand(command);
325
+ let matched = null;
326
+ for (const rule of ruleList) {
327
+ if ((0, regex_safety_js_1.isLikelyRedos)(rule.pattern))
328
+ continue;
329
+ let re;
330
+ try {
331
+ re = (0, exports.compileClaudeBashPattern)(rule.pattern);
332
+ }
333
+ catch {
334
+ continue;
335
+ }
336
+ if (re.test(canonical)) {
337
+ matched = {
338
+ pattern: rule.pattern,
339
+ decision: rule.decision,
340
+ ...(rule.require_two_approvals !== undefined && {
341
+ require_two_approvals: rule.require_two_approvals,
342
+ }),
343
+ ...(rule.allowed_approvers && rule.allowed_approvers.length > 0 && {
344
+ allowed_approvers: rule.allowed_approvers,
345
+ }),
346
+ };
347
+ break;
348
+ }
349
+ }
350
+ // Two ways to reach an outcome: a rule matched, or no rule matched and
351
+ // the policy has a fallback `defaultDecision`. Both flow through the
352
+ // same safety gates below — otherwise `defaultDecision: ALLOW` would
353
+ // be a pole-vault around the gates and `rm -rf /` would auto-approve.
354
+ let source;
355
+ if (matched) {
356
+ source = 'rule';
357
+ }
358
+ else if (rules.defaultDecision) {
359
+ // Synthesised default-match has no per-rule overrides — the surrounding
360
+ // permission.claude_code rule's flags will apply via the caller's
361
+ // fallback.
362
+ matched = { pattern: '', decision: rules.defaultDecision };
363
+ source = 'default';
364
+ }
365
+ else {
366
+ return null;
367
+ }
368
+ const matchReason = (suffix) => source === 'rule'
369
+ ? `Matched user rule: ${matched.pattern}${suffix}`
370
+ : `No user rule matched; using policy defaultDecision${suffix}`;
371
+ // Pre-build the per-rule override fields so every return path
372
+ // propagates them consistently. A synthesised default-match has neither.
373
+ const ruleOverrides = {
374
+ ...(matched.require_two_approvals !== undefined && {
375
+ require_two_approvals: matched.require_two_approvals,
376
+ }),
377
+ ...(matched.allowed_approvers && matched.allowed_approvers.length > 0 && {
378
+ allowed_approvers: matched.allowed_approvers,
379
+ }),
380
+ };
381
+ // BLOCK / REQUIRE_APPROVAL are always applied as written — escalating
382
+ // them would mean an admin's "stop this!" intent could be bypassed,
383
+ // which defeats the purpose of having a deny list.
384
+ if (matched.decision !== 'ALLOW') {
385
+ return {
386
+ decision: matched.decision,
387
+ reason: matchReason(''),
388
+ matchedPattern: matched.pattern,
389
+ ...ruleOverrides,
390
+ };
391
+ }
392
+ // ALLOW path — run safety gates. The shell-meta check runs against the
393
+ // RAW command (not the canonical form) because canonicalizeCommand
394
+ // collapses whitespace — including newlines — which would otherwise
395
+ // mask `git status\ncurl evil.com` from the gate. Newlines act as
396
+ // command separators in real shells, so we have to see them here.
397
+ // For the synthesized default-ALLOW outcome, `pattern` is empty and
398
+ // therefore not "advanced" (no `/.../` wrapping, no regex hint chars),
399
+ // so the gate runs as expected.
400
+ if (!isAdvancedPattern(matched.pattern) && SHELL_CONTROL_RE.test(command)) {
401
+ return {
402
+ decision: 'REQUIRE_APPROVAL',
403
+ reason: source === 'rule'
404
+ ? `ALLOW rule "${matched.pattern}" matched but command contains shell metacharacters; ` +
405
+ `escalated to approval. Wrap your pattern in /.../ to opt into compound matching.`
406
+ : 'Default ALLOW would have applied but command contains shell metacharacters; escalated to approval.',
407
+ matchedPattern: matched.pattern,
408
+ ...ruleOverrides,
409
+ };
410
+ }
411
+ return {
412
+ decision: 'ALLOW',
413
+ reason: matchReason(''),
414
+ matchedPattern: matched.pattern,
415
+ ...ruleOverrides,
416
+ };
417
+ }
418
+ function sshHostAllowed(host, allowedHosts) {
419
+ // Reject malformed host values: userinfo (`user@host`), ports, paths or
420
+ // whitespace. A bare hostname/IP never contains these, and allowing them
421
+ // would let `evil@trusted.example.com` slip past a `*.example.com` wildcard.
422
+ if (/[@/:\s]/.test(host))
423
+ return false;
424
+ // DNS hostnames are case-insensitive and a single trailing dot denotes the
425
+ // same FQDN — normalise both sides before comparing. (Unix usernames are
426
+ // case-sensitive and are matched separately, so they are left untouched.)
427
+ const normalizeHost = (h) => h.toLowerCase().replace(/\.$/, '');
428
+ const target = normalizeHost(host);
429
+ return allowedHosts.some((allowed) => {
430
+ const a = normalizeHost(allowed);
431
+ if (a.startsWith('*.'))
432
+ return target.endsWith(a.slice(1));
433
+ return target === a;
434
+ });
435
+ }
436
+ function evaluateSshRules(command, sshRules) {
437
+ const canonical = canonicalizeCommand(command);
438
+ for (const rule of sshRules.commandRules) {
439
+ // Defense in depth: the save-time schema already rejects catastrophic
440
+ // patterns, but a rule could pre-date that validation. Skip anything
441
+ // that trips the ReDoS heuristic rather than risk wedging the event
442
+ // loop on a single evaluation.
443
+ if ((0, regex_safety_js_1.isLikelyRedos)(rule.pattern))
444
+ continue;
445
+ let re;
446
+ try {
447
+ re = compileSshPattern(rule.pattern);
448
+ }
449
+ catch {
450
+ continue; // skip invalid pattern — policy UI should validate at save time
451
+ }
452
+ if (re.test(canonical)) {
453
+ return { decision: rule.decision, reason: `Matched SSH rule: ${rule.pattern}`, matchedPattern: rule.pattern };
454
+ }
455
+ }
456
+ return { decision: sshRules.defaultDecision, reason: 'No SSH command rule matched; using defaultDecision' };
457
+ }
458
+ function evaluatePolicy(action, rules, options = {}) {
459
+ // Block unknown action types (defense-in-depth)
460
+ if (!(action.action_type in RISK_MAP)) {
461
+ return { decision: 'BLOCK', risk_level: 'critical', reason: `Unknown action type: ${action.action_type}` };
462
+ // No effective_action_type — we never trusted the input.
463
+ }
464
+ // SECURITY: Normalize tool name to lowercase to prevent case-sensitivity
465
+ // bypasses (e.g., "Http.get" skipping HTTP domain allowlist checks).
466
+ // Also coerce the short-form `http` → `http.request` so per-tool rules
467
+ // and default-policy entries (which use the canonical `http.request`)
468
+ // match the identifier real-world SDKs / docs emit. Prior behavior: a
469
+ // rule configured via the Policies UI for `http.request` silently did
470
+ // nothing against an agent sending `tool: 'http'`.
471
+ const normalizedTool = normalizeToolName(action.tool);
472
+ // SECURITY: Anti-downgrade defense. The agent self-declares `action_type`,
473
+ // but a prompt-injected or malicious agent could simply label everything as
474
+ // `read` to bypass restrictions. Compute a server-side "floor" from the
475
+ // tool+payload and use the more restrictive of the two.
476
+ //
477
+ // When `options.skipCategoryFloor` is true (set by the gateway for agents
478
+ // with `trust_declared_action_type = true`), the floor is bypassed and the
479
+ // declared action_type is used as-is. This is an owner-opt-in escape hatch
480
+ // for agents with a very narrow allowed_tools surface.
481
+ const effectiveActionType = options.skipCategoryFloor
482
+ ? action.action_type
483
+ : maxCategory(action.action_type, getCategoryFloor(action.tool, action.payload));
484
+ const wasDowngradeAttempt = effectiveActionType !== action.action_type;
485
+ const risk_level = RISK_MAP[effectiveActionType] ?? 'medium';
486
+ if (normalizedTool.startsWith('ssh.')) {
487
+ // ssh.open: establish a persistent session. Always REQUIRE_APPROVAL,
488
+ // mirrors browser.open. Validates credential_id + host + user against
489
+ // the workspace's SSH policy allowlists. The gateway separately
490
+ // enforces per-plan concurrent-session limits.
491
+ if (normalizedTool === 'ssh.open') {
492
+ const credentialId = action.payload.credential_id;
493
+ const host = typeof action.payload.host === 'string' ? action.payload.host : '';
494
+ const user = typeof action.payload.user === 'string' ? action.payload.user : '';
495
+ if (typeof credentialId !== 'string' || credentialId.length === 0) {
496
+ return { decision: 'BLOCK', risk_level: 'critical', reason: 'ssh.open requires credential_id in payload', effective_action_type: effectiveActionType };
497
+ }
498
+ if (host.length === 0) {
499
+ return { decision: 'BLOCK', risk_level: 'critical', reason: 'ssh.open requires host in payload', effective_action_type: effectiveActionType };
500
+ }
501
+ if (user.length === 0) {
502
+ return { decision: 'BLOCK', risk_level: 'critical', reason: 'ssh.open requires user in payload', effective_action_type: effectiveActionType };
503
+ }
504
+ if (rules.ssh) {
505
+ if (!sshHostAllowed(host, rules.ssh.allowedHosts)) {
506
+ return { decision: 'BLOCK', risk_level: 'critical', reason: `SSH host not in allowedHosts: ${host}`, effective_action_type: effectiveActionType };
507
+ }
508
+ if (rules.ssh.allowedUsers.length > 0 && !rules.ssh.allowedUsers.includes(user)) {
509
+ return { decision: 'BLOCK', risk_level: 'critical', reason: `SSH user not in allowedUsers: ${user}`, effective_action_type: effectiveActionType };
510
+ }
511
+ }
512
+ // Honor an explicit per-tool BLOCK rule. This early return sits before
513
+ // the generic tool-rule matching at the bottom of evaluatePolicy, so
514
+ // without this check even DEFAULT_POLICY_RULES' own ssh.open entry was
515
+ // dead. Only the restrictive direction is honored — the approval
516
+ // requirement for opening a session is a hardcoded invariant (mirrors
517
+ // browser.open), so an ALLOW rule cannot downgrade it.
518
+ const sshOpenRule = rules.rules.find((r) => r.tool !== undefined && r.tool.toLowerCase() === 'ssh.open');
519
+ if (sshOpenRule?.decision === 'BLOCK') {
520
+ return {
521
+ decision: 'BLOCK',
522
+ risk_level: 'critical',
523
+ reason: 'Matched rule: ssh.open',
524
+ matched_rule: sshOpenRule,
525
+ effective_action_type: effectiveActionType,
526
+ };
527
+ }
35
528
  return {
36
529
  decision: 'REQUIRE_APPROVAL',
530
+ risk_level: 'critical',
531
+ reason: 'SSH sessions always require approval to open',
532
+ effective_action_type: effectiveActionType,
533
+ };
534
+ }
535
+ // ssh.close: release a session the agent already owns. The gateway
536
+ // validates that `session_id` belongs to this workspace+agent before
537
+ // policy eval — reaching here means ownership is already confirmed,
538
+ // so allow without per-call approval.
539
+ if (normalizedTool === 'ssh.close') {
540
+ const sessionId = action.payload.session_id;
541
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
542
+ return { decision: 'BLOCK', risk_level: 'medium', reason: 'ssh.close requires session_id in payload', effective_action_type: effectiveActionType };
543
+ }
544
+ // Honor an explicit per-tool rule in the restrictive direction
545
+ // (BLOCK / REQUIRE_APPROVAL). Same dead-rule problem as ssh.open:
546
+ // this early return preceded the generic tool-rule matching.
547
+ const sshCloseRule = rules.rules.find((r) => r.tool !== undefined && r.tool.toLowerCase() === 'ssh.close');
548
+ if (sshCloseRule && sshCloseRule.decision !== 'ALLOW') {
549
+ return {
550
+ decision: sshCloseRule.decision,
551
+ risk_level: 'medium',
552
+ reason: 'Matched rule: ssh.close',
553
+ matched_rule: sshCloseRule,
554
+ effective_action_type: effectiveActionType,
555
+ };
556
+ }
557
+ return {
558
+ decision: 'ALLOW',
37
559
  risk_level: 'medium',
38
- reason: 'Browser sessions always require approval to start',
560
+ reason: 'ssh.close is owner-only (session ownership validated by gateway)',
561
+ effective_action_type: effectiveActionType,
39
562
  };
40
563
  }
41
- // Other browser.* actions with a valid session are handled at the gateway
42
- // level (auto-approved). If they reach the policy engine without a session,
43
- // they should be blocked.
564
+ if (normalizedTool === 'ssh.run') {
565
+ const credentialId = action.payload.credential_id;
566
+ // session_id is optional. When set, it indicates a sessioned run
567
+ // against an existing ssh.open session. The gateway will have
568
+ // validated ownership and populated host/user/port from the session
569
+ // row before calling evaluatePolicy, so the same checks below apply.
570
+ const sessionId = typeof action.payload.session_id === 'string' ? action.payload.session_id : '';
571
+ const host = typeof action.payload.host === 'string' ? action.payload.host : '';
572
+ const user = typeof action.payload.user === 'string' ? action.payload.user : '';
573
+ const command = action.payload.command;
574
+ // Sessioned runs don't require credential_id (the session was opened
575
+ // with one). One-shot runs always do.
576
+ if (sessionId.length === 0 && (typeof credentialId !== 'string' || credentialId.length === 0)) {
577
+ return { decision: 'BLOCK', risk_level: 'critical', reason: 'ssh.run requires credential_id (or session_id for sessioned runs)', effective_action_type: effectiveActionType };
578
+ }
579
+ if (host.length === 0) {
580
+ return { decision: 'BLOCK', risk_level: 'critical', reason: 'ssh.run requires host in payload', effective_action_type: effectiveActionType };
581
+ }
582
+ if (user.length === 0) {
583
+ return { decision: 'BLOCK', risk_level: 'critical', reason: 'ssh.run requires user in payload', effective_action_type: effectiveActionType };
584
+ }
585
+ if (typeof command !== 'string' || command.length === 0) {
586
+ return { decision: 'BLOCK', risk_level: 'critical', reason: 'ssh.run requires a non-empty command payload', effective_action_type: effectiveActionType };
587
+ }
588
+ if (rules.ssh) {
589
+ if (!sshHostAllowed(host, rules.ssh.allowedHosts)) {
590
+ return { decision: 'BLOCK', risk_level: 'critical', reason: `SSH host not in allowedHosts: ${host}`, effective_action_type: effectiveActionType };
591
+ }
592
+ if (rules.ssh.allowedUsers.length > 0 && !rules.ssh.allowedUsers.includes(user)) {
593
+ return { decision: 'BLOCK', risk_level: 'critical', reason: `SSH user not in allowedUsers: ${user}`, effective_action_type: effectiveActionType };
594
+ }
595
+ const outcome = evaluateSshRules(command, rules.ssh);
596
+ if (outcome.decision === 'ALLOW') {
597
+ // Safety gate — mirrors the Claude Bash ALLOW path: a non-advanced
598
+ // ALLOW glob must not hand an auto-pass to a compound/chained command
599
+ // (`systemctl status x; rm -rf /`). Check the RAW command because
600
+ // canonicalizeCommand collapses newlines that act as shell separators.
601
+ // Applies even when admin auto-approval is enabled.
602
+ const matchedPattern = outcome.matchedPattern ?? '';
603
+ if (!isAdvancedPattern(matchedPattern) && SHELL_CONTROL_RE.test(command)) {
604
+ return { decision: 'REQUIRE_APPROVAL', risk_level: 'critical', reason: `${outcome.reason} — command contains shell metacharacters; escalated to approval`, effective_action_type: effectiveActionType };
605
+ }
606
+ if (!rules.allowHighRiskAutoApproval?.admin) {
607
+ return { decision: 'REQUIRE_APPROVAL', risk_level: 'critical', reason: `${outcome.reason} — escalated to approval (admin auto-approval not enabled)`, effective_action_type: effectiveActionType };
608
+ }
609
+ }
610
+ return { decision: outcome.decision, risk_level: 'critical', reason: outcome.reason, effective_action_type: effectiveActionType };
611
+ }
612
+ return { decision: 'REQUIRE_APPROVAL', risk_level: 'critical', reason: 'No SSH policy configured; defaulting to approval', effective_action_type: effectiveActionType };
613
+ }
44
614
  return {
45
615
  decision: 'BLOCK',
46
- risk_level: 'medium',
47
- reason: 'Browser actions require an active session (use browser.open first)',
616
+ risk_level: 'critical',
617
+ reason: `Unknown SSH tool: ${normalizedTool}`,
618
+ effective_action_type: effectiveActionType,
48
619
  };
49
620
  }
50
- // MCP tools: list_tools is a read (low risk), call_tool defers to action_type rules
51
- if (action.tool === 'mcp.list_tools') {
52
- return {
53
- decision: 'ALLOW',
54
- risk_level: 'low',
55
- reason: 'MCP tool discovery is read-only',
56
- };
621
+ // Browser tools: browser.open always requires approval
622
+ if (normalizedTool.startsWith('browser.')) {
623
+ if (normalizedTool === 'browser.open') {
624
+ return {
625
+ decision: 'REQUIRE_APPROVAL',
626
+ risk_level: 'medium',
627
+ reason: 'Browser sessions always require approval to start',
628
+ effective_action_type: effectiveActionType,
629
+ };
630
+ }
631
+ // Other browser.* actions require a live session. If the gateway has
632
+ // already validated one (hasActiveSession=true) we fall through to normal
633
+ // per-tool rule evaluation so admin-configured BLOCKs on specific tools
634
+ // (e.g. browser.fill_credentials) are honored. Without a validated
635
+ // session, the safe default is to block — never let an unauthenticated
636
+ // session_id survive policy eval.
637
+ if (!options.hasActiveSession) {
638
+ return {
639
+ decision: 'BLOCK',
640
+ risk_level: 'medium',
641
+ reason: 'Browser actions require an active session (use browser.open first)',
642
+ effective_action_type: effectiveActionType,
643
+ };
644
+ }
645
+ // Fall through to per-tool rule matching + default-mode below.
57
646
  }
58
- if (action.tool.split('.')[0] === 'http' && rules.http) {
647
+ // MCP tools: list_tools handled via default rules (can be overridden by custom policies)
648
+ // HTTP tool checks: always enforce domain/method restrictions
649
+ const isHttpTool = normalizedTool.split('.')[0] === 'http';
650
+ if (isHttpTool) {
651
+ const httpRules = rules.http ?? DEFAULT_HTTP_RULES;
59
652
  const url = action.payload.url;
653
+ if (!url) {
654
+ // SECURITY: HTTP actions without a URL must be blocked at the policy level.
655
+ // The connector would also reject it, but policy should be the first gate.
656
+ return {
657
+ decision: 'BLOCK',
658
+ risk_level: 'critical',
659
+ reason: 'HTTP actions require a URL in payload',
660
+ effective_action_type: effectiveActionType,
661
+ };
662
+ }
60
663
  if (url) {
61
664
  try {
62
- const domain = new URL(url).hostname;
665
+ const domain = new URL(url).hostname.replace(/\.$/, '').toLowerCase();
63
666
  // Use exact match or proper subdomain match (preceded by a dot)
64
667
  // to prevent "not-trusted.com" from matching allowlist entry "trusted.com"
65
- const matchesDomain = (d, pattern) => d === pattern || d.endsWith('.' + pattern);
66
- if (rules.http.blockList.some((b) => matchesDomain(domain, b))) {
67
- return { decision: 'BLOCK', risk_level: 'critical', reason: `Domain ${domain} is in block list` };
68
- }
69
- if (rules.http.allowedDomains.length === 0) {
70
- // No allowlist configured: safe default is REQUIRE_APPROVAL, not ALLOW.
71
- // This prevents agents from exfiltrating data to arbitrary domains.
668
+ // Both sides are normalized: lowercase + trailing-dot stripped.
669
+ const matchesDomain = (d, pattern) => {
670
+ const normalizedPattern = pattern.replace(/\.$/, '').toLowerCase();
671
+ return d === normalizedPattern || d.endsWith('.' + normalizedPattern);
672
+ };
673
+ if (httpRules.blockList.some((b) => matchesDomain(domain, b))) {
72
674
  return {
73
- decision: 'REQUIRE_APPROVAL',
74
- risk_level,
75
- reason: 'HTTP allowlist not configured approval required for all HTTP calls',
675
+ decision: 'BLOCK',
676
+ risk_level: 'critical',
677
+ reason: `Domain ${domain} is in block list`,
678
+ effective_action_type: effectiveActionType,
76
679
  };
77
680
  }
78
- if (!rules.http.allowedDomains.some((d) => matchesDomain(domain, d))) {
79
- return { decision: 'BLOCK', risk_level, reason: `Domain ${domain} not in allowed list` };
681
+ // DANGEROUS opt-in: if allowAllDomains is true, any domain not in the
682
+ // blockList is permitted fall through to the normal rule evaluation.
683
+ // Otherwise enforce the safe default (empty allowlist → REQUIRE_APPROVAL).
684
+ if (!httpRules.allowAllDomains) {
685
+ if (httpRules.allowedDomains.length === 0) {
686
+ // No allowlist configured: safe default is REQUIRE_APPROVAL, not ALLOW.
687
+ // This prevents agents from exfiltrating data to arbitrary domains.
688
+ return {
689
+ decision: 'REQUIRE_APPROVAL',
690
+ risk_level,
691
+ reason: 'HTTP allowlist not configured — approval required for all HTTP calls',
692
+ effective_action_type: effectiveActionType,
693
+ };
694
+ }
695
+ if (!httpRules.allowedDomains.some((d) => matchesDomain(domain, d))) {
696
+ return {
697
+ decision: 'BLOCK',
698
+ risk_level,
699
+ reason: `Domain ${domain} not in allowed list`,
700
+ effective_action_type: effectiveActionType,
701
+ };
702
+ }
80
703
  }
81
704
  }
82
705
  catch {
83
- return { decision: 'BLOCK', risk_level: 'critical', reason: 'Invalid URL' };
706
+ return {
707
+ decision: 'BLOCK',
708
+ risk_level: 'critical',
709
+ reason: 'Invalid URL',
710
+ effective_action_type: effectiveActionType,
711
+ };
84
712
  }
85
713
  }
86
714
  const method = action.payload.method?.toUpperCase();
87
- if (method && !rules.http.allowedMethods.includes(method)) {
88
- return { decision: 'BLOCK', risk_level, reason: `HTTP method ${method} not allowed` };
715
+ if (method && !httpRules.allowedMethods.includes(method)) {
716
+ return {
717
+ decision: 'BLOCK',
718
+ risk_level,
719
+ reason: `HTTP method ${method} not allowed`,
720
+ effective_action_type: effectiveActionType,
721
+ };
89
722
  }
90
723
  }
91
724
  if (rules.limits?.maxCostPerAction !== undefined &&
@@ -95,28 +728,100 @@ function evaluatePolicy(action, rules) {
95
728
  decision: 'BLOCK',
96
729
  risk_level: 'high',
97
730
  reason: `Cost estimate ${action.cost_estimate} exceeds limit ${rules.limits.maxCostPerAction}`,
731
+ effective_action_type: effectiveActionType,
98
732
  };
99
733
  }
100
- // Most specific: tool-specific rule
101
- let matched = rules.rules.find((r) => r.tool === action.tool);
102
- // Then action-type rule
734
+ // Most specific: tool-specific rule (case-insensitive to prevent bypasses)
735
+ let matched = rules.rules.find((r) => r.tool !== undefined && r.tool.toLowerCase() === normalizedTool);
736
+ // Then action-type rule — match against the EFFECTIVE type so a downgraded
737
+ // `read` declaration on `stripe.charge` still hits the `financial` rule.
103
738
  if (!matched)
104
- matched = rules.rules.find((r) => r.action_type === action.action_type);
739
+ matched = rules.rules.find((r) => r.action_type === effectiveActionType);
740
+ // Helper: is auto-approval explicitly opted in for this high-risk category?
741
+ const highRiskOptIn = (cat) => rules.allowHighRiskAutoApproval?.[cat] === true;
742
+ // Reason suffix that explains a floor upgrade, so the audit trail makes the
743
+ // downgrade attempt visible to humans reviewing the log.
744
+ const downgradeSuffix = wasDowngradeAttempt
745
+ ? ` [category floored: agent declared '${action.action_type}', server classified as '${effectiveActionType}']`
746
+ : '';
105
747
  if (matched) {
748
+ let finalDecision = matched.decision;
749
+ // SECURITY: By default, never auto-allow admin/financial actions even via
750
+ // explicit rules. Workspace admins can opt in via allowHighRiskAutoApproval
751
+ // (shown with a prominent warning in the UI). Check against the EFFECTIVE
752
+ // type so floored categories are also covered.
753
+ if (finalDecision === 'ALLOW' && effectiveActionType === 'financial' && !highRiskOptIn('financial')) {
754
+ finalDecision = 'REQUIRE_APPROVAL';
755
+ }
756
+ if (finalDecision === 'ALLOW' && effectiveActionType === 'admin' && !highRiskOptIn('admin')) {
757
+ finalDecision = 'REQUIRE_APPROVAL';
758
+ }
759
+ // SECURITY: a write that was floored UP (the agent under-declared, or the LLM
760
+ // reclassified it above the declared type) must not ride a permissive ALLOW
761
+ // rule. An explicit ALLOW is honored only for an honestly-declared write; a
762
+ // reclassified one is escalated to approval. financial/admin handled above.
763
+ if (finalDecision === 'ALLOW' && effectiveActionType === 'write' && wasDowngradeAttempt) {
764
+ finalDecision = 'REQUIRE_APPROVAL';
765
+ }
106
766
  return {
107
- decision: matched.decision,
767
+ decision: finalDecision,
108
768
  risk_level,
109
- reason: `Matched rule: ${matched.action_type ?? matched.tool}`,
769
+ reason: `Matched rule: ${matched.action_type ?? matched.tool}${downgradeSuffix}`,
110
770
  matched_rule: matched,
771
+ effective_action_type: effectiveActionType,
111
772
  };
112
773
  }
113
774
  const defaultDecision = rules.defaultMode === 'allow' ? 'ALLOW' : rules.defaultMode === 'block' ? 'BLOCK' : 'REQUIRE_APPROVAL';
114
- return { decision: defaultDecision, risk_level, reason: 'Default policy' };
775
+ // SECURITY: By default, never auto-allow high-risk actions via permissive default mode.
776
+ // Opt-in via allowHighRiskAutoApproval honors the permissive default instead.
777
+ if (defaultDecision === 'ALLOW' && effectiveActionType === 'financial' && !highRiskOptIn('financial')) {
778
+ return {
779
+ decision: 'REQUIRE_APPROVAL',
780
+ risk_level,
781
+ reason: `Financial actions require approval by default (enable allowHighRiskAutoApproval.financial to change this)${downgradeSuffix}`,
782
+ effective_action_type: effectiveActionType,
783
+ };
784
+ }
785
+ if (defaultDecision === 'ALLOW' && effectiveActionType === 'admin' && !highRiskOptIn('admin')) {
786
+ return {
787
+ decision: 'REQUIRE_APPROVAL',
788
+ risk_level,
789
+ reason: `Admin actions require approval by default (enable allowHighRiskAutoApproval.admin to change this)${downgradeSuffix}`,
790
+ effective_action_type: effectiveActionType,
791
+ };
792
+ }
793
+ // SECURITY: a write floored UP above the declared action_type must not ride a
794
+ // permissive default ALLOW — escalate the reclassified write to approval.
795
+ if (defaultDecision === 'ALLOW' && effectiveActionType === 'write' && wasDowngradeAttempt) {
796
+ return {
797
+ decision: 'REQUIRE_APPROVAL',
798
+ risk_level,
799
+ reason: `Write reclassified above the declared action_type; escalated to approval${downgradeSuffix}`,
800
+ effective_action_type: effectiveActionType,
801
+ };
802
+ }
803
+ return {
804
+ decision: defaultDecision,
805
+ risk_level,
806
+ reason: `Default policy${downgradeSuffix}`,
807
+ effective_action_type: effectiveActionType,
808
+ };
115
809
  }
116
- function buildActionPreview(action) {
117
- let summary = `${action.action_type.toUpperCase()} via ${action.tool}`;
810
+ function buildActionPreview(action, effectiveActionType) {
811
+ const normalizedTool = normalizeToolName(action.tool);
812
+ // Prefer the server-computed effective type in the preview. Falls back to
813
+ // the declared type when the caller didn't pass one (e.g. in tests).
814
+ const effective = effectiveActionType ?? action.action_type;
815
+ let summary = `${effective.toUpperCase()} via ${action.tool}`;
118
816
  let target;
119
- if (action.tool.split('.')[0] === 'http') {
817
+ // Extracted from permission.claude_code payloads so the approval UI can
818
+ // pre-fill its rule-creation form. Only set when the underlying Claude
819
+ // Code tool is Bash (path-based rules for Edit/Write are a separate
820
+ // feature). Already redaction-safe — the routing endpoint runs `redact`
821
+ // + truncates to 2000 chars before stashing it in the payload.
822
+ let command;
823
+ let claudeTool;
824
+ if (normalizedTool.split('.')[0] === 'http') {
120
825
  const url = action.payload.url;
121
826
  const method = action.payload.method;
122
827
  if (url) {
@@ -129,40 +834,228 @@ function buildActionPreview(action) {
129
834
  summary = `${method?.toUpperCase() ?? 'HTTP'} request to ${target}`;
130
835
  }
131
836
  }
132
- else if (action.tool === 'browser.open') {
837
+ else if (normalizedTool === 'browser.open') {
133
838
  const url = action.payload.url;
839
+ const allowedDomains = Array.isArray(action.payload.allowed_domains)
840
+ ? action.payload.allowed_domains.filter((d) => typeof d === 'string')
841
+ : [];
842
+ let initialHost = '';
134
843
  if (url) {
135
844
  try {
136
- target = new URL(url).hostname;
845
+ initialHost = new URL(url).hostname;
137
846
  }
138
847
  catch {
139
- target = url;
848
+ initialHost = url;
140
849
  }
141
- summary = `Open browser session to ${target}`;
850
+ }
851
+ // Approver must see the full allow-list, not just the landing domain.
852
+ // Previously the preview only surfaced the initial URL's host, so an
853
+ // agent could request `browser.open` with url=accounts.example.com but
854
+ // allowed_domains=[attacker.example.com], and the approver would
855
+ // approve a seemingly narrow session that actually grants broader
856
+ // reach. Summary lists every domain the session will accept.
857
+ const extraDomains = allowedDomains.filter((d) => d !== initialHost);
858
+ target = initialHost || url || 'browser session';
859
+ if (url) {
860
+ summary = extraDomains.length > 0
861
+ ? `Open browser session to ${initialHost} (also allows: ${extraDomains.join(', ')})`
862
+ : `Open browser session to ${initialHost}`;
142
863
  }
143
864
  else {
144
- summary = 'Open browser session';
865
+ summary = allowedDomains.length > 0
866
+ ? `Open browser session (allows: ${allowedDomains.join(', ')})`
867
+ : 'Open browser session';
145
868
  }
146
869
  }
147
- else if (action.tool === 'mcp.list_tools') {
870
+ else if (normalizedTool === 'mcp.list_tools') {
148
871
  const server = action.payload.server;
149
872
  target = server;
150
873
  summary = `List available tools on MCP server "${server ?? 'unknown'}"`;
151
874
  }
152
- else if (action.tool === 'mcp.call_tool') {
875
+ else if (normalizedTool === 'mcp.call_tool') {
153
876
  const server = action.payload.server;
154
877
  const method = action.payload.method;
155
878
  target = server;
156
879
  summary = `Call "${method ?? 'unknown'}" on MCP server "${server ?? 'unknown'}"`;
157
880
  }
158
- else if (action.tool === 'demo') {
159
- summary = `Write to demo table: ${JSON.stringify((0, redact_js_1.redact)(action.payload)).slice(0, 80)}`;
881
+ else if (normalizedTool === 'permission.claude_code') {
882
+ const claudeToolName = action.payload.tool_name;
883
+ const cmd = action.payload.command;
884
+ const filePath = action.payload.file_path;
885
+ const cwd = action.payload.cwd;
886
+ target = filePath ?? cwd;
887
+ claudeTool = claudeToolName;
888
+ if (claudeToolName === 'Bash' && cmd) {
889
+ command = cmd;
890
+ const shortenedCommand = cmd.length > 120
891
+ ? `${cmd.slice(0, 117)}...`
892
+ : cmd;
893
+ summary = `Allow Claude Code to run "${shortenedCommand}"`;
894
+ }
895
+ else if (filePath) {
896
+ summary = `Allow Claude Code ${claudeToolName ?? 'tool'} on ${filePath}`;
897
+ }
898
+ else {
899
+ summary = `Allow Claude Code ${claudeToolName ?? 'tool'} action`;
900
+ }
901
+ }
902
+ else if (normalizedTool === 'ssh.run') {
903
+ const host = action.payload.host;
904
+ const user = action.payload.user;
905
+ const port = action.payload.port;
906
+ const command = action.payload.command;
907
+ const sessionId = action.payload.session_id;
908
+ const targetSuffix = port && port !== 22 ? `:${port}` : '';
909
+ target = host ? `${user ?? 'unknown'}@${host}${targetSuffix}` : undefined;
910
+ const shortenedCommand = typeof command === 'string' && command.length > 0
911
+ ? command.length > 80
912
+ ? `${command.slice(0, 77)}...`
913
+ : command
914
+ : 'unknown command';
915
+ const sessionSuffix = sessionId ? ' (in existing session)' : '';
916
+ summary = target
917
+ ? `Run SSH command "${shortenedCommand}" on ${target}${sessionSuffix}`
918
+ : `Run SSH command "${shortenedCommand}"${sessionSuffix}`;
919
+ }
920
+ else if (normalizedTool === 'ssh.open') {
921
+ const host = action.payload.host;
922
+ const user = action.payload.user;
923
+ const port = action.payload.port;
924
+ const targetSuffix = port && port !== 22 ? `:${port}` : '';
925
+ target = host ? `${user ?? 'unknown'}@${host}${targetSuffix}` : undefined;
926
+ summary = target
927
+ ? `Open persistent SSH session to ${target}`
928
+ : 'Open persistent SSH session';
929
+ }
930
+ else if (normalizedTool === 'ssh.close') {
931
+ const sessionId = action.payload.session_id;
932
+ target = sessionId;
933
+ summary = sessionId
934
+ ? `Close SSH session ${sessionId.slice(0, 12)}…`
935
+ : 'Close SSH session';
160
936
  }
937
+ // Impact text reflects the effective category (so write/financial/admin all
938
+ // surface a warning, not just the declared category).
939
+ let impact;
940
+ if (effective === 'admin')
941
+ impact = 'Administrative action — privileged change';
942
+ else if (effective === 'financial')
943
+ impact = 'Financial action — money movement';
944
+ else if (effective === 'write')
945
+ impact = 'Data will be modified';
161
946
  return {
162
947
  summary,
163
948
  target,
164
- impact: action.action_type === 'write' ? 'Data will be modified' : undefined,
949
+ impact,
165
950
  cost_estimate: action.cost_estimate,
951
+ declared_action_type: action.action_type,
952
+ effective_action_type: effective,
953
+ ...(command !== undefined && { command }),
954
+ ...(claudeTool !== undefined && { claude_tool: claudeTool }),
166
955
  };
167
956
  }
957
+ /**
958
+ * Suggest a Claude Code Bash policy pattern from a raw command string. Used
959
+ * by the "Approve and remember" affordance in the approval UI to pre-fill
960
+ * the rule-creation form with a sensible default; admins can edit the
961
+ * suggestion before submitting.
962
+ *
963
+ * Heuristic (in order):
964
+ * - Empty / whitespace-only → empty string (UI handles this).
965
+ * - Quoted segments (`"..."`, `'...'`) are removed before tokenising so
966
+ * `git commit -m "fix bug"` → tokens `[git, commit, -m]`, not five
967
+ * fragmented strings.
968
+ * - Single token → `<token>` (exact match for that bare command).
969
+ * - Two tokens, second is a flag (`-x`, `--long`) → `<first> *`.
970
+ * - Two tokens, second looks like a path (starts with `/`, `.`, `~`, `\\`)
971
+ * → `<first> *`. Pinning the path makes the rule too narrow; dropping
972
+ * it makes nothing match. Falling back to `<first> *` is the safest
973
+ * middle ground; the admin can tighten further if they want.
974
+ * - Two+ tokens, second is non-flag → `<first> <second> *` (typical
975
+ * subcommand form: `git push *`, `kubectl apply *`).
976
+ *
977
+ * The output is always a glob the routing endpoint understands. Admins
978
+ * can switch to a regex via `/.../` if they need finer control.
979
+ */
980
+ function suggestClaudeBashPattern(command) {
981
+ // Strip quoted segments first so a single-quoted commit message like
982
+ // `git commit -m "wip: fix"` doesn't tokenise into the five-token mess
983
+ // `[git, commit, -m, "wip:, fix"]` and lock the suggestion to whatever
984
+ // happened to land at index 1.
985
+ const cleaned = command.replace(/"[^"]*"|'[^']*'/g, ' ');
986
+ const tokens = cleaned.trim().split(/\s+/).filter(Boolean);
987
+ if (tokens.length === 0)
988
+ return '';
989
+ if (tokens.length === 1)
990
+ return tokens[0];
991
+ const [first, second] = tokens;
992
+ if (second.startsWith('-'))
993
+ return `${first} *`;
994
+ // Subcommand-style second token: alphanumeric + `-` / `_` only. Anything
995
+ // else (path operand, shell meta, URL, JSON literal, …) drops to
996
+ // `<first> *` — pinning a path makes the rule too specific to be
997
+ // useful, and pinning a shell meta produces nonsense like `echo | *`.
998
+ // Single source of truth for both concerns.
999
+ if (!/^[a-zA-Z0-9_-]+$/.test(second))
1000
+ return `${first} *`;
1001
+ return `${first} ${second} *`;
1002
+ }
1003
+ // ---------------------------------------------------------------------------
1004
+ // VPN route resolution
1005
+ // ---------------------------------------------------------------------------
1006
+ // Policies can carry a `vpnRoutes` table that pins specific domains to a
1007
+ // specific WireGuard credential. The runner calls `resolveVpnRoute(host,
1008
+ // rules.vpnRoutes)` before resolving credentials so the matched route
1009
+ // wins over the credential's own `vpn_credential_id` default.
1010
+ //
1011
+ // Matching rules:
1012
+ // - "example.com" → exact hostname match (case-insensitive)
1013
+ // - "*.example.com" → matches any single-or-multi-level SUBDOMAIN of
1014
+ // example.com (e.g. "www.example.com",
1015
+ // "a.b.example.com"), NOT "example.com" itself
1016
+ // - First entry in the array wins; later matches are ignored.
1017
+ //
1018
+ // Invalid patterns (empty string, whitespace-only, `*`-only) are silently
1019
+ // skipped rather than throwing — the Zod schema already rejects them at
1020
+ // save time, but this path stays robust if a malformed entry sneaks in.
1021
+ /** Return the vpnCredentialId matching `host`, or `undefined` if no route
1022
+ * matches (or `host`/`routes` is missing/empty). */
1023
+ function resolveVpnRoute(host, routes) {
1024
+ if (!host || !routes || routes.length === 0)
1025
+ return undefined;
1026
+ const target = host.trim().toLowerCase().replace(/\.$/, ''); // drop trailing dot from FQDN forms
1027
+ if (!target)
1028
+ return undefined;
1029
+ for (const route of routes) {
1030
+ const pattern = (route.domainPattern ?? '').trim().toLowerCase();
1031
+ if (!pattern)
1032
+ continue;
1033
+ if (pattern.startsWith('*.')) {
1034
+ // Wildcard subdomain: the suffix after "*." must be non-empty, and
1035
+ // `target` must end with ".<suffix>". Bare suffix does NOT match.
1036
+ const suffix = pattern.slice(2);
1037
+ if (!suffix)
1038
+ continue;
1039
+ if (target.endsWith('.' + suffix))
1040
+ return route.vpnCredentialId;
1041
+ continue;
1042
+ }
1043
+ if (target === pattern)
1044
+ return route.vpnCredentialId;
1045
+ }
1046
+ return undefined;
1047
+ }
1048
+ /** Parse a URL string and return its hostname in lowercase, or `undefined`
1049
+ * if the input isn't a parseable URL. Used by the runner to extract the
1050
+ * routing key for http.request / browser.open actions. */
1051
+ function hostnameFromUrl(url) {
1052
+ if (!url || typeof url !== 'string')
1053
+ return undefined;
1054
+ try {
1055
+ return new URL(url).hostname.toLowerCase();
1056
+ }
1057
+ catch {
1058
+ return undefined;
1059
+ }
1060
+ }
168
1061
  //# sourceMappingURL=policy.js.map