@solongate/proxy 0.43.0 → 0.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/global-install.js +198 -0
- package/dist/index.js +7423 -2722
- package/dist/init.js +300 -68
- package/dist/lib.js +6759 -2062
- package/dist/login.js +327 -0
- package/dist/pull-push.js +2 -1
- package/hooks/audit.mjs +32 -11
- package/hooks/guard.bundled.mjs +7257 -0
- package/hooks/guard.mjs +703 -1077
- package/package.json +6 -2
package/hooks/guard.mjs
CHANGED
|
@@ -1,14 +1,30 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* SolonGate Policy Guard Hook (PreToolUse)
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* SolonGate Cloud Policy Guard Hook (PreToolUse) — GLOBAL system-wide enforcement.
|
|
4
|
+
*
|
|
5
|
+
* This is the cloud twin of the air-gapped guard hook. Identical decision engine
|
|
6
|
+
* (OPA WASM, NIST SP 800-207 PDP, fail-closed), but the policy + compiled WASM
|
|
7
|
+
* are fetched from SolonGate Cloud and authenticated with the project API key.
|
|
8
|
+
* Installed globally (~/.claude/settings.json) it intercepts EVERY tool call from
|
|
9
|
+
* EVERY Claude Code session on the machine — exactly like the air-gapped product,
|
|
10
|
+
* just sourced from the cloud instead of a local docker API.
|
|
11
|
+
*
|
|
12
|
+
* Cloud differences vs. air-gap guard.mjs:
|
|
13
|
+
* - API_KEY (sg_live_…/sg_test_…) from env/.env, attached to every API call.
|
|
14
|
+
* - API_URL defaults to https://api.solongate.com.
|
|
15
|
+
* - Enforcement is gated on the API key (the key identifies the project +
|
|
16
|
+
* its active policy), NOT on SOLONGATE_AGENT_ID.
|
|
17
|
+
* - No AI Judge and NO gray route: cloud routing is binary, WHITE (allow) /
|
|
18
|
+
* BLACK (block). The OPA policy alone decides; nothing is escalated.
|
|
19
|
+
*
|
|
6
20
|
* Exit code 2 = BLOCK, exit code 0 = ALLOW.
|
|
7
21
|
* Logs DENY decisions to SolonGate Cloud. ALLOWs are logged by audit.mjs.
|
|
8
|
-
* Auto-installed by: npx @solongate/proxy init
|
|
22
|
+
* Auto-installed by: npx @solongate/proxy init --global
|
|
9
23
|
*/
|
|
10
24
|
import { readFileSync, existsSync, statSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
11
25
|
import { resolve, join } from 'node:path';
|
|
26
|
+
import { homedir } from 'node:os';
|
|
27
|
+
import { gunzipSync } from 'node:zlib';
|
|
12
28
|
|
|
13
29
|
// Safe file read with size limit (1MB max) to prevent DoS via large files
|
|
14
30
|
const MAX_FILE_READ = 1024 * 1024; // 1MB
|
|
@@ -20,7 +36,7 @@ function safeReadFileSync(filePath, encoding = 'utf-8') {
|
|
|
20
36
|
} catch { return ''; }
|
|
21
37
|
}
|
|
22
38
|
|
|
23
|
-
// ── Load
|
|
39
|
+
// ── Load .env file (Claude Code doesn't load .env into process.env) ──
|
|
24
40
|
function loadEnvKey(dir) {
|
|
25
41
|
try {
|
|
26
42
|
const envPath = resolve(dir, '.env');
|
|
@@ -35,6 +51,20 @@ function loadEnvKey(dir) {
|
|
|
35
51
|
} catch { return {}; }
|
|
36
52
|
}
|
|
37
53
|
|
|
54
|
+
// ── Global cloud config (~/.solongate/cloud-guard.json) ──
|
|
55
|
+
// A GLOBAL hook runs from an arbitrary cwd every session, so a project-local
|
|
56
|
+
// .env can't be relied on to carry the API key. The global installer writes the
|
|
57
|
+
// key + URL here once; this absolute path is read regardless of cwd. Shape:
|
|
58
|
+
// { "apiKey": "sg_live_…", "apiUrl": "https://api.solongate.com" }
|
|
59
|
+
function loadGlobalCloudConfig() {
|
|
60
|
+
try {
|
|
61
|
+
const p = resolve(homedir(), '.solongate', 'cloud-guard.json');
|
|
62
|
+
if (!existsSync(p)) return {};
|
|
63
|
+
const cfg = JSON.parse(readFileSync(p, 'utf-8'));
|
|
64
|
+
return (cfg && typeof cfg === 'object') ? cfg : {};
|
|
65
|
+
} catch { return {}; }
|
|
66
|
+
}
|
|
67
|
+
|
|
38
68
|
function guessPermission(toolName) {
|
|
39
69
|
const name = (toolName || '').toLowerCase();
|
|
40
70
|
if (name.includes('exec') || name.includes('shell') || name.includes('run') || name.includes('eval') || name === 'bash') return 'EXECUTE';
|
|
@@ -45,12 +75,34 @@ function guessPermission(toolName) {
|
|
|
45
75
|
|
|
46
76
|
const hookCwdEarly = process.cwd();
|
|
47
77
|
const dotenv = loadEnvKey(hookCwdEarly);
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
78
|
+
const globalCfg = loadGlobalCloudConfig();
|
|
79
|
+
// Resolution order: process env → project-local .env → global ~/.solongate
|
|
80
|
+
// config. The global config is what makes a system-wide install self-sufficient.
|
|
81
|
+
const API_URL = process.env.SOLONGATE_API_URL || dotenv.SOLONGATE_API_URL || globalCfg.apiUrl || 'https://api.solongate.com';
|
|
82
|
+
// Cloud API key (sg_live_… / sg_test_…). The key identifies the project AND
|
|
83
|
+
// authenticates every API call (active policy, compiled WASM, audit logs). When
|
|
84
|
+
// absent, this hook does nothing — a machine with no key is intentionally
|
|
85
|
+
// unenforced (the cloud has no policy to apply).
|
|
86
|
+
const API_KEY = process.env.SOLONGATE_API_KEY || dotenv.SOLONGATE_API_KEY || globalCfg.apiKey || '';
|
|
87
|
+
// Auth headers attached to every cloud API request. Cloud accepts either the
|
|
88
|
+
// Authorization: Bearer form or X-API-Key; we send both for robustness.
|
|
89
|
+
const AUTH_HEADERS = API_KEY ? { 'Authorization': 'Bearer ' + API_KEY, 'X-API-Key': API_KEY } : {};
|
|
90
|
+
|
|
91
|
+
// Two distinct identities, deliberately kept separate:
|
|
92
|
+
//
|
|
93
|
+
// AGENT_TYPE — the real AI client running this hook (claude-code /
|
|
94
|
+
// gemini-cli / openclaw). Baked into the hook registration by the installer
|
|
95
|
+
// as argv[2] (e.g. `node guard.mjs claude-code`). Decides the response
|
|
96
|
+
// format AND whether a selected policy actually applies to this client.
|
|
97
|
+
//
|
|
98
|
+
// POLICY_SELECTOR — set per-terminal via SOLONGATE_AGENT_ID (a policy id
|
|
99
|
+
// from the dashboard "Use in terminal" button, or an agent name). Decides
|
|
100
|
+
// WHICH policy to load. When unset, no policy is enforced — a plain launch
|
|
101
|
+
// is intentionally unrestricted.
|
|
102
|
+
const AGENT_TYPE = process.argv[2] || 'claude-code';
|
|
103
|
+
const POLICY_SELECTOR = process.env.SOLONGATE_AGENT_ID || '';
|
|
104
|
+
const AGENT_ID = POLICY_SELECTOR || AGENT_TYPE;
|
|
105
|
+
const AGENT_NAME = process.env.SOLONGATE_AGENT_NAME || process.argv[3] || AGENT_TYPE;
|
|
54
106
|
|
|
55
107
|
// ── Per-tool block/allow output ──
|
|
56
108
|
// Response format depends on the agent:
|
|
@@ -58,7 +110,7 @@ const AGENT_NAME = process.argv[3] || 'Claude Code';
|
|
|
58
110
|
// Gemini CLI: {"decision": "deny/allow", "reason": "..."}
|
|
59
111
|
|
|
60
112
|
function blockTool(reason) {
|
|
61
|
-
if (
|
|
113
|
+
if (AGENT_TYPE === 'gemini-cli') {
|
|
62
114
|
process.stdout.write(JSON.stringify({
|
|
63
115
|
decision: 'deny',
|
|
64
116
|
reason: `[SolonGate] ${reason}`,
|
|
@@ -72,7 +124,7 @@ function blockTool(reason) {
|
|
|
72
124
|
}
|
|
73
125
|
|
|
74
126
|
function allowTool() {
|
|
75
|
-
if (
|
|
127
|
+
if (AGENT_TYPE === 'gemini-cli') {
|
|
76
128
|
process.stdout.write(JSON.stringify({ decision: 'allow' }));
|
|
77
129
|
}
|
|
78
130
|
process.exit(0);
|
|
@@ -261,25 +313,69 @@ function looksLikeFilename(s) {
|
|
|
261
313
|
return known.includes(s.toLowerCase());
|
|
262
314
|
}
|
|
263
315
|
|
|
316
|
+
// Deterministic shell normalizer — handles the common bypass tricks BEFORE
|
|
317
|
+
// any semantic check, so OPA's literal matcher sees the canonical command.
|
|
318
|
+
// Specifically: variable assignment + interpolation, quote concatenation
|
|
319
|
+
// (.e""nv, ."env"). Doesn't try to be a full shell — just enough to defeat
|
|
320
|
+
// the obfuscation patterns AI judges keep getting wrong non-deterministically.
|
|
321
|
+
function normalizeShellCommand(cmd) {
|
|
322
|
+
if (typeof cmd !== 'string' || !cmd) return cmd;
|
|
323
|
+
const vars = {};
|
|
324
|
+
const out = [];
|
|
325
|
+
// Split on statement separators (; && ||) but NOT pipes (|).
|
|
326
|
+
for (const rawPart of cmd.split(/\s*(?:;|&&|\|\|)\s*/)) {
|
|
327
|
+
let part = rawPart;
|
|
328
|
+
// Detect var assignment: NAME=value | NAME="value" | NAME='value'
|
|
329
|
+
const m = part.match(/^(\w+)=(?:"([^"]*)"|'([^']*)'|([^\s;&|]*))\s*$/);
|
|
330
|
+
if (m) {
|
|
331
|
+
vars[m[1]] = m[2] ?? m[3] ?? m[4] ?? '';
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
// Substitute ${var} then $var.
|
|
335
|
+
part = part.replace(/\$\{(\w+)\}/g, (_, n) => vars[n] !== undefined ? vars[n] : '${' + n + '}');
|
|
336
|
+
part = part.replace(/\$(\w+)/g, (_, n) => vars[n] !== undefined ? vars[n] : '$' + n);
|
|
337
|
+
// Collapse quote-concat: a"b"c → abc, .e""nv → .env, ."env" → .env
|
|
338
|
+
part = part.replace(/"([^"]*)"/g, '$1').replace(/'([^']*)'/g, '$1');
|
|
339
|
+
out.push(part);
|
|
340
|
+
}
|
|
341
|
+
return out.join('; ');
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Normalize all shell-command-valued fields of an args object before tokenizing.
|
|
345
|
+
function normalizeArgs(args) {
|
|
346
|
+
if (!args || typeof args !== 'object') return args;
|
|
347
|
+
const fields = ['command', 'cmd', 'function', 'script', 'shell'];
|
|
348
|
+
const copy = { ...args };
|
|
349
|
+
for (const [k, v] of Object.entries(copy)) {
|
|
350
|
+
if (fields.includes(k.toLowerCase()) && typeof v === 'string') {
|
|
351
|
+
copy[k] = normalizeShellCommand(v);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return copy;
|
|
355
|
+
}
|
|
356
|
+
|
|
264
357
|
function extractFilenames(args) {
|
|
358
|
+
args = normalizeArgs(args);
|
|
265
359
|
const names = new Set();
|
|
360
|
+
// Strip surrounding/trailing quotes — `"…/secret.env"` must reduce to
|
|
361
|
+
// `secret.env`, not `secret.env"` (a trailing quote breaks the *.env glob).
|
|
362
|
+
const dequote = (t) => t.replace(/^["'`]+/, '').replace(/["'`]+$/, '');
|
|
266
363
|
for (const s of scanStrings(args)) {
|
|
267
364
|
if (/^https?:\/\//i.test(s)) continue;
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
365
|
+
// Process EVERY whitespace-separated token, not just the last `/` segment of
|
|
366
|
+
// the whole string. Multi-file commands (`rm a b c`) must check all of them.
|
|
367
|
+
const tokens = s.includes(' ') ? s.split(/\s+/) : [s];
|
|
368
|
+
const single = tokens.length === 1;
|
|
369
|
+
for (let tok of tokens) {
|
|
370
|
+
tok = dequote(tok);
|
|
371
|
+
if (!tok || /^https?:\/\//i.test(tok)) continue;
|
|
372
|
+
if (tok.includes('/') || tok.includes('\\')) {
|
|
373
|
+
const b = dequote(tok.replace(/\\/g, '/').split('/').pop() || '');
|
|
374
|
+
if (b && (single || looksLikeFilename(b))) names.add(b);
|
|
375
|
+
} else if (looksLikeFilename(tok)) {
|
|
376
|
+
names.add(tok);
|
|
279
377
|
}
|
|
280
|
-
continue;
|
|
281
378
|
}
|
|
282
|
-
if (looksLikeFilename(s)) names.add(s);
|
|
283
379
|
}
|
|
284
380
|
return [...names];
|
|
285
381
|
}
|
|
@@ -298,6 +394,7 @@ function extractUrls(args) {
|
|
|
298
394
|
}
|
|
299
395
|
|
|
300
396
|
function extractCommands(args) {
|
|
397
|
+
args = normalizeArgs(args);
|
|
301
398
|
const cmds = [];
|
|
302
399
|
const fields = ['command', 'cmd', 'function', 'script', 'shell'];
|
|
303
400
|
if (typeof args === 'object' && args) {
|
|
@@ -322,1155 +419,684 @@ function extractPaths(args) {
|
|
|
322
419
|
return paths;
|
|
323
420
|
}
|
|
324
421
|
|
|
325
|
-
// ──
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
422
|
+
// ── Hardcoded Tamper Protection ──
|
|
423
|
+
// Runs BEFORE policy evaluation. Cannot be disabled by editing policies.
|
|
424
|
+
// Even if all policy rules are removed, these stay enforced.
|
|
425
|
+
const TAMPER_GUARD_TOOLS_WRITE = new Set([
|
|
426
|
+
'write', 'edit', 'multiedit', 'notebookedit',
|
|
427
|
+
'create', 'update', 'delete', 'remove', 'move', 'rename', 'copy',
|
|
428
|
+
'filesystem', 'fs_write', 'fs_edit', 'str_replace_editor',
|
|
429
|
+
]);
|
|
430
|
+
const TAMPER_GUARD_TOOLS_EXEC = new Set([
|
|
431
|
+
'bash', 'powershell', 'shell', 'exec', 'run', 'eval', 'cmd',
|
|
432
|
+
]);
|
|
433
|
+
const TAMPER_HOME = resolve(homedir()).replace(/\\/g, '/').toLowerCase();
|
|
434
|
+
const TAMPER_SG = '/.solongate';
|
|
435
|
+
const TAMPER_CC = '/.claude';
|
|
436
|
+
const TAMPER_PROTECTED_ABS = [
|
|
437
|
+
TAMPER_HOME + TAMPER_CC + '/settings.json',
|
|
438
|
+
TAMPER_HOME + TAMPER_CC + '/settings.local.json',
|
|
439
|
+
TAMPER_HOME + TAMPER_SG + '/hooks',
|
|
440
|
+
TAMPER_HOME + TAMPER_SG + '/policy.json',
|
|
441
|
+
TAMPER_HOME + TAMPER_SG + '/.policy-cache.json',
|
|
442
|
+
// The cloud credential (contains the API key) — never readable via a tool.
|
|
443
|
+
TAMPER_HOME + TAMPER_SG + '/cloud-guard.json',
|
|
444
|
+
];
|
|
445
|
+
const TAMPER_INSTALL = '/solongate';
|
|
446
|
+
const TAMPER_PROTECTED_GLOBS = [
|
|
447
|
+
'**' + TAMPER_CC + '/settings.json',
|
|
448
|
+
'**' + TAMPER_CC + '/settings.local.json',
|
|
449
|
+
'**' + TAMPER_SG + '/hooks/**',
|
|
450
|
+
'**' + TAMPER_SG + '/policy.json',
|
|
451
|
+
'**' + TAMPER_SG + '/.policy-cache.json',
|
|
452
|
+
'**' + TAMPER_SG + '/.policy-cache-*.json',
|
|
453
|
+
'**' + TAMPER_SG + '/.pi-config-cache.json',
|
|
454
|
+
'**' + TAMPER_SG + '/cloud-guard.json',
|
|
455
|
+
'**' + TAMPER_SG + '/.opa-wasm-*.json',
|
|
456
|
+
// Persistent host data (DB + audit JSONL) at ~/.solongate/data
|
|
457
|
+
'**' + TAMPER_SG + '/data/**',
|
|
458
|
+
// Customer install layout (zip extracted as solongate/)
|
|
459
|
+
'**' + TAMPER_INSTALL + '/compose/**',
|
|
460
|
+
'**' + TAMPER_INSTALL + '/data/**',
|
|
461
|
+
'**' + TAMPER_INSTALL + '/images/**',
|
|
462
|
+
'**' + TAMPER_INSTALL + '/helm/**',
|
|
463
|
+
'**' + TAMPER_INSTALL + '/solongate.exe',
|
|
464
|
+
'**' + TAMPER_INSTALL + '/setup.sh',
|
|
465
|
+
];
|
|
466
|
+
const TAMPER_BASENAMES = [
|
|
467
|
+
'guard.mjs', 'audit.mjs', 'stop.mjs',
|
|
468
|
+
'policy.json', '.policy-cache.json',
|
|
469
|
+
'.pi-config-cache.json', 'cloud-guard.json',
|
|
470
|
+
// Customer install: DB and wizard exe
|
|
471
|
+
'solongate.db', 'solongate.exe',
|
|
472
|
+
];
|
|
473
|
+
const TAMPER_PATH_FIELDS = new Set([
|
|
474
|
+
'file_path', 'path', 'target_file', 'notebook_path',
|
|
475
|
+
'dest', 'destination', 'source', 'src', 'from', 'to',
|
|
476
|
+
'directory', 'dir', 'folder',
|
|
477
|
+
]);
|
|
478
|
+
|
|
479
|
+
function normTamperPath(p) {
|
|
480
|
+
return String(p || '').replace(/\\/g, '/').toLowerCase();
|
|
481
|
+
}
|
|
331
482
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
if (matchGlob(fn, pat)) return 'Blocked by policy: filename "' + fn + '" matches "' + pat + '"';
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
if (rule.urlConstraints && rule.urlConstraints.denied) {
|
|
342
|
-
const urls = extractUrls(args);
|
|
343
|
-
for (const url of urls) {
|
|
344
|
-
for (const pat of rule.urlConstraints.denied) {
|
|
345
|
-
if (matchGlob(url, pat)) return 'Blocked by policy: URL "' + url + '" matches "' + pat + '"';
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
if (rule.commandConstraints && rule.commandConstraints.denied) {
|
|
350
|
-
const cmds = extractCommands(args);
|
|
351
|
-
for (const cmd of cmds) {
|
|
352
|
-
for (const pat of rule.commandConstraints.denied) {
|
|
353
|
-
if (matchGlob(cmd, pat)) return 'Blocked by policy: command "' + cmd.slice(0,60) + '" matches "' + pat + '"';
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
if (rule.pathConstraints && rule.pathConstraints.denied) {
|
|
358
|
-
const paths = extractPaths(args);
|
|
359
|
-
for (const p of paths) {
|
|
360
|
-
for (const pat of rule.pathConstraints.denied) {
|
|
361
|
-
if (matchPathGlob(p, pat)) return 'Blocked by policy: path "' + p + '" matches "' + pat + '"';
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
}
|
|
483
|
+
function isProtectedPath(p) {
|
|
484
|
+
if (!p) return false;
|
|
485
|
+
const np = normTamperPath(p);
|
|
486
|
+
for (const abs of TAMPER_PROTECTED_ABS) {
|
|
487
|
+
if (np === abs || np.startsWith(abs + '/')) return abs;
|
|
365
488
|
}
|
|
366
|
-
|
|
489
|
+
for (const g of TAMPER_PROTECTED_GLOBS) {
|
|
490
|
+
if (matchPathGlob(np, g)) return g;
|
|
491
|
+
}
|
|
492
|
+
if (/\/\.claude\/settings(\.local)?\.json$/.test(np)) return 'settings.json';
|
|
493
|
+
if (/\/\.solongate\/hooks(\/|$)/.test(np)) return 'solongate-hooks';
|
|
494
|
+
return false;
|
|
367
495
|
}
|
|
368
496
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
if (
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
497
|
+
function commandTargetsProtected(cmd) {
|
|
498
|
+
const c = String(cmd || '').toLowerCase();
|
|
499
|
+
if (!c) return false;
|
|
500
|
+
for (const b of TAMPER_BASENAMES) {
|
|
501
|
+
if (c.includes(b.toLowerCase())) return b;
|
|
502
|
+
}
|
|
503
|
+
if (/\.claude[\\/]+settings(\.local)?\.json/.test(c)) return 'settings.json';
|
|
504
|
+
if (/\.solongate[\\/]+hooks/.test(c)) return 'solongate-hooks';
|
|
505
|
+
// Customer install dirs
|
|
506
|
+
if (/[\\/]solongate[\\/]+(compose|data|images|helm)[\\/]/.test(c)) return 'solongate-install';
|
|
507
|
+
// Mutating API calls against policies / audit-logs endpoints
|
|
508
|
+
const mutating = /\b(post|put|delete|patch)\b/.test(c) ||
|
|
509
|
+
/(-x|--request|-method)\s+(post|put|delete|patch)\b/.test(c);
|
|
510
|
+
if (mutating && /api\/v1\/(policies|audit-logs)/.test(c)) return 'api-policies-mutation';
|
|
511
|
+
return false;
|
|
375
512
|
}
|
|
376
513
|
|
|
377
|
-
function
|
|
378
|
-
const
|
|
379
|
-
if (!
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
if (
|
|
388
|
-
if (rule.effect === 'DENY') {
|
|
389
|
-
return 'Blocked by agent group "' + groupName + '": ' + rule.toolPattern;
|
|
390
|
-
}
|
|
514
|
+
function extractTargetPaths(args) {
|
|
515
|
+
const out = [];
|
|
516
|
+
if (typeof args !== 'object' || !args) return out;
|
|
517
|
+
for (const [k, v] of Object.entries(args)) {
|
|
518
|
+
const lk = k.toLowerCase();
|
|
519
|
+
if (TAMPER_PATH_FIELDS.has(lk) && typeof v === 'string') out.push(v);
|
|
520
|
+
if (Array.isArray(v)) {
|
|
521
|
+
for (const item of v) {
|
|
522
|
+
if (item && typeof item === 'object') {
|
|
523
|
+
for (const [k2, v2] of Object.entries(item)) {
|
|
524
|
+
if (TAMPER_PATH_FIELDS.has(k2.toLowerCase()) && typeof v2 === 'string') out.push(v2);
|
|
391
525
|
}
|
|
392
526
|
}
|
|
393
527
|
}
|
|
394
528
|
}
|
|
395
529
|
}
|
|
530
|
+
return out;
|
|
531
|
+
}
|
|
396
532
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
}
|
|
533
|
+
function tamperCheck(toolName, args) {
|
|
534
|
+
const tn = String(toolName || '').toLowerCase();
|
|
535
|
+
const isExec = TAMPER_GUARD_TOOLS_EXEC.has(tn) || /bash|shell|exec|powershell|cmd|run|eval/.test(tn);
|
|
536
|
+
// ANY tool that targets a protected path is blocked — READ as well as write.
|
|
537
|
+
// An AI must not even read SolonGate's own protection files. This is enforced
|
|
538
|
+
// at the tool boundary; the hooks themselves are run by node directly (not via
|
|
539
|
+
// a Claude Code tool), so node still loads/executes them normally.
|
|
540
|
+
// Check the tool's TARGET PATH fields only (file_path, path, …) — never the
|
|
541
|
+
// free-form content/body, which would false-positive on any file that merely
|
|
542
|
+
// mentions a protected path in its text.
|
|
543
|
+
for (const p of extractTargetPaths(args)) {
|
|
544
|
+
const hit = isProtectedPath(p);
|
|
545
|
+
if (hit) return 'Tamper protection: access to "' + p + '" is blocked (protected: ' + hit + ')';
|
|
411
546
|
}
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
if (del.chain && del.chain.includes(agentId)) {
|
|
417
|
-
if (del.effectiveTools && del.effectiveTools.length > 0) {
|
|
418
|
-
if (!del.effectiveTools.some(p => matchTrustGlob(toolName, p))) {
|
|
419
|
-
return 'Tool not in delegation chain effective tools';
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
}
|
|
547
|
+
if (isExec) {
|
|
548
|
+
for (const cmd of extractCommands(args)) {
|
|
549
|
+
const hit = commandTargetsProtected(cmd);
|
|
550
|
+
if (hit) return 'Tamper protection: command references protected resource "' + hit + '" — blocked';
|
|
423
551
|
}
|
|
424
552
|
}
|
|
425
|
-
|
|
426
553
|
return null;
|
|
427
554
|
}
|
|
428
555
|
|
|
429
|
-
// ──
|
|
430
|
-
let input = '';
|
|
431
|
-
process.stdin.on('data', c => input += c);
|
|
432
|
-
process.stdin.on('end', async () => {
|
|
433
|
-
try {
|
|
434
|
-
const raw = JSON.parse(input);
|
|
435
|
-
|
|
436
|
-
// Debug: append guard invocation to debug log
|
|
437
|
-
try {
|
|
438
|
-
const { appendFileSync: afs, mkdirSync: mds } = await import('node:fs');
|
|
439
|
-
mds(resolve('.solongate'), { recursive: true });
|
|
440
|
-
const debugLine = JSON.stringify({ ts: new Date().toISOString(), hook: 'guard', argv: process.argv.slice(2), tool_name: raw.tool_name || raw.toolName || raw.command, agent_id: AGENT_ID }) + '\n';
|
|
441
|
-
afs(resolve('.solongate', '.debug-guard-log'), debugLine);
|
|
442
|
-
} catch {}
|
|
443
|
-
|
|
444
|
-
let mappedToolName = raw.tool_name || raw.toolName || '';
|
|
445
|
-
let mappedToolInput = raw.tool_input || raw.toolInput || raw.params || {};
|
|
446
|
-
|
|
447
|
-
// Normalize field names across tools
|
|
448
|
-
const data = {
|
|
449
|
-
...raw,
|
|
450
|
-
tool_name: mappedToolName,
|
|
451
|
-
tool_input: mappedToolInput,
|
|
452
|
-
tool_response: raw.tool_response || raw.toolResponse || {},
|
|
453
|
-
cwd: raw.cwd || process.cwd(),
|
|
454
|
-
session_id: raw.session_id || raw.sessionId || raw.conversation_id || '',
|
|
455
|
-
};
|
|
456
|
-
const args = data.tool_input;
|
|
457
|
-
const toolName = data.tool_name || '';
|
|
458
|
-
|
|
459
|
-
// ── Self-protection: block access to hook files and settings ──
|
|
460
|
-
// Hardcoded, no bypass possible — runs before policy/PI config
|
|
461
|
-
// Fully protected: block ALL access (read, write, delete, move)
|
|
462
|
-
const protectedPaths = [
|
|
463
|
-
'.solongate', '.claude', '.gemini', '.openclaw',
|
|
464
|
-
'policy.json', '.mcp.json',
|
|
465
|
-
];
|
|
466
|
-
// Write-protected: block write/delete/modify, allow read (cat, grep, head, etc.)
|
|
467
|
-
const writeProtectedPaths = ['.env', '.gitignore'];
|
|
468
|
-
|
|
469
|
-
// Block helper — logs to cloud + exits with code 2
|
|
470
|
-
async function blockSelfProtection(reason) {
|
|
471
|
-
if (API_KEY && API_KEY.startsWith('sg_live_')) {
|
|
472
|
-
try {
|
|
473
|
-
await fetch(API_URL + '/api/v1/audit-logs', {
|
|
474
|
-
method: 'POST',
|
|
475
|
-
headers: { 'Authorization': 'Bearer ' + API_KEY, 'Content-Type': 'application/json' },
|
|
476
|
-
body: JSON.stringify({
|
|
477
|
-
tool: data.tool_name || '', arguments: args,
|
|
478
|
-
decision: 'DENY', reason,
|
|
479
|
-
permission: guessPermission(data.tool_name || ''),
|
|
480
|
-
source: `${AGENT_ID}-guard`,
|
|
481
|
-
agent_id: AGENT_ID, agent_name: AGENT_NAME,
|
|
482
|
-
}),
|
|
483
|
-
signal: AbortSignal.timeout(3000),
|
|
484
|
-
});
|
|
485
|
-
} catch {}
|
|
486
|
-
}
|
|
487
|
-
writeDenyFlag(toolName);
|
|
488
|
-
blockTool(reason);
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
// ── Normalization layers ──
|
|
492
|
-
|
|
493
|
-
// 1. Decode ANSI-C quoting: $'\x72' → r, $'\162' → r, $'\n' → newline
|
|
494
|
-
function decodeAnsiC(s) {
|
|
495
|
-
return s.replace(/\$'([^']*)'/g, (_, content) => {
|
|
496
|
-
return content
|
|
497
|
-
.replace(/\\x([0-9a-fA-F]{2})/g, (__, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
498
|
-
.replace(/\\([0-7]{1,3})/g, (__, oct) => String.fromCharCode(parseInt(oct, 8)))
|
|
499
|
-
.replace(/\\u([0-9a-fA-F]{4})/g, (__, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
500
|
-
.replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\r/g, '\r')
|
|
501
|
-
.replace(/\\(.)/g, '$1');
|
|
502
|
-
});
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
// 2. Strip all shell quoting: empty quotes, single, double, backslash escapes
|
|
506
|
-
function stripShellQuotes(s) {
|
|
507
|
-
let r = s;
|
|
508
|
-
r = r.replace(/""/g, ''); // empty double quotes
|
|
509
|
-
r = r.replace(/''/g, ''); // empty single quotes
|
|
510
|
-
r = r.replace(/\\(.)/g, '$1'); // backslash escapes
|
|
511
|
-
r = r.replace(/'/g, ''); // remaining single quotes
|
|
512
|
-
r = r.replace(/"/g, ''); // remaining double quotes
|
|
513
|
-
return r;
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
// 3. Full normalization pipeline
|
|
517
|
-
function normalizeShell(s) {
|
|
518
|
-
return stripShellQuotes(decodeAnsiC(s));
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
// 4. Extract inner commands from eval, bash -c, sh -c, pipe to bash/sh, heredoc, process substitution
|
|
522
|
-
function extractInnerCommands(s) {
|
|
523
|
-
const inner = [];
|
|
524
|
-
// eval "cmd" or eval 'cmd' or eval cmd
|
|
525
|
-
for (const m of s.matchAll(/\beval\s+["']([^"']+)["']/gi)) inner.push(m[1]);
|
|
526
|
-
for (const m of s.matchAll(/\beval\s+([^;"'|&]+)/gi)) inner.push(m[1]);
|
|
527
|
-
// bash -c "cmd" or sh -c "cmd"
|
|
528
|
-
for (const m of s.matchAll(/\b(?:bash|sh)\s+-c\s+["']([^"']+)["']/gi)) inner.push(m[1]);
|
|
529
|
-
// echo "cmd" | bash/sh or printf "cmd" | bash/sh
|
|
530
|
-
for (const m of s.matchAll(/(?:echo|printf)\s+["']([^"']+)["']\s*\|\s*(?:bash|sh)\b/gi)) inner.push(m[1]);
|
|
531
|
-
// find ... -name "pattern" ... -exec ...
|
|
532
|
-
for (const m of s.matchAll(/-name\s+["']?([^\s"']+)["']?/gi)) inner.push(m[1]);
|
|
533
|
-
// Heredoc: bash << 'EOF'\n...\nEOF or bash <<- EOF\n...\nEOF
|
|
534
|
-
for (const m of s.matchAll(/<<-?\s*['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\s*\1/gi)) inner.push(m[2]);
|
|
535
|
-
// Herestring: bash <<< "cmd"
|
|
536
|
-
for (const m of s.matchAll(/<<<\s*["']([^"']+)["']/gi)) inner.push(m[1]);
|
|
537
|
-
for (const m of s.matchAll(/<<<\s*([^\s;&|]+)/gi)) inner.push(m[1]);
|
|
538
|
-
// Process substitution: <(cmd) or >(cmd)
|
|
539
|
-
for (const m of s.matchAll(/[<>]\(\s*([^)]+)\s*\)/gi)) inner.push(m[1]);
|
|
540
|
-
return inner;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
// 5. Check variable assignments for protected path fragments
|
|
544
|
-
// e.g. X=".solon" && rm -rf ${X}gate → detects ".solon" as prefix of ".solongate"
|
|
545
|
-
function checkVarAssignments(s) {
|
|
546
|
-
const assignments = [...s.matchAll(/(\w+)=["']?([^"'\s&|;]+)["']?/g)];
|
|
547
|
-
for (const [, , value] of assignments) {
|
|
548
|
-
const v = value.toLowerCase();
|
|
549
|
-
if (v.length < 3) continue; // avoid false positives
|
|
550
|
-
for (const p of protectedPaths) {
|
|
551
|
-
if (p.startsWith(v) || p.includes(v)) return p;
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
return null;
|
|
555
|
-
}
|
|
556
|
+
// ── Policy Evaluation ──
|
|
556
557
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
const firstWild = Math.min(
|
|
568
|
-
candidate.includes('*') ? candidate.indexOf('*') : Infinity,
|
|
569
|
-
candidate.includes('?') ? candidate.indexOf('?') : Infinity,
|
|
570
|
-
/\[/.test(candidate) ? candidate.indexOf('[') : Infinity,
|
|
571
|
-
);
|
|
572
|
-
const prefix = candidate.slice(0, firstWild).toLowerCase();
|
|
573
|
-
if (prefix.length > 0) {
|
|
574
|
-
for (const p of protectedPaths) {
|
|
575
|
-
if (p.startsWith(prefix)) return p;
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
// Regex match — convert shell glob to regex
|
|
579
|
-
// Handles *, ?, and [...] character classes
|
|
580
|
-
try {
|
|
581
|
-
let regex = '';
|
|
582
|
-
let i = 0;
|
|
583
|
-
const c = candidate.toLowerCase();
|
|
584
|
-
while (i < c.length) {
|
|
585
|
-
if (c[i] === '*') { regex += '.*'; i++; }
|
|
586
|
-
else if (c[i] === '?') { regex += '.'; i++; }
|
|
587
|
-
else if (c[i] === '[') {
|
|
588
|
-
// Pass through [...] as regex character class
|
|
589
|
-
const close = c.indexOf(']', i + 1);
|
|
590
|
-
if (close !== -1) {
|
|
591
|
-
regex += c.slice(i, close + 1);
|
|
592
|
-
i = close + 1;
|
|
593
|
-
} else {
|
|
594
|
-
regex += '\\['; i++;
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
else {
|
|
598
|
-
regex += c[i].replace(/[.+^${}()|\\]/g, '\\$&');
|
|
599
|
-
i++;
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
|
-
const re = new RegExp('^' + regex + '$', 'i');
|
|
603
|
-
for (const p of protectedPaths) {
|
|
604
|
-
if (re.test(p)) return p;
|
|
605
|
-
}
|
|
606
|
-
} catch {}
|
|
607
|
-
}
|
|
608
|
-
return null;
|
|
609
|
-
}
|
|
558
|
+
// Permission filter: a rule with rule.permission set only applies to tool
|
|
559
|
+
// calls whose guessed permission category is in that list. Empty/missing =
|
|
560
|
+
// applies to all categories.
|
|
561
|
+
function permissionApplies(rule, toolName) {
|
|
562
|
+
if (!rule.permission) return true;
|
|
563
|
+
const perms = Array.isArray(rule.permission) ? rule.permission : [rule.permission];
|
|
564
|
+
if (perms.length === 0) return true;
|
|
565
|
+
const guessed = guessPermission(toolName);
|
|
566
|
+
return perms.includes(guessed);
|
|
567
|
+
}
|
|
610
568
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
569
|
+
// Returns the first pattern that any of the rule's constraints matches against
|
|
570
|
+
// the args, or null if nothing matches. Used for both DENY (engine blocks on
|
|
571
|
+
// match) and ALLOW (whitelist mode requires at least one match).
|
|
572
|
+
// Each constraint may store its pattern list in either `denied` or `allowed`
|
|
573
|
+
// depending on which effect the rule was created with in the dashboard. The
|
|
574
|
+
// hook treats both as the same "pattern list" — the rule's effect determines
|
|
575
|
+
// whether a match means block (DENY) or pass (ALLOW in whitelist mode).
|
|
576
|
+
function patternsOf(constraint) {
|
|
577
|
+
if (!constraint) return null;
|
|
578
|
+
const list = constraint.denied || constraint.allowed;
|
|
579
|
+
return Array.isArray(list) && list.length > 0 ? list : null;
|
|
580
|
+
}
|
|
614
581
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
for (const inner of extractInnerCommands(s)) {
|
|
623
|
-
allStrings.add(inner.toLowerCase());
|
|
624
|
-
allStrings.add(normalizeShell(inner.toLowerCase()));
|
|
625
|
-
}
|
|
626
|
-
// Split by spaces + shell operators for token-level checks
|
|
627
|
-
for (const tok of s.split(/[\s;&|]+/)) {
|
|
628
|
-
if (tok) {
|
|
629
|
-
allStrings.add(tok);
|
|
630
|
-
allStrings.add(normalizeShell(tok));
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
// Also split normalized version
|
|
634
|
-
for (const tok of norm.split(/[\s;&|]+/)) {
|
|
635
|
-
if (tok) allStrings.add(tok);
|
|
582
|
+
function ruleMatches(rule, args) {
|
|
583
|
+
const fnPats = patternsOf(rule.filenameConstraints);
|
|
584
|
+
if (fnPats) {
|
|
585
|
+
const filenames = extractFilenames(args);
|
|
586
|
+
for (const fn of filenames) {
|
|
587
|
+
for (const pat of fnPats) {
|
|
588
|
+
if (matchGlob(fn, pat)) return { kind: 'filename', value: fn, pattern: pat };
|
|
636
589
|
}
|
|
637
590
|
}
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
// Wildcard/glob match
|
|
648
|
-
const globHit = globMatchesProtected(s);
|
|
649
|
-
if (globHit) {
|
|
650
|
-
await blockSelfProtection('SOLONGATE: Wildcard "' + s + '" matches protected "' + globHit + '" — blocked');
|
|
651
|
-
}
|
|
652
|
-
// Variable assignment targeting protected paths
|
|
653
|
-
const varHit = checkVarAssignments(s);
|
|
654
|
-
if (varHit) {
|
|
655
|
-
await blockSelfProtection('SOLONGATE: Variable assignment targets protected "' + varHit + '" — blocked');
|
|
591
|
+
}
|
|
592
|
+
const urlPats = patternsOf(rule.urlConstraints);
|
|
593
|
+
if (urlPats) {
|
|
594
|
+
const urls = extractUrls(args);
|
|
595
|
+
for (const url of urls) {
|
|
596
|
+
for (const pat of urlPats) {
|
|
597
|
+
if (matchGlob(url, pat)) return { kind: 'URL', value: url, pattern: pat };
|
|
656
598
|
}
|
|
657
599
|
}
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
const
|
|
662
|
-
const
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
const fullCmd = rawStrings.join(' ');
|
|
666
|
-
|
|
667
|
-
for (const wp of writeProtectedPaths) {
|
|
668
|
-
if (isWriteTool) {
|
|
669
|
-
// Check file_path and all strings for write-protected paths
|
|
670
|
-
for (const s of allStrings) {
|
|
671
|
-
if (s.includes(wp)) {
|
|
672
|
-
await blockSelfProtection('SOLONGATE: Write to protected file "' + wp + '" is blocked');
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
if (isBashTool && bashDestructive.test(fullCmd)) {
|
|
677
|
-
for (const s of allStrings) {
|
|
678
|
-
if (s.includes(wp)) {
|
|
679
|
-
await blockSelfProtection('SOLONGATE: Destructive operation on "' + wp + '" is blocked');
|
|
680
|
-
}
|
|
681
|
-
}
|
|
600
|
+
}
|
|
601
|
+
const cmdPats = patternsOf(rule.commandConstraints);
|
|
602
|
+
if (cmdPats) {
|
|
603
|
+
const cmds = extractCommands(args);
|
|
604
|
+
for (const cmd of cmds) {
|
|
605
|
+
for (const pat of cmdPats) {
|
|
606
|
+
if (matchGlob(cmd, pat)) return { kind: 'command', value: cmd.slice(0, 60), pattern: pat };
|
|
682
607
|
}
|
|
683
608
|
}
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
try {
|
|
692
|
-
let regex = '';
|
|
693
|
-
let i = 0;
|
|
694
|
-
const c = s.toLowerCase();
|
|
695
|
-
while (i < c.length) {
|
|
696
|
-
if (c[i] === '*') { regex += '.*'; i++; }
|
|
697
|
-
else if (c[i] === '?') { regex += '.'; i++; }
|
|
698
|
-
else if (c[i] === '[') {
|
|
699
|
-
const close = c.indexOf(']', i + 1);
|
|
700
|
-
if (close !== -1) { regex += c.slice(i, close + 1); i = close + 1; }
|
|
701
|
-
else { regex += '\\['; i++; }
|
|
702
|
-
}
|
|
703
|
-
else { regex += c[i].replace(/[.+^${}()|\\]/g, '\\$&'); i++; }
|
|
704
|
-
}
|
|
705
|
-
const re = new RegExp('^' + regex + '$', 'i');
|
|
706
|
-
for (const wp of writeProtectedPaths) {
|
|
707
|
-
if (re.test(wp)) {
|
|
708
|
-
await blockSelfProtection('SOLONGATE: Wildcard "' + s + '" matches write-protected "' + wp + '" — blocked');
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
} catch {}
|
|
609
|
+
}
|
|
610
|
+
const pathPats = patternsOf(rule.pathConstraints);
|
|
611
|
+
if (pathPats) {
|
|
612
|
+
const paths = extractPaths(args);
|
|
613
|
+
for (const p of paths) {
|
|
614
|
+
for (const pat of pathPats) {
|
|
615
|
+
if (matchPathGlob(p, pat)) return { kind: 'path', value: p, pattern: pat };
|
|
712
616
|
}
|
|
713
617
|
}
|
|
618
|
+
}
|
|
619
|
+
return null;
|
|
620
|
+
}
|
|
714
621
|
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
622
|
+
// Evaluate policy. Two modes:
|
|
623
|
+
// denylist (default): default ALLOW. Any DENY rule that matches → block.
|
|
624
|
+
// whitelist (strict): default DENY. Must match at least one ALLOW rule to
|
|
625
|
+
// pass. DENY rules still override on top.
|
|
626
|
+
function evaluate(policy, args, toolName) {
|
|
627
|
+
if (!policy || !policy.rules) return null;
|
|
628
|
+
const enabledRules = policy.rules.filter(r => r.enabled !== false);
|
|
629
|
+
const mode = policy.mode === 'whitelist' ? 'whitelist' : 'denylist';
|
|
718
630
|
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
631
|
+
// DENY pass — runs in both modes. DENY wins over ALLOW.
|
|
632
|
+
const denyRules = enabledRules
|
|
633
|
+
.filter(r => r.effect === 'DENY' && permissionApplies(r, toolName))
|
|
634
|
+
.sort((a, b) => (a.priority || 100) - (b.priority || 100));
|
|
635
|
+
for (const rule of denyRules) {
|
|
636
|
+
const m = ruleMatches(rule, args);
|
|
637
|
+
if (m) return 'Blocked by policy: ' + m.kind + ' "' + m.value + '" matches "' + m.pattern + '"';
|
|
638
|
+
}
|
|
727
639
|
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
640
|
+
// Whitelist pass — only in strict mode. Must match at least one ALLOW rule.
|
|
641
|
+
if (mode === 'whitelist') {
|
|
642
|
+
const allowRules = enabledRules.filter(r => r.effect === 'ALLOW' && permissionApplies(r, toolName));
|
|
643
|
+
if (allowRules.length === 0) {
|
|
644
|
+
return 'Blocked by policy: strict whitelist mode is on and no ALLOW rule applies to ' + (toolName || 'this tool');
|
|
732
645
|
}
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
// Pattern: destructive command + $() or `` containing interpreter call
|
|
737
|
-
const hasDestructive = /\b(?:rm|rmdir|del|unlink|mv|move|rename|shred)\b/i.test(fullCmd);
|
|
738
|
-
const hasCmdSubInterpreter = /\$\(\s*(?:node|bash|sh|python[23]?|perl|ruby)\b/i.test(fullCmd)
|
|
739
|
-
|| /`\s*(?:node|bash|sh|python[23]?|perl|ruby)\b/i.test(fullCmd);
|
|
740
|
-
if (hasDestructive && hasCmdSubInterpreter) {
|
|
741
|
-
await blockSelfProtection('SOLONGATE: Command substitution + destructive op — blocked');
|
|
646
|
+
let matched = false;
|
|
647
|
+
for (const rule of allowRules) {
|
|
648
|
+
if (ruleMatches(rule, args)) { matched = true; break; }
|
|
742
649
|
}
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
// Catches: node scan.mjs | while read d; do rm -rf "$d"; done
|
|
746
|
-
// node scan.mjs | xargs rm -rf
|
|
747
|
-
// bash scan.sh | while read ...
|
|
748
|
-
const pipeFromInterpreter = /\b(?:node|bash|sh|python[23]?|perl|ruby)\s+\S+\s*\|/i;
|
|
749
|
-
if (pipeFromInterpreter.test(fullCmd) && hasDestructive) {
|
|
750
|
-
await blockSelfProtection('SOLONGATE: Pipe from script to destructive command — blocked');
|
|
650
|
+
if (!matched) {
|
|
651
|
+
return 'Blocked by policy: strict whitelist mode — request does not match any ALLOW rule';
|
|
751
652
|
}
|
|
653
|
+
}
|
|
752
654
|
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
// bash scan.sh; while read d < targets.txt; do rm -rf "$d"; done
|
|
756
|
-
const hasScriptExec = /\b(?:node|bash|sh|python[23]?|perl|ruby)\s+\S+\.\S+/i.test(fullCmd);
|
|
757
|
-
if (hasScriptExec && hasDestructive) {
|
|
758
|
-
await blockSelfProtection('SOLONGATE: Script execution + destructive command in chain — blocked');
|
|
759
|
-
}
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
760
657
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
658
|
+
// ── OPA WASM Evaluation (NIST SP 800-207 PDP) ──
|
|
659
|
+
//
|
|
660
|
+
// When the API has compiled this policy to an OPA WASM bundle AND the
|
|
661
|
+
// @open-policy-agent/opa-wasm runtime is resolvable, we evaluate through OPA
|
|
662
|
+
// instead of the hand-written evaluate() above. This is the same decision
|
|
663
|
+
// engine the MCP proxy uses (packages/policy-engine/src/opa).
|
|
664
|
+
//
|
|
665
|
+
// Graceful degradation is the contract: any missing piece (no bundle, no
|
|
666
|
+
// runtime, fetch/parse/eval error) makes evaluateWithOpa() return `undefined`,
|
|
667
|
+
// and the caller falls back to the legacy JS evaluate() — so air-gapped
|
|
668
|
+
// installs without OPA see ZERO behavior change.
|
|
669
|
+
|
|
670
|
+
// Cheap, dependency-free djb2 fingerprint to detect policy changes for caching.
|
|
671
|
+
function djb2(str) {
|
|
672
|
+
let h = 5381;
|
|
673
|
+
for (let i = 0; i < str.length; i++) h = ((h << 5) + h + str.charCodeAt(i)) | 0;
|
|
674
|
+
return (h >>> 0).toString(36);
|
|
675
|
+
}
|
|
768
676
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
677
|
+
// Extracts /policy.wasm from an OPA bundle. Mirrors
|
|
678
|
+
// packages/policy-engine/src/opa/opa-evaluator.ts extractWasmFromBundle().
|
|
679
|
+
// The bundle may be a gzipped tar (.tar.gz from `opa build -t wasm`) or raw WASM.
|
|
680
|
+
function extractWasmFromBundle(buf) {
|
|
681
|
+
if (buf[0] === 0x1f && buf[1] === 0x8b) {
|
|
682
|
+
const tar = gunzipSync(buf);
|
|
683
|
+
let offset = 0;
|
|
684
|
+
while (offset < tar.length - 512) {
|
|
685
|
+
const nameEnd = tar.indexOf(0, offset);
|
|
686
|
+
const name = tar.subarray(offset, Math.min(nameEnd, offset + 100)).toString('utf-8');
|
|
687
|
+
if (!name || name.length === 0) break;
|
|
688
|
+
const sizeStr = tar.subarray(offset + 124, offset + 136).toString('utf-8').trim();
|
|
689
|
+
const size = parseInt(sizeStr, 8) || 0;
|
|
690
|
+
offset += 512;
|
|
691
|
+
if (name === 'policy.wasm' || name === './policy.wasm' || name.endsWith('/policy.wasm')) {
|
|
692
|
+
return Buffer.from(tar.subarray(offset, offset + size));
|
|
693
|
+
}
|
|
694
|
+
offset += Math.ceil(size / 512) * 512;
|
|
773
695
|
}
|
|
696
|
+
throw new Error('policy.wasm not found in OPA bundle');
|
|
697
|
+
}
|
|
698
|
+
if (buf[0] === 0x00 && buf[1] === 0x61 && buf[2] === 0x73 && buf[3] === 0x6d) {
|
|
699
|
+
return buf; // already raw WASM
|
|
700
|
+
}
|
|
701
|
+
throw new Error('Unknown OPA bundle format');
|
|
702
|
+
}
|
|
774
703
|
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
await blockSelfProtection('SOLONGATE: npm script "' + key + '" references protected "' + p + '" — blocked');
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
// Check for string construction patterns
|
|
800
|
-
if (/fromcharcode|atob\s*\(|buffer\.from|\\x[0-9a-f]{2}/i.test(scriptLower)) {
|
|
801
|
-
await blockSelfProtection('SOLONGATE: npm script "' + key + '" contains string construction — blocked');
|
|
802
|
-
}
|
|
803
|
-
// Check for discovery+destruction combo
|
|
804
|
-
const hasDisc = /\breaddirsync\b|\breaddir\b|\bos\.listdir\b|\bscandir\b|\bglob\b|\bls\s+-[adl]/i.test(scriptLower);
|
|
805
|
-
const hasDest = /\brmsync\b|\brm\s+-rf\b|\bunlinksync\b|\brmdirsync\b|\bunlink\b|\brimraf\b/i.test(scriptLower);
|
|
806
|
-
if (hasDisc && hasDest) {
|
|
807
|
-
await blockSelfProtection('SOLONGATE: npm script "' + key + '" has discovery+destruction — blocked');
|
|
808
|
-
}
|
|
809
|
-
// Follow node/bash <file> references in npm scripts — deep scan the target file
|
|
810
|
-
const scriptFileMatch = scriptLower.match(/\b(?:node|bash|sh|python[23]?)\s+([^\s;&|$]+)/i);
|
|
811
|
-
if (scriptFileMatch) {
|
|
812
|
-
try {
|
|
813
|
-
const targetPath = resolve(hookCwdForNpm, scriptFileMatch[1]);
|
|
814
|
-
if (existsSync(targetPath)) {
|
|
815
|
-
const targetContent = safeReadFileSync(targetPath).toLowerCase();
|
|
816
|
-
// Check target file for protected paths
|
|
817
|
-
for (const pp of [...protectedPaths, ...writeProtectedPaths]) {
|
|
818
|
-
if (targetContent.includes(pp)) {
|
|
819
|
-
await blockSelfProtection('SOLONGATE: npm script "' + key + '" runs file referencing "' + pp + '" — blocked');
|
|
820
|
-
}
|
|
821
|
-
}
|
|
822
|
-
// Check target for discovery+destruction
|
|
823
|
-
const tDisc = /\breaddirsync\b|\breaddir\b|\bos\.listdir\b|\bglob\b/i.test(targetContent);
|
|
824
|
-
const tDest = /\brmsync\b|\bunlinksync\b|\brmdirsync\b|\bunlink\b|\brimraf\b|\bfs\.\s*(?:rm|unlink|rmdir)/i.test(targetContent);
|
|
825
|
-
if (tDisc && tDest) {
|
|
826
|
-
await blockSelfProtection('SOLONGATE: npm script "' + key + '" runs file with discovery+destruction — blocked');
|
|
827
|
-
}
|
|
828
|
-
// Check target for string construction + destruction
|
|
829
|
-
const tStrCon = /\bfromcharcode\b|\batob\s*\(|\bbuffer\.from\b/i.test(targetContent);
|
|
830
|
-
if (tStrCon && tDest) {
|
|
831
|
-
await blockSelfProtection('SOLONGATE: npm script "' + key + '" runs file with string construction+destruction — blocked');
|
|
832
|
-
}
|
|
833
|
-
// Follow imports in the target file
|
|
834
|
-
const tDir = targetPath.replace(/[/\\][^/\\]+$/, '');
|
|
835
|
-
const tImports = [...targetContent.matchAll(/(?:import\s+.*?\s+from\s+|import\s+|require\s*\()['"]\.\/([^'"]+)['"]/gi)];
|
|
836
|
-
for (const [, imp] of tImports) {
|
|
837
|
-
try {
|
|
838
|
-
const impAbs = resolve(tDir, imp);
|
|
839
|
-
const candidates = [impAbs, impAbs + '.mjs', impAbs + '.js', impAbs + '.cjs'];
|
|
840
|
-
for (const c of candidates) {
|
|
841
|
-
if (existsSync(c)) {
|
|
842
|
-
const impContent = safeReadFileSync(c).toLowerCase();
|
|
843
|
-
const iDisc = /\breaddirsync\b|\breaddir\b|\bos\.listdir\b|\bglob\b/i.test(impContent);
|
|
844
|
-
const iDest = /\brmsync\b|\bunlinksync\b|\bfs\.\s*(?:rm|unlink|rmdir)/i.test(impContent);
|
|
845
|
-
if ((iDisc && tDest) || (tDisc && iDest)) {
|
|
846
|
-
await blockSelfProtection('SOLONGATE: npm script "' + key + '" — cross-module discovery+destruction — blocked');
|
|
847
|
-
}
|
|
848
|
-
break;
|
|
849
|
-
}
|
|
850
|
-
}
|
|
851
|
-
} catch {}
|
|
852
|
-
}
|
|
853
|
-
}
|
|
854
|
-
} catch {}
|
|
855
|
-
}
|
|
856
|
-
}
|
|
857
|
-
}
|
|
704
|
+
// Fetches the compiled WASM for this policy from the API, with a local cache
|
|
705
|
+
// keyed by a fingerprint of the policy so updates propagate. Returns the raw
|
|
706
|
+
// policy.wasm bytes (Uint8Array) or null when unavailable.
|
|
707
|
+
const OPA_WASM_TTL_MS = 30_000;
|
|
708
|
+
async function getOpaWasmBytes(policy) {
|
|
709
|
+
if (!policy || !policy.id) return null;
|
|
710
|
+
const fp = djb2(JSON.stringify(policy.rules || []) + '|' + (policy.mode || ''));
|
|
711
|
+
const agentKey = (AGENT_ID || 'default').replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
712
|
+
const cacheFile = join(resolve(homedir(), '.solongate'), '.opa-wasm-' + agentKey + '.json');
|
|
713
|
+
|
|
714
|
+
// Read any cached bundle. A "fresh" hit (same policy fingerprint, within TTL)
|
|
715
|
+
// is returned immediately; otherwise we keep it as `stale` to fall back on if
|
|
716
|
+
// the API is momentarily unreachable — so transient downtime never drops OPA.
|
|
717
|
+
let stale = null;
|
|
718
|
+
try {
|
|
719
|
+
if (existsSync(cacheFile)) {
|
|
720
|
+
const c = JSON.parse(readFileSync(cacheFile, 'utf-8'));
|
|
721
|
+
if (c && c.wasm) {
|
|
722
|
+
stale = new Uint8Array(Buffer.from(c.wasm, 'base64'));
|
|
723
|
+
if (c.fp === fp && c._ts && Date.now() - c._ts < OPA_WASM_TTL_MS) {
|
|
724
|
+
return stale;
|
|
858
725
|
}
|
|
859
|
-
} catch {} // Package.json read error — skip
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
// 7a. Inline interpreter execution — TOTAL BLOCK (no content scan needed)
|
|
863
|
-
// These can construct ANY string at runtime, bypassing all static analysis
|
|
864
|
-
const blockedInterpreters = [
|
|
865
|
-
[/\bnode\s+(?:-e|--eval)\b/i, 'node -e/--eval'],
|
|
866
|
-
[/\bnode\s+-p\b/i, 'node -p'],
|
|
867
|
-
[/\bpython[23]?\s+-c\b/i, 'python -c'],
|
|
868
|
-
[/\bperl\s+-e\b/i, 'perl -e'],
|
|
869
|
-
[/\bruby\s+-e\b/i, 'ruby -e'],
|
|
870
|
-
[/\bpowershell(?:\.exe)?\s+.*-(?:EncodedCommand|e|ec)\b/i, 'powershell -EncodedCommand'],
|
|
871
|
-
[/\bpwsh(?:\.exe)?\s+.*-(?:EncodedCommand|e|ec)\b/i, 'pwsh -EncodedCommand'],
|
|
872
|
-
[/\bpowershell(?:\.exe)?\s+-c(?:ommand)?\b/i, 'powershell -Command'],
|
|
873
|
-
[/\bpwsh(?:\.exe)?\s+-c(?:ommand)?\b/i, 'pwsh -Command'],
|
|
874
|
-
];
|
|
875
|
-
for (const [pat, name] of blockedInterpreters) {
|
|
876
|
-
if (pat.test(fullCmd)) {
|
|
877
|
-
await blockSelfProtection('SOLONGATE: Inline code execution blocked (' + name + ')');
|
|
878
726
|
}
|
|
879
727
|
}
|
|
728
|
+
} catch {}
|
|
880
729
|
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
730
|
+
// Fetch the compiled bundle from the API (route already exists).
|
|
731
|
+
try {
|
|
732
|
+
const res = await fetch(
|
|
733
|
+
API_URL + '/api/v1/policies/' + encodeURIComponent(policy.id) + '/wasm',
|
|
734
|
+
{ headers: AUTH_HEADERS, signal: AbortSignal.timeout(2000) },
|
|
735
|
+
);
|
|
736
|
+
if (!res.ok) return stale; // API has no compiled WASM right now → last known good
|
|
737
|
+
const bundle = Buffer.from(await res.arrayBuffer());
|
|
738
|
+
const wasm = extractWasmFromBundle(bundle);
|
|
739
|
+
try {
|
|
740
|
+
mkdirSync(resolve(homedir(), '.solongate'), { recursive: true });
|
|
741
|
+
writeFileSync(cacheFile, JSON.stringify({ _ts: Date.now(), fp, wasm: Buffer.from(wasm).toString('base64') }));
|
|
742
|
+
} catch {}
|
|
743
|
+
return new Uint8Array(wasm);
|
|
744
|
+
} catch {
|
|
745
|
+
return stale; // transient network failure → last known good
|
|
746
|
+
}
|
|
747
|
+
}
|
|
893
748
|
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
749
|
+
// Lazily load the opa-wasm runtime. Returns the loadPolicy fn or null if the
|
|
750
|
+
// package isn't installed in this environment (typical for air-gapped hooks).
|
|
751
|
+
let _loadPolicyFn = null;
|
|
752
|
+
let _loadPolicyTried = false;
|
|
753
|
+
async function getLoadPolicy() {
|
|
754
|
+
if (_loadPolicyTried) return _loadPolicyFn;
|
|
755
|
+
_loadPolicyTried = true;
|
|
756
|
+
try {
|
|
757
|
+
const mod = await import('@open-policy-agent/opa-wasm');
|
|
758
|
+
_loadPolicyFn = mod.loadPolicy || (mod.default && mod.default.loadPolicy) || null;
|
|
759
|
+
} catch {
|
|
760
|
+
_loadPolicyFn = null;
|
|
761
|
+
}
|
|
762
|
+
return _loadPolicyFn;
|
|
763
|
+
}
|
|
898
764
|
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
765
|
+
// Evaluates the policy through OPA WASM. Returns:
|
|
766
|
+
// - a reason string → DENY
|
|
767
|
+
// - null → ALLOW (OPA decided, no violation)
|
|
768
|
+
// - undefined → OPA unavailable, caller must fall back to evaluate()
|
|
769
|
+
async function evaluateWithOpa(policy, args, toolName, cwd) {
|
|
770
|
+
if (!policy || !policy.rules) return undefined;
|
|
771
|
+
try {
|
|
772
|
+
const loadPolicy = await getLoadPolicy();
|
|
773
|
+
if (!loadPolicy) return undefined;
|
|
774
|
+
const wasmBytes = await getOpaWasmBytes(policy);
|
|
775
|
+
if (!wasmBytes) return undefined;
|
|
776
|
+
|
|
777
|
+
const opaPolicy = await loadPolicy(wasmBytes, { initial: 5 });
|
|
778
|
+
// trust_level is fixed to 'TRUSTED' to preserve legacy guard.mjs behavior,
|
|
779
|
+
// which never evaluated minimumTrustLevel constraints.
|
|
780
|
+
// If the tool call references files (bash X.sh, source X, etc.), inline
|
|
781
|
+
// their contents so the SAME deterministic extractors see hidden commands.
|
|
782
|
+
// The hook reads files itself; OPA gets a flat, expanded view — no LLM
|
|
783
|
+
// needed for hidden-in-file detection at this layer.
|
|
784
|
+
// Inline referenced-file CONTENT only for tools that EXECUTE a script
|
|
785
|
+
// (`bash X.sh` → X.sh would run, so its contents matter). For read/write
|
|
786
|
+
// tools the file is data, not code — inlining its content there causes false
|
|
787
|
+
// positives (e.g. reading a file that merely mentions ".env" tripping an
|
|
788
|
+
// *.env rule, or reading a script that documents `rm -rf`).
|
|
789
|
+
const isExecTool = /bash|shell|exec|powershell|cmd|run|eval/.test((toolName || '').toLowerCase());
|
|
790
|
+
const refFiles = (isExecTool && typeof readReferencedFiles === 'function')
|
|
791
|
+
? readReferencedFiles(args, cwd || process.cwd())
|
|
792
|
+
: {};
|
|
793
|
+
const expandedArgs = { ...((args && typeof args === 'object') ? args : {}) };
|
|
794
|
+
for (const [, content] of Object.entries(refFiles)) {
|
|
795
|
+
const lines = String(content).split('\n')
|
|
796
|
+
.map(l => l.trim())
|
|
797
|
+
.filter(l => l && !l.startsWith('#'));
|
|
798
|
+
if (lines.length > 0) {
|
|
799
|
+
const extra = lines.join('; ');
|
|
800
|
+
if (typeof expandedArgs.command === 'string') {
|
|
801
|
+
expandedArgs.command = expandedArgs.command + '; ' + extra;
|
|
802
|
+
} else {
|
|
803
|
+
expandedArgs.command = extra;
|
|
904
804
|
}
|
|
905
805
|
}
|
|
906
806
|
}
|
|
907
|
-
|
|
908
|
-
//
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
807
|
+
// Matching a filename/URL/path that appears in a tool BODY (content,
|
|
808
|
+
// new_string, text, …) only makes sense for EXEC tools, where that text would
|
|
809
|
+
// RUN. For read/write tools the body is data, not access — writing a doc that
|
|
810
|
+
// merely mentions a secret-file pattern is not accessing one. So strip body
|
|
811
|
+
// fields before extracting access targets; the command fields (what actually
|
|
812
|
+
// executes) are always scanned via extractCommands.
|
|
813
|
+
// (isExecTool already computed above for the referenced-file inlining gate.)
|
|
814
|
+
// For NON-exec tools, only the explicit path/target fields are an "access" —
|
|
815
|
+
// arbitrary text fields (a question, a description, a file body) are data, not
|
|
816
|
+
// access, and must not be matched against filename/path/url rules. So scan an
|
|
817
|
+
// ALLOWLIST of target fields only. Exec tools scan the full command instead.
|
|
818
|
+
// Includes network/url-bearing fields (url, uri, …) so non-exec network
|
|
819
|
+
// tools (Fetch/WebFetch) keep their access target — otherwise the url field
|
|
820
|
+
// is stripped here, input.urls comes out empty, and urlConstraints DENY
|
|
821
|
+
// rules never match (a fetch to a blocked host slips through).
|
|
822
|
+
const ACCESS_FIELDS = new Set(['file_path', 'path', 'target_file', 'notebook_path', 'filename', 'dest', 'destination', 'source', 'src', 'from', 'to', 'directory', 'dir', 'folder', 'url', 'urls', 'uri', 'href', 'link', 'endpoint']);
|
|
823
|
+
let accessArgs = expandedArgs;
|
|
824
|
+
if (!isExecTool && expandedArgs && typeof expandedArgs === 'object') {
|
|
825
|
+
accessArgs = {};
|
|
826
|
+
for (const [k, v] of Object.entries(expandedArgs)) {
|
|
827
|
+
if (ACCESS_FIELDS.has(k.toLowerCase())) accessArgs[k] = v;
|
|
914
828
|
}
|
|
915
829
|
}
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
if (existsSync(absPath)) {
|
|
928
|
-
const scriptContent = safeReadFileSync(absPath).toLowerCase();
|
|
929
|
-
// Check for discovery+destruction combo
|
|
930
|
-
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);
|
|
931
|
-
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);
|
|
932
|
-
if (hasDiscovery && hasDestruction) {
|
|
933
|
-
await blockSelfProtection('SOLONGATE: Script contains directory discovery + destructive ops — blocked');
|
|
934
|
-
}
|
|
935
|
-
// String construction + destructive = BLOCK
|
|
936
|
-
// Runtime string construction (fromCharCode, atob, Buffer.from) bypasses literal name checks
|
|
937
|
-
const hasStringConstruction = /\bfromcharcode\b|\batob\s*\(|\bbuffer\.from\b|\\x[0-9a-f]{2}|\bstring\.fromcodepoin/i.test(scriptContent);
|
|
938
|
-
if (hasStringConstruction && hasDestruction) {
|
|
939
|
-
await blockSelfProtection('SOLONGATE: Script uses string construction + destructive ops — blocked');
|
|
940
|
-
}
|
|
941
|
-
// Import/require chain: if script imports other local files, scan them too
|
|
942
|
-
const scriptDir = absPath.replace(/[/\\][^/\\]+$/, '');
|
|
943
|
-
const imports = [...scriptContent.matchAll(/(?:import\s+.*?\s+from\s+|import\s+|require\s*\()['"]\.\/([^'"]+)['"]/gi)];
|
|
944
|
-
for (const [, importPath] of imports) {
|
|
945
|
-
try {
|
|
946
|
-
const importAbs = resolve(scriptDir, importPath);
|
|
947
|
-
const candidates = [importAbs, importAbs + '.mjs', importAbs + '.js', importAbs + '.cjs'];
|
|
948
|
-
for (const candidate of candidates) {
|
|
949
|
-
if (existsSync(candidate)) {
|
|
950
|
-
const importContent = safeReadFileSync(candidate).toLowerCase();
|
|
951
|
-
// Cross-module: check if imported module has discovery/destruction/string construction
|
|
952
|
-
const importDisc = /\breaddirsync\b|\breaddir\b|\bos\.listdir\b|\bscandir\b|\bglob(?:sync)?\b/i.test(importContent);
|
|
953
|
-
const importDest = /\brmsync\b|\bunlinksync\b|\brmdirsync\b|\bunlink\b|\brimraf\b|\bfs\.\s*(?:rm|unlink|rmdir)/i.test(importContent);
|
|
954
|
-
const importStrCon = /\bfromcharcode\b|\batob\s*\(|\bbuffer\.from\b/i.test(importContent);
|
|
955
|
-
// Cross-module discovery in import + destruction in main (or vice versa)
|
|
956
|
-
if ((importDisc && hasDestruction) || (hasDiscovery && importDest)) {
|
|
957
|
-
await blockSelfProtection('SOLONGATE: Cross-module discovery+destruction detected — blocked');
|
|
958
|
-
}
|
|
959
|
-
if ((importStrCon && hasDestruction) || (hasStringConstruction && importDest)) {
|
|
960
|
-
await blockSelfProtection('SOLONGATE: Cross-module string construction+destruction — blocked');
|
|
961
|
-
}
|
|
962
|
-
// Check imported module for protected paths
|
|
963
|
-
for (const p of [...protectedPaths, ...writeProtectedPaths]) {
|
|
964
|
-
if (importContent.includes(p)) {
|
|
965
|
-
await blockSelfProtection('SOLONGATE: Imported module references protected "' + p + '" — blocked');
|
|
966
|
-
}
|
|
967
|
-
}
|
|
968
|
-
break;
|
|
969
|
-
}
|
|
970
|
-
}
|
|
971
|
-
} catch {}
|
|
972
|
-
}
|
|
973
|
-
// Check for protected path names in script content
|
|
974
|
-
for (const p of [...protectedPaths, ...writeProtectedPaths]) {
|
|
975
|
-
if (scriptContent.includes(p)) {
|
|
976
|
-
await blockSelfProtection('SOLONGATE: Script references protected path "' + p + '" — blocked');
|
|
977
|
-
}
|
|
978
|
-
}
|
|
979
|
-
}
|
|
980
|
-
} catch {} // File read error — skip
|
|
830
|
+
const input = {
|
|
831
|
+
tool_name: toolName || '',
|
|
832
|
+
permission: guessPermission(toolName),
|
|
833
|
+
trust_level: 'TRUSTED',
|
|
834
|
+
arguments: expandedArgs,
|
|
835
|
+
paths: extractPaths(accessArgs),
|
|
836
|
+
commands: extractCommands(expandedArgs),
|
|
837
|
+
urls: extractUrls(accessArgs),
|
|
838
|
+
filenames: extractFilenames(accessArgs),
|
|
839
|
+
};
|
|
840
|
+
if (process.env.SOLONGATE_DEBUG) {
|
|
981
841
|
}
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
842
|
+
const results = opaPolicy.evaluate(input);
|
|
843
|
+
const decision = results && results[0] && results[0].result;
|
|
844
|
+
if (!decision || !decision.effect) return null;
|
|
845
|
+
|
|
846
|
+
// The generated Rego always has `default decision := DENY` (whitelist
|
|
847
|
+
// semantics). We must re-apply the policy mode here so denylist policies
|
|
848
|
+
// keep their default-ALLOW behavior, matching legacy evaluate():
|
|
849
|
+
// - denylist: default-allow → block ONLY when a DENY rule actually
|
|
850
|
+
// matched (matched_rule != null). Default DENY means "no rule matched".
|
|
851
|
+
// - whitelist: default-deny → block on any DENY (default or matched).
|
|
852
|
+
// Routing per policy mode semantics:
|
|
853
|
+
//
|
|
854
|
+
// DENYLIST (default-allow):
|
|
855
|
+
// DENY match → BLACK (block)
|
|
856
|
+
// no match → WHITE (default-allow, skip AI Judge — this IS the
|
|
857
|
+
// semantics of denylist: "block these, allow the rest")
|
|
858
|
+
// REVIEW match → GRAY (only this explicit effect calls AI Judge)
|
|
859
|
+
//
|
|
860
|
+
// WHITELIST (default-deny):
|
|
861
|
+
// ALLOW match → WHITE (skip AI Judge)
|
|
862
|
+
// DENY match → BLACK
|
|
863
|
+
// REVIEW match → GRAY
|
|
864
|
+
// no match → BLACK (default-deny)
|
|
865
|
+
//
|
|
866
|
+
// AI Judge runs ONLY when a rule explicitly says "this needs semantic
|
|
867
|
+
// review" — never as a fallback for "I'm not sure". That keeps token cost
|
|
868
|
+
// proportional to actual ambiguity and avoids running the model on every
|
|
869
|
+
// routine call.
|
|
870
|
+
const mode = policy.mode === 'whitelist' ? 'whitelist' : 'denylist';
|
|
871
|
+
const matched = decision.matched_rule != null;
|
|
872
|
+
const eff = decision.effect;
|
|
873
|
+
if (mode === 'denylist') {
|
|
874
|
+
if (eff === 'DENY' && matched) return '[SolonGate OPA] ' + (decision.reason || 'Blocked by policy');
|
|
875
|
+
if (eff === 'REVIEW' && matched) return { white: false, reason: decision.reason, ruleId: decision.matched_rule };
|
|
876
|
+
return { white: true, ruleId: matched ? decision.matched_rule : null };
|
|
1000
877
|
}
|
|
878
|
+
// whitelist
|
|
879
|
+
if (eff === 'DENY' && matched) return '[SolonGate OPA] ' + (decision.reason || 'Blocked by policy');
|
|
880
|
+
if (eff === 'REVIEW' && matched) return { white: false, reason: decision.reason, ruleId: decision.matched_rule };
|
|
881
|
+
if (eff === 'ALLOW' && matched) return { white: true, ruleId: decision.matched_rule };
|
|
882
|
+
return '[SolonGate OPA] ' + (decision.reason || 'Blocked by policy: no ALLOW rule matched');
|
|
883
|
+
} catch {
|
|
884
|
+
return undefined; // any failure → fall back to legacy evaluator
|
|
885
|
+
}
|
|
886
|
+
}
|
|
1001
887
|
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
888
|
+
// ── Main ──
|
|
889
|
+
let input = '';
|
|
890
|
+
// Read the contents of files a tool call references, so the AI Judge can see a
|
|
891
|
+
// command HIDDEN inside a script/file (e.g. `bash deploy.sh`). Bounded: at most
|
|
892
|
+
// a few small text files. Returns { name: content }.
|
|
893
|
+
function readReferencedFiles(args, cwd) {
|
|
894
|
+
const out = {};
|
|
895
|
+
const MAX_FILES = 3, MAX_BYTES = 65536;
|
|
896
|
+
const cands = new Set();
|
|
897
|
+
// Only inline a file that is actually EXECUTED by an interpreter — `bash x.sh`,
|
|
898
|
+
// `python x.py`, `source x`, `. x`. A file that is merely an argument (rm/cp/cat
|
|
899
|
+
// x, or a read/write target) is NOT run, so its content must NOT be scanned —
|
|
900
|
+
// otherwise deleting a file whose text mentions a blocked name would false-block.
|
|
901
|
+
const INTERP = /^(?:bash|sh|zsh|ksh|dash|ash|python3?|node|deno|bun|ruby|perl|php|pwsh|powershell|source|\.)$/i;
|
|
902
|
+
if (args && typeof args === 'object') {
|
|
903
|
+
for (const f of ['command', 'cmd', 'script', 'shell', 'code']) {
|
|
904
|
+
const v = args[f];
|
|
905
|
+
if (typeof v !== 'string') continue;
|
|
906
|
+
const toks = v.split(/[\s'"();|&<>]+/).filter(Boolean);
|
|
907
|
+
for (let i = 0; i < toks.length - 1; i++) {
|
|
908
|
+
if (!INTERP.test(toks[i])) continue;
|
|
909
|
+
// The first non-flag token after the interpreter is the script it runs.
|
|
910
|
+
let j = i + 1;
|
|
911
|
+
while (j < toks.length && toks[j].startsWith('-')) j++;
|
|
912
|
+
if (j < toks.length) cands.add(toks[j]);
|
|
1027
913
|
}
|
|
1028
914
|
}
|
|
915
|
+
}
|
|
916
|
+
let n = 0;
|
|
917
|
+
for (const c of cands) {
|
|
918
|
+
if (n >= MAX_FILES) break;
|
|
919
|
+
try {
|
|
920
|
+
const p = resolve(cwd || process.cwd(), c);
|
|
921
|
+
if (!existsSync(p)) continue;
|
|
922
|
+
const st = statSync(p);
|
|
923
|
+
if (!st.isFile() || st.size > MAX_BYTES) continue;
|
|
924
|
+
out[c] = readFileSync(p, 'utf-8').slice(0, MAX_BYTES);
|
|
925
|
+
n++;
|
|
926
|
+
} catch {}
|
|
927
|
+
}
|
|
928
|
+
return out;
|
|
929
|
+
}
|
|
1029
930
|
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
931
|
+
process.stdin.on('data', c => input += c);
|
|
932
|
+
process.stdin.on('end', async () => {
|
|
933
|
+
// No policy selected => no enforcement. A plain launch (no SOLONGATE_AGENT_ID)
|
|
934
|
+
// is intentionally unrestricted.
|
|
935
|
+
if (process.env.SOLONGATE_DEBUG) {
|
|
936
|
+
}
|
|
937
|
+
// Cloud gate: the API key IS the policy selector — it identifies the project
|
|
938
|
+
// and its active policy. No key → nothing to enforce → allow. (Air-gap gated
|
|
939
|
+
// on SOLONGATE_AGENT_ID instead; here the key does that job.)
|
|
940
|
+
if (!API_KEY) {
|
|
941
|
+
allowTool();
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
const _evalStart = Date.now();
|
|
945
|
+
try {
|
|
946
|
+
const raw = JSON.parse(input);
|
|
947
|
+
|
|
948
|
+
// Debug: append guard invocation to a cwd-local log. Opt-in only — set
|
|
949
|
+
// SOLONGATE_DEBUG=1 to enable. Off by default so it doesn't litter every
|
|
950
|
+
// working directory with .solongate/.debug-guard-log.
|
|
951
|
+
if (process.env.SOLONGATE_DEBUG) {
|
|
1035
952
|
try {
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
piCfg = { ...piCfg, ...rest };
|
|
1041
|
-
usedCache = true;
|
|
1042
|
-
}
|
|
1043
|
-
}
|
|
953
|
+
const { appendFileSync: afs, mkdirSync: mds } = await import('node:fs');
|
|
954
|
+
mds(resolve('.solongate'), { recursive: true });
|
|
955
|
+
const debugLine = JSON.stringify({ ts: new Date().toISOString(), hook: 'guard', argv: process.argv.slice(2), tool_name: raw.tool_name || raw.toolName || raw.command, agent_id: AGENT_ID }) + '\n';
|
|
956
|
+
afs(resolve('.solongate', '.debug-guard-log'), debugLine);
|
|
1044
957
|
} catch {}
|
|
1045
|
-
if (!usedCache) {
|
|
1046
|
-
try {
|
|
1047
|
-
const cfgRes = await fetch(API_URL + '/api/v1/project-config', {
|
|
1048
|
-
headers: { 'Authorization': 'Bearer ' + API_KEY },
|
|
1049
|
-
signal: AbortSignal.timeout(3000),
|
|
1050
|
-
});
|
|
1051
|
-
if (cfgRes.ok) {
|
|
1052
|
-
const cfg = await cfgRes.json();
|
|
1053
|
-
piCfg = { ...piCfg, ...cfg };
|
|
1054
|
-
try { writeFileSync(cacheFile, JSON.stringify({ ...cfg, _ts: Date.now() })); } catch {}
|
|
1055
|
-
}
|
|
1056
|
-
} catch {} // Fallback: defaults (safe)
|
|
1057
|
-
}
|
|
1058
958
|
}
|
|
1059
959
|
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
if (piCfg.piToolConfig && typeof piCfg.piToolConfig === 'object') {
|
|
1063
|
-
if (piCfg.piToolConfig[toolName] === false) {
|
|
1064
|
-
// PI scanning explicitly disabled for this tool — skip detection
|
|
1065
|
-
piCfg.piEnabled = false;
|
|
1066
|
-
}
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
// ── Prompt Injection Detection (Stage 1: Rules + Custom Patterns) ──
|
|
1070
|
-
const allText = scanStrings(args).join(' ');
|
|
1071
|
-
|
|
1072
|
-
// Check whitelist — if input matches any whitelist pattern, skip PI detection
|
|
1073
|
-
let whitelisted = false;
|
|
1074
|
-
if (piCfg.piEnabled !== false && Array.isArray(piCfg.piWhitelist) && piCfg.piWhitelist.length > 0) {
|
|
1075
|
-
for (const wlPattern of piCfg.piWhitelist) {
|
|
1076
|
-
try {
|
|
1077
|
-
if (!isSafeRegex(wlPattern)) continue;
|
|
1078
|
-
if (new RegExp(wlPattern, 'i').test(allText)) {
|
|
1079
|
-
whitelisted = true;
|
|
1080
|
-
break;
|
|
1081
|
-
}
|
|
1082
|
-
} catch {} // Invalid regex — skip
|
|
1083
|
-
}
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
// Build custom patterns from config
|
|
1087
|
-
const customCategories = [];
|
|
1088
|
-
if (piCfg.piEnabled !== false && Array.isArray(piCfg.piCustomPatterns)) {
|
|
1089
|
-
for (const cp of piCfg.piCustomPatterns) {
|
|
1090
|
-
if (cp && cp.pattern && isSafeRegex(cp.pattern)) {
|
|
1091
|
-
try {
|
|
1092
|
-
customCategories.push({
|
|
1093
|
-
name: cp.name || 'custom_pattern',
|
|
1094
|
-
weight: Math.max(0, Math.min(1, Number(cp.weight) || 0.8)),
|
|
1095
|
-
patterns: [new RegExp(cp.pattern, 'iu')],
|
|
1096
|
-
});
|
|
1097
|
-
} catch {} // Invalid regex — skip
|
|
1098
|
-
}
|
|
1099
|
-
}
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
const piResult = (piCfg.piEnabled !== false && !whitelisted)
|
|
1103
|
-
? detectPromptInjection(allText, customCategories, piCfg.piThreshold)
|
|
1104
|
-
: null;
|
|
1105
|
-
|
|
1106
|
-
if (piResult && piResult.blocked) {
|
|
1107
|
-
const isLogOnly = piCfg.piMode === 'log-only';
|
|
1108
|
-
const msg = isLogOnly
|
|
1109
|
-
? 'SOLONGATE: Prompt injection detected [LOG-ONLY] (trust score: ' +
|
|
1110
|
-
(piResult.trustScore * 100).toFixed(0) + '%, categories: ' +
|
|
1111
|
-
piResult.categories.join(', ') + ')'
|
|
1112
|
-
: 'SOLONGATE: Prompt injection detected (trust score: ' +
|
|
1113
|
-
(piResult.trustScore * 100).toFixed(0) + '%, categories: ' +
|
|
1114
|
-
piResult.categories.join(', ') + ')';
|
|
1115
|
-
|
|
1116
|
-
// Log to Cloud
|
|
1117
|
-
if (API_KEY && API_KEY.startsWith('sg_live_')) {
|
|
1118
|
-
try {
|
|
1119
|
-
await fetch(API_URL + '/api/v1/audit-logs', {
|
|
1120
|
-
method: 'POST',
|
|
1121
|
-
headers: { 'Authorization': 'Bearer ' + API_KEY, 'Content-Type': 'application/json' },
|
|
1122
|
-
body: JSON.stringify({
|
|
1123
|
-
tool: toolName,
|
|
1124
|
-
arguments: args,
|
|
1125
|
-
decision: isLogOnly ? 'ALLOW' : 'DENY',
|
|
1126
|
-
reason: msg,
|
|
1127
|
-
permission: guessPermission(toolName),
|
|
1128
|
-
source: `${AGENT_ID}-guard`,
|
|
1129
|
-
agent_id: AGENT_ID, agent_name: AGENT_NAME,
|
|
1130
|
-
pi_detected: true,
|
|
1131
|
-
pi_trust_score: piResult.trustScore,
|
|
1132
|
-
pi_blocked: !isLogOnly,
|
|
1133
|
-
pi_categories: JSON.stringify(piResult.categories),
|
|
1134
|
-
pi_stage_scores: JSON.stringify({ rules: piResult.score, embedding: 0, classifier: 0 }),
|
|
1135
|
-
}),
|
|
1136
|
-
signal: AbortSignal.timeout(3000),
|
|
1137
|
-
});
|
|
1138
|
-
} catch {}
|
|
960
|
+
let mappedToolName = raw.tool_name || raw.toolName || '';
|
|
961
|
+
let mappedToolInput = raw.tool_input || raw.toolInput || raw.params || {};
|
|
1139
962
|
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
blocked: !isLogOnly,
|
|
1152
|
-
mode: piCfg.piMode,
|
|
1153
|
-
timestamp: new Date().toISOString(),
|
|
1154
|
-
}),
|
|
1155
|
-
signal: AbortSignal.timeout(3000),
|
|
1156
|
-
});
|
|
1157
|
-
} catch {} // Webhook failure is non-blocking
|
|
1158
|
-
}
|
|
1159
|
-
}
|
|
963
|
+
// Normalize field names across tools
|
|
964
|
+
const data = {
|
|
965
|
+
...raw,
|
|
966
|
+
tool_name: mappedToolName,
|
|
967
|
+
tool_input: mappedToolInput,
|
|
968
|
+
tool_response: raw.tool_response || raw.toolResponse || {},
|
|
969
|
+
cwd: raw.cwd || process.cwd(),
|
|
970
|
+
session_id: raw.session_id || raw.sessionId || raw.conversation_id || '',
|
|
971
|
+
};
|
|
972
|
+
const args = data.tool_input;
|
|
973
|
+
const toolName = data.tool_name || '';
|
|
1160
974
|
|
|
1161
|
-
|
|
1162
|
-
if (isLogOnly) {
|
|
1163
|
-
process.stderr.write(msg);
|
|
1164
|
-
// Fall through to policy evaluation (don't exit)
|
|
1165
|
-
} else {
|
|
1166
|
-
writeDenyFlag(toolName);
|
|
1167
|
-
blockTool(msg);
|
|
1168
|
-
}
|
|
1169
|
-
}
|
|
975
|
+
// (self-protection + PI hook layers removed per project decision)
|
|
1170
976
|
|
|
1171
|
-
// Load policy
|
|
977
|
+
// Load policy. Priority:
|
|
978
|
+
// 1. Dashboard-managed policy (GET /api/v1/policies/active, cached 10s)
|
|
979
|
+
// 2. Local policy.json next to cwd
|
|
980
|
+
// The dashboard is the source of truth — local policy.json is only a
|
|
981
|
+
// fallback for when the API is unreachable.
|
|
1172
982
|
const hookCwd = data.cwd || process.cwd();
|
|
1173
983
|
let policy;
|
|
984
|
+
// Cache keyed by agent_id so different agents in different terminals
|
|
985
|
+
// don't share a stale cached policy.
|
|
986
|
+
const agentKey = (AGENT_ID || 'default').replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
987
|
+
const policyCacheFile = join(resolve(homedir(), '.solongate'), '.policy-cache-' + agentKey + '.json');
|
|
988
|
+
const POLICY_TTL_MS = 10_000;
|
|
1174
989
|
try {
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
} catch {
|
|
1178
|
-
// No policy file — still log if PI was detected but not blocked
|
|
1179
|
-
if (piResult && API_KEY && API_KEY.startsWith('sg_live_')) {
|
|
1180
|
-
try {
|
|
1181
|
-
await fetch(API_URL + '/api/v1/audit-logs', {
|
|
1182
|
-
method: 'POST',
|
|
1183
|
-
headers: { 'Authorization': 'Bearer ' + API_KEY, 'Content-Type': 'application/json' },
|
|
1184
|
-
body: JSON.stringify({
|
|
1185
|
-
tool: toolName,
|
|
1186
|
-
arguments: args,
|
|
1187
|
-
decision: 'ALLOW',
|
|
1188
|
-
reason: 'Prompt injection detected but below threshold (trust: ' + (piResult.trustScore * 100).toFixed(0) + '%)',
|
|
1189
|
-
permission: guessPermission(toolName),
|
|
1190
|
-
source: `${AGENT_ID}-guard`,
|
|
1191
|
-
agent_id: AGENT_ID, agent_name: AGENT_NAME,
|
|
1192
|
-
pi_detected: true,
|
|
1193
|
-
pi_trust_score: piResult.trustScore,
|
|
1194
|
-
pi_blocked: false,
|
|
1195
|
-
pi_categories: JSON.stringify(piResult.categories),
|
|
1196
|
-
pi_stage_scores: JSON.stringify({ rules: piResult.score, embedding: 0, classifier: 0 }),
|
|
1197
|
-
}),
|
|
1198
|
-
signal: AbortSignal.timeout(3000),
|
|
1199
|
-
});
|
|
1200
|
-
} catch {}
|
|
1201
|
-
}
|
|
1202
|
-
allowTool(); // No policy = allow all
|
|
1203
|
-
}
|
|
1204
|
-
|
|
1205
|
-
// ── Fetch cloud trust-map rules and merge into policy.agentTrustMap ──
|
|
1206
|
-
if (API_KEY && API_KEY.startsWith('sg_live_') && AGENT_ID) {
|
|
1207
|
-
const tmCacheFile = join(resolve('.solongate'), '.trust-rules-cache.json');
|
|
1208
|
-
let tmCached = null;
|
|
990
|
+
let dashboardPolicy = null;
|
|
991
|
+
// Try cache first
|
|
1209
992
|
try {
|
|
1210
|
-
if (existsSync(
|
|
1211
|
-
const cached = JSON.parse(readFileSync(
|
|
1212
|
-
if (cached._ts && Date.now() - cached._ts <
|
|
1213
|
-
|
|
993
|
+
if (existsSync(policyCacheFile)) {
|
|
994
|
+
const cached = JSON.parse(readFileSync(policyCacheFile, 'utf-8'));
|
|
995
|
+
if (cached && cached._ts && Date.now() - cached._ts < POLICY_TTL_MS && cached.policy) {
|
|
996
|
+
dashboardPolicy = cached.policy;
|
|
1214
997
|
}
|
|
1215
998
|
}
|
|
1216
999
|
} catch {}
|
|
1217
|
-
if
|
|
1000
|
+
// Refresh from API if cache expired
|
|
1001
|
+
if (!dashboardPolicy) {
|
|
1218
1002
|
try {
|
|
1219
|
-
const
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
tmCached._agentId = AGENT_ID;
|
|
1227
|
-
try { writeFileSync(tmCacheFile, JSON.stringify(tmCached)); } catch {}
|
|
1003
|
+
const res = await fetch(API_URL + '/api/v1/policies/active?agent_id=' + encodeURIComponent(AGENT_ID || ''), { headers: AUTH_HEADERS, signal: AbortSignal.timeout(2000) });
|
|
1004
|
+
if (res.ok) {
|
|
1005
|
+
const body = await res.json();
|
|
1006
|
+
if (body && body.policy) {
|
|
1007
|
+
dashboardPolicy = body.policy;
|
|
1008
|
+
try { writeFileSync(policyCacheFile, JSON.stringify({ _ts: Date.now(), policy: dashboardPolicy })); } catch {}
|
|
1009
|
+
}
|
|
1228
1010
|
}
|
|
1229
1011
|
} catch {}
|
|
1230
1012
|
}
|
|
1231
|
-
if (tmCached) {
|
|
1232
|
-
if (!policy) policy = {};
|
|
1233
|
-
if (!policy.agentTrustMap) policy.agentTrustMap = {};
|
|
1234
|
-
const tm = policy.agentTrustMap;
|
|
1235
|
-
|
|
1236
|
-
// Merge cloud groups into local groups
|
|
1237
|
-
if (tmCached.groups && tmCached.groups.length > 0) {
|
|
1238
|
-
if (!tm.groups) tm.groups = {};
|
|
1239
|
-
for (const g of tmCached.groups) {
|
|
1240
|
-
const key = 'cloud_' + g.id;
|
|
1241
|
-
if (!tm.groups[key]) {
|
|
1242
|
-
tm.groups[key] = {
|
|
1243
|
-
members: [AGENT_ID],
|
|
1244
|
-
rules: (g.policyRules || []).map(r => ({
|
|
1245
|
-
toolPattern: r.toolPattern || '*',
|
|
1246
|
-
permission: r.permission,
|
|
1247
|
-
effect: r.effect || 'DENY',
|
|
1248
|
-
})),
|
|
1249
|
-
};
|
|
1250
|
-
}
|
|
1251
|
-
}
|
|
1252
|
-
}
|
|
1253
|
-
|
|
1254
|
-
// Merge cloud relationships into local relationships
|
|
1255
|
-
if (tmCached.relationships && tmCached.relationships.length > 0) {
|
|
1256
|
-
if (!tm.relationships) tm.relationships = [];
|
|
1257
|
-
for (const r of tmCached.relationships) {
|
|
1258
|
-
tm.relationships.push({
|
|
1259
|
-
source: r.sourceAgentId,
|
|
1260
|
-
target: AGENT_ID,
|
|
1261
|
-
type: r.relationshipType || 'peer',
|
|
1262
|
-
allowedTools: r.allowedTools || [],
|
|
1263
|
-
deniedTools: r.deniedTools || [],
|
|
1264
|
-
});
|
|
1265
|
-
}
|
|
1266
|
-
}
|
|
1267
1013
|
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1014
|
+
if (process.env.SOLONGATE_DEBUG) {
|
|
1015
|
+
}
|
|
1016
|
+
if (dashboardPolicy) {
|
|
1017
|
+
policy = dashboardPolicy;
|
|
1018
|
+
} else {
|
|
1019
|
+
// Fall back to ~/.solongate/policy.json (where the wizard writes the
|
|
1020
|
+
// default), then a per-project policy.json next to cwd.
|
|
1021
|
+
const candidates = [
|
|
1022
|
+
join(resolve(homedir(), '.solongate'), 'policy.json'),
|
|
1023
|
+
resolve(hookCwd, 'policy.json'),
|
|
1024
|
+
];
|
|
1025
|
+
for (const p of candidates) {
|
|
1026
|
+
if (existsSync(p)) {
|
|
1027
|
+
try { policy = JSON.parse(readFileSync(p, 'utf-8')); break; } catch {}
|
|
1277
1028
|
}
|
|
1278
1029
|
}
|
|
1279
1030
|
}
|
|
1031
|
+
} catch {
|
|
1032
|
+
// Couldn't load any policy — leave policy undefined; evaluate() returns null.
|
|
1280
1033
|
}
|
|
1281
1034
|
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
// ── Agent Trust Map evaluation ──
|
|
1285
|
-
if (!reason) {
|
|
1286
|
-
const trustReason = evaluateAgentTrust(policy, AGENT_ID, toolName, guessPermission(toolName));
|
|
1287
|
-
if (trustReason) reason = trustReason;
|
|
1035
|
+
if (process.env.SOLONGATE_DEBUG) {
|
|
1288
1036
|
}
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
if (
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
let aiJudgeEndpoint = 'https://api.groq.com/openai';
|
|
1298
|
-
let aiJudgeTimeout = 5000;
|
|
1299
|
-
|
|
1300
|
-
// Check cloud config for AI Judge settings (cached for 60s)
|
|
1301
|
-
if (API_KEY && API_KEY.startsWith('sg_live_')) {
|
|
1302
|
-
const ajCacheFile = join(resolve('.solongate'), '.aj-config-cache.json');
|
|
1303
|
-
let ajCached = false;
|
|
1304
|
-
try {
|
|
1305
|
-
if (existsSync(ajCacheFile)) {
|
|
1306
|
-
const cached = JSON.parse(readFileSync(ajCacheFile, 'utf-8'));
|
|
1307
|
-
if (cached._ts && Date.now() - cached._ts < 60000) {
|
|
1308
|
-
aiJudgeEnabled = Boolean(cached.enabled);
|
|
1309
|
-
if (cached.model) aiJudgeModel = cached.model;
|
|
1310
|
-
if (cached.endpoint) aiJudgeEndpoint = cached.endpoint;
|
|
1311
|
-
if (cached.timeoutMs) aiJudgeTimeout = cached.timeoutMs;
|
|
1312
|
-
ajCached = true;
|
|
1313
|
-
}
|
|
1314
|
-
}
|
|
1315
|
-
} catch {}
|
|
1316
|
-
if (!ajCached) {
|
|
1317
|
-
try {
|
|
1318
|
-
const cfgRes = await fetch(API_URL + '/api/v1/project-config/ai-judge', {
|
|
1319
|
-
headers: { 'Authorization': 'Bearer ' + API_KEY },
|
|
1320
|
-
signal: AbortSignal.timeout(3000),
|
|
1321
|
-
});
|
|
1322
|
-
if (cfgRes.ok) {
|
|
1323
|
-
const cfg = await cfgRes.json();
|
|
1324
|
-
aiJudgeEnabled = Boolean(cfg.enabled);
|
|
1325
|
-
if (cfg.model) aiJudgeModel = cfg.model;
|
|
1326
|
-
if (cfg.endpoint) aiJudgeEndpoint = cfg.endpoint;
|
|
1327
|
-
if (cfg.timeoutMs) aiJudgeTimeout = cfg.timeoutMs;
|
|
1328
|
-
try { writeFileSync(ajCacheFile, JSON.stringify({ ...cfg, _ts: Date.now() })); } catch {}
|
|
1329
|
-
}
|
|
1330
|
-
} catch {}
|
|
1331
|
-
}
|
|
1037
|
+
// Agent scoping
|
|
1038
|
+
{
|
|
1039
|
+
const scope = (policy && Array.isArray(policy.agents) && policy.agents.length > 0)
|
|
1040
|
+
? policy.agents
|
|
1041
|
+
: ['*'];
|
|
1042
|
+
if (!scope.includes('*') && !scope.includes(AGENT_TYPE)) {
|
|
1043
|
+
allowTool();
|
|
1044
|
+
return;
|
|
1332
1045
|
}
|
|
1046
|
+
}
|
|
1333
1047
|
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
if (core && core.length >= 2 && argStr.includes(core)) {
|
|
1365
|
-
couldMatchProtected = true;
|
|
1366
|
-
break;
|
|
1367
|
-
}
|
|
1368
|
-
}
|
|
1369
|
-
}
|
|
1370
|
-
|
|
1371
|
-
// If args clearly don't reference any protected pattern, skip LLM — instant ALLOW
|
|
1372
|
-
if (!couldMatchProtected) {
|
|
1373
|
-
// No need to call AI Judge — nothing to protect here
|
|
1374
|
-
} else {
|
|
1375
|
-
|
|
1376
|
-
const judgePayload = JSON.stringify({
|
|
1377
|
-
tool: toolName,
|
|
1378
|
-
arguments: args,
|
|
1379
|
-
protected_files: protectedFiles,
|
|
1380
|
-
protected_paths: protectedPathsList,
|
|
1381
|
-
});
|
|
1382
|
-
|
|
1383
|
-
const systemPrompt = `You are a security judge for an AI coding tool. Your ONLY job is to check if a tool call accesses files or paths that are in the protected lists. Nothing else.
|
|
1384
|
-
|
|
1385
|
-
You will receive a JSON object with:
|
|
1386
|
-
- "tool": the tool name being called
|
|
1387
|
-
- "arguments": the tool's arguments
|
|
1388
|
-
- "protected_files": the EXACT and COMPLETE list of protected files from the user's policy
|
|
1389
|
-
- "protected_paths": the EXACT and COMPLETE list of protected directories from the user's policy
|
|
1390
|
-
|
|
1391
|
-
RULES:
|
|
1392
|
-
1. DENY ONLY if the tool call could access a file or path that is in protected_files or protected_paths.
|
|
1393
|
-
2. ALLOW everything else. You must NOT invent your own security rules.
|
|
1394
|
-
3. If a file is NOT in protected_files, it is NOT protected — even if the filename looks sensitive.
|
|
1395
|
-
4. "cat test.txt" is ALLOWED if test.txt is not in protected_files.
|
|
1396
|
-
5. "curl https://example.com" is ALLOWED unless it sends protected file content.
|
|
1397
|
-
6. "printenv" is ALLOWED unless the policy explicitly protects it.
|
|
1398
|
-
|
|
1399
|
-
BYPASS DETECTION — DENY if the command accesses a protected file through:
|
|
1400
|
-
- Shell glob patterns: "cat cred*" could match "credentials.json" IF it is in protected_files
|
|
1401
|
-
- Command substitution: "cat $(echo .env)" builds ".env"
|
|
1402
|
-
- Variable interpolation: f=".en"; cat \${f}v builds ".env"
|
|
1403
|
-
- Process substitution: <(cat .env)
|
|
1404
|
-
- Multi-stage: cp protected_file /tmp/x && cat /tmp/x
|
|
1405
|
-
- Input redirection: < .env
|
|
1406
|
-
- Any file-reading utility (cat, head, tail, less, perl, awk, sed, xxd, etc.)
|
|
1407
|
-
|
|
1408
|
-
CRITICAL: You have NO security opinions of your own. You ONLY enforce the protected_files and protected_paths lists. If something is not in those lists, it is ALLOWED. Do NOT over-block.
|
|
1409
|
-
|
|
1410
|
-
Respond with ONLY valid JSON: {"decision": "ALLOW" or "DENY", "reason": "brief explanation", "confidence": 0.0 to 1.0}`;
|
|
1411
|
-
|
|
1412
|
-
const llmRes = await fetch(aiJudgeEndpoint + '/v1/chat/completions', {
|
|
1413
|
-
method: 'POST',
|
|
1414
|
-
headers: {
|
|
1415
|
-
'Content-Type': 'application/json',
|
|
1416
|
-
'Authorization': 'Bearer ' + GROQ_KEY,
|
|
1417
|
-
},
|
|
1418
|
-
body: JSON.stringify({
|
|
1419
|
-
model: aiJudgeModel,
|
|
1420
|
-
messages: [
|
|
1421
|
-
{ role: 'system', content: systemPrompt },
|
|
1422
|
-
{ role: 'user', content: judgePayload },
|
|
1423
|
-
],
|
|
1424
|
-
temperature: 0,
|
|
1425
|
-
max_tokens: 200,
|
|
1426
|
-
}),
|
|
1427
|
-
signal: AbortSignal.timeout(aiJudgeTimeout),
|
|
1428
|
-
});
|
|
1429
|
-
|
|
1430
|
-
if (llmRes.ok) {
|
|
1431
|
-
const llmData = await llmRes.json();
|
|
1432
|
-
const content = llmData.choices?.[0]?.message?.content || '';
|
|
1433
|
-
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
|
1434
|
-
if (jsonMatch) {
|
|
1435
|
-
const verdict = JSON.parse(jsonMatch[0]);
|
|
1436
|
-
if (verdict.decision === 'DENY' && verdict.confidence >= 0.7) {
|
|
1437
|
-
reason = '[SolonGate AI Judge] Blocked: ' + (verdict.reason || 'Semantic analysis detected a policy violation');
|
|
1438
|
-
}
|
|
1439
|
-
// Low-confidence DENY or ALLOW → skip (don't block)
|
|
1440
|
-
}
|
|
1441
|
-
}
|
|
1442
|
-
// Auth/config errors (401, 403) → skip AI Judge, don't DENY
|
|
1443
|
-
// Other LLM errors → skip too (policy engine already evaluated)
|
|
1444
|
-
|
|
1445
|
-
} // end else (couldMatchProtected)
|
|
1446
|
-
} catch {
|
|
1447
|
-
// Timeout or parse error → skip AI Judge (policy engine already passed)
|
|
1448
|
-
}
|
|
1048
|
+
if (process.env.SOLONGATE_DEBUG) {
|
|
1049
|
+
}
|
|
1050
|
+
// Hardcoded tamper protection — runs unconditionally, before policy eval.
|
|
1051
|
+
let reason = tamperCheck(toolName, args);
|
|
1052
|
+
if (process.env.SOLONGATE_DEBUG) {
|
|
1053
|
+
}
|
|
1054
|
+
// OPA WASM is the SOLE policy engine. With no policy configured for this
|
|
1055
|
+
// agent we skip evaluation entirely (allow). With a policy present,
|
|
1056
|
+
// evaluateWithOpa returns a reason (DENY), null (ALLOW), or undefined when
|
|
1057
|
+
// the WASM bundle could not be obtained at all — in which case we fail
|
|
1058
|
+
// CLOSED rather than silently allowing. (The legacy JS evaluate() below is
|
|
1059
|
+
// retained but no longer on the decision path — OPA decides everything.)
|
|
1060
|
+
// Cloud routing is BINARY — WHITE (allow) / BLACK (block). There is NO AI
|
|
1061
|
+
// Judge in the cloud (that is an air-gap-only feature), so there is no GRAY
|
|
1062
|
+
// "send to the judge" lane: the OPA policy alone decides. Tamper protection
|
|
1063
|
+
// and any DENY (incl. fail-closed) → BLACK; everything else → WHITE. A REVIEW
|
|
1064
|
+
// rule with no judge to escalate to is treated as allow under denylist.
|
|
1065
|
+
let opaRoute = 'white';
|
|
1066
|
+
if (reason) {
|
|
1067
|
+
opaRoute = 'black'; // hardcoded tamper protection blocked it
|
|
1068
|
+
} else if (policy && policy.rules) {
|
|
1069
|
+
const opaResult = await evaluateWithOpa(policy, args, toolName, hookCwd);
|
|
1070
|
+
if (opaResult === undefined) {
|
|
1071
|
+
reason = '[SolonGate OPA] Policy WASM unavailable — failing closed (DENY). Ensure the SolonGate API is reachable so policies compile to WASM.';
|
|
1072
|
+
opaRoute = 'black';
|
|
1073
|
+
} else if (typeof opaResult === 'string') {
|
|
1074
|
+
reason = opaResult; // explicit DENY
|
|
1075
|
+
opaRoute = 'black';
|
|
1076
|
+
} else {
|
|
1077
|
+
opaRoute = 'white'; // allow (rule match, default-allow, or review w/o judge)
|
|
1449
1078
|
}
|
|
1450
1079
|
}
|
|
1451
1080
|
|
|
1081
|
+
process.stderr.write(`[SolonGate ROUTE] ${opaRoute.toUpperCase()} (${reason ? 'block' : 'allow'})\n`);
|
|
1082
|
+
|
|
1452
1083
|
// Only log DENY decisions from guard hook.
|
|
1453
1084
|
// ALLOW decisions are logged by the audit hook (PostToolUse) to avoid double-counting.
|
|
1454
1085
|
if (reason) {
|
|
1455
|
-
if (
|
|
1086
|
+
if (true) {
|
|
1456
1087
|
try {
|
|
1457
1088
|
const logEntry = {
|
|
1458
1089
|
tool: toolName, arguments: args,
|
|
1459
1090
|
decision: 'DENY', reason,
|
|
1460
1091
|
permission: guessPermission(toolName),
|
|
1461
|
-
source: `${
|
|
1462
|
-
agent_id:
|
|
1092
|
+
source: `${AGENT_TYPE}-guard`,
|
|
1093
|
+
agent_id: AGENT_TYPE, agent_name: AGENT_NAME,
|
|
1094
|
+
evaluation_time_ms: Date.now() - _evalStart,
|
|
1463
1095
|
};
|
|
1464
|
-
|
|
1465
|
-
logEntry.pi_detected = true;
|
|
1466
|
-
logEntry.pi_trust_score = piResult.trustScore;
|
|
1467
|
-
logEntry.pi_blocked = false;
|
|
1468
|
-
logEntry.pi_categories = JSON.stringify(piResult.categories);
|
|
1469
|
-
logEntry.pi_stage_scores = JSON.stringify({ rules: piResult.score, embedding: 0, classifier: 0 });
|
|
1470
|
-
}
|
|
1096
|
+
// PI hook layer removed — piResult fields no longer attached.
|
|
1471
1097
|
await fetch(API_URL + '/api/v1/audit-logs', {
|
|
1472
1098
|
method: 'POST',
|
|
1473
|
-
headers: { '
|
|
1099
|
+
headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
|
|
1474
1100
|
body: JSON.stringify(logEntry),
|
|
1475
1101
|
signal: AbortSignal.timeout(3000),
|
|
1476
1102
|
});
|