From f73c23bf57f6929fd59072cad04521f61d6c7bba Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Sat, 30 May 2026 16:41:34 -0700 Subject: [PATCH] =?UTF-8?q?feat(hh):=20P4=20=E2=80=94=20permissions=20(own?= =?UTF-8?q?er/superuser=20+=20drive=20delegation)=20=E2=9B=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App-level RBAC over the single shared PTY, enforced by the broker: - The sandbox launcher becomes owner (superuser) and first driver; broadcasts an encrypted {"_perm":"acl",owner,drivers} frame all clients track. - /grant and /revoke (owner-only) delegate/withdraw drive rights = delegating control of the shared (root) shell — the superuser-delegation ask. - The broker honors {"_sbx":"input"} only from permitted drivers, keyed on the SERVER-AUTHENTICATED sender (the message username the Sanic session stamps), not a spoofable self-asserted field — closes the spec's identity-binding gap. - F2 is gated: non-drivers get 'ask the owner to /grant you'; revoke drops drive live. Roster shows roles: ⛧ owner · ◆ driver · • member. Verified live (two TUIs): member blocked pre-grant, owner /grant member, member then drives a command in the sandbox; roster + permission messages all correct. cargo test: 4 pass. Note: per the single-shared-PTY decision, drive-grant *is* the permission model; per-user unix accounts/sudo would need per-user shells (future mode). Co-Authored-By: Claude Opus 4.8 --- hh/src/app.rs | 95 ++++++++++++++++++++++++++++++++++++++++++++++++--- hh/src/net.rs | 35 ++++++++++++++++--- hh/src/ui.rs | 10 +++++- 3 files changed, 130 insertions(+), 10 deletions(-) diff --git a/hh/src/app.rs b/hh/src/app.rs index 230f041..a20681d 100644 --- a/hh/src/app.rs +++ b/hh/src/app.rs @@ -47,7 +47,8 @@ pub enum Net { SbxStatus { backend: String, ready: bool, rows: u16, cols: u16 }, SbxResize { rows: u16, cols: u16 }, SbxData(Vec), - SbxInput(Vec), + SbxInput { from: String, bytes: Vec }, + Perm { owner: String, drivers: Vec }, Sys(String), Closed, } @@ -79,6 +80,10 @@ pub struct App { pub connected: bool, pub sandbox: Option, pub driving: bool, + /// Sandbox owner (the initiator / superuser). Empty until a sandbox launches. + pub owner: Option, + /// Members allowed to drive the shared shell (always includes the owner). + pub drivers: std::collections::HashSet, } impl App { @@ -92,9 +97,19 @@ impl App { connected: false, sandbox: None, driving: false, + owner: None, + drivers: std::collections::HashSet::new(), } } + pub fn is_owner(&self) -> bool { + self.owner.as_deref() == Some(self.me.as_str()) + } + + pub fn can_drive(&self) -> bool { + self.drivers.contains(&self.me) + } + fn sys(&mut self, text: impl Into) { self.lines.push(ChatLine { ts: String::new(), @@ -135,9 +150,26 @@ impl App { } else { self.sandbox = None; self.driving = false; + self.owner = None; + self.drivers.clear(); self.sys("⛧ sandbox dismissed"); } } + Net::Perm { owner, drivers } => { + let new: std::collections::HashSet = drivers.into_iter().collect(); + // Surface changes that affect me. + if !owner.is_empty() && self.owner.as_deref() != Some(owner.as_str()) { + self.sys(format!("⛧ {owner} is the superuser (sandbox owner)")); + } + if new.contains(&self.me) && !self.drivers.contains(&self.me) && self.owner.is_some() { + self.sys("⛧ you were granted drive (you can drive — F2)"); + } else if !new.contains(&self.me) && self.drivers.contains(&self.me) { + self.driving = false; + self.sys("⛧ your drive permission was revoked"); + } + self.owner = Some(owner).filter(|o| !o.is_empty()); + self.drivers = new; + } Net::SbxResize { rows, cols } => { if let Some(v) = &mut self.sandbox { v.parser.set_size(rows.max(1), cols.max(1)); @@ -148,7 +180,7 @@ impl App { v.parser.process(&bytes); } } - Net::SbxInput(_) => {} // broker writes to PTY in the run loop + Net::SbxInput { .. } => {} // broker enforces + writes to PTY in the run loop Net::Sys(t) => self.sys(t), Net::Closed => { self.connected = false; @@ -196,6 +228,20 @@ where let _ = write.send(WsMsg::Text(ct)).await; } +/// Broadcast the current access-control list (owner + permitted drivers). +async fn broadcast_acl(write: &mut S, room: &fernet::Fernet, app: &App) +where + S: SinkExt + Unpin, +{ + let drivers: Vec<&String> = app.drivers.iter().collect(); + send_frame( + write, + room, + json!({"_perm":"acl","owner": app.owner, "drivers": drivers}), + ) + .await; +} + pub async fn run(session: Session, theme: Theme) -> Result<()> { let ws = net::connect(&session).await?; let (mut write, read) = ws.split(); @@ -250,8 +296,12 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> { break Ok(()); } if k.code == KeyCode::F(2) { - if app.sandbox.is_some() { + if app.sandbox.is_none() { + // nothing to drive + } else if app.can_drive() { app.driving = !app.driving; + } else { + app.sys("you don't have drive permission — the owner can /grant you"); } } else if app.driving { if k.code == KeyCode::Esc { @@ -304,6 +354,30 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> { } _ => app.sys("usage: /sbx launch [local|docker|multipass] [image] | /sbx stop"), } + } else if let Some(rest) = line.strip_prefix("/grant") { + let target = rest.trim(); + if !app.is_owner() { + app.sys("only the sandbox owner can /grant"); + } else if target.is_empty() { + app.sys("usage: /grant "); + } else { + app.drivers.insert(target.to_string()); + broadcast_acl(&mut write, &session.room, &app).await; + app.sys(format!("granted drive to {target}")); + } + } else if let Some(rest) = line.strip_prefix("/revoke") { + let target = rest.trim(); + if !app.is_owner() { + app.sys("only the sandbox owner can /revoke"); + } else if target == app.me { + app.sys("the owner cannot revoke themselves"); + } else if target.is_empty() { + app.sys("usage: /revoke "); + } else { + app.drivers.remove(target); + broadcast_acl(&mut write, &session.room, &app).await; + app.sys(format!("revoked drive from {target}")); + } } else if !line.is_empty() && app.connected { let ct = session.room.encrypt(line.as_bytes()); if write.send(WsMsg::Text(ct)).await.is_err() { @@ -323,8 +397,14 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> { } net = rx.recv() => { match net { - Some(Net::SbxInput(b)) => { - if let Some(sb) = &mut broker { let _ = sb.write_input(&b); } + Some(Net::SbxInput { from, bytes }) => { + // Broker authority: only honor input from a permitted driver + // (sender is server-authenticated via the message username). + if let Some(sb) = &mut broker { + if app.drivers.contains(&from) { + let _ = sb.write_input(&bytes); + } + } } Some(n) => app.apply(n), None => break Ok(()), @@ -337,10 +417,15 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> { broker_meta = Some((backend, name)); announced_dims = Some((rows, cols)); launching = false; + // The launcher is the owner / superuser and the first driver. + app.owner = Some(app.me.clone()); + app.drivers.clear(); + app.drivers.insert(app.me.clone()); send_frame(&mut write, &session.room, json!({ "_sbx":"status","state":"ready", "backend": backend.label(), "rows": rows, "cols": cols })).await; + broadcast_acl(&mut write, &session.room, &app).await; } Some(BrokerMsg::Failed) => { launching = false; } None => {} diff --git a/hh/src/net.rs b/hh/src/net.rs index 22048b8..c1ebd72 100644 --- a/hh/src/net.rs +++ b/hh/src/net.rs @@ -131,10 +131,15 @@ fn decode_msg(room: &fernet::Fernet, m: &Value, allow_sbx: bool) -> Decoded { if t.starts_with("{\"_ft\":") { return Decoded::Skip; // file-transfer control frame (P5) } + // Server-stamped (authenticated) sender of this message. + let sender = m["username"].as_str().unwrap_or("?"); + if t.starts_with("{\"_perm\":") { + return parse_perm(&t).map(Decoded::Sbx).unwrap_or(Decoded::Skip); + } if t.starts_with("{\"_sbx\":") { // Don't replay terminal history from the stored snapshot. return if allow_sbx { - parse_sbx(&t).map(Decoded::Sbx).unwrap_or(Decoded::Skip) + parse_sbx(&t, sender).map(Decoded::Sbx).unwrap_or(Decoded::Skip) } else { Decoded::Skip }; @@ -153,8 +158,9 @@ fn decode_msg(room: &fernet::Fernet, m: &Value, allow_sbx: bool) -> Decoded { }) } -/// Parse a decrypted `{"_sbx":...}` frame into a Net event. -fn parse_sbx(text: &str) -> Option { +/// Parse a decrypted `{"_sbx":...}` frame into a Net event. `sender` is the +/// server-authenticated username of whoever sent it (used to gate drive input). +fn parse_sbx(text: &str, sender: &str) -> Option { let v: Value = serde_json::from_str(text).ok()?; match v["_sbx"].as_str()? { "status" => Some(Net::SbxStatus { @@ -168,11 +174,32 @@ fn parse_sbx(text: &str) -> Option { cols: v["cols"].as_u64().unwrap_or(80) as u16, }), "data" => Some(Net::SbxData(STANDARD.decode(v["b64"].as_str()?).ok()?)), - "input" => Some(Net::SbxInput(STANDARD.decode(v["b64"].as_str()?).ok()?)), + "input" => Some(Net::SbxInput { + from: sender.to_string(), + bytes: STANDARD.decode(v["b64"].as_str()?).ok()?, + }), _ => None, } } +/// Parse a decrypted `{"_perm":"acl",...}` frame. +fn parse_perm(text: &str) -> Option { + let v: Value = serde_json::from_str(text).ok()?; + if v["_perm"].as_str()? != "acl" { + return None; + } + let drivers = v["drivers"] + .as_array() + .into_iter() + .flatten() + .filter_map(|d| d.as_str().map(str::to_string)) + .collect(); + Some(Net::Perm { + owner: v["owner"].as_str().unwrap_or("").to_string(), + drivers, + }) +} + /// Read websocket frames forever, forwarding decoded `Net` events to the UI. pub async fn reader(mut read: impl StreamExt> + Unpin, room: Arc, tx: UnboundedSender) { while let Some(frame) = read.next().await { diff --git a/hh/src/ui.rs b/hh/src/ui.rs index 638829c..111e9d8 100644 --- a/hh/src/ui.rs +++ b/hh/src/ui.rs @@ -116,7 +116,15 @@ fn draw_roster(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Th .iter() .map(|u| { let me = u.username == app.me; - let mark = if me { "⛧" } else { "•" }; + // ⛧ owner/superuser · ◆ may drive · • member + let owner = app.owner.as_deref() == Some(u.username.as_str()); + let mark = if owner { + "⛧" + } else if app.drivers.contains(&u.username) { + "◆" + } else { + "•" + }; let color = if me { theme.roster_me } else { theme.other }; ListItem::new(Line::from(Span::styled( format!(" {mark} {}", u.username),