- 7 source files: providers, tools (14), prompt, session, agent (core loop), cli, http - Multi-provider: Anthropic, OpenAI, Groq - 14 built-in tools for file ops, web, code execution - HTTP API + CLI interface
335 lines
14 KiB
JavaScript
335 lines
14 KiB
JavaScript
/**
|
|
* ═══════════════════════════════════════════════════════════════════════════
|
|
* 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);
|
|
}
|