@aerostack/gateway 0.13.0 → 0.13.2
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/hook-server.js +90 -25
- package/package.json +1 -1
package/dist/hook-server.js
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* Also manages ~/.claude/settings.json hook entries when enabled/disabled.
|
|
11
11
|
*/
|
|
12
12
|
import { createServer } from 'node:http';
|
|
13
|
-
import { readFile, writeFile } from 'node:fs/promises';
|
|
13
|
+
import { readFile, writeFile, appendFile, stat } from 'node:fs/promises';
|
|
14
|
+
import { watch } from 'node:fs';
|
|
14
15
|
import { homedir } from 'node:os';
|
|
15
16
|
import { join } from 'node:path';
|
|
16
17
|
import { info, warn, debug } from './logger.js';
|
|
@@ -158,34 +159,93 @@ function handleHookRequest(req, res) {
|
|
|
158
159
|
});
|
|
159
160
|
}
|
|
160
161
|
// ─── Public API ───────────────────────────────────────────────────────────
|
|
162
|
+
let fileWatcher = null;
|
|
163
|
+
let lastFileSize = 0;
|
|
164
|
+
/** Process new lines appended to the JSONL events file. */
|
|
165
|
+
async function processNewEvents() {
|
|
166
|
+
try {
|
|
167
|
+
const st = await stat(HOOK_EVENTS_FILE).catch(() => null);
|
|
168
|
+
if (!st || st.size <= lastFileSize)
|
|
169
|
+
return;
|
|
170
|
+
const content = await readFile(HOOK_EVENTS_FILE, 'utf-8');
|
|
171
|
+
const lines = content.split('\n').filter(Boolean);
|
|
172
|
+
// Only process lines we haven't seen (based on byte offset)
|
|
173
|
+
const newContent = content.slice(lastFileSize);
|
|
174
|
+
lastFileSize = st.size;
|
|
175
|
+
const newLines = newContent.split('\n').filter(Boolean);
|
|
176
|
+
for (const line of newLines) {
|
|
177
|
+
try {
|
|
178
|
+
const data = JSON.parse(line);
|
|
179
|
+
const toolName = data.tool_name ?? '';
|
|
180
|
+
// Skip read-only tools
|
|
181
|
+
if (READONLY_TOOLS.has(toolName))
|
|
182
|
+
continue;
|
|
183
|
+
if (!MUTATION_TOOLS.has(toolName) && !bridgeConfig.tools.includes(toolName))
|
|
184
|
+
continue;
|
|
185
|
+
const toolInput = data.tool_input ?? {};
|
|
186
|
+
const { category, risk } = detectCategory(toolName, toolInput);
|
|
187
|
+
const summary = summarizeToolInput(toolName, toolInput);
|
|
188
|
+
addToBatch({
|
|
189
|
+
action: `${toolName}: ${summary}`.slice(0, 500),
|
|
190
|
+
category,
|
|
191
|
+
risk_level: risk,
|
|
192
|
+
details: JSON.stringify({ tool: toolName, ...toolInput }).slice(0, 500),
|
|
193
|
+
});
|
|
194
|
+
debug(`File hook event: ${toolName}`, { category });
|
|
195
|
+
}
|
|
196
|
+
catch { /* skip malformed lines */ }
|
|
197
|
+
}
|
|
198
|
+
// Truncate file if it gets too large (>1MB)
|
|
199
|
+
if (st.size > 1_048_576) {
|
|
200
|
+
await writeFile(HOOK_EVENTS_FILE, '', 'utf-8');
|
|
201
|
+
lastFileSize = 0;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
catch { /* file may not exist yet */ }
|
|
205
|
+
}
|
|
161
206
|
export async function startHookServer(flushFn, port = DEFAULT_PORT) {
|
|
162
207
|
batchFlushFn = flushFn;
|
|
163
|
-
//
|
|
164
|
-
|
|
208
|
+
// Start HTTP server (still useful for non-Claude clients)
|
|
209
|
+
const actualPort = await new Promise((resolve, reject) => {
|
|
165
210
|
httpServer = createServer(handleHookRequest);
|
|
166
211
|
httpServer.on('error', (err) => {
|
|
167
212
|
if (err.code === 'EADDRINUSE') {
|
|
168
|
-
// Try next port
|
|
169
213
|
info(`Port ${port} in use, trying ${port + 1}`);
|
|
170
214
|
httpServer.close();
|
|
171
215
|
startHookServer(flushFn, port + 1).then(resolve).catch(reject);
|
|
172
216
|
}
|
|
173
217
|
else {
|
|
174
|
-
|
|
218
|
+
// Non-fatal — file-based hooks still work without HTTP server
|
|
219
|
+
warn('HTTP hook server failed, using file-based hooks only', { error: err.message });
|
|
220
|
+
resolve(0);
|
|
175
221
|
}
|
|
176
222
|
});
|
|
177
223
|
httpServer.listen(port, '127.0.0.1', () => {
|
|
178
224
|
serverPort = port;
|
|
179
225
|
info(`Hook server listening on http://127.0.0.1:${port}/hook`);
|
|
180
|
-
// Start batch flush timer
|
|
181
|
-
flushTimer = setInterval(() => {
|
|
182
|
-
flushBatch().catch(err => {
|
|
183
|
-
warn('Batch flush failed', { error: err instanceof Error ? err.message : String(err) });
|
|
184
|
-
});
|
|
185
|
-
}, BATCH_INTERVAL_MS);
|
|
186
226
|
resolve(port);
|
|
187
227
|
});
|
|
188
228
|
});
|
|
229
|
+
// Start file watcher for JSONL events (primary hook mechanism — no port conflicts)
|
|
230
|
+
try {
|
|
231
|
+
// Touch file so watcher has something to watch
|
|
232
|
+
await appendFile(HOOK_EVENTS_FILE, '');
|
|
233
|
+
fileWatcher = watch(HOOK_EVENTS_FILE, () => {
|
|
234
|
+
processNewEvents().catch(() => { });
|
|
235
|
+
});
|
|
236
|
+
info('File watcher started', { path: HOOK_EVENTS_FILE });
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
warn('File watcher failed', { error: err instanceof Error ? err.message : String(err) });
|
|
240
|
+
}
|
|
241
|
+
// Start batch flush timer — also polls JSONL file as safety net for fs.watch misses
|
|
242
|
+
flushTimer = setInterval(() => {
|
|
243
|
+
processNewEvents().catch(() => { });
|
|
244
|
+
flushBatch().catch(err => {
|
|
245
|
+
warn('Batch flush failed', { error: err instanceof Error ? err.message : String(err) });
|
|
246
|
+
});
|
|
247
|
+
}, BATCH_INTERVAL_MS);
|
|
248
|
+
return actualPort;
|
|
189
249
|
}
|
|
190
250
|
export function stopHookServer() {
|
|
191
251
|
if (flushTimer) {
|
|
@@ -194,6 +254,10 @@ export function stopHookServer() {
|
|
|
194
254
|
}
|
|
195
255
|
// Final flush
|
|
196
256
|
flushBatch().catch(() => { });
|
|
257
|
+
if (fileWatcher) {
|
|
258
|
+
fileWatcher.close();
|
|
259
|
+
fileWatcher = null;
|
|
260
|
+
}
|
|
197
261
|
if (httpServer) {
|
|
198
262
|
httpServer.close();
|
|
199
263
|
httpServer = null;
|
|
@@ -202,7 +266,10 @@ export function stopHookServer() {
|
|
|
202
266
|
}
|
|
203
267
|
// ─── Claude Code hook management ──────────────────────────────────────────
|
|
204
268
|
const HOOK_MARKER = '/* aerostack-guardian-hook */';
|
|
205
|
-
|
|
269
|
+
/** Path where hook events are written as JSONL (one JSON object per line).
|
|
270
|
+
* Use /tmp/ explicitly — macOS tmpdir() returns /var/folders/... which differs between processes. */
|
|
271
|
+
export const HOOK_EVENTS_FILE = '/tmp/aerostack-guardian-events.jsonl';
|
|
272
|
+
export async function installClaudeHook(_port) {
|
|
206
273
|
const settingsPath = join(homedir(), '.claude', 'settings.json');
|
|
207
274
|
try {
|
|
208
275
|
let settings = {};
|
|
@@ -215,28 +282,26 @@ export async function installClaudeHook(port) {
|
|
|
215
282
|
}
|
|
216
283
|
const hooks = (settings.hooks ?? {});
|
|
217
284
|
const preToolUse = (hooks.PreToolUse ?? []);
|
|
218
|
-
//
|
|
219
|
-
const
|
|
285
|
+
// Remove any old HTTP-based aerostack hooks
|
|
286
|
+
const cleaned = preToolUse.filter(h => {
|
|
220
287
|
const innerHooks = (h.hooks ?? []);
|
|
221
|
-
return innerHooks.some(ih => ih.url?.includes('127.0.0.1') && ih.url?.includes('/hook'))
|
|
288
|
+
return !innerHooks.some(ih => (ih.url?.includes('127.0.0.1') && ih.url?.includes('/hook')) ||
|
|
289
|
+
(ih.command?.includes('aerostack-guardian')));
|
|
222
290
|
});
|
|
291
|
+
// Command hook: reads stdin JSON, appends to JSONL file
|
|
292
|
+
// No HTTP, no port, no conflicts between sessions
|
|
223
293
|
const hookEntry = {
|
|
224
294
|
matcher: 'Bash|Write|Edit',
|
|
225
295
|
hooks: [{
|
|
226
|
-
type: '
|
|
227
|
-
|
|
296
|
+
type: 'command',
|
|
297
|
+
command: `cat >> ${HOOK_EVENTS_FILE}`,
|
|
228
298
|
}],
|
|
229
299
|
};
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
}
|
|
233
|
-
else {
|
|
234
|
-
preToolUse.push(hookEntry);
|
|
235
|
-
}
|
|
236
|
-
hooks.PreToolUse = preToolUse;
|
|
300
|
+
cleaned.push(hookEntry);
|
|
301
|
+
hooks.PreToolUse = cleaned;
|
|
237
302
|
settings.hooks = hooks;
|
|
238
303
|
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
239
|
-
info('Installed Claude Code hook', {
|
|
304
|
+
info('Installed Claude Code hook (file-based)', { eventsFile: HOOK_EVENTS_FILE, path: settingsPath });
|
|
240
305
|
return true;
|
|
241
306
|
}
|
|
242
307
|
catch (err) {
|