toolfunnel 0.7.1 → 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.
- package/README.md +14 -4
- package/bin/toolfunnel.js +103 -24
- package/docs/KNOWN_BUGS.md +48 -28
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +1 -1
- package/docs/packaging.md +2 -1
- package/mcp/servers/mock-modern/server.js +1 -1
- package/mcp/servers/mock-upstream/server.js +3 -4
- package/package.json +1 -1
- package/src/auth/config.js +2 -4
- package/src/auth/resource-server.js +6 -1
- package/src/core/atomic-write.js +97 -0
- package/src/core/config-home.js +100 -8
- package/src/core/hook-engine.js +29 -54
- package/src/core/hook-loader.js +3 -61
- package/src/core/hook-runner.js +92 -43
- package/src/core/logger.js +5 -7
- package/src/core/metrics.js +17 -2
- package/src/core/output-cap.js +31 -0
- package/src/core/rename-migration.js +36 -21
- package/src/extend/add-hook.md +4 -1
- package/src/extend/create-tool.md +1 -1
- package/src/extend/package.md +1 -1
- package/src/mcp/aggregator.js +583 -54
- package/src/mcp/expose-store.js +246 -68
- package/src/mcp/http-transport.js +111 -17
- package/src/mcp/mcp-client.js +130 -26
- package/src/mcp/modern.js +9 -6
- package/src/mcp/protocol.js +12 -7
- package/src/mcp/server.js +268 -63
- package/src/mcp/tool-approvals.js +396 -0
- package/src/tools/registry.js +33 -87
- package/src/tools/tool-state.js +1 -1
- package/src/ui/public/app.js +60 -1
- package/src/ui/server.js +142 -16
- package/tools/scripts/tf-accept-change.js +67 -0
- package/tools/scripts/tf-mcp-add.js +28 -15
- package/tools/tools.register.json +16 -5
package/src/core/config-home.js
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
* empty directory makes the gateway SEED it on first use - the shipped register + its scripts, an
|
|
15
15
|
* empty expose.json, the default hooks manifest - and every mutable path (tools/ mcp/ hooks/
|
|
16
16
|
* auth/ logs/ toolfunnel.json packages/) anchors there from then on. Seeding never overwrites an
|
|
17
|
-
* existing file, so a home survives package updates by construction
|
|
17
|
+
* existing file, so a home survives package updates by construction; the one addition is a tool
|
|
18
|
+
* new in a release, appended to the register once (offerNewShippedTools).
|
|
18
19
|
*
|
|
19
20
|
* Two env vars are the process-tree contract (children inherit them via defaultRunScript's
|
|
20
21
|
* spawn env):
|
|
@@ -31,6 +32,7 @@
|
|
|
31
32
|
|
|
32
33
|
const fs = require('node:fs');
|
|
33
34
|
const path = require('node:path');
|
|
35
|
+
const { atomicWriteJson } = require('./atomic-write');
|
|
34
36
|
|
|
35
37
|
/** The package root: this file lives at <pkg>/src/core/config-home.js. */
|
|
36
38
|
const PKG_ROOT = path.resolve(__dirname, '..', '..');
|
|
@@ -58,23 +60,90 @@ function copyTreeIfAbsent(srcDir, destDir, seeded) {
|
|
|
58
60
|
} catch (_e) {
|
|
59
61
|
return; // a missing source dir seeds nothing (defensive)
|
|
60
62
|
}
|
|
61
|
-
|
|
63
|
+
// A home the user has made read-only still starts: what cannot be written is not seeded, and the
|
|
64
|
+
// gateway runs with the files the home already has. Any other failure is real and is thrown.
|
|
65
|
+
if (!writeOrSkip(() => fs.mkdirSync(destDir, { recursive: true }))) return;
|
|
62
66
|
for (const e of entries) {
|
|
63
67
|
const from = path.join(srcDir, e.name);
|
|
64
68
|
const to = path.join(destDir, e.name);
|
|
65
69
|
if (e.isDirectory()) {
|
|
66
70
|
copyTreeIfAbsent(from, to, seeded);
|
|
67
71
|
} else if (e.isFile() && !fs.existsSync(to)) {
|
|
68
|
-
fs.copyFileSync(from, to);
|
|
69
|
-
seeded.push(to);
|
|
72
|
+
if (writeOrSkip(() => fs.copyFileSync(from, to))) seeded.push(to);
|
|
70
73
|
}
|
|
71
74
|
}
|
|
72
75
|
}
|
|
73
76
|
|
|
77
|
+
/** Runs a write; false when the file system refuses it for permission (the caller skips that file). */
|
|
78
|
+
function writeOrSkip(write) {
|
|
79
|
+
try {
|
|
80
|
+
write();
|
|
81
|
+
return true;
|
|
82
|
+
} catch (e) {
|
|
83
|
+
if (e && (e.code === 'EACCES' || e.code === 'EPERM' || e.code === 'EROFS')) return false;
|
|
84
|
+
throw e;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The shipped register ids of 0.7.x, the releases before tools/tools.seeded.json existed: a home
|
|
89
|
+
// upgraded from one of them has already been offered these, so only tools shipped since are added.
|
|
90
|
+
const SHIPPED_BEFORE_LEDGER = ['echo', 'base64', 'hash', 'uuid', 'json', 'text-stats', 'danger',
|
|
91
|
+
'tf_tool_add', 'tf_tool_set', 'tf_mcp_add', 'tf_mcp_set', 'tf_wrap', 'tf_hook_add', 'tf_hook_set',
|
|
92
|
+
'tf_list', 'tf_log', 'tf_pack'];
|
|
93
|
+
|
|
94
|
+
// Windows paths are case-insensitive: g:\dev\toolfunnel IS the package root.
|
|
95
|
+
const samePath = (a, b) => (process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b);
|
|
96
|
+
const readJson = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (_e) { return null; } };
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Add the shipped register entries this home has never been offered, once each. The ledger
|
|
100
|
+
* (tools/tools.seeded.json) records every id offered, so a shipped tool the user deleted is never
|
|
101
|
+
* added back; existing entries are never touched, and an unreadable register or ledger is left
|
|
102
|
+
* alone. Seeding alone cannot do this: it never overwrites a file, and the register is one file.
|
|
103
|
+
* @param {string} home
|
|
104
|
+
* @param {boolean} registerSeeded the register was copied in on this call (a fresh home)
|
|
105
|
+
* @returns {string[]} the ids added to the register
|
|
106
|
+
*/
|
|
107
|
+
function offerNewShippedTools(home, registerSeeded) {
|
|
108
|
+
const regPath = path.join(home, 'tools', 'tools.register.json');
|
|
109
|
+
const ledgerPath = path.join(home, 'tools', 'tools.seeded.json');
|
|
110
|
+
const shipped = readJson(path.join(PKG_ROOT, 'tools', 'tools.register.json'));
|
|
111
|
+
const reg = readJson(regPath);
|
|
112
|
+
if (!shipped || !Array.isArray(shipped.tools) || !reg || !Array.isArray(reg.tools)) return [];
|
|
113
|
+
const shippedIds = shipped.tools.map((t) => t && t.id).filter((id) => typeof id === 'string');
|
|
114
|
+
const hadLedger = fs.existsSync(ledgerPath);
|
|
115
|
+
let offered;
|
|
116
|
+
if (hadLedger) {
|
|
117
|
+
const ledger = readJson(ledgerPath);
|
|
118
|
+
if (!ledger || !Array.isArray(ledger.ids)) return [];
|
|
119
|
+
offered = new Set(ledger.ids);
|
|
120
|
+
} else {
|
|
121
|
+
offered = new Set(registerSeeded ? shippedIds : SHIPPED_BEFORE_LEDGER);
|
|
122
|
+
}
|
|
123
|
+
const offeredBefore = offered.size;
|
|
124
|
+
const present = new Set(reg.tools.map((t) => t && t.id));
|
|
125
|
+
const added = [];
|
|
126
|
+
for (const t of shipped.tools) {
|
|
127
|
+
if (!t || offered.has(t.id)) continue;
|
|
128
|
+
if (!present.has(t.id)) { reg.tools.push(t); added.push(t.id); }
|
|
129
|
+
offered.add(t.id);
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
if (added.length) atomicWriteJson(regPath, reg);
|
|
133
|
+
if (!hadLedger || offered.size !== offeredBefore) atomicWriteJson(ledgerPath, { version: 1, ids: [...offered] });
|
|
134
|
+
} catch (err) {
|
|
135
|
+
// A home that cannot be rewritten (read-only file or mount) ran fine before: keep it running,
|
|
136
|
+
// without the new tools, and try again next start.
|
|
137
|
+
process.stderr.write(`[toolfunnel] could not add new shipped tools to ${regPath}: ${(err && err.message) || err}\n`);
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
return added;
|
|
141
|
+
}
|
|
142
|
+
|
|
74
143
|
/**
|
|
75
144
|
* Make `home` a working config home, seeding whatever is ABSENT from the shipped defaults.
|
|
76
145
|
* Idempotent and never destructive: an existing file is NEVER overwritten (a home must survive
|
|
77
|
-
* package updates - that is the whole point). home === package root -> a no-op (the git-clone
|
|
146
|
+
* package updates - that is the whole point), bar the register gaining a new shipped tool once. home === package root -> a no-op (the git-clone
|
|
78
147
|
* layout is already complete). Throws only if the home cannot be created at all.
|
|
79
148
|
*
|
|
80
149
|
* Deliberately NOT seeded: tools.state.json (absent = default visibility), auth/ (absent = auth
|
|
@@ -82,16 +151,18 @@ function copyTreeIfAbsent(srcDir, destDir, seeded) {
|
|
|
82
151
|
* npm-wrap ships its OWN copy inside its bundled home).
|
|
83
152
|
*
|
|
84
153
|
* @param {string} home absolute config-home path (from resolveConfigHome)
|
|
85
|
-
* @returns {{home: string, seeded: string[]}} the files seeded on THIS call (empty =
|
|
154
|
+
* @returns {{home: string, seeded: string[], added: string[]}} the files seeded on THIS call (empty =
|
|
155
|
+
* was complete) and the shipped tool ids newly added to an existing register
|
|
86
156
|
*/
|
|
87
157
|
function ensureConfigHome(home) {
|
|
88
158
|
const seeded = [];
|
|
89
|
-
if (path.resolve(home)
|
|
159
|
+
if (samePath(path.resolve(home), PKG_ROOT)) return { home: PKG_ROOT, seeded, added: [] };
|
|
90
160
|
fs.mkdirSync(home, { recursive: true });
|
|
91
161
|
|
|
92
162
|
// The register + every shipped script (management tf_* + demo tools). The management scripts
|
|
93
163
|
// are config-tree citizens (the register's invoke paths point at them), so they travel.
|
|
94
164
|
copyTreeIfAbsent(path.join(PKG_ROOT, 'tools'), path.join(home, 'tools'), seeded);
|
|
165
|
+
const added = offerNewShippedTools(home, seeded.includes(path.join(home, 'tools', 'tools.register.json')));
|
|
95
166
|
// The state overlay is user-state, not a shipped default - drop a copied one if the package
|
|
96
167
|
// tree happened to carry local toggles (dev clone); a FRESH home starts with default visibility.
|
|
97
168
|
const strayState = path.join(home, 'tools', 'tools.state.json');
|
|
@@ -106,6 +177,19 @@ function ensureConfigHome(home) {
|
|
|
106
177
|
// the example is a POPULATED demo (it attaches the mock upstream), and a fresh home must start
|
|
107
178
|
// connected to nothing, exactly like a fresh clone.
|
|
108
179
|
copyTreeIfAbsent(path.join(PKG_ROOT, 'mcp'), path.join(home, 'mcp'), seeded);
|
|
180
|
+
// That tarball fact does not hold for a git clone: the package tree then carries the LIVE
|
|
181
|
+
// store (mcp/expose.json) and any operator backups of it, both user-state, and the copy above
|
|
182
|
+
// brought them across - so the "synthesise EMPTY when absent" guard below was dead and a fresh
|
|
183
|
+
// home started attached to the developer's real upstreams. Same class as the tools.state.json
|
|
184
|
+
// case above, same remedy: at this level the package ships ONLY expose.example.json (see
|
|
185
|
+
// package.json "files"), so drop anything else the copy seeded here.
|
|
186
|
+
const mcpHome = path.join(home, 'mcp');
|
|
187
|
+
for (const copied of seeded.slice()) {
|
|
188
|
+
if (path.dirname(copied) === mcpHome && path.basename(copied) !== 'expose.example.json') {
|
|
189
|
+
try { fs.unlinkSync(copied); } catch (_e) { /* best-effort */ }
|
|
190
|
+
seeded.splice(seeded.indexOf(copied), 1);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
109
193
|
const exposePath = path.join(home, 'mcp', 'expose.json');
|
|
110
194
|
if (!fs.existsSync(exposePath)) {
|
|
111
195
|
fs.mkdirSync(path.dirname(exposePath), { recursive: true });
|
|
@@ -113,7 +197,15 @@ function ensureConfigHome(home) {
|
|
|
113
197
|
seeded.push(exposePath);
|
|
114
198
|
}
|
|
115
199
|
copyTreeIfAbsent(path.join(PKG_ROOT, 'hooks'), path.join(home, 'hooks'), seeded);
|
|
116
|
-
|
|
200
|
+
// hooks.state.json is user state too - the live hook toggles - same class as tools.state.json above:
|
|
201
|
+
// a git clone's copy must not travel into a fresh home, which starts from the shipped manifest's own
|
|
202
|
+
// enabled flags (config-home-hookstate.test.js).
|
|
203
|
+
const strayHookState = path.join(home, 'hooks', 'hooks.state.json');
|
|
204
|
+
if (seeded.includes(strayHookState)) {
|
|
205
|
+
try { fs.unlinkSync(strayHookState); } catch (_e) { /* best-effort */ }
|
|
206
|
+
seeded.splice(seeded.indexOf(strayHookState), 1);
|
|
207
|
+
}
|
|
208
|
+
return { home, seeded, added };
|
|
117
209
|
}
|
|
118
210
|
|
|
119
211
|
/**
|
package/src/core/hook-engine.js
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* contributes a synthetic BLOCKING result (its command never runs) - an unreadable gate is
|
|
26
26
|
* never permission, mirroring the runner's unreadable-decision rule. Authoring surfaces
|
|
27
27
|
* refuse such a matcher up front (hook-loader.addEntry), so this only fires for a
|
|
28
|
-
* hand-edited manifest.
|
|
28
|
+
* hand-edited manifest.
|
|
29
29
|
* - Build the stdin payload once via events.buildPayload(event, ctx, extra).
|
|
30
30
|
* - Run selected hooks CONCURRENTLY (they are independent) with a global concurrency cap
|
|
31
31
|
* (default 8) so a big event cannot fork-bomb, but return results in MANIFEST ORDER.
|
|
@@ -48,56 +48,22 @@ const hookRunner = require('./hook-runner');
|
|
|
48
48
|
|
|
49
49
|
const DEFAULT_CONCURRENCY = 8;
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
function
|
|
57
|
-
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Resolve matcherError from matcher.js (named export). Falls back to "no problem" so an older
|
|
65
|
-
* matcher module degrades to the previous behaviour rather than crashing the engine.
|
|
66
|
-
* @returns {(m: string|undefined) => (string|null)}
|
|
67
|
-
*/
|
|
68
|
-
function getMatcherErrorFn() {
|
|
69
|
-
if (matcher && typeof matcher.matcherError === 'function') return matcher.matcherError;
|
|
70
|
-
return () => null;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Resolve buildPayload from events.js (named export per contract).
|
|
75
|
-
* @returns {(event: string, ctx: object, extra: object) => object}
|
|
76
|
-
*/
|
|
77
|
-
function getBuildPayloadFn() {
|
|
78
|
-
if (events && typeof events.buildPayload === 'function') return events.buildPayload;
|
|
79
|
-
// Defensive fallback: assemble a minimal common payload.
|
|
80
|
-
return (event, ctx, extra) =>
|
|
81
|
-
Object.assign(
|
|
82
|
-
{
|
|
83
|
-
session_id: (ctx && ctx.session_id) || '',
|
|
84
|
-
transcript_path: (ctx && ctx.transcript_path) || '',
|
|
85
|
-
cwd: (ctx && ctx.cwd) || process.cwd(),
|
|
86
|
-
hook_event_name: event,
|
|
87
|
-
},
|
|
88
|
-
extra || {}
|
|
89
|
-
);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Resolve runHook from hook-runner.js (named export per contract).
|
|
94
|
-
* @returns {(spec: object, payload: object, opts: object) => Promise<object>}
|
|
95
|
-
*/
|
|
96
|
-
function getRunHookFn() {
|
|
97
|
-
if (hookRunner && typeof hookRunner.runHook === 'function') return hookRunner.runHook;
|
|
98
|
-
if (typeof hookRunner === 'function') return hookRunner;
|
|
99
|
-
throw new Error('hook-engine: hook-runner.runHook is unavailable');
|
|
51
|
+
// The engine's dependencies, bound directly, with no stand-ins: firing every hook, reading every
|
|
52
|
+
// matcher as valid or building a payload by hand would change what a gate means, and the one case
|
|
53
|
+
// that reaches them (a broken or mismatched install) is exactly when it must not. Refuse to LOAD
|
|
54
|
+
// instead, naming what is missing
|
|
55
|
+
// (hook-engine-requires.test.js).
|
|
56
|
+
function required(mod, name, from) {
|
|
57
|
+
const fn = mod && mod[name];
|
|
58
|
+
if (typeof fn !== 'function') {
|
|
59
|
+
throw new Error(`hook-engine: ${from} must export ${name}() - refusing to load without it`);
|
|
60
|
+
}
|
|
61
|
+
return fn;
|
|
100
62
|
}
|
|
63
|
+
const matchesFn = required(matcher, 'matches', './matcher');
|
|
64
|
+
const matcherErrorFn = required(matcher, 'matcherError', './matcher');
|
|
65
|
+
const buildPayloadFn = required(events, 'buildPayload', './events');
|
|
66
|
+
const runHookFn = required(hookRunner, 'runHook', './hook-runner');
|
|
101
67
|
|
|
102
68
|
/**
|
|
103
69
|
* Build a synthetic "failed" result matching the runner's result shape, used when the runner rejects
|
|
@@ -180,10 +146,10 @@ class HookEngine {
|
|
|
180
146
|
this.env = opts.env || null;
|
|
181
147
|
|
|
182
148
|
// Resolve contract functions once.
|
|
183
|
-
this._matches =
|
|
184
|
-
this._matcherError =
|
|
185
|
-
this._buildPayload =
|
|
186
|
-
this._runHook =
|
|
149
|
+
this._matches = matchesFn;
|
|
150
|
+
this._matcherError = matcherErrorFn;
|
|
151
|
+
this._buildPayload = buildPayloadFn;
|
|
152
|
+
this._runHook = runHookFn;
|
|
187
153
|
}
|
|
188
154
|
|
|
189
155
|
/**
|
|
@@ -224,6 +190,15 @@ class HookEngine {
|
|
|
224
190
|
const runCwd = (payload && payload.cwd) || this.cwd;
|
|
225
191
|
const runOpts = { cwd: runCwd };
|
|
226
192
|
if (this.env) runOpts.env = this.env;
|
|
193
|
+
// The home the hooks were loaded from, so the runner sets HOOKS_DIR for the child from THIS
|
|
194
|
+
// tree (a relocated --config-dir home) rather than its package default - the same value the
|
|
195
|
+
// engine already uses for the child cwd above. CLAUDE_PROJECT_DIR: a value INHERITED from the
|
|
196
|
+
// gateway's own environment (a host's project - hooks ported from Claude Code read it that way)
|
|
197
|
+
// wins; the home is the fallback when nothing set it.
|
|
198
|
+
if (this.loader.hooksDir) {
|
|
199
|
+
runOpts.hooksDir = this.loader.hooksDir;
|
|
200
|
+
runOpts.projectDir = process.env.CLAUDE_PROJECT_DIR || path.dirname(this.loader.hooksDir);
|
|
201
|
+
}
|
|
227
202
|
|
|
228
203
|
// Select enabled hooks for this event and build task factories, in MANIFEST ORDER.
|
|
229
204
|
//
|
package/src/core/hook-loader.js
CHANGED
|
@@ -396,7 +396,7 @@ class HookLoader {
|
|
|
396
396
|
// A matcher that does not compile can never be evaluated - storing it would author a
|
|
397
397
|
// hook whose gate the engine must refuse on every tool-bearing call (hook-engine's
|
|
398
398
|
// unreadable-gate rule). Refuse HERE so the UI's POST /api/hooks/add and tf_hook_add
|
|
399
|
-
// both answer with the problem instead of 200/ok.
|
|
399
|
+
// both answer with the problem instead of 200/ok.
|
|
400
400
|
if (spec.matcher !== undefined && spec.matcher !== null && typeof spec.matcher !== 'string') {
|
|
401
401
|
throw new Error('addEntry: spec.matcher must be a string when provided');
|
|
402
402
|
}
|
|
@@ -640,66 +640,8 @@ function collapseManifest(manifest, hooksDir) {
|
|
|
640
640
|
return copy;
|
|
641
641
|
}
|
|
642
642
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
* as the target (so rename is atomic on the same filesystem), then rename over the target.
|
|
646
|
-
*
|
|
647
|
-
* @param {string} targetPath absolute destination
|
|
648
|
-
* @param {object} obj
|
|
649
|
-
*/
|
|
650
|
-
function atomicWriteJson(targetPath, obj) {
|
|
651
|
-
const data = JSON.stringify(obj, null, 2) + '\n';
|
|
652
|
-
atomicWriteText(targetPath, data, path.dirname(targetPath));
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
/**
|
|
656
|
-
* Atomically write text: temp file in `tmpDir` (must be on the same volume as the target),
|
|
657
|
-
* fsync, then rename over the target. Cleans up the temp file on failure.
|
|
658
|
-
*
|
|
659
|
-
* @param {string} targetPath absolute destination
|
|
660
|
-
* @param {string} data
|
|
661
|
-
* @param {string} tmpDir directory to place the temp file (same FS as target)
|
|
662
|
-
*/
|
|
663
|
-
function atomicWriteText(targetPath, data, tmpDir) {
|
|
664
|
-
const dir = tmpDir || path.dirname(targetPath);
|
|
665
|
-
const tmpName = `.${path.basename(targetPath)}.${process.pid}.${Date.now()}.${Math.random()
|
|
666
|
-
.toString(36)
|
|
667
|
-
.slice(2)}.tmp`;
|
|
668
|
-
const tmpPath = path.join(dir, tmpName);
|
|
669
|
-
|
|
670
|
-
let fd;
|
|
671
|
-
try {
|
|
672
|
-
fd = fs.openSync(tmpPath, 'w');
|
|
673
|
-
fs.writeFileSync(fd, data);
|
|
674
|
-
try {
|
|
675
|
-
fs.fsyncSync(fd);
|
|
676
|
-
} catch (_) {
|
|
677
|
-
// fsync may fail on some platforms/filesystems; the rename is still ordered.
|
|
678
|
-
}
|
|
679
|
-
} finally {
|
|
680
|
-
if (fd !== undefined) {
|
|
681
|
-
try {
|
|
682
|
-
fs.closeSync(fd);
|
|
683
|
-
} catch (_) {
|
|
684
|
-
/* ignore */
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
try {
|
|
690
|
-
// rename is atomic on the same filesystem and overwrites the destination on POSIX;
|
|
691
|
-
// on Windows it overwrites too as long as the target isn't locked.
|
|
692
|
-
fs.renameSync(tmpPath, targetPath);
|
|
693
|
-
} catch (err) {
|
|
694
|
-
// Best-effort cleanup so we don't leave temp files behind.
|
|
695
|
-
try {
|
|
696
|
-
fs.unlinkSync(tmpPath);
|
|
697
|
-
} catch (_) {
|
|
698
|
-
/* ignore */
|
|
699
|
-
}
|
|
700
|
-
throw err;
|
|
701
|
-
}
|
|
702
|
-
}
|
|
643
|
+
// THE atomic writer (temp + fsync + rename, failure-path unlink, stale sweep) - shared by every store.
|
|
644
|
+
const { atomicWriteJson, atomicWriteText } = require('./atomic-write');
|
|
703
645
|
|
|
704
646
|
/**
|
|
705
647
|
* Load a manifest from disk, expand ${HOOKS_DIR} in every command string, and return a
|
package/src/core/hook-runner.js
CHANGED
|
@@ -14,21 +14,35 @@
|
|
|
14
14
|
|
|
15
15
|
const { spawn, execFile } = require('node:child_process');
|
|
16
16
|
const reaper = require('./child-reaper');
|
|
17
|
+
const { takeWithinCap } = require('./output-cap');
|
|
17
18
|
const path = require('node:path');
|
|
18
19
|
|
|
19
|
-
// Default HOOKS_DIR: <root>/
|
|
20
|
-
|
|
20
|
+
// Default HOOKS_DIR: <root>/hooks (the shipped hooks tree). __dirname is <root>/src/core, so up two.
|
|
21
|
+
// The engine always passes the loaded home's hooksDir, so only a direct runHook() call without
|
|
22
|
+
// options sees this default.
|
|
23
|
+
const DEFAULT_HOOKS_DIR = path.resolve(__dirname, '..', '..', 'hooks');
|
|
21
24
|
|
|
22
25
|
// Events whose exit-0 stdout is treated as injected context (the exit-code protocol's injection half).
|
|
23
26
|
const INJECTABLE_EVENTS = new Set(['SessionStart', 'UserPromptSubmit']);
|
|
24
27
|
|
|
25
|
-
// Returned by tryParseJsonProtocol when stdout
|
|
26
|
-
//
|
|
28
|
+
// Returned by tryParseJsonProtocol when stdout clearly carried an ATTEMPTED decision (it opens with
|
|
29
|
+
// '{', or a line-opening '{' block after other output names a protocol key) that does not parse. This is DISTINCT from null: null means "no protocol was attempted,
|
|
27
30
|
// use the exit-code protocol", whereas this means "a decision was attempted but could not be read".
|
|
28
31
|
// The two must be handled differently - an unreadable decision fails closed, the same as output
|
|
29
32
|
// past the cap, so a decision that loses a character is never mistaken for "allow".
|
|
30
33
|
const CORRUPT_PROTOCOL = Symbol('corrupt-protocol');
|
|
31
34
|
|
|
35
|
+
// A line-opening '{' block that will not parse counts as an ATTEMPTED decision only when it names a
|
|
36
|
+
// protocol key - otherwise it is a diagnostic dump, not an answer.
|
|
37
|
+
// Keys may be double-quoted (JSON), single-quoted (a printed Python dict) or bare (a Node object printed
|
|
38
|
+
// without JSON.stringify) - all three are a hook TRYING to answer, and an unreadable answer is never allow.
|
|
39
|
+
const PROTOCOL_KEY_RE = /(^|[{,\s])(["']?)(continue|decision|reason|hookSpecificOutput|permissionDecision)\2\s*:/;
|
|
40
|
+
// The prefixed scan is BOUNDED: it inspects at most this many line-opening '{' lines from the tail, and
|
|
41
|
+
// tries the block-to-end parse (a pretty-printed decision) only for the last few of them. Unbounded, a
|
|
42
|
+
// hook dumping tens of thousands of object lines stalled the gateway's event loop for seconds per call.
|
|
43
|
+
const MAX_CANDIDATE_LINES = 64;
|
|
44
|
+
const MAX_BLOCK_CANDIDATES = 4;
|
|
45
|
+
|
|
32
46
|
// Output ceilings. A hook's decision is a small JSON object and its diagnostics are a few lines;
|
|
33
47
|
// anything approaching these is a runaway, and accumulating it unbounded would OOM the gateway
|
|
34
48
|
// itself. Hitting either cap is treated as a WIRING FAILURE and fails closed (see the accumulator
|
|
@@ -59,10 +73,24 @@ function isKnownJsonProtocol(parsed) {
|
|
|
59
73
|
}
|
|
60
74
|
|
|
61
75
|
/**
|
|
62
|
-
* Try to
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
76
|
+
* Try to read stdout as the JSON protocol. Returns the parsed decision object when one is found;
|
|
77
|
+
* CORRUPT_PROTOCOL when a decision was ATTEMPTED but cannot be read; otherwise null (no protocol
|
|
78
|
+
* attempt - use the exit-code protocol).
|
|
79
|
+
*
|
|
80
|
+
* When the whole of stdout is one JSON value, that is the answer (a decision if it names a
|
|
81
|
+
* protocol key, otherwise none). Otherwise - a startup line before the decision, log lines after
|
|
82
|
+
* it - the decision is a block that opens with '{' at the start of a line: the block running to
|
|
83
|
+
* the end of stdout (a pretty-printed decision) or that one line. A block that opens with '{' and
|
|
84
|
+
* parses as something else (an inspect-style dump, a JSON log line) is not a decision. An
|
|
85
|
+
* unparseable block that NAMES a protocol key (quoted or bare) AFTER the last readable decision is
|
|
86
|
+
* an attempted answer -> CORRUPT: the hook's last word could not be read. Unparseable chatter is
|
|
87
|
+
* chatter; without that narrowing an allow-gate that printed `{ tool: 'x' }` denied every call.
|
|
88
|
+
*
|
|
89
|
+
* Two readable decisions that disagree are CORRUPT too: a hook that echoes what it was given (tool
|
|
90
|
+
* arguments, an audit line) prints text the MODEL controls, and that text must never outvote the
|
|
91
|
+
* hook's own answer, before or after it. For the same reason a scan cut short by its bound
|
|
92
|
+
* (MAX_CANDIDATE_LINES, so a hook dumping thousands of object lines cannot stall the event loop)
|
|
93
|
+
* is CORRUPT when anything it did not read names a protocol key - that could be the real answer.
|
|
66
94
|
*
|
|
67
95
|
* @param {string} stdout
|
|
68
96
|
* @returns {object|symbol|null}
|
|
@@ -70,19 +98,42 @@ function isKnownJsonProtocol(parsed) {
|
|
|
70
98
|
function tryParseJsonProtocol(stdout) {
|
|
71
99
|
const trimmed = (stdout || '').trim();
|
|
72
100
|
if (!trimmed) return null;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
101
|
+
const asObject = (text) => {
|
|
102
|
+
let v;
|
|
103
|
+
try { v = JSON.parse(text); } catch (_e) { return undefined; } // undefined = did not parse
|
|
104
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v) ? v : null; // null = parsed, not an object
|
|
105
|
+
};
|
|
106
|
+
if (trimmed[0] === '{') {
|
|
107
|
+
const whole = asObject(trimmed);
|
|
108
|
+
if (whole !== undefined) return whole && isKnownJsonProtocol(whole) ? whole : null;
|
|
109
|
+
}
|
|
110
|
+
const lines = trimmed.split('\n');
|
|
111
|
+
const decisions = [];
|
|
112
|
+
let attempted = false;
|
|
113
|
+
let seen = 0;
|
|
114
|
+
let blocks = 0;
|
|
115
|
+
let i = lines.length - 1;
|
|
116
|
+
for (; i >= 0 && seen < MAX_CANDIDATE_LINES; i -= 1) {
|
|
117
|
+
if (lines[i][0] !== '{') continue;
|
|
118
|
+
seen += 1;
|
|
119
|
+
const candidates = blocks < MAX_BLOCK_CANDIDATES ? [lines.slice(i).join('\n'), lines[i]] : [lines[i]];
|
|
120
|
+
if (blocks < MAX_BLOCK_CANDIDATES) blocks += 1;
|
|
121
|
+
let readable = false;
|
|
122
|
+
for (const candidate of candidates) {
|
|
123
|
+
const obj = asObject(candidate);
|
|
124
|
+
if (obj === undefined) continue;
|
|
125
|
+
readable = true;
|
|
126
|
+
if (obj && isKnownJsonProtocol(obj)) decisions.push(obj);
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
// Scanned from the tail: before any decision is found, an unreadable answer is the LAST word.
|
|
130
|
+
if (!readable && decisions.length === 0 && PROTOCOL_KEY_RE.test(candidates[0])) attempted = true;
|
|
84
131
|
}
|
|
85
|
-
|
|
132
|
+
// Cut short by the bound: an unread line that names a protocol key could be the hook's answer.
|
|
133
|
+
if (attempted || lines.slice(0, i + 1).some((l) => PROTOCOL_KEY_RE.test(l))) return CORRUPT_PROTOCOL;
|
|
134
|
+
if (decisions.length === 0) return null;
|
|
135
|
+
const first = JSON.stringify(decisions[0]);
|
|
136
|
+
return decisions.every((d) => JSON.stringify(d) === first) ? decisions[0] : CORRUPT_PROTOCOL;
|
|
86
137
|
}
|
|
87
138
|
|
|
88
139
|
/**
|
|
@@ -94,7 +145,7 @@ function tryParseJsonProtocol(stdout) {
|
|
|
94
145
|
* @param {string} [opts.cwd] child working directory.
|
|
95
146
|
* @param {object} [opts.env] extra env merged over process.env.
|
|
96
147
|
* @param {string} [opts.projectDir] value for CLAUDE_PROJECT_DIR.
|
|
97
|
-
* @param {string} [opts.hooksDir] value for HOOKS_DIR (default <root>/
|
|
148
|
+
* @param {string} [opts.hooksDir] value for HOOKS_DIR (default <root>/hooks).
|
|
98
149
|
* @param {AbortSignal} [opts.signal] external cancellation; aborts the child.
|
|
99
150
|
* @returns {Promise<object>} the full result shape. Never rejects.
|
|
100
151
|
*/
|
|
@@ -142,8 +193,9 @@ function runHook(hookSpec, payload, opts = {}) {
|
|
|
142
193
|
opts.projectDir ||
|
|
143
194
|
(opts.env && opts.env.CLAUDE_PROJECT_DIR) ||
|
|
144
195
|
process.env.CLAUDE_PROJECT_DIR ||
|
|
145
|
-
//
|
|
146
|
-
|
|
196
|
+
// The project root is the tree the hooks dir sits in (<root>/hooks -> <root>; a config home's
|
|
197
|
+
// <home>/hooks -> <home>).
|
|
198
|
+
path.dirname(hooksDir);
|
|
147
199
|
|
|
148
200
|
const childEnv = Object.assign({}, process.env, opts.env || {}, {
|
|
149
201
|
CLAUDE_PROJECT_DIR: projectDir,
|
|
@@ -244,9 +296,9 @@ function runHook(hookSpec, payload, opts = {}) {
|
|
|
244
296
|
//
|
|
245
297
|
// 'close' fires only when every stdio pipe is closed, and a GRANDCHILD that inherited those
|
|
246
298
|
// pipes keeps them open for its own lifetime even after the shell is dead. Waiting for 'close'
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
299
|
+
// would make the timeout unenforceable in exactly the case it exists for: a hook whose
|
|
300
|
+
// grandchild sleeps 60 s resolves at 60 s against a 2.5 s timeout, on Windows, even WITH
|
|
301
|
+
// taskkill /T. The tree kill still matters (it stops us leaking processes), but
|
|
250
302
|
// the timeout contract must not depend on it succeeding on any platform.
|
|
251
303
|
const timer = setTimeout(() => {
|
|
252
304
|
timedOut = true;
|
|
@@ -309,13 +361,11 @@ function runHook(hookSpec, payload, opts = {}) {
|
|
|
309
361
|
child.stdout.setEncoding('utf8');
|
|
310
362
|
child.stdout.on('data', (chunk) => {
|
|
311
363
|
if (hrOutBytes >= MAX_STDOUT_BYTES) return;
|
|
312
|
-
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
const outOver = hrOutBytes - MAX_STDOUT_BYTES;
|
|
318
|
-
stdout += outOver > 0 ? chunk.slice(0, Math.max(0, chunk.length - outOver)) : chunk;
|
|
364
|
+
// Streams deliver whole chunks (64 KB on Windows), so the chunk that crosses the cap is cut to the bytes
|
|
365
|
+
// left, at a whole character: never past the cap, never half a character or half a surrogate pair.
|
|
366
|
+
const kept = takeWithinCap(chunk, hrOutBytes, MAX_STDOUT_BYTES);
|
|
367
|
+
stdout += kept;
|
|
368
|
+
hrOutBytes = kept.length < chunk.length ? MAX_STDOUT_BYTES : hrOutBytes + Buffer.byteLength(kept, 'utf8');
|
|
319
369
|
if (hrOutBytes >= MAX_STDOUT_BYTES) {
|
|
320
370
|
outputCapped = 'stdout';
|
|
321
371
|
killChildTree();
|
|
@@ -327,9 +377,9 @@ function runHook(hookSpec, payload, opts = {}) {
|
|
|
327
377
|
child.stderr.setEncoding('utf8');
|
|
328
378
|
child.stderr.on('data', (chunk) => {
|
|
329
379
|
if (hrErrBytes >= MAX_STDERR_BYTES) return;
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
380
|
+
const kept = takeWithinCap(chunk, hrErrBytes, MAX_STDERR_BYTES);
|
|
381
|
+
stderr += kept;
|
|
382
|
+
hrErrBytes = kept.length < chunk.length ? MAX_STDERR_BYTES : hrErrBytes + Buffer.byteLength(kept, 'utf8');
|
|
333
383
|
if (hrErrBytes >= MAX_STDERR_BYTES) {
|
|
334
384
|
stderr += '\n[hook-runner: stderr truncated at cap]';
|
|
335
385
|
if (!outputCapped) outputCapped = 'stderr';
|
|
@@ -420,11 +470,11 @@ function runHook(hookSpec, payload, opts = {}) {
|
|
|
420
470
|
// B) Advanced / JSON protocol - honored only on exit 0.
|
|
421
471
|
const json = r.exitCode === 0 ? tryParseJsonProtocol(stdout) : null;
|
|
422
472
|
|
|
423
|
-
//
|
|
424
|
-
// unreadable decision. It fails closed - denied - the same as output past the
|
|
425
|
-
// saw an attempted answer it could not read, and an unreadable answer is never
|
|
426
|
-
// stdout that made no protocol attempt (
|
|
427
|
-
// falls through to the exit-code protocol below.
|
|
473
|
+
// An ATTEMPTED decision that FAILED TO PARSE (see tryParseJsonProtocol for what counts as an
|
|
474
|
+
// attempt) is an unreadable decision. It fails closed - denied - the same as output past the
|
|
475
|
+
// cap: the runner saw an attempted answer it could not read, and an unreadable answer is never
|
|
476
|
+
// "allow". Only stdout that made no protocol attempt (plain text, valid JSON that is not a
|
|
477
|
+
// decision) falls through to the exit-code protocol below.
|
|
428
478
|
if (json === CORRUPT_PROTOCOL) {
|
|
429
479
|
r.blocked = true;
|
|
430
480
|
r.reason = `hook-runner: ${event || 'hook'}${id ? ` "${id}"` : ''} emitted a protocol `
|
|
@@ -448,8 +498,7 @@ function runHook(hookSpec, payload, opts = {}) {
|
|
|
448
498
|
// output caps). A key marks an attempt ONLY when it carries a value: `null` is the
|
|
449
499
|
// readable spelling of "no value" - what typed-struct and dataclass serialisers
|
|
450
500
|
// emit for an unset optional field - so a null slot is no decision and falls
|
|
451
|
-
// through exactly as the absent key does.
|
|
452
|
-
// both halves.)
|
|
501
|
+
// through exactly as the absent key does.
|
|
453
502
|
const permAttempted =
|
|
454
503
|
hso !== null &&
|
|
455
504
|
Object.prototype.hasOwnProperty.call(hso, 'permissionDecision') &&
|
package/src/core/logger.js
CHANGED
|
@@ -16,10 +16,10 @@
|
|
|
16
16
|
* swallowed silently.
|
|
17
17
|
*
|
|
18
18
|
* Paths are resolved against the config home, and the resolved path MUST stay inside
|
|
19
|
-
* <home>/logs
|
|
19
|
+
* <home>/logs/. The log path is
|
|
20
20
|
* settable from the tf_log tool surface and the UI's POST /api/logs/config, and every
|
|
21
21
|
* other file-writing path in the repo is confined to its root (hook-loader's scripts-dir
|
|
22
|
-
* assert, registry.writeScript, sendStatic)
|
|
22
|
+
* assert, registry.writeScript, sendStatic), and so is this one. setConfig REFUSES an
|
|
23
23
|
* out-of-home path loudly; getConfig treats one already in the file as unset (default),
|
|
24
24
|
* so log() and tail() stay in-home even against a hand-edited config. An operator who
|
|
25
25
|
* wants the log on another disk can make <home>/logs a symlink/junction - containment is
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* Config file: <root>/logs/log.config.json
|
|
29
29
|
* { "enabled": false, "path": "logs/toolfunnel.log.jsonl" }
|
|
30
30
|
*
|
|
31
|
-
* Both paths are BOUNDED
|
|
31
|
+
* Both paths are BOUNDED: the live file rotates to `<path>.1`
|
|
32
32
|
* at MAX_LOG_BYTES (one older generation kept), and tail(n) reads a trailing slice rather
|
|
33
33
|
* than the whole file - with a whole-file fallback so bounding never changes an answer.
|
|
34
34
|
*
|
|
@@ -41,6 +41,7 @@ const path = require('node:path');
|
|
|
41
41
|
/** The CONFIG HOME (TOOLFUNNEL_HOME / --config-dir; defaults to the package root - see
|
|
42
42
|
* config-home.js). Logs + their toggle are user-state, so they live with the home. */
|
|
43
43
|
const { resolveConfigHome } = require('./config-home');
|
|
44
|
+
const { atomicWriteJson } = require('./atomic-write'); // THE atomic writer, shared by every store
|
|
44
45
|
const ROOT = resolveConfigHome();
|
|
45
46
|
|
|
46
47
|
/** The toggle/config file. NOT created until setConfig() writes it. */
|
|
@@ -190,10 +191,7 @@ function setConfig(patch) {
|
|
|
190
191
|
};
|
|
191
192
|
|
|
192
193
|
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
|
|
193
|
-
|
|
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);
|
|
194
|
+
atomicWriteJson(CONFIG_PATH, next); // temp + fsync + rename, failure-path unlink, stale sweep
|
|
197
195
|
|
|
198
196
|
return next;
|
|
199
197
|
}
|