@solongate/proxy 0.49.1 → 0.50.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,293 +1,379 @@
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 init
6
- */
7
- import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs';
8
- import { resolve, join } from 'node:path';
9
- import { homedir } from 'node:os';
10
-
11
- function loadEnvKey(dir) {
12
- try {
13
- const envPath = resolve(dir, '.env');
14
- if (!existsSync(envPath)) return {};
15
- const lines = readFileSync(envPath, 'utf-8').split('\n');
16
- const env = {};
17
- for (const line of lines) {
18
- const m = line.match(/^([A-Z_]+)=(.*)$/);
19
- if (m) env[m[1]] = m[2].replace(/^["']|["']$/g, '').trim();
20
- }
21
- return env;
22
- } catch { return {}; }
23
- }
24
-
25
- // Global cloud config written by `init --global` (~/.solongate/cloud-guard.json).
26
- // A system-wide PostToolUse hook runs from any cwd, so a project .env can't be
27
- // relied on for the key — read the absolute global config too.
28
- function loadGlobalCloudConfig() {
29
- try {
30
- const p = resolve(homedir(), '.solongate', 'cloud-guard.json');
31
- if (!existsSync(p)) return {};
32
- const cfg = JSON.parse(readFileSync(p, 'utf-8'));
33
- return (cfg && typeof cfg === 'object') ? cfg : {};
34
- } catch { return {}; }
35
- }
36
-
37
- // ── Ghost paths (PostToolUse twin of guard.mjs) ──
38
- // The guard's PreToolUse hook blocks MUTATIONS to hidden paths; here we make
39
- // hidden paths invisible to READS and LISTINGS by rewriting the tool output the
40
- // model sees (Claude Code `updatedToolOutput`). The matcher below is mirrored
41
- // verbatim from guard.mjs — keep the two in sync. Config is read from the policy
42
- // cache the guard just wrote (same PreToolUse call), so no extra API request.
43
- function ghostGlobToRegExp(glob) {
44
- let re = '';
45
- for (let i = 0; i < glob.length; i++) {
46
- const c = glob[i];
47
- if (c === '*') {
48
- if (glob[i + 1] === '*') { re += '.*'; i++; }
49
- else re += '[^/]*';
50
- } else if (c === '?') re += '[^/]';
51
- else if ('\\^$.|+()[]{}'.indexOf(c) !== -1) re += '\\' + c;
52
- else re += c;
53
- }
54
- try { return new RegExp('^' + re + '$'); } catch { return null; }
55
- }
56
- function ghostMatch(targetPath, patterns) {
57
- if (!targetPath || !Array.isArray(patterns) || patterns.length === 0) return false;
58
- const norm = String(targetPath).replace(/\\/g, '/').replace(/\/+$/, '');
59
- if (!norm) return false;
60
- const segments = norm.split('/').filter(Boolean);
61
- const base = segments.length ? segments[segments.length - 1] : norm;
62
- for (let pat of patterns) {
63
- pat = String(pat || '').trim();
64
- if (!pat) continue;
65
- let dirOnly = false;
66
- if (pat.endsWith('/')) { dirOnly = true; pat = pat.slice(0, -1); }
67
- if (!pat) continue;
68
- const hasSlash = pat.indexOf('/') !== -1;
69
- const hasWild = /[*?]/.test(pat);
70
- const re = ghostGlobToRegExp(pat);
71
- if (!re) continue;
72
- if (dirOnly) {
73
- if (!hasSlash && !hasWild) { if (segments.indexOf(pat) !== -1) return true; continue; }
74
- let acc = '';
75
- for (const s of segments) { acc = acc ? acc + '/' + s : s; if (re.test(acc) || re.test(s)) return true; }
76
- continue;
77
- }
78
- if (!hasSlash) {
79
- if (re.test(base)) return true;
80
- if (segments.some((s) => re.test(s))) return true;
81
- continue;
82
- }
83
- if (re.test(norm)) return true;
84
- }
85
- return false;
86
- }
87
- function ghostCleanToken(tok) {
88
- let t = String(tok || '').trim();
89
- t = t.replace(/^[<>|;&(]+/, '').replace(/[);&|]+$/, '');
90
- t = t.replace(/^['"]+/, '').replace(/['"]+$/, '');
91
- t = t.replace(/^\d*>>?/, '');
92
- return t.trim();
93
- }
94
- // Drop listing lines that reference a ghost entry. `find`/glob output (one path
95
- // per line) drops the whole line; multi-column `ls -l` rows drop entirely; a
96
- // bare space-separated `ls` row drops only the matching names.
97
- function ghostStripLines(text, pats) {
98
- const lines = String(text).split('\n');
99
- const kept = [];
100
- for (const line of lines) {
101
- const trimmed = line.trim();
102
- if (!trimmed) { kept.push(line); continue; }
103
- if (ghostMatch(trimmed, pats)) continue; // full-path listing line
104
- const toks = trimmed.split(/\s+/);
105
- const anyHit = toks.some((t) => ghostMatch(ghostCleanToken(t), pats));
106
- if (!anyHit) { kept.push(line); continue; }
107
- if (toks.length > 3) continue; // ls -l style row → drop entirely
108
- const remaining = toks.filter((t) => !ghostMatch(ghostCleanToken(t), pats));
109
- if (remaining.length === 0) continue;
110
- kept.push(remaining.join(' '));
111
- }
112
- return kept.join('\n');
113
- }
114
- // Read ghost patterns from the policy cache the guard wrote on the matching
115
- // PreToolUse call. Same agent-key derivation as guard.mjs.
116
- function loadGhostPatterns() {
117
- try {
118
- const sel = (process.env.SOLONGATE_AGENT_ID || process.argv[2] || 'default').replace(/[^a-zA-Z0-9_-]/g, '_');
119
- const f = resolve(homedir(), '.solongate', '.policy-cache-' + sel + '.json');
120
- if (!existsSync(f)) return [];
121
- const c = JSON.parse(readFileSync(f, 'utf-8'));
122
- const g = c && c.security && c.security.ghost;
123
- return g && Array.isArray(g.patterns) ? g.patterns : [];
124
- } catch { return []; }
125
- }
126
- // Returns the rewritten output text, or null if nothing is hidden.
127
- function buildGhostOutput(toolName, toolInput, toolResponse, toolOutput, pats) {
128
- if (!Array.isArray(pats) || pats.length === 0) return null;
129
- const name = toolName || '';
130
- const getText = () => {
131
- if (typeof toolResponse === 'string') return toolResponse;
132
- if (toolResponse && typeof toolResponse.stdout === 'string') return toolResponse.stdout;
133
- if (toolResponse && typeof toolResponse.content === 'string') return toolResponse.content;
134
- if (typeof toolOutput === 'string' && toolOutput) return toolOutput;
135
- return null;
136
- };
137
- try {
138
- // Direct read of a hidden file → looks like it doesn't exist.
139
- if (name === 'Read' || name === 'NotebookRead') {
140
- const p = toolInput && (toolInput.file_path || toolInput.notebook_path);
141
- if (p && ghostMatch(p, pats)) return p + ': No such file or directory';
142
- return null;
143
- }
144
- if (name === 'Bash' || name === 'BashOutput') {
145
- const text = getText();
146
- if (text == null) return null;
147
- const cmd = String((toolInput && toolInput.command) || '');
148
- for (const raw of cmd.split(/\s+/)) {
149
- const t = ghostCleanToken(raw);
150
- // A command that names the hidden path directly (cat A/Y/.data, ls A/Y)
151
- // → not-found, regardless of what the command actually returned.
152
- if (t && t[0] !== '-' && ghostMatch(t, pats)) return t + ': No such file or directory';
153
- }
154
- const stripped = ghostStripLines(text, pats);
155
- return stripped === text ? null : stripped;
156
- }
157
- // Listing-style tools → strip hidden entries from the result.
158
- if (name === 'Glob' || name === 'Grep' || name === 'LS') {
159
- const text = getText();
160
- if (text == null) return null;
161
- const stripped = ghostStripLines(text, pats);
162
- return stripped === text ? null : stripped;
163
- }
164
- } catch { /* fail open */ }
165
- return null;
166
- }
167
-
168
- function guessPermission(toolName) {
169
- const name = (toolName || '').toLowerCase();
170
- if (name.includes('exec') || name.includes('shell') || name.includes('run') || name.includes('eval') || name === 'bash') return 'EXECUTE';
171
- 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';
172
- 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';
173
- return 'READ';
174
- }
175
-
176
- const dotenv = loadEnvKey(process.cwd());
177
- const globalCfg = loadGlobalCloudConfig();
178
- const API_KEY = process.env.SOLONGATE_API_KEY || dotenv.SOLONGATE_API_KEY || globalCfg.apiKey || '';
179
- const API_URL = process.env.SOLONGATE_API_URL || dotenv.SOLONGATE_API_URL || globalCfg.apiUrl || 'https://api.solongate.com';
180
-
181
- // Agent identity from CLI args: node audit.mjs <agent_id> <agent_name>
182
- const AGENT_ID = process.argv[2] || 'claude-code';
183
- const AGENT_NAME = process.argv[3] || 'Claude Code';
184
-
185
- // Accept both live and test keys (test keys are used for trials / sandboxes).
186
- if (!API_KEY || !(API_KEY.startsWith('sg_live_') || API_KEY.startsWith('sg_test_'))) process.exit(0);
187
-
188
- let input = '';
189
- process.stdin.on('data', c => input += c);
190
- process.stdin.on('end', async () => {
191
- try {
192
- const data = JSON.parse(input);
193
-
194
- // Debug: append raw stdin to file for agent detection troubleshooting.
195
- // Opt-in (SOLONGATE_DEBUG) so a global hook doesn't litter every cwd.
196
- if (process.env.SOLONGATE_DEBUG) {
197
- try {
198
- 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';
199
- const { appendFileSync: afs, mkdirSync: mds } = await import('node:fs');
200
- mds(resolve('.solongate'), { recursive: true });
201
- afs(resolve('.solongate', '.debug-audit-log'), debugLine);
202
- } catch {}
203
- }
204
-
205
- let toolName = data.tool_name || data.toolName || '';
206
- let toolInput = data.tool_input || data.toolInput || data.params || {};
207
- if (!toolName) toolName = 'unknown';
208
-
209
- if (toolName === 'Bash' && JSON.stringify(toolInput).includes('audit-logs')) {
210
- process.exit(0);
211
- }
212
-
213
- // Check if guard.mjs already logged a DENY for this tool (avoid duplicate ALLOW after DENY)
214
- let guardDenied = false;
215
- try {
216
- const denyFlagPath = resolve('.solongate', '.last-deny');
217
- if (existsSync(denyFlagPath)) {
218
- const flag = JSON.parse(readFileSync(denyFlagPath, 'utf-8'));
219
- // If deny was recent (< 10s) and same tool, this postToolUse is a duplicate
220
- if (flag.ts && Date.now() - flag.ts < 10000 && flag.tool === toolName) {
221
- guardDenied = true;
222
- }
223
- }
224
- } catch {}
225
-
226
- const toolResponse = data.tool_response || data.toolResponse || {};
227
- const toolOutput = data.tool_output || data.toolOutput || '';
228
- const resultJson = data.result_json ? (typeof data.result_json === 'string' ? data.result_json : JSON.stringify(data.result_json)) : '';
229
-
230
- // Ghost paths: rewrite the output the model sees so hidden files/dirs are
231
- // stripped from listings and direct reads look like "no such file". Emit the
232
- // updated output BEFORE the (fire-and-forget) audit log. Fail-open: any
233
- // error leaves the original output untouched.
234
- try {
235
- const ghostText = buildGhostOutput(toolName, toolInput, toolResponse, toolOutput, loadGhostPatterns());
236
- if (typeof ghostText === 'string') {
237
- // updatedToolOutput must be a PLAIN STRING (an object form is silently
238
- // ignored by Claude Code). This replaces the tool result the model sees.
239
- process.stdout.write(JSON.stringify({
240
- hookSpecificOutput: { hookEventName: 'PostToolUse', updatedToolOutput: ghostText },
241
- }));
242
- }
243
- } catch {}
244
-
245
- const hasError = guardDenied ||
246
- toolResponse.error ||
247
- toolResponse.exitCode > 0 ||
248
- toolResponse.isError ||
249
- (toolOutput && typeof toolOutput === 'string' && toolOutput.includes('"error"')) ||
250
- (resultJson && resultJson.includes('"error"'));
251
-
252
- // Keep the full command/args for audit review (the dashboard shows them in
253
- // the detail view). Only cap pathologically large values.
254
- const argsSummary = {};
255
- for (const [k, v] of Object.entries(toolInput)) {
256
- argsSummary[k] = typeof v === 'string' && v.length > 8000
257
- ? v.slice(0, 8000) + '…'
258
- : v;
259
- }
260
-
261
- // Write flag so stop.mjs knows tool calls happened (skip text-only ALLOW)
262
- try {
263
- const flagDir = resolve('.solongate');
264
- mkdirSync(flagDir, { recursive: true });
265
- writeFileSync(join(flagDir, '.last-tool-call'), Date.now().toString());
266
- } catch {}
267
-
268
- // Fire-and-forget: don't block tool execution waiting for API response
269
- fetch(`${API_URL}/api/v1/audit-logs`, {
270
- method: 'POST',
271
- headers: {
272
- 'Authorization': `Bearer ${API_KEY}`,
273
- 'Content-Type': 'application/json',
274
- },
275
- body: JSON.stringify({
276
- tool: toolName,
277
- arguments: argsSummary,
278
- decision: hasError ? 'DENY' : 'ALLOW',
279
- reason: guardDenied ? 'blocked by policy guard' : hasError ? 'tool returned error' : 'allowed',
280
- permission: guessPermission(toolName),
281
- source: `${AGENT_ID}-hook`,
282
- evaluationTimeMs: 0,
283
- agent_id: AGENT_ID,
284
- agent_name: AGENT_NAME,
285
- }),
286
- signal: AbortSignal.timeout(5000),
287
- }).catch(() => {}).finally(() => process.exit(0));
288
- // Exit after short delay if fetch hangs on DNS/connect
289
- setTimeout(() => process.exit(0), 3000);
290
- } catch {
291
- process.exit(0);
292
- }
293
- });
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 } from 'node:fs';
8
+ import { resolve, join } from 'node:path';
9
+ import { homedir } from 'node:os';
10
+
11
+ function loadEnvKey(dir) {
12
+ try {
13
+ const envPath = resolve(dir, '.env');
14
+ if (!existsSync(envPath)) return {};
15
+ const lines = readFileSync(envPath, 'utf-8').split('\n');
16
+ const env = {};
17
+ for (const line of lines) {
18
+ const m = line.match(/^([A-Z_]+)=(.*)$/);
19
+ if (m) env[m[1]] = m[2].replace(/^["']|["']$/g, '').trim();
20
+ }
21
+ return env;
22
+ } catch { return {}; }
23
+ }
24
+
25
+ // Global cloud config written by `init --global` (~/.solongate/cloud-guard.json).
26
+ // A system-wide PostToolUse hook runs from any cwd, so a project .env can't be
27
+ // relied on for the key — read the absolute global config too.
28
+ function loadGlobalCloudConfig() {
29
+ try {
30
+ const p = resolve(homedir(), '.solongate', 'cloud-guard.json');
31
+ if (!existsSync(p)) return {};
32
+ const cfg = JSON.parse(readFileSync(p, 'utf-8'));
33
+ return (cfg && typeof cfg === 'object') ? cfg : {};
34
+ } catch { return {}; }
35
+ }
36
+
37
+ // ── Ghost paths (PostToolUse twin of guard.mjs) ──
38
+ // The guard's PreToolUse hook blocks MUTATIONS to hidden paths; here we make
39
+ // hidden paths invisible to READS and LISTINGS by rewriting the tool output the
40
+ // model sees (Claude Code `updatedToolOutput`). The matcher below is mirrored
41
+ // verbatim from guard.mjs — keep the two in sync. Config is read from the policy
42
+ // cache the guard just wrote (same PreToolUse call), so no extra API request.
43
+ function ghostGlobToRegExp(glob) {
44
+ let re = '';
45
+ for (let i = 0; i < glob.length; i++) {
46
+ const c = glob[i];
47
+ if (c === '*') {
48
+ if (glob[i + 1] === '*') { re += '.*'; i++; }
49
+ else re += '[^/]*';
50
+ } else if (c === '?') re += '[^/]';
51
+ else if ('\\^$.|+()[]{}'.indexOf(c) !== -1) re += '\\' + c;
52
+ else re += c;
53
+ }
54
+ try { return new RegExp('^' + re + '$'); } catch { return null; }
55
+ }
56
+ function ghostMatch(targetPath, patterns) {
57
+ if (!targetPath || !Array.isArray(patterns) || patterns.length === 0) return false;
58
+ const norm = String(targetPath).replace(/\\/g, '/').replace(/\/+$/, '');
59
+ if (!norm) return false;
60
+ const segments = norm.split('/').filter(Boolean);
61
+ const base = segments.length ? segments[segments.length - 1] : norm;
62
+ for (let pat of patterns) {
63
+ pat = String(pat || '').trim();
64
+ if (!pat) continue;
65
+ let dirOnly = false;
66
+ if (pat.endsWith('/')) { dirOnly = true; pat = pat.slice(0, -1); }
67
+ if (!pat) continue;
68
+ const hasSlash = pat.indexOf('/') !== -1;
69
+ const hasWild = /[*?]/.test(pat);
70
+ const re = ghostGlobToRegExp(pat);
71
+ if (!re) continue;
72
+ if (dirOnly) {
73
+ if (!hasSlash && !hasWild) { if (segments.indexOf(pat) !== -1) return true; continue; }
74
+ let acc = '';
75
+ for (const s of segments) { acc = acc ? acc + '/' + s : s; if (re.test(acc) || re.test(s)) return true; }
76
+ continue;
77
+ }
78
+ if (!hasSlash) {
79
+ if (re.test(base)) return true;
80
+ if (segments.some((s) => re.test(s))) return true;
81
+ continue;
82
+ }
83
+ if (re.test(norm)) return true;
84
+ }
85
+ return false;
86
+ }
87
+ function ghostCleanToken(tok) {
88
+ let t = String(tok || '').trim();
89
+ t = t.replace(/^[<>|;&(]+/, '').replace(/[);&|]+$/, '');
90
+ t = t.replace(/^['"]+/, '').replace(/['"]+$/, '');
91
+ t = t.replace(/^\d*>>?/, '');
92
+ return t.trim();
93
+ }
94
+ // Drop listing lines that reference a ghost entry. `find`/glob output (one path
95
+ // per line) drops the whole line; multi-column `ls -l` rows drop entirely; a
96
+ // bare space-separated `ls` row drops only the matching names.
97
+ function ghostStripLines(text, pats) {
98
+ const lines = String(text).split('\n');
99
+ const kept = [];
100
+ for (const line of lines) {
101
+ const trimmed = line.trim();
102
+ if (!trimmed) { kept.push(line); continue; }
103
+ if (ghostMatch(trimmed, pats)) continue; // full-path listing line
104
+ const toks = trimmed.split(/\s+/);
105
+ const anyHit = toks.some((t) => ghostMatch(ghostCleanToken(t), pats));
106
+ if (!anyHit) { kept.push(line); continue; }
107
+ if (toks.length > 3) continue; // ls -l style row → drop entirely
108
+ const remaining = toks.filter((t) => !ghostMatch(ghostCleanToken(t), pats));
109
+ if (remaining.length === 0) continue;
110
+ kept.push(remaining.join(' '));
111
+ }
112
+ return kept.join('\n');
113
+ }
114
+ // Read ghost patterns from the policy cache the guard wrote on the matching
115
+ // PreToolUse call. Same agent-key derivation as guard.mjs.
116
+ function loadGhostPatterns() {
117
+ try {
118
+ const sel = (process.env.SOLONGATE_AGENT_ID || process.argv[2] || 'default').replace(/[^a-zA-Z0-9_-]/g, '_');
119
+ const f = resolve(homedir(), '.solongate', '.policy-cache-' + sel + '.json');
120
+ if (!existsSync(f)) return [];
121
+ const c = JSON.parse(readFileSync(f, 'utf-8'));
122
+ const g = c && c.security && c.security.ghost;
123
+ return g && Array.isArray(g.patterns) ? g.patterns : [];
124
+ } catch { return []; }
125
+ }
126
+ // Returns the rewritten output text, or null if nothing is hidden.
127
+ function buildGhostOutput(toolName, toolInput, toolResponse, toolOutput, pats) {
128
+ if (!Array.isArray(pats) || pats.length === 0) return null;
129
+ const name = toolName || '';
130
+ const getText = () => {
131
+ if (typeof toolResponse === 'string') return toolResponse;
132
+ if (toolResponse && typeof toolResponse.stdout === 'string') return toolResponse.stdout;
133
+ if (toolResponse && typeof toolResponse.content === 'string') return toolResponse.content;
134
+ if (typeof toolOutput === 'string' && toolOutput) return toolOutput;
135
+ return null;
136
+ };
137
+ try {
138
+ // Direct read of a hidden file → looks like it doesn't exist.
139
+ if (name === 'Read' || name === 'NotebookRead') {
140
+ const p = toolInput && (toolInput.file_path || toolInput.notebook_path);
141
+ if (p && ghostMatch(p, pats)) return p + ': No such file or directory';
142
+ return null;
143
+ }
144
+ if (name === 'Bash' || name === 'BashOutput') {
145
+ const text = getText();
146
+ if (text == null) return null;
147
+ const cmd = String((toolInput && toolInput.command) || '');
148
+ for (const raw of cmd.split(/\s+/)) {
149
+ const t = ghostCleanToken(raw);
150
+ // A command that names the hidden path directly (cat A/Y/.data, ls A/Y)
151
+ // → not-found, regardless of what the command actually returned.
152
+ if (t && t[0] !== '-' && ghostMatch(t, pats)) return t + ': No such file or directory';
153
+ }
154
+ const stripped = ghostStripLines(text, pats);
155
+ return stripped === text ? null : stripped;
156
+ }
157
+ // Listing-style tools → strip hidden entries from the result.
158
+ if (name === 'Glob' || name === 'Grep' || name === 'LS') {
159
+ const text = getText();
160
+ if (text == null) return null;
161
+ const stripped = ghostStripLines(text, pats);
162
+ return stripped === text ? null : stripped;
163
+ }
164
+ } catch { /* fail open */ }
165
+ return null;
166
+ }
167
+
168
+ // ── DLP output redaction (PostToolUse) ──
169
+ // Same family as ghost, but ghost hides files/dirs by PATH while this masks
170
+ // secret VALUES inside the tool OUTPUT the model sees (file reads, stdout,
171
+ // fetched pages). Active whenever DLP is on (detect OR block) the server
172
+ // delivers the enabled pattern set as `security.dlpRedact` in the policy cache.
173
+ // Patterns mirror guard.mjs / apps/api/src/lib/security-layers.ts (global flag
174
+ // so every occurrence is replaced). Scanning RAW output text (not JSON) means
175
+ // the quote handling is exact — no escaping artifacts.
176
+ const DLP_PATTERNS = [
177
+ { name: 'AWS access key', re: /AKIA[0-9A-Z]{16}/g },
178
+ { name: 'private key block', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g },
179
+ { name: 'Anthropic key', re: /sk-ant-[A-Za-z0-9_-]{20,}/g },
180
+ { name: 'OpenAI key', re: /sk-(proj-)?[A-Za-z0-9_-]{20,}/g },
181
+ { name: 'GitHub token', re: /gh[pousr]_[A-Za-z0-9]{20,}/g },
182
+ { name: 'GitHub fine-grained PAT', re: /github_pat_[A-Za-z0-9_]{20,}/g },
183
+ { name: 'GitLab token', re: /glpat-[A-Za-z0-9_-]{20,}/g },
184
+ { name: 'Slack token', re: /xox[baprs]-[A-Za-z0-9-]{10,}/g },
185
+ { name: 'Google API key', re: /AIza[0-9A-Za-z_-]{35}/g },
186
+ { name: 'Stripe key', re: /[sr]k_(live|test)_[A-Za-z0-9]{20,}/g },
187
+ { name: 'SendGrid key', re: /SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/g },
188
+ { name: 'Twilio key', re: /SK[0-9a-fA-F]{32}/g },
189
+ { name: 'npm token', re: /npm_[A-Za-z0-9]{36}/g },
190
+ { name: 'JWT', re: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g },
191
+ { name: 'Bearer token', re: /bearer\s+[A-Za-z0-9._-]{20,}/gi },
192
+ { name: 'secret assignment', re: /(api[_-]?key|secret|token|password|passwd|access[_-]?key)["']?\s*[:=]\s*["']?[A-Za-z0-9/+_.-]{12,}/gi },
193
+ ];
194
+
195
+ // Read the redaction config the guard cached on the matching PreToolUse call.
196
+ function loadDlpRedact() {
197
+ try {
198
+ const sel = (process.env.SOLONGATE_AGENT_ID || process.argv[2] || 'default').replace(/[^a-zA-Z0-9_-]/g, '_');
199
+ const f = resolve(homedir(), '.solongate', '.policy-cache-' + sel + '.json');
200
+ if (!existsSync(f)) return null;
201
+ const c = JSON.parse(readFileSync(f, 'utf-8'));
202
+ const d = c && c.security && c.security.dlpRedact;
203
+ return d && Array.isArray(d.patterns) ? d : null;
204
+ } catch { return null; }
205
+ }
206
+
207
+ // Replace every secret match with a labelled placeholder. cfg = { patterns:
208
+ // string[] (enabled built-in names), custom: {name,re}[] }.
209
+ function dlpRedactText(text, cfg) {
210
+ if (!cfg || typeof text !== 'string' || !text) return text;
211
+ const allow = new Set(Array.isArray(cfg.patterns) ? cfg.patterns : []);
212
+ let out = text;
213
+ for (const p of DLP_PATTERNS) {
214
+ if (allow.has(p.name)) out = out.replace(p.re, '[REDACTED:' + p.name + ']');
215
+ }
216
+ for (const c of Array.isArray(cfg.custom) ? cfg.custom : []) {
217
+ try { out = out.replace(new RegExp(c.re, 'g'), '[REDACTED:' + (c.name || 'custom') + ']'); } catch { /* skip invalid */ }
218
+ }
219
+ return out;
220
+ }
221
+
222
+ // Best-effort extraction of the model-visible text from a tool_response. Shapes
223
+ // are undocumented, so probe the known fields (Bash stdout, Read file.content,
224
+ // string/array content) and fall back to a raw string output.
225
+ function extractOutputText(toolResponse, toolOutput) {
226
+ const r = toolResponse;
227
+ if (typeof r === 'string') return r;
228
+ if (r && typeof r === 'object') {
229
+ if (typeof r.stdout === 'string' && r.stdout) return r.stdout;
230
+ if (typeof r.content === 'string' && r.content) return r.content;
231
+ if (r.file && typeof r.file.content === 'string') return r.file.content;
232
+ if (Array.isArray(r.content)) {
233
+ const t = r.content.filter((x) => x && x.type === 'text' && typeof x.text === 'string').map((x) => x.text).join('\n');
234
+ if (t) return t;
235
+ }
236
+ }
237
+ if (typeof toolOutput === 'string' && toolOutput) return toolOutput;
238
+ return null;
239
+ }
240
+
241
+ function guessPermission(toolName) {
242
+ const name = (toolName || '').toLowerCase();
243
+ if (name.includes('exec') || name.includes('shell') || name.includes('run') || name.includes('eval') || name === 'bash') return 'EXECUTE';
244
+ 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';
245
+ 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';
246
+ return 'READ';
247
+ }
248
+
249
+ const dotenv = loadEnvKey(process.cwd());
250
+ const globalCfg = loadGlobalCloudConfig();
251
+ const API_KEY = process.env.SOLONGATE_API_KEY || dotenv.SOLONGATE_API_KEY || globalCfg.apiKey || '';
252
+ const API_URL = process.env.SOLONGATE_API_URL || dotenv.SOLONGATE_API_URL || globalCfg.apiUrl || 'https://api.solongate.com';
253
+
254
+ // Agent identity from CLI args: node audit.mjs <agent_id> <agent_name>
255
+ const AGENT_ID = process.argv[2] || 'claude-code';
256
+ const AGENT_NAME = process.argv[3] || 'Claude Code';
257
+
258
+ // Accept both live and test keys (test keys are used for trials / sandboxes).
259
+ if (!API_KEY || !(API_KEY.startsWith('sg_live_') || API_KEY.startsWith('sg_test_'))) process.exit(0);
260
+
261
+ let input = '';
262
+ process.stdin.on('data', c => input += c);
263
+ process.stdin.on('end', async () => {
264
+ try {
265
+ const data = JSON.parse(input);
266
+
267
+ // Debug: append raw stdin to file for agent detection troubleshooting.
268
+ // Opt-in (SOLONGATE_DEBUG) so a global hook doesn't litter every cwd.
269
+ if (process.env.SOLONGATE_DEBUG) {
270
+ try {
271
+ 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';
272
+ const { appendFileSync: afs, mkdirSync: mds } = await import('node:fs');
273
+ mds(resolve('.solongate'), { recursive: true });
274
+ afs(resolve('.solongate', '.debug-audit-log'), debugLine);
275
+ } catch {}
276
+ }
277
+
278
+ let toolName = data.tool_name || data.toolName || '';
279
+ let toolInput = data.tool_input || data.toolInput || data.params || {};
280
+ if (!toolName) toolName = 'unknown';
281
+
282
+ if (toolName === 'Bash' && JSON.stringify(toolInput).includes('audit-logs')) {
283
+ process.exit(0);
284
+ }
285
+
286
+ // Check if guard.mjs already logged a DENY for this tool (avoid duplicate ALLOW after DENY)
287
+ let guardDenied = false;
288
+ try {
289
+ const denyFlagPath = resolve('.solongate', '.last-deny');
290
+ if (existsSync(denyFlagPath)) {
291
+ const flag = JSON.parse(readFileSync(denyFlagPath, 'utf-8'));
292
+ // If deny was recent (< 10s) and same tool, this postToolUse is a duplicate
293
+ if (flag.ts && Date.now() - flag.ts < 10000 && flag.tool === toolName) {
294
+ guardDenied = true;
295
+ }
296
+ }
297
+ } catch {}
298
+
299
+ const toolResponse = data.tool_response || data.toolResponse || {};
300
+ const toolOutput = data.tool_output || data.toolOutput || '';
301
+ const resultJson = data.result_json ? (typeof data.result_json === 'string' ? data.result_json : JSON.stringify(data.result_json)) : '';
302
+
303
+ // Ghost paths: rewrite the output the model sees so hidden files/dirs are
304
+ // stripped from listings and direct reads look like "no such file". Emit the
305
+ // updated output BEFORE the (fire-and-forget) audit log. Fail-open: any
306
+ // error leaves the original output untouched.
307
+ try {
308
+ const ghostText = buildGhostOutput(toolName, toolInput, toolResponse, toolOutput, loadGhostPatterns());
309
+ // Layer DLP redaction on top: mask any secret VALUES in the output the
310
+ // model would see. If ghost already rewrote the output, redact that;
311
+ // otherwise redact the raw output. Only emit when something changed.
312
+ const dlpCfg = loadDlpRedact();
313
+ let out = typeof ghostText === 'string' ? ghostText : null;
314
+ if (dlpCfg) {
315
+ const base = typeof ghostText === 'string' ? ghostText : extractOutputText(toolResponse, toolOutput);
316
+ if (typeof base === 'string') {
317
+ const redacted = dlpRedactText(base, dlpCfg);
318
+ if (redacted !== base || typeof ghostText === 'string') out = redacted;
319
+ }
320
+ }
321
+ if (typeof out === 'string') {
322
+ // updatedToolOutput must be a PLAIN STRING (an object form is silently
323
+ // ignored by Claude Code). This replaces the tool result the model sees.
324
+ process.stdout.write(JSON.stringify({
325
+ hookSpecificOutput: { hookEventName: 'PostToolUse', updatedToolOutput: out },
326
+ }));
327
+ }
328
+ } catch {}
329
+
330
+ const hasError = guardDenied ||
331
+ toolResponse.error ||
332
+ toolResponse.exitCode > 0 ||
333
+ toolResponse.isError ||
334
+ (toolOutput && typeof toolOutput === 'string' && toolOutput.includes('"error"')) ||
335
+ (resultJson && resultJson.includes('"error"'));
336
+
337
+ // Keep the full command/args for audit review (the dashboard shows them in
338
+ // the detail view). Only cap pathologically large values.
339
+ const argsSummary = {};
340
+ for (const [k, v] of Object.entries(toolInput)) {
341
+ argsSummary[k] = typeof v === 'string' && v.length > 8000
342
+ ? v.slice(0, 8000) + '…'
343
+ : v;
344
+ }
345
+
346
+ // Write flag so stop.mjs knows tool calls happened (skip text-only ALLOW)
347
+ try {
348
+ const flagDir = resolve('.solongate');
349
+ mkdirSync(flagDir, { recursive: true });
350
+ writeFileSync(join(flagDir, '.last-tool-call'), Date.now().toString());
351
+ } catch {}
352
+
353
+ // Fire-and-forget: don't block tool execution waiting for API response
354
+ fetch(`${API_URL}/api/v1/audit-logs`, {
355
+ method: 'POST',
356
+ headers: {
357
+ 'Authorization': `Bearer ${API_KEY}`,
358
+ 'Content-Type': 'application/json',
359
+ },
360
+ body: JSON.stringify({
361
+ tool: toolName,
362
+ arguments: argsSummary,
363
+ decision: hasError ? 'DENY' : 'ALLOW',
364
+ reason: guardDenied ? 'blocked by policy guard' : hasError ? 'tool returned error' : 'allowed',
365
+ permission: guessPermission(toolName),
366
+ source: `${AGENT_ID}-hook`,
367
+ evaluationTimeMs: 0,
368
+ agent_id: AGENT_ID,
369
+ agent_name: AGENT_NAME,
370
+ session_id: data.session_id || data.sessionId || data.conversation_id || '',
371
+ }),
372
+ signal: AbortSignal.timeout(5000),
373
+ }).catch(() => {}).finally(() => process.exit(0));
374
+ // Exit after short delay if fetch hangs on DNS/connect
375
+ setTimeout(() => process.exit(0), 3000);
376
+ } catch {
377
+ process.exit(0);
378
+ }
379
+ });