Skip to main content

everruns_core/
path_identity.rs

1// Model-visible path identity conformance (EVE-750).
2//
3// Shared helpers for asserting that serialized values exposed to the model use
4// the active `SessionFileSystem` display identity. The contract is documented
5// in `specs/file-store.md` under "Model-visible path identity".
6
7use crate::session_path::WORKSPACE_PREFIX;
8use crate::traits::SessionFileSystem;
9use serde_json::Value;
10
11/// Expected path identity for a filesystem backend.
12#[derive(Debug, Clone)]
13pub struct PathIdentityExpectations {
14    /// Canonical visible root (`/workspace` for mounted agent contexts, host path
15    /// for direct real-disk integrations).
16    pub expected_root: String,
17    /// Prefixes that must not appear in model-visible absolute paths.
18    pub forbidden_prefixes: Vec<String>,
19    /// Additional mount namespaces allowed even when they share a substring with
20    /// a forbidden primary-root alias (e.g. `/workspace/roots/backend/`).
21    pub allowed_mount_prefixes: Vec<String>,
22}
23
24impl PathIdentityExpectations {
25    /// Derive expectations from a live store.
26    pub fn for_store(store: &dyn SessionFileSystem) -> Self {
27        let root = store.display_root();
28        if root == WORKSPACE_PREFIX {
29            Self::vfs()
30        } else {
31            Self::host_backed(&root)
32        }
33    }
34
35    /// In-memory/VFS sessions expose `/workspace`.
36    pub fn vfs() -> Self {
37        Self {
38            expected_root: WORKSPACE_PREFIX.to_string(),
39            forbidden_prefixes: vec![],
40            allowed_mount_prefixes: vec![],
41        }
42    }
43
44    /// Direct host-backed integrations expose the real root and must not emit
45    /// `/workspace` except under explicit secondary-mount namespaces.
46    pub fn host_backed(root: &str) -> Self {
47        Self {
48            expected_root: root.to_string(),
49            forbidden_prefixes: vec![WORKSPACE_PREFIX.to_string()],
50            allowed_mount_prefixes: vec![format!("{WORKSPACE_PREFIX}/roots/")],
51        }
52    }
53
54    fn is_allowed_path(&self, path: &str) -> bool {
55        self.allowed_mount_prefixes
56            .iter()
57            .any(|prefix| path.starts_with(prefix))
58    }
59
60    fn violates_forbidden_prefix(&self, path: &str) -> Option<String> {
61        if self.is_allowed_path(path) {
62            return None;
63        }
64        self.forbidden_prefixes
65            .iter()
66            .find(|prefix| path == *prefix || path.starts_with(&format!("{prefix}/")))
67            .cloned()
68    }
69}
70
71/// Whether a string looks like an absolute filesystem path rather than prose,
72/// markup, or instructional text.
73pub fn looks_like_absolute_path(value: &str) -> bool {
74    value.starts_with('/')
75        && !value.contains(' ')
76        && value.len() > 1
77        && !value.contains('<')
78        && !value.contains('>')
79        && !value.contains('`')
80}
81
82/// Recursively collect absolute path-like strings from a serialized value.
83pub fn collect_absolute_paths(value: &Value, json_path: &str, out: &mut Vec<(String, String)>) {
84    match value {
85        Value::String(text) if looks_like_absolute_path(text) => {
86            out.push((json_path.to_string(), text.clone()));
87        }
88        Value::Array(items) => {
89            for (index, item) in items.iter().enumerate() {
90                collect_absolute_paths(item, &format!("{json_path}[{index}]"), out);
91            }
92        }
93        Value::Object(map) => {
94            for (key, item) in map {
95                let child_path = if json_path.is_empty() {
96                    key.clone()
97                } else {
98                    format!("{json_path}.{key}")
99                };
100                collect_absolute_paths(item, &child_path, out);
101            }
102        }
103        _ => {}
104    }
105}
106
107/// Assert no collected absolute path violates the forbidden-prefix contract.
108///
109/// Panics with a descriptive message on violation.
110pub fn assert_no_forbidden_prefixes(
111    value: &Value,
112    expectations: &PathIdentityExpectations,
113    context: &str,
114) {
115    let mut paths = Vec::new();
116    collect_absolute_paths(value, "", &mut paths);
117    for (json_path, path) in paths {
118        if let Some(prefix) = expectations.violates_forbidden_prefix(&path) {
119            panic!("{context}: forbidden path prefix `{prefix}` in `{path}` at `{json_path}`");
120        }
121    }
122}
123
124/// Assert every absolute path in `value` is under the expected root or an
125/// allowed mount namespace.
126pub fn assert_paths_under_expected_root(
127    value: &Value,
128    expectations: &PathIdentityExpectations,
129    context: &str,
130) {
131    let mut paths = Vec::new();
132    collect_absolute_paths(value, "", &mut paths);
133    let root = &expectations.expected_root;
134    for (json_path, path) in paths {
135        if expectations.is_allowed_path(&path) {
136            continue;
137        }
138        let ok = path == root.as_str() || path.starts_with(&format!("{root}/"));
139        assert!(
140            ok,
141            "{context}: path `{path}` at `{json_path}` is not under expected root `{root}`"
142        );
143    }
144}
145
146/// Assert a serialized model-visible value conforms to the store's path identity.
147pub fn assert_model_visible_value(value: &Value, store: &dyn SessionFileSystem, context: &str) {
148    let expectations = PathIdentityExpectations::for_store(store);
149    assert_no_forbidden_prefixes(value, &expectations, context);
150    assert_paths_under_expected_root(value, &expectations, context);
151}
152
153/// Assert assembled system prompt text uses the expected root and does not
154/// advertise forbidden aliases in host-backed mode.
155pub fn assert_system_prompt(prompt: &str, expectations: &PathIdentityExpectations) {
156    assert!(
157        prompt.contains(&format!("Workspace root: `{}`", expectations.expected_root))
158            || prompt.contains(&format!(
159                "Workspace root: `{}/`",
160                expectations.expected_root
161            )),
162        "system prompt must identify expected root `{}`; got:\n{prompt}",
163        expectations.expected_root
164    );
165
166    if expectations.expected_root != WORKSPACE_PREFIX {
167        for prefix in &expectations.forbidden_prefixes {
168            assert!(
169                !prompt.contains(&format!("`{prefix}` is also accepted")),
170                "host-backed system prompt must not advertise `{prefix}` alias; got:\n{prompt}"
171            );
172        }
173    }
174}
175
176/// Assert a tool JSON result conforms to the active store identity.
177pub fn assert_tool_result_paths_conform(
178    store: &dyn SessionFileSystem,
179    tool_name: &str,
180    response: &Value,
181) {
182    assert_model_visible_value(response, store, tool_name);
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use serde_json::json;
189
190    #[test]
191    fn looks_like_absolute_path_rejects_prose() {
192        assert!(!looks_like_absolute_path(
193            "A leading '/' or '/workspace/' prefix is also accepted."
194        ));
195        assert!(looks_like_absolute_path("/workspace/crates/server"));
196        assert!(looks_like_absolute_path("/tmp/repo/src/lib.rs"));
197    }
198
199    #[test]
200    fn collect_absolute_paths_walks_nested_values() {
201        let value = json!({
202            "path": "/repo/crates/server",
203            "entries": [{"path": "/repo/crates/server/main.rs"}],
204            "note": "not a path"
205        });
206        let mut paths = Vec::new();
207        collect_absolute_paths(&value, "", &mut paths);
208        assert_eq!(paths.len(), 2);
209        let collected: Vec<_> = paths.into_iter().map(|(_, path)| path).collect();
210        assert!(collected.contains(&"/repo/crates/server".to_string()));
211        assert!(collected.contains(&"/repo/crates/server/main.rs".to_string()));
212    }
213
214    #[test]
215    fn assert_no_forbidden_prefixes_allows_secondary_mounts() {
216        let expectations = PathIdentityExpectations::host_backed("/repo");
217        let value = json!({
218            "path": "/workspace/roots/backend/Cargo.toml"
219        });
220        assert_no_forbidden_prefixes(&value, &expectations, "secondary mount");
221    }
222
223    #[test]
224    #[should_panic(expected = "forbidden path prefix `/workspace`")]
225    fn assert_no_forbidden_prefixes_rejects_primary_alias() {
226        let expectations = PathIdentityExpectations::host_backed("/repo");
227        let value = json!({ "path": "/workspace/crates/server" });
228        assert_no_forbidden_prefixes(&value, &expectations, "read_file");
229    }
230}