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 app_path: Option<String>,
11    pub baseline_path: Option<String>,
12    pub agent_pack_dir: Option<String>,
13    pub profile: Option<String>,
14    pub shell_script: bool,
15    pub fix_prompt_path: Option<String>,
16}
17
18pub fn write_agents_file(
19    path: &Path,
20    agent_pack: Option<&AgentPack>,
21    agent_pack_dir: Option<&Path>,
22    command_hints: Option<&CommandHints>,
23) -> Result<(), miette::Report> {
24    let existing = if path.exists() {
25        Some(std::fs::read_to_string(path).map_err(|err| {
26            miette::miette!(
27                "Failed to read existing AGENTS.md at {}: {}",
28                path.display(),
29                err
30            )
31        })?)
32    } else {
33        None
34    };
35
36    let managed_block = build_managed_block(agent_pack, agent_pack_dir, command_hints);
37    let next = merge_agents_content(existing.as_deref(), &managed_block);
38    std::fs::write(path, next)
39        .map_err(|err| miette::miette!("Failed to write AGENTS.md at {}: {}", path.display(), err))
40}
41
42pub fn merge_agents_content(existing: Option<&str>, managed_block: &str) -> String {
43    match existing {
44        None => format!("# AGENTS.md\n\n{}", managed_block),
45        Some(content) => {
46            if let Some((start, end)) = managed_block_range(content) {
47                let mut next = String::new();
48                next.push_str(&content[..start]);
49                if !next.ends_with('\n') {
50                    next.push('\n');
51                }
52                next.push_str(managed_block);
53                let tail = &content[end..];
54                if !tail.is_empty() && !tail.starts_with('\n') {
55                    next.push('\n');
56                }
57                next.push_str(tail);
58                next
59            } else if content.trim().is_empty() {
60                format!("# AGENTS.md\n\n{}", managed_block)
61            } else {
62                let mut next = content.trim_end().to_string();
63                next.push_str("\n\n");
64                next.push_str(managed_block);
65                next.push('\n');
66                next
67            }
68        }
69    }
70}
71
72pub fn build_managed_block(
73    agent_pack: Option<&AgentPack>,
74    agent_pack_dir: Option<&Path>,
75    command_hints: Option<&CommandHints>,
76) -> String {
77    let inventory = rule_inventory();
78    let agent_pack_dir_display = agent_pack_dir
79        .map(|path| path.display().to_string())
80        .unwrap_or_else(|| ".verifyos-agent".to_string());
81    let mut out = String::new();
82    out.push_str(MANAGED_START);
83    out.push('\n');
84    out.push_str("## verifyOS-cli\n\n");
85    out.push_str("Use `voc` before large iOS submission changes or release builds.\n\n");
86    out.push_str("### Recommended Workflow\n\n");
87    out.push_str("1. Run `voc --app <path-to-.ipa-or-.app> --profile basic` for a quick gate.\n");
88    out.push_str(&format!(
89        "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",
90        agent_pack_dir_display
91    ));
92    out.push_str(&format!(
93        "3. Read `{}/agent-pack.md` first, then patch the highest-priority scopes.\n",
94        agent_pack_dir_display
95    ));
96    out.push_str("4. Re-run `voc` after each fix batch until the pack is clean.\n\n");
97    out.push_str("### AI Agent Rules\n\n");
98    out.push_str("- Prefer `voc --profile basic` during fast inner loops and `voc --profile full` before shipping.\n");
99    out.push_str(&format!(
100        "- When findings exist, generate an agent bundle with `voc --agent-pack {} --agent-pack-format bundle`.\n",
101        agent_pack_dir_display
102    ));
103    out.push_str("- Fix `high` priority findings before `medium` and `low`.\n");
104    out.push_str("- Treat `Info.plist`, `entitlements`, `ats-config`, and `bundle-resources` as the main fix scopes.\n");
105    out.push_str("- Re-run `voc` after edits and compare against the previous agent pack to confirm findings were actually removed.\n\n");
106    if let Some(hints) = command_hints {
107        append_next_commands(&mut out, hints);
108    }
109    if let Some(pack) = agent_pack {
110        append_current_project_risks(&mut out, pack, &agent_pack_dir_display);
111    }
112    out.push_str("### Rule Inventory\n\n");
113    out.push_str("| Rule ID | Name | Category | Severity | Default Profiles |\n");
114    out.push_str("| --- | --- | --- | --- | --- |\n");
115    for item in inventory {
116        out.push_str(&inventory_row(&item));
117    }
118    out.push('\n');
119    out.push_str(MANAGED_END);
120    out.push('\n');
121    out
122}
123
124fn append_next_commands(out: &mut String, hints: &CommandHints) {
125    let Some(app_path) = hints.app_path.as_deref() else {
126        return;
127    };
128
129    let profile = hints.profile.as_deref().unwrap_or("full");
130    let agent_pack_dir = hints.agent_pack_dir.as_deref().unwrap_or(".verifyos-agent");
131
132    out.push_str("### Next Commands\n\n");
133    out.push_str("Use these exact commands after each patch batch:\n\n");
134    if hints.shell_script {
135        out.push_str(&format!(
136            "- Shortcut script: `{}/next-steps.sh`\n\n",
137            agent_pack_dir
138        ));
139    }
140    if let Some(prompt_path) = hints.fix_prompt_path.as_deref() {
141        out.push_str(&format!("- Agent fix prompt: `{}`\n\n", prompt_path));
142    }
143    out.push_str("```bash\n");
144    out.push_str(&format!(
145        "voc --app {} --profile {}\n",
146        shell_quote(app_path),
147        profile
148    ));
149    out.push_str(&format!(
150        "voc --app {} --profile {} --format json > report.json\n",
151        shell_quote(app_path),
152        profile
153    ));
154    out.push_str(&format!(
155        "voc --app {} --profile {} --agent-pack {} --agent-pack-format bundle\n",
156        shell_quote(app_path),
157        profile,
158        shell_quote(agent_pack_dir)
159    ));
160    if let Some(baseline) = hints.baseline_path.as_deref() {
161        let mut cmd = format!(
162            "voc init --from-scan {} --profile {} --baseline {} --agent-pack-dir {} --write-commands",
163            shell_quote(app_path),
164            profile,
165            shell_quote(baseline),
166            shell_quote(agent_pack_dir)
167        );
168        if hints.shell_script {
169            cmd.push_str(" --shell-script");
170        }
171        out.push_str(&format!("{cmd}\n"));
172    } else {
173        let mut cmd = format!(
174            "voc init --from-scan {} --profile {} --agent-pack-dir {} --write-commands",
175            shell_quote(app_path),
176            profile,
177            shell_quote(agent_pack_dir)
178        );
179        if hints.shell_script {
180            cmd.push_str(" --shell-script");
181        }
182        out.push_str(&format!("{cmd}\n"));
183    }
184    out.push_str("```\n\n");
185}
186
187pub fn render_fix_prompt(pack: &AgentPack, hints: &CommandHints) -> String {
188    let mut out = String::new();
189    out.push_str("# verifyOS Fix Prompt\n\n");
190    out.push_str(
191        "Patch the current iOS bundle risks conservatively. Prefer minimal, review-safe edits.\n\n",
192    );
193    if let Some(app_path) = hints.app_path.as_deref() {
194        out.push_str(&format!("- App artifact: `{}`\n", app_path));
195    }
196    if let Some(profile) = hints.profile.as_deref() {
197        out.push_str(&format!("- Scan profile: `{}`\n", profile));
198    }
199    if let Some(agent_pack_dir) = hints.agent_pack_dir.as_deref() {
200        out.push_str(&format!("- Agent bundle: `{}`\n", agent_pack_dir));
201    }
202    if let Some(prompt_path) = hints.fix_prompt_path.as_deref() {
203        out.push_str(&format!("- Prompt file: `{}`\n", prompt_path));
204    }
205    out.push('\n');
206
207    if pack.findings.is_empty() {
208        out.push_str("## Findings\n\n- No current findings. Re-run the validation commands to confirm the app is still clean.\n\n");
209    } else {
210        out.push_str("## Findings\n\n");
211        for finding in &pack.findings {
212            out.push_str(&format!(
213                "- **{}** (`{}`)\n",
214                finding.rule_name, finding.rule_id
215            ));
216            out.push_str(&format!("  - Priority: `{}`\n", finding.priority));
217            out.push_str(&format!("  - Scope: `{}`\n", finding.suggested_fix_scope));
218            if !finding.target_files.is_empty() {
219                out.push_str(&format!(
220                    "  - Target files: {}\n",
221                    finding.target_files.join(", ")
222                ));
223            }
224            out.push_str(&format!(
225                "  - Why it fails review: {}\n",
226                finding.why_it_fails_review
227            ));
228            out.push_str(&format!("  - Patch hint: {}\n", finding.patch_hint));
229            out.push_str(&format!("  - Recommendation: {}\n", finding.recommendation));
230        }
231        out.push('\n');
232    }
233
234    out.push_str("## Done When\n\n");
235    out.push_str("- The relevant files are patched without widening permissions or exceptions.\n");
236    out.push_str("- `voc` no longer reports the patched findings.\n");
237    out.push_str("- Updated outputs are regenerated for the next loop.\n\n");
238
239    out.push_str("## Validation Commands\n\n");
240    if let Some(app_path) = hints.app_path.as_deref() {
241        let profile = hints.profile.as_deref().unwrap_or("full");
242        let agent_pack_dir = hints.agent_pack_dir.as_deref().unwrap_or(".verifyos-agent");
243        out.push_str("```bash\n");
244        out.push_str(&format!(
245            "voc --app {} --profile {}\n",
246            shell_quote(app_path),
247            profile
248        ));
249        out.push_str(&format!(
250            "voc --app {} --profile {} --agent-pack {} --agent-pack-format bundle\n",
251            shell_quote(app_path),
252            profile,
253            shell_quote(agent_pack_dir)
254        ));
255        out.push_str("```\n");
256    }
257
258    out
259}
260
261fn append_current_project_risks(out: &mut String, pack: &AgentPack, agent_pack_dir: &str) {
262    out.push_str("### Current Project Risks\n\n");
263    out.push_str(&format!(
264        "- Agent bundle: `{}/agent-pack.json` and `{}/agent-pack.md`\n\n",
265        agent_pack_dir, agent_pack_dir
266    ));
267    if pack.findings.is_empty() {
268        out.push_str(
269            "- No new or regressed risks after applying the latest scan context. Re-run `voc` before release to keep this section fresh.\n\n",
270        );
271        return;
272    }
273
274    let mut findings = pack.findings.clone();
275    findings.sort_by(|a, b| {
276        priority_rank(&a.priority)
277            .cmp(&priority_rank(&b.priority))
278            .then_with(|| a.suggested_fix_scope.cmp(&b.suggested_fix_scope))
279            .then_with(|| a.rule_id.cmp(&b.rule_id))
280    });
281
282    out.push_str("| Priority | Rule ID | Scope | Why it matters |\n");
283    out.push_str("| --- | --- | --- | --- |\n");
284    for finding in &findings {
285        out.push_str(&format!(
286            "| `{}` | `{}` | `{}` | {} |\n",
287            finding.priority,
288            finding.rule_id,
289            finding.suggested_fix_scope,
290            finding.why_it_fails_review
291        ));
292    }
293    out.push('\n');
294
295    out.push_str("#### Suggested Patch Order\n\n");
296    for finding in &findings {
297        out.push_str(&format!(
298            "- **{}** (`{}`)\n",
299            finding.rule_name, finding.rule_id
300        ));
301        out.push_str(&format!("  - Priority: `{}`\n", finding.priority));
302        out.push_str(&format!(
303            "  - Fix scope: `{}`\n",
304            finding.suggested_fix_scope
305        ));
306        if !finding.target_files.is_empty() {
307            out.push_str(&format!(
308                "  - Target files: {}\n",
309                finding.target_files.join(", ")
310            ));
311        }
312        out.push_str(&format!(
313            "  - Why it fails review: {}\n",
314            finding.why_it_fails_review
315        ));
316        out.push_str(&format!("  - Patch hint: {}\n", finding.patch_hint));
317    }
318    out.push('\n');
319}
320
321fn priority_rank(priority: &str) -> u8 {
322    match priority {
323        "high" => 0,
324        "medium" => 1,
325        "low" => 2,
326        _ => 3,
327    }
328}
329
330fn inventory_row(item: &RuleInventoryItem) -> String {
331    format!(
332        "| `{}` | {} | `{:?}` | `{:?}` | `{}` |\n",
333        item.rule_id,
334        item.name,
335        item.category,
336        item.severity,
337        item.default_profiles.join(", ")
338    )
339}
340
341fn managed_block_range(content: &str) -> Option<(usize, usize)> {
342    let start = content.find(MANAGED_START)?;
343    let end_marker = content.find(MANAGED_END)?;
344    Some((start, end_marker + MANAGED_END.len()))
345}
346
347fn shell_quote(value: &str) -> String {
348    if value
349        .chars()
350        .all(|ch| ch.is_ascii_alphanumeric() || "/._-".contains(ch))
351    {
352        value.to_string()
353    } else {
354        format!("'{}'", value.replace('\'', "'\"'\"'"))
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::{build_managed_block, merge_agents_content, CommandHints};
361    use crate::report::{AgentFinding, AgentPack};
362    use crate::rules::core::{RuleCategory, Severity};
363    use std::path::Path;
364
365    #[test]
366    fn merge_agents_content_creates_new_file_when_missing() {
367        let block = build_managed_block(None, None, None);
368        let merged = merge_agents_content(None, &block);
369
370        assert!(merged.starts_with("# AGENTS.md"));
371        assert!(merged.contains("## verifyOS-cli"));
372        assert!(merged.contains("RULE_PRIVACY_MANIFEST"));
373    }
374
375    #[test]
376    fn merge_agents_content_replaces_existing_managed_block() {
377        let block = build_managed_block(None, None, None);
378        let existing = r#"# AGENTS.md
379
380Custom note
381
382<!-- verifyos-cli:agents:start -->
383old block
384<!-- verifyos-cli:agents:end -->
385
386Keep this
387"#;
388
389        let merged = merge_agents_content(Some(existing), &block);
390
391        assert!(merged.contains("Custom note"));
392        assert!(merged.contains("Keep this"));
393        assert!(!merged.contains("old block"));
394        assert_eq!(
395            merged.matches("<!-- verifyos-cli:agents:start -->").count(),
396            1
397        );
398    }
399
400    #[test]
401    fn build_managed_block_includes_current_project_risks_when_scan_exists() {
402        let pack = AgentPack {
403            generated_at_unix: 0,
404            total_findings: 1,
405            findings: vec![AgentFinding {
406                rule_id: "RULE_USAGE_DESCRIPTIONS".to_string(),
407                rule_name: "Missing required usage description keys".to_string(),
408                severity: Severity::Warning,
409                category: RuleCategory::Privacy,
410                priority: "medium".to_string(),
411                message: "Missing NSCameraUsageDescription".to_string(),
412                evidence: None,
413                recommendation: "Add usage descriptions".to_string(),
414                suggested_fix_scope: "Info.plist".to_string(),
415                target_files: vec!["Info.plist".to_string()],
416                patch_hint: "Update Info.plist".to_string(),
417                why_it_fails_review: "Protected APIs require usage strings.".to_string(),
418            }],
419        };
420
421        let block = build_managed_block(Some(&pack), Some(Path::new(".verifyos-agent")), None);
422
423        assert!(block.contains("### Current Project Risks"));
424        assert!(block.contains("#### Suggested Patch Order"));
425        assert!(block.contains("`RULE_USAGE_DESCRIPTIONS`"));
426        assert!(block.contains("Info.plist"));
427        assert!(block.contains(".verifyos-agent/agent-pack.md"));
428    }
429
430    #[test]
431    fn build_managed_block_includes_next_commands_when_requested() {
432        let hints = CommandHints {
433            app_path: Some("examples/bad_app.ipa".to_string()),
434            baseline_path: Some("baseline.json".to_string()),
435            agent_pack_dir: Some(".verifyos-agent".to_string()),
436            profile: Some("basic".to_string()),
437            shell_script: true,
438            fix_prompt_path: Some(".verifyos-agent/fix-prompt.md".to_string()),
439        };
440
441        let block = build_managed_block(None, Some(Path::new(".verifyos-agent")), Some(&hints));
442
443        assert!(block.contains("### Next Commands"));
444        assert!(block.contains("voc --app examples/bad_app.ipa --profile basic"));
445        assert!(block.contains("--baseline baseline.json"));
446        assert!(block.contains("--write-commands"));
447        assert!(block.contains(".verifyos-agent/next-steps.sh"));
448        assert!(block.contains("--shell-script"));
449        assert!(block.contains(".verifyos-agent/fix-prompt.md"));
450    }
451}