Files
moltbot/src/infra/exec-approvals.ts
2026-02-26 18:09:01 +01:00

549 lines
16 KiB
TypeScript

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 SystemRunApprovalBindingV1 = {
version: 1;
argv: string[];
cwd: string | null;
agentId: string | null;
sessionKey: string | null;
envHash: string | null;
};
export type ExecApprovalRequestPayload = {
command: string;
commandArgv?: string[];
// Optional UI-safe env key preview for approval prompts.
envKeys?: string[];
systemRunBindingV1?: SystemRunApprovalBindingV1 | null;
cwd?: string | null;
nodeId?: string | null;
host?: string | null;
security?: string | null;
ask?: string | null;
agentId?: string | null;
resolvedPath?: string | null;
sessionKey?: string | null;
turnSourceChannel?: string | null;
turnSourceTo?: string | null;
turnSourceAccountId?: string | null;
turnSourceThreadId?: string | number | null;
};
export type ExecApprovalRequest = {
id: string;
request: ExecApprovalRequestPayload;
createdAtMs: number;
expiresAtMs: number;
};
export type ExecApprovalResolved = {
id: string;
decision: ExecApprovalDecision;
resolvedBy?: string | null;
ts: number;
request?: ExecApprovalRequest["request"];
};
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<string, ExecApprovalsAgent>;
};
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<ExecApprovalsDefaults>;
agent: Required<ExecApprovalsDefaults>;
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<string>();
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<ExecApprovalsDefaults> = {
security: normalizeSecurity(defaults.security, fallbackSecurity),
ask: normalizeAsk(defaults.ask, fallbackAsk),
askFallback: normalizeSecurity(
defaults.askFallback ?? fallbackAskFallback,
fallbackAskFallback,
),
autoAllowSkills: Boolean(defaults.autoAllowSkills ?? fallbackAutoAllowSkills),
};
const resolvedAgent: Required<ExecApprovalsDefaults> = {
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<ExecSecurity, number> = { deny: 0, allowlist: 1, full: 2 };
return order[a] <= order[b] ? a : b;
}
export function maxAsk(a: ExecAsk, b: ExecAsk): ExecAsk {
const order: Record<ExecAsk, number> = { 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<string, unknown>;
timeoutMs?: number;
}): Promise<ExecApprovalDecision | null> {
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;
},
});
}