fix(python-sdk): plumb connected_account_id through CustomTool [PLEN-2345]#3312
fix(python-sdk): plumb connected_account_id through CustomTool [PLEN-2345]#3312zen-agent wants to merge 4 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Post-PR lifecycle statusCodex review (
|
| Iteration | Result | Action |
|---|---|---|
| 1 | P1: cross-tenant exposure via retrieve(nanoid) (CWE-639) |
Switched to list(...) envelope filter — 47d000f06 |
| 2 | P1: partial allows caller override of bound account |
Replaced partial with closure — d2e9fd48f |
| 3 | LGTM | — |
CI (/gh-fix-ci-loop)
✅ All 10 checks PASSED in 113s on the head commit (d2e9fd48f):
secrets / Detect Secrets with Gitleaks, Test Python SDK (3.10), Test Python SDK (3.11), Test Python SDK (3.12), Test Circular Import Prevention, Integration Tests, Lint and Format Check, common-checks, Vercel, plus 1 app check.
Tests / E2E
- 17 unit tests in
test_custom_tools_security.py(9 SEC-365 baseline, 8 new for PLEN-2345 — including the codex-1 envelope-violation pin and the codex-2 caller-override-rejection pin). - 654 tests pass in the full python suite.
mypyandruffclean.- Local Apollo E2E confirmed the bug premise:
POST /api/v3/tools/execute/proxywith no auth context returns400 ExternalProxy_MissingAuthContext(code 2811); the SDK now bindsconnected_account_idto every custom-tool proxy call so this path is no longer reachable.
PR comments addressed
No human review comments yet (only Vercel's automated deploy preview comment).
Companion PR
The stale docstring on composio_client/resources/tools.py proxy() (sync + async) is fixed in composio-base-py#62.
Remaining blockers
None.
…2345]
`connected_account_id` was silently ignored on the custom-tool path:
- `Tools._execute_custom_tool` did not accept it, so the routing fork
in `Tools.execute` dropped it before it could reach `CustomTools`.
- `CustomTool.invoke_trusted` passed `client.tools.proxy` bare as
`execute_request`, with no auth context bound. After the backend
closed the empty-auth proxy 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.
Resolution now flows 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
-> functools.partial(proxy, connected_account_id=...) # bound
The renamed `__resolve_connected_account` returns `(account_id, credentials)`.
With an explicit id it calls `connected_accounts.retrieve(nanoid=...)`;
without one it keeps the existing list + sort-by-created_at fallback.
The resolved id is bound onto `execute_request` via `functools.partial`,
so the proxy call carries the same auth context as `auth_credentials`.
`connected_account_id` is structurally separate from `request_kwargs`
at every layer (mirrors how `user_id` is handled for SEC-365): the
allowlist still drops a smuggled `connected_account_id` from the
LLM-supplied `request`, and `__call__` pops it from `kwargs` before
request validation.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Karan Vaidya <karan@composio.dev>
Co-authored-by: Uday Sidagana <129588963+Uday-sidagana@users.noreply.github.com>
…) envelope [PLEN-2345]
The first PLEN-2345 commit resolved an explicit ``connected_account_id``
via ``connected_accounts.retrieve(nanoid=...)``, which fetches the
account by id without enforcing it belongs to the trusted ``user_id``
or this tool's ``toolkit``. Codex flagged it as a CWE-639 regression:
an upstream caller forwarding an attacker-influenced id (e.g., from a
prompt-injection input that flows through tool-router multi-execute or
similar plumbing) could grant cross-tenant credential access.
Switch the explicit-id branch to a server-side filter:
connected_accounts.list(
toolkit_slugs=[self.toolkit],
user_ids=[user_id],
connected_account_ids=[connected_account_id],
)
The id only resolves when it lies inside the trusted (toolkit, user)
envelope; otherwise the response is empty and the resolver raises
before any credentials reach the tool function. This re-establishes
the same authorization boundary the list-fallback path already had.
Pinned by test_explicit_connected_account_id_outside_envelope_raises
(new) and refreshed assertions on the existing 6 PLEN-2345 tests.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Karan Vaidya <karan@composio.dev>
Co-authored-by: Uday Sidagana <129588963+Uday-sidagana@users.noreply.github.com>
…t [PLEN-2345] The previous PLEN-2345 commits used ``functools.partial`` to pre-bind ``connected_account_id`` onto the ``execute_request`` proxy callable. Codex flagged that ``partial`` lets a caller-supplied keyword override a pre-bound value: a custom tool calling ``execute_request(connected_account_id="ca_other")`` would run the proxy under that account while ``auth_credentials`` came from the trusted resolved account — a credential/identity mismatch (CWE-639). Replace the partial with a closure whose signature omits ``connected_account_id`` entirely. The id is fixed by the SDK to the account whose credentials were also passed to the tool function; any attempt to override it now raises ``TypeError`` at the call site rather than silently swapping the account out from under the credentials. Side effect: the public ``ExecuteRequestFn`` Protocol no longer declares ``connected_account_id`` (it would never have been honoured anyway given the new wrapper). Sentinel switched ``NotGiven``→``Omit`` on the same Protocol to match the underlying ``client.tools.proxy`` type signature — both sentinels are runtime-equivalent in Stainless' ``is_given``/``strip_not_given``, but the type alignment removes a mypy error in the wrapper forwarding code. Pinned by ``test_execute_request_rejects_caller_supplied_connected_account_id`` (new). Existing PLEN-2345 proxy-binding assertions adjusted to match the wrapper's ``body=omit, parameters=omit`` forwarding. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Karan Vaidya <karan@composio.dev> Co-authored-by: Uday Sidagana <129588963+Uday-sidagana@users.noreply.github.com>
d2e9fd4 to
ab82149
Compare
jkomyno
left a comment
There was a problem hiding this comment.
Reviewed the PLEN-2345 custom-tool fix. No blocking findings. The final head keeps the account id structurally separate from LLM request args, filters explicit ids by the trusted toolkit/user/account envelope, and wraps the proxy callable so tool code cannot override the SDK-bound account.
|
Non-blocking docs nit: the top-level security model in |
Addresses jkomyno's review comment on PR #3312. The ``test_execute_request_partial_binds_*`` names predate the codex iteration 2 switch from ``functools.partial`` to a closure wrapper. Renaming them to ``test_execute_request_wrapper_binds_*`` keeps the regression tests aligned with the security fix and avoids future confusion when someone reads the test name expecting ``partial`` semantics that the implementation no longer has. Pure rename plus a docstring sentence in the first test that points at ``test_execute_request_rejects_caller_supplied_...`` for the companion override-rejection contract. No behavior change. Co-authored-by: Karan Vaidya <karan@composio.dev> Co-authored-by: Uday Sidagana <129588963+Uday-sidagana@users.noreply.github.com> Co-authored-by: karan <karan@composio.dev>
|
Reviewed for changeset/release housekeeping — no changeset needed, ships as a patch.
The PR title + body already serve as the release note via git log, which seems to be the actual convention here. |
Description
PLEN-2345.
connected_account_idwas silently ignored on the custom-tool path:Tools._execute_custom_tooldid not accept it, soTools.execute's routing fork dropped it before it could reachCustomTools. Custom tools always fell back to listing all connected accounts for the user and picking the most recently created one.CustomTool.invoke_trustedpassedclient.tools.proxybare asexecute_request, with no auth context bound. After the proxy endpoint closed the empty-auth path (returns 400ExternalProxy_MissingAuthContext), every custom tool using the(request, execute_request, auth_credentials)shape and callingexecute_requestfor a proxy call started failing in production (Salesforce / Databricks / Google Docs).Resolution flow now end-to-end
__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 ontoexecute_requestvia a closure (NOTfunctools.partial— see Codex iteration 2 below) so the proxy call carries the same auth context asauth_credentials.Security model preserved (SEC-365 / CWE-639)
connected_account_idis structurally separate fromrequest_kwargsat every layer, mirroring howuser_idis handled:connected_account_idfrom the LLM-suppliedrequest.CustomTool.__call__popsconnected_account_idfromkwargsbefore request validation, so it never reaches the user's tool function as a request field.connected_account_idalways wins over a smuggled one.(toolkit, user_id)envelope MUST raise (server-side filter returns no items → resolver raises before any credentials reach the tool function).connected_account_id— any attempt to override the SDK-bound id raisesTypeErrorrather than swapping the account out from underauth_credentials.Codex review iterations
Three rounds of
/codex-review-loop: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).list(toolkit_slugs, user_ids, connected_account_ids); explicit id only resolves inside the trusted envelope.47d000f06functools.partial(proxy, connected_account_id=account_id)lets a runtime kwarg override the pre-bound id; tool callingexecute_request(connected_account_id="ca_other")would run proxy on one account whileauth_credentialscame from another.connected_account_id. Caller-supplied override now raisesTypeError. UpdatedExecuteRequestFnProtocol to match.d2e9fd48fHow did I test this PR
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_requestis 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 throughTools.execute.test_call_pops_connected_account_id_before_request_validation—__call__extracts the kwarg as a separate parameter.test_custom_tools_security.pypass unchanged. Two adjacentmock_executesignatures intest_tool_execution.pyandtest_provider.pyupdated for the new_execute_custom_toolsignature.ruff checkandruff format --checkclean on changed files.mypy --config-file config/mypy.ini composio/clean (50 source files).curl http://localhost:9900/api/v3/tools/execute/proxywith no auth context returns400 ExternalProxy_MissingAuthContext(code 2811). The SDK now bindsconnected_account_idto every custom-tool proxy call so this path is no longer reachable from custom tools.d2e9fd48f).🤖 Generated with Claude Code