@solongate/proxy 0.47.0 → 0.47.1

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.
@@ -170,12 +170,13 @@ async function runGlobalInstall(opts = {}) {
170
170
  const guardAbs = join(p.hooksDir, "guard.mjs").replace(/\\/g, "/");
171
171
  const auditAbs = join(p.hooksDir, "audit.mjs").replace(/\\/g, "/");
172
172
  const stopAbs = join(p.hooksDir, "stop.mjs").replace(/\\/g, "/");
173
+ const nodeBin = process.execPath.replace(/\\/g, "/");
173
174
  const merged = {
174
175
  ...existing,
175
176
  hooks: {
176
- PreToolUse: [{ matcher: "", hooks: [{ type: "command", command: `node "${guardAbs}" claude-code "Claude Code"` }] }],
177
- PostToolUse: [{ matcher: "", hooks: [{ type: "command", command: `node "${auditAbs}" claude-code "Claude Code"` }] }],
178
- Stop: [{ matcher: "", hooks: [{ type: "command", command: `node "${stopAbs}" claude-code "Claude Code"` }] }]
177
+ PreToolUse: [{ matcher: "", hooks: [{ type: "command", command: `"${nodeBin}" "${guardAbs}" claude-code "Claude Code"` }] }],
178
+ PostToolUse: [{ matcher: "", hooks: [{ type: "command", command: `"${nodeBin}" "${auditAbs}" claude-code "Claude Code"` }] }],
179
+ Stop: [{ matcher: "", hooks: [{ type: "command", command: `"${nodeBin}" "${stopAbs}" claude-code "Claude Code"` }] }]
179
180
  }
180
181
  };
181
182
  writeFileSync(p.settingsPath, JSON.stringify(merged, null, 2) + "\n");
package/dist/index.js CHANGED
@@ -6747,12 +6747,13 @@ async function runGlobalInstall(opts = {}) {
6747
6747
  const guardAbs = join3(p.hooksDir, "guard.mjs").replace(/\\/g, "/");
6748
6748
  const auditAbs = join3(p.hooksDir, "audit.mjs").replace(/\\/g, "/");
6749
6749
  const stopAbs = join3(p.hooksDir, "stop.mjs").replace(/\\/g, "/");
6750
+ const nodeBin = process.execPath.replace(/\\/g, "/");
6750
6751
  const merged = {
6751
6752
  ...existing,
6752
6753
  hooks: {
6753
- PreToolUse: [{ matcher: "", hooks: [{ type: "command", command: `node "${guardAbs}" claude-code "Claude Code"` }] }],
6754
- PostToolUse: [{ matcher: "", hooks: [{ type: "command", command: `node "${auditAbs}" claude-code "Claude Code"` }] }],
6755
- Stop: [{ matcher: "", hooks: [{ type: "command", command: `node "${stopAbs}" claude-code "Claude Code"` }] }]
6754
+ PreToolUse: [{ matcher: "", hooks: [{ type: "command", command: `"${nodeBin}" "${guardAbs}" claude-code "Claude Code"` }] }],
6755
+ PostToolUse: [{ matcher: "", hooks: [{ type: "command", command: `"${nodeBin}" "${auditAbs}" claude-code "Claude Code"` }] }],
6756
+ Stop: [{ matcher: "", hooks: [{ type: "command", command: `"${nodeBin}" "${stopAbs}" claude-code "Claude Code"` }] }]
6756
6757
  }
6757
6758
  };
6758
6759
  writeFileSync2(p.settingsPath, JSON.stringify(merged, null, 2) + "\n");
@@ -7047,9 +7048,10 @@ function installHooks(selectedTools = [], wrappedMcpConfig) {
7047
7048
  for (const client of clients) {
7048
7049
  const clientDir = resolve4(client.dir);
7049
7050
  mkdirSync4(clientDir, { recursive: true });
7050
- const guardCmd = `node .solongate/hooks/guard.mjs ${client.agentId} "${client.agentName}"`;
7051
- const auditCmd = `node .solongate/hooks/audit.mjs ${client.agentId} "${client.agentName}"`;
7052
- const stopCmd = `node .solongate/hooks/stop.mjs ${client.agentId} "${client.agentName}"`;
7051
+ const nodeBin = process.execPath.replace(/\\/g, "/");
7052
+ const guardCmd = `"${nodeBin}" .solongate/hooks/guard.mjs ${client.agentId} "${client.agentName}"`;
7053
+ const auditCmd = `"${nodeBin}" .solongate/hooks/audit.mjs ${client.agentId} "${client.agentName}"`;
7054
+ const stopCmd = `"${nodeBin}" .solongate/hooks/stop.mjs ${client.agentId} "${client.agentName}"`;
7053
7055
  if (client.key === "gemini") {
7054
7056
  const result = installGeminiConfig(clientDir, guardCmd, auditCmd, stopCmd, wrappedMcpConfig);
7055
7057
  if (result === "skipped") skippedNames.push(client.name);
@@ -7596,6 +7598,12 @@ async function main2() {
7596
7598
  console.log(" \u2502 Restart Claude Code to apply. \u2502");
7597
7599
  console.log(" \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518");
7598
7600
  console.log("");
7601
+ console.log(` ${c.bold}${c.yellow}! Already-open terminals are NOT protected yet.${c.reset}`);
7602
+ console.log(` ${c.dim} Hooks load only when a session starts, so any terminal`);
7603
+ console.log(` (and the Claude Code running in it) that was already open`);
7604
+ console.log(` won't be caught. Open a NEW terminal and start Claude Code`);
7605
+ console.log(` there for the guard + audit logging to take effect.${c.reset}`);
7606
+ console.log("");
7599
7607
  }
7600
7608
  var sleep2, SPINNER_FRAMES2;
7601
7609
  var init_login = __esm({
package/dist/init.js CHANGED
@@ -209,12 +209,13 @@ async function runGlobalInstall(opts = {}) {
209
209
  const guardAbs = join(p.hooksDir, "guard.mjs").replace(/\\/g, "/");
210
210
  const auditAbs = join(p.hooksDir, "audit.mjs").replace(/\\/g, "/");
211
211
  const stopAbs = join(p.hooksDir, "stop.mjs").replace(/\\/g, "/");
212
+ const nodeBin = process.execPath.replace(/\\/g, "/");
212
213
  const merged = {
213
214
  ...existing,
214
215
  hooks: {
215
- PreToolUse: [{ matcher: "", hooks: [{ type: "command", command: `node "${guardAbs}" claude-code "Claude Code"` }] }],
216
- PostToolUse: [{ matcher: "", hooks: [{ type: "command", command: `node "${auditAbs}" claude-code "Claude Code"` }] }],
217
- Stop: [{ matcher: "", hooks: [{ type: "command", command: `node "${stopAbs}" claude-code "Claude Code"` }] }]
216
+ PreToolUse: [{ matcher: "", hooks: [{ type: "command", command: `"${nodeBin}" "${guardAbs}" claude-code "Claude Code"` }] }],
217
+ PostToolUse: [{ matcher: "", hooks: [{ type: "command", command: `"${nodeBin}" "${auditAbs}" claude-code "Claude Code"` }] }],
218
+ Stop: [{ matcher: "", hooks: [{ type: "command", command: `"${nodeBin}" "${stopAbs}" claude-code "Claude Code"` }] }]
218
219
  }
219
220
  };
220
221
  writeFileSync(p.settingsPath, JSON.stringify(merged, null, 2) + "\n");
@@ -504,9 +505,10 @@ function installHooks(selectedTools = [], wrappedMcpConfig) {
504
505
  for (const client of clients) {
505
506
  const clientDir = resolve2(client.dir);
506
507
  mkdirSync2(clientDir, { recursive: true });
507
- const guardCmd = `node .solongate/hooks/guard.mjs ${client.agentId} "${client.agentName}"`;
508
- const auditCmd = `node .solongate/hooks/audit.mjs ${client.agentId} "${client.agentName}"`;
509
- const stopCmd = `node .solongate/hooks/stop.mjs ${client.agentId} "${client.agentName}"`;
508
+ const nodeBin = process.execPath.replace(/\\/g, "/");
509
+ const guardCmd = `"${nodeBin}" .solongate/hooks/guard.mjs ${client.agentId} "${client.agentName}"`;
510
+ const auditCmd = `"${nodeBin}" .solongate/hooks/audit.mjs ${client.agentId} "${client.agentName}"`;
511
+ const stopCmd = `"${nodeBin}" .solongate/hooks/stop.mjs ${client.agentId} "${client.agentName}"`;
510
512
  if (client.key === "gemini") {
511
513
  const result = installGeminiConfig(clientDir, guardCmd, auditCmd, stopCmd, wrappedMcpConfig);
512
514
  if (result === "skipped") skippedNames.push(client.name);
package/dist/login.js CHANGED
@@ -178,12 +178,13 @@ async function runGlobalInstall(opts = {}) {
178
178
  const guardAbs = join(p.hooksDir, "guard.mjs").replace(/\\/g, "/");
179
179
  const auditAbs = join(p.hooksDir, "audit.mjs").replace(/\\/g, "/");
180
180
  const stopAbs = join(p.hooksDir, "stop.mjs").replace(/\\/g, "/");
181
+ const nodeBin = process.execPath.replace(/\\/g, "/");
181
182
  const merged = {
182
183
  ...existing,
183
184
  hooks: {
184
- PreToolUse: [{ matcher: "", hooks: [{ type: "command", command: `node "${guardAbs}" claude-code "Claude Code"` }] }],
185
- PostToolUse: [{ matcher: "", hooks: [{ type: "command", command: `node "${auditAbs}" claude-code "Claude Code"` }] }],
186
- Stop: [{ matcher: "", hooks: [{ type: "command", command: `node "${stopAbs}" claude-code "Claude Code"` }] }]
185
+ PreToolUse: [{ matcher: "", hooks: [{ type: "command", command: `"${nodeBin}" "${guardAbs}" claude-code "Claude Code"` }] }],
186
+ PostToolUse: [{ matcher: "", hooks: [{ type: "command", command: `"${nodeBin}" "${auditAbs}" claude-code "Claude Code"` }] }],
187
+ Stop: [{ matcher: "", hooks: [{ type: "command", command: `"${nodeBin}" "${stopAbs}" claude-code "Claude Code"` }] }]
187
188
  }
188
189
  };
189
190
  writeFileSync(p.settingsPath, JSON.stringify(merged, null, 2) + "\n");
@@ -320,6 +321,12 @@ async function main() {
320
321
  console.log(" \u2502 Restart Claude Code to apply. \u2502");
321
322
  console.log(" \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518");
322
323
  console.log("");
324
+ console.log(` ${c.bold}${c.yellow}! Already-open terminals are NOT protected yet.${c.reset}`);
325
+ console.log(` ${c.dim} Hooks load only when a session starts, so any terminal`);
326
+ console.log(` (and the Claude Code running in it) that was already open`);
327
+ console.log(` won't be caught. Open a NEW terminal and start Claude Code`);
328
+ console.log(` there for the guard + audit logging to take effect.${c.reset}`);
329
+ console.log("");
323
330
  }
324
331
  main().catch((err) => {
325
332
  console.log(`Fatal: ${err instanceof Error ? err.message : String(err)}`);
@@ -6530,11 +6530,23 @@ var init_src = __esm({
6530
6530
  });
6531
6531
 
6532
6532
  // hooks/guard.mjs
6533
- import { readFileSync, existsSync, statSync, writeFileSync, mkdirSync } from "node:fs";
6533
+ import { readFileSync, existsSync, statSync, writeFileSync, mkdirSync, chmodSync, renameSync } from "node:fs";
6534
6534
  import { resolve, join } from "node:path";
6535
6535
  import { homedir } from "node:os";
6536
6536
  import { gunzipSync } from "node:zlib";
6537
+ import { createHash } from "node:crypto";
6538
+ var HOOK_VERSION = 5;
6537
6539
  var MAX_FILE_READ = 1024 * 1024;
6540
+ function safeReadFileSync(filePath, encoding = "utf-8") {
6541
+ try {
6542
+ const stat = statSync(filePath);
6543
+ if (stat.size > MAX_FILE_READ)
6544
+ return "";
6545
+ return readFileSync(filePath, encoding);
6546
+ } catch {
6547
+ return "";
6548
+ }
6549
+ }
6538
6550
  function loadEnvKey(dir) {
6539
6551
  try {
6540
6552
  const envPath = resolve(dir, ".env");
@@ -6563,6 +6575,17 @@ function loadGlobalCloudConfig() {
6563
6575
  return {};
6564
6576
  }
6565
6577
  }
6578
+ function isRealKey(k) {
6579
+ if (typeof k !== "string")
6580
+ return false;
6581
+ const v = k.trim();
6582
+ if (!/^sg_(live|test)_/.test(v))
6583
+ return false;
6584
+ const body = v.replace(/^sg_(live|test)_/, "");
6585
+ if (/your_key_here|placeholder|example|^x+$/i.test(body))
6586
+ return false;
6587
+ return /^[a-f0-9]{16,}$/i.test(body);
6588
+ }
6566
6589
  function guessPermission(toolName) {
6567
6590
  const name = (toolName || "").toLowerCase();
6568
6591
  if (name.includes("exec") || name.includes("shell") || name.includes("run") || name.includes("eval") || name === "bash")
@@ -6577,8 +6600,46 @@ var hookCwdEarly = process.cwd();
6577
6600
  var dotenv = loadEnvKey(hookCwdEarly);
6578
6601
  var globalCfg = loadGlobalCloudConfig();
6579
6602
  var API_URL = process.env.SOLONGATE_API_URL || dotenv.SOLONGATE_API_URL || globalCfg.apiUrl || "https://api.solongate.com";
6580
- var API_KEY = process.env.SOLONGATE_API_KEY || dotenv.SOLONGATE_API_KEY || globalCfg.apiKey || "";
6603
+ var API_KEY = [process.env.SOLONGATE_API_KEY, dotenv.SOLONGATE_API_KEY, globalCfg.apiKey].find(isRealKey) || "";
6581
6604
  var AUTH_HEADERS = API_KEY ? { "Authorization": "Bearer " + API_KEY, "X-API-Key": API_KEY } : {};
6605
+ async function maybeSelfUpdate() {
6606
+ if (!API_KEY)
6607
+ return;
6608
+ try {
6609
+ const sgDir = resolve(homedir(), ".solongate");
6610
+ const stamp = join(sgDir, ".hook-update-check");
6611
+ const last = parseInt(safeReadFileSync(stamp) || "0", 10);
6612
+ if (Number.isFinite(last) && Date.now() - last < 6 * 3600 * 1e3)
6613
+ return;
6614
+ try {
6615
+ writeFileSync(stamp, String(Date.now()));
6616
+ } catch {
6617
+ }
6618
+ const res = await fetch(API_URL + "/api/v1/hooks/guard", { headers: AUTH_HEADERS, signal: AbortSignal.timeout(5e3) });
6619
+ if (!res.ok)
6620
+ return;
6621
+ const data = await res.json();
6622
+ if (!data || typeof data.version !== "number" || data.version <= HOOK_VERSION)
6623
+ return;
6624
+ if (typeof data.content !== "string" || typeof data.sha256 !== "string")
6625
+ return;
6626
+ const buf = Buffer.from(data.content, "base64");
6627
+ if (createHash("sha256").update(buf).digest("hex") !== data.sha256)
6628
+ return;
6629
+ const text = buf.toString("utf-8");
6630
+ if (!text.startsWith("#!/usr/bin/env node") || text.length < 5e4 || !text.includes("SolonGate Cloud Policy Guard"))
6631
+ return;
6632
+ const hooksDir = join(sgDir, "hooks");
6633
+ const tmp = join(hooksDir, ".guard.mjs.tmp");
6634
+ writeFileSync(tmp, text);
6635
+ try {
6636
+ chmodSync(join(hooksDir, "guard.mjs"), 420);
6637
+ } catch {
6638
+ }
6639
+ renameSync(tmp, join(hooksDir, "guard.mjs"));
6640
+ } catch {
6641
+ }
6642
+ }
6582
6643
  var AGENT_TYPE = process.argv[2] || "claude-code";
6583
6644
  var POLICY_SELECTOR = process.env.SOLONGATE_AGENT_ID || "";
6584
6645
  var AGENT_ID = POLICY_SELECTOR || AGENT_TYPE;
@@ -6757,13 +6818,23 @@ function extractCommands(args) {
6757
6818
  }
6758
6819
  return cmds;
6759
6820
  }
6760
- function extractPaths(args) {
6821
+ function extractPaths(args, isExec) {
6761
6822
  const paths = [];
6823
+ const add = (t) => {
6824
+ if (!t || /^https?:\/\//i.test(t))
6825
+ return;
6826
+ if (t.includes("/") || t.includes("\\") || t.startsWith("."))
6827
+ paths.push(t.replace(/\\/g, "/"));
6828
+ };
6762
6829
  for (const s of scanStrings(args)) {
6763
6830
  if (/^https?:\/\//i.test(s))
6764
6831
  continue;
6765
- if (s.includes("/") || s.includes("\\") || s.startsWith("."))
6766
- paths.push(s.replace(/\\/g, "/"));
6832
+ if (isExec && /\s/.test(s)) {
6833
+ for (const tok of s.split(/[\s;|&><()`'"]+/))
6834
+ add(tok);
6835
+ } else {
6836
+ add(s);
6837
+ }
6767
6838
  }
6768
6839
  return paths;
6769
6840
  }
@@ -7036,7 +7107,7 @@ async function evaluateWithOpa(policy, args, toolName, cwd) {
7036
7107
  permission: guessPermission(toolName),
7037
7108
  trust_level: "TRUSTED",
7038
7109
  arguments: expandedArgs,
7039
- paths: extractPaths(accessArgs),
7110
+ paths: extractPaths(accessArgs, isExecTool),
7040
7111
  commands: extractCommands(expandedArgs),
7041
7112
  urls: extractUrls(accessArgs),
7042
7113
  filenames: extractFilenames(accessArgs)
@@ -7145,7 +7216,7 @@ process.stdin.on("end", async () => {
7145
7216
  let policy;
7146
7217
  const agentKey = (AGENT_ID || "default").replace(/[^a-zA-Z0-9_-]/g, "_");
7147
7218
  const policyCacheFile = join(resolve(homedir(), ".solongate"), ".policy-cache-" + agentKey + ".json");
7148
- const POLICY_TTL_MS = 1e4;
7219
+ const POLICY_TTL_MS = 3e3;
7149
7220
  try {
7150
7221
  let dashboardPolicy = null;
7151
7222
  try {
@@ -7214,8 +7285,12 @@ process.stdin.on("end", async () => {
7214
7285
  } else if (policy && policy.rules) {
7215
7286
  const opaResult = await evaluateWithOpa(policy, args, toolName, hookCwd);
7216
7287
  if (opaResult === void 0) {
7217
- reason = "[SolonGate OPA] Policy WASM unavailable \u2014 failing closed (DENY). Ensure the SolonGate API is reachable so policies compile to WASM.";
7218
- opaRoute = "black";
7288
+ if (policy.mode === "whitelist") {
7289
+ reason = "[SolonGate OPA] Policy WASM unavailable \u2014 failing closed (DENY). Ensure the SolonGate API is reachable so policies compile to WASM.";
7290
+ opaRoute = "black";
7291
+ } else {
7292
+ opaRoute = "white";
7293
+ }
7219
7294
  } else if (typeof opaResult === "string") {
7220
7295
  reason = opaResult;
7221
7296
  opaRoute = "black";
@@ -7249,9 +7324,11 @@ process.stdin.on("end", async () => {
7249
7324
  }
7250
7325
  }
7251
7326
  writeDenyFlag(toolName);
7327
+ await maybeSelfUpdate();
7252
7328
  blockTool(reason);
7253
7329
  }
7254
7330
  } catch {
7255
7331
  }
7332
+ await maybeSelfUpdate();
7256
7333
  allowTool();
7257
7334
  });
package/hooks/guard.mjs CHANGED
@@ -21,10 +21,17 @@
21
21
  * Logs DENY decisions to SolonGate Cloud. ALLOWs are logged by audit.mjs.
22
22
  * Auto-installed by: npx @solongate/proxy init --global
23
23
  */
24
- import { readFileSync, existsSync, statSync, writeFileSync, mkdirSync } from 'node:fs';
24
+ import { readFileSync, existsSync, statSync, writeFileSync, mkdirSync, chmodSync, renameSync } from 'node:fs';
25
25
  import { resolve, join } from 'node:path';
26
26
  import { homedir } from 'node:os';
27
27
  import { gunzipSync } from 'node:zlib';
28
+ import { createHash } from 'node:crypto';
29
+
30
+ // Bump on every guard.mjs change. The cloud serves the newest bundle + version;
31
+ // the installed hook self-updates when the cloud version is higher (see
32
+ // maybeSelfUpdate). This is what makes guard fixes propagate without a manual
33
+ // reinstall — the same trust model as the OPA WASM this hook already runs.
34
+ const HOOK_VERSION = 5;
28
35
 
29
36
  // Safe file read with size limit (1MB max) to prevent DoS via large files
30
37
  const MAX_FILE_READ = 1024 * 1024; // 1MB
@@ -65,6 +72,23 @@ function loadGlobalCloudConfig() {
65
72
  } catch { return {}; }
66
73
  }
67
74
 
75
+ // A real cloud key is `sg_live_`/`sg_test_` followed by hex (see generateApiKey:
76
+ // 24 random bytes → 48 hex chars). Template/placeholder values shipped in sample
77
+ // .env files (e.g. `sg_live_your_key_here`) pass a naive truthiness check but are
78
+ // bogus — and because resolution prefers a project .env over the global login
79
+ // credential, a stray placeholder .env would shadow a valid login and 401 every
80
+ // API call, making the guard fail closed on EVERYTHING. Filter to real keys so a
81
+ // placeholder is skipped and the next real candidate (usually the login cred in
82
+ // cloud-guard.json) is used instead.
83
+ function isRealKey(k) {
84
+ if (typeof k !== 'string') return false;
85
+ const v = k.trim();
86
+ if (!/^sg_(live|test)_/.test(v)) return false;
87
+ const body = v.replace(/^sg_(live|test)_/, '');
88
+ if (/your_key_here|placeholder|example|^x+$/i.test(body)) return false;
89
+ return /^[a-f0-9]{16,}$/i.test(body);
90
+ }
91
+
68
92
  function guessPermission(toolName) {
69
93
  const name = (toolName || '').toLowerCase();
70
94
  if (name.includes('exec') || name.includes('shell') || name.includes('run') || name.includes('eval') || name === 'bash') return 'EXECUTE';
@@ -82,12 +106,46 @@ const API_URL = process.env.SOLONGATE_API_URL || dotenv.SOLONGATE_API_URL || glo
82
106
  // Cloud API key (sg_live_… / sg_test_…). The key identifies the project AND
83
107
  // authenticates every API call (active policy, compiled WASM, audit logs). When
84
108
  // 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 || '';
109
+ // unenforced (the cloud has no policy to apply). Each candidate is filtered
110
+ // through isRealKey() so a placeholder .env (sg_live_your_key_here) can't shadow
111
+ // the real login credential and force a fail-closed on every call.
112
+ const API_KEY = [process.env.SOLONGATE_API_KEY, dotenv.SOLONGATE_API_KEY, globalCfg.apiKey].find(isRealKey) || '';
87
113
  // Auth headers attached to every cloud API request. Cloud accepts either the
88
114
  // Authorization: Bearer form or X-API-Key; we send both for robustness.
89
115
  const AUTH_HEADERS = API_KEY ? { 'Authorization': 'Bearer ' + API_KEY, 'X-API-Key': API_KEY } : {};
90
116
 
117
+ // ── Self-update (best-effort, throttled, integrity-checked) ──
118
+ // Once per ~6h the hook asks the cloud for the latest guard bundle. If the cloud
119
+ // version is higher AND the sha256 verifies AND the payload looks like this guard
120
+ // hook, it atomically replaces its own file. Any failure is swallowed so a bad
121
+ // update can never break enforcement — the current code simply keeps running.
122
+ async function maybeSelfUpdate() {
123
+ if (!API_KEY) return;
124
+ try {
125
+ const sgDir = resolve(homedir(), '.solongate');
126
+ const stamp = join(sgDir, '.hook-update-check');
127
+ const last = parseInt(safeReadFileSync(stamp) || '0', 10);
128
+ if (Number.isFinite(last) && Date.now() - last < 6 * 3600 * 1000) return;
129
+ try { writeFileSync(stamp, String(Date.now())); } catch { /* ignore */ }
130
+
131
+ const res = await fetch(API_URL + '/api/v1/hooks/guard', { headers: AUTH_HEADERS, signal: AbortSignal.timeout(5000) });
132
+ if (!res.ok) return;
133
+ const data = await res.json();
134
+ if (!data || typeof data.version !== 'number' || data.version <= HOOK_VERSION) return;
135
+ if (typeof data.content !== 'string' || typeof data.sha256 !== 'string') return;
136
+ const buf = Buffer.from(data.content, 'base64');
137
+ if (createHash('sha256').update(buf).digest('hex') !== data.sha256) return;
138
+ const text = buf.toString('utf-8');
139
+ // Sanity gate: must look like THIS guard hook before we overwrite ourselves.
140
+ if (!text.startsWith('#!/usr/bin/env node') || text.length < 50000 || !text.includes('SolonGate Cloud Policy Guard')) return;
141
+ const hooksDir = join(sgDir, 'hooks');
142
+ const tmp = join(hooksDir, '.guard.mjs.tmp');
143
+ writeFileSync(tmp, text);
144
+ try { chmodSync(join(hooksDir, 'guard.mjs'), 0o644); } catch { /* may be locked read-only */ }
145
+ renameSync(tmp, join(hooksDir, 'guard.mjs')); // atomic swap, takes effect next call
146
+ } catch { /* never break enforcement on update failure */ }
147
+ }
148
+
91
149
  // Two distinct identities, deliberately kept separate:
92
150
  //
93
151
  // AGENT_TYPE — the real AI client running this hook (claude-code /
@@ -410,14 +468,26 @@ function extractCommands(args) {
410
468
  return cmds;
411
469
  }
412
470
 
413
- function extractPaths(args) {
471
+ function extractPaths(args, isExec) {
414
472
  const paths = [];
415
- for (const s of scanStrings(args)) {
416
- if (/^https?:\/\//i.test(s)) continue;
473
+ const add = (t) => {
474
+ if (!t || /^https?:\/\//i.test(t)) return;
417
475
  // Normalize Windows backslashes to forward slashes so paths match the
418
476
  // compiled Rego patterns (which are also normalized to "/"). OPA glob.match
419
477
  // does no separator translation, so raw "C:\..." never matched "/" patterns.
420
- if (s.includes('/') || s.includes('\\') || s.startsWith('.')) paths.push(s.replace(/\\/g, '/'));
478
+ if (t.includes('/') || t.includes('\\') || t.startsWith('.')) paths.push(t.replace(/\\/g, '/'));
479
+ };
480
+ for (const s of scanStrings(args)) {
481
+ if (/^https?:\/\//i.test(s)) continue;
482
+ if (isExec && /\s/.test(s)) {
483
+ // A command line (exec tool): pull out individual path-like tokens instead
484
+ // of treating the whole command as one path. Otherwise `node src/app.js`
485
+ // becomes the path "node src/app.js", which no path glob can match — so a
486
+ // path-scoped EXECUTE rule would never fire. Tokenizing yields "src/app.js".
487
+ for (const tok of s.split(/[\s;|&><()`'"]+/)) add(tok);
488
+ } else {
489
+ add(s);
490
+ }
421
491
  }
422
492
  return paths;
423
493
  }
@@ -582,7 +652,7 @@ function patternsOf(constraint) {
582
652
  return Array.isArray(list) && list.length > 0 ? list : null;
583
653
  }
584
654
 
585
- function ruleMatches(rule, args) {
655
+ function ruleMatches(rule, args, isExec) {
586
656
  const fnPats = patternsOf(rule.filenameConstraints);
587
657
  if (fnPats) {
588
658
  const filenames = extractFilenames(args);
@@ -612,7 +682,7 @@ function ruleMatches(rule, args) {
612
682
  }
613
683
  const pathPats = patternsOf(rule.pathConstraints);
614
684
  if (pathPats) {
615
- const paths = extractPaths(args);
685
+ const paths = extractPaths(args, isExec);
616
686
  for (const p of paths) {
617
687
  for (const pat of pathPats) {
618
688
  if (matchPathGlob(p, pat)) return { kind: 'path', value: p, pattern: pat };
@@ -630,13 +700,14 @@ function evaluate(policy, args, toolName) {
630
700
  if (!policy || !policy.rules) return null;
631
701
  const enabledRules = policy.rules.filter(r => r.enabled !== false);
632
702
  const mode = policy.mode === 'whitelist' ? 'whitelist' : 'denylist';
703
+ const isExec = /bash|shell|exec|powershell|cmd|run|eval/.test((toolName || '').toLowerCase());
633
704
 
634
705
  // DENY pass — runs in both modes. DENY wins over ALLOW.
635
706
  const denyRules = enabledRules
636
707
  .filter(r => r.effect === 'DENY' && permissionApplies(r, toolName))
637
708
  .sort((a, b) => (a.priority || 100) - (b.priority || 100));
638
709
  for (const rule of denyRules) {
639
- const m = ruleMatches(rule, args);
710
+ const m = ruleMatches(rule, args, isExec);
640
711
  if (m) return 'Blocked by policy: ' + m.kind + ' "' + m.value + '" matches "' + m.pattern + '"';
641
712
  }
642
713
 
@@ -648,7 +719,7 @@ function evaluate(policy, args, toolName) {
648
719
  }
649
720
  let matched = false;
650
721
  for (const rule of allowRules) {
651
- if (ruleMatches(rule, args)) { matched = true; break; }
722
+ if (ruleMatches(rule, args, isExec)) { matched = true; break; }
652
723
  }
653
724
  if (!matched) {
654
725
  return 'Blocked by policy: strict whitelist mode — request does not match any ALLOW rule';
@@ -835,7 +906,7 @@ async function evaluateWithOpa(policy, args, toolName, cwd) {
835
906
  permission: guessPermission(toolName),
836
907
  trust_level: 'TRUSTED',
837
908
  arguments: expandedArgs,
838
- paths: extractPaths(accessArgs),
909
+ paths: extractPaths(accessArgs, isExecTool),
839
910
  commands: extractCommands(expandedArgs),
840
911
  urls: extractUrls(accessArgs),
841
912
  filenames: extractFilenames(accessArgs),
@@ -988,7 +1059,7 @@ process.stdin.on('end', async () => {
988
1059
  // don't share a stale cached policy.
989
1060
  const agentKey = (AGENT_ID || 'default').replace(/[^a-zA-Z0-9_-]/g, '_');
990
1061
  const policyCacheFile = join(resolve(homedir(), '.solongate'), '.policy-cache-' + agentKey + '.json');
991
- const POLICY_TTL_MS = 10_000;
1062
+ const POLICY_TTL_MS = 3_000;
992
1063
  try {
993
1064
  let dashboardPolicy = null;
994
1065
  // Try cache first
@@ -1057,9 +1128,10 @@ process.stdin.on('end', async () => {
1057
1128
  // OPA WASM is the SOLE policy engine. With no policy configured for this
1058
1129
  // agent we skip evaluation entirely (allow). With a policy present,
1059
1130
  // evaluateWithOpa returns a reason (DENY), null (ALLOW), or undefined when
1060
- // the WASM bundle could not be obtained at all — in which case we fail
1061
- // CLOSED rather than silently allowing. (The legacy JS evaluate() below is
1062
- // retained but no longer on the decision path OPA decides everything.)
1131
+ // the WASM bundle could not be obtained at all — in which case we fall back
1132
+ // to the policy mode's default (whitelist fail closed, denylist → fail
1133
+ // open); see the branch below. (The legacy JS evaluate() below is retained
1134
+ // but no longer on the decision path — OPA decides everything.)
1063
1135
  // Cloud routing is BINARY — WHITE (allow) / BLACK (block). There is NO AI
1064
1136
  // Judge in the cloud (that is an air-gap-only feature), so there is no GRAY
1065
1137
  // "send to the judge" lane: the OPA policy alone decides. Tamper protection
@@ -1071,8 +1143,21 @@ process.stdin.on('end', async () => {
1071
1143
  } else if (policy && policy.rules) {
1072
1144
  const opaResult = await evaluateWithOpa(policy, args, toolName, hookCwd);
1073
1145
  if (opaResult === undefined) {
1074
- reason = '[SolonGate OPA] Policy WASM unavailable — failing closed (DENY). Ensure the SolonGate API is reachable so policies compile to WASM.';
1075
- opaRoute = 'black';
1146
+ // OPA produced no decision (no WASM bundle, runtime missing, fetch error).
1147
+ // Honor policy-mode defaults instead of a blanket fail-closed:
1148
+ // whitelist (default-deny): fail CLOSED — nothing is allowed without an
1149
+ // explicit ALLOW match, so an unavailable engine must block.
1150
+ // denylist (default-allow): fail OPEN — the engine being down means no
1151
+ // DENY rule could match, and denylist's default IS allow. Blocking
1152
+ // everything here is whitelist behavior and wrong for a denylist policy
1153
+ // (it bricks every tool call during any transient WASM hiccup).
1154
+ // Tamper protection already ran above and is unaffected either way.
1155
+ if (policy.mode === 'whitelist') {
1156
+ reason = '[SolonGate OPA] Policy WASM unavailable — failing closed (DENY). Ensure the SolonGate API is reachable so policies compile to WASM.';
1157
+ opaRoute = 'black';
1158
+ } else {
1159
+ opaRoute = 'white';
1160
+ }
1076
1161
  } else if (typeof opaResult === 'string') {
1077
1162
  reason = opaResult; // explicit DENY
1078
1163
  opaRoute = 'black';
@@ -1106,8 +1191,10 @@ process.stdin.on('end', async () => {
1106
1191
  } catch {}
1107
1192
  }
1108
1193
  writeDenyFlag(toolName);
1194
+ await maybeSelfUpdate();
1109
1195
  blockTool(reason);
1110
1196
  }
1111
1197
  } catch {}
1198
+ await maybeSelfUpdate();
1112
1199
  allowTool();
1113
1200
  });
package/package.json CHANGED
@@ -1,76 +1,76 @@
1
- {
2
- "name": "@solongate/proxy",
3
- "version": "0.47.0",
4
- "description": "AI tool security proxy — protect any AI tool server with customizable policies, path/command constraints, rate limiting, and audit logging. Zero code changes required.",
5
- "type": "module",
6
- "bin": {
7
- "solongate": "./dist/index.js",
8
- "solongate-proxy": "./dist/index.js",
9
- "solongate-init": "./dist/init.js",
10
- "proxy": "./dist/index.js"
11
- },
12
- "main": "./dist/lib.js",
13
- "exports": {
14
- ".": {
15
- "import": "./dist/lib.js"
16
- },
17
- "./cli": {
18
- "import": "./dist/index.js"
19
- }
20
- },
21
- "files": [
22
- "dist",
23
- "hooks",
24
- "README.md"
25
- ],
26
- "scripts": {
27
- "build": "tsup && pnpm build:hooks",
28
- "build:hooks": "node scripts/bundle-hooks.mjs",
29
- "dev": "tsx src/index.ts",
30
- "typecheck": "tsc --noEmit",
31
- "clean": "rm -rf dist .turbo"
32
- },
33
- "keywords": [
34
- "ai-tool-security",
35
- "ai-tool-proxy",
36
- "security",
37
- "proxy",
38
- "gateway",
39
- "firewall",
40
- "ai-security",
41
- "tool-security",
42
- "claude",
43
- "openclaw",
44
- "solongate",
45
- "prompt-injection",
46
- "path-traversal",
47
- "rate-limiting"
48
- ],
49
- "author": "SolonGate <hello@solongate.com>",
50
- "license": "MIT",
51
- "homepage": "https://solongate.com",
52
- "repository": {
53
- "type": "git",
54
- "url": "https://github.com/solongate/solongate"
55
- },
56
- "engines": {
57
- "node": ">=18.0.0"
58
- },
59
- "dependencies": {
60
- "@modelcontextprotocol/sdk": "^1.26.0",
61
- "zod": "^3.25.0"
62
- },
63
- "optionalDependencies": {
64
- "@huggingface/transformers": ">=3.0.0"
65
- },
66
- "devDependencies": {
67
- "@open-policy-agent/opa-wasm": "^1.10.0",
68
- "@solongate/core": "workspace:*",
69
- "@solongate/policy-engine": "workspace:*",
70
- "@solongate/tsconfig": "workspace:*",
71
- "esbuild": "^0.19.12",
72
- "tsup": "^8.3.0",
73
- "tsx": "^4.19.0",
74
- "typescript": "^5.7.0"
75
- }
76
- }
1
+ {
2
+ "name": "@solongate/proxy",
3
+ "version": "0.47.1",
4
+ "description": "AI tool security proxy — protect any AI tool server with customizable policies, path/command constraints, rate limiting, and audit logging. Zero code changes required.",
5
+ "type": "module",
6
+ "bin": {
7
+ "solongate": "./dist/index.js",
8
+ "solongate-proxy": "./dist/index.js",
9
+ "solongate-init": "./dist/init.js",
10
+ "proxy": "./dist/index.js"
11
+ },
12
+ "main": "./dist/lib.js",
13
+ "exports": {
14
+ ".": {
15
+ "import": "./dist/lib.js"
16
+ },
17
+ "./cli": {
18
+ "import": "./dist/index.js"
19
+ }
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "hooks",
24
+ "README.md"
25
+ ],
26
+ "scripts": {
27
+ "build": "tsup && pnpm build:hooks",
28
+ "build:hooks": "node scripts/bundle-hooks.mjs",
29
+ "dev": "tsx src/index.ts",
30
+ "typecheck": "tsc --noEmit",
31
+ "clean": "rm -rf dist .turbo"
32
+ },
33
+ "keywords": [
34
+ "ai-tool-security",
35
+ "ai-tool-proxy",
36
+ "security",
37
+ "proxy",
38
+ "gateway",
39
+ "firewall",
40
+ "ai-security",
41
+ "tool-security",
42
+ "claude",
43
+ "openclaw",
44
+ "solongate",
45
+ "prompt-injection",
46
+ "path-traversal",
47
+ "rate-limiting"
48
+ ],
49
+ "author": "SolonGate <hello@solongate.com>",
50
+ "license": "MIT",
51
+ "homepage": "https://solongate.com",
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "https://github.com/solongate/solongate"
55
+ },
56
+ "engines": {
57
+ "node": ">=18.0.0"
58
+ },
59
+ "dependencies": {
60
+ "@modelcontextprotocol/sdk": "^1.26.0",
61
+ "zod": "^3.25.0"
62
+ },
63
+ "optionalDependencies": {
64
+ "@huggingface/transformers": ">=3.0.0"
65
+ },
66
+ "devDependencies": {
67
+ "@open-policy-agent/opa-wasm": "^1.10.0",
68
+ "@solongate/core": "workspace:*",
69
+ "@solongate/policy-engine": "workspace:*",
70
+ "@solongate/tsconfig": "workspace:*",
71
+ "esbuild": "^0.19.12",
72
+ "tsup": "^8.3.0",
73
+ "tsx": "^4.19.0",
74
+ "typescript": "^5.7.0"
75
+ }
76
+ }