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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`,
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
17 changes: 13 additions & 4 deletions src/project_wrap/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -176,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():
Expand All @@ -188,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:
Expand Down
21 changes: 20 additions & 1 deletion tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -206,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):
Expand Down
Loading