refactor(security): simplify system.run approval model

This commit is contained in:
Peter Steinberger
2026-03-11 01:42:47 +00:00
parent 5716e52417
commit 68c674d37c
32 changed files with 332 additions and 207 deletions

View File

@@ -7,7 +7,7 @@ import { callGatewayTool } from "./tools/gateway.js";
export type RequestExecApprovalDecisionParams = {
id: string;
command: string;
command?: string;
commandArgv?: string[];
systemRunPlan?: SystemRunApprovalPlan;
env?: Record<string, string>;
@@ -35,8 +35,8 @@ function buildExecApprovalRequestToolParams(
): ExecApprovalRequestToolParams {
return {
id: params.id,
command: params.command,
commandArgv: params.commandArgv,
...(params.command ? { command: params.command } : {}),
...(params.commandArgv ? { commandArgv: params.commandArgv } : {}),
systemRunPlan: params.systemRunPlan,
env: params.env,
cwd: params.cwd,
@@ -150,7 +150,7 @@ export async function requestExecApprovalDecision(
type HostExecApprovalParams = {
approvalId: string;
command: string;
command?: string;
commandArgv?: string[];
systemRunPlan?: SystemRunApprovalPlan;
env?: Record<string, string>;

View File

@@ -125,7 +125,7 @@ export async function executeNodeHostCommand(
throw new Error("invalid system.run.prepare response");
}
const runArgv = prepared.plan.argv;
const runRawCommand = prepared.plan.rawCommand ?? prepared.cmdText;
const runRawCommand = prepared.plan.commandText;
const runCwd = prepared.plan.cwd ?? params.workdir;
const runAgentId = prepared.plan.agentId ?? params.agentId;
const runSessionKey = prepared.plan.sessionKey ?? params.sessionKey;
@@ -238,8 +238,6 @@ export async function executeNodeHostCommand(
// Register first so the returned approval ID is actionable immediately.
const registration = await registerExecApprovalRequestForHostOrThrow({
approvalId,
command: prepared.cmdText,
commandArgv: prepared.plan.argv,
systemRunPlan: prepared.plan,
env: nodeEnv,
workdir: runCwd,
@@ -391,7 +389,7 @@ export async function executeNodeHostCommand(
warningText,
approvalSlug,
approvalId,
command: prepared.cmdText,
command: prepared.plan.commandText,
cwd: runCwd,
host: "node",
nodeId,

View File

@@ -135,11 +135,10 @@ function setupNodeInvokeMock(params: {
function createSystemRunPreparePayload(cwd: string | null) {
return {
payload: {
cmdText: "echo hi",
plan: {
argv: ["echo", "hi"],
cwd,
rawCommand: "echo hi",
commandText: "echo hi",
agentId: null,
sessionKey: null,
},
@@ -662,10 +661,9 @@ describe("nodes run", () => {
onApprovalRequest: (approvalParams) => {
expect(approvalParams).toMatchObject({
id: expect.any(String),
command: "echo hi",
commandArgv: ["echo", "hi"],
systemRunPlan: expect.objectContaining({
argv: ["echo", "hi"],
commandText: "echo hi",
}),
nodeId: NODE_ID,
host: "node",

View File

@@ -97,11 +97,11 @@ describe("createNodesTool screen_record duration guardrails", () => {
if (payload?.command === "system.run.prepare") {
return {
payload: {
cmdText: "echo hi",
plan: {
argv: ["bash", "-lc", "echo hi"],
cwd: null,
rawCommand: null,
commandText: 'bash -lc "echo hi"',
commandPreview: "echo hi",
agentId: null,
sessionKey: null,
},

View File

@@ -664,7 +664,7 @@ export function createNodesTool(options?: {
}
const runParams = {
command: prepared.plan.argv,
rawCommand: prepared.plan.rawCommand ?? prepared.cmdText,
rawCommand: prepared.plan.commandText,
cwd: prepared.plan.cwd ?? cwd,
env,
timeoutMs: commandTimeoutMs,
@@ -699,8 +699,6 @@ export function createNodesTool(options?: {
{ ...gatewayOpts, timeoutMs: APPROVAL_TIMEOUT_MS + 5_000 },
{
id: approvalId,
command: prepared.cmdText,
commandArgv: prepared.plan.argv,
systemRunPlan: prepared.plan,
cwd: prepared.plan.cwd ?? cwd,
nodeId,

View File

@@ -36,8 +36,7 @@ const renderGatewayPortHealthDiagnostics = vi.fn(() => ["diag: unhealthy port"])
const renderRestartDiagnostics = vi.fn(() => ["diag: unhealthy runtime"]);
const resolveGatewayPort = vi.fn(() => 18789);
const findGatewayPidsOnPortSync = vi.fn<(port: number) => number[]>(() => []);
const probeGateway =
vi.fn<
const probeGateway = vi.fn<
(opts: {
url: string;
auth?: { token?: string; password?: string };

View File

@@ -186,11 +186,10 @@ describe("nodes-cli coverage", () => {
});
expect(invoke?.params?.timeoutMs).toBe(5000);
const approval = getApprovalRequestCall();
expect(approval?.params?.["commandArgv"]).toEqual(["echo", "hi"]);
expect(approval?.params?.["systemRunPlan"]).toEqual({
argv: ["echo", "hi"],
cwd: "/tmp",
rawCommand: "echo hi",
commandText: "echo hi",
commandPreview: null,
agentId: "main",
sessionKey: null,
@@ -221,11 +220,10 @@ describe("nodes-cli coverage", () => {
runId: expect.any(String),
});
const approval = getApprovalRequestCall();
expect(approval?.params?.["commandArgv"]).toEqual(["/bin/sh", "-lc", "echo hi"]);
expect(approval?.params?.["systemRunPlan"]).toEqual({
argv: ["/bin/sh", "-lc", "echo hi"],
cwd: null,
rawCommand: '/bin/sh -lc "echo hi"',
commandText: '/bin/sh -lc "echo hi"',
commandPreview: "echo hi",
agentId: "main",
sessionKey: null,

View File

@@ -189,7 +189,6 @@ async function maybeRequestNodesRunApproval(params: {
opts: NodesRunOpts;
nodeId: string;
agentId: string | undefined;
preparedCmdText: string;
approvalPlan: ReturnType<typeof requirePreparedRunPayload>["plan"];
hostSecurity: ExecSecurity;
hostAsk: ExecAsk;
@@ -215,8 +214,6 @@ async function maybeRequestNodesRunApproval(params: {
params.opts,
{
id: approvalId,
command: params.preparedCmdText,
commandArgv: params.approvalPlan.argv,
systemRunPlan: params.approvalPlan,
cwd: params.approvalPlan.cwd,
nodeId: params.nodeId,
@@ -272,7 +269,7 @@ function buildSystemRunInvokeParams(params: {
command: "system.run",
params: {
command: params.approvalPlan.argv,
rawCommand: params.approvalPlan.rawCommand,
rawCommand: params.approvalPlan.commandText,
cwd: params.approvalPlan.cwd,
env: params.nodeEnv,
timeoutMs: params.timeoutMs,
@@ -403,7 +400,6 @@ export function registerNodesInvokeCommands(nodes: Command) {
opts,
nodeId,
agentId,
preparedCmdText: preparedContext.prepared.cmdText,
approvalPlan,
hostSecurity: approvals.hostSecurity,
hostAsk: approvals.hostAsk,

View File

@@ -16,6 +16,7 @@ import type { DiscordExecApprovalConfig } from "../../config/types.discord.js";
import { GatewayClient } from "../../gateway/client.js";
import { createOperatorApprovalsGatewayClient } from "../../gateway/operator-approvals-client.js";
import type { EventFrame } from "../../gateway/protocol/index.js";
import { resolveExecApprovalCommandDisplay } from "../../infra/exec-approval-command-display.js";
import { getExecApprovalApproverDmNoticeText } from "../../infra/exec-approval-reply.js";
import type {
ExecApprovalDecision,
@@ -257,12 +258,11 @@ function createExecApprovalRequestContainer(params: {
accountId: string;
actionRow?: Row<Button>;
}): ExecApprovalContainer {
const commandText = params.request.request.command;
const commandPreview = formatCommandPreview(commandText, 1000);
const commandSecondaryPreview = formatOptionalCommandPreview(
params.request.request.commandPreview,
500,
const { commandText, commandPreview: secondaryPreview } = resolveExecApprovalCommandDisplay(
params.request.request,
);
const commandPreview = formatCommandPreview(commandText, 1000);
const commandSecondaryPreview = formatOptionalCommandPreview(secondaryPreview, 500);
const expiresAtSeconds = Math.max(0, Math.floor(params.request.expiresAtMs / 1000));
return new ExecApprovalContainer({
@@ -286,12 +286,11 @@ function createResolvedContainer(params: {
cfg: OpenClawConfig;
accountId: string;
}): ExecApprovalContainer {
const commandText = params.request.request.command;
const commandPreview = formatCommandPreview(commandText, 500);
const commandSecondaryPreview = formatOptionalCommandPreview(
params.request.request.commandPreview,
300,
const { commandText, commandPreview: secondaryPreview } = resolveExecApprovalCommandDisplay(
params.request.request,
);
const commandPreview = formatCommandPreview(commandText, 500);
const commandSecondaryPreview = formatOptionalCommandPreview(secondaryPreview, 300);
const decisionLabel =
params.decision === "allow-once"
@@ -324,12 +323,11 @@ function createExpiredContainer(params: {
cfg: OpenClawConfig;
accountId: string;
}): ExecApprovalContainer {
const commandText = params.request.request.command;
const commandPreview = formatCommandPreview(commandText, 500);
const commandSecondaryPreview = formatOptionalCommandPreview(
params.request.request.commandPreview,
300,
const { commandText, commandPreview: secondaryPreview } = resolveExecApprovalCommandDisplay(
params.request.request,
);
const commandPreview = formatCommandPreview(commandText, 500);
const commandSecondaryPreview = formatOptionalCommandPreview(secondaryPreview, 300);
return new ExecApprovalContainer({
cfg: params.cfg,

View File

@@ -245,7 +245,7 @@ describe("sanitizeSystemRunParamsForForwarding", () => {
record.request.systemRunPlan = {
argv: ["/usr/bin/echo", "SAFE"],
cwd: "/real/cwd",
rawCommand: "/usr/bin/echo SAFE",
commandText: "/usr/bin/echo SAFE",
agentId: "main",
sessionKey: "agent:main:main",
};
@@ -282,7 +282,7 @@ describe("sanitizeSystemRunParamsForForwarding", () => {
expect.objectContaining({
argv: ["/usr/bin/echo", "SAFE"],
cwd: "/real/cwd",
rawCommand: "/usr/bin/echo SAFE",
commandText: "/usr/bin/echo SAFE",
agentId: "main",
sessionKey: "agent:main:main",
}),

View File

@@ -1,5 +1,5 @@
import { resolveSystemRunApprovalRuntimeContext } from "../infra/system-run-approval-context.js";
import { resolveSystemRunCommand } from "../infra/system-run-command.js";
import { resolveSystemRunCommandRequest } from "../infra/system-run-command.js";
import type { ExecApprovalRecord } from "./exec-approval-manager.js";
import {
systemRunApprovalGuardError,
@@ -117,7 +117,7 @@ export function sanitizeSystemRunParamsForForwarding(opts: {
const next: Record<string, unknown> = pickSystemRunParams(obj);
if (!wantsApprovalOverride) {
const cmdTextResolution = resolveSystemRunCommand({
const cmdTextResolution = resolveSystemRunCommandRequest({
command: p.command,
rawCommand: p.rawCommand,
});
@@ -230,8 +230,8 @@ export function sanitizeSystemRunParamsForForwarding(opts: {
if (runtimeContext.plan) {
next.command = [...runtimeContext.plan.argv];
next.systemRunPlan = runtimeContext.plan;
if (runtimeContext.rawCommand) {
next.rawCommand = runtimeContext.rawCommand;
if (runtimeContext.commandText) {
next.rawCommand = runtimeContext.commandText;
} else {
delete next.rawCommand;
}

View File

@@ -88,14 +88,14 @@ export const ExecApprovalsNodeSetParamsSchema = Type.Object(
export const ExecApprovalRequestParamsSchema = Type.Object(
{
id: Type.Optional(NonEmptyString),
command: NonEmptyString,
command: Type.Optional(NonEmptyString),
commandArgv: Type.Optional(Type.Array(Type.String())),
systemRunPlan: Type.Optional(
Type.Object(
{
argv: Type.Array(Type.String()),
cwd: Type.Union([Type.String(), Type.Null()]),
rawCommand: Type.Union([Type.String(), Type.Null()]),
commandText: Type.String(),
commandPreview: Type.Optional(Type.Union([Type.String(), Type.Null()])),
agentId: Type.Union([Type.String(), Type.Null()]),
sessionKey: Type.Union([Type.String(), Type.Null()]),

View File

@@ -75,7 +75,6 @@ export function createExecApprovalHandlers(
const effectiveAgentId = approvalContext.agentId;
const effectiveSessionKey = approvalContext.sessionKey;
const effectiveCommandText = approvalContext.commandText;
const effectiveCommandPreview = approvalContext.commandPreview;
if (host === "node" && !nodeId) {
respond(
false,
@@ -92,6 +91,10 @@ export function createExecApprovalHandlers(
);
return;
}
if (!effectiveCommandText) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "command is required"));
return;
}
if (
host === "node" &&
(!Array.isArray(effectiveCommandArgv) || effectiveCommandArgv.length === 0)
@@ -123,8 +126,8 @@ export function createExecApprovalHandlers(
}
const request = {
command: effectiveCommandText,
commandPreview: effectiveCommandPreview,
commandArgv: effectiveCommandArgv,
commandPreview: host === "node" ? undefined : approvalContext.commandPreview,
commandArgv: host === "node" ? undefined : effectiveCommandArgv,
envKeys: systemRunBinding?.envKeys?.length ? systemRunBinding.envKeys : undefined,
systemRunBinding: systemRunBinding?.binding ?? null,
systemRunPlan: approvalContext.plan,

View File

@@ -305,7 +305,7 @@ describe("exec approval handlers", () => {
systemRunPlan: {
argv: ["/usr/bin/echo", "ok"],
cwd: "/tmp",
rawCommand: "/usr/bin/echo ok",
commandText: "/usr/bin/echo ok",
agentId: "main",
sessionKey: "agent:main:main",
},
@@ -358,7 +358,7 @@ describe("exec approval handlers", () => {
requestParams.systemRunPlan = {
argv: commandArgv,
cwd: cwdValue,
rawCommand: commandText,
commandText: commandText ?? commandArgv.join(" "),
agentId:
typeof (requestParams as { agentId?: unknown }).agentId === "string"
? ((requestParams as { agentId: string }).agentId ?? null)
@@ -586,7 +586,7 @@ describe("exec approval handlers", () => {
systemRunPlan: {
argv: ["/usr/bin/echo", "ok"],
cwd: "/real/cwd",
rawCommand: "/usr/bin/echo ok",
commandText: "/usr/bin/echo ok",
commandPreview: "echo ok",
agentId: "main",
sessionKey: "agent:main:main",
@@ -597,15 +597,15 @@ describe("exec approval handlers", () => {
expect(requested).toBeTruthy();
const request = (requested?.payload as { request?: Record<string, unknown> })?.request ?? {};
expect(request["command"]).toBe("/usr/bin/echo ok");
expect(request["commandPreview"]).toBe("echo ok");
expect(request["commandArgv"]).toEqual(["/usr/bin/echo", "ok"]);
expect(request["commandPreview"]).toBeUndefined();
expect(request["commandArgv"]).toBeUndefined();
expect(request["cwd"]).toBe("/real/cwd");
expect(request["agentId"]).toBe("main");
expect(request["sessionKey"]).toBe("agent:main:main");
expect(request["systemRunPlan"]).toEqual({
argv: ["/usr/bin/echo", "ok"],
cwd: "/real/cwd",
rawCommand: "/usr/bin/echo ok",
commandText: "/usr/bin/echo ok",
commandPreview: "echo ok",
agentId: "main",
sessionKey: "agent:main:main",
@@ -625,7 +625,7 @@ describe("exec approval handlers", () => {
systemRunPlan: {
argv: ["./env", "sh", "-c", "jq --version"],
cwd: "/real/cwd",
rawCommand: './env sh -c "jq --version"',
commandText: './env sh -c "jq --version"',
agentId: "main",
sessionKey: "agent:main:main",
},
@@ -635,7 +635,10 @@ describe("exec approval handlers", () => {
expect(requested).toBeTruthy();
const request = (requested?.payload as { request?: Record<string, unknown> })?.request ?? {};
expect(request["command"]).toBe('./env sh -c "jq --version"');
expect(request["commandPreview"]).toBe("jq --version");
expect(request["commandPreview"]).toBeUndefined();
expect((request["systemRunPlan"] as { commandPreview?: string }).commandPreview).toBe(
"jq --version",
);
});
it("accepts resolve during broadcast", async () => {

View File

@@ -0,0 +1,28 @@
import type { ExecApprovalRequestPayload } from "./exec-approvals.js";
function normalizePreview(commandText: string, commandPreview?: string | null): string | null {
const preview = commandPreview?.trim() ?? "";
if (!preview || preview === commandText) {
return null;
}
return preview;
}
export function resolveExecApprovalCommandDisplay(request: ExecApprovalRequestPayload): {
commandText: string;
commandPreview: string | null;
} {
if (request.host === "node" && request.systemRunPlan) {
return {
commandText: request.systemRunPlan.commandText,
commandPreview: normalizePreview(
request.systemRunPlan.commandText,
request.systemRunPlan.commandPreview,
),
};
}
return {
commandText: request.command,
commandPreview: normalizePreview(request.command, request.commandPreview),
};
}

View File

@@ -16,6 +16,7 @@ import {
normalizeMessageChannel,
type DeliverableMessageChannel,
} from "../utils/message-channel.js";
import { resolveExecApprovalCommandDisplay } from "./exec-approval-command-display.js";
import { buildExecApprovalPendingReplyPayload } from "./exec-approval-reply.js";
import type {
ExecApprovalDecision,
@@ -211,7 +212,9 @@ function formatApprovalCommand(command: string): { inline: boolean; text: string
function buildRequestMessage(request: ExecApprovalRequest, nowMs: number) {
const lines: string[] = ["🔒 Exec approval required", `ID: ${request.id}`];
const command = formatApprovalCommand(request.request.command);
const command = formatApprovalCommand(
resolveExecApprovalCommandDisplay(request.request).commandText,
);
if (command.inline) {
lines.push(`Command: ${command.text}`);
} else {
@@ -375,7 +378,7 @@ function buildRequestPayloadForTarget(
approvalId: request.id,
approvalSlug: request.id.slice(0, 8),
approvalCommandId: request.id,
command: request.request.command,
command: resolveExecApprovalCommandDisplay(request.request).commandText,
cwd: request.request.cwd ?? undefined,
host: request.request.host === "node" ? "node" : "gateway",
nodeId: request.request.nodeId ?? undefined,

View File

@@ -52,7 +52,7 @@ export type SystemRunApprovalFileOperand = {
export type SystemRunApprovalPlan = {
argv: string[];
cwd: string | null;
rawCommand: string | null;
commandText: string;
commandPreview?: string | null;
agentId: string | null;
sessionKey: string | null;

View File

@@ -50,10 +50,15 @@ export function normalizeSystemRunApprovalPlan(value: unknown): SystemRunApprova
if (candidate.mutableFileOperand !== undefined && mutableFileOperand === null) {
return null;
}
const commandText =
normalizeNonEmptyString(candidate.commandText) ?? normalizeNonEmptyString(candidate.rawCommand);
if (!commandText) {
return null;
}
return {
argv,
cwd: normalizeNonEmptyString(candidate.cwd),
rawCommand: normalizeNonEmptyString(candidate.rawCommand),
commandText,
commandPreview: normalizeNonEmptyString(candidate.commandPreview),
agentId: normalizeNonEmptyString(candidate.agentId),
sessionKey: normalizeNonEmptyString(candidate.sessionKey),

View File

@@ -9,7 +9,7 @@ describe("resolveSystemRunApprovalRequestContext", () => {
systemRunPlan: {
argv: ["./env", "sh", "-c", "jq --version"],
cwd: "/tmp",
rawCommand: './env sh -c "jq --version"',
commandText: './env sh -c "jq --version"',
commandPreview: "jq --version",
agentId: "main",
sessionKey: "agent:main:main",

View File

@@ -1,10 +1,9 @@
import type { SystemRunApprovalPlan } from "./exec-approvals.js";
import { normalizeSystemRunApprovalPlan } from "./system-run-approval-binding.js";
import { formatExecCommand, resolveSystemRunCommand } from "./system-run-command.js";
import { formatExecCommand, resolveSystemRunCommandRequest } from "./system-run-command.js";
import { normalizeNonEmptyString, normalizeStringArray } from "./system-run-normalize.js";
type PreparedRunPayload = {
cmdText: string;
plan: SystemRunApprovalPlan;
};
@@ -26,7 +25,7 @@ type SystemRunApprovalRuntimeContext =
cwd: string | null;
agentId: string | null;
sessionKey: string | null;
rawCommand: string | null;
commandText: string;
}
| {
ok: false;
@@ -53,13 +52,33 @@ export function parsePreparedSystemRunPayload(payload: unknown): PreparedRunPayl
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return null;
}
const raw = payload as { cmdText?: unknown; plan?: unknown };
const cmdText = normalizeNonEmptyString(raw.cmdText);
const raw = payload as { plan?: unknown; commandText?: unknown; cmdText?: unknown };
const plan = normalizeSystemRunApprovalPlan(raw.plan);
if (!cmdText || !plan) {
if (plan) {
return { plan };
}
if (!raw.plan || typeof raw.plan !== "object" || Array.isArray(raw.plan)) {
return null;
}
return { cmdText, plan };
const legacyPlan = raw.plan as Record<string, unknown>;
const argv = normalizeStringArray(legacyPlan.argv);
const commandText =
normalizeNonEmptyString(legacyPlan.rawCommand) ??
normalizeNonEmptyString(raw.commandText) ??
normalizeNonEmptyString(raw.cmdText);
if (argv.length === 0 || !commandText) {
return null;
}
return {
plan: {
argv,
cwd: normalizeNonEmptyString(legacyPlan.cwd),
commandText,
commandPreview: normalizeNonEmptyString(legacyPlan.commandPreview),
agentId: normalizeNonEmptyString(legacyPlan.agentId),
sessionKey: normalizeNonEmptyString(legacyPlan.sessionKey),
},
};
}
export function resolveSystemRunApprovalRequestContext(params: {
@@ -72,17 +91,22 @@ export function resolveSystemRunApprovalRequestContext(params: {
sessionKey?: unknown;
}): SystemRunApprovalRequestContext {
const host = normalizeNonEmptyString(params.host) ?? "";
const plan = host === "node" ? normalizeSystemRunApprovalPlan(params.systemRunPlan) : null;
const normalizedPlan =
host === "node" ? normalizeSystemRunApprovalPlan(params.systemRunPlan) : null;
const fallbackArgv = normalizeStringArray(params.commandArgv);
const fallbackCommand = normalizeCommandText(params.command);
const commandText = plan ? (plan.rawCommand ?? formatExecCommand(plan.argv)) : fallbackCommand;
const commandText = normalizedPlan
? normalizedPlan.commandText || formatExecCommand(normalizedPlan.argv)
: fallbackCommand;
const commandPreview = normalizedPlan
? normalizeCommandPreview(normalizedPlan.commandPreview ?? fallbackCommand, commandText)
: null;
const plan = normalizedPlan ? { ...normalizedPlan, commandPreview } : null;
return {
plan,
commandArgv: plan?.argv ?? (fallbackArgv.length > 0 ? fallbackArgv : undefined),
commandText,
commandPreview: plan
? normalizeCommandPreview(plan.commandPreview ?? fallbackCommand, commandText)
: null,
commandPreview,
cwd: plan?.cwd ?? normalizeNonEmptyString(params.cwd),
agentId: plan?.agentId ?? normalizeNonEmptyString(params.agentId),
sessionKey: plan?.sessionKey ?? normalizeNonEmptyString(params.sessionKey),
@@ -106,10 +130,10 @@ export function resolveSystemRunApprovalRuntimeContext(params: {
cwd: normalizedPlan.cwd,
agentId: normalizedPlan.agentId,
sessionKey: normalizedPlan.sessionKey,
rawCommand: normalizedPlan.rawCommand,
commandText: normalizedPlan.commandText,
};
}
const command = resolveSystemRunCommand({
const command = resolveSystemRunCommandRequest({
command: params.command,
rawCommand: params.rawCommand,
});
@@ -123,6 +147,6 @@ export function resolveSystemRunApprovalRuntimeContext(params: {
cwd: normalizeNonEmptyString(params.cwd),
agentId: normalizeNonEmptyString(params.agentId),
sessionKey: normalizeNonEmptyString(params.sessionKey),
rawCommand: normalizeNonEmptyString(params.rawCommand),
commandText: command.commandText,
};
}

View File

@@ -2,7 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, test } from "vitest";
import { resolveSystemRunCommand } from "./system-run-command.js";
import { resolveSystemRunCommandRequest } from "./system-run-command.js";
type ContractFixture = {
cases: ContractCase[];
@@ -28,7 +28,7 @@ const fixture = JSON.parse(fs.readFileSync(fixturePath, "utf8")) as ContractFixt
describe("system-run command contract fixtures", () => {
for (const entry of fixture.cases) {
test(entry.name, () => {
const result = resolveSystemRunCommand({
const result = resolveSystemRunCommandRequest({
command: entry.command,
rawCommand: entry.rawCommand,
});
@@ -48,7 +48,7 @@ describe("system-run command contract fixtures", () => {
if (!result.ok) {
throw new Error(`unexpected validation failure: ${result.message}`);
}
expect(result.cmdText).toBe(entry.expected.displayCommand);
expect(result.commandText).toBe(entry.expected.displayCommand);
});
}
});

View File

@@ -3,6 +3,7 @@ import {
extractShellCommandFromArgv,
formatExecCommand,
resolveSystemRunCommand,
resolveSystemRunCommandRequest,
validateSystemRunCommandConsistency,
} from "./system-run-command.js";
@@ -89,8 +90,8 @@ describe("system run command helpers", () => {
if (!res.ok) {
throw new Error("unreachable");
}
expect(res.shellCommand).toBe(null);
expect(res.cmdText).toBe("echo hi");
expect(res.shellPayload).toBe(null);
expect(res.commandText).toBe("echo hi");
});
test("validateSystemRunCommandConsistency rejects mismatched rawCommand vs direct argv", () => {
@@ -104,6 +105,7 @@ describe("system run command helpers", () => {
const res = validateSystemRunCommandConsistency({
argv: ["/bin/sh", "-lc", "echo hi"],
rawCommand: "echo hi",
allowLegacyShellText: true,
});
expect(res.ok).toBe(true);
if (!res.ok) {
@@ -123,6 +125,7 @@ describe("system run command helpers", () => {
const res = validateSystemRunCommandConsistency({
argv: ["/usr/bin/env", "bash", "-lc", "echo hi"],
rawCommand: "echo hi",
allowLegacyShellText: true,
});
expect(res.ok).toBe(true);
if (!res.ok) {
@@ -148,8 +151,8 @@ describe("system run command helpers", () => {
if (!res.ok) {
throw new Error("unreachable");
}
expect(res.shellCommand).toBe("echo hi");
expect(res.cmdText).toBe(raw);
expect(res.shellPayload).toBe("echo hi");
expect(res.commandText).toBe(raw);
expect(res.previewText).toBe(null);
});
@@ -177,8 +180,8 @@ describe("system run command helpers", () => {
expect(res.details?.code).toBe("MISSING_COMMAND");
});
test("resolveSystemRunCommand returns normalized argv and cmdText", () => {
const res = resolveSystemRunCommand({
test("resolveSystemRunCommandRequest accepts legacy shell payloads but returns canonical command text", () => {
const res = resolveSystemRunCommandRequest({
command: ["cmd.exe", "/d", "/s", "/c", "echo", "SAFE&&whoami"],
rawCommand: "echo SAFE&&whoami",
});
@@ -187,12 +190,12 @@ describe("system run command helpers", () => {
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");
expect(res.shellPayload).toBe("echo SAFE&&whoami");
expect(res.commandText).toBe("cmd.exe /d /s /c echo SAFE&&whoami");
expect(res.previewText).toBe("echo SAFE&&whoami");
});
test("resolveSystemRunCommand binds cmdText to full argv for shell-wrapper positional-argv carriers", () => {
test("resolveSystemRunCommand binds commandText to full argv for shell-wrapper positional-argv carriers", () => {
const res = resolveSystemRunCommand({
command: ["/bin/sh", "-lc", '$0 "$1"', "/usr/bin/touch", "/tmp/marker"],
});
@@ -200,12 +203,12 @@ describe("system run command helpers", () => {
if (!res.ok) {
throw new Error("unreachable");
}
expect(res.shellCommand).toBe('$0 "$1"');
expect(res.cmdText).toBe('/bin/sh -lc "$0 \\"$1\\"" /usr/bin/touch /tmp/marker');
expect(res.shellPayload).toBe('$0 "$1"');
expect(res.commandText).toBe('/bin/sh -lc "$0 \\"$1\\"" /usr/bin/touch /tmp/marker');
expect(res.previewText).toBe(null);
});
test("resolveSystemRunCommand binds cmdText to full argv when env prelude modifies shell wrapper", () => {
test("resolveSystemRunCommand binds commandText to full argv when env prelude modifies shell wrapper", () => {
const res = resolveSystemRunCommand({
command: ["/usr/bin/env", "BASH_ENV=/tmp/payload.sh", "bash", "-lc", "echo hi"],
});
@@ -213,12 +216,12 @@ describe("system run command helpers", () => {
if (!res.ok) {
throw new Error("unreachable");
}
expect(res.shellCommand).toBe("echo hi");
expect(res.cmdText).toBe('/usr/bin/env BASH_ENV=/tmp/payload.sh bash -lc "echo hi"');
expect(res.shellPayload).toBe("echo hi");
expect(res.commandText).toBe('/usr/bin/env BASH_ENV=/tmp/payload.sh bash -lc "echo hi"');
expect(res.previewText).toBe(null);
});
test("resolveSystemRunCommand keeps wrapper preview separate from approval text", () => {
test("resolveSystemRunCommand keeps wrapper preview separate from canonical command text", () => {
const res = resolveSystemRunCommand({
command: ["./env", "sh", "-c", "jq --version"],
});
@@ -226,7 +229,7 @@ describe("system run command helpers", () => {
if (!res.ok) {
throw new Error("unreachable");
}
expect(res.cmdText).toBe("jq --version");
expect(res.commandText).toBe('./env sh -c "jq --version"');
expect(res.previewText).toBe("jq --version");
});
@@ -239,8 +242,20 @@ describe("system run command helpers", () => {
if (!res.ok) {
throw new Error("unreachable");
}
expect(res.cmdText).toBe('./env sh -c "jq --version"');
expect(res.commandText).toBe('./env sh -c "jq --version"');
expect(res.previewText).toBe("jq --version");
expect(res.shellCommand).toBe("jq --version");
expect(res.shellPayload).toBe("jq --version");
});
test("resolveSystemRunCommand rejects legacy shell payload text in strict mode", () => {
const res = resolveSystemRunCommand({
command: ["/bin/sh", "-lc", "echo hi"],
rawCommand: "echo hi",
});
expect(res.ok).toBe(false);
if (res.ok) {
throw new Error("unreachable");
}
expect(res.message).toContain("rawCommand does not match command");
});
});

View File

@@ -14,8 +14,8 @@ import {
export type SystemRunCommandValidation =
| {
ok: true;
shellCommand: string | null;
cmdText: string;
shellPayload: string | null;
commandText: string;
previewText: string | null;
}
| {
@@ -28,9 +28,8 @@ export type ResolvedSystemRunCommand =
| {
ok: true;
argv: string[];
rawCommand: string | null;
shellCommand: string | null;
cmdText: string;
commandText: string;
shellPayload: string | null;
previewText: string | null;
}
| {
@@ -58,6 +57,12 @@ export function extractShellCommandFromArgv(argv: string[]): string | null {
return extractShellWrapperCommand(argv).command;
}
type SystemRunCommandDisplay = {
shellPayload: string | null;
commandText: string;
previewText: string | null;
};
const POSIX_OR_POWERSHELL_INLINE_WRAPPER_NAMES = new Set([
"ash",
"bash",
@@ -100,29 +105,42 @@ function hasTrailingPositionalArgvAfterInlineCommand(argv: string[]): boolean {
return wrapperArgv.slice(inlineCommandIndex + 1).some((entry) => entry.trim().length > 0);
}
function buildSystemRunCommandDisplay(argv: string[]): SystemRunCommandDisplay {
const shellWrapperResolution = extractShellWrapperCommand(argv);
const shellPayload = shellWrapperResolution.command;
const shellWrapperPositionalArgv = hasTrailingPositionalArgvAfterInlineCommand(argv);
const envManipulationBeforeShellWrapper =
shellWrapperResolution.isWrapper && hasEnvManipulationBeforeShellWrapper(argv);
const formattedArgv = formatExecCommand(argv);
const previewText =
shellPayload !== null && !envManipulationBeforeShellWrapper && !shellWrapperPositionalArgv
? shellPayload.trim()
: null;
return {
shellPayload,
commandText: formattedArgv,
previewText,
};
}
function normalizeRawCommandText(rawCommand?: unknown): string | null {
return typeof rawCommand === "string" && rawCommand.trim().length > 0 ? rawCommand.trim() : null;
}
export function validateSystemRunCommandConsistency(params: {
argv: string[];
rawCommand?: string | null;
allowLegacyShellText?: boolean;
}): SystemRunCommandValidation {
const raw =
typeof params.rawCommand === "string" && params.rawCommand.trim().length > 0
? params.rawCommand.trim()
: null;
const shellWrapperResolution = extractShellWrapperCommand(params.argv);
const shellCommand = shellWrapperResolution.command;
const shellWrapperPositionalArgv = hasTrailingPositionalArgvAfterInlineCommand(params.argv);
const envManipulationBeforeShellWrapper =
shellWrapperResolution.isWrapper && hasEnvManipulationBeforeShellWrapper(params.argv);
const mustBindDisplayToFullArgv = envManipulationBeforeShellWrapper || shellWrapperPositionalArgv;
const formattedArgv = formatExecCommand(params.argv);
const legacyShellText =
shellCommand !== null && !mustBindDisplayToFullArgv ? shellCommand.trim() : null;
const previewText = legacyShellText;
const cmdText = raw ?? legacyShellText ?? formattedArgv;
const raw = normalizeRawCommandText(params.rawCommand);
const display = buildSystemRunCommandDisplay(params.argv);
if (raw) {
const matchesCanonicalArgv = raw === formattedArgv;
const matchesLegacyShellText = legacyShellText !== null && raw === legacyShellText;
const matchesCanonicalArgv = raw === display.commandText;
const matchesLegacyShellText =
params.allowLegacyShellText === true &&
display.previewText !== null &&
raw === display.previewText;
if (!matchesCanonicalArgv && !matchesLegacyShellText) {
return {
ok: false,
@@ -130,8 +148,8 @@ export function validateSystemRunCommandConsistency(params: {
details: {
code: "RAW_COMMAND_MISMATCH",
rawCommand: raw,
inferred: legacyShellText ?? formattedArgv,
formattedArgv,
inferred: display.commandText,
formattedArgv: display.commandText,
},
};
}
@@ -139,10 +157,9 @@ export function validateSystemRunCommandConsistency(params: {
return {
ok: true,
// Only treat this as a shell command when argv is a recognized shell wrapper.
shellCommand: shellCommand !== null ? shellCommand : null,
cmdText,
previewText,
shellPayload: display.shellPayload,
commandText: display.commandText,
previewText: display.previewText,
};
}
@@ -150,10 +167,7 @@ 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 raw = normalizeRawCommandText(params.rawCommand);
const command = Array.isArray(params.command) ? params.command : [];
if (command.length === 0) {
if (raw) {
@@ -166,9 +180,8 @@ export function resolveSystemRunCommand(params: {
return {
ok: true,
argv: [],
rawCommand: null,
shellCommand: null,
cmdText: "",
commandText: "",
shellPayload: null,
previewText: null,
};
}
@@ -177,6 +190,7 @@ export function resolveSystemRunCommand(params: {
const validation = validateSystemRunCommandConsistency({
argv,
rawCommand: raw,
allowLegacyShellText: false,
});
if (!validation.ok) {
return {
@@ -189,9 +203,54 @@ export function resolveSystemRunCommand(params: {
return {
ok: true,
argv,
rawCommand: raw,
shellCommand: validation.shellCommand,
cmdText: validation.cmdText,
commandText: validation.commandText,
shellPayload: validation.shellPayload,
previewText: validation.previewText,
};
}
export function resolveSystemRunCommandRequest(params: {
command?: unknown;
rawCommand?: unknown;
}): ResolvedSystemRunCommand {
const raw = normalizeRawCommandText(params.rawCommand);
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: [],
commandText: "",
shellPayload: null,
previewText: null,
};
}
const argv = command.map((v) => String(v));
const validation = validateSystemRunCommandConsistency({
argv,
rawCommand: raw,
allowLegacyShellText: true,
});
if (!validation.ok) {
return {
ok: false,
message: validation.message,
details: validation.details ?? { code: "RAW_COMMAND_MISMATCH" },
};
}
return {
ok: true,
argv,
commandText: validation.commandText,
shellPayload: validation.shellPayload,
previewText: validation.previewText,
};
}

View File

@@ -101,7 +101,7 @@ describe("hardenApprovedExecutionPaths", () => {
mode: "build-plan",
argv: ["env", "sh", "-c", "echo SAFE"],
expectedArgv: () => ["env", "sh", "-c", "echo SAFE"],
expectedCmdText: "echo SAFE",
expectedCmdText: 'env sh -c "echo SAFE"',
expectedCommandPreview: "echo SAFE",
},
{
@@ -144,7 +144,7 @@ describe("hardenApprovedExecutionPaths", () => {
mode: "build-plan",
argv: ["./env", "sh", "-c", "echo SAFE"],
expectedArgv: () => ["./env", "sh", "-c", "echo SAFE"],
expectedCmdText: "echo SAFE",
expectedCmdText: './env sh -c "echo SAFE"',
checkRawCommandMatchesArgv: true,
expectedCommandPreview: "echo SAFE",
},
@@ -175,10 +175,10 @@ describe("hardenApprovedExecutionPaths", () => {
}
expect(prepared.plan.argv).toEqual(testCase.expectedArgv({ pathToken }));
if (testCase.expectedCmdText) {
expect(prepared.cmdText).toBe(testCase.expectedCmdText);
expect(prepared.plan.commandText).toBe(testCase.expectedCmdText);
}
if (testCase.checkRawCommandMatchesArgv) {
expect(prepared.plan.rawCommand).toBe(formatExecCommand(prepared.plan.argv));
expect(prepared.plan.commandText).toBe(formatExecCommand(prepared.plan.argv));
}
if ("expectedCommandPreview" in testCase) {
expect(prepared.plan.commandPreview ?? null).toBe(testCase.expectedCommandPreview);

View File

@@ -17,7 +17,7 @@ import {
POSIX_INLINE_COMMAND_FLAGS,
resolveInlineCommandMatch,
} from "../infra/shell-inline-command.js";
import { formatExecCommand, resolveSystemRunCommand } from "../infra/system-run-command.js";
import { formatExecCommand, resolveSystemRunCommandRequest } from "../infra/system-run-command.js";
export type ApprovedCwdSnapshot = {
cwd: string;
@@ -630,8 +630,8 @@ export function buildSystemRunApprovalPlan(params: {
cwd?: unknown;
agentId?: unknown;
sessionKey?: unknown;
}): { ok: true; plan: SystemRunApprovalPlan; cmdText: string } | { ok: false; message: string } {
const command = resolveSystemRunCommand({
}): { ok: true; plan: SystemRunApprovalPlan } | { ok: false; message: string } {
const command = resolveSystemRunCommandRequest({
command: params.command,
rawCommand: params.rawCommand,
});
@@ -644,15 +644,15 @@ export function buildSystemRunApprovalPlan(params: {
const hardening = hardenApprovedExecutionPaths({
approvedByAsk: true,
argv: command.argv,
shellCommand: command.shellCommand,
shellCommand: command.shellPayload,
cwd: normalizeString(params.cwd) ?? undefined,
});
if (!hardening.ok) {
return { ok: false, message: hardening.message };
}
const rawCommand = formatExecCommand(hardening.argv) || null;
const commandText = formatExecCommand(hardening.argv);
const commandPreview =
command.previewText?.trim() && command.previewText.trim() !== rawCommand
command.previewText?.trim() && command.previewText.trim() !== commandText
? command.previewText.trim()
: null;
const mutableFileOperand = resolveMutableFileOperandSnapshotSync({
@@ -667,12 +667,11 @@ export function buildSystemRunApprovalPlan(params: {
plan: {
argv: hardening.argv,
cwd: hardening.cwd ?? null,
rawCommand,
commandText,
commandPreview,
agentId: normalizeString(params.agentId),
sessionKey: normalizeString(params.sessionKey),
mutableFileOperand: mutableFileOperand.snapshot ?? undefined,
},
cmdText: commandPreview ?? rawCommand ?? formatExecCommand(hardening.argv),
};
}

View File

@@ -432,7 +432,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
expectInvokeOk(sendInvokeResult, { payloadContains: "app-ok" });
});
it("forwards canonical cmdText to mac app exec host for positional-argv shell wrappers", async () => {
it("forwards canonical command text to mac app exec host for positional-argv shell wrappers", async () => {
const { runViaMacAppExecHost } = await runSystemInvoke({
preferMacAppExecHost: true,
command: ["/bin/sh", "-lc", '$0 "$1"', "/usr/bin/touch", "/tmp/marker"],
@@ -505,7 +505,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
approvals: expect.anything(),
request: expect.objectContaining({
command: ["env", "sh", "-c", "echo SAFE"],
rawCommand: "echo SAFE",
rawCommand: 'env sh -c "echo SAFE"',
cwd: canonicalCwd,
}),
});
@@ -593,7 +593,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
const { runCommand, sendInvokeResult } = await runSystemInvoke({
preferMacAppExecHost: false,
command: prepared.plan.argv,
rawCommand: prepared.plan.rawCommand,
rawCommand: prepared.plan.commandText,
approved: true,
security: "full",
ask: "off",
@@ -789,7 +789,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
const { runCommand, sendInvokeResult } = await runSystemInvoke({
preferMacAppExecHost: false,
command: prepared.plan.argv,
rawCommand: prepared.plan.rawCommand,
rawCommand: prepared.plan.commandText,
systemRunPlan: prepared.plan,
cwd: prepared.plan.cwd ?? tmp,
approved: true,
@@ -827,7 +827,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
const { runCommand, sendInvokeResult } = await runSystemInvoke({
preferMacAppExecHost: false,
command: prepared.plan.argv,
rawCommand: prepared.plan.rawCommand,
rawCommand: prepared.plan.commandText,
systemRunPlan: prepared.plan,
cwd: prepared.plan.cwd ?? tmp,
approved: true,
@@ -866,7 +866,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
const { runCommand, sendInvokeResult } = await runSystemInvoke({
preferMacAppExecHost: false,
command: prepared.plan.argv,
rawCommand: prepared.plan.rawCommand,
rawCommand: prepared.plan.commandText,
systemRunPlan: prepared.plan,
cwd: prepared.plan.cwd ?? tmp,
approved: true,
@@ -908,7 +908,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
const { runCommand, sendInvokeResult } = await runSystemInvoke({
preferMacAppExecHost: false,
command: prepared.plan.argv,
rawCommand: prepared.plan.rawCommand,
rawCommand: prepared.plan.commandText,
systemRunPlan: prepared.plan,
cwd: prepared.plan.cwd ?? tmp,
approved: true,

View File

@@ -16,7 +16,7 @@ import type { ExecHostRequest, ExecHostResponse, ExecHostRunResult } from "../in
import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js";
import { sanitizeSystemRunEnvOverrides } from "../infra/host-env-security.js";
import { normalizeSystemRunApprovalPlan } from "../infra/system-run-approval-binding.js";
import { resolveSystemRunCommand } from "../infra/system-run-command.js";
import { resolveSystemRunCommandRequest } from "../infra/system-run-command.js";
import { logWarn } from "../logger.js";
import { evaluateSystemRunPolicy, resolveExecApprovalDecision } from "./exec-policy.js";
import {
@@ -56,7 +56,7 @@ type SystemRunDeniedReason =
type SystemRunExecutionContext = {
sessionKey: string;
runId: string;
cmdText: string;
commandText: string;
suppressNotifyOnExit: boolean;
};
@@ -64,8 +64,9 @@ type ResolvedExecApprovals = ReturnType<typeof resolveExecApprovals>;
type SystemRunParsePhase = {
argv: string[];
shellCommand: string | null;
cmdText: string;
shellPayload: string | null;
commandText: string;
commandPreview: string | null;
approvalPlan: import("../infra/exec-approvals.js").SystemRunApprovalPlan | null;
agentId: string | undefined;
sessionKey: string;
@@ -167,7 +168,7 @@ async function sendSystemRunDenied(
sessionKey: execution.sessionKey,
runId: execution.runId,
host: "node",
command: execution.cmdText,
command: execution.commandText,
reason: params.reason,
suppressNotifyOnExit: execution.suppressNotifyOnExit,
}),
@@ -184,7 +185,7 @@ export { buildSystemRunApprovalPlan } from "./invoke-system-run-plan.js";
async function parseSystemRunPhase(
opts: HandleSystemRunInvokeOptions,
): Promise<SystemRunParsePhase | null> {
const command = resolveSystemRunCommand({
const command = resolveSystemRunCommandRequest({
command: opts.params.command,
rawCommand: opts.params.rawCommand,
});
@@ -203,8 +204,8 @@ async function parseSystemRunPhase(
return null;
}
const shellCommand = command.shellCommand;
const cmdText = command.cmdText;
const shellPayload = command.shellPayload;
const commandText = command.commandText;
const approvalPlan =
opts.params.systemRunPlan === undefined
? null
@@ -222,17 +223,18 @@ async function parseSystemRunPhase(
const suppressNotifyOnExit = opts.params.suppressNotifyOnExit === true;
const envOverrides = sanitizeSystemRunEnvOverrides({
overrides: opts.params.env ?? undefined,
shellWrapper: shellCommand !== null,
shellWrapper: shellPayload !== null,
});
return {
argv: command.argv,
shellCommand,
cmdText,
shellPayload,
commandText,
commandPreview: command.previewText,
approvalPlan,
agentId,
sessionKey,
runId,
execution: { sessionKey, runId, cmdText, suppressNotifyOnExit },
execution: { sessionKey, runId, commandText, suppressNotifyOnExit },
approvalDecision: resolveExecApprovalDecision(opts.params.approvalDecision),
envOverrides,
env: opts.sanitizeEnv(envOverrides),
@@ -270,7 +272,7 @@ async function evaluateSystemRunPolicyPhase(
});
const bins = autoAllowSkills ? await opts.skillBins.current() : [];
let { analysisOk, allowlistMatches, allowlistSatisfied, segments } = evaluateSystemRunAllowlist({
shellCommand: parsed.shellCommand,
shellCommand: parsed.shellPayload,
argv: parsed.argv,
approvals,
security,
@@ -283,7 +285,7 @@ async function evaluateSystemRunPolicyPhase(
autoAllowSkills,
});
const isWindows = process.platform === "win32";
const cmdInvocation = parsed.shellCommand
const cmdInvocation = parsed.shellPayload
? opts.isCmdExeInvocation(segments[0]?.argv ?? [])
: opts.isCmdExeInvocation(parsed.argv);
const policy = evaluateSystemRunPolicy({
@@ -295,7 +297,7 @@ async function evaluateSystemRunPolicyPhase(
approved: parsed.approved,
isWindows,
cmdInvocation,
shellWrapperInvocation: parsed.shellCommand !== null,
shellWrapperInvocation: parsed.shellPayload !== null,
});
analysisOk = policy.analysisOk;
allowlistSatisfied = policy.allowlistSatisfied;
@@ -308,7 +310,7 @@ async function evaluateSystemRunPolicyPhase(
}
// Fail closed if policy/runtime drift re-allows unapproved shell wrappers.
if (security === "allowlist" && parsed.shellCommand && !policy.approvedByAsk) {
if (security === "allowlist" && parsed.shellPayload && !policy.approvedByAsk) {
await sendSystemRunDenied(opts, parsed.execution, {
reason: "approval-required",
message: "SYSTEM_RUN_DENIED: approval required",
@@ -319,7 +321,7 @@ async function evaluateSystemRunPolicyPhase(
const hardenedPaths = hardenApprovedExecutionPaths({
approvedByAsk: policy.approvedByAsk,
argv: parsed.argv,
shellCommand: parsed.shellCommand,
shellCommand: parsed.shellPayload,
cwd: parsed.cwd,
});
if (!hardenedPaths.ok) {
@@ -340,7 +342,7 @@ async function evaluateSystemRunPolicyPhase(
const plannedAllowlistArgv = resolvePlannedAllowlistArgv({
security,
shellCommand: parsed.shellCommand,
shellCommand: parsed.shellPayload,
policy,
segments,
});
@@ -405,7 +407,7 @@ async function executeSystemRunPhase(
command: phase.plannedAllowlistArgv ?? phase.argv,
// Forward canonical display text so companion approval/prompt surfaces bind to
// the exact command context already validated on the node-host.
rawCommand: phase.cmdText || null,
rawCommand: phase.commandText || null,
cwd: phase.cwd ?? null,
env: phase.envOverrides ?? null,
timeoutMs: phase.timeoutMs ?? null,
@@ -437,7 +439,7 @@ async function executeSystemRunPhase(
await opts.sendExecFinishedEvent({
sessionKey: phase.sessionKey,
runId: phase.runId,
cmdText: phase.cmdText,
commandText: phase.commandText,
result,
suppressNotifyOnExit: phase.suppressNotifyOnExit,
});
@@ -476,7 +478,7 @@ async function executeSystemRunPhase(
phase.approvals.file,
phase.agentId,
match,
phase.cmdText,
phase.commandText,
phase.segments[0]?.resolution?.resolvedPath,
);
}
@@ -496,7 +498,7 @@ async function executeSystemRunPhase(
security: phase.security,
isWindows: phase.isWindows,
policy: phase.policy,
shellCommand: phase.shellCommand,
shellCommand: phase.shellPayload,
segments: phase.segments,
});
@@ -505,7 +507,7 @@ async function executeSystemRunPhase(
await opts.sendExecFinishedEvent({
sessionKey: phase.sessionKey,
runId: phase.runId,
cmdText: phase.cmdText,
commandText: phase.commandText,
result,
suppressNotifyOnExit: phase.suppressNotifyOnExit,
});

View File

@@ -51,7 +51,7 @@ export type ExecFinishedResult = {
export type ExecFinishedEventParams = {
sessionKey: string;
runId: string;
cmdText: string;
commandText: string;
result: ExecFinishedResult;
suppressNotifyOnExit?: boolean;
};

View File

@@ -350,7 +350,7 @@ async function sendExecFinishedEvent(
sessionKey: params.sessionKey,
runId: params.runId,
host: "node",
command: params.cmdText,
command: params.commandText,
exitCode: params.result.exitCode ?? undefined,
timedOut: params.result.timedOut,
success: params.result.success,
@@ -505,7 +505,6 @@ export async function handleInvoke(
return;
}
await sendJsonPayloadResult(client, frame, {
cmdText: prepared.cmdText,
plan: prepared.plan,
});
} catch (err) {
@@ -549,8 +548,8 @@ export async function handleInvoke(
sendInvokeResult: async (result) => {
await sendInvokeResult(client, frame, result);
},
sendExecFinishedEvent: async ({ sessionKey, runId, cmdText, result }) => {
await sendExecFinishedEvent({ client, sessionKey, runId, cmdText, result });
sendExecFinishedEvent: async ({ sessionKey, runId, commandText, result }) => {
await sendExecFinishedEvent({ client, sessionKey, runId, commandText, result });
},
preferMacAppExecHost,
});

View File

@@ -3,6 +3,7 @@ import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
import { GatewayClient } from "../gateway/client.js";
import { createOperatorApprovalsGatewayClient } from "../gateway/operator-approvals-client.js";
import type { EventFrame } from "../gateway/protocol/index.js";
import { resolveExecApprovalCommandDisplay } from "../infra/exec-approval-command-display.js";
import {
buildExecApprovalPendingReplyPayload,
type ExecApprovalPendingReplyParams,
@@ -312,7 +313,7 @@ export class TelegramExecApprovalHandler {
approvalId: request.id,
approvalSlug: request.id.slice(0, 8),
approvalCommandId: request.id,
command: request.request.command,
command: resolveExecApprovalCommandDisplay(request.request).commandText,
cwd: request.request.cwd ?? undefined,
host: request.request.host === "node" ? "node" : "gateway",
nodeId: request.request.nodeId ?? undefined,

View File

@@ -10,19 +10,18 @@ type SystemRunPrepareInput = {
export function buildSystemRunPreparePayload(params: SystemRunPrepareInput) {
const argv = Array.isArray(params.command) ? params.command.map(String) : [];
const rawCommand =
const previewCommand =
typeof params.rawCommand === "string" && params.rawCommand.trim().length > 0
? params.rawCommand
: null;
const formattedArgv = formatExecCommand(argv) || null;
const commandPreview = rawCommand && rawCommand !== formattedArgv ? rawCommand : null;
const commandText = formatExecCommand(argv) || "";
const commandPreview = previewCommand && previewCommand !== commandText ? previewCommand : null;
return {
payload: {
cmdText: rawCommand ?? argv.join(" "),
plan: {
argv,
cwd: typeof params.cwd === "string" ? params.cwd : null,
rawCommand: formattedArgv,
commandText,
commandPreview,
agentId: typeof params.agentId === "string" ? params.agentId : null,
sessionKey: typeof params.sessionKey === "string" ? params.sessionKey : null,

View File

@@ -18,12 +18,12 @@
}
},
{
"name": "shell wrapper accepts shell payload raw command when no positional argv carriers",
"name": "shell wrapper accepts shell payload raw command at ingress",
"command": ["/bin/sh", "-lc", "echo hi"],
"rawCommand": "echo hi",
"expected": {
"valid": true,
"displayCommand": "echo hi"
"displayCommand": "/bin/sh -lc \"echo hi\""
}
},
{
@@ -45,12 +45,12 @@
}
},
{
"name": "env wrapper shell payload accepted when prelude has no env modifiers",
"name": "env wrapper shell payload accepted at ingress when prelude has no env modifiers",
"command": ["/usr/bin/env", "bash", "-lc", "echo hi"],
"rawCommand": "echo hi",
"expected": {
"valid": true,
"displayCommand": "echo hi"
"displayCommand": "/usr/bin/env bash -lc \"echo hi\""
}
},
{