toolfunnel 0.5.0 → 0.7.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.
Files changed (72) hide show
  1. package/README.md +235 -167
  2. package/SECURITY.md +84 -0
  3. package/bin/toolfunnel.js +451 -147
  4. package/docs/KNOWN_BUGS.md +80 -33
  5. package/docs/MANUAL.pdf +0 -0
  6. package/docs/design.md +84 -0
  7. package/docs/packaging.md +115 -115
  8. package/hooks/hooks.manifest.json +27 -27
  9. package/hooks/scripts/example-audit.js +21 -21
  10. package/hooks/scripts/example-deny-dangerous.js +39 -31
  11. package/mcp/expose.example.json +30 -30
  12. package/mcp/servers/mock-modern/server.js +147 -0
  13. package/mcp/servers/mock-upstream/server.js +222 -12
  14. package/package.json +67 -65
  15. package/src/auth/config.js +174 -174
  16. package/src/auth/install.js +181 -181
  17. package/src/auth/resource-server.js +353 -344
  18. package/src/core/child-reaper.js +61 -0
  19. package/src/core/config-home.js +134 -134
  20. package/src/core/events.js +105 -105
  21. package/src/core/hook-engine.js +329 -275
  22. package/src/core/hook-loader.js +824 -791
  23. package/src/core/hook-runner.js +566 -346
  24. package/src/core/logger.js +297 -171
  25. package/src/core/matcher.js +100 -51
  26. package/src/core/metrics.js +75 -75
  27. package/src/core/rename-migration.js +163 -0
  28. package/src/core/requires.js +138 -138
  29. package/src/core/server-config.js +98 -77
  30. package/src/extend/add-hook.md +152 -142
  31. package/src/extend/add-mcp.md +119 -121
  32. package/src/extend/configure.md +88 -0
  33. package/src/extend/create-tool.md +168 -152
  34. package/src/extend/howto.js +102 -98
  35. package/src/extend/package.md +190 -187
  36. package/src/extend/wrap.md +99 -0
  37. package/src/mcp/aggregator.js +1674 -807
  38. package/src/mcp/expose-store.js +577 -430
  39. package/src/mcp/gated-run.js +210 -194
  40. package/src/mcp/http-transport.js +1563 -1012
  41. package/src/mcp/mcp-client.js +1070 -530
  42. package/src/mcp/modern.js +406 -0
  43. package/src/mcp/protocol.js +468 -466
  44. package/src/mcp/server.js +3112 -1405
  45. package/src/tools/registry.js +661 -513
  46. package/src/tools/tool-state.js +170 -104
  47. package/src/ui/public/app.js +1187 -1055
  48. package/src/ui/public/index.html +286 -249
  49. package/src/ui/public/styles.css +535 -535
  50. package/src/ui/server.js +1616 -1190
  51. package/tools/scripts/base64.js +139 -139
  52. package/tools/scripts/danger.js +61 -61
  53. package/tools/scripts/echo.js +39 -39
  54. package/tools/scripts/hash.js +101 -101
  55. package/tools/scripts/json.js +109 -109
  56. package/tools/scripts/text-stats.js +131 -131
  57. package/tools/scripts/tf-env.js +43 -43
  58. package/tools/scripts/tf-hook-add.js +151 -151
  59. package/tools/scripts/tf-hook-set.js +120 -112
  60. package/tools/scripts/tf-list.js +192 -192
  61. package/tools/scripts/tf-log.js +86 -86
  62. package/tools/scripts/tf-mcp-add.js +117 -117
  63. package/tools/scripts/tf-mcp-set.js +121 -102
  64. package/tools/scripts/tf-pack.js +237 -237
  65. package/tools/scripts/tf-tool-add.js +134 -129
  66. package/tools/scripts/tf-tool-set.js +142 -142
  67. package/tools/scripts/tf-wrap.js +103 -0
  68. package/tools/scripts/uuid.js +102 -102
  69. package/tools/tools.register.json +14 -3
  70. package/src/core/tool-registry.js +0 -180
  71. package/src/packages/packager.js +0 -715
  72. package/src/tools/drift.js +0 -138
