Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ on:
pull_request:
branches: [main]

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`,
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
27 changes: 21 additions & 6 deletions src/project_wrap/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import glob
import os
import shlex
import subprocess
Expand Down Expand Up @@ -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",
]


Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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():
Expand All @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions src/project_wrap/templates/project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
44 changes: 43 additions & 1 deletion tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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):
Expand Down
Loading