fix(hooks): keep silent hook paths silent (#2987)

* fix(hooks): keep silent hook paths silent

* fix(hooks): harden stream failure handling

* fix(hooks): settle interrupted input streams

* test(hooks): name stream input limits

* test(hooks): isolate PostToolUse dispatcher fixtures
This commit is contained in:
He Dong
2026-09-18 18:42:20 -04:00
committed by GitHub
parent 34de45f210
commit 8bf16ccfec
25 changed files with 1489 additions and 289 deletions
+59 -39
View File
@@ -12,28 +12,25 @@ const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { isHookEnabled, isDryRun } = require('../lib/hook-flags');
const { readStdinRaw: readBoundedStdin, resolveMaxStdin } = require('./hook-input');
const { buildPreToolUseAdditionalContext } = require('./pretooluse-visible-output');
const MAX_STDIN = 1024 * 1024;
const FAIL_CLOSED_ON_TRUNCATION_HOOKS = new Set([
'pre:powershell:gateguard-fact-force',
'pre:edit-write:gateguard-fact-force',
'pre:mcp-health-check'
]);
const MAX_STDIN = resolveMaxStdin(process.env.ECC_HOOK_INPUT_MAX_BYTES, {
writeDiagnostic: message => process.stderr.write(message)
});
function readStdinRaw() {
return new Promise(resolve => {
let raw = '';
let truncated = false;
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
if (chunk.length > remaining) {
truncated = true;
}
} else {
truncated = true;
}
});
process.stdin.on('end', () => resolve({ raw, truncated }));
process.stdin.on('error', () => resolve({ raw, truncated }));
return readBoundedStdin(process.stdin, {
maxStdin: MAX_STDIN,
truncated: /^(1|true|yes)$/i.test(
String(process.env.ECC_HOOK_INPUT_TRUNCATED_UPSTREAM || '')
)
});
}
@@ -68,7 +65,7 @@ function exitWithStdout(text, exitCode) {
process.stderr.write('', exitWhenFlushed);
}
function resolveHookResult(raw, output) {
function resolveHookResult(output) {
if (typeof output === 'string' || Buffer.isBuffer(output)) {
return { stdout: String(output), exitCode: 0 };
}
@@ -83,23 +80,39 @@ function resolveHookResult(raw, output) {
if (Object.prototype.hasOwnProperty.call(output, 'stdout')) {
return { stdout: String(output.stdout ?? ''), exitCode };
}
return { stdout: exitCode === 0 ? raw : '', exitCode };
return { stdout: '', exitCode };
}
return { stdout: raw, exitCode: 0 };
return { stdout: '', exitCode: 0 };
}
function resolveLegacySpawnStdout(raw, result) {
function resolveLegacySpawnStdout(result) {
const stdout = typeof result.stdout === 'string' ? result.stdout : '';
if (stdout) {
return stdout;
return stdout || '';
}
function truncatedInputResult(hookId, maxStdin) {
if (!FAIL_CLOSED_ON_TRUNCATION_HOOKS.has(hookId)) return null;
if (hookId === 'pre:powershell:gateguard-fact-force'
|| hookId === 'pre:edit-write:gateguard-fact-force') {
const gateGuardValue = String(process.env.ECC_GATEGUARD || '').trim().toLowerCase();
const legacyDisabled = String(process.env.GATEGUARD_DISABLED || '').trim() === '1';
if (legacyDisabled || ['0', 'false', 'off', 'disabled', 'disable'].includes(gateGuardValue)) {
return null;
}
}
if (hookId === 'pre:mcp-health-check') {
const failOpen = /^(1|true|yes)$/i.test(
String(process.env.ECC_MCP_HEALTH_FAIL_OPEN || '')
);
if (failOpen) return null;
}
if (Number.isInteger(result.status) && result.status === 0) {
return raw;
}
return '';
return {
stdout: '',
stderr: `BLOCKED: Hook input exceeded ${maxStdin} bytes, so ${hookId} could not safely inspect the complete request. Retry with a smaller tool input or explicitly disable this hook.`,
exitCode: 2
};
}
function getPluginRoot() {
@@ -157,28 +170,28 @@ async function main() {
// Oversized payloads: never echo the truncated string — a JSON document
// cut mid-stream is treated by the harness as a hook failure, blocking the
// tool call (#2222). Empty stdout + exit 0 means "no opinion", so
// pass-through paths fail open. The hook itself still runs and receives
// silent/no-op paths fail open. The hook itself still runs and receives
// the truncated flag (run() context / ECC_HOOK_INPUT_TRUNCATED), so
// security hooks like config-protection can still choose to block.
const sanitizeEcho = text => (truncated && text === raw ? '' : text);
if (truncated) {
process.stderr.write(`[Hook] stdin exceeded ${MAX_STDIN} bytes for ${hookId || 'unknown'}; suppressing pass-through (fail-open unless the hook blocks)\n`);
process.stderr.write(`[Hook] stdin exceeded ${MAX_STDIN} bytes for ${hookId || 'unknown'}; suppressing raw passthrough\n`);
}
if (!hookId || !relScriptPath) {
exitWithStdout(sanitizeEcho(raw), 0);
exitWithStdout('', 0);
return;
}
if (!isHookEnabled(hookId, { profiles: profilesCsv })) {
exitWithStdout(sanitizeEcho(raw), 0);
exitWithStdout('', 0);
return;
}
if (isDryRun()) {
const preview = buildDryRunPreview(hookId, relScriptPath, profilesCsv, raw);
process.stderr.write(preview);
exitWithStdout(sanitizeEcho(raw), 0);
exitWithStdout('', 0);
return;
}
@@ -189,13 +202,20 @@ async function main() {
// Prevent path traversal outside the plugin root
if (!scriptPath.startsWith(resolvedRoot + path.sep)) {
process.stderr.write(`[Hook] Path traversal rejected for ${hookId}: ${scriptPath}\n`);
exitWithStdout(sanitizeEcho(raw), 0);
exitWithStdout('', 0);
return;
}
if (!fs.existsSync(scriptPath)) {
process.stderr.write(`[Hook] Script not found for ${hookId}: ${scriptPath}\n`);
exitWithStdout(sanitizeEcho(raw), 0);
exitWithStdout('', 0);
return;
}
const truncationBlock = truncated ? truncatedInputResult(hookId, MAX_STDIN) : null;
if (truncationBlock) {
writeStderr(truncationBlock.stderr);
exitWithStdout(truncationBlock.stdout, truncationBlock.exitCode);
return;
}
@@ -231,11 +251,11 @@ async function main() {
truncated,
maxStdin: MAX_STDIN
});
const result = resolveHookResult(raw, output);
const result = resolveHookResult(output);
exitWithStdout(sanitizeEcho(result.stdout), result.exitCode);
} catch (runErr) {
process.stderr.write(`[Hook] run() error for ${hookId}: ${runErr.message}\n`);
exitWithStdout(sanitizeEcho(raw), 0);
exitWithStdout('', 0);
}
return;
}
@@ -256,7 +276,7 @@ async function main() {
timeout: 30000
});
const legacyStdout = sanitizeEcho(resolveLegacySpawnStdout(raw, result));
const legacyStdout = sanitizeEcho(resolveLegacySpawnStdout(result));
if (result.stderr) process.stderr.write(result.stderr);
if (result.error || result.signal || result.status === null) {