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

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