@solongate/proxy 0.47.0 → 0.47.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/global-install.js +4 -3
- package/dist/index.js +18 -7
- package/dist/init.js +8 -6
- package/dist/login.js +14 -4
- package/hooks/guard.bundled.mjs +7536 -7257
- package/hooks/guard.mjs +234 -27
- package/package.json +76 -76
package/hooks/guard.mjs
CHANGED
|
@@ -21,10 +21,17 @@
|
|
|
21
21
|
* Logs DENY decisions to SolonGate Cloud. ALLOWs are logged by audit.mjs.
|
|
22
22
|
* Auto-installed by: npx @solongate/proxy init --global
|
|
23
23
|
*/
|
|
24
|
-
import { readFileSync, existsSync, statSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
24
|
+
import { readFileSync, existsSync, statSync, writeFileSync, mkdirSync, chmodSync, renameSync } from 'node:fs';
|
|
25
25
|
import { resolve, join } from 'node:path';
|
|
26
26
|
import { homedir } from 'node:os';
|
|
27
27
|
import { gunzipSync } from 'node:zlib';
|
|
28
|
+
import { createHash } from 'node:crypto';
|
|
29
|
+
|
|
30
|
+
// Bump on every guard.mjs change. The cloud serves the newest bundle + version;
|
|
31
|
+
// the installed hook self-updates when the cloud version is higher (see
|
|
32
|
+
// maybeSelfUpdate). This is what makes guard fixes propagate without a manual
|
|
33
|
+
// reinstall — the same trust model as the OPA WASM this hook already runs.
|
|
34
|
+
const HOOK_VERSION = 14;
|
|
28
35
|
|
|
29
36
|
// Safe file read with size limit (1MB max) to prevent DoS via large files
|
|
30
37
|
const MAX_FILE_READ = 1024 * 1024; // 1MB
|
|
@@ -65,6 +72,23 @@ function loadGlobalCloudConfig() {
|
|
|
65
72
|
} catch { return {}; }
|
|
66
73
|
}
|
|
67
74
|
|
|
75
|
+
// A real cloud key is `sg_live_`/`sg_test_` followed by hex (see generateApiKey:
|
|
76
|
+
// 24 random bytes → 48 hex chars). Template/placeholder values shipped in sample
|
|
77
|
+
// .env files (e.g. `sg_live_your_key_here`) pass a naive truthiness check but are
|
|
78
|
+
// bogus — and because resolution prefers a project .env over the global login
|
|
79
|
+
// credential, a stray placeholder .env would shadow a valid login and 401 every
|
|
80
|
+
// API call, making the guard fail closed on EVERYTHING. Filter to real keys so a
|
|
81
|
+
// placeholder is skipped and the next real candidate (usually the login cred in
|
|
82
|
+
// cloud-guard.json) is used instead.
|
|
83
|
+
function isRealKey(k) {
|
|
84
|
+
if (typeof k !== 'string') return false;
|
|
85
|
+
const v = k.trim();
|
|
86
|
+
if (!/^sg_(live|test)_/.test(v)) return false;
|
|
87
|
+
const body = v.replace(/^sg_(live|test)_/, '');
|
|
88
|
+
if (/your_key_here|placeholder|example|^x+$/i.test(body)) return false;
|
|
89
|
+
return /^[a-f0-9]{16,}$/i.test(body);
|
|
90
|
+
}
|
|
91
|
+
|
|
68
92
|
function guessPermission(toolName) {
|
|
69
93
|
const name = (toolName || '').toLowerCase();
|
|
70
94
|
if (name.includes('exec') || name.includes('shell') || name.includes('run') || name.includes('eval') || name === 'bash') return 'EXECUTE';
|
|
@@ -82,12 +106,46 @@ const API_URL = process.env.SOLONGATE_API_URL || dotenv.SOLONGATE_API_URL || glo
|
|
|
82
106
|
// Cloud API key (sg_live_… / sg_test_…). The key identifies the project AND
|
|
83
107
|
// authenticates every API call (active policy, compiled WASM, audit logs). When
|
|
84
108
|
// absent, this hook does nothing — a machine with no key is intentionally
|
|
85
|
-
// unenforced (the cloud has no policy to apply).
|
|
86
|
-
|
|
109
|
+
// unenforced (the cloud has no policy to apply). Each candidate is filtered
|
|
110
|
+
// through isRealKey() so a placeholder .env (sg_live_your_key_here) can't shadow
|
|
111
|
+
// the real login credential and force a fail-closed on every call.
|
|
112
|
+
const API_KEY = [process.env.SOLONGATE_API_KEY, dotenv.SOLONGATE_API_KEY, globalCfg.apiKey].find(isRealKey) || '';
|
|
87
113
|
// Auth headers attached to every cloud API request. Cloud accepts either the
|
|
88
114
|
// Authorization: Bearer form or X-API-Key; we send both for robustness.
|
|
89
115
|
const AUTH_HEADERS = API_KEY ? { 'Authorization': 'Bearer ' + API_KEY, 'X-API-Key': API_KEY } : {};
|
|
90
116
|
|
|
117
|
+
// ── Self-update (best-effort, throttled, integrity-checked) ──
|
|
118
|
+
// Once per ~6h the hook asks the cloud for the latest guard bundle. If the cloud
|
|
119
|
+
// version is higher AND the sha256 verifies AND the payload looks like this guard
|
|
120
|
+
// hook, it atomically replaces its own file. Any failure is swallowed so a bad
|
|
121
|
+
// update can never break enforcement — the current code simply keeps running.
|
|
122
|
+
async function maybeSelfUpdate() {
|
|
123
|
+
if (!API_KEY) return;
|
|
124
|
+
try {
|
|
125
|
+
const sgDir = resolve(homedir(), '.solongate');
|
|
126
|
+
const stamp = join(sgDir, '.hook-update-check');
|
|
127
|
+
const last = parseInt(safeReadFileSync(stamp) || '0', 10);
|
|
128
|
+
if (Number.isFinite(last) && Date.now() - last < 6 * 3600 * 1000) return;
|
|
129
|
+
try { writeFileSync(stamp, String(Date.now())); } catch { /* ignore */ }
|
|
130
|
+
|
|
131
|
+
const res = await fetch(API_URL + '/api/v1/hooks/guard', { headers: AUTH_HEADERS, signal: AbortSignal.timeout(5000) });
|
|
132
|
+
if (!res.ok) return;
|
|
133
|
+
const data = await res.json();
|
|
134
|
+
if (!data || typeof data.version !== 'number' || data.version <= HOOK_VERSION) return;
|
|
135
|
+
if (typeof data.content !== 'string' || typeof data.sha256 !== 'string') return;
|
|
136
|
+
const buf = Buffer.from(data.content, 'base64');
|
|
137
|
+
if (createHash('sha256').update(buf).digest('hex') !== data.sha256) return;
|
|
138
|
+
const text = buf.toString('utf-8');
|
|
139
|
+
// Sanity gate: must look like THIS guard hook before we overwrite ourselves.
|
|
140
|
+
if (!text.startsWith('#!/usr/bin/env node') || text.length < 50000 || !text.includes('SolonGate Cloud Policy Guard')) return;
|
|
141
|
+
const hooksDir = join(sgDir, 'hooks');
|
|
142
|
+
const tmp = join(hooksDir, '.guard.mjs.tmp');
|
|
143
|
+
writeFileSync(tmp, text);
|
|
144
|
+
try { chmodSync(join(hooksDir, 'guard.mjs'), 0o644); } catch { /* may be locked read-only */ }
|
|
145
|
+
renameSync(tmp, join(hooksDir, 'guard.mjs')); // atomic swap, takes effect next call
|
|
146
|
+
} catch { /* never break enforcement on update failure */ }
|
|
147
|
+
}
|
|
148
|
+
|
|
91
149
|
// Two distinct identities, deliberately kept separate:
|
|
92
150
|
//
|
|
93
151
|
// AGENT_TYPE — the real AI client running this hook (claude-code /
|
|
@@ -410,14 +468,26 @@ function extractCommands(args) {
|
|
|
410
468
|
return cmds;
|
|
411
469
|
}
|
|
412
470
|
|
|
413
|
-
function extractPaths(args) {
|
|
471
|
+
function extractPaths(args, isExec) {
|
|
414
472
|
const paths = [];
|
|
415
|
-
|
|
416
|
-
if (/^https?:\/\//i.test(
|
|
473
|
+
const add = (t) => {
|
|
474
|
+
if (!t || /^https?:\/\//i.test(t)) return;
|
|
417
475
|
// Normalize Windows backslashes to forward slashes so paths match the
|
|
418
476
|
// compiled Rego patterns (which are also normalized to "/"). OPA glob.match
|
|
419
477
|
// does no separator translation, so raw "C:\..." never matched "/" patterns.
|
|
420
|
-
if (
|
|
478
|
+
if (t.includes('/') || t.includes('\\') || t.startsWith('.')) paths.push(t.replace(/\\/g, '/'));
|
|
479
|
+
};
|
|
480
|
+
for (const s of scanStrings(args)) {
|
|
481
|
+
if (/^https?:\/\//i.test(s)) continue;
|
|
482
|
+
if (isExec && /\s/.test(s)) {
|
|
483
|
+
// A command line (exec tool): pull out individual path-like tokens instead
|
|
484
|
+
// of treating the whole command as one path. Otherwise `node src/app.js`
|
|
485
|
+
// becomes the path "node src/app.js", which no path glob can match — so a
|
|
486
|
+
// path-scoped EXECUTE rule would never fire. Tokenizing yields "src/app.js".
|
|
487
|
+
for (const tok of s.split(/[\s;|&><()`'"]+/)) add(tok);
|
|
488
|
+
} else {
|
|
489
|
+
add(s);
|
|
490
|
+
}
|
|
421
491
|
}
|
|
422
492
|
return paths;
|
|
423
493
|
}
|
|
@@ -456,6 +526,7 @@ const TAMPER_PROTECTED_GLOBS = [
|
|
|
456
526
|
'**' + TAMPER_SG + '/.pi-config-cache.json',
|
|
457
527
|
'**' + TAMPER_SG + '/cloud-guard.json',
|
|
458
528
|
'**' + TAMPER_SG + '/.opa-wasm-*.json',
|
|
529
|
+
'**' + TAMPER_SG + '/.ratelimit-*.json',
|
|
459
530
|
// Persistent host data (DB + audit JSONL) at ~/.solongate/data
|
|
460
531
|
'**' + TAMPER_SG + '/data/**',
|
|
461
532
|
// Customer install layout (zip extracted as solongate/)
|
|
@@ -556,6 +627,107 @@ function tamperCheck(toolName, args) {
|
|
|
556
627
|
return null;
|
|
557
628
|
}
|
|
558
629
|
|
|
630
|
+
// ── Extra security layers (rate limit, egress allowlist, DLP block) ──
|
|
631
|
+
// These are configured per-project in the dashboard and delivered to the guard
|
|
632
|
+
// via /policies/active (security). All fail OPEN: any error here returns null
|
|
633
|
+
// (allow) so a config glitch never bricks the agent. Tamper protection and
|
|
634
|
+
// policy are unaffected and still run.
|
|
635
|
+
|
|
636
|
+
// DLP patterns mirror the server's set (apps/api/src/lib/security-layers.ts).
|
|
637
|
+
// Mirrors apps/api/src/lib/security-layers.ts DLP_PATTERNS. Provider-specific
|
|
638
|
+
// rules plus generic Bearer / secret-assignment catch-alls for the long tail.
|
|
639
|
+
const DLP_PATTERNS = [
|
|
640
|
+
{ name: 'AWS access key', re: /AKIA[0-9A-Z]{16}/ },
|
|
641
|
+
{ name: 'private key block', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
|
|
642
|
+
{ name: 'Anthropic key', re: /sk-ant-[A-Za-z0-9_-]{20,}/ },
|
|
643
|
+
{ name: 'OpenAI key', re: /sk-(proj-)?[A-Za-z0-9_-]{20,}/ },
|
|
644
|
+
{ name: 'GitHub token', re: /gh[pousr]_[A-Za-z0-9]{20,}/ },
|
|
645
|
+
{ name: 'GitHub fine-grained PAT', re: /github_pat_[A-Za-z0-9_]{20,}/ },
|
|
646
|
+
{ name: 'GitLab token', re: /glpat-[A-Za-z0-9_-]{20,}/ },
|
|
647
|
+
{ name: 'Slack token', re: /xox[baprs]-[A-Za-z0-9-]{10,}/ },
|
|
648
|
+
{ name: 'Google API key', re: /AIza[0-9A-Za-z_-]{35}/ },
|
|
649
|
+
{ name: 'Stripe key', re: /[sr]k_(live|test)_[A-Za-z0-9]{20,}/ },
|
|
650
|
+
{ name: 'SendGrid key', re: /SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/ },
|
|
651
|
+
{ name: 'Twilio key', re: /SK[0-9a-fA-F]{32}/ },
|
|
652
|
+
{ name: 'npm token', re: /npm_[A-Za-z0-9]{36}/ },
|
|
653
|
+
{ name: 'JWT', re: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
|
|
654
|
+
{ name: 'Bearer token', re: /bearer\s+[A-Za-z0-9._-]{20,}/i },
|
|
655
|
+
{ name: 'secret assignment', re: /(api[_-]?key|secret|token|password|passwd|access[_-]?key)["']?\s*[:=]\s*["']?[A-Za-z0-9/+_.-]{12,}/i },
|
|
656
|
+
];
|
|
657
|
+
|
|
658
|
+
// Scan args against the enabled built-in patterns + any user custom patterns.
|
|
659
|
+
// `cfg` = { patterns: string[], custom: {name,re}[] }.
|
|
660
|
+
function dlpScan(args, cfg) {
|
|
661
|
+
if (!cfg) return null;
|
|
662
|
+
let text = '';
|
|
663
|
+
try { text = JSON.stringify(args || {}); } catch { return null; }
|
|
664
|
+
const allow = new Set(Array.isArray(cfg.patterns) ? cfg.patterns : []);
|
|
665
|
+
for (const p of DLP_PATTERNS) {
|
|
666
|
+
if (allow.has(p.name) && p.re.test(text)) return p.name;
|
|
667
|
+
}
|
|
668
|
+
for (const c of Array.isArray(cfg.custom) ? cfg.custom : []) {
|
|
669
|
+
try { if (new RegExp(c.re).test(text)) return c.name || 'custom pattern'; } catch { /* skip invalid */ }
|
|
670
|
+
}
|
|
671
|
+
return null;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Multi-window sliding rate limit, persisted under ~/.solongate (tamper-protected
|
|
675
|
+
// from the agent, writable by the guard). One timestamps file per agent, pruned
|
|
676
|
+
// to the last 24h and capped for performance; counts this agent's calls within
|
|
677
|
+
// each enabled window (minute/hour/day). Returns the exceeded window or null.
|
|
678
|
+
const RL_WINDOWS = [
|
|
679
|
+
{ key: 'perDay', ms: 86400000, label: 'day' },
|
|
680
|
+
{ key: 'perHour', ms: 3600000, label: 'hour' },
|
|
681
|
+
{ key: 'perMinute', ms: 60000, label: 'minute' },
|
|
682
|
+
];
|
|
683
|
+
function rateLimitCheck(agentKey, limits) {
|
|
684
|
+
try {
|
|
685
|
+
const file = join(resolve(homedir(), '.solongate'), '.ratelimit-' + agentKey + '.json');
|
|
686
|
+
const now = Date.now();
|
|
687
|
+
let stamps = [];
|
|
688
|
+
if (existsSync(file)) {
|
|
689
|
+
try { stamps = JSON.parse(readFileSync(file, 'utf-8')); } catch { stamps = []; }
|
|
690
|
+
}
|
|
691
|
+
if (!Array.isArray(stamps)) stamps = [];
|
|
692
|
+
// Prune to the longest window (24h) and cap size to bound work/IO.
|
|
693
|
+
stamps = stamps.filter((t) => typeof t === 'number' && now - t < 86400000);
|
|
694
|
+
if (stamps.length > 50000) stamps = stamps.slice(-50000);
|
|
695
|
+
// Check each enabled window against the current count (before adding now).
|
|
696
|
+
for (const w of RL_WINDOWS) {
|
|
697
|
+
const limit = limits[w.key];
|
|
698
|
+
if (limit > 0) {
|
|
699
|
+
const count = stamps.reduce((n, t) => (now - t < w.ms ? n + 1 : n), 0);
|
|
700
|
+
if (count >= limit) return { window: w.label, limit };
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
stamps.push(now);
|
|
704
|
+
try { writeFileSync(file, JSON.stringify(stamps)); } catch {}
|
|
705
|
+
return null;
|
|
706
|
+
} catch {
|
|
707
|
+
return null; // fail open
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Runs all enabled enforcement layers; returns a deny reason or null (allow).
|
|
712
|
+
function securityLayerCheck(toolName, args, cfg, agentKey) {
|
|
713
|
+
if (!cfg) return null;
|
|
714
|
+
try {
|
|
715
|
+
if (cfg.dlpBlock) {
|
|
716
|
+
const hit = dlpScan(args, cfg.dlpBlock);
|
|
717
|
+
if (hit) return 'Security layer (DLP): blocked - arguments contain a ' + hit +
|
|
718
|
+
'. Blocked by SolonGate - check your dashboard for details.';
|
|
719
|
+
}
|
|
720
|
+
if (cfg.rateLimit) {
|
|
721
|
+
const hit = rateLimitCheck(agentKey, cfg.rateLimit);
|
|
722
|
+
if (hit) {
|
|
723
|
+
return 'Security layer (rate limit): exceeded ' + hit.limit + ' calls/' + hit.window +
|
|
724
|
+
' for this agent. Blocked by SolonGate - check your dashboard to review or adjust the limit.';
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
} catch { /* fail open */ }
|
|
728
|
+
return null;
|
|
729
|
+
}
|
|
730
|
+
|
|
559
731
|
// ── Policy Evaluation ──
|
|
560
732
|
|
|
561
733
|
// Permission filter: a rule with rule.permission set only applies to tool
|
|
@@ -582,7 +754,7 @@ function patternsOf(constraint) {
|
|
|
582
754
|
return Array.isArray(list) && list.length > 0 ? list : null;
|
|
583
755
|
}
|
|
584
756
|
|
|
585
|
-
function ruleMatches(rule, args) {
|
|
757
|
+
function ruleMatches(rule, args, isExec) {
|
|
586
758
|
const fnPats = patternsOf(rule.filenameConstraints);
|
|
587
759
|
if (fnPats) {
|
|
588
760
|
const filenames = extractFilenames(args);
|
|
@@ -612,7 +784,7 @@ function ruleMatches(rule, args) {
|
|
|
612
784
|
}
|
|
613
785
|
const pathPats = patternsOf(rule.pathConstraints);
|
|
614
786
|
if (pathPats) {
|
|
615
|
-
const paths = extractPaths(args);
|
|
787
|
+
const paths = extractPaths(args, isExec);
|
|
616
788
|
for (const p of paths) {
|
|
617
789
|
for (const pat of pathPats) {
|
|
618
790
|
if (matchPathGlob(p, pat)) return { kind: 'path', value: p, pattern: pat };
|
|
@@ -630,13 +802,14 @@ function evaluate(policy, args, toolName) {
|
|
|
630
802
|
if (!policy || !policy.rules) return null;
|
|
631
803
|
const enabledRules = policy.rules.filter(r => r.enabled !== false);
|
|
632
804
|
const mode = policy.mode === 'whitelist' ? 'whitelist' : 'denylist';
|
|
805
|
+
const isExec = /bash|shell|exec|powershell|cmd|run|eval/.test((toolName || '').toLowerCase());
|
|
633
806
|
|
|
634
807
|
// DENY pass — runs in both modes. DENY wins over ALLOW.
|
|
635
808
|
const denyRules = enabledRules
|
|
636
809
|
.filter(r => r.effect === 'DENY' && permissionApplies(r, toolName))
|
|
637
810
|
.sort((a, b) => (a.priority || 100) - (b.priority || 100));
|
|
638
811
|
for (const rule of denyRules) {
|
|
639
|
-
const m = ruleMatches(rule, args);
|
|
812
|
+
const m = ruleMatches(rule, args, isExec);
|
|
640
813
|
if (m) return 'Blocked by policy: ' + m.kind + ' "' + m.value + '" matches "' + m.pattern + '"';
|
|
641
814
|
}
|
|
642
815
|
|
|
@@ -648,7 +821,7 @@ function evaluate(policy, args, toolName) {
|
|
|
648
821
|
}
|
|
649
822
|
let matched = false;
|
|
650
823
|
for (const rule of allowRules) {
|
|
651
|
-
if (ruleMatches(rule, args)) { matched = true; break; }
|
|
824
|
+
if (ruleMatches(rule, args, isExec)) { matched = true; break; }
|
|
652
825
|
}
|
|
653
826
|
if (!matched) {
|
|
654
827
|
return 'Blocked by policy: strict whitelist mode — request does not match any ALLOW rule';
|
|
@@ -835,7 +1008,7 @@ async function evaluateWithOpa(policy, args, toolName, cwd) {
|
|
|
835
1008
|
permission: guessPermission(toolName),
|
|
836
1009
|
trust_level: 'TRUSTED',
|
|
837
1010
|
arguments: expandedArgs,
|
|
838
|
-
paths: extractPaths(accessArgs),
|
|
1011
|
+
paths: extractPaths(accessArgs, isExecTool),
|
|
839
1012
|
commands: extractCommands(expandedArgs),
|
|
840
1013
|
urls: extractUrls(accessArgs),
|
|
841
1014
|
filenames: extractFilenames(accessArgs),
|
|
@@ -984,32 +1157,44 @@ process.stdin.on('end', async () => {
|
|
|
984
1157
|
// fallback for when the API is unreachable.
|
|
985
1158
|
const hookCwd = data.cwd || process.cwd();
|
|
986
1159
|
let policy;
|
|
1160
|
+
// Self-protection (tamper guard) defaults ON. The cloud per-project setting
|
|
1161
|
+
// can turn it off; delivered via /policies/active and cached alongside the
|
|
1162
|
+
// policy. Any failure to read it leaves protection ON (fail safe).
|
|
1163
|
+
let selfProtectEnabled = true;
|
|
1164
|
+
// Extra security layers (rate limit, egress, DLP block) delivered by the
|
|
1165
|
+
// cloud. Null = none configured. Fail open if unread.
|
|
1166
|
+
let securityCfg = null;
|
|
987
1167
|
// Cache keyed by agent_id so different agents in different terminals
|
|
988
1168
|
// don't share a stale cached policy.
|
|
989
1169
|
const agentKey = (AGENT_ID || 'default').replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
990
1170
|
const policyCacheFile = join(resolve(homedir(), '.solongate'), '.policy-cache-' + agentKey + '.json');
|
|
991
|
-
const POLICY_TTL_MS =
|
|
1171
|
+
const POLICY_TTL_MS = 3_000;
|
|
992
1172
|
try {
|
|
993
1173
|
let dashboardPolicy = null;
|
|
994
1174
|
// Try cache first
|
|
995
1175
|
try {
|
|
996
1176
|
if (existsSync(policyCacheFile)) {
|
|
997
1177
|
const cached = JSON.parse(readFileSync(policyCacheFile, 'utf-8'));
|
|
998
|
-
if (cached && cached._ts && Date.now() - cached._ts < POLICY_TTL_MS
|
|
999
|
-
dashboardPolicy = cached.policy;
|
|
1178
|
+
if (cached && cached._ts && Date.now() - cached._ts < POLICY_TTL_MS) {
|
|
1179
|
+
if (cached.policy) dashboardPolicy = cached.policy;
|
|
1180
|
+
if (typeof cached.selfProtect === 'boolean') selfProtectEnabled = cached.selfProtect;
|
|
1181
|
+
if (cached.security !== undefined) securityCfg = cached.security;
|
|
1000
1182
|
}
|
|
1001
1183
|
}
|
|
1002
1184
|
} catch {}
|
|
1003
1185
|
// Refresh from API if cache expired
|
|
1004
1186
|
if (!dashboardPolicy) {
|
|
1005
1187
|
try {
|
|
1006
|
-
|
|
1188
|
+
// Report our installed version (hv) so the dashboard can show whether
|
|
1189
|
+
// this guard is on the latest build.
|
|
1190
|
+
const res = await fetch(API_URL + '/api/v1/policies/active?agent_id=' + encodeURIComponent(AGENT_ID || '') + '&hv=' + HOOK_VERSION, { headers: AUTH_HEADERS, signal: AbortSignal.timeout(8000) });
|
|
1007
1191
|
if (res.ok) {
|
|
1008
1192
|
const body = await res.json();
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1193
|
+
// Capture the self-protection flag even when no cloud policy is set.
|
|
1194
|
+
if (typeof body?.self_protection_enabled === 'boolean') selfProtectEnabled = body.self_protection_enabled;
|
|
1195
|
+
if (body?.security !== undefined) securityCfg = body.security;
|
|
1196
|
+
if (body && body.policy) dashboardPolicy = body.policy;
|
|
1197
|
+
try { writeFileSync(policyCacheFile, JSON.stringify({ _ts: Date.now(), policy: dashboardPolicy || null, selfProtect: selfProtectEnabled, security: securityCfg })); } catch {}
|
|
1013
1198
|
}
|
|
1014
1199
|
} catch {}
|
|
1015
1200
|
}
|
|
@@ -1050,16 +1235,21 @@ process.stdin.on('end', async () => {
|
|
|
1050
1235
|
|
|
1051
1236
|
if (process.env.SOLONGATE_DEBUG) {
|
|
1052
1237
|
}
|
|
1053
|
-
//
|
|
1054
|
-
|
|
1238
|
+
// Tamper / self-protection — runs before policy eval. ON by default; the
|
|
1239
|
+
// per-project cloud setting can disable it (fail safe: stays on if unread).
|
|
1240
|
+
let reason = selfProtectEnabled ? tamperCheck(toolName, args) : null;
|
|
1241
|
+
// Extra security layers run after tamper, before policy. Block reason wins
|
|
1242
|
+
// immediately (BLACK). Fail-open by design.
|
|
1243
|
+
if (!reason) reason = securityLayerCheck(toolName, args, securityCfg, agentKey);
|
|
1055
1244
|
if (process.env.SOLONGATE_DEBUG) {
|
|
1056
1245
|
}
|
|
1057
1246
|
// OPA WASM is the SOLE policy engine. With no policy configured for this
|
|
1058
1247
|
// agent we skip evaluation entirely (allow). With a policy present,
|
|
1059
1248
|
// evaluateWithOpa returns a reason (DENY), null (ALLOW), or undefined when
|
|
1060
|
-
// the WASM bundle could not be obtained at all — in which case we
|
|
1061
|
-
//
|
|
1062
|
-
//
|
|
1249
|
+
// the WASM bundle could not be obtained at all — in which case we fall back
|
|
1250
|
+
// to the policy mode's default (whitelist → fail closed, denylist → fail
|
|
1251
|
+
// open); see the branch below. (The legacy JS evaluate() below is retained
|
|
1252
|
+
// but no longer on the decision path — OPA decides everything.)
|
|
1063
1253
|
// Cloud routing is BINARY — WHITE (allow) / BLACK (block). There is NO AI
|
|
1064
1254
|
// Judge in the cloud (that is an air-gap-only feature), so there is no GRAY
|
|
1065
1255
|
// "send to the judge" lane: the OPA policy alone decides. Tamper protection
|
|
@@ -1071,8 +1261,23 @@ process.stdin.on('end', async () => {
|
|
|
1071
1261
|
} else if (policy && policy.rules) {
|
|
1072
1262
|
const opaResult = await evaluateWithOpa(policy, args, toolName, hookCwd);
|
|
1073
1263
|
if (opaResult === undefined) {
|
|
1074
|
-
|
|
1075
|
-
|
|
1264
|
+
// OPA produced no decision (no WASM bundle yet, runtime missing, fetch
|
|
1265
|
+
// error). This happens on COLD START — the first call(s) in a session
|
|
1266
|
+
// before the policy + WASM are cached. Don't leave an enforcement gap:
|
|
1267
|
+
// run the deterministic, WASM-free JS evaluator so DENY rules (e.g.
|
|
1268
|
+
// secret-file protection) and whitelist defaults apply IMMEDIATELY, from
|
|
1269
|
+
// the very first call. evaluate() implements both modes:
|
|
1270
|
+
// - returns a deny reason → block (DENY match, or whitelist no-match)
|
|
1271
|
+
// - returns null → allow (denylist default / whitelist match)
|
|
1272
|
+
// This closes the "worked, but late" window where a denylist policy used
|
|
1273
|
+
// to fail OPEN until WASM warmed up.
|
|
1274
|
+
const legacy = evaluate(policy, args, toolName);
|
|
1275
|
+
if (typeof legacy === 'string') {
|
|
1276
|
+
reason = legacy;
|
|
1277
|
+
opaRoute = 'black';
|
|
1278
|
+
} else {
|
|
1279
|
+
opaRoute = 'white';
|
|
1280
|
+
}
|
|
1076
1281
|
} else if (typeof opaResult === 'string') {
|
|
1077
1282
|
reason = opaResult; // explicit DENY
|
|
1078
1283
|
opaRoute = 'black';
|
|
@@ -1106,8 +1311,10 @@ process.stdin.on('end', async () => {
|
|
|
1106
1311
|
} catch {}
|
|
1107
1312
|
}
|
|
1108
1313
|
writeDenyFlag(toolName);
|
|
1314
|
+
await maybeSelfUpdate();
|
|
1109
1315
|
blockTool(reason);
|
|
1110
1316
|
}
|
|
1111
1317
|
} catch {}
|
|
1318
|
+
await maybeSelfUpdate();
|
|
1112
1319
|
allowTool();
|
|
1113
1320
|
});
|
package/package.json
CHANGED
|
@@ -1,76 +1,76 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@solongate/proxy",
|
|
3
|
-
"version": "0.47.
|
|
4
|
-
"description": "AI tool security proxy — protect any AI tool server with customizable policies, path/command constraints, rate limiting, and audit logging. Zero code changes required.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
"solongate": "./dist/index.js",
|
|
8
|
-
"solongate-proxy": "./dist/index.js",
|
|
9
|
-
"solongate-init": "./dist/init.js",
|
|
10
|
-
"proxy": "./dist/index.js"
|
|
11
|
-
},
|
|
12
|
-
"main": "./dist/lib.js",
|
|
13
|
-
"exports": {
|
|
14
|
-
".": {
|
|
15
|
-
"import": "./dist/lib.js"
|
|
16
|
-
},
|
|
17
|
-
"./cli": {
|
|
18
|
-
"import": "./dist/index.js"
|
|
19
|
-
}
|
|
20
|
-
},
|
|
21
|
-
"files": [
|
|
22
|
-
"dist",
|
|
23
|
-
"hooks",
|
|
24
|
-
"README.md"
|
|
25
|
-
],
|
|
26
|
-
"scripts": {
|
|
27
|
-
"build": "tsup && pnpm build:hooks",
|
|
28
|
-
"build:hooks": "node scripts/bundle-hooks.mjs",
|
|
29
|
-
"dev": "tsx src/index.ts",
|
|
30
|
-
"typecheck": "tsc --noEmit",
|
|
31
|
-
"clean": "rm -rf dist .turbo"
|
|
32
|
-
},
|
|
33
|
-
"keywords": [
|
|
34
|
-
"ai-tool-security",
|
|
35
|
-
"ai-tool-proxy",
|
|
36
|
-
"security",
|
|
37
|
-
"proxy",
|
|
38
|
-
"gateway",
|
|
39
|
-
"firewall",
|
|
40
|
-
"ai-security",
|
|
41
|
-
"tool-security",
|
|
42
|
-
"claude",
|
|
43
|
-
"openclaw",
|
|
44
|
-
"solongate",
|
|
45
|
-
"prompt-injection",
|
|
46
|
-
"path-traversal",
|
|
47
|
-
"rate-limiting"
|
|
48
|
-
],
|
|
49
|
-
"author": "SolonGate <hello@solongate.com>",
|
|
50
|
-
"license": "MIT",
|
|
51
|
-
"homepage": "https://solongate.com",
|
|
52
|
-
"repository": {
|
|
53
|
-
"type": "git",
|
|
54
|
-
"url": "https://github.com/solongate/solongate"
|
|
55
|
-
},
|
|
56
|
-
"engines": {
|
|
57
|
-
"node": ">=18.0.0"
|
|
58
|
-
},
|
|
59
|
-
"dependencies": {
|
|
60
|
-
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
61
|
-
"zod": "^3.25.0"
|
|
62
|
-
},
|
|
63
|
-
"optionalDependencies": {
|
|
64
|
-
"@huggingface/transformers": ">=3.0.0"
|
|
65
|
-
},
|
|
66
|
-
"devDependencies": {
|
|
67
|
-
"@open-policy-agent/opa-wasm": "^1.10.0",
|
|
68
|
-
"@solongate/core": "workspace:*",
|
|
69
|
-
"@solongate/policy-engine": "workspace:*",
|
|
70
|
-
"@solongate/tsconfig": "workspace:*",
|
|
71
|
-
"esbuild": "^0.19.12",
|
|
72
|
-
"tsup": "^8.3.0",
|
|
73
|
-
"tsx": "^4.19.0",
|
|
74
|
-
"typescript": "^5.7.0"
|
|
75
|
-
}
|
|
76
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@solongate/proxy",
|
|
3
|
+
"version": "0.47.2",
|
|
4
|
+
"description": "AI tool security proxy — protect any AI tool server with customizable policies, path/command constraints, rate limiting, and audit logging. Zero code changes required.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"solongate": "./dist/index.js",
|
|
8
|
+
"solongate-proxy": "./dist/index.js",
|
|
9
|
+
"solongate-init": "./dist/init.js",
|
|
10
|
+
"proxy": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"main": "./dist/lib.js",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"import": "./dist/lib.js"
|
|
16
|
+
},
|
|
17
|
+
"./cli": {
|
|
18
|
+
"import": "./dist/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"hooks",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsup && pnpm build:hooks",
|
|
28
|
+
"build:hooks": "node scripts/bundle-hooks.mjs",
|
|
29
|
+
"dev": "tsx src/index.ts",
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"clean": "rm -rf dist .turbo"
|
|
32
|
+
},
|
|
33
|
+
"keywords": [
|
|
34
|
+
"ai-tool-security",
|
|
35
|
+
"ai-tool-proxy",
|
|
36
|
+
"security",
|
|
37
|
+
"proxy",
|
|
38
|
+
"gateway",
|
|
39
|
+
"firewall",
|
|
40
|
+
"ai-security",
|
|
41
|
+
"tool-security",
|
|
42
|
+
"claude",
|
|
43
|
+
"openclaw",
|
|
44
|
+
"solongate",
|
|
45
|
+
"prompt-injection",
|
|
46
|
+
"path-traversal",
|
|
47
|
+
"rate-limiting"
|
|
48
|
+
],
|
|
49
|
+
"author": "SolonGate <hello@solongate.com>",
|
|
50
|
+
"license": "MIT",
|
|
51
|
+
"homepage": "https://solongate.com",
|
|
52
|
+
"repository": {
|
|
53
|
+
"type": "git",
|
|
54
|
+
"url": "https://github.com/solongate/solongate"
|
|
55
|
+
},
|
|
56
|
+
"engines": {
|
|
57
|
+
"node": ">=18.0.0"
|
|
58
|
+
},
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
61
|
+
"zod": "^3.25.0"
|
|
62
|
+
},
|
|
63
|
+
"optionalDependencies": {
|
|
64
|
+
"@huggingface/transformers": ">=3.0.0"
|
|
65
|
+
},
|
|
66
|
+
"devDependencies": {
|
|
67
|
+
"@open-policy-agent/opa-wasm": "^1.10.0",
|
|
68
|
+
"@solongate/core": "workspace:*",
|
|
69
|
+
"@solongate/policy-engine": "workspace:*",
|
|
70
|
+
"@solongate/tsconfig": "workspace:*",
|
|
71
|
+
"esbuild": "^0.19.12",
|
|
72
|
+
"tsup": "^8.3.0",
|
|
73
|
+
"tsx": "^4.19.0",
|
|
74
|
+
"typescript": "^5.7.0"
|
|
75
|
+
}
|
|
76
|
+
}
|