#!/usr/bin/env python3 """Live check: do the plugins sl0ppi ships start without a permission nag, and does that trust stop at our own plugin directory? Both halves matter. The first is the feature: pane-finder asks for three permissions the moment it loads, and answering that prompt on first launch (per machine, per session cache) is not something a shipped keybinding should make you do. The second is the limit: "we trust the plugins we ship" must not decay into "we trust any wasm anyone drops on this box", so the same plugin loaded from outside the plugin dir must still ask. Usage: plugin-permissions-live.py [path-to-sl0ppij] [path-to-pane-finder.wasm] """ import fcntl import os import pty import re import shutil import struct import subprocess import sys import tempfile import termios import time HERE = os.path.dirname(os.path.abspath(__file__)) BIN = os.path.abspath( sys.argv[1] if len(sys.argv) > 1 else os.path.join(HERE, "../../sl0ppij/target/fastdev/sl0ppij") ) WASM = os.path.abspath( sys.argv[2] if len(sys.argv) > 2 else os.path.join(HERE, "../../sl0ppij/target/wasm32-wasip1/release/zj-pane-finder.wasm") ) ROWS, COLS = 30, 100 SESSION = f"sl0ptest-perms-{os.getpid()}" CONFIG = """ pane_frames true pane_frame_style "full" default_mode "locked" show_startup_tips false """ # what the permission prompt says (plugin_pane.rs: display_request_permission_message) NAG = ("asks permission to", "This plugin asks permission to", "Allow?") # what the pane finder draws once it is actually running RUNNING = "type to filter panes" def screen_text(raw): """Printable text of the stream, with escape sequences stripped. Coarser than the split-button check's grid, and that is on purpose: this one asks "does this string appear", not "at which column", so a torn repaint can only ever hide a nag we then fail to find -- and we assert the plugin's own output too, which would go missing at the same time. """ text = re.sub(r"\x1b\][^\x07\x1b]*(\x07|\x1b\\)", "", raw) text = re.sub(r"\x1b\[[0-9;?]*[a-zA-Z]", "", text) text = re.sub(r"\x1b[()][0-9A-B]", "", text) return text.replace("\r", "\n") def read_until(fd, predicate, seconds=25): """Accumulate output until `predicate` is happy or the deadline passes.""" buffered = "" deadline = time.time() + seconds while time.time() < deadline: time.sleep(0.2) try: buffered += os.read(fd, 1 << 20).decode("utf8", "replace") except BlockingIOError: pass except OSError: break if predicate(screen_text(buffered)): break return screen_text(buffered) def main(): if not os.path.isfile(WASM): print(f"FAIL: no pane-finder wasm at {WASM} (build it with `make plugins`)") return 1 tmp = tempfile.mkdtemp(prefix="sl0p-perms-") config_dir = os.path.join(tmp, "config") shipped = os.path.join(config_dir, "plugins") # where the installer puts ours elsewhere = os.path.join(tmp, "elsewhere") # a plugin we do NOT ship os.makedirs(shipped) os.makedirs(elsewhere) config_file = os.path.join(config_dir, "config.kdl") with open(config_file, "w") as f: f.write(CONFIG) shutil.copy(WASM, os.path.join(shipped, "pane-finder.wasm")) shutil.copy(WASM, os.path.join(elsewhere, "pane-finder.wasm")) env = dict(os.environ) env.update( { "SL0PPIJ_SOCKET_DIR": os.path.join(tmp, "sock"), "SL0PPIJ_CONFIG_FILE": config_file, "SL0PPIJ_CONFIG_DIR": config_dir, "SL0PPIJ_CACHE_DIR": os.path.join(tmp, "cache"), "XDG_CACHE_HOME": os.path.join(tmp, "cache"), # the permission cache lives here "TERM": "xterm-256color", "SHELL": "/bin/sh", } ) pid, fd = pty.fork() if pid == 0: os.execvpe(BIN, [BIN, "-s", SESSION], env) fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", ROWS, COLS, 0, 0)) os.set_blocking(fd, False) failures = [] try: read_until(fd, lambda text: "$" in text, seconds=20) def launch(path): subprocess.run( [ BIN, "-s", SESSION, "action", "launch-or-focus-plugin", "--floating", f"file:{path}", ], env=env, capture_output=True, text=True, ) # 1. a plugin we ship: must run, must not ask launch(os.path.join(shipped, "pane-finder.wasm")) text = read_until( fd, lambda t: RUNNING in t or any(n in t for n in NAG), seconds=25 ) nagged = [n for n in NAG if n in text] running = RUNNING in text print(f"shipped plugin: running={running} nagged={bool(nagged)}") if nagged: failures.append(f"the plugin we ship asked for permissions: {nagged}") if not running: failures.append("the plugin we ship did not start") print("\n".join(text.splitlines()[-25:])) # 2. the same plugin from outside our plugin dir: must still ask, or # "trusted" would just mean "any wasm on this machine" subprocess.run( [BIN, "-s", SESSION, "action", "close-pane"], env=env, capture_output=True ) time.sleep(1) launch(os.path.join(elsewhere, "pane-finder.wasm")) text = read_until(fd, lambda t: any(n in t for n in NAG), seconds=25) nagged = [n for n in NAG if n in text] print(f"unshipped plugin: nagged={bool(nagged)}") if not nagged: failures.append( "a plugin outside the shipped plugin dir was granted permissions silently" ) print("\n".join(text.splitlines()[-25:])) return failures finally: subprocess.run( [BIN, "kill-session", SESSION], env=env, capture_output=True, text=True ) # `kill-session NAME`, not `-s NAME kill-session`: the latter parses # but leaves the session running. Then DELETE it -- a killed session # stays in `list-sessions` as "EXITED - attach to resurrect", so a day # of check runs silently fills the user's session picker. subprocess.run([BIN, "delete-session", SESSION], env=env, capture_output=True, text=True) try: os.close(fd) except OSError: pass os.waitpid(pid, os.WNOHANG) time.sleep(1) # never `pkill -f`: it matches this script's own command line ps = subprocess.run(["ps", "-eo", "pid,comm,args"], capture_output=True, text=True).stdout for line in ps.splitlines(): fields = line.split(None, 2) if len(fields) == 3 and fields[1] == os.path.basename(BIN) and tmp in fields[2]: try: os.kill(int(fields[0]), 15) except (ProcessLookupError, ValueError, PermissionError): pass shutil.rmtree(tmp, ignore_errors=True) if __name__ == "__main__": problems = main() if problems == 1: sys.exit(1) for p in problems: print(f"FAIL: {p}") if not problems: print("ok: shipped plugins start silently, others still ask") sys.exit(1 if problems else 0)