@solongate/proxy 0.57.0 → 0.59.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 +78 -68
- package/dist/audit/index.js +0 -0
- package/dist/create.js +0 -0
- package/dist/global-install.js +1 -1
- package/dist/index.js +341 -52
- package/dist/inject.js +0 -0
- package/dist/login.js +5 -39
- package/dist/logs-server.d.ts +1 -0
- package/dist/logs-server.js +174 -0
- package/dist/pull-push.js +0 -0
- package/dist/shield.js +106 -7
- package/hooks/.solongate/.last-deny +1 -1
- package/hooks/.solongate/.last-eval +1 -0
- package/hooks/.solongate/.last-tool-call +1 -1
- package/hooks/audit.mjs +565 -544
- package/hooks/guard.bundled.mjs +21 -2
- package/hooks/guard.mjs +1626 -1604
- package/hooks/shield.mjs +271 -271
- package/hooks/stop.mjs +75 -75
- package/package.json +74 -76
package/hooks/shield.mjs
CHANGED
|
@@ -1,271 +1,271 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* SolonGate Shield (standalone hook) — secret redaction on the LLM request path.
|
|
4
|
-
*
|
|
5
|
-
* Wraps a command (the real `claude`) with a local proxy that masks secrets in
|
|
6
|
-
* the request body BEFORE it reaches the Anthropic API, so the model never sees
|
|
7
|
-
* them. The model's RESPONSE is streamed back untouched. Lifecycle is tied to
|
|
8
|
-
* the wrapped process — no daemon. FAIL OPEN everywhere: any parse/redact/upstream
|
|
9
|
-
* error forwards the original bytes rather than breaking the model connection.
|
|
10
|
-
*
|
|
11
|
-
* Installed to ~/.solongate/hooks/shield.mjs and invoked by the `claude` shim
|
|
12
|
-
* that `login` adds to the shell so every terminal session is masked automatically:
|
|
13
|
-
* node ~/.solongate/hooks/shield.mjs -- "<real claude>" <args...>
|
|
14
|
-
*
|
|
15
|
-
* Logic mirrors src/shield.ts — keep the two in sync.
|
|
16
|
-
*/
|
|
17
|
-
import { createServer, request as httpRequest } from 'node:http';
|
|
18
|
-
import { request as httpsRequest } from 'node:https';
|
|
19
|
-
import { spawn } from 'node:child_process';
|
|
20
|
-
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
21
|
-
import { resolve } from 'node:path';
|
|
22
|
-
import { homedir } from 'node:os';
|
|
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
|
-
|
|
28
|
-
const log = (...a) => process.stderr.write(`[SolonGate shield] ${a.map(String).join(' ')}\n`);
|
|
29
|
-
|
|
30
|
-
const DLP_PATTERNS = [
|
|
31
|
-
{ name: 'AWS access key', re: /AKIA[0-9A-Z]{16}/g },
|
|
32
|
-
{ name: 'private key block', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g },
|
|
33
|
-
{ name: 'Anthropic key', re: /sk-ant-[A-Za-z0-9_-]{20,}/g },
|
|
34
|
-
{ name: 'OpenAI key', re: /sk-(proj-)?[A-Za-z0-9_-]{20,}/g },
|
|
35
|
-
{ name: 'GitHub token', re: /gh[pousr]_[A-Za-z0-9]{20,}/g },
|
|
36
|
-
{ name: 'GitHub fine-grained PAT', re: /github_pat_[A-Za-z0-9_]{20,}/g },
|
|
37
|
-
{ name: 'GitLab token', re: /glpat-[A-Za-z0-9_-]{20,}/g },
|
|
38
|
-
{ name: 'Slack token', re: /xox[baprs]-[A-Za-z0-9-]{10,}/g },
|
|
39
|
-
{ name: 'Stripe key', re: /[sr]k_(live|test)_[A-Za-z0-9]{20,}/g },
|
|
40
|
-
{ name: 'SendGrid key', re: /SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/g },
|
|
41
|
-
{ name: 'Twilio key', re: /SK[0-9a-fA-F]{32}/g },
|
|
42
|
-
{ name: 'npm token', re: /npm_[A-Za-z0-9]{36}/g },
|
|
43
|
-
{ name: 'JWT', re: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g },
|
|
44
|
-
{ name: 'Bearer token', re: /bearer\s+[A-Za-z0-9._-]{20,}/gi },
|
|
45
|
-
];
|
|
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
|
-
|
|
72
|
-
function loadCfg() {
|
|
73
|
-
try {
|
|
74
|
-
const f = findCacheFile();
|
|
75
|
-
if (f && existsSync(f)) {
|
|
76
|
-
const c = JSON.parse(readFileSync(f, 'utf-8'));
|
|
77
|
-
const d = c && c.security && c.security.dlpRedact;
|
|
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 };
|
|
82
|
-
}
|
|
83
|
-
} catch { /* default below */ }
|
|
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');
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// Custom patterns are GLOBs: `*` = any run of non-whitespace, same as policy/ghost.
|
|
164
|
-
function dlpGlobToRe(glob, flags) {
|
|
165
|
-
let re = '';
|
|
166
|
-
for (const ch of String(glob || '')) {
|
|
167
|
-
if (ch === '*') re += '[^\\s]*';
|
|
168
|
-
else if ('.+?^${}()|[]\\'.indexOf(ch) !== -1) re += '\\' + ch;
|
|
169
|
-
else re += ch;
|
|
170
|
-
}
|
|
171
|
-
return new RegExp(re, flags);
|
|
172
|
-
}
|
|
173
|
-
function redactString(s, cfg) {
|
|
174
|
-
if (!cfg || typeof s !== 'string' || !s) return s;
|
|
175
|
-
const allow = new Set(cfg.patterns);
|
|
176
|
-
let out = s;
|
|
177
|
-
for (const p of DLP_PATTERNS) if (allow.has(p.name)) out = out.replace(p.re, `[REDACTED: ${p.name}]`);
|
|
178
|
-
for (const c of cfg.custom) {
|
|
179
|
-
try { out = out.replace(dlpGlobToRe(c.re, 'g'), `[REDACTED: ${c.name || 'custom'}]`); } catch { /* skip */ }
|
|
180
|
-
}
|
|
181
|
-
return out;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function redactDeep(value, 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
|
-
}
|
|
199
|
-
if (value && typeof value === 'object') {
|
|
200
|
-
const out = {};
|
|
201
|
-
for (const [k, v] of Object.entries(value)) out[k] = redactDeep(v, cfg);
|
|
202
|
-
return out;
|
|
203
|
-
}
|
|
204
|
-
return value;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function pickUpstream() {
|
|
208
|
-
const raw = process.env.SOLONGATE_SHIELD_UPSTREAM || process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com';
|
|
209
|
-
try { return new URL(raw); } catch { return new URL('https://api.anthropic.com'); }
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function startProxy(upstream) {
|
|
213
|
-
const cfg = loadCfg();
|
|
214
|
-
const forward = upstream.protocol === 'https:' ? httpsRequest : httpRequest;
|
|
215
|
-
const server = createServer((req, res) => {
|
|
216
|
-
const chunks = [];
|
|
217
|
-
req.on('data', (c) => chunks.push(c));
|
|
218
|
-
req.on('end', () => {
|
|
219
|
-
let body = Buffer.concat(chunks);
|
|
220
|
-
try {
|
|
221
|
-
if (body.length && String(req.headers['content-type'] || '').includes('json')) {
|
|
222
|
-
const parsed = JSON.parse(body.toString('utf-8'));
|
|
223
|
-
body = Buffer.from(JSON.stringify(redactDeep(parsed, cfg)), 'utf-8');
|
|
224
|
-
}
|
|
225
|
-
} catch { /* fail open */ }
|
|
226
|
-
const headers = { ...req.headers };
|
|
227
|
-
delete headers['host']; delete headers['content-length']; delete headers['accept-encoding'];
|
|
228
|
-
headers['content-length'] = String(body.length);
|
|
229
|
-
const upReq = forward({
|
|
230
|
-
protocol: upstream.protocol,
|
|
231
|
-
hostname: upstream.hostname,
|
|
232
|
-
port: upstream.port || (upstream.protocol === 'https:' ? 443 : 80),
|
|
233
|
-
method: req.method,
|
|
234
|
-
path: req.url,
|
|
235
|
-
headers: { ...headers, host: upstream.host },
|
|
236
|
-
}, (upRes) => { res.writeHead(upRes.statusCode || 502, upRes.headers); upRes.pipe(res); });
|
|
237
|
-
upReq.on('error', (e) => {
|
|
238
|
-
log('upstream error:', e.message);
|
|
239
|
-
if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain' });
|
|
240
|
-
res.end('shield upstream error');
|
|
241
|
-
});
|
|
242
|
-
upReq.end(body);
|
|
243
|
-
});
|
|
244
|
-
req.on('error', () => { try { res.destroy(); } catch { /* ignore */ } });
|
|
245
|
-
});
|
|
246
|
-
return new Promise((resolveP) => {
|
|
247
|
-
server.listen(0, '127.0.0.1', () => {
|
|
248
|
-
const addr = server.address();
|
|
249
|
-
resolveP({ port: addr && typeof addr === 'object' ? addr.port : 0, close: () => server.close() });
|
|
250
|
-
});
|
|
251
|
-
});
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
async function main() {
|
|
255
|
-
const sep = process.argv.indexOf('--');
|
|
256
|
-
const cmd = sep !== -1 ? process.argv.slice(sep + 1) : [];
|
|
257
|
-
if (cmd.length === 0) { log('usage: node shield.mjs -- <command> [args...]'); process.exit(1); }
|
|
258
|
-
const upstream = pickUpstream();
|
|
259
|
-
const { port, close } = await startProxy(upstream);
|
|
260
|
-
const child = spawn(cmd[0], cmd.slice(1), {
|
|
261
|
-
stdio: 'inherit',
|
|
262
|
-
env: { ...process.env, ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}` },
|
|
263
|
-
shell: process.platform === 'win32',
|
|
264
|
-
});
|
|
265
|
-
const shutdown = () => { try { close(); } catch { /* ignore */ } };
|
|
266
|
-
child.on('exit', (code, signal) => { shutdown(); if (signal) process.kill(process.pid, signal); else process.exit(code ?? 0); });
|
|
267
|
-
child.on('error', (e) => { log('failed to launch command:', e.message); shutdown(); process.exit(1); });
|
|
268
|
-
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { try { child.kill(sig); } catch { /* ignore */ } });
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
main();
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SolonGate Shield (standalone hook) — secret redaction on the LLM request path.
|
|
4
|
+
*
|
|
5
|
+
* Wraps a command (the real `claude`) with a local proxy that masks secrets in
|
|
6
|
+
* the request body BEFORE it reaches the Anthropic API, so the model never sees
|
|
7
|
+
* them. The model's RESPONSE is streamed back untouched. Lifecycle is tied to
|
|
8
|
+
* the wrapped process — no daemon. FAIL OPEN everywhere: any parse/redact/upstream
|
|
9
|
+
* error forwards the original bytes rather than breaking the model connection.
|
|
10
|
+
*
|
|
11
|
+
* Installed to ~/.solongate/hooks/shield.mjs and invoked by the `claude` shim
|
|
12
|
+
* that `login` adds to the shell so every terminal session is masked automatically:
|
|
13
|
+
* node ~/.solongate/hooks/shield.mjs -- "<real claude>" <args...>
|
|
14
|
+
*
|
|
15
|
+
* Logic mirrors src/shield.ts — keep the two in sync.
|
|
16
|
+
*/
|
|
17
|
+
import { createServer, request as httpRequest } from 'node:http';
|
|
18
|
+
import { request as httpsRequest } from 'node:https';
|
|
19
|
+
import { spawn } from 'node:child_process';
|
|
20
|
+
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
21
|
+
import { resolve } from 'node:path';
|
|
22
|
+
import { homedir } from 'node:os';
|
|
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
|
+
|
|
28
|
+
const log = (...a) => process.stderr.write(`[SolonGate shield] ${a.map(String).join(' ')}\n`);
|
|
29
|
+
|
|
30
|
+
const DLP_PATTERNS = [
|
|
31
|
+
{ name: 'AWS access key', re: /AKIA[0-9A-Z]{16}/g },
|
|
32
|
+
{ name: 'private key block', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g },
|
|
33
|
+
{ name: 'Anthropic key', re: /sk-ant-[A-Za-z0-9_-]{20,}/g },
|
|
34
|
+
{ name: 'OpenAI key', re: /sk-(proj-)?[A-Za-z0-9_-]{20,}/g },
|
|
35
|
+
{ name: 'GitHub token', re: /gh[pousr]_[A-Za-z0-9]{20,}/g },
|
|
36
|
+
{ name: 'GitHub fine-grained PAT', re: /github_pat_[A-Za-z0-9_]{20,}/g },
|
|
37
|
+
{ name: 'GitLab token', re: /glpat-[A-Za-z0-9_-]{20,}/g },
|
|
38
|
+
{ name: 'Slack token', re: /xox[baprs]-[A-Za-z0-9-]{10,}/g },
|
|
39
|
+
{ name: 'Stripe key', re: /[sr]k_(live|test)_[A-Za-z0-9]{20,}/g },
|
|
40
|
+
{ name: 'SendGrid key', re: /SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/g },
|
|
41
|
+
{ name: 'Twilio key', re: /SK[0-9a-fA-F]{32}/g },
|
|
42
|
+
{ name: 'npm token', re: /npm_[A-Za-z0-9]{36}/g },
|
|
43
|
+
{ name: 'JWT', re: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g },
|
|
44
|
+
{ name: 'Bearer token', re: /bearer\s+[A-Za-z0-9._-]{20,}/gi },
|
|
45
|
+
];
|
|
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
|
+
|
|
72
|
+
function loadCfg() {
|
|
73
|
+
try {
|
|
74
|
+
const f = findCacheFile();
|
|
75
|
+
if (f && existsSync(f)) {
|
|
76
|
+
const c = JSON.parse(readFileSync(f, 'utf-8'));
|
|
77
|
+
const d = c && c.security && c.security.dlpRedact;
|
|
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 };
|
|
82
|
+
}
|
|
83
|
+
} catch { /* default below */ }
|
|
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');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Custom patterns are GLOBs: `*` = any run of non-whitespace, same as policy/ghost.
|
|
164
|
+
function dlpGlobToRe(glob, flags) {
|
|
165
|
+
let re = '';
|
|
166
|
+
for (const ch of String(glob || '')) {
|
|
167
|
+
if (ch === '*') re += '[^\\s]*';
|
|
168
|
+
else if ('.+?^${}()|[]\\'.indexOf(ch) !== -1) re += '\\' + ch;
|
|
169
|
+
else re += ch;
|
|
170
|
+
}
|
|
171
|
+
return new RegExp(re, flags);
|
|
172
|
+
}
|
|
173
|
+
function redactString(s, cfg) {
|
|
174
|
+
if (!cfg || typeof s !== 'string' || !s) return s;
|
|
175
|
+
const allow = new Set(cfg.patterns);
|
|
176
|
+
let out = s;
|
|
177
|
+
for (const p of DLP_PATTERNS) if (allow.has(p.name)) out = out.replace(p.re, `[REDACTED: ${p.name}]`);
|
|
178
|
+
for (const c of cfg.custom) {
|
|
179
|
+
try { out = out.replace(dlpGlobToRe(c.re, 'g'), `[REDACTED: ${c.name || 'custom'}]`); } catch { /* skip */ }
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function redactDeep(value, 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
|
+
}
|
|
199
|
+
if (value && typeof value === 'object') {
|
|
200
|
+
const out = {};
|
|
201
|
+
for (const [k, v] of Object.entries(value)) out[k] = redactDeep(v, cfg);
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
return value;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function pickUpstream() {
|
|
208
|
+
const raw = process.env.SOLONGATE_SHIELD_UPSTREAM || process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com';
|
|
209
|
+
try { return new URL(raw); } catch { return new URL('https://api.anthropic.com'); }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function startProxy(upstream) {
|
|
213
|
+
const cfg = loadCfg();
|
|
214
|
+
const forward = upstream.protocol === 'https:' ? httpsRequest : httpRequest;
|
|
215
|
+
const server = createServer((req, res) => {
|
|
216
|
+
const chunks = [];
|
|
217
|
+
req.on('data', (c) => chunks.push(c));
|
|
218
|
+
req.on('end', () => {
|
|
219
|
+
let body = Buffer.concat(chunks);
|
|
220
|
+
try {
|
|
221
|
+
if (body.length && String(req.headers['content-type'] || '').includes('json')) {
|
|
222
|
+
const parsed = JSON.parse(body.toString('utf-8'));
|
|
223
|
+
body = Buffer.from(JSON.stringify(redactDeep(parsed, cfg)), 'utf-8');
|
|
224
|
+
}
|
|
225
|
+
} catch { /* fail open */ }
|
|
226
|
+
const headers = { ...req.headers };
|
|
227
|
+
delete headers['host']; delete headers['content-length']; delete headers['accept-encoding'];
|
|
228
|
+
headers['content-length'] = String(body.length);
|
|
229
|
+
const upReq = forward({
|
|
230
|
+
protocol: upstream.protocol,
|
|
231
|
+
hostname: upstream.hostname,
|
|
232
|
+
port: upstream.port || (upstream.protocol === 'https:' ? 443 : 80),
|
|
233
|
+
method: req.method,
|
|
234
|
+
path: req.url,
|
|
235
|
+
headers: { ...headers, host: upstream.host },
|
|
236
|
+
}, (upRes) => { res.writeHead(upRes.statusCode || 502, upRes.headers); upRes.pipe(res); });
|
|
237
|
+
upReq.on('error', (e) => {
|
|
238
|
+
log('upstream error:', e.message);
|
|
239
|
+
if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain' });
|
|
240
|
+
res.end('shield upstream error');
|
|
241
|
+
});
|
|
242
|
+
upReq.end(body);
|
|
243
|
+
});
|
|
244
|
+
req.on('error', () => { try { res.destroy(); } catch { /* ignore */ } });
|
|
245
|
+
});
|
|
246
|
+
return new Promise((resolveP) => {
|
|
247
|
+
server.listen(0, '127.0.0.1', () => {
|
|
248
|
+
const addr = server.address();
|
|
249
|
+
resolveP({ port: addr && typeof addr === 'object' ? addr.port : 0, close: () => server.close() });
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function main() {
|
|
255
|
+
const sep = process.argv.indexOf('--');
|
|
256
|
+
const cmd = sep !== -1 ? process.argv.slice(sep + 1) : [];
|
|
257
|
+
if (cmd.length === 0) { log('usage: node shield.mjs -- <command> [args...]'); process.exit(1); }
|
|
258
|
+
const upstream = pickUpstream();
|
|
259
|
+
const { port, close } = await startProxy(upstream);
|
|
260
|
+
const child = spawn(cmd[0], cmd.slice(1), {
|
|
261
|
+
stdio: 'inherit',
|
|
262
|
+
env: { ...process.env, ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}` },
|
|
263
|
+
shell: process.platform === 'win32',
|
|
264
|
+
});
|
|
265
|
+
const shutdown = () => { try { close(); } catch { /* ignore */ } };
|
|
266
|
+
child.on('exit', (code, signal) => { shutdown(); if (signal) process.kill(process.pid, signal); else process.exit(code ?? 0); });
|
|
267
|
+
child.on('error', (e) => { log('failed to launch command:', e.message); shutdown(); process.exit(1); });
|
|
268
|
+
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { try { child.kill(sig); } catch { /* ignore */ } });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
main();
|