Description
PLEN-2345. connected_account_id was silently ignored on the custom-tool path:
Tools._execute_custom_tool did not accept it, so Tools.execute's routing fork dropped it before it could reach CustomTools. Custom tools always fell back to listing all connected accounts for the user and picking the most recently created one.
CustomTool.invoke_trusted passed client.tools.proxy bare as execute_request, with no auth context bound. After the proxy endpoint closed the empty-auth path (returns 400 ExternalProxy_MissingAuthContext), every custom tool using the (request, execute_request, auth_credentials) shape and calling execute_request for a proxy call started failing in production (Salesforce / Databricks / Google Docs).
Resolution flow now end-to-end
Tools.execute(connected_account_id=...)
-> _execute_custom_tool(connected_account_id=...)
-> CustomTools.execute(connected_account_id=...)
-> CustomTool.invoke_trusted(connected_account_id=...)
-> __resolve_connected_account(...) # creds + id, server-side authorized
-> closure: execute_request(endpoint, method, body, parameters) # account fixed
__resolve_connected_account (renamed from __get_auth_credentials) returns (account_id, credentials). With an explicit id it list-filters by (toolkit_slugs=[toolkit], user_ids=[user_id], connected_account_ids=[id]) so the explicit id is server-side bound to the trusted envelope. Without an explicit id it falls back to the most-recently-created account for (toolkit, user_id). The resolved id is bound onto execute_request via a closure (NOT functools.partial — see Codex iteration 2 below) so the proxy call carries the same auth context as auth_credentials.
Security model preserved (SEC-365 / CWE-639)
connected_account_id is structurally separate from request_kwargs at every layer, mirroring how user_id is handled:
- The allowlist drops a smuggled
connected_account_id from the LLM-supplied request.
CustomTool.__call__ pops connected_account_id from kwargs before request validation, so it never reaches the user's tool function as a request field.
- A trusted, explicit
connected_account_id always wins over a smuggled one.
- An explicit id outside the trusted
(toolkit, user_id) envelope MUST raise (server-side filter returns no items → resolver raises before any credentials reach the tool function).
- The proxy callable's signature omits
connected_account_id — any attempt to override the SDK-bound id raises TypeError rather than swapping the account out from under auth_credentials.
Codex review iterations
Three rounds of /codex-review-loop:
| Iteration | Codex finding | Resolution | Commit |
|---|
| 1 | P1 — retrieve(nanoid=...) skipped the (toolkit, user_id) constraint that the list path enforced; cross-tenant credential exposure if an upstream caller forwards an attacker-influenced id (CWE-639). | Switched to list(toolkit_slugs, user_ids, connected_account_ids); explicit id only resolves inside the trusted envelope. | 47d000f06 |
| 2 | P1 — functools.partial(proxy, connected_account_id=account_id) lets a runtime kwarg override the pre-bound id; tool calling execute_request(connected_account_id="ca_other") would run proxy on one account while auth_credentials came from another. | Replaced partial with a closure whose signature does not declare connected_account_id. Caller-supplied override now raises TypeError. Updated ExecuteRequestFn Protocol to match. | d2e9fd48f |
| 3 | LGTM | — | — |
How did I test this PR
- New tests (
tests/test_custom_tools_security.py, 8 added) pin the full new contract:
test_execute_with_connected_account_id_filters_list_by_envelope — explicit id is list-filtered by (toolkit, user, id).
test_explicit_connected_account_id_outside_envelope_raises — id outside the trusted envelope raises before any credentials are returned (CWE-639 boundary, codex-1 fix pinned).
test_execute_request_rejects_caller_supplied_connected_account_id — proxy wrapper rejects override (codex-2 fix pinned).
test_execute_request_partial_binds_resolved_account_id / ..._on_fallback — execute_request is bound on both explicit and fallback paths.
test_explicit_connected_account_id_wins_over_smuggled_one — trusted parameter beats LLM-smuggled key.
test_tools_execute_e2e_forwards_connected_account_id — end-to-end through Tools.execute.
test_call_pops_connected_account_id_before_request_validation — __call__ extracts the kwarg as a separate parameter.
- Existing tests — all 9 SEC-365 baseline tests in
test_custom_tools_security.py pass unchanged. Two adjacent mock_execute signatures in test_tool_execution.py and test_provider.py updated for the new _execute_custom_tool signature.
- Full python suite — 654 tests pass.
- Lint —
ruff check and ruff format --check clean on changed files.
- Type check —
mypy --config-file config/mypy.ini composio/ clean (50 source files).
- Local E2E — confirmed the bug premise via
curl http://localhost:9900/api/v3/tools/execute/proxy with no auth context returns 400 ExternalProxy_MissingAuthContext (code 2811). The SDK now binds connected_account_id to every custom-tool proxy call so this path is no longer reachable from custom tools.
- CI — all 10 checks pass on the head commit (
d2e9fd48f).
🤖 Generated with Claude Code