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 "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
30pub 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#[derive(Clone, Debug)]
52pub struct TrustStore {
53 path: PathBuf,
54}
55
56impl TrustStore {
57 pub fn new(agent_dir: &Path) -> TrustStore {
59 TrustStore {
60 path: agent_dir.join("trust.json"),
61 }
62 }
63
64 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 pub fn get(&self, cwd: &Path) -> std::io::Result<Option<bool>> {
91 Ok(self.entry(cwd)?.map(|(_, decision)| decision))
92 }
93
94 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 pub fn set(&self, cwd: &Path, decision: Option<bool>) -> std::io::Result<()> {
107 self.set_many(&[(cwd.to_path_buf(), decision)])
108 }
109
110 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#[derive(Clone, Debug, PartialEq, Eq)]
139pub struct TrustOption {
140 pub label: String,
142 pub trusted: bool,
144 pub updates: Vec<(PathBuf, Option<bool>)>,
146 pub saved_path: Option<PathBuf>,
148}
149
150pub 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
193pub 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
201pub 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
220pub 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}