Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ rmcp = { workspace = true, features = [
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
serde_yaml = { workspace = true }
sha2 = "0.10"
tokio = { workspace = true, features = ["full"] }
tracing = { workspace = true }
tracing-subscriber = { workspace = true, features = ["env-filter"] }
Expand Down
1 change: 1 addition & 0 deletions changelog.d/567.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Implemented headless mode policy enforcement and runtime permission handling.
2 changes: 1 addition & 1 deletion crates/component2json/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1018,7 +1018,7 @@ mod tests {

use super::*;

fn result_schema<'a>(schema: &'a Value) -> &'a Value {
fn result_schema(schema: &Value) -> &Value {
schema
.get("properties")
.and_then(|props| props.get("result"))
Expand Down
2 changes: 2 additions & 0 deletions crates/wassette/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ license.workspace = true

[dependencies]
anyhow = { workspace = true }
clap = { version = "4.5", features = ["derive"] }
component2json = { path = "../component2json" }
etcetera = { workspace = true }
futures = { workspace = true }
Expand All @@ -22,6 +23,7 @@ serde_json = { workspace = true }
sha2 = "0.10"
serde_yaml = { workspace = true }
tempfile = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["full", "test-util"] }
tokio-util = { workspace = true, features = ["io"] }
tracing = { workspace = true, features = ["attributes"] }
Expand Down
30 changes: 30 additions & 0 deletions crates/wassette/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,24 @@ use std::path::{Path, PathBuf};
use std::time::Duration;

use anyhow::{Context, Result};
use clap::ValueEnum;
use serde::{Deserialize, Serialize};

use crate::{
get_default_secrets_dir, LifecycleManager, DEFAULT_HTTP_TIMEOUT_SECS, DEFAULT_OCI_TIMEOUT_SECS,
};

/// Deployment mode for the MCP server
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ValueEnum)]
#[serde(rename_all = "lowercase")]
pub enum DeploymentProfile {
/// Interactive mode: permits runtime grants, interactive component loading
#[default]
Interactive,
/// Headless mode: declarative-only, fails fast on missing configuration
Headless,
}

