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.
- {projectwrap-202604.2 → projectwrap-202604.4}/PKG-INFO +118 -13
- {projectwrap-202604.2 → projectwrap-202604.4}/README.md +117 -12
- {projectwrap-202604.2 → projectwrap-202604.4}/pyproject.toml +7 -3
- projectwrap-202604.4/src/project_wrap/__init__.py +8 -0
- {projectwrap-202604.2 → projectwrap-202604.4}/src/project_wrap/cli.py +1 -2
- {projectwrap-202604.2 → projectwrap-202604.4}/src/project_wrap/core.py +54 -111
- projectwrap-202604.4/src/project_wrap/deps.py +159 -0
- projectwrap-202604.4/src/project_wrap/scaffold.py +150 -0
- projectwrap-202604.4/src/project_wrap/templates/project.toml +54 -0
- {projectwrap-202604.2 → projectwrap-202604.4}/src/project_wrap/validate.py +19 -6
- {projectwrap-202604.2 → projectwrap-202604.4}/src/project_wrap/vault.py +51 -9
- projectwrap-202604.2/src/project_wrap/__init__.py +0 -3
- projectwrap-202604.2/src/project_wrap/deps.py +0 -87
- projectwrap-202604.2/src/project_wrap/templates/project.toml +0 -36
- {projectwrap-202604.2 → projectwrap-202604.4}/LICENSE +0 -0
- {projectwrap-202604.2 → projectwrap-202604.4}/src/project_wrap/templates/init.fish +0 -0
- {projectwrap-202604.2 → projectwrap-202604.4}/src/project_wrap/templates/init.sh +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: projectwrap
|
|
3
|
-
Version: 202604.
|
|
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,
|
|
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
|
-
**
|
|
220
|
-
`
|
|
221
|
-
|
|
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
|
|
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
|
-
-
|
|
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,
|
|
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
|
-
**
|
|
196
|
-
`
|
|
197
|
-
|
|
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
|
|
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
|
-
-
|
|
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.
|
|
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 = "
|
|
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
|
+
]
|
|
@@ -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
|
-
#
|
|
128
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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,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
|
|
File without changes
|
|
File without changes
|