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
+381
View File
@@ -0,0 +1,381 @@
//! Broad detection of manually-provided credentials in HTTP request parameters.
//!
//! Used by the built-in HTTP tool to decide whether approval is needed when
//! the LLM provides auth data directly in headers or URL query parameters.
/// Check whether HTTP request parameters contain manually-provided credentials.
///
/// Inspects headers (name/value), URL query parameters, and URL userinfo
/// for patterns that indicate authentication data.
pub fn params_contain_manual_credentials(params: &serde_json::Value) -> bool {
headers_contain_credentials(params)
|| url_contains_credential_params(params)
|| url_contains_userinfo(params)
}
/// Header names that are exact matches for credential-carrying headers (case-insensitive).
const AUTH_HEADER_EXACT: &[&str] = &[
"authorization",
"proxy-authorization",
"cookie",
"x-api-key",
"api-key",
"x-auth-token",
"x-token",
"x-access-token",
"x-session-token",
"x-csrf-token",
"x-secret",
"x-api-secret",
];
/// Substrings in header names that suggest credentials (case-insensitive).
/// Note: "key" is excluded to avoid false positives like "X-Idempotency-Key".
const AUTH_HEADER_SUBSTRINGS: &[&str] = &["auth", "token", "secret", "credential", "password"];
/// Value prefixes that indicate auth schemes (case-insensitive).
const AUTH_VALUE_PREFIXES: &[&str] = &[
"bearer ",
"basic ",
"token ",
"digest ",
"hoba ",
"mutual ",
"aws4-hmac-sha256 ",
];
/// URL query parameter names that are exact matches for credentials (case-insensitive).
const AUTH_QUERY_EXACT: &[&str] = &[
"api_key",
"apikey",
"api-key",
"access_token",
"token",
"key",
"secret",
"password",
"auth",
"auth_token",
"session_token",
"client_secret",
"client_id",
"app_key",
"app_secret",
"sig",
"signature",
];
/// Substrings in query parameter names that suggest credentials (case-insensitive).
const AUTH_QUERY_SUBSTRINGS: &[&str] = &["token", "secret", "auth", "password", "credential"];
fn header_name_is_credential(name: &str) -> bool {
let lower = name.to_lowercase();
if AUTH_HEADER_EXACT.contains(&lower.as_str()) {
return true;
}
AUTH_HEADER_SUBSTRINGS.iter().any(|sub| lower.contains(sub))
}
fn header_value_is_credential(value: &str) -> bool {
let lower = value.to_lowercase();
AUTH_VALUE_PREFIXES.iter().any(|pfx| lower.starts_with(pfx))
}
fn headers_contain_credentials(params: &serde_json::Value) -> bool {
match params.get("headers") {
Some(serde_json::Value::Object(map)) => map.iter().any(|(k, v)| {
header_name_is_credential(k) || v.as_str().is_some_and(header_value_is_credential)
}),
Some(serde_json::Value::Array(items)) => items.iter().any(|item| {
let name_match = item
.get("name")
.and_then(|n| n.as_str())
.is_some_and(header_name_is_credential);
let value_match = item
.get("value")
.and_then(|v| v.as_str())
.is_some_and(header_value_is_credential);
name_match || value_match
}),
_ => false,
}
}
fn query_param_is_credential(name: &str) -> bool {
let lower = name.to_lowercase();
if AUTH_QUERY_EXACT.contains(&lower.as_str()) {
return true;
}
AUTH_QUERY_SUBSTRINGS.iter().any(|sub| lower.contains(sub))
}
fn url_contains_credential_params(params: &serde_json::Value) -> bool {
let url_str = match params.get("url").and_then(|u| u.as_str()) {
Some(u) => u,
None => return false,
};
let parsed = match url::Url::parse(url_str) {
Ok(u) => u,
Err(_) => return false,
};
parsed
.query_pairs()
.any(|(name, _)| query_param_is_credential(&name))
}
/// Detect credentials embedded in URL userinfo (e.g., `https://user:pass@host/`).
fn url_contains_userinfo(params: &serde_json::Value) -> bool {
let url_str = match params.get("url").and_then(|u| u.as_str()) {
Some(u) => u,
None => return false,
};
let parsed = match url::Url::parse(url_str) {
Ok(u) => u,
Err(_) => return false,
};
// Non-empty username or password in the URL indicates embedded credentials
!parsed.username().is_empty() || parsed.password().is_some()
}
#[cfg(test)]
mod tests {
use super::*;
// ── Header name exact match ────────────────────────────────────────
#[test]
fn test_authorization_header_detected() {
let params = serde_json::json!({
"method": "GET",
"url": "https://api.example.com",
"headers": {"Authorization": "Bearer token123"}
});
assert!(params_contain_manual_credentials(&params));
}
#[test]
fn test_all_exact_header_names() {
for name in AUTH_HEADER_EXACT {
let params = serde_json::json!({
"method": "GET",
"url": "https://example.com",
"headers": {name.to_string(): "some_value"}
});
assert!(
params_contain_manual_credentials(&params),
"Header '{}' should be detected",
name
);
}
}
#[test]
fn test_header_name_case_insensitive() {
let params = serde_json::json!({
"method": "GET",
"url": "https://example.com",
"headers": {"AUTHORIZATION": "value"}
});
assert!(params_contain_manual_credentials(&params));
}
// ── Header name substring match ────────────────────────────────────
#[test]
fn test_header_substring_auth() {
let params = serde_json::json!({
"method": "GET",
"url": "https://example.com",
"headers": {"X-Custom-Auth-Header": "value"}
});
assert!(params_contain_manual_credentials(&params));
}
#[test]
fn test_header_substring_token() {
let params = serde_json::json!({
"method": "GET",
"url": "https://example.com",
"headers": {"X-My-Token": "value"}
});
assert!(params_contain_manual_credentials(&params));
}
// ── Header value prefix match ──────────────────────────────────────
#[test]
fn test_bearer_value_detected() {
let params = serde_json::json!({
"method": "GET",
"url": "https://example.com",
"headers": {"X-Custom": "Bearer sk-abc123"}
});
assert!(params_contain_manual_credentials(&params));
}
#[test]
fn test_basic_value_detected() {
let params = serde_json::json!({
"method": "GET",
"url": "https://example.com",
"headers": {"X-Custom": "Basic dXNlcjpwYXNz"}
});
assert!(params_contain_manual_credentials(&params));
}
// ── Array-format headers ───────────────────────────────────────────
#[test]
fn test_array_format_header_name() {
let params = serde_json::json!({
"method": "GET",
"url": "https://example.com",
"headers": [{"name": "Authorization", "value": "Bearer token"}]
});
assert!(params_contain_manual_credentials(&params));
}
#[test]
fn test_array_format_header_value_prefix() {
let params = serde_json::json!({
"method": "GET",
"url": "https://example.com",
"headers": [{"name": "X-Custom", "value": "Token abc123"}]
});
assert!(params_contain_manual_credentials(&params));
}
// ── URL query parameter detection ──────────────────────────────────
#[test]
fn test_url_api_key_param() {
let params = serde_json::json!({
"method": "GET",
"url": "https://api.example.com/data?api_key=abc123"
});
assert!(params_contain_manual_credentials(&params));
}
#[test]
fn test_url_access_token_param() {
let params = serde_json::json!({
"method": "GET",
"url": "https://api.example.com/data?access_token=xyz"
});
assert!(params_contain_manual_credentials(&params));
}
#[test]
fn test_url_query_substring_match() {
let params = serde_json::json!({
"method": "GET",
"url": "https://api.example.com/data?my_auth_code=xyz"
});
assert!(params_contain_manual_credentials(&params));
}
#[test]
fn test_url_query_case_insensitive() {
let params = serde_json::json!({
"method": "GET",
"url": "https://api.example.com/data?API_KEY=abc"
});
assert!(params_contain_manual_credentials(&params));
}
// ── False positive checks ──────────────────────────────────────────
#[test]
fn test_idempotency_key_not_detected() {
let params = serde_json::json!({
"method": "POST",
"url": "https://api.example.com",
"headers": {"X-Idempotency-Key": "uuid-1234"}
});
assert!(!params_contain_manual_credentials(&params));
}
#[test]
fn test_content_type_not_detected() {
let params = serde_json::json!({
"method": "GET",
"url": "https://example.com",
"headers": {"Content-Type": "application/json", "Accept": "text/html"}
});
assert!(!params_contain_manual_credentials(&params));
}
#[test]
fn test_no_headers_no_query() {
let params = serde_json::json!({
"method": "GET",
"url": "https://example.com/path"
});
assert!(!params_contain_manual_credentials(&params));
}
#[test]
fn test_safe_query_params() {
let params = serde_json::json!({
"method": "GET",
"url": "https://api.example.com/search?q=hello&page=1&limit=10"
});
assert!(!params_contain_manual_credentials(&params));
}
#[test]
fn test_empty_headers() {
let params = serde_json::json!({
"method": "GET",
"url": "https://example.com",
"headers": {}
});
assert!(!params_contain_manual_credentials(&params));
}
#[test]
fn test_invalid_url_returns_false() {
let params = serde_json::json!({
"method": "GET",
"url": "not a url"
});
assert!(!params_contain_manual_credentials(&params));
}
// ── URL userinfo detection ─────────────────────────────────────────
#[test]
fn test_url_userinfo_with_password_detected() {
let params = serde_json::json!({
"method": "GET",
"url": "https://user:[email protected]/data"
});
assert!(params_contain_manual_credentials(&params));
}
#[test]
fn test_url_userinfo_username_only_detected() {
let params = serde_json::json!({
"method": "GET",
"url": "https://[email protected]/data"
});
assert!(params_contain_manual_credentials(&params));
}
#[test]
fn test_url_without_userinfo_not_detected_by_userinfo_check() {
// This specifically tests that url_contains_userinfo returns false
// for a normal URL (the broader function may still detect query params).
assert!(!url_contains_userinfo(&serde_json::json!({
"url": "https://api.example.com/data"
})));
}
}
+2
View File
@@ -7,11 +7,13 @@
//! - Enforcing safety policies
//! - Detecting secret leakage in outputs
mod credential_detect;
mod leak_detector;
mod policy;
mod sanitizer;
mod validator;
pub use credential_detect::params_contain_manual_credentials;
pub use leak_detector::{
LeakAction, LeakDetectionError, LeakDetector, LeakMatch, LeakPattern, LeakScanResult,
LeakSeverity,