FrameBridge 0.3.1 community source release

This commit is contained in:
FrameBridge Development committed 2026-10-26 22:09:18 -04:00
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()