Files
ruflo/scripts/audit-hook-handler-prompt.mjs
rUvandReuven 3657a69366 fix(hooks): pre-bash TypeError + global-install MODULE_NOT_FOUND (#1944, #1943) (#1957)
Two critical hook regressions on a fresh `ruflo init` (alpha.27):

**#1944** — `helpers/hook-handler.cjs` builds the `prompt` string from a
`||` fallback chain whose third term is the normalised `toolInput` *object*
(`{command:"ls"}` etc.). The object is truthy so the chain stops there;
the next line calls `.toLowerCase()` on it and every `pre-bash` invocation
prints `[WARN] Hook pre-bash encountered an error: …toLowerCase is not a
function` — i.e. **every Bash tool call from Claude Code spams a TypeError**.

Fix: fall back to `toolInput.command` (the string) instead of `toolInput`
(the object). One-character change in three places — the deployed handler
(both tracked copies) and the template in `helpers-generator.ts` that
writes the user's `.claude/helpers/hook-handler.cjs` at init time.

Repro on the fixed file:
  echo '{"tool_input":{"command":"ls"},"tool_name":"Bash"}' \
    | node v3/@claude-flow/cli/.claude/helpers/hook-handler.cjs pre-bash
  → [OK] Command validated   (was: TypeError warning)

Dangerous-command guard still fires:
  echo '{"tool_input":{"command":"rm -rf /"},"tool_name":"Bash"}' | … pre-bash
  → [BLOCKED] Dangerous command detected: rm -rf /

**#1943** — `settings-generator.ts` writes hook command strings anchored at
`${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/…`. That works when `ruflo init`
lands helpers project-locally, but breaks when init's settings end up in
`~/.claude/settings.json` (global install) with helpers at
`~/.claude/helpers/`: Claude Code still sets `CLAUDE_PROJECT_DIR` to the
*project* root, the path resolves there, finds nothing, and every Bash /
Edit / Session hook fires `MODULE_NOT_FOUND`.

Fix: replace the path expression with a POSIX `sh` probe that tries the
project-local path first and falls back to `$HOME` when it's missing —
project-local overrides still take precedence; global installs work; no
upgrade-skew. Windows gets the same probe via `cmd /c IF EXIST … ELSE …`.
Applied to both `hookCmd()` and `generateStatusLineConfig()` (the
statusline scaffolding had the same anchor bug).

**CI guard**: `scripts/audit-hook-handler-prompt.mjs` (new) — fails the
build if any `.cjs` handler / TS template falls back to a bare `toolInput`
(object) instead of `toolInput.command` (string). Catches the #1944
pattern exactly. Wired into the existing `hook-command-audit` job.
Verified to flag a deliberately-reverted regression (and clean on HEAD).

Resolves #1944, #1943.

Co-authored-by: Reuven <cohen@ruv-mac-mini.local>
2026-05-13 09:02:51 -04:00

88 lines
3.4 KiB
JavaScript
Executable File

#!/usr/bin/env node
/**
* Hook-handler prompt-resolution audit — regression guard for #1944.
*
* Claude Code sends `pre-bash`/`pre-edit`/etc. hooks a JSON payload like
* `{"tool_input":{"command":"ls"},"tool_name":"Bash"}` on stdin. The hook
* handler in `helpers/hook-handler.cjs` (and its template in
* `helpers-generator.ts`) builds a single `prompt` string from a fallback
* chain. If it falls back to the **object** form — `hookInput.toolInput`
* or the locally-normalised `toolInput` — instead of `.command`, the prompt
* gets bound to an object and the very next call (`.toLowerCase()`,
* `.substring()`) throws on every Bash tool call.
*
* The fix is to fall back to `toolInput.command` (the actual string). This
* guard fails CI if the regression returns: any line that contains
* `|| <something>toolInput` (with no `.` after `toolInput`) inside a hook
* handler / generator source.
*
* Usage:
* node scripts/audit-hook-handler-prompt.mjs # exit 1 on any hit
* node scripts/audit-hook-handler-prompt.mjs --json # machine-readable report
*/
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
const REPO_ROOT = process.cwd();
const JSON_OUT = process.argv.includes('--json');
// Files we audit. The deployed/tracked `.cjs` files + the TS template that
// generates them at `ruflo init` time.
const TARGETS = [
'v3/@claude-flow/cli/.claude/helpers/hook-handler.cjs',
'.claude/helpers/hook-handler.cjs',
'v3/@claude-flow/cli/src/init/helpers-generator.ts',
];
// `||` followed by an identifier ending in `toolInput`, NOT followed by `.`
// (i.e. the object form, not the safe `toolInput.command` form). Catches:
// - `|| toolInput` (post-normalisation local form)
// - `|| hookInput.toolInput` (raw stdin form)
// - `|| (anything).toolInput` (defensively)
// Also catches `|| toolInput\s|$` to cover the multi-line wrap from the
// reporter's repro (`|| toolInput\n || process.env.PROMPT`).
const BAD = /\|\|\s*([A-Za-z_$][\w$]*\.)?toolInput\b(?!\.[A-Za-z_$])/g;
const offenders = [];
for (const rel of TARGETS) {
const p = join(REPO_ROOT, rel);
let src;
try {
statSync(p);
src = readFileSync(p, 'utf8');
} catch {
// Missing files are fine — they may not exist in every checkout.
continue;
}
let m;
BAD.lastIndex = 0;
while ((m = BAD.exec(src)) !== null) {
const line = src.slice(0, m.index).split('\n').length;
const lineText = src.split('\n')[line - 1]?.trim() ?? '';
offenders.push({ file: rel, line, match: m[0], context: lineText });
}
}
if (JSON_OUT) {
process.stdout.write(JSON.stringify({ offenders }, null, 2) + '\n');
process.exit(offenders.length === 0 ? 0 : 1);
}
console.log(`hook-handler prompt-resolution audit — guard for #1944`);
console.log(` scanned ${TARGETS.length} target(s)`);
if (offenders.length === 0) {
console.log(` ✓ no `+`'|| <…>toolInput' (object) fallbacks found`);
process.exit(0);
}
console.error(`\n ✗ ${offenders.length} offending fallback(s) — #1944 regression:`);
for (const o of offenders) {
console.error(` ${o.file}:${o.line}`);
console.error(` match: ${o.match}`);
console.error(` context: ${o.context}`);
}
console.error('\n Fix: replace `|| <…>toolInput` with `|| <…>toolInput.command` (or pull `.command` off whichever stdin shape Claude Code sent — `tool_input.command` / `toolInput.command`).');
process.exit(1);