diff options
| author | user <user@clank> | 2026-08-13 20:08:50 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-13 20:08:50 +0200 |
| commit | fb7076efd6d3ab5c1d36b6e554045d87b43c15b6 (patch) | |
| tree | 5ef521792cd35974cd83814dba9e68edadedcc30 /packaging/checks | |
| parent | check: stop the live check leaking a server per run (diff) | |
| download | sl0ppi-fb7076efd6d3ab5c1d36b6e554045d87b43c15b6.tar.gz sl0ppi-fb7076efd6d3ab5c1d36b6e554045d87b43c15b6.tar.xz sl0ppi-fb7076efd6d3ab5c1d36b6e554045d87b43c15b6.zip | |
sl0p: the plugins we ship do not ask for permission
pane-finder requests three permissions as it loads, so the first press of a
keybinding we ship opened a consent screen instead of a fuzzy finder. Prompting
is right for a wasm you found on the internet and wrong for the ones that come
in our own tarball.
plugin_is_shipped_with_sl0ppi (zellij-utils/src/input/permission.rs) grants
those implicitly, through the same instruction a human "y" produces, so the
plugin's permissions are set and recorded exactly as before. "Ours" is narrow
on purpose:
- builtins, asked via the existing PluginConfig::is_builtin -- the same
predicate check_command_permission already uses to exempt them, so the two
cannot drift;
- files inside a plugin directory we install into: the config dir's plugins/
and the data dir's plugins/;
- nothing else. Remote plugins are never trusted, paths are normalised
lexically first so plugins/../../evil.wasm is not "inside" the dir, and the
directories are derived from the environment and default locations -- so
getting them wrong costs a prompt and can never grant one elsewhere.
The trade, documented in config/README.md: <config dir>/plugins/ is now a
trusted directory. Anything able to write there can already bind arbitrary
commands to a key in config.kdl, so a consent screen for the wasm beside it was
not buying anything -- but it does mean plugins/ is not a download folder.
packaging/checks/plugin-permissions-live.py drives both halves on a real
session: the shipped plugin must run without the prompt, and the SAME wasm
loaded from outside the plugin dir must still ask. Confirmed by disabling the
rule and watching the first half fail with the exact "asks permission to ...
Allow? (y/n)" screen it was written against.
Diffstat (limited to 'packaging/checks')
| -rwxr-xr-x | packaging/checks/plugin-permissions-live.py | 199 |
1 files changed, 199 insertions, 0 deletions
diff --git a/packaging/checks/plugin-permissions-live.py b/packaging/checks/plugin-permissions-live.py new file mode 100755 index 0000000..7241504 --- /dev/null +++ b/packaging/checks/plugin-permissions-live.py @@ -0,0 +1,199 @@ +#!/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, "-s", SESSION, "kill-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) |
