From 7a345524e91aaa9898a4e3e60e6f7fc4c0a1ff0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=A5=C3=A5rd?= Date: Wed, 22 Apr 2026 17:26:59 +0200 Subject: [PATCH 1/4] Dedupe paths for e.g. /var/run mapping to /run --- .github/workflows/ci.yml | 3 +++ pyproject.toml | 2 +- src/project_wrap/core.py | 12 ++++++++++-- tests/test_core.py | 19 +++++++++++++++++++ 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 85fee87..9a903bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [main] +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest diff --git a/pyproject.toml b/pyproject.toml index fddbfb3..24ebf9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "projectwrap" -version = "202604.4" +version = "202604.4.1a0" description = "Isolated project environments with bubblewrap sandboxing" readme = "README.md" license = "MIT" diff --git a/src/project_wrap/core.py b/src/project_wrap/core.py index 93f97ab..5ccc05b 100644 --- a/src/project_wrap/core.py +++ b/src/project_wrap/core.py @@ -129,10 +129,18 @@ def build_bwrap_args( # Mask docker sockets — connect() works on ro-bound sockets, so any # accessible socket is a sandbox escape to root if docker is running. # Cover the common locations; add others via writable to override. + # Resolve to canonical paths and dedupe: /var/run is a symlink to /run on + # modern Linux, and bwrap can't bind through a symlink destination. + seen_socks: set[str] = set() for sock in _DOCKER_SOCKET_CANDIDATES: sock_path = expand_path(sock) - if os.path.lexists(str(sock_path)): - args.extend(["--ro-bind", "/dev/null", str(sock_path)]) + if not os.path.lexists(str(sock_path)): + continue + resolved = os.path.realpath(str(sock_path)) + if resolved in seen_socks: + continue + seen_socks.add(resolved) + args.extend(["--ro-bind", "/dev/null", resolved]) # Always blacklist the config directory (prevents reading other project configs # or modifying sandbox rules from inside the sandbox) diff --git a/tests/test_core.py b/tests/test_core.py index 1f7a094..7263cb3 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -173,6 +173,25 @@ def test_docker_socket_masked_when_present(self, tmp_path, monkeypatch): assert result[idx - 2] == "--ro-bind" assert str(absent) not in result + def test_docker_socket_dedup_via_symlink(self, tmp_path, monkeypatch): + real_dir = tmp_path / "run" + real_dir.mkdir() + sock = real_dir / "docker.sock" + sock.touch() + link_dir = tmp_path / "var_run" + link_dir.symlink_to(real_dir) + via_link = link_dir / "docker.sock" + + monkeypatch.setattr( + "project_wrap.core._DOCKER_SOCKET_CANDIDATES", + [str(sock), str(via_link)], + ) + result = build_bwrap_args({}, tmp_path) + + resolved = os.path.realpath(str(sock)) + assert result.count(resolved) == 1 + assert str(via_link) not in result + def test_blacklist_args(self, tmp_path): blacklist_dir = tmp_path / "secret" blacklist_dir.mkdir() From 2e23237f99fa9f0a365dd19c901c98aba71ce659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=A5=C3=A5rd?= Date: Wed, 22 Apr 2026 17:49:14 +0200 Subject: [PATCH 2/4] Fix whitelist binding rw instead of ro --- CHANGELOG.md | 13 +++++++++++++ src/project_wrap/core.py | 5 +++-- tests/test_core.py | 2 +- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43259ae..5517900 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 202604.4.1a0 + +- **Breaking:** `whitelist` entries are now bound read-only (`--ro-bind`) + instead of read-write (`--bind`). This reduces blast radius of whitelisting + broad trees (e.g. `/mnt/wsl` for WSL DNS) where the bound path contains + writable sockets or files the sandboxee shouldn't be able to mutate. Use + `writable` for rw exceptions — `core.py` +- Docker socket mask deduplicates candidates by canonical path, so + `/var/run/docker.sock` (symlink to `/run/docker.sock` on modern Linux) no + longer produces a duplicate `--ro-bind` that bwrap refuses with "Can't + create file at /var/run/docker.sock: No such file or directory" — + `core.py` + ## 202604.4 - Docker socket mask now covers `/var/run/docker.sock`, diff --git a/src/project_wrap/core.py b/src/project_wrap/core.py index 5ccc05b..26ef346 100644 --- a/src/project_wrap/core.py +++ b/src/project_wrap/core.py @@ -184,7 +184,8 @@ def build_bwrap_args( args.extend(["--tmpfs", str(mount_path)]) blacklist_paths.append(mount_path) - # Whitelist paths by binding them back (must be under a blacklisted path) + # Whitelist paths by binding them back read-only (must be under a + # blacklisted path). Use `writable` for an rw exception. for path in sandbox.get("whitelist", []): p = expand_path(path) if not p.exists(): @@ -196,7 +197,7 @@ def build_bwrap_args( f"Whitelist path {p} is not under any blacklisted path. " f"Blacklisted: {[str(bl) for bl in blacklist_paths]}" ) - args.extend(["--bind", str(resolved), str(resolved)]) + args.extend(["--ro-bind", str(resolved), str(resolved)]) # Extra writable paths (e.g. ~/.pyenv/shims, ~/.keychain) for p in writable_expanded: diff --git a/tests/test_core.py b/tests/test_core.py index 7263cb3..4c42089 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -225,7 +225,7 @@ def test_whitelist_under_blacklist_ok(self, tmp_path): ) idx = result.index(str(child)) - assert result[idx - 1] == "--bind" + assert result[idx - 1] == "--ro-bind" assert result[idx + 1] == str(child) def test_whitelist_not_under_blacklist_raises(self, tmp_path): From 9866a7a0c3a055cccfa47d4091de297371974885 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=A5=C3=A5rd?= Date: Wed, 22 Apr 2026 17:53:08 +0200 Subject: [PATCH 3/4] Fix docker sockets in WSL --- CHANGELOG.md | 6 ++++++ src/project_wrap/core.py | 28 +++++++++++++++---------- src/project_wrap/templates/project.toml | 2 ++ tests/test_core.py | 23 ++++++++++++++++++++ 4 files changed, 48 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5517900..36ebead 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ longer produces a duplicate `--ro-bind` that bwrap refuses with "Can't create file at /var/run/docker.sock: No such file or directory" — `core.py` +- Docker socket mask now covers WSL Docker Desktop paths + (`/mnt/wsl/docker-desktop-bind-mounts/*/docker.sock` and + `/mnt/wsl/docker-desktop/shared-sockets/*.sock`). Previously, a project + whitelisting `/mnt/wsl` (e.g. to get resolv.conf) had a clean path to the + host Docker engine — reachable via `curl --unix-socket …` — which is a + full root escape. Candidate list now accepts glob patterns — `core.py` ## 202604.4 diff --git a/src/project_wrap/core.py b/src/project_wrap/core.py index 26ef346..6940dd8 100644 --- a/src/project_wrap/core.py +++ b/src/project_wrap/core.py @@ -2,6 +2,7 @@ from __future__ import annotations +import glob import os import shlex import subprocess @@ -39,6 +40,11 @@ class ProjectExec: "/var/run/docker.sock", "~/.docker/desktop/docker-cli.sock", "~/.docker/run/docker.sock", + # WSL: Docker Desktop exposes the engine under /mnt/wsl, reachable whenever + # the user whitelists /mnt/wsl (e.g. for resolv.conf). Wildcard matches the + # distro-named subdir (Ubuntu, Debian, ...) and every shared-socket. + "/mnt/wsl/docker-desktop-bind-mounts/*/docker.sock", + "/mnt/wsl/docker-desktop/shared-sockets/*.sock", ] @@ -128,19 +134,19 @@ def build_bwrap_args( # Mask docker sockets — connect() works on ro-bound sockets, so any # accessible socket is a sandbox escape to root if docker is running. - # Cover the common locations; add others via writable to override. - # Resolve to canonical paths and dedupe: /var/run is a symlink to /run on - # modern Linux, and bwrap can't bind through a symlink destination. + # Candidates may include wildcards (WSL Docker Desktop paths vary by + # distro name). Resolve to canonical paths and dedupe: /var/run is a + # symlink to /run on modern Linux, and bwrap can't bind through a + # symlink destination. Override via `writable`. seen_socks: set[str] = set() for sock in _DOCKER_SOCKET_CANDIDATES: - sock_path = expand_path(sock) - if not os.path.lexists(str(sock_path)): - continue - resolved = os.path.realpath(str(sock_path)) - if resolved in seen_socks: - continue - seen_socks.add(resolved) - args.extend(["--ro-bind", "/dev/null", resolved]) + pattern = str(expand_path(sock)) + for match in glob.glob(pattern): + resolved = os.path.realpath(match) + if resolved in seen_socks: + continue + seen_socks.add(resolved) + args.extend(["--ro-bind", "/dev/null", resolved]) # Always blacklist the config directory (prevents reading other project configs # or modifying sandbox rules from inside the sandbox) diff --git a/src/project_wrap/templates/project.toml b/src/project_wrap/templates/project.toml index ef2b984..fac1315 100644 --- a/src/project_wrap/templates/project.toml +++ b/src/project_wrap/templates/project.toml @@ -35,6 +35,8 @@ blacklist = [ # Paths to hide (overlaid with tmpfs) # # "/var/run/docker.sock", # alt system docker # # "~/.docker/desktop/docker-cli.sock", # Docker Desktop (Linux) # # "~/.docker/run/docker.sock", # Docker Desktop alt +# # "/mnt/wsl/docker-desktop-bind-mounts/Ubuntu/docker.sock", # WSL +# # # (replace Ubuntu with your distro) # ] # unshare_net = false # Isolate network namespace # unshare_pid = true # Isolate PID namespace (default: true) diff --git a/tests/test_core.py b/tests/test_core.py index 4c42089..be51c04 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -173,6 +173,29 @@ def test_docker_socket_masked_when_present(self, tmp_path, monkeypatch): assert result[idx - 2] == "--ro-bind" assert str(absent) not in result + def test_docker_socket_glob_expansion(self, tmp_path, monkeypatch): + shared = tmp_path / "shared-sockets" + shared.mkdir() + sock_a = shared / "backend.sock" + sock_b = shared / "filesystem.sock" + sock_a.touch() + sock_b.touch() + other = shared / "not-a-socket.txt" + other.touch() + + monkeypatch.setattr( + "project_wrap.core._DOCKER_SOCKET_CANDIDATES", + [str(shared / "*.sock")], + ) + result = build_bwrap_args({}, tmp_path) + + for expected in (sock_a, sock_b): + resolved = os.path.realpath(str(expected)) + idx = result.index(resolved) + assert result[idx - 1] == "/dev/null" + assert result[idx - 2] == "--ro-bind" + assert os.path.realpath(str(other)) not in result + def test_docker_socket_dedup_via_symlink(self, tmp_path, monkeypatch): real_dir = tmp_path / "run" real_dir.mkdir() From dbe63dbcbf3ce85f272966be171f83e85d281702 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=A5=C3=A5rd?= Date: Wed, 22 Apr 2026 17:59:06 +0200 Subject: [PATCH 4/4] Mypy fixes --- src/project_wrap/core.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/project_wrap/core.py b/src/project_wrap/core.py index 6940dd8..63f4f49 100644 --- a/src/project_wrap/core.py +++ b/src/project_wrap/core.py @@ -142,11 +142,11 @@ def build_bwrap_args( for sock in _DOCKER_SOCKET_CANDIDATES: pattern = str(expand_path(sock)) for match in glob.glob(pattern): - resolved = os.path.realpath(match) - if resolved in seen_socks: + sock_real = os.path.realpath(match) + if sock_real in seen_socks: continue - seen_socks.add(resolved) - args.extend(["--ro-bind", "/dev/null", resolved]) + seen_socks.add(sock_real) + args.extend(["--ro-bind", "/dev/null", sock_real]) # Always blacklist the config directory (prevents reading other project configs # or modifying sandbox rules from inside the sandbox)