/// Fully-specified configuration for constructing a [`LifecycleManager`].
#[derive(Clone)]
pub struct LifecycleConfig {
Expand All @@ -23,6 +36,7 @@ pub struct LifecycleConfig {
http_client: reqwest::Client,
oci_client: oci_client::Client,
eager_load: bool,
profile: DeploymentProfile,
}

impl LifecycleConfig {
Expand Down Expand Up @@ -56,6 +70,11 @@ impl LifecycleConfig {
self.eager_load
}

/// Get the deployment profile (interactive or headless).
pub fn profile(&self) -> &DeploymentProfile {
&self.profile
}

pub(crate) fn into_parts(
self,
) -> (
Expand All @@ -65,6 +84,7 @@ impl LifecycleConfig {
reqwest::Client,
oci_client::Client,
bool,
DeploymentProfile,
) {
(
self.component_dir,
Expand All @@ -73,6 +93,7 @@ impl LifecycleConfig {
self.http_client,
self.oci_client,
self.eager_load,
self.profile,
)
}
}
Expand All @@ -86,6 +107,7 @@ pub struct LifecycleBuilder {
http_client: Option<reqwest::Client>,
oci_client: Option<oci_client::Client>,
eager_load: bool,
profile: DeploymentProfile,
}

impl LifecycleBuilder {
Expand All @@ -99,6 +121,7 @@ impl LifecycleBuilder {
http_client: None,
oci_client: None,
eager_load: true,
profile: DeploymentProfile::default(),
}
}

Expand Down Expand Up @@ -142,6 +165,12 @@ impl LifecycleBuilder {
self
}

/// Set the deployment profile (interactive or headless).
pub fn with_profile(mut self, profile: DeploymentProfile) -> Self {
self.profile = profile;
self
}

/// Produce a validated [`LifecycleConfig`] without constructing a manager.
pub fn build_config(self) -> Result<LifecycleConfig> {
let component_dir = match self.component_dir.canonicalize() {
Expand All @@ -168,6 +197,7 @@ impl LifecycleBuilder {
http_client,
oci_client,
eager_load: self.eager_load,
profile: self.profile,
})
}

Expand Down
29 changes: 26 additions & 3 deletions crates/wassette/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,14 @@ mod secrets;
mod wasistate;

use component_storage::ComponentStorage;
pub use config::{LifecycleBuilder, LifecycleConfig};
pub use config::{DeploymentProfile, LifecycleBuilder, LifecycleConfig};
pub use http::WassetteWasiState;
use loader::{ComponentResource, DownloadedResource};
use policy_internal::PolicyManager;
pub use policy_internal::{PermissionGrantRequest, PermissionRule, PolicyInfo};
pub use policy_internal::{
HeadlessPolicyBackend, InteractivePolicyBackend, PermissionGrantRequest, PermissionRule,
PolicyBackend, PolicyInfo,
};
use runtime_context::RuntimeContext;
pub use secrets::SecretsManager;
use wasistate::WasiState;
Expand Down Expand Up @@ -304,6 +307,7 @@ pub struct LifecycleManager {
oci_client: Arc<oci_wasm::WasmClient>,
http_client: reqwest::Client,
secrets_manager: Arc<SecretsManager>,
profile: DeploymentProfile,
}

/// A representation of a loaded component instance. It contains both the base component info and a
Expand Down Expand Up @@ -340,7 +344,7 @@ impl LifecycleManager {
/// Construct a lifecycle manager from an explicit configuration without loading components.
#[instrument(skip_all, fields(component_dir = %config.component_dir().display()))]
pub async fn from_config(config: LifecycleConfig) -> Result<Self> {
let (component_dir, secrets_dir, environment_vars, http_client, oci_client, _) =
let (component_dir, secrets_dir, environment_vars, http_client, oci_client, _, profile) =
config.into_parts();

let storage =
Expand All @@ -354,12 +358,19 @@ impl LifecycleManager {
let environment_vars = Arc::new(environment_vars);
let oci_client = Arc::new(oci_wasm::WasmClient::new(oci_client));

// Create the appropriate policy backend based on profile
let policy_backend: Arc<dyn policy_internal::PolicyBackend> = match profile {
DeploymentProfile::Interactive => Arc::new(policy_internal::InteractivePolicyBackend),
DeploymentProfile::Headless => Arc::new(policy_internal::HeadlessPolicyBackend),
};

let policy_manager = PolicyManager::new(
storage.clone(),
Arc::clone(&secrets_manager),
Arc::clone(&environment_vars),
Arc::clone(&oci_client),
http_client.clone(),
policy_backend,
);

Ok(Self {
Expand All @@ -370,6 +381,7 @@ impl LifecycleManager {
oci_client,
http_client,
secrets_manager,
profile,
})
}

Expand Down Expand Up @@ -423,6 +435,12 @@ impl LifecycleManager {
self.policy_manager.restore_from_disk(component_id).await
}

/// Apply policy to a component by loading it from the co-located policy file
/// This is useful after provisioning when the policy file is created after component loading
pub async fn apply_policy_to_component(&self, component_id: &str) -> Result<()> {
self.restore_policy_attachment(component_id).await
}

async fn resolve_component_resource(&self, uri: &str) -> Result<(String, DownloadedResource)> {
// Show progress when running in CLI mode (stderr is a TTY)
let show_progress = std::io::stderr().is_terminal();
Expand Down Expand Up @@ -1300,6 +1318,11 @@ async fn load_components_parallel(
}

impl LifecycleManager {
/// Get the deployment profile
pub fn profile(&self) -> &DeploymentProfile {
&self.profile
}

/// Get the secrets manager
pub fn secrets_manager(&self) -> &SecretsManager {
&self.secrets_manager
Expand Down
68 changes: 68 additions & 0 deletions crates/wassette/src/policy_internal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,66 @@ use crate::component_storage::ComponentStorage;
use crate::loader::{self, PolicyResource};
use crate::{SecretsManager, WasiStateTemplate};

/// Policy backend error types
#[derive(Debug, thiserror::Error)]
pub enum PolicyError {
/// Runtime permission grants are not allowed in headless mode
#[error("Runtime permission grants are disabled in headless mode. To grant {permission_type} permission to component '{component_id}', update the provisioning manifest.")]
HeadlessGrantDenied {
component_id: String,
permission_type: String,
},

/// Component not found
#[error("Component '{0}' not found")]
ComponentNotFound(String),

/// Other policy errors
#[error("Policy error: {0}")]
Other(#[from] anyhow::Error),
}

/// Policy backend determines whether runtime grants are permitted
pub trait PolicyBackend: Send + Sync {
/// Check if a runtime permission grant is allowed
fn can_grant_runtime(
&self,
component_id: &str,
permission_type: &str,
) -> Result<(), PolicyError>;
}

/// Interactive mode backend: permits all runtime grants
pub struct InteractivePolicyBackend;

impl PolicyBackend for InteractivePolicyBackend {
fn can_grant_runtime(
&self,
_component_id: &str,
_permission_type: &str,
) -> Result<(), PolicyError> {
// Interactive mode allows all runtime grants
Ok(())
}
}

/// Headless mode backend: denies all runtime grants
pub struct HeadlessPolicyBackend;

impl PolicyBackend for HeadlessPolicyBackend {
fn can_grant_runtime(
&self,
component_id: &str,
permission_type: &str,
) -> Result<(), PolicyError> {
// Headless mode rejects runtime grants with helpful error
Err(PolicyError::HeadlessGrantDenied {
component_id: component_id.to_string(),
permission_type: permission_type.to_string(),
})
}
}

/// Granular permission rule types
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PermissionRule {
Expand Down Expand Up @@ -65,6 +125,7 @@ pub(crate) struct PolicyManager {
environment_vars: Arc<HashMap<String, String>>,
oci_client: Arc<WasmClient>,
http_client: Client,
backend: Arc<dyn PolicyBackend>,
}

/// Information about a policy attached to a component
Expand All @@ -89,6 +150,7 @@ impl PolicyManager {
environment_vars: Arc<HashMap<String, String>>,
oci_client: Arc<WasmClient>,
http_client: Client,
backend: Arc<dyn PolicyBackend>,
) -> Self {
Self {
registry: Arc::new(RwLock::new(PolicyRegistry::default())),
Expand All @@ -97,6 +159,7 @@ impl PolicyManager {
environment_vars,
oci_client,
http_client,
backend,
}
}

Expand Down Expand Up @@ -341,6 +404,11 @@ impl PolicyManager {
permission_type: &str,
details: &serde_json::Value,
) -> Result<()> {
// Check with policy backend before proceeding
self.backend
.can_grant_runtime(component_id, permission_type)
.map_err(|e| anyhow!("{}", e))?;

info!(
component_id,
permission_type, "Granting permission to component"
Expand Down
1 change: 1 addition & 0 deletions src/cli_handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ pub async fn create_lifecycle_manager(component_dir: Option<PathBuf>) -> Result<
disable_builtin_tools: false,
bind_address: None,
manifest: None,
profile: None,
})
.context("Failed to load configuration")?
};
Expand Down
5 changes: 5 additions & 0 deletions src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,11 @@ pub struct Serve {
#[arg(long)]
#[serde(skip_serializing_if = "Option::is_none")]
pub manifest: Option<PathBuf>,

/// Deployment profile: interactive (default) or headless
#[arg(long, value_enum)]
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<wassette::DeploymentProfile>,
}

#[derive(Args, Debug, Clone, Serialize, Deserialize, Default)]
Expand Down
4 changes: 4 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ mod tests {
disable_builtin_tools: false,
bind_address: None,
manifest: None,
profile: None,
}
}

Expand All @@ -159,6 +160,7 @@ mod tests {
disable_builtin_tools: false,
bind_address: None,
manifest: None,
profile: None,
}
}

Expand Down Expand Up @@ -371,6 +373,7 @@ bind_address = "0.0.0.0:8080"
disable_builtin_tools: false,
bind_address: Some("192.168.1.100:9090".to_string()),
manifest: None,
profile: None,
};

let config =
Expand Down Expand Up @@ -415,6 +418,7 @@ bind_address = "0.0.0.0:8080"
disable_builtin_tools: false,
bind_address: Some("192.168.1.100:9090".to_string()),
manifest: None,
profile: None,
};

let config = Config::new_from_path(&serve_config, &config_file)
Expand Down
Loading