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:
parent
d8018cbe2a
commit
f73c23bf57
|
|
@ -47,7 +47,8 @@ pub enum Net {
|
||||||
SbxStatus { backend: String, ready: bool, rows: u16, cols: u16 },
|
SbxStatus { backend: String, ready: bool, rows: u16, cols: u16 },
|
||||||
SbxResize { rows: u16, cols: u16 },
|
SbxResize { rows: u16, cols: u16 },
|
||||||
SbxData(Vec<u8>),
|
SbxData(Vec<u8>),
|
||||||
SbxInput(Vec<u8>),
|
SbxInput { from: String, bytes: Vec<u8> },
|
||||||
|
Perm { owner: String, drivers: Vec<String> },
|
||||||
Sys(String),
|
Sys(String),
|
||||||
Closed,
|
Closed,
|
||||||
}
|
}
|
||||||
|
|
@ -79,6 +80,10 @@ pub struct App {
|
||||||
pub connected: bool,
|
pub connected: bool,
|
||||||
pub sandbox: Option<SbxView>,
|
pub sandbox: Option<SbxView>,
|
||||||
pub driving: bool,
|
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 {
|
impl App {
|
||||||
|
|
@ -92,9 +97,19 @@ impl App {
|
||||||
connected: false,
|
connected: false,
|
||||||
sandbox: None,
|
sandbox: None,
|
||||||
driving: false,
|
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>) {
|
fn sys(&mut self, text: impl Into<String>) {
|
||||||
self.lines.push(ChatLine {
|
self.lines.push(ChatLine {
|
||||||
ts: String::new(),
|
ts: String::new(),
|
||||||
|
|
@ -135,9 +150,26 @@ impl App {
|
||||||
} else {
|
} else {
|
||||||
self.sandbox = None;
|
self.sandbox = None;
|
||||||
self.driving = false;
|
self.driving = false;
|
||||||
|
self.owner = None;
|
||||||
|
self.drivers.clear();
|
||||||
self.sys("⛧ sandbox dismissed");
|
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 } => {
|
Net::SbxResize { rows, cols } => {
|
||||||
if let Some(v) = &mut self.sandbox {
|
if let Some(v) = &mut self.sandbox {
|
||||||
v.parser.set_size(rows.max(1), cols.max(1));
|
v.parser.set_size(rows.max(1), cols.max(1));
|
||||||
|
|
@ -148,7 +180,7 @@ impl App {
|
||||||
v.parser.process(&bytes);
|
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::Sys(t) => self.sys(t),
|
||||||
Net::Closed => {
|
Net::Closed => {
|
||||||
self.connected = false;
|
self.connected = false;
|
||||||
|
|
@ -196,6 +228,20 @@ where
|
||||||
let _ = write.send(WsMsg::Text(ct)).await;
|
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<()> {
|
pub async fn run(session: Session, theme: Theme) -> Result<()> {
|
||||||
let ws = net::connect(&session).await?;
|
let ws = net::connect(&session).await?;
|
||||||
let (mut write, read) = ws.split();
|
let (mut write, read) = ws.split();
|
||||||
|
|
@ -250,8 +296,12 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
|
||||||
break Ok(());
|
break Ok(());
|
||||||
}
|
}
|
||||||
if k.code == KeyCode::F(2) {
|
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;
|
app.driving = !app.driving;
|
||||||
|
} else {
|
||||||
|
app.sys("you don't have drive permission — the owner can /grant you");
|
||||||
}
|
}
|
||||||
} else if app.driving {
|
} else if app.driving {
|
||||||
if k.code == KeyCode::Esc {
|
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"),
|
_ => 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 {
|
} else if !line.is_empty() && app.connected {
|
||||||
let ct = session.room.encrypt(line.as_bytes());
|
let ct = session.room.encrypt(line.as_bytes());
|
||||||
if write.send(WsMsg::Text(ct)).await.is_err() {
|
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() => {
|
net = rx.recv() => {
|
||||||
match net {
|
match net {
|
||||||
Some(Net::SbxInput(b)) => {
|
Some(Net::SbxInput { from, bytes }) => {
|
||||||
if let Some(sb) = &mut broker { let _ = sb.write_input(&b); }
|
// 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),
|
Some(n) => app.apply(n),
|
||||||
None => break Ok(()),
|
None => break Ok(()),
|
||||||
|
|
@ -337,10 +417,15 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
|
||||||
broker_meta = Some((backend, name));
|
broker_meta = Some((backend, name));
|
||||||
announced_dims = Some((rows, cols));
|
announced_dims = Some((rows, cols));
|
||||||
launching = false;
|
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!({
|
send_frame(&mut write, &session.room, json!({
|
||||||
"_sbx":"status","state":"ready",
|
"_sbx":"status","state":"ready",
|
||||||
"backend": backend.label(), "rows": rows, "cols": cols
|
"backend": backend.label(), "rows": rows, "cols": cols
|
||||||
})).await;
|
})).await;
|
||||||
|
broadcast_acl(&mut write, &session.room, &app).await;
|
||||||
}
|
}
|
||||||
Some(BrokerMsg::Failed) => { launching = false; }
|
Some(BrokerMsg::Failed) => { launching = false; }
|
||||||
None => {}
|
None => {}
|
||||||
|
|
|
||||||
|
|
@ -131,10 +131,15 @@ fn decode_msg(room: &fernet::Fernet, m: &Value, allow_sbx: bool) -> Decoded {
|
||||||
if t.starts_with("{\"_ft\":") {
|
if t.starts_with("{\"_ft\":") {
|
||||||
return Decoded::Skip; // file-transfer control frame (P5)
|
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\":") {
|
if t.starts_with("{\"_sbx\":") {
|
||||||
// Don't replay terminal history from the stored snapshot.
|
// Don't replay terminal history from the stored snapshot.
|
||||||
return if allow_sbx {
|
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 {
|
} else {
|
||||||
Decoded::Skip
|
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.
|
/// Parse a decrypted `{"_sbx":...}` frame into a Net event. `sender` is the
|
||||||
fn parse_sbx(text: &str) -> Option<Net> {
|
/// 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()?;
|
let v: Value = serde_json::from_str(text).ok()?;
|
||||||
match v["_sbx"].as_str()? {
|
match v["_sbx"].as_str()? {
|
||||||
"status" => Some(Net::SbxStatus {
|
"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,
|
cols: v["cols"].as_u64().unwrap_or(80) as u16,
|
||||||
}),
|
}),
|
||||||
"data" => Some(Net::SbxData(STANDARD.decode(v["b64"].as_str()?).ok()?)),
|
"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,
|
_ => 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.
|
/// 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>) {
|
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 {
|
while let Some(frame) = read.next().await {
|
||||||
|
|
|
||||||
10
hh/src/ui.rs
10
hh/src/ui.rs
|
|
@ -116,7 +116,15 @@ fn draw_roster(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Th
|
||||||
.iter()
|
.iter()
|
||||||
.map(|u| {
|
.map(|u| {
|
||||||
let me = u.username == app.me;
|
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 };
|
let color = if me { theme.roster_me } else { theme.other };
|
||||||
ListItem::new(Line::from(Span::styled(
|
ListItem::new(Line::from(Span::styled(
|
||||||
format!(" {mark} {}", u.username),
|
format!(" {mark} {}", u.username),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user