mirror of
https://github.com/osmarks/random-stuff
synced 2026-04-13 21:01:23 +00:00
192 lines
5.2 KiB
Python
Executable File
192 lines
5.2 KiB
Python
Executable File
#!/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())
|