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:
Illia Polosukhin
2026-02-21 01:28:23 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 542268fde5
commit 2cdd1acb1e
21 changed files with 1113 additions and 148 deletions
+157
View File
@@ -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(&registry);
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);
}
}
+4 -1
View File
@@ -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)