@solongate/proxy 0.58.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/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 -0
- package/hooks/.solongate/.last-eval +1 -0
- package/hooks/.solongate/.last-tool-call +1 -0
- 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 -74
package/hooks/audit.mjs
CHANGED
|
@@ -1,544 +1,565 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* SolonGate Audit Hook for Claude Code (PostToolUse)
|
|
4
|
-
* Logs tool execution results to SolonGate Cloud.
|
|
5
|
-
* Auto-installed by: npx @solongate/proxy login
|
|
6
|
-
*/
|
|
7
|
-
import { readFileSync, existsSync, writeFileSync, mkdirSync, appendFileSync } from 'node:fs';
|
|
8
|
-
import { resolve, join } from 'node:path';
|
|
9
|
-
import { homedir } from 'node:os';
|
|
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
|
-
|
|
16
|
-
function loadEnvKey(dir) {
|
|
17
|
-
try {
|
|
18
|
-
const envPath = resolve(dir, '.env');
|
|
19
|
-
if (!existsSync(envPath)) return {};
|
|
20
|
-
const lines = readFileSync(envPath, 'utf-8').split('\n');
|
|
21
|
-
const env = {};
|
|
22
|
-
for (const line of lines) {
|
|
23
|
-
const m = line.match(/^([A-Z_]+)=(.*)$/);
|
|
24
|
-
if (m) env[m[1]] = m[2].replace(/^["']|["']$/g, '').trim();
|
|
25
|
-
}
|
|
26
|
-
return env;
|
|
27
|
-
} catch { return {}; }
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// Global cloud config written by `init --global` (~/.solongate/cloud-guard.json).
|
|
31
|
-
// A system-wide PostToolUse hook runs from any cwd, so a project .env can't be
|
|
32
|
-
// relied on for the key — read the absolute global config too.
|
|
33
|
-
function loadGlobalCloudConfig() {
|
|
34
|
-
try {
|
|
35
|
-
const p = resolve(homedir(), '.solongate', 'cloud-guard.json');
|
|
36
|
-
if (!existsSync(p)) return {};
|
|
37
|
-
const cfg = JSON.parse(readFileSync(p, 'utf-8'));
|
|
38
|
-
return (cfg && typeof cfg === 'object') ? cfg : {};
|
|
39
|
-
} catch { return {}; }
|
|
40
|
-
}
|
|
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
|
-
|
|
68
|
-
// ── Ghost paths (PostToolUse twin of guard.mjs) ──
|
|
69
|
-
// The guard's PreToolUse hook blocks MUTATIONS to hidden paths; here we make
|
|
70
|
-
// hidden paths invisible to READS and LISTINGS by rewriting the tool output the
|
|
71
|
-
// model sees (Claude Code `updatedToolOutput`). The matcher below is mirrored
|
|
72
|
-
// verbatim from guard.mjs — keep the two in sync. Config is read from the policy
|
|
73
|
-
// cache the guard just wrote (same PreToolUse call), so no extra API request.
|
|
74
|
-
function ghostGlobToRegExp(glob) {
|
|
75
|
-
let re = '';
|
|
76
|
-
for (let i = 0; i < glob.length; i++) {
|
|
77
|
-
const c = glob[i];
|
|
78
|
-
if (c === '*') {
|
|
79
|
-
if (glob[i + 1] === '*') { re += '.*'; i++; }
|
|
80
|
-
else re += '[^/]*';
|
|
81
|
-
} else if (c === '?') re += '[^/]';
|
|
82
|
-
else if ('\\^$.|+()[]{}'.indexOf(c) !== -1) re += '\\' + c;
|
|
83
|
-
else re += c;
|
|
84
|
-
}
|
|
85
|
-
try { return new RegExp('^' + re + '$'); } catch { return null; }
|
|
86
|
-
}
|
|
87
|
-
function ghostMatch(targetPath, patterns) {
|
|
88
|
-
if (!targetPath || !Array.isArray(patterns) || patterns.length === 0) return false;
|
|
89
|
-
const norm = String(targetPath).replace(/\\/g, '/').replace(/\/+$/, '');
|
|
90
|
-
if (!norm) return false;
|
|
91
|
-
const segments = norm.split('/').filter(Boolean);
|
|
92
|
-
const base = segments.length ? segments[segments.length - 1] : norm;
|
|
93
|
-
for (let pat of patterns) {
|
|
94
|
-
pat = String(pat || '').trim();
|
|
95
|
-
if (!pat) continue;
|
|
96
|
-
let dirOnly = false;
|
|
97
|
-
if (pat.endsWith('/')) { dirOnly = true; pat = pat.slice(0, -1); }
|
|
98
|
-
if (!pat) continue;
|
|
99
|
-
const hasSlash = pat.indexOf('/') !== -1;
|
|
100
|
-
const hasWild = /[*?]/.test(pat);
|
|
101
|
-
const re = ghostGlobToRegExp(pat);
|
|
102
|
-
if (!re) continue;
|
|
103
|
-
if (dirOnly) {
|
|
104
|
-
if (!hasSlash && !hasWild) { if (segments.indexOf(pat) !== -1) return true; continue; }
|
|
105
|
-
let acc = '';
|
|
106
|
-
for (const s of segments) { acc = acc ? acc + '/' + s : s; if (re.test(acc) || re.test(s)) return true; }
|
|
107
|
-
continue;
|
|
108
|
-
}
|
|
109
|
-
if (!hasSlash) {
|
|
110
|
-
if (re.test(base)) return true;
|
|
111
|
-
if (segments.some((s) => re.test(s))) return true;
|
|
112
|
-
continue;
|
|
113
|
-
}
|
|
114
|
-
if (re.test(norm)) return true;
|
|
115
|
-
}
|
|
116
|
-
return false;
|
|
117
|
-
}
|
|
118
|
-
function ghostCleanToken(tok) {
|
|
119
|
-
let t = String(tok || '').trim();
|
|
120
|
-
t = t.replace(/^[<>|;&(]+/, '').replace(/[);&|]+$/, '');
|
|
121
|
-
t = t.replace(/^['"]+/, '').replace(/['"]+$/, '');
|
|
122
|
-
t = t.replace(/^\d*>>?/, '');
|
|
123
|
-
return t.trim();
|
|
124
|
-
}
|
|
125
|
-
// Drop listing lines that reference a ghost entry. `find`/glob output (one path
|
|
126
|
-
// per line) drops the whole line; multi-column `ls -l` rows drop entirely; a
|
|
127
|
-
// bare space-separated `ls` row drops only the matching names.
|
|
128
|
-
function ghostStripLines(text, pats) {
|
|
129
|
-
const lines = String(text).split('\n');
|
|
130
|
-
const kept = [];
|
|
131
|
-
for (const line of lines) {
|
|
132
|
-
const trimmed = line.trim();
|
|
133
|
-
if (!trimmed) { kept.push(line); continue; }
|
|
134
|
-
if (ghostMatch(trimmed, pats)) continue; // full-path listing line
|
|
135
|
-
const toks = trimmed.split(/\s+/);
|
|
136
|
-
const anyHit = toks.some((t) => ghostMatch(ghostCleanToken(t), pats));
|
|
137
|
-
if (!anyHit) { kept.push(line); continue; }
|
|
138
|
-
if (toks.length > 3) continue; // ls -l style row → drop entirely
|
|
139
|
-
const remaining = toks.filter((t) => !ghostMatch(ghostCleanToken(t), pats));
|
|
140
|
-
if (remaining.length === 0) continue;
|
|
141
|
-
kept.push(remaining.join(' '));
|
|
142
|
-
}
|
|
143
|
-
return kept.join('\n');
|
|
144
|
-
}
|
|
145
|
-
// Read ghost patterns from the policy cache the guard wrote on the matching
|
|
146
|
-
// PreToolUse call. Same agent-key derivation as guard.mjs.
|
|
147
|
-
function loadGhostPatterns() {
|
|
148
|
-
try {
|
|
149
|
-
const sel = (process.env.SOLONGATE_AGENT_ID || process.argv[2] || 'default').replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
150
|
-
const f = resolve(homedir(), '.solongate', '.policy-cache-' + sel + '.json');
|
|
151
|
-
if (!existsSync(f)) return [];
|
|
152
|
-
const c = JSON.parse(readFileSync(f, 'utf-8'));
|
|
153
|
-
const g = c && c.security && c.security.ghost;
|
|
154
|
-
return g && Array.isArray(g.patterns) ? g.patterns : [];
|
|
155
|
-
} catch { return []; }
|
|
156
|
-
}
|
|
157
|
-
// Returns the rewritten output text, or null if nothing is hidden.
|
|
158
|
-
function buildGhostOutput(toolName, toolInput, toolResponse, toolOutput, pats) {
|
|
159
|
-
if (!Array.isArray(pats) || pats.length === 0) return null;
|
|
160
|
-
const name = toolName || '';
|
|
161
|
-
// Use the SAME broad extraction as DLP redaction (Read file.content, array
|
|
162
|
-
// content, stdout, …). Previously this was narrower, so ghost-strip silently
|
|
163
|
-
// skipped tool shapes DLP still scanned — a ghosted file then survived in a
|
|
164
|
-
// listing (e.g. the LS tool) even though names elsewhere got redacted.
|
|
165
|
-
const getText = () => extractOutputText(toolResponse, toolOutput);
|
|
166
|
-
try {
|
|
167
|
-
// Direct read of a hidden file → looks like it doesn't exist.
|
|
168
|
-
if (name === 'Read' || name === 'NotebookRead') {
|
|
169
|
-
const p = toolInput && (toolInput.file_path || toolInput.notebook_path);
|
|
170
|
-
if (p && ghostMatch(p, pats)) return p + ': No such file or directory';
|
|
171
|
-
return null;
|
|
172
|
-
}
|
|
173
|
-
if (name === 'Bash' || name === 'BashOutput') {
|
|
174
|
-
const text = getText();
|
|
175
|
-
if (text == null) return null;
|
|
176
|
-
const cmd = String((toolInput && toolInput.command) || '');
|
|
177
|
-
for (const raw of cmd.split(/\s+/)) {
|
|
178
|
-
const t = ghostCleanToken(raw);
|
|
179
|
-
// A command that names the hidden path directly (cat A/Y/.data, ls A/Y)
|
|
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';
|
|
187
|
-
}
|
|
188
|
-
const stripped = ghostStripLines(text, pats);
|
|
189
|
-
return stripped === text ? null : stripped;
|
|
190
|
-
}
|
|
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
|
-
{
|
|
206
|
-
const text = getText();
|
|
207
|
-
if (text == null) return null;
|
|
208
|
-
const stripped = ghostStripLines(text, pats);
|
|
209
|
-
return stripped === text ? null : stripped;
|
|
210
|
-
}
|
|
211
|
-
} catch { /* fail open */ }
|
|
212
|
-
return null;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// ── DLP output redaction (PostToolUse) ──
|
|
216
|
-
// Same family as ghost, but ghost hides files/dirs by PATH while this masks
|
|
217
|
-
// secret VALUES inside the tool OUTPUT the model sees (file reads, stdout,
|
|
218
|
-
// fetched pages). Active whenever DLP is on (detect OR block) — the server
|
|
219
|
-
// delivers the enabled pattern set as `security.dlpRedact` in the policy cache.
|
|
220
|
-
// Patterns mirror guard.mjs / apps/api/src/lib/security-layers.ts (global flag
|
|
221
|
-
// so every occurrence is replaced). Scanning RAW output text (not JSON) means
|
|
222
|
-
// the quote handling is exact — no escaping artifacts.
|
|
223
|
-
const DLP_PATTERNS = [
|
|
224
|
-
{ name: 'AWS access key', re: /AKIA[0-9A-Z]{16}/g },
|
|
225
|
-
{ name: 'private key block', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g },
|
|
226
|
-
{ name: 'Anthropic key', re: /sk-ant-[A-Za-z0-9_-]{20,}/g },
|
|
227
|
-
{ name: 'OpenAI key', re: /sk-(proj-)?[A-Za-z0-9_-]{20,}/g },
|
|
228
|
-
{ name: 'GitHub token', re: /gh[pousr]_[A-Za-z0-9]{20,}/g },
|
|
229
|
-
{ name: 'GitHub fine-grained PAT', re: /github_pat_[A-Za-z0-9_]{20,}/g },
|
|
230
|
-
{ name: 'GitLab token', re: /glpat-[A-Za-z0-9_-]{20,}/g },
|
|
231
|
-
{ name: 'Slack token', re: /xox[baprs]-[A-Za-z0-9-]{10,}/g },
|
|
232
|
-
{ name: 'Stripe key', re: /[sr]k_(live|test)_[A-Za-z0-9]{20,}/g },
|
|
233
|
-
{ name: 'SendGrid key', re: /SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/g },
|
|
234
|
-
{ name: 'Twilio key', re: /SK[0-9a-fA-F]{32}/g },
|
|
235
|
-
{ name: 'npm token', re: /npm_[A-Za-z0-9]{36}/g },
|
|
236
|
-
{ name: 'JWT', re: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g },
|
|
237
|
-
{ name: 'Bearer token', re: /bearer\s+[A-Za-z0-9._-]{20,}/gi },
|
|
238
|
-
];
|
|
239
|
-
|
|
240
|
-
// Read the redaction config the guard cached on the matching PreToolUse call.
|
|
241
|
-
function loadDlpRedact() {
|
|
242
|
-
try {
|
|
243
|
-
const sel = (process.env.SOLONGATE_AGENT_ID || process.argv[2] || 'default').replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
244
|
-
const f = resolve(homedir(), '.solongate', '.policy-cache-' + sel + '.json');
|
|
245
|
-
if (!existsSync(f)) return null;
|
|
246
|
-
const c = JSON.parse(readFileSync(f, 'utf-8'));
|
|
247
|
-
const d = c && c.security && c.security.dlpRedact;
|
|
248
|
-
return d && Array.isArray(d.patterns) ? d : null;
|
|
249
|
-
} catch { return null; }
|
|
250
|
-
}
|
|
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
|
-
//
|
|
270
|
-
//
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
return
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
const
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
const
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SolonGate Audit Hook for Claude Code (PostToolUse)
|
|
4
|
+
* Logs tool execution results to SolonGate Cloud.
|
|
5
|
+
* Auto-installed by: npx @solongate/proxy login
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync, existsSync, writeFileSync, mkdirSync, appendFileSync } from 'node:fs';
|
|
8
|
+
import { resolve, join, isAbsolute } from 'node:path';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
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
|
+
|
|
16
|
+
function loadEnvKey(dir) {
|
|
17
|
+
try {
|
|
18
|
+
const envPath = resolve(dir, '.env');
|
|
19
|
+
if (!existsSync(envPath)) return {};
|
|
20
|
+
const lines = readFileSync(envPath, 'utf-8').split('\n');
|
|
21
|
+
const env = {};
|
|
22
|
+
for (const line of lines) {
|
|
23
|
+
const m = line.match(/^([A-Z_]+)=(.*)$/);
|
|
24
|
+
if (m) env[m[1]] = m[2].replace(/^["']|["']$/g, '').trim();
|
|
25
|
+
}
|
|
26
|
+
return env;
|
|
27
|
+
} catch { return {}; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Global cloud config written by `init --global` (~/.solongate/cloud-guard.json).
|
|
31
|
+
// A system-wide PostToolUse hook runs from any cwd, so a project .env can't be
|
|
32
|
+
// relied on for the key — read the absolute global config too.
|
|
33
|
+
function loadGlobalCloudConfig() {
|
|
34
|
+
try {
|
|
35
|
+
const p = resolve(homedir(), '.solongate', 'cloud-guard.json');
|
|
36
|
+
if (!existsSync(p)) return {};
|
|
37
|
+
const cfg = JSON.parse(readFileSync(p, 'utf-8'));
|
|
38
|
+
return (cfg && typeof cfg === 'object') ? cfg : {};
|
|
39
|
+
} catch { return {}; }
|
|
40
|
+
}
|
|
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
|
+
|
|
68
|
+
// ── Ghost paths (PostToolUse twin of guard.mjs) ──
|
|
69
|
+
// The guard's PreToolUse hook blocks MUTATIONS to hidden paths; here we make
|
|
70
|
+
// hidden paths invisible to READS and LISTINGS by rewriting the tool output the
|
|
71
|
+
// model sees (Claude Code `updatedToolOutput`). The matcher below is mirrored
|
|
72
|
+
// verbatim from guard.mjs — keep the two in sync. Config is read from the policy
|
|
73
|
+
// cache the guard just wrote (same PreToolUse call), so no extra API request.
|
|
74
|
+
function ghostGlobToRegExp(glob) {
|
|
75
|
+
let re = '';
|
|
76
|
+
for (let i = 0; i < glob.length; i++) {
|
|
77
|
+
const c = glob[i];
|
|
78
|
+
if (c === '*') {
|
|
79
|
+
if (glob[i + 1] === '*') { re += '.*'; i++; }
|
|
80
|
+
else re += '[^/]*';
|
|
81
|
+
} else if (c === '?') re += '[^/]';
|
|
82
|
+
else if ('\\^$.|+()[]{}'.indexOf(c) !== -1) re += '\\' + c;
|
|
83
|
+
else re += c;
|
|
84
|
+
}
|
|
85
|
+
try { return new RegExp('^' + re + '$'); } catch { return null; }
|
|
86
|
+
}
|
|
87
|
+
function ghostMatch(targetPath, patterns) {
|
|
88
|
+
if (!targetPath || !Array.isArray(patterns) || patterns.length === 0) return false;
|
|
89
|
+
const norm = String(targetPath).replace(/\\/g, '/').replace(/\/+$/, '');
|
|
90
|
+
if (!norm) return false;
|
|
91
|
+
const segments = norm.split('/').filter(Boolean);
|
|
92
|
+
const base = segments.length ? segments[segments.length - 1] : norm;
|
|
93
|
+
for (let pat of patterns) {
|
|
94
|
+
pat = String(pat || '').trim();
|
|
95
|
+
if (!pat) continue;
|
|
96
|
+
let dirOnly = false;
|
|
97
|
+
if (pat.endsWith('/')) { dirOnly = true; pat = pat.slice(0, -1); }
|
|
98
|
+
if (!pat) continue;
|
|
99
|
+
const hasSlash = pat.indexOf('/') !== -1;
|
|
100
|
+
const hasWild = /[*?]/.test(pat);
|
|
101
|
+
const re = ghostGlobToRegExp(pat);
|
|
102
|
+
if (!re) continue;
|
|
103
|
+
if (dirOnly) {
|
|
104
|
+
if (!hasSlash && !hasWild) { if (segments.indexOf(pat) !== -1) return true; continue; }
|
|
105
|
+
let acc = '';
|
|
106
|
+
for (const s of segments) { acc = acc ? acc + '/' + s : s; if (re.test(acc) || re.test(s)) return true; }
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (!hasSlash) {
|
|
110
|
+
if (re.test(base)) return true;
|
|
111
|
+
if (segments.some((s) => re.test(s))) return true;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (re.test(norm)) return true;
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
function ghostCleanToken(tok) {
|
|
119
|
+
let t = String(tok || '').trim();
|
|
120
|
+
t = t.replace(/^[<>|;&(]+/, '').replace(/[);&|]+$/, '');
|
|
121
|
+
t = t.replace(/^['"]+/, '').replace(/['"]+$/, '');
|
|
122
|
+
t = t.replace(/^\d*>>?/, '');
|
|
123
|
+
return t.trim();
|
|
124
|
+
}
|
|
125
|
+
// Drop listing lines that reference a ghost entry. `find`/glob output (one path
|
|
126
|
+
// per line) drops the whole line; multi-column `ls -l` rows drop entirely; a
|
|
127
|
+
// bare space-separated `ls` row drops only the matching names.
|
|
128
|
+
function ghostStripLines(text, pats) {
|
|
129
|
+
const lines = String(text).split('\n');
|
|
130
|
+
const kept = [];
|
|
131
|
+
for (const line of lines) {
|
|
132
|
+
const trimmed = line.trim();
|
|
133
|
+
if (!trimmed) { kept.push(line); continue; }
|
|
134
|
+
if (ghostMatch(trimmed, pats)) continue; // full-path listing line
|
|
135
|
+
const toks = trimmed.split(/\s+/);
|
|
136
|
+
const anyHit = toks.some((t) => ghostMatch(ghostCleanToken(t), pats));
|
|
137
|
+
if (!anyHit) { kept.push(line); continue; }
|
|
138
|
+
if (toks.length > 3) continue; // ls -l style row → drop entirely
|
|
139
|
+
const remaining = toks.filter((t) => !ghostMatch(ghostCleanToken(t), pats));
|
|
140
|
+
if (remaining.length === 0) continue;
|
|
141
|
+
kept.push(remaining.join(' '));
|
|
142
|
+
}
|
|
143
|
+
return kept.join('\n');
|
|
144
|
+
}
|
|
145
|
+
// Read ghost patterns from the policy cache the guard wrote on the matching
|
|
146
|
+
// PreToolUse call. Same agent-key derivation as guard.mjs.
|
|
147
|
+
function loadGhostPatterns() {
|
|
148
|
+
try {
|
|
149
|
+
const sel = (process.env.SOLONGATE_AGENT_ID || process.argv[2] || 'default').replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
150
|
+
const f = resolve(homedir(), '.solongate', '.policy-cache-' + sel + '.json');
|
|
151
|
+
if (!existsSync(f)) return [];
|
|
152
|
+
const c = JSON.parse(readFileSync(f, 'utf-8'));
|
|
153
|
+
const g = c && c.security && c.security.ghost;
|
|
154
|
+
return g && Array.isArray(g.patterns) ? g.patterns : [];
|
|
155
|
+
} catch { return []; }
|
|
156
|
+
}
|
|
157
|
+
// Returns the rewritten output text, or null if nothing is hidden.
|
|
158
|
+
function buildGhostOutput(toolName, toolInput, toolResponse, toolOutput, pats) {
|
|
159
|
+
if (!Array.isArray(pats) || pats.length === 0) return null;
|
|
160
|
+
const name = toolName || '';
|
|
161
|
+
// Use the SAME broad extraction as DLP redaction (Read file.content, array
|
|
162
|
+
// content, stdout, …). Previously this was narrower, so ghost-strip silently
|
|
163
|
+
// skipped tool shapes DLP still scanned — a ghosted file then survived in a
|
|
164
|
+
// listing (e.g. the LS tool) even though names elsewhere got redacted.
|
|
165
|
+
const getText = () => extractOutputText(toolResponse, toolOutput);
|
|
166
|
+
try {
|
|
167
|
+
// Direct read of a hidden file → looks like it doesn't exist.
|
|
168
|
+
if (name === 'Read' || name === 'NotebookRead') {
|
|
169
|
+
const p = toolInput && (toolInput.file_path || toolInput.notebook_path);
|
|
170
|
+
if (p && ghostMatch(p, pats)) return p + ': No such file or directory';
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
if (name === 'Bash' || name === 'BashOutput') {
|
|
174
|
+
const text = getText();
|
|
175
|
+
if (text == null) return null;
|
|
176
|
+
const cmd = String((toolInput && toolInput.command) || '');
|
|
177
|
+
for (const raw of cmd.split(/\s+/)) {
|
|
178
|
+
const t = ghostCleanToken(raw);
|
|
179
|
+
// A command that names the hidden path directly (cat A/Y/.data, ls A/Y)
|
|
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';
|
|
187
|
+
}
|
|
188
|
+
const stripped = ghostStripLines(text, pats);
|
|
189
|
+
return stripped === text ? null : stripped;
|
|
190
|
+
}
|
|
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
|
+
{
|
|
206
|
+
const text = getText();
|
|
207
|
+
if (text == null) return null;
|
|
208
|
+
const stripped = ghostStripLines(text, pats);
|
|
209
|
+
return stripped === text ? null : stripped;
|
|
210
|
+
}
|
|
211
|
+
} catch { /* fail open */ }
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── DLP output redaction (PostToolUse) ──
|
|
216
|
+
// Same family as ghost, but ghost hides files/dirs by PATH while this masks
|
|
217
|
+
// secret VALUES inside the tool OUTPUT the model sees (file reads, stdout,
|
|
218
|
+
// fetched pages). Active whenever DLP is on (detect OR block) — the server
|
|
219
|
+
// delivers the enabled pattern set as `security.dlpRedact` in the policy cache.
|
|
220
|
+
// Patterns mirror guard.mjs / apps/api/src/lib/security-layers.ts (global flag
|
|
221
|
+
// so every occurrence is replaced). Scanning RAW output text (not JSON) means
|
|
222
|
+
// the quote handling is exact — no escaping artifacts.
|
|
223
|
+
const DLP_PATTERNS = [
|
|
224
|
+
{ name: 'AWS access key', re: /AKIA[0-9A-Z]{16}/g },
|
|
225
|
+
{ name: 'private key block', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g },
|
|
226
|
+
{ name: 'Anthropic key', re: /sk-ant-[A-Za-z0-9_-]{20,}/g },
|
|
227
|
+
{ name: 'OpenAI key', re: /sk-(proj-)?[A-Za-z0-9_-]{20,}/g },
|
|
228
|
+
{ name: 'GitHub token', re: /gh[pousr]_[A-Za-z0-9]{20,}/g },
|
|
229
|
+
{ name: 'GitHub fine-grained PAT', re: /github_pat_[A-Za-z0-9_]{20,}/g },
|
|
230
|
+
{ name: 'GitLab token', re: /glpat-[A-Za-z0-9_-]{20,}/g },
|
|
231
|
+
{ name: 'Slack token', re: /xox[baprs]-[A-Za-z0-9-]{10,}/g },
|
|
232
|
+
{ name: 'Stripe key', re: /[sr]k_(live|test)_[A-Za-z0-9]{20,}/g },
|
|
233
|
+
{ name: 'SendGrid key', re: /SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/g },
|
|
234
|
+
{ name: 'Twilio key', re: /SK[0-9a-fA-F]{32}/g },
|
|
235
|
+
{ name: 'npm token', re: /npm_[A-Za-z0-9]{36}/g },
|
|
236
|
+
{ name: 'JWT', re: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g },
|
|
237
|
+
{ name: 'Bearer token', re: /bearer\s+[A-Za-z0-9._-]{20,}/gi },
|
|
238
|
+
];
|
|
239
|
+
|
|
240
|
+
// Read the redaction config the guard cached on the matching PreToolUse call.
|
|
241
|
+
function loadDlpRedact() {
|
|
242
|
+
try {
|
|
243
|
+
const sel = (process.env.SOLONGATE_AGENT_ID || process.argv[2] || 'default').replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
244
|
+
const f = resolve(homedir(), '.solongate', '.policy-cache-' + sel + '.json');
|
|
245
|
+
if (!existsSync(f)) return null;
|
|
246
|
+
const c = JSON.parse(readFileSync(f, 'utf-8'));
|
|
247
|
+
const d = c && c.security && c.security.dlpRedact;
|
|
248
|
+
return d && Array.isArray(d.patterns) ? d : null;
|
|
249
|
+
} catch { return null; }
|
|
250
|
+
}
|
|
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
|
+
// Resolve the FOLDER local logs may be written into. It MUST be absolute on
|
|
270
|
+
// THIS machine. A relative path — e.g. a Windows "C:/Users/…" path evaluated on
|
|
271
|
+
// Linux, where Node treats it as relative — would be created under the agent's
|
|
272
|
+
// current working directory and pollute whatever project it happens to run in.
|
|
273
|
+
// When the configured path isn't absolute here, fall back to a fixed home folder
|
|
274
|
+
// so entries are never lost and never leak into a project, and record the bad
|
|
275
|
+
// path so the dashboard/user can be told their path isn't valid on this device.
|
|
276
|
+
function resolveLocalLogDir(rawPath) {
|
|
277
|
+
const dir = String(rawPath || '').trim().replace(/[\\/]+$/, '');
|
|
278
|
+
if (!dir) return null;
|
|
279
|
+
if (isAbsolute(dir)) return dir;
|
|
280
|
+
const fallback = resolve(homedir(), '.solongate', 'local-logs');
|
|
281
|
+
try {
|
|
282
|
+
mkdirSync(resolve(homedir(), '.solongate'), { recursive: true });
|
|
283
|
+
writeFileSync(resolve(homedir(), '.solongate', '.local-logs-invalid-path'),
|
|
284
|
+
JSON.stringify({ configured: dir, fallback, ts: Date.now() }));
|
|
285
|
+
} catch { /* ignore */ }
|
|
286
|
+
return fallback;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// The path is a FOLDER; we write solongate-audit.jsonl inside it (creating the
|
|
290
|
+
// folder if missing), then append one JSON line.
|
|
291
|
+
function appendLocalLog(cfg, entry) {
|
|
292
|
+
try {
|
|
293
|
+
const dir = resolveLocalLogDir(cfg.path);
|
|
294
|
+
if (!dir) return;
|
|
295
|
+
try { mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
|
|
296
|
+
appendFileSync(join(dir, 'solongate-audit.jsonl'), JSON.stringify(entry) + '\n');
|
|
297
|
+
} catch { /* best-effort: never disturb the tool call */ }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Replace every secret match with a labelled placeholder. cfg = { patterns:
|
|
301
|
+
// string[] (enabled built-in names), custom: {name,re}[] }.
|
|
302
|
+
// Custom patterns are GLOBs: `*` = any run of non-whitespace (so a fragment
|
|
303
|
+
// matches a whole token), same wildcard mechanic as policy/ghost.
|
|
304
|
+
function dlpGlobToRe(glob, flags) {
|
|
305
|
+
let re = '';
|
|
306
|
+
for (const ch of String(glob || '')) {
|
|
307
|
+
if (ch === '*') re += '[^\\s]*';
|
|
308
|
+
else if ('.+?^${}()|[]\\'.indexOf(ch) !== -1) re += '\\' + ch;
|
|
309
|
+
else re += ch;
|
|
310
|
+
}
|
|
311
|
+
return new RegExp(re, flags);
|
|
312
|
+
}
|
|
313
|
+
function dlpRedactText(text, cfg) {
|
|
314
|
+
if (!cfg || typeof text !== 'string' || !text) return text;
|
|
315
|
+
const allow = new Set(Array.isArray(cfg.patterns) ? cfg.patterns : []);
|
|
316
|
+
let out = text;
|
|
317
|
+
for (const p of DLP_PATTERNS) {
|
|
318
|
+
if (allow.has(p.name)) out = out.replace(p.re, '[REDACTED:' + p.name + ']');
|
|
319
|
+
}
|
|
320
|
+
for (const c of Array.isArray(cfg.custom) ? cfg.custom : []) {
|
|
321
|
+
try { out = out.replace(dlpGlobToRe(c.re, 'g'), '[REDACTED:' + (c.name || 'custom') + ']'); } catch { /* skip invalid */ }
|
|
322
|
+
}
|
|
323
|
+
return out;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Best-effort extraction of the model-visible text from a tool_response. Shapes
|
|
327
|
+
// are undocumented, so probe the known fields (Bash stdout, Read file.content,
|
|
328
|
+
// string/array content) and fall back to a raw string output.
|
|
329
|
+
function extractOutputText(toolResponse, toolOutput) {
|
|
330
|
+
const r = toolResponse;
|
|
331
|
+
if (typeof r === 'string') return r;
|
|
332
|
+
if (r && typeof r === 'object') {
|
|
333
|
+
if (typeof r.stdout === 'string' && r.stdout) return r.stdout;
|
|
334
|
+
if (typeof r.content === 'string' && r.content) return r.content;
|
|
335
|
+
if (r.file && typeof r.file.content === 'string') return r.file.content;
|
|
336
|
+
if (Array.isArray(r.content)) {
|
|
337
|
+
const t = r.content.filter((x) => x && x.type === 'text' && typeof x.text === 'string').map((x) => x.text).join('\n');
|
|
338
|
+
if (t) return t;
|
|
339
|
+
}
|
|
340
|
+
// Native Glob delivers its hits as a `filenames` string[] with NO stdout/
|
|
341
|
+
// content field, so getText() returned null and the ghost/DLP strip silently
|
|
342
|
+
// skipped it — a ghosted file then survived in the Glob listing the model saw
|
|
343
|
+
// (the exact leak: a `*ghosttest*` path showed up in a Glob result). Join to
|
|
344
|
+
// one-path-per-line so ghostStripLines/dlpRedactText can act on it; the
|
|
345
|
+
// PostToolUse updatedToolOutput then replaces the model-visible list.
|
|
346
|
+
if (Array.isArray(r.filenames)) {
|
|
347
|
+
const t = r.filenames.filter((x) => typeof x === 'string').join('\n');
|
|
348
|
+
if (t) return t;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (typeof toolOutput === 'string' && toolOutput) return toolOutput;
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function guessPermission(toolName) {
|
|
356
|
+
const name = (toolName || '').toLowerCase();
|
|
357
|
+
if (name.includes('exec') || name.includes('shell') || name.includes('run') || name.includes('eval') || name === 'bash') return 'EXECUTE';
|
|
358
|
+
if (name.includes('fetch') || name.includes('http') || name.includes('request') || name.includes('curl') || name.includes('network') || name.includes('download') || name.includes('upload') || name === 'websearch') return 'NETWORK';
|
|
359
|
+
if (name.includes('write') || name.includes('create') || name.includes('delete') || name.includes('update') || name.includes('set') || name.includes('edit') || name.includes('remove') || name.includes('insert')) return 'WRITE';
|
|
360
|
+
return 'READ';
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const dotenv = loadEnvKey(process.cwd());
|
|
364
|
+
const globalCfg = loadGlobalCloudConfig();
|
|
365
|
+
const API_KEY = process.env.SOLONGATE_API_KEY || dotenv.SOLONGATE_API_KEY || globalCfg.apiKey || '';
|
|
366
|
+
const API_URL = process.env.SOLONGATE_API_URL || dotenv.SOLONGATE_API_URL || globalCfg.apiUrl || 'https://api.solongate.com';
|
|
367
|
+
|
|
368
|
+
// Agent identity from CLI args: node audit.mjs <agent_id> <agent_name>
|
|
369
|
+
const AGENT_ID = process.argv[2] || 'claude-code';
|
|
370
|
+
const AGENT_NAME = process.argv[3] || 'Claude Code';
|
|
371
|
+
|
|
372
|
+
// Accept both live and test keys (test keys are used for trials / sandboxes).
|
|
373
|
+
if (!API_KEY || !(API_KEY.startsWith('sg_live_') || API_KEY.startsWith('sg_test_'))) process.exit(0);
|
|
374
|
+
|
|
375
|
+
let input = '';
|
|
376
|
+
process.stdin.on('data', c => input += c);
|
|
377
|
+
process.stdin.on('end', async () => {
|
|
378
|
+
try {
|
|
379
|
+
const data = JSON.parse(input);
|
|
380
|
+
let EMITTED_PAYLOAD = null;
|
|
381
|
+
|
|
382
|
+
// Debug: append raw stdin to file for agent detection troubleshooting.
|
|
383
|
+
// Opt-in (SOLONGATE_DEBUG) so a global hook doesn't litter every cwd.
|
|
384
|
+
if (process.env.SOLONGATE_DEBUG) {
|
|
385
|
+
try {
|
|
386
|
+
const debugLine = JSON.stringify({ ts: new Date().toISOString(), argv: process.argv.slice(2), tool_name: data.tool_name || data.toolName, agent_id: AGENT_ID }) + '\n';
|
|
387
|
+
const { appendFileSync: afs, mkdirSync: mds } = await import('node:fs');
|
|
388
|
+
mds(resolve('.solongate'), { recursive: true });
|
|
389
|
+
afs(resolve('.solongate', '.debug-audit-log'), debugLine);
|
|
390
|
+
} catch {}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
let toolName = data.tool_name || data.toolName || '';
|
|
394
|
+
let toolInput = data.tool_input || data.toolInput || data.params || {};
|
|
395
|
+
if (!toolName) toolName = 'unknown';
|
|
396
|
+
|
|
397
|
+
if (toolName === 'Bash' && JSON.stringify(toolInput).includes('audit-logs')) {
|
|
398
|
+
process.exit(0);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Check if guard.mjs already logged a DENY for this tool (avoid duplicate ALLOW after DENY)
|
|
402
|
+
let guardDenied = false;
|
|
403
|
+
try {
|
|
404
|
+
const denyFlagPath = resolve('.solongate', '.last-deny');
|
|
405
|
+
if (existsSync(denyFlagPath)) {
|
|
406
|
+
const flag = JSON.parse(readFileSync(denyFlagPath, 'utf-8'));
|
|
407
|
+
// If deny was recent (< 10s) and same tool, this postToolUse is a duplicate
|
|
408
|
+
if (flag.ts && Date.now() - flag.ts < 10000 && flag.tool === toolName) {
|
|
409
|
+
guardDenied = true;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
} catch {}
|
|
413
|
+
|
|
414
|
+
const toolResponse = data.tool_response || data.toolResponse || {};
|
|
415
|
+
const toolOutput = data.tool_output || data.toolOutput || '';
|
|
416
|
+
const resultJson = data.result_json ? (typeof data.result_json === 'string' ? data.result_json : JSON.stringify(data.result_json)) : '';
|
|
417
|
+
|
|
418
|
+
// Ghost paths: rewrite the output the model sees so hidden files/dirs are
|
|
419
|
+
// stripped from listings and direct reads look like "no such file". Emit the
|
|
420
|
+
// updated output BEFORE the (fire-and-forget) audit log. Fail-open: any
|
|
421
|
+
// error leaves the original output untouched.
|
|
422
|
+
let ghostFired = false;
|
|
423
|
+
try {
|
|
424
|
+
const ghostPats = loadGhostPatterns();
|
|
425
|
+
const dlpCfg = loadDlpRedact();
|
|
426
|
+
const redactName = (s) => (dlpCfg && typeof s === 'string') ? dlpRedactText(s, dlpCfg) : s;
|
|
427
|
+
|
|
428
|
+
// Glob (and Grep in files mode) deliver a STRUCTURED result:
|
|
429
|
+
// { filenames: string[], numFiles, truncated, totalMatches, ... }
|
|
430
|
+
// Claude Code REJECTS a plain-string updatedToolOutput for such a tool —
|
|
431
|
+
// it prints "PostToolUse:Glob hook warning" and keeps the ORIGINAL result,
|
|
432
|
+
// so the ghost entry leaks into the listing the model sees. The fix is to
|
|
433
|
+
// return the SAME shape: the filenames array with ghost entries dropped
|
|
434
|
+
// (and secret-looking names masked), preserving every other field.
|
|
435
|
+
if (toolResponse && typeof toolResponse === 'object' && Array.isArray(toolResponse.filenames)) {
|
|
436
|
+
const orig = toolResponse.filenames;
|
|
437
|
+
const kept = orig.filter((f) => !(ghostPats.length && ghostMatch(String(f), ghostPats)));
|
|
438
|
+
ghostFired = kept.length !== orig.length;
|
|
439
|
+
const masked = kept.map((f) => redactName(String(f)));
|
|
440
|
+
const changed = ghostFired || masked.some((f, i) => f !== String(kept[i]));
|
|
441
|
+
if (changed) {
|
|
442
|
+
const updated = { ...toolResponse, filenames: masked, numFiles: masked.length };
|
|
443
|
+
if (typeof toolResponse.totalMatches === 'number') updated.totalMatches = masked.length;
|
|
444
|
+
EMITTED_PAYLOAD = JSON.stringify({
|
|
445
|
+
hookSpecificOutput: { hookEventName: 'PostToolUse', updatedToolOutput: updated },
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
} else {
|
|
449
|
+
// String-output tools (Bash, Read, text Grep, MCP listers): strip ghost
|
|
450
|
+
// lines and redact secrets in the TEXT, return a STRING.
|
|
451
|
+
const ghostText = buildGhostOutput(toolName, toolInput, toolResponse, toolOutput, ghostPats);
|
|
452
|
+
ghostFired = typeof ghostText === 'string';
|
|
453
|
+
let out = typeof ghostText === 'string' ? ghostText : null;
|
|
454
|
+
if (dlpCfg) {
|
|
455
|
+
const base = typeof ghostText === 'string' ? ghostText : extractOutputText(toolResponse, toolOutput);
|
|
456
|
+
if (typeof base === 'string') {
|
|
457
|
+
const redacted = dlpRedactText(base, dlpCfg);
|
|
458
|
+
if (redacted !== base || typeof ghostText === 'string') out = redacted;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (typeof out === 'string') {
|
|
462
|
+
// Preserve the tool's result SHAPE. Bash and other tools deliver a
|
|
463
|
+
// STRUCTURED result ({ stdout, stderr, ... } or { content }); Claude
|
|
464
|
+
// Code rejects a bare-string replacement for those (hook warning) and
|
|
465
|
+
// keeps the original. Clone the object and swap its text field; only a
|
|
466
|
+
// genuinely string-typed result is replaced with a string.
|
|
467
|
+
const tr = toolResponse;
|
|
468
|
+
let updated;
|
|
469
|
+
if (tr && typeof tr === 'object' && typeof tr.stdout === 'string') updated = { ...tr, stdout: out };
|
|
470
|
+
else if (tr && typeof tr === 'object' && typeof tr.content === 'string') updated = { ...tr, content: out };
|
|
471
|
+
else if (tr && typeof tr === 'object' && Array.isArray(tr.content)) updated = { ...tr, content: [{ type: 'text', text: out }] };
|
|
472
|
+
else updated = out;
|
|
473
|
+
EMITTED_PAYLOAD = JSON.stringify({
|
|
474
|
+
hookSpecificOutput: { hookEventName: 'PostToolUse', updatedToolOutput: updated },
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
} catch {}
|
|
479
|
+
|
|
480
|
+
const hasError = guardDenied ||
|
|
481
|
+
toolResponse.error ||
|
|
482
|
+
toolResponse.exitCode > 0 ||
|
|
483
|
+
toolResponse.isError ||
|
|
484
|
+
(toolOutput && typeof toolOutput === 'string' && toolOutput.includes('"error"')) ||
|
|
485
|
+
(resultJson && resultJson.includes('"error"'));
|
|
486
|
+
|
|
487
|
+
// Keep the full command/args for audit review (the dashboard shows them in
|
|
488
|
+
// the detail view). Only cap pathologically large values.
|
|
489
|
+
const argsSummary = {};
|
|
490
|
+
for (const [k, v] of Object.entries(toolInput)) {
|
|
491
|
+
argsSummary[k] = typeof v === 'string' && v.length > 8000
|
|
492
|
+
? v.slice(0, 8000) + '…'
|
|
493
|
+
: v;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Write flag so stop.mjs knows tool calls happened (skip text-only ALLOW)
|
|
497
|
+
try {
|
|
498
|
+
const flagDir = resolve('.solongate');
|
|
499
|
+
mkdirSync(flagDir, { recursive: true });
|
|
500
|
+
writeFileSync(join(flagDir, '.last-tool-call'), Date.now().toString());
|
|
501
|
+
} catch {}
|
|
502
|
+
|
|
503
|
+
// Flush the model-visible replacement to stdout, THEN exit. On Windows a
|
|
504
|
+
// bare process.exit() can truncate an un-drained pipe write, so Claude Code
|
|
505
|
+
// receives malformed hook JSON, prints "PostToolUse hook warning", and
|
|
506
|
+
// DISCARDS the replacement — leaving the ghost entry visible. Gate the exit
|
|
507
|
+
// on the write's flush callback (and on the fire-and-forget audit POST).
|
|
508
|
+
let flushed = (typeof EMITTED_PAYLOAD !== 'string');
|
|
509
|
+
let fetchDone = false;
|
|
510
|
+
const maybeExit = () => { if (flushed && fetchDone) process.exit(0); };
|
|
511
|
+
if (typeof EMITTED_PAYLOAD === 'string') {
|
|
512
|
+
try { process.stdout.write(EMITTED_PAYLOAD, () => { flushed = true; maybeExit(); }); }
|
|
513
|
+
catch { flushed = true; }
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const sessionId = data.session_id || data.sessionId || data.conversation_id || '';
|
|
517
|
+
const decision = hasError ? 'DENY' : 'ALLOW';
|
|
518
|
+
const reason = guardDenied ? 'blocked by policy guard' : hasError ? 'tool returned error' : ghostFired ? 'ghost path (hidden from agent)' : 'allowed';
|
|
519
|
+
const permission = guessPermission(toolName);
|
|
520
|
+
const evaluationTimeMs = readLastEvalMs(toolName, sessionId);
|
|
521
|
+
|
|
522
|
+
// Local log storage (opt-in): when ON, logs are kept LOCAL ONLY — we append
|
|
523
|
+
// this entry to the user's chosen file and do NOT send it to the cloud.
|
|
524
|
+
const localLogs = loadLocalLogs();
|
|
525
|
+
if (localLogs) {
|
|
526
|
+
appendLocalLog(localLogs, {
|
|
527
|
+
ts: new Date().toISOString(),
|
|
528
|
+
tool: toolName, arguments: argsSummary, decision, reason, permission,
|
|
529
|
+
evaluation_time_ms: evaluationTimeMs, agent_id: AGENT_ID, agent_name: AGENT_NAME, session_id: sessionId,
|
|
530
|
+
});
|
|
531
|
+
fetchDone = true;
|
|
532
|
+
maybeExit();
|
|
533
|
+
} else {
|
|
534
|
+
// Fire-and-forget: don't block tool execution waiting for API response
|
|
535
|
+
fetch(`${API_URL}/api/v1/audit-logs`, {
|
|
536
|
+
method: 'POST',
|
|
537
|
+
headers: {
|
|
538
|
+
'Authorization': `Bearer ${API_KEY}`,
|
|
539
|
+
'Content-Type': 'application/json',
|
|
540
|
+
},
|
|
541
|
+
body: JSON.stringify({
|
|
542
|
+
tool: toolName,
|
|
543
|
+
arguments: argsSummary,
|
|
544
|
+
// Ghost-on-a-listing is NOT a denial: the call was ALLOWED and succeeded,
|
|
545
|
+
// we just hid ghost entries from its result. Log it as ALLOW but carry the
|
|
546
|
+
// ghost reason so the dashboard can badge it Ghost (the allow-side twin of
|
|
547
|
+
// the guard's DENY+Ghost for a direct ghost hit). Real denials stay DENY.
|
|
548
|
+
decision,
|
|
549
|
+
reason,
|
|
550
|
+
permission,
|
|
551
|
+
source: `${AGENT_ID}-hook`,
|
|
552
|
+
evaluationTimeMs,
|
|
553
|
+
agent_id: AGENT_ID,
|
|
554
|
+
agent_name: AGENT_NAME,
|
|
555
|
+
session_id: sessionId,
|
|
556
|
+
}),
|
|
557
|
+
signal: AbortSignal.timeout(5000),
|
|
558
|
+
}).catch(() => {}).finally(() => { fetchDone = true; maybeExit(); });
|
|
559
|
+
}
|
|
560
|
+
// Hard backstop: exit even if the write callback or fetch never settles.
|
|
561
|
+
setTimeout(() => process.exit(0), 3000);
|
|
562
|
+
} catch {
|
|
563
|
+
process.exit(0);
|
|
564
|
+
}
|
|
565
|
+
});
|