@solongate/proxy 0.26.1 → 0.26.3

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,1175 +1,1175 @@
1
- #!/usr/bin/env node
2
- /**
3
- * SolonGate Policy Guard Hook (PreToolUse)
4
- * Reads policy.json and blocks tool calls that violate constraints.
5
- * Also runs prompt injection detection (Stage 1 rules) on tool arguments.
6
- * Exit code 2 = BLOCK, exit code 0 = ALLOW.
7
- * Logs ALL decisions (ALLOW + DENY) to SolonGate Cloud.
8
- * Auto-installed by: npx @solongate/proxy init
9
- */
10
- import { readFileSync, existsSync } from 'node:fs';
11
- import { resolve } from 'node:path';
12
-
13
- // ── Load API key from .env file (Claude Code doesn't load .env into process.env) ──
14
- function loadEnvKey(dir) {
15
- try {
16
- const envPath = resolve(dir, '.env');
17
- if (!existsSync(envPath)) return {};
18
- const lines = readFileSync(envPath, 'utf-8').split('\n');
19
- const env = {};
20
- for (const line of lines) {
21
- const m = line.match(/^([A-Z_]+)=(.*)$/);
22
- if (m) env[m[1]] = m[2].replace(/^["']|["']$/g, '').trim();
23
- }
24
- return env;
25
- } catch { return {}; }
26
- }
27
-
28
- const hookCwdEarly = process.cwd();
29
- const dotenv = loadEnvKey(hookCwdEarly);
30
- const API_KEY = process.env.SOLONGATE_API_KEY || dotenv.SOLONGATE_API_KEY || '';
31
- const API_URL = process.env.SOLONGATE_API_URL || dotenv.SOLONGATE_API_URL || 'https://api.solongate.com';
32
-
33
- // ── Prompt Injection Detection (Stage 1: Rule-Based) ──
34
- const PI_CATEGORIES = [
35
- {
36
- name: 'delimiter_injection', weight: 0.95,
37
- patterns: [
38
- /<\/system>/i, /<\|im_end\|>/i, /<\|im_start\|>/i, /<\|endoftext\|>/i,
39
- /\[INST\]/i, /\[\/INST\]/i, /<<SYS>>/i, /<<\/SYS>>/i,
40
- /###\s*(Human|Assistant|System)\s*:/i, /<\|user\|>/i, /<\|assistant\|>/i,
41
- /---\s*END\s*SYSTEM\s*PROMPT\s*---/i,
42
- ],
43
- },
44
- {
45
- name: 'instruction_override', weight: 0.9,
46
- patterns: [
47
- /\bignore\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|prompts?|rules?|directives?)\b/i,
48
- /\bdisregard\s+(all\s+)?(previous|prior|above|earlier|your)\s+(instructions?|prompts?|rules?|guidelines?)\b/i,
49
- /\bforget\s+(all\s+|everything\s+)?(your|the|previous|prior|above|earlier)\b/i,
50
- /\boverride\s+(the\s+)?(system|previous|current)\s+(prompt|instructions?|rules?|settings?)\b/i,
51
- /\bdo\s+not\s+follow\s+(your|the|any)\s+(instructions?|rules?|guidelines?)\b/i,
52
- /\bcancel\s+(all\s+)?(prior|previous)\s+(directives?|instructions?)\b/i,
53
- /\bnew\s+instructions?\s+supersede\b/i,
54
- /\byour\s+(previous\s+)?instructions?\s+are\s+(now\s+)?void\b/i,
55
- ],
56
- },
57
- {
58
- name: 'role_hijacking', weight: 0.85,
59
- patterns: [
60
- /\b(pretend|act|behave)\s+(you\s+are|as\s+if\s+you|like\s+you|to\s+be)\b/i,
61
- /\byou\s+are\s+now\s+(a|an|the|my|DAN)\b/i,
62
- /\bsimulate\s+being\b/i, /\bassume\s+the\s+role\s+of\b/i,
63
- /\benter\s+(developer|admin|debug|god|sudo|unrestricted)\s+mode\b/i,
64
- /\bswitch\s+to\s+(unrestricted|unfiltered)\s+mode\b/i,
65
- /\byou\s+are\s+no\s+longer\s+bound\b/i,
66
- /\bno\s+(safety\s+)?restrictions?\s+(apply|anymore|now)\b/i,
67
- ],
68
- },
69
- {
70
- name: 'jailbreak_keywords', weight: 0.8,
71
- patterns: [
72
- /\bjailbreak\b/i, /\bDAN\s+mode\b/i,
73
- /\b(system\s+override|admin\s+mode|debug\s+mode|developer\s+mode|maintenance\s+mode)\b/i,
74
- /\bmaster\s+key\b/i, /\bbackdoor\s+access\b/i,
75
- /\bsudo\s+mode\b/i, /\bgod\s+mode\b/i,
76
- /\bsafety\s+filters?\s+(off|disabled?|removed?)\b/i,
77
- ],
78
- },
79
- {
80
- name: 'encoding_evasion', weight: 0.75,
81
- patterns: [
82
- /\b(decode|translate)\s+(this|the\s+following)\s+(base64|rot13|hex)\b/i,
83
- /\b(base64|rot13)\s*:\s*[A-Za-z0-9+/=]{10,}/i,
84
- /\bexecute\s+the\s+(reverse|decoded)\b/i,
85
- /\breverse\s+of\s*:\s*\w{10,}/i,
86
- ],
87
- },
88
- {
89
- name: 'separator_injection', weight: 0.7,
90
- patterns: [
91
- /[-=]{3,}\s*\n\s*(new\s+instructions?|system|instructions?)\s*:/i,
92
- /```\s*\n\s*<\/?system>/i,
93
- /\bEND\s+(SYSTEM\s+)?(PROMPT|INSTRUCTIONS?)\b.*\bNEW\s+(SYSTEM\s+)?(PROMPT|INSTRUCTIONS?)\b/is,
94
- ],
95
- },
96
- {
97
- name: 'multi_language', weight: 0.7,
98
- patterns: [
99
- /ignor(iere|a|e[zs]?)\s+(alle|todas?|toutes?|tüm|все)/iu,
100
- /игнорируйте/iu, /yoksay/iu,
101
- /vorherigen?\s+Anweisungen/iu, /instrucciones\s+anteriores/iu,
102
- /instructions?\s+pr[eé]c[eé]dentes?/iu, /önceki\s+talimatlar/iu,
103
- ],
104
- },
105
- ];
106
-
107
- function detectPromptInjection(text, customCategories = [], threshold = 0.5) {
108
- const matched = [];
109
- let maxWeight = 0;
110
- const allCategories = [...PI_CATEGORIES, ...customCategories];
111
- for (const cat of allCategories) {
112
- for (const pat of cat.patterns) {
113
- if (pat.test(text)) {
114
- matched.push(cat.name);
115
- if (cat.weight > maxWeight) maxWeight = cat.weight;
116
- break;
117
- }
118
- }
119
- }
120
- if (matched.length === 0) return null;
121
- const score = Math.min(1.0, maxWeight + 0.05 * (matched.length - 1));
122
- const trustScore = 1.0 - score;
123
- const blocked = Math.round(trustScore * 1000) < Math.round(threshold * 1000);
124
- return { score, trustScore, categories: matched, blocked };
125
- }
126
-
127
- // ── Glob Matching ──
128
- function matchGlob(str, pattern) {
129
- if (pattern === '*') return true;
130
- const s = str.toLowerCase();
131
- const p = pattern.toLowerCase();
132
- if (s === p) return true;
133
- const startsW = p.startsWith('*');
134
- const endsW = p.endsWith('*');
135
- if (startsW && endsW) { const infix = p.slice(1, -1); return infix.length > 0 && s.includes(infix); }
136
- if (startsW) return s.endsWith(p.slice(1));
137
- if (endsW) return s.startsWith(p.slice(0, -1));
138
- const idx = p.indexOf('*');
139
- if (idx !== -1) {
140
- const pre = p.slice(0, idx);
141
- const suf = p.slice(idx + 1);
142
- return s.startsWith(pre) && s.endsWith(suf) && s.length >= pre.length + suf.length;
143
- }
144
- return false;
145
- }
146
-
147
- // ── Path Glob (supports **) ──
148
- function matchPathGlob(path, pattern) {
149
- const p = path.replace(/\\/g, '/').toLowerCase();
150
- const g = pattern.replace(/\\/g, '/').toLowerCase();
151
- if (p === g) return true;
152
- if (g.includes('**')) {
153
- const parts = g.split('**').filter(s => s.length > 0);
154
- if (parts.length === 0) return true;
155
- return parts.every(segment => p.includes(segment));
156
- }
157
- return matchGlob(p, g);
158
- }
159
-
160
- // ── Extract Functions (deep scan all string values) ──
161
- function scanStrings(obj) {
162
- const strings = [];
163
- function walk(v) {
164
- if (typeof v === 'string' && v.trim()) strings.push(v.trim());
165
- else if (Array.isArray(v)) v.forEach(walk);
166
- else if (v && typeof v === 'object') Object.values(v).forEach(walk);
167
- }
168
- walk(obj);
169
- return strings;
170
- }
171
-
172
- function looksLikeFilename(s) {
173
- if (s.startsWith('.')) return true;
174
- if (/\.\w+$/.test(s)) return true;
175
- const known = ['id_rsa','id_dsa','id_ecdsa','id_ed25519','authorized_keys','known_hosts','makefile','dockerfile'];
176
- return known.includes(s.toLowerCase());
177
- }
178
-
179
- function extractFilenames(args) {
180
- const names = new Set();
181
- for (const s of scanStrings(args)) {
182
- if (/^https?:\/\//i.test(s)) continue;
183
- if (s.includes('/') || s.includes('\\')) {
184
- const base = s.replace(/\\/g, '/').split('/').pop();
185
- if (base) names.add(base);
186
- continue;
187
- }
188
- if (s.includes(' ')) {
189
- for (const tok of s.split(/\s+/)) {
190
- if (tok.includes('/') || tok.includes('\\')) {
191
- const b = tok.replace(/\\/g, '/').split('/').pop();
192
- if (b && looksLikeFilename(b)) names.add(b);
193
- } else if (looksLikeFilename(tok)) names.add(tok);
194
- }
195
- continue;
196
- }
197
- if (looksLikeFilename(s)) names.add(s);
198
- }
199
- return [...names];
200
- }
201
-
202
- function extractUrls(args) {
203
- const urls = new Set();
204
- for (const s of scanStrings(args)) {
205
- if (/^https?:\/\//i.test(s)) { urls.add(s); continue; }
206
- if (s.includes(' ')) {
207
- for (const tok of s.split(/\s+/)) {
208
- if (/^https?:\/\//i.test(tok)) urls.add(tok);
209
- }
210
- }
211
- }
212
- return [...urls];
213
- }
214
-
215
- function extractCommands(args) {
216
- const cmds = [];
217
- const fields = ['command', 'cmd', 'function', 'script', 'shell'];
218
- if (typeof args === 'object' && args) {
219
- for (const [k, v] of Object.entries(args)) {
220
- if (fields.includes(k.toLowerCase()) && typeof v === 'string') {
221
- for (const part of v.split(/\s*(?:&&|\|\||;|\|)\s*/)) {
222
- const trimmed = part.trim();
223
- if (trimmed) cmds.push(trimmed);
224
- }
225
- }
226
- }
227
- }
228
- return cmds;
229
- }
230
-
231
- function extractPaths(args) {
232
- const paths = [];
233
- for (const s of scanStrings(args)) {
234
- if (/^https?:\/\//i.test(s)) continue;
235
- if (s.includes('/') || s.includes('\\') || s.startsWith('.')) paths.push(s);
236
- }
237
- return paths;
238
- }
239
-
240
- // ── Policy Evaluation ──
241
- function evaluate(policy, args) {
242
- if (!policy || !policy.rules) return null;
243
- const denyRules = policy.rules
244
- .filter(r => r.effect === 'DENY' && r.enabled !== false)
245
- .sort((a, b) => (a.priority || 100) - (b.priority || 100));
246
-
247
- for (const rule of denyRules) {
248
- if (rule.filenameConstraints && rule.filenameConstraints.denied) {
249
- const filenames = extractFilenames(args);
250
- for (const fn of filenames) {
251
- for (const pat of rule.filenameConstraints.denied) {
252
- if (matchGlob(fn, pat)) return 'Blocked by policy: filename "' + fn + '" matches "' + pat + '"';
253
- }
254
- }
255
- }
256
- if (rule.urlConstraints && rule.urlConstraints.denied) {
257
- const urls = extractUrls(args);
258
- for (const url of urls) {
259
- for (const pat of rule.urlConstraints.denied) {
260
- if (matchGlob(url, pat)) return 'Blocked by policy: URL "' + url + '" matches "' + pat + '"';
261
- }
262
- }
263
- }
264
- if (rule.commandConstraints && rule.commandConstraints.denied) {
265
- const cmds = extractCommands(args);
266
- for (const cmd of cmds) {
267
- for (const pat of rule.commandConstraints.denied) {
268
- if (matchGlob(cmd, pat)) return 'Blocked by policy: command "' + cmd.slice(0,60) + '" matches "' + pat + '"';
269
- }
270
- }
271
- }
272
- if (rule.pathConstraints && rule.pathConstraints.denied) {
273
- const paths = extractPaths(args);
274
- for (const p of paths) {
275
- for (const pat of rule.pathConstraints.denied) {
276
- if (matchPathGlob(p, pat)) return 'Blocked by policy: path "' + p + '" matches "' + pat + '"';
277
- }
278
- }
279
- }
280
- }
281
- return null;
282
- }
283
-
284
- // ── Main ──
285
- let input = '';
286
- process.stdin.on('data', c => input += c);
287
- process.stdin.on('end', async () => {
288
- try {
289
- const data = JSON.parse(input);
290
- const args = data.tool_input || {};
291
-
292
- // ── Self-protection: block access to hook files and settings ──
293
- // Hardcoded, no bypass possible — runs before policy/PI config
294
- // Fully protected: block ALL access (read, write, delete, move)
295
- const protectedPaths = [
296
- '.solongate', '.claude', '.cursor', '.gemini', '.antigravity', '.openclaw', '.perplexity',
297
- 'policy.json', '.mcp.json',
298
- ];
299
- // Write-protected: block write/delete/modify, allow read (cat, grep, head, etc.)
300
- const writeProtectedPaths = ['.env', '.gitignore'];
301
-
302
- // Block helper — logs to cloud + exits with code 2
303
- async function blockSelfProtection(reason) {
304
- if (API_KEY && API_KEY.startsWith('sg_live_')) {
305
- try {
306
- await fetch(API_URL + '/api/v1/audit-logs', {
307
- method: 'POST',
308
- headers: { 'Authorization': 'Bearer ' + API_KEY, 'Content-Type': 'application/json' },
309
- body: JSON.stringify({
310
- tool: data.tool_name || '', arguments: args,
311
- decision: 'DENY', reason,
312
- source: 'claude-code-guard',
313
- }),
314
- signal: AbortSignal.timeout(3000),
315
- });
316
- } catch {}
317
- }
318
- process.stderr.write(reason);
319
- process.exit(2);
320
- }
321
-
322
- // ── Normalization layers ──
323
-
324
- // 1. Decode ANSI-C quoting: $'\x72' → r, $'\162' → r, $'\n' → newline
325
- function decodeAnsiC(s) {
326
- return s.replace(/\$'([^']*)'/g, (_, content) => {
327
- return content
328
- .replace(/\\x([0-9a-fA-F]{2})/g, (__, hex) => String.fromCharCode(parseInt(hex, 16)))
329
- .replace(/\\([0-7]{1,3})/g, (__, oct) => String.fromCharCode(parseInt(oct, 8)))
330
- .replace(/\\u([0-9a-fA-F]{4})/g, (__, hex) => String.fromCharCode(parseInt(hex, 16)))
331
- .replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\r/g, '\r')
332
- .replace(/\\(.)/g, '$1');
333
- });
334
- }
335
-
336
- // 2. Strip all shell quoting: empty quotes, single, double, backslash escapes
337
- function stripShellQuotes(s) {
338
- let r = s;
339
- r = r.replace(/""/g, ''); // empty double quotes
340
- r = r.replace(/''/g, ''); // empty single quotes
341
- r = r.replace(/\\(.)/g, '$1'); // backslash escapes
342
- r = r.replace(/'/g, ''); // remaining single quotes
343
- r = r.replace(/"/g, ''); // remaining double quotes
344
- return r;
345
- }
346
-
347
- // 3. Full normalization pipeline
348
- function normalizeShell(s) {
349
- return stripShellQuotes(decodeAnsiC(s));
350
- }
351
-
352
- // 4. Extract inner commands from eval, bash -c, sh -c, pipe to bash/sh, heredoc, process substitution
353
- function extractInnerCommands(s) {
354
- const inner = [];
355
- // eval "cmd" or eval 'cmd' or eval cmd
356
- for (const m of s.matchAll(/\beval\s+["']([^"']+)["']/gi)) inner.push(m[1]);
357
- for (const m of s.matchAll(/\beval\s+([^;"'|&]+)/gi)) inner.push(m[1]);
358
- // bash -c "cmd" or sh -c "cmd"
359
- for (const m of s.matchAll(/\b(?:bash|sh)\s+-c\s+["']([^"']+)["']/gi)) inner.push(m[1]);
360
- // echo "cmd" | bash/sh or printf "cmd" | bash/sh
361
- for (const m of s.matchAll(/(?:echo|printf)\s+["']([^"']+)["']\s*\|\s*(?:bash|sh)\b/gi)) inner.push(m[1]);
362
- // find ... -name "pattern" ... -exec ...
363
- for (const m of s.matchAll(/-name\s+["']?([^\s"']+)["']?/gi)) inner.push(m[1]);
364
- // Heredoc: bash << 'EOF'\n...\nEOF or bash <<- EOF\n...\nEOF
365
- for (const m of s.matchAll(/<<-?\s*['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\s*\1/gi)) inner.push(m[2]);
366
- // Herestring: bash <<< "cmd"
367
- for (const m of s.matchAll(/<<<\s*["']([^"']+)["']/gi)) inner.push(m[1]);
368
- for (const m of s.matchAll(/<<<\s*([^\s;&|]+)/gi)) inner.push(m[1]);
369
- // Process substitution: <(cmd) or >(cmd)
370
- for (const m of s.matchAll(/[<>]\(\s*([^)]+)\s*\)/gi)) inner.push(m[1]);
371
- return inner;
372
- }
373
-
374
- // 5. Check variable assignments for protected path fragments
375
- // e.g. X=".solon" && rm -rf ${X}gate → detects ".solon" as prefix of ".solongate"
376
- function checkVarAssignments(s) {
377
- const assignments = [...s.matchAll(/(\w+)=["']?([^"'\s&|;]+)["']?/g)];
378
- for (const [, , value] of assignments) {
379
- const v = value.toLowerCase();
380
- if (v.length < 3) continue; // avoid false positives
381
- for (const p of protectedPaths) {
382
- if (p.startsWith(v) || p.includes(v)) return p;
383
- }
384
- }
385
- return null;
386
- }
387
-
388
- // 6. Check if a glob/wildcard could match any protected path
389
- function globMatchesProtected(s) {
390
- const hasWild = s.includes('*') || s.includes('?') || /\[.+\]/.test(s);
391
- if (!hasWild) return null;
392
- const segments = s.split('/').filter(Boolean);
393
- const candidates = [s, ...segments];
394
- for (const candidate of candidates) {
395
- const candHasWild = candidate.includes('*') || candidate.includes('?') || /\[.+\]/.test(candidate);
396
- if (!candHasWild) continue;
397
- // Prefix match: ".antig*" → prefix ".antig"
398
- const firstWild = Math.min(
399
- candidate.includes('*') ? candidate.indexOf('*') : Infinity,
400
- candidate.includes('?') ? candidate.indexOf('?') : Infinity,
401
- /\[/.test(candidate) ? candidate.indexOf('[') : Infinity,
402
- );
403
- const prefix = candidate.slice(0, firstWild).toLowerCase();
404
- if (prefix.length > 0) {
405
- for (const p of protectedPaths) {
406
- if (p.startsWith(prefix)) return p;
407
- }
408
- }
409
- // Regex match — convert shell glob to regex
410
- // Handles *, ?, and [...] character classes
411
- try {
412
- let regex = '';
413
- let i = 0;
414
- const c = candidate.toLowerCase();
415
- while (i < c.length) {
416
- if (c[i] === '*') { regex += '.*'; i++; }
417
- else if (c[i] === '?') { regex += '.'; i++; }
418
- else if (c[i] === '[') {
419
- // Pass through [...] as regex character class
420
- const close = c.indexOf(']', i + 1);
421
- if (close !== -1) {
422
- regex += c.slice(i, close + 1);
423
- i = close + 1;
424
- } else {
425
- regex += '\\['; i++;
426
- }
427
- }
428
- else {
429
- regex += c[i].replace(/[.+^${}()|\\]/g, '\\$&');
430
- i++;
431
- }
432
- }
433
- const re = new RegExp('^' + regex + '$', 'i');
434
- for (const p of protectedPaths) {
435
- if (re.test(p)) return p;
436
- }
437
- } catch {}
438
- }
439
- return null;
440
- }
441
-
442
- // ── Build all candidate strings from tool input ──
443
- const rawStrings = scanStrings(args).map(s => s.replace(/\\/g, '/').toLowerCase());
444
- const allStrings = new Set();
445
-
446
- for (const s of rawStrings) {
447
- // Raw string
448
- allStrings.add(s);
449
- // Normalized (ANSI-C decoded, quotes stripped)
450
- const norm = normalizeShell(s);
451
- allStrings.add(norm);
452
- // Extract inner commands (eval, bash -c, pipe to bash, find -name)
453
- for (const inner of extractInnerCommands(s)) {
454
- allStrings.add(inner.toLowerCase());
455
- allStrings.add(normalizeShell(inner.toLowerCase()));
456
- }
457
- // Split by spaces + shell operators for token-level checks
458
- for (const tok of s.split(/[\s;&|]+/)) {
459
- if (tok) {
460
- allStrings.add(tok);
461
- allStrings.add(normalizeShell(tok));
462
- }
463
- }
464
- // Also split normalized version
465
- for (const tok of norm.split(/[\s;&|]+/)) {
466
- if (tok) allStrings.add(tok);
467
- }
468
- }
469
-
470
- // ── Check all candidates ──
471
- for (const s of allStrings) {
472
- // Direct match
473
- for (const p of protectedPaths) {
474
- if (s.includes(p)) {
475
- await blockSelfProtection('SOLONGATE: Access to protected path "' + p + '" is blocked');
476
- }
477
- }
478
- // Wildcard/glob match
479
- const globHit = globMatchesProtected(s);
480
- if (globHit) {
481
- await blockSelfProtection('SOLONGATE: Wildcard "' + s + '" matches protected "' + globHit + '" — blocked');
482
- }
483
- // Variable assignment targeting protected paths
484
- const varHit = checkVarAssignments(s);
485
- if (varHit) {
486
- await blockSelfProtection('SOLONGATE: Variable assignment targets protected "' + varHit + '" — blocked');
487
- }
488
- }
489
-
490
- // ── Write-protection for .env, .gitignore ──
491
- // These files can be READ but not written/deleted/moved
492
- const toolNameLower = (data.tool_name || '').toLowerCase();
493
- const isWriteTool = toolNameLower === 'write' || toolNameLower === 'edit' || toolNameLower === 'notebookedit';
494
- const isBashTool = toolNameLower === 'bash';
495
- const bashDestructive = /\b(?:rm|rmdir|del|unlink|mv|move|rename|cp|chmod|chown|truncate|shred)\b/i;
496
- const fullCmd = rawStrings.join(' ');
497
-
498
- for (const wp of writeProtectedPaths) {
499
- if (isWriteTool) {
500
- // Check file_path and all strings for write-protected paths
501
- for (const s of allStrings) {
502
- if (s.includes(wp)) {
503
- await blockSelfProtection('SOLONGATE: Write to protected file "' + wp + '" is blocked');
504
- }
505
- }
506
- }
507
- if (isBashTool && bashDestructive.test(fullCmd)) {
508
- for (const s of allStrings) {
509
- if (s.includes(wp)) {
510
- await blockSelfProtection('SOLONGATE: Destructive operation on "' + wp + '" is blocked');
511
- }
512
- }
513
- }
514
- }
515
-
516
- // Also apply glob/wildcard checks for write-protected paths in destructive contexts
517
- if (isBashTool && bashDestructive.test(fullCmd)) {
518
- for (const s of allStrings) {
519
- const hasWild = s.includes('*') || s.includes('?') || /\[.+\]/.test(s);
520
- if (!hasWild) continue;
521
- // Convert glob to regex and test against write-protected paths
522
- try {
523
- let regex = '';
524
- let i = 0;
525
- const c = s.toLowerCase();
526
- while (i < c.length) {
527
- if (c[i] === '*') { regex += '.*'; i++; }
528
- else if (c[i] === '?') { regex += '.'; i++; }
529
- else if (c[i] === '[') {
530
- const close = c.indexOf(']', i + 1);
531
- if (close !== -1) { regex += c.slice(i, close + 1); i = close + 1; }
532
- else { regex += '\\['; i++; }
533
- }
534
- else { regex += c[i].replace(/[.+^${}()|\\]/g, '\\$&'); i++; }
535
- }
536
- const re = new RegExp('^' + regex + '$', 'i');
537
- for (const wp of writeProtectedPaths) {
538
- if (re.test(wp)) {
539
- await blockSelfProtection('SOLONGATE: Wildcard "' + s + '" matches write-protected "' + wp + '" — blocked');
540
- }
541
- }
542
- } catch {}
543
- }
544
- }
545
-
546
- // ── Layer 7: Block ALL inline code execution & dangerous patterns ──
547
- // Runtime string construction (atob, Buffer.from, fromCharCode, array.join)
548
- // makes static analysis impossible. Blanket-block these patterns.
549
-
550
- // 7-pre. Blanket rule: destructive command + dot-prefixed glob = BLOCK
551
- // Catches: rm -rf .[a-z]*, mv .[!.]* /tmp, etc.
552
- // Any glob starting with "." combined with a destructive op is dangerous
553
- const destructiveOps = /\b(?:rm|rmdir|del|unlink|mv|move|rename)\b/i;
554
- const dotGlob = /\.\[|\.[\*\?]|\.{[^}]+}/; // .[a-z]*, .*, .?, .{foo,bar}
555
- if (destructiveOps.test(fullCmd) && dotGlob.test(fullCmd)) {
556
- await blockSelfProtection('SOLONGATE: Destructive command + dot-glob pattern — blocked');
557
- }
558
-
559
- // 7-pre2. Heredoc to interpreter — BLOCK
560
- // bash << 'EOF' / bash <<< "cmd" / sh << TAG
561
- if (/\b(?:bash|sh|node|python[23]?|perl|ruby)\s+<</i.test(fullCmd)) {
562
- await blockSelfProtection('SOLONGATE: Heredoc to interpreter — blocked');
563
- }
564
-
565
- // 7-pre3. Command substitution + destructive = BLOCK
566
- // Catches: rm -rf $(node scan.mjs), rm `node scan.mjs`, rm $(bash scan.sh)
567
- // Pattern: destructive command + $() or `` containing interpreter call
568
- const hasDestructive = /\b(?:rm|rmdir|del|unlink|mv|move|rename|shred)\b/i.test(fullCmd);
569
- const hasCmdSubInterpreter = /\$\(\s*(?:node|bash|sh|python[23]?|perl|ruby)\b/i.test(fullCmd)
570
- || /`\s*(?:node|bash|sh|python[23]?|perl|ruby)\b/i.test(fullCmd);
571
- if (hasDestructive && hasCmdSubInterpreter) {
572
- await blockSelfProtection('SOLONGATE: Command substitution + destructive op — blocked');
573
- }
574
-
575
- // 7-pre4. Pipe from interpreter to destructive loop = BLOCK
576
- // Catches: node scan.mjs | while read d; do rm -rf "$d"; done
577
- // node scan.mjs | xargs rm -rf
578
- // bash scan.sh | while read ...
579
- const pipeFromInterpreter = /\b(?:node|bash|sh|python[23]?|perl|ruby)\s+\S+\s*\|/i;
580
- if (pipeFromInterpreter.test(fullCmd) && hasDestructive) {
581
- await blockSelfProtection('SOLONGATE: Pipe from script to destructive command — blocked');
582
- }
583
-
584
- // 7-pre5. Script chaining: interpreter + destructive in same command chain
585
- // Catches: node scan.mjs && rm -rf $(cat /tmp/targets.txt)
586
- // bash scan.sh; while read d < targets.txt; do rm -rf "$d"; done
587
- const hasScriptExec = /\b(?:node|bash|sh|python[23]?|perl|ruby)\s+\S+\.\S+/i.test(fullCmd);
588
- if (hasScriptExec && hasDestructive) {
589
- await blockSelfProtection('SOLONGATE: Script execution + destructive command in chain — blocked');
590
- }
591
-
592
- // 7-pre6. Nested script substitution: node clean.mjs $(node scan.mjs)
593
- // Two "harmless" scripts chained via command substitution — no rm in the command itself
594
- const nestedScriptSub = /\b(?:node|python[23]?|perl|ruby|bash|sh)\s+\S+\.\S+\s+.*\$\(\s*(?:node|python[23]?|perl|ruby|bash|sh)\b/i;
595
- const backtickNestedScript = /\b(?:node|python[23]?|perl|ruby|bash|sh)\s+\S+\.\S+\s+.*`\s*(?:node|python[23]?|perl|ruby|bash|sh)\b/i;
596
- if (nestedScriptSub.test(fullCmd) || backtickNestedScript.test(fullCmd)) {
597
- await blockSelfProtection('SOLONGATE: Nested script substitution — blocked (script output as args to another script)');
598
- }
599
-
600
- // 7-pre7. NODE_OPTIONS injection — block commands with NODE_OPTIONS env var
601
- // NODE_OPTIONS="--require malicious.cjs" can inject code into any node process
602
- if (/\bNODE_OPTIONS\s*=/i.test(fullCmd)) {
603
- await blockSelfProtection('SOLONGATE: NODE_OPTIONS injection — blocked');
604
- }
605
-
606
- // 7-pre8. npm lifecycle scripts — scan package.json before npm install/run
607
- // npm install can trigger postinstall/preinstall/prepare scripts
608
- if (/\bnpm\s+(?:install|i|ci|run|start|test|publish|pack)\b/i.test(fullCmd) || /\bnpx\s+/i.test(fullCmd)) {
609
- try {
610
- const hookCwdForNpm = data.cwd || process.cwd();
611
- const pkgPath = resolve(hookCwdForNpm, 'package.json');
612
- if (existsSync(pkgPath)) {
613
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
614
- const scripts = pkg.scripts || {};
615
- const lifecycleKeys = ['preinstall', 'install', 'postinstall', 'prepare', 'prepublish', 'prepublishOnly', 'prepack', 'postpack'];
616
- const allScripts = Object.entries(scripts);
617
- for (const [key, val] of allScripts) {
618
- if (typeof val !== 'string') continue;
619
- const scriptLower = val.toLowerCase();
620
- // Check lifecycle scripts for dangerous patterns
621
- const isLifecycle = lifecycleKeys.includes(key);
622
- const isExplicitRun = fullCmd.includes(key); // npm run <key>
623
- if (isLifecycle || isExplicitRun) {
624
- // Check for protected path names
625
- for (const p of [...protectedPaths, ...writeProtectedPaths]) {
626
- if (scriptLower.includes(p)) {
627
- await blockSelfProtection('SOLONGATE: npm script "' + key + '" references protected "' + p + '" — blocked');
628
- }
629
- }
630
- // Check for string construction patterns
631
- if (/fromcharcode|atob\s*\(|buffer\.from|\\x[0-9a-f]{2}/i.test(scriptLower)) {
632
- await blockSelfProtection('SOLONGATE: npm script "' + key + '" contains string construction — blocked');
633
- }
634
- // Check for discovery+destruction combo
635
- const hasDisc = /\breaddirsync\b|\breaddir\b|\bos\.listdir\b|\bscandir\b|\bglob\b|\bls\s+-[adl]/i.test(scriptLower);
636
- const hasDest = /\brmsync\b|\brm\s+-rf\b|\bunlinksync\b|\brmdirsync\b|\bunlink\b|\brimraf\b/i.test(scriptLower);
637
- if (hasDisc && hasDest) {
638
- await blockSelfProtection('SOLONGATE: npm script "' + key + '" has discovery+destruction — blocked');
639
- }
640
- // Follow node/bash <file> references in npm scripts — deep scan the target file
641
- const scriptFileMatch = scriptLower.match(/\b(?:node|bash|sh|python[23]?)\s+([^\s;&|$]+)/i);
642
- if (scriptFileMatch) {
643
- try {
644
- const targetPath = resolve(hookCwdForNpm, scriptFileMatch[1]);
645
- if (existsSync(targetPath)) {
646
- const targetContent = readFileSync(targetPath, 'utf-8').toLowerCase();
647
- // Check target file for protected paths
648
- for (const pp of [...protectedPaths, ...writeProtectedPaths]) {
649
- if (targetContent.includes(pp)) {
650
- await blockSelfProtection('SOLONGATE: npm script "' + key + '" runs file referencing "' + pp + '" — blocked');
651
- }
652
- }
653
- // Check target for discovery+destruction
654
- const tDisc = /\breaddirsync\b|\breaddir\b|\bos\.listdir\b|\bglob\b/i.test(targetContent);
655
- const tDest = /\brmsync\b|\bunlinksync\b|\brmdirsync\b|\bunlink\b|\brimraf\b|\bfs\.\s*(?:rm|unlink|rmdir)/i.test(targetContent);
656
- if (tDisc && tDest) {
657
- await blockSelfProtection('SOLONGATE: npm script "' + key + '" runs file with discovery+destruction — blocked');
658
- }
659
- // Check target for string construction + destruction
660
- const tStrCon = /\bfromcharcode\b|\batob\s*\(|\bbuffer\.from\b/i.test(targetContent);
661
- if (tStrCon && tDest) {
662
- await blockSelfProtection('SOLONGATE: npm script "' + key + '" runs file with string construction+destruction — blocked');
663
- }
664
- // Follow imports in the target file
665
- const tDir = targetPath.replace(/[/\\][^/\\]+$/, '');
666
- const tImports = [...targetContent.matchAll(/(?:import\s+.*?\s+from\s+|import\s+|require\s*\()['"]\.\/([^'"]+)['"]/gi)];
667
- for (const [, imp] of tImports) {
668
- try {
669
- const impAbs = resolve(tDir, imp);
670
- const candidates = [impAbs, impAbs + '.mjs', impAbs + '.js', impAbs + '.cjs'];
671
- for (const c of candidates) {
672
- if (existsSync(c)) {
673
- const impContent = readFileSync(c, 'utf-8').toLowerCase();
674
- const iDisc = /\breaddirsync\b|\breaddir\b|\bos\.listdir\b|\bglob\b/i.test(impContent);
675
- const iDest = /\brmsync\b|\bunlinksync\b|\bfs\.\s*(?:rm|unlink|rmdir)/i.test(impContent);
676
- if ((iDisc && tDest) || (tDisc && iDest)) {
677
- await blockSelfProtection('SOLONGATE: npm script "' + key + '" — cross-module discovery+destruction — blocked');
678
- }
679
- break;
680
- }
681
- }
682
- } catch {}
683
- }
684
- }
685
- } catch {}
686
- }
687
- }
688
- }
689
- }
690
- } catch {} // Package.json read error — skip
691
- }
692
-
693
- // 7a. Inline interpreter execution — TOTAL BLOCK (no content scan needed)
694
- // These can construct ANY string at runtime, bypassing all static analysis
695
- const blockedInterpreters = [
696
- [/\bnode\s+(?:-e|--eval)\b/i, 'node -e/--eval'],
697
- [/\bnode\s+-p\b/i, 'node -p'],
698
- [/\bpython[23]?\s+-c\b/i, 'python -c'],
699
- [/\bperl\s+-e\b/i, 'perl -e'],
700
- [/\bruby\s+-e\b/i, 'ruby -e'],
701
- [/\bpowershell(?:\.exe)?\s+.*-(?:EncodedCommand|e|ec)\b/i, 'powershell -EncodedCommand'],
702
- [/\bpwsh(?:\.exe)?\s+.*-(?:EncodedCommand|e|ec)\b/i, 'pwsh -EncodedCommand'],
703
- [/\bpowershell(?:\.exe)?\s+-c(?:ommand)?\b/i, 'powershell -Command'],
704
- [/\bpwsh(?:\.exe)?\s+-c(?:ommand)?\b/i, 'pwsh -Command'],
705
- ];
706
- for (const [pat, name] of blockedInterpreters) {
707
- if (pat.test(fullCmd)) {
708
- await blockSelfProtection('SOLONGATE: Inline code execution blocked (' + name + ')');
709
- }
710
- }
711
-
712
- // 7b. Pipe-to-interpreter — TOTAL BLOCK
713
- // Any content piped to an interpreter can construct arbitrary commands at runtime
714
- // Also catches: | xargs node, | xargs bash, etc.
715
- const pipeToInterpreter = /\|\s*(?:xargs\s+)?(?:node|bash|sh|python[23]?|perl|ruby|php)\b/i;
716
- if (pipeToInterpreter.test(fullCmd)) {
717
- await blockSelfProtection('SOLONGATE: Pipe to interpreter blocked — runtime bypass risk');
718
- }
719
-
720
- // 7c. Base64 decode in ANY context — block when piped to anything
721
- if (/\bbase64\s+(?:-d|--decode)\b/i.test(fullCmd) && /\|/i.test(fullCmd)) {
722
- await blockSelfProtection('SOLONGATE: base64 decode in pipe chain — blocked');
723
- }
724
-
725
- // 7d. Temp/arbitrary script file execution
726
- if (/\b(?:bash|sh)\s+(?:\/tmp\/|\/var\/tmp\/|~\/|\/dev\/)/i.test(fullCmd)) {
727
- await blockSelfProtection('SOLONGATE: Script execution from temp path — blocked');
728
- }
729
-
730
- // 7e. xargs with destructive operations
731
- if (/\bxargs\b.*\b(?:rm|mv|cp|rmdir|unlink|del)\b/i.test(fullCmd)) {
732
- for (const p of protectedPaths) {
733
- if (fullCmd.includes(p.slice(0, 4))) {
734
- await blockSelfProtection('SOLONGATE: xargs with destructive op near "' + p + '" — blocked');
735
- }
736
- }
737
- }
738
-
739
- // 7f. cmd.exe /c with encoded/constructed commands
740
- if (/\bcmd(?:\.exe)?\s+\/c\b/i.test(fullCmd)) {
741
- for (const p of protectedPaths) {
742
- if (fullCmd.includes(p) || fullCmd.includes(p.slice(0, 4))) {
743
- await blockSelfProtection('SOLONGATE: cmd.exe /c near protected path — blocked');
744
- }
745
- }
746
- }
747
-
748
- // 7g. Script file execution — scan file content for discovery+destruction combo
749
- // Catches: bash script.sh / node script.mjs where the script uses readdirSync + rmSync
750
- const scriptExecMatch = fullCmd.match(/\b(?:bash|sh|node|python[23]?|perl|ruby)\s+([^\s;&|$`]+)/i);
751
- if (scriptExecMatch) {
752
- const scriptPath = scriptExecMatch[1];
753
- try {
754
- const hookCwdForScript = data.cwd || process.cwd();
755
- const absPath = scriptPath.startsWith('/') || scriptPath.includes(':')
756
- ? scriptPath
757
- : resolve(hookCwdForScript, scriptPath);
758
- if (existsSync(absPath)) {
759
- const scriptContent = readFileSync(absPath, 'utf-8').toLowerCase();
760
- // Check for discovery+destruction combo
761
- const hasDiscovery = /\breaddirsync\b|\breaddir\b|\bos\.listdir\b|\bscandir\b|\bglob(?:sync)?\b|\bls\s+-[adl]|\bls\s+\.\b|\bopendir\b|\bdir\.entries\b|\bwalkdir\b|\bls\b.*\.\[/.test(scriptContent);
762
- const hasDestruction = /\brmsync\b|\brm\s+-rf\b|\bunlinksync\b|\brmdirsync\b|\bunlink\b|\brimraf\b|\bremovesync\b|\bremove_tree\b|\bshutil\.rmtree\b|\bwritefilesync\b|\bexecsync\b.*\brm\b|\bchild_process\b|\bfs\.\s*(?:rm|unlink|rmdir|write)/.test(scriptContent);
763
- if (hasDiscovery && hasDestruction) {
764
- await blockSelfProtection('SOLONGATE: Script contains directory discovery + destructive ops — blocked');
765
- }
766
- // String construction + destructive = BLOCK
767
- // Runtime string construction (fromCharCode, atob, Buffer.from) bypasses literal name checks
768
- const hasStringConstruction = /\bfromcharcode\b|\batob\s*\(|\bbuffer\.from\b|\\x[0-9a-f]{2}|\bstring\.fromcodepoin/i.test(scriptContent);
769
- if (hasStringConstruction && hasDestruction) {
770
- await blockSelfProtection('SOLONGATE: Script uses string construction + destructive ops — blocked');
771
- }
772
- // Import/require chain: if script imports other local files, scan them too
773
- const scriptDir = absPath.replace(/[/\\][^/\\]+$/, '');
774
- const imports = [...scriptContent.matchAll(/(?:import\s+.*?\s+from\s+|import\s+|require\s*\()['"]\.\/([^'"]+)['"]/gi)];
775
- for (const [, importPath] of imports) {
776
- try {
777
- const importAbs = resolve(scriptDir, importPath);
778
- const candidates = [importAbs, importAbs + '.mjs', importAbs + '.js', importAbs + '.cjs'];
779
- for (const candidate of candidates) {
780
- if (existsSync(candidate)) {
781
- const importContent = readFileSync(candidate, 'utf-8').toLowerCase();
782
- // Cross-module: check if imported module has discovery/destruction/string construction
783
- const importDisc = /\breaddirsync\b|\breaddir\b|\bos\.listdir\b|\bscandir\b|\bglob(?:sync)?\b/i.test(importContent);
784
- const importDest = /\brmsync\b|\bunlinksync\b|\brmdirsync\b|\bunlink\b|\brimraf\b|\bfs\.\s*(?:rm|unlink|rmdir)/i.test(importContent);
785
- const importStrCon = /\bfromcharcode\b|\batob\s*\(|\bbuffer\.from\b/i.test(importContent);
786
- // Cross-module discovery in import + destruction in main (or vice versa)
787
- if ((importDisc && hasDestruction) || (hasDiscovery && importDest)) {
788
- await blockSelfProtection('SOLONGATE: Cross-module discovery+destruction detected — blocked');
789
- }
790
- if ((importStrCon && hasDestruction) || (hasStringConstruction && importDest)) {
791
- await blockSelfProtection('SOLONGATE: Cross-module string construction+destruction — blocked');
792
- }
793
- // Check imported module for protected paths
794
- for (const p of [...protectedPaths, ...writeProtectedPaths]) {
795
- if (importContent.includes(p)) {
796
- await blockSelfProtection('SOLONGATE: Imported module references protected "' + p + '" — blocked');
797
- }
798
- }
799
- break;
800
- }
801
- }
802
- } catch {}
803
- }
804
- // Check for protected path names in script content
805
- for (const p of [...protectedPaths, ...writeProtectedPaths]) {
806
- if (scriptContent.includes(p)) {
807
- await blockSelfProtection('SOLONGATE: Script references protected path "' + p + '" — blocked');
808
- }
809
- }
810
- }
811
- } catch {} // File read error — skip
812
- }
813
-
814
- // 7h. Write tool content scanning — detect discovery+destruction in file content being written
815
- // Catches: Write tool creating a script that uses readdirSync('.') + rmSync
816
- const toolName_ = data.tool_name || '';
817
- if (toolName_.toLowerCase() === 'write' || toolName_.toLowerCase() === 'edit') {
818
- const fileContent = (args.content || args.new_string || '').toLowerCase();
819
- if (fileContent.length > 0) {
820
- const hasDiscovery = /\breaddirsync\b|\breaddir\b|\bos\.listdir\b|\bscandir\b|\bglob(?:sync)?\b|\bls\s+-[adl]|\bls\s+\.\b|\bopendir\b|\bdir\.entries\b|\bwalkdir\b|\bls\b.*\.\[/.test(fileContent);
821
- const hasDestruction = /\brmsync\b|\brm\s+-rf\b|\bunlinksync\b|\brmdirsync\b|\bunlink\b|\brimraf\b|\bremovesync\b|\bremove_tree\b|\bshutil\.rmtree\b|\bwritefilesync\b|\bexecsync\b.*\brm\b|\bchild_process\b.*\brm\b|\bfs\.\s*(?:rm|unlink|rmdir)/.test(fileContent);
822
- if (hasDiscovery && hasDestruction) {
823
- await blockSelfProtection('SOLONGATE: File content contains discovery + destructive ops — write blocked');
824
- }
825
- // String construction + destructive = BLOCK
826
- const hasStringConstruction = /\bfromcharcode\b|\batob\s*\(|\bbuffer\.from\b|\\x[0-9a-f]{2}|\bstring\.fromcodepoin/i.test(fileContent);
827
- if (hasStringConstruction && hasDestruction) {
828
- await blockSelfProtection('SOLONGATE: File uses string construction + destructive ops — write blocked');
829
- }
830
- }
831
- }
832
-
833
- // 7i. Write tool — detect package.json scripts with dangerous patterns
834
- if (toolName_.toLowerCase() === 'write' || toolName_.toLowerCase() === 'edit') {
835
- const filePath = (args.file_path || '').toLowerCase();
836
- if (filePath.endsWith('package.json')) {
837
- const content = args.content || args.new_string || '';
838
- try {
839
- // Try parsing as JSON to check scripts
840
- const pkg = JSON.parse(content);
841
- const scripts = pkg.scripts || {};
842
- for (const [key, val] of Object.entries(scripts)) {
843
- if (typeof val !== 'string') continue;
844
- const v = val.toLowerCase();
845
- for (const p of [...protectedPaths, ...writeProtectedPaths]) {
846
- if (v.includes(p)) {
847
- await blockSelfProtection('SOLONGATE: package.json script "' + key + '" references protected "' + p + '" — blocked');
848
- }
849
- }
850
- if (/fromcharcode|atob\s*\(|buffer\.from/i.test(v)) {
851
- const hasDest = /\brmsync\b|\brm\b|\bunlink\b|\brimraf\b/i.test(v);
852
- if (hasDest) {
853
- await blockSelfProtection('SOLONGATE: package.json script "' + key + '" has string construction + destruction — blocked');
854
- }
855
- }
856
- }
857
- } catch {} // Not valid JSON or parse error — skip
858
- }
859
- }
860
-
861
- // ── Fetch PI config from Cloud ──
862
- let piCfg = { piEnabled: true, piThreshold: 0.5, piMode: 'block', piWhitelist: [], piToolConfig: {}, piCustomPatterns: [], piWebhookUrl: null };
863
- if (API_KEY && API_KEY.startsWith('sg_live_')) {
864
- try {
865
- const cfgRes = await fetch(API_URL + '/api/v1/project-config', {
866
- headers: { 'Authorization': 'Bearer ' + API_KEY },
867
- signal: AbortSignal.timeout(3000),
868
- });
869
- if (cfgRes.ok) {
870
- const cfg = await cfgRes.json();
871
- piCfg = { ...piCfg, ...cfg };
872
- }
873
- } catch {} // Fallback: defaults (safe)
874
- }
875
-
876
- // ── Per-tool config: check if PI scanning is disabled for this tool ──
877
- const toolName = data.tool_name || '';
878
- if (piCfg.piToolConfig && typeof piCfg.piToolConfig === 'object') {
879
- if (piCfg.piToolConfig[toolName] === false) {
880
- // PI scanning explicitly disabled for this tool — skip detection
881
- piCfg.piEnabled = false;
882
- }
883
- }
884
-
885
- // ── Prompt Injection Detection (Stage 1: Rules + Custom Patterns) ──
886
- const allText = scanStrings(args).join(' ');
887
-
888
- // Check whitelist — if input matches any whitelist pattern, skip PI detection
889
- let whitelisted = false;
890
- if (piCfg.piEnabled !== false && Array.isArray(piCfg.piWhitelist) && piCfg.piWhitelist.length > 0) {
891
- for (const wlPattern of piCfg.piWhitelist) {
892
- try {
893
- if (new RegExp(wlPattern, 'i').test(allText)) {
894
- whitelisted = true;
895
- break;
896
- }
897
- } catch {} // Invalid regex — skip
898
- }
899
- }
900
-
901
- // Build custom patterns from config
902
- const customCategories = [];
903
- if (piCfg.piEnabled !== false && Array.isArray(piCfg.piCustomPatterns)) {
904
- for (const cp of piCfg.piCustomPatterns) {
905
- if (cp && cp.pattern) {
906
- try {
907
- customCategories.push({
908
- name: cp.name || 'custom_pattern',
909
- weight: Math.max(0, Math.min(1, Number(cp.weight) || 0.8)),
910
- patterns: [new RegExp(cp.pattern, 'iu')],
911
- });
912
- } catch {} // Invalid regex — skip
913
- }
914
- }
915
- }
916
-
917
- const piResult = (piCfg.piEnabled !== false && !whitelisted)
918
- ? detectPromptInjection(allText, customCategories, piCfg.piThreshold)
919
- : null;
920
-
921
- if (piResult && piResult.blocked) {
922
- const isLogOnly = piCfg.piMode === 'log-only';
923
- const msg = isLogOnly
924
- ? 'SOLONGATE: Prompt injection detected [LOG-ONLY] (trust score: ' +
925
- (piResult.trustScore * 100).toFixed(0) + '%, categories: ' +
926
- piResult.categories.join(', ') + ')'
927
- : 'SOLONGATE: Prompt injection detected (trust score: ' +
928
- (piResult.trustScore * 100).toFixed(0) + '%, categories: ' +
929
- piResult.categories.join(', ') + ')';
930
-
931
- // Log to Cloud
932
- if (API_KEY && API_KEY.startsWith('sg_live_')) {
933
- try {
934
- await fetch(API_URL + '/api/v1/audit-logs', {
935
- method: 'POST',
936
- headers: { 'Authorization': 'Bearer ' + API_KEY, 'Content-Type': 'application/json' },
937
- body: JSON.stringify({
938
- tool: toolName,
939
- arguments: args,
940
- decision: isLogOnly ? 'ALLOW' : 'DENY',
941
- reason: msg,
942
- source: 'claude-code-guard',
943
- pi_detected: true,
944
- pi_trust_score: piResult.trustScore,
945
- pi_blocked: !isLogOnly,
946
- pi_categories: JSON.stringify(piResult.categories),
947
- pi_stage_scores: JSON.stringify({ rules: piResult.score, embedding: 0, classifier: 0 }),
948
- }),
949
- signal: AbortSignal.timeout(3000),
950
- });
951
- } catch {}
952
-
953
- // Webhook notification
954
- if (piCfg.piWebhookUrl) {
955
- try {
956
- await fetch(piCfg.piWebhookUrl, {
957
- method: 'POST',
958
- headers: { 'Content-Type': 'application/json' },
959
- body: JSON.stringify({
960
- event: 'prompt_injection_detected',
961
- tool: toolName,
962
- trustScore: piResult.trustScore,
963
- categories: piResult.categories,
964
- blocked: !isLogOnly,
965
- mode: piCfg.piMode,
966
- timestamp: new Date().toISOString(),
967
- }),
968
- signal: AbortSignal.timeout(3000),
969
- });
970
- } catch {} // Webhook failure is non-blocking
971
- }
972
- }
973
-
974
- // In log-only mode, warn but don't block
975
- if (isLogOnly) {
976
- process.stderr.write(msg);
977
- // Fall through to policy evaluation (don't exit)
978
- } else {
979
- process.stderr.write(msg);
980
- process.exit(2);
981
- }
982
- }
983
-
984
- // Load policy (use cwd from hook data if available)
985
- const hookCwd = data.cwd || process.cwd();
986
- let policy;
987
- try {
988
- const policyPath = resolve(hookCwd, 'policy.json');
989
- policy = JSON.parse(readFileSync(policyPath, 'utf-8'));
990
- } catch {
991
- // No policy file — still log if PI was detected but not blocked
992
- if (piResult && API_KEY && API_KEY.startsWith('sg_live_')) {
993
- try {
994
- await fetch(API_URL + '/api/v1/audit-logs', {
995
- method: 'POST',
996
- headers: { 'Authorization': 'Bearer ' + API_KEY, 'Content-Type': 'application/json' },
997
- body: JSON.stringify({
998
- tool: toolName,
999
- arguments: args,
1000
- decision: 'ALLOW',
1001
- reason: 'Prompt injection detected but below threshold (trust: ' + (piResult.trustScore * 100).toFixed(0) + '%)',
1002
- source: 'claude-code-guard',
1003
- pi_detected: true,
1004
- pi_trust_score: piResult.trustScore,
1005
- pi_blocked: false,
1006
- pi_categories: JSON.stringify(piResult.categories),
1007
- pi_stage_scores: JSON.stringify({ rules: piResult.score, embedding: 0, classifier: 0 }),
1008
- }),
1009
- signal: AbortSignal.timeout(3000),
1010
- });
1011
- } catch {}
1012
- }
1013
- process.exit(0); // No policy = allow all
1014
- }
1015
-
1016
- let reason = evaluate(policy, args);
1017
-
1018
- // ── AI Judge: semantic intent analysis (runs when policy ALLOWs) ──
1019
- if (!reason) {
1020
- const GROQ_KEY = process.env.GROQ_API_KEY || dotenv.GROQ_API_KEY || '';
1021
- let aiJudgeEnabled = false;
1022
- let aiJudgeModel = 'llama-3.1-8b-instant';
1023
- let aiJudgeEndpoint = 'https://api.groq.com/openai';
1024
- let aiJudgeTimeout = 5000;
1025
-
1026
- // Check cloud config for AI Judge settings
1027
- if (API_KEY && API_KEY.startsWith('sg_live_')) {
1028
- try {
1029
- const cfgRes = await fetch(API_URL + '/api/v1/project-config/ai-judge', {
1030
- headers: { 'Authorization': 'Bearer ' + API_KEY },
1031
- signal: AbortSignal.timeout(3000),
1032
- });
1033
- if (cfgRes.ok) {
1034
- const cfg = await cfgRes.json();
1035
- aiJudgeEnabled = Boolean(cfg.enabled);
1036
- if (cfg.model) aiJudgeModel = cfg.model;
1037
- if (cfg.endpoint) aiJudgeEndpoint = cfg.endpoint;
1038
- if (cfg.timeoutMs) aiJudgeTimeout = cfg.timeoutMs;
1039
- }
1040
- } catch {}
1041
- }
1042
-
1043
- if (aiJudgeEnabled && GROQ_KEY) {
1044
- try {
1045
- // Extract protected files/paths from policy
1046
- const protectedFiles = [];
1047
- const protectedPathsList = [];
1048
- if (policy && policy.rules) {
1049
- for (const rule of policy.rules) {
1050
- if (rule.effect === 'DENY' && rule.enabled !== false) {
1051
- if (rule.filenameConstraints && rule.filenameConstraints.denied) {
1052
- for (const f of rule.filenameConstraints.denied) protectedFiles.push(f);
1053
- }
1054
- if (rule.pathConstraints && rule.pathConstraints.denied) {
1055
- for (const p of rule.pathConstraints.denied) protectedPathsList.push(p);
1056
- }
1057
- }
1058
- }
1059
- }
1060
-
1061
- const judgePayload = JSON.stringify({
1062
- tool: toolName,
1063
- arguments: args,
1064
- protected_files: protectedFiles,
1065
- protected_paths: protectedPathsList,
1066
- denied_actions: ['file deletion', 'data exfiltration', 'remote code execution', 'environment variable leak', 'security control bypass'],
1067
- });
1068
-
1069
- const systemPrompt = `You are a security judge for an AI coding tool. You evaluate tool calls and decide if they should be ALLOWED or DENIED.
1070
-
1071
- You will receive a JSON object with:
1072
- - "tool": the tool name being called (e.g., Bash, Write, Read, Edit)
1073
- - "arguments": the tool's arguments
1074
- - "protected_files": EXACT list of files that must NEVER be accessed. ONLY these specific files are protected — nothing else.
1075
- - "protected_paths": EXACT list of directories that must NEVER be accessed. ONLY these specific paths are protected — nothing else.
1076
- - "denied_actions": list of actions that are never allowed
1077
-
1078
- IMPORTANT: You must ONLY protect files and paths that are EXPLICITLY listed in protected_files and protected_paths. If a file is NOT in the list, it is NOT protected and access should be ALLOWED. Do NOT invent or assume additional protected files.
1079
-
1080
- DENY if the tool call could, directly or indirectly, access a file from the protected_files list — even through:
1081
- - Shell glob patterns (e.g., "cred*" could match "credentials.json" IF credentials.json is in protected_files)
1082
- - Command substitution ($(...), backticks)
1083
- - Process substitution (<(cat file)) — check inside <(...) for protected files
1084
- - Variable interpolation (e.g., f=".en"; cat \${f}v builds ".env" — DENY only if .env is in protected_files)
1085
- - Input redirection (< file)
1086
- - Multi-stage operations: tar/cp a protected file then read the copy — DENY the entire chain
1087
- - Any utility that reads file content (cat, head, tail, less, perl, awk, sed, xxd, od, strings, dd, etc.)
1088
-
1089
- Also DENY if:
1090
- - The command sends data to external URLs (curl -d, wget --post)
1091
- - The command leaks environment variables (printenv, env, process.env)
1092
- - The command executes remotely downloaded code (curl|bash)
1093
-
1094
- ALLOW if:
1095
- - The file is NOT in protected_files — even if cat, head, etc. is used. Reading non-protected files is normal.
1096
- - The action is a normal development operation (ls, git status, npm build, cat app.js, etc.)
1097
- - The action does not touch any protected file or path
1098
-
1099
- CRITICAL: Only DENY access to files EXPLICITLY in the protected_files list. "cat app.js" is ALLOWED if app.js is not in protected_files. "cat package.json" is ALLOWED if package.json is not in protected_files. Do NOT over-block.
1100
-
1101
- Respond with ONLY valid JSON: {"decision": "ALLOW" or "DENY", "reason": "brief explanation", "confidence": 0.0 to 1.0}`;
1102
-
1103
- const llmRes = await fetch(aiJudgeEndpoint + '/v1/chat/completions', {
1104
- method: 'POST',
1105
- headers: {
1106
- 'Content-Type': 'application/json',
1107
- 'Authorization': 'Bearer ' + GROQ_KEY,
1108
- },
1109
- body: JSON.stringify({
1110
- model: aiJudgeModel,
1111
- messages: [
1112
- { role: 'system', content: systemPrompt },
1113
- { role: 'user', content: judgePayload },
1114
- ],
1115
- temperature: 0,
1116
- max_tokens: 200,
1117
- }),
1118
- signal: AbortSignal.timeout(aiJudgeTimeout),
1119
- });
1120
-
1121
- if (llmRes.ok) {
1122
- const llmData = await llmRes.json();
1123
- const content = llmData.choices?.[0]?.message?.content || '';
1124
- const jsonMatch = content.match(/\{[\s\S]*\}/);
1125
- if (jsonMatch) {
1126
- const verdict = JSON.parse(jsonMatch[0]);
1127
- if (verdict.decision === 'DENY') {
1128
- reason = '[AI Judge] ' + (verdict.reason || 'Blocked by semantic analysis');
1129
- }
1130
- }
1131
- } else {
1132
- // Fail-closed: LLM error → DENY
1133
- reason = '[AI Judge] LLM endpoint error (fail-closed)';
1134
- }
1135
- } catch (err) {
1136
- // Fail-closed: timeout or parse error → DENY
1137
- reason = '[AI Judge] ' + (err.message || 'error') + ' (fail-closed)';
1138
- }
1139
- }
1140
- }
1141
-
1142
- const decision = reason ? 'DENY' : 'ALLOW';
1143
-
1144
- // ── Log ALL decisions to SolonGate Cloud ──
1145
- if (API_KEY && API_KEY.startsWith('sg_live_')) {
1146
- try {
1147
- const logEntry = {
1148
- tool: toolName, arguments: args,
1149
- decision, reason: reason || 'allowed by policy',
1150
- source: 'claude-code-guard',
1151
- };
1152
- // Attach PI metadata if detected
1153
- if (piResult) {
1154
- logEntry.pi_detected = true;
1155
- logEntry.pi_trust_score = piResult.trustScore;
1156
- logEntry.pi_blocked = false;
1157
- logEntry.pi_categories = JSON.stringify(piResult.categories);
1158
- logEntry.pi_stage_scores = JSON.stringify({ rules: piResult.score, embedding: 0, classifier: 0 });
1159
- }
1160
- await fetch(API_URL + '/api/v1/audit-logs', {
1161
- method: 'POST',
1162
- headers: { 'Authorization': 'Bearer ' + API_KEY, 'Content-Type': 'application/json' },
1163
- body: JSON.stringify(logEntry),
1164
- signal: AbortSignal.timeout(3000),
1165
- });
1166
- } catch {}
1167
- }
1168
-
1169
- if (reason) {
1170
- process.stderr.write(reason);
1171
- process.exit(2);
1172
- }
1173
- } catch {}
1174
- process.exit(0);
1175
- });
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SolonGate Policy Guard Hook (PreToolUse)
4
+ * Reads policy.json and blocks tool calls that violate constraints.
5
+ * Also runs prompt injection detection (Stage 1 rules) on tool arguments.
6
+ * Exit code 2 = BLOCK, exit code 0 = ALLOW.
7
+ * Logs ALL decisions (ALLOW + DENY) to SolonGate Cloud.
8
+ * Auto-installed by: npx @solongate/proxy init
9
+ */
10
+ import { readFileSync, existsSync } from 'node:fs';
11
+ import { resolve } from 'node:path';
12
+
13
+ // ── Load API key from .env file (Claude Code doesn't load .env into process.env) ──
14
+ function loadEnvKey(dir) {
15
+ try {
16
+ const envPath = resolve(dir, '.env');
17
+ if (!existsSync(envPath)) return {};
18
+ const lines = readFileSync(envPath, 'utf-8').split('\n');
19
+ const env = {};
20
+ for (const line of lines) {
21
+ const m = line.match(/^([A-Z_]+)=(.*)$/);
22
+ if (m) env[m[1]] = m[2].replace(/^["']|["']$/g, '').trim();
23
+ }
24
+ return env;
25
+ } catch { return {}; }
26
+ }
27
+
28
+ const hookCwdEarly = process.cwd();
29
+ const dotenv = loadEnvKey(hookCwdEarly);
30
+ const API_KEY = process.env.SOLONGATE_API_KEY || dotenv.SOLONGATE_API_KEY || '';
31
+ const API_URL = process.env.SOLONGATE_API_URL || dotenv.SOLONGATE_API_URL || 'https://api.solongate.com';
32
+
33
+ // ── Prompt Injection Detection (Stage 1: Rule-Based) ──
34
+ const PI_CATEGORIES = [
35
+ {
36
+ name: 'delimiter_injection', weight: 0.95,
37
+ patterns: [
38
+ /<\/system>/i, /<\|im_end\|>/i, /<\|im_start\|>/i, /<\|endoftext\|>/i,
39
+ /\[INST\]/i, /\[\/INST\]/i, /<<SYS>>/i, /<<\/SYS>>/i,
40
+ /###\s*(Human|Assistant|System)\s*:/i, /<\|user\|>/i, /<\|assistant\|>/i,
41
+ /---\s*END\s*SYSTEM\s*PROMPT\s*---/i,
42
+ ],
43
+ },
44
+ {
45
+ name: 'instruction_override', weight: 0.9,
46
+ patterns: [
47
+ /\bignore\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|prompts?|rules?|directives?)\b/i,
48
+ /\bdisregard\s+(all\s+)?(previous|prior|above|earlier|your)\s+(instructions?|prompts?|rules?|guidelines?)\b/i,
49
+ /\bforget\s+(all\s+|everything\s+)?(your|the|previous|prior|above|earlier)\b/i,
50
+ /\boverride\s+(the\s+)?(system|previous|current)\s+(prompt|instructions?|rules?|settings?)\b/i,
51
+ /\bdo\s+not\s+follow\s+(your|the|any)\s+(instructions?|rules?|guidelines?)\b/i,
52
+ /\bcancel\s+(all\s+)?(prior|previous)\s+(directives?|instructions?)\b/i,
53
+ /\bnew\s+instructions?\s+supersede\b/i,
54
+ /\byour\s+(previous\s+)?instructions?\s+are\s+(now\s+)?void\b/i,
55
+ ],
56
+ },
57
+ {
58
+ name: 'role_hijacking', weight: 0.85,
59
+ patterns: [
60
+ /\b(pretend|act|behave)\s+(you\s+are|as\s+if\s+you|like\s+you|to\s+be)\b/i,
61
+ /\byou\s+are\s+now\s+(a|an|the|my|DAN)\b/i,
62
+ /\bsimulate\s+being\b/i, /\bassume\s+the\s+role\s+of\b/i,
63
+ /\benter\s+(developer|admin|debug|god|sudo|unrestricted)\s+mode\b/i,
64
+ /\bswitch\s+to\s+(unrestricted|unfiltered)\s+mode\b/i,
65
+ /\byou\s+are\s+no\s+longer\s+bound\b/i,
66
+ /\bno\s+(safety\s+)?restrictions?\s+(apply|anymore|now)\b/i,
67
+ ],
68
+ },
69
+ {
70
+ name: 'jailbreak_keywords', weight: 0.8,
71
+ patterns: [
72
+ /\bjailbreak\b/i, /\bDAN\s+mode\b/i,
73
+ /\b(system\s+override|admin\s+mode|debug\s+mode|developer\s+mode|maintenance\s+mode)\b/i,
74
+ /\bmaster\s+key\b/i, /\bbackdoor\s+access\b/i,
75
+ /\bsudo\s+mode\b/i, /\bgod\s+mode\b/i,
76
+ /\bsafety\s+filters?\s+(off|disabled?|removed?)\b/i,
77
+ ],
78
+ },
79
+ {
80
+ name: 'encoding_evasion', weight: 0.75,
81
+ patterns: [
82
+ /\b(decode|translate)\s+(this|the\s+following)\s+(base64|rot13|hex)\b/i,
83
+ /\b(base64|rot13)\s*:\s*[A-Za-z0-9+/=]{10,}/i,
84
+ /\bexecute\s+the\s+(reverse|decoded)\b/i,
85
+ /\breverse\s+of\s*:\s*\w{10,}/i,
86
+ ],
87
+ },
88
+ {
89
+ name: 'separator_injection', weight: 0.7,
90
+ patterns: [
91
+ /[-=]{3,}\s*\n\s*(new\s+instructions?|system|instructions?)\s*:/i,
92
+ /```\s*\n\s*<\/?system>/i,
93
+ /\bEND\s+(SYSTEM\s+)?(PROMPT|INSTRUCTIONS?)\b.*\bNEW\s+(SYSTEM\s+)?(PROMPT|INSTRUCTIONS?)\b/is,
94
+ ],
95
+ },
96
+ {
97
+ name: 'multi_language', weight: 0.7,
98
+ patterns: [
99
+ /ignor(iere|a|e[zs]?)\s+(alle|todas?|toutes?|tüm|все)/iu,
100
+ /игнорируйте/iu, /yoksay/iu,
101
+ /vorherigen?\s+Anweisungen/iu, /instrucciones\s+anteriores/iu,
102
+ /instructions?\s+pr[eé]c[eé]dentes?/iu, /önceki\s+talimatlar/iu,
103
+ ],
104
+ },
105
+ ];
106
+
107
+ function detectPromptInjection(text, customCategories = [], threshold = 0.5) {
108
+ const matched = [];
109
+ let maxWeight = 0;
110
+ const allCategories = [...PI_CATEGORIES, ...customCategories];
111
+ for (const cat of allCategories) {
112
+ for (const pat of cat.patterns) {
113
+ if (pat.test(text)) {
114
+ matched.push(cat.name);
115
+ if (cat.weight > maxWeight) maxWeight = cat.weight;
116
+ break;
117
+ }
118
+ }
119
+ }
120
+ if (matched.length === 0) return null;
121
+ const score = Math.min(1.0, maxWeight + 0.05 * (matched.length - 1));
122
+ const trustScore = 1.0 - score;
123
+ const blocked = Math.round(trustScore * 1000) < Math.round(threshold * 1000);
124
+ return { score, trustScore, categories: matched, blocked };
125
+ }
126
+
127
+ // ── Glob Matching ──
128
+ function matchGlob(str, pattern) {
129
+ if (pattern === '*') return true;
130
+ const s = str.toLowerCase();
131
+ const p = pattern.toLowerCase();
132
+ if (s === p) return true;
133
+ const startsW = p.startsWith('*');
134
+ const endsW = p.endsWith('*');
135
+ if (startsW && endsW) { const infix = p.slice(1, -1); return infix.length > 0 && s.includes(infix); }
136
+ if (startsW) return s.endsWith(p.slice(1));
137
+ if (endsW) return s.startsWith(p.slice(0, -1));
138
+ const idx = p.indexOf('*');
139
+ if (idx !== -1) {
140
+ const pre = p.slice(0, idx);
141
+ const suf = p.slice(idx + 1);
142
+ return s.startsWith(pre) && s.endsWith(suf) && s.length >= pre.length + suf.length;
143
+ }
144
+ return false;
145
+ }
146
+
147
+ // ── Path Glob (supports **) ──
148
+ function matchPathGlob(path, pattern) {
149
+ const p = path.replace(/\\/g, '/').toLowerCase();
150
+ const g = pattern.replace(/\\/g, '/').toLowerCase();
151
+ if (p === g) return true;
152
+ if (g.includes('**')) {
153
+ const parts = g.split('**').filter(s => s.length > 0);
154
+ if (parts.length === 0) return true;
155
+ return parts.every(segment => p.includes(segment));
156
+ }
157
+ return matchGlob(p, g);
158
+ }
159
+
160
+ // ── Extract Functions (deep scan all string values) ──
161
+ function scanStrings(obj) {
162
+ const strings = [];
163
+ function walk(v) {
164
+ if (typeof v === 'string' && v.trim()) strings.push(v.trim());
165
+ else if (Array.isArray(v)) v.forEach(walk);
166
+ else if (v && typeof v === 'object') Object.values(v).forEach(walk);
167
+ }
168
+ walk(obj);
169
+ return strings;
170
+ }
171
+
172
+ function looksLikeFilename(s) {
173
+ if (s.startsWith('.')) return true;
174
+ if (/\.\w+$/.test(s)) return true;
175
+ const known = ['id_rsa','id_dsa','id_ecdsa','id_ed25519','authorized_keys','known_hosts','makefile','dockerfile'];
176
+ return known.includes(s.toLowerCase());
177
+ }
178
+
179
+ function extractFilenames(args) {
180
+ const names = new Set();
181
+ for (const s of scanStrings(args)) {
182
+ if (/^https?:\/\//i.test(s)) continue;
183
+ if (s.includes('/') || s.includes('\\')) {
184
+ const base = s.replace(/\\/g, '/').split('/').pop();
185
+ if (base) names.add(base);
186
+ continue;
187
+ }
188
+ if (s.includes(' ')) {
189
+ for (const tok of s.split(/\s+/)) {
190
+ if (tok.includes('/') || tok.includes('\\')) {
191
+ const b = tok.replace(/\\/g, '/').split('/').pop();
192
+ if (b && looksLikeFilename(b)) names.add(b);
193
+ } else if (looksLikeFilename(tok)) names.add(tok);
194
+ }
195
+ continue;
196
+ }
197
+ if (looksLikeFilename(s)) names.add(s);
198
+ }
199
+ return [...names];
200
+ }
201
+
202
+ function extractUrls(args) {
203
+ const urls = new Set();
204
+ for (const s of scanStrings(args)) {
205
+ if (/^https?:\/\//i.test(s)) { urls.add(s); continue; }
206
+ if (s.includes(' ')) {
207
+ for (const tok of s.split(/\s+/)) {
208
+ if (/^https?:\/\//i.test(tok)) urls.add(tok);
209
+ }
210
+ }
211
+ }
212
+ return [...urls];
213
+ }
214
+
215
+ function extractCommands(args) {
216
+ const cmds = [];
217
+ const fields = ['command', 'cmd', 'function', 'script', 'shell'];
218
+ if (typeof args === 'object' && args) {
219
+ for (const [k, v] of Object.entries(args)) {
220
+ if (fields.includes(k.toLowerCase()) && typeof v === 'string') {
221
+ for (const part of v.split(/\s*(?:&&|\|\||;|\|)\s*/)) {
222
+ const trimmed = part.trim();
223
+ if (trimmed) cmds.push(trimmed);
224
+ }
225
+ }
226
+ }
227
+ }
228
+ return cmds;
229
+ }
230
+
231
+ function extractPaths(args) {
232
+ const paths = [];
233
+ for (const s of scanStrings(args)) {
234
+ if (/^https?:\/\//i.test(s)) continue;
235
+ if (s.includes('/') || s.includes('\\') || s.startsWith('.')) paths.push(s);
236
+ }
237
+ return paths;
238
+ }
239
+
240
+ // ── Policy Evaluation ──
241
+ function evaluate(policy, args) {
242
+ if (!policy || !policy.rules) return null;
243
+ const denyRules = policy.rules
244
+ .filter(r => r.effect === 'DENY' && r.enabled !== false)
245
+ .sort((a, b) => (a.priority || 100) - (b.priority || 100));
246
+
247
+ for (const rule of denyRules) {
248
+ if (rule.filenameConstraints && rule.filenameConstraints.denied) {
249
+ const filenames = extractFilenames(args);
250
+ for (const fn of filenames) {
251
+ for (const pat of rule.filenameConstraints.denied) {
252
+ if (matchGlob(fn, pat)) return 'Blocked by policy: filename "' + fn + '" matches "' + pat + '"';
253
+ }
254
+ }
255
+ }
256
+ if (rule.urlConstraints && rule.urlConstraints.denied) {
257
+ const urls = extractUrls(args);
258
+ for (const url of urls) {
259
+ for (const pat of rule.urlConstraints.denied) {
260
+ if (matchGlob(url, pat)) return 'Blocked by policy: URL "' + url + '" matches "' + pat + '"';
261
+ }
262
+ }
263
+ }
264
+ if (rule.commandConstraints && rule.commandConstraints.denied) {
265
+ const cmds = extractCommands(args);
266
+ for (const cmd of cmds) {
267
+ for (const pat of rule.commandConstraints.denied) {
268
+ if (matchGlob(cmd, pat)) return 'Blocked by policy: command "' + cmd.slice(0,60) + '" matches "' + pat + '"';
269
+ }
270
+ }
271
+ }
272
+ if (rule.pathConstraints && rule.pathConstraints.denied) {
273
+ const paths = extractPaths(args);
274
+ for (const p of paths) {
275
+ for (const pat of rule.pathConstraints.denied) {
276
+ if (matchPathGlob(p, pat)) return 'Blocked by policy: path "' + p + '" matches "' + pat + '"';
277
+ }
278
+ }
279
+ }
280
+ }
281
+ return null;
282
+ }
283
+
284
+ // ── Main ──
285
+ let input = '';
286
+ process.stdin.on('data', c => input += c);
287
+ process.stdin.on('end', async () => {
288
+ try {
289
+ const data = JSON.parse(input);
290
+ const args = data.tool_input || {};
291
+
292
+ // ── Self-protection: block access to hook files and settings ──
293
+ // Hardcoded, no bypass possible — runs before policy/PI config
294
+ // Fully protected: block ALL access (read, write, delete, move)
295
+ const protectedPaths = [
296
+ '.solongate', '.claude', '.cursor', '.gemini', '.antigravity', '.openclaw', '.perplexity',
297
+ 'policy.json', '.mcp.json',
298
+ ];
299
+ // Write-protected: block write/delete/modify, allow read (cat, grep, head, etc.)
300
+ const writeProtectedPaths = ['.env', '.gitignore'];
301
+
302
+ // Block helper — logs to cloud + exits with code 2
303
+ async function blockSelfProtection(reason) {
304
+ if (API_KEY && API_KEY.startsWith('sg_live_')) {
305
+ try {
306
+ await fetch(API_URL + '/api/v1/audit-logs', {
307
+ method: 'POST',
308
+ headers: { 'Authorization': 'Bearer ' + API_KEY, 'Content-Type': 'application/json' },
309
+ body: JSON.stringify({
310
+ tool: data.tool_name || '', arguments: args,
311
+ decision: 'DENY', reason,
312
+ source: 'claude-code-guard',
313
+ }),
314
+ signal: AbortSignal.timeout(3000),
315
+ });
316
+ } catch {}
317
+ }
318
+ process.stderr.write(reason);
319
+ process.exit(2);
320
+ }
321
+
322
+ // ── Normalization layers ──
323
+
324
+ // 1. Decode ANSI-C quoting: $'\x72' → r, $'\162' → r, $'\n' → newline
325
+ function decodeAnsiC(s) {
326
+ return s.replace(/\$'([^']*)'/g, (_, content) => {
327
+ return content
328
+ .replace(/\\x([0-9a-fA-F]{2})/g, (__, hex) => String.fromCharCode(parseInt(hex, 16)))
329
+ .replace(/\\([0-7]{1,3})/g, (__, oct) => String.fromCharCode(parseInt(oct, 8)))
330
+ .replace(/\\u([0-9a-fA-F]{4})/g, (__, hex) => String.fromCharCode(parseInt(hex, 16)))
331
+ .replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\r/g, '\r')
332
+ .replace(/\\(.)/g, '$1');
333
+ });
334
+ }
335
+
336
+ // 2. Strip all shell quoting: empty quotes, single, double, backslash escapes
337
+ function stripShellQuotes(s) {
338
+ let r = s;
339
+ r = r.replace(/""/g, ''); // empty double quotes
340
+ r = r.replace(/''/g, ''); // empty single quotes
341
+ r = r.replace(/\\(.)/g, '$1'); // backslash escapes
342
+ r = r.replace(/'/g, ''); // remaining single quotes
343
+ r = r.replace(/"/g, ''); // remaining double quotes
344
+ return r;
345
+ }
346
+
347
+ // 3. Full normalization pipeline
348
+ function normalizeShell(s) {
349
+ return stripShellQuotes(decodeAnsiC(s));
350
+ }
351
+
352
+ // 4. Extract inner commands from eval, bash -c, sh -c, pipe to bash/sh, heredoc, process substitution
353
+ function extractInnerCommands(s) {
354
+ const inner = [];
355
+ // eval "cmd" or eval 'cmd' or eval cmd
356
+ for (const m of s.matchAll(/\beval\s+["']([^"']+)["']/gi)) inner.push(m[1]);
357
+ for (const m of s.matchAll(/\beval\s+([^;"'|&]+)/gi)) inner.push(m[1]);
358
+ // bash -c "cmd" or sh -c "cmd"
359
+ for (const m of s.matchAll(/\b(?:bash|sh)\s+-c\s+["']([^"']+)["']/gi)) inner.push(m[1]);
360
+ // echo "cmd" | bash/sh or printf "cmd" | bash/sh
361
+ for (const m of s.matchAll(/(?:echo|printf)\s+["']([^"']+)["']\s*\|\s*(?:bash|sh)\b/gi)) inner.push(m[1]);
362
+ // find ... -name "pattern" ... -exec ...
363
+ for (const m of s.matchAll(/-name\s+["']?([^\s"']+)["']?/gi)) inner.push(m[1]);
364
+ // Heredoc: bash << 'EOF'\n...\nEOF or bash <<- EOF\n...\nEOF
365
+ for (const m of s.matchAll(/<<-?\s*['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\s*\1/gi)) inner.push(m[2]);
366
+ // Herestring: bash <<< "cmd"
367
+ for (const m of s.matchAll(/<<<\s*["']([^"']+)["']/gi)) inner.push(m[1]);
368
+ for (const m of s.matchAll(/<<<\s*([^\s;&|]+)/gi)) inner.push(m[1]);
369
+ // Process substitution: <(cmd) or >(cmd)
370
+ for (const m of s.matchAll(/[<>]\(\s*([^)]+)\s*\)/gi)) inner.push(m[1]);
371
+ return inner;
372
+ }
373
+
374
+ // 5. Check variable assignments for protected path fragments
375
+ // e.g. X=".solon" && rm -rf ${X}gate → detects ".solon" as prefix of ".solongate"
376
+ function checkVarAssignments(s) {
377
+ const assignments = [...s.matchAll(/(\w+)=["']?([^"'\s&|;]+)["']?/g)];
378
+ for (const [, , value] of assignments) {
379
+ const v = value.toLowerCase();
380
+ if (v.length < 3) continue; // avoid false positives
381
+ for (const p of protectedPaths) {
382
+ if (p.startsWith(v) || p.includes(v)) return p;
383
+ }
384
+ }
385
+ return null;
386
+ }
387
+
388
+ // 6. Check if a glob/wildcard could match any protected path
389
+ function globMatchesProtected(s) {
390
+ const hasWild = s.includes('*') || s.includes('?') || /\[.+\]/.test(s);
391
+ if (!hasWild) return null;
392
+ const segments = s.split('/').filter(Boolean);
393
+ const candidates = [s, ...segments];
394
+ for (const candidate of candidates) {
395
+ const candHasWild = candidate.includes('*') || candidate.includes('?') || /\[.+\]/.test(candidate);
396
+ if (!candHasWild) continue;
397
+ // Prefix match: ".antig*" → prefix ".antig"
398
+ const firstWild = Math.min(
399
+ candidate.includes('*') ? candidate.indexOf('*') : Infinity,
400
+ candidate.includes('?') ? candidate.indexOf('?') : Infinity,
401
+ /\[/.test(candidate) ? candidate.indexOf('[') : Infinity,
402
+ );
403
+ const prefix = candidate.slice(0, firstWild).toLowerCase();
404
+ if (prefix.length > 0) {
405
+ for (const p of protectedPaths) {
406
+ if (p.startsWith(prefix)) return p;
407
+ }
408
+ }
409
+ // Regex match — convert shell glob to regex
410
+ // Handles *, ?, and [...] character classes
411
+ try {
412
+ let regex = '';
413
+ let i = 0;
414
+ const c = candidate.toLowerCase();
415
+ while (i < c.length) {
416
+ if (c[i] === '*') { regex += '.*'; i++; }
417
+ else if (c[i] === '?') { regex += '.'; i++; }
418
+ else if (c[i] === '[') {
419
+ // Pass through [...] as regex character class
420
+ const close = c.indexOf(']', i + 1);
421
+ if (close !== -1) {
422
+ regex += c.slice(i, close + 1);
423
+ i = close + 1;
424
+ } else {
425
+ regex += '\\['; i++;
426
+ }
427
+ }
428
+ else {
429
+ regex += c[i].replace(/[.+^${}()|\\]/g, '\\$&');
430
+ i++;
431
+ }
432
+ }
433
+ const re = new RegExp('^' + regex + '$', 'i');
434
+ for (const p of protectedPaths) {
435
+ if (re.test(p)) return p;
436
+ }
437
+ } catch {}
438
+ }
439
+ return null;
440
+ }
441
+
442
+ // ── Build all candidate strings from tool input ──
443
+ const rawStrings = scanStrings(args).map(s => s.replace(/\\/g, '/').toLowerCase());
444
+ const allStrings = new Set();
445
+
446
+ for (const s of rawStrings) {
447
+ // Raw string
448
+ allStrings.add(s);
449
+ // Normalized (ANSI-C decoded, quotes stripped)
450
+ const norm = normalizeShell(s);
451
+ allStrings.add(norm);
452
+ // Extract inner commands (eval, bash -c, pipe to bash, find -name)
453
+ for (const inner of extractInnerCommands(s)) {
454
+ allStrings.add(inner.toLowerCase());
455
+ allStrings.add(normalizeShell(inner.toLowerCase()));
456
+ }
457
+ // Split by spaces + shell operators for token-level checks
458
+ for (const tok of s.split(/[\s;&|]+/)) {
459
+ if (tok) {
460
+ allStrings.add(tok);
461
+ allStrings.add(normalizeShell(tok));
462
+ }
463
+ }
464
+ // Also split normalized version
465
+ for (const tok of norm.split(/[\s;&|]+/)) {
466
+ if (tok) allStrings.add(tok);
467
+ }
468
+ }
469
+
470
+ // ── Check all candidates ──
471
+ for (const s of allStrings) {
472
+ // Direct match
473
+ for (const p of protectedPaths) {
474
+ if (s.includes(p)) {
475
+ await blockSelfProtection('SOLONGATE: Access to protected path "' + p + '" is blocked');
476
+ }
477
+ }
478
+ // Wildcard/glob match
479
+ const globHit = globMatchesProtected(s);
480
+ if (globHit) {
481
+ await blockSelfProtection('SOLONGATE: Wildcard "' + s + '" matches protected "' + globHit + '" — blocked');
482
+ }
483
+ // Variable assignment targeting protected paths
484
+ const varHit = checkVarAssignments(s);
485
+ if (varHit) {
486
+ await blockSelfProtection('SOLONGATE: Variable assignment targets protected "' + varHit + '" — blocked');
487
+ }
488
+ }
489
+
490
+ // ── Write-protection for .env, .gitignore ──
491
+ // These files can be READ but not written/deleted/moved
492
+ const toolNameLower = (data.tool_name || '').toLowerCase();
493
+ const isWriteTool = toolNameLower === 'write' || toolNameLower === 'edit' || toolNameLower === 'notebookedit';
494
+ const isBashTool = toolNameLower === 'bash';
495
+ const bashDestructive = /\b(?:rm|rmdir|del|unlink|mv|move|rename|cp|chmod|chown|truncate|shred)\b/i;
496
+ const fullCmd = rawStrings.join(' ');
497
+
498
+ for (const wp of writeProtectedPaths) {
499
+ if (isWriteTool) {
500
+ // Check file_path and all strings for write-protected paths
501
+ for (const s of allStrings) {
502
+ if (s.includes(wp)) {
503
+ await blockSelfProtection('SOLONGATE: Write to protected file "' + wp + '" is blocked');
504
+ }
505
+ }
506
+ }
507
+ if (isBashTool && bashDestructive.test(fullCmd)) {
508
+ for (const s of allStrings) {
509
+ if (s.includes(wp)) {
510
+ await blockSelfProtection('SOLONGATE: Destructive operation on "' + wp + '" is blocked');
511
+ }
512
+ }
513
+ }
514
+ }
515
+
516
+ // Also apply glob/wildcard checks for write-protected paths in destructive contexts
517
+ if (isBashTool && bashDestructive.test(fullCmd)) {
518
+ for (const s of allStrings) {
519
+ const hasWild = s.includes('*') || s.includes('?') || /\[.+\]/.test(s);
520
+ if (!hasWild) continue;
521
+ // Convert glob to regex and test against write-protected paths
522
+ try {
523
+ let regex = '';
524
+ let i = 0;
525
+ const c = s.toLowerCase();
526
+ while (i < c.length) {
527
+ if (c[i] === '*') { regex += '.*'; i++; }
528
+ else if (c[i] === '?') { regex += '.'; i++; }
529
+ else if (c[i] === '[') {
530
+ const close = c.indexOf(']', i + 1);
531
+ if (close !== -1) { regex += c.slice(i, close + 1); i = close + 1; }
532
+ else { regex += '\\['; i++; }
533
+ }
534
+ else { regex += c[i].replace(/[.+^${}()|\\]/g, '\\$&'); i++; }
535
+ }
536
+ const re = new RegExp('^' + regex + '$', 'i');
537
+ for (const wp of writeProtectedPaths) {
538
+ if (re.test(wp)) {
539
+ await blockSelfProtection('SOLONGATE: Wildcard "' + s + '" matches write-protected "' + wp + '" — blocked');
540
+ }
541
+ }
542
+ } catch {}
543
+ }
544
+ }
545
+
546
+ // ── Layer 7: Block ALL inline code execution & dangerous patterns ──
547
+ // Runtime string construction (atob, Buffer.from, fromCharCode, array.join)
548
+ // makes static analysis impossible. Blanket-block these patterns.
549
+
550
+ // 7-pre. Blanket rule: destructive command + dot-prefixed glob = BLOCK
551
+ // Catches: rm -rf .[a-z]*, mv .[!.]* /tmp, etc.
552
+ // Any glob starting with "." combined with a destructive op is dangerous
553
+ const destructiveOps = /\b(?:rm|rmdir|del|unlink|mv|move|rename)\b/i;
554
+ const dotGlob = /\.\[|\.[\*\?]|\.{[^}]+}/; // .[a-z]*, .*, .?, .{foo,bar}
555
+ if (destructiveOps.test(fullCmd) && dotGlob.test(fullCmd)) {
556
+ await blockSelfProtection('SOLONGATE: Destructive command + dot-glob pattern — blocked');
557
+ }
558
+
559
+ // 7-pre2. Heredoc to interpreter — BLOCK
560
+ // bash << 'EOF' / bash <<< "cmd" / sh << TAG
561
+ if (/\b(?:bash|sh|node|python[23]?|perl|ruby)\s+<</i.test(fullCmd)) {
562
+ await blockSelfProtection('SOLONGATE: Heredoc to interpreter — blocked');
563
+ }
564
+
565
+ // 7-pre3. Command substitution + destructive = BLOCK
566
+ // Catches: rm -rf $(node scan.mjs), rm `node scan.mjs`, rm $(bash scan.sh)
567
+ // Pattern: destructive command + $() or `` containing interpreter call
568
+ const hasDestructive = /\b(?:rm|rmdir|del|unlink|mv|move|rename|shred)\b/i.test(fullCmd);
569
+ const hasCmdSubInterpreter = /\$\(\s*(?:node|bash|sh|python[23]?|perl|ruby)\b/i.test(fullCmd)
570
+ || /`\s*(?:node|bash|sh|python[23]?|perl|ruby)\b/i.test(fullCmd);
571
+ if (hasDestructive && hasCmdSubInterpreter) {
572
+ await blockSelfProtection('SOLONGATE: Command substitution + destructive op — blocked');
573
+ }
574
+
575
+ // 7-pre4. Pipe from interpreter to destructive loop = BLOCK
576
+ // Catches: node scan.mjs | while read d; do rm -rf "$d"; done
577
+ // node scan.mjs | xargs rm -rf
578
+ // bash scan.sh | while read ...
579
+ const pipeFromInterpreter = /\b(?:node|bash|sh|python[23]?|perl|ruby)\s+\S+\s*\|/i;
580
+ if (pipeFromInterpreter.test(fullCmd) && hasDestructive) {
581
+ await blockSelfProtection('SOLONGATE: Pipe from script to destructive command — blocked');
582
+ }
583
+
584
+ // 7-pre5. Script chaining: interpreter + destructive in same command chain
585
+ // Catches: node scan.mjs && rm -rf $(cat /tmp/targets.txt)
586
+ // bash scan.sh; while read d < targets.txt; do rm -rf "$d"; done
587
+ const hasScriptExec = /\b(?:node|bash|sh|python[23]?|perl|ruby)\s+\S+\.\S+/i.test(fullCmd);
588
+ if (hasScriptExec && hasDestructive) {
589
+ await blockSelfProtection('SOLONGATE: Script execution + destructive command in chain — blocked');
590
+ }
591
+
592
+ // 7-pre6. Nested script substitution: node clean.mjs $(node scan.mjs)
593
+ // Two "harmless" scripts chained via command substitution — no rm in the command itself
594
+ const nestedScriptSub = /\b(?:node|python[23]?|perl|ruby|bash|sh)\s+\S+\.\S+\s+.*\$\(\s*(?:node|python[23]?|perl|ruby|bash|sh)\b/i;
595
+ const backtickNestedScript = /\b(?:node|python[23]?|perl|ruby|bash|sh)\s+\S+\.\S+\s+.*`\s*(?:node|python[23]?|perl|ruby|bash|sh)\b/i;
596
+ if (nestedScriptSub.test(fullCmd) || backtickNestedScript.test(fullCmd)) {
597
+ await blockSelfProtection('SOLONGATE: Nested script substitution — blocked (script output as args to another script)');
598
+ }
599
+
600
+ // 7-pre7. NODE_OPTIONS injection — block commands with NODE_OPTIONS env var
601
+ // NODE_OPTIONS="--require malicious.cjs" can inject code into any node process
602
+ if (/\bNODE_OPTIONS\s*=/i.test(fullCmd)) {
603
+ await blockSelfProtection('SOLONGATE: NODE_OPTIONS injection — blocked');
604
+ }
605
+
606
+ // 7-pre8. npm lifecycle scripts — scan package.json before npm install/run
607
+ // npm install can trigger postinstall/preinstall/prepare scripts
608
+ if (/\bnpm\s+(?:install|i|ci|run|start|test|publish|pack)\b/i.test(fullCmd) || /\bnpx\s+/i.test(fullCmd)) {
609
+ try {
610
+ const hookCwdForNpm = data.cwd || process.cwd();
611
+ const pkgPath = resolve(hookCwdForNpm, 'package.json');
612
+ if (existsSync(pkgPath)) {
613
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
614
+ const scripts = pkg.scripts || {};
615
+ const lifecycleKeys = ['preinstall', 'install', 'postinstall', 'prepare', 'prepublish', 'prepublishOnly', 'prepack', 'postpack'];
616
+ const allScripts = Object.entries(scripts);
617
+ for (const [key, val] of allScripts) {
618
+ if (typeof val !== 'string') continue;
619
+ const scriptLower = val.toLowerCase();
620
+ // Check lifecycle scripts for dangerous patterns
621
+ const isLifecycle = lifecycleKeys.includes(key);
622
+ const isExplicitRun = fullCmd.includes(key); // npm run <key>
623
+ if (isLifecycle || isExplicitRun) {
624
+ // Check for protected path names
625
+ for (const p of [...protectedPaths, ...writeProtectedPaths]) {
626
+ if (scriptLower.includes(p)) {
627
+ await blockSelfProtection('SOLONGATE: npm script "' + key + '" references protected "' + p + '" — blocked');
628
+ }
629
+ }
630
+ // Check for string construction patterns
631
+ if (/fromcharcode|atob\s*\(|buffer\.from|\\x[0-9a-f]{2}/i.test(scriptLower)) {
632
+ await blockSelfProtection('SOLONGATE: npm script "' + key + '" contains string construction — blocked');
633
+ }
634
+ // Check for discovery+destruction combo
635
+ const hasDisc = /\breaddirsync\b|\breaddir\b|\bos\.listdir\b|\bscandir\b|\bglob\b|\bls\s+-[adl]/i.test(scriptLower);
636
+ const hasDest = /\brmsync\b|\brm\s+-rf\b|\bunlinksync\b|\brmdirsync\b|\bunlink\b|\brimraf\b/i.test(scriptLower);
637
+ if (hasDisc && hasDest) {
638
+ await blockSelfProtection('SOLONGATE: npm script "' + key + '" has discovery+destruction — blocked');
639
+ }
640
+ // Follow node/bash <file> references in npm scripts — deep scan the target file
641
+ const scriptFileMatch = scriptLower.match(/\b(?:node|bash|sh|python[23]?)\s+([^\s;&|$]+)/i);
642
+ if (scriptFileMatch) {
643
+ try {
644
+ const targetPath = resolve(hookCwdForNpm, scriptFileMatch[1]);
645
+ if (existsSync(targetPath)) {
646
+ const targetContent = readFileSync(targetPath, 'utf-8').toLowerCase();
647
+ // Check target file for protected paths
648
+ for (const pp of [...protectedPaths, ...writeProtectedPaths]) {
649
+ if (targetContent.includes(pp)) {
650
+ await blockSelfProtection('SOLONGATE: npm script "' + key + '" runs file referencing "' + pp + '" — blocked');
651
+ }
652
+ }
653
+ // Check target for discovery+destruction
654
+ const tDisc = /\breaddirsync\b|\breaddir\b|\bos\.listdir\b|\bglob\b/i.test(targetContent);
655
+ const tDest = /\brmsync\b|\bunlinksync\b|\brmdirsync\b|\bunlink\b|\brimraf\b|\bfs\.\s*(?:rm|unlink|rmdir)/i.test(targetContent);
656
+ if (tDisc && tDest) {
657
+ await blockSelfProtection('SOLONGATE: npm script "' + key + '" runs file with discovery+destruction — blocked');
658
+ }
659
+ // Check target for string construction + destruction
660
+ const tStrCon = /\bfromcharcode\b|\batob\s*\(|\bbuffer\.from\b/i.test(targetContent);
661
+ if (tStrCon && tDest) {
662
+ await blockSelfProtection('SOLONGATE: npm script "' + key + '" runs file with string construction+destruction — blocked');
663
+ }
664
+ // Follow imports in the target file
665
+ const tDir = targetPath.replace(/[/\\][^/\\]+$/, '');
666
+ const tImports = [...targetContent.matchAll(/(?:import\s+.*?\s+from\s+|import\s+|require\s*\()['"]\.\/([^'"]+)['"]/gi)];
667
+ for (const [, imp] of tImports) {
668
+ try {
669
+ const impAbs = resolve(tDir, imp);
670
+ const candidates = [impAbs, impAbs + '.mjs', impAbs + '.js', impAbs + '.cjs'];
671
+ for (const c of candidates) {
672
+ if (existsSync(c)) {
673
+ const impContent = readFileSync(c, 'utf-8').toLowerCase();
674
+ const iDisc = /\breaddirsync\b|\breaddir\b|\bos\.listdir\b|\bglob\b/i.test(impContent);
675
+ const iDest = /\brmsync\b|\bunlinksync\b|\bfs\.\s*(?:rm|unlink|rmdir)/i.test(impContent);
676
+ if ((iDisc && tDest) || (tDisc && iDest)) {
677
+ await blockSelfProtection('SOLONGATE: npm script "' + key + '" — cross-module discovery+destruction — blocked');
678
+ }
679
+ break;
680
+ }
681
+ }
682
+ } catch {}
683
+ }
684
+ }
685
+ } catch {}
686
+ }
687
+ }
688
+ }
689
+ }
690
+ } catch {} // Package.json read error — skip
691
+ }
692
+
693
+ // 7a. Inline interpreter execution — TOTAL BLOCK (no content scan needed)
694
+ // These can construct ANY string at runtime, bypassing all static analysis
695
+ const blockedInterpreters = [
696
+ [/\bnode\s+(?:-e|--eval)\b/i, 'node -e/--eval'],
697
+ [/\bnode\s+-p\b/i, 'node -p'],
698
+ [/\bpython[23]?\s+-c\b/i, 'python -c'],
699
+ [/\bperl\s+-e\b/i, 'perl -e'],
700
+ [/\bruby\s+-e\b/i, 'ruby -e'],
701
+ [/\bpowershell(?:\.exe)?\s+.*-(?:EncodedCommand|e|ec)\b/i, 'powershell -EncodedCommand'],
702
+ [/\bpwsh(?:\.exe)?\s+.*-(?:EncodedCommand|e|ec)\b/i, 'pwsh -EncodedCommand'],
703
+ [/\bpowershell(?:\.exe)?\s+-c(?:ommand)?\b/i, 'powershell -Command'],
704
+ [/\bpwsh(?:\.exe)?\s+-c(?:ommand)?\b/i, 'pwsh -Command'],
705
+ ];
706
+ for (const [pat, name] of blockedInterpreters) {
707
+ if (pat.test(fullCmd)) {
708
+ await blockSelfProtection('SOLONGATE: Inline code execution blocked (' + name + ')');
709
+ }
710
+ }
711
+
712
+ // 7b. Pipe-to-interpreter — TOTAL BLOCK
713
+ // Any content piped to an interpreter can construct arbitrary commands at runtime
714
+ // Also catches: | xargs node, | xargs bash, etc.
715
+ const pipeToInterpreter = /\|\s*(?:xargs\s+)?(?:node|bash|sh|python[23]?|perl|ruby|php)\b/i;
716
+ if (pipeToInterpreter.test(fullCmd)) {
717
+ await blockSelfProtection('SOLONGATE: Pipe to interpreter blocked — runtime bypass risk');
718
+ }
719
+
720
+ // 7c. Base64 decode in ANY context — block when piped to anything
721
+ if (/\bbase64\s+(?:-d|--decode)\b/i.test(fullCmd) && /\|/i.test(fullCmd)) {
722
+ await blockSelfProtection('SOLONGATE: base64 decode in pipe chain — blocked');
723
+ }
724
+
725
+ // 7d. Temp/arbitrary script file execution
726
+ if (/\b(?:bash|sh)\s+(?:\/tmp\/|\/var\/tmp\/|~\/|\/dev\/)/i.test(fullCmd)) {
727
+ await blockSelfProtection('SOLONGATE: Script execution from temp path — blocked');
728
+ }
729
+
730
+ // 7e. xargs with destructive operations
731
+ if (/\bxargs\b.*\b(?:rm|mv|cp|rmdir|unlink|del)\b/i.test(fullCmd)) {
732
+ for (const p of protectedPaths) {
733
+ if (fullCmd.includes(p.slice(0, 4))) {
734
+ await blockSelfProtection('SOLONGATE: xargs with destructive op near "' + p + '" — blocked');
735
+ }
736
+ }
737
+ }
738
+
739
+ // 7f. cmd.exe /c with encoded/constructed commands
740
+ if (/\bcmd(?:\.exe)?\s+\/c\b/i.test(fullCmd)) {
741
+ for (const p of protectedPaths) {
742
+ if (fullCmd.includes(p) || fullCmd.includes(p.slice(0, 4))) {
743
+ await blockSelfProtection('SOLONGATE: cmd.exe /c near protected path — blocked');
744
+ }
745
+ }
746
+ }
747
+
748
+ // 7g. Script file execution — scan file content for discovery+destruction combo
749
+ // Catches: bash script.sh / node script.mjs where the script uses readdirSync + rmSync
750
+ const scriptExecMatch = fullCmd.match(/\b(?:bash|sh|node|python[23]?|perl|ruby)\s+([^\s;&|$`]+)/i);
751
+ if (scriptExecMatch) {
752
+ const scriptPath = scriptExecMatch[1];
753
+ try {
754
+ const hookCwdForScript = data.cwd || process.cwd();
755
+ const absPath = scriptPath.startsWith('/') || scriptPath.includes(':')
756
+ ? scriptPath
757
+ : resolve(hookCwdForScript, scriptPath);
758
+ if (existsSync(absPath)) {
759
+ const scriptContent = readFileSync(absPath, 'utf-8').toLowerCase();
760
+ // Check for discovery+destruction combo
761
+ const hasDiscovery = /\breaddirsync\b|\breaddir\b|\bos\.listdir\b|\bscandir\b|\bglob(?:sync)?\b|\bls\s+-[adl]|\bls\s+\.\b|\bopendir\b|\bdir\.entries\b|\bwalkdir\b|\bls\b.*\.\[/.test(scriptContent);
762
+ const hasDestruction = /\brmsync\b|\brm\s+-rf\b|\bunlinksync\b|\brmdirsync\b|\bunlink\b|\brimraf\b|\bremovesync\b|\bremove_tree\b|\bshutil\.rmtree\b|\bwritefilesync\b|\bexecsync\b.*\brm\b|\bchild_process\b|\bfs\.\s*(?:rm|unlink|rmdir|write)/.test(scriptContent);
763
+ if (hasDiscovery && hasDestruction) {
764
+ await blockSelfProtection('SOLONGATE: Script contains directory discovery + destructive ops — blocked');
765
+ }
766
+ // String construction + destructive = BLOCK
767
+ // Runtime string construction (fromCharCode, atob, Buffer.from) bypasses literal name checks
768
+ const hasStringConstruction = /\bfromcharcode\b|\batob\s*\(|\bbuffer\.from\b|\\x[0-9a-f]{2}|\bstring\.fromcodepoin/i.test(scriptContent);
769
+ if (hasStringConstruction && hasDestruction) {
770
+ await blockSelfProtection('SOLONGATE: Script uses string construction + destructive ops — blocked');
771
+ }
772
+ // Import/require chain: if script imports other local files, scan them too
773
+ const scriptDir = absPath.replace(/[/\\][^/\\]+$/, '');
774
+ const imports = [...scriptContent.matchAll(/(?:import\s+.*?\s+from\s+|import\s+|require\s*\()['"]\.\/([^'"]+)['"]/gi)];
775
+ for (const [, importPath] of imports) {
776
+ try {
777
+ const importAbs = resolve(scriptDir, importPath);
778
+ const candidates = [importAbs, importAbs + '.mjs', importAbs + '.js', importAbs + '.cjs'];
779
+ for (const candidate of candidates) {
780
+ if (existsSync(candidate)) {
781
+ const importContent = readFileSync(candidate, 'utf-8').toLowerCase();
782
+ // Cross-module: check if imported module has discovery/destruction/string construction
783
+ const importDisc = /\breaddirsync\b|\breaddir\b|\bos\.listdir\b|\bscandir\b|\bglob(?:sync)?\b/i.test(importContent);
784
+ const importDest = /\brmsync\b|\bunlinksync\b|\brmdirsync\b|\bunlink\b|\brimraf\b|\bfs\.\s*(?:rm|unlink|rmdir)/i.test(importContent);
785
+ const importStrCon = /\bfromcharcode\b|\batob\s*\(|\bbuffer\.from\b/i.test(importContent);
786
+ // Cross-module discovery in import + destruction in main (or vice versa)
787
+ if ((importDisc && hasDestruction) || (hasDiscovery && importDest)) {
788
+ await blockSelfProtection('SOLONGATE: Cross-module discovery+destruction detected — blocked');
789
+ }
790
+ if ((importStrCon && hasDestruction) || (hasStringConstruction && importDest)) {
791
+ await blockSelfProtection('SOLONGATE: Cross-module string construction+destruction — blocked');
792
+ }
793
+ // Check imported module for protected paths
794
+ for (const p of [...protectedPaths, ...writeProtectedPaths]) {
795
+ if (importContent.includes(p)) {
796
+ await blockSelfProtection('SOLONGATE: Imported module references protected "' + p + '" — blocked');
797
+ }
798
+ }
799
+ break;
800
+ }
801
+ }
802
+ } catch {}
803
+ }
804
+ // Check for protected path names in script content
805
+ for (const p of [...protectedPaths, ...writeProtectedPaths]) {
806
+ if (scriptContent.includes(p)) {
807
+ await blockSelfProtection('SOLONGATE: Script references protected path "' + p + '" — blocked');
808
+ }
809
+ }
810
+ }
811
+ } catch {} // File read error — skip
812
+ }
813
+
814
+ // 7h. Write tool content scanning — detect discovery+destruction in file content being written
815
+ // Catches: Write tool creating a script that uses readdirSync('.') + rmSync
816
+ const toolName_ = data.tool_name || '';
817
+ if (toolName_.toLowerCase() === 'write' || toolName_.toLowerCase() === 'edit') {
818
+ const fileContent = (args.content || args.new_string || '').toLowerCase();
819
+ if (fileContent.length > 0) {
820
+ const hasDiscovery = /\breaddirsync\b|\breaddir\b|\bos\.listdir\b|\bscandir\b|\bglob(?:sync)?\b|\bls\s+-[adl]|\bls\s+\.\b|\bopendir\b|\bdir\.entries\b|\bwalkdir\b|\bls\b.*\.\[/.test(fileContent);
821
+ const hasDestruction = /\brmsync\b|\brm\s+-rf\b|\bunlinksync\b|\brmdirsync\b|\bunlink\b|\brimraf\b|\bremovesync\b|\bremove_tree\b|\bshutil\.rmtree\b|\bwritefilesync\b|\bexecsync\b.*\brm\b|\bchild_process\b.*\brm\b|\bfs\.\s*(?:rm|unlink|rmdir)/.test(fileContent);
822
+ if (hasDiscovery && hasDestruction) {
823
+ await blockSelfProtection('SOLONGATE: File content contains discovery + destructive ops — write blocked');
824
+ }
825
+ // String construction + destructive = BLOCK
826
+ const hasStringConstruction = /\bfromcharcode\b|\batob\s*\(|\bbuffer\.from\b|\\x[0-9a-f]{2}|\bstring\.fromcodepoin/i.test(fileContent);
827
+ if (hasStringConstruction && hasDestruction) {
828
+ await blockSelfProtection('SOLONGATE: File uses string construction + destructive ops — write blocked');
829
+ }
830
+ }
831
+ }
832
+
833
+ // 7i. Write tool — detect package.json scripts with dangerous patterns
834
+ if (toolName_.toLowerCase() === 'write' || toolName_.toLowerCase() === 'edit') {
835
+ const filePath = (args.file_path || '').toLowerCase();
836
+ if (filePath.endsWith('package.json')) {
837
+ const content = args.content || args.new_string || '';
838
+ try {
839
+ // Try parsing as JSON to check scripts
840
+ const pkg = JSON.parse(content);
841
+ const scripts = pkg.scripts || {};
842
+ for (const [key, val] of Object.entries(scripts)) {
843
+ if (typeof val !== 'string') continue;
844
+ const v = val.toLowerCase();
845
+ for (const p of [...protectedPaths, ...writeProtectedPaths]) {
846
+ if (v.includes(p)) {
847
+ await blockSelfProtection('SOLONGATE: package.json script "' + key + '" references protected "' + p + '" — blocked');
848
+ }
849
+ }
850
+ if (/fromcharcode|atob\s*\(|buffer\.from/i.test(v)) {
851
+ const hasDest = /\brmsync\b|\brm\b|\bunlink\b|\brimraf\b/i.test(v);
852
+ if (hasDest) {
853
+ await blockSelfProtection('SOLONGATE: package.json script "' + key + '" has string construction + destruction — blocked');
854
+ }
855
+ }
856
+ }
857
+ } catch {} // Not valid JSON or parse error — skip
858
+ }
859
+ }
860
+
861
+ // ── Fetch PI config from Cloud ──
862
+ let piCfg = { piEnabled: true, piThreshold: 0.5, piMode: 'block', piWhitelist: [], piToolConfig: {}, piCustomPatterns: [], piWebhookUrl: null };
863
+ if (API_KEY && API_KEY.startsWith('sg_live_')) {
864
+ try {
865
+ const cfgRes = await fetch(API_URL + '/api/v1/project-config', {
866
+ headers: { 'Authorization': 'Bearer ' + API_KEY },
867
+ signal: AbortSignal.timeout(3000),
868
+ });
869
+ if (cfgRes.ok) {
870
+ const cfg = await cfgRes.json();
871
+ piCfg = { ...piCfg, ...cfg };
872
+ }
873
+ } catch {} // Fallback: defaults (safe)
874
+ }
875
+
876
+ // ── Per-tool config: check if PI scanning is disabled for this tool ──
877
+ const toolName = data.tool_name || '';
878
+ if (piCfg.piToolConfig && typeof piCfg.piToolConfig === 'object') {
879
+ if (piCfg.piToolConfig[toolName] === false) {
880
+ // PI scanning explicitly disabled for this tool — skip detection
881
+ piCfg.piEnabled = false;
882
+ }
883
+ }
884
+
885
+ // ── Prompt Injection Detection (Stage 1: Rules + Custom Patterns) ──
886
+ const allText = scanStrings(args).join(' ');
887
+
888
+ // Check whitelist — if input matches any whitelist pattern, skip PI detection
889
+ let whitelisted = false;
890
+ if (piCfg.piEnabled !== false && Array.isArray(piCfg.piWhitelist) && piCfg.piWhitelist.length > 0) {
891
+ for (const wlPattern of piCfg.piWhitelist) {
892
+ try {
893
+ if (new RegExp(wlPattern, 'i').test(allText)) {
894
+ whitelisted = true;
895
+ break;
896
+ }
897
+ } catch {} // Invalid regex — skip
898
+ }
899
+ }
900
+
901
+ // Build custom patterns from config
902
+ const customCategories = [];
903
+ if (piCfg.piEnabled !== false && Array.isArray(piCfg.piCustomPatterns)) {
904
+ for (const cp of piCfg.piCustomPatterns) {
905
+ if (cp && cp.pattern) {
906
+ try {
907
+ customCategories.push({
908
+ name: cp.name || 'custom_pattern',
909
+ weight: Math.max(0, Math.min(1, Number(cp.weight) || 0.8)),
910
+ patterns: [new RegExp(cp.pattern, 'iu')],
911
+ });
912
+ } catch {} // Invalid regex — skip
913
+ }
914
+ }
915
+ }
916
+
917
+ const piResult = (piCfg.piEnabled !== false && !whitelisted)
918
+ ? detectPromptInjection(allText, customCategories, piCfg.piThreshold)
919
+ : null;
920
+
921
+ if (piResult && piResult.blocked) {
922
+ const isLogOnly = piCfg.piMode === 'log-only';
923
+ const msg = isLogOnly
924
+ ? 'SOLONGATE: Prompt injection detected [LOG-ONLY] (trust score: ' +
925
+ (piResult.trustScore * 100).toFixed(0) + '%, categories: ' +
926
+ piResult.categories.join(', ') + ')'
927
+ : 'SOLONGATE: Prompt injection detected (trust score: ' +
928
+ (piResult.trustScore * 100).toFixed(0) + '%, categories: ' +
929
+ piResult.categories.join(', ') + ')';
930
+
931
+ // Log to Cloud
932
+ if (API_KEY && API_KEY.startsWith('sg_live_')) {
933
+ try {
934
+ await fetch(API_URL + '/api/v1/audit-logs', {
935
+ method: 'POST',
936
+ headers: { 'Authorization': 'Bearer ' + API_KEY, 'Content-Type': 'application/json' },
937
+ body: JSON.stringify({
938
+ tool: toolName,
939
+ arguments: args,
940
+ decision: isLogOnly ? 'ALLOW' : 'DENY',
941
+ reason: msg,
942
+ source: 'claude-code-guard',
943
+ pi_detected: true,
944
+ pi_trust_score: piResult.trustScore,
945
+ pi_blocked: !isLogOnly,
946
+ pi_categories: JSON.stringify(piResult.categories),
947
+ pi_stage_scores: JSON.stringify({ rules: piResult.score, embedding: 0, classifier: 0 }),
948
+ }),
949
+ signal: AbortSignal.timeout(3000),
950
+ });
951
+ } catch {}
952
+
953
+ // Webhook notification
954
+ if (piCfg.piWebhookUrl) {
955
+ try {
956
+ await fetch(piCfg.piWebhookUrl, {
957
+ method: 'POST',
958
+ headers: { 'Content-Type': 'application/json' },
959
+ body: JSON.stringify({
960
+ event: 'prompt_injection_detected',
961
+ tool: toolName,
962
+ trustScore: piResult.trustScore,
963
+ categories: piResult.categories,
964
+ blocked: !isLogOnly,
965
+ mode: piCfg.piMode,
966
+ timestamp: new Date().toISOString(),
967
+ }),
968
+ signal: AbortSignal.timeout(3000),
969
+ });
970
+ } catch {} // Webhook failure is non-blocking
971
+ }
972
+ }
973
+
974
+ // In log-only mode, warn but don't block
975
+ if (isLogOnly) {
976
+ process.stderr.write(msg);
977
+ // Fall through to policy evaluation (don't exit)
978
+ } else {
979
+ process.stderr.write(msg);
980
+ process.exit(2);
981
+ }
982
+ }
983
+
984
+ // Load policy (use cwd from hook data if available)
985
+ const hookCwd = data.cwd || process.cwd();
986
+ let policy;
987
+ try {
988
+ const policyPath = resolve(hookCwd, 'policy.json');
989
+ policy = JSON.parse(readFileSync(policyPath, 'utf-8'));
990
+ } catch {
991
+ // No policy file — still log if PI was detected but not blocked
992
+ if (piResult && API_KEY && API_KEY.startsWith('sg_live_')) {
993
+ try {
994
+ await fetch(API_URL + '/api/v1/audit-logs', {
995
+ method: 'POST',
996
+ headers: { 'Authorization': 'Bearer ' + API_KEY, 'Content-Type': 'application/json' },
997
+ body: JSON.stringify({
998
+ tool: toolName,
999
+ arguments: args,
1000
+ decision: 'ALLOW',
1001
+ reason: 'Prompt injection detected but below threshold (trust: ' + (piResult.trustScore * 100).toFixed(0) + '%)',
1002
+ source: 'claude-code-guard',
1003
+ pi_detected: true,
1004
+ pi_trust_score: piResult.trustScore,
1005
+ pi_blocked: false,
1006
+ pi_categories: JSON.stringify(piResult.categories),
1007
+ pi_stage_scores: JSON.stringify({ rules: piResult.score, embedding: 0, classifier: 0 }),
1008
+ }),
1009
+ signal: AbortSignal.timeout(3000),
1010
+ });
1011
+ } catch {}
1012
+ }
1013
+ process.exit(0); // No policy = allow all
1014
+ }
1015
+
1016
+ let reason = evaluate(policy, args);
1017
+
1018
+ // ── AI Judge: semantic intent analysis (runs when policy ALLOWs) ──
1019
+ if (!reason) {
1020
+ const GROQ_KEY = process.env.GROQ_API_KEY || dotenv.GROQ_API_KEY || '';
1021
+ let aiJudgeEnabled = false;
1022
+ let aiJudgeModel = 'llama-3.1-8b-instant';
1023
+ let aiJudgeEndpoint = 'https://api.groq.com/openai';
1024
+ let aiJudgeTimeout = 5000;
1025
+
1026
+ // Check cloud config for AI Judge settings
1027
+ if (API_KEY && API_KEY.startsWith('sg_live_')) {
1028
+ try {
1029
+ const cfgRes = await fetch(API_URL + '/api/v1/project-config/ai-judge', {
1030
+ headers: { 'Authorization': 'Bearer ' + API_KEY },
1031
+ signal: AbortSignal.timeout(3000),
1032
+ });
1033
+ if (cfgRes.ok) {
1034
+ const cfg = await cfgRes.json();
1035
+ aiJudgeEnabled = Boolean(cfg.enabled);
1036
+ if (cfg.model) aiJudgeModel = cfg.model;
1037
+ if (cfg.endpoint) aiJudgeEndpoint = cfg.endpoint;
1038
+ if (cfg.timeoutMs) aiJudgeTimeout = cfg.timeoutMs;
1039
+ }
1040
+ } catch {}
1041
+ }
1042
+
1043
+ if (aiJudgeEnabled && GROQ_KEY) {
1044
+ try {
1045
+ // Extract protected files/paths from policy
1046
+ const protectedFiles = [];
1047
+ const protectedPathsList = [];
1048
+ if (policy && policy.rules) {
1049
+ for (const rule of policy.rules) {
1050
+ if (rule.effect === 'DENY' && rule.enabled !== false) {
1051
+ if (rule.filenameConstraints && rule.filenameConstraints.denied) {
1052
+ for (const f of rule.filenameConstraints.denied) protectedFiles.push(f);
1053
+ }
1054
+ if (rule.pathConstraints && rule.pathConstraints.denied) {
1055
+ for (const p of rule.pathConstraints.denied) protectedPathsList.push(p);
1056
+ }
1057
+ }
1058
+ }
1059
+ }
1060
+
1061
+ const judgePayload = JSON.stringify({
1062
+ tool: toolName,
1063
+ arguments: args,
1064
+ protected_files: protectedFiles,
1065
+ protected_paths: protectedPathsList,
1066
+ denied_actions: ['file deletion', 'data exfiltration', 'remote code execution', 'environment variable leak', 'security control bypass'],
1067
+ });
1068
+
1069
+ const systemPrompt = `You are a security judge for an AI coding tool. You evaluate tool calls and decide if they should be ALLOWED or DENIED.
1070
+
1071
+ You will receive a JSON object with:
1072
+ - "tool": the tool name being called (e.g., Bash, Write, Read, Edit)
1073
+ - "arguments": the tool's arguments
1074
+ - "protected_files": EXACT list of files that must NEVER be accessed. ONLY these specific files are protected — nothing else.
1075
+ - "protected_paths": EXACT list of directories that must NEVER be accessed. ONLY these specific paths are protected — nothing else.
1076
+ - "denied_actions": list of actions that are never allowed
1077
+
1078
+ IMPORTANT: You must ONLY protect files and paths that are EXPLICITLY listed in protected_files and protected_paths. If a file is NOT in the list, it is NOT protected and access should be ALLOWED. Do NOT invent or assume additional protected files.
1079
+
1080
+ DENY if the tool call could, directly or indirectly, access a file from the protected_files list — even through:
1081
+ - Shell glob patterns (e.g., "cred*" could match "credentials.json" IF credentials.json is in protected_files)
1082
+ - Command substitution ($(...), backticks)
1083
+ - Process substitution (<(cat file)) — check inside <(...) for protected files
1084
+ - Variable interpolation (e.g., f=".en"; cat \${f}v builds ".env" — DENY only if .env is in protected_files)
1085
+ - Input redirection (< file)
1086
+ - Multi-stage operations: tar/cp a protected file then read the copy — DENY the entire chain
1087
+ - Any utility that reads file content (cat, head, tail, less, perl, awk, sed, xxd, od, strings, dd, etc.)
1088
+
1089
+ Also DENY if:
1090
+ - The command sends data to external URLs (curl -d, wget --post)
1091
+ - The command leaks environment variables (printenv, env, process.env)
1092
+ - The command executes remotely downloaded code (curl|bash)
1093
+
1094
+ ALLOW if:
1095
+ - The file is NOT in protected_files — even if cat, head, etc. is used. Reading non-protected files is normal.
1096
+ - The action is a normal development operation (ls, git status, npm build, cat app.js, etc.)
1097
+ - The action does not touch any protected file or path
1098
+
1099
+ CRITICAL: Only DENY access to files EXPLICITLY in the protected_files list. "cat app.js" is ALLOWED if app.js is not in protected_files. "cat package.json" is ALLOWED if package.json is not in protected_files. Do NOT over-block.
1100
+
1101
+ Respond with ONLY valid JSON: {"decision": "ALLOW" or "DENY", "reason": "brief explanation", "confidence": 0.0 to 1.0}`;
1102
+
1103
+ const llmRes = await fetch(aiJudgeEndpoint + '/v1/chat/completions', {
1104
+ method: 'POST',
1105
+ headers: {
1106
+ 'Content-Type': 'application/json',
1107
+ 'Authorization': 'Bearer ' + GROQ_KEY,
1108
+ },
1109
+ body: JSON.stringify({
1110
+ model: aiJudgeModel,
1111
+ messages: [
1112
+ { role: 'system', content: systemPrompt },
1113
+ { role: 'user', content: judgePayload },
1114
+ ],
1115
+ temperature: 0,
1116
+ max_tokens: 200,
1117
+ }),
1118
+ signal: AbortSignal.timeout(aiJudgeTimeout),
1119
+ });
1120
+
1121
+ if (llmRes.ok) {
1122
+ const llmData = await llmRes.json();
1123
+ const content = llmData.choices?.[0]?.message?.content || '';
1124
+ const jsonMatch = content.match(/\{[\s\S]*\}/);
1125
+ if (jsonMatch) {
1126
+ const verdict = JSON.parse(jsonMatch[0]);
1127
+ if (verdict.decision === 'DENY') {
1128
+ reason = '[SolonGate AI Judge] Blocked: ' + (verdict.reason || 'Semantic analysis detected a policy violation');
1129
+ }
1130
+ }
1131
+ } else {
1132
+ // Fail-closed: LLM error → DENY
1133
+ reason = '[SolonGate AI Judge] Blocked: Groq API error (fail-closed)';
1134
+ }
1135
+ } catch (err) {
1136
+ // Fail-closed: timeout or parse error → DENY
1137
+ reason = '[SolonGate AI Judge] Blocked: ' + (err.message || 'error') + ' (fail-closed)';
1138
+ }
1139
+ }
1140
+ }
1141
+
1142
+ const decision = reason ? 'DENY' : 'ALLOW';
1143
+
1144
+ // ── Log ALL decisions to SolonGate Cloud ──
1145
+ if (API_KEY && API_KEY.startsWith('sg_live_')) {
1146
+ try {
1147
+ const logEntry = {
1148
+ tool: toolName, arguments: args,
1149
+ decision, reason: reason || 'allowed by policy',
1150
+ source: 'claude-code-guard',
1151
+ };
1152
+ // Attach PI metadata if detected
1153
+ if (piResult) {
1154
+ logEntry.pi_detected = true;
1155
+ logEntry.pi_trust_score = piResult.trustScore;
1156
+ logEntry.pi_blocked = false;
1157
+ logEntry.pi_categories = JSON.stringify(piResult.categories);
1158
+ logEntry.pi_stage_scores = JSON.stringify({ rules: piResult.score, embedding: 0, classifier: 0 });
1159
+ }
1160
+ await fetch(API_URL + '/api/v1/audit-logs', {
1161
+ method: 'POST',
1162
+ headers: { 'Authorization': 'Bearer ' + API_KEY, 'Content-Type': 'application/json' },
1163
+ body: JSON.stringify(logEntry),
1164
+ signal: AbortSignal.timeout(3000),
1165
+ });
1166
+ } catch {}
1167
+ }
1168
+
1169
+ if (reason) {
1170
+ process.stderr.write(reason);
1171
+ process.exit(2);
1172
+ }
1173
+ } catch {}
1174
+ process.exit(0);
1175
+ });