mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-01 09:09:19 +00:00
refactor: consolidate tool approval into single param-aware method (#274)
* refactor: consolidate tool approval into single param-aware method Replace the two confusing approval methods (requires_approval() and requires_approval_for()) with a single requires_approval(&self, params) returning a 3-variant ApprovalRequirement enum (Never, UnlessAutoApproved, Always). This enables param-aware approval decisions: HTTP calls without auth headers now skip approval entirely, while authenticated requests always require it. Shell tool merges its destructive-command detection into the same method. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: add credential injection to built-in HTTP tool Wire the WASM credential injection system into the built-in HTTP tool so credentials are auto-injected at the boundary (zero-exposure model). - Add SharedCredentialRegistry: thread-safe, append-only registry of credential mappings populated by WASM tools at registration time - Add credential_detect module with broad auth detection for headers (12 exact + 5 substring matches), header values (7 auth scheme prefixes), and URL query params (17 exact + 5 substring matches) - HttpTool now accepts optional credential registry + secrets store, auto-injects matching credentials in execute(), and uses broader auth detection in requires_approval() - ToolRegistry passes credential registry to HttpTool at startup and populates it when WASM tools register - Remove old hardcoded AUTH_HEADER_NAMES / has_auth_headers in favor of the new params_contain_manual_credentials() Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address PR #274 review comments (query param injection, lock poisoning, visibility) - Fix injected query params not being sent on outbound HTTP requests by also calling .query() on the RequestBuilder alongside parsed_url mutation - Recover from poisoned RwLock in SharedCredentialRegistry instead of silently ignoring failures, with tracing::warn for visibility - Narrow inject_credential and host_matches_pattern to pub(crate) to avoid committing to them as stable public API Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
542268fde5
commit
2cdd1acb1e
@@ -23,6 +23,7 @@
|
||||
//! ```
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use crate::secrets::{
|
||||
CredentialLocation, CredentialMapping, DecryptedSecret, SecretError, SecretsStore,
|
||||
@@ -59,6 +60,88 @@ impl From<SecretError> for InjectionError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe, append-only registry of credential mappings from all installed tools.
|
||||
///
|
||||
/// Aggregates credential mappings from WASM tools so the built-in HTTP tool can
|
||||
/// auto-inject credentials for matching hosts. Uses `std::sync::RwLock` so
|
||||
/// `requires_approval` (sync) can query it without async.
|
||||
pub struct SharedCredentialRegistry {
|
||||
mappings: RwLock<Vec<CredentialMapping>>,
|
||||
}
|
||||
|
||||
impl SharedCredentialRegistry {
|
||||
/// Create an empty registry.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
mappings: RwLock::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add credential mappings (called when WASM tools register).
|
||||
pub fn add_mappings(&self, mappings: impl IntoIterator<Item = CredentialMapping>) {
|
||||
match self.mappings.write() {
|
||||
Ok(mut guard) => {
|
||||
guard.extend(mappings);
|
||||
}
|
||||
Err(poisoned) => {
|
||||
tracing::warn!(
|
||||
"SharedCredentialRegistry RwLock poisoned during add_mappings; recovering"
|
||||
);
|
||||
let mut guard = poisoned.into_inner();
|
||||
guard.extend(mappings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if any credential mapping matches this host (sync, for requires_approval).
|
||||
pub fn has_credentials_for_host(&self, host: &str) -> bool {
|
||||
let guard = match self.mappings.read() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => {
|
||||
tracing::warn!(
|
||||
"SharedCredentialRegistry RwLock poisoned during has_credentials_for_host; recovering"
|
||||
);
|
||||
poisoned.into_inner()
|
||||
}
|
||||
};
|
||||
guard.iter().any(|mapping| {
|
||||
mapping
|
||||
.host_patterns
|
||||
.iter()
|
||||
.any(|pattern| host_matches_pattern(host, pattern))
|
||||
})
|
||||
}
|
||||
|
||||
/// Get all credential mappings matching a host (for injection).
|
||||
pub fn find_for_host(&self, host: &str) -> Vec<CredentialMapping> {
|
||||
let guard = match self.mappings.read() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => {
|
||||
tracing::warn!(
|
||||
"SharedCredentialRegistry RwLock poisoned during find_for_host; recovering"
|
||||
);
|
||||
poisoned.into_inner()
|
||||
}
|
||||
};
|
||||
guard
|
||||
.iter()
|
||||
.filter(|mapping| {
|
||||
mapping
|
||||
.host_patterns
|
||||
.iter()
|
||||
.any(|pattern| host_matches_pattern(host, pattern))
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SharedCredentialRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of credential injection.
|
||||
#[derive(Debug)]
|
||||
pub struct InjectedCredentials {
|
||||
@@ -431,4 +514,78 @@ mod tests {
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// ── SharedCredentialRegistry tests ─────────────────────────────────
|
||||
|
||||
use crate::tools::wasm::credential_injector::SharedCredentialRegistry;
|
||||
|
||||
#[test]
|
||||
fn test_shared_registry_empty() {
|
||||
let registry = SharedCredentialRegistry::new();
|
||||
assert!(!registry.has_credentials_for_host("api.example.com"));
|
||||
assert!(registry.find_for_host("api.example.com").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shared_registry_add_and_find() {
|
||||
let registry = SharedCredentialRegistry::new();
|
||||
registry.add_mappings(vec![
|
||||
CredentialMapping::bearer("openai_key", "api.openai.com"),
|
||||
CredentialMapping::header("github_token", "X-GitHub-Token", "*.github.com"),
|
||||
]);
|
||||
|
||||
assert!(registry.has_credentials_for_host("api.openai.com"));
|
||||
assert!(!registry.has_credentials_for_host("api.anthropic.com"));
|
||||
|
||||
let found = registry.find_for_host("api.openai.com");
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].secret_name, "openai_key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shared_registry_wildcard_host() {
|
||||
let registry = SharedCredentialRegistry::new();
|
||||
registry.add_mappings(vec![CredentialMapping::bearer("gh_token", "*.github.com")]);
|
||||
|
||||
assert!(registry.has_credentials_for_host("api.github.com"));
|
||||
assert!(registry.has_credentials_for_host("uploads.github.com"));
|
||||
assert!(!registry.has_credentials_for_host("github.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shared_registry_multiple_adds() {
|
||||
let registry = SharedCredentialRegistry::new();
|
||||
registry.add_mappings(vec![CredentialMapping::bearer("key1", "api.example.com")]);
|
||||
registry.add_mappings(vec![CredentialMapping::bearer("key2", "api.example.com")]);
|
||||
|
||||
let found = registry.find_for_host("api.example.com");
|
||||
assert_eq!(found.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shared_registry_thread_safety() {
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
|
||||
let registry = Arc::new(SharedCredentialRegistry::new());
|
||||
|
||||
let handles: Vec<_> = (0..4)
|
||||
.map(|i| {
|
||||
let r = Arc::clone(®istry);
|
||||
thread::spawn(move || {
|
||||
r.add_mappings(vec![CredentialMapping::bearer(
|
||||
format!("key_{}", i),
|
||||
"api.example.com",
|
||||
)]);
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
for h in handles {
|
||||
h.join().unwrap();
|
||||
}
|
||||
|
||||
let found = registry.find_for_host("api.example.com");
|
||||
assert_eq!(found.len(), 4);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +104,10 @@ pub use capabilities::{
|
||||
|
||||
// Security components (V2)
|
||||
pub use allowlist::{AllowlistResult, AllowlistValidator, DenyReason};
|
||||
pub use credential_injector::{CredentialInjector, InjectedCredentials, InjectionError};
|
||||
pub(crate) use credential_injector::inject_credential;
|
||||
pub use credential_injector::{
|
||||
CredentialInjector, InjectedCredentials, InjectionError, SharedCredentialRegistry,
|
||||
};
|
||||
pub use rate_limiter::{LimitType, RateLimitError, RateLimitResult, RateLimiter};
|
||||
|
||||
// Storage (V2)
|
||||
|
||||
Reference in New Issue
Block a user