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
+31 -4
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 {