mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-26 19:45:03 +02:00
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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user