fix(sandbox): reject unsafe rootless Podman users

This commit is contained in:
Vincent Koc
2026-07-31 17:44:22 +08:00
parent 9d80906505
commit a53caebb6f
3 changed files with 35 additions and 18 deletions
+4 -4
View File
@@ -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:<id>"` 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.
@@ -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;
+6 -1
View File
@@ -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 {