toolfunnel 0.3.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/LICENSE +21 -0
- package/README.md +324 -0
- package/bin/toolfunnel.js +112 -0
- package/docs/admin-ui.md +404 -0
- package/docs/configuration.md +402 -0
- package/docs/hooks-and-gating.md +428 -0
- package/docs/logging.md +223 -0
- package/docs/management.md +429 -0
- package/docs/oauth.md +103 -0
- package/docs/setup.md +352 -0
- package/docs/tools-and-upstreams.md +469 -0
- package/hooks/hooks.manifest.json +27 -0
- package/hooks/scripts/.gitkeep +3 -0
- package/hooks/scripts/example-audit.js +21 -0
- package/hooks/scripts/example-deny-dangerous.js +31 -0
- package/mcp/expose.example.json +30 -0
- package/mcp/servers/mock-upstream/server.js +170 -0
- package/package.json +63 -0
- package/src/auth/config.js +172 -0
- package/src/auth/install.js +181 -0
- package/src/auth/resource-server.js +319 -0
- package/src/core/events.js +105 -0
- package/src/core/hook-engine.js +275 -0
- package/src/core/hook-loader.js +791 -0
- package/src/core/hook-runner.js +346 -0
- package/src/core/logger.js +169 -0
- package/src/core/matcher.js +51 -0
- package/src/core/metrics.js +75 -0
- package/src/core/tool-registry.js +180 -0
- package/src/extend/add-hook.md +142 -0
- package/src/extend/add-mcp.md +121 -0
- package/src/extend/create-tool.md +132 -0
- package/src/extend/howto.js +98 -0
- package/src/extend/package.md +154 -0
- package/src/mcp/aggregator.js +802 -0
- package/src/mcp/expose-store.js +430 -0
- package/src/mcp/gated-run.js +194 -0
- package/src/mcp/http-transport.js +962 -0
- package/src/mcp/mcp-client.js +524 -0
- package/src/mcp/protocol.js +466 -0
- package/src/mcp/server.js +1240 -0
- package/src/packages/packager.js +713 -0
- package/src/tools/drift.js +138 -0
- package/src/tools/registry.js +468 -0
- package/src/tools/tool-state.js +104 -0
- package/src/ui/public/app.js +1055 -0
- package/src/ui/public/index.html +249 -0
- package/src/ui/public/logo.jpg +0 -0
- package/src/ui/public/styles.css +535 -0
- package/src/ui/server.js +1144 -0
- package/tools/scripts/base64.js +139 -0
- package/tools/scripts/danger.js +61 -0
- package/tools/scripts/echo.js +39 -0
- package/tools/scripts/hash.js +101 -0
- package/tools/scripts/json.js +109 -0
- package/tools/scripts/text-stats.js +131 -0
- package/tools/scripts/tf-hook-add.js +152 -0
- package/tools/scripts/tf-hook-set.js +113 -0
- package/tools/scripts/tf-list.js +195 -0
- package/tools/scripts/tf-log.js +83 -0
- package/tools/scripts/tf-mcp-add.js +119 -0
- package/tools/scripts/tf-mcp-set.js +104 -0
- package/tools/scripts/tf-tool-add.js +129 -0
- package/tools/scripts/tf-tool-set.js +148 -0
- package/tools/scripts/uuid.js +102 -0
- package/tools/tools.register.json +171 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* hook-runner.js — run ONE command hook and return a structured result.
|
|
5
|
+
*
|
|
6
|
+
* Authoritative contract: docs/HOOK_ENGINE.md §3 (two output protocols) and §4
|
|
7
|
+
* (the exact result shape). This module is backend-agnostic and has ZERO host-framework
|
|
8
|
+
* imports so it runs headless under `node --test`.
|
|
9
|
+
*
|
|
10
|
+
* Hard guarantee (§4): runHook NEVER rejects. A spawn failure or a timeout always
|
|
11
|
+
* resolves with the full result object (exitCode:-1, timedOut set appropriately,
|
|
12
|
+
* blocked:false).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { spawn, execFile } = require('node:child_process');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
|
|
18
|
+
// Default HOOKS_DIR: <root>/src/hooks. __dirname is <root>/src/core, so up one.
|
|
19
|
+
const DEFAULT_HOOKS_DIR = path.resolve(__dirname, '..', 'hooks');
|
|
20
|
+
|
|
21
|
+
// Events whose exit-0 stdout is treated as injected context (§3-A, §1 table).
|
|
22
|
+
const INJECTABLE_EVENTS = new Set(['SessionStart', 'UserPromptSubmit']);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Decide whether a parsed stdout object should be treated as the §3-B JSON
|
|
26
|
+
* protocol. We only switch to the JSON interpretation when the object actually
|
|
27
|
+
* carries one of the protocol's known keys — otherwise an arbitrary JSON blob a
|
|
28
|
+
* hook happens to print should fall through to the exit-code protocol (§3:
|
|
29
|
+
* "Exit-code protocol wins if stdout is not valid JSON").
|
|
30
|
+
*
|
|
31
|
+
* @param {*} parsed
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
function isKnownJsonProtocol(parsed) {
|
|
35
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
return (
|
|
39
|
+
Object.prototype.hasOwnProperty.call(parsed, 'continue') ||
|
|
40
|
+
Object.prototype.hasOwnProperty.call(parsed, 'decision') ||
|
|
41
|
+
Object.prototype.hasOwnProperty.call(parsed, 'reason') ||
|
|
42
|
+
Object.prototype.hasOwnProperty.call(parsed, 'hookSpecificOutput')
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Try to parse stdout as the JSON protocol. Returns the parsed object only if it
|
|
48
|
+
* is valid JSON AND carries known protocol keys; otherwise null (use exit-code
|
|
49
|
+
* protocol).
|
|
50
|
+
*
|
|
51
|
+
* @param {string} stdout
|
|
52
|
+
* @returns {object|null}
|
|
53
|
+
*/
|
|
54
|
+
function tryParseJsonProtocol(stdout) {
|
|
55
|
+
const trimmed = (stdout || '').trim();
|
|
56
|
+
if (!trimmed) return null;
|
|
57
|
+
// Cheap pre-check: protocol output is always a JSON object.
|
|
58
|
+
if (trimmed[0] !== '{') return null;
|
|
59
|
+
let parsed;
|
|
60
|
+
try {
|
|
61
|
+
parsed = JSON.parse(trimmed);
|
|
62
|
+
} catch (_e) {
|
|
63
|
+
return null; // not valid JSON → exit-code protocol wins
|
|
64
|
+
}
|
|
65
|
+
return isKnownJsonProtocol(parsed) ? parsed : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Run a single command hook.
|
|
70
|
+
*
|
|
71
|
+
* @param {object} hookSpec Resolved spec: { id, event, command, timeout(seconds), ... }.
|
|
72
|
+
* @param {object} payload The stdin JSON object (built by events.js buildPayload).
|
|
73
|
+
* @param {object} [opts]
|
|
74
|
+
* @param {string} [opts.cwd] child working directory.
|
|
75
|
+
* @param {object} [opts.env] extra env merged over process.env.
|
|
76
|
+
* @param {string} [opts.projectDir] value for CLAUDE_PROJECT_DIR.
|
|
77
|
+
* @param {string} [opts.hooksDir] value for HOOKS_DIR (default <root>/src/hooks).
|
|
78
|
+
* @param {AbortSignal} [opts.signal] external cancellation; aborts the child.
|
|
79
|
+
* @returns {Promise<object>} the §4 result shape. Never rejects.
|
|
80
|
+
*/
|
|
81
|
+
function runHook(hookSpec, payload, opts = {}) {
|
|
82
|
+
return new Promise((resolve) => {
|
|
83
|
+
const started = Date.now();
|
|
84
|
+
|
|
85
|
+
const id = hookSpec && hookSpec.id != null ? hookSpec.id : null;
|
|
86
|
+
const event = hookSpec && hookSpec.event != null ? hookSpec.event : null;
|
|
87
|
+
|
|
88
|
+
// Build the immutable base of every result so any early exit is well-formed.
|
|
89
|
+
const baseResult = () => ({
|
|
90
|
+
id,
|
|
91
|
+
event,
|
|
92
|
+
exitCode: -1,
|
|
93
|
+
timedOut: false,
|
|
94
|
+
stdout: '',
|
|
95
|
+
stderr: '',
|
|
96
|
+
blocked: false,
|
|
97
|
+
stopLoop: false,
|
|
98
|
+
reason: null,
|
|
99
|
+
inject: null,
|
|
100
|
+
durationMs: 0,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Guard: a malformed spec must not throw out of the runner (§4: never reject).
|
|
104
|
+
if (!hookSpec || typeof hookSpec.command !== 'string' || hookSpec.command.length === 0) {
|
|
105
|
+
const r = baseResult();
|
|
106
|
+
r.stderr = 'hook-runner: invalid hookSpec (missing command)';
|
|
107
|
+
r.durationMs = Date.now() - started;
|
|
108
|
+
resolve(r);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// timeout is stored in SECONDS in the manifest → convert to ms (§4).
|
|
113
|
+
// Fall back to a sane default if absent or non-positive.
|
|
114
|
+
const timeoutSec =
|
|
115
|
+
typeof hookSpec.timeout === 'number' && hookSpec.timeout > 0 ? hookSpec.timeout : 60;
|
|
116
|
+
const timeoutMs = timeoutSec * 1000;
|
|
117
|
+
|
|
118
|
+
// Child env: inherit process.env, layer opts.env, then set the two paths the
|
|
119
|
+
// hook scripts rely on to resolve themselves (§4).
|
|
120
|
+
const hooksDir = opts.hooksDir || (opts.env && opts.env.HOOKS_DIR) || DEFAULT_HOOKS_DIR;
|
|
121
|
+
const projectDir =
|
|
122
|
+
opts.projectDir ||
|
|
123
|
+
(opts.env && opts.env.CLAUDE_PROJECT_DIR) ||
|
|
124
|
+
process.env.CLAUDE_PROJECT_DIR ||
|
|
125
|
+
// Project root is two levels above the hooks dir (<root>/src/hooks → <root>).
|
|
126
|
+
path.resolve(hooksDir, '..', '..');
|
|
127
|
+
|
|
128
|
+
const childEnv = Object.assign({}, process.env, opts.env || {}, {
|
|
129
|
+
CLAUDE_PROJECT_DIR: projectDir,
|
|
130
|
+
HOOKS_DIR: hooksDir,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Own AbortController so we can enforce the timeout via kill, and chain any
|
|
134
|
+
// externally-supplied signal so the engine can cancel us too.
|
|
135
|
+
const controller = new AbortController();
|
|
136
|
+
const onExternalAbort = () => killChildTree();
|
|
137
|
+
if (opts.signal) {
|
|
138
|
+
if (opts.signal.aborted) controller.abort();
|
|
139
|
+
else opts.signal.addEventListener('abort', onExternalAbort, { once: true });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let child;
|
|
143
|
+
try {
|
|
144
|
+
child = spawn(hookSpec.command, {
|
|
145
|
+
shell: true, // run as a shell command line (bash/PowerShell-launched scripts)
|
|
146
|
+
cwd: opts.cwd || undefined,
|
|
147
|
+
env: childEnv,
|
|
148
|
+
signal: controller.signal,
|
|
149
|
+
windowsHide: true,
|
|
150
|
+
});
|
|
151
|
+
} catch (err) {
|
|
152
|
+
// Synchronous spawn failure (rare). Resolve, never throw.
|
|
153
|
+
cleanupSignal();
|
|
154
|
+
const r = baseResult();
|
|
155
|
+
r.stderr = `hook-runner: spawn failed: ${err && err.message ? err.message : String(err)}`;
|
|
156
|
+
r.durationMs = Date.now() - started;
|
|
157
|
+
resolve(r);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let stdout = '';
|
|
162
|
+
let stderr = '';
|
|
163
|
+
let timedOut = false;
|
|
164
|
+
let settled = false;
|
|
165
|
+
|
|
166
|
+
// Kill the WHOLE child tree. On Windows, killing the shell (cmd.exe) does NOT
|
|
167
|
+
// kill its node/bash grandchildren — they orphan and hold the stdio pipes open,
|
|
168
|
+
// which prevents the parent process (and node:test) from ever exiting. taskkill
|
|
169
|
+
// /T tears down the tree so the pipes close and nothing lingers. (Real hooks
|
|
170
|
+
// that hang would orphan the same way, so this matters beyond the test suite.)
|
|
171
|
+
function killChildTree() {
|
|
172
|
+
try {
|
|
173
|
+
if (child && child.pid != null && process.platform === 'win32') {
|
|
174
|
+
execFile('taskkill', ['/pid', String(child.pid), '/T', '/F'], () => {});
|
|
175
|
+
} else if (child) {
|
|
176
|
+
child.kill('SIGKILL');
|
|
177
|
+
}
|
|
178
|
+
} catch (_e) {
|
|
179
|
+
try {
|
|
180
|
+
if (child) child.kill();
|
|
181
|
+
} catch (_e2) {
|
|
182
|
+
/* nothing more we can do */
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
controller.abort();
|
|
187
|
+
} catch (_e) {
|
|
188
|
+
/* abort backstop */
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Hard timeout: kill the child tree; the 'close'/'error' handler resolves.
|
|
193
|
+
const timer = setTimeout(() => {
|
|
194
|
+
timedOut = true;
|
|
195
|
+
killChildTree();
|
|
196
|
+
}, timeoutMs);
|
|
197
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
198
|
+
|
|
199
|
+
function cleanupSignal() {
|
|
200
|
+
if (opts.signal) opts.signal.removeEventListener('abort', onExternalAbort);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function finish(result) {
|
|
204
|
+
if (settled) return;
|
|
205
|
+
settled = true;
|
|
206
|
+
clearTimeout(timer);
|
|
207
|
+
cleanupSignal();
|
|
208
|
+
result.durationMs = Date.now() - started;
|
|
209
|
+
resolve(result);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (child.stdout) {
|
|
213
|
+
child.stdout.setEncoding('utf8');
|
|
214
|
+
child.stdout.on('data', (chunk) => {
|
|
215
|
+
stdout += chunk;
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
if (child.stderr) {
|
|
219
|
+
child.stderr.setEncoding('utf8');
|
|
220
|
+
child.stderr.on('data', (chunk) => {
|
|
221
|
+
stderr += chunk;
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Write the payload to the child's stdin, then close it. Guard against EPIPE
|
|
226
|
+
// (child may exit before reading) — that must not throw the runner.
|
|
227
|
+
if (child.stdin) {
|
|
228
|
+
child.stdin.on('error', () => {
|
|
229
|
+
/* swallow EPIPE / write-after-end; the exit code is what matters */
|
|
230
|
+
});
|
|
231
|
+
try {
|
|
232
|
+
child.stdin.write(JSON.stringify(payload == null ? {} : payload));
|
|
233
|
+
child.stdin.end();
|
|
234
|
+
} catch (_e) {
|
|
235
|
+
// ignore — stdin write race; child outcome still resolves below
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Spawn / runtime error (ENOENT, abort, etc.). If it's the timeout abort we
|
|
240
|
+
// report timedOut; otherwise a non-blocking error with exitCode -1.
|
|
241
|
+
child.on('error', (err) => {
|
|
242
|
+
const r = baseResult();
|
|
243
|
+
r.stdout = stdout;
|
|
244
|
+
r.stderr = stderr || (err && err.message ? err.message : String(err));
|
|
245
|
+
if (timedOut) {
|
|
246
|
+
r.timedOut = true;
|
|
247
|
+
r.stderr = stderr || `hook-runner: timed out after ${timeoutMs}ms`;
|
|
248
|
+
}
|
|
249
|
+
// blocked stays false; exitCode stays -1 (§4).
|
|
250
|
+
finish(r);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
child.on('close', (code, sig) => {
|
|
254
|
+
// If we aborted for the timeout, classify as timeout regardless of code.
|
|
255
|
+
if (timedOut) {
|
|
256
|
+
const r = baseResult();
|
|
257
|
+
r.stdout = stdout;
|
|
258
|
+
r.stderr = stderr || `hook-runner: timed out after ${timeoutMs}ms`;
|
|
259
|
+
r.timedOut = true; // exitCode -1, blocked false (§4)
|
|
260
|
+
finish(r);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const r = baseResult();
|
|
265
|
+
r.stdout = stdout;
|
|
266
|
+
r.stderr = stderr;
|
|
267
|
+
// On a clean close, code is a number; if killed by signal, code is null.
|
|
268
|
+
r.exitCode = typeof code === 'number' ? code : -1;
|
|
269
|
+
|
|
270
|
+
// --- Protocol interpretation (§3) ---
|
|
271
|
+
// Exit 2 is ALWAYS stderr-blocking, and stdout/JSON is ignored on any
|
|
272
|
+
// non-zero exit — this matches real Claude Code (the JSON protocol is
|
|
273
|
+
// processed ONLY on a clean exit 0; on exit 2 the reason is stderr).
|
|
274
|
+
if (r.exitCode === 2) {
|
|
275
|
+
r.blocked = true;
|
|
276
|
+
r.reason = stderr != null && stderr.trim().length > 0 ? stderr.trim() : '';
|
|
277
|
+
finish(r);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// B) Advanced / JSON protocol (§3-B) — honored only on exit 0.
|
|
282
|
+
const json = r.exitCode === 0 ? tryParseJsonProtocol(stdout) : null;
|
|
283
|
+
|
|
284
|
+
if (json) {
|
|
285
|
+
const hso =
|
|
286
|
+
json.hookSpecificOutput && typeof json.hookSpecificOutput === 'object'
|
|
287
|
+
? json.hookSpecificOutput
|
|
288
|
+
: null;
|
|
289
|
+
|
|
290
|
+
// PreToolUse blocks via hookSpecificOutput.permissionDecision
|
|
291
|
+
// (allow|deny|ask), NOT top-level decision — this is the real Claude Code
|
|
292
|
+
// mechanism. "deny" blocks (reason = permissionDecisionReason). "allow"
|
|
293
|
+
// passes. "ask" has no interactive prompt in the autonomous host, so it
|
|
294
|
+
// is treated as non-blocking with the reason captured for context.
|
|
295
|
+
const permDecision =
|
|
296
|
+
hso && typeof hso.permissionDecision === 'string' ? hso.permissionDecision : null;
|
|
297
|
+
const permReason =
|
|
298
|
+
hso && typeof hso.permissionDecisionReason === 'string'
|
|
299
|
+
? hso.permissionDecisionReason
|
|
300
|
+
: null;
|
|
301
|
+
|
|
302
|
+
// Top-level decision: real Claude Code defines only "block" here (for
|
|
303
|
+
// UserPromptSubmit / PostToolUse / Stop / PreCompact). There is no "approve".
|
|
304
|
+
const decision = json.decision;
|
|
305
|
+
const cont = json.continue;
|
|
306
|
+
const jsonReason = typeof json.reason === 'string' ? json.reason : null;
|
|
307
|
+
const additional =
|
|
308
|
+
hso && typeof hso.additionalContext === 'string' ? hso.additionalContext : null;
|
|
309
|
+
|
|
310
|
+
if (permDecision === 'deny') {
|
|
311
|
+
r.blocked = true;
|
|
312
|
+
r.reason = permReason != null ? permReason : jsonReason;
|
|
313
|
+
if (r.reason == null) r.reason = '';
|
|
314
|
+
} else if (decision === 'block') {
|
|
315
|
+
r.blocked = true;
|
|
316
|
+
r.reason = jsonReason; // block reason from JSON.reason (§4)
|
|
317
|
+
}
|
|
318
|
+
if (cont === false) {
|
|
319
|
+
r.stopLoop = true; // continue:false → stop the whole loop (§3-B, §4)
|
|
320
|
+
}
|
|
321
|
+
// additionalContext is the injected text when present (§3-B, §4).
|
|
322
|
+
if (additional !== null) {
|
|
323
|
+
r.inject = additional;
|
|
324
|
+
}
|
|
325
|
+
finish(r);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// A) Simple / exit-code protocol (§3-A), exit 0.
|
|
330
|
+
if (r.exitCode === 0) {
|
|
331
|
+
// Success. For SessionStart / UserPromptSubmit, stdout IS the injection.
|
|
332
|
+
// For other events stdout is advisory only (captured, not injected).
|
|
333
|
+
if (INJECTABLE_EVENTS.has(event)) {
|
|
334
|
+
const out = stdout != null ? stdout.trim() : '';
|
|
335
|
+
r.inject = out.length > 0 ? out : null;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
// Any other non-zero (non-2) code → non-blocking error: stderr captured,
|
|
339
|
+
// blocked stays false, reason stays null (§3-A).
|
|
340
|
+
|
|
341
|
+
finish(r);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
module.exports = { runHook };
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* logger.js — a toggleable JSONL activity/audit log for the gateway.
|
|
5
|
+
*
|
|
6
|
+
* DEFAULT OFF (privacy + lean). Nothing is written, and no files are created, unless
|
|
7
|
+
* logging has been explicitly enabled via setConfig(). A MISSING config file means
|
|
8
|
+
* disabled — the safe default — so a fresh checkout logs nothing.
|
|
9
|
+
*
|
|
10
|
+
* Design rules:
|
|
11
|
+
* - Zero dependencies. Node built-ins only (fs, path). No transport, no SDK.
|
|
12
|
+
* - Config is read FRESH on every call (no caching), so a toggle takes effect for the
|
|
13
|
+
* very next event without a reconnect or restart.
|
|
14
|
+
* - log() must NEVER throw. A logging failure (bad config, unwritable disk, full FS)
|
|
15
|
+
* must never break a tool call or the gate. Everything is wrapped; failures are
|
|
16
|
+
* swallowed silently.
|
|
17
|
+
*
|
|
18
|
+
* Paths are resolved against the repo root (two dirs up from src/core/). The configured
|
|
19
|
+
* log `path` may be relative (resolved against root) or absolute.
|
|
20
|
+
*
|
|
21
|
+
* Config file: <root>/logs/log.config.json
|
|
22
|
+
* { "enabled": false, "path": "logs/toolfunnel.log.jsonl" }
|
|
23
|
+
*
|
|
24
|
+
* CommonJS only.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const fs = require('node:fs');
|
|
28
|
+
const path = require('node:path');
|
|
29
|
+
|
|
30
|
+
/** Repo root: <root>/src/core/logger.js -> two dirs up. */
|
|
31
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
32
|
+
|
|
33
|
+
/** The toggle/config file. NOT created until setConfig() writes it. */
|
|
34
|
+
const CONFIG_PATH = path.join(ROOT, 'logs', 'log.config.json');
|
|
35
|
+
|
|
36
|
+
/** Safe defaults — used whenever the config file is absent or unreadable. */
|
|
37
|
+
const DEFAULT_ENABLED = false;
|
|
38
|
+
const DEFAULT_PATH = 'logs/toolfunnel.log.jsonl';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve a (possibly relative) log path against the repo root.
|
|
42
|
+
* @param {string} p
|
|
43
|
+
* @returns {string}
|
|
44
|
+
*/
|
|
45
|
+
function resolveLogPath(p) {
|
|
46
|
+
return path.isAbsolute(p) ? p : path.join(ROOT, p);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* getConfig — the resolved { enabled, path }.
|
|
51
|
+
*
|
|
52
|
+
* Reads the config file fresh. A missing/unreadable/malformed file resolves to the
|
|
53
|
+
* safe defaults (disabled). Never throws.
|
|
54
|
+
*
|
|
55
|
+
* @returns {{ enabled: boolean, path: string }}
|
|
56
|
+
*/
|
|
57
|
+
function getConfig() {
|
|
58
|
+
try {
|
|
59
|
+
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
|
60
|
+
const parsed = JSON.parse(raw);
|
|
61
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
62
|
+
return { enabled: DEFAULT_ENABLED, path: DEFAULT_PATH };
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
enabled: parsed.enabled === true,
|
|
66
|
+
path:
|
|
67
|
+
typeof parsed.path === 'string' && parsed.path.length > 0
|
|
68
|
+
? parsed.path
|
|
69
|
+
: DEFAULT_PATH,
|
|
70
|
+
};
|
|
71
|
+
} catch (_err) {
|
|
72
|
+
// Missing file = disabled (the safe default); also covers unreadable/malformed.
|
|
73
|
+
return { enabled: DEFAULT_ENABLED, path: DEFAULT_PATH };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* log — append one JSONL record IF logging is enabled, else a silent no-op.
|
|
79
|
+
*
|
|
80
|
+
* The record is the event fields plus a logger-stamped ISO-8601 "ts" timestamp.
|
|
81
|
+
* Resolves the configured path against root, mkdir -p its directory, and appends one
|
|
82
|
+
* line with fs.appendFileSync. NEVER throws — any failure is swallowed.
|
|
83
|
+
*
|
|
84
|
+
* @param {object} event arbitrary serialisable fields (e.g. { type, tool, decision }).
|
|
85
|
+
*/
|
|
86
|
+
function log(event) {
|
|
87
|
+
try {
|
|
88
|
+
const cfg = getConfig();
|
|
89
|
+
if (!cfg.enabled) return; // default-off: no-op, no file created.
|
|
90
|
+
|
|
91
|
+
const record = Object.assign(
|
|
92
|
+
{ ts: new Date().toISOString() },
|
|
93
|
+
event && typeof event === 'object' && !Array.isArray(event) ? event : {}
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const logPath = resolveLogPath(cfg.path);
|
|
97
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
98
|
+
fs.appendFileSync(logPath, JSON.stringify(record) + '\n');
|
|
99
|
+
} catch (_err) {
|
|
100
|
+
// Logging must NEVER break the caller. Swallow everything.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* setConfig — atomically merge a patch into logs/log.config.json (temp + rename).
|
|
106
|
+
*
|
|
107
|
+
* Merges with the current resolved config so a partial patch (e.g. { enabled: true })
|
|
108
|
+
* preserves the existing path. Creates the logs/ dir and the config file if absent —
|
|
109
|
+
* this is the ONLY function that creates the config file.
|
|
110
|
+
*
|
|
111
|
+
* @param {{ enabled?: boolean, path?: string }} patch
|
|
112
|
+
* @returns {{ enabled: boolean, path: string }} the merged, written config.
|
|
113
|
+
*/
|
|
114
|
+
function setConfig(patch) {
|
|
115
|
+
const current = getConfig();
|
|
116
|
+
const p = patch && typeof patch === 'object' && !Array.isArray(patch) ? patch : {};
|
|
117
|
+
|
|
118
|
+
const next = {
|
|
119
|
+
enabled: typeof p.enabled === 'boolean' ? p.enabled : current.enabled,
|
|
120
|
+
path:
|
|
121
|
+
typeof p.path === 'string' && p.path.length > 0 ? p.path : current.path,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
|
|
125
|
+
// Atomic write: write a unique temp file, then rename over the target.
|
|
126
|
+
const tmp = CONFIG_PATH + '.' + process.pid + '.' + Date.now() + '.tmp';
|
|
127
|
+
fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n');
|
|
128
|
+
fs.renameSync(tmp, CONFIG_PATH);
|
|
129
|
+
|
|
130
|
+
return next;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* tail — return the last n parsed JSON lines from the configured log.
|
|
135
|
+
*
|
|
136
|
+
* Returns [] if the log is missing or unreadable. Unparseable lines are skipped. When
|
|
137
|
+
* n is not a positive finite number, all lines are returned. Never throws.
|
|
138
|
+
*
|
|
139
|
+
* @param {number} [n] how many trailing lines to parse and return.
|
|
140
|
+
* @returns {object[]}
|
|
141
|
+
*/
|
|
142
|
+
function tail(n) {
|
|
143
|
+
try {
|
|
144
|
+
const cfg = getConfig();
|
|
145
|
+
const logPath = resolveLogPath(cfg.path);
|
|
146
|
+
const raw = fs.readFileSync(logPath, 'utf8');
|
|
147
|
+
const lines = raw.split('\n').filter(function (l) {
|
|
148
|
+
return l.length > 0;
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const wanted =
|
|
152
|
+
typeof n === 'number' && isFinite(n) && n > 0 ? Math.floor(n) : lines.length;
|
|
153
|
+
const slice = lines.slice(-wanted);
|
|
154
|
+
|
|
155
|
+
const out = [];
|
|
156
|
+
for (let i = 0; i < slice.length; i++) {
|
|
157
|
+
try {
|
|
158
|
+
out.push(JSON.parse(slice[i]));
|
|
159
|
+
} catch (_e) {
|
|
160
|
+
// Skip a corrupt/partial line rather than failing the whole tail.
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
} catch (_err) {
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = { log, setConfig, getConfig, tail };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* matcher.js — does a hook's matcher string fire for a given tool? (HOOK_ENGINE.md §5)
|
|
5
|
+
*
|
|
6
|
+
* Mirrors Claude Code's matcher semantics:
|
|
7
|
+
* - "" | undefined | "*" → always fire (wildcard).
|
|
8
|
+
* - otherwise the matcher is a regex, anchored as a FULL match against toolName
|
|
9
|
+
* (e.g. "Bash|Write|Edit").
|
|
10
|
+
* - tool-less events (SessionStart, UserPromptSubmit, Stop, PreCompact) pass
|
|
11
|
+
* toolName == null/undefined → always fire (the matcher is ignored).
|
|
12
|
+
*
|
|
13
|
+
* CommonJS only. Zero host imports (core/ must run headless under node --test).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {string|undefined|null} matcher the hook's matcher string.
|
|
18
|
+
* @param {string|undefined|null} toolName the tool the model requested, or
|
|
19
|
+
* null/undefined for events without a tool.
|
|
20
|
+
* @returns {boolean} true if the hook should fire for this tool.
|
|
21
|
+
*/
|
|
22
|
+
function matches(matcher, toolName) {
|
|
23
|
+
// Wildcard / unset matcher → always fire, regardless of tool.
|
|
24
|
+
if (matcher === undefined || matcher === null || matcher === '' || matcher === '*') {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Tool-less events: there is nothing to match against, so the matcher is
|
|
29
|
+
// ignored and the hook always fires (§5). This is checked AFTER the wildcard
|
|
30
|
+
// case so a "*" matcher is handled uniformly, and BEFORE we attempt a regex
|
|
31
|
+
// (which would otherwise need a string to test).
|
|
32
|
+
if (toolName === undefined || toolName === null) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Anything else is a regex, anchored as a full match against toolName.
|
|
37
|
+
// We wrap in ^(?:…)$ so alternations like "Bash|Write" mean "the whole tool
|
|
38
|
+
// name is one of these", not "contains one of these".
|
|
39
|
+
let re;
|
|
40
|
+
try {
|
|
41
|
+
re = new RegExp(`^(?:${matcher})$`);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
// A malformed matcher must never throw out of the engine. Treat an
|
|
44
|
+
// uncompilable pattern as "does not match" — fail closed, stay defensive.
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return re.test(toolName);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { matches };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* metrics.js — in-memory, per-process tool-call counters (lightweight observability).
|
|
5
|
+
*
|
|
6
|
+
* A privileged gateway should be able to answer "what has run through me, and how often did it
|
|
7
|
+
* fail?" without standing up a metrics stack. This is the zero-dependency MVP of that: process-
|
|
8
|
+
* lifetime counters incremented at the single tools/call chokepoint and surfaced via the HTTP
|
|
9
|
+
* `/health` endpoint and the admin UI. It is NOT persistent (counts reset when the host restarts)
|
|
10
|
+
* and NOT a substitute for the JSONL audit log (src/core/logger.js, which records individual events
|
|
11
|
+
* to disk when enabled) — the two are complementary: the log is the durable per-event record, this
|
|
12
|
+
* is the cheap live aggregate.
|
|
13
|
+
*
|
|
14
|
+
* Design rules (same discipline as logger.js):
|
|
15
|
+
* - Zero dependencies. Plain objects + integers.
|
|
16
|
+
* - record() / snapshot() NEVER throw — instrumentation must never break a tool call.
|
|
17
|
+
* - In-memory only; no files, no timers, no network.
|
|
18
|
+
*
|
|
19
|
+
* CommonJS only.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const state = {
|
|
23
|
+
startedAt: new Date().toISOString(),
|
|
24
|
+
calls: 0,
|
|
25
|
+
errors: 0,
|
|
26
|
+
byTool: Object.create(null), // name -> { calls, errors }
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* record — count one tools/call outcome. `ok:false` (an isError result — a tool failure OR a
|
|
31
|
+
* PreToolUse denial, which both surface as an error envelope) increments the error counters.
|
|
32
|
+
* @param {{ tool?:string, ok?:boolean }} ev
|
|
33
|
+
*/
|
|
34
|
+
function record(ev) {
|
|
35
|
+
try {
|
|
36
|
+
const tool = ev && typeof ev.tool === 'string' && ev.tool.length > 0 ? ev.tool : 'unknown';
|
|
37
|
+
const ok = !(ev && ev.ok === false);
|
|
38
|
+
state.calls += 1;
|
|
39
|
+
if (!ok) state.errors += 1;
|
|
40
|
+
let t = state.byTool[tool];
|
|
41
|
+
if (!t) {
|
|
42
|
+
t = { calls: 0, errors: 0 };
|
|
43
|
+
state.byTool[tool] = t;
|
|
44
|
+
}
|
|
45
|
+
t.calls += 1;
|
|
46
|
+
if (!ok) t.errors += 1;
|
|
47
|
+
} catch (_e) {
|
|
48
|
+
/* instrumentation must never break the caller */
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* snapshot — a fresh, mutation-safe copy of the counters for /health + the UI. NEVER throws.
|
|
54
|
+
* @returns {{ startedAt:string, calls:number, errors:number, byTool:Object<string,{calls:number,errors:number}> }}
|
|
55
|
+
*/
|
|
56
|
+
function snapshot() {
|
|
57
|
+
try {
|
|
58
|
+
const byTool = {};
|
|
59
|
+
for (const k of Object.keys(state.byTool)) {
|
|
60
|
+
byTool[k] = { calls: state.byTool[k].calls, errors: state.byTool[k].errors };
|
|
61
|
+
}
|
|
62
|
+
return { startedAt: state.startedAt, calls: state.calls, errors: state.errors, byTool };
|
|
63
|
+
} catch (_e) {
|
|
64
|
+
return { startedAt: state.startedAt, calls: 0, errors: 0, byTool: {} };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** reset — zero the counters (tests). The startedAt stamp is left as the process start. */
|
|
69
|
+
function reset() {
|
|
70
|
+
state.calls = 0;
|
|
71
|
+
state.errors = 0;
|
|
72
|
+
state.byTool = Object.create(null);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { record, snapshot, reset };
|