feat(hh): P4 — permissions (owner/superuser + drive delegation) ⛧

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 <user> and /revoke <user> (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 <noreply@anthropic.com>
This commit is contained in:
leetcrypt 2026-05-30 16:41:34 -07:00
parent d8018cbe2a
commit f73c23bf57
3 changed files with 130 additions and 10 deletions

View File

@ -47,7 +47,8 @@ pub enum Net {
SbxStatus { backend: String, ready: bool, rows: u16, cols: u16 },
SbxResize { rows: u16, cols: u16 },
SbxData(Vec<u8>),
SbxInput(Vec<u8>),
SbxInput { from: String, bytes: Vec<u8> },
Perm { owner: String, drivers: Vec<String> },
Sys(String),
Closed,
}
@ -79,6 +80,10 @@ pub struct App {
pub connected: bool,
pub sandbox: Option<SbxView>,
pub driving: bool,
/// Sandbox owner (the initiator / superuser). Empty until a sandbox launches.
pub owner: Option<String>,
/// Members allowed to drive the shared shell (always includes the owner).
pub drivers: std::collections::HashSet<String>,
}
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<String>) {
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<String> = 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<S>(write: &mut S, room: &fernet::Fernet, app: &App)
where
S: SinkExt<WsMsg> + 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 <user>");
} 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 <user>");
} 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 => {}

View File

@ -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<Net> {
/// 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<Net> {
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<Net> {
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<Net> {
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<Item = Result<WsMsg, tokio_tungstenite::tungstenite::Error>> + Unpin, room: Arc<fernet::Fernet>, tx: UnboundedSender<Net>) {
while let Some(frame) = read.next().await {

View File

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