Skip to content

Conversation

Copy link
Contributor

Copilot AI commented Nov 16, 2025

Implements IPC-based dynamic secret injection allowing secrets to be provided to running components without server restart. Secrets injected via IPC are immediately available to components with proper precedence over file-based secrets.

Changes

In-memory secret store (secrets.rs)

  • Added RwLock<HashMap<String, HashMap<String, SecretString>>> for runtime secret storage
  • Implemented inject_secret(), remove_memory_secret(), get_all_secrets(), list_all_secrets()
  • Secrets use secrecy::SecretString and are zeroized on removal

Secret precedence (wasistate.rs, policy_internal.rs)

  • Environment variables (highest)
  • IPC-injected secrets (new)
  • File-based secrets
  • Policy defaults (lowest)

IPC integration (ipc_server.rs)

  • SetSecretinject_secret() stores in memory
  • DeleteSecret → tries memory first, falls back to file
  • ListSecrets → returns combined view

Error enhancement (lib.rs)

  • Detects missing environment variable errors via regex patterns
  • Enhances error messages with CLI command hints

Usage

# Component fails with: "Missing secrets: API_KEY. Use: wassette secret set --component weather API_KEY=value" # Inject secret dynamically (no restart needed) wassette secret set --component weather API_KEY=abc123 # Component now executes with secret available

Dependencies

  • secrecy = "0.10" - Secure secret handling
  • zeroize = "1.8" - Memory zeroization
  • regex = "1.11" - Error pattern matching
Original prompt

This section details on the original issue you should resolve

<issue_title>[subtask] [Subtask 3/4] Integrate dynamic secret injection with component runtime</issue_title>
<issue_description>Parent Issue: #177

Objective

Integrate the IPC-based secret management system with the component runtime, enabling components to receive dynamically injected secrets as environment variables at execution time.

Context

With the IPC server and CLI commands in place, this task connects secret management to component execution. When a component runs and fails due to missing secrets, the system should notify the agent/user. When secrets are provided via IPC, they should be immediately available to the component without server restart.

Implementation Details

Files to Modify

  • Modify: crates/mcp-server/src/lifecycle_manager.rs - Add secret injection logic
  • Modify: crates/wassette/src/wasistate.rs - Update extract_env_vars to include dynamic secrets
  • Modify: crates/wassette/src/secrets.rs - Add in-memory secret storage
  • Modify: crates/wassette/src/ipc_server.rs - Connect IPC commands to LifecycleManager

Key Implementation Points

In-Memory Secret Store:

  • Add RwLock(HashMap<String, HashMap<String, SecretString)>> to SecretsManager
  • Component ID → (Secret Key → Secret Value)
  • Use secrecy::SecretString and zeroize for values
  • Secrets loaded via IPC go into memory store
  • Secrets loaded from files remain in file-based cache

Secret Precedence (highest to lowest):

  1. Environment variables from --env or --env-file
  2. Dynamically injected secrets (IPC)
  3. File-based secrets (current system)
  4. Policy defaults

Runtime Integration:

  • When component is executed, merge secrets from all sources
  • Pass merged environment to WASI context
  • If component execution fails with missing env var error, include hint in error message

Error Reporting Enhancement:

  • Detect missing environment variable errors from component execution
  • Extract missing variable names from error messages
  • Return helpful MCP tool error: "Component execution failed. Missing secrets: API_KEY. Use 'wassette secret set --component {id} API_KEY=value' to provide the secret."

IPC Command Routing:

  • Route IPC SetSecret command to LifecycleManager::inject_secret(component_id, key, value)
  • Route IPC DeleteSecret command to LifecycleManager::remove_secret(component_id, key)
  • Route IPC ListSecrets command to SecretsManager::list_all_secrets(component_id)

Component Notification:

  • When secrets are updated for a loaded component, log the change
  • Consider: Should we notify MCP clients that component state changed? (Future enhancement)

Acceptance Criteria

  • Secrets set via IPC are immediately available to components
  • Secret precedence is correctly enforced (env > IPC > file > policy)
  • Component execution uses merged secrets from all sources
  • Missing secret errors include helpful error messages with CLI command hints
  • list_secrets via IPC returns combined view (IPC + file-based)
  • Deleting IPC secrets works correctly
  • Memory secrets are properly zeroized when removed
  • Integration tests verify end-to-end secret injection

