astrid-capsule 0.7.0

Core runtime management for User-Space Capsules in Astrid OS
Documentation
//! `astrid:sys@1.0.0` host implementation.
//!
//! `trigger_hook` was removed from the kernel ABI when the
//! `astrid:capsule@0.1.0` world was split into per-domain packages —
//! capsule-to-capsule fan-out lives on the IPC bus
//! (`astrid-bus:hook@1.0.0`), not as a sys host call. Capsules that
//! need to dispatch hooks now publish `hook.trigger.v1` and aggregate
//! responses via subscriptions.

use crate::engine::wasm::bindings::astrid::sys::host::{
    self as sys, CallerContext, CapabilityCheckRequest, CapabilityCheckResponse, ErrorCode,
    LogLevel,
};
use crate::engine::wasm::host::util;
use crate::engine::wasm::host_state::HostState;

/// Cap on a single `random-bytes` request, per the WIT contract.
const RANDOM_BYTES_CAP: u64 = 4096;

/// Cap on a single `sleep-ns` request (60 seconds in nanoseconds).
const SLEEP_NS_CAP: u64 = 60_000_000_000;

impl sys::Host for HostState {
    fn get_config(&mut self, key: String) -> Result<Option<String>, ErrorCode> {
        // Manifest-declared secrets route through the file-per-secret
        // store at invocation time, never through `self.config`. This
        // keeps plaintext secret material off disk and out of long-lived
        // host memory.
        //
        // Lookup precedence: per-invocation principal first, then host-
        // wide fall-through. Scope is operator-decided at
        // `astrid secret set --scope` time (not a manifest declaration),
        // so the kernel-side read path always tries both slots.
        if self.secret_env.contains(&key) {
            let value = resolve_secret(self, &key);
            return Ok(if value.is_empty() { None } else { Some(value) });
        }

        match self.config.get(&key) {
            None => Ok(None),
            Some(serde_json::Value::String(s)) => Ok(Some(s.clone())),
            Some(v) => Ok(Some(serde_json::to_string(v).unwrap_or_default())),
        }
    }

    fn get_caller(&mut self) -> Result<CallerContext, ErrorCode> {
        if let Some(ref msg) = self.caller_context {
            Ok(CallerContext {
                principal: msg.principal.clone(),
                source_id: msg.source_id.to_string(),
                timestamp: msg.timestamp.to_rfc3339(),
            })
        } else {
            Ok(CallerContext {
                principal: None,
                source_id: String::new(),
                timestamp: String::new(),
            })
        }
    }

    fn log(&mut self, level: LogLevel, message: String) {
        let capsule_id = self.capsule_id.as_str().to_owned();
        let log_file = self.effective_capsule_log().cloned();

        let level_str = match level {
            LogLevel::Trace => "TRACE",
            LogLevel::Debug => "DEBUG",
            LogLevel::Info => "INFO",
            LogLevel::Warn => "WARN",
            LogLevel::Error => "ERROR",
        };
        let timestamp = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map_or_else(|_| "0".to_string(), |d| format!("{:.3}", d.as_secs_f64()));

        let wrote_to_file = if let Some(log_file) = log_file {
            use std::io::Write;
            match log_file.lock() {
                Ok(mut f) => {
                    let _ = writeln!(f, "{timestamp} {level_str} [{capsule_id}] {message}");
                    true
                },
                Err(e) => {
                    tracing::warn!(
                        capsule = %capsule_id,
                        error = %e,
                        "capsule log mutex poisoned; falling back to tracing subscriber"
                    );
                    false
                },
            }
        } else {
            false
        };

        if !wrote_to_file {
            match level {
                LogLevel::Trace => tracing::trace!(plugin = %capsule_id, "{message}"),
                LogLevel::Debug => tracing::debug!(plugin = %capsule_id, "{message}"),
                LogLevel::Info => tracing::info!(plugin = %capsule_id, "{message}"),
                LogLevel::Warn => tracing::warn!(plugin = %capsule_id, "{message}"),
                LogLevel::Error => tracing::error!(plugin = %capsule_id, "{message}"),
            }
        }
    }

