toolfunnel 0.6.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.
- package/README.md +228 -231
- package/SECURITY.md +84 -0
- package/bin/toolfunnel.js +451 -372
- package/docs/KNOWN_BUGS.md +80 -99
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +84 -70
- 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 +39 -31
- package/mcp/expose.example.json +30 -30
- package/mcp/servers/mock-modern/server.js +147 -147
- package/mcp/servers/mock-upstream/server.js +380 -350
- package/package.json +3 -1
- package/src/auth/config.js +174 -174
- package/src/auth/install.js +181 -181
- package/src/auth/resource-server.js +353 -344
- package/src/core/child-reaper.js +61 -0
- package/src/core/config-home.js +134 -134
- package/src/core/events.js +105 -105
- package/src/core/hook-engine.js +329 -275
- package/src/core/hook-loader.js +824 -791
- package/src/core/hook-runner.js +566 -346
- package/src/core/logger.js +297 -171
- package/src/core/matcher.js +100 -51
- package/src/core/metrics.js +75 -75
- package/src/core/rename-migration.js +163 -0
- package/src/core/requires.js +138 -138
- package/src/core/server-config.js +98 -98
- package/src/extend/add-hook.md +152 -152
- package/src/extend/add-mcp.md +119 -122
- package/src/extend/configure.md +88 -87
- package/src/extend/create-tool.md +168 -168
- package/src/extend/howto.js +102 -102
- package/src/extend/package.md +190 -187
- package/src/extend/wrap.md +99 -99
- package/src/mcp/aggregator.js +1719 -1246
- package/src/mcp/expose-store.js +577 -460
- package/src/mcp/gated-run.js +210 -201
- package/src/mcp/http-transport.js +1563 -1311
- package/src/mcp/mcp-client.js +1070 -907
- package/src/mcp/modern.js +406 -372
- package/src/mcp/protocol.js +468 -468
- package/src/mcp/server.js +3112 -2688
- package/src/tools/registry.js +661 -513
- package/src/tools/tool-state.js +170 -170
- package/src/ui/public/app.js +1187 -1174
- package/src/ui/public/index.html +286 -286
- package/src/ui/public/styles.css +535 -535
- package/src/ui/server.js +1616 -1348
- 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 +120 -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 -121
- package/tools/scripts/tf-pack.js +237 -237
- package/tools/scripts/tf-tool-add.js +134 -134
- package/tools/scripts/tf-tool-set.js +142 -142
- package/tools/scripts/tf-wrap.js +103 -103
- package/tools/scripts/uuid.js +102 -102
- package/tools/tools.register.json +227 -227
- package/src/core/tool-registry.js +0 -180
- package/src/packages/packager.js +0 -715
- package/src/tools/drift.js +0 -138
package/src/core/logger.js
CHANGED
|
@@ -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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
*
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
*/
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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 };
|
package/src/core/matcher.js
CHANGED
|
@@ -1,51 +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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
|
|
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 };
|