1use 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
31pub 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#[derive(Clone, Debug)]
53pub struct TrustStore {
54 path: PathBuf,
55}
56
57impl TrustStore {
58 pub fn new(agent_dir: &Path) -> TrustStore {
60 TrustStore {
61 path: agent_dir.join("trust.json"),
62 }
63 }
64
65 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 pub fn get(&self, cwd: &Path) -> std::io::Result<Option<bool>> {
92 Ok(self.entry(cwd)?.map(|(_, decision)| decision))
93 }
94
95 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 pub fn set(&self, cwd: &Path, decision: Option<bool>) -> std::io::Result<()> {
108 self.set_many(&[(cwd.to_path_buf(), decision)])
109 }
110
111 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#[derive(Clone, Debug, PartialEq, Eq)]
140pub struct TrustOption {
141 pub label: String,
143 pub trusted: bool,
145 pub updates: Vec<(PathBuf, Option<bool>)>,
147 pub saved_path: Option<PathBuf>,
149}
150
151pub 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
194pub 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
202pub 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
221pub 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 #[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}