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