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/hook-runner.js
CHANGED
|
@@ -1,346 +1,346 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* hook-runner.js
|
|
5
|
-
*
|
|
6
|
-
* Authoritative contract: docs/HOOK_ENGINE.md §3 (two output protocols) and §4
|
|
7
|
-
* (the exact result shape). This module is backend-agnostic and has ZERO host-framework
|
|
8
|
-
* imports so it runs headless under `node --test`.
|
|
9
|
-
*
|
|
10
|
-
* Hard guarantee (§4): runHook NEVER rejects. A spawn failure or a timeout always
|
|
11
|
-
* resolves with the full result object (exitCode:-1, timedOut set appropriately,
|
|
12
|
-
* blocked:false).
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
const { spawn, execFile } = require('node:child_process');
|
|
16
|
-
const path = require('node:path');
|
|
17
|
-
|
|
18
|
-
// Default HOOKS_DIR: <root>/src/hooks. __dirname is <root>/src/core, so up one.
|
|
19
|
-
const DEFAULT_HOOKS_DIR = path.resolve(__dirname, '..', 'hooks');
|
|
20
|
-
|
|
21
|
-
// Events whose exit-0 stdout is treated as injected context (§3-A, §1 table).
|
|
22
|
-
const INJECTABLE_EVENTS = new Set(['SessionStart', 'UserPromptSubmit']);
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Decide whether a parsed stdout object should be treated as the §3-B JSON
|
|
26
|
-
* protocol. We only switch to the JSON interpretation when the object actually
|
|
27
|
-
* carries one of the protocol's known keys
|
|
28
|
-
* hook happens to print should fall through to the exit-code protocol (§3:
|
|
29
|
-
* "Exit-code protocol wins if stdout is not valid JSON").
|
|
30
|
-
*
|
|
31
|
-
* @param {*} parsed
|
|
32
|
-
* @returns {boolean}
|
|
33
|
-
*/
|
|
34
|
-
function isKnownJsonProtocol(parsed) {
|
|
35
|
-
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
36
|
-
return false;
|
|
37
|
-
}
|
|
38
|
-
return (
|
|
39
|
-
Object.prototype.hasOwnProperty.call(parsed, 'continue') ||
|
|
40
|
-
Object.prototype.hasOwnProperty.call(parsed, 'decision') ||
|
|
41
|
-
Object.prototype.hasOwnProperty.call(parsed, 'reason') ||
|
|
42
|
-
Object.prototype.hasOwnProperty.call(parsed, 'hookSpecificOutput')
|
|
43
|
-
);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Try to parse stdout as the JSON protocol. Returns the parsed object only if it
|
|
48
|
-
* is valid JSON AND carries known protocol keys; otherwise null (use exit-code
|
|
49
|
-
* protocol).
|
|
50
|
-
*
|
|
51
|
-
* @param {string} stdout
|
|
52
|
-
* @returns {object|null}
|
|
53
|
-
*/
|
|
54
|
-
function tryParseJsonProtocol(stdout) {
|
|
55
|
-
const trimmed = (stdout || '').trim();
|
|
56
|
-
if (!trimmed) return null;
|
|
57
|
-
// Cheap pre-check: protocol output is always a JSON object.
|
|
58
|
-
if (trimmed[0] !== '{') return null;
|
|
59
|
-
let parsed;
|
|
60
|
-
try {
|
|
61
|
-
parsed = JSON.parse(trimmed);
|
|
62
|
-
} catch (_e) {
|
|
63
|
-
return null; // not valid JSON
|
|
64
|
-
}
|
|
65
|
-
return isKnownJsonProtocol(parsed) ? parsed : null;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Run a single command hook.
|
|
70
|
-
*
|
|
71
|
-
* @param {object} hookSpec Resolved spec: { id, event, command, timeout(seconds), ... }.
|
|
72
|
-
* @param {object} payload The stdin JSON object (built by events.js buildPayload).
|
|
73
|
-
* @param {object} [opts]
|
|
74
|
-
* @param {string} [opts.cwd] child working directory.
|
|
75
|
-
* @param {object} [opts.env] extra env merged over process.env.
|
|
76
|
-
* @param {string} [opts.projectDir] value for CLAUDE_PROJECT_DIR.
|
|
77
|
-
* @param {string} [opts.hooksDir] value for HOOKS_DIR (default <root>/src/hooks).
|
|
78
|
-
* @param {AbortSignal} [opts.signal] external cancellation; aborts the child.
|
|
79
|
-
* @returns {Promise<object>} the §4 result shape. Never rejects.
|
|
80
|
-
*/
|
|
81
|
-
function runHook(hookSpec, payload, opts = {}) {
|
|
82
|
-
return new Promise((resolve) => {
|
|
83
|
-
const started = Date.now();
|
|
84
|
-
|
|
85
|
-
const id = hookSpec && hookSpec.id != null ? hookSpec.id : null;
|
|
86
|
-
const event = hookSpec && hookSpec.event != null ? hookSpec.event : null;
|
|
87
|
-
|
|
88
|
-
// Build the immutable base of every result so any early exit is well-formed.
|
|
89
|
-
const baseResult = () => ({
|
|
90
|
-
id,
|
|
91
|
-
event,
|
|
92
|
-
exitCode: -1,
|
|
93
|
-
timedOut: false,
|
|
94
|
-
stdout: '',
|
|
95
|
-
stderr: '',
|
|
96
|
-
blocked: false,
|
|
97
|
-
stopLoop: false,
|
|
98
|
-
reason: null,
|
|
99
|
-
inject: null,
|
|
100
|
-
durationMs: 0,
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
// Guard: a malformed spec must not throw out of the runner (§4: never reject).
|
|
104
|
-
if (!hookSpec || typeof hookSpec.command !== 'string' || hookSpec.command.length === 0) {
|
|
105
|
-
const r = baseResult();
|
|
106
|
-
r.stderr = 'hook-runner: invalid hookSpec (missing command)';
|
|
107
|
-
r.durationMs = Date.now() - started;
|
|
108
|
-
resolve(r);
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// timeout is stored in SECONDS in the manifest
|
|
113
|
-
// Fall back to a sane default if absent or non-positive.
|
|
114
|
-
const timeoutSec =
|
|
115
|
-
typeof hookSpec.timeout === 'number' && hookSpec.timeout > 0 ? hookSpec.timeout : 60;
|
|
116
|
-
const timeoutMs = timeoutSec * 1000;
|
|
117
|
-
|
|
118
|
-
// Child env: inherit process.env, layer opts.env, then set the two paths the
|
|
119
|
-
// hook scripts rely on to resolve themselves (§4).
|
|
120
|
-
const hooksDir = opts.hooksDir || (opts.env && opts.env.HOOKS_DIR) || DEFAULT_HOOKS_DIR;
|
|
121
|
-
const projectDir =
|
|
122
|
-
opts.projectDir ||
|
|
123
|
-
(opts.env && opts.env.CLAUDE_PROJECT_DIR) ||
|
|
124
|
-
process.env.CLAUDE_PROJECT_DIR ||
|
|
125
|
-
// Project root is two levels above the hooks dir (<root>/src/hooks
|
|
126
|
-
path.resolve(hooksDir, '..', '..');
|
|
127
|
-
|
|
128
|
-
const childEnv = Object.assign({}, process.env, opts.env || {}, {
|
|
129
|
-
CLAUDE_PROJECT_DIR: projectDir,
|
|
130
|
-
HOOKS_DIR: hooksDir,
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
// Own AbortController so we can enforce the timeout via kill, and chain any
|
|
134
|
-
// externally-supplied signal so the engine can cancel us too.
|
|
135
|
-
const controller = new AbortController();
|
|
136
|
-
const onExternalAbort = () => killChildTree();
|
|
137
|
-
if (opts.signal) {
|
|
138
|
-
if (opts.signal.aborted) controller.abort();
|
|
139
|
-
else opts.signal.addEventListener('abort', onExternalAbort, { once: true });
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
let child;
|
|
143
|
-
try {
|
|
144
|
-
child = spawn(hookSpec.command, {
|
|
145
|
-
shell: true, // run as a shell command line (bash/PowerShell-launched scripts)
|
|
146
|
-
cwd: opts.cwd || undefined,
|
|
147
|
-
env: childEnv,
|
|
148
|
-
signal: controller.signal,
|
|
149
|
-
windowsHide: true,
|
|
150
|
-
});
|
|
151
|
-
} catch (err) {
|
|
152
|
-
// Synchronous spawn failure (rare). Resolve, never throw.
|
|
153
|
-
cleanupSignal();
|
|
154
|
-
const r = baseResult();
|
|
155
|
-
r.stderr = `hook-runner: spawn failed: ${err && err.message ? err.message : String(err)}`;
|
|
156
|
-
r.durationMs = Date.now() - started;
|
|
157
|
-
resolve(r);
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
let stdout = '';
|
|
162
|
-
let stderr = '';
|
|
163
|
-
let timedOut = false;
|
|
164
|
-
let settled = false;
|
|
165
|
-
|
|
166
|
-
// Kill the WHOLE child tree. On Windows, killing the shell (cmd.exe) does NOT
|
|
167
|
-
// kill its node/bash grandchildren
|
|
168
|
-
// which prevents the parent process (and node:test) from ever exiting. taskkill
|
|
169
|
-
// /T tears down the tree so the pipes close and nothing lingers. (Real hooks
|
|
170
|
-
// that hang would orphan the same way, so this matters beyond the test suite.)
|
|
171
|
-
function killChildTree() {
|
|
172
|
-
try {
|
|
173
|
-
if (child && child.pid != null && process.platform === 'win32') {
|
|
174
|
-
execFile('taskkill', ['/pid', String(child.pid), '/T', '/F'], () => {});
|
|
175
|
-
} else if (child) {
|
|
176
|
-
child.kill('SIGKILL');
|
|
177
|
-
}
|
|
178
|
-
} catch (_e) {
|
|
179
|
-
try {
|
|
180
|
-
if (child) child.kill();
|
|
181
|
-
} catch (_e2) {
|
|
182
|
-
/* nothing more we can do */
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
try {
|
|
186
|
-
controller.abort();
|
|
187
|
-
} catch (_e) {
|
|
188
|
-
/* abort backstop */
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// Hard timeout: kill the child tree; the 'close'/'error' handler resolves.
|
|
193
|
-
const timer = setTimeout(() => {
|
|
194
|
-
timedOut = true;
|
|
195
|
-
killChildTree();
|
|
196
|
-
}, timeoutMs);
|
|
197
|
-
if (typeof timer.unref === 'function') timer.unref();
|
|
198
|
-
|
|
199
|
-
function cleanupSignal() {
|
|
200
|
-
if (opts.signal) opts.signal.removeEventListener('abort', onExternalAbort);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function finish(result) {
|
|
204
|
-
if (settled) return;
|
|
205
|
-
settled = true;
|
|
206
|
-
clearTimeout(timer);
|
|
207
|
-
cleanupSignal();
|
|
208
|
-
result.durationMs = Date.now() - started;
|
|
209
|
-
resolve(result);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
if (child.stdout) {
|
|
213
|
-
child.stdout.setEncoding('utf8');
|
|
214
|
-
child.stdout.on('data', (chunk) => {
|
|
215
|
-
stdout += chunk;
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
if (child.stderr) {
|
|
219
|
-
child.stderr.setEncoding('utf8');
|
|
220
|
-
child.stderr.on('data', (chunk) => {
|
|
221
|
-
stderr += chunk;
|
|
222
|
-
});
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// Write the payload to the child's stdin, then close it. Guard against EPIPE
|
|
226
|
-
// (child may exit before reading)
|
|
227
|
-
if (child.stdin) {
|
|
228
|
-
child.stdin.on('error', () => {
|
|
229
|
-
/* swallow EPIPE / write-after-end; the exit code is what matters */
|
|
230
|
-
});
|
|
231
|
-
try {
|
|
232
|
-
child.stdin.write(JSON.stringify(payload == null ? {} : payload));
|
|
233
|
-
child.stdin.end();
|
|
234
|
-
} catch (_e) {
|
|
235
|
-
// ignore
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Spawn / runtime error (ENOENT, abort, etc.). If it's the timeout abort we
|
|
240
|
-
// report timedOut; otherwise a non-blocking error with exitCode -1.
|
|
241
|
-
child.on('error', (err) => {
|
|
242
|
-
const r = baseResult();
|
|
243
|
-
r.stdout = stdout;
|
|
244
|
-
r.stderr = stderr || (err && err.message ? err.message : String(err));
|
|
245
|
-
if (timedOut) {
|
|
246
|
-
r.timedOut = true;
|
|
247
|
-
r.stderr = stderr || `hook-runner: timed out after ${timeoutMs}ms`;
|
|
248
|
-
}
|
|
249
|
-
// blocked stays false; exitCode stays -1 (§4).
|
|
250
|
-
finish(r);
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
child.on('close', (code, sig) => {
|
|
254
|
-
// If we aborted for the timeout, classify as timeout regardless of code.
|
|
255
|
-
if (timedOut) {
|
|
256
|
-
const r = baseResult();
|
|
257
|
-
r.stdout = stdout;
|
|
258
|
-
r.stderr = stderr || `hook-runner: timed out after ${timeoutMs}ms`;
|
|
259
|
-
r.timedOut = true; // exitCode -1, blocked false (§4)
|
|
260
|
-
finish(r);
|
|
261
|
-
return;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
const r = baseResult();
|
|
265
|
-
r.stdout = stdout;
|
|
266
|
-
r.stderr = stderr;
|
|
267
|
-
// On a clean close, code is a number; if killed by signal, code is null.
|
|
268
|
-
r.exitCode = typeof code === 'number' ? code : -1;
|
|
269
|
-
|
|
270
|
-
// --- Protocol interpretation (§3) ---
|
|
271
|
-
// Exit 2 is ALWAYS stderr-blocking, and stdout/JSON is ignored on any
|
|
272
|
-
// non-zero exit
|
|
273
|
-
// processed ONLY on a clean exit 0; on exit 2 the reason is stderr).
|
|
274
|
-
if (r.exitCode === 2) {
|
|
275
|
-
r.blocked = true;
|
|
276
|
-
r.reason = stderr != null && stderr.trim().length > 0 ? stderr.trim() : '';
|
|
277
|
-
finish(r);
|
|
278
|
-
return;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// B) Advanced / JSON protocol (§3-B)
|
|
282
|
-
const json = r.exitCode === 0 ? tryParseJsonProtocol(stdout) : null;
|
|
283
|
-
|
|
284
|
-
if (json) {
|
|
285
|
-
const hso =
|
|
286
|
-
json.hookSpecificOutput && typeof json.hookSpecificOutput === 'object'
|
|
287
|
-
? json.hookSpecificOutput
|
|
288
|
-
: null;
|
|
289
|
-
|
|
290
|
-
// PreToolUse blocks via hookSpecificOutput.permissionDecision
|
|
291
|
-
// (allow|deny|ask), NOT top-level decision
|
|
292
|
-
// mechanism. "deny" blocks (reason = permissionDecisionReason). "allow"
|
|
293
|
-
// passes. "ask" has no interactive prompt in the autonomous host, so it
|
|
294
|
-
// is treated as non-blocking with the reason captured for context.
|
|
295
|
-
const permDecision =
|
|
296
|
-
hso && typeof hso.permissionDecision === 'string' ? hso.permissionDecision : null;
|
|
297
|
-
const permReason =
|
|
298
|
-
hso && typeof hso.permissionDecisionReason === 'string'
|
|
299
|
-
? hso.permissionDecisionReason
|
|
300
|
-
: null;
|
|
301
|
-
|
|
302
|
-
// Top-level decision: real Claude Code defines only "block" here (for
|
|
303
|
-
// UserPromptSubmit / PostToolUse / Stop / PreCompact). There is no "approve".
|
|
304
|
-
const decision = json.decision;
|
|
305
|
-
const cont = json.continue;
|
|
306
|
-
const jsonReason = typeof json.reason === 'string' ? json.reason : null;
|
|
307
|
-
const additional =
|
|
308
|
-
hso && typeof hso.additionalContext === 'string' ? hso.additionalContext : null;
|
|
309
|
-
|
|
310
|
-
if (permDecision === 'deny') {
|
|
311
|
-
r.blocked = true;
|
|
312
|
-
r.reason = permReason != null ? permReason : jsonReason;
|
|
313
|
-
if (r.reason == null) r.reason = '';
|
|
314
|
-
} else if (decision === 'block') {
|
|
315
|
-
r.blocked = true;
|
|
316
|
-
r.reason = jsonReason; // block reason from JSON.reason (§4)
|
|
317
|
-
}
|
|
318
|
-
if (cont === false) {
|
|
319
|
-
r.stopLoop = true; // continue:false
|
|
320
|
-
}
|
|
321
|
-
// additionalContext is the injected text when present (§3-B, §4).
|
|
322
|
-
if (additional !== null) {
|
|
323
|
-
r.inject = additional;
|
|
324
|
-
}
|
|
325
|
-
finish(r);
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// A) Simple / exit-code protocol (§3-A), exit 0.
|
|
330
|
-
if (r.exitCode === 0) {
|
|
331
|
-
// Success. For SessionStart / UserPromptSubmit, stdout IS the injection.
|
|
332
|
-
// For other events stdout is advisory only (captured, not injected).
|
|
333
|
-
if (INJECTABLE_EVENTS.has(event)) {
|
|
334
|
-
const out = stdout != null ? stdout.trim() : '';
|
|
335
|
-
r.inject = out.length > 0 ? out : null;
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
// Any other non-zero (non-2) code
|
|
339
|
-
// blocked stays false, reason stays null (§3-A).
|
|
340
|
-
|
|
341
|
-
finish(r);
|
|
342
|
-
});
|
|
343
|
-
});
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
module.exports = { runHook };
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* hook-runner.js - run ONE command hook and return a structured result.
|
|
5
|
+
*
|
|
6
|
+
* Authoritative contract: docs/HOOK_ENGINE.md §3 (two output protocols) and §4
|
|
7
|
+
* (the exact result shape). This module is backend-agnostic and has ZERO host-framework
|
|
8
|
+
* imports so it runs headless under `node --test`.
|
|
9
|
+
*
|
|
10
|
+
* Hard guarantee (§4): runHook NEVER rejects. A spawn failure or a timeout always
|
|
11
|
+
* resolves with the full result object (exitCode:-1, timedOut set appropriately,
|
|
12
|
+
* blocked:false).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { spawn, execFile } = require('node:child_process');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
|
|
18
|
+
// Default HOOKS_DIR: <root>/src/hooks. __dirname is <root>/src/core, so up one.
|
|
19
|
+
const DEFAULT_HOOKS_DIR = path.resolve(__dirname, '..', 'hooks');
|
|
20
|
+
|
|
21
|
+
// Events whose exit-0 stdout is treated as injected context (§3-A, §1 table).
|
|
22
|
+
const INJECTABLE_EVENTS = new Set(['SessionStart', 'UserPromptSubmit']);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Decide whether a parsed stdout object should be treated as the §3-B JSON
|
|
26
|
+
* protocol. We only switch to the JSON interpretation when the object actually
|
|
27
|
+
* carries one of the protocol's known keys - otherwise an arbitrary JSON blob a
|
|
28
|
+
* hook happens to print should fall through to the exit-code protocol (§3:
|
|
29
|
+
* "Exit-code protocol wins if stdout is not valid JSON").
|
|
30
|
+
*
|
|
31
|
+
* @param {*} parsed
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
function isKnownJsonProtocol(parsed) {
|
|
35
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
return (
|
|
39
|
+
Object.prototype.hasOwnProperty.call(parsed, 'continue') ||
|
|
40
|
+
Object.prototype.hasOwnProperty.call(parsed, 'decision') ||
|
|
41
|
+
Object.prototype.hasOwnProperty.call(parsed, 'reason') ||
|
|
42
|
+
Object.prototype.hasOwnProperty.call(parsed, 'hookSpecificOutput')
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Try to parse stdout as the JSON protocol. Returns the parsed object only if it
|
|
48
|
+
* is valid JSON AND carries known protocol keys; otherwise null (use exit-code
|
|
49
|
+
* protocol).
|
|
50
|
+
*
|
|
51
|
+
* @param {string} stdout
|
|
52
|
+
* @returns {object|null}
|
|
53
|
+
*/
|
|
54
|
+
function tryParseJsonProtocol(stdout) {
|
|
55
|
+
const trimmed = (stdout || '').trim();
|
|
56
|
+
if (!trimmed) return null;
|
|
57
|
+
// Cheap pre-check: protocol output is always a JSON object.
|
|
58
|
+
if (trimmed[0] !== '{') return null;
|
|
59
|
+
let parsed;
|
|
60
|
+
try {
|
|
61
|
+
parsed = JSON.parse(trimmed);
|
|
62
|
+
} catch (_e) {
|
|
63
|
+
return null; // not valid JSON -> exit-code protocol wins
|
|
64
|
+
}
|
|
65
|
+
return isKnownJsonProtocol(parsed) ? parsed : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Run a single command hook.
|
|
70
|
+
*
|
|
71
|
+
* @param {object} hookSpec Resolved spec: { id, event, command, timeout(seconds), ... }.
|
|
72
|
+
* @param {object} payload The stdin JSON object (built by events.js buildPayload).
|
|
73
|
+
* @param {object} [opts]
|
|
74
|
+
* @param {string} [opts.cwd] child working directory.
|
|
75
|
+
* @param {object} [opts.env] extra env merged over process.env.
|
|
76
|
+
* @param {string} [opts.projectDir] value for CLAUDE_PROJECT_DIR.
|
|
77
|
+
* @param {string} [opts.hooksDir] value for HOOKS_DIR (default <root>/src/hooks).
|
|
78
|
+
* @param {AbortSignal} [opts.signal] external cancellation; aborts the child.
|
|
79
|
+
* @returns {Promise<object>} the §4 result shape. Never rejects.
|
|
80
|
+
*/
|
|
81
|
+
function runHook(hookSpec, payload, opts = {}) {
|
|
82
|
+
return new Promise((resolve) => {
|
|
83
|
+
const started = Date.now();
|
|
84
|
+
|
|
85
|
+
const id = hookSpec && hookSpec.id != null ? hookSpec.id : null;
|
|
86
|
+
const event = hookSpec && hookSpec.event != null ? hookSpec.event : null;
|
|
87
|
+
|
|
88
|
+
// Build the immutable base of every result so any early exit is well-formed.
|
|
89
|
+
const baseResult = () => ({
|
|
90
|
+
id,
|
|
91
|
+
event,
|
|
92
|
+
exitCode: -1,
|
|
93
|
+
timedOut: false,
|
|
94
|
+
stdout: '',
|
|
95
|
+
stderr: '',
|
|
96
|
+
blocked: false,
|
|
97
|
+
stopLoop: false,
|
|
98
|
+
reason: null,
|
|
99
|
+
inject: null,
|
|
100
|
+
durationMs: 0,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Guard: a malformed spec must not throw out of the runner (§4: never reject).
|
|
104
|
+
if (!hookSpec || typeof hookSpec.command !== 'string' || hookSpec.command.length === 0) {
|
|
105
|
+
const r = baseResult();
|
|
106
|
+
r.stderr = 'hook-runner: invalid hookSpec (missing command)';
|
|
107
|
+
r.durationMs = Date.now() - started;
|
|
108
|
+
resolve(r);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// timeout is stored in SECONDS in the manifest -> convert to ms (§4).
|
|
113
|
+
// Fall back to a sane default if absent or non-positive.
|
|
114
|
+
const timeoutSec =
|
|
115
|
+
typeof hookSpec.timeout === 'number' && hookSpec.timeout > 0 ? hookSpec.timeout : 60;
|
|
116
|
+
const timeoutMs = timeoutSec * 1000;
|
|
117
|
+
|
|
118
|
+
// Child env: inherit process.env, layer opts.env, then set the two paths the
|
|
119
|
+
// hook scripts rely on to resolve themselves (§4).
|
|
120
|
+
const hooksDir = opts.hooksDir || (opts.env && opts.env.HOOKS_DIR) || DEFAULT_HOOKS_DIR;
|
|
121
|
+
const projectDir =
|
|
122
|
+
opts.projectDir ||
|
|
123
|
+
(opts.env && opts.env.CLAUDE_PROJECT_DIR) ||
|
|
124
|
+
process.env.CLAUDE_PROJECT_DIR ||
|
|
125
|
+
// Project root is two levels above the hooks dir (<root>/src/hooks -> <root>).
|
|
126
|
+
path.resolve(hooksDir, '..', '..');
|
|
127
|
+
|
|
128
|
+
const childEnv = Object.assign({}, process.env, opts.env || {}, {
|
|
129
|
+
CLAUDE_PROJECT_DIR: projectDir,
|
|
130
|
+
HOOKS_DIR: hooksDir,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Own AbortController so we can enforce the timeout via kill, and chain any
|
|
134
|
+
// externally-supplied signal so the engine can cancel us too.
|
|
135
|
+
const controller = new AbortController();
|
|
136
|
+
const onExternalAbort = () => killChildTree();
|
|
137
|
+
if (opts.signal) {
|
|
138
|
+
if (opts.signal.aborted) controller.abort();
|
|
139
|
+
else opts.signal.addEventListener('abort', onExternalAbort, { once: true });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let child;
|
|
143
|
+
try {
|
|
144
|
+
child = spawn(hookSpec.command, {
|
|
145
|
+
shell: true, // run as a shell command line (bash/PowerShell-launched scripts)
|
|
146
|
+
cwd: opts.cwd || undefined,
|
|
147
|
+
env: childEnv,
|
|
148
|
+
signal: controller.signal,
|
|
149
|
+
windowsHide: true,
|
|
150
|
+
});
|
|
151
|
+
} catch (err) {
|
|
152
|
+
// Synchronous spawn failure (rare). Resolve, never throw.
|
|
153
|
+
cleanupSignal();
|
|
154
|
+
const r = baseResult();
|
|
155
|
+
r.stderr = `hook-runner: spawn failed: ${err && err.message ? err.message : String(err)}`;
|
|
156
|
+
r.durationMs = Date.now() - started;
|
|
157
|
+
resolve(r);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let stdout = '';
|
|
162
|
+
let stderr = '';
|
|
163
|
+
let timedOut = false;
|
|
164
|
+
let settled = false;
|
|
165
|
+
|
|
166
|
+
// Kill the WHOLE child tree. On Windows, killing the shell (cmd.exe) does NOT
|
|
167
|
+
// kill its node/bash grandchildren - they orphan and hold the stdio pipes open,
|
|
168
|
+
// which prevents the parent process (and node:test) from ever exiting. taskkill
|
|
169
|
+
// /T tears down the tree so the pipes close and nothing lingers. (Real hooks
|
|
170
|
+
// that hang would orphan the same way, so this matters beyond the test suite.)
|
|
171
|
+
function killChildTree() {
|
|
172
|
+
try {
|
|
173
|
+
if (child && child.pid != null && process.platform === 'win32') {
|
|
174
|
+
execFile('taskkill', ['/pid', String(child.pid), '/T', '/F'], () => {});
|
|
175
|
+
} else if (child) {
|
|
176
|
+
child.kill('SIGKILL');
|
|
177
|
+
}
|
|
178
|
+
} catch (_e) {
|
|
179
|
+
try {
|
|
180
|
+
if (child) child.kill();
|
|
181
|
+
} catch (_e2) {
|
|
182
|
+
/* nothing more we can do */
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
controller.abort();
|
|
187
|
+
} catch (_e) {
|
|
188
|
+
/* abort backstop */
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Hard timeout: kill the child tree; the 'close'/'error' handler resolves.
|
|
193
|
+
const timer = setTimeout(() => {
|
|
194
|
+
timedOut = true;
|
|
195
|
+
killChildTree();
|
|
196
|
+
}, timeoutMs);
|
|
197
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
198
|
+
|
|
199
|
+
function cleanupSignal() {
|
|
200
|
+
if (opts.signal) opts.signal.removeEventListener('abort', onExternalAbort);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function finish(result) {
|
|
204
|
+
if (settled) return;
|
|
205
|
+
settled = true;
|
|
206
|
+
clearTimeout(timer);
|
|
207
|
+
cleanupSignal();
|
|
208
|
+
result.durationMs = Date.now() - started;
|
|
209
|
+
resolve(result);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (child.stdout) {
|
|
213
|
+
child.stdout.setEncoding('utf8');
|
|
214
|
+
child.stdout.on('data', (chunk) => {
|
|
215
|
+
stdout += chunk;
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
if (child.stderr) {
|
|
219
|
+
child.stderr.setEncoding('utf8');
|
|
220
|
+
child.stderr.on('data', (chunk) => {
|
|
221
|
+
stderr += chunk;
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Write the payload to the child's stdin, then close it. Guard against EPIPE
|
|
226
|
+
// (child may exit before reading) - that must not throw the runner.
|
|
227
|
+
if (child.stdin) {
|
|
228
|
+
child.stdin.on('error', () => {
|
|
229
|
+
/* swallow EPIPE / write-after-end; the exit code is what matters */
|
|
230
|
+
});
|
|
231
|
+
try {
|
|
232
|
+
child.stdin.write(JSON.stringify(payload == null ? {} : payload));
|
|
233
|
+
child.stdin.end();
|
|
234
|
+
} catch (_e) {
|
|
235
|
+
// ignore - stdin write race; child outcome still resolves below
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Spawn / runtime error (ENOENT, abort, etc.). If it's the timeout abort we
|
|
240
|
+
// report timedOut; otherwise a non-blocking error with exitCode -1.
|
|
241
|
+
child.on('error', (err) => {
|
|
242
|
+
const r = baseResult();
|
|
243
|
+
r.stdout = stdout;
|
|
244
|
+
r.stderr = stderr || (err && err.message ? err.message : String(err));
|
|
245
|
+
if (timedOut) {
|
|
246
|
+
r.timedOut = true;
|
|
247
|
+
r.stderr = stderr || `hook-runner: timed out after ${timeoutMs}ms`;
|
|
248
|
+
}
|
|
249
|
+
// blocked stays false; exitCode stays -1 (§4).
|
|
250
|
+
finish(r);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
child.on('close', (code, sig) => {
|
|
254
|
+
// If we aborted for the timeout, classify as timeout regardless of code.
|
|
255
|
+
if (timedOut) {
|
|
256
|
+
const r = baseResult();
|
|
257
|
+
r.stdout = stdout;
|
|
258
|
+
r.stderr = stderr || `hook-runner: timed out after ${timeoutMs}ms`;
|
|
259
|
+
r.timedOut = true; // exitCode -1, blocked false (§4)
|
|
260
|
+
finish(r);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const r = baseResult();
|
|
265
|
+
r.stdout = stdout;
|
|
266
|
+
r.stderr = stderr;
|
|
267
|
+
// On a clean close, code is a number; if killed by signal, code is null.
|
|
268
|
+
r.exitCode = typeof code === 'number' ? code : -1;
|
|
269
|
+
|
|
270
|
+
// --- Protocol interpretation (§3) ---
|
|
271
|
+
// Exit 2 is ALWAYS stderr-blocking, and stdout/JSON is ignored on any
|
|
272
|
+
// non-zero exit - this matches real Claude Code (the JSON protocol is
|
|
273
|
+
// processed ONLY on a clean exit 0; on exit 2 the reason is stderr).
|
|
274
|
+
if (r.exitCode === 2) {
|
|
275
|
+
r.blocked = true;
|
|
276
|
+
r.reason = stderr != null && stderr.trim().length > 0 ? stderr.trim() : '';
|
|
277
|
+
finish(r);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// B) Advanced / JSON protocol (§3-B) - honored only on exit 0.
|
|
282
|
+
const json = r.exitCode === 0 ? tryParseJsonProtocol(stdout) : null;
|
|
283
|
+
|
|
284
|
+
if (json) {
|
|
285
|
+
const hso =
|
|
286
|
+
json.hookSpecificOutput && typeof json.hookSpecificOutput === 'object'
|
|
287
|
+
? json.hookSpecificOutput
|
|
288
|
+
: null;
|
|
289
|
+
|
|
290
|
+
// PreToolUse blocks via hookSpecificOutput.permissionDecision
|
|
291
|
+
// (allow|deny|ask), NOT top-level decision - this is the real Claude Code
|
|
292
|
+
// mechanism. "deny" blocks (reason = permissionDecisionReason). "allow"
|
|
293
|
+
// passes. "ask" has no interactive prompt in the autonomous host, so it
|
|
294
|
+
// is treated as non-blocking with the reason captured for context.
|
|
295
|
+
const permDecision =
|
|
296
|
+
hso && typeof hso.permissionDecision === 'string' ? hso.permissionDecision : null;
|
|
297
|
+
const permReason =
|
|
298
|
+
hso && typeof hso.permissionDecisionReason === 'string'
|
|
299
|
+
? hso.permissionDecisionReason
|
|
300
|
+
: null;
|
|
301
|
+
|
|
302
|
+
// Top-level decision: real Claude Code defines only "block" here (for
|
|
303
|
+
// UserPromptSubmit / PostToolUse / Stop / PreCompact). There is no "approve".
|
|
304
|
+
const decision = json.decision;
|
|
305
|
+
const cont = json.continue;
|
|
306
|
+
const jsonReason = typeof json.reason === 'string' ? json.reason : null;
|
|
307
|
+
const additional =
|
|
308
|
+
hso && typeof hso.additionalContext === 'string' ? hso.additionalContext : null;
|
|
309
|
+
|
|
310
|
+
if (permDecision === 'deny') {
|
|
311
|
+
r.blocked = true;
|
|
312
|
+
r.reason = permReason != null ? permReason : jsonReason;
|
|
313
|
+
if (r.reason == null) r.reason = '';
|
|
314
|
+
} else if (decision === 'block') {
|
|
315
|
+
r.blocked = true;
|
|
316
|
+
r.reason = jsonReason; // block reason from JSON.reason (§4)
|
|
317
|
+
}
|
|
318
|
+
if (cont === false) {
|
|
319
|
+
r.stopLoop = true; // continue:false -> stop the whole loop (§3-B, §4)
|
|
320
|
+
}
|
|
321
|
+
// additionalContext is the injected text when present (§3-B, §4).
|
|
322
|
+
if (additional !== null) {
|
|
323
|
+
r.inject = additional;
|
|
324
|
+
}
|
|
325
|
+
finish(r);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// A) Simple / exit-code protocol (§3-A), exit 0.
|
|
330
|
+
if (r.exitCode === 0) {
|
|
331
|
+
// Success. For SessionStart / UserPromptSubmit, stdout IS the injection.
|
|
332
|
+
// For other events stdout is advisory only (captured, not injected).
|
|
333
|
+
if (INJECTABLE_EVENTS.has(event)) {
|
|
334
|
+
const out = stdout != null ? stdout.trim() : '';
|
|
335
|
+
r.inject = out.length > 0 ? out : null;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
// Any other non-zero (non-2) code -> non-blocking error: stderr captured,
|
|
339
|
+
// blocked stays false, reason stays null (§3-A).
|
|
340
|
+
|
|
341
|
+
finish(r);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
module.exports = { runHook };
|