    fn signal_ready(&mut self) {
        if let Some(tx) = &self.ready_tx {
            let _ = tx.send(true);
            tracing::debug!(capsule = %self.capsule_id, "Capsule signaled ready");
        }
    }

    fn clock_ms(&mut self) -> u64 {
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map_or(0u64, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
    }

    fn clock_monotonic_ns(&mut self) -> u64 {
        // Use std::time::Instant via a process-anchor stored in HostState
        // (initialised at first call so the absolute value monotonically
        // increases from then on; differences are what matters).
        use std::sync::OnceLock;
        use std::time::Instant;
        static ANCHOR: OnceLock<Instant> = OnceLock::new();
        let anchor = *ANCHOR.get_or_init(Instant::now);
        u64::try_from(anchor.elapsed().as_nanos()).unwrap_or(u64::MAX)
    }

    fn sleep_ns(&mut self, duration_ns: u64) -> Result<(), ErrorCode> {
        if duration_ns > SLEEP_NS_CAP {
            return Err(ErrorCode::TooLarge);
        }
        let cancel = self.cancel_token.clone();
        let rt = self.runtime_handle.clone();
        let sem = self.host_semaphore.clone();
        let duration = std::time::Duration::from_nanos(duration_ns);
        let cancelled = util::bounded_block_on(&rt, &sem, async move {
            tokio::select! {
                () = tokio::time::sleep(duration) => false,
                () = cancel.cancelled() => true,
            }
        });
        if cancelled {
            Err(ErrorCode::Cancelled)
        } else {
            Ok(())
        }
    }

    fn random_bytes(&mut self, length: u64) -> Result<Vec<u8>, ErrorCode> {
        if length > RANDOM_BYTES_CAP {
            return Err(ErrorCode::TooLarge);
        }
        let len = usize::try_from(length).map_err(|_| ErrorCode::TooLarge)?;
        use rand::RngCore;
        let mut buf = vec![0u8; len];
        rand::thread_rng().fill_bytes(&mut buf);
        Ok(buf)
    }

    fn check_capsule_capability(
        &mut self,
        request: CapabilityCheckRequest,
    ) -> Result<CapabilityCheckResponse, ErrorCode> {
        let registry = self.capsule_registry.clone();
        let rt_handle = self.runtime_handle.clone();
        let host_semaphore = self.host_semaphore.clone();

        let registry = registry.ok_or(ErrorCode::RegistryUnavailable)?;
        let Ok(source_uuid) = uuid::Uuid::parse_str(&request.source_uuid) else {
            // Fail-closed per the WIT contract.
            return Ok(CapabilityCheckResponse { allowed: false });
        };

        let allowed = util::bounded_block_on(&rt_handle, &host_semaphore, async {
            let reg = registry.read().await;
            let Some(capsule_id) = reg.find_by_uuid(&source_uuid) else {
                return false;
            };
            let Some(capsule) = reg.get(capsule_id) else {
                return false;
            };
            match request.capability.as_str() {
                "allow_prompt_injection" => capsule.manifest().capabilities.allow_prompt_injection,
                _ => false,
            }
        });

        Ok(CapabilityCheckResponse { allowed })
    }
}

/// Resolve a secret-typed env value through the file-per-secret store.
///
/// Precedence (operator-controlled at `astrid secret set --scope` time;
/// the kernel just follows the chain):
///
/// 1. **Per-agent** — `~/.astrid/secrets/<effective_principal>/<capsule>/<key>`.
/// 2. **Host-wide** — `~/.astrid/secrets/__host__/<capsule>/<key>`.
fn resolve_secret(state: &HostState, key: &str) -> String {
    use astrid_storage::{FileSecretStore, SecretStore};

    let capsule = state.capsule_id.as_str();
    let principal = state.effective_principal();

    let Ok(home) = astrid_core::dirs::AstridHome::resolve() else {
        tracing::warn!(
            security_event = true,
            %principal,
            capsule,
            key,
            "AstridHome::resolve failed during secret lookup"
        );
        return String::new();
    };
    let secrets_dir = home.secrets_dir();

    let try_get = |scope: &str| -> Option<String> {
        let store = FileSecretStore::new(secrets_dir.join(scope).join(capsule));
        match store.get(key) {
            Ok(value) => value,
            Err(e) => {
                tracing::warn!(
                    security_event = true,
                    %principal,
                    capsule,
                    key,
                    scope,
                    error = %e,
                    "file secret-store read failed for secret-typed env key"
                );
                None
            },
        }
    };

    if let Some(v) = try_get(principal.as_str()) {
        return v;
    }
    if let Some(v) = try_get("__host__") {
        return v;
    }
    String::new()
}

#[cfg(test)]
mod log_chain_tests {
    use std::sync::Arc;

