desktop: Fix with AI on scan findings that need a code change (aiFix: true, AI_FIX_RULES). The host (src-tauri/src/ai_fix.rs) runs the owner's local Claude Code headless in the app folder with Read / Edit / Write / Grep / Glob only (Bash, web tools, sub-agents disallowed) and a prompt it builds from the finding's fields; it refuses while the folder has uncommitted files, streams progress, times out after 10 min with a heartbeat, kills the process group on Cancel or exit, and records the run's changed files so Undo restores exactly those; the window shows the diff with Keep / Undo and rescans. Without claude the button copies the prompt (Copy for AI). Only the claude binary, git and kill / taskkill are started there (tests/security.rs); tests/ai_fix.rs covers the prompt, dirty-tree refusal, undo scope, timeout and cancel
This commit is contained in:
parent
992f5bcc0e
commit
c95f2af8ff
24 changed files with 1023 additions and 10 deletions
177
apps/desktop/src-tauri/tests/ai_fix.rs
Normal file
177
apps/desktop/src-tauri/tests/ai_fix.rs
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
//! Fix with AI (src/ai_fix.rs): the prompt, the dirty-tree refusal, Undo restricted to the run's own files, the
|
||||
//! timeout and kill by PID, and that claude only ever gets the file tools.
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use storebound_desktop::ai_fix::*;
|
||||
|
||||
fn finding() -> AiFinding {
|
||||
AiFinding {
|
||||
rule: "ats".into(),
|
||||
guideline: "Apple: App Transport Security".into(),
|
||||
store: "apple".into(),
|
||||
title: "NSAllowsArbitraryLoads is true".into(),
|
||||
evidence: vec![
|
||||
Evidence { file: "ios/App/Info.plist".into(), line: 42, text: Some("NSAllowsArbitraryLoads\ttrue".into()) },
|
||||
Evidence { file: "../outside.txt".into(), line: 1, text: None },
|
||||
],
|
||||
fix: "Remove NSAllowsArbitraryLoads; add exception domains only where needed.".into(),
|
||||
skill: "review-rejections".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("sb-aifix-{name}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn git(dir: &Path, args: &[&str]) {
|
||||
let ok = Command::new("git").current_dir(dir).args(args).env("GIT_AUTHOR_NAME", "T").env("GIT_AUTHOR_EMAIL", "t@example.com").env("GIT_COMMITTER_NAME", "T").env("GIT_COMMITTER_EMAIL", "t@example.com").output().unwrap();
|
||||
assert!(ok.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&ok.stderr));
|
||||
}
|
||||
|
||||
fn repo(name: &str) -> PathBuf {
|
||||
let d = tmp(name);
|
||||
git(&d, &["init", "-q"]);
|
||||
fs::create_dir_all(d.join("app")).unwrap();
|
||||
fs::write(d.join("app/Info.plist"), "a\n").unwrap();
|
||||
fs::write(d.join("app/Other.swift"), "b\n").unwrap();
|
||||
git(&d, &["add", "."]);
|
||||
git(&d, &["commit", "-q", "-m", "init"]);
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_names_the_finding_and_the_limits() {
|
||||
let p = build_prompt(&finding()).unwrap();
|
||||
for s in ["ats", "NSAllowsArbitraryLoads is true", "ios/App/Info.plist:42", "review-rejections", "Change only what this finding needs", "Do not run commands", "commit or push", "Never invent an answer"] {
|
||||
assert!(p.contains(s), "missing {s}");
|
||||
}
|
||||
// evidence outside the folder is left out; control characters are flattened
|
||||
assert!(!p.contains("outside.txt"));
|
||||
assert!(!p.contains('\t'));
|
||||
let mut bad = finding();
|
||||
bad.rule = "ats; rm -rf".into();
|
||||
assert!(build_prompt(&bad).is_err());
|
||||
bad = finding();
|
||||
bad.store = "web".into();
|
||||
assert!(build_prompt(&bad).is_err());
|
||||
bad = finding();
|
||||
bad.title = "x".repeat(5000);
|
||||
assert!(build_prompt(&bad).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_gets_only_the_file_tools() {
|
||||
let a = claude_args("hi");
|
||||
assert_eq!(a[0], "-p");
|
||||
let allowed = &a[a.iter().position(|x| x == "--allowedTools").unwrap() + 1];
|
||||
assert_eq!(allowed, "Read,Edit,Write,Grep,Glob");
|
||||
let denied = &a[a.iter().position(|x| x == "--disallowedTools").unwrap() + 1];
|
||||
for t in ["Bash", "WebFetch", "WebSearch", "Task"] {
|
||||
assert!(denied.split(',').any(|x| x == t), "{t} not denied");
|
||||
}
|
||||
assert!(!a.iter().any(|x| x.contains("dangerously") || x == "bypassPermissions"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_claude_in_the_usual_folders() {
|
||||
let d = tmp("which");
|
||||
let home = d.join("home");
|
||||
fs::create_dir_all(home.join(".local/bin")).unwrap();
|
||||
let dirs = search_path(Some(""), Some(&home));
|
||||
assert!(find_claude_in(&dirs).is_none() || !find_claude_in(&dirs).unwrap().starts_with(&home));
|
||||
let name = if cfg!(windows) { "claude.exe" } else { "claude" };
|
||||
fs::write(home.join(".local/bin").join(name), "").unwrap();
|
||||
assert_eq!(find_claude_in(&dirs).unwrap(), home.join(".local/bin").join(name));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirty_tree_is_reported_and_clean_tree_is_empty() {
|
||||
let d = repo("dirty");
|
||||
let (_, clean) = status(&d.join("app")).unwrap();
|
||||
assert!(clean.is_empty());
|
||||
fs::write(d.join("app/Other.swift"), "changed\n").unwrap();
|
||||
fs::write(d.join("app/new.txt"), "n\n").unwrap();
|
||||
let (_, dirty) = status(&d.join("app")).unwrap();
|
||||
let mut paths: Vec<(String, String)> = dirty.iter().map(|c| (c.path.clone(), c.status.clone())).collect();
|
||||
paths.sort();
|
||||
assert_eq!(paths, vec![("app/Other.swift".into(), "M".into()), ("app/new.txt".into(), "??".into())]);
|
||||
// not a repository: refused with a reason
|
||||
assert!(status(&tmp("nogit")).unwrap_err().contains("git"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_restores_only_the_runs_files() {
|
||||
let d = repo("undo");
|
||||
// the run changed Info.plist and created new.txt; Other.swift was changed later by the owner, not by the run
|
||||
fs::write(d.join("app/Info.plist"), "fixed\n").unwrap();
|
||||
fs::write(d.join("app/new.txt"), "n\n").unwrap();
|
||||
let (_, run) = status(&d.join("app")).unwrap();
|
||||
fs::write(d.join("app/Other.swift"), "owner edit\n").unwrap();
|
||||
let diff_text = diff(&d, &run);
|
||||
assert!(diff_text.contains("+fixed") && diff_text.contains("+++ b/app/new.txt"));
|
||||
let done = undo(&d, &run).unwrap();
|
||||
assert_eq!(done.len(), 2);
|
||||
assert_eq!(fs::read_to_string(d.join("app/Info.plist")).unwrap(), "a\n");
|
||||
assert!(!d.join("app/new.txt").exists());
|
||||
assert_eq!(fs::read_to_string(d.join("app/Other.swift")).unwrap(), "owner edit\n");
|
||||
// paths outside the repository never reach git or the file system
|
||||
let evil = vec![Change { path: "../x".into(), status: "M".into() }, Change { path: "/etc/hosts".into(), status: "??".into() }, Change { path: "-f".into(), status: "M".into() }];
|
||||
assert_eq!(undo_plan(&evil), (vec![], vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_parser_handles_renames_and_new_files() {
|
||||
let z = "R new.txt\0old.txt\0 M a b.txt\0?? n/x.txt\0";
|
||||
assert_eq!(parse_status(z), vec![
|
||||
Change { path: "new.txt".into(), status: "R".into() },
|
||||
Change { path: "a b.txt".into(), status: "M".into() },
|
||||
Change { path: "n/x.txt".into(), status: "??".into() },
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_json_lines_become_progress() {
|
||||
assert_eq!(progress_line(r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Edit","input":{"file_path":"ios/App/Info.plist"}}]}}"#).unwrap(), "Edit ios/App/Info.plist");
|
||||
assert_eq!(progress_line(r#"{"type":"result","result":"Removed NSAllowsArbitraryLoads."}"#).unwrap(), "Removed NSAllowsArbitraryLoads.");
|
||||
assert!(progress_line(r#"{"type":"system","subtype":"init"}"#).is_none());
|
||||
assert!(progress_line("not json").is_none());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn timeout_kills_the_process_group_with_heartbeats() {
|
||||
let mut c = Command::new("sh");
|
||||
c.args(["-c", "sleep 30 & sleep 30; echo never"]);
|
||||
let t = Instant::now();
|
||||
let mut lines = Vec::new();
|
||||
let o = run(c, "t-timeout", Duration::from_millis(800), Duration::from_millis(300), |l| lines.push(l.to_string())).unwrap();
|
||||
assert!(t.elapsed() < Duration::from_secs(10));
|
||||
assert!(o.timed_out);
|
||||
assert!(lines.iter().any(|l| l.starts_with("still working")));
|
||||
assert!(lines.iter().any(|l| l.starts_with("timed out")));
|
||||
assert!(!lines.iter().any(|l| l.contains("never")));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn cancel_kills_by_pid() {
|
||||
let h = std::thread::spawn(|| {
|
||||
let mut c = Command::new("sh");
|
||||
c.args(["-c", "sleep 30"]);
|
||||
run(c, "t-cancel", Duration::from_secs(60), Duration::from_secs(60), |_| {}).unwrap()
|
||||
});
|
||||
let end = Instant::now() + Duration::from_secs(5);
|
||||
while !cancel("t-cancel") {
|
||||
assert!(Instant::now() < end, "never started");
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
let o = h.join().unwrap();
|
||||
assert!(o.cancelled && !o.timed_out);
|
||||
assert!(!cancel("t-cancel"));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue