471 lines
18 KiB
Rust
471 lines
18 KiB
Rust
//! "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<Evidence>,
|
|
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<String>,
|
|
}
|
|
|
|
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::<String>())
|
|
} 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<String, String> {
|
|
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<PathBuf> {
|
|
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<PathBuf> {
|
|
let mut v: Vec<PathBuf> = 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<PathBuf> {
|
|
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<PathBuf> {
|
|
std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from)
|
|
}
|
|
|
|
pub fn find_claude() -> Option<PathBuf> {
|
|
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<String> {
|
|
[
|
|
"-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<Command, String> {
|
|
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<String, String> {
|
|
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<Change> {
|
|
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<Change>), 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<String>, Vec<String>) {
|
|
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<Vec<String>, 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::<Vec<_>>().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<i32>,
|
|
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<HashMap<String, (u32, Arc<AtomicBool>)>>;
|
|
static RUNNING: OnceLock<Running> = 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<AtomicBool>)> = 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<String> {
|
|
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::<Vec<_>>().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<Outcome, String> {
|
|
#[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::<String>();
|
|
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<String> = 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::<Vec<_>>().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<HashMap<String, (PathBuf, Vec<Change>)>>;
|
|
static RUNS: OnceLock<Runs> = OnceLock::new();
|
|
pub fn runs() -> &'static Runs {
|
|
RUNS.get_or_init(|| Mutex::new(HashMap::new()))
|
|
}
|