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::BTreeMap;
83    use std::collections::HashSet;
84
85    use super::*;
86    use crate::model::context::{AgentContext, FileRange, LoadFile};
87    use crate::model::fields::{NodeType, Span};
88    use crate::model::node::Node;
89
90    fn minimal_node() -> Node {
91        Node {
92            id: "test.node".to_owned(),
93            node_type: NodeType::Facts,
94            summary: "a test node".to_owned(),
95            priority: None,
96            stability: None,
97            confidence: None,
98            status: None,
99            depends: None,
100            related_to: None,
101            replaces: None,
102            conflicts: None,
103            see_also: None,
104            items: None,
105            steps: None,
106            fields: None,
107            input: None,
108            output: None,
109            detail: None,
110            rationale: None,
111            tradeoffs: None,
112            resolution: None,
113            examples: None,
114            notes: None,
115            code: None,
116            code_blocks: None,
117            verify: None,
118            agent_context: None,
119            target: None,
120            execution_status: None,
121            executed_by: None,
122            executed_at: None,
123            execution_log: None,
124            retry_count: None,
125            parallel_groups: None,
126            memory: None,
127            scope: None,
128            applies_when: None,
129            valid_from: None,
130            valid_until: None,
131            tags: None,
132            aliases: None,
133            keywords: None,
134            extra_fields: BTreeMap::new(),
135            span: Span::new(5, 7),
136        }
137    }
138
139    #[test]
140    fn test_validate_context_none_returns_empty() {
141        let node = minimal_node();
142        let all_ids = HashSet::new();
143        let errors = validate_context(&node, &all_ids, &HashSet::new(), "test.agm");
144        assert!(errors.is_empty());
145    }
146
147    #[test]
148    fn test_validate_context_load_nodes_valid_returns_empty() {
149        let mut node = minimal_node();
150        node.agent_context = Some(AgentContext {
151            load_nodes: Some(vec!["auth.login".to_owned()]),
152            load_files: None,
153            system_hint: None,
154            max_tokens: None,
155            load_memory: None,
156        });
157        let mut all_ids = HashSet::new();
158        all_ids.insert("auth.login".to_owned());
159        let errors = validate_context(&node, &all_ids, &HashSet::new(), "test.agm");
160        assert!(errors.is_empty());
161    }
162
163    #[test]
164    fn test_validate_context_load_nodes_unresolved_returns_v004() {
165        let mut node = minimal_node();
166        node.agent_context = Some(AgentContext {
167            load_nodes: Some(vec!["missing.node".to_owned()]),
168            load_files: None,
169            system_hint: None,
170            max_tokens: None,
171            load_memory: None,
172        });
173        let all_ids = HashSet::new();
174        let errors = validate_context(&node, &all_ids, &HashSet::new(), "test.agm");
175        assert!(errors.iter().any(|e| e.code == ErrorCode::V004));
176    }
177
178    #[test]
179    fn test_validate_context_load_files_relative_path_returns_empty() {
180        let mut node = minimal_node();
181        node.agent_context = Some(AgentContext {
182            load_nodes: None,
183            load_files: Some(vec![LoadFile {
184                path: "src/auth.rs".to_owned(),
185                range: FileRange::Full,
186            }]),
187            system_hint: None,
188            max_tokens: None,
189            load_memory: None,
190        });
191        let all_ids = HashSet::new();
192        let errors = validate_context(&node, &all_ids, &HashSet::new(), "test.agm");
193        assert!(errors.is_empty());
194    }
195
196    #[test]
197    fn test_validate_context_load_files_absolute_returns_v015() {
198        let mut node = minimal_node();
199        node.agent_context = Some(AgentContext {
200            load_nodes: None,
201            load_files: Some(vec![LoadFile {
202                path: "/etc/passwd".to_owned(),
203                range: FileRange::Full,
204            }]),
205            system_hint: None,
206            max_tokens: None,
207            load_memory: None,
208        });
209        let all_ids = HashSet::new();
210        let errors = validate_context(&node, &all_ids, &HashSet::new(), "test.agm");
211        assert!(errors.iter().any(|e| e.code == ErrorCode::V015));
212    }
213
214    #[test]
215    fn test_validate_context_load_files_traversal_returns_v015() {
216        let mut node = minimal_node();
217        node.agent_context = Some(AgentContext {
218            load_nodes: None,
219            load_files: Some(vec![LoadFile {
220                path: "src/../../../etc/shadow".to_owned(),
221                range: FileRange::Full,
222            }]),
223            system_hint: None,
224            max_tokens: None,
225            load_memory: None,
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::V015));
230    }
231
232    #[test]
233    fn test_validate_context_load_memory_none_returns_empty() {
234        let node = minimal_node();
235        let all_ids = HashSet::new();
236        let errors = validate_context(&node, &all_ids, &HashSet::new(), "test.agm");
237        assert!(errors.is_empty());
238    }
239
240    #[test]
241    fn test_validate_context_load_memory_valid_returns_empty() {
242        let mut node = minimal_node();
243        node.agent_context = Some(AgentContext {
244            load_nodes: None,
245            load_files: None,
246            system_hint: None,
247            max_tokens: None,
248            load_memory: Some(vec!["rust.repository".to_owned()]),
249        });
250        let all_ids = HashSet::new();
251        let mut all_memory_topics = HashSet::new();
252        all_memory_topics.insert("rust.repository".to_owned());
253        let errors = validate_context(&node, &all_ids, &all_memory_topics, "test.agm");
254        assert!(errors.is_empty());
255    }
256
257    #[test]
258    fn test_validate_context_load_memory_unresolved_returns_v026() {
259        let mut node = minimal_node();
260        node.agent_context = Some(AgentContext {
261            load_nodes: None,
262            load_files: None,
263            system_hint: None,
264            max_tokens: None,
265            load_memory: Some(vec!["rust.repository".to_owned()]),
266        });
267        let all_ids = HashSet::new();
268        let errors = validate_context(&node, &all_ids, &HashSet::new(), "test.agm");
269        assert!(errors.iter().any(|e| e.code == ErrorCode::V026));
270    }
271
272    #[test]
273    fn test_validate_context_load_memory_invalid_format_returns_v025() {
274        let mut node = minimal_node();
275        node.agent_context = Some(AgentContext {
276            load_nodes: None,
277            load_files: None,
278            system_hint: None,
279            max_tokens: None,
280            load_memory: Some(vec!["Rust.Models".to_owned()]),
281        });
282        let all_ids = HashSet::new();
283        let errors = validate_context(&node, &all_ids, &HashSet::new(), "test.agm");
284        assert!(errors.iter().any(|e| e.code == ErrorCode::V025));
285    }
286}