toolfunnel 0.4.0 → 0.6.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/README.md +102 -213
- package/bin/toolfunnel.js +372 -147
- package/docs/KNOWN_BUGS.md +99 -225
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +70 -0
- package/docs/packaging.md +115 -115
- package/hooks/hooks.manifest.json +27 -27
- package/hooks/scripts/example-audit.js +21 -21
- package/hooks/scripts/example-deny-dangerous.js +31 -31
- package/mcp/expose.example.json +30 -30
- package/mcp/servers/mock-modern/server.js +147 -0
- package/mcp/servers/mock-upstream/server.js +192 -12
- package/package.json +1 -1
- package/src/auth/config.js +174 -174
- package/src/auth/install.js +181 -181
- package/src/auth/resource-server.js +344 -344
- package/src/core/config-home.js +134 -134
- package/src/core/events.js +105 -105
- package/src/core/hook-engine.js +275 -275
- package/src/core/hook-loader.js +791 -791
- package/src/core/hook-runner.js +346 -346
- package/src/core/logger.js +171 -171
- package/src/core/matcher.js +51 -51
- package/src/core/metrics.js +75 -75
- package/src/core/requires.js +138 -138
- package/src/core/server-config.js +98 -77
- package/src/core/tool-registry.js +180 -180
- package/src/extend/add-hook.md +152 -142
- package/src/extend/add-mcp.md +122 -121
- package/src/extend/configure.md +87 -0
- package/src/extend/create-tool.md +168 -152
- package/src/extend/howto.js +102 -98
- package/src/extend/package.md +187 -187
- package/src/extend/wrap.md +99 -0
- package/src/mcp/aggregator.js +1246 -807
- package/src/mcp/expose-store.js +460 -430
- package/src/mcp/gated-run.js +201 -194
- package/src/mcp/http-transport.js +1311 -981
- package/src/mcp/mcp-client.js +907 -530
- package/src/mcp/modern.js +372 -0
- package/src/mcp/protocol.js +468 -466
- package/src/mcp/server.js +2688 -1322
- package/src/packages/packager.js +715 -715
- package/src/tools/drift.js +138 -138
- package/src/tools/registry.js +513 -511
- package/src/tools/tool-state.js +170 -104
- package/src/ui/public/app.js +1174 -1055
- package/src/ui/public/index.html +286 -249
- package/src/ui/public/styles.css +535 -535
- package/src/ui/server.js +1348 -1190
- package/tools/scripts/base64.js +139 -139
- package/tools/scripts/danger.js +61 -61
- package/tools/scripts/echo.js +39 -39
- package/tools/scripts/hash.js +101 -101
- package/tools/scripts/json.js +109 -109
- package/tools/scripts/text-stats.js +131 -131
- package/tools/scripts/tf-env.js +43 -43
- package/tools/scripts/tf-hook-add.js +151 -151
- package/tools/scripts/tf-hook-set.js +112 -112
- package/tools/scripts/tf-list.js +192 -192
- package/tools/scripts/tf-log.js +86 -86
- package/tools/scripts/tf-mcp-add.js +117 -117
- package/tools/scripts/tf-mcp-set.js +121 -102
- package/tools/scripts/tf-pack.js +237 -237
- package/tools/scripts/tf-tool-add.js +134 -129
- package/tools/scripts/tf-tool-set.js +142 -142
- package/tools/scripts/tf-wrap.js +103 -0
- package/tools/scripts/uuid.js +102 -102
- package/tools/tools.register.json +227 -216
- package/docs/admin-ui.md +0 -404
- package/docs/configuration.md +0 -402
- package/docs/hooks-and-gating.md +0 -428
- package/docs/logging.md +0 -223
- package/docs/management.md +0 -429
- package/docs/oauth.md +0 -103
- package/docs/releasing.md +0 -31
- package/docs/setup.md +0 -352
- package/docs/tools-and-upstreams.md +0 -469
package/src/core/logger.js
CHANGED
|
@@ -1,171 +1,171 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* logger.js
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 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 };
|
package/src/core/matcher.js
CHANGED
|
@@ -1,51 +1,51 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* matcher.js
|
|
5
|
-
*
|
|
6
|
-
* Mirrors Claude Code's matcher semantics:
|
|
7
|
-
* - "" | undefined | "*"
|
|
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
|
|
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
|
|
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 ^(
|
|
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"
|
|
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? (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 };
|
package/src/core/metrics.js
CHANGED
|
@@ -1,75 +1,75 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* metrics.js
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 };
|