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:
+31
-4
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user