@@ -1,171 +1,297 @@
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
- /** The CONFIG HOME (TOOLFUNNEL_HOME / --config-dir; defaults to the package root — see
31
- * config-home.js). Logs + their toggle are user-state, so they live with the home. */
32
- const { resolveConfigHome } = require('./config-home');
33
- const ROOT = resolveConfigHome();
34
-
35
- /** The toggle/config file. NOT created until setConfig() writes it. */
36
- const CONFIG_PATH = path.join(ROOT, 'logs', 'log.config.json');
37
-
38
- /** Safe defaults — used whenever the config file is absent or unreadable. */
39
- const DEFAULT_ENABLED = false;
40
- const DEFAULT_PATH = 'logs/toolfunnel.log.jsonl';
41
-
42
- /**
43
- * Resolve a (possibly relative) log path against the repo root.
44
- * @param {string} p
45
- * @returns {string}
46
- */
47
- function resolveLogPath(p) {
48
- return path.isAbsolute(p) ? p : path.join(ROOT, p);
49
- }
50
-
51
- /**
52
- * getConfig — the resolved { enabled, path }.
53
- *
54
- * Reads the config file fresh. A missing/unreadable/malformed file resolves to the
55
- * safe defaults (disabled). Never throws.
56
- *
57
- * @returns {{ enabled: boolean, path: string }}
58
- */
59
- function getConfig() {
60
- try {
61
- const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
62
- const parsed = JSON.parse(raw);
63
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
64
- return { enabled: DEFAULT_ENABLED, path: DEFAULT_PATH };
65
- }
66
- return {
67
- enabled: parsed.enabled === true,
68
- path:
69
- typeof parsed.path === 'string' && parsed.path.length > 0
70
- ? parsed.path
71
- : DEFAULT_PATH,
72
- };
73
- } catch (_err) {
74
- // Missing file = disabled (the safe default); also covers unreadable/malformed.
75
- return { enabled: DEFAULT_ENABLED, path: DEFAULT_PATH };
76
- }
77
- }
78
-
79
- /**
80
- * log — append one JSONL record IF logging is enabled, else a silent no-op.
81
- *
82
- * The record is the event fields plus a logger-stamped ISO-8601 "ts" timestamp.
83
- * Resolves the configured path against root, mkdir -p its directory, and appends one
84
- * line with fs.appendFileSync. NEVER throws — any failure is swallowed.
85
- *
86
- * @param {object} event arbitrary serialisable fields (e.g. { type, tool, decision }).
87
- */
88
- function log(event) {
89
- try {
90
- const cfg = getConfig();
91
- if (!cfg.enabled) return; // default-off: no-op, no file created.
92
-
93
- const record = Object.assign(
94
- { ts: new Date().toISOString() },
95
- event && typeof event === 'object' && !Array.isArray(event) ? event : {}
96
- );
97
-
98
- const logPath = resolveLogPath(cfg.path);
99
- fs.mkdirSync(path.dirname(logPath), { recursive: true });
100
- fs.appendFileSync(logPath, JSON.stringify(record) + '\n');
101
- } catch (_err) {
102
- // Logging must NEVER break the caller. Swallow everything.
103
- }
104
- }
105
-
106
- /**
107
- * setConfig — atomically merge a patch into logs/log.config.json (temp + rename).
108
- *
109
- * Merges with the current resolved config so a partial patch (e.g. { enabled: true })
110
- * preserves the existing path. Creates the logs/ dir and the config file if absent —
111
- * this is the ONLY function that creates the config file.
112
- *
113
- * @param {{ enabled?: boolean, path?: string }} patch
114
- * @returns {{ enabled: boolean, path: string }} the merged, written config.
115
- */
116
- function setConfig(patch) {
117
- const current = getConfig();
118
- const p = patch && typeof patch === 'object' && !Array.isArray(patch) ? patch : {};
119
-
120
- const next = {
121
- enabled: typeof p.enabled === 'boolean' ? p.enabled : current.enabled,
122
- path:
123
- typeof p.path === 'string' && p.path.length > 0 ? p.path : current.path,
124
- };
125
-
126
- fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
127
- // Atomic write: write a unique temp file, then rename over the target.
128
- const tmp = CONFIG_PATH + '.' + process.pid + '.' + Date.now() + '.tmp';
129
- fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n');
130
- fs.renameSync(tmp, CONFIG_PATH);
131
-
132
- return next;
133
- }
134
-
135
- /**
136
- * tail — return the last n parsed JSON lines from the configured log.
137
- *
138
- * Returns [] if the log is missing or unreadable. Unparseable lines are skipped. When
139
- * n is not a positive finite number, all lines are returned. Never throws.
140
- *
141
- * @param {number} [n] how many trailing lines to parse and return.
142
- * @returns {object[]}
143
- */
144
- function tail(n) {
145
- try {
146
- const cfg = getConfig();
147
- const logPath = resolveLogPath(cfg.path);
148
- const raw = fs.readFileSync(logPath, 'utf8');
149
- const lines = raw.split('\n').filter(function (l) {
150
- return l.length > 0;
151
- });
152
-
153
- const wanted =
154
- typeof n === 'number' && isFinite(n) && n > 0 ? Math.floor(n) : lines.length;
155
- const slice = lines.slice(-wanted);
156
-
157
- const out = [];
158
- for (let i = 0; i < slice.length; i++) {
159
- try {
160
- out.push(JSON.parse(slice[i]));
161
- } catch (_e) {
162
- // Skip a corrupt/partial line rather than failing the whole tail.
163
- }
164
- }
165
- return out;
166
- } catch (_err) {
167
- return [];
168
- }
169
- }
170
-
171
- module.exports = { log, setConfig, getConfig, tail };
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 config home, and the resolved path MUST stay inside
19
+ * <home>/logs/ (0.7.0; logging.test.js step 5 is the acceptance). The log path is
20
+ * settable from the tf_log tool surface and the UI's POST /api/logs/config, and every
21
+ * other file-writing path in the repo is confined to its root (hook-loader's scripts-dir
22
+ * assert, registry.writeScript, sendStatic) - this one now matches. setConfig REFUSES an
23
+ * out-of-home path loudly; getConfig treats one already in the file as unset (default),
24
+ * so log() and tail() stay in-home even against a hand-edited config. An operator who
25
+ * wants the log on another disk can make <home>/logs a symlink/junction - containment is
26
+ * checked on the unfollowed path.
27
+ *
28
+ * Config file: <root>/logs/log.config.json
29
+ * { "enabled": false, "path": "logs/toolfunnel.log.jsonl" }
30
+ *
31
+ * Both paths are BOUNDED (0.7.0, log-rotation.test.js): the live file rotates to `<path>.1`
32
+ * at MAX_LOG_BYTES (one older generation kept), and tail(n) reads a trailing slice rather
33
+ * than the whole file - with a whole-file fallback so bounding never changes an answer.
34
+ *
35
+ * CommonJS only.
36
+ */
37
+
38
+ const fs = require('node:fs');
39
+ const path = require('node:path');
40
+
41
+ /** The CONFIG HOME (TOOLFUNNEL_HOME / --config-dir; defaults to the package root - see
42
+ * config-home.js). Logs + their toggle are user-state, so they live with the home. */
43
+ const { resolveConfigHome } = require('./config-home');
44
+ const ROOT = resolveConfigHome();
45
+
46
+ /** The toggle/config file. NOT created until setConfig() writes it. */
47
+ const CONFIG_PATH = path.join(ROOT, 'logs', 'log.config.json');
48
+
49
+ /** Safe defaults - used whenever the config file is absent or unreadable. */
50
+ const DEFAULT_ENABLED = false;
51
+ const DEFAULT_PATH = 'logs/toolfunnel.log.jsonl';
52
+
53
+ /**
54
+ * The live file's size cap. An append that finds the file at/over it first renames the
55
+ * file to `<path>.1` (ONE older generation, overwritten by the next rotation), so disk
56
+ * use is bounded at ~2x the cap and the no-argument tail() (the documented all-lines
57
+ * mode, tf_log status's count) can never be asked to parse an unbounded file again.
58
+ * Rotation is best-effort: if the rename is refused (e.g. a reader holds `.1` open on
59
+ * win32) the append proceeds on the oversized live file and rotation retries next call -
60
+ * a record is never dropped for the sake of the cap.
61
+ */
62
+ const MAX_LOG_BYTES = 5 * 1024 * 1024;
63
+
64
+ /** tail(n)'s bounded read: bytes budgeted per requested line (generous - records run
65
+ * ~100-200 bytes), with a floor so tiny n still reads one decent chunk. */
66
+ const TAIL_BYTES_PER_LINE = 1024;
67
+ const TAIL_READ_MIN_BYTES = 64 * 1024;
68
+
69
+ /** The directory every log path must resolve inside. */
70
+ const LOG_DIR = path.join(ROOT, 'logs');
71
+
72
+ /**
73
+ * Resolve a (possibly relative) log path against the config home, REQUIRING the result
74
+ * to stay inside <home>/logs/. Returns the resolved absolute path, or null when it
75
+ * resolves outside (same refusal shape as registry.js defaultRunScript: '' would be the
76
+ * dir itself, '..'-prefixed left it, absolute rel = another drive on win32).
77
+ * @param {string} p
78
+ * @returns {string|null}
79
+ */
80
+ function resolveLogPath(p) {
81
+ const resolved = path.resolve(ROOT, String(p));
82
+ const rel = path.relative(LOG_DIR, resolved);
83
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return null;
84
+ return resolved;
85
+ }
86
+
87
+ /**
88
+ * getConfig - the resolved { enabled, path }.
89
+ *
90
+ * Reads the config file fresh. A missing/unreadable/malformed file resolves to the
91
+ * safe defaults (disabled). Never throws.
92
+ *
93
+ * @returns {{ enabled: boolean, path: string }}
94
+ */
95
+ function getConfig() {
96
+ try {
97
+ const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
98
+ const parsed = JSON.parse(raw);
99
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
100
+ return { enabled: DEFAULT_ENABLED, path: DEFAULT_PATH };
101
+ }
102
+ return {
103
+ enabled: parsed.enabled === true,
104
+ // A path that resolves outside <home>/logs/ is treated as UNSET, not honoured: the
105
+ // config file can be edited directly, and the readers (log/tail) must stay in-home
106
+ // regardless of how the value got there. setConfig additionally refuses one loudly.
107
+ path:
108
+ typeof parsed.path === 'string' && parsed.path.length > 0 && resolveLogPath(parsed.path) !== null
109
+ ? parsed.path
110
+ : DEFAULT_PATH,
111
+ };
112
+ } catch (_err) {
113
+ // Missing file = disabled (the safe default); also covers unreadable/malformed.
114
+ return { enabled: DEFAULT_ENABLED, path: DEFAULT_PATH };
115
+ }
116
+ }
117
+
118
+ /**
119
+ * log - append one JSONL record IF logging is enabled, else a silent no-op.
120
+ *
121
+ * The record is the event fields plus a logger-stamped ISO-8601 "ts" timestamp.
122
+ * Resolves the configured path against root, mkdir -p its directory, and appends one
123
+ * line with fs.appendFileSync. NEVER throws - any failure is swallowed.
124
+ *
125
+ * @param {object} event arbitrary serialisable fields (e.g. { type, tool, decision }).
126
+ */
127
+ function log(event) {
128
+ try {
129
+ const cfg = getConfig();
130
+ if (!cfg.enabled) return; // default-off: no-op, no file created.
131
+
132
+ const record = Object.assign(
133
+ { ts: new Date().toISOString() },
134
+ event && typeof event === 'object' && !Array.isArray(event) ? event : {}
135
+ );
136
+
137
+ const logPath = resolveLogPath(cfg.path);
138
+ if (logPath === null) return; // cannot happen via getConfig (it sanitises); belt-and-braces
139
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
140
+
141
+ // Rotate BEFORE the append when the live file has reached the cap. Its own try/catch:
142
+ // a refused stat/rename must never cost the record below. `logPath + '.1'` inherits
143
+ // logPath's containment, and rename replaces an existing `.1` (one older generation).
144
+ try {
145
+ if (fs.statSync(logPath).size >= MAX_LOG_BYTES) {
146
+ fs.renameSync(logPath, logPath + '.1');
147
+ }
148
+ } catch (_e) {
149
+ // Missing file (nothing to rotate) or a refused rename - append regardless.
150
+ }
151
+
152
+ fs.appendFileSync(logPath, JSON.stringify(record) + '\n');
153
+ } catch (_err) {
154
+ // Logging must NEVER break the caller. Swallow everything.
155
+ }
156
+ }
157
+
158
+ /**
159
+ * setConfig - atomically merge a patch into logs/log.config.json (temp + rename).
160
+ *
161
+ * Merges with the current resolved config so a partial patch (e.g. { enabled: true })
162
+ * preserves the existing path. Creates the logs/ dir and the config file if absent -
163
+ * this is the ONLY function that creates the config file.
164
+ *
165
+ * @param {{ enabled?: boolean, path?: string }} patch
166
+ * @returns {{ enabled: boolean, path: string }} the merged, written config.
167
+ */
168
+ function setConfig(patch) {
169
+ const current = getConfig();
170
+ const p = patch && typeof patch === 'object' && !Array.isArray(patch) ? patch : {};
171
+
172
+ // Refuse an out-of-home path LOUDLY before anything is written. Reaches every setter
173
+ // surface (tf_log's { ok:false }, the UI's error response) with nothing persisted.
174
+ // `current.path` needs no re-check - getConfig only ever returns a contained path.
175
+ // The code lets callers tell "you sent a bad path" (client input -> the UI's 400) apart
176
+ // from a genuine write failure (disk, perms -> 500) without matching on message prose.
177
+ if (typeof p.path === 'string' && p.path.length > 0 && resolveLogPath(p.path) === null) {
178
+ const err = new Error(
179
+ `log path refused: "${p.path}" resolves outside the config home's logs directory - ` +
180
+ 'the activity log lives inside <home>/logs'
181
+ );
182
+ err.code = 'TF_LOG_PATH_REFUSED';
183
+ throw err;
184
+ }
185
+
186
+ const next = {
187
+ enabled: typeof p.enabled === 'boolean' ? p.enabled : current.enabled,
188
+ path:
189
+ typeof p.path === 'string' && p.path.length > 0 ? p.path : current.path,
190
+ };
191
+
192
+ fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
193
+ // Atomic write: write a unique temp file, then rename over the target.
194
+ const tmp = CONFIG_PATH + '.' + process.pid + '.' + Date.now() + '.tmp';
195
+ fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n');
196
+ fs.renameSync(tmp, CONFIG_PATH);
197
+
198
+ return next;
199
+ }
200
+
201
+ /**
202
+ * Read the trailing slice of a file that is expected to contain the last `wanted` lines:
203
+ * the final min(size, max(64 KB, wanted KB)) bytes. When the read starts mid-file the
204
+ * first segment may be a PARTIAL line, so it is dropped (mid-character utf8 starts land
205
+ * in that dropped segment too). Returns the complete lines it can vouch for, plus whether
206
+ * the whole file was covered - the caller falls back to a whole-file read on a shortfall
207
+ * (e.g. one line longer than the window), so the bounded path can never change an answer,
208
+ * only the bytes it took to produce it.
209
+ * @param {string} logPath
210
+ * @param {number} wanted
211
+ * @returns {{ lines: string[], wholeFile: boolean }}
212
+ */
213
+ function readTrailingLines(logPath, wanted) {
214
+ const fd = fs.openSync(logPath, 'r');
215
+ try {
216
+ const size = fs.fstatSync(fd).size;
217
+ const budget = Math.max(TAIL_READ_MIN_BYTES, wanted * TAIL_BYTES_PER_LINE);
218
+ const readLen = Math.min(size, budget);
219
+ const position = size - readLen;
220
+ const buf = Buffer.alloc(readLen);
221
+ let got = 0;
222
+ while (got < readLen) {
223
+ const r = fs.readSync(fd, buf, got, readLen - got, position + got);
224
+ if (r <= 0) break; // shrank underneath us (rotation) - use what we have
225
+ got += r;
226
+ }
227
+ let text = buf.toString('utf8', 0, got);
228
+ if (position > 0) {
229
+ const firstNl = text.indexOf('\n');
230
+ text = firstNl === -1 ? '' : text.slice(firstNl + 1);
231
+ }
232
+ const lines = text.split('\n').filter(function (l) { return l.length > 0; });
233
+ return { lines, wholeFile: position === 0 };
234
+ } finally {
235
+ try { fs.closeSync(fd); } catch (_e) { /* ignore */ }
236
+ }
237
+ }
238
+
239
+ /**
240
+ * tail - return the last n parsed JSON lines from the configured log.
241
+ *
242
+ * Returns [] if the log is missing or unreadable. Unparseable lines are skipped. When
243
+ * n is not a positive finite number, all lines are returned. Never throws.
244
+ *
245
+ * A finite n reads a BOUNDED trailing slice (~1 KB budgeted per requested line), not the
246
+ * whole file - with a whole-file fallback when the slice holds fewer than n lines, so the
247
+ * answer is byte-identical to the unbounded engine's in every case. The no-argument mode
248
+ * still reads everything by design; rotation (MAX_LOG_BYTES) bounds that file for it.
249
+ *
250
+ * @param {number} [n] how many trailing lines to parse and return.
251
+ * @returns {object[]}
252
+ */
253
+ function tail(n) {
254
+ try {
255
+ const cfg = getConfig();
256
+ const logPath = resolveLogPath(cfg.path);
257
+ if (logPath === null) return []; // cannot happen via getConfig (it sanitises); belt-and-braces
258
+
259
+ const bounded = typeof n === 'number' && isFinite(n) && n > 0;
260
+ let lines;
261
+ let wanted;
262
+ if (bounded) {
263
+ wanted = Math.floor(n);
264
+ const sliceRead = readTrailingLines(logPath, wanted);
265
+ if (sliceRead.lines.length >= wanted || sliceRead.wholeFile) {
266
+ lines = sliceRead.lines;
267
+ } else {
268
+ // Shortfall on a partial window (unusually long lines): take the exact answer
269
+ // over the byte bound.
270
+ lines = fs.readFileSync(logPath, 'utf8').split('\n').filter(function (l) {
271
+ return l.length > 0;
272
+ });
273
+ }
274
+ } else {
275
+ lines = fs.readFileSync(logPath, 'utf8').split('\n').filter(function (l) {
276
+ return l.length > 0;
277
+ });
278
+ wanted = lines.length;
279
+ }
280
+
281
+ const slice = lines.slice(-wanted);
282
+
283
+ const out = [];
284
+ for (let i = 0; i < slice.length; i++) {
285
+ try {
286
+ out.push(JSON.parse(slice[i]));
287
+ } catch (_e) {
288
+ // Skip a corrupt/partial line rather than failing the whole tail.
289
+ }
290
+ }
291
+ return out;
292
+ } catch (_err) {
293
+ return [];
294
+ }
295
+ }
296
+
297
+ module.exports = { log, setConfig, getConfig, tail };
@@ -1,51 +1,100 @@
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 };
1
+ 'use strict';
2
+
3
+ /**
4
+ * matcher.js - does a hook's matcher string fire for a given tool?
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
+ // One compile per distinct matcher string. matches() sits on the gate's hot path (every
17
+ // hook x every tool call), and rebuilding `^(?:Bash|Write|Edit)$` per call was pure waste:
18
+ // RegExp construction is a deterministic function of the string and ours carries no flags
19
+ // (no g/y -> no lastIndex state), so memoising by the string can never change an answer.
20
+ // Invalidation is by construction, not by event: a reloaded manifest's new matcher is a
21
+ // NEW string -> a new key; the old entry just goes unused. Failed compiles memoise too
22
+ // (a malformed matcher answers false without a fresh throw per call). The cap is a
23
+ // backstop against degenerate dynamically-generated matchers: past it the memo resets -
24
+ // worst case is exactly the old per-call compile, never unbounded growth or a wrong fire.
25
+ const COMPILE_CACHE_MAX = 256;
26
+ const compileCache = new Map(); // String(matcher) -> { re: RegExp|null, err: string|null }
27
+
28
+ /**
29
+ * The memoised compile shared by matches() / matcherError(). Keyed on String(matcher) so a
30
+ * non-string matcher coerces exactly as the template literal always did.
31
+ * @param {*} matcher
32
+ * @returns {{ re: RegExp|null, err: string|null }}
33
+ */
34
+ function compiled(matcher) {
35
+ const key = String(matcher);
36
+ let hit = compileCache.get(key);
37
+ if (hit) return hit;
38
+ try {
39
+ hit = { re: new RegExp(`^(?:${key})$`), err: null };
40
+ } catch (err) {
41
+ hit = { re: null, err: (err && err.message) || String(err) };
42
+ }
43
+ if (compileCache.size >= COMPILE_CACHE_MAX) compileCache.clear();
44
+ compileCache.set(key, hit);
45
+ return hit;
46
+ }
47
+
48
+ /**
49
+ * @param {string|undefined|null} matcher the hook's matcher string.
50
+ * @param {string|undefined|null} toolName the tool the model requested, or
51
+ * null/undefined for events without a tool.
52
+ * @returns {boolean} true if the hook should fire for this tool.
53
+ */
54
+ function matches(matcher, toolName) {
55
+ // Wildcard / unset matcher -> always fire, regardless of tool.
56
+ if (matcher === undefined || matcher === null || matcher === '' || matcher === '*') {
57
+ return true;
58
+ }
59
+
60
+ // Tool-less events: there is nothing to match against, so the matcher is
61
+ // ignored and the hook always fires. This is checked AFTER the wildcard
62
+ // case so a "*" matcher is handled uniformly, and BEFORE we attempt a regex
63
+ // (which would otherwise need a string to test).
64
+ if (toolName === undefined || toolName === null) {
65
+ return true;
66
+ }
67
+
68
+ // Anything else is a regex, anchored as a full match against toolName.
69
+ // We wrap in ^(?:...)$ so alternations like "Bash|Write" mean "the whole tool
70
+ // name is one of these", not "contains one of these".
71
+ const c = compiled(matcher);
72
+ if (!c.re) {
73
+ // A malformed matcher must never throw out of the engine, and matches() cannot
74
+ // claim a fire it could not evaluate - so this answers false. That is NOT
75
+ // permission to proceed: callers that gate on the answer must distinguish
76
+ // "did not match" from "could not be read" via matcherError() (hook-engine
77
+ // denies the call; hook-loader.addEntry refuses to store the matcher at all).
78
+ return false;
79
+ }
80
+
81
+ return c.re.test(toolName);
82
+ }
83
+
84
+ /**
85
+ * Why does this matcher NOT compile? Returns null for a wildcard/unset matcher or one that
86
+ * compiles under the same ^(?:...)$ anchoring matches() applies; otherwise the compile error's
87
+ * message. This is the discriminator between "did not match" and "could not be read":
88
+ * authoring surfaces (hook-loader.addEntry, and through it the UI and tf_hook_add) use it to
89
+ * REFUSE a pattern that can never be evaluated, and hook-engine.fire treats a non-null answer
90
+ * on a tool-bearing event as an unreadable gate and denies the call (matcher-compile.test.js).
91
+ *
92
+ * @param {string|undefined|null} m the hook's matcher string.
93
+ * @returns {string|null} the compile problem, or null when the matcher is usable.
94
+ */
95
+ function matcherError(m) {
96
+ if (m === undefined || m === null || m === '' || m === '*') return null;
97
+ return compiled(m).err;
98
+ }
99
+
100
+ module.exports = { matches, matcherError };