toolfunnel 0.7.0 → 0.7.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.
Files changed (67) hide show
  1. package/README.md +228 -235
  2. package/SECURITY.md +84 -84
  3. package/bin/toolfunnel.js +451 -451
  4. package/docs/KNOWN_BUGS.md +80 -80
  5. package/docs/design.md +84 -84
  6. package/docs/packaging.md +115 -115
  7. package/hooks/hooks.manifest.json +27 -27
  8. package/hooks/scripts/example-audit.js +21 -21
  9. package/hooks/scripts/example-deny-dangerous.js +39 -39
  10. package/mcp/expose.example.json +30 -30
  11. package/mcp/servers/mock-modern/server.js +147 -147
  12. package/mcp/servers/mock-upstream/server.js +380 -380
  13. package/package.json +67 -67
  14. package/src/auth/config.js +174 -174
  15. package/src/auth/install.js +181 -181
  16. package/src/auth/resource-server.js +353 -353
  17. package/src/core/child-reaper.js +61 -61
  18. package/src/core/config-home.js +134 -134
  19. package/src/core/events.js +105 -105
  20. package/src/core/hook-engine.js +329 -329
  21. package/src/core/hook-loader.js +824 -824
  22. package/src/core/hook-runner.js +566 -566
  23. package/src/core/logger.js +297 -297
  24. package/src/core/matcher.js +100 -100
  25. package/src/core/metrics.js +75 -75
  26. package/src/core/rename-migration.js +163 -163
  27. package/src/core/requires.js +138 -138
  28. package/src/core/server-config.js +98 -98
  29. package/src/extend/add-hook.md +152 -152
  30. package/src/extend/add-mcp.md +119 -119
  31. package/src/extend/configure.md +88 -88
  32. package/src/extend/create-tool.md +168 -168
  33. package/src/extend/howto.js +102 -102
  34. package/src/extend/package.md +190 -190
  35. package/src/extend/wrap.md +99 -99
  36. package/src/mcp/aggregator.js +1719 -1674
  37. package/src/mcp/expose-store.js +577 -577
  38. package/src/mcp/gated-run.js +210 -210
  39. package/src/mcp/http-transport.js +1563 -1563
  40. package/src/mcp/mcp-client.js +1070 -1070
  41. package/src/mcp/modern.js +406 -406
  42. package/src/mcp/protocol.js +468 -468
  43. package/src/mcp/server.js +3112 -3112
  44. package/src/tools/registry.js +661 -661
  45. package/src/tools/tool-state.js +170 -170
  46. package/src/ui/public/app.js +1187 -1187
  47. package/src/ui/public/index.html +286 -286
  48. package/src/ui/public/styles.css +535 -535
  49. package/src/ui/server.js +1616 -1616
  50. package/tools/scripts/base64.js +139 -139
  51. package/tools/scripts/danger.js +61 -61
  52. package/tools/scripts/echo.js +39 -39
  53. package/tools/scripts/hash.js +101 -101
  54. package/tools/scripts/json.js +109 -109
  55. package/tools/scripts/text-stats.js +131 -131
  56. package/tools/scripts/tf-env.js +43 -43
  57. package/tools/scripts/tf-hook-add.js +151 -151
  58. package/tools/scripts/tf-hook-set.js +120 -120
  59. package/tools/scripts/tf-list.js +192 -192
  60. package/tools/scripts/tf-log.js +86 -86
  61. package/tools/scripts/tf-mcp-add.js +117 -117
  62. package/tools/scripts/tf-mcp-set.js +121 -121
  63. package/tools/scripts/tf-pack.js +237 -237
  64. package/tools/scripts/tf-tool-add.js +134 -134
  65. package/tools/scripts/tf-tool-set.js +142 -142
  66. package/tools/scripts/tf-wrap.js +103 -103
  67. package/tools/scripts/uuid.js +102 -102
@@ -1,100 +1,100 @@
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 };
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 };
@@ -1,75 +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 };
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 };