Testing Strategy

  • Integration test: Load component → set secret via IPC → execute component → verify secret is available
  • Test secret precedence: Set same key in file, IPC, and env → verify correct precedence
  • Test missing secret error message contains helpful hint
  • Test secret updates are immediately visible to component
  • Test secret deletion removes from in-memory store
  • Test concurrent secret access from multiple components

Dependencies

Required:

  • Subtask 1 (IPC server infrastructure)
  • Subtask 2 (CLI commands over IPC)

Example Workflow

# Start Wassette wassette serve --sse # Load a component that requires API key # Component execution fails with helpful error # In another terminal, set the secret dynamically wassette secret set --component weather API_KEY=abc123 # Execute component again - now succeeds with the secret

Notes

  • Use secrecy::SecretString throughout to prevent accidental logging
  • Consider audit logging for secret access (VNext feature)
  • Memory secrets are transient - lost on server restart (documented behavior)
  • File-based secrets remain for persistence across restarts
    Related to Proposal: secret injection and management #177

AI generated by Plan for #177</issue_description>

Comments on the Issue (you are @copilot in this section)


💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

Copilot AI and others added 4 commits November 16, 2025 06:33
Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com>
Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com>
Copilot AI changed the title [WIP] Integrate dynamic secret injection with component runtime Integrate dynamic secret injection with component runtime Nov 16, 2025
Copilot AI requested a review from Mossaka November 16, 2025 07:01
Copilot finished work on behalf of Mossaka November 16, 2025 07:01
@Mossaka Mossaka requested a review from Copilot November 17, 2025 16:10
Copilot finished reviewing on behalf of Mossaka November 17, 2025 16:14
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR implements dynamic secret injection for Wassette components, enabling secrets to be provided to running components via IPC without requiring server restart. The implementation adds an in-memory secret store that takes precedence over file-based secrets, integrates with the IPC server for runtime management, and enhances error messages to guide users when secrets are missing.

Key Changes

  • In-memory secret storage using SecretString for secure handling with zeroization
  • Secret precedence chain: environment variables > IPC-injected secrets > file-based secrets > policy defaults
  • IPC commands route to new secret injection methods with fallback logic for deletion
  • Enhanced error messages detect missing environment variables and suggest CLI commands

Reviewed Changes

