fix(acp): block sandboxed slash spawns

This commit is contained in:
Peter Steinberger
2026-03-08 00:22:57 +00:00
parent bda035768f
commit 61000b8e4d
5 changed files with 56 additions and 15 deletions

View File

@@ -158,6 +158,7 @@ Docs: https://docs.openclaw.ai
- Discord/thread session lifecycle: reset thread-scoped sessions when a thread is archived so reopening a thread starts fresh without deleting transcript history. Thanks @thewilloftheshadow.
- Discord/presence defaults: send an online presence update on ready when no custom presence is configured so bots no longer appear offline by default. Thanks @thewilloftheshadow.
- Discord/typing cleanup: stop typing indicators after silent/NO_REPLY runs by marking the run complete before dispatch idle cleanup. Thanks @thewilloftheshadow.
- ACP/sandbox spawn parity: block `/acp spawn` from sandboxed requester sessions with the same host-runtime guard already enforced for `sessions_spawn({ runtime: "acp" })`, preserving non-sandbox ACP flows while closing the command-path policy gap. Thanks @patte.
- Discord/config SecretRef typing: align Discord account token config typing with SecretInput so SecretRef tokens typecheck. (#32490) Thanks @scoootscooob.
- Discord/voice messages: request upload slots with JSON fetch calls so voice message uploads no longer fail with content-type errors. Thanks @thewilloftheshadow.
- Discord/voice decoder fallback: drop the native Opus dependency and use opusscript for voice decoding to avoid native-opus installs. Thanks @thewilloftheshadow.

View File

@@ -252,7 +252,7 @@ ACP sessions currently run on the host runtime, not inside the OpenClaw sandbox.
Current limitations:
- If the requester session is sandboxed, ACP spawns are blocked.
- If the requester session is sandboxed, ACP spawns are blocked for both `sessions_spawn({ runtime: "acp" })` and `/acp spawn`.
- Error: `Sandboxed sessions cannot spawn ACP sessions because runtime="acp" runs on the host. Use runtime="subagent" from sandboxed sessions.`
- `sessions_spawn` with `runtime: "acp"` does not support `sandbox: "require"`.
- Error: `sessions_spawn sandbox="require" is unsupported for runtime="acp" because ACP sessions run outside the sandbox. Use runtime="subagent" or sandbox="inherit".`

View File

@@ -81,6 +81,27 @@ export const ACP_SPAWN_ACCEPTED_NOTE =
export const ACP_SPAWN_SESSION_ACCEPTED_NOTE =
"thread-bound ACP session stays active after this task; continue in-thread for follow-ups.";
export function resolveAcpSpawnRuntimePolicyError(params: {
cfg: OpenClawConfig;
requesterSessionKey?: string;
requesterSandboxed?: boolean;
sandbox?: SpawnAcpSandboxMode;
}): string | undefined {
const sandboxMode = params.sandbox === "require" ? "require" : "inherit";
const requesterRuntime = resolveSandboxRuntimeStatus({
cfg: params.cfg,
sessionKey: params.requesterSessionKey,
});
const requesterSandboxed = params.requesterSandboxed === true || requesterRuntime.sandboxed;
if (requesterSandboxed) {
return 'Sandboxed sessions cannot spawn ACP sessions because runtime="acp" runs on the host. Use runtime="subagent" from sandboxed sessions.';
}
if (sandboxMode === "require") {
return 'sessions_spawn sandbox="require" is unsupported for runtime="acp" because ACP sessions run outside the sandbox. Use runtime="subagent" or sandbox="inherit".';
}
return undefined;
}
type PreparedAcpThreadBinding = {
channel: string;
accountId: string;
@@ -242,7 +263,6 @@ export async function spawnAcpDirect(
error: "ACP is disabled by policy (`acp.enabled=false`).",
};
}
const sandboxMode = params.sandbox === "require" ? "require" : "inherit";
const streamToParentRequested = params.streamTo === "parent";
const parentSessionKey = ctx.agentSessionKey?.trim();
if (streamToParentRequested && !parentSessionKey) {
@@ -251,23 +271,16 @@ export async function spawnAcpDirect(
error: 'sessions_spawn streamTo="parent" requires an active requester session context.',
};
}
const requesterRuntime = resolveSandboxRuntimeStatus({
const runtimePolicyError = resolveAcpSpawnRuntimePolicyError({
cfg,
sessionKey: ctx.agentSessionKey,
requesterSessionKey: ctx.agentSessionKey,
requesterSandboxed: ctx.sandboxed,
sandbox: params.sandbox,
});
const requesterSandboxed = ctx.sandboxed === true || requesterRuntime.sandboxed;
if (requesterSandboxed) {
if (runtimePolicyError) {
return {
status: "forbidden",
error:
'Sandboxed sessions cannot spawn ACP sessions because runtime="acp" runs on the host. Use runtime="subagent" from sandboxed sessions.',
};
}
if (sandboxMode === "require") {
return {
status: "forbidden",
error:
'sessions_spawn sandbox="require" is unsupported for runtime="acp" because ACP sessions run outside the sandbox. Use runtime="subagent" or sandbox="inherit".',
error: runtimePolicyError,
};
}

View File

@@ -592,6 +592,25 @@ describe("/acp command", () => {
);
});
it("forbids /acp spawn from sandboxed requester sessions", async () => {
const cfg = {
...baseCfg,
agents: {
defaults: {
sandbox: { mode: "all" },
},
},
} satisfies OpenClawConfig;
const result = await runDiscordAcpCommand("/acp spawn codex", cfg);
expect(result?.reply?.text).toContain("Sandboxed sessions cannot spawn ACP sessions");
expect(hoisted.requireAcpRuntimeBackendMock).not.toHaveBeenCalled();
expect(hoisted.ensureSessionMock).not.toHaveBeenCalled();
expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled();
expect(hoisted.callGatewayMock).not.toHaveBeenCalled();
});
it("cancels the ACP session bound to the current thread", async () => {
mockBoundThreadSession({ state: "running" });
const result = await runThreadAcpCommand("/acp cancel", baseCfg);

View File

@@ -15,6 +15,7 @@ import {
resolveAcpSessionCwd,
resolveAcpThreadSessionDetailLines,
} from "../../../acp/runtime/session-identifiers.js";
import { resolveAcpSpawnRuntimePolicyError } from "../../../agents/acp-spawn.js";
import {
resolveThreadBindingIntroText,
resolveThreadBindingThreadName,
@@ -253,6 +254,13 @@ export async function handleAcpSpawnAction(
}
const spawn = parsed.value;
const runtimePolicyError = resolveAcpSpawnRuntimePolicyError({
cfg: params.cfg,
requesterSessionKey: params.sessionKey,
});
if (runtimePolicyError) {
return stopWithText(`⚠️ ${runtimePolicyError}`);
}
const agentPolicyError = resolveAcpAgentPolicyError(params.cfg, spawn.agentId);
if (agentPolicyError) {
return stopWithText(