Skip to content

fix(security): harden approval thread safety (TOCTOU + error handling) - #2366

Merged
serrrfirat merged 2 commits into
stagingfrom
fix/approval-thread-safety-v2
Apr 14, 2026
Merged

serrrfirat merged 2 commits into
stagingfrom
fix/approval-thread-safety-v2

Conversation

@zmanian

@zmanian zmanian commented Apr 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

Consolidates two security fixes for the approval processing flow in thread_ops.rs:

  • TOCTOU race ([CRITICAL] TOCTOU race condition in approval thread resolution #1486): Hold session lock for the entire take-verify sequence in process_approval() so pending approval cannot be lost if a concurrent operation modifies the thread between take and restore. Previously, the lock was dropped after take_pending_approval() and re-acquired for request_id verification, creating a window where the approval could be permanently lost.

  • Silent error fallback ([HIGH] Incomplete fallback logic for non-existent approval threads #1487): Replace 10 silent if let Some(thread) patterns with explicit match arms. Critical paths (state transitions, deferred approval setup) return errors when threads disappear. Non-critical paths (tool result recording, auth mode, rejection) log debug messages but continue.

Files changed

  • src/agent/thread_ops.rs — both fixes + 2 regression tests

Test plan

  • Regression test: test_approval_request_id_mismatch_restores_pending
  • Regression test: test_approval_on_missing_thread_should_error
  • cargo check clean
  • Zero clippy warnings
  • CI: full test suite (local env too memory-constrained for test binary linking)

Supersedes #1591 (branch had no merge base with current staging after repo restructuring).
Closes #1486, Closes #1487

https://claude.ai/code/session_01X86EZxqXEFiU9VetyhPKjM

@github-actions github-actions Bot added scope: agent Agent core (agent loop, router, scheduler) size: L 200-499 changed lines risk: medium Business logic, config, or moderate-risk modules contributor: core 20+ merged PRs labels Apr 12, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request addresses TOCTOU races in the process_approval function by ensuring that pending approvals are taken and verified under a single lock. It also improves error handling across several thread operations by explicitly checking for missing threads and providing appropriate logging or error returns. Regression tests were added to verify the fix for the race condition and the handling of missing threads. Feedback suggests enhancing visibility into unexpected states by adding warning logs when a thread has no turns during tool result recording.

Comment thread src/agent/thread_ops.rs
Comment on lines +1364 to 1385
match sess.threads.get_mut(&thread_id) {
Some(thread) => {
if let Some(turn) = thread.last_turn_mut() {
if is_tool_error {
turn.record_tool_error_for(
&pending.tool_call_id,
result_content.clone(),
);
} else {
turn.record_tool_result_for(
&pending.tool_call_id,
serde_json::json!(result_content),
);
}
}
}
None => {
tracing::debug!(
%thread_id,
"Thread disappeared before tool result could be recorded"
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

While the change correctly handles the case where the thread is missing, it still silently ignores the case where thread.last_turn_mut() returns None. Given the objective to harden error handling and avoid silent fallbacks, this unexpected state should be surfaced via tracing::warn! to aid in troubleshooting, consistent with the repository's practice for handling persistence and task failures.

                match sess.threads.get_mut(&thread_id) {
                    Some(thread) => {
                        match thread.last_turn_mut() {
                            Some(turn) => {
                                if is_tool_error {
                                    turn.record_tool_error_for(
                                        &pending.tool_call_id,
                                        result_content.clone(),
                                    );
                                } else {
                                    turn.record_tool_result_for(
                                        &pending.tool_call_id,
                                        serde_json::json!(result_content),
                                    );
                                }
                            }
                            None => {
                                tracing::warn!(
                                    %thread_id,
                                    "Thread has no turns; tool result could not be recorded"
                                );
                            }
                        }
                    }
                    None => {
                        tracing::warn!(
                            %thread_id,
                            "Thread disappeared before tool result could be recorded"
                        );
                    }
                }
References
  1. Errors and unexpected states in persistence or task execution should be surfaced via tracing::warn! to ensure reliability and visibility.

Comment thread src/agent/thread_ops.rs
Comment on lines +1623 to 1642
match sess.threads.get_mut(&thread_id) {
Some(thread) => {
if let Some(turn) = thread.last_turn_mut() {
if is_deferred_error {
turn.record_tool_error_for(&tc.id, deferred_content.clone());
} else {
turn.record_tool_result_for(
&tc.id,
serde_json::json!(deferred_content),
);
}
}
}
None => {
tracing::debug!(
%thread_id,
tool = %tc.name,
"Thread disappeared before deferred tool result could be recorded"
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the tool result recording path above, the case where thread.last_turn_mut() is None is currently handled silently. Adding a warning log for this scenario ensures consistency in error visibility across the codebase and avoids silent failures during tool result recording.

                    match sess.threads.get_mut(&thread_id) {
                        Some(thread) => {
                            match thread.last_turn_mut() {
                                Some(turn) => {
                                    if is_deferred_error {
                                        turn.record_tool_error_for(&tc.id, deferred_content.clone());
                                    } else {
                                        turn.record_tool_result_for(
                                            &tc.id,
                                            serde_json::json!(deferred_content),
                                        );
                                    }
                                }
                                None => {
                                    tracing::warn!(
                                        %thread_id,
                                        tool = %tc.name,
                                        "Thread has no turns; deferred tool result could not be recorded"
                                    );
                                }
                            }
                        }
                        None => {
                            tracing::warn!(
                                %thread_id,
                                tool = %tc.name,
                                "Thread disappeared before deferred tool result could be recorded"
                            );
                        }
                    }
References
  1. Surface unexpected failures or state inconsistencies via tracing::warn! to ensure reliability and visibility, as seen in rules for audit persistence and task joining.

@github-actions github-actions Bot added the scope: dependencies Dependency updates label Apr 12, 2026
Consolidates two security fixes for the approval processing flow in
thread_ops.rs:

**TOCTOU race (#1486):** Hold session lock for the entire take-verify
sequence in process_approval() so pending approval cannot be lost if a
concurrent operation modifies the thread between take and restore.
Previously, the lock was dropped after take_pending_approval() and
re-acquired for request_id verification, creating a window where the
approval could be permanently lost.

**Silent error fallback (#1487):** Replace 10 silent `if let Some(thread)`
patterns with explicit `match` arms. Critical paths (state transitions,
deferred approval setup) return errors when threads disappear. Non-critical
paths (tool result recording, auth mode, rejection) log debug messages but
continue.

Regression tests:
- test_approval_request_id_mismatch_restores_pending
- test_approval_on_missing_thread_should_error

Supersedes #1591 (branch had no merge base with current staging).
Closes #1486, Closes #1487

https://claude.ai/code/session_01X86EZxqXEFiU9VetyhPKjM
Comment thread src/agent/thread_ops.rs Outdated
None => {
tracing::debug!(
%thread_id,
"Thread disappeared before rejection could be recorded"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High — Rejection path returns success when thread is gone

When the thread disappears before rejection can be recorded, the function logs a debug message but still sends StatusUpdate::Status("Rejected") and returns SubmissionResult::response(rejection). The caller and UI see a successful rejection for a thread that no longer exists.

More critically, the pending approval was already take()-n at line 1181 and consumed. If the thread disappears between take and the rejection path, the approval is permanently lost with no error surfaced.

Suggested fix: The None arm for the rejection branch should return an error (like the approval-path critical sites at lines 1267-1274 and 1691-1697), e.g., SubmissionResult::error("Thread disappeared during rejection").

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finding #1 was already addressed in the original PR — the rejection path returns Err(JobError::NotFound) at line 1888 when the thread is gone.

Comment thread src/agent/thread_ops.rs
None => {
tracing::debug!(
%thread_id,
"Thread disappeared before tool result could be recorded"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — Tool result recording silently continues when thread gone

When the thread disappears before the tool result can be recorded, the code logs debug and continues. But the tool has already been executed (line 1307-1308). The context_messages still get the tool result appended (line 1389-1393), so the agentic loop may resume with context referencing a tool call whose result was never persisted in the thread.

This is partially mitigated by the error at line 1769-1770 but could cause a mismatch between LLM context and persisted state. A comment explaining why proceeding is safe would clarify intent.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added explanatory comment: the tool result is still appended to context_messages so the LLM sees a matching tool_result for every tool_use ID (mismatches cause API errors). The missing thread will be detected on the next agentic loop iteration.

Comment thread src/agent/thread_ops.rs
None => {
tracing::debug!(
%thread_id,
"Thread disappeared before auth intercept could be applied"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — Orphaned SSE emission for dead thread

When the thread disappears, the None arm logs debug but execution continues past the block to emit_auth_required_status(). This sends an AuthRequired SSE event to the client for a thread that no longer exists. The client will show an auth prompt that can never be completed. The caller (process_approval) subsequently returns SubmissionResult::auth_pending() for a dead thread.

Suggested fix: Return early from handle_auth_intercept when the thread is gone, or suppress the SSE emission.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6fee0ae. handle_auth_intercept now returns early in the None arm, preventing orphaned auth-required SSE events for dead threads.

Comment thread src/agent/thread_ops.rs
None => {
tracing::debug!(
%thread_id,
"Thread disappeared before auth mode could be re-entered"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — Orphaned SSE emission in process_auth_token

Two code paths in process_auth_token (the Ok(result) and Err(e) arms) re-enter auth mode. When the thread is gone, the None arm logs debug but execution continues to emit_auth_required_status(). This sends auth-required SSE events for a nonexistent thread.

Suggested fix: Add early return or skip the emit_auth_required_status call when the thread was not found.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6fee0ae. Both Ok(result) and Err(e) retry paths in process_auth_token now check thread_alive before emitting emit_auth_required_status. Dead threads no longer receive auth prompts.

@serrrfirat serrrfirat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Paranoid Architect Review — REQUEST CHANGES

1 High, 4 Medium findings.

The core TOCTOU fix (holding the lock for take-verify) is correct and the regression test validates it well. However, the silent-error-fallback fix is inconsistently applied:

  • High: The rejection path returns success to the caller when the thread is gone, permanently losing the approval with no error.
  • Medium: Two handle_auth_intercept / process_auth_token paths emit SSE events for dead threads — clients get stuck in auth prompts that can never resolve.
  • Medium (Finding 5 — not in diff, line ~374): One if let Some(thread) pattern at line ~374 in process_user_input (the Processing/queue path) was not converted, contrary to the PR's stated scope of "Replace 10 silent if let Some(thread) patterns." If the thread disappears between the state snapshot (line 344) and the mutable re-lock (line 373), the code silently falls through to the Idle | Completed match arm, which could incorrectly start a new turn on a removed thread. Suggested fix: Convert this to a match with explicit None handling, consistent with the other 9 sites.

The rejection path (finding #1) is the ship-blocker — it should return an error, not success, matching the approval path's behavior.

@serrrfirat serrrfirat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Paranoid Security Review — NEEDS CHANGES

PR: #2366 — Harden approval thread safety (TOCTOU + error handling)
Reviewer context: Automated deep security review

High

Rejection path returns success when thread disappears. The rejection branch converts if let Some(thread) to a match with a None arm that logs debug but continues. After the block, the code still sends StatusUpdate::Status("Rejected") and returns SubmissionResult::response(rejection). The pending approval was already consumed by take_pending_approval(), the rejection was never persisted (thread is gone), but the caller sees a successful rejection. The approval is permanently lost with no error surfaced. Should return an error, matching the approval path's behavior.

Orphaned SSE emissions in handle_auth_intercept and process_auth_token. Converted sites log debug when the thread is gone but continue to emit_auth_required_status(). This sends auth-required SSE events for nonexistent threads — clients display auth prompts that can never complete. Add early returns.

Medium

  • process_user_input line 374 not converted. If the thread disappears between the state snapshot (line 354) and the mutable re-lock (line 374), execution silently falls through to the Idle | Completed arm, starting a new turn on a nonexistent thread. This was in the stated scope of "replacing 10 silent if let Some(thread) patterns."
  • No concurrent stress test. The regression tests are excellent for single-threaded scenarios, but there's no test that spawns two process_approval tasks simultaneously to verify only one succeeds.

Low

  • Misleading title says "TOCTOU + atomics" but no std::sync::atomic types are used — the fix uses tokio::sync::Mutex lock holds. The description body is accurate ("TOCTOU + error handling").

Summary

The core TOCTOU fix is correct and well-targeted — consolidating take/verify/restore into a single lock hold eliminates the race. The ship-blockers are the rejection path returning success on thread disappearance and the orphaned SSE emissions. Both are straightforward fixes.

Address review feedback:
- handle_auth_intercept: return early when thread is gone instead
  of emitting auth-required SSE to a dead thread
- process_auth_token: skip emit_auth_required_status when thread
  disappeared (both Ok retry and Err retry paths)

Clients will no longer see auth prompts that can never resolve
when the underlying thread has been deleted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@zmanian
zmanian requested a review from serrrfirat April 14, 2026 08:40
@zmanian

zmanian commented Apr 14, 2026

Copy link
Copy Markdown
Collaborator Author

All findings from both review rounds are addressed in the current branch:

  • Finding 1 (High - rejection path): Fixed in 1e93133. The rejection None arm returns Err(JobError::NotFound) at line 1916, matching the approval path's behavior.
  • Finding 5 (Medium - line 374 not converted): Fixed in 6fee0ae. Line 374 now uses match sess.threads.get_mut(&thread_id) { Some(thread) => { ... } None => { ... } } with explicit None handling, consistent with all other sites.
  • Orphaned SSE findings: Fixed in 6fee0ae (handle_auth_intercept and process_auth_token paths).

@zmanian
zmanian dismissed stale reviews from serrrfirat and serrrfirat April 14, 2026 08:43

All findings addressed: rejection path returns Err(JobError::NotFound) in 1e93133, line 374 converted to match in 6fee0ae, orphaned SSE emissions fixed in 6fee0ae.

@serrrfirat serrrfirat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed as paranoid architect. TOCTOU fix is correct — lock held atomically for take-verify-restore. All 10 silent if let Some patterns properly categorized (critical → error, non-critical → debug log). No correctness bugs, no security concerns, no convention violations. Clean PR.

This was referenced Apr 16, 2026
@henrypark133 henrypark133 mentioned this pull request Apr 21, 2026
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
nearai#2366)

* fix(security): harden approval thread safety (TOCTOU + error handling)

Consolidates two security fixes for the approval processing flow in
thread_ops.rs:

**TOCTOU race (nearai#1486):** Hold session lock for the entire take-verify
sequence in process_approval() so pending approval cannot be lost if a
concurrent operation modifies the thread between take and restore.
Previously, the lock was dropped after take_pending_approval() and
re-acquired for request_id verification, creating a window where the
approval could be permanently lost.

**Silent error fallback (nearai#1487):** Replace 10 silent `if let Some(thread)`
patterns with explicit `match` arms. Critical paths (state transitions,
deferred approval setup) return errors when threads disappear. Non-critical
paths (tool result recording, auth mode, rejection) log debug messages but
continue.

Regression tests:
- test_approval_request_id_mismatch_restores_pending
- test_approval_on_missing_thread_should_error

Supersedes nearai#1591 (branch had no merge base with current staging).
Closes nearai#1486, Closes nearai#1487

https://claude.ai/code/session_01X86EZxqXEFiU9VetyhPKjM

* fix(security): prevent orphaned SSE events for dead threads

Address review feedback:
- handle_auth_intercept: return early when thread is gone instead
  of emitting auth-required SSE to a dead thread
- process_auth_token: skip emit_auth_required_status when thread
  disappeared (both Ok retry and Err retry paths)

Clients will no longer see auth prompts that can never resolve
when the underlying thread has been deleted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs risk: medium Business logic, config, or moderate-risk modules scope: agent Agent core (agent loop, router, scheduler) scope: dependencies Dependency updates size: L 200-499 changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[HIGH] Incomplete fallback logic for non-existent approval threads [CRITICAL] TOCTOU race condition in approval thread resolution

3 participants