Use Landlock to restrict bash calls. (#5)

https://docs.kernel.org/userspace-api/landlock.html
Reviewed-on: #5
Co-authored-by: Drew Galbraith <drew@tiramisu.one>
Co-committed-by: Drew Galbraith <drew@tiramisu.one>
This commit is contained in:
Drew 2026-03-02 03:51:46 +00:00 committed by Drew
parent 797d7564b7
commit 7efc6705d3
19 changed files with 1315 additions and 238 deletions

View file

@ -62,6 +62,9 @@ pub(super) fn drain_ui_events(event_rx: &mut mpsc::Receiver<UIEvent>, state: &mu
UIEvent::TurnComplete => {
debug!("turn complete");
}
UIEvent::NetworkPolicyChanged(allowed) => {
state.network_allowed = allowed;
}
UIEvent::Error(msg) => {
state
.messages

View file

@ -15,6 +15,8 @@ pub(super) enum LoopControl {
ClearHistory,
/// The user responded to a tool approval prompt.
ToolApproval { tool_use_id: String, approved: bool },
/// The user ran `:net on` or `:net off`.
SetNetworkPolicy(bool),
}
/// Map a key event to a [`LoopControl`] signal, mutating `state` as a side-effect.
@ -200,6 +202,8 @@ fn execute_command(buf: &str, state: &mut AppState) -> Option<LoopControl> {
state.scroll = 0;
Some(LoopControl::ClearHistory)
}
"net on" => Some(LoopControl::SetNetworkPolicy(true)),
"net off" => Some(LoopControl::SetNetworkPolicy(false)),
other => {
state.status_error = Some(format!("Unknown command: {other}"));
None
@ -514,6 +518,20 @@ mod tests {
assert_eq!(state.status_error.as_deref(), Some("Unknown command: foo"));
}
#[test]
fn command_net_on_returns_set_network_policy() {
let mut state = AppState::new();
let result = execute_command("net on", &mut state);
assert!(matches!(result, Some(LoopControl::SetNetworkPolicy(true))));
}
#[test]
fn command_net_off_returns_set_network_policy() {
let mut state = AppState::new();
let result = execute_command("net off", &mut state);
assert!(matches!(result, Some(LoopControl::SetNetworkPolicy(false))));
}
#[test]
fn status_error_cleared_on_next_keypress() {
let mut state = AppState::new();

View file

@ -74,6 +74,10 @@ pub struct AppState {
pub status_error: Option<String>,
/// A tool approval request waiting for user input (y/n).
pub pending_approval: Option<events::PendingApproval>,
/// Whether the sandbox is in yolo (unsandboxed) mode.
pub sandbox_yolo: bool,
/// Whether network access is currently allowed.
pub network_allowed: bool,
}
impl AppState {
@ -88,6 +92,8 @@ impl AppState {
viewport_height: 0,
status_error: None,
pending_approval: None,
sandbox_yolo: false,
network_allowed: false,
}
}
}
@ -145,10 +151,12 @@ pub fn install_panic_hook() {
pub async fn run(
action_tx: mpsc::Sender<UserAction>,
mut event_rx: mpsc::Receiver<UIEvent>,
sandbox_yolo: bool,
) -> Result<(), TuiError> {
install_panic_hook();
let mut terminal = init_terminal()?;
let mut state = AppState::new();
state.sandbox_yolo = sandbox_yolo;
let mut event_stream = EventStream::new();
loop {
@ -199,6 +207,9 @@ pub async fn run(
})
.await;
}
Some(input::LoopControl::SetNetworkPolicy(allowed)) => {
let _ = action_tx.send(UserAction::SetNetworkPolicy(allowed)).await;
}
None => {}
}
}

View file

@ -197,6 +197,24 @@ pub(super) fn render(frame: &mut Frame, state: &AppState) {
),
};
// Sandbox indicator: leftmost after mode label.
let (sandbox_label, sandbox_style) = if state.sandbox_yolo {
(
" UNSANDBOXED ",
Style::default().bg(Color::Red).fg(Color::White),
)
} else {
let net_label = if state.network_allowed {
" NET:ON "
} else {
" NET:OFF "
};
(
net_label,
Style::default().bg(Color::DarkGray).fg(Color::White),
)
};
let right_text = if let Some(ref err) = state.status_error {
err.clone()
} else {
@ -210,12 +228,13 @@ pub(super) fn render(frame: &mut Frame, state: &AppState) {
};
let bar_width = chunks[2].width as usize;
let left_len = mode_label.len();
let left_len = mode_label.len() + sandbox_label.len();
let right_len = right_text.len();
let pad = bar_width.saturating_sub(left_len + right_len);
let status_line = Line::from(vec![
Span::styled(mode_label, mode_style),
Span::styled(sandbox_label, sandbox_style),
Span::raw(" ".repeat(pad)),
Span::styled(right_text, right_style),
]);
@ -439,4 +458,41 @@ mod tests {
"expected tool name in overlay"
);
}
#[test]
fn render_status_bar_shows_net_off() {
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let state = AppState::new(); // network_allowed defaults to false
terminal.draw(|frame| render(frame, &state)).unwrap();
let buf = terminal.backend().buffer().clone();
let all_text: String = buf
.content()
.iter()
.map(|c| c.symbol().to_string())
.collect();
assert!(
all_text.contains("NET:OFF"),
"expected 'NET:OFF' in status bar, got: {all_text:.200}"
);
}
#[test]
fn render_status_bar_shows_unsandboxed() {
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let mut state = AppState::new();
state.sandbox_yolo = true;
terminal.draw(|frame| render(frame, &state)).unwrap();
let buf = terminal.backend().buffer().clone();
let all_text: String = buf
.content()
.iter()
.map(|c| c.symbol().to_string())
.collect();
assert!(
all_text.contains("UNSANDBOXED"),
"expected 'UNSANDBOXED' in status bar, got: {all_text:.200}"
);
}
}