@solongate/proxy 0.55.0 → 0.57.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/README.md +2 -6
- package/dist/index.js +26 -4
- package/dist/shield.js +26 -4
- package/hooks/.solongate/.last-deny +1 -0
- package/hooks/.solongate/.last-tool-call +1 -0
- package/hooks/audit.mjs +202 -46
- package/hooks/guard.bundled.mjs +111 -49
- package/hooks/guard.mjs +97 -24
- package/hooks/shield.mjs +127 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,14 +16,11 @@ AI agent ──(tool call)──> SolonGate guard ──> Tool runs
|
|
|
16
16
|
[allow / block / audit]
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
SolonGate
|
|
20
|
-
|
|
21
|
-
- **Cloud** — managed, nothing to host. Pair your machine with one command and manage policies + audit logs at [dashboard.solongate.com](https://dashboard.solongate.com).
|
|
22
|
-
- **Local / air-gapped** — run the whole stack on your own hardware with Docker, zero outbound connectivity, no API keys.
|
|
19
|
+
SolonGate is fully managed — nothing to host. Pair your machine with one command and manage policies + audit logs at [dashboard.solongate.com](https://dashboard.solongate.com).
|
|
23
20
|
|
|
24
21
|
---
|
|
25
22
|
|
|
26
|
-
## Quick start
|
|
23
|
+
## Quick start
|
|
27
24
|
|
|
28
25
|
**You need:** a free [SolonGate account](https://auth.solongate.com), Node.js 18+ on the machine you want to protect, and an AI tool that makes tool calls (Claude Code; Gemini CLI is also supported).
|
|
29
26
|
|
|
@@ -65,7 +62,6 @@ AI agents get direct access to your system — shell, file system, databases, ne
|
|
|
65
62
|
|
|
66
63
|
- **Docs:** [solongate.com/docs](https://solongate.com/docs)
|
|
67
64
|
- **Dashboard:** [dashboard.solongate.com](https://dashboard.solongate.com)
|
|
68
|
-
- **Air-gapped guide:** [solongate.com/docs/local](https://solongate.com/docs/local)
|
|
69
65
|
|
|
70
66
|
## License
|
|
71
67
|
|
package/dist/index.js
CHANGED
|
@@ -7017,14 +7017,36 @@ import { createServer, request as httpRequest } from "http";
|
|
|
7017
7017
|
import { request as httpsRequest } from "https";
|
|
7018
7018
|
import { spawn as spawn2 } from "child_process";
|
|
7019
7019
|
import { URL as URL2 } from "url";
|
|
7020
|
-
import { readFileSync as readFileSync6, existsSync as existsSync5 } from "fs";
|
|
7020
|
+
import { readFileSync as readFileSync6, existsSync as existsSync5, readdirSync, statSync } from "fs";
|
|
7021
7021
|
import { resolve as resolve4 } from "path";
|
|
7022
7022
|
import { homedir as homedir3 } from "os";
|
|
7023
|
+
function findCacheFile() {
|
|
7024
|
+
const dir = resolve4(homedir3(), ".solongate");
|
|
7025
|
+
const envSel = process.env.SOLONGATE_AGENT_ID;
|
|
7026
|
+
if (envSel) {
|
|
7027
|
+
const f = resolve4(dir, ".policy-cache-" + envSel.replace(/[^a-zA-Z0-9_-]/g, "_") + ".json");
|
|
7028
|
+
if (existsSync5(f)) return f;
|
|
7029
|
+
}
|
|
7030
|
+
let best = null, bestTs = -1;
|
|
7031
|
+
try {
|
|
7032
|
+
for (const name of readdirSync(dir)) {
|
|
7033
|
+
if (name.startsWith(".policy-cache-") && name.endsWith(".json")) {
|
|
7034
|
+
const full = resolve4(dir, name);
|
|
7035
|
+
const ts = statSync(full).mtimeMs;
|
|
7036
|
+
if (ts > bestTs) {
|
|
7037
|
+
bestTs = ts;
|
|
7038
|
+
best = full;
|
|
7039
|
+
}
|
|
7040
|
+
}
|
|
7041
|
+
}
|
|
7042
|
+
} catch {
|
|
7043
|
+
}
|
|
7044
|
+
return best;
|
|
7045
|
+
}
|
|
7023
7046
|
function loadCfg() {
|
|
7024
7047
|
try {
|
|
7025
|
-
const
|
|
7026
|
-
|
|
7027
|
-
if (existsSync5(f)) {
|
|
7048
|
+
const f = findCacheFile();
|
|
7049
|
+
if (f && existsSync5(f)) {
|
|
7028
7050
|
const c3 = JSON.parse(readFileSync6(f, "utf-8"));
|
|
7029
7051
|
const d = c3?.security?.dlpRedact;
|
|
7030
7052
|
if (d && Array.isArray(d.patterns)) return { patterns: d.patterns, custom: Array.isArray(d.custom) ? d.custom : [] };
|
package/dist/shield.js
CHANGED
|
@@ -3,7 +3,7 @@ import { createServer, request as httpRequest } from "http";
|
|
|
3
3
|
import { request as httpsRequest } from "https";
|
|
4
4
|
import { spawn } from "child_process";
|
|
5
5
|
import { URL } from "url";
|
|
6
|
-
import { readFileSync, existsSync } from "fs";
|
|
6
|
+
import { readFileSync, existsSync, readdirSync, statSync } from "fs";
|
|
7
7
|
import { resolve } from "path";
|
|
8
8
|
import { homedir } from "os";
|
|
9
9
|
var log = (...a) => process.stderr.write(`[SolonGate shield] ${a.map(String).join(" ")}
|
|
@@ -26,11 +26,33 @@ var DLP_PATTERNS = [
|
|
|
26
26
|
{ name: "Bearer token", re: /bearer\s+[A-Za-z0-9._-]{20,}/gi },
|
|
27
27
|
{ name: "secret assignment", re: /(api[_-]?key|secret|token|password|passwd|access[_-]?key)["']?\s*[:=]\s*["']?[A-Za-z0-9/+_.-]{12,}/gi }
|
|
28
28
|
];
|
|
29
|
+
function findCacheFile() {
|
|
30
|
+
const dir = resolve(homedir(), ".solongate");
|
|
31
|
+
const envSel = process.env.SOLONGATE_AGENT_ID;
|
|
32
|
+
if (envSel) {
|
|
33
|
+
const f = resolve(dir, ".policy-cache-" + envSel.replace(/[^a-zA-Z0-9_-]/g, "_") + ".json");
|
|
34
|
+
if (existsSync(f)) return f;
|
|
35
|
+
}
|
|
36
|
+
let best = null, bestTs = -1;
|
|
37
|
+
try {
|
|
38
|
+
for (const name of readdirSync(dir)) {
|
|
39
|
+
if (name.startsWith(".policy-cache-") && name.endsWith(".json")) {
|
|
40
|
+
const full = resolve(dir, name);
|
|
41
|
+
const ts = statSync(full).mtimeMs;
|
|
42
|
+
if (ts > bestTs) {
|
|
43
|
+
bestTs = ts;
|
|
44
|
+
best = full;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
}
|
|
50
|
+
return best;
|
|
51
|
+
}
|
|
29
52
|
function loadCfg() {
|
|
30
53
|
try {
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
if (existsSync(f)) {
|
|
54
|
+
const f = findCacheFile();
|
|
55
|
+
if (f && existsSync(f)) {
|
|
34
56
|
const c = JSON.parse(readFileSync(f, "utf-8"));
|
|
35
57
|
const d = c?.security?.dlpRedact;
|
|
36
58
|
if (d && Array.isArray(d.patterns)) return { patterns: d.patterns, custom: Array.isArray(d.custom) ? d.custom : [] };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"tool":"Bash","ts":1782827530142}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
1782827550450
|
package/hooks/audit.mjs
CHANGED
|
@@ -4,10 +4,15 @@
|
|
|
4
4
|
* Logs tool execution results to SolonGate Cloud.
|
|
5
5
|
* Auto-installed by: npx @solongate/proxy login
|
|
6
6
|
*/
|
|
7
|
-
import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
7
|
+
import { readFileSync, existsSync, writeFileSync, mkdirSync, appendFileSync } from 'node:fs';
|
|
8
8
|
import { resolve, join } from 'node:path';
|
|
9
9
|
import { homedir } from 'node:os';
|
|
10
10
|
|
|
11
|
+
// Bump on every audit.mjs change. The cloud serves the newest version; the guard
|
|
12
|
+
// hook installs it on its next run (no re-login needed). See guard.mjs
|
|
13
|
+
// fetchAndInstallHook / maybeSelfUpdate.
|
|
14
|
+
const HOOK_VERSION = 14;
|
|
15
|
+
|
|
11
16
|
function loadEnvKey(dir) {
|
|
12
17
|
try {
|
|
13
18
|
const envPath = resolve(dir, '.env');
|
|
@@ -34,6 +39,32 @@ function loadGlobalCloudConfig() {
|
|
|
34
39
|
} catch { return {}; }
|
|
35
40
|
}
|
|
36
41
|
|
|
42
|
+
// The guard (PreToolUse) measures the policy-eval time and drops it in a flag
|
|
43
|
+
// file; this hook logs the ALLOW path but can't time the guard itself, so it
|
|
44
|
+
// reads that value back. The flag carries the tool (and session) it was measured
|
|
45
|
+
// for, so a long-running tool (a 10-minute Bash build) still gets ITS eval time
|
|
46
|
+
// instead of a stale-TTL zero. Returns null when no matching measurement exists —
|
|
47
|
+
// an unknown eval time must be stored as null, not a fake 0 that drags averages.
|
|
48
|
+
function readLastEvalMs(toolName, sessionId) {
|
|
49
|
+
try {
|
|
50
|
+
const p = resolve('.solongate', '.last-eval');
|
|
51
|
+
if (!existsSync(p)) return null;
|
|
52
|
+
const c = JSON.parse(readFileSync(p, 'utf-8'));
|
|
53
|
+
if (!c || typeof c.ms !== 'number' || typeof c.ts !== 'number') return null;
|
|
54
|
+
if (typeof c.tool === 'string') {
|
|
55
|
+
// New flag format: match this invocation (tool + session when both known);
|
|
56
|
+
// 2h ceiling only guards against a truly abandoned flag file.
|
|
57
|
+
if (c.tool !== toolName) return null;
|
|
58
|
+
if (c.session && sessionId && c.session !== sessionId) return null;
|
|
59
|
+
if (Date.now() - c.ts > 2 * 3600 * 1000) return null;
|
|
60
|
+
return Math.max(0, Math.round(c.ms));
|
|
61
|
+
}
|
|
62
|
+
// Legacy flag (no tool info): keep the old conservative 30s freshness rule.
|
|
63
|
+
if (Date.now() - c.ts < 30000) return Math.max(0, Math.round(c.ms));
|
|
64
|
+
return null;
|
|
65
|
+
} catch { return null; }
|
|
66
|
+
}
|
|
67
|
+
|
|
37
68
|
// ── Ghost paths (PostToolUse twin of guard.mjs) ──
|
|
38
69
|
// The guard's PreToolUse hook blocks MUTATIONS to hidden paths; here we make
|
|
39
70
|
// hidden paths invisible to READS and LISTINGS by rewriting the tool output the
|
|
@@ -146,14 +177,32 @@ function buildGhostOutput(toolName, toolInput, toolResponse, toolOutput, pats) {
|
|
|
146
177
|
for (const raw of cmd.split(/\s+/)) {
|
|
147
178
|
const t = ghostCleanToken(raw);
|
|
148
179
|
// A command that names the hidden path directly (cat A/Y/.data, ls A/Y)
|
|
149
|
-
// → not-found, regardless of what the command actually returned.
|
|
150
|
-
|
|
180
|
+
// → not-found, regardless of what the command actually returned. Skip
|
|
181
|
+
// flags and any token carrying regex/shell metacharacters: the guard's
|
|
182
|
+
// PreToolUse listing rewrite injects a ghost REGEX (e.g.
|
|
183
|
+
// ")([^/]*ghosttest[^/]*)(/|$)") into the command, and tokenizing that
|
|
184
|
+
// would falsely "match" and emit a bogus not-found string as the result.
|
|
185
|
+
if (!t || t[0] === '-' || /[()|[\]^$*?]/.test(t)) continue;
|
|
186
|
+
if (ghostMatch(t, pats)) return t + ': No such file or directory';
|
|
151
187
|
}
|
|
152
188
|
const stripped = ghostStripLines(text, pats);
|
|
153
189
|
return stripped === text ? null : stripped;
|
|
154
190
|
}
|
|
155
|
-
//
|
|
156
|
-
|
|
191
|
+
// MCP filesystem direct reads → make a hidden file look absent, mirroring
|
|
192
|
+
// the native Read branch above (these never reach the Bash/LS branches).
|
|
193
|
+
if (name === 'mcp__filesystem__read_file' || name === 'mcp__filesystem__read_text_file' ||
|
|
194
|
+
name === 'mcp__filesystem__read_media_file' || name === 'mcp__filesystem__get_file_info') {
|
|
195
|
+
const p = toolInput && (toolInput.path || toolInput.file_path);
|
|
196
|
+
if (p && ghostMatch(p, pats)) return String(p) + ': No such file or directory';
|
|
197
|
+
}
|
|
198
|
+
// Every OTHER tool that returns listing-like text → strip hidden entries.
|
|
199
|
+
// Native Glob/Grep/LS plus any non-shell lister (MCP filesystem
|
|
200
|
+
// list_directory / directory_tree / search_files, and future tools). Bash
|
|
201
|
+
// listings are already covered above (and via the guard's PreToolUse command
|
|
202
|
+
// rewrite); this catch-all is the post-hoc safety net so a ghost entry can't
|
|
203
|
+
// survive in ANY listing shape — previously only the hard-coded
|
|
204
|
+
// Glob/Grep/LS names were stripped, so an MCP/unknown lister leaked it.
|
|
205
|
+
{
|
|
157
206
|
const text = getText();
|
|
158
207
|
if (text == null) return null;
|
|
159
208
|
const stripped = ghostStripLines(text, pats);
|
|
@@ -180,14 +229,12 @@ const DLP_PATTERNS = [
|
|
|
180
229
|
{ name: 'GitHub fine-grained PAT', re: /github_pat_[A-Za-z0-9_]{20,}/g },
|
|
181
230
|
{ name: 'GitLab token', re: /glpat-[A-Za-z0-9_-]{20,}/g },
|
|
182
231
|
{ name: 'Slack token', re: /xox[baprs]-[A-Za-z0-9-]{10,}/g },
|
|
183
|
-
{ name: 'Google API key', re: /AIza[0-9A-Za-z_-]{35}/g },
|
|
184
232
|
{ name: 'Stripe key', re: /[sr]k_(live|test)_[A-Za-z0-9]{20,}/g },
|
|
185
233
|
{ name: 'SendGrid key', re: /SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/g },
|
|
186
234
|
{ name: 'Twilio key', re: /SK[0-9a-fA-F]{32}/g },
|
|
187
235
|
{ name: 'npm token', re: /npm_[A-Za-z0-9]{36}/g },
|
|
188
236
|
{ name: 'JWT', re: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g },
|
|
189
237
|
{ name: 'Bearer token', re: /bearer\s+[A-Za-z0-9._-]{20,}/gi },
|
|
190
|
-
{ name: 'secret assignment', re: /(api[_-]?key|secret|token|password|passwd|access[_-]?key)["']?\s*[:=]\s*["']?[A-Za-z0-9/+_.-]{12,}/gi },
|
|
191
238
|
];
|
|
192
239
|
|
|
193
240
|
// Read the redaction config the guard cached on the matching PreToolUse call.
|
|
@@ -202,6 +249,33 @@ function loadDlpRedact() {
|
|
|
202
249
|
} catch { return null; }
|
|
203
250
|
}
|
|
204
251
|
|
|
252
|
+
// Local log storage: the user can opt to keep a full copy of every audit entry
|
|
253
|
+
// in a file of their choosing (set from the dashboard survey / Settings, then
|
|
254
|
+
// delivered to us via the same policy cache the guard writes). We append one
|
|
255
|
+
// JSON object per line (JSONL) to that path. Fully local, best-effort, and
|
|
256
|
+
// never blocks the tool call or the cloud audit POST.
|
|
257
|
+
function loadLocalLogs() {
|
|
258
|
+
try {
|
|
259
|
+
const sel = (process.env.SOLONGATE_AGENT_ID || process.argv[2] || 'default').replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
260
|
+
const f = resolve(homedir(), '.solongate', '.policy-cache-' + sel + '.json');
|
|
261
|
+
if (!existsSync(f)) return null;
|
|
262
|
+
const c = JSON.parse(readFileSync(f, 'utf-8'));
|
|
263
|
+
const l = c && c.security && c.security.localLogs;
|
|
264
|
+
if (l && l.enabled && typeof l.path === 'string' && l.path.trim()) return { path: l.path.trim() };
|
|
265
|
+
return null;
|
|
266
|
+
} catch { return null; }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// The path is a FOLDER; we write solongate-audit.jsonl inside it (creating the
|
|
270
|
+
// folder if missing), then append one JSON line.
|
|
271
|
+
function appendLocalLog(cfg, entry) {
|
|
272
|
+
try {
|
|
273
|
+
const dir = cfg.path.replace(/[\\/]+$/, '');
|
|
274
|
+
try { mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
|
|
275
|
+
appendFileSync(join(dir, 'solongate-audit.jsonl'), JSON.stringify(entry) + '\n');
|
|
276
|
+
} catch { /* best-effort: never disturb the tool call */ }
|
|
277
|
+
}
|
|
278
|
+
|
|
205
279
|
// Replace every secret match with a labelled placeholder. cfg = { patterns:
|
|
206
280
|
// string[] (enabled built-in names), custom: {name,re}[] }.
|
|
207
281
|
// Custom patterns are GLOBs: `*` = any run of non-whitespace (so a fragment
|
|
@@ -242,6 +316,16 @@ function extractOutputText(toolResponse, toolOutput) {
|
|
|
242
316
|
const t = r.content.filter((x) => x && x.type === 'text' && typeof x.text === 'string').map((x) => x.text).join('\n');
|
|
243
317
|
if (t) return t;
|
|
244
318
|
}
|
|
319
|
+
// Native Glob delivers its hits as a `filenames` string[] with NO stdout/
|
|
320
|
+
// content field, so getText() returned null and the ghost/DLP strip silently
|
|
321
|
+
// skipped it — a ghosted file then survived in the Glob listing the model saw
|
|
322
|
+
// (the exact leak: a `*ghosttest*` path showed up in a Glob result). Join to
|
|
323
|
+
// one-path-per-line so ghostStripLines/dlpRedactText can act on it; the
|
|
324
|
+
// PostToolUse updatedToolOutput then replaces the model-visible list.
|
|
325
|
+
if (Array.isArray(r.filenames)) {
|
|
326
|
+
const t = r.filenames.filter((x) => typeof x === 'string').join('\n');
|
|
327
|
+
if (t) return t;
|
|
328
|
+
}
|
|
245
329
|
}
|
|
246
330
|
if (typeof toolOutput === 'string' && toolOutput) return toolOutput;
|
|
247
331
|
return null;
|
|
@@ -272,6 +356,7 @@ process.stdin.on('data', c => input += c);
|
|
|
272
356
|
process.stdin.on('end', async () => {
|
|
273
357
|
try {
|
|
274
358
|
const data = JSON.parse(input);
|
|
359
|
+
let EMITTED_PAYLOAD = null;
|
|
275
360
|
|
|
276
361
|
// Debug: append raw stdin to file for agent detection troubleshooting.
|
|
277
362
|
// Opt-in (SOLONGATE_DEBUG) so a global hook doesn't litter every cwd.
|
|
@@ -313,26 +398,61 @@ process.stdin.on('end', async () => {
|
|
|
313
398
|
// stripped from listings and direct reads look like "no such file". Emit the
|
|
314
399
|
// updated output BEFORE the (fire-and-forget) audit log. Fail-open: any
|
|
315
400
|
// error leaves the original output untouched.
|
|
401
|
+
let ghostFired = false;
|
|
316
402
|
try {
|
|
317
|
-
const
|
|
318
|
-
// Layer DLP redaction on top: mask any secret VALUES in the output the
|
|
319
|
-
// model would see. If ghost already rewrote the output, redact that;
|
|
320
|
-
// otherwise redact the raw output. Only emit when something changed.
|
|
403
|
+
const ghostPats = loadGhostPatterns();
|
|
321
404
|
const dlpCfg = loadDlpRedact();
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
405
|
+
const redactName = (s) => (dlpCfg && typeof s === 'string') ? dlpRedactText(s, dlpCfg) : s;
|
|
406
|
+
|
|
407
|
+
// Glob (and Grep in files mode) deliver a STRUCTURED result:
|
|
408
|
+
// { filenames: string[], numFiles, truncated, totalMatches, ... }
|
|
409
|
+
// Claude Code REJECTS a plain-string updatedToolOutput for such a tool —
|
|
410
|
+
// it prints "PostToolUse:Glob hook warning" and keeps the ORIGINAL result,
|
|
411
|
+
// so the ghost entry leaks into the listing the model sees. The fix is to
|
|
412
|
+
// return the SAME shape: the filenames array with ghost entries dropped
|
|
413
|
+
// (and secret-looking names masked), preserving every other field.
|
|
414
|
+
if (toolResponse && typeof toolResponse === 'object' && Array.isArray(toolResponse.filenames)) {
|
|
415
|
+
const orig = toolResponse.filenames;
|
|
416
|
+
const kept = orig.filter((f) => !(ghostPats.length && ghostMatch(String(f), ghostPats)));
|
|
417
|
+
ghostFired = kept.length !== orig.length;
|
|
418
|
+
const masked = kept.map((f) => redactName(String(f)));
|
|
419
|
+
const changed = ghostFired || masked.some((f, i) => f !== String(kept[i]));
|
|
420
|
+
if (changed) {
|
|
421
|
+
const updated = { ...toolResponse, filenames: masked, numFiles: masked.length };
|
|
422
|
+
if (typeof toolResponse.totalMatches === 'number') updated.totalMatches = masked.length;
|
|
423
|
+
EMITTED_PAYLOAD = JSON.stringify({
|
|
424
|
+
hookSpecificOutput: { hookEventName: 'PostToolUse', updatedToolOutput: updated },
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
} else {
|
|
428
|
+
// String-output tools (Bash, Read, text Grep, MCP listers): strip ghost
|
|
429
|
+
// lines and redact secrets in the TEXT, return a STRING.
|
|
430
|
+
const ghostText = buildGhostOutput(toolName, toolInput, toolResponse, toolOutput, ghostPats);
|
|
431
|
+
ghostFired = typeof ghostText === 'string';
|
|
432
|
+
let out = typeof ghostText === 'string' ? ghostText : null;
|
|
433
|
+
if (dlpCfg) {
|
|
434
|
+
const base = typeof ghostText === 'string' ? ghostText : extractOutputText(toolResponse, toolOutput);
|
|
435
|
+
if (typeof base === 'string') {
|
|
436
|
+
const redacted = dlpRedactText(base, dlpCfg);
|
|
437
|
+
if (redacted !== base || typeof ghostText === 'string') out = redacted;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if (typeof out === 'string') {
|
|
441
|
+
// Preserve the tool's result SHAPE. Bash and other tools deliver a
|
|
442
|
+
// STRUCTURED result ({ stdout, stderr, ... } or { content }); Claude
|
|
443
|
+
// Code rejects a bare-string replacement for those (hook warning) and
|
|
444
|
+
// keeps the original. Clone the object and swap its text field; only a
|
|
445
|
+
// genuinely string-typed result is replaced with a string.
|
|
446
|
+
const tr = toolResponse;
|
|
447
|
+
let updated;
|
|
448
|
+
if (tr && typeof tr === 'object' && typeof tr.stdout === 'string') updated = { ...tr, stdout: out };
|
|
449
|
+
else if (tr && typeof tr === 'object' && typeof tr.content === 'string') updated = { ...tr, content: out };
|
|
450
|
+
else if (tr && typeof tr === 'object' && Array.isArray(tr.content)) updated = { ...tr, content: [{ type: 'text', text: out }] };
|
|
451
|
+
else updated = out;
|
|
452
|
+
EMITTED_PAYLOAD = JSON.stringify({
|
|
453
|
+
hookSpecificOutput: { hookEventName: 'PostToolUse', updatedToolOutput: updated },
|
|
454
|
+
});
|
|
328
455
|
}
|
|
329
|
-
}
|
|
330
|
-
if (typeof out === 'string') {
|
|
331
|
-
// updatedToolOutput must be a PLAIN STRING (an object form is silently
|
|
332
|
-
// ignored by Claude Code). This replaces the tool result the model sees.
|
|
333
|
-
process.stdout.write(JSON.stringify({
|
|
334
|
-
hookSpecificOutput: { hookEventName: 'PostToolUse', updatedToolOutput: out },
|
|
335
|
-
}));
|
|
336
456
|
}
|
|
337
457
|
} catch {}
|
|
338
458
|
|
|
@@ -359,28 +479,64 @@ process.stdin.on('end', async () => {
|
|
|
359
479
|
writeFileSync(join(flagDir, '.last-tool-call'), Date.now().toString());
|
|
360
480
|
} catch {}
|
|
361
481
|
|
|
362
|
-
//
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
482
|
+
// Flush the model-visible replacement to stdout, THEN exit. On Windows a
|
|
483
|
+
// bare process.exit() can truncate an un-drained pipe write, so Claude Code
|
|
484
|
+
// receives malformed hook JSON, prints "PostToolUse hook warning", and
|
|
485
|
+
// DISCARDS the replacement — leaving the ghost entry visible. Gate the exit
|
|
486
|
+
// on the write's flush callback (and on the fire-and-forget audit POST).
|
|
487
|
+
let flushed = (typeof EMITTED_PAYLOAD !== 'string');
|
|
488
|
+
let fetchDone = false;
|
|
489
|
+
const maybeExit = () => { if (flushed && fetchDone) process.exit(0); };
|
|
490
|
+
if (typeof EMITTED_PAYLOAD === 'string') {
|
|
491
|
+
try { process.stdout.write(EMITTED_PAYLOAD, () => { flushed = true; maybeExit(); }); }
|
|
492
|
+
catch { flushed = true; }
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const sessionId = data.session_id || data.sessionId || data.conversation_id || '';
|
|
496
|
+
const decision = hasError ? 'DENY' : 'ALLOW';
|
|
497
|
+
const reason = guardDenied ? 'blocked by policy guard' : hasError ? 'tool returned error' : ghostFired ? 'ghost path (hidden from agent)' : 'allowed';
|
|
498
|
+
const permission = guessPermission(toolName);
|
|
499
|
+
const evaluationTimeMs = readLastEvalMs(toolName, sessionId);
|
|
500
|
+
|
|
501
|
+
// Local log storage (opt-in): when ON, logs are kept LOCAL ONLY — we append
|
|
502
|
+
// this entry to the user's chosen file and do NOT send it to the cloud.
|
|
503
|
+
const localLogs = loadLocalLogs();
|
|
504
|
+
if (localLogs) {
|
|
505
|
+
appendLocalLog(localLogs, {
|
|
506
|
+
ts: new Date().toISOString(),
|
|
507
|
+
tool: toolName, arguments: argsSummary, decision, reason, permission,
|
|
508
|
+
evaluation_time_ms: evaluationTimeMs, agent_id: AGENT_ID, agent_name: AGENT_NAME, session_id: sessionId,
|
|
509
|
+
});
|
|
510
|
+
fetchDone = true;
|
|
511
|
+
maybeExit();
|
|
512
|
+
} else {
|
|
513
|
+
// Fire-and-forget: don't block tool execution waiting for API response
|
|
514
|
+
fetch(`${API_URL}/api/v1/audit-logs`, {
|
|
515
|
+
method: 'POST',
|
|
516
|
+
headers: {
|
|
517
|
+
'Authorization': `Bearer ${API_KEY}`,
|
|
518
|
+
'Content-Type': 'application/json',
|
|
519
|
+
},
|
|
520
|
+
body: JSON.stringify({
|
|
521
|
+
tool: toolName,
|
|
522
|
+
arguments: argsSummary,
|
|
523
|
+
// Ghost-on-a-listing is NOT a denial: the call was ALLOWED and succeeded,
|
|
524
|
+
// we just hid ghost entries from its result. Log it as ALLOW but carry the
|
|
525
|
+
// ghost reason so the dashboard can badge it Ghost (the allow-side twin of
|
|
526
|
+
// the guard's DENY+Ghost for a direct ghost hit). Real denials stay DENY.
|
|
527
|
+
decision,
|
|
528
|
+
reason,
|
|
529
|
+
permission,
|
|
530
|
+
source: `${AGENT_ID}-hook`,
|
|
531
|
+
evaluationTimeMs,
|
|
532
|
+
agent_id: AGENT_ID,
|
|
533
|
+
agent_name: AGENT_NAME,
|
|
534
|
+
session_id: sessionId,
|
|
535
|
+
}),
|
|
536
|
+
signal: AbortSignal.timeout(5000),
|
|
537
|
+
}).catch(() => {}).finally(() => { fetchDone = true; maybeExit(); });
|
|
538
|
+
}
|
|
539
|
+
// Hard backstop: exit even if the write callback or fetch never settles.
|
|
384
540
|
setTimeout(() => process.exit(0), 3000);
|
|
385
541
|
} catch {
|
|
386
542
|
process.exit(0);
|
package/hooks/guard.bundled.mjs
CHANGED
|
@@ -6530,12 +6530,30 @@ var init_src = __esm({
|
|
|
6530
6530
|
});
|
|
6531
6531
|
|
|
6532
6532
|
// hooks/guard.mjs
|
|
6533
|
-
import { readFileSync, existsSync, statSync, writeFileSync, mkdirSync, chmodSync, renameSync } from "node:fs";
|
|
6534
|
-
import { resolve, join } from "node:path";
|
|
6533
|
+
import { readFileSync, existsSync, statSync, writeFileSync, mkdirSync, chmodSync, renameSync, appendFileSync } from "node:fs";
|
|
6534
|
+
import { resolve, join, dirname } from "node:path";
|
|
6535
6535
|
import { homedir } from "node:os";
|
|
6536
6536
|
import { gunzipSync } from "node:zlib";
|
|
6537
6537
|
import { createHash } from "node:crypto";
|
|
6538
|
-
var HOOK_VERSION =
|
|
6538
|
+
var HOOK_VERSION = 30;
|
|
6539
|
+
function localLogsOnly(security) {
|
|
6540
|
+
const l = security && security.localLogs;
|
|
6541
|
+
return !!(l && l.enabled && typeof l.path === "string" && l.path.trim());
|
|
6542
|
+
}
|
|
6543
|
+
function writeLocalLog(security, entry) {
|
|
6544
|
+
try {
|
|
6545
|
+
const l = security && security.localLogs;
|
|
6546
|
+
if (!l || !l.enabled || typeof l.path !== "string" || !l.path.trim())
|
|
6547
|
+
return;
|
|
6548
|
+
const dir = l.path.trim().replace(/[\\/]+$/, "");
|
|
6549
|
+
try {
|
|
6550
|
+
mkdirSync(dir, { recursive: true });
|
|
6551
|
+
} catch {
|
|
6552
|
+
}
|
|
6553
|
+
appendFileSync(join(dir, "solongate-audit.jsonl"), JSON.stringify(entry) + "\n");
|
|
6554
|
+
} catch {
|
|
6555
|
+
}
|
|
6556
|
+
}
|
|
6539
6557
|
var MAX_FILE_READ = 1024 * 1024;
|
|
6540
6558
|
function safeReadFileSync(filePath, encoding = "utf-8") {
|
|
6541
6559
|
try {
|
|
@@ -6602,24 +6620,13 @@ var globalCfg = loadGlobalCloudConfig();
|
|
|
6602
6620
|
var API_URL = process.env.SOLONGATE_API_URL || dotenv.SOLONGATE_API_URL || globalCfg.apiUrl || "https://api.solongate.com";
|
|
6603
6621
|
var API_KEY = [process.env.SOLONGATE_API_KEY, dotenv.SOLONGATE_API_KEY, globalCfg.apiKey].find(isRealKey) || "";
|
|
6604
6622
|
var AUTH_HEADERS = API_KEY ? { "Authorization": "Bearer " + API_KEY, "X-API-Key": API_KEY } : {};
|
|
6605
|
-
async function
|
|
6606
|
-
if (!API_KEY)
|
|
6607
|
-
return;
|
|
6623
|
+
async function fetchAndInstallHook(endpoint, fileName, currentVersion, marker, minLen) {
|
|
6608
6624
|
try {
|
|
6609
|
-
const
|
|
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) });
|
|
6625
|
+
const res = await fetch(API_URL + "/api/v1/hooks/" + endpoint, { headers: AUTH_HEADERS, signal: AbortSignal.timeout(5e3) });
|
|
6619
6626
|
if (!res.ok)
|
|
6620
6627
|
return;
|
|
6621
6628
|
const data = await res.json();
|
|
6622
|
-
if (!data || typeof data.version !== "number" || data.version <=
|
|
6629
|
+
if (!data || typeof data.version !== "number" || data.version <= currentVersion)
|
|
6623
6630
|
return;
|
|
6624
6631
|
if (typeof data.content !== "string" || typeof data.sha256 !== "string")
|
|
6625
6632
|
return;
|
|
@@ -6627,16 +6634,59 @@ async function maybeSelfUpdate() {
|
|
|
6627
6634
|
if (createHash("sha256").update(buf).digest("hex") !== data.sha256)
|
|
6628
6635
|
return;
|
|
6629
6636
|
const text = buf.toString("utf-8");
|
|
6630
|
-
if (!text.startsWith("#!/usr/bin/env node") || text.length <
|
|
6637
|
+
if (!text.startsWith("#!/usr/bin/env node") || text.length < minLen || !text.includes(marker))
|
|
6631
6638
|
return;
|
|
6632
|
-
const hooksDir = join(
|
|
6633
|
-
const tmp = join(hooksDir, ".
|
|
6639
|
+
const hooksDir = join(resolve(homedir(), ".solongate"), "hooks");
|
|
6640
|
+
const tmp = join(hooksDir, "." + fileName + ".tmp");
|
|
6634
6641
|
writeFileSync(tmp, text);
|
|
6635
6642
|
try {
|
|
6636
|
-
chmodSync(join(hooksDir,
|
|
6643
|
+
chmodSync(join(hooksDir, fileName), 420);
|
|
6637
6644
|
} catch {
|
|
6638
6645
|
}
|
|
6639
|
-
renameSync(tmp, join(hooksDir,
|
|
6646
|
+
renameSync(tmp, join(hooksDir, fileName));
|
|
6647
|
+
} catch {
|
|
6648
|
+
}
|
|
6649
|
+
}
|
|
6650
|
+
function installedHookVersion(fileName) {
|
|
6651
|
+
try {
|
|
6652
|
+
const f = join(resolve(homedir(), ".solongate"), "hooks", fileName);
|
|
6653
|
+
const m = (safeReadFileSync(f) || "").match(/HOOK_VERSION\s*=\s*(\d+)/);
|
|
6654
|
+
return m ? parseInt(m[1], 10) : 0;
|
|
6655
|
+
} catch {
|
|
6656
|
+
return 0;
|
|
6657
|
+
}
|
|
6658
|
+
}
|
|
6659
|
+
var CLOUD_HOOK_VERSIONS = null;
|
|
6660
|
+
function hooksBehindCloud() {
|
|
6661
|
+
const v = CLOUD_HOOK_VERSIONS;
|
|
6662
|
+
if (!v || typeof v !== "object")
|
|
6663
|
+
return false;
|
|
6664
|
+
if (Number(v.guard) > HOOK_VERSION)
|
|
6665
|
+
return true;
|
|
6666
|
+
if (Number(v.audit) > installedHookVersion("audit.mjs"))
|
|
6667
|
+
return true;
|
|
6668
|
+
if (Number(v.shield) > installedHookVersion("shield.mjs"))
|
|
6669
|
+
return true;
|
|
6670
|
+
return false;
|
|
6671
|
+
}
|
|
6672
|
+
async function maybeSelfUpdate() {
|
|
6673
|
+
if (!API_KEY)
|
|
6674
|
+
return;
|
|
6675
|
+
try {
|
|
6676
|
+
const sgDir = resolve(homedir(), ".solongate");
|
|
6677
|
+
const stamp = join(sgDir, ".hook-update-check");
|
|
6678
|
+
if (!hooksBehindCloud()) {
|
|
6679
|
+
const last = parseInt(safeReadFileSync(stamp) || "0", 10);
|
|
6680
|
+
if (Number.isFinite(last) && Date.now() - last < 6 * 3600 * 1e3)
|
|
6681
|
+
return;
|
|
6682
|
+
}
|
|
6683
|
+
try {
|
|
6684
|
+
writeFileSync(stamp, String(Date.now()));
|
|
6685
|
+
} catch {
|
|
6686
|
+
}
|
|
6687
|
+
await fetchAndInstallHook("guard", "guard.mjs", HOOK_VERSION, "SolonGate Cloud Policy Guard", 5e4);
|
|
6688
|
+
await fetchAndInstallHook("audit", "audit.mjs", installedHookVersion("audit.mjs"), "SolonGate Audit Hook", 1500);
|
|
6689
|
+
await fetchAndInstallHook("shield", "shield.mjs", installedHookVersion("shield.mjs"), "SolonGate Shield", 1500);
|
|
6640
6690
|
} catch {
|
|
6641
6691
|
}
|
|
6642
6692
|
}
|
|
@@ -7001,14 +7051,12 @@ var DLP_PATTERNS = [
|
|
|
7001
7051
|
{ name: "GitHub fine-grained PAT", re: /github_pat_[A-Za-z0-9_]{20,}/ },
|
|
7002
7052
|
{ name: "GitLab token", re: /glpat-[A-Za-z0-9_-]{20,}/ },
|
|
7003
7053
|
{ name: "Slack token", re: /xox[baprs]-[A-Za-z0-9-]{10,}/ },
|
|
7004
|
-
{ name: "Google API key", re: /AIza[0-9A-Za-z_-]{35}/ },
|
|
7005
7054
|
{ name: "Stripe key", re: /[sr]k_(live|test)_[A-Za-z0-9]{20,}/ },
|
|
7006
7055
|
{ name: "SendGrid key", re: /SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/ },
|
|
7007
7056
|
{ name: "Twilio key", re: /SK[0-9a-fA-F]{32}/ },
|
|
7008
7057
|
{ name: "npm token", re: /npm_[A-Za-z0-9]{36}/ },
|
|
7009
7058
|
{ name: "JWT", re: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
|
|
7010
|
-
{ name: "Bearer token", re: /bearer\s+[A-Za-z0-9._-]{20,}/i }
|
|
7011
|
-
{ name: "secret assignment", re: /(api[_-]?key|secret|token|password|passwd|access[_-]?key)["']?\s*[:=]\s*["']?[A-Za-z0-9/+_.-]{12,}/i }
|
|
7059
|
+
{ name: "Bearer token", re: /bearer\s+[A-Za-z0-9._-]{20,}/i }
|
|
7012
7060
|
];
|
|
7013
7061
|
function dlpGlobToRe(glob) {
|
|
7014
7062
|
let re = "";
|
|
@@ -7592,6 +7640,8 @@ process.stdin.on("end", async () => {
|
|
|
7592
7640
|
selfProtectEnabled = cached.selfProtect;
|
|
7593
7641
|
if (cached.security !== void 0)
|
|
7594
7642
|
securityCfg = cached.security;
|
|
7643
|
+
if (cached.hookVersions)
|
|
7644
|
+
CLOUD_HOOK_VERSIONS = cached.hookVersions;
|
|
7595
7645
|
}
|
|
7596
7646
|
}
|
|
7597
7647
|
} catch {
|
|
@@ -7605,10 +7655,12 @@ process.stdin.on("end", async () => {
|
|
|
7605
7655
|
selfProtectEnabled = body.self_protection_enabled;
|
|
7606
7656
|
if (body?.security !== void 0)
|
|
7607
7657
|
securityCfg = body.security;
|
|
7658
|
+
if (body?.hook_versions && typeof body.hook_versions === "object")
|
|
7659
|
+
CLOUD_HOOK_VERSIONS = body.hook_versions;
|
|
7608
7660
|
if (body && body.policy)
|
|
7609
7661
|
dashboardPolicy = body.policy;
|
|
7610
7662
|
try {
|
|
7611
|
-
writeFileSync(policyCacheFile, JSON.stringify({ _ts: Date.now(), policy: dashboardPolicy || null, selfProtect: selfProtectEnabled, security: securityCfg }));
|
|
7663
|
+
writeFileSync(policyCacheFile, JSON.stringify({ _ts: Date.now(), policy: dashboardPolicy || null, selfProtect: selfProtectEnabled, security: securityCfg, hookVersions: CLOUD_HOOK_VERSIONS }));
|
|
7612
7664
|
} catch {
|
|
7613
7665
|
}
|
|
7614
7666
|
}
|
|
@@ -7655,24 +7707,26 @@ process.stdin.on("end", async () => {
|
|
|
7655
7707
|
writeDenyFlag(toolName);
|
|
7656
7708
|
} catch {
|
|
7657
7709
|
}
|
|
7710
|
+
writeLocalLog(securityCfg, { ts: (/* @__PURE__ */ new Date()).toISOString(), tool: toolName, arguments: args, decision: "DENY", reason: "ghost path (hidden from agent)", permission: guessPermission(toolName), source: `${AGENT_TYPE}-guard`, agent_id: AGENT_TYPE, agent_name: AGENT_NAME, session_id: data.session_id || "", evaluation_time_ms: Date.now() - _evalStart });
|
|
7658
7711
|
try {
|
|
7659
|
-
|
|
7660
|
-
|
|
7661
|
-
|
|
7662
|
-
|
|
7663
|
-
|
|
7664
|
-
|
|
7665
|
-
|
|
7666
|
-
|
|
7667
|
-
|
|
7668
|
-
|
|
7669
|
-
|
|
7670
|
-
|
|
7671
|
-
|
|
7672
|
-
|
|
7673
|
-
|
|
7674
|
-
|
|
7675
|
-
|
|
7712
|
+
if (!localLogsOnly(securityCfg))
|
|
7713
|
+
await fetch(API_URL + "/api/v1/audit-logs", {
|
|
7714
|
+
method: "POST",
|
|
7715
|
+
headers: { "Content-Type": "application/json", ...AUTH_HEADERS },
|
|
7716
|
+
body: JSON.stringify({
|
|
7717
|
+
tool: toolName,
|
|
7718
|
+
arguments: args,
|
|
7719
|
+
decision: "DENY",
|
|
7720
|
+
reason: "ghost path (hidden from agent)",
|
|
7721
|
+
permission: guessPermission(toolName),
|
|
7722
|
+
source: `${AGENT_TYPE}-guard`,
|
|
7723
|
+
agent_id: AGENT_TYPE,
|
|
7724
|
+
agent_name: AGENT_NAME,
|
|
7725
|
+
session_id: data.session_id || "",
|
|
7726
|
+
evaluation_time_ms: Date.now() - _evalStart
|
|
7727
|
+
}),
|
|
7728
|
+
signal: AbortSignal.timeout(3e3)
|
|
7729
|
+
});
|
|
7676
7730
|
} catch {
|
|
7677
7731
|
}
|
|
7678
7732
|
await maybeSelfUpdate();
|
|
@@ -7717,6 +7771,12 @@ process.stdin.on("end", async () => {
|
|
|
7717
7771
|
}
|
|
7718
7772
|
process.stderr.write(`[SolonGate ROUTE] ${opaRoute.toUpperCase()} (${reason ? "block" : "allow"})
|
|
7719
7773
|
`);
|
|
7774
|
+
try {
|
|
7775
|
+
const _fd = resolve(".solongate");
|
|
7776
|
+
mkdirSync(_fd, { recursive: true });
|
|
7777
|
+
writeFileSync(join(_fd, ".last-eval"), JSON.stringify({ ms: Date.now() - _evalStart, ts: Date.now(), tool: toolName, session: data.session_id || "" }));
|
|
7778
|
+
} catch {
|
|
7779
|
+
}
|
|
7720
7780
|
if (reason) {
|
|
7721
7781
|
if (true) {
|
|
7722
7782
|
try {
|
|
@@ -7732,12 +7792,14 @@ process.stdin.on("end", async () => {
|
|
|
7732
7792
|
session_id: data.session_id || "",
|
|
7733
7793
|
evaluation_time_ms: Date.now() - _evalStart
|
|
7734
7794
|
};
|
|
7735
|
-
|
|
7736
|
-
|
|
7737
|
-
|
|
7738
|
-
|
|
7739
|
-
|
|
7740
|
-
|
|
7795
|
+
writeLocalLog(securityCfg, { ts: (/* @__PURE__ */ new Date()).toISOString(), ...logEntry });
|
|
7796
|
+
if (!localLogsOnly(securityCfg))
|
|
7797
|
+
await fetch(API_URL + "/api/v1/audit-logs", {
|
|
7798
|
+
method: "POST",
|
|
7799
|
+
headers: { "Content-Type": "application/json", ...AUTH_HEADERS },
|
|
7800
|
+
body: JSON.stringify(logEntry),
|
|
7801
|
+
signal: AbortSignal.timeout(3e3)
|
|
7802
|
+
});
|
|
7741
7803
|
} catch {
|
|
7742
7804
|
}
|
|
7743
7805
|
}
|
package/hooks/guard.mjs
CHANGED
|
@@ -21,8 +21,8 @@
|
|
|
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, chmodSync, renameSync } from 'node:fs';
|
|
25
|
-
import { resolve, join } from 'node:path';
|
|
24
|
+
import { readFileSync, existsSync, statSync, writeFileSync, mkdirSync, chmodSync, renameSync, appendFileSync } from 'node:fs';
|
|
25
|
+
import { resolve, join, dirname } from 'node:path';
|
|
26
26
|
import { homedir } from 'node:os';
|
|
27
27
|
import { gunzipSync } from 'node:zlib';
|
|
28
28
|
import { createHash } from 'node:crypto';
|
|
@@ -31,7 +31,27 @@ import { createHash } from 'node:crypto';
|
|
|
31
31
|
// the installed hook self-updates when the cloud version is higher (see
|
|
32
32
|
// maybeSelfUpdate). This is what makes guard fixes propagate without a manual
|
|
33
33
|
// reinstall — the same trust model as the OPA WASM this hook already runs.
|
|
34
|
-
const HOOK_VERSION =
|
|
34
|
+
const HOOK_VERSION = 30;
|
|
35
|
+
|
|
36
|
+
// True when local log storage is ON. In that mode logs are kept LOCAL ONLY and
|
|
37
|
+
// nothing is sent to the cloud audit log.
|
|
38
|
+
function localLogsOnly(security) {
|
|
39
|
+
const l = security && security.localLogs;
|
|
40
|
+
return !!(l && l.enabled && typeof l.path === 'string' && l.path.trim());
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Local log storage (opt-in): write solongate-audit.jsonl inside the user's
|
|
44
|
+
// chosen FOLDER. The audit hook does the ALLOW path; the guard does DENY (a
|
|
45
|
+
// blocked call never reaches PostToolUse). `security` is the resolved config.
|
|
46
|
+
function writeLocalLog(security, entry) {
|
|
47
|
+
try {
|
|
48
|
+
const l = security && security.localLogs;
|
|
49
|
+
if (!l || !l.enabled || typeof l.path !== 'string' || !l.path.trim()) return;
|
|
50
|
+
const dir = l.path.trim().replace(/[\\/]+$/, '');
|
|
51
|
+
try { mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
|
|
52
|
+
appendFileSync(join(dir, 'solongate-audit.jsonl'), JSON.stringify(entry) + '\n');
|
|
53
|
+
} catch { /* best-effort */ }
|
|
54
|
+
}
|
|
35
55
|
|
|
36
56
|
// Safe file read with size limit (1MB max) to prevent DoS via large files
|
|
37
57
|
const MAX_FILE_READ = 1024 * 1024; // 1MB
|
|
@@ -119,30 +139,74 @@ const AUTH_HEADERS = API_KEY ? { 'Authorization': 'Bearer ' + API_KEY, 'X-API-Ke
|
|
|
119
139
|
// version is higher AND the sha256 verifies AND the payload looks like this guard
|
|
120
140
|
// hook, it atomically replaces its own file. Any failure is swallowed so a bad
|
|
121
141
|
// update can never break enforcement — the current code simply keeps running.
|
|
122
|
-
|
|
123
|
-
|
|
142
|
+
// Fetch one hook bundle from the cloud and atomically replace the installed file
|
|
143
|
+
// if the served version is newer AND the sha256 verifies AND it looks like the
|
|
144
|
+
// right hook. Any failure is swallowed.
|
|
145
|
+
async function fetchAndInstallHook(endpoint, fileName, currentVersion, marker, minLen) {
|
|
124
146
|
try {
|
|
125
|
-
const
|
|
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) });
|
|
147
|
+
const res = await fetch(API_URL + '/api/v1/hooks/' + endpoint, { headers: AUTH_HEADERS, signal: AbortSignal.timeout(5000) });
|
|
132
148
|
if (!res.ok) return;
|
|
133
149
|
const data = await res.json();
|
|
134
|
-
if (!data || typeof data.version !== 'number' || data.version <=
|
|
150
|
+
if (!data || typeof data.version !== 'number' || data.version <= currentVersion) return;
|
|
135
151
|
if (typeof data.content !== 'string' || typeof data.sha256 !== 'string') return;
|
|
136
152
|
const buf = Buffer.from(data.content, 'base64');
|
|
137
153
|
if (createHash('sha256').update(buf).digest('hex') !== data.sha256) return;
|
|
138
154
|
const text = buf.toString('utf-8');
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
const tmp = join(hooksDir, '.guard.mjs.tmp');
|
|
155
|
+
if (!text.startsWith('#!/usr/bin/env node') || text.length < minLen || !text.includes(marker)) return;
|
|
156
|
+
const hooksDir = join(resolve(homedir(), '.solongate'), 'hooks');
|
|
157
|
+
const tmp = join(hooksDir, '.' + fileName + '.tmp');
|
|
143
158
|
writeFileSync(tmp, text);
|
|
144
|
-
try { chmodSync(join(hooksDir,
|
|
145
|
-
renameSync(tmp, join(hooksDir,
|
|
159
|
+
try { chmodSync(join(hooksDir, fileName), 0o644); } catch { /* may be locked read-only */ }
|
|
160
|
+
renameSync(tmp, join(hooksDir, fileName)); // atomic swap, takes effect next call
|
|
161
|
+
} catch { /* never break enforcement on update failure */ }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Read the HOOK_VERSION baked into an installed sibling hook (0 if absent/old).
|
|
165
|
+
function installedHookVersion(fileName) {
|
|
166
|
+
try {
|
|
167
|
+
const f = join(resolve(homedir(), '.solongate'), 'hooks', fileName);
|
|
168
|
+
const m = (safeReadFileSync(f) || '').match(/HOOK_VERSION\s*=\s*(\d+)/);
|
|
169
|
+
return m ? parseInt(m[1], 10) : 0;
|
|
170
|
+
} catch { return 0; }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Latest hook versions the cloud reports on /policies/active (hook_versions).
|
|
174
|
+
// Captured during the policy fetch of THIS run (or its short-lived cache); lets
|
|
175
|
+
// maybeSelfUpdate() know it is behind and bypass the 6h stamp entirely.
|
|
176
|
+
let CLOUD_HOOK_VERSIONS = null;
|
|
177
|
+
|
|
178
|
+
function hooksBehindCloud() {
|
|
179
|
+
const v = CLOUD_HOOK_VERSIONS;
|
|
180
|
+
if (!v || typeof v !== 'object') return false;
|
|
181
|
+
if (Number(v.guard) > HOOK_VERSION) return true;
|
|
182
|
+
if (Number(v.audit) > installedHookVersion('audit.mjs')) return true;
|
|
183
|
+
if (Number(v.shield) > installedHookVersion('shield.mjs')) return true;
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Once per ~6h: update the guard itself AND its sibling hooks (audit, shield).
|
|
188
|
+
// The guard is the only hook that self-updates from the cloud, so it carries the
|
|
189
|
+
// others — that's why a new audit/shield reaches every device with NO re-login:
|
|
190
|
+
// the guard fetches and installs them on its next run.
|
|
191
|
+
//
|
|
192
|
+
// The 6h stamp only rate-limits the BLIND check. When the policy response says
|
|
193
|
+
// the cloud serves a NEWER hook (hook_versions), we update immediately — so a
|
|
194
|
+
// fresh release lands on the next executed command, and a stamp refreshed by an
|
|
195
|
+
// earlier run (e.g. before the release finished deploying) can't delay it.
|
|
196
|
+
async function maybeSelfUpdate() {
|
|
197
|
+
if (!API_KEY) return;
|
|
198
|
+
try {
|
|
199
|
+
const sgDir = resolve(homedir(), '.solongate');
|
|
200
|
+
const stamp = join(sgDir, '.hook-update-check');
|
|
201
|
+
if (!hooksBehindCloud()) {
|
|
202
|
+
const last = parseInt(safeReadFileSync(stamp) || '0', 10);
|
|
203
|
+
if (Number.isFinite(last) && Date.now() - last < 6 * 3600 * 1000) return;
|
|
204
|
+
}
|
|
205
|
+
try { writeFileSync(stamp, String(Date.now())); } catch { /* ignore */ }
|
|
206
|
+
// Guard compares to its OWN running version; siblings to their installed file.
|
|
207
|
+
await fetchAndInstallHook('guard', 'guard.mjs', HOOK_VERSION, 'SolonGate Cloud Policy Guard', 50000);
|
|
208
|
+
await fetchAndInstallHook('audit', 'audit.mjs', installedHookVersion('audit.mjs'), 'SolonGate Audit Hook', 1500);
|
|
209
|
+
await fetchAndInstallHook('shield', 'shield.mjs', installedHookVersion('shield.mjs'), 'SolonGate Shield', 1500);
|
|
146
210
|
} catch { /* never break enforcement on update failure */ }
|
|
147
211
|
}
|
|
148
212
|
|
|
@@ -655,14 +719,12 @@ const DLP_PATTERNS = [
|
|
|
655
719
|
{ name: 'GitHub fine-grained PAT', re: /github_pat_[A-Za-z0-9_]{20,}/ },
|
|
656
720
|
{ name: 'GitLab token', re: /glpat-[A-Za-z0-9_-]{20,}/ },
|
|
657
721
|
{ name: 'Slack token', re: /xox[baprs]-[A-Za-z0-9-]{10,}/ },
|
|
658
|
-
{ name: 'Google API key', re: /AIza[0-9A-Za-z_-]{35}/ },
|
|
659
722
|
{ name: 'Stripe key', re: /[sr]k_(live|test)_[A-Za-z0-9]{20,}/ },
|
|
660
723
|
{ name: 'SendGrid key', re: /SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/ },
|
|
661
724
|
{ name: 'Twilio key', re: /SK[0-9a-fA-F]{32}/ },
|
|
662
725
|
{ name: 'npm token', re: /npm_[A-Za-z0-9]{36}/ },
|
|
663
726
|
{ name: 'JWT', re: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
|
|
664
727
|
{ name: 'Bearer token', re: /bearer\s+[A-Za-z0-9._-]{20,}/i },
|
|
665
|
-
{ name: 'secret assignment', re: /(api[_-]?key|secret|token|password|passwd|access[_-]?key)["']?\s*[:=]\s*["']?[A-Za-z0-9/+_.-]{12,}/i },
|
|
666
728
|
];
|
|
667
729
|
|
|
668
730
|
// Custom patterns are GLOBs: `*` = any run of non-whitespace, same wildcard
|
|
@@ -1353,6 +1415,7 @@ process.stdin.on('end', async () => {
|
|
|
1353
1415
|
if (cached.policy) dashboardPolicy = cached.policy;
|
|
1354
1416
|
if (typeof cached.selfProtect === 'boolean') selfProtectEnabled = cached.selfProtect;
|
|
1355
1417
|
if (cached.security !== undefined) securityCfg = cached.security;
|
|
1418
|
+
if (cached.hookVersions) CLOUD_HOOK_VERSIONS = cached.hookVersions;
|
|
1356
1419
|
}
|
|
1357
1420
|
}
|
|
1358
1421
|
} catch {}
|
|
@@ -1367,8 +1430,9 @@ process.stdin.on('end', async () => {
|
|
|
1367
1430
|
// Capture the self-protection flag even when no cloud policy is set.
|
|
1368
1431
|
if (typeof body?.self_protection_enabled === 'boolean') selfProtectEnabled = body.self_protection_enabled;
|
|
1369
1432
|
if (body?.security !== undefined) securityCfg = body.security;
|
|
1433
|
+
if (body?.hook_versions && typeof body.hook_versions === 'object') CLOUD_HOOK_VERSIONS = body.hook_versions;
|
|
1370
1434
|
if (body && body.policy) dashboardPolicy = body.policy;
|
|
1371
|
-
try { writeFileSync(policyCacheFile, JSON.stringify({ _ts: Date.now(), policy: dashboardPolicy || null, selfProtect: selfProtectEnabled, security: securityCfg })); } catch {}
|
|
1435
|
+
try { writeFileSync(policyCacheFile, JSON.stringify({ _ts: Date.now(), policy: dashboardPolicy || null, selfProtect: selfProtectEnabled, security: securityCfg, hookVersions: CLOUD_HOOK_VERSIONS })); } catch {}
|
|
1372
1436
|
}
|
|
1373
1437
|
} catch {}
|
|
1374
1438
|
}
|
|
@@ -1421,8 +1485,9 @@ process.stdin.on('end', async () => {
|
|
|
1421
1485
|
const ghostHit = ghostBlock(toolName, args, securityCfg.ghost);
|
|
1422
1486
|
if (ghostHit) {
|
|
1423
1487
|
try { writeDenyFlag(toolName); } catch {}
|
|
1488
|
+
writeLocalLog(securityCfg, { ts: new Date().toISOString(), tool: toolName, arguments: args, decision: 'DENY', reason: 'ghost path (hidden from agent)', permission: guessPermission(toolName), source: `${AGENT_TYPE}-guard`, agent_id: AGENT_TYPE, agent_name: AGENT_NAME, session_id: data.session_id || '', evaluation_time_ms: Date.now() - _evalStart });
|
|
1424
1489
|
try {
|
|
1425
|
-
await fetch(API_URL + '/api/v1/audit-logs', {
|
|
1490
|
+
if (!localLogsOnly(securityCfg)) await fetch(API_URL + '/api/v1/audit-logs', {
|
|
1426
1491
|
method: 'POST',
|
|
1427
1492
|
headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
|
|
1428
1493
|
body: JSON.stringify({
|
|
@@ -1498,6 +1563,12 @@ process.stdin.on('end', async () => {
|
|
|
1498
1563
|
|
|
1499
1564
|
process.stderr.write(`[SolonGate ROUTE] ${opaRoute.toUpperCase()} (${reason ? 'block' : 'allow'})\n`);
|
|
1500
1565
|
|
|
1566
|
+
// Hand the measured policy-eval time to the audit hook: PostToolUse logs the
|
|
1567
|
+
// ALLOW path and can't time the guard itself, so it reads this file back.
|
|
1568
|
+
// Keyed by tool + session so the audit hook can match THIS invocation even
|
|
1569
|
+
// when the tool itself runs for minutes (a bare timestamp TTL lost those).
|
|
1570
|
+
try { const _fd = resolve('.solongate'); mkdirSync(_fd, { recursive: true }); writeFileSync(join(_fd, '.last-eval'), JSON.stringify({ ms: Date.now() - _evalStart, ts: Date.now(), tool: toolName, session: data.session_id || '' })); } catch {}
|
|
1571
|
+
|
|
1501
1572
|
// Only log DENY decisions from guard hook.
|
|
1502
1573
|
// ALLOW decisions are logged by the audit hook (PostToolUse) to avoid double-counting.
|
|
1503
1574
|
if (reason) {
|
|
@@ -1512,8 +1583,10 @@ process.stdin.on('end', async () => {
|
|
|
1512
1583
|
session_id: data.session_id || '',
|
|
1513
1584
|
evaluation_time_ms: Date.now() - _evalStart,
|
|
1514
1585
|
};
|
|
1586
|
+
writeLocalLog(securityCfg, { ts: new Date().toISOString(), ...logEntry });
|
|
1515
1587
|
// PI hook layer removed — piResult fields no longer attached.
|
|
1516
|
-
|
|
1588
|
+
// Local-only mode: keep the log on the user's machine, skip the cloud.
|
|
1589
|
+
if (!localLogsOnly(securityCfg)) await fetch(API_URL + '/api/v1/audit-logs', {
|
|
1517
1590
|
method: 'POST',
|
|
1518
1591
|
headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
|
|
1519
1592
|
body: JSON.stringify(logEntry),
|
package/hooks/shield.mjs
CHANGED
|
@@ -17,10 +17,14 @@
|
|
|
17
17
|
import { createServer, request as httpRequest } from 'node:http';
|
|
18
18
|
import { request as httpsRequest } from 'node:https';
|
|
19
19
|
import { spawn } from 'node:child_process';
|
|
20
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
20
|
+
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
21
21
|
import { resolve } from 'node:path';
|
|
22
22
|
import { homedir } from 'node:os';
|
|
23
23
|
|
|
24
|
+
// Bump on every shield.mjs change. The cloud serves the newest version; the
|
|
25
|
+
// guard hook installs it on its next run (no re-login needed).
|
|
26
|
+
const HOOK_VERSION = 5;
|
|
27
|
+
|
|
24
28
|
const log = (...a) => process.stderr.write(`[SolonGate shield] ${a.map(String).join(' ')}\n`);
|
|
25
29
|
|
|
26
30
|
const DLP_PATTERNS = [
|
|
@@ -32,27 +36,128 @@ const DLP_PATTERNS = [
|
|
|
32
36
|
{ name: 'GitHub fine-grained PAT', re: /github_pat_[A-Za-z0-9_]{20,}/g },
|
|
33
37
|
{ name: 'GitLab token', re: /glpat-[A-Za-z0-9_-]{20,}/g },
|
|
34
38
|
{ name: 'Slack token', re: /xox[baprs]-[A-Za-z0-9-]{10,}/g },
|
|
35
|
-
{ name: 'Google API key', re: /AIza[0-9A-Za-z_-]{35}/g },
|
|
36
39
|
{ name: 'Stripe key', re: /[sr]k_(live|test)_[A-Za-z0-9]{20,}/g },
|
|
37
40
|
{ name: 'SendGrid key', re: /SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/g },
|
|
38
41
|
{ name: 'Twilio key', re: /SK[0-9a-fA-F]{32}/g },
|
|
39
42
|
{ name: 'npm token', re: /npm_[A-Za-z0-9]{36}/g },
|
|
40
43
|
{ name: 'JWT', re: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g },
|
|
41
44
|
{ name: 'Bearer token', re: /bearer\s+[A-Za-z0-9._-]{20,}/gi },
|
|
42
|
-
{ name: 'secret assignment', re: /(api[_-]?key|secret|token|password|passwd|access[_-]?key)["']?\s*[:=]\s*["']?[A-Za-z0-9/+_.-]{12,}/gi },
|
|
43
45
|
];
|
|
44
46
|
|
|
47
|
+
// Find the policy cache to read. The guard writes one per agent
|
|
48
|
+
// (.policy-cache-<agent>.json) - the shield wraps `claude` and doesn't know the
|
|
49
|
+
// agent id, so unless SOLONGATE_AGENT_ID is set it picks the MOST RECENTLY
|
|
50
|
+
// written cache (the active session's). A fixed 'default' missed the guard's
|
|
51
|
+
// real cache, so custom patterns never reached the shield.
|
|
52
|
+
function findCacheFile() {
|
|
53
|
+
const dir = resolve(homedir(), '.solongate');
|
|
54
|
+
const envSel = process.env.SOLONGATE_AGENT_ID;
|
|
55
|
+
if (envSel) {
|
|
56
|
+
const f = resolve(dir, '.policy-cache-' + envSel.replace(/[^a-zA-Z0-9_-]/g, '_') + '.json');
|
|
57
|
+
if (existsSync(f)) return f;
|
|
58
|
+
}
|
|
59
|
+
let best = null, bestTs = -1;
|
|
60
|
+
try {
|
|
61
|
+
for (const name of readdirSync(dir)) {
|
|
62
|
+
if (name.startsWith('.policy-cache-') && name.endsWith('.json')) {
|
|
63
|
+
const full = resolve(dir, name);
|
|
64
|
+
const ts = statSync(full).mtimeMs;
|
|
65
|
+
if (ts > bestTs) { bestTs = ts; best = full; }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch { /* dir missing */ }
|
|
69
|
+
return best;
|
|
70
|
+
}
|
|
71
|
+
|
|
45
72
|
function loadCfg() {
|
|
46
73
|
try {
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
if (existsSync(f)) {
|
|
74
|
+
const f = findCacheFile();
|
|
75
|
+
if (f && existsSync(f)) {
|
|
50
76
|
const c = JSON.parse(readFileSync(f, 'utf-8'));
|
|
51
77
|
const d = c && c.security && c.security.dlpRedact;
|
|
52
|
-
|
|
78
|
+
const g = c && c.security && c.security.ghost;
|
|
79
|
+
const ghost = g && Array.isArray(g.patterns) ? g.patterns : [];
|
|
80
|
+
if (d && Array.isArray(d.patterns)) return { patterns: d.patterns, custom: Array.isArray(d.custom) ? d.custom : [], ghost };
|
|
81
|
+
return { patterns: DLP_PATTERNS.map((p) => p.name), custom: [], ghost };
|
|
53
82
|
}
|
|
54
83
|
} catch { /* default below */ }
|
|
55
|
-
return { patterns: DLP_PATTERNS.map((p) => p.name), custom: [] };
|
|
84
|
+
return { patterns: DLP_PATTERNS.map((p) => p.name), custom: [], ghost: [] };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Ghost paths: hidden files/dirs the model must not even see exist. The audit
|
|
88
|
+
// PostToolUse hook strips them per tool shape, but that depends on parsing each
|
|
89
|
+
// tool's response. The shield sits on the request path where EVERY tool result
|
|
90
|
+
// is already a plain string, so stripping here catches every listing shape
|
|
91
|
+
// (Glob/LS/Grep/MCP/Bash) uniformly. Glob mirrors policy/audit: `*` = any run of
|
|
92
|
+
// non-slash, `**` = any run. Anchored, so a pattern matches a whole path segment.
|
|
93
|
+
function ghostGlobToRegExp(glob) {
|
|
94
|
+
let re = '';
|
|
95
|
+
for (let i = 0; i < glob.length; i++) {
|
|
96
|
+
const c = glob[i];
|
|
97
|
+
if (c === '*') { if (glob[i + 1] === '*') { re += '.*'; i++; } else re += '[^/]*'; }
|
|
98
|
+
else if (c === '?') re += '[^/]';
|
|
99
|
+
else if ('\\^$.|+()[]{}'.indexOf(c) !== -1) re += '\\' + c;
|
|
100
|
+
else re += c;
|
|
101
|
+
}
|
|
102
|
+
try { return new RegExp('^' + re + '$'); } catch { return null; }
|
|
103
|
+
}
|
|
104
|
+
function ghostMatch(targetPath, patterns) {
|
|
105
|
+
if (!targetPath || !Array.isArray(patterns) || patterns.length === 0) return false;
|
|
106
|
+
const norm = String(targetPath).replace(/\\/g, '/').replace(/\/+$/, '');
|
|
107
|
+
if (!norm) return false;
|
|
108
|
+
const segments = norm.split('/').filter(Boolean);
|
|
109
|
+
const base = segments.length ? segments[segments.length - 1] : norm;
|
|
110
|
+
for (let pat of patterns) {
|
|
111
|
+
pat = String(pat || '').trim();
|
|
112
|
+
if (!pat) continue;
|
|
113
|
+
let dirOnly = false;
|
|
114
|
+
if (pat.endsWith('/')) { dirOnly = true; pat = pat.slice(0, -1); }
|
|
115
|
+
if (!pat) continue;
|
|
116
|
+
const hasSlash = pat.indexOf('/') !== -1;
|
|
117
|
+
const hasWild = /[*?]/.test(pat);
|
|
118
|
+
const re = ghostGlobToRegExp(pat);
|
|
119
|
+
if (!re) continue;
|
|
120
|
+
if (dirOnly) {
|
|
121
|
+
if (!hasSlash && !hasWild) { if (segments.indexOf(pat) !== -1) return true; continue; }
|
|
122
|
+
let acc = '';
|
|
123
|
+
for (const s of segments) { acc = acc ? acc + '/' + s : s; if (re.test(acc) || re.test(s)) return true; }
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (!hasSlash) {
|
|
127
|
+
if (re.test(base)) return true;
|
|
128
|
+
if (segments.some((s) => re.test(s))) return true;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (re.test(norm)) return true;
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
function ghostCleanToken(tok) {
|
|
136
|
+
let t = String(tok || '').trim();
|
|
137
|
+
t = t.replace(/^[<>|;&(]+/, '').replace(/[);&|]+$/, '');
|
|
138
|
+
t = t.replace(/^['"]+/, '').replace(/['"]+$/, '');
|
|
139
|
+
t = t.replace(/^\d*>>?/, '');
|
|
140
|
+
return t.trim();
|
|
141
|
+
}
|
|
142
|
+
// Drop any line that references a ghost entry (full-path listings, ls -l rows,
|
|
143
|
+
// space-separated names). Same logic as audit.mjs ghostStripLines.
|
|
144
|
+
function ghostStripLines(text, pats) {
|
|
145
|
+
if (!Array.isArray(pats) || pats.length === 0) return text;
|
|
146
|
+
const lines = String(text).split('\n');
|
|
147
|
+
const kept = [];
|
|
148
|
+
for (const line of lines) {
|
|
149
|
+
const trimmed = line.trim();
|
|
150
|
+
if (!trimmed) { kept.push(line); continue; }
|
|
151
|
+
if (ghostMatch(trimmed, pats)) continue;
|
|
152
|
+
const toks = trimmed.split(/\s+/);
|
|
153
|
+
const anyHit = toks.some((t) => ghostMatch(ghostCleanToken(t), pats));
|
|
154
|
+
if (!anyHit) { kept.push(line); continue; }
|
|
155
|
+
if (toks.length > 3) continue;
|
|
156
|
+
const remaining = toks.filter((t) => !ghostMatch(ghostCleanToken(t), pats));
|
|
157
|
+
if (remaining.length === 0) continue;
|
|
158
|
+
kept.push(remaining.join(' '));
|
|
159
|
+
}
|
|
160
|
+
return kept.join('\n');
|
|
56
161
|
}
|
|
57
162
|
|
|
58
163
|
// Custom patterns are GLOBs: `*` = any run of non-whitespace, same as policy/ghost.
|
|
@@ -77,8 +182,20 @@ function redactString(s, cfg) {
|
|
|
77
182
|
}
|
|
78
183
|
|
|
79
184
|
function redactDeep(value, cfg) {
|
|
80
|
-
|
|
81
|
-
if (
|
|
185
|
+
const ghost = cfg && Array.isArray(cfg.ghost) ? cfg.ghost : null;
|
|
186
|
+
if (typeof value === 'string') {
|
|
187
|
+
let out = redactString(value, cfg);
|
|
188
|
+
if (ghost && ghost.length) out = ghostStripLines(out, ghost);
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
if (Array.isArray(value)) {
|
|
192
|
+
// Drop array elements that are themselves a whole ghost path (e.g. a Glob
|
|
193
|
+
// result delivered as one-path-per-element), so no empty husk remains.
|
|
194
|
+
const arr = ghost && ghost.length
|
|
195
|
+
? value.filter((v) => !(typeof v === 'string' && ghostMatch(v.trim(), ghost)))
|
|
196
|
+
: value;
|
|
197
|
+
return arr.map((v) => redactDeep(v, cfg));
|
|
198
|
+
}
|
|
82
199
|
if (value && typeof value === 'object') {
|
|
83
200
|
const out = {};
|
|
84
201
|
for (const [k, v] of Object.entries(value)) out[k] = redactDeep(v, cfg);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solongate/proxy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.57.0",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"bin": {
|