agentlock-shared 0.2.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 (169) 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__/dns-pinning.test.d.ts +2 -0
  6. package/dist/__tests__/dns-pinning.test.d.ts.map +1 -0
  7. package/dist/__tests__/dns-pinning.test.js +33 -0
  8. package/dist/__tests__/dns-pinning.test.js.map +1 -0
  9. package/dist/__tests__/llm-classifier-cache-store.test.d.ts +2 -0
  10. package/dist/__tests__/llm-classifier-cache-store.test.d.ts.map +1 -0
  11. package/dist/__tests__/llm-classifier-cache-store.test.js +65 -0
  12. package/dist/__tests__/llm-classifier-cache-store.test.js.map +1 -0
  13. package/dist/__tests__/llm-classifier-cache.test.d.ts +2 -0
  14. package/dist/__tests__/llm-classifier-cache.test.d.ts.map +1 -0
  15. package/dist/__tests__/llm-classifier-cache.test.js +44 -0
  16. package/dist/__tests__/llm-classifier-cache.test.js.map +1 -0
  17. package/dist/__tests__/llm-classifier.test.d.ts +2 -0
  18. package/dist/__tests__/llm-classifier.test.d.ts.map +1 -0
  19. package/dist/__tests__/llm-classifier.test.js +167 -0
  20. package/dist/__tests__/llm-classifier.test.js.map +1 -0
  21. package/dist/__tests__/plans-classifier-limits.test.d.ts +2 -0
  22. package/dist/__tests__/plans-classifier-limits.test.d.ts.map +1 -0
  23. package/dist/__tests__/plans-classifier-limits.test.js +22 -0
  24. package/dist/__tests__/plans-classifier-limits.test.js.map +1 -0
  25. package/dist/__tests__/policy-category-floor.test.d.ts +2 -0
  26. package/dist/__tests__/policy-category-floor.test.d.ts.map +1 -0
  27. package/dist/__tests__/policy-category-floor.test.js +46 -0
  28. package/dist/__tests__/policy-category-floor.test.js.map +1 -0
  29. package/dist/__tests__/policy-claude-bash.test.d.ts +2 -0
  30. package/dist/__tests__/policy-claude-bash.test.d.ts.map +1 -0
  31. package/dist/__tests__/policy-claude-bash.test.js +401 -0
  32. package/dist/__tests__/policy-claude-bash.test.js.map +1 -0
  33. package/dist/__tests__/policy-llm-floor.test.d.ts +2 -0
  34. package/dist/__tests__/policy-llm-floor.test.d.ts.map +1 -0
  35. package/dist/__tests__/policy-llm-floor.test.js +107 -0
  36. package/dist/__tests__/policy-llm-floor.test.js.map +1 -0
  37. package/dist/__tests__/policy-ssh-e2e.test.d.ts +2 -0
  38. package/dist/__tests__/policy-ssh-e2e.test.d.ts.map +1 -0
  39. package/dist/__tests__/policy-ssh-e2e.test.js +89 -0
  40. package/dist/__tests__/policy-ssh-e2e.test.js.map +1 -0
  41. package/dist/__tests__/policy-ssh-sessions.test.d.ts +2 -0
  42. package/dist/__tests__/policy-ssh-sessions.test.d.ts.map +1 -0
  43. package/dist/__tests__/policy-ssh-sessions.test.js +139 -0
  44. package/dist/__tests__/policy-ssh-sessions.test.js.map +1 -0
  45. package/dist/__tests__/policy-ssh.test.d.ts +2 -0
  46. package/dist/__tests__/policy-ssh.test.d.ts.map +1 -0
  47. package/dist/__tests__/policy-ssh.test.js +180 -0
  48. package/dist/__tests__/policy-ssh.test.js.map +1 -0
  49. package/dist/__tests__/policy.test.js +400 -2
  50. package/dist/__tests__/policy.test.js.map +1 -1
  51. package/dist/__tests__/redact.test.js +76 -0
  52. package/dist/__tests__/redact.test.js.map +1 -1
  53. package/dist/__tests__/signing.test.js +89 -0
  54. package/dist/__tests__/signing.test.js.map +1 -1
  55. package/dist/__tests__/ssh-fingerprint.test.d.ts +2 -0
  56. package/dist/__tests__/ssh-fingerprint.test.d.ts.map +1 -0
  57. package/dist/__tests__/ssh-fingerprint.test.js +19 -0
  58. package/dist/__tests__/ssh-fingerprint.test.js.map +1 -0
  59. package/dist/__tests__/vpn-route.test.d.ts +2 -0
  60. package/dist/__tests__/vpn-route.test.d.ts.map +1 -0
  61. package/dist/__tests__/vpn-route.test.js +72 -0
  62. package/dist/__tests__/vpn-route.test.js.map +1 -0
  63. package/dist/__tests__/wireguard.test.d.ts +2 -0
  64. package/dist/__tests__/wireguard.test.d.ts.map +1 -0
  65. package/dist/__tests__/wireguard.test.js +114 -0
  66. package/dist/__tests__/wireguard.test.js.map +1 -0
  67. package/dist/billing.d.ts +12 -0
  68. package/dist/billing.d.ts.map +1 -0
  69. package/dist/billing.js +41 -0
  70. package/dist/billing.js.map +1 -0
  71. package/dist/crypto.d.ts +5 -0
  72. package/dist/crypto.d.ts.map +1 -1
  73. package/dist/crypto.js +80 -23
  74. package/dist/crypto.js.map +1 -1
  75. package/dist/dns-pinning.d.ts +28 -0
  76. package/dist/dns-pinning.d.ts.map +1 -0
  77. package/dist/dns-pinning.js +113 -0
  78. package/dist/dns-pinning.js.map +1 -0
  79. package/dist/index.d.ts +6 -0
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/index.js +9 -0
  82. package/dist/index.js.map +1 -1
  83. package/dist/llm-classifier-cache-store.d.ts +49 -0
  84. package/dist/llm-classifier-cache-store.d.ts.map +1 -0
  85. package/dist/llm-classifier-cache-store.js +63 -0
  86. package/dist/llm-classifier-cache-store.js.map +1 -0
  87. package/dist/llm-classifier-cache.d.ts +6 -0
  88. package/dist/llm-classifier-cache.d.ts.map +1 -0
  89. package/dist/llm-classifier-cache.js +52 -0
  90. package/dist/llm-classifier-cache.js.map +1 -0
  91. package/dist/llm-classifier.d.ts +29 -0
  92. package/dist/llm-classifier.d.ts.map +1 -0
  93. package/dist/llm-classifier.js +191 -0
  94. package/dist/llm-classifier.js.map +1 -0
  95. package/dist/observability.d.ts +36 -0
  96. package/dist/observability.d.ts.map +1 -0
  97. package/dist/observability.js +75 -0
  98. package/dist/observability.js.map +1 -0
  99. package/dist/plans.d.ts +17 -0
  100. package/dist/plans.d.ts.map +1 -1
  101. package/dist/plans.js +36 -14
  102. package/dist/plans.js.map +1 -1
  103. package/dist/policy.d.ts +173 -3
  104. package/dist/policy.d.ts.map +1 -1
  105. package/dist/policy.js +910 -42
  106. package/dist/policy.js.map +1 -1
  107. package/dist/redact.d.ts.map +1 -1
  108. package/dist/redact.js +83 -3
  109. package/dist/redact.js.map +1 -1
  110. package/dist/regex-safety.d.ts +21 -0
  111. package/dist/regex-safety.d.ts.map +1 -0
  112. package/dist/regex-safety.js +49 -0
  113. package/dist/regex-safety.js.map +1 -0
  114. package/dist/sanitize.d.ts +31 -0
  115. package/dist/sanitize.d.ts.map +1 -0
  116. package/dist/sanitize.js +54 -0
  117. package/dist/sanitize.js.map +1 -0
  118. package/dist/schemas.d.ts +202 -10
  119. package/dist/schemas.d.ts.map +1 -1
  120. package/dist/schemas.js +91 -1
  121. package/dist/schemas.js.map +1 -1
  122. package/dist/signing.d.ts +15 -0
  123. package/dist/signing.d.ts.map +1 -1
  124. package/dist/signing.js +53 -4
  125. package/dist/signing.js.map +1 -1
  126. package/dist/ssh-fingerprint.d.ts +10 -0
  127. package/dist/ssh-fingerprint.d.ts.map +1 -0
  128. package/dist/ssh-fingerprint.js +52 -0
  129. package/dist/ssh-fingerprint.js.map +1 -0
  130. package/dist/ssrf.d.ts +36 -0
  131. package/dist/ssrf.d.ts.map +1 -0
  132. package/dist/ssrf.js +140 -0
  133. package/dist/ssrf.js.map +1 -0
  134. package/dist/types.d.ts +130 -0
  135. package/dist/types.d.ts.map +1 -1
  136. package/dist/wireguard.d.ts +63 -0
  137. package/dist/wireguard.d.ts.map +1 -0
  138. package/dist/wireguard.js +226 -0
  139. package/dist/wireguard.js.map +1 -0
  140. package/package.json +42 -29
  141. package/.turbo/turbo-build.log +0 -4
  142. package/.turbo/turbo-test.log +0 -76
  143. package/dist/__tests__/content-crypto.test.d.ts +0 -2
  144. package/dist/__tests__/content-crypto.test.d.ts.map +0 -1
  145. package/dist/__tests__/content-crypto.test.js +0 -117
  146. package/dist/__tests__/content-crypto.test.js.map +0 -1
  147. package/dist/__tests__/signing.test (# Edit conflict 2026-04-01 z3etfmC #).js +0 -51
  148. package/dist/__tests__/signing.test.js (# Edit conflict 2026-04-01 4rndy9C #).map +0 -1
  149. package/dist/content-crypto.d.ts +0 -24
  150. package/dist/content-crypto.d.ts.map +0 -1
  151. package/dist/content-crypto.js +0 -58
  152. package/dist/content-crypto.js.map +0 -1
  153. package/src/__tests__/crypto.test.ts +0 -169
  154. package/src/__tests__/messaging.test.ts +0 -83
  155. package/src/__tests__/policy.test.ts +0 -222
  156. package/src/__tests__/redact.test.ts +0 -41
  157. package/src/__tests__/signing.test.ts +0 -55
  158. package/src/crypto.ts +0 -235
  159. package/src/index.ts +0 -8
  160. package/src/mcp-catalog.ts +0 -181
  161. package/src/plans.ts +0 -116
  162. package/src/policy.ts +0 -216
  163. package/src/redact.ts +0 -131
  164. package/src/schemas.ts +0 -121
  165. package/src/signing.ts +0 -120
  166. package/src/types.ts +0 -213
  167. package/test-gateway.mjs +0 -47
  168. package/tsconfig.json +0 -10
  169. 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;
10
+ exports.suggestClaudeBashPattern = suggestClaudeBashPattern;
11
+ exports.resolveVpnRoute = resolveVpnRoute;
12
+ exports.hostnameFromUrl = hostnameFromUrl;
13
+ const regex_safety_js_1 = require("./regex-safety.js");
6
14
  const RISK_MAP = {
7
15
  read: 'low',
8
16
  write: 'medium',
9
17
  financial: 'high',
10
18
  admin: 'critical',
11
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
+ }
12
167
  exports.DEFAULT_POLICY_RULES = {
13
168
  defaultMode: 'require_approval',
14
169
  rules: [
170
+ // Action-type defaults
15
171
  { action_type: 'read', decision: 'ALLOW' },
16
172
  { action_type: 'write', decision: 'REQUIRE_APPROVAL' },
17
173
  { action_type: 'financial', decision: 'REQUIRE_APPROVAL' },
18
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
19
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: [],
@@ -33,15 +214,410 @@ const DEFAULT_HTTP_RULES = {
33
214
  allowedMethods: ['GET', 'POST', 'PUT', 'DELETE'],
34
215
  blockList: [],
35
216
  };
36
- function evaluatePolicy(action, rules) {
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 = {}) {
37
459
  // Block unknown action types (defense-in-depth)
38
460
  if (!(action.action_type in RISK_MAP)) {
39
461
  return { decision: 'BLOCK', risk_level: 'critical', reason: `Unknown action type: ${action.action_type}` };
462
+ // No effective_action_type — we never trusted the input.
40
463
  }
41
464
  // SECURITY: Normalize tool name to lowercase to prevent case-sensitivity
42
465
  // bypasses (e.g., "Http.get" skipping HTTP domain allowlist checks).
43
- const normalizedTool = action.tool.toLowerCase();
44
- const risk_level = RISK_MAP[action.action_type] ?? 'medium';
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
+ }
528
+ return {
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',
559
+ risk_level: 'medium',
560
+ reason: 'ssh.close is owner-only (session ownership validated by gateway)',
561
+ effective_action_type: effectiveActionType,
562
+ };
563
+ }
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
+ }
614
+ return {
615
+ decision: 'BLOCK',
616
+ risk_level: 'critical',
617
+ reason: `Unknown SSH tool: ${normalizedTool}`,
618
+ effective_action_type: effectiveActionType,
619
+ };
620
+ }
45
621
  // Browser tools: browser.open always requires approval
46
622
  if (normalizedTool.startsWith('browser.')) {
47
623
  if (normalizedTool === 'browser.open') {
@@ -49,16 +625,24 @@ function evaluatePolicy(action, rules) {
49
625
  decision: 'REQUIRE_APPROVAL',
50
626
  risk_level: 'medium',
51
627
  reason: 'Browser sessions always require approval to start',
628
+ effective_action_type: effectiveActionType,
52
629
  };
53
630
  }
54
- // Other browser.* actions with a valid session are handled at the gateway
55
- // level (auto-approved). If they reach the policy engine without a session,
56
- // they should be blocked.
57
- return {
58
- decision: 'BLOCK',
59
- risk_level: 'medium',
60
- reason: 'Browser actions require an active session (use browser.open first)',
61
- };
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.
62
646
  }
63
647
  // MCP tools: list_tools handled via default rules (can be overridden by custom policies)
64
648
  // HTTP tool checks: always enforce domain/method restrictions
@@ -69,7 +653,12 @@ function evaluatePolicy(action, rules) {
69
653
  if (!url) {
70
654
  // SECURITY: HTTP actions without a URL must be blocked at the policy level.
71
655
  // The connector would also reject it, but policy should be the first gate.
72
- return { decision: 'BLOCK', risk_level: 'critical', reason: 'HTTP actions require a URL in payload' };
656
+ return {
657
+ decision: 'BLOCK',
658
+ risk_level: 'critical',
659
+ reason: 'HTTP actions require a URL in payload',
660
+ effective_action_type: effectiveActionType,
661
+ };
73
662
  }
74
663
  if (url) {
75
664
  try {
@@ -82,28 +671,54 @@ function evaluatePolicy(action, rules) {
82
671
  return d === normalizedPattern || d.endsWith('.' + normalizedPattern);
83
672
  };
84
673
  if (httpRules.blockList.some((b) => matchesDomain(domain, b))) {
85
- return { decision: 'BLOCK', risk_level: 'critical', reason: `Domain ${domain} is in block list` };
86
- }
87
- if (httpRules.allowedDomains.length === 0) {
88
- // No allowlist configured: safe default is REQUIRE_APPROVAL, not ALLOW.
89
- // This prevents agents from exfiltrating data to arbitrary domains.
90
674
  return {
91
- decision: 'REQUIRE_APPROVAL',
92
- risk_level,
93
- 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,
94
679
  };
95
680
  }
96
- if (!httpRules.allowedDomains.some((d) => matchesDomain(domain, d))) {
97
- 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
+ }
98
703
  }
99
704
  }
100
705
  catch {
101
- 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
+ };
102
712
  }
103
713
  }
