diff --git a/README.md b/README.md index 3632137..9a9489a 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Run OpenCode inside a bubblewrap sandbox for security isolation. - **Process Isolation**: Uses unshare PID, IPC, and UTS namespaces - **Controlled Filesystem**: Most system filesystem mounted read-only - **Custom Bind Mounts**: Add read-write or read-only access with `--with` and `--with-ro` +- **Device Access (Opt-In)**: Expose host device nodes with their host mode using `--device` - **Mise Support**: Integrated with [mise](https://mise.jdx.dev) for tool management - **SSH Agent Forwarding**: Supports SSH commit signing through the host `ssh-agent` - **Seccomp Sandbox Filter**: Mitigates kernel privilege escalation vulnerabilities (see [details](#seccomp-sandbox-filter)) @@ -50,6 +51,7 @@ opencodebox [OPTIONS] [OPENCODE_ARGS...] - `--with /host[:/sandbox]` - Bind host path read-write to sandbox - `--with-ro /host[:/sandbox]` - Bind host path read-only to sandbox +- `--device /dev/NAME` - Expose one host device node with its host mode; repeat for more nodes; see [device access](#device-access-opt-in) These options allow you to specify additional directories to mount inside the sandbox for read-write or read-only access. @@ -68,13 +70,16 @@ opencodebox --with /mnt/data:/workspace/data # Run sandboxed opencode with read-only access to config opencodebox --with-ro /etc/hosts +# Run sandboxed opencode with direct access to a serial port and a webcam +opencodebox --device /dev/ttyUSB0 --device /dev/video0 + # Run sandboxed opencode server with specified bind mounts opencodebox --with /data --with-ro /config serve ``` ## How It Works -1. Parse arguments (`--with`, `--with-ro`, `--version`, `--help`) +1. Parse arguments (`--with`, `--with-ro`, `--device`, `--version`, `--help`) 2. Check prerequisites (bwrap and opencode) 3. Load seccomp sandbox filter (see [details](#seccomp-sandbox-filter)) 4. **Enforce security restrictions**: @@ -98,6 +103,7 @@ opencodebox --with /data --with-ro /config serve - `/etc/ca-certificates` - CA certificate store (if present) - `/etc/pki/ca-trust`, `/etc/pki/tls/certs` - Fedora/RHEL trust store and certificate targets for `/etc/ssl` symlinks (if present); `/etc/pki/tls/private` is not mounted - `/etc/alternatives` - System alternatives (managed by update-alternatives) +- `/etc/passwd`, `/etc/group` - Synthetic identity: your user plus `root`, `nobody`, and every group you belong to on the host, so group and owner lookups (`getent`, initgroups) resolve inside the sandbox; ids not mapped into the sandbox's user namespace (any other host user) still display as `65534`/`nobody`; no system credential files are exposed - `$HOME/.local` - User local data (except keyrings/tool data) - `$HOME/.cache/opencode` - OpenCode cache - `$HOME/.ssh/*.pub` - Sanitized OpenSSH public key material, when `$HOME/.ssh` is not a symlink @@ -147,9 +153,145 @@ Each tool is mounted only when `command -v ` succeeds on the host. If the - **Project directory**: Cannot run from `$HOME`, `~/.ssh`, `~/.gnupg`, or their ancestors. Use a dedicated project directory. - **Bind mounts**: `--with` and `--with-ro` reject paths that point to or enclose sensitive locations (`$HOME`, `~/.ssh`, `~/.gnupg`). +- **Device binds**: `--device` accepts only existing character or block device nodes under `/dev`; symlinks, symlinked parent directories, and unsafe path components are rejected. - **State directory**: `~/.local/state/opencode` is mounted read-write so that locks and TUI state stay shared between the sandbox and the host. The launcher masks the host background-service registration inside the sandbox, but a sandboxed process can still create files in that directory, and the host client reads some of them (including the pty runtime registration). Treat that directory as shared state, not as a trust boundary. - **SSH directory**: `~/.ssh` must have permissions `0700`. Fix with: `chmod 700 ~/.ssh` +## Device Access (Opt-In) + +`--device /dev/NAME` exposes an individual host device node to programs running +**directly inside opencodebox**. Repeat the switch for multiple nodes: + +```bash +opencodebox --device /dev/ttyUSB0 +opencodebox --device /dev/video0 --device /dev/snd/controlC0 +# Combine with other options: +opencodebox --device /dev/ttyUSB0 -s SESSION_ID +``` + +When testing a checkout, use `./opencodebox` rather than an older installed +copy, and launch from your normal host environment. No mounts are added to an +already running session. + +### Same mode as on the host + +Each node is bound with bubblewrap's `--dev-bind` at the **same path** inside +the sandbox's private `/dev`. The bind shares the host inode, so the device +keeps **the same mode (permission bits), owner, group, and ACLs as on the +host** — opencodebox never recreates device nodes, changes device permissions, +or grants access the host denies. Verify inside the sandbox: + +```bash +ls -l /dev/ttyUSB0 # same type, mode, and device numbers +test -r /dev/ttyUSB0 && echo readable # access matches the host user +test -w /dev/ttyUSB0 && echo writable +``` + +Owner and group **names** may render as `nobody` — see +[When a bound device cannot be opened](#when-a-bound-device-cannot-be-opened) +for why that display artifact is harmless. + +If your user cannot open the device on the host, fix that through the host's +normal configuration (udev rules, group membership) and relaunch; the sandbox +only inherits host device access. + +### When a bound device cannot be opened + +The bind shares the host inode, and the sandbox keeps your uid, gid, and +supplementary group membership, so an `open()` inside the sandbox is +authorized exactly like on the host: same mode bits, owner, group, POSIX +ACLs, SELinux label, and device-cgroup rules. If a program inside cannot +open the node, your user could not open it on the host either. Check without +`sudo` and without opening or powering up the device: + +```bash +test -r /dev/ttyUSB0 && echo readable +test -w /dev/ttyUSB0 && echo writable +``` + +On servers and over SSH — typical RHEL 9 setups — there is no graphical +login, so systemd-logind grants no per-seat ACLs and device access comes +from group membership: + +| Node | Typical group | +|---|---| +| `/dev/ttyUSB*`, `/dev/ttyACM*` | `dialout` | +| `/dev/video*` | `video` | +| `/dev/snd/*` | `audio` | + +Fix it host-side, then start a new session (reconnect SSH or log out and +back in) so the new group applies, and relaunch: + +```bash +sudo usermod -aG dialout "$USER" +``` + +At launch, opencodebox prints a warning when a requested node's mode bits +deny your user both read and write (for example `root:dialout 0660` while you +are not in `dialout`). Nodes with POSIX ACLs are skipped because their mode +bits do not describe their access; the bind still happens either way. + +#### Why `id` and `ls` look wrong inside the sandbox + +bubblewrap always runs the sandbox in a user namespace that maps only your +own uid and gid. Any *other* id on a bound inode — for example +`root:dialout` on `/dev/ttyACM0` — is unmapped inside, so the kernel reports +it as the overflow id `65534`, which renders as `nobody`: + +```console +$ ls -l /dev/ttyACM0 # host shows: root dialout +crw-rw----. 1 nobody nobody 166, 0 Oct 7 04:25 /dev/ttyACM0 +``` + +This is a display artifact, not a change of ownership or access: + +- The inode is still the host's `root:dialout` node with unchanged mode + bits, device numbers, ACLs, and SELinux label — and the kernel still + authorizes `open()` with your real host uid and group membership. +- No process inside the sandbox can *become* those unmapped ids, so the + artifact never widens access. +- Inside the sandbox, `id` likewise prints your supplementary groups as + `nobody`/`65534`. Do not diagnose device access with `id` or `ls` inside + the sandbox; use `test -r`/`test -w` or the actual device operation. + +opencodebox writes your host groups into the sandbox's `/etc/group` (plus +`root` and `nobody` entries) so lookups by number or name — `getent group +dialout`, initgroups-style membership checks — resolve normally; the +`nobody` rendering of unmapped ids is expected and harmless. + +> **Note:** Upgrading bubblewrap does not change device-access semantics. +> RHEL 9 ships bubblewrap 0.4.1 originally, rebased to 0.6.3 in 2024 +> (`dnf update` does not go further). Every option opencodebox uses — +> including `--seccomp` and `--dev-bind` — exists in 0.4.1, and +> `--dev-bind` behaves the same across versions: the node is exposed with +> its host mode, and host permissions decide whether it can be opened. + +### Validation and behavior + +- Only absolute paths under `/dev` are accepted, and the node must already + exist. Validation failure aborts the launch. +- The node must be a **character or block device** (checked with + `stat -c '%F'`). Regular files, directories, FIFOs, and symlinks are + rejected; resolve symlinks such as `/dev/serial/by-id/...` yourself and pass + the real node (usually `/dev/ttyUSB0`). Symlinked parent directories and + paths with empty, `.`, or `..` components are also refused. +- Parent directories (for example `/dev/snd`) are created inside the sandbox + before the node is bound; repeating an identical `--device` binds it once. +- Before binding, the launcher warns when the node's mode bits deny your + user both read and write on the host (POSIX-ACL nodes are skipped); + the bind still happens. +- The launcher never runs `sudo`, loads drivers, or changes device permissions. +- Host udev, ACL, group, SELinux, and device-cgroup restrictions still apply. + Hotplugged or renumbered nodes need a sandbox restart. Explicit `--with` + mounts still take precedence and can hide or replace device mounts. + +> **Warning:** `--device` exposes everything the node allows on the host. For +> block devices (`/dev/sda`) or privileged nodes (`/dev/mem`, `/dev/kvm`, +> `/dev/net/tun`), that can mean raw storage, kernel, or virtualization access; +> the node's host permissions are the only boundary. The local namespace, +> capability, and seccomp settings are unchanged. Expose the narrowest node +> you need. + ## SSH Agent and Git Signing If `SSH_AUTH_SOCK` points to a valid socket, `opencodebox` forwards that socket into the sandbox. This allows SSH commit signing with keys already loaded by `ssh-add` on the host. This feature does not mount private SSH keys into the sandbox. @@ -204,7 +346,7 @@ bash tests/seccomp.sh # seccomp integration (real bwrap + prebuilt .bpf ``` Launcher tests are black-box: `bwrap` is mocked, the argv it would receive is -asserted. GPU tests (future) plug into `tests/conftest.py` the same way. +asserted. Device tests (`tests/test_device.py`) use the same `tests/conftest.py` harness. ### Seccomp BPF filter diff --git a/opencodebox b/opencodebox index 71595ad..99ee342 100755 --- a/opencodebox +++ b/opencodebox @@ -27,6 +27,7 @@ Usage: Options: --with /host[:/sandbox] Bind host path read-write (sandbox path defaults to host path) --with-ro /host[:/sandbox] Bind host path read-only (sandbox path defaults to host path) + --device /dev/NAME Expose a host device node with the same mode as on the host --version, -v Show version information --help, -h Show this help message @@ -42,6 +43,7 @@ Examples: opencodebox --with ../app-backend opencodebox --with /mnt/data:/workspace/data opencodebox --with-ro /etc/hosts + opencodebox --device /dev/ttyUSB0 --device /dev/video0 opencodebox --with ~/another-project1 --with-ro ~/another-project2 serve EOF exit 0 @@ -163,10 +165,126 @@ sanitize_ssh_public_key_file() { esac } +# BEGIN DEVICE MOUNTS +# Internal root argument allows fixture testing without real device nodes. +# The launcher always calls this with the host's /dev. --dev-bind shares the +# host inode, so the bound node keeps its host mode, owner, group, and ACLs: +# device access inside the sandbox matches the host. + +# Advisory only: the sandbox inherits host device access exactly (same inode, +# same uid/gid and supplementary groups), so a node whose mode bits deny the +# invoking user both read and write cannot be opened inside either. Warn with +# the usual host-side fix instead of failing silently at open() time. Nodes +# with POSIX ACLs (stat '%A' ends in '+') are skipped: their access is not +# described by the mode bits. The node is never opened. Launcher identity +# variables are required; when absent (extracted helper tests) this is skipped. +warn_if_device_inaccessible() { + local path="$1" mode node_uid node_gid perms mode_bits class group_label + [[ -n "${USER_ID:-}" && -n "${USER_NAME:-}" ]] || return 0 + + read -r mode node_uid node_gid perms \ + < <(LC_ALL=C stat -c '%a %u %g %A' "$path" 2>/dev/null) || return 0 + # stat %a prints octal without leading zeros ("0" for mode 000). + [[ "$mode" =~ ^[0-7]{1,4}$ ]] || return 0 + # POSIX ACLs may grant access beyond the mode bits. + [[ "$perms" != *"+"* ]] || return 0 + + # Permission class that applies to the invoking user. + mode_bits=$(( 8#$mode & 8#777 )) + if [[ "$node_uid" == "$USER_ID" ]]; then + class=$(( (mode_bits >> 6) & 7 )) + elif [[ " ${USER_GROUPS:-} " == *" $node_gid "* ]]; then + class=$(( (mode_bits >> 3) & 7 )) + else + class=$(( mode_bits & 7 )) + fi + # Warn only when neither read (4) nor write (2) is possible. + [[ $(( class & 6 )) -eq 0 ]] || return 0 + + if [[ "$node_uid" == "$USER_ID" ]]; then + echo "Warning: --device '$path' is owned by user '$USER_NAME' but its mode ($mode) grants no read or write. Programs inside the sandbox inherit host access and cannot open it. Fix the node's permissions on the host (for example with a udev rule) and relaunch." >&2 + return 0 + fi + + group_line="" + read -r group_line < <(getent group "$node_gid" 2>/dev/null) + group_label="${group_line%%:*}" + [[ -n "$group_label" ]] || group_label="$node_gid" + echo "Warning: --device '$path' is owned by uid $node_uid, group $group_label (mode $mode) and user '$USER_NAME' has no read or write access to it on the host. Programs inside the sandbox inherit host access and cannot open it. Fix host access on the host (e.g. 'sudo usermod -aG $group_label $USER_NAME', then log out and back in) and relaunch." >&2 +} + +configure_device_mounts() { + local dev_root="${1:-/dev}" + shift + local path relative component sub kind + local -a mounts=() components=() + local -A seen_paths=() + local -A seen_dirs=() + + for path in "$@"; do + # Devices live under the host /dev; bind them at the same sandbox path. + if [[ "$path" != "$dev_root"/* ]]; then + echo "Error: --device requires an absolute path under $dev_root (got '$path')." >&2 + return 1 + fi + relative="${path#"$dev_root"/}" + [[ -z "${seen_paths[$relative]:-}" ]] || continue + + # Refuse empty, '.', '..', and '//' path components. + IFS='/' read -ra components <<< "$relative" + for component in "${components[@]}"; do + if [[ -z "$component" || "$component" == "." || "$component" == ".." ]]; then + echo "Error: --device refuses unsafe path '$path'." >&2 + return 1 + fi + done + + # stat without -L does not follow symlinks. Fix locale for its type text. + if [[ -L "$path" ]]; then + echo "Error: --device refuses symlink '$path'; pass the resolved device node instead." >&2 + return 1 + fi + kind="$(LC_ALL=C stat -c '%F' "$path" 2>/dev/null)" || kind="" + if [[ "$kind" != "character special file" && "$kind" != "block special file" ]]; then + echo "Error: --device '$path' is not a character or block device node (stat reports: '${kind:-inaccessible}')." >&2 + return 1 + fi + seen_paths[$relative]=1 + + # Advisory only: the bind below still happens. + warn_if_device_inaccessible "$path" + + # bwrap --dir does not auto-create parents; add each ancestor under /dev. + if [[ ${#components[@]} -gt 1 ]]; then + sub="" + for component in "${components[@]:0:${#components[@]}-1}"; do + sub+="/$component" + if [[ -z "${seen_dirs[$sub]:-}" ]]; then + if [[ -L "$dev_root/$sub" ]]; then + echo "Error: --device refuses symlinked device directory '$dev_root/$sub'." >&2 + return 1 + fi + seen_dirs[$sub]=1 + mounts+=(--dir "/dev$sub") + fi + done + fi + mounts+=(--dev-bind "$path" "/dev/$relative") + done + + if [[ ${#mounts[@]} -eq 0 ]]; then + return 0 + fi + BWRAP_ARGS+=("${mounts[@]}") + echo "Warning: --device exposes host device nodes to the sandbox; each node keeps its host mode and permissions." >&2 +} +# END DEVICE MOUNTS + # Parse --with and --with-ro arguments # These allow users to bind additional paths into the sandbox declare -a EXTRA_BINDS=() declare -a REMAINING_ARGS=() +declare -a DEVICE_PATHS=() add_readonly_regular_file() { local file="$1" @@ -253,6 +371,23 @@ while [[ $# -gt 0 ]]; do parse_bind_arg "--ro-bind" "$1" shift ;; + # --device: expose one host device node (repeat for multiple nodes) + --device) + shift + if [[ $# -eq 0 ]]; then + echo "Error: --device requires a value" >&2 + echo "Usage: opencodebox --device /dev/ttyUSB0" >&2 + exit 1 + fi + # Device paths are absolute under /dev, so a dash can only be a typo. + if [[ "$1" == -* ]]; then + echo "Error: --device requires a device path, not another option (got '$1')." >&2 + echo "Usage: opencodebox --device /dev/ttyUSB0" >&2 + exit 1 + fi + DEVICE_PATHS+=("$1") + shift + ;; # Pass through all other args to opencode *) REMAINING_ARGS+=("$1") @@ -305,8 +440,30 @@ USER_ID="$(id -u)" GROUP_ID="$(id -g)" USER_NAME="$(id -un)" GROUP_NAME="$(id -gn)" -exec {PASSWD_FD}< <(printf '%s:x:%s:%s:%s:%s:/bin/bash\n' "$USER_NAME" "$USER_ID" "$GROUP_ID" "$USER_NAME" "$HOME") -exec {GROUP_FD}< <(printf '%s:x:%s:\n' "$GROUP_NAME" "$GROUP_ID") +# All group IDs the user belongs to (primary first). bwrap's user namespace +# maps only the user's own uid/gid, so `id` inside the sandbox prints +# supplementary groups as 65534 even though the kernel still enforces the +# real host groups on every bound inode (devices included). Writing the host +# groups into /etc/group keeps name-based lookups (getent, ls -l, +# initgroups) consistent inside the sandbox. +USER_GROUPS="$(id -G 2>/dev/null)" +GROUP_ENTRIES="" +for group_id in $USER_GROUPS; do + group_line="" + read -r group_line < <(getent group "$group_id" 2>/dev/null) + group_name="${group_line%%:*}" + [[ -n "$group_name" ]] || group_name="$group_id" + GROUP_ENTRIES+="$(printf '%s:x:%s:%s' "$group_name" "$group_id" "$USER_NAME")"$'\n' +done +# Fallback to the primary group only (pre-existing behavior). +[[ -n "$GROUP_ENTRIES" ]] || GROUP_ENTRIES="$(printf '%s:x:%s:' "$GROUP_NAME" "$GROUP_ID")"$'\n' +# 'root' and 'nobody' (the kernel overflow id, 65534) resolve for name +# lookups. Ids that are not mapped into the sandbox's user namespace — for +# example root/dialout on a bound device node — always render as +# 65534/'nobody' inside; that display artifact does not change access. +exec {PASSWD_FD}< <(printf 'root:x:0:0:root:/root:/sbin/nologin\nnobody:x:65534:65534:Kernel Overflow User:/:/sbin/nologin\n%s:x:%s:%s:%s:%s:/bin/bash\n' \ + "$USER_NAME" "$USER_ID" "$GROUP_ID" "$USER_NAME" "$HOME") +exec {GROUP_FD}< <(printf 'root:x:0:\nnobody:x:65534:\n%s' "$GROUP_ENTRIES") # Restore remaining args for opencode set -- "${REMAINING_ARGS[@]}" @@ -513,6 +670,12 @@ declare -a BWRAP_ARGS=( "$OPCODE_BIND_FLAG" "$OPCODE_ROOT" /opt/opencode ) +# Explicitly requested device nodes follow the private /dev; explicit user +# binds and the isolation overlay are appended later and still override them. +if [[ ${#DEVICE_PATHS[@]} -gt 0 ]]; then + configure_device_mounts /dev "${DEVICE_PATHS[@]}" || exit 1 +fi + # Private keyring (unmount if present) [[ -d "$HOME/.local/share/keyrings" ]] && BWRAP_ARGS+=(--tmpfs "$HOME/.local/share/keyrings") diff --git a/tests/conftest.py b/tests/conftest.py index e6b99e9..839301c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,12 +28,14 @@ def __init__(self, root: Path, bin_dir: Path | None = None): directory.mkdir(parents=True) self.capture = root / "bwrap.json" self.calls = root / "calls.jsonl" + self.files = root / "files.json" self.env = { "HOME": str(self.home), "PATH": str(self.bin), "LC_ALL": "C", "CAPTURE": str(self.capture), "CALLS": str(self.calls), + "FILES": str(self.files), } for name in ("bash", "cat", "dirname", "uname", "id", "stat", "realpath", "mkdir", "mktemp", "mv", "rm"): @@ -41,7 +43,17 @@ def __init__(self, root: Path, bin_dir: Path | None = None): assert target is not None, name (self.bin / name).symlink_to(target) self.command("bwrap", """ -Path(os.environ['CAPTURE']).write_text(json.dumps(sys.argv[1:])) +args = sys.argv[1:] +files = {} +for i, arg in enumerate(args): + if arg == '--file' and i + 2 < len(args): + try: + files[args[i + 2]] = os.read(int(args[i + 1]), 65536).decode() + except (OSError, ValueError): + pass +Path(os.environ['CAPTURE']).write_text(json.dumps(args)) +if files and os.environ.get('FILES'): + Path(os.environ['FILES']).write_text(json.dumps(files)) """) self.command("opencode", """ if sys.argv[1:] == ['--version']: @@ -63,11 +75,14 @@ def command(self, name: str, body: str) -> None: ) path.chmod(0o755) - def run_launcher(self, *args: str, cwd: Path | None = None) -> subprocess.CompletedProcess: + def run_launcher(self, *args: str, cwd: Path | None = None, + script: Path | None = None) -> subprocess.CompletedProcess: + """Run the launcher (or an adapted copy of it) as a black box.""" self.capture.unlink(missing_ok=True) self.calls.unlink(missing_ok=True) + self.files.unlink(missing_ok=True) result = subprocess.run( - [str(self.bin / "bash"), str(LAUNCHER), *args], + [str(self.bin / "bash"), str(script if script is not None else LAUNCHER), *args], cwd=str(cwd or self.project), env=self.env, capture_output=True, diff --git a/tests/test_device.py b/tests/test_device.py new file mode 100644 index 0000000..661673a --- /dev/null +++ b/tests/test_device.py @@ -0,0 +1,454 @@ +"""Device mount contract tests; no real devices are exposed or started. + +Run with: uv run --group dev pytest tests/test_device.py -v +Regular fixture files become character/block devices only to the mocked stat +command. The helper is extracted from the production launcher, never +reimplemented here. +""" + +import json +import os +import shlex +import shutil +import subprocess +from pathlib import Path + +import pytest + +from conftest import LAUNCHER, Launcher + +DEVICE_BEGIN, DEVICE_END = "# BEGIN DEVICE MOUNTS", "# END DEVICE MOUNTS" +DEVICE_CALL = 'configure_device_mounts /dev "${DEVICE_PATHS[@]}" || exit 1' + + +def _symbolic(kind: str, mode: int) -> str: + """Permission string like stat's %A; only the trailing '+' matters.""" + lead = "c" if "character" in kind else "b" + + def triple(shift: int) -> str: + bits = (mode >> shift) & 7 + return ("r" if bits & 4 else "-") + ("w" if bits & 2 else "-") \ + + ("x" if bits & 1 else "-") + + return lead + triple(6) + triple(3) + triple(0) + + +class DeviceLauncher(Launcher): + """Launcher harness plus a fake /dev root for --device nodes. + + A mocked `stat` reports fixture device files as character/block devices + with configurable mode and ownership; every other stat invocation is + delegated to the real stat binary. `id` is mocked so the launch-time + access advisory is deterministic. + """ + + def __init__(self, root: Path): + super().__init__(root) + self.uid = os.getuid() or 12345 + self.dev = self.root / "dev fixture" + self.dev.mkdir() + self.kinds: dict[str, str] = {} + self.access: dict[str, list[str]] = {} + self.env["TEST_UID"] = str(self.uid) + # conftest symlinks the real id; replace it with the fixture mock. + (self.bin / "id").unlink() + self.command("id", """ +values = {'-u': os.environ['TEST_UID'], '-g': '12345', + '-un': 'test-user', '-gn': 'test-group', + '-G': os.environ.get('TEST_GROUPS', '12345')} +print(values[sys.argv[1]]) +""") + # conftest symlinks the real stat; replace it with the fixture mock. + (self.bin / "stat").unlink() + real_stat = shutil.which("stat", path="/usr/bin:/bin") + assert real_stat is not None + self.command("stat", f""" +kinds = json.loads(os.environ['DEVICE_TEST_KINDS']) +access = json.loads(os.environ['DEVICE_TEST_ACCESS']) +fmt = sys.argv[sys.argv.index('-c') + 1] if '-c' in sys.argv else '' +if '%a' in fmt and sys.argv[-1] in access: + print(*access[sys.argv[-1]]) +elif '%F' in fmt and Path(sys.argv[-1]).exists() and sys.argv[-1] in kinds: + print(kinds[sys.argv[-1]]) +else: + os.execv({real_stat!r}, ['stat', *sys.argv[1:]]) +""") + self.env["DEVICE_TEST_KINDS"] = "{}" + self.env["DEVICE_TEST_ACCESS"] = "{}" + + def node(self, name: str, kind: str = "character special file", + mode: int = 0o666, uid: int = 0, gid: int = 0, + acl: bool = False) -> Path: + """Create a fixture file that the mocked stat reports as a device.""" + path = self.dev / name + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + self.kinds[str(path)] = kind + self.access[str(path)] = [f"{mode:o}", str(uid), str(gid), + _symbolic(kind, mode) + ("+" if acl else "")] + self.env["DEVICE_TEST_KINDS"] = json.dumps(self.kinds) + self.env["DEVICE_TEST_ACCESS"] = json.dumps(self.access) + return path + + def device_helper_text(self) -> str: + text = LAUNCHER.read_text() + assert text.count(DEVICE_BEGIN) == 1, "Device helper needs its BEGIN marker" + assert text.count(DEVICE_END) == 1, "Device helper needs its END marker" + helper = text.split(DEVICE_BEGIN, 1)[1].split(DEVICE_END, 1)[0] + assert "configure_device_mounts" in helper + return helper + + def run_device_helper(self, *paths: Path) -> subprocess.CompletedProcess: + script = (self.device_helper_text() + '\nBWRAP_ARGS=(--dev /dev --proc /proc)\n' + + "configure_device_mounts " + + shlex.join([str(self.dev), *map(str, paths)]) + ' || exit 1\n' + + 'exec bwrap "${BWRAP_ARGS[@]}" --\n') + self.capture.unlink(missing_ok=True) + self.calls.unlink(missing_ok=True) + return subprocess.run( + [str(self.bin / "bash"), "-c", script], cwd=str(self.project), + env=self.env, capture_output=True, text=True, timeout=30, + ) + + def run_fixture_launcher(self, *args: str) -> subprocess.CompletedProcess: + """Run the launcher with the device helper pointed at the fixture root.""" + text = LAUNCHER.read_text() + assert text.count(DEVICE_CALL) == 1, "launcher must propagate device helper failure" + replacement = ("configure_device_mounts " + shlex.join([str(self.dev)]) + + ' "${DEVICE_PATHS[@]}" || exit 1') + launcher = self.root / "device fixture launcher" + launcher.write_text(text.replace(DEVICE_CALL, replacement, 1)) + return self.run_launcher(*args, script=launcher) + + @staticmethod + def binds(options: list, flag: str = "--dev-bind") -> list: + return [options[i:i + 3] for i, item in enumerate(options) if item == flag] + + def assert_bound(self, options: list, names: list) -> None: + assert sorted(map(tuple, self.binds(options))) == sorted( + ("--dev-bind", str(self.dev / name), "/dev/" + name) for name in names) + + def assert_failure(self, result: subprocess.CompletedProcess) -> None: + assert result.returncode != 0, result.stdout + result.stderr + assert not self.capture.exists(), "bwrap ran after device setup failed" + assert result.stderr.strip(), "device failure needs a diagnostic" + + +@pytest.fixture +def device(tmp_path: Path) -> DeviceLauncher: + return DeviceLauncher(tmp_path) + + +# --- Standalone helper contract --- + + +def test_top_level_node_needs_no_directory(device): + tty = device.node("ttyUSB0") + options, _ = device.captured(device.run_device_helper(tty)) + assert options[:4] == ["--dev", "/dev", "--proc", "/proc"] + device.assert_bound(options, ["ttyUSB0"]) + assert "--dir" not in options + + +def test_nested_nodes_create_each_ancestor_once(device): + control = device.node("snd/controlC0") + pcm = device.node("snd/pcmC0D0p") + deep = device.node("hidraw/by-serial/usb-1") + options, _ = device.captured(device.run_device_helper(control, pcm, deep)) + device.assert_bound(options, ["snd/controlC0", "snd/pcmC0D0p", + "hidraw/by-serial/usb-1"]) + pairs = [options[i:i + 2] for i in range(len(options) - 1)] + for directory in ("/dev/snd", "/dev/hidraw", "/dev/hidraw/by-serial"): + assert pairs.count(["--dir", directory]) == 1, directory + assert options.index("/dev/snd") < options.index(str(control)) + assert options.index("/dev/snd") < options.index(str(pcm)) + assert options.index("/dev/hidraw") < options.index("/dev/hidraw/by-serial") + assert options.index("/dev/hidraw/by-serial") < options.index(str(deep)) + + +def test_block_device_is_accepted(device): + loop = device.node("loop0", kind="block special file") + options, _ = device.captured(device.run_device_helper(loop)) + device.assert_bound(options, ["loop0"]) + + +def test_repeated_path_is_bound_once(device): + tty = device.node("ttyUSB0") + options, _ = device.captured(device.run_device_helper(tty, tty)) + assert device.binds(options) == [["--dev-bind", str(tty), "/dev/ttyUSB0"]] + + +def test_empty_call_adds_nothing(device): + result = device.run_device_helper() + options, _ = device.captured(result) + assert not device.binds(options) + assert "--device" not in result.stderr + + +def test_success_emits_warning(device): + tty = device.node("ttyUSB0") + result = device.run_device_helper(tty) + assert result.returncode == 0, result.stdout + result.stderr + assert "Warning: --device" in result.stderr + + +def test_helper_skips_access_advisory_without_launcher_identity(device): + # The extracted helper runs without the launcher's identity variables, + # so the host-access advisory must stay silent instead of guessing. + tty = device.node("ttyUSB0", mode=0o600) + result = device.run_device_helper(tty) + options, _ = device.captured(result) + device.assert_bound(options, ["ttyUSB0"]) + assert "usermod" not in result.stderr + + +def test_only_character_or_block_kinds_pass(device): + for kind in ("regular file", "directory", "socket", "fifo"): + path = device.node("weird", kind=kind) + device.assert_failure(device.run_device_helper(path)) + + +def test_missing_node_is_rejected(device): + device.assert_failure(device.run_device_helper(device.dev / "missing")) + + +def test_unregistered_regular_file_is_rejected(device): + regular = device.dev / "regular" + regular.touch() + device.assert_failure(device.run_device_helper(regular)) + + +def test_directory_is_rejected(device): + directory = device.dev / "subdir" + directory.mkdir() + device.assert_failure(device.run_device_helper(directory)) + + +def test_symlink_node_is_rejected_even_if_target_is_a_device(device): + target = device.node("target") + link = device.dev / "ttyUSB0" + link.symlink_to(target) + device.assert_failure(device.run_device_helper(link)) + + +def test_paths_outside_the_dev_root_are_rejected(device): + outside = device.project / "ttyUSB0" + outside.touch() + device.assert_failure(device.run_device_helper(outside)) + device.assert_failure(device.run_device_helper("ttyUSB0")) + device.assert_failure(device.run_device_helper(device.dev)) + device.assert_failure(device.run_device_helper("")) + + +def test_unsafe_path_components_are_rejected(device): + for suffix in ("/../escape", "/./dot", "//double", "/trailing/"): + path = device.dev / ("ttyUSB0" + suffix) + device.assert_failure(device.run_device_helper(path)) + + +def test_symlinked_parent_directory_is_rejected(device): + external = device.root / "external snd" + external.mkdir() + (external / "controlC0").touch() + (device.dev / "snd").symlink_to(external, target_is_directory=True) + device.assert_failure(device.run_device_helper(device.node("snd/controlC0"))) + + +# --- CLI contract --- + + +def test_help_documents_device(device): + for flag in ("--help", "-h"): + result = device.run_launcher(flag) + assert result.returncode == 0, result.stderr + assert "--device" in result.stdout + assert not device.capture.exists() + + +def test_default_does_not_probe_or_bind_devices(device): + options, command = device.captured(device.run_launcher("-s", "session")) + assert not device.binds(options) + assert not any(call[0] == "stat" and "%F" in call + for call in device.command_calls()) + assert command[3:] == ["--", "-s", "session"] + + +def test_device_requires_a_value(device): + result = device.run_launcher("--device") + assert result.returncode != 0, result.stdout + result.stderr + assert not device.capture.exists() + assert "requires a value" in result.stderr + + +def test_option_like_value_is_rejected_before_any_setup(device): + for value in ("--device", "--with", "-s"): + result = device.run_launcher("--with-ro", "/etc/hosts", "--device", value, + "/dev/ttyACM0", "-s", "session") + assert result.returncode != 0, result.stdout + result.stderr + assert not device.capture.exists(), "bwrap ran despite option-like --device value" + assert "Error: --device requires a device path" in result.stderr + assert value in result.stderr + assert "Usage: opencodebox --device" in result.stderr + # Parsing aborts before any filesystem or device setup happens. + assert not any(call[0] == "stat" and "%F" in call + for call in device.command_calls()) + + +def test_real_host_null_device_is_bound_via_cli(device): + result = device.run_launcher("--device", "/dev/null", "-s", "session") + options, command = device.captured(result) + assert device.binds(options) == [["--dev-bind", "/dev/null", "/dev/null"]] + assert command[3:] == ["--", "-s", "session"] + assert "Warning: --device" in result.stderr + + +def test_inaccessible_device_warns_with_host_fix_hint(device): + # root:dialout-style node that the invoking user cannot open on the + # host is still bound, but the launcher explains the host-side fix. + # getent is absent from the test PATH, so the group keeps its gid. + tty = device.node("ttyUSB0", mode=0o660, gid=100018) + result = device.run_fixture_launcher("--device", str(tty), "-s", "session") + options, _ = device.captured(result) + assert device.binds(options) == [["--dev-bind", str(tty), "/dev/ttyUSB0"]] + assert str(tty) in result.stderr + assert "usermod -aG 100018 test-user" in result.stderr + assert "cannot open" in result.stderr + + +def test_group_accessible_device_does_not_warn(device): + tty = device.node("ttyUSB0", mode=0o660, gid=100018) + device.env["TEST_GROUPS"] = "12345 100018" + result = device.run_fixture_launcher("--device", str(tty), "-s", "session") + device.captured(result) + assert "usermod" not in result.stderr + + +def test_owner_accessible_device_does_not_warn(device): + tty = device.node("ttyUSB0", mode=0o600, uid=device.uid) + result = device.run_fixture_launcher("--device", str(tty), "-s", "session") + device.captured(result) + assert "usermod" not in result.stderr + + +def test_owner_mode_denied_warns_about_node_permissions(device): + tty = device.node("ttyUSB0", mode=0o000, uid=device.uid) + result = device.run_fixture_launcher("--device", str(tty), "-s", "session") + device.captured(result) + assert "cannot open" in result.stderr + assert "udev rule" in result.stderr + assert "usermod" not in result.stderr + + +def test_acl_backed_device_does_not_warn_beyond_mode_bits(device): + # POSIX ACLs (stat '%A' ends in '+') can grant access the mode bits + # deny; the advisory cannot reason about them and stays silent. + tty = device.node("ttyUSB0", mode=0o600, gid=100018, acl=True) + result = device.run_fixture_launcher("--device", str(tty), "-s", "session") + device.captured(result) + assert "usermod" not in result.stderr + + +def test_paths_outside_dev_are_rejected_via_cli(device): + for value in ("/dev", "ttyUSB0", str(device.project / "ttyUSB0"), "/dev/./null"): + result = device.run_launcher("--device", value, "-s", "session") + assert result.returncode != 0, result.stdout + result.stderr + assert not device.capture.exists(), "bwrap ran despite invalid device" + + +def test_single_flag_binds_fixture_node(device): + tty = device.node("ttyUSB0") + result = device.run_fixture_launcher("--device", str(tty), "-s", "session with spaces") + options, command = device.captured(result) + assert device.binds(options) == [["--dev-bind", str(tty), "/dev/ttyUSB0"]] + assert command[3:] == ["--", "-s", "session with spaces"] + assert "Warning: --device" in result.stderr + + +def test_repeated_flags_bind_each_node_once(device): + tty = device.node("ttyUSB0") + video = device.node("video0") + options, command = device.captured(device.run_fixture_launcher( + "--device", str(tty), "run", "--device", str(video), + "--device", str(tty), "-s", "session")) + assert device.binds(options) == [ + ["--dev-bind", str(tty), "/dev/ttyUSB0"], + ["--dev-bind", str(video), "/dev/video0"], + ] + assert command[3:] == ["--", "run", "-s", "session"] + + +def test_nested_node_gets_parent_directory(device): + control = device.node("snd/controlC0") + options, _ = device.captured(device.run_fixture_launcher("--device", str(control))) + pairs = [options[i:i + 2] for i in range(len(options) - 1)] + assert pairs.count(["--dir", "/dev/snd"]) == 1 + assert options.index("/dev/snd") < options.index(str(control)) + + +def test_block_node_via_cli(device): + loop = device.node("loop0", kind="block special file") + options, _ = device.captured(device.run_fixture_launcher("--device", str(loop))) + assert device.binds(options) == [["--dev-bind", str(loop), "/dev/loop0"]] + + +def test_empty_value_is_rejected(device): + device.assert_failure(device.run_fixture_launcher("--device", "")) + + +def test_helper_failure_exits_without_bwrap(device): + device.assert_failure(device.run_fixture_launcher( + "--device", str(device.dev / "missing"), "-s", "session")) + + +# --- Interaction with the rest of the launcher --- + + +def test_composition_seccomp_namespaces_and_override_order(device): + tty = device.node("ttyUSB0") + seccomp = device.home / ".local/share/opencodebox/seccomp-security.bpf" + seccomp.parent.mkdir(parents=True) + seccomp.write_bytes(b"test filter") + (device.bin / "uname").unlink() + device.command("uname", "print('x86_64')") + baseline, _ = device.captured(device.run_launcher()) + override = device.root / "device override" + override.mkdir() + options, command = device.captured(device.run_fixture_launcher( + "--with-ro", f"{override}:/dev/ttyUSB1", "--device", str(tty), + "-s", "session")) + assert device.binds(options) == [["--dev-bind", str(tty), "/dev/ttyUSB0"]] + assert command[3:] == ["--", "-s", "session"] + # Device mounts come first, explicit user binds override them, and the + # OpenCode 2 isolation overlay still forms the tail (later binds win). + assert options.index(str(tty)) < options.index(str(override)) + assert options.index(str(override)) < options.index( + str(device.home / ".local/share/opencodebox/disabled-service.json")) + def isolation(args): + return [arg for arg in args if arg.startswith("--unshare") + or arg in ("--share-net", "--die-with-parent", "--new-session")] + assert isolation(options) == isolation(baseline) + for args in (baseline, options): + assert args.count("--seccomp") == 1 + assert args[args.index("--seccomp") + 1].isdigit() + assert ["--dev", "/dev"] in [args[i:i + 2] for i in range(len(args) - 1)] + + +def test_nss_identity_files_list_host_groups(device): + # Every host group of the invoking user must be resolvable inside the + # sandbox: getent is absent from the test PATH, so unresolvable gids + # keep numeric names. The device access advisory relies on the same data. + device.env["TEST_GROUPS"] = "12345 100018" + device.run_launcher("-s", "session") + assert device.files.exists() + files = json.loads(device.files.read_text()) + assert "/etc/passwd" in files + assert "/etc/group" in files + # Root and the kernel overflow id resolve for display and lookup; + # the user keeps the same ids. + assert files["/etc/passwd"].startswith("root:x:0:0:root:/root:") + assert "nobody:x:65534:65534:" in files["/etc/passwd"] + assert f"test-user:x:{device.uid}:12345:" in files["/etc/passwd"] + group_lines = files["/etc/group"].splitlines() + assert group_lines[:2] == ["root:x:0:", "nobody:x:65534:"] + for gid in ("12345", "100018"): + assert f"{gid}:x:{gid}:test-user" in group_lines