1use std::collections::BTreeMap;
9use std::env;
10use std::fs::{self, File, OpenOptions};
11use std::io::{self, Write};
12use std::path::{Path, PathBuf};
13use std::time::{SystemTime, UNIX_EPOCH};
14
15use serde_json::{Map, Value};
16use thiserror::Error;
17
18use super::config::{
19 CONFIG_DIR_NAME, PathInputOptions, canonicalize_path, resolve_path, resolve_path_with,
20};
21use super::lockfile::{LockError, LockGuard, LockOptions};
22
23const TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES: &[&str] = &[
25 "settings.json",
26 "extensions",
27 "skills",
28 "prompts",
29 "themes",
30 "SYSTEM.md",
31 "APPEND_SYSTEM.md",
32];
33
34pub type ProjectTrustDecision = Option<bool>;
40
41#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
43pub enum DefaultProjectTrust {
44 #[default]
46 Ask,
47 Always,
49 Never,
51}
52
53impl DefaultProjectTrust {
54 #[must_use]
56 pub fn parse(value: Option<&str>) -> Self {
57 match value {
58 Some("always") => Self::Always,
59 Some("never") => Self::Never,
60 _ => Self::Ask,
61 }
62 }
63
64 #[must_use]
66 pub const fn as_str(self) -> &'static str {
67 match self {
68 Self::Ask => "ask",
69 Self::Always => "always",
70 Self::Never => "never",
71 }
72 }
73}
74
75#[derive(Clone, Copy, Debug, Eq, PartialEq)]
77pub enum ProjectTrustEventDecision {
78 Yes,
80 No,
82 Undecided,
84}
85
86#[derive(Clone, Debug, Eq, PartialEq)]
88pub struct ProjectTrustExtensionResult {
89 pub trusted: ProjectTrustEventDecision,
91 pub remember: bool,
93}
94
95#[derive(Clone, Debug, Eq, PartialEq)]
97pub struct ProjectTrustStoreEntry {
98 pub path: PathBuf,
100 pub decision: bool,
102}
103
104#[derive(Clone, Debug, Eq, PartialEq)]
106pub struct ProjectTrustUpdate {
107 pub path: PathBuf,
109 pub decision: ProjectTrustDecision,
111}
112
113#[derive(Clone, Debug, Eq, PartialEq)]
115pub struct ProjectTrustOption {
116 pub label: String,
118 pub trusted: bool,
120 pub updates: Vec<ProjectTrustUpdate>,
122 pub saved_path: Option<PathBuf>,
124}
125
126pub trait TrustUi {
130 fn has_ui(&self) -> bool;
132
133 fn select(&mut self, prompt: &str, options: &[String]) -> Option<String>;
135}
136
137#[derive(Debug, Error)]
139pub enum TrustError {
140 #[error("Failed to read trust store {path}: {message}")]
142 Read {
143 path: String,
145 message: String,
147 },
148 #[error("Invalid trust store {path}: expected an object")]
150 InvalidObject {
151 path: String,
153 },
154 #[error("Invalid trust store {path}: value for {key} must be true, false, or null")]
156 InvalidValue {
157 path: String,
159 key: String,
161 },
162 #[error("Failed to acquire trust store lock")]
164 Lock,
165 #[error("Failed to write trust store {path}: {message}")]
167 Write {
168 path: String,
170 message: String,
172 },
173}
174
175impl From<LockError> for TrustError {
176 fn from(_value: LockError) -> Self {
177 Self::Lock
178 }
179}
180
181pub type ProjectTrustExtensionHook<'a> =
183 &'a mut dyn FnMut(&Path) -> Result<Option<ProjectTrustExtensionResult>, String>;
184
185pub struct ResolveProjectTrustedOptions<'a> {
187 pub cwd: PathBuf,
189 pub trust_store: &'a ProjectTrustStore,
191 pub trust_override: Option<bool>,
193 pub default_project_trust: DefaultProjectTrust,
195 pub extension_hook: Option<ProjectTrustExtensionHook<'a>>,
203 pub ui: Option<&'a mut dyn TrustUi>,
206 pub on_extension_error: Option<&'a mut dyn FnMut(String)>,
208}
209
210#[derive(Clone, Debug, Eq, PartialEq)]
212pub struct ProjectTrustStore {
213 trust_path: PathBuf,
214}
215
216impl ProjectTrustStore {
217 #[must_use]
219 pub fn new(agent_dir: impl AsRef<Path>) -> Self {
220 let agent = path_to_string(agent_dir.as_ref());
221 let resolved = resolve_path(&agent);
222 Self {
223 trust_path: resolved.join("trust.json"),
224 }
225 }
226
227 #[must_use]
229 pub fn path(&self) -> &Path {
230 &self.trust_path
231 }
232
233 pub fn get(&self, cwd: impl AsRef<Path>) -> Result<ProjectTrustDecision, TrustError> {
239 Ok(self.get_entry(cwd)?.map(|entry| entry.decision))
240 }
241
242 pub fn get_entry(
248 &self,
249 cwd: impl AsRef<Path>,
250 ) -> Result<Option<ProjectTrustStoreEntry>, TrustError> {
251 self.with_lock(|data| Ok(find_nearest_trust_entry(data, cwd.as_ref())))
252 }
253
254 pub fn set(
260 &self,
261 cwd: impl AsRef<Path>,
262 decision: ProjectTrustDecision,
263 ) -> Result<(), TrustError> {
264 self.set_many([ProjectTrustUpdate {
265 path: cwd.as_ref().to_path_buf(),
266 decision,
267 }])
268 }
269
270 pub fn set_many(
276 &self,
277 decisions: impl IntoIterator<Item = ProjectTrustUpdate>,
278 ) -> Result<(), TrustError> {
279 self.with_lock(|data| {
280 for update in decisions {
281 let key = path_key(&update.path);
282 match update.decision {
283 None => {
284 data.remove(&key);
285 }
286 Some(value) => {
287 data.insert(key, Some(value));
288 }
289 }
290 }
291 write_trust_file(&self.trust_path, data)?;
292 Ok(())
293 })
294 }
295
296 fn with_lock<T>(
297 &self,
298 f: impl FnOnce(&mut TrustFile) -> Result<T, TrustError>,
299 ) -> Result<T, TrustError> {
300 let _guard = acquire_trust_lock(&self.trust_path)?;
301 let mut data = read_trust_file(&self.trust_path)?;
302 f(&mut data)
303 }
304}
305
306#[must_use]
308pub fn get_project_trust_parent_path(cwd: impl AsRef<Path>) -> Option<PathBuf> {
309 let trust_path = normalize_cwd(cwd.as_ref());
310 let parent = trust_path.parent()?;
311 if parent.as_os_str().is_empty() || parent == trust_path {
312 None
313 } else {
314 let parent_norm = normalize_cwd(parent);
317 if parent_norm == trust_path {
318 None
319 } else {
320 Some(parent_norm)
321 }
322 }
323}
324
325#[must_use]
327pub fn get_project_trust_options(
328 cwd: impl AsRef<Path>,
329 include_session_only: bool,
330) -> Vec<ProjectTrustOption> {
331 let trust_path = normalize_cwd(cwd.as_ref());
332 let mut options = vec![ProjectTrustOption {
333 label: "Trust".to_owned(),
334 trusted: true,
335 updates: vec![ProjectTrustUpdate {
336 path: trust_path.clone(),
337 decision: Some(true),
338 }],
339 saved_path: Some(trust_path.clone()),
340 }];
341
342 if let Some(parent_path) = get_project_trust_parent_path(&trust_path) {
343 options.push(ProjectTrustOption {
344 label: format!("Trust parent folder ({})", path_to_string(&parent_path)),
345 trusted: true,
346 updates: vec![
347 ProjectTrustUpdate {
348 path: parent_path.clone(),
349 decision: Some(true),
350 },
351 ProjectTrustUpdate {
352 path: trust_path.clone(),
353 decision: None,
354 },
355 ],
356 saved_path: Some(parent_path),
357 });
358 }
359
360 if include_session_only {
361 options.push(ProjectTrustOption {
362 label: "Trust (this session only)".to_owned(),
363 trusted: true,
364 updates: Vec::new(),
365 saved_path: None,
366 });
367 }
368
369 options.push(ProjectTrustOption {
370 label: "Do not trust".to_owned(),
371 trusted: false,
372 updates: vec![ProjectTrustUpdate {
373 path: trust_path.clone(),
374 decision: Some(false),
375 }],
376 saved_path: Some(trust_path),
377 });
378
379 if include_session_only {
380 options.push(ProjectTrustOption {
381 label: "Do not trust (this session only)".to_owned(),
382 trusted: false,
383 updates: Vec::new(),
384 saved_path: None,
385 });
386 }
387
388 options
389}
390
391#[must_use]
393pub fn format_project_trust_prompt(cwd: impl AsRef<Path>) -> String {
394 let cwd_display = path_to_string(cwd.as_ref());
395 format!(
396 "Trust project folder?\n{cwd_display}\n\nThis allows pi to load {CONFIG_DIR_NAME} settings and resources, install missing project packages, and execute project extensions."
397 )
398}
399
400#[must_use]
405pub fn has_trust_requiring_project_resources(cwd: impl AsRef<Path>) -> bool {
406 let home = process_home_path();
407 has_trust_requiring_project_resources_with(cwd.as_ref(), home.as_deref())
408}
409
410#[must_use]
412pub fn has_trust_requiring_project_resources_with(cwd: &Path, home_dir: Option<&Path>) -> bool {
413 let home_canonical = home_dir.map(|home| {
414 let home_str = path_to_string(home);
415 canonicalize_path(resolve_path_with(
416 &home_str,
417 Path::new("/"),
418 PathInputOptions::new()
419 .home_dir(Some(home))
420 .expand_tilde(false),
421 ))
422 });
423 let user_agents_skills = home_canonical
424 .as_ref()
425 .map(|home| home.join(".agents").join("skills"));
426
427 let mut current_dir = normalize_cwd_with(cwd, home_dir);
428
429 let config_dir = current_dir.join(CONFIG_DIR_NAME);
430 if TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES
431 .iter()
432 .any(|entry| config_dir.join(entry).exists())
433 {
434 return true;
435 }
436
437 loop {
438 let agents_skills_dir = current_dir.join(".agents").join("skills");
439 let is_user_global = user_agents_skills
440 .as_ref()
441 .is_some_and(|user| agents_skills_dir == *user);
442 if !is_user_global && agents_skills_dir.exists() {
443 return true;
444 }
445
446 let Some(parent) = current_dir.parent() else {
447 return false;
448 };
449 if parent.as_os_str().is_empty() || parent == current_dir.as_path() {
450 return false;
451 }
452 let parent_norm = normalize_cwd_with(parent, home_dir);
453 if parent_norm == current_dir {
454 return false;
455 }
456 current_dir = parent_norm;
457 }
458}
459
460pub fn resolve_project_trusted(
476 options: ResolveProjectTrustedOptions<'_>,
477) -> Result<bool, TrustError> {
478 if let Some(override_value) = options.trust_override {
479 return Ok(override_value);
480 }
481
482 if !has_trust_requiring_project_resources(&options.cwd) {
483 return Ok(true);
484 }
485
486 if let Some(hook) = options.extension_hook {
487 match hook(&options.cwd) {
488 Ok(Some(result)) => match result.trusted {
489 ProjectTrustEventDecision::Yes => {
490 if result.remember {
491 options.trust_store.set(&options.cwd, Some(true))?;
492 }
493 return Ok(true);
494 }
495 ProjectTrustEventDecision::No => {
496 if result.remember {
497 options.trust_store.set(&options.cwd, Some(false))?;
498 }
499 return Ok(false);
500 }
501 ProjectTrustEventDecision::Undecided => {}
502 },
503 Ok(None) => {}
504 Err(message) => {
505 if let Some(on_error) = options.on_extension_error {
506 on_error(message);
507 }
508 }
509 }
510 }
511
512 if let Some(decision) = options.trust_store.get(&options.cwd)? {
513 return Ok(decision);
514 }
515
516 match options.default_project_trust {
517 DefaultProjectTrust::Always => return Ok(true),
518 DefaultProjectTrust::Never => return Ok(false),
519 DefaultProjectTrust::Ask => {}
520 }
521
522 let Some(ui) = options.ui else {
523 return Ok(false);
524 };
525 if !ui.has_ui() {
526 return Ok(false);
527 }
528
529 let prompt_options = get_project_trust_options(&options.cwd, true);
530 let labels: Vec<String> = prompt_options
531 .iter()
532 .map(|option| option.label.clone())
533 .collect();
534 let prompt = format_project_trust_prompt(&options.cwd);
535 let selected_label = ui.select(&prompt, &labels);
536 if let Some(label) = selected_label
537 && let Some(selected) = prompt_options
538 .into_iter()
539 .find(|option| option.label == label)
540 {
541 if !selected.updates.is_empty() {
542 options.trust_store.set_many(selected.updates)?;
543 }
544 return Ok(selected.trusted);
545 }
546
547 Ok(false)
548}
549
550type TrustFile = BTreeMap<String, Option<bool>>;
551
552fn acquire_trust_lock(trust_path: &Path) -> Result<LockGuard, TrustError> {
553 let trust_dir = trust_path
554 .parent()
555 .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
556 if let Err(error) = fs::create_dir_all(&trust_dir) {
557 return Err(TrustError::Write {
558 path: path_to_string(trust_path),
559 message: error.to_string(),
560 });
561 }
562 let lockfile_path = trust_lock_path(trust_path);
563 let options = LockOptions::new().lockfile_path(lockfile_path);
564 LockGuard::acquire_with(&trust_dir, &options).map_err(TrustError::from)
565}
566
567fn trust_lock_path(trust_path: &Path) -> PathBuf {
568 let mut lock = trust_path.as_os_str().to_os_string();
569 lock.push(".lock");
570 PathBuf::from(lock)
571}
572
573fn read_trust_file(path: &Path) -> Result<TrustFile, TrustError> {
574 if !path.exists() {
575 return Ok(TrustFile::new());
576 }
577
578 let text = fs::read_to_string(path).map_err(|error| TrustError::Read {
579 path: path_to_string(path),
580 message: error.to_string(),
581 })?;
582
583 let parsed: Value = serde_json::from_str(&text).map_err(|error| TrustError::Read {
584 path: path_to_string(path),
585 message: error.to_string(),
586 })?;
587
588 let Value::Object(object) = parsed else {
589 return Err(TrustError::InvalidObject {
590 path: path_to_string(path),
591 });
592 };
593
594 let mut data = TrustFile::new();
595 for (key, value) in object {
596 let decision = match value {
597 Value::Bool(flag) => Some(flag),
598 Value::Null => None,
599 _ => {
600 return Err(TrustError::InvalidValue {
601 path: path_to_string(path),
602 key: json_string_key(&key),
603 });
604 }
605 };
606 data.insert(key, decision);
609 }
610 Ok(data)
611}
612
613fn write_trust_file(path: &Path, data: &TrustFile) -> Result<(), TrustError> {
614 let mut sorted = Map::new();
615 for (key, value) in data {
616 let json_value = match value {
617 Some(flag) => Value::Bool(*flag),
618 None => Value::Null,
619 };
620 sorted.insert(key.clone(), json_value);
621 }
622
623 if let Some(parent) = path.parent() {
624 fs::create_dir_all(parent).map_err(|error| TrustError::Write {
625 path: path_to_string(path),
626 message: error.to_string(),
627 })?;
628 }
629
630 let body = serde_json::to_string_pretty(&Value::Object(sorted)).map_err(|error| {
631 TrustError::Write {
632 path: path_to_string(path),
633 message: error.to_string(),
634 }
635 })?;
636 let mut bytes = body.into_bytes();
637 bytes.push(b'\n');
638 atomic_write_trust_file(path, &bytes).map_err(|error| TrustError::Write {
639 path: path_to_string(path),
640 message: error.to_string(),
641 })
642}
643
644fn atomic_write_trust_file(path: &Path, bytes: &[u8]) -> io::Result<()> {
645 let parent = path
646 .parent()
647 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "trust path has no parent"))?;
648 let nonce = SystemTime::now()
649 .duration_since(UNIX_EPOCH)
650 .unwrap_or_default()
651 .as_nanos();
652 let file_name = path.file_name().map_or_else(
653 || "trust.json".to_owned(),
654 |name| name.to_string_lossy().into_owned(),
655 );
656 let temporary = parent.join(format!(".{file_name}.tmp.{}.{nonce}", std::process::id()));
657
658 let result = (|| {
659 let mut file = OpenOptions::new()
660 .write(true)
661 .create_new(true)
662 .open(&temporary)?;
663 file.write_all(bytes)?;
664 file.sync_all()?;
665 drop(file);
666 fs::rename(&temporary, path)?;
667 sync_parent_directory(parent)
668 })();
669 if result.is_err() {
670 let _ = fs::remove_file(&temporary);
671 }
672 result
673}
674
675#[cfg(unix)]
676fn sync_parent_directory(parent: &Path) -> io::Result<()> {
677 File::open(parent)?.sync_all()
678}
679
680#[cfg(not(unix))]
681fn sync_parent_directory(_parent: &Path) -> io::Result<()> {
682 Ok(())
683}
684
685fn find_nearest_trust_entry(data: &TrustFile, cwd: &Path) -> Option<ProjectTrustStoreEntry> {
686 let mut current_dir = normalize_cwd(cwd);
687 loop {
688 let key = path_to_string(¤t_dir);
689 if let Some(Some(decision)) = data.get(&key) {
690 return Some(ProjectTrustStoreEntry {
691 path: current_dir,
692 decision: *decision,
693 });
694 }
695
696 let parent = current_dir.parent()?;
697 if parent.as_os_str().is_empty() {
698 return None;
699 }
700 let parent_norm = normalize_cwd(parent);
701 if parent_norm == current_dir {
702 return None;
703 }
704 current_dir = parent_norm;
705 }
706}
707
708fn normalize_cwd(cwd: &Path) -> PathBuf {
709 normalize_cwd_with(cwd, process_home_path().as_deref())
710}
711
712fn normalize_cwd_with(cwd: &Path, home_dir: Option<&Path>) -> PathBuf {
713 let cwd_str = path_to_string(cwd);
714 let process_cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
715 let resolved = resolve_path_with(
716 &cwd_str,
717 &process_cwd,
718 PathInputOptions::new().home_dir(home_dir),
719 );
720 canonicalize_path(resolved)
721}
722
723fn path_key(path: &Path) -> String {
724 path_to_string(&normalize_cwd(path))
725}
726
727fn path_to_string(path: &Path) -> String {
728 path.to_string_lossy().into_owned()
729}
730
731fn json_string_key(key: &str) -> String {
732 serde_json::to_string(key).unwrap_or_else(|_| format!("\"{key}\""))
733}
734
735fn process_home_path() -> Option<PathBuf> {
736 env::var_os("HOME")
737 .map(PathBuf::from)
738 .or_else(dirs::home_dir)
739}
740
741#[cfg(test)]
742mod tests {
743 use super::*;
744 use std::sync::Arc;
745 use std::thread;
746 use std::time::{SystemTime, UNIX_EPOCH};
747
748 type TestResult = Result<(), String>;
749
750 fn unique_temp_dir(label: &str) -> Result<PathBuf, String> {
751 let nanos = SystemTime::now()
752 .duration_since(UNIX_EPOCH)
753 .map_err(|error| error.to_string())?
754 .as_nanos();
755 let dir = env::temp_dir().join(format!("pi-trust-{label}-{nanos}"));
756 fs::create_dir_all(&dir).map_err(|error| error.to_string())?;
757 Ok(dir)
758 }
759
760 fn write_resource(path: &Path) -> Result<(), String> {
761 if let Some(parent) = path.parent() {
762 fs::create_dir_all(parent).map_err(|error| error.to_string())?;
763 }
764 fs::write(path, b"{}").map_err(|error| error.to_string())
765 }
766
767 fn trust_fixture(label: &str) -> Result<(PathBuf, PathBuf, ProjectTrustStore), String> {
768 let root = unique_temp_dir(label)?;
769 let agent_dir = root.join("agent");
770 let project = root.join("project");
771 fs::create_dir_all(&agent_dir).map_err(|error| error.to_string())?;
772 fs::create_dir_all(&project).map_err(|error| error.to_string())?;
773 write_resource(&project.join(".pi").join("settings.json"))?;
774 let store = ProjectTrustStore::new(&agent_dir);
775 Ok((root, project, store))
776 }
777
778 fn resolve_without_extensions(
779 cwd: &Path,
780 store: &ProjectTrustStore,
781 trust_override: Option<bool>,
782 default_project_trust: DefaultProjectTrust,
783 ) -> Result<bool, String> {
784 resolve_project_trusted(ResolveProjectTrustedOptions {
785 cwd: cwd.to_path_buf(),
786 trust_store: store,
787 trust_override,
788 default_project_trust,
789 extension_hook: None,
790 ui: None,
791 on_extension_error: None,
792 })
793 .map_err(|error| error.to_string())
794 }
795
796 fn require_error<T, E>(result: Result<T, E>, message: &str) -> Result<E, String> {
797 match result {
798 Ok(_) => Err(message.to_owned()),
799 Err(error) => Ok(error),
800 }
801 }
802
803 struct ScriptedUi {
804 has_ui: bool,
805 choice: Option<String>,
806 last_prompt: Option<String>,
807 last_options: Vec<String>,
808 }
809
810 impl TrustUi for ScriptedUi {
811 fn has_ui(&self) -> bool {
812 self.has_ui
813 }
814
815 fn select(&mut self, prompt: &str, options: &[String]) -> Option<String> {
816 self.last_prompt = Some(prompt.to_owned());
817 self.last_options = options.to_vec();
818 self.choice.clone()
819 }
820 }
821
822 #[test]
823 fn stores_decisions_and_inherits_from_parent_directories() -> TestResult {
824 let root = unique_temp_dir("inherit")?;
825 let agent_dir = root.join("agent");
826 fs::create_dir_all(&agent_dir).map_err(|error| error.to_string())?;
827 let parent_dir = root.join("trusted-parent");
828 let child_dir = parent_dir.join("project");
829 fs::create_dir_all(&child_dir).map_err(|error| error.to_string())?;
830
831 let store = ProjectTrustStore::new(&agent_dir);
832 assert_eq!(store.get(&child_dir).map_err(|e| e.to_string())?, None);
833
834 store
835 .set(&parent_dir, Some(true))
836 .map_err(|e| e.to_string())?;
837 assert_eq!(
838 store.get(&child_dir).map_err(|e| e.to_string())?,
839 Some(true)
840 );
841
842 store
843 .set(&child_dir, Some(false))
844 .map_err(|e| e.to_string())?;
845 assert_eq!(
846 store.get(&child_dir).map_err(|e| e.to_string())?,
847 Some(false)
848 );
849
850 store.set(&child_dir, None).map_err(|e| e.to_string())?;
851 assert_eq!(
852 store.get(&child_dir).map_err(|e| e.to_string())?,
853 Some(true)
854 );
855
856 let _ = fs::remove_dir_all(root);
857 Ok(())
858 }
859
860 #[test]
861 fn null_entries_are_skipped_during_ancestor_walk() -> TestResult {
862 let root = unique_temp_dir("null-skip")?;
863 let agent_dir = root.join("agent");
864 fs::create_dir_all(&agent_dir).map_err(|error| error.to_string())?;
865 let parent = root.join("parent");
866 let child = parent.join("child");
867 fs::create_dir_all(&child).map_err(|error| error.to_string())?;
868
869 let store = ProjectTrustStore::new(&agent_dir);
870 store.set(&parent, Some(true)).map_err(|e| e.to_string())?;
871
872 {
874 let text = fs::read_to_string(store.path()).map_err(|e| e.to_string())?;
875 let mut value: Value = serde_json::from_str(&text).map_err(|e| e.to_string())?;
876 let object = value
877 .as_object_mut()
878 .ok_or_else(|| "expected object".to_owned())?;
879 object.insert(path_key(&child), Value::Null);
880 let body = serde_json::to_string_pretty(&value).map_err(|e| e.to_string())?;
881 fs::write(store.path(), format!("{body}\n")).map_err(|e| e.to_string())?;
882 }
883
884 assert_eq!(store.get(&child).map_err(|e| e.to_string())?, Some(true));
885 let entry = store.get_entry(&child).map_err(|e| e.to_string())?;
886 assert_eq!(entry.map(|e| e.path), Some(normalize_cwd(&parent)));
887
888 let _ = fs::remove_dir_all(root);
889 Ok(())
890 }
891
892 #[test]
893 fn set_null_deletes_key_and_serialization_is_sorted_with_trailing_newline() -> TestResult {
894 let root = unique_temp_dir("serialize")?;
895 let agent_dir = root.join("agent");
896 fs::create_dir_all(&agent_dir).map_err(|error| error.to_string())?;
897 let store = ProjectTrustStore::new(&agent_dir);
898
899 let zed = root.join("zed");
900 let alpha = root.join("alpha");
901 fs::create_dir_all(&zed).map_err(|error| error.to_string())?;
902 fs::create_dir_all(&alpha).map_err(|error| error.to_string())?;
903
904 store.set(&zed, Some(false)).map_err(|e| e.to_string())?;
905 store.set(&alpha, Some(true)).map_err(|e| e.to_string())?;
906 store.set(&zed, None).map_err(|e| e.to_string())?;
907
908 let raw = fs::read_to_string(store.path()).map_err(|e| e.to_string())?;
909 assert!(raw.ends_with('\n'), "missing trailing newline: {raw:?}");
910 assert!(!raw.contains(&path_key(&zed)), "null delete must drop key");
911
912 let alpha_key = path_key(&alpha);
913 let expected = format!("{{\n {key}: true\n}}\n", key = json_string_key(&alpha_key));
914 assert!(raw.ends_with('\n'));
916 let parsed: Value = serde_json::from_str(raw.trim_end()).map_err(|e| e.to_string())?;
917 let object = parsed
918 .as_object()
919 .ok_or_else(|| "expected object".to_owned())?;
920 let keys: Vec<&String> = object.keys().collect();
921 assert_eq!(keys, vec![&alpha_key]);
922 assert_eq!(object.get(&alpha_key), Some(&Value::Bool(true)));
923
924 let lock_path = trust_lock_path(store.path());
926 assert_eq!(
927 lock_path.file_name().and_then(|n| n.to_str()),
928 Some("trust.json.lock")
929 );
930 assert!(
931 !lock_path.exists(),
932 "lock directory must not remain after write"
933 );
934
935 let entries = fs::read_dir(&agent_dir)
936 .map_err(|error| error.to_string())?
937 .collect::<Result<Vec<_>, _>>()
938 .map_err(|error| error.to_string())?;
939 assert!(
940 entries
941 .iter()
942 .all(|entry| !entry.file_name().to_string_lossy().contains(".tmp.")),
943 "atomic writer left a temporary file"
944 );
945
946 let _ = expected; let _ = fs::remove_dir_all(root);
948 Ok(())
949 }
950
951 #[test]
952 fn lock_targets_parent_dir_with_trust_json_lock_artifact() -> TestResult {
953 let root = unique_temp_dir("lock-artifact")?;
954 let agent_dir = root.join("agent");
955 fs::create_dir_all(&agent_dir).map_err(|error| error.to_string())?;
956 let store = ProjectTrustStore::new(&agent_dir);
957 let trust_path = store.path().to_path_buf();
958 let lock_path = trust_lock_path(&trust_path);
959
960 let guard = acquire_trust_lock(&trust_path).map_err(|e| e.to_string())?;
962 assert_eq!(guard.lock_path(), lock_path.as_path());
963 assert!(lock_path.is_dir());
964 assert_eq!(
965 guard.target(),
966 trust_path.parent().ok_or_else(|| "parent".to_owned())?
967 );
968 drop(guard);
969 assert!(!lock_path.exists());
970
971 let _ = fs::remove_dir_all(root);
972 Ok(())
973 }
974
975 #[test]
976 fn detects_trust_requiring_project_resources() -> TestResult {
977 let root = unique_temp_dir("resources")?;
978 let project = root.join("project");
979 fs::create_dir_all(&project).map_err(|error| error.to_string())?;
980 fs::create_dir_all(root.join(".pi").join("agent")).map_err(|error| error.to_string())?;
981 fs::create_dir_all(root.join(".agents").join("skills"))
982 .map_err(|error| error.to_string())?;
983
984 assert!(
985 !has_trust_requiring_project_resources_with(&root, Some(&root)),
986 "user-global ~/.agents/skills and bare .pi/agent must be ignored"
987 );
988 assert!(!has_trust_requiring_project_resources_with(
989 &project,
990 Some(&root)
991 ));
992
993 write_resource(&root.join(".pi").join("settings.json"))?;
994 assert!(has_trust_requiring_project_resources_with(
995 &root,
996 Some(&root)
997 ));
998 fs::remove_file(root.join(".pi").join("settings.json")).map_err(|e| e.to_string())?;
999
1000 write_resource(&project.join(".pi").join("settings.json"))?;
1001 assert!(has_trust_requiring_project_resources_with(
1002 &project,
1003 Some(&root)
1004 ));
1005
1006 fs::remove_dir_all(project.join(".pi")).map_err(|e| e.to_string())?;
1007 fs::create_dir_all(project.join(".agents").join("skills")).map_err(|e| e.to_string())?;
1008 assert!(has_trust_requiring_project_resources_with(
1009 &project,
1010 Some(&root)
1011 ));
1012
1013 let _ = fs::remove_dir_all(root);
1014 Ok(())
1015 }
1016
1017 #[test]
1018 fn project_trust_options_labels_and_updates() -> TestResult {
1019 let root = unique_temp_dir("options")?;
1020 let cwd = root.join("proj");
1021 fs::create_dir_all(&cwd).map_err(|error| error.to_string())?;
1022 let options = get_project_trust_options(&cwd, true);
1023 let labels: Vec<&str> = options.iter().map(|o| o.label.as_str()).collect();
1024 let parent = get_project_trust_parent_path(&cwd).ok_or("parent")?;
1025 assert_eq!(
1026 labels,
1027 vec![
1028 "Trust",
1029 format!("Trust parent folder ({})", path_to_string(&parent)).as_str(),
1030 "Trust (this session only)",
1031 "Do not trust",
1032 "Do not trust (this session only)",
1033 ]
1034 );
1035
1036 assert!(options[0].trusted);
1037 assert_eq!(options[0].updates.len(), 1);
1038 assert_eq!(options[0].updates[0].decision, Some(true));
1039
1040 assert!(options[1].trusted);
1041 assert_eq!(options[1].updates.len(), 2);
1042 assert_eq!(options[1].updates[0].decision, Some(true));
1043 assert_eq!(options[1].updates[1].decision, None);
1044
1045 assert!(options[2].updates.is_empty());
1046 assert!(!options[3].trusted);
1047 assert_eq!(options[3].updates[0].decision, Some(false));
1048 assert!(options[4].updates.is_empty());
1049
1050 let prompt = format_project_trust_prompt(&cwd);
1051 assert_eq!(
1052 prompt,
1053 format!(
1054 "Trust project folder?\n{}\n\nThis allows pi to load .pi settings and resources, install missing project packages, and execute project extensions.",
1055 path_to_string(&cwd)
1056 )
1057 );
1058
1059 let _ = fs::remove_dir_all(root);
1060 Ok(())
1061 }
1062
1063 #[test]
1064 fn resolve_order_override_resources_defaults_and_non_ui() -> TestResult {
1065 let (root, project, store) = trust_fixture("resolve-basic")?;
1066
1067 assert!(resolve_without_extensions(
1068 &project,
1069 &store,
1070 Some(true),
1071 DefaultProjectTrust::Never,
1072 )?);
1073
1074 let empty = root.join("empty");
1075 fs::create_dir_all(&empty).map_err(|error| error.to_string())?;
1076 assert!(resolve_without_extensions(
1077 &empty,
1078 &store,
1079 None,
1080 DefaultProjectTrust::Never,
1081 )?);
1082
1083 assert!(resolve_without_extensions(
1084 &project,
1085 &store,
1086 None,
1087 DefaultProjectTrust::Always,
1088 )?);
1089 assert!(!resolve_without_extensions(
1090 &project,
1091 &store,
1092 None,
1093 DefaultProjectTrust::Never,
1094 )?);
1095 assert!(!resolve_without_extensions(
1096 &project,
1097 &store,
1098 None,
1099 DefaultProjectTrust::Ask,
1100 )?);
1101
1102 let mut no_ui = ScriptedUi {
1103 has_ui: false,
1104 choice: Some("Trust".to_owned()),
1105 last_prompt: None,
1106 last_options: Vec::new(),
1107 };
1108 let trusted = resolve_project_trusted(ResolveProjectTrustedOptions {
1109 cwd: project,
1110 trust_store: &store,
1111 trust_override: None,
1112 default_project_trust: DefaultProjectTrust::Ask,
1113 extension_hook: None,
1114 ui: Some(&mut no_ui),
1115 on_extension_error: None,
1116 })
1117 .map_err(|error| error.to_string())?;
1118 assert!(!trusted);
1119
1120 let _ = fs::remove_dir_all(root);
1121 Ok(())
1122 }
1123
1124 #[test]
1125 fn resolve_order_extension_remember_then_store() -> TestResult {
1126 let (root, project, store) = trust_fixture("resolve-extension")?;
1127 let mut hook = |cwd: &Path| {
1128 assert_eq!(cwd, project.as_path());
1129 Ok(Some(ProjectTrustExtensionResult {
1130 trusted: ProjectTrustEventDecision::Yes,
1131 remember: true,
1132 }))
1133 };
1134 let trusted = resolve_project_trusted(ResolveProjectTrustedOptions {
1135 cwd: project.clone(),
1136 trust_store: &store,
1137 trust_override: None,
1138 default_project_trust: DefaultProjectTrust::Never,
1139 extension_hook: Some(&mut hook),
1140 ui: None,
1141 on_extension_error: None,
1142 })
1143 .map_err(|error| error.to_string())?;
1144 assert!(trusted);
1145 assert_eq!(store.get(&project).map_err(|e| e.to_string())?, Some(true));
1146
1147 store
1148 .set(&project, Some(false))
1149 .map_err(|error| error.to_string())?;
1150 let mut undecided = |_cwd: &Path| {
1151 Ok(Some(ProjectTrustExtensionResult {
1152 trusted: ProjectTrustEventDecision::Undecided,
1153 remember: false,
1154 }))
1155 };
1156 let trusted = resolve_project_trusted(ResolveProjectTrustedOptions {
1157 cwd: project,
1158 trust_store: &store,
1159 trust_override: None,
1160 default_project_trust: DefaultProjectTrust::Always,
1161 extension_hook: Some(&mut undecided),
1162 ui: None,
1163 on_extension_error: None,
1164 })
1165 .map_err(|error| error.to_string())?;
1166 assert!(!trusted);
1167
1168 let _ = fs::remove_dir_all(root);
1169 Ok(())
1170 }
1171
1172 #[test]
1173 fn resolve_order_selection_updates_and_cancel() -> TestResult {
1174 let (root, project, store) = trust_fixture("resolve-ui")?;
1175 let mut ui = ScriptedUi {
1176 has_ui: true,
1177 choice: Some("Trust".to_owned()),
1178 last_prompt: None,
1179 last_options: Vec::new(),
1180 };
1181 let trusted = resolve_project_trusted(ResolveProjectTrustedOptions {
1182 cwd: project.clone(),
1183 trust_store: &store,
1184 trust_override: None,
1185 default_project_trust: DefaultProjectTrust::Ask,
1186 extension_hook: None,
1187 ui: Some(&mut ui),
1188 on_extension_error: None,
1189 })
1190 .map_err(|error| error.to_string())?;
1191 assert!(trusted);
1192 assert_eq!(store.get(&project).map_err(|e| e.to_string())?, Some(true));
1193 let expected_prompt = format_project_trust_prompt(&project);
1194 assert_eq!(ui.last_prompt.as_deref(), Some(expected_prompt.as_str()));
1195
1196 store
1197 .set(&project, None)
1198 .map_err(|error| error.to_string())?;
1199 let mut cancel_ui = ScriptedUi {
1200 has_ui: true,
1201 choice: None,
1202 last_prompt: None,
1203 last_options: Vec::new(),
1204 };
1205 let trusted = resolve_project_trusted(ResolveProjectTrustedOptions {
1206 cwd: project.clone(),
1207 trust_store: &store,
1208 trust_override: None,
1209 default_project_trust: DefaultProjectTrust::Ask,
1210 extension_hook: None,
1211 ui: Some(&mut cancel_ui),
1212 on_extension_error: None,
1213 })
1214 .map_err(|error| error.to_string())?;
1215 assert!(!trusted);
1216 assert_eq!(store.get(&project).map_err(|e| e.to_string())?, None);
1217
1218 let _ = fs::remove_dir_all(root);
1219 Ok(())
1220 }
1221
1222 #[test]
1223 fn extension_errors_are_reported_and_do_not_abort_resolution() -> TestResult {
1224 let root = unique_temp_dir("ext-err")?;
1225 let agent_dir = root.join("agent");
1226 let project = root.join("project");
1227 fs::create_dir_all(&agent_dir).map_err(|error| error.to_string())?;
1228 fs::create_dir_all(&project).map_err(|error| error.to_string())?;
1229 write_resource(&project.join(".pi").join("SYSTEM.md"))?;
1230 let store = ProjectTrustStore::new(&agent_dir);
1231
1232 let mut errors = Vec::new();
1233 let mut on_error = |message: String| errors.push(message);
1234 let mut hook = |_cwd: &Path| -> Result<Option<ProjectTrustExtensionResult>, String> {
1235 Err("Extension \"/tmp/ext.ts\" project_trust error: boom".to_owned())
1236 };
1237
1238 let trusted = resolve_project_trusted(ResolveProjectTrustedOptions {
1239 cwd: project,
1240 trust_store: &store,
1241 trust_override: None,
1242 default_project_trust: DefaultProjectTrust::Always,
1243 extension_hook: Some(&mut hook),
1244 ui: None,
1245 on_extension_error: Some(&mut on_error),
1246 })
1247 .map_err(|e| e.to_string())?;
1248 assert!(trusted);
1249 assert_eq!(
1250 errors,
1251 vec!["Extension \"/tmp/ext.ts\" project_trust error: boom".to_owned()]
1252 );
1253
1254 let _ = fs::remove_dir_all(root);
1255 Ok(())
1256 }
1257
1258 #[test]
1259 fn invalid_trust_file_errors_are_exact() -> TestResult {
1260 let root = unique_temp_dir("invalid")?;
1261 let agent_dir = root.join("agent");
1262 fs::create_dir_all(&agent_dir).map_err(|error| error.to_string())?;
1263 let store = ProjectTrustStore::new(&agent_dir);
1264 fs::write(store.path(), b"[1,2,3]\n").map_err(|e| e.to_string())?;
1265 let err = require_error(store.get(root.join("p")), "array root must fail")?;
1266 assert_eq!(
1267 err.to_string(),
1268 format!(
1269 "Invalid trust store {}: expected an object",
1270 path_to_string(store.path())
1271 )
1272 );
1273
1274 fs::write(store.path(), b"{\"a\":1}\n").map_err(|e| e.to_string())?;
1275 let err = require_error(store.get(root.join("p")), "non-bool value must fail")?;
1276 assert_eq!(
1277 err.to_string(),
1278 format!(
1279 "Invalid trust store {}: value for \"a\" must be true, false, or null",
1280 path_to_string(store.path())
1281 )
1282 );
1283
1284 let _ = fs::remove_dir_all(root);
1285 Ok(())
1286 }
1287
1288 #[test]
1289 fn concurrent_unknown_safe_updates_preserve_all_keys() -> TestResult {
1290 let root = unique_temp_dir("concurrent")?;
1291 let agent_dir = root.join("agent");
1292 fs::create_dir_all(&agent_dir).map_err(|error| error.to_string())?;
1293 let store = Arc::new(ProjectTrustStore::new(&agent_dir));
1294
1295 let other = root.join("other");
1297 fs::create_dir_all(&other).map_err(|error| error.to_string())?;
1298 store.set(&other, Some(true)).map_err(|e| e.to_string())?;
1299
1300 let mut handles = Vec::new();
1301 for index in 0..8 {
1302 let store = Arc::clone(&store);
1303 let root = root.clone();
1304 handles.push(thread::spawn(move || -> TestResult {
1305 let path = root.join(format!("p{index}"));
1306 fs::create_dir_all(&path).map_err(|error| error.to_string())?;
1307 store
1308 .set(&path, Some(index % 2 == 0))
1309 .map_err(|error| error.to_string())
1310 }));
1311 }
1312 let mut worker_error = None;
1313 for handle in handles {
1314 let result = handle
1315 .join()
1316 .map_err(|_| "thread panicked".to_owned())
1317 .and_then(|result| result);
1318 if worker_error.is_none() {
1319 worker_error = result.err();
1320 }
1321 }
1322 if let Some(error) = worker_error {
1323 return Err(error);
1324 }
1325
1326 assert_eq!(store.get(&other).map_err(|e| e.to_string())?, Some(true));
1327 for index in 0..8 {
1328 let path = root.join(format!("p{index}"));
1329 assert_eq!(
1330 store.get(&path).map_err(|e| e.to_string())?,
1331 Some(index % 2 == 0)
1332 );
1333 }
1334
1335 let raw = fs::read_to_string(store.path()).map_err(|e| e.to_string())?;
1336 assert!(raw.ends_with('\n'));
1337 let parsed: Value = serde_json::from_str(raw.trim_end()).map_err(|e| e.to_string())?;
1338 let object = parsed
1339 .as_object()
1340 .ok_or_else(|| "expected object".to_owned())?;
1341 assert_eq!(object.len(), 9);
1342
1343 let _ = fs::remove_dir_all(root);
1344 Ok(())
1345 }
1346
1347 #[test]
1348 fn default_project_trust_parse() {
1349 assert_eq!(
1350 DefaultProjectTrust::parse(Some("always")),
1351 DefaultProjectTrust::Always
1352 );
1353 assert_eq!(
1354 DefaultProjectTrust::parse(Some("never")),
1355 DefaultProjectTrust::Never
1356 );
1357 assert_eq!(
1358 DefaultProjectTrust::parse(Some("ask")),
1359 DefaultProjectTrust::Ask
1360 );
1361 assert_eq!(
1362 DefaultProjectTrust::parse(Some("sometimes")),
1363 DefaultProjectTrust::Ask
1364 );
1365 assert_eq!(DefaultProjectTrust::parse(None), DefaultProjectTrust::Ask);
1366 }
1367
1368 #[test]
1369 fn read_missing_trust_file_is_empty() -> TestResult {
1370 let root = unique_temp_dir("missing")?;
1371 let agent_dir = root.join("agent");
1372 fs::create_dir_all(&agent_dir).map_err(|error| error.to_string())?;
1373 let store = ProjectTrustStore::new(&agent_dir);
1374 assert_eq!(store.get(&root).map_err(|e| e.to_string())?, None);
1375 let _ = fs::remove_dir_all(root);
1376 Ok(())
1377 }
1378
1379 #[test]
1380 fn io_error_message_shape_for_unreadable_file() -> TestResult {
1381 let root = unique_temp_dir("read-fail")?;
1383 let agent_dir = root.join("agent");
1384 fs::create_dir_all(agent_dir.join("trust.json")).map_err(|error| error.to_string())?;
1385 let store = ProjectTrustStore::new(&agent_dir);
1386 let err = require_error(store.get(&root), "directory read must fail")?;
1387 let message = err.to_string();
1388 assert!(
1389 message.starts_with(&format!(
1390 "Failed to read trust store {}:",
1391 path_to_string(store.path())
1392 )),
1393 "unexpected message: {message}"
1394 );
1395 let _ = fs::remove_dir_all(root);
1396 Ok(())
1397 }
1398
1399 #[test]
1400 fn parent_path_none_at_filesystem_root() {
1401 let root = if cfg!(windows) {
1403 PathBuf::from(r"C:\")
1404 } else {
1405 PathBuf::from("/")
1406 };
1407 assert_eq!(get_project_trust_parent_path(&root), None);
1408 let options = get_project_trust_options(&root, false);
1409 assert_eq!(
1410 options.iter().map(|o| o.label.as_str()).collect::<Vec<_>>(),
1411 vec!["Trust", "Do not trust"]
1412 );
1413 }
1414}