fix(sandbox): apply PID floor on execute path, scrub all env on capture, guard openat on non-POSIX

Three valid P1s from adversarial review:

- execute()'s _build_docker_command passed max_processes straight to
  --pids-limit, so the default 10 killed the container before python
  started (exit 125). Apply MIN_CONTAINER_PIDS floor, matching run_command().

- docker commit scrubbed only a fixed secret-name set. ComputeConfig.env is
  public (.praisonai/environment.yaml), so an arbitrary custom secret was
  baked into the persistent image. Scrub the union of the known set and the
  config's own env names -- the values actually injected into the container.

- open_in_sandbox/makedirs_in_sandbox referenced os.O_DIRECTORY / O_NOFOLLOW
  and dir_fd unconditionally, raising AttributeError on Windows. Gate the
  openat walk behind a capability flag; fall back to the resolved-string
  guard where openat is unavailable (Docker there runs a Linux VM, so no
  host-shared dir exists to race).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Mervin Praison <[email protected]>
This commit is contained in:
praisonai-triage-agent[bot]andMervin Praison committed 2026-08-20 11:01:23 +00:00
1 parent a87e97c343
commit ef6bb47be8
3 files changed
+64 -8

No files matched your search

@@ -6,6 +6,19 @@ import os
logger = logging.getLogger(__name__)
# The descriptor-relative walk relies on POSIX-only openat semantics:
# O_DIRECTORY / O_NOFOLLOW and os.open()'s dir_fd argument. Windows has none of
# them -- referencing os.O_DIRECTORY there is an AttributeError, and dir_fd is
# unsupported -- so the whole approach is guarded behind this flag. Docker on
# Windows talks to a Linux daemon, so the container-side race these helpers
# defend against does not exist on the host; falling back to the resolved-string
# guard there is both correct and the only thing that runs.
_HAS_OPENAT = (
hasattr(os, "O_NOFOLLOW")
and hasattr(os, "O_DIRECTORY")
and os.open in getattr(os, "supports_dir_fd", set())
)
def safe_sandbox_path(temp_dir: str | None, path: str) -> str | None:
"""Resolve a caller-supplied path to an absolute path inside temp_dir.
@@ -66,6 +79,19 @@ def open_in_sandbox(temp_dir: str | None, path: str, flags: int, mode: int = 0o6
logger.warning("Path traversal attempt blocked: %s", path)
return None
if not _HAS_OPENAT:
# No openat here (Windows). The container-side symlink race this walk
# defends against cannot occur -- Docker Desktop runs Linux containers
# in a VM, so nothing shares this host directory -- so the resolved
# string guard is sufficient and is the only thing that can run.
resolved = safe_sandbox_path(temp_dir, path)
if resolved is None:
return None
try:
return os.open(resolved, flags, mode)
except OSError:
return None
try:
root_fd = os.open(temp_dir, os.O_RDONLY | os.O_DIRECTORY)
except OSError:
@@ -107,6 +133,20 @@ def makedirs_in_sandbox(temp_dir: str | None, path: str) -> bool:
if parts is None:
return False
if not _HAS_OPENAT:
# Windows fallback: no openat, and no host-shared directory to race.
# Create the parent chain through the resolved string guard.
if len(parts) <= 1:
return True
resolved = safe_sandbox_path(temp_dir, "/".join(parts[:-1]))
if resolved is None:
return False
try:
os.makedirs(resolved, exist_ok=True)
return True
except OSError:
return False
try:
dir_fd = os.open(temp_dir, os.O_RDONLY | os.O_DIRECTORY)
except OSError:
@@ -325,13 +325,20 @@ class DockerCompute(SyncComputeProvider):
return None
try:
repository, _, tag = ref.partition(":")
# Scrub the forwarded secrets out of the committed image. `docker
# commit` preserves Config.Env, so every model API key handed to
# the container was baked into an image that persists for days --
# readable by anyone who can run `docker inspect`. The capture is
# meant to reuse the installed *packages*, not the credentials that
# happened to be in the environment when they were installed.
scrub = [f"ENV {name}=" for name in sorted(_FORWARDED_SECRETS)]
# Scrub every forwarded env value out of the committed image.
# `docker commit` preserves Config.Env, so anything handed to the
# container -- a model API key, or an arbitrary secret the caller
# put in ComputeConfig.env -- was baked into an image that persists
# for days, readable by anyone who can run `docker inspect`. The
# capture is meant to reuse the installed *packages*, not the
# credentials that happened to be in the environment when they were
# installed. The known-secret set is only a floor; the config's own
# env names are the values actually injected into THIS container, so
# scrub their union rather than trusting a fixed list to be complete.
config = info.get("config")
config_env = getattr(config, "env", None) or {}
names = _FORWARDED_SECRETS | {str(name) for name in config_env}
scrub = [f"ENV {name}=" for name in sorted(names)]
info["container"].commit(
repository=repository, tag=tag or "latest", changes=scrub
)
@@ -505,8 +505,17 @@ class DockerSandbox:
"-v", f"{self._temp_dir}:{SANDBOX_ROOT}",
"--memory", f"{limits.memory_mb}m",
"--cpus", str(limits.cpu_percent / 100),
"--pids-limit", str(limits.max_processes),
]
# Same floor as run_command(): --pids-limit is per CONTAINER, and the
# default max_processes of 10 was chosen for setrlimit's per-user world.
# A raw 10 here kills the container before python even starts (exit 125),
# so raise it to something a container can live within. Still bounds a
# fork bomb; just stops the bound from being the bug.
if limits.max_processes and limits.max_processes > 0:
docker_cmd.extend(
["--pids-limit", str(max(limits.max_processes, MIN_CONTAINER_PIDS))]
)
if not limits.network_enabled:
docker_cmd.extend(["--network", "none"])