fix(hooks): preserve complete bounded passthrough payloads

Forward-port #2925 for #2924 and verify ASCII and multibyte over-limit input suppression. Supersedes the overlapping direct-entrypoint fix in #2978.

Co-authored-by: jackie-cqz <2557911191@qq.com>
This commit is contained in:
haelyra
2026-09-07 16:26:10 -04:00
co-authored by jackie-cqz
parent e04ea0b9cc
commit 20b1ba423e
9 changed files with 299 additions and 78 deletions
+15 -13
View File
@@ -26,29 +26,31 @@ const EXCLUDED_PATTERNS = [
/__mocks__\//,
];
const MAX_STDIN = 1024 * 1024; // 1MB limit
const MAX_DIRECT_STDIN_BYTES = 16 * 1024 * 1024;
let data = '';
let truncated = false;
let stdinBytes = 0;
let oversized = false;
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (data.length < MAX_STDIN) {
const remaining = MAX_STDIN - data.length;
data += chunk.substring(0, remaining);
if (chunk.length > remaining) truncated = true;
} else {
truncated = true;
if (oversized) return;
stdinBytes += Buffer.byteLength(chunk, 'utf8');
if (stdinBytes > MAX_DIRECT_STDIN_BYTES) {
data = '';
oversized = true;
return;
}
data += chunk;
});
/**
* Echo stdin back (ECC pass-through convention), then exit once the pipe has
* flushed. Truncated stdin is never echoed: a JSON document cut mid-stream is
* reported by the harness as a Stop hook JSON validation failure (#2090).
* flushed. Direct/legacy entrypoints preserve complete supported payloads up
* to 16MiB; the production runner applies its stricter bounded-input policy.
*/
function passThroughAndExit() {
if (truncated) {
log('[Hook] check-console-log: stdin exceeded 1MB; suppressing pass-through (fail-open)');
if (oversized) {
log('[Hook] check-console-log: direct stdin exceeded 16MiB; suppressing pass-through');
process.exit(0);
}
if (!data) {
@@ -85,6 +87,6 @@ process.stdin.on('end', () => {
log(`[Hook] check-console-log error: ${err.message}`);
}
// Always output the original data (unless truncated)
// Always output the complete original data.
passThroughAndExit();
});
+15 -7
View File
@@ -16,7 +16,7 @@
const path = require('path');
const { buildPreToolUseAdditionalContext } = require('./pretooluse-visible-output');
const MAX_STDIN = 1024 * 1024;
const MAX_DIRECT_STDIN_BYTES = 16 * 1024 * 1024;
// Known ad-hoc filenames that indicate impulse/scratch files (case-sensitive, uppercase only)
const ADHOC_FILENAMES = /^(NOTES|TODO|SCRATCH|TEMP|DRAFT|BRAINSTORM|SPIKE|DEBUG|WIP)\.(md|txt)$/;
@@ -71,21 +71,29 @@ function run(inputOrRaw, _options = {}) {
/**
* Stdin entrypoint for direct/spawnSync execution: reads the hook payload from
* stdin (capped at MAX_STDIN), runs the policy, and writes the PreToolUse result
* to stdout. Must only run when invoked directly, never on require(), so the
* stdin listeners are not leaked into a parent that loads this hook in-process.
* stdin, runs the policy, and writes the PreToolUse result to stdout. Direct
* and legacy entrypoints preserve complete supported payloads up to 16MiB;
* the production runner applies its stricter bounded-input policy. Must only
* run when invoked directly so stdin listeners are not leaked into a parent.
*/
function main() {
let data = '';
let stdinBytes = 0;
let oversized = false;
process.stdin.setEncoding('utf8');
process.stdin.on('data', c => {
if (data.length < MAX_STDIN) {
const remaining = MAX_STDIN - data.length;
data += c.substring(0, remaining);
if (oversized) return;
stdinBytes += Buffer.byteLength(c, 'utf8');
if (stdinBytes > MAX_DIRECT_STDIN_BYTES) {
data = '';
oversized = true;
return;
}
data += c;
});
process.stdin.on('end', () => {
if (oversized) return;
const result = run(data);
if (result.stderr) {
+14 -4
View File
@@ -11,7 +11,7 @@
const { readFile } = require('../lib/utils');
const MAX_STDIN = 1024 * 1024; // 1MB limit
const MAX_DIRECT_STDIN_BYTES = 16 * 1024 * 1024;
function run(data) {
const warnings = [];
try {
@@ -47,14 +47,24 @@ function run(data) {
if (require.main === module) {
let data = '';
let stdinBytes = 0;
let oversized = false;
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (data.length < MAX_STDIN) {
const remaining = MAX_STDIN - data.length;
data += chunk.substring(0, remaining);
if (oversized) return;
stdinBytes += Buffer.byteLength(chunk, 'utf8');
if (stdinBytes > MAX_DIRECT_STDIN_BYTES) {
data = '';
oversized = true;
return;
}
data += chunk;
});
process.stdin.on('end', () => {
if (oversized) {
process.exitCode = 0;
return;
}
const result = run(data);
if (result.stderr) process.stderr.write(`${result.stderr}\n`);
process.stdout.write(result.stdout);
+15 -6
View File
@@ -25,7 +25,7 @@ const UNSAFE_PATH_CHARS = /[&|<>^%!;`()$]/;
const { findProjectRoot, detectFormatter, resolveFormatterBin } = require('../lib/resolve-formatter');
const MAX_STDIN = 1024 * 1024; // 1MB limit
const MAX_DIRECT_STDIN_BYTES = 16 * 1024 * 1024;
/**
* Core logic — exported so run-with-flags.js can call directly
@@ -90,19 +90,28 @@ function run(rawInput) {
// ── stdin entry point (backwards-compatible) ────────────────────
if (require.main === module) {
let data = '';
let stdinBytes = 0;
let oversized = false;
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (data.length < MAX_STDIN) {
const remaining = MAX_STDIN - data.length;
data += chunk.substring(0, remaining);
if (oversized) return;
stdinBytes += Buffer.byteLength(chunk, 'utf8');
if (stdinBytes > MAX_DIRECT_STDIN_BYTES) {
data = '';
oversized = true;
return;
}
data += chunk;
});
process.stdin.on('end', () => {
if (oversized) {
process.exit(0);
return;
}
data = run(data);
process.stdout.write(data);
process.exit(0);
process.stdout.write(data, () => process.exit(0));
});
}
+17 -8
View File
@@ -13,18 +13,28 @@ const { execFileSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const MAX_STDIN = 1024 * 1024; // 1MB limit
const MAX_DIRECT_STDIN_BYTES = 16 * 1024 * 1024;
let data = "";
let stdinBytes = 0;
let oversized = false;
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
if (data.length < MAX_STDIN) {
const remaining = MAX_STDIN - data.length;
data += chunk.substring(0, remaining);
if (oversized) return;
stdinBytes += Buffer.byteLength(chunk, "utf8");
if (stdinBytes > MAX_DIRECT_STDIN_BYTES) {
data = "";
oversized = true;
return;
}
data += chunk;
});
process.stdin.on("end", () => {
if (oversized) {
process.exit(0);
return;
}
try {
const input = JSON.parse(data);
const filePath = input.tool_input?.file_path;
@@ -32,8 +42,8 @@ process.stdin.on("end", () => {
if (filePath && /\.(ts|tsx)$/.test(filePath)) {
const resolvedPath = path.resolve(filePath);
if (!fs.existsSync(resolvedPath)) {
process.stdout.write(data);
process.exit(0);
process.stdout.write(data, () => process.exit(0));
return;
}
// Find nearest tsconfig.json by walking up (max 20 levels to prevent infinite loop)
let dir = path.dirname(resolvedPath);
@@ -91,6 +101,5 @@ process.stdin.on("end", () => {
// Invalid input — pass through
}
process.stdout.write(data);
process.exit(0);
process.stdout.write(data, () => process.exit(0));
});
+32 -37
View File
@@ -4310,9 +4310,13 @@ async function runTests() {
else failed++;
if (
await asyncTest('source calls process.exit(0) after writing output', async () => {
await asyncTest('source exits only after stdout finishes writing', async () => {
const formatSource = fs.readFileSync(path.join(scriptsDir, 'post-edit-format.js'), 'utf8');
assert.ok(formatSource.includes('process.exit(0)'), 'Should call process.exit(0) for clean termination');
assert.match(
formatSource,
/process\.stdout\.write\(data,\s*\(\)\s*=>\s*process\.exit\(0\)\)/,
'Should exit from the stdout write callback'
);
})
)
passed++;
@@ -4321,7 +4325,7 @@ async function runTests() {
if (
await asyncTest('uses process.stdout.write instead of console.log for pass-through', async () => {
const formatSource = fs.readFileSync(path.join(scriptsDir, 'post-edit-format.js'), 'utf8');
assert.ok(formatSource.includes('process.stdout.write(data)'), 'Should use process.stdout.write to avoid trailing newline');
assert.ok(formatSource.includes('process.stdout.write(data,'), 'Should use process.stdout.write to avoid trailing newline');
// Verify no console.log(data) for pass-through (console.error for warnings is OK)
const lines = formatSource.split('\n');
const passThrough = lines.filter(l => /console\.log\(data\)/.test(l));
@@ -4334,9 +4338,13 @@ async function runTests() {
console.log('\nRound 29: post-edit-typecheck.js (exit and pass-through):');
if (
await asyncTest('source calls process.exit(0) after writing output', async () => {
await asyncTest('source exits only after stdout finishes writing', async () => {
const tcSource = fs.readFileSync(path.join(scriptsDir, 'post-edit-typecheck.js'), 'utf8');
assert.ok(tcSource.includes('process.exit(0)'), 'Should call process.exit(0) for clean termination');
assert.match(
tcSource,
/process\.stdout\.write\(data,\s*\(\)\s*=>\s*process\.exit\(0\)\)/,
'Should exit from the stdout write callback'
);
})
)
passed++;
@@ -4345,7 +4353,7 @@ async function runTests() {
if (
await asyncTest('uses process.stdout.write instead of console.log for pass-through', async () => {
const tcSource = fs.readFileSync(path.join(scriptsDir, 'post-edit-typecheck.js'), 'utf8');
assert.ok(tcSource.includes('process.stdout.write(data)'), 'Should use process.stdout.write');
assert.ok(tcSource.includes('process.stdout.write(data,'), 'Should use process.stdout.write');
const lines = tcSource.split('\n');
const passThrough = lines.filter(l => /console\.log\(data\)/.test(l));
assert.strictEqual(passThrough.length, 0, 'Should not use console.log(data) for pass-through');
@@ -5446,18 +5454,17 @@ async function runTests() {
passed++;
else failed++;
console.log('\nRound 59: check-console-log.js (stdin exceeding 1MB — truncation):');
console.log('\nRound 59: check-console-log.js (large stdin pass-through):');
if (
await asyncTest('suppresses pass-through for oversized stdin (fail-open, #2090)', async () => {
// Send 1.2MB of data — exceeds the 1MB MAX_STDIN limit. Echoing the
// truncated string would emit a JSON document cut mid-stream, which the
// harness reports as a Stop hook JSON validation failure.
await asyncTest('preserves complete oversized stdin (#2924)', async () => {
// Direct/legacy entrypoints preserve the protocol payload. Production
// wrappers continue to enforce their own bounded-input policy.
const payload = 'x'.repeat(1024 * 1024 + 200000);
const result = await runScript(path.join(scriptsDir, 'check-console-log.js'), payload);
assert.strictEqual(result.code, 0, 'Should exit 0 even with oversized stdin');
assert.strictEqual(result.stdout, '', 'Truncated stdin must not be echoed (empty stdout = no opinion)');
assert.strictEqual(result.stdout, payload, 'stdout should exactly match the complete stdin payload');
})
)
passed++;
@@ -5548,20 +5555,16 @@ async function runTests() {
passed++;
else failed++;
console.log('\nRound 60: post-edit-console-warn.js (stdin exceeding 1MB — truncation):');
console.log('\nRound 60: post-edit-console-warn.js (large stdin pass-through):');
if (
await asyncTest('truncates stdin at 1MB limit and still passes through data', async () => {
// Send 1.2MB of data — exceeds the 1MB MAX_STDIN limit
await asyncTest('preserves complete oversized stdin', async () => {
const payload = 'x'.repeat(1024 * 1024 + 200000);
const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), payload);
assert.strictEqual(result.code, 0, 'Should exit 0 even with oversized stdin');
// Data should be truncated — stdout significantly less than input
assert.ok(result.stdout.length < payload.length, `stdout (${result.stdout.length}) should be shorter than input (${payload.length})`);
// Should be approximately 1MB (last accepted chunk may push slightly over)
assert.ok(result.stdout.length <= 1024 * 1024 + 65536, `stdout (${result.stdout.length}) should be near 1MB, not unbounded`);
assert.ok(result.stdout.length > 0, 'Should still pass through truncated data');
assert.strictEqual(result.stdout, payload, 'stdout should exactly match the complete stdin payload');
assert.ok(result.stdout.length > 0, 'Should pass through complete data');
})
)
passed++;
@@ -6074,40 +6077,32 @@ Some random content without the expected ### Context to Load section
passed++;
else failed++;
// ── Round 87: post-edit-format.js and post-edit-typecheck.js stdin overflow (1MB) ──
console.log('\nRound 87: post-edit-format.js (stdin exceeding 1MB — truncation):');
// ── Round 87: post-edit-format.js and post-edit-typecheck.js large stdin pass-through ──
console.log('\nRound 87: post-edit-format.js (large stdin pass-through):');
if (
await asyncTest('truncates stdin at 1MB limit and still passes through data (post-edit-format)', async () => {
// Send 1.2MB of data — exceeds the 1MB MAX_STDIN limit (lines 14-22)
await asyncTest('preserves complete oversized stdin (post-edit-format)', async () => {
const payload = 'x'.repeat(1024 * 1024 + 200000);
const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), payload);
assert.strictEqual(result.code, 0, 'Should exit 0 even with oversized stdin');
// Output should be truncated — significantly less than input
assert.ok(result.stdout.length < payload.length, `stdout (${result.stdout.length}) should be shorter than input (${payload.length})`);
// Output should be approximately 1MB (last accepted chunk may push slightly over)
assert.ok(result.stdout.length <= 1024 * 1024 + 65536, `stdout (${result.stdout.length}) should be near 1MB, not unbounded`);
assert.ok(result.stdout.length > 0, 'Should still pass through truncated data');
assert.strictEqual(result.stdout, payload, 'stdout should exactly match the complete stdin payload');
assert.ok(result.stdout.length > 0, 'Should pass through complete data');
})
)
passed++;
else failed++;
console.log('\nRound 87: post-edit-typecheck.js (stdin exceeding 1MB — truncation):');
console.log('\nRound 87: post-edit-typecheck.js (large stdin pass-through):');
if (
await asyncTest('truncates stdin at 1MB limit and still passes through data (post-edit-typecheck)', async () => {
// Send 1.2MB of data — exceeds the 1MB MAX_STDIN limit (lines 16-24)
await asyncTest('preserves complete oversized stdin (post-edit-typecheck)', async () => {
const payload = 'x'.repeat(1024 * 1024 + 200000);
const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), payload);
assert.strictEqual(result.code, 0, 'Should exit 0 even with oversized stdin');
// Output should be truncated — significantly less than input
assert.ok(result.stdout.length < payload.length, `stdout (${result.stdout.length}) should be shorter than input (${payload.length})`);
// Output should be approximately 1MB (last accepted chunk may push slightly over)
assert.ok(result.stdout.length <= 1024 * 1024 + 65536, `stdout (${result.stdout.length}) should be near 1MB, not unbounded`);
assert.ok(result.stdout.length > 0, 'Should still pass through truncated data');
assert.strictEqual(result.stdout, payload, 'stdout should exactly match the complete stdin payload');
assert.ok(result.stdout.length > 0, 'Should pass through complete data');
})
)
passed++;
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env node
/**
* Regression coverage for #2924.
*
* Legacy direct hook entrypoints that echo stdin must preserve the complete
* hook payload. Cutting the input at an arbitrary byte/character boundary
* produces invalid JSON, while exiting before stdout drains loses everything
* past the platform pipe buffer.
*/
'use strict';
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const repoRoot = path.join(__dirname, '..', '..');
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-passthrough-'));
const DIRECT_STDIN_LIMIT_BYTES = 16 * 1024 * 1024;
const PASSTHROUGH_HOOKS = [
'scripts/hooks/check-console-log.js',
'scripts/hooks/post-edit-typecheck.js',
'scripts/hooks/post-edit-console-warn.js',
'scripts/hooks/post-edit-format.js',
'scripts/hooks/pre-write-doc-warn.js'
];
const PAYLOADS = [
['1KB payload', 'x'.repeat(1024)],
['200KB payload', 'x'.repeat(200 * 1024)],
['2MB payload', 'x'.repeat(2 * 1024 * 1024)],
['5MB payload', 'x'.repeat(5 * 1024 * 1024)],
['multibyte payload beyond 1MB', '韩'.repeat(600 * 1024)]
];
function test(name, fn) {
try {
fn();
console.log(` ✓ ${name}`);
return true;
} catch (error) {
console.log(` ✗ ${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
function hookPayload(padding) {
return JSON.stringify({
session_id: `passthrough-${process.pid}`,
hook_event_name: 'PostToolUse',
tool_name: 'Edit',
tool_input: { file_path: path.join(workDir, 'fixture.txt') },
padding
});
}
function runDirect(script, input) {
return spawnSync(process.execPath, [path.join(repoRoot, script)], {
input,
encoding: 'utf8',
cwd: workDir,
timeout: 30000,
maxBuffer: 32 * 1024 * 1024,
stdio: ['pipe', 'pipe', 'pipe']
});
}
console.log('\nPassthrough hook large-stdin tests (#2924):');
let passed = 0;
let failed = 0;
for (const script of PASSTHROUGH_HOOKS) {
for (const [label, padding] of PAYLOADS) {
if (
test(`${path.basename(script)} preserves the complete ${label}`, () => {
const input = hookPayload(padding);
const result = runDirect(script, input);
assert.strictEqual(
result.status,
0,
`${script}: expected exit 0, got ${result.status}: ${result.stderr}`
);
assert.ok(
result.stdout === input,
`${script}: expected ${Buffer.byteLength(input)} bytes, got ${Buffer.byteLength(result.stdout || '')}`
);
assert.deepStrictEqual(JSON.parse(result.stdout), JSON.parse(input));
})
) {
passed += 1;
} else {
failed += 1;
}
}
}
const oversizedInputs = [
['ASCII', hookPayload('x'.repeat(DIRECT_STDIN_LIMIT_BYTES))],
['multibyte', hookPayload('韩'.repeat(6 * 1024 * 1024))]
];
for (const [encoding, overLimitInput] of oversizedInputs) {
for (const script of PASSTHROUGH_HOOKS) {
if (
test(`${path.basename(script)} suppresses ${encoding} input beyond the 16MiB direct-entrypoint limit`, () => {
assert.ok(Buffer.byteLength(overLimitInput) > DIRECT_STDIN_LIMIT_BYTES);
const result = runDirect(script, overLimitInput);
assert.strictEqual(
result.status,
0,
`${script}: expected exit 0, got ${result.status}: ${result.stderr || result.error || ''}`
);
assert.ok(result.stdout === '', 'oversized input must not be emitted as truncated JSON');
})
) {
passed += 1;
} else {
failed += 1;
}
}
}
if (
test('post-edit-typecheck.js flushes the nonexistent-TypeScript-file early return', () => {
const input = JSON.stringify({
hook_event_name: 'PostToolUse',
tool_name: 'Edit',
tool_input: { file_path: path.join(workDir, 'missing.ts') },
padding: 'x'.repeat(2 * 1024 * 1024)
});
const result = runDirect('scripts/hooks/post-edit-typecheck.js', input);
assert.strictEqual(result.status, 0, result.stderr);
assert.ok(result.stdout === input, 'early return must wait for the complete stdout payload');
JSON.parse(result.stdout);
})
) {
passed += 1;
} else {
failed += 1;
}
if (
test('pre-write-doc-warn.js returns valid structured output for a large warned payload', () => {
const input = JSON.stringify({
hook_event_name: 'PreToolUse',
tool_name: 'Write',
tool_input: { file_path: 'TODO.md', content: 'x'.repeat(2 * 1024 * 1024) }
});
const result = runDirect('scripts/hooks/pre-write-doc-warn.js', input);
assert.strictEqual(result.status, 0, result.stderr);
const output = JSON.parse(result.stdout);
assert.ok(
output.hookSpecificOutput.additionalContext.includes('TODO.md'),
'large warned payload should retain the doc warning'
);
})
) {
passed += 1;
} else {
failed += 1;
}
try {
fs.rmSync(workDir, { recursive: true, force: true });
} catch {
/* best-effort cleanup */
}
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}\n`);
process.exit(failed > 0 ? 1 : 0);
+11 -1
View File
@@ -142,7 +142,6 @@ const STOP_HOOKS = [
// Direct-invocation legacy paths that echo stdin.
const ECHOING_STOP_HOOKS = [
'scripts/hooks/stop-format-typecheck.js',
'scripts/hooks/check-console-log.js',
'scripts/hooks/cost-tracker.js',
'scripts/hooks/desktop-notify.js'
];
@@ -316,6 +315,17 @@ for (const script of ECHOING_STOP_HOOKS) {
else failed++;
}
if (
test('check-console-log invoked directly echoes a >1MB payload uncut', () => {
const result = runDirect('scripts/hooks/check-console-log.js', oversizedPayload);
assert.strictEqual(result.status, 0);
assert.strictEqual(result.stdout, oversizedPayload, 'direct pass-through must preserve the complete payload');
JSON.parse(result.stdout);
})
)
passed++;
else failed++;
if (
test('check-console-log invoked directly echoes a sub-cap >64KB payload uncut', () => {
const result = runDirect('scripts/hooks/check-console-log.js', realisticPayload);
+2 -2
View File
@@ -854,8 +854,8 @@ async function runTests() {
})) passed++; else failed++;
if (await asyncTest('hooks survive stdin exceeding 1MB limit', async () => {
// The post-edit-console-warn hook reads stdin up to 1MB then passes through
// Send > 1MB to verify truncation doesn't crash the hook
// Direct invocation preserves the complete payload. Send >1MB to verify
// the pass-through path remains stable under backpressure.
const oversizedInput = JSON.stringify({
tool_input: { file_path: '/test.js' },
tool_output: { output: 'x'.repeat(1200000) } // ~1.2MB