diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6746a0db..bc9692cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -261,6 +261,14 @@ jobs: env: RUSTFLAGS: -D warnings run: cargo build --release -p keld-host + - name: Build Linux media guard probe + run: | + cargo build -p keld-wv --example linux_media_guard + cc -shared -fPIC -Wall -Wextra -Werror -Wpedantic \ + $(pkg-config --cflags webkit2gtk-4.1) \ + crates/keld-wv/tests/fixtures/linux_media_interpose.c \ + -o "$RUNNER_TEMP/linux_media_interpose.so" \ + -ldl $(pkg-config --libs webkit2gtk-4.1) # KEL-28's own DoD promised "a smoke test exists that CI can run # (headless/virtual display where needed)" but this job never existed # until now — closing a disclosed gap, not a new requirement. @@ -268,251 +276,7 @@ jobs: # WebKitGtkEngine's fail-closed validation both report Normal here; # this job proves window creation, not the NVIDIA+Wayland re-exec branch. - name: Xvfb GUI smoke test — title, controls, and clean close - run: | - xvfb-run --auto-servernum --server-args='-screen 0 1024x768x24' bash <<'X11' - set -euo pipefail - window_manager_pid="" - hello_pid="" - cleanup_probe_pid="" - title_confirmed=0 - pid_bound=0 - close_confirmed=0 - # A bare `trap '... || true' EXIT` clobbers $? with the trap's own - # last command status, so a script that hit `exit 1` would report - # success to the CI step. Capture the real exit code first, run - # cleanup, then re-exit with the captured code explicitly. - process_alive() { - local process_pid=$1 - local process_state - if ! process_state=$(ps -o stat= -p "$process_pid" 2>/dev/null \ - | tr -d '[:space:]'); then - return 1 - fi - case "$process_state" in - ''|Z*) return 1 ;; - *) return 0 ;; - esac - } - terminate_child() { - local child_pid=$1 - [ -n "$child_pid" ] || return - if process_alive "$child_pid"; then - kill "$child_pid" 2>/dev/null || true - for _ in $(seq 1 20); do - process_alive "$child_pid" || break - sleep 0.1 - done - fi - if process_alive "$child_pid"; then - kill -KILL "$child_pid" 2>/dev/null || true - for _ in $(seq 1 20); do - process_alive "$child_pid" || break - sleep 0.1 - done - fi - if ! process_alive "$child_pid"; then - wait "$child_pid" 2>/dev/null || true - fi - } - cleanup() { - ec=$? - set +e - terminate_child "$cleanup_probe_pid" - terminate_child "$hello_pid" - terminate_child "$window_manager_pid" - exit "$ec" - } - trap cleanup EXIT - - sh -c 'kill -STOP $$' & - cleanup_probe_pid=$! - probe_stopped=0 - for _ in $(seq 1 20); do - case "$(ps -o stat= -p "$cleanup_probe_pid" 2>/dev/null)" in - T*) probe_stopped=1; break ;; - esac - sleep 0.05 - done - if [ "$probe_stopped" -ne 1 ]; then - echo "::error::cleanup negative-control child never stopped" - exit 1 - fi - terminate_child "$cleanup_probe_pid" - if process_alive "$cleanup_probe_pid"; then - echo "::error::cleanup did not reap a stopped child within its bound" - exit 1 - fi - cleanup_probe_pid="" - - if ! xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then - echo "::error::xvfb-run display is unreachable: $DISPLAY" - exit 1 - fi - - fluxbox -display "$DISPLAY" >"$RUNNER_TEMP/fluxbox.log" 2>&1 & - window_manager_pid=$! - window_manager_ready=0 - for _ in $(seq 1 50); do - if ! kill -0 "$window_manager_pid" 2>/dev/null; then - echo "::error::Fluxbox exited before owning the X11 display" - cat "$RUNNER_TEMP/fluxbox.log" - break - fi - if root_check=$(xprop -root _NET_SUPPORTING_WM_CHECK 2>/dev/null); then - supporting_window=$(printf '%s\n' "$root_check" | awk '/window id/ { print $NF }') - if [ -n "$supporting_window" ] && \ - child_check=$(xprop -id "$supporting_window" _NET_SUPPORTING_WM_CHECK 2>/dev/null) && \ - wm_name=$(xprop -id "$supporting_window" _NET_WM_NAME 2>/dev/null); then - child_window=$(printf '%s\n' "$child_check" | awk '/window id/ { print $NF }') - if [ "$child_window" = "$supporting_window" ] && \ - printf '%s\n' "$wm_name" | grep -q '= "Fluxbox"$'; then - window_manager_ready=1 - break - fi - fi - fi - sleep 0.2 - done - if [ "$window_manager_ready" -ne 1 ]; then - echo "::error::Fluxbox never advertised its EWMH control window" - exit 1 - fi - - ./target/release/keld-host --hello --title CI-Linux-Smoke & - hello_pid=$! - - window_id="" - for _ in $(seq 1 60); do - if ! kill -0 "$hello_pid" 2>/dev/null; then - echo "::error::keld-host --hello exited before a window was found" - break - fi - if matches=$(xdotool search --all --onlyvisible --pid "$hello_pid" \ - --name '^CI-Linux-Smoke$' 2>/dev/null); then - if [ "$(printf '%s\n' "$matches" | wc -l)" -ne 1 ]; then - echo "::error::expected one exact Linux smoke window, got: $matches" - exit 1 - fi - window_id=$matches - break - else - search_status=$? - if [ "$search_status" -ne 1 ]; then - echo "::error::xdotool window search failed with $search_status" - exit 1 - fi - fi - sleep 0.5 - done - - if [ -z "$window_id" ]; then - echo "::error::keld-host --hello never produced a titled window under Xvfb" - exit 1 - fi - if [ "$(xdotool getwindowname "$window_id")" != "CI-Linux-Smoke" ]; then - echo "::error::Linux smoke window title is not exact" - exit 1 - fi - window_pid=$(xdotool getwindowpid "$window_id") - if [ "$window_pid" != "$hello_pid" ]; then - echo "::error::Linux smoke window belongs to PID $window_pid, expected $hello_pid" - exit 1 - fi - title_confirmed=1 - pid_bound=1 - - xdotool windowsize "$window_id" 800 600 - resized=0 - width=unknown - height=unknown - for _ in $(seq 1 50); do - geometry=$(xdotool getwindowgeometry --shell "$window_id") - width=$(printf '%s\n' "$geometry" | awk -F= '$1 == "WIDTH" { print $2 }') - height=$(printf '%s\n' "$geometry" | awk -F= '$1 == "HEIGHT" { print $2 }') - if [ "$width" = 800 ] && [ "$height" = 600 ]; then - resized=1 - break - fi - sleep 0.1 - done - if [ "$resized" -ne 1 ]; then - echo "::error::resize requested 800x600, observed ${width}x${height}" - exit 1 - fi - - xdotool windowminimize "$window_id" - minimized=0 - for _ in $(seq 1 50); do - if ! window_state=$(xprop -id "$window_id" WM_STATE 2>&1); then - echo "::error::cannot read minimized WM_STATE: $window_state" - exit 1 - fi - if printf '%s\n' "$window_state" | grep -q 'window state: Iconic'; then - minimized=1 - break - fi - sleep 0.1 - done - if [ "$minimized" -ne 1 ]; then - echo "::error::Linux smoke window did not become minimized" - exit 1 - fi - - xdotool windowactivate "$window_id" - restored=0 - for _ in $(seq 1 50); do - if ! window_state=$(xprop -id "$window_id" WM_STATE 2>&1); then - echo "::error::cannot read restored WM_STATE: $window_state" - exit 1 - fi - if printf '%s\n' "$window_state" | grep -q 'window state: Normal'; then - restored=1 - break - fi - sleep 0.1 - done - if [ "$restored" -ne 1 ]; then - echo "::error::Linux smoke window did not restore" - exit 1 - fi - - if ! process_alive "$hello_pid"; then - echo "::error::keld-host stopped before the close request" - exit 1 - fi - window_hex=$(printf '0x%x' "$window_id") - wmctrl -ic "$window_hex" - exited=0 - for _ in $(seq 1 100); do - if ! process_alive "$hello_pid"; then - exited=1 - break - fi - sleep 0.1 - done - if [ "$exited" -ne 1 ]; then - echo "::error::keld-host did not exit after the window close request" - exit 1 - fi - set +e - wait "$hello_pid" - hello_status=$? - set -e - hello_pid="" - if [ "$hello_status" -ne 0 ]; then - echo "::error::keld-host exited $hello_status after window close" - exit 1 - fi - close_confirmed=1 - if [ "$window_manager_ready" -ne 1 ] || [ "$title_confirmed" -ne 1 ] || \ - [ "$pid_bound" -ne 1 ] || [ "$resized" -ne 1 ] || \ - [ "$minimized" -ne 1 ] || [ "$restored" -ne 1 ] || \ - [ "$close_confirmed" -ne 1 ]; then - echo "::error::Linux window-control receipt is incomplete" - exit 1 - fi - echo "Linux hello title, resize, minimize, restore, close, and reap confirmed under X11" - X11 + run: xvfb-run --auto-servernum --server-args='-screen 0 1024x768x24' crates/keld-wv/tests/linux_gui_smoke.sh "$RUNNER_TEMP/linux_media_interpose.so" target/debug/examples/linux_media_guard ./target/release/keld-host msrv: name: MSRV diff --git a/crates/keld-wv/AGENTS.md b/crates/keld-wv/AGENTS.md index 5708b1f4..9cd8b930 100644 --- a/crates/keld-wv/AGENTS.md +++ b/crates/keld-wv/AGENTS.md @@ -18,10 +18,12 @@ Spec: `docs/architecture/05-webview-and-native.md`. Platform truth: `docs/resear back to AppProcess. v0 `evaluate` still denies webview principals (`KELD-GUARD006`) until window-level grants exist — that is fail-closed, not a reason to present AppProcess. Per backend: - - macOS / Linux (wry interim): agents MUST NOT omit wry `with_permission_handler` - on a live `WebViewBuilder` — wry 0.56 auto-grants on macOS and shows - WebKitGTK's own prompt on Linux when the handler is `None`; neither is - default-deny. + - macOS 12+ (wry interim): agents MUST NOT omit wry `with_permission_handler`; + wry auto-grants new media requests when absent. Pinned wry cfg-removes its + delegate on older debug hosts; oldest-OS proof is open ([source](https://github.com/tauri-apps/wry/blob/14be44842747a62c4110bd982f61f6c1acd705c3/build.rs)). + - Linux (wry interim): WebKitGTK 2.52.6 and wry 0.56.1 default-deny an + unhandled new request, but that fallback is not proof Keld evaluated the + right principal/manifest ([source](https://webkitgtk.org/reference/webkit2gtk/stable/class.UserMediaPermissionRequest.html)); explicit callback provenance remains mandatory. - Windows (direct COM, KEL-65): agents MUST register the guarded `add_PermissionRequested` handler before the first navigation — WebView2's fallback is a user prompt (default-ask, not default-deny). The first diff --git a/crates/keld-wv/examples/linux_media_guard.rs b/crates/keld-wv/examples/linux_media_guard.rs new file mode 100644 index 00000000..a0b7d944 --- /dev/null +++ b/crates/keld-wv/examples/linux_media_guard.rs @@ -0,0 +1,401 @@ +//! Real Linux `WebKitGTK` media-permission probe for KEL-132. +//! +//! The example is test evidence, not a shipping binary. It serves one secure +//! localhost page, requests one mock capture kind, and exits only after the +//! page reports the observed result. The companion `LD_PRELOAD` fixture records +//! whether wry consumed Keld's callback through the `WebKitGTK` deny API. + +#[cfg(target_os = "linux")] +mod linux { + use std::env; + use std::io::{ErrorKind, Read, Write}; + use std::net::{TcpListener, TcpStream}; + use std::sync::mpsc; + use std::thread; + use std::time::{Duration, Instant}; + + use keld_wv::webkitgtk::{WebKitGtkEngine, prepare_gpu_safe_mode_process}; + use keld_wv::{ + AppWindowCommand, AppWindowEvent, LogicalSize, NavTarget, WebEngine, WebviewSpec, + }; + + const SERVER_DEADLINE: Duration = Duration::from_secs(20); + const STREAM_DEADLINE: Duration = Duration::from_secs(2); + const MAX_REQUEST_BYTES: usize = 16 * 1024; + + #[derive(Clone, Copy)] + enum MediaKind { + Camera, + Microphone, + } + + impl MediaKind { + fn parse(value: &str) -> Result { + match value { + "camera" => Ok(Self::Camera), + "microphone" => Ok(Self::Microphone), + _ => Err(format!( + "unknown media kind `{value}`; use `camera` or `microphone`" + )), + } + } + + const fn name(self) -> &'static str { + match self { + Self::Camera => "camera", + Self::Microphone => "microphone", + } + } + + const fn constraints(self) -> &'static str { + match self { + Self::Camera => "{ audio: false, video: true }", + Self::Microphone => "{ audio: true, video: false }", + } + } + } + + #[derive(Clone, Copy)] + enum ExpectedOutcome { + Denied, + Allowed, + } + + impl ExpectedOutcome { + fn parse(value: &str) -> Result { + match value { + "denied" => Ok(Self::Denied), + "allowed" => Ok(Self::Allowed), + _ => Err(format!( + "unknown expected outcome `{value}`; use `denied` or `allowed`" + )), + } + } + + fn matches(self, outcome: &str) -> bool { + match self { + Self::Denied => matches!(outcome, "NotAllowedError" | "SecurityError"), + Self::Allowed => matches!(outcome, "resolved"), + } + } + } + + struct ProbeResult { + secure_context: bool, + outcome: String, + } + + pub fn run() -> Result<(), String> { + let mut args = env::args().skip(1); + let kind = MediaKind::parse( + &args + .next() + .ok_or_else(|| String::from("missing media kind"))?, + )?; + let expected = ExpectedOutcome::parse( + &args + .next() + .ok_or_else(|| String::from("missing expected outcome"))?, + )?; + let nonce = args + .next() + .ok_or_else(|| String::from("missing run nonce"))?; + if nonce.is_empty() + || !nonce + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + { + return Err(String::from( + "run nonce must contain only ASCII letters, digits, and hyphens", + )); + } + if env::var("KELD_MEDIA_NONCE").as_deref() != Ok(nonce.as_str()) { + return Err(String::from( + "KELD_MEDIA_NONCE must exactly match the run nonce argument", + )); + } + if let Some(extra) = args.next() { + return Err(format!("unexpected argument `{extra}`")); + } + + let _ = prepare_gpu_safe_mode_process().map_err(|error| error.to_string())?; + let listener = TcpListener::bind(("127.0.0.1", 0)).map_err(|error| error.to_string())?; + listener + .set_nonblocking(true) + .map_err(|error| error.to_string())?; + let address = listener.local_addr().map_err(|error| error.to_string())?; + let (commands_tx, commands_rx) = mpsc::channel(); + let server_nonce = nonce.clone(); + let server = thread::Builder::new() + .name(String::from("keld-media-probe-http")) + .spawn(move || serve(&listener, kind, &server_nonce, &commands_tx)) + .map_err(|error| error.to_string())?; + + let mut engine = WebKitGtkEngine::new().map_err(|error| error.to_string())?; + let (events_tx, _events_rx) = mpsc::channel::(); + let primer = WebviewSpec { + title: format!("Keld Media Guard Primer {nonce}"), + initial: NavTarget::Html(String::from( + "identity primer", + )), + size: LogicalSize { + width: 320.0, + height: 240.0, + }, + }; + let primer_id = engine.create(&primer).map_err(|error| error.to_string())?; + engine + .destroy(primer_id) + .map_err(|error| error.to_string())?; + let spec = WebviewSpec { + title: format!("Keld Media Guard {} {nonce}", kind.name()), + initial: NavTarget::Url(format!("http://{address}/{nonce}/")), + size: LogicalSize { + width: 640.0, + height: 480.0, + }, + }; + let media_id = engine + .create_app(&spec, events_tx.clone()) + .map_err(|error| error.to_string())?; + publish_media_id(media_id)?; + engine + .run_app_until_quit(commands_rx, events_tx) + .map_err(|error| error.to_string())?; + + let result = server + .join() + .map_err(|_| String::from("media probe server thread panicked"))??; + if !result.secure_context { + return Err(String::from( + "localhost page was not a secure context; media result is not a permission oracle", + )); + } + if !expected.matches(&result.outcome) { + return Err(format!( + "{} expected a different result, observed `{}`", + kind.name(), + result.outcome + )); + } + println!( + "KELD_MEDIA_RESULT nonce={nonce} kind={} secure_context=true outcome={}", + kind.name(), + result.outcome + ); + Ok(()) + } + + fn publish_media_id(id: keld_wv::WebviewId) -> Result<(), String> { + let path = env::var_os("KELD_MEDIA_IDENTITY_RECEIPT") + .ok_or_else(|| String::from("KELD_MEDIA_IDENTITY_RECEIPT is unset"))?; + let mut receipt = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .map_err(|error| format!("cannot publish media webview id: {error}"))?; + writeln!(receipt, "{}", id.0).map_err(|error| error.to_string()) + } + + fn serve( + listener: &TcpListener, + kind: MediaKind, + nonce: &str, + commands: &mpsc::Sender, + ) -> Result { + let result = serve_until_result(listener, kind, nonce, commands); + if result.is_err() { + let _ = commands.send(AppWindowCommand::Fatal); + } + result + } + + fn serve_until_result( + listener: &TcpListener, + kind: MediaKind, + nonce: &str, + commands: &mpsc::Sender, + ) -> Result { + let deadline = Instant::now() + SERVER_DEADLINE; + while Instant::now() < deadline { + match listener.accept() { + Ok((mut stream, _)) => { + let path = request_path(&mut stream)?; + if path == format!("/{nonce}/") { + respond_html(&mut stream, kind, nonce)?; + } else if path == format!("/{nonce}/ready") { + await_request_start()?; + respond(&mut stream, "204 No Content", "text/plain", b"")?; + } else if let Some(query) = path.strip_prefix(&format!("/{nonce}/result?")) { + let result = parse_result(query)?; + respond(&mut stream, "204 No Content", "text/plain", b"")?; + await_census()?; + commands + .send(AppWindowCommand::Quit) + .map_err(|_| String::from("window command receiver closed"))?; + return Ok(result); + } else { + respond(&mut stream, "404 Not Found", "text/plain", b"not found")?; + } + } + Err(error) if error.kind() == ErrorKind::WouldBlock => thread::yield_now(), + Err(error) => return Err(error.to_string()), + } + } + Err(String::from( + "media page produced no result before the server deadline", + )) + } + + fn await_census() -> Result<(), String> { + let ready = env::var_os("KELD_MEDIA_READY") + .ok_or_else(|| String::from("KELD_MEDIA_READY is unset"))?; + let release = env::var_os("KELD_MEDIA_RELEASE") + .ok_or_else(|| String::from("KELD_MEDIA_RELEASE is unset"))?; + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&ready) + .map_err(|error| format!("cannot publish census readiness: {error}"))?; + let deadline = Instant::now() + SERVER_DEADLINE; + while Instant::now() < deadline { + if std::path::Path::new(&release).is_file() { + return Ok(()); + } + thread::yield_now(); + } + Err(String::from( + "window census did not release the media probe before its deadline", + )) + } + + fn await_request_start() -> Result<(), String> { + let ready = env::var_os("KELD_MEDIA_PAGE_READY") + .ok_or_else(|| String::from("KELD_MEDIA_PAGE_READY is unset"))?; + let release = env::var_os("KELD_MEDIA_REQUEST_RELEASE") + .ok_or_else(|| String::from("KELD_MEDIA_REQUEST_RELEASE is unset"))?; + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&ready) + .map_err(|error| format!("cannot publish page readiness: {error}"))?; + let deadline = Instant::now() + SERVER_DEADLINE; + while Instant::now() < deadline { + if std::path::Path::new(&release).is_file() { + return Ok(()); + } + thread::yield_now(); + } + Err(String::from( + "window census did not release the media request before its deadline", + )) + } + + fn request_path(stream: &mut TcpStream) -> Result { + stream + .set_read_timeout(Some(STREAM_DEADLINE)) + .map_err(|error| error.to_string())?; + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 1024]; + while bytes.len() < MAX_REQUEST_BYTES { + let read = stream + .read(&mut buffer) + .map_err(|error| error.to_string())?; + if read == 0 { + break; + } + bytes.extend_from_slice(&buffer[..read]); + if bytes.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + if bytes.len() >= MAX_REQUEST_BYTES { + return Err(String::from("HTTP request exceeded 16 KiB")); + } + let request = std::str::from_utf8(&bytes).map_err(|error| error.to_string())?; + let line = request + .lines() + .next() + .ok_or_else(|| String::from("HTTP request has no request line"))?; + let mut fields = line.split_ascii_whitespace(); + if fields.next() != Some("GET") { + return Err(String::from("media probe accepts only GET")); + } + fields + .next() + .map(str::to_owned) + .ok_or_else(|| String::from("HTTP request has no path")) + } + + fn respond_html(stream: &mut TcpStream, kind: MediaKind, nonce: &str) -> Result<(), String> { + let html = format!( + r#"Keld media probe +"#, + kind.constraints() + ); + respond( + stream, + "200 OK", + "text/html; charset=utf-8", + html.as_bytes(), + ) + } + + fn parse_result(query: &str) -> Result { + let mut secure = None; + let mut outcome = None; + for field in query.split('&') { + if let Some(value) = field.strip_prefix("secure=") { + secure = Some(value == "true"); + } else if let Some(value) = field.strip_prefix("outcome=") { + outcome = Some(value.to_owned()); + } + } + Ok(ProbeResult { + secure_context: secure.ok_or_else(|| String::from("result omitted secure state"))?, + outcome: outcome.ok_or_else(|| String::from("result omitted outcome"))?, + }) + } + + fn respond( + stream: &mut TcpStream, + status: &str, + content_type: &str, + body: &[u8], + ) -> Result<(), String> { + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ) + .map_err(|error| error.to_string())?; + stream.write_all(body).map_err(|error| error.to_string()) + } +} + +#[cfg(target_os = "linux")] +fn main() { + if let Err(error) = linux::run() { + eprintln!("KELD_MEDIA_PROBE_FAIL: {error}"); + std::process::exit(1); + } +} + +#[cfg(not(target_os = "linux"))] +fn main() { + eprintln!("linux_media_guard is available only on Linux"); + std::process::exit(1); +} diff --git a/crates/keld-wv/src/media.rs b/crates/keld-wv/src/media.rs index 02483c78..182635f0 100644 --- a/crates/keld-wv/src/media.rs +++ b/crates/keld-wv/src/media.rs @@ -3,14 +3,20 @@ //! One policy, two install mechanisms: //! //! - **macOS + Linux (wry interim)**: wry's `with_permission_handler` is the -//! same builder call on both — omitting it means auto-grant on macOS +//! same builder call on both — omitting it means auto-grant on macOS 12+ //! (`WKPermissionDecision::Grant` in //! [`wry_web_view_ui_delegate.rs`](https://github.com/tauri-apps/wry/blob/14be44842747a62c4110bd982f61f6c1acd705c3/src/wkwebview/class/wry_web_view_ui_delegate.rs)) -//! or `WebKitGTK`'s own prompt on Linux -//! ([`connect_permission_request`](https://github.com/tauri-apps/wry/blob/14be44842747a62c4110bd982f61f6c1acd705c3/src/webkitgtk/mod.rs#L585), -//! KEL-28) — different platform defaults, same wrong-for-Keld direction. -//! Keld installs `with_guarded_media_permissions` on both (cfg-gated, so no -//! intra-doc link). Vendored locally: `competitors/wry` @ this same commit. +//! while `WebKitGTK` 2.52.6 and wry 0.56.1 default-deny an unhandled Linux +//! request. The Linux fallback still cannot prove Keld evaluated the right +//! principal and manifest. Keld therefore installs an explicit guarded +//! callback on both through a build witness (cfg-gated, so no intra-doc +//! link). Wry cfg-removes that delegate method below macOS 12 on debug +//! hosts; oldest-supported-macOS proof remains open. Vendored locally: +//! `competitors/wry` @ this same commit. +//! `WebKitGTK`'s user-media default is documented by +//! [`UserMediaPermissionRequest`](https://webkitgtk.org/reference/webkit2gtk/stable/class.UserMediaPermissionRequest.html); +//! wry's OS gate is in its pinned +//! [`build.rs`](https://github.com/tauri-apps/wry/blob/14be44842747a62c4110bd982f61f6c1acd705c3/build.rs). //! - **Windows (direct COM, KEL-65)**: without a handler `WebView2` falls back //! to its own user prompt — default-ask, not default-deny. The backend //! registers `add_PermissionRequested` before the first navigation and maps @@ -29,9 +35,16 @@ //! (no capture start, no principal mint, no manifest write). Requested //! resource remains [`WEB_MEDIA_ORIGIN`] (`*`). +#[cfg(all(target_os = "linux", debug_assertions))] +use std::fs::OpenOptions; +#[cfg(all(target_os = "linux", debug_assertions))] +use std::io::Write; + use keld_guard::{Decision, DenyReason, PermissionsManifest, Principal, evaluate}; use crate::WebviewId; +#[cfg(any(target_os = "macos", target_os = "linux"))] +use crate::{engine::NavTarget, error::WvError}; /// Capability id for camera capture (`getUserMedia` video). pub const WEB_CAMERA: &str = "web.camera"; @@ -162,46 +175,224 @@ pub fn wry_media_kind(kind: wry::PermissionKind) -> MediaPermission { } /// Guard decision for one wry permission request. Deny is fail-closed. -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(all(test, any(target_os = "macos", target_os = "linux")))] #[must_use] -pub fn media_permission_response( +fn media_permission_response( manifest: &PermissionsManifest, principal: Principal, kind: wry::PermissionKind, ) -> wry::PermissionResponse { - if media_permission_allowed(manifest, Some(principal), wry_media_kind(kind)) { + let decision = wry_media_decision(manifest, principal, wry_media_kind(kind)); + wry_response(decision.as_ref()) +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn wry_media_decision( + manifest: &PermissionsManifest, + principal: Principal, + kind: MediaPermission, +) -> Option { + kind.capability() + .map(|capability| media_permission_decision(manifest, Some(principal), capability)) +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn wry_response(decision: Option<&Decision>) -> wry::PermissionResponse { + if matches!(decision, Some(Decision::Allow)) { wry::PermissionResponse::Allow } else { wry::PermissionResponse::Deny } } +/// Boxed callback the wry adapter installs before initial content. +#[cfg(any(target_os = "macos", target_os = "linux"))] +pub(crate) type WryPermissionCallback = + Box wry::PermissionResponse + Send + Sync + 'static>; + +/// Adapter boundary that turns an unguarded builder into a guarded witness. +#[cfg(any(target_os = "macos", target_os = "linux"))] +pub(crate) trait WryPermissionInstaller: Sized { + /// Witness type produced only after the callback is installed. + type Guarded; + + /// Installs `callback` and returns the guarded witness. + fn install_permission_handler(self, callback: WryPermissionCallback) -> Self::Guarded; +} + +/// Opaque witness retaining the wry builder after Keld installs its callback. +#[cfg(any(target_os = "macos", target_os = "linux"))] +pub(crate) struct GuardedWryBuilder { + inner: B, +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +impl WryPermissionInstaller for wry::WebViewBuilder<'_> { + type Guarded = GuardedWryBuilder; + + fn install_permission_handler(self, callback: WryPermissionCallback) -> Self::Guarded { + GuardedWryBuilder { + inner: self.with_permission_handler(callback), + } + } +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +impl<'a> GuardedWryBuilder> { + fn with_initial_target(self, target: &NavTarget) -> wry::WebViewBuilder<'a> { + match target { + NavTarget::Html(html) => self.inner.with_html(html), + NavTarget::Url(url) => self.inner.with_url(url), + } + } +} + +#[cfg(target_os = "linux")] +impl<'a> GuardedWryBuilder> { + /// Applies initial content and performs the only Linux build operation + /// without exposing the guarded builder for callback replacement. + pub(crate) fn build_initial_gtk( + self, + target: &NavTarget, + window: &'a tao::window::Window, + ) -> Result { + use tao::platform::unix::WindowExtUnix; + use wry::WebViewBuilderExtUnix; + + let vbox = window.default_vbox().ok_or_else(|| { + WvError::Webview(String::from( + "tao window has no default GTK vbox (WindowBuilderExtUnix::with_default_vbox(false) was set)", + )) + })?; + self.with_initial_target(target) + .build_gtk(vbox) + .map_err(|error| WvError::Webview(error.to_string())) + } +} + +#[cfg(target_os = "macos")] +impl<'a> GuardedWryBuilder> { + /// Applies initial content and performs the only macOS build operation + /// without exposing the guarded builder for callback replacement. + pub(crate) fn build_initial_window( + self, + target: &NavTarget, + window: &'a tao::window::Window, + ) -> Result { + self.with_initial_target(target) + .build(window) + .map_err(|error| WvError::Webview(error.to_string())) + } +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +pub(crate) fn guarded_default_media_builder( + id: WebviewId, + on_page_load: impl Fn(wry::PageLoadEvent, String) + 'static, +) -> GuardedWryBuilder> { + let builder = wry::WebViewBuilder::new(); + #[cfg(debug_assertions)] + let builder = builder.with_devtools(true); + let builder = builder.with_on_page_load_handler(on_page_load); + with_guarded_media_permissions(builder, PermissionsManifest::default(), id) +} + /// Installs a default-deny media-capture handler backed by `keld-guard`. /// -/// Omitting wry's handler means wry 0.56.1 auto-grants on macOS +/// Omitting wry's handler means wry 0.56.1 auto-grants on macOS 12+ /// ([`wry_web_view_ui_delegate.rs`](https://github.com/tauri-apps/wry/blob/14be44842747a62c4110bd982f61f6c1acd705c3/src/wkwebview/class/wry_web_view_ui_delegate.rs) -/// returns `Grant` unconditionally) or shows `WebKitGTK`'s own prompt on Linux -/// ([`webkitgtk/mod.rs`](https://github.com/tauri-apps/wry/blob/14be44842747a62c4110bd982f61f6c1acd705c3/src/webkitgtk/mod.rs#L642): -/// an unhandled request "let[s] `WebKitGTK` show default prompt"). The -/// manifest is the authority (`docs/architecture/03-security.md` §1), so -/// `Deny` here is deliberate on both: default-deny, not default-ask. +/// returns `Grant` unconditionally). Linux's unhandled default is already +/// deny, but only this explicit callback proves the host evaluated a new +/// request against the minted webview principal and immutable manifest. Saved +/// browser permission preferences can bypass wry's callback; KEL-135 owns the +/// required ephemeral-dev/persistent-profile lifecycle boundary. The manifest +/// remains the authority (`docs/architecture/03-security.md` §1). /// -/// `principal` MUST be the webview this builder will become. Presenting -/// [`Principal::AppProcess`] is `KELD-GUARD007`, not an allow. -/// -/// The `backends_install_guarded_handler` test asserts every live backend -/// wires its platform mechanism; dropping the call silently restores the -/// platform default. Windows registers `add_PermissionRequested` directly in -/// `webview2/mod.rs` (`install_guarded_media_permissions`) since KEL-65. +/// This helper accepts a [`WebviewId`], not an arbitrary [`Principal`], and +/// mints the media principal itself. The opaque witness gates initial content +/// and platform build. Windows keeps its fallible COM-specific +/// `GuardInstalled` owner in `webview2/mod.rs`. #[cfg(any(target_os = "macos", target_os = "linux"))] #[must_use] -pub fn with_guarded_media_permissions( - builder: wry::WebViewBuilder<'_>, +pub(crate) fn with_guarded_media_permissions( + installer: I, manifest: PermissionsManifest, + id: WebviewId, +) -> I::Guarded +where + I: WryPermissionInstaller, +{ + let principal = webview_media_principal(id); + let callback = Box::new(move |kind| { + let media_kind = wry_media_kind(kind); + let decision = wry_media_decision(&manifest, principal, media_kind); + let response = wry_response(decision.as_ref()); + trace_linux_policy_decision( + principal, + media_kind, + decision.as_ref(), + response, + &manifest, + ); + response + }); + installer.install_permission_handler(callback) +} + +#[cfg(all(target_os = "linux", debug_assertions))] +fn trace_linux_policy_decision( principal: Principal, -) -> wry::WebViewBuilder<'_> { - builder - .with_permission_handler(move |kind| media_permission_response(&manifest, principal, kind)) + kind: MediaPermission, + decision: Option<&Decision>, + response: wry::PermissionResponse, + manifest: &PermissionsManifest, +) { + let Some(path) = std::env::var_os("KELD_MEDIA_POLICY_TRACE") else { + return; + }; + let Some(capability) = kind.capability() else { + return; + }; + let decision = match decision { + Some(Decision::Allow) => "allow", + Some(Decision::Deny(reason)) => reason.code(), + None => return, + }; + let response = match response { + wry::PermissionResponse::Allow => "allow", + wry::PermissionResponse::Deny => "deny", + wry::PermissionResponse::Default => "default", + }; + let Principal::Webview { id, generation } = principal else { + return; + }; + let nonce = std::env::var("KELD_MEDIA_NONCE").unwrap_or_else(|_| String::from("missing")); + let Ok(mut trace) = OpenOptions::new().create(true).append(true).open(path) else { + return; + }; + let manifest_fingerprint = format!("{manifest:?}") + .bytes() + .fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| { + (hash ^ u64::from(byte)).wrapping_mul(0x100_0000_01b3) + }); + let _ = writeln!( + trace, + "policy nonce={nonce} capability={capability} principal=webview:{id}:{generation} manifest_fnv1a64={manifest_fingerprint:016x} decision={decision} response={response} pid={}", + std::process::id() + ); +} + +#[cfg(all( + any(target_os = "macos", target_os = "linux"), + not(all(target_os = "linux", debug_assertions)) +))] +fn trace_linux_policy_decision( + _principal: Principal, + _kind: MediaPermission, + _decision: Option<&Decision>, + _response: wry::PermissionResponse, + _manifest: &PermissionsManifest, +) { } #[cfg(test)] @@ -352,74 +543,6 @@ mod tests { assert_eq!(MediaPermission::Other.capability(), None); assert_eq!(WEB_MEDIA_ORIGIN, "*"); } - - /// Every live backend must wire its platform's guarded handler. Dropping - /// the call is silent: macOS falls back to unconditional `Grant`, Windows - /// to a user prompt — neither is default-deny, and neither fails any other - /// test. - /// - /// Source-text assertions because the alternative is driving a live - /// `getUserMedia` request through a real webview, which needs a GUI session - /// and a camera. This at least fails loudly if the wiring is deleted. - #[test] - fn backends_install_guarded_handler() { - // macOS (wry interim): the builder must pass through the shared helper. - let wkwebview = include_str!("wkwebview/mod.rs"); - assert!( - wkwebview.contains("with_guarded_media_permissions"), - "KEL-59: wkwebview omits the guarded handler, restoring wry's auto-grant" - ); - assert!( - wkwebview.contains("webview_media_principal"), - "KEL-73: wkwebview must mint a webview principal, not fall back to AppProcess" - ); - // The wry helper must still reach wry and the guard, or the backends - // above and below would be calling a no-op. - let helper = include_str!("media.rs"); - assert!( - helper.contains("with_permission_handler"), - "KEL-59: the helper must call wry's permission handler, not a no-op wrapper" - ); - assert!( - helper.contains("media_permission_response"), - "KEL-59: the handler must call media_permission_response, not a constant Allow" - ); - - // Linux (wry interim, KEL-28): same wry mechanism as macOS. - let webkitgtk = include_str!("webkitgtk/mod.rs"); - assert!( - webkitgtk.contains("with_guarded_media_permissions"), - "KEL-28/KEL-59: webkitgtk omits the guarded handler, restoring `WebKitGTK`'s default prompt" - ); - assert!( - webkitgtk.contains("webview_media_principal"), - "KEL-73: webkitgtk must mint a webview principal, not fall back to AppProcess" - ); - - // Windows (direct COM, KEL-65): the backend must register the guarded - // `PermissionRequested` handler and route it through the shared policy. - let webview2 = include_str!("webview2/mod.rs"); - assert!( - webview2.contains("install_guarded_media_permissions"), - "KEL-59: webview2 omits the guarded handler, restoring WebView2's default prompt" - ); - assert!( - webview2.contains("add_PermissionRequested"), - "KEL-59: webview2 guard must register the COM PermissionRequested handler" - ); - assert!( - webview2.contains("media_permission_allowed"), - "KEL-59: webview2 guard must consult the shared policy, not a constant" - ); - assert!( - webview2.contains("webview_media_principal"), - "KEL-73: webview2 must mint a webview principal, not fall back to AppProcess" - ); - assert!( - webview2.contains("navigate_initial(&view.webview, &guard"), - "KEL-65: the first navigation must present the GuardInstalled proof" - ); - } } /// `WebView2`-facing tests for the Windows kind mapping. Pure data — the COM @@ -500,12 +623,34 @@ mod webview2_tests { /// Windows no longer links wry. #[cfg(all(test, any(target_os = "macos", target_os = "linux")))] mod wry_tests { + use std::sync::{Arc, Mutex}; + use super::{ - MediaPermission, WebviewId, media_permission_response, webview_media_principal, - with_guarded_media_permissions, wry_media_kind, + MediaPermission, WebviewId, WryPermissionCallback, WryPermissionInstaller, + media_permission_response, webview_media_principal, with_guarded_media_permissions, + wry_media_kind, }; use keld_guard::parse_manifest; + struct FakeInstaller { + installed: Arc>>, + } + + struct FakeGuardInstalled; + + impl WryPermissionInstaller for FakeInstaller { + type Guarded = FakeGuardInstalled; + + fn install_permission_handler(self, callback: WryPermissionCallback) -> Self::Guarded { + let mut installed = match self.installed.lock() { + Ok(installed) => installed, + Err(poisoned) => poisoned.into_inner(), + }; + *installed = Some(callback); + FakeGuardInstalled + } + } + fn view() -> keld_guard::Principal { webview_media_principal(WebviewId(1)) } @@ -554,10 +699,38 @@ mod wry_tests { assert_ne!( media_permission_response(&empty, principal, wry::PermissionKind::Camera), wry::PermissionResponse::Default, - "Default continues the platform behaviour — macOS auto-grants, Linux/Windows prompt. \n v0 must Deny on all three." + "Default delegates platform policy — macOS auto-grants and Linux defaults deny without Keld provenance. v0 must explicitly Deny on both." ); } + #[test] + fn adapter_installs_the_exact_default_deny_callback() { + let installed = Arc::new(Mutex::new(None)); + let _guard = with_guarded_media_permissions( + FakeInstaller { + installed: Arc::clone(&installed), + }, + parse_manifest("{}").expect("empty manifest"), + WebviewId(7), + ); + let callback = installed + .lock() + .expect("fake callback slot") + .take() + .expect("adapter must install a callback"); + for kind in [ + wry::PermissionKind::Camera, + wry::PermissionKind::Microphone, + wry::PermissionKind::Other, + ] { + assert_eq!( + callback(kind), + wry::PermissionResponse::Deny, + "installed callback must explicitly deny {kind:?}" + ); + } + } + #[test] fn camera_grant_does_not_start_wry_capture_for_webview() { let granted = parse_manifest(r#"{"app":{"web":{"camera":["*"]}}}"#).expect("grant"); @@ -571,6 +744,5 @@ mod wry_tests { media_permission_response(&granted, principal, wry::PermissionKind::Microphone), wry::PermissionResponse::Deny ); - let _ = with_guarded_media_permissions(wry::WebViewBuilder::new(), granted, principal); } } diff --git a/crates/keld-wv/src/webkitgtk/mod.rs b/crates/keld-wv/src/webkitgtk/mod.rs index eaad404c..47f49c64 100644 --- a/crates/keld-wv/src/webkitgtk/mod.rs +++ b/crates/keld-wv/src/webkitgtk/mod.rs @@ -29,22 +29,17 @@ use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender}; use std::thread; use std::time::{Duration, Instant}; -use tao::event::{Event, WindowEvent}; -use tao::event_loop::{ControlFlow, EventLoop, EventLoopBuilder, EventLoopProxy}; -use tao::platform::run_return::EventLoopExtRunReturn; -use tao::platform::unix::WindowExtUnix; -use tao::window::{Window, WindowBuilder}; -use wry::WebViewBuilderExtUnix; - -use keld_guard::PermissionsManifest; - use crate::WebviewId; use crate::engine::{ AppWindowCommand, AppWindowEvent, DevtoolsAction, NavTarget, Rect, WebEngine, WebKitGtkEngineExt, WebviewSpec, }; use crate::error::WvError; -use crate::media::{webview_media_principal, with_guarded_media_permissions}; +use crate::media::guarded_default_media_builder; +use tao::event::{Event, WindowEvent}; +use tao::event_loop::{ControlFlow, EventLoop, EventLoopBuilder, EventLoopProxy}; +use tao::platform::run_return::EventLoopExtRunReturn; +use tao::window::{Window, WindowBuilder}; const INITIAL_NAVIGATION_DEADLINE: Duration = Duration::from_secs(5); const GPU_SAFE_MODE_ENV: &str = "WEBKIT_DISABLE_DMABUF_RENDERER"; @@ -526,20 +521,9 @@ impl WebKitGtkEngine { .build(event_loop) .map_err(|e| WvError::Window(e.to_string()))?; - let builder = wry::WebViewBuilder::new(); - #[cfg(debug_assertions)] - let builder = builder.with_devtools(true); - // KEL-59 parity: without the guard WebKitGTK falls back to its own - // permission prompt. The empty manifest is the default-deny policy; - // mint the webview id first so it cannot inherit app-process grants. - let id = self.next_id; - let builder = with_guarded_media_permissions( - builder, - PermissionsManifest::default(), - webview_media_principal(WebviewId(id)), - ); let ready = Arc::clone(&self.navigation_ready); - let builder = builder.with_on_page_load_handler(move |event, _url| { + let id = self.next_id; + let builder = guarded_default_media_builder(WebviewId(id), move |event, _url| { if matches!(event, wry::PageLoadEvent::Finished) && !ready.swap(true, Ordering::AcqRel) && let Some(events) = app_events.as_ref() @@ -547,21 +531,14 @@ impl WebKitGtkEngine { let _ = events.send(AppWindowEvent::NavigationReady); } }); - let builder = match &spec.initial { - NavTarget::Html(html) => builder.with_html(html), - NavTarget::Url(url) => builder.with_url(url), - }; - // wry's plain `build(&window)` wires only X11. `build_gtk` with tao's - // existing default vbox is required for both Wayland and X11; passing - // the GtkApplicationWindow itself silently leaves the webview detached. - let vbox = window.default_vbox().ok_or_else(|| { - WvError::Webview(String::from( - "tao window has no default GTK vbox (WindowBuilderExtUnix::with_default_vbox(false) was set)", - )) - })?; - let webview = builder - .build_gtk(vbox) - .map_err(|e| WvError::Webview(e.to_string()))?; + // KEL-59/KEL-132: Linux defaults an unhandled request to deny, but + // that is not proof Keld evaluated the right manifest/principal. The + // guarded witness mints the webview principal and is required to apply + // initial content and build the live WebKitGTK view. + // wry's plain `build(&window)` wires only X11. The witness owns the + // `build_gtk` call, so the guarded builder cannot be recovered to + // replace its callback before the Wayland/X11 build. + let webview = builder.build_initial_gtk(&spec.initial, &window)?; self.next_id += 1; self.views.insert(id, View { webview, window }); diff --git a/crates/keld-wv/src/wkwebview/mod.rs b/crates/keld-wv/src/wkwebview/mod.rs index f0c76892..d53891ae 100644 --- a/crates/keld-wv/src/wkwebview/mod.rs +++ b/crates/keld-wv/src/wkwebview/mod.rs @@ -30,13 +30,11 @@ use tao::event_loop::{ControlFlow, EventLoop, EventLoopBuilder, EventLoopProxy}; use tao::platform::run_return::EventLoopExtRunReturn; use tao::window::{Window, WindowBuilder}; -use keld_guard::PermissionsManifest; - use crate::WebviewId; pub use crate::engine::{AppWindowCommand, AppWindowEvent}; use crate::engine::{DevtoolsAction, NavTarget, Rect, WebEngine, WebviewSpec, WkWebViewEngineExt}; use crate::error::WvError; -use crate::media::{webview_media_principal, with_guarded_media_permissions}; +use crate::media::guarded_default_media_builder; use crate::startup::{PageLoad, StartupPhase, StartupTrace, trace_enabled}; const INITIAL_NAVIGATION_DEADLINE: Duration = Duration::from_secs(5); @@ -262,26 +260,12 @@ impl WkWebViewEngine { .build(event_loop) .map_err(|error| WvError::Window(error.to_string()))?; mark_startup(&self.startup, StartupPhase::WindowCreated); - let builder = wry::WebViewBuilder::new(); - #[cfg(debug_assertions)] - let builder = builder.with_devtools(true); let id = self.next_id; - let builder = with_guarded_media_permissions( - builder, - PermissionsManifest::default(), - webview_media_principal(WebviewId(id)), + let builder = guarded_default_media_builder( + WebviewId(id), + page_load_trace_handler(Arc::clone(&self.startup), app_events), ); - let builder = builder.with_on_page_load_handler(page_load_trace_handler( - Arc::clone(&self.startup), - app_events, - )); - let builder = match &spec.initial { - NavTarget::Html(html) => builder.with_html(html), - NavTarget::Url(url) => builder.with_url(url), - }; - let webview = builder - .build(&window) - .map_err(|error| WvError::Webview(error.to_string()))?; + let webview = builder.build_initial_window(&spec.initial, &window)?; mark_startup(&self.startup, StartupPhase::WebviewAttached); self.next_id += 1; self.views.insert(id, View { webview, window }); diff --git a/crates/keld-wv/tests/fixtures/linux_media_interpose.c b/crates/keld-wv/tests/fixtures/linux_media_interpose.c new file mode 100644 index 00000000..1e6681b3 --- /dev/null +++ b/crates/keld-wv/tests/fixtures/linux_media_interpose.c @@ -0,0 +1,166 @@ +/* + * KEL-132 evidence fixture, not product code. Supported only on Linux's + * dynamic-loader LD_PRELOAD contract (https://man7.org/linux/man-pages/man8/ld.so.8.html) + * with Keld's pinned WebKit2GTK 4.1 ABI; the permission default and API are + * documented at https://webkitgtk.org/reference/webkit2gtk/stable/class.UserMediaPermissionRequest.html. + * CI compiles this against its installed WebKitGTK headers and fails on any + * missing/interposition-incompatible symbol rather than claiming other OSes. + */ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +typedef void (*load_uri_fn)(WebKitWebView *, const gchar *); +typedef void (*permission_fn)(WebKitPermissionRequest *); +typedef gulong (*signal_connect_data_fn)(gpointer, const gchar *, GCallback, + gpointer, GClosureNotify, + GConnectFlags); + +static void *required_symbol(const char *name) { + void *symbol = dlsym(RTLD_NEXT, name); + if (symbol == NULL) { + const char *error = dlerror(); + dprintf(STDERR_FILENO, "KELD_MEDIA_INTERPOSE_FAIL symbol=%s error=%s\n", + name, error == NULL ? "unknown" : error); + _exit(125); + } + return symbol; +} + +static load_uri_fn real_load_uri(void) { + void *symbol = required_symbol("webkit_web_view_load_uri"); + load_uri_fn function = NULL; + memcpy(&function, &symbol, sizeof(function)); + return function; +} + +static permission_fn real_deny(void) { + void *symbol = required_symbol("webkit_permission_request_deny"); + permission_fn function = NULL; + memcpy(&function, &symbol, sizeof(function)); + return function; +} + +static signal_connect_data_fn real_signal_connect_data(void) { + void *symbol = required_symbol("g_signal_connect_data"); + signal_connect_data_fn function = NULL; + memcpy(&function, &symbol, sizeof(function)); + return function; +} + +static const char *required_nonce(void) { + const char *nonce = getenv("KELD_MEDIA_NONCE"); + if (nonce == NULL || nonce[0] == '\0') { + dprintf(STDERR_FILENO, + "KELD_MEDIA_INTERPOSE_FAIL KELD_MEDIA_NONCE is unset\n"); + _exit(125); + } + return nonce; +} + +static const char *current_exe(char path[PATH_MAX]) { + const ssize_t length = readlink("/proc/self/exe", path, PATH_MAX - 1); + if (length < 0 || length >= PATH_MAX - 1) { + dprintf(STDERR_FILENO, + "KELD_MEDIA_INTERPOSE_FAIL cannot resolve /proc/self/exe\n"); + _exit(125); + } + path[length] = '\0'; + return path; +} + +static void trace_line(const char *format, ...) { + const char *path = getenv("KELD_MEDIA_TRACE"); + if (path == NULL || path[0] == '\0') { + dprintf(STDERR_FILENO, + "KELD_MEDIA_INTERPOSE_FAIL KELD_MEDIA_TRACE is unset\n"); + _exit(125); + } + int fd = open(path, O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0600); + if (fd < 0) { + dprintf(STDERR_FILENO, "KELD_MEDIA_INTERPOSE_FAIL cannot open trace\n"); + _exit(125); + } + va_list args; + va_start(args, format); + vdprintf(fd, format, args); + va_end(args); + close(fd); +} + +void webkit_web_view_load_uri(WebKitWebView *web_view, const gchar *uri) { + char exe[PATH_MAX]; + WebKitSettings *settings = webkit_web_view_get_settings(web_view); + webkit_settings_set_enable_media_stream(settings, TRUE); + webkit_settings_set_enable_mock_capture_devices(settings, TRUE); + trace_line("setup nonce=%s mock_capture_devices=true webview=%p exe=%s pid=%ld tid=%ld uri=%s\n", + required_nonce(), (void *)web_view, current_exe(exe), (long)getpid(), + (long)syscall(SYS_gettid), uri); + real_load_uri()(web_view, uri); +} + +gulong g_signal_connect_data(gpointer instance, const gchar *detailed_signal, + GCallback c_handler, gpointer data, + GClosureNotify destroy_data, + GConnectFlags connect_flags) { + const gulong handler_id = real_signal_connect_data()( + instance, detailed_signal, c_handler, data, destroy_data, connect_flags); + if (g_strcmp0(detailed_signal, "permission-request") == 0) { + char exe[PATH_MAX]; + Dl_info caller = {0}; + const void *return_address = __builtin_return_address(0); + const char *caller_name = "unknown"; + if (return_address != NULL && dladdr(return_address, &caller) != 0 && + caller.dli_fname != NULL) { + caller_name = caller.dli_fname; + } + trace_line("registration nonce=%s signal=permission-request handler=%lu webview=%p caller=%s exe=%s pid=%ld tid=%ld\n", + required_nonce(), handler_id, instance, caller_name, + current_exe(exe), (long)getpid(), (long)syscall(SYS_gettid)); + } + return handler_id; +} + +void webkit_permission_request_deny(WebKitPermissionRequest *request) { + char exe[PATH_MAX]; + const char *kind = "other"; + if (WEBKIT_IS_USER_MEDIA_PERMISSION_REQUEST(request)) { + WebKitUserMediaPermissionRequest *media = + WEBKIT_USER_MEDIA_PERMISSION_REQUEST(request); + if (webkit_user_media_permission_is_for_video_device(media)) { + kind = "camera"; + } else if (webkit_user_media_permission_is_for_audio_device(media)) { + kind = "microphone"; + } + } + + Dl_info caller = {0}; + const void *return_address = __builtin_return_address(0); + const char *caller_name = "unknown"; + if (return_address != NULL && dladdr(return_address, &caller) != 0 && + caller.dli_fname != NULL) { + caller_name = caller.dli_fname; + } + + const gboolean force_allow = + g_strcmp0(getenv("KELD_MEDIA_FORCE_ALLOW"), "1") == 0; + trace_line("callback nonce=%s kind=%s action=%s caller=%s exe=%s pid=%ld tid=%ld\n", + required_nonce(), kind, force_allow ? "force_allow" : "deny", + caller_name, current_exe(exe), (long)getpid(), + (long)syscall(SYS_gettid)); + if (force_allow) { + webkit_permission_request_allow(request); + } else { + real_deny()(request); + } +} diff --git a/crates/keld-wv/tests/linux_gui_smoke.sh b/crates/keld-wv/tests/linux_gui_smoke.sh new file mode 100755 index 00000000..60711874 --- /dev/null +++ b/crates/keld-wv/tests/linux_gui_smoke.sh @@ -0,0 +1,250 @@ +#!/usr/bin/env bash +set -euo pipefail + +media_interposer=$1 +media_probe=$2 +keld_host=$3 + +window_manager_pid="" +hello_pid="" +cleanup_probe_pid="" +title_confirmed=0 +pid_bound=0 +close_confirmed=0 +# A bare `trap '... || true' EXIT` clobbers $? with the trap's own +# last command status, so a script that hit `exit 1` would report +# success to the CI step. Capture the real exit code first, run +# cleanup, then re-exit with the captured code explicitly. +process_alive() { + local process_pid=$1 + local process_state + if ! process_state=$(ps -o stat= -p "$process_pid" 2>/dev/null \ + | tr -d '[:space:]'); then + return 1 + fi + case "$process_state" in + ''|Z*) return 1 ;; + *) return 0 ;; + esac +} +terminate_child() { + local child_pid=$1 + [ -n "$child_pid" ] || return + if process_alive "$child_pid"; then + kill "$child_pid" 2>/dev/null || true + for _ in $(seq 1 20); do + process_alive "$child_pid" || break + sleep 0.1 + done + fi + if process_alive "$child_pid"; then + kill -KILL "$child_pid" 2>/dev/null || true + for _ in $(seq 1 20); do + process_alive "$child_pid" || break + sleep 0.1 + done + fi + if ! process_alive "$child_pid"; then + wait "$child_pid" 2>/dev/null || true + fi +} +cleanup() { + ec=$? + set +e + terminate_child "$cleanup_probe_pid" + terminate_child "$hello_pid" + terminate_child "$window_manager_pid" + exit "$ec" +} +trap cleanup EXIT + +sh -c 'kill -STOP $$' & +cleanup_probe_pid=$! +probe_stopped=0 +for _ in $(seq 1 20); do + case "$(ps -o stat= -p "$cleanup_probe_pid" 2>/dev/null)" in + T*) probe_stopped=1; break ;; + esac + sleep 0.05 +done +if [ "$probe_stopped" -ne 1 ]; then + echo "::error::cleanup negative-control child never stopped" + exit 1 +fi +terminate_child "$cleanup_probe_pid" +if process_alive "$cleanup_probe_pid"; then + echo "::error::cleanup did not reap a stopped child within its bound" + exit 1 +fi +cleanup_probe_pid="" + +if ! xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then + echo "::error::xvfb-run display is unreachable: $DISPLAY" + exit 1 +fi + +fluxbox -display "$DISPLAY" >"$RUNNER_TEMP/fluxbox.log" 2>&1 & +window_manager_pid=$! +window_manager_ready=0 +for _ in $(seq 1 50); do + if ! kill -0 "$window_manager_pid" 2>/dev/null; then + echo "::error::Fluxbox exited before owning the X11 display" + cat "$RUNNER_TEMP/fluxbox.log" + break + fi + if root_check=$(xprop -root _NET_SUPPORTING_WM_CHECK 2>/dev/null); then + supporting_window=$(printf '%s\n' "$root_check" | awk '/window id/ { print $NF }') + if [ -n "$supporting_window" ] && \ + child_check=$(xprop -id "$supporting_window" _NET_SUPPORTING_WM_CHECK 2>/dev/null) && \ + wm_name=$(xprop -id "$supporting_window" _NET_WM_NAME 2>/dev/null); then + child_window=$(printf '%s\n' "$child_check" | awk '/window id/ { print $NF }') + if [ "$child_window" = "$supporting_window" ] && \ + printf '%s\n' "$wm_name" | grep -q '= "Fluxbox"$'; then + window_manager_ready=1 + break + fi + fi + fi + sleep 0.2 +done +if [ "$window_manager_ready" -ne 1 ]; then + echo "::error::Fluxbox never advertised its EWMH control window" + exit 1 +fi + +crates/keld-wv/tests/linux_media_guard.sh "$media_interposer" "$media_probe" + +"$keld_host" --hello --title CI-Linux-Smoke & +hello_pid=$! + +window_id="" +for _ in $(seq 1 60); do + if ! kill -0 "$hello_pid" 2>/dev/null; then + echo "::error::keld-host --hello exited before a window was found" + break + fi + if matches=$(xdotool search --all --onlyvisible --pid "$hello_pid" \ + --name '^CI-Linux-Smoke$' 2>/dev/null); then + if [ "$(printf '%s\n' "$matches" | wc -l)" -ne 1 ]; then + echo "::error::expected one exact Linux smoke window, got: $matches" + exit 1 + fi + window_id=$matches + break + else + search_status=$? + if [ "$search_status" -ne 1 ]; then + echo "::error::xdotool window search failed with $search_status" + exit 1 + fi + fi + sleep 0.5 +done + +if [ -z "$window_id" ]; then + echo "::error::keld-host --hello never produced a titled window under Xvfb" + exit 1 +fi +if [ "$(xdotool getwindowname "$window_id")" != "CI-Linux-Smoke" ]; then + echo "::error::Linux smoke window title is not exact" + exit 1 +fi +window_pid=$(xdotool getwindowpid "$window_id") +if [ "$window_pid" != "$hello_pid" ]; then + echo "::error::Linux smoke window belongs to PID $window_pid, expected $hello_pid" + exit 1 +fi +title_confirmed=1 +pid_bound=1 + +xdotool windowsize "$window_id" 800 600 +resized=0 +width=unknown +height=unknown +for _ in $(seq 1 50); do + geometry=$(xdotool getwindowgeometry --shell "$window_id") + width=$(printf '%s\n' "$geometry" | awk -F= '$1 == "WIDTH" { print $2 }') + height=$(printf '%s\n' "$geometry" | awk -F= '$1 == "HEIGHT" { print $2 }') + if [ "$width" = 800 ] && [ "$height" = 600 ]; then + resized=1 + break + fi + sleep 0.1 +done +if [ "$resized" -ne 1 ]; then + echo "::error::resize requested 800x600, observed ${width}x${height}" + exit 1 +fi + +xdotool windowminimize "$window_id" +minimized=0 +for _ in $(seq 1 50); do + if ! window_state=$(xprop -id "$window_id" WM_STATE 2>&1); then + echo "::error::cannot read minimized WM_STATE: $window_state" + exit 1 + fi + if printf '%s\n' "$window_state" | grep -q 'window state: Iconic'; then + minimized=1 + break + fi + sleep 0.1 +done +if [ "$minimized" -ne 1 ]; then + echo "::error::Linux smoke window did not become minimized" + exit 1 +fi + +xdotool windowactivate "$window_id" +restored=0 +for _ in $(seq 1 50); do + if ! window_state=$(xprop -id "$window_id" WM_STATE 2>&1); then + echo "::error::cannot read restored WM_STATE: $window_state" + exit 1 + fi + if printf '%s\n' "$window_state" | grep -q 'window state: Normal'; then + restored=1 + break + fi + sleep 0.1 +done +if [ "$restored" -ne 1 ]; then + echo "::error::Linux smoke window did not restore" + exit 1 +fi + +if ! process_alive "$hello_pid"; then + echo "::error::keld-host stopped before the close request" + exit 1 +fi +window_hex=$(printf '0x%x' "$window_id") +wmctrl -ic "$window_hex" +exited=0 +for _ in $(seq 1 100); do + if ! process_alive "$hello_pid"; then + exited=1 + break + fi + sleep 0.1 +done +if [ "$exited" -ne 1 ]; then + echo "::error::keld-host did not exit after the window close request" + exit 1 +fi +set +e +wait "$hello_pid" +hello_status=$? +set -e +hello_pid="" +if [ "$hello_status" -ne 0 ]; then + echo "::error::keld-host exited $hello_status after window close" + exit 1 +fi +close_confirmed=1 +if [ "$window_manager_ready" -ne 1 ] || [ "$title_confirmed" -ne 1 ] || \ + [ "$pid_bound" -ne 1 ] || [ "$resized" -ne 1 ] || \ + [ "$minimized" -ne 1 ] || [ "$restored" -ne 1 ] || \ + [ "$close_confirmed" -ne 1 ]; then + echo "::error::Linux window-control receipt is incomplete" + exit 1 +fi +echo "Linux hello title, resize, minimize, restore, close, and reap confirmed under X11" diff --git a/crates/keld-wv/tests/linux_media_guard.sh b/crates/keld-wv/tests/linux_media_guard.sh new file mode 100755 index 00000000..91a9e615 --- /dev/null +++ b/crates/keld-wv/tests/linux_media_guard.sh @@ -0,0 +1,381 @@ +#!/usr/bin/env bash +set -euo pipefail + +interposer=${1:?usage: linux_media_guard.sh [probe-binary]} +probe_binary=${2:-target/debug/examples/linux_media_guard} +probe_root=$(mktemp -d "${RUNNER_TEMP:-/tmp}/keld-media-guard.XXXXXX") +active_runner_pid="" +active_release_file="" +active_synthetic_pid="" +active_monitor_pid="" + +cleanup() { + local status=$? + set +e + if [ -n "$active_release_file" ]; then + : >"$active_release_file" + fi + if [ -n "$active_runner_pid" ] && kill -0 "$active_runner_pid" 2>/dev/null; then + kill "$active_runner_pid" 2>/dev/null || true + wait "$active_runner_pid" 2>/dev/null || true + fi + if [ -n "$active_synthetic_pid" ] && kill -0 "$active_synthetic_pid" 2>/dev/null; then + kill "$active_synthetic_pid" 2>/dev/null || true + wait "$active_synthetic_pid" 2>/dev/null || true + fi + if [ -n "$active_monitor_pid" ] && kill -0 "$active_monitor_pid" 2>/dev/null; then + kill "$active_monitor_pid" 2>/dev/null || true + wait "$active_monitor_pid" 2>/dev/null || true + fi + xprop -root -remove KELD_MEDIA_MONITOR >/dev/null 2>&1 || true + xprop -root -remove KELD_MEDIA_MONITOR_FENCE >/dev/null 2>&1 || true + rm -r -- "$probe_root" + exit "$status" +} +trap cleanup EXIT + +client_windows() { + xprop -root _NET_CLIENT_LIST 2>/dev/null \ + | sed -n 's/^.*# //p' \ + | tr ',' '\n' \ + | tr -d ' \t' \ + | sed -n '/^0x[0-9a-fA-F][0-9a-fA-F]*$/p' \ + | sort -u +} + +run_probe() { + local kind=$1 + local expected=$2 + local callback=$3 + local case_name=${4:-${kind}-${expected}} + local nonce="${case_name}-${BASHPID}-${RANDOM}" + local trace_file="$probe_root/${case_name}.trace" + local output_file="$probe_root/${case_name}.out" + local ready_file="$probe_root/${case_name}.ready" + local release_file="$probe_root/${case_name}.release" + local page_ready_file="$probe_root/${case_name}.page-ready" + local request_release_file="$probe_root/${case_name}.request-release" + local identity_file="$probe_root/${case_name}.identity" + local event_file="$probe_root/${case_name}.xevents" + local -a baseline_clients=() + local policy_trace_file=$trace_file + if [ "${KELD_MEDIA_DROP_POLICY_RECEIPT:-0}" = 1 ]; then + policy_trace_file="$probe_root/${case_name}.discarded-policy" + fi + local -a environment=( + "LD_PRELOAD=$interposer" + "KELD_MEDIA_TRACE=$trace_file" + "KELD_MEDIA_POLICY_TRACE=$policy_trace_file" + "KELD_MEDIA_NONCE=$nonce" + "KELD_MEDIA_READY=$ready_file" + "KELD_MEDIA_RELEASE=$release_file" + "KELD_MEDIA_PAGE_READY=$page_ready_file" + "KELD_MEDIA_REQUEST_RELEASE=$request_release_file" + "KELD_MEDIA_IDENTITY_RECEIPT=$identity_file" + ) + if [ "$expected" = allowed ]; then + environment+=("KELD_MEDIA_FORCE_ALLOW=1") + fi + + timeout --signal=TERM --kill-after=5s 30s \ + env "${environment[@]}" "$probe_binary" "$kind" "$expected" "$nonce" \ + >"$output_file" 2>&1 & + local runner_pid=$! + active_runner_pid=$runner_pid + active_release_file=$release_file + local deadline=$((SECONDS + 30)) + while [ ! -f "$page_ready_file" ]; do + if ! kill -0 "$runner_pid" 2>/dev/null; then + wait "$runner_pid" || true + sed -n '1,120p' "$output_file" >&2 + echo "media probe exited before pre-request readiness" >&2 + exit 1 + fi + if [ "$SECONDS" -ge "$deadline" ]; then + kill "$runner_pid" 2>/dev/null || true + wait "$runner_pid" || true + echo "media page did not reach pre-request readiness" >&2 + exit 1 + fi + done + + xprop -root -remove KELD_MEDIA_MONITOR >/dev/null 2>&1 || true + xprop -root -remove KELD_MEDIA_MONITOR_FENCE >/dev/null 2>&1 || true + stdbuf -oL xev -1 -root -event substructure -event property \ + >"$event_file" 2>&1 & + local monitor_pid=$! + active_monitor_pid=$monitor_pid + local monitor_deadline=$((SECONDS + 10)) + while ! grep -q 'KELD_MEDIA_MONITOR' "$event_file"; do + if ! kill -0 "$monitor_pid" 2>/dev/null; then + echo "X event monitor exited before its readiness round-trip" >&2 + exit 1 + fi + if [ "$SECONDS" -ge "$monitor_deadline" ]; then + echo "X event monitor missed its readiness round-trip" >&2 + exit 1 + fi + xprop -root -f KELD_MEDIA_MONITOR 8s -set KELD_MEDIA_MONITOR \ + "${nonce}-${RANDOM}" >/dev/null + done + mapfile -t baseline_clients < <(client_windows) + local event_barrier_line + event_barrier_line=$(wc -l <"$event_file") + if [ "${KELD_MEDIA_KILL_MONITOR:-0}" = 1 ]; then + kill "$monitor_pid" + set +e + wait "$monitor_pid" + local killed_monitor_status=$? + set -e + if [ "$killed_monitor_status" -ne 143 ]; then + echo "monitor-kill negative control exited $killed_monitor_status, expected SIGTERM status 143" >&2 + exit 1 + fi + active_monitor_pid="" + fi + + if [ "${KELD_MEDIA_SYNTHETIC_PROMPT:-0}" = 1 ]; then + xmessage -title "Camera Permission" -buttons Allow,Deny "Allow camera access?" \ + >"$probe_root/${case_name}.prompt.log" 2>&1 & + active_synthetic_pid=$! + local prompt_deadline=$((SECONDS + 10)) + local prompt_ready=0 + while [ "$SECONDS" -lt "$prompt_deadline" ]; do + local prompt_window + while IFS= read -r prompt_window; do + if xprop -id "$prompt_window" _NET_WM_NAME WM_NAME 2>/dev/null \ + | grep -Fq "Camera Permission"; then + prompt_ready=1 + break + fi + done < <(client_windows) + [ "$prompt_ready" -eq 0 ] || break + done + if [ "$prompt_ready" -ne 1 ]; then + echo "synthetic external prompt did not become a managed top-level client" >&2 + exit 1 + fi + kill "$active_synthetic_pid" 2>/dev/null || true + wait "$active_synthetic_pid" 2>/dev/null || true + active_synthetic_pid="" + fi + : >"$request_release_file" + + while [ ! -f "$ready_file" ]; do + if ! kill -0 "$runner_pid" 2>/dev/null; then + wait "$runner_pid" || true + sed -n '1,120p' "$output_file" >&2 + echo "media probe exited before window-census readiness" >&2 + exit 1 + fi + if [ "$SECONDS" -ge "$deadline" ]; then + kill "$runner_pid" 2>/dev/null || true + wait "$runner_pid" || true + echo "media probe did not reach window-census readiness" >&2 + exit 1 + fi + done + + local expected_exe + expected_exe=$(readlink -f -- "$probe_binary") + local expected_exe_ere + expected_exe_ere=$(printf '%s' "$expected_exe" | sed 's/[][\\.^$*+?(){}|]/\\&/g') + local expected_caller_ere + expected_caller_ere=$(basename -- "$expected_exe" | sed 's/[][\\.^$*+?(){}|]/\\&/g') + local setup_pattern="^setup nonce=${nonce} mock_capture_devices=true webview=0x[0-9a-f]+ exe=${expected_exe_ere} pid=[0-9]+ tid=[0-9]+ uri=http://127\\.0\\.0\\.1:[0-9]+/${nonce}/$" + if [ "$(grep -Ec "$setup_pattern" "$trace_file")" -ne 1 ]; then + echo "expected one mock-capture setup record for $kind/$expected" >&2 + exit 1 + fi + local setup_line + setup_line=$(grep -E "$setup_pattern" "$trace_file") + local setup_pid + local setup_tid + setup_pid=$(printf '%s\n' "$setup_line" | sed -E 's/.* pid=([0-9]+) tid=.*/\1/') + setup_tid=$(printf '%s\n' "$setup_line" | sed -E 's/.* tid=([0-9]+) uri=.*/\1/') + if [ "$setup_pid" != "$setup_tid" ]; then + echo "mock setup left the process main thread: pid=$setup_pid tid=$setup_tid" >&2 + exit 1 + fi + local setup_webview + setup_webview=$(printf '%s\n' "$setup_line" | sed -E 's/.* webview=(0x[0-9a-f]+) exe=.*/\1/') + + local registration_line + local registration_pattern="^registration nonce=${nonce} signal=permission-request handler=[1-9][0-9]* webview=${setup_webview} caller=.*${expected_caller_ere} exe=${expected_exe_ere} pid=[0-9]+ tid=[0-9]+$" + if [ "$(grep -Ec "$registration_pattern" "$trace_file")" -ne 1 ]; then + echo "expected one successful permission-request registration for $kind/$expected" >&2 + exit 1 + fi + registration_line=$(grep -E "$registration_pattern" "$trace_file") + local registration_pid + local registration_tid + registration_pid=$(printf '%s\n' "$registration_line" | sed -E 's/.* pid=([0-9]+) tid=.*/\1/') + registration_tid=$(printf '%s\n' "$registration_line" | sed -E 's/.* tid=([0-9]+).*/\1/') + if [ "$registration_pid" != "$setup_pid" ] || [ "$registration_tid" != "$setup_pid" ]; then + echo "permission handler was not registered on the setup process main thread: setup=$setup_pid registration=$registration_pid tid=$registration_tid" >&2 + exit 1 + fi + + local callback_line + local callback_pattern="^callback nonce=${nonce} kind=${kind} action=${callback} caller=.*${expected_caller_ere} exe=${expected_exe_ere} pid=[0-9]+ tid=[0-9]+$" + if [ "$(grep -Ec "$callback_pattern" "$trace_file")" -ne 1 ]; then + echo "expected one $callback callback record for $kind/$expected" >&2 + exit 1 + fi + callback_line=$(grep -E "$callback_pattern" "$trace_file") + local callback_pid + local callback_tid + callback_pid=$(printf '%s\n' "$callback_line" | sed -E 's/.* pid=([0-9]+) tid=.*/\1/') + callback_tid=$(printf '%s\n' "$callback_line" | sed -E 's/.* tid=([0-9]+).*/\1/') + if [ "$callback_pid" != "$callback_tid" ] || [ "$callback_pid" != "$setup_pid" ]; then + echo "media callback is not the setup process main thread: setup=$setup_pid callback=$callback_pid tid=$callback_tid" >&2 + exit 1 + fi + + local media_id + media_id=$(tr -d '[:space:]' <"$identity_file") + if ! [[ "$media_id" =~ ^[0-9]+$ ]] || [ "$media_id" -le 1 ]; then + echo "media request was not bound to the independently returned non-first webview id: $media_id" >&2 + exit 1 + fi + local capability="web.${kind}" + local policy_pattern="^policy nonce=${nonce} capability=${capability} principal=webview:${media_id}:0 manifest_fnv1a64=e117311975d9f419 decision=KELD-GUARD006 response=deny pid=${setup_pid}$" + + local -a current_clients=() + mapfile -t current_clients < <(client_windows) + local -a new_clients=() + local candidate + local baseline + for candidate in "${current_clients[@]}"; do + local existed=0 + for baseline in "${baseline_clients[@]}"; do + if [ "$candidate" = "$baseline" ]; then + existed=1 + break + fi + done + if [ "$existed" -eq 0 ]; then + new_clients+=("$candidate") + fi + done + local visible_window_count=${#new_clients[@]} + local unexpected_windows="" + for candidate in "${new_clients[@]}"; do + local window_properties + window_properties=$(xprop -id "$candidate" _NET_WM_NAME WM_NAME 2>/dev/null || true) + unexpected_windows+=$'\n' + unexpected_windows+="${candidate}: ${window_properties//$'\n'/; }" + done + local monitor_error="" + if ! kill -0 "$monitor_pid" 2>/dev/null; then + monitor_error="X event monitor died before the post-result fence" + else + local fence_deadline=$((SECONDS + 10)) + while ! tail -n "+$((event_barrier_line + 1))" "$event_file" \ + | grep -q 'KELD_MEDIA_MONITOR_FENCE'; do + if ! kill -0 "$monitor_pid" 2>/dev/null; then + monitor_error="X event monitor died before acknowledging the final fence" + break + fi + if [ "$SECONDS" -ge "$fence_deadline" ]; then + monitor_error="X event monitor missed the final event-drain fence" + break + fi + xprop -root -f KELD_MEDIA_MONITOR_FENCE 8s \ + -set KELD_MEDIA_MONITOR_FENCE "${nonce}-${RANDOM}" >/dev/null + done + fi + if [ -z "$monitor_error" ]; then + kill "$monitor_pid" + set +e + wait "$monitor_pid" + local monitor_status=$? + set -e + if [ "$monitor_status" -ne 143 ]; then + monitor_error="X event monitor exited $monitor_status instead of SIGTERM status 143" + fi + fi + active_monitor_pid="" + xprop -root -remove KELD_MEDIA_MONITOR >/dev/null 2>&1 || true + xprop -root -remove KELD_MEDIA_MONITOR_FENCE >/dev/null 2>&1 || true + local map_event_count + map_event_count=$(tail -n "+$((event_barrier_line + 1))" "$event_file" \ + | grep -c '^MapNotify event' || true) + : >"$release_file" + if ! wait "$runner_pid"; then + sed -n '1,120p' "$output_file" >&2 + echo "media probe failed after window census" >&2 + exit 1 + fi + active_runner_pid="" + active_release_file="" + if [ -n "$active_synthetic_pid" ]; then + kill "$active_synthetic_pid" 2>/dev/null || true + wait "$active_synthetic_pid" 2>/dev/null || true + active_synthetic_pid="" + fi + if [ -n "$monitor_error" ]; then + echo "$monitor_error" >&2 + exit 1 + fi + if [ "$visible_window_count" -ne 0 ]; then + echo "permission request added unexpected top-level clients for $kind/$expected: total=$visible_window_count details=${unexpected_windows:-none}" >&2 + exit 1 + fi + if [ "$map_event_count" -ne 0 ]; then + echo "permission interval mapped $map_event_count transient top-level window(s) for $kind/$expected" >&2 + exit 1 + fi + if [ "$(grep -Ec "$policy_pattern" "$trace_file")" -ne 1 ]; then + echo "expected one keld-guard policy receipt for $kind/$expected" >&2 + exit 1 + fi + + if [ "$expected" = denied ]; then + grep -Eq "^KELD_MEDIA_RESULT nonce=${nonce} kind=${kind} secure_context=true outcome=(NotAllowedError|SecurityError)$" "$output_file" + if grep -q 'action=force_allow' "$trace_file"; then + echo "deny run unexpectedly reached the force-allow control" >&2 + exit 1 + fi + else + grep -q "^KELD_MEDIA_RESULT nonce=${nonce} kind=${kind} secure_context=true outcome=resolved$" "$output_file" + fi + + printf 'media_guard kind=%s expected=%s callback=%s pid=%s tid=%s\n' \ + "$kind" "$expected" "$callback" "$callback_pid" "$callback_tid" +} + +run_probe camera denied deny +run_probe microphone denied deny +run_probe camera allowed force_allow +run_probe microphone allowed force_allow + +if (KELD_MEDIA_SYNTHETIC_PROMPT=1 run_probe camera denied deny synthetic-prompt); then + echo "synthetic media prompt unexpectedly passed the no-prompt census" >&2 + exit 1 +fi +echo "media_guard negative_control=synthetic_prompt rejected" + +if (KELD_MEDIA_DROP_POLICY_RECEIPT=1 run_probe camera denied deny missing-policy); then + echo "missing-policy cleanup negative control unexpectedly passed" >&2 + exit 1 +fi +missing_policy_pid=$(sed -n -E 's/^setup .* pid=([0-9]+) tid=.*/\1/p' \ + "$probe_root/missing-policy.trace") +if [ -z "$missing_policy_pid" ] || kill -0 "$missing_policy_pid" 2>/dev/null; then + echo "missing-policy failure left the probe process alive" >&2 + exit 1 +fi +echo "media_guard negative_control=missing_policy rejected_and_reaped" + +if (KELD_MEDIA_KILL_MONITOR=1 run_probe camera denied deny killed-monitor); then + echo "killed-monitor negative control unexpectedly passed" >&2 + exit 1 +fi +killed_monitor_pid=$(sed -n -E 's/^setup .* pid=([0-9]+) tid=.*/\1/p' \ + "$probe_root/killed-monitor.trace") +if [ -z "$killed_monitor_pid" ] || kill -0 "$killed_monitor_pid" 2>/dev/null; then + echo "killed-monitor failure left the probe process alive" >&2 + exit 1 +fi +echo "media_guard negative_control=killed_monitor rejected_and_reaped" diff --git a/docs/architecture/03-security.md b/docs/architecture/03-security.md index 2d433ff8..4c1604f0 100644 --- a/docs/architecture/03-security.md +++ b/docs/architecture/03-security.md @@ -141,14 +141,18 @@ manifest decoder. fetch-isolated per principal, remote-content windows get `channels: []` unless granted, navigation policy hooks (allow-list), devtools off in release unless `web.devtools: true`. - **v0:** camera and microphone requests are default-deny on all three live - backends: macOS and Linux (KEL-28) both install wry `with_permission_handler` - (`with_guarded_media_permissions`, shared helper); Windows registers the - `WebView2` `add_PermissionRequested` handler before the first navigation - (KEL-65 direct COM — the ordering is compile-enforced). All three evaluate + **v0:** new camera and microphone requests are explicitly default-denied by + Keld on Linux, Windows, and macOS 12+: macOS/Linux install wry + `with_permission_handler`; Windows registers WebView2 + `add_PermissionRequested` before first navigation. The callbacks evaluate `web.camera` / `web.microphone` as the requesting `Principal::Webview` - (host-minted `WebviewId`, generation `0` until navigation rotation lands) - with requested resource `*` (no platform callback passes an origin). + (host-minted `WebviewId`, generation `0`). Wry exposes no origin; WebView2 + exposes `Uri`, but Keld's v0 adapter currently reads only `PermissionKind` + and deliberately evaluates resource `*` without origin filtering. Pinned + wry does not consume the macOS callback below 12 on debug hosts, and saved + browser permission preferences can bypass + new-request callbacks; oldest-macOS proof and KEL-135 profile-backed + restart/revocation evidence remain open. Missing identity and `AppProcess` fail closed (`KELD-GUARD007`) so `/app` media grants cannot apply to a remote or other webview. A minted webview principal is still `KELD-GUARD006` until window-level grants exist. CSP diff --git a/docs/architecture/05-webview-and-native.md b/docs/architecture/05-webview-and-native.md index ca84a202..3455a6cd 100644 --- a/docs/architecture/05-webview-and-native.md +++ b/docs/architecture/05-webview-and-native.md @@ -70,6 +70,23 @@ surfacing that result, and the fuller version-matrix probe in `docs/research/library/host-platforms/06-webview-reality.md` describes (today's probe is driver + session type only). +Media permission installation is explicit for new requests on every live +backend. macOS 12+ wry auto-grants when its handler is absent; pinned wry does +not expose that delegate callback on older debug hosts, whose support boundary +and real acceptance remain open. WebKitGTK 2.52.6 and wry 0.56.1 deny an +unhandled Linux request by default, but that fallback is not evidence that +Keld evaluated the correct webview principal and manifest. The interim wry +backends therefore keep the raw builder inside a guard-installed witness +through initial content/build. KEL-132's Linux proof binds one run nonce, +process, executable, default manifest, minted principal, `keld-guard` decision, +secure-localhost mock request, explicit WebKitGTK deny API, and top-level-window +census. The successful GLib signal registration, callback decision, and deny +API are observed on the process main thread. JavaScript denial alone is not +sufficient provenance. Wry documents +that saved browser permission preferences bypass its callback; KEL-135's +ephemeral-dev and identity-bound persistent-store lifecycle is a prerequisite +for the corresponding restart/revocation proof. + ## 2. Renderer bridge contract (`window.keld`) **Destination; not implemented in the live hello backends.** The bridge is injected diff --git a/docs/engineering/decisions.md b/docs/engineering/decisions.md index bfc085a0..1ae68f26 100644 --- a/docs/engineering/decisions.md +++ b/docs/engineering/decisions.md @@ -156,6 +156,21 @@ and had no `with_permission_handler`. 0.56.1 adds the handler; Keld installs it and default-denies via `keld-guard` (`web.camera` / `web.microphone`). tao stays 0.35.3: wry 0.56 `build` takes `raw_window_handle::HasWindowHandle`. +**Update (2026-09-04, KEL-132).** The former source-string installation test +passed even when the production wry installer returned its input unchanged. +Both interim wry backends now keep the raw builder inside a guard-installed +witness through initial content/build, and a fake invokes the exact installed +callback. +Linux's unhandled WebKitGTK/wry default is deny rather than a platform prompt; +the real Linux proof therefore records the default manifest, minted principal, +`keld-guard` decision, explicit deny API call, request/process nonce, and +window census with mock camera/microphone devices. A JavaScript +`NotAllowedError` alone is not Keld-policy provenance. Saved permission +preferences bypass wry's new-request callback; KEL-135 owns their profile and +restart/revocation lifecycle. Pinned wry also cfg-removes the delegate callback +below macOS 12 on debug hosts, so oldest-supported-macOS acceptance remains +open rather than inferred from Linux. + **Destination (architecture 05 §1).** `keld-wv` is Keld's own `WebEngine` layer over WKWebView (**objc2**), WebView2 (**windows-rs** + WebView2 COM), and WebKitGTK (**webkit6/gtk4**). wry/tao stay as reference implementations and a quirks catalog. @@ -207,9 +222,9 @@ a shell variable, while preserving the documented DMA-BUF crash/flicker mitigation on `WebKitGTK` ≤ 2.54 (tauri-apps/tauri#9394, #14924). The pure detector lets `keld doctor` read the state later without side effects. Media-permission guard (KEL-59) reuses the existing wry helpers unchanged (`media.rs` widened -from macOS-only to `any(macos, linux)`), since Linux's default without a -handler is also "show the platform's own prompt," same category as the old -Windows default. +from macOS-only to `any(macos, linux)`). Later WebKitGTK 2.52.6/wry 0.56.1 +inspection corrected the original prompt claim: an unhandled Linux request is +denied, but that fallback supplies no Keld principal/manifest provenance. Verification: compiled, clippy-clean, and 225 tests green (including live Bun↔Rust kipc integration) on real Ubuntu 26.04 (GTK3/WebKit2GTK 4.1 dev libs diff --git a/docs/onboarding/02-architecture-guide.md b/docs/onboarding/02-architecture-guide.md index 05bb2ed2..15e5963d 100644 --- a/docs/onboarding/02-architecture-guide.md +++ b/docs/onboarding/02-architecture-guide.md @@ -309,9 +309,11 @@ The honest reading of that diagram: caller: `keld_native::fs::{fs_read, fs_write}` (KEL-71) — a real kipc channel (`serve_fs_session`), guard-checked before any OS call, with real temp-file oracles proving allow/deny/`..`/non-`AppProcess` cases. Every other `keld-native` module is - still a name only. MCP `keld_permissions_explain` and the webview media-capture - handlers (all three OS backends) call `keld-guard::evaluate` directly, independent of - `dispatch_privileged`. + still a name only. MCP `keld_permissions_explain` and each applicable + new-request webview media callback call `keld-guard::evaluate` directly, + independent of `dispatch_privileged`. Pinned wry does not consume that + callback below macOS 12 on debug hosts, and saved browser permission + preferences remain a KEL-135 profile-lifecycle boundary. - **`keld-runtime` now supervises the Bun spawn (KEL-70).** `keld-cli/src/dev.rs` `run_dev_echo` spawns through `keld_runtime::Supervisor`, which restarts a crashed (non-zero exit) child with exponential backoff up to `RestartPolicy`'s defaults diff --git a/docs/onboarding/03-api-and-cli-surface.md b/docs/onboarding/03-api-and-cli-surface.md index 238e316d..be2b60bd 100644 --- a/docs/onboarding/03-api-and-cli-surface.md +++ b/docs/onboarding/03-api-and-cli-surface.md @@ -486,8 +486,10 @@ Supporting types: `WEB_MEDIA_ORIGIN`, `media_permission_allowed(manifest, principal, kind)` — default-deny camera/mic policy (KEL-59, KEL-73). Evaluates as the requesting `Principal::Webview` when the host has minted that id. Missing identity and `AppProcess` are - `KELD-GUARD007`. v0 requested resource is `*` because neither platform callback - passes an origin (wry's handler on macOS, `PermissionRequested` args on Windows). + `KELD-GUARD007`. Wry's macOS/Linux callback exposes no origin. Windows + `PermissionRequested` exposes `Uri`, but the v0 adapter reads only + `PermissionKind` and discards the URI, so all three evaluate resource `*` + without origin filtering. - `WebviewId(pub u32)`, `EnginePolicy::{ System (default), Pinned }` (declared in [`lib.rs`](../../crates/keld-wv/src/lib.rs); nothing reads `EnginePolicy` yet) - `WvError` — nine variants, codes `KELD-WV-001..008` and `KELD-WV-010` @@ -501,9 +503,9 @@ Backends: | Module | Platform | State | |---|---|---| -| `wkwebview` (`#[cfg(target_os = "macos")]`) | macOS | **Live.** `WkWebViewEngine::new()` / `run_until_closed()` / `run_hello(title, html)`. Built on tao 0.35 + wry 0.56 as interim scaffolding, to be replaced by direct objc2 bindings. Camera/mic go through `with_permission_handler` → `keld-guard` (`web.camera` / `web.microphone`, default-deny). | +| `wkwebview` (`#[cfg(target_os = "macos")]`) | macOS | **Live.** `WkWebViewEngine::new()` / `run_until_closed()` / `run_hello(title, html)`. Built on tao 0.35 + wry 0.56 as interim scaffolding, to be replaced by direct objc2 bindings. On macOS 12+, new camera/mic requests go through `with_permission_handler` → `keld-guard` (`web.camera` / `web.microphone`, default-deny). Pinned wry cfg-removes that delegate callback below 12 on debug hosts, so the oldest-supported-OS boundary and real proof remain open. | | [`webview2`](../../crates/keld-wv/src/webview2/mod.rs) | Windows | **Live (KEL-27, direct COM since KEL-65).** `WebView2Engine::new()` / `run_until_closed()` / `run_hello`; drives `webview2-com` directly (environment, controller, navigation) with tao for window + event loop — wry is not linked on Windows. Runtime probe fails closed as `KELD-WV-008`. Camera/mic go through `add_PermissionRequested` → `keld-guard`, registered before the first navigation (compile-enforced). | -| [`webkitgtk`](../../crates/keld-wv/src/webkitgtk/mod.rs) | Linux | **Live (KEL-28), wry interim** — GTK3 + `libwebkit2gtk-4.1-dev`, same "wry now, direct webkit6/gtk4 later" policy as macOS/Windows started with; `build_gtk` (not plain `build`) so Wayland works, not just X11. Process entry calls `prepare_gpu_safe_mode_process()` to exact-self re-exec with NVIDIA+Wayland safe-mode before any GTK/WebKit call; fallible `WebKitGtkEngine::new()` rejects an unprepared risky stack as `KELD-WV-010`. Pure `detect_gpu_safe_mode()` distinguishes normal, risky/unprepared, and risky/prepared without side effects for `keld doctor`; `is_degraded()` is true only for the prepared state. Compiled/tested on real Ubuntu; `Xvfb` + `xdotool` confirms the X11 backend, and KEL-96 adds native GNOME Wayland rendered-navigation/no-flag evidence. A real X11 product run remains open. Camera/mic go through the shared wry `with_guarded_media_permissions` → `keld-guard`. | +| [`webkitgtk`](../../crates/keld-wv/src/webkitgtk/mod.rs) | Linux | **Live (KEL-28), wry interim** — GTK3 + `libwebkit2gtk-4.1-dev`, same "wry now, direct webkit6/gtk4 later" policy as macOS/Windows started with; `build_gtk` (not plain `build`) so Wayland works, not just X11. Process entry calls `prepare_gpu_safe_mode_process()` to exact-self re-exec with NVIDIA+Wayland safe-mode before any GTK/WebKit call; fallible `WebKitGtkEngine::new()` rejects an unprepared risky stack as `KELD-WV-010`. Pure `detect_gpu_safe_mode()` distinguishes normal, risky/unprepared, and risky/prepared without side effects for `keld doctor`; `is_degraded()` is true only for the prepared state. Compiled/tested on real Ubuntu; `Xvfb` + `xdotool` confirms the X11 backend, and KEL-96 adds native GNOME Wayland rendered-navigation/no-flag evidence. A real X11 product run remains open. New camera/mic requests go through a guard-installed wry builder → `keld-guard`; Linux's unhandled default deny is not accepted as policy provenance, so the KEL-132 real probe binds callback/API denial to the manifest, principal, process and no-prompt census. Saved-preference restart/revocation remains KEL-135-owned. | Hello-window entry points, re-exported at crate root: `HELLO_HTML` (the dark-background "Hello from Keld" document — engine-neutral on purpose, one const backs both live @@ -574,7 +576,7 @@ the behavior that exists today so target contracts are not mistaken for shipped | Crate | Everything it exposes | |---|---| -| `keld_guard` | `Principal::{AppProcess, Webview{id,generation}, Plugin{id}}`, `Decision::{Allow, Deny(DenyReason)}`, `DenyReason::{NotGranted, OutOfScope, ChannelForbidden, NotAppProcess, MediaPrincipalRequired}`, `parse_manifest` / `load_manifest` / `evaluate`, plus `verified_manifest::{VerifiedManifest, load_verified_manifest}` for the shipping no-flag startup snapshot. MCP `keld_permissions_explain`, all three webview media-capture handlers, and `keld_ipc::guard_dispatch::dispatch_privileged` (KEL-69) call the evaluator; reachable privileged host routing remains KEL-102/T3. | +| `keld_guard` | `Principal::{AppProcess, Webview{id,generation}, Plugin{id}}`, `Decision::{Allow, Deny(DenyReason)}`, `DenyReason::{NotGranted, OutOfScope, ChannelForbidden, NotAppProcess, MediaPrincipalRequired}`, `parse_manifest` / `load_manifest` / `evaluate`, plus `verified_manifest::{VerifiedManifest, load_verified_manifest}` for the shipping no-flag startup snapshot. MCP `keld_permissions_explain`, each applicable new-request webview media callback, and `keld_ipc::guard_dispatch::dispatch_privileged` (KEL-69) call the evaluator; saved-preference profile lifecycle remains KEL-135-owned and reachable privileged host routing remains KEL-102/T3. | | `keld_runtime` | `primary::{PrimaryRoleSupervisor, PrimaryRoleConfig, BoundPrimaryGeneration, PrimaryRecoveryGate}` over the one shared generation/restart owner. The gated start surface pauses the first crash successor until the host arms recovery after initial Ready; dropping/denying the gate prevents provisioning. | | `keld_native` | `MODULES: &[&str]` — the 15 planned module names (`window`, `menu`, `tray`, `dialog`, …) | | `keld_update` | `Channel::{Stable, Beta, Canary}` | diff --git a/docs/onboarding/04-wire-formats-and-contracts.md b/docs/onboarding/04-wire-formats-and-contracts.md index 6cf71599..d155e8e9 100644 --- a/docs/onboarding/04-wire-formats-and-contracts.md +++ b/docs/onboarding/04-wire-formats-and-contracts.md @@ -375,11 +375,20 @@ pub struct EchoResponse { Echo is **ungated on purpose**: the frame goes from decode straight to handler. `keld-guard::evaluate` is not on this path. That is not the privileged-IPC story. -`keld-guard::evaluate` takes a `Principal` and default-denies anything other than -`AppProcess` (`KELD-GUARD006`); it is live for MCP `keld_permissions_explain`, -webview camera/microphone capture (as the requesting `Webview` principal; missing -identity and `AppProcess` are `KELD-GUARD007`), and privileged kipc via -`dispatch_privileged` (KEL-69). Echo dispatch still does not call the guard. +`keld-guard::evaluate` applies `app.*` grants only to `AppProcess`; another +principal presented for those capabilities is `KELD-GUARD006`. The media +wrapper first requires a minted `Webview`: missing identity or `AppProcess` is +`KELD-GUARD007`, while that valid `Webview` reaches `evaluate` and is currently +`KELD-GUARD006` until window-level grants exist. This path is live for MCP +`keld_permissions_explain`, applicable new-request media callbacks, and +privileged kipc via `dispatch_privileged` (KEL-69). Echo does not call the guard. +[Wry 0.56.1 documents](https://docs.rs/wry/0.56.1/wry/struct.WebViewBuilder.html#method.with_permission_handler) +that saved browser preferences can bypass its callback, and its pinned +[`build.rs`](https://github.com/tauri-apps/wry/blob/14be44842747a62c4110bd982f61f6c1acd705c3/build.rs) +cfg-removes the delegate below macOS 12 on debug hosts. Current v0 neither +proves nor claims default denial for those cases; they block full KEL-132 +closure until the approved [KEL-135 profile lifecycle](../specs/kel135-persistent-profile-identity.md) +and oldest-supported-macOS evidence land. **FS is gated.** `FS_CHANNEL` (`keld-native::fs`, KEL-71) runs every `fs.read` / `fs.write` `Call` through `keld_ipc::guard_dispatch::dispatch_privileged` before @@ -395,9 +404,9 @@ It is not routed through `dispatch_privileged`: ready / last-window / quit ride the app-link the host already minted (`crates/keld-ipc/src/lifecycle.rs`). `@keld/electron` maps those onto `app.whenReady` / `window-all-closed` / `app.quit`. -`keld-guard::evaluate` also runs for MCP `keld_permissions_explain` and for -webview camera/microphone capture as the requesting webview principal (KEL-73); -missing identity and `AppProcess` are `KELD-GUARD007`. Echo and other ungated +`keld-guard::evaluate` also runs for MCP `keld_permissions_explain` and each +applicable new-request webview media callback under the identity/code rule +above (KEL-73); the saved-preference and older-macOS boundaries remain open. Echo and other ungated demo paths do not make the [`03` §1](../architecture/03-security.md) "every privileged operation passes the guard" property true of *all* IPC — only of the privileged channels that call @@ -551,9 +560,10 @@ sections. Three honest observations about the gap: normative in [`03` §2](../architecture/03-security.md). v0 code is `parse_manifest` / `load_manifest` / `evaluate` in `keld-guard` (path scopes for `app..`). Recorder and `keld doctor --permissions` are not this slice. -Privileged kipc uses `dispatch_privileged` (KEL-69). Webview camera and -microphone capture *do* call `evaluate` (`web.camera` / `web.microphone`, KEL-59) -as the requesting webview principal (KEL-73); `AppProcess` is `KELD-GUARD007`. +Privileged kipc uses `dispatch_privileged` (KEL-69). Applicable new-request +webview camera/microphone callbacks call `evaluate` (`web.camera` / +`web.microphone`, KEL-59) under the identity/code rule above (KEL-73); +saved-preference/KEL-135 and older-macOS boundaries remain open. **v0 matcher:** `$VARS` match as **literals**; a `..` path segment is always out of scope; symlink canonicalization is not in this slice. That is not an Allow. diff --git a/llms-full.txt b/llms-full.txt index 81875202..c613e924 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -447,11 +447,20 @@ pub struct EchoResponse { Echo is **ungated on purpose**: the frame goes from decode straight to handler. `keld-guard::evaluate` is not on this path. That is not the privileged-IPC story. -`keld-guard::evaluate` takes a `Principal` and default-denies anything other than -`AppProcess` (`KELD-GUARD006`); it is live for MCP `keld_permissions_explain`, -webview camera/microphone capture (as the requesting `Webview` principal; missing -identity and `AppProcess` are `KELD-GUARD007`), and privileged kipc via -`dispatch_privileged` (KEL-69). Echo dispatch still does not call the guard. +`keld-guard::evaluate` applies `app.*` grants only to `AppProcess`; another +principal presented for those capabilities is `KELD-GUARD006`. The media +wrapper first requires a minted `Webview`: missing identity or `AppProcess` is +`KELD-GUARD007`, while that valid `Webview` reaches `evaluate` and is currently +`KELD-GUARD006` until window-level grants exist. This path is live for MCP +`keld_permissions_explain`, applicable new-request media callbacks, and +privileged kipc via `dispatch_privileged` (KEL-69). Echo does not call the guard. +[Wry 0.56.1 documents](https://docs.rs/wry/0.56.1/wry/struct.WebViewBuilder.html#method.with_permission_handler) +that saved browser preferences can bypass its callback, and its pinned +[`build.rs`](https://github.com/tauri-apps/wry/blob/14be44842747a62c4110bd982f61f6c1acd705c3/build.rs) +cfg-removes the delegate below macOS 12 on debug hosts. Current v0 neither +proves nor claims default denial for those cases; they block full KEL-132 +closure until the approved [KEL-135 profile lifecycle](../specs/kel135-persistent-profile-identity.md) +and oldest-supported-macOS evidence land. **FS is gated.** `FS_CHANNEL` (`keld-native::fs`, KEL-71) runs every `fs.read` / `fs.write` `Call` through `keld_ipc::guard_dispatch::dispatch_privileged` before @@ -467,9 +476,9 @@ It is not routed through `dispatch_privileged`: ready / last-window / quit ride the app-link the host already minted (`crates/keld-ipc/src/lifecycle.rs`). `@keld/electron` maps those onto `app.whenReady` / `window-all-closed` / `app.quit`. -`keld-guard::evaluate` also runs for MCP `keld_permissions_explain` and for -webview camera/microphone capture as the requesting webview principal (KEL-73); -missing identity and `AppProcess` are `KELD-GUARD007`. Echo and other ungated +`keld-guard::evaluate` also runs for MCP `keld_permissions_explain` and each +applicable new-request webview media callback under the identity/code rule +above (KEL-73); the saved-preference and older-macOS boundaries remain open. Echo and other ungated demo paths do not make the [`03` §1](../architecture/03-security.md) "every privileged operation passes the guard" property true of *all* IPC — only of the privileged channels that call @@ -623,9 +632,10 @@ sections. Three honest observations about the gap: normative in [`03` §2](../architecture/03-security.md). v0 code is `parse_manifest` / `load_manifest` / `evaluate` in `keld-guard` (path scopes for `app..`). Recorder and `keld doctor --permissions` are not this slice. -Privileged kipc uses `dispatch_privileged` (KEL-69). Webview camera and -microphone capture *do* call `evaluate` (`web.camera` / `web.microphone`, KEL-59) -as the requesting webview principal (KEL-73); `AppProcess` is `KELD-GUARD007`. +Privileged kipc uses `dispatch_privileged` (KEL-69). Applicable new-request +webview camera/microphone callbacks call `evaluate` (`web.camera` / +`web.microphone`, KEL-59) under the identity/code rule above (KEL-73); +saved-preference/KEL-135 and older-macOS boundaries remain open. **v0 matcher:** `$VARS` match as **literals**; a `..` path segment is always out of scope; symlink canonicalization is not in this slice. That is not an Allow. @@ -1485,14 +1495,18 @@ manifest decoder. fetch-isolated per principal, remote-content windows get `channels: []` unless granted, navigation policy hooks (allow-list), devtools off in release unless `web.devtools: true`. - **v0:** camera and microphone requests are default-deny on all three live - backends: macOS and Linux (KEL-28) both install wry `with_permission_handler` - (`with_guarded_media_permissions`, shared helper); Windows registers the - `WebView2` `add_PermissionRequested` handler before the first navigation - (KEL-65 direct COM — the ordering is compile-enforced). All three evaluate + **v0:** new camera and microphone requests are explicitly default-denied by + Keld on Linux, Windows, and macOS 12+: macOS/Linux install wry + `with_permission_handler`; Windows registers WebView2 + `add_PermissionRequested` before first navigation. The callbacks evaluate `web.camera` / `web.microphone` as the requesting `Principal::Webview` - (host-minted `WebviewId`, generation `0` until navigation rotation lands) - with requested resource `*` (no platform callback passes an origin). + (host-minted `WebviewId`, generation `0`). Wry exposes no origin; WebView2 + exposes `Uri`, but Keld's v0 adapter currently reads only `PermissionKind` + and deliberately evaluates resource `*` without origin filtering. Pinned + wry does not consume the macOS callback below 12 on debug hosts, and saved + browser permission preferences can bypass + new-request callbacks; oldest-macOS proof and KEL-135 profile-backed + restart/revocation evidence remain open. Missing identity and `AppProcess` fail closed (`KELD-GUARD007`) so `/app` media grants cannot apply to a remote or other webview. A minted webview principal is still `KELD-GUARD006` until window-level grants exist. CSP @@ -1783,6 +1797,23 @@ surfacing that result, and the fuller version-matrix probe in `docs/research/library/host-platforms/06-webview-reality.md` describes (today's probe is driver + session type only). +Media permission installation is explicit for new requests on every live +backend. macOS 12+ wry auto-grants when its handler is absent; pinned wry does +not expose that delegate callback on older debug hosts, whose support boundary +and real acceptance remain open. WebKitGTK 2.52.6 and wry 0.56.1 deny an +unhandled Linux request by default, but that fallback is not evidence that +Keld evaluated the correct webview principal and manifest. The interim wry +backends therefore keep the raw builder inside a guard-installed witness +through initial content/build. KEL-132's Linux proof binds one run nonce, +process, executable, default manifest, minted principal, `keld-guard` decision, +secure-localhost mock request, explicit WebKitGTK deny API, and top-level-window +census. The successful GLib signal registration, callback decision, and deny +API are observed on the process main thread. JavaScript denial alone is not +sufficient provenance. Wry documents +that saved browser permission preferences bypass its callback; KEL-135's +ephemeral-dev and identity-bound persistent-store lifecycle is a prerequisite +for the corresponding restart/revocation proof. + ## 2. Renderer bridge contract (`window.keld`) **Destination; not implemented in the live hello backends.** The bridge is injected @@ -3076,6 +3107,21 @@ and had no `with_permission_handler`. 0.56.1 adds the handler; Keld installs it and default-denies via `keld-guard` (`web.camera` / `web.microphone`). tao stays 0.35.3: wry 0.56 `build` takes `raw_window_handle::HasWindowHandle`. +**Update (2026-09-04, KEL-132).** The former source-string installation test +passed even when the production wry installer returned its input unchanged. +Both interim wry backends now keep the raw builder inside a guard-installed +witness through initial content/build, and a fake invokes the exact installed +callback. +Linux's unhandled WebKitGTK/wry default is deny rather than a platform prompt; +the real Linux proof therefore records the default manifest, minted principal, +`keld-guard` decision, explicit deny API call, request/process nonce, and +window census with mock camera/microphone devices. A JavaScript +`NotAllowedError` alone is not Keld-policy provenance. Saved permission +preferences bypass wry's new-request callback; KEL-135 owns their profile and +restart/revocation lifecycle. Pinned wry also cfg-removes the delegate callback +below macOS 12 on debug hosts, so oldest-supported-macOS acceptance remains +open rather than inferred from Linux. + **Destination (architecture 05 §1).** `keld-wv` is Keld's own `WebEngine` layer over WKWebView (**objc2**), WebView2 (**windows-rs** + WebView2 COM), and WebKitGTK (**webkit6/gtk4**). wry/tao stay as reference implementations and a quirks catalog. @@ -3127,9 +3173,9 @@ a shell variable, while preserving the documented DMA-BUF crash/flicker mitigation on `WebKitGTK` ≤ 2.54 (tauri-apps/tauri#9394, #14924). The pure detector lets `keld doctor` read the state later without side effects. Media-permission guard (KEL-59) reuses the existing wry helpers unchanged (`media.rs` widened -from macOS-only to `any(macos, linux)`), since Linux's default without a -handler is also "show the platform's own prompt," same category as the old -Windows default. +from macOS-only to `any(macos, linux)`). Later WebKitGTK 2.52.6/wry 0.56.1 +inspection corrected the original prompt claim: an unhandled Linux request is +denied, but that fallback supplies no Keld principal/manifest provenance. Verification: compiled, clippy-clean, and 225 tests green (including live Bun↔Rust kipc integration) on real Ubuntu 26.04 (GTK3/WebKit2GTK 4.1 dev libs diff --git a/tools/ci_hygiene.rs b/tools/ci_hygiene.rs index 121b8c79..49eea6df 100644 --- a/tools/ci_hygiene.rs +++ b/tools/ci_hygiene.rs @@ -910,6 +910,57 @@ fn check_bun_test_job(text: &str) -> Result<(), String> { Ok(()) } +fn check_linux_media_guard_step(text: &str) -> Result<(), String> { + let Some(block) = workflow_job_block(text, "linux-gui-smoke") else { + return Err(format!( + "CI-HYGIENE: `{WORKFLOW}` must keep the `linux-gui-smoke` job that owns real WebKitGTK media and window evidence." + )); + }; + let build_step = "Build Linux media guard probe"; + let gui_step = "Xvfb GUI smoke test — title, controls, and clean close"; + for step in [build_step, gui_step] { + if workflow_direct_named_step_count(&block, step) != 1 { + return Err(format!( + "CI-HYGIENE: `{WORKFLOW}` must contain exactly one direct `{step}` step for KEL-132." + )); + } + let step_block = workflow_direct_named_step_block(&block, step).ok_or_else(|| { + format!("CI-HYGIENE: `{WORKFLOW}` cannot parse the direct `{step}` step for KEL-132.") + })?; + if workflow_named_step_direct_keys(&step_block, step) != Some(vec![String::from("run")]) { + return Err(format!( + "CI-HYGIENE: `{WORKFLOW}` `{step}` must have only an unconditional direct `run` key for KEL-132." + )); + } + } + let build_block = workflow_direct_named_step_block(&block, build_step).ok_or_else(|| { + format!("CI-HYGIENE: `{WORKFLOW}` cannot parse `{build_step}` for KEL-132.") + })?; + let expected_build = vec![ + String::from("cargo build -p keld-wv --example linux_media_guard"), + String::from( + "cc -shared -fPIC -Wall -Wextra -Werror -Wpedantic $(pkg-config --cflags webkit2gtk-4.1) crates/keld-wv/tests/fixtures/linux_media_interpose.c -o \"$RUNNER_TEMP/linux_media_interpose.so\" -ldl $(pkg-config --libs webkit2gtk-4.1)", + ), + ]; + if workflow_named_step_shell_commands(&build_block, build_step) != Some(expected_build) { + return Err(format!( + "CI-HYGIENE: `{WORKFLOW}` `{build_step}` must contain exactly KEL-132's two executable build commands and no wrappers." + )); + } + let gui_block = workflow_direct_named_step_block(&block, gui_step).ok_or_else(|| { + format!("CI-HYGIENE: `{WORKFLOW}` cannot parse `{gui_step}` for KEL-132.") + })?; + let expected_gui = "xvfb-run --auto-servernum --server-args='-screen 0 1024x768x24' crates/keld-wv/tests/linux_gui_smoke.sh \"$RUNNER_TEMP/linux_media_interpose.so\" target/debug/examples/linux_media_guard ./target/release/keld-host"; + if workflow_named_step_direct_value(&gui_block, gui_step, "run").as_deref() + != Some(expected_gui) + { + return Err(format!( + "CI-HYGIENE: `{WORKFLOW}` `{gui_step}` must directly execute KEL-132's tracked Linux GUI oracle as its only command; conditional or early-success wrappers are forbidden." + )); + } + Ok(()) +} + fn check_required_job(text: &str) -> Result<(), String> { let Some(block) = workflow_job_block(text, "required") else { return Err(format!( @@ -1609,6 +1660,7 @@ fn check_workflow(root: &Path) -> Result<(), String> { check_fuzz_workspace_step(&text)?; check_msrv_avoids_apt(&text)?; check_bun_test_job(&text)?; + check_linux_media_guard_step(&text)?; check_required_job(&text)?; check_product_status_step(&text)?; check_product_status_windows_step(&text)?; @@ -1811,6 +1863,14 @@ mod tests { " env:", " KELD_CI_TS_PACKAGES: ${{ needs.changes.outputs.ts_packages }}", " run: cd fixture && bun test", + " linux-gui-smoke:", + " steps:", + " - name: Build Linux media guard probe", + " run: |", + " cargo build -p keld-wv --example linux_media_guard", + " cc -shared -fPIC -Wall -Wextra -Werror -Wpedantic $(pkg-config --cflags webkit2gtk-4.1) crates/keld-wv/tests/fixtures/linux_media_interpose.c -o \"$RUNNER_TEMP/linux_media_interpose.so\" -ldl $(pkg-config --libs webkit2gtk-4.1)", + " - name: Xvfb GUI smoke test — title, controls, and clean close", + " run: xvfb-run --auto-servernum --server-args='-screen 0 1024x768x24' crates/keld-wv/tests/linux_gui_smoke.sh \"$RUNNER_TEMP/linux_media_interpose.so\" target/debug/examples/linux_media_guard ./target/release/keld-host", " msrv:", " runs-on: macos-latest", " steps:", @@ -1981,6 +2041,59 @@ mod tests { check(temp.path()).expect("complete KEL-39 fixture must pass"); } + #[test] + fn linux_media_guard_commands_are_mandatory_in_the_gui_lane() { + for (needle, replacement) in [ + ( + " cargo build -p keld-wv --example linux_media_guard\n", + " removed-media-build\n", + ), + ( + "crates/keld-wv/tests/fixtures/linux_media_interpose.c", + "removed-media-interposer.c", + ), + ("-Wall -Wextra -Werror -Wpedantic", "-Wall -Wextra"), + ( + "xvfb-run --auto-servernum --server-args='-screen 0 1024x768x24' crates/keld-wv/tests/linux_gui_smoke.sh \"$RUNNER_TEMP/linux_media_interpose.so\" target/debug/examples/linux_media_guard ./target/release/keld-host", + "removed-media-run", + ), + ] { + let temp = complete_fixture(); + let workflow = valid_workflow().replacen(needle, replacement, 1); + temp.write(WORKFLOW, &workflow); + let error = check(temp.path()).expect_err("missing media command must fail"); + assert!(error.contains("KEL-132"), "{needle}: {error}"); + } + } + + #[test] + fn linux_media_guard_rejects_inert_or_conditional_commands() { + for (needle, replacement) in [ + ( + " cargo build -p keld-wv --example linux_media_guard\n", + " echo cargo build -p keld-wv --example linux_media_guard\n", + ), + ( + " - name: Build Linux media guard probe\n run: |\n", + " - name: Build Linux media guard probe\n if: false\n run: |\n", + ), + ( + " run: xvfb-run --auto-servernum --server-args='-screen 0 1024x768x24' crates/keld-wv/tests/linux_gui_smoke.sh \"$RUNNER_TEMP/linux_media_interpose.so\" target/debug/examples/linux_media_guard ./target/release/keld-host\n", + " run: |\n exit\n xvfb-run --auto-servernum --server-args='-screen 0 1024x768x24' crates/keld-wv/tests/linux_gui_smoke.sh \"$RUNNER_TEMP/linux_media_interpose.so\" target/debug/examples/linux_media_guard ./target/release/keld-host\n", + ), + ( + " run: xvfb-run --auto-servernum --server-args='-screen 0 1024x768x24' crates/keld-wv/tests/linux_gui_smoke.sh \"$RUNNER_TEMP/linux_media_interpose.so\" target/debug/examples/linux_media_guard ./target/release/keld-host\n", + " run: |\n if [ 1 -eq 0 ]; then\n xvfb-run --auto-servernum --server-args='-screen 0 1024x768x24' crates/keld-wv/tests/linux_gui_smoke.sh \"$RUNNER_TEMP/linux_media_interpose.so\" target/debug/examples/linux_media_guard ./target/release/keld-host\n fi\n", + ), + ] { + let temp = complete_fixture(); + let workflow = valid_workflow().replacen(needle, replacement, 1); + temp.write(WORKFLOW, &workflow); + let error = check(temp.path()).expect_err("inert media command must fail"); + assert!(error.contains("KEL-132"), "{needle}: {error}"); + } + } + #[test] fn required_result_job_is_mandatory_and_always_created() { let temp = complete_fixture();