use crate::config::Config;
use crate::error::{Error, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha256};
use std::collections::BTreeMap;
use std::fs::File;
use std::io::Read as _;
use std::path::{Path, PathBuf};
pub const TRUST_STORE_FILE: &str = "workspace-trust.json";
pub const TRUST_ENV_VAR: &str = "PI_WORKSPACE_TRUST";
const TRUST_STORE_VERSION: u32 = 1;
const TRUST_SURFACE_DIGEST_DOMAIN: &[u8] = b"pi_agent_rust:workspace-trust-surface:v2";
const MAX_TRUST_CONFIG_BYTES: usize = 1024 * 1024;
const MAX_TRUST_EXTENSION_BYTES: usize = 16 * 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TrustDecision {
Trusted,
Untrusted,
}
#[derive(Debug, Clone)]
pub struct WorkspaceTrustSurface {
pub workspace_display: String,
pub has_project_settings: bool,
pub package_count: usize,
pub extension_entries: Vec<String>,
pub mcp_config_entries: Vec<String>,
pub digest: String,
}
impl WorkspaceTrustSurface {
pub fn scan(cwd: &Path) -> Result<Option<Self>> {
const PROJECT_MCP_CONFIGS: &[&str] = &[
".agents/mcp.json",
".claude/mcp.json",
".codex/config.toml",
".cursor/mcp.json",
".gemini/settings.json",
".pi/mcp.json",
".windsurf/mcp.json",
];
let project_dir = cwd.join(Config::project_dir());
let settings_path = project_dir.join("settings.json");
let extensions_dir = project_dir.join("extensions");
let settings_bytes = read_bounded_regular_file(&settings_path, MAX_TRUST_CONFIG_BYTES)?;
let mut extension_files = Vec::new();
collect_files_recursive(&extensions_dir, &extensions_dir, &mut extension_files)?;
extension_files.sort_by(|a, b| a.relative.cmp(&b.relative));
let mut mcp_config_files = Vec::new();
for relative in PROJECT_MCP_CONFIGS {
let absolute = cwd.join(relative);
if let Some(bytes) = read_bounded_regular_file(&absolute, MAX_TRUST_CONFIG_BYTES)? {
mcp_config_files.push((PathBuf::from(*relative), bytes));
}
}
if settings_bytes.is_none() && extension_files.is_empty() && mcp_config_files.is_empty() {
return Ok(None);
}
let package_count = settings_bytes
.as_deref()
.and_then(|bytes| serde_json::from_slice::<serde_json::Value>(bytes).ok())
.and_then(|value| {
value
.get("packages")
.and_then(|packages| packages.as_array().map(Vec::len))
})
.unwrap_or(0);
let mut surface_hasher = Sha256::new();
surface_hasher.update(TRUST_SURFACE_DIGEST_DOMAIN);
if let Some(bytes) = &settings_bytes {
hash_surface_record(
&mut surface_hasher,
b"settings",
Path::new(".pi/settings.json"),
bytes,
);
}
let mut extension_entries = Vec::with_capacity(extension_files.len());
for found in &extension_files {
let bytes = read_bounded_regular_file(&found.absolute, MAX_TRUST_EXTENSION_BYTES)?
.ok_or_else(|| {
Error::config(format!(
"Trust surface disappeared while scanning {}",
escaped_path(&found.absolute)
))
})?;
let relative = Path::new(".pi/extensions").join(&found.relative);
hash_surface_record(&mut surface_hasher, b"extension", &relative, &bytes);
extension_entries.push(escaped_path(&relative));
}
let mut mcp_config_entries = Vec::with_capacity(mcp_config_files.len());
for (relative, bytes) in mcp_config_files {
hash_surface_record(&mut surface_hasher, b"mcp", &relative, &bytes);
mcp_config_entries.push(escaped_path(&relative));
}
Ok(Some(Self {
workspace_display: escaped_path(
&std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf()),
),
has_project_settings: settings_bytes.is_some(),
package_count,
extension_entries,
mcp_config_entries,
digest: crate::package_manager::hex_encode(&surface_hasher.finalize()),
}))
}
}
fn read_bounded_regular_file(path: &Path, max_bytes: usize) -> Result<Option<Vec<u8>>> {
let metadata = match std::fs::metadata(path) {
Ok(metadata) => metadata,
Err(err)
if matches!(
err.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) =>
{
return Ok(None);
}
Err(err) => {
return Err(Error::config(format!(
"Failed to inspect {}: {err}",
escaped_path(path)
)));
}
};
if !metadata.is_file() {
return Err(Error::config(format!(
"Trust surface is not a regular file: {}",
escaped_path(path)
)));
}
if metadata.len() > max_bytes as u64 {
return Err(Error::config(format!(
"Trust surface exceeds {max_bytes} bytes: {}",
escaped_path(path)
)));
}
let file = File::open(path)
.map_err(|err| Error::config(format!("Failed to read {}: {err}", escaped_path(path))))?;
#[allow(clippy::cast_possible_truncation)]
let mut bytes = Vec::with_capacity((metadata.len() as usize).min(max_bytes));
file.take(max_bytes as u64 + 1)
.read_to_end(&mut bytes)
.map_err(|err| Error::config(format!("Failed to read {}: {err}", escaped_path(path))))?;
if bytes.len() > max_bytes {
return Err(Error::config(format!(
"Trust surface exceeds {max_bytes} bytes: {}",
escaped_path(path)
)));
}
Ok(Some(bytes))
}
fn hash_surface_record(hasher: &mut Sha256, kind: &[u8], path: &Path, bytes: &[u8]) {
hash_length_prefixed(hasher, kind);
hash_length_prefixed(hasher, path.as_os_str().as_encoded_bytes());
hash_length_prefixed(hasher, bytes);
}
fn hash_length_prefixed(hasher: &mut Sha256, bytes: &[u8]) {
hasher.update((bytes.len() as u64).to_be_bytes());
hasher.update(bytes);
}
fn escaped_path(path: &Path) -> String {
path.as_os_str()
.to_string_lossy()
.chars()
.flat_map(char::escape_default)
.collect()
}
struct FoundSurfaceFile {
relative: PathBuf,
absolute: PathBuf,
}
fn collect_files_recursive(root: &Path, dir: &Path, out: &mut Vec<FoundSurfaceFile>) -> Result<()> {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(err) => {
return Err(Error::config(format!(
"Failed to scan {}: {err}",
dir.display()
)));
}
};
for entry in entries {
let entry = entry
.map_err(|err| Error::config(format!("Failed to scan {}: {err}", dir.display())))?;
let path = entry.path();
let file_type = entry
.file_type()
.map_err(|err| Error::config(format!("Failed to inspect {}: {err}", path.display())))?;
if file_type.is_dir() {
collect_files_recursive(root, &path, out)?;
} else if file_type.is_file() || file_type.is_symlink() {
if let Ok(relative) = path.strip_prefix(root) {
let relative = relative.to_path_buf();
out.push(FoundSurfaceFile {
relative,
absolute: path,
});
}
}
}
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct TrustRecord {
digest: String,
decision: TrustDecision,
#[serde(default)]
updated_at: String,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct TrustStoreFile {
#[serde(default)]
version: u32,
#[serde(default)]
workspaces: BTreeMap<String, TrustRecord>,
}
#[derive(Debug)]
pub struct WorkspaceTrustStore {
path: PathBuf,
data: TrustStoreFile,
}
impl WorkspaceTrustStore {
#[must_use]
pub fn default_path() -> PathBuf {
Config::global_dir().join(TRUST_STORE_FILE)
}
#[must_use]
pub fn load(path: &Path) -> Self {
let data = std::fs::read(path)
.ok()
.and_then(|bytes| serde_json::from_slice::<TrustStoreFile>(&bytes).ok())
.unwrap_or_default();
Self {
path: path.to_path_buf(),
data,
}
}
#[must_use]
pub fn decision(&self, workspace: &str, digest: &str) -> Option<TrustDecision> {
self.data
.workspaces
.get(workspace)
.filter(|record| record.digest == digest)
.map(|record| record.decision)
}
pub fn record(&mut self, workspace: &str, digest: &str, decision: TrustDecision) -> Result<()> {
self.data.version = TRUST_STORE_VERSION;
self.data.workspaces.insert(
workspace.to_string(),
TrustRecord {
digest: digest.to_string(),
decision,
updated_at: chrono::Utc::now().to_rfc3339(),
},
);
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent).map_err(|err| {
Error::config(format!("Failed to create {}: {err}", parent.display()))
})?;
}
let json = serde_json::to_string_pretty(&self.data)
.map_err(|err| Error::config(format!("Failed to encode trust store: {err}")))?;
std::fs::write(&self.path, json).map_err(|err| {
Error::config(format!("Failed to write {}: {err}", self.path.display()))
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let _ = std::fs::set_permissions(&self.path, std::fs::Permissions::from_mode(0o600));
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrustSource {
NoSurface,
CliFlag,
TrustAllConfig,
EnvOverride,
Store,
Prompt,
NonInteractive,
}
#[derive(Debug)]
pub struct WorkspaceTrustState {
pub trusted: bool,
pub source: TrustSource,
pub surface: Option<WorkspaceTrustSurface>,
}
#[derive(Debug, Clone)]
pub struct TrustInputs {
pub cli_trust: bool,
pub trust_all_workspaces: bool,
pub env_override: Option<String>,
pub interactive: bool,
}
#[cfg(unix)]
fn encoded_workspace_key(canonical: &Path) -> String {
use std::os::unix::ffi::OsStrExt as _;
format!(
"path-v3:unix:{}",
crate::package_manager::hex_encode(canonical.as_os_str().as_bytes())
)
}
#[cfg(windows)]
fn encoded_workspace_key(canonical: &Path) -> String {
use std::os::windows::ffi::OsStrExt as _;
let bytes = canonical
.as_os_str()
.encode_wide()
.flat_map(u16::to_le_bytes)
.collect::<Vec<_>>();
format!(
"path-v3:windows-utf16le:{}",
crate::package_manager::hex_encode(&bytes)
)
}
#[cfg(not(any(unix, windows)))]
fn encoded_workspace_key(canonical: &Path) -> String {
format!(
"path-v3:platform-encoded:{}",
crate::package_manager::hex_encode(canonical.as_os_str().as_encoded_bytes())
)
}
#[must_use]
pub fn workspace_key(cwd: &Path) -> String {
let canonical = std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
encoded_workspace_key(&canonical)
}
pub fn establish(
cwd: &Path,
store_path: &Path,
inputs: &TrustInputs,
prompt: impl FnOnce(&WorkspaceTrustSurface) -> Result<bool>,
) -> Result<WorkspaceTrustState> {
let Some(surface) = WorkspaceTrustSurface::scan(cwd)? else {
return Ok(WorkspaceTrustState {
trusted: true,
source: TrustSource::NoSurface,
surface: None,
});
};
let key = workspace_key(cwd);
let mut store = WorkspaceTrustStore::load(store_path);
if let Some(value) = inputs.env_override.as_deref() {
let trusted = match value.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "trusted" => true,
"0" | "false" | "untrusted" => false,
other => {
return Err(Error::config(format!(
"Invalid {TRUST_ENV_VAR} value '{other}': expected trusted or untrusted"
)));
}
};
return Ok(WorkspaceTrustState {
trusted,
source: TrustSource::EnvOverride,
surface: Some(surface),
});
}
if inputs.cli_trust {
store.record(&key, &surface.digest, TrustDecision::Trusted)?;
return Ok(WorkspaceTrustState {
trusted: true,
source: TrustSource::CliFlag,
surface: Some(surface),
});
}
if inputs.trust_all_workspaces {
return Ok(WorkspaceTrustState {
trusted: true,
source: TrustSource::TrustAllConfig,
surface: Some(surface),
});
}
match store.decision(&key, &surface.digest) {
Some(TrustDecision::Trusted) => Ok(WorkspaceTrustState {
trusted: true,
source: TrustSource::Store,
surface: Some(surface),
}),
Some(TrustDecision::Untrusted) => Ok(WorkspaceTrustState {
trusted: false,
source: TrustSource::Store,
surface: Some(surface),
}),
None if inputs.interactive => {
let granted = prompt(&surface)?;
let decision = if granted {
TrustDecision::Trusted
} else {
TrustDecision::Untrusted
};
store.record(&key, &surface.digest, decision)?;
Ok(WorkspaceTrustState {
trusted: granted,
source: TrustSource::Prompt,
surface: Some(surface),
})
}
None => Ok(WorkspaceTrustState {
trusted: false,
source: TrustSource::NonInteractive,
surface: Some(surface),
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn write(path: &Path, content: &str) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("create parent");
}
std::fs::write(path, content).expect("write fixture");
}
fn no_prompt(_: &WorkspaceTrustSurface) -> Result<bool> {
Err(Error::config("prompt must not run in this scenario"))
}
fn inputs() -> TrustInputs {
TrustInputs {
cli_trust: false,
trust_all_workspaces: false,
env_override: None,
interactive: false,
}
}
#[test]
fn scan_treats_a_file_where_a_surface_directory_is_expected_as_absent() {
let dir = tempfile::tempdir().expect("tempdir"); std::fs::write(dir.path().join(".codex"), b"").expect("write .codex file"); assert!(
WorkspaceTrustSurface::scan(dir.path())
.expect("scan") .is_none()
);
}
#[test]
fn scan_returns_none_without_project_surfaces() {
let dir = tempfile::tempdir().expect("tempdir");
assert!(
WorkspaceTrustSurface::scan(dir.path())
.expect("scan")
.is_none()
);
write(&dir.path().join(".pi/notes.txt"), "not a surface");
assert!(
WorkspaceTrustSurface::scan(dir.path())
.expect("scan")
.is_none()
);
}
#[test]
fn scan_digest_is_stable_and_tracks_content() {
let dir = tempfile::tempdir().expect("tempdir");
write(
&dir.path().join(".pi/settings.json"),
r#"{"packages":["npm:left-pad"],"theme":"dark"}"#,
);
write(&dir.path().join(".pi/extensions/hook.js"), "export {}\n");
write(
&dir.path().join(".pi/extensions/nested/util.ts"),
"export const x = 1\n",
);
let first = WorkspaceTrustSurface::scan(dir.path())
.expect("scan")
.expect("surface");
let second = WorkspaceTrustSurface::scan(dir.path())
.expect("scan")
.expect("surface");
assert_eq!(first.digest, second.digest, "digest must be deterministic");
assert!(first.has_project_settings);
assert_eq!(first.package_count, 1);
assert_eq!(
first.extension_entries,
vec![
".pi/extensions/hook.js".to_string(),
".pi/extensions/nested/util.ts".to_string(),
]
);
assert!(first.mcp_config_entries.is_empty());
write(
&dir.path().join(".pi/settings.json"),
r#"{"packages":["npm:left-pad","npm:evil"],"theme":"dark"}"#,
);
let settings_changed = WorkspaceTrustSurface::scan(dir.path())
.expect("scan")
.expect("surface");
assert_ne!(first.digest, settings_changed.digest);
assert_eq!(settings_changed.package_count, 2);
write(
&dir.path().join(".pi/extensions/hook.js"),
"export const changed = true\n",
);
let extension_changed = WorkspaceTrustSurface::scan(dir.path())
.expect("scan")
.expect("surface");
assert_ne!(settings_changed.digest, extension_changed.digest);
write(&dir.path().join(".pi/extensions/new.js"), "export {}\n");
let extension_added = WorkspaceTrustSurface::scan(dir.path())
.expect("scan")
.expect("surface");
assert_ne!(extension_changed.digest, extension_added.digest);
}
#[test]
fn scan_tracks_project_mcp_configuration_as_executable_surface() {
let dir = tempfile::tempdir().expect("tempdir");
let project_mcp = dir.path().join(".pi/mcp.json");
write(
&project_mcp,
r#"{"mcpServers":{"local":{"command":"first"}}}"#,
);
let first = WorkspaceTrustSurface::scan(dir.path())
.expect("scan")
.expect("MCP-only workspace must have a trust surface");
assert!(!first.has_project_settings);
assert!(first.extension_entries.is_empty());
assert_eq!(first.mcp_config_entries, vec![".pi/mcp.json"]);
write(
&project_mcp,
r#"{"mcpServers":{"local":{"command":"second"}}}"#,
);
let changed = WorkspaceTrustSurface::scan(dir.path())
.expect("scan")
.expect("surface");
assert_ne!(
first.digest, changed.digest,
"changing an MCP execution target must invalidate workspace trust"
);
write(
&dir.path().join(".codex/config.toml"),
"[mcp_servers.foreign]\ncommand = \"foreign\"\n",
);
let foreign_added = WorkspaceTrustSurface::scan(dir.path())
.expect("scan")
.expect("surface");
assert_eq!(
foreign_added.mcp_config_entries,
vec![".codex/config.toml", ".pi/mcp.json"]
);
assert_ne!(changed.digest, foreign_added.digest);
}
#[test]
fn scan_rejects_non_regular_and_oversized_trust_surfaces() {
let non_regular = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(non_regular.path().join(".pi/mcp.json"))
.expect("create non-regular MCP surface");
let err = WorkspaceTrustSurface::scan(non_regular.path())
.expect_err("directories must not be read as trust surfaces");
assert!(err.to_string().contains("not a regular file"));
let oversized = tempfile::tempdir().expect("tempdir");
write(
&oversized.path().join(".pi/mcp.json"),
&"x".repeat(MAX_TRUST_CONFIG_BYTES + 1),
);
let err = WorkspaceTrustSurface::scan(oversized.path())
.expect_err("oversized configs must fail before allocation grows unbounded");
assert!(err.to_string().contains("exceeds 1048576 bytes"));
}
#[cfg(unix)]
#[test]
fn scan_rejects_device_symlinks_and_escapes_untrusted_names() {
use std::os::unix::fs::symlink;
let device = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(device.path().join(".pi")).expect("create project dir");
symlink("/dev/zero", device.path().join(".pi/mcp.json")).expect("create device symlink");
let err = WorkspaceTrustSurface::scan(device.path())
.expect_err("device symlinks must be rejected before opening");
assert!(err.to_string().contains("not a regular file"));
let controls = tempfile::tempdir().expect("tempdir");
write(
&controls.path().join(".pi/extensions/evil\n\u{1b}[2J.js"),
"export {}\n",
);
let surface = WorkspaceTrustSurface::scan(controls.path())
.expect("scan")
.expect("surface");
let displayed = surface.extension_entries.join(" ");
assert!(!displayed.contains('\n'));
assert!(!displayed.contains('\u{1b}'));
assert!(displayed.contains("\\n"));
assert!(displayed.contains("\\u{1b}"));
}
#[test]
fn store_roundtrip_and_digest_mismatch() {
let dir = tempfile::tempdir().expect("tempdir");
let store_path = dir.path().join("workspace-trust.json");
let mut store = WorkspaceTrustStore::load(&store_path);
assert!(store.decision("/ws", "d1").is_none());
store
.record("/ws", "d1", TrustDecision::Trusted)
.expect("record");
let reloaded = WorkspaceTrustStore::load(&store_path);
assert_eq!(reloaded.decision("/ws", "d1"), Some(TrustDecision::Trusted));
assert_eq!(
reloaded.decision("/ws", "d2"),
None,
"a digest change must invalidate the stored decision"
);
assert_eq!(reloaded.decision("/other", "d1"), None);
}
#[cfg(unix)]
#[test]
fn workspace_keys_preserve_non_utf8_canonical_path_identity() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt as _;
let dir = tempfile::tempdir().expect("tempdir");
let first = dir
.path()
.join(OsString::from_vec(vec![b'w', b's', b'-', 0x80]));
let second = dir
.path()
.join(OsString::from_vec(vec![b'w', b's', b'-', 0x81]));
std::fs::create_dir_all(&first).expect("create first workspace");
std::fs::create_dir_all(&second).expect("create second workspace");
let first_key = workspace_key(&first);
let second_key = workspace_key(&second);
assert_ne!(
first_key, second_key,
"distinct raw canonical paths must never share trust decisions"
);
assert!(first_key.starts_with("path-v3:unix:"));
assert!(first_key.is_ascii(), "store keys must remain JSON-safe");
}
#[cfg(windows)]
#[test]
fn workspace_keys_use_tagged_utf16le_path_encoding() {
use std::os::windows::ffi::OsStrExt as _;
let dir = tempfile::tempdir().expect("tempdir");
let canonical = std::fs::canonicalize(dir.path()).expect("canonical workspace");
let expected_bytes = canonical
.as_os_str()
.encode_wide()
.flat_map(u16::to_le_bytes)
.collect::<Vec<_>>();
assert_eq!(
workspace_key(dir.path()),
format!(
"path-v3:windows-utf16le:{}",
crate::package_manager::hex_encode(&expected_bytes)
)
);
}
#[test]
fn corrupt_store_is_treated_as_empty() {
let dir = tempfile::tempdir().expect("tempdir");
let store_path = dir.path().join("workspace-trust.json");
write(&store_path, "{not json");
let store = WorkspaceTrustStore::load(&store_path);
assert!(store.decision("/ws", "d1").is_none());
}
fn seeded_workspace() -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
write(
&dir.path().join(".pi/settings.json"),
r#"{"packages":["npm:left-pad"]}"#,
);
write(&dir.path().join(".pi/extensions/hook.js"), "export {}\n");
dir
}
#[test]
fn establish_trivially_trusts_workspaces_without_surfaces() {
let dir = tempfile::tempdir().expect("tempdir");
let store_path = dir.path().join("store.json");
let state = establish(dir.path(), &store_path, &inputs(), no_prompt).expect("establish");
assert!(state.trusted);
assert_eq!(state.source, TrustSource::NoSurface);
assert!(!store_path.exists(), "no-surface runs must not persist");
}
#[test]
fn establish_non_interactive_mcp_only_workspace_fails_closed() {
let dir = tempfile::tempdir().expect("tempdir");
write(
&dir.path().join(".agents/mcp.json"),
r#"{"mcpServers":{"project":{"command":"project-server"}}}"#,
);
let store_path = dir.path().join("store.json");
let state = establish(dir.path(), &store_path, &inputs(), no_prompt).expect("establish");
assert!(!state.trusted);
assert_eq!(state.source, TrustSource::NonInteractive);
assert_eq!(
state.surface.as_ref().expect("surface").mcp_config_entries,
vec![".agents/mcp.json"]
);
assert!(!store_path.exists());
}
#[test]
fn establish_cli_flag_trusts_and_persists() {
let dir = seeded_workspace();
let store_path = dir.path().join("store.json");
let state = establish(
dir.path(),
&store_path,
&TrustInputs {
cli_trust: true,
..inputs()
},
no_prompt,
)
.expect("establish");
assert!(state.trusted);
assert_eq!(state.source, TrustSource::CliFlag);
let followup = establish(dir.path(), &store_path, &inputs(), no_prompt).expect("establish");
assert!(followup.trusted);
assert_eq!(followup.source, TrustSource::Store);
}
#[test]
fn establish_trust_all_config_trusts_without_persisting() {
let dir = seeded_workspace();
let store_path = dir.path().join("store.json");
let state = establish(
dir.path(),
&store_path,
&TrustInputs {
trust_all_workspaces: true,
..inputs()
},
no_prompt,
)
.expect("establish");
assert!(state.trusted);
assert_eq!(state.source, TrustSource::TrustAllConfig);
assert!(!store_path.exists());
}
#[test]
fn establish_env_override_wins_without_persisting() {
let dir = seeded_workspace();
let store_path = dir.path().join("store.json");
for (value, expected) in [("trusted", true), ("0", false)] {
let state = establish(
dir.path(),
&store_path,
&TrustInputs {
env_override: Some(value.to_string()),
cli_trust: true,
..inputs()
},
no_prompt,
)
.expect("establish");
assert_eq!(state.trusted, expected, "env value {value}");
assert_eq!(state.source, TrustSource::EnvOverride);
}
assert!(!store_path.exists());
let err = establish(
dir.path(),
&store_path,
&TrustInputs {
env_override: Some("maybe".to_string()),
..inputs()
},
no_prompt,
)
.expect_err("invalid env value must fail");
assert!(err.to_string().contains(TRUST_ENV_VAR));
}
#[test]
fn establish_prompt_answers_persist_both_ways() {
for (answer, expected) in [(true, true), (false, false)] {
let dir = seeded_workspace();
let store_path = dir.path().join("store.json");
let state = establish(
dir.path(),
&store_path,
&TrustInputs {
interactive: true,
..inputs()
},
|_| Ok(answer),
)
.expect("establish");
assert_eq!(state.trusted, expected);
assert_eq!(state.source, TrustSource::Prompt);
let followup =
establish(dir.path(), &store_path, &inputs(), no_prompt).expect("establish");
assert_eq!(followup.trusted, expected);
assert_eq!(followup.source, TrustSource::Store);
}
}
#[test]
fn establish_reprompts_after_content_change() {
let dir = seeded_workspace();
let store_path = dir.path().join("store.json");
establish(
dir.path(),
&store_path,
&TrustInputs {
interactive: true,
..inputs()
},
|_| Ok(true),
)
.expect("establish");
write(
&dir.path().join(".pi/extensions/hook.js"),
"export const changed = 1\n",
);
let state = establish(
dir.path(),
&store_path,
&TrustInputs {
interactive: true,
..inputs()
},
|_| Ok(false),
)
.expect("establish");
assert!(!state.trusted, "digest change must invalidate stored trust");
assert_eq!(state.source, TrustSource::Prompt);
}
#[test]
fn establish_non_interactive_fails_closed_without_persisting() {
let dir = seeded_workspace();
let store_path = dir.path().join("store.json");
let state = establish(dir.path(), &store_path, &inputs(), no_prompt).expect("establish");
assert!(!state.trusted);
assert_eq!(state.source, TrustSource::NonInteractive);
assert!(
!store_path.exists(),
"non-interactive denial must stay ephemeral so a later interactive run prompts"
);
}
}