mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 14:24:30 +00:00
fix(security): harden and refactor system.run command resolution
This commit is contained in:
85
src/gateway/node-invoke-system-run-approval.test.ts
Normal file
85
src/gateway/node-invoke-system-run-approval.test.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import type { ExecApprovalRecord } from "./exec-approval-manager.js";
|
||||||
|
import { sanitizeSystemRunParamsForForwarding } from "./node-invoke-system-run-approval.js";
|
||||||
|
|
||||||
|
describe("sanitizeSystemRunParamsForForwarding", () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const client = {
|
||||||
|
connId: "conn-1",
|
||||||
|
connect: {
|
||||||
|
scopes: ["operator.write", "operator.approvals"],
|
||||||
|
device: { id: "dev-1" },
|
||||||
|
client: { id: "cli-1" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeRecord(command: string): ExecApprovalRecord {
|
||||||
|
return {
|
||||||
|
id: "approval-1",
|
||||||
|
request: {
|
||||||
|
host: "node",
|
||||||
|
command,
|
||||||
|
cwd: null,
|
||||||
|
agentId: null,
|
||||||
|
sessionKey: null,
|
||||||
|
},
|
||||||
|
createdAtMs: now - 1_000,
|
||||||
|
expiresAtMs: now + 60_000,
|
||||||
|
requestedByConnId: "conn-1",
|
||||||
|
requestedByDeviceId: "dev-1",
|
||||||
|
requestedByClientId: "cli-1",
|
||||||
|
resolvedAtMs: now - 500,
|
||||||
|
decision: "allow-once",
|
||||||
|
resolvedBy: "operator",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function manager(record: ReturnType<typeof makeRecord>) {
|
||||||
|
return {
|
||||||
|
getSnapshot: () => record,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("rejects cmd.exe /c trailing-arg mismatch against rawCommand", () => {
|
||||||
|
const result = sanitizeSystemRunParamsForForwarding({
|
||||||
|
rawParams: {
|
||||||
|
command: ["cmd.exe", "/d", "/s", "/c", "echo", "SAFE&&whoami"],
|
||||||
|
rawCommand: "echo",
|
||||||
|
runId: "approval-1",
|
||||||
|
approved: true,
|
||||||
|
approvalDecision: "allow-once",
|
||||||
|
},
|
||||||
|
client,
|
||||||
|
execApprovalManager: manager(makeRecord("echo")),
|
||||||
|
nowMs: now,
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) {
|
||||||
|
throw new Error("unreachable");
|
||||||
|
}
|
||||||
|
expect(result.message).toContain("rawCommand does not match command");
|
||||||
|
expect(result.details?.code).toBe("RAW_COMMAND_MISMATCH");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts matching cmd.exe /c command text for approval binding", () => {
|
||||||
|
const result = sanitizeSystemRunParamsForForwarding({
|
||||||
|
rawParams: {
|
||||||
|
command: ["cmd.exe", "/d", "/s", "/c", "echo", "SAFE&&whoami"],
|
||||||
|
rawCommand: "echo SAFE&&whoami",
|
||||||
|
runId: "approval-1",
|
||||||
|
approved: true,
|
||||||
|
approvalDecision: "allow-once",
|
||||||
|
},
|
||||||
|
client,
|
||||||
|
execApprovalManager: manager(makeRecord("echo SAFE&&whoami")),
|
||||||
|
nowMs: now,
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new Error("unreachable");
|
||||||
|
}
|
||||||
|
const params = result.params as Record<string, unknown>;
|
||||||
|
expect(params.approved).toBe(true);
|
||||||
|
expect(params.approvalDecision).toBe("allow-once");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
import {
|
import { resolveSystemRunCommand } from "../infra/system-run-command.js";
|
||||||
formatExecCommand,
|
import type { ExecApprovalRecord } from "./exec-approval-manager.js";
|
||||||
validateSystemRunCommandConsistency,
|
|
||||||
} from "../infra/system-run-command.js";
|
|
||||||
import type { ExecApprovalManager, ExecApprovalRecord } from "./exec-approval-manager.js";
|
|
||||||
import type { GatewayClient } from "./server-methods/types.js";
|
|
||||||
|
|
||||||
type SystemRunParamsLike = {
|
type SystemRunParamsLike = {
|
||||||
command?: unknown;
|
command?: unknown;
|
||||||
@@ -19,6 +15,18 @@ type SystemRunParamsLike = {
|
|||||||
runId?: unknown;
|
runId?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ApprovalLookup = {
|
||||||
|
getSnapshot: (recordId: string) => ExecApprovalRecord | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ApprovalClient = {
|
||||||
|
connId?: string | null;
|
||||||
|
connect?: {
|
||||||
|
scopes?: unknown;
|
||||||
|
device?: { id?: string | null } | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -39,31 +47,20 @@ function normalizeApprovalDecision(value: unknown): "allow-once" | "allow-always
|
|||||||
return s === "allow-once" || s === "allow-always" ? s : null;
|
return s === "allow-once" || s === "allow-always" ? s : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clientHasApprovals(client: GatewayClient | null): boolean {
|
function clientHasApprovals(client: ApprovalClient | null): boolean {
|
||||||
const scopes = Array.isArray(client?.connect?.scopes) ? client?.connect?.scopes : [];
|
const scopes = Array.isArray(client?.connect?.scopes) ? client?.connect?.scopes : [];
|
||||||
return scopes.includes("operator.admin") || scopes.includes("operator.approvals");
|
return scopes.includes("operator.admin") || scopes.includes("operator.approvals");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCmdText(params: SystemRunParamsLike): string {
|
function approvalMatchesRequest(
|
||||||
const raw = normalizeString(params.rawCommand);
|
cmdText: string,
|
||||||
if (raw) {
|
params: SystemRunParamsLike,
|
||||||
return raw;
|
record: ExecApprovalRecord,
|
||||||
}
|
): boolean {
|
||||||
if (Array.isArray(params.command)) {
|
|
||||||
const parts = params.command.map((v) => String(v));
|
|
||||||
if (parts.length > 0) {
|
|
||||||
return formatExecCommand(parts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function approvalMatchesRequest(params: SystemRunParamsLike, record: ExecApprovalRecord): boolean {
|
|
||||||
if (record.request.host !== "node") {
|
if (record.request.host !== "node") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cmdText = getCmdText(params);
|
|
||||||
if (!cmdText || record.request.command !== cmdText) {
|
if (!cmdText || record.request.command !== cmdText) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -118,8 +115,8 @@ function pickSystemRunParams(raw: Record<string, unknown>): Record<string, unkno
|
|||||||
*/
|
*/
|
||||||
export function sanitizeSystemRunParamsForForwarding(opts: {
|
export function sanitizeSystemRunParamsForForwarding(opts: {
|
||||||
rawParams: unknown;
|
rawParams: unknown;
|
||||||
client: GatewayClient | null;
|
client: ApprovalClient | null;
|
||||||
execApprovalManager?: ExecApprovalManager;
|
execApprovalManager?: ApprovalLookup;
|
||||||
nowMs?: number;
|
nowMs?: number;
|
||||||
}):
|
}):
|
||||||
| { ok: true; params: unknown }
|
| { ok: true; params: unknown }
|
||||||
@@ -130,25 +127,18 @@ export function sanitizeSystemRunParamsForForwarding(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const p = obj as SystemRunParamsLike;
|
const p = obj as SystemRunParamsLike;
|
||||||
const argv = Array.isArray(p.command) ? p.command.map((v) => String(v)) : [];
|
const cmdTextResolution = resolveSystemRunCommand({
|
||||||
const raw = normalizeString(p.rawCommand);
|
command: p.command,
|
||||||
if (raw) {
|
rawCommand: p.rawCommand,
|
||||||
if (!Array.isArray(p.command) || argv.length === 0) {
|
});
|
||||||
return {
|
if (!cmdTextResolution.ok) {
|
||||||
ok: false,
|
return {
|
||||||
message: "rawCommand requires params.command",
|
ok: false,
|
||||||
details: { code: "MISSING_COMMAND" },
|
message: cmdTextResolution.message,
|
||||||
};
|
details: cmdTextResolution.details,
|
||||||
}
|
};
|
||||||
const validation = validateSystemRunCommandConsistency({ argv, rawCommand: raw });
|
|
||||||
if (!validation.ok) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
message: validation.message,
|
|
||||||
details: validation.details ?? { code: "RAW_COMMAND_MISMATCH" },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
const cmdText = cmdTextResolution.cmdText;
|
||||||
|
|
||||||
const approved = p.approved === true;
|
const approved = p.approved === true;
|
||||||
const requestedDecision = normalizeApprovalDecision(p.approvalDecision);
|
const requestedDecision = normalizeApprovalDecision(p.approvalDecision);
|
||||||
@@ -221,7 +211,7 @@ export function sanitizeSystemRunParamsForForwarding(opts: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!approvalMatchesRequest(p, snapshot)) {
|
if (!approvalMatchesRequest(cmdText, p, snapshot)) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
message: "approval id does not match request",
|
message: "approval id does not match request",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "vitest";
|
|||||||
import {
|
import {
|
||||||
extractShellCommandFromArgv,
|
extractShellCommandFromArgv,
|
||||||
formatExecCommand,
|
formatExecCommand,
|
||||||
|
resolveSystemRunCommand,
|
||||||
validateSystemRunCommandConsistency,
|
validateSystemRunCommandConsistency,
|
||||||
} from "./system-run-command.js";
|
} from "./system-run-command.js";
|
||||||
|
|
||||||
@@ -18,6 +19,12 @@ describe("system run command helpers", () => {
|
|||||||
expect(extractShellCommandFromArgv(["cmd.exe", "/d", "/s", "/c", "echo hi"])).toBe("echo hi");
|
expect(extractShellCommandFromArgv(["cmd.exe", "/d", "/s", "/c", "echo hi"])).toBe("echo hi");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("extractShellCommandFromArgv includes trailing cmd.exe args after /c", () => {
|
||||||
|
expect(extractShellCommandFromArgv(["cmd.exe", "/d", "/s", "/c", "echo", "SAFE&&whoami"])).toBe(
|
||||||
|
"echo SAFE&&whoami",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("validateSystemRunCommandConsistency accepts rawCommand matching direct argv", () => {
|
test("validateSystemRunCommandConsistency accepts rawCommand matching direct argv", () => {
|
||||||
const res = validateSystemRunCommandConsistency({
|
const res = validateSystemRunCommandConsistency({
|
||||||
argv: ["echo", "hi"],
|
argv: ["echo", "hi"],
|
||||||
@@ -51,4 +58,41 @@ describe("system run command helpers", () => {
|
|||||||
});
|
});
|
||||||
expect(res.ok).toBe(true);
|
expect(res.ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("validateSystemRunCommandConsistency rejects cmd.exe /c trailing-arg smuggling", () => {
|
||||||
|
const res = validateSystemRunCommandConsistency({
|
||||||
|
argv: ["cmd.exe", "/d", "/s", "/c", "echo", "SAFE&&whoami"],
|
||||||
|
rawCommand: "echo",
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
if (res.ok) {
|
||||||
|
throw new Error("unreachable");
|
||||||
|
}
|
||||||
|
expect(res.message).toContain("rawCommand does not match command");
|
||||||
|
expect(res.details?.code).toBe("RAW_COMMAND_MISMATCH");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveSystemRunCommand requires command when rawCommand is present", () => {
|
||||||
|
const res = resolveSystemRunCommand({ rawCommand: "echo hi" });
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
if (res.ok) {
|
||||||
|
throw new Error("unreachable");
|
||||||
|
}
|
||||||
|
expect(res.message).toContain("rawCommand requires params.command");
|
||||||
|
expect(res.details?.code).toBe("MISSING_COMMAND");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveSystemRunCommand returns normalized argv and cmdText", () => {
|
||||||
|
const res = resolveSystemRunCommand({
|
||||||
|
command: ["cmd.exe", "/d", "/s", "/c", "echo", "SAFE&&whoami"],
|
||||||
|
rawCommand: "echo SAFE&&whoami",
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("unreachable");
|
||||||
|
}
|
||||||
|
expect(res.argv).toEqual(["cmd.exe", "/d", "/s", "/c", "echo", "SAFE&&whoami"]);
|
||||||
|
expect(res.shellCommand).toBe("echo SAFE&&whoami");
|
||||||
|
expect(res.cmdText).toBe("echo SAFE&&whoami");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,20 @@ export type SystemRunCommandValidation =
|
|||||||
details?: Record<string, unknown>;
|
details?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ResolvedSystemRunCommand =
|
||||||
|
| {
|
||||||
|
ok: true;
|
||||||
|
argv: string[];
|
||||||
|
rawCommand: string | null;
|
||||||
|
shellCommand: string | null;
|
||||||
|
cmdText: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
ok: false;
|
||||||
|
message: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
function basenameLower(token: string): string {
|
function basenameLower(token: string): string {
|
||||||
const win = path.win32.basename(token);
|
const win = path.win32.basename(token);
|
||||||
const posix = path.posix.basename(token);
|
const posix = path.posix.basename(token);
|
||||||
@@ -65,8 +79,12 @@ export function extractShellCommandFromArgv(argv: string[]): string | null {
|
|||||||
if (idx === -1) {
|
if (idx === -1) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const cmd = argv[idx + 1];
|
const tail = argv.slice(idx + 1).map((item) => String(item));
|
||||||
return typeof cmd === "string" ? cmd : null;
|
if (tail.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const cmd = tail.join(" ").trim();
|
||||||
|
return cmd.length > 0 ? cmd : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -81,7 +99,7 @@ export function validateSystemRunCommandConsistency(params: {
|
|||||||
? params.rawCommand.trim()
|
? params.rawCommand.trim()
|
||||||
: null;
|
: null;
|
||||||
const shellCommand = extractShellCommandFromArgv(params.argv);
|
const shellCommand = extractShellCommandFromArgv(params.argv);
|
||||||
const inferred = shellCommand ? shellCommand.trim() : formatExecCommand(params.argv);
|
const inferred = shellCommand !== null ? shellCommand.trim() : formatExecCommand(params.argv);
|
||||||
|
|
||||||
if (raw && raw !== inferred) {
|
if (raw && raw !== inferred) {
|
||||||
return {
|
return {
|
||||||
@@ -100,7 +118,55 @@ export function validateSystemRunCommandConsistency(params: {
|
|||||||
// Only treat this as a shell command when argv is a recognized shell wrapper.
|
// Only treat this as a shell command when argv is a recognized shell wrapper.
|
||||||
// For direct argv execution, rawCommand is purely display/approval text and
|
// For direct argv execution, rawCommand is purely display/approval text and
|
||||||
// must match the formatted argv.
|
// must match the formatted argv.
|
||||||
shellCommand: shellCommand ? (raw ?? shellCommand) : null,
|
shellCommand: shellCommand !== null ? (raw ?? shellCommand) : null,
|
||||||
cmdText: raw ?? shellCommand ?? inferred,
|
cmdText: raw ?? shellCommand ?? inferred,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveSystemRunCommand(params: {
|
||||||
|
command?: unknown;
|
||||||
|
rawCommand?: unknown;
|
||||||
|
}): ResolvedSystemRunCommand {
|
||||||
|
const raw =
|
||||||
|
typeof params.rawCommand === "string" && params.rawCommand.trim().length > 0
|
||||||
|
? params.rawCommand.trim()
|
||||||
|
: null;
|
||||||
|
const command = Array.isArray(params.command) ? params.command : [];
|
||||||
|
if (command.length === 0) {
|
||||||
|
if (raw) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: "rawCommand requires params.command",
|
||||||
|
details: { code: "MISSING_COMMAND" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
argv: [],
|
||||||
|
rawCommand: null,
|
||||||
|
shellCommand: null,
|
||||||
|
cmdText: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const argv = command.map((v) => String(v));
|
||||||
|
const validation = validateSystemRunCommandConsistency({
|
||||||
|
argv,
|
||||||
|
rawCommand: raw,
|
||||||
|
});
|
||||||
|
if (!validation.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: validation.message,
|
||||||
|
details: validation.details ?? { code: "RAW_COMMAND_MISMATCH" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
argv,
|
||||||
|
rawCommand: raw,
|
||||||
|
shellCommand: validation.shellCommand,
|
||||||
|
cmdText: validation.cmdText,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
422
src/node-host/invoke-system-run.ts
Normal file
422
src/node-host/invoke-system-run.ts
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
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,
|
||||||
|
requiresExecApproval,
|
||||||
|
resolveExecApprovals,
|
||||||
|
resolveSafeBins,
|
||||||
|
type ExecAllowlistEntry,
|
||||||
|
type ExecAsk,
|
||||||
|
type ExecCommandSegment,
|
||||||
|
type ExecSecurity,
|
||||||
|
} from "../infra/exec-approvals.js";
|
||||||
|
import type { ExecHostRequest, ExecHostResponse, ExecHostRunResult } from "../infra/exec-host.js";
|
||||||
|
import { getTrustedSafeBinDirs } from "../infra/exec-safe-bin-trust.js";
|
||||||
|
import { resolveSystemRunCommand } from "../infra/system-run-command.js";
|
||||||
|
|
||||||
|
type SystemRunParams = {
|
||||||
|
command: string[];
|
||||||
|
rawCommand?: string | null;
|
||||||
|
cwd?: string | null;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
timeoutMs?: number | null;
|
||||||
|
needsScreenRecording?: boolean | null;
|
||||||
|
agentId?: string | null;
|
||||||
|
sessionKey?: string | null;
|
||||||
|
approved?: boolean | null;
|
||||||
|
approvalDecision?: string | null;
|
||||||
|
runId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RunResult = {
|
||||||
|
exitCode?: number;
|
||||||
|
timedOut: boolean;
|
||||||
|
success: boolean;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
error?: string | null;
|
||||||
|
truncated: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExecEventPayload = {
|
||||||
|
sessionKey: string;
|
||||||
|
runId: string;
|
||||||
|
host: string;
|
||||||
|
command?: string;
|
||||||
|
exitCode?: number;
|
||||||
|
timedOut?: boolean;
|
||||||
|
success?: boolean;
|
||||||
|
output?: string;
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SkillBinsProvider = {
|
||||||
|
current(force?: boolean): Promise<Set<string>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SystemRunInvokeResult = {
|
||||||
|
ok: boolean;
|
||||||
|
payloadJSON?: string | null;
|
||||||
|
error?: { code?: string; message?: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function handleSystemRunInvoke(opts: {
|
||||||
|
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>;
|
||||||
|
}): 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 rawCommand = command.rawCommand ?? "";
|
||||||
|
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 env = opts.sanitizeEnv(opts.params.env ?? undefined);
|
||||||
|
const safeBins = resolveSafeBins(agentExec?.safeBins ?? cfg.tools?.exec?.safeBins);
|
||||||
|
const trustedSafeBinDirs = getTrustedSafeBinDirs();
|
||||||
|
const bins = autoAllowSkills ? await opts.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: opts.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: opts.params.cwd ?? undefined, env });
|
||||||
|
const allowlistEval = evaluateExecAllowlist({
|
||||||
|
analysis,
|
||||||
|
allowlist: approvals.allowlist,
|
||||||
|
safeBins,
|
||||||
|
cwd: opts.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
|
||||||
|
? opts.isCmdExeInvocation(segments[0]?.argv ?? [])
|
||||||
|
: opts.isCmdExeInvocation(argv);
|
||||||
|
if (security === "allowlist" && isWindows && cmdInvocation) {
|
||||||
|
analysisOk = false;
|
||||||
|
allowlistSatisfied = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useMacAppExec = process.platform === "darwin";
|
||||||
|
if (useMacAppExec) {
|
||||||
|
const approvalDecision =
|
||||||
|
opts.params.approvalDecision === "allow-once" ||
|
||||||
|
opts.params.approvalDecision === "allow-always"
|
||||||
|
? opts.params.approvalDecision
|
||||||
|
: null;
|
||||||
|
const execRequest: ExecHostRequest = {
|
||||||
|
command: argv,
|
||||||
|
rawCommand: rawCommand || shellCommand || null,
|
||||||
|
cwd: opts.params.cwd ?? null,
|
||||||
|
env: opts.params.env ?? 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 opts.sendNodeEvent(
|
||||||
|
opts.client,
|
||||||
|
"exec.denied",
|
||||||
|
opts.buildExecEventPayload({
|
||||||
|
sessionKey,
|
||||||
|
runId,
|
||||||
|
host: "node",
|
||||||
|
command: cmdText,
|
||||||
|
reason: "companion-unavailable",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await opts.sendInvokeResult({
|
||||||
|
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 opts.sendNodeEvent(
|
||||||
|
opts.client,
|
||||||
|
"exec.denied",
|
||||||
|
opts.buildExecEventPayload({
|
||||||
|
sessionKey,
|
||||||
|
runId,
|
||||||
|
host: "node",
|
||||||
|
command: cmdText,
|
||||||
|
reason,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await opts.sendInvokeResult({
|
||||||
|
ok: false,
|
||||||
|
error: { code: "UNAVAILABLE", 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 (security === "deny") {
|
||||||
|
await opts.sendNodeEvent(
|
||||||
|
opts.client,
|
||||||
|
"exec.denied",
|
||||||
|
opts.buildExecEventPayload({
|
||||||
|
sessionKey,
|
||||||
|
runId,
|
||||||
|
host: "node",
|
||||||
|
command: cmdText,
|
||||||
|
reason: "security=deny",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await opts.sendInvokeResult({
|
||||||
|
ok: false,
|
||||||
|
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DISABLED: security=deny" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiresAsk = requiresExecApproval({
|
||||||
|
ask,
|
||||||
|
security,
|
||||||
|
analysisOk,
|
||||||
|
allowlistSatisfied,
|
||||||
|
});
|
||||||
|
|
||||||
|
const approvalDecision =
|
||||||
|
opts.params.approvalDecision === "allow-once" || opts.params.approvalDecision === "allow-always"
|
||||||
|
? opts.params.approvalDecision
|
||||||
|
: null;
|
||||||
|
const approvedByAsk = approvalDecision !== null || opts.params.approved === true;
|
||||||
|
if (requiresAsk && !approvedByAsk) {
|
||||||
|
await opts.sendNodeEvent(
|
||||||
|
opts.client,
|
||||||
|
"exec.denied",
|
||||||
|
opts.buildExecEventPayload({
|
||||||
|
sessionKey,
|
||||||
|
runId,
|
||||||
|
host: "node",
|
||||||
|
command: cmdText,
|
||||||
|
reason: "approval-required",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await opts.sendInvokeResult({
|
||||||
|
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 opts.sendNodeEvent(
|
||||||
|
opts.client,
|
||||||
|
"exec.denied",
|
||||||
|
opts.buildExecEventPayload({
|
||||||
|
sessionKey,
|
||||||
|
runId,
|
||||||
|
host: "node",
|
||||||
|
command: cmdText,
|
||||||
|
reason: "allowlist-miss",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await opts.sendInvokeResult({
|
||||||
|
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 (opts.params.needsScreenRecording === true) {
|
||||||
|
await opts.sendNodeEvent(
|
||||||
|
opts.client,
|
||||||
|
"exec.denied",
|
||||||
|
opts.buildExecEventPayload({
|
||||||
|
sessionKey,
|
||||||
|
runId,
|
||||||
|
host: "node",
|
||||||
|
command: cmdText,
|
||||||
|
reason: "permission:screenRecording",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await opts.sendInvokeResult({
|
||||||
|
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 opts.runCommand(
|
||||||
|
execArgv,
|
||||||
|
opts.params.cwd?.trim() || undefined,
|
||||||
|
env,
|
||||||
|
opts.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 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,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,40 +1,26 @@
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import crypto from "node:crypto";
|
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
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 { GatewayClient } from "../gateway/client.js";
|
||||||
import {
|
import {
|
||||||
addAllowlistEntry,
|
|
||||||
analyzeArgvCommand,
|
|
||||||
evaluateExecAllowlist,
|
|
||||||
evaluateShellAllowlist,
|
|
||||||
requiresExecApproval,
|
|
||||||
normalizeExecApprovals,
|
|
||||||
mergeExecApprovalsSocketDefaults,
|
|
||||||
recordAllowlistUse,
|
|
||||||
resolveExecApprovals,
|
|
||||||
resolveSafeBins,
|
|
||||||
ensureExecApprovals,
|
ensureExecApprovals,
|
||||||
|
mergeExecApprovalsSocketDefaults,
|
||||||
|
normalizeExecApprovals,
|
||||||
readExecApprovalsSnapshot,
|
readExecApprovalsSnapshot,
|
||||||
saveExecApprovals,
|
saveExecApprovals,
|
||||||
type ExecAsk,
|
type ExecAsk,
|
||||||
type ExecApprovalsFile,
|
type ExecApprovalsFile,
|
||||||
type ExecAllowlistEntry,
|
type ExecApprovalsResolved,
|
||||||
type ExecCommandSegment,
|
|
||||||
type ExecSecurity,
|
type ExecSecurity,
|
||||||
} from "../infra/exec-approvals.js";
|
} from "../infra/exec-approvals.js";
|
||||||
import {
|
import {
|
||||||
requestExecHostViaSocket,
|
requestExecHostViaSocket,
|
||||||
type ExecHostRequest,
|
type ExecHostRequest,
|
||||||
type ExecHostResponse,
|
type ExecHostResponse,
|
||||||
type ExecHostRunResult,
|
|
||||||
} from "../infra/exec-host.js";
|
} from "../infra/exec-host.js";
|
||||||
import { getTrustedSafeBinDirs } from "../infra/exec-safe-bin-trust.js";
|
|
||||||
import { sanitizeHostExecEnv } from "../infra/host-env-security.js";
|
import { sanitizeHostExecEnv } from "../infra/host-env-security.js";
|
||||||
import { validateSystemRunCommandConsistency } from "../infra/system-run-command.js";
|
|
||||||
import { runBrowserProxyCommand } from "./invoke-browser.js";
|
import { runBrowserProxyCommand } from "./invoke-browser.js";
|
||||||
|
import { handleSystemRunInvoke } from "./invoke-system-run.js";
|
||||||
|
|
||||||
const OUTPUT_CAP = 200_000;
|
const OUTPUT_CAP = 200_000;
|
||||||
const OUTPUT_EVENT_TAIL = 20_000;
|
const OUTPUT_EVENT_TAIL = 20_000;
|
||||||
@@ -336,7 +322,7 @@ async function sendExecFinishedEvent(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runViaMacAppExecHost(params: {
|
async function runViaMacAppExecHost(params: {
|
||||||
approvals: ReturnType<typeof resolveExecApprovals>;
|
approvals: ExecApprovalsResolved;
|
||||||
request: ExecHostRequest;
|
request: ExecHostRequest;
|
||||||
}): Promise<ExecHostResponse | null> {
|
}): Promise<ExecHostResponse | null> {
|
||||||
const { approvals, request } = params;
|
const { approvals, request } = params;
|
||||||
@@ -483,308 +469,26 @@ export async function handleInvoke(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const argv = params.command.map((item) => String(item));
|
await handleSystemRunInvoke({
|
||||||
const rawCommand = typeof params.rawCommand === "string" ? params.rawCommand.trim() : "";
|
client,
|
||||||
const consistency = validateSystemRunCommandConsistency({
|
params,
|
||||||
argv,
|
skillBins,
|
||||||
rawCommand: rawCommand || null,
|
execHostEnforced,
|
||||||
});
|
execHostFallbackAllowed,
|
||||||
if (!consistency.ok) {
|
resolveExecSecurity,
|
||||||
await sendErrorResult(client, frame, "INVALID_REQUEST", consistency.message);
|
resolveExecAsk,
|
||||||
return;
|
isCmdExeInvocation,
|
||||||
}
|
sanitizeEnv,
|
||||||
|
runCommand,
|
||||||
const shellCommand = consistency.shellCommand;
|
runViaMacAppExecHost,
|
||||||
const cmdText = consistency.cmdText;
|
sendNodeEvent,
|
||||||
const agentId = params.agentId?.trim() || undefined;
|
buildExecEventPayload,
|
||||||
const cfg = loadConfig();
|
sendInvokeResult: async (result) => {
|
||||||
const agentExec = agentId ? resolveAgentConfig(cfg, agentId)?.tools?.exec : undefined;
|
await sendInvokeResult(client, frame, result);
|
||||||
const configuredSecurity = resolveExecSecurity(agentExec?.security ?? cfg.tools?.exec?.security);
|
},
|
||||||
const configuredAsk = resolveExecAsk(agentExec?.ask ?? cfg.tools?.exec?.ask);
|
sendExecFinishedEvent: async ({ sessionKey, runId, cmdText, result }) => {
|
||||||
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 sendExecFinishedEvent({ client, 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