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.
@@ -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
- fs.mkdirSync(destDir, { recursive: true });
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 = was complete)
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) === PKG_ROOT) return { home: PKG_ROOT, seeded };
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
- return { home, seeded };
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
  /**
@@ -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. (0.7.0; matcher-compile.test.js is the acceptance.)
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
- * Resolve the matcher function from matcher.js regardless of whether it is the default or
53
- * a named export, so we interlock with the contract's `matches(matcher, toolName)`.
54
- * @returns {(m: string|undefined, toolName: string) => boolean}
55
- */
56
- function getMatchesFn() {
57
- if (typeof matcher === 'function') return matcher;
58
- if (matcher && typeof matcher.matches === 'function') return matcher.matches;
59
- // Defensive fallback: if matcher.js is unavailable, fire everything (never crash).
60
- return () => true;
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 = getMatchesFn();
184
- this._matcherError = getMatcherErrorFn();
185
- this._buildPayload = getBuildPayloadFn();
186
- this._runHook = getRunHookFn();
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
  //
@@ -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. (0.7.0; matcher-compile.test.js.)
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
- * Atomically write a JS object as pretty JSON: write to a temp file in the SAME directory
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
@@ -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>/src/hooks. __dirname is <root>/src/core, so up one.
20
- const DEFAULT_HOOKS_DIR = path.resolve(__dirname, '..', 'hooks');
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 was clearly meant to be a protocol object (it opens
26
- // with '{') but does not parse. This is DISTINCT from null: null means "no protocol was attempted,
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 parse stdout as the JSON protocol. Returns the parsed object if it is
63
- * valid JSON AND carries known protocol keys; CORRUPT_PROTOCOL if stdout opened
64
- * with '{' (a protocol object was attempted) but did not parse; otherwise null
65
- * (no protocol attempt - use the exit-code protocol).
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
- // Cheap pre-check: protocol output is always a JSON object. Anything not opening with '{' made
74
- // no protocol attempt and belongs to the exit-code protocol (arbitrary text a hook prints).
75
- if (trimmed[0] !== '{') return null;
76
- let parsed;
77
- try {
78
- parsed = JSON.parse(trimmed);
79
- } catch (_e) {
80
- // Opened with '{' but is not valid JSON: an attempted decision that could not be read. Signal
81
- // CORRUPT (fail closed) rather than null - falling through to exit-code here would let a
82
- // truncated or malformed deny be read as "allow".
83
- return CORRUPT_PROTOCOL;
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
- return isKnownJsonProtocol(parsed) ? parsed : null;
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>/src/hooks).
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
- // Project root is two levels above the hooks dir (<root>/src/hooks -> <root>).
146
- path.resolve(hooksDir, '..', '..');
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
- // therefore made the timeout unenforceable in exactly the case it exists for: measured
248
- // 2026-07-30, a hook whose grandchild slept 60 s resolved at 60 s against a 2.5 s timeout, on
249
- // Windows, WITH taskkill /T. The tree kill still matters (it stops us leaking processes), but
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
- hrOutBytes += Buffer.byteLength(chunk, 'utf8');
313
- // Trim the final chunk to the byte budget - streams deliver whole chunks, so appending
314
- // before checking retained up to cap+chunkSize (seen: +64KB on win node 18/20, where
315
- // chunk boundaries do not align with the cap). Chars trimmed by a byte overshoot can
316
- // only under-retain (a char is >= 1 byte), which is the safe side of the ceiling.
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
- hrErrBytes += Buffer.byteLength(chunk, 'utf8');
331
- const errOver = hrErrBytes - MAX_STDERR_BYTES;
332
- stderr += errOver > 0 ? chunk.slice(0, Math.max(0, chunk.length - errOver)) : chunk;
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
- // A protocol object that FAILED TO PARSE (stdout opened with '{' but is not valid JSON) is an
424
- // unreadable decision. It fails closed - denied - the same as output past the cap: the runner
425
- // saw an attempted answer it could not read, and an unreadable answer is never "allow". Only
426
- // stdout that made no protocol attempt (no leading '{', or valid JSON that is not a decision)
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. (0.7.0; gate-semantics.test.js pins
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') &&
@@ -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/ (0.7.0; logging.test.js step 5 is the acceptance). The log path is
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) - this one now matches. setConfig REFUSES an
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 (0.7.0, log-rotation.test.js): the live file rotates to `<path>.1`
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
- // 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);
194
+ atomicWriteJson(CONFIG_PATH, next); // temp + fsync + rename, failure-path unlink, stale sweep
197
195
 
198
196
  return next;
199
197
  }