@solongate/proxy 0.56.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 CHANGED
@@ -16,14 +16,11 @@ AI agent ──(tool call)──> SolonGate guard ──> Tool runs
16
16
  [allow / block / audit]
17
17
  ```
18
18
 
19
- SolonGate comes in two editions that share the same policy model and dashboard:
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 (Cloud)
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
 
@@ -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
- if (t && t[0] !== '-' && ghostMatch(t, pats)) return t + ': No such file or directory';
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
- // Listing-style toolsstrip hidden entries from the result.
156
- if (name === 'Glob' || name === 'Grep' || name === 'LS') {
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 ghostText = buildGhostOutput(toolName, toolInput, toolResponse, toolOutput, loadGhostPatterns());
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
- let out = typeof ghostText === 'string' ? ghostText : null;
323
- if (dlpCfg) {
324
- const base = typeof ghostText === 'string' ? ghostText : extractOutputText(toolResponse, toolOutput);
325
- if (typeof base === 'string') {
326
- const redacted = dlpRedactText(base, dlpCfg);
327
- if (redacted !== base || typeof ghostText === 'string') out = redacted;
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
- // Fire-and-forget: don't block tool execution waiting for API response
363
- fetch(`${API_URL}/api/v1/audit-logs`, {
364
- method: 'POST',
365
- headers: {
366
- 'Authorization': `Bearer ${API_KEY}`,
367
- 'Content-Type': 'application/json',
368
- },
369
- body: JSON.stringify({
370
- tool: toolName,
371
- arguments: argsSummary,
372
- decision: hasError ? 'DENY' : 'ALLOW',
373
- reason: guardDenied ? 'blocked by policy guard' : hasError ? 'tool returned error' : 'allowed',
374
- permission: guessPermission(toolName),
375
- source: `${AGENT_ID}-hook`,
376
- evaluationTimeMs: 0,
377
- agent_id: AGENT_ID,
378
- agent_name: AGENT_NAME,
379
- session_id: data.session_id || data.sessionId || data.conversation_id || '',
380
- }),
381
- signal: AbortSignal.timeout(5000),
382
- }).catch(() => {}).finally(() => process.exit(0));
383
- // Exit after short delay if fetch hangs on DNS/connect
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);
@@ -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 = 19;
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 maybeSelfUpdate() {
6606
- if (!API_KEY)
6607
- return;
6623
+ async function fetchAndInstallHook(endpoint, fileName, currentVersion, marker, minLen) {
6608
6624
  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) });
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 <= HOOK_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 < 5e4 || !text.includes("SolonGate Cloud Policy Guard"))
6637
+ if (!text.startsWith("#!/usr/bin/env node") || text.length < minLen || !text.includes(marker))
6631
6638
  return;
6632
- const hooksDir = join(sgDir, "hooks");
6633
- const tmp = join(hooksDir, ".guard.mjs.tmp");
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, "guard.mjs"), 420);
6643
+ chmodSync(join(hooksDir, fileName), 420);
6637
6644
  } catch {
6638
6645
  }
6639
- renameSync(tmp, join(hooksDir, "guard.mjs"));
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
- await fetch(API_URL + "/api/v1/audit-logs", {
7660
- method: "POST",
7661
- headers: { "Content-Type": "application/json", ...AUTH_HEADERS },
7662
- body: JSON.stringify({
7663
- tool: toolName,
7664
- arguments: args,
7665
- decision: "DENY",
7666
- reason: "ghost path (hidden from agent)",
7667
- permission: guessPermission(toolName),
7668
- source: `${AGENT_TYPE}-guard`,
7669
- agent_id: AGENT_TYPE,
7670
- agent_name: AGENT_NAME,
7671
- session_id: data.session_id || "",
7672
- evaluation_time_ms: Date.now() - _evalStart
7673
- }),
7674
- signal: AbortSignal.timeout(3e3)
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
- await fetch(API_URL + "/api/v1/audit-logs", {
7736
- method: "POST",
7737
- headers: { "Content-Type": "application/json", ...AUTH_HEADERS },
7738
- body: JSON.stringify(logEntry),
7739
- signal: AbortSignal.timeout(3e3)
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 = 19;
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
- async function maybeSelfUpdate() {
123
- if (!API_KEY) return;
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 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) });
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 <= HOOK_VERSION) return;
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
- // 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');
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, 'guard.mjs'), 0o644); } catch { /* may be locked read-only */ }
145
- renameSync(tmp, join(hooksDir, 'guard.mjs')); // atomic swap, takes effect next call
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
- await fetch(API_URL + '/api/v1/audit-logs', {
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
@@ -21,6 +21,10 @@ 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,14 +36,12 @@ 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
 
45
47
  // Find the policy cache to read. The guard writes one per agent
@@ -73,10 +75,89 @@ function loadCfg() {
73
75
  if (f && existsSync(f)) {
74
76
  const c = JSON.parse(readFileSync(f, 'utf-8'));
75
77
  const d = c && c.security && c.security.dlpRedact;
76
- if (d && Array.isArray(d.patterns)) return { patterns: d.patterns, custom: Array.isArray(d.custom) ? d.custom : [] };
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 };
77
82
  }
78
83
  } catch { /* default below */ }
79
- 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');
80
161
  }
81
162
 
82
163
  // Custom patterns are GLOBs: `*` = any run of non-whitespace, same as policy/ghost.
@@ -101,8 +182,20 @@ function redactString(s, cfg) {
101
182
  }
102
183
 
103
184
  function redactDeep(value, cfg) {
104
- if (typeof value === 'string') return redactString(value, cfg);
105
- if (Array.isArray(value)) return value.map((v) => redactDeep(v, cfg));
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
+ }
106
199
  if (value && typeof value === 'object') {
107
200
  const out = {};
108
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.56.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": {