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 },
|
||||
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 => {}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
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()
|
||||
.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),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user