Copilot reviewed 8 out of 9 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
tests/secret_injection_integration_test.rs Comprehensive integration tests for secret injection, precedence, removal, listing, and component isolation
crates/wassette/src/wasistate.rs Updated comments to clarify environment variable precedence over secrets
crates/wassette/src/secrets.rs Implements in-memory secret store with inject_secret(), remove_memory_secret(), get_all_secrets(), and list_all_secrets()
crates/wassette/src/policy_internal.rs Updated to use get_all_secrets() for merged file and memory secrets in template building and policy operations
crates/wassette/src/lib.rs Added regex-based error detection for missing environment variables with enhanced error messages
crates/wassette/src/ipc_server.rs Routes IPC commands to memory-based operations with fallback to file-based for deletion
crates/wassette/src/ipc_protocol.rs Added #[serde(default)] to show_values field for backward compatibility
crates/wassette/Cargo.toml Added secrecy, zeroize, and regex dependencies
Cargo.lock Lock file updates for new dependencies

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +338 to +345
for pattern in &patterns {
if let Ok(re) = regex::Regex::new(pattern) {
for cap in re.captures_iter(error_message) {
if let Some(var_name) = cap.get(1) {
missing_vars.push(var_name.as_str().to_string());
}
}
}
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex compilation is inside the loop, which means each pattern is compiled multiple times for each error message. This is inefficient and could impact performance if many errors are processed.

Consider using lazy_static or once_cell to compile the regexes once and reuse them:

use once_cell::sync::Lazy; static ENV_VAR_PATTERNS: Lazy<Vec<regex::Regex>> = Lazy::new(|| { vec![ regex::Regex::new(r"environment variable not found:\s*([A-Z_][A-Z0-9_]*)").unwrap(), regex::Regex::new(r"missing environment variable\s*([A-Z_][A-Z0-9_]*)").unwrap(), // ... ] });

Alternatively, the error handling if let Ok(re) silently ignores regex compilation errors, which could hide bugs. Since these are static patterns, they should be validated at compile time or initialization.

Copilot uses AI. Check for mistakes.
Comment on lines +373 to +381
if let Some(secret) = component_secrets.remove(key) {
// Zeroize the secret before dropping
let mut exposed = secret.expose_secret().to_string();
exposed.zeroize();
info!(
"Removed memory secret '{}' for component: {}",
key, component_id
);
Ok(())
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security concern: The zeroization logic has a flaw. Creating a new owned String from expose_secret() means the original secret data in SecretString is not zeroized - only the copy is. The SecretString itself should handle zeroization when dropped.

This manual zeroization is unnecessary and misleading. The correct approach is to simply drop the SecretString, which will automatically handle zeroization internally (as it implements Drop with zeroization):

pub async fn remove_memory_secret(&self, component_id: &str, key: &str) -> Result<()> { let mut store = self.memory_store.write().await; if let Some(component_secrets) = store.get_mut(component_id) { if component_secrets.remove(key).is_some() { // SecretString handles zeroization automatically when dropped info!( "Removed memory secret '{}' for component: {}", key, component_id ); Ok(()) } else { Err(anyhow!( "Memory secret '{}' not found for component: {}", key, component_id )) } } else { Err(anyhow!( "No memory secrets found for component: {}", component_id )) } }
Copilot uses AI. Check for mistakes.
Comment on lines +172 to +192
// Try to delete from memory store first
match secrets_manager
.remove_memory_secret(&component_id, &key)
.await
.context("Failed to delete secret")?;
Ok(IpcResponse::success(format!(
"Secret '{}' deleted from component '{}'",
key, component_id
)))
{
Ok(_) => Ok(IpcResponse::success(format!(
"Secret '{}' deleted from component '{}'",
key, component_id
))),
Err(_) => {
// If not in memory, try file-based
secrets_manager
.delete_component_secrets(&component_id, std::slice::from_ref(&key))
.await
.context("Failed to delete secret")?;
Ok(IpcResponse::success(format!(
"Secret '{}' deleted from component '{}'",
key, component_id
)))
}
}
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Silent error swallowing: When remove_memory_secret fails (line 174), the error is silently ignored (the Err(_) pattern on line 181 doesn't log or report why it failed). This makes debugging difficult when a user tries to delete a secret and it's unclear whether it wasn't found in memory or there was another issue.

Consider logging the error before falling back:

match secrets_manager .remove_memory_secret(&component_id, &key) .await { Ok(_) => Ok(IpcResponse::success(format!( "Secret '{}' deleted from component '{}'", key, component_id ))), Err(memory_err) => { // Log that memory delete failed, trying file-based debug!( "Memory secret not found, trying file-based: {}", memory_err ); secrets_manager .delete_component_secrets(&component_id, std::slice::from_ref(&key)) .await .context("Failed to delete secret")?; Ok(IpcResponse::success(format!( "Secret '{}' deleted from component '{}'", key, component_id ))) } }
Copilot uses AI. Check for mistakes.
Comment on lines +349 to +444
/// Inject a secret into in-memory store (dynamic secret injection via IPC)
pub async fn inject_secret(
&self,
component_id: &str,
key: String,
value: String,
) -> Result<()> {
let secret_value = SecretString::new(value.into());
let mut store = self.memory_store.write().await;

let component_secrets = store
.entry(component_id.to_string())
.or_insert_with(HashMap::new);
component_secrets.insert(key.clone(), secret_value);

info!("Injected secret '{}' for component: {}", key, component_id);
Ok(())
}

/// Remove a secret from in-memory store
pub async fn remove_memory_secret(&self, component_id: &str, key: &str) -> Result<()> {
let mut store = self.memory_store.write().await;

if let Some(component_secrets) = store.get_mut(component_id) {
if let Some(secret) = component_secrets.remove(key) {
// Zeroize the secret before dropping
let mut exposed = secret.expose_secret().to_string();
exposed.zeroize();
info!(
"Removed memory secret '{}' for component: {}",
key, component_id
);
Ok(())
} else {
Err(anyhow!(
"Memory secret '{}' not found for component: {}",
key,
component_id
))
}
} else {
Err(anyhow!(
"No memory secrets found for component: {}",
component_id
))
}
}

/// Get all secrets for a component (merged from file and memory)
/// Returns a combined map with memory secrets taking precedence over file-based secrets
pub async fn get_all_secrets(&self, component_id: &str) -> Result<HashMap<String, String>> {
let mut merged = HashMap::new();

// Start with file-based secrets (lower precedence)
let file_secrets = self.load_component_secrets(component_id).await?;
merged.extend(file_secrets);

// Overlay memory secrets (higher precedence)
let store = self.memory_store.read().await;
if let Some(memory_secrets) = store.get(component_id) {
for (key, secret_value) in memory_secrets {
merged.insert(key.clone(), secret_value.expose_secret().to_string());
}
}

Ok(merged)
}

/// List all secrets for a component (both file-based and memory)
pub async fn list_all_secrets(
&self,
component_id: &str,
show_values: bool,
) -> Result<HashMap<String, Option<String>>> {
let mut result = HashMap::new();

// Add file-based secrets
let file_secrets = self
.list_component_secrets(component_id, show_values)
.await?;
result.extend(file_secrets);

// Add memory secrets
let store = self.memory_store.read().await;
if let Some(memory_secrets) = store.get(component_id) {
for (key, secret_value) in memory_secrets {
if show_values {
result.insert(key.clone(), Some(secret_value.expose_secret().to_string()));
} else {
result.insert(key.clone(), None);
}
}
}

Ok(result)
}
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Missing documentation: The new functions inject_secret, remove_memory_secret, get_all_secrets, and list_all_secrets lack comprehensive documentation. While they have basic doc comments, they should document:

  1. Security characteristics: That secrets are stored in memory and lost on restart
  2. Precedence behavior: That memory secrets override file-based secrets
  3. Concurrency guarantees: That operations are atomic with respect to each other
  4. Error conditions: When each error type is returned

Example for inject_secret:

/// Inject a secret into the in-memory store (dynamic secret injection via IPC). /// /// Memory secrets are: /// - Stored only in RAM and lost on server restart /// - Take precedence over file-based secrets with the same key /// - Immediately available to components without reload /// - Zeroized when removed or on drop /// /// # Arguments /// * `component_id` - The component to associate the secret with /// * `key` - The secret's environment variable name /// * `value` - The secret value (will be wrapped in SecretString) /// /// # Thread Safety /// This operation is atomic - concurrent calls will not corrupt the store. 
Copilot uses AI. Check for mistakes.
Comment on lines +328 to +335
// WASI error: "environment variable not found: API_KEY"
r"environment variable not found:\s*([A-Z_][A-Z0-9_]*)",
// Generic: "missing environment variable API_KEY"
r"missing environment variable\s*([A-Z_][A-Z0-9_]*)",
// env::var error: "environment variable `API_KEY` not found"
r"environment variable `([A-Z_][A-Z0-9_]*)` not found",
// Other variations
r"variable\s+([A-Z_][A-Z0-9_]*)\s+not\s+found",
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex patterns are overly restrictive. They only match environment variables that start with uppercase letters and contain only uppercase letters, numbers, and underscores (e.g., API_KEY). This will miss environment variables with lowercase letters or mixed case (e.g., Path, HOME, myVar), which are valid in many contexts.

Consider using a more flexible pattern like:

r"environment variable not found:\s*([A-Za-z_][A-Za-z0-9_]*)"

Or if you specifically want to detect only uppercase variables (as suggested by the naming convention), add a comment explaining this decision.

Suggested change
// WASI error: "environment variable not found: API_KEY"
r"environment variable not found:\s*([A-Z_][A-Z0-9_]*)",
// Generic: "missing environment variable API_KEY"
r"missing environment variable\s*([A-Z_][A-Z0-9_]*)",
// env::var error: "environment variable `API_KEY` not found"
r"environment variable `([A-Z_][A-Z0-9_]*)` not found",
// Other variations
r"variable\s+([A-Z_][A-Z0-9_]*)\s+not\s+found",
// WASI error: "environment variable not found: API_KEY" (now matches any valid env var name)
r"environment variable not found:\s*([A-Za-z_][A-Za-z0-9_]*)",
// Generic: "missing environment variable API_KEY"
r"missing environment variable\s*([A-Za-z_][A-Za-z0-9_]*)",
// env::var error: "environment variable `API_KEY` not found"
r"environment variable `([A-Za-z_][A-Za-z0-9_]*)` not found",
// Other variations
r"variable\s+([A-Za-z_][A-Za-z0-9_]*)\s+not\s+found",
Copilot uses AI. Check for mistakes.
Comment on lines +425 to +439
// Add file-based secrets
let file_secrets = self
.list_component_secrets(component_id, show_values)
.await?;
result.extend(file_secrets);

// Add memory secrets
let store = self.memory_store.read().await;
if let Some(memory_secrets) = store.get(component_id) {
for (key, secret_value) in memory_secrets {
if show_values {
result.insert(key.clone(), Some(secret_value.expose_secret().to_string()));
} else {
result.insert(key.clone(), None);
}
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Potential issue with precedence in listing: When a secret key exists in both file-based and memory stores, the list_all_secrets function will add the file-based secret first (line 429), then overwrite it with the memory secret (line 436). While this produces the correct result (memory secrets take precedence), it's less efficient and less clear than necessary.

Consider checking for duplicates to avoid unnecessary work:

// Add file-based secrets let file_secrets = self .list_component_secrets(component_id, show_values) .await?; result.extend(file_secrets); // Add/override with memory secrets (higher precedence) let store = self.memory_store.read().await; if let Some(memory_secrets) = store.get(component_id) { for (key, secret_value) in memory_secrets { let value = if show_values { Some(secret_value.expose_secret().to_string()) } else { None }; result.insert(key.clone(), value); // Explicitly shows override behavior } }

This makes the precedence behavior more explicit and matches the pattern used in get_all_secrets.

Suggested change
// Add file-based secrets
let file_secrets = self
.list_component_secrets(component_id, show_values)
.await?;
result.extend(file_secrets);
// Add memory secrets
let store = self.memory_store.read().await;
if let Some(memory_secrets) = store.get(component_id) {
for (key, secret_value) in memory_secrets {
if show_values {
result.insert(key.clone(), Some(secret_value.expose_secret().to_string()));
} else {
result.insert(key.clone(), None);
}
// Add file-based secrets (lower precedence)
let file_secrets = self
.list_component_secrets(component_id, show_values)
.await?;
result.extend(file_secrets);
// Overlay memory secrets (higher precedence)
let store = self.memory_store.read().await;
if let Some(memory_secrets) = store.get(component_id) {
for (key, secret_value) in memory_secrets {
let value = if show_values {
Some(secret_value.expose_secret().to_string())
} else {
None
};
result.insert(key.clone(), value); // Explicitly shows override behavior
Copilot uses AI. Check for mistakes.
Comment on lines +397 to +441
/// Get all secrets for a component (merged from file and memory)
/// Returns a combined map with memory secrets taking precedence over file-based secrets
pub async fn get_all_secrets(&self, component_id: &str) -> Result<HashMap<String, String>> {
let mut merged = HashMap::new();

// Start with file-based secrets (lower precedence)
let file_secrets = self.load_component_secrets(component_id).await?;
merged.extend(file_secrets);

// Overlay memory secrets (higher precedence)
let store = self.memory_store.read().await;
if let Some(memory_secrets) = store.get(component_id) {
for (key, secret_value) in memory_secrets {
merged.insert(key.clone(), secret_value.expose_secret().to_string());
}
}

Ok(merged)
}

/// List all secrets for a component (both file-based and memory)
pub async fn list_all_secrets(
&self,
component_id: &str,
show_values: bool,
) -> Result<HashMap<String, Option<String>>> {
let mut result = HashMap::new();

// Add file-based secrets
let file_secrets = self
.list_component_secrets(component_id, show_values)
.await?;
result.extend(file_secrets);

// Add memory secrets
let store = self.memory_store.read().await;
if let Some(memory_secrets) = store.get(component_id) {
for (key, secret_value) in memory_secrets {
if show_values {
result.insert(key.clone(), Some(secret_value.expose_secret().to_string()));
} else {
result.insert(key.clone(), None);
}
}
}
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Potential data race in secret exposure: Multiple concurrent calls to get_all_secrets or list_all_secrets will each call expose_secret() on the same SecretString instances (lines 410, 436). While secrecy crate's expose_secret() is safe to call concurrently (it only provides a reference), creating multiple String copies of secrets in memory increases the attack surface.

Consider one of the following approaches:

  1. Document that this is acceptable for the use case (secrets need to be exposed to pass to WASM anyway)
  2. Add rate limiting or caching to reduce the number of simultaneous exposures
  3. Add a comment explaining the security trade-off

Since the primary use case is providing secrets to components, and they will be exposed anyway, option 1 (documentation) is likely sufficient.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants