diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index f1d81e4aa309..021bff86ba1d 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -149,7 +149,7 @@ Use `sandbox.backend: "podman"` to select the native `podman` CLI directly. This Podman reuses the existing `sandbox.docker.*` settings and the active native `podman` CLI context; it adds no separate connection configuration surface. -Rootless Podman defaults to `--userns=keep-id` for writable workspace mounts. A long-lived sandbox can reserve subordinate IDs and block unrelated `--userns=auto` workloads; remove it before starting those workloads. Set `sandbox.docker.user` to control the container user; rootful Podman otherwise uses the workspace owner when available. +Rootless Podman defaults to `--userns=keep-id` for writable workspace mounts. A long-lived sandbox can reserve subordinate IDs and block unrelated `--userns=auto` workloads; remove it before starting those workloads. Set `sandbox.docker.user` to a nonzero numeric UID or UID:GID to control the container user. Rootless Podman rejects UID or GID 0 because Podman 4.x cannot remap namespace root while preserving workspace bind ownership; bake root-required setup into the image or use rootful Podman. Rootful Podman otherwise uses the workspace owner when available. ```json5 { @@ -523,9 +523,9 @@ Paths: - Default `docker.network` is `"none"` (no egress), so package installs will fail. - `docker.network: "container:"` requires `dangerouslyAllowContainerNamespaceJoin: true` and is break-glass only. - `readOnlyRoot: true` prevents writes; set `readOnlyRoot: false` or bake a custom image. - - `user` must be root for package installs. Docker can omit `user` or set - `user: "0:0"`; rootless Podman must set `user: "0:0"` because omission - defaults to the invoking user through `--userns=keep-id`. + - `user` must be root for package installs. Docker or rootful Podman can omit + `user` or set `user: "0:0"`. Rootless Podman rejects zero-valued users; + bake packages into the image or use rootful Podman. - Sandbox exec does **not** inherit host `process.env`. Use `agents.defaults.sandbox.docker.env` (or a custom image) for skill API keys. - Values in `agents.defaults.sandbox.docker.env` are passed as explicit container environment variables. Anyone with access to the selected container engine can inspect them with metadata commands such as `docker inspect` or `podman inspect`. Use a custom image, mounted secret file, or another secret delivery path if that metadata exposure is not acceptable. diff --git a/src/agents/sandbox/docker.config-hash-recreate.test.ts b/src/agents/sandbox/docker.config-hash-recreate.test.ts index 4c1433ae05f2..b49cf5fba1aa 100644 --- a/src/agents/sandbox/docker.config-hash-recreate.test.ts +++ b/src/agents/sandbox/docker.config-hash-recreate.test.ts @@ -681,20 +681,32 @@ describe("ensureSandboxContainer config-hash recreation", () => { expect(collectDockerFlagValues(createCall.args, "--userns")).toEqual([]); }); - it("maps an explicit root user through rootless Podman keep-id", async () => { - const cfg = createSandboxConfig([]); - cfg.docker.user = "0:0"; - spawnState.inspectRunning = false; - registryMocks.readRegistryEntry.mockResolvedValue(null); + it.each([{ user: "0" }, { user: "0:0" }, { user: "1001:0" }, { user: "0:1002" }])( + "rejects zero-valued rootless Podman user $user", + async ({ user }) => { + const cfg = createSandboxConfig([]); + cfg.docker.user = user; + spawnState.inspectRunning = false; + registryMocks.readRegistryEntry.mockResolvedValue(null); - const createCall = await ensureSandboxCreateCallForTest({ - cfg, - engine: PODMAN_SANDBOX_ENGINE, - }); + await expect( + ensureSandboxContainer({ + engine: PODMAN_SANDBOX_ENGINE, + scopeKey: "shared", + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/workspace", + cfg, + }), + ).rejects.toThrow(/cannot use UID or GID 0/iu); - expect(collectDockerFlagValues(createCall.args, "--user")).toEqual(["0:0"]); - expect(collectDockerFlagValues(createCall.args, "--userns")).toEqual(["keep-id:uid=0,gid=0"]); - }); + expect(spawnState.calls).not.toContainEqual( + expect.objectContaining({ + command: "podman", + args: expect.arrayContaining(["create"]), + }), + ); + }, + ); it("rejects nonnumeric users for rootless Podman keep-id", async () => { const cfg = createSandboxConfig([]); @@ -943,7 +955,7 @@ describe("ensureSandboxContainer config-hash recreation", () => { createArgsEpoch: SANDBOX_DOCKER_CREATE_ARGS_EPOCH, readOnlyWorkspaceSkillMounts: [], }); - const oldHash = `${genericHash}:podman-runtime-v6:keep-id:default`; + const oldHash = `${genericHash}:podman-runtime-v8:keep-id:default`; cfg.dockerTmpfsSource = "configured"; spawnState.inspectRunning = false; spawnState.labelHash = oldHash; diff --git a/src/agents/sandbox/podman-runtime.ts b/src/agents/sandbox/podman-runtime.ts index 2f563297f93e..ac88915f66ef 100644 --- a/src/agents/sandbox/podman-runtime.ts +++ b/src/agents/sandbox/podman-runtime.ts @@ -42,6 +42,11 @@ function resolvePodmanKeepIdMode(user: string | undefined): string { ); } const [, uid, gid] = match; + if (uid === "0" || gid === "0") { + throw invalidPodmanConfig( + `Rootless Podman sandbox user "${normalized}" cannot use UID or GID 0 while preserving workspace bind ownership. Bake root-required setup into the image or use rootful Podman.`, + ); + } return gid ? `keep-id:uid=${uid},gid=${gid}` : `keep-id:uid=${uid}`; } @@ -287,7 +292,7 @@ export function resolvePodmanSandboxConfigHash(params: { dockerTmpfsSource: SandboxConfig["dockerTmpfsSource"]; }): string { const userMode = params.configuredUser ? "configured-user" : "keep-id"; - return `${params.genericConfigHash}:podman-runtime-v6:${userMode}:${params.dockerTmpfsSource}`; + return `${params.genericConfigHash}:podman-runtime-v8:${userMode}:${params.dockerTmpfsSource}`; } export function resolvePodmanSandboxContainerPrefix(containerPrefix: string): string {