pub mod hooks;
pub mod policy;
pub mod stdlib;
use std::cell::Cell;
thread_local! {
static IN_BUND_EVAL: Cell<bool> = const { Cell::new(false) };
static ACTIVE_APP: Cell<*mut crate::tui::app::App> = const {
Cell::new(std::ptr::null_mut())
};
}
pub(crate) fn is_in_eval() -> bool {
IN_BUND_EVAL.with(|c| c.get())
}
pub(crate) fn with_active_app<F, R>(f: F) -> Option<R>
where
F: FnOnce(&mut crate::tui::app::App) -> R,
{
let ptr = ACTIVE_APP.with(|c| c.get());
if ptr.is_null() {
return None;
}
Some(f(unsafe { &mut *ptr }))
}
pub(crate) struct AppGuard {
prev: *mut crate::tui::app::App,
}
impl AppGuard {
pub(crate) fn enter(app: &mut crate::tui::app::App) -> Self {
let new: *mut crate::tui::app::App = app;
let prev = ACTIVE_APP.with(|c| c.replace(new));
Self { prev }
}
}
impl Drop for AppGuard {
fn drop(&mut self) {
ACTIVE_APP.with(|c| c.set(self.prev));
}
}
struct EvalGuard {
prev: bool,
}
impl EvalGuard {
fn enter() -> Self {
let prev = IN_BUND_EVAL.with(|c| c.replace(true));
Self { prev }
}
}
impl Drop for EvalGuard {
fn drop(&mut self) {
IN_BUND_EVAL.with(|c| c.set(self.prev));
}
}
use anyhow::{anyhow, Result};
use bundcore::bundcore::Bund;
use parking_lot::RwLock;
use rust_dynamic::value::Value;
use std::sync::OnceLock;
use crate::config::Config;
use crate::store::Store;
use policy::Policy;
static ADAM: OnceLock<RwLock<Bund>> = OnceLock::new();
static ACTIVE_STORE: OnceLock<Store> = OnceLock::new();
static ACTIVE_CONFIG: OnceLock<Config> = OnceLock::new();
static POLICY: OnceLock<Policy> = OnceLock::new();
pub fn init_adam() -> Result<()> {
if ADAM.get().is_some() {
return Ok(());
}
let mut bund = Bund::new();
stdlib::register_ink_stdlib(&mut bund.vm)
.map_err(|e| anyhow!("register ink stdlib: {e}"))?;
let p = POLICY.get().cloned().unwrap_or_default();
if !p.is_open() {
policy::apply_policy(&mut bund.vm, &p)
.map_err(|e| anyhow!("apply policy: {e}"))?;
}
if !p.bootstrap.trim().is_empty() {
if let Err(e) = bund.eval(p.bootstrap.clone()) {
tracing::warn!(
target: "inkhaven::scripting",
"bootstrap script failed: {}",
e
);
}
}
load_store_scripts(&mut bund);
let _ = ADAM.set(RwLock::new(bund));
Ok(())
}
fn load_store_scripts(bund: &mut Bund) {
let Some(store) = active_store() else { return };
let hierarchy = match crate::store::hierarchy::Hierarchy::load(store) {
Ok(h) => h,
Err(e) => {
tracing::warn!(
target: "inkhaven::scripting",
"load_store_scripts: hierarchy load failed: {}",
e
);
return;
}
};
let script_count = hierarchy
.iter()
.filter(|n| n.kind == crate::store::NodeKind::Script)
.count();
if script_count == 0 {
return;
}
let policy = POLICY.get().cloned().unwrap_or_default();
let decision = trust_decision(&policy, store.project_root());
match decision {
TrustOutcome::Deny => {
tracing::warn!(
target: "inkhaven::security",
"{} script(s) NOT auto-loaded — scripting.trust_decision = \"deny\"",
script_count,
);
return;
}
TrustOutcome::PendingOptIn => {
tracing::warn!(
target: "inkhaven::security",
"{} script(s) pending opt-in. Create `<project>/.inkhaven/trust` \
(with the marker line `trust`) to enable, or set \
`scripting.trust_decision: \"trust\"` in inkhaven.hjson if you \
authored / audited these scripts.",
script_count,
);
return;
}
TrustOutcome::Trusted => {
tracing::info!(
target: "inkhaven::security",
"{} script(s) trusted — loading.",
script_count,
);
}
}
for node in hierarchy.iter() {
if node.kind != crate::store::NodeKind::Script {
continue;
}
let bytes = match store.get_content(node.id) {
Ok(Some(b)) => b,
Ok(None) => continue,
Err(e) => {
tracing::warn!(
target: "inkhaven::scripting",
"script {} read failed: {}",
node.id,
e
);
continue;
}
};
let body = String::from_utf8_lossy(&bytes).into_owned();
if body.trim().is_empty() {
continue;
}
if let Err(e) = bund.eval(body) {
tracing::warn!(
target: "inkhaven::scripting",
"script `{}` ({}) eval failed: {}",
node.title,
node.id,
e
);
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TrustOutcome {
Trusted,
PendingOptIn,
Deny,
}
fn trust_decision(policy: &Policy, project_root: &std::path::Path) -> TrustOutcome {
match policy.trust_decision.trim().to_lowercase().as_str() {
"trust" => TrustOutcome::Trusted,
"deny" => TrustOutcome::Deny,
other => {
if !other.is_empty() && other != "ask" {
tracing::warn!(
target: "inkhaven::security",
"unknown scripting.trust_decision `{other}` — treating as `ask`",
);
}
if read_trust_file(project_root) {
TrustOutcome::Trusted
} else {
TrustOutcome::PendingOptIn
}
}
}
}
fn read_trust_file(project_root: &std::path::Path) -> bool {
let path = project_root.join(".inkhaven").join("trust");
let Ok(body) = std::fs::read_to_string(&path) else {
return false;
};
body.lines()
.any(|line| line.trim().eq_ignore_ascii_case("trust"))
}
#[cfg(test)]
mod trust_tests {
use super::*;
fn temp_dir(label: &str) -> std::path::PathBuf {
let p = std::env::temp_dir().join(format!(
"inkhaven-trust-{}-{}",
label,
std::process::id()
));
std::fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn explicit_trust_in_hjson_bypasses_file() {
let dir = temp_dir("explicit-trust");
let policy = Policy {
trust_decision: "trust".into(),
..Policy::default()
};
assert_eq!(trust_decision(&policy, &dir), TrustOutcome::Trusted);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn explicit_deny_in_hjson_overrides_file() {
let dir = temp_dir("explicit-deny");
std::fs::create_dir_all(dir.join(".inkhaven")).unwrap();
std::fs::write(dir.join(".inkhaven/trust"), b"trust\n").unwrap();
let policy = Policy {
trust_decision: "deny".into(),
..Policy::default()
};
assert_eq!(trust_decision(&policy, &dir), TrustOutcome::Deny);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn ask_without_trust_file_is_pending_opt_in() {
let dir = temp_dir("ask-no-file");
let policy = Policy::default(); assert_eq!(
trust_decision(&policy, &dir),
TrustOutcome::PendingOptIn
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn ask_with_trust_file_marker_is_trusted() {
let dir = temp_dir("ask-with-marker");
std::fs::create_dir_all(dir.join(".inkhaven")).unwrap();
std::fs::write(dir.join(".inkhaven/trust"), b"trust\n").unwrap();
let policy = Policy::default();
assert_eq!(trust_decision(&policy, &dir), TrustOutcome::Trusted);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn trust_file_without_marker_does_not_grant_trust() {
let dir = temp_dir("no-marker");
std::fs::create_dir_all(dir.join(".inkhaven")).unwrap();
std::fs::write(dir.join(".inkhaven/trust"), b"some other content\n").unwrap();
let policy = Policy::default();
assert_eq!(
trust_decision(&policy, &dir),
TrustOutcome::PendingOptIn
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn unrecognised_trust_decision_falls_back_to_ask() {
let dir = temp_dir("unknown");
let policy = Policy {
trust_decision: "yes-please-i-trust-everything".into(),
..Policy::default()
};
assert_eq!(
trust_decision(&policy, &dir),
TrustOutcome::PendingOptIn
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn case_insensitive_marker_match() {
let dir = temp_dir("case");
std::fs::create_dir_all(dir.join(".inkhaven")).unwrap();
std::fs::write(dir.join(".inkhaven/trust"), b" TRUST\n").unwrap();
let policy = Policy::default();
assert_eq!(trust_decision(&policy, &dir), TrustOutcome::Trusted);
let _ = std::fs::remove_dir_all(&dir);
}
}
pub(crate) fn with_adam<F, R>(f: F) -> Option<R>
where
F: FnOnce(&mut Bund) -> R,
{
let adam = ADAM.get()?;
let mut guard = adam.write();
Some(f(&mut guard))
}
pub fn set_policy(policy: Policy) {
let _ = POLICY.set(policy);
}
pub fn active_policy() -> Option<&'static Policy> {
POLICY.get()
}
pub fn register_active_store(store: Store) {
let _ = ACTIVE_STORE.set(store);
}
pub fn active_config() -> Option<&'static Config> {
ACTIVE_CONFIG.get()
}
pub fn register_active_config(cfg: Config) {
let _ = ACTIVE_CONFIG.set(cfg);
}
pub fn configure(policy: Policy, store: Store, cfg: Config) {
set_policy(policy);
register_active_store(store);
register_active_config(cfg);
}
pub fn active_store() -> Option<&'static Store> {
ACTIVE_STORE.get()
}
#[derive(Debug, Default)]
pub struct EvalOutput {
pub stdout: String,
pub top: Option<Value>,
}
pub fn eval(code: &str) -> Result<EvalOutput> {
init_adam()?;
let adam = ADAM.get().ok_or_else(|| anyhow!("Adam VM missing after init"))?;
let _ = stdlib::io::drain_print_buffer();
let _eval_guard = EvalGuard::enter();
let mut guard = adam.write();
guard
.eval(code)
.map_err(|e| anyhow!("bund eval failed: {e}"))?;
let top = guard.vm.stack.pull();
drop(guard);
let stdout = stdlib::io::drain_print_buffer();
Ok(EvalOutput { stdout, top })
}
pub fn format_value(v: &Value) -> String {
if let Ok(s) = v.clone().cast_string() {
return s;
}
if let Ok(i) = v.clone().cast_int() {
return i.to_string();
}
if let Ok(f) = v.clone().cast_float() {
return f.to_string();
}
if let Ok(b) = v.clone().cast_bool() {
return b.to_string();
}
let j = value_to_json(v);
serde_json::to_string_pretty(&j).unwrap_or_else(|_| j.to_string())
}
fn value_to_json(v: &Value) -> serde_json::Value {
if let Ok(s) = v.clone().cast_string() {
return serde_json::Value::String(s);
}
if let Ok(i) = v.clone().cast_int() {
return serde_json::Value::from(i);
}
if let Ok(f) = v.clone().cast_float() {
return serde_json::Value::from(f);
}
if let Ok(b) = v.clone().cast_bool() {
return serde_json::Value::Bool(b);
}
if let Ok(list) = v.clone().cast_list() {
return serde_json::Value::Array(list.iter().map(value_to_json).collect());
}
if let Ok(dict) = v.clone().cast_dict() {
let mut m = serde_json::Map::new();
for (k, val) in dict.iter() {
m.insert(k.clone(), value_to_json(val));
}
return serde_json::Value::Object(m);
}
serde_json::Value::Null
}