From 9082e11490c5b35c75ab0bbd55e6663f950cdc37 Mon Sep 17 00:00:00 2001 From: Mike Swanson Date: Sat, 30 May 2026 07:44:09 -0700 Subject: [PATCH] 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) --- agent/src/consent/mod.rs | 155 ++++++++++++ agent/src/main.rs | 1 + agent/src/session/mod.rs | 70 ++++++ proto/guruconnect.proto | 38 +++ server/src/api/mod.rs | 5 + server/src/db/events.rs | 11 + server/src/db/sessions.rs | 46 +++- server/src/relay/mod.rs | 349 +++++++++++++++++++++++++++ server/src/session/mod.rs | 179 ++++++++++++++ specs/v2-secure-session-core/plan.md | 57 ++++- 10 files changed, 906 insertions(+), 5 deletions(-) create mode 100644 agent/src/consent/mod.rs diff --git a/agent/src/consent/mod.rs b/agent/src/consent/mod.rs new file mode 100644 index 0000000..956e149 --- /dev/null +++ b/agent/src/consent/mod.rs @@ -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 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 = OsStr::new(title) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let body_wide: Vec = 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); + } +} diff --git a/agent/src/main.rs b/agent/src/main.rs index 5fdf9c6..9e6b028 100644 --- a/agent/src/main.rs +++ b/agent/src/main.rs @@ -15,6 +15,7 @@ mod capture; mod chat; mod config; +mod consent; mod encoder; mod input; mod install; diff --git a/agent/src/session/mod.rs b/agent/src/session/mod.rs index 1a9b3c9..71bf451 100644 --- a/agent/src/session/mod.rs +++ b/agent/src/session/mod.rs @@ -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 { diff --git a/proto/guruconnect.proto b/proto/guruconnect.proto index cf974e0..bcc5e4f 100644 --- a/proto/guruconnect.proto +++ b/proto/guruconnect.proto @@ -294,6 +294,40 @@ enum AdminCommandType { ADMIN_UPDATE = 2; // Download and install update } +// ============================================================================ +// Attended-mode Consent (Task 5) +// ============================================================================ +// +// For an ATTENDED (support-code) session, the end user on the managed machine +// must explicitly accept a consent prompt before the technician's session goes +// live. The server holds the session in `consent_state = pending` and does NOT +// surface it to the technician (no viewer join / no streaming) until the agent +// returns a ConsentResponse with granted = true. A denial or timeout tears the +// session down. Managed/unattended sessions are `not_required` and never see a +// ConsentRequest (Phase-1 default policy). + +// Server/relay -> Agent: ask the end user to allow this support session. +message ConsentRequest { + string session_id = 1; // Session awaiting consent (UUID string) + string technician_name = 2; // Display name of the requesting technician + ConsentAccessMode access_mode = 3; // View vs. control, so the prompt is honest + int32 timeout_secs = 4; // How long the agent should wait before auto-deny +} + +// Agent -> Server: the end user's decision. +message ConsentResponse { + string session_id = 1; // Echoes the ConsentRequest session_id + bool granted = 2; // true = allow, false = deny (incl. dialog closed) + string reason = 3; // Optional human-readable reason (e.g. "timeout") +} + +// Whether the technician is requesting view-only or full control. Mirrors the +// viewer access mode the server already enforces; used only to phrase the prompt. +enum ConsentAccessMode { + CONSENT_VIEW = 0; // Technician will VIEW the screen + CONSENT_CONTROL = 1; // Technician will VIEW and CONTROL +} + // ============================================================================ // Auto-Update Messages // ============================================================================ @@ -374,5 +408,9 @@ message Message { // Auto-update messages UpdateInfo update_info = 75; // Server -> Agent: update available UpdateStatus update_status = 76; // Agent -> Server: update progress + + // Attended-mode consent (Task 5) + ConsentRequest consent_request = 80; // Server/relay -> Agent: prompt end user + ConsentResponse consent_response = 81; // Agent -> Server: end user's decision } } diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs index 7a16a55..86bf016 100644 --- a/server/src/api/mod.rs +++ b/server/src/api/mod.rs @@ -55,6 +55,10 @@ pub struct SessionInfo { pub uptime_secs: i64, pub display_count: i32, pub agent_version: Option, + /// Attended-consent state (Task 5): `not_required` | `pending` | `granted` + /// | `denied`. The dashboard can surface "awaiting consent" for an attended + /// session that has not yet been accepted on the managed machine. + pub consent_state: String, } impl From for SessionInfo { @@ -75,6 +79,7 @@ impl From for SessionInfo { uptime_secs: s.uptime_secs, display_count: s.display_count, agent_version: s.agent_version, + consent_state: s.consent_state.as_db_str().to_string(), } } } diff --git a/server/src/db/events.rs b/server/src/db/events.rs index e84c404..8c70eb4 100644 --- a/server/src/db/events.rs +++ b/server/src/db/events.rs @@ -47,6 +47,17 @@ impl EventTypes { #[allow(dead_code)] // TODO(audit-events): emit on cancelled-code bind rejection pub const CONNECTION_REJECTED_CANCELLED_CODE: &'static str = "connection_rejected_cancelled_code"; + + // Attended-mode consent decisions (Task 5). Audited with the session id and + // the agent id in `details` so the consent decision is fully traceable. + /// End user accepted the attended-session consent prompt. + pub const CONSENT_GRANTED: &'static str = "consent_granted"; + /// End user declined, the prompt timed out, or the agent disconnected + /// before answering — the attended session was torn down. + pub const CONSENT_DENIED: &'static str = "consent_denied"; + /// A `ConsentRequest` was sent to the agent for an attended session (the + /// prompt is now awaiting the end user's decision). + pub const CONSENT_REQUESTED: &'static str = "consent_requested"; } /// Log a session event diff --git a/server/src/db/sessions.rs b/server/src/db/sessions.rs index 9867557..27cf035 100644 --- a/server/src/db/sessions.rs +++ b/server/src/db/sessions.rs @@ -28,6 +28,13 @@ pub struct DbSession { } /// Create a new session record +/// +/// The consent/managed columns are derived from `is_support_session`: +/// - support session (attended): `is_managed = false`, `source = 'standalone'`, +/// `consent_state = 'pending'` — a viewer cannot be surfaced to the +/// technician until the end user accepts (Task 5). +/// - managed/persistent session: `is_managed = true`, `source = 'gururmm'`, +/// `consent_state = 'not_required'` (Phase-1 default policy). pub async fn create_session( pool: &PgPool, session_id: Uuid, @@ -35,10 +42,18 @@ pub async fn create_session( is_support_session: bool, support_code: Option<&str>, ) -> Result { + let (is_managed, source, consent_state) = if is_support_session { + (false, "standalone", "pending") + } else { + (true, "gururmm", "not_required") + }; + sqlx::query_as::<_, DbSession>( r#" - INSERT INTO connect_sessions (id, machine_id, is_support_session, support_code, status) - VALUES ($1, $2, $3, $4, 'active') + INSERT INTO connect_sessions + (id, machine_id, is_support_session, support_code, status, + is_managed, source, consent_state) + VALUES ($1, $2, $3, $4, 'active', $5, $6, $7) RETURNING * "#, ) @@ -46,10 +61,37 @@ pub async fn create_session( .bind(machine_id) .bind(is_support_session) .bind(support_code) + .bind(is_managed) + .bind(source) + .bind(consent_state) .fetch_one(pool) .await } +/// Update the attended-consent state on a session row (Task 5). +/// +/// `state` must be one of `not_required` | `pending` | `granted` | `denied` +/// (the column CHECK enforces this). Persists the consent decision so it is +/// durable and auditable alongside the `consent_granted`/`consent_denied` +/// events. Best-effort from the relay: a failure here does not change the +/// authoritative in-memory consent gate. +pub async fn update_consent_state( + pool: &PgPool, + session_id: Uuid, + state: &str, +) -> Result<(), sqlx::Error> { + sqlx::query( + r#" + UPDATE connect_sessions SET consent_state = $1 WHERE id = $2 + "#, + ) + .bind(state) + .bind(session_id) + .execute(pool) + .await?; + Ok(()) +} + /// End a session pub async fn end_session( pool: &PgPool, diff --git a/server/src/relay/mod.rs b/server/src/relay/mod.rs index fa2fe23..3275943 100644 --- a/server/src/relay/mod.rs +++ b/server/src/relay/mod.rs @@ -11,6 +11,7 @@ use axum::{ http::StatusCode, response::IntoResponse, }; +use futures_util::stream::{SplitSink, SplitStream}; use futures_util::{SinkExt, StreamExt}; use prost::Message as ProstMessage; use serde::Deserialize; @@ -49,6 +50,18 @@ const VIEWER_WS_MAX_MESSAGE_BYTES: usize = 64 * 1024; /// buffered unboundedly. (Closes the input-injection MEDIUM.) const VIEWER_INPUT_EVENTS_PER_SEC: u32 = 200; +/// How long the server waits for the end user to answer the attended-session +/// consent prompt before treating it as a DENY and tearing the session down. +/// +/// An attended (support-code) session is held with `consent_state = pending` +/// for at most this long: if the agent does not return a `ConsentResponse` +/// (user accepted/declined) within the window, the session is denied and torn +/// down. The agent is told the same value in `ConsentRequest.timeout_secs` so +/// its dialog can auto-deny in lock-step. 60s is long enough for a real person +/// to read and decide, short enough that an unattended/abandoned prompt does +/// not hold a session open indefinitely. +const CONSENT_TIMEOUT_SECS: u64 = 60; + #[derive(Debug, Deserialize)] pub struct AgentParams { agent_id: String, @@ -628,6 +641,76 @@ async fn handle_agent_connection( } } + // ATTENDED-MODE CONSENT GATE (Task 5). + // + // For an attended (support-code) session, the end user on the managed + // machine must SEE and ACCEPT a consent prompt before the technician's + // session goes live. The session was registered with `consent_state = + // Pending` (in-memory) and the DB row created with `consent_state = + // 'pending'`, so `join_session` already refuses any viewer until this + // resolves. Here we drive the handshake to completion BEFORE entering the + // main relay loop: + // 1. Send a `ConsentRequest` to the agent (it shows a native dialog). + // 2. Wait up to `CONSENT_TIMEOUT_SECS` for a `ConsentResponse`. + // 3. granted -> consent_state = Granted, audit, proceed to the main loop. + // denied/timeout/disconnect -> consent_state = Denied, audit, send a + // Disconnect to the agent, and tear the session down (return early). + // + // Managed/unattended sessions (no support code) are `NotRequired`: they skip + // this entirely and proceed straight to the relay loop. + if support_code.is_some() { + let consent_granted = run_consent_handshake( + &mut ws_sender, + &mut ws_receiver, + &sessions, + db.as_ref(), + session_id, + &agent_id, + &support_codes, + client_ip, + ) + .await; + + if !consent_granted { + // Denied / timed out / agent vanished. The session row is already + // marked denied + audited inside the handshake. Tear down the live + // session and DB record, then return — the technician never sees it. + info!( + "Attended session {} denied/abandoned at consent; tearing down", + session_id + ); + + sessions.mark_agent_disconnected(session_id).await; + + if let Some(ref db) = db { + let _ = db::sessions::end_session(db.pool(), session_id, "denied").await; + let _ = db::machines::mark_machine_offline(db.pool(), &agent_id).await; + } + + // Best-effort: release the support code so a fresh attempt can start + // clean rather than colliding with a half-bound code. + if let Some(ref code) = support_code { + support_codes.mark_completed(code).await; + if let Some(ref db) = db { + let _ = db::support_codes::mark_code_completed(db.pool(), code).await; + } + } + + // Tell the agent why it is being dropped, then close the socket. + let disconnect_msg = proto::Message { + payload: Some(proto::message::Payload::Disconnect(proto::Disconnect { + reason: "Remote support was declined on the managed computer".to_string(), + })), + }; + let mut buf = Vec::new(); + if prost::Message::encode(&disconnect_msg, &mut buf).is_ok() { + let _ = ws_sender.send(Message::Binary(buf)).await; + } + let _ = ws_sender.close().await; + return; + } + } + // Use Arc for sender so we can use it from multiple places let ws_sender = std::sync::Arc::new(tokio::sync::Mutex::new(ws_sender)); let ws_sender_input = ws_sender.clone(); @@ -758,6 +841,19 @@ async fn handle_agent_connection( // Agent acknowledged our heartbeat sessions_status.update_heartbeat(session_id).await; } + Some(proto::message::Payload::ConsentResponse(_)) => { + // The consent handshake (Task 5) runs to + // completion BEFORE this loop is entered, so any + // ConsentResponse arriving here is a late/dup — + // the decision is already final. Acknowledge by + // ignoring it (do not re-open the gate), but log + // so a stray response is observable. + tracing::debug!( + "Late ConsentResponse from agent {} on session {}; ignoring \ + (consent already finalized)", + agent_id, session_id + ); + } _ => {} } } @@ -826,6 +922,259 @@ async fn handle_agent_connection( info!("Session {} ended", session_id); } +/// Drive the attended-mode consent handshake for a support-code session +/// (Task 5). +/// +/// Sends a [`proto::ConsentRequest`] to the agent (which shows the end user a +/// native dialog), then waits up to [`CONSENT_TIMEOUT_SECS`] for a +/// [`proto::ConsentResponse`]. Returns `true` iff the end user GRANTED consent; +/// `false` for a denial, a timeout, a closed/errored socket, or any other +/// outcome. On every terminal outcome it updates the session's `consent_state` +/// (in-memory authoritative + best-effort DB mirror) and writes a +/// `consent_granted` / `consent_denied` audit event. +/// +/// While waiting, non-consent inbound messages (e.g. heartbeats the agent may +/// emit) are tolerated and ignored — only a `ConsentResponse` (or the timeout / +/// disconnect) resolves the gate. No secret or code value is logged. +#[allow(clippy::too_many_arguments)] // mirrors the relay/session contract; tracked for a params-struct refactor in docs/specs/native-remote-control/ +async fn run_consent_handshake( + ws_sender: &mut SplitSink, + ws_receiver: &mut SplitStream, + sessions: &SessionManager, + db: Option<&Database>, + session_id: Uuid, + agent_id: &str, + support_codes: &crate::support_codes::SupportCodeManager, + client_ip: Option, +) -> bool { + // Resolve the requesting technician's display name (best-effort) so the + // prompt is honest about who is asking. The support code carries the name + // of the technician who generated it (`created_by`); if the mapping is not + // yet available, fall back to a generic label rather than leaking the + // machine's own name. Attended sessions grant CONTROL. + let technician_name = match support_codes.get_by_session(session_id).await { + Some(code) => code.created_by, + None => "A support technician".to_string(), + }; + + // 1. Send the ConsentRequest to the agent. + let request = proto::Message { + payload: Some(proto::message::Payload::ConsentRequest( + proto::ConsentRequest { + session_id: session_id.to_string(), + technician_name, + // Attended support sessions are full-control by default; the + // viewer-token access split (Task 3) still governs what the relay + // actually forwards once joined. + access_mode: proto::ConsentAccessMode::ConsentControl as i32, + timeout_secs: CONSENT_TIMEOUT_SECS as i32, + }, + )), + }; + + let mut buf = Vec::new(); + if prost::Message::encode(&request, &mut buf).is_err() { + warn!( + "Failed to encode ConsentRequest for session {}; denying", + session_id + ); + finalize_consent(sessions, db, session_id, agent_id, false, "encode_error", client_ip).await; + return false; + } + if ws_sender.send(Message::Binary(buf)).await.is_err() { + warn!( + "Failed to send ConsentRequest to agent {} (session {}); denying", + agent_id, session_id + ); + finalize_consent(sessions, db, session_id, agent_id, false, "send_error", client_ip).await; + return false; + } + + info!( + "Attended session {}: ConsentRequest sent to agent {}, awaiting end-user decision", + session_id, agent_id + ); + + // Audit that consent was requested (prompt now pending). + if let Some(db) = db { + let _ = db::events::log_event( + db.pool(), + session_id, + db::events::EventTypes::CONSENT_REQUESTED, + None, + None, + Some(serde_json::json!({ "agent_id": agent_id })), + client_ip, + ) + .await; + } + + // 2. Wait for the ConsentResponse, bounded by the timeout. + let deadline = tokio::time::Instant::now() + + std::time::Duration::from_secs(CONSENT_TIMEOUT_SECS); + + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + warn!( + "Attended session {}: consent timed out after {}s; denying", + session_id, CONSENT_TIMEOUT_SECS + ); + finalize_consent(sessions, db, session_id, agent_id, false, "timeout", client_ip).await; + return false; + } + + match tokio::time::timeout(remaining, ws_receiver.next()).await { + // Timed out waiting for the next frame. + Err(_) => { + warn!( + "Attended session {}: consent timed out after {}s; denying", + session_id, CONSENT_TIMEOUT_SECS + ); + finalize_consent(sessions, db, session_id, agent_id, false, "timeout", client_ip) + .await; + return false; + } + // Socket closed before answering. + Ok(None) => { + warn!( + "Attended session {}: agent {} disconnected before consent; denying", + session_id, agent_id + ); + finalize_consent( + sessions, db, session_id, agent_id, false, "agent_disconnected", client_ip, + ) + .await; + return false; + } + Ok(Some(Ok(Message::Binary(data)))) => { + match proto::Message::decode(data.as_ref()) { + Ok(proto_msg) => match proto_msg.payload { + Some(proto::message::Payload::ConsentResponse(resp)) => { + // Verify the response is for THIS session (an agent + // only ever has one pending consent, but be strict). + if resp.session_id != session_id.to_string() { + warn!( + "Attended session {}: ConsentResponse for a different \ + session ({}); ignoring", + session_id, resp.session_id + ); + continue; + } + if resp.granted { + info!("Attended session {}: end user GRANTED consent", session_id); + finalize_consent( + sessions, db, session_id, agent_id, true, "granted", client_ip, + ) + .await; + return true; + } else { + let reason = if resp.reason.is_empty() { + "denied".to_string() + } else { + resp.reason.clone() + }; + info!( + "Attended session {}: end user DENIED consent ({})", + session_id, reason + ); + finalize_consent( + sessions, db, session_id, agent_id, false, &reason, client_ip, + ) + .await; + return false; + } + } + // Any other message during the consent window is + // tolerated (e.g. heartbeats) — keep waiting. + _ => continue, + }, + Err(e) => { + warn!( + "Attended session {}: failed to decode agent message during \ + consent wait: {}", + session_id, e + ); + continue; + } + } + } + // Agent-initiated close frame. + Ok(Some(Ok(Message::Close(_)))) => { + warn!( + "Attended session {}: agent {} closed during consent; denying", + session_id, agent_id + ); + finalize_consent( + sessions, db, session_id, agent_id, false, "agent_closed", client_ip, + ) + .await; + return false; + } + // Ping/pong/text and transient frames: keep waiting. + Ok(Some(Ok(_))) => continue, + Ok(Some(Err(e))) => { + warn!( + "Attended session {}: WebSocket error during consent wait: {}; denying", + session_id, e + ); + finalize_consent( + sessions, db, session_id, agent_id, false, "ws_error", client_ip, + ) + .await; + return false; + } + } + } +} + +/// Apply a terminal consent decision: update the in-memory consent state +/// (authoritative), mirror it to the DB row (best-effort), and audit it. +async fn finalize_consent( + sessions: &SessionManager, + db: Option<&Database>, + session_id: Uuid, + agent_id: &str, + granted: bool, + reason: &str, + client_ip: Option, +) { + use crate::session::ConsentState; + + let new_state = if granted { + ConsentState::Granted + } else { + ConsentState::Denied + }; + + // Authoritative in-memory state — this is what `join_session` consults. + sessions.set_consent_state(session_id, new_state).await; + + if let Some(db) = db { + // Durable mirror of the consent decision on the session row. + let _ = db::sessions::update_consent_state(db.pool(), session_id, new_state.as_db_str()) + .await; + + // Audit event (consent_granted | consent_denied) with the agent id and + // the (non-secret) reason so the decision is fully traceable. + let event_type = if granted { + db::events::EventTypes::CONSENT_GRANTED + } else { + db::events::EventTypes::CONSENT_DENIED + }; + let _ = db::events::log_event( + db.pool(), + session_id, + event_type, + None, + None, + Some(serde_json::json!({ "agent_id": agent_id, "reason": reason })), + client_ip, + ) + .await; + } +} + /// Handle a viewer WebSocket connection /// /// `access` is the VERIFIED access mode from the viewer token's signed claims. diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs index 605225f..763fe1f 100644 --- a/server/src/session/mod.rs +++ b/server/src/session/mod.rs @@ -26,6 +26,45 @@ pub struct ViewerInfo { pub connected_at: chrono::DateTime, } +/// Attended-consent state for a session (Task 5). +/// +/// Mirrors the `connect_sessions.consent_state` column +/// (`not_required` | `pending` | `granted` | `denied`). For an ATTENDED +/// (support-code) session the end user must accept a consent prompt before the +/// technician's session goes live: the session starts `Pending` and a viewer +/// cannot join (and the agent is not asked to stream) until it becomes +/// `Granted`. Managed/unattended sessions are `NotRequired` and join freely. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConsentState { + /// Managed/unattended session — no consent prompt (Phase-1 default policy). + NotRequired, + /// Attended session awaiting the end user's decision. Viewers are blocked. + Pending, + /// End user accepted — the session may proceed (viewer join / streaming). + Granted, + /// End user declined, or the prompt timed out — the session is torn down. + Denied, +} + +impl ConsentState { + /// Database/wire string form (matches the `consent_state` column CHECK). + pub fn as_db_str(self) -> &'static str { + match self { + ConsentState::NotRequired => "not_required", + ConsentState::Pending => "pending", + ConsentState::Granted => "granted", + ConsentState::Denied => "denied", + } + } + + /// True if a viewer is allowed to join a session in this consent state. + /// Only `NotRequired` (managed) and `Granted` (consented attended) admit a + /// viewer; `Pending` and `Denied` block the join. + pub fn allows_viewer(self) -> bool { + matches!(self, ConsentState::NotRequired | ConsentState::Granted) + } +} + /// Heartbeat timeout (90 seconds - 3x the agent's 30 second interval) #[allow(dead_code)] // TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/ const HEARTBEAT_TIMEOUT_SECS: u64 = 90; @@ -42,6 +81,8 @@ pub struct Session { pub is_streaming: bool, pub is_online: bool, // Whether agent is currently connected pub is_persistent: bool, // Persistent agent (no support code) vs support session + /// Attended-consent state (Task 5). Gates viewer join for attended sessions. + pub consent_state: ConsentState, pub last_heartbeat: chrono::DateTime, // Agent status info pub os_version: Option, @@ -149,6 +190,13 @@ impl SessionManager { is_streaming: false, is_online: true, is_persistent, + // Attended (support-code) sessions require consent before a viewer + // may join; managed/persistent sessions do not (Phase-1 policy). + consent_state: if is_persistent { + ConsentState::NotRequired + } else { + ConsentState::Pending + }, last_heartbeat: now, os_version: None, is_elevated: false, @@ -228,6 +276,29 @@ impl SessionManager { } } + /// Set the attended-consent state for a session (Task 5). + /// + /// Returns the previous state if the session exists, or `None` if it does + /// not. Used by the relay to move an attended session through + /// `Pending → Granted` (proceed) or `Pending → Denied` (tear down). + pub async fn set_consent_state( + &self, + session_id: SessionId, + state: ConsentState, + ) -> Option { + let mut sessions = self.sessions.write().await; + let session_data = sessions.get_mut(&session_id)?; + let previous = session_data.info.consent_state; + session_data.info.consent_state = state; + Some(previous) + } + + /// Get the current attended-consent state for a session, if it exists. + pub async fn get_consent_state(&self, session_id: SessionId) -> Option { + let sessions = self.sessions.read().await; + sessions.get(&session_id).map(|s| s.info.consent_state) + } + /// Check if a session has timed out (no heartbeat for too long) #[allow(dead_code)] // TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/ pub async fn is_session_timed_out(&self, session_id: SessionId) -> bool { @@ -277,6 +348,21 @@ impl SessionManager { let mut sessions = self.sessions.write().await; let session_data = sessions.get_mut(&session_id)?; + // CONSENT GATE (Task 5): an attended session must have the end user's + // consent before a viewer may join. A `Pending`/`Denied` session is not + // joinable — the technician's session is not surfaced until `Granted` + // (or `NotRequired` for managed sessions). This is the enforcement point + // that keeps a support-code session invisible to the technician until + // the end user accepts. + if !session_data.info.consent_state.allows_viewer() { + tracing::warn!( + "Viewer join refused for session {}: consent_state = {}", + session_id, + session_data.info.consent_state.as_db_str() + ); + return None; + } + let was_empty = session_data.viewers.is_empty(); // Add viewer info @@ -519,6 +605,7 @@ impl SessionManager { is_streaming: false, is_online: false, // Offline until agent reconnects is_persistent: true, + consent_state: ConsentState::NotRequired, // managed/persistent last_heartbeat: now, os_version: None, is_elevated: false, @@ -553,3 +640,95 @@ impl SessionManager { session_id } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn consent_state_db_strings_match_column_check() { + assert_eq!(ConsentState::NotRequired.as_db_str(), "not_required"); + assert_eq!(ConsentState::Pending.as_db_str(), "pending"); + assert_eq!(ConsentState::Granted.as_db_str(), "granted"); + assert_eq!(ConsentState::Denied.as_db_str(), "denied"); + } + + #[test] + fn only_granted_or_not_required_admit_a_viewer() { + assert!(ConsentState::NotRequired.allows_viewer()); + assert!(ConsentState::Granted.allows_viewer()); + assert!(!ConsentState::Pending.allows_viewer()); + assert!(!ConsentState::Denied.allows_viewer()); + } + + #[tokio::test] + async fn attended_session_starts_pending_and_blocks_viewer_until_granted() { + let mgr = SessionManager::new(); + + // Attended (support-code) session: is_persistent = false. + let (session_id, _frame_tx, _input_rx) = mgr + .register_agent("agent-att".to_string(), "Attended PC".to_string(), false) + .await; + + // Starts Pending. + assert_eq!( + mgr.get_consent_state(session_id).await, + Some(ConsentState::Pending) + ); + + // A viewer cannot join while Pending. + assert!(mgr + .join_session(session_id, "viewer-1".to_string(), "Tech".to_string()) + .await + .is_none()); + + // Grant consent -> viewer may now join. + let previous = mgr + .set_consent_state(session_id, ConsentState::Granted) + .await; + assert_eq!(previous, Some(ConsentState::Pending)); + assert!(mgr + .join_session(session_id, "viewer-2".to_string(), "Tech".to_string()) + .await + .is_some()); + } + + #[tokio::test] + async fn managed_session_is_not_required_and_joins_immediately() { + let mgr = SessionManager::new(); + + // Managed/persistent session: is_persistent = true. + let (session_id, _frame_tx, _input_rx) = mgr + .register_agent("agent-mgd".to_string(), "Managed PC".to_string(), true) + .await; + + assert_eq!( + mgr.get_consent_state(session_id).await, + Some(ConsentState::NotRequired) + ); + + // No consent prompt — a viewer joins immediately. + assert!(mgr + .join_session(session_id, "viewer-1".to_string(), "Tech".to_string()) + .await + .is_some()); + } + + #[tokio::test] + async fn denied_attended_session_keeps_viewer_blocked() { + let mgr = SessionManager::new(); + let (session_id, _frame_tx, _input_rx) = mgr + .register_agent("agent-deny".to_string(), "Deny PC".to_string(), false) + .await; + + mgr.set_consent_state(session_id, ConsentState::Denied).await; + assert_eq!( + mgr.get_consent_state(session_id).await, + Some(ConsentState::Denied) + ); + assert!(mgr + .join_session(session_id, "viewer-x".to_string(), "Tech".to_string()) + .await + .is_none()); + } +} diff --git a/specs/v2-secure-session-core/plan.md b/specs/v2-secure-session-core/plan.md index 79a50e3..b2a9325 100644 --- a/specs/v2-secure-session-core/plan.md +++ b/specs/v2-secure-session-core/plan.md @@ -250,10 +250,61 @@ Reference: audit Pass B/E (rate limiting disabled/non-compiling; reusable codes) --- -## Task 5: Attended-mode consent +## Task 5 [IMPLEMENTED 2026-05-30 — self-reviewed; no Rust toolchain on this machine, not yet `cargo check`-verified; pending Code Review]: Attended-mode consent -Files touched: `proto/guruconnect.proto`, `agent/src/session/mod.rs`, `agent/src/` (consent UI dialog), -`server/src/relay/mod.rs`, `server/src/session/mod.rs`. +> [IMPLEMENTED] An ATTENDED (support-code) session now requires the end user to +> ACCEPT a native consent prompt before the technician's session is surfaced. +> +> - PROTO (`proto/guruconnect.proto`): added `ConsentRequest` (server/relay → +> agent: `session_id`, `technician_name`, `access_mode` (`ConsentAccessMode` +> = `CONSENT_VIEW`|`CONSENT_CONTROL`), `timeout_secs`) and `ConsentResponse` +> (agent → server: `session_id`, `granted`, `reason`), inserted AFTER +> `AdminCommand`. New `Message` oneof field numbers `consent_request = 80`, +> `consent_response = 81` (no existing field renumbered). +> - SERVER (`session/mod.rs`): new `ConsentState` enum +> (`NotRequired|Pending|Granted|Denied`, `as_db_str`/`allows_viewer`) added to +> the in-memory `Session`. `register_agent` starts attended (`!is_persistent`) +> sessions `Pending`, managed/persistent `NotRequired`. `join_session` REFUSES +> any viewer unless `consent_state.allows_viewer()` (only `Granted`/ +> `NotRequired`) — this is the gate that keeps a support session invisible to +> the technician until accepted. New `set_consent_state`/`get_consent_state`. +> Unit tests cover the db-string mapping, the viewer-admission predicate, and +> the attended-pending-blocks / granted-admits / managed-admits / denied-blocks +> transitions. +> - SERVER (`relay/mod.rs`): after registering an attended agent, +> `run_consent_handshake` sends `ConsentRequest`, audits `consent_requested`, +> then waits up to `CONSENT_TIMEOUT_SECS = 60` for a `ConsentResponse`. +> granted → `consent_state = Granted` + audit `consent_granted` + proceed. +> denied/timeout/agent-disconnect → `consent_state = Denied` + audit +> `consent_denied`, send a Disconnect to the agent, end the session row +> (`status='denied'`), release the code, and TEAR DOWN (early return — the +> technician never sees the session). In-memory consent is authoritative; the +> DB `consent_state` (via `db::sessions::update_consent_state`) is a durable/ +> audit mirror. A late/dup `ConsentResponse` in the main loop is logged+ignored +> (no silent unhandled variant). `db::events` gained `CONSENT_GRANTED`/ +> `CONSENT_DENIED`/`CONSENT_REQUESTED`. `db::sessions::create_session` now sets +> `is_managed`/`source`/`consent_state` from `is_support_session` (attended → +> `false`/`standalone`/`pending`; managed → `true`/`gururmm`/`not_required`). +> `api::SessionInfo` echoes `consent_state` so the dashboard can show "awaiting +> consent". +> - AGENT (`consent/mod.rs` [new], `session/mod.rs`, `main.rs`): on +> `ConsentRequest`, `handle_consent_request` runs a blocking Windows +> `MessageBox` (MB_YESNO | TOPMOST | SETFOREGROUND | SYSTEMMODAL | ICONQUESTION) +> on `spawn_blocking` so the async loop/heartbeats are not stalled, phrasing +> the prompt VIEW vs VIEW-and-CONTROL from `access_mode`, then sends a +> `ConsentResponse`. Anything other than an explicit Yes (closed box, panic) is +> a DENY. Non-Windows build is a `// TODO(platform)` stub that fails CLOSED +> (denies). Unit tests cover the prompt wording + the access-mode decode +> fallback. +> - Managed/unattended sessions are `not_required`, never prompted (Phase-1 +> default). PER-TENANT consent policy beyond this default is a future +> refinement — left as a TODO (no per-tenant policy table consulted yet). +> - No `.unwrap()` on a non-test path; runtime `sqlx::query`; no code/secret/ +> token value logged. No Rust toolchain here — self-reviewed only. + +Files touched: `proto/guruconnect.proto`, `agent/src/session/mod.rs`, `agent/src/consent/mod.rs` (new), +`agent/src/main.rs`, `server/src/relay/mod.rs`, `server/src/session/mod.rs`, `server/src/db/sessions.rs`, +`server/src/db/events.rs`, `server/src/api/mod.rs`. - Add `ConsentRequest` / `ConsentResponse` to the proto (after `AdminCommand`). - On an attended session, the agent shows a consent dialog to the end user; the server keeps the session