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.
- package/dist/__tests__/billing.test.d.ts +2 -0
- package/dist/__tests__/billing.test.d.ts.map +1 -0
- package/dist/__tests__/billing.test.js +31 -0
- package/dist/__tests__/billing.test.js.map +1 -0
- package/dist/__tests__/dns-pinning.test.d.ts +2 -0
- package/dist/__tests__/dns-pinning.test.d.ts.map +1 -0
- package/dist/__tests__/dns-pinning.test.js +33 -0
- package/dist/__tests__/dns-pinning.test.js.map +1 -0
- package/dist/__tests__/llm-classifier-cache-store.test.d.ts +2 -0
- package/dist/__tests__/llm-classifier-cache-store.test.d.ts.map +1 -0
- package/dist/__tests__/llm-classifier-cache-store.test.js +65 -0
- package/dist/__tests__/llm-classifier-cache-store.test.js.map +1 -0
- package/dist/__tests__/llm-classifier-cache.test.d.ts +2 -0
- package/dist/__tests__/llm-classifier-cache.test.d.ts.map +1 -0
- package/dist/__tests__/llm-classifier-cache.test.js +44 -0
- package/dist/__tests__/llm-classifier-cache.test.js.map +1 -0
- package/dist/__tests__/llm-classifier.test.d.ts +2 -0
- package/dist/__tests__/llm-classifier.test.d.ts.map +1 -0
- package/dist/__tests__/llm-classifier.test.js +167 -0
- package/dist/__tests__/llm-classifier.test.js.map +1 -0
- package/dist/__tests__/plans-classifier-limits.test.d.ts +2 -0
- package/dist/__tests__/plans-classifier-limits.test.d.ts.map +1 -0
- package/dist/__tests__/plans-classifier-limits.test.js +22 -0
- package/dist/__tests__/plans-classifier-limits.test.js.map +1 -0
- package/dist/__tests__/policy-category-floor.test.d.ts +2 -0
- package/dist/__tests__/policy-category-floor.test.d.ts.map +1 -0
- package/dist/__tests__/policy-category-floor.test.js +46 -0
- package/dist/__tests__/policy-category-floor.test.js.map +1 -0
- package/dist/__tests__/policy-claude-bash.test.d.ts +2 -0
- package/dist/__tests__/policy-claude-bash.test.d.ts.map +1 -0
- package/dist/__tests__/policy-claude-bash.test.js +401 -0
- package/dist/__tests__/policy-claude-bash.test.js.map +1 -0
- package/dist/__tests__/policy-llm-floor.test.d.ts +2 -0
- package/dist/__tests__/policy-llm-floor.test.d.ts.map +1 -0
- package/dist/__tests__/policy-llm-floor.test.js +107 -0
- package/dist/__tests__/policy-llm-floor.test.js.map +1 -0
- package/dist/__tests__/policy-ssh-e2e.test.d.ts +2 -0
- package/dist/__tests__/policy-ssh-e2e.test.d.ts.map +1 -0
- package/dist/__tests__/policy-ssh-e2e.test.js +89 -0
- package/dist/__tests__/policy-ssh-e2e.test.js.map +1 -0
- package/dist/__tests__/policy-ssh-sessions.test.d.ts +2 -0
- package/dist/__tests__/policy-ssh-sessions.test.d.ts.map +1 -0
- package/dist/__tests__/policy-ssh-sessions.test.js +139 -0
- package/dist/__tests__/policy-ssh-sessions.test.js.map +1 -0
- package/dist/__tests__/policy-ssh.test.d.ts +2 -0
- package/dist/__tests__/policy-ssh.test.d.ts.map +1 -0
- package/dist/__tests__/policy-ssh.test.js +180 -0
- package/dist/__tests__/policy-ssh.test.js.map +1 -0
- package/dist/__tests__/policy.test.js +400 -2
- package/dist/__tests__/policy.test.js.map +1 -1
- package/dist/__tests__/redact.test.js +76 -0
- package/dist/__tests__/redact.test.js.map +1 -1
- package/dist/__tests__/signing.test.js +89 -0
- package/dist/__tests__/signing.test.js.map +1 -1
- package/dist/__tests__/ssh-fingerprint.test.d.ts +2 -0
- package/dist/__tests__/ssh-fingerprint.test.d.ts.map +1 -0
- package/dist/__tests__/ssh-fingerprint.test.js +19 -0
- package/dist/__tests__/ssh-fingerprint.test.js.map +1 -0
- package/dist/__tests__/vpn-route.test.d.ts +2 -0
- package/dist/__tests__/vpn-route.test.d.ts.map +1 -0
- package/dist/__tests__/vpn-route.test.js +72 -0
- package/dist/__tests__/vpn-route.test.js.map +1 -0
- package/dist/__tests__/wireguard.test.d.ts +2 -0
- package/dist/__tests__/wireguard.test.d.ts.map +1 -0
- package/dist/__tests__/wireguard.test.js +114 -0
- package/dist/__tests__/wireguard.test.js.map +1 -0
- package/dist/billing.d.ts +12 -0
- package/dist/billing.d.ts.map +1 -0
- package/dist/billing.js +41 -0
- package/dist/billing.js.map +1 -0
- package/dist/crypto.d.ts +5 -0
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js +80 -23
- package/dist/crypto.js.map +1 -1
- package/dist/dns-pinning.d.ts +28 -0
- package/dist/dns-pinning.d.ts.map +1 -0
- package/dist/dns-pinning.js +113 -0
- package/dist/dns-pinning.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/llm-classifier-cache-store.d.ts +49 -0
- package/dist/llm-classifier-cache-store.d.ts.map +1 -0
- package/dist/llm-classifier-cache-store.js +63 -0
- package/dist/llm-classifier-cache-store.js.map +1 -0
- package/dist/llm-classifier-cache.d.ts +6 -0
- package/dist/llm-classifier-cache.d.ts.map +1 -0
- package/dist/llm-classifier-cache.js +52 -0
- package/dist/llm-classifier-cache.js.map +1 -0
- package/dist/llm-classifier.d.ts +29 -0
- package/dist/llm-classifier.d.ts.map +1 -0
- package/dist/llm-classifier.js +191 -0
- package/dist/llm-classifier.js.map +1 -0
- package/dist/observability.d.ts +36 -0
- package/dist/observability.d.ts.map +1 -0
- package/dist/observability.js +75 -0
- package/dist/observability.js.map +1 -0
- package/dist/plans.d.ts +17 -0
- package/dist/plans.d.ts.map +1 -1
- package/dist/plans.js +36 -14
- package/dist/plans.js.map +1 -1
- package/dist/policy.d.ts +173 -3
- package/dist/policy.d.ts.map +1 -1
- package/dist/policy.js +910 -42
- package/dist/policy.js.map +1 -1
- package/dist/redact.d.ts.map +1 -1
- package/dist/redact.js +83 -3
- package/dist/redact.js.map +1 -1
- package/dist/regex-safety.d.ts +21 -0
- package/dist/regex-safety.d.ts.map +1 -0
- package/dist/regex-safety.js +49 -0
- package/dist/regex-safety.js.map +1 -0
- package/dist/sanitize.d.ts +31 -0
- package/dist/sanitize.d.ts.map +1 -0
- package/dist/sanitize.js +54 -0
- package/dist/sanitize.js.map +1 -0
- package/dist/schemas.d.ts +202 -10
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +91 -1
- package/dist/schemas.js.map +1 -1
- package/dist/signing.d.ts +15 -0
- package/dist/signing.d.ts.map +1 -1
- package/dist/signing.js +53 -4
- package/dist/signing.js.map +1 -1
- package/dist/ssh-fingerprint.d.ts +10 -0
- package/dist/ssh-fingerprint.d.ts.map +1 -0
- package/dist/ssh-fingerprint.js +52 -0
- package/dist/ssh-fingerprint.js.map +1 -0
- package/dist/ssrf.d.ts +36 -0
- package/dist/ssrf.d.ts.map +1 -0
- package/dist/ssrf.js +140 -0
- package/dist/ssrf.js.map +1 -0
- package/dist/types.d.ts +130 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/wireguard.d.ts +63 -0
- package/dist/wireguard.d.ts.map +1 -0
- package/dist/wireguard.js +226 -0
- package/dist/wireguard.js.map +1 -0
- package/package.json +42 -29
- package/.turbo/turbo-build.log +0 -4
- package/.turbo/turbo-test.log +0 -76
- package/dist/__tests__/content-crypto.test.d.ts +0 -2
- package/dist/__tests__/content-crypto.test.d.ts.map +0 -1
- package/dist/__tests__/content-crypto.test.js +0 -117
- package/dist/__tests__/content-crypto.test.js.map +0 -1
- package/dist/__tests__/signing.test (# Edit conflict 2026-04-01 z3etfmC #).js +0 -51
- package/dist/__tests__/signing.test.js (# Edit conflict 2026-04-01 4rndy9C #).map +0 -1
- package/dist/content-crypto.d.ts +0 -24
- package/dist/content-crypto.d.ts.map +0 -1
- package/dist/content-crypto.js +0 -58
- package/dist/content-crypto.js.map +0 -1
- package/src/__tests__/crypto.test.ts +0 -169
- package/src/__tests__/messaging.test.ts +0 -83
- package/src/__tests__/policy.test.ts +0 -222
- package/src/__tests__/redact.test.ts +0 -41
- package/src/__tests__/signing.test.ts +0 -55
- package/src/crypto.ts +0 -235
- package/src/index.ts +0 -8
- package/src/mcp-catalog.ts +0 -181
- package/src/plans.ts +0 -116
- package/src/policy.ts +0 -216
- package/src/redact.ts +0 -131
- package/src/schemas.ts +0 -121
- package/src/signing.ts +0 -120
- package/src/types.ts +0 -213
- package/test-gateway.mjs +0 -47
- package/tsconfig.json +0 -10
- 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
|
|
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
|
-
|
|
44
|
-
|
|
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
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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 {
|
|
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: '
|
|
92
|
-
risk_level,
|
|
93
|
-
reason:
|
|
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
|
|
97
|
-
|
|
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 {
|
|
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 {
|
|
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 ===
|
|
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:
|
|
126
|
-
|
|
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:
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
|
145
|
-
|
|
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
|
-
|
|
845
|
+
initialHost = new URL(url).hostname;
|
|
165
846
|
}
|
|
166
847
|
catch {
|
|
167
|
-
|
|
848
|
+
initialHost = url;
|
|
168
849
|
}
|
|
169
|
-
|
|
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 =
|
|
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
|
|
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
|