Skip to main content

agm_core/validator/
context.rs

1//! Agent context validation (spec S25).
2//!
3//! Pass 3 (structural): validates agent_context field on a node.
4
5use std::collections::HashSet;
6
7use crate::error::codes::ErrorCode;
8use crate::error::diagnostic::{AgmError, ErrorLocation, Severity};
9use crate::model::node::Node;
10
11/// Returns true if the path is unsafe (absolute or contains traversal).
12fn is_unsafe_path(path: &str) -> bool {
13    path.starts_with('/') || path.starts_with('\\') || path.contains("..")
14}
15
16/// Validates the `agent_context` field on a node.
17///
18/// Rules: V004 (unresolved load_nodes reference), V015 (unsafe load_files
19/// path), V025/V026 (invalid or unresolved load_memory topics).
20#[must_use]
21pub fn validate_context(
22    node: &Node,
23    all_ids: &HashSet<String>,
24    all_memory_topics: &HashSet<String>,
25    file_name: &str,
26) -> Vec<AgmError> {
27    let ctx = match &node.agent_context {
28        Some(c) => c,
29        None => return Vec::new(),
30    };
31
32    let mut errors = Vec::new();
33    let line = node.span.start_line;
34    let id = node.id.as_str();
35
36    // V004 — load_nodes must reference existing node IDs
37    if let Some(ref load_nodes) = ctx.load_nodes {
38        for ref_id in load_nodes {
39            if !all_ids.contains(ref_id.as_str()) {
40                errors.push(AgmError::new(
41                    ErrorCode::V004,
42                    format!(
43                        "Unresolved reference `{ref_id}` in `agent_context.load_nodes` of node `{id}`"
44                    ),
45                    ErrorLocation::full(file_name, line, id),
46                ));
47            }
48        }
49    }
50
51    // V015 — load_files paths must be relative and traversal-free (warning)
52    if let Some(ref load_files) = ctx.load_files {
53        for load_file in load_files {
54            if is_unsafe_path(&load_file.path) {
55                errors.push(AgmError::with_severity(
56                    ErrorCode::V015,
57                    Severity::Warning,
58                    format!(
59                        "`target` path is absolute or contains traversal: `{}`",
60                        load_file.path
61                    ),
62                    ErrorLocation::full(file_name, line, id),
63                ));
64            }
65        }
66    }
67
68    // V025/V026 — load_memory topics must be valid and resolvable
69    if let Some(ref load_memory) = ctx.load_memory {
70        errors.extend(crate::memory::schema::validate_load_memory(
71            load_memory,
72            all_memory_topics,
73            ErrorLocation::full(file_name, line, id),
74        ));
75    }
76
77    errors
78}
79
80#[cfg(test)]
81mod tests {
82    use std::collections::HashSet;
83
84    use super::*;
85    use crate::model::context::{AgentContext, FileRange, LoadFile};
86    use crate::model::fields::{NodeType, Span};
87    use crate::model::node::Node;
88
89    fn minimal_node() -> Node {
90        Node {
91            id: "test.node".to_owned(),
92            node_type: NodeType::Facts,
93            summary: "a test node".to_owned(),
94            span: Span::new(5, 7),
95            ..Default::default()
96        }
97    }
98
99    #[test]
100    fn test_validate_context_none_returns_empty() {
101        let node = minimal_node();
102        let all_ids = HashSet::new();
103        let errors = validate_context(&node, &all_ids, &HashSet::new(), "test.agm");
104        assert!(errors.is_empty());
105    }
106
107    #[test]
108    fn test_validate_context_load_nodes_valid_returns_empty() {
109        let mut node = minimal_node();
110        node.agent_context = Some(AgentContext {
111            load_nodes: Some(vec!["auth.login".to_owned()]),
112            load_files: None,
113            system_hint: None,
114            max_tokens: None,
115            load_memory: None,
116        });
117        let mut all_ids = HashSet::new();
118        all_ids.insert("auth.login".to_owned());
119        let errors = validate_context(&node, &all_ids, &HashSet::new(), "test.agm");
120        assert!(errors.is_empty());
121    }
122
123    #[test]
124    fn test_validate_context_load_nodes_unresolved_returns_v004() {
125        let mut node = minimal_node();
126        node.agent_context = Some(AgentContext {
127            load_nodes: Some(vec!["missing.node".to_owned()]),
128            load_files: None,
129            system_hint: None,
130            max_tokens: None,
131            load_memory: None,
132        });
133        let all_ids = HashSet::new();
134        let errors = validate_context(&node, &all_ids, &HashSet::new(), "test.agm");
135        assert!(errors.iter().any(|e| e.code == ErrorCode::V004));
136    }
137
138    #[test]
139    fn test_validate_context_load_files_relative_path_returns_empty() {
140        let mut node = minimal_node();
141        node.agent_context = Some(AgentContext {
142            load_nodes: None,
143            load_files: Some(vec![LoadFile {
144                path: "src/auth.rs".to_owned(),
145                range: FileRange::Full,
146            }]),
147            system_hint: None,
148            max_tokens: None,
149            load_memory: None,
150        });
151        let all_ids = HashSet::new();
152        let errors = validate_context(&node, &all_ids, &HashSet::new(), "test.agm");
153        assert!(errors.is_empty());
154    }
155
156    #[test]
157    fn test_validate_context_load_files_absolute_returns_v015() {
158        let mut node = minimal_node();
159        node.agent_context = Some(AgentContext {
160            load_nodes: None,
161            load_files: Some(vec![LoadFile {
162                path: "/etc/passwd".to_owned(),
163                range: FileRange::Full,
164            }]),
165            system_hint: None,
166            max_tokens: None,
167            load_memory: None,
168        });
169        let all_ids = HashSet::new();
170        let errors = validate_context(&node, &all_ids, &HashSet::new(), "test.agm");
171        assert!(errors.iter().any(|e| e.code == ErrorCode::V015));
172    }
173
174    #[test]
175    fn test_validate_context_load_files_traversal_returns_v015() {
176        let mut node = minimal_node();
177        node.agent_context = Some(AgentContext {
178            load_nodes: None,
179            load_files: Some(vec![LoadFile {
180                path: "src/../../../etc/shadow".to_owned(),
181                range: FileRange::Full,
182            }]),
183            system_hint: None,
184            max_tokens: None,
185            load_memory: None,
186        });
187        let all_ids = HashSet::new();
188        let errors = validate_context(&node, &all_ids, &HashSet::new(), "test.agm");
189        assert!(errors.iter().any(|e| e.code == ErrorCode::V015));
190    }
191
192    #[test]
193    fn test_validate_context_load_memory_none_returns_empty() {
194        let node = minimal_node();
195        let all_ids = HashSet::new();
196        let errors = validate_context(&node, &all_ids, &HashSet::new(), "test.agm");
197        assert!(errors.is_empty());
198    }
199
200    #[test]
201    fn test_validate_context_load_memory_valid_returns_empty() {
202        let mut node = minimal_node();
203        node.agent_context = Some(AgentContext {
204            load_nodes: None,
205            load_files: None,
206            system_hint: None,
207            max_tokens: None,
208            load_memory: Some(vec!["rust.repository".to_owned()]),
209        });
210        let all_ids = HashSet::new();
211        let mut all_memory_topics = HashSet::new();
212        all_memory_topics.insert("rust.repository".to_owned());
213        let errors = validate_context(&node, &all_ids, &all_memory_topics, "test.agm");
214        assert!(errors.is_empty());
215    }
216
217    #[test]
218    fn test_validate_context_load_memory_unresolved_returns_v026() {
219        let mut node = minimal_node();
220        node.agent_context = Some(AgentContext {
221            load_nodes: None,
222            load_files: None,
223            system_hint: None,
224            max_tokens: None,
225            load_memory: Some(vec!["rust.repository".to_owned()]),
226        });
227        let all_ids = HashSet::new();
228        let errors = validate_context(&node, &all_ids, &HashSet::new(), "test.agm");
229        assert!(errors.iter().any(|e| e.code == ErrorCode::V026));
230    }
231
232    #[test]
233    fn test_validate_context_load_memory_invalid_format_returns_v025() {
234        let mut node = minimal_node();
235        node.agent_context = Some(AgentContext {
236            load_nodes: None,
237            load_files: None,
238            system_hint: None,
239            max_tokens: None,
240            load_memory: Some(vec!["Rust.Models".to_owned()]),
241        });
242        let all_ids = HashSet::new();
243        let errors = validate_context(&node, &all_ids, &HashSet::new(), "test.agm");
244        assert!(errors.iter().any(|e| e.code == ErrorCode::V025));
245    }
246}