projectwrap 202604.1__py3-none-any.whl

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.
@@ -0,0 +1,3 @@
1
+ """project-wrap: Isolated project environments with bubblewrap sandboxing."""
2
+
3
+ __version__ = "202604.1"
project_wrap/cli.py ADDED
@@ -0,0 +1,115 @@
1
+ """CLI entry point for project-wrap."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from . import __version__
9
+ from .core import (
10
+ create_project,
11
+ ensure_templates,
12
+ get_config_dir,
13
+ list_projects,
14
+ run_project,
15
+ )
16
+ from .deps import check_optional_deps
17
+
18
+
19
+ def main(argv: list[str] | None = None) -> int:
20
+ """Main entry point."""
21
+ parser = argparse.ArgumentParser(
22
+ prog="pwrap",
23
+ description="Isolated project environments with bubblewrap sandboxing",
24
+ )
25
+ parser.add_argument(
26
+ "project",
27
+ nargs="?",
28
+ help="Project name to load",
29
+ )
30
+ parser.add_argument(
31
+ "--new",
32
+ metavar="DIR",
33
+ help="Create a new project config with DIR as the project directory",
34
+ )
35
+ parser.add_argument(
36
+ "--no-sandbox",
37
+ action="store_true",
38
+ help="Disable sandbox in generated config (use with --new)",
39
+ )
40
+ parser.add_argument(
41
+ "--shell",
42
+ metavar="SHELL",
43
+ help="Shell to configure (use with --new, defaults to $SHELL)",
44
+ )
45
+ parser.add_argument(
46
+ "-l",
47
+ "--list",
48
+ action="store_true",
49
+ help="List available projects",
50
+ )
51
+ parser.add_argument(
52
+ "--check-deps",
53
+ action="store_true",
54
+ help="Check availability of optional dependencies",
55
+ )
56
+ parser.add_argument(
57
+ "--version",
58
+ action="version",
59
+ version=f"%(prog)s {__version__}",
60
+ )
61
+ parser.add_argument(
62
+ "-v",
63
+ "--verbose",
64
+ action="store_true",
65
+ help="Verbose output",
66
+ )
67
+
68
+ args = parser.parse_args(argv)
69
+
70
+ try:
71
+ if args.check_deps:
72
+ check_optional_deps(verbose=True)
73
+ return 0
74
+
75
+ if args.new:
76
+ if ensure_templates():
77
+ config_dir = get_config_dir()
78
+ print("Templates created:")
79
+ for name in ["project.tpl.toml", "init.tpl.fish", "init.tpl.sh"]:
80
+ print(f" {config_dir / name}")
81
+ print("Edit these templates, then run pwrap --new again.")
82
+ return 0
83
+
84
+ print(f"Using template {get_config_dir() / 'project.tpl.toml'}")
85
+ config_path = create_project(
86
+ args.new,
87
+ name=args.project or None,
88
+ sandbox=not args.no_sandbox,
89
+ shell=args.shell,
90
+ )
91
+ print(f"Created {config_path}")
92
+ return 0
93
+
94
+ if args.list or not args.project:
95
+ list_projects()
96
+ return 0
97
+
98
+ run_project(args.project, verbose=args.verbose)
99
+ return 0 # Won't reach here if exec succeeds
100
+
101
+ except KeyboardInterrupt:
102
+ print("\nAborted.")
103
+ return 130
104
+ except SystemExit as e:
105
+ if isinstance(e.code, int):
106
+ return e.code
107
+ print(f"Error: {e.code}", file=sys.stderr)
108
+ return 1
109
+ except Exception as e:
110
+ print(f"Error: {e}", file=sys.stderr)
111
+ return 1
112
+
113
+
114
+ if __name__ == "__main__":
115
+ sys.exit(main())
project_wrap/core.py ADDED
@@ -0,0 +1,566 @@
1
+ """Core functionality for project-wrap."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shlex
7
+ import subprocess
8
+ import sys
9
+ import tomllib
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from .deps import require_dep
15
+ from .validate import (
16
+ check_config_permissions,
17
+ tiocsti_vulnerable,
18
+ validate_config,
19
+ validate_project_name,
20
+ validate_shell,
21
+ )
22
+ from .vault import VaultConfig
23
+
24
+
25
+ @dataclass
26
+ class ProjectExec:
27
+ """Everything needed to exec into a project environment."""
28
+
29
+ display_name: str
30
+ program: str
31
+ argv: list[str]
32
+ is_sandboxed: bool = False
33
+ verbose_info: str | None = None
34
+ vault_config: VaultConfig | None = None
35
+
36
+
37
+ def get_config_dir() -> Path:
38
+ """Get the project configuration directory."""
39
+ # Support XDG, fall back to ~/.config
40
+ xdg_config = os.environ.get("XDG_CONFIG_HOME")
41
+ if xdg_config:
42
+ base = Path(xdg_config)
43
+ else:
44
+ base = Path.home() / ".config"
45
+ return base / "pwrap"
46
+
47
+
48
+ def expand_path(path: str) -> Path:
49
+ """Expand ~ and environment variables in path."""
50
+ return Path(os.path.expandvars(os.path.expanduser(path)))
51
+
52
+
53
+
54
+ def load_config(name: str) -> dict[str, Any]:
55
+ """Load project configuration."""
56
+ validate_project_name(name)
57
+ config_dir = get_config_dir()
58
+ project_path = config_dir / name
59
+
60
+ if not project_path.is_dir():
61
+ raise SystemExit(f"Unknown project: {name}")
62
+
63
+ config_file = project_path / "project.toml"
64
+ if not config_file.exists():
65
+ raise SystemExit(f"Missing config: {config_file}")
66
+
67
+ check_config_permissions(config_file)
68
+
69
+ with open(config_file, "rb") as f:
70
+ config: dict[str, Any] = tomllib.load(f)
71
+
72
+ validate_config(config)
73
+ config["_config_dir"] = project_path
74
+ return config
75
+
76
+
77
+
78
+ def build_bwrap_args(
79
+ sandbox: dict[str, Any],
80
+ project_dir: Path,
81
+ init_script: Path | None = None,
82
+ ro_bind_extra: list[Path] | None = None,
83
+ rw_bind_extra: list[Path] | None = None,
84
+ ) -> list[str]:
85
+ """Build bubblewrap command arguments.
86
+
87
+ Args:
88
+ sandbox: Sandbox configuration dict
89
+ project_dir: Project working directory
90
+ init_script: Optional init script to bind-mount read-only into sandbox
91
+ ro_bind_extra: Additional paths to bind-mount read-only
92
+ rw_bind_extra: Additional paths to bind-mount read-write (e.g. encrypted mountpoint)
93
+
94
+ Returns:
95
+ List of bwrap arguments
96
+ """
97
+ args = [
98
+ "bwrap",
99
+ # Base filesystem (read-only root, read-only home, writable project dir)
100
+ "--ro-bind",
101
+ "/",
102
+ "/",
103
+ "--dev",
104
+ "/dev",
105
+ "--proc",
106
+ "/proc",
107
+ "--tmpfs",
108
+ "/tmp",
109
+ "--ro-bind",
110
+ str(Path.home()),
111
+ str(Path.home()),
112
+ # Hardening
113
+ "--die-with-parent",
114
+ "--unshare-ipc",
115
+ ]
116
+
117
+ # Isolate XDG runtime dir (D-Bus, Wayland, SSH/GPG agent sockets)
118
+ uid = os.getuid()
119
+ args.extend(["--tmpfs", f"/run/user/{uid}"])
120
+
121
+ # Always blacklist the config directory (prevents reading other project configs
122
+ # or modifying sandbox rules from inside the sandbox)
123
+ config_dir_resolved = get_config_dir().resolve()
124
+ if config_dir_resolved.exists():
125
+ args.extend(["--tmpfs", str(config_dir_resolved)])
126
+
127
+ # Blacklist paths by overlaying with tmpfs
128
+ blacklist_paths: list[Path] = [config_dir_resolved]
129
+ for path in sandbox.get("blacklist", []):
130
+ p = expand_path(path)
131
+ 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
+ )
136
+ # bwrap can't mount tmpfs over symlinks — resolve to the real path
137
+ mount_path = p.resolve()
138
+ args.extend(["--tmpfs", str(mount_path)])
139
+ blacklist_paths.append(mount_path)
140
+
141
+ # Whitelist paths by binding them back (must be under a blacklisted path)
142
+ for path in sandbox.get("whitelist", []):
143
+ p = expand_path(path)
144
+ if not p.exists():
145
+ continue
146
+ # Resolve once to prevent symlink TOCTOU
147
+ resolved = p.resolve()
148
+ if not any(resolved == bl or bl in resolved.parents for bl in blacklist_paths):
149
+ raise SystemExit(
150
+ f"Whitelist path {p} is not under any blacklisted path. "
151
+ f"Blacklisted: {[str(bl) for bl in blacklist_paths]}"
152
+ )
153
+ args.extend(["--bind", str(resolved), str(resolved)])
154
+
155
+ # 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
+ )
163
+ mount_path = p.resolve()
164
+ args.extend(["--bind", str(mount_path), str(mount_path)])
165
+
166
+ # Project dir writable (after blacklist/whitelist so it's not overwritten)
167
+ args.extend(["--bind", str(project_dir), str(project_dir)])
168
+
169
+ # Bind-mount init script read-only (config dir may be blacklisted)
170
+ if init_script is not None:
171
+ args.extend(["--ro-bind", str(init_script), str(init_script)])
172
+
173
+ # Bind-mount extra read-only paths (e.g. secrets identity file)
174
+ for path in ro_bind_extra or []:
175
+ args.extend(["--ro-bind", str(path), str(path)])
176
+
177
+ # Bind-mount extra read-write paths (e.g. writeback archive)
178
+ for path in rw_bind_extra or []:
179
+ args.extend(["--bind", str(path), str(path)])
180
+
181
+ # TIOCSTI protection (--new-session breaks fish TTY, so only enable when needed)
182
+ vulnerable = tiocsti_vulnerable()
183
+ new_session_cfg = sandbox.get("new_session")
184
+ if new_session_cfg is True:
185
+ args.append("--new-session")
186
+ elif new_session_cfg is None and vulnerable:
187
+ # Default: auto-enable on vulnerable kernels
188
+ args.append("--new-session")
189
+ elif new_session_cfg is False and vulnerable:
190
+ import sys
191
+
192
+ print(
193
+ "Warning: new_session disabled but kernel is vulnerable to TIOCSTI "
194
+ f"(Linux {os.uname().release}). Upgrade to 6.2+ or set "
195
+ "new_session = true.",
196
+ file=sys.stderr,
197
+ )
198
+
199
+ # Network isolation
200
+ if sandbox.get("unshare_net", False):
201
+ args.append("--unshare-net")
202
+
203
+ # PID namespace isolation (default: on)
204
+ if sandbox.get("unshare_pid", True):
205
+ args.append("--unshare-pid")
206
+
207
+ # Set environment variables
208
+ args.extend(["--setenv", "PROJECT_WRAP", "1"])
209
+
210
+ # Set working directory
211
+ args.extend(["--chdir", str(project_dir)])
212
+
213
+ return args
214
+
215
+
216
+ def get_init_script(config_dir: Path, shell: str) -> Path | None:
217
+ """Find shell-specific init script.
218
+
219
+ Looks for init.{shell} (e.g., init.fish) then falls back to init.sh.
220
+ """
221
+ shell_name = Path(shell).name
222
+
223
+ candidates = [
224
+ config_dir / f"init.{shell_name}",
225
+ config_dir / "init.sh",
226
+ ]
227
+
228
+ for candidate in candidates:
229
+ if candidate.exists():
230
+ return candidate
231
+
232
+ return None
233
+
234
+
235
+ def _build_init_commands(
236
+ project_dir: Path,
237
+ config_dir: Path,
238
+ shell: str,
239
+ ) -> list[str]:
240
+ """Build setup commands (cd, init script sourcing).
241
+
242
+ Returns list of shell commands — does NOT include the final exec/shell launch.
243
+ """
244
+ commands: list[str] = []
245
+
246
+ commands.append(f"cd {shlex.quote(str(project_dir))}")
247
+
248
+ # Custom init script
249
+ if init_script := get_init_script(config_dir, shell):
250
+ commands.append(f"source {shlex.quote(str(init_script))}")
251
+
252
+ return commands
253
+
254
+
255
+ def build_shell_argv(
256
+ project_dir: Path,
257
+ config_dir: Path,
258
+ shell: str,
259
+ ) -> list[str]:
260
+ """Build the full argv to launch an interactive shell with project setup.
261
+
262
+ Uses shell-specific mechanisms so setup runs inside the interactive shell:
263
+ - fish: --init-command (runs after config.fish, before prompt)
264
+ - other shells: -c "setup; exec shell"
265
+ """
266
+ commands = _build_init_commands(project_dir, config_dir, shell)
267
+ shell_name = Path(shell).name
268
+
269
+ if shell_name == "fish":
270
+ return [shell, "--init-command", "; ".join(commands)]
271
+
272
+ commands.append(f"exec {shlex.quote(shell)}")
273
+ return [shell, "-c", "; ".join(commands)]
274
+
275
+
276
+ def redact_bwrap_args(args: list[str]) -> list[str]:
277
+ """Redact --setenv values from bwrap args for display."""
278
+ redacted = list(args)
279
+ i = 0
280
+ while i < len(redacted):
281
+ if redacted[i] == "--setenv" and i + 2 < len(redacted):
282
+ redacted[i + 2] = "***"
283
+ i += 3
284
+ else:
285
+ i += 1
286
+ return redacted
287
+
288
+
289
+ def rename_tmux_window(name: str) -> None:
290
+ """Rename current tmux window if in tmux session."""
291
+ if os.environ.get("TMUX"):
292
+ subprocess.run(
293
+ ["tmux", "rename-window", name],
294
+ capture_output=True,
295
+ )
296
+
297
+
298
+ def prepare_project(name: str, verbose: bool = False) -> ProjectExec:
299
+ """Prepare a project environment for execution.
300
+
301
+ Loads config, renames tmux window, and returns everything needed
302
+ to exec into the project shell.
303
+ """
304
+ config = load_config(name)
305
+
306
+ # Extract config sections
307
+ project_cfg = config.get("project", {})
308
+ sandbox_cfg = config.get("sandbox", {})
309
+ encrypted_cfg = config.get("encrypted", {})
310
+ config_dir: Path = config["_config_dir"]
311
+
312
+ # Resolve settings
313
+ display_name = project_cfg.get("name", name)
314
+ project_dir = expand_path(project_cfg.get("dir", f"~/projects/{name}"))
315
+ shell = project_cfg.get("shell", os.environ.get("SHELL", "/bin/bash"))
316
+ validate_shell(shell)
317
+ sandbox_enabled = sandbox_cfg.get("enabled", False)
318
+
319
+ # Verify project directory exists
320
+ if not project_dir.exists():
321
+ raise SystemExit(f"Project directory does not exist: {project_dir}")
322
+
323
+ # Encrypted volumes require sandbox
324
+ if encrypted_cfg and not sandbox_enabled:
325
+ raise SystemExit(
326
+ "[encrypted] requires sandbox to be enabled"
327
+ )
328
+
329
+ # Resolve encrypted volume config
330
+ vault_config: VaultConfig | None = None
331
+ if encrypted_cfg:
332
+ require_dep("gocryptfs")
333
+
334
+ cipherdir_raw = encrypted_cfg["cipherdir"]
335
+ cipherdir_path = expand_path(cipherdir_raw)
336
+ if not cipherdir_path.is_absolute():
337
+ cipherdir_path = config_dir / cipherdir_raw
338
+ cipherdir = cipherdir_path.resolve()
339
+
340
+ if not cipherdir.is_dir():
341
+ raise SystemExit(
342
+ f"Encrypted cipherdir does not exist: {cipherdir}\n"
343
+ f"Initialize it with: mkdir -p '{cipherdir}' && "
344
+ f"gocryptfs -init '{cipherdir}'"
345
+ )
346
+
347
+ mountpoint = expand_path(encrypted_cfg["mountpoint"])
348
+ mountpoint.mkdir(parents=True, exist_ok=True)
349
+
350
+ vault_config = VaultConfig(
351
+ cipherdir=cipherdir,
352
+ mountpoint=mountpoint.resolve(),
353
+ project_name=name,
354
+ shared=encrypted_cfg.get("shared", False),
355
+ )
356
+
357
+ # Rename tmux window
358
+ rename_tmux_window(display_name)
359
+
360
+ # Resolve init script and build shell argv
361
+ init_script = get_init_script(config_dir, shell)
362
+ shell_argv = build_shell_argv(project_dir, config_dir, shell)
363
+
364
+ # Non-sandboxed execution
365
+ if not sandbox_enabled:
366
+ return ProjectExec(
367
+ display_name=display_name,
368
+ program=shell,
369
+ argv=shell_argv,
370
+ )
371
+
372
+ # Sandboxed execution - verify bwrap is available
373
+ require_dep("bwrap")
374
+
375
+ # If encrypted volume, bind the mountpoint into the sandbox
376
+ rw_bind_extra: list[Path] = []
377
+ if vault_config:
378
+ rw_bind_extra = [vault_config.mountpoint]
379
+
380
+ bwrap_args = build_bwrap_args(
381
+ sandbox_cfg, project_dir,
382
+ init_script=init_script, rw_bind_extra=rw_bind_extra,
383
+ )
384
+ if vault_config:
385
+ bwrap_args.extend(["--setenv", "PWRAP_VAULT_DIR", str(vault_config.mountpoint)])
386
+ bwrap_args.extend(shell_argv)
387
+
388
+ verbose_info = None
389
+ if verbose:
390
+ verbose_info = f"Exec: {' '.join(redact_bwrap_args(bwrap_args))}"
391
+
392
+ return ProjectExec(
393
+ display_name=display_name,
394
+ program="bwrap",
395
+ argv=bwrap_args,
396
+ is_sandboxed=True,
397
+ verbose_info=verbose_info,
398
+ vault_config=vault_config,
399
+ )
400
+
401
+
402
+ def run_project(name: str, verbose: bool = False) -> None:
403
+ """Load and run a project environment.
404
+
405
+ This function does not return on success (execs into new shell).
406
+ """
407
+ from .vault import run_vault
408
+
409
+ result = prepare_project(name, verbose)
410
+
411
+ label = result.display_name
412
+ if result.is_sandboxed:
413
+ label += " (sandboxed)"
414
+ if result.vault_config is not None:
415
+ if result.vault_config.shared:
416
+ label += " (shared vault)"
417
+ else:
418
+ label += " (vault)"
419
+ print(f"Loading {label}")
420
+
421
+ if result.verbose_info:
422
+ print(result.verbose_info)
423
+
424
+ if result.vault_config:
425
+ sys.exit(run_vault(result.vault_config, result.argv))
426
+ else:
427
+ os.execvp(result.program, result.argv)
428
+
429
+
430
+
431
+ TEMPLATE_NAMES = ["project.tpl.toml", "init.tpl.fish", "init.tpl.sh"]
432
+
433
+
434
+ def _load_package_template(name: str) -> str:
435
+ """Load a template file from the package templates directory."""
436
+ from importlib.resources import files
437
+
438
+ # Package templates use plain names (project.toml), user templates use .tpl. names
439
+ return (files("project_wrap") / "templates" / name).read_text()
440
+
441
+
442
+ def ensure_templates() -> bool:
443
+ """Ensure user-editable templates exist in the config directory.
444
+
445
+ On first run, copies package templates to ~/.config/pwrap/ with .tpl. names.
446
+ Returns True if templates were just created (caller should pause for editing).
447
+ """
448
+ config_dir = get_config_dir()
449
+ marker = config_dir / "project.tpl.toml"
450
+
451
+ if marker.exists():
452
+ return False
453
+
454
+ config_dir.mkdir(parents=True, exist_ok=True)
455
+
456
+ # Map .tpl. names to package template names
457
+ pkg_names = {"project.tpl.toml": "project.toml", "init.tpl.fish": "init.fish",
458
+ "init.tpl.sh": "init.sh"}
459
+ for tpl_name, pkg_name in pkg_names.items():
460
+ (config_dir / tpl_name).write_text(_load_package_template(pkg_name))
461
+
462
+ return True
463
+
464
+
465
+ def _load_template(name: str) -> str:
466
+ """Load a template, preferring user-editable version over package default.
467
+
468
+ Maps template names: project.toml -> project.tpl.toml, init.fish -> init.tpl.fish
469
+ """
470
+ tpl_name = name.replace(".", ".tpl.", 1) # project.toml -> project.tpl.toml
471
+ user_tpl = get_config_dir() / tpl_name
472
+ if user_tpl.exists():
473
+ return user_tpl.read_text()
474
+ return _load_package_template(name)
475
+
476
+
477
+ def create_project(
478
+ project_dir: str,
479
+ name: str | None = None,
480
+ sandbox: bool = True,
481
+ shell: str | None = None,
482
+ ) -> Path:
483
+ """Create a new project config directory with templates.
484
+
485
+ Args:
486
+ project_dir: Path to the project working directory.
487
+ name: Project name. Defaults to the directory basename.
488
+ sandbox: Whether to enable sandbox in the generated config.
489
+ shell: Shell path. Defaults to $SHELL.
490
+
491
+ Returns the path to the created config directory.
492
+ """
493
+ resolved_dir = expand_path(project_dir).resolve()
494
+ if not resolved_dir.is_dir():
495
+ raise SystemExit(f"Project directory does not exist: {resolved_dir}")
496
+
497
+ if name is None:
498
+ name = resolved_dir.name
499
+
500
+ if shell is None:
501
+ shell = os.environ.get("SHELL", "/bin/bash")
502
+
503
+ validate_project_name(name)
504
+ config_dir = get_config_dir() / name
505
+
506
+ if config_dir.exists():
507
+ raise SystemExit(f"Project already exists: {config_dir}")
508
+
509
+ sandbox_enabled = "true" if sandbox else "false"
510
+
511
+ toml = _load_template("project.toml").format(
512
+ name=name, dir=resolved_dir, sandbox_enabled=sandbox_enabled, shell=shell
513
+ )
514
+
515
+ config_dir.mkdir(parents=True)
516
+ (config_dir / "project.toml").write_text(toml)
517
+
518
+ # Copy matching init template
519
+ shell_name = Path(shell).name
520
+ if shell_name == "fish":
521
+ (config_dir / "init.fish").write_text(_load_template("init.fish"))
522
+ else:
523
+ (config_dir / "init.sh").write_text(_load_template("init.sh"))
524
+
525
+ return config_dir
526
+
527
+
528
+ def list_projects() -> None:
529
+ """List all available projects."""
530
+ config_dir = get_config_dir()
531
+
532
+ if not config_dir.exists():
533
+ print(f"No projects configured. Create configs in: {config_dir}")
534
+ return
535
+
536
+ print("Projects:")
537
+
538
+ items = sorted(config_dir.iterdir())
539
+ if not items:
540
+ print(" (none)")
541
+ return
542
+
543
+ for item in items:
544
+ if item.name.startswith("."):
545
+ continue
546
+
547
+ if item.is_dir():
548
+ # Check for valid config
549
+ config_file = item / "project.toml"
550
+ if config_file.exists():
551
+ # Try to load to show name
552
+ try:
553
+ with open(config_file, "rb") as f:
554
+ cfg = tomllib.load(f)
555
+ display_name = cfg.get("project", {}).get("name", item.name)
556
+ sandboxed = cfg.get("sandbox", {}).get("enabled", False)
557
+ marker = " [sandboxed]" if sandboxed else ""
558
+ print(f" {item.name}/{marker}")
559
+ if display_name != item.name:
560
+ print(f" → {display_name}")
561
+ except Exception:
562
+ print(f" {item.name}/ (invalid config)")
563
+ else:
564
+ print(f" {item.name}/ (missing project.toml)")
565
+
566
+