feat(server,agent): v2 secure-session-core Task 5 - attended consent
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 5m42s
Build and Test / Build Agent (Windows) (push) Successful in 8m22s
Build and Test / Security Audit (push) Successful in 5m12s
Build and Test / Build Summary (push) Has been skipped

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:
2026-05-30 07:44:09 -07:00
parent 8cb0b5b16b
commit 9082e11490
10 changed files with 906 additions and 5 deletions

155
agent/src/consent/mod.rs Normal file
View 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);
}
}

View File

@@ -15,6 +15,7 @@
mod capture;
mod chat;
mod config;
mod consent;
mod encoder;
mod input;
mod install;

View File

@@ -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 {