refactor!: remove versioned system-run approval contract

This commit is contained in:
Peter Steinberger
2026-03-02 01:12:47 +00:00
parent 1636f7ff5f
commit 155118751f
33 changed files with 564 additions and 215 deletions

View File

@@ -94,6 +94,11 @@ Docs: https://docs.openclaw.ai
- OpenAI/WebSocket warm-up: add optional OpenAI Responses WebSocket warm-up (`response.create` with `generate:false`), enable it by default for `openai/*`, and expose `params.openaiWsWarmup` for per-model enable/disable control. - OpenAI/WebSocket warm-up: add optional OpenAI Responses WebSocket warm-up (`response.create` with `generate:false`), enable it by default for `openai/*`, and expose `params.openaiWsWarmup` for per-model enable/disable control.
- Agents/Subagents runtime events: replace ad-hoc subagent completion system-message handoff with typed internal completion events (`task_completion`) that are rendered consistently across direct and queued announce paths, with gateway/CLI plumbing for structured `internalEvents`. - Agents/Subagents runtime events: replace ad-hoc subagent completion system-message handoff with typed internal completion events (`task_completion`) that are rendered consistently across direct and queued announce paths, with gateway/CLI plumbing for structured `internalEvents`.
### Breaking
- **BREAKING:** Node exec approval payloads now require `systemRunPlan`. `host=node` approval requests without that plan are rejected.
- **BREAKING:** Node `system.run` execution now pins path-token commands to the canonical executable path (`realpath`) in both allowlist and approval execution flows. Integrations/tests that asserted token-form argv (for example `tr`) must now accept canonical paths (for example `/usr/bin/tr`).
### Fixes ### Fixes
- Security/Sandbox media reads: eliminate sandbox media TOCTOU symlink-retarget escapes by enforcing root-scoped boundary-safe reads at attachment/image load time and consolidating shared safe-read helpers across sandbox media callsites. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Sandbox media reads: eliminate sandbox media TOCTOU symlink-retarget escapes by enforcing root-scoped boundary-safe reads at attachment/image load time and consolidating shared safe-read helpers across sandbox media callsites. This ships in the next npm release. Thanks @tdjackey for reporting.
@@ -257,7 +262,7 @@ Docs: https://docs.openclaw.ai
- Models/OpenAI Codex config schema parity: accept `openai-codex-responses` in the config model API schema and TypeScript `ModelApi` union, with regression coverage for config validation. Landed from contributor PR #27501 by @AytuncYildizli. Thanks @AytuncYildizli. - Models/OpenAI Codex config schema parity: accept `openai-codex-responses` in the config model API schema and TypeScript `ModelApi` union, with regression coverage for config validation. Landed from contributor PR #27501 by @AytuncYildizli. Thanks @AytuncYildizli.
- Agents/Models config: preserve agent-level provider `apiKey` and `baseUrl` during merge-mode `models.json` updates when agent values are present. (#27293) thanks @Sid-Qin. - Agents/Models config: preserve agent-level provider `apiKey` and `baseUrl` during merge-mode `models.json` updates when agent values are present. (#27293) thanks @Sid-Qin.
- Azure OpenAI Responses: force `store=true` for `azure-openai-responses` direct responses API calls to avoid multi-turn 400 failures. Landed from contributor PR #27499 by @polarbear-Yang. (#27497) - Azure OpenAI Responses: force `store=true` for `azure-openai-responses` direct responses API calls to avoid multi-turn 400 failures. Landed from contributor PR #27499 by @polarbear-Yang. (#27497)
- Security/Node exec approvals: require structured `commandArgv` approvals for `host=node`, enforce versioned `systemRunBindingV1` matching for argv/cwd/session/agent/env context with fail-closed behavior on missing/mismatched bindings, and add `GIT_EXTERNAL_DIFF` to blocked host env keys. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. - Security/Node exec approvals: require structured `commandArgv` approvals for `host=node`, enforce `systemRunBinding` matching for argv/cwd/session/agent/env context with fail-closed behavior on missing/mismatched bindings, and add `GIT_EXTERNAL_DIFF` to blocked host env keys. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
- Security/Command authorization: enforce sender authorization for natural-language abort triggers (`stop`-like text) and `/models` listings, preventing unauthorized session aborts and model-auth metadata disclosure. This ships in the next npm release (`2026.2.27`). Thanks @tdjackey for reporting. - Security/Command authorization: enforce sender authorization for natural-language abort triggers (`stop`-like text) and `/models` listings, preventing unauthorized session aborts and model-auth metadata disclosure. This ships in the next npm release (`2026.2.27`). Thanks @tdjackey for reporting.
- Security/Plugin channel HTTP auth: normalize protected `/api/channels` path checks against canonicalized request paths (case + percent-decoding + slash normalization), resolve encoded dot-segment traversal variants, and fail closed on malformed `%`-encoded channel prefixes so alternate-path variants cannot bypass gateway auth. This ships in the next npm release (`2026.2.26`). Thanks @zpbrent for reporting. - Security/Plugin channel HTTP auth: normalize protected `/api/channels` path checks against canonicalized request paths (case + percent-decoding + slash normalization), resolve encoded dot-segment traversal variants, and fail closed on malformed `%`-encoded channel prefixes so alternate-path variants cannot bypass gateway auth. This ships in the next npm release (`2026.2.26`). Thanks @zpbrent for reporting.
- Security/Gateway node pairing: pin paired-device `platform`/`deviceFamily` metadata across reconnects and bind those fields into device-auth signatures, so reconnect metadata spoofing cannot expand node command allowlists without explicit repair pairing. This ships in the next npm release (`2026.2.26`). Thanks @76embiid21 for reporting. - Security/Gateway node pairing: pin paired-device `platform`/`deviceFamily` metadata across reconnects and bind those fields into device-auth signatures, so reconnect metadata spoofing cannot expand node command allowlists without explicit repair pairing. This ships in the next npm release (`2026.2.26`). Thanks @76embiid21 for reporting.

View File

@@ -2822,7 +2822,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
public let id: String? public let id: String?
public let command: String public let command: String
public let commandargv: [String]? public let commandargv: [String]?
public let systemrunplanv2: [String: AnyCodable]? public let systemrunplan: [String: AnyCodable]?
public let env: [String: AnyCodable]? public let env: [String: AnyCodable]?
public let cwd: AnyCodable? public let cwd: AnyCodable?
public let nodeid: AnyCodable? public let nodeid: AnyCodable?
@@ -2843,7 +2843,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
id: String?, id: String?,
command: String, command: String,
commandargv: [String]?, commandargv: [String]?,
systemrunplanv2: [String: AnyCodable]?, systemrunplan: [String: AnyCodable]?,
env: [String: AnyCodable]?, env: [String: AnyCodable]?,
cwd: AnyCodable?, cwd: AnyCodable?,
nodeid: AnyCodable?, nodeid: AnyCodable?,
@@ -2863,7 +2863,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
self.id = id self.id = id
self.command = command self.command = command
self.commandargv = commandargv self.commandargv = commandargv
self.systemrunplanv2 = systemrunplanv2 self.systemrunplan = systemrunplan
self.env = env self.env = env
self.cwd = cwd self.cwd = cwd
self.nodeid = nodeid self.nodeid = nodeid
@@ -2885,7 +2885,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
case id case id
case command case command
case commandargv = "commandArgv" case commandargv = "commandArgv"
case systemrunplanv2 = "systemRunPlanV2" case systemrunplan = "systemRunPlan"
case env case env
case cwd case cwd
case nodeid = "nodeId" case nodeid = "nodeId"

View File

@@ -2822,7 +2822,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
public let id: String? public let id: String?
public let command: String public let command: String
public let commandargv: [String]? public let commandargv: [String]?
public let systemrunplanv2: [String: AnyCodable]? public let systemrunplan: [String: AnyCodable]?
public let env: [String: AnyCodable]? public let env: [String: AnyCodable]?
public let cwd: AnyCodable? public let cwd: AnyCodable?
public let nodeid: AnyCodable? public let nodeid: AnyCodable?
@@ -2843,7 +2843,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
id: String?, id: String?,
command: String, command: String,
commandargv: [String]?, commandargv: [String]?,
systemrunplanv2: [String: AnyCodable]?, systemrunplan: [String: AnyCodable]?,
env: [String: AnyCodable]?, env: [String: AnyCodable]?,
cwd: AnyCodable?, cwd: AnyCodable?,
nodeid: AnyCodable?, nodeid: AnyCodable?,
@@ -2863,7 +2863,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
self.id = id self.id = id
self.command = command self.command = command
self.commandargv = commandargv self.commandargv = commandargv
self.systemrunplanv2 = systemrunplanv2 self.systemrunplan = systemrunplan
self.env = env self.env = env
self.cwd = cwd self.cwd = cwd
self.nodeid = nodeid self.nodeid = nodeid
@@ -2885,7 +2885,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
case id case id
case command case command
case commandargv = "commandArgv" case commandargv = "commandArgv"
case systemrunplanv2 = "systemRunPlanV2" case systemrunplan = "systemRunPlan"
case env case env
case cwd case cwd
case nodeid = "nodeId" case nodeid = "nodeId"

View File

@@ -182,6 +182,7 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
- When an exec request needs approval, the gateway broadcasts `exec.approval.requested`. - When an exec request needs approval, the gateway broadcasts `exec.approval.requested`.
- Operator clients resolve by calling `exec.approval.resolve` (requires `operator.approvals` scope). - Operator clients resolve by calling `exec.approval.resolve` (requires `operator.approvals` scope).
- For `host=node`, `exec.approval.request` must include `systemRunPlan` (canonical `argv`/`cwd`/`rawCommand`/session metadata). Requests missing `systemRunPlan` are rejected.
## Versioning ## Versioning

View File

@@ -252,6 +252,10 @@ When a prompt is required, the gateway broadcasts `exec.approval.requested` to o
The Control UI and macOS app resolve it via `exec.approval.resolve`, then the gateway forwards the The Control UI and macOS app resolve it via `exec.approval.resolve`, then the gateway forwards the
approved request to the node host. approved request to the node host.
For `host=node`, approval requests include a canonical `systemRunPlan` payload. The gateway uses
that plan as the authoritative command/cwd/session context when forwarding approved `system.run`
requests.
When approvals are required, the exec tool returns immediately with an approval id. Use that id to When approvals are required, the exec tool returns immediately with an approval id. Use that id to
correlate later system events (`Exec finished` / `Exec denied`). If no decision arrives before the correlate later system events (`Exec finished` / `Exec denied`). If no decision arrives before the
timeout, the request is treated as an approval timeout and surfaced as a denial reason. timeout, the request is treated as an approval timeout and surfaced as a denial reason.

View File

@@ -1,4 +1,4 @@
import type { ExecAsk, ExecSecurity } from "../infra/exec-approvals.js"; import type { ExecAsk, ExecSecurity, SystemRunApprovalPlan } from "../infra/exec-approvals.js";
import { import {
DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS, DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS,
DEFAULT_APPROVAL_TIMEOUT_MS, DEFAULT_APPROVAL_TIMEOUT_MS,
@@ -9,6 +9,7 @@ export type RequestExecApprovalDecisionParams = {
id: string; id: string;
command: string; command: string;
commandArgv?: string[]; commandArgv?: string[];
systemRunPlan?: SystemRunApprovalPlan;
env?: Record<string, string>; env?: Record<string, string>;
cwd: string; cwd: string;
nodeId?: string; nodeId?: string;
@@ -28,6 +29,7 @@ type ExecApprovalRequestToolParams = {
id: string; id: string;
command: string; command: string;
commandArgv?: string[]; commandArgv?: string[];
systemRunPlan?: SystemRunApprovalPlan;
env?: Record<string, string>; env?: Record<string, string>;
cwd: string; cwd: string;
nodeId?: string; nodeId?: string;
@@ -52,6 +54,7 @@ function buildExecApprovalRequestToolParams(
id: params.id, id: params.id,
command: params.command, command: params.command,
commandArgv: params.commandArgv, commandArgv: params.commandArgv,
systemRunPlan: params.systemRunPlan,
env: params.env, env: params.env,
cwd: params.cwd, cwd: params.cwd,
nodeId: params.nodeId, nodeId: params.nodeId,
@@ -156,6 +159,7 @@ export async function requestExecApprovalDecisionForHost(params: {
approvalId: string; approvalId: string;
command: string; command: string;
commandArgv?: string[]; commandArgv?: string[];
systemRunPlan?: SystemRunApprovalPlan;
env?: Record<string, string>; env?: Record<string, string>;
workdir: string; workdir: string;
host: "gateway" | "node"; host: "gateway" | "node";
@@ -174,6 +178,7 @@ export async function requestExecApprovalDecisionForHost(params: {
id: params.approvalId, id: params.approvalId,
command: params.command, command: params.command,
commandArgv: params.commandArgv, commandArgv: params.commandArgv,
systemRunPlan: params.systemRunPlan,
env: params.env, env: params.env,
cwd: params.workdir, cwd: params.workdir,
nodeId: params.nodeId, nodeId: params.nodeId,
@@ -194,6 +199,7 @@ export async function registerExecApprovalRequestForHost(params: {
approvalId: string; approvalId: string;
command: string; command: string;
commandArgv?: string[]; commandArgv?: string[];
systemRunPlan?: SystemRunApprovalPlan;
env?: Record<string, string>; env?: Record<string, string>;
workdir: string; workdir: string;
host: "gateway" | "node"; host: "gateway" | "node";
@@ -212,6 +218,7 @@ export async function registerExecApprovalRequestForHost(params: {
id: params.approvalId, id: params.approvalId,
command: params.command, command: params.command,
commandArgv: params.commandArgv, commandArgv: params.commandArgv,
systemRunPlan: params.systemRunPlan,
env: params.env, env: params.env,
cwd: params.workdir, cwd: params.workdir,
nodeId: params.nodeId, nodeId: params.nodeId,

View File

@@ -13,6 +13,7 @@ import {
} from "../infra/exec-approvals.js"; } from "../infra/exec-approvals.js";
import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js"; import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js";
import { buildNodeShellCommand } from "../infra/node-shell.js"; import { buildNodeShellCommand } from "../infra/node-shell.js";
import { parsePreparedSystemRunPayload } from "../infra/system-run-approval-context.js";
import { logInfo } from "../logger.js"; import { logInfo } from "../logger.js";
import { import {
registerExecApprovalRequestForHost, registerExecApprovalRequestForHost,
@@ -95,6 +96,31 @@ export async function executeNodeHostCommand(
); );
} }
const argv = buildNodeShellCommand(params.command, nodeInfo?.platform); const argv = buildNodeShellCommand(params.command, nodeInfo?.platform);
const prepareRaw = await callGatewayTool<{ payload?: unknown }>(
"node.invoke",
{ timeoutMs: 15_000 },
{
nodeId,
command: "system.run.prepare",
params: {
command: argv,
rawCommand: params.command,
cwd: params.workdir,
agentId: params.agentId,
sessionKey: params.sessionKey,
},
idempotencyKey: crypto.randomUUID(),
},
);
const prepared = parsePreparedSystemRunPayload(prepareRaw?.payload);
if (!prepared) {
throw new Error("invalid system.run.prepare response");
}
const runArgv = prepared.plan.argv;
const runRawCommand = prepared.plan.rawCommand ?? prepared.cmdText;
const runCwd = prepared.plan.cwd ?? params.workdir;
const runAgentId = prepared.plan.agentId ?? params.agentId;
const runSessionKey = prepared.plan.sessionKey ?? params.sessionKey;
const nodeEnv = params.requestedEnv ? { ...params.requestedEnv } : undefined; const nodeEnv = params.requestedEnv ? { ...params.requestedEnv } : undefined;
const baseAllowlistEval = evaluateShellAllowlist({ const baseAllowlistEval = evaluateShellAllowlist({
@@ -170,13 +196,13 @@ export async function executeNodeHostCommand(
nodeId, nodeId,
command: "system.run", command: "system.run",
params: { params: {
command: argv, command: runArgv,
rawCommand: params.command, rawCommand: runRawCommand,
cwd: params.workdir, cwd: runCwd,
env: nodeEnv, env: nodeEnv,
timeoutMs: typeof params.timeoutSec === "number" ? params.timeoutSec * 1000 : undefined, timeoutMs: typeof params.timeoutSec === "number" ? params.timeoutSec * 1000 : undefined,
agentId: params.agentId, agentId: runAgentId,
sessionKey: params.sessionKey, sessionKey: runSessionKey,
approved: approvedByAsk, approved: approvedByAsk,
approvalDecision: approvalDecision ?? undefined, approvalDecision: approvalDecision ?? undefined,
runId: runId ?? undefined, runId: runId ?? undefined,
@@ -197,16 +223,17 @@ export async function executeNodeHostCommand(
// Register first so the returned approval ID is actionable immediately. // Register first so the returned approval ID is actionable immediately.
const registration = await registerExecApprovalRequestForHost({ const registration = await registerExecApprovalRequestForHost({
approvalId, approvalId,
command: params.command, command: prepared.cmdText,
commandArgv: argv, commandArgv: prepared.plan.argv,
systemRunPlan: prepared.plan,
env: nodeEnv, env: nodeEnv,
workdir: params.workdir, workdir: runCwd,
host: "node", host: "node",
nodeId, nodeId,
security: hostSecurity, security: hostSecurity,
ask: hostAsk, ask: hostAsk,
agentId: params.agentId, agentId: runAgentId,
sessionKey: params.sessionKey, sessionKey: runSessionKey,
turnSourceChannel: params.turnSourceChannel, turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo, turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId, turnSourceAccountId: params.turnSourceAccountId,

View File

@@ -27,6 +27,33 @@ let callGatewayTool: typeof import("./tools/gateway.js").callGatewayTool;
let createExecTool: typeof import("./bash-tools.exec.js").createExecTool; let createExecTool: typeof import("./bash-tools.exec.js").createExecTool;
let detectCommandObfuscation: typeof import("../infra/exec-obfuscation-detect.js").detectCommandObfuscation; let detectCommandObfuscation: typeof import("../infra/exec-obfuscation-detect.js").detectCommandObfuscation;
function buildPreparedSystemRunPayload(rawInvokeParams: unknown) {
const invoke = (rawInvokeParams ?? {}) as {
params?: {
command?: unknown;
rawCommand?: unknown;
cwd?: unknown;
agentId?: unknown;
sessionKey?: unknown;
};
};
const params = invoke.params ?? {};
const argv = Array.isArray(params.command) ? params.command.map(String) : [];
const rawCommand = typeof params.rawCommand === "string" ? params.rawCommand : null;
return {
payload: {
cmdText: rawCommand ?? argv.join(" "),
plan: {
argv,
cwd: typeof params.cwd === "string" ? params.cwd : null,
rawCommand,
agentId: typeof params.agentId === "string" ? params.agentId : null,
sessionKey: typeof params.sessionKey === "string" ? params.sessionKey : null,
},
},
};
}
describe("exec approvals", () => { describe("exec approvals", () => {
let previousHome: string | undefined; let previousHome: string | undefined;
let previousUserProfile: string | undefined; let previousUserProfile: string | undefined;
@@ -71,8 +98,14 @@ describe("exec approvals", () => {
return { decision: "allow-once" }; return { decision: "allow-once" };
} }
if (method === "node.invoke") { if (method === "node.invoke") {
invokeParams = params; const invoke = params as { command?: string };
return { ok: true }; if (invoke.command === "system.run.prepare") {
return buildPreparedSystemRunPayload(params);
}
if (invoke.command === "system.run") {
invokeParams = params;
return { payload: { success: true, stdout: "ok" } };
}
} }
return { ok: true }; return { ok: true };
}); });
@@ -116,12 +149,16 @@ describe("exec approvals", () => {
}; };
const calls: string[] = []; const calls: string[] = [];
vi.mocked(callGatewayTool).mockImplementation(async (method) => { vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
calls.push(method); calls.push(method);
if (method === "exec.approvals.node.get") { if (method === "exec.approvals.node.get") {
return { file: approvalsFile }; return { file: approvalsFile };
} }
if (method === "node.invoke") { if (method === "node.invoke") {
const invoke = params as { command?: string };
if (invoke.command === "system.run.prepare") {
return buildPreparedSystemRunPayload(params);
}
return { payload: { success: true, stdout: "ok" } }; return { payload: { success: true, stdout: "ok" } };
} }
// exec.approval.request should NOT be called when allowlist is satisfied // exec.approval.request should NOT be called when allowlist is satisfied
@@ -266,7 +303,8 @@ describe("exec approvals", () => {
}); });
const calls: string[] = []; const calls: string[] = [];
vi.mocked(callGatewayTool).mockImplementation(async (method) => { const nodeInvokeCommands: string[] = [];
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
calls.push(method); calls.push(method);
if (method === "exec.approval.request") { if (method === "exec.approval.request") {
return { status: "accepted", id: "approval-id" }; return { status: "accepted", id: "approval-id" };
@@ -275,6 +313,13 @@ describe("exec approvals", () => {
return {}; return {};
} }
if (method === "node.invoke") { if (method === "node.invoke") {
const invoke = params as { command?: string };
if (invoke.command) {
nodeInvokeCommands.push(invoke.command);
}
if (invoke.command === "system.run.prepare") {
return buildPreparedSystemRunPayload(params);
}
return { payload: { success: true, stdout: "should-not-run" } }; return { payload: { success: true, stdout: "should-not-run" } };
} }
return { ok: true }; return { ok: true };
@@ -289,7 +334,7 @@ describe("exec approvals", () => {
const result = await tool.execute("call5", { command: "echo hi | sh" }); const result = await tool.execute("call5", { command: "echo hi | sh" });
expect(result.details.status).toBe("approval-pending"); expect(result.details.status).toBe("approval-pending");
await expect.poll(() => calls.filter((call) => call === "node.invoke").length).toBe(0); await expect.poll(() => nodeInvokeCommands.includes("system.run")).toBe(false);
}); });
it("denies gateway obfuscated command when approval request times out", async () => { it("denies gateway obfuscated command when approval request times out", async () => {

View File

@@ -356,6 +356,21 @@ describe("nodes run", () => {
return mockNodeList(["system.run"]); return mockNodeList(["system.run"]);
} }
if (method === "node.invoke") { if (method === "node.invoke") {
const command = (params as { command?: string } | undefined)?.command;
if (command === "system.run.prepare") {
return {
payload: {
cmdText: "echo hi",
plan: {
argv: ["echo", "hi"],
cwd: "/tmp",
rawCommand: "echo hi",
agentId: null,
sessionKey: null,
},
},
};
}
expect(params).toMatchObject({ expect(params).toMatchObject({
nodeId: NODE_ID, nodeId: NODE_ID,
command: "system.run", command: "system.run",
@@ -391,6 +406,21 @@ describe("nodes run", () => {
return mockNodeList(["system.run"]); return mockNodeList(["system.run"]);
} }
if (method === "node.invoke") { if (method === "node.invoke") {
const command = (params as { command?: string } | undefined)?.command;
if (command === "system.run.prepare") {
return {
payload: {
cmdText: "echo hi",
plan: {
argv: ["echo", "hi"],
cwd: null,
rawCommand: "echo hi",
agentId: null,
sessionKey: null,
},
},
};
}
invokeCalls += 1; invokeCalls += 1;
if (invokeCalls === 1) { if (invokeCalls === 1) {
throw new Error("SYSTEM_RUN_DENIED: approval required"); throw new Error("SYSTEM_RUN_DENIED: approval required");
@@ -411,6 +441,10 @@ describe("nodes run", () => {
expect(params).toMatchObject({ expect(params).toMatchObject({
id: expect.any(String), id: expect.any(String),
command: "echo hi", command: "echo hi",
commandArgv: ["echo", "hi"],
systemRunPlan: expect.objectContaining({
argv: ["echo", "hi"],
}),
nodeId: NODE_ID, nodeId: NODE_ID,
host: "node", host: "node",
timeoutMs: 120_000, timeoutMs: 120_000,
@@ -429,11 +463,26 @@ describe("nodes run", () => {
}); });
it("fails with user denied when approval decision is deny", async () => { it("fails with user denied when approval decision is deny", async () => {
callGateway.mockImplementation(async ({ method }) => { callGateway.mockImplementation(async ({ method, params }) => {
if (method === "node.list") { if (method === "node.list") {
return mockNodeList(["system.run"]); return mockNodeList(["system.run"]);
} }
if (method === "node.invoke") { if (method === "node.invoke") {
const command = (params as { command?: string } | undefined)?.command;
if (command === "system.run.prepare") {
return {
payload: {
cmdText: "echo hi",
plan: {
argv: ["echo", "hi"],
cwd: null,
rawCommand: "echo hi",
agentId: null,
sessionKey: null,
},
},
};
}
throw new Error("SYSTEM_RUN_DENIED: approval required"); throw new Error("SYSTEM_RUN_DENIED: approval required");
} }
if (method === "exec.approval.request") { if (method === "exec.approval.request") {
@@ -446,11 +495,26 @@ describe("nodes run", () => {
}); });
it("fails closed for timeout and invalid approval decisions", async () => { it("fails closed for timeout and invalid approval decisions", async () => {
callGateway.mockImplementation(async ({ method }) => { callGateway.mockImplementation(async ({ method, params }) => {
if (method === "node.list") { if (method === "node.list") {
return mockNodeList(["system.run"]); return mockNodeList(["system.run"]);
} }
if (method === "node.invoke") { if (method === "node.invoke") {
const command = (params as { command?: string } | undefined)?.command;
if (command === "system.run.prepare") {
return {
payload: {
cmdText: "echo hi",
plan: {
argv: ["echo", "hi"],
cwd: null,
rawCommand: "echo hi",
agentId: null,
sessionKey: null,
},
},
};
}
throw new Error("SYSTEM_RUN_DENIED: approval required"); throw new Error("SYSTEM_RUN_DENIED: approval required");
} }
if (method === "exec.approval.request") { if (method === "exec.approval.request") {
@@ -460,11 +524,26 @@ describe("nodes run", () => {
}); });
await expect(executeNodes(BASE_RUN_INPUT)).rejects.toThrow("exec denied: approval timed out"); await expect(executeNodes(BASE_RUN_INPUT)).rejects.toThrow("exec denied: approval timed out");
callGateway.mockImplementation(async ({ method }) => { callGateway.mockImplementation(async ({ method, params }) => {
if (method === "node.list") { if (method === "node.list") {
return mockNodeList(["system.run"]); return mockNodeList(["system.run"]);
} }
if (method === "node.invoke") { if (method === "node.invoke") {
const command = (params as { command?: string } | undefined)?.command;
if (command === "system.run.prepare") {
return {
payload: {
cmdText: "echo hi",
plan: {
argv: ["echo", "hi"],
cwd: null,
rawCommand: "echo hi",
agentId: null,
sessionKey: null,
},
},
};
}
throw new Error("SYSTEM_RUN_DENIED: approval required"); throw new Error("SYSTEM_RUN_DENIED: approval required");
} }
if (method === "exec.approval.request") { if (method === "exec.approval.request") {

View File

@@ -18,6 +18,7 @@ import {
} from "../../cli/nodes-screen.js"; } from "../../cli/nodes-screen.js";
import { parseDurationMs } from "../../cli/parse-duration.js"; import { parseDurationMs } from "../../cli/parse-duration.js";
import type { OpenClawConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/config.js";
import { parsePreparedSystemRunPayload } from "../../infra/system-run-approval-context.js";
import { formatExecCommand } from "../../infra/system-run-command.js"; import { formatExecCommand } from "../../infra/system-run-command.js";
import { imageMimeFromFormat } from "../../media/mime.js"; import { imageMimeFromFormat } from "../../media/mime.js";
import type { GatewayMessageChannel } from "../../utils/message-channel.js"; import type { GatewayMessageChannel } from "../../utils/message-channel.js";
@@ -530,14 +531,36 @@ export function createNodesTool(options?: {
typeof params.needsScreenRecording === "boolean" typeof params.needsScreenRecording === "boolean"
? params.needsScreenRecording ? params.needsScreenRecording
: undefined; : undefined;
const prepareRaw = await callGatewayTool<{ payload?: unknown }>(
"node.invoke",
gatewayOpts,
{
nodeId,
command: "system.run.prepare",
params: {
command,
rawCommand: formatExecCommand(command),
cwd,
agentId,
sessionKey,
},
timeoutMs: invokeTimeoutMs,
idempotencyKey: crypto.randomUUID(),
},
);
const prepared = parsePreparedSystemRunPayload(prepareRaw?.payload);
if (!prepared) {
throw new Error("invalid system.run.prepare response");
}
const runParams = { const runParams = {
command, command: prepared.plan.argv,
cwd, rawCommand: prepared.plan.rawCommand ?? prepared.cmdText,
cwd: prepared.plan.cwd ?? cwd,
env, env,
timeoutMs: commandTimeoutMs, timeoutMs: commandTimeoutMs,
needsScreenRecording, needsScreenRecording,
agentId, agentId: prepared.plan.agentId ?? agentId,
sessionKey, sessionKey: prepared.plan.sessionKey ?? sessionKey,
}; };
// First attempt without approval flags. // First attempt without approval flags.
@@ -560,20 +583,20 @@ export function createNodesTool(options?: {
// Node requires approval create a pending approval request on // Node requires approval create a pending approval request on
// the gateway and wait for the user to approve/deny via the UI. // the gateway and wait for the user to approve/deny via the UI.
const APPROVAL_TIMEOUT_MS = 120_000; const APPROVAL_TIMEOUT_MS = 120_000;
const cmdText = formatExecCommand(command);
const approvalId = crypto.randomUUID(); const approvalId = crypto.randomUUID();
const approvalResult = await callGatewayTool( const approvalResult = await callGatewayTool(
"exec.approval.request", "exec.approval.request",
{ ...gatewayOpts, timeoutMs: APPROVAL_TIMEOUT_MS + 5_000 }, { ...gatewayOpts, timeoutMs: APPROVAL_TIMEOUT_MS + 5_000 },
{ {
id: approvalId, id: approvalId,
command: cmdText, command: prepared.cmdText,
commandArgv: command, commandArgv: prepared.plan.argv,
cwd, systemRunPlan: prepared.plan,
cwd: prepared.plan.cwd ?? cwd,
nodeId, nodeId,
host: "node", host: "node",
agentId, agentId: prepared.plan.agentId ?? agentId,
sessionKey, sessionKey: prepared.plan.sessionKey ?? sessionKey,
turnSourceChannel, turnSourceChannel,
turnSourceTo, turnSourceTo,
turnSourceAccountId, turnSourceAccountId,

View File

@@ -47,7 +47,6 @@ const callGateway = vi.fn(async (opts: NodeInvokeCall) => {
payload: { payload: {
cmdText: rawCommand ?? argv.join(" "), cmdText: rawCommand ?? argv.join(" "),
plan: { plan: {
version: 2,
argv, argv,
cwd: typeof params.cwd === "string" ? params.cwd : null, cwd: typeof params.cwd === "string" ? params.cwd : null,
rawCommand, rawCommand,
@@ -185,8 +184,7 @@ describe("nodes-cli coverage", () => {
expect(invoke?.params?.timeoutMs).toBe(5000); expect(invoke?.params?.timeoutMs).toBe(5000);
const approval = getApprovalRequestCall(); const approval = getApprovalRequestCall();
expect(approval?.params?.["commandArgv"]).toEqual(["echo", "hi"]); expect(approval?.params?.["commandArgv"]).toEqual(["echo", "hi"]);
expect(approval?.params?.["systemRunPlanV2"]).toEqual({ expect(approval?.params?.["systemRunPlan"]).toEqual({
version: 2,
argv: ["echo", "hi"], argv: ["echo", "hi"],
cwd: "/tmp", cwd: "/tmp",
rawCommand: null, rawCommand: null,
@@ -220,8 +218,7 @@ describe("nodes-cli coverage", () => {
}); });
const approval = getApprovalRequestCall(); const approval = getApprovalRequestCall();
expect(approval?.params?.["commandArgv"]).toEqual(["/bin/sh", "-lc", "echo hi"]); expect(approval?.params?.["commandArgv"]).toEqual(["/bin/sh", "-lc", "echo hi"]);
expect(approval?.params?.["systemRunPlanV2"]).toEqual({ expect(approval?.params?.["systemRunPlan"]).toEqual({
version: 2,
argv: ["/bin/sh", "-lc", "echo hi"], argv: ["/bin/sh", "-lc", "echo hi"],
cwd: null, cwd: null,
rawCommand: "echo hi", rawCommand: "echo hi",

View File

@@ -228,7 +228,7 @@ async function maybeRequestNodesRunApproval(params: {
id: approvalId, id: approvalId,
command: params.preparedCmdText, command: params.preparedCmdText,
commandArgv: params.approvalPlan.argv, commandArgv: params.approvalPlan.argv,
systemRunPlanV2: params.approvalPlan, systemRunPlan: params.approvalPlan,
cwd: params.approvalPlan.cwd, cwd: params.approvalPlan.cwd,
nodeId: params.nodeId, nodeId: params.nodeId,
host: "node", host: "node",

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { buildSystemRunApprovalBindingV1 } from "../infra/system-run-approval-binding.js"; import { buildSystemRunApprovalBinding } from "../infra/system-run-approval-binding.js";
import { evaluateSystemRunApprovalMatch } from "./node-invoke-system-run-approval-match.js"; import { evaluateSystemRunApprovalMatch } from "./node-invoke-system-run-approval-match.js";
describe("evaluateSystemRunApprovalMatch", () => { describe("evaluateSystemRunApprovalMatch", () => {
@@ -29,7 +29,7 @@ describe("evaluateSystemRunApprovalMatch", () => {
request: { request: {
host: "node", host: "node",
command: "echo SAFE", command: "echo SAFE",
systemRunBindingV1: buildSystemRunApprovalBindingV1({ systemRunBinding: buildSystemRunApprovalBinding({
argv: ["echo", "SAFE"], argv: ["echo", "SAFE"],
cwd: null, cwd: null,
agentId: null, agentId: null,
@@ -51,7 +51,7 @@ describe("evaluateSystemRunApprovalMatch", () => {
request: { request: {
host: "node", host: "node",
command: "echo SAFE", command: "echo SAFE",
systemRunBindingV1: buildSystemRunApprovalBindingV1({ systemRunBinding: buildSystemRunApprovalBinding({
argv: ["echo SAFE"], argv: ["echo SAFE"],
cwd: null, cwd: null,
agentId: null, agentId: null,
@@ -77,7 +77,7 @@ describe("evaluateSystemRunApprovalMatch", () => {
request: { request: {
host: "node", host: "node",
command: "git diff", command: "git diff",
systemRunBindingV1: buildSystemRunApprovalBindingV1({ systemRunBinding: buildSystemRunApprovalBinding({
argv: ["git", "diff"], argv: ["git", "diff"],
cwd: null, cwd: null,
agentId: null, agentId: null,
@@ -104,7 +104,7 @@ describe("evaluateSystemRunApprovalMatch", () => {
request: { request: {
host: "node", host: "node",
command: "git diff", command: "git diff",
systemRunBindingV1: buildSystemRunApprovalBindingV1({ systemRunBinding: buildSystemRunApprovalBinding({
argv: ["git", "diff"], argv: ["git", "diff"],
cwd: null, cwd: null,
agentId: null, agentId: null,
@@ -149,7 +149,7 @@ describe("evaluateSystemRunApprovalMatch", () => {
host: "node", host: "node",
command: "echo STALE", command: "echo STALE",
commandArgv: ["echo STALE"], commandArgv: ["echo STALE"],
systemRunBindingV1: buildSystemRunApprovalBindingV1({ systemRunBinding: buildSystemRunApprovalBinding({
argv: ["echo", "SAFE"], argv: ["echo", "SAFE"],
cwd: null, cwd: null,
agentId: null, agentId: null,

View File

@@ -1,8 +1,8 @@
import type { ExecApprovalRequestPayload } from "../infra/exec-approvals.js"; import type { ExecApprovalRequestPayload } from "../infra/exec-approvals.js";
import { import {
buildSystemRunApprovalBindingV1, buildSystemRunApprovalBinding,
missingSystemRunApprovalBindingV1, missingSystemRunApprovalBinding,
matchSystemRunApprovalBindingV1, matchSystemRunApprovalBinding,
type SystemRunApprovalMatchResult, type SystemRunApprovalMatchResult,
} from "../infra/system-run-approval-binding.js"; } from "../infra/system-run-approval-binding.js";
@@ -33,7 +33,7 @@ export function evaluateSystemRunApprovalMatch(params: {
return requestMismatch(); return requestMismatch();
} }
const actualBinding = buildSystemRunApprovalBindingV1({ const actualBinding = buildSystemRunApprovalBinding({
argv: params.argv, argv: params.argv,
cwd: params.binding.cwd, cwd: params.binding.cwd,
agentId: params.binding.agentId, agentId: params.binding.agentId,
@@ -41,13 +41,13 @@ export function evaluateSystemRunApprovalMatch(params: {
env: params.binding.env, env: params.binding.env,
}); });
const expectedBinding = params.request.systemRunBindingV1; const expectedBinding = params.request.systemRunBinding;
if (!expectedBinding) { if (!expectedBinding) {
return missingSystemRunApprovalBindingV1({ return missingSystemRunApprovalBinding({
actualEnvKeys: actualBinding.envKeys, actualEnvKeys: actualBinding.envKeys,
}); });
} }
return matchSystemRunApprovalBindingV1({ return matchSystemRunApprovalBinding({
expected: expectedBinding, expected: expectedBinding,
actual: actualBinding.binding, actual: actualBinding.binding,
actualEnvKeys: actualBinding.envKeys, actualEnvKeys: actualBinding.envKeys,

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { import {
buildSystemRunApprovalBindingV1, buildSystemRunApprovalBinding,
buildSystemRunApprovalEnvBinding, buildSystemRunApprovalEnvBinding,
} from "../infra/system-run-approval-binding.js"; } from "../infra/system-run-approval-binding.js";
import { ExecApprovalManager, type ExecApprovalRecord } from "./exec-approval-manager.js"; import { ExecApprovalManager, type ExecApprovalRecord } from "./exec-approval-manager.js";
@@ -30,7 +30,7 @@ describe("sanitizeSystemRunParamsForForwarding", () => {
nodeId: "node-1", nodeId: "node-1",
command, command,
commandArgv, commandArgv,
systemRunBindingV1: buildSystemRunApprovalBindingV1({ systemRunBinding: buildSystemRunApprovalBinding({
argv: effectiveBindingArgv, argv: effectiveBindingArgv,
cwd: null, cwd: null,
agentId: null, agentId: null,
@@ -229,17 +229,16 @@ describe("sanitizeSystemRunParamsForForwarding", () => {
expectAllowOnceForwardingResult(result); expectAllowOnceForwardingResult(result);
}); });
test("uses systemRunPlanV2 for forwarded command context and ignores caller tampering", () => { test("uses systemRunPlan for forwarded command context and ignores caller tampering", () => {
const record = makeRecord("echo SAFE", ["echo", "SAFE"]); const record = makeRecord("echo SAFE", ["echo", "SAFE"]);
record.request.systemRunPlanV2 = { record.request.systemRunPlan = {
version: 2,
argv: ["/usr/bin/echo", "SAFE"], argv: ["/usr/bin/echo", "SAFE"],
cwd: "/real/cwd", cwd: "/real/cwd",
rawCommand: "/usr/bin/echo SAFE", rawCommand: "/usr/bin/echo SAFE",
agentId: "main", agentId: "main",
sessionKey: "agent:main:main", sessionKey: "agent:main:main",
}; };
record.request.systemRunBindingV1 = buildSystemRunApprovalBindingV1({ record.request.systemRunBinding = buildSystemRunApprovalBinding({
argv: ["/usr/bin/echo", "SAFE"], argv: ["/usr/bin/echo", "SAFE"],
cwd: "/real/cwd", cwd: "/real/cwd",
agentId: "main", agentId: "main",
@@ -297,8 +296,7 @@ describe("sanitizeSystemRunParamsForForwarding", () => {
test("rejects env hash mismatch", () => { test("rejects env hash mismatch", () => {
const record = makeRecord("git diff", ["git", "diff"]); const record = makeRecord("git diff", ["git", "diff"]);
record.request.systemRunBindingV1 = { record.request.systemRunBinding = {
version: 1,
argv: ["git", "diff"], argv: ["git", "diff"],
cwd: null, cwd: null,
agentId: null, agentId: null,
@@ -329,8 +327,7 @@ describe("sanitizeSystemRunParamsForForwarding", () => {
test("accepts matching env hash with reordered keys", () => { test("accepts matching env hash with reordered keys", () => {
const record = makeRecord("git diff", ["git", "diff"]); const record = makeRecord("git diff", ["git", "diff"]);
const binding = buildSystemRunApprovalEnvBinding({ SAFE_A: "1", SAFE_B: "2" }); const binding = buildSystemRunApprovalEnvBinding({ SAFE_A: "1", SAFE_B: "2" });
record.request.systemRunBindingV1 = { record.request.systemRunBinding = {
version: 1,
argv: ["git", "diff"], argv: ["git", "diff"],
cwd: null, cwd: null,
agentId: null, agentId: null,
@@ -363,7 +360,7 @@ describe("sanitizeSystemRunParamsForForwarding", () => {
nodeId: "node-1", nodeId: "node-1",
command: "echo SAFE", command: "echo SAFE",
commandArgv: ["echo", "SAFE"], commandArgv: ["echo", "SAFE"],
systemRunBindingV1: buildSystemRunApprovalBindingV1({ systemRunBinding: buildSystemRunApprovalBinding({
argv: ["echo", "SAFE"], argv: ["echo", "SAFE"],
cwd: null, cwd: null,
agentId: null, agentId: null,

View File

@@ -209,7 +209,7 @@ export function sanitizeSystemRunParamsForForwarding(opts: {
} }
const runtimeContext = resolveSystemRunApprovalRuntimeContext({ const runtimeContext = resolveSystemRunApprovalRuntimeContext({
planV2: snapshot.request.systemRunPlanV2 ?? null, plan: snapshot.request.systemRunPlan ?? null,
command: p.command, command: p.command,
rawCommand: p.rawCommand, rawCommand: p.rawCommand,
cwd: p.cwd, cwd: p.cwd,
@@ -223,8 +223,8 @@ export function sanitizeSystemRunParamsForForwarding(opts: {
details: runtimeContext.details, details: runtimeContext.details,
}; };
} }
if (runtimeContext.planV2) { if (runtimeContext.plan) {
next.command = [...runtimeContext.planV2.argv]; next.command = [...runtimeContext.plan.argv];
if (runtimeContext.rawCommand) { if (runtimeContext.rawCommand) {
next.rawCommand = runtimeContext.rawCommand; next.rawCommand = runtimeContext.rawCommand;
} else { } else {

View File

@@ -90,10 +90,9 @@ export const ExecApprovalRequestParamsSchema = Type.Object(
id: Type.Optional(NonEmptyString), id: Type.Optional(NonEmptyString),
command: NonEmptyString, command: NonEmptyString,
commandArgv: Type.Optional(Type.Array(Type.String())), commandArgv: Type.Optional(Type.Array(Type.String())),
systemRunPlanV2: Type.Optional( systemRunPlan: Type.Optional(
Type.Object( Type.Object(
{ {
version: Type.Literal(2),
argv: Type.Array(Type.String()), argv: Type.Array(Type.String()),
cwd: Type.Union([Type.String(), Type.Null()]), cwd: Type.Union([Type.String(), Type.Null()]),
rawCommand: Type.Union([Type.String(), Type.Null()]), rawCommand: Type.Union([Type.String(), Type.Null()]),

View File

@@ -3,7 +3,7 @@ import {
DEFAULT_EXEC_APPROVAL_TIMEOUT_MS, DEFAULT_EXEC_APPROVAL_TIMEOUT_MS,
type ExecApprovalDecision, type ExecApprovalDecision,
} from "../../infra/exec-approvals.js"; } from "../../infra/exec-approvals.js";
import { buildSystemRunApprovalBindingV1 } from "../../infra/system-run-approval-binding.js"; import { buildSystemRunApprovalBinding } from "../../infra/system-run-approval-binding.js";
import { resolveSystemRunApprovalRequestContext } from "../../infra/system-run-approval-context.js"; import { resolveSystemRunApprovalRequestContext } from "../../infra/system-run-approval-context.js";
import type { ExecApprovalManager } from "../exec-approval-manager.js"; import type { ExecApprovalManager } from "../exec-approval-manager.js";
import { import {
@@ -48,7 +48,7 @@ export function createExecApprovalHandlers(
commandArgv?: string[]; commandArgv?: string[];
env?: Record<string, string>; env?: Record<string, string>;
cwd?: string; cwd?: string;
systemRunPlanV2?: unknown; systemRunPlan?: unknown;
nodeId?: string; nodeId?: string;
host?: string; host?: string;
security?: string; security?: string;
@@ -73,7 +73,7 @@ export function createExecApprovalHandlers(
host, host,
command: p.command, command: p.command,
commandArgv: p.commandArgv, commandArgv: p.commandArgv,
systemRunPlanV2: p.systemRunPlanV2, systemRunPlan: p.systemRunPlan,
cwd: p.cwd, cwd: p.cwd,
agentId: p.agentId, agentId: p.agentId,
sessionKey: p.sessionKey, sessionKey: p.sessionKey,
@@ -91,6 +91,14 @@ export function createExecApprovalHandlers(
); );
return; return;
} }
if (host === "node" && !approvalContext.plan) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "systemRunPlan is required for host=node"),
);
return;
}
if ( if (
host === "node" && host === "node" &&
(!Array.isArray(effectiveCommandArgv) || effectiveCommandArgv.length === 0) (!Array.isArray(effectiveCommandArgv) || effectiveCommandArgv.length === 0)
@@ -102,9 +110,9 @@ export function createExecApprovalHandlers(
); );
return; return;
} }
const systemRunBindingV1 = const systemRunBinding =
host === "node" host === "node"
? buildSystemRunApprovalBindingV1({ ? buildSystemRunApprovalBinding({
argv: effectiveCommandArgv, argv: effectiveCommandArgv,
cwd: effectiveCwd, cwd: effectiveCwd,
agentId: effectiveAgentId, agentId: effectiveAgentId,
@@ -123,9 +131,9 @@ export function createExecApprovalHandlers(
const request = { const request = {
command: effectiveCommandText, command: effectiveCommandText,
commandArgv: effectiveCommandArgv, commandArgv: effectiveCommandArgv,
envKeys: systemRunBindingV1?.envKeys?.length ? systemRunBindingV1.envKeys : undefined, envKeys: systemRunBinding?.envKeys?.length ? systemRunBinding.envKeys : undefined,
systemRunBindingV1: systemRunBindingV1?.binding ?? null, systemRunBinding: systemRunBinding?.binding ?? null,
systemRunPlanV2: approvalContext.planV2, systemRunPlan: approvalContext.plan,
cwd: effectiveCwd ?? null, cwd: effectiveCwd ?? null,
nodeId: host === "node" ? nodeId : null, nodeId: host === "node" ? nodeId : null,
host: host || null, host: host || null,

View File

@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { emitAgentEvent } from "../../infra/agent-events.js"; import { emitAgentEvent } from "../../infra/agent-events.js";
import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.js"; import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.js";
import { buildSystemRunApprovalBindingV1 } from "../../infra/system-run-approval-binding.js"; import { buildSystemRunApprovalBinding } from "../../infra/system-run-approval-binding.js";
import { resetLogger, setLoggerOverride } from "../../logging.js"; import { resetLogger, setLoggerOverride } from "../../logging.js";
import { ExecApprovalManager } from "../exec-approval-manager.js"; import { ExecApprovalManager } from "../exec-approval-manager.js";
import { validateExecApprovalRequestParams } from "../protocol/index.js"; import { validateExecApprovalRequestParams } from "../protocol/index.js";
@@ -249,6 +249,13 @@ describe("exec approval handlers", () => {
const defaultExecApprovalRequestParams = { const defaultExecApprovalRequestParams = {
command: "echo ok", command: "echo ok",
commandArgv: ["echo", "ok"], commandArgv: ["echo", "ok"],
systemRunPlan: {
argv: ["/usr/bin/echo", "ok"],
cwd: "/tmp",
rawCommand: "/usr/bin/echo ok",
agentId: "main",
sessionKey: "agent:main:main",
},
cwd: "/tmp", cwd: "/tmp",
nodeId: "node-1", nodeId: "node-1",
host: "node", host: "node",
@@ -278,6 +285,37 @@ describe("exec approval handlers", () => {
...defaultExecApprovalRequestParams, ...defaultExecApprovalRequestParams,
...params.params, ...params.params,
} as unknown as ExecApprovalRequestArgs["params"]; } as unknown as ExecApprovalRequestArgs["params"];
const hasExplicitPlan = !!params.params && Object.hasOwn(params.params, "systemRunPlan");
if (
!hasExplicitPlan &&
(requestParams as { host?: string }).host === "node" &&
Array.isArray((requestParams as { commandArgv?: unknown }).commandArgv)
) {
const commandArgv = (requestParams as { commandArgv: unknown[] }).commandArgv.map((entry) =>
String(entry),
);
const cwdValue =
typeof (requestParams as { cwd?: unknown }).cwd === "string"
? ((requestParams as { cwd: string }).cwd ?? null)
: null;
const commandText =
typeof (requestParams as { command?: unknown }).command === "string"
? ((requestParams as { command: string }).command ?? null)
: null;
requestParams.systemRunPlan = {
argv: commandArgv,
cwd: cwdValue,
rawCommand: commandText,
agentId:
typeof (requestParams as { agentId?: unknown }).agentId === "string"
? ((requestParams as { agentId: string }).agentId ?? null)
: null,
sessionKey:
typeof (requestParams as { sessionKey?: unknown }).sessionKey === "string"
? ((requestParams as { sessionKey: string }).sessionKey ?? null)
: null,
};
}
return params.handlers["exec.approval.request"]({ return params.handlers["exec.approval.request"]({
params: requestParams, params: requestParams,
respond: params.respond as unknown as ExecApprovalRequestArgs["respond"], respond: params.respond as unknown as ExecApprovalRequestArgs["respond"],
@@ -385,21 +423,21 @@ describe("exec approval handlers", () => {
); );
}); });
it("rejects host=node approval requests without commandArgv", async () => { it("rejects host=node approval requests without systemRunPlan", async () => {
const { handlers, respond, context } = createExecApprovalFixture(); const { handlers, respond, context } = createExecApprovalFixture();
await requestExecApproval({ await requestExecApproval({
handlers, handlers,
respond, respond,
context, context,
params: { params: {
commandArgv: undefined, systemRunPlan: undefined,
}, },
}); });
expect(respond).toHaveBeenCalledWith( expect(respond).toHaveBeenCalledWith(
false, false,
undefined, undefined,
expect.objectContaining({ expect.objectContaining({
message: "commandArgv is required for host=node", message: "systemRunPlan is required for host=node",
}), }),
); );
}); });
@@ -462,8 +500,8 @@ describe("exec approval handlers", () => {
expect(requested).toBeTruthy(); expect(requested).toBeTruthy();
const request = (requested?.payload as { request?: Record<string, unknown> })?.request ?? {}; const request = (requested?.payload as { request?: Record<string, unknown> })?.request ?? {};
expect(request["envKeys"]).toEqual(["A_VAR", "Z_VAR"]); expect(request["envKeys"]).toEqual(["A_VAR", "Z_VAR"]);
expect(request["systemRunBindingV1"]).toEqual( expect(request["systemRunBinding"]).toEqual(
buildSystemRunApprovalBindingV1({ buildSystemRunApprovalBinding({
argv: ["echo", "ok"], argv: ["echo", "ok"],
cwd: "/tmp", cwd: "/tmp",
env: { A_VAR: "a", Z_VAR: "z" }, env: { A_VAR: "a", Z_VAR: "z" },
@@ -471,7 +509,7 @@ describe("exec approval handlers", () => {
); );
}); });
it("prefers systemRunPlanV2 canonical command/cwd when present", async () => { it("prefers systemRunPlan canonical command/cwd when present", async () => {
const { handlers, broadcasts, respond, context } = createExecApprovalFixture(); const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
await requestExecApproval({ await requestExecApproval({
handlers, handlers,
@@ -481,8 +519,7 @@ describe("exec approval handlers", () => {
command: "echo stale", command: "echo stale",
commandArgv: ["echo", "stale"], commandArgv: ["echo", "stale"],
cwd: "/tmp/link/sub", cwd: "/tmp/link/sub",
systemRunPlanV2: { systemRunPlan: {
version: 2,
argv: ["/usr/bin/echo", "ok"], argv: ["/usr/bin/echo", "ok"],
cwd: "/real/cwd", cwd: "/real/cwd",
rawCommand: "/usr/bin/echo ok", rawCommand: "/usr/bin/echo ok",
@@ -499,8 +536,7 @@ describe("exec approval handlers", () => {
expect(request["cwd"]).toBe("/real/cwd"); expect(request["cwd"]).toBe("/real/cwd");
expect(request["agentId"]).toBe("main"); expect(request["agentId"]).toBe("main");
expect(request["sessionKey"]).toBe("agent:main:main"); expect(request["sessionKey"]).toBe("agent:main:main");
expect(request["systemRunPlanV2"]).toEqual({ expect(request["systemRunPlan"]).toEqual({
version: 2,
argv: ["/usr/bin/echo", "ok"], argv: ["/usr/bin/echo", "ok"],
cwd: "/real/cwd", cwd: "/real/cwd",
rawCommand: "/usr/bin/echo ok", rawCommand: "/usr/bin/echo ok",

View File

@@ -80,6 +80,13 @@ async function requestAllowOnceApproval(
id: approvalId, id: approvalId,
command, command,
commandArgv, commandArgv,
systemRunPlan: {
argv: commandArgv,
cwd: null,
rawCommand: command,
agentId: null,
sessionKey: null,
},
nodeId, nodeId,
cwd: null, cwd: null,
host: "node", host: "node",

View File

@@ -3,7 +3,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import type { ExecApprovalRequestPayload } from "../infra/exec-approvals.js"; import type { ExecApprovalRequestPayload } from "../infra/exec-approvals.js";
import { buildSystemRunApprovalBindingV1 } from "../infra/system-run-approval-binding.js"; import { buildSystemRunApprovalBinding } from "../infra/system-run-approval-binding.js";
import { evaluateSystemRunApprovalMatch } from "./node-invoke-system-run-approval-match.js"; import { evaluateSystemRunApprovalMatch } from "./node-invoke-system-run-approval-match.js";
type FixtureCase = { type FixtureCase = {
@@ -15,7 +15,7 @@ type FixtureCase = {
cwd?: string | null; cwd?: string | null;
agentId?: string | null; agentId?: string | null;
sessionKey?: string | null; sessionKey?: string | null;
bindingV1?: { binding?: {
argv: string[]; argv: string[];
cwd?: string | null; cwd?: string | null;
agentId?: string | null; agentId?: string | null;
@@ -57,13 +57,13 @@ function buildRequestPayload(entry: FixtureCase): ExecApprovalRequestPayload {
agentId: entry.request.agentId ?? null, agentId: entry.request.agentId ?? null,
sessionKey: entry.request.sessionKey ?? null, sessionKey: entry.request.sessionKey ?? null,
}; };
if (entry.request.bindingV1) { if (entry.request.binding) {
payload.systemRunBindingV1 = buildSystemRunApprovalBindingV1({ payload.systemRunBinding = buildSystemRunApprovalBinding({
argv: entry.request.bindingV1.argv, argv: entry.request.binding.argv,
cwd: entry.request.bindingV1.cwd, cwd: entry.request.binding.cwd,
agentId: entry.request.bindingV1.agentId, agentId: entry.request.binding.agentId,
sessionKey: entry.request.bindingV1.sessionKey, sessionKey: entry.request.binding.sessionKey,
env: entry.request.bindingV1.env, env: entry.request.binding.env,
}).binding; }).binding;
} }
return payload; return payload;

View File

@@ -1,8 +1,8 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { import {
buildSystemRunApprovalBindingV1, buildSystemRunApprovalBinding,
buildSystemRunApprovalEnvBinding, buildSystemRunApprovalEnvBinding,
matchSystemRunApprovalBindingV1, matchSystemRunApprovalBinding,
matchSystemRunApprovalEnvHash, matchSystemRunApprovalEnvHash,
toSystemRunApprovalMismatchError, toSystemRunApprovalMismatchError,
} from "../infra/system-run-approval-binding.js"; } from "../infra/system-run-approval-binding.js";
@@ -48,16 +48,16 @@ describe("matchSystemRunApprovalEnvHash", () => {
}); });
}); });
describe("matchSystemRunApprovalBindingV1", () => { describe("matchSystemRunApprovalBinding", () => {
test("accepts matching binding with reordered env keys", () => { test("accepts matching binding with reordered env keys", () => {
const expected = buildSystemRunApprovalBindingV1({ const expected = buildSystemRunApprovalBinding({
argv: ["git", "diff"], argv: ["git", "diff"],
cwd: null, cwd: null,
agentId: null, agentId: null,
sessionKey: null, sessionKey: null,
env: { SAFE_A: "1", SAFE_B: "2" }, env: { SAFE_A: "1", SAFE_B: "2" },
}); });
const actual = buildSystemRunApprovalBindingV1({ const actual = buildSystemRunApprovalBinding({
argv: ["git", "diff"], argv: ["git", "diff"],
cwd: null, cwd: null,
agentId: null, agentId: null,
@@ -65,7 +65,7 @@ describe("matchSystemRunApprovalBindingV1", () => {
env: { SAFE_B: "2", SAFE_A: "1" }, env: { SAFE_B: "2", SAFE_A: "1" },
}); });
expect( expect(
matchSystemRunApprovalBindingV1({ matchSystemRunApprovalBinding({
expected: expected.binding, expected: expected.binding,
actual: actual.binding, actual: actual.binding,
actualEnvKeys: actual.envKeys, actualEnvKeys: actual.envKeys,
@@ -74,21 +74,21 @@ describe("matchSystemRunApprovalBindingV1", () => {
}); });
test("rejects env mismatch", () => { test("rejects env mismatch", () => {
const expected = buildSystemRunApprovalBindingV1({ const expected = buildSystemRunApprovalBinding({
argv: ["git", "diff"], argv: ["git", "diff"],
cwd: null, cwd: null,
agentId: null, agentId: null,
sessionKey: null, sessionKey: null,
env: { SAFE: "1" }, env: { SAFE: "1" },
}); });
const actual = buildSystemRunApprovalBindingV1({ const actual = buildSystemRunApprovalBinding({
argv: ["git", "diff"], argv: ["git", "diff"],
cwd: null, cwd: null,
agentId: null, agentId: null,
sessionKey: null, sessionKey: null,
env: { SAFE: "2" }, env: { SAFE: "2" },
}); });
const result = matchSystemRunApprovalBindingV1({ const result = matchSystemRunApprovalBinding({
expected: expected.binding, expected: expected.binding,
actual: actual.binding, actual: actual.binding,
actualEnvKeys: actual.envKeys, actualEnvKeys: actual.envKeys,

View File

@@ -626,7 +626,7 @@ function renderQuotedArgv(argv: string[]): string {
return argv.map((token) => shellEscapeSingleArg(token)).join(" "); return argv.map((token) => shellEscapeSingleArg(token)).join(" ");
} }
function resolvePlannedSegmentArgv(segment: ExecCommandSegment): string[] | null { export function resolvePlannedSegmentArgv(segment: ExecCommandSegment): string[] | null {
if (segment.resolution?.policyBlocked === true) { if (segment.resolution?.policyBlocked === true) {
return null; return null;
} }
@@ -638,7 +638,8 @@ function resolvePlannedSegmentArgv(segment: ExecCommandSegment): string[] | null
return null; return null;
} }
const argv = [...baseArgv]; const argv = [...baseArgv];
const resolvedExecutable = segment.resolution?.resolvedPath?.trim() ?? ""; const resolvedExecutable =
segment.resolution?.resolvedRealPath?.trim() ?? segment.resolution?.resolvedPath?.trim() ?? "";
if (resolvedExecutable) { if (resolvedExecutable) {
argv[0] = resolvedExecutable; argv[0] = resolvedExecutable;
} }

View File

@@ -11,8 +11,7 @@ export type ExecHost = "sandbox" | "gateway" | "node";
export type ExecSecurity = "deny" | "allowlist" | "full"; export type ExecSecurity = "deny" | "allowlist" | "full";
export type ExecAsk = "off" | "on-miss" | "always"; export type ExecAsk = "off" | "on-miss" | "always";
export type SystemRunApprovalBindingV1 = { export type SystemRunApprovalBinding = {
version: 1;
argv: string[]; argv: string[];
cwd: string | null; cwd: string | null;
agentId: string | null; agentId: string | null;
@@ -20,8 +19,7 @@ export type SystemRunApprovalBindingV1 = {
envHash: string | null; envHash: string | null;
}; };
export type SystemRunApprovalPlanV2 = { export type SystemRunApprovalPlan = {
version: 2;
argv: string[]; argv: string[];
cwd: string | null; cwd: string | null;
rawCommand: string | null; rawCommand: string | null;
@@ -34,8 +32,8 @@ export type ExecApprovalRequestPayload = {
commandArgv?: string[]; commandArgv?: string[];
// Optional UI-safe env key preview for approval prompts. // Optional UI-safe env key preview for approval prompts.
envKeys?: string[]; envKeys?: string[];
systemRunBindingV1?: SystemRunApprovalBindingV1 | null; systemRunBinding?: SystemRunApprovalBinding | null;
systemRunPlanV2?: SystemRunApprovalPlanV2 | null; systemRunPlan?: SystemRunApprovalPlan | null;
cwd?: string | null; cwd?: string | null;
nodeId?: string | null; nodeId?: string | null;
host?: string | null; host?: string | null;

View File

@@ -9,6 +9,7 @@ export const DEFAULT_SAFE_BINS = ["jq", "cut", "uniq", "head", "tail", "tr", "wc
export type CommandResolution = { export type CommandResolution = {
rawExecutable: string; rawExecutable: string;
resolvedPath?: string; resolvedPath?: string;
resolvedRealPath?: string;
executableName: string; executableName: string;
effectiveArgv?: string[]; effectiveArgv?: string[];
wrapperChain?: string[]; wrapperChain?: string[];
@@ -86,6 +87,17 @@ function resolveExecutablePath(rawExecutable: string, cwd?: string, env?: NodeJS
return undefined; return undefined;
} }
function tryResolveRealpath(filePath: string | undefined): string | undefined {
if (!filePath) {
return undefined;
}
try {
return fs.realpathSync(filePath);
} catch {
return undefined;
}
}
export function resolveCommandResolution( export function resolveCommandResolution(
command: string, command: string,
cwd?: string, cwd?: string,
@@ -96,10 +108,12 @@ export function resolveCommandResolution(
return null; return null;
} }
const resolvedPath = resolveExecutablePath(rawExecutable, cwd, env); const resolvedPath = resolveExecutablePath(rawExecutable, cwd, env);
const resolvedRealPath = tryResolveRealpath(resolvedPath);
const executableName = resolvedPath ? path.basename(resolvedPath) : rawExecutable; const executableName = resolvedPath ? path.basename(resolvedPath) : rawExecutable;
return { return {
rawExecutable, rawExecutable,
resolvedPath, resolvedPath,
resolvedRealPath,
executableName, executableName,
effectiveArgv: [rawExecutable], effectiveArgv: [rawExecutable],
wrapperChain: [], wrapperChain: [],
@@ -119,10 +133,12 @@ export function resolveCommandResolutionFromArgv(
return null; return null;
} }
const resolvedPath = resolveExecutablePath(rawExecutable, cwd, env); const resolvedPath = resolveExecutablePath(rawExecutable, cwd, env);
const resolvedRealPath = tryResolveRealpath(resolvedPath);
const executableName = resolvedPath ? path.basename(resolvedPath) : rawExecutable; const executableName = resolvedPath ? path.basename(resolvedPath) : rawExecutable;
return { return {
rawExecutable, rawExecutable,
resolvedPath, resolvedPath,
resolvedRealPath,
executableName, executableName,
effectiveArgv, effectiveArgv,
wrapperChain: plan.wrappers, wrapperChain: plan.wrappers,

View File

@@ -1,5 +1,5 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import type { SystemRunApprovalBindingV1, SystemRunApprovalPlanV2 } from "./exec-approvals.js"; import type { SystemRunApprovalBinding, SystemRunApprovalPlan } from "./exec-approvals.js";
import { normalizeEnvVarKey } from "./host-env-security.js"; import { normalizeEnvVarKey } from "./host-env-security.js";
type NormalizedSystemRunEnvEntry = [key: string, value: string]; type NormalizedSystemRunEnvEntry = [key: string, value: string];
@@ -16,20 +16,16 @@ function normalizeStringArray(value: unknown): string[] {
return Array.isArray(value) ? value.map((entry) => String(entry)) : []; return Array.isArray(value) ? value.map((entry) => String(entry)) : [];
} }
export function normalizeSystemRunApprovalPlanV2(value: unknown): SystemRunApprovalPlanV2 | null { export function normalizeSystemRunApprovalPlan(value: unknown): SystemRunApprovalPlan | null {
if (!value || typeof value !== "object" || Array.isArray(value)) { if (!value || typeof value !== "object" || Array.isArray(value)) {
return null; return null;
} }
const candidate = value as Record<string, unknown>; const candidate = value as Record<string, unknown>;
if (candidate.version !== 2) {
return null;
}
const argv = normalizeStringArray(candidate.argv); const argv = normalizeStringArray(candidate.argv);
if (argv.length === 0) { if (argv.length === 0) {
return null; return null;
} }
return { return {
version: 2,
argv, argv,
cwd: normalizeString(candidate.cwd), cwd: normalizeString(candidate.cwd),
rawCommand: normalizeString(candidate.rawCommand), rawCommand: normalizeString(candidate.rawCommand),
@@ -75,17 +71,16 @@ export function buildSystemRunApprovalEnvBinding(env: unknown): {
}; };
} }
export function buildSystemRunApprovalBindingV1(params: { export function buildSystemRunApprovalBinding(params: {
argv: unknown; argv: unknown;
cwd?: unknown; cwd?: unknown;
agentId?: unknown; agentId?: unknown;
sessionKey?: unknown; sessionKey?: unknown;
env?: unknown; env?: unknown;
}): { binding: SystemRunApprovalBindingV1; envKeys: string[] } { }): { binding: SystemRunApprovalBinding; envKeys: string[] } {
const envBinding = buildSystemRunApprovalEnvBinding(params.env); const envBinding = buildSystemRunApprovalEnvBinding(params.env);
return { return {
binding: { binding: {
version: 1,
argv: normalizeStringArray(params.argv), argv: normalizeStringArray(params.argv),
cwd: normalizeString(params.cwd), cwd: normalizeString(params.cwd),
agentId: normalizeString(params.agentId), agentId: normalizeString(params.agentId),
@@ -161,17 +156,11 @@ export function matchSystemRunApprovalEnvHash(params: {
return { ok: true }; return { ok: true };
} }
export function matchSystemRunApprovalBindingV1(params: { export function matchSystemRunApprovalBinding(params: {
expected: SystemRunApprovalBindingV1; expected: SystemRunApprovalBinding;
actual: SystemRunApprovalBindingV1; actual: SystemRunApprovalBinding;
actualEnvKeys: string[]; actualEnvKeys: string[];
}): SystemRunApprovalMatchResult { }): SystemRunApprovalMatchResult {
if (params.expected.version !== 1 || params.actual.version !== 1) {
return requestMismatch({
expectedVersion: params.expected.version,
actualVersion: params.actual.version,
});
}
if (!argvMatches(params.expected.argv, params.actual.argv)) { if (!argvMatches(params.expected.argv, params.actual.argv)) {
return requestMismatch(); return requestMismatch();
} }
@@ -191,11 +180,10 @@ export function matchSystemRunApprovalBindingV1(params: {
}); });
} }
export function missingSystemRunApprovalBindingV1(params: { export function missingSystemRunApprovalBinding(params: {
actualEnvKeys: string[]; actualEnvKeys: string[];
}): SystemRunApprovalMatchResult { }): SystemRunApprovalMatchResult {
return requestMismatch({ return requestMismatch({
requiredBindingVersion: 1,
envKeys: params.actualEnvKeys, envKeys: params.actualEnvKeys,
}); });
} }

View File

@@ -1,14 +1,14 @@
import type { SystemRunApprovalPlanV2 } from "./exec-approvals.js"; import type { SystemRunApprovalPlan } from "./exec-approvals.js";
import { normalizeSystemRunApprovalPlanV2 } from "./system-run-approval-binding.js"; import { normalizeSystemRunApprovalPlan } from "./system-run-approval-binding.js";
import { formatExecCommand, resolveSystemRunCommand } from "./system-run-command.js"; import { formatExecCommand, resolveSystemRunCommand } from "./system-run-command.js";
type PreparedRunPayload = { type PreparedRunPayload = {
cmdText: string; cmdText: string;
plan: SystemRunApprovalPlanV2; plan: SystemRunApprovalPlan;
}; };
type SystemRunApprovalRequestContext = { type SystemRunApprovalRequestContext = {
planV2: SystemRunApprovalPlanV2 | null; plan: SystemRunApprovalPlan | null;
commandArgv: string[] | undefined; commandArgv: string[] | undefined;
commandText: string; commandText: string;
cwd: string | null; cwd: string | null;
@@ -19,7 +19,7 @@ type SystemRunApprovalRequestContext = {
type SystemRunApprovalRuntimeContext = type SystemRunApprovalRuntimeContext =
| { | {
ok: true; ok: true;
planV2: SystemRunApprovalPlanV2 | null; plan: SystemRunApprovalPlan | null;
argv: string[]; argv: string[];
cwd: string | null; cwd: string | null;
agentId: string | null; agentId: string | null;
@@ -54,7 +54,7 @@ export function parsePreparedSystemRunPayload(payload: unknown): PreparedRunPayl
} }
const raw = payload as { cmdText?: unknown; plan?: unknown }; const raw = payload as { cmdText?: unknown; plan?: unknown };
const cmdText = normalizeString(raw.cmdText); const cmdText = normalizeString(raw.cmdText);
const plan = normalizeSystemRunApprovalPlanV2(raw.plan); const plan = normalizeSystemRunApprovalPlan(raw.plan);
if (!cmdText || !plan) { if (!cmdText || !plan) {
return null; return null;
} }
@@ -65,38 +65,38 @@ export function resolveSystemRunApprovalRequestContext(params: {
host?: unknown; host?: unknown;
command?: unknown; command?: unknown;
commandArgv?: unknown; commandArgv?: unknown;
systemRunPlanV2?: unknown; systemRunPlan?: unknown;
cwd?: unknown; cwd?: unknown;
agentId?: unknown; agentId?: unknown;
sessionKey?: unknown; sessionKey?: unknown;
}): SystemRunApprovalRequestContext { }): SystemRunApprovalRequestContext {
const host = normalizeString(params.host) ?? ""; const host = normalizeString(params.host) ?? "";
const planV2 = host === "node" ? normalizeSystemRunApprovalPlanV2(params.systemRunPlanV2) : null; const plan = host === "node" ? normalizeSystemRunApprovalPlan(params.systemRunPlan) : null;
const fallbackArgv = normalizeStringArray(params.commandArgv); const fallbackArgv = normalizeStringArray(params.commandArgv);
const fallbackCommand = normalizeCommandText(params.command); const fallbackCommand = normalizeCommandText(params.command);
return { return {
planV2, plan,
commandArgv: planV2?.argv ?? (fallbackArgv.length > 0 ? fallbackArgv : undefined), commandArgv: plan?.argv ?? (fallbackArgv.length > 0 ? fallbackArgv : undefined),
commandText: planV2 ? (planV2.rawCommand ?? formatExecCommand(planV2.argv)) : fallbackCommand, commandText: plan ? (plan.rawCommand ?? formatExecCommand(plan.argv)) : fallbackCommand,
cwd: planV2?.cwd ?? normalizeString(params.cwd), cwd: plan?.cwd ?? normalizeString(params.cwd),
agentId: planV2?.agentId ?? normalizeString(params.agentId), agentId: plan?.agentId ?? normalizeString(params.agentId),
sessionKey: planV2?.sessionKey ?? normalizeString(params.sessionKey), sessionKey: plan?.sessionKey ?? normalizeString(params.sessionKey),
}; };
} }
export function resolveSystemRunApprovalRuntimeContext(params: { export function resolveSystemRunApprovalRuntimeContext(params: {
planV2?: unknown; plan?: unknown;
command?: unknown; command?: unknown;
rawCommand?: unknown; rawCommand?: unknown;
cwd?: unknown; cwd?: unknown;
agentId?: unknown; agentId?: unknown;
sessionKey?: unknown; sessionKey?: unknown;
}): SystemRunApprovalRuntimeContext { }): SystemRunApprovalRuntimeContext {
const normalizedPlan = normalizeSystemRunApprovalPlanV2(params.planV2 ?? null); const normalizedPlan = normalizeSystemRunApprovalPlan(params.plan ?? null);
if (normalizedPlan) { if (normalizedPlan) {
return { return {
ok: true, ok: true,
planV2: normalizedPlan, plan: normalizedPlan,
argv: [...normalizedPlan.argv], argv: [...normalizedPlan.argv],
cwd: normalizedPlan.cwd, cwd: normalizedPlan.cwd,
agentId: normalizedPlan.agentId, agentId: normalizedPlan.agentId,
@@ -113,7 +113,7 @@ export function resolveSystemRunApprovalRuntimeContext(params: {
} }
return { return {
ok: true, ok: true,
planV2: null, plan: null,
argv: command.argv, argv: command.argv,
cwd: normalizeString(params.cwd), cwd: normalizeString(params.cwd),
agentId: normalizeString(params.agentId), agentId: normalizeString(params.agentId),

View File

@@ -2,6 +2,7 @@ import {
analyzeArgvCommand, analyzeArgvCommand,
evaluateExecAllowlist, evaluateExecAllowlist,
evaluateShellAllowlist, evaluateShellAllowlist,
resolvePlannedSegmentArgv,
resolveExecApprovals, resolveExecApprovals,
type ExecAllowlistEntry, type ExecAllowlistEntry,
type ExecCommandSegment, type ExecCommandSegment,
@@ -95,7 +96,7 @@ export function resolvePlannedAllowlistArgv(params: {
) { ) {
return undefined; return undefined;
} }
const plannedAllowlistArgv = params.segments[0]?.resolution?.effectiveArgv; const plannedAllowlistArgv = resolvePlannedSegmentArgv(params.segments[0]);
return plannedAllowlistArgv && plannedAllowlistArgv.length > 0 ? plannedAllowlistArgv : null; return plannedAllowlistArgv && plannedAllowlistArgv.length > 0 ? plannedAllowlistArgv : null;
} }

View File

@@ -1,6 +1,7 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import type { SystemRunApprovalPlanV2 } from "../infra/exec-approvals.js"; import type { SystemRunApprovalPlan } from "../infra/exec-approvals.js";
import { resolveCommandResolutionFromArgv } from "../infra/exec-command-resolution.js";
import { sameFileIdentity } from "../infra/file-identity.js"; import { sameFileIdentity } from "../infra/file-identity.js";
import { resolveSystemRunCommand } from "../infra/system-run-command.js"; import { resolveSystemRunCommand } from "../infra/system-run-command.js";
@@ -12,22 +13,6 @@ function normalizeString(value: unknown): string | null {
return trimmed ? trimmed : null; return trimmed ? trimmed : null;
} }
function isPathLikeExecutableToken(value: string): boolean {
if (!value) {
return false;
}
if (value.startsWith(".") || value.startsWith("/") || value.startsWith("\\")) {
return true;
}
if (value.includes("/") || value.includes("\\")) {
return true;
}
if (process.platform === "win32" && /^[a-zA-Z]:[\\/]/.test(value)) {
return true;
}
return false;
}
function pathComponentsFromRootSync(targetPath: string): string[] { function pathComponentsFromRootSync(targetPath: string): string[] {
const absolute = path.resolve(targetPath); const absolute = path.resolve(targetPath);
const parts: string[] = []; const parts: string[] = [];
@@ -71,7 +56,6 @@ function hasMutableSymlinkPathComponentSync(targetPath: string): boolean {
export function hardenApprovedExecutionPaths(params: { export function hardenApprovedExecutionPaths(params: {
approvedByAsk: boolean; approvedByAsk: boolean;
argv: string[]; argv: string[];
shellCommand: string | null;
cwd: string | undefined; cwd: string | undefined;
}): { ok: true; argv: string[]; cwd: string | undefined } | { ok: false; message: string } { }): { ok: true; argv: string[]; cwd: string | undefined } | { ok: false; message: string } {
if (!params.approvedByAsk) { if (!params.approvedByAsk) {
@@ -127,38 +111,31 @@ export function hardenApprovedExecutionPaths(params: {
hardenedCwd = cwdReal; hardenedCwd = cwdReal;
} }
if (params.shellCommand !== null || params.argv.length === 0) { if (params.argv.length === 0) {
return { ok: true, argv: params.argv, cwd: hardenedCwd }; return { ok: true, argv: params.argv, cwd: hardenedCwd };
} }
const argv = [...params.argv]; const resolution = resolveCommandResolutionFromArgv(params.argv, hardenedCwd);
const rawExecutable = argv[0] ?? ""; const pinnedExecutable = resolution?.resolvedRealPath ?? resolution?.resolvedPath;
if (!isPathLikeExecutableToken(rawExecutable)) { if (!pinnedExecutable) {
return { ok: true, argv, cwd: hardenedCwd };
}
const base = hardenedCwd ?? process.cwd();
const candidate = path.isAbsolute(rawExecutable)
? rawExecutable
: path.resolve(base, rawExecutable);
try {
argv[0] = fs.realpathSync(candidate);
} catch {
return { return {
ok: false, ok: false,
message: "SYSTEM_RUN_DENIED: approval requires a stable executable path", message: "SYSTEM_RUN_DENIED: approval requires a stable executable path",
}; };
} }
const argv = [...params.argv];
argv[0] = pinnedExecutable;
return { ok: true, argv, cwd: hardenedCwd }; return { ok: true, argv, cwd: hardenedCwd };
} }
export function buildSystemRunApprovalPlanV2(params: { export function buildSystemRunApprovalPlan(params: {
command?: unknown; command?: unknown;
rawCommand?: unknown; rawCommand?: unknown;
cwd?: unknown; cwd?: unknown;
agentId?: unknown; agentId?: unknown;
sessionKey?: unknown; sessionKey?: unknown;
}): { ok: true; plan: SystemRunApprovalPlanV2; cmdText: string } | { ok: false; message: string } { }): { ok: true; plan: SystemRunApprovalPlan; cmdText: string } | { ok: false; message: string } {
const command = resolveSystemRunCommand({ const command = resolveSystemRunCommand({
command: params.command, command: params.command,
rawCommand: params.rawCommand, rawCommand: params.rawCommand,
@@ -172,7 +149,6 @@ export function buildSystemRunApprovalPlanV2(params: {
const hardening = hardenApprovedExecutionPaths({ const hardening = hardenApprovedExecutionPaths({
approvedByAsk: true, approvedByAsk: true,
argv: command.argv, argv: command.argv,
shellCommand: command.shellCommand,
cwd: normalizeString(params.cwd) ?? undefined, cwd: normalizeString(params.cwd) ?? undefined,
}); });
if (!hardening.ok) { if (!hardening.ok) {
@@ -181,7 +157,6 @@ export function buildSystemRunApprovalPlanV2(params: {
return { return {
ok: true, ok: true,
plan: { plan: {
version: 2,
argv: hardening.argv, argv: hardening.argv,
cwd: hardening.cwd ?? null, cwd: hardening.cwd ?? null,
rawCommand: command.cmdText.trim() || null, rawCommand: command.cmdText.trim() || null,

View File

@@ -54,15 +54,22 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
ask?: "off" | "on-miss" | "always"; ask?: "off" | "on-miss" | "always";
approved?: boolean; approved?: boolean;
}) { }) {
const runCommand = vi.fn(async () => ({ const runCommand = vi.fn(
success: true, async (
stdout: "local-ok", _command: string[],
stderr: "", _cwd?: string,
timedOut: false, _env?: Record<string, string>,
truncated: false, _timeoutMs?: number,
exitCode: 0, ) => ({
error: null, success: true,
})); stdout: "local-ok",
stderr: "",
timedOut: false,
truncated: false,
exitCode: 0,
error: null,
}),
);
const runViaMacAppExecHost = vi.fn(async () => params.runViaResponse ?? null); const runViaMacAppExecHost = vi.fn(async () => params.runViaResponse ?? null);
const sendInvokeResult = vi.fn(async () => {}); const sendInvokeResult = vi.fn(async () => {});
const sendExecFinishedEvent = vi.fn(async () => {}); const sendExecFinishedEvent = vi.fn(async () => {});
@@ -192,7 +199,10 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
return; return;
} }
expect(runCommand).toHaveBeenCalledWith(["tr", "a", "b"], undefined, undefined, undefined); const runArgs = vi.mocked(runCommand).mock.calls[0]?.[0] as string[] | undefined;
expect(runArgs).toBeDefined();
expect(runArgs?.[0]).toMatch(/(^|[/\\])tr$/);
expect(runArgs?.slice(1)).toEqual(["a", "b"]);
expect(sendInvokeResult).toHaveBeenCalledWith( expect(sendInvokeResult).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
ok: true, ok: true,
@@ -217,6 +227,132 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
); );
}); });
it.runIf(process.platform !== "win32")(
"pins PATH-token executable to canonical path for approval-based runs",
async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-path-pin-"));
const binDir = path.join(tmp, "bin");
fs.mkdirSync(binDir, { recursive: true });
const link = path.join(binDir, "poccmd");
fs.symlinkSync("/bin/echo", link);
const expected = fs.realpathSync(link);
const oldPath = process.env.PATH;
process.env.PATH = `${binDir}${path.delimiter}${oldPath ?? ""}`;
try {
const { runCommand, sendInvokeResult } = await runSystemInvoke({
preferMacAppExecHost: false,
command: ["poccmd", "-n", "SAFE"],
approved: true,
security: "full",
ask: "off",
});
expect(runCommand).toHaveBeenCalledWith(
[expected, "-n", "SAFE"],
undefined,
undefined,
undefined,
);
expect(sendInvokeResult).toHaveBeenCalledWith(
expect.objectContaining({
ok: true,
}),
);
} finally {
if (oldPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = oldPath;
}
fs.rmSync(tmp, { recursive: true, force: true });
}
},
);
it.runIf(process.platform !== "win32")(
"pins PATH-token executable to canonical path for allowlist runs",
async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-allowlist-path-pin-"));
const binDir = path.join(tmp, "bin");
fs.mkdirSync(binDir, { recursive: true });
const link = path.join(binDir, "poccmd");
fs.symlinkSync("/bin/echo", link);
const expected = fs.realpathSync(link);
const oldPath = process.env.PATH;
process.env.PATH = `${binDir}${path.delimiter}${oldPath ?? ""}`;
const runCommand = vi.fn(async () => ({
success: true,
stdout: "local-ok",
stderr: "",
timedOut: false,
truncated: false,
exitCode: 0,
error: null,
}));
const sendInvokeResult = vi.fn(async () => {});
const sendNodeEvent = vi.fn(async () => {});
try {
await withTempApprovalsHome({
approvals: {
version: 1,
defaults: {
security: "allowlist",
ask: "off",
askFallback: "deny",
},
agents: {
main: {
allowlist: [{ pattern: link }],
},
},
},
run: async () => {
await handleSystemRunInvoke({
client: {} as never,
params: {
command: ["poccmd", "-n", "SAFE"],
sessionKey: "agent:main:main",
},
skillBins: {
current: async () => [],
},
execHostEnforced: false,
execHostFallbackAllowed: true,
resolveExecSecurity: () => "allowlist",
resolveExecAsk: () => "off",
isCmdExeInvocation: () => false,
sanitizeEnv: () => undefined,
runCommand,
runViaMacAppExecHost: vi.fn(async () => null),
sendNodeEvent,
buildExecEventPayload: (payload) => payload,
sendInvokeResult,
sendExecFinishedEvent: vi.fn(async () => {}),
preferMacAppExecHost: false,
});
},
});
expect(runCommand).toHaveBeenCalledWith(
[expected, "-n", "SAFE"],
undefined,
undefined,
undefined,
);
expect(sendInvokeResult).toHaveBeenCalledWith(
expect.objectContaining({
ok: true,
}),
);
} finally {
if (oldPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = oldPath;
}
fs.rmSync(tmp, { recursive: true, force: true });
}
},
);
it.runIf(process.platform !== "win32")( it.runIf(process.platform !== "win32")(
"denies approval-based execution when cwd is a symlink", "denies approval-based execution when cwd is a symlink",
async () => { async () => {

View File

@@ -174,7 +174,7 @@ async function sendSystemRunDenied(
} }
export { formatSystemRunAllowlistMissMessage } from "./exec-policy.js"; export { formatSystemRunAllowlistMissMessage } from "./exec-policy.js";
export { buildSystemRunApprovalPlanV2 } from "./invoke-system-run-plan.js"; export { buildSystemRunApprovalPlan } from "./invoke-system-run-plan.js";
async function parseSystemRunPhase( async function parseSystemRunPhase(
opts: HandleSystemRunInvokeOptions, opts: HandleSystemRunInvokeOptions,
@@ -300,7 +300,6 @@ async function evaluateSystemRunPolicyPhase(
const hardenedPaths = hardenApprovedExecutionPaths({ const hardenedPaths = hardenApprovedExecutionPaths({
approvedByAsk: policy.approvedByAsk, approvedByAsk: policy.approvedByAsk,
argv: parsed.argv, argv: parsed.argv,
shellCommand: parsed.shellCommand,
cwd: parsed.cwd, cwd: parsed.cwd,
}); });
if (!hardenedPaths.ok) { if (!hardenedPaths.ok) {

View File

@@ -20,7 +20,7 @@ import {
} from "../infra/exec-host.js"; } from "../infra/exec-host.js";
import { sanitizeHostExecEnv } from "../infra/host-env-security.js"; import { sanitizeHostExecEnv } from "../infra/host-env-security.js";
import { runBrowserProxyCommand } from "./invoke-browser.js"; import { runBrowserProxyCommand } from "./invoke-browser.js";
import { buildSystemRunApprovalPlanV2, handleSystemRunInvoke } from "./invoke-system-run.js"; import { buildSystemRunApprovalPlan, handleSystemRunInvoke } from "./invoke-system-run.js";
import type { import type {
ExecEventPayload, ExecEventPayload,
RunResult, RunResult,
@@ -429,7 +429,7 @@ export async function handleInvoke(
agentId?: unknown; agentId?: unknown;
sessionKey?: unknown; sessionKey?: unknown;
}>(frame.paramsJSON); }>(frame.paramsJSON);
const prepared = buildSystemRunApprovalPlanV2(params); const prepared = buildSystemRunApprovalPlan(params);
if (!prepared.ok) { if (!prepared.ok) {
await sendErrorResult(client, frame, "INVALID_REQUEST", prepared.message); await sendErrorResult(client, frame, "INVALID_REQUEST", prepared.message);
return; return;

View File

@@ -1,11 +1,11 @@
{ {
"cases": [ "cases": [
{ {
"name": "v1 matches when env key order changes", "name": "binding matches when env key order changes",
"request": { "request": {
"host": "node", "host": "node",
"command": "git diff", "command": "git diff",
"bindingV1": { "binding": {
"argv": ["git", "diff"], "argv": ["git", "diff"],
"cwd": null, "cwd": null,
"agentId": null, "agentId": null,
@@ -25,11 +25,11 @@
"expected": { "ok": true } "expected": { "ok": true }
}, },
{ {
"name": "v1 rejects env mismatch", "name": "binding rejects env mismatch",
"request": { "request": {
"host": "node", "host": "node",
"command": "git diff", "command": "git diff",
"bindingV1": { "binding": {
"argv": ["git", "diff"], "argv": ["git", "diff"],
"cwd": null, "cwd": null,
"agentId": null, "agentId": null,
@@ -49,11 +49,11 @@
"expected": { "ok": false, "code": "APPROVAL_ENV_MISMATCH" } "expected": { "ok": false, "code": "APPROVAL_ENV_MISMATCH" }
}, },
{ {
"name": "v1 rejects unbound env overrides", "name": "binding rejects unbound env overrides",
"request": { "request": {
"host": "node", "host": "node",
"command": "git diff", "command": "git diff",
"bindingV1": { "binding": {
"argv": ["git", "diff"], "argv": ["git", "diff"],
"cwd": null, "cwd": null,
"agentId": null, "agentId": null,
@@ -89,12 +89,12 @@
"expected": { "ok": false, "code": "APPROVAL_REQUEST_MISMATCH" } "expected": { "ok": false, "code": "APPROVAL_REQUEST_MISMATCH" }
}, },
{ {
"name": "v1 stays authoritative when legacy command text diverges", "name": "binding stays authoritative when legacy command text diverges",
"request": { "request": {
"host": "node", "host": "node",
"command": "echo STALE", "command": "echo STALE",
"commandArgv": ["echo", "STALE"], "commandArgv": ["echo", "STALE"],
"bindingV1": { "binding": {
"argv": ["echo", "SAFE"], "argv": ["echo", "SAFE"],
"cwd": null, "cwd": null,
"agentId": null, "agentId": null,