diff --git a/src/coder_eval/sandbox.py b/src/coder_eval/sandbox.py index 0804d979..07217061 100644 --- a/src/coder_eval/sandbox.py +++ b/src/coder_eval/sandbox.py @@ -22,12 +22,45 @@ logger = logging.getLogger(__name__) -# Top-level entries excluded from Sandbox.capture_to (docker WORKDIR-alignment). -# The WORKDIR can be HOME or overlap framework mounts, so skip credentials and -# sandbox-created bulk. `.claude` is the RW lean copy of ~/.claude (carries -# .credentials.json). The rest are sandbox infra the standard artifacts path -# also drops. Matched by basename at every level (shutil.ignore_patterns). -_WORKSPACE_CAPTURE_IGNORE = (".claude", ".venv", ".npm-prefix", "node_modules") +# Entries excluded from Sandbox.capture_to (docker WORKDIR-alignment). +# Two classes of exclusion: +# +# 1. SECURITY denylist: credential files/dirs that must never leak into +# captured artifacts (which get uploaded). Defense-in-depth -- the eval +# images don't bake credentials, but any future image that does should +# not silently expose them. +# +# 2. NOISE suppression: sandbox-created bulk and home-dir infrastructure +# written by tools (uv, pip, npm, shell) when WORKDIR overlaps HOME +# (e.g. /root). These are never task deliverables. +# +# Matched by basename at every level via shutil.ignore_patterns. +_WORKSPACE_CAPTURE_IGNORE = ( + # --- Security: credential stores --- + ".claude", # RW lean copy of host ~/.claude (carries .credentials.json) + ".aws", # AWS credentials / config + ".ssh", # SSH keys + ".gnupg", # GPG keys + ".docker", # Docker auth (config.json) + ".azure", # Azure CLI credentials + ".netrc", # FTP/curl/git credentials + ".gitconfig", # May embed PATs via credential.helper + # --- Noise: Python / JS build infra --- + ".venv", + ".npm-prefix", + "node_modules", + # --- Noise: home-dir caches & config (uv, pip, npm, etc.) --- + ".cache", + ".config", + ".npm", + ".local", + # --- Noise: shell dotfiles pre-baked into the image --- + ".bashrc", + ".bash_history", + ".bash_logout", + ".profile", + ".wget-hsts", +) def _grant_read_traverse(root: Path) -> None: @@ -1080,8 +1113,10 @@ def capture_to(self, artifact_dir: Path) -> Path: :data:`_WORKSPACE_CAPTURE_IGNORE` -- most importantly ``.claude`` (the RW lean copy of the host ``~/.claude`` carries ``.credentials.json``; without this a ``/root`` WORKDIR would leak it into artifacts), plus - ``.venv``/``node_modules``/``.npm-prefix`` (sandbox-created bulk, already - stripped by the standard artifacts path's post-run cleanup). + ``.venv``/``node_modules``/``.npm-prefix`` (sandbox-created bulk), and + Linux home-directory noise (``.cache``, ``.config``, ``.npm``, + ``.local``, shell dotfiles) written by tools like uv/pip/npm when + HOME == WORKDIR. Returns the destination path; unlike preserve_to it does NOT repoint ``self.sandbox_dir`` -- the workspace persists in-container and is reaped diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py index 83176b8f..c606fa92 100644 --- a/tests/test_sandbox.py +++ b/tests/test_sandbox.py @@ -1196,13 +1196,36 @@ def test_capture_to_copies_and_tolerates_dangling_symlink(tmp_path): (ws / "real.txt").write_text("hello", encoding="utf-8") # A dangling symlink is the exact failure the old `cp -a` bridge hit. (ws / "dangling").symlink_to(ws / "does_not_exist") - # Framework/sensitive entries that must NOT be captured (WORKDIR may be HOME). + # Security denylist: credential stores that must never leak into artifacts. (ws / ".claude").mkdir() (ws / ".claude" / ".credentials.json").write_text("SECRET", encoding="utf-8") + (ws / ".aws").mkdir() + (ws / ".aws" / "credentials").write_text("[default]\naws_access_key_id=FAKE", encoding="utf-8") + (ws / ".ssh").mkdir() + (ws / ".ssh" / "id_rsa").write_text("PRIVATE KEY", encoding="utf-8") + (ws / ".gnupg").mkdir() + (ws / ".docker").mkdir() + (ws / ".docker" / "config.json").write_text('{"auths":{}}', encoding="utf-8") + (ws / ".azure").mkdir() + (ws / ".netrc").write_text("machine github.com login user password TOKEN", encoding="utf-8") + (ws / ".gitconfig").write_text("[user]\n\tname = Test", encoding="utf-8") + # Noise: Python / JS build infra. (ws / ".venv").mkdir() (ws / ".venv" / "pyvenv.cfg").write_text("x", encoding="utf-8") (ws / "node_modules").mkdir() (ws / "node_modules" / "pkg.js").write_text("x", encoding="utf-8") + # Noise: home-dir caches written by uv/pip/npm/shell when WORKDIR == HOME. + (ws / ".cache").mkdir() + (ws / ".cache" / "uv").mkdir() + (ws / ".config").mkdir() + (ws / ".config" / "uv").mkdir() + (ws / ".npm").mkdir() + (ws / ".local").mkdir() + (ws / ".bashrc").write_text("# bash", encoding="utf-8") + (ws / ".bash_history").write_text("ls\n", encoding="utf-8") + (ws / ".bash_logout").write_text("# logout", encoding="utf-8") + (ws / ".profile").write_text("# profile", encoding="utf-8") + (ws / ".wget-hsts").write_text("", encoding="utf-8") artifacts = tmp_path / "artifacts" dest = sandbox.capture_to(artifacts) @@ -1211,10 +1234,28 @@ def test_capture_to_copies_and_tolerates_dangling_symlink(tmp_path): assert (dest / "real.txt").read_text(encoding="utf-8") == "hello" # Dangling symlink skipped (ignore_dangling_symlinks) -> did not raise. assert not (dest / "dangling").exists() - # Credentials / sandbox bulk excluded from the captured artifacts. + # Security denylist: no credential stores in artifacts. assert not (dest / ".claude").exists() + assert not (dest / ".aws").exists() + assert not (dest / ".ssh").exists() + assert not (dest / ".gnupg").exists() + assert not (dest / ".docker").exists() + assert not (dest / ".azure").exists() + assert not (dest / ".netrc").exists() + assert not (dest / ".gitconfig").exists() + # Noise: build infra excluded. assert not (dest / ".venv").exists() assert not (dest / "node_modules").exists() + # Noise: home-dir bulk excluded. + assert not (dest / ".cache").exists() + assert not (dest / ".config").exists() + assert not (dest / ".npm").exists() + assert not (dest / ".local").exists() + assert not (dest / ".bashrc").exists() + assert not (dest / ".bash_history").exists() + assert not (dest / ".bash_logout").exists() + assert not (dest / ".profile").exists() + assert not (dest / ".wget-hsts").exists() # Source workspace is COPIED, not moved (originals untouched). assert (ws / "real.txt").exists() assert (ws / ".claude" / ".credentials.json").exists()