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:
CRTkafa 2026-10-15 06:26:29 +08:00
parent 992f5bcc0e
commit c95f2af8ff
24 changed files with 1023 additions and 10 deletions

View file

@ -53,6 +53,9 @@
"allow-project-reveal",
"allow-project-locate",
"allow-state-reset",
"allow-log-webview"
"allow-log-webview",
"allow-ai-fix-info",
"allow-ai-fix-run",
"allow-ai-fix-undo"
]
}

View file

@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-ai-fix-info"
description = "Enables the ai_fix_info command without any pre-configured scope."
commands.allow = ["ai_fix_info"]
[[permission]]
identifier = "deny-ai-fix-info"
description = "Denies the ai_fix_info command without any pre-configured scope."
commands.deny = ["ai_fix_info"]

View file

@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-ai-fix-run"
description = "Enables the ai_fix_run command without any pre-configured scope."
commands.allow = ["ai_fix_run"]
[[permission]]
identifier = "deny-ai-fix-run"
description = "Denies the ai_fix_run command without any pre-configured scope."
commands.deny = ["ai_fix_run"]

View file

@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-ai-fix-undo"
description = "Enables the ai_fix_undo command without any pre-configured scope."
commands.allow = ["ai_fix_undo"]
[[permission]]
identifier = "deny-ai-fix-undo"
description = "Denies the ai_fix_undo command without any pre-configured scope."
commands.deny = ["ai_fix_undo"]

View file

@ -0,0 +1,463 @@
//! "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)]
{
let _ = Command::new("kill").args(["-TERM", &format!("-{pid}")]).status();
std::thread::sleep(Duration::from_millis(300));
let _ = Command::new("kill").args(["-KILL", &format!("-{pid}")]).status();
}
#[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()))
}

View file

