import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { DEFAULT_AGENT_ID } from "../routing/session-key.js"; import { expandHomePrefix } from "./home-dir.js"; import { requestJsonlSocket } from "./jsonl-socket.js"; export * from "./exec-approvals-analysis.js"; export * from "./exec-approvals-allowlist.js"; export type ExecHost = "sandbox" | "gateway" | "node"; export type ExecSecurity = "deny" | "allowlist" | "full"; export type ExecAsk = "off" | "on-miss" | "always"; export type ExecApprovalRequest = { id: string; request: { command: string; cwd?: string | null; host?: string | null; security?: string | null; ask?: string | null; agentId?: string | null; resolvedPath?: string | null; sessionKey?: string | null; }; createdAtMs: number; expiresAtMs: number; }; export type ExecApprovalResolved = { id: string; decision: ExecApprovalDecision; resolvedBy?: string | null; ts: number; }; export type ExecApprovalsDefaults = { security?: ExecSecurity; ask?: ExecAsk; askFallback?: ExecSecurity; autoAllowSkills?: boolean; }; export type ExecAllowlistEntry = { id?: string; pattern: string; lastUsedAt?: number; lastUsedCommand?: string; lastResolvedPath?: string; }; export type ExecApprovalsAgent = ExecApprovalsDefaults & { allowlist?: ExecAllowlistEntry[]; }; export type ExecApprovalsFile = { version: 1; socket?: { path?: string; token?: string; }; defaults?: ExecApprovalsDefaults; agents?: Record; }; export type ExecApprovalsSnapshot = { path: string; exists: boolean; raw: string | null; file: ExecApprovalsFile; hash: string; }; export type ExecApprovalsResolved = { path: string; socketPath: string; token: string; defaults: Required; agent: Required; allowlist: ExecAllowlistEntry[]; file: ExecApprovalsFile; }; // Keep CLI + gateway defaults in sync. export const DEFAULT_EXEC_APPROVAL_TIMEOUT_MS = 120_000; const DEFAULT_SECURITY: ExecSecurity = "deny"; const DEFAULT_ASK: ExecAsk = "on-miss"; const DEFAULT_ASK_FALLBACK: ExecSecurity = "deny"; const DEFAULT_AUTO_ALLOW_SKILLS = false; const DEFAULT_SOCKET = "~/.openclaw/exec-approvals.sock"; const DEFAULT_FILE = "~/.openclaw/exec-approvals.json"; function hashExecApprovalsRaw(raw: string | null): string { return crypto .createHash("sha256") .update(raw ?? "") .digest("hex"); } export function resolveExecApprovalsPath(): string { return expandHomePrefix(DEFAULT_FILE); } export function resolveExecApprovalsSocketPath(): string { return expandHomePrefix(DEFAULT_SOCKET); } function normalizeAllowlistPattern(value: string | undefined): string | null { const trimmed = value?.trim() ?? ""; return trimmed ? trimmed.toLowerCase() : null; } function mergeLegacyAgent( current: ExecApprovalsAgent, legacy: ExecApprovalsAgent, ): ExecApprovalsAgent { const allowlist: ExecAllowlistEntry[] = []; const seen = new Set(); const pushEntry = (entry: ExecAllowlistEntry) => { const key = normalizeAllowlistPattern(entry.pattern); if (!key || seen.has(key)) { return; } seen.add(key); allowlist.push(entry); }; for (const entry of current.allowlist ?? []) { pushEntry(entry); } for (const entry of legacy.allowlist ?? []) { pushEntry(entry); } return { security: current.security ?? legacy.security, ask: current.ask ?? legacy.ask, askFallback: current.askFallback ?? legacy.askFallback, autoAllowSkills: current.autoAllowSkills ?? legacy.autoAllowSkills, allowlist: allowlist.length > 0 ? allowlist : undefined, }; } function ensureDir(filePath: string) { const dir = path.dirname(filePath); fs.mkdirSync(dir, { recursive: true }); } // Coerce legacy/corrupted allowlists into `ExecAllowlistEntry[]` before we spread // entries to add ids (spreading strings creates {"0":"l","1":"s",...}). function coerceAllowlistEntries(allowlist: unknown): ExecAllowlistEntry[] | undefined { if (!Array.isArray(allowlist) || allowlist.length === 0) { return Array.isArray(allowlist) ? (allowlist as ExecAllowlistEntry[]) : undefined; } let changed = false; const result: ExecAllowlistEntry[] = []; for (const item of allowlist) { if (typeof item === "string") { const trimmed = item.trim(); if (trimmed) { result.push({ pattern: trimmed }); changed = true; } else { changed = true; // dropped empty string } } else if (item && typeof item === "object" && !Array.isArray(item)) { const pattern = (item as { pattern?: unknown }).pattern; if (typeof pattern === "string" && pattern.trim().length > 0) { result.push(item as ExecAllowlistEntry); } else { changed = true; // dropped invalid entry } } else { changed = true; // dropped invalid entry } } return changed ? (result.length > 0 ? result : undefined) : (allowlist as ExecAllowlistEntry[]); } function ensureAllowlistIds( allowlist: ExecAllowlistEntry[] | undefined, ): ExecAllowlistEntry[] | undefined { if (!Array.isArray(allowlist) || allowlist.length === 0) { return allowlist; } let changed = false; const next = allowlist.map((entry) => { if (entry.id) { return entry; } changed = true; return { ...entry, id: crypto.randomUUID() }; }); return changed ? next : allowlist; } export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile { const socketPath = file.socket?.path?.trim(); const token = file.socket?.token?.trim(); const agents = { ...file.agents }; const legacyDefault = agents.default; if (legacyDefault) { const main = agents[DEFAULT_AGENT_ID]; agents[DEFAULT_AGENT_ID] = main ? mergeLegacyAgent(main, legacyDefault) : legacyDefault; delete agents.default; } for (const [key, agent] of Object.entries(agents)) { const coerced = coerceAllowlistEntries(agent.allowlist); const allowlist = ensureAllowlistIds(coerced); if (allowlist !== agent.allowlist) { agents[key] = { ...agent, allowlist }; } } const normalized: ExecApprovalsFile = { version: 1, socket: { path: socketPath && socketPath.length > 0 ? socketPath : undefined, token: token && token.length > 0 ? token : undefined, }, defaults: { security: file.defaults?.security, ask: file.defaults?.ask, askFallback: file.defaults?.askFallback, autoAllowSkills: file.defaults?.autoAllowSkills, }, agents, }; return normalized; } export function mergeExecApprovalsSocketDefaults(params: { normalized: ExecApprovalsFile; current?: ExecApprovalsFile; }): ExecApprovalsFile { const currentSocketPath = params.current?.socket?.path?.trim(); const currentToken = params.current?.socket?.token?.trim(); const socketPath = params.normalized.socket?.path?.trim() ?? currentSocketPath ?? resolveExecApprovalsSocketPath(); const token = params.normalized.socket?.token?.trim() ?? currentToken ?? ""; return { ...params.normalized, socket: { path: socketPath, token, }, }; } function generateToken(): string { return crypto.randomBytes(24).toString("base64url"); } export function readExecApprovalsSnapshot(): ExecApprovalsSnapshot { const filePath = resolveExecApprovalsPath(); if (!fs.existsSync(filePath)) { const file = normalizeExecApprovals({ version: 1, agents: {} }); return { path: filePath, exists: false, raw: null, file, hash: hashExecApprovalsRaw(null), }; } const raw = fs.readFileSync(filePath, "utf8"); let parsed: ExecApprovalsFile | null = null; try { parsed = JSON.parse(raw) as ExecApprovalsFile; } catch { parsed = null; } const file = parsed?.version === 1 ? normalizeExecApprovals(parsed) : normalizeExecApprovals({ version: 1, agents: {} }); return { path: filePath, exists: true, raw, file, hash: hashExecApprovalsRaw(raw), }; } export function loadExecApprovals(): ExecApprovalsFile { const filePath = resolveExecApprovalsPath(); try { if (!fs.existsSync(filePath)) { return normalizeExecApprovals({ version: 1, agents: {} }); } const raw = fs.readFileSync(filePath, "utf8"); const parsed = JSON.parse(raw) as ExecApprovalsFile; if (parsed?.version !== 1) { return normalizeExecApprovals({ version: 1, agents: {} }); } return normalizeExecApprovals(parsed); } catch { return normalizeExecApprovals({ version: 1, agents: {} }); } } export function saveExecApprovals(file: ExecApprovalsFile) { const filePath = resolveExecApprovalsPath(); ensureDir(filePath); fs.writeFileSync(filePath, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 }); try { fs.chmodSync(filePath, 0o600); } catch { // best-effort on platforms without chmod } } export function ensureExecApprovals(): ExecApprovalsFile { const loaded = loadExecApprovals(); const next = normalizeExecApprovals(loaded); const socketPath = next.socket?.path?.trim(); const token = next.socket?.token?.trim(); const updated: ExecApprovalsFile = { ...next, socket: { path: socketPath && socketPath.length > 0 ? socketPath : resolveExecApprovalsSocketPath(), token: token && token.length > 0 ? token : generateToken(), }, }; saveExecApprovals(updated); return updated; } function normalizeSecurity(value: ExecSecurity | undefined, fallback: ExecSecurity): ExecSecurity { if (value === "allowlist" || value === "full" || value === "deny") { return value; } return fallback; } function normalizeAsk(value: ExecAsk | undefined, fallback: ExecAsk): ExecAsk { if (value === "always" || value === "off" || value === "on-miss") { return value; } return fallback; } export type ExecApprovalsDefaultOverrides = { security?: ExecSecurity; ask?: ExecAsk; askFallback?: ExecSecurity; autoAllowSkills?: boolean; }; export function resolveExecApprovals( agentId?: string, overrides?: ExecApprovalsDefaultOverrides, ): ExecApprovalsResolved { const file = ensureExecApprovals(); return resolveExecApprovalsFromFile({ file, agentId, overrides, path: resolveExecApprovalsPath(), socketPath: expandHomePrefix(file.socket?.path ?? resolveExecApprovalsSocketPath()), token: file.socket?.token ?? "", }); } export function resolveExecApprovalsFromFile(params: { file: ExecApprovalsFile; agentId?: string; overrides?: ExecApprovalsDefaultOverrides; path?: string; socketPath?: string; token?: string; }): ExecApprovalsResolved { const file = normalizeExecApprovals(params.file); const defaults = file.defaults ?? {}; const agentKey = params.agentId ?? DEFAULT_AGENT_ID; const agent = file.agents?.[agentKey] ?? {}; const wildcard = file.agents?.["*"] ?? {}; const fallbackSecurity = params.overrides?.security ?? DEFAULT_SECURITY; const fallbackAsk = params.overrides?.ask ?? DEFAULT_ASK; const fallbackAskFallback = params.overrides?.askFallback ?? DEFAULT_ASK_FALLBACK; const fallbackAutoAllowSkills = params.overrides?.autoAllowSkills ?? DEFAULT_AUTO_ALLOW_SKILLS; const resolvedDefaults: Required = { security: normalizeSecurity(defaults.security, fallbackSecurity), ask: normalizeAsk(defaults.ask, fallbackAsk), askFallback: normalizeSecurity( defaults.askFallback ?? fallbackAskFallback, fallbackAskFallback, ), autoAllowSkills: Boolean(defaults.autoAllowSkills ?? fallbackAutoAllowSkills), }; const resolvedAgent: Required = { security: normalizeSecurity( agent.security ?? wildcard.security ?? resolvedDefaults.security, resolvedDefaults.security, ), ask: normalizeAsk(agent.ask ?? wildcard.ask ?? resolvedDefaults.ask, resolvedDefaults.ask), askFallback: normalizeSecurity( agent.askFallback ?? wildcard.askFallback ?? resolvedDefaults.askFallback, resolvedDefaults.askFallback, ), autoAllowSkills: Boolean( agent.autoAllowSkills ?? wildcard.autoAllowSkills ?? resolvedDefaults.autoAllowSkills, ), }; const allowlist = [ ...(Array.isArray(wildcard.allowlist) ? wildcard.allowlist : []), ...(Array.isArray(agent.allowlist) ? agent.allowlist : []), ]; return { path: params.path ?? resolveExecApprovalsPath(), socketPath: expandHomePrefix( params.socketPath ?? file.socket?.path ?? resolveExecApprovalsSocketPath(), ), token: params.token ?? file.socket?.token ?? "", defaults: resolvedDefaults, agent: resolvedAgent, allowlist, file, }; } export function requiresExecApproval(params: { ask: ExecAsk; security: ExecSecurity; analysisOk: boolean; allowlistSatisfied: boolean; }): boolean { return ( params.ask === "always" || (params.ask === "on-miss" && params.security === "allowlist" && (!params.analysisOk || !params.allowlistSatisfied)) ); } export function recordAllowlistUse( approvals: ExecApprovalsFile, agentId: string | undefined, entry: ExecAllowlistEntry, command: string, resolvedPath?: string, ) { const target = agentId ?? DEFAULT_AGENT_ID; const agents = approvals.agents ?? {}; const existing = agents[target] ?? {}; const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : []; const nextAllowlist = allowlist.map((item) => item.pattern === entry.pattern ? { ...item, id: item.id ?? crypto.randomUUID(), lastUsedAt: Date.now(), lastUsedCommand: command, lastResolvedPath: resolvedPath, } : item, ); agents[target] = { ...existing, allowlist: nextAllowlist }; approvals.agents = agents; saveExecApprovals(approvals); } export function addAllowlistEntry( approvals: ExecApprovalsFile, agentId: string | undefined, pattern: string, ) { const target = agentId ?? DEFAULT_AGENT_ID; const agents = approvals.agents ?? {}; const existing = agents[target] ?? {}; const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : []; const trimmed = pattern.trim(); if (!trimmed) { return; } if (allowlist.some((entry) => entry.pattern === trimmed)) { return; } allowlist.push({ id: crypto.randomUUID(), pattern: trimmed, lastUsedAt: Date.now() }); agents[target] = { ...existing, allowlist }; approvals.agents = agents; saveExecApprovals(approvals); } export function minSecurity(a: ExecSecurity, b: ExecSecurity): ExecSecurity { const order: Record = { deny: 0, allowlist: 1, full: 2 }; return order[a] <= order[b] ? a : b; } export function maxAsk(a: ExecAsk, b: ExecAsk): ExecAsk { const order: Record = { off: 0, "on-miss": 1, always: 2 }; return order[a] >= order[b] ? a : b; } export type ExecApprovalDecision = "allow-once" | "allow-always" | "deny"; export async function requestExecApprovalViaSocket(params: { socketPath: string; token: string; request: Record; timeoutMs?: number; }): Promise { const { socketPath, token, request } = params; if (!socketPath || !token) { return null; } const timeoutMs = params.timeoutMs ?? 15_000; const payload = JSON.stringify({ type: "request", token, id: crypto.randomUUID(), request, }); return await requestJsonlSocket({ socketPath, payload, timeoutMs, accept: (value) => { const msg = value as { type?: string; decision?: ExecApprovalDecision }; if (msg?.type === "decision" && msg.decision) { return msg.decision; } return undefined; }, }); }