mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
499 lines
15 KiB
TypeScript
499 lines
15 KiB
TypeScript
import crypto from "node:crypto";
|
|
import { resolveAgentConfig } from "../agents/agent-scope.js";
|
|
import { loadConfig } from "../config/config.js";
|
|
import type { GatewayClient } from "../gateway/client.js";
|
|
import {
|
|
addAllowlistEntry,
|
|
analyzeArgvCommand,
|
|
evaluateExecAllowlist,
|
|
evaluateShellAllowlist,
|
|
recordAllowlistUse,
|
|
resolveAllowAlwaysPatterns,
|
|
resolveExecApprovals,
|
|
type ExecAllowlistEntry,
|
|
type ExecAsk,
|
|
type ExecCommandSegment,
|
|
type ExecSecurity,
|
|
type SkillBinTrustEntry,
|
|
} from "../infra/exec-approvals.js";
|
|
import type { ExecHostRequest, ExecHostResponse, ExecHostRunResult } from "../infra/exec-host.js";
|
|
import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js";
|
|
import { sanitizeSystemRunEnvOverrides } from "../infra/host-env-security.js";
|
|
import { resolveSystemRunCommand } from "../infra/system-run-command.js";
|
|
import { evaluateSystemRunPolicy, resolveExecApprovalDecision } from "./exec-policy.js";
|
|
import type {
|
|
ExecEventPayload,
|
|
RunResult,
|
|
SkillBinsProvider,
|
|
SystemRunParams,
|
|
} from "./invoke-types.js";
|
|
|
|
type SystemRunInvokeResult = {
|
|
ok: boolean;
|
|
payloadJSON?: string | null;
|
|
error?: { code?: string; message?: string } | null;
|
|
};
|
|
|
|
type SystemRunDeniedReason =
|
|
| "security=deny"
|
|
| "approval-required"
|
|
| "allowlist-miss"
|
|
| "execution-plan-miss"
|
|
| "companion-unavailable"
|
|
| "permission:screenRecording";
|
|
|
|
type SystemRunExecutionContext = {
|
|
sessionKey: string;
|
|
runId: string;
|
|
cmdText: string;
|
|
};
|
|
|
|
type SystemRunAllowlistAnalysis = {
|
|
analysisOk: boolean;
|
|
allowlistMatches: ExecAllowlistEntry[];
|
|
allowlistSatisfied: boolean;
|
|
segments: ExecCommandSegment[];
|
|
};
|
|
|
|
const safeBinTrustedDirWarningCache = new Set<string>();
|
|
|
|
function warnWritableTrustedDirOnce(message: string): void {
|
|
if (safeBinTrustedDirWarningCache.has(message)) {
|
|
return;
|
|
}
|
|
safeBinTrustedDirWarningCache.add(message);
|
|
console.warn(message);
|
|
}
|
|
|
|
function normalizeDeniedReason(reason: string | null | undefined): SystemRunDeniedReason {
|
|
switch (reason) {
|
|
case "security=deny":
|
|
case "approval-required":
|
|
case "allowlist-miss":
|
|
case "execution-plan-miss":
|
|
case "companion-unavailable":
|
|
case "permission:screenRecording":
|
|
return reason;
|
|
default:
|
|
return "approval-required";
|
|
}
|
|
}
|
|
|
|
export type HandleSystemRunInvokeOptions = {
|
|
client: GatewayClient;
|
|
params: SystemRunParams;
|
|
skillBins: SkillBinsProvider;
|
|
execHostEnforced: boolean;
|
|
execHostFallbackAllowed: boolean;
|
|
resolveExecSecurity: (value?: string) => ExecSecurity;
|
|
resolveExecAsk: (value?: string) => ExecAsk;
|
|
isCmdExeInvocation: (argv: string[]) => boolean;
|
|
sanitizeEnv: (overrides?: Record<string, string> | null) => Record<string, string> | undefined;
|
|
runCommand: (
|
|
argv: string[],
|
|
cwd: string | undefined,
|
|
env: Record<string, string> | undefined,
|
|
timeoutMs: number | undefined,
|
|
) => Promise<RunResult>;
|
|
runViaMacAppExecHost: (params: {
|
|
approvals: ReturnType<typeof resolveExecApprovals>;
|
|
request: ExecHostRequest;
|
|
}) => Promise<ExecHostResponse | null>;
|
|
sendNodeEvent: (client: GatewayClient, event: string, payload: unknown) => Promise<void>;
|
|
buildExecEventPayload: (payload: ExecEventPayload) => ExecEventPayload;
|
|
sendInvokeResult: (result: SystemRunInvokeResult) => Promise<void>;
|
|
sendExecFinishedEvent: (params: {
|
|
sessionKey: string;
|
|
runId: string;
|
|
cmdText: string;
|
|
result: {
|
|
stdout?: string;
|
|
stderr?: string;
|
|
error?: string | null;
|
|
exitCode?: number | null;
|
|
timedOut?: boolean;
|
|
success?: boolean;
|
|
};
|
|
}) => Promise<void>;
|
|
preferMacAppExecHost: boolean;
|
|
};
|
|
|
|
async function sendSystemRunDenied(
|
|
opts: Pick<
|
|
HandleSystemRunInvokeOptions,
|
|
"client" | "sendNodeEvent" | "buildExecEventPayload" | "sendInvokeResult"
|
|
>,
|
|
execution: SystemRunExecutionContext,
|
|
params: {
|
|
reason: SystemRunDeniedReason;
|
|
message: string;
|
|
},
|
|
) {
|
|
await opts.sendNodeEvent(
|
|
opts.client,
|
|
"exec.denied",
|
|
opts.buildExecEventPayload({
|
|
sessionKey: execution.sessionKey,
|
|
runId: execution.runId,
|
|
host: "node",
|
|
command: execution.cmdText,
|
|
reason: params.reason,
|
|
}),
|
|
);
|
|
await opts.sendInvokeResult({
|
|
ok: false,
|
|
error: { code: "UNAVAILABLE", message: params.message },
|
|
});
|
|
}
|
|
|
|
function evaluateSystemRunAllowlist(params: {
|
|
shellCommand: string | null;
|
|
argv: string[];
|
|
approvals: ReturnType<typeof resolveExecApprovals>;
|
|
security: ExecSecurity;
|
|
safeBins: ReturnType<typeof resolveExecSafeBinRuntimePolicy>["safeBins"];
|
|
safeBinProfiles: ReturnType<typeof resolveExecSafeBinRuntimePolicy>["safeBinProfiles"];
|
|
trustedSafeBinDirs: ReturnType<typeof resolveExecSafeBinRuntimePolicy>["trustedSafeBinDirs"];
|
|
cwd: string | undefined;
|
|
env: Record<string, string> | undefined;
|
|
skillBins: SkillBinTrustEntry[];
|
|
autoAllowSkills: boolean;
|
|
}): SystemRunAllowlistAnalysis {
|
|
if (params.shellCommand) {
|
|
const allowlistEval = evaluateShellAllowlist({
|
|
command: params.shellCommand,
|
|
allowlist: params.approvals.allowlist,
|
|
safeBins: params.safeBins,
|
|
safeBinProfiles: params.safeBinProfiles,
|
|
cwd: params.cwd,
|
|
env: params.env,
|
|
trustedSafeBinDirs: params.trustedSafeBinDirs,
|
|
skillBins: params.skillBins,
|
|
autoAllowSkills: params.autoAllowSkills,
|
|
platform: process.platform,
|
|
});
|
|
return {
|
|
analysisOk: allowlistEval.analysisOk,
|
|
allowlistMatches: allowlistEval.allowlistMatches,
|
|
allowlistSatisfied:
|
|
params.security === "allowlist" && allowlistEval.analysisOk
|
|
? allowlistEval.allowlistSatisfied
|
|
: false,
|
|
segments: allowlistEval.segments,
|
|
};
|
|
}
|
|
|
|
const analysis = analyzeArgvCommand({ argv: params.argv, cwd: params.cwd, env: params.env });
|
|
const allowlistEval = evaluateExecAllowlist({
|
|
analysis,
|
|
allowlist: params.approvals.allowlist,
|
|
safeBins: params.safeBins,
|
|
safeBinProfiles: params.safeBinProfiles,
|
|
cwd: params.cwd,
|
|
trustedSafeBinDirs: params.trustedSafeBinDirs,
|
|
skillBins: params.skillBins,
|
|
autoAllowSkills: params.autoAllowSkills,
|
|
});
|
|
return {
|
|
analysisOk: analysis.ok,
|
|
allowlistMatches: allowlistEval.allowlistMatches,
|
|
allowlistSatisfied:
|
|
params.security === "allowlist" && analysis.ok ? allowlistEval.allowlistSatisfied : false,
|
|
segments: analysis.segments,
|
|
};
|
|
}
|
|
|
|
function resolvePlannedAllowlistArgv(params: {
|
|
security: ExecSecurity;
|
|
shellCommand: string | null;
|
|
policy: {
|
|
approvedByAsk: boolean;
|
|
analysisOk: boolean;
|
|
allowlistSatisfied: boolean;
|
|
};
|
|
segments: ExecCommandSegment[];
|
|
}): string[] | undefined | null {
|
|
if (
|
|
params.security !== "allowlist" ||
|
|
params.policy.approvedByAsk ||
|
|
params.shellCommand ||
|
|
!params.policy.analysisOk ||
|
|
!params.policy.allowlistSatisfied ||
|
|
params.segments.length !== 1
|
|
) {
|
|
return undefined;
|
|
}
|
|
const plannedAllowlistArgv = params.segments[0]?.resolution?.effectiveArgv;
|
|
return plannedAllowlistArgv && plannedAllowlistArgv.length > 0 ? plannedAllowlistArgv : null;
|
|
}
|
|
|
|
function resolveSystemRunExecArgv(params: {
|
|
plannedAllowlistArgv: string[] | undefined;
|
|
argv: string[];
|
|
security: ExecSecurity;
|
|
isWindows: boolean;
|
|
policy: {
|
|
approvedByAsk: boolean;
|
|
analysisOk: boolean;
|
|
allowlistSatisfied: boolean;
|
|
};
|
|
shellCommand: string | null;
|
|
segments: ExecCommandSegment[];
|
|
}): string[] {
|
|
let execArgv = params.plannedAllowlistArgv ?? params.argv;
|
|
if (
|
|
params.security === "allowlist" &&
|
|
params.isWindows &&
|
|
!params.policy.approvedByAsk &&
|
|
params.shellCommand &&
|
|
params.policy.analysisOk &&
|
|
params.policy.allowlistSatisfied &&
|
|
params.segments.length === 1 &&
|
|
params.segments[0]?.argv.length > 0
|
|
) {
|
|
execArgv = params.segments[0].argv;
|
|
}
|
|
return execArgv;
|
|
}
|
|
|
|
function applyOutputTruncation(result: RunResult) {
|
|
if (!result.truncated) {
|
|
return;
|
|
}
|
|
const suffix = "... (truncated)";
|
|
if (result.stderr.trim().length > 0) {
|
|
result.stderr = `${result.stderr}\n${suffix}`;
|
|
} else {
|
|
result.stdout = `${result.stdout}\n${suffix}`;
|
|
}
|
|
}
|
|
|
|
export { formatSystemRunAllowlistMissMessage } from "./exec-policy.js";
|
|
|
|
export async function handleSystemRunInvoke(opts: HandleSystemRunInvokeOptions): Promise<void> {
|
|
const command = resolveSystemRunCommand({
|
|
command: opts.params.command,
|
|
rawCommand: opts.params.rawCommand,
|
|
});
|
|
if (!command.ok) {
|
|
await opts.sendInvokeResult({
|
|
ok: false,
|
|
error: { code: "INVALID_REQUEST", message: command.message },
|
|
});
|
|
return;
|
|
}
|
|
if (command.argv.length === 0) {
|
|
await opts.sendInvokeResult({
|
|
ok: false,
|
|
error: { code: "INVALID_REQUEST", message: "command required" },
|
|
});
|
|
return;
|
|
}
|
|
|
|
const argv = command.argv;
|
|
const shellCommand = command.shellCommand;
|
|
const cmdText = command.cmdText;
|
|
const agentId = opts.params.agentId?.trim() || undefined;
|
|
const cfg = loadConfig();
|
|
const agentExec = agentId ? resolveAgentConfig(cfg, agentId)?.tools?.exec : undefined;
|
|
const configuredSecurity = opts.resolveExecSecurity(
|
|
agentExec?.security ?? cfg.tools?.exec?.security,
|
|
);
|
|
const configuredAsk = opts.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 = opts.params.sessionKey?.trim() || "node";
|
|
const runId = opts.params.runId?.trim() || crypto.randomUUID();
|
|
const execution: SystemRunExecutionContext = { sessionKey, runId, cmdText };
|
|
const approvalDecision = resolveExecApprovalDecision(opts.params.approvalDecision);
|
|
const envOverrides = sanitizeSystemRunEnvOverrides({
|
|
overrides: opts.params.env ?? undefined,
|
|
shellWrapper: shellCommand !== null,
|
|
});
|
|
const env = opts.sanitizeEnv(envOverrides);
|
|
const { safeBins, safeBinProfiles, trustedSafeBinDirs } = resolveExecSafeBinRuntimePolicy({
|
|
global: cfg.tools?.exec,
|
|
local: agentExec,
|
|
onWarning: warnWritableTrustedDirOnce,
|
|
});
|
|
const bins = autoAllowSkills ? await opts.skillBins.current() : [];
|
|
let { analysisOk, allowlistMatches, allowlistSatisfied, segments } = evaluateSystemRunAllowlist({
|
|
shellCommand,
|
|
argv,
|
|
approvals,
|
|
security,
|
|
safeBins,
|
|
safeBinProfiles,
|
|
trustedSafeBinDirs,
|
|
cwd: opts.params.cwd ?? undefined,
|
|
env,
|
|
skillBins: bins,
|
|
autoAllowSkills,
|
|
});
|
|
const isWindows = process.platform === "win32";
|
|
const cmdInvocation = shellCommand
|
|
? opts.isCmdExeInvocation(segments[0]?.argv ?? [])
|
|
: opts.isCmdExeInvocation(argv);
|
|
const policy = evaluateSystemRunPolicy({
|
|
security,
|
|
ask,
|
|
analysisOk,
|
|
allowlistSatisfied,
|
|
approvalDecision,
|
|
approved: opts.params.approved === true,
|
|
isWindows,
|
|
cmdInvocation,
|
|
shellWrapperInvocation: shellCommand !== null,
|
|
});
|
|
analysisOk = policy.analysisOk;
|
|
allowlistSatisfied = policy.allowlistSatisfied;
|
|
if (!policy.allowed) {
|
|
await sendSystemRunDenied(opts, execution, {
|
|
reason: policy.eventReason,
|
|
message: policy.errorMessage,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Fail closed if policy/runtime drift re-allows unapproved shell wrappers.
|
|
if (security === "allowlist" && shellCommand && !policy.approvedByAsk) {
|
|
await sendSystemRunDenied(opts, execution, {
|
|
reason: "approval-required",
|
|
message: "SYSTEM_RUN_DENIED: approval required",
|
|
});
|
|
return;
|
|
}
|
|
|
|
const plannedAllowlistArgv = resolvePlannedAllowlistArgv({
|
|
security,
|
|
shellCommand,
|
|
policy,
|
|
segments,
|
|
});
|
|
if (plannedAllowlistArgv === null) {
|
|
await sendSystemRunDenied(opts, execution, {
|
|
reason: "execution-plan-miss",
|
|
message: "SYSTEM_RUN_DENIED: execution plan mismatch",
|
|
});
|
|
return;
|
|
}
|
|
|
|
const useMacAppExec = opts.preferMacAppExecHost;
|
|
if (useMacAppExec) {
|
|
const execRequest: ExecHostRequest = {
|
|
command: plannedAllowlistArgv ?? argv,
|
|
// Forward canonical display text so companion approval/prompt surfaces bind to
|
|
// the exact command context already validated on the node-host.
|
|
rawCommand: cmdText || null,
|
|
cwd: opts.params.cwd ?? null,
|
|
env: envOverrides ?? null,
|
|
timeoutMs: opts.params.timeoutMs ?? null,
|
|
needsScreenRecording: opts.params.needsScreenRecording ?? null,
|
|
agentId: agentId ?? null,
|
|
sessionKey: sessionKey ?? null,
|
|
approvalDecision,
|
|
};
|
|
const response = await opts.runViaMacAppExecHost({ approvals, request: execRequest });
|
|
if (!response) {
|
|
if (opts.execHostEnforced || !opts.execHostFallbackAllowed) {
|
|
await sendSystemRunDenied(opts, execution, {
|
|
reason: "companion-unavailable",
|
|
message: "COMPANION_APP_UNAVAILABLE: macOS app exec host unreachable",
|
|
});
|
|
return;
|
|
}
|
|
} else if (!response.ok) {
|
|
await sendSystemRunDenied(opts, execution, {
|
|
reason: normalizeDeniedReason(response.error.reason),
|
|
message: response.error.message,
|
|
});
|
|
return;
|
|
} else {
|
|
const result: ExecHostRunResult = response.payload;
|
|
await opts.sendExecFinishedEvent({ sessionKey, runId, cmdText, result });
|
|
await opts.sendInvokeResult({
|
|
ok: true,
|
|
payloadJSON: JSON.stringify(result),
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (policy.approvalDecision === "allow-always" && security === "allowlist") {
|
|
if (policy.analysisOk) {
|
|
const patterns = resolveAllowAlwaysPatterns({
|
|
segments,
|
|
cwd: opts.params.cwd ?? undefined,
|
|
env,
|
|
platform: process.platform,
|
|
});
|
|
for (const pattern of patterns) {
|
|
if (pattern) {
|
|
addAllowlistEntry(approvals.file, agentId, pattern);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 (opts.params.needsScreenRecording === true) {
|
|
await sendSystemRunDenied(opts, execution, {
|
|
reason: "permission:screenRecording",
|
|
message: "PERMISSION_MISSING: screenRecording",
|
|
});
|
|
return;
|
|
}
|
|
|
|
const execArgv = resolveSystemRunExecArgv({
|
|
plannedAllowlistArgv: plannedAllowlistArgv ?? undefined,
|
|
argv,
|
|
security,
|
|
isWindows,
|
|
policy,
|
|
shellCommand,
|
|
segments,
|
|
});
|
|
|
|
const result = await opts.runCommand(
|
|
execArgv,
|
|
opts.params.cwd?.trim() || undefined,
|
|
env,
|
|
opts.params.timeoutMs ?? undefined,
|
|
);
|
|
applyOutputTruncation(result);
|
|
await opts.sendExecFinishedEvent({ sessionKey, runId, cmdText, result });
|
|
|
|
await opts.sendInvokeResult({
|
|
ok: true,
|
|
payloadJSON: JSON.stringify({
|
|
exitCode: result.exitCode,
|
|
timedOut: result.timedOut,
|
|
success: result.success,
|
|
stdout: result.stdout,
|
|
stderr: result.stderr,
|
|
error: result.error ?? null,
|
|
}),
|
|
});
|
|
}
|