UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

538 lines (537 loc) 20.2 kB
import { i as formatErrorMessage } from "./errors-BXgSefBE.js"; import { i as isPathInside } from "./path-BlG8lhgR.js"; import { a as inspectPathPermissions, d as safeStat } from "./permissions-ya3cPkFH.js"; import { p as resolveUserPath } from "./utils-CCC-BEJH.js"; import { s as resolveRuntimeServiceVersion } from "./version-Crcn9X9T.js"; import { i as normalizePositiveTimerMs, r as normalizePositiveInt } from "./shared-CuqTS6Vs.js"; import "./audit-fs-CBe_wA_B.js"; import "./scan-paths-Bve2UhXh.js"; import path from "node:path"; import fs from "node:fs/promises"; import { spawn } from "node:child_process"; //#region src/security/install-policy.ts const DEFAULT_TIMEOUT_MS = 1e4; const DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024; const DEFAULT_MAX_REQUEST_BYTES = 256 * 1024; const MAX_REASON_CHARS = 1e3; const MAX_FINDINGS = 100; const MAX_FINDING_TEXT_CHARS = 1e3; const WINDOWS_ABS_PATH_PATTERN = /^[A-Za-z]:[\\/]/; const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\\]+\\[^\\]+/; const POLICY_INTERPRETER_NAMES = new Set([ "bash", "bun", "deno", "env", "fish", "node", "perl", "powershell", "pwsh", "python", "python3", "ruby", "sh", "zsh" ]); const POLICY_SCRIPT_ARG_PATTERN = /\.(?:bash|cjs|cts|js|mjs|mts|pl|ps1|py|rb|sh|ts|zsh)$/i; function isAbsolutePathname(value) { if (path.isAbsolute(value)) return true; return process.platform === "win32" && (WINDOWS_ABS_PATH_PATTERN.test(value) || WINDOWS_UNC_PATH_PATTERN.test(value)); } function executableName(commandPath) { return path.basename(commandPath).replace(/\.exe$/i, "").toLowerCase(); } function isPolicyScriptArg(value) { return isAbsolutePathname(value) || value.startsWith(".") || value.includes("/") || value.includes("\\") || POLICY_SCRIPT_ARG_PATTERN.test(value); } function resolvePolicyScriptArg(params) { const interpreterName = executableName(params.command); const startIndex = 0; if (interpreterName === "env") return { kind: "unsupported", message: "security.installPolicy.exec.command must not use env; configure the policy executable directly." }; if (!POLICY_INTERPRETER_NAMES.has(interpreterName) || interpreterName === "env") return; const scripts = []; for (let index = startIndex; index < params.args.length; index += 1) { const arg = params.args[index]; if (!arg) continue; if (arg.startsWith("-")) { const equalsIndex = arg.indexOf("="); if (equalsIndex > 0) { const optionValue = arg.slice(equalsIndex + 1); if (isPolicyScriptArg(optionValue)) scripts.push({ index, path: optionValue }); } continue; } if (isPolicyScriptArg(arg)) scripts.push({ index, path: arg }); } return scripts.length > 0 ? { kind: "scripts", scripts } : void 0; } async function readFileStatOrThrow(pathname, label) { const stat = await safeStat(pathname); if (!stat.ok) throw new Error(`${label} is not readable: ${pathname}`); if (stat.isDir) throw new Error(`${label} must be a file: ${pathname}`); return stat; } function collectPathAncestorDirs(targetPath) { const dirs = []; let current = path.resolve(path.dirname(targetPath)); while (true) { dirs.push(current); const parent = path.dirname(current); if (parent === current) return dirs; current = parent; } } async function assertSecureCommandAncestorDirs(params) { const currentUid = typeof process.getuid === "function" ? process.getuid() : void 0; for (const dir of collectPathAncestorDirs(params.targetPath)) { const perms = await inspectPathPermissions(dir); if (!perms.ok) throw new Error(`${params.label} parent directory permissions could not be verified: ${dir}`); let sticky = false; if (process.platform !== "win32" && (perms.worldWritable || perms.groupWritable)) try { sticky = ((await fs.stat(dir)).mode & 512) !== 0; } catch { sticky = false; } if ((perms.worldWritable || perms.groupWritable) && !sticky) throw new Error(`${params.label} parent directory permissions are too open: ${dir}`); if (process.platform !== "win32" && currentUid !== void 0) { let stat; try { stat = await fs.stat(dir); } catch { throw new Error(`${params.label} parent directory ownership could not be verified: ${dir}`); } if (stat.uid !== 0 && stat.uid !== currentUid) throw new Error(`${params.label} parent directory owner is not trusted: ${dir}`); } if (process.platform === "win32" && perms.source === "unknown") throw new Error(`${params.label} parent directory ACL verification unavailable on Windows for ${dir}. Set allowInsecurePath=true for this policy to bypass this check when the path is trusted.`); } } async function assertSecureCommandPath(params) { if (!isAbsolutePathname(params.targetPath)) throw new Error(`${params.label} must be an absolute path.`); let effectivePath = params.targetPath; let stat = await readFileStatOrThrow(effectivePath, params.label); if (stat.isSymlink) { if (!params.allowSymlinkPath) throw new Error(`${params.label} must not be a symlink: ${effectivePath}`); try { effectivePath = await fs.realpath(effectivePath); } catch { throw new Error(`${params.label} symlink target is not readable: ${params.targetPath}`); } if (!isAbsolutePathname(effectivePath)) throw new Error(`${params.label} resolved symlink target must be an absolute path.`); stat = await readFileStatOrThrow(effectivePath, params.label); if (stat.isSymlink) throw new Error(`${params.label} symlink target must not be a symlink: ${effectivePath}`); } if (params.trustedDirs && params.trustedDirs.length > 0) { if (!params.trustedDirs.map((entry) => resolveUserPath(entry)).some((dir) => isPathInside(dir, effectivePath))) throw new Error(`${params.label} is outside trustedDirs: ${effectivePath}`); } if (params.allowInsecurePath) return effectivePath; const perms = await inspectPathPermissions(effectivePath); if (!perms.ok) throw new Error(`${params.label} permissions could not be verified: ${effectivePath}`); if (perms.worldWritable || perms.groupWritable) throw new Error(`${params.label} permissions are too open: ${effectivePath}`); await assertSecureCommandAncestorDirs({ targetPath: effectivePath, label: params.label }); if (process.platform === "win32" && perms.source === "unknown") throw new Error(`${params.label} ACL verification unavailable on Windows for ${effectivePath}. Set allowInsecurePath=true for this policy to bypass this check when the path is trusted.`); if (process.platform !== "win32" && typeof process.getuid === "function" && stat.uid != null) { const uid = process.getuid(); if (stat.uid !== uid && stat.uid !== 0) throw new Error(`${params.label} must be owned by the current user (uid=${uid}) or root: ${effectivePath}`); } return effectivePath; } async function assertSecurePolicyScriptArg(params) { const scriptArg = resolvePolicyScriptArg({ command: params.command, args: params.args }); if (!scriptArg) return; if (scriptArg.kind === "unsupported") throw new Error(scriptArg.message); for (const script of scriptArg.scripts) await assertSecureCommandPath({ targetPath: script.path, label: `security.installPolicy.exec.args[${script.index}]`, trustedDirs: params.trustedDirs, allowInsecurePath: params.allowInsecurePath, allowSymlinkPath: false }); } function truncateText(value, maxChars) { return value.length <= maxChars ? value : `${value.slice(0, maxChars)}...`; } function createPolicyChildEnv(sourceEnv) { return {}; } function readPassEnvValue(env, key) { const exact = env[key]; if (exact !== void 0 || process.platform !== "win32") return exact; const lowerKey = key.toLowerCase(); const matchedKey = Object.keys(env).find((candidate) => candidate.toLowerCase() === lowerKey); return matchedKey ? env[matchedKey] : void 0; } function blockedByFailure(message) { return { blocked: { code: "security_scan_failed", reason: `install policy failed closed: ${truncateText(message, MAX_REASON_CHARS)}` } }; } function blockedByPolicy(reason, findings) { return { blocked: { code: "security_scan_blocked", reason: `blocked by install policy: ${truncateText(reason, MAX_REASON_CHARS)}` }, ...findings && findings.length > 0 ? { findings } : {} }; } function isTargetEnabled(params) { const targets = params.policy.targets; if (!targets || targets.length === 0) return true; return targets.includes(params.targetType); } function resolvePolicy(config, targetType) { const policy = config?.security?.installPolicy; if (!policy || policy.enabled !== true) return { kind: "disabled" }; if (!isTargetEnabled({ policy, targetType })) return { kind: "disabled" }; if (!policy.exec) return { kind: "failure", result: blockedByFailure("security.installPolicy is enabled but security.installPolicy.exec is not configured") }; return { kind: "configured", exec: policy.exec }; } function resolveConfiguredTargets(policy) { const targets = policy.targets; return targets && targets.length > 0 ? [...new Set(targets)] : ["skill", "plugin"]; } async function validateInstallPolicyStatic(config) { const policy = config?.security?.installPolicy; if (!policy || policy.enabled !== true) return { enabled: false, targets: [], issues: [] }; const targets = resolveConfiguredTargets(policy); const issues = []; if (!policy.exec) { issues.push({ severity: "error", message: "security.installPolicy is enabled but security.installPolicy.exec is not configured." }); return { enabled: true, targets, issues }; } if (!isAbsolutePathname(policy.exec.command)) { issues.push({ severity: "error", message: "security.installPolicy.exec.command must be an absolute path." }); return { enabled: true, targets, issues }; } try { await assertSecureCommandPath({ targetPath: policy.exec.command, label: "security.installPolicy.exec.command", trustedDirs: policy.exec.trustedDirs, allowInsecurePath: policy.exec.allowInsecurePath, allowSymlinkPath: policy.exec.allowSymlinkCommand }); } catch (err) { issues.push({ severity: "error", message: formatErrorMessage(err) }); } try { await assertSecurePolicyScriptArg({ command: policy.exec.command, args: policy.exec.args ?? [], trustedDirs: policy.exec.trustedDirs, allowInsecurePath: policy.exec.allowInsecurePath, allowSymlinkPath: policy.exec.allowSymlinkCommand }); } catch (err) { issues.push({ severity: "error", message: formatErrorMessage(err) }); } return { enabled: true, targets, issues }; } function isIgnorableStdinWriteError(error) { if (typeof error !== "object" || error === null || !("code" in error)) return false; const code = String(error.code); return code === "EPIPE" || code === "ERR_STREAM_DESTROYED"; } async function runPolicyCommand(params) { return await new Promise((resolve, reject) => { const child = spawn(params.command, params.args, { cwd: params.cwd, env: params.env, stdio: [ "pipe", "pipe", "pipe" ], shell: false, windowsHide: true }); let settled = false; let stdout = ""; let stderr = ""; let timedOut = false; let noOutputTimedOut = false; let outputBytes = 0; let noOutputTimer = null; const timeoutTimer = setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, params.timeoutMs); const clearTimers = () => { clearTimeout(timeoutTimer); if (noOutputTimer) { clearTimeout(noOutputTimer); noOutputTimer = null; } }; const armNoOutputTimer = () => { if (noOutputTimer) clearTimeout(noOutputTimer); noOutputTimer = setTimeout(() => { noOutputTimedOut = true; child.kill("SIGKILL"); }, params.noOutputTimeoutMs); }; const append = (chunk, target) => { const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); outputBytes += Buffer.byteLength(text, "utf8"); if (outputBytes > params.maxOutputBytes) { child.kill("SIGKILL"); if (!settled) { settled = true; clearTimers(); reject(/* @__PURE__ */ new Error(`output exceeded maxOutputBytes (${params.maxOutputBytes})`)); } return; } if (target === "stdout") stdout += text; else stderr += text; armNoOutputTimer(); }; armNoOutputTimer(); child.on("error", (error) => { if (settled) return; settled = true; clearTimers(); reject(error); }); child.stdout?.on("data", (chunk) => append(chunk, "stdout")); child.stderr?.on("data", (chunk) => append(chunk, "stderr")); child.on("close", (code, signal) => { if (settled) return; settled = true; clearTimers(); resolve({ stdout, stderr, code, signal, termination: noOutputTimedOut ? "no-output-timeout" : timedOut ? "timeout" : "exit" }); }); const handleStdinError = (error) => { if (isIgnorableStdinWriteError(error) || settled) return; settled = true; clearTimers(); reject(error instanceof Error ? error : new Error(String(error))); }; child.stdin?.on("error", handleStdinError); try { child.stdin?.end(params.input); } catch (error) { handleStdinError(error); } }); } function normalizeFinding(value) { if (typeof value !== "object" || value === null) return null; const record = value; const ruleId = typeof record.ruleId === "string" ? record.ruleId.trim() : ""; const severity = record.severity; const file = typeof record.file === "string" ? record.file.trim() : ""; const lineNumber = typeof record.line === "number" && Number.isFinite(record.line) ? Math.max(1, Math.floor(record.line)) : void 0; const message = typeof record.message === "string" ? record.message.trim() : ""; if (!ruleId || !message || severity !== "info" && severity !== "warn" && severity !== "critical") return null; const evidence = typeof record.evidence === "string" ? record.evidence.trim() : ""; return { ruleId: truncateText(ruleId, MAX_FINDING_TEXT_CHARS), severity, message: truncateText(message, MAX_FINDING_TEXT_CHARS), ...file ? { file: truncateText(file, MAX_FINDING_TEXT_CHARS) } : {}, ...lineNumber ? { line: lineNumber } : {}, ...evidence ? { evidence: truncateText(evidence, MAX_FINDING_TEXT_CHARS) } : {} }; } function parsePolicyResponse(stdout) { const trimmed = stdout.trim(); if (!trimmed) return blockedByFailure("policy command returned empty stdout"); let parsed; try { parsed = JSON.parse(trimmed); } catch (err) { return blockedByFailure(`policy command returned invalid JSON (${formatErrorMessage(err)})`); } if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return blockedByFailure("policy response must be a JSON object"); const record = parsed; if (record.protocolVersion !== 1) return blockedByFailure("policy response protocolVersion must be 1"); const decision = record.decision; if (decision !== "allow" && decision !== "block") return blockedByFailure("policy response decision must be \"allow\" or \"block\""); const normalizedFindings = Array.isArray(record.findings) ? record.findings.slice(0, MAX_FINDINGS).map(normalizeFinding).filter(Boolean) : []; if (decision === "allow") return normalizedFindings.length > 0 ? { findings: normalizedFindings } : {}; const reason = typeof record.reason === "string" ? record.reason.trim() : ""; if (!reason) return blockedByFailure("policy response decision \"block\" requires a non-empty reason"); return blockedByPolicy(reason, normalizedFindings); } async function runInstallPolicy(params) { const decisionContext = formatDecisionContext(params.request); const logBlocked = (result) => { if (result.blocked) params.logger?.warn?.(`Install policy ${decisionContext}: ${result.blocked.reason}`); return result; }; const failClosed = (message) => logBlocked(blockedByFailure(message)); let config = params.config; if (!config) try { const { getRuntimeConfig } = await import("./io-BYWoyJ4I.js"); config = getRuntimeConfig({ skipPluginValidation: true }); } catch (err) { return failClosed(`could not load OpenClaw config (${formatErrorMessage(err)})`); } const policy = resolvePolicy(config, params.request.targetType); if (policy.kind === "disabled") return; if (policy.kind === "failure") return logBlocked(policy.result); const input = JSON.stringify({ protocolVersion: 1, openclawVersion: resolveRuntimeServiceVersion(params.env ?? process.env), ...params.request }); if (Buffer.byteLength(input, "utf8") > DEFAULT_MAX_REQUEST_BYTES) return failClosed(`policy request exceeded maxInputBytes (${DEFAULT_MAX_REQUEST_BYTES})`); const commandPath = policy.exec.command; if (!isAbsolutePathname(commandPath)) return failClosed("security.installPolicy.exec.command must be an absolute path."); let secureCommandPath; try { secureCommandPath = await assertSecureCommandPath({ targetPath: commandPath, label: "security.installPolicy.exec.command", trustedDirs: policy.exec.trustedDirs, allowInsecurePath: policy.exec.allowInsecurePath, allowSymlinkPath: policy.exec.allowSymlinkCommand }); } catch (err) { return failClosed(formatErrorMessage(err)); } try { await assertSecurePolicyScriptArg({ command: secureCommandPath, args: policy.exec.args ?? [], trustedDirs: policy.exec.trustedDirs, allowInsecurePath: policy.exec.allowInsecurePath, allowSymlinkPath: policy.exec.allowSymlinkCommand }); } catch (err) { return failClosed(formatErrorMessage(err)); } const env = params.env ?? process.env; const childEnv = createPolicyChildEnv(env); for (const key of policy.exec.passEnv ?? []) { const value = readPassEnvValue(env, key); if (value !== void 0) childEnv[key] = value; } for (const [key, value] of Object.entries(policy.exec.env ?? {})) childEnv[key] = value; const timeoutMs = normalizePositiveTimerMs(policy.exec.timeoutMs, DEFAULT_TIMEOUT_MS); const noOutputTimeoutMs = normalizePositiveTimerMs(policy.exec.noOutputTimeoutMs, timeoutMs); const maxOutputBytes = normalizePositiveInt(policy.exec.maxOutputBytes, DEFAULT_MAX_OUTPUT_BYTES); const cwd = path.dirname(secureCommandPath); let result; try { result = await runPolicyCommand({ command: secureCommandPath, args: policy.exec.args ?? [], cwd, env: childEnv, input, timeoutMs, noOutputTimeoutMs, maxOutputBytes }); } catch (err) { return failClosed(formatErrorMessage(err)); } if (result.termination === "timeout") return failClosed(`policy command timed out after ${timeoutMs}ms`); if (result.termination === "no-output-timeout") return failClosed(`policy command produced no output for ${noOutputTimeoutMs}ms`); if (result.code !== 0) return failClosed(`policy command exited with code ${String(result.code)}`); const parsed = parsePolicyResponse(result.stdout); if (parsed.blocked) return logBlocked(parsed); params.logger?.debug?.(`Install policy ${decisionContext}: allowed`); return parsed; } function formatDecisionContext(request) { const source = request.source ? ` source=${request.source.kind}/${request.source.authority}` : ""; const origin = typeof request.origin.type === "string" ? request.origin.type : "unknown"; return [ `target=${request.targetType}:${request.targetName}`, `request=${request.request.kind}/${request.request.mode}`, `origin=${origin}`, `pathKind=${request.sourcePathKind}`, source.trim() ].filter(Boolean).join(" "); } async function probeInstallPolicy(params) { const validation = await validateInstallPolicyStatic(params.config); if (!validation.enabled || validation.issues.some((issue) => issue.severity === "error")) return; const targetType = validation.targets.includes("skill") ? "skill" : validation.targets[0]; if (!targetType) return; return await runInstallPolicy({ config: params.config, env: params.env, logger: params.logger, request: { targetType, targetName: "doctor-install-policy-probe", sourcePath: params.sourcePath, sourcePathKind: "directory", origin: { type: "doctor" }, request: { kind: targetType === "skill" ? "skill-install" : "plugin-dir", mode: "install", requestedSpecifier: "doctor:install-policy-probe" } } }); } //#endregion export { runInstallPolicy as n, validateInstallPolicyStatic as r, probeInstallPolicy as t };