Complete authenticated interactive prompt routing

This commit is contained in:
ms2k 2026-07-17 07:20:21 +09:00
commit 46b52b4b43

View file

@ -13,13 +13,14 @@ use lazaret_core::identity::{
GuestProcessGroupId, GuestProcessId, GuestProcessStartTimeTicks, InodeId,
PidfsProcessIdentityV1, SessionId,
};
use lazaret_core::protocol::VerifiedSession;
use lazaret_core::records::{
DomainStore, DurableDecision, GuestProcessIdentity, InteractiveSessionBatch,
DeveloperDecision, DomainStore, DurableDecision, GuestProcessIdentity, InteractiveSessionBatch,
InteractiveSessionEvent, InteractiveSessionMessage, InteractiveSessionRequest,
ParticipantOwnership, ParticipantRole, ParticipantState, PersistedHostProcessOwnership,
ProcessOwnershipCommitAck, ProtocolVersion, PtyIdentity, SessionExit, SessionPhase,
SessionTerminal, SessionTerminalEvent, SessionTerminalFields, SessionTerminalMessage,
SessionTerminalState, StagedProcessOwnership, TerminalOutput, TerminalResize,
SessionTerminalState, StagedProcessOwnership, TerminalOutput, TerminalPrompt, TerminalResize,
TerminalSignalKind,
};
use lazaret_linux::process::{
@ -29,6 +30,7 @@ use lazaret_linux::process::{
use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl, fstat};
use rustix::pty::{OpenptFlags, grantpt, ioctl_tiocgptpeer, openpt, unlockpt};
use rustix::termios::{Winsize, tcsetwinsize};
use sha2::{Digest, Sha256};
use crate::control::{ControlError, InteractiveControlSession, InteractiveSessionExecutor};
use crate::domain_store::{DomainStoreError, DomainStoreMutation, HostCoordinatorDomainStore};
@ -87,6 +89,8 @@ pub enum TerminalError {
GuestGroupStillPresent,
InvalidExit,
AuthorityMismatch,
DuplicatePrompt,
PromptValidation,
AcquisitionCleanup {
original: Box<TerminalError>,
cleanup: Option<Box<TerminalError>>,
@ -119,6 +123,8 @@ impl std::fmt::Display for TerminalError {
Self::AuthorityMismatch => {
formatter.write_str("Session Terminal runtime authority does not match")
}
Self::DuplicatePrompt => formatter.write_str("Session already has an active prompt"),
Self::PromptValidation => formatter.write_str("prompt authority validation failed"),
Self::AcquisitionCleanup { original, cleanup } => {
write!(formatter, "Session Terminal acquisition failed: {original}")?;
if let Some(cleanup) = cleanup {
@ -146,7 +152,9 @@ impl std::error::Error for TerminalError {
| Self::GuestIdentityMismatch
| Self::GuestGroupStillPresent
| Self::InvalidExit
| Self::AuthorityMismatch => None,
| Self::AuthorityMismatch
| Self::DuplicatePrompt
| Self::PromptValidation => None,
}
}
}
@ -176,6 +184,185 @@ impl PromptRouteCancellation for NoActivePrompt {
}
}
struct PendingPrompt {
authority: VerifiedSession,
prompt: TerminalPrompt,
emitted: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RoutedPromptDecision {
pub signed_decision: Vec<u8>,
pub decision: DeveloperDecision,
}
pub struct SessionPromptTransport {
store: Arc<HostCoordinatorDomainStore>,
pending: Mutex<HashMap<SessionId, PendingPrompt>>,
routed: Mutex<HashMap<SessionId, RoutedPromptDecision>>,
}
impl SessionPromptTransport {
#[must_use]
pub fn new(store: Arc<HostCoordinatorDomainStore>) -> Self {
Self {
store,
pending: Mutex::new(HashMap::new()),
routed: Mutex::new(HashMap::new()),
}
}
/// Queues one sandbox-signed request for its exact registered Session.
///
/// # Errors
/// Refuses invalid authority, stale Session state, and concurrent prompts.
pub fn queue_signed_request(
&self,
authority: VerifiedSession,
signed_request: &[u8],
) -> Result<TerminalPrompt, TerminalError> {
let (request, digest) = authority
.verify_request(signed_request)
.map_err(|_| TerminalError::PromptValidation)?;
let binding = authority.session_binding();
let anchor_id = lazaret_core::identity::SessionAnchorId::new(
Sha256::digest(&binding.session_anchor_public_key).into(),
);
let snapshot = self.store.snapshot()?;
let mut session = snapshot
.sessions
.into_iter()
.find(|session| {
session.generation_id == binding.generation_identity.generation_id
&& session.session_id == binding.session_id
&& session.cli_id == binding.cli_id
&& session.session_anchor_id == anchor_id
&& session.session_phase == SessionPhase::Attached
})
.ok_or(TerminalError::AuthorityMismatch)?;
let prompt = TerminalPrompt {
session_id: binding.session_id,
request_id: request.request_id(),
request_sha256: digest,
agent_profile: binding.generation_identity.agent_profile.clone(),
command_argv: request.command_argv().to_vec(),
justification: request.justification().to_owned(),
};
let mut pending = self
.pending
.lock()
.map_err(|_| TerminalError::PromptValidation)?;
if pending.contains_key(&binding.session_id) {
return Err(TerminalError::DuplicatePrompt);
}
session.session_phase = SessionPhase::Prompting;
self.store
.apply(&[DomainStoreMutation::PutSession(session)])?;
pending.insert(
binding.session_id,
PendingPrompt {
authority,
prompt: prompt.clone(),
emitted: false,
},
);
Ok(prompt)
}
fn prompt_for(&self, session_id: SessionId) -> Result<Option<TerminalPrompt>, TerminalError> {
let mut pending = self
.pending
.lock()
.map_err(|_| TerminalError::PromptValidation)?;
let Some(pending) = pending.get_mut(&session_id) else {
return Ok(None);
};
if pending.emitted {
return Ok(None);
}
pending.emitted = true;
Ok(Some(pending.prompt.clone()))
}
fn route_decision(
&self,
session_id: SessionId,
signed_decision: Vec<u8>,
) -> Result<DeveloperDecision, TerminalError> {
let mut prompts = self
.pending
.lock()
.map_err(|_| TerminalError::PromptValidation)?;
let pending = prompts
.get(&session_id)
.ok_or(TerminalError::InactiveRoute)?;
let decision = pending
.authority
.verify_developer_decision(
&signed_decision,
pending.prompt.request_id,
pending.prompt.request_sha256,
)
.map_err(|_| TerminalError::PromptValidation)?;
prompts.remove(&session_id);
drop(prompts);
self.routed
.lock()
.map_err(|_| TerminalError::PromptValidation)?
.insert(
session_id,
RoutedPromptDecision {
signed_decision,
decision: decision.clone(),
},
);
Ok(decision)
}
fn restore_sandbox_route(&self, session_id: SessionId) -> Result<(), TerminalError> {
let snapshot = self.store.snapshot()?;
let mut session = snapshot
.sessions
.into_iter()
.find(|session| session.session_id == session_id)
.ok_or(TerminalError::UnknownSession)?;
if session.session_phase != SessionPhase::Prompting {
return Err(TerminalError::InactiveRoute);
}
session.session_phase = SessionPhase::Attached;
self.store
.apply(&[DomainStoreMutation::PutSession(session)])?;
Ok(())
}
/// # Errors
/// Refuses access after internal synchronization failure.
pub fn take_decision(
&self,
session_id: SessionId,
) -> Result<Option<RoutedPromptDecision>, TerminalError> {
Ok(self
.routed
.lock()
.map_err(|_| TerminalError::PromptValidation)?
.remove(&session_id))
}
}
impl PromptRouteCancellation for SessionPromptTransport {
fn cancel_for_close(&self, session_id: SessionId) -> Result<(), TerminalError> {
self.pending
.lock()
.map_err(|_| TerminalError::PromptValidation)?
.remove(&session_id);
self.routed
.lock()
.map_err(|_| TerminalError::PromptValidation)?
.remove(&session_id);
Ok(())
}
}
#[allow(clippy::missing_errors_doc)]
pub trait SessionTerminalProcess: Send {
fn write_input(&mut self, bytes: &[u8]) -> io::Result<usize>;
@ -411,6 +598,7 @@ pub struct SessionTerminalEngine {
pub struct RunscInteractiveSessionExecutor {
engine: Arc<Mutex<SessionTerminalEngine>>,
prompt_transport: Arc<SessionPromptTransport>,
domain_id: DomainId,
generation_id: GenerationId,
coordinator_id: CoordinatorId,
@ -442,12 +630,14 @@ impl RunscInteractiveSessionExecutor {
if run + 3 != command.arguments().len() {
return Err(TerminalError::AuthorityMismatch);
}
let prompt_transport = Arc::new(SessionPromptTransport::new(Arc::clone(&store)));
Ok(Self {
engine: Arc::new(Mutex::new(SessionTerminalEngine::new(
store,
Arc::new(RunscSessionTerminalFactory),
Arc::new(NoActivePrompt),
prompt_transport.clone(),
))),
prompt_transport,
domain_id: generation.domain_id,
generation_id: generation.generation_id,
coordinator_id: generation.coordinator_id,
@ -458,6 +648,11 @@ impl RunscInteractiveSessionExecutor {
runtime_root,
})
}
#[must_use]
pub fn prompt_transport(&self) -> Arc<SessionPromptTransport> {
Arc::clone(&self.prompt_transport)
}
}
impl InteractiveSessionExecutor for RunscInteractiveSessionExecutor {
@ -503,6 +698,7 @@ impl InteractiveSessionExecutor for RunscInteractiveSessionExecutor {
drop(engine);
Ok(Box::new(RunscInteractiveControlSession {
engine: Arc::clone(&self.engine),
prompt_transport: Arc::clone(&self.prompt_transport),
generation_id: self.generation_id,
session_id: request.session_id,
}))
@ -511,6 +707,7 @@ impl InteractiveSessionExecutor for RunscInteractiveSessionExecutor {
struct RunscInteractiveControlSession {
engine: Arc<Mutex<SessionTerminalEngine>>,
prompt_transport: Arc<SessionPromptTransport>,
generation_id: GenerationId,
session_id: SessionId,
}
@ -528,9 +725,40 @@ impl InteractiveControlSession for RunscInteractiveControlSession {
InteractiveSessionEvent::Poll { session_id } if session_id == self.session_id => {
Vec::new()
}
InteractiveSessionEvent::PromptDecision {
session_id,
signed_decision,
} if session_id == self.session_id => {
let decision = self
.prompt_transport
.route_decision(session_id, signed_decision)
.map_err(|error| terminal_control_error(&error))?;
if decision.decision == lazaret_core::records::DeveloperDecisionKind::Deny {
self.prompt_transport
.restore_sandbox_route(session_id)
.map_err(|error| terminal_control_error(&error))?;
engine
.set_route(session_id, self.generation_id, TerminalRoute::Sandbox)
.map_err(|error| terminal_control_error(&error))?;
}
Vec::new()
}
InteractiveSessionEvent::PromptDecision { .. }
| InteractiveSessionEvent::Poll { .. } => return Err(ControlError::InvalidRequest),
};
if let Some(prompt) = self
.prompt_transport
.prompt_for(self.session_id)
.map_err(|error| terminal_control_error(&error))?
{
engine
.set_route(self.session_id, self.generation_id, TerminalRoute::Prompt)
.map_err(|error| terminal_control_error(&error))?;
return Ok(InteractiveSessionBatch {
session_id: self.session_id,
messages: vec![InteractiveSessionMessage::Prompt(prompt)],
});
}
if !messages
.iter()
.any(|message| matches!(message, SessionTerminalMessage::Exit(_)))