Skip to content

Commit 2717e7c

Browse files
committed
feat(sparsekernel): honor plugin subprocess boundaries
1 parent e2a2ba3 commit 2717e7c

11 files changed

Lines changed: 147 additions & 36 deletions

docs/architecture/local-agent-kernel.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ Sensitive operations should check capabilities before they mutate state or alloc
123123
- sandbox allocation;
124124
- tool invocation.
125125

126-
Capability grants, revokes, and denied checks are audited. Embedded agent runs now materialize the active step as a task lease and transcript events, then wrap the effective tool set with the local ToolBroker outside tests unless explicitly disabled with `OPENCLAW_RUNTIME_TOOL_BROKER=off`. Set `OPENCLAW_RUNTIME_TOOL_BROKER=daemon` to use the `sparsekerneld` run-ledger and ToolBroker path; daemon setup failures fall back to the local runtime broker. Set `OPENCLAW_RUNTIME_TOOL_CAPABILITY_MODE=strict` to stop auto-granting sensitive tools such as `exec`, `read`, `write`, and `browser`; those tools then fail closed unless a capability already exists or `OPENCLAW_RUNTIME_TOOL_ALLOW_SENSITIVE=1` is set for compatibility. Set `OPENCLAW_RUNTIME_TOOL_SANDBOX_EXEC=1` to route `exec`, `bash`, and `shell` shaped tools through the sandbox broker command runner instead of the ambient in-process tool implementation when the active agent has sandbox allocation capability. Native in-process plugins remain trusted by default. When `OPENCLAW_RUNTIME_PLUGIN_PROCESS_BOUNDARY=subprocess` or `OPENCLAW_RUNTIME_PLUGIN_PROCESS=strict` is set, plugin tools with subprocess metadata execute through a JSON stdin/stdout worker via the sandbox broker, emit subprocess and sandbox audit events, and fail closed if the worker would use `local/no_isolation` without an explicit trusted-worker opt-out; plugin tools without subprocess metadata fail closed.
126+
Capability grants, revokes, and denied checks are audited. Embedded agent runs now materialize the active step as a task lease and transcript events, then wrap the effective tool set with the local ToolBroker outside tests unless explicitly disabled with `OPENCLAW_RUNTIME_TOOL_BROKER=off`. Set `OPENCLAW_RUNTIME_TOOL_BROKER=daemon` to use the `sparsekerneld` run-ledger and ToolBroker path; daemon setup failures fall back to the local runtime broker. Set `OPENCLAW_RUNTIME_TOOL_CAPABILITY_MODE=strict` to stop auto-granting sensitive tools such as `exec`, `read`, `write`, and `browser`; those tools then fail closed unless a capability already exists or `OPENCLAW_RUNTIME_TOOL_ALLOW_SENSITIVE=1` is set for compatibility. Set `OPENCLAW_RUNTIME_TOOL_SANDBOX_EXEC=1` to route `exec`, `bash`, and `shell` shaped tools through the sandbox broker command runner instead of the ambient in-process tool implementation when the active agent has sandbox allocation capability. Native in-process plugins remain trusted by default, but individual plugin tools can declare `processBoundary: "subprocess_required"` and operators can set `OPENCLAW_RUNTIME_PLUGIN_TRUST_DEFAULT=untrusted` to require subprocess workers for non-bundled plugin tools. When a subprocess boundary is required, plugin tools with subprocess metadata execute through a JSON stdin/stdout worker via the sandbox broker, emit subprocess and sandbox audit events, and fail closed if the worker would use `local/no_isolation` without an explicit trusted-worker opt-out; plugin tools without subprocess metadata fail closed.
127127

128128
The SparseKernel daemon now exposes the v0 run and ToolBroker lifecycle over local JSON: session upsert/list, transcript append/list, task enqueue/claim-by-id/claim-next/heartbeat/complete/fail, tool-call create/start/complete/fail/list, browser acquire/release, sandbox allocate/release, and artifact access. Tool-call create checks `tool` / `<tool-name>` / `invoke`; completion records small structured output plus `artifact_ids`; and every transition writes audit records. See [Tool Broker](/architecture/tool-broker).
129129

