use crate::rules::{Confirmed, Context, Evidence, Examine, Finding, Platform, Rule, Stance, Trend};
use crate::shell::{Parsed, Simple};
pub const RULE: Rule = Rule {
id: "gui-open-unbounded",
default_stance: Stance::Advise,
max_stance: Stance::Deny,
evidence: Evidence {
per_1000: 0.0,
measured: "2026-10-14",
trend: Trend::Rare,
},
examine: Examine::Legacy(examine),
confirm: Some(confirm),
};
fn examine(parsed: &Parsed) -> Option<Finding> {
let cmd = parsed
.judgeable()
.find(|c| c.program() == Some("open") && !c.wrapped_by("timeout") && launches_an_app(c))?;
Some(Finding {
reason: "`open` of an app can block with nothing to end it: on 2026-10-14 `open -a \
Simulator` was measured to hang twice (63 and 10 minutes), `-g` and `-j` did \
not help, and in a background call the chain after it never ran, the output \
stayed empty and no completion notice came."
.to_string(),
remedy: "Run `timeout 15 open -a <App>` as a Bash call of its own, or skip the GUI: \
`xcrun simctl boot`, `simctl io <udid> screenshot` and `simctl openurl` work \
headless."
.to_string(),
span: cmd.at..cmd.end,
})
}
fn launches_an_app(cmd: &Simple) -> bool {
let mut args = cmd.args().iter();
while let Some(w) = args.next() {
if w.quoted {
if is_app_path(&w.text) {
return true;
}
continue;
}
let t = w.text.as_str();
match t {
"--args" => return false,
"--reveal" => return false,
_ if t.starts_with("--") => {}
_ if t.starts_with('-') && t.len() > 1 => {
let cluster = &t[1..];
if cluster.contains('R') {
return false;
}
match cluster.chars().last() {
Some('a') | Some('b') => return true,
Some('f') | Some('t') | Some('e') | Some('F') | Some('s') => {
args.next();
}
_ => {}
}
}
_ if is_app_path(t) => return true,
_ => {}
}
}
false
}
fn is_app_path(word: &str) -> bool {
word.trim_end_matches('/').ends_with(".app")
}
fn confirm(ctx: &Context, _f: &Finding) -> Confirmed {
if matches!(
ctx.platform,
Some(Platform::Linux | Platform::Windows | Platform::Other)
) {
return Confirmed::No("`open` is not an app launcher on this platform");
}
if !ctx.background {
return Confirmed::No("in the foreground the tool's own timeout bounds the launch");
}
Confirmed::Yes
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shell::lex;
fn fires(command: &str) -> bool {
examine(&lex(command)).is_some()
}
#[test]
fn an_app_launch_is_examined() {
assert!(fires("open -a Simulator"));
assert!(fires("open -g -a Simulator"));
assert!(fires("open -ja Simulator"));
assert!(fires("open -a \"Google Chrome\" https://example.com"));
assert!(fires("open /Applications/Xcode.app"));
assert!(fires("open -b com.apple.iphonesimulator"));
assert!(fires("cd x && open -a Simulator; yarn ios"));
}
#[test]
fn a_document_a_url_or_a_reveal_is_not() {
assert!(!fires("open README.md"));
assert!(!fires("open https://example.com"));
assert!(!fires("open ."));
assert!(!fires("open -R /Applications/Xcode.app"));
assert!(!fires("echo open -a Simulator"));
assert!(!fires("git commit -m \"open -a Simulator hangs\""));
}
#[test]
fn timeout_bounds_its_own_clause_only() {
assert!(!fires("timeout 15 open -a Simulator"));
assert!(!fires("timeout -k 5 15 open -a Simulator && echo ok"));
assert!(fires("timeout 15 open -a Simulator; open -a Safari"));
assert!(fires("timeout 15 open -a Simulator && open -g -a Safari"));
}
fn confirmed_on(platform: Option<Platform>, background: bool) -> Confirmed {
let parsed = lex("open -a Simulator");
let ctx = Context {
cwd: std::path::Path::new("/"),
parsed: &parsed,
background,
timeout_ms: None,
tool_use_id: "",
transcript: None,
agent_transcript: None,
command: "open -a Simulator",
platform,
};
let finding = Finding {
reason: String::new(),
remedy: String::new(),
span: 0..0,
};
confirm(&ctx, &finding)
}
#[test]
fn a_background_launch_is_confirmed_only_where_open_is_macos() {
assert!(matches!(
confirmed_on(Some(Platform::MacOs), true),
Confirmed::Yes
));
assert!(matches!(
confirmed_on(Some(Platform::Linux), true),
Confirmed::No(_)
));
assert!(matches!(
confirmed_on(Some(Platform::Windows), true),
Confirmed::No(_)
));
assert!(matches!(
confirmed_on(Some(Platform::Other), true),
Confirmed::No(_)
));
assert!(matches!(confirmed_on(None, true), Confirmed::Yes));
}
#[test]
fn the_apps_own_arguments_are_not_ours() {
assert!(!fires("open ./notes.txt --args -a"));
}
}