mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
549 lines
16 KiB
TypeScript
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;
|
|
},
|
|
});
|
|
}
|