toolfunnel 0.7.0 → 0.8.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 (73) hide show
  1. package/README.md +238 -235
  2. package/SECURITY.md +84 -84
  3. package/bin/toolfunnel.js +530 -451
  4. package/docs/KNOWN_BUGS.md +100 -80
  5. package/docs/MANUAL.pdf +0 -0
  6. package/docs/design.md +84 -84
  7. package/docs/packaging.md +116 -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 -39
  11. package/mcp/expose.example.json +30 -30
  12. package/mcp/servers/mock-modern/server.js +147 -147
  13. package/mcp/servers/mock-upstream/server.js +379 -380
  14. package/package.json +67 -67
  15. package/src/auth/config.js +172 -174
  16. package/src/auth/install.js +181 -181
  17. package/src/auth/resource-server.js +358 -353
  18. package/src/core/atomic-write.js +97 -0
  19. package/src/core/child-reaper.js +61 -61
  20. package/src/core/config-home.js +226 -134
  21. package/src/core/events.js +105 -105
  22. package/src/core/hook-engine.js +304 -329
  23. package/src/core/hook-loader.js +766 -824
  24. package/src/core/hook-runner.js +615 -566
  25. package/src/core/logger.js +295 -297
  26. package/src/core/matcher.js +100 -100
  27. package/src/core/metrics.js +90 -75
  28. package/src/core/output-cap.js +31 -0
  29. package/src/core/rename-migration.js +178 -163
  30. package/src/core/requires.js +138 -138
  31. package/src/core/server-config.js +98 -98
  32. package/src/extend/add-hook.md +155 -152
  33. package/src/extend/add-mcp.md +119 -119
  34. package/src/extend/configure.md +88 -88
  35. package/src/extend/create-tool.md +168 -168
  36. package/src/extend/howto.js +102 -102
  37. package/src/extend/package.md +190 -190
  38. package/src/extend/wrap.md +99 -99
  39. package/src/mcp/aggregator.js +2248 -1674
  40. package/src/mcp/expose-store.js +755 -577
  41. package/src/mcp/gated-run.js +210 -210
  42. package/src/mcp/http-transport.js +1657 -1563
  43. package/src/mcp/mcp-client.js +1174 -1070
  44. package/src/mcp/modern.js +409 -406
  45. package/src/mcp/protocol.js +473 -468
  46. package/src/mcp/server.js +3317 -3112
  47. package/src/mcp/tool-approvals.js +396 -0
  48. package/src/tools/registry.js +607 -661
  49. package/src/tools/tool-state.js +170 -170
  50. package/src/ui/public/app.js +1246 -1187
  51. package/src/ui/public/index.html +286 -286
  52. package/src/ui/public/styles.css +535 -535
  53. package/src/ui/server.js +1742 -1616
  54. package/tools/scripts/base64.js +139 -139
  55. package/tools/scripts/danger.js +61 -61
  56. package/tools/scripts/echo.js +39 -39
  57. package/tools/scripts/hash.js +101 -101
  58. package/tools/scripts/json.js +109 -109
  59. package/tools/scripts/text-stats.js +131 -131
  60. package/tools/scripts/tf-accept-change.js +67 -0
  61. package/tools/scripts/tf-env.js +43 -43
  62. package/tools/scripts/tf-hook-add.js +151 -151
  63. package/tools/scripts/tf-hook-set.js +120 -120
  64. package/tools/scripts/tf-list.js +192 -192
  65. package/tools/scripts/tf-log.js +86 -86
  66. package/tools/scripts/tf-mcp-add.js +130 -117
  67. package/tools/scripts/tf-mcp-set.js +121 -121
  68. package/tools/scripts/tf-pack.js +237 -237
  69. package/tools/scripts/tf-tool-add.js +134 -134
  70. package/tools/scripts/tf-tool-set.js +142 -142
  71. package/tools/scripts/tf-wrap.js +103 -103
  72. package/tools/scripts/uuid.js +102 -102
  73. package/tools/tools.register.json +16 -5
