mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-07 22:52:58 -10:00
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:
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"])
|
||||
|
||||
Reference in new issue
Block a user