use std::path::{Path, PathBuf};
use oino_settings::paths::{CONFIG_DIR_NAME, TRUST_FILE, canonicalize_or_self, lexical_normalize};
use oino_settings::storage::{FileLock, write_atomic};
use oino_settings::{JsonObject, ProjectTrustDefault};
use serde_json::Value;
pub const PROTECTED_PROJECT_RESOURCES: [&str; 7] = [
"settings.json",
"extensions",
"skills",
"prompts",
"themes",
"SYSTEM.md",
"APPEND_SYSTEM.md",
];
pub type ProjectTrustDecision = Option<bool>;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TrustEntry {
pub path: PathBuf,
pub trusted: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TrustUpdate {
pub path: PathBuf,
pub decision: ProjectTrustDecision,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TrustOption {
pub label: String,
pub trusted: bool,
pub updates: Vec<TrustUpdate>,
pub saved_path: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum TrustStoreError {
#[error("Failed to read trust store {path}: {message}")]
Read {
path: PathBuf,
message: String,
},
#[error("Invalid trust store {path}: expected an object")]
NotAnObject {
path: PathBuf,
},
#[error("Invalid trust store {path}: value for {key} must be true, false, or null")]
InvalidValue {
path: PathBuf,
key: String,
},
#[error("Failed to write trust store {path}: {message}")]
Write {
path: PathBuf,
message: String,
},
}
#[derive(Clone, Debug)]
pub struct TrustStore {
path: PathBuf,
}
fn normalize_dir(dir: &Path) -> PathBuf {
let absolute =
std::path::absolute(dir).map_or_else(|_| dir.to_path_buf(), |p| lexical_normalize(&p));
canonicalize_or_self(&absolute)
}
impl TrustStore {
#[must_use]
pub fn new(agent_dir: &Path) -> Self {
Self {
path: agent_dir.join(TRUST_FILE),
}
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
pub fn get(&self, cwd: &Path) -> Result<ProjectTrustDecision, TrustStoreError> {
Ok(self.get_entry(cwd)?.map(|entry| entry.trusted))
}
pub fn get_entry(&self, cwd: &Path) -> Result<Option<TrustEntry>, TrustStoreError> {
let data = self.locked(|| self.read())?;
let start = normalize_dir(cwd);
Ok(start
.ancestors()
.find_map(|dir| match data.get(dir.to_string_lossy().as_ref()) {
Some(Value::Bool(trusted)) => Some(TrustEntry {
path: dir.to_path_buf(),
trusted: *trusted,
}),
_ => None,
}))
}
pub fn set(&self, cwd: &Path, decision: ProjectTrustDecision) -> Result<(), TrustStoreError> {
self.set_many(&[TrustUpdate {
path: cwd.to_path_buf(),
decision,
}])
}
pub fn set_many(&self, updates: &[TrustUpdate]) -> Result<(), TrustStoreError> {
self.locked(|| {
let mut data = self.read()?;
for update in updates {
let key = normalize_dir(&update.path).to_string_lossy().into_owned();
match update.decision {
Some(trusted) => {
data.insert(key, Value::Bool(trusted));
}
None => {
data.shift_remove(&key);
}
}
}
let mut keys: Vec<&String> = data.keys().collect();
keys.sort();
let sorted: JsonObject = keys
.into_iter()
.map(|key| (key.clone(), data[key].clone()))
.collect();
let text = serde_json::to_string_pretty(&Value::Object(sorted)).map_err(|e| {
TrustStoreError::Write {
path: self.path.clone(),
message: e.to_string(),
}
})?;
write_atomic(&self.path, format!("{text}\n").as_bytes()).map_err(|e| {
TrustStoreError::Write {
path: self.path.clone(),
message: e.to_string(),
}
})
})
}
fn locked<T>(
&self,
f: impl FnOnce() -> Result<T, TrustStoreError>,
) -> Result<T, TrustStoreError> {
let _lock = FileLock::acquire(&self.path).map_err(|e| TrustStoreError::Write {
path: self.path.clone(),
message: e.to_string(),
})?;
f()
}
fn read(&self) -> Result<JsonObject, TrustStoreError> {
let content = match std::fs::read_to_string(&self.path) {
Ok(content) => content,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(JsonObject::new());
}
Err(error) => {
return Err(TrustStoreError::Read {
path: self.path.clone(),
message: error.to_string(),
});
}
};
let parsed: Value = serde_json::from_str(oino_settings::json::strip_bom(&content))
.map_err(|error| TrustStoreError::Read {
path: self.path.clone(),
message: error.to_string(),
})?;
let Value::Object(data) = parsed else {
return Err(TrustStoreError::NotAnObject {
path: self.path.clone(),
});
};
if let Some((key, _)) = data
.iter()
.find(|(_, value)| !matches!(value, Value::Bool(_) | Value::Null))
{
return Err(TrustStoreError::InvalidValue {
path: self.path.clone(),
key: Value::from(key.as_str()).to_string(),
});
}
Ok(data)
}
}
#[must_use]
pub fn has_protected_resources(cwd: &Path, home: Option<&Path>) -> bool {
let user_agents_skills = home.map(|home| normalize_dir(home).join(".agents").join("skills"));
let start = normalize_dir(cwd);
let config = start.join(CONFIG_DIR_NAME);
if PROTECTED_PROJECT_RESOURCES
.iter()
.any(|entry| config.join(entry).exists())
{
return true;
}
start.ancestors().any(|dir| {
let skills = dir.join(".agents").join("skills");
Some(&skills) != user_agents_skills.as_ref() && skills.exists()
})
}
#[must_use]
pub fn trust_parent_path(cwd: &Path) -> Option<PathBuf> {
normalize_dir(cwd).parent().map(Path::to_path_buf)
}
#[must_use]
pub fn trust_options(cwd: &Path, include_session_only: bool) -> Vec<TrustOption> {
let trust_path = normalize_dir(cwd);
let mut options = vec![TrustOption {
label: "Trust".to_owned(),
trusted: true,
updates: vec![TrustUpdate {
path: trust_path.clone(),
decision: Some(true),
}],
saved_path: Some(trust_path.clone()),
}];
if let Some(parent) = trust_parent_path(cwd) {
options.push(TrustOption {
label: format!("Trust parent folder ({})", parent.display()),
trusted: true,
updates: vec![
TrustUpdate {
path: parent.clone(),
decision: Some(true),
},
TrustUpdate {
path: trust_path.clone(),
decision: None,
},
],
saved_path: Some(parent),
});
}
if include_session_only {
options.push(TrustOption {
label: "Trust (this session only)".to_owned(),
trusted: true,
updates: Vec::new(),
saved_path: None,
});
}
options.push(TrustOption {
label: "Do not trust".to_owned(),
trusted: false,
updates: vec![TrustUpdate {
path: trust_path.clone(),
decision: Some(false),
}],
saved_path: Some(trust_path),
});
if include_session_only {
options.push(TrustOption {
label: "Do not trust (this session only)".to_owned(),
trusted: false,
updates: Vec::new(),
saved_path: None,
});
}
options
}
#[must_use]
pub fn trust_prompt(cwd: &Path) -> String {
format!(
"Trust project folder?\n{}\n\nThis allows Oino to load {CONFIG_DIR_NAME} settings and resources, install missing project packages, and execute project extensions.",
cwd.display()
)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ExtensionTrustDecision {
pub trusted: bool,
pub remember: bool,
}
#[derive(Clone, Debug)]
pub struct TrustRequest<'a> {
pub cwd: &'a Path,
pub home: Option<&'a Path>,
pub cli_override: Option<bool>,
pub extension_decision: Option<ExtensionTrustDecision>,
pub default_trust: ProjectTrustDefault,
pub has_ui: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TrustSource {
Cli,
NoProtectedResources,
Extension,
Saved,
Default,
NonInteractive,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TrustResolution {
Decided {
trusted: bool,
source: TrustSource,
},
Ask {
options: Vec<TrustOption>,
},
}
pub fn resolve_project_trust(
request: &TrustRequest<'_>,
store: &TrustStore,
) -> Result<TrustResolution, TrustStoreError> {
let decided = |trusted, source| TrustResolution::Decided { trusted, source };
if let Some(trusted) = request.cli_override {
return Ok(decided(trusted, TrustSource::Cli));
}
if !has_protected_resources(request.cwd, request.home) {
return Ok(decided(true, TrustSource::NoProtectedResources));
}
if let Some(extension) = request.extension_decision {
if extension.remember {
store.set(request.cwd, Some(extension.trusted))?;
}
return Ok(decided(extension.trusted, TrustSource::Extension));
}
if let Some(trusted) = store.get(request.cwd)? {
return Ok(decided(trusted, TrustSource::Saved));
}
match request.default_trust {
ProjectTrustDefault::Always => Ok(decided(true, TrustSource::Default)),
ProjectTrustDefault::Never => Ok(decided(false, TrustSource::Default)),
ProjectTrustDefault::Ask if !request.has_ui => {
Ok(decided(false, TrustSource::NonInteractive))
}
ProjectTrustDefault::Ask => Ok(TrustResolution::Ask {
options: trust_options(request.cwd, true),
}),
}
}