fix(sandbox): relabel managed workspace mounts for SELinux (#58025)

This commit is contained in:
Neerav Makwana 2026-03-31 00:30:34 -04:00 committed by GitHub
parent e89bd883d8
commit 7516b423eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 133 additions and 24 deletions

View File

@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { collectDockerFlagValues, findDockerArgsCall } from "./test-args.js";
import { SANDBOX_MOUNT_FORMAT_VERSION } from "./workspace-mounts.js";
import type { SandboxConfig } from "./types.js";
let BROWSER_BRIDGES: Map<string, unknown>;
@ -168,7 +169,7 @@ describe("ensureSandboxBrowser create args", () => {
entry.startsWith("OPENCLAW_BROWSER_NOVNC_PASSWORD="),
);
expect(passwordEntry).toMatch(/^OPENCLAW_BROWSER_NOVNC_PASSWORD=[A-Za-z0-9]{8}$/);
expect(result?.noVncUrl).toMatch(/^http:\/\/127\.0\.0\.1:19000\/sandbox\/novnc\?token=/);
expect(result?.noVncUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/sandbox\/novnc\?token=/);
expect(result?.noVncUrl).not.toContain("password=");
});
@ -202,7 +203,7 @@ describe("ensureSandboxBrowser create args", () => {
const createArgs = findDockerArgsCall(dockerMocks.execDocker.mock.calls, "create");
expect(createArgs).toBeDefined();
expect(createArgs).toContain("/tmp/workspace:/workspace:ro");
expect(createArgs).toContain("/tmp/workspace:/workspace:ro,z");
});
it("keeps the main workspace writable when workspaceAccess is rw", async () => {
@ -219,7 +220,20 @@ describe("ensureSandboxBrowser create args", () => {
const createArgs = findDockerArgsCall(dockerMocks.execDocker.mock.calls, "create");
expect(createArgs).toBeDefined();
expect(createArgs).toContain("/tmp/workspace:/workspace");
expect(createArgs).not.toContain("/tmp/workspace:/workspace:ro");
expect(createArgs).toContain("/tmp/workspace:/workspace:z");
expect(createArgs).not.toContain("/tmp/workspace:/workspace:ro,z");
});
it("stamps the mount format version label on browser containers", async () => {
await ensureSandboxBrowser({
scopeKey: "session:test",
workspaceDir: "/tmp/workspace",
agentWorkspaceDir: "/tmp/workspace",
cfg: buildConfig(false),
});
const createArgs = findDockerArgsCall(dockerMocks.execDocker.mock.calls, "create");
const labels = collectDockerFlagValues(createArgs ?? [], "--label");
expect(labels).toContain(`openclaw.mountFormatVersion=${SANDBOX_MOUNT_FORMAT_VERSION}`);
});
});

View File

