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/2] 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/2] 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):