//! "Fix with AI": one scan finding handed to the owner's local Claude Code CLI, run headless in the app folder. //! //! The host spawns exactly two programs here, never anything the webview names: the `claude` binary found on PATH //! (or its usual install folders) and `git`. claude runs with `-p` and only the file tools (Read, Edit, Write, Grep, //! Glob); Bash, the web tools, sub-agents and notebooks are disallowed, so it cannot run commands, reach the network //! or push. The prompt is built here from the finding's fields (the webview cannot pass free text). A run needs the app //! folder to be clean in git, so everything changed afterwards is the run's own work: the window shows that diff with //! Keep and Undo, and Undo restores exactly the files this run changed (recorded here, not sent by the webview). //! Every run has a 10 minute timeout with a heartbeat line every 15 s; a cancel or the app's exit kills the process //! group by PID. use std::collections::HashMap; use std::io::{BufRead, BufReader, Read}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{mpsc, Arc, Mutex, OnceLock}; use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; use serde_json::Value; /// The only tools the agent gets. pub const ALLOWED_TOOLS: &str = "Read,Edit,Write,Grep,Glob"; /// Tools it never gets (commands, network, sub-agents, notebooks), even if a settings file allows them. pub const DISALLOWED_TOOLS: &str = "Bash,WebFetch,WebSearch,Task,Agent,NotebookEdit,KillShell,BashOutput"; pub const TIMEOUT: Duration = Duration::from_secs(600); pub const HEARTBEAT: Duration = Duration::from_secs(15); /// The finding as the scan reports it (only these fields; lengths checked). #[derive(Debug, Clone, Deserialize, Serialize)] pub struct AiFinding { pub rule: String, pub guideline: String, pub store: String, pub title: String, #[serde(default)] pub evidence: Vec, pub fix: String, pub skill: String, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Evidence { pub file: String, pub line: u64, #[serde(default)] pub text: Option, } fn short(s: &str, max: usize) -> String { let one: String = s.chars().map(|c| if c.is_control() { ' ' } else { c }).collect(); let t = one.trim(); if t.chars().count() > max { format!("{}...", t.chars().take(max).collect::()) } else { t.to_string() } } fn ident(s: &str) -> bool { !s.is_empty() && s.len() <= 64 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') } /// A path inside the app folder: relative, no `..`, no option-looking start. pub fn safe_rel(p: &str) -> bool { !p.is_empty() && p.len() <= 1000 && !p.starts_with('-') && !p.starts_with('/') && !p.starts_with('\\') && !p.contains(':') && !p.chars().any(|c| c.is_control()) && !p.split(['/', '\\']).any(|seg| seg == "..") } pub fn check_finding(f: &AiFinding) -> Result<(), String> { if !ident(&f.rule) || !ident(&f.skill) { return Err("bad rule or skill".into()); } if f.store != "apple" && f.store != "play" { return Err("bad store".into()); } if f.title.is_empty() || f.title.len() > 2000 || f.fix.len() > 4000 || f.guideline.len() > 200 || f.evidence.len() > 20 { return Err("finding too long".into()); } Ok(()) } /// The prompt for one finding. The same text is what "Copy for AI" copies. pub fn build_prompt(f: &AiFinding) -> Result { check_finding(f)?; let store = if f.store == "apple" { "App Store" } else { "Google Play" }; let mut ev = String::new(); for e in f.evidence.iter().filter(|e| safe_rel(&e.file)).take(5) { ev.push_str(&format!("- {}:{}", e.file, e.line)); if let Some(t) = e.text.as_deref().filter(|t| !t.trim().is_empty()) { ev.push_str(&format!(" ({})", short(t, 200))); } ev.push('\n'); } if ev.is_empty() { ev.push_str("- (no file named: find where it lives first)\n"); } Ok(format!( "Fix one {store} rejection risk that `storebound scan` found in this app folder.\n\n\ Finding ({rule}, {guideline}): {title}\n\ Evidence:\n{ev}\ Suggested fix: {fix}\n\ Background: the storebound skill \"{skill}\" covers this topic.\n\n\ Rules:\n\ - Change only what this finding needs, in as few files and lines as possible. No refactoring, reformatting or unrelated edits.\n\ - Only read and edit files in this folder. Do not run commands, install packages, use the network, commit or push.\n\ - Never invent an answer only the owner can give (export compliance, Data safety, App Privacy, age rating, a purpose the app does not have). If the fix needs one, change nothing and say what the owner must decide.\n\ - Keep the project's style (indentation, quoting, file format).\n\ - When done, say in one to three lines what you changed and why.\n", rule = f.rule, guideline = short(&f.guideline, 200), title = short(&f.title, 600), fix = short(&f.fix, 1500), skill = f.skill, )) } // ---- finding claude ---- /// Folders a GUI app's PATH usually lacks but a terminal install of Claude Code uses. fn extra_dirs(home: Option<&Path>) -> Vec { let mut v = Vec::new(); if let Some(h) = home { v.push(h.join(".claude/local")); v.push(h.join(".local/bin")); v.push(h.join(".npm-global/bin")); v.push(h.join(".bun/bin")); v.push(h.join(".volta/bin")); } v.push(PathBuf::from("/opt/homebrew/bin")); v.push(PathBuf::from("/usr/local/bin")); v } /// The search PATH: the process PATH plus the usual install folders (node for an npm install lives there too). pub fn search_path(path_env: Option<&str>, home: Option<&Path>) -> Vec { let mut v: Vec = path_env.map(|p| std::env::split_paths(p).collect()).unwrap_or_default(); for d in extra_dirs(home) { if !v.contains(&d) { v.push(d); } } v } pub fn find_claude_in(dirs: &[PathBuf]) -> Option { let names: &[&str] = if cfg!(windows) { &["claude.exe", "claude.cmd"] } else { &["claude"] }; dirs.iter().flat_map(|d| names.iter().map(move |n| d.join(n))).find(|p| p.is_file()) } fn home() -> Option { std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from) } pub fn find_claude() -> Option { find_claude_in(&search_path(std::env::var("PATH").ok().as_deref(), home().as_deref())) } /// The arguments claude runs with (the prompt is the only variable part). pub fn claude_args(prompt: &str) -> Vec { [ "-p", prompt, "--output-format", "stream-json", "--verbose", "--permission-mode", "acceptEdits", "--allowedTools", ALLOWED_TOOLS, "--disallowedTools", DISALLOWED_TOOLS, ] .iter() .map(|s| s.to_string()) .collect() } /// The claude process for one prompt in the app folder: only a binary named claude, only `claude_args`. pub fn claude_command(claude: &Path, dir: &Path, prompt: &str) -> Result { let name = claude.file_name().and_then(|n| n.to_str()).unwrap_or_default(); if !matches!(name, "claude" | "claude.exe" | "claude.cmd") || !claude.is_absolute() { return Err("not the claude binary".into()); } let mut cmd = Command::new(claude); cmd.current_dir(dir).args(claude_args(prompt)); if let Ok(p) = std::env::join_paths(search_path(std::env::var("PATH").ok().as_deref(), home().as_deref())) { cmd.env("PATH", p); } Ok(cmd) } // ---- git ---- #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct Change { /// Path relative to the repository root. pub path: String, /// Porcelain status: "M", "D", "A" or "??" (new, untracked). pub status: String, } fn git(dir: &Path, args: &[&str]) -> Result { let out = Command::new("git").current_dir(dir).args(args).stdin(Stdio::null()).output().map_err(|e| format!("git: {e}"))?; if !out.status.success() { return Err(format!("git {}: {}", args.first().unwrap_or(&""), String::from_utf8_lossy(&out.stderr).trim())); } Ok(String::from_utf8_lossy(&out.stdout).to_string()) } /// `git status --porcelain -z` of the app folder (renames reported as their new path). pub fn parse_status(z: &str) -> Vec { let mut v = Vec::new(); let mut parts = z.split('\0').filter(|s| !s.is_empty()); while let Some(e) = parts.next() { if e.len() < 4 { continue; } let (xy, path) = (&e[..2], &e[3..]); if xy.starts_with('R') || xy.starts_with('C') { parts.next(); // the old path } let status = if xy == "??" { "??".to_string() } else { xy.trim().chars().next().map(String::from).unwrap_or_default() }; v.push(Change { path: path.to_string(), status }); } v } /// Repository root and the app folder's dirty files. Err when the folder is not in a git repository. pub fn status(dir: &Path) -> Result<(PathBuf, Vec), String> { let top = git(dir, &["rev-parse", "--show-toplevel"]).map_err(|_| "the app folder is not in a git repository: Undo needs git".to_string())?; let top = PathBuf::from(top.trim()); let z = git(dir, &["status", "--porcelain", "-z", "--untracked-files=all", "--", "."])?; Ok((top, parse_status(&z))) } /// What Undo does with the changes of one run: tracked files are checked out again, files the run created are removed. /// Only paths of that run, only relative ones inside the repository. pub fn undo_plan(run: &[Change]) -> (Vec, Vec) { let mut restore = Vec::new(); let mut remove = Vec::new(); for c in run.iter().filter(|c| safe_rel(&c.path)) { if c.status == "??" || c.status == "A" { remove.push(c.path.clone()); } else { restore.push(c.path.clone()); } } (restore, remove) } pub fn undo(top: &Path, run: &[Change]) -> Result, String> { let (restore, remove) = undo_plan(run); if !restore.is_empty() { let mut a: Vec<&str> = vec!["checkout", "HEAD", "--"]; a.extend(restore.iter().map(String::as_str)); git(top, &a)?; } for p in &remove { let abs = top.join(p); if abs.starts_with(top) && abs.is_file() { std::fs::remove_file(&abs).map_err(|e| format!("{p}: {e}"))?; } } Ok(restore.into_iter().chain(remove).collect()) } /// The diff of a run's changes (new files shown whole), at most 200 kB. pub fn diff(top: &Path, run: &[Change]) -> String { let tracked: Vec<&str> = run.iter().filter(|c| c.status != "??" && safe_rel(&c.path)).map(|c| c.path.as_str()).collect(); let mut out = String::new(); if !tracked.is_empty() { let mut a = vec!["diff", "--no-color", "HEAD", "--"]; a.extend(tracked); out.push_str(&git(top, &a).unwrap_or_default()); } for c in run.iter().filter(|c| c.status == "??" && safe_rel(&c.path)) { let text = std::fs::read_to_string(top.join(&c.path)).unwrap_or_default(); out.push_str(&format!("--- /dev/null\n+++ b/{}\n{}\n", c.path, text.lines().map(|l| format!("+{l}")).collect::>().join("\n"))); } if out.len() > 200_000 { out.truncate(out.char_indices().take_while(|(i, _)| *i < 200_000).last().map(|(i, _)| i).unwrap_or(0)); out.push_str("\n... (cut)"); } out } // ---- running with a timeout, a heartbeat and kill by PID ---- #[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct Outcome { pub code: Option, pub timed_out: bool, pub cancelled: bool, /// The agent's final words (stream-json "result"), else the last output lines. pub summary: String, } /// Running agents by job: PID and the cancel flag. type Running = Mutex)>>; static RUNNING: OnceLock = OnceLock::new(); fn running() -> &'static Running { RUNNING.get_or_init(|| Mutex::new(HashMap::new())) } /// Kills the whole process group (claude starts helpers) of a PID. pub fn kill_tree(pid: u32) { #[cfg(unix)] { // the agent runs in its own process group (pgid = its pid); never signal any other group, least of all ours // (a CI run once died of SIGTERM in the middle of these tests) let pgid = pid as libc::pid_t; // SAFETY: getpgrp has no preconditions; killpg only sends a signal to the group named let own = unsafe { libc::getpgrp() }; if pgid <= 1 || pgid == own { return; } unsafe { libc::killpg(pgid, libc::SIGTERM) }; std::thread::sleep(Duration::from_millis(300)); unsafe { libc::killpg(pgid, libc::SIGKILL) }; } #[cfg(windows)] { let _ = Command::new("taskkill").args(["/T", "/F", "/PID", &pid.to_string()]).status(); } } /// Cancels the run of this job; true when one was running. pub fn cancel(job: &str) -> bool { let hit = running().lock().ok().and_then(|m| m.get(job).cloned()); if let Some((pid, flag)) = hit { flag.store(true, Ordering::SeqCst); kill_tree(pid); true } else { false } } /// App exit: no agent outlives the window. pub fn kill_all() { let all: Vec<(u32, Arc)> = running().lock().map(|m| m.values().cloned().collect()).unwrap_or_default(); for (pid, flag) in all { flag.store(true, Ordering::SeqCst); kill_tree(pid); } } /// One progress line of claude's stream-json output (None: nothing worth showing). pub fn progress_line(line: &str) -> Option { let v: Value = serde_json::from_str(line).ok()?; match v["type"].as_str()? { "assistant" => { let mut out = Vec::new(); for c in v["message"]["content"].as_array()? { match c["type"].as_str() { Some("text") => out.push(short(c["text"].as_str().unwrap_or_default().lines().next().unwrap_or_default(), 200)), Some("tool_use") => { let i = &c["input"]; let what = i["file_path"].as_str().or(i["pattern"].as_str()).or(i["path"].as_str()).unwrap_or_default(); out.push(short(&format!("{} {}", c["name"].as_str().unwrap_or("tool"), what), 200)); } _ => {} } } let s = out.into_iter().filter(|s| !s.is_empty()).collect::>().join(" ยท "); (!s.is_empty()).then_some(s) } "result" => Some(short(v["result"].as_str().unwrap_or("done"), 400)), _ => None, } } /// Runs `cmd` in its own process group, calls `on_line` with each progress line (and a heartbeat while it is quiet), /// kills it on timeout or when `job` is cancelled. pub fn run(mut cmd: Command, job: &str, timeout: Duration, heartbeat: Duration, mut on_line: impl FnMut(&str)) -> Result { #[cfg(unix)] { use std::os::unix::process::CommandExt; cmd.process_group(0); } cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); let mut child = cmd.spawn().map_err(|e| format!("could not start: {e}"))?; let pid = child.id(); let flag = Arc::new(AtomicBool::new(false)); if let Ok(mut m) = running().lock() { m.insert(job.to_string(), (pid, flag.clone())); } let (tx, rx) = mpsc::channel::(); let out = child.stdout.take(); let tx2 = tx.clone(); let reader = std::thread::spawn(move || { if let Some(o) = out { for l in BufReader::new(o).lines().map_while(Result::ok) { let _ = tx2.send(l); } } }); let err = child.stderr.take(); let err_reader = std::thread::spawn(move || { let mut s = String::new(); if let Some(mut e) = err { let _ = e.read_to_string(&mut s); } s }); drop(tx); let start = Instant::now(); let mut last = Instant::now(); let mut summary = String::new(); let mut tail: Vec = Vec::new(); let mut timed_out = false; let code = loop { match rx.recv_timeout(Duration::from_millis(200)) { Ok(l) => { last = Instant::now(); if let Some(p) = progress_line(&l) { if l.contains("\"type\":\"result\"") { summary = p.clone(); } on_line(&p); } else if !l.trim_start().starts_with('{') && !l.trim().is_empty() { on_line(&short(&l, 300)); tail.push(short(&l, 300)); } } Err(mpsc::RecvTimeoutError::Timeout) | Err(mpsc::RecvTimeoutError::Disconnected) => {} } if flag.load(Ordering::SeqCst) { kill_tree(pid); } if start.elapsed() > timeout && !timed_out { timed_out = true; on_line(&format!("timed out after {} s: stopping it", timeout.as_secs())); kill_tree(pid); let _ = child.kill(); } if last.elapsed() >= heartbeat { last = Instant::now(); on_line(&format!("still working ({} s)", start.elapsed().as_secs())); } if let Ok(Some(st)) = child.try_wait() { // drain what is left while let Ok(l) = rx.recv_timeout(Duration::from_millis(50)) { if let Some(p) = progress_line(&l) { if l.contains("\"type\":\"result\"") { summary = p.clone(); } on_line(&p); } } break st.code(); } }; let _ = reader.join(); let errs = err_reader.join().unwrap_or_default(); if let Ok(mut m) = running().lock() { m.remove(job); } if summary.is_empty() { tail.extend(errs.lines().map(|l| short(l, 300)).filter(|l| !l.is_empty())); summary = tail.iter().rev().take(4).rev().cloned().collect::>().join("\n"); } Ok(Outcome { code, timed_out, cancelled: flag.load(Ordering::SeqCst), summary }) } // ---- runs recorded for Undo ---- /// Finished runs by id: repository root and the files the run changed (what Undo may restore). pub type Runs = Mutex)>>; static RUNS: OnceLock = OnceLock::new(); pub fn runs() -> &'static Runs { RUNS.get_or_init(|| Mutex::new(HashMap::new())) }