projectwrap 202604.2__tar.gz → 202604.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: projectwrap
3
- Version: 202604.2
3
+ Version: 202604.3
4
4
  Summary: Isolated project environments with bubblewrap sandboxing
5
5
  License: MIT
6
6
  Keywords: sandbox,bubblewrap,bwrap,isolation,development,security
@@ -59,7 +59,9 @@ _absolutely no warranty_.
59
59
  - Protect you from `root`
60
60
 
61
61
  **Dependencies:** Python 3.11+ (stdlib only, no pip dependencies),
62
- [bubblewrap](https://github.com/containers/bubblewrap) for sandboxing,
62
+ [bubblewrap](https://github.com/containers/bubblewrap) ≥ 0.4 for sandboxing
63
+ (encrypted volumes need `--unshare-user` / `--uid` support to drop back to
64
+ the real uid inside the sandbox),
63
65
  [gocryptfs](https://nuetzlich.net/gocryptfs/) for encrypted volumes.
64
66
 
65
67
  **Design principles:**
@@ -113,6 +115,8 @@ blacklist = [ # can't be accessed at all from the sandbox
113
115
  "~/.aws",
114
116
  "~/.ssh",
115
117
  "~/projects/", # hide all other projects
118
+ # trimmed for brevity — see the generated template (`pwrap --new`) for
119
+ # the full default blacklist (GCP, Azure, GPG, docker, npm, PyPI, /mnt).
116
120
  ]
117
121
  whitelist = [ # exceptions to the blacklist
118
122
  "~/.kube/myproject",
@@ -170,6 +174,95 @@ set -gx AICHAT_CONFIG_DIR vault/aichat
170
174
 
171
175
  ```
172
176
 
177
+ ##### Claude Code in sandbox #####
178
+
179
+ Claude Code writes state to several paths under `~/` (`~/.claude`,
180
+ `~/.claude.json.lock`, `~/.local/state/claude`, `~/.cache/claude-cli-nodejs`,
181
+ `~/.npm`). Rather than making each of these writable, redirect all of Claude's
182
+ state into the project directory:
183
+
184
+ ```toml
185
+ [env]
186
+ CLAUDE_CONFIG_DIR = "vault/claude" # or any writable path inside the sandbox
187
+ ```
188
+
189
+ This gives each project its own Claude state (settings, history, MCP logs) —
190
+ no leakage between sandboxed projects. With an `[encrypted]` volume, Claude's
191
+ state is encrypted at rest.
192
+
193
+ ##### Encrypted vault, minimal sandbox rules #####
194
+
195
+ Minimal sandbox configuration — no custom blacklists or whitelists, no
196
+ `clean_env`. The goal is to keep secrets (kubeconfig, shell history, LLM
197
+ chat logs) encrypted at rest and isolated per project, without locking down
198
+ the rest of the environment.
199
+
200
+ ```toml
201
+ [project]
202
+ name = "myproject"
203
+ dir = "~/projects/myproject"
204
+ shell = "/usr/bin/fish"
205
+
206
+ [sandbox]
207
+ enabled = true
208
+
209
+ [encrypted]
210
+ cipherdir = "encrypted"
211
+ mountpoint = "~/projects/myproject/vault"
212
+
213
+ [env]
214
+ KUBECONFIG = "vault/kubeconfig"
215
+ CLAUDE_CONFIG_DIR = "vault/claude"
216
+ XDG_DATA_HOME = "vault/.config" # fish history, tool state
217
+ ```
218
+
219
+ `init.fish`:
220
+ ```fish
221
+ source .venv/bin/activate.fish
222
+ ```
223
+
224
+ `[sandbox] enabled = true` still applies the security defaults below: home
225
+ is bound read-only, the config dir is blacklisted, the docker socket is
226
+ masked, `/tmp` is a tmpfs, and PID/IPC namespaces are isolated. With no
227
+ custom blacklist/whitelist/writable entries, the only writable paths are
228
+ the project directory and the vault mountpoint — reads from anywhere else
229
+ under home still work, but writes outside those two paths fail. Add entries
230
+ to `writable` to poke rw holes in the read-only home if you need them. The
231
+ encrypted vault holds project-specific secrets and history that disappear
232
+ when the shell exits; `vault/` lives inside the project directory so it's
233
+ writable by default.
234
+
235
+ ##### Maximum isolation #####
236
+
237
+ Default-deny for both filesystem and environment. Nothing is visible or set
238
+ unless explicitly allowed.
239
+
240
+ ```toml
241
+ [project]
242
+ name = "myproject"
243
+ dir = "~/projects/myproject"
244
+ shell = "/usr/bin/fish"
245
+
246
+ [sandbox]
247
+ enabled = true
248
+ clean_env = true # only PATH/HOME/USER/SHELL/TERM/LANG
249
+ blacklist = [
250
+ "~", # hide all dotfiles and home contents
251
+ "/mnt", # WSL: hide Windows drives
252
+ ]
253
+ whitelist = [
254
+ "~/.config/fish", # shell config (read-only)
255
+ "~/.pyenv", # python versions (read-only)
256
+ ]
257
+
258
+ [env]
259
+ XDG_DATA_HOME = ".config" # fish history, tool state → project dir
260
+ ```
261
+
262
+ The project directory is always writable regardless of blacklist. With
263
+ `XDG_DATA_HOME` pointing inside it, fish history and XDG-aware tools write
264
+ their state there instead of the (hidden) home directory.
265
+
173
266
  ##### GUI apps in sandbox #####
174
267
 
175
268
  To run emacs or other GUI apps inside the sandbox on WSL2:
@@ -216,12 +309,9 @@ with an `[encrypted]` section, pointing at the mountpoint. Use it from init
216
309
  scripts or app configs to redirect history/state into the vault without
217
310
  hardcoding paths per project.
218
311
 
219
- **You will appear as root.** Mounting gocryptfs unprivileged requires
220
- `unshare --user --map-root-user`, so `whoami` reports `root` and `id -u`
221
- reports `0` inside the sandbox. This is a user-namespace remapping only —
222
- you have no real privileges on the host and cannot escalate. Your files
223
- remain owned by your real uid. Scripts that gate on `$UID == 0` will
224
- misbehave; check `$PROJECT_WRAP` or `$PWRAP_VAULT_DIR` instead.
312
+ **If `id -u` reports 0 inside the sandbox**, your bubblewrap is too old —
313
+ run `pwrap --check-deps`. Encrypted vaults need `--unshare-user` / `--uid`
314
+ support (bubblewrap ≥ 0.4) to drop back to your real uid.
225
315
 
226
316
  **Multiple terminals** (`shared = false`, default): each terminal gets an
227
317
  independent gocryptfs mount. Writes to different files merge on next
@@ -242,8 +332,7 @@ and the mount is released.
242
332
  pwrap # list projects
243
333
  pwrap myproject # launch project
244
334
  pwrap -v myproject # verbose output
245
- pwrap --new ~/projects/myproject # create config (name from dir)
246
- pwrap --new ~/projects/myproject custom # create with explicit name
335
+ pwrap --new ~/projects/myproject # create config (name = dir basename)
247
336
  pwrap --new --shell /bin/bash ~/projects/x # specify shell
248
337
  pwrap --check-deps # check optional dependencies
249
338
  pwrap --version # show version
@@ -255,12 +344,17 @@ When sandboxing is enabled:
255
344
 
256
345
  - Home is **read-only**; only the project directory is writable
257
346
  - Config directory (`~/.config/pwrap`) is always blacklisted
347
+ - Docker socket (`/run/docker.sock`) masked — `connect()` works on
348
+ ro-bound sockets, so an exposed docker socket is a full escape to root
349
+ - Default template blacklists credential dirs (SSH, GPG, AWS, GCP, Azure,
350
+ Docker, npm, PyPI) and `/mnt` (WSL Windows drives)
258
351
  - PID and IPC namespaces are isolated
259
352
  - TIOCSTI injection blocked automatically on kernels < 6.2
260
- - XDG runtime directory isolated
353
+ - XDG runtime directory isolated (D-Bus, Wayland, keyring sockets)
261
354
  - Sandbox dies with parent process
262
355
  - Encrypted volumes mount in isolated namespace (invisible on host)
263
- - All paths in shell commands are quoted to prevent injection
356
+ - Writable paths are auto-created (as directories) if they don't exist;
357
+ paths that already exist are bound as-is, so a file entry binds that file
264
358
 
265
359
  Run your editor from inside the sandbox if it has any capacity to run
266
360
  linters, hooks, or anything else from the project environment. A
@@ -35,7 +35,9 @@ _absolutely no warranty_.
35
35
  - Protect you from `root`
36
36
 
37
37
  **Dependencies:** Python 3.11+ (stdlib only, no pip dependencies),
38
- [bubblewrap](https://github.com/containers/bubblewrap) for sandboxing,
38
+ [bubblewrap](https://github.com/containers/bubblewrap) ≥ 0.4 for sandboxing
39
+ (encrypted volumes need `--unshare-user` / `--uid` support to drop back to
40
+ the real uid inside the sandbox),
39
41
  [gocryptfs](https://nuetzlich.net/gocryptfs/) for encrypted volumes.
40
42
 
41
43
  **Design principles:**
@@ -89,6 +91,8 @@ blacklist = [ # can't be accessed at all from the sandbox
89
91
  "~/.aws",
90
92
  "~/.ssh",
91
93
  "~/projects/", # hide all other projects
94
+ # trimmed for brevity — see the generated template (`pwrap --new`) for
95
+ # the full default blacklist (GCP, Azure, GPG, docker, npm, PyPI, /mnt).
92
96
  ]
93
97
  whitelist = [ # exceptions to the blacklist
94
98
  "~/.kube/myproject",
@@ -146,6 +150,95 @@ set -gx AICHAT_CONFIG_DIR vault/aichat
146
150
 
147
151
  ```
148
152
 
153
+ ##### Claude Code in sandbox #####
154
+
155
+ Claude Code writes state to several paths under `~/` (`~/.claude`,
156
+ `~/.claude.json.lock`, `~/.local/state/claude`, `~/.cache/claude-cli-nodejs`,
157
+ `~/.npm`). Rather than making each of these writable, redirect all of Claude's
158
+ state into the project directory:
159
+
160
+ ```toml
161
+ [env]
162
+ CLAUDE_CONFIG_DIR = "vault/claude" # or any writable path inside the sandbox
163
+ ```
164
+
165
+ This gives each project its own Claude state (settings, history, MCP logs) —
166
+ no leakage between sandboxed projects. With an `[encrypted]` volume, Claude's
167
+ state is encrypted at rest.
168
+
169
+ ##### Encrypted vault, minimal sandbox rules #####
170
+
171
+ Minimal sandbox configuration — no custom blacklists or whitelists, no
172
+ `clean_env`. The goal is to keep secrets (kubeconfig, shell history, LLM
173
+ chat logs) encrypted at rest and isolated per project, without locking down
174
+ the rest of the environment.
175
+
176
+ ```toml
177
+ [project]
178
+ name = "myproject"
179
+ dir = "~/projects/myproject"
180
+ shell = "/usr/bin/fish"
181
+
182
+ [sandbox]
183
+ enabled = true
184
+
185
+ [encrypted]
186
+ cipherdir = "encrypted"
187
+ mountpoint = "~/projects/myproject/vault"
188
+
189
+ [env]
190
+ KUBECONFIG = "vault/kubeconfig"
191
+ CLAUDE_CONFIG_DIR = "vault/claude"
192
+ XDG_DATA_HOME = "vault/.config" # fish history, tool state
193
+ ```
194
+
195
+ `init.fish`:
196
+ ```fish
197
+ source .venv/bin/activate.fish
198
+ ```
199
+
200
+ `[sandbox] enabled = true` still applies the security defaults below: home
201
+ is bound read-only, the config dir is blacklisted, the docker socket is
202
+ masked, `/tmp` is a tmpfs, and PID/IPC namespaces are isolated. With no
203
+ custom blacklist/whitelist/writable entries, the only writable paths are
204
+ the project directory and the vault mountpoint — reads from anywhere else
205
+ under home still work, but writes outside those two paths fail. Add entries
206
+ to `writable` to poke rw holes in the read-only home if you need them. The
207
+ encrypted vault holds project-specific secrets and history that disappear
208
+ when the shell exits; `vault/` lives inside the project directory so it's
209
+ writable by default.
210
+
211
+ ##### Maximum isolation #####
212
+
213
+ Default-deny for both filesystem and environment. Nothing is visible or set
214
+ unless explicitly allowed.
215
+
216
+ ```toml
217
+ [project]
218
+ name = "myproject"
219
+ dir = "~/projects/myproject"
220
+ shell = "/usr/bin/fish"
221
+
222
+ [sandbox]
223
+ enabled = true
224
+ clean_env = true # only PATH/HOME/USER/SHELL/TERM/LANG
225
+ blacklist = [
226
+ "~", # hide all dotfiles and home contents
227
+ "/mnt", # WSL: hide Windows drives
228
+ ]
229
+ whitelist = [
230
+ "~/.config/fish", # shell config (read-only)
231
+ "~/.pyenv", # python versions (read-only)
232
+ ]
233
+
234
+ [env]
235
+ XDG_DATA_HOME = ".config" # fish history, tool state → project dir
236
+ ```
237
+
238
+ The project directory is always writable regardless of blacklist. With
239
+ `XDG_DATA_HOME` pointing inside it, fish history and XDG-aware tools write
240
+ their state there instead of the (hidden) home directory.
241
+
149
242
  ##### GUI apps in sandbox #####
150
243
 
151
244
  To run emacs or other GUI apps inside the sandbox on WSL2:
@@ -192,12 +285,9 @@ with an `[encrypted]` section, pointing at the mountpoint. Use it from init
192
285
  scripts or app configs to redirect history/state into the vault without
193
286
  hardcoding paths per project.
194
287
 
195
- **You will appear as root.** Mounting gocryptfs unprivileged requires
196
- `unshare --user --map-root-user`, so `whoami` reports `root` and `id -u`
197
- reports `0` inside the sandbox. This is a user-namespace remapping only —
198
- you have no real privileges on the host and cannot escalate. Your files
199
- remain owned by your real uid. Scripts that gate on `$UID == 0` will
200
- misbehave; check `$PROJECT_WRAP` or `$PWRAP_VAULT_DIR` instead.
288
+ **If `id -u` reports 0 inside the sandbox**, your bubblewrap is too old —
289
+ run `pwrap --check-deps`. Encrypted vaults need `--unshare-user` / `--uid`
290
+ support (bubblewrap ≥ 0.4) to drop back to your real uid.
201
291
 
202
292
  **Multiple terminals** (`shared = false`, default): each terminal gets an
203
293
  independent gocryptfs mount. Writes to different files merge on next
@@ -218,8 +308,7 @@ and the mount is released.
218
308
  pwrap # list projects
219
309
  pwrap myproject # launch project
220
310
  pwrap -v myproject # verbose output
221
- pwrap --new ~/projects/myproject # create config (name from dir)
222
- pwrap --new ~/projects/myproject custom # create with explicit name
311
+ pwrap --new ~/projects/myproject # create config (name = dir basename)
223
312
  pwrap --new --shell /bin/bash ~/projects/x # specify shell
224
313
  pwrap --check-deps # check optional dependencies
225
314
  pwrap --version # show version
@@ -231,12 +320,17 @@ When sandboxing is enabled:
231
320
 
232
321
  - Home is **read-only**; only the project directory is writable
233
322
  - Config directory (`~/.config/pwrap`) is always blacklisted
323
+ - Docker socket (`/run/docker.sock`) masked — `connect()` works on
324
+ ro-bound sockets, so an exposed docker socket is a full escape to root
325
+ - Default template blacklists credential dirs (SSH, GPG, AWS, GCP, Azure,
326
+ Docker, npm, PyPI) and `/mnt` (WSL Windows drives)
234
327
  - PID and IPC namespaces are isolated
235
328
  - TIOCSTI injection blocked automatically on kernels < 6.2
236
- - XDG runtime directory isolated
329
+ - XDG runtime directory isolated (D-Bus, Wayland, keyring sockets)
237
330
  - Sandbox dies with parent process
238
331
  - Encrypted volumes mount in isolated namespace (invisible on host)
239
- - All paths in shell commands are quoted to prevent injection
332
+ - Writable paths are auto-created (as directories) if they don't exist;
333
+ paths that already exist are bound as-is, so a file entry binds that file
240
334
 
241
335
  Run your editor from inside the sandbox if it has any capacity to run
242
336
  linters, hooks, or anything else from the project environment. A
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "projectwrap"
3
- version = "202604.2"
3
+ version = "202604.3"
4
4
  description = "Isolated project environments with bubblewrap sandboxing"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -54,4 +54,8 @@ strict = true
54
54
 
55
55
  [tool.pytest.ini_options]
56
56
  testpaths = ["tests"]
57
- addopts = "-v"
57
+ addopts = "-v"
58
+ markers = [
59
+ "integration: end-to-end tests that exec bwrap/gocryptfs/unshare (opt-in)",
60
+ "real_probe: tests that exercise the real bwrap feature probe (not stubbed)",
61
+ ]
@@ -118,13 +118,17 @@ def build_bwrap_args(
118
118
  uid = os.getuid()
119
119
  args.extend(["--tmpfs", f"/run/user/{uid}"])
120
120
 
121
+ # Mask docker socket — connect() works on ro-bound sockets, so this
122
+ # is a full sandbox escape to root if docker is running
123
+ args.extend(["--ro-bind-try", "/dev/null", "/run/docker.sock"])
124
+
121
125
  # Always blacklist the config directory (prevents reading other project configs
122
126
  # or modifying sandbox rules from inside the sandbox)
123
127
  config_dir_resolved = get_config_dir().resolve()
124
128
  if config_dir_resolved.exists():
125
129
  args.extend(["--tmpfs", str(config_dir_resolved)])
126
130
 
127
- # Blacklist paths by overlaying with tmpfs
131
+ # Blacklist paths by overlaying with tmpfs (dirs) or /dev/null (files)
128
132
  blacklist_paths: list[Path] = [config_dir_resolved]
129
133
  for path in sandbox.get("blacklist", []):
130
134
  p = expand_path(path)
@@ -135,7 +139,10 @@ def build_bwrap_args(
135
139
  )
136
140
  # bwrap can't mount tmpfs over symlinks — resolve to the real path
137
141
  mount_path = p.resolve()
138
- args.extend(["--tmpfs", str(mount_path)])
142
+ if mount_path.is_file():
143
+ args.extend(["--ro-bind", "/dev/null", str(mount_path)])
144
+ else:
145
+ args.extend(["--tmpfs", str(mount_path)])
139
146
  blacklist_paths.append(mount_path)
140
147
 
141
148
  # Whitelist paths by binding them back (must be under a blacklisted path)
@@ -156,10 +163,7 @@ def build_bwrap_args(
156
163
  for path in sandbox.get("writable", []):
157
164
  p = expand_path(path)
158
165
  if not p.exists():
159
- raise SystemExit(
160
- f"Writable path does not exist: {p}\n"
161
- f"Fix your config or remove this entry."
162
- )
166
+ p.mkdir(parents=True, exist_ok=True)
163
167
  mount_path = p.resolve()
164
168
  args.extend(["--bind", str(mount_path), str(mount_path)])
165
169
 
@@ -204,6 +208,14 @@ def build_bwrap_args(
204
208
  if sandbox.get("unshare_pid", True):
205
209
  args.append("--unshare-pid")
206
210
 
211
+ # Clean environment: clear everything, pass through essentials
212
+ if sandbox.get("clean_env", False):
213
+ args.append("--clearenv")
214
+ for var in ("PATH", "HOME", "USER", "SHELL", "TERM", "LANG"):
215
+ val = os.environ.get(var)
216
+ if val is not None:
217
+ args.extend(["--setenv", var, val])
218
+
207
219
  # Set environment variables
208
220
  args.extend(["--setenv", "PROJECT_WRAP", "1"])
209
221
 
@@ -0,0 +1,159 @@
1
+ """Dependency checking for optional external tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass, field
9
+ from enum import Enum
10
+
11
+
12
+ class DepStatus(Enum):
13
+ """Dependency availability status."""
14
+
15
+ AVAILABLE = "available"
16
+ MISSING = "missing"
17
+ UNSUPPORTED = "unsupported"
18
+
19
+
20
+ @dataclass
21
+ class Dependency:
22
+ """External dependency information."""
23
+
24
+ name: str
25
+ binary: str
26
+ install_hint: str
27
+ required_for: str
28
+ # Optional feature probe: returns (ok, reason). Runs only if the binary
29
+ # is found on PATH. `reason` is shown to the user on failure.
30
+ feature_probe: Callable[[str], tuple[bool, str]] | None = field(
31
+ default=None, repr=False
32
+ )
33
+
34
+ def check(self) -> tuple[DepStatus, str]:
35
+ """Check if dependency is available and satisfies feature requirements.
36
+
37
+ Returns (status, detail). `detail` is empty on success, otherwise a
38
+ human-readable reason (missing flag, install hint, etc).
39
+ """
40
+ path = shutil.which(self.binary)
41
+ if not path:
42
+ return DepStatus.MISSING, ""
43
+ if self.feature_probe is not None:
44
+ ok, reason = self.feature_probe(path)
45
+ if not ok:
46
+ return DepStatus.UNSUPPORTED, reason
47
+ return DepStatus.AVAILABLE, ""
48
+
49
+
50
+ _bwrap_probe_cache: tuple[bool, str] | None = None
51
+
52
+
53
+ def _probe_bwrap(path: str) -> tuple[bool, str]:
54
+ """Check that bwrap supports --unshare-user and --uid.
55
+
56
+ These flags are required by the vault path (vault.py:_inject_uid) to
57
+ drop from the outer user-ns "root" back to the real uid inside the
58
+ sandbox. Result is cached per-process.
59
+ """
60
+ global _bwrap_probe_cache
61
+ if _bwrap_probe_cache is not None:
62
+ return _bwrap_probe_cache
63
+
64
+ try:
65
+ result = subprocess.run(
66
+ [path, "--help"],
67
+ capture_output=True,
68
+ text=True,
69
+ timeout=5,
70
+ )
71
+ except (OSError, subprocess.TimeoutExpired) as e:
72
+ _bwrap_probe_cache = (False, f"could not run '{path} --help': {e}")
73
+ return _bwrap_probe_cache
74
+
75
+ help_text = result.stdout + result.stderr
76
+ missing = [f for f in ("--unshare-user", "--uid") if f not in help_text]
77
+ if missing:
78
+ _bwrap_probe_cache = (
79
+ False,
80
+ f"bwrap is missing required flags: {', '.join(missing)}. "
81
+ f"Upgrade bubblewrap (>= 0.4) — encrypted vaults need these to "
82
+ f"drop back to your real uid inside the sandbox.",
83
+ )
84
+ return _bwrap_probe_cache
85
+
86
+ _bwrap_probe_cache = (True, "")
87
+ return _bwrap_probe_cache
88
+
89
+
90
+ # Known optional dependencies
91
+ DEPS = {
92
+ "bwrap": Dependency(
93
+ name="bubblewrap",
94
+ binary="bwrap",
95
+ install_hint="sudo apt install bubblewrap",
96
+ required_for="sandbox isolation",
97
+ feature_probe=_probe_bwrap,
98
+ ),
99
+ "gocryptfs": Dependency(
100
+ name="gocryptfs",
101
+ binary="gocryptfs",
102
+ install_hint="sudo apt install gocryptfs",
103
+ required_for="encrypted volumes",
104
+ ),
105
+ }
106
+
107
+
108
+ class MissingDependencyError(Exception):
109
+ """Raised when a required dependency is not available."""
110
+
111
+ def __init__(self, dep: Dependency):
112
+ self.dep = dep
113
+ super().__init__(
114
+ f"{dep.name} is required for {dep.required_for} but is not installed.\n"
115
+ f"Install with: {dep.install_hint}"
116
+ )
117
+
118
+
119
+ class UnsupportedDependencyError(Exception):
120
+ """Raised when a dependency is installed but lacks a required feature."""
121
+
122
+ def __init__(self, dep: Dependency, reason: str):
123
+ self.dep = dep
124
+ self.reason = reason
125
+ super().__init__(f"{dep.name}: {reason}")
126
+
127
+
128
+ def require_dep(name: str) -> None:
129
+ """Ensure a dependency is available, raise if not."""
130
+ dep = DEPS.get(name)
131
+ if dep is None:
132
+ raise ValueError(f"Unknown dependency: {name}")
133
+
134
+ status, detail = dep.check()
135
+ if status == DepStatus.MISSING:
136
+ raise MissingDependencyError(dep)
137
+ if status == DepStatus.UNSUPPORTED:
138
+ raise UnsupportedDependencyError(dep, detail)
139
+
140
+
141
+ def check_optional_deps(verbose: bool = False) -> dict[str, DepStatus]:
142
+ """Check all optional dependencies and return their status."""
143
+ results = {}
144
+
145
+ for name, dep in DEPS.items():
146
+ status, detail = dep.check()
147
+ results[name] = status
148
+
149
+ if verbose:
150
+ icon = "✓" if status == DepStatus.AVAILABLE else "✗"
151
+ status_text = status.value
152
+ print(f" {icon} {dep.name} ({dep.binary}): {status_text}")
153
+ if status == DepStatus.MISSING:
154
+ print(f" Install: {dep.install_hint}")
155
+ print(f" Used for: {dep.required_for}")
156
+ elif status == DepStatus.UNSUPPORTED:
157
+ print(f" {detail}")
158
+
159
+ return results
@@ -0,0 +1,48 @@
1
+ [project]
2
+ name = "{name}"
3
+ dir = "{dir}"
4
+ shell = "{shell}"
5
+
6
+ [sandbox]
7
+ enabled = {sandbox_enabled}
8
+ # Note: the config directory (~/.config/pwrap) is always blacklisted automatically.
9
+ blacklist = [ # Paths to hide (overlaid with tmpfs)
10
+ "~/.ssh", # SSH keys and agent config
11
+ "~/.gnupg", # GPG keys
12
+ "~/.aws", # AWS credentials and config
13
+ "~/.kube", # Kubernetes contexts and tokens
14
+ "~/.config/gcloud", # GCP credentials and auth tokens
15
+ "~/.azure", # Azure CLI credentials
16
+ "~/.docker", # Docker config and auth tokens
17
+ "~/.npmrc", # npm auth tokens
18
+ "~/.pypirc", # PyPI upload tokens
19
+ "~/.boto", # Legacy GCS/S3 credentials
20
+ "/mnt", # WSL: Windows drives. Without this, cmd.exe and
21
+ # powershell.exe are callable via binfmt_misc
22
+ # interop — a full sandbox escape.
23
+ ]
24
+ # whitelist = [ # Exceptions to blacklist (bound back read-only)
25
+ # "~/.kube/{name}",
26
+ # "/mnt/wsl", # WSL config — /etc/resolv.conf symlinks here,
27
+ # # so DNS breaks without it when /mnt is blacklisted.
28
+ # # Add "/mnt/wslg/..." writable for GUI apps.
29
+ # ]
30
+ # writable = [ # Extra writable paths (home is read-only)
31
+ # "~/.pyenv/shims",
32
+ # ]
33
+ # unshare_net = false # Isolate network namespace
34
+ # unshare_pid = true # Isolate PID namespace (default: true)
35
+ # new_session = true # TIOCSTI protection (auto on kernels < 6.2)
36
+ # clean_env = false # Clear env, pass through only PATH/HOME/USER/
37
+ # # SHELL/TERM/LANG. Add others via [env].
38
+
39
+ # [env] # environment variables (set before shell starts)
40
+ # XDG_DATA_HOME = "vault/.config" # ~ expanded for values starting with ~/
41
+ # AICHAT_CONFIG_DIR = "~/.local/share/aichat"
42
+
43
+ # [encrypted] # gocryptfs encrypted volume (sandbox only)
44
+ # cipherdir = "encrypted" # Relative to config dir, or absolute
45
+ # mountpoint = "~/.local/share/myapp" # Where decrypted files appear
46
+ # shared = false # true = share one mount across terminals; first
47
+ # # terminal is primary, attached terminals die with it.
48
+ # Inside the sandbox, $PWRAP_VAULT_DIR points at the mountpoint.
@@ -88,6 +88,7 @@ _SCHEMA: dict[str, dict[str, type]] = {
88
88
  "unshare_net": bool,
89
89
  "unshare_pid": bool,
90
90
  "new_session": bool,
91
+ "clean_env": bool,
91
92
  "writable": list,
92
93
  },
93
94
  "encrypted": {"cipherdir": str, "mountpoint": str, "shared": bool},
@@ -228,7 +228,10 @@ def _run_single(config: VaultConfig, bwrap_argv: list[str]) -> int:
228
228
  return 130
229
229
  os.set_inheritable(lock_fd, True)
230
230
 
231
- bwrap_cmd = " ".join(shlex.quote(a) for a in bwrap_argv)
231
+ # Capture real uid/gid before entering --map-root-user namespace; inject
232
+ # into bwrap so the sandbox shell drops back to the real user identity.
233
+ bwrap_with_uid = _inject_uid(bwrap_argv, os.getuid(), os.getgid())
234
+ bwrap_cmd = " ".join(shlex.quote(a) for a in bwrap_with_uid)
232
235
  inner = (
233
236
  f"gocryptfs {shlex.quote(str(config.cipherdir))} "
234
237
  f"{shlex.quote(str(config.mountpoint))} && "
@@ -242,6 +245,31 @@ def _run_single(config: VaultConfig, bwrap_argv: list[str]) -> int:
242
245
  return 1 # unreachable
243
246
 
244
247
 
248
+ def _inject_uid(bwrap_argv: list[str], uid: int, gid: int) -> list[str]:
249
+ """Inject --unshare-user --uid --gid so bwrap drops back to the real uid.
250
+
251
+ User-namespace mapping chain for the vault path:
252
+
253
+ 1. Outer: `unshare --user --map-root-user` maps real uid -> 0 so we can
254
+ mount gocryptfs (FUSE requires "root" in the user ns).
255
+ 2. gocryptfs mounts; files inside it are owned by uid 0 in the outer ns
256
+ (= the real uid on the host kernel).
257
+ 3. bwrap creates a nested user ns via `--unshare-user` and writes a
258
+ uid_map of {outer 0 -> nested REAL_UID}. We can only do this because
259
+ we are "root" in the outer ns, which can write arbitrary mappings
260
+ for a child ns.
261
+ 4. Processes inside the sandbox see files owned by REAL_UID and
262
+ `id -u` / `whoami` report the real user — not root.
263
+
264
+ Requires bwrap >= 0.4 with --unshare-user and --uid support; this is
265
+ verified at startup by deps._probe_bwrap. The end-to-end chain is
266
+ exercised by tests/test_vault_integration.py.
267
+ """
268
+ argv = list(bwrap_argv)
269
+ argv[1:1] = ["--unshare-user", "--uid", str(uid), "--gid", str(gid)]
270
+ return argv
271
+
272
+
245
273
  def _exec_primary_serve(
246
274
  config: VaultConfig, bwrap_argv: list[str], lock_fd: int
247
275
  ) -> None:
@@ -259,6 +287,8 @@ def _exec_primary_serve(
259
287
  "--mountpoint", str(config.mountpoint),
260
288
  "--project", config.project_name,
261
289
  "--sock-path", str(sock),
290
+ "--real-uid", str(os.getuid()),
291
+ "--real-gid", str(os.getgid()),
262
292
  "--bwrap-argv", json.dumps(bwrap_argv),
263
293
  ]
264
294
  os.execvp("unshare", argv)
@@ -325,7 +355,13 @@ def _inject_token(bwrap_argv: list[str], token: str) -> list[str]:
325
355
  return argv
326
356
 
327
357
 
328
- def serve(config: VaultConfig, sock_path: Path, bwrap_argv: list[str]) -> None:
358
+ def serve(
359
+ config: VaultConfig,
360
+ sock_path: Path,
361
+ bwrap_argv: list[str],
362
+ real_uid: int,
363
+ real_gid: int,
364
+ ) -> None:
329
365
  """Primary session: mount, fork primary bwrap, accept attached clients.
330
366
 
331
367
  Called via `python -m project_wrap.vault serve ...` after unshare. Runs in
@@ -359,7 +395,7 @@ def serve(config: VaultConfig, sock_path: Path, bwrap_argv: list[str]) -> None:
359
395
  signal.signal(signal.SIGHUP, handle_signal)
360
396
  signal.signal(signal.SIGCHLD, signal.SIG_DFL)
361
397
 
362
- primary_argv = _inject_token(bwrap_argv, token)
398
+ primary_argv = _inject_uid(_inject_token(bwrap_argv, token), real_uid, real_gid)
363
399
 
364
400
  primary_pid = os.fork()
365
401
  if primary_pid == 0:
@@ -452,7 +488,9 @@ def serve(config: VaultConfig, sock_path: Path, bwrap_argv: list[str]) -> None:
452
488
  pass
453
489
  continue
454
490
 
455
- child_argv = _inject_token(msg["argv"], token)
491
+ child_argv = _inject_uid(
492
+ _inject_token(msg["argv"], token), real_uid, real_gid
493
+ )
456
494
 
457
495
  proxy_pid = os.fork()
458
496
  if proxy_pid == 0:
@@ -546,6 +584,8 @@ def main() -> None:
546
584
  parser.add_argument("--mountpoint", required=True)
547
585
  parser.add_argument("--project", required=True)
548
586
  parser.add_argument("--sock-path", required=True)
587
+ parser.add_argument("--real-uid", type=int, required=True)
588
+ parser.add_argument("--real-gid", type=int, required=True)
549
589
  parser.add_argument("--bwrap-argv", required=True)
550
590
  args = parser.parse_args()
551
591
 
@@ -556,7 +596,7 @@ def main() -> None:
556
596
  shared=True,
557
597
  )
558
598
  bwrap_argv = json.loads(args.bwrap_argv)
559
- serve(config, Path(args.sock_path), bwrap_argv)
599
+ serve(config, Path(args.sock_path), bwrap_argv, args.real_uid, args.real_gid)
560
600
 
561
601
 
562
602
  if __name__ == "__main__":
@@ -1,87 +0,0 @@
1
- """Dependency checking for optional external tools."""
2
-
3
- from __future__ import annotations
4
-
5
- import shutil
6
- from dataclasses import dataclass
7
- from enum import Enum
8
-
9
-
10
- class DepStatus(Enum):
11
- """Dependency availability status."""
12
-
13
- AVAILABLE = "available"
14
- MISSING = "missing"
15
-
16
-
17
- @dataclass
18
- class Dependency:
19
- """External dependency information."""
20
-
21
- name: str
22
- binary: str
23
- install_hint: str
24
- required_for: str
25
-
26
- def check(self) -> DepStatus:
27
- """Check if dependency is available."""
28
- if shutil.which(self.binary):
29
- return DepStatus.AVAILABLE
30
- return DepStatus.MISSING
31
-
32
-
33
- # Known optional dependencies
34
- DEPS = {
35
- "bwrap": Dependency(
36
- name="bubblewrap",
37
- binary="bwrap",
38
- install_hint="sudo apt install bubblewrap",
39
- required_for="sandbox isolation",
40
- ),
41
- "gocryptfs": Dependency(
42
- name="gocryptfs",
43
- binary="gocryptfs",
44
- install_hint="sudo apt install gocryptfs",
45
- required_for="encrypted volumes",
46
- ),
47
- }
48
-
49
-
50
- class MissingDependencyError(Exception):
51
- """Raised when a required dependency is not available."""
52
-
53
- def __init__(self, dep: Dependency):
54
- self.dep = dep
55
- super().__init__(
56
- f"{dep.name} is required for {dep.required_for} but is not installed.\n"
57
- f"Install with: {dep.install_hint}"
58
- )
59
-
60
-
61
- def require_dep(name: str) -> None:
62
- """Ensure a dependency is available, raise if not."""
63
- dep = DEPS.get(name)
64
- if dep is None:
65
- raise ValueError(f"Unknown dependency: {name}")
66
-
67
- if dep.check() == DepStatus.MISSING:
68
- raise MissingDependencyError(dep)
69
-
70
-
71
- def check_optional_deps(verbose: bool = False) -> dict[str, DepStatus]:
72
- """Check all optional dependencies and return their status."""
73
- results = {}
74
-
75
- for name, dep in DEPS.items():
76
- status = dep.check()
77
- results[name] = status
78
-
79
- if verbose:
80
- icon = "✓" if status == DepStatus.AVAILABLE else "✗"
81
- status_text = "available" if status == DepStatus.AVAILABLE else "missing"
82
- print(f" {icon} {dep.name} ({dep.binary}): {status_text}")
83
- if status == DepStatus.MISSING:
84
- print(f" Install: {dep.install_hint}")
85
- print(f" Used for: {dep.required_for}")
86
-
87
- return results
@@ -1,36 +0,0 @@
1
- [project]
2
- name = "{name}"
3
- dir = "{dir}"
4
- shell = "{shell}"
5
-
6
- [sandbox]
7
- enabled = {sandbox_enabled}
8
- # Note: the config directory (~/.config/pwrap) is always blacklisted automatically.
9
- # blacklist = [ # Additional paths to hide (overlaid with tmpfs)
10
- # "~/.kube",
11
- # "~/.aws",
12
- # "~/.ssh",
13
- # ]
14
- # whitelist = [ # Exceptions to blacklist (bound back)
15
- # "~/.kube/{name}",
16
- # ]
17
- # writable = [ # Extra writable paths (home is read-only)
18
- # "~/.pyenv/shims",
19
- # ]
20
- # unshare_net = false # Isolate network namespace
21
- # unshare_pid = true # Isolate PID namespace (default: true)
22
- # new_session = true # TIOCSTI protection (auto on kernels < 6.2)
23
-
24
- # [env] # environment variables (set before shell starts)
25
- # XDG_DATA_HOME = "vault/.config" # ~ expanded for values starting with ~/
26
- # AICHAT_CONFIG_DIR = "~/.local/share/aichat"
27
-
28
- # [encrypted] # gocryptfs encrypted volume (sandbox only)
29
- # cipherdir = "encrypted" # Relative to config dir, or absolute
30
- # mountpoint = "~/.local/share/myapp" # Where decrypted files appear
31
- # shared = false # true = share one mount across terminals; first
32
- # # terminal is primary, attached terminals die with it.
33
- # Inside the sandbox, $PWRAP_VAULT_DIR points at the mountpoint.
34
- # Note: encrypted volumes require unshare --user --map-root-user, so
35
- # `whoami` / `id -u` will report root inside the sandbox (user namespace only,
36
- # not real root).
File without changes