@@ -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,90 @@
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
+ // Distinct byTool entries are CAPPED: the key is the tools/call name exactly as the client sent it,
23
+ // including names that never existed, so an unbounded map let a client spraying fresh names grow
24
+ // the gateway's memory for the life of the process. Names seen before the cap keep their own
25
+ // counters; everything after lands in one overflow bucket. Totals stay exact either way.
26
+ const MAX_DISTINCT_TOOLS = 1000;
27
+ const OVERFLOW_KEY = '(other)';
28
+
29
+ const state = {
30
+ startedAt: new Date().toISOString(),
31
+ calls: 0,
32
+ errors: 0,
33
+ byTool: Object.create(null), // name -> { calls, errors }
34
+ distinct: 0, // entries in byTool (the overflow bucket counts as one)
35
+ };
36
+
37
+ /**
38
+ * record - count one tools/call outcome. `ok:false` (an isError result - a tool failure OR a
39
+ * PreToolUse denial, which both surface as an error envelope) increments the error counters.
40
+ * @param {{ tool?:string, ok?:boolean }} ev
41
+ */
42
+ function record(ev) {
43
+ try {
44
+ const tool = ev && typeof ev.tool === 'string' && ev.tool.length > 0 ? ev.tool : 'unknown';
45
+ const ok = !(ev && ev.ok === false);
46
+ state.calls += 1;
47
+ if (!ok) state.errors += 1;
48
+ let t = state.byTool[tool];
49
+ if (!t && state.distinct >= MAX_DISTINCT_TOOLS && tool !== OVERFLOW_KEY) {
50
+ t = state.byTool[OVERFLOW_KEY]; // at the cap: an unseen name counts under the overflow bucket
51
+ if (!t) { t = { calls: 0, errors: 0 }; state.byTool[OVERFLOW_KEY] = t; state.distinct += 1; }
52
+ } else if (!t) {
53
+ t = { calls: 0, errors: 0 };
54
+ state.byTool[tool] = t;
55
+ state.distinct += 1;
56
+ }
57
+ t.calls += 1;
58
+ if (!ok) t.errors += 1;
59
+ } catch (_e) {
60
+ /* instrumentation must never break the caller */
61
+ }
62
+ }
63
+
64
+ /**
65
+ * snapshot - a fresh, mutation-safe copy of the counters for /health + the UI. NEVER throws.
66
+ * @returns {{ startedAt:string, calls:number, errors:number, byTool:Object<string,{calls:number,errors:number}> }}
67
+ */
68
+ function snapshot() {
69
+ try {
70
+ // Null-prototype like the source: into a plain {} a key of "__proto__" sets the PROTOTYPE
71
+ // instead of a property, so that tool's counter silently vanished from /health and the UI.
72
+ const byTool = Object.create(null);
73
+ for (const k of Object.keys(state.byTool)) {
74
+ byTool[k] = { calls: state.byTool[k].calls, errors: state.byTool[k].errors };
75
+ }
76
+ return { startedAt: state.startedAt, calls: state.calls, errors: state.errors, byTool };
77
+ } catch (_e) {
78
+ return { startedAt: state.startedAt, calls: 0, errors: 0, byTool: {} };
79
+ }
80
+ }
81
+
82
+ /** reset - zero the counters (tests). The startedAt stamp is left as the process start. */
83
+ function reset() {
84
+ state.calls = 0;
85
+ state.errors = 0;
86
+ state.byTool = Object.create(null);
87
+ state.distinct = 0;
88
+ }
89
+
90
+ module.exports = { record, snapshot, reset };
@@ -0,0 +1,31 @@
1
+ 'use strict';
2
+ /**
3
+ * output-cap.js - keep a captured stream within a byte cap.
4
+ *
5
+ * A child's output arrives in chunks (64 KiB pipe reads on Windows). Appending a whole chunk and checking the cap
6
+ * afterwards lets the captured text run past the cap by up to one chunk. takeWithinCap returns the part of a chunk
7
+ * that still fits, ending on a whole character so a multi-byte UTF-8 character is never split.
8
+ */
9
+
10
+ /**
11
+ * @param {string} text the decoded chunk
12
+ * @param {number} used bytes already captured
13
+ * @param {number} max the cap in bytes
14
+ * @returns {string} the leading part of text whose UTF-8 size fits in max - used
15
+ */
16
+ function takeWithinCap(text, used, max) {
17
+ const room = max - used;
18
+ if (room <= 0) return '';
19
+ if (Buffer.byteLength(text, 'utf8') <= room) return text;
20
+ let bytes = 0;
21
+ let end = 0;
22
+ for (const ch of text) { // by code point, so a surrogate pair is never split either
23
+ const n = Buffer.byteLength(ch, 'utf8');
24
+ if (bytes + n > room) break;
25
+ bytes += n;
26
+ end += ch.length;
27
+ }
28
+ return text.slice(0, end);
29
+ }
30
+
31
+ module.exports = { takeWithinCap };