1use 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#[derive(Debug, Clone, PartialEq, Eq)]
26struct Rule {
27 allow: bool,
28 kind: Option<EffectKind>,
29 method: Option<String>,
30}
31
32#[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
141pub struct CliConfig {
142 pub policy: Option<PathBuf>,
143 pub events: bool,
144}
145
146pub 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
181pub 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
193pub 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#[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 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 "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#[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 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}