Skip to content

fix(python-sdk): plumb connected_account_id through CustomTool [PLEN-2345]#3312

Open
zen-agent wants to merge 4 commits into
nextfrom
zen/plen-2345-custom-tool-connected-account-hoobk9
Open

fix(python-sdk): plumb connected_account_id through CustomTool [PLEN-2345]#3312
zen-agent wants to merge 4 commits into
nextfrom
zen/plen-2345-custom-tool-connected-account-hoobk9

Conversation

@zen-agent
Copy link
Copy Markdown
Contributor

@zen-agent zen-agent commented Apr 29, 2026

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 P1retrieve(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 P1functools.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_fallbackexecute_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.
  • Lintruff check and ruff format --check clean on changed files.
  • Type checkmypy --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

@zen-agent zen-agent requested a review from jkomyno as a code owner April 29, 2026 10:50
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 29, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview, Comment Apr 30, 2026 7:14am

Request Review

@zen-agent
Copy link
Copy Markdown
Contributor Author

Post-PR lifecycle status

Codex review (/codex-review-loop)

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.
  • mypy and ruff clean.
  • Local Apollo E2E confirmed the bug premise: POST /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.

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.

zen-agent and others added 3 commits April 29, 2026 12:23
…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>
@zen-agent zen-agent force-pushed the zen/plen-2345-custom-tool-connected-account-hoobk9 branch from d2e9fd4 to ab82149 Compare April 29, 2026 12:23
Copy link
Copy Markdown
Contributor

@jkomyno jkomyno left a comment

Choose a reason for hiding this comment

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

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.

Comment thread python/tests/test_custom_tools_security.py Outdated
@jkomyno
Copy link
Copy Markdown
Contributor

jkomyno commented Apr 29, 2026

Non-blocking docs nit: the top-level security model in python/composio/core/models/custom_tools.py now understates the trust boundary. Since this PR also makes connected_account_id a structurally separate trusted parameter, it would be clearer to mention CustomTools.execute(slug, request, user_id, connected_account_id) and invoke_trusted(..., connected_account_id=...) there too, not only user_id.

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>
@jkomyno
Copy link
Copy Markdown
Contributor

jkomyno commented May 7, 2026

Reviewed for changeset/release housekeeping — no changeset needed, ships as a patch.

  • Scope is Python-only, so the .changeset/ system (TS-only — every entry under pnpm-workspace.yaml's ts/ packages) doesn't apply.
  • python/CHANGELOG.md was created Feb 2026 by an unrelated docs PR (docs: separate webhook & event notification guides from triggers #2606) and hasn't been edited since — 0.8.11 there vs 0.12.0 in pyproject.toml vs 1.0.0-rc2 in __version__.py. The release workflow (.github/workflows/py.release.yml) doesn't read it; python/scripts/bump.py doesn't write it. No PR I checked adds entries to it. Adding [Unreleased] would just sit there.
  • Bump level: patch. Restores broken behavior (proxy 400 ExternalProxy_MissingAuthContext on the (request, execute_request, auth_credentials) shape), no public API change, no new feature surface. make bump --patch whenever the next Python release is cut.

The PR title + body already serve as the release note via git log, which seems to be the actual convention here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants