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,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()