Skip to main content

verifyos_cli/
agents.rs

1use crate::profiles::{rule_inventory, RuleInventoryItem};
2use crate::report::AgentPack;
3use std::path::Path;
4
5const MANAGED_START: &str = "<!-- verifyos-cli:agents:start -->";
6const MANAGED_END: &str = "<!-- verifyos-cli:agents:end -->";
7
8#[derive(Debug, Clone, Default)]
9pub struct CommandHints {
10    pub output_dir: Option<String>,
11    pub app_path: Option<String>,
12    pub baseline_path: Option<String>,
13    pub agent_pack_dir: Option<String>,
14    pub profile: Option<String>,
15    pub shell_script: bool,
16    pub fix_prompt_path: Option<String>,
17    pub pr_brief_path: Option<String>,
18    pub pr_comment_path: Option<String>,
19}
20
21pub fn write_agents_file(
22    path: &Path,
23    agent_pack: Option<&AgentPack>,
24    agent_pack_dir: Option<&Path>,
25    command_hints: Option<&CommandHints>,
26) -> Result<(), miette::Report> {
27    let existing = if path.exists() {
28        Some(std::fs::read_to_string(path).map_err(|err| {
29            miette::miette!(
30                "Failed to read existing AGENTS.md at {}: {}",
31                path.display(),
32                err
33            )
34        })?)
35    } else {
36        None
37    };
38
39    let managed_block = build_managed_block(agent_pack, agent_pack_dir, command_hints);
40    let next = merge_agents_content(existing.as_deref(), &managed_block);
41    std::fs::write(path, next)
42        .map_err(|err| miette::miette!("Failed to write AGENTS.md at {}: {}", path.display(), err))
43}
44
45pub fn merge_agents_content(existing: Option<&str>, managed_block: &str) -> String {
46    match existing {
47        None => format!("# AGENTS.md\n\n{}", managed_block),
48        Some(content) => {
49            if let Some((start, end)) = managed_block_range(content) {
50                let mut next = String::new();
51                next.push_str(&content[..start]);
52                if !next.ends_with('\n') {
53                    next.push('\n');
54                }
55                next.push_str(managed_block);
56                let tail = &content[end..];
57                if !tail.is_empty() && !tail.starts_with('\n') {
58                    next.push('\n');
59                }
60                next.push_str(tail);
61                next
62            } else if content.trim().is_empty() {
63                format!("# AGENTS.md\n\n{}", managed_block)
64            } else {
65                let mut next = content.trim_end().to_string();
66                next.push_str("\n\n");
67                next.push_str(managed_block);
68                next.push('\n');
69                next
70            }
71        }
72    }
73}
74
75pub fn build_managed_block(
76    agent_pack: Option<&AgentPack>,
77    agent_pack_dir: Option<&Path>,
78    command_hints: Option<&CommandHints>,
79) -> String {
80    let inventory = rule_inventory();
81    let agent_pack_dir_display = agent_pack_dir
82        .map(|path| path.display().to_string())
83        .unwrap_or_else(|| ".verifyos-agent".to_string());
84    let mut out = String::new();
85    out.push_str(MANAGED_START);
86    out.push('\n');
87    out.push_str("## verifyOS-cli\n\n");
88    out.push_str("Use `voc` before large iOS submission changes or release builds.\n\n");
89    out.push_str("### Recommended Workflow\n\n");
90    out.push_str("1. Run `voc --app <path-to-.ipa-or-.app> --profile basic` for a quick gate.\n");
91    out.push_str(&format!(
92        "2. Run `voc --app <path-to-.ipa-or-.app> --profile full --agent-pack {} --agent-pack-format bundle` before release or when an AI agent will patch findings.\n",
93        agent_pack_dir_display
94    ));
95    out.push_str(&format!(
96        "3. Read `{}/agent-pack.md` first, then patch the highest-priority scopes.\n",
97        agent_pack_dir_display
98    ));
99    out.push_str("4. Re-run `voc` after each fix batch until the pack is clean.\n\n");
100    out.push_str("### AI Agent Rules\n\n");
101    out.push_str("- Prefer `voc --profile basic` during fast inner loops and `voc --profile full` before shipping.\n");
102    out.push_str(&format!(
103        "- When findings exist, generate an agent bundle with `voc --agent-pack {} --agent-pack-format bundle`.\n",
104        agent_pack_dir_display
105    ));
106    out.push_str("- Fix `high` priority findings before `medium` and `low`.\n");
107    out.push_str("- Treat `Info.plist`, `entitlements`, `ats-config`, and `bundle-resources` as the main fix scopes.\n");
108    out.push_str("- Re-run `voc` after edits and compare against the previous agent pack to confirm findings were actually removed.\n\n");
109    if let Some(hints) = command_hints {
110        append_next_commands(&mut out, hints);
111    }
112    if let Some(pack) = agent_pack {
113        append_current_project_risks(&mut out, pack, &agent_pack_dir_display);
114    }
115    out.push_str("### Rule Inventory\n\n");
116    out.push_str("| Rule ID | Name | Category | Severity | Default Profiles |\n");
117    out.push_str("| --- | --- | --- | --- | --- |\n");
118    for item in inventory {
119        out.push_str(&inventory_row(&item));
120    }
121    out.push('\n');
122    out.push_str(MANAGED_END);
123    out.push('\n');
124    out
125}
126
127fn append_next_commands(out: &mut String, hints: &CommandHints) {
128    let Some(app_path) = hints.app_path.as_deref() else {
129        return;
130    };
131
132    let profile = hints.profile.as_deref().unwrap_or("full");
133    let agent_pack_dir = hints.agent_pack_dir.as_deref().unwrap_or(".verifyos-agent");
134
135    out.push_str("### Next Commands\n\n");
136    out.push_str("Use these exact commands after each patch batch:\n\n");
137    if hints.shell_script {
138        out.push_str(&format!(
139            "- Shortcut script: `{}/next-steps.sh`\n\n",
140            agent_pack_dir
141        ));
142    }
143    if let Some(prompt_path) = hints.fix_prompt_path.as_deref() {
144        out.push_str(&format!("- Agent fix prompt: `{}`\n\n", prompt_path));
145    }
146    if let Some(pr_brief_path) = hints.pr_brief_path.as_deref() {
147        out.push_str(&format!("- PR brief: `{}`\n\n", pr_brief_path));
148    }
149    if let Some(pr_comment_path) = hints.pr_comment_path.as_deref() {
150        out.push_str(&format!("- PR comment draft: `{}`\n\n", pr_comment_path));
151    }
152    out.push_str("```bash\n");
153    out.push_str(&format!(
154        "voc --app {} --profile {}\n",
155        shell_quote(app_path),
156        profile
157    ));
158    out.push_str(&format!(
159        "voc --app {} --profile {} --format json > report.json\n",
160        shell_quote(app_path),
161        profile
162    ));
163    out.push_str(&format!(
164        "voc --app {} --profile {} --agent-pack {} --agent-pack-format bundle\n",
165        shell_quote(app_path),
166        profile,
167        shell_quote(agent_pack_dir)
168    ));
169    if let Some(output_dir) = hints.output_dir.as_deref() {
170        let mut cmd = format!(
171            "voc doctor --output-dir {} --fix --from-scan {} --profile {}",
172            shell_quote(output_dir),
173            shell_quote(app_path),
174            profile
175        );
176        if let Some(baseline) = hints.baseline_path.as_deref() {
177            cmd.push_str(&format!(" --baseline {}", shell_quote(baseline)));
178        }
179        if hints.pr_brief_path.is_some() {
180            cmd.push_str(" --open-pr-brief");
181        }
182        if hints.pr_comment_path.is_some() {
183            cmd.push_str(" --open-pr-comment");
184        }
185        out.push_str(&format!("{cmd}\n"));
186    } else if let Some(baseline) = hints.baseline_path.as_deref() {
187        let mut cmd = format!(
188            "voc init --from-scan {} --profile {} --baseline {} --agent-pack-dir {} --write-commands",
189            shell_quote(app_path),
190            profile,
191            shell_quote(baseline),
192            shell_quote(agent_pack_dir)
193        );
194        if hints.shell_script {
195            cmd.push_str(" --shell-script");
196        }
197        out.push_str(&format!("{cmd}\n"));
198    } else {
199        let mut cmd = format!(
200            "voc init --from-scan {} --profile {} --agent-pack-dir {} --write-commands",
201            shell_quote(app_path),
202            profile,
203            shell_quote(agent_pack_dir)
204        );
205        if hints.shell_script {
206            cmd.push_str(" --shell-script");
207        }
208        out.push_str(&format!("{cmd}\n"));
209    }
210    out.push_str("```\n\n");
211}
212
213pub fn render_fix_prompt(pack: &AgentPack, hints: &CommandHints) -> String {
214    let mut out = String::new();
215    out.push_str("# verifyOS Fix Prompt\n\n");
216    out.push_str(
217        "Patch the current iOS bundle risks conservatively. Prefer minimal, review-safe edits.\n\n",
218    );
219    if let Some(app_path) = hints.app_path.as_deref() {
220        out.push_str(&format!("- App artifact: `{}`\n", app_path));
221    }
222    if let Some(profile) = hints.profile.as_deref() {
223        out.push_str(&format!("- Scan profile: `{}`\n", profile));
224    }
225    if let Some(agent_pack_dir) = hints.agent_pack_dir.as_deref() {
226        out.push_str(&format!("- Agent bundle: `{}`\n", agent_pack_dir));
227    }
228    if let Some(prompt_path) = hints.fix_prompt_path.as_deref() {
229        out.push_str(&format!("- Prompt file: `{}`\n", prompt_path));
230    }
231    out.push('\n');
232
233    if pack.findings.is_empty() {
234        out.push_str("## Findings\n\n- No current findings. Re-run the validation commands to confirm the app is still clean.\n\n");
235    } else {
236        out.push_str("## Findings\n\n");
237        for finding in &pack.findings {
238            out.push_str(&format!(
239                "- **{}** (`{}`)\n",
240                finding.rule_name, finding.rule_id
241            ));
242            out.push_str(&format!("  - Priority: `{}`\n", finding.priority));
243            out.push_str(&format!("  - Scope: `{}`\n", finding.suggested_fix_scope));
244            if !finding.target_files.is_empty() {
245                out.push_str(&format!(
246                    "  - Target files: {}\n",
247                    finding.target_files.join(", ")
248                ));
249            }
250            out.push_str(&format!(
251                "  - Why it fails review: {}\n",
252                finding.why_it_fails_review
253            ));
254            out.push_str(&format!("  - Patch hint: {}\n", finding.patch_hint));
255            out.push_str(&format!("  - Recommendation: {}\n", finding.recommendation));
256        }
257        out.push('\n');
258    }
259
260    out.push_str("## Done When\n\n");
261    out.push_str("- The relevant files are patched without widening permissions or exceptions.\n");
262    out.push_str("- `voc` no longer reports the patched findings.\n");
263    out.push_str("- Updated outputs are regenerated for the next loop.\n\n");
264
265    out.push_str("## Validation Commands\n\n");
266    if let Some(app_path) = hints.app_path.as_deref() {
267        let profile = hints.profile.as_deref().unwrap_or("full");
268        let agent_pack_dir = hints.agent_pack_dir.as_deref().unwrap_or(".verifyos-agent");
269        out.push_str("```bash\n");
270        out.push_str(&format!(
271            "voc --app {} --profile {}\n",
272            shell_quote(app_path),
273            profile
274        ));
275        out.push_str(&format!(
276            "voc --app {} --profile {} --agent-pack {} --agent-pack-format bundle\n",
277            shell_quote(app_path),
278            profile,
279            shell_quote(agent_pack_dir)
280        ));
281        out.push_str("```\n");
282    }
283
284    out
285}
286
287pub fn render_pr_brief(pack: &AgentPack, hints: &CommandHints) -> String {
288    let mut out = String::new();
289    out.push_str("# verifyOS PR Brief\n\n");
290    out.push_str("## Summary\n\n");
291    out.push_str(&format!("- Findings in scope: `{}`\n", pack.total_findings));
292    if let Some(app_path) = hints.app_path.as_deref() {
293        out.push_str(&format!("- App artifact: `{}`\n", app_path));
294    }
295    if let Some(profile) = hints.profile.as_deref() {
296        out.push_str(&format!("- Scan profile: `{}`\n", profile));
297    }
298    if let Some(baseline) = hints.baseline_path.as_deref() {
299        out.push_str(&format!("- Baseline: `{}`\n", baseline));
300    }
301    out.push('\n');
302
303    out.push_str("## What Changed\n\n");
304    if pack.findings.is_empty() {
305        out.push_str(
306            "- No new or regressed risks are currently in scope after the latest scan.\n\n",
307        );
308    } else {
309        out.push_str(
310            "- This branch still contains findings that can affect App Store review outcomes.\n",
311        );
312        out.push_str(
313            "- The recommended patch order below is sorted for review safety and repair efficiency.\n\n",
314        );
315    }
316
317    out.push_str("## Current Risks\n\n");
318    if pack.findings.is_empty() {
319        out.push_str("- No open findings.\n\n");
320    } else {
321        let mut findings = pack.findings.clone();
322        findings.sort_by(|a, b| {
323            priority_rank(&a.priority)
324                .cmp(&priority_rank(&b.priority))
325                .then_with(|| a.suggested_fix_scope.cmp(&b.suggested_fix_scope))
326                .then_with(|| a.rule_id.cmp(&b.rule_id))
327        });
328
329        for finding in &findings {
330            out.push_str(&format!(
331                "- **{}** (`{}`)\n",
332                finding.rule_name, finding.rule_id
333            ));
334            out.push_str(&format!("  - Priority: `{}`\n", finding.priority));
335            out.push_str(&format!("  - Scope: `{}`\n", finding.suggested_fix_scope));
336            if !finding.target_files.is_empty() {
337                out.push_str(&format!(
338                    "  - Target files: {}\n",
339                    finding.target_files.join(", ")
340                ));
341            }
342            out.push_str(&format!(
343                "  - Why review cares: {}\n",
344                finding.why_it_fails_review
345            ));
346            out.push_str(&format!("  - Patch hint: {}\n", finding.patch_hint));
347        }
348        out.push('\n');
349    }
350
351    out.push_str("## Validation Commands\n\n");
352    if let Some(app_path) = hints.app_path.as_deref() {
353        let profile = hints.profile.as_deref().unwrap_or("full");
354        let agent_pack_dir = hints.agent_pack_dir.as_deref().unwrap_or(".verifyos-agent");
355        out.push_str("```bash\n");
356        out.push_str(&format!(
357            "voc --app {} --profile {}\n",
358            shell_quote(app_path),
359            profile
360        ));
361        out.push_str(&format!(
362            "voc --app {} --profile {} --agent-pack {} --agent-pack-format bundle\n",
363            shell_quote(app_path),
364            profile,
365            shell_quote(agent_pack_dir)
366        ));
367        if let Some(output_dir) = hints.output_dir.as_deref() {
368            let mut cmd = format!(
369                "voc doctor --output-dir {} --fix --from-scan {} --profile {}",
370                shell_quote(output_dir),
371                shell_quote(app_path),
372                profile
373            );
374            if let Some(baseline) = hints.baseline_path.as_deref() {
375                cmd.push_str(&format!(" --baseline {}", shell_quote(baseline)));
376            }
377            if hints.pr_brief_path.is_some() {
378                cmd.push_str(" --open-pr-brief");
379            }
380            out.push_str(&format!("{cmd}\n"));
381        } else if let Some(baseline) = hints.baseline_path.as_deref() {
382            out.push_str(&format!(
383                "voc doctor --fix --from-scan {} --profile {} --baseline {} --open-pr-brief\n",
384                shell_quote(app_path),
385                profile,
386                shell_quote(baseline)
387            ));
388        }
389        out.push_str("```\n");
390    }
391
392    out
393}
394
395pub fn render_pr_comment(pack: &AgentPack, hints: &CommandHints) -> String {
396    let mut out = String::new();
397    out.push_str("## verifyOS review summary\n\n");
398    out.push_str(&format!("- Findings in scope: `{}`\n", pack.total_findings));
399    if let Some(app_path) = hints.app_path.as_deref() {
400        out.push_str(&format!("- App artifact: `{}`\n", app_path));
401    }
402    if let Some(profile) = hints.profile.as_deref() {
403        out.push_str(&format!("- Scan profile: `{}`\n", profile));
404    }
405    out.push('\n');
406
407    if pack.findings.is_empty() {
408        out.push_str("- No open findings after the latest scan.\n\n");
409    } else {
410        out.push_str("### Top risks\n\n");
411        for finding in pack.findings.iter().take(5) {
412            out.push_str(&format!(
413                "- **{}** (`{}`) [{}/{}]\n",
414                finding.rule_name, finding.rule_id, finding.priority, finding.suggested_fix_scope
415            ));
416            out.push_str(&format!(
417                "  - Why it matters: {}\n",
418                finding.why_it_fails_review
419            ));
420            out.push_str(&format!("  - Patch hint: {}\n", finding.patch_hint));
421        }
422        out.push('\n');
423    }
424
425    out.push_str("### Validation\n\n");
426    if let Some(app_path) = hints.app_path.as_deref() {
427        let profile = hints.profile.as_deref().unwrap_or("full");
428        out.push_str("```bash\n");
429        out.push_str(&format!(
430            "voc --app {} --profile {}\n",
431            shell_quote(app_path),
432            profile
433        ));
434        out.push_str("```\n");
435    }
436
437    out
438}
439
440fn append_current_project_risks(out: &mut String, pack: &AgentPack, agent_pack_dir: &str) {
441    out.push_str("### Current Project Risks\n\n");
442    out.push_str(&format!(
443        "- Agent bundle: `{}/agent-pack.json` and `{}/agent-pack.md`\n\n",
444        agent_pack_dir, agent_pack_dir
445    ));
446    if pack.findings.is_empty() {
447        out.push_str(
448            "- No new or regressed risks after applying the latest scan context. Re-run `voc` before release to keep this section fresh.\n\n",
449        );
450        return;
451    }
452
453    let mut findings = pack.findings.clone();
454    findings.sort_by(|a, b| {
455        priority_rank(&a.priority)
456            .cmp(&priority_rank(&b.priority))
457            .then_with(|| a.suggested_fix_scope.cmp(&b.suggested_fix_scope))
458            .then_with(|| a.rule_id.cmp(&b.rule_id))
459    });
460
461    out.push_str("| Priority | Rule ID | Scope | Why it matters |\n");
462    out.push_str("| --- | --- | --- | --- |\n");
463    for finding in &findings {
464        out.push_str(&format!(
465            "| `{}` | `{}` | `{}` | {} |\n",
466            finding.priority,
467            finding.rule_id,
468            finding.suggested_fix_scope,
469            finding.why_it_fails_review
470        ));
471    }
472    out.push('\n');
473
474    out.push_str("#### Suggested Patch Order\n\n");
475    for finding in &findings {
476        out.push_str(&format!(
477            "- **{}** (`{}`)\n",
478            finding.rule_name, finding.rule_id
479        ));
480        out.push_str(&format!("  - Priority: `{}`\n", finding.priority));
481        out.push_str(&format!(
482            "  - Fix scope: `{}`\n",
483            finding.suggested_fix_scope
484        ));
485        if !finding.target_files.is_empty() {
486            out.push_str(&format!(
487                "  - Target files: {}\n",
488                finding.target_files.join(", ")
489            ));
490        }
491        out.push_str(&format!(
492            "  - Why it fails review: {}\n",
493            finding.why_it_fails_review
494        ));
495        out.push_str(&format!("  - Patch hint: {}\n", finding.patch_hint));
496    }
497    out.push('\n');
498}
499
500fn priority_rank(priority: &str) -> u8 {
501    match priority {
502        "high" => 0,
503        "medium" => 1,
504        "low" => 2,
505        _ => 3,
506    }
507}
508
509fn inventory_row(item: &RuleInventoryItem) -> String {
510    format!(
511        "| `{}` | {} | `{:?}` | `{:?}` | `{}` |\n",
512        item.rule_id,
513        item.name,
514        item.category,
515        item.severity,
516        item.default_profiles.join(", ")
517    )
518}
519
520fn managed_block_range(content: &str) -> Option<(usize, usize)> {
521    let start = content.find(MANAGED_START)?;
522    let end_marker = content.find(MANAGED_END)?;
523    Some((start, end_marker + MANAGED_END.len()))
524}
525
526fn shell_quote(value: &str) -> String {
527    if value
528        .chars()
529        .all(|ch| ch.is_ascii_alphanumeric() || "/._-".contains(ch))
530    {
531        value.to_string()
532    } else {
533        format!("'{}'", value.replace('\'', "'\"'\"'"))
534    }
535}
536
537#[cfg(test)]
538mod tests {
539    use super::{build_managed_block, merge_agents_content, CommandHints};
540    use crate::report::{AgentFinding, AgentPack};
541    use crate::rules::core::{RuleCategory, Severity};
542    use std::path::Path;
543
544    #[test]
545    fn merge_agents_content_creates_new_file_when_missing() {
546        let block = build_managed_block(None, None, None);
547        let merged = merge_agents_content(None, &block);
548
549        assert!(merged.starts_with("# AGENTS.md"));
550        assert!(merged.contains("## verifyOS-cli"));
551        assert!(merged.contains("RULE_PRIVACY_MANIFEST"));
552    }
553
554    #[test]
555    fn merge_agents_content_replaces_existing_managed_block() {
556        let block = build_managed_block(None, None, None);
557        let existing = r#"# AGENTS.md
558
559Custom note
560
561<!-- verifyos-cli:agents:start -->
562old block
563<!-- verifyos-cli:agents:end -->
564
565Keep this
566"#;
567
568        let merged = merge_agents_content(Some(existing), &block);
569
570        assert!(merged.contains("Custom note"));
571        assert!(merged.contains("Keep this"));
572        assert!(!merged.contains("old block"));
573        assert_eq!(
574            merged.matches("<!-- verifyos-cli:agents:start -->").count(),
575            1
576        );
577    }
578
579    #[test]
580    fn build_managed_block_includes_current_project_risks_when_scan_exists() {
581        let pack = AgentPack {
582            generated_at_unix: 0,
583            total_findings: 1,
584            findings: vec![AgentFinding {
585                rule_id: "RULE_USAGE_DESCRIPTIONS".to_string(),
586                rule_name: "Missing required usage description keys".to_string(),
587                severity: Severity::Warning,
588                category: RuleCategory::Privacy,
589                priority: "medium".to_string(),
590                message: "Missing NSCameraUsageDescription".to_string(),
591                evidence: None,
592                recommendation: "Add usage descriptions".to_string(),
593                suggested_fix_scope: "Info.plist".to_string(),
594                target_files: vec!["Info.plist".to_string()],
595                patch_hint: "Update Info.plist".to_string(),
596                why_it_fails_review: "Protected APIs require usage strings.".to_string(),
597            }],
598        };
599
600        let block = build_managed_block(Some(&pack), Some(Path::new(".verifyos-agent")), None);
601
602        assert!(block.contains("### Current Project Risks"));
603        assert!(block.contains("#### Suggested Patch Order"));
604        assert!(block.contains("`RULE_USAGE_DESCRIPTIONS`"));
605        assert!(block.contains("Info.plist"));
606        assert!(block.contains(".verifyos-agent/agent-pack.md"));
607    }
608
609    #[test]
610    fn build_managed_block_includes_next_commands_when_requested() {
611        let hints = CommandHints {
612            output_dir: Some(".verifyos".to_string()),
613            app_path: Some("examples/bad_app.ipa".to_string()),
614            baseline_path: Some("baseline.json".to_string()),
615            agent_pack_dir: Some(".verifyos-agent".to_string()),
616            profile: Some("basic".to_string()),
617            shell_script: true,
618            fix_prompt_path: Some(".verifyos-agent/fix-prompt.md".to_string()),
619            pr_brief_path: Some(".verifyos-agent/pr-brief.md".to_string()),
620            pr_comment_path: Some(".verifyos-agent/pr-comment.md".to_string()),
621        };
622
623        let block = build_managed_block(None, Some(Path::new(".verifyos-agent")), Some(&hints));
624
625        assert!(block.contains("### Next Commands"));
626        assert!(block.contains("voc --app examples/bad_app.ipa --profile basic"));
627        assert!(block.contains("--baseline baseline.json"));
628        assert!(block.contains("voc doctor --output-dir .verifyos --fix --from-scan examples/bad_app.ipa --profile basic --baseline baseline.json --open-pr-brief"));
629        assert!(block.contains(".verifyos-agent/next-steps.sh"));
630        assert!(block.contains(".verifyos-agent/fix-prompt.md"));
631        assert!(block.contains(".verifyos-agent/pr-brief.md"));
632        assert!(block.contains(".verifyos-agent/pr-comment.md"));
633    }
634}