@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/dist/create.js +15 -11
- package/dist/index.js +822 -747
- package/dist/init.js +46 -33
- package/dist/inject.js +48 -39
- package/dist/pull-push.js +13 -10
- package/hooks/guard.mjs +1175 -1175
- package/package.json +70 -70
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 || '
|
|
1129
|
-
}
|
|
1130
|
-
}
|
|
1131
|
-
} else {
|
|
1132
|
-
// Fail-closed: LLM error → DENY
|
|
1133
|
-
reason = '[AI Judge]
|
|
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
|
+
});
|