@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.
- package/dist/global-install.js +4 -3
- package/dist/index.js +14 -6
- package/dist/init.js +8 -6
- package/dist/login.js +10 -3
- package/hooks/guard.bundled.mjs +86 -9
- package/hooks/guard.mjs +105 -18
- package/package.json +76 -76
package/dist/global-install.js
CHANGED
|
@@ -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: `
|
|
177
|
-
PostToolUse: [{ matcher: "", hooks: [{ type: "command", command: `
|
|
178
|
-
Stop: [{ matcher: "", hooks: [{ type: "command", command: `
|
|
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: `
|
|
6754
|
-
PostToolUse: [{ matcher: "", hooks: [{ type: "command", command: `
|
|
6755
|
-
Stop: [{ matcher: "", hooks: [{ type: "command", command: `
|
|
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
|
|
7051
|
-
const
|
|
7052
|
-
const
|
|
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: `
|
|
216
|
-
PostToolUse: [{ matcher: "", hooks: [{ type: "command", command: `
|
|
217
|
-
Stop: [{ matcher: "", hooks: [{ type: "command", command: `
|
|
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
|
|
508
|
-
const
|
|
509
|
-
const
|
|
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: `
|
|
185
|
-
PostToolUse: [{ matcher: "", hooks: [{ type: "command", command: `
|
|
186
|
-
Stop: [{ matcher: "", hooks: [{ type: "command", command: `
|
|
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)}`);
|
package/hooks/guard.bundled.mjs
CHANGED
|
@@ -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
|
|
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 (
|
|
6766
|
-
|
|
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 =
|
|
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
|
-
|
|
7218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
416
|
-
if (/^https?:\/\//i.test(
|
|
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 (
|
|
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 =
|
|
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
|
|
1061
|
-
//
|
|
1062
|
-
//
|
|
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
|
-
|
|
1075
|
-
|
|
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.
|
|
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
|
+
}
|