@ -12,6 +12,7 @@ use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
use tauri_plugin_opener::OpenerExt;
use crate::accounts::{self, Account, AccountKind, Secret, SecretStore};
use crate::ai_fix;
use crate::profiles::{self, ProfileStore};
use crate::registry;
use crate::cache::{Cache, Entry};
@ -256,7 +257,7 @@ pub async fn job_cancel(window: WebviewWindow, state: State<'_, AppState>, job:
if !valid_job(&job) {
return Err(Fail::host("bad job id"));
}
let mut hit = state.downloads.cancel(&job);
let mut hit = state.downloads.cancel(&job) | ai_fix::cancel(&job);
if let Some(r) = state.sidecar.current().await {
hit |= r.cancel_job(&job) > 0;
hit |= r.cancel_job(&format!("{job}:p")) > 0;
@ -1489,3 +1490,66 @@ async fn open_folder(app: &AppHandle, dir: &Path) -> Res<()> {
let _ = app.emit_to(policy::MAIN, "deep-link-folder", id);
Ok(())
}
// ---- Fix with AI (src/ai_fix.rs): the owner's local Claude Code CLI on one scan finding ----
fn project_dir(state: &AppState, project_id: &str) -> Res<PathBuf> {
let p = saved(state)?.projects.iter().find(|p| p.id == project_id).cloned().ok_or_else(|| Fail::host("unknown project"))?;
Ok(PathBuf::from(p.path))
}
/// Whether claude is installed, the prompt (also what "Copy for AI" copies) and what is dirty in git.
#[tauri::command]
pub async fn ai_fix_info(window: WebviewWindow, state: State<'_, AppState>, project_id: String, finding: ai_fix::AiFinding) -> Res<Value> {
guard(&window)?;
let prompt = ai_fix::build_prompt(&finding).map_err(Fail::host)?;
let dir = project_dir(&state, &project_id)?;
let claude = ai_fix::find_claude();
let (git, dirty) = match tauri::async_runtime::spawn_blocking(move || ai_fix::status(&dir)).await.map_err(|e| Fail::host(e.to_string()))? {
Ok((_, d)) => (Ok(()), d),
Err(e) => (Err(e), Vec::new()),
};
Ok(json!({ "claude": claude.is_some(), "prompt": prompt, "git": git.is_ok(), "gitError": git.err(), "dirty": dirty }))
}
/// Runs claude on one finding in the app folder (clean git tree only), streams "ai-fix" lines, returns the run's changes.
#[tauri::command]
pub async fn ai_fix_run(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>, project_id: String, finding: ai_fix::AiFinding, job: String) -> Res<Value> {
guard(&window)?;
if !valid_job(&job) {
return Err(Fail::host("bad job id"));
}
let prompt = ai_fix::build_prompt(&finding).map_err(Fail::host)?;
let dir = project_dir(&state, &project_id)?;
let claude = ai_fix::find_claude().ok_or_else(|| Fail::host("Claude Code (claude) was not found on PATH"))?;
let handle = app.clone();
let res = tauri::async_runtime::spawn_blocking(move || -> Result<Value, String> {
let (top, dirty) = ai_fix::status(&dir)?;
if !dirty.is_empty() {
return Ok(json!({ "refused": "dirty", "dirty": dirty }));
}
let cmd = ai_fix::claude_command(&claude, &dir, &prompt)?;
let emit_job = job.clone();
let out = ai_fix::run(cmd, &job, ai_fix::TIMEOUT, ai_fix::HEARTBEAT, |line| {
let _ = handle.emit_to(policy::MAIN, "ai-fix", json!({ "job": emit_job, "line": line }));
})?;
let (_, changes) = ai_fix::status(&dir)?;
let diff = ai_fix::diff(&top, &changes);
let run_id = job.clone();
if let Ok(mut m) = ai_fix::runs().lock() {
m.insert(run_id.clone(), (top, changes.clone()));
}
Ok(json!({ "runId": run_id, "outcome": out, "changes": changes, "diff": diff }))
})
.await
.map_err(|e| Fail::host(e.to_string()))?;
res.map_err(Fail::host)
}
/// Undo of one run: restores exactly the files that run changed (recorded by the host) and removes the ones it created.
#[tauri::command]
pub async fn ai_fix_undo(window: WebviewWindow, run_id: String) -> Res<Vec<String>> {
guard(&window)?;
let rec = ai_fix::runs().lock().ok().and_then(|mut m| m.remove(&run_id)).ok_or_else(|| Fail::host("no such AI fix run"))?;
tauri::async_runtime::spawn_blocking(move || ai_fix::undo(&rec.0, &rec.1)).await.map_err(|e| Fail::host(e.to_string()))?.map_err(Fail::host)
}

View file

@ -46,4 +46,7 @@ pub const COMMANDS: &[&str] = &[
"project_locate",
"state_reset",
"log_webview",
"ai_fix_info",
"ai_fix_run",
"ai_fix_undo",
];

View file

@ -1,6 +1,7 @@
//! storebound desktop: a Tauri shell around the storebound CLI, which runs as a sidecar (`storebound serve --stdio`).
//! The GUI never talks to a store itself; every store call is a storebound command.
pub mod accounts;
pub mod ai_fix;
pub mod cache;
pub mod commands;
pub mod confirm;
@ -256,6 +257,9 @@ pub fn run() {
commands::project_locate,
commands::state_reset,
commands::log_webview,
commands::ai_fix_info,
commands::ai_fix_run,
commands::ai_fix_undo,
])
.build(tauri::generate_context!())
.expect("storebound desktop failed to start");
@ -267,6 +271,7 @@ pub fn run() {
if let Some(s) = app.try_state::<commands::AppState>() {
s.sidecar.shutdown_now();
}
ai_fix::kill_all();
}
_ => {}
});

View 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"));
}

View file

@ -179,6 +179,16 @@ fn sidecar_starts_with_fixed_arguments_from_one_place() {
assert!(src.contains("Command::new(&bin)\n .args(policy::SIDECAR_ARGS)"));
continue;
}
// Fix with AI: only the claude binary (claude_command checks its name), git, and kill / taskkill by PID
if p.ends_with("ai_fix.rs") {
for (i, _) in src.match_indices("Command::new(") {
let arg = &src[i + "Command::new(".len()..];
let ok = ["claude)", "\"git\")", "\"kill\")", "\"taskkill\")"];
assert!(ok.iter().any(|o| arg.starts_with(o)), "unexpected program: {}", &arg[..arg.find(')').unwrap_or(20).min(40)]);
}
assert!(src.contains("if !matches!(name, \"claude\" | \"claude.exe\" | \"claude.cmd\")"));
continue;
}
// other programs: only the download checks (unpack, signature, version banner), with fixed program names
if src.contains("Command::new(") {
assert!(p.ends_with("downloads.rs"), "{} starts a program", p.display());