From 74afefb553706d03da072d90e25b4d4c43929cde Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 14 Aug 2026 23:39:13 +0800 Subject: [PATCH] fix(hooks): compare passthrough output as bytes --- scripts/hooks/plugin-hook-bootstrap.js | 48 ++++++++++++------- .../plugin-hook-bootstrap-no-echo.test.js | 42 +++++++++++++++- 2 files changed, 71 insertions(+), 19 deletions(-) diff --git a/scripts/hooks/plugin-hook-bootstrap.js b/scripts/hooks/plugin-hook-bootstrap.js index 057b7365b..627afeeca 100644 --- a/scripts/hooks/plugin-hook-bootstrap.js +++ b/scripts/hooks/plugin-hook-bootstrap.js @@ -18,26 +18,37 @@ function readStdinRaw() { } function writeStderr(stderr) { - if (typeof stderr === 'string' && stderr.length > 0) { + if ((typeof stderr === 'string' || Buffer.isBuffer(stderr)) && stderr.length > 0) { process.stderr.write(stderr); } } +function toBuffer(value) { + if (Buffer.isBuffer(value)) return value; + return typeof value === 'string' ? Buffer.from(value, 'utf8') : Buffer.alloc(0); +} + function withComparisonInput(result, comparisonInput) { return { ...result, comparisonInput }; } function isRawPassthrough(raw, stdout) { - if (!raw || !stdout) return false; + const rawBytes = toBuffer(raw); + const stdoutBytes = toBuffer(stdout); + if (rawBytes.length === 0 || stdoutBytes.length === 0) return false; return ( - stdout === raw || - (Buffer.byteLength(stdout, 'utf8') === STDOUT_PIPE_CAP_BYTES && raw.startsWith(stdout)) + stdoutBytes.equals(rawBytes) || + (stdoutBytes.length === STDOUT_PIPE_CAP_BYTES && + rawBytes.subarray(0, stdoutBytes.length).equals(stdoutBytes)) ); } function passthrough(result) { - const stdout = typeof result?.stdout === 'string' ? result.stdout : ''; - if (stdout) { + const stdout = + typeof result?.stdout === 'string' || Buffer.isBuffer(result?.stdout) + ? result.stdout + : Buffer.alloc(0); + if (stdout.length > 0) { // Most ECC hook scripts follow a `run(rawInput) -> rawInput` passthrough // pattern: they do their work, then return the original input so the hook // chain's tool result is preserved. The harness then writes the verbatim @@ -52,7 +63,7 @@ function passthrough(result) { // ~64 KB Node.js pipe buffer and get truncated -- stdout is then exactly // 65536 bytes and a strict prefix of raw. So we also detect that // truncation sentinel. - const raw = typeof result?.comparisonInput === 'string' ? result.comparisonInput : ''; + const raw = result?.comparisonInput; const looksLikePassthrough = isRawPassthrough(raw, stdout); if (looksLikePassthrough) { writeStderr( @@ -183,13 +194,12 @@ function spawnNode(rootDir, relPath, raw, args) { }; const result = spawnSync(process.execPath, [resolveTarget(rootDir, relPath), ...args], { input: raw, - encoding: 'utf8', env: hookEnv, cwd: process.cwd(), timeout: 30000, windowsHide: true, }); - return withComparisonInput(result, raw); + return withComparisonInput(result, Buffer.from(raw, 'utf8')); } // spawnShell is not used by any hook in the shipped hooks.json configuration @@ -228,13 +238,12 @@ function spawnShell(rootDir, relPath, raw, args) { } const bashResult = spawnSync(bash, [scriptPath, ...args], { input: raw, - encoding: 'utf8', env: hookEnv, cwd: process.cwd(), timeout: 30000, windowsHide: true, }); - return withComparisonInput(bashResult, raw); + return withComparisonInput(bashResult, Buffer.from(raw, 'utf8')); } const shellArgs = isPs @@ -245,13 +254,12 @@ function spawnShell(rootDir, relPath, raw, args) { const result = spawnSync(shell, shellArgs, { input: raw, - encoding: 'utf8', env: hookEnv, cwd: process.cwd(), timeout: 30000, windowsHide: true, }); - return withComparisonInput(result, raw); + return withComparisonInput(result, Buffer.from(raw, 'utf8')); } function main() { @@ -265,7 +273,8 @@ function main() { writeStderr( '[Hook] bootstrap: missing required args (mode/relPath/rootDir); emitting empty stdout\n' ); - process.exit(0); + process.exitCode = 0; + return; } let result; @@ -276,11 +285,13 @@ function main() { result = spawnShell(rootDir, relPath, raw, args); } else { writeStderr(`[Hook] unknown bootstrap mode: ${mode}; emitting empty stdout\n`); - process.exit(0); + process.exitCode = 0; + return; } } catch (error) { writeStderr(`[Hook] bootstrap resolution failed: ${error.message}; emitting empty stdout\n`); - process.exit(0); + process.exitCode = 0; + return; } passthrough(result); @@ -293,10 +304,11 @@ function main() { ? `terminated by signal ${result.signal}` : 'missing exit status'; writeStderr(`[Hook] bootstrap execution failed: ${reason}\n`); - process.exit(0); + process.exitCode = 0; + return; } - process.exit(Number.isInteger(result.status) ? result.status : 0); + process.exitCode = Number.isInteger(result.status) ? result.status : 0; } // Run when invoked as a hook entry. Production hooks load this via diff --git a/tests/hooks/plugin-hook-bootstrap-no-echo.test.js b/tests/hooks/plugin-hook-bootstrap-no-echo.test.js index d72fa42aa..ed94df763 100644 --- a/tests/hooks/plugin-hook-bootstrap-no-echo.test.js +++ b/tests/hooks/plugin-hook-bootstrap-no-echo.test.js @@ -283,6 +283,46 @@ if ( passed++; else failed++; +if ( + test('64 KiB byte prefix split inside UTF-8 remains a raw passthrough', () => { + const raw = Buffer.from(`${'a'.repeat(65535)}étail`, 'utf8'); + const cappedStdout = raw.subarray(0, 64 * 1024); + + assert.strictEqual(cappedStdout.length, 64 * 1024); + assert.strictEqual(cappedStdout.at(-1), Buffer.from('é', 'utf8')[0]); + assert.strictEqual( + isRawPassthrough(raw, cappedStdout), + true, + 'classification must compare bytes before UTF-8 decoding can insert U+FFFD' + ); + }) +) + passed++; +else failed++; + +if ( + test('spawn classification suppresses a 64 KiB prefix split inside UTF-8', () => { + const fixturePath = path.join(FIXTURE_DIR, 'split-byte-prefix-fixture.js'); + fs.writeFileSync( + fixturePath, + "const chunks=[]; process.stdin.on('data', chunk => chunks.push(chunk)); process.stdin.on('end', () => process.stdout.write(Buffer.concat(chunks).subarray(0, 64 * 1024)));" + ); + try { + const payload = `${'a'.repeat(65535)}étail`; + const result = runBootstrap(['node', path.basename(fixturePath)], payload, { + CLAUDE_PLUGIN_ROOT: FIXTURE_DIR + }); + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(result.stdout, '', 'classification must occur before UTF-8 decoding'); + assert.match(result.stderr, /returned raw input as stdout/); + } finally { + fs.unlinkSync(fixturePath); + } + }) +) + passed++; +else failed++; + if (process.platform !== 'win32') { if ( test('shell branch suppresses raw stdin echoed by the child', () => { @@ -360,5 +400,5 @@ if ( passed++; else failed++; -console.log('\n ' + passed + ' passed, ' + failed + ' failed\n'); +console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0);