#!/usr/bin/env python3 # Automatically put shell sessions in bwrap when navigating to a project folder. # Supply chain attack mitigation. # Written by GPT-5.4 and adapted slightly by hand. import json import os import subprocess import shutil import sys from pathlib import Path import os CONFIG_PATH = Path.home() / ".config" / "jails.json" MARKER_ENV = "IN_PROJECT_JAIL" ANTIJACK_SYSCALL_FILTER = Path.home() / ".config" / "jail-antijack" # this is not great because it shares the caches, but oh well PROFILES = { "rust": [ ("rw", "~/.cargo/bin"), ("rw", "~/.cargo/git"), ("rw", "~/.cargo/registry"), ("ro", "~/.gitconfig"), ("ro", "~/.rustup") ], "node": [ ("rw", "~/.npm"), ("rw", "~/.cache/node-gyp"), ("ro", "~/.gitconfig") ], "python": [], "gpu": [ # TODO: this is very broad ("rw", "/sys") ] } def load_config() -> dict[str, dict]: with CONFIG_PATH.open("r", encoding="utf-8") as f: res = json.load(f) for path, entry in res.items(): entry["path"] = path return res def resolve_path(p: str) -> Path: return Path(os.path.expandvars(os.path.expanduser(p))).resolve() def find_matching_entry(cwd: Path, config: dict[str, dict]) -> dict | None: best: tuple[int, dict] | None = None for path, entry in config.items(): try: root = resolve_path(str(path)) cwd.relative_to(root) except Exception: continue score = len(root.parts) if best is None or score > best[0]: best = (score, entry) return best[1] if best else None def ensure_dir(path: Path) -> None: path.mkdir(parents=True, exist_ok=True) def build_bwrap_command(entry: dict, cwd: Path, fd: int) -> list[str]: bwrap = shutil.which("bwrap") if not bwrap: print("project-jail: bwrap not found in PATH", file=sys.stderr) sys.exit(1) shell = os.environ.get("SHELL", "/usr/bin/fish") home = Path.home() project_root = resolve_path(str(entry["path"])) state_dir = Path(os.environ.get("XDG_STATE_HOME", home / ".local" / "state")) / "project-jails" sandbox_name = entry.get("name") or project_root.name sandbox_home = state_dir / sandbox_name / os.path.expanduser("~") sandbox_tmp = state_dir / sandbox_name / "tmp" ensure_dir(sandbox_home) ensure_dir(sandbox_tmp) ro_binds = [ "/usr", "/bin", "/sbin", "/lib", "/lib64", "/opt", "/etc", ] dev_binds = [ "/dev/dri", # GPU "/dev/shm", # shared memory ] cmd = [ bwrap, "--unshare-all", "--share-net", "--die-with-parent", "--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--dir", str(sandbox_home), "--setenv", "HOME", str(sandbox_home), "--setenv", "USER", os.environ.get("USER", ""), "--setenv", "LOGNAME", os.environ.get("LOGNAME", ""), "--setenv", MARKER_ENV, "1", "--setenv", "PROJECT_ROOT", str(project_root), "--chdir", str(cwd), "--seccomp", str(fd) ] rw_binds = [] for profile in entry["profile"]: for type, path in PROFILES[profile]: path = str(resolve_path(path)) if type == "rw": rw_binds.append(path) elif type == "ro": ro_binds.append(path) else: assert False for path in ro_binds: if Path(path).exists(): cmd += ["--ro-bind", path, path] for path in rw_binds: if Path(path).exists(): cmd += ["--bind", path, path] for path in dev_binds: if Path(path).exists(): cmd += ["--dev-bind", path, path] # TODO: maybe don't pass all this through runtime_dir = os.environ.get("XDG_RUNTIME_DIR") if runtime_dir and Path(runtime_dir).exists(): cmd += ["--bind", runtime_dir, runtime_dir] cmd += ["--setenv", "XDG_RUNTIME_DIR", runtime_dir] cmd += ["--setenv", "fish_color_cwd", "red"] envs = ["WAYLAND_DISPLAY", "DISPLAY", "DBUS_SESSION_BUS_ADDRESS", "PULSE_SERVER"] for env in envs: if res := os.environ.get(env): cmd += ["--setenv", env, res] # Mount the selected project tree at the same absolute path. cmd += ["--bind", str(project_root), str(project_root)] # Minimal env cleanup. cmd += [ shell, "-i", ] #print(cmd) print(f"-> sandbox profile {entry['profile']} for {entry['name']}") return cmd def main() -> int: if not ANTIJACK_SYSCALL_FILTER.exists(): subprocess.run(["antijack", "-o", ANTIJACK_SYSCALL_FILTER]).check_returncode() if os.environ.get(MARKER_ENV) == "1": return 0 cwd = Path.cwd().resolve() config = load_config() entry = find_matching_entry(cwd, config) if not entry: return 2 # TODO: seccomp filter is not invulnerable and this would ideally be pty-based f = open(ANTIJACK_SYSCALL_FILTER, "rb") fd = f.fileno() os.set_inheritable(fd, True) cmd = build_bwrap_command(entry, cwd, fd) os.execvp(cmd[0], cmd) return 1 if __name__ == "__main__": raise SystemExit(main())