177 lines
7.5 KiB
Rust
177 lines
7.5 KiB
Rust
//! 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"));
|
|
}
|