From c95f2af8ff4307930bbda5bcf901799bffb4b4f5 Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 15 Oct 2026 06:26:29 +0800 Subject: [PATCH] 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 --- CHANGELOG.md | 5 + apps/desktop/src-tauri/capabilities/main.json | 5 +- .../autogenerated/ai_fix_info.toml | 11 + .../permissions/autogenerated/ai_fix_run.toml | 11 + .../autogenerated/ai_fix_undo.toml | 11 + apps/desktop/src-tauri/src/ai_fix.rs | 463 ++++++++++++++++++ apps/desktop/src-tauri/src/commands.rs | 66 ++- apps/desktop/src-tauri/src/commands_list.rs | 3 + apps/desktop/src-tauri/src/lib.rs | 5 + apps/desktop/src-tauri/tests/ai_fix.rs | 177 +++++++ apps/desktop/src-tauri/tests/security.rs | 10 + apps/desktop/src/api.ts | 13 + apps/desktop/src/dev/fixtures.ts | 29 +- apps/desktop/src/i18n/en.ts | 19 + apps/desktop/src/i18n/tr.ts | 19 + apps/desktop/src/lib/model.ts | 2 +- apps/desktop/src/screens/tabs/Checks.tsx | 27 +- apps/desktop/src/styles.css | 6 + apps/desktop/src/ui/AiFix.tsx | 115 +++++ cli/lib/fix-actions.ts | 9 + cli/lib/scan/index.ts | 3 +- cli/lib/scan/rules.ts | 2 + docs/desktop.md | 9 + tests/fix-actions.test.ts | 13 +- 24 files changed, 1023 insertions(+), 10 deletions(-) create mode 100644 apps/desktop/src-tauri/permissions/autogenerated/ai_fix_info.toml create mode 100644 apps/desktop/src-tauri/permissions/autogenerated/ai_fix_run.toml create mode 100644 apps/desktop/src-tauri/permissions/autogenerated/ai_fix_undo.toml create mode 100644 apps/desktop/src-tauri/src/ai_fix.rs create mode 100644 apps/desktop/src-tauri/tests/ai_fix.rs create mode 100644 apps/desktop/src/ui/AiFix.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 93c2292..994bc93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- **Desktop: Fix with AI** on scan findings that need a code change (NSAllowsArbitraryLoads in Info.plist, a missing + foregroundServiceType, purpose strings, target SDK, ...). The app runs your local Claude Code headless in the app + folder with file tools only (no commands, no network, no git push), streams its progress, stops after 10 minutes or + on Cancel, and shows the diff with Keep and Undo (exactly the files it changed), then rescans. It refuses while the + folder has uncommitted files. Without Claude Code installed the button copies the prompt instead (Copy for AI). - **Desktop: a Fix button on findings** that storebound can fix itself. Scan and validate findings carry a machine-readable `action` (`--json`); the Checks tab runs it as a write with its dry run in the native dialog before anything changes. privacy-labels writes the detected answers into the config (`scan --write-only`, the diff --git a/apps/desktop/src-tauri/capabilities/main.json b/apps/desktop/src-tauri/capabilities/main.json index 4bebd39..89a4039 100644 --- a/apps/desktop/src-tauri/capabilities/main.json +++ b/apps/desktop/src-tauri/capabilities/main.json @@ -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" ] } diff --git a/apps/desktop/src-tauri/permissions/autogenerated/ai_fix_info.toml b/apps/desktop/src-tauri/permissions/autogenerated/ai_fix_info.toml new file mode 100644 index 0000000..c8a8488 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/ai_fix_info.toml @@ -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"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/ai_fix_run.toml b/apps/desktop/src-tauri/permissions/autogenerated/ai_fix_run.toml new file mode 100644 index 0000000..e973a6e --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/ai_fix_run.toml @@ -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"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/ai_fix_undo.toml b/apps/desktop/src-tauri/permissions/autogenerated/ai_fix_undo.toml new file mode 100644 index 0000000..f58ef79 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/ai_fix_undo.toml @@ -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"] diff --git a/apps/desktop/src-tauri/src/ai_fix.rs b/apps/desktop/src-tauri/src/ai_fix.rs new file mode 100644 index 0000000..72904b7 --- /dev/null +++ b/apps/desktop/src-tauri/src/ai_fix.rs @@ -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, + 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)] + { + 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)> = 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())) +} diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index e80bfc7..d22ad97 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -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 { + 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 { + 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 { + 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 { + 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> { + 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) +} diff --git a/apps/desktop/src-tauri/src/commands_list.rs b/apps/desktop/src-tauri/src/commands_list.rs index 9ccdf1c..3846067 100644 --- a/apps/desktop/src-tauri/src/commands_list.rs +++ b/apps/desktop/src-tauri/src/commands_list.rs @@ -46,4 +46,7 @@ pub const COMMANDS: &[&str] = &[ "project_locate", "state_reset", "log_webview", + "ai_fix_info", + "ai_fix_run", + "ai_fix_undo", ]; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 1bf574e..c16ea24 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -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::() { s.sidecar.shutdown_now(); } + ai_fix::kill_all(); } _ => {} }); diff --git a/apps/desktop/src-tauri/tests/ai_fix.rs b/apps/desktop/src-tauri/tests/ai_fix.rs new file mode 100644 index 0000000..2231869 --- /dev/null +++ b/apps/desktop/src-tauri/tests/ai_fix.rs @@ -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")); +} diff --git a/apps/desktop/src-tauri/tests/security.rs b/apps/desktop/src-tauri/tests/security.rs index 68697fe..9b780bd 100644 --- a/apps/desktop/src-tauri/tests/security.rs +++ b/apps/desktop/src-tauri/tests/security.rs @@ -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()); diff --git a/apps/desktop/src/api.ts b/apps/desktop/src/api.ts index 2ec3a68..68cce68 100644 --- a/apps/desktop/src/api.ts +++ b/apps/desktop/src/api.ts @@ -69,6 +69,13 @@ async function listen(event: string, cb: (payload: T) => void): Promise(event, e => cb(e.payload)); } +/** The finding fields the host builds the Fix with AI prompt from. */ +export interface AiFinding { rule: string; guideline: string; store: 'apple' | 'play'; title: string; evidence: { file: string; line: number; text?: string }[]; fix: string; skill: string } +export interface AiChange { path: string; status: string } +export interface AiFixInfo { claude: boolean; prompt: string; git: boolean; gitError: string | null; dirty: AiChange[] } +export type AiFixRun = { refused: 'dirty'; dirty: AiChange[] } + | { refused?: undefined; runId: string; outcome: { code: number | null; timed_out: boolean; cancelled: boolean; summary: string }; changes: AiChange[]; diff: string }; + let jobSeq = 0; /** A fresh job id for one background run (progress events and cancel refer to it). */ export const newJob = (kind: string): string => `${kind.replace(/[^A-Za-z0-9_.-]/g, '-').slice(0, 40)}-${Date.now().toString(36)}-${++jobSeq}`; @@ -84,6 +91,10 @@ export const api = { invoke | null>('cache_get', { method, projectId, args }), clearCache: () => invoke('cache_clear'), cancel: (job: string) => invoke('job_cancel', { job }), + /** Fix with AI (src-tauri/src/ai_fix.rs): claude installed?, the prompt (also for Copy for AI), the dirty files. */ + aiFixInfo: (projectId: string, finding: AiFinding) => invoke('ai_fix_info', { projectId, finding }), + aiFixRun: (projectId: string, finding: AiFinding, job: string) => invoke('ai_fix_run', { projectId, finding, job }), + aiFixUndo: (runId: string) => invoke('ai_fix_undo', { runId }), /** Dry run, native confirm dialog with that dry run, then the real run with a host-minted token. */ writeWithConfirm: (method: string, projectId: string, args: Record = {}, job?: string) => invoke('write_with_confirm', { method, projectId, args, job: job ?? null }), @@ -155,6 +166,8 @@ export const reads = { export interface ProgressEvent extends ProgressParams { job?: string } export const onProgress = (cb: (p: ProgressEvent) => void): Promise => listen<{ params: ProgressParams; job?: string }>('sidecar-progress', e => cb({ ...e.params, job: e.job })); +/** One progress line of a Fix with AI run. */ +export const onAiFix = (cb: (e: { job: string; line: string }) => void): Promise => listen<{ job: string; line: string }>('ai-fix', cb); export const onDownload = (cb: (e: DownloadEvent) => void): Promise => listen('tool-download', cb); export const onDeepLink = (cb: (screen: string) => void): Promise => listen('deep-link', cb); /** storebound://open?path=... selected or added an app folder (its project id). */ diff --git a/apps/desktop/src/dev/fixtures.ts b/apps/desktop/src/dev/fixtures.ts index 7b56da5..acc6a3c 100644 --- a/apps/desktop/src/dev/fixtures.ts +++ b/apps/desktop/src/dev/fixtures.ts @@ -359,15 +359,19 @@ const SCAN_PRISM: ScanResult['findings'] = [ { rule: 'att', guideline: '5.1.2(i)', store: 'apple', severity: 'high', title: 'Tracking-capable code without App Tracking Transparency (AppLovin MAX, Unity Ads)', evidence: [{ file: 'Packages/manifest.json', line: 4, text: 'com.applovin.mediation.ads 8.1.0' }], fix: 'Add NSUserTrackingUsageDescription and request ATT before the SDKs start, or configure non-personalized ads and answer "no tracking".', skill: 'ads-mediation-checks', ref: 'https://developer.apple.com/app-store/review/guidelines/' }, { rule: 'play-target-sdk', guideline: 'Play: Target API level', store: 'play', severity: 'blocker', title: 'targetSdk 35 is below 36, which new apps and updates need since 2026-08-31', - evidence: [{ file: 'ProjectSettings/ProjectSettings.asset', line: 212, text: 'targetSdk 35' }], fix: 'Raise targetSdk to 36, test, upload a new AAB.', skill: 'play-first-release', ref: 'https://developer.android.com/google/play/requirements/target-sdk' }, + evidence: [{ file: 'ProjectSettings/ProjectSettings.asset', line: 212, text: 'targetSdk 35' }], fix: 'Raise targetSdk to 36, test, upload a new AAB.', skill: 'play-first-release', ref: 'https://developer.android.com/google/play/requirements/target-sdk', aiFix: true }, { rule: 'restore-purchases', guideline: '3.1.1', store: 'apple', severity: 'high', title: 'Restorable purchases (non-consumables or subscriptions) but no Restore Purchases path was found', evidence: [{ file: 'storebound.config.ts', line: 41, text: 'no_ads (non_consumable)' }], fix: 'Add a visible "Restore Purchases" button on the store screen or in Settings.', skill: 'store-iap-setup', ref: 'https://developer.apple.com/app-store/review/guidelines/' }, { rule: 'demo-account', guideline: '2.1(a)', store: 'apple', severity: 'blocker', title: 'Sign-in is required but there is no demo account for App Review', evidence: [{ file: 'storebound.config.ts', line: 17, text: 'reviewAccess: { signInRequired: true }' }], fix: 'Add the App Review demo account in .storebound/ (review-access skill).', skill: 'review-access', ref: 'https://developer.apple.com/app-store/review/guidelines/', accepted: 'leaderboards only; the game plays without signing in' }, + // code changes: Fix with AI (the owner's Claude Code) or Copy for AI + { rule: 'ats', guideline: '2.5.1 / ATS', store: 'apple', severity: 'medium', title: 'NSAllowsArbitraryLoads is true: App Review asks for a reason', + evidence: [{ file: 'ios/Unity-iPhone/Info.plist', line: 48, text: 'NSAllowsArbitraryLoads' }], fix: 'Remove NSAllowsArbitraryLoads (or limit it with NSExceptionDomains) unless the app must load arbitrary http content.', skill: 'review-rejections', ref: 'https://developer.apple.com/documentation/bundleresources/information-property-list/nsapptransportsecurity', aiFix: true }, // a deterministic fix (scan.write: the detected answers into the config) { rule: 'privacy-labels', guideline: 'Play: User Data (Data safety)', store: 'play', severity: 'medium', title: 'Data safety likely misses PSL_DEVICE_ID: an SDK collects it, dataSafety does not declare it', evidence: [{ file: 'Packages/manifest.json', line: 4, text: 'com.applovin.mediation.ads 8.1.0' }], fix: 'Confirm with the SDK\'s Data safety guidance, add it with `storebound scan --write` (or an edit of the config), then `storebound play data-safety`.', skill: 'store-declarations', ref: 'https://support.google.com/googleplay/android-developer/answer/10787469', action: { method: 'scan.write', args: {} } }, ]; +const aiRuns = new Map(); const SCAN: Record = { 'p-prism': { stacks: ['unity'], sdks: [{ id: 'applovin', name: 'AppLovin MAX', category: 'ads', tracking: true }, { id: 'unity-ads', name: 'Unity Ads', category: 'ads', tracking: true }, { id: 'gameanalytics', name: 'GameAnalytics', category: 'analytics', tracking: false }], findings: SCAN_PRISM, counts: scanCounts(SCAN_PRISM), @@ -578,6 +582,29 @@ async function answer(cmd: string, a: Record): Promise return fixtureRead(method, id, ((a.params as { args?: Record } | null)?.args) ?? {}); } case 'job_cancel': cancelled.add(a.job as string); return true; + case 'ai_fix_info': { + const f = a.finding as { rule: string; evidence: { file: string; line: number }[] }; + return { claude: !new URLSearchParams(location.search).has('noclaude'), git: true, gitError: null, dirty: new URLSearchParams(location.search).has('dirty') ? [{ path: 'ios/Unity-iPhone/Info.plist', status: 'M' }] : [], + prompt: `Fix one App Store rejection risk that \`storebound scan\` found in this app folder.\n\nFinding (${f.rule}): ...\nEvidence:\n${f.evidence.map(e => `- ${e.file}:${e.line}`).join('\n')}\n` }; + } + case 'ai_fix_run': { + const job = a.job as string, f = a.finding as { rule: string; title: string; evidence: { file: string }[] }; + for (const line of ['Read ios/Unity-iPhone/Info.plist', 'Grep NSAllowsArbitraryLoads', 'Edit ios/Unity-iPhone/Info.plist', 'Removed NSAllowsArbitraryLoads; no http URLs are loaded.']) { + await sleep(400); if (cancelled.has(job)) break; emit('ai-fix', { job, line }); + } + if (cancelled.has(job)) return { runId: job, outcome: { code: null, timed_out: false, cancelled: true, summary: '' }, changes: [], diff: '' }; + const file = f.evidence[0]?.file ?? 'ios/Unity-iPhone/Info.plist'; + const i = SCAN_PRISM.findIndex(x => x.rule === f.rule && x.title === f.title); + const removed = i >= 0 ? SCAN_PRISM.splice(i, 1)[0] : undefined; + if (removed) { aiRuns.set(job, removed); SCAN['p-prism']!.counts = scanCounts(SCAN_PRISM); } + return { runId: job, outcome: { code: 0, timed_out: false, cancelled: false, summary: 'Removed NSAllowsArbitraryLoads; no http URLs are loaded.' }, changes: [{ path: file, status: 'M' }], + diff: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -45,8 +45,6 @@\n NSAppTransportSecurity\n \n- NSAllowsArbitraryLoads\n- \n \n` }; + } + case 'ai_fix_undo': { + const r = aiRuns.get(a.runId as string); + if (r) { SCAN_PRISM.push(r); SCAN['p-prism']!.counts = scanCounts(SCAN_PRISM); aiRuns.delete(a.runId as string); } + return ['ios/Unity-iPhone/Info.plist']; + } case 'write_with_confirm': { const method = a.method as string, args = (a.args ?? {}) as Record; if (method === 'console.fill') { diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 309b810..126de0b 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -444,6 +444,25 @@ export const en = { 'checks.fixHint.play.listing': 'Pushes the listing file to Google Play (text only, nothing is published). You see the dry run first.', 'checks.fixHint.apple.listing': 'Pushes the listing file and copyright to the App Store version (text only, nothing is submitted). You see the dry run first.', 'checks.fixHint.apple.review_details': 'Sends the App Review contact and notes from the config (nothing is submitted). You see the dry run first.', + 'ai.run': 'Fix with AI', + 'ai.runHint': 'Runs Claude Code on this machine in the app folder with file tools only (read and edit; no commands, no network, no git push). You see the diff and can undo it.', + 'ai.copy': 'Copy for AI', + 'ai.copyHint': 'Claude Code (claude) is not installed: copies a prompt for this finding to paste into your AI agent.', + 'ai.starting': 'Starting Claude Code...', + 'ai.running': 'Claude Code is fixing this finding (at most 10 minutes).', + 'ai.dirty': 'Uncommitted files in the app folder ({count}): commit or stash them first, so Undo can restore exactly what the AI changes.', + 'ai.noGit': 'The app folder is not in a git repository: Fix with AI needs git for the diff and Undo.', + 'ai.cancelled': 'Cancelled', + 'ai.timedOut': 'Stopped after 10 minutes', + 'ai.noChanges': 'No files changed', + 'ai.changed': 'Changed files: {count}', + 'ai.stillThere': 'the scan still reports it', + 'ai.gone': 'the scan no longer reports it', + 'ai.diff': 'Changes', + 'ai.keep': 'Keep', + 'ai.undo': 'Undo', + 'ai.undone': 'Undone. Restored files: {count}', + 'ai.failed': 'Fix with AI failed: {msg}', 'checks.accepted': 'Accepted: {reason}', 'checks.sev.blocker': 'Blocker', 'checks.sev.high': 'High', diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index 1cc92ed..9a27102 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -446,6 +446,25 @@ export const tr: Record = { 'checks.fixHint.play.listing': "Mağaza metni dosyasını Google Play'e gönderir (yalnız metin, yayınlanmaz). Önce deneme çalıştırmasını görürsün.", 'checks.fixHint.apple.listing': 'Mağaza metnini ve telif hakkını App Store sürümüne gönderir (yalnız metin, incelemeye gönderilmez). Önce deneme çalıştırmasını görürsün.', 'checks.fixHint.apple.review_details': 'App Review iletişim bilgisini ve notları ayardan gönderir (incelemeye gönderilmez). Önce deneme çalıştırmasını görürsün.', + 'ai.run': 'Yapay zekâyla düzelt', + 'ai.runHint': "Claude Code'u bu bilgisayarda, uygulama klasöründe yalnız dosya araçlarıyla çalıştırır (okuma ve düzenleme; komut, ağ, git push yok). Farkı görür, geri alabilirsin.", + 'ai.copy': 'Yapay zekâ için kopyala', + 'ai.copyHint': 'Claude Code (claude) kurulu değil: bu bulgu için bir istemi yapay zekâ ajanına yapıştırmak üzere kopyalar.', + 'ai.starting': 'Claude Code başlıyor...', + 'ai.running': 'Claude Code bu bulguyu düzeltiyor (en çok 10 dakika).', + 'ai.dirty': 'Uygulama klasöründe commit edilmemiş {count} dosya var: önce commit et ya da stash yap ki Geri al tam olarak yapay zekânın değiştirdiğini geri yüklesin.', + 'ai.noGit': 'Uygulama klasörü bir git deposunda değil: Yapay zekâyla düzelt fark ve Geri al için git ister.', + 'ai.cancelled': 'İptal edildi', + 'ai.timedOut': '10 dakika sonra durduruldu', + 'ai.noChanges': 'Hiçbir dosya değişmedi', + 'ai.changed': 'Değişen dosya: {count}', + 'ai.stillThere': 'tarama hâlâ bildiriyor', + 'ai.gone': 'tarama artık bildirmiyor', + 'ai.diff': 'Değişiklikler', + 'ai.keep': 'Tut', + 'ai.undo': 'Geri al', + 'ai.undone': 'Geri alındı. Geri yüklenen dosya: {count}', + 'ai.failed': 'Yapay zekâyla düzeltme başarısız: {msg}', 'checks.accepted': 'Kabul edildi: {reason}', 'checks.sev.blocker': 'Engel', 'checks.sev.high': 'Yüksek', diff --git a/apps/desktop/src/lib/model.ts b/apps/desktop/src/lib/model.ts index 82da3e1..6efd7ef 100644 --- a/apps/desktop/src/lib/model.ts +++ b/apps/desktop/src/lib/model.ts @@ -47,7 +47,7 @@ export interface ValidateResult { blockers: number; findings: Finding[] } export type ScanSeverity = 'blocker' | 'high' | 'medium' | 'low'; export interface ScanFinding { rule: string; guideline: string; store: 'apple' | 'play'; severity: ScanSeverity; title: string; - evidence: { file: string; line: number; text?: string }[]; fix: string; skill: string; ref: string; accepted?: string; action?: FixAction; + evidence: { file: string; line: number; text?: string }[]; fix: string; skill: string; ref: string; accepted?: string; action?: FixAction; aiFix?: boolean; } export interface ScanResult { stacks: string[]; diff --git a/apps/desktop/src/screens/tabs/Checks.tsx b/apps/desktop/src/screens/tabs/Checks.tsx index 2dc5b01..80177ea 100644 --- a/apps/desktop/src/screens/tabs/Checks.tsx +++ b/apps/desktop/src/screens/tabs/Checks.tsx @@ -3,9 +3,12 @@ import type { Project } from '../../api'; import { t } from '../../i18n'; import { ago } from '../../lib/format'; -import type { Finding, FixAction, ScanResult, ScanSeverity, ValidateResult } from '../../lib/model'; +import type { Finding, FixAction, ScanFinding, ScanResult, ScanSeverity, ValidateResult } from '../../lib/model'; import { useData, useRead, useWrite } from '../../store'; import { WriteButton } from '../../ui/WriteButton'; +import { AiFix, aiFinding } from '../../ui/AiFix'; +import { api } from '../../api'; +import { useEffect, useState } from 'react'; import { Icon } from '../../ui/icons'; import { Card, Chip, Empty, ErrorNotice, ReadControl, Skeleton } from '../../ui/kit'; import { exportName, validateMd } from '../../lib/exports'; @@ -33,6 +36,20 @@ function ScanCard({ project }: { project: Project }) { const s = useRead(project.id, 'scan', { network: false, title: t('checks.scan') }); const w = useWrite(project.id); const findings = Array.isArray(s.data?.findings) ? s.data!.findings : []; + // Fix with AI: is claude installed (else Copy for AI); one run at a time + const [claude, setClaude] = useState(null); + const [aiBusy, setAiBusy] = useState(false); + // the finding an AI fix works on stays in the list (its diff, Keep / Undo) even after the scan stops reporting it + const [aiActive, setAiActive] = useState(null); + const same = (a: ScanFinding, b: ScanFinding) => a.store === b.store && a.rule === b.rule && a.title === b.title; + const rows = aiActive && !findings.some(x => same(x, aiActive)) ? [...findings, aiActive] : findings; + const seen = new Map(); + const keyOf = (f: ScanFinding) => { const k = `${f.store}|${f.rule}|${f.title}`; const n = (seen.get(k) ?? 0) + 1; seen.set(k, n); return `${k}|${n}`; }; + const firstAi = findings.find(f => f.aiFix && !f.accepted && !fixable(f.action)); + useEffect(() => { + if (!firstAi || claude !== null) return; + api.aiFixInfo(project.id, aiFinding(firstAi)).then(i => setClaude(i.claude), () => setClaude(false)); + }, [project.id, firstAi, claude]); const sdks = Array.isArray(s.data?.sdks) ? s.data!.sdks : []; const asks = (Array.isArray(s.data?.declarations?.items) ? s.data!.declarations.items : []).filter(x => x.status === 'ask').length; return ( @@ -40,19 +57,21 @@ function ScanCard({ project }: { project: Project }) { actions={}> {s.error &&
} {!s.data ? (s.loading && !s.error ?
: null) - : !findings.length ? {t('checks.scanCleanText')} : ( + : !rows.length ? {t('checks.scanCleanText')} : (
- {findings.map((f, i) => { + {rows.map(f => { const [tone, icon] = f.accepted ? ['ok', 'check'] as const : SEV[f.severity] ?? SEV.low; const ev = Array.isArray(f.evidence) ? f.evidence : []; return ( -
+
{f.guideline} · {f.title} {ev.slice(0, 2).map((e, j) => {e.file}:{e.line}{e.text ? ` ${e.text}` : ''})} {f.accepted ? {t('checks.accepted', { reason: f.accepted })} : {f.fix} {f.skill}} + {!f.accepted && f.aiFix && !fixable(f.action) && { s.refresh(); }} + stillThere={findings.some(x => same(x, f))} onActive={on => setAiActive(on ? f : null)} />}
{!f.accepted && fixable(f.action) && } {t(f.accepted ? 'checks.sev.accepted' : `checks.sev.${f.severity}` as Parameters[0])} diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 6871caf..fadfe77 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -1008,3 +1008,9 @@ button.ck.ck-pick[aria-checked="true"] { border-color: var(--accent-solid); box- .ed-scenes li { display: grid; grid-template-columns: 28px minmax(80px, 140px) minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 10px 14px; border-top: 1px solid var(--line); } .ed-scene-frame { min-width: 0; } .rot90 { transform: rotate(90deg); } + +/* Fix with AI (ui/AiFix.tsx): the button under a finding, its run log, the diff with Keep / Undo */ +.ai-actions { display: flex; gap: 8px; margin-top: 6px; } +.ai-panel { display: grid; grid-template-columns: minmax(0, 1fr); gap: 6px; margin-top: 8px; } +.ai-panel .row { display: flex; gap: 8px; flex-wrap: wrap; } +.ai-panel .ai-diff { max-height: 320px; } diff --git a/apps/desktop/src/ui/AiFix.tsx b/apps/desktop/src/ui/AiFix.tsx new file mode 100644 index 0000000..d0c85d4 --- /dev/null +++ b/apps/desktop/src/ui/AiFix.tsx @@ -0,0 +1,115 @@ +// "Fix with AI" for one scan finding: the owner's local Claude Code CLI, run by the host (src-tauri/src/ai_fix.rs) +// in the app folder with the file tools only. Without claude on PATH the button copies the same prompt ("Copy for +// AI"). A dirty git tree is refused with the list of files; a run streams its lines, can be cancelled, and ends with +// the diff of what it changed, Keep (nothing more) and Undo (the host restores exactly those files), and a fresh scan +// that says whether the finding is gone. +import { type ReactNode, useEffect, useRef, useState } from 'react'; +import { type AiChange, type AiFinding, type AiFixRun, api, errorText, newJob, onAiFix } from '../api'; +import { t } from '../i18n'; +import type { ScanFinding } from '../lib/model'; +import { useData } from '../store'; +import { Icon } from './icons'; + +export const aiFinding = (f: ScanFinding): AiFinding => ({ + rule: f.rule, guideline: f.guideline, store: f.store, title: f.title, fix: f.fix, skill: f.skill, + evidence: (Array.isArray(f.evidence) ? f.evidence : []).slice(0, 5).map(e => ({ file: e.file, line: e.line, ...(e.text ? { text: e.text } : {}) })), +}); + +type Phase = + | { kind: 'idle' } + | { kind: 'dirty'; files: AiChange[] } + | { kind: 'running'; job: string; lines: string[] } + | { kind: 'done'; run: Extract; lines: string[]; undone?: string[] } + | { kind: 'error'; msg: string }; + +/** One finding's AI fix, under the finding's text: the button (Fix with AI / Copy for AI), then its panel. */ +export function AiFix({ projectId, f, claude, busy, setBusy, rescan, stillThere, onActive }: { + projectId: string; f: ScanFinding; claude: boolean | null; busy: boolean; setBusy: (b: boolean) => void; + rescan: () => void; stillThere: boolean; onActive: (on: boolean) => void; +}) { + const d = useData(); + const [p, setP] = useState({ kind: 'idle' }); + const logRef = useRef(null); + useEffect(() => { if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; }); + const active = p.kind !== 'idle'; + useEffect(() => { onActive(active); }, [active]); // eslint-disable-line react-hooks/exhaustive-deps + + const copy = async () => { + try { + const info = await api.aiFixInfo(projectId, aiFinding(f)); + await navigator.clipboard.writeText(info.prompt); + d.toast({ kind: 'ok', title: t('health.copied') }); + } catch (e) { d.toast({ kind: 'bad', title: t('health.copyFailed'), detail: errorText(e) }); } + }; + + const start = async () => { + const job = newJob('ai-fix'); + setBusy(true); + const lines: string[] = []; + const un = await onAiFix(e => { if (e.job === job) { lines.push(e.line); setP({ kind: 'running', job, lines: lines.slice(-200) }); } }); + try { + const info = await api.aiFixInfo(projectId, aiFinding(f)); + if (!info.git) { setP({ kind: 'error', msg: info.gitError ?? t('ai.noGit') }); return; } + if (info.dirty.length) { setP({ kind: 'dirty', files: info.dirty }); return; } + setP({ kind: 'running', job, lines: [] }); + const r = await api.aiFixRun(projectId, aiFinding(f), job); + if (r.refused) { setP({ kind: 'dirty', files: r.dirty }); return; } + setP({ kind: 'done', run: r, lines: lines.slice(-200) }); + if (r.changes.length) rescan(); + } catch (e) { setP({ kind: 'error', msg: errorText(e) }); } + finally { un(); setBusy(false); } + }; + + const undo = async (run: Extract) => { + try { + const files = await api.aiFixUndo(run.runId); + setP(x => (x.kind === 'done' ? { ...x, undone: files } : x)); + rescan(); + } catch (e) { setP({ kind: 'error', msg: errorText(e) }); } + }; + + const button = claude === false + ? + : ; + + let panel: ReactNode = null; + if (p.kind === 'dirty') panel = ( +
+ {t('ai.dirty', { count: p.files.length })} +
{p.files.slice(0, 30).map(c => `${c.status.padEnd(2)} ${c.path}`).join('\n')}
+
+
+ ); + else if (p.kind === 'running') panel = ( +
+ {t('ai.running')} +
{p.lines.join('\n') || t('ai.starting')}
+
+
+ ); + else if (p.kind === 'done') { + const o = p.run.outcome; + const head = o.cancelled ? t('ai.cancelled') : o.timed_out ? t('ai.timedOut') : !p.run.changes.length ? t('ai.noChanges') : t('ai.changed', { count: p.run.changes.length }); + panel = ( +
+ {head}{p.run.changes.length > 0 && !p.undone ? ` · ${stillThere ? t('ai.stillThere') : t('ai.gone')}` : ''} + {o.summary && {o.summary}} + {p.run.diff && !p.undone &&
{p.run.diff}
} + {p.undone ? <>{t('ai.undone', { count: p.undone.length })}
: ( +
+ {p.run.changes.length > 0 && } + {p.run.changes.length > 0 && } + {!p.run.changes.length && } +
+ )} +
+ ); + } else if (p.kind === 'error') panel = ( +
+ {t('ai.failed', { msg: p.msg })} +
+
+ ); + return <>{p.kind === 'idle' || p.kind === 'running' ?
{button}
: null}{panel}; +} diff --git a/cli/lib/fix-actions.ts b/cli/lib/fix-actions.ts index 3a29007..dde3ff6 100644 --- a/cli/lib/fix-actions.ts +++ b/cli/lib/fix-actions.ts @@ -21,3 +21,12 @@ export function scanFixAction(rule: string, o: { configPath: string | null; decl export const fixPlayListing = (): FixAction => ({ method: 'play.listing', args: {} }); export const fixAppleListing = (version: string): FixAction => ({ method: 'apple.listing', args: { version } }); export const fixReviewDetails = (): FixAction => ({ method: 'apple.review_details', args: {} }); + +/** Scan rules whose fix is a code or project-file change (Info.plist, AndroidManifest, Gradle, sources): the desktop + * offers "Fix with AI" (the owner's local Claude Code, file tools only) or "Copy for AI" for them. Rules whose fix is a + * compliance answer, a store form or listing text are not here. */ +export const AI_FIX_RULES = new Set(['ats', 'att', 'purpose-strings', 'unused-permission', 'privacy-manifest', 'deprecated-api', 'private-api', 'ipv6', + 'restore-purchases', 'account-deletion', 'subscription-info', 'login-services', 'review-prompt', 'placeholder-text', 'beta-wording', 'other-platforms', + 'privacy-policy-link', 'kids-ads', 'play-fgs', 'play-target-sdk', 'play-ad-id', 'play-families-ad-id', 'play-exact-alarm', 'play-query-all-packages', + 'play-full-screen-intent', 'play-all-files-access', 'play-photo-video', 'play-background-location', 'play-install-packages', 'play-sms-call-log', + 'play-prominent-disclosure', 'play-16kb']); diff --git a/cli/lib/scan/index.ts b/cli/lib/scan/index.ts index 28f8b4e..dbc1e67 100644 --- a/cli/lib/scan/index.ts +++ b/cli/lib/scan/index.ts @@ -5,7 +5,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { join, relative, resolve } from 'node:path'; import { CONFIG_NAMES, loadConfig, type ShipConfig } from '../config'; import { reviewDemoAccount } from '../credentials'; -import { scanFixAction } from '../fix-actions'; +import { AI_FIX_RULES, scanFixAction } from '../fix-actions'; import { proposeDeclarations, type Proposal } from './declare'; import { detectProject, ECO_LABEL, type ProjectFacts, type Stack, where } from './project'; import type { OriginalityReport } from '../originality'; @@ -66,6 +66,7 @@ export async function runScan(dir: string, o: { config?: string } = {}): Promise for (const f of findings) { const action = f.accepted ? undefined : scanFixAction(f.rule, { configPath, declarationsChanged: declarations.changed }); if (action) f.action = action; + else if (!f.accepted && AI_FIX_RULES.has(f.rule)) f.aiFix = true; } const counts = { blocker: 0, high: 0, medium: 0, low: 0, accepted: 0 }; for (const f of findings) { if (f.accepted) counts.accepted++; else counts[f.severity]++; } diff --git a/cli/lib/scan/rules.ts b/cli/lib/scan/rules.ts index ea7b3e1..67dc7b8 100644 --- a/cli/lib/scan/rules.ts +++ b/cli/lib/scan/rules.ts @@ -65,6 +65,8 @@ export interface ScanFinding { accepted?: string; /** The desktop's Fix button: a write fully determined by an existing command (lib/fix-actions.ts). */ action?: FixAction; + /** A code / project-file change: the desktop offers Fix with AI (AI_FIX_RULES). */ + aiFix?: boolean; } export interface ScanInput { diff --git a/docs/desktop.md b/docs/desktop.md index 729969d..f13f7f0 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -72,6 +72,15 @@ Types of the protocol: `cli/lib/desktop-protocol.ts`; pure portfolio logic: `cli config, the dialog shows the config diff), Play listing gaps -> `play.listing`, App Store missing localization and copyright -> `apple.listing` (editable version only), App Review contact -> `apple.review_details`. No fix reaches a submit, release or publish (tests/fix-actions.test.ts). + Findings whose fix is a code or project-file change (`aiFix: true`, AI_FIX_RULES: ATS, foreground service types, + purpose strings, target SDK, ...) get **Fix with AI**: the host (src-tauri/src/ai_fix.rs) runs the owner's local + Claude Code (`claude -p`, found on PATH or its usual install folders) in the app folder with only Read, Edit, Write, + Grep and Glob (Bash, web tools and sub-agents disallowed), a prompt it builds from the finding's fields (the window + cannot send free text), a 10 minute timeout with a heartbeat, and kill by PID (process group) on Cancel or app exit. + It refuses while the app folder has uncommitted files (and lists them), so the diff afterwards is the run's own work; + the window shows it with Keep and Undo (the host restores exactly the files that run changed and removes the ones it + created) and rescans to say whether the finding is gone. Without claude the button is **Copy for AI** (the same + prompt). The host only starts the claude binary, git and kill / taskkill there (tests/security.rs, tests/ai_fix.rs). - **First-run guide**: what the app does, add or start an app folder (`project.init` = `storebound init --profile`, confirmed in a native dialog), store keys into the shared credential profiles and which app uses which, local tools, first read. diff --git a/tests/fix-actions.test.ts b/tests/fix-actions.test.ts index 8fe0734..032bc4a 100644 --- a/tests/fix-actions.test.ts +++ b/tests/fix-actions.test.ts @@ -6,7 +6,8 @@ import { cpSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { mint } from '../cli/lib/confirm-token'; -import { FIX_METHODS, scanFixAction } from '../cli/lib/fix-actions'; +import { AI_FIX_RULES, FIX_METHODS, SCAN_WRITE_RULES, scanFixAction } from '../cli/lib/fix-actions'; +import { readFileSync as read } from 'node:fs'; import { runScan } from '../cli/lib/scan'; import { appleRules, copyrightRules, playRules } from '../cli/lib/validate-rules'; import { FORBIDDEN_ARGS, TOOLS, toArgv } from '../cli/mcp-tools'; @@ -57,6 +58,16 @@ describe('finding -> fix action', () => { }); }); +describe('Fix with AI rules', () => { + test('only real scan rules that need a code change; never a compliance answer or a deterministic fix', () => { + const src = ['rules.ts', 'play-rules.ts'].map(f => read(join(import.meta.dir, '..', 'cli', 'lib', 'scan', f), 'utf8')).join('\n'); + const ids = new Set([...src.matchAll(/rule: '([a-z0-9-]+)'/g)].map(m => m[1]!)); + for (const r of AI_FIX_RULES) expect({ r, known: ids.has(r) }).toEqual({ r, known: true }); + for (const r of ['export-compliance', 'demo-account', 'template-answers', 'privacy-labels', 'play-health-connect', 'metadata-keywords']) expect(AI_FIX_RULES.has(r)).toBe(false); + for (const r of SCAN_WRITE_RULES) expect(AI_FIX_RULES.has(r)).toBe(false); + }); +}); + describe('scan.write round trip', () => { test('dry run shows the config diff and writes nothing; the confirmed run writes; the finding is gone', async () => { const dir = mkdtempSync(join(tmpdir(), 'sb-fix-'));