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);
}
}