@ -35,7 +35,7 @@ import { resolveSandboxAgentId, slugifySessionKey } from "./shared.js";
import { isToolAllowed } from "./tool-policy.js";
import type { SandboxBrowserContext, SandboxConfig } from "./types.js";
import { validateNetworkMode } from "./validate-sandbox-security.js";
import { appendWorkspaceMountArgs } from "./workspace-mounts.js";
import { appendWorkspaceMountArgs, SANDBOX_MOUNT_FORMAT_VERSION } from "./workspace-mounts.js";
const HOT_BROWSER_WINDOW_MS = 5 * 60 * 1000;
const CDP_SOURCE_RANGE_ENV_KEY = "OPENCLAW_BROWSER_CDP_SOURCE_RANGE";
@ -167,6 +167,7 @@ export async function ensureSandboxBrowser(params: {
workspaceAccess: params.cfg.workspaceAccess,
workspaceDir: params.workspaceDir,
agentWorkspaceDir: params.agentWorkspaceDir,
mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION,
});
const now = Date.now();
@ -270,6 +271,7 @@ export async function ensureSandboxBrowser(params: {
args.push(browserImage);
await execDocker(args);
await execDocker(["start", containerName]);
} else if (!running) {
} else if (!running) {
await execDocker(["start", containerName]);
}
@ -333,8 +335,8 @@ export async function ensureSandboxBrowser(params: {
const onEnsureAttachTarget = params.cfg.browser.autoStart
? async () => {
const state = await dockerContainerState(containerName);
if (state.exists && !state.running) {
const currentState = await dockerContainerState(containerName);
if (currentState.exists && !currentState.running) {
await execDocker(["start", containerName]);
}
const ok = await waitForSandboxCdp({

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { computeSandboxBrowserConfigHash, computeSandboxConfigHash } from "./config-hash.js";
import { SANDBOX_MOUNT_FORMAT_VERSION } from "./workspace-mounts.js";
import type { SandboxDockerConfig } from "./types.js";
function createDockerConfig(overrides?: Partial<SandboxDockerConfig>): SandboxDockerConfig {
@ -59,6 +60,7 @@ describe("computeSandboxConfigHash", () => {
workspaceAccess: "rw" as const,
workspaceDir: "/tmp/workspace",
agentWorkspaceDir: "/tmp/workspace",
mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION,
};
const left = computeSandboxConfigHash({
...shared,
@ -88,6 +90,7 @@ describe("computeSandboxConfigHash", () => {
workspaceAccess: "rw" as const,
workspaceDir: "/tmp/workspace",
agentWorkspaceDir: "/tmp/workspace",
mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION,
};
const left = computeSandboxConfigHash({
...shared,
@ -120,6 +123,7 @@ describe("computeSandboxBrowserConfigHash", () => {
workspaceAccess: "rw" as const,
workspaceDir: "/tmp/workspace",
agentWorkspaceDir: "/tmp/workspace",
mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION,
};
const left = computeSandboxBrowserConfigHash({
...shared,
@ -150,6 +154,7 @@ describe("computeSandboxBrowserConfigHash", () => {
workspaceAccess: "rw" as const,
workspaceDir: "/tmp/workspace",
agentWorkspaceDir: "/tmp/workspace",
mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION,
};
const left = computeSandboxBrowserConfigHash({
...shared,
@ -176,6 +181,7 @@ describe("computeSandboxBrowserConfigHash", () => {
workspaceAccess: "rw" as const,
workspaceDir: "/tmp/workspace",
agentWorkspaceDir: "/tmp/workspace",
mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION,
};
const left = computeSandboxBrowserConfigHash({
...shared,
@ -187,4 +193,31 @@ describe("computeSandboxBrowserConfigHash", () => {
});
expect(left).not.toBe(right);
});
it("changes when mount format version changes", () => {
const shared = {
docker: createDockerConfig(),
browser: {
cdpPort: 9222,
cdpSourceRange: undefined,
vncPort: 5900,
noVncPort: 6080,
headless: false,
enableNoVnc: true,
},
securityEpoch: "epoch-v1",
workspaceAccess: "rw" as const,
workspaceDir: "/tmp/workspace",
agentWorkspaceDir: "/tmp/workspace",
};
const left = computeSandboxBrowserConfigHash({
...shared,
mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION,
});
const right = computeSandboxBrowserConfigHash({
...shared,
mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION - 1,
});
expect(left).not.toBe(right);
});
});

View File

@ -6,6 +6,7 @@ type SandboxHashInput = {
workspaceAccess: SandboxWorkspaceAccess;
workspaceDir: string;
agentWorkspaceDir: string;
mountFormatVersion: number;
};
type SandboxBrowserHashInput = {
@ -18,6 +19,7 @@ type SandboxBrowserHashInput = {
workspaceAccess: SandboxWorkspaceAccess;
workspaceDir: string;
agentWorkspaceDir: string;
mountFormatVersion: number;
};
function normalizeForHash(value: unknown): unknown {

View File

@ -3,6 +3,7 @@ import { Readable } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { computeSandboxConfigHash } from "./config-hash.js";
import { collectDockerFlagValues } from "./test-args.js";
import { SANDBOX_MOUNT_FORMAT_VERSION } from "./workspace-mounts.js";
import type { SandboxConfig } from "./types.js";
type SpawnCall = {
@ -183,12 +184,14 @@ describe("ensureSandboxContainer config-hash recreation", () => {
workspaceAccess: oldCfg.workspaceAccess,
workspaceDir,
agentWorkspaceDir: workspaceDir,
mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION,
});
const newHash = computeSandboxConfigHash({
docker: newCfg.docker,
workspaceAccess: newCfg.workspaceAccess,
workspaceDir,
agentWorkspaceDir: workspaceDir,
mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION,
});
expect(newHash).not.toBe(oldHash);
@ -244,6 +247,7 @@ describe("ensureSandboxContainer config-hash recreation", () => {
workspaceAccess: cfg.workspaceAccess,
workspaceDir,
agentWorkspaceDir: workspaceDir,
mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION,
});
spawnState.inspectRunning = false;
@ -275,16 +279,16 @@ describe("ensureSandboxContainer config-hash recreation", () => {
expect(createCall?.args).toContain(`openclaw.configHash=${expectedHash}`);
const bindArgs = collectDockerFlagValues(createCall?.args ?? [], "-v");
const workspaceMountIdx = bindArgs.indexOf("/tmp/workspace:/workspace");
const workspaceMountIdx = bindArgs.indexOf("/tmp/workspace:/workspace:z");
const customMountIdx = bindArgs.indexOf("/tmp/workspace-shared/USER.md:/workspace/USER.md:ro");
expect(workspaceMountIdx).toBeGreaterThanOrEqual(0);
expect(customMountIdx).toBeGreaterThan(workspaceMountIdx);
});
it.each([
{ workspaceAccess: "rw" as const, expectedMainMount: "/tmp/workspace:/workspace" },
{ workspaceAccess: "ro" as const, expectedMainMount: "/tmp/workspace:/workspace:ro" },
{ workspaceAccess: "none" as const, expectedMainMount: "/tmp/workspace:/workspace:ro" },
{ workspaceAccess: "rw" as const, expectedMainMount: "/tmp/workspace:/workspace:z" },
{ workspaceAccess: "ro" as const, expectedMainMount: "/tmp/workspace:/workspace:ro,z" },
{ workspaceAccess: "none" as const, expectedMainMount: "/tmp/workspace:/workspace:ro,z" },
])(
"uses expected main mount permissions when workspaceAccess=$workspaceAccess",
async ({ workspaceAccess, expectedMainMount }) => {
@ -312,4 +316,26 @@ describe("ensureSandboxContainer config-hash recreation", () => {
expect(bindArgs).toContain(expectedMainMount);
},
);
it("stamps the mount format version label on created containers", async () => {
const workspaceDir = "/tmp/workspace";
const cfg = createSandboxConfig([]);
spawnState.inspectRunning = false;
spawnState.labelHash = "";
registryMocks.readRegistry.mockResolvedValue({ entries: [] });
await ensureSandboxContainer({
sessionKey: "agent:main:session-1",
workspaceDir,
agentWorkspaceDir: workspaceDir,
cfg,
});
const createCall = spawnState.calls.find(
(call) => call.command === "docker" && call.args[0] === "create",
);
expect(createCall).toBeDefined();
expect(createCall?.args).toContain(`openclaw.mountFormatVersion=${SANDBOX_MOUNT_FORMAT_VERSION}`);
});
});

View File

@ -171,7 +171,7 @@ import { readRegistry, updateRegistry } from "./registry.js";
import { resolveSandboxAgentId, resolveSandboxScopeKey, slugifySessionKey } from "./shared.js";
import type { SandboxConfig, SandboxDockerConfig, SandboxWorkspaceAccess } from "./types.js";
import { validateSandboxSecurity } from "./validate-sandbox-security.js";
import { appendWorkspaceMountArgs } from "./workspace-mounts.js";
import { appendWorkspaceMountArgs, SANDBOX_MOUNT_FORMAT_VERSION } from "./workspace-mounts.js";
const log = createSubsystemLogger("docker");
@ -348,6 +348,7 @@ export function buildSandboxCreateArgs(params: {
args.push("--label", "openclaw.sandbox=1");
args.push("--label", `openclaw.sessionKey=${params.scopeKey}`);
args.push("--label", `openclaw.createdAtMs=${createdAtMs}`);
args.push("--label", `openclaw.mountFormatVersion=${SANDBOX_MOUNT_FORMAT_VERSION}`);
if (params.configHash) {
args.push("--label", `openclaw.configHash=${params.configHash}`);
}
@ -504,6 +505,7 @@ export async function ensureSandboxContainer(params: {
workspaceAccess: params.cfg.workspaceAccess,
workspaceDir: params.workspaceDir,
agentWorkspaceDir: params.agentWorkspaceDir,
mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION,
});
const now = Date.now();
const state = await dockerContainerState(containerName);

View File

@ -3,9 +3,9 @@ import { appendWorkspaceMountArgs } from "./workspace-mounts.js";
describe("appendWorkspaceMountArgs", () => {
it.each([
{ access: "rw" as const, expected: "/tmp/workspace:/workspace" },
{ access: "ro" as const, expected: "/tmp/workspace:/workspace:ro" },
{ access: "none" as const, expected: "/tmp/workspace:/workspace:ro" },
{ access: "rw" as const, expected: "/tmp/workspace:/workspace:z" },
{ access: "ro" as const, expected: "/tmp/workspace:/workspace:ro,z" },
{ access: "none" as const, expected: "/tmp/workspace:/workspace:ro,z" },
])("sets main mount permissions for workspaceAccess=$access", ({ access, expected }) => {
const args: string[] = [];
appendWorkspaceMountArgs({
@ -30,7 +30,7 @@ describe("appendWorkspaceMountArgs", () => {
});
const mounts = args.filter((arg) => arg.startsWith("/tmp/"));
expect(mounts).toEqual(["/tmp/workspace:/workspace:ro"]);
expect(mounts).toEqual(["/tmp/workspace:/workspace:ro,z"]);
});
it("omits agent workspace mount when paths are identical", () => {
@ -44,6 +44,23 @@ describe("appendWorkspaceMountArgs", () => {
});
const mounts = args.filter((arg) => arg.startsWith("/tmp/"));
expect(mounts).toEqual(["/tmp/workspace:/workspace"]);
expect(mounts).toEqual(["/tmp/workspace:/workspace:z"]);
});
it("marks split agent workspace mounts shared for SELinux", () => {
const args: string[] = [];
appendWorkspaceMountArgs({
args,
workspaceDir: "/tmp/workspace",
agentWorkspaceDir: "/tmp/agent-workspace",
workdir: "/workspace",
workspaceAccess: "ro",
});
const mounts = args.filter((arg) => arg.startsWith("/tmp/"));
expect(mounts).toEqual([
"/tmp/workspace:/workspace:ro,z",
"/tmp/agent-workspace:/agent:ro,z",
]);
});
});

View File

@ -1,12 +1,14 @@
import { SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js";
import type { SandboxWorkspaceAccess } from "./types.js";
function mainWorkspaceMountSuffix(access: SandboxWorkspaceAccess): "" | ":ro" {
return access === "rw" ? "" : ":ro";
}
export const SANDBOX_MOUNT_FORMAT_VERSION = 2;
function agentWorkspaceMountSuffix(access: SandboxWorkspaceAccess): "" | ":ro" {
return access === "ro" ? ":ro" : "";
function formatManagedWorkspaceBind(params: {
hostPath: string;
containerPath: string;
readOnly: boolean;
}): string {
return `${params.hostPath}:${params.containerPath}:${params.readOnly ? "ro,z" : "z"}`;
}
export function appendWorkspaceMountArgs(params: {
@ -18,11 +20,22 @@ export function appendWorkspaceMountArgs(params: {
}) {
const { args, workspaceDir, agentWorkspaceDir, workdir, workspaceAccess } = params;
args.push("-v", `${workspaceDir}:${workdir}${mainWorkspaceMountSuffix(workspaceAccess)}`);
args.push(
"-v",
formatManagedWorkspaceBind({
hostPath: workspaceDir,
containerPath: workdir,
readOnly: workspaceAccess !== "rw",
}),
);
if (workspaceAccess !== "none" && workspaceDir !== agentWorkspaceDir) {
args.push(
"-v",
`${agentWorkspaceDir}:${SANDBOX_AGENT_WORKSPACE_MOUNT}${agentWorkspaceMountSuffix(workspaceAccess)}`,
formatManagedWorkspaceBind({
hostPath: agentWorkspaceDir,
containerPath: SANDBOX_AGENT_WORKSPACE_MOUNT,
readOnly: workspaceAccess === "ro",
}),
);
}
}