104
714
  const method = action.payload.method?.toUpperCase();
105
715
  if (method && !httpRules.allowedMethods.includes(method)) {
106
- return { decision: 'BLOCK', risk_level, reason: `HTTP method ${method} not allowed` };
716
+ return {
717
+ decision: 'BLOCK',
718
+ risk_level,
719
+ reason: `HTTP method ${method} not allowed`,
720
+ effective_action_type: effectiveActionType,
721
+ };
107
722
  }
108
723
  }
109
724
  if (rules.limits?.maxCostPerAction !== undefined &&
@@ -113,37 +728,99 @@ function evaluatePolicy(action, rules) {
113
728
  decision: 'BLOCK',
114
729
  risk_level: 'high',
115
730
  reason: `Cost estimate ${action.cost_estimate} exceeds limit ${rules.limits.maxCostPerAction}`,
731
+ effective_action_type: effectiveActionType,
116
732
  };
117
733
  }
118
734
  // Most specific: tool-specific rule (case-insensitive to prevent bypasses)
119
735
  let matched = rules.rules.find((r) => r.tool !== undefined && r.tool.toLowerCase() === normalizedTool);
120
- // Then action-type rule
736
+ // Then action-type rule — match against the EFFECTIVE type so a downgraded
737
+ // `read` declaration on `stripe.charge` still hits the `financial` rule.
121
738
  if (!matched)