    use super::*;
    use crate::engine::wasm::bindings::astrid::sys::host::Host as SysHost;
    use crate::engine::wasm::test_fixtures::{minimal_host_state, open_log};

    fn make_host_state() -> crate::engine::wasm::host_state::HostState {
        minimal_host_state(tokio::runtime::Handle::current())
    }

    #[tokio::test]
    async fn log_routes_to_invocation_file_when_installed() {
        let tmp = tempfile::tempdir().unwrap();
        let owner_log_path = tmp.path().join("owner.log");
        let alice_log_path = tmp.path().join("alice.log");
        let owner_log = open_log(&owner_log_path);
        let alice_log = open_log(&alice_log_path);

        let mut state = make_host_state();
        state.capsule_log = Some(owner_log);
        state.invocation_capsule_log = Some(alice_log);

        state.log(LogLevel::Info, "hello from alice".into());

        let alice_contents = std::fs::read_to_string(&alice_log_path).unwrap();
        let owner_contents = std::fs::read_to_string(&owner_log_path).unwrap();
        assert!(alice_contents.contains("hello from alice"));
        assert!(!owner_contents.contains("hello from alice"));
    }

    #[tokio::test]
    async fn log_falls_back_to_load_time_file_when_no_invocation() {
        let tmp = tempfile::tempdir().unwrap();
        let owner_log_path = tmp.path().join("owner.log");
        let owner_log = open_log(&owner_log_path);

        let mut state = make_host_state();
        state.capsule_log = Some(owner_log);

        state.log(LogLevel::Warn, "single-tenant line".into());

        let contents = std::fs::read_to_string(&owner_log_path).unwrap();
        assert!(contents.contains("single-tenant line"));
        assert!(contents.contains("WARN"));
    }

    #[tokio::test]
    async fn log_isolates_writes_across_sequential_invocations() {
        let tmp = tempfile::tempdir().unwrap();
        let alice_path = tmp.path().join("alice.log");
        let bob_path = tmp.path().join("bob.log");

        let mut state = make_host_state();

        state.invocation_capsule_log = Some(open_log(&alice_path));
        state.log(LogLevel::Info, "alice-msg".into());
        state.invocation_capsule_log = None;

        state.invocation_capsule_log = Some(open_log(&bob_path));
        state.log(LogLevel::Info, "bob-msg".into());
        state.invocation_capsule_log = None;

        let alice = std::fs::read_to_string(&alice_path).unwrap();
        let bob = std::fs::read_to_string(&bob_path).unwrap();
        assert!(alice.contains("alice-msg") && !alice.contains("bob-msg"));
        assert!(bob.contains("bob-msg") && !bob.contains("alice-msg"));
    }

    #[tokio::test]
    async fn log_survives_poisoned_mutex_without_dropping_message() {
        let tmp = tempfile::tempdir().unwrap();
        let log_path = tmp.path().join("poisoned.log");
        let log_file = open_log(&log_path);

        let poisoner = Arc::clone(&log_file);
        let _ = std::thread::spawn(move || {
            let _guard = poisoner.lock().unwrap();
            panic!("intentional panic to poison mutex");
        })
        .join();
        assert!(log_file.is_poisoned(), "precondition: mutex is poisoned");

        let mut state = make_host_state();
        state.capsule_log = Some(log_file);

        state.log(LogLevel::Error, "post-poison line".into());
    }
}