@aerostack/gateway 0.13.0 → 0.13.1
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 -26
- package/package.json +1 -1
package/dist/hook-server.js
CHANGED
|
@@ -10,8 +10,9 @@
|
|
|
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';
|
|
14
|
-
import {
|
|
13
|
+
import { readFile, writeFile, appendFile, stat } from 'node:fs/promises';
|
|
14
|
+
import { watch } from 'node:fs';
|
|
15
|
+
import { homedir, tmpdir } from 'node:os';
|
|
15
16
|
import { join } from 'node:path';
|
|
16
17
|
import { info, warn, debug } from './logger.js';
|
|
17
18
|
// ─── Config ───────────────────────────────────────────────────────────────
|
|
@@ -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,9 @@ 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
|
+
export const HOOK_EVENTS_FILE = join(tmpdir(), 'aerostack-guardian-events.jsonl');
|
|
271
|
+
export async function installClaudeHook(_port) {
|
|
206
272
|
const settingsPath = join(homedir(), '.claude', 'settings.json');
|
|
207
273
|
try {
|
|
208
274
|
let settings = {};
|
|
@@ -215,28 +281,26 @@ export async function installClaudeHook(port) {
|
|
|
215
281
|
}
|
|
216
282
|
const hooks = (settings.hooks ?? {});
|
|
217
283
|
const preToolUse = (hooks.PreToolUse ?? []);
|
|
218
|
-
//
|
|
219
|
-
const
|
|
284
|
+
// Remove any old HTTP-based aerostack hooks
|
|
285
|
+
const cleaned = preToolUse.filter(h => {
|
|
220
286
|
const innerHooks = (h.hooks ?? []);
|
|
221
|
-
return innerHooks.some(ih => ih.url?.includes('127.0.0.1') && ih.url?.includes('/hook'))
|
|
287
|
+
return !innerHooks.some(ih => (ih.url?.includes('127.0.0.1') && ih.url?.includes('/hook')) ||
|
|
288
|
+
(ih.command?.includes('aerostack-guardian')));
|
|
222
289
|
});
|
|
290
|
+
// Command hook: reads stdin JSON, appends to JSONL file
|
|
291
|
+
// No HTTP, no port, no conflicts between sessions
|
|
223
292
|
const hookEntry = {
|
|
224
293
|
matcher: 'Bash|Write|Edit',
|
|
225
294
|
hooks: [{
|
|
226
|
-
type: '
|
|
227
|
-
|
|
295
|
+
type: 'command',
|
|
296
|
+
command: `cat >> ${HOOK_EVENTS_FILE}`,
|
|
228
297
|
}],
|
|
229
298
|
};
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
}
|
|
233
|
-
else {
|
|
234
|
-
preToolUse.push(hookEntry);
|
|
235
|
-
}
|
|
236
|
-
hooks.PreToolUse = preToolUse;
|
|
299
|
+
cleaned.push(hookEntry);
|
|
300
|
+
hooks.PreToolUse = cleaned;
|
|
237
301
|
settings.hooks = hooks;
|
|
238
302
|
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
239
|
-
info('Installed Claude Code hook', {
|
|
303
|
+
info('Installed Claude Code hook (file-based)', { eventsFile: HOOK_EVENTS_FILE, path: settingsPath });
|
|
240
304
|
return true;
|
|
241
305
|
}
|
|
242
306
|
catch (err) {
|