mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-31 08:39:24 +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
@@ -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(¶ms));
|
||||
}
|
||||
|
||||
#[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(¶ms),
|
||||
"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(¶ms));
|
||||
}
|
||||
|
||||
// ── 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(¶ms));
|
||||
}
|
||||
|
||||
#[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(¶ms));
|
||||
}
|
||||
|
||||
// ── 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(¶ms));
|
||||
}
|
||||
|
||||
#[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(¶ms));
|
||||
}
|
||||
|
||||
// ── 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(¶ms));
|
||||
}
|
||||
|
||||
#[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(¶ms));
|
||||
}
|
||||
|
||||
// ── 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(¶ms));
|
||||
}
|
||||
|
||||
#[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(¶ms));
|
||||
}
|
||||
|
||||
#[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(¶ms));
|
||||
}
|
||||
|
||||
#[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(¶ms));
|
||||
}
|
||||
|
||||
// ── 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(¶ms));
|
||||
}
|
||||
|
||||
#[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(¶ms));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_headers_no_query() {
|
||||
let params = serde_json::json!({
|
||||
"method": "GET",
|
||||
"url": "https://example.com/path"
|
||||
});
|
||||
assert!(!params_contain_manual_credentials(¶ms));
|
||||
}
|
||||
|
||||
#[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(¶ms));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_headers() {
|
||||
let params = serde_json::json!({
|
||||
"method": "GET",
|
||||
"url": "https://example.com",
|
||||
"headers": {}
|
||||
});
|
||||
assert!(!params_contain_manual_credentials(¶ms));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_url_returns_false() {
|
||||
let params = serde_json::json!({
|
||||
"method": "GET",
|
||||
"url": "not a url"
|
||||
});
|
||||
assert!(!params_contain_manual_credentials(¶ms));
|
||||
}
|
||||
|
||||
// ── 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(¶ms));
|
||||
}
|
||||
|
||||
#[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(¶ms));
|
||||
}
|
||||
|
||||
#[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"
|
||||
})));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user