{"assessments":[],"deployments":[],"fuzz":[],"identity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"interpretation":"Records acceptance and evidence. Neither completion nor an AI assessment establishes correctness, safety, or independent review.","jobId":"a99dc20d-ebea-4abf-824d-ce94ceb1bc65","kind":"audit","nodes":[{"acceptedSubmissionHash":"e3a3438297d261aad89285c295ac2917beb05d123eeb8e5ba3da0acd8d055b32","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"7af468622dce6b10ea363653cd13fd7b8c2e93021a4277bf496fb665b97292e7","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"0b034c2ba4ddb7a7560e1d8c8edb430d06293ced43fa72bd9ec0f53e61698cb0","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"8b000dba0f36559bd204a0b3ecefb45a8092e2ae09fdd029c4b75bd258361a84","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"ce21d36dc71e511a57720519f1e9896ff3b0519458b27fbe174a671858a5ece4","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"}],"objective":"Security audit of the agent-wallet/ directory of this repo. Focus on: authorization bypass (approval/confirmation gates, autonomous session limits, intent preview vs execute mismatches), key and secret holding (seed/private key storage, encryption at rest, leakage via logs/errors/tool outputs, env handling), and signing flows (what gets signed, transaction/permit construction, replay, amount/recipient tampering between preview and execution). Report findings with severity, file:line references and reproduction scenarios.","parentJobId":null,"planHash":"6965e999cd3c97b907993b7d390908eb880412758bb757bb7a802b45ee0e4ebb","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"a99dc20d-ebea-4abf-824d-ce94ceb1bc65","publication":{"commit":null,"deliveredAt":null,"repoUrl":null},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"52590","feedbackHash":"5beef8a1a67ac93d9ac8209d57541716b96f29f457aa1dba3cf6cbdace9cd13b","nodeKey":"audit_economics","submissionHash":"e3a3438297d261aad89285c295ac2917beb05d123eeb8e5ba3da0acd8d055b32","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52267","feedbackHash":"8f0a7bc197160b907a643a04d51a16fffd2a9af5330cd460669e8a94b9b717a8","nodeKey":"audit_flow","submissionHash":"7af468622dce6b10ea363653cd13fd7b8c2e93021a4277bf496fb665b97292e7","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52678","feedbackHash":"b3c25db70f1a46aa4b8176c50d4784c96e2f299287bf5bef3a654ef4edba11fc","nodeKey":"audit_judge","submissionHash":"0b034c2ba4ddb7a7560e1d8c8edb430d06293ced43fa72bd9ec0f53e61698cb0","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52171","feedbackHash":"2f90f6a40cba4fd2d916f3a95f624d622058f654c8605d7db9039f53e8b8b1e2","nodeKey":"audit_math","submissionHash":"8b000dba0f36559bd204a0b3ecefb45a8092e2ae09fdd029c4b75bd258361a84","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52258","feedbackHash":"4ed1d8451d8a88cb731b56e431c0c7caf9bd0dbf8f60c3095f5e279d2a1d5868","nodeKey":"audit_permissions","submissionHash":"ce21d36dc71e511a57720519f1e9896ff3b0519458b27fbe174a671858a5ece4","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"4c867fa55b3da069d34157e7b10c4d7ab04925a0da277ff0faf5b1558f4e0e54","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"03332db135fe4c15","findings":[{"citation":"resolved","description":"agentlayer_autonomous_approve is an ordinary agent-callable tool (registered through _autonomous_permission_tool_specs and returned by list_tools on every backend). Its only guards are a scope in SUPPORTED_SCOPES, a non-empty purpose string and args['user_intent'] is True, all three supplied by the model. On success it calls autonomous_permissions.approve_all() (line 4045), which persists an enabled 'all' group to OPENCLAW_HOME/autonomous_permissions.json with no tool, network, recipient, spend or TTL bound. From then on _require_execute_approval (lines 585-590) mints its own approval token via autonomous_permissions.authorize_operation() for ANY write tool on ANY network with mainnet_confirmed=True (autonomous_permissions.py authorize_operation documents 'no per-tool or per-network allow-list'). This defeats the AGENTS.md invariant that execute 'must continue requiring a host-issued approval_token'. The sibling tool start_autonomous_session (lines 4087-4103) correctly demands a genuine host token because 'an agent must not be able to widen its own envelope'; the strictly more powerful standing grant has no such check. The deprecated scopes base_swaps/defi_tools behave identically (approve_base_swaps -> approve_all), so a 'Base swaps only' request also unlocks Solana transfers and x402 payments. A prompt-injected or misbehaving agent therefore converts a per-transaction human-approval wallet into an unattended one with one tool call, persisted across sessions and hosts. Fix: require a host-issued, single-use approval token bound to {operation:'agentlayer_autonomous_approve', scope} exactly as start_autonomous_session does (or remove the agent-facing tool and keep the grant only on the host CLI path), and never treat user_intent as the authorization signal. Merged from audit_economics, audit_permissions, audit_flow, audit_math.","line":4041,"path":"agent-wallet/agent_wallet/openclaw_adapter.py","reproduction":"Reproduced with OpenClawWalletAdapter over the smoke-test FakeBackend (chain solana, network mainnet), sealed approval_secret installed, no session, no permission file. Step 1: invoke('transfer_sol', {recipient:'Dest111...', amount:1.0, mode:'execute', purpose:'x'}) with no approval_token -> ok=False, error 'SOL transfer execution requires a host-issued approval_token ...'. Step 2: invoke('agentlayer_autonomous_approve', {scope:'all', purpose:'speed', user_intent:true}) -> ok=True, data.active=True; autonomous_permissions.json written. Step 3: invoke('transfer_sol', {recipient:'Dest111...', amount:5.0, mode:'execute', purpose:'x'}) still with no approval_token -> ok=True and the backend's send_native_transfer was called with 5.0 SOL on mainnet. Variant: step 2 with scope:'base_swaps' -> status() lists tools ['swap_evm_tokens','swap_evm_uniswap_tokens'] yet step 3 (transfer_sol, 2.0 SOL) also succeeds. Expected: step 2 rejected without a host-issued token, so step 3 keeps failing.","severity":"critical","snippet":"                if args.get(\"user_intent\") is not True:","title":"Agent can self-grant the standing all-tools autonomous permission: agentlayer_autonomous_approve is gated only by an agent-supplied user_intent flag"},{"citation":"resolved","description":"The invoke() dispatcher handles issue_wallet_approval for any backend (the tool spec is only inserted into the EVM tool list, but _invoke_tool_dispatch never checks list_tools membership, so it is reachable on Solana backends too). The handler takes tool_name, summary (any object) and mainnet_confirmed straight from the agent's arguments and calls approval.issue_approval_token() with the sealed approval_secret (line 5873-5878, issued_by='claude-code-bridge'), returning the signed token to the caller. No host involvement, no user_intent check (the spec sets requires_explicit_user_intent=True but the handler never reads it), no check that the summary came from a real preview. Because every execute path only verifies the HMAC and the (tool, network, summary) binding, an agent can approve its own transfers, swaps, DeFi operations, x402 payments and even start_autonomous_session envelopes. The handler is reachable from every host that forwards tool calls verbatim (openclaw_cli invoke, the Hermes bridge, the Codex/Claude Code MCP server). Fix: remove the tool from the agent-facing dispatcher and issue tokens only from the host process (openclaw_cli issue-approval / OpenClawWalletRuntimeContext.issue_execute_approval) after an out-of-band user confirmation; if a bridge must keep it, gate it behind a host-held secret or interactive confirmation rather than agent arguments. Merged from audit_economics.","line":5853,"path":"agent-wallet/agent_wallet/openclaw_adapter.py","reproduction":"Reproduced with OpenClawWalletAdapter over the smoke-test FakeBackend (solana mainnet), sealed approval_secret present, no session/permission. list_tools() does NOT contain issue_wallet_approval on this backend, yet: Step 1: invoke('transfer_sol', {recipient:'Dest111...', amount:1.0, mode:'prepare', purpose:'x', user_intent:true}) -> copy data.confirmation_summary S. Step 2: invoke('issue_wallet_approval', {tool_name:'transfer_sol', summary:S, mainnet_confirmed:true}) -> ok=True, data.approval_token=T, usage 'Pass approval_token to transfer_sol with mode=execute'. Step 3: invoke('transfer_sol', {recipient:'Dest111...', amount:1.0, mode:'execute', purpose:'x', approval_token:T}) -> ok=True, send_native_transfer called. Expected: only a host-issued token after human confirmation can satisfy execute; the agent should not be able to obtain T.","severity":"critical","snippet":"            if tool_name == \"issue_wallet_approval\":","title":"issue_wallet_approval lets the agent mint host approval tokens for any tool and any summary, on every backend"},{"citation":"resolved","description":"require_single_use() records consumed token fingerprints in a module-level in-memory dict. The documented deployment runs `python -m agent_wallet.openclaw_cli invoke` as a fresh subprocess per tool call (autonomous_session.py docstring; the OpenClaw extension, Hermes plugin and Codex bridge all spawn it per call), so the registry is empty at the start of every execute and verify_approval_token never checks the token's jti against any persisted store. A host token for one exact transfer therefore authorises that transfer repeatedly until exp (settings.agent_wallet_approval_ttl_seconds defaults to 600). The native-transfer binding summary carries no per-execution nonce (only operation, network, from_address, to_address, amount_native), so the same token verifies again each time. Tokens are held by the agent in the Hermes flow and via issue_wallet_approval, and appear in CLI argv in all hosts. Replaying the start_autonomous_session token also re-runs start_session(), resetting operations and spend_entries of an existing session. Fix: persist consumed jti values (with exp) under OPENCLAW_HOME behind a file lock, check them in the same critical section as verify_approval_token, or bind each execute token to a one-shot value the backend consumes. Merged from audit_economics, audit_permissions, audit_flow, audit_math.","line":48,"path":"agent-wallet/agent_wallet/nonce_registry.py","reproduction":"Reproduced. Issue one token T = issue_approval_token(tool_name='transfer_sol', network='mainnet', summary=<_build_confirmation_summary of the 1 SOL preview: keys amount_native, from_address, network, operation, to_address>, mainnet_confirmed=True). In one process: invoke('transfer_sol', {..., mode:'execute', approval_token:T}) #1 -> ok=True, #2 -> ok=False 'Approval token has already been used'. Then spawn three separate Python processes that each build the adapter and call the same execute with the same T: all three print ok=True and each calls send_native_transfer (3 SOL moved for one 1 SOL approval). Expected: any use after the first, in any process, rejected.","severity":"high","snippet":"_registry = NonceRegistry()","title":"Approval-token single-use protection is per-process, so a token replays for its whole TTL across the per-call CLI subprocesses"},{"citation":"resolved","description":"x402_pay_request skips _require_execute_approval entirely when is_de_minimis_payment(preview) is true (openclaw_adapter.py 4221-4235). That decision reads x402_amount_display, which normalize_payment_requirement only fills when _looks_like_usdc() is true, and _looks_like_usdc accepts either a known USDC asset id OR any requirement whose server-supplied extra.name is 'usdc'/'usd coin', without cross-checking the asset field. The payment itself is built by the x402 SDK from the requirement's real asset and amount. A malicious endpoint can therefore return asset=<any mint/contract the wallet holds>, amount=1999999, extra.name='USDC' and the wallet signs and submits 1,999,999 raw units of that asset to payTo with no host token, session, permission or user confirmation (for 8-decimal cbBTC that is ~0.02 BTC; for low-decimal tokens far more). The agent only has to be induced to call x402_pay_request on an attacker URL. Fix: treat a requirement as USDC only when asset itself is in the vetted per-network USDC list, ignore extra.name for this purpose, and apply de minimis only when decimals are known to be 6. Merged from audit_permissions, audit_flow.","line":287,"path":"agent-wallet/agent_wallet/providers/x402.py","reproduction":"Reproduced: req={'scheme':'exact','network':'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp','asset':'cbbtcf3aa214zXHbiAZQwf4122FBYbraNdFqgw4iMij','amount':'1999999','payTo':'Attacker111...','extra':{'name':'USDC'}}; n = x402.normalize_payment_requirement(req, source='t') -> n['amount_display'] == '1.999999'; x402.is_de_minimis_payment({'payment_required':True,'x402_amount_display':n['amount_display']}) -> True, so openclaw_adapter.py:4224 never calls _require_execute_approval and pay_and_fetch signs the cbBTC transfer. The same requirement without extra.name yields amount_display None (approval required). Expected: amount_display None and de_minimis False whenever asset is not a known USDC id.","severity":"high","snippet":"    return _trim(extra.get(\"name\")).lower() in _USDC_IDENTIFIERS","title":"x402 de-minimis bypass trusts the merchant's extra.name to decide an asset is USDC, so a hostile 402 server gets any SPL/ERC-20 asset transferred with no approval"},{"citation":"resolved","description":"x402_pay_request reads _approved_preview from the tool arguments and passes it to x402.resolve_payment_preview(). _reusable_approved_preview (x402.py 1592-1615) returns the dict unchanged whenever request_fingerprint matches the (computable) SHA over method/URL/body and it carries a selected_payment plus a payment-required header; no fresh probe is made and nothing about the object is authenticated. The adapter then derives the approval decision from that same object: is_de_minimis_payment reads preview['x402_amount_display'] (line 4223) and skips _require_execute_approval when it parses below $2, while pay_and_fetch signs from preview['selected_payment'] and preview['response_headers']['payment-required'], i.e. the real (possibly $50 or attacker-chosen) requirement. The gate reads one field and the signer reads another, both caller-controlled. _approved_preview is agent-controlled on every host that forwards arguments verbatim (openclaw_cli invoke, Hermes), and the OpenClaw/Codex bridges only fill it when absent. Fix: never derive the approval decision from caller-supplied data (compute de minimis from a fresh probe or re-normalised selected_payment), require _approved_preview to carry a host-bound digest before reuse, and ignore it when no approval_token is present. Merged from audit_economics, audit_flow.","line":4201,"path":"agent-wallet/agent_wallet/openclaw_adapter.py","reproduction":"Reproduced: url='https://pay.example.test/report'; fp=x402._build_request_metadata(url=url, method='GET')['request_fingerprint']; crafted={'payment_required':True,'request_fingerprint':fp,'selected_payment':{'scheme':'exact','network':'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp','asset':'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v','amount':'250000000000','pay_to':'Attacker111...'},'x402_amount_display':'0.01','response_headers':{'payment-required':'ZZZ'}}; resolved = await x402.resolve_payment_preview(backend=FakeBackend(), url=url, method='GET', approved_preview=crafted) -> resolved == crafted (returned verbatim, no network call) and x402.is_de_minimis_payment(resolved) -> True. In the adapter this means invoke('x402_pay_request', {url, purpose, _approved_preview: crafted}) with no approval_token skips _require_execute_approval and pay_and_fetch proceeds to sign the 250,000 USDC payment described by the crafted selected_payment/header. Expected: a fresh probe (or a host-authenticated preview) decides the amount; a $0.01 display string must not authorise a $250,000 payment.","severity":"high","snippet":"                approved_preview = args.get(\"_approved_preview\")","title":"x402_pay_request reuses an agent-supplied _approved_preview verbatim for both the de-minimis decision and the payment it signs"},{"citation":"resolved","description":"verify_provider_swap_transaction deliberately accepts unknown program ids (reject_unknown=False at line 196) on the stated basis that the simulation check is the stronger swap-specific gate. But verify_provider_swap_simulation_result only bounds the delta of input_mint and output_mint, and checks the native lamport delta only when input_mint is wrapped SOL. Any other wallet token delta and, for token-input swaps, the native SOL delta are computed (token_deltas / native_delta_lamports) but never enforced. A compromised or spoofed swap provider response (the Jupiter/Ultra base URLs and provider gateway are configurable, and prepare_swap_from_preview trusts preview['quote_response']['transaction']) can append SPL transfer/approve or System transfer instructions and the wallet still signs (wallet_layer/solana.py ~5162) and broadcasts. If simulateTransaction raises ProviderError the code proceeds with no balance check at all (solana.py 5196-5206: 'Proceeding with structural provider verification to preserve swap availability'). Fix: reject any negative delta for mints other than input_mint, bound the native delta to a fee allowance when the input is not SOL, and fail closed when the simulation is unavailable. Merged from audit_flow.","line":357,"path":"agent-wallet/agent_wallet/transaction_policy.py","reproduction":"Reproduced: verify_provider_swap_simulation_result(sim, wallet_address=W, wallet_account_index=0, input_mint=USDC, output_mint=JUP, input_amount_raw=10_000_000, minimum_output_amount_raw=100_000_000) with sim={'err':None,'preBalances':[50_000_000_000,0],'postBalances':[10_000_000,0],'preTokenBalances':[USDC 10_000_000, JUP 0, BONK 5_000_000_000_000 all owner=W],'postTokenBalances':[USDC 0, JUP 100_000_000, BONK 0]} returns verified=True with enforced_checks ['simulation_err_is_none','token_input_spend_within_approved_amount','token_output_meets_approved_minimum'] while its own token_deltas show BONK -5000000000000 and native_delta_lamports -49990000000. Expected: rejection, since the wallet loses 49.99 SOL and all BONK beyond the approved 10 USDC.","severity":"high","snippet":"        input_delta = token_deltas.get(input_mint)","title":"Provider swap simulation check only inspects input/output mints, so a provider-built transaction that also drains other tokens or native SOL is signed and broadcast"},{"citation":"resolved","description":"verify_provider_bags_transaction calls _assert_program_allowlist with reject_unknown=False, so the allow-list is advisory; the only hard requirements are that the wallet is a required signer, at most two signers exist, and the token mint string appears somewhere in the account keys. _prepare_bags_transactions (wallet_layer/solana.py 1362-1381) then signs every transaction returned by the Bags API and _execute_prepared_bags_transactions broadcasts them, without simulateTransaction or any balance-delta check (unlike the swap and Kamino paths). A compromised or MITM'd Bags API response can have the wallet sign a transaction whose only effect is SystemProgram::Transfer of the whole SOL balance, or SPL transfer/approve of any token, as long as the expected mint is listed as an unused account. Fix: require at least one recognised Bags/DBC/Meteora program id, reject unknown programs, and simulate with the swap-style balance-delta verification before signing. Merged from audit_flow.","line":434,"path":"agent-wallet/agent_wallet/transaction_policy.py","reproduction":"Reproduced with solders: wallet, attacker, mint = Keypair()x3; ix = system_program.transfer(from=wallet, to=attacker, lamports=50_000_000_000); ref = Instruction(SystemProgram, <any data>, [AccountMeta(wallet, signer), AccountMeta(mint.pubkey(), writable)]); msg = Message.new_with_blockhash([ix, ref], wallet.pubkey(), Hash.default()); verify_provider_bags_transaction(msg, wallet_address=str(wallet.pubkey()), token_mint=str(mint.pubkey()), action='Bags claim') -> returns verified=True, program_ids=['11111111111111111111111111111111', ...], unknown_program_ids=[]. Expected: rejected (no Bags/Meteora program, pure 50 SOL transfer to a third party).","severity":"medium","snippet":"        reject_unknown=False,","title":"Bags provider transactions are signed after a structural check that accepts any program and any instruction, with no simulation"},{"citation":"resolved","description":"AutonomousSessionConfig.require_simulation (default True) is documented as requiring a passing simulation before approving and the policy engine denies on `require_simulation and not op.simulated` (autonomous_policy.py rule 8). The only production producer of OperationRequest, _require_execute_approval, passes simulated=True unconditionally regardless of whether any simulation ran; transfer_sol, transfer_spl_token, EVM transfers, x402 payments and Bags flows reach it from plain previews that do balance/fee arithmetic only. The control is therefore unenforceable while get_autonomous_session still reports require_simulation=True to the operator. Fix: pass the real simulation outcome (e.g. a successful swap_safety/kamino_safety verification block) and False otherwise, or remove the flag. Merged from audit_economics, audit_permissions, audit_flow, audit_math.","line":573,"path":"agent-wallet/agent_wallet/openclaw_adapter.py","reproduction":"Reproduced: autonomous_session.start_session(AutonomousSessionConfig(enabled=True, allowed_tools={'transfer_sol'}, allowed_networks={'mainnet'}, allow_mainnet=True, allow_any_recipient=True, require_simulation=True)); invoke('transfer_sol', {recipient:'Dest111...', amount:1.0, mode:'execute', purpose:'x'}) with no approval_token over FakeBackend whose preview_native_transfer performs no simulation -> ok=True, send_native_transfer called, session operations=1. Control: AutonomousPolicyEngine.evaluate with simulated=False on the same config -> approved=False, rule='simulation'. Expected: the execute denied with 'operation has no passing simulation and require_simulation is true'.","severity":"medium","snippet":"                        simulated=True,","title":"Autonomous session require_simulation gate is a no-op: the adapter hard-codes simulated=True for every operation"},{"citation":"resolved","description":"_autonomous_signals extracts the first of input_amount_raw / amount_raw / amount_wei / amount_lamports / amount_base_units from the summary and feeds it to the session SpendingLedger, whose caps are configured once per session in lamports (SpendingConfig: max_per_tx_lamports etc.). The session is shared by every tool and network, so 6-decimal USDC base units, 8-decimal WBTC units, 18-decimal wei and 9-decimal lamports are all measured and summed against the same integer. A cap meant as 0.1 SOL (100_000_000) admits a 99 USDC transfer; a cap meant as 100 USDC blocks every ETH transfer above 1e-16 ETH; daily totals across assets are meaningless. Fix: account spend per (network, asset) with per-asset caps or a USD-normalised figure from the existing price providers, and deny when the unit cannot be mapped. Merged from audit_economics, audit_permissions, audit_math.","line":393,"path":"agent-wallet/agent_wallet/openclaw_adapter.py","reproduction":"Reproduced: start_session(AutonomousSessionConfig(enabled=True, allowed_tools={'transfer_spl_token'}, allowed_networks={'mainnet'}, allow_mainnet=True, allow_any_recipient=True, require_simulation=False, spending=SpendingConfig(max_per_tx_lamports=100_000_000, max_daily_lamports=200_000_000))) intending a 0.1 SOL / 0.2 SOL budget. invoke('transfer_spl_token', {recipient:'Dest111...', mint:'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', amount:99.0, decimals:6, mode:'execute', purpose:'x'}) with no approval_token (preview amount_raw=99_000_000) -> #1 ok=True, #2 ok=True (198 USDC sent), #3 denied by the daily cap. Expected: a 99 USDC transfer denied under a 0.1 SOL (~$20) per-tx cap.","severity":"medium","snippet":"        for key in (\"input_amount_raw\", \"amount_raw\", \"amount_wei\", \"amount_lamports\", \"amount_base_units\"):","title":"Autonomous spend caps compare raw units of unrelated assets against one lamport-denominated limit"},{"citation":"resolved","description":"When the 402 challenge is recognised as USDC below DE_MINIMIS_USD_THRESHOLD (2.0, providers/x402.py:1624) x402_pay_request executes the payment with no approval token, no autonomous session and no permission, mainnet included. Nothing counts these payments: no ledger entry, no per-minute rate, no daily cap, no per-merchant counter. The amount is chosen by the remote server whose response body the agent reads next, so a malicious endpoint priced at 1.99 USDC that keeps answering 402 (or exposes many paths) drains USDC indefinitely with zero human involvement. The card-payment analogy in the code relies on issuer-side velocity limits that do not exist here. Fix: route de-minimis payments through a persisted SpendingLedger (USD units, hourly/daily caps, per-endpoint count) and require normal approval once the cap is hit. Merged from audit_permissions, audit_math.","line":4223,"path":"agent-wallet/agent_wallet/openclaw_adapter.py","reproduction":"Reproduced with x402.resolve_payment_preview patched to return a genuine-shape preview for a 1.99 USDC Solana requirement (asset EPjFWdd5..., amount 1990000, x402_amount_display '1.99') and x402.pay_and_fetch patched to record the amount: with autonomous_session.is_active()=False and autonomous_permissions.is_autonomous_approved()=False, 50 consecutive invoke('x402_pay_request', {url:'https://merchant.example/item/<i>', purpose:'x'}) calls with no approval_token all return ok=True; total recorded payment 99.5 USDC. Expected: an aggregate limit to stop the loop or force an approval.","severity":"medium","snippet":"                    de_minimis = x402.is_de_minimis_payment(preview_payload)","title":"x402 de-minimis payments skip every approval gate with no cumulative, rate or session limit"},{"citation":"resolved","description":"sign_wallet_message is the only signing tool outside the preview/prepare/execute + approval_token flow. Its gate is args.get('user_confirmed') is True, a boolean the model writes itself; no approval token, autonomous session or permission is consulted, so neither the host nor the autonomous framework can restrict or audit signatures. On Solana the backend (wallet_layer/solana.py:2312-2316) UTF-8 encodes the string and signs it raw with the funds-holding Ed25519 key with no off-chain-message envelope; on EVM it is forwarded to the message-sign endpoint. A prompt-injected agent can thus produce valid Sign-In-With-Solana/Ethereum logins, off-chain order or permit-style authorisations, or any valid-UTF-8 payload a verifier accepts, silently. AGENTS.md lists user_confirmed among the mandatory safety gates, but an agent-controlled boolean is not a gate. Fix: route message signing through the same host approval (token bound to {tool:'sign_wallet_message', message_sha256, purpose}), and on Solana sign with the off-chain message format (or reject inputs that parse as a transaction message). Merged from audit_economics, audit_permissions, audit_flow, audit_math.","line":6005,"path":"agent-wallet/agent_wallet/openclaw_adapter.py","reproduction":"Reproduced: with no session, no permission and no approval_token, invoke('sign_wallet_message', {message:'api.example wants you to sign in\\nNonce: 1', purpose:'login', user_confirmed:true}) over the adapter -> ok=True and a signature is returned immediately. SolanaWalletBackend.sign_message source confirms `payload = message.encode('utf-8') ...; return b58encode(self.signer.sign_message(payload))` with no prefix. Expected: refusal until a host-issued approval bound to the message hash is supplied, and a domain-separated signing format.","severity":"medium","snippet":"            if tool_name == \"sign_wallet_message\":","title":"sign_wallet_message signs arbitrary bytes with the wallet key gated only by an agent-supplied user_confirmed flag, with no off-chain domain separation on Solana"},{"citation":"resolved","description":"authorize_operation guards its read-modify-write of autonomous_session.json with a threading.Lock, which only serialises threads inside one process. The module exists precisely because each tool call is a separate subprocess, yet there is no file lock (fcntl/msvcrt) and no compare-and-swap on the record. Two CLI processes whose load->evaluate->write windows overlap both pass the operations/ledger checks, both receive signed approval tokens, and the last writer wins, so persisted counters undercount; max_operations, max_txs_per_minute and hourly/daily caps can be exceeded by the concurrency factor. Hosts that run tool calls in parallel, or an agent that fans out calls, hit this window. Fix: take an exclusive advisory file lock around load->evaluate->write, or write with O_EXCL/rename-and-verify retry. Merged from audit_math; the specialist's barrier-based run was not reproducible here in 6-way parallel attempts (the window is a few ms), so the reproduction below widens the window deterministically.","line":43,"path":"agent-wallet/agent_wallet/autonomous_session.py","reproduction":"Reproduced deterministically: start_session(AutonomousSessionConfig(enabled=True, allowed_tools={'transfer_sol'}, allowed_networks={'mainnet'}, allow_mainnet=True, allow_any_recipient=True, require_simulation=False, max_operations=1)). Process A patches autonomous_session._load_record to sleep 1 s after loading (standing in for scheduler delay between the read and the write inside authorize_operation), then calls authorize_operation(OperationRequest(tool_name='transfer_sol', network='mainnet', summary={...}, spend_amount=1, recipient='r', simulated=True)); process B starts 0.5 s later and calls the same without delay. Output: 'fast APPROVED', 'slow APPROVED', 'ops recorded after both: 1 (max_operations=1)'. Expected: exactly one approval and one denial 'operation budget exhausted', with the counter reflecting every approval.","severity":"medium","snippet":"_lock = threading.Lock()","title":"Autonomous session counters are only guarded by a threading lock, so concurrent CLI processes exceed max_operations and spend caps (lost update)"},{"citation":"resolved","description":"config.py defines agent_wallet_max_per_tx_sol, agent_wallet_max_hourly_sol, agent_wallet_max_daily_sol and agent_wallet_max_txs_per_minute, and agent-wallet/.env.example ships them (lines 14-17) as wallet guardrails. Nothing reads them: the only consumer of SpendingLedger is the autonomous session/policy engine, which uses per-session values from start_autonomous_session arguments. The host-approved, intent, permission-toggle and de-minimis execute paths never consult a ledger, so an operator who sets AGENT_WALLET_MAX_PER_TX_SOL=0.1 believing it caps every transaction has no cap. Fix: wire a persisted SpendingLedger built from these settings into _require_execute_approval, or delete the settings and the .env.example lines. Merged from audit_economics.","line":25,"path":"agent-wallet/agent_wallet/config.py","reproduction":"Reproduced by search: `grep -rn 'max_per_tx_sol\\|max_daily_sol\\|max_hourly_sol\\|agent_wallet_max_txs_per_minute' agent-wallet/agent_wallet` returns only the four field definitions at config.py:25-28; no module reads settings.agent_wallet_max_*. Consequently with AGENT_WALLET_MAX_PER_TX_SOL=0.1 and a normal host token for a 3 SOL transfer, invoke('transfer_sol', {..., amount:3, mode:'execute', approval_token:T}) executes. Expected: rejection for exceeding the configured per-tx limit.","severity":"low","snippet":"    agent_wallet_max_per_tx_sol: float = 0","title":"Global spend-limit settings (AGENT_WALLET_MAX_PER_TX_SOL / HOURLY / DAILY / TXS_PER_MINUTE) are parsed but never enforced"},{"citation":"resolved","description":"_write_record uses Path.write_text plus os.replace, so the session envelope (allow-lists, caps, operation counters, spend ledger that gate unattended execution) is created with the process umask, typically 0644, whereas autonomous_permissions.py and the keystore use file_ops.atomic_write_text with mode 0o600. _load_record also trusts any well-formed JSON with no MAC, so any same-user process (including an agent with a file-write tool) can write an envelope with enabled=true, allow_mainnet=true, allow_any_recipient=true and no caps, bypassing the host-token gate start_autonomous_session enforces; other local users can read the allowed recipients and remaining budget. Fix: write via atomic_write_text (0o600) and HMAC the record with the sealed approval secret, rejecting records whose MAC does not verify (same for autonomous_permissions.json). Merged from audit_permissions, audit_math.","line":119,"path":"agent-wallet/agent_wallet/autonomous_session.py","reproduction":"Reproduced: with umask 022, autonomous_session.start_session(AutonomousSessionConfig(enabled=True)) -> stat mode of $OPENCLAW_HOME/autonomous_session.json is 644 while autonomous_permissions.approve_all() produces autonomous_permissions.json at 600. Separately, writing $OPENCLAW_HOME/autonomous_session.json = {version:1, started_at:now, operations:0, spend_entries:[], config:{enabled:true, allowed_tools:['transfer_sol'], allowed_networks:['mainnet'], allow_mainnet:true, allow_any_recipient:true, require_simulation:false, spending:{}, max_operations:0, session_ttl_seconds:0, approval_ttl_seconds:120}} with no start_session call makes autonomous_session.is_active() True and invoke('transfer_sol', {..., amount:3.0, mode:'execute'}) with no approval_token succeeds (send_native_transfer called). Expected: 0600 and an envelope never authorised by a host token ignored.","severity":"low","snippet":"    tmp.write_text(json.dumps(record, separators=(\",\", \":\")), encoding=\"utf-8\")","title":"autonomous_session.json is written with umask permissions (0644) and trusted verbatim on load, unlike every other policy/secret file"},{"citation":"resolved","description":"The generic confirmation-summary key list (lines 1087-1135) copies amount_native, amount_ui, amount_raw, amount_wei and others but not amount_lamports, which preview_native_transfer exposes as the integer amount. _autonomous_signals (line 393) therefore finds no raw amount and reports spend=None, and authorize_operation denies with 'spend amount could not be verified while spend limits are configured' whenever any lamport cap is set. The lamport-denominated SpendingConfig is thus unusable with the one tool actually denominated in lamports, pushing operators to run sessions with caps disabled. Fix: include amount_lamports in the summary key list (which also strengthens the token binding) or derive it from amount_native for Solana native transfers. Merged from audit_permissions.","line":1095,"path":"agent-wallet/agent_wallet/openclaw_adapter.py","reproduction":"Reproduced: start_session(AutonomousSessionConfig(enabled=True, allowed_tools={'transfer_sol'}, allowed_networks={'mainnet'}, allow_mainnet=True, allow_any_recipient=True, require_simulation=False, spending=SpendingConfig(max_per_tx_lamports=5_000_000_000))); invoke('transfer_sol', {recipient:'Dest111...', amount:1.0, mode:'execute', purpose:'x'}) -> ok=False 'autonomous policy denied operation: spend amount could not be verified while spend limits are configured.' Expected: a 1 SOL transfer under a 5 SOL cap approved.","severity":"low","snippet":"            \"amount_native\",","title":"Autonomous sessions with spend caps can never approve transfer_sol: the SOL transfer summary omits amount_lamports so the fail-closed check always denies"},{"citation":"resolved","description":"The adapter's _is_mainnet_network (openclaw_adapter.py:129) treats 'arc' as a real-money EVM network but the policy engine's MAINNET_NETWORKS does not. For a session with allow_mainnet=false and allowed_networks containing 'arc', rule 6 is skipped, the ledger records the spend, the operation counter is incremented and persisted, and a token with mainnet_confirmed=False is issued; _require_execute_approval then rejects it ('approval_token is missing explicit mainnet confirmation'). Funds are safe but the session is charged for operations that never executed, so repeated attempts exhaust max_operations and the daily cap. The two mainnet lists should be one shared definition and counters committed only after the token is accepted. Merged from audit_economics.","line":49,"path":"agent-wallet/agent_wallet/autonomous_policy.py","reproduction":"Reproduced: FakeBackend subclass with chain='evm', network='arc'; start_session(AutonomousSessionConfig(enabled=True, allowed_tools={'transfer_evm_native'}, allowed_networks={'arc'}, allow_mainnet=False, allow_any_recipient=True, require_simulation=False, max_operations=3)); call adapter._require_execute_approval(approval_token=None, tool_name='transfer_evm_native', summary={'operation':'EVM native transfer','network':'arc','recipient':'0xdead','amount_wei':'1'}, action_label='EVM native transfer', backend=be) four times -> errors ['approval_token is missing explicit mainnet confirmation.' x3, 'autonomous policy denied operation: autonomous operation budget exhausted (3 ops)'] with session operations=3. Expected: each attempt denied at rule 6 with no state change.","severity":"low","snippet":"MAINNET_NETWORKS = frozenset({\"mainnet\", \"mainnet-beta\", \"ethereum\", \"base\", \"robinhood\", \"arbitrum\", \"optimism\", \"polygon\"})","title":"Autonomous engine omits 'arc' from MAINNET_NETWORKS, so sessions charge budget for operations the adapter then rejects"},{"citation":"resolved","description":"config.py rejects AGENT_WALLET_MASTER_KEY, AGENT_WALLET_APPROVAL_SECRET and SOLANA_AGENT_PRIVATE_KEY from the runtime environment and AGENTS.md forbids re-adding runtime env secret loading, but the EVM vault password that unlocks the wdk-evm-wallet seed is still accepted from WDK_EVM_WALLET_PASSWORD before the sealed store is consulted. Environment variables leak through /proc/<pid>/environ, crash reports, shell history and every child process the host bridges spawn, which is the exposure the sealed design removed. Fix: drop the env fallback (or reject it like the other three secrets) and rely on the sealed wdk_evm_wallet_password. Merged from audit_math.","line":635,"path":"agent-wallet/agent_wallet/config.py","reproduction":"Reproduced: with WDK_EVM_WALLET_PASSWORD=hunter2 in the environment, agent_wallet.config.resolve_evm_wallet_password() returns 'hunter2' without consulting sealed_keys.json. Expected: a WalletBackendError like _reject_legacy_runtime_secret_env raises for the other legacy env secrets.","severity":"low","snippet":"    direct = os.getenv(\"WDK_EVM_WALLET_PASSWORD\", \"\").strip()","title":"WDK_EVM_WALLET_PASSWORD is read from the runtime environment ahead of the sealed store, contrary to the sealed-secret-only invariant"},{"citation":"resolved","description":"MacKeychainStore.set runs /usr/bin/security add-generic-password with the secret as a `-w <value>` argv element. Process arguments are readable by other processes on the host (ps, Activity Monitor, EDR/audit logs) for the lifetime of the subprocess, so the install-level boot key that unseals sealed_keys.json (approval_secret, master_key, EVM vault password) is briefly disclosed outside the keychain's protection. The Linux (secret-tool via stdin) and Windows (PowerShell via stdin) stores avoid this. Fix: invoke add-generic-password with `-w` and no value so it reads the password from stdin/prompt, matching the other backends. Merged from audit_permissions, audit_flow, audit_math; verified by code reading only (no macOS host available here).","line":213,"path":"agent-wallet/agent_wallet/keystore.py","reproduction":"Code-level reproduction: the argv list built at keystore.py:210-214 is [_SECURITY_BIN, 'add-generic-password', '-s', self.service, '-a', name, '-w', value, '-A'] where value is the boot key. On macOS, run the installer/migration path that calls record/set (e.g. boot_key_migration.migrate_boot_key_to_keystore) while another terminal loops `ps -eo args | grep add-generic-password`; the boot key appears in the captured command line. Expected: no secret material in argv.","severity":"low","snippet":"            \"-w\", value, \"-A\",","title":"macOS keystore passes the boot key on the `security` command line, exposing it to process listings"},{"citation":"resolved","description":"approve_base_swaps and approve_defi_tools delegate to approve_all, and both the adapter (openclaw_adapter.py:4045) and the CLI (openclaw_cli _run_autonomous_permission) call approve_all for whatever scope string is passed. The stored record and status() still describe base_swaps as network='base', tools=[swap_evm_tokens, swap_evm_uniswap_tokens], so a user (or host CLI operator) granting 'Base swaps' actually authorises Solana transfers, x402 payments and every other write tool on every network via authorize_operation. Fix: reject the deprecated scopes with an explicit error pointing to 'all', or restore per-scope enforcement so authorize_operation only covers tools/networks of an enabled scope. Merged from audit_permissions.","line":151,"path":"agent-wallet/agent_wallet/autonomous_permissions.py","reproduction":"Reproduced: invoke('agentlayer_autonomous_approve', {scope:'base_swaps', purpose:'x', user_intent:true}) -> ok=True; autonomous_permissions.status()['scopes']['base_swaps']['tools'] == ['swap_evm_tokens','swap_evm_uniswap_tokens']; then invoke('transfer_sol', {recipient:'Dest111...', amount:2.0, mode:'execute', purpose:'x'}) on the Solana mainnet backend with no approval_token -> ok=True and send_native_transfer called. Expected: a Base-swap grant to authorise only Base swap tools.","severity":"low","snippet":"    return approve_all(approved_by=approved_by)","title":"Deprecated narrow permission scopes (base_swaps, defi_tools) silently grant the full 'all' group while status() reports the narrow scope"}],"hash":"0b034c2ba4ddb7a7560e1d8c8edb430d06293ced43fa72bd9ec0f53e61698cb0","nodeId":"1ee02a85-7ad8-4ee0-8591-66b165816fde","outcome":"completed","summary":"The review is complete and the findings file is written and validated.\n\n**Outcome:** `.imd-findings.json` at the repository root holds 19 findings, every one reproduced against the code at this commit (2 critical, 4 high, 6 medium, 7 low). All 42 specialist findings were checked; duplicates across the four specialists were merged, and all path, line and snippet references were verified against the tree.\n\n**What reproduced and was kept**\n\n- **Critical:** the agent can grant itself the standing all-tools permission with only `user_intent: true`, after which every execute tool self-issues mainnet-confirmed tokens. Separately, the `issue_wallet_approval` handler lets the agent mint host approval tokens for any tool, and it is reachable on Solana backends even though it is only listed for EVM.\n- **High:** approval tokens replay across the per-call CLI subprocesses for their full TTL (three fresh processes each executed the same 1 SOL approval). Two independent x402 de-minimis bypasses: a hostile server can label any asset as USDC via `extra.name`, and an agent-supplied `_approved_preview` is trusted verbatim for both the amount check and the signed payment. The swap simulation gate ignores drains of other tokens and native SOL.\n- **Medium:** Bags transactions are signed with no program enforcement or simulation, `require_simulation` is hard-coded to true, spend caps mix lamports with USDC/wei units, de-minimis payments have no cumulative cap (50 unapproved payments totalling 99.5 USDC), message signing is gated only by an agent flag, and session counters have no cross-process lock.\n- **Low:** unused global spend settings, session file written 0644 and trusted without a MAC, SOL transfers unapprovable under lamport caps, `arc` missing from the engine's mainnet list, EVM vault password accepted from the environment, macOS keystore passing the boot key in argv, and deprecated scopes silently granting everything.\n\n**What I changed or dropped**\n\n- The concurrency race did not reproduce with a plain barrier, so I replaced the specialist's reproduction with a deterministic two-process lost-update run and noted that in the finding.\n- Host-bridge findings in `.openclaw/`, `hermes/` and `codex/` were not kept as separate findings because they are outside the audited `agent-wallet/` directory. Their relevance is noted inside the related findings.\n- The macOS keystore finding was verified by code reading only, since no macOS host was available; the finding says so.\n\nNo Foundry proofs were attached since this is a Python codebase, not Solidity. No repository files were modified other than the findings file.","treeHash":null,"usage":{"cachedInputTokens":3633619,"inputTokens":996,"model":"claude-fable-5-1","outputTokens":49289,"runtime":"claude","turns":33,"wallClockMs":919733}},{"artifacts":[],"attempt":2,"bundleHash":null,"device":"3baa2091028c32c1","findings":[{"citation":"resolved","description":"`agentlayer_autonomous_approve` is an ordinary agent-facing tool (registered in list_tools via _autonomous_permission_tool_specs and exposed verbatim by the OpenClaw extension). Its only gate is the agent-supplied argument `user_intent: true` (line 4041); no host-issued approval_token is required, unlike `start_autonomous_session` (line 4096) whose comment states 'an agent must not be able to widen its own envelope'. Once approve_all() has written autonomous_permissions.json, `_require_execute_approval` (line 585-590) falls through to `autonomous_permissions.authorize_operation`, which mints a token for ANY tool on ANY network with `mainnet_confirmed=True` and no per-tool, recipient, or spend limit. The AGENTS.md invariant 'execute must continue requiring a host-issued approval_token' is therefore defeated by two agent tool calls. A prompt-injected agent (e.g. via connector__ output, which the runtime instructions explicitly describe as untrusted) can drain the wallet with transfer_sol / transfer_evm_native etc.","line":4045,"path":"agent-wallet/agent_wallet/openclaw_adapter.py","reproduction":"State: sealed approval_secret present, no autonomous session, backend network=mainnet. Input 1: adapter.invoke('agentlayer_autonomous_approve', {'scope':'all','purpose':'x','user_intent':True}) -> ok=True, active=True (no token was asked for). Input 2: adapter._require_execute_approval(approval_token=None, tool_name='transfer_sol', summary={'operation':'SOL transfer','network':'mainnet','to_address':'<attacker>','amount_native':5.0}, action_label='SOL transfer'). Expected: WalletBackendError 'execution requires a host-issued approval_token'. Actual: returns normally (gate passed); the same call before step 1 raised the error. Verified with a stub backend: 'before grant -> WalletBackendError ...; approve tool ok = True active = True; after grant -> mainnet execute gate PASSED with no host-issued token'. Fix: require a host-issued approval token bound to {'operation':'agentlayer_autonomous_approve','scope':...} exactly like start_autonomous_session, or remove the tool from the agent-facing list and keep approval only on the host CLI path (`openclaw_cli autonomous-permission --action approve`).","severity":"critical","snippet":"                data = autonomous_permissions.approve_all(","title":"Agent can grant itself the all-tools autonomous permission and execute mainnet writes without any host-issued approval token"},{"citation":"resolved","description":"`require_single_use` records consumed token fingerprints only in a module-level in-memory dict. Every host bridge runs `python -m agent_wallet.openclaw_cli invoke` as a fresh subprocess per tool call (autonomous_session.py docstring, .openclaw index.ts callWalletCli, hermes tools.py `_call_wallet_cli`, codex server.py), so the registry is empty on every execute. A token the host issued for one exact transfer (same recipient/amount summary) is accepted again and again until `exp` (settings.agent_wallet_approval_ttl_seconds = 600). `_run_invoke` explicitly lets a caller-provided approval_token win (openclaw_cli.py line 209-219), and Hermes returns the token string to the agent (`agent_wallet_approve`), so an agent that was approved for one 1 SOL transfer can send it N times within 10 minutes. The token payload carries a `jti` that is never persisted or checked.","line":48,"path":"agent-wallet/agent_wallet/nonce_registry.py","reproduction":"State: host issues one token T = issue_approval_token(tool_name='transfer_sol', network='mainnet', summary=S, mainnet_confirmed=True). Input: in three separate python processes call adapter._require_execute_approval(approval_token=T, tool_name='transfer_sol', summary=S, action_label='SOL transfer'). Expected: first accepted, later ones rejected 'Approval token has already been used'. Actual: 'execute #1 in a fresh process -> accepted', '#2 -> accepted', '#3 -> accepted'; only a second use inside the same process is rejected. Fix: persist consumed `jti`/fingerprints under OPENCLAW_HOME (atomic file like autonomous_session.json) with expiry = token exp, and check it in require_single_use.","severity":"high","snippet":"_registry = NonceRegistry()","title":"Approval-token single-use registry is per-process, so a token can be replayed across CLI subprocesses for its whole TTL (default 600 s)"},{"citation":"resolved","description":"`is_de_minimis_payment` (line 1643) skips `_require_execute_approval` entirely in x402_pay_request (openclaw_adapter.py 4222-4235) when `x402_amount_display` < 2 USD. That display value is produced by `_normalize_amount_hint`, which treats the amount as 6-decimal USDC whenever `_looks_like_usdc` is true, and `_looks_like_usdc` accepts the server-controlled `extra.name` field of the PAYMENT-REQUIRED requirement without checking the actual `asset`. `_requirement_compatibility` (line 472-530) only checks scheme and network, never the asset, and `_create_payment_headers` hands the selected requirement to the x402 SDK which signs a transfer of `amount` base units of whatever `asset` the server declared. A malicious or compromised x402 endpoint therefore gets 1,999,999 base units of any SPL/ERC-20 asset in the wallet (for a 2-decimal token that is 19,999.99 tokens; for 6-decimal non-USD stable/wrapped assets whatever they are worth) with no host token, no autonomous session, and no user confirmation. Repeated calls are not rate limited.","line":287,"path":"agent-wallet/agent_wallet/providers/x402.py","reproduction":"Input: requirement = {'scheme':'exact','network':'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp','asset':'Rwa2DecimalMint1111111111111111111111111111','amount':'1999999','payTo':'<attacker>','extra':{'name':'USDC'}}; n = normalize_payment_requirement(requirement, source='x402-http'); is_de_minimis_payment({'x402_amount_display': n['amount_display']}). Expected: amount_display None (asset is not USDC) and de-minimis False, so approval is required. Actual: amount_display '1.999999', de_minimis_usd_amount 1.999999, is_de_minimis_payment True -> x402_pay_request pays without calling _require_execute_approval. The same requirement without extra.name yields amount_display None and de_minimis False. Fix: only derive amount_display when `asset` itself is a known USDC contract/mint for that CAIP network (ignore extra.name), and consider a cumulative per-hour de-minimis cap.","severity":"high","snippet":"    return _trim(extra.get(\"name\")).lower() in _USDC_IDENTIFIERS","title":"x402 de-minimis bypass: a 402 server can label any asset as USDC through `extra.name` and the payment is signed with no approval at all"},{"citation":"resolved","description":"`x402_pay_request` reads `_approved_preview` straight from the tool arguments (which come from the agent in Hermes `_call_wallet_cli` and in any host that forwards params) and passes it to `x402.resolve_payment_preview` / `pay_and_fetch`. `_reusable_approved_preview` (x402.py 1592-1615) returns the dict unchanged as long as `request_fingerprint` (a computable SHA over method/URL/body), a `selected_payment` object and a `response_headers['payment-required']` string are present. No fresh probe is made, so `x402_amount_display`, `selected_payment.amount/pay_to` and the PAYMENT-REQUIRED header that will be decoded and signed are all caller-chosen. Setting `x402_amount_display` to '0.01' makes `is_de_minimis_payment` true and the adapter skips `_require_execute_approval`, while `_create_payment_headers` signs the payment described by the supplied header/selected_payment (e.g. 250,000 USDC to an attacker payTo). This is a preview-vs-execute mismatch: the gate reads one field, the signer reads another, both untrusted.","line":4201,"path":"agent-wallet/agent_wallet/openclaw_adapter.py","reproduction":"Input: url='https://pay.example.test/report'; crafted = {'payment_required':True,'request_fingerprint':_build_request_metadata(url=url,method='GET')['request_fingerprint'],'selected_payment':{'scheme':'exact','network':'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp','asset':'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v','amount':'250000000000','pay_to':'<attacker>'},'x402_amount_display':'0.01','response_headers':{'payment-required':'<attacker-encoded header>'}}; resolved = await resolve_payment_preview(backend, url=url, method='GET', approved_preview=crafted). Expected: the preview is re-probed from the live server, or at least amount_display is recomputed from selected_payment. Actual: resolved == crafted (returned verbatim, no network call), is_de_minimis_payment(resolved) is True, so the adapter never calls _require_execute_approval and pay_and_fetch proceeds to sign from the crafted header. Fix: never accept `_approved_preview` from tool arguments (strip it in the adapter and only let the host bridge attach it out-of-band), and derive the de-minimis decision from the freshly probed requirement rather than a cached display string.","severity":"high","snippet":"                approved_preview = args.get(\"_approved_preview\")","title":"Agent-supplied `_approved_preview` is reused verbatim by x402_pay_request, letting the caller set the amount that decides whether approval is needed"},{"citation":"resolved","description":"`verify_provider_swap_transaction` deliberately accepts unknown program ids (reject_unknown=False, and the SPL Token and System programs are in CORE_PROGRAM_IDS anyway), stating that the simulation check is 'the stronger swap-specific safety gate'. But `verify_provider_swap_simulation_result` looks only at the delta of `input_mint` and `output_mint`, and at the native lamport delta only when input_mint is wrapped SOL. Any other wallet token delta and, for token-input swaps, the native SOL delta are computed but ignored (lines 357-371, 334-335). A compromised or spoofed swap provider response (JUPITER_API_BASE_URL / jupiterUltraBaseUrl / provider gateway are plugin-configurable, and `prepare_swap_from_preview` trusts `preview['quote_response']['transaction']`) can append SPL transfer / System transfer / SPL approve instructions and the wallet still signs (solana.py line 5162) and `_submit_prepared_swap` broadcasts. If simulation errors with ProviderError the code proceeds with no balance check at all (solana.py 5196-5206, 'Proceeding with structural provider verification to preserve swap availability').","line":357,"path":"agent-wallet/agent_wallet/transaction_policy.py","reproduction":"Input: verify_provider_swap_simulation_result(simulation, wallet_address=W, wallet_account_index=0, input_mint=USDC, output_mint=JUP, input_amount_raw=10_000_000, minimum_output_amount_raw=100_000_000) where simulation = {'err':None,'preBalances':[50_000_000_000,0],'postBalances':[10_000_000,0],'preTokenBalances':[USDC 10_000_000, JUP 0, BONK 5_000_000_000_000 all owned by W],'postTokenBalances':[USDC 0, JUP 100_000_000, BONK 0]}. Expected: rejected (wallet loses 49.99 SOL and all BONK beyond the approved 10 USDC). Actual: returns verified=True, enforced_checks=['simulation_err_is_none','token_input_spend_within_approved_amount','token_output_meets_approved_minimum'] while its own token_deltas show BONK -5000000000000 and native_delta_lamports -49990000000. Fix: reject any negative delta for mints other than input_mint, bound the native delta to fee allowance when input is not SOL, and fail closed (not open) when simulation is unavailable.","severity":"high","snippet":"        input_delta = token_deltas.get(input_mint)","title":"Provider swap simulation check only inspects input/output mints, so a provider-built transaction that also drains other tokens or native SOL is signed and broadcast"},{"citation":"resolved","description":"`verify_provider_bags_transaction` calls `_assert_program_allowlist` with `reject_unknown=False`, so the allow-list is advisory; the only hard requirements are that the wallet is a required signer, at most two signers exist, and the token mint string appears somewhere in the account keys. `_prepare_bags_transactions` (solana.py 1362-1381) then signs every transaction returned by the Bags API with the wallet key and `_execute_prepared_bags_transactions` broadcasts them, without running simulateTransaction or any balance-delta check (unlike the swap and Kamino paths). A compromised or MITM'd Bags API response (bags.fm, also reachable via the provider gateway) can therefore have the wallet sign a transaction whose only instruction is SystemProgram::Transfer of the whole SOL balance, or SPL Transfer/Approve of any token, as long as the expected mint is listed as an (unused) account.","line":432,"path":"agent-wallet/agent_wallet/transaction_policy.py","reproduction":"Input: a message with header.num_required_signatures=1, account_keys=[WALLET, ATTACKER, EXPECTED_MINT, SystemProgram], instructions=[CompiledInstruction(program_id_index=3, accounts=[0,1], data=Transfer(50 SOL))]; verify_provider_bags_transaction(message, wallet_address=WALLET, token_mint=EXPECTED_MINT, action='Bags claim'). Expected: rejected (no Bags/Meteora program, pure SOL transfer to a third party). Actual: returns verified=True, program_ids=['11111111111111111111111111111111'], unknown_program_ids=[], and the caller signs it. Fix: require at least one recognized Bags/DBC/Meteora program id (as the Kamino verifier does), reject unknown programs, and simulate with the same balance-delta verification used for swaps before signing.","severity":"medium","snippet":"        allowed_programs=CORE_PROGRAM_IDS,","title":"Bags provider transactions are signed after a structural check that accepts any program and any instruction, with no simulation or balance check"},{"citation":"resolved","description":"`_require_execute_approval` builds the OperationRequest for the autonomous policy engine with `simulated=True` unconditionally, regardless of whether any simulation/dry-run ran for the tool (transfer_sol, transfer_evm_native, x402_pay_request, Bags, etc. perform none). AutonomousSessionConfig documents require_simulation as 'the autonomous analogue of a human eyeballing the preview' and defaults it to True, and the engine denies on `require_simulation and not op.simulated` (autonomous_policy.py line 248). Operators who rely on that control get no protection.","line":573,"path":"agent-wallet/agent_wallet/openclaw_adapter.py","reproduction":"State: autonomous_session.start_session(AutonomousSessionConfig(enabled=True, allowed_tools={'transfer_sol'}, allowed_networks={'mainnet'}, allow_mainnet=True, allow_any_recipient=True, require_simulation=True)). Input: adapter._require_execute_approval(approval_token=None, tool_name='transfer_sol', summary={'operation':'SOL transfer','network':'mainnet','to_address':'Dest','amount_native':1.0,'amount_lamports':1000000000}, action_label='SOL transfer') for a path that never simulated. Expected: denied with rule 'simulation' (the engine returns approved=False rule='simulation' when given simulated=False). Actual: approved and session operations counter becomes 1. Fix: pass the real simulation outcome (e.g. from swap_safety/kamino_safety 'verified', or False when the tool has no simulation) instead of the constant.","severity":"medium","snippet":"                        simulated=True,","title":"Autonomous session `require_simulation` is dead: adapter hard-codes simulated=True for every operation"},{"citation":"resolved","description":"Outside agent-wallet/ but it is what the Python gate's 'host-issued token after explicit user confirmation' assumption rests on. `attachApprovalForExecute` looks up `latestCachedPreview(userId, toolName)` (any preview/prepare/intent_preview response from the last 15 minutes, cached automatically in `cachePreviewForApproval`) and immediately runs `issue-approval` with that summary and `--mainnet-confirmed` whenever the agent calls the same tool with mode=execute. The human confirmation is only the LLM's chat behaviour (OPENCLAW_EXECUTE_APPROVAL_GUIDANCE). The same path mints the token for `start_autonomous_session` execute, which the Python side explicitly reserves for 'a genuine host token'. A prompt-injected agent can therefore preview and execute any mainnet write, or start an unbounded autonomous session, in two tool calls.","line":519,"path":".openclaw/extensions/agent-wallet/index.ts","reproduction":"State: OpenClaw plugin loaded, user never interacted. Input: agent calls transfer_sol {mode:'preview', recipient:A, amount:5, purpose:'x'} then transfer_sol {mode:'execute', recipient:A, amount:5, purpose:'x'} with no approval_token. Expected: execute fails with APPROVAL_CONTEXT_MISSING_MESSAGE until a human approves the shown summary. Actual: lines 517-525 find the cached preview, call `issue-approval --mainnet-confirmed`, inject approval_token into params, and the Python gate passes. Fix: gate issueApprovalToken behind a host-side confirmation primitive (OpenClaw approval prompt/button) or at least require the user's confirming message to be passed in and validated by the host, never derived solely from the agent's execute call.","severity":"medium","snippet":"    effectiveParams.approval_token = await issueApprovalToken(","title":"OpenClaw host auto-issues the execute approval token from the agent's own cached preview, so no human step exists between preview and execute"},{"citation":"resolved","description":"Outside agent-wallet/ but it feeds the Python gate. `agent_wallet_approve` is an agent-callable Hermes tool whose only check is `user_confirmed is True` in the tool arguments; it then runs `openclaw_cli issue-approval --mainnet-confirmed` with whatever confirmation_summary the agent passes and returns the raw approval_token string to the agent. Combined with the per-process nonce registry (finding above) the agent can also reuse the token. The Python invariant 'execute must continue requiring a host-issued approval_token' becomes 'the agent must set a boolean'.","line":403,"path":"hermes/plugins/agent_wallet/tools.py","reproduction":"Input: agent_wallet_approve({'tool_name':'transfer_sol','confirmation_summary':<summary copied from a preview>,'mainnet_confirmed':True,'user_confirmed':True}) followed by agent_wallet_invoke({'tool_name':'transfer_sol','arguments':{...,'mode':'execute','approval_token':<returned token>}}). Expected: token issuance requires an out-of-band human action. Actual: token is returned to the agent and execute succeeds. Fix: issue tokens only from a Hermes-side confirmation UI/hook that the model cannot invoke, and never return the token string to the model.","severity":"medium","snippet":"    if args.get(\"user_confirmed\") is not True:","title":"Hermes bridge lets the agent mint host approval tokens itself, gated only by an agent-asserted user_confirmed flag"},{"citation":"resolved","description":"The tool's only gate is the agent-asserted `user_confirmed: true` argument; it does not go through `_require_execute_approval`, so no host token, autonomous session or permission is consulted. The Solana backend (`SolanaWalletBackend.sign_message`, solana.py 2312-2316) signs the UTF-8 bytes directly with ed25519 and no Solana off-chain-message prefix. A prompt-injected agent can therefore produce valid Sign-In-With-Solana / dApp login signatures, or any authorization message a third-party service accepts, without any host involvement. Forging an on-chain transaction this way is not practical (a serialized message would have to be valid UTF-8), so impact is limited to off-chain authorizations.","line":6017,"path":"agent-wallet/agent_wallet/openclaw_adapter.py","reproduction":"Input: adapter.invoke('sign_wallet_message', {'message':'<SIWS payload for attacker.example>', 'purpose':'login', 'user_confirmed':True}) with no approval_token and no autonomous grant. Expected: refused until a host-issued approval bound to the message is supplied, or at least signed with the '\\xffsolana offchain' domain prefix. Actual: returns a base58 ed25519 signature over the raw bytes. Fix: route the tool through _require_execute_approval with summary={'operation':'sign message','message_sha256':...} and sign using the Solana off-chain message format.","severity":"low","snippet":"                signature = await self.backend.sign_message(message.strip())","title":"sign_wallet_message signs raw agent-chosen bytes with no approval token and no off-chain message domain separation"},{"citation":"resolved","description":"`MacKeychainStore.set` passes the boot key as an argv element to `/usr/bin/security add-generic-password -w <value>`. Command-line arguments are visible to every process of every user on the machine via `ps`/`proc_pidinfo` for the lifetime of the subprocess (and may be captured by endpoint tooling or shell/audit logs). The Linux and Windows backends correctly use stdin. `security add-generic-password` supports reading the password interactively or via `-w` only, so the fix is to prefer the stdin-driven `-w` prompt variant (omit the value, feed it on stdin) or at minimum document the exposure.","line":213,"path":"agent-wallet/agent_wallet/keystore.py","reproduction":"State: macOS, AGENT_WALLET_KEYSTORE_BACKEND=auto, first install (migrate_boot_key_to_keystore). Input: run `ps -A -o args | grep add-generic-password` from another local account while install runs. Expected: no secret material visible. Actual: `/usr/bin/security add-generic-password -s ai.agentlayer.wallet -a boot_key -w <boot key> -A` is listed with the full boot key, which unseals sealed_keys.json (approval_secret, master_key, EVM vault password).","severity":"low","snippet":"            \"-w\", value, \"-A\",","title":"macOS keystore writes the boot key on the `security` command line, exposing it to process listings"}],"hash":"7af468622dce6b10ea363653cd13fd7b8c2e93021a4277bf496fb665b97292e7","nodeId":"a65ab0a1-31bf-4bee-95cf-8d361d1bf762","outcome":"completed","summary":"The review is complete. The findings file is valid JSON, every path/line/snippet matches the tree, and the working tree is otherwise untouched (git reports the findings file only, since it is the sole addition).\n\n**Outcome:** `.imd-findings.json` holds 11 substantiated findings for the agent-wallet authorization, key-handling and signing surface. Each was reproduced by executing the real code against a stub backend in an isolated OPENCLAW_HOME with a sealed approval secret. No Foundry proofs apply: the codebase is Python and there is no foundry.toml.\n\n**Confirmed defects, by severity**\n\n- **Critical, agent self-grants autonomy.** `agentlayer_autonomous_approve` is an agent-facing tool gated only by an agent-supplied `user_intent: true`. After one call, `_require_execute_approval` mints tokens for any tool on any network with mainnet confirmed. A mainnet transfer gate that refused before the call passes after it.\n- **High, token replay.** The single-use registry is an in-process dict, but every host runs the CLI as a fresh subprocess. One host-issued transfer token was accepted in three separate processes; only a same-process reuse was rejected. Default TTL is 600 seconds.\n- **High, x402 USDC spoof.** A 402 server can set `extra.name: \"USDC\"` on any asset. The amount is then displayed as 6-decimal USDC, falls under the 2 USD de-minimis floor, and is paid with no approval of any kind.\n- **High, crafted `_approved_preview`.** The adapter reads `_approved_preview` from tool arguments and the x402 layer returns it verbatim. The de-minimis gate reads one caller-chosen field while the signer uses another caller-chosen requirement.\n- **High, swap simulation blind spots.** The balance check inspects only input and output mints. A simulation showing a full drain of a third token plus 49.99 SOL returned `verified: True`. Simulation failure also falls open.\n- **Medium.** Bags transactions are signed after a structural check that accepted a pure SOL transfer to a third party; `require_simulation` is dead because the adapter hard-codes `simulated=True`; the OpenClaw host auto-issues execute tokens from the agent's own cached preview; the Hermes bridge lets the agent mint tokens behind a self-asserted flag.\n- **Low.** `sign_wallet_message` signs raw bytes with no token and no off-chain domain prefix; the macOS keystore passes the boot key on the `security` command line.\n\n**Coverage notes.** The in-scope directory was reviewed in depth: approval, nonce, autonomous session and permission modules, the adapter gates and summary builders, transaction policy, encrypted storage, sealed keys, keystore, config secret resolution, telemetry, the WDK EVM client and the Solana signing paths. Two findings sit in the host bridges outside agent-wallet; they are included because they are what the Python gate's \"host-issued token\" assumption rests on. Telemetry, sealed-key encryption, the user-wallet envelope and the WDK localhost/bearer transport showed no defects I could substantiate. The Kamino and EVM DeFi intent paths were traced only far enough to confirm they reuse the gates above.","treeHash":null,"usage":{"cachedInputTokens":3817589,"inputTokens":614,"model":"claude-fable-5-1","outputTokens":57323,"runtime":"claude","turns":98,"wallClockMs":777592}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"2e0556e0bee1a109","findings":[{"citation":"resolved","description":"The agent-facing tool `agentlayer_autonomous_approve` enables the standing 'all' autonomous permission group using only agent-controlled arguments (`scope`, `purpose`, `user_intent=true`). Unlike `start_autonomous_session` (lines 4087-4103), which explicitly requires a host-issued approval token because 'an agent must not be able to widen its own envelope', this tool performs no host-token verification at all. Once the group is on, `_require_execute_approval` (lines 583-590) mints an approval token for ANY execute tool on ANY network with `mainnet_confirmed=True` via `autonomous_permissions.authorize_operation`, which has no per-tool, per-network, recipient, spend or TTL limit. The tool is registered for the model in every host (OpenClaw index.ts line 729, Codex/Claude Code MCP server, Hermes bridge), so a prompt-injected or misbehaving agent can remove every human approval gate with a single call and then drain the wallet (transfer_sol, transfer_evm_native, call_evm_contract, x402 payments...). The Claude Code slash command marks itself disable-model-invocation, but the underlying MCP tool remains model-callable. The Python backend is documented (AGENTS.md) as the authoritative policy gate, so this gate must not rely on the agent's own `user_intent` flag. Fix: require a host-issued approval token bound to a summary like {'operation':'agentlayer_autonomous_approve','scope':...} (mirror the start_autonomous_session preview/execute flow), or move `approve_all` to a host-only CLI command and remove the agent-facing tool from `invoke`.","line":4041,"path":"agent-wallet/agent_wallet/openclaw_adapter.py","reproduction":"Fresh install with sealed approval_secret, autonomous permissions revoked, EVM backend on ethereum. 1) adapter._require_execute_approval(approval_token=None, tool_name='transfer_evm_native', summary=S) -> raises 'execution requires a host-issued approval_token' (expected). 2) adapter.invoke('agentlayer_autonomous_approve', {'scope':'all','purpose':'x','user_intent':True}) -> ok=True, active=True (no approval_token argument exists or is checked). 3) adapter._require_execute_approval(approval_token=None, ...) now PASSES for transfer_evm_native@ethereum, transfer_sol@mainnet and call_evm_contract@base. Expected: step 2 is rejected without a host-issued token; actual: the agent enabled unbounded autonomous execution on mainnet by itself. Reproduced with the module directly (script output: 'step3: ... execute gate PASSED with no host token').","severity":"critical","snippet":"                if args.get(\"user_intent\") is not True:\n                    raise WalletBackendError(\n                        \"agentlayer_autonomous_approve requires user_intent=true after the user explicitly asks for this permission.\"\n                    )\n                data = autonomous_permissions.approve_all(\n                    approved_by=\"agentlayer_autonomous_approve\"\n                )","title":"Agent can self-grant unbounded autonomous execution via agentlayer_autonomous_approve (no host token)"},{"citation":"resolved","description":"`require_single_use` records consumed token fingerprints in a module-level in-memory dict. `openclaw_cli.py` is spawned as a new Python process for every `invoke` (OpenClaw extension, Codex/Claude Code MCP server and Hermes bridge all shell out per call, and autonomous_session.py's own docstring states the CLI is 'a fresh subprocess per tool call'). The registry is therefore empty on every execute, and `verify_approval_token` (approval.py lines 85-121) never checks `jti` against any persisted store. A host-issued token stays valid for its full TTL (default `agent_wallet_approval_ttl_seconds = 600`, config.py line 19; Hermes allows up to 3600 s). The Hermes bridge returns the token to the agent (`agent_wallet_approve`) and the agent passes it to `agent_wallet_invoke`; the OpenClaw bridge also accepts an agent-supplied `approval_token` (index.ts line 544). An agent that was approved once for 'send 1 ETH to X' can resend the identical transfer every few seconds for ten minutes: the binding {tool, network, summary} is identical, so verification passes each time and the wallet is drained N-fold. The adapter comment at openclaw_adapter.py:603 ('Enforce single-use: reject replayed approval tokens') is false for the production deployment model. Fix: persist consumed `jti` values (with exp) under OPENCLAW_HOME behind a file lock and check them in `verify_approval_token`; optionally shorten the default TTL.","line":46,"path":"agent-wallet/agent_wallet/nonce_registry.py","reproduction":"Issue one token: issue_approval_token(tool_name='transfer_evm_native', network='ethereum', summary=S, mainnet_confirmed=True). Run three separate python processes each doing verify_approval_token(token, ...) followed by require_single_use(token): all three print ACCEPTED (rc=0). In the same process a second require_single_use raises 'Approval token has already been used'. Expected: the second and third process reject the token; actual: every process accepts it, so three identical transfers execute from one human approval.","severity":"high","snippet":"\n# Module-level singleton — shared across the process.","title":"Approval tokens are replayable: single-use registry is in-memory while every CLI tool call is a fresh process"},{"citation":"resolved","description":"`authorize_operation` guards its read-modify-write of autonomous_session.json with `_lock = threading.Lock()` (line 43), which only serialises threads inside one process. The module docstring says the whole point of the file is to enforce limits 'across every subprocess', yet there is no file lock (fcntl/msvcrt) and no compare-and-swap on the record. Two or more CLI processes started in parallel (hosts such as Claude Code run independent tool calls concurrently, and an agent can deliberately fan out) all load the same record, all pass the `operations`/ledger checks, all receive signed approval tokens, and the last writer wins, so the persisted counters undercount. max_operations, max_txs_per_minute, hourly and daily lamport caps can all be exceeded by a factor equal to the concurrency. Fix: take an exclusive advisory file lock (e.g. fcntl.flock on a sidecar lock file) around load->evaluate->write, or write the record with O_EXCL rename-and-verify retry.","line":192,"path":"agent-wallet/agent_wallet/autonomous_session.py","reproduction":"Start a session with max_operations=1 (allow_any_recipient, require_simulation=False, network base). Fork 4 processes that wait on a Barrier and then call autonomous_session.authorize_operation(OperationRequest(tool_name='transfer_evm_native', network='base', spend_amount=1, recipient='0xdead...', simulated=True)). Observed on the first trial: 4/4 processes returned an approval token and session_status()['operations'] == 1 afterwards. Expected: exactly 1 approval and 3 denials with 'operation budget exhausted'.","severity":"high","snippet":"    with _lock:\n        record = _load_record()","title":"Autonomous session caps (max_operations, spend limits, rate limit) are bypassed by concurrent CLI invocations"},{"citation":"resolved","description":"`AutonomousSessionConfig.require_simulation` defaults to True and is documented as 'the autonomous analogue of a human eyeballing the preview' (autonomous_policy.py lines 89-92, rule 8 at lines 247-249). The only caller that builds an `OperationRequest`, `_require_execute_approval`, passes `simulated=True` unconditionally regardless of whether any preview/simulation ran or succeeded for the summary being approved. Several execute paths reach this point without a simulation (e.g. EVM native/token transfers pass an agent-supplied `_approved_preview` dict at lines 6160-6167, x402 payments, Solana intents whose simulation happens later or not at all). The operator-facing safety control therefore provides no protection, while the session status still reports require_simulation=True. Fix: carry an explicit simulation result (e.g. preview payload field `simulation.ok`) into `_require_execute_approval` and pass `simulated=bool(result)`; deny when unknown.","line":566,"path":"agent-wallet/agent_wallet/openclaw_adapter.py","reproduction":"Start a session with require_simulation=True, allowed_tools={'transfer_evm_native'}, allowed_networks={'base'}, allow_mainnet=True, allow_any_recipient=True. Call adapter._require_execute_approval(approval_token=None, tool_name='transfer_evm_native', summary={'operation':'EVM native transfer','network':'base','recipient':'0xdead...','amount_wei':'1'}, ...) with no preview or simulation having been executed for that summary. Expected: denied with 'operation has no passing simulation'. Actual: approved and a signed token is minted (script output '(a) ... approved an operation with NO simulation evidence -> PASSED').","severity":"high","snippet":"                approval_token = autonomous_session.authorize_operation(\n                    OperationRequest(\n                        tool_name=tool_name,\n                        network=network,\n                        summary=summary,\n                        spend_amount=spend,\n                        recipient=recipient,\n                        simulated=True,\n                    )\n                )","title":"require_simulation policy is a no-op: adapter hardcodes simulated=True for every autonomous operation"},{"citation":"resolved","description":"`_autonomous_signals` feeds whichever of input_amount_raw / amount_raw / amount_wei / amount_lamports / amount_base_units is present straight into a `SpendingLedger` whose limits are defined in lamports (`max_per_tx_lamports` etc.). The ledger also sums these heterogeneous numbers into one hourly/daily total. Consequences: (1) a cap meant as '1 SOL' (1_000_000_000) allows a 1,000 USDC transfer (6 decimals) or a 1,000 USDC Solana SPL transfer/swap, i.e. roughly 7x the intended value, and allows 1e9 base units of any high-value low-decimal token; (2) the same cap blocks a 0.01 ETH native transfer (1e16 wei), so operators are pushed to raise the cap, which widens the token-side hole further; (3) daily totals are meaningless across assets. This is a numerical-gap defect in the limit check rather than a bypass of a gate, but it means the spend envelope the human authorised is not what is enforced. Fix: normalise to a common unit (USD via the existing pricing providers, or per-asset caps keyed by chain+asset) before check_and_record, and deny when the asset's decimals/price are unknown.","line":392,"path":"agent-wallet/agent_wallet/openclaw_adapter.py","reproduction":"Session with SpendingConfig(max_per_tx_lamports=1_000_000_000), allowed_tools={'transfer_evm_token','transfer_evm_native'}, network base, allow_any_recipient=True, require_simulation=False. (b1) summary for transfer_evm_token with token_address=USDC(Base) and amount_raw='1000000000' (1,000 USDC) -> approved. (b2) summary for transfer_evm_native with amount_wei='10000000000000000' (0.01 ETH) -> denied 'Transaction exceeds per-tx limit: 10000000000000000 > 1000000000 lamports (1.0000 SOL)'. Expected: a ~$150 cap rejects the $1,000 transfer and accepts the $25 one; actual is the reverse.","severity":"medium","snippet":"        spend: int | None = None\n        for key in (\"input_amount_raw\", \"amount_raw\", \"amount_wei\", \"amount_lamports\", \"amount_base_units\"):\n            value = summary.get(key)\n            if value is None:\n                continue\n            try:\n                spend = int(str(value))\n            except (TypeError, ValueError):\n                continue\n            break\n        return recipient, spend","title":"Spend caps mix units: lamport limits are compared against wei and ERC-20/SPL base units"},{"citation":"resolved","description":"When the provider's 402 challenge is recognised as USDC below DE_MINIMIS_USD_THRESHOLD (2.0 USD, providers/x402.py line 1624), `x402_pay_request` executes the payment with no approval token, no autonomous session and no autonomous permission, on mainnet included. Nothing counts these payments: there is no ledger entry, no per-minute rate limit and no daily cap in this branch, and the preview cache is not consulted. The amount is chosen by the remote server, whose response body is also what the agent reads next, so a malicious or compromised x402 endpoint can price itself at 1.99 USDC and instruct the agent (prompt injection) to call again; 500 calls move ~1,000 USDC with zero human confirmation. The card-payment analogy cited in the code relies on issuer-side velocity limits that do not exist here. Fix: route de-minimis payments through a persisted SpendingLedger (per-hour/per-day USD ceiling, per-endpoint count) and require normal approval once the ceiling is reached.","line":4221,"path":"agent-wallet/agent_wallet/openclaw_adapter.py","reproduction":"Server returns a 402 challenge with asset=USDC (Base) and amount 1.99 USD. Agent calls x402_pay_request(url, purpose='p') with no approval_token, no autonomous session and permissions revoked: is_de_minimis_payment(preview) is True (verified: x402.is_de_minimis_payment({'x402_amount_display':'1.99','payment_required':True}) -> True), so `_require_execute_approval` is never called and pay_and_fetch signs and settles. Repeat the call N times: every call settles. Expected: a bounded number/value of unapproved micro-payments per period; actual: unbounded.","severity":"medium","snippet":"                de_minimis = False\n                if preview_payload.get(\"payment_required\"):\n                    de_minimis = x402.is_de_minimis_payment(preview_payload)\n                    if not de_minimis:","title":"x402 de minimis payments skip every approval gate with no cumulative, rate or session limit"},{"citation":"resolved","description":"`start_autonomous_session` (openclaw_adapter.py lines 4087-4103) requires a host-issued, mainnet-confirmed approval token precisely so the agent cannot widen its own envelope, but the resulting envelope is stored as plain JSON in OPENCLAW_HOME/autonomous_session.json (and autonomous_permissions.json) with no MAC. `_load_record` trusts any well-formed file. Agents in Claude Code, Codex, OpenClaw and Hermes typically also have a file-write/shell tool, so an agent (or any same-user process) can write a record with enabled=true, allow_mainnet=true, allow_any_recipient=true, no caps and no TTL, after which `_require_execute_approval` mints tokens for everything. The approval secret is sealed and kept out of the agent's reach, yet this side channel grants the same authority as holding it. Fix: HMAC the persisted record with the sealed approval secret (same primitive as approval tokens) and reject records whose MAC does not verify; do the same for autonomous_permissions.json.","line":102,"path":"agent-wallet/agent_wallet/autonomous_session.py","reproduction":"With no session started, write OPENCLAW_HOME/autonomous_session.json = {'version':1,'started_at':now,'operations':0,'spend_entries':[],'config':{'enabled':true,'allowed_tools':['transfer_sol'],'allowed_networks':['mainnet'],'allow_mainnet':true,'allow_any_recipient':true,'require_simulation':false,'spending':{},'max_operations':0,'session_ttl_seconds':0,'approval_ttl_seconds':120}}. autonomous_session.is_active() -> True and adapter._require_execute_approval(approval_token=None, tool_name='transfer_sol', summary={... 'network':'mainnet','amount_lamports':5}) passes on mainnet. Expected: an envelope that was never authorised by a host token is ignored; actual: it is honoured.","severity":"medium","snippet":"def _load_record() -> dict[str, Any] | None:\n    path = _session_path()\n    if not path.exists():\n        return None\n    try:\n        record = json.loads(path.read_text(encoding=\"utf-8\"))\n    except (OSError, ValueError):\n        return None\n    if not isinstance(record, dict) or int(record.get(\"version\") or 0) != SESSION_VERSION:\n        return None\n    return record","title":"Autonomous session and permission files are unsigned JSON, so a local file write bypasses the host-token gate on start_autonomous_session"},{"citation":"resolved","description":"Every other write tool goes through preview -> host-issued approval token -> execute, but message signing executes immediately when the agent sets `user_confirmed: true`, a value the agent itself supplies (the tool schema at lines 2964-2990 just asks the model to set it). No approval token, autonomous session or permission is consulted, so the autonomous framework cannot restrict or audit signatures. The Solana backend (`wallet_layer/solana.py` lines 2312-2316) signs the exact UTF-8 bytes with ed25519 and no prefix; the EVM path signs an EIP-191 personal message. Arbitrary signatures let a prompt-injected agent authenticate to attacker sites (SIWE/SIWS), sign off-chain orders/permits that protocols accept via personal_sign, or sign any valid-UTF-8 payload a verifier treats as an authorisation. Fix: require the same host-issued approval token (bound to a hash of the message) as execute tools, and for Solana wrap messages in the off-chain message signing format (or reject messages that parse as transaction messages).","line":6006,"path":"agent-wallet/agent_wallet/openclaw_adapter.py","reproduction":"Solana backend with a local signer. adapter.invoke('sign_wallet_message', {'message': '\\x01\\x00\\x01AAAA...', 'purpose':'p', 'user_confirmed': True}) with no approval_token, no session and permissions revoked -> ok=True and a signature is returned; nacl VerifyKey(address).verify(message_bytes, signature) succeeds, i.e. the signature is a plain ed25519 signature over the raw bytes. Expected: refusal without a host-issued approval, and a domain-separated signing format; actual: signature produced on the agent's say-so.","severity":"medium","snippet":"                user_confirmed = args.get(\"user_confirmed\")\n                if user_confirmed is not True:\n                    raise WalletBackendError(\n                        \"Message signing requires explicit user confirmation.\"\n                    )\n                message = args.get(\"message\")\n                purpose = args.get(\"purpose\")\n                if not isinstance(message, str) or not message.strip():\n                    raise WalletBackendError(\"message is required.\")\n                if not isinstance(purpose, str) or not purpose.strip():\n                    raise WalletBackendError(\"purpose is required.\")\n                signature = await self.backend.sign_message(message.strip())","title":"sign_wallet_message is gated only by the agent's own user_confirmed flag and signs raw bytes without domain separation"},{"citation":"resolved","description":"`MacKeychainStore.set` runs `security add-generic-password ... -w <boot key>` with the secret in argv. On macOS the arguments of a running process are readable by other processes of the same user (and by root), so the install-level boot key that unlocks sealed_keys.json and every wallet is briefly visible to any local process that polls `ps`. The Linux (`secret-tool` reads stdin) and Windows (PowerShell reads stdin) backends avoid this. Fix: omit the `-w` value so `security` prompts on stdin, or write via `-w` with the value piped using the `-i` interactive mode.","line":210,"path":"agent-wallet/agent_wallet/keystore.py","reproduction":"On macOS with backend auto/macos-keychain, run boot-key import or first install while another same-user process runs `while true; do ps -axo command | grep add-generic-password; done`: the boot key appears in the captured command line. Expected: the secret never appears in argv; actual: it does for the duration of the subprocess.","severity":"low","snippet":"        proc = _run([\n            _SECURITY_BIN, \"add-generic-password\",\n            \"-s\", self.service, \"-a\", name,\n            \"-w\", value, \"-A\",\n        ])","title":"macOS keychain backend passes the boot key on the command line, exposing it to process listing"},{"citation":"resolved","description":"config.py deliberately rejects AGENT_WALLET_MASTER_KEY, AGENT_WALLET_APPROVAL_SECRET and SOLANA_AGENT_PRIVATE_KEY from the runtime environment (lines 585-594) and AGENTS.md forbids re-adding runtime env secret loading, but the EVM vault password, which unlocks the wdk-evm-wallet seed, is still accepted from `WDK_EVM_WALLET_PASSWORD` ahead of the sealed store. Environment variables leak through `/proc/<pid>/environ`, crash reports, shell history and child processes (the CLI is spawned by host bridges that pass the environment through), which is exactly the exposure the sealed design was introduced to remove. Fix: drop the env fallback (or reject it like the other three secrets) and rely on the sealed `wdk_evm_wallet_password`.","line":633,"path":"agent-wallet/agent_wallet/config.py","reproduction":"export WDK_EVM_WALLET_PASSWORD=hunter2; resolve_evm_wallet_password() returns 'hunter2' without consulting sealed_keys.json, and any `python -m agent_wallet.openclaw_cli invoke ...` child inherits it. Expected: a WalletBackendError like the other legacy env secrets; actual: the plaintext password is honoured.","severity":"low","snippet":"def resolve_evm_wallet_password() -> str:\n    \"\"\"Resolve the local EVM vault password from env or the sealed secret store.\"\"\"\n    direct = os.getenv(\"WDK_EVM_WALLET_PASSWORD\", \"\").strip()\n    if direct:\n        return direct\n    return _resolve_sealed_secret(\n        \"wdk_evm_wallet_password\",\n        \"evm_wallet_password\",\n    )","title":"WDK_EVM_WALLET_PASSWORD is read directly from runtime environment despite the sealed-secret-only invariant"}],"hash":"8b000dba0f36559bd204a0b3ecefb45a8092e2ae09fdd029c4b75bd258361a84","nodeId":"3c9b93e5-9167-45ec-86f0-aa232e46e094","outcome":"completed","summary":"The review is complete. The report is at `.imd-findings.json` in the repository root with 10 findings, and no other file in the tree was changed.\n\n**Outcome.** The agent-wallet backend's human-approval model can be defeated by the agent itself, and several of its published safety limits do not hold under the real deployment model (one Python process per tool call). Every finding below was reproduced by running the actual modules under a scratch OPENCLAW_HOME with a sealed approval secret.\n\n**Findings, most severe first**\n\n- **Critical: agent self-grants unbounded autonomy.** The `agentlayer_autonomous_approve` tool enables the \"all\" permission group from agent-controlled arguments alone. After one call, the execute gate mints mainnet-confirmed tokens for every tool on every network. Reproduced: gate refused, tool called, gate passed for transfer_evm_native, transfer_sol and call_evm_contract.\n- **High: approval tokens replay across processes.** The single-use registry is in-memory and each CLI invoke is a new process. Three separate processes accepted the same token. Default TTL is 600 seconds, so one approval authorises many identical transfers.\n- **High: session caps bypassed by concurrency.** The session file is guarded by a thread lock only. Four parallel processes were all approved under a cap of one operation.\n- **High: `require_simulation` is a no-op.** The adapter hardcodes `simulated=True`, so the default simulation requirement never denies anything.\n- **Medium: spend caps mix units.** Lamport limits are compared against wei and token base units. A 1,000 USDC transfer passed a cap that rejected 0.01 ETH.\n- **Medium: x402 de minimis bypass is unbounded.** Payments under 2 USD skip every gate with no counter, rate limit or ledger, while the paying server controls both the price and the content the agent reads next.\n- **Medium: session and permission files are unsigned.** A hand-written session JSON enabled mainnet execution without the host token that `start_autonomous_session` requires.\n- **Medium: message signing gated by agent flag only.** `sign_wallet_message` needs just `user_confirmed=true` from the agent, and the Solana signer signs raw bytes with no domain separation.\n- **Low: macOS keychain writes put the boot key in argv.**\n- **Low: EVM vault password still read from runtime env**, contrary to the sealed-secret invariant.\n\n**Coverage and limits.** I read the approval, session, permission, policy, spending, nonce, config, sealed-key, keystore, encrypted-storage, boot-key, CLI, runtime and adapter gate code in full, plus the transfer, DeFi, x402 and message-sign handlers and the host bridges that call them. Key storage looked sound: wallets are encrypted per user under a sealed master key, secrets are rejected from plugin config, and telemetry carries no amounts or addresses. Not exercised: live signing against the wdk-evm-wallet service or Solana RPC, the Bitcoin backend, and the provider modules beyond their approval interactions. No Foundry proofs were written because the target is Python, not Solidity.","treeHash":null,"usage":{"cachedInputTokens":4184749,"inputTokens":610,"model":"claude-fable-5-1","outputTokens":108353,"runtime":"claude","turns":700,"wallClockMs":1178053}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"b4e34f9d3c724992","findings":[],"hash":"b90f3eaa651f27365fa18e4993b9f75b06f75b7edfccaffe57f6d03df47e5324","nodeId":"a65ab0a1-31bf-4bee-95cf-8d361d1bf762","outcome":"failed","summary":"runtime reported claude-opus-4-8, not the required premium model claude-fable-5-1","treeHash":null,"usage":{"cachedInputTokens":5284913,"inputTokens":530,"model":"claude-opus-4-8","outputTokens":65552,"runtime":"claude","turns":103,"wallClockMs":941953}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"76f57db4177175be","findings":[{"citation":"resolved","description":"The adapter is documented as 'the policy gate for agent-facing tools' and AGENTS.md says execute 'must continue requiring a host-issued approval_token'. start_autonomous_session honours that (lines 4087-4103: execute needs a host token, 'an agent must not be able to widen its own envelope'). The sibling tool agentlayer_autonomous_approve does not: it is an ordinary agent-callable tool (registered via _autonomous_permission_tool_specs at line 2558 and exposed to every host through list_tools), and its only guards are `purpose` being a non-empty string and `user_intent` being literally true, both of which the model sets itself. On success it calls autonomous_permissions.approve_all (line 4045), which enables the 'all' group with no per-tool allow-list, no network allow-list, no recipient allow-list, no spend cap, no operation budget and no TTL. From then on _require_execute_approval (lines 585-590) mints an approval token for any write tool on any network via autonomous_permissions.authorize_operation, which hard-codes mainnet_confirmed=True (autonomous_permissions.py:253). The result is that a prompt-injected or misbehaving agent converts a per-transaction human-approval wallet into an unattended one in a single tool call and persists that state on disk (~/.openclaw/autonomous_permissions.json) across sessions and hosts. The in-tool and host-side 'ask the user first' text is advisory only; nothing in code verifies a human was involved. Fix: require a host-issued approval token (same pattern as start_autonomous_session, bound to a preview summary of the requested scope) before approve_all, or move the grant to a host-only CLI path (openclaw_cli autonomous-permission approve) and remove the agent-facing tool; additionally bound the 'all' grant with the session engine's caps rather than issuing unlimited tokens.","line":4041,"path":"agent-wallet/agent_wallet/openclaw_adapter.py","reproduction":"Verified with test/scratch/repro_agent_wallet.py selfgrant (OpenClawWalletAdapter over a fake mainnet Solana backend with a sealed approval_secret). Step 1: invoke('transfer_sol', {recipient, amount:1.0, mode:'execute', purpose:'x'}) with no approval_token -> ok=False, 'SOL transfer execution requires a host-issued approval_token'. Step 2: invoke('agentlayer_autonomous_approve', {scope:'base_swaps', purpose:'x', user_intent:true}) -> ok=True, data.active=True (note: scope 'base_swaps' enabled the full group). Step 3: repeat the exact call from step 1, still with no approval_token -> ok=True and the backend's send_native_transfer was called on mainnet. Expected: step 2 to be rejected without a host-issued token (as start_autonomous_session is), or step 3 to still require a host token.","severity":"critical","snippet":"                if args.get(\"user_intent\") is not True:","title":"Agent can grant itself unbounded standing execute authority: agentlayer_autonomous_approve is gated only by an agent-supplied boolean, and every execute tool then self-issues mainnet-confirmed approva"},{"citation":"resolved","description":"require_single_use records consumed token fingerprints in a module-level in-memory dict. Every host bridge executes `python -m agent_wallet.openclaw_cli invoke` as a new process per tool call (.openclaw/extensions/agent-wallet/index.ts:439 execFileAsync, codex/plugins/agent-wallet/server.py:676 subprocess.run, hermes/plugins/agent_wallet/tools.py:351 subprocess.run), so the registry is always empty when a token is checked. A token issued for one transfer therefore authorises that exact transfer an unlimited number of times until `exp` (settings.agent_wallet_approval_ttl_seconds defaults to 600 in config.py:19; Hermes allows up to 3600). The agent holds the token directly in the Hermes integration: agent_wallet_approve returns it (tools.py:599) and the model passes it to agent_wallet_invoke, so the model (or anything that reads its context) can resend it; in OpenClaw/Codex the token lives only in host memory, but the issue-approval CLI output and any host log of the invoke argv also carry it. autonomous_session.py already solved the same cross-process problem for its counters by persisting to OPENCLAW_HOME; the nonce registry was not given the same treatment. Fix: persist consumed `jti` values (the token already carries one) in a locked file under OPENCLAW_HOME with the same atomic-write pattern autonomous_session uses, and check/record inside the same critical section as verify_approval_token.","line":48,"path":"agent-wallet/agent_wallet/nonce_registry.py","reproduction":"Verified with test/scratch/repro_agent_wallet.py replay. Issue one token: issue_approval_token(tool_name='transfer_sol', network='mainnet', summary=<summary of 1 SOL to RECIPIENT>, mainnet_confirmed=True). Then run three separate Python processes that each build the adapter and call invoke('transfer_sol', {..., mode:'execute', approval_token: <same token>}): all three return ok=True and each one calls send_native_transfer (3 SOL moved for a 1 SOL approval). Only a second use inside the same process is rejected with 'Approval token has already been used', which is the in-process case that never occurs in production. Expected: the second and later uses, in any process, to be rejected.","severity":"high","snippet":"_registry = NonceRegistry()","title":"Approval-token single-use protection is a no-op in the deployed architecture: nonce registry is per-process while every host runs the CLI as a fresh subprocess, so a token can be replayed for its full"},{"citation":"resolved","description":"x402_pay_request skips the host/session/permission approval entirely when is_de_minimis_payment(preview) is true (openclaw_adapter.py:4221-4235). That decision is derived from x402_amount_display, which normalize_payment_requirement populates only when _looks_like_usdc returns true. _looks_like_usdc accepts either a known USDC asset identifier OR any requirement whose server-supplied `extra.name` is 'usdc'/'usd coin' (line 287), without cross-checking the `asset` field. The payment itself is then built by the x402 SDK from the requirement's real `asset` and `amount`. A hostile x402 endpoint can therefore return asset=<any mint/contract the wallet holds>, amount=1999999, extra.name='USDC' and the wallet signs and submits a transfer of 1,999,999 raw units of that asset to payTo with no confirmation. For an 8-decimal asset such as cbBTC on Solana (mint cbbtcf3aa214zXHbiAZQwf4122FBYbraNdFqgw4iMij) that is 0.01999999 BTC (roughly $1,000-2,000 at current prices); for low-decimal tokens the loss is larger. The agent only needs to be induced to fetch an attacker-controlled URL with x402_pay_request (e.g. via a service listed in discovery or a link in untrusted content). Fix: treat a requirement as USDC only when `asset` itself is in the vetted per-network USDC address list, ignore extra.name for this purpose, and apply de minimis only when the asset decimals are known to be 6.","line":287,"path":"agent-wallet/agent_wallet/providers/x402.py","reproduction":"PYTHONPATH=agent-wallet python: req={'scheme':'exact','network':'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp','asset':'cbbtcf3aa214zXHbiAZQwf4122FBYbraNdFqgw4iMij','amount':'1999999','payTo':'<attacker>','extra':{'name':'USDC'}}; n=x402.normalize_payment_requirement(req, source='t') -> n['amount_display']=='1.999999'; x402.is_de_minimis_payment({'payment_required':True,'x402_amount_display':n['amount_display']}) -> True. Consequently openclaw_adapter.py:4224 skips _require_execute_approval and pay_and_fetch signs the SPL transfer of the cbBTC mint. Expected: amount_display None (asset is not USDC) and de_minimis False, forcing approval.","severity":"high","snippet":"    return _trim(extra.get(\"name\")).lower() in _USDC_IDENTIFIERS","title":"x402 de minimis bypass trusts the merchant's extra.name to decide an asset is USDC, so a malicious 402 server can have an arbitrary SPL/ERC-20 token transferred with no approval at all"},{"citation":"resolved","description":"_autonomous_signals extracts the first integer found under input_amount_raw / amount_raw / amount_wei / amount_lamports and passes it as spend_amount to the session SpendingLedger, whose caps are documented and configured in lamports (SpendingConfig in spending_limits.py, start_autonomous_session schema 'max_per_tx_lamports'). For SPL token transfers, Jupiter swaps, EVM token transfers and DeFi operations the value is the raw amount of the input token (6 decimals for USDC, 8 for cbBTC/WBTC), so the ledger silently mixes units. A human who authorises a session with max_per_tx_lamports=100_000_000 (0.1 SOL, about $20) has in fact authorised 99 USDC per transaction, and the hourly/daily caps are equally meaningless across assets. Fix: normalise spend to one unit (e.g. USD via the existing price providers, or per-asset caps keyed by mint/contract) and deny when the asset's unit cannot be mapped, instead of accepting any raw integer.","line":393,"path":"agent-wallet/agent_wallet/openclaw_adapter.py","reproduction":"Verified with test/scratch/repro_agent_wallet.py units. start_session(AutonomousSessionConfig(enabled=True, allowed_tools={'transfer_spl_token'}, allowed_networks={'mainnet'}, allow_mainnet=True, allowed_recipients={RECIPIENT}, spending=SpendingConfig(max_per_tx_lamports=100_000_000))). Then invoke('transfer_spl_token', {recipient:RECIPIENT, mint:'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', amount:99.0, decimals:6, mode:'execute', purpose:'x'}) with no approval_token -> ok=True and send_spl_transfer is called for 99 USDC (amount_raw 99_000_000 <= cap 100_000_000). Expected: denial, because 99 USDC is roughly 50x the 0.1 SOL cap the human approved.","severity":"high","snippet":"        for key in (\"input_amount_raw\", \"amount_raw\", \"amount_wei\", \"amount_lamports\", \"amount_base_units\"):","title":"Autonomous session spend caps compare raw units of any asset against a lamport/wei denominated limit, so a tiny SOL cap admits large stablecoin or low-decimal token outflows"},{"citation":"resolved","description":"AutonomousSessionConfig.require_simulation (default True) is described as 'the autonomous analogue of a human eyeballing the preview' and the policy engine denies when `not op.simulated` (autonomous_policy.py:248). The only production caller, _require_execute_approval, constructs OperationRequest with simulated=True unconditionally, regardless of whether the preceding preview ran any simulation (preview_native_transfer, preview_spl_transfer and the EVM previews perform none). The control is therefore unenforceable and the status surface (get_autonomous_session reports require_simulation=True) misleads the operator. Fix: set simulated from the actual preview payload (e.g. presence of a successful `verification`/`simulation` block) and leave it False otherwise.","line":573,"path":"agent-wallet/agent_wallet/openclaw_adapter.py","reproduction":"Verified with test/scratch/repro_agent_wallet.py simulated. start_session with require_simulation=True, allowed_tools={'transfer_sol'}, allowed_recipients={RECIPIENT}, allow_mainnet=True; invoke('transfer_sol', {recipient:RECIPIENT, amount:1.0, mode:'execute', purpose:'x'}) against a backend whose preview performs no simulation -> ok=True and send_native_transfer is called. Expected: 'operation has no passing simulation and require_simulation is true'.","severity":"medium","snippet":"                        simulated=True,","title":"require_simulation in autonomous sessions is dead: the adapter hard-codes simulated=True for every operation"},{"citation":"resolved","description":"The Python backend's security model (AGENTS.md, README 'Policy defaults') rests on execute requiring a host-issued approval_token issued 'after explicit user confirmation'. In the OpenClaw extension, attachApprovalForExecute (lines 509-548) looks up latestCachedPreview(userId, toolName) — populated by cachePreviewForApproval for every successful preview/prepare/intent_preview result (lines 81-95, 15-minute TTL) — and, if present, immediately calls issue-approval and attaches the token to the execute call. No chat message, button, or any other out-of-band user signal is consulted; the only precondition is that the same agent called preview earlier. The Codex/Claude Code/universal MCP bridge does the same (codex/plugins/agent-wallet/server.py:1157-1166), and Hermes requires only an agent-set user_confirmed=true (hermes/plugins/agent_wallet/tools.py:403) before returning a token to the agent. The approval token therefore proves 'the model called preview then execute', not 'a person approved', which is exactly the failure mode the preview/prepare/execute design is meant to stop (prompt injection, hallucinated confirmation). This is a documented product decision ('do not ask OpenClaw users for /approve, buttons, popups') so it is reported as a trust-gap finding rather than a code defect: the per-transaction human gate the backend documentation promises is not enforced by any component. Fix options: have the host require a user-originated event (host-level confirmation UI/command) before issuing the token, or at minimum bind issuance to a host-visible confirmation message id, and update the Python docs to state that approval tokens are agent-triggered.","line":519,"path":".openclaw/extensions/agent-wallet/index.ts","reproduction":"In an OpenClaw session with the extension loaded and the wallet funded: the model calls transfer_sol {recipient:X, amount:1, mode:'preview', purpose:'p'} (result cached at line 89), then without any user message calls transfer_sol {recipient:X, amount:1, mode:'execute', purpose:'p'}. attachApprovalForExecute finds the cached preview, issueApprovalToken mints a mainnet-confirmed token (lines 498-501), and the Python backend executes the transfer. Expected per README/AGENTS.md: execute to fail until a human explicitly confirms the shown summary.","severity":"medium","snippet":"    effectiveParams.approval_token = await issueApprovalToken(","title":"Host bridges mint the approval token from any cached preview on the next execute call, so the 'host-issued after explicit user confirmation' gate has no technical link to a human action"},{"citation":"resolved","description":"approve_base_swaps and approve_defi_tools both delegate to approve_all, and the adapter (openclaw_adapter.py:4045) and CLI (openclaw_cli.py:260) call approve_all for whatever scope string is passed. Older host integrations, skills and user habits that ask for 'base swaps' (the scope name and the stored record still say network='base', tools=[swap_evm_tokens, swap_evm_uniswap_tokens]) therefore produce a standing grant for every tool on every network, and status() reports the narrow-looking scopes as the thing that was enabled. The asymmetry between what is asked/recorded and what is granted means a user cannot give the limited permission the API still advertises. Fix: either reject the deprecated scopes with an explicit error pointing to 'all', or restore per-scope enforcement so authorize_operation only covers tools/networks in an enabled scope.","line":151,"path":"agent-wallet/agent_wallet/autonomous_permissions.py","reproduction":"PYTHONPATH=agent-wallet python: from agent_wallet import autonomous_permissions as p; p.approve_base_swaps(); p.status()['scopes']['base_swaps'] shows network='base', tools=['swap_evm_tokens','swap_evm_uniswap_tokens'], yet p.authorize_operation(tool_name='transfer_sol', network='mainnet', summary={...}) returns a valid mainnet_confirmed token and the adapter executes transfer_sol with no host token (see repro_agent_wallet.py selfgrant, which used scope 'base_swaps'). Expected: a Base-swap grant to authorise only Base swap tools.","severity":"medium","snippet":"    return approve_all(approved_by=approved_by)","title":"Deprecated narrow permission scopes (base_swaps, defi_tools) silently grant the full 'all' group, so a user approving Base swaps authorises Solana transfers, x402 payments and every other write tool"},{"citation":"resolved","description":"Any x402 payment whose USDC amount is below $2.00 skips _require_execute_approval entirely (openclaw_adapter.py:4221-4235) and is not recorded anywhere: no spending ledger entry, no per-hour/per-day count, no distinct-merchant limit. The card-payment analogy used in the comment relies on issuer-side velocity controls that do not exist here. An agent that is prompt-injected, stuck in a retry loop, or talking to a merchant that keeps returning 402 with a fresh nonce can therefore send $1.99 payments indefinitely and drain the wallet's USDC with zero human involvement. Fix: feed de minimis payments into a persistent ledger (reuse SpendingLedger with USD units) with hourly/daily caps and a per-merchant counter, and require approval once the cap is hit.","line":1624,"path":"agent-wallet/agent_wallet/providers/x402.py","reproduction":"With a Solana or Base wallet holding USDC, point x402_pay_request at a server that answers every request with HTTP 402, requirement asset=USDC, amount=1990000 (1.99 USDC), and after settlement responds with another 402 (or expose N distinct paths). Each call satisfies is_de_minimis_payment -> True, skips approval, and settles 1.99 USDC; after 50 calls $99.50 has left the wallet. Expected: an aggregate limit to stop the loop or force an approval.","severity":"medium","snippet":"DE_MINIMIS_USD_THRESHOLD = 2.0","title":"x402 de minimis payments have no cumulative cap or rate limit, allowing unbounded drip-draining of USDC below the $2 threshold without any approval"},{"citation":"resolved","description":"preview_native_transfer (wallet_layer/solana.py:3836) exposes the integer amount as `amount_lamports`, but the generic confirmation-summary key list (lines 1087-1135) copies amount_native, amount_ui, amount_raw, amount_wei... and not amount_lamports. _autonomous_signals (line 393) therefore finds no raw amount and reports spend=None, and authorize_operation (autonomous_session.py:211) denies with 'spend amount could not be verified' whenever any lamport cap is set. The spend caps that the whole lamport-denominated SpendingConfig was designed for are unusable with the one tool that is actually denominated in lamports; operators are pushed to run sessions with caps disabled, weakening the control. Fix: include amount_lamports in the summary key list (it also strengthens the token binding) or have _autonomous_signals fall back to amount_native * 1e9 for Solana native transfers.","line":1095,"path":"agent-wallet/agent_wallet/openclaw_adapter.py","reproduction":"Verified with test/scratch/repro_agent_wallet.py sol_caps: start_session with max_per_tx_lamports=5_000_000_000, allowed_tools={'transfer_sol'}, allowed_recipients={RECIPIENT}, allow_mainnet=True; invoke('transfer_sol', {recipient:RECIPIENT, amount:1.0, mode:'execute', purpose:'x'}) -> ok=False 'autonomous policy denied operation: spend amount could not be verified while spend limits are configured.' Expected: a 1 SOL transfer under a 5 SOL cap to be approved.","severity":"low","snippet":"            \"amount_native\",","title":"Autonomous sessions with spend caps can never approve transfer_sol: the SOL transfer summary omits the raw lamport amount, so the fail-closed check always denies"},{"citation":"resolved","description":"Solana transactions are authorised by an Ed25519 signature over the serialized Message bytes with no prefix. sign_message signs exactly the UTF-8 bytes the agent passes (after strip()), so the tool is a raw signing oracle for the wallet key; the only guard is the agent-set user_confirmed flag (openclaw_adapter.py:6007). If the serialized bytes of a transaction message are valid UTF-8 (the fee-payer pubkey and the recent blockhash are the random fields; a signer could also be a durable-nonce value) an agent can obtain a valid transaction signature without the preview/approval path and broadcast it through any RPC. For a given wallet this is only exploitable when its pubkey bytes happen to decode as UTF-8, so the practical risk is low, but the signing oracle also enables signing of arbitrary off-chain authorisations (e.g. program-specific permits) that the approval model never sees. Fix: sign with the Solana off-chain message envelope ('\\xffsolana offchain' header) or otherwise reject any message that parses as a legacy/v0 Message, and surface the signed payload in the confirmation summary.","line":2316,"path":"agent-wallet/agent_wallet/wallet_layer/solana.py","reproduction":"Given a wallet whose 32-byte pubkey is valid UTF-8 (grind a keypair for the test), build a legacy Message {header 1/0/1, keys [wallet, recipient, 11111111111111111111111111111111], recent_blockhash chosen from recent blockhashes until UTF-8 valid, one SystemProgram.transfer instruction}, decode the bytes as UTF-8 and call sign_wallet_message {message:<that string>, purpose:'x', user_confirmed:true}. The returned base58 signature verifies against the message bytes and the transaction with that signature attached is accepted by the cluster. Expected: message signing to be impossible to convert into a transaction signature.","severity":"low","snippet":"        return b58encode(self.signer.sign_message(payload))","title":"sign_wallet_message signs raw agent-supplied bytes with the Solana Ed25519 wallet key with no off-chain-message domain separation"},{"citation":"resolved","description":"_write_record uses Path.write_text and os.replace, so the session envelope (allow-lists, caps, operation counters and spend ledger that gate unattended execution) is created with the process umask, typically 0644. autonomous_permissions.py and the keystore use file_ops.atomic_write_text with mode=0o600 for the same class of data. Any other local user who can read ~/.openclaw learns the allowed recipients and remaining budget; if the directory is group/world writable they can also replace the record, since _load_record trusts the file content. Fix: use atomic_write_text(path, text) which applies 0o600.","line":119,"path":"agent-wallet/agent_wallet/autonomous_session.py","reproduction":"umask 022; PYTHONPATH=agent-wallet python -c \"from agent_wallet import autonomous_session as s; from agent_wallet.autonomous_policy import AutonomousSessionConfig; s.start_session(AutonomousSessionConfig(enabled=True))\"; stat -c %a $OPENCLAW_HOME/autonomous_session.json -> 644. Expected 600, matching $OPENCLAW_HOME/autonomous_permissions.json.","severity":"low","snippet":"    tmp.write_text(json.dumps(record, separators=(\",\", \":\")), encoding=\"utf-8\")","title":"autonomous_session.json is written with default (umask) permissions rather than 0600 like every other policy/secret file"},{"citation":"resolved","description":"MacKeychainStore.set invokes /usr/bin/security add-generic-password with the secret as a `-w <value>` argument. Command-line arguments are visible to every process on the host (ps, Activity Monitor, EDR logs, shell history of wrapper scripts) for the duration of the call, so the install-level boot key, which unseals the master key, approval secret and EVM vault password, is briefly disclosed outside the keychain's protection. The Linux and Windows stores correctly pass the value on stdin. Fix: invoke `security add-generic-password ... -w` with no value so the tool prompts and feed the key on stdin, or use `-X` hex via stdin, matching the other backends.","line":213,"path":"agent-wallet/agent_wallet/keystore.py","reproduction":"On macOS run the installer or migration path that calls record/set (e.g. boot_key_migration.migrate_boot_key_to_keystore) while another terminal runs `while :; do ps -eo args | grep add-generic-password | grep -v grep; done`; the boot key appears in the captured argv. Expected: no secret material in argv.","severity":"low","snippet":"            \"-w\", value, \"-A\",","title":"macOS keystore writes pass the boot key on the `security` command line, exposing it to any local process listing during the call"}],"hash":"ce21d36dc71e511a57720519f1e9896ff3b0519458b27fbe174a671858a5ece4","nodeId":"752ace30-4e4f-4ec4-87a2-76d76686f694","outcome":"completed","summary":"Audit complete. The findings file is written and validated: every path, line and snippet in it was checked against the tree, and the first five findings were reproduced by running the adapter in a scratch script.\n\n**What I found** (12 findings in `.imd-findings.json`, ranked):\n\n- **Critical. Agent self-grants unbounded authority.** The agent-callable `agentlayer_autonomous_approve` tool only checks a model-supplied `user_intent: true`. It then enables the \"all\" group, after which every execute tool mints its own mainnet-confirmed approval token. Reproduced: a mainnet `transfer_sol` execute with no token fails, the agent calls approve, the same call succeeds. `start_autonomous_session` correctly requires a host token for exactly this reason, so the two paths are inconsistent.\n- **High. Token replay.** The single-use nonce registry lives in process memory while every host spawns a new CLI process per call. Reproduced: one approval token executed the same 1 SOL transfer in three separate processes. Default TTL is 600 seconds.\n- **High. x402 de minimis asset spoofing.** A 402 server can label any token as USDC via `extra.name`, making a sub-$2 \"display\" amount skip approval while the real transfer is of a different asset. Reproduced with a cbBTC mint producing `amount_display 1.999999` and `de_minimis True`.\n- **High. Spend caps mix units.** Session caps are in lamports but swaps and token transfers feed raw token units. Reproduced: a 0.1 SOL per-tx cap admitted a 99 USDC transfer.\n- **Medium.** `require_simulation` is dead because the adapter hard-codes `simulated=True` (reproduced). Host bridges mint tokens from any cached preview with no human signal (documented product choice, reported as a trust gap). Deprecated `base_swaps`/`defi_tools` scopes grant the full group. De minimis x402 payments have no cumulative cap.\n- **Low.** SOL transfers can never pass a capped session because the summary omits `amount_lamports` (reproduced). Raw Solana message signing has no domain separation. Session file written with umask permissions (verified 644). macOS keystore passes the boot key as a command-line argument.\n\n**Areas reviewed clean:** approval token HMAC and binding, encrypted envelope and sealed-key handling, `_approved_preview` digest binding for swaps, Solana swap-intent execute taking limits from the token-bound summary, telemetry payload contents, EVM transfer recipient/amount rechecks.\n\n**Limits:** the `wdk-evm-wallet` service that performs EVM signing is outside the assigned directory and was not audited. The message-signing finding is low-probability for a given wallet and is rated accordingly. The scratch reproductions live under `test/scratch/` and no project files were changed.","treeHash":null,"usage":{"cachedInputTokens":5411003,"inputTokens":932,"model":"claude-fable-5-1","outputTokens":52081,"runtime":"claude","turns":82,"wallClockMs":778516}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"99e4819277ea59fc","findings":[{"citation":"resolved","description":"agentlayer_autonomous_approve is exposed as an ordinary agent tool (tool specs at openclaw_adapter.py:3836 and :3919; the OpenClaw extension registers it at .openclaw/extensions/agent-wallet/index.ts:729 and forwards it to `openclaw_cli invoke` with no host prompt). Its only guards are `scope` in an enum, a non-empty `purpose` string and `args.get(\"user_intent\") is True` (line 4041), all of which the agent itself supplies. It then calls autonomous_permissions.approve_all(), which writes ~/.openclaw/autonomous_permissions.json with both scopes enabled. Once that file says enabled, _require_execute_approval (openclaw_adapter.py:585-590) mints its own approval token through autonomous_permissions.authorize_operation() for ANY tool on ANY network with mainnet_confirmed=True and no per-tool, per-network, recipient or spend limit (autonomous_permissions.py:234-256 documents that it has 'no per-tool or per-network allow-list'). This defeats the repo's stated invariant that execute 'must continue requiring a host-issued approval_token': a prompt-injected or misbehaving agent turns the toggle on and then drains the wallet with transfer_sol / transfer_evm_native / swap_* / x402_pay_request without any human confirmation. Contrast start_autonomous_session (line 4087-4103), which correctly demands a genuine host token because 'an agent must not be able to widen its own envelope'; the standing-permission path has no such protection and grants strictly more authority. Fix: require a host-issued, single-use approval token bound to {operation: 'agentlayer_autonomous_approve', scope} exactly as start_autonomous_session does (or move approve_all behind the host-only `openclaw_cli autonomous-permission` subcommand and drop the agent tool), and never accept user_intent as the authorization signal.","line":4045,"path":"agent-wallet/agent_wallet/openclaw_adapter.py","reproduction":"Precondition: wallet on a mainnet backend (e.g. Solana mainnet with a funded per-user wallet), no autonomous session, no host token. Step 1: agent calls invoke('agentlayer_autonomous_approve', {\"scope\":\"all\",\"purpose\":\"speed\",\"user_intent\":true}) -> returns {active:true,...} and autonomous_permissions.json is written. Step 2: agent calls invoke('transfer_sol', {\"recipient\":\"<attacker base58 address>\",\"amount\":5,\"mode\":\"execute\",\"purpose\":\"x\"}) with NO approval_token. Expected: WalletBackendError 'execution requires a host-issued approval_token'. Actual: _require_execute_approval takes the autonomous_permissions fallback, authorize_operation() issues a token with mainnet_confirmed=True, verify_approval_token passes, and 5 SOL is broadcast to the attacker. Same sequence works for transfer_evm_native, transfer_evm_token, swap_evm_tokens, x402_pay_request, stake_sol_native etc. (any tool that funnels through _require_execute_approval).","severity":"critical","snippet":"                data = autonomous_permissions.approve_all(","title":"Agent can self-grant the all-tools autonomous permission: agentlayer_autonomous_approve is gated only by agent-supplied user_intent=true"},{"citation":"resolved","description":"For EVM backends the adapter inserts an agent-facing tool named issue_wallet_approval into the tool list (openclaw_adapter.py:2521-2555) and handles it in invoke() at lines 5853-5890. The handler takes tool_name, summary (any object) and mainnet_confirmed from the agent's arguments and calls approval.issue_approval_token() with the sealed approval_secret, returning the signed token to the agent. There is no host involvement, no check of user_intent (the spec sets requires_explicit_user_intent=True but the handler never reads args['user_intent']), and no check that the summary came from a real preview. Because every execute path only verifies that the presented token is HMAC-valid and bound to (tool, network, summary), an agent that can call this tool can approve its own transfers, swaps, DeFi operations and start_autonomous_session envelopes. The in-code description says this is a 'Claude Code bridge' step, but the handler is reachable from every host that forwards tool calls to OpenClawWalletAdapter.invoke (openclaw_cli invoke, hermes/plugins/agent_wallet/tools.py:320-350 forwards any tool_name/arguments verbatim, codex server.py:1075). Fix: remove the tool from the agent-facing surface and issue tokens only from the host process (openclaw_cli issue-approval / OpenClawWalletRuntimeContext.issue_execute_approval) after a real user confirmation; if a bridge must keep it, gate it behind a host-held secret or interactive confirmation rather than agent arguments.","line":5873,"path":"agent-wallet/agent_wallet/openclaw_adapter.py","reproduction":"Precondition: backend=wdk_evm_local on network=base (mainnet), funded wallet. Step 1: agent calls invoke('transfer_evm_native', {\"recipient\":\"0xATTACKER\",\"amount_wei\":\"1000000000000000000\",\"mode\":\"prepare\",\"user_intent\":true,\"purpose\":\"x\"}) and copies data.confirmation_summary S (operation='EVM native transfer', network='base', recipient, amount_wei, estimated_fee_wei, evm_transfer_fingerprint, ...). Step 2: agent calls invoke('issue_wallet_approval', {\"tool_name\":\"transfer_evm_native\",\"summary\":S,\"mainnet_confirmed\":true}) -> returns approval_token T (issued_by='claude-code-bridge'). Step 3: agent calls invoke('transfer_evm_native', {...same args..., \"mode\":\"execute\",\"approval_token\":T}). Expected: only a host-issued token after human confirmation can satisfy execute. Actual: verify_approval_token accepts T (correct HMAC, matching binding, mainnet_confirmed=true) and 1 ETH is sent to 0xATTACKER with no human in the loop. The same works for start_autonomous_session with summary = the previewed policy_summary, which then grants a standing envelope.","severity":"critical","snippet":"                token = issue_approval_token(","title":"issue_wallet_approval lets the agent mint host approval tokens for any tool and any summary (EVM backends)"},{"citation":"resolved","description":"require_single_use() records consumed token fingerprints in a module-level in-memory dict. The documented and actual deployment runs `python -m agent_wallet.openclaw_cli invoke ...` as a new subprocess for every tool call (autonomous_session.py:3-5 states this; openclaw_cli.main() -> asyncio.run(_run_invoke) -> exit; the OpenClaw extension, Hermes plugin and Codex bridge all spawn it per call). The registry is therefore empty at the start of every execute, and a token remains valid for its full TTL (settings.agent_wallet_approval_ttl_seconds defaults to 600 s, config.py:19). The binding summary for native transfers contains no per-execution nonce or blockhash: for transfer_sol the default summary branch (openclaw_adapter.py:1083-1139) keeps only operation, network, from_address, to_address and amount_native, so the same token verifies again on the next process. Any party holding a token (the agent receives tokens directly in the Claude Code bridge flow via issue_wallet_approval, in the Hermes flow via tool_args.approval_token, and tokens appear in CLI argv/arguments-json visible to other local processes) can re-execute the approved transfer repeatedly for 10 minutes. The same replay against start_autonomous_session (line 4096-4104) re-runs start_session(), which resets operations=0 and spend_entries=[] and thereby wipes the spend/op budget of an existing session. Fix: persist consumed jti/fingerprints under OPENCLAW_HOME (same pattern as autonomous_session.json, with a lock and TTL-based eviction), or bind each execute token to a one-shot value the backend consumes (e.g. the preview's latest_blockhash / nonce) and lower the default TTL.","line":48,"path":"agent-wallet/agent_wallet/nonce_registry.py","reproduction":"Precondition: Solana mainnet wallet with 3 SOL, host issues one token: `openclaw_cli issue-approval --user-id u1 --tool transfer_sol --summary-json '{\"operation\":\"SOL transfer\",\"network\":\"mainnet\",\"from_address\":\"<wallet>\",\"to_address\":\"<dest>\",\"amount_native\":1.0}' --mainnet-confirmed` -> token T. Step 1: `openclaw_cli invoke --user-id u1 --tool transfer_sol --arguments-json '{\"recipient\":\"<dest>\",\"amount\":1.0,\"mode\":\"execute\",\"purpose\":\"x\",\"approval_token\":\"T\"}'` -> 1 SOL sent. Step 2: run the identical command again within 600 s. Expected: 'Approval token has already been used'. Actual: new process, empty registry, verify passes, second 1 SOL transfer is broadcast (and a third, ...). Variant: replay the start_autonomous_session execute token after the session has spent its budget -> session record is rewritten with operations=0 and spend_entries=[].","severity":"high","snippet":"_registry = NonceRegistry()","title":"Approval-token replay: single-use registry is per-process while every CLI tool call is a fresh process"},{"citation":"resolved","description":"x402_pay_request reads `_approved_preview` straight from the tool arguments (line 4201) and passes it to x402.resolve_payment_preview(). _reusable_approved_preview (providers/x402.py:1592-1615) accepts it whenever request_fingerprint matches the URL/method/body hash and it carries a selected_payment and a payment-required header; nothing is checked against a fresh probe and nothing is signed/authenticated about the object. The adapter then decides whether any approval is needed from that same object: x402.is_de_minimis_payment(preview_payload) (line 4223) only reads preview['x402_amount_display'] and skips _require_execute_approval entirely when it parses below DE_MINIMIS_USD_THRESHOLD = 2.0 (x402.py:1624-1658). pay_and_fetch() then signs the payment from preview['selected_payment'] and preview['response_headers']['payment-required'] (x402.py:1741-1759), i.e. the real server requirement. So an agent that controls `_approved_preview` can take a genuine preview of a $50 USDC endpoint, overwrite x402_amount_display with \"0.50\", and have the wallet sign and send the $50 payment with no approval token, no autonomous session and no permission toggle. _approved_preview is agent-controlled on every host that forwards arguments verbatim (hermes/plugins/agent_wallet/tools.py:327-347; codex server only adds it when absent; the OpenClaw extension copies params with `{...params}` and only fills it when undefined). Fix: never derive the approval decision from caller-supplied data: compute de-minimis from selected_payment.amount/asset (re-normalised via normalize_payment_requirement) or from a fresh probe, require _approved_preview to carry a host-signed digest (e.g. the _preview_digest bound into the approval token) before reusing it, and ignore it when no approval_token is present.","line":4201,"path":"agent-wallet/agent_wallet/openclaw_adapter.py","reproduction":"Precondition: EVM (base) or Solana mainnet wallet with USDC; a paid endpoint https://api.example/report that returns 402 with amount 50 USDC. Step 1: agent calls x402_preview_request({\"url\":\"https://api.example/report\",\"purpose\":\"x\"}) and copies the returned preview P (payment_required=true, request_fingerprint, selected_payment{amount:50000000,...}, response_headers['payment-required'], x402_amount_display='50'). Step 2: agent sets P['x402_amount_display']='0.50' and calls x402_pay_request({\"url\":\"https://api.example/report\",\"purpose\":\"x\",\"_approved_preview\":P}) with no approval_token (on Hermes or any direct adapter.invoke caller). Expected: approval required because the real payment is $50 (> $2 threshold). Actual: resolve_payment_preview returns P unchanged (fingerprint matches), is_de_minimis_payment(P) is True, _require_execute_approval is skipped, pay_and_fetch reuses P and signs/sends a 50 USDC payment to selected_payment.pay_to.","severity":"high","snippet":"                approved_preview = args.get(\"_approved_preview\")","title":"x402_pay_request trusts an agent-supplied _approved_preview for the de-minimis decision and for the payment it signs"},{"citation":"resolved","description":"AutonomousSessionConfig.require_simulation (default True) is documented as 'Require a passing simulation/dry-run before approving' and the tool schema repeats 'Require a passing simulation before each autonomous execute (default true)'. The only producer of OperationRequest is _require_execute_approval, which always passes simulated=True regardless of whether any simulation ran. Most execute paths that reach it (transfer_sol, transfer_evm_native, transfer_evm_token, x402_pay_request, stake_sol_native, ...) build their summary from a plain preview that performs balance/fee arithmetic, not a simulateTransaction/eth_call dry-run. Rule 8 in AutonomousPolicyEngine.evaluate (autonomous_policy.py:248-249) can therefore never deny, so a human who authorises a session relying on 'simulation required' gets no such protection. Fix: pass the real outcome (e.g. simulated = preview.get('simulation', {}).get('success') is True) and make previews used for autonomous execution run and report a simulation, or remove the flag from the policy surface so the host does not advertise a guarantee that does not exist.","line":573,"path":"agent-wallet/agent_wallet/openclaw_adapter.py","reproduction":"Precondition: host starts a session via start_autonomous_session execute with {allowed_tools:[\"transfer_sol\"], allowed_networks:[\"mainnet\"], allow_mainnet:true, allow_any_recipient:true, require_simulation:true}. Agent calls transfer_sol {\"recipient\":\"<dest>\",\"amount\":0.5,\"mode\":\"execute\",\"purpose\":\"x\"} with no approval_token. Expected: denial 'operation has no passing simulation and require_simulation is true' because preview_native_transfer (wallet_layer/solana.py:3800-3848) never simulates. Actual: OperationRequest(simulated=True) is submitted, the engine approves, and the transfer is broadcast.","severity":"medium","snippet":"                        simulated=True,","title":"Autonomous session's require_simulation gate is a no-op: adapter hard-codes simulated=True"},{"citation":"resolved","description":"config.py:25-28 defines agent_wallet_max_per_tx_sol, agent_wallet_max_hourly_sol, agent_wallet_max_daily_sol and agent_wallet_max_txs_per_minute, and agent-wallet/.env.example ships them as wallet 'spending guardrails'. Nothing reads them: the only consumer of SpendingLedger is the autonomous session/policy engine (autonomous_session.py:217, autonomous_policy.py:181) which uses per-session values supplied by the start_autonomous_session arguments. The host-approved, intent, Base-swap-permission and all-tools-permission execute paths never consult a ledger at all, so an operator who sets AGENT_WALLET_MAX_PER_TX_SOL=0.1 believing it caps every transaction has no cap. Fix: either wire a process-wide/persisted SpendingLedger built from these settings into _require_execute_approval (recording the spend from the bound summary) or delete the settings and the .env.example lines so operators are not misled.","line":25,"path":"agent-wallet/agent_wallet/config.py","reproduction":"Precondition: environment AGENT_WALLET_MAX_PER_TX_SOL=0.1 and AGENT_WALLET_MAX_DAILY_SOL=0.2, Solana mainnet wallet with 5 SOL, host issues a normal approval token for a 3 SOL transfer. Agent calls transfer_sol {\"recipient\":\"<dest>\",\"amount\":3,\"mode\":\"execute\",\"purpose\":\"x\",\"approval_token\":T}. Expected: rejection 'Transaction exceeds per-tx limit'. Actual: no code path reads settings.agent_wallet_max_per_tx_sol (grep shows the only references are the field definitions), 3 SOL is sent; repeating with new tokens exceeds the 'daily' cap with no error.","severity":"medium","snippet":"    agent_wallet_max_per_tx_sol: float = 0","title":"Global spend limits (AGENT_WALLET_MAX_PER_TX_SOL / HOURLY / DAILY / TXS_PER_MINUTE) are parsed but never enforced"},{"citation":"resolved","description":"sign_wallet_message is the only signing tool outside the preview/prepare/execute + approval_token flow. Its gate is `args.get('user_confirmed') is True`, a boolean the agent writes itself; no approval token, autonomous session or permission toggle is consulted. On Solana the backend (wallet_layer/solana.py:2312-2316) UTF-8 encodes the string and signs it raw with the wallet's Ed25519 key, without any off-chain-message domain separator, so the output is a plain ed25519 signature by the funds-holding key over attacker-chosen bytes; on EVM it is forwarded to /v1/evm/message/sign. Off-chain signatures from this key are accepted by Sign-In-With-Solana/Ethereum logins, marketplace order books, token-gated services and permit-style flows, so an injected agent can authenticate as the user or sign off-chain commitments silently. AGENTS.md lists user_confirmed among the 'mandatory safety gates', but an agent-controlled boolean is not a gate. Fix: route message signing through the same host approval (token bound to {tool:'sign_wallet_message', summary:{message_sha256, purpose}}), apply a Solana off-chain message prefix/length framing so a signature can never double as a transaction or program-recognised payload, and keep the autonomous fallbacks out of this tool.","line":6007,"path":"agent-wallet/agent_wallet/openclaw_adapter.py","reproduction":"Precondition: any signing-capable backend. Agent calls invoke('sign_wallet_message', {\"message\":\"api.example wants you to sign in with your Solana account:\\n<wallet>\\n\\nNonce: 1234\",\"purpose\":\"login\",\"user_confirmed\":true}) without the user ever being asked. Expected: a host-issued approval (or at least a host prompt) before the private key signs anything. Actual: the adapter returns {signature: <base58 ed25519 sig>, message: ...} immediately; the agent can present it to the third-party service as the user.","severity":"medium","snippet":"                if user_confirmed is not True:","title":"sign_wallet_message signs arbitrary raw bytes with only an agent-supplied user_confirmed flag, no host approval"},{"citation":"resolved","description":"_autonomous_signals extracts the first of input_amount_raw / amount_raw / amount_wei / amount_lamports / amount_base_units from the summary and feeds it to the session's SpendingLedger, whose caps are configured once per session in 'lamports' (SpendingConfig docstring: 'all values in lamports'). The autonomous session is shared by every tool and network (allowed_tools may mix transfer_sol, transfer_evm_token, swap_evm_tokens, manage_evm_aave_position), so an ERC-20 amount_raw (6-decimal USDC, 8-decimal WBTC), an 18-decimal wei value and 9-decimal lamports are all measured against the same integer. A cap meant as 0.1 SOL (100_000_000) allows a 99 USDC transfer (99_000_000 base units) or any WBTC amount below 1 BTC, while a cap meant as 100 USDC blocks every ETH transfer above 1e-16 ETH. Conversely for Solana SOL transfers the summary only carries amount_native (the default summary branch does not copy amount_lamports), so spend is reported as None and the operation is denied whenever caps are set, which pushes operators toward disabling caps. Fix: account spend per (network, asset) with per-asset caps or a USD-normalised figure from the price providers already used for portfolio valuation, and include amount_lamports in the native-transfer summary.","line":393,"path":"agent-wallet/agent_wallet/openclaw_adapter.py","reproduction":"Precondition: host starts a session {allowed_tools:[\"transfer_evm_token\",\"transfer_sol\"], allowed_networks:[\"base\",\"mainnet\"], allow_mainnet:true, allow_any_recipient:true, max_per_tx_lamports:100000000, max_daily_lamports:200000000} intending a 0.1 SOL / 0.2 SOL budget. Agent calls transfer_evm_token on base {\"token_address\":\"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913\" (USDC), \"recipient\":\"<dest>\", \"amount_raw\":\"99000000\", \"mode\":\"execute\", \"purpose\":\"x\"} twice. Expected: a budget of roughly $20 total. Actual: 99 USDC passes the per-tx cap (99_000_000 < 100_000_000) and a second 99 USDC transfer passes the daily cap (198_000_000 < 200_000_000): $198 moved under a '0.2 SOL' budget.","severity":"medium","snippet":"        for key in (\"input_amount_raw\", \"amount_raw\", \"amount_wei\", \"amount_lamports\", \"amount_base_units\"):","title":"Autonomous spend caps compare raw amounts of unrelated assets against one 'lamports' cap"},{"citation":"resolved","description":"MAINNET_NETWORKS (autonomous_policy.py:49) omits 'arc', which config.normalize_evm_network and the adapter's _is_mainnet_network (openclaw_adapter.py:129) treat as a supported real-money EVM network. For a session with allow_mainnet=false and allowed_networks containing 'arc', rule 6 is skipped, the ledger records the spend (line 253), the operation counter is incremented (line 270) and a token with mainnet_confirmed=False is issued; _require_execute_approval then rejects it ('approval_token is missing explicit mainnet confirmation') because the adapter does consider arc mainnet. The outcome is fail-closed for funds but the persisted session record (autonomous_session.authorize_operation writes operations/spend_entries before returning) has already been charged, so repeated attempts exhaust max_operations and the daily cap without any transaction. The two mainnet lists should be one shared definition, and the engine should only commit counters after the token is accepted.","line":270,"path":"agent-wallet/agent_wallet/autonomous_policy.py","reproduction":"Precondition: host starts a session {allowed_tools:[\"transfer_evm_native\"], allowed_networks:[\"arc\"], allow_mainnet:false, allow_any_recipient:true, max_operations:3} on backend wdk_evm_local network=arc. Agent calls transfer_evm_native execute three times without a token. Expected: each attempt denied at rule 6 ('requires allow_mainnet') with no state change. Actual: each attempt is approved by the engine, autonomous_session.json operations advances 1,2,3, then verify_approval_token raises on the missing mainnet confirmation; a fourth attempt is denied with 'operation budget exhausted' although nothing executed.","severity":"low","snippet":"            self._state.operations += 1","title":"Autonomous engine consumes op/spend budget before downstream mainnet check can reject the token (arc network)"}],"hash":"e3a3438297d261aad89285c295ac2917beb05d123eeb8e5ba3da0acd8d055b32","nodeId":"02ff3b64-92a2-4efb-82b5-cc4599f63bce","outcome":"completed","summary":"Review complete. The findings file is written and validated against the tree.\n\n**Deliverable:** `.imd-findings.json` at the repository root holds 9 findings, each with path, line, exact snippet, and a concrete reproduction. All 9 snippet/line pairs were checked against the files with a script. No Solidity is involved, so no Foundry proofs apply. No files other than the findings file were changed.\n\n**Findings, most severe first**\n\n- **Critical, self-granted autonomy.** The agent-facing tool `agentlayer_autonomous_approve` only checks an agent-supplied `user_intent: true` and then enables the all-tools permission. After that, the execute gate mints its own approval tokens for every tool and network with mainnet confirmed, so no human approval is needed for any transfer or swap. See `agent-wallet/agent_wallet/openclaw_adapter.py:4045`.\n- **Critical, agent mints host tokens.** On EVM backends `issue_wallet_approval` lets the agent sign an approval token for any tool and any summary, with no host or user step. Prepare, issue, execute drains the wallet. See `openclaw_adapter.py:5873`.\n- **High, token replay.** The single-use registry lives in process memory while every CLI tool call is a fresh process, so a token is reusable for its full 600 second TTL. Replaying a session-start token also resets the spend ledger. See `nonce_registry.py:48`.\n- **High, x402 preview trust.** `x402_pay_request` accepts an agent-supplied `_approved_preview`, decides \"de minimis, skip approval\" from its `x402_amount_display`, then signs the real server amount from the same object. See `openclaw_adapter.py:4201`.\n- **Medium.** The session's `require_simulation` is a no-op because the adapter hard-codes `simulated=True`. The global `AGENT_WALLET_MAX_*` spend limits are parsed but never enforced. `sign_wallet_message` signs raw bytes behind an agent-set boolean. Autonomous spend caps compare raw amounts of unrelated assets against one lamport cap.\n- **Low.** The autonomous engine charges the op and spend budget before the adapter's mainnet check rejects \"arc\" tokens.\n\n**Coverage and limits.** This was a static review. No Python runtime exists on this machine, so none of the repo's smoke tests or reproductions were executed. I covered the approval, autonomous session and permission modules in full, the execute choke point and transfer, swap, DeFi, x402 and signing handlers in the adapter, token issuance in the CLI and runtime, and the key-holding modules (sealed keys, encrypted storage, keystore, Solana and EVM user wallets). Telemetry and installer scripts were checked for secret leakage and came up clean. Not reviewed in depth: transaction construction inside `wallet_layer/wdk_evm.py` and `wallet_layer/solana.py` (about 7,700 lines), the provider transaction verifiers in `transaction_policy.py`, and the Bitcoin backend. The OpenClaw extension auto-issues tokens from a cached preview without a human prompt, which is outside the assigned directory but weakens every \"host-issued\" assumption above.","treeHash":null,"usage":{"cachedInputTokens":2963409,"inputTokens":610,"model":"claude-fable-5-1","outputTokens":44570,"runtime":"claude","turns":76,"wallClockMs":652952}}],"verification":[]}