From ff43e38ecc0272b99417153c09e38917b1fbfffe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:29:55 +0000 Subject: [PATCH 1/3] Initial plan From fad3728256abfc2a334a4f51b7368017dc9eb331 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:33:06 +0000 Subject: [PATCH 2/3] Harden Apptainer bundle extraction against path traversal --- .../apptainer/agentix/provider/apptainer.py | 80 +++++++++++++++++-- .../tests/test_apptainer_provider.py | 15 ++++ 2 files changed, 88 insertions(+), 7 deletions(-) diff --git a/plugins/providers/apptainer/agentix/provider/apptainer.py b/plugins/providers/apptainer/agentix/provider/apptainer.py index 827bc39..9586d4f 100644 --- a/plugins/providers/apptainer/agentix/provider/apptainer.py +++ b/plugins/providers/apptainer/agentix/provider/apptainer.py @@ -42,10 +42,12 @@ import json import logging import os +import posixpath import shutil import socket import tarfile from pathlib import Path +from tempfile import TemporaryDirectory from uuid import uuid4 from agentix.provider.base import Sandbox, SandboxConfig, SandboxId, SandboxInfo, SandboxProvider @@ -151,13 +153,77 @@ def _extract_bundle(bundle_tar: Path, target: Path) -> Path: nix_root = target / "nix" if (nix_root / "runtime" / "bootstrap.sh").exists(): return nix_root - target.mkdir(parents=True, exist_ok=True) - with tarfile.open(bundle_tar, "r:*") as tar: - for member in tar: - name = member.name - if not (name == "nix" or name.startswith("nix/")): - continue - tar.extract(member, target) + + def checked_nix_member_name(name: str) -> str: + normalized = posixpath.normpath(name) + if normalized in {"", "."} or normalized.startswith("/") or normalized == ".." or normalized.startswith("../"): + raise RuntimeError(f"bundle tar produced unsafe member: {name!r}") + if normalized != "nix" and not normalized.startswith("nix/"): + raise RuntimeError(f"bundle tar produced non-/nix member: {name!r}") + return normalized + + def ensure_safe_parent(root: Path, path: Path) -> None: + current = root + for part in path.parent.relative_to(root).parts: + current = current / part + if os.path.lexists(current): + if current.is_symlink() or not current.is_dir(): + raise RuntimeError(f"bundle tar member parent is not a directory: {current}") + else: + current.mkdir() + + def remove_existing_path(path: Path) -> None: + if path.is_dir() and not path.is_symlink(): + shutil.rmtree(path) + elif os.path.lexists(path): + path.unlink() + + def extract_nix_member(tar: tarfile.TarFile, member: tarfile.TarInfo, root: Path) -> None: + name = checked_nix_member_name(member.name) + member_path = root / name + ensure_safe_parent(root, member_path) + + if member.isdir(): + if os.path.lexists(member_path): + if member_path.is_symlink() or not member_path.is_dir(): + raise RuntimeError( + f"bundle tar cannot replace non-directory with directory: {name}" + ) + else: + member_path.mkdir() + return + + remove_existing_path(member_path) + if member.issym(): + os.symlink(member.linkname, member_path) + return + if member.islnk(): + link_target = root / checked_nix_member_name(member.linkname) + os.link(link_target, member_path) + return + if member.isfile(): + source = tar.extractfile(member) + if source is None: + raise RuntimeError(f"bundle tar has unreadable file member: {name}") + with source, member_path.open("wb") as f: + shutil.copyfileobj(source, f) + os.chmod(member_path, member.mode & 0o7777) + return + + raise RuntimeError(f"bundle tar contains unsupported member type: {name}") + + target.parent.mkdir(parents=True, exist_ok=True) + with TemporaryDirectory(prefix=f".{target.name}.", dir=target.parent) as tmp: + tmp_root = Path(tmp) + with tarfile.open(bundle_tar, "r:*") as tar: + for member in tar: + name = member.name + if not (name == "nix" or name.startswith("nix/")): + continue + extract_nix_member(tar, member, tmp_root) + remove_existing_path(target) + tmp_root.replace(target) + if not nix_root.is_dir(): raise RuntimeError(f"bundle {bundle_tar} did not contain a `nix/` tree") return nix_root diff --git a/plugins/providers/apptainer/tests/test_apptainer_provider.py b/plugins/providers/apptainer/tests/test_apptainer_provider.py index c6a2b10..7e5fdc2 100644 --- a/plugins/providers/apptainer/tests/test_apptainer_provider.py +++ b/plugins/providers/apptainer/tests/test_apptainer_provider.py @@ -13,6 +13,7 @@ from __future__ import annotations +import io import json import os import stat @@ -206,6 +207,20 @@ def test_extract_bundle_skips_when_runtime_already_present(tmp_path: Path) -> No assert sentinel.read_text() == "MUTATED" +def test_extract_bundle_rejects_path_traversal_member(tmp_path: Path) -> None: + bundle = tmp_path / "bundle.tar" + with tarfile.open(bundle, "w") as tar: + data = b"owned" + info = tarfile.TarInfo("nix/../../escape.txt") + info.size = len(data) + tar.addfile(info, io.BytesIO(data)) + + target = tmp_path / "cache" / "bundle" + with pytest.raises(RuntimeError, match="unsafe member"): + _extract_bundle(bundle, target) + assert not (tmp_path / "cache" / "escape.txt").exists() + + @pytest.mark.anyio async def test_create_and_delete_roundtrip(env, tmp_path: Path) -> None: bundle = _write_bundle_tar(tmp_path) From 456c53447c5d7d487855bdb183bfa8e08cc9d68c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:36:15 +0000 Subject: [PATCH 3/3] Tighten apptainer tar extraction safety checks --- .../apptainer/agentix/provider/apptainer.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/plugins/providers/apptainer/agentix/provider/apptainer.py b/plugins/providers/apptainer/agentix/provider/apptainer.py index 9586d4f..00b720c 100644 --- a/plugins/providers/apptainer/agentix/provider/apptainer.py +++ b/plugins/providers/apptainer/agentix/provider/apptainer.py @@ -163,8 +163,12 @@ def checked_nix_member_name(name: str) -> str: return normalized def ensure_safe_parent(root: Path, path: Path) -> None: + try: + relative_parent = path.parent.relative_to(root) + except ValueError as exc: + raise RuntimeError(f"bundle tar member escaped extraction root: {path}") from exc current = root - for part in path.parent.relative_to(root).parts: + for part in relative_parent.parts: current = current / part if os.path.lexists(current): if current.is_symlink() or not current.is_dir(): @@ -198,7 +202,13 @@ def extract_nix_member(tar: tarfile.TarFile, member: tarfile.TarInfo, root: Path os.symlink(member.linkname, member_path) return if member.islnk(): - link_target = root / checked_nix_member_name(member.linkname) + try: + link_name = checked_nix_member_name(member.linkname) + except RuntimeError as exc: + raise RuntimeError(f"bundle tar produced unsafe hard-link target: {member.linkname!r}") from exc + link_target = root / link_name + if not os.path.lexists(link_target): + raise RuntimeError(f"bundle tar hard-link target does not exist: {member.linkname!r}") os.link(link_target, member_path) return if member.isfile():