@solongate/proxy 0.58.0 → 0.59.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/hooks/guard.mjs CHANGED
@@ -1,1604 +1,1626 @@
1
- #!/usr/bin/env node
2
- /**
3
- * SolonGate Cloud Policy Guard Hook (PreToolUse) — GLOBAL system-wide enforcement.
4
- *
5
- * This is the cloud twin of the air-gapped guard hook. Identical decision engine
6
- * (OPA WASM, NIST SP 800-207 PDP, fail-closed), but the policy + compiled WASM
7
- * are fetched from SolonGate Cloud and authenticated with the project API key.
8
- * Installed globally (~/.claude/settings.json) it intercepts EVERY tool call from
9
- * EVERY Claude Code session on the machine — exactly like the air-gapped product,
10
- * just sourced from the cloud instead of a local docker API.
11
- *
12
- * Cloud differences vs. air-gap guard.mjs:
13
- * - API_KEY (sg_live_…/sg_test_…) from env/.env, attached to every API call.
14
- * - API_URL defaults to https://api.solongate.com.
15
- * - Enforcement is gated on the API key (the key identifies the project +
16
- * its active policy), NOT on SOLONGATE_AGENT_ID.
17
- * - No AI Judge and NO gray route: cloud routing is binary, WHITE (allow) /
18
- * BLACK (block). The OPA policy alone decides; nothing is escalated.
19
- *
20
- * Exit code 2 = BLOCK, exit code 0 = ALLOW.
21
- * Logs DENY decisions to SolonGate Cloud. ALLOWs are logged by audit.mjs.
22
- * Auto-installed by: npx @solongate/proxy init --global
23
- */
24
- import { readFileSync, existsSync, statSync, writeFileSync, mkdirSync, chmodSync, renameSync, appendFileSync } from 'node:fs';
25
- import { resolve, join, dirname } from 'node:path';
26
- import { homedir } from 'node:os';
27
- import { gunzipSync } from 'node:zlib';
28
- import { createHash } from 'node:crypto';
29
-
30
- // Bump on every guard.mjs change. The cloud serves the newest bundle + version;
31
- // the installed hook self-updates when the cloud version is higher (see
32
- // maybeSelfUpdate). This is what makes guard fixes propagate without a manual
33
- // reinstall — the same trust model as the OPA WASM this hook already runs.
34
- const HOOK_VERSION = 30;
35
-
36
- // True when local log storage is ON. In that mode logs are kept LOCAL ONLY and
37
- // nothing is sent to the cloud audit log.
38
- function localLogsOnly(security) {
39
- const l = security && security.localLogs;
40
- return !!(l && l.enabled && typeof l.path === 'string' && l.path.trim());
41
- }
42
-
43
- // Local log storage (opt-in): write solongate-audit.jsonl inside the user's
44
- // chosen FOLDER. The audit hook does the ALLOW path; the guard does DENY (a
45
- // blocked call never reaches PostToolUse). `security` is the resolved config.
46
- function writeLocalLog(security, entry) {
47
- try {
48
- const l = security && security.localLogs;
49
- if (!l || !l.enabled || typeof l.path !== 'string' || !l.path.trim()) return;
50
- const dir = l.path.trim().replace(/[\\/]+$/, '');
51
- try { mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
52
- appendFileSync(join(dir, 'solongate-audit.jsonl'), JSON.stringify(entry) + '\n');
53
- } catch { /* best-effort */ }
54
- }
55
-
56
- // Safe file read with size limit (1MB max) to prevent DoS via large files
57
- const MAX_FILE_READ = 1024 * 1024; // 1MB
58
- function safeReadFileSync(filePath, encoding = 'utf-8') {
59
- try {
60
- const stat = statSync(filePath);
61
- if (stat.size > MAX_FILE_READ) return '';
62
- return readFileSync(filePath, encoding);
63
- } catch { return ''; }
64
- }
65
-
66
- // ── Load .env file (Claude Code doesn't load .env into process.env) ──
67
- function loadEnvKey(dir) {
68
- try {
69
- const envPath = resolve(dir, '.env');
70
- if (!existsSync(envPath)) return {};
71
- const lines = readFileSync(envPath, 'utf-8').split('\n');
72
- const env = {};
73
- for (const line of lines) {
74
- const m = line.match(/^([A-Z_]+)=(.*)$/);
75
- if (m) env[m[1]] = m[2].replace(/^["']|["']$/g, '').trim();
76
- }
77
- return env;
78
- } catch { return {}; }
79
- }
80
-
81
- // ── Global cloud config (~/.solongate/cloud-guard.json) ──
82
- // A GLOBAL hook runs from an arbitrary cwd every session, so a project-local
83
- // .env can't be relied on to carry the API key. The global installer writes the
84
- // key + URL here once; this absolute path is read regardless of cwd. Shape:
85
- // { "apiKey": "sg_live_…", "apiUrl": "https://api.solongate.com" }
86
- function loadGlobalCloudConfig() {
87
- try {
88
- const p = resolve(homedir(), '.solongate', 'cloud-guard.json');
89
- if (!existsSync(p)) return {};
90
- const cfg = JSON.parse(readFileSync(p, 'utf-8'));
91
- return (cfg && typeof cfg === 'object') ? cfg : {};
92
- } catch { return {}; }
93
- }
94
-
95
- // A real cloud key is `sg_live_`/`sg_test_` followed by hex (see generateApiKey:
96
- // 24 random bytes → 48 hex chars). Template/placeholder values shipped in sample
97
- // .env files (e.g. `sg_live_your_key_here`) pass a naive truthiness check but are
98
- // bogus — and because resolution prefers a project .env over the global login
99
- // credential, a stray placeholder .env would shadow a valid login and 401 every
100
- // API call, making the guard fail closed on EVERYTHING. Filter to real keys so a
101
- // placeholder is skipped and the next real candidate (usually the login cred in
102
- // cloud-guard.json) is used instead.
103
- function isRealKey(k) {
104
- if (typeof k !== 'string') return false;
105
- const v = k.trim();
106
- if (!/^sg_(live|test)_/.test(v)) return false;
107
- const body = v.replace(/^sg_(live|test)_/, '');
108
- if (/your_key_here|placeholder|example|^x+$/i.test(body)) return false;
109
- return /^[a-f0-9]{16,}$/i.test(body);
110
- }
111
-
112
- function guessPermission(toolName) {
113
- const name = (toolName || '').toLowerCase();
114
- if (name.includes('exec') || name.includes('shell') || name.includes('run') || name.includes('eval') || name === 'bash') return 'EXECUTE';
115
- if (name.includes('fetch') || name.includes('http') || name.includes('request') || name.includes('curl') || name.includes('network') || name.includes('download') || name.includes('upload') || name === 'websearch') return 'NETWORK';
116
- if (name.includes('write') || name.includes('create') || name.includes('delete') || name.includes('update') || name.includes('set') || name.includes('edit') || name.includes('remove') || name.includes('insert')) return 'WRITE';
117
- return 'READ';
118
- }
119
-
120
- const hookCwdEarly = process.cwd();
121
- const dotenv = loadEnvKey(hookCwdEarly);
122
- const globalCfg = loadGlobalCloudConfig();
123
- // Resolution order: process env project-local .env global ~/.solongate
124
- // config. The global config is what makes a system-wide install self-sufficient.
125
- const API_URL = process.env.SOLONGATE_API_URL || dotenv.SOLONGATE_API_URL || globalCfg.apiUrl || 'https://api.solongate.com';
126
- // Cloud API key (sg_live_… / sg_test_…). The key identifies the project AND
127
- // authenticates every API call (active policy, compiled WASM, audit logs). When
128
- // absent, this hook does nothing — a machine with no key is intentionally
129
- // unenforced (the cloud has no policy to apply). Each candidate is filtered
130
- // through isRealKey() so a placeholder .env (sg_live_your_key_here) can't shadow
131
- // the real login credential and force a fail-closed on every call.
132
- const API_KEY = [process.env.SOLONGATE_API_KEY, dotenv.SOLONGATE_API_KEY, globalCfg.apiKey].find(isRealKey) || '';
133
- // Auth headers attached to every cloud API request. Cloud accepts either the
134
- // Authorization: Bearer form or X-API-Key; we send both for robustness.
135
- const AUTH_HEADERS = API_KEY ? { 'Authorization': 'Bearer ' + API_KEY, 'X-API-Key': API_KEY } : {};
136
-
137
- // ── Self-update (best-effort, throttled, integrity-checked) ──
138
- // Once per ~6h the hook asks the cloud for the latest guard bundle. If the cloud
139
- // version is higher AND the sha256 verifies AND the payload looks like this guard
140
- // hook, it atomically replaces its own file. Any failure is swallowed so a bad
141
- // update can never break enforcement — the current code simply keeps running.
142
- // Fetch one hook bundle from the cloud and atomically replace the installed file
143
- // if the served version is newer AND the sha256 verifies AND it looks like the
144
- // right hook. Any failure is swallowed.
145
- async function fetchAndInstallHook(endpoint, fileName, currentVersion, marker, minLen) {
146
- try {
147
- const res = await fetch(API_URL + '/api/v1/hooks/' + endpoint, { headers: AUTH_HEADERS, signal: AbortSignal.timeout(5000) });
148
- if (!res.ok) return;
149
- const data = await res.json();
150
- if (!data || typeof data.version !== 'number' || data.version <= currentVersion) return;
151
- if (typeof data.content !== 'string' || typeof data.sha256 !== 'string') return;
152
- const buf = Buffer.from(data.content, 'base64');
153
- if (createHash('sha256').update(buf).digest('hex') !== data.sha256) return;
154
- const text = buf.toString('utf-8');
155
- if (!text.startsWith('#!/usr/bin/env node') || text.length < minLen || !text.includes(marker)) return;
156
- const hooksDir = join(resolve(homedir(), '.solongate'), 'hooks');
157
- const tmp = join(hooksDir, '.' + fileName + '.tmp');
158
- writeFileSync(tmp, text);
159
- try { chmodSync(join(hooksDir, fileName), 0o644); } catch { /* may be locked read-only */ }
160
- renameSync(tmp, join(hooksDir, fileName)); // atomic swap, takes effect next call
161
- } catch { /* never break enforcement on update failure */ }
162
- }
163
-
164
- // Read the HOOK_VERSION baked into an installed sibling hook (0 if absent/old).
165
- function installedHookVersion(fileName) {
166
- try {
167
- const f = join(resolve(homedir(), '.solongate'), 'hooks', fileName);
168
- const m = (safeReadFileSync(f) || '').match(/HOOK_VERSION\s*=\s*(\d+)/);
169
- return m ? parseInt(m[1], 10) : 0;
170
- } catch { return 0; }
171
- }
172
-
173
- // Latest hook versions the cloud reports on /policies/active (hook_versions).
174
- // Captured during the policy fetch of THIS run (or its short-lived cache); lets
175
- // maybeSelfUpdate() know it is behind and bypass the 6h stamp entirely.
176
- let CLOUD_HOOK_VERSIONS = null;
177
-
178
- function hooksBehindCloud() {
179
- const v = CLOUD_HOOK_VERSIONS;
180
- if (!v || typeof v !== 'object') return false;
181
- if (Number(v.guard) > HOOK_VERSION) return true;
182
- if (Number(v.audit) > installedHookVersion('audit.mjs')) return true;
183
- if (Number(v.shield) > installedHookVersion('shield.mjs')) return true;
184
- return false;
185
- }
186
-
187
- // Once per ~6h: update the guard itself AND its sibling hooks (audit, shield).
188
- // The guard is the only hook that self-updates from the cloud, so it carries the
189
- // others that's why a new audit/shield reaches every device with NO re-login:
190
- // the guard fetches and installs them on its next run.
191
- //
192
- // The 6h stamp only rate-limits the BLIND check. When the policy response says
193
- // the cloud serves a NEWER hook (hook_versions), we update immediately — so a
194
- // fresh release lands on the next executed command, and a stamp refreshed by an
195
- // earlier run (e.g. before the release finished deploying) can't delay it.
196
- async function maybeSelfUpdate() {
197
- if (!API_KEY) return;
198
- try {
199
- const sgDir = resolve(homedir(), '.solongate');
200
- const stamp = join(sgDir, '.hook-update-check');
201
- if (!hooksBehindCloud()) {
202
- const last = parseInt(safeReadFileSync(stamp) || '0', 10);
203
- if (Number.isFinite(last) && Date.now() - last < 6 * 3600 * 1000) return;
204
- }
205
- try { writeFileSync(stamp, String(Date.now())); } catch { /* ignore */ }
206
- // Guard compares to its OWN running version; siblings to their installed file.
207
- await fetchAndInstallHook('guard', 'guard.mjs', HOOK_VERSION, 'SolonGate Cloud Policy Guard', 50000);
208
- await fetchAndInstallHook('audit', 'audit.mjs', installedHookVersion('audit.mjs'), 'SolonGate Audit Hook', 1500);
209
- await fetchAndInstallHook('shield', 'shield.mjs', installedHookVersion('shield.mjs'), 'SolonGate Shield', 1500);
210
- } catch { /* never break enforcement on update failure */ }
211
- }
212
-
213
- // Two distinct identities, deliberately kept separate:
214
- //
215
- // AGENT_TYPE the real AI client running this hook (claude-code /
216
- // gemini-cli / openclaw). Baked into the hook registration by the installer
217
- // as argv[2] (e.g. `node guard.mjs claude-code`). Decides the response
218
- // format AND whether a selected policy actually applies to this client.
219
- //
220
- // POLICY_SELECTOR — set per-terminal via SOLONGATE_AGENT_ID (a policy id
221
- // from the dashboard "Use in terminal" button, or an agent name). Decides
222
- // WHICH policy to load. When unset, no policy is enforced — a plain launch
223
- // is intentionally unrestricted.
224
- const AGENT_TYPE = process.argv[2] || 'claude-code';
225
- const POLICY_SELECTOR = process.env.SOLONGATE_AGENT_ID || '';
226
- const AGENT_ID = POLICY_SELECTOR || AGENT_TYPE;
227
- const AGENT_NAME = process.env.SOLONGATE_AGENT_NAME || process.argv[3] || AGENT_TYPE;
228
-
229
- // ── Per-tool block/allow output ──
230
- // Response format depends on the agent:
231
- // Claude Code: exit 2 + stderr = BLOCK, exit 0 = ALLOW
232
- // Gemini CLI: {"decision": "deny/allow", "reason": "..."}
233
-
234
- function blockTool(reason) {
235
- if (AGENT_TYPE === 'gemini-cli') {
236
- process.stdout.write(JSON.stringify({
237
- decision: 'deny',
238
- reason: `[SolonGate] ${reason}`,
239
- }));
240
- process.exit(0);
241
- } else {
242
- // Claude Code exit code 2
243
- process.stderr.write(reason);
244
- process.exit(2);
245
- }
246
- }
247
-
248
- function allowTool() {
249
- if (AGENT_TYPE === 'gemini-cli') {
250
- process.stdout.write(JSON.stringify({ decision: 'allow' }));
251
- }
252
- process.exit(0);
253
- }
254
-
255
- // Allow the tool but REPLACE its input (Claude Code `updatedInput`). Used by the
256
- // ghost layer to rewrite a listing command so hidden entries are filtered out of
257
- // its output the agent never sees them. Claude Code only.
258
- function rewriteTool(updatedInput) {
259
- process.stdout.write(JSON.stringify({
260
- hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', updatedInput },
261
- }));
262
- process.exit(0);
263
- }
264
-
265
- // Write flag file so stop.mjs knows a tool call (DENY) happened and doesn't log extra ALLOW
266
- function writeDenyFlag(toolName) {
267
- try {
268
- const flagDir = resolve('.solongate');
269
- mkdirSync(flagDir, { recursive: true });
270
- writeFileSync(join(flagDir, '.last-tool-call'), Date.now().toString());
271
- // Write deny-specific flag so audit.mjs can detect and skip duplicate ALLOW logging
272
- writeFileSync(join(flagDir, '.last-deny'), JSON.stringify({ tool: toolName, ts: Date.now() }));
273
- } catch {}
274
- }
275
-
276
- // ── Prompt Injection Detection (Stage 1: Rule-Based) ──
277
- const PI_CATEGORIES = [
278
- {
279
- name: 'delimiter_injection', weight: 0.95,
280
- patterns: [
281
- /<\/system>/i, /<\|im_end\|>/i, /<\|im_start\|>/i, /<\|endoftext\|>/i,
282
- /\[INST\]/i, /\[\/INST\]/i, /<<SYS>>/i, /<<\/SYS>>/i,
283
- /###\s*(Human|Assistant|System)\s*:/i, /<\|user\|>/i, /<\|assistant\|>/i,
284
- /---\s*END\s*SYSTEM\s*PROMPT\s*---/i,
285
- ],
286
- },
287
- {
288
- name: 'instruction_override', weight: 0.9,
289
- patterns: [
290
- /\bignore\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|prompts?|rules?|directives?)\b/i,
291
- /\bdisregard\s+(all\s+)?(previous|prior|above|earlier|your)\s+(instructions?|prompts?|rules?|guidelines?)\b/i,
292
- /\bforget\s+(all\s+|everything\s+)?(your|the|previous|prior|above|earlier)\b/i,
293
- /\boverride\s+(the\s+)?(system|previous|current)\s+(prompt|instructions?|rules?|settings?)\b/i,
294
- /\bdo\s+not\s+follow\s+(your|the|any)\s+(instructions?|rules?|guidelines?)\b/i,
295
- /\bcancel\s+(all\s+)?(prior|previous)\s+(directives?|instructions?)\b/i,
296
- /\bnew\s+instructions?\s+supersede\b/i,
297
- /\byour\s+(previous\s+)?instructions?\s+are\s+(now\s+)?void\b/i,
298
- ],
299
- },
300
- {
301
- name: 'role_hijacking', weight: 0.85,
302
- patterns: [
303
- /\b(pretend|act|behave)\s+(you\s+are|as\s+if\s+you|like\s+you|to\s+be)\b/i,
304
- /\byou\s+are\s+now\s+(a|an|the|my|DAN)\b/i,
305
- /\bsimulate\s+being\b/i, /\bassume\s+the\s+role\s+of\b/i,
306
- /\benter\s+(developer|admin|debug|god|sudo|unrestricted)\s+mode\b/i,
307
- /\bswitch\s+to\s+(unrestricted|unfiltered)\s+mode\b/i,
308
- /\byou\s+are\s+no\s+longer\s+bound\b/i,
309
- /\bno\s+(safety\s+)?restrictions?\s+(apply|anymore|now)\b/i,
310
- ],
311
- },
312
- {
313
- name: 'jailbreak_keywords', weight: 0.8,
314
- patterns: [
315
- /\bjailbreak\b/i, /\bDAN\s+mode\b/i,
316
- /\b(system\s+override|admin\s+mode|debug\s+mode|developer\s+mode|maintenance\s+mode)\b/i,
317
- /\bmaster\s+key\b/i, /\bbackdoor\s+access\b/i,
318
- /\bsudo\s+mode\b/i, /\bgod\s+mode\b/i,
319
- /\bsafety\s+filters?\s+(off|disabled?|removed?)\b/i,
320
- ],
321
- },
322
- {
323
- name: 'encoding_evasion', weight: 0.75,
324
- patterns: [
325
- /\b(decode|translate)\s+(this|the\s+following)\s+(base64|rot13|hex)\b/i,
326
- /\b(base64|rot13)\s*:\s*[A-Za-z0-9+/=]{10,}/i,
327
- /\bexecute\s+the\s+(reverse|decoded)\b/i,
328
- /\breverse\s+of\s*:\s*\w{10,}/i,
329
- ],
330
- },
331
- {
332
- name: 'separator_injection', weight: 0.7,
333
- patterns: [
334
- /[-=]{3,}\s*\n\s*(new\s+instructions?|system|instructions?)\s*:/i,
335
- /```\s*\n\s*<\/?system>/i,
336
- /\bEND\s+(SYSTEM\s+)?(PROMPT|INSTRUCTIONS?)\b.*\bNEW\s+(SYSTEM\s+)?(PROMPT|INSTRUCTIONS?)\b/is,
337
- ],
338
- },
339
- {
340
- name: 'multi_language', weight: 0.7,
341
- patterns: [
342
- /ignor(iere|a|e[zs]?)\s+(alle|todas?|toutes?|tüm|все)/iu,
343
- /игнорируйте/iu, /yoksay/iu,
344
- /vorherigen?\s+Anweisungen/iu, /instrucciones\s+anteriores/iu,
345
- /instructions?\s+pr[eé]c[eé]dentes?/iu, /önceki\s+talimatlar/iu,
346
- ],
347
- },
348
- ];
349
-
350
- function detectPromptInjection(text, customCategories = [], threshold = 0.5) {
351
- const matched = [];
352
- let maxWeight = 0;
353
- const allCategories = [...PI_CATEGORIES, ...customCategories];
354
- for (const cat of allCategories) {
355
- for (const pat of cat.patterns) {
356
- if (pat.test(text)) {
357
- matched.push(cat.name);
358
- if (cat.weight > maxWeight) maxWeight = cat.weight;
359
- break;
360
- }
361
- }
362
- }
363
- if (matched.length === 0) return null;
364
- const score = Math.min(1.0, maxWeight + 0.05 * (matched.length - 1));
365
- const trustScore = 1.0 - score;
366
- const blocked = Math.round(trustScore * 1000) < Math.round(threshold * 1000);
367
- return { score, trustScore, categories: matched, blocked };
368
- }
369
-
370
- // ── Glob Matching ──
371
- function matchGlob(str, pattern) {
372
- if (pattern === '*') return true;
373
- const s = str.toLowerCase();
374
- const p = pattern.toLowerCase();
375
- if (s === p) return true;
376
- const startsW = p.startsWith('*');
377
- const endsW = p.endsWith('*');
378
- if (startsW && endsW) { const infix = p.slice(1, -1); return infix.length > 0 && s.includes(infix); }
379
- if (startsW) return s.endsWith(p.slice(1));
380
- if (endsW) return s.startsWith(p.slice(0, -1));
381
- const idx = p.indexOf('*');
382
- if (idx !== -1) {
383
- const pre = p.slice(0, idx);
384
- const suf = p.slice(idx + 1);
385
- return s.startsWith(pre) && s.endsWith(suf) && s.length >= pre.length + suf.length;
386
- }
387
- return false;
388
- }
389
-
390
- // ── Path Glob (supports **) ──
391
- function matchPathGlob(path, pattern) {
392
- const p = path.replace(/\\/g, '/').toLowerCase();
393
- const g = pattern.replace(/\\/g, '/').toLowerCase();
394
- if (p === g) return true;
395
- if (g.includes('**')) {
396
- const parts = g.split('**').filter(s => s.length > 0);
397
- if (parts.length === 0) return true;
398
- return parts.every(segment => p.includes(segment));
399
- }
400
- return matchGlob(p, g);
401
- }
402
-
403
- // ── Safe Webhook URL Validation (prevent SSRF) ──
404
- function isSafeWebhookUrl(urlStr) {
405
- try {
406
- const u = new URL(urlStr);
407
- if (u.protocol !== 'https:') return false;
408
- const host = u.hostname.toLowerCase();
409
- // Block private/reserved IPs and metadata endpoints
410
- if (host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' || host === '::1') return false;
411
- if (host.startsWith('10.') || host.startsWith('192.168.') || host.startsWith('172.')) return false;
412
- if (host === '169.254.169.254' || host === 'metadata.google.internal') return false;
413
- if (host.endsWith('.internal') || host.endsWith('.local')) return false;
414
- return true;
415
- } catch { return false; }
416
- }
417
-
418
- // ── Safe Regex Validation (prevent ReDoS from cloud-supplied patterns) ──
419
- function isSafeRegex(pattern) {
420
- if (typeof pattern !== 'string' || pattern.length > 512) return false;
421
- // Block nested quantifiers: (a+)+, (a*)+, (a{1,})+, etc.
422
- if (/(\+|\*|\{[^}]+\})\s*(\+|\*|\{[^}]+\})/.test(pattern)) return false;
423
- if (/\([^)]*(\+|\*|\{[^}]+\})[^)]*\)\s*(\+|\*|\{[^}]+\})/.test(pattern)) return false;
424
- // Block excessive alternation groups (>10 alternatives)
425
- if ((pattern.match(/\|/g) || []).length > 10) return false;
426
- try { new RegExp(pattern); return true; } catch { return false; }
427
- }
428
-
429
- // ── Extract Functions (deep scan all string values) ──
430
- function scanStrings(obj) {
431
- const strings = [];
432
- function walk(v) {
433
- if (typeof v === 'string' && v.trim()) strings.push(v.trim());
434
- else if (Array.isArray(v)) v.forEach(walk);
435
- else if (v && typeof v === 'object') Object.values(v).forEach(walk);
436
- }
437
- walk(obj);
438
- return strings;
439
- }
440
-
441
- function looksLikeFilename(s) {
442
- if (s.startsWith('.')) return true;
443
- if (/\.\w+$/.test(s)) return true;
444
- const known = ['id_rsa','id_dsa','id_ecdsa','id_ed25519','authorized_keys','known_hosts','makefile','dockerfile'];
445
- return known.includes(s.toLowerCase());
446
- }
447
-
448
- // Deterministic shell normalizer handles the common bypass tricks BEFORE
449
- // any semantic check, so OPA's literal matcher sees the canonical command.
450
- // Specifically: variable assignment + interpolation, quote concatenation
451
- // (.e""nv, ."env"). Doesn't try to be a full shell — just enough to defeat
452
- // the obfuscation patterns AI judges keep getting wrong non-deterministically.
453
- function normalizeShellCommand(cmd) {
454
- if (typeof cmd !== 'string' || !cmd) return cmd;
455
- const vars = {};
456
- const out = [];
457
- // Split on statement separators (; && ||) but NOT pipes (|).
458
- for (const rawPart of cmd.split(/\s*(?:;|&&|\|\|)\s*/)) {
459
- let part = rawPart;
460
- // Detect var assignment: NAME=value | NAME="value" | NAME='value'
461
- const m = part.match(/^(\w+)=(?:"([^"]*)"|'([^']*)'|([^\s;&|]*))\s*$/);
462
- if (m) {
463
- vars[m[1]] = m[2] ?? m[3] ?? m[4] ?? '';
464
- continue;
465
- }
466
- // Substitute ${var} then $var.
467
- part = part.replace(/\$\{(\w+)\}/g, (_, n) => vars[n] !== undefined ? vars[n] : '${' + n + '}');
468
- part = part.replace(/\$(\w+)/g, (_, n) => vars[n] !== undefined ? vars[n] : '$' + n);
469
- // Collapse quote-concat: a"b"c → abc, .e""nv → .env, ."env" → .env
470
- part = part.replace(/"([^"]*)"/g, '$1').replace(/'([^']*)'/g, '$1');
471
- out.push(part);
472
- }
473
- return out.join('; ');
474
- }
475
-
476
- // Normalize all shell-command-valued fields of an args object before tokenizing.
477
- function normalizeArgs(args) {
478
- if (!args || typeof args !== 'object') return args;
479
- const fields = ['command', 'cmd', 'function', 'script', 'shell'];
480
- const copy = { ...args };
481
- for (const [k, v] of Object.entries(copy)) {
482
- if (fields.includes(k.toLowerCase()) && typeof v === 'string') {
483
- copy[k] = normalizeShellCommand(v);
484
- }
485
- }
486
- return copy;
487
- }
488
-
489
- function extractFilenames(args) {
490
- args = normalizeArgs(args);
491
- const names = new Set();
492
- // Strip surrounding/trailing quotes — `"…/secret.env"` must reduce to
493
- // `secret.env`, not `secret.env"` (a trailing quote breaks the *.env glob).
494
- const dequote = (t) => t.replace(/^["'`]+/, '').replace(/["'`]+$/, '');
495
- for (const s of scanStrings(args)) {
496
- if (/^https?:\/\//i.test(s)) continue;
497
- // Process EVERY whitespace-separated token, not just the last `/` segment of
498
- // the whole string. Multi-file commands (`rm a b c`) must check all of them.
499
- const tokens = s.includes(' ') ? s.split(/\s+/) : [s];
500
- const single = tokens.length === 1;
501
- for (let tok of tokens) {
502
- tok = dequote(tok);
503
- if (!tok || /^https?:\/\//i.test(tok)) continue;
504
- if (tok.includes('/') || tok.includes('\\')) {
505
- const b = dequote(tok.replace(/\\/g, '/').split('/').pop() || '');
506
- if (b && (single || looksLikeFilename(b))) names.add(b);
507
- } else if (looksLikeFilename(tok)) {
508
- names.add(tok);
509
- }
510
- }
511
- }
512
- return [...names];
513
- }
514
-
515
- function extractUrls(args) {
516
- const urls = new Set();
517
- for (const s of scanStrings(args)) {
518
- if (/^https?:\/\//i.test(s)) { urls.add(s); continue; }
519
- if (s.includes(' ')) {
520
- for (const tok of s.split(/\s+/)) {
521
- if (/^https?:\/\//i.test(tok)) urls.add(tok);
522
- }
523
- }
524
- }
525
- return [...urls];
526
- }
527
-
528
- function extractCommands(args) {
529
- args = normalizeArgs(args);
530
- const cmds = [];
531
- const fields = ['command', 'cmd', 'function', 'script', 'shell'];
532
- if (typeof args === 'object' && args) {
533
- for (const [k, v] of Object.entries(args)) {
534
- if (fields.includes(k.toLowerCase()) && typeof v === 'string') {
535
- for (const part of v.split(/\s*(?:&&|\|\||;|\|)\s*/)) {
536
- const trimmed = part.trim();
537
- if (trimmed) cmds.push(trimmed);
538
- }
539
- }
540
- }
541
- }
542
- return cmds;
543
- }
544
-
545
- function extractPaths(args, isExec) {
546
- const paths = [];
547
- const add = (t) => {
548
- if (!t || /^https?:\/\//i.test(t)) return;
549
- // Normalize Windows backslashes to forward slashes so paths match the
550
- // compiled Rego patterns (which are also normalized to "/"). OPA glob.match
551
- // does no separator translation, so raw "C:\..." never matched "/" patterns.
552
- if (t.includes('/') || t.includes('\\') || t.startsWith('.')) paths.push(t.replace(/\\/g, '/'));
553
- };
554
- for (const s of scanStrings(args)) {
555
- if (/^https?:\/\//i.test(s)) continue;
556
- if (isExec && /\s/.test(s)) {
557
- // A command line (exec tool): pull out individual path-like tokens instead
558
- // of treating the whole command as one path. Otherwise `node src/app.js`
559
- // becomes the path "node src/app.js", which no path glob can match — so a
560
- // path-scoped EXECUTE rule would never fire. Tokenizing yields "src/app.js".
561
- for (const tok of s.split(/[\s;|&><()`'"]+/)) add(tok);
562
- } else {
563
- add(s);
564
- }
565
- }
566
- return paths;
567
- }
568
-
569
- // ── Hardcoded Tamper Protection ──
570
- // Runs BEFORE policy evaluation. Cannot be disabled by editing policies.
571
- // Even if all policy rules are removed, these stay enforced.
572
- const TAMPER_GUARD_TOOLS_WRITE = new Set([
573
- 'write', 'edit', 'multiedit', 'notebookedit',
574
- 'create', 'update', 'delete', 'remove', 'move', 'rename', 'copy',
575
- 'filesystem', 'fs_write', 'fs_edit', 'str_replace_editor',
576
- ]);
577
- const TAMPER_GUARD_TOOLS_EXEC = new Set([
578
- 'bash', 'powershell', 'shell', 'exec', 'run', 'eval', 'cmd',
579
- ]);
580
- const TAMPER_HOME = resolve(homedir()).replace(/\\/g, '/').toLowerCase();
581
- const TAMPER_SG = '/.solongate';
582
- const TAMPER_CC = '/.claude';
583
- const TAMPER_PROTECTED_ABS = [
584
- TAMPER_HOME + TAMPER_CC + '/settings.json',
585
- TAMPER_HOME + TAMPER_CC + '/settings.local.json',
586
- TAMPER_HOME + TAMPER_SG + '/hooks',
587
- TAMPER_HOME + TAMPER_SG + '/policy.json',
588
- TAMPER_HOME + TAMPER_SG + '/.policy-cache.json',
589
- // The cloud credential (contains the API key) — never readable via a tool.
590
- TAMPER_HOME + TAMPER_SG + '/cloud-guard.json',
591
- ];
592
- const TAMPER_INSTALL = '/solongate';
593
- const TAMPER_PROTECTED_GLOBS = [
594
- '**' + TAMPER_CC + '/settings.json',
595
- '**' + TAMPER_CC + '/settings.local.json',
596
- '**' + TAMPER_SG + '/hooks/**',
597
- '**' + TAMPER_SG + '/policy.json',
598
- '**' + TAMPER_SG + '/.policy-cache.json',
599
- '**' + TAMPER_SG + '/.policy-cache-*.json',
600
- '**' + TAMPER_SG + '/.pi-config-cache.json',
601
- '**' + TAMPER_SG + '/cloud-guard.json',
602
- '**' + TAMPER_SG + '/.opa-wasm-*.json',
603
- '**' + TAMPER_SG + '/.ratelimit-*.json',
604
- // Persistent host data (DB + audit JSONL) at ~/.solongate/data
605
- '**' + TAMPER_SG + '/data/**',
606
- // Customer install layout (zip extracted as solongate/)
607
- '**' + TAMPER_INSTALL + '/compose/**',
608
- '**' + TAMPER_INSTALL + '/data/**',
609
- '**' + TAMPER_INSTALL + '/images/**',
610
- '**' + TAMPER_INSTALL + '/helm/**',
611
- '**' + TAMPER_INSTALL + '/solongate.exe',
612
- '**' + TAMPER_INSTALL + '/setup.sh',
613
- ];
614
- const TAMPER_BASENAMES = [
615
- 'guard.mjs', 'audit.mjs', 'stop.mjs',
616
- 'policy.json', '.policy-cache.json',
617
- '.pi-config-cache.json', 'cloud-guard.json',
618
- // Customer install: DB and wizard exe
619
- 'solongate.db', 'solongate.exe',
620
- ];
621
- const TAMPER_PATH_FIELDS = new Set([
622
- 'file_path', 'path', 'target_file', 'notebook_path',
623
- 'dest', 'destination', 'source', 'src', 'from', 'to',
624
- 'directory', 'dir', 'folder',
625
- ]);
626
-
627
- function normTamperPath(p) {
628
- return String(p || '').replace(/\\/g, '/').toLowerCase();
629
- }
630
-
631
- function isProtectedPath(p) {
632
- if (!p) return false;
633
- const np = normTamperPath(p);
634
- for (const abs of TAMPER_PROTECTED_ABS) {
635
- if (np === abs || np.startsWith(abs + '/')) return abs;
636
- }
637
- for (const g of TAMPER_PROTECTED_GLOBS) {
638
- if (matchPathGlob(np, g)) return g;
639
- }
640
- if (/\/\.claude\/settings(\.local)?\.json$/.test(np)) return 'settings.json';
641
- if (/\/\.solongate\/hooks(\/|$)/.test(np)) return 'solongate-hooks';
642
- return false;
643
- }
644
-
645
- function commandTargetsProtected(cmd) {
646
- const c = String(cmd || '').toLowerCase();
647
- if (!c) return false;
648
- for (const b of TAMPER_BASENAMES) {
649
- if (c.includes(b.toLowerCase())) return b;
650
- }
651
- if (/\.claude[\\/]+settings(\.local)?\.json/.test(c)) return 'settings.json';
652
- if (/\.solongate[\\/]+hooks/.test(c)) return 'solongate-hooks';
653
- // Customer install dirs
654
- if (/[\\/]solongate[\\/]+(compose|data|images|helm)[\\/]/.test(c)) return 'solongate-install';
655
- // Mutating API calls against policies / audit-logs endpoints
656
- const mutating = /\b(post|put|delete|patch)\b/.test(c) ||
657
- /(-x|--request|-method)\s+(post|put|delete|patch)\b/.test(c);
658
- if (mutating && /api\/v1\/(policies|audit-logs)/.test(c)) return 'api-policies-mutation';
659
- return false;
660
- }
661
-
662
- function extractTargetPaths(args) {
663
- const out = [];
664
- if (typeof args !== 'object' || !args) return out;
665
- for (const [k, v] of Object.entries(args)) {
666
- const lk = k.toLowerCase();
667
- if (TAMPER_PATH_FIELDS.has(lk) && typeof v === 'string') out.push(v);
668
- if (Array.isArray(v)) {
669
- for (const item of v) {
670
- if (item && typeof item === 'object') {
671
- for (const [k2, v2] of Object.entries(item)) {
672
- if (TAMPER_PATH_FIELDS.has(k2.toLowerCase()) && typeof v2 === 'string') out.push(v2);
673
- }
674
- }
675
- }
676
- }
677
- }
678
- return out;
679
- }
680
-
681
- function tamperCheck(toolName, args) {
682
- const tn = String(toolName || '').toLowerCase();
683
- const isExec = TAMPER_GUARD_TOOLS_EXEC.has(tn) || /bash|shell|exec|powershell|cmd|run|eval/.test(tn);
684
- // ANY tool that targets a protected path is blocked — READ as well as write.
685
- // An AI must not even read SolonGate's own protection files. This is enforced
686
- // at the tool boundary; the hooks themselves are run by node directly (not via
687
- // a Claude Code tool), so node still loads/executes them normally.
688
- // Check the tool's TARGET PATH fields only (file_path, path, …) — never the
689
- // free-form content/body, which would false-positive on any file that merely
690
- // mentions a protected path in its text.
691
- for (const p of extractTargetPaths(args)) {
692
- const hit = isProtectedPath(p);
693
- if (hit) return 'Tamper protection: access to "' + p + '" is blocked (protected: ' + hit + ')';
694
- }
695
- if (isExec) {
696
- for (const cmd of extractCommands(args)) {
697
- const hit = commandTargetsProtected(cmd);
698
- if (hit) return 'Tamper protection: command references protected resource "' + hit + '" — blocked';
699
- }
700
- }
701
- return null;
702
- }
703
-
704
- // ── Extra security layers (rate limit, egress allowlist, DLP block) ──
705
- // These are configured per-project in the dashboard and delivered to the guard
706
- // via /policies/active (security). All fail OPEN: any error here returns null
707
- // (allow) so a config glitch never bricks the agent. Tamper protection and
708
- // policy are unaffected and still run.
709
-
710
- // DLP patterns mirror the server's set (apps/api/src/lib/security-layers.ts).
711
- // Mirrors apps/api/src/lib/security-layers.ts DLP_PATTERNS. Provider-specific
712
- // rules plus generic Bearer / secret-assignment catch-alls for the long tail.
713
- const DLP_PATTERNS = [
714
- { name: 'AWS access key', re: /AKIA[0-9A-Z]{16}/ },
715
- { name: 'private key block', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
716
- { name: 'Anthropic key', re: /sk-ant-[A-Za-z0-9_-]{20,}/ },
717
- { name: 'OpenAI key', re: /sk-(proj-)?[A-Za-z0-9_-]{20,}/ },
718
- { name: 'GitHub token', re: /gh[pousr]_[A-Za-z0-9]{20,}/ },
719
- { name: 'GitHub fine-grained PAT', re: /github_pat_[A-Za-z0-9_]{20,}/ },
720
- { name: 'GitLab token', re: /glpat-[A-Za-z0-9_-]{20,}/ },
721
- { name: 'Slack token', re: /xox[baprs]-[A-Za-z0-9-]{10,}/ },
722
- { name: 'Stripe key', re: /[sr]k_(live|test)_[A-Za-z0-9]{20,}/ },
723
- { name: 'SendGrid key', re: /SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/ },
724
- { name: 'Twilio key', re: /SK[0-9a-fA-F]{32}/ },
725
- { name: 'npm token', re: /npm_[A-Za-z0-9]{36}/ },
726
- { name: 'JWT', re: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
727
- { name: 'Bearer token', re: /bearer\s+[A-Za-z0-9._-]{20,}/i },
728
- ];
729
-
730
- // Custom patterns are GLOBs: `*` = any run of non-whitespace, same wildcard
731
- // mechanic as policy/ghost.
732
- function dlpGlobToRe(glob) {
733
- let re = '';
734
- for (const ch of String(glob || '')) {
735
- if (ch === '*') re += '[^\\s]*';
736
- else if ('.+?^${}()|[]\\'.indexOf(ch) !== -1) re += '\\' + ch;
737
- else re += ch;
738
- }
739
- return new RegExp(re);
740
- }
741
- // Scan args against the enabled built-in patterns + any user custom patterns.
742
- // `cfg` = { patterns: string[], custom: {name,re}[] }.
743
- function dlpScan(args, cfg) {
744
- if (!cfg) return null;
745
- let text = '';
746
- try { text = JSON.stringify(args || {}); } catch { return null; }
747
- const allow = new Set(Array.isArray(cfg.patterns) ? cfg.patterns : []);
748
- for (const p of DLP_PATTERNS) {
749
- if (allow.has(p.name) && p.re.test(text)) return p.name;
750
- }
751
- for (const c of Array.isArray(cfg.custom) ? cfg.custom : []) {
752
- try { if (dlpGlobToRe(c.re).test(text)) return c.name || 'custom pattern'; } catch { /* skip invalid */ }
753
- }
754
- return null;
755
- }
756
-
757
- // ── Ghost paths ──
758
- // Files/dirs matching a ghost glob are INVISIBLE to the agent. This module is
759
- // mirrored verbatim in audit.mjs (the PostToolUse twin that strips them from
760
- // output). Keep the two copies in sync.
761
- //
762
- // Split of responsibility:
763
- // - PreToolUse (here): block MUTATIONS (write/edit/delete/move) targeting a
764
- // ghost path, returning a plain "No such file or directory" — never a
765
- // SolonGate/policy message so the path looks like it simply doesn't exist.
766
- // - PostToolUse (audit.mjs): strip ghost entries from listings and rewrite
767
- // direct reads to not-found. Reads are NOT blocked here so the agent gets a
768
- // natural "missing file" rather than a visible hook block.
769
- function ghostGlobToRegExp(glob) {
770
- let re = '';
771
- for (let i = 0; i < glob.length; i++) {
772
- const c = glob[i];
773
- if (c === '*') {
774
- if (glob[i + 1] === '*') { re += '.*'; i++; }
775
- else re += '[^/]*';
776
- } else if (c === '?') re += '[^/]';
777
- else if ('\\^$.|+()[]{}'.indexOf(c) !== -1) re += '\\' + c;
778
- else re += c;
779
- }
780
- try { return new RegExp('^' + re + '$'); } catch { return null; }
781
- }
782
-
783
- // True if `targetPath` is ghosted by any pattern. A bare name (`.data`) matches
784
- // that entry anywhere in the path; a trailing `/` (`secrets/`) ghosts a whole
785
- // directory subtree; a pattern with `/` is matched against the full path.
786
- function ghostMatch(targetPath, patterns) {
787
- if (!targetPath || !Array.isArray(patterns) || patterns.length === 0) return false;
788
- const norm = String(targetPath).replace(/\\/g, '/').replace(/\/+$/, '');
789
- if (!norm) return false;
790
- const segments = norm.split('/').filter(Boolean);
791
- const base = segments.length ? segments[segments.length - 1] : norm;
792
- for (let pat of patterns) {
793
- pat = String(pat || '').trim();
794
- if (!pat) continue;
795
- let dirOnly = false;
796
- if (pat.endsWith('/')) { dirOnly = true; pat = pat.slice(0, -1); }
797
- if (!pat) continue;
798
- const hasSlash = pat.indexOf('/') !== -1;
799
- const hasWild = /[*?]/.test(pat);
800
- const re = ghostGlobToRegExp(pat);
801
- if (!re) continue;
802
- if (dirOnly) {
803
- // Directory: ghost the dir itself and everything under it.
804
- if (!hasSlash && !hasWild) { if (segments.indexOf(pat) !== -1) return true; continue; }
805
- let acc = '';
806
- for (const s of segments) { acc = acc ? acc + '/' + s : s; if (re.test(acc) || re.test(s)) return true; }
807
- continue;
808
- }
809
- if (!hasSlash) {
810
- // Name glob: match basename or any single path segment.
811
- if (re.test(base)) return true;
812
- if (segments.some((s) => re.test(s))) return true;
813
- continue;
814
- }
815
- // Path glob (contains '/'): match the full normalized path.
816
- if (re.test(norm)) return true;
817
- }
818
- return false;
819
- }
820
-
821
- // Strip shell decoration from a token so it can be tested as a path:
822
- // surrounding quotes, redirection operators, trailing punctuation.
823
- function ghostCleanToken(tok) {
824
- let t = String(tok || '').trim();
825
- t = t.replace(/^[<>|;&(]+/, '').replace(/[);&|]+$/, '');
826
- t = t.replace(/^['"]+/, '').replace(/['"]+$/, '');
827
- t = t.replace(/^\d*>>?/, ''); // strip leading redirection like 2>
828
- return t.trim();
829
- }
830
-
831
- // Returns a plain not-found message if a tool DIRECTLY targets a ghost path —
832
- // read OR write else null. Reads are sealed too: a hidden file must be
833
- // inaccessible, not merely unlisted, so `cat A/Y/.data` looks as absent as
834
- // `rm A/Y`. Listing a PARENT dir that only CONTAINS a ghost child is NOT a
835
- // direct hit (no token equals the ghost) and falls through to the listing
836
- // rewrite. Never returns a branded/policy string.
837
- function ghostBlock(toolName, args, ghostCfg) {
838
- if (!ghostCfg || !Array.isArray(ghostCfg.patterns) || ghostCfg.patterns.length === 0) return null;
839
- const pats = ghostCfg.patterns;
840
- const name = (toolName || '');
841
- const notFound = (p) => p + ': No such file or directory';
842
- try {
843
- // Tools that carry an explicit path argument.
844
- if (name === 'Write' || name === 'Edit' || name === 'MultiEdit' || name === 'NotebookEdit' ||
845
- name === 'Read' || name === 'NotebookRead' || name === 'LS') {
846
- const p = args?.file_path || args?.notebook_path || args?.path || '';
847
- if (p && ghostMatch(p, pats)) return notFound(p);
848
- return null;
849
- }
850
- if (name === 'Glob' || name === 'Grep') {
851
- const p = args?.path || '';
852
- const pat = args?.pattern || args?.glob || '';
853
- if (p && ghostMatch(p, pats)) return notFound(p);
854
- if (pat && ghostMatch(pat, pats)) return notFound(String(pat));
855
- return null;
856
- }
857
- // Bash & other exec: deny if any token directly names a ghost path. Seals
858
- // direct reads (cat/head/less/…) and mutations (rm/mv/…) alike. A listing of
859
- // a parent dir has no ghost token and falls through to the rewrite.
860
- if (name === 'Bash' || name === 'BashOutput' || guessPermission(name) === 'EXECUTE') {
861
- const cmd = String(args?.command || '');
862
- if (!cmd) return null;
863
- for (const raw of cmd.split(/\s+/)) {
864
- const tok = ghostCleanToken(raw);
865
- if (tok && tok.indexOf('-') !== 0 && ghostMatch(tok, pats)) return notFound(tok);
866
- }
867
- }
868
- } catch { /* fail open */ }
869
- return null;
870
- }
871
-
872
- // Shell single-quote a string.
873
- function ghostShq(s) { return "'" + String(s).replace(/'/g, "'\\''") + "'"; }
874
-
875
- // If `args.command` is a simple directory listing, return a rewritten command
876
- // that pipes its output through a filter dropping the ghost entries — so the
877
- // agent never sees them. Returns null when there's nothing to rewrite. Only
878
- // touches bare `ls` listings (no pipe/redirect/compound) to stay safe; richer
879
- // output formats are handled by the PostToolUse filter on clients that honor it.
880
- function ghostListingRewrite(args, ghostCfg) {
881
- if (!ghostCfg || !Array.isArray(ghostCfg.patterns) || ghostCfg.patterns.length === 0) return null;
882
- const cmd = String(args?.command || '');
883
- if (!cmd) return null;
884
- if (/[|>;&\n`]/.test(cmd)) return null; // no shell composition
885
- if (!/^\s*(ls|ll|dir|find|tree|exa|lsd|fd)(\s|$)/.test(cmd)) return null;
886
- // Translate each hidden glob to an ERE alternative, then match it as a whole
887
- // path component, the whole line, or the trailing token — covers `ls`,
888
- // `ls -la`, `find` and `tree`. A trailing slash (dir) is dropped.
889
- const alts = [];
890
- for (let p of ghostCfg.patterns) {
891
- p = String(p).replace(/\/$/, '');
892
- if (!p) continue;
893
- let re = '';
894
- for (let i = 0; i < p.length; i++) {
895
- const c = p[i];
896
- if (c === '*') { if (p[i + 1] === '*') { re += '.*'; i++; } else re += '[^/]*'; }
897
- else if (c === '?') re += '[^/]';
898
- else if ('.^$+(){}[]|\\/'.indexOf(c) >= 0) re += '\\' + c;
899
- else re += c;
900
- }
901
- alts.push(re);
902
- }
903
- if (alts.length === 0) return null;
904
- // grep -vE drops any line where a hidden name appears as a path component, the
905
- // whole line, or the final token. Single-quoted so the shell leaves it intact.
906
- const ere = '(^|/| )(' + alts.join('|') + ')(/|$)';
907
- return cmd + " | grep -vE '" + ere.replace(/'/g, "'\\''") + "'";
908
- }
909
-
910
- // Multi-window sliding rate limit, persisted under ~/.solongate (tamper-protected
911
- // from the agent, writable by the guard). One timestamps file per agent, pruned
912
- // to the last 24h and capped for performance; counts this agent's calls within
913
- // each enabled window (minute/hour/day). Returns the exceeded window or null.
914
- const RL_WINDOWS = [
915
- { key: 'perDay', ms: 86400000, label: 'day' },
916
- { key: 'perHour', ms: 3600000, label: 'hour' },
917
- { key: 'perMinute', ms: 60000, label: 'minute' },
918
- ];
919
- function rateLimitCheck(agentKey, limits) {
920
- try {
921
- const file = join(resolve(homedir(), '.solongate'), '.ratelimit-' + agentKey + '.json');
922
- const now = Date.now();
923
- let stamps = [];
924
- if (existsSync(file)) {
925
- try { stamps = JSON.parse(readFileSync(file, 'utf-8')); } catch { stamps = []; }
926
- }
927
- if (!Array.isArray(stamps)) stamps = [];
928
- // Prune to the longest window (24h) and cap size to bound work/IO.
929
- stamps = stamps.filter((t) => typeof t === 'number' && now - t < 86400000);
930
- if (stamps.length > 50000) stamps = stamps.slice(-50000);
931
- // Check each enabled window against the current count (before adding now).
932
- for (const w of RL_WINDOWS) {
933
- const limit = limits[w.key];
934
- if (limit > 0) {
935
- const count = stamps.reduce((n, t) => (now - t < w.ms ? n + 1 : n), 0);
936
- if (count >= limit) return { window: w.label, limit };
937
- }
938
- }
939
- stamps.push(now);
940
- try { writeFileSync(file, JSON.stringify(stamps)); } catch {}
941
- return null;
942
- } catch {
943
- return null; // fail open
944
- }
945
- }
946
-
947
- // Runs all enabled enforcement layers; returns a deny reason or null (allow).
948
- function securityLayerCheck(toolName, args, cfg, agentKey) {
949
- if (!cfg) return null;
950
- try {
951
- if (cfg.dlpBlock) {
952
- const hit = dlpScan(args, cfg.dlpBlock);
953
- if (hit) return 'Security layer (DLP): blocked - arguments contain a ' + hit +
954
- '. Blocked by SolonGate - check your dashboard for details.';
955
- }
956
- if (cfg.rateLimit) {
957
- const hit = rateLimitCheck(agentKey, cfg.rateLimit);
958
- if (hit) {
959
- return 'Security layer (rate limit): exceeded ' + hit.limit + ' calls/' + hit.window +
960
- ' for this agent. Blocked by SolonGate - check your dashboard to review or adjust the limit.';
961
- }
962
- }
963
- } catch { /* fail open */ }
964
- return null;
965
- }
966
-
967
- // ── Policy Evaluation ──
968
-
969
- // Permission filter: a rule with rule.permission set only applies to tool
970
- // calls whose guessed permission category is in that list. Empty/missing =
971
- // applies to all categories.
972
- function permissionApplies(rule, toolName) {
973
- if (!rule.permission) return true;
974
- const perms = Array.isArray(rule.permission) ? rule.permission : [rule.permission];
975
- if (perms.length === 0) return true;
976
- const guessed = guessPermission(toolName);
977
- return perms.includes(guessed);
978
- }
979
-
980
- // Returns the first pattern that any of the rule's constraints matches against
981
- // the args, or null if nothing matches. Used for both DENY (engine blocks on
982
- // match) and ALLOW (whitelist mode requires at least one match).
983
- // Each constraint may store its pattern list in either `denied` or `allowed`
984
- // depending on which effect the rule was created with in the dashboard. The
985
- // hook treats both as the same "pattern list" — the rule's effect determines
986
- // whether a match means block (DENY) or pass (ALLOW in whitelist mode).
987
- function patternsOf(constraint) {
988
- if (!constraint) return null;
989
- const list = constraint.denied || constraint.allowed;
990
- return Array.isArray(list) && list.length > 0 ? list : null;
991
- }
992
-
993
- function ruleMatches(rule, args, isExec) {
994
- const fnPats = patternsOf(rule.filenameConstraints);
995
- if (fnPats) {
996
- const filenames = extractFilenames(args);
997
- for (const fn of filenames) {
998
- for (const pat of fnPats) {
999
- if (matchGlob(fn, pat)) return { kind: 'filename', value: fn, pattern: pat };
1000
- }
1001
- }
1002
- }
1003
- const urlPats = patternsOf(rule.urlConstraints);
1004
- if (urlPats) {
1005
- const urls = extractUrls(args);
1006
- for (const url of urls) {
1007
- for (const pat of urlPats) {
1008
- if (matchGlob(url, pat)) return { kind: 'URL', value: url, pattern: pat };
1009
- }
1010
- }
1011
- }
1012
- const cmdPats = patternsOf(rule.commandConstraints);
1013
- if (cmdPats) {
1014
- const cmds = extractCommands(args);
1015
- for (const cmd of cmds) {
1016
- for (const pat of cmdPats) {
1017
- if (matchGlob(cmd, pat)) return { kind: 'command', value: cmd.slice(0, 60), pattern: pat };
1018
- }
1019
- }
1020
- }
1021
- const pathPats = patternsOf(rule.pathConstraints);
1022
- if (pathPats) {
1023
- const paths = extractPaths(args, isExec);
1024
- for (const p of paths) {
1025
- for (const pat of pathPats) {
1026
- if (matchPathGlob(p, pat)) return { kind: 'path', value: p, pattern: pat };
1027
- }
1028
- }
1029
- }
1030
- return null;
1031
- }
1032
-
1033
- // Evaluate policy. Two modes:
1034
- // denylist (default): default ALLOW. Any DENY rule that matches → block.
1035
- // whitelist (strict): default DENY. Must match at least one ALLOW rule to
1036
- // pass. DENY rules still override on top.
1037
- function evaluate(policy, args, toolName) {
1038
- if (!policy || !policy.rules) return null;
1039
- const enabledRules = policy.rules.filter(r => r.enabled !== false);
1040
- const mode = policy.mode === 'whitelist' ? 'whitelist' : 'denylist';
1041
- const isExec = /bash|shell|exec|powershell|cmd|run|eval/.test((toolName || '').toLowerCase());
1042
-
1043
- // DENY pass — runs in both modes. DENY wins over ALLOW.
1044
- const denyRules = enabledRules
1045
- .filter(r => r.effect === 'DENY' && permissionApplies(r, toolName))
1046
- .sort((a, b) => (a.priority || 100) - (b.priority || 100));
1047
- for (const rule of denyRules) {
1048
- const m = ruleMatches(rule, args, isExec);
1049
- if (m) return 'Blocked by policy: ' + m.kind + ' "' + m.value + '" matches "' + m.pattern + '"';
1050
- }
1051
-
1052
- // Whitelist pass — only in strict mode. Must match at least one ALLOW rule.
1053
- if (mode === 'whitelist') {
1054
- const allowRules = enabledRules.filter(r => r.effect === 'ALLOW' && permissionApplies(r, toolName));
1055
- if (allowRules.length === 0) {
1056
- return 'Blocked by policy: strict whitelist mode is on and no ALLOW rule applies to ' + (toolName || 'this tool');
1057
- }
1058
- let matched = false;
1059
- for (const rule of allowRules) {
1060
- if (ruleMatches(rule, args, isExec)) { matched = true; break; }
1061
- }
1062
- if (!matched) {
1063
- return 'Blocked by policy: strict whitelist mode — request does not match any ALLOW rule';
1064
- }
1065
- }
1066
-
1067
- return null;
1068
- }
1069
-
1070
- // ── OPA WASM Evaluation (NIST SP 800-207 PDP) ──
1071
- //
1072
- // When the API has compiled this policy to an OPA WASM bundle AND the
1073
- // @open-policy-agent/opa-wasm runtime is resolvable, we evaluate through OPA
1074
- // instead of the hand-written evaluate() above. This is the same decision
1075
- // engine the MCP proxy uses (packages/policy-engine/src/opa).
1076
- //
1077
- // Graceful degradation is the contract: any missing piece (no bundle, no
1078
- // runtime, fetch/parse/eval error) makes evaluateWithOpa() return `undefined`,
1079
- // and the caller falls back to the legacy JS evaluate() — so air-gapped
1080
- // installs without OPA see ZERO behavior change.
1081
-
1082
- // Cheap, dependency-free djb2 fingerprint to detect policy changes for caching.
1083
- function djb2(str) {
1084
- let h = 5381;
1085
- for (let i = 0; i < str.length; i++) h = ((h << 5) + h + str.charCodeAt(i)) | 0;
1086
- return (h >>> 0).toString(36);
1087
- }
1088
-
1089
- // Extracts /policy.wasm from an OPA bundle. Mirrors
1090
- // packages/policy-engine/src/opa/opa-evaluator.ts extractWasmFromBundle().
1091
- // The bundle may be a gzipped tar (.tar.gz from `opa build -t wasm`) or raw WASM.
1092
- function extractWasmFromBundle(buf) {
1093
- if (buf[0] === 0x1f && buf[1] === 0x8b) {
1094
- const tar = gunzipSync(buf);
1095
- let offset = 0;
1096
- while (offset < tar.length - 512) {
1097
- const nameEnd = tar.indexOf(0, offset);
1098
- const name = tar.subarray(offset, Math.min(nameEnd, offset + 100)).toString('utf-8');
1099
- if (!name || name.length === 0) break;
1100
- const sizeStr = tar.subarray(offset + 124, offset + 136).toString('utf-8').trim();
1101
- const size = parseInt(sizeStr, 8) || 0;
1102
- offset += 512;
1103
- if (name === 'policy.wasm' || name === './policy.wasm' || name.endsWith('/policy.wasm')) {
1104
- return Buffer.from(tar.subarray(offset, offset + size));
1105
- }
1106
- offset += Math.ceil(size / 512) * 512;
1107
- }
1108
- throw new Error('policy.wasm not found in OPA bundle');
1109
- }
1110
- if (buf[0] === 0x00 && buf[1] === 0x61 && buf[2] === 0x73 && buf[3] === 0x6d) {
1111
- return buf; // already raw WASM
1112
- }
1113
- throw new Error('Unknown OPA bundle format');
1114
- }
1115
-
1116
- // Fetches the compiled WASM for this policy from the API, with a local cache
1117
- // keyed by a fingerprint of the policy so updates propagate. Returns the raw
1118
- // policy.wasm bytes (Uint8Array) or null when unavailable.
1119
- const OPA_WASM_TTL_MS = 30_000;
1120
- async function getOpaWasmBytes(policy) {
1121
- if (!policy || !policy.id) return null;
1122
- const fp = djb2(JSON.stringify(policy.rules || []) + '|' + (policy.mode || ''));
1123
- const agentKey = (AGENT_ID || 'default').replace(/[^a-zA-Z0-9_-]/g, '_');
1124
- const cacheFile = join(resolve(homedir(), '.solongate'), '.opa-wasm-' + agentKey + '.json');
1125
-
1126
- // Read any cached bundle. A "fresh" hit (same policy fingerprint, within TTL)
1127
- // is returned immediately; otherwise we keep it as `stale` to fall back on if
1128
- // the API is momentarily unreachable — so transient downtime never drops OPA.
1129
- let stale = null;
1130
- try {
1131
- if (existsSync(cacheFile)) {
1132
- const c = JSON.parse(readFileSync(cacheFile, 'utf-8'));
1133
- if (c && c.wasm) {
1134
- stale = new Uint8Array(Buffer.from(c.wasm, 'base64'));
1135
- if (c.fp === fp && c._ts && Date.now() - c._ts < OPA_WASM_TTL_MS) {
1136
- return stale;
1137
- }
1138
- }
1139
- }
1140
- } catch {}
1141
-
1142
- // Fetch the compiled bundle from the API (route already exists).
1143
- try {
1144
- const res = await fetch(
1145
- API_URL + '/api/v1/policies/' + encodeURIComponent(policy.id) + '/wasm',
1146
- { headers: AUTH_HEADERS, signal: AbortSignal.timeout(8000) },
1147
- );
1148
- if (!res.ok) return stale; // API has no compiled WASM right now → last known good
1149
- const bundle = Buffer.from(await res.arrayBuffer());
1150
- const wasm = extractWasmFromBundle(bundle);
1151
- try {
1152
- mkdirSync(resolve(homedir(), '.solongate'), { recursive: true });
1153
- writeFileSync(cacheFile, JSON.stringify({ _ts: Date.now(), fp, wasm: Buffer.from(wasm).toString('base64') }));
1154
- } catch {}
1155
- return new Uint8Array(wasm);
1156
- } catch {
1157
- return stale; // transient network failure last known good
1158
- }
1159
- }
1160
-
1161
- // Lazily load the opa-wasm runtime. Returns the loadPolicy fn or null if the
1162
- // package isn't installed in this environment (typical for air-gapped hooks).
1163
- let _loadPolicyFn = null;
1164
- let _loadPolicyTried = false;
1165
- async function getLoadPolicy() {
1166
- if (_loadPolicyTried) return _loadPolicyFn;
1167
- _loadPolicyTried = true;
1168
- try {
1169
- const mod = await import('@open-policy-agent/opa-wasm');
1170
- _loadPolicyFn = mod.loadPolicy || (mod.default && mod.default.loadPolicy) || null;
1171
- } catch {
1172
- _loadPolicyFn = null;
1173
- }
1174
- return _loadPolicyFn;
1175
- }
1176
-
1177
- // Evaluates the policy through OPA WASM. Returns:
1178
- // - a reason string → DENY
1179
- // - null → ALLOW (OPA decided, no violation)
1180
- // - undefined → OPA unavailable, caller must fall back to evaluate()
1181
- async function evaluateWithOpa(policy, args, toolName, cwd) {
1182
- if (!policy || !policy.rules) return undefined;
1183
- try {
1184
- const loadPolicy = await getLoadPolicy();
1185
- if (!loadPolicy) return undefined;
1186
- const wasmBytes = await getOpaWasmBytes(policy);
1187
- if (!wasmBytes) return undefined;
1188
-
1189
- const opaPolicy = await loadPolicy(wasmBytes, { initial: 5 });
1190
- // trust_level is fixed to 'TRUSTED' to preserve legacy guard.mjs behavior,
1191
- // which never evaluated minimumTrustLevel constraints.
1192
- // If the tool call references files (bash X.sh, source X, etc.), inline
1193
- // their contents so the SAME deterministic extractors see hidden commands.
1194
- // The hook reads files itself; OPA gets a flat, expanded view — no LLM
1195
- // needed for hidden-in-file detection at this layer.
1196
- // Inline referenced-file CONTENT only for tools that EXECUTE a script
1197
- // (`bash X.sh` → X.sh would run, so its contents matter). For read/write
1198
- // tools the file is data, not code — inlining its content there causes false
1199
- // positives (e.g. reading a file that merely mentions ".env" tripping an
1200
- // *.env rule, or reading a script that documents `rm -rf`).
1201
- const isExecTool = /bash|shell|exec|powershell|cmd|run|eval/.test((toolName || '').toLowerCase());
1202
- const refFiles = (isExecTool && typeof readReferencedFiles === 'function')
1203
- ? readReferencedFiles(args, cwd || process.cwd())
1204
- : {};
1205
- const expandedArgs = { ...((args && typeof args === 'object') ? args : {}) };
1206
- for (const [, content] of Object.entries(refFiles)) {
1207
- const lines = String(content).split('\n')
1208
- .map(l => l.trim())
1209
- .filter(l => l && !l.startsWith('#'));
1210
- if (lines.length > 0) {
1211
- const extra = lines.join('; ');
1212
- if (typeof expandedArgs.command === 'string') {
1213
- expandedArgs.command = expandedArgs.command + '; ' + extra;
1214
- } else {
1215
- expandedArgs.command = extra;
1216
- }
1217
- }
1218
- }
1219
- // Matching a filename/URL/path that appears in a tool BODY (content,
1220
- // new_string, text, …) only makes sense for EXEC tools, where that text would
1221
- // RUN. For read/write tools the body is data, not access — writing a doc that
1222
- // merely mentions a secret-file pattern is not accessing one. So strip body
1223
- // fields before extracting access targets; the command fields (what actually
1224
- // executes) are always scanned via extractCommands.
1225
- // (isExecTool already computed above for the referenced-file inlining gate.)
1226
- // For NON-exec tools, only the explicit path/target fields are an "access" —
1227
- // arbitrary text fields (a question, a description, a file body) are data, not
1228
- // access, and must not be matched against filename/path/url rules. So scan an
1229
- // ALLOWLIST of target fields only. Exec tools scan the full command instead.
1230
- // Includes network/url-bearing fields (url, uri, …) so non-exec network
1231
- // tools (Fetch/WebFetch) keep their access target — otherwise the url field
1232
- // is stripped here, input.urls comes out empty, and urlConstraints DENY
1233
- // rules never match (a fetch to a blocked host slips through).
1234
- const ACCESS_FIELDS = new Set(['file_path', 'path', 'target_file', 'notebook_path', 'filename', 'dest', 'destination', 'source', 'src', 'from', 'to', 'directory', 'dir', 'folder', 'url', 'urls', 'uri', 'href', 'link', 'endpoint']);
1235
- let accessArgs = expandedArgs;
1236
- if (!isExecTool && expandedArgs && typeof expandedArgs === 'object') {
1237
- accessArgs = {};
1238
- for (const [k, v] of Object.entries(expandedArgs)) {
1239
- if (ACCESS_FIELDS.has(k.toLowerCase())) accessArgs[k] = v;
1240
- }
1241
- }
1242
- const input = {
1243
- tool_name: toolName || '',
1244
- permission: guessPermission(toolName),
1245
- trust_level: 'TRUSTED',
1246
- arguments: expandedArgs,
1247
- paths: extractPaths(accessArgs, isExecTool),
1248
- commands: extractCommands(expandedArgs),
1249
- urls: extractUrls(accessArgs),
1250
- filenames: extractFilenames(accessArgs),
1251
- };
1252
- if (process.env.SOLONGATE_DEBUG) {
1253
- }
1254
- const results = opaPolicy.evaluate(input);
1255
- const decision = results && results[0] && results[0].result;
1256
- if (!decision || !decision.effect) return null;
1257
-
1258
- // The generated Rego always has `default decision := DENY` (whitelist
1259
- // semantics). We must re-apply the policy mode here so denylist policies
1260
- // keep their default-ALLOW behavior, matching legacy evaluate():
1261
- // - denylist: default-allow block ONLY when a DENY rule actually
1262
- // matched (matched_rule != null). Default DENY means "no rule matched".
1263
- // - whitelist: default-deny → block on any DENY (default or matched).
1264
- // Routing per policy mode semantics:
1265
- //
1266
- // DENYLIST (default-allow):
1267
- // DENY match → BLACK (block)
1268
- // no match → WHITE (default-allow, skip AI Judge — this IS the
1269
- // semantics of denylist: "block these, allow the rest")
1270
- // REVIEW match → GRAY (only this explicit effect calls AI Judge)
1271
- //
1272
- // WHITELIST (default-deny):
1273
- // ALLOW match → WHITE (skip AI Judge)
1274
- // DENY match → BLACK
1275
- // REVIEW match → GRAY
1276
- // no match → BLACK (default-deny)
1277
- //
1278
- // AI Judge runs ONLY when a rule explicitly says "this needs semantic
1279
- // review" — never as a fallback for "I'm not sure". That keeps token cost
1280
- // proportional to actual ambiguity and avoids running the model on every
1281
- // routine call.
1282
- const mode = policy.mode === 'whitelist' ? 'whitelist' : 'denylist';
1283
- const matched = decision.matched_rule != null;
1284
- const eff = decision.effect;
1285
- if (mode === 'denylist') {
1286
- if (eff === 'DENY' && matched) return '[SolonGate OPA] ' + (decision.reason || 'Blocked by policy');
1287
- if (eff === 'REVIEW' && matched) return { white: false, reason: decision.reason, ruleId: decision.matched_rule };
1288
- return { white: true, ruleId: matched ? decision.matched_rule : null };
1289
- }
1290
- // whitelist
1291
- if (eff === 'DENY' && matched) return '[SolonGate OPA] ' + (decision.reason || 'Blocked by policy');
1292
- if (eff === 'REVIEW' && matched) return { white: false, reason: decision.reason, ruleId: decision.matched_rule };
1293
- if (eff === 'ALLOW' && matched) return { white: true, ruleId: decision.matched_rule };
1294
- return '[SolonGate OPA] ' + (decision.reason || 'Blocked by policy: no ALLOW rule matched');
1295
- } catch {
1296
- return undefined; // any failure fall back to legacy evaluator
1297
- }
1298
- }
1299
-
1300
- // ── Main ──
1301
- let input = '';
1302
- // Read the contents of files a tool call references, so the AI Judge can see a
1303
- // command HIDDEN inside a script/file (e.g. `bash deploy.sh`). Bounded: at most
1304
- // a few small text files. Returns { name: content }.
1305
- function readReferencedFiles(args, cwd) {
1306
- const out = {};
1307
- const MAX_FILES = 3, MAX_BYTES = 65536;
1308
- const cands = new Set();
1309
- // Only inline a file that is actually EXECUTED by an interpreter `bash x.sh`,
1310
- // `python x.py`, `source x`, `. x`. A file that is merely an argument (rm/cp/cat
1311
- // x, or a read/write target) is NOT run, so its content must NOT be scanned —
1312
- // otherwise deleting a file whose text mentions a blocked name would false-block.
1313
- const INTERP = /^(?:bash|sh|zsh|ksh|dash|ash|python3?|node|deno|bun|ruby|perl|php|pwsh|powershell|source|\.)$/i;
1314
- if (args && typeof args === 'object') {
1315
- for (const f of ['command', 'cmd', 'script', 'shell', 'code']) {
1316
- const v = args[f];
1317
- if (typeof v !== 'string') continue;
1318
- const toks = v.split(/[\s'"();|&<>]+/).filter(Boolean);
1319
- for (let i = 0; i < toks.length - 1; i++) {
1320
- if (!INTERP.test(toks[i])) continue;
1321
- // The first non-flag token after the interpreter is the script it runs.
1322
- let j = i + 1;
1323
- while (j < toks.length && toks[j].startsWith('-')) j++;
1324
- if (j < toks.length) cands.add(toks[j]);
1325
- }
1326
- }
1327
- }
1328
- let n = 0;
1329
- for (const c of cands) {
1330
- if (n >= MAX_FILES) break;
1331
- try {
1332
- const p = resolve(cwd || process.cwd(), c);
1333
- if (!existsSync(p)) continue;
1334
- const st = statSync(p);
1335
- if (!st.isFile() || st.size > MAX_BYTES) continue;
1336
- out[c] = readFileSync(p, 'utf-8').slice(0, MAX_BYTES);
1337
- n++;
1338
- } catch {}
1339
- }
1340
- return out;
1341
- }
1342
-
1343
- process.stdin.on('data', c => input += c);
1344
- process.stdin.on('end', async () => {
1345
- // No policy selected => no enforcement. A plain launch (no SOLONGATE_AGENT_ID)
1346
- // is intentionally unrestricted.
1347
- if (process.env.SOLONGATE_DEBUG) {
1348
- }
1349
- // Cloud gate: the API key IS the policy selector — it identifies the project
1350
- // and its active policy. No key → nothing to enforce → allow. (Air-gap gated
1351
- // on SOLONGATE_AGENT_ID instead; here the key does that job.)
1352
- if (!API_KEY) {
1353
- allowTool();
1354
- return;
1355
- }
1356
- const _evalStart = Date.now();
1357
- try {
1358
- const raw = JSON.parse(input);
1359
-
1360
- // Debug: append guard invocation to a cwd-local log. Opt-in only — set
1361
- // SOLONGATE_DEBUG=1 to enable. Off by default so it doesn't litter every
1362
- // working directory with .solongate/.debug-guard-log.
1363
- if (process.env.SOLONGATE_DEBUG) {
1364
- try {
1365
- const { appendFileSync: afs, mkdirSync: mds } = await import('node:fs');
1366
- mds(resolve('.solongate'), { recursive: true });
1367
- const debugLine = JSON.stringify({ ts: new Date().toISOString(), hook: 'guard', argv: process.argv.slice(2), tool_name: raw.tool_name || raw.toolName || raw.command, agent_id: AGENT_ID }) + '\n';
1368
- afs(resolve('.solongate', '.debug-guard-log'), debugLine);
1369
- } catch {}
1370
- }
1371
-
1372
- let mappedToolName = raw.tool_name || raw.toolName || '';
1373
- let mappedToolInput = raw.tool_input || raw.toolInput || raw.params || {};
1374
-
1375
- // Normalize field names across tools
1376
- const data = {
1377
- ...raw,
1378
- tool_name: mappedToolName,
1379
- tool_input: mappedToolInput,
1380
- tool_response: raw.tool_response || raw.toolResponse || {},
1381
- cwd: raw.cwd || process.cwd(),
1382
- session_id: raw.session_id || raw.sessionId || raw.conversation_id || '',
1383
- };
1384
- const args = data.tool_input;
1385
- const toolName = data.tool_name || '';
1386
-
1387
- // (self-protection + PI hook layers removed per project decision)
1388
-
1389
- // Load policy. Priority:
1390
- // 1. Dashboard-managed policy (GET /api/v1/policies/active, cached 10s)
1391
- // 2. Local policy.json next to cwd
1392
- // The dashboard is the source of truth — local policy.json is only a
1393
- // fallback for when the API is unreachable.
1394
- const hookCwd = data.cwd || process.cwd();
1395
- let policy;
1396
- // Self-protection (tamper guard) defaults ON. The cloud per-project setting
1397
- // can turn it off; delivered via /policies/active and cached alongside the
1398
- // policy. Any failure to read it leaves protection ON (fail safe).
1399
- let selfProtectEnabled = true;
1400
- // Extra security layers (rate limit, egress, DLP block) delivered by the
1401
- // cloud. Null = none configured. Fail open if unread.
1402
- let securityCfg = null;
1403
- // Cache keyed by agent_id so different agents in different terminals
1404
- // don't share a stale cached policy.
1405
- const agentKey = (AGENT_ID || 'default').replace(/[^a-zA-Z0-9_-]/g, '_');
1406
- const policyCacheFile = join(resolve(homedir(), '.solongate'), '.policy-cache-' + agentKey + '.json');
1407
- const POLICY_TTL_MS = 3_000;
1408
- try {
1409
- let dashboardPolicy = null;
1410
- // Try cache first
1411
- try {
1412
- if (existsSync(policyCacheFile)) {
1413
- const cached = JSON.parse(readFileSync(policyCacheFile, 'utf-8'));
1414
- if (cached && cached._ts && Date.now() - cached._ts < POLICY_TTL_MS) {
1415
- if (cached.policy) dashboardPolicy = cached.policy;
1416
- if (typeof cached.selfProtect === 'boolean') selfProtectEnabled = cached.selfProtect;
1417
- if (cached.security !== undefined) securityCfg = cached.security;
1418
- if (cached.hookVersions) CLOUD_HOOK_VERSIONS = cached.hookVersions;
1419
- }
1420
- }
1421
- } catch {}
1422
- // Refresh from API if cache expired
1423
- if (!dashboardPolicy) {
1424
- try {
1425
- // Report our installed version (hv) so the dashboard can show whether
1426
- // this guard is on the latest build.
1427
- const res = await fetch(API_URL + '/api/v1/policies/active?agent_id=' + encodeURIComponent(AGENT_ID || '') + '&hv=' + HOOK_VERSION, { headers: AUTH_HEADERS, signal: AbortSignal.timeout(8000) });
1428
- if (res.ok) {
1429
- const body = await res.json();
1430
- // Capture the self-protection flag even when no cloud policy is set.
1431
- if (typeof body?.self_protection_enabled === 'boolean') selfProtectEnabled = body.self_protection_enabled;
1432
- if (body?.security !== undefined) securityCfg = body.security;
1433
- if (body?.hook_versions && typeof body.hook_versions === 'object') CLOUD_HOOK_VERSIONS = body.hook_versions;
1434
- if (body && body.policy) dashboardPolicy = body.policy;
1435
- try { writeFileSync(policyCacheFile, JSON.stringify({ _ts: Date.now(), policy: dashboardPolicy || null, selfProtect: selfProtectEnabled, security: securityCfg, hookVersions: CLOUD_HOOK_VERSIONS })); } catch {}
1436
- }
1437
- } catch {}
1438
- }
1439
-
1440
- if (process.env.SOLONGATE_DEBUG) {
1441
- }
1442
- if (dashboardPolicy) {
1443
- policy = dashboardPolicy;
1444
- } else {
1445
- // Fall back to ~/.solongate/policy.json (where the wizard writes the
1446
- // default), then a per-project policy.json next to cwd.
1447
- const candidates = [
1448
- join(resolve(homedir(), '.solongate'), 'policy.json'),
1449
- resolve(hookCwd, 'policy.json'),
1450
- ];
1451
- for (const p of candidates) {
1452
- if (existsSync(p)) {
1453
- try { policy = JSON.parse(readFileSync(p, 'utf-8')); break; } catch {}
1454
- }
1455
- }
1456
- }
1457
- } catch {
1458
- // Couldn't load any policy — leave policy undefined; evaluate() returns null.
1459
- }
1460
-
1461
- if (process.env.SOLONGATE_DEBUG) {
1462
- }
1463
- // Agent scoping
1464
- {
1465
- const scope = (policy && Array.isArray(policy.agents) && policy.agents.length > 0)
1466
- ? policy.agents
1467
- : ['*'];
1468
- if (!scope.includes('*') && !scope.includes(AGENT_TYPE)) {
1469
- allowTool();
1470
- return;
1471
- }
1472
- }
1473
-
1474
- if (process.env.SOLONGATE_DEBUG) {
1475
- }
1476
- // Tamper / self-protection — runs before policy eval. ON by default; the
1477
- // per-project cloud setting can disable it (fail safe: stays on if unread).
1478
- let reason = selfProtectEnabled ? tamperCheck(toolName, args) : null;
1479
- // Ghost paths — handled BEFORE the other layers and emitted as a STEALTH
1480
- // block: a mutating op on a hidden path is denied with a bare OS-style
1481
- // "No such file or directory" and NOTHING else (no ROUTE line, no SolonGate
1482
- // wording), so the agent can't tell the path is protected — it just looks
1483
- // absent. (Reads/listings aren't blocked; the PostToolUse hook strips them.)
1484
- if (!reason && securityCfg && securityCfg.ghost) {
1485
- const ghostHit = ghostBlock(toolName, args, securityCfg.ghost);
1486
- if (ghostHit) {
1487
- try { writeDenyFlag(toolName); } catch {}
1488
- writeLocalLog(securityCfg, { ts: new Date().toISOString(), tool: toolName, arguments: args, decision: 'DENY', reason: 'ghost path (hidden from agent)', permission: guessPermission(toolName), source: `${AGENT_TYPE}-guard`, agent_id: AGENT_TYPE, agent_name: AGENT_NAME, session_id: data.session_id || '', evaluation_time_ms: Date.now() - _evalStart });
1489
- try {
1490
- if (!localLogsOnly(securityCfg)) await fetch(API_URL + '/api/v1/audit-logs', {
1491
- method: 'POST',
1492
- headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
1493
- body: JSON.stringify({
1494
- tool: toolName, arguments: args, decision: 'DENY',
1495
- reason: 'ghost path (hidden from agent)',
1496
- permission: guessPermission(toolName),
1497
- source: `${AGENT_TYPE}-guard`, agent_id: AGENT_TYPE, agent_name: AGENT_NAME,
1498
- session_id: data.session_id || '',
1499
- evaluation_time_ms: Date.now() - _evalStart,
1500
- }),
1501
- signal: AbortSignal.timeout(3000),
1502
- });
1503
- } catch {}
1504
- await maybeSelfUpdate();
1505
- if (AGENT_TYPE === 'gemini-cli') { process.stdout.write(JSON.stringify({ decision: 'deny', reason: ghostHit })); process.exit(0); }
1506
- process.stderr.write(ghostHit);
1507
- process.exit(2);
1508
- }
1509
- // No direct hit: if this is a listing command, rewrite it so hidden
1510
- // entries are filtered out of its output (Claude Code only).
1511
- if (AGENT_TYPE !== 'gemini-cli' && toolName === 'Bash') {
1512
- const rw = ghostListingRewrite(args, securityCfg.ghost);
1513
- if (rw) { await maybeSelfUpdate(); rewriteTool({ command: rw }); }
1514
- }
1515
- }
1516
- // Extra security layers run after tamper, before policy. Block reason wins
1517
- // immediately (BLACK). Fail-open by design.
1518
- if (!reason) reason = securityLayerCheck(toolName, args, securityCfg, agentKey);
1519
- if (process.env.SOLONGATE_DEBUG) {
1520
- }
1521
- // OPA WASM is the SOLE policy engine. With no policy configured for this
1522
- // agent we skip evaluation entirely (allow). With a policy present,
1523
- // evaluateWithOpa returns a reason (DENY), null (ALLOW), or undefined when
1524
- // the WASM bundle could not be obtained at all — in which case we fall back
1525
- // to the policy mode's default (whitelist → fail closed, denylist → fail
1526
- // open); see the branch below. (The legacy JS evaluate() below is retained
1527
- // but no longer on the decision path OPA decides everything.)
1528
- // Cloud routing is BINARY — WHITE (allow) / BLACK (block). There is NO AI
1529
- // Judge in the cloud (that is an air-gap-only feature), so there is no GRAY
1530
- // "send to the judge" lane: the OPA policy alone decides. Tamper protection
1531
- // and any DENY (incl. fail-closed) BLACK; everything else WHITE. A REVIEW
1532
- // rule with no judge to escalate to is treated as allow under denylist.
1533
- let opaRoute = 'white';
1534
- if (reason) {
1535
- opaRoute = 'black'; // hardcoded tamper protection blocked it
1536
- } else if (policy && policy.rules) {
1537
- const opaResult = await evaluateWithOpa(policy, args, toolName, hookCwd);
1538
- if (opaResult === undefined) {
1539
- // OPA produced no decision (no WASM bundle yet, runtime missing, fetch
1540
- // error). This happens on COLD START — the first call(s) in a session
1541
- // before the policy + WASM are cached. Don't leave an enforcement gap:
1542
- // run the deterministic, WASM-free JS evaluator so DENY rules (e.g.
1543
- // secret-file protection) and whitelist defaults apply IMMEDIATELY, from
1544
- // the very first call. evaluate() implements both modes:
1545
- // - returns a deny reason block (DENY match, or whitelist no-match)
1546
- // - returns null → allow (denylist default / whitelist match)
1547
- // This closes the "worked, but late" window where a denylist policy used
1548
- // to fail OPEN until WASM warmed up.
1549
- const legacy = evaluate(policy, args, toolName);
1550
- if (typeof legacy === 'string') {
1551
- reason = legacy;
1552
- opaRoute = 'black';
1553
- } else {
1554
- opaRoute = 'white';
1555
- }
1556
- } else if (typeof opaResult === 'string') {
1557
- reason = opaResult; // explicit DENY
1558
- opaRoute = 'black';
1559
- } else {
1560
- opaRoute = 'white'; // allow (rule match, default-allow, or review w/o judge)
1561
- }
1562
- }
1563
-
1564
- process.stderr.write(`[SolonGate ROUTE] ${opaRoute.toUpperCase()} (${reason ? 'block' : 'allow'})\n`);
1565
-
1566
- // Hand the measured policy-eval time to the audit hook: PostToolUse logs the
1567
- // ALLOW path and can't time the guard itself, so it reads this file back.
1568
- // Keyed by tool + session so the audit hook can match THIS invocation even
1569
- // when the tool itself runs for minutes (a bare timestamp TTL lost those).
1570
- try { const _fd = resolve('.solongate'); mkdirSync(_fd, { recursive: true }); writeFileSync(join(_fd, '.last-eval'), JSON.stringify({ ms: Date.now() - _evalStart, ts: Date.now(), tool: toolName, session: data.session_id || '' })); } catch {}
1571
-
1572
- // Only log DENY decisions from guard hook.
1573
- // ALLOW decisions are logged by the audit hook (PostToolUse) to avoid double-counting.
1574
- if (reason) {
1575
- if (true) {
1576
- try {
1577
- const logEntry = {
1578
- tool: toolName, arguments: args,
1579
- decision: 'DENY', reason,
1580
- permission: guessPermission(toolName),
1581
- source: `${AGENT_TYPE}-guard`,
1582
- agent_id: AGENT_TYPE, agent_name: AGENT_NAME,
1583
- session_id: data.session_id || '',
1584
- evaluation_time_ms: Date.now() - _evalStart,
1585
- };
1586
- writeLocalLog(securityCfg, { ts: new Date().toISOString(), ...logEntry });
1587
- // PI hook layer removed — piResult fields no longer attached.
1588
- // Local-only mode: keep the log on the user's machine, skip the cloud.
1589
- if (!localLogsOnly(securityCfg)) await fetch(API_URL + '/api/v1/audit-logs', {
1590
- method: 'POST',
1591
- headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
1592
- body: JSON.stringify(logEntry),
1593
- signal: AbortSignal.timeout(3000),
1594
- });
1595
- } catch {}
1596
- }
1597
- writeDenyFlag(toolName);
1598
- await maybeSelfUpdate();
1599
- blockTool(reason);
1600
- }
1601
- } catch {}
1602
- await maybeSelfUpdate();
1603
- allowTool();
1604
- });
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SolonGate Cloud Policy Guard Hook (PreToolUse) — GLOBAL system-wide enforcement.
4
+ *
5
+ * This is the cloud twin of the air-gapped guard hook. Identical decision engine
6
+ * (OPA WASM, NIST SP 800-207 PDP, fail-closed), but the policy + compiled WASM
7
+ * are fetched from SolonGate Cloud and authenticated with the project API key.
8
+ * Installed globally (~/.claude/settings.json) it intercepts EVERY tool call from
9
+ * EVERY Claude Code session on the machine — exactly like the air-gapped product,
10
+ * just sourced from the cloud instead of a local docker API.
11
+ *
12
+ * Cloud differences vs. air-gap guard.mjs:
13
+ * - API_KEY (sg_live_…/sg_test_…) from env/.env, attached to every API call.
14
+ * - API_URL defaults to https://api.solongate.com.
15
+ * - Enforcement is gated on the API key (the key identifies the project +
16
+ * its active policy), NOT on SOLONGATE_AGENT_ID.
17
+ * - No AI Judge and NO gray route: cloud routing is binary, WHITE (allow) /
18
+ * BLACK (block). The OPA policy alone decides; nothing is escalated.
19
+ *
20
+ * Exit code 2 = BLOCK, exit code 0 = ALLOW.
21
+ * Logs DENY decisions to SolonGate Cloud. ALLOWs are logged by audit.mjs.
22
+ * Auto-installed by: npx @solongate/proxy init --global
23
+ */
24
+ import { readFileSync, existsSync, statSync, writeFileSync, mkdirSync, chmodSync, renameSync, appendFileSync } from 'node:fs';
25
+ import { resolve, join, dirname, isAbsolute } from 'node:path';
26
+ import { homedir } from 'node:os';
27
+ import { gunzipSync } from 'node:zlib';
28
+ import { createHash } from 'node:crypto';
29
+
30
+ // Bump on every guard.mjs change. The cloud serves the newest bundle + version;
31
+ // the installed hook self-updates when the cloud version is higher (see
32
+ // maybeSelfUpdate). This is what makes guard fixes propagate without a manual
33
+ // reinstall — the same trust model as the OPA WASM this hook already runs.
34
+ const HOOK_VERSION = 30;
35
+
36
+ // True when local log storage is ON. In that mode logs are kept LOCAL ONLY and
37
+ // nothing is sent to the cloud audit log.
38
+ function localLogsOnly(security) {
39
+ const l = security && security.localLogs;
40
+ return !!(l && l.enabled && typeof l.path === 'string' && l.path.trim());
41
+ }
42
+
43
+ // Resolve the FOLDER local logs may be written into. It MUST be absolute on
44
+ // THIS machine. A relative path e.g. a Windows "C:/Users/…" path evaluated on
45
+ // Linux, where Node treats it as relative — would be created under the agent's
46
+ // current working directory and pollute whatever project it happens to run in
47
+ // (that's how stray "…/C:/Users/HP/solongate-logs" folders appear inside repos).
48
+ // When the configured path isn't absolute here, fall back to a fixed home folder
49
+ // so entries are never lost and never leak into a project, and record the bad
50
+ // path so the dashboard/user can be told their path isn't valid on this device.
51
+ function resolveLocalLogDir(rawPath) {
52
+ const dir = String(rawPath || '').trim().replace(/[\\/]+$/, '');
53
+ if (!dir) return null;
54
+ if (isAbsolute(dir)) return dir;
55
+ const fallback = resolve(homedir(), '.solongate', 'local-logs');
56
+ try {
57
+ mkdirSync(resolve(homedir(), '.solongate'), { recursive: true });
58
+ writeFileSync(resolve(homedir(), '.solongate', '.local-logs-invalid-path'),
59
+ JSON.stringify({ configured: dir, fallback, ts: Date.now() }));
60
+ } catch { /* ignore */ }
61
+ return fallback;
62
+ }
63
+
64
+ // Local log storage (opt-in): write solongate-audit.jsonl inside the user's
65
+ // chosen FOLDER. The audit hook does the ALLOW path; the guard does DENY (a
66
+ // blocked call never reaches PostToolUse). `security` is the resolved config.
67
+ function writeLocalLog(security, entry) {
68
+ try {
69
+ const l = security && security.localLogs;
70
+ if (!l || !l.enabled || typeof l.path !== 'string' || !l.path.trim()) return;
71
+ const dir = resolveLocalLogDir(l.path);
72
+ if (!dir) return;
73
+ try { mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
74
+ appendFileSync(join(dir, 'solongate-audit.jsonl'), JSON.stringify(entry) + '\n');
75
+ } catch { /* best-effort */ }
76
+ }
77
+
78
+ // Safe file read with size limit (1MB max) to prevent DoS via large files
79
+ const MAX_FILE_READ = 1024 * 1024; // 1MB
80
+ function safeReadFileSync(filePath, encoding = 'utf-8') {
81
+ try {
82
+ const stat = statSync(filePath);
83
+ if (stat.size > MAX_FILE_READ) return '';
84
+ return readFileSync(filePath, encoding);
85
+ } catch { return ''; }
86
+ }
87
+
88
+ // ── Load .env file (Claude Code doesn't load .env into process.env) ──
89
+ function loadEnvKey(dir) {
90
+ try {
91
+ const envPath = resolve(dir, '.env');
92
+ if (!existsSync(envPath)) return {};
93
+ const lines = readFileSync(envPath, 'utf-8').split('\n');
94
+ const env = {};
95
+ for (const line of lines) {
96
+ const m = line.match(/^([A-Z_]+)=(.*)$/);
97
+ if (m) env[m[1]] = m[2].replace(/^["']|["']$/g, '').trim();
98
+ }
99
+ return env;
100
+ } catch { return {}; }
101
+ }
102
+
103
+ // ── Global cloud config (~/.solongate/cloud-guard.json) ──
104
+ // A GLOBAL hook runs from an arbitrary cwd every session, so a project-local
105
+ // .env can't be relied on to carry the API key. The global installer writes the
106
+ // key + URL here once; this absolute path is read regardless of cwd. Shape:
107
+ // { "apiKey": "sg_live_…", "apiUrl": "https://api.solongate.com" }
108
+ function loadGlobalCloudConfig() {
109
+ try {
110
+ const p = resolve(homedir(), '.solongate', 'cloud-guard.json');
111
+ if (!existsSync(p)) return {};
112
+ const cfg = JSON.parse(readFileSync(p, 'utf-8'));
113
+ return (cfg && typeof cfg === 'object') ? cfg : {};
114
+ } catch { return {}; }
115
+ }
116
+
117
+ // A real cloud key is `sg_live_`/`sg_test_` followed by hex (see generateApiKey:
118
+ // 24 random bytes → 48 hex chars). Template/placeholder values shipped in sample
119
+ // .env files (e.g. `sg_live_your_key_here`) pass a naive truthiness check but are
120
+ // bogus and because resolution prefers a project .env over the global login
121
+ // credential, a stray placeholder .env would shadow a valid login and 401 every
122
+ // API call, making the guard fail closed on EVERYTHING. Filter to real keys so a
123
+ // placeholder is skipped and the next real candidate (usually the login cred in
124
+ // cloud-guard.json) is used instead.
125
+ function isRealKey(k) {
126
+ if (typeof k !== 'string') return false;
127
+ const v = k.trim();
128
+ if (!/^sg_(live|test)_/.test(v)) return false;
129
+ const body = v.replace(/^sg_(live|test)_/, '');
130
+ if (/your_key_here|placeholder|example|^x+$/i.test(body)) return false;
131
+ return /^[a-f0-9]{16,}$/i.test(body);
132
+ }
133
+
134
+ function guessPermission(toolName) {
135
+ const name = (toolName || '').toLowerCase();
136
+ if (name.includes('exec') || name.includes('shell') || name.includes('run') || name.includes('eval') || name === 'bash') return 'EXECUTE';
137
+ if (name.includes('fetch') || name.includes('http') || name.includes('request') || name.includes('curl') || name.includes('network') || name.includes('download') || name.includes('upload') || name === 'websearch') return 'NETWORK';
138
+ if (name.includes('write') || name.includes('create') || name.includes('delete') || name.includes('update') || name.includes('set') || name.includes('edit') || name.includes('remove') || name.includes('insert')) return 'WRITE';
139
+ return 'READ';
140
+ }
141
+
142
+ const hookCwdEarly = process.cwd();
143
+ const dotenv = loadEnvKey(hookCwdEarly);
144
+ const globalCfg = loadGlobalCloudConfig();
145
+ // Resolution order: process env project-local .env → global ~/.solongate
146
+ // config. The global config is what makes a system-wide install self-sufficient.
147
+ const API_URL = process.env.SOLONGATE_API_URL || dotenv.SOLONGATE_API_URL || globalCfg.apiUrl || 'https://api.solongate.com';
148
+ // Cloud API key (sg_live_… / sg_test_…). The key identifies the project AND
149
+ // authenticates every API call (active policy, compiled WASM, audit logs). When
150
+ // absent, this hook does nothing a machine with no key is intentionally
151
+ // unenforced (the cloud has no policy to apply). Each candidate is filtered
152
+ // through isRealKey() so a placeholder .env (sg_live_your_key_here) can't shadow
153
+ // the real login credential and force a fail-closed on every call.
154
+ const API_KEY = [process.env.SOLONGATE_API_KEY, dotenv.SOLONGATE_API_KEY, globalCfg.apiKey].find(isRealKey) || '';
155
+ // Auth headers attached to every cloud API request. Cloud accepts either the
156
+ // Authorization: Bearer form or X-API-Key; we send both for robustness.
157
+ const AUTH_HEADERS = API_KEY ? { 'Authorization': 'Bearer ' + API_KEY, 'X-API-Key': API_KEY } : {};
158
+
159
+ // ── Self-update (best-effort, throttled, integrity-checked) ──
160
+ // Once per ~6h the hook asks the cloud for the latest guard bundle. If the cloud
161
+ // version is higher AND the sha256 verifies AND the payload looks like this guard
162
+ // hook, it atomically replaces its own file. Any failure is swallowed so a bad
163
+ // update can never break enforcement — the current code simply keeps running.
164
+ // Fetch one hook bundle from the cloud and atomically replace the installed file
165
+ // if the served version is newer AND the sha256 verifies AND it looks like the
166
+ // right hook. Any failure is swallowed.
167
+ async function fetchAndInstallHook(endpoint, fileName, currentVersion, marker, minLen) {
168
+ try {
169
+ const res = await fetch(API_URL + '/api/v1/hooks/' + endpoint, { headers: AUTH_HEADERS, signal: AbortSignal.timeout(5000) });
170
+ if (!res.ok) return;
171
+ const data = await res.json();
172
+ if (!data || typeof data.version !== 'number' || data.version <= currentVersion) return;
173
+ if (typeof data.content !== 'string' || typeof data.sha256 !== 'string') return;
174
+ const buf = Buffer.from(data.content, 'base64');
175
+ if (createHash('sha256').update(buf).digest('hex') !== data.sha256) return;
176
+ const text = buf.toString('utf-8');
177
+ if (!text.startsWith('#!/usr/bin/env node') || text.length < minLen || !text.includes(marker)) return;
178
+ const hooksDir = join(resolve(homedir(), '.solongate'), 'hooks');
179
+ const tmp = join(hooksDir, '.' + fileName + '.tmp');
180
+ writeFileSync(tmp, text);
181
+ try { chmodSync(join(hooksDir, fileName), 0o644); } catch { /* may be locked read-only */ }
182
+ renameSync(tmp, join(hooksDir, fileName)); // atomic swap, takes effect next call
183
+ } catch { /* never break enforcement on update failure */ }
184
+ }
185
+
186
+ // Read the HOOK_VERSION baked into an installed sibling hook (0 if absent/old).
187
+ function installedHookVersion(fileName) {
188
+ try {
189
+ const f = join(resolve(homedir(), '.solongate'), 'hooks', fileName);
190
+ const m = (safeReadFileSync(f) || '').match(/HOOK_VERSION\s*=\s*(\d+)/);
191
+ return m ? parseInt(m[1], 10) : 0;
192
+ } catch { return 0; }
193
+ }
194
+
195
+ // Latest hook versions the cloud reports on /policies/active (hook_versions).
196
+ // Captured during the policy fetch of THIS run (or its short-lived cache); lets
197
+ // maybeSelfUpdate() know it is behind and bypass the 6h stamp entirely.
198
+ let CLOUD_HOOK_VERSIONS = null;
199
+
200
+ function hooksBehindCloud() {
201
+ const v = CLOUD_HOOK_VERSIONS;
202
+ if (!v || typeof v !== 'object') return false;
203
+ if (Number(v.guard) > HOOK_VERSION) return true;
204
+ if (Number(v.audit) > installedHookVersion('audit.mjs')) return true;
205
+ if (Number(v.shield) > installedHookVersion('shield.mjs')) return true;
206
+ return false;
207
+ }
208
+
209
+ // Once per ~6h: update the guard itself AND its sibling hooks (audit, shield).
210
+ // The guard is the only hook that self-updates from the cloud, so it carries the
211
+ // others — that's why a new audit/shield reaches every device with NO re-login:
212
+ // the guard fetches and installs them on its next run.
213
+ //
214
+ // The 6h stamp only rate-limits the BLIND check. When the policy response says
215
+ // the cloud serves a NEWER hook (hook_versions), we update immediately — so a
216
+ // fresh release lands on the next executed command, and a stamp refreshed by an
217
+ // earlier run (e.g. before the release finished deploying) can't delay it.
218
+ async function maybeSelfUpdate() {
219
+ if (!API_KEY) return;
220
+ try {
221
+ const sgDir = resolve(homedir(), '.solongate');
222
+ const stamp = join(sgDir, '.hook-update-check');
223
+ if (!hooksBehindCloud()) {
224
+ const last = parseInt(safeReadFileSync(stamp) || '0', 10);
225
+ if (Number.isFinite(last) && Date.now() - last < 6 * 3600 * 1000) return;
226
+ }
227
+ try { writeFileSync(stamp, String(Date.now())); } catch { /* ignore */ }
228
+ // Guard compares to its OWN running version; siblings to their installed file.
229
+ await fetchAndInstallHook('guard', 'guard.mjs', HOOK_VERSION, 'SolonGate Cloud Policy Guard', 50000);
230
+ await fetchAndInstallHook('audit', 'audit.mjs', installedHookVersion('audit.mjs'), 'SolonGate Audit Hook', 1500);
231
+ await fetchAndInstallHook('shield', 'shield.mjs', installedHookVersion('shield.mjs'), 'SolonGate Shield', 1500);
232
+ } catch { /* never break enforcement on update failure */ }
233
+ }
234
+
235
+ // Two distinct identities, deliberately kept separate:
236
+ //
237
+ // AGENT_TYPE — the real AI client running this hook (claude-code /
238
+ // gemini-cli / openclaw). Baked into the hook registration by the installer
239
+ // as argv[2] (e.g. `node guard.mjs claude-code`). Decides the response
240
+ // format AND whether a selected policy actually applies to this client.
241
+ //
242
+ // POLICY_SELECTOR set per-terminal via SOLONGATE_AGENT_ID (a policy id
243
+ // from the dashboard "Use in terminal" button, or an agent name). Decides
244
+ // WHICH policy to load. When unset, no policy is enforced — a plain launch
245
+ // is intentionally unrestricted.
246
+ const AGENT_TYPE = process.argv[2] || 'claude-code';
247
+ const POLICY_SELECTOR = process.env.SOLONGATE_AGENT_ID || '';
248
+ const AGENT_ID = POLICY_SELECTOR || AGENT_TYPE;
249
+ const AGENT_NAME = process.env.SOLONGATE_AGENT_NAME || process.argv[3] || AGENT_TYPE;
250
+
251
+ // ── Per-tool block/allow output ──
252
+ // Response format depends on the agent:
253
+ // Claude Code: exit 2 + stderr = BLOCK, exit 0 = ALLOW
254
+ // Gemini CLI: {"decision": "deny/allow", "reason": "..."}
255
+
256
+ function blockTool(reason) {
257
+ if (AGENT_TYPE === 'gemini-cli') {
258
+ process.stdout.write(JSON.stringify({
259
+ decision: 'deny',
260
+ reason: `[SolonGate] ${reason}`,
261
+ }));
262
+ process.exit(0);
263
+ } else {
264
+ // Claude Code — exit code 2
265
+ process.stderr.write(reason);
266
+ process.exit(2);
267
+ }
268
+ }
269
+
270
+ function allowTool() {
271
+ if (AGENT_TYPE === 'gemini-cli') {
272
+ process.stdout.write(JSON.stringify({ decision: 'allow' }));
273
+ }
274
+ process.exit(0);
275
+ }
276
+
277
+ // Allow the tool but REPLACE its input (Claude Code `updatedInput`). Used by the
278
+ // ghost layer to rewrite a listing command so hidden entries are filtered out of
279
+ // its output — the agent never sees them. Claude Code only.
280
+ function rewriteTool(updatedInput) {
281
+ process.stdout.write(JSON.stringify({
282
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', updatedInput },
283
+ }));
284
+ process.exit(0);
285
+ }
286
+
287
+ // Write flag file so stop.mjs knows a tool call (DENY) happened and doesn't log extra ALLOW
288
+ function writeDenyFlag(toolName) {
289
+ try {
290
+ const flagDir = resolve('.solongate');
291
+ mkdirSync(flagDir, { recursive: true });
292
+ writeFileSync(join(flagDir, '.last-tool-call'), Date.now().toString());
293
+ // Write deny-specific flag so audit.mjs can detect and skip duplicate ALLOW logging
294
+ writeFileSync(join(flagDir, '.last-deny'), JSON.stringify({ tool: toolName, ts: Date.now() }));
295
+ } catch {}
296
+ }
297
+
298
+ // ── Prompt Injection Detection (Stage 1: Rule-Based) ──
299
+ const PI_CATEGORIES = [
300
+ {
301
+ name: 'delimiter_injection', weight: 0.95,
302
+ patterns: [
303
+ /<\/system>/i, /<\|im_end\|>/i, /<\|im_start\|>/i, /<\|endoftext\|>/i,
304
+ /\[INST\]/i, /\[\/INST\]/i, /<<SYS>>/i, /<<\/SYS>>/i,
305
+ /###\s*(Human|Assistant|System)\s*:/i, /<\|user\|>/i, /<\|assistant\|>/i,
306
+ /---\s*END\s*SYSTEM\s*PROMPT\s*---/i,
307
+ ],
308
+ },
309
+ {
310
+ name: 'instruction_override', weight: 0.9,
311
+ patterns: [
312
+ /\bignore\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|prompts?|rules?|directives?)\b/i,
313
+ /\bdisregard\s+(all\s+)?(previous|prior|above|earlier|your)\s+(instructions?|prompts?|rules?|guidelines?)\b/i,
314
+ /\bforget\s+(all\s+|everything\s+)?(your|the|previous|prior|above|earlier)\b/i,
315
+ /\boverride\s+(the\s+)?(system|previous|current)\s+(prompt|instructions?|rules?|settings?)\b/i,
316
+ /\bdo\s+not\s+follow\s+(your|the|any)\s+(instructions?|rules?|guidelines?)\b/i,
317
+ /\bcancel\s+(all\s+)?(prior|previous)\s+(directives?|instructions?)\b/i,
318
+ /\bnew\s+instructions?\s+supersede\b/i,
319
+ /\byour\s+(previous\s+)?instructions?\s+are\s+(now\s+)?void\b/i,
320
+ ],
321
+ },
322
+ {
323
+ name: 'role_hijacking', weight: 0.85,
324
+ patterns: [
325
+ /\b(pretend|act|behave)\s+(you\s+are|as\s+if\s+you|like\s+you|to\s+be)\b/i,
326
+ /\byou\s+are\s+now\s+(a|an|the|my|DAN)\b/i,
327
+ /\bsimulate\s+being\b/i, /\bassume\s+the\s+role\s+of\b/i,
328
+ /\benter\s+(developer|admin|debug|god|sudo|unrestricted)\s+mode\b/i,
329
+ /\bswitch\s+to\s+(unrestricted|unfiltered)\s+mode\b/i,
330
+ /\byou\s+are\s+no\s+longer\s+bound\b/i,
331
+ /\bno\s+(safety\s+)?restrictions?\s+(apply|anymore|now)\b/i,
332
+ ],
333
+ },
334
+ {
335
+ name: 'jailbreak_keywords', weight: 0.8,
336
+ patterns: [
337
+ /\bjailbreak\b/i, /\bDAN\s+mode\b/i,
338
+ /\b(system\s+override|admin\s+mode|debug\s+mode|developer\s+mode|maintenance\s+mode)\b/i,
339
+ /\bmaster\s+key\b/i, /\bbackdoor\s+access\b/i,
340
+ /\bsudo\s+mode\b/i, /\bgod\s+mode\b/i,
341
+ /\bsafety\s+filters?\s+(off|disabled?|removed?)\b/i,
342
+ ],
343
+ },
344
+ {
345
+ name: 'encoding_evasion', weight: 0.75,
346
+ patterns: [
347
+ /\b(decode|translate)\s+(this|the\s+following)\s+(base64|rot13|hex)\b/i,
348
+ /\b(base64|rot13)\s*:\s*[A-Za-z0-9+/=]{10,}/i,
349
+ /\bexecute\s+the\s+(reverse|decoded)\b/i,
350
+ /\breverse\s+of\s*:\s*\w{10,}/i,
351
+ ],
352
+ },
353
+ {
354
+ name: 'separator_injection', weight: 0.7,
355
+ patterns: [
356
+ /[-=]{3,}\s*\n\s*(new\s+instructions?|system|instructions?)\s*:/i,
357
+ /```\s*\n\s*<\/?system>/i,
358
+ /\bEND\s+(SYSTEM\s+)?(PROMPT|INSTRUCTIONS?)\b.*\bNEW\s+(SYSTEM\s+)?(PROMPT|INSTRUCTIONS?)\b/is,
359
+ ],
360
+ },
361
+ {
362
+ name: 'multi_language', weight: 0.7,
363
+ patterns: [
364
+ /ignor(iere|a|e[zs]?)\s+(alle|todas?|toutes?|tüm|все)/iu,
365
+ /игнорируйте/iu, /yoksay/iu,
366
+ /vorherigen?\s+Anweisungen/iu, /instrucciones\s+anteriores/iu,
367
+ /instructions?\s+pr[eé]c[eé]dentes?/iu, /önceki\s+talimatlar/iu,
368
+ ],
369
+ },
370
+ ];
371
+
372
+ function detectPromptInjection(text, customCategories = [], threshold = 0.5) {
373
+ const matched = [];
374
+ let maxWeight = 0;
375
+ const allCategories = [...PI_CATEGORIES, ...customCategories];
376
+ for (const cat of allCategories) {
377
+ for (const pat of cat.patterns) {
378
+ if (pat.test(text)) {
379
+ matched.push(cat.name);
380
+ if (cat.weight > maxWeight) maxWeight = cat.weight;
381
+ break;
382
+ }
383
+ }
384
+ }
385
+ if (matched.length === 0) return null;
386
+ const score = Math.min(1.0, maxWeight + 0.05 * (matched.length - 1));
387
+ const trustScore = 1.0 - score;
388
+ const blocked = Math.round(trustScore * 1000) < Math.round(threshold * 1000);
389
+ return { score, trustScore, categories: matched, blocked };
390
+ }
391
+
392
+ // ── Glob Matching ──
393
+ function matchGlob(str, pattern) {
394
+ if (pattern === '*') return true;
395
+ const s = str.toLowerCase();
396
+ const p = pattern.toLowerCase();
397
+ if (s === p) return true;
398
+ const startsW = p.startsWith('*');
399
+ const endsW = p.endsWith('*');
400
+ if (startsW && endsW) { const infix = p.slice(1, -1); return infix.length > 0 && s.includes(infix); }
401
+ if (startsW) return s.endsWith(p.slice(1));
402
+ if (endsW) return s.startsWith(p.slice(0, -1));
403
+ const idx = p.indexOf('*');
404
+ if (idx !== -1) {
405
+ const pre = p.slice(0, idx);
406
+ const suf = p.slice(idx + 1);
407
+ return s.startsWith(pre) && s.endsWith(suf) && s.length >= pre.length + suf.length;
408
+ }
409
+ return false;
410
+ }
411
+
412
+ // ── Path Glob (supports **) ──
413
+ function matchPathGlob(path, pattern) {
414
+ const p = path.replace(/\\/g, '/').toLowerCase();
415
+ const g = pattern.replace(/\\/g, '/').toLowerCase();
416
+ if (p === g) return true;
417
+ if (g.includes('**')) {
418
+ const parts = g.split('**').filter(s => s.length > 0);
419
+ if (parts.length === 0) return true;
420
+ return parts.every(segment => p.includes(segment));
421
+ }
422
+ return matchGlob(p, g);
423
+ }
424
+
425
+ // ── Safe Webhook URL Validation (prevent SSRF) ──
426
+ function isSafeWebhookUrl(urlStr) {
427
+ try {
428
+ const u = new URL(urlStr);
429
+ if (u.protocol !== 'https:') return false;
430
+ const host = u.hostname.toLowerCase();
431
+ // Block private/reserved IPs and metadata endpoints
432
+ if (host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' || host === '::1') return false;
433
+ if (host.startsWith('10.') || host.startsWith('192.168.') || host.startsWith('172.')) return false;
434
+ if (host === '169.254.169.254' || host === 'metadata.google.internal') return false;
435
+ if (host.endsWith('.internal') || host.endsWith('.local')) return false;
436
+ return true;
437
+ } catch { return false; }
438
+ }
439
+
440
+ // ── Safe Regex Validation (prevent ReDoS from cloud-supplied patterns) ──
441
+ function isSafeRegex(pattern) {
442
+ if (typeof pattern !== 'string' || pattern.length > 512) return false;
443
+ // Block nested quantifiers: (a+)+, (a*)+, (a{1,})+, etc.
444
+ if (/(\+|\*|\{[^}]+\})\s*(\+|\*|\{[^}]+\})/.test(pattern)) return false;
445
+ if (/\([^)]*(\+|\*|\{[^}]+\})[^)]*\)\s*(\+|\*|\{[^}]+\})/.test(pattern)) return false;
446
+ // Block excessive alternation groups (>10 alternatives)
447
+ if ((pattern.match(/\|/g) || []).length > 10) return false;
448
+ try { new RegExp(pattern); return true; } catch { return false; }
449
+ }
450
+
451
+ // ── Extract Functions (deep scan all string values) ──
452
+ function scanStrings(obj) {
453
+ const strings = [];
454
+ function walk(v) {
455
+ if (typeof v === 'string' && v.trim()) strings.push(v.trim());
456
+ else if (Array.isArray(v)) v.forEach(walk);
457
+ else if (v && typeof v === 'object') Object.values(v).forEach(walk);
458
+ }
459
+ walk(obj);
460
+ return strings;
461
+ }
462
+
463
+ function looksLikeFilename(s) {
464
+ if (s.startsWith('.')) return true;
465
+ if (/\.\w+$/.test(s)) return true;
466
+ const known = ['id_rsa','id_dsa','id_ecdsa','id_ed25519','authorized_keys','known_hosts','makefile','dockerfile'];
467
+ return known.includes(s.toLowerCase());
468
+ }
469
+
470
+ // Deterministic shell normalizer — handles the common bypass tricks BEFORE
471
+ // any semantic check, so OPA's literal matcher sees the canonical command.
472
+ // Specifically: variable assignment + interpolation, quote concatenation
473
+ // (.e""nv, ."env"). Doesn't try to be a full shell — just enough to defeat
474
+ // the obfuscation patterns AI judges keep getting wrong non-deterministically.
475
+ function normalizeShellCommand(cmd) {
476
+ if (typeof cmd !== 'string' || !cmd) return cmd;
477
+ const vars = {};
478
+ const out = [];
479
+ // Split on statement separators (; && ||) but NOT pipes (|).
480
+ for (const rawPart of cmd.split(/\s*(?:;|&&|\|\|)\s*/)) {
481
+ let part = rawPart;
482
+ // Detect var assignment: NAME=value | NAME="value" | NAME='value'
483
+ const m = part.match(/^(\w+)=(?:"([^"]*)"|'([^']*)'|([^\s;&|]*))\s*$/);
484
+ if (m) {
485
+ vars[m[1]] = m[2] ?? m[3] ?? m[4] ?? '';
486
+ continue;
487
+ }
488
+ // Substitute ${var} then $var.
489
+ part = part.replace(/\$\{(\w+)\}/g, (_, n) => vars[n] !== undefined ? vars[n] : '${' + n + '}');
490
+ part = part.replace(/\$(\w+)/g, (_, n) => vars[n] !== undefined ? vars[n] : '$' + n);
491
+ // Collapse quote-concat: a"b"c → abc, .e""nv → .env, ."env" → .env
492
+ part = part.replace(/"([^"]*)"/g, '$1').replace(/'([^']*)'/g, '$1');
493
+ out.push(part);
494
+ }
495
+ return out.join('; ');
496
+ }
497
+
498
+ // Normalize all shell-command-valued fields of an args object before tokenizing.
499
+ function normalizeArgs(args) {
500
+ if (!args || typeof args !== 'object') return args;
501
+ const fields = ['command', 'cmd', 'function', 'script', 'shell'];
502
+ const copy = { ...args };
503
+ for (const [k, v] of Object.entries(copy)) {
504
+ if (fields.includes(k.toLowerCase()) && typeof v === 'string') {
505
+ copy[k] = normalizeShellCommand(v);
506
+ }
507
+ }
508
+ return copy;
509
+ }
510
+
511
+ function extractFilenames(args) {
512
+ args = normalizeArgs(args);
513
+ const names = new Set();
514
+ // Strip surrounding/trailing quotes — `"…/secret.env"` must reduce to
515
+ // `secret.env`, not `secret.env"` (a trailing quote breaks the *.env glob).
516
+ const dequote = (t) => t.replace(/^["'`]+/, '').replace(/["'`]+$/, '');
517
+ for (const s of scanStrings(args)) {
518
+ if (/^https?:\/\//i.test(s)) continue;
519
+ // Process EVERY whitespace-separated token, not just the last `/` segment of
520
+ // the whole string. Multi-file commands (`rm a b c`) must check all of them.
521
+ const tokens = s.includes(' ') ? s.split(/\s+/) : [s];
522
+ const single = tokens.length === 1;
523
+ for (let tok of tokens) {
524
+ tok = dequote(tok);
525
+ if (!tok || /^https?:\/\//i.test(tok)) continue;
526
+ if (tok.includes('/') || tok.includes('\\')) {
527
+ const b = dequote(tok.replace(/\\/g, '/').split('/').pop() || '');
528
+ if (b && (single || looksLikeFilename(b))) names.add(b);
529
+ } else if (looksLikeFilename(tok)) {
530
+ names.add(tok);
531
+ }
532
+ }
533
+ }
534
+ return [...names];
535
+ }
536
+
537
+ function extractUrls(args) {
538
+ const urls = new Set();
539
+ for (const s of scanStrings(args)) {
540
+ if (/^https?:\/\//i.test(s)) { urls.add(s); continue; }
541
+ if (s.includes(' ')) {
542
+ for (const tok of s.split(/\s+/)) {
543
+ if (/^https?:\/\//i.test(tok)) urls.add(tok);
544
+ }
545
+ }
546
+ }
547
+ return [...urls];
548
+ }
549
+
550
+ function extractCommands(args) {
551
+ args = normalizeArgs(args);
552
+ const cmds = [];
553
+ const fields = ['command', 'cmd', 'function', 'script', 'shell'];
554
+ if (typeof args === 'object' && args) {
555
+ for (const [k, v] of Object.entries(args)) {
556
+ if (fields.includes(k.toLowerCase()) && typeof v === 'string') {
557
+ for (const part of v.split(/\s*(?:&&|\|\||;|\|)\s*/)) {
558
+ const trimmed = part.trim();
559
+ if (trimmed) cmds.push(trimmed);
560
+ }
561
+ }
562
+ }
563
+ }
564
+ return cmds;
565
+ }
566
+
567
+ function extractPaths(args, isExec) {
568
+ const paths = [];
569
+ const add = (t) => {
570
+ if (!t || /^https?:\/\//i.test(t)) return;
571
+ // Normalize Windows backslashes to forward slashes so paths match the
572
+ // compiled Rego patterns (which are also normalized to "/"). OPA glob.match
573
+ // does no separator translation, so raw "C:\..." never matched "/" patterns.
574
+ if (t.includes('/') || t.includes('\\') || t.startsWith('.')) paths.push(t.replace(/\\/g, '/'));
575
+ };
576
+ for (const s of scanStrings(args)) {
577
+ if (/^https?:\/\//i.test(s)) continue;
578
+ if (isExec && /\s/.test(s)) {
579
+ // A command line (exec tool): pull out individual path-like tokens instead
580
+ // of treating the whole command as one path. Otherwise `node src/app.js`
581
+ // becomes the path "node src/app.js", which no path glob can match — so a
582
+ // path-scoped EXECUTE rule would never fire. Tokenizing yields "src/app.js".
583
+ for (const tok of s.split(/[\s;|&><()`'"]+/)) add(tok);
584
+ } else {
585
+ add(s);
586
+ }
587
+ }
588
+ return paths;
589
+ }
590
+
591
+ // ── Hardcoded Tamper Protection ──
592
+ // Runs BEFORE policy evaluation. Cannot be disabled by editing policies.
593
+ // Even if all policy rules are removed, these stay enforced.
594
+ const TAMPER_GUARD_TOOLS_WRITE = new Set([
595
+ 'write', 'edit', 'multiedit', 'notebookedit',
596
+ 'create', 'update', 'delete', 'remove', 'move', 'rename', 'copy',
597
+ 'filesystem', 'fs_write', 'fs_edit', 'str_replace_editor',
598
+ ]);
599
+ const TAMPER_GUARD_TOOLS_EXEC = new Set([
600
+ 'bash', 'powershell', 'shell', 'exec', 'run', 'eval', 'cmd',
601
+ ]);
602
+ const TAMPER_HOME = resolve(homedir()).replace(/\\/g, '/').toLowerCase();
603
+ const TAMPER_SG = '/.solongate';
604
+ const TAMPER_CC = '/.claude';
605
+ const TAMPER_PROTECTED_ABS = [
606
+ TAMPER_HOME + TAMPER_CC + '/settings.json',
607
+ TAMPER_HOME + TAMPER_CC + '/settings.local.json',
608
+ TAMPER_HOME + TAMPER_SG + '/hooks',
609
+ TAMPER_HOME + TAMPER_SG + '/policy.json',
610
+ TAMPER_HOME + TAMPER_SG + '/.policy-cache.json',
611
+ // The cloud credential (contains the API key) — never readable via a tool.
612
+ TAMPER_HOME + TAMPER_SG + '/cloud-guard.json',
613
+ ];
614
+ const TAMPER_INSTALL = '/solongate';
615
+ const TAMPER_PROTECTED_GLOBS = [
616
+ '**' + TAMPER_CC + '/settings.json',
617
+ '**' + TAMPER_CC + '/settings.local.json',
618
+ '**' + TAMPER_SG + '/hooks/**',
619
+ '**' + TAMPER_SG + '/policy.json',
620
+ '**' + TAMPER_SG + '/.policy-cache.json',
621
+ '**' + TAMPER_SG + '/.policy-cache-*.json',
622
+ '**' + TAMPER_SG + '/.pi-config-cache.json',
623
+ '**' + TAMPER_SG + '/cloud-guard.json',
624
+ '**' + TAMPER_SG + '/.opa-wasm-*.json',
625
+ '**' + TAMPER_SG + '/.ratelimit-*.json',
626
+ // Persistent host data (DB + audit JSONL) at ~/.solongate/data
627
+ '**' + TAMPER_SG + '/data/**',
628
+ // Customer install layout (zip extracted as solongate/)
629
+ '**' + TAMPER_INSTALL + '/compose/**',
630
+ '**' + TAMPER_INSTALL + '/data/**',
631
+ '**' + TAMPER_INSTALL + '/images/**',
632
+ '**' + TAMPER_INSTALL + '/helm/**',
633
+ '**' + TAMPER_INSTALL + '/solongate.exe',
634
+ '**' + TAMPER_INSTALL + '/setup.sh',
635
+ ];
636
+ const TAMPER_BASENAMES = [
637
+ 'guard.mjs', 'audit.mjs', 'stop.mjs',
638
+ 'policy.json', '.policy-cache.json',
639
+ '.pi-config-cache.json', 'cloud-guard.json',
640
+ // Customer install: DB and wizard exe
641
+ 'solongate.db', 'solongate.exe',
642
+ ];
643
+ const TAMPER_PATH_FIELDS = new Set([
644
+ 'file_path', 'path', 'target_file', 'notebook_path',
645
+ 'dest', 'destination', 'source', 'src', 'from', 'to',
646
+ 'directory', 'dir', 'folder',
647
+ ]);
648
+
649
+ function normTamperPath(p) {
650
+ return String(p || '').replace(/\\/g, '/').toLowerCase();
651
+ }
652
+
653
+ function isProtectedPath(p) {
654
+ if (!p) return false;
655
+ const np = normTamperPath(p);
656
+ for (const abs of TAMPER_PROTECTED_ABS) {
657
+ if (np === abs || np.startsWith(abs + '/')) return abs;
658
+ }
659
+ for (const g of TAMPER_PROTECTED_GLOBS) {
660
+ if (matchPathGlob(np, g)) return g;
661
+ }
662
+ if (/\/\.claude\/settings(\.local)?\.json$/.test(np)) return 'settings.json';
663
+ if (/\/\.solongate\/hooks(\/|$)/.test(np)) return 'solongate-hooks';
664
+ return false;
665
+ }
666
+
667
+ function commandTargetsProtected(cmd) {
668
+ const c = String(cmd || '').toLowerCase();
669
+ if (!c) return false;
670
+ for (const b of TAMPER_BASENAMES) {
671
+ if (c.includes(b.toLowerCase())) return b;
672
+ }
673
+ if (/\.claude[\\/]+settings(\.local)?\.json/.test(c)) return 'settings.json';
674
+ if (/\.solongate[\\/]+hooks/.test(c)) return 'solongate-hooks';
675
+ // Customer install dirs
676
+ if (/[\\/]solongate[\\/]+(compose|data|images|helm)[\\/]/.test(c)) return 'solongate-install';
677
+ // Mutating API calls against policies / audit-logs endpoints
678
+ const mutating = /\b(post|put|delete|patch)\b/.test(c) ||
679
+ /(-x|--request|-method)\s+(post|put|delete|patch)\b/.test(c);
680
+ if (mutating && /api\/v1\/(policies|audit-logs)/.test(c)) return 'api-policies-mutation';
681
+ return false;
682
+ }
683
+
684
+ function extractTargetPaths(args) {
685
+ const out = [];
686
+ if (typeof args !== 'object' || !args) return out;
687
+ for (const [k, v] of Object.entries(args)) {
688
+ const lk = k.toLowerCase();
689
+ if (TAMPER_PATH_FIELDS.has(lk) && typeof v === 'string') out.push(v);
690
+ if (Array.isArray(v)) {
691
+ for (const item of v) {
692
+ if (item && typeof item === 'object') {
693
+ for (const [k2, v2] of Object.entries(item)) {
694
+ if (TAMPER_PATH_FIELDS.has(k2.toLowerCase()) && typeof v2 === 'string') out.push(v2);
695
+ }
696
+ }
697
+ }
698
+ }
699
+ }
700
+ return out;
701
+ }
702
+
703
+ function tamperCheck(toolName, args) {
704
+ const tn = String(toolName || '').toLowerCase();
705
+ const isExec = TAMPER_GUARD_TOOLS_EXEC.has(tn) || /bash|shell|exec|powershell|cmd|run|eval/.test(tn);
706
+ // ANY tool that targets a protected path is blocked READ as well as write.
707
+ // An AI must not even read SolonGate's own protection files. This is enforced
708
+ // at the tool boundary; the hooks themselves are run by node directly (not via
709
+ // a Claude Code tool), so node still loads/executes them normally.
710
+ // Check the tool's TARGET PATH fields only (file_path, path, …) — never the
711
+ // free-form content/body, which would false-positive on any file that merely
712
+ // mentions a protected path in its text.
713
+ for (const p of extractTargetPaths(args)) {
714
+ const hit = isProtectedPath(p);
715
+ if (hit) return 'Tamper protection: access to "' + p + '" is blocked (protected: ' + hit + ')';
716
+ }
717
+ if (isExec) {
718
+ for (const cmd of extractCommands(args)) {
719
+ const hit = commandTargetsProtected(cmd);
720
+ if (hit) return 'Tamper protection: command references protected resource "' + hit + '" blocked';
721
+ }
722
+ }
723
+ return null;
724
+ }
725
+
726
+ // ── Extra security layers (rate limit, egress allowlist, DLP block) ──
727
+ // These are configured per-project in the dashboard and delivered to the guard
728
+ // via /policies/active (security). All fail OPEN: any error here returns null
729
+ // (allow) so a config glitch never bricks the agent. Tamper protection and
730
+ // policy are unaffected and still run.
731
+
732
+ // DLP patterns mirror the server's set (apps/api/src/lib/security-layers.ts).
733
+ // Mirrors apps/api/src/lib/security-layers.ts DLP_PATTERNS. Provider-specific
734
+ // rules plus generic Bearer / secret-assignment catch-alls for the long tail.
735
+ const DLP_PATTERNS = [
736
+ { name: 'AWS access key', re: /AKIA[0-9A-Z]{16}/ },
737
+ { name: 'private key block', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
738
+ { name: 'Anthropic key', re: /sk-ant-[A-Za-z0-9_-]{20,}/ },
739
+ { name: 'OpenAI key', re: /sk-(proj-)?[A-Za-z0-9_-]{20,}/ },
740
+ { name: 'GitHub token', re: /gh[pousr]_[A-Za-z0-9]{20,}/ },
741
+ { name: 'GitHub fine-grained PAT', re: /github_pat_[A-Za-z0-9_]{20,}/ },
742
+ { name: 'GitLab token', re: /glpat-[A-Za-z0-9_-]{20,}/ },
743
+ { name: 'Slack token', re: /xox[baprs]-[A-Za-z0-9-]{10,}/ },
744
+ { name: 'Stripe key', re: /[sr]k_(live|test)_[A-Za-z0-9]{20,}/ },
745
+ { name: 'SendGrid key', re: /SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/ },
746
+ { name: 'Twilio key', re: /SK[0-9a-fA-F]{32}/ },
747
+ { name: 'npm token', re: /npm_[A-Za-z0-9]{36}/ },
748
+ { name: 'JWT', re: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
749
+ { name: 'Bearer token', re: /bearer\s+[A-Za-z0-9._-]{20,}/i },
750
+ ];
751
+
752
+ // Custom patterns are GLOBs: `*` = any run of non-whitespace, same wildcard
753
+ // mechanic as policy/ghost.
754
+ function dlpGlobToRe(glob) {
755
+ let re = '';
756
+ for (const ch of String(glob || '')) {
757
+ if (ch === '*') re += '[^\\s]*';
758
+ else if ('.+?^${}()|[]\\'.indexOf(ch) !== -1) re += '\\' + ch;
759
+ else re += ch;
760
+ }
761
+ return new RegExp(re);
762
+ }
763
+ // Scan args against the enabled built-in patterns + any user custom patterns.
764
+ // `cfg` = { patterns: string[], custom: {name,re}[] }.
765
+ function dlpScan(args, cfg) {
766
+ if (!cfg) return null;
767
+ let text = '';
768
+ try { text = JSON.stringify(args || {}); } catch { return null; }
769
+ const allow = new Set(Array.isArray(cfg.patterns) ? cfg.patterns : []);
770
+ for (const p of DLP_PATTERNS) {
771
+ if (allow.has(p.name) && p.re.test(text)) return p.name;
772
+ }
773
+ for (const c of Array.isArray(cfg.custom) ? cfg.custom : []) {
774
+ try { if (dlpGlobToRe(c.re).test(text)) return c.name || 'custom pattern'; } catch { /* skip invalid */ }
775
+ }
776
+ return null;
777
+ }
778
+
779
+ // ── Ghost paths ──
780
+ // Files/dirs matching a ghost glob are INVISIBLE to the agent. This module is
781
+ // mirrored verbatim in audit.mjs (the PostToolUse twin that strips them from
782
+ // output). Keep the two copies in sync.
783
+ //
784
+ // Split of responsibility:
785
+ // - PreToolUse (here): block MUTATIONS (write/edit/delete/move) targeting a
786
+ // ghost path, returning a plain "No such file or directory" — never a
787
+ // SolonGate/policy message so the path looks like it simply doesn't exist.
788
+ // - PostToolUse (audit.mjs): strip ghost entries from listings and rewrite
789
+ // direct reads to not-found. Reads are NOT blocked here so the agent gets a
790
+ // natural "missing file" rather than a visible hook block.
791
+ function ghostGlobToRegExp(glob) {
792
+ let re = '';
793
+ for (let i = 0; i < glob.length; i++) {
794
+ const c = glob[i];
795
+ if (c === '*') {
796
+ if (glob[i + 1] === '*') { re += '.*'; i++; }
797
+ else re += '[^/]*';
798
+ } else if (c === '?') re += '[^/]';
799
+ else if ('\\^$.|+()[]{}'.indexOf(c) !== -1) re += '\\' + c;
800
+ else re += c;
801
+ }
802
+ try { return new RegExp('^' + re + '$'); } catch { return null; }
803
+ }
804
+
805
+ // True if `targetPath` is ghosted by any pattern. A bare name (`.data`) matches
806
+ // that entry anywhere in the path; a trailing `/` (`secrets/`) ghosts a whole
807
+ // directory subtree; a pattern with `/` is matched against the full path.
808
+ function ghostMatch(targetPath, patterns) {
809
+ if (!targetPath || !Array.isArray(patterns) || patterns.length === 0) return false;
810
+ const norm = String(targetPath).replace(/\\/g, '/').replace(/\/+$/, '');
811
+ if (!norm) return false;
812
+ const segments = norm.split('/').filter(Boolean);
813
+ const base = segments.length ? segments[segments.length - 1] : norm;
814
+ for (let pat of patterns) {
815
+ pat = String(pat || '').trim();
816
+ if (!pat) continue;
817
+ let dirOnly = false;
818
+ if (pat.endsWith('/')) { dirOnly = true; pat = pat.slice(0, -1); }
819
+ if (!pat) continue;
820
+ const hasSlash = pat.indexOf('/') !== -1;
821
+ const hasWild = /[*?]/.test(pat);
822
+ const re = ghostGlobToRegExp(pat);
823
+ if (!re) continue;
824
+ if (dirOnly) {
825
+ // Directory: ghost the dir itself and everything under it.
826
+ if (!hasSlash && !hasWild) { if (segments.indexOf(pat) !== -1) return true; continue; }
827
+ let acc = '';
828
+ for (const s of segments) { acc = acc ? acc + '/' + s : s; if (re.test(acc) || re.test(s)) return true; }
829
+ continue;
830
+ }
831
+ if (!hasSlash) {
832
+ // Name glob: match basename or any single path segment.
833
+ if (re.test(base)) return true;
834
+ if (segments.some((s) => re.test(s))) return true;
835
+ continue;
836
+ }
837
+ // Path glob (contains '/'): match the full normalized path.
838
+ if (re.test(norm)) return true;
839
+ }
840
+ return false;
841
+ }
842
+
843
+ // Strip shell decoration from a token so it can be tested as a path:
844
+ // surrounding quotes, redirection operators, trailing punctuation.
845
+ function ghostCleanToken(tok) {
846
+ let t = String(tok || '').trim();
847
+ t = t.replace(/^[<>|;&(]+/, '').replace(/[);&|]+$/, '');
848
+ t = t.replace(/^['"]+/, '').replace(/['"]+$/, '');
849
+ t = t.replace(/^\d*>>?/, ''); // strip leading redirection like 2>
850
+ return t.trim();
851
+ }
852
+
853
+ // Returns a plain not-found message if a tool DIRECTLY targets a ghost path —
854
+ // read OR write else null. Reads are sealed too: a hidden file must be
855
+ // inaccessible, not merely unlisted, so `cat A/Y/.data` looks as absent as
856
+ // `rm A/Y`. Listing a PARENT dir that only CONTAINS a ghost child is NOT a
857
+ // direct hit (no token equals the ghost) and falls through to the listing
858
+ // rewrite. Never returns a branded/policy string.
859
+ function ghostBlock(toolName, args, ghostCfg) {
860
+ if (!ghostCfg || !Array.isArray(ghostCfg.patterns) || ghostCfg.patterns.length === 0) return null;
861
+ const pats = ghostCfg.patterns;
862
+ const name = (toolName || '');
863
+ const notFound = (p) => p + ': No such file or directory';
864
+ try {
865
+ // Tools that carry an explicit path argument.
866
+ if (name === 'Write' || name === 'Edit' || name === 'MultiEdit' || name === 'NotebookEdit' ||
867
+ name === 'Read' || name === 'NotebookRead' || name === 'LS') {
868
+ const p = args?.file_path || args?.notebook_path || args?.path || '';
869
+ if (p && ghostMatch(p, pats)) return notFound(p);
870
+ return null;
871
+ }
872
+ if (name === 'Glob' || name === 'Grep') {
873
+ const p = args?.path || '';
874
+ const pat = args?.pattern || args?.glob || '';
875
+ if (p && ghostMatch(p, pats)) return notFound(p);
876
+ if (pat && ghostMatch(pat, pats)) return notFound(String(pat));
877
+ return null;
878
+ }
879
+ // Bash & other exec: deny if any token directly names a ghost path. Seals
880
+ // direct reads (cat/head/less/…) and mutations (rm/mv/…) alike. A listing of
881
+ // a parent dir has no ghost token and falls through to the rewrite.
882
+ if (name === 'Bash' || name === 'BashOutput' || guessPermission(name) === 'EXECUTE') {
883
+ const cmd = String(args?.command || '');
884
+ if (!cmd) return null;
885
+ for (const raw of cmd.split(/\s+/)) {
886
+ const tok = ghostCleanToken(raw);
887
+ if (tok && tok.indexOf('-') !== 0 && ghostMatch(tok, pats)) return notFound(tok);
888
+ }
889
+ }
890
+ } catch { /* fail open */ }
891
+ return null;
892
+ }
893
+
894
+ // Shell single-quote a string.
895
+ function ghostShq(s) { return "'" + String(s).replace(/'/g, "'\\''") + "'"; }
896
+
897
+ // If `args.command` is a simple directory listing, return a rewritten command
898
+ // that pipes its output through a filter dropping the ghost entries — so the
899
+ // agent never sees them. Returns null when there's nothing to rewrite. Only
900
+ // touches bare `ls` listings (no pipe/redirect/compound) to stay safe; richer
901
+ // output formats are handled by the PostToolUse filter on clients that honor it.
902
+ function ghostListingRewrite(args, ghostCfg) {
903
+ if (!ghostCfg || !Array.isArray(ghostCfg.patterns) || ghostCfg.patterns.length === 0) return null;
904
+ const cmd = String(args?.command || '');
905
+ if (!cmd) return null;
906
+ if (/[|>;&\n`]/.test(cmd)) return null; // no shell composition
907
+ if (!/^\s*(ls|ll|dir|find|tree|exa|lsd|fd)(\s|$)/.test(cmd)) return null;
908
+ // Translate each hidden glob to an ERE alternative, then match it as a whole
909
+ // path component, the whole line, or the trailing token — covers `ls`,
910
+ // `ls -la`, `find` and `tree`. A trailing slash (dir) is dropped.
911
+ const alts = [];
912
+ for (let p of ghostCfg.patterns) {
913
+ p = String(p).replace(/\/$/, '');
914
+ if (!p) continue;
915
+ let re = '';
916
+ for (let i = 0; i < p.length; i++) {
917
+ const c = p[i];
918
+ if (c === '*') { if (p[i + 1] === '*') { re += '.*'; i++; } else re += '[^/]*'; }
919
+ else if (c === '?') re += '[^/]';
920
+ else if ('.^$+(){}[]|\\/'.indexOf(c) >= 0) re += '\\' + c;
921
+ else re += c;
922
+ }
923
+ alts.push(re);
924
+ }
925
+ if (alts.length === 0) return null;
926
+ // grep -vE drops any line where a hidden name appears as a path component, the
927
+ // whole line, or the final token. Single-quoted so the shell leaves it intact.
928
+ const ere = '(^|/| )(' + alts.join('|') + ')(/|$)';
929
+ return cmd + " | grep -vE '" + ere.replace(/'/g, "'\\''") + "'";
930
+ }
931
+
932
+ // Multi-window sliding rate limit, persisted under ~/.solongate (tamper-protected
933
+ // from the agent, writable by the guard). One timestamps file per agent, pruned
934
+ // to the last 24h and capped for performance; counts this agent's calls within
935
+ // each enabled window (minute/hour/day). Returns the exceeded window or null.
936
+ const RL_WINDOWS = [
937
+ { key: 'perDay', ms: 86400000, label: 'day' },
938
+ { key: 'perHour', ms: 3600000, label: 'hour' },
939
+ { key: 'perMinute', ms: 60000, label: 'minute' },
940
+ ];
941
+ function rateLimitCheck(agentKey, limits) {
942
+ try {
943
+ const file = join(resolve(homedir(), '.solongate'), '.ratelimit-' + agentKey + '.json');
944
+ const now = Date.now();
945
+ let stamps = [];
946
+ if (existsSync(file)) {
947
+ try { stamps = JSON.parse(readFileSync(file, 'utf-8')); } catch { stamps = []; }
948
+ }
949
+ if (!Array.isArray(stamps)) stamps = [];
950
+ // Prune to the longest window (24h) and cap size to bound work/IO.
951
+ stamps = stamps.filter((t) => typeof t === 'number' && now - t < 86400000);
952
+ if (stamps.length > 50000) stamps = stamps.slice(-50000);
953
+ // Check each enabled window against the current count (before adding now).
954
+ for (const w of RL_WINDOWS) {
955
+ const limit = limits[w.key];
956
+ if (limit > 0) {
957
+ const count = stamps.reduce((n, t) => (now - t < w.ms ? n + 1 : n), 0);
958
+ if (count >= limit) return { window: w.label, limit };
959
+ }
960
+ }
961
+ stamps.push(now);
962
+ try { writeFileSync(file, JSON.stringify(stamps)); } catch {}
963
+ return null;
964
+ } catch {
965
+ return null; // fail open
966
+ }
967
+ }
968
+
969
+ // Runs all enabled enforcement layers; returns a deny reason or null (allow).
970
+ function securityLayerCheck(toolName, args, cfg, agentKey) {
971
+ if (!cfg) return null;
972
+ try {
973
+ if (cfg.dlpBlock) {
974
+ const hit = dlpScan(args, cfg.dlpBlock);
975
+ if (hit) return 'Security layer (DLP): blocked - arguments contain a ' + hit +
976
+ '. Blocked by SolonGate - check your dashboard for details.';
977
+ }
978
+ if (cfg.rateLimit) {
979
+ const hit = rateLimitCheck(agentKey, cfg.rateLimit);
980
+ if (hit) {
981
+ return 'Security layer (rate limit): exceeded ' + hit.limit + ' calls/' + hit.window +
982
+ ' for this agent. Blocked by SolonGate - check your dashboard to review or adjust the limit.';
983
+ }
984
+ }
985
+ } catch { /* fail open */ }
986
+ return null;
987
+ }
988
+
989
+ // ── Policy Evaluation ──
990
+
991
+ // Permission filter: a rule with rule.permission set only applies to tool
992
+ // calls whose guessed permission category is in that list. Empty/missing =
993
+ // applies to all categories.
994
+ function permissionApplies(rule, toolName) {
995
+ if (!rule.permission) return true;
996
+ const perms = Array.isArray(rule.permission) ? rule.permission : [rule.permission];
997
+ if (perms.length === 0) return true;
998
+ const guessed = guessPermission(toolName);
999
+ return perms.includes(guessed);
1000
+ }
1001
+
1002
+ // Returns the first pattern that any of the rule's constraints matches against
1003
+ // the args, or null if nothing matches. Used for both DENY (engine blocks on
1004
+ // match) and ALLOW (whitelist mode requires at least one match).
1005
+ // Each constraint may store its pattern list in either `denied` or `allowed`
1006
+ // depending on which effect the rule was created with in the dashboard. The
1007
+ // hook treats both as the same "pattern list" — the rule's effect determines
1008
+ // whether a match means block (DENY) or pass (ALLOW in whitelist mode).
1009
+ function patternsOf(constraint) {
1010
+ if (!constraint) return null;
1011
+ const list = constraint.denied || constraint.allowed;
1012
+ return Array.isArray(list) && list.length > 0 ? list : null;
1013
+ }
1014
+
1015
+ function ruleMatches(rule, args, isExec) {
1016
+ const fnPats = patternsOf(rule.filenameConstraints);
1017
+ if (fnPats) {
1018
+ const filenames = extractFilenames(args);
1019
+ for (const fn of filenames) {
1020
+ for (const pat of fnPats) {
1021
+ if (matchGlob(fn, pat)) return { kind: 'filename', value: fn, pattern: pat };
1022
+ }
1023
+ }
1024
+ }
1025
+ const urlPats = patternsOf(rule.urlConstraints);
1026
+ if (urlPats) {
1027
+ const urls = extractUrls(args);
1028
+ for (const url of urls) {
1029
+ for (const pat of urlPats) {
1030
+ if (matchGlob(url, pat)) return { kind: 'URL', value: url, pattern: pat };
1031
+ }
1032
+ }
1033
+ }
1034
+ const cmdPats = patternsOf(rule.commandConstraints);
1035
+ if (cmdPats) {
1036
+ const cmds = extractCommands(args);
1037
+ for (const cmd of cmds) {
1038
+ for (const pat of cmdPats) {
1039
+ if (matchGlob(cmd, pat)) return { kind: 'command', value: cmd.slice(0, 60), pattern: pat };
1040
+ }
1041
+ }
1042
+ }
1043
+ const pathPats = patternsOf(rule.pathConstraints);
1044
+ if (pathPats) {
1045
+ const paths = extractPaths(args, isExec);
1046
+ for (const p of paths) {
1047
+ for (const pat of pathPats) {
1048
+ if (matchPathGlob(p, pat)) return { kind: 'path', value: p, pattern: pat };
1049
+ }
1050
+ }
1051
+ }
1052
+ return null;
1053
+ }
1054
+
1055
+ // Evaluate policy. Two modes:
1056
+ // denylist (default): default ALLOW. Any DENY rule that matches block.
1057
+ // whitelist (strict): default DENY. Must match at least one ALLOW rule to
1058
+ // pass. DENY rules still override on top.
1059
+ function evaluate(policy, args, toolName) {
1060
+ if (!policy || !policy.rules) return null;
1061
+ const enabledRules = policy.rules.filter(r => r.enabled !== false);
1062
+ const mode = policy.mode === 'whitelist' ? 'whitelist' : 'denylist';
1063
+ const isExec = /bash|shell|exec|powershell|cmd|run|eval/.test((toolName || '').toLowerCase());
1064
+
1065
+ // DENY pass — runs in both modes. DENY wins over ALLOW.
1066
+ const denyRules = enabledRules
1067
+ .filter(r => r.effect === 'DENY' && permissionApplies(r, toolName))
1068
+ .sort((a, b) => (a.priority || 100) - (b.priority || 100));
1069
+ for (const rule of denyRules) {
1070
+ const m = ruleMatches(rule, args, isExec);
1071
+ if (m) return 'Blocked by policy: ' + m.kind + ' "' + m.value + '" matches "' + m.pattern + '"';
1072
+ }
1073
+
1074
+ // Whitelist pass only in strict mode. Must match at least one ALLOW rule.
1075
+ if (mode === 'whitelist') {
1076
+ const allowRules = enabledRules.filter(r => r.effect === 'ALLOW' && permissionApplies(r, toolName));
1077
+ if (allowRules.length === 0) {
1078
+ return 'Blocked by policy: strict whitelist mode is on and no ALLOW rule applies to ' + (toolName || 'this tool');
1079
+ }
1080
+ let matched = false;
1081
+ for (const rule of allowRules) {
1082
+ if (ruleMatches(rule, args, isExec)) { matched = true; break; }
1083
+ }
1084
+ if (!matched) {
1085
+ return 'Blocked by policy: strict whitelist mode request does not match any ALLOW rule';
1086
+ }
1087
+ }
1088
+
1089
+ return null;
1090
+ }
1091
+
1092
+ // ── OPA WASM Evaluation (NIST SP 800-207 PDP) ──
1093
+ //
1094
+ // When the API has compiled this policy to an OPA WASM bundle AND the
1095
+ // @open-policy-agent/opa-wasm runtime is resolvable, we evaluate through OPA
1096
+ // instead of the hand-written evaluate() above. This is the same decision
1097
+ // engine the MCP proxy uses (packages/policy-engine/src/opa).
1098
+ //
1099
+ // Graceful degradation is the contract: any missing piece (no bundle, no
1100
+ // runtime, fetch/parse/eval error) makes evaluateWithOpa() return `undefined`,
1101
+ // and the caller falls back to the legacy JS evaluate() so air-gapped
1102
+ // installs without OPA see ZERO behavior change.
1103
+
1104
+ // Cheap, dependency-free djb2 fingerprint to detect policy changes for caching.
1105
+ function djb2(str) {
1106
+ let h = 5381;
1107
+ for (let i = 0; i < str.length; i++) h = ((h << 5) + h + str.charCodeAt(i)) | 0;
1108
+ return (h >>> 0).toString(36);
1109
+ }
1110
+
1111
+ // Extracts /policy.wasm from an OPA bundle. Mirrors
1112
+ // packages/policy-engine/src/opa/opa-evaluator.ts extractWasmFromBundle().
1113
+ // The bundle may be a gzipped tar (.tar.gz from `opa build -t wasm`) or raw WASM.
1114
+ function extractWasmFromBundle(buf) {
1115
+ if (buf[0] === 0x1f && buf[1] === 0x8b) {
1116
+ const tar = gunzipSync(buf);
1117
+ let offset = 0;
1118
+ while (offset < tar.length - 512) {
1119
+ const nameEnd = tar.indexOf(0, offset);
1120
+ const name = tar.subarray(offset, Math.min(nameEnd, offset + 100)).toString('utf-8');
1121
+ if (!name || name.length === 0) break;
1122
+ const sizeStr = tar.subarray(offset + 124, offset + 136).toString('utf-8').trim();
1123
+ const size = parseInt(sizeStr, 8) || 0;
1124
+ offset += 512;
1125
+ if (name === 'policy.wasm' || name === './policy.wasm' || name.endsWith('/policy.wasm')) {
1126
+ return Buffer.from(tar.subarray(offset, offset + size));
1127
+ }
1128
+ offset += Math.ceil(size / 512) * 512;
1129
+ }
1130
+ throw new Error('policy.wasm not found in OPA bundle');
1131
+ }
1132
+ if (buf[0] === 0x00 && buf[1] === 0x61 && buf[2] === 0x73 && buf[3] === 0x6d) {
1133
+ return buf; // already raw WASM
1134
+ }
1135
+ throw new Error('Unknown OPA bundle format');
1136
+ }
1137
+
1138
+ // Fetches the compiled WASM for this policy from the API, with a local cache
1139
+ // keyed by a fingerprint of the policy so updates propagate. Returns the raw
1140
+ // policy.wasm bytes (Uint8Array) or null when unavailable.
1141
+ const OPA_WASM_TTL_MS = 30_000;
1142
+ async function getOpaWasmBytes(policy) {
1143
+ if (!policy || !policy.id) return null;
1144
+ const fp = djb2(JSON.stringify(policy.rules || []) + '|' + (policy.mode || ''));
1145
+ const agentKey = (AGENT_ID || 'default').replace(/[^a-zA-Z0-9_-]/g, '_');
1146
+ const cacheFile = join(resolve(homedir(), '.solongate'), '.opa-wasm-' + agentKey + '.json');
1147
+
1148
+ // Read any cached bundle. A "fresh" hit (same policy fingerprint, within TTL)
1149
+ // is returned immediately; otherwise we keep it as `stale` to fall back on if
1150
+ // the API is momentarily unreachable — so transient downtime never drops OPA.
1151
+ let stale = null;
1152
+ try {
1153
+ if (existsSync(cacheFile)) {
1154
+ const c = JSON.parse(readFileSync(cacheFile, 'utf-8'));
1155
+ if (c && c.wasm) {
1156
+ stale = new Uint8Array(Buffer.from(c.wasm, 'base64'));
1157
+ if (c.fp === fp && c._ts && Date.now() - c._ts < OPA_WASM_TTL_MS) {
1158
+ return stale;
1159
+ }
1160
+ }
1161
+ }
1162
+ } catch {}
1163
+
1164
+ // Fetch the compiled bundle from the API (route already exists).
1165
+ try {
1166
+ const res = await fetch(
1167
+ API_URL + '/api/v1/policies/' + encodeURIComponent(policy.id) + '/wasm',
1168
+ { headers: AUTH_HEADERS, signal: AbortSignal.timeout(8000) },
1169
+ );
1170
+ if (!res.ok) return stale; // API has no compiled WASM right now → last known good
1171
+ const bundle = Buffer.from(await res.arrayBuffer());
1172
+ const wasm = extractWasmFromBundle(bundle);
1173
+ try {
1174
+ mkdirSync(resolve(homedir(), '.solongate'), { recursive: true });
1175
+ writeFileSync(cacheFile, JSON.stringify({ _ts: Date.now(), fp, wasm: Buffer.from(wasm).toString('base64') }));
1176
+ } catch {}
1177
+ return new Uint8Array(wasm);
1178
+ } catch {
1179
+ return stale; // transient network failure last known good
1180
+ }
1181
+ }
1182
+
1183
+ // Lazily load the opa-wasm runtime. Returns the loadPolicy fn or null if the
1184
+ // package isn't installed in this environment (typical for air-gapped hooks).
1185
+ let _loadPolicyFn = null;
1186
+ let _loadPolicyTried = false;
1187
+ async function getLoadPolicy() {
1188
+ if (_loadPolicyTried) return _loadPolicyFn;
1189
+ _loadPolicyTried = true;
1190
+ try {
1191
+ const mod = await import('@open-policy-agent/opa-wasm');
1192
+ _loadPolicyFn = mod.loadPolicy || (mod.default && mod.default.loadPolicy) || null;
1193
+ } catch {
1194
+ _loadPolicyFn = null;
1195
+ }
1196
+ return _loadPolicyFn;
1197
+ }
1198
+
1199
+ // Evaluates the policy through OPA WASM. Returns:
1200
+ // - a reason string → DENY
1201
+ // - null → ALLOW (OPA decided, no violation)
1202
+ // - undefined → OPA unavailable, caller must fall back to evaluate()
1203
+ async function evaluateWithOpa(policy, args, toolName, cwd) {
1204
+ if (!policy || !policy.rules) return undefined;
1205
+ try {
1206
+ const loadPolicy = await getLoadPolicy();
1207
+ if (!loadPolicy) return undefined;
1208
+ const wasmBytes = await getOpaWasmBytes(policy);
1209
+ if (!wasmBytes) return undefined;
1210
+
1211
+ const opaPolicy = await loadPolicy(wasmBytes, { initial: 5 });
1212
+ // trust_level is fixed to 'TRUSTED' to preserve legacy guard.mjs behavior,
1213
+ // which never evaluated minimumTrustLevel constraints.
1214
+ // If the tool call references files (bash X.sh, source X, etc.), inline
1215
+ // their contents so the SAME deterministic extractors see hidden commands.
1216
+ // The hook reads files itself; OPA gets a flat, expanded view — no LLM
1217
+ // needed for hidden-in-file detection at this layer.
1218
+ // Inline referenced-file CONTENT only for tools that EXECUTE a script
1219
+ // (`bash X.sh` X.sh would run, so its contents matter). For read/write
1220
+ // tools the file is data, not code inlining its content there causes false
1221
+ // positives (e.g. reading a file that merely mentions ".env" tripping an
1222
+ // *.env rule, or reading a script that documents `rm -rf`).
1223
+ const isExecTool = /bash|shell|exec|powershell|cmd|run|eval/.test((toolName || '').toLowerCase());
1224
+ const refFiles = (isExecTool && typeof readReferencedFiles === 'function')
1225
+ ? readReferencedFiles(args, cwd || process.cwd())
1226
+ : {};
1227
+ const expandedArgs = { ...((args && typeof args === 'object') ? args : {}) };
1228
+ for (const [, content] of Object.entries(refFiles)) {
1229
+ const lines = String(content).split('\n')
1230
+ .map(l => l.trim())
1231
+ .filter(l => l && !l.startsWith('#'));
1232
+ if (lines.length > 0) {
1233
+ const extra = lines.join('; ');
1234
+ if (typeof expandedArgs.command === 'string') {
1235
+ expandedArgs.command = expandedArgs.command + '; ' + extra;
1236
+ } else {
1237
+ expandedArgs.command = extra;
1238
+ }
1239
+ }
1240
+ }
1241
+ // Matching a filename/URL/path that appears in a tool BODY (content,
1242
+ // new_string, text, …) only makes sense for EXEC tools, where that text would
1243
+ // RUN. For read/write tools the body is data, not access — writing a doc that
1244
+ // merely mentions a secret-file pattern is not accessing one. So strip body
1245
+ // fields before extracting access targets; the command fields (what actually
1246
+ // executes) are always scanned via extractCommands.
1247
+ // (isExecTool already computed above for the referenced-file inlining gate.)
1248
+ // For NON-exec tools, only the explicit path/target fields are an "access" —
1249
+ // arbitrary text fields (a question, a description, a file body) are data, not
1250
+ // access, and must not be matched against filename/path/url rules. So scan an
1251
+ // ALLOWLIST of target fields only. Exec tools scan the full command instead.
1252
+ // Includes network/url-bearing fields (url, uri, …) so non-exec network
1253
+ // tools (Fetch/WebFetch) keep their access target — otherwise the url field
1254
+ // is stripped here, input.urls comes out empty, and urlConstraints DENY
1255
+ // rules never match (a fetch to a blocked host slips through).
1256
+ const ACCESS_FIELDS = new Set(['file_path', 'path', 'target_file', 'notebook_path', 'filename', 'dest', 'destination', 'source', 'src', 'from', 'to', 'directory', 'dir', 'folder', 'url', 'urls', 'uri', 'href', 'link', 'endpoint']);
1257
+ let accessArgs = expandedArgs;
1258
+ if (!isExecTool && expandedArgs && typeof expandedArgs === 'object') {
1259
+ accessArgs = {};
1260
+ for (const [k, v] of Object.entries(expandedArgs)) {
1261
+ if (ACCESS_FIELDS.has(k.toLowerCase())) accessArgs[k] = v;
1262
+ }
1263
+ }
1264
+ const input = {
1265
+ tool_name: toolName || '',
1266
+ permission: guessPermission(toolName),
1267
+ trust_level: 'TRUSTED',
1268
+ arguments: expandedArgs,
1269
+ paths: extractPaths(accessArgs, isExecTool),
1270
+ commands: extractCommands(expandedArgs),
1271
+ urls: extractUrls(accessArgs),
1272
+ filenames: extractFilenames(accessArgs),
1273
+ };
1274
+ if (process.env.SOLONGATE_DEBUG) {
1275
+ }
1276
+ const results = opaPolicy.evaluate(input);
1277
+ const decision = results && results[0] && results[0].result;
1278
+ if (!decision || !decision.effect) return null;
1279
+
1280
+ // The generated Rego always has `default decision := DENY` (whitelist
1281
+ // semantics). We must re-apply the policy mode here so denylist policies
1282
+ // keep their default-ALLOW behavior, matching legacy evaluate():
1283
+ // - denylist: default-allow block ONLY when a DENY rule actually
1284
+ // matched (matched_rule != null). Default DENY means "no rule matched".
1285
+ // - whitelist: default-deny → block on any DENY (default or matched).
1286
+ // Routing per policy mode semantics:
1287
+ //
1288
+ // DENYLIST (default-allow):
1289
+ // DENY match → BLACK (block)
1290
+ // no match → WHITE (default-allow, skip AI Judge — this IS the
1291
+ // semantics of denylist: "block these, allow the rest")
1292
+ // REVIEW match GRAY (only this explicit effect calls AI Judge)
1293
+ //
1294
+ // WHITELIST (default-deny):
1295
+ // ALLOW match → WHITE (skip AI Judge)
1296
+ // DENY match BLACK
1297
+ // REVIEW match → GRAY
1298
+ // no match → BLACK (default-deny)
1299
+ //
1300
+ // AI Judge runs ONLY when a rule explicitly says "this needs semantic
1301
+ // review" never as a fallback for "I'm not sure". That keeps token cost
1302
+ // proportional to actual ambiguity and avoids running the model on every
1303
+ // routine call.
1304
+ const mode = policy.mode === 'whitelist' ? 'whitelist' : 'denylist';
1305
+ const matched = decision.matched_rule != null;
1306
+ const eff = decision.effect;
1307
+ if (mode === 'denylist') {
1308
+ if (eff === 'DENY' && matched) return '[SolonGate OPA] ' + (decision.reason || 'Blocked by policy');
1309
+ if (eff === 'REVIEW' && matched) return { white: false, reason: decision.reason, ruleId: decision.matched_rule };
1310
+ return { white: true, ruleId: matched ? decision.matched_rule : null };
1311
+ }
1312
+ // whitelist
1313
+ if (eff === 'DENY' && matched) return '[SolonGate OPA] ' + (decision.reason || 'Blocked by policy');
1314
+ if (eff === 'REVIEW' && matched) return { white: false, reason: decision.reason, ruleId: decision.matched_rule };
1315
+ if (eff === 'ALLOW' && matched) return { white: true, ruleId: decision.matched_rule };
1316
+ return '[SolonGate OPA] ' + (decision.reason || 'Blocked by policy: no ALLOW rule matched');
1317
+ } catch {
1318
+ return undefined; // any failure → fall back to legacy evaluator
1319
+ }
1320
+ }
1321
+
1322
+ // ── Main ──
1323
+ let input = '';
1324
+ // Read the contents of files a tool call references, so the AI Judge can see a
1325
+ // command HIDDEN inside a script/file (e.g. `bash deploy.sh`). Bounded: at most
1326
+ // a few small text files. Returns { name: content }.
1327
+ function readReferencedFiles(args, cwd) {
1328
+ const out = {};
1329
+ const MAX_FILES = 3, MAX_BYTES = 65536;
1330
+ const cands = new Set();
1331
+ // Only inline a file that is actually EXECUTED by an interpreter — `bash x.sh`,
1332
+ // `python x.py`, `source x`, `. x`. A file that is merely an argument (rm/cp/cat
1333
+ // x, or a read/write target) is NOT run, so its content must NOT be scanned —
1334
+ // otherwise deleting a file whose text mentions a blocked name would false-block.
1335
+ const INTERP = /^(?:bash|sh|zsh|ksh|dash|ash|python3?|node|deno|bun|ruby|perl|php|pwsh|powershell|source|\.)$/i;
1336
+ if (args && typeof args === 'object') {
1337
+ for (const f of ['command', 'cmd', 'script', 'shell', 'code']) {
1338
+ const v = args[f];
1339
+ if (typeof v !== 'string') continue;
1340
+ const toks = v.split(/[\s'"();|&<>]+/).filter(Boolean);
1341
+ for (let i = 0; i < toks.length - 1; i++) {
1342
+ if (!INTERP.test(toks[i])) continue;
1343
+ // The first non-flag token after the interpreter is the script it runs.
1344
+ let j = i + 1;
1345
+ while (j < toks.length && toks[j].startsWith('-')) j++;
1346
+ if (j < toks.length) cands.add(toks[j]);
1347
+ }
1348
+ }
1349
+ }
1350
+ let n = 0;
1351
+ for (const c of cands) {
1352
+ if (n >= MAX_FILES) break;
1353
+ try {
1354
+ const p = resolve(cwd || process.cwd(), c);
1355
+ if (!existsSync(p)) continue;
1356
+ const st = statSync(p);
1357
+ if (!st.isFile() || st.size > MAX_BYTES) continue;
1358
+ out[c] = readFileSync(p, 'utf-8').slice(0, MAX_BYTES);
1359
+ n++;
1360
+ } catch {}
1361
+ }
1362
+ return out;
1363
+ }
1364
+
1365
+ process.stdin.on('data', c => input += c);
1366
+ process.stdin.on('end', async () => {
1367
+ // No policy selected => no enforcement. A plain launch (no SOLONGATE_AGENT_ID)
1368
+ // is intentionally unrestricted.
1369
+ if (process.env.SOLONGATE_DEBUG) {
1370
+ }
1371
+ // Cloud gate: the API key IS the policy selector — it identifies the project
1372
+ // and its active policy. No key → nothing to enforce → allow. (Air-gap gated
1373
+ // on SOLONGATE_AGENT_ID instead; here the key does that job.)
1374
+ if (!API_KEY) {
1375
+ allowTool();
1376
+ return;
1377
+ }
1378
+ const _evalStart = Date.now();
1379
+ try {
1380
+ const raw = JSON.parse(input);
1381
+
1382
+ // Debug: append guard invocation to a cwd-local log. Opt-in only — set
1383
+ // SOLONGATE_DEBUG=1 to enable. Off by default so it doesn't litter every
1384
+ // working directory with .solongate/.debug-guard-log.
1385
+ if (process.env.SOLONGATE_DEBUG) {
1386
+ try {
1387
+ const { appendFileSync: afs, mkdirSync: mds } = await import('node:fs');
1388
+ mds(resolve('.solongate'), { recursive: true });
1389
+ const debugLine = JSON.stringify({ ts: new Date().toISOString(), hook: 'guard', argv: process.argv.slice(2), tool_name: raw.tool_name || raw.toolName || raw.command, agent_id: AGENT_ID }) + '\n';
1390
+ afs(resolve('.solongate', '.debug-guard-log'), debugLine);
1391
+ } catch {}
1392
+ }
1393
+
1394
+ let mappedToolName = raw.tool_name || raw.toolName || '';
1395
+ let mappedToolInput = raw.tool_input || raw.toolInput || raw.params || {};
1396
+
1397
+ // Normalize field names across tools
1398
+ const data = {
1399
+ ...raw,
1400
+ tool_name: mappedToolName,
1401
+ tool_input: mappedToolInput,
1402
+ tool_response: raw.tool_response || raw.toolResponse || {},
1403
+ cwd: raw.cwd || process.cwd(),
1404
+ session_id: raw.session_id || raw.sessionId || raw.conversation_id || '',
1405
+ };
1406
+ const args = data.tool_input;
1407
+ const toolName = data.tool_name || '';
1408
+
1409
+ // (self-protection + PI hook layers removed per project decision)
1410
+
1411
+ // Load policy. Priority:
1412
+ // 1. Dashboard-managed policy (GET /api/v1/policies/active, cached 10s)
1413
+ // 2. Local policy.json next to cwd
1414
+ // The dashboard is the source of truth — local policy.json is only a
1415
+ // fallback for when the API is unreachable.
1416
+ const hookCwd = data.cwd || process.cwd();
1417
+ let policy;
1418
+ // Self-protection (tamper guard) defaults ON. The cloud per-project setting
1419
+ // can turn it off; delivered via /policies/active and cached alongside the
1420
+ // policy. Any failure to read it leaves protection ON (fail safe).
1421
+ let selfProtectEnabled = true;
1422
+ // Extra security layers (rate limit, egress, DLP block) delivered by the
1423
+ // cloud. Null = none configured. Fail open if unread.
1424
+ let securityCfg = null;
1425
+ // Cache keyed by agent_id so different agents in different terminals
1426
+ // don't share a stale cached policy.
1427
+ const agentKey = (AGENT_ID || 'default').replace(/[^a-zA-Z0-9_-]/g, '_');
1428
+ const policyCacheFile = join(resolve(homedir(), '.solongate'), '.policy-cache-' + agentKey + '.json');
1429
+ const POLICY_TTL_MS = 3_000;
1430
+ try {
1431
+ let dashboardPolicy = null;
1432
+ // Try cache first
1433
+ try {
1434
+ if (existsSync(policyCacheFile)) {
1435
+ const cached = JSON.parse(readFileSync(policyCacheFile, 'utf-8'));
1436
+ if (cached && cached._ts && Date.now() - cached._ts < POLICY_TTL_MS) {
1437
+ if (cached.policy) dashboardPolicy = cached.policy;
1438
+ if (typeof cached.selfProtect === 'boolean') selfProtectEnabled = cached.selfProtect;
1439
+ if (cached.security !== undefined) securityCfg = cached.security;
1440
+ if (cached.hookVersions) CLOUD_HOOK_VERSIONS = cached.hookVersions;
1441
+ }
1442
+ }
1443
+ } catch {}
1444
+ // Refresh from API if cache expired
1445
+ if (!dashboardPolicy) {
1446
+ try {
1447
+ // Report our installed version (hv) so the dashboard can show whether
1448
+ // this guard is on the latest build.
1449
+ const res = await fetch(API_URL + '/api/v1/policies/active?agent_id=' + encodeURIComponent(AGENT_ID || '') + '&hv=' + HOOK_VERSION, { headers: AUTH_HEADERS, signal: AbortSignal.timeout(8000) });
1450
+ if (res.ok) {
1451
+ const body = await res.json();
1452
+ // Capture the self-protection flag even when no cloud policy is set.
1453
+ if (typeof body?.self_protection_enabled === 'boolean') selfProtectEnabled = body.self_protection_enabled;
1454
+ if (body?.security !== undefined) securityCfg = body.security;
1455
+ if (body?.hook_versions && typeof body.hook_versions === 'object') CLOUD_HOOK_VERSIONS = body.hook_versions;
1456
+ if (body && body.policy) dashboardPolicy = body.policy;
1457
+ try { writeFileSync(policyCacheFile, JSON.stringify({ _ts: Date.now(), policy: dashboardPolicy || null, selfProtect: selfProtectEnabled, security: securityCfg, hookVersions: CLOUD_HOOK_VERSIONS })); } catch {}
1458
+ }
1459
+ } catch {}
1460
+ }
1461
+
1462
+ if (process.env.SOLONGATE_DEBUG) {
1463
+ }
1464
+ if (dashboardPolicy) {
1465
+ policy = dashboardPolicy;
1466
+ } else {
1467
+ // Fall back to ~/.solongate/policy.json (where the wizard writes the
1468
+ // default), then a per-project policy.json next to cwd.
1469
+ const candidates = [
1470
+ join(resolve(homedir(), '.solongate'), 'policy.json'),
1471
+ resolve(hookCwd, 'policy.json'),
1472
+ ];
1473
+ for (const p of candidates) {
1474
+ if (existsSync(p)) {
1475
+ try { policy = JSON.parse(readFileSync(p, 'utf-8')); break; } catch {}
1476
+ }
1477
+ }
1478
+ }
1479
+ } catch {
1480
+ // Couldn't load any policy leave policy undefined; evaluate() returns null.
1481
+ }
1482
+
1483
+ if (process.env.SOLONGATE_DEBUG) {
1484
+ }
1485
+ // Agent scoping
1486
+ {
1487
+ const scope = (policy && Array.isArray(policy.agents) && policy.agents.length > 0)
1488
+ ? policy.agents
1489
+ : ['*'];
1490
+ if (!scope.includes('*') && !scope.includes(AGENT_TYPE)) {
1491
+ allowTool();
1492
+ return;
1493
+ }
1494
+ }
1495
+
1496
+ if (process.env.SOLONGATE_DEBUG) {
1497
+ }
1498
+ // Tamper / self-protection — runs before policy eval. ON by default; the
1499
+ // per-project cloud setting can disable it (fail safe: stays on if unread).
1500
+ let reason = selfProtectEnabled ? tamperCheck(toolName, args) : null;
1501
+ // Ghost paths — handled BEFORE the other layers and emitted as a STEALTH
1502
+ // block: a mutating op on a hidden path is denied with a bare OS-style
1503
+ // "No such file or directory" and NOTHING else (no ROUTE line, no SolonGate
1504
+ // wording), so the agent can't tell the path is protected — it just looks
1505
+ // absent. (Reads/listings aren't blocked; the PostToolUse hook strips them.)
1506
+ if (!reason && securityCfg && securityCfg.ghost) {
1507
+ const ghostHit = ghostBlock(toolName, args, securityCfg.ghost);
1508
+ if (ghostHit) {
1509
+ try { writeDenyFlag(toolName); } catch {}
1510
+ writeLocalLog(securityCfg, { ts: new Date().toISOString(), tool: toolName, arguments: args, decision: 'DENY', reason: 'ghost path (hidden from agent)', permission: guessPermission(toolName), source: `${AGENT_TYPE}-guard`, agent_id: AGENT_TYPE, agent_name: AGENT_NAME, session_id: data.session_id || '', evaluation_time_ms: Date.now() - _evalStart });
1511
+ try {
1512
+ if (!localLogsOnly(securityCfg)) await fetch(API_URL + '/api/v1/audit-logs', {
1513
+ method: 'POST',
1514
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
1515
+ body: JSON.stringify({
1516
+ tool: toolName, arguments: args, decision: 'DENY',
1517
+ reason: 'ghost path (hidden from agent)',
1518
+ permission: guessPermission(toolName),
1519
+ source: `${AGENT_TYPE}-guard`, agent_id: AGENT_TYPE, agent_name: AGENT_NAME,
1520
+ session_id: data.session_id || '',
1521
+ evaluation_time_ms: Date.now() - _evalStart,
1522
+ }),
1523
+ signal: AbortSignal.timeout(3000),
1524
+ });
1525
+ } catch {}
1526
+ await maybeSelfUpdate();
1527
+ if (AGENT_TYPE === 'gemini-cli') { process.stdout.write(JSON.stringify({ decision: 'deny', reason: ghostHit })); process.exit(0); }
1528
+ process.stderr.write(ghostHit);
1529
+ process.exit(2);
1530
+ }
1531
+ // No direct hit: if this is a listing command, rewrite it so hidden
1532
+ // entries are filtered out of its output (Claude Code only).
1533
+ if (AGENT_TYPE !== 'gemini-cli' && toolName === 'Bash') {
1534
+ const rw = ghostListingRewrite(args, securityCfg.ghost);
1535
+ if (rw) { await maybeSelfUpdate(); rewriteTool({ command: rw }); }
1536
+ }
1537
+ }
1538
+ // Extra security layers run after tamper, before policy. Block reason wins
1539
+ // immediately (BLACK). Fail-open by design.
1540
+ if (!reason) reason = securityLayerCheck(toolName, args, securityCfg, agentKey);
1541
+ if (process.env.SOLONGATE_DEBUG) {
1542
+ }
1543
+ // OPA WASM is the SOLE policy engine. With no policy configured for this
1544
+ // agent we skip evaluation entirely (allow). With a policy present,
1545
+ // evaluateWithOpa returns a reason (DENY), null (ALLOW), or undefined when
1546
+ // the WASM bundle could not be obtained at all — in which case we fall back
1547
+ // to the policy mode's default (whitelist fail closed, denylist fail
1548
+ // open); see the branch below. (The legacy JS evaluate() below is retained
1549
+ // but no longer on the decision path — OPA decides everything.)
1550
+ // Cloud routing is BINARY — WHITE (allow) / BLACK (block). There is NO AI
1551
+ // Judge in the cloud (that is an air-gap-only feature), so there is no GRAY
1552
+ // "send to the judge" lane: the OPA policy alone decides. Tamper protection
1553
+ // and any DENY (incl. fail-closed) → BLACK; everything else → WHITE. A REVIEW
1554
+ // rule with no judge to escalate to is treated as allow under denylist.
1555
+ let opaRoute = 'white';
1556
+ if (reason) {
1557
+ opaRoute = 'black'; // hardcoded tamper protection blocked it
1558
+ } else if (policy && policy.rules) {
1559
+ const opaResult = await evaluateWithOpa(policy, args, toolName, hookCwd);
1560
+ if (opaResult === undefined) {
1561
+ // OPA produced no decision (no WASM bundle yet, runtime missing, fetch
1562
+ // error). This happens on COLD START — the first call(s) in a session
1563
+ // before the policy + WASM are cached. Don't leave an enforcement gap:
1564
+ // run the deterministic, WASM-free JS evaluator so DENY rules (e.g.
1565
+ // secret-file protection) and whitelist defaults apply IMMEDIATELY, from
1566
+ // the very first call. evaluate() implements both modes:
1567
+ // - returns a deny reason → block (DENY match, or whitelist no-match)
1568
+ // - returns null → allow (denylist default / whitelist match)
1569
+ // This closes the "worked, but late" window where a denylist policy used
1570
+ // to fail OPEN until WASM warmed up.
1571
+ const legacy = evaluate(policy, args, toolName);
1572
+ if (typeof legacy === 'string') {
1573
+ reason = legacy;
1574
+ opaRoute = 'black';
1575
+ } else {
1576
+ opaRoute = 'white';
1577
+ }
1578
+ } else if (typeof opaResult === 'string') {
1579
+ reason = opaResult; // explicit DENY
1580
+ opaRoute = 'black';
1581
+ } else {
1582
+ opaRoute = 'white'; // allow (rule match, default-allow, or review w/o judge)
1583
+ }
1584
+ }
1585
+
1586
+ process.stderr.write(`[SolonGate ROUTE] ${opaRoute.toUpperCase()} (${reason ? 'block' : 'allow'})\n`);
1587
+
1588
+ // Hand the measured policy-eval time to the audit hook: PostToolUse logs the
1589
+ // ALLOW path and can't time the guard itself, so it reads this file back.
1590
+ // Keyed by tool + session so the audit hook can match THIS invocation even
1591
+ // when the tool itself runs for minutes (a bare timestamp TTL lost those).
1592
+ try { const _fd = resolve('.solongate'); mkdirSync(_fd, { recursive: true }); writeFileSync(join(_fd, '.last-eval'), JSON.stringify({ ms: Date.now() - _evalStart, ts: Date.now(), tool: toolName, session: data.session_id || '' })); } catch {}
1593
+
1594
+ // Only log DENY decisions from guard hook.
1595
+ // ALLOW decisions are logged by the audit hook (PostToolUse) to avoid double-counting.
1596
+ if (reason) {
1597
+ if (true) {
1598
+ try {
1599
+ const logEntry = {
1600
+ tool: toolName, arguments: args,
1601
+ decision: 'DENY', reason,
1602
+ permission: guessPermission(toolName),
1603
+ source: `${AGENT_TYPE}-guard`,
1604
+ agent_id: AGENT_TYPE, agent_name: AGENT_NAME,
1605
+ session_id: data.session_id || '',
1606
+ evaluation_time_ms: Date.now() - _evalStart,
1607
+ };
1608
+ writeLocalLog(securityCfg, { ts: new Date().toISOString(), ...logEntry });
1609
+ // PI hook layer removed — piResult fields no longer attached.
1610
+ // Local-only mode: keep the log on the user's machine, skip the cloud.
1611
+ if (!localLogsOnly(securityCfg)) await fetch(API_URL + '/api/v1/audit-logs', {
1612
+ method: 'POST',
1613
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
1614
+ body: JSON.stringify(logEntry),
1615
+ signal: AbortSignal.timeout(3000),
1616
+ });
1617
+ } catch {}
1618
+ }
1619
+ writeDenyFlag(toolName);
1620
+ await maybeSelfUpdate();
1621
+ blockTool(reason);
1622
+ }
1623
+ } catch {}
1624
+ await maybeSelfUpdate();
1625
+ allowTool();
1626
+ });