Skip to main content

yapi_core/
trust.rs

1//! Project trust: whether yapi may load a project's `.yapi` settings and resources.
2//!
3//! Port of `trust-manager.ts` and `project-trust.ts` in pi `v1.0.0`. Decisions live
4//! in `<agent>/trust.json`, keyed by directory. The nearest decided ancestor wins.
5
6use std::path::{Path, PathBuf};
7
8use serde_json::{Map, Value};
9use yapi_ai::auth::lock::{FileLock, write_atomic};
10use yapi_types::settings::DefaultProjectTrust;
11
12use crate::config::PROJECT_DIR;
13use crate::tools::path::home_dir;
14
15const TRUST_REQUIRING: &[&str] = &[
16    "settings.json",
17    "settings.toml",
18    "mcp.json",
19    "extensions",
20    "skills",
21    "prompts",
22    "themes",
23    "SYSTEM.md",
24    "APPEND_SYSTEM.md",
25];
26
27fn canonical(path: &Path) -> PathBuf {
28    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
29}
30
31/// Whether `cwd` has project resources that need trust: anything in `.yapi/` that
32/// changes behavior, a chest manifest that installs anything, or an
33/// `.agents/skills` directory in it or an ancestor (other than the user's own).
34pub fn requires_trust(cwd: &Path) -> bool {
35    let cwd = canonical(cwd);
36    let config = cwd.join(PROJECT_DIR);
37    if TRUST_REQUIRING
38        .iter()
39        .any(|entry| config.join(entry).exists())
40        || crate::chest::startup::needs_trust(&cwd)
41    {
42        return true;
43    }
44    let user_skills = canonical(&home_dir()).join(".agents").join("skills");
45    cwd.ancestors().any(|dir| {
46        let skills = dir.join(".agents").join("skills");
47        skills != user_skills && skills.exists()
48    })
49}
50
51/// Stored trust decisions.
52#[derive(Clone, Debug)]
53pub struct TrustStore {
54    path: PathBuf,
55}
56
57impl TrustStore {
58    /// The store in `agent_dir`.
59    pub fn new(agent_dir: &Path) -> TrustStore {
60        TrustStore {
61            path: agent_dir.join("trust.json"),
62        }
63    }
64
65    /// The stored decisions, as pi's `readTrustFile`: none when the file is
66    /// missing, and an error when it cannot be read or is not a JSON object.
67    fn read(&self) -> std::io::Result<Map<String, Value>> {
68        let failed = |message: String| {
69            std::io::Error::other(format!(
70                "Failed to read trust store {}: {message}",
71                self.path.display()
72            ))
73        };
74        let text = match std::fs::read_to_string(&self.path) {
75            Ok(text) => text,
76            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Map::new()),
77            Err(err) => return Err(failed(err.to_string())),
78        };
79        match yapi_types::json::parse::<Value>(&text) {
80            Ok(Value::Object(data)) => Ok(data),
81            Ok(_) => Err(std::io::Error::other(format!(
82                "Invalid trust store {}: expected an object",
83                self.path.display()
84            ))),
85            Err(err) => Err(failed(err.to_string())),
86        }
87    }
88
89    /// The decision for `cwd` from it or its nearest decided ancestor, or an
90    /// error when the store cannot be read.
91    pub fn get(&self, cwd: &Path) -> std::io::Result<Option<bool>> {
92        Ok(self.entry(cwd)?.map(|(_, decision)| decision))
93    }
94
95    /// The nearest decided directory at or above `cwd`, and its decision, or
96    /// an error when the store cannot be read.
97    pub fn entry(&self, cwd: &Path) -> std::io::Result<Option<(PathBuf, bool)>> {
98        let data = self.read()?;
99        Ok(canonical(cwd).ancestors().find_map(|dir| {
100            data.get(&*dir.to_string_lossy())
101                .and_then(Value::as_bool)
102                .map(|decision| (dir.to_path_buf(), decision))
103        }))
104    }
105
106    /// Records a decision for `cwd`. `None` clears it. Keys are written sorted.
107    pub fn set(&self, cwd: &Path, decision: Option<bool>) -> std::io::Result<()> {
108        self.set_many(&[(cwd.to_path_buf(), decision)])
109    }
110
111    /// Records several decisions at once under the store's lock, as pi's
112    /// `setMany`. A store that cannot be read is left as it is and the error
113    /// returned.
114    pub fn set_many(&self, updates: &[(PathBuf, Option<bool>)]) -> std::io::Result<()> {
115        if let Some(dir) = self.path.parent() {
116            std::fs::create_dir_all(dir)?;
117        }
118        let _lock = FileLock::acquire_blocking(&self.path).map_err(std::io::Error::other)?;
119        let mut data = self.read()?;
120        for (path, decision) in updates {
121            let key = canonical(path).to_string_lossy().into_owned();
122            match decision {
123                Some(decision) => {
124                    data.insert(key, Value::Bool(*decision));
125                }
126                None => {
127                    data.remove(&key);
128                }
129            }
130        }
131        data.sort_keys();
132        let text = yapi_types::json::to_string_pretty(&Value::Object(data), "  ")
133            .map_err(std::io::Error::other)?;
134        write_atomic(&self.path, (text + "\n").as_bytes(), 0o666)
135    }
136}
137
138/// A choice of the trust prompt and `/trust`: pi's `ProjectTrustOption`.
139#[derive(Clone, Debug, PartialEq, Eq)]
140pub struct TrustOption {
141    /// What the option says.
142    pub label: String,
143    /// Whether choosing it trusts the project.
144    pub trusted: bool,
145    /// The decisions it stores, where `None` clears one.
146    pub updates: Vec<(PathBuf, Option<bool>)>,
147    /// The directory whose stored decision it stands for.
148    pub saved_path: Option<PathBuf>,
149}
150
151/// pi's `getProjectTrustOptions`: trust the project or its parent, or not.
152/// With `session_only`, also trust or distrust for this session only.
153pub fn trust_options(cwd: &Path, session_only: bool) -> Vec<TrustOption> {
154    let path = canonical(cwd);
155    let option = |label: String, trusted: bool, updates: Vec<(PathBuf, Option<bool>)>| {
156        let saved_path = updates.first().map(|(path, _)| path.clone());
157        TrustOption {
158            label,
159            trusted,
160            updates,
161            saved_path,
162        }
163    };
164    let mut options = vec![option(
165        "Trust".into(),
166        true,
167        vec![(path.clone(), Some(true))],
168    )];
169    if let Some(parent) = path.parent() {
170        options.push(option(
171            format!("Trust parent folder ({})", parent.display()),
172            true,
173            vec![(parent.to_path_buf(), Some(true)), (path.clone(), None)],
174        ));
175    }
176    if session_only {
177        options.push(option("Trust (this session only)".into(), true, Vec::new()));
178    }
179    options.push(option(
180        "Do not trust".into(),
181        false,
182        vec![(path.clone(), Some(false))],
183    ));
184    if session_only {
185        options.push(option(
186            "Do not trust (this session only)".into(),
187            false,
188            Vec::new(),
189        ));
190    }
191    options
192}
193
194/// The title of the startup trust prompt for `cwd`.
195pub fn prompt_title(cwd: &Path) -> String {
196    format!(
197        "Trust project folder?\n{}\n\nThis allows yapi to load {PROJECT_DIR} settings and resources, install missing project packages, and execute project extensions.",
198        canonical(cwd).display()
199    )
200}
201
202/// Whether startup asks whether to trust `cwd`: it has resources needing
203/// trust and neither an override, a stored decision nor the
204/// `defaultProjectTrust` setting decides. An error when the store must be
205/// read and cannot be.
206pub fn needs_prompt(
207    cwd: &Path,
208    store: &TrustStore,
209    override_: Option<bool>,
210    default: Option<DefaultProjectTrust>,
211) -> std::io::Result<bool> {
212    Ok(override_.is_none()
213        && requires_trust(cwd)
214        && store.get(cwd)?.is_none()
215        && !matches!(
216            default,
217            Some(DefaultProjectTrust::Always | DefaultProjectTrust::Never)
218        ))
219}
220
221/// Resolves trust without a UI: an explicit override, no resources needing trust,
222/// a stored decision, then the `defaultProjectTrust` setting. `ask` without a UI
223/// means untrusted. A store that must be read and cannot be is an error, as in
224/// pi, never trust.
225pub fn resolve_trusted(
226    cwd: &Path,
227    store: &TrustStore,
228    override_: Option<bool>,
229    default: Option<DefaultProjectTrust>,
230) -> std::io::Result<bool> {
231    if let Some(trusted) = override_ {
232        return Ok(trusted);
233    }
234    if !requires_trust(cwd) {
235        return Ok(true);
236    }
237    if let Some(decision) = store.get(cwd)? {
238        return Ok(decision);
239    }
240    Ok(matches!(default, Some(DefaultProjectTrust::Always)))
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn decisions_stored_inherited_cleared() {
249        let root = std::env::temp_dir().join(format!("yapi-trust-{}", std::process::id()));
250        let _ = std::fs::remove_dir_all(&root);
251        let project = root.join("work").join("app");
252        std::fs::create_dir_all(&project).unwrap();
253        let store = TrustStore::new(&root.join("agent"));
254        assert_eq!(store.entry(&project).unwrap(), None);
255
256        let parent = trust_options(&project, false)
257            .into_iter()
258            .find(|option| option.label.starts_with("Trust parent folder"))
259            .unwrap();
260        store.set(&project, Some(false)).unwrap();
261        store.set_many(&parent.updates).unwrap();
262        let parent_dir = canonical(&root.join("work"));
263        assert_eq!(
264            store.entry(&project).unwrap(),
265            Some((parent_dir.clone(), true))
266        );
267        let text = std::fs::read_to_string(root.join("agent").join("trust.json")).unwrap();
268        assert_eq!(
269            text,
270            format!("{{\n  \"{}\": true\n}}\n", parent_dir.display())
271        );
272        std::fs::remove_dir_all(&root).unwrap();
273    }
274
275    /// Each of pi's project files asks for trust, and so does yapi's
276    /// `settings.toml`, which holds the same settings.
277    #[test]
278    fn project_files_need_trust() {
279        let root = std::env::temp_dir().join(format!("yapi-trust-files-{}", std::process::id()));
280        for file in ["settings.json", "settings.toml"] {
281            let _ = std::fs::remove_dir_all(&root);
282            std::fs::create_dir_all(root.join(PROJECT_DIR)).unwrap();
283            assert!(!requires_trust(&root));
284            std::fs::write(root.join(PROJECT_DIR).join(file), "").unwrap();
285            assert!(requires_trust(&root), "{file}");
286        }
287        std::fs::remove_dir_all(&root).unwrap();
288    }
289
290    #[test]
291    fn options_follow_pi() {
292        let labels: Vec<String> = trust_options(Path::new("/tmp"), true)
293            .into_iter()
294            .map(|option| option.label)
295            .collect();
296        let root = canonical(Path::new("/tmp"));
297        let parent = root.parent().map(|parent| parent.display().to_string());
298        let mut expected = vec!["Trust".to_owned()];
299        expected.extend(parent.map(|parent| format!("Trust parent folder ({parent})")));
300        expected.extend(
301            [
302                "Trust (this session only)",
303                "Do not trust",
304                "Do not trust (this session only)",
305            ]
306            .map(str::to_owned),
307        );
308        assert_eq!(labels, expected);
309        assert!(
310            !needs_prompt(
311                Path::new("/"),
312                &TrustStore::new(Path::new("/nonexistent")),
313                Some(true),
314                None
315            )
316            .unwrap()
317        );
318    }
319
320    #[test]
321    fn corrupt_store_untrusted() {
322        let root = std::env::temp_dir().join(format!("yapi-trust-bad-{}", std::process::id()));
323        let _ = std::fs::remove_dir_all(&root);
324        let project = root.join("app");
325        std::fs::create_dir_all(project.join(PROJECT_DIR).join("skills")).unwrap();
326        let agent = root.join("agent");
327        std::fs::create_dir_all(&agent).unwrap();
328        let file = agent.join("trust.json");
329        let corrupt = format!("{{\n  \"{}\": false,\n", canonical(&project).display());
330        std::fs::write(&file, &corrupt).unwrap();
331        let store = TrustStore::new(&agent);
332
333        let always = Some(DefaultProjectTrust::Always);
334        let error = resolve_trusted(&project, &store, None, always).unwrap_err();
335        assert!(error.to_string().starts_with("Failed to read trust store"));
336        assert!(needs_prompt(&project, &store, None, None).is_err());
337        assert!(store.entry(&project).is_err());
338        assert!(store.set(&root, Some(true)).is_err());
339        assert_eq!(std::fs::read_to_string(&file).unwrap(), corrupt);
340        assert!(resolve_trusted(&project, &store, Some(true), always).unwrap());
341        std::fs::remove_dir_all(&root).unwrap();
342    }
343}