Files
everything-claude-code/scripts/hooks/governance-capture.js
Affaan Mustafa 0b0b66c02f feat: agent compression, inspection logic, governance hooks (#491, #485, #482) (#688)
Implements three roadmap features:

- Agent description compression (#491): New `agent-compress` module with
  catalog/summary/full compression modes and lazy-loading. Reduces ~26k
  token agent descriptions to ~2-3k catalog entries for context efficiency.

- Inspection logic (#485): New `inspection` module that detects recurring
  failure patterns in skill_runs. Groups by skill + normalized failure
  reason, generates structured reports with suggested remediation actions.
  Configurable threshold (default: 3 failures).

- Governance event capture hook (#482): PreToolUse/PostToolUse hook that
  detects secrets, policy violations, approval-required commands, and
  elevated privilege usage. Gated behind ECC_GOVERNANCE_CAPTURE=1 flag.
  Writes to governance_events table via JSON-line stderr output.

59 new tests (16 + 16 + 27), all passing.
2026-03-20 01:38:13 -07:00

281 lines
7.7 KiB
JavaScript

#!/usr/bin/env node
/**
* Governance Event Capture Hook
*
* PreToolUse/PostToolUse hook that detects governance-relevant events
* and writes them to the governance_events table in the state store.
*
* Captured event types:
* - secret_detected: Hardcoded secrets in tool input/output
* - policy_violation: Actions that violate configured policies
* - security_finding: Security-relevant tool invocations
* - approval_requested: Operations requiring explicit approval
*
* Enable: Set ECC_GOVERNANCE_CAPTURE=1
* Configure session: Set ECC_SESSION_ID for session correlation
*/
'use strict';
const crypto = require('crypto');
const MAX_STDIN = 1024 * 1024;
// Patterns that indicate potential hardcoded secrets
const SECRET_PATTERNS = [
{ name: 'aws_key', pattern: /(?:AKIA|ASIA)[A-Z0-9]{16}/i },
{ name: 'generic_secret', pattern: /(?:secret|password|token|api[_-]?key)\s*[:=]\s*["'][^"']{8,}/i },
{ name: 'private_key', pattern: /-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----/ },
{ name: 'jwt', pattern: /eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
{ name: 'github_token', pattern: /gh[pousr]_[A-Za-z0-9_]{36,}/ },
];
// Tool names that represent security-relevant operations
const SECURITY_RELEVANT_TOOLS = new Set([
'Bash', // Could execute arbitrary commands
]);
// Commands that require governance approval
const APPROVAL_COMMANDS = [
/git\s+push\s+.*--force/,
/git\s+reset\s+--hard/,
/rm\s+-rf?\s/,
/DROP\s+(?:TABLE|DATABASE)/i,
/DELETE\s+FROM\s+\w+\s*(?:;|$)/i,
];
// File patterns that indicate policy-sensitive paths
const SENSITIVE_PATHS = [
/\.env(?:\.|$)/,
/credentials/i,
/secrets?\./i,
/\.pem$/,
/\.key$/,
/id_rsa/,
];
/**
* Generate a unique event ID.
*/
function generateEventId() {
return `gov-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
}
/**
* Scan text content for hardcoded secrets.
* Returns array of { name, match } for each detected secret.
*/
function detectSecrets(text) {
if (!text || typeof text !== 'string') return [];
const findings = [];
for (const { name, pattern } of SECRET_PATTERNS) {
if (pattern.test(text)) {
findings.push({ name });
}
}
return findings;
}
/**
* Check if a command requires governance approval.
*/
function detectApprovalRequired(command) {
if (!command || typeof command !== 'string') return [];
const findings = [];
for (const pattern of APPROVAL_COMMANDS) {
if (pattern.test(command)) {
findings.push({ pattern: pattern.source });
}
}
return findings;
}
/**
* Check if a file path is policy-sensitive.
*/
function detectSensitivePath(filePath) {
if (!filePath || typeof filePath !== 'string') return false;
return SENSITIVE_PATHS.some(pattern => pattern.test(filePath));
}
/**
* Analyze a hook input payload and return governance events to capture.
*
* @param {Object} input - Parsed hook input (tool_name, tool_input, tool_output)
* @param {Object} [context] - Additional context (sessionId, hookPhase)
* @returns {Array<Object>} Array of governance event objects
*/
function analyzeForGovernanceEvents(input, context = {}) {
const events = [];
const toolName = input.tool_name || '';
const toolInput = input.tool_input || {};
const toolOutput = typeof input.tool_output === 'string' ? input.tool_output : '';
const sessionId = context.sessionId || null;
const hookPhase = context.hookPhase || 'unknown';
// 1. Secret detection in tool input content
const inputText = typeof toolInput === 'object'
? JSON.stringify(toolInput)
: String(toolInput);
const inputSecrets = detectSecrets(inputText);
const outputSecrets = detectSecrets(toolOutput);
const allSecrets = [...inputSecrets, ...outputSecrets];
if (allSecrets.length > 0) {
events.push({
id: generateEventId(),
sessionId,
eventType: 'secret_detected',
payload: {
toolName,
hookPhase,
secretTypes: allSecrets.map(s => s.name),
location: inputSecrets.length > 0 ? 'input' : 'output',
severity: 'critical',
},
resolvedAt: null,
resolution: null,
});
}
// 2. Approval-required commands (Bash only)
if (toolName === 'Bash') {
const command = toolInput.command || '';
const approvalFindings = detectApprovalRequired(command);
if (approvalFindings.length > 0) {
events.push({
id: generateEventId(),
sessionId,
eventType: 'approval_requested',
payload: {
toolName,
hookPhase,
command: command.slice(0, 200),
matchedPatterns: approvalFindings.map(f => f.pattern),
severity: 'high',
},
resolvedAt: null,
resolution: null,
});
}
}
// 3. Policy violation: writing to sensitive paths
const filePath = toolInput.file_path || toolInput.path || '';
if (filePath && detectSensitivePath(filePath)) {
events.push({
id: generateEventId(),
sessionId,
eventType: 'policy_violation',
payload: {
toolName,
hookPhase,
filePath: filePath.slice(0, 200),
reason: 'sensitive_file_access',
severity: 'warning',
},
resolvedAt: null,
resolution: null,
});
}
// 4. Security-relevant tool usage tracking
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);
if (hasElevated) {
events.push({
id: generateEventId(),
sessionId,
eventType: 'security_finding',
payload: {
toolName,
hookPhase,
command: command.slice(0, 200),
reason: 'elevated_privilege_command',
severity: 'medium',
},
resolvedAt: null,
resolution: null,
});
}
}
return events;
}
/**
* Core hook logic — exported so run-with-flags.js can call directly.
*
* @param {string} rawInput - Raw JSON string from stdin
* @returns {string} The original input (pass-through)
*/
function run(rawInput) {
// Gate on feature flag
if (String(process.env.ECC_GOVERNANCE_CAPTURE || '').toLowerCase() !== '1') {
return rawInput;
}
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,
hookPhase: hookPhase.startsWith('Pre') ? 'pre' : 'post',
});
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`
);
}
}
} catch {
// Silently ignore parse errors — never block the tool pipeline.
}
return rawInput;
}
// ── stdin entry point ────────────────────────────────
if (require.main === module) {
let raw = '';
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);
}
});
process.stdin.on('end', () => {
const result = run(raw);
process.stdout.write(result);
});
}
module.exports = {
APPROVAL_COMMANDS,
SECRET_PATTERNS,
SECURITY_RELEVANT_TOOLS,
SENSITIVE_PATHS,
analyzeForGovernanceEvents,
detectApprovalRequired,
detectSecrets,
detectSensitivePath,
generateEventId,
run,
};