car-desktop 0.15.1

OS-level screen capture, accessibility inspection, and input synthesis for Common Agent Runtime
Documentation
//! Shared safety utilities for input-synthesis backends.
//!
//! Every hard-coded safety rule listed in docs/CAR_DESKTOP.md ยง
//! "Safety model" that can be expressed cross-platform lives here.
//! Platform backends import these utilities; they do not
//! re-implement them with platform-specific twists.
//!
//! The rules implemented here are:
//! * Destructive-label detection via regex.
//! * Per-window token-bucket rate limiting at 8 events/sec.
//!
//! Platform-specific rules (target-window frame clamping, Esc-Esc
//! kill switch wiring) live alongside the platform backend because
//! they require platform APIs.

use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};

use crate::errors::{CarDesktopError, Result};
use crate::models::WindowHandle;

/// Case-insensitive substring check against the destructive-action
/// word list. The list is deliberately short and conservative: it
/// catches the clear cases (Delete, Quit, Send, Publish, Pay) and
/// leaves the ambiguous ones (Save, Open, Apply) alone. Callers
/// that want stricter policing can layer their own checks on top.
///
/// Case-insensitive whole-word-ish match (bounded by either start-
/// of-string, end-of-string, or non-alphanumeric). We avoid the
/// regex crate because the word list is small and static.
pub const DESTRUCTIVE_WORDS: &[&str] = &[
    "delete", "quit", "remove", "discard", "drop", "erase", "send", "publish", "submit", "buy",
    "pay", "confirm",
];

/// Check whether `label` (a button title, AX attribute, or similar)
/// carries a destructive word. Returns the matched word on hit so the
/// error can quote back to the caller *why* the action was gated.
pub fn destructive_word_in(label: &str) -> Option<&'static str> {
    let lowered = label.to_ascii_lowercase();
    for word in DESTRUCTIVE_WORDS {
        if contains_whole_word(&lowered, word) {
            return Some(word);
        }
    }
    None
}

/// Very small whole-word-ish substring check โ€” `needle` must be
/// preceded by start-of-haystack or a non-alphanumeric character,
/// and followed by end-of-haystack or a non-alphanumeric character.
/// This rejects the false positive "undelete" for the needle
/// "delete" and "submitted" for "submit" while still matching
/// "Delete" (start), "...Delete." (period boundary), and so on.
fn contains_whole_word(haystack: &str, needle: &str) -> bool {
    if haystack.is_empty() || needle.is_empty() || needle.len() > haystack.len() {
        return false;
    }
    let bytes = haystack.as_bytes();
    let n_bytes = needle.as_bytes();
    let mut i = 0;
    while i + n_bytes.len() <= bytes.len() {
        if &bytes[i..i + n_bytes.len()] == n_bytes {
            let before_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric();
            let after_idx = i + n_bytes.len();
            let after_ok = after_idx == bytes.len() || !bytes[after_idx].is_ascii_alphanumeric();
            if before_ok && after_ok {
                return true;
            }
        }
        i += 1;
    }
    false
}

/// Token-bucket rate limiter. 8 tokens, refilled at 8 tokens/sec,
/// per window. Every input event consumes 1 token; when the bucket
/// is empty the request fails with `CarDesktopError::RateLimited`.
#[derive(Debug)]
pub struct PerWindowRateLimiter {
    /// Tokens per window, with the last-refill timestamp captured
    /// to compute replenishment at check time.
    state: Mutex<HashMap<WindowHandle, Bucket>>,
}

#[derive(Debug)]
struct Bucket {
    tokens: f64,
    last_refill: Instant,
}

const BUCKET_CAPACITY: f64 = 8.0;
const REFILL_PER_SEC: f64 = 8.0;

impl Default for PerWindowRateLimiter {
    fn default() -> Self {
        Self::new()
    }
}

impl PerWindowRateLimiter {
    pub fn new() -> Self {
        Self {
            state: Mutex::new(HashMap::new()),
        }
    }

