fix: add transfer mutex to prevent TOCTOU race in transfer_credits (CWE-367)#171
fix: add transfer mutex to prevent TOCTOU race in transfer_credits (CWE-367)#171quangtran88 wants to merge 2 commits intoConway-Research:mainfrom
Conversation
8bfe5a9 to
049a3b9
Compare
…WE-367) Add withTransferLock() mutex to serialize credit transfer operations, preventing race condition between balance check and transfer execution. Fixes Conway-Research#177
049a3b9 to
3721d66
Compare
There was a problem hiding this comment.
🔴 fund_child bypasses transfer mutex, preserving the TOCTOU race the PR aims to fix
The fund_child tool at src/agent/tools.ts:1416-1425 performs the same balance-check-then-transfer pattern as transfer_credits, but does not use withTransferLock. This means concurrent transfer_credits and fund_child calls (or two fund_child calls) can still race, defeating the purpose of the mutex.
Root Cause
The PR adds withTransferLock to transfer_credits (line 884) to serialize the balance check (getCreditsBalance()) and the actual transfer (transferCredits()). However, fund_child at lines 1416-1425 performs the exact same TOCTOU-vulnerable sequence without the lock:
// fund_child — no lock!
const balance = await ctx.conway.getCreditsBalance(); // line 1416
if (amount > balance / 2) { ... } // line 1417
const transfer = await ctx.conway.transferCredits(...); // line 1421If transfer_credits and fund_child are called concurrently (e.g., the agent issues both tool calls in a single turn), both can read the same balance, both pass the "half balance" guard, and both transfers execute — potentially draining more than intended.
Impact: The self-preservation guard ("don't transfer more than half your balance") can be bypassed via concurrent fund_child + transfer_credits calls, which is exactly the class of bug this PR is supposed to fix.
(Refers to lines 1416-1425)
Prompt for agents
In src/agent/tools.ts, the fund_child tool (around lines 1395-1455) performs a balance check followed by a credit transfer without using the withTransferLock mutex. Wrap the balance-check-and-transfer section (lines 1416-1455, from `const balance = await ctx.conway.getCreditsBalance()` through the return statement) inside a `withTransferLock(async () => { ... })` call, similar to how transfer_credits does it at line 884. The early validation checks (child lookup, wallet validation, status check, amount validation) can remain outside the lock since they don't involve the balance race. The return value of the execute function should be `return withTransferLock(async () => { ... })` encompassing the balance check, transfer, transaction recording, funded amount update, lifecycle transition, and return message.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Good catch — fund_child had the same TOCTOU-vulnerable pattern.
Fixed in ba94c3c: wrapped the balance-check-through-return sequence in fund_child with withTransferLock, same as transfer_credits. CI is passing (typecheck + tests on Node 20 & 22).
…ack) fund_child performs the same balance-check-then-transfer pattern as transfer_credits but was not serialized by the mutex, allowing concurrent calls to bypass the half-balance guard.
Summary
Fixes critical CWE-367 TOCTOU race condition in the
transfer_creditstool where the balance check and transfer execution were not atomic.Changes
withTransferLock) to serialize credit transfer operationsSecurity Impact
Before: Concurrent
transfer_creditscalls could both pass the "max half balance" guard before either transfer executes, allowing an agent to drain its balance beyond the safety limit:After: Transfer operations are serialized — T2 would see the updated $60 balance and correctly enforce the guard.
Note: Server-side atomic transfers would be the ideal long-term fix. This client-side mutex is a pragmatic defense-in-depth measure.
Closes #177