Skip to main content

rightkit_control/
cli.rs

1//! `rightkit-control` command-line dispatch. Every effectful subcommand is
2//! admitted through one [`EffectGate`] (EFF-001); read-only subcommands
3//! (`trusted`, `frontmost`, `windows`, `ax`, `find`) are not effects.
4//!
5//! Global options (before the subcommand):
6//! - `--policy <file>` (or `RIGHTKIT_CONTROL_POLICY=<file>`): admission policy
7//!   (see [`Policy`]). Without one the gate is allow-all, so behaviour is
8//!   unchanged, but every effect is still validated and settled. A policy
9//!   that cannot be read or parsed is fatal (exit 2); it never falls back to
10//!   allow-all.
11//! - `--events` (or `RIGHTKIT_CONTROL_EVENTS=stderr`): print content-free
12//!   lifecycle events to stderr.
13//!
14//! Exit codes: 0 ok, 1 failed, 2 usage/configuration, 3 denied by admission,
15//! 4 cancelled.
16use std::path::PathBuf;
17use std::sync::Arc;
18
19use crate::admission::{
20    AdmissionDecision, AdmissionHook, Effect, EffectGate, EffectKind, EffectRequest, SettleOutcome,
21};
22use crate::events::{ControlEvent, EventSink};
23
24/// One policy rule: `allow|deny <kind|*> [method|*]`.
25#[derive(Debug, Clone, PartialEq, Eq)]
26struct Rule {
27    allow: bool,
28    kind: Option<EffectKind>,
29    method: Option<String>,
30}
31
32/// A file-configured admission hook. Line format (`#` starts a comment):
33///
34/// ```text
35/// default deny            # decision when no rule matches (required)
36/// allow capture           # any capture
37/// allow input click       # only click among input effects
38/// deny * *                # explicit catch-all
39/// ```
40///
41/// Kinds: `navigate input action script capture process command` or `*`.
42/// The first matching rule wins; otherwise `default` decides. A policy with
43/// no `default` line is rejected, so there is no implicit allow.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct Policy {
46    rules: Vec<Rule>,
47    default_allow: bool,
48}
49
50fn parse_kind(s: &str) -> Option<Option<EffectKind>> {
51    Some(Some(match s {
52        "*" => return Some(None),
53        "navigate" => EffectKind::Navigate,
54        "input" => EffectKind::Input,
55        "action" => EffectKind::Action,
56        "script" => EffectKind::Script,
57        "capture" => EffectKind::Capture,
58        "process" => EffectKind::Process,
59        "command" => EffectKind::Command,
60        _ => return None,
61    }))
62}
63
64fn parse_decision(s: &str) -> Option<bool> {
65    match s {
66        "allow" => Some(true),
67        "deny" => Some(false),
68        _ => None,
69    }
70}
71
72impl Policy {
73    pub fn parse(text: &str) -> Result<Self, String> {
74        let mut rules = Vec::new();
75        let mut default_allow = None;
76        for (n, raw) in text.lines().enumerate() {
77            let line = raw.split('#').next().unwrap_or("").trim();
78            if line.is_empty() {
79                continue;
80            }
81            let at = |m: &str| format!("policy line {}: {m}", n + 1);
82            let words: Vec<&str> = line.split_whitespace().collect();
83            match words.as_slice() {
84                ["default", d] => {
85                    if default_allow.is_some() {
86                        return Err(at("duplicate default"));
87                    }
88                    default_allow =
89                        Some(parse_decision(d).ok_or_else(|| at("default must be allow or deny"))?);
90                }
91                [d, kind, rest @ ..] if rest.len() <= 1 => {
92                    let allow = parse_decision(d)
93                        .ok_or_else(|| at("rule must start with allow or deny"))?;
94                    let kind = parse_kind(kind)
95                        .ok_or_else(|| at(&format!("unknown effect kind {kind:?}")))?;
96                    let method = rest.first().filter(|m| **m != "*").map(|m| m.to_string());
97                    rules.push(Rule {
98                        allow,
99                        kind,
100                        method,
101                    });
102                }
103                _ => {
104                    return Err(at(
105                        "expected `default allow|deny` or `allow|deny <kind|*> [method|*]`",
106                    ))
107                }
108            }
109        }
110        let default_allow = default_allow.ok_or("policy has no `default allow|deny` line")?;
111        Ok(Self {
112            rules,
113            default_allow,
114        })
115    }
116
117    pub fn load(path: &std::path::Path) -> Result<Self, String> {
118        let text =
119            std::fs::read_to_string(path).map_err(|e| format!("policy {}: {e}", path.display()))?;
120        Self::parse(&text).map_err(|e| format!("policy {}: {e}", path.display()))
121    }
122}
123
124impl AdmissionHook for Policy {
125    fn approve(&self, r: &EffectRequest) -> AdmissionDecision {
126        let hit = self.rules.iter().find(|rule| {
127            rule.kind.is_none_or(|k| k == r.kind)
128                && rule.method.as_deref().is_none_or(|m| m == r.method)
129        });
130        let allow = hit.map_or(self.default_allow, |rule| rule.allow);
131        if allow {
132            AdmissionDecision::Allow
133        } else {
134            AdmissionDecision::deny(format!("policy denies {} {}", r.kind.label(), r.method))
135        }
136    }
137}
138
139/// Global CLI configuration.
140#[derive(Debug, Clone, Default, PartialEq, Eq)]
141pub struct CliConfig {
142    pub policy: Option<PathBuf>,
143    pub events: bool,
144}
145
146/// Split leading global options from the subcommand arguments. `env` is
147/// consulted for `RIGHTKIT_CONTROL_POLICY` / `RIGHTKIT_CONTROL_EVENTS`; flags
148/// win over the environment.
149pub fn parse_global(
150    args: &[String],
151    env: impl Fn(&str) -> Option<String>,
152) -> Result<(CliConfig, Vec<String>), String> {
153    let mut cfg = CliConfig {
154        policy: env("RIGHTKIT_CONTROL_POLICY")
155            .filter(|p| !p.is_empty())
156            .map(PathBuf::from),
157        events: env("RIGHTKIT_CONTROL_EVENTS").is_some_and(|v| v == "stderr" || v == "1"),
158    };
159    let mut i = 0;
160    while let Some(a) = args.get(i) {
161        match a.as_str() {
162            "--policy" => {
163                let p = args.get(i + 1).ok_or("--policy needs a file")?;
164                cfg.policy = Some(PathBuf::from(p));
165                i += 2;
166            }
167            "--events" => {
168                cfg.events = true;
169                i += 1;
170            }
171            _ => break,
172        }
173    }
174    Ok((cfg, args[i..].to_vec()))
175}
176
177fn stderr_sink() -> EventSink {
178    Arc::new(|e: &ControlEvent| eprintln!("rightkit-control event: {e:?}"))
179}
180
181/// Build the CLI gate: allow-all unless a policy is configured.
182pub fn gate_for(cfg: &CliConfig) -> Result<Arc<EffectGate>, String> {
183    let mut gate = match &cfg.policy {
184        Some(p) => EffectGate::new(Policy::load(p)?),
185        None => EffectGate::allow_all(),
186    };
187    if cfg.events {
188        gate = gate.with_events(stderr_sink());
189    }
190    Ok(Arc::new(gate))
191}
192
193/// Exit code for a settled effect; prints the refusal/failure to stderr.
194pub fn exit_code<T>(effect: &Effect<T>) -> i32 {
195    let st = &effect.settlement;
196    let reason = st.reason.clone().unwrap_or_default();
197    match st.outcome {
198        SettleOutcome::Ok => 0,
199        SettleOutcome::Denied => {
200            eprintln!("denied: {reason}");
201            3
202        }
203        SettleOutcome::Failed => {
204            eprintln!("{reason}");
205            1
206        }
207        SettleOutcome::Cancelled => {
208            eprintln!("cancelled: {reason}");
209            4
210        }
211    }
212}
213
214pub const USAGE: &str = "usage: rightkit-control [--policy FILE] [--events] trusted|frontmost|launch|windows|ax|find|click|click-text|press|keywin|keyraw|hover|drag|scroll|type|key|shot|serve <pid> ...";
215
216/// Run one subcommand (`args` excludes global options). Returns the process
217/// exit code. Effects go through `gate`; nothing effectful bypasses it.
218#[cfg(target_os = "macos")]
219pub fn run(args: &[String], gate: Arc<EffectGate>) -> i32 {
220    use crate::keys;
221    use crate::mac::{self, Gated};
222
223    let g = Gated::new(gate.clone());
224    let arg = |i: usize| args.get(i).map(String::as_str).unwrap_or("");
225    let num = |i: usize| arg(i).parse::<f64>().unwrap_or(0.0);
226    let pid = arg(1).parse::<i32>().unwrap_or(0);
227    let target = format!("pid:{pid}");
228    let window = |p: i32| mac::main_window(p).ok_or_else(|| format!("no window for pid {p}"));
229    let coords = |from: usize, n: usize| {
230        let v: Vec<String> = (from..from + n).map(|i| num(i).to_string()).collect();
231        format!("[{}]", v.join(","))
232    };
233    let not_found = || "not found".to_string();
234
235    match arg(0) {
236        "trusted" => println!("{}", mac::accessibility_trusted()),
237        "frontmost" => println!(
238            "{} {}",
239            mac::frontmost_pid().unwrap_or(0),
240            mac::frontmost_name().unwrap_or_default()
241        ),
242        "windows" => {
243            for w in mac::windows(pid) {
244                println!(
245                    "id={} layer={} onscreen={} {:.0},{:.0} {:.0}x{:.0} {:?}",
246                    w.id, w.layer, w.onscreen, w.x, w.y, w.w, w.h, w.name
247                );
248            }
249        }
250        "ax" => {
251            for l in mac::ax_dump(pid, arg(2).parse().unwrap_or(12), 20_000) {
252                println!("{l}")
253            }
254        }
255        "find" => match mac::ax_find(pid, arg(2), false) {
256            Some(n) => println!("{} {:?} {:?}", n.role, n.text, n.frame),
257            None => {
258                eprintln!("not found");
259                return 1;
260            }
261        },
262        "launch" => {
263            // launch <App.app> <exe-name> [K=V ...]
264            let env: Vec<(String, String)> = args[3.min(args.len())..]
265                .iter()
266                .filter_map(|kv| kv.split_once('='))
267                .map(|(k, v)| (k.into(), v.into()))
268                .collect();
269            let e = g.launch_hidden(arg(1), arg(2), &env);
270            if let Some(p) = e.value {
271                println!("{p}");
272            }
273            return exit_code(&e);
274        }
275        // Composite commands admit first, then resolve the window/element
276        // inside the executor: a denial does no lookup and posts nothing.
277        "click" => {
278            let (x, y) = (num(2), num(3));
279            let e = g.admit(EffectKind::Input, "click", target, coords(2, 2), || {
280                mac::click(pid, window(pid)?.id, x, y);
281                Ok(())
282            });
283            return exit_code(&e);
284        }
285        "hover" => {
286            let (x, y) = (num(2), num(3));
287            let e = g.admit(EffectKind::Input, "hover", target, coords(2, 2), || {
288                mac::hover(pid, window(pid)?.id, x, y);
289                Ok(())
290            });
291            return exit_code(&e);
292        }
293        "drag" => {
294            let (a, b) = ((num(2), num(3)), (num(4), num(5)));
295            let e = g.admit(EffectKind::Input, "drag", target, coords(2, 4), || {
296                mac::drag(pid, window(pid)?.id, a, b, 20);
297                Ok(())
298            });
299            return exit_code(&e);
300        }
301        "scroll" => {
302            let (x, y, dy) = (num(2), num(3), num(4) as i32);
303            let e = g.admit(EffectKind::Input, "scroll", target, coords(2, 3), || {
304                mac::scroll(pid, window(pid)?.id, x, y, dy);
305                Ok(())
306            });
307            return exit_code(&e);
308        }
309        "click-text" => {
310            let needle = arg(2);
311            let p = format!("{{\"text\":\"{}\"}}", crate::json::esc(needle));
312            let e = g.admit(EffectKind::Input, "click_text", target, p, || {
313                let n = mac::ax_find(pid, needle, false).ok_or_else(not_found)?;
314                mac::click_node(pid, &n)
315            });
316            return exit_code(&e);
317        }
318        "press" => {
319            let needle = arg(2);
320            let p = format!("{{\"text\":\"{}\"}}", crate::json::esc(needle));
321            let e = g.admit(EffectKind::Action, "press", target, p, || {
322                let n = mac::ax_find(pid, needle, false).ok_or_else(not_found)?;
323                Ok(mac::ax_press(&n))
324            });
325            if let Some(v) = e.value {
326                println!("{v}");
327            }
328            return exit_code(&e);
329        }
330        "keywin" => {
331            let attrs: Vec<&str> = args
332                .get(2..)
333                .unwrap_or(&[])
334                .iter()
335                .map(String::as_str)
336                .collect();
337            let e = g.ax_make_key(pid, &attrs);
338            if let Some(v) = &e.value {
339                println!("{v:?}");
340            }
341            return exit_code(&e);
342        }
343        "keyraw" => {
344            let e = g.admit(
345                EffectKind::Action,
346                "key_without_raise",
347                target,
348                String::new(),
349                || Ok(mac::key_without_raise(pid, window(pid)?.id)),
350            );
351            if let Some(v) = e.value {
352                println!("{v}");
353            }
354            return exit_code(&e);
355        }
356        "type" => return exit_code(&g.type_text(pid, arg(2))),
357        "key" => match keys::chord(arg(2)) {
358            Some((k, f)) => return exit_code(&g.key(pid, k, f)),
359            None => {
360                eprintln!("unknown key {}", arg(2));
361                return 1;
362            }
363        },
364        "shot" => {
365            let out = arg(2);
366            let p = format!("{{\"out\":\"{}\"}}", crate::json::esc(out));
367            let e = g.admit(EffectKind::Capture, "capture", target, p, || {
368                mac::capture_window(window(pid)?.id, out)
369                    .then_some(())
370                    .ok_or_else(|| "screencapture failed".to_string())
371            });
372            return exit_code(&e);
373        }
374        "serve" => return serve(args, pid, gate),
375        _ => {
376            eprintln!("{USAGE}");
377            return 2;
378        }
379    }
380    0
381}
382
383/// `serve <pid> <port|0> <credential-file>`: the WebDriver bridge, with its
384/// click / send keys / screenshot admitted through `gate`.
385#[cfg(target_os = "macos")]
386fn serve(args: &[String], pid: i32, gate: Arc<EffectGate>) -> i32 {
387    use crate::webdriver;
388    use std::io::{Read, Write};
389    use std::os::unix::fs::OpenOptionsExt;
390    // The per-launch credential is generated here, written owner-only to the
391    // file (never printed), and required as `Authorization: Bearer` on every request.
392    let arg = |i: usize| args.get(i).map(String::as_str).unwrap_or("");
393    let port: u16 = arg(2).parse().unwrap_or(0);
394    let cred_path = arg(3);
395    if cred_path.is_empty() {
396        eprintln!("usage: rightkit-control serve <pid> <port|0> <credential-file>");
397        return 2;
398    }
399    let mut raw = [0u8; 32];
400    if std::fs::File::open("/dev/urandom")
401        .and_then(|mut f| f.read_exact(&mut raw))
402        .is_err()
403    {
404        eprintln!("no system randomness");
405        return 1;
406    }
407    let cred: String = raw.iter().map(|b| format!("{b:02x}")).collect();
408    let wrote = std::fs::OpenOptions::new()
409        .write(true)
410        .create_new(true)
411        .mode(0o600)
412        .open(cred_path)
413        .and_then(|mut f| f.write_all(cred.as_bytes()));
414    if let Err(e) = wrote {
415        eprintln!("credential file {cred_path}: {e}");
416        return 1;
417    }
418    let listener = match std::net::TcpListener::bind(("127.0.0.1", port)) {
419        Ok(l) => l,
420        Err(e) => {
421            eprintln!("{e}");
422            return 1;
423        }
424    };
425    let bound = listener.local_addr().map(|a| a.port()).unwrap_or(0);
426    let mut s = webdriver::Server::new(webdriver::macos::AxBackend::new(pid)).with_gate(gate);
427    eprintln!(
428        "rightkit-control WebDriver bridge on 127.0.0.1:{bound} -> pid {pid} (bearer credential in {cred_path})"
429    );
430    if let Err(e) = s.serve(listener, &cred) {
431        eprintln!("{e}");
432        return 1;
433    }
434    0
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440
441    fn r(kind: EffectKind, m: &str) -> EffectRequest {
442        EffectRequest::new(kind, m, "pid:1")
443    }
444
445    #[test]
446    fn policy_first_match_then_default() {
447        let p = Policy::parse("# demo\ndefault deny\nallow input click\nallow capture\ndeny * *\n")
448            .unwrap();
449        assert_eq!(
450            p.approve(&r(EffectKind::Input, "click")),
451            AdmissionDecision::Allow
452        );
453        assert!(matches!(
454            p.approve(&r(EffectKind::Input, "type")),
455            AdmissionDecision::Deny { .. }
456        ));
457        assert_eq!(
458            p.approve(&r(EffectKind::Capture, "capture")),
459            AdmissionDecision::Allow
460        );
461        let open = Policy::parse("default allow\ndeny process\n").unwrap();
462        assert!(matches!(
463            open.approve(&r(EffectKind::Process, "launch")),
464            AdmissionDecision::Deny { .. }
465        ));
466        assert_eq!(
467            open.approve(&r(EffectKind::Input, "key")),
468            AdmissionDecision::Allow
469        );
470    }
471
472    #[test]
473    fn policy_rejects_ambiguity() {
474        assert!(Policy::parse("allow input\n").is_err(), "no default");
475        assert!(Policy::parse("default maybe\n").is_err());
476        assert!(Policy::parse("default deny\nallow teleport\n").is_err());
477        assert!(Policy::parse("default deny\ndefault allow\n").is_err());
478    }
479
480    #[test]
481    fn global_options_and_env() {
482        let a: Vec<String> = ["--policy", "p.txt", "--events", "click", "1"]
483            .iter()
484            .map(|s| s.to_string())
485            .collect();
486        let (cfg, rest) = parse_global(&a, |_| None).unwrap();
487        assert_eq!(cfg.policy, Some(PathBuf::from("p.txt")));
488        assert!(cfg.events);
489        assert_eq!(rest, ["click", "1"]);
490        let (cfg, rest) = parse_global(&["type".to_string()], |k| {
491            (k == "RIGHTKIT_CONTROL_POLICY").then(|| "env.txt".to_string())
492        })
493        .unwrap();
494        assert_eq!(cfg.policy, Some(PathBuf::from("env.txt")));
495        assert!(!cfg.events);
496        assert_eq!(rest, ["type"]);
497        assert!(parse_global(&["--policy".to_string()], |_| None).is_err());
498    }
499
500    #[test]
501    fn unreadable_policy_fails_closed() {
502        let cfg = CliConfig {
503            policy: Some(PathBuf::from("/nonexistent/rightkit-control-policy")),
504            events: false,
505        };
506        assert!(gate_for(&cfg).is_err());
507        assert!(gate_for(&CliConfig::default()).is_ok());
508    }
509}