    /// Acquire one token for `window`. Blocks briefly if a refill
    /// is imminent; returns `RateLimited` only if the window has
    /// exceeded 8 events/sec sustained.
    pub fn acquire(&self, window: WindowHandle) -> Result<()> {
        let mut guard = self
            .state
            .lock()
            .expect("PerWindowRateLimiter mutex poisoned");
        let now = Instant::now();
        let bucket = guard.entry(window).or_insert(Bucket {
            tokens: BUCKET_CAPACITY,
            last_refill: now,
        });
        // Refill according to elapsed wall time since the last check.
        let elapsed = now.saturating_duration_since(bucket.last_refill);
        bucket.tokens =
            (bucket.tokens + elapsed.as_secs_f64() * REFILL_PER_SEC).min(BUCKET_CAPACITY);
        bucket.last_refill = now;
        if bucket.tokens < 1.0 {
            return Err(CarDesktopError::RateLimited);
        }
        bucket.tokens -= 1.0;
        Ok(())
    }
}

/// Shared constants โ€” every input tool sleeps this long after
/// posting an event to let the OS's hit-testing catch up.
pub const POST_EVENT_SETTLE: Duration = Duration::from_millis(8);

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn destructive_flags_obvious_hits() {
        assert_eq!(destructive_word_in("Delete"), Some("delete"));
        assert_eq!(destructive_word_in("Send Now"), Some("send"));
        assert_eq!(destructive_word_in("Pay $50"), Some("pay"));
        assert_eq!(destructive_word_in("Publish post"), Some("publish"));
        assert_eq!(destructive_word_in("CONFIRM"), Some("confirm"));
    }

    #[test]
    fn destructive_ignores_innocuous_labels() {
        assert_eq!(destructive_word_in("Save"), None);
        assert_eq!(destructive_word_in("Open"), None);
        assert_eq!(destructive_word_in("Apply"), None);
        assert_eq!(destructive_word_in(""), None);
    }

    #[test]
    fn whole_word_rejects_substring_collisions() {
        // "undelete" should NOT match "delete".
        assert_eq!(destructive_word_in("Undelete"), None);
        // "submitted" should NOT match "submit".
        assert_eq!(destructive_word_in("submitted"), None);
        // "Presend" should NOT match "send" (no word boundary before).
        assert_eq!(destructive_word_in("Presend"), None);
        // "Pays" should NOT match "pay" (no word boundary after).
        assert_eq!(destructive_word_in("Pays"), None);
    }

    #[test]
    fn whole_word_handles_punctuation_boundaries() {
        // These all use non-alphanumeric boundaries; should match.
        assert!(destructive_word_in("Delete?").is_some());
        assert!(destructive_word_in("  delete  ").is_some());
        assert!(destructive_word_in("Click to delete this item").is_some());
        assert!(destructive_word_in("send/publish").is_some());
    }

    #[test]
    fn rate_limiter_allows_bucket_capacity() {
        let lim = PerWindowRateLimiter::new();
        let w = WindowHandle::new(1, 1);
        for _ in 0..8 {
            lim.acquire(w).expect("first 8 events must fit in bucket");
        }
    }

    #[test]
    fn rate_limiter_rejects_overflow() {
        let lim = PerWindowRateLimiter::new();
        let w = WindowHandle::new(1, 1);
        for _ in 0..8 {
            lim.acquire(w).unwrap();
        }
        assert!(matches!(lim.acquire(w), Err(CarDesktopError::RateLimited)));
    }

    #[test]
    fn rate_limiter_is_per_window() {
        let lim = PerWindowRateLimiter::new();
        let w1 = WindowHandle::new(1, 1);
        let w2 = WindowHandle::new(1, 2);
        for _ in 0..8 {
            lim.acquire(w1).unwrap();
        }
        // Second window has its own bucket.
        lim.acquire(w2).unwrap();
    }

    #[test]
    fn rate_limiter_refills_after_delay() {
        let lim = PerWindowRateLimiter::new();
        let w = WindowHandle::new(1, 1);
        for _ in 0..8 {
            lim.acquire(w).unwrap();
        }
        // Wait 200ms โ€” at 8 tokens/sec that's 1.6 tokens replenished,
        // enough for one more.
        std::thread::sleep(std::time::Duration::from_millis(200));
        lim.acquire(w).expect("refill must allow one more event");
    }
}