Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 163 additions & 1 deletion libs/cua-driver/rust/crates/cua-driver/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ pub enum Command {
CheckUpdate { json: bool, no_cache: bool },
Doctor { json: bool },
Diagnose,
/// `cua-driver permissions status|grant [--json]` — report TCC status
/// (with source attribution + a live capture probe) or raise the
/// correctly-attributed grant by launching CuaDriver via LaunchServices.
Permissions { subcommand: String, json: bool },
Config {
/// `show` | `get` | `set` | `reset` (None → show)
subcommand: Option<String>,
Expand Down Expand Up @@ -137,7 +141,16 @@ pub fn parse_command() -> Command {
if args.iter().any(|a| a == "--help" || a == "-h") {
println!("cua-driver {} — cross-platform computer-use automation driver", env!("CARGO_PKG_VERSION"));
println!("Usage: cua-driver [SUBCOMMAND] [OPTIONS]");
println!("Subcommands: mcp, list-tools, describe, call, serve, stop, status, config, recording, update, check-update, doctor, diagnose, autostart, skills");
println!("Subcommands: mcp, list-tools, describe, call, serve, stop, status, config, recording, update, check-update, doctor, diagnose, permissions, autostart, skills");
println!();
println!("permissions options (macOS):");
println!(" cua-driver permissions status Report Accessibility + Screen Recording status. Read-only (no prompt).");
println!(" Routes through a running daemon when one is up, so the answer carries");
println!(" the CuaDriver identity; otherwise reads the calling process's grants");
println!(" and labels them as such (`source`). Add --json for the raw payload.");
println!(" cua-driver permissions grant Launch CuaDriver via LaunchServices so the permission dialog attributes");
println!(" to com.trycua.driver (not your terminal), wait for the grant, then");
println!(" confirm the driver's own status. This is the correct way to grant.");
println!();
println!("Updating cua-driver:");
println!(" cua-driver check-update Ask GitHub whether a newer release is available. Read-only.");
Expand Down Expand Up @@ -277,6 +290,11 @@ pub fn parse_command() -> Command {
Command::Doctor { json }
}
Some("diagnose") => Command::Diagnose,
Some("permissions") => {
let subcommand = pos.next().unwrap_or("status").to_string();
let json = args.iter().any(|a| a == "--json");
Command::Permissions { subcommand, json }
}
Some("config") => {
let subcommand = pos.next().map(str::to_owned);
let key = pos.next().map(str::to_owned);
Expand Down Expand Up @@ -1434,6 +1452,149 @@ pub fn run_update_cmd(apply: bool, json: bool) {
}
}

/// `cua-driver permissions status|grant`.
pub fn run_permissions_cmd(
registry: std::sync::Arc<ToolRegistry>,
subcommand: &str,
json: bool,
) {
match subcommand {
"status" => run_permissions_status(registry, json),
"grant" => run_permissions_grant(),
other => {
eprintln!("unknown permissions subcommand '{other}'. Valid: status, grant.");
process::exit(2);
}
}
}

/// Report TCC status with source attribution + the live capture probe.
/// Routes through a running daemon when one is up (so the answer carries the
/// com.trycua.driver identity); otherwise reads in-process (the caller's
/// identity, clearly labelled). Never raises a prompt — `grant` does that.
fn run_permissions_status(registry: std::sync::Arc<ToolRegistry>, json: bool) {
let socket = crate::serve::default_socket_path();
let structured: serde_json::Value = if crate::serve::is_daemon_listening(&socket) {
let req = crate::serve::DaemonRequest {
method: "call".into(),
name: Some("check_permissions".into()),
args: Some(serde_json::json!({ "prompt": false })),
};
crate::serve::send_request(&socket, &req)
.ok()
.filter(|r| r.ok)
.and_then(|r| r.result)
.and_then(|res| res.get("structuredContent").cloned())
.unwrap_or_else(|| serde_json::json!({}))
} else {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio runtime");
rt.block_on(registry.invoke(
"check_permissions",
serde_json::json!({ "prompt": false }),
))
.structured_content
.unwrap_or_else(|| serde_json::json!({}))
};

if json {
println!(
"{}",
serde_json::to_string_pretty(&structured).unwrap_or_else(|_| structured.to_string())
);
return;
}

let b = |k: &str| structured.get(k).and_then(|v| v.as_bool()).unwrap_or(false);
let ax = b("accessibility");
let sr = b("screen_recording");
let cap = b("screen_recording_capturable");
let source = structured.get("source");
let attribution = source
.and_then(|s| s.get("attribution"))
.and_then(|v| v.as_str())
.unwrap_or("unknown");

println!("Accessibility: {}", if ax { "✅ granted" } else { "❌ not granted" });
println!("Screen Recording: {}", if sr { "✅ granted" } else { "❌ not granted" });
if sr && !cap {
println!(
" ⚠️ preflight reports granted, but a live capture probe failed — the grant \
likely belongs to another process, not this one."
);
}
println!("Source: {attribution}");
if attribution == "caller" {
if let Some(note) = source.and_then(|s| s.get("note")).and_then(|v| v.as_str()) {
println!(" {note}");
}
println!(" → To grant for the driver, run: cua-driver permissions grant");
}
}

/// Launch CuaDriver via LaunchServices so the permission prompt attributes to
/// com.trycua.driver, wait (user-paced) for the daemon to come up — its socket
/// only appears once the permissions gate passes, i.e. the grant was given —
/// then report the driver's own status.
fn run_permissions_grant() {
#[cfg(target_os = "macos")]
{
let socket = crate::serve::default_socket_path();
if crate::serve::is_daemon_listening(&socket) {
println!("CuaDriver daemon already running — checking its permissions…");
} else {
println!("Launching CuaDriver to request permissions.");
println!(
"A dialog titled \u{201c}Cua Driver\u{201d} will appear — approve Accessibility \
and Screen Recording in System Settings, then this command continues."
);
if let Err(e) = launch_daemon_and_wait(&socket, 180) {
eprintln!("\nDidn't detect the CuaDriver daemon: {e}");
eprintln!(
"If you haven't yet, grant Accessibility + Screen Recording to CuaDriver \
in System Settings, then re-run `cua-driver permissions grant`."
);
process::exit(1);
}
}
let req = crate::serve::DaemonRequest {
method: "call".into(),
name: Some("check_permissions".into()),
args: Some(serde_json::json!({ "prompt": false })),
};
let structured = crate::serve::send_request(&socket, &req)
.ok()
.filter(|r| r.ok)
.and_then(|r| r.result)
.and_then(|res| res.get("structuredContent").cloned())
.unwrap_or_else(|| serde_json::json!({}));
let ax = structured.get("accessibility").and_then(|v| v.as_bool()).unwrap_or(false);
let sr = structured.get("screen_recording").and_then(|v| v.as_bool()).unwrap_or(false);
if ax && sr {
println!("\n✅ CuaDriver has Accessibility + Screen Recording. You're set.");
} else {
let missing = match (ax, sr) {
(false, false) => "Accessibility + Screen Recording",
(false, true) => "Accessibility",
(true, false) => "Screen Recording",
(true, true) => unreachable!(),
};
println!("\n⚠️ CuaDriver is running but still missing: {missing}.");
println!(
"Approve it for \u{201c}Cua Driver\u{201d} in System Settings \u{2192} Privacy & \
Security, then re-run `cua-driver permissions status`."
);
}
}
#[cfg(not(target_os = "macos"))]
{
eprintln!("`cua-driver permissions grant` is macOS-only.");
process::exit(1);
}
}

/// `cua-driver check-update [--json] [--no-cache]` — pure check, never installs.
///
/// Mirror of the `check_for_update` MCP tool. Both routes call into
Expand Down Expand Up @@ -2346,6 +2507,7 @@ pub fn telemetry_entry_event(cmd: &Command) -> Option<String> {
Command::Update { .. } => "cua_driver_update".to_owned(),
Command::CheckUpdate { .. } => "cua_driver_check_update".to_owned(),
Command::Doctor { .. } => "cua_driver_doctor".to_owned(),
Command::Permissions { .. } => "cua_driver_permissions".to_owned(),
Command::Diagnose => "cua_driver_diagnose".to_owned(),
// Per-subcommand event so dashboards can split enable / disable /
// status / kick separately — they have very different meanings
Expand Down
10 changes: 10 additions & 0 deletions libs/cua-driver/rust/crates/cua-driver/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,11 @@ fn main() {
cli::run_diagnose_cmd(reg);
return;
}
cli::Command::Permissions { subcommand, json } => {
let reg = Arc::new(build_macos_registry());
cli::run_permissions_cmd(reg, &subcommand, json);
return;
}
cli::Command::Autostart { subcommand } => {
autostart::run_autostart_cmd(&subcommand);
return;
Expand Down Expand Up @@ -575,6 +580,11 @@ fn main() -> anyhow::Result<()> {
cli::run_diagnose_cmd(reg);
return Ok(());
}
cli::Command::Permissions { subcommand, json } => {
let reg = Arc::new(build_registry_no_cursor());
cli::run_permissions_cmd(reg, &subcommand, json);
return Ok(());
}
cli::Command::Autostart { subcommand } => {
autostart::run_autostart_cmd(&subcommand);
return Ok(());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,71 @@ use crate::permissions::status::{

pub struct CheckPermissionsTool;

/// (A) Real ScreenCaptureKit capability probe — what THIS process can
/// actually capture right now, independent of the CGPreflight cache.
///
/// `CGPreflightScreenCaptureAccess()` (used by `screen_recording_granted`)
/// answers from a per-process cache that goes stale after `tccutil reset`
/// and is unreliable for CLI / child processes — the same finding Peekaboo
/// documents. `SCShareableContent::get()` does a live query: it only
/// returns displays when the answering process can genuinely capture. When
/// it disagrees with the preflight boolean, the preflight one is lying.
fn screen_recording_capturable() -> bool {
use screencapturekit::prelude::SCShareableContent;
SCShareableContent::get()
.map(|c| !c.displays().is_empty())
.unwrap_or(false)
Comment on lines +21 to +25

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="libs/cua-driver/rust/crates/platform-macos/src/tools/check_permissions.rs"

echo "== File =="
ls -l "$FILE"

echo "== Relevant function: screen_recording_capturable() =="
rg -n "fn screen_recording_capturable" -n "$FILE" || true
# Print around it (small window)
python3 - <<'PY'
import itertools,sys,os
path="libs/cua-driver/rust/crates/platform-macos/src/tools/check_permissions.rs"
start=1
with open(path,'r',encoding='utf-8') as f:
    lines=f.readlines()
# find line numbers for the function
for i,l in enumerate(lines, start=1):
    if "fn screen_recording_capturable" in l:
        s=i-5
        e=i+30
        print(f"== lines {s}-{e} ==")
        for j in range(max(1,s), min(len(lines),e)+1):
            print(f"{j:4d}:{lines[j-1].rstrip()}")
        break
PY

echo "== CheckPermissionsTool::invoke() warning + structured payload =="
python3 - <<'PY'
path="libs/cua-driver/rust/crates/platform-macos/src/tools/check_permissions.rs"
with open(path,'r',encoding='utf-8') as f:
    lines=f.readlines()
def show_around(substr, window=120):
    for i,l in enumerate(lines, start=1):
        if substr in l:
            s=i-20
            e=i+window
            print(f"\n== around line {i} ({substr}) => {s}-{e} ==")
            for j in range(max(1,s), min(len(lines),e)+1):
                print(f"{j:4d}:{lines[j-1].rstrip()}")
            return True
    return False

show_around("⚠️  Screen Recording reads granted")
show_around("screen_recording_capturable")
show_around("responsible_ppid")
PY

echo "== permission_source() responsible_ppid field =="
rg -n "permission_source\\(" "$FILE" || true
python3 - <<'PY'
path="libs/cua-driver/rust/crates/platform-macos/src/tools/check_permissions.rs"
with open(path,'r',encoding='utf-8') as f:
    lines=f.readlines()
for i,l in enumerate(lines, start=1):
    if "fn permission_source" in l:
        s=i-5
        e=i+80
        print(f"\n== lines {s}-{e} ===============")
        for j in range(max(1,s), min(len(lines),e)+1):
            print(f"{j:4d}:{lines[j-1].rstrip()}")
        break
PY

Repository: trycua/cua

Length of output: 21994


Don’t collapse ScreenCaptureKit probe errors into false

  • screen_recording_capturable() turns any SCShareableContent::get() error into false (unwrap_or(false)), so transient probe failures are indistinguishable from a true permission miss; invoke() can then emit the “reads granted but a live capture probe failed” warning and serialize screen_recording_capturable: false. (libs/cua-driver/rust/crates/platform-macos/src/tools/check_permissions.rs:21-26, 145-165)
  • source sets responsible_ppid to libc::getppid() (immediate parent PID). If the intent is the LaunchServices/TCC “responsible process” PID, this can be wrong for indirect spawn/reparenting cases—rename or add the accurate identity. (40-75)
Possible fix
-fn screen_recording_capturable() -> bool {
+fn screen_recording_capturable() -> Result<bool, String> {
     use screencapturekit::prelude::SCShareableContent;
     SCShareableContent::get()
         .map(|c| !c.displays().is_empty())
-        .unwrap_or(false)
+        .map_err(|err| err.to_string())
 }
-        let screen_recording_capturable = screen_recording_capturable();
+        let screen_recording_capturable = screen_recording_capturable();
+        let screen_recording_capturable_value =
+            screen_recording_capturable.as_ref().ok().copied();
 
-        if screen_recording && !screen_recording_capturable {
+        if screen_recording && screen_recording_capturable_value == Some(false) {
             summary.push_str(
                 "\n⚠️  Screen Recording reads granted but a live capture probe failed — \
                  the grant likely belongs to a different process, not this one.",
             );
         }
 
         ToolResult::text(summary)
             .with_structured(serde_json::json!({
                 "accessibility":               accessibility,
                 "screen_recording":            screen_recording,
-                "screen_recording_capturable": screen_recording_capturable,
+                "screen_recording_capturable": screen_recording_capturable_value,
+                "screen_recording_capturable_error": screen_recording_capturable.err(),
                 "source":                      source,
             }))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libs/cua-driver/rust/crates/platform-macos/src/tools/check_permissions.rs`
around lines 21 - 25, The probe function screen_recording_capturable() currently
swallows SCShareableContent::get() errors by using unwrap_or(false), making
transient probe failures indistinguishable from a true permission-denied result;
change it to return a Result<bool, E> (or an enum) so callers (e.g., invoke())
can distinguish probe errors from an actual non-capturable state and
log/propagate the underlying error instead of serializing false. Also, in the
source construction, don’t call the field responsible_ppid with libc::getppid()
if you intend the LaunchServices/TCC “responsible process” PID; rename the field
to immediate_ppid or add a new field (e.g., responsible_process_pid) that
attempts to resolve the true responsible PID, keeping libc::getppid() only for
the immediate parent identity.

}

/// (B) Which TCC identity the booleans in this response reflect.
///
/// macOS attributes Accessibility / Screen-Recording to the *responsible
/// process* (the LaunchServices launching app), not the executable path.
/// So `check_permissions` answered in-process reflects:
/// - the **CuaDriver daemon** (`com.trycua.driver`) when we're the bundle
/// binary reparented to launchd (ppid == 1) — the real driver status;
/// this is the forced bundle/daemon path.
/// - the **calling app** otherwise — e.g. the terminal/IDE that spawned
/// `cua-driver call …`. That grant is NOT the driver's, which is why a
/// standalone check can read `true` while `tccutil … com.trycua.driver`
/// reports no record.
fn permission_source() -> serde_json::Value {
let pid = unsafe { libc::getpid() };
let ppid = unsafe { libc::getppid() };
let exe = std::env::current_exe()
.ok()
.and_then(|p| std::fs::canonicalize(p).ok())
.and_then(|p| p.to_str().map(str::to_owned))
.unwrap_or_default();
let is_driver_daemon =
exe.contains("/CuaDriver.app/Contents/MacOS/") && ppid == 1;

let (attribution, note) = if is_driver_daemon {
(
"driver-daemon",
"These booleans reflect the CuaDriver daemon's own TCC identity \
(com.trycua.driver) — the process that does the work.",
)
} else {
(
"caller",
"These booleans reflect the TCC identity of the app that launched \
this process (e.g. your terminal/IDE), NOT the CuaDriver daemon \
(com.trycua.driver). A standalone check can read `true` here while \
`tccutil … com.trycua.driver` reports no record. To grant for the \
driver, run `cua-driver permissions grant`.",
)
};

serde_json::json!({
"attribution": attribution,
"pid": pid,
"responsible_ppid": ppid,
"executable": exe,
"note": note,
})
}

static DEF: std::sync::OnceLock<ToolDef> = std::sync::OnceLock::new();

fn def() -> &'static ToolDef {
Expand All @@ -19,7 +84,14 @@ fn def() -> &'static ToolDef {
By default also raises the system permission dialogs for any missing grants — \
Apple's request APIs are no-ops when the grant is already active, so this is \
safe to call repeatedly. Pass {\"prompt\": false} for a purely read-only \
status check.".into(),
status check.\n\n\
Returns: `accessibility` + `screen_recording` (booleans from the TCC \
preflight APIs), `screen_recording_capturable` (a live ScreenCaptureKit \
probe — if it disagrees with `screen_recording`, the preflight grant \
belongs to a different process), and `source` (which TCC identity the \
booleans reflect: the CuaDriver daemon vs the launching terminal/IDE). \
macOS attributes grants to the responsible process, so a standalone call \
from a terminal reports the terminal's grants, not the driver's.".into(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
Expand Down Expand Up @@ -53,21 +125,42 @@ impl Tool for CheckPermissionsTool {
}
let accessibility = accessibility_granted();
let screen_recording = screen_recording_granted();
// (A) Authoritative live probe — see `screen_recording_capturable`.
let screen_recording_capturable = screen_recording_capturable();
// (B) Which identity the booleans above belong to.
let source = permission_source();
let is_caller = source.get("attribution").and_then(|v| v.as_str()) == Some("caller");

// Text format mirrors Swift 1:1:
// "✅ Accessibility: granted.\n✅ Screen Recording: granted."
let ax_prefix = if accessibility { "✅" } else { "❌" };
let sr_prefix = if screen_recording { "✅" } else { "❌" };
let ax_state = if accessibility { "granted" } else { "NOT granted" };
let sr_state = if screen_recording { "granted" } else { "NOT granted" };
let summary = format!(
let mut summary = format!(
"{ax_prefix} Accessibility: {ax_state}.\n{sr_prefix} Screen Recording: {sr_state}."
);
// Flag a preflight/probe disagreement (the false-positive tell).
if screen_recording && !screen_recording_capturable {
summary.push_str(
"\n⚠️ Screen Recording reads granted but a live capture probe failed — \
the grant likely belongs to a different process, not this one.",
);
}
// Make the attribution explicit when answering for the caller (not the daemon).
if is_caller {
summary.push_str(
"\nℹ️ Status reflects the launching app's TCC identity, not the CuaDriver \
daemon (com.trycua.driver). See `source` for details.",
);
}

ToolResult::text(summary)
.with_structured(serde_json::json!({
"accessibility": accessibility,
"screen_recording": screen_recording,
"accessibility": accessibility,
"screen_recording": screen_recording,
"screen_recording_capturable": screen_recording_capturable,
"source": source,
}))
}
}
Loading