@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/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
- // The path is a FOLDER; we write solongate-audit.jsonl inside it (creating the
270
- // folder if missing), then append one JSON line.
271
- function appendLocalLog(cfg, entry) {
272
- try {
273
- const dir = cfg.path.replace(/[\\/]+$/, '');
274
- try { mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
275
- appendFileSync(join(dir, 'solongate-audit.jsonl'), JSON.stringify(entry) + '\n');
276
- } catch { /* best-effort: never disturb the tool call */ }
277
- }
278
-
279
- // Replace every secret match with a labelled placeholder. cfg = { patterns:
280
- // string[] (enabled built-in names), custom: {name,re}[] }.
281
- // Custom patterns are GLOBs: `*` = any run of non-whitespace (so a fragment
282
- // matches a whole token), same wildcard mechanic as policy/ghost.
283
- function dlpGlobToRe(glob, flags) {
284
- let re = '';
285
- for (const ch of String(glob || '')) {
286
- if (ch === '*') re += '[^\\s]*';
287
- else if ('.+?^${}()|[]\\'.indexOf(ch) !== -1) re += '\\' + ch;
288
- else re += ch;
289
- }
290
- return new RegExp(re, flags);
291
- }
292
- function dlpRedactText(text, cfg) {
293
- if (!cfg || typeof text !== 'string' || !text) return text;
294
- const allow = new Set(Array.isArray(cfg.patterns) ? cfg.patterns : []);
295
- let out = text;
296
- for (const p of DLP_PATTERNS) {
297
- if (allow.has(p.name)) out = out.replace(p.re, '[REDACTED:' + p.name + ']');
298
- }
299
- for (const c of Array.isArray(cfg.custom) ? cfg.custom : []) {
300
- try { out = out.replace(dlpGlobToRe(c.re, 'g'), '[REDACTED:' + (c.name || 'custom') + ']'); } catch { /* skip invalid */ }
301
- }
302
- return out;
303
- }
304
-
305
- // Best-effort extraction of the model-visible text from a tool_response. Shapes
306
- // are undocumented, so probe the known fields (Bash stdout, Read file.content,
307
- // string/array content) and fall back to a raw string output.
308
- function extractOutputText(toolResponse, toolOutput) {
309
- const r = toolResponse;
310
- if (typeof r === 'string') return r;
311
- if (r && typeof r === 'object') {
312
- if (typeof r.stdout === 'string' && r.stdout) return r.stdout;
313
- if (typeof r.content === 'string' && r.content) return r.content;
314
- if (r.file && typeof r.file.content === 'string') return r.file.content;
315
- if (Array.isArray(r.content)) {
316
- const t = r.content.filter((x) => x && x.type === 'text' && typeof x.text === 'string').map((x) => x.text).join('\n');
317
- if (t) return t;
318
- }
319
- // Native Glob delivers its hits as a `filenames` string[] with NO stdout/
320
- // content field, so getText() returned null and the ghost/DLP strip silently
321
- // skipped it a ghosted file then survived in the Glob listing the model saw
322
- // (the exact leak: a `*ghosttest*` path showed up in a Glob result). Join to
323
- // one-path-per-line so ghostStripLines/dlpRedactText can act on it; the
324
- // PostToolUse updatedToolOutput then replaces the model-visible list.
325
- if (Array.isArray(r.filenames)) {
326
- const t = r.filenames.filter((x) => typeof x === 'string').join('\n');
327
- if (t) return t;
328
- }
329
- }
330
- if (typeof toolOutput === 'string' && toolOutput) return toolOutput;
331
- return null;
332
- }
333
-
334
- function guessPermission(toolName) {
335
- const name = (toolName || '').toLowerCase();
336
- if (name.includes('exec') || name.includes('shell') || name.includes('run') || name.includes('eval') || name === 'bash') return 'EXECUTE';
337
- 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';
338
- 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';
339
- return 'READ';
340
- }
341
-
342
- const dotenv = loadEnvKey(process.cwd());
343
- const globalCfg = loadGlobalCloudConfig();
344
- const API_KEY = process.env.SOLONGATE_API_KEY || dotenv.SOLONGATE_API_KEY || globalCfg.apiKey || '';
345
- const API_URL = process.env.SOLONGATE_API_URL || dotenv.SOLONGATE_API_URL || globalCfg.apiUrl || 'https://api.solongate.com';
346
-
347
- // Agent identity from CLI args: node audit.mjs <agent_id> <agent_name>
348
- const AGENT_ID = process.argv[2] || 'claude-code';
349
- const AGENT_NAME = process.argv[3] || 'Claude Code';
350
-
351
- // Accept both live and test keys (test keys are used for trials / sandboxes).
352
- if (!API_KEY || !(API_KEY.startsWith('sg_live_') || API_KEY.startsWith('sg_test_'))) process.exit(0);
353
-
354
- let input = '';
355
- process.stdin.on('data', c => input += c);
356
- process.stdin.on('end', async () => {
357
- try {
358
- const data = JSON.parse(input);
359
- let EMITTED_PAYLOAD = null;
360
-
361
- // Debug: append raw stdin to file for agent detection troubleshooting.
362
- // Opt-in (SOLONGATE_DEBUG) so a global hook doesn't litter every cwd.
363
- if (process.env.SOLONGATE_DEBUG) {
364
- try {
365
- 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';
366
- const { appendFileSync: afs, mkdirSync: mds } = await import('node:fs');
367
- mds(resolve('.solongate'), { recursive: true });
368
- afs(resolve('.solongate', '.debug-audit-log'), debugLine);
369
- } catch {}
370
- }
371
-
372
- let toolName = data.tool_name || data.toolName || '';
373
- let toolInput = data.tool_input || data.toolInput || data.params || {};
374
- if (!toolName) toolName = 'unknown';
375
-
376
- if (toolName === 'Bash' && JSON.stringify(toolInput).includes('audit-logs')) {
377
- process.exit(0);
378
- }
379
-
380
- // Check if guard.mjs already logged a DENY for this tool (avoid duplicate ALLOW after DENY)
381
- let guardDenied = false;
382
- try {
383
- const denyFlagPath = resolve('.solongate', '.last-deny');
384
- if (existsSync(denyFlagPath)) {
385
- const flag = JSON.parse(readFileSync(denyFlagPath, 'utf-8'));
386
- // If deny was recent (< 10s) and same tool, this postToolUse is a duplicate
387
- if (flag.ts && Date.now() - flag.ts < 10000 && flag.tool === toolName) {
388
- guardDenied = true;
389
- }
390
- }
391
- } catch {}
392
-
393
- const toolResponse = data.tool_response || data.toolResponse || {};
394
- const toolOutput = data.tool_output || data.toolOutput || '';
395
- const resultJson = data.result_json ? (typeof data.result_json === 'string' ? data.result_json : JSON.stringify(data.result_json)) : '';
396
-
397
- // Ghost paths: rewrite the output the model sees so hidden files/dirs are
398
- // stripped from listings and direct reads look like "no such file". Emit the
399
- // updated output BEFORE the (fire-and-forget) audit log. Fail-open: any
400
- // error leaves the original output untouched.
401
- let ghostFired = false;
402
- try {
403
- const ghostPats = loadGhostPatterns();
404
- const dlpCfg = loadDlpRedact();
405
- const redactName = (s) => (dlpCfg && typeof s === 'string') ? dlpRedactText(s, dlpCfg) : s;
406
-
407
- // Glob (and Grep in files mode) deliver a STRUCTURED result:
408
- // { filenames: string[], numFiles, truncated, totalMatches, ... }
409
- // Claude Code REJECTS a plain-string updatedToolOutput for such a tool —
410
- // it prints "PostToolUse:Glob hook warning" and keeps the ORIGINAL result,
411
- // so the ghost entry leaks into the listing the model sees. The fix is to
412
- // return the SAME shape: the filenames array with ghost entries dropped
413
- // (and secret-looking names masked), preserving every other field.
414
- if (toolResponse && typeof toolResponse === 'object' && Array.isArray(toolResponse.filenames)) {
415
- const orig = toolResponse.filenames;
416
- const kept = orig.filter((f) => !(ghostPats.length && ghostMatch(String(f), ghostPats)));
417
- ghostFired = kept.length !== orig.length;
418
- const masked = kept.map((f) => redactName(String(f)));
419
- const changed = ghostFired || masked.some((f, i) => f !== String(kept[i]));
420
- if (changed) {
421
- const updated = { ...toolResponse, filenames: masked, numFiles: masked.length };
422
- if (typeof toolResponse.totalMatches === 'number') updated.totalMatches = masked.length;
423
- EMITTED_PAYLOAD = JSON.stringify({
424
- hookSpecificOutput: { hookEventName: 'PostToolUse', updatedToolOutput: updated },
425
- });
426
- }
427
- } else {
428
- // String-output tools (Bash, Read, text Grep, MCP listers): strip ghost
429
- // lines and redact secrets in the TEXT, return a STRING.
430
- const ghostText = buildGhostOutput(toolName, toolInput, toolResponse, toolOutput, ghostPats);
431
- ghostFired = typeof ghostText === 'string';
432
- let out = typeof ghostText === 'string' ? ghostText : null;
433
- if (dlpCfg) {
434
- const base = typeof ghostText === 'string' ? ghostText : extractOutputText(toolResponse, toolOutput);
435
- if (typeof base === 'string') {
436
- const redacted = dlpRedactText(base, dlpCfg);
437
- if (redacted !== base || typeof ghostText === 'string') out = redacted;
438
- }
439
- }
440
- if (typeof out === 'string') {
441
- // Preserve the tool's result SHAPE. Bash and other tools deliver a
442
- // STRUCTURED result ({ stdout, stderr, ... } or { content }); Claude
443
- // Code rejects a bare-string replacement for those (hook warning) and
444
- // keeps the original. Clone the object and swap its text field; only a
445
- // genuinely string-typed result is replaced with a string.
446
- const tr = toolResponse;
447
- let updated;
448
- if (tr && typeof tr === 'object' && typeof tr.stdout === 'string') updated = { ...tr, stdout: out };
449
- else if (tr && typeof tr === 'object' && typeof tr.content === 'string') updated = { ...tr, content: out };
450
- else if (tr && typeof tr === 'object' && Array.isArray(tr.content)) updated = { ...tr, content: [{ type: 'text', text: out }] };
451
- else updated = out;
452
- EMITTED_PAYLOAD = JSON.stringify({
453
- hookSpecificOutput: { hookEventName: 'PostToolUse', updatedToolOutput: updated },
454
- });
455
- }
456
- }
457
- } catch {}
458
-
459
- const hasError = guardDenied ||
460
- toolResponse.error ||
461
- toolResponse.exitCode > 0 ||
462
- toolResponse.isError ||
463
- (toolOutput && typeof toolOutput === 'string' && toolOutput.includes('"error"')) ||
464
- (resultJson && resultJson.includes('"error"'));
465
-
466
- // Keep the full command/args for audit review (the dashboard shows them in
467
- // the detail view). Only cap pathologically large values.
468
- const argsSummary = {};
469
- for (const [k, v] of Object.entries(toolInput)) {
470
- argsSummary[k] = typeof v === 'string' && v.length > 8000
471
- ? v.slice(0, 8000) + ''
472
- : v;
473
- }
474
-
475
- // Write flag so stop.mjs knows tool calls happened (skip text-only ALLOW)
476
- try {
477
- const flagDir = resolve('.solongate');
478
- mkdirSync(flagDir, { recursive: true });
479
- writeFileSync(join(flagDir, '.last-tool-call'), Date.now().toString());
480
- } catch {}
481
-
482
- // Flush the model-visible replacement to stdout, THEN exit. On Windows a
483
- // bare process.exit() can truncate an un-drained pipe write, so Claude Code
484
- // receives malformed hook JSON, prints "PostToolUse hook warning", and
485
- // DISCARDS the replacement — leaving the ghost entry visible. Gate the exit
486
- // on the write's flush callback (and on the fire-and-forget audit POST).
487
- let flushed = (typeof EMITTED_PAYLOAD !== 'string');
488
- let fetchDone = false;
489
- const maybeExit = () => { if (flushed && fetchDone) process.exit(0); };
490
- if (typeof EMITTED_PAYLOAD === 'string') {
491
- try { process.stdout.write(EMITTED_PAYLOAD, () => { flushed = true; maybeExit(); }); }
492
- catch { flushed = true; }
493
- }
494
-
495
- const sessionId = data.session_id || data.sessionId || data.conversation_id || '';
496
- const decision = hasError ? 'DENY' : 'ALLOW';
497
- const reason = guardDenied ? 'blocked by policy guard' : hasError ? 'tool returned error' : ghostFired ? 'ghost path (hidden from agent)' : 'allowed';
498
- const permission = guessPermission(toolName);
499
- const evaluationTimeMs = readLastEvalMs(toolName, sessionId);
500
-
501
- // Local log storage (opt-in): when ON, logs are kept LOCAL ONLY — we append
502
- // this entry to the user's chosen file and do NOT send it to the cloud.
503
- const localLogs = loadLocalLogs();
504
- if (localLogs) {
505
- appendLocalLog(localLogs, {
506
- ts: new Date().toISOString(),
507
- tool: toolName, arguments: argsSummary, decision, reason, permission,
508
- evaluation_time_ms: evaluationTimeMs, agent_id: AGENT_ID, agent_name: AGENT_NAME, session_id: sessionId,
509
- });
510
- fetchDone = true;
511
- maybeExit();
512
- } else {
513
- // Fire-and-forget: don't block tool execution waiting for API response
514
- fetch(`${API_URL}/api/v1/audit-logs`, {
515
- method: 'POST',
516
- headers: {
517
- 'Authorization': `Bearer ${API_KEY}`,
518
- 'Content-Type': 'application/json',
519
- },
520
- body: JSON.stringify({
521
- tool: toolName,
522
- arguments: argsSummary,
523
- // Ghost-on-a-listing is NOT a denial: the call was ALLOWED and succeeded,
524
- // we just hid ghost entries from its result. Log it as ALLOW but carry the
525
- // ghost reason so the dashboard can badge it Ghost (the allow-side twin of
526
- // the guard's DENY+Ghost for a direct ghost hit). Real denials stay DENY.
527
- decision,
528
- reason,
529
- permission,
530
- source: `${AGENT_ID}-hook`,
531
- evaluationTimeMs,
532
- agent_id: AGENT_ID,
533
- agent_name: AGENT_NAME,
534
- session_id: sessionId,
535
- }),
536
- signal: AbortSignal.timeout(5000),
537
- }).catch(() => {}).finally(() => { fetchDone = true; maybeExit(); });
538
- }
539
- // Hard backstop: exit even if the write callback or fetch never settles.
540
- setTimeout(() => process.exit(0), 3000);
541
- } catch {
542
- process.exit(0);
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
+ });