use crate::action::ActionPolicy;
use crate::gateway::budget::BudgetPolicy;
use crate::gateway::schema::Policy;
use crate::imp::context::{CompiledContextPolicy, ContextPolicy};
use crate::imp::memory::{CompiledMemoryPolicy, MemoryPolicy};
use crate::imp::storage::{CompiledStoragePolicy, StoragePolicy};
use crate::paths;
use serde_json::{json, Value};
use std::path::PathBuf;
use std::sync::{Arc, Mutex, OnceLock};
#[derive(Clone, Debug)]
pub struct Connection {
pub name: String,
pub provider: String,
pub imps: Option<Vec<String>>,
pub hosts: Vec<String>,
pub methods: Vec<String>,
pub env: String,
pub enabled: bool,
}
#[derive(Clone, Debug)]
pub struct Expose {
pub scope: String,
pub credential: String,
pub env: String,
}
pub struct Loaded {
pub policy: Policy,
pub budget: BudgetPolicy,
pub actions: ActionPolicy,
pub triggers: Vec<Value>,
pub memory: CompiledMemoryPolicy,
pub context: CompiledContextPolicy,
pub storage: CompiledStoragePolicy,
pub listeners: Vec<(String, String, String)>,
pub exposes: Vec<Expose>,
pub connections: Vec<Connection>,
pub warnings: Vec<String>,
pub imps: Vec<String>,
pub engine_dir: Option<PathBuf>,
}
pub fn load() -> Result<Loaded, Vec<String>> {
let mut errors: Vec<String> = Vec::new();
let org_path = paths::org_file();
let org = match read_toml(&org_path) {
Ok(v) => v,
Err(e) => {
errors.push(format!("{}: {e}", org_path.display()));
toml::Value::Table(Default::default())
}
};
let mut rules: Vec<Value> = Vec::new();
let mut limits: Vec<Value> = Vec::new();
let mut actions: Vec<Value> = Vec::new();
let mut trust: Vec<Value> = Vec::new();
let mut triggers: Vec<Value> = Vec::new();
let mut listeners: Vec<(String, String, String)> = Vec::new();
let mut exposes: Vec<Expose> = Vec::new();
let mut imps: Vec<String> = Vec::new();
let default_memory = memory_policy(org.get("memory"), None).unwrap_or_else(|e| {
errors.push(format!("org.toml [memory]: {e}"));
MemoryPolicy::default()
});
let default_context = context_policy(org.get("context"), None).unwrap_or_else(|e| {
errors.push(format!("org.toml [context]: {e}"));
ContextPolicy::default()
});
let default_storage = storage_policy(&org, None).unwrap_or_else(|e| {
errors.push(format!("org.toml [knowledge]: {e}"));
StoragePolicy::default()
});
let mut imp_memory = std::collections::HashMap::new();
let mut imp_context = std::collections::HashMap::new();
let mut imp_storage = std::collections::HashMap::new();
for g in array(&org, "grant") {
rules.push(with_scope(g, "org"));
}
for a in array(&org, "action") {
actions.push(with_scope(a, "org"));
}
for t in array(&org, "trust") {
trust.push(with_scope(t, "org"));
}
let org_budget = org.get("budget");
for l in org_budget.map(|b| array(b, "limit")).unwrap_or_default() {
limits.push(with_scope(l, "org"));
}
for e in array(&org, "expose") {
parse_expose(e, "org", "org.toml", &mut exposes, &mut errors);
}
let engine_dir = org
.get("engine")
.and_then(|e| e.get("dir"))
.and_then(|v| v.as_str())
.map(PathBuf::from);
let imps_dir = paths::imps_dir();
if imps_dir.is_dir() {
let mut names: Vec<String> = std::fs::read_dir(&imps_dir)
.into_iter()
.flatten()
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
names.sort();
for name in names {
let spec = imps_dir.join(&name).join("imp.toml");
if !spec.exists() {
continue;
}
let w = match read_toml(&spec) {
Ok(v) => v,
Err(e) => {
errors.push(format!("{}: {e}", spec.display()));
continue;
}
};
let declared = w.get("name").and_then(|v| v.as_str());
if declared != Some(name.as_str()) {
errors.push(format!(
"{}: name {declared:?} != folder \"{name}\"",
spec.display()
));
continue;
}
let scope = format!("org/{name}");
imps.push(name.clone());
match memory_policy(w.get("memory"), Some(&default_memory)) {
Ok(p) => {
imp_memory.insert(name.clone(), p);
}
Err(e) => errors.push(format!("{name} [memory]: {e}")),
}
match context_policy(w.get("context"), Some(&default_context)) {
Ok(p) => {
imp_context.insert(name.clone(), p);
}
Err(e) => errors.push(format!("{name} [context]: {e}")),
}
match storage_policy(&w, Some(&default_storage)) {
Ok(storage) => {
match crate::imp::storage::validate_imp_overlay(&default_storage, &storage) {
Ok(()) => {
imp_storage.insert(name.clone(), storage);
}
Err(e) => errors.push(format!("{name} [knowledge]: {e}")),
}
}
Err(e) => errors.push(format!("{name} [knowledge]: {e}")),
}
for g in array(&w, "grant") {
rules.push(with_scope(g, &scope));
}
for a in array(&w, "action") {
actions.push(with_scope(a, &scope));
}
for t in array(&w, "trust") {
trust.push(with_scope(t, &scope));
}
for tr in array(&w, "trigger") {
let mut j = to_json(tr);
if let Some(obj) = j.as_object_mut() {
obj.insert("imp".to_string(), json!(name));
}
triggers.push(j);
}
if let Some(b) = w.get("budget") {
for l in array(b, "limit") {
limits.push(with_scope(l, &scope));
}
}
for e in array(&w, "expose") {
parse_expose(e, &scope, &name, &mut exposes, &mut errors);
}
for platform in ["discord", "slack"] {
if let Some(credential) = w
.get("channels")
.and_then(|c| c.get(platform))
.and_then(|v| v.as_str())
{
if let Some((taken, _, _)) = listeners.iter().find(|(_, _, c)| c == credential)
{
errors.push(format!(
"imps {taken} and {name} both listen with credential \"{credential}\" — one bot cannot serve two listeners"
));
} else {
listeners.push((
name.clone(),
platform.to_string(),
credential.to_string(),
));
}
}
}
}
}
let mut warnings: Vec<String> = Vec::new();
let mut connections: Vec<Connection> = Vec::new();
let mut connection_rules: Vec<Value> = Vec::new();
let registry = crate::credential::registry::registry_json();
let mut connection_files: Vec<PathBuf> = std::fs::read_dir(paths::connections_dir())
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|x| x.to_str()) == Some("toml"))
.collect();
connection_files.sort();
for path in connection_files {
let name = path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
let v = match read_toml(&path) {
Ok(v) => v,
Err(e) => {
errors.push(format!("{}: {e}", path.display()));
continue;
}
};
match compile_connection(
&name,
&v,
&imps,
|p| registry.contains_key(p),
|c| crate::credential::vault::get_credential(c).is_some(),
) {
Ok((connection, rules, connection_exposes, warning)) => {
connection_rules.extend(rules);
exposes.extend(connection_exposes);
warnings.extend(warning);
connections.push(connection);
}
Err(mut e) => errors.append(&mut e),
}
}
connection_rules.extend(rules);
let rules = connection_rules;
let policy = parse::<Policy>(&mut errors, "policy (grants)", json!({ "rules": rules }));
let budget = parse::<BudgetPolicy>(
&mut errors,
"budget",
json!({
"scope": "org",
"currencies": org_budget.and_then(|b| b.get("currencies")).map(to_json).unwrap_or(json!([])),
"vars": org_budget.and_then(|b| b.get("vars")).map(to_json).unwrap_or(json!({})),
"meters": org_budget.map(|b| array(b, "meter")).unwrap_or_default().iter().map(|m| to_json(m)).collect::<Vec<_>>(),
"limits": limits,
}),
);
let actions = parse::<ActionPolicy>(
&mut errors,
"actions/trust",
json!({ "actions": actions, "trust": trust }),
);
validate_exposes(
&exposes,
|name| crate::credential::vault::get_credential(name).is_some(),
&mut errors,
);
if !errors.is_empty() {
return Err(errors);
}
Ok(Loaded {
policy,
budget,
actions,
triggers,
memory: CompiledMemoryPolicy {
default: default_memory,
imps: imp_memory,
},
context: CompiledContextPolicy {
default: default_context,
imps: imp_context,
},
storage: CompiledStoragePolicy {
default: default_storage,
imps: imp_storage,
},
listeners,
exposes,
connections,
warnings,
imps,
engine_dir,
})
}
type CompiledConnection = (Connection, Vec<Value>, Vec<Expose>, Option<String>);
fn compile_connection(
name: &str,
v: &toml::Value,
known_imps: &[String],
provider_exists: impl Fn(&str) -> bool,
secret_exists: impl Fn(&str) -> bool,
) -> Result<CompiledConnection, Vec<String>> {
let mut errors = Vec::new();
let ctx = format!("connection \"{name}\"");
let provider = v
.get("provider")
.and_then(|x| x.as_str())
.unwrap_or_default()
.to_string();
if provider.is_empty() {
errors.push(format!("{ctx}: needs provider = \"<registry name>\""));
} else if !provider_exists(&provider) {
errors.push(format!(
"{ctx}: unknown provider \"{provider}\" (declare it in providers.toml)"
));
}
let strings = |key: &str| -> Option<Vec<String>> {
v.get(key).and_then(|x| x.as_array()).map(|a| {
a.iter()
.filter_map(|s| s.as_str())
.map(str::to_string)
.collect()
})
};
let org_scoped = v.get("scope").and_then(|x| x.as_str()) == Some("org");
let imps = strings("imps");
match (&imps, org_scoped) {
(Some(_), true) => errors.push(format!(
"{ctx}: choose imps = [..] OR scope = \"org\", not both"
)),
(None, false) => errors.push(format!(
"{ctx}: needs imps = [\"<name>\", ..] or scope = \"org\""
)),
(Some(list), false) => {
for w in list {
if !known_imps.contains(w) {
errors.push(format!("{ctx}: no such imp \"{w}\""));
}
}
if list.is_empty() {
errors.push(format!(
"{ctx}: imps = [] grants nothing — use scope = \"org\" or name imps"
));
}
}
(None, true) => {}
}
let hosts = strings("hosts").unwrap_or_default();
if hosts.is_empty() {
errors.push(format!("{ctx}: needs hosts = [\"api.example.com\", ..]"));
}
let methods = strings("methods").unwrap_or_else(|| vec!["GET".into()]);
let env = v
.get("env")
.and_then(|x| x.as_str())
.unwrap_or_default()
.to_string();
if env.is_empty() {
errors.push(format!("{ctx}: needs env = \"<VAR the box sees>\""));
}
if !errors.is_empty() {
return Err(errors);
}
let enabled = secret_exists(name);
let connection = Connection {
name: name.to_string(),
provider,
imps: imps.clone(),
hosts: hosts.clone(),
methods: methods.clone(),
env: env.clone(),
enabled,
};
if !enabled {
let fix = if name == connection.provider {
format!("impyard server connect {name}")
} else {
format!("impyard server connect {} --as {name}", connection.provider)
};
let warning =
format!("{ctx} is disabled — no \"{name}\" credential in the vault (run: {fix})");
return Ok((connection, Vec::new(), Vec::new(), Some(warning)));
}
let scopes: Vec<String> = match &imps {
Some(list) => list.iter().map(|w| format!("org/{w}")).collect(),
None => vec!["org".to_string()],
};
let rules = scopes
.iter()
.map(|scope| {
json!({
"scope": scope,
"name": format!("connection:{name}"),
"match": { "host": hosts, "port": 443, "method": methods },
"verdict": "allow",
"inject": { "credential": name },
})
})
.collect();
let exposes = scopes
.into_iter()
.map(|scope| Expose {
scope,
credential: name.to_string(),
env: env.clone(),
})
.collect();
Ok((connection, rules, exposes, None))
}
const RESERVED_ENV: &[&str] = &[
"HOME",
"TMPDIR",
"PI_CODING_AGENT_DIR",
"ANTHROPIC_API_KEY",
"HTTP_PROXY",
"HTTPS_PROXY",
"NO_PROXY",
"NODE_USE_ENV_PROXY",
"NODE_EXTRA_CA_CERTS",
"SSL_CERT_FILE",
"CURL_CA_BUNDLE",
"REQUESTS_CA_BUNDLE",
"GIT_SSL_CAINFO",
"PIP_CERT",
];
fn parse_expose(
v: &toml::Value,
scope: &str,
source: &str,
exposes: &mut Vec<Expose>,
errors: &mut Vec<String>,
) {
let field = |k: &str| v.get(k).and_then(|x| x.as_str()).map(str::to_string);
match (field("credential"), field("env")) {
(Some(credential), Some(env)) => exposes.push(Expose {
scope: scope.to_string(),
credential,
env,
}),
_ => errors.push(format!(
"{source} [[expose]]: needs string fields \"credential\" and \"env\""
)),
}
}
fn validate_exposes(
exposes: &[Expose],
credential_exists: impl Fn(&str) -> bool,
errors: &mut Vec<String>,
) {
for (i, e) in exposes.iter().enumerate() {
let well_formed = !e.env.is_empty()
&& !e.env.as_bytes()[0].is_ascii_digit()
&& e.env
.bytes()
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_');
if !well_formed {
errors.push(format!(
"[[expose]] env \"{}\": use UPPER_SNAKE_CASE",
e.env
));
}
if RESERVED_ENV.contains(&e.env.as_str()) || e.env.starts_with("IMPYARD_") {
errors.push(format!(
"[[expose]] env \"{}\" is reserved box wiring",
e.env
));
}
if !credential_exists(&e.credential) {
errors.push(format!(
"[[expose]] {}: no \"{}\" credential in the vault — run: impyard server vault connect",
e.env, e.credential
));
}
for other in &exposes[i + 1..] {
let overlap = e.scope == other.scope || e.scope == "org" || other.scope == "org";
if e.env == other.env && overlap {
errors.push(format!(
"[[expose]] env \"{}\" claimed twice for overlapping scopes {} and {}",
e.env, e.scope, other.scope
));
}
}
}
}
pub fn snapshot() -> Result<Arc<Loaded>, String> {
type Cached = Mutex<Option<(String, Arc<Loaded>)>>;
static CACHE: OnceLock<Cached> = OnceLock::new();
let cache = CACHE.get_or_init(|| Mutex::new(None));
let fp = fingerprint();
{
let cached = cache.lock().unwrap();
if let Some((cached_fp, loaded)) = cached.as_ref() {
if *cached_fp == fp {
return Ok(loaded.clone());
}
}
}
match load() {
Ok(loaded) => {
let loaded = Arc::new(loaded);
*cache.lock().unwrap() = Some((fp, loaded.clone()));
Ok(loaded)
}
Err(errors) => Err(errors.join("\n")),
}
}
fn fingerprint() -> String {
fn stamp(path: &std::path::Path) -> String {
std::fs::metadata(path)
.map(|m| {
let mtime = m
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("{}:{mtime}:{}", path.display(), m.len())
})
.unwrap_or_else(|_| format!("{}:absent", path.display()))
}
let mut parts = vec![stamp(&paths::org_file())];
let mut names: Vec<PathBuf> = std::fs::read_dir(paths::imps_dir())
.into_iter()
.flatten()
.flatten()
.map(|e| e.path().join("imp.toml"))
.collect();
names.sort();
for spec in names {
parts.push(stamp(&spec));
}
let mut connections: Vec<PathBuf> = std::fs::read_dir(paths::connections_dir())
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.collect();
connections.sort();
for c in connections {
parts.push(stamp(&c));
}
parts.push(stamp(&crate::paths::vault_dir()));
parts.join("|")
}
fn parse<T: serde::de::DeserializeOwned + Default>(
errors: &mut Vec<String>,
what: &str,
v: Value,
) -> T {
match serde_json::from_value::<T>(v) {
Ok(t) => t,
Err(e) => {
errors.push(format!("{what}: {e}"));
T::default()
}
}
}
type BErr = Box<dyn std::error::Error>;
fn context_policy(
value: Option<&toml::Value>,
base: Option<&ContextPolicy>,
) -> Result<ContextPolicy, BErr> {
let mut merged = serde_json::to_value(base.cloned().unwrap_or_default())?;
if let Some(value) = value {
merge_json(&mut merged, to_json(value));
}
serde_json::from_value(merged).map_err(|e| format!("context policy is invalid: {e}").into())
}
fn memory_policy(
value: Option<&toml::Value>,
base: Option<&MemoryPolicy>,
) -> Result<MemoryPolicy, BErr> {
let mut merged = serde_json::to_value(base.cloned().unwrap_or_default())?;
if let Some(value) = value {
merge_json(&mut merged, to_json(value));
}
let policy: MemoryPolicy =
serde_json::from_value(merged).map_err(|e| format!("memory policy is invalid: {e}"))?;
if let Some(kind) = policy
.allowed_kinds
.iter()
.find(|kind| !crate::imp::memory::SUPPORTED_MEMORY_KINDS.contains(&kind.as_str()))
{
return Err(format!(
"memory policy kind \"{kind}\" is not interaction memory; supported kinds are {}",
crate::imp::memory::SUPPORTED_MEMORY_KINDS.join(", ")
)
.into());
}
Ok(policy)
}
fn storage_policy(
value: &toml::Value,
base: Option<&StoragePolicy>,
) -> Result<StoragePolicy, BErr> {
let mut merged = serde_json::to_value(base.cloned().unwrap_or_default())?;
let overlay = json!({
"knowledge": value.get("knowledge").map(to_json).unwrap_or(json!({})),
});
merge_json(&mut merged, overlay);
let policy: StoragePolicy = serde_json::from_value(merged)
.map_err(|error| format!("storage policy is invalid: {error}"))?;
crate::imp::storage::validate(&policy)
.map_err(|error| format!("storage policy is invalid: {error}"))?;
Ok(policy)
}
fn merge_json(base: &mut Value, overlay: Value) {
match (base, overlay) {
(Value::Object(base), Value::Object(overlay)) => {
for (key, value) in overlay {
match base.get_mut(&key) {
Some(existing) => merge_json(existing, value),
None => {
base.insert(key, value);
}
}
}
}
(base, overlay) => *base = overlay,
}
}
fn read_toml(path: &std::path::Path) -> Result<toml::Value, BErr> {
if !path.exists() {
return Ok(toml::Value::Table(Default::default()));
}
Ok(toml::from_str(&std::fs::read_to_string(path)?)?)
}
fn array<'a>(v: &'a toml::Value, key: &str) -> Vec<&'a toml::Value> {
v.get(key)
.and_then(|x| x.as_array())
.map(|a| a.iter().collect())
.unwrap_or_default()
}
fn to_json(v: &toml::Value) -> Value {
serde_json::to_value(v).unwrap_or(Value::Null)
}
fn with_scope(v: &toml::Value, scope: &str) -> Value {
let mut j = to_json(v);
if let Some(obj) = j.as_object_mut() {
obj.insert("scope".to_string(), json!(scope));
}
j
}
#[cfg(test)]
mod tests {
use super::*;
fn expose(scope: &str, credential: &str, env: &str) -> Expose {
Expose {
scope: scope.into(),
credential: credential.into(),
env: env.into(),
}
}
fn toml(s: &str) -> toml::Value {
toml::from_str(s).unwrap()
}
#[test]
fn connection_compiles_per_imp_grants_and_exposes() {
let v = toml(
r#"
provider = "github"
imps = ["yuko", "kdemo"]
hosts = ["api.github.com"]
env = "GH_TOKEN"
"#,
);
let imps = vec!["yuko".to_string(), "kdemo".to_string()];
let (c, rules, exposes, warning) =
compile_connection("github", &v, &imps, |_| true, |_| true).unwrap();
assert!(c.enabled);
assert_eq!(c.methods, vec!["GET"]); assert_eq!(rules.len(), 2);
assert_eq!(rules[0]["scope"], "org/yuko");
assert_eq!(rules[0]["name"], "connection:github");
assert_eq!(rules[0]["match"]["host"][0], "api.github.com");
assert_eq!(rules[0]["inject"]["credential"], "github");
assert_eq!(exposes.len(), 2);
assert_eq!(exposes[1].scope, "org/kdemo");
assert_eq!(exposes[1].env, "GH_TOKEN");
assert!(warning.is_none());
}
#[test]
fn connection_without_secret_is_disabled_not_broken() {
let v = toml(
r#"
provider = "github"
scope = "org"
hosts = ["api.github.com"]
env = "GH_TOKEN"
"#,
);
let (c, rules, exposes, warning) =
compile_connection("github", &v, &[], |_| true, |_| false).unwrap();
assert!(!c.enabled);
assert!(rules.is_empty() && exposes.is_empty());
assert!(warning.unwrap().contains("disabled"));
}
#[test]
fn connection_validation_catches_each_failure_mode() {
let v = toml(
r#"
provider = "nope"
imps = ["ghost"]
env = ""
"#,
);
let errors = compile_connection(
"acme",
&v,
&["yuko".to_string()],
|p| p == "github",
|_| true,
)
.unwrap_err();
assert!(errors.iter().any(|e| e.contains("unknown provider")));
assert!(errors.iter().any(|e| e.contains("no such imp \"ghost\"")));
assert!(errors.iter().any(|e| e.contains("needs hosts")));
assert!(errors.iter().any(|e| e.contains("needs env")));
}
#[test]
fn expose_validation_catches_each_failure_mode() {
let vault = |name: &str| name == "github";
let mut errors = Vec::new();
let ok = [
expose("org/a", "github", "GH_TOKEN"),
expose("org/b", "github", "GH_TOKEN"),
];
validate_exposes(&ok, vault, &mut errors);
assert!(errors.is_empty(), "{errors:?}");
let mut errors = Vec::new();
let bad = [
expose("org", "github", "HTTP_PROXY"),
expose("org", "github", "IMPYARD_X"),
expose("org", "github", "lower"),
expose("org", "nope", "A_TOKEN"),
expose("org", "github", "B_TOKEN"),
expose("org/a", "github", "B_TOKEN"),
];
validate_exposes(&bad, vault, &mut errors);
assert_eq!(errors.len(), 5, "{errors:?}");
assert!(errors
.iter()
.any(|e| e.contains("reserved") && e.contains("HTTP_PROXY")));
assert!(errors
.iter()
.any(|e| e.contains("reserved") && e.contains("IMPYARD_X")));
assert!(errors.iter().any(|e| e.contains("UPPER_SNAKE_CASE")));
assert!(errors.iter().any(|e| e.contains("no \"nope\" credential")));
assert!(errors.iter().any(|e| e.contains("claimed twice")));
}
}