fix: fold session manager blockers into one candidate

This commit is contained in:
Affaan Mustafa
2026-03-24 23:08:27 -04:00
parent 7726c25e46
commit 1d0aa5ac2a
30 changed files with 1126 additions and 288 deletions

View File

@@ -61,11 +61,34 @@ const PROTECTED_FILES = new Set([
'.markdownlintrc',
]);
function parseInput(inputOrRaw) {
if (typeof inputOrRaw === 'string') {
try {
return inputOrRaw.trim() ? JSON.parse(inputOrRaw) : {};
} catch {
return {};
}
}
return inputOrRaw && typeof inputOrRaw === 'object' ? inputOrRaw : {};
}
/**
* Exportable run() for in-process execution via run-with-flags.js.
* Avoids the ~50-100ms spawnSync overhead when available.
*/
function run(input) {
function run(inputOrRaw, options = {}) {
if (options.truncated) {
return {
exitCode: 2,
stderr:
`BLOCKED: Hook input exceeded ${options.maxStdin || MAX_STDIN} bytes. ` +
'Refusing to bypass config-protection on a truncated payload. ' +
'Retry with a smaller edit or disable the config-protection hook temporarily.'
};
}
const input = parseInput(inputOrRaw);
const filePath = input?.tool_input?.file_path || input?.tool_input?.file || '';
if (!filePath) return { exitCode: 0 };
@@ -75,9 +98,9 @@ function run(input) {
exitCode: 2,
stderr:
`BLOCKED: Modifying ${basename} is not allowed. ` +
`Fix the source code to satisfy linter/formatter rules instead of ` +
`weakening the config. If this is a legitimate config change, ` +
`disable the config-protection hook temporarily.`,
'Fix the source code to satisfy linter/formatter rules instead of ' +
'weakening the config. If this is a legitimate config change, ' +
'disable the config-protection hook temporarily.',
};
}
@@ -87,7 +110,7 @@ function run(input) {
module.exports = { run };
// Stdin fallback for spawnSync execution
let truncated = false;
let truncated = /^(1|true|yes)$/i.test(String(process.env.ECC_HOOK_INPUT_TRUNCATED || ''));
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
@@ -100,25 +123,17 @@ process.stdin.on('data', chunk => {
});
process.stdin.on('end', () => {
// If stdin was truncated, the JSON is likely malformed. Fail open but
// log a warning so the issue is visible. The run() path (used by
// run-with-flags.js in-process) is not affected by this.
if (truncated) {
process.stderr.write('[config-protection] Warning: stdin exceeded 1MB, skipping check\n');
process.stdout.write(raw);
return;
const result = run(raw, {
truncated,
maxStdin: Number(process.env.ECC_HOOK_INPUT_MAX_BYTES) || MAX_STDIN,
});
if (result.stderr) {
process.stderr.write(result.stderr + '\n');
}
try {
const input = raw.trim() ? JSON.parse(raw) : {};
const result = run(input);
if (result.exitCode === 2) {
process.stderr.write(result.stderr + '\n');
process.exit(2);
}
} catch {
// Keep hook non-blocking on parse errors.
if (result.exitCode === 2) {
process.exit(2);
}
process.stdout.write(raw);

View File

@@ -10,6 +10,7 @@
* - policy_violation: Actions that violate configured policies
* - security_finding: Security-relevant tool invocations
* - approval_requested: Operations requiring explicit approval
* - hook_input_truncated: Hook input exceeded the safe inspection limit
*
* Enable: Set ECC_GOVERNANCE_CAPTURE=1
* Configure session: Set ECC_SESSION_ID for session correlation
@@ -101,6 +102,37 @@ function detectSensitivePath(filePath) {
return SENSITIVE_PATHS.some(pattern => pattern.test(filePath));
}
function fingerprintCommand(command) {
if (!command || typeof command !== 'string') return null;
return crypto.createHash('sha256').update(command).digest('hex').slice(0, 12);
}
function summarizeCommand(command) {
if (!command || typeof command !== 'string') {
return {
commandName: null,
commandFingerprint: null,
};
}
const trimmed = command.trim();
if (!trimmed) {
return {
commandName: null,
commandFingerprint: null,
};
}
return {
commandName: trimmed.split(/\s+/)[0] || null,
commandFingerprint: fingerprintCommand(trimmed),
};
}
function emitGovernanceEvent(event) {
process.stderr.write(`[governance] ${JSON.stringify(event)}\n`);
}
/**
* Analyze a hook input payload and return governance events to capture.
*
@@ -146,6 +178,7 @@ function analyzeForGovernanceEvents(input, context = {}) {
if (toolName === 'Bash') {
const command = toolInput.command || '';
const approvalFindings = detectApprovalRequired(command);
const commandSummary = summarizeCommand(command);
if (approvalFindings.length > 0) {
events.push({
@@ -155,7 +188,7 @@ function analyzeForGovernanceEvents(input, context = {}) {
payload: {
toolName,
hookPhase,
command: command.slice(0, 200),
...commandSummary,
matchedPatterns: approvalFindings.map(f => f.pattern),
severity: 'high',
},
@@ -188,6 +221,7 @@ function analyzeForGovernanceEvents(input, context = {}) {
if (SECURITY_RELEVANT_TOOLS.has(toolName) && hookPhase === 'post') {
const command = toolInput.command || '';
const hasElevated = /sudo\s/.test(command) || /chmod\s/.test(command) || /chown\s/.test(command);
const commandSummary = summarizeCommand(command);
if (hasElevated) {
events.push({
@@ -197,7 +231,7 @@ function analyzeForGovernanceEvents(input, context = {}) {
payload: {
toolName,
hookPhase,
command: command.slice(0, 200),
...commandSummary,
reason: 'elevated_privilege_command',
severity: 'medium',
},
@@ -216,16 +250,32 @@ function analyzeForGovernanceEvents(input, context = {}) {
* @param {string} rawInput - Raw JSON string from stdin
* @returns {string} The original input (pass-through)
*/
function run(rawInput) {
function run(rawInput, options = {}) {
// Gate on feature flag
if (String(process.env.ECC_GOVERNANCE_CAPTURE || '').toLowerCase() !== '1') {
return rawInput;
}
const sessionId = process.env.ECC_SESSION_ID || null;
const hookPhase = process.env.CLAUDE_HOOK_EVENT_NAME || 'unknown';
if (options.truncated) {
emitGovernanceEvent({
id: generateEventId(),
sessionId,
eventType: 'hook_input_truncated',
payload: {
hookPhase: hookPhase.startsWith('Pre') ? 'pre' : 'post',
sizeLimitBytes: options.maxStdin || MAX_STDIN,
severity: 'warning',
},
resolvedAt: null,
resolution: null,
});
}
try {
const input = JSON.parse(rawInput);
const sessionId = process.env.ECC_SESSION_ID || null;
const hookPhase = process.env.CLAUDE_HOOK_EVENT_NAME || 'unknown';
const events = analyzeForGovernanceEvents(input, {
sessionId,
@@ -233,13 +283,8 @@ function run(rawInput) {
});
if (events.length > 0) {
// Write events to stderr as JSON-lines for the caller to capture.
// The state store write is async and handled by a separate process
// to avoid blocking the hook pipeline.
for (const event of events) {
process.stderr.write(
`[governance] ${JSON.stringify(event)}\n`
);
emitGovernanceEvent(event);
}
}
} catch {
@@ -252,16 +297,25 @@ function run(rawInput) {
// ── stdin entry point ────────────────────────────────
if (require.main === module) {
let raw = '';
let truncated = /^(1|true|yes)$/i.test(String(process.env.ECC_HOOK_INPUT_TRUNCATED || ''));
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', () => {
const result = run(raw);
const result = run(raw, {
truncated,
maxStdin: Number(process.env.ECC_HOOK_INPUT_MAX_BYTES) || MAX_STDIN,
});
process.stdout.write(result);
});
}

View File

@@ -99,15 +99,21 @@ function saveState(filePath, state) {
function readRawStdin() {
return new Promise(resolve => {
let raw = '';
let truncated = /^(1|true|yes)$/i.test(String(process.env.ECC_HOOK_INPUT_TRUNCATED || ''));
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));
process.stdin.on('error', () => resolve(raw));
process.stdin.on('end', () => resolve({ raw, truncated }));
process.stdin.on('error', () => resolve({ raw, truncated }));
});
}
@@ -155,6 +161,18 @@ function extractMcpTarget(input) {
};
}
function extractMcpTargetFromRaw(raw) {
const toolNameMatch = raw.match(/"(?:tool_name|name)"\s*:\s*"([^"]+)"/);
const serverMatch = raw.match(/"(?:server|mcp_server|connector)"\s*:\s*"([^"]+)"/);
const toolMatch = raw.match(/"(?:tool|mcp_tool)"\s*:\s*"([^"]+)"/);
return extractMcpTarget({
tool_name: toolNameMatch ? toolNameMatch[1] : '',
server: serverMatch ? serverMatch[1] : undefined,
tool: toolMatch ? toolMatch[1] : undefined
});
}
function resolveServerConfig(serverName) {
for (const filePath of configPaths()) {
const data = readJsonFile(filePath);
@@ -559,9 +577,9 @@ async function handlePostToolUseFailure(rawInput, input, target, statePathValue,
}
async function main() {
const rawInput = await readRawStdin();
const { raw: rawInput, truncated } = await readRawStdin();
const input = safeParse(rawInput);
const target = extractMcpTarget(input);
const target = extractMcpTarget(input) || (truncated ? extractMcpTargetFromRaw(rawInput) : null);
if (!target) {
process.stdout.write(rawInput);
@@ -569,6 +587,19 @@ async function main() {
return;
}
if (truncated) {
const limit = Number(process.env.ECC_HOOK_INPUT_MAX_BYTES) || MAX_STDIN;
const logs = [
shouldFailOpen()
? `[MCPHealthCheck] Hook input exceeded ${limit} bytes while checking ${target.server}; allowing ${target.tool || 'tool'} because fail-open mode is enabled`
: `[MCPHealthCheck] Hook input exceeded ${limit} bytes while checking ${target.server}; blocking ${target.tool || 'tool'} to avoid bypassing MCP health checks`
];
emitLogs(logs);
process.stdout.write(rawInput);
process.exit(shouldFailOpen() ? 0 : 2);
return;
}
const eventName = process.env.CLAUDE_HOOK_EVENT_NAME || 'PreToolUse';
const now = Date.now();
const statePathValue = stateFilePath();

View File

@@ -18,18 +18,54 @@ const MAX_STDIN = 1024 * 1024;
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));
process.stdin.on('error', () => resolve(raw));
process.stdin.on('end', () => resolve({ raw, truncated }));
process.stdin.on('error', () => resolve({ raw, truncated }));
});
}
function writeStderr(stderr) {
if (typeof stderr !== 'string' || stderr.length === 0) {
return;
}
process.stderr.write(stderr.endsWith('\n') ? stderr : `${stderr}\n`);
}
function emitHookResult(raw, output) {
if (typeof output === 'string' || Buffer.isBuffer(output)) {
process.stdout.write(String(output));
return 0;
}
if (output && typeof output === 'object') {
writeStderr(output.stderr);
if (Object.prototype.hasOwnProperty.call(output, 'stdout')) {
process.stdout.write(String(output.stdout ?? ''));
} else if (!Number.isInteger(output.exitCode) || output.exitCode === 0) {
process.stdout.write(raw);
}
return Number.isInteger(output.exitCode) ? output.exitCode : 0;
}
process.stdout.write(raw);
return 0;
}
function getPluginRoot() {
if (process.env.CLAUDE_PLUGIN_ROOT && process.env.CLAUDE_PLUGIN_ROOT.trim()) {
return process.env.CLAUDE_PLUGIN_ROOT;
@@ -39,7 +75,7 @@ function getPluginRoot() {
async function main() {
const [, , hookId, relScriptPath, profilesCsv] = process.argv;
const raw = await readStdinRaw();
const { raw, truncated } = await readStdinRaw();
if (!hookId || !relScriptPath) {
process.stdout.write(raw);
@@ -89,8 +125,8 @@ async function main() {
if (hookModule && typeof hookModule.run === 'function') {
try {
const output = hookModule.run(raw);
if (output !== null && output !== undefined) process.stdout.write(output);
const output = hookModule.run(raw, { truncated, maxStdin: MAX_STDIN });
process.exit(emitHookResult(raw, output));
} catch (runErr) {
process.stderr.write(`[Hook] run() error for ${hookId}: ${runErr.message}\n`);
process.stdout.write(raw);
@@ -102,7 +138,11 @@ async function main() {
const result = spawnSync('node', [scriptPath], {
input: raw,
encoding: 'utf8',
env: process.env,
env: {
...process.env,
ECC_HOOK_INPUT_TRUNCATED: truncated ? '1' : '0',
ECC_HOOK_INPUT_MAX_BYTES: String(MAX_STDIN)
},
cwd: process.cwd(),
timeout: 30000
});

View File

@@ -11,13 +11,13 @@
const {
getSessionsDir,
getSessionSearchDirs,
getLearnedSkillsDir,
findFiles,
ensureDir,
readFile,
stripAnsi,
log,
output
log
} = require('../lib/utils');
const { getPackageManager, getSelectionPrompt } = require('../lib/package-manager');
const { listAliases } = require('../lib/session-aliases');
@@ -26,13 +26,16 @@ const { detectProjectType } = require('../lib/project-detect');
async function main() {
const sessionsDir = getSessionsDir();
const learnedDir = getLearnedSkillsDir();
const additionalContextParts = [];
// Ensure directories exist
ensureDir(sessionsDir);
ensureDir(learnedDir);
// Check for recent session files (last 7 days)
const recentSessions = findFiles(sessionsDir, '*-session.tmp', { maxAge: 7 });
const recentSessions = getSessionSearchDirs()
.flatMap(dir => findFiles(dir, '*-session.tmp', { maxAge: 7 }))
.sort((a, b) => b.mtime - a.mtime);
if (recentSessions.length > 0) {
const latest = recentSessions[0];
@@ -43,7 +46,7 @@ async function main() {
const content = stripAnsi(readFile(latest.path));
if (content && !content.includes('[Session context goes here]')) {
// Only inject if the session has actual content (not the blank template)
output(`Previous session summary:\n${content}`);
additionalContextParts.push(`Previous session summary:\n${content}`);
}
}
@@ -84,15 +87,49 @@ async function main() {
parts.push(`frameworks: ${projectInfo.frameworks.join(', ')}`);
}
log(`[SessionStart] Project detected — ${parts.join('; ')}`);
output(`Project type: ${JSON.stringify(projectInfo)}`);
additionalContextParts.push(`Project type: ${JSON.stringify(projectInfo)}`);
} else {
log('[SessionStart] No specific project type detected');
}
process.exit(0);
await writeSessionStartPayload(additionalContextParts.join('\n\n'));
}
function writeSessionStartPayload(additionalContext) {
return new Promise((resolve, reject) => {
let settled = false;
const payload = JSON.stringify({
hookSpecificOutput: {
hookEventName: 'SessionStart',
additionalContext
}
});
const handleError = (err) => {
if (settled) return;
settled = true;
if (err) {
log(`[SessionStart] stdout write error: ${err.message}`);
}
reject(err || new Error('stdout stream error'));
};
process.stdout.once('error', handleError);
process.stdout.write(payload, (err) => {
process.stdout.removeListener('error', handleError);
if (settled) return;
settled = true;
if (err) {
log(`[SessionStart] stdout write error: ${err.message}`);
reject(err);
return;
}
resolve();
});
});
}
main().catch(err => {
console.error('[SessionStart] Error:', err.message);
process.exit(0); // Don't block on errors
process.exitCode = 0; // Don't block on errors
});