feat(hh): real VM unix users + sudo delegation ⛧

Linux-style user permissions inside the sandbox (the original superuser ask):
- Backends are now persistent (docker run -d + exec; multipass instance) so the
  broker can provision accounts and run the shell as a chosen user.
- sbx::provision(): create a real unix account per coven member at launch; the
  OWNER becomes a passwordless superuser (sudo group + /etc/sudoers.d NOPASSWD
  drop-in on multipass). The shared shell runs as the owner's account.
- /sudo <user> and /unsudo <user> (owner-only): real usermod + sudoers.d in the
  VM — delegate/withdraw superuser. ACL frame carries sudoers; roster shows
  ⛧ owner ·  sudoer · ◆ driver · • member.

Verified live on a real Multipass VM: shell runs as owner@vm with
'sudo -n whoami' == root; '/sudo member' gives member 'NOPASSWD: ALL';
teardown purges the instance. Docker provisions accounts + persistent
container (shell as root; sudo pkg absent so drive-grant is the delegation).

Tests: 7 cargo tests pass; clean build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-05-30 19:51:40 -07:00
parent 1dfc614cc5
commit 0e269afce7
4 changed files with 210 additions and 49 deletions
+62 -6
View File
@@ -65,7 +65,7 @@ pub enum Net {
SbxResize { rows: u16, cols: u16 },
SbxData(Vec<u8>),
SbxInput { from: String, bytes: Vec<u8> },
Perm { owner: String, drivers: Vec<String> },
Perm { owner: String, drivers: Vec<String>, sudoers: Vec<String> },
Ft(ft::Ft),
Sys(String),
Closed,
@@ -87,6 +87,8 @@ pub struct App {
pub driving: bool,
pub owner: Option<String>,
pub drivers: std::collections::HashSet<String>,
/// Members whose VM unix account has sudo (superuser). Always includes owner.
pub sudoers: std::collections::HashSet<String>,
pub pending_offer: Option<ft::Offer>,
transfers: HashMap<String, Transfer>,
}
@@ -104,6 +106,7 @@ impl App {
driving: false,
owner: None,
drivers: std::collections::HashSet::new(),
sudoers: std::collections::HashSet::new(),
pending_offer: None,
transfers: HashMap::new(),
}
@@ -158,6 +161,7 @@ impl App {
self.driving = false;
self.owner = None;
self.drivers.clear();
self.sudoers.clear();
self.sys("⛧ sandbox dismissed");
}
}
@@ -172,8 +176,9 @@ impl App {
}
}
Net::SbxInput { .. } => {} // broker enforces + writes in the run loop
Net::Perm { owner, drivers } => {
Net::Perm { owner, drivers, sudoers } => {
let new: std::collections::HashSet<String> = drivers.into_iter().collect();
let sudo: std::collections::HashSet<String> = sudoers.into_iter().collect();
if !owner.is_empty() && self.owner.as_deref() != Some(owner.as_str()) {
self.sys(format!("{owner} is the superuser (sandbox owner)"));
}
@@ -183,8 +188,12 @@ impl App {
self.driving = false;
self.sys("⛧ your drive permission was revoked");
}
if sudo.contains(&self.me) && !self.sudoers.contains(&self.me) && self.owner.is_some() {
self.sys("⛧ you were granted sudo (superuser) in the VM");
}
self.owner = Some(owner).filter(|o| !o.is_empty());
self.drivers = new;
self.sudoers = sudo;
}
Net::Ft(_) => {} // handled in the run loop (needs out channel + disk)
Net::Sys(t) => self.sys(t),
@@ -230,7 +239,10 @@ fn send_frame(out: &UnboundedSender<WsMsg>, room: &fernet::Fernet, value: serde_
fn broadcast_acl(out: &UnboundedSender<WsMsg>, room: &fernet::Fernet, app: &App) {
let drivers: Vec<&String> = app.drivers.iter().collect();
send_frame(out, room, json!({"_perm":"acl","owner": app.owner, "drivers": drivers}));
let sudoers: Vec<&String> = app.sudoers.iter().collect();
send_frame(out, room, json!({
"_perm":"acl","owner": app.owner, "drivers": drivers, "sudoers": sudoers
}));
}
/// Stream a payload to the coven as `_ft` chunks (background, paced).
@@ -421,6 +433,8 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
app.owner = Some(app.me.clone());
app.drivers.clear();
app.drivers.insert(app.me.clone());
app.sudoers.clear();
app.sudoers.insert(app.me.clone()); // owner = superuser
send_frame(&out_tx, &session.room, json!({
"_sbx":"status","state":"ready","backend": backend.label(), "rows": rows, "cols": cols
}));
@@ -538,8 +552,10 @@ fn handle_command(
let sz = term.size().map(|s| (s.width, s.height)).unwrap_or((80, 24));
let (rows, cols) = sbx_dims(sz.0, sz.1);
*launching = true;
app.sys(format!("summoning {} sandbox… (multipass boot can take ~30s)", backend.label()));
spawn_launch(backend, image, rows, cols, pty_tx.clone(), broker_tx.clone(), app_tx.clone());
let members: Vec<String> = app.users.iter().map(|u| u.username.clone()).collect();
app.sys(format!("summoning {} sandbox… (provisioning unix users; multipass boot ~30s)", backend.label()));
spawn_launch(backend, image, app.me.clone(), members, rows, cols,
pty_tx.clone(), broker_tx.clone(), app_tx.clone());
}
}
Some("stop") => {
@@ -556,6 +572,36 @@ fn handle_command(
}
_ => app.sys("usage: /sbx launch [local|docker|multipass] [image] | /sbx stop"),
}
} else if let Some(rest) = line.strip_prefix("/unsudo") {
let target = rest.trim();
if !app.is_owner() {
app.sys("only the owner can /unsudo");
} else if target.is_empty() {
app.sys("usage: /unsudo <user>");
} else if let Some((be, name)) = broker_meta.clone() {
app.sudoers.remove(target);
let (t, n) = (target.to_string(), name);
tokio::task::spawn_blocking(move || sbx::set_sudo(be, &n, &t, false));
broadcast_acl(out_tx, room, app);
app.sys(format!("revoked sudo from {target} in the VM"));
} else {
app.sys("no sandbox running");
}
} else if let Some(rest) = line.strip_prefix("/sudo") {
let target = rest.trim();
if !app.is_owner() {
app.sys("only the owner can delegate sudo");
} else if target.is_empty() {
app.sys("usage: /sudo <user> (delegate VM superuser) | /unsudo <user>");
} else if let Some((be, name)) = broker_meta.clone() {
app.sudoers.insert(target.to_string());
let (t, n) = (target.to_string(), name);
tokio::task::spawn_blocking(move || sbx::set_sudo(be, &n, &t, true));
broadcast_acl(out_tx, room, app);
app.sys(format!("delegated VM superuser (sudo) to {target}"));
} else {
app.sys("no sandbox running");
}
} else if let Some(rest) = line.strip_prefix("/grant") {
let target = rest.trim();
if !app.is_owner() {
@@ -585,9 +631,12 @@ fn handle_command(
}
}
#[allow(clippy::too_many_arguments)]
fn spawn_launch(
backend: sbx::Backend,
image: String,
owner: String,
members: Vec<String>,
rows: u16,
cols: u16,
pty_tx: UnboundedSender<Vec<u8>>,
@@ -605,8 +654,15 @@ fn spawn_launch(
let _ = broker_tx.send(BrokerMsg::Failed);
return;
}
// Provision real unix accounts (owner = sudoer) → the shell's run-user.
let run_user = {
let (n, o, ms) = (name.clone(), owner.clone(), members.clone());
tokio::task::spawn_blocking(move || sbx::provision(backend, &n, &o, &ms))
.await
.unwrap_or_default()
};
let (std_tx, std_rx) = std::sync::mpsc::channel::<Vec<u8>>();
match sbx::Sandbox::launch(backend, &name, &image, rows, cols, std_tx) {
match sbx::Sandbox::launch(backend, &name, &run_user, rows, cols, std_tx) {
Ok(sb) => {
std::thread::spawn(move || {
while let Ok(b) = std_rx.recv() {