FrameBridge 0.3.1 community source release
This commit is contained in:
commit
b23819fff1
1354 files changed
+343716
No files matched your search
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"origin": "Original text-console source capture; community derivative",
|
||||
"files": [
|
||||
{
|
||||
"source": "gerameg_cua_runtime.py",
|
||||
"path": "gerameg_cua_runtime.py",
|
||||
"bytes": 5049,
|
||||
"sha256": "381a7dc740227e27085e66d5f7e5d398a09f86eff66d6aefce9025e896634887"
|
||||
},
|
||||
{
|
||||
"source": "real_tty_manager.py",
|
||||
"path": "real_tty_manager.py",
|
||||
"bytes": 9647,
|
||||
"sha256": "3c8fe23cf1499172d4064f04f373656f6fd6109a1c1bc01657d669aabd4121d8"
|
||||
},
|
||||
{
|
||||
"source": "cua_tty_rpc.py",
|
||||
"path": "cua_tty_rpc.py",
|
||||
"bytes": 9957,
|
||||
"sha256": "e64b0b9283c8143b5fc4db2c6020c288927f0a26a9c4fd42cfc8aca8bfc36175"
|
||||
},
|
||||
{
|
||||
"source": "framebridge_cua_tty/__init__.py",
|
||||
"path": "framebridge_cua_tty/__init__.py",
|
||||
"bytes": 98,
|
||||
"sha256": "a873aecc609c08bd8b703e0917370278a64e6ad24fc96b5da2fd04c9d958855c"
|
||||
},
|
||||
{
|
||||
"source": "framebridge_cua_tty/client.py",
|
||||
"path": "framebridge_cua_tty/client.py",
|
||||
"bytes": 1465,
|
||||
"sha256": "6a1fc004f2d50944661fcce67002b04534721bc3118eaf44e3f0694e19d15fe7"
|
||||
},
|
||||
{
|
||||
"source": "framebridge_cua_tty/core.py",
|
||||
"path": "framebridge_cua_tty/core.py",
|
||||
"bytes": 7850,
|
||||
"sha256": "1c259afd5233f8dc4b0df58c2e69e7df346637e20f8c6fc24303bdb7a4e29590"
|
||||
},
|
||||
{
|
||||
"source": "framebridge_cua_tty/demo.py",
|
||||
"path": "framebridge_cua_tty/demo.py",
|
||||
"bytes": 1385,
|
||||
"sha256": "22614e47bc2189f83fed4ef294bd48a78bea587b3619f8d333eff74570dee39d"
|
||||
},
|
||||
{
|
||||
"source": "framebridge_cua_tty/screen.py",
|
||||
"path": "framebridge_cua_tty/screen.py",
|
||||
"bytes": 5362,
|
||||
"sha256": "20f47b9d21189f1e9ac679ba7a0a01d0298d7dcce459d88cfb589504be0893d7"
|
||||
},
|
||||
{
|
||||
"source": "framebridge_cua_tty/server.py",
|
||||
"path": "framebridge_cua_tty/server.py",
|
||||
"bytes": 5977,
|
||||
"sha256": "7c803ca0de25e28914fd15804d0cc785c4362d38c8a29b6f7fb377c5b963674d"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
#!/usr/bin/env python3
|
||||
"""RPC local CUA-TTY de GERAMEG, invocado por el FBEXEC original firmado."""
|
||||
from __future__ import annotations
|
||||
import base64
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import socket
|
||||
import stat
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
UID = 1000
|
||||
HOSTNAME = "GERAMEG"
|
||||
HOME = Path.home()
|
||||
SOCKET_PATH = Path("/run/user/1000/framebridge-alpha01-cua/agent.sock")
|
||||
CLIENT_PATH = Path("/opt/framebridge-alpha-01/vendor/framebridge_cua_tty/client.py")
|
||||
CLIENT_SHA256 = "6a1fc004f2d50944661fcce67002b04534721bc3118eaf44e3f0694e19d15fe7"
|
||||
RESULTS_DIR = HOME / ".local/share/framebridge/cua-tty-results"
|
||||
PROTOCOL = "FB-CUA-TTY-RPC/1"
|
||||
MAX_BYTES = 65536
|
||||
INLINE_BYTES = 3000
|
||||
CHUNK_BYTES = 2100
|
||||
SID = re.compile(r"[A-Za-z0-9_-]{12,80}\Z")
|
||||
REPLY_ID = re.compile(r"[0-9a-f]{32}\Z")
|
||||
KEYS = frozenset(['UP', 'DOWN', 'LEFT', 'RIGHT', 'ENTER', 'RETURN', 'TAB', 'SHIFT_TAB', 'ESC', 'BACKSPACE', 'DELETE', 'INSERT', 'HOME', 'END', 'PAGE_UP', 'PAGE_DOWN', 'SPACE', 'CTRL_A', 'CTRL_B', 'CTRL_C', 'CTRL_D', 'CTRL_E', 'CTRL_F', 'CTRL_G', 'CTRL_H', 'CTRL_I', 'CTRL_J', 'CTRL_K', 'CTRL_L', 'CTRL_M', 'CTRL_N', 'CTRL_O', 'CTRL_P', 'CTRL_Q', 'CTRL_R', 'CTRL_S', 'CTRL_T', 'CTRL_U', 'CTRL_V', 'CTRL_W', 'CTRL_X', 'CTRL_Y', 'CTRL_Z', 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12'])
|
||||
SENSITIVE = re.compile(r"(?i)(api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|passwd|secret|authorization|cookie|credential)")
|
||||
KEY_BEGIN = re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----")
|
||||
KEY_END = re.compile(r"-----END [A-Z0-9 ]*PRIVATE KEY-----")
|
||||
|
||||
|
||||
class RpcError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def require(ok, code):
|
||||
if not ok:
|
||||
raise RpcError(code)
|
||||
|
||||
|
||||
def encode(value):
|
||||
return json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("ascii")
|
||||
|
||||
|
||||
def sanitize(value):
|
||||
# Sanear ANTES de persistir o codificar en base64, igual que el stdout original.
|
||||
private_key = [False]
|
||||
|
||||
def clean(item):
|
||||
if isinstance(item, dict):
|
||||
return {key: clean(val) for key, val in item.items()}
|
||||
if isinstance(item, list):
|
||||
return [clean(val) for val in item]
|
||||
if not isinstance(item, str):
|
||||
return item
|
||||
lines = []
|
||||
for line in item.splitlines():
|
||||
if KEY_BEGIN.search(line):
|
||||
private_key[0] = True
|
||||
lines.append("[REDACTED PRIVATE KEY BLOCK]")
|
||||
elif private_key[0]:
|
||||
if KEY_END.search(line):
|
||||
private_key[0] = False
|
||||
elif SENSITIVE.search(line) and ("=" in line or ":" in line):
|
||||
lines.append("[REDACTED SENSITIVE LINE]")
|
||||
else:
|
||||
lines.append(line)
|
||||
return "\n".join(lines)
|
||||
|
||||
return clean(value)
|
||||
|
||||
|
||||
def validate_request(req):
|
||||
require(type(req) is dict and type(req.get("op")) is str, "REQUEST_INVALID")
|
||||
op = req["op"]
|
||||
shapes = {
|
||||
"status": {"op"}, "open_demo": {"op"}, "open_shell": {"op"},
|
||||
"observe": {"op", "session_id"}, "abort": {"op", "session_id"},
|
||||
"resize": {"op", "session_id", "rows", "cols"},
|
||||
"key_send": {"op", "session_id", "key"},
|
||||
"text_type": {"op", "session_id", "text"},
|
||||
}
|
||||
require(op in shapes and set(req) == shapes[op], "REQUEST_INVALID")
|
||||
if "session_id" in req:
|
||||
require(type(req["session_id"]) is str and SID.fullmatch(req["session_id"]) is not None, "SESSION_INVALID")
|
||||
if op == "resize":
|
||||
require(type(req["rows"]) is int and type(req["cols"]) is int and 5 <= req["rows"] <= 100 and 20 <= req["cols"] <= 220, "SIZE_INVALID")
|
||||
if op == "key_send":
|
||||
require(type(req["key"]) is str and req["key"] in KEYS, "KEY_NOT_ALLOWLISTED")
|
||||
if op == "text_type":
|
||||
text = req["text"]
|
||||
require(type(text) is str and 0 < len(text.encode("utf-8")) <= 16384 and "\x00" not in text, "TEXT_NOT_ALLOWED")
|
||||
try:
|
||||
text.encode("utf-8")
|
||||
except UnicodeEncodeError:
|
||||
raise RpcError("TEXT_NOT_ALLOWED") from None
|
||||
args = {key: val for key, val in req.items() if key != "op"}
|
||||
if op == "open_shell":
|
||||
return "open", {"program": "bash"}
|
||||
if op == "open_demo":
|
||||
return "open", {"program": "demo-readonly"}
|
||||
if op in {"key_send", "text_type"}:
|
||||
# Modo local legado autorizado por el propietario; no es una firma Owner.
|
||||
args["owner_assertion"] = {}
|
||||
op = {"key_send": "key.send", "text_type": "text.type"}[op]
|
||||
return op, args
|
||||
|
||||
|
||||
def guard_runtime():
|
||||
require(os.getuid() == UID and os.geteuid() == UID, "UID_MISMATCH")
|
||||
require(socket.gethostname().upper() == HOSTNAME, "HOSTNAME_MISMATCH")
|
||||
require(Path.home().resolve() == HOME.resolve(), "HOME_MISMATCH")
|
||||
|
||||
|
||||
def load_client():
|
||||
require(CLIENT_PATH.is_file() and not CLIENT_PATH.is_symlink(), "CLIENT_PATH_INVALID")
|
||||
source = CLIENT_PATH.read_bytes()
|
||||
require(hashlib.sha256(source).hexdigest() == CLIENT_SHA256, "CLIENT_SHA256_MISMATCH")
|
||||
spec = importlib.util.spec_from_file_location("_gerameg_cua_client_pinned", CLIENT_PATH)
|
||||
require(spec is not None and spec.loader is not None, "CLIENT_IMPORT_INVALID")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
exec(compile(source, str(CLIENT_PATH), "exec"), module.__dict__)
|
||||
return module.rpc
|
||||
|
||||
|
||||
def backend_call(req):
|
||||
op, args = validate_request(req)
|
||||
try:
|
||||
parent, target = SOCKET_PATH.parent.lstat(), SOCKET_PATH.lstat()
|
||||
except OSError:
|
||||
raise RpcError("CUA_TTY_OFFLINE") from None
|
||||
require(stat.S_ISDIR(parent.st_mode) and parent.st_uid == UID and parent.st_mode & 0o077 == 0, "SOCKET_PARENT_UNTRUSTED")
|
||||
require(stat.S_ISSOCK(target.st_mode) and target.st_uid == UID and target.st_mode & 0o077 == 0, "SOCKET_UNTRUSTED")
|
||||
reply = load_client()(op, path=SOCKET_PATH, **args)
|
||||
require(type(reply) is dict and type(reply.get("ok")) is bool, "BACKEND_RESPONSE_INVALID")
|
||||
if reply.get("ok") is True:
|
||||
require(type(reply.get("result")) is dict, "BACKEND_RESPONSE_INVALID")
|
||||
return sanitize(reply)
|
||||
|
||||
|
||||
def result_directory(create=False):
|
||||
if create:
|
||||
RESULTS_DIR.mkdir(mode=0o700, exist_ok=True)
|
||||
require(RESULTS_DIR.resolve() == RESULTS_DIR.absolute(), "RESULT_DIRECTORY_UNSAFE")
|
||||
fd = os.open(RESULTS_DIR, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||
st = os.fstat(fd)
|
||||
if st.st_uid != UID or st.st_mode & 0o077:
|
||||
os.close(fd)
|
||||
raise RpcError("RESULT_DIRECTORY_UNSAFE")
|
||||
return fd
|
||||
|
||||
|
||||
def pack_reply(reply):
|
||||
reply = sanitize(reply)
|
||||
data = encode(reply)
|
||||
require(0 < len(data) <= MAX_BYTES, "RESPONSE_TOO_LARGE")
|
||||
meta = {"protocol": PROTOCOL, "ok": True, "bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}
|
||||
# El sobre ASCII evita que campos del backend como "authorization_mode"
|
||||
# hagan que el saneador de stdout sustituya toda una línea JSON válida.
|
||||
inline = {**meta, "kind": "inline", "data_b64": base64.b64encode(data).decode("ascii")}
|
||||
if len(encode(inline)) <= INLINE_BYTES:
|
||||
return inline
|
||||
reply_id = uuid.uuid4().hex
|
||||
directory = result_directory(create=True)
|
||||
try:
|
||||
fd = os.open(reply_id + ".json", os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=directory)
|
||||
with os.fdopen(fd, "wb") as stream:
|
||||
stream.write(data)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.fsync(directory)
|
||||
finally:
|
||||
os.close(directory)
|
||||
return {**meta, "kind": "file", "reply_id": reply_id}
|
||||
|
||||
|
||||
def read_chunk(reply_id, offset):
|
||||
require(type(reply_id) is str and REPLY_ID.fullmatch(reply_id) is not None, "REPLY_ID_INVALID")
|
||||
require(type(offset) is int and 0 <= offset < MAX_BYTES and offset % CHUNK_BYTES == 0, "OFFSET_INVALID")
|
||||
directory = result_directory()
|
||||
try:
|
||||
fd = os.open(reply_id + ".json", os.O_RDONLY | os.O_NOFOLLOW, dir_fd=directory)
|
||||
with os.fdopen(fd, "rb") as stream:
|
||||
st = os.fstat(stream.fileno())
|
||||
require(stat.S_ISREG(st.st_mode) and st.st_nlink == 1 and st.st_uid == UID and st.st_mode & 0o077 == 0, "RESULT_FILE_UNSAFE")
|
||||
require(0 < st.st_size <= MAX_BYTES, "RESULT_SIZE_INVALID")
|
||||
data = stream.read(MAX_BYTES + 1)
|
||||
require(len(data) == st.st_size, "RESULT_SIZE_CHANGED")
|
||||
finally:
|
||||
os.close(directory)
|
||||
require(offset < len(data), "OFFSET_INVALID")
|
||||
chunk = data[offset:offset + CHUNK_BYTES]
|
||||
return {"protocol": PROTOCOL, "ok": True, "kind": "chunk", "reply_id": reply_id,
|
||||
"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest(),
|
||||
"offset": offset, "length": len(chunk), "data_b64": base64.b64encode(chunk).decode("ascii")}
|
||||
|
||||
|
||||
def dispatch(argv):
|
||||
require(type(argv) is list, "CLI_INVALID")
|
||||
if len(argv) == 2 and argv[0] == "invoke":
|
||||
require(len(argv[1]) <= 50000, "REQUEST_TOO_LARGE")
|
||||
try:
|
||||
raw = base64.b64decode(argv[1], validate=True)
|
||||
require(len(raw) <= 40000, "REQUEST_TOO_LARGE")
|
||||
request = json.loads(raw)
|
||||
except (ValueError, UnicodeError):
|
||||
raise RpcError("REQUEST_ENCODING_INVALID") from None
|
||||
return pack_reply(backend_call(request))
|
||||
if len(argv) == 3 and argv[0] == "read":
|
||||
require(re.fullmatch(r"0|[1-9][0-9]{0,4}", argv[2]) is not None, "OFFSET_INVALID")
|
||||
return read_chunk(argv[1], int(argv[2]))
|
||||
raise RpcError("CLI_INVALID")
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
guard_runtime()
|
||||
result = dispatch(sys.argv[1:])
|
||||
except RpcError as exc:
|
||||
result = {"protocol": PROTOCOL, "ok": False, "error": str(exc)}
|
||||
except Exception as exc:
|
||||
result = {"protocol": PROTOCOL, "ok": False, "error": "RPC_FAILED", "error_type": type(exc).__name__}
|
||||
output = encode(result)
|
||||
require(len(output) <= 3500, "ENVELOPE_TOO_LARGE")
|
||||
sys.stdout.write(output.decode("ascii") + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,2 @@
|
||||
"""FrameBridge CUA-TTY prototype. No network or shell execution."""
|
||||
__version__ = "0.1.0-staging"
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Local staging inspection client; input operations cannot bypass OwnerDevice."""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import socket
|
||||
|
||||
|
||||
def rpc(op, *, path, **kwargs):
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
||||
sock.settimeout(4)
|
||||
sock.connect(str(path))
|
||||
sock.sendall((json.dumps({'op':op, **kwargs})+'\n').encode())
|
||||
reply = bytearray()
|
||||
while len(reply) < 65536:
|
||||
chunk = sock.recv(min(8192, 65536-len(reply)))
|
||||
if not chunk: break
|
||||
reply.extend(chunk)
|
||||
if b'\n' in chunk: break
|
||||
if len(reply) >= 65536 or b'\n' not in reply: raise RuntimeError('RESPONSE_TOO_LARGE')
|
||||
return json.loads(reply.split(b'\n',1)[0])
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
p=argparse.ArgumentParser()
|
||||
p.add_argument('op', choices=('status','open','observe','resize','abort','key.send','text.type'))
|
||||
p.add_argument('--session-id',default='')
|
||||
p.add_argument('--socket',default=str(Path.home()/'.local/share/framebridge-cua-tty-r1/agent.sock'))
|
||||
p.add_argument('--key',default='')
|
||||
p.add_argument('--text',default='')
|
||||
args=p.parse_args(argv)
|
||||
req={}
|
||||
if args.session_id: req['session_id']=args.session_id
|
||||
if args.key: req['key']=args.key
|
||||
if args.text: req['text']=args.text
|
||||
print(json.dumps(rpc(args.op,path=args.socket,**req),ensure_ascii=False,indent=2))
|
||||
|
||||
|
||||
if __name__=='__main__':main()
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Unprivileged local PTY session engine with mandatory owner gate for inputs.
|
||||
|
||||
Staging only: only fixed, no-side-effects demo program may be opened. Never
|
||||
use an LLM-provided shell/argv or `authorized=True` as authority.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
import fcntl
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pty
|
||||
import secrets
|
||||
import select
|
||||
import signal
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import termios
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable
|
||||
from .screen import Screen
|
||||
|
||||
|
||||
class TerminalDenied(PermissionError):
|
||||
pass
|
||||
|
||||
|
||||
KEYS = {'UP': b'\x1b[A', 'DOWN': b'\x1b[B', 'LEFT': b'\x1b[D',
|
||||
'RIGHT': b'\x1b[C', 'ENTER': b'\r', 'TAB': b'\t', 'ESC': b'\x1b',
|
||||
'BACKSPACE': b'\x7f', 'CTRL_C': b'\x03'}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Session:
|
||||
session_id: str
|
||||
proc: subprocess.Popen
|
||||
fd: int
|
||||
screen: Screen
|
||||
born: float
|
||||
last_access: float
|
||||
program: str
|
||||
sequence: int = 0
|
||||
|
||||
|
||||
class Manager:
|
||||
"""A trusted OwnerDevice verifier must implement callable(context)->bool.
|
||||
|
||||
IMPORTANT: no grants can be passed in via RPC, environment, or `authorized`.
|
||||
An absent verifier rejects ALL input. A production verifier must validate
|
||||
signed challenge, owner identity, exact payload hash, nonce, TTL and one-use.
|
||||
"""
|
||||
def __init__(self, owner_verifier: Callable | None = None, max_sessions: int = 2,
|
||||
idle_timeout_s: int = 600):
|
||||
if not 1 <= max_sessions <= 4: raise ValueError('MAX_SESSIONS_INVALID')
|
||||
self._gate = owner_verifier
|
||||
self._sessions = {}
|
||||
self._lock = threading.RLock()
|
||||
self.max_sessions, self.idle_timeout_s = max_sessions, idle_timeout_s
|
||||
|
||||
def _get(self, sid):
|
||||
s = self._sessions.get(sid)
|
||||
if not s: raise KeyError('SESSION_NOT_FOUND')
|
||||
if time.monotonic() - s.last_access > self.idle_timeout_s:
|
||||
self._terminate(s)
|
||||
del self._sessions[sid]
|
||||
raise TerminalDenied('SESSION_EXPIRED')
|
||||
s.last_access = time.monotonic()
|
||||
return s
|
||||
|
||||
@staticmethod
|
||||
def _terminate(s):
|
||||
if s.proc.poll() is None:
|
||||
try: os.killpg(s.proc.pid, signal.SIGTERM)
|
||||
except ProcessLookupError: pass
|
||||
try: s.proc.wait(timeout=0.7)
|
||||
except subprocess.TimeoutExpired:
|
||||
try: os.killpg(s.proc.pid, signal.SIGKILL)
|
||||
except ProcessLookupError: pass
|
||||
try: s.proc.wait(timeout=1)
|
||||
except subprocess.TimeoutExpired: pass
|
||||
try: os.close(s.fd)
|
||||
except OSError: pass
|
||||
|
||||
@staticmethod
|
||||
def _child_ctty():
|
||||
# CUA does not use setuid. Child has same UID as daemon.
|
||||
fcntl.ioctl(0, termios.TIOCSCTTY, 0)
|
||||
|
||||
def open(self, program='demo-readonly', rows=24, cols=80):
|
||||
if program != 'demo-readonly':
|
||||
raise TerminalDenied('PROGRAM_NOT_ALLOWLISTED')
|
||||
if os.geteuid() == 0:
|
||||
raise TerminalDenied('ROOT_DAEMON_FORBIDDEN')
|
||||
with self._lock:
|
||||
self.reap()
|
||||
if len(self._sessions) >= self.max_sessions:
|
||||
raise TerminalDenied('SESSION_LIMIT')
|
||||
screen = Screen(rows, cols)
|
||||
master, slave = pty.openpty()
|
||||
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', rows, cols, 0, 0))
|
||||
env = {'PATH': '/usr/bin:/bin', 'TERM': 'xterm', 'LANG': 'C.UTF-8',
|
||||
'HOME': str(Path.home())}
|
||||
try:
|
||||
proc = subprocess.Popen([sys.executable, '-B', str(Path(__file__).with_name('demo.py'))],
|
||||
stdin=slave, stdout=slave, stderr=slave, env=env, close_fds=True,
|
||||
start_new_session=True, preexec_fn=self._child_ctty)
|
||||
except BaseException:
|
||||
os.close(master)
|
||||
raise
|
||||
finally:
|
||||
os.close(slave)
|
||||
os.set_blocking(master, False)
|
||||
sid = secrets.token_hex(16)
|
||||
now = time.monotonic()
|
||||
self._sessions[sid] = Session(sid, proc, master, screen, now, now, program)
|
||||
return {'session_id': sid, 'program': program, 'state': 'OPEN'}
|
||||
|
||||
def _drain(self, s):
|
||||
total = 0
|
||||
while total < 65536:
|
||||
ready, _, _ = select.select([s.fd], [], [], 0)
|
||||
if not ready: break
|
||||
try: data = os.read(s.fd, min(4096, 65536-total))
|
||||
except (BlockingIOError, OSError): break
|
||||
if not data: break
|
||||
total += len(data)
|
||||
s.screen.feed(data)
|
||||
return total
|
||||
|
||||
def observe(self, sid):
|
||||
with self._lock:
|
||||
s = self._get(sid)
|
||||
n = self._drain(s)
|
||||
s.sequence += 1
|
||||
return {'session_id': sid, 'program': s.program, 'running': s.proc.poll() is None,
|
||||
'sequence': s.sequence, 'drained_bytes': n, 'screen': s.screen.snapshot()}
|
||||
|
||||
def status(self):
|
||||
with self._lock:
|
||||
self.reap()
|
||||
return {'active_sessions': len(self._sessions), 'max_sessions': self.max_sessions,
|
||||
'owner_device_connected': self._gate is not None,
|
||||
'input_enabled': False if self._gate is None else 'per-call-only'}
|
||||
|
||||
def _authorize(self, s, operation, payload: bytes, approved_context=None):
|
||||
if self._gate is None:
|
||||
raise TerminalDenied('OWNER_DEVICE_NOT_CONNECTED')
|
||||
if approved_context is None:
|
||||
raise TerminalDenied('OWNER_APPROVAL_REQUIRED')
|
||||
context = {'session_id': s.session_id, 'program': s.program, 'operation': operation,
|
||||
'payload_sha256': hashlib.sha256(payload).hexdigest(),
|
||||
'owner_assertion': approved_context}
|
||||
if self._gate(context) is not True:
|
||||
raise TerminalDenied('OWNER_APPROVAL_INVALID_OR_REPLAYED')
|
||||
|
||||
def key(self, sid, key, approved_context=None):
|
||||
if key not in KEYS:
|
||||
raise TerminalDenied('KEY_NOT_ALLOWLISTED')
|
||||
with self._lock:
|
||||
s = self._get(sid)
|
||||
data = KEYS[key]
|
||||
self._authorize(s, 'key.send', data, approved_context)
|
||||
if s.proc.poll() is not None: raise TerminalDenied('SESSION_CLOSED')
|
||||
os.write(s.fd, data)
|
||||
return {'session_id': sid, 'submitted': key, 'state': 'SENT'}
|
||||
|
||||
def text(self, sid, text, approved_context=None):
|
||||
if not isinstance(text, str) or not 1 <= len(text) <= 64 or any(ord(c)<32 or ord(c)==127 for c in text):
|
||||
raise TerminalDenied('TEXT_NOT_ALLOWED')
|
||||
with self._lock:
|
||||
s = self._get(sid)
|
||||
data = text.encode('utf-8')
|
||||
self._authorize(s, 'text.type', data, approved_context)
|
||||
if s.proc.poll() is not None: raise TerminalDenied('SESSION_CLOSED')
|
||||
os.write(s.fd, data)
|
||||
return {'session_id': sid, 'submitted_chars': len(text), 'state': 'SENT'}
|
||||
|
||||
def resize(self, sid, rows, cols):
|
||||
with self._lock:
|
||||
s = self._get(sid)
|
||||
s.screen.resize(rows, cols)
|
||||
fcntl.ioctl(s.fd, termios.TIOCSWINSZ, struct.pack('HHHH', rows, cols, 0, 0))
|
||||
return {'session_id': sid, 'rows': rows, 'cols': cols}
|
||||
|
||||
def abort(self, sid):
|
||||
# Emergency stop always works, but only against its own demo session.
|
||||
with self._lock:
|
||||
s = self._get(sid)
|
||||
self._terminate(s)
|
||||
del self._sessions[sid]
|
||||
return {'session_id': sid, 'state': 'ABORTED'}
|
||||
|
||||
def reap(self):
|
||||
for sid, s in list(self._sessions.items()):
|
||||
if time.monotonic() - s.last_access > self.idle_timeout_s:
|
||||
self._terminate(s)
|
||||
self._sessions.pop(sid, None)
|
||||
|
||||
def shutdown(self):
|
||||
with self._lock:
|
||||
for s in self._sessions.values(): self._terminate(s)
|
||||
self._sessions.clear()
|
||||
@@ -0,0 +1,43 @@
|
||||
"""No-side-effects simulated interactive menu. Not a shell."""
|
||||
import os
|
||||
import sys
|
||||
import tty
|
||||
|
||||
OPTIONS = ('Estado del sistema (demo)', 'Almacenamiento (demo)', 'Red (demo)', 'Salir')
|
||||
|
||||
|
||||
def show(i):
|
||||
sys.stdout.write('\x1b[2J\x1b[H')
|
||||
sys.stdout.write('FrameBridge CUA-TTY - DEMOSTRACION AISLADA\r\n')
|
||||
sys.stdout.write('NO ejecuta comandos; NO modifica el servidor.\r\n\r\n')
|
||||
for n, label in enumerate(OPTIONS):
|
||||
sys.stdout.write(('> ' if n == i else ' ') + label + '\r\n')
|
||||
sys.stdout.write('\r\nUse flechas arriba/abajo, Enter y q.\r\n')
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def main():
|
||||
tty.setcbreak(sys.stdin.fileno())
|
||||
i = 0
|
||||
show(i)
|
||||
while True:
|
||||
b = os.read(0, 1)
|
||||
if not b: break
|
||||
if b in (b'q', b'Q', b'\x03'): break
|
||||
if b == b'\x1b':
|
||||
seq = os.read(0, 2)
|
||||
if seq == b'[A': i = (i - 1) % len(OPTIONS)
|
||||
elif seq == b'[B': i = (i + 1) % len(OPTIONS)
|
||||
elif b in (b'j', b'J'): i = (i + 1) % len(OPTIONS)
|
||||
elif b in (b'k', b'K'): i = (i - 1) % len(OPTIONS)
|
||||
elif b in (b'\r', b'\n'):
|
||||
if i == len(OPTIONS) - 1: break
|
||||
sys.stdout.write('\r\nSeleccion simulada: ' + OPTIONS[i] + '\r\n')
|
||||
sys.stdout.flush()
|
||||
show(i)
|
||||
sys.stdout.write('\r\nDEMOSTRACION_FINALIZADA\r\n')
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Small bounded VT-like ASCII/UTF-8 text screen for staging.
|
||||
|
||||
This is deliberately NOT advertised as a full xterm emulator. It handles common
|
||||
CSI cursor/erase controls and suppresses OSC/DCS/other escape payloads.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import codecs
|
||||
import unicodedata
|
||||
|
||||
class Screen:
|
||||
def __init__(self, rows: int = 24, cols: int = 80):
|
||||
if not 8 <= rows <= 80 or not 20 <= cols <= 200:
|
||||
raise ValueError('SCREEN_DIMENSIONS_INVALID')
|
||||
self.rows, self.cols = rows, cols
|
||||
self.buf = [[' '] * cols for _ in range(rows)]
|
||||
self.y = self.x = 0
|
||||
self.mode = 'normal'
|
||||
self.control = ''
|
||||
self.decoder = codecs.getincrementaldecoder('utf-8')('replace')
|
||||
self.max_control = 96
|
||||
|
||||
def reset(self):
|
||||
self.buf = [[' '] * self.cols for _ in range(self.rows)]
|
||||
self.y = self.x = 0
|
||||
|
||||
def resize(self, rows: int, cols: int):
|
||||
if not 8 <= rows <= 80 or not 20 <= cols <= 200:
|
||||
raise ValueError('SCREEN_DIMENSIONS_INVALID')
|
||||
new = [[' '] * cols for _ in range(rows)]
|
||||
for y in range(min(rows, self.rows)):
|
||||
for x in range(min(cols, self.cols)):
|
||||
new[y][x] = self.buf[y][x]
|
||||
self.rows, self.cols, self.buf = rows, cols, new
|
||||
self.y = min(self.y, rows - 1)
|
||||
self.x = min(self.x, cols - 1)
|
||||
|
||||
def scroll(self):
|
||||
self.buf.pop(0)
|
||||
self.buf.append([' '] * self.cols)
|
||||
self.y = self.rows - 1
|
||||
|
||||
def newline(self):
|
||||
self.y += 1
|
||||
if self.y >= self.rows:
|
||||
self.scroll()
|
||||
|
||||
def put(self, c: str):
|
||||
if unicodedata.combining(c):
|
||||
return
|
||||
# Avoid unmodeled full-width terminal-cell ambiguity in this R1 pilot.
|
||||
if unicodedata.east_asian_width(c) in ('W', 'F'):
|
||||
c = '?'
|
||||
if self.x >= self.cols:
|
||||
self.x = 0
|
||||
self.newline()
|
||||
self.buf[self.y][self.x] = c
|
||||
self.x += 1
|
||||
|
||||
def csi(self, final: str, args: str):
|
||||
if len(args) > self.max_control:
|
||||
return
|
||||
if args.startswith('?'):
|
||||
if final in ('h', 'l') and args in ('?1049', '?1047', '?47') and final == 'h':
|
||||
self.reset()
|
||||
return
|
||||
nums = [int(a) if a.isdecimal() else 0 for a in args.split(';')[:8]]
|
||||
n = nums[0] if nums else 0
|
||||
count = max(1, min(n, 200))
|
||||
if final == 'A': self.y = max(0, self.y - count)
|
||||
elif final == 'B': self.y = min(self.rows - 1, self.y + count)
|
||||
elif final == 'C': self.x = min(self.cols - 1, self.x + count)
|
||||
elif final == 'D': self.x = max(0, self.x - count)
|
||||
elif final in ('H', 'f'):
|
||||
self.y = max(0, min(self.rows - 1, (nums[0] if nums and nums[0] else 1) - 1))
|
||||
self.x = max(0, min(self.cols - 1, (nums[1] if len(nums) > 1 and nums[1] else 1) - 1))
|
||||
elif final == 'G': self.x = max(0, min(self.cols - 1, count - 1))
|
||||
elif final == 'd': self.y = max(0, min(self.rows - 1, count - 1))
|
||||
elif final == 'J':
|
||||
if n == 2: self.reset()
|
||||
elif n == 0:
|
||||
for y in range(self.y, self.rows):
|
||||
start = self.x if y == self.y else 0
|
||||
for x in range(start, self.cols): self.buf[y][x] = ' '
|
||||
elif final == 'K':
|
||||
start, end = (0, self.cols) if n == 2 else ((0, self.x + 1) if n == 1 else (self.x, self.cols))
|
||||
for x in range(start, min(end, self.cols)): self.buf[self.y][x] = ' '
|
||||
# SGR and unrecognized commands are intentionally ignored.
|
||||
|
||||
def feed(self, data: bytes):
|
||||
for c in self.decoder.decode(data):
|
||||
if self.mode == 'normal':
|
||||
if c == '\x1b': self.mode = 'esc'
|
||||
elif c == '\r': self.x = 0
|
||||
elif c == '\n': self.newline()
|
||||
elif c == '\b': self.x = max(0, self.x - 1)
|
||||
elif c == '\t': self.x = min(self.cols - 1, (self.x // 8 + 1) * 8)
|
||||
elif c.isprintable() and c not in '\x7f': self.put(c)
|
||||
elif self.mode == 'esc':
|
||||
if c == '[': self.mode, self.control = 'csi', ''
|
||||
elif c == ']': self.mode, self.control = 'osc', ''
|
||||
elif c in ('P', '^', '_'): self.mode, self.control = 'str', ''
|
||||
elif c == 'c': self.reset(); self.mode = 'normal'
|
||||
else: self.mode = 'normal'
|
||||
elif self.mode == 'csi':
|
||||
if '@' <= c <= '~':
|
||||
self.csi(c, self.control); self.mode = 'normal'; self.control = ''
|
||||
elif len(self.control) >= self.max_control: self.mode, self.control = 'discard_csi', ''
|
||||
else: self.control += c
|
||||
elif self.mode == 'discard_csi':
|
||||
if '@' <= c <= '~': self.mode = 'normal'
|
||||
elif self.mode in ('osc', 'str'):
|
||||
if c == '\x07' and self.mode == 'osc': self.mode = 'normal'
|
||||
elif c == '\x1b': self.mode = 'string_esc'
|
||||
elif self.mode == 'string_esc':
|
||||
if c == '\\': self.mode = 'normal'
|
||||
elif c == '\x1b': self.mode = 'string_esc'
|
||||
else: self.mode = 'str'
|
||||
|
||||
def snapshot(self):
|
||||
return {'rows': self.rows, 'cols': self.cols, 'cursor': {'row': self.y, 'col': self.x},
|
||||
'lines': [''.join(row).rstrip() for row in self.buf]}
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Local-only JSON-lines Unix socket prototype. No TCP listener, no shell."""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import socketserver
|
||||
import stat
|
||||
import struct
|
||||
from .core import Manager, TerminalDenied
|
||||
|
||||
MAX_REQUEST = 65536
|
||||
|
||||
|
||||
class Handler(socketserver.StreamRequestHandler):
|
||||
def handle(self):
|
||||
# On Linux: SO_PEERCRED is the connecting process's kernel UID.
|
||||
try:
|
||||
_, uid, _ = struct.unpack('3i', self.request.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))
|
||||
if uid != self.server.owner_uid:
|
||||
return
|
||||
raw = self.rfile.readline(MAX_REQUEST + 1)
|
||||
if len(raw) > MAX_REQUEST or not raw.endswith(b'\n'):
|
||||
self.send({'ok': False, 'error': 'REQUEST_TOO_LARGE_OR_TRUNCATED'})
|
||||
return
|
||||
req = json.loads(raw)
|
||||
if type(req) is not dict or type(req.get('op')) is not str:
|
||||
self.send({'ok': False, 'error': 'INVALID_REQUEST'})
|
||||
return
|
||||
# Only specific RPC shapes. The caller cannot supply binary argv,
|
||||
# host path, a shell, an approval bool, or an OwnerDevice verifier.
|
||||
op = req['op']
|
||||
# Enforce exact wire shape for every operation. Untrusted clients
|
||||
# may not attach arbitrary authority or additional arguments.
|
||||
permitted = {
|
||||
'status': ({'op'},),
|
||||
'open': ({'op'}, {'op','program'}),
|
||||
'observe': ({'op','session_id'},),
|
||||
'resize': ({'op','session_id','rows','cols'},),
|
||||
'abort': ({'op','session_id'},),
|
||||
'key.send': ({'op','session_id','key','owner_assertion'},),
|
||||
'text.type': ({'op','session_id','text','owner_assertion'},),
|
||||
}
|
||||
accepted = permitted.get(op)
|
||||
if accepted is None:
|
||||
raise TerminalDenied('UNKNOWN_OPERATION')
|
||||
if set(req) not in accepted:
|
||||
raise TerminalDenied('REQUEST_INVALID')
|
||||
m = self.server.manager
|
||||
if op == 'status': data = m.status()
|
||||
elif op == 'open': data = m.open(req.get('program', 'demo-readonly'))
|
||||
elif op == 'observe': data = m.observe(req['session_id'])
|
||||
elif op == 'resize': data = m.resize(req['session_id'], req['rows'], req['cols'])
|
||||
elif op == 'abort': data = m.abort(req['session_id'])
|
||||
elif op in ('key.send', 'text.type'):
|
||||
# Server default remains Manager(owner_verifier=None) -> DENY.
|
||||
# A separately deployed trusted bootstrap must bind a real
|
||||
# WebAuthn/Owner verifier. Neither AUTHORIZED nor an RPC bool
|
||||
# can alter the verifier. Never accept arbitrary shell/argv.
|
||||
if m._gate is None:
|
||||
raise TerminalDenied('OWNER_DEVICE_NOT_CONNECTED')
|
||||
if type(req.get('owner_assertion')) is not dict:
|
||||
raise TerminalDenied('OWNER_APPROVAL_REQUIRED')
|
||||
required = ({'op', 'session_id', 'key', 'owner_assertion'}
|
||||
if op == 'key.send' else
|
||||
{'op', 'session_id', 'text', 'owner_assertion'})
|
||||
if set(req) != required:
|
||||
raise TerminalDenied('REQUEST_INVALID')
|
||||
if op == 'key.send':
|
||||
data = m.key(req['session_id'], req['key'],
|
||||
approved_context=req['owner_assertion'])
|
||||
else:
|
||||
data = m.text(req['session_id'], req['text'],
|
||||
approved_context=req['owner_assertion'])
|
||||
else: raise TerminalDenied('UNKNOWN_OPERATION')
|
||||
self.send({'ok': True, 'result': data})
|
||||
except (ValueError, KeyError, TypeError, json.JSONDecodeError):
|
||||
self.send({'ok': False, 'error': 'REQUEST_INVALID'})
|
||||
except TerminalDenied as exc:
|
||||
self.send({'ok': False, 'error': str(exc)})
|
||||
except Exception:
|
||||
# Never echo arbitrary error content, terminal output or secrets.
|
||||
self.send({'ok': False, 'error': 'INTERNAL_ERROR'})
|
||||
|
||||
def send(self, data):
|
||||
self.wfile.write((json.dumps(data, ensure_ascii=True, separators=(',', ':')) + '\n').encode())
|
||||
|
||||
|
||||
class LocalServer(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
|
||||
daemon_threads = True
|
||||
allow_reuse_address = False
|
||||
def __init__(self, path, manager=None):
|
||||
if os.geteuid() == 0:
|
||||
raise TerminalDenied('ROOT_SERVER_FORBIDDEN')
|
||||
p = Path(path).absolute()
|
||||
p.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
# Do not operate in a shared or symlinked parent directory.
|
||||
if p.parent.is_symlink() or stat.S_IMODE(p.parent.stat().st_mode) != 0o700:
|
||||
raise TerminalDenied('SOCKET_PARENT_NOT_PRIVATE')
|
||||
if p.exists() or p.is_symlink():
|
||||
raise TerminalDenied('SOCKET_ALREADY_EXISTS')
|
||||
self.owner_uid = os.geteuid()
|
||||
self.manager = manager or Manager()
|
||||
self._socket_path = p
|
||||
old = os.umask(0o077)
|
||||
try: super().__init__(str(p), Handler)
|
||||
finally: os.umask(old)
|
||||
os.chmod(p, 0o600)
|
||||
|
||||
def server_close(self):
|
||||
self.manager.shutdown()
|
||||
super().server_close()
|
||||
try: self._socket_path.unlink(missing_ok=True)
|
||||
except OSError: pass
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description='FrameBridge CUA-TTY staging demo, local socket only')
|
||||
parser.add_argument('--socket', default=str(Path.home()/'.local/share/framebridge-cua-tty-r1/agent.sock'))
|
||||
args = parser.parse_args(argv)
|
||||
with LocalServer(args.socket) as server:
|
||||
print('CUA_TTY_R1_LOCAL_ONLY_SOCKET_READY', flush=True)
|
||||
try: server.serve_forever(poll_interval=0.2)
|
||||
except KeyboardInterrupt: pass
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/python3
|
||||
"""GERAMEG CUA-TTY with the explicitly selected local-user input policy.
|
||||
|
||||
Reuse the installed LocalServer and Manager without changing their source,
|
||||
socket protocol, existing demos and PTY handling, or input/session limits for demos.
|
||||
|
||||
Remote callers must still enter through authenticated FrameBridge. This
|
||||
process authenticates LOCAL peers by the original LocalServer's SO_PEERCRED
|
||||
check. It cannot distinguish an authenticated FBEXEC process from another
|
||||
process running as gerabh (UID 1000). Both have the same local authority.
|
||||
|
||||
The legacy owner_assertion RPC field remains mandatory for wire compatibility
|
||||
but must be an empty object. It is not a grant, signature, or approval flag.
|
||||
No Owner R12 signing code, private keys, or FBAUTH state is loaded.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import signal
|
||||
import stat
|
||||
|
||||
|
||||
EXPECTED_UID = 1000
|
||||
EXPECTED_HOST = "GERAMEG"
|
||||
DEFAULT_SOCKET = "/run/user/1000/framebridge-alpha01-cua/agent.sock"
|
||||
_SESSION_ID = re.compile(r"[a-f0-9]{32}\Z")
|
||||
_PAYLOAD_HASH = re.compile(r"[a-f0-9]{64}\Z")
|
||||
_CONTEXT_FIELDS = frozenset(
|
||||
{"session_id", "program", "operation", "payload_sha256", "owner_assertion"}
|
||||
)
|
||||
|
||||
|
||||
def require_local_identity() -> None:
|
||||
if (
|
||||
os.getuid() != EXPECTED_UID
|
||||
or os.geteuid() != EXPECTED_UID
|
||||
or os.uname().nodename.upper() != EXPECTED_HOST
|
||||
):
|
||||
raise RuntimeError("CUA_LOCAL_UID_OR_HOST_INVALID")
|
||||
|
||||
|
||||
class LocalUidVerifier:
|
||||
"""Apply the selected UID policy after LocalServer checks the peer UID.
|
||||
|
||||
Manager constructs this context after resolving its own live session and
|
||||
validating the input. No caller-provided boolean or assertion grants access.
|
||||
"""
|
||||
|
||||
def __call__(self, context: object) -> bool:
|
||||
try:
|
||||
require_local_identity()
|
||||
if type(context) is not dict or set(context) != _CONTEXT_FIELDS:
|
||||
return False
|
||||
if context.get("program") not in ("demo-readonly", "bash"):
|
||||
return False
|
||||
if context.get("operation") not in ("key.send", "text.type"):
|
||||
return False
|
||||
session_id = context.get("session_id")
|
||||
payload_hash = context.get("payload_sha256")
|
||||
if type(session_id) is not str or _SESSION_ID.fullmatch(session_id) is None:
|
||||
return False
|
||||
if type(payload_hash) is not str or _PAYLOAD_HASH.fullmatch(payload_hash) is None:
|
||||
return False
|
||||
assertion = context.get("owner_assertion")
|
||||
return type(assertion) is dict and not assertion
|
||||
except (AttributeError, OSError, RuntimeError, TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _stop(_signum: int, _frame: object) -> None:
|
||||
# Exiting the LocalServer context closes only this runtime's own sessions
|
||||
# and socket. Do not leave a stale socket after a normal systemd stop.
|
||||
raise SystemExit(0)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
parser = argparse.ArgumentParser(description="GERAMEG CUA-TTY local UID policy")
|
||||
parser.add_argument("--socket", default=DEFAULT_SOCKET)
|
||||
args = parser.parse_args(argv)
|
||||
require_local_identity()
|
||||
if args.socket != DEFAULT_SOCKET:
|
||||
raise RuntimeError("CUA_LOCAL_SOCKET_INVALID")
|
||||
|
||||
parent = Path(args.socket).parent
|
||||
try:
|
||||
parent_state = parent.lstat()
|
||||
except FileNotFoundError:
|
||||
# LocalServer creates its own private directory with mode 0700.
|
||||
pass
|
||||
else:
|
||||
if (
|
||||
not stat.S_ISDIR(parent_state.st_mode)
|
||||
or parent_state.st_uid != EXPECTED_UID
|
||||
or stat.S_IMODE(parent_state.st_mode) != 0o700
|
||||
):
|
||||
raise RuntimeError("CUA_LOCAL_SOCKET_PARENT_UNTRUSTED")
|
||||
|
||||
# Keep imports on the existing service's installed PYTHONPATH. This file
|
||||
# does not copy, patch, or replace the original CUA-TTY package.
|
||||
from framebridge_cua_tty.core import Manager
|
||||
from framebridge_cua_tty.server import LocalServer
|
||||
|
||||
from real_tty_manager import RealTerminalMixin
|
||||
|
||||
class LocalPolicyManager(RealTerminalMixin, Manager):
|
||||
def status(self):
|
||||
value = super().status()
|
||||
# A callable verifier is not evidence of an Owner device. Report
|
||||
# the selected authority honestly while retaining session status.
|
||||
value["owner_device_connected"] = False
|
||||
value["input_enabled"] = "same-uid-1000"
|
||||
value["input_policy"] = "local-uid-1000"
|
||||
value["owner_signature_required"] = False
|
||||
return value
|
||||
|
||||
manager = LocalPolicyManager(
|
||||
owner_verifier=LocalUidVerifier(), max_sessions=2, idle_timeout_s=600
|
||||
)
|
||||
signal.signal(signal.SIGTERM, _stop)
|
||||
with LocalServer(args.socket, manager=manager) as server:
|
||||
print("CUA_TTY_READY POLICY=LOCAL_UID_1000 OWNER_SIGNATURE_REQUIRED=NO", flush=True)
|
||||
try:
|
||||
server.serve_forever(poll_interval=0.2)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Real terminals for the existing FrameBridge CUA-TTY Manager.
|
||||
Transport identity and same-UID socket policy are unchanged. No Owner prompts.
|
||||
Only this module's tmux sessions are operated on; no existing tty is hijacked.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
from framebridge_cua_tty.core import TerminalDenied
|
||||
|
||||
TMUX_SOCKET = '/run/user/1000/framebridge-alpha01-cua/real-tmux.sock'
|
||||
KEY_NAMES = {'UP':'Up','DOWN':'Down','LEFT':'Left','RIGHT':'Right',
|
||||
'ENTER':'Enter','RETURN':'Enter','TAB':'Tab','SHIFT_TAB':'BTab','ESC':'Escape',
|
||||
'BACKSPACE':'BSpace','DELETE':'DC','INSERT':'IC','HOME':'Home','END':'End',
|
||||
'PAGE_UP':'PPage','PAGE_DOWN':'NPage','SPACE':'Space'}
|
||||
KEY_NAMES.update({'CTRL_'+c:'C-'+c.lower() for c in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'})
|
||||
KEY_NAMES.update({'F'+str(i):'F'+str(i) for i in range(1,13)})
|
||||
|
||||
@dataclass
|
||||
class RealSession:
|
||||
session_id: str
|
||||
name: str
|
||||
pane: str
|
||||
born: float
|
||||
program: str = 'bash'
|
||||
sequence: int = 0
|
||||
|
||||
class RealTerminalMixin:
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._real = {}
|
||||
|
||||
def _tmux(self, *args, data=None, optional=False):
|
||||
env={k:v for k,v in os.environ.items() if k not in ('TMUX','TMUX_PANE')}
|
||||
env.update({'HOME':str(Path.home()),'USER':'gerabh','LOGNAME':'gerabh',
|
||||
'LANG':'C.UTF-8','TERM':'xterm-256color','HISTFILE':'/dev/null'})
|
||||
try:
|
||||
p=subprocess.run(['/usr/bin/tmux','-S',TMUX_SOCKET,'-f','/dev/null',*args],
|
||||
input=data,stdout=subprocess.PIPE,stderr=subprocess.PIPE,env=env,timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise TerminalDenied('TMUX_COMMAND_TIMEOUT') from None
|
||||
except OSError:
|
||||
raise TerminalDenied('TMUX_NOT_AVAILABLE') from None
|
||||
if p.returncode and not optional:
|
||||
raise TerminalDenied('TMUX_COMMAND_FAILED')
|
||||
return p
|
||||
|
||||
def _info(self, session):
|
||||
keys=['pane_width','pane_height','cursor_x','cursor_y','pane_dead',
|
||||
'pane_dead_status','pane_pid']
|
||||
fmt='|'.join('#{'+key+'}' for key in keys)
|
||||
p=self._tmux('display-message','-p','-t',session.pane,fmt)
|
||||
parts=p.stdout.decode('utf-8','replace').rstrip('\n').split('|')
|
||||
if len(parts)!=len(keys):raise TerminalDenied('TMUX_METADATA_INVALID')
|
||||
info=dict(zip(keys,parts))
|
||||
for key in ['pane_current_path','pane_current_command','pane_tty']:
|
||||
p=self._tmux('display-message','-p','-t',session.pane,'#{'+key+'}')
|
||||
info[key]=p.stdout.decode('utf-8','replace').rstrip('\n')
|
||||
return info
|
||||
|
||||
def open(self, program='demo-readonly', rows=24, cols=80):
|
||||
if program!='bash':
|
||||
with self._lock:
|
||||
self.reap()
|
||||
if len(self._sessions)+len(self._real)>=self.max_sessions:
|
||||
raise TerminalDenied('SESSION_LIMIT')
|
||||
return super().open(program,rows,cols)
|
||||
if not (5<=rows<=100 and 20<=cols<=220):raise TerminalDenied('SIZE_INVALID')
|
||||
with self._lock:
|
||||
self.reap()
|
||||
if len(self._sessions)+len(self._real)>=self.max_sessions:
|
||||
raise TerminalDenied('SESSION_LIMIT')
|
||||
sid=uuid.uuid4().hex
|
||||
name='fb-real-'+sid
|
||||
pane=None
|
||||
try:
|
||||
p=self._tmux('new-session','-d','-s',name,'-x',str(cols),'-y',str(rows),
|
||||
'-c',str(Path.home()),'-P','-F','#{pane_id}',
|
||||
'/usr/bin/bash --noprofile --norc -i')
|
||||
pane=p.stdout.decode().strip()
|
||||
if not re.fullmatch(r'%[0-9]+',pane):raise TerminalDenied('PANE_ID_INVALID')
|
||||
self._tmux('set-option','-p','-t',pane,'remain-on-exit','on')
|
||||
self._tmux('set-option','-t',name,'status','off')
|
||||
self._tmux('set-option','-t',name,'history-limit','10000')
|
||||
s=RealSession(sid,name,pane,time.monotonic())
|
||||
self._real[sid]=s
|
||||
info=self._info(s)
|
||||
return {'session_id':sid,'program':'bash','state':'OPEN','backend':'tmux',
|
||||
'real_terminal':True,'tty':info['pane_tty'],'pid':int(info['pane_pid']),
|
||||
'cwd':info['pane_current_path'],'persistent_between_chat_calls':True,
|
||||
'attach_command':'tmux -S '+TMUX_SOCKET+' attach-session -t '+name}
|
||||
except BaseException:
|
||||
self._real.pop(sid,None)
|
||||
self._tmux('kill-session','-t',name,optional=True)
|
||||
raise
|
||||
|
||||
def status(self):
|
||||
with self._lock:
|
||||
value=super().status()
|
||||
sessions=[]
|
||||
for sid,s in list(self._real.items()):
|
||||
try:
|
||||
info=self._info(s)
|
||||
except TerminalDenied:
|
||||
self._real.pop(sid,None)
|
||||
continue
|
||||
sessions.append({'session_id':sid,'program':'bash',
|
||||
'running':info['pane_dead']!='1','tty':info['pane_tty'],
|
||||
'cwd':info['pane_current_path'],'command':info['pane_current_command']})
|
||||
value.update({'active_sessions':value['active_sessions']+len(sessions),
|
||||
'real_sessions':sessions,'real_terminal_enabled':True,
|
||||
'real_programs':['bash'],'input_policy':'local-uid-1000',
|
||||
'owner_device_connected':False,'owner_signature_required':False,
|
||||
'input_enabled':'same-uid-1000','real_idle_timeout_s':None})
|
||||
return value
|
||||
|
||||
def observe(self, sid):
|
||||
with self._lock:
|
||||
if sid not in self._real:return super().observe(sid)
|
||||
s=self._real[sid];info=self._info(s)
|
||||
text=self._tmux('capture-pane','-p','-t',s.pane).stdout.decode('utf-8','replace')
|
||||
lines=text.splitlines()
|
||||
# The original RPC response has a finite frame size, not an approval gate.
|
||||
limited=False
|
||||
import json
|
||||
while len(json.dumps(lines,ensure_ascii=True).encode())>48000:
|
||||
lines=lines[:-1];limited=True
|
||||
return {'session_id':sid,'program':'bash','real_terminal':True,'backend':'tmux',
|
||||
'running':info['pane_dead']!='1','exit_code':int(info['pane_dead_status']) if info['pane_dead_status'] else None,
|
||||
'cwd':info['pane_current_path'],'current_command':info['pane_current_command'],
|
||||
'tty':info['pane_tty'],'pid':int(info['pane_pid']),'sequence':s.sequence,
|
||||
'screen':{'rows':int(info['pane_height']),'cols':int(info['pane_width']),
|
||||
'cursor':{'row':int(info['cursor_y']),'col':int(info['cursor_x'])},'lines':lines},
|
||||
'screen_truncated':limited,'screen_sha256':hashlib.sha256(text.encode()).hexdigest()}
|
||||
|
||||
def text(self, sid, text, approved_context=None):
|
||||
with self._lock:
|
||||
if sid not in self._real:return super().text(sid,text,approved_context=approved_context)
|
||||
if not isinstance(text,str) or not text or '\x00' in text or len(text.encode())>16384:
|
||||
raise TerminalDenied('TEXT_INVALID_OR_TOO_LARGE')
|
||||
s=self._real[sid];payload=text.encode('utf-8')
|
||||
self._authorize(s,'text.type',payload,approved_context)
|
||||
if self._info(s)['pane_dead']=='1':raise TerminalDenied('SESSION_EXITED')
|
||||
name='fb-input-'+sid
|
||||
self._tmux('load-buffer','-b',name,'-',data=payload)
|
||||
try:self._tmux('paste-buffer','-d','-r','-b',name,'-t',s.pane)
|
||||
finally:self._tmux('delete-buffer','-b',name,optional=True)
|
||||
s.sequence+=1
|
||||
return {'session_id':sid,'state':'TYPED','submitted_chars':len(text),'sequence':s.sequence}
|
||||
|
||||
def key(self, sid, key, approved_context=None):
|
||||
with self._lock:
|
||||
if sid not in self._real:return super().key(sid,key,approved_context=approved_context)
|
||||
if not isinstance(key,str) or key not in KEY_NAMES:raise TerminalDenied('KEY_NOT_ALLOWLISTED')
|
||||
s=self._real[sid]
|
||||
self._authorize(s,'key.send',key.encode(),approved_context)
|
||||
if self._info(s)['pane_dead']=='1':raise TerminalDenied('SESSION_EXITED')
|
||||
self._tmux('send-keys','-t',s.pane,KEY_NAMES[key]);s.sequence+=1
|
||||
return {'session_id':sid,'state':'KEY_SENT','submitted':key,'sequence':s.sequence}
|
||||
|
||||
def resize(self, sid, rows, cols):
|
||||
with self._lock:
|
||||
if sid not in self._real:return super().resize(sid,rows,cols)
|
||||
if type(rows) is not int or type(cols) is not int or not (5<=rows<=100 and 20<=cols<=220):
|
||||
raise TerminalDenied('SIZE_INVALID')
|
||||
self._tmux('resize-window','-t',self._real[sid].pane,'-x',str(cols),'-y',str(rows))
|
||||
self._real[sid].sequence+=1
|
||||
return {'session_id':sid,'rows':rows,'cols':cols}
|
||||
|
||||
def abort(self, sid):
|
||||
with self._lock:
|
||||
if sid not in self._real:return super().abort(sid)
|
||||
s=self._real[sid]
|
||||
self._tmux('kill-session','-t',s.name)
|
||||
self._real.pop(sid,None)
|
||||
probe=self._tmux('has-session','-t',s.name,optional=True)
|
||||
if probe.returncode==0:raise TerminalDenied('SESSION_CLOSE_NOT_VERIFIED')
|
||||
return {'session_id':sid,'state':'CLOSED','cleanup_verified':True}
|
||||
|
||||
def shutdown(self):
|
||||
with self._lock:
|
||||
for sid in list(self._real):
|
||||
try:self.abort(sid)
|
||||
except TerminalDenied:pass
|
||||
super().shutdown()
|
||||
Reference in new issue
Block a user