Core rewrite: - agent.js: streaming, mid-loop compaction, context tracking, skill matching - providers.js: streamQuery() method with SSE events for Anthropic - index.js: v2 HTTP server with 12 endpoints (/health, /chat/stream, /tokens, etc.) - cli.js: --stream flag, /tokens, /context, /skills commands New services: - tokenEstimation.js: multi-strategy token counting with context warnings - messages.js: typed message system (user/assistant/system/compact/tombstone) - compact.js: 4-tier compaction engine (micro → auto → memory → cleanup) - contextTracker.js: file/git/error/discovery tracking with scoring - steering.js: per-tool safety rules (OWASP-aligned) - skillEngine.js: SKILL.md parser with keyword triggers and hot reload - agentFork.js: sub-agent spawning with persistent task tracking - redact.js: Aho-Corasick secret scrubbing from tool outputs - intent.js, memory.js, permissions.js, costTracker.js, modelRouter.js, doctor.js
357 lines
11 KiB
JavaScript
357 lines
11 KiB
JavaScript
/**
|
|
* ═══════════════════════════════════════════════════════════════════════════
|
|
* ALFRED AGENT — Permissions & Approval Flow Service
|
|
*
|
|
* Tool-level access control with:
|
|
* - Commander (client_id=33): unrestricted, all tools allowed
|
|
* - Customer tier: sandboxed, dangerous ops require approval
|
|
* - Future: per-user rules, time-limited grants
|
|
*
|
|
* Pattern: preToolUse hook checks → allow/deny/ask
|
|
*
|
|
* Built by Commander Danny William Perez and Alfred.
|
|
* ═══════════════════════════════════════════════════════════════════════════
|
|
*/
|
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
import { join } from 'path';
|
|
|
|
const PERMISSIONS_DIR = join(process.env.HOME || '/tmp', 'alfred-agent', 'data', 'permissions');
|
|
|
|
// ── Default Permission Profiles ────────────────────────────────────────
|
|
|
|
const PROFILES = {
|
|
commander: {
|
|
name: 'Commander',
|
|
description: 'Full unrestricted access — Commander Danny (client_id=33)',
|
|
allowAll: true,
|
|
maxMultiplier: 600,
|
|
maxTokensPerQuery: 128000,
|
|
canApproveOthers: true,
|
|
canAccessVault: true,
|
|
canModifySystem: true,
|
|
canDeleteFiles: true,
|
|
canRunBash: true,
|
|
canRunDestructive: true,
|
|
canAccessAllDomains: true,
|
|
},
|
|
|
|
customer: {
|
|
name: 'Customer',
|
|
description: 'Sandboxed access — paying customer with workspace',
|
|
allowAll: false,
|
|
maxMultiplier: 120,
|
|
maxTokensPerQuery: 32000,
|
|
canApproveOthers: false,
|
|
canAccessVault: false,
|
|
canModifySystem: false,
|
|
canDeleteFiles: false, // Must approve each delete
|
|
canRunBash: true, // Sandboxed bash only
|
|
canRunDestructive: false,
|
|
canAccessAllDomains: false,
|
|
allowedTools: [
|
|
'read_file', 'write_file', 'edit_file', 'list_dir',
|
|
'bash', 'search', 'web_fetch', 'mcp_call',
|
|
'agent', 'task_create', 'task_update', 'task_list',
|
|
],
|
|
blockedTools: [
|
|
'vault_read', 'vault_write', 'system_config',
|
|
'pm2_control', 'database_admin',
|
|
],
|
|
// Bash command restrictions for customer profile
|
|
bashAllowPatterns: [
|
|
/^(ls|cat|head|tail|wc|grep|find|echo|pwd|date|whoami|node|npm|python3?|php|git)\b/,
|
|
],
|
|
bashBlockPatterns: [
|
|
/\brm\s+-rf?\s+\//, // No rm -r /
|
|
/\bsudo\b/, // No sudo
|
|
/\bchmod\s+[0-7]*7/, // No world-writable
|
|
/\bkill\b/, // No kill
|
|
/\bpkill\b/,
|
|
/\bsystemctl\b/,
|
|
/\biptables\b/,
|
|
/\bcrontab\b/,
|
|
/\bcurl.*\|\s*bash/, // No curl|bash
|
|
/\bwget.*\|\s*bash/,
|
|
/\bdd\s+if=/, // No dd
|
|
/\bmkfs\b/,
|
|
],
|
|
},
|
|
|
|
free: {
|
|
name: 'Free Tier',
|
|
description: 'Limited access — free account',
|
|
allowAll: false,
|
|
maxMultiplier: 30,
|
|
maxTokensPerQuery: 8192,
|
|
canApproveOthers: false,
|
|
canAccessVault: false,
|
|
canModifySystem: false,
|
|
canDeleteFiles: false,
|
|
canRunBash: false,
|
|
canRunDestructive: false,
|
|
canAccessAllDomains: false,
|
|
allowedTools: [
|
|
'read_file', 'write_file', 'edit_file', 'list_dir',
|
|
'search', 'web_fetch',
|
|
],
|
|
blockedTools: [
|
|
'bash', 'mcp_call', 'vault_read', 'vault_write',
|
|
'system_config', 'pm2_control', 'database_admin',
|
|
'agent', 'task_create',
|
|
],
|
|
},
|
|
};
|
|
|
|
// ── Per-User Rule Overrides ────────────────────────────────────────────
|
|
|
|
/**
|
|
* Load per-user permission overrides from disk.
|
|
*/
|
|
function loadUserRules(clientId) {
|
|
try {
|
|
const filePath = join(PERMISSIONS_DIR, `user-${clientId}.json`);
|
|
if (!existsSync(filePath)) return null;
|
|
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save per-user permission overrides.
|
|
*/
|
|
function saveUserRules(clientId, rules) {
|
|
mkdirSync(PERMISSIONS_DIR, { recursive: true });
|
|
const filePath = join(PERMISSIONS_DIR, `user-${clientId}.json`);
|
|
writeFileSync(filePath, JSON.stringify(rules, null, 2));
|
|
}
|
|
|
|
// ── Permission Engine ──────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Create a permission engine for a user session.
|
|
*
|
|
* @param {string} profile - 'commander' | 'customer' | 'free'
|
|
* @param {Object} opts
|
|
* @param {number} opts.clientId
|
|
* @param {string} opts.workspaceRoot - Sandbox root path
|
|
* @param {Function} opts.onApprovalNeeded - Called when user approval is needed
|
|
* Returns Promise<boolean> (true = approve, false = deny)
|
|
*/
|
|
export function createPermissionEngine(profile = 'commander', opts = {}) {
|
|
const baseProfile = PROFILES[profile] || PROFILES.customer;
|
|
const userRules = opts.clientId ? loadUserRules(opts.clientId) : null;
|
|
const workspaceRoot = opts.workspaceRoot || process.cwd();
|
|
const onApprovalNeeded = opts.onApprovalNeeded || (() => Promise.resolve(false));
|
|
|
|
// Merge user overrides onto base profile
|
|
const effectiveProfile = { ...baseProfile };
|
|
if (userRules) {
|
|
if (userRules.additionalTools) {
|
|
effectiveProfile.allowedTools = [
|
|
...(effectiveProfile.allowedTools || []),
|
|
...userRules.additionalTools,
|
|
];
|
|
}
|
|
if (userRules.maxMultiplier !== undefined) {
|
|
effectiveProfile.maxMultiplier = Math.min(
|
|
userRules.maxMultiplier,
|
|
baseProfile.maxMultiplier,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Approval log
|
|
const approvalLog = [];
|
|
|
|
/**
|
|
* Check if a tool call is permitted.
|
|
* Returns: { allowed: boolean, reason?: string, needsApproval?: boolean }
|
|
*/
|
|
function checkToolPermission(toolName, input = {}) {
|
|
// Commander gets everything
|
|
if (effectiveProfile.allowAll) {
|
|
return { allowed: true };
|
|
}
|
|
|
|
// Explicitly blocked tools
|
|
if (effectiveProfile.blockedTools?.includes(toolName)) {
|
|
return {
|
|
allowed: false,
|
|
reason: `Tool "${toolName}" is blocked for ${effectiveProfile.name} profile`,
|
|
};
|
|
}
|
|
|
|
// Check if tool is in allowed list
|
|
if (effectiveProfile.allowedTools && !effectiveProfile.allowedTools.includes(toolName)) {
|
|
return {
|
|
allowed: false,
|
|
reason: `Tool "${toolName}" is not in the allowed list for ${effectiveProfile.name}`,
|
|
};
|
|
}
|
|
|
|
// Special checks for bash commands
|
|
if (toolName === 'bash' && input.command) {
|
|
return checkBashPermission(input.command);
|
|
}
|
|
|
|
// File path sandboxing
|
|
if (['read_file', 'write_file', 'edit_file'].includes(toolName) && input.path) {
|
|
return checkPathPermission(input.path);
|
|
}
|
|
|
|
// Delete operations need approval
|
|
if (toolName === 'write_file' && input.path && !effectiveProfile.canDeleteFiles) {
|
|
// Writing empty content = effective delete
|
|
if (!input.content || input.content.trim() === '') {
|
|
return {
|
|
allowed: false,
|
|
needsApproval: true,
|
|
reason: `Deleting files requires approval for ${effectiveProfile.name}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
return { allowed: true };
|
|
}
|
|
|
|
/**
|
|
* Check bash command permission.
|
|
*/
|
|
function checkBashPermission(command) {
|
|
if (!effectiveProfile.canRunBash) {
|
|
return { allowed: false, reason: 'Bash access is not available on your plan' };
|
|
}
|
|
|
|
// Check block patterns
|
|
if (effectiveProfile.bashBlockPatterns) {
|
|
for (const pattern of effectiveProfile.bashBlockPatterns) {
|
|
if (pattern.test(command)) {
|
|
return {
|
|
allowed: false,
|
|
reason: `Command matches blocked pattern: ${pattern}`,
|
|
needsApproval: effectiveProfile.name === 'Customer',
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
return { allowed: true };
|
|
}
|
|
|
|
/**
|
|
* Check file path permission (sandboxing).
|
|
*/
|
|
function checkPathPermission(filePath) {
|
|
if (effectiveProfile.canAccessAllDomains) {
|
|
return { allowed: true };
|
|
}
|
|
|
|
// Must be under workspace root
|
|
const resolved = require('path').resolve(filePath);
|
|
if (!resolved.startsWith(workspaceRoot)) {
|
|
return {
|
|
allowed: false,
|
|
reason: `Access denied: ${filePath} is outside your workspace (${workspaceRoot})`,
|
|
};
|
|
}
|
|
|
|
// Block access to sensitive files
|
|
const sensitivePatterns = [
|
|
/\.env$/,
|
|
/credentials/i,
|
|
/\.key$/,
|
|
/\.pem$/,
|
|
/password/i,
|
|
/secret/i,
|
|
/\.htaccess$/,
|
|
];
|
|
|
|
for (const pattern of sensitivePatterns) {
|
|
if (pattern.test(filePath)) {
|
|
return {
|
|
allowed: false,
|
|
needsApproval: true,
|
|
reason: `Accessing sensitive file requires approval: ${filePath}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
return { allowed: true };
|
|
}
|
|
|
|
/**
|
|
* Validate multiplier against profile limits.
|
|
* Returns clamped multiplier.
|
|
*/
|
|
function clampMultiplier(requestedMultiplier) {
|
|
const max = effectiveProfile.maxMultiplier || 30;
|
|
return Math.min(Math.max(1, requestedMultiplier || 30), max);
|
|
}
|
|
|
|
/**
|
|
* Validate max tokens against profile limits.
|
|
*/
|
|
function clampMaxTokens(requestedTokens) {
|
|
const max = effectiveProfile.maxTokensPerQuery || 8192;
|
|
return Math.min(Math.max(256, requestedTokens || 8192), max);
|
|
}
|
|
|
|
/**
|
|
* Request approval for a blocked action.
|
|
* Records the result for audit.
|
|
*/
|
|
async function requestApproval(toolName, input, reason) {
|
|
const request = {
|
|
ts: Date.now(),
|
|
toolName,
|
|
input: JSON.stringify(input).slice(0, 500),
|
|
reason,
|
|
profile: effectiveProfile.name,
|
|
clientId: opts.clientId,
|
|
};
|
|
|
|
try {
|
|
const approved = await onApprovalNeeded(request);
|
|
request.result = approved ? 'approved' : 'denied';
|
|
approvalLog.push(request);
|
|
return approved;
|
|
} catch {
|
|
request.result = 'error';
|
|
approvalLog.push(request);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the effective profile for inspection.
|
|
*/
|
|
function getProfile() {
|
|
return {
|
|
...effectiveProfile,
|
|
// Don't expose regex patterns in JSON
|
|
bashAllowPatterns: undefined,
|
|
bashBlockPatterns: undefined,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get the approval audit log.
|
|
*/
|
|
function getApprovalLog() {
|
|
return [...approvalLog];
|
|
}
|
|
|
|
return {
|
|
checkToolPermission,
|
|
clampMultiplier,
|
|
clampMaxTokens,
|
|
requestApproval,
|
|
getProfile,
|
|
getApprovalLog,
|
|
saveUserRules: (rules) => saveUserRules(opts.clientId, rules),
|
|
};
|
|
}
|
|
|
|
export { PROFILES, loadUserRules, saveUserRules };
|