@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/hooks/guard.mjs CHANGED
@@ -1,14 +1,30 @@
1
1
  #!/usr/bin/env node
2
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.
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 API key from .env file (Claude Code doesn't load .env into process.env) ──
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 API_KEY = process.env.SOLONGATE_API_KEY || dotenv.SOLONGATE_API_KEY || '';
49
- const API_URL = process.env.SOLONGATE_API_URL || dotenv.SOLONGATE_API_URL || 'https://api.solongate.com';
50
-
51
- // Agent identity from CLI args: node guard.mjs <agent_id> <agent_name>
52
- const AGENT_ID = process.argv[2] || 'claude-code';
53
- const AGENT_NAME = process.argv[3] || 'Claude Code';
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 (AGENT_ID === 'gemini-cli') {
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 (AGENT_ID === 'gemini-cli') {
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
- if (s.includes('/') || s.includes('\\')) {
269
- const base = s.replace(/\\/g, '/').split('/').pop();
270
- if (base) names.add(base);
271
- continue;
272
- }
273
- if (s.includes(' ')) {
274
- for (const tok of s.split(/\s+/)) {
275
- if (tok.includes('/') || tok.includes('\\')) {
276
- const b = tok.replace(/\\/g, '/').split('/').pop();
277
- if (b && looksLikeFilename(b)) names.add(b);
278
- } else if (looksLikeFilename(tok)) names.add(tok);
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
- // ── Policy Evaluation ──
326
- function evaluate(policy, args) {
327
- if (!policy || !policy.rules) return null;
328
- const denyRules = policy.rules
329
- .filter(r => r.effect === 'DENY' && r.enabled !== false)
330
- .sort((a, b) => (a.priority || 100) - (b.priority || 100));
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
- for (const rule of denyRules) {
333
- if (rule.filenameConstraints && rule.filenameConstraints.denied) {
334
- const filenames = extractFilenames(args);
335
- for (const fn of filenames) {
336
- for (const pat of rule.filenameConstraints.denied) {
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
- return null;
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
- // ── Agent Trust Map Evaluation ──
370
- function matchTrustGlob(value, pattern) {
371
- if (pattern === '*') return true;
372
- if (!pattern.includes('*')) return value === pattern;
373
- const regex = new RegExp('^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$');
374
- return regex.test(value);
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 evaluateAgentTrust(policy, agentId, toolName, permission) {
378
- const trustMap = policy && policy.agentTrustMap;
379
- if (!trustMap) return null;
380
-
381
- // 1. Group rules
382
- if (trustMap.groups) {
383
- for (const [groupName, group] of Object.entries(trustMap.groups)) {
384
- if (group.members && group.members.includes(agentId)) {
385
- for (const rule of (group.rules || [])) {
386
- if (matchTrustGlob(toolName, rule.toolPattern)) {
387
- if (rule.permission && rule.permission !== permission) continue;
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
- // 2. Relationship rules (where this agent is the target)
398
- if (trustMap.relationships) {
399
- for (const rel of trustMap.relationships) {
400
- if (rel.target === agentId) {
401
- if (rel.deniedTools && rel.deniedTools.some(p => matchTrustGlob(toolName, p))) {
402
- return 'Blocked by relationship: ' + rel.source + ' ' + rel.target;
403
- }
404
- if (rel.allowedTools && rel.allowedTools.length > 0) {
405
- if (!rel.allowedTools.some(p => matchTrustGlob(toolName, p))) {
406
- return 'Tool not in allowed list: ' + rel.source + ' → ' + rel.target;
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
- // 3. Delegation chains
414
- if (trustMap.delegations) {
415
- for (const del of trustMap.delegations) {
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
- // ── Main ──
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
- // 6. Check if a glob/wildcard could match any protected path
558
- function globMatchesProtected(s) {
559
- const hasWild = s.includes('*') || s.includes('?') || /\[.+\]/.test(s);
560
- if (!hasWild) return null;
561
- const segments = s.split('/').filter(Boolean);
562
- const candidates = [s, ...segments];
563
- for (const candidate of candidates) {
564
- const candHasWild = candidate.includes('*') || candidate.includes('?') || /\[.+\]/.test(candidate);
565
- if (!candHasWild) continue;
566
- // Prefix match: ".antig*" → prefix ".antig"
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
- // ── Build all candidate strings from tool input ──
612
- const rawStrings = scanStrings(args).map(s => s.replace(/\\/g, '/').toLowerCase());
613
- const allStrings = new Set();
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
- for (const s of rawStrings) {
616
- // Raw string
617
- allStrings.add(s);
618
- // Normalized (ANSI-C decoded, quotes stripped)
619
- const norm = normalizeShell(s);
620
- allStrings.add(norm);
621
- // Extract inner commands (eval, bash -c, pipe to bash, find -name)
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
- // ── Check all candidates ──
640
- for (const s of allStrings) {
641
- // Direct match
642
- for (const p of protectedPaths) {
643
- if (s.includes(p)) {
644
- await blockSelfProtection('SOLONGATE: Access to protected path "' + p + '" is blocked');
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
- // ── Write-protection for .env, .gitignore ──
660
- // These files can be READ but not written/deleted/moved
661
- const toolNameLower = (data.tool_name || '').toLowerCase();
662
- const isWriteTool = toolNameLower === 'write' || toolNameLower === 'edit' || toolNameLower === 'notebookedit';
663
- const isBashTool = toolNameLower === 'bash';
664
- const bashDestructive = /\b(?:rm|rmdir|del|unlink|mv|move|rename|cp|chmod|chown|truncate|shred)\b/i;
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
- // Also apply glob/wildcard checks for write-protected paths in destructive contexts
686
- if (isBashTool && bashDestructive.test(fullCmd)) {
687
- for (const s of allStrings) {
688
- const hasWild = s.includes('*') || s.includes('?') || /\[.+\]/.test(s);
689
- if (!hasWild) continue;
690
- // Convert glob to regex and test against write-protected paths
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
- // ── Layer 7: Block ALL inline code execution & dangerous patterns ──
716
- // Runtime string construction (atob, Buffer.from, fromCharCode, array.join)
717
- // makes static analysis impossible. Blanket-block these patterns.
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
- // 7-pre. Blanket rule: destructive command + dot-prefixed glob = BLOCK
720
- // Catches: rm -rf .[a-z]*, mv .[!.]* /tmp, etc.
721
- // Any glob starting with "." combined with a destructive op is dangerous
722
- const destructiveOps = /\b(?:rm|rmdir|del|unlink|mv|move|rename)\b/i;
723
- const dotGlob = /\.\[|\.[\*\?]|\.{[^}]+}/; // .[a-z]*, .*, .?, .{foo,bar}
724
- if (destructiveOps.test(fullCmd) && dotGlob.test(fullCmd)) {
725
- await blockSelfProtection('SOLONGATE: Destructive command + dot-glob pattern blocked');
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
- // 7-pre2. Heredoc to interpreter BLOCK
729
- // bash << 'EOF' / bash <<< "cmd" / sh << TAG
730
- if (/\b(?:bash|sh|node|python[23]?|perl|ruby)\s+<</i.test(fullCmd)) {
731
- await blockSelfProtection('SOLONGATE: Heredoc to interpreter — blocked');
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
- // 7-pre3. Command substitution + destructive = BLOCK
735
- // Catches: rm -rf $(node scan.mjs), rm `node scan.mjs`, rm $(bash scan.sh)
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
- // 7-pre4. Pipe from interpreter to destructive loop = BLOCK
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
- // 7-pre5. Script chaining: interpreter + destructive in same command chain
754
- // Catches: node scan.mjs && rm -rf $(cat /tmp/targets.txt)
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
- // 7-pre6. Nested script substitution: node clean.mjs $(node scan.mjs)
762
- // Two "harmless" scripts chained via command substitution — no rm in the command itself
763
- const nestedScriptSub = /\b(?:node|python[23]?|perl|ruby|bash|sh)\s+\S+\.\S+\s+.*\$\(\s*(?:node|python[23]?|perl|ruby|bash|sh)\b/i;
764
- const backtickNestedScript = /\b(?:node|python[23]?|perl|ruby|bash|sh)\s+\S+\.\S+\s+.*`\s*(?:node|python[23]?|perl|ruby|bash|sh)\b/i;
765
- if (nestedScriptSub.test(fullCmd) || backtickNestedScript.test(fullCmd)) {
766
- await blockSelfProtection('SOLONGATE: Nested script substitution blocked (script output as args to another script)');
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
- // 7-pre7. NODE_OPTIONS injection block commands with NODE_OPTIONS env var
770
- // NODE_OPTIONS="--require malicious.cjs" can inject code into any node process
771
- if (/\bNODE_OPTIONS\s*=/i.test(fullCmd)) {
772
- await blockSelfProtection('SOLONGATE: NODE_OPTIONS injection — blocked');
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
- // 7-pre8. npm lifecycle scripts scan package.json before npm install/run
776
- // npm install can trigger postinstall/preinstall/prepare scripts
777
- if (/\bnpm\s+(?:install|i|ci|run|start|test|publish|pack)\b/i.test(fullCmd) || /\bnpx\s+/i.test(fullCmd)) {
778
- try {
779
- const hookCwdForNpm = data.cwd || process.cwd();
780
- const pkgPath = resolve(hookCwdForNpm, 'package.json');
781
- if (existsSync(pkgPath)) {
782
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
783
- const scripts = pkg.scripts || {};
784
- const lifecycleKeys = ['preinstall', 'install', 'postinstall', 'prepare', 'prepublish', 'prepublishOnly', 'prepack', 'postpack'];
785
- const allScripts = Object.entries(scripts);
786
- for (const [key, val] of allScripts) {
787
- if (typeof val !== 'string') continue;
788
- const scriptLower = val.toLowerCase();
789
- // Check lifecycle scripts for dangerous patterns
790
- const isLifecycle = lifecycleKeys.includes(key);
791
- const isExplicitRun = fullCmd.includes(key); // npm run <key>
792
- if (isLifecycle || isExplicitRun) {
793
- // Check for protected path names
794
- for (const p of [...protectedPaths, ...writeProtectedPaths]) {
795
- if (scriptLower.includes(p)) {
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
- // 7b. Pipe-to-interpreter TOTAL BLOCK
882
- // Any content piped to an interpreter can construct arbitrary commands at runtime
883
- // Also catches: | xargs node, | xargs bash, etc.
884
- const pipeToInterpreter = /\|\s*(?:xargs\s+)?(?:node|bash|sh|python[23]?|perl|ruby|php)\b/i;
885
- if (pipeToInterpreter.test(fullCmd)) {
886
- await blockSelfProtection('SOLONGATE: Pipe to interpreter blocked — runtime bypass risk');
887
- }
888
-
889
- // 7c. Base64 decode in ANY context — block when piped to anything
890
- if (/\bbase64\s+(?:-d|--decode)\b/i.test(fullCmd) && /\|/i.test(fullCmd)) {
891
- await blockSelfProtection('SOLONGATE: base64 decode in pipe chain — blocked');
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
- // 7d. Temp/arbitrary script file execution
895
- if (/\b(?:bash|sh)\s+(?:\/tmp\/|\/var\/tmp\/|~\/|\/dev\/)/i.test(fullCmd)) {
896
- await blockSelfProtection('SOLONGATE: Script execution from temp path — blocked');
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
- // 7e. xargs with destructive operations
900
- if (/\bxargs\b.*\b(?:rm|mv|cp|rmdir|unlink|del)\b/i.test(fullCmd)) {
901
- for (const p of protectedPaths) {
902
- if (fullCmd.includes(p.slice(0, 4))) {
903
- await blockSelfProtection('SOLONGATE: xargs with destructive op near "' + p + '" — blocked');
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
- // 7f. cmd.exe /c with encoded/constructed commands
909
- if (/\bcmd(?:\.exe)?\s+\/c\b/i.test(fullCmd)) {
910
- for (const p of protectedPaths) {
911
- if (fullCmd.includes(p) || fullCmd.includes(p.slice(0, 4))) {
912
- await blockSelfProtection('SOLONGATE: cmd.exe /c near protected path — blocked');
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
- // 7g. Script file execution — scan file content for discovery+destruction combo
918
- // Catches: bash script.sh / node script.mjs where the script uses readdirSync + rmSync
919
- const scriptExecMatch = fullCmd.match(/\b(?:bash|sh|node|python[23]?|perl|ruby)\s+([^\s;&|$`]+)/i);
920
- if (scriptExecMatch) {
921
- const scriptPath = scriptExecMatch[1];
922
- try {
923
- const hookCwdForScript = data.cwd || process.cwd();
924
- const absPath = scriptPath.startsWith('/') || scriptPath.includes(':')
925
- ? scriptPath
926
- : resolve(hookCwdForScript, scriptPath);
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
- // 7h. Write tool content scanning detect discovery+destruction in file content being written
984
- // Catches: Write tool creating a script that uses readdirSync('.') + rmSync
985
- const toolName_ = data.tool_name || '';
986
- if (toolName_.toLowerCase() === 'write' || toolName_.toLowerCase() === 'edit') {
987
- const fileContent = (args.content || args.new_string || '').toLowerCase();
988
- if (fileContent.length > 0) {
989
- 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);
990
- 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);
991
- if (hasDiscovery && hasDestruction) {
992
- await blockSelfProtection('SOLONGATE: File content contains discovery + destructive ops — write blocked');
993
- }
994
- // String construction + destructive = BLOCK
995
- const hasStringConstruction = /\bfromcharcode\b|\batob\s*\(|\bbuffer\.from\b|\\x[0-9a-f]{2}|\bstring\.fromcodepoin/i.test(fileContent);
996
- if (hasStringConstruction && hasDestruction) {
997
- await blockSelfProtection('SOLONGATE: File uses string construction + destructive ops — write blocked');
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
- // 7i. Write tool — detect package.json scripts with dangerous patterns
1003
- if (toolName_.toLowerCase() === 'write' || toolName_.toLowerCase() === 'edit') {
1004
- const filePath = (args.file_path || '').toLowerCase();
1005
- if (filePath.endsWith('package.json')) {
1006
- const content = args.content || args.new_string || '';
1007
- try {
1008
- // Try parsing as JSON to check scripts
1009
- const pkg = JSON.parse(content);
1010
- const scripts = pkg.scripts || {};
1011
- for (const [key, val] of Object.entries(scripts)) {
1012
- if (typeof val !== 'string') continue;
1013
- const v = val.toLowerCase();
1014
- for (const p of [...protectedPaths, ...writeProtectedPaths]) {
1015
- if (v.includes(p)) {
1016
- await blockSelfProtection('SOLONGATE: package.json script "' + key + '" references protected "' + p + '" — blocked');
1017
- }
1018
- }
1019
- if (/fromcharcode|atob\s*\(|buffer\.from/i.test(v)) {
1020
- const hasDest = /\brmsync\b|\brm\b|\bunlink\b|\brimraf\b/i.test(v);
1021
- if (hasDest) {
1022
- await blockSelfProtection('SOLONGATE: package.json script "' + key + '" has string construction + destruction — blocked');
1023
- }
1024
- }
1025
- }
1026
- } catch {} // Not valid JSON or parse error — skip
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
- // ── Fetch PI config from Cloud (cached for 60s to avoid per-call latency) ──
1031
- let piCfg = { piEnabled: true, piThreshold: 0.5, piMode: 'block', piWhitelist: [], piToolConfig: {}, piCustomPatterns: [], piWebhookUrl: null };
1032
- if (API_KEY && API_KEY.startsWith('sg_live_')) {
1033
- const cacheFile = join(resolve('.solongate'), '.pi-config-cache.json');
1034
- let usedCache = false;
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
- if (existsSync(cacheFile)) {
1037
- const cached = JSON.parse(readFileSync(cacheFile, 'utf-8'));
1038
- if (cached._ts && Date.now() - cached._ts < 60000) {
1039
- const { _ts, ...rest } = cached;
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
- // ── Per-tool config: check if PI scanning is disabled for this tool ──
1061
- // toolName already defined above (before self-protection)
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
- // Webhook notification (SSRF-safe: HTTPS only, no private IPs)
1141
- if (piCfg.piWebhookUrl && isSafeWebhookUrl(piCfg.piWebhookUrl)) {
1142
- try {
1143
- await fetch(piCfg.piWebhookUrl, {
1144
- method: 'POST',
1145
- headers: { 'Content-Type': 'application/json' },
1146
- body: JSON.stringify({
1147
- event: 'prompt_injection_detected',
1148
- tool: toolName,
1149
- trustScore: piResult.trustScore,
1150
- categories: piResult.categories,
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
- // In log-only mode, warn but don't block
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 (use cwd from hook data if available)
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
- const policyPath = resolve(hookCwd, 'policy.json');
1176
- policy = JSON.parse(readFileSync(policyPath, 'utf-8'));
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(tmCacheFile)) {
1211
- const cached = JSON.parse(readFileSync(tmCacheFile, 'utf-8'));
1212
- if (cached._ts && Date.now() - cached._ts < 60000 && cached._agentId === AGENT_ID) {
1213
- tmCached = cached;
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 (!tmCached) {
1000
+ // Refresh from API if cache expired
1001
+ if (!dashboardPolicy) {
1218
1002
  try {
1219
- const tmRes = await fetch(API_URL + '/api/v1/trust-map/rules?agentId=' + encodeURIComponent(AGENT_ID), {
1220
- headers: { 'Authorization': 'Bearer ' + API_KEY },
1221
- signal: AbortSignal.timeout(5000),
1222
- });
1223
- if (tmRes.ok) {
1224
- tmCached = await tmRes.json();
1225
- tmCached._ts = Date.now();
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
- // Merge cloud delegations into local delegations
1269
- if (tmCached.delegations && tmCached.delegations.length > 0) {
1270
- if (!tm.delegations) tm.delegations = [];
1271
- for (const d of tmCached.delegations) {
1272
- tm.delegations.push({
1273
- chain: d.chain || [],
1274
- effectiveTools: d.effectiveTools || [],
1275
- effectivePermissions: d.effectivePermissions || [],
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
- let reason = evaluate(policy, args);
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
- // ── AI Judge: semantic intent analysis (runs when policy ALLOWs) ──
1291
- if (!reason) {
1292
- let GROQ_KEY = process.env.GROQ_API_KEY || dotenv.GROQ_API_KEY || '';
1293
- // Skip placeholder values
1294
- if (GROQ_KEY && (GROQ_KEY.includes('your_') || GROQ_KEY.includes('_here') || GROQ_KEY.length < 10)) GROQ_KEY = '';
1295
- let aiJudgeEnabled = false;
1296
- let aiJudgeModel = 'llama-3.1-8b-instant';
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
- if (aiJudgeEnabled && GROQ_KEY) {
1335
- try {
1336
- // Extract protected files/paths from policy
1337
- const protectedFiles = [];
1338
- const protectedPathsList = [];
1339
- if (policy && policy.rules) {
1340
- for (const rule of policy.rules) {
1341
- if (rule.effect === 'DENY' && rule.enabled !== false) {
1342
- if (rule.filenameConstraints && rule.filenameConstraints.denied) {
1343
- for (const f of rule.filenameConstraints.denied) protectedFiles.push(f);
1344
- }
1345
- if (rule.pathConstraints && rule.pathConstraints.denied) {
1346
- for (const p of rule.pathConstraints.denied) protectedPathsList.push(p);
1347
- }
1348
- }
1349
- }
1350
- }
1351
-
1352
- // Pre-filter: skip AI Judge if tool args clearly don't touch any protected file/path.
1353
- // Only call LLM when there's a potential match or obfuscation attempt.
1354
- const argStr = JSON.stringify(args).toLowerCase();
1355
- const hasShellTricks = /\$[\({]|`|<\(|>\(|\beval\b|\bexec\b|\bsource\b|\bxargs\b/.test(argStr);
1356
- const hasWildcard = /[*?]/.test(argStr);
1357
- let couldMatchProtected = hasShellTricks || hasWildcard;
1358
- if (!couldMatchProtected) {
1359
- // Check if any protected file/path name (without glob chars) appears in args
1360
- const allProtected = [...protectedFiles, ...protectedPathsList];
1361
- for (const p of allProtected) {
1362
- // Strip glob chars to get the core name: "*.env*" → "env", ".solongate" → "solongate"
1363
- const core = p.replace(/[*?[\]{}]/g, '').replace(/^\.+/, '').toLowerCase();
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 (API_KEY && API_KEY.startsWith('sg_live_')) {
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: `${AGENT_ID}-guard`,
1462
- agent_id: AGENT_ID, agent_name: AGENT_NAME,
1092
+ source: `${AGENT_TYPE}-guard`,
1093
+ agent_id: AGENT_TYPE, agent_name: AGENT_NAME,
1094
+ evaluation_time_ms: Date.now() - _evalStart,
1463
1095
  };
1464
- if (piResult) {
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: { 'Authorization': 'Bearer ' + API_KEY, 'Content-Type': 'application/json' },
1099
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
1474
1100
  body: JSON.stringify(logEntry),
1475
1101
  signal: AbortSignal.timeout(3000),
1476
1102
  });