mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-30 02:18:27 +00:00
fix(node-host): bind approved script operands
This commit is contained in:
@@ -299,6 +299,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/OpenAI-responses compatibility: strip unsupported `store` payload fields when `supportsStore=false` (including OpenAI-compatible non-OpenAI providers) while preserving server-compaction payload behavior. (#39219) Thanks @ademczuk.
|
||||
- Agents/model fallback visibility: warn when configured model IDs cannot be resolved and fallback is applied, with log-safe sanitization of model text to prevent control-sequence injection in warning output. (#39215) Thanks @ademczuk.
|
||||
- Outbound delivery replay safety: use two-phase delivery ACK markers (`.json` -> `.delivered` -> unlink) and startup marker cleanup so crash windows between send and cleanup do not replay already-delivered messages. (#38668) Thanks @Gundam98.
|
||||
- Nodes/system.run approval binding: carry prepared approval plans through gateway forwarding and bind interpreter-style script operands across approval to execution, so post-approval script rewrites are denied while unchanged approved script runs keep working. Thanks @tdjackey for reporting.
|
||||
|
||||
## 2026.3.2
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ Trust model note:
|
||||
- Gateway-authenticated callers are trusted operators for that Gateway.
|
||||
- Paired nodes extend that trusted operator capability onto the node host.
|
||||
- Exec approvals reduce accidental execution risk, but are not a per-user auth boundary.
|
||||
- Approved node-host runs also bind canonical execution context: canonical cwd, pinned executable
|
||||
path when applicable, and interpreter-style script operands. If a bound script changes after
|
||||
approval but before execution, the run is denied instead of executing drifted content.
|
||||
|
||||
macOS split:
|
||||
|
||||
|
||||
@@ -278,6 +278,7 @@ describe("sanitizeSystemRunParamsForForwarding", () => {
|
||||
const forwarded = result.params as Record<string, unknown>;
|
||||
expect(forwarded.command).toEqual(["/usr/bin/echo", "SAFE"]);
|
||||
expect(forwarded.rawCommand).toBe("/usr/bin/echo SAFE");
|
||||
expect(forwarded.systemRunPlan).toEqual(record.request.systemRunPlan);
|
||||
expect(forwarded.cwd).toBe("/real/cwd");
|
||||
expect(forwarded.agentId).toBe("main");
|
||||
expect(forwarded.sessionKey).toBe("agent:main:main");
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type SystemRunParamsLike = {
|
||||
command?: unknown;
|
||||
rawCommand?: unknown;
|
||||
systemRunPlan?: unknown;
|
||||
cwd?: unknown;
|
||||
env?: unknown;
|
||||
timeoutMs?: unknown;
|
||||
@@ -69,6 +70,7 @@ function pickSystemRunParams(raw: Record<string, unknown>): Record<string, unkno
|
||||
for (const key of [
|
||||
"command",
|
||||
"rawCommand",
|
||||
"systemRunPlan",
|
||||
"cwd",
|
||||
"env",
|
||||
"timeoutMs",
|
||||
@@ -225,6 +227,7 @@ export function sanitizeSystemRunParamsForForwarding(opts: {
|
||||
}
|
||||
if (runtimeContext.plan) {
|
||||
next.command = [...runtimeContext.plan.argv];
|
||||
next.systemRunPlan = runtimeContext.plan;
|
||||
if (runtimeContext.rawCommand) {
|
||||
next.rawCommand = runtimeContext.rawCommand;
|
||||
} else {
|
||||
|
||||
@@ -98,6 +98,19 @@ export const ExecApprovalRequestParamsSchema = Type.Object(
|
||||
rawCommand: Type.Union([Type.String(), Type.Null()]),
|
||||
agentId: Type.Union([Type.String(), Type.Null()]),
|
||||
sessionKey: Type.Union([Type.String(), Type.Null()]),
|
||||
mutableFileOperand: Type.Optional(
|
||||
Type.Union([
|
||||
Type.Object(
|
||||
{
|
||||
argvIndex: Type.Integer({ minimum: 0 }),
|
||||
path: Type.String(),
|
||||
sha256: Type.String(),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
Type.Null(),
|
||||
]),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
|
||||
@@ -43,12 +43,19 @@ export type SystemRunApprovalBinding = {
|
||||
envHash: string | null;
|
||||
};
|
||||
|
||||
export type SystemRunApprovalFileOperand = {
|
||||
argvIndex: number;
|
||||
path: string;
|
||||
sha256: string;
|
||||
};
|
||||
|
||||
export type SystemRunApprovalPlan = {
|
||||
argv: string[];
|
||||
cwd: string | null;
|
||||
rawCommand: string | null;
|
||||
agentId: string | null;
|
||||
sessionKey: string | null;
|
||||
mutableFileOperand?: SystemRunApprovalFileOperand | null;
|
||||
};
|
||||
|
||||
export type ExecApprovalRequestPayload = {
|
||||
|
||||
@@ -1,10 +1,42 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { SystemRunApprovalBinding, SystemRunApprovalPlan } from "./exec-approvals.js";
|
||||
import type {
|
||||
SystemRunApprovalBinding,
|
||||
SystemRunApprovalFileOperand,
|
||||
SystemRunApprovalPlan,
|
||||
} from "./exec-approvals.js";
|
||||
import { normalizeEnvVarKey } from "./host-env-security.js";
|
||||
import { normalizeNonEmptyString, normalizeStringArray } from "./system-run-normalize.js";
|
||||
|
||||
type NormalizedSystemRunEnvEntry = [key: string, value: string];
|
||||
|
||||
function normalizeSystemRunApprovalFileOperand(
|
||||
value: unknown,
|
||||
): SystemRunApprovalFileOperand | null | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const candidate = value as Record<string, unknown>;
|
||||
const argvIndex =
|
||||
typeof candidate.argvIndex === "number" &&
|
||||
Number.isInteger(candidate.argvIndex) &&
|
||||
candidate.argvIndex >= 0
|
||||
? candidate.argvIndex
|
||||
: null;
|
||||
const filePath = normalizeNonEmptyString(candidate.path);
|
||||
const sha256 = normalizeNonEmptyString(candidate.sha256);
|
||||
if (argvIndex === null || !filePath || !sha256) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
argvIndex,
|
||||
path: filePath,
|
||||
sha256,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSystemRunApprovalPlan(value: unknown): SystemRunApprovalPlan | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
@@ -14,12 +46,17 @@ export function normalizeSystemRunApprovalPlan(value: unknown): SystemRunApprova
|
||||
if (argv.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const mutableFileOperand = normalizeSystemRunApprovalFileOperand(candidate.mutableFileOperand);
|
||||
if (candidate.mutableFileOperand !== undefined && mutableFileOperand === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
argv,
|
||||
cwd: normalizeNonEmptyString(candidate.cwd),
|
||||
rawCommand: normalizeNonEmptyString(candidate.rawCommand),
|
||||
agentId: normalizeNonEmptyString(candidate.agentId),
|
||||
sessionKey: normalizeNonEmptyString(candidate.sessionKey),
|
||||
mutableFileOperand: mutableFileOperand ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -128,4 +128,28 @@ describe("hardenApprovedExecutionPaths", () => {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("captures mutable shell script operands in approval plans", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-script-plan-"));
|
||||
const script = path.join(tmp, "run.sh");
|
||||
fs.writeFileSync(script, "#!/bin/sh\necho SAFE\n");
|
||||
fs.chmodSync(script, 0o755);
|
||||
try {
|
||||
const prepared = buildSystemRunApprovalPlan({
|
||||
command: ["/bin/sh", "./run.sh"],
|
||||
cwd: tmp,
|
||||
});
|
||||
expect(prepared.ok).toBe(true);
|
||||
if (!prepared.ok) {
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
expect(prepared.plan.mutableFileOperand).toEqual({
|
||||
argvIndex: 1,
|
||||
path: fs.realpathSync(script),
|
||||
sha256: expect.any(String),
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { SystemRunApprovalPlan } from "../infra/exec-approvals.js";
|
||||
import type {
|
||||
SystemRunApprovalFileOperand,
|
||||
SystemRunApprovalPlan,
|
||||
} from "../infra/exec-approvals.js";
|
||||
import { resolveCommandResolutionFromArgv } from "../infra/exec-command-resolution.js";
|
||||
import {
|
||||
POSIX_SHELL_WRAPPERS,
|
||||
normalizeExecutableToken,
|
||||
unwrapKnownDispatchWrapperInvocation,
|
||||
unwrapKnownShellMultiplexerInvocation,
|
||||
} from "../infra/exec-wrapper-resolution.js";
|
||||
import { sameFileIdentity } from "../infra/file-identity.js";
|
||||
import {
|
||||
POSIX_INLINE_COMMAND_FLAGS,
|
||||
resolveInlineCommandMatch,
|
||||
} from "../infra/shell-inline-command.js";
|
||||
import { formatExecCommand, resolveSystemRunCommand } from "../infra/system-run-command.js";
|
||||
|
||||
export type ApprovedCwdSnapshot = {
|
||||
@@ -10,6 +24,14 @@ export type ApprovedCwdSnapshot = {
|
||||
stat: fs.Stats;
|
||||
};
|
||||
|
||||
const MUTABLE_ARGV1_INTERPRETER_PATTERNS = [
|
||||
/^(?:node|nodejs)$/,
|
||||
/^perl$/,
|
||||
/^php$/,
|
||||
/^python(?:\d+(?:\.\d+)*)?$/,
|
||||
/^ruby$/,
|
||||
] as const;
|
||||
|
||||
function normalizeString(value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
@@ -68,6 +90,125 @@ function shouldPinExecutableForApproval(params: {
|
||||
return (params.wrapperChain?.length ?? 0) === 0;
|
||||
}
|
||||
|
||||
function hashFileContentsSync(filePath: string): string {
|
||||
return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
|
||||
}
|
||||
|
||||
function unwrapArgvForMutableOperand(argv: string[]): { argv: string[]; baseIndex: number } {
|
||||
let current = argv;
|
||||
let baseIndex = 0;
|
||||
while (true) {
|
||||
const dispatchUnwrap = unwrapKnownDispatchWrapperInvocation(current);
|
||||
if (dispatchUnwrap.kind === "unwrapped") {
|
||||
baseIndex += current.length - dispatchUnwrap.argv.length;
|
||||
current = dispatchUnwrap.argv;
|
||||
continue;
|
||||
}
|
||||
const shellMultiplexerUnwrap = unwrapKnownShellMultiplexerInvocation(current);
|
||||
if (shellMultiplexerUnwrap.kind === "unwrapped") {
|
||||
baseIndex += current.length - shellMultiplexerUnwrap.argv.length;
|
||||
current = shellMultiplexerUnwrap.argv;
|
||||
continue;
|
||||
}
|
||||
return { argv: current, baseIndex };
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePosixShellScriptOperandIndex(argv: string[]): number | null {
|
||||
if (
|
||||
resolveInlineCommandMatch(argv, POSIX_INLINE_COMMAND_FLAGS, {
|
||||
allowCombinedC: true,
|
||||
}).valueTokenIndex !== null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
let afterDoubleDash = false;
|
||||
for (let i = 1; i < argv.length; i += 1) {
|
||||
const token = argv[i]?.trim() ?? "";
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
if (token === "-") {
|
||||
return null;
|
||||
}
|
||||
if (!afterDoubleDash && token === "--") {
|
||||
afterDoubleDash = true;
|
||||
continue;
|
||||
}
|
||||
if (!afterDoubleDash && token === "-s") {
|
||||
return null;
|
||||
}
|
||||
if (!afterDoubleDash && token.startsWith("-")) {
|
||||
continue;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveMutableFileOperandIndex(argv: string[]): number | null {
|
||||
const unwrapped = unwrapArgvForMutableOperand(argv);
|
||||
const executable = normalizeExecutableToken(unwrapped.argv[0] ?? "");
|
||||
if (!executable) {
|
||||
return null;
|
||||
}
|
||||
if ((POSIX_SHELL_WRAPPERS as ReadonlySet<string>).has(executable)) {
|
||||
const shellIndex = resolvePosixShellScriptOperandIndex(unwrapped.argv);
|
||||
return shellIndex === null ? null : unwrapped.baseIndex + shellIndex;
|
||||
}
|
||||
if (!MUTABLE_ARGV1_INTERPRETER_PATTERNS.some((pattern) => pattern.test(executable))) {
|
||||
return null;
|
||||
}
|
||||
const operand = unwrapped.argv[1]?.trim() ?? "";
|
||||
if (!operand || operand === "-" || operand.startsWith("-")) {
|
||||
return null;
|
||||
}
|
||||
return unwrapped.baseIndex + 1;
|
||||
}
|
||||
|
||||
function resolveMutableFileOperandSnapshotSync(params: {
|
||||
argv: string[];
|
||||
cwd: string | undefined;
|
||||
}): { ok: true; snapshot: SystemRunApprovalFileOperand | null } | { ok: false; message: string } {
|
||||
const argvIndex = resolveMutableFileOperandIndex(params.argv);
|
||||
if (argvIndex === null) {
|
||||
return { ok: true, snapshot: null };
|
||||
}
|
||||
const rawOperand = params.argv[argvIndex]?.trim();
|
||||
if (!rawOperand) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "SYSTEM_RUN_DENIED: approval requires a stable script operand",
|
||||
};
|
||||
}
|
||||
const resolvedPath = path.resolve(params.cwd ?? process.cwd(), rawOperand);
|
||||
let realPath: string;
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
realPath = fs.realpathSync(resolvedPath);
|
||||
stat = fs.statSync(realPath);
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
message: "SYSTEM_RUN_DENIED: approval requires an existing script operand",
|
||||
};
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "SYSTEM_RUN_DENIED: approval requires a file script operand",
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
snapshot: {
|
||||
argvIndex,
|
||||
path: realPath,
|
||||
sha256: hashFileContentsSync(realPath),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCanonicalApprovalCwdSync(cwd: string):
|
||||
| {
|
||||
ok: true;
|
||||
@@ -135,6 +276,32 @@ export function revalidateApprovedCwdSnapshot(params: { snapshot: ApprovedCwdSna
|
||||
return sameFileIdentity(params.snapshot.stat, current.snapshot.stat);
|
||||
}
|
||||
|
||||
export function revalidateApprovedMutableFileOperand(params: {
|
||||
snapshot: SystemRunApprovalFileOperand;
|
||||
argv: string[];
|
||||
cwd: string | undefined;
|
||||
}): boolean {
|
||||
const operand = params.argv[params.snapshot.argvIndex]?.trim();
|
||||
if (!operand) {
|
||||
return false;
|
||||
}
|
||||
const resolvedPath = path.resolve(params.cwd ?? process.cwd(), operand);
|
||||
let realPath: string;
|
||||
try {
|
||||
realPath = fs.realpathSync(resolvedPath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (realPath !== params.snapshot.path) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return hashFileContentsSync(realPath) === params.snapshot.sha256;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function hardenApprovedExecutionPaths(params: {
|
||||
approvedByAsk: boolean;
|
||||
argv: string[];
|
||||
@@ -257,6 +424,13 @@ export function buildSystemRunApprovalPlan(params: {
|
||||
const rawCommand = hardening.argvChanged
|
||||
? formatExecCommand(hardening.argv) || null
|
||||
: command.cmdText.trim() || null;
|
||||
const mutableFileOperand = resolveMutableFileOperandSnapshotSync({
|
||||
argv: hardening.argv,
|
||||
cwd: hardening.cwd,
|
||||
});
|
||||
if (!mutableFileOperand.ok) {
|
||||
return { ok: false, message: mutableFileOperand.message };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
plan: {
|
||||
@@ -265,7 +439,8 @@ export function buildSystemRunApprovalPlan(params: {
|
||||
rawCommand,
|
||||
agentId: normalizeString(params.agentId),
|
||||
sessionKey: normalizeString(params.sessionKey),
|
||||
mutableFileOperand: mutableFileOperand.snapshot ?? undefined,
|
||||
},
|
||||
cmdText: command.cmdText,
|
||||
cmdText: rawCommand ?? formatExecCommand(hardening.argv),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, type Mock, vi } from "vitest";
|
||||
import type { SystemRunApprovalPlan } from "../infra/exec-approvals.js";
|
||||
import { saveExecApprovals } from "../infra/exec-approvals.js";
|
||||
import type { ExecHostResponse } from "../infra/exec-host.js";
|
||||
import { buildSystemRunApprovalPlan } from "./invoke-system-run-plan.js";
|
||||
@@ -235,6 +236,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
|
||||
runViaResponse?: ExecHostResponse | null;
|
||||
command?: string[];
|
||||
rawCommand?: string | null;
|
||||
systemRunPlan?: SystemRunApprovalPlan | null;
|
||||
cwd?: string;
|
||||
security?: "full" | "allowlist";
|
||||
ask?: "off" | "on-miss" | "always";
|
||||
@@ -289,6 +291,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
|
||||
params: {
|
||||
command: params.command ?? ["echo", "ok"],
|
||||
rawCommand: params.rawCommand,
|
||||
systemRunPlan: params.systemRunPlan,
|
||||
cwd: params.cwd,
|
||||
approved: params.approved ?? false,
|
||||
sessionKey: "agent:main:main",
|
||||
@@ -687,6 +690,76 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("denies approval-based execution when a script operand changes after approval", async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-script-drift-"));
|
||||
const script = path.join(tmp, "run.sh");
|
||||
fs.writeFileSync(script, "#!/bin/sh\necho SAFE\n");
|
||||
fs.chmodSync(script, 0o755);
|
||||
try {
|
||||
const prepared = buildSystemRunApprovalPlan({
|
||||
command: ["/bin/sh", "./run.sh"],
|
||||
cwd: tmp,
|
||||
});
|
||||
expect(prepared.ok).toBe(true);
|
||||
if (!prepared.ok) {
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
fs.writeFileSync(script, "#!/bin/sh\necho PWNED\n");
|
||||
const { runCommand, sendInvokeResult } = await runSystemInvoke({
|
||||
preferMacAppExecHost: false,
|
||||
command: prepared.plan.argv,
|
||||
rawCommand: prepared.plan.rawCommand,
|
||||
systemRunPlan: prepared.plan,
|
||||
cwd: prepared.plan.cwd ?? tmp,
|
||||
approved: true,
|
||||
security: "full",
|
||||
ask: "off",
|
||||
});
|
||||
|
||||
expect(runCommand).not.toHaveBeenCalled();
|
||||
expectInvokeErrorMessage(sendInvokeResult, {
|
||||
message: "SYSTEM_RUN_DENIED: approval script operand changed before execution",
|
||||
exact: true,
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps approved shell script execution working when the script is unchanged", async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-script-stable-"));
|
||||
const script = path.join(tmp, "run.sh");
|
||||
fs.writeFileSync(script, "#!/bin/sh\necho SAFE\n");
|
||||
fs.chmodSync(script, 0o755);
|
||||
try {
|
||||
const prepared = buildSystemRunApprovalPlan({
|
||||
command: ["/bin/sh", "./run.sh"],
|
||||
cwd: tmp,
|
||||
});
|
||||
expect(prepared.ok).toBe(true);
|
||||
if (!prepared.ok) {
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
const { runCommand, sendInvokeResult } = await runSystemInvoke({
|
||||
preferMacAppExecHost: false,
|
||||
command: prepared.plan.argv,
|
||||
rawCommand: prepared.plan.rawCommand,
|
||||
systemRunPlan: prepared.plan,
|
||||
cwd: prepared.plan.cwd ?? tmp,
|
||||
approved: true,
|
||||
security: "full",
|
||||
ask: "off",
|
||||
});
|
||||
|
||||
expect(runCommand).toHaveBeenCalledTimes(1);
|
||||
expectInvokeOk(sendInvokeResult);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("denies ./sh wrapper spoof in allowlist on-miss mode before execution", async () => {
|
||||
const marker = path.join(os.tmpdir(), `openclaw-wrapper-spoof-${process.pid}-${Date.now()}`);
|
||||
const runCommand = vi.fn(async () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import type { ExecHostRequest, ExecHostResponse, ExecHostRunResult } from "../infra/exec-host.js";
|
||||
import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js";
|
||||
import { sanitizeSystemRunEnvOverrides } from "../infra/host-env-security.js";
|
||||
import { normalizeSystemRunApprovalPlan } from "../infra/system-run-approval-binding.js";
|
||||
import { resolveSystemRunCommand } from "../infra/system-run-command.js";
|
||||
import { logWarn } from "../logger.js";
|
||||
import { evaluateSystemRunPolicy, resolveExecApprovalDecision } from "./exec-policy.js";
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
import {
|
||||
hardenApprovedExecutionPaths,
|
||||
revalidateApprovedCwdSnapshot,
|
||||
revalidateApprovedMutableFileOperand,
|
||||
type ApprovedCwdSnapshot,
|
||||
} from "./invoke-system-run-plan.js";
|
||||
import type {
|
||||
@@ -63,6 +65,7 @@ type SystemRunParsePhase = {
|
||||
argv: string[];
|
||||
shellCommand: string | null;
|
||||
cmdText: string;
|
||||
approvalPlan: import("../infra/exec-approvals.js").SystemRunApprovalPlan | null;
|
||||
agentId: string | undefined;
|
||||
sessionKey: string;
|
||||
runId: string;
|
||||
@@ -92,6 +95,8 @@ type SystemRunPolicyPhase = SystemRunParsePhase & {
|
||||
const safeBinTrustedDirWarningCache = new Set<string>();
|
||||
const APPROVAL_CWD_DRIFT_DENIED_MESSAGE =
|
||||
"SYSTEM_RUN_DENIED: approval cwd changed before execution";
|
||||
const APPROVAL_SCRIPT_OPERAND_DRIFT_DENIED_MESSAGE =
|
||||
"SYSTEM_RUN_DENIED: approval script operand changed before execution";
|
||||
|
||||
function warnWritableTrustedDirOnce(message: string): void {
|
||||
if (safeBinTrustedDirWarningCache.has(message)) {
|
||||
@@ -197,6 +202,17 @@ async function parseSystemRunPhase(
|
||||
|
||||
const shellCommand = command.shellCommand;
|
||||
const cmdText = command.cmdText;
|
||||
const approvalPlan =
|
||||
opts.params.systemRunPlan === undefined
|
||||
? null
|
||||
: normalizeSystemRunApprovalPlan(opts.params.systemRunPlan);
|
||||
if (opts.params.systemRunPlan !== undefined && !approvalPlan) {
|
||||
await opts.sendInvokeResult({
|
||||
ok: false,
|
||||
error: { code: "INVALID_REQUEST", message: "systemRunPlan invalid" },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const agentId = opts.params.agentId?.trim() || undefined;
|
||||
const sessionKey = opts.params.sessionKey?.trim() || "node";
|
||||
const runId = opts.params.runId?.trim() || crypto.randomUUID();
|
||||
@@ -208,6 +224,7 @@ async function parseSystemRunPhase(
|
||||
argv: command.argv,
|
||||
shellCommand,
|
||||
cmdText,
|
||||
approvalPlan,
|
||||
agentId,
|
||||
sessionKey,
|
||||
runId,
|
||||
@@ -361,6 +378,21 @@ async function executeSystemRunPhase(
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
phase.approvalPlan?.mutableFileOperand &&
|
||||
!revalidateApprovedMutableFileOperand({
|
||||
snapshot: phase.approvalPlan.mutableFileOperand,
|
||||
argv: phase.argv,
|
||||
cwd: phase.cwd,
|
||||
})
|
||||
) {
|
||||
logWarn(`security: system.run approval script drift blocked (runId=${phase.runId})`);
|
||||
await sendSystemRunDenied(opts, phase.execution, {
|
||||
reason: "approval-required",
|
||||
message: APPROVAL_SCRIPT_OPERAND_DRIFT_DENIED_MESSAGE,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const useMacAppExec = opts.preferMacAppExecHost;
|
||||
if (useMacAppExec) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { SkillBinTrustEntry } from "../infra/exec-approvals.js";
|
||||
import type { SkillBinTrustEntry, SystemRunApprovalPlan } from "../infra/exec-approvals.js";
|
||||
|
||||
export type SystemRunParams = {
|
||||
command: string[];
|
||||
rawCommand?: string | null;
|
||||
systemRunPlan?: SystemRunApprovalPlan | null;
|
||||
cwd?: string | null;
|
||||
env?: Record<string, string>;
|
||||
timeoutMs?: number | null;
|
||||
|
||||
Reference in New Issue
Block a user