fix(security): harden and refactor system.run command resolution

This commit is contained in:
Peter Steinberger
2026-02-21 11:49:13 +01:00
parent 5cc631cc9c
commit 6007941f04
6 changed files with 679 additions and 368 deletions

View File

@@ -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,
}),
},
});
}