@solongate/proxy 0.8.2 → 0.8.3

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.
@@ -0,0 +1,264 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SolonGate Policy Guard Hook (PreToolUse)
4
+ * Reads policy.json and blocks tool calls that violate constraints.
5
+ * Exit code 2 = BLOCK, exit code 0 = ALLOW.
6
+ * Logs ALL decisions (ALLOW + DENY) to SolonGate Cloud.
7
+ * Auto-installed by: npx @solongate/proxy init
8
+ */
9
+ import { readFileSync, existsSync } from 'node:fs';
10
+ import { resolve } from 'node:path';
11
+
12
+ // ── Load API key from .env file (Claude Code doesn't load .env into process.env) ──
13
+ function loadEnvKey(dir) {
14
+ try {
15
+ const envPath = resolve(dir, '.env');
16
+ if (!existsSync(envPath)) return {};
17
+ const lines = readFileSync(envPath, 'utf-8').split('\n');
18
+ const env = {};
19
+ for (const line of lines) {
20
+ const m = line.match(/^([A-Z_]+)=(.*)$/);
21
+ if (m) env[m[1]] = m[2].replace(/^["']|["']$/g, '').trim();
22
+ }
23
+ return env;
24
+ } catch { return {}; }
25
+ }
26
+
27
+ const hookCwdEarly = process.cwd();
28
+ const dotenv = loadEnvKey(hookCwdEarly);
29
+ const API_KEY = process.env.SOLONGATE_API_KEY || dotenv.SOLONGATE_API_KEY || '';
30
+ const API_URL = process.env.SOLONGATE_API_URL || dotenv.SOLONGATE_API_URL || 'https://api.solongate.com';
31
+
32
+ // ── Glob Matching ──
33
+ function matchGlob(str, pattern) {
34
+ if (pattern === '*') return true;
35
+ const s = str.toLowerCase();
36
+ const p = pattern.toLowerCase();
37
+ if (s === p) return true;
38
+ const startsW = p.startsWith('*');
39
+ const endsW = p.endsWith('*');
40
+ if (startsW && endsW) { const infix = p.slice(1, -1); return infix.length > 0 && s.includes(infix); }
41
+ if (startsW) return s.endsWith(p.slice(1));
42
+ if (endsW) return s.startsWith(p.slice(0, -1));
43
+ const idx = p.indexOf('*');
44
+ if (idx !== -1) {
45
+ const pre = p.slice(0, idx);
46
+ const suf = p.slice(idx + 1);
47
+ return s.startsWith(pre) && s.endsWith(suf) && s.length >= pre.length + suf.length;
48
+ }
49
+ return false;
50
+ }
51
+
52
+ // ── Path Glob (supports **) ──
53
+ function matchPathGlob(path, pattern) {
54
+ const p = path.replace(/\\/g, '/').toLowerCase();
55
+ const g = pattern.replace(/\\/g, '/').toLowerCase();
56
+ if (p === g) return true;
57
+ if (g.includes('**')) {
58
+ const parts = g.split('**').filter(s => s.length > 0);
59
+ if (parts.length === 0) return true; // just ** or ****
60
+ return parts.every(segment => p.includes(segment));
61
+ }
62
+ return matchGlob(p, g);
63
+ }
64
+
65
+ // ── Extract Functions (deep scan all string values) ──
66
+ function scanStrings(obj) {
67
+ const strings = [];
68
+ function walk(v) {
69
+ if (typeof v === 'string' && v.trim()) strings.push(v.trim());
70
+ else if (Array.isArray(v)) v.forEach(walk);
71
+ else if (v && typeof v === 'object') Object.values(v).forEach(walk);
72
+ }
73
+ walk(obj);
74
+ return strings;
75
+ }
76
+
77
+ function looksLikeFilename(s) {
78
+ if (s.startsWith('.')) return true;
79
+ if (/\.\w+$/.test(s)) return true;
80
+ const known = ['id_rsa','id_dsa','id_ecdsa','id_ed25519','authorized_keys','known_hosts','makefile','dockerfile'];
81
+ return known.includes(s.toLowerCase());
82
+ }
83
+
84
+ function extractFilenames(args) {
85
+ const names = new Set();
86
+ for (const s of scanStrings(args)) {
87
+ if (/^https?:\/\//i.test(s)) continue;
88
+ if (s.includes('/') || s.includes('\\')) {
89
+ const base = s.replace(/\\/g, '/').split('/').pop();
90
+ if (base) names.add(base);
91
+ continue;
92
+ }
93
+ if (s.includes(' ')) {
94
+ for (const tok of s.split(/\s+/)) {
95
+ if (tok.includes('/') || tok.includes('\\')) {
96
+ const b = tok.replace(/\\/g, '/').split('/').pop();
97
+ if (b && looksLikeFilename(b)) names.add(b);
98
+ } else if (looksLikeFilename(tok)) names.add(tok);
99
+ }
100
+ continue;
101
+ }
102
+ if (looksLikeFilename(s)) names.add(s);
103
+ }
104
+ return [...names];
105
+ }
106
+
107
+ function extractUrls(args) {
108
+ const urls = new Set();
109
+ for (const s of scanStrings(args)) {
110
+ if (/^https?:\/\//i.test(s)) { urls.add(s); continue; }
111
+ if (s.includes(' ')) {
112
+ for (const tok of s.split(/\s+/)) {
113
+ if (/^https?:\/\//i.test(tok)) urls.add(tok);
114
+ }
115
+ }
116
+ }
117
+ return [...urls];
118
+ }
119
+
120
+ function extractCommands(args) {
121
+ const cmds = [];
122
+ const fields = ['command', 'cmd', 'function', 'script', 'shell'];
123
+ if (typeof args === 'object' && args) {
124
+ for (const [k, v] of Object.entries(args)) {
125
+ if (fields.includes(k.toLowerCase()) && typeof v === 'string') {
126
+ // Split chained commands: cd /path && npm install → [cd /path, npm install]
127
+ for (const part of v.split(/\s*(?:&&|\|\||;|\|)\s*/)) {
128
+ const trimmed = part.trim();
129
+ if (trimmed) cmds.push(trimmed);
130
+ }
131
+ }
132
+ }
133
+ }
134
+ return cmds;
135
+ }
136
+
137
+ function extractPaths(args) {
138
+ const paths = [];
139
+ for (const s of scanStrings(args)) {
140
+ if (/^https?:\/\//i.test(s)) continue;
141
+ if (s.includes('/') || s.includes('\\') || s.startsWith('.')) paths.push(s);
142
+ }
143
+ return paths;
144
+ }
145
+
146
+ // ── Policy Evaluation ──
147
+ function evaluate(policy, args) {
148
+ if (!policy || !policy.rules) return null;
149
+ const denyRules = policy.rules
150
+ .filter(r => r.effect === 'DENY' && r.enabled !== false)
151
+ .sort((a, b) => (a.priority || 100) - (b.priority || 100));
152
+
153
+ for (const rule of denyRules) {
154
+ // Filename constraints
155
+ if (rule.filenameConstraints && rule.filenameConstraints.denied) {
156
+ const filenames = extractFilenames(args);
157
+ for (const fn of filenames) {
158
+ for (const pat of rule.filenameConstraints.denied) {
159
+ if (matchGlob(fn, pat)) return 'Blocked by policy: filename "' + fn + '" matches "' + pat + '"';
160
+ }
161
+ }
162
+ }
163
+ // URL constraints
164
+ if (rule.urlConstraints && rule.urlConstraints.denied) {
165
+ const urls = extractUrls(args);
166
+ for (const url of urls) {
167
+ for (const pat of rule.urlConstraints.denied) {
168
+ if (matchGlob(url, pat)) return 'Blocked by policy: URL "' + url + '" matches "' + pat + '"';
169
+ }
170
+ }
171
+ }
172
+ // Command constraints
173
+ if (rule.commandConstraints && rule.commandConstraints.denied) {
174
+ const cmds = extractCommands(args);
175
+ for (const cmd of cmds) {
176
+ for (const pat of rule.commandConstraints.denied) {
177
+ if (matchGlob(cmd, pat)) return 'Blocked by policy: command "' + cmd.slice(0,60) + '" matches "' + pat + '"';
178
+ }
179
+ }
180
+ }
181
+ // Path constraints
182
+ if (rule.pathConstraints && rule.pathConstraints.denied) {
183
+ const paths = extractPaths(args);
184
+ for (const p of paths) {
185
+ for (const pat of rule.pathConstraints.denied) {
186
+ if (matchPathGlob(p, pat)) return 'Blocked by policy: path "' + p + '" matches "' + pat + '"';
187
+ }
188
+ }
189
+ }
190
+ }
191
+ return null;
192
+ }
193
+
194
+ // ── Main ──
195
+ let input = '';
196
+ process.stdin.on('data', c => input += c);
197
+ process.stdin.on('end', async () => {
198
+ try {
199
+ const data = JSON.parse(input);
200
+ const args = data.tool_input || {};
201
+
202
+ // ── Self-protection: block access to hook files and settings ──
203
+ const allStrings = scanStrings(args).map(s => s.replace(/\\/g, '/').toLowerCase());
204
+ const protectedPaths = ['.solongate', '.claude', '.cursor', 'policy.json', '.mcp.json'];
205
+ for (const s of allStrings) {
206
+ for (const p of protectedPaths) {
207
+ if (s.includes(p)) {
208
+ const msg = 'SOLONGATE: Access to protected file "' + p + '" is blocked';
209
+ if (API_KEY && API_KEY.startsWith('sg_live_')) {
210
+ try {
211
+ await fetch(API_URL + '/api/v1/audit-logs', {
212
+ method: 'POST',
213
+ headers: { 'Authorization': 'Bearer ' + API_KEY, 'Content-Type': 'application/json' },
214
+ body: JSON.stringify({
215
+ tool: data.tool_name || '', arguments: args,
216
+ decision: 'DENY', reason: msg,
217
+ source: 'claude-code-guard',
218
+ }),
219
+ signal: AbortSignal.timeout(3000),
220
+ });
221
+ } catch {}
222
+ }
223
+ process.stderr.write(msg);
224
+ process.exit(2);
225
+ }
226
+ }
227
+ }
228
+
229
+ // Load policy (use cwd from hook data if available)
230
+ const hookCwd = data.cwd || process.cwd();
231
+ let policy;
232
+ try {
233
+ const policyPath = resolve(hookCwd, 'policy.json');
234
+ policy = JSON.parse(readFileSync(policyPath, 'utf-8'));
235
+ } catch {
236
+ process.exit(0); // No policy = allow all
237
+ }
238
+
239
+ const reason = evaluate(policy, args);
240
+ const decision = reason ? 'DENY' : 'ALLOW';
241
+
242
+ // ── Log ALL decisions to SolonGate Cloud ──
243
+ if (API_KEY && API_KEY.startsWith('sg_live_')) {
244
+ try {
245
+ await fetch(API_URL + '/api/v1/audit-logs', {
246
+ method: 'POST',
247
+ headers: { 'Authorization': 'Bearer ' + API_KEY, 'Content-Type': 'application/json' },
248
+ body: JSON.stringify({
249
+ tool: data.tool_name || '', arguments: args,
250
+ decision, reason: reason || 'allowed by policy',
251
+ source: 'claude-code-guard',
252
+ }),
253
+ signal: AbortSignal.timeout(3000),
254
+ });
255
+ } catch {}
256
+ }
257
+
258
+ if (reason) {
259
+ process.stderr.write(reason);
260
+ process.exit(2);
261
+ }
262
+ } catch {}
263
+ process.exit(0);
264
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solongate/proxy",
3
- "version": "0.8.2",
3
+ "version": "0.8.3",
4
4
  "description": "MCP security proxy — protect any MCP server with customizable policies, path/command constraints, rate limiting, and audit logging. Zero code changes required.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,6 +16,7 @@
16
16
  },
17
17
  "files": [
18
18
  "dist",
19
+ "hooks",
19
20
  "README.md"
20
21
  ],
21
22
  "scripts": {