1use serde::{Deserialize, Serialize};
9use serde_json::Value as JsonValue;
10use std::collections::BTreeMap;
11use std::path::{Path, PathBuf};
12
13use crate::errors::diagnostic::Diagnostic;
14
15#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
16#[serde(rename_all = "lowercase")]
17pub enum RiskLevel {
18 Low,
19 Medium,
20 High,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct SuggestedAction {
25 pub id: String,
26 pub title: String,
27 pub risk: RiskLevel,
28 pub command: Vec<String>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct SuggestedPatch {
33 pub id: String,
34 pub title: String,
35 pub risk: RiskLevel,
36 pub file: String, pub ops: Vec<JsonPatchOp>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41#[serde(tag = "op", rename_all = "lowercase")]
42pub enum JsonPatchOp {
43 Add { path: String, value: JsonValue },
44 Remove { path: String },
45 Replace { path: String, value: JsonValue },
46 Move { from: String, path: String },
47}
48
49#[derive(Debug, Clone, Copy, PartialEq)]
50enum PolicyShape {
51 TopLevel, ToolsMap, }
54
55#[derive(Debug, Clone)]
56struct PolicyCacheEntry {
57 doc: serde_yaml::Value,
58 shape: PolicyShape,
59}
60
61pub struct AgenticCtx {
66 pub policy_path: Option<PathBuf>,
69
70 pub config_path: Option<PathBuf>,
73}
74
75pub fn build_suggestions(
83 diags: &[Diagnostic],
84 ctx: &AgenticCtx,
85) -> (Vec<SuggestedAction>, Vec<SuggestedPatch>) {
86 let mut actions_map: BTreeMap<String, SuggestedAction> = BTreeMap::new();
88 let mut patches_map: BTreeMap<String, SuggestedPatch> = BTreeMap::new();
89
90 let default_policy = ctx
92 .policy_path
93 .clone()
94 .unwrap_or_else(|| PathBuf::from("policy.yaml"));
95
96 let default_config = ctx
97 .config_path
98 .clone()
99 .unwrap_or_else(|| PathBuf::from("assay.yaml"));
100
101 let mut policy_cache: BTreeMap<String, PolicyCacheEntry> = BTreeMap::new();
103
104 for d in diags {
105 let policy_path_str = d
108 .context
109 .get("policy_file")
110 .and_then(|v| v.as_str())
111 .map(|s| s.to_string())
112 .unwrap_or_else(|| default_policy.display().to_string());
113
114 let config_path_str = d
117 .context
118 .get("config_file")
119 .and_then(|v| v.as_str())
120 .map(|s| s.to_string())
121 .unwrap_or_else(|| default_config.display().to_string());
122
123 let (policy_doc_this, policy_shape_this) =
125 get_policy_entry(&mut policy_cache, &policy_path_str)
126 .map(|(doc, shape)| (Some(doc), shape))
127 .unwrap_or((None, PolicyShape::TopLevel));
128
129 match d.code.as_str() {
130 "E_CFG_PARSE" | "E_POLICY_PARSE" => {
134 let id = "regen_config".to_string();
135 actions_map.insert(
136 id.clone(),
137 SuggestedAction {
138 id,
139 title: "Regenerate a clean config (does not overwrite existing files)"
140 .into(),
141 risk: RiskLevel::Low,
142 command: vec!["assay".into(), "init".into()],
143 },
144 );
145 }
146
147 "E_CFG_SCHEMA_UNKNOWN_FIELD" | "E_POLICY_SCHEMA_UNKNOWN_FIELD" => {
151 let file = d.context.get("file").and_then(|v: &JsonValue| v.as_str());
152 let parent = d
153 .context
154 .get("json_pointer_parent")
155 .and_then(|v: &JsonValue| v.as_str());
156 let unknown = d
157 .context
158 .get("unknown_field")
159 .and_then(|v: &JsonValue| v.as_str());
160 let suggested = d
161 .context
162 .get("suggested_field")
163 .and_then(|v: &JsonValue| v.as_str());
164
165 if let (Some(file), Some(parent), Some(unknown), Some(suggested)) =
166 (file, parent, unknown, suggested)
167 {
168 let id = format!("rename_field:{}->{}", unknown, suggested);
169 let from = format!(
170 "{}/{}",
171 parent.trim_end_matches('/'),
172 escape_pointer(unknown)
173 );
174 let to = format!(
175 "{}/{}",
176 parent.trim_end_matches('/'),
177 escape_pointer(suggested)
178 );
179
180 patches_map.insert(
181 id.clone(),
182 SuggestedPatch {
183 id,
184 title: format!("Rename field '{}' to '{}'", unknown, suggested),
185 risk: RiskLevel::Low,
186 file: file.to_string(),
187 ops: vec![JsonPatchOp::Move { from, path: to }],
188 },
189 );
190 }
191 }
192
193 "UNKNOWN_TOOL" => {
197 if let Some(tool) = d.context.get("tool").and_then(|v: &JsonValue| v.as_str()) {
198 let id = format!("fix_unknown_tool:{}", tool);
199 actions_map.insert(
200 id.clone(),
201 SuggestedAction {
202 id,
203 title: format!(
204 "Verify if tool '{}' exists and is named correctly in policy",
205 tool
206 ),
207 risk: RiskLevel::Low,
208 command: vec![
209 "assay".into(),
210 "doctor".into(),
211 "--format".into(),
212 "json".into(),
213 ],
214 },
215 );
216 }
217 }
218
219 "MCP_TOOL_NOT_ALLOWED" | "E_TOOL_NOT_ALLOWED" => {
223 if let Some(tool) = d.context.get("tool").and_then(|v: &JsonValue| v.as_str()) {
224 let (allow_ptr, _) = policy_pointers(policy_shape_this);
225
226 let allow_is_wildcard = policy_doc_this
228 .and_then(|doc| get_seq_strings(doc, allow_ptr))
229 .map(|xs| xs.iter().any(|s| s == "*"))
230 .unwrap_or(false);
231
232 if !allow_is_wildcard {
233 let id = format!("allow_tool:{}", tool);
234 patches_map.insert(
235 id.clone(),
236 SuggestedPatch {
237 id,
238 title: format!("Allow tool '{}'", tool),
239 risk: RiskLevel::High,
240 file: policy_path_str.clone(),
241 ops: vec![JsonPatchOp::Add {
242 path: format!("{}/-", allow_ptr),
243 value: JsonValue::String(tool.to_string()),
244 }],
245 },
246 );
247 }
248 }
249 }
250
251 "E_EXEC_DENIED" | "MCP_TOOL_DENIED" | "E_TOOL_DENIED" => {
255 if let Some(tool) = d.context.get("tool").and_then(|v: &JsonValue| v.as_str()) {
256 let (_, deny_ptr) = policy_pointers(policy_shape_this);
257
258 if let Some(doc) = policy_doc_this {
259 if let Some(idx) = find_in_seq(doc, deny_ptr, tool) {
260 let id = format!("remove_deny:{}", tool);
261 patches_map.insert(
262 id.clone(),
263 SuggestedPatch {
264 id,
265 title: format!("Remove '{}' from denylist", tool),
266 risk: RiskLevel::High,
267 file: policy_path_str.clone(),
268 ops: vec![JsonPatchOp::Remove {
269 path: format!("{}/{}", deny_ptr, idx),
270 }],
271 },
272 );
273 } else {
274 let id = format!("manual_remove_deny:{}", tool);
275 actions_map.insert(
276 id.clone(),
277 SuggestedAction {
278 id,
279 title: format!(
280 "Manually remove '{}' from denylist in {}",
281 tool, policy_path_str
282 ),
283 risk: RiskLevel::High,
284 command: vec![
285 "assay".into(),
286 "doctor".into(),
287 "--format".into(),
288 "json".into(),
289 ],
290 },
291 );
292 }
293 }
294 }
295 }
296
297 "E_PATH_SCOPE_VIOLATION" | "E_ARG_PATTERN_BLOCKED" | "E_CONSTRAINT_MISSING" => {
301 let tool = d
302 .context
303 .get("tool")
304 .and_then(|v: &JsonValue| v.as_str())
305 .unwrap_or("read_file");
306 let param = d
307 .context
308 .get("param")
309 .and_then(|v: &JsonValue| v.as_str())
310 .unwrap_or("path");
311 let re = d
312 .context
313 .get("recommended_matches")
314 .and_then(|v: &JsonValue| v.as_str())
315 .unwrap_or("^/app/.*|^/data/.*");
316
317 let id = format!("add_constraint:{}:{}", tool, param);
318 patches_map.insert(
319 id.clone(),
320 SuggestedPatch {
321 id,
322 title: format!("Add constraint {}.{} matches {}", tool, param, re),
323 risk: RiskLevel::Medium,
324 file: policy_path_str.clone(),
325 ops: vec![JsonPatchOp::Add {
326 path: "/constraints/-".into(),
327 value: serde_json::json!({
328 "tool": tool,
329 "params": {
330 param: { "matches": re }
331 }
332 }),
333 }],
334 },
335 );
336 }
337
338 "E_TOOL_POISONING_PATTERN" | "E_TOOL_DESC_SUSPICIOUS" | "E_SIGNATURES_DISABLED" => {
343 let id = "enable_tool_poisoning_checks".to_string();
344 actions_map.insert(
345 id.clone(),
346 SuggestedAction {
347 id,
348 title: format!(
349 "Enable tool poisoning heuristics (check_descriptions) in {}",
350 policy_path_str
351 ),
352 risk: RiskLevel::Low,
353 command: vec![
354 "assay".into(),
355 "fix".into(),
356 "--config".into(),
357 config_path_str.clone(),
360 ],
361 },
362 );
363 }
364
365 "E_PATH_NOT_FOUND" | "E_CFG_REF_MISSING" | "E_BASELINE_NOT_FOUND" => {
369 let file = d
370 .context
371 .get("file")
372 .and_then(|v: &JsonValue| v.as_str())
373 .unwrap_or("assay.yaml");
374 let field = d.context.get("field").and_then(|v: &JsonValue| v.as_str());
375
376 if file.ends_with("assay.yaml") {
377 if let Some(field) = field {
378 if field == "policy" {
379 if let Some(best) = best_candidate(&d.context) {
380 let id = "fix_assay_policy_path".to_string();
381 patches_map.insert(
382 id.clone(),
383 SuggestedPatch {
384 id,
385 title: format!("Update assay.yaml policy path → {}", best),
386 risk: RiskLevel::Low,
387 file: file.to_string(),
388 ops: vec![JsonPatchOp::Replace {
389 path: "/policy".into(),
390 value: JsonValue::String(best),
391 }],
392 },
393 );
394 }
395 }
396 if field == "baseline" {
397 let id = "fix_baseline_path".to_string();
398 patches_map.insert(
399 id.clone(),
400 SuggestedPatch {
401 id,
402 title: "Set baseline path to .assay/baseline.json".into(),
403 risk: RiskLevel::Low,
404 file: file.to_string(),
405 ops: vec![JsonPatchOp::Replace {
406 path: "/baseline".into(),
407 value: JsonValue::String(".assay/baseline.json".into()),
408 }],
409 },
410 );
411
412 let action_id = "create_baseline_dir".to_string();
413 actions_map.insert(
414 action_id.clone(),
415 SuggestedAction {
416 id: action_id,
417 title: "Create baseline directory".into(),
418 risk: RiskLevel::Low,
419 command: vec!["mkdir".into(), "-p".into(), ".assay".into()],
420 },
421 );
422 }
423 }
424 }
425 }
426
427 "E_TRACE_SCHEMA_DRIFT" | "E_TRACE_SCHEMA_INVALID" | "E_TRACE_LEGACY_FUNCTION_CALL" => {
431 let raw_trace_file = d
432 .context
433 .get("trace_file")
434 .and_then(|v| v.as_str())
435 .unwrap_or("<raw.jsonl>");
436
437 let id = "normalize_trace".to_string();
438 actions_map.insert(
439 id.clone(),
440 SuggestedAction {
441 id,
442 title: "Normalize traces to Assay V2 schema".into(),
443 risk: RiskLevel::Low,
444 command: vec![
445 "assay".into(),
446 "trace".into(),
447 "ingest".into(),
448 "--input".into(),
449 raw_trace_file.to_string(),
450 "--output".into(),
451 "traces.jsonl".into(),
452 ],
453 },
454 );
455 }
456
457 "E_BASE_MISMATCH" | "E_BASELINE_SUITE_MISMATCH" => {
461 let id = "export_baseline".to_string();
462 actions_map.insert(
463 id.clone(),
464 SuggestedAction {
465 id,
466 title: "Export a new baseline from the current run".into(),
467 risk: RiskLevel::Low,
468 command: vec![
469 "assay".into(),
470 "run".into(),
471 "--export-baseline".into(),
472 ".assay/baseline.json".into(),
473 ],
474 },
475 );
476 }
477
478 _ => {}
479 }
480 }
481
482 (
484 actions_map.into_values().collect(),
485 patches_map.into_values().collect(),
486 )
487}
488
489fn policy_pointers(shape: PolicyShape) -> (&'static str, &'static str) {
490 match shape {
491 PolicyShape::TopLevel => ("/allow", "/deny"),
492 PolicyShape::ToolsMap => ("/tools/allow", "/tools/deny"),
493 }
494}
495
496fn detect_policy_shape(doc: &serde_yaml::Value) -> PolicyShape {
497 let tools_map_opt = doc
499 .as_mapping()
500 .and_then(|m| m.get(serde_yaml::Value::String("tools".into())))
501 .and_then(|v| v.as_mapping());
502
503 if let Some(tm) = tools_map_opt {
504 let has_allow = tm
506 .get(serde_yaml::Value::String("allow".into()))
507 .and_then(|v| v.as_sequence())
508 .is_some();
509 let has_deny = tm
510 .get(serde_yaml::Value::String("deny".into()))
511 .and_then(|v| v.as_sequence())
512 .is_some();
513
514 if has_allow || has_deny {
515 return PolicyShape::ToolsMap;
516 }
517 }
518 PolicyShape::TopLevel
519}
520
521fn read_yaml(path: &Path) -> Option<serde_yaml::Value> {
522 let s = std::fs::read_to_string(path).ok()?;
523 serde_yaml::from_str::<serde_yaml::Value>(&s).ok()
524}
525
526fn get_policy_entry<'a>(
527 cache: &'a mut BTreeMap<String, PolicyCacheEntry>,
528 path_str: &str,
529) -> Option<(&'a serde_yaml::Value, PolicyShape)> {
530 if !cache.contains_key(path_str) {
531 let pb = PathBuf::from(path_str);
532 if let Some(doc) = read_yaml(&pb) {
533 let shape = detect_policy_shape(&doc);
534 cache.insert(path_str.to_string(), PolicyCacheEntry { doc, shape });
535 }
536 }
537 cache.get(path_str).map(|e| (&e.doc, e.shape))
538}
539
540fn best_candidate(ctx: &serde_json::Value) -> Option<String> {
541 ctx.get("candidates")
543 .and_then(|v| v.as_array())
544 .and_then(|arr| arr.first())
545 .and_then(|v| v.as_str())
546 .map(|s| s.to_string())
547}
548
549fn get_seq_strings(doc: &serde_yaml::Value, ptr: &str) -> Option<Vec<String>> {
552 let node = yaml_ptr(doc, ptr)?;
553 let seq = node.as_sequence()?;
554 let mut out = Vec::new();
555 for it in seq {
556 if let Some(s) = it.as_str() {
557 out.push(s.to_string());
558 }
559 }
560 Some(out)
561}
562
563fn find_in_seq(doc: &serde_yaml::Value, ptr: &str, target: &str) -> Option<usize> {
564 let node = yaml_ptr(doc, ptr)?;
565 let seq = node.as_sequence()?;
566 for (i, it) in seq.iter().enumerate() {
567 if it.as_str() == Some(target) {
568 return Some(i);
569 }
570 }
571 None
572}
573
574fn yaml_ptr<'a>(doc: &'a serde_yaml::Value, ptr: &str) -> Option<&'a serde_yaml::Value> {
575 if ptr.is_empty() || ptr == "/" {
577 return Some(doc);
578 }
579
580 let mut cur = doc;
581 for token in ptr.split('/').skip(1) {
582 let key = unescape_pointer(token);
583 match cur {
584 serde_yaml::Value::Mapping(m) => {
585 cur = m.get(serde_yaml::Value::String(key))?;
586 }
587 serde_yaml::Value::Sequence(seq) => {
588 let idx: usize = key.parse().ok()?;
589 cur = seq.get(idx)?;
590 }
591 _ => return None,
592 }
593 }
594 Some(cur)
595}
596
597fn escape_pointer(s: &str) -> String {
598 s.replace('~', "~0").replace('/', "~1")
600}
601fn unescape_pointer(s: &str) -> String {
602 s.replace("~1", "/").replace("~0", "~")
603}
604
605#[cfg(test)]
606mod tests {
607 use super::*;
608 use serde_json::json;
609
610 #[test]
611 fn test_deduplication() {
612 let diags = vec![
613 Diagnostic::new("E_CFG_PARSE", "Error 1"),
614 Diagnostic::new("E_CFG_PARSE", "Error 2"),
615 ];
616 let ctx = AgenticCtx {
617 policy_path: None,
618 config_path: None,
619 };
620 let (actions, patches) = build_suggestions(&diags, &ctx);
621
622 assert_eq!(actions.len(), 1);
623 assert_eq!(actions[0].id, "regen_config");
624 assert!(patches.is_empty());
625 }
626
627 #[test]
628 fn test_unknown_tool_action_only() {
629 let mut d = Diagnostic::new("UNKNOWN_TOOL", "Unknown tool");
630 d.context = json!({ "tool": "weird-tool" });
631
632 let diags = vec![d];
633 let ctx = AgenticCtx {
634 policy_path: None,
635 config_path: None,
636 };
637 let (actions, patches) = build_suggestions(&diags, &ctx);
638
639 assert_eq!(actions.len(), 1);
640 assert_eq!(actions[0].id, "fix_unknown_tool:weird-tool");
641 assert!(
642 patches.is_empty(),
643 "UNKNOWN_TOOL should not generate patches"
644 );
645 }
646
647 #[test]
648 fn test_rename_field_patch() {
649 let mut d = Diagnostic::new("E_CFG_SCHEMA_UNKNOWN_FIELD", "Unknown field");
650 d.context = json!({
651 "file": "assay.yaml",
652 "json_pointer_parent": "/config",
653 "unknown_field": "policcy",
654 "suggested_field": "policy"
655 });
656
657 let diags = vec![d];
658 let ctx = AgenticCtx {
659 policy_path: None,
660 config_path: None,
661 };
662 let (_, patches) = build_suggestions(&diags, &ctx);
663
664 assert_eq!(patches.len(), 1);
665 let p = &patches[0];
666 assert_eq!(p.id, "rename_field:policcy->policy");
667
668 match &p.ops[0] {
669 JsonPatchOp::Move { from, path } => {
670 assert_eq!(from, "/config/policcy");
671 assert_eq!(path, "/config/policy");
672 }
673 _ => panic!("Expected Move op"),
674 }
675 }
676
677 #[test]
678 fn test_detect_policy_shape() {
679 let doc1: serde_yaml::Value = serde_yaml::from_str("allow: []\ndeny: []").unwrap();
681 match detect_policy_shape(&doc1) {
682 PolicyShape::TopLevel => {}
683 _ => panic!("Expected TopLevel"),
684 }
685
686 let doc2: serde_yaml::Value = serde_yaml::from_str(
688 r#"
689tools:
690 allow: ["read_file"]
691 deny: []
692"#,
693 )
694 .unwrap();
695 match detect_policy_shape(&doc2) {
696 PolicyShape::ToolsMap => {}
697 _ => panic!("Expected ToolsMap"),
698 }
699
700 let doc3: serde_yaml::Value = serde_yaml::from_str(
704 r#"
705tools:
706 my-tool:
707 image: python:3.9
708"#,
709 )
710 .unwrap();
711 match detect_policy_shape(&doc3) {
712 PolicyShape::TopLevel => {}
713 _ => panic!("Expected TopLevel for tools definition map"),
714 }
715 }
716
717 #[test]
718 fn test_tool_poisoning_action_uses_assay_config_not_policy() {
719 let mut d = Diagnostic::new("E_TOOL_DESC_SUSPICIOUS", "Suspicious tool description");
720 d.context = json!({
721 "policy_file": "policy.yaml",
722 "config_file": "assay.yaml"
723 });
724
725 let diags = vec![d];
726 let ctx = AgenticCtx {
727 policy_path: None,
728 config_path: None,
729 };
730 let (actions, _patches) = build_suggestions(&diags, &ctx);
731
732 let a = actions
733 .iter()
734 .find(|a| a.id == "enable_tool_poisoning_checks")
735 .expect("expected enable_tool_poisoning_checks action");
736
737 assert_eq!(a.command[0], "assay");
738 assert_eq!(a.command[1], "fix");
739 assert_eq!(a.command[2], "--config");
740 assert_eq!(a.command[3], "assay.yaml");
741 }
742}