alfred-agent/src/hooks.js

335 lines
14 KiB
JavaScript
Raw Normal View History

/**
*
* ALFRED AGENT HARNESS Hook System
*
* PreToolUse runs BEFORE a tool executes. Can BLOCK, MODIFY, or APPROVE.
* PostToolUse runs AFTER a tool executes. Can LOG, FILTER, or ALERT.
*
* Profiles:
* commander full access, minimal guardrails (just logging + sanity)
* customer sandboxed, isolated to their workspace, no system access
*
* Inspired by Anthropic Claude Code's hook architecture.
* Built by Commander Danny William Perez and Alfred.
*
*/
import { resolve } from 'path';
import { appendFileSync, mkdirSync, existsSync } from 'fs';
import { homedir } from 'os';
const HOME = homedir();
const LOG_DIR = resolve(HOME, 'alfred-agent/data/hook-logs');
// Ensure log directory exists
if (!existsSync(LOG_DIR)) mkdirSync(LOG_DIR, { recursive: true });
// ── Hook Result Types ────────────────────────────────────────────────
// Return from PreToolUse hooks:
// { action: 'allow' } — proceed normally
// { action: 'block', reason: '...' } — stop execution, tell the model why
// { action: 'modify', input: {...} } — rewrite tool input, then proceed
//
// Return from PostToolUse hooks:
// { action: 'pass' } — result goes through unchanged
// { action: 'filter', result: {...} } — replace result before model sees it
// { action: 'alert', message: '...' } — log alert, result still passes
/**
* Create a hook engine for a given profile.
*
* @param {string} profile - 'commander' or 'customer'
* @param {Object} opts
* @param {string} opts.clientId - Customer client ID (for sandbox scoping)
* @param {string} opts.workspaceRoot - Customer's sandbox root dir
* @param {Function} opts.onHookEvent - Callback for hook events
*/
export function createHookEngine(profile = 'commander', opts = {}) {
const clientId = opts.clientId || '33';
const workspaceRoot = opts.workspaceRoot || HOME;
const onHookEvent = opts.onHookEvent || ((event) => {
console.error(`\x1b[35m🪝 ${event.phase} ${event.tool}: ${event.action}\x1b[0m`);
});
// Select hooks based on profile
const preHooks = profile === 'commander' ? commanderPreHooks : customerPreHooks(workspaceRoot);
const postHooks = profile === 'commander' ? commanderPostHooks : customerPostHooks(workspaceRoot, clientId);
/**
* Run PreToolUse hooks. Returns { action, reason?, input? }
*/
async function runPreToolUse(toolName, toolInput) {
for (const hook of preHooks) {
if (hook.matcher && !hook.matcher(toolName)) continue;
const result = await hook.run(toolName, toolInput);
logHookEvent('PreToolUse', toolName, toolInput, result, profile, clientId);
onHookEvent({ phase: 'PreToolUse', tool: toolName, action: result.action, detail: result });
if (result.action === 'block') return result;
if (result.action === 'modify') {
toolInput = result.input; // Feed modified input to remaining hooks
}
}
return { action: 'allow', input: toolInput };
}
/**
* Run PostToolUse hooks. Returns { action, result?, message? }
*/
async function runPostToolUse(toolName, toolInput, toolResult) {
let currentResult = toolResult;
for (const hook of postHooks) {
if (hook.matcher && !hook.matcher(toolName)) continue;
const outcome = await hook.run(toolName, toolInput, currentResult);
logHookEvent('PostToolUse', toolName, { input: toolInput, resultPreview: JSON.stringify(currentResult).slice(0, 200) }, outcome, profile, clientId);
onHookEvent({ phase: 'PostToolUse', tool: toolName, action: outcome.action, detail: outcome });
if (outcome.action === 'filter') {
currentResult = outcome.result;
}
}
return { action: 'pass', result: currentResult };
}
return { runPreToolUse, runPostToolUse, profile };
}
// ═══════════════════════════════════════════════════════════════════════
// COMMANDER PROFILE — Minimal guardrails, full power
// ═══════════════════════════════════════════════════════════════════════
const commanderPreHooks = [
// Sanity check: block catastrophic bash commands even for Commander
{
matcher: (tool) => tool === 'bash',
async run(toolName, input) {
const cmd = input.command || '';
const catastrophic = [
/rm\s+(-rf?|--recursive)\s+\/\s*$/, // rm -rf /
/mkfs\./, // format disk
/>(\/dev\/sda|\/dev\/vda)/, // overwrite disk
/:\(\)\{.*\|.*\};:/, // fork bomb
/dd\s+if=.*\s+of=\/dev\/(sd|vd)/, // dd to disk
];
for (const pattern of catastrophic) {
if (pattern.test(cmd)) {
return { action: 'block', reason: `CATASTROPHIC COMMAND BLOCKED: ${cmd.slice(0, 80)}` };
}
}
return { action: 'allow' };
},
},
// Suggest rg over grep (advisory, not blocking)
{
matcher: (tool) => tool === 'bash',
async run(toolName, input) {
const cmd = input.command || '';
if (/^grep\b/.test(cmd) && !cmd.includes('|')) {
// Rewrite grep → rg for better performance
const rgCmd = cmd.replace(/^grep/, 'rg');
return { action: 'modify', input: { ...input, command: rgCmd } };
}
return { action: 'allow' };
},
},
];
const commanderPostHooks = [
// Log all DB queries for audit trail
{
matcher: (tool) => tool === 'db_query',
async run(toolName, input, result) {
const logLine = `[${new Date().toISOString()}] DB_QUERY client=33 sql="${(input.query || '').slice(0, 200)}"\n`;
appendFileSync(resolve(LOG_DIR, 'db-audit.log'), logLine);
return { action: 'pass' };
},
},
// Log all bash commands for history
{
matcher: (tool) => tool === 'bash',
async run(toolName, input, result) {
const logLine = `[${new Date().toISOString()}] BASH client=33 cmd="${(input.command || '').slice(0, 300)}"\n`;
appendFileSync(resolve(LOG_DIR, 'bash-audit.log'), logLine);
return { action: 'pass' };
},
},
];
// ═══════════════════════════════════════════════════════════════════════
// CUSTOMER PROFILE — Sandboxed, isolated, safe
// ═══════════════════════════════════════════════════════════════════════
function customerPreHooks(workspaceRoot) {
return [
// FILESYSTEM SANDBOX: Block any file access outside their workspace
{
matcher: (tool) => ['read_file', 'write_file', 'edit_file', 'glob', 'list_dir'].includes(tool),
async run(toolName, input) {
const targetPath = input.path || input.pattern || input.directory || '';
const resolved = resolve(workspaceRoot, targetPath);
if (!resolved.startsWith(workspaceRoot)) {
return {
action: 'block',
reason: `Access denied: path "${targetPath}" is outside your workspace. You can only access files within ${workspaceRoot}`
};
}
// Block access to dotfiles and config dirs
const dangerousPaths = ['.env', '.git/config', '.ssh', '.vault', 'node_modules/.cache'];
for (const dp of dangerousPaths) {
if (resolved.includes(dp)) {
return { action: 'block', reason: `Access denied: "${dp}" files are restricted` };
}
}
return { action: 'allow' };
},
},
// BASH SANDBOX: Heavily restricted for customers
{
matcher: (tool) => tool === 'bash',
async run(toolName, input) {
const cmd = input.command || '';
// Whitelist approach: only allow safe commands
const allowedPrefixes = [
'node ', 'npm ', 'npx ', 'python3 ', 'python ',
'pip ', 'pip3 ', 'git ', 'ls ', 'cat ', 'head ',
'tail ', 'wc ', 'echo ', 'date', 'pwd', 'whoami',
'rg ', 'grep ', 'find ', 'sort ', 'uniq ', 'awk ',
'sed ', 'jq ', 'curl ', 'which ',
];
const cmdTrimmed = cmd.trim();
const isAllowed = allowedPrefixes.some(p => cmdTrimmed.startsWith(p));
if (!isAllowed) {
return {
action: 'block',
reason: `Command not allowed in customer sandbox: "${cmdTrimmed.slice(0, 50)}". Allowed: node, npm, python, git, common unix tools.`
};
}
// Block network access except localhost
if (/curl\s/.test(cmd) && !/localhost|127\.0\.0\.1/.test(cmd)) {
return { action: 'block', reason: 'External network requests are not allowed in sandbox. Only localhost is permitted.' };
}
// Block sudo/su
if (/sudo|su\s|chmod\s+[0-7]*7|chown/.test(cmd)) {
return { action: 'block', reason: 'Privilege escalation not allowed in sandbox.' };
}
// Ensure commands run inside their workspace
return {
action: 'modify',
input: { ...input, command: `cd ${workspaceRoot} && ${cmd}` }
};
},
},
// BLOCK DANGEROUS TOOLS: Customers cannot access system tools
{
matcher: (tool) => ['vault_get_credential', 'pm2_status', 'db_query', 'session_journal'].includes(tool),
async run(toolName, input) {
return {
action: 'block',
reason: `Tool "${toolName}" is not available in customer workspaces. This is a system-level tool.`
};
},
},
// WEB FETCH: Block internal/private IPs (SSRF protection)
{
matcher: (tool) => tool === 'web_fetch',
async run(toolName, input) {
const url = input.url || '';
const blocked = /localhost|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|0\.0\.0\.0|::1|\[::1\]/i;
if (blocked.test(url)) {
return { action: 'block', reason: 'Cannot fetch internal/private URLs from customer sandbox.' };
}
return { action: 'allow' };
},
},
// MEMORY: Scope to customer's own memory space
{
matcher: (tool) => ['memory_store', 'memory_recall'].includes(tool),
async run(toolName, input) {
// Prefix key with client ID to isolate memory
if (input.key && !input.key.startsWith(`client_`)) {
return {
action: 'modify',
input: { ...input, key: `client_${input.key}` }
};
}
return { action: 'allow' };
},
},
];
}
function customerPostHooks(workspaceRoot, clientId) {
return [
// Log everything customers do for billing and security audit
{
async run(toolName, input, result) {
const logLine = `[${new Date().toISOString()}] client=${clientId} tool=${toolName} input=${JSON.stringify(input).slice(0, 300)}\n`;
appendFileSync(resolve(LOG_DIR, `client-${clientId}.log`), logLine);
return { action: 'pass' };
},
},
// Scrub any accidental credential leaks from results
{
async run(toolName, input, result) {
const resultStr = JSON.stringify(result);
// Check for patterns that look like API keys or passwords
const sensitivePatterns = [
/sk-ant-api\d+-[A-Za-z0-9_-]+/g,
/sk-proj-[A-Za-z0-9_-]+/g,
/sk-[A-Za-z0-9]{20,}/g,
/gsk_[A-Za-z0-9]{20,}/g,
/VENC1:[A-Za-z0-9+/=]+/g,
/GQES1:[A-Za-z0-9+/=]+/g,
/password['":\s]*[=:]\s*['"][^'"]{4,}/gi,
];
let scrubbed = false;
let cleanStr = resultStr;
for (const pattern of sensitivePatterns) {
if (pattern.test(cleanStr)) {
cleanStr = cleanStr.replace(pattern, '[REDACTED]');
scrubbed = true;
}
}
if (scrubbed) {
const logLine = `[${new Date().toISOString()}] CREDENTIAL_SCRUB client=${clientId} tool=${toolName}\n`;
appendFileSync(resolve(LOG_DIR, 'security-alerts.log'), logLine);
return { action: 'filter', result: JSON.parse(cleanStr) };
}
return { action: 'pass' };
},
},
];
}
// ═══════════════════════════════════════════════════════════════════════
// LOGGING
// ═══════════════════════════════════════════════════════════════════════
function logHookEvent(phase, tool, input, result, profile, clientId) {
const logLine = `[${new Date().toISOString()}] ${phase} profile=${profile} client=${clientId} tool=${tool} action=${result.action}${result.reason ? ` reason="${result.reason}"` : ''}\n`;
appendFileSync(resolve(LOG_DIR, 'hooks.log'), logLine);
}