feat(server,agent): v2 secure-session-core Task 5 - attended consent
SPEC-002 Phase 1 Task 5, code-reviewed APPROVED. An attended (support-code) session is invisible and inert to the technician until the end user accepts a consent prompt on their own machine. - proto: ConsentRequest / ConsentResponse + ConsentAccessMode enum (oneof fields 80/81; no existing field renumbered). - server: ConsentState on Session; attended -> Pending, managed -> NotRequired; join_session refuses viewers unless Granted/NotRequired (single chokepoint - StartStream only fires from join_session, so no frames or input flow pre- consent); run_consent_handshake sends ConsentRequest, 60s timeout, granted -> proceed, denied/timeout/disconnect -> teardown (end_session denied, machine offline, support code released). consent_state persisted; consent_requested/ granted/denied audited. - agent: Windows MessageBox (topmost/system-modal) on spawn_blocking; anything but an explicit Yes = deny; non-Windows build is a fail-closed stub. Not cargo-check-verified locally (no toolchain). Server verified on the build host; the Windows agent half is verified by CI build-agent (Pluto). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
155
agent/src/consent/mod.rs
Normal file
155
agent/src/consent/mod.rs
Normal file
@@ -0,0 +1,155 @@
|
||||
//! Attended-mode consent prompt (Task 5).
|
||||
//!
|
||||
//! For an attended (support-code) session, the GuruConnect server sends the
|
||||
//! agent a `ConsentRequest` before the technician's session is allowed to go
|
||||
//! live. The agent shows the end user a native dialog ("Allow <technician> to
|
||||
//! VIEW/CONTROL this computer?") and returns the user's choice as a
|
||||
//! `ConsentResponse`. The server holds the session in `consent_state = pending`
|
||||
//! and tears it down on a denial or timeout.
|
||||
//!
|
||||
//! v1 uses a Windows `MessageBox` (Yes/No, top-most, foreground). It is
|
||||
//! synchronous and reliable on every supported Windows version (7 SP1+), needs
|
||||
//! no extra windowing, and cannot be dismissed into an ambiguous state — the
|
||||
//! only outcomes are Yes (allow), No (deny), or the box being closed (treated
|
||||
//! as deny). A nicer custom branded dialog (countdown, technician avatar) is a
|
||||
//! possible future refinement; it is not required for correctness.
|
||||
//!
|
||||
//! The decision is the end user's and is purely advisory to the agent: the
|
||||
//! server is the enforcement point (it will not surface the session to the
|
||||
//! technician until it receives a `granted` response). The agent simply relays
|
||||
//! the human's choice.
|
||||
|
||||
/// Whether the technician requested view-only or full control, used only to
|
||||
/// phrase the prompt. Mirrors `proto::ConsentAccessMode`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ConsentAccessMode {
|
||||
View,
|
||||
Control,
|
||||
}
|
||||
|
||||
impl ConsentAccessMode {
|
||||
/// Decode the proto enum value (defaults to the more conservative `Control`
|
||||
/// wording on an unknown value so the prompt never under-states access).
|
||||
pub fn from_proto(value: i32) -> Self {
|
||||
match crate::proto::ConsentAccessMode::try_from(value) {
|
||||
Ok(crate::proto::ConsentAccessMode::ConsentView) => ConsentAccessMode::View,
|
||||
Ok(crate::proto::ConsentAccessMode::ConsentControl) => ConsentAccessMode::Control,
|
||||
Err(_) => ConsentAccessMode::Control,
|
||||
}
|
||||
}
|
||||
|
||||
fn verb(self) -> &'static str {
|
||||
match self {
|
||||
ConsentAccessMode::View => "VIEW",
|
||||
ConsentAccessMode::Control => "VIEW and CONTROL",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the consent prompt body shown to the end user.
|
||||
fn prompt_body(technician_name: &str, access: ConsentAccessMode) -> String {
|
||||
let who = if technician_name.trim().is_empty() {
|
||||
"A support technician"
|
||||
} else {
|
||||
technician_name
|
||||
};
|
||||
format!(
|
||||
"{who} is requesting a remote support session.\n\n\
|
||||
If you allow this, they will be able to {verb} this computer.\n\n\
|
||||
Do you want to allow this remote support session?",
|
||||
who = who,
|
||||
verb = access.verb()
|
||||
)
|
||||
}
|
||||
|
||||
/// Show the consent dialog and return the end user's decision.
|
||||
///
|
||||
/// Returns `true` if the user ALLOWED the session, `false` if they denied it or
|
||||
/// the dialog was closed/could not be shown. Blocking — callers should run this
|
||||
/// off the async runtime (e.g. `tokio::task::spawn_blocking`).
|
||||
#[cfg(windows)]
|
||||
pub fn prompt_consent(technician_name: &str, access: ConsentAccessMode) -> bool {
|
||||
use std::ffi::OsStr;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
MessageBoxW, IDYES, MB_ICONQUESTION, MB_SETFOREGROUND, MB_SYSTEMMODAL, MB_TOPMOST,
|
||||
MB_YESNO,
|
||||
};
|
||||
|
||||
let title = "GuruConnect - Remote Support Request";
|
||||
let body = prompt_body(technician_name, access);
|
||||
|
||||
let title_wide: Vec<u16> = OsStr::new(title)
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let body_wide: Vec<u16> = OsStr::new(&body)
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
|
||||
// MB_YESNO - explicit Allow (Yes) / Deny (No)
|
||||
// MB_ICONQUESTION - prompt styling
|
||||
// MB_TOPMOST - sit above other windows so it cannot be hidden
|
||||
// MB_SETFOREGROUND - bring to the foreground
|
||||
// MB_SYSTEMMODAL - ensure visibility even from a service/elevated context
|
||||
let result = unsafe {
|
||||
MessageBoxW(
|
||||
None,
|
||||
PCWSTR(body_wide.as_ptr()),
|
||||
PCWSTR(title_wide.as_ptr()),
|
||||
MB_YESNO | MB_ICONQUESTION | MB_TOPMOST | MB_SETFOREGROUND | MB_SYSTEMMODAL,
|
||||
)
|
||||
};
|
||||
|
||||
// Any outcome other than an explicit "Yes" is a denial (including the box
|
||||
// being closed, which returns IDNO/IDCANCEL-style values).
|
||||
result == IDYES
|
||||
}
|
||||
|
||||
/// Non-Windows stub. The agent is Windows-first; on other platforms there is no
|
||||
/// native end-user consent surface yet, so we fail CLOSED (deny) rather than
|
||||
/// silently allowing an unattended session.
|
||||
///
|
||||
// TODO(platform): provide a real consent dialog on macOS/Linux when the agent
|
||||
// is ported there (e.g. a GTK/Cocoa modal). Until then, deny so a non-Windows
|
||||
// build can never grant an attended session without an explicit human prompt.
|
||||
#[cfg(not(windows))]
|
||||
pub fn prompt_consent(_technician_name: &str, _access: ConsentAccessMode) -> bool {
|
||||
tracing::warn!(
|
||||
"Consent prompt requested on a non-Windows build; no native dialog available — denying"
|
||||
);
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prompt_body_uses_control_wording() {
|
||||
let body = prompt_body("Mike", ConsentAccessMode::Control);
|
||||
assert!(body.contains("Mike"));
|
||||
assert!(body.contains("VIEW and CONTROL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_body_uses_view_wording() {
|
||||
let body = prompt_body("Mike", ConsentAccessMode::View);
|
||||
assert!(body.contains("VIEW"));
|
||||
assert!(!body.contains("CONTROL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_body_falls_back_on_empty_name() {
|
||||
let body = prompt_body(" ", ConsentAccessMode::Control);
|
||||
assert!(body.contains("A support technician"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_mode_from_proto_defaults_to_control() {
|
||||
// An out-of-range proto value must not under-state access.
|
||||
assert_eq!(ConsentAccessMode::from_proto(999), ConsentAccessMode::Control);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
mod capture;
|
||||
mod chat;
|
||||
mod config;
|
||||
mod consent;
|
||||
mod encoder;
|
||||
mod input;
|
||||
mod install;
|
||||
|
||||
@@ -369,6 +369,17 @@ impl SessionManager {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
message::Payload::ConsentRequest(req) => {
|
||||
// ATTENDED-MODE CONSENT (Task 5). The server is holding
|
||||
// this session in `consent_state = pending` and will not
|
||||
// surface it to the technician until we reply. Show the
|
||||
// end user a native dialog and return their decision; the
|
||||
// dialog blocks, so run it off the async runtime. If the
|
||||
// user closes it / no choice is made, `prompt_consent`
|
||||
// returns false (deny).
|
||||
self.handle_consent_request(req.clone()).await;
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -498,6 +509,65 @@ impl SessionManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle an attended-mode `ConsentRequest` from the server (Task 5).
|
||||
///
|
||||
/// Shows the end user a native consent dialog (off the async runtime, since
|
||||
/// it blocks) and sends a `ConsentResponse` carrying their decision. A
|
||||
/// closed dialog / unavailable surface is treated as a DENY. The server
|
||||
/// gates the whole session on this reply, so we always send a response (even
|
||||
/// on send failure the server's consent timeout will deny).
|
||||
async fn handle_consent_request(&mut self, req: crate::proto::ConsentRequest) {
|
||||
use crate::consent::{prompt_consent, ConsentAccessMode};
|
||||
use crate::proto::ConsentResponse;
|
||||
|
||||
let session_id = req.session_id.clone();
|
||||
let technician_name = req.technician_name.clone();
|
||||
let access = ConsentAccessMode::from_proto(req.access_mode);
|
||||
|
||||
tracing::info!(
|
||||
"Consent requested for session {} by '{}' ({:?}); prompting end user",
|
||||
session_id,
|
||||
technician_name,
|
||||
access
|
||||
);
|
||||
|
||||
// The MessageBox blocks the calling thread; run it on the blocking pool
|
||||
// so the agent's async loop is not stalled and heartbeats keep flowing.
|
||||
let granted = tokio::task::spawn_blocking(move || {
|
||||
prompt_consent(&technician_name, access)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
// The blocking task panicked — fail closed (deny).
|
||||
tracing::error!("Consent dialog task failed: {}; denying", e);
|
||||
false
|
||||
});
|
||||
|
||||
tracing::info!(
|
||||
"End user {} consent for session {}",
|
||||
if granted { "GRANTED" } else { "DENIED" },
|
||||
session_id
|
||||
);
|
||||
|
||||
let response = Message {
|
||||
payload: Some(message::Payload::ConsentResponse(ConsentResponse {
|
||||
session_id,
|
||||
granted,
|
||||
reason: if granted {
|
||||
String::new()
|
||||
} else {
|
||||
"user_declined".to_string()
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
if let Some(transport) = self.transport.as_mut() {
|
||||
if let Err(e) = transport.send(response).await {
|
||||
tracing::error!("Failed to send ConsentResponse: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle incoming message from server
|
||||
async fn handle_message(&mut self, msg: Message) -> Result<()> {
|
||||
match msg.payload {
|
||||
|
||||
Reference in New Issue
Block a user