mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d9821d37a | ||
|
|
52d935d744 |
+175
-55
@@ -868,7 +868,9 @@ impl Agent {
|
||||
approved: bool,
|
||||
always: bool,
|
||||
) -> Result<SubmissionResult, Error> {
|
||||
// Get pending approval for this thread
|
||||
// Get pending approval for this thread.
|
||||
// Hold the session lock for the entire check-take-verify-mutate sequence
|
||||
// to eliminate the TOCTOU race window (fixes #1486).
|
||||
let pending = {
|
||||
let mut sess = session.lock().await;
|
||||
let thread = sess
|
||||
@@ -886,54 +888,46 @@ impl Agent {
|
||||
return Ok(SubmissionResult::ok_with_message(""));
|
||||
}
|
||||
|
||||
thread.take_pending_approval()
|
||||
};
|
||||
let pending = match thread.take_pending_approval() {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
tracing::debug!(
|
||||
%thread_id,
|
||||
"Ignoring stale approval: no pending approval found"
|
||||
);
|
||||
return Ok(SubmissionResult::ok_with_message(""));
|
||||
}
|
||||
};
|
||||
|
||||
let pending = match pending {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
tracing::debug!(
|
||||
%thread_id,
|
||||
"Ignoring stale approval: no pending approval found"
|
||||
);
|
||||
return Ok(SubmissionResult::ok_with_message(""));
|
||||
}
|
||||
};
|
||||
|
||||
// Verify request ID if provided
|
||||
if let Some(req_id) = request_id
|
||||
&& req_id != pending.request_id
|
||||
{
|
||||
// Put it back and return error
|
||||
let mut sess = session.lock().await;
|
||||
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
||||
// Verify request ID while still holding the lock
|
||||
if let Some(req_id) = request_id
|
||||
&& req_id != pending.request_id
|
||||
{
|
||||
// Restore the pending approval atomically without releasing the lock
|
||||
thread.await_approval(pending);
|
||||
return Ok(SubmissionResult::error(
|
||||
"Request ID mismatch. Use the correct request ID.",
|
||||
));
|
||||
}
|
||||
return Ok(SubmissionResult::error(
|
||||
"Request ID mismatch. Use the correct request ID.",
|
||||
));
|
||||
}
|
||||
|
||||
// If approved with "always", set auto-approve while we still have the lock.
|
||||
// Drop the thread borrow before calling sess methods to satisfy the borrow checker.
|
||||
if approved && always {
|
||||
let tool_name = pending.tool_name.clone();
|
||||
sess.auto_approve_tool(&tool_name);
|
||||
tracing::info!("Auto-approved tool '{}' for session {}", tool_name, sess.id);
|
||||
}
|
||||
|
||||
// Set thread state to Processing for approved requests.
|
||||
// Re-borrow the thread since the previous borrow was dropped.
|
||||
if approved && let Some(thread) = sess.threads.get_mut(&thread_id) {
|
||||
thread.state = ThreadState::Processing;
|
||||
}
|
||||
|
||||
pending
|
||||
};
|
||||
|
||||
if approved {
|
||||
// If always, add to auto-approved set
|
||||
if always {
|
||||
let mut sess = session.lock().await;
|
||||
sess.auto_approve_tool(&pending.tool_name);
|
||||
tracing::info!(
|
||||
"Auto-approved tool '{}' for session {}",
|
||||
pending.tool_name,
|
||||
sess.id
|
||||
);
|
||||
}
|
||||
|
||||
// Reset thread state to processing
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
||||
thread.state = ThreadState::Processing;
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the approved tool and continue the loop
|
||||
let mut job_ctx =
|
||||
JobContext::with_user(&message.user_id, "chat", "Interactive chat session")
|
||||
@@ -1460,17 +1454,25 @@ impl Agent {
|
||||
);
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
||||
thread.clear_pending_approval();
|
||||
thread.complete_turn(&rejection);
|
||||
// User message already persisted at turn start; save rejection response
|
||||
self.persist_assistant_response(
|
||||
thread_id,
|
||||
&message.channel,
|
||||
&message.user_id,
|
||||
&rejection,
|
||||
)
|
||||
.await;
|
||||
match sess.threads.get_mut(&thread_id) {
|
||||
Some(thread) => {
|
||||
thread.clear_pending_approval();
|
||||
thread.complete_turn(&rejection);
|
||||
// User message already persisted at turn start; save rejection response
|
||||
self.persist_assistant_response(
|
||||
thread_id,
|
||||
&message.channel,
|
||||
&message.user_id,
|
||||
&rejection,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
%thread_id,
|
||||
"Thread disappeared during approval rejection"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2012,6 +2014,124 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression test for #1486: verify that request_id mismatch atomically
|
||||
/// restores the pending approval (no TOCTOU window where it could be lost).
|
||||
#[test]
|
||||
fn test_request_id_mismatch_preserves_pending_state() {
|
||||
use crate::agent::session::{PendingApproval, Session, Thread, ThreadState};
|
||||
use uuid::Uuid;
|
||||
|
||||
let session_id = Uuid::new_v4();
|
||||
let thread_id = Uuid::new_v4();
|
||||
let correct_request_id = Uuid::new_v4();
|
||||
let wrong_request_id = Uuid::new_v4();
|
||||
|
||||
let mut thread = Thread::with_id(thread_id, session_id);
|
||||
let pending = PendingApproval {
|
||||
request_id: correct_request_id,
|
||||
tool_name: "shell".to_string(),
|
||||
parameters: serde_json::json!({"command": "rm -rf /"}),
|
||||
display_parameters: serde_json::json!({"command": "[REDACTED]"}),
|
||||
description: "Execute dangerous command".to_string(),
|
||||
tool_call_id: "call_42".to_string(),
|
||||
context_messages: vec![],
|
||||
deferred_tool_calls: vec![],
|
||||
user_timezone: None,
|
||||
allow_always: false,
|
||||
};
|
||||
thread.await_approval(pending);
|
||||
|
||||
let mut session = Session::new("test-user");
|
||||
session.threads.insert(thread_id, thread);
|
||||
|
||||
// Verify initial state
|
||||
assert_eq!(
|
||||
session.threads[&thread_id].state,
|
||||
ThreadState::AwaitingApproval
|
||||
);
|
||||
assert!(session.threads[&thread_id].pending_approval.is_some());
|
||||
|
||||
// Simulate the atomic check-take-verify sequence from the fixed code:
|
||||
// take the approval, then verify request_id, and restore on mismatch.
|
||||
let thread = session.threads.get_mut(&thread_id).unwrap();
|
||||
let taken = thread.take_pending_approval().unwrap();
|
||||
assert!(thread.pending_approval.is_none(), "take should clear it");
|
||||
|
||||
// Wrong request_id -- restore atomically (within same "lock scope" in production)
|
||||
assert_ne!(wrong_request_id, taken.request_id);
|
||||
thread.await_approval(taken);
|
||||
|
||||
// Verify the pending approval is fully restored
|
||||
assert_eq!(
|
||||
session.threads[&thread_id].state,
|
||||
ThreadState::AwaitingApproval
|
||||
);
|
||||
let restored = session.threads[&thread_id]
|
||||
.pending_approval
|
||||
.as_ref()
|
||||
.expect("pending approval must be restored after request_id mismatch");
|
||||
assert_eq!(restored.request_id, correct_request_id);
|
||||
assert_eq!(restored.tool_name, "shell");
|
||||
assert_eq!(restored.tool_call_id, "call_42");
|
||||
}
|
||||
|
||||
/// Regression test for #1486: verify that approval with "always" flag
|
||||
/// correctly transitions thread state and registers auto-approve in a
|
||||
/// single lock scope.
|
||||
#[test]
|
||||
fn test_approval_with_always_sets_auto_approve_and_processing() {
|
||||
use crate::agent::session::{PendingApproval, Session, Thread, ThreadState};
|
||||
use uuid::Uuid;
|
||||
|
||||
let session_id = Uuid::new_v4();
|
||||
let thread_id = Uuid::new_v4();
|
||||
let request_id = Uuid::new_v4();
|
||||
|
||||
let mut thread = Thread::with_id(thread_id, session_id);
|
||||
let pending = PendingApproval {
|
||||
request_id,
|
||||
tool_name: "web_fetch".to_string(),
|
||||
parameters: serde_json::json!({"url": "https://example.com"}),
|
||||
display_parameters: serde_json::json!({"url": "https://example.com"}),
|
||||
description: "Fetch a web page".to_string(),
|
||||
tool_call_id: "call_99".to_string(),
|
||||
context_messages: vec![],
|
||||
deferred_tool_calls: vec![],
|
||||
user_timezone: None,
|
||||
allow_always: true,
|
||||
};
|
||||
thread.await_approval(pending);
|
||||
|
||||
let mut session = Session::new("test-user");
|
||||
session.threads.insert(thread_id, thread);
|
||||
|
||||
// Simulate the combined lock scope: take + verify + auto_approve + state transition
|
||||
// Simulate the combined lock scope: take + verify + auto_approve + state transition.
|
||||
// Use separate scopes to mirror the borrow pattern in production code.
|
||||
let taken = {
|
||||
let thread = session.threads.get_mut(&thread_id).unwrap();
|
||||
thread.take_pending_approval().unwrap()
|
||||
};
|
||||
|
||||
// Request ID matches
|
||||
assert_eq!(taken.request_id, request_id);
|
||||
|
||||
// Auto-approve the tool (thread borrow dropped, so session is accessible)
|
||||
session.auto_approve_tool(&taken.tool_name);
|
||||
|
||||
// Re-borrow thread and set state to Processing
|
||||
{
|
||||
let thread = session.threads.get_mut(&thread_id).unwrap();
|
||||
thread.state = ThreadState::Processing;
|
||||
}
|
||||
|
||||
// Verify both mutations happened atomically
|
||||
assert!(session.is_tool_auto_approved("web_fetch"));
|
||||
assert_eq!(session.threads[&thread_id].state, ThreadState::Processing);
|
||||
// Pending approval should be consumed (not restored)
|
||||
assert!(session.threads[&thread_id].pending_approval.is_none());
|
||||
}
|
||||
|
||||
// Helper function to extract the approval message without needing a full Agent instance
|
||||
fn extract_approval_message(
|
||||
session: &crate::agent::session::Session,
|
||||
|
||||
+1
-72
@@ -176,28 +176,6 @@ pub(crate) fn parse_string_env(
|
||||
Ok(optional_env(key)?.unwrap_or_else(|| default.into()))
|
||||
}
|
||||
|
||||
/// Redact userinfo (username:password) from a parsed URL for safe inclusion in error messages.
|
||||
fn redact_parsed_url(parsed: &reqwest::Url) -> String {
|
||||
if !parsed.username().is_empty() || parsed.password().is_some() {
|
||||
let mut redacted = parsed.clone();
|
||||
let _ = redacted.set_username("");
|
||||
let _ = redacted.set_password(None);
|
||||
format!("{} (credentials redacted)", redacted)
|
||||
} else {
|
||||
parsed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Redact userinfo (username:password) from a URL string for safe inclusion in error messages.
|
||||
///
|
||||
/// Use this when you only have the raw string and no parsed URL (e.g., on a parse-error path).
|
||||
fn redact_url(url: &str) -> String {
|
||||
match reqwest::Url::parse(url) {
|
||||
Ok(parsed) => redact_parsed_url(&parsed),
|
||||
Err(_) => "<invalid URL>".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a user-configurable base URL to prevent SSRF attacks (#1103).
|
||||
///
|
||||
/// Rejects:
|
||||
@@ -210,10 +188,9 @@ fn redact_url(url: &str) -> String {
|
||||
pub(crate) fn validate_base_url(url: &str, field_name: &str) -> Result<(), ConfigError> {
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
|
||||
// Parse once and reuse — on parse failure, fall back to `redact_url()` for the error message.
|
||||
let parsed = reqwest::Url::parse(url).map_err(|e| ConfigError::InvalidValue {
|
||||
key: field_name.to_string(),
|
||||
message: format!("invalid URL '{}': {}", redact_url(url), e),
|
||||
message: format!("invalid URL '{}': {}", url, e),
|
||||
})?;
|
||||
|
||||
let scheme = parsed.scheme();
|
||||
@@ -512,52 +489,4 @@ mod tests {
|
||||
"Expected DNS resolution failure, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_base_url_credentials_redacted_in_errors() {
|
||||
// Verify that error messages do not leak credentials from URLs
|
||||
let err = validate_base_url("ftp://user:[email protected]", "TEST")
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
!err.contains("secret"),
|
||||
"error message should not contain the password: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redact_url_strips_credentials() {
|
||||
let result = redact_url("https://user:[email protected]/v1");
|
||||
assert!(
|
||||
!result.contains("user:pass"),
|
||||
"credentials should be redacted: {result}"
|
||||
);
|
||||
assert!(
|
||||
result.contains("example.com"),
|
||||
"host should be preserved: {result}"
|
||||
);
|
||||
assert!(
|
||||
result.contains("redacted"),
|
||||
"should indicate redaction: {result}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redact_url_no_credentials_unchanged() {
|
||||
let result = redact_url("https://example.com/v1");
|
||||
assert!(
|
||||
!result.contains("redacted"),
|
||||
"no-credential URL should not mention redaction: {result}"
|
||||
);
|
||||
assert!(
|
||||
result.contains("example.com"),
|
||||
"host should be preserved: {result}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redact_url_invalid_url_returns_placeholder() {
|
||||
let result = redact_url("not-a-url");
|
||||
assert_eq!(result, "<invalid URL>");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user