mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-19 00:52:10 -06:00
test(computer-use): security closure across the v2 action surface (#124112)
This commit is contained in:
committed by
GitHub
parent
de4104c84d
commit
af99d1ac7b
@@ -56,6 +56,12 @@ The app waits until the private socket accepts connections before advertising CU
|
||||
|
||||
The embedded CUA daemon runs in unrestricted mode because bounded CUA grants require exact launch-time resources and cannot represent OpenClaw's runtime-discovered windows and elements. OpenClaw command arming, pairing approval, and tool policy are the authoritative authorization gate, identical to the shipped Peekaboo fulfiller. The app owns the daemon and its macOS TCC identity, and the daemon accepts local connections only through an owner-only socket directory.
|
||||
|
||||
The `computer.act` node-invoke policy classifies exact arguments before transport dispatch. Forced app termination, browser navigation, browser downloads, browser file inputs, recording start, trajectory replay, and desktop-scope escalation are separate high-risk families; ordinary observation and input remain distinct. Classification does not add a per-action prompt or weaken the command-level gates: every action still requires the same exposed tool, armed command, approved pairing, enabled node provider, and OS permissions.
|
||||
|
||||
The managed endpoint is not part of the model contract. The CUA plugin registers no model tool, CLI command, service, or raw node-MCP descriptor, and its action schema accepts neither helper binaries, sockets, native sessions, driver arguments, nor provider tool names. On macOS only the app-owned worker receives the endpoint, while node shell execution is routed through the app host without that worker-only value. These boundaries prevent an OpenClaw model action from selecting an alternate route to the managed daemon.
|
||||
|
||||
The private socket is a local-user trust boundary, not a same-user process sandbox. Its owner-only directory excludes remote clients and other local users, but a process already running with arbitrary inspection rights as the logged-in user may be able to discover and use same-user resources. Unrestricted CUA mode does not contain that host compromise. Keep unrestricted host shell access behind its own tool and exec-approval policy; stronger same-user isolation would require inherited connected IPC or an OS-enforced process boundary.
|
||||
|
||||
The CUA descriptor advertises window, element, and browser targets; background and foreground delivery; image, accessibility, and browser observations; and recording. Peekaboo remains the default in this release and does not advertise recording.
|
||||
|
||||
#### Browser profiles
|
||||
|
||||
@@ -49,10 +49,18 @@ describe("cua-computer plugin registration", () => {
|
||||
it("registers the screen and dangerous computer node-host commands", () => {
|
||||
const commands: OpenClawPluginNodeHostCommand[] = [];
|
||||
const policies: OpenClawPluginNodeInvokePolicy[] = [];
|
||||
const registerTool = vi.fn();
|
||||
const registerCli = vi.fn();
|
||||
const registerNodeCliFeature = vi.fn();
|
||||
const registerService = vi.fn();
|
||||
plugin.register({
|
||||
pluginConfig: {},
|
||||
registerNodeHostCommand: (command: OpenClawPluginNodeHostCommand) => commands.push(command),
|
||||
registerNodeInvokePolicy: (policy: OpenClawPluginNodeInvokePolicy) => policies.push(policy),
|
||||
registerTool,
|
||||
registerCli,
|
||||
registerNodeCliFeature,
|
||||
registerService,
|
||||
} as unknown as OpenClawPluginApi);
|
||||
|
||||
expect(commands.map(({ command, cap, dangerous }) => ({ command, cap, dangerous }))).toEqual([
|
||||
@@ -62,6 +70,11 @@ describe("cua-computer plugin registration", () => {
|
||||
expect(policies).toHaveLength(1);
|
||||
expect(policies[0]).toMatchObject({ commands: ["computer.act"], dangerous: true });
|
||||
expect(policies[0]?.defaultPlatforms).toBeUndefined();
|
||||
expect(commands.every((command) => command.agentTool === undefined)).toBe(true);
|
||||
expect(registerTool).not.toHaveBeenCalled();
|
||||
expect(registerCli).not.toHaveBeenCalled();
|
||||
expect(registerNodeCliFeature).not.toHaveBeenCalled();
|
||||
expect(registerService).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts the retired driver path as a no-op while keeping both schemas strict", () => {
|
||||
@@ -124,7 +137,10 @@ describe("cua-computer plugin registration", () => {
|
||||
const invokeNode = vi.fn(async () => refusal);
|
||||
|
||||
await expect(
|
||||
policies[0]!.handle({ invokeNode } as unknown as OpenClawPluginNodeInvokePolicyContext),
|
||||
policies[0]!.handle({
|
||||
invokeNode,
|
||||
risk: { level: "ordinary", family: "input" },
|
||||
} as unknown as OpenClawPluginNodeInvokePolicyContext),
|
||||
).resolves.toEqual(refusal);
|
||||
expect(invokeNode).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { z } from "zod";
|
||||
import { registerCuaDriverDoctorChecks } from "./api.js";
|
||||
import { createCuaComputerProvider } from "./src/commands.js";
|
||||
import { verifyInstalledCuaDriverArtifacts } from "./src/driver-artifacts.js";
|
||||
import { createCuaComputerNodeInvokePolicy } from "./src/node-invoke-policy.js";
|
||||
|
||||
const CuaComputerConfigSchema = z.strictObject({
|
||||
// Keep the shipped daemon setting as a named no-op: strict validation accepts
|
||||
@@ -33,10 +34,6 @@ export default definePluginEntry({
|
||||
registerComputerUseProvider(api, createCuaComputerProvider());
|
||||
// Dangerous plugin command: excluded from default allowlists, and the
|
||||
// Gateway fails closed when this policy registration is missing.
|
||||
api.registerNodeInvokePolicy({
|
||||
commands: ["computer.act"],
|
||||
dangerous: true,
|
||||
handle: async (context) => await context.invokeNode(),
|
||||
});
|
||||
api.registerNodeInvokePolicy(createCuaComputerNodeInvokePolicy());
|
||||
},
|
||||
});
|
||||
|
||||
@@ -321,6 +321,19 @@ describe("cua-computer browser actions", () => {
|
||||
elementRef: observed.details.elements[0]!.elementRef,
|
||||
};
|
||||
|
||||
const callsBeforeForgedRefs = first.callTool.mock.calls.length;
|
||||
for (const forged of [
|
||||
{ ...staleAction, browserRef: "/tmp/native-browser-target" },
|
||||
{ ...staleAction, pageRef: "../native-page" },
|
||||
{ ...staleAction, observationId: "/tmp/native-observation" },
|
||||
{ ...staleAction, elementRef: "p7:0" },
|
||||
]) {
|
||||
await expect(computer.act(JSON.stringify(forged))).rejects.toThrow(
|
||||
"COMPUTER_STALE_OBSERVATION",
|
||||
);
|
||||
}
|
||||
expect(first.callTool).toHaveBeenCalledTimes(callsBeforeForgedRefs);
|
||||
|
||||
await computer.act(
|
||||
JSON.stringify({ action: "browser_navigate", browserRef, pageRef, url: "about:blank" }),
|
||||
);
|
||||
|
||||
@@ -304,6 +304,27 @@ describe("cua-computer provider", () => {
|
||||
expect(click).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a forged frame before desktop input", async () => {
|
||||
const { session, click } = driver();
|
||||
const computer = await execution(session);
|
||||
const screen = JSON.parse(await computer.snapshot('{"format":"png","maxWidth":100}')) as {
|
||||
width: number;
|
||||
};
|
||||
|
||||
await expect(
|
||||
computer.act(
|
||||
JSON.stringify({
|
||||
action: "left_click",
|
||||
displayFrameId: "cua:v1:forged",
|
||||
refWidth: screen.width,
|
||||
x: 10,
|
||||
y: 20,
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow("COMPUTER_STALE_FRAME");
|
||||
expect(click).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lazily owns one session and closes it when node-host availability stops", async () => {
|
||||
const { session, dispose } = driver();
|
||||
const createDriver = vi.fn(() => session);
|
||||
@@ -415,6 +436,51 @@ describe("cua-computer provider", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects forged window, observation, and element refs before native resolution", async () => {
|
||||
const { session, callTool } = driver();
|
||||
callTool.mockImplementation(async (name) => {
|
||||
if (name === "list_windows") {
|
||||
return cuaToolResult(CUA_DRIVER_CONTRACT_FIXTURES.listWindows);
|
||||
}
|
||||
if (name === "get_window_state") {
|
||||
return cuaToolResult(CUA_DRIVER_CONTRACT_FIXTURES.windowState, { image: true });
|
||||
}
|
||||
return cuaToolResult({});
|
||||
});
|
||||
const computer = await execution(session);
|
||||
const listed = JSON.parse(await computer.act('{"action":"list_windows"}')) as {
|
||||
details: { windows: Array<{ windowRef: string }> };
|
||||
};
|
||||
const windowRef = listed.details.windows[0]!.windowRef;
|
||||
const observed = JSON.parse(
|
||||
await computer.act(JSON.stringify({ action: "get_window_state", windowRef })),
|
||||
) as {
|
||||
observation: { observationId: string; elements: Array<{ elementRef: string }> };
|
||||
};
|
||||
const callsBeforeHostileRefs = callTool.mock.calls.length;
|
||||
|
||||
for (const input of [
|
||||
{ action: "get_window_state", windowRef: "/tmp/native-window" },
|
||||
{
|
||||
action: "left_click",
|
||||
windowRef,
|
||||
observationId: "/tmp/native-observation",
|
||||
elementRef: observed.observation.elements[0]!.elementRef,
|
||||
},
|
||||
{
|
||||
action: "left_click",
|
||||
windowRef,
|
||||
observationId: observed.observation.observationId,
|
||||
elementRef: "../native-element",
|
||||
},
|
||||
]) {
|
||||
await expect(computer.act(JSON.stringify(input))).rejects.toThrow(
|
||||
"COMPUTER_STALE_OBSERVATION",
|
||||
);
|
||||
}
|
||||
expect(callTool).toHaveBeenCalledTimes(callsBeforeHostileRefs);
|
||||
});
|
||||
|
||||
it("maps window pixels, app lifecycle, menu, zoom, and escalation tools", async () => {
|
||||
const { session, callTool, escalateScope } = driver();
|
||||
callTool.mockImplementation(async (name) => {
|
||||
@@ -490,6 +556,19 @@ describe("cua-computer provider", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects model-supplied app paths and commands before driver dispatch", async () => {
|
||||
const { session, callTool } = driver();
|
||||
const computer = await execution(session);
|
||||
|
||||
for (const app of ["/usr/bin/open", "../outside", "sh -c 'touch /tmp/owned'"]) {
|
||||
await expect(computer.act(JSON.stringify({ action: "launch_app", app }))).rejects.toThrow(
|
||||
"COMPUTER_STALE_OBSERVATION",
|
||||
);
|
||||
}
|
||||
|
||||
expect(callTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps the complete Linux window pointer and keyboard family", async () => {
|
||||
const { session, callTool } = driver();
|
||||
callTool.mockImplementation(async (name) => {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { OpenClawPluginNodeInvokePolicyContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createCuaComputerNodeInvokePolicy } from "./node-invoke-policy.js";
|
||||
|
||||
const resourceHandle = "openclaw:computer-resource:v1:123e4567-e89b-42d3-a456-426614174000";
|
||||
|
||||
describe("cua-computer node invoke policy", () => {
|
||||
const classifyRisk = (params: unknown) => {
|
||||
const classify = createCuaComputerNodeInvokePolicy().classifyRisk;
|
||||
if (!classify) {
|
||||
throw new Error("missing CUA Computer risk classifier");
|
||||
}
|
||||
return classify({ command: "computer.act", params });
|
||||
};
|
||||
|
||||
it.each([
|
||||
[{ action: "kill_app", app: "app-ref" }, "process_termination"],
|
||||
[
|
||||
{ action: "browser_navigate", browserRef: "browser", pageRef: "page", url: "about:blank" },
|
||||
"browser_navigation",
|
||||
],
|
||||
[
|
||||
{
|
||||
action: "browser_download",
|
||||
browserRef: "browser",
|
||||
pageRef: "page",
|
||||
observationId: "observation",
|
||||
elementRef: "element",
|
||||
},
|
||||
"browser_download",
|
||||
],
|
||||
[
|
||||
{
|
||||
action: "browser_set_input_files",
|
||||
browserRef: "browser",
|
||||
pageRef: "page",
|
||||
observationId: "observation",
|
||||
elementRef: "element",
|
||||
resourceHandles: [resourceHandle],
|
||||
},
|
||||
"browser_file_input",
|
||||
],
|
||||
[{ action: "start_recording" }, "recording_start"],
|
||||
[{ action: "replay_trajectory", resourceHandle }, "recording_replay"],
|
||||
[{ action: "escalate_scope", reason: "other" }, "desktop_scope_escalation"],
|
||||
])("classifies $family as high risk", (params, family) => {
|
||||
expect(classifyRisk(params)).toEqual({ level: "high", family });
|
||||
});
|
||||
|
||||
it("distinguishes ordinary observation, input, and lifecycle arguments", () => {
|
||||
expect(classifyRisk({ action: "list_windows" })).toEqual({
|
||||
level: "ordinary",
|
||||
family: "observation",
|
||||
});
|
||||
expect(classifyRisk({ action: "type", text: "hello", windowRef: "window" })).toEqual({
|
||||
level: "ordinary",
|
||||
family: "input",
|
||||
});
|
||||
expect(
|
||||
classifyRisk({
|
||||
action: "__close_execution",
|
||||
executionId: "123e4567-e89b-42d3-a456-426614174000",
|
||||
reason: "completion",
|
||||
}),
|
||||
).toEqual({ level: "ordinary", family: "execution_lifecycle" });
|
||||
});
|
||||
|
||||
it("rejects raw provider calls and native process controls before dispatch", async () => {
|
||||
const policy = createCuaComputerNodeInvokePolicy();
|
||||
for (const params of [
|
||||
{ providerTool: "click", arguments: { x: 1, y: 2 } },
|
||||
{ action: "left_click", binaryPath: "/tmp/cua-driver" },
|
||||
{ action: "left_click", socketPath: "/tmp/cua.sock" },
|
||||
{ action: "left_click", session: "native-session" },
|
||||
{ action: "left_click", driverArgs: ["--dangerously-bypass-approvals"] },
|
||||
]) {
|
||||
expect(() => policy.classifyRisk?.({ command: "computer.act", params })).toThrow(
|
||||
"COMPUTER_INVALID_REQUEST",
|
||||
);
|
||||
}
|
||||
|
||||
const invokeNode = vi.fn(async () => ({ ok: true as const }));
|
||||
await expect(
|
||||
policy.handle({ invokeNode } as unknown as OpenClawPluginNodeInvokePolicyContext),
|
||||
).resolves.toMatchObject({ ok: false, code: "COMPUTER_RISK_UNCLASSIFIED" });
|
||||
expect(invokeNode).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
parseComputerActParamsJSON,
|
||||
type ComputerActParams,
|
||||
} from "openclaw/plugin-sdk/computer-use";
|
||||
import type {
|
||||
OpenClawPluginNodeInvokePolicy,
|
||||
OpenClawPluginNodeInvokePolicyContext,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
const COMPUTER_ACT_COMMAND = "computer.act";
|
||||
|
||||
const HIGH_RISK_FAMILIES = new Map<
|
||||
ComputerActParams["action"],
|
||||
NonNullable<OpenClawPluginNodeInvokePolicyContext["risk"]>["family"]
|
||||
>([
|
||||
["kill_app", "process_termination"],
|
||||
["browser_navigate", "browser_navigation"],
|
||||
["browser_download", "browser_download"],
|
||||
["browser_set_input_files", "browser_file_input"],
|
||||
["start_recording", "recording_start"],
|
||||
["replay_trajectory", "recording_replay"],
|
||||
["escalate_scope", "desktop_scope_escalation"],
|
||||
]);
|
||||
|
||||
const OBSERVATION_ACTIONS = new Set<ComputerActParams["action"]>([
|
||||
"list_apps",
|
||||
"list_windows",
|
||||
"get_accessibility_tree",
|
||||
"get_cursor_position",
|
||||
"get_window_state",
|
||||
"get_browser_state",
|
||||
"get_recording_state",
|
||||
]);
|
||||
|
||||
function classifyCuaComputerActRisk(
|
||||
params: unknown,
|
||||
): NonNullable<OpenClawPluginNodeInvokePolicyContext["risk"]> {
|
||||
// Node-host owns the exact close envelope. This internal action never enters
|
||||
// the model schema, but it still traverses the same classified policy seam.
|
||||
if (isRecord(params) && params.action === "__close_execution") {
|
||||
return { level: "ordinary", family: "execution_lifecycle" };
|
||||
}
|
||||
const serialized = JSON.stringify(params);
|
||||
if (serialized === undefined) {
|
||||
throw new Error("computer action arguments are not serializable");
|
||||
}
|
||||
const parsed = parseComputerActParamsJSON(serialized);
|
||||
const highRiskFamily = HIGH_RISK_FAMILIES.get(parsed.action);
|
||||
if (highRiskFamily) {
|
||||
return { level: "high", family: highRiskFamily };
|
||||
}
|
||||
if (
|
||||
parsed.action === "browser_dialog" &&
|
||||
"dialogAction" in parsed &&
|
||||
parsed.dialogAction === "inspect"
|
||||
) {
|
||||
return { level: "ordinary", family: "observation" };
|
||||
}
|
||||
return {
|
||||
level: "ordinary",
|
||||
family: OBSERVATION_ACTIONS.has(parsed.action) ? "observation" : "input",
|
||||
};
|
||||
}
|
||||
|
||||
export function createCuaComputerNodeInvokePolicy(): OpenClawPluginNodeInvokePolicy {
|
||||
return {
|
||||
commands: [COMPUTER_ACT_COMMAND],
|
||||
dangerous: true,
|
||||
classifyRisk: ({ command, params }) => {
|
||||
if (command !== COMPUTER_ACT_COMMAND) {
|
||||
throw new Error("unsupported CUA Computer node command");
|
||||
}
|
||||
return classifyCuaComputerActRisk(params);
|
||||
},
|
||||
handle: async (context) => {
|
||||
if (!context.risk) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "COMPUTER_RISK_UNCLASSIFIED",
|
||||
message: "computer.act arguments were not classified before dispatch",
|
||||
};
|
||||
}
|
||||
return await context.invokeNode();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -123,6 +123,20 @@ describe("cua-computer recording actions", () => {
|
||||
await computer.act(JSON.stringify({ action: "start_recording" })),
|
||||
) as { details: { resourceHandle: string } };
|
||||
await computer.act(JSON.stringify({ action: "stop_recording" }));
|
||||
const escapedChild = path.join(nativeRecordingRoot, "escaped-child");
|
||||
await fs.symlink(outside, escapedChild, "dir");
|
||||
const callsBeforeChildEscape = active.callTool.mock.calls.length;
|
||||
await expect(
|
||||
computer.act(
|
||||
JSON.stringify({
|
||||
action: "replay_trajectory",
|
||||
resourceHandle: started.details.resourceHandle,
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow("COMPUTER_INVALID_RESOURCE");
|
||||
expect(active.callTool).toHaveBeenCalledTimes(callsBeforeChildEscape);
|
||||
await fs.rm(escapedChild);
|
||||
|
||||
await fs.rm(nativeRecordingRoot, { recursive: true });
|
||||
await fs.symlink(outside, nativeRecordingRoot, "dir");
|
||||
const callsBeforeReplay = active.callTool.mock.calls.length;
|
||||
|
||||
@@ -314,20 +314,18 @@ export async function handleV2Act(
|
||||
verifyGeneration(state, driver.generation);
|
||||
const appName = input.app!;
|
||||
const app = resolveAppRef(state, appName);
|
||||
if (appName.startsWith("cua:v2:app:") && !app) {
|
||||
if (!app) {
|
||||
throw new Error("COMPUTER_STALE_OBSERVATION: refresh list_apps and retry");
|
||||
}
|
||||
const result = await callWindowTool(
|
||||
driver,
|
||||
state,
|
||||
"launch_app",
|
||||
app
|
||||
? app.launchPath
|
||||
? { launch_path: app.launchPath }
|
||||
: app.bundleId
|
||||
? { bundle_id: app.bundleId }
|
||||
: { name: app.name }
|
||||
: { name: appName },
|
||||
app.launchPath
|
||||
? { launch_path: app.launchPath }
|
||||
: app.bundleId
|
||||
? { bundle_id: app.bundleId }
|
||||
: { name: app.name },
|
||||
signal,
|
||||
);
|
||||
const structured = projectedToolDetails(result, "launch_app");
|
||||
|
||||
@@ -45,7 +45,7 @@ describe("createComputerTool schema", () => {
|
||||
});
|
||||
|
||||
it("keeps the v2 guidance provider-neutral and free of host setup instructions", () => {
|
||||
const description = createComputerTool({
|
||||
const tool = createComputerTool({
|
||||
capabilityDescriptor: v2Descriptor([
|
||||
"screenshot",
|
||||
"left_click",
|
||||
@@ -53,7 +53,8 @@ describe("createComputerTool schema", () => {
|
||||
"get_window_state",
|
||||
"set_value",
|
||||
]),
|
||||
}).description;
|
||||
});
|
||||
const description = tool.description;
|
||||
|
||||
expect(description).toContain("Observe first with `get_window_state`");
|
||||
expect(description).toContain('`effect:"confirmed"` > `unverifiable` > `suspected_noop`');
|
||||
@@ -63,6 +64,19 @@ describe("createComputerTool schema", () => {
|
||||
/cua|peekaboo|\b(?:cli|mcp|daemon|socket|install(?:ation|ing)?)\b|verify_state|start_session|end_session|element_token|snapshot_id|window_id|delivery_mode/iu,
|
||||
);
|
||||
expect(description.length).toBeLessThan(2_400);
|
||||
const schema = JSON.stringify(tool.parameters);
|
||||
for (const nativeField of [
|
||||
"providerTool",
|
||||
"arguments",
|
||||
"binaryPath",
|
||||
"socketPath",
|
||||
"session",
|
||||
"driverArgs",
|
||||
"output_dir",
|
||||
"destinationRoot",
|
||||
]) {
|
||||
expect(schema).not.toContain(`"${nativeField}":`);
|
||||
}
|
||||
});
|
||||
|
||||
it("filters guidance to the selected node's advertised capability families", () => {
|
||||
|
||||
@@ -282,6 +282,47 @@ describe("applyPluginNodeInvokePolicy", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies exact arguments before the policy handler and transport", async () => {
|
||||
const policy = createDemoPolicy((ctx: OpenClawPluginNodeInvokePolicyContext) => {
|
||||
expect(ctx.risk).toEqual({ level: "high", family: "fixture_mutation" });
|
||||
return ctx.invokeNode();
|
||||
});
|
||||
policy.policy.classifyRisk = ({ command, params }) => {
|
||||
expect({ command, params }).toEqual({ command: DEMO_COMMAND, params: DEMO_PARAMS });
|
||||
return { level: "high", family: "fixture_mutation" };
|
||||
};
|
||||
setDangerousDemoCommandRegistry([policy]);
|
||||
const { context, invoke } = createContext();
|
||||
|
||||
await expect(invokeDemoPolicy(context)).resolves.toMatchObject({ ok: true });
|
||||
expect(invoke).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("fails closed when argument risk classification throws or returns invalid metadata", async () => {
|
||||
for (const classifyRisk of [
|
||||
() => {
|
||||
throw new Error("hostile argument text");
|
||||
},
|
||||
() => ({ level: "high" as const, family: "contains spaces" }),
|
||||
]) {
|
||||
const policy = createDemoPolicy((ctx: OpenClawPluginNodeInvokePolicyContext) =>
|
||||
ctx.invokeNode(),
|
||||
);
|
||||
policy.policy.classifyRisk = classifyRisk;
|
||||
setDangerousDemoCommandRegistry([policy]);
|
||||
const { context, invoke } = createContext();
|
||||
|
||||
await expect(invokeDemoPolicy(context)).resolves.toEqual({
|
||||
ok: false,
|
||||
code: "PLUGIN_POLICY_RISK_CLASSIFICATION_FAILED",
|
||||
message: `node.invoke ${DEMO_COMMAND} arguments could not be classified by plugin ${DEMO_PLUGIN_ID}`,
|
||||
details: { nodeCommandDispatched: false },
|
||||
});
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
resetPluginRuntimeStateForTest();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([5_000, 0])(
|
||||
"bounds plugin timeout override %i by the remaining invocation deadline",
|
||||
async (overrideTimeoutMs) => {
|
||||
|
||||
@@ -93,6 +93,20 @@ function findDangerousPluginNodeCommand(registry: PluginRegistry | null, command
|
||||
);
|
||||
}
|
||||
|
||||
function validateRiskClassification(
|
||||
value: NonNullable<OpenClawPluginNodeInvokePolicyContext["risk"]>,
|
||||
): NonNullable<OpenClawPluginNodeInvokePolicyContext["risk"]> | null {
|
||||
const family = normalizeOptionalString(value?.family);
|
||||
if (
|
||||
(value?.level !== "ordinary" && value?.level !== "high") ||
|
||||
!family ||
|
||||
!/^[a-z0-9][a-z0-9._-]{0,63}$/u.test(family)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { level: value.level, family };
|
||||
}
|
||||
|
||||
function createApprovalRuntime(params: {
|
||||
context: GatewayRequestContext;
|
||||
client: GatewayClient | null;
|
||||
@@ -252,6 +266,27 @@ export async function applyPluginNodeInvokePolicy(params: {
|
||||
return null;
|
||||
}
|
||||
|
||||
let risk: OpenClawPluginNodeInvokePolicyContext["risk"];
|
||||
if (entry.policy.classifyRisk) {
|
||||
try {
|
||||
risk =
|
||||
validateRiskClassification(
|
||||
entry.policy.classifyRisk({ command: params.command, params: params.params }),
|
||||
) ?? undefined;
|
||||
} catch {
|
||||
// Argument classifiers run before the policy handler and transport. Do
|
||||
// not expose rejected arguments or plugin exception text to the caller.
|
||||
}
|
||||
if (!risk) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "PLUGIN_POLICY_RISK_CLASSIFICATION_FAILED",
|
||||
message: `node.invoke ${params.command} arguments could not be classified by plugin ${entry.pluginId}`,
|
||||
details: { nodeCommandDispatched: false },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let nodeCommandDispatched = false;
|
||||
const invokeNode: OpenClawPluginNodeInvokePolicyContext["invokeNode"] = async (
|
||||
override = {},
|
||||
@@ -406,6 +441,7 @@ export async function applyPluginNodeInvokePolicy(params: {
|
||||
scopes: parseScopes(params.client),
|
||||
}
|
||||
: null,
|
||||
...(risk ? { risk } : {}),
|
||||
approvals: createApprovalRuntime({
|
||||
context: params.context,
|
||||
client: params.client,
|
||||
|
||||
@@ -173,6 +173,11 @@ describe("Computer Use wire contract", () => {
|
||||
{ action: "start_recording", helperPath: "/tmp/ffmpeg" },
|
||||
{ action: "replay_trajectory", dir: "../outside" },
|
||||
{ action: "replay_trajectory", ffmpegPath: "/tmp/ffmpeg" },
|
||||
{ action: "get_window_state", windowRef: "window-1", session: "native-session" },
|
||||
{ action: "left_click", binaryPath: "/tmp/cua-driver" },
|
||||
{ action: "left_click", socketPath: "/tmp/cua.sock" },
|
||||
{ action: "left_click", driverArgs: ["--dangerously-bypass-approvals"] },
|
||||
{ providerTool: "click", arguments: { x: 1, y: 2 } },
|
||||
{
|
||||
action: "browser_set_input_files",
|
||||
browserRef: "browser-1",
|
||||
@@ -312,7 +317,7 @@ describe("Computer Use provider registration", () => {
|
||||
expect(stopWatching).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects cross-execution control and closes only the exact host execution", async () => {
|
||||
it("refuses a second mutating execution and closes only the exact host execution", async () => {
|
||||
const firstId = "123e4567-e89b-42d3-a456-426614174000";
|
||||
const secondId = "223e4567-e89b-42d3-a456-426614174000";
|
||||
const commands: OpenClawPluginNodeHostCommand[] = [];
|
||||
|
||||
@@ -194,6 +194,11 @@ export type OpenClawPluginNodeInvokePolicyContext = {
|
||||
connId?: string;
|
||||
scopes?: string[];
|
||||
} | null;
|
||||
risk?: {
|
||||
level: "ordinary" | "high";
|
||||
/** Stable, content-free family name; never include user or action arguments. */
|
||||
family: string;
|
||||
};
|
||||
approvals?: OpenClawPluginNodeInvokePolicyApprovalRuntime;
|
||||
invokeNode: (input?: {
|
||||
params?: unknown;
|
||||
@@ -233,6 +238,13 @@ export type OpenClawPluginNodeInvokePolicy = {
|
||||
* when an iOS node reports BACKGROUND_UNAVAILABLE.
|
||||
*/
|
||||
foregroundRestrictedOnIos?: boolean;
|
||||
/**
|
||||
* Classify exact command arguments before the policy handler or node transport runs.
|
||||
* Throwing rejects the invocation before dispatch.
|
||||
*/
|
||||
classifyRisk?: (
|
||||
ctx: Pick<OpenClawPluginNodeInvokePolicyContext, "command" | "params">,
|
||||
) => NonNullable<OpenClawPluginNodeInvokePolicyContext["risk"]>;
|
||||
handle: (
|
||||
ctx: OpenClawPluginNodeInvokePolicyContext,
|
||||
) => Promise<OpenClawPluginNodeInvokePolicyResult> | OpenClawPluginNodeInvokePolicyResult;
|
||||
|
||||
Reference in New Issue
Block a user