Skip to main content

assay_core/agentic/
mod.rs

1// crates/assay-core/src/agentic/mod.rs
2// A reusable "Agentic Contract" builder that turns Diagnostics into:
3// - suggested_actions (commands to run)
4// - suggested_patches (JSON Patch ops, machine-applicable)
5//
6// This is intentionally conservative + deterministic.
7
8use 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, // path relative to cwd (or absolute)
37    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, // allow/deny at root
52    ToolsMap, // tools.allow/tools.deny
53}
54
55#[derive(Debug, Clone)]
56struct PolicyCacheEntry {
57    doc: serde_yaml::Value,
58    shape: PolicyShape,
59}
60
61/// Context for Agentic suggestions.
62///
63/// This provides the "world view" needed to generate relevant fixes,
64/// such as where the policy file is located or what the assay config path is.
65pub struct AgenticCtx {
66    /// Optional: path to the *policy* file (policy.yaml).
67    /// If not set, we fall back to diagnostics.context.policy_file or "policy.yaml".
68    pub policy_path: Option<PathBuf>,
69
70    /// Optional: path to the *assay config* file (assay.yaml).
71    /// If not set, we fall back to diagnostics.context.config_file or "assay.yaml".
72    pub config_path: Option<PathBuf>,
73}
74
75/// Main entrypoint: build suggestions for any diagnostics list.
76///
77/// Analyzes a list of `Diagnostic` items and generates:
78/// 1. `SuggestedAction`: High-level commands (e.g., `assay fix`, `mkdir`).
79/// 2. `SuggestedPatch`: Concrete JSON Patch operations to apply to files.
80///
81/// The generation is deterministic and stateless (except for reading files referenced in context).
82pub fn build_suggestions(
83    diags: &[Diagnostic],
84    ctx: &AgenticCtx,
85) -> (Vec<SuggestedAction>, Vec<SuggestedPatch>) {
86    // Deduplication maps
87    let mut actions_map: BTreeMap<String, SuggestedAction> = BTreeMap::new();
88    let mut patches_map: BTreeMap<String, SuggestedPatch> = BTreeMap::new();
89
90    // Default policy path lookup
91    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    // Policy docs are per-file; cache them (deterministic + avoids repeated IO).
102    let mut policy_cache: BTreeMap<String, PolicyCacheEntry> = BTreeMap::new();
103
104    for d in diags {
105        // Resolve policy path for this specific diagnostic
106        // Priority: 1. diag.context.policy_file, 2. ctx.policy_path, 3. "policy.yaml"
107        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        // Resolve assay config path for this diagnostic
115        // Priority: 1. diag.context.config_file, 2. ctx.config_path, 3. "assay.yaml"
116        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        // Load this policy doc (if available) and detect shape for pointers.
124        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            // ----------------------------
131            // YAML parse errors -> actions
132            // ----------------------------
133            "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            // --------------------------------
148            // Unknown field -> rename via move
149            // --------------------------------
150            "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            // --------------------------------------------------
194            // Unknown tool -> Action only (no patch, safer)
195            // --------------------------------------------------
196            "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            // --------------------------------------------------
220            // Tool not allowed -> add to allowlist (Patch)
221            // --------------------------------------------------
222            "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                    // If allowlist is "*" then tool-not-allowed is likely not the issue.
227                    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            // --------------------------------------------------
252            // Tool denied -> remove from denylist (High risk)
253            // --------------------------------------------------
254            "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            // --------------------------------------------------
298            // Path scope/arg blocked -> add a constraint (Medium)
299            // --------------------------------------------------
300            "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            // --------------------------------------------------
339            // Tool poisoning -> Action only (Conservative for v1.5)
340            // Avoids complex Replace/Add branching without EnsureObject
341            // --------------------------------------------------
342            "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                            // IMPORTANT: `assay fix --config` points to assay.yaml (the config),
358                            // not policy.yaml. The fixer can then follow `policy:` inside assay.yaml.
359                            config_path_str.clone(),
360                        ],
361                    },
362                );
363            }
364
365            // --------------------------------------------------
366            // Missing paths in assay.yaml
367            // --------------------------------------------------
368            "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            // --------------------------------------------------
428            // Trace drift -> action with context-aware filename
429            // --------------------------------------------------
430            "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            // --------------------------------------------------
458            // Baseline mismatch
459            // --------------------------------------------------
460            "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    // Convert BTreeMaps to Vecs (already sorted by id key)
483    (
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    // Check if `tools` key exists and is a mapping
498    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        // Robust check: it's only the "ToolsMap" shape if allow/deny are SEQUENCES inside tools
505        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    // Prefer candidates[0] if present; else none.
542    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
549// --- JSON Pointer helpers for YAML doc inspection (only for indexing remove ops) ---
550
551fn 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    // special case: root
576    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    // JSON Pointer escaping
599    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        // Top Level
680        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        // Tools Map (Legacy/Standard)
687        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        // Tools as explicit map (Bug regression check)
701        // If tools is just a map of definitions, it should NOT be detected as ToolsMap
702        // unless it has allow/deny sequences.
703        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}