mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 18:14:31 +00:00
fix(security): harden and refactor system.run command resolution
This commit is contained in:
@@ -1,40 +1,26 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolveAgentConfig } from "../agents/agent-scope.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { GatewayClient } from "../gateway/client.js";
|
||||
import {
|
||||
addAllowlistEntry,
|
||||
analyzeArgvCommand,
|
||||
evaluateExecAllowlist,
|
||||
evaluateShellAllowlist,
|
||||
requiresExecApproval,
|
||||
normalizeExecApprovals,
|
||||
mergeExecApprovalsSocketDefaults,
|
||||
recordAllowlistUse,
|
||||
resolveExecApprovals,
|
||||
resolveSafeBins,
|
||||
ensureExecApprovals,
|
||||
mergeExecApprovalsSocketDefaults,
|
||||
normalizeExecApprovals,
|
||||
readExecApprovalsSnapshot,
|
||||
saveExecApprovals,
|
||||
type ExecAsk,
|
||||
type ExecApprovalsFile,
|
||||
type ExecAllowlistEntry,
|
||||
type ExecCommandSegment,
|
||||
type ExecApprovalsResolved,
|
||||
type ExecSecurity,
|
||||
} from "../infra/exec-approvals.js";
|
||||
import {
|
||||
requestExecHostViaSocket,
|
||||
type ExecHostRequest,
|
||||
type ExecHostResponse,
|
||||
type ExecHostRunResult,
|
||||
} from "../infra/exec-host.js";
|
||||
import { getTrustedSafeBinDirs } from "../infra/exec-safe-bin-trust.js";
|
||||
import { sanitizeHostExecEnv } from "../infra/host-env-security.js";
|
||||
import { validateSystemRunCommandConsistency } from "../infra/system-run-command.js";
|
||||
import { runBrowserProxyCommand } from "./invoke-browser.js";
|
||||
import { handleSystemRunInvoke } from "./invoke-system-run.js";
|
||||
|
||||
const OUTPUT_CAP = 200_000;
|
||||
const OUTPUT_EVENT_TAIL = 20_000;
|
||||
@@ -336,7 +322,7 @@ async function sendExecFinishedEvent(params: {
|
||||
}
|
||||
|
||||
async function runViaMacAppExecHost(params: {
|
||||
approvals: ReturnType<typeof resolveExecApprovals>;
|
||||
approvals: ExecApprovalsResolved;
|
||||
request: ExecHostRequest;
|
||||
}): Promise<ExecHostResponse | null> {
|
||||
const { approvals, request } = params;
|
||||
@@ -483,308 +469,26 @@ export async function handleInvoke(
|
||||
return;
|
||||
}
|
||||
|
||||
const argv = params.command.map((item) => String(item));
|
||||
const rawCommand = typeof params.rawCommand === "string" ? params.rawCommand.trim() : "";
|
||||
const consistency = validateSystemRunCommandConsistency({
|
||||
argv,
|
||||
rawCommand: rawCommand || null,
|
||||
});
|
||||
if (!consistency.ok) {
|
||||
await sendErrorResult(client, frame, "INVALID_REQUEST", consistency.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const shellCommand = consistency.shellCommand;
|
||||
const cmdText = consistency.cmdText;
|
||||
const agentId = params.agentId?.trim() || undefined;
|
||||
const cfg = loadConfig();
|
||||
const agentExec = agentId ? resolveAgentConfig(cfg, agentId)?.tools?.exec : undefined;
|
||||
const configuredSecurity = resolveExecSecurity(agentExec?.security ?? cfg.tools?.exec?.security);
|
||||
const configuredAsk = resolveExecAsk(agentExec?.ask ?? cfg.tools?.exec?.ask);
|
||||
const approvals = resolveExecApprovals(agentId, {
|
||||
security: configuredSecurity,
|
||||
ask: configuredAsk,
|
||||
});
|
||||
const security = approvals.agent.security;
|
||||
const ask = approvals.agent.ask;
|
||||
const autoAllowSkills = approvals.agent.autoAllowSkills;
|
||||
const sessionKey = params.sessionKey?.trim() || "node";
|
||||
const runId = params.runId?.trim() || crypto.randomUUID();
|
||||
const env = sanitizeEnv(params.env ?? undefined);
|
||||
const safeBins = resolveSafeBins(agentExec?.safeBins ?? cfg.tools?.exec?.safeBins);
|
||||
const trustedSafeBinDirs = getTrustedSafeBinDirs();
|
||||
const bins = autoAllowSkills ? await skillBins.current() : new Set<string>();
|
||||
let analysisOk = false;
|
||||
let allowlistMatches: ExecAllowlistEntry[] = [];
|
||||
let allowlistSatisfied = false;
|
||||
let segments: ExecCommandSegment[] = [];
|
||||
if (shellCommand) {
|
||||
const allowlistEval = evaluateShellAllowlist({
|
||||
command: shellCommand,
|
||||
allowlist: approvals.allowlist,
|
||||
safeBins,
|
||||
cwd: params.cwd ?? undefined,
|
||||
env,
|
||||
trustedSafeBinDirs,
|
||||
skillBins: bins,
|
||||
autoAllowSkills,
|
||||
platform: process.platform,
|
||||
});
|
||||
analysisOk = allowlistEval.analysisOk;
|
||||
allowlistMatches = allowlistEval.allowlistMatches;
|
||||
allowlistSatisfied =
|
||||
security === "allowlist" && analysisOk ? allowlistEval.allowlistSatisfied : false;
|
||||
segments = allowlistEval.segments;
|
||||
} else {
|
||||
const analysis = analyzeArgvCommand({ argv, cwd: params.cwd ?? undefined, env });
|
||||
const allowlistEval = evaluateExecAllowlist({
|
||||
analysis,
|
||||
allowlist: approvals.allowlist,
|
||||
safeBins,
|
||||
cwd: params.cwd ?? undefined,
|
||||
trustedSafeBinDirs,
|
||||
skillBins: bins,
|
||||
autoAllowSkills,
|
||||
});
|
||||
analysisOk = analysis.ok;
|
||||
allowlistMatches = allowlistEval.allowlistMatches;
|
||||
allowlistSatisfied =
|
||||
security === "allowlist" && analysisOk ? allowlistEval.allowlistSatisfied : false;
|
||||
segments = analysis.segments;
|
||||
}
|
||||
const isWindows = process.platform === "win32";
|
||||
const cmdInvocation = shellCommand
|
||||
? isCmdExeInvocation(segments[0]?.argv ?? [])
|
||||
: isCmdExeInvocation(argv);
|
||||
if (security === "allowlist" && isWindows && cmdInvocation) {
|
||||
analysisOk = false;
|
||||
allowlistSatisfied = false;
|
||||
}
|
||||
|
||||
const useMacAppExec = process.platform === "darwin";
|
||||
if (useMacAppExec) {
|
||||
const approvalDecision =
|
||||
params.approvalDecision === "allow-once" || params.approvalDecision === "allow-always"
|
||||
? params.approvalDecision
|
||||
: null;
|
||||
const execRequest: ExecHostRequest = {
|
||||
command: argv,
|
||||
rawCommand: rawCommand || shellCommand || null,
|
||||
cwd: params.cwd ?? null,
|
||||
env: params.env ?? null,
|
||||
timeoutMs: params.timeoutMs ?? null,
|
||||
needsScreenRecording: params.needsScreenRecording ?? null,
|
||||
agentId: agentId ?? null,
|
||||
sessionKey: sessionKey ?? null,
|
||||
approvalDecision,
|
||||
};
|
||||
const response = await runViaMacAppExecHost({ approvals, request: execRequest });
|
||||
if (!response) {
|
||||
if (execHostEnforced || !execHostFallbackAllowed) {
|
||||
await sendNodeEvent(
|
||||
client,
|
||||
"exec.denied",
|
||||
buildExecEventPayload({
|
||||
sessionKey,
|
||||
runId,
|
||||
host: "node",
|
||||
command: cmdText,
|
||||
reason: "companion-unavailable",
|
||||
}),
|
||||
);
|
||||
await sendInvokeResult(client, frame, {
|
||||
ok: false,
|
||||
error: {
|
||||
code: "UNAVAILABLE",
|
||||
message: "COMPANION_APP_UNAVAILABLE: macOS app exec host unreachable",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else if (!response.ok) {
|
||||
const reason = response.error.reason ?? "approval-required";
|
||||
await sendNodeEvent(
|
||||
client,
|
||||
"exec.denied",
|
||||
buildExecEventPayload({
|
||||
sessionKey,
|
||||
runId,
|
||||
host: "node",
|
||||
command: cmdText,
|
||||
reason,
|
||||
}),
|
||||
);
|
||||
await sendInvokeResult(client, frame, {
|
||||
ok: false,
|
||||
error: { code: "UNAVAILABLE", message: response.error.message },
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
const result: ExecHostRunResult = response.payload;
|
||||
await handleSystemRunInvoke({
|
||||
client,
|
||||
params,
|
||||
skillBins,
|
||||
execHostEnforced,
|
||||
execHostFallbackAllowed,
|
||||
resolveExecSecurity,
|
||||
resolveExecAsk,
|
||||
isCmdExeInvocation,
|
||||
sanitizeEnv,
|
||||
runCommand,
|
||||
runViaMacAppExecHost,
|
||||
sendNodeEvent,
|
||||
buildExecEventPayload,
|
||||
sendInvokeResult: async (result) => {
|
||||
await sendInvokeResult(client, frame, result);
|
||||
},
|
||||
sendExecFinishedEvent: async ({ sessionKey, runId, cmdText, result }) => {
|
||||
await sendExecFinishedEvent({ client, sessionKey, runId, cmdText, result });
|
||||
await sendInvokeResult(client, frame, {
|
||||
ok: true,
|
||||
payloadJSON: JSON.stringify(result),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (security === "deny") {
|
||||
await sendNodeEvent(
|
||||
client,
|
||||
"exec.denied",
|
||||
buildExecEventPayload({
|
||||
sessionKey,
|
||||
runId,
|
||||
host: "node",
|
||||
command: cmdText,
|
||||
reason: "security=deny",
|
||||
}),
|
||||
);
|
||||
await sendInvokeResult(client, frame, {
|
||||
ok: false,
|
||||
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DISABLED: security=deny" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const requiresAsk = requiresExecApproval({
|
||||
ask,
|
||||
security,
|
||||
analysisOk,
|
||||
allowlistSatisfied,
|
||||
});
|
||||
|
||||
const approvalDecision =
|
||||
params.approvalDecision === "allow-once" || params.approvalDecision === "allow-always"
|
||||
? params.approvalDecision
|
||||
: null;
|
||||
const approvedByAsk = approvalDecision !== null || params.approved === true;
|
||||
if (requiresAsk && !approvedByAsk) {
|
||||
await sendNodeEvent(
|
||||
client,
|
||||
"exec.denied",
|
||||
buildExecEventPayload({
|
||||
sessionKey,
|
||||
runId,
|
||||
host: "node",
|
||||
command: cmdText,
|
||||
reason: "approval-required",
|
||||
}),
|
||||
);
|
||||
await sendInvokeResult(client, frame, {
|
||||
ok: false,
|
||||
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: approval required" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (approvalDecision === "allow-always" && security === "allowlist") {
|
||||
if (analysisOk) {
|
||||
for (const segment of segments) {
|
||||
const pattern = segment.resolution?.resolvedPath ?? "";
|
||||
if (pattern) {
|
||||
addAllowlistEntry(approvals.file, agentId, pattern);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (security === "allowlist" && (!analysisOk || !allowlistSatisfied) && !approvedByAsk) {
|
||||
await sendNodeEvent(
|
||||
client,
|
||||
"exec.denied",
|
||||
buildExecEventPayload({
|
||||
sessionKey,
|
||||
runId,
|
||||
host: "node",
|
||||
command: cmdText,
|
||||
reason: "allowlist-miss",
|
||||
}),
|
||||
);
|
||||
await sendInvokeResult(client, frame, {
|
||||
ok: false,
|
||||
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: allowlist miss" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (allowlistMatches.length > 0) {
|
||||
const seen = new Set<string>();
|
||||
for (const match of allowlistMatches) {
|
||||
if (!match?.pattern || seen.has(match.pattern)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(match.pattern);
|
||||
recordAllowlistUse(
|
||||
approvals.file,
|
||||
agentId,
|
||||
match,
|
||||
cmdText,
|
||||
segments[0]?.resolution?.resolvedPath,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (params.needsScreenRecording === true) {
|
||||
await sendNodeEvent(
|
||||
client,
|
||||
"exec.denied",
|
||||
buildExecEventPayload({
|
||||
sessionKey,
|
||||
runId,
|
||||
host: "node",
|
||||
command: cmdText,
|
||||
reason: "permission:screenRecording",
|
||||
}),
|
||||
);
|
||||
await sendInvokeResult(client, frame, {
|
||||
ok: false,
|
||||
error: { code: "UNAVAILABLE", message: "PERMISSION_MISSING: screenRecording" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let execArgv = argv;
|
||||
if (
|
||||
security === "allowlist" &&
|
||||
isWindows &&
|
||||
!approvedByAsk &&
|
||||
shellCommand &&
|
||||
analysisOk &&
|
||||
allowlistSatisfied &&
|
||||
segments.length === 1 &&
|
||||
segments[0]?.argv.length > 0
|
||||
) {
|
||||
execArgv = segments[0].argv;
|
||||
}
|
||||
|
||||
const result = await runCommand(
|
||||
execArgv,
|
||||
params.cwd?.trim() || undefined,
|
||||
env,
|
||||
params.timeoutMs ?? undefined,
|
||||
);
|
||||
if (result.truncated) {
|
||||
const suffix = "... (truncated)";
|
||||
if (result.stderr.trim().length > 0) {
|
||||
result.stderr = `${result.stderr}\n${suffix}`;
|
||||
} else {
|
||||
result.stdout = `${result.stdout}\n${suffix}`;
|
||||
}
|
||||
}
|
||||
await sendExecFinishedEvent({ client, sessionKey, runId, cmdText, result });
|
||||
|
||||
await sendInvokeResult(client, frame, {
|
||||
ok: true,
|
||||
payloadJSON: JSON.stringify({
|
||||
exitCode: result.exitCode,
|
||||
timedOut: result.timedOut,
|
||||
success: result.success,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
error: result.error ?? null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user