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}