Files

302 lines
14 KiB
Python

"""Bind real framebuffer observations to the visible, owned tmux terminal.
This module never selects a window, pane or VT. Input is permitted only to the
pane that was active in the client on the observed foreground VT. Capture files
and manifests are stored by the caller in its private, immutable capture store.
"""
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
import re
import stat
import subprocess
import time
from datetime import datetime, timezone
class ScreenControlError(RuntimeError):
def __init__(self, code, **details):
super().__init__(code)
self.code = code
self.details = details
ControlError = ScreenControlError
KEYS = {
"ENTER": "Enter", "RETURN": "Enter", "TAB": "Tab", "SHIFT_TAB": "BTab",
"ESC": "Escape", "BACKSPACE": "BSpace", "DELETE": "DC", "INSERT": "IC",
"UP": "Up", "DOWN": "Down", "LEFT": "Left", "RIGHT": "Right",
"HOME": "Home", "END": "End", "PAGE_UP": "PPage", "PAGE_DOWN": "NPage",
"SPACE": "Space", "CTRL_C": "C-c", "CTRL_D": "C-d", "CTRL_Z": "C-z",
"CTRL_A": "C-a", "CTRL_E": "C-e", "CTRL_L": "C-l", "CTRL_U": "C-u",
"CTRL_K": "C-k", "CTRL_R": "C-r", "CTRL_W": "C-w",
**{"F" + str(n): "F" + str(n) for n in range(1, 13)},
}
def _active_vt():
value = Path("/sys/class/tty/tty0/active").read_text().strip()
if re.fullmatch(r"tty[1-9][0-9]*", value) is None:
raise ScreenControlError("SCREEN_ACTIVE_VT_INVALID")
return value
def _proc(pid, *, tmux=False):
p = Path("/proc") / str(int(pid))
if p.stat().st_uid != os.getuid():
raise ScreenControlError("SCREEN_PROCESS_OWNER_MISMATCH")
raw = (p / "stat").read_text()
fields = raw[raw.rfind(")") + 2:].split()
if tmux and os.path.realpath(p / "exe") != os.path.realpath("/usr/bin/tmux"):
raise ScreenControlError("SCREEN_TMUX_PROCESS_MISMATCH")
return {"pid": int(pid), "start_ticks": int(fields[19]),
"uid": p.stat().st_uid}
def _socket_identity(path):
"""Reject symlinks and paths outside the owned runtime directories."""
p = Path(path)
if not p.is_absolute() or ".." in p.parts:
raise ScreenControlError("SCREEN_SOCKET_PATH_UNTRUSTED")
roots = [Path("/run/user") / str(os.getuid()),
Path("/tmp") / ("tmux-" + str(os.getuid()))]
root = next((r for r in roots if p.is_relative_to(r)), None)
if root is None:
raise ScreenControlError("SCREEN_SOCKET_PATH_UNTRUSTED")
current = root
for part in ("", *p.relative_to(root).parts):
if part:
current /= part
st = current.lstat()
if stat.S_ISLNK(st.st_mode) or st.st_uid != os.getuid():
raise ScreenControlError("SCREEN_SOCKET_OWNER_MISMATCH")
if current != p:
if not stat.S_ISDIR(st.st_mode) or stat.S_IMODE(st.st_mode) & 0o022:
raise ScreenControlError("SCREEN_SOCKET_PARENT_UNTRUSTED")
elif not stat.S_ISSOCK(st.st_mode):
raise ScreenControlError("SCREEN_SOCKET_NOT_SOCKET")
return {"path": str(p), "device": st.st_dev, "inode": st.st_ino,
"uid": st.st_uid}
def _server_sockets():
"""Only probe sockets actually held by a same-UID tmux server process."""
unix = {}
for line in Path("/proc/net/unix").read_text().splitlines()[1:]:
fields = line.split(maxsplit=7)
if len(fields) == 8 and fields[7].startswith("/"):
unix[fields[6]] = fields[7]
found = []
for p in Path("/proc").iterdir():
if not p.name.isdigit():
continue
try:
if p.stat().st_uid != os.getuid() or (p / "comm").read_text().strip() != "tmux: server":
continue
process = _proc(int(p.name), tmux=True)
for fd in (p / "fd").iterdir():
target = os.readlink(fd)
match = re.fullmatch(r"socket:\[([0-9]+)\]", target)
if match and match[1] in unix:
try:
identity = _socket_identity(unix[match[1]])
except (OSError, ScreenControlError):
continue
found.append((identity, process))
if _proc(int(p.name), tmux=True) != process:
raise ScreenControlError("SCREEN_TMUX_SERVER_CHANGED")
except (FileNotFoundError, PermissionError, ProcessLookupError):
continue
unique = {(s[0]["path"], s[1]["pid"]): s for s in found}
if len(unique) > 32:
raise ScreenControlError("SCREEN_TMUX_SERVER_LIMIT")
return list(unique.values())
def _tmux(socket_path, *args):
env = {k: v for k, v in os.environ.items() if k not in ("TMUX", "TMUX_PANE")}
env.update({"LC_ALL": "C.UTF-8", "TERM": "xterm-256color"})
try:
result = subprocess.run(["/usr/bin/tmux", "-S", socket_path, *args],
capture_output=True, text=True, env=env, timeout=4)
except (OSError, subprocess.TimeoutExpired) as exc:
raise ScreenControlError("SCREEN_TMUX_COMMAND_FAILED") from exc
if result.returncode or len(result.stdout.encode()) > 65536:
raise ScreenControlError("SCREEN_TMUX_COMMAND_FAILED")
return result.stdout
def _rows(value, size):
out = []
for line in value.splitlines():
fields = line.split("|")
if len(fields) != size:
raise ScreenControlError("SCREEN_TMUX_METADATA_INVALID")
out.append(fields)
return out
def snapshot_binding():
"""Read-only snapshot of the active visible terminal; never fall back hidden."""
vt = _active_vt()
tty = "/dev/" + vt
matches = []
client_format = "|".join(["#{client_pid}", "#{client_tty}", "#{session_id}",
"#{client_width}", "#{client_height}", "#{client_control_mode}"])
pane_format = "|".join(["#{session_id}", "#{window_id}", "#{window_active}",
"#{pane_id}", "#{pane_pid}", "#{pane_tty}", "#{pane_active}",
"#{pane_left}", "#{pane_top}", "#{pane_width}", "#{pane_height}",
"#{window_width}", "#{window_height}", "#{pane_dead}",
"#{window_zoomed_flag}", "#{pane_in_mode}"])
for socket_identity, server in _server_sockets():
path = socket_identity["path"]
try:
clients = [c for c in _rows(_tmux(path, "list-clients", "-F", client_format), 6)
if c[1] == tty and c[5] == "0"]
if not clients:
continue
panes = _rows(_tmux(path, "list-panes", "-a", "-F", pane_format), 16)
for client in clients:
visible = [p for p in panes if p[0] == client[2] and p[2] == "1"
and p[6] == "1" and p[13] == "0"]
if len(visible) != 1:
raise ScreenControlError("SCREEN_VISIBLE_PANE_AMBIGUOUS")
p = visible[0]
binding = {"version": 1, "backend": "visible-tmux", "node": os.uname().nodename,
"uid": os.getuid(), "active_vt": vt, "client_tty": tty,
"socket": socket_identity, "server": server,
"client": _proc(int(client[0]), tmux=True),
"session_id": p[0], "window_id": p[1], "pane_id": p[3],
"pane": _proc(int(p[4])), "pane_tty": p[5],
"client_size": [int(client[3]), int(client[4])],
"window_size": [int(p[11]), int(p[12])],
"pane_geometry": [int(x) for x in p[7:11]],
"window_zoomed": p[14] == "1", "pane_in_mode": p[15] == "1"}
if _socket_identity(path) != socket_identity or _proc(server["pid"], tmux=True) != server:
raise ScreenControlError("SCREEN_TMUX_SERVER_CHANGED")
matches.append(binding)
except ScreenControlError as exc:
if exc.code == "SCREEN_TMUX_COMMAND_FAILED":
continue
raise
if _active_vt() != vt:
raise ScreenControlError("SCREEN_ACTIVE_VT_CHANGED")
if not matches:
raise ScreenControlError("SCREEN_VISIBLE_TMUX_NOT_FOUND")
if len(matches) != 1:
raise ScreenControlError("SCREEN_VISIBLE_CLIENT_AMBIGUOUS")
return matches[0]
def validate_binding(expected):
if not isinstance(expected, dict) or expected.get("backend") != "visible-tmux":
raise ScreenControlError("SCREEN_BINDING_REQUIRED")
current = snapshot_binding()
if current != expected:
raise ScreenControlError("SCREEN_BINDING_CHANGED")
return current
def bind_screen(manifest):
if not isinstance(manifest, dict) or not isinstance(manifest.get("capture_id"), str) or not manifest["capture_id"]:
raise ScreenControlError("SCREEN_CAPTURE_ID_REQUIRED")
if manifest.get("source") != "linux-framebuffer" or manifest.get("screen_type") != "tty":
raise ScreenControlError("SCREEN_CONTROL_MODE_UNSUPPORTED")
binding = snapshot_binding()
if manifest.get("active_vt") != binding["active_vt"]:
raise ScreenControlError("SCREEN_CAPTURE_VT_MISMATCH")
if manifest.get("node") != binding["node"] or manifest.get("uid") != binding["uid"]:
raise ScreenControlError("SCREEN_CAPTURE_NODE_MISMATCH")
return {**manifest, "binding": binding}
def _verify_capture(manifest, max_age_s):
if not isinstance(manifest, dict) or not manifest.get("capture_id"):
raise ScreenControlError("SCREEN_CAPTURE_ID_REQUIRED")
if manifest.get("source") != "linux-framebuffer" or manifest.get("screen_type") != "tty":
raise ScreenControlError("SCREEN_CONTROL_MODE_UNSUPPORTED")
if manifest.get("synthetic") is not False or manifest.get("pixel_source_verified") is not True:
raise ScreenControlError("SCREEN_REAL_CAPTURE_REQUIRED")
if max_age_s is not None and (not isinstance(max_age_s, (int, float)) or not 0 < max_age_s <= 300):
raise ScreenControlError("SCREEN_CAPTURE_AGE_LIMIT_INVALID")
try:
when = datetime.fromisoformat(manifest["completed_at"])
if when.tzinfo is None:
raise ValueError()
age = (datetime.now(timezone.utc) - when).total_seconds()
except (KeyError, TypeError, ValueError):
raise ScreenControlError("SCREEN_CAPTURE_TIME_INVALID") from None
if age < -5 or (max_age_s is not None and age > max_age_s):
raise ScreenControlError("SCREEN_CAPTURE_EXPIRED")
path = Path(manifest.get("path", ""))
expected = manifest.get("sha256", "")
if not path.is_absolute() or re.fullmatch(r"[a-f0-9]{64}", expected) is None:
raise ScreenControlError("SCREEN_CAPTURE_FILE_INVALID")
try:
fd = os.open(path, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW)
try:
st = os.fstat(fd)
if not stat.S_ISREG(st.st_mode) or st.st_uid != os.getuid() or st.st_size > 64 * 1024 * 1024:
raise ScreenControlError("SCREEN_CAPTURE_FILE_UNTRUSTED")
with os.fdopen(fd, "rb", closefd=False) as f:
digest = hashlib.file_digest(f, "sha256").hexdigest()
if digest != expected:
raise ScreenControlError("SCREEN_CAPTURE_SHA256_MISMATCH")
finally:
os.close(fd)
except OSError as exc:
raise ScreenControlError("SCREEN_CAPTURE_FILE_UNAVAILABLE") from exc
def act(capture_manifest, kind, *, text=None, key=None, max_age_s=None):
"""Send literal text (no controls/newline) or one explicit allowlisted key.
The RPC caller must load this manifest by capture_id from its private store;
it must not accept a replacement manifest supplied by a remote caller.
Foreground changes are checked before and after dispatch. An independent
local user's VT switch concurrent with the actual write cannot be made
atomic with tmux; postflight failure therefore reports performed=True.
"""
if kind in ("text", "text.type", "type"):
if key is not None or not isinstance(text, str) or not text or len(text.encode()) > 16384:
raise ScreenControlError("SCREEN_TEXT_INVALID")
if any(ord(c) < 32 or ord(c) == 127 for c in text):
raise ScreenControlError("SCREEN_TEXT_CONTROL_CHARACTER")
command = ["send-keys", "-l", "--", text]
action = "text.type"
elif kind in ("key", "key.send"):
if text is not None or not isinstance(key, str) or key not in KEYS:
raise ScreenControlError("SCREEN_KEY_INVALID")
command = ["send-keys", KEYS[key]]
action = "key.send"
else:
raise ScreenControlError("SCREEN_ACTION_UNSUPPORTED")
_verify_capture(capture_manifest, max_age_s)
expected = capture_manifest.get("binding")
binding = validate_binding(expected)
if binding.get("pane_in_mode"):
raise ScreenControlError("SCREEN_TMUX_MODE_INPUT_UNSUPPORTED")
if capture_manifest.get("active_vt") != binding["active_vt"]:
raise ScreenControlError("SCREEN_CAPTURE_VT_MISMATCH")
if capture_manifest.get("node") != binding["node"] or capture_manifest.get("uid") != binding["uid"]:
raise ScreenControlError("SCREEN_CAPTURE_NODE_MISMATCH")
# A second check immediately before dispatch prevents using a stale probe.
binding = validate_binding(expected)
path, pane = binding["socket"]["path"], binding["pane_id"]
_tmux(path, command[0], "-t", pane, *command[1:])
try:
validate_binding(expected)
except (ScreenControlError, OSError) as exc:
raise ScreenControlError("SCREEN_CHANGED_AFTER_INPUT", performed=True,
action=action, capture_id=capture_manifest["capture_id"]) from exc
return {"ok": True, "performed": True, "action": action,
"capture_id": capture_manifest["capture_id"], "binding": binding,
"input_bytes": len(text.encode()) if action == "text.type" else None,
"key": key if action == "key.send" else None,
"input_target": "observed-visible-pane"}