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

@ -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

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());

View file

@ -69,6 +69,13 @@ async function listen<T>(event: string, cb: (payload: T) => void): Promise<Unlis
return tauriListen<T>(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<CacheEntry<T> | null>('cache_get', { method, projectId, args }),
clearCache: () => invoke<void>('cache_clear'),
cancel: (job: string) => invoke<boolean>('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<AiFixInfo>('ai_fix_info', { projectId, finding }),
aiFixRun: (projectId: string, finding: AiFinding, job: string) => invoke<AiFixRun>('ai_fix_run', { projectId, finding, job }),
aiFixUndo: (runId: string) => invoke<string[]>('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<string, unknown> = {}, job?: string) =>
invoke<WriteOutcome>('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<UnlistenFn> =>
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<UnlistenFn> => listen<{ job: string; line: string }>('ai-fix', cb);
export const onDownload = (cb: (e: DownloadEvent) => void): Promise<UnlistenFn> => listen<DownloadEvent>('tool-download', cb);
export const onDeepLink = (cb: (screen: string) => void): Promise<UnlistenFn> => listen<string>('deep-link', cb);
/** storebound://open?path=... selected or added an app folder (its project id). */

View file

@ -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: '<key>NSAllowsArbitraryLoads</key><true/>' }], 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<string, ScanResult['findings'][number]>();
const SCAN: Record<string, ScanResult> = {
'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<string, unknown>): Promise<unknown>
return fixtureRead(method, id, ((a.params as { args?: Record<string, unknown> } | 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 <key>NSAppTransportSecurity</key>\n <dict>\n- <key>NSAllowsArbitraryLoads</key>\n- <true/>\n </dict>\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<string, unknown>;
if (method === 'console.fill') {

View file

@ -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',

View file

@ -446,6 +446,25 @@ export const tr: Record<keyof typeof en, string> = {
'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',

View file

@ -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[];

View file

@ -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<ScanResult>(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<boolean | null>(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<ScanFinding | null>(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<string, number>();
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={<ReadControl r={s} label={t('checks.scanAgain')} />}>
{s.error && <div className="card-body"><ErrorNotice error={s.error} keep={!!s.data} /></div>}
{!s.data ? (s.loading && !s.error ? <div className="card-body"><Skeleton lines={4} /></div> : null)
: !findings.length ? <Empty compact illo="shield" title={t('checks.scanClean')}>{t('checks.scanCleanText')}</Empty> : (
: !rows.length ? <Empty compact illo="shield" title={t('checks.scanClean')}>{t('checks.scanCleanText')}</Empty> : (
<div className="list">
{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 (
<div className="li" key={`${f.rule}-${i}`}>
<div className="li" key={keyOf(f)}>
<span className={`li-icon tone-bg-${tone}`}><Icon name={icon} /></span>
<div className="li-main">
<span className="li-title">{f.guideline} · {f.title}</span>
{ev.slice(0, 2).map((e, j) => <span className="li-sub selectable" key={j}><code>{e.file}:{e.line}</code>{e.text ? ` ${e.text}` : ''}</span>)}
{f.accepted ? <span className="li-sub">{t('checks.accepted', { reason: f.accepted })}</span>
: <span className="fix"><Icon name="wand" /><span className="selectable">{f.fix} <code>{f.skill}</code></span></span>}
{!f.accepted && f.aiFix && !fixable(f.action) && <AiFix projectId={project.id} f={f} claude={claude} busy={aiBusy} setBusy={setAiBusy} rescan={() => { s.refresh(); }}
stillThere={findings.some(x => same(x, f))} onActive={on => setAiActive(on ? f : null)} />}
</div>
{!f.accepted && fixable(f.action) && <FixButton action={f.action} what={f.title} w={w} />}
<Chip tone={f.accepted ? 'ok' : tone}>{t(f.accepted ? 'checks.sev.accepted' : `checks.sev.${f.severity}` as Parameters<typeof t>[0])}</Chip>

View file

@ -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; }

View file

@ -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<AiFixRun, { runId: string }>; 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<Phase>({ kind: 'idle' });
const logRef = useRef<HTMLPreElement>(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<AiFixRun, { runId: string }>) => {
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
? <button className="btn btn-sm" onClick={copy} title={t('ai.copyHint')}><Icon name="copy" />{t('ai.copy')}</button>
: <button className="btn btn-sm" onClick={start} disabled={busy || claude === null} title={t('ai.runHint')} aria-busy={p.kind === 'running' || undefined}>
<Icon name={p.kind === 'running' ? 'refresh' : 'sparkle'} className={p.kind === 'running' ? 'spin' : undefined} />{t('ai.run')}</button>;
let panel: ReactNode = null;
if (p.kind === 'dirty') panel = (
<div className="ai-panel" role="status">
<span className="li-sub">{t('ai.dirty', { count: p.files.length })}</span>
<pre className="log selectable">{p.files.slice(0, 30).map(c => `${c.status.padEnd(2)} ${c.path}`).join('\n')}</pre>
<div className="row"><button className="btn btn-sm" onClick={() => setP({ kind: 'idle' })}>{t('common.close')}</button></div>
</div>
);
else if (p.kind === 'running') panel = (
<div className="ai-panel" role="status">
<span className="li-sub">{t('ai.running')}</span>
<pre className="log" ref={logRef} aria-live="polite">{p.lines.join('\n') || t('ai.starting')}</pre>
<div className="row"><button className="btn btn-sm" onClick={() => { api.cancel(p.job).catch(() => {}); }}><Icon name="x" />{t('common.cancel')}</button></div>
</div>
);
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 = (
<div className="ai-panel" role="status">
<span className="li-sub">{head}{p.run.changes.length > 0 && !p.undone ? ` · ${stillThere ? t('ai.stillThere') : t('ai.gone')}` : ''}</span>
{o.summary && <span className="li-sub selectable">{o.summary}</span>}
{p.run.diff && !p.undone && <pre className="log selectable ai-diff" aria-label={t('ai.diff')}>{p.run.diff}</pre>}
{p.undone ? <><span className="li-sub">{t('ai.undone', { count: p.undone.length })}</span><div className="row"><button className="btn btn-sm" onClick={() => setP({ kind: 'idle' })}>{t('common.close')}</button></div></> : (
<div className="row">
{p.run.changes.length > 0 && <button className="btn btn-sm btn-primary" onClick={() => setP({ kind: 'idle' })}><Icon name="check" />{t('ai.keep')}</button>}
{p.run.changes.length > 0 && <button className="btn btn-sm" onClick={() => undo(p.run)}><Icon name="rotate" />{t('ai.undo')}</button>}
{!p.run.changes.length && <button className="btn btn-sm" onClick={() => setP({ kind: 'idle' })}>{t('common.close')}</button>}
</div>
)}
</div>
);
} else if (p.kind === 'error') panel = (
<div className="ai-panel" role="alert">
<span className="li-sub">{t('ai.failed', { msg: p.msg })}</span>
<div className="row"><button className="btn btn-sm" onClick={() => setP({ kind: 'idle' })}>{t('common.close')}</button></div>
</div>
);
return <>{p.kind === 'idle' || p.kind === 'running' ? <div className="ai-actions">{button}</div> : null}{panel}</>;
}

View file

@ -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']);

View file

@ -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]++; }

View file

@ -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 {

View file

@ -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.

View file

@ -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-'));