docs/architecture/security-boundaries.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ SparseKernel security claims must be precise.
2626
- Persisted lease metadata records the selected backend and policy intent; it is audit/accounting state, not isolation by itself.
2727
- Untrusted plugins must not get ambient host authority.
2828
- The subprocess-required plugin mode runs explicit JSON workers through the sandbox broker. It auto-selects an available isolated backend when possible, but it still only becomes suitable for untrusted code when the selected backend is a real isolation backend; `local/no_isolation` is blocked by default for plugin subprocess workers and should only be enabled for trusted local workers.
29+
- Plugin tool `processBoundary: "subprocess_required"` and `OPENCLAW_RUNTIME_PLUGIN_TRUST_DEFAULT=untrusted` are fail-closed controls for brokered tool execution; they do not make existing in-process plugin initialization safe.
2930
- Secrets should be referenced, not stored as plaintext in SQLite.
3031

3132
Capabilities are the v0 policy primitive. They are intentionally simple: subject, resource, action, optional constraints, optional expiry. Denied sensitive checks are audit-logged.

docs/architecture/tool-broker.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,4 @@ For exec-shaped tools, set `OPENCLAW_RUNTIME_TOOL_SANDBOX_EXEC=1` to route `exec
4242

4343
Sandbox allocations persist the selected backend and trust-zone policy snapshot in resource-lease metadata. A restarted broker object can recover whether an allocation was local/no-isolation, bwrap, minijail, or Docker without relying on an in-memory map. Docker command execution is explicit: callers must provide `dockerImage` or `OPENCLAW_SPARSEKERNEL_DOCKER_IMAGE`, the wrapper uses `--pull never`, drops Linux capabilities, sets `no-new-privileges`, applies read-only root/tmpfs defaults, maps trust-zone memory/process limits to Docker flags, and keeps container networking disabled unless a policy proxy is configured. `OPENCLAW_RUNTIME_SANDBOX_REQUIRE_PROXY=1` makes network-allowing sandbox allocations fail closed unless the trust zone has a valid loopback proxy reference and the selected backend can carry that proxy path; Docker workers receive `HTTP_PROXY`/`HTTPS_PROXY`/`ALL_PROXY` pointing at `host.docker.internal`. `OPENCLAW_RUNTIME_SANDBOX_REQUIRE_HARD_EGRESS=1` is stricter: network-allowing sandbox allocations fail closed because current v0 backends do not provide bypass-proof host egress enforcement.
4444

45-
V0 does not move native plugins out of process by default. It establishes the ledger and API contract so OpenClaw adapters can route invocation through SparseKernel now, then move untrusted or community plugins behind stronger process or sandbox boundaries later. Set `OPENCLAW_RUNTIME_PLUGIN_PROCESS_BOUNDARY=subprocess` or `OPENCLAW_RUNTIME_PLUGIN_PROCESS=strict` to require plugin tools to provide subprocess metadata. Opt-in workers receive a JSON request on stdin with `protocol`, `pluginId`, `toolName`, `toolCallId`, `params`, and broker context, and must return an `AgentToolResult` JSON object on stdout. Plugin subprocess workers now execute through the sandbox broker as `plugin_untrusted` leases by default, so allocation capability, command audit events, usage records, and release accounting apply to plugin workers too. When no explicit backend is configured, the broker auto-selects the first available isolated plugin backend from `bwrap`, `minijail`, then Docker when a plugin Docker image is configured. Operators can override the order with `OPENCLAW_RUNTIME_PLUGIN_SANDBOX_BACKENDS`, force one backend with `OPENCLAW_RUNTIME_PLUGIN_SANDBOX_BACKEND`, or provide Docker with `OPENCLAW_RUNTIME_PLUGIN_DOCKER_IMAGE`. The broker fails closed when only `local/no_isolation` remains unless the plugin marks the subprocess sandbox `requireIsolated: false` or `OPENCLAW_RUNTIME_PLUGIN_ALLOW_NO_ISOLATION=1` is set for trusted local workers. Plugin tools without subprocess metadata fail closed with `plugin_tool.subprocess_required` audit records.
45+
V0 does not move every native plugin out of process by default. It establishes the ledger and API contract so OpenClaw adapters can route invocation through SparseKernel now, then move untrusted or community plugins behind stronger process or sandbox boundaries incrementally. A plugin tool can now register `processBoundary: "subprocess_required"` plus subprocess worker metadata to require an out-of-process boundary for that tool. Set `OPENCLAW_RUNTIME_PLUGIN_PROCESS_BOUNDARY=subprocess` or `OPENCLAW_RUNTIME_PLUGIN_PROCESS=strict` to require all plugin tools to provide subprocess metadata. Set `OPENCLAW_RUNTIME_PLUGIN_TRUST_DEFAULT=untrusted` to require subprocess metadata for non-bundled plugin tools unless the tool explicitly opts into `processBoundary: "in_process"` for trusted local code. Opt-in workers receive a JSON request on stdin with `protocol`, `pluginId`, `toolName`, `toolCallId`, `params`, and broker context, and must return an `AgentToolResult` JSON object on stdout. Plugin subprocess workers now execute through the sandbox broker as `plugin_untrusted` leases by default, so allocation capability, command audit events, usage records, and release accounting apply to plugin workers too. When no explicit backend is configured, the broker auto-selects the first available isolated plugin backend from `bwrap`, `minijail`, then Docker when a plugin Docker image is configured. Operators can override the order with `OPENCLAW_RUNTIME_PLUGIN_SANDBOX_BACKENDS`, force one backend with `OPENCLAW_RUNTIME_PLUGIN_SANDBOX_BACKEND`, or provide Docker with `OPENCLAW_RUNTIME_PLUGIN_DOCKER_IMAGE`. The broker fails closed when only `local/no_isolation` remains unless the plugin marks the subprocess sandbox `requireIsolated: false` or `OPENCLAW_RUNTIME_PLUGIN_ALLOW_NO_ISOLATION=1` is set for trusted local workers. Plugin tools without subprocess metadata fail closed with `plugin_tool.subprocess_required` audit records.

src/local-kernel/tool-broker-runtime.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
import {
1818
CapabilityToolBroker,
1919
isSandboxCommandToolName,
20-
requiresPluginSubprocess,
20+
pluginToolRequiresSubprocess,
2121
resolvePluginSandboxConfig,
2222
resolvePluginSubprocessPlan,
2323
type PluginSubprocessPlan,
@@ -304,16 +304,13 @@ function wrapDaemonPluginSubprocessTools(
304304
capabilityExpiresAt: string,
305305
): AnyAgentTool[] {
306306
const env = input.env ?? process.env;
307-
const strictPluginBoundary =
308-
requiresPluginSubprocess(env.OPENCLAW_RUNTIME_PLUGIN_PROCESS_BOUNDARY) ||
309-
requiresPluginSubprocess(env.OPENCLAW_RUNTIME_PLUGIN_PROCESS);
310307
return input.tools.map((tool) => {
311308
const pluginMeta = getPluginToolMeta(tool);
312309
if (!pluginMeta) {
313310
return tool;
314311
}
315312
const plan = resolvePluginSubprocessPlan(pluginMeta);
316-
if (!plan && !strictPluginBoundary) {
313+
if (!plan && !pluginToolRequiresSubprocess(pluginMeta, env)) {
317314
return tool;
318315
}
319316
return {

src/local-kernel/tool-broker.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,74 @@ describe("CapabilityToolBroker", () => {
426426
}
427427
});
428428

429+
it("fails closed when plugin tool metadata requires a subprocess boundary", async () => {
430+
const db = new LocalKernelDatabase({ dbPath: ":memory:" });
431+
try {
432+
db.ensureAgent({ id: "main" });
433+
db.grantCapability({
434+
subjectType: "agent",
435+
subjectId: "main",
436+
resourceType: "tool",
437+
resourceId: "plugin_tool",
438+
action: "invoke",
439+
});
440+
const rawTool = makeTool("plugin_tool");
441+
setPluginToolMeta(rawTool, {
442+
pluginId: "community-plugin",
443+
optional: false,
444+
processBoundary: "subprocess_required",
445+
});
446+
const broker = new CapabilityToolBroker(db);
447+
const tool = broker.wrapTool(rawTool, {
448+
subject: { subjectType: "agent", subjectId: "main" },
449+
agentId: "main",
450+
});
451+
await expect(tool.execute("call-plugin-boundary", {})).rejects.toThrow(
452+
/out-of-process execution/,
453+
);
454+
expect(db.listAudit({ limit: 20 }).map((entry) => entry.action)).toEqual(
455+
expect.arrayContaining(["plugin_tool.subprocess_required", "tool_call.failed"]),
456+
);
457+
} finally {
458+
db.close();
459+
}
460+
});
461+
462+
it("can require subprocess boundaries for non-bundled plugin tools", async () => {
463+
const db = new LocalKernelDatabase({ dbPath: ":memory:" });
464+
try {
465+
db.ensureAgent({ id: "main" });
466+
db.grantCapability({
467+
subjectType: "agent",
468+
subjectId: "main",
469+
resourceType: "tool",
470+
resourceId: "plugin_tool",
471+
action: "invoke",
472+
});
473+
const rawTool = makeTool("plugin_tool");
474+
setPluginToolMeta(rawTool, {
475+
pluginId: "workspace-plugin",
476+
optional: false,
477+
origin: "workspace",
478+
});
479+
const broker = new CapabilityToolBroker(db, {
480+
env: { OPENCLAW_RUNTIME_PLUGIN_TRUST_DEFAULT: "untrusted" } as NodeJS.ProcessEnv,
481+
});
482+
const tool = broker.wrapTool(rawTool, {
483+
subject: { subjectType: "agent", subjectId: "main" },
484+
agentId: "main",
485+
});
486+
await expect(tool.execute("call-untrusted-plugin", {})).rejects.toThrow(
487+
/out-of-process execution/,
488+
);
489+
expect(db.listAudit({ limit: 20 }).map((entry) => entry.action)).toEqual(
490+
expect.arrayContaining(["plugin_tool.subprocess_required", "tool_call.failed"]),
491+
);
492+
} finally {
493+
db.close();
494+
}
495+
});
496+
429497
it("auto-selects an available isolated backend for plugin subprocess workers", () => {
430498
expect(
431499
resolvePluginSandboxConfig({

src/local-kernel/tool-broker.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,36 @@ export function requiresPluginSubprocess(raw: string | undefined): boolean {
118118
);
119119
}
120120

121+
function requiresNonBundledPluginSubprocess(raw: string | undefined): boolean {
122+
const normalized = raw?.trim().toLowerCase();
123+
return normalized === "untrusted" || normalized === "non-bundled" || normalized === "non_bundled";
124+
}
125+
126+
export function pluginToolRequiresSubprocess(
127+
meta: PluginToolMeta | undefined,
128+
env: NodeJS.ProcessEnv,
129+
): boolean {
130+
if (!meta) {
131+
return false;
132+
}
133+
if (
134+
requiresPluginSubprocess(env.OPENCLAW_RUNTIME_PLUGIN_PROCESS_BOUNDARY) ||
135+
requiresPluginSubprocess(env.OPENCLAW_RUNTIME_PLUGIN_PROCESS)
136+
) {
137+
return true;
138+
}
139+
if (meta.processBoundary === "subprocess_required") {
140+
return true;
141+
}
142+
if (meta.processBoundary === "in_process") {
143+
return false;
144+
}
145+
return (
146+
requiresNonBundledPluginSubprocess(env.OPENCLAW_RUNTIME_PLUGIN_TRUST_DEFAULT) &&
147+
meta.origin !== "bundled"
148+
);
149+
}
150+
121151
export type PluginSubprocessPlan = NonNullable<PluginToolMeta["subprocess"]>;
122152

123153
export type PluginSandboxConfig = {
@@ -345,12 +375,7 @@ export class CapabilityToolBroker {
345375
}
346376
const env = this.options.env ?? process.env;
347377
const pluginSubprocessPlan = resolvePluginSubprocessPlan(pluginMeta);
348-
if (
349-
pluginMeta &&
350-
(requiresPluginSubprocess(env.OPENCLAW_RUNTIME_PLUGIN_PROCESS_BOUNDARY) ||
351-
requiresPluginSubprocess(env.OPENCLAW_RUNTIME_PLUGIN_PROCESS)) &&
352-
!pluginSubprocessPlan
353-
) {
378+
if (pluginMeta && pluginToolRequiresSubprocess(pluginMeta, env) && !pluginSubprocessPlan) {
354379
const err = new Error(
355380
`Plugin tool ${tool.name} from ${pluginMeta.pluginId} requires out-of-process execution, but no subprocess worker is configured.`,
356381
);

src/plugins/bundled-capability-runtime.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,7 @@ export function loadBundledCapabilityRuntimeRegistry(params: {
485485
factory: () => tool,
486486
names: [tool.name],
487487
optional: false,
488+
origin: record.origin,
488489
source: record.source,
489490
rootDir: record.rootDir,
490491
})),

src/plugins/registry-types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import type {
2929
OpenClawPluginCliCommandDescriptor,
3030
OpenClawPluginCliRegistrar,
3131
OpenClawPluginCommandDefinition,
32+
OpenClawPluginToolOptions,
3233
OpenClawPluginGatewayRuntimeScopeSurface,
3334
OpenClawGatewayDiscoveryService,
3435
OpenClawPluginHttpRouteAuth,
@@ -59,6 +60,9 @@ export type PluginToolRegistration = {
5960
factory: OpenClawPluginToolFactory;
6061
names: string[];
6162
optional: boolean;
63+
origin: PluginOrigin;
64+
processBoundary?: OpenClawPluginToolOptions["processBoundary"];
65+
subprocess?: OpenClawPluginToolOptions["subprocess"];
6266
source: string;
6367
rootDir?: string;
6468
};

src/plugins/registry.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ import type {
117117
OpenClawPluginService,
118118
OpenClawPluginToolContext,
119119
OpenClawPluginToolFactory,
120+
OpenClawPluginToolOptions,
120121
PluginHookHandlerMap,
121122
PluginHookName,
122123
PluginHookRegistration as TypedPluginHookRegistration,
@@ -387,7 +388,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
387388
const registerTool = (
388389
record: PluginRecord,
389390
tool: AnyAgentTool | OpenClawPluginToolFactory,
390-
opts?: { name?: string; names?: string[]; optional?: boolean },
391+
opts?: OpenClawPluginToolOptions,
391392
) => {
392393
if (pluginsWithChannelRegistrationConflict.has(record.id)) {
393394
return;
@@ -411,6 +412,9 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
411412
factory,
412413
names: normalized,
413414
optional,
415+
origin: record.origin,
416+
...(opts?.processBoundary ? { processBoundary: opts.processBoundary } : {}),
417+
...(opts?.subprocess ? { subprocess: opts.subprocess } : {}),
414418
source: record.source,
415419
rootDir: record.rootDir,
416420
});

src/plugins/tool-types.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,35 @@ export type OpenClawPluginToolFactory = (
3838
ctx: OpenClawPluginToolContext,
3939
) => AnyAgentTool | AnyAgentTool[] | null | undefined;
4040

41+
export type OpenClawPluginToolSubprocessOptions = {
42+
command: string;
43+
args?: string[];
44+
cwd?: string;
45+
timeoutMs?: number;
46+
sandbox?: {
47+
trustZoneId?: string;
48+
backend?:
49+
| "local/no_isolation"
50+
| "docker"
51+
| "bwrap"
52+
| "minijail"
53+
| "ssh"
54+
| "openshell"
55+
| "vm"
56+
| "other";
57+
dockerImage?: string;
58+
requireIsolated?: boolean;
59+
maxRuntimeMs?: number;
60+
maxBytesOut?: number;
61+
};
62+
};
63+
4164
export type OpenClawPluginToolOptions = {
4265
name?: string;
4366
names?: string[];
4467
optional?: boolean;
68+
processBoundary?: "in_process" | "subprocess_required";
69+
subprocess?: OpenClawPluginToolSubprocessOptions;
4570
};
4671

4772
export type OpenClawPluginHookOptions = {

0 commit comments

Comments
 (0)