122
- 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
+ : '';
123
747
  if (matched) {
124
748
  let finalDecision = matched.decision;
125
- // SECURITY: Never auto-allow admin/financial actions even via explicit rules
126
- if (finalDecision === 'ALLOW' && ['admin', 'financial'].includes(action.action_type)) {
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) {
127
764
  finalDecision = 'REQUIRE_APPROVAL';
128
765
  }
129
766
  return {
130
767
  decision: finalDecision,
131
768
  risk_level,
132
- reason: `Matched rule: ${matched.action_type ?? matched.tool}`,
769
+ reason: `Matched rule: ${matched.action_type ?? matched.tool}${downgradeSuffix}`,
133
770
  matched_rule: matched,
771
+ effective_action_type: effectiveActionType,
134
772
  };
135
773
  }
136
774
  const defaultDecision = rules.defaultMode === 'allow' ? 'ALLOW' : rules.defaultMode === 'block' ? 'BLOCK' : 'REQUIRE_APPROVAL';
137
- // SECURITY: Never auto-allow high-risk actions via permissive default mode
138
- if (defaultDecision === 'ALLOW' && ['admin', 'financial'].includes(action.action_type)) {
139
- return { decision: 'REQUIRE_APPROVAL', risk_level, reason: 'High-risk action types always require approval even with permissive 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
+ };
140
792
  }
141
- return { decision: defaultDecision, risk_level, reason: 'Default policy' };
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
+ };
142
809
  }
143
- function buildActionPreview(action) {
144
- const normalizedTool = action.tool.toLowerCase();
145
- 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}`;
146
816
  let target;
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;
147
824
  if (normalizedTool.split('.')[0] === 'http') {
148
825
  const url = action.payload.url;
149
826
  const method = action.payload.method;
@@ -159,17 +836,35 @@ function buildActionPreview(action) {
159
836
  }
160
837
  else if (normalizedTool === 'browser.open') {
161
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 = '';
162
843
  if (url) {
163
844
  try {
164
- target = new URL(url).hostname;
845
+ initialHost = new URL(url).hostname;
165
846
  }
166
847
  catch {
167
- target = url;
848
+ initialHost = url;
168
849
  }
169
- 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}`;
170
863
  }
171
864
  else {
172
- summary = 'Open browser session';
865
+ summary = allowedDomains.length > 0
866
+ ? `Open browser session (allows: ${allowedDomains.join(', ')})`
867
+ : 'Open browser session';
173
868
  }
174
869
  }
175
870
  else if (normalizedTool === 'mcp.list_tools') {
@@ -183,11 +878,184 @@ function buildActionPreview(action) {
183
878
  target = server;
184
879
  summary = `Call "${method ?? 'unknown'}" on MCP server "${server ?? 'unknown'}"`;
185
880
  }
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';
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';
186
946
  return {
187
947
  summary,
188
948
  target,
189
- impact: action.action_type === 'write' ? 'Data will be modified' : undefined,
949
+ impact,
190
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 }),
191
955
  };
192
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
+ }
193
1061
  //# sourceMappingURL=policy.js.map