Complete authenticated interactive prompt routing

This commit is contained in:
ms2k 2026-07-17 07:20:21 +09:00
commit 46b52b4b43
3 changed files with 595 additions and 96 deletions

View file

@ -19,7 +19,7 @@ lazaret-runtime-supervisor = { version = "=0.0.0", path = "../lazaret-runtime-su
rustix.workspace = true
rustls.workspace = true
sha2.workspace = true
signal-hook.workspace = true
nix.workspace = true
[dev-dependencies]
tempfile = "=3.27.0"

View file

@ -1,8 +1,6 @@
use std::fs::{File, OpenOptions};
use std::io::{self, Read, Write};
use std::num::NonZeroU16;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use lazaret_core::records::{
@ -11,10 +9,10 @@ use lazaret_core::records::{
SessionClose, SessionExitOutcome, SessionTerminalEvent, SessionTerminalMessage, TerminalInput,
TerminalPrompt, TerminalResize, TerminalSignal, TerminalSignalKind,
};
use nix::sys::signal::{SigSet, SigmaskHow, Signal};
use nix::sys::signalfd::{SfdFlags, SignalFd};
use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl};
use rustix::termios::{LocalModes, OptionalActions, Termios, tcgetattr, tcgetwinsize, tcsetattr};
use signal_hook::SigId;
use signal_hook::consts::signal::{SIGHUP, SIGINT, SIGQUIT, SIGTERM, SIGTSTP, SIGWINCH};
use crate::{ClientError, ClientSessionIdentity};
@ -64,18 +62,24 @@ pub trait HostTerminal {
fn write_output(&mut self, bytes: &[u8]) -> io::Result<()>;
/// # Errors
/// Returns an error while collecting one explicit developer decision.
fn prompt(&mut self, prompt: &TerminalPrompt) -> io::Result<DeveloperDecisionKind>;
fn prompt(&mut self, prompt: &TerminalPrompt) -> io::Result<PromptOutcome>;
/// # Errors
/// Returns an error restoring, stopping, or re-entering raw mode.
fn suspend_and_resume(&mut self) -> io::Result<()>;
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PromptOutcome {
pub decision: DeveloperDecisionKind,
pub resumed_size: Option<TerminalResize>,
}
pub struct SystemTerminal {
terminal: File,
original: Termios,
original_flags: OFlags,
raw: bool,
signals: SignalFlags,
signals: SignalQueue,
}
impl SystemTerminal {
@ -90,7 +94,7 @@ impl SystemTerminal {
original_flags: fcntl_getfl(&terminal)?,
terminal,
raw: false,
signals: SignalFlags::register()?,
signals: SignalQueue::register()?,
})
}
}
@ -136,20 +140,8 @@ impl HostTerminal for SystemTerminal {
}
fn next_event(&mut self) -> io::Result<HostTerminalEvent> {
if self.signals.terminate.swap(false, Ordering::SeqCst) {
return Ok(HostTerminalEvent::Terminate);
}
if self.signals.suspend.swap(false, Ordering::SeqCst) {
return Ok(HostTerminalEvent::LocalSuspend);
}
if self.signals.resize.swap(false, Ordering::SeqCst) {
return Ok(HostTerminalEvent::Resize);
}
if self.signals.interrupt.swap(false, Ordering::SeqCst) {
return Ok(HostTerminalEvent::Signal(TerminalSignalKind::Interrupt));
}
if self.signals.quit.swap(false, Ordering::SeqCst) {
return Ok(HostTerminalEvent::Signal(TerminalSignalKind::Quit));
if let Some(event) = self.signals.next_event()? {
return Ok(event);
}
let mut bytes = vec![0_u8; 16 * 1024];
match self.terminal.read(&mut bytes) {
@ -171,7 +163,7 @@ impl HostTerminal for SystemTerminal {
self.terminal.flush()
}
fn prompt(&mut self, prompt: &TerminalPrompt) -> io::Result<DeveloperDecisionKind> {
fn prompt(&mut self, prompt: &TerminalPrompt) -> io::Result<PromptOutcome> {
self.write_output(
format!(
"\r\nLazaret host request\r\nprofile: {}\r\nsession: {}\r\ncommand: {}\r\njustification: {}\r\nApprove? [y/N] ",
@ -182,6 +174,7 @@ impl HostTerminal for SystemTerminal {
)
.as_bytes(),
)?;
let mut resumed_size = None;
loop {
match self.next_event()? {
HostTerminalEvent::Input(bytes) => {
@ -189,13 +182,19 @@ impl HostTerminal for SystemTerminal {
.first()
.is_some_and(|byte| matches!(byte, b'y' | b'Y'));
self.write_output(if approve { b"y\r\n" } else { b"n\r\n" })?;
return Ok(if approve {
DeveloperDecisionKind::Approve
} else {
DeveloperDecisionKind::Deny
return Ok(PromptOutcome {
decision: if approve {
DeveloperDecisionKind::Approve
} else {
DeveloperDecisionKind::Deny
},
resumed_size,
});
}
HostTerminalEvent::LocalSuspend => self.suspend_and_resume()?,
HostTerminalEvent::LocalSuspend => {
self.suspend_and_resume()?;
resumed_size = Some(self.size(prompt.session_id)?);
}
HostTerminalEvent::Terminate => return Err(io::ErrorKind::Interrupted.into()),
HostTerminalEvent::Idle
| HostTerminalEvent::Resize
@ -214,52 +213,79 @@ impl HostTerminal for SystemTerminal {
impl Drop for SystemTerminal {
fn drop(&mut self) {
let _ = self.restore();
self.signals.restore();
}
}
struct SignalFlags {
interrupt: Arc<AtomicBool>,
quit: Arc<AtomicBool>,
suspend: Arc<AtomicBool>,
resize: Arc<AtomicBool>,
terminate: Arc<AtomicBool>,
registrations: Vec<SigId>,
struct SignalQueue {
fd: SignalFd,
original_mask: SigSet,
restored: bool,
}
impl SignalFlags {
impl SignalQueue {
fn register() -> io::Result<Self> {
let interrupt = Arc::new(AtomicBool::new(false));
let quit = Arc::new(AtomicBool::new(false));
let suspend = Arc::new(AtomicBool::new(false));
let resize = Arc::new(AtomicBool::new(false));
let terminate = Arc::new(AtomicBool::new(false));
let registrations = vec![
signal_hook::flag::register(SIGINT, Arc::clone(&interrupt))?,
signal_hook::flag::register(SIGQUIT, Arc::clone(&quit))?,
signal_hook::flag::register(SIGTSTP, Arc::clone(&suspend))?,
signal_hook::flag::register(SIGWINCH, Arc::clone(&resize))?,
signal_hook::flag::register(SIGTERM, Arc::clone(&terminate))?,
signal_hook::flag::register(SIGHUP, Arc::clone(&terminate))?,
];
let mut mask = SigSet::empty();
for signal in [
Signal::SIGINT,
Signal::SIGQUIT,
Signal::SIGTSTP,
Signal::SIGWINCH,
Signal::SIGTERM,
Signal::SIGHUP,
] {
mask.add(signal);
}
let original_mask = mask
.thread_swap_mask(SigmaskHow::SIG_BLOCK)
.map_err(errno_to_io)?;
let fd = match SignalFd::with_flags(&mask, SfdFlags::SFD_NONBLOCK | SfdFlags::SFD_CLOEXEC) {
Ok(fd) => fd,
Err(error) => {
let _ = original_mask.thread_swap_mask(SigmaskHow::SIG_SETMASK);
return Err(errno_to_io(error));
}
};
Ok(Self {
interrupt,
quit,
suspend,
resize,
terminate,
registrations,
fd,
original_mask,
restored: false,
})
}
}
impl Drop for SignalFlags {
fn drop(&mut self) {
for registration in self.registrations.drain(..) {
signal_hook::low_level::unregister(registration);
fn next_event(&mut self) -> io::Result<Option<HostTerminalEvent>> {
let Some(info) = self.fd.read_signal().map_err(errno_to_io)? else {
return Ok(None);
};
let signal = Signal::try_from(info.ssi_signo.cast_signed()).map_err(errno_to_io)?;
Ok(match signal {
Signal::SIGINT => Some(HostTerminalEvent::Signal(TerminalSignalKind::Interrupt)),
Signal::SIGQUIT => Some(HostTerminalEvent::Signal(TerminalSignalKind::Quit)),
Signal::SIGTSTP => Some(HostTerminalEvent::LocalSuspend),
Signal::SIGWINCH => Some(HostTerminalEvent::Resize),
Signal::SIGTERM | Signal::SIGHUP => Some(HostTerminalEvent::Terminate),
_ => None,
})
}
fn restore(&mut self) {
if !self.restored {
let _ = self.original_mask.thread_swap_mask(SigmaskHow::SIG_SETMASK);
self.restored = true;
}
}
}
impl Drop for SignalQueue {
fn drop(&mut self) {
self.restore();
}
}
fn errno_to_io(error: nix::errno::Errno) -> io::Error {
io::Error::from_raw_os_error(error as i32)
}
/// Runs one interactive terminal route until its exact Session exit.
///
/// # Errors
@ -344,9 +370,19 @@ fn handle_batch<T: InteractiveSessionTransport, H: HostTerminal>(
}
InteractiveSessionMessage::Prompt(prompt) => {
require_session(prompt.session_id, identity.session_id())?;
let decision = terminal.prompt(&prompt)?;
let outcome = terminal.prompt(&prompt)?;
if let Some(resize) = outcome.resumed_size {
let response = transport.exchange(InteractiveSessionEvent::Terminal(
SessionTerminalEvent::Resize(resize),
))?;
if let Some(exit) =
handle_batch(transport, terminal, identity, binding, response)?
{
return Ok(Some(exit));
}
}
let signed_decision =
identity.sign_developer_decision(binding.clone(), &prompt, decision)?;
identity.sign_developer_decision(binding.clone(), &prompt, outcome.decision)?;
let response = transport.exchange(InteractiveSessionEvent::PromptDecision {
session_id: identity.session_id(),
signed_decision,
@ -387,3 +423,44 @@ fn hex(bytes: &[u8]) -> String {
output
})
}
#[cfg(test)]
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
mod tests {
use std::os::unix::process::ExitStatusExt as _;
use std::process::Command;
use nix::sys::signal::{SigSet, SigmaskHow, Signal, kill};
use nix::unistd::Pid;
use super::SignalQueue;
#[test]
fn signal_scope_restores_mask_and_default_termination() {
if std::env::var_os("LAZARET_SIGNAL_SCOPE_CHILD").is_some() {
let signals = SignalQueue::register().unwrap();
drop(signals);
kill(Pid::this(), Signal::SIGTERM).unwrap();
panic!("restored SIGTERM did not terminate the process");
}
let observed = SigSet::empty()
.thread_swap_mask(SigmaskHow::SIG_BLOCK)
.unwrap();
let signals = SignalQueue::register().unwrap();
drop(signals);
let restored = SigSet::empty()
.thread_swap_mask(SigmaskHow::SIG_BLOCK)
.unwrap();
assert_eq!(restored, observed);
let status = Command::new(std::env::current_exe().unwrap())
.arg("--exact")
.arg("terminal::tests::signal_scope_restores_mask_and_default_termination")
.arg("--nocapture")
.env("LAZARET_SIGNAL_SCOPE_CHILD", "1")
.status()
.unwrap();
assert_eq!(status.signal(), Some(nix::libc::SIGTERM));
}
}

View file

@ -3,7 +3,8 @@ use std::fs::File;
use std::io;
use lazaret_client::terminal::{
HostTerminal, HostTerminalEvent, InteractiveSessionTransport, run_interactive_session,
HostTerminal, HostTerminalEvent, InteractiveSessionTransport, PromptOutcome,
run_interactive_session,
};
use lazaret_client::{ClientError, ClientSessionIdentity};
use lazaret_core::identity::{
@ -106,11 +107,21 @@ impl HostTerminal for PtyTerminal {
Ok(())
}
fn prompt(&mut self, prompt: &TerminalPrompt) -> io::Result<DeveloperDecisionKind> {
fn prompt(&mut self, prompt: &TerminalPrompt) -> io::Result<PromptOutcome> {
assert_eq!(prompt.agent_profile, "trusted");
assert_eq!(prompt.command_argv, [b"/usr/bin/tool".to_vec()]);
assert_eq!(prompt.justification, "update host metadata");
Ok(self.decision)
let resumed_size = if matches!(self.events.front(), Some(HostTerminalEvent::LocalSuspend)) {
self.events.pop_front();
self.suspend_and_resume()?;
Some(self.size(prompt.session_id)?)
} else {
None
};
Ok(PromptOutcome {
decision: self.decision,
resumed_size,
})
}
fn suspend_and_resume(&mut self) -> io::Result<()> {
@ -132,6 +143,7 @@ struct FixtureTransport {
prompt_sent: bool,
failure: Option<TransportFailure>,
signed_decision: Option<Vec<u8>>,
prompt_after_events: usize,
}
impl FixtureTransport {
@ -142,6 +154,7 @@ impl FixtureTransport {
prompt_sent: false,
failure: None,
signed_decision: None,
prompt_after_events: 5,
}
}
}
@ -188,7 +201,7 @@ impl InteractiveSessionTransport for FixtureTransport {
)],
));
}
if !self.prompt_sent && self.events.len() >= 5 {
if !self.prompt_sent && self.events.len() >= self.prompt_after_events {
self.prompt_sent = true;
return Ok(batch(
self.session_id,
@ -206,6 +219,39 @@ impl InteractiveSessionTransport for FixtureTransport {
}
}
#[test]
fn prompt_suspend_sends_resumed_size_before_decision() {
let identity = ClientSessionIdentity::generate::<std::convert::Infallible>().unwrap();
let binding = binding(&identity);
let mut terminal = PtyTerminal::new([HostTerminalEvent::Idle, HostTerminalEvent::LocalSuspend]);
let mut transport = FixtureTransport::successful(identity.session_id());
transport.prompt_after_events = 1;
assert_eq!(
run_interactive_session(
&mut transport,
&mut terminal,
&identity,
&binding,
&[b"/bin/sh".to_vec()],
)
.unwrap(),
23
);
assert_eq!(terminal.suspend_count, 1);
assert!(matches!(
transport.events.as_slice(),
[
InteractiveSessionEvent::Poll { .. },
InteractiveSessionEvent::Terminal(lazaret_core::records::SessionTerminalEvent::Resize(
_
)),
InteractiveSessionEvent::PromptDecision { .. }
]
));
}
#[test]
fn terminal_fixture_routes_resize_signals_suspend_prompt_and_nonzero_exit() {
let identity = ClientSessionIdentity::generate::<std::convert::Infallible>().unwrap();