diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79080b9..6b9a880 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/CHANGELOG.md b/CHANGELOG.md index 43259ae..36ebead 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # 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` +- 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 - Docker socket mask now covers `/var/run/docker.sock`, diff --git a/pyproject.toml b/pyproject.toml index 8553bdb..04d7bd7 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..63f4f49 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,11 +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. + # 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 os.path.lexists(str(sock_path)): - args.extend(["--ro-bind", "/dev/null", str(sock_path)]) + pattern = str(expand_path(sock)) + for match in glob.glob(pattern): + sock_real = os.path.realpath(match) + if sock_real in seen_socks: + continue + 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) @@ -176,7 +190,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(): @@ -188,7 +203,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/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 32b55d9..826c73e 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -175,6 +175,48 @@ 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() + 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() @@ -208,7 +250,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):