projectwrap 202604.2__tar.gz → 202604.4__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.4
4
4
  Summary: Isolated project environments with bubblewrap sandboxing
5
5
  License: MIT
6
6
  Keywords: sandbox,bubblewrap,bwrap,isolation,development,security
@@ -32,7 +32,8 @@ infrastructure from your sloppy side project.
32
32
 
33
33
  ### Why ###
34
34
 
35
- I got tired of my ad-hoc fish bwrap scripts, and I'm increasingly worried about supply chain attacks.
35
+ I got tired of my ad-hoc fish bwrap scripts, I'm increasingly worried about supply chain attacks,
36
+ and Claude Code "auto mode" is just not made to run without isolation.
36
37
 
37
38
  If every side project feels like a potential vector — one [npm|pip|cargo] install away from pwned AWS credentials,
38
39
  and custom-wrapping with bwrap and some vault product feels too fragile or too much work, pwrap might help.
@@ -59,7 +60,9 @@ _absolutely no warranty_.
59
60
  - Protect you from `root`
60
61
 
61
62
  **Dependencies:** Python 3.11+ (stdlib only, no pip dependencies),
62
- [bubblewrap](https://github.com/containers/bubblewrap) for sandboxing,
63
+ [bubblewrap](https://github.com/containers/bubblewrap) ≥ 0.4 for sandboxing
64
+ (encrypted volumes need `--unshare-user` / `--uid` support to drop back to
65
+ the real uid inside the sandbox),
63
66
  [gocryptfs](https://nuetzlich.net/gocryptfs/) for encrypted volumes.
64
67
 
65
68
  **Design principles:**
@@ -113,6 +116,8 @@ blacklist = [ # can't be accessed at all from the sandbox
113
116
  "~/.aws",
114
117
  "~/.ssh",
115
118
  "~/projects/", # hide all other projects
119
+ # trimmed for brevity — see the generated template (`pwrap --new`) for
120
+ # the full default blacklist (GCP, Azure, GPG, docker, npm, PyPI, /mnt).
116
121
  ]
117
122
  whitelist = [ # exceptions to the blacklist
118
123
  "~/.kube/myproject",
@@ -170,6 +175,95 @@ set -gx AICHAT_CONFIG_DIR vault/aichat
170
175
 
171
176
  ```
172
177
 
178
+ ##### Claude Code in sandbox #####
179
+
180
+ Claude Code writes state to several paths under `~/` (`~/.claude`,
181
+ `~/.claude.json.lock`, `~/.local/state/claude`, `~/.cache/claude-cli-nodejs`,
182
+ `~/.npm`). Rather than making each of these writable, redirect all of Claude's
183
+ state into the project directory:
184
+
185
+ ```toml
186
+ [env]
187
+ CLAUDE_CONFIG_DIR = "vault/claude" # or any writable path inside the sandbox
188
+ ```
189
+
190
+ This gives each project its own Claude state (settings, history, MCP logs) —
191
+ no leakage between sandboxed projects. With an `[encrypted]` volume, Claude's
192
+ state is encrypted at rest.
193
+
194
+ ##### Encrypted vault, minimal sandbox rules #####
195
+
196
+ Minimal sandbox configuration — no custom blacklists or whitelists, no
197
+ `clean_env`. The goal is to keep secrets (kubeconfig, shell history, LLM
198
+ chat logs) encrypted at rest and isolated per project, without locking down
199
+ the rest of the environment.
200
+
201
+ ```toml
202
+ [project]
203
+ name = "myproject"
204
+ dir = "~/projects/myproject"
205
+ shell = "/usr/bin/fish"
206
+
207
+ [sandbox]
208
+ enabled = true
209
+
210
+ [encrypted]
211
+ cipherdir = "encrypted"
212
+ mountpoint = "~/projects/myproject/vault"
213
+
214
+ [env]
215
+ KUBECONFIG = "vault/kubeconfig"
216
+ CLAUDE_CONFIG_DIR = "vault/claude"
217
+ XDG_DATA_HOME = "vault/.config" # fish history, tool state
218
+ ```
219
+
220
+ `init.fish`:
221
+ ```fish
222
+ source .venv/bin/activate.fish
223
+ ```
224
+
225
+ `[sandbox] enabled = true` still applies the security defaults below: home
226
+ is bound read-only, the config dir is blacklisted, the docker socket is
227
+ masked, `/tmp` is a tmpfs, and PID/IPC namespaces are isolated. With no
228
+ custom blacklist/whitelist/writable entries, the only writable paths are
229
+ the project directory and the vault mountpoint — reads from anywhere else
230
+ under home still work, but writes outside those two paths fail. Add entries
231
+ to `writable` to poke rw holes in the read-only home if you need them. The
232
+ encrypted vault holds project-specific secrets and history that disappear
233
+ when the shell exits; `vault/` lives inside the project directory so it's
234
+ writable by default.
235
+
236
+ ##### Maximum isolation #####
237
+
238
+ Default-deny for both filesystem and environment. Nothing is visible or set
239
+ unless explicitly allowed.
240
+
241
+ ```toml
242
+ [project]
243
+ name = "myproject"
244
+ dir = "~/projects/myproject"
245
+ shell = "/usr/bin/fish"
246
+
247
+ [sandbox]
248
+ enabled = true
249
+ clean_env = true # only PATH/HOME/USER/SHELL/TERM/LANG
250
+ blacklist = [
251
+ "~", # hide all dotfiles and home contents
252
+ "/mnt", # WSL: hide Windows drives
253
+ ]
254
+ whitelist = [
255
+ "~/.config/fish", # shell config (read-only)
256
+ "~/.pyenv", # python versions (read-only)
257
+ ]
258
+
259
+ [env]
260
+ XDG_DATA_HOME = ".config" # fish history, tool state → project dir
261
+ ```
262
+
263
+ The project directory is always writable regardless of blacklist. With
264
+ `XDG_DATA_HOME` pointing inside it, fish history and XDG-aware tools write
265
+ their state there instead of the (hidden) home directory.
266
+
173
267
  ##### GUI apps in sandbox #####
174
268
 
175
269
  To run emacs or other GUI apps inside the sandbox on WSL2:
@@ -216,12 +310,9 @@ with an `[encrypted]` section, pointing at the mountpoint. Use it from init
216
310
  scripts or app configs to redirect history/state into the vault without
217
311
  hardcoding paths per project.
218
312
 
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.
313
+ **If `id -u` reports 0 inside the sandbox**, your bubblewrap is too old —
314
+ run `pwrap --check-deps`. Encrypted vaults need `--unshare-user` / `--uid`
315
+ support (bubblewrap ≥ 0.4) to drop back to your real uid.
225
316
 
226
317
  **Multiple terminals** (`shared = false`, default): each terminal gets an
227
318
  independent gocryptfs mount. Writes to different files merge on next
@@ -242,8 +333,7 @@ and the mount is released.
242
333
  pwrap # list projects
243
334
  pwrap myproject # launch project
244
335
  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
336
+ pwrap --new ~/projects/myproject # create config (name = dir basename)
247
337
  pwrap --new --shell /bin/bash ~/projects/x # specify shell
248
338
  pwrap --check-deps # check optional dependencies
249
339
  pwrap --version # show version
@@ -255,18 +345,33 @@ When sandboxing is enabled:
255
345
 
256
346
  - Home is **read-only**; only the project directory is writable
257
347
  - Config directory (`~/.config/pwrap`) is always blacklisted
348
+ - Docker sockets masked at `/run/docker.sock`, `/var/run/docker.sock`,
349
+ `~/.docker/desktop/docker-cli.sock`, `~/.docker/run/docker.sock` —
350
+ `connect()` works on ro-bound sockets, so an exposed docker socket is a
351
+ full escape to root. Override via `writable` to enable docker access.
352
+ - Default template blacklists credential dirs (SSH, GPG, AWS, GCP, Azure,
353
+ Docker, npm, PyPI) and `/mnt` (WSL Windows drives)
258
354
  - PID and IPC namespaces are isolated
259
355
  - TIOCSTI injection blocked automatically on kernels < 6.2
260
- - XDG runtime directory isolated
356
+ - XDG runtime directory isolated (D-Bus, Wayland, keyring sockets)
261
357
  - Sandbox dies with parent process
262
358
  - Encrypted volumes mount in isolated namespace (invisible on host)
263
- - All paths in shell commands are quoted to prevent injection
359
+ - Writable and blacklist paths must exist on the host; missing entries
360
+ abort with a single aggregated error listing every missing path
264
361
 
265
362
  Run your editor from inside the sandbox if it has any capacity to run
266
363
  linters, hooks, or anything else from the project environment. A
267
364
  super-protected terminal does nothing if a malicious `.pth` can escape
268
365
  via your linter.
269
366
 
367
+ **Snap-packaged tools won't run inside the sandbox.** `snap-confine` is setuid
368
+ and requires Linux capabilities (`cap_dac_override` and friends) that bwrap
369
+ strips. You'll see errors like `required permitted capability cap_dac_override
370
+ not found in current capabilities`. Prefer apt or upstream installs — e.g. for
371
+ `gh`, use [GitHub's apt repo](https://github.com/cli/cli/blob/trunk/docs/install_linux.md)
372
+ rather than `snap install gh`. Same applies to any snap binary (VS Code,
373
+ Firefox, etc.) you want to use inside a pwrap shell.
374
+
270
375
  #### Shell Completions ####
271
376
 
272
377
  ```bash
@@ -8,7 +8,8 @@ infrastructure from your sloppy side project.
8
8
 
9
9
  ### Why ###
10
10
 
11
- I got tired of my ad-hoc fish bwrap scripts, and I'm increasingly worried about supply chain attacks.
11
+ I got tired of my ad-hoc fish bwrap scripts, I'm increasingly worried about supply chain attacks,
12
+ and Claude Code "auto mode" is just not made to run without isolation.
12
13
 
13
14
  If every side project feels like a potential vector — one [npm|pip|cargo] install away from pwned AWS credentials,
14
15
  and custom-wrapping with bwrap and some vault product feels too fragile or too much work, pwrap might help.
@@ -35,7 +36,9 @@ _absolutely no warranty_.
35
36
  - Protect you from `root`
36
37
 
37
38
  **Dependencies:** Python 3.11+ (stdlib only, no pip dependencies),
38
- [bubblewrap](https://github.com/containers/bubblewrap) for sandboxing,
39
+ [bubblewrap](https://github.com/containers/bubblewrap) ≥ 0.4 for sandboxing
40
+ (encrypted volumes need `--unshare-user` / `--uid` support to drop back to
41
+ the real uid inside the sandbox),
39
42
  [gocryptfs](https://nuetzlich.net/gocryptfs/) for encrypted volumes.
40
43
 
41
44
  **Design principles:**
@@ -89,6 +92,8 @@ blacklist = [ # can't be accessed at all from the sandbox
89
92
  "~/.aws",
90
93
  "~/.ssh",
91
94
  "~/projects/", # hide all other projects
95
+ # trimmed for brevity — see the generated template (`pwrap --new`) for
96
+ # the full default blacklist (GCP, Azure, GPG, docker, npm, PyPI, /mnt).
92
97
  ]
93
98
  whitelist = [ # exceptions to the blacklist
94
99
  "~/.kube/myproject",
@@ -146,6 +151,95 @@ set -gx AICHAT_CONFIG_DIR vault/aichat
146
151
 
147
152
  ```
148
153
 
154
+ ##### Claude Code in sandbox #####
155
+
156
+ Claude Code writes state to several paths under `~/` (`~/.claude`,
157
+ `~/.claude.json.lock`, `~/.local/state/claude`, `~/.cache/claude-cli-nodejs`,
158
+ `~/.npm`). Rather than making each of these writable, redirect all of Claude's
159
+ state into the project directory:
160
+
161
+ ```toml
162
+ [env]
163
+ CLAUDE_CONFIG_DIR = "vault/claude" # or any writable path inside the sandbox
164
+ ```
165
+
166
+ This gives each project its own Claude state (settings, history, MCP logs) —
167
+ no leakage between sandboxed projects. With an `[encrypted]` volume, Claude's
168
+ state is encrypted at rest.
169
+
170
+ ##### Encrypted vault, minimal sandbox rules #####
171
+
172
+ Minimal sandbox configuration — no custom blacklists or whitelists, no
173
+ `clean_env`. The goal is to keep secrets (kubeconfig, shell history, LLM
174
+ chat logs) encrypted at rest and isolated per project, without locking down
175
+ the rest of the environment.
176
+
177
+ ```toml
178
+ [project]
179
+ name = "myproject"
180
+ dir = "~/projects/myproject"
181
+ shell = "/usr/bin/fish"
182
+
183
+ [sandbox]
184
+ enabled = true
185
+
186
+ [encrypted]
187
+ cipherdir = "encrypted"
188
+ mountpoint = "~/projects/myproject/vault"
189
+
190
+ [env]
191
+ KUBECONFIG = "vault/kubeconfig"
192
+ CLAUDE_CONFIG_DIR = "vault/claude"
193
+ XDG_DATA_HOME = "vault/.config" # fish history, tool state
194
+ ```
195
+
196
+ `init.fish`:
197
+ ```fish
198
+ source .venv/bin/activate.fish
199
+ ```
200
+
201
+ `[sandbox] enabled = true` still applies the security defaults below: home
202
+ is bound read-only, the config dir is blacklisted, the docker socket is
203
+ masked, `/tmp` is a tmpfs, and PID/IPC namespaces are isolated. With no
204
+ custom blacklist/whitelist/writable entries, the only writable paths are
205
+ the project directory and the vault mountpoint — reads from anywhere else
206
+ under home still work, but writes outside those two paths fail. Add entries
207
+ to `writable` to poke rw holes in the read-only home if you need them. The
208
+ encrypted vault holds project-specific secrets and history that disappear
209
+ when the shell exits; `vault/` lives inside the project directory so it's
210
+ writable by default.
211
+
212
+ ##### Maximum isolation #####
213
+
214
+ Default-deny for both filesystem and environment. Nothing is visible or set
215
+ unless explicitly allowed.
216
+
217
+ ```toml
218
+ [project]
219
+ name = "myproject"
220
+ dir = "~/projects/myproject"
221
+ shell = "/usr/bin/fish"
222
+
223
+ [sandbox]
224
+ enabled = true
225
+ clean_env = true # only PATH/HOME/USER/SHELL/TERM/LANG
226
+ blacklist = [
227
+ "~", # hide all dotfiles and home contents
228
+ "/mnt", # WSL: hide Windows drives
229
+ ]
230
+ whitelist = [
231
+ "~/.config/fish", # shell config (read-only)
232
+ "~/.pyenv", # python versions (read-only)
233
+ ]
234
+
235
+ [env]
236
+ XDG_DATA_HOME = ".config" # fish history, tool state → project dir
237
+ ```
238
+
239
+ The project directory is always writable regardless of blacklist. With
240
+ `XDG_DATA_HOME` pointing inside it, fish history and XDG-aware tools write
241
+ their state there instead of the (hidden) home directory.
242
+
149
243
  ##### GUI apps in sandbox #####
150
244
 
151
245
  To run emacs or other GUI apps inside the sandbox on WSL2:
@@ -192,12 +286,9 @@ with an `[encrypted]` section, pointing at the mountpoint. Use it from init
192
286
  scripts or app configs to redirect history/state into the vault without
193
287
  hardcoding paths per project.
194
288
 
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.
289
+ **If `id -u` reports 0 inside the sandbox**, your bubblewrap is too old —
290
+ run `pwrap --check-deps`. Encrypted vaults need `--unshare-user` / `--uid`
291
+ support (bubblewrap ≥ 0.4) to drop back to your real uid.
201
292
 
202
293
  **Multiple terminals** (`shared = false`, default): each terminal gets an
203
294
  independent gocryptfs mount. Writes to different files merge on next
@@ -218,8 +309,7 @@ and the mount is released.
218
309
  pwrap # list projects
219
310
  pwrap myproject # launch project
220
311
  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
312
+ pwrap --new ~/projects/myproject # create config (name = dir basename)
223
313
  pwrap --new --shell /bin/bash ~/projects/x # specify shell
224
314
  pwrap --check-deps # check optional dependencies
225
315
  pwrap --version # show version
@@ -231,18 +321,33 @@ When sandboxing is enabled:
231
321
 
232
322
  - Home is **read-only**; only the project directory is writable
233
323
  - Config directory (`~/.config/pwrap`) is always blacklisted
324
+ - Docker sockets masked at `/run/docker.sock`, `/var/run/docker.sock`,
325
+ `~/.docker/desktop/docker-cli.sock`, `~/.docker/run/docker.sock` —
326
+ `connect()` works on ro-bound sockets, so an exposed docker socket is a
327
+ full escape to root. Override via `writable` to enable docker access.
328
+ - Default template blacklists credential dirs (SSH, GPG, AWS, GCP, Azure,
329
+ Docker, npm, PyPI) and `/mnt` (WSL Windows drives)
234
330
  - PID and IPC namespaces are isolated
235
331
  - TIOCSTI injection blocked automatically on kernels < 6.2
236
- - XDG runtime directory isolated
332
+ - XDG runtime directory isolated (D-Bus, Wayland, keyring sockets)
237
333
  - Sandbox dies with parent process
238
334
  - Encrypted volumes mount in isolated namespace (invisible on host)
239
- - All paths in shell commands are quoted to prevent injection
335
+ - Writable and blacklist paths must exist on the host; missing entries
336
+ abort with a single aggregated error listing every missing path
240
337
 
241
338
  Run your editor from inside the sandbox if it has any capacity to run
242
339
  linters, hooks, or anything else from the project environment. A
243
340
  super-protected terminal does nothing if a malicious `.pth` can escape
244
341
  via your linter.
245
342
 
343
+ **Snap-packaged tools won't run inside the sandbox.** `snap-confine` is setuid
344
+ and requires Linux capabilities (`cap_dac_override` and friends) that bwrap
345
+ strips. You'll see errors like `required permitted capability cap_dac_override
346
+ not found in current capabilities`. Prefer apt or upstream installs — e.g. for
347
+ `gh`, use [GitHub's apt repo](https://github.com/cli/cli/blob/trunk/docs/install_linux.md)
348
+ rather than `snap install gh`. Same applies to any snap binary (VS Code,
349
+ Firefox, etc.) you want to use inside a pwrap shell.
350
+
246
351
  #### Shell Completions ####
247
352
 
248
353
  ```bash
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "projectwrap"
3
- version = "202604.2"
3
+ version = "202604.4"
4
4
  description = "Isolated project environments with bubblewrap sandboxing"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -25,7 +25,7 @@ packages = [{ include = "project_wrap", from = "src" }]
25
25
  python = "^3.11"
26
26
 
27
27
  [tool.poetry.group.dev.dependencies]
28
- pytest = "^8.0"
28
+ pytest = ">=8,<10"
29
29
  pytest-cov = "^4.0"
30
30
  ruff = "^0.4"
31
31
  mypy = "^1.0"
@@ -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
+ ]
@@ -0,0 +1,8 @@
1
+ """project-wrap: Isolated project environments with bubblewrap sandboxing."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("projectwrap")
7
+ except PackageNotFoundError:
8
+ __version__ = "0+unknown"
@@ -7,13 +7,12 @@ import sys
7
7
 
8
8
  from . import __version__
9
9
  from .core import (
10
- create_project,
11
- ensure_templates,
12
10
  get_config_dir,
13
11
  list_projects,
14
12
  run_project,
15
13
  )
16
14
  from .deps import check_optional_deps
15
+ from .scaffold import create_project, ensure_templates
17
16
 
18
17
 
19
18
  def main(argv: list[str] | None = None) -> int:
@@ -34,6 +34,14 @@ class ProjectExec:
34
34
  vault_config: VaultConfig | None = None
35
35
 
36
36
 
37
+ _DOCKER_SOCKET_CANDIDATES = [
38
+ "/run/docker.sock",
39
+ "/var/run/docker.sock",
40
+ "~/.docker/desktop/docker-cli.sock",
41
+ "~/.docker/run/docker.sock",
42
+ ]
43
+
44
+
37
45
  def get_config_dir() -> Path:
38
46
  """Get the project configuration directory."""
39
47
  # Support XDG, fall back to ~/.config
@@ -118,24 +126,54 @@ def build_bwrap_args(
118
126
  uid = os.getuid()
119
127
  args.extend(["--tmpfs", f"/run/user/{uid}"])
120
128
 
129
+ # Mask docker sockets — connect() works on ro-bound sockets, so any
130
+ # accessible socket is a sandbox escape to root if docker is running.
131
+ # Cover the common locations; add others via writable to override.
132
+ for sock in _DOCKER_SOCKET_CANDIDATES:
133
+ sock_path = expand_path(sock)
134
+ if os.path.lexists(str(sock_path)):
135
+ args.extend(["--ro-bind", "/dev/null", str(sock_path)])
136
+
121
137
  # Always blacklist the config directory (prevents reading other project configs
122
138
  # or modifying sandbox rules from inside the sandbox)
123
139
  config_dir_resolved = get_config_dir().resolve()
124
140
  if config_dir_resolved.exists():
125
141
  args.extend(["--tmpfs", str(config_dir_resolved)])
126
142
 
127
- # Blacklist paths by overlaying with tmpfs
128
- blacklist_paths: list[Path] = [config_dir_resolved]
143
+ # Validate blacklist and writable up front so the user sees every missing
144
+ # path in one error, rather than one-per-run. Whitelist is "exception to
145
+ # blacklist" and silently skips missing entries by design.
146
+ missing: list[tuple[str, Path]] = []
147
+ blacklist_expanded: list[Path] = []
129
148
  for path in sandbox.get("blacklist", []):
130
149
  p = expand_path(path)
131
150
  if not p.exists():
132
- raise SystemExit(
133
- f"Blacklist path does not exist: {p}\n"
134
- f"Fix your config or remove this entry."
135
- )
151
+ missing.append(("blacklist", p))
152
+ else:
153
+ blacklist_expanded.append(p)
154
+ writable_expanded: list[Path] = []
155
+ for path in sandbox.get("writable", []):
156
+ p = expand_path(path)
157
+ if not p.exists():
158
+ missing.append(("writable", p))
159
+ else:
160
+ writable_expanded.append(p)
161
+ if missing:
162
+ lines = "\n".join(f" [{kind}] {p}" for kind, p in missing)
163
+ raise SystemExit(
164
+ f"Config references paths that do not exist on this host:\n{lines}\n"
165
+ f"Fix your config (comment out the missing entries or adjust the paths)."
166
+ )
167
+
168
+ # Blacklist paths by overlaying with tmpfs (dirs) or /dev/null (files)
169
+ blacklist_paths: list[Path] = [config_dir_resolved]
170
+ for p in blacklist_expanded:
136
171
  # bwrap can't mount tmpfs over symlinks — resolve to the real path
137
172
  mount_path = p.resolve()
138
- args.extend(["--tmpfs", str(mount_path)])
173
+ if mount_path.is_file():
174
+ args.extend(["--ro-bind", "/dev/null", str(mount_path)])
175
+ else:
176
+ args.extend(["--tmpfs", str(mount_path)])
139
177
  blacklist_paths.append(mount_path)
140
178
 
141
179
  # Whitelist paths by binding them back (must be under a blacklisted path)
@@ -153,13 +191,7 @@ def build_bwrap_args(
153
191
  args.extend(["--bind", str(resolved), str(resolved)])
154
192
 
155
193
  # Extra writable paths (e.g. ~/.pyenv/shims, ~/.keychain)
156
- for path in sandbox.get("writable", []):
157
- p = expand_path(path)
158
- 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
- )
194
+ for p in writable_expanded:
163
195
  mount_path = p.resolve()
164
196
  args.extend(["--bind", str(mount_path), str(mount_path)])
165
197
 
@@ -204,6 +236,14 @@ def build_bwrap_args(
204
236
  if sandbox.get("unshare_pid", True):
205
237
  args.append("--unshare-pid")
206
238
 
239
+ # Clean environment: clear everything, pass through essentials
240
+ if sandbox.get("clean_env", False):
241
+ args.append("--clearenv")
242
+ for var in ("PATH", "HOME", "USER", "SHELL", "TERM", "LANG"):
243
+ val = os.environ.get(var)
244
+ if val is not None:
245
+ args.extend(["--setenv", var, val])
246
+
207
247
  # Set environment variables
208
248
  args.extend(["--setenv", "PROJECT_WRAP", "1"])
209
249
 
@@ -438,103 +478,6 @@ def run_project(name: str, verbose: bool = False) -> None:
438
478
 
439
479
 
440
480
 
441
- TEMPLATE_NAMES = ["project.tpl.toml", "init.tpl.fish", "init.tpl.sh"]
442
-
443
-
444
- def _load_package_template(name: str) -> str:
445
- """Load a template file from the package templates directory."""
446
- from importlib.resources import files
447
-
448
- # Package templates use plain names (project.toml), user templates use .tpl. names
449
- return (files("project_wrap") / "templates" / name).read_text()
450
-
451
-
452
- def ensure_templates() -> bool:
453
- """Ensure user-editable templates exist in the config directory.
454
-
455
- On first run, copies package templates to ~/.config/pwrap/ with .tpl. names.
456
- Returns True if templates were just created (caller should pause for editing).
457
- """
458
- config_dir = get_config_dir()
459
- marker = config_dir / "project.tpl.toml"
460
-
461
- if marker.exists():
462
- return False
463
-
464
- config_dir.mkdir(parents=True, exist_ok=True)
465
-
466
- # Map .tpl. names to package template names
467
- pkg_names = {"project.tpl.toml": "project.toml", "init.tpl.fish": "init.fish",
468
- "init.tpl.sh": "init.sh"}
469
- for tpl_name, pkg_name in pkg_names.items():
470
- (config_dir / tpl_name).write_text(_load_package_template(pkg_name))
471
-
472
- return True
473
-
474
-
475
- def _load_template(name: str) -> str:
476
- """Load a template, preferring user-editable version over package default.
477
-
478
- Maps template names: project.toml -> project.tpl.toml, init.fish -> init.tpl.fish
479
- """
480
- tpl_name = name.replace(".", ".tpl.", 1) # project.toml -> project.tpl.toml
481
- user_tpl = get_config_dir() / tpl_name
482
- if user_tpl.exists():
483
- return user_tpl.read_text()
484
- return _load_package_template(name)
485
-
486
-
487
- def create_project(
488
- project_dir: str,
489
- name: str | None = None,
490
- sandbox: bool = True,
491
- shell: str | None = None,
492
- ) -> Path:
493
- """Create a new project config directory with templates.
494
-
495
- Args:
496
- project_dir: Path to the project working directory.
497
- name: Project name. Defaults to the directory basename.
498
- sandbox: Whether to enable sandbox in the generated config.
499
- shell: Shell path. Defaults to $SHELL.
500
-
501
- Returns the path to the created config directory.
502
- """
503
- resolved_dir = expand_path(project_dir).resolve()
504
- if not resolved_dir.is_dir():
505
- raise SystemExit(f"Project directory does not exist: {resolved_dir}")
506
-
507
- if name is None:
508
- name = resolved_dir.name
509
-
510
- if shell is None:
511
- shell = os.environ.get("SHELL", "/bin/bash")
512
-
513
- validate_project_name(name)
514
- config_dir = get_config_dir() / name
515
-
516
- if config_dir.exists():
517
- raise SystemExit(f"Project already exists: {config_dir}")
518
-
519
- sandbox_enabled = "true" if sandbox else "false"
520
-
521
- toml = _load_template("project.toml").format(
522
- name=name, dir=resolved_dir, sandbox_enabled=sandbox_enabled, shell=shell
523
- )
524
-
525
- config_dir.mkdir(parents=True)
526
- (config_dir / "project.toml").write_text(toml)
527
-
528
- # Copy matching init template
529
- shell_name = Path(shell).name
530
- if shell_name == "fish":
531
- (config_dir / "init.fish").write_text(_load_template("init.fish"))
532
- else:
533
- (config_dir / "init.sh").write_text(_load_template("init.sh"))
534
-
535
- return config_dir
536
-
537
-
538
481
  def list_projects() -> None:
539
482
  """List all available projects."""
540
483
  config_dir = get_config_dir()
@@ -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,150 @@
1
+ """Project scaffolding: user-editable templates and new-project creation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from pathlib import Path
8
+
9
+ from . import core
10
+ from .validate import validate_project_name
11
+
12
+ _SCANNED_LIST_KEYS = {"blacklist", "whitelist", "writable"}
13
+ _LIST_START_RE = re.compile(r"^\s*(\w+)\s*=\s*\[")
14
+ _STRING_ENTRY_RE = re.compile(r'^\s*"([^"]*)"')
15
+
16
+
17
+ def _comment_missing_paths(toml_text: str) -> str:
18
+ """Comment out blacklist/whitelist/writable entries whose paths don't exist.
19
+
20
+ Line-based so template comments and formatting survive. Only uncommented
21
+ string entries are considered; already-commented lines are left as-is.
22
+ """
23
+ out: list[str] = []
24
+ in_scanned_list = False
25
+ for line in toml_text.splitlines():
26
+ stripped = line.lstrip()
27
+ if not in_scanned_list:
28
+ m = _LIST_START_RE.match(line)
29
+ if m and m.group(1) in _SCANNED_LIST_KEYS:
30
+ in_scanned_list = True
31
+ out.append(line)
32
+ continue
33
+ if stripped.startswith("]"):
34
+ in_scanned_list = False
35
+ out.append(line)
36
+ continue
37
+ if not stripped or stripped.startswith("#"):
38
+ out.append(line)
39
+ continue
40
+ entry = _STRING_ENTRY_RE.match(line)
41
+ if entry and not core.expand_path(entry.group(1)).exists():
42
+ indent = line[: len(line) - len(stripped)]
43
+ out.append(f"{indent}# {stripped} # path not found on host")
44
+ continue
45
+ out.append(line)
46
+ result = "\n".join(out)
47
+ if toml_text.endswith("\n"):
48
+ result += "\n"
49
+ return result
50
+
51
+
52
+ def _load_package_template(name: str) -> str:
53
+ """Load a template file from the package templates directory."""
54
+ from importlib.resources import files
55
+
56
+ # Package templates use plain names (project.toml), user templates use .tpl. names
57
+ return (files("project_wrap") / "templates" / name).read_text()
58
+
59
+
60
+ def ensure_templates() -> bool:
61
+ """Ensure user-editable templates exist in the config directory.
62
+
63
+ On first run, copies package templates to ~/.config/pwrap/ with .tpl. names.
64
+ Returns True if templates were just created (caller should pause for editing).
65
+ """
66
+ config_dir = core.get_config_dir()
67
+ marker = config_dir / "project.tpl.toml"
68
+
69
+ if marker.exists():
70
+ return False
71
+
72
+ config_dir.mkdir(parents=True, exist_ok=True)
73
+
74
+ # Map .tpl. names to package template names
75
+ pkg_names = {"project.tpl.toml": "project.toml", "init.tpl.fish": "init.fish",
76
+ "init.tpl.sh": "init.sh"}
77
+ for tpl_name, pkg_name in pkg_names.items():
78
+ (config_dir / tpl_name).write_text(_load_package_template(pkg_name))
79
+
80
+ return True
81
+
82
+
83
+ def _load_template(name: str) -> str:
84
+ """Load a template, preferring user-editable version over package default.
85
+
86
+ Maps template names: project.toml -> project.tpl.toml, init.fish -> init.tpl.fish
87
+ """
88
+ tpl_name = name.replace(".", ".tpl.", 1) # project.toml -> project.tpl.toml
89
+ user_tpl = core.get_config_dir() / tpl_name
90
+ if user_tpl.exists():
91
+ return user_tpl.read_text()
92
+ return _load_package_template(name)
93
+
94
+
95
+ def create_project(
96
+ project_dir: str,
97
+ name: str | None = None,
98
+ sandbox: bool = True,
99
+ shell: str | None = None,
100
+ ) -> Path:
101
+ """Create a new project config directory with templates.
102
+
103
+ Args:
104
+ project_dir: Path to the project working directory.
105
+ name: Project name. Defaults to the directory basename.
106
+ sandbox: Whether to enable sandbox in the generated config.
107
+ shell: Shell path. Defaults to $SHELL.
108
+
109
+ Returns the path to the created config directory.
110
+ """
111
+ resolved_dir = core.expand_path(project_dir).resolve()
112
+ if not resolved_dir.is_dir():
113
+ raise SystemExit(f"Project directory does not exist: {resolved_dir}")
114
+
115
+ if name is None:
116
+ name = resolved_dir.name
117
+
118
+ if shell is None:
119
+ shell = os.environ.get("SHELL", "/bin/bash")
120
+
121
+ validate_project_name(name)
122
+ config_dir = core.get_config_dir() / name
123
+
124
+ if config_dir.exists():
125
+ raise SystemExit(f"Project already exists: {config_dir}")
126
+
127
+ sandbox_enabled = "true" if sandbox else "false"
128
+
129
+ toml = _load_template("project.toml").format(
130
+ name=name, dir=resolved_dir, sandbox_enabled=sandbox_enabled, shell=shell
131
+ )
132
+ toml = _comment_missing_paths(toml)
133
+
134
+ config_dir.mkdir(parents=True)
135
+ config_dir.chmod(0o700)
136
+ project_toml = config_dir / "project.toml"
137
+ project_toml.write_text(toml)
138
+ project_toml.chmod(0o600)
139
+
140
+ # Copy matching init template
141
+ shell_name = Path(shell).name
142
+ if shell_name == "fish":
143
+ init_path = config_dir / "init.fish"
144
+ init_path.write_text(_load_template("init.fish"))
145
+ else:
146
+ init_path = config_dir / "init.sh"
147
+ init_path.write_text(_load_template("init.sh"))
148
+ init_path.chmod(0o600)
149
+
150
+ return config_dir
@@ -0,0 +1,54 @@
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
+ # # To enable docker inside the sandbox, uncomment the matching socket
33
+ # # below (this overrides the default docker-socket mask):
34
+ # # "/run/docker.sock", # system docker
35
+ # # "/var/run/docker.sock", # alt system docker
36
+ # # "~/.docker/desktop/docker-cli.sock", # Docker Desktop (Linux)
37
+ # # "~/.docker/run/docker.sock", # Docker Desktop alt
38
+ # ]
39
+ # unshare_net = false # Isolate network namespace
40
+ # unshare_pid = true # Isolate PID namespace (default: true)
41
+ # new_session = true # TIOCSTI protection (auto on kernels < 6.2)
42
+ # clean_env = false # Clear env, pass through only PATH/HOME/USER/
43
+ # # SHELL/TERM/LANG. Add others via [env].
44
+
45
+ # [env] # environment variables (set before shell starts)
46
+ # XDG_DATA_HOME = "vault/.config" # ~ expanded for values starting with ~/
47
+ # AICHAT_CONFIG_DIR = "~/.local/share/aichat"
48
+
49
+ # [encrypted] # gocryptfs encrypted volume (sandbox only)
50
+ # cipherdir = "encrypted" # Relative to config dir, or absolute
51
+ # mountpoint = "~/.local/share/myapp" # Where decrypted files appear
52
+ # shared = false # true = share one mount across terminals; first
53
+ # # terminal is primary, attached terminals die with it.
54
+ # Inside the sandbox, $PWRAP_VAULT_DIR points at the mountpoint.
@@ -65,18 +65,30 @@ def check_config_permissions(config_file: Path) -> None:
65
65
  """Check config file and parent directory permissions, refuse insecure files."""
66
66
  # Check parent directory
67
67
  parent = config_file.parent
68
- parent_mode = parent.stat().st_mode
68
+ parent_mode = parent.stat().st_mode & 0o777
69
69
  if parent_mode & 0o002:
70
- raise SystemExit(f"Config directory is world-writable, refusing to load: {parent}")
70
+ raise SystemExit(
71
+ f"Config directory is world-writable (mode {parent_mode:04o}), "
72
+ f"refusing to load: chmod 0700 {parent}"
73
+ )
71
74
  if parent_mode & 0o020:
72
- raise SystemExit(f"Config directory is group-writable, refusing to load: {parent}")
75
+ raise SystemExit(
76
+ f"Config directory is group-writable (mode {parent_mode:04o}), "
77
+ f"refusing to load: chmod 0700 {parent}"
78
+ )
73
79
 
74
80
  # Check file
75
- mode = config_file.stat().st_mode
81
+ mode = config_file.stat().st_mode & 0o777
76
82
  if mode & 0o002:
77
- raise SystemExit(f"Config file is world-writable, refusing to load: {config_file}")
83
+ raise SystemExit(
84
+ f"Config file is world-writable (mode {mode:04o}), "
85
+ f"refusing to load: chmod 0600 {config_file}"
86
+ )
78
87
  if mode & 0o020:
79
- raise SystemExit(f"Config file is group-writable, refusing to load: {config_file}")
88
+ raise SystemExit(
89
+ f"Config file is group-writable (mode {mode:04o}), "
90
+ f"refusing to load: chmod 0600 {config_file}"
91
+ )
80
92
 
81
93
 
82
94
  _SCHEMA: dict[str, dict[str, type]] = {
@@ -88,6 +100,7 @@ _SCHEMA: dict[str, dict[str, type]] = {
88
100
  "unshare_net": bool,
89
101
  "unshare_pid": bool,
90
102
  "new_session": bool,
103
+ "clean_env": bool,
91
104
  "writable": list,
92
105
  },
93
106
  "encrypted": {"cipherdir": str, "mountpoint": str, "shared": bool},
@@ -72,7 +72,10 @@ def _try_lock(project: str) -> int | None:
72
72
  def _check_concurrent(project: str) -> int | None:
73
73
  """Check if another non-shared session is active.
74
74
 
75
- Returns a lock fd (exclusive or shared). None if the user aborted.
75
+ Returns an fd on the lockfile. If another session already holds LOCK_EX,
76
+ prompt the user; on consent, return an unlocked fd — the primary's
77
+ LOCK_EX is already sufficient for future sessions to detect concurrency
78
+ via _try_lock. None if the user aborted.
76
79
  """
77
80
  fd = _try_lock(project)
78
81
  if fd is not None:
@@ -89,9 +92,8 @@ def _check_concurrent(project: str) -> int | None:
89
92
  print()
90
93
  return None
91
94
 
92
- fd = os.open(str(_lock_path(project)), os.O_CREAT | os.O_RDWR, 0o600)
93
- fcntl.flock(fd, fcntl.LOCK_SH)
94
- return fd
95
+ # LOCK_SH would block on the primary's LOCK_EX — return an unlocked fd.
96
+ return os.open(str(_lock_path(project)), os.O_CREAT | os.O_RDWR, 0o600)
95
97
 
96
98
 
97
99
  # --- fd passing helpers ---
@@ -228,7 +230,10 @@ def _run_single(config: VaultConfig, bwrap_argv: list[str]) -> int:
228
230
  return 130
229
231
  os.set_inheritable(lock_fd, True)
230
232
 
231
- bwrap_cmd = " ".join(shlex.quote(a) for a in bwrap_argv)
233
+ # Capture real uid/gid before entering --map-root-user namespace; inject
234
+ # into bwrap so the sandbox shell drops back to the real user identity.
235
+ bwrap_with_uid = _inject_uid(bwrap_argv, os.getuid(), os.getgid())
236
+ bwrap_cmd = " ".join(shlex.quote(a) for a in bwrap_with_uid)
232
237
  inner = (
233
238
  f"gocryptfs {shlex.quote(str(config.cipherdir))} "
234
239
  f"{shlex.quote(str(config.mountpoint))} && "
@@ -242,6 +247,31 @@ def _run_single(config: VaultConfig, bwrap_argv: list[str]) -> int:
242
247
  return 1 # unreachable
243
248
 
244
249
 
250
+ def _inject_uid(bwrap_argv: list[str], uid: int, gid: int) -> list[str]:
251
+ """Inject --unshare-user --uid --gid so bwrap drops back to the real uid.
252
+
253
+ User-namespace mapping chain for the vault path:
254
+
255
+ 1. Outer: `unshare --user --map-root-user` maps real uid -> 0 so we can
256
+ mount gocryptfs (FUSE requires "root" in the user ns).
257
+ 2. gocryptfs mounts; files inside it are owned by uid 0 in the outer ns
258
+ (= the real uid on the host kernel).
259
+ 3. bwrap creates a nested user ns via `--unshare-user` and writes a
260
+ uid_map of {outer 0 -> nested REAL_UID}. We can only do this because
261
+ we are "root" in the outer ns, which can write arbitrary mappings
262
+ for a child ns.
263
+ 4. Processes inside the sandbox see files owned by REAL_UID and
264
+ `id -u` / `whoami` report the real user — not root.
265
+
266
+ Requires bwrap >= 0.4 with --unshare-user and --uid support; this is
267
+ verified at startup by deps._probe_bwrap. The end-to-end chain is
268
+ exercised by tests/test_vault_integration.py.
269
+ """
270
+ argv = list(bwrap_argv)
271
+ argv[1:1] = ["--unshare-user", "--uid", str(uid), "--gid", str(gid)]
272
+ return argv
273
+
274
+
245
275
  def _exec_primary_serve(
246
276
  config: VaultConfig, bwrap_argv: list[str], lock_fd: int
247
277
  ) -> None:
@@ -259,6 +289,8 @@ def _exec_primary_serve(
259
289
  "--mountpoint", str(config.mountpoint),
260
290
  "--project", config.project_name,
261
291
  "--sock-path", str(sock),
292
+ "--real-uid", str(os.getuid()),
293
+ "--real-gid", str(os.getgid()),
262
294
  "--bwrap-argv", json.dumps(bwrap_argv),
263
295
  ]
264
296
  os.execvp("unshare", argv)
@@ -325,7 +357,13 @@ def _inject_token(bwrap_argv: list[str], token: str) -> list[str]:
325
357
  return argv
326
358
 
327
359
 
328
- def serve(config: VaultConfig, sock_path: Path, bwrap_argv: list[str]) -> None:
360
+ def serve(
361
+ config: VaultConfig,
362
+ sock_path: Path,
363
+ bwrap_argv: list[str],
364
+ real_uid: int,
365
+ real_gid: int,
366
+ ) -> None:
329
367
  """Primary session: mount, fork primary bwrap, accept attached clients.
330
368
 
331
369
  Called via `python -m project_wrap.vault serve ...` after unshare. Runs in
@@ -359,7 +397,7 @@ def serve(config: VaultConfig, sock_path: Path, bwrap_argv: list[str]) -> None:
359
397
  signal.signal(signal.SIGHUP, handle_signal)
360
398
  signal.signal(signal.SIGCHLD, signal.SIG_DFL)
361
399
 
362
- primary_argv = _inject_token(bwrap_argv, token)
400
+ primary_argv = _inject_uid(_inject_token(bwrap_argv, token), real_uid, real_gid)
363
401
 
364
402
  primary_pid = os.fork()
365
403
  if primary_pid == 0:
@@ -452,7 +490,9 @@ def serve(config: VaultConfig, sock_path: Path, bwrap_argv: list[str]) -> None:
452
490
  pass
453
491
  continue
454
492
 
455
- child_argv = _inject_token(msg["argv"], token)
493
+ child_argv = _inject_uid(
494
+ _inject_token(msg["argv"], token), real_uid, real_gid
495
+ )
456
496
 
457
497
  proxy_pid = os.fork()
458
498
  if proxy_pid == 0:
@@ -546,6 +586,8 @@ def main() -> None:
546
586
  parser.add_argument("--mountpoint", required=True)
547
587
  parser.add_argument("--project", required=True)
548
588
  parser.add_argument("--sock-path", required=True)
589
+ parser.add_argument("--real-uid", type=int, required=True)
590
+ parser.add_argument("--real-gid", type=int, required=True)
549
591
  parser.add_argument("--bwrap-argv", required=True)
550
592
  args = parser.parse_args()
551
593
 
@@ -556,7 +598,7 @@ def main() -> None:
556
598
  shared=True,
557
599
  )
558
600
  bwrap_argv = json.loads(args.bwrap_argv)
559
- serve(config, Path(args.sock_path), bwrap_argv)
601
+ serve(config, Path(args.sock_path), bwrap_argv, args.real_uid, args.real_gid)
560
602
 
561
603
 
562
604
  if __name__ == "__main__":
@@ -1,3 +0,0 @@
1
- """project-wrap: Isolated project environments with bubblewrap sandboxing."""
2
-
3
- __version__ = "202604.1"
@@ -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