getRawComposioToolBySlug relabelled every client error, including an
invalid API key (401), as ComposioToolNotFoundError. Map only 404/400 to
not-found and wrap the rest in a new ComposioToolFetchError that keeps
the client error as cause. Toolkits.getToolkitBySlug compared against
the OpenAI APIError class, so its not-found branch never fired; import
the Composio client class instead. Python mirrors the mapping: an
unknown slug raises ToolNotFoundError (now a NotFoundError), anything
else propagates the composio_client error unchanged.
PRDE-1613
Claude-Session: https://claude.ai/code/session_017HtbhwMAKcfebo8HyXWa5s
This PR:
- wraps `pysher.Pusher` in `_ComposioPusher`, whose channel-auth POST
carries a `(5, 15)` connect/read timeout and raises
`TriggerSubscriptionAuthError` (a `TriggerSubscriptionError`) on a
transport failure, a non-200, or a response without an `auth` token —
pysher 1.0.8 sent it with no timeout and turned a non-200 into a bare
`AssertionError` on the websocket thread, on every (re)subscribe
- keeps that POST a plain `requests.post(..., timeout=...)` rather than
routing it through `safe_request`: the endpoint is built from the
configured Composio API base URL, a fixed trusted host, not a value from
a response, and the SSRF guard would refuse a local dev base URL
- validates `pusher_cluster` against `^[a-z0-9-]+$` (non-empty, at most
64 chars) before pysher formats it into `ws-{cluster}.pusher.com`,
raising `InvalidPusherClusterError` that names the shape violation
without echoing the value
- replaces the `unittest.mock.MagicMock` stand-in for pysher's
connection logger with a dedicated `logging.Logger` (`NullHandler`,
`propagate=False`, disabled), so `unittest` leaves the runtime import
graph while raw frames stay out of user logs; a test asserts the module
source no longer mentions `unittest`
- strips `Authorization`, `Proxy-Authorization`, and `Cookie` from the
next hop when `ssrfSafeFetch` or `safe_request` follows a redirect to a
different origin; same-origin hops keep them. Manual redirect following
bypasses both `fetch`'s cross-origin rule and `requests`'
`rebuild_auth`, so neither guard applied it before — the gap #4387 left
out
- `@composio/slim` has no mirrored source (its build copies
`core/dist`), so the changeset covers `@composio/core` and
`@composio/slim` as patches
Verified with `pytest tests/test_triggers.py tests/test_url_safety.py
tests/test_path_join_guardrail.py` (192 passed), `ruff check` / `ruff
format --check` on the changed files, `mypy --config-file
config/mypy.ini` on the three changed modules with the noxfile's stub
pins (no issues), `vitest run test/utils/ssrfGuard.test.ts` in
`@composio/core` (42 passed), `pnpm typecheck` at the root (14 tasks
successful), and `oxlint` + `prettier --check` on the changed TypeScript
files.
https://claude.ai/code/session_016ZuBv7JhVdSYTLYcTy2VJr
This PR:
- builds on top of https://github.com/ComposioHQ/composio/pull/4271,
whose commit it carries unchanged
- applies the Fetch standard's redirect method/body rules in **both**
SSRF guards via `_redirect_rewrite` / `redirectRewrite`: a `303` retries
as a bodiless request, a `301`/`302` does the same for a `POST`, and
`307`/`308` replay both
- narrows `ssrfSafeFetch` to the five statuses the Fetch standard calls
a redirect, so a `304` or `305` carrying a `Location` is returned to the
caller instead of followed — Python already used
`_REDIRECT_STATUS_CODES`
- drops `params` after the first hop in `safe_request`, since `Location`
carries the query for the target it names and re-appending handed a
query-string credential to a target that never asked for one
- purges the union of the Fetch `request-body-header` set and the two
`requests` also drops, identically on both sides
- blocks the IPv6 transition ranges the TypeScript CIDR list missed —
6to4 `2002::/16`, Teredo and the rest of `2001::/23`, local-use NAT64
`64:ff9b:1::/48`, `100::/64`, `2001:db8::/32`, site-local `fec0::/10` —
and the IPv4/IPv6 multicast and `192.88.99.0/24` ranges Python's
`is_global` missed
## Context
Both guards follow redirects by hand so every hop is revalidated against
the address blocklist. That also means neither inherits the method and
body rewriting `fetch` and `requests` would have done, so an upload
answered with a `303` was replayed — payload and all — at a result URL
that expects a GET.
https://github.com/ComposioHQ/composio/pull/4271 landed that rule in
Python only, which left the two SDKs disagreeing on the same wire
behavior. Reviewing for that divergence surfaced the redirect-status
set, the `params` replay, and the address-blocklist gaps above.
`2002:7f00:1::` is 6to4 for `127.0.0.1`, and it passed the TypeScript
guard as a public address.
Verified with `pytest python/tests/test_url_safety.py` (56 passed) and
`vitest run` in `@composio/core` (54 files, 1280 passed), plus `ruff`,
`tsc --noEmit`, `oxlint` and `prettier`. Fail-before confirmed: 10 of
the new TypeScript cases and 5 of the new Python cases fail against the
unmodified guards.
Two known gaps are deliberately left out, each deserving its own change:
neither guard strips `Authorization`/`Cookie` on a cross-origin
redirect, and a non-seekable Python body is re-sent exhausted on a `307`
where TypeScript throws a bare `TypeError` on a consumed
`ReadableStream`.
https://claude.ai/code/session_01SB3ZJdvoqBcRrWb2toWVrX
---------
Co-authored-by: ump45nose <52391318+ump45nose@users.noreply.github.com>
## Summary
Follow-up to #4310. The TypeScript SDK (since #3800) catches a thrown
backend call for the remote half of a mixed
`COMPOSIO_MULTI_EXECUTE_TOOL` batch and turns it into one failure entry
per remote slug, so completed local results are not lost. The Python SDK
still let the exception escape `_route_multi_execute`, discarding every
local result that had already run.
This ports the TypeScript behavior so both SDKs return the same shape on
a remote transport failure.
Fixes #
## Changes
- Catch the remote future's exception in `_route_multi_execute` and keep
`str(error)`, falling back to `Remote tool execution failed` when the
message is empty (same fallback as TS).
- Synthesize `{response: {successful: False, data: {}, error},
tool_slug, error}` for each remote index and merge them in original
request order.
- Recompute `total_count` / `success_count` / `error_count` on transport
failure, as TS does.
- Add two regression tests mirroring the TS cases in
`customToolRouting.test.ts`: local results preserved with per-tool
remote errors, and the empty-message fallback.
## Type of change
- [x] Bug fix
- [ ] New feature
- [ ] Refactor/Chore
- [ ] Documentation
- [ ] Breaking change
## How Has This Been Tested?
- `uv run --locked --group dev pytest tests/test_custom_tools.py -q`: 87
passed.
- `uv run --locked --group dev nox -s chk`: ruff and mypy clean.
- Without the source change, the new
`test_remote_transport_failure_keeps_local_results` raises
`RuntimeError: remote unavailable` out of `_route_multi_execute`.
## Checklist
- [x] I have read the Code of Conduct and this PR adheres to it
- [x] I ran linters/tests locally and they passed
- [ ] I updated documentation as needed
- [x] I added tests or explain why not applicable
- [ ] I added a changeset if this change affects published packages
(Python does not use Changesets)
## Additional context
TypeScript reference:
`ts/packages/core/src/models/ToolRouterSession.ts`, the
`remoteErrorMessage` branch, and the test "should preserve successful
local results when remote transport fails".
https://claude.ai/code/session_01PAXMbiZd3qPoJ8Z9uPvEAb
EOF -R ComposioHQ/composio
## Summary
`ToolRouterSession._route_multi_execute` currently concatenates remote
results before local results and assigns new indexes from that
concatenated list. For a request such as `[LOCAL_TOOL, REMOTE_TOOL]`,
callers receive `[REMOTE_TOOL, LOCAL_TOOL]`, so code that correlates
`results[index]` with the requested tools can use the wrong result.
This brings the Python implementation in line with the merged TypeScript
behavior in [#3800](https://github.com/ComposioHQ/composio/pull/3800):
preserve each tool's original request index, restore that order after
local/remote execution, and then assign contiguous result indexes.
Fixes #
## Changes
- Preserve the original index on locally executed result entries.
- Map remote sub-batch results back to their original request indexes
before merging.
- Sort the merged results by original index and re-index them
sequentially.
- Update the mixed local/remote regression test to assert request order
and indexes.
## Type of change
- [x] Bug fix
- [ ] New feature
- [ ] Refactor/Chore
- [ ] Documentation
- [ ] Breaking change
## How Has This Been Tested?
- `uv run --locked --group dev pytest tests/test_custom_tools.py -q` —
69 passed.
- `uv run --locked --group dev nox -s tst -- tests/test_custom_tools.py`
— 69 passed.
- `uv run --locked --group dev ruff --config config/ruff.toml check
composio/core/models/tool_router_session.py tests/test_custom_tools.py`
— passed.
- Ruff format check on both changed files — passed.
- Verified the regression test fails on the pre-fix implementation and
passes after the fix.
## Screenshots (if applicable)
Not applicable.
## Checklist
- [x] I have read the Code of Conduct and this PR adheres to it
- [x] I ran linters/tests locally and they passed
- [ ] I updated documentation as needed
- [x] I added tests or explain why not applicable
- [ ] I added a changeset if this change affects published packages
## Additional context
This change is limited to Python multi-execute result ordering.
All-local and all-remote fast paths remain unchanged. No changeset is
needed because this repository does not use Changesets for Python
package changes.
Signed-off-by: CoralGarden52 <2193436736@qq.com>
Construct a fresh OpenAIProvider per SDK instance instead of sharing a
module-level singleton whose execute_tool binding was overwritten by the
last-constructed instance, silently routing tool execution through the
wrong client/API key. Regression test in tests/test_sdk.py.
Fixes#4369
Co-authored-by: Adesh Deshmukh <adeshkd123@gmail.com>
Claude-Session: https://claude.ai/code/session_015YPz5SzeScR9TkgoRi2p1F
EOF -R ComposioHQ/composio
## Summary
The Python Vertex AI Google provider rebuilt tool parameter schemas from
`properties` and `required` without resolving internal `$ref`/`$defs`
references first. As a result, referenced properties were sent as
dangling references and could not be interpreted by Vertex AI.
This change dereferences internal schema references before the existing
Google-specific translation. It follows the provider behavior fixed in
[TypeScript PR #4288](https://github.com/ComposioHQ/composio/pull/4288).
## Changes
- Dereference Google provider input schemas with the existing
`dereference_json_schema` helper.
- Use the resolved schema when extracting properties and required
fields.
- Add a regression test covering a property defined through
`$ref`/`$defs`.
## Type of change
- [x] Bug fix
- [ ] New feature
- [ ] Refactor/Chore
- [ ] Documentation
- [ ] Breaking change
## How Has This Been Tested?
- `pytest tests/test_google_provider.py tests/test_json_schema.py
tests/test_provider.py -q -k 'not TestLangchainReservedKeywords and not
TestLangchainFreeFormObjectArguments'` — 59 passed, 4 skipped, 5
deselected.
- `ruff check --config config/ruff.toml
providers/google/composio_google/provider.py
tests/test_google_provider.py` — passed.
- `ruff format --check providers/google/composio_google/provider.py
tests/test_google_provider.py` — passed.
- `mypy --config-file config/mypy.ini
providers/google/composio_google/provider.py
tests/test_google_provider.py` — passed.
## Screenshots (if applicable)
Not applicable.
## Checklist
- [x] I have read the Code of Conduct and this PR adheres to it
- [x] I ran linters/tests locally and they passed
- [x] I updated documentation as needed
- [x] I added tests or explain why not applicable
- [x] I added a changeset if this change affects published TypeScript
packages
## Additional context
This is a Python-only provider fix; no TypeScript changeset is required.
No existing issue was found for the Python provider, so this PR includes
the minimal reproduction and regression test directly.
---------
Co-authored-by: jkomyno <alberto@composio.dev>
## Summary
- preserve boolean, empty, null, type-array, enum, const, and
scalar-constraint semantics across every Python conversion entry point
- intersect Zod enum and const values with declared types and
constraints, including compound JSON values
- default unversioned exact validation to Draft 7 and apply inclusive
and numeric exclusive bounds independently
- run one byte-identical corpus through Python, Zod, and Effect so
accepted and rejected inputs stay aligned
- keep exact JSON Schema acceptance separate from Pydantic default
materialization
## Review follow-up (second push)
- Python: exact Draft 7 acceptance now wraps all three entry points
(`json_schema_to_pydantic_type`, `json_schema_to_model`,
`pydantic_model_from_param_schema`), so they can no longer disagree
- Python: draft-4 boolean `exclusiveMinimum`/`exclusiveMaximum` (OpenAPI
3.0 style) no longer crash conversion — exact validation falls back to
Draft 4, and the library input is translated to the numeric spelling
- Python: ECMA-only regex patterns (look-around) no longer crash
pydantic model builds — Rust-incompatible patterns fall back to Python
`re`
- Python: type arrays with sibling constraints no longer raise
`TypeError` on valid input — constraints are scoped per member before
the library sees them
- Python: integral floats satisfy `integer`, `const` intersects `enum`,
annotation-only schemas accept anything, and an optional property with
an empty `enum` tolerates absence
- Zod: typeless scalar constraints apply per instance type, and string
lengths count Unicode code points instead of UTF-16 code units
- Effect: draft-4 boolean exclusive bounds are enforced instead of
silently ignored
- `multipleOf` uses decimal scaling in all three converters (declared
`divergesFromJsonSchema` on the corpus case)
- shared corpus grows by 13 primitive cases; new property-based tests
check acceptance against real Draft 7 oracles (hypothesis + `jsonschema`
in Python, fast-check + Ajv in TypeScript)
## Verification
- Python `make chk` (ruff + mypy)
- Python pytest: 1,572 passed (5 langchain-extra tests need an env this
sandbox lacks; unchanged from base)
- `@composio/json-schema-to-zod`: 187 passed incl. 300-run fast-check
property test; typecheck + build
- `@composio/json-schema-to-effect-schema`: 133 passed; typecheck
- `@composio/core` corpus ingress tests: 61 passed
- shared Python/TypeScript corpus files are byte-identical
(shasum-verified)
- `git diff --check`
## Contributor context
This replaces four narrow proposals after independent local
reproduction:
- [#4301](https://github.com/ComposioHQ/composio/pull/4301) ·
[Glen](https://app.tryglen.com/ComposioHQ/composio/pull/4301)
- [#4302](https://github.com/ComposioHQ/composio/pull/4302) ·
[Glen](https://app.tryglen.com/ComposioHQ/composio/pull/4302)
- [#4303](https://github.com/ComposioHQ/composio/pull/4303) ·
[Glen](https://app.tryglen.com/ComposioHQ/composio/pull/4303)
- [#4307](https://github.com/ComposioHQ/composio/pull/4307) ·
[Glen](https://app.tryglen.com/ComposioHQ/composio/pull/4307)
---------
Co-authored-by: simpleqt <89645338+simpleqt@users.noreply.github.com>
## Summary
The Python SDK treated a custom tool's `original_slug` as globally
unique, rejecting valid custom toolkits that reuse common child names
such as `SEARCH`, `VERSION`, or `GREP` even though the backend-assigned
final slugs are toolkit-qualified (`LOCAL_ALPHA_GREP`,
`LOCAL_BETA_GREP`).
This ports the toolkit-qualified lookup from #3360 to Python, then fixes
three response-mapping bugs found in review and applies the same fixes
to the TypeScript SDK so both stay in parity.
## Changes
### Python (`composio`)
- Scope custom-tool collision detection and response matching by toolkit
plus original slug.
- Keep bare original-slug aliases only when unambiguous;
`session.execute("GREP")` raises with the final slugs to use when the
slug is shared.
- Preserve toolkit-qualified final slugs in `custom_toolkits()`.
- `build_custom_tools_map_from_response`: raise when a response tool has
local handles but no exact toolkit match instead of silently dropping it
or binding another toolkit's handler; only fall back to a bare match
when the response carries no toolkit identity; reject duplicate
qualified response entries; derive bare-slug ambiguity from local
definitions so omitting a sibling in the response never makes the
survivor callable by bare name.
- `custom_toolkits()` only reuses a bare alias that belongs to the same
toolkit.
- Docstring and Python session reference page state that bare-slug
execution requires a unique original slug.
### TypeScript (`@composio/core`)
- Same four fixes in `buildCustomToolsMapFromResponse` and the same
guard in `customToolkits()`.
- JSDoc and TypeScript session reference page updated.
- Changeset: patch for `@composio/core`.
### Not changed
- `COMPOSIO_MULTI_EXECUTE_TOOL` still aborts the whole batch when one
item uses an ambiguous bare slug, matching current TS behavior.
Switching to per-item errors is a cross-SDK design change left for a
follow-up.
## Type of change
- [x] Bug fix
- [ ] New feature
- [ ] Refactor/Chore
- [ ] Documentation
- [ ] Breaking change
## How Has This Been Tested?
Python:
- `pytest tests/test_custom_tools.py tests/test_tool_router.py`: 181
passed.
- ruff (project config) clean; mypy reports no errors in the touched
files.
- New tests: sibling routing, multi-execute, preload rejection, listing
guard, and five response-mapping cases (no exact match, cross-toolkit
binding, standalone bare fallback, unknown response tools skipped,
ambiguity from local definitions, duplicate qualified entries).
TypeScript:
- `vitest run` in `ts/packages/core`: 53 files, 1251 passed, 2 expected
failures.
- `tsc --noEmit` clean; prettier and oxlint via pre-commit hook.
- New tests: cross-toolkit reuse in `buildCustomToolsMap` and a new
`buildCustomToolsMapFromResponse` block mirroring the Python cases.
Python and TypeScript CI do not run automatically on this fork PR; a
maintainer needs to approve the workflow run.
## Checklist
- [x] I have read the Code of Conduct and this PR adheres to it
- [x] I ran linters/tests locally and they passed
- [x] I updated documentation as needed
- [x] I added tests or explain why not applicable
- [x] I added a changeset if this change affects published packages
## Additional context
Reviewed with a second opinion from Codex (gpt-5.6-sol), which flagged
the wrong-handler binding and response-derived ambiguity bugs fixed in
the follow-up commits.
https://claude.ai/code/session_01Y7Ni3QEBDGShSrEtwQS5bA
EOF -R ComposioHQ/composio
---------
Signed-off-by: CoralGarden52 <2193436736@qq.com>
Co-authored-by: jkomyno <alberto@composio.dev>
Co-authored-by: Alberto Schiabel <jkomyno@users.noreply.github.com>
## Problem
`composio.utils.mimetypes.guess()` maps `.jpg` to `"image/jpg"`:
```python
".jpe": "image/jpeg",
".jpeg": "image/jpeg",
".jpg": "image/jpg", # <- non-standard
```
`image/jpg` is not a registered IANA media type. It is inconsistent
with:
- its own sibling extensions `.jpe`, `.jpeg`, and `.jfif-bnl`, which all
map to `image/jpeg`
- Python's standard library: `mimetypes.guess_type("x.jpg")` returns
`image/jpeg`
- IANA, which registers `image/jpeg`
`guess()` feeds the `Content-Type` used for presigned uploads
(`_upload_to_presigned_url`), so `.jpg` files are uploaded and stored
with a non-standard content type.
## Fix
Map `.jpg` to `image/jpeg`. One line.
The reverse `_MIME_TO_EXT` map keeps its `"image/jpg": "jpg"` entry on
purpose: the SDK should still accept `image/jpg` when a server sends it
in a `Content-Type` header (liberal in what it accepts, strict in what
it produces).
## Tests
Added coverage in `tests/test_mimetypes.py`: `.jpg`/`.jpeg` in the
known-extensions table plus a dedicated test documenting that `.jpg`
resolves to `image/jpeg`. Verified the new tests fail on the old value
and pass after the fix; the full `test_mimetypes.py` (35 tests) passes.
## Summary
- map Python file-fetch failures that occur after response headers into
the documented upload and download errors
- map TypeScript RemoteFile connection and streamed-body failures into
RemoteFileDownloadError while preserving blocked-URL errors
- close or cancel response bodies on every exit and apply the shared 100
MiB response limit to TypeScript RemoteFile downloads
This supersedes the Python-only proposal in #4305 and carries the same
failure category across both SDKs.
## Independent reproduction
A response double returned one chunk and then raised a connection-reset
error. On current next:
- Python _fetch_file_from_url leaked ConnectionError, although it did
close the response
- Python Tool Router URL fetch leaked ConnectionError and left the
response open
- TypeScript RemoteFile leaked the native fetch/body TypeError instead
of RemoteFileDownloadError
## Verification
- Python make chk
- Python make tst: 1,490 passed
- TypeScript core typecheck
- TypeScript core tests: 1,245 passed, 2 expected failures
- TypeScript package build: 19 packages
- focused Python regression tests: 3 passed
- focused TypeScript RemoteFile tests: 17 passed
## Summary
- write CLI user data, pending login sessions, and agent identities
through one atomic `0600` helper
- repair `0644` credential files created by older CLI versions before
reading them
- redact credential-shaped structured values from CLI user-context
diagnostics
- redact secret-shaped text at both TypeScript and Python SDK log-output
boundaries, including Pusher `auth` responses and exception tracebacks
- preserve Python logger compatibility: errors remain untruncated,
disabled levels remain lazy, and malformed placeholders cannot expose
arguments
## Local reproduction
Under the normal `022` umask, `next` created a plaintext credential file
with mode `0644`. The pre-fix CLI user-context and TypeScript SDK debug
paths also emitted sentinel credentials. The private atomic writer
changes an existing `0644` target to `0600`, and the upgrade tests now
prove all three legacy credential files are tightened without changing
their contents.
## Verification
- CLI permission upgrade tests: 31 passed across user data, pending
login, and agent identity paths
- CLI source and test typechecks passed
- TypeScript core logging, redaction, and Pusher tests: 17 passed
- TypeScript core source and type-test typechecks passed
- Python logging regression tests: 5 passed
- focused Ruff, Prettier, Oxlint, and `git diff --check` passed
The focused CLI runner needed a temporary local alias for the
pre-existing missing `#ssrf_guard` mapping in the CLI Vitest config. The
alias was removed after verification and is not part of this PR.
## Contributor context
Credit to **Syed Anas Mohiuddin**, independent security researcher, for
reporting the legacy CLI credential-file permission issue.
Supersedes [#4300](https://github.com/ComposioHQ/composio/pull/4300) ·
[Glen review](https://app.tryglen.com/ComposioHQ/composio/pull/4300).
The implementation also covers agent credentials, retains atomic writes,
and applies redaction at the shared SDK logging boundary.
Review follow-up on the download size cap.
`requests.exceptions.RequestException` subclasses `OSError`, so the two
handlers added for the write loop were byte-identical and the second already
subsumed the first. Collapse them into one `except OSError` and say why in a
comment, so the next reader does not re-add the redundant clause.
Route partial-file cleanup through `_discard_partial_download`, which
suppresses cleanup failures: an `OSError` from `unlink` would otherwise
replace the `ResponseTooLargeError` or transport error the caller needs.
Cover the two error paths that had no tests: a transport failure mid-stream
and a failing write both raise `ErrorDownloadingFile` and leave no partial
file behind. Without the handler the write failure escapes as a raw
`OSError(28)` — the defect these pin.
Claude-Session: https://claude.ai/code/session_01K1hH9PMmd6KPKdkACX553z
`FileDownloadable.download` streamed the response straight to disk with no
byte accounting, so an untrusted `s3url` could fill the disk. Add the same
`Content-Length` pre-check plus authoritative streamed-byte counter the
sibling `_fetch_file_from_url` already uses, capped at `_MAX_RESPONSE_SIZE`
and overridable per call via `max_size`.
Also close two gaps the write loop left open: an `OSError` from `fd.write`
(disk full, permissions) escaped the documented `ErrorDownloadingFile`
contract, and any failure left a partial file on disk that no caller was
told about. Every failure path now unlinks the partial file;
`ResponseTooLargeError` still propagates uncaught so callers see the limit.
Claude-Session: https://claude.ai/code/session_01K1hH9PMmd6KPKdkACX553z
Extends strict-cases.json to 68 cases with shapes enumerated independently
(single-element and three-member type arrays, null-only and null-carrying
enum/const properties, nested compositions, nullable objects in arrays,
tuple and boolean items, conditional and dependency keywords, oneOf beside
anyOf, boolean and malformed properties, $ref siblings and chains, legacy
definitions next to $defs, non-string required entries, ten-level
nesting) plus null-omission pairs for nullable, composed and $ref-typed
arguments. Checks in the generator that derives the pinned JSON.
Co-authored-by: AseemPrasad <aseemprasad0520@gmail.com>
Claude-Session: https://claude.ai/code/session_01TDrxCHn2hg51HmxVstSUgs
strict-cases.json (one byte-identical copy per language, next to
object-cases.json) pins the exact strict schema or the reported
incompatibilities for 44 shapes: optional widening at every depth,
nullable type arrays, compositions, enum/const wrapping, annotation
stripping, keyword-named and prototype-named properties, dynamic-key and
free-form objects, allOf/prefixItems, $defs recursion, dangling and
external refs, malformed required, non-object roots, plus null-omission
argument pairs. The TypeScript suite pins the implementation and the
Python suite checks parity against the same file.
Co-authored-by: AseemPrasad <aseemprasad0520@gmail.com>
Claude-Session: https://claude.ai/code/session_01TDrxCHn2hg51HmxVstSUgs
OpenAI structured outputs support local $ref pointers, including recursive
definitions, so toStrictJsonSchema no longer inlines them: $defs and
definitions are normalized where they are declared, an optional $ref
property is widened with an anyOf null branch, and external or dangling
$refs are reported as unsupported. omitNullToolArguments follows local
$refs when deciding whether a null is accepted. The Vercel provider still
inlines definitions before converting to Zod, which does not follow $ref.
Co-authored-by: AseemPrasad <aseemprasad0520@gmail.com>
Claude-Session: https://claude.ai/code/session_01TDrxCHn2hg51HmxVstSUgs
The strict flag now calls the base initializer (schema_config kwargs keep
working), emits strict on the wrapped tool, and mirrors the TypeScript
pipeline: optional parameters become required-nullable, unsupported
schemas downgrade the tool to non-strict, and null arguments the tool
schema rejects are dropped before execution.
Co-authored-by: AseemPrasad <aseemprasad0520@gmail.com>
Claude-Session: https://claude.ai/code/session_01TDrxCHn2hg51HmxVstSUgs
# Description
Found by the scheduled security audit, while checking whether a test
failure on my other PR was pre-existing. It was — and the reason it
fails is a real gap in a shipped security control.
`isBlockedSensitiveFileUploadPath` (the GHSA-hp3h-89pf-5q58 denylist)
matched deny segments **only against the symlink-resolved path**. That
catches a benign name pointing at a secret — `~/innocent-name ->
~/nested/.aws/creds`, which the existing test covers — but misses the
inverse:
| Layout | Written path | Resolved path | Blocked before? |
|---|---|---|---|
| `~/innocent -> ~/.aws/creds` | no `.aws` | **`.aws`** | yes |
| `~/.claude -> /state/claude` | **`.claude`** | no `.claude` | **no** |
`~/.claude/settings.json` resolves to `/state/claude/settings.json`,
which has no `.claude` segment, so it sailed through — and `.claude` is
on the denylist precisely because it *"may contain API keys and project
context read by assistants"*.
This layout is not exotic. Dotfile managers (chezmoi, stow, yadm) and
containerised home directories produce it routinely — Composio's own
agent sandbox image has `/home/zen/.claude -> /state/claude`. For every
user in that shape the control was silently inactive, which is the worst
failure mode for a denylist: no error, no warning, upload proceeds.
The same hiding trick applies to the basename check (`~/.env ->
/state/plain-config`), so that path is fixed too.
## Why CI never caught this
`ts/packages/core/test/utils/sensitiveFileUploadPaths.test.ts` **already
asserts** the blocked behaviour:
```ts
expect(isBlockedSensitiveFileUploadPath(path.join(os.homedir(), '.claude', 'settings.json'))).toBe(true);
```
That assertion has been failing on `next` on any machine where
`~/.claude` is a symlink. It passes in CI only because `~/.claude` does
not exist on the runners: `existsSync` is false, no `realpath` runs, and
the written path keeps its `.claude` segment. The test is
environment-dependent, so green CI was never evidence the control
worked.
## The fix
The TypeScript `normalizePath` helper now returns both the written and
resolved segments, and the segment scan and basename check each consider
both. The Python guard now applies the same rule. Either path can carry
the denied name, so both SDKs inspect both forms.
# How did I test this PR
**The TypeScript fix is gated by tests — 3 fail without it, 13/13 pass
with it.**
Without the `src` change (test file only):
```
× blocks common credential directory segments
× blocks a sensitive directory that is itself a symlink to a plain path
× blocks a denied basename whose symlink target is named innocuously
Tests 3 failed | 10 passed (13)
```
With the fix:
```
Test Files 1 passed (1)
Tests 13 passed (13)
```
Note the first of those three is the **pre-existing** assertion quoted
above — this PR turns it green rather than adding it.
Three tests added, each building a real symlink in a temp dir:
- sensitive directory that is itself a symlink to a plain path (the
`~/.claude -> /state/claude` case), asserting both
`isBlockedSensitiveFileUploadPath` and that `assertSafeFileUploadPath`
throws
- denied basename whose symlink target is named innocuously (`.env ->
plain-config`)
- **negative case**: an ordinary file reached through a symlinked
directory (`docs/document.pdf`) is still allowed, so the fix does not
over-block
The Python parity change adds the same three cases. Before the Python
source change, the sensitive written directory and basename both
returned `False`; with the fix, all 11 focused Python tests pass.
**Full verification:**
| Command | Result |
|---|---|
| `vitest run` in `ts/packages/core` | **48 files, 1114 tests passed** |
| `pnpm typecheck` (workspace) | **14/14 tasks successful**, exit 0 |
| `oxlint` on both changed files | exit 0, clean |
| `prettier --check` on both changed files | "All matched files use
Prettier code style!" |
| `nox -s chk` in `python/` | Ruff and mypy passed |
| `nox -s tst` in `python/` | **1339 passed, 33 skipped**, exit 0 |
# Security
- No dependency changes, no new network calls, no new imports. The diff
is limited to the equivalent TypeScript and Python guards, their tests,
and the required `@composio/core` patch changeset.
- This **strengthens** an existing control and cannot weaken it: the
previous match set is a strict subset of the new one, so nothing that
was blocked before is allowed now. The added negative test pins that the
widening does not over-block ordinary files.
- **Grype** — `grype dir:ts/packages/core --only-fixed --fail-on medium`
→ reported below.
- **Socket** — could not run; `doppler secrets get SOCKET_API_TOKEN
--plain --project hermes --config dev_zen` returns empty in this cron
sandbox, so `socket ci` exits `Auth Error`. Reporting rather than
skipping silently.
- Unrelated pre-existing note: the repo's `pnpm audit --prod` comment
flags `extract-zip <=2.0.1` with `Patched versions >=2.0.2`, a version
that does not exist on npm. Details in #4217.
Origin: cron-48e51eab745f /
[zen-cron-44e260352d1a](https://zen.corp.composio.io/dashboard/#/chat/zen-cron-44e260352d1a)
Triggered by: saransh@composio.dev | Source: unknown
Session:
https://zen.corp.composio.io/dashboard/#/chat/zen-cron-44e260352d1a
Both SDKs forwarded "" for a file_uploadable parameter (e.g. Gmail
attachment) verbatim to the backend, which rejected it with a Pydantic
validation error. Python only dropped it inside the opt-in auto-upload
walker; TypeScript never did, and with auto-upload on it tried to upload
the empty string.
Run a schema-aware, upload-free pass on the default execute path that
omits empty file values, and reuse the same walker for staging when
auto-upload is enabled.
Closes#4233
> ### ⚠️ Breaking change
>
> `proxy_execute()` now returns a dict instead of the generated
`SessionProxyExecuteResponse` model. Every caller since `py@0.11.4` that
reads the result with attribute access breaks at runtime with
`AttributeError`.
>
> ```python
> # before
> response.status
>
> # after
> response["status"]
> ```
>
> `data`, `headers`, and `binary_data` follow the same rule. No version
bump or changelog entry ships in this PR. That omission is deliberate,
so the release call stays explicit. Details below.
## Summary
Builds on @AseemPrasad's #4163, which spotted a real problem. Python's
`proxy_execute()` returns the generated client's
`SessionProxyExecuteResponse` directly, while TypeScript's
`proxyExecute()` projects onto a curated shape. Returning the generated
model leaks a regenerated artifact into a public SDK return type.
This PR keeps that fix and resolves the review findings on top. #4163's
commit is preserved with its original authorship. The commits on top
carry the correction and the review fixes.
## What changed relative to #4163
| | #4163 | Here |
|---|---|---|
| Key casing | `binaryData`, `contentType`, `expiresAt` | `binary_data`,
`content_type`, `expires_at` |
| `status` type | declared `int`, returned `200.0` | declared `int`,
returns `200` |
| Test doubles | `SimpleNamespace` | real `SessionProxyExecuteResponse`
/ `BinaryData` |
| `mypy` | fails `nox -s chk` | clean |
| Docs | 3 snippets left broken | fixed |
**Casing.** Python public APIs use snake_case and TypeScript public APIs
use camelCase. The fields and their meanings match across SDKs, and the
spelling follows each language. `session.delete()` already works this
way (`session_id` in Python, `sessionId` in TypeScript), and so does
`RemoteFile` (`expires_at` / `expiresAt`).
**`status` and `size` are narrowed to `int`.** The generated model types
both as `float` and pydantic coerces, so a response read straight off it
renders `200.0` where TypeScript renders `200`. #4163 declared `int` but
still returned `200.0`. That mismatch also failed `nox -s chk`:
```
composio/core/models/session_context.py:56: error: Incompatible types
(expression has type "float", TypedDict item "status" has type "int") [typeddict-item]
```
**Tests use the real generated models again.** `SimpleNamespace` accepts
any attribute name and any type, so it silently tolerates a client
regeneration that renames or retypes a field. It was also what hid the
`float` coercion, since `assert result == {"status": 200}` passes
against `200.0`. The suite now asserts the narrowed types directly. This
matters ahead of the `composio-client` 2.x migration, which types every
response field as `Any` and removes type checking on this projection
entirely. The tests become the only remaining check.
**Simplification.** The projection folds into `proxy_execute_impl`, so
both entry points are a single call rather than an impl-then-normalize
pair. `response.binary_data` is read directly instead of through
`getattr(..., None)`. The defensive default could never fire on a typed
response, but it made mypy infer `Any` and stop checking the projection.
**Docs.** Three Python snippets that read the result as attributes are
fixed, and the response-shape table gets a per-language column. The
follow-up commit also marks `headers` and `data` as nullable in that
table, replaces the "returns the upstream response verbatim" claim with
what the projection actually does, and documents that `expires_at` can
be absent in TypeScript and `None` in Python.
## Breaking change
The method has shipped since `py@0.11.4`. Both directions of the old
access pattern were already inconsistent in the repo.
`python/examples/custom_tools_agent_test.py:95` does `res["status"]`,
which raises `TypeError` on `next` today and is fixed by this PR. The
doc snippets did attribute access and are updated here.
No changelog entry and no version bump are included. That is deliberate,
so the release call stays explicit rather than implied by the merge.
## How Has This Been Tested?
```bash
cd python
mypy --config-file config/mypy.ini composio/ tests/ # clean
ruff check --config config/ruff.toml composio/ tests/ # clean
pytest tests/ # 1336 passed, 33 skipped
```
`ruff format` was run with the repo's pinned toolchain.
## Type of change
- [x] Bug fix
- [ ] New feature
- [ ] Refactor/Chore
- [ ] Documentation
- [x] Breaking change
## Checklist
- [x] I ran linters/tests locally and they passed
- [x] I updated documentation as needed
- [x] I added tests or explain why not applicable
- [ ] I added a changeset if this change affects published packages. Not
applicable: `AGENTS.md` reserves changesets for published TypeScript
packages
https://claude.ai/code/session_01GsD8zvAhrjFwk144oWkD9K
---------
Co-authored-by: AseemPrasad <aseemprasad0520@gmail.com>
Co-authored-by: Kshitij Jhunjhunwala <113939507+KJ-11@users.noreply.github.com>
This PR:
- closes#4152
- normalizes model- and mapping-shaped tool responses at the Python SDK
boundary
- uses `unknown` for direct/schema modifiers and `composio` for Tool
Router execution, matching TypeScript
- caches the untouched fetched tool before schema modifiers so execution
keeps the original toolkit version metadata
- reuses the cached tool during execution instead of retrieving the same
schema twice
- adds behavior regressions for schema, before/after execution,
single-fetch execution, raw metadata preservation, and TypeScript parity
- verifies the Python regressions against both the current Stainless
client and `ComposioHQ/composio-client` at `605f508e`
Fixes#4151.
## The problem
Both SDKs validated a URL by resolving its hostname, and then handed the
*hostname* to the HTTP client, which resolved it again when it opened
the socket. Two lookups, two answers: a short-TTL record under an
attacker's control answers publicly for the check and with
`169.254.169.254`, `127.0.0.1`, or RFC 1918 space for the connect. The
guard passes and the connection lands inside the network — classic
TOCTOU DNS rebinding, documented in both modules until now as a known
residual.
```mermaid
sequenceDiagram
participant SDK
participant DNS as Attacker DNS
participant Meta as 169.254.169.254
Note over SDK,Meta: before
SDK->>DNS: resolve evil.example.com (validate)
DNS-->>SDK: 93.184.216.34 — passes the guard
SDK->>DNS: resolve evil.example.com (connect)
DNS-->>SDK: 169.254.169.254
SDK->>Meta: GET /latest/meta-data/…
Meta-->>SDK: credentials
```
## The fix
Resolve once, validate every answer, then connect to the address that
was validated. There is no second lookup left to rebind.
- **Python** — `safe_get` / `safe_request` mount a transport adapter
that swaps the connect target for the duration of the socket connect
only. The `Host` header and TLS SNI keep the hostname, so certificate
verification is unchanged; rewriting `conn._dns_host` for the whole
connection would have sent `Host: <ip>` and offered the IP as SNI,
failing against every real origin. Every fetch call site now goes
through those two helpers, so no `requests.get` sits next to a bare
check any more:
- `_files.py::_fetch_file_from_url`,
`_files.py::FileDownloadable.download`
- `tool_router_session_files.py::_fetch_url_bytes`
- `safe_request`, per redirect hop
- **TypeScript** — `assertSafeFetchTarget` returns the validated address
and `ssrfSafeFetch` hands `fetch` a dispatcher pinned to it, re-pinned
per redirect hop. The dispatcher goes to the runtime's own `fetch`, so
callers that stub `globalThis.fetch` keep working. The pinned `lookup`
answers both shapes Node calls it with — the address *list* it uses for
Happy Eyeballs, and the single `(address, family)` it uses when
`autoSelectFamily` is off — since answering in the wrong shape is
rejected as an invalid address.
- A fail-closed peer assertion runs on the Python side before a byte is
written to the socket — redundant while pinning works, and a tripwire if
a urllib3 upgrade ever breaks it.
- `workerd` is unchanged: it already fails closed for user-supplied
URLs.
Redirect *validation* already existed in both SDKs (`safe_request` /
`ssrfSafeFetch`); what was missing was re-pinning each hop.
## Tests
The existing suites could not express this bug: they mock both the
resolver and the HTTP client, so check and use are the same mock. The
new tests use real sockets.
- `python/tests/test_url_safety_pinning.py` — two loopback servers and a
resolver that answers the first lookup with one endpoint and every later
one with another, which is what a short-TTL rebinding record does.
Asserts the rebound endpoint receives **zero** connections, and that
`Host` still carries the hostname. Both tests fail on `next` and pass
here.
- `ts/packages/core/test/utils/pinnedDispatcher.node.test.ts` — a real
server plus a hostname under `.invalid`, which RFC 2606 guarantees never
resolves. A request that arrives proves the connect used the pinned
address and never consulted DNS. The third case shows the contrast:
unpinned, the same fetch cannot resolve at all.
- `ssrfGuard.test.ts` gains assertions that each hop is pinned to that
hop's own validated address.
- `pinnedDispatcher.node.test.ts` also pins with
`setDefaultAutoSelectFamily(false)`, which is the branch Node takes for
the single-address callback.
## Notes
- Supersedes #4157, which diagnosed this correctly. Its post-response
peer check turned out not to hold: with an HTTP/1.0 or `Connection:
close` server, urllib3 detaches the socket (`conn.sock is None`) while
`r.content` still returns the full body, so the check fails open exactly
where exfiltration succeeds. That is why the assertion here runs at
connect time instead.
- The Python package now declares `urllib3>=2` directly. `url_safety`
imports it for `NameResolutionError`, which only exists from 2.0, and
the pinning adapter reaches into 2.x connection internals; `requests`
alone allows 1.x, where `import composio` would have failed outright.
- `@composio/core` gains an `undici` dependency, pinned to `^7`: undici
8 dispatchers are rejected by the `fetch` in every Node version this
package supports (22/24/25, verified). The real-socket test runs on the
full CI matrix, so a future incompatibility fails loudly instead of
silently un-pinning.
- `undici` is imported on first pinned request rather than at module
load: importing it installs a process-wide global dispatcher, which
would have handed the host application's own unrelated `fetch` calls
this package's undici merely because it imported `@composio/core`.
- Residuals, now documented in the modules:
- Requests routed through an environment proxy keep the pre-flight check
only. The proxy resolves the hostname itself and the SDK cannot see or
pin that resolution.
- A process that does perform a pinned fetch still ends up on this
package's `Agent` if nothing had claimed the global dispatcher slot yet.
undici defines that slot non-configurable, so it cannot be handed back —
assigning `undefined` leaves the runtime's own `fetch` asserting on a
missing dispatcher.
## Problem
Provider tool-call helpers always used the globally injected direct
`Tools.execute` function. When a model received tools from
`session.tools()`, calling `handleToolCalls` or `handle_tool_calls`
therefore discarded the Tool Router session context and caused session
meta-tools such as `COMPOSIO_SEARCH_TOOLS` to fail.
Calling `session.execute()` manually preserved the session, but bypassed
provider behavior such as Anthropic input normalization and schema-alias
restoration.
## Root fix
- Add an explicit execution target to the non-agentic provider helpers:
- TypeScript: `handleToolCalls(session, response)` and
`executeToolCall(session, call)`
- Python: `handle_tool_calls(response=response, session=session)` and
`execute_tool_call(tool_call=call, session=session)`
- Route normalized provider arguments through the supplied Tool Router
session.
- Map session responses back to each helper's existing result shape.
- Keep provider-specific normalization before execution, including
Anthropic schema-alias restoration.
- Reject direct-only options and modifiers when the selected target is a
session, including plain JavaScript calls that bypass the TypeScript
overloads.
- Update OpenAI and Anthropic examples to use the session-aware helpers.
- Harden the docs policy test so setup and execution split across fences
in one sample are still detected.
## Docs review follow-ups
- Reword the concepts-page prohibition so it forbids user-ID-bound
helper calls, not the helpers themselves, matching the provider pages in
this PR.
- Add minimum-version callouts to the OpenAI and Anthropic provider
pages (Python `composio` newer than 0.19.0; TypeScript `@composio/core`
≥ 0.17.0 with `@composio/openai` ≥ 0.12.0 / `@composio/anthropic` ≥
0.11.0), pointing older versions at `session.execute()`.
- Bump `docs/package.json` to `@composio/core` `^0.15.0` and
`@composio/openai` `^0.11.0` (the published majors at the time of the
bump; `@composio/core` 0.16.0 and `composio` 0.19.0 have since released
from `next` without this PR, so its changeset will publish core 0.17.0
and the next Python minor) and annotate each `@errors: 2345` Twoslash
marker with a TODO naming the minor version that retires it; since this
changeset releases minors, all three pins need a manual range bump to
retire the markers. This version of twoslash only throws on *unlisted*
errors, so a stale marker cannot break the build — it would only mask
future TS2345s, which the TODOs now track.
- Update `SESSION_GUARDRAILS` (the block appended to `.md` responses for
agents): add a session-execution bullet (scoped to the OpenAI and
Anthropic helpers, with `session.execute()` for every other provider)
and qualify the direct-execution list with "with a user ID". The
session-execution static test now scans the guardrail blocks like the
execute-version test already did.
- Tighten the docs detector: the Python branch is bounded to the helper
call's argument list (tolerating one level of nested calls) instead of
running past the closing paren, and the TypeScript branch catches whole
user-ID identifiers (`userId`, `user_id`, `uid`) without flagging
session variables like `userSession` — each edge has a regression test.
- Note on the Google provider page that its `executeToolCall` is not
session-aware yet.
## Compatibility and release
Existing user-ID calls remain unchanged and continue to use direct tool
execution. The new session call forms are additive.
The changeset applies minor releases to `@composio/core`,
`@composio/openai`, and `@composio/anthropic` — the new session
overloads are a type-level break for provider subclasses, so patch was
too small. The configured fixed group also includes `@composio/slim`.
The docs site intentionally checks examples against currently published
SDK declarations. The three new TypeScript calls therefore carry exact
Twoslash `TS2345` release-skew annotations; remove them (per the inline
TODOs) once `docs/package.json` picks up `@composio/core` ≥ 0.17.0,
`@composio/openai` ≥ 0.12.0, and `@composio/anthropic` ≥ 0.11.0.
## Verification
- `@composio/core`: 1,061 tests passed; typecheck passed
- `@composio/openai`: 34 tests passed; typecheck passed
- `@composio/anthropic`: 53 tests passed; typecheck passed
- Python provider and aliasing suites: 40 passed, 4 skipped
- Focused Python mypy and Ruff checks passed
- Docs static suite: 208 tests passed (including the new guardrail-scan
and detector cases)
- Docs production build passed with the bumped `@composio/core` 0.15.0 /
`@composio/openai` 0.11.0, including Twoslash, TypeScript, and all
generated pages
- Docs lint passed; lint reports only existing warnings
- Changeset status reports the expected minor packages
---------
Co-authored-by: Soumya Medapati <soumyamedapati@soumyas-air.local.meter>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: jkomyno <alberto@composio.dev>
This PR:
- fixes#4153
- supersedes https://github.com/ComposioHQ/composio/pull/4154 (fork PR
authored against a pre-#4146 tree; its tests patch `requests.put`, which
`upload()` no longer calls after #4146, so they fail on merge)
- adds `parse_content_length()` in `composio.utils.url_safety`, shared
by both URL fetch helpers (`_files.py::_fetch_file_from_url` and
`tool_router_session_files.py::_fetch_url_bytes`), so a
malformed/negative remote `Content-Length` degrades to unknown size
under the streamed byte count instead of raising a raw `ValueError`
- converges the file and bytes upload paths onto
`_upload_to_presigned_url()`: one PUT that sends the `Content-Type` the
presign request was signed with and raises `ErrorUploadingFile` carrying
the HTTP status (a 403 no longer collapses into a path-only error)
- single-sources the presign wire shape via
`_request_presigned_upload()`; `from_path()` forwards the exact mimetype
it minted, so signed and sent content types cannot drift
- mirrors the TypeScript SDK: `uploadFileToS3` funnels path/URL/File
inputs through one uploader that always sends `Content-Type` and throws
on non-2xx, and `readResponseBodyWithLimit` trusts `Content-Length` only
as a hint
- adds tests against the `safe_request` seam
(`test_file_upload_robustness.py`, plus one malformed-header case for
`RemoteFile.buffer()`)
## Context
Both defects in #4153 were symptoms of two parallel upload paths
drifting: the bytes path sent `Content-Type` and raised with the status,
the file path did neither. Patching the symptom in place (as #4154
proposed) would have left three presigned PUT sites and two error
contracts in the tree; this PR deletes the drift vector instead. Full
suite green (1320 passed), `make chk` and `make snt` clean; Python-only
change, so no changeset per `AGENTS.md`.
---------
Co-authored-by: Mustaqeem66 <265153888+Mustaqeem66@users.noreply.github.com>
This PR:
- builds on top of https://github.com/ComposioHQ/composio/pull/4144
- routes every fetch whose URL comes from an API response through the
SSRF guards that already existed — ten sinks, five per SDK: the
tool-execution download, both S3 presigned uploads,
`RemoteFile.buffer()`/`blob()`, and the Tool Router session file upload
- adds `safe_request()` (Python), which follows redirects itself and
re-validates each hop, so a validated URL cannot 302 into private space
and an S3 307 region redirect still works
- makes `RemoteFile.buffer()` (Python) share `_fetch_url_bytes` with the
user-supplied-URL path instead of duplicating it — it previously read
`response.content` with no size cap, no redirect control, and no target
validation
- adds `ssrfSafeFetchWhereSupported` (TypeScript): the full guard on
Node, a plain `fetch` on workerd, so Tool Router session file transfers
keep working in edge runtimes rather than failing closed
- documents DNS rebinding as a known residual in both guards
- adds tests that assert the guard *runs* — blocked URL, sink never
called, nothing written — rather than that a transfer succeeds
## Context
`python/AGENTS.md` states the trust boundary: every field of an API
response is untrusted input, because the backend may be compromised or
the connection MITM'd. Both SDKs enforced that for URLs a *user* passes
in (`composio.utils.url_safety`, `ssrfGuard.node.ts`) and left the URLs
an API *response* supplies unguarded — backwards relative to the stated
model. A response naming an internal address turned the SDK into a
request proxy for it, and the fetched bytes were written to disk or
returned to the caller, typically into an LLM context.
`RemoteFile.buffer()` was the weakest of the ten: four lines above it,
`_fetch_from_url` validated its target, refused redirects, and streamed
against a 100 MiB cap; `buffer()` did none of the three. The only
difference between them was which side of the trust boundary the URL
arrived from.
Three decisions worth review:
- **The guard is unconditional.** Presigned URLs are public, so nothing
legitimate should resolve to private space. There is no escape hatch,
and no new configuration surface.
- **The tool-execution download stays uncapped.** It streams straight to
disk, so `_MAX_RESPONSE_SIZE` — a memory-exhaustion bound — does not
apply, and tool attachments legitimately exceed it.
`RemoteFile.buffer()` *is* capped, because it buffers in memory.
- **Uploads follow redirects with re-validation rather than refusing
them**, since S3 can answer a PUT with a 307 region redirect. Downloads
keep `allow_redirects=False`, matching the existing fetch paths.
Python now matches the TypeScript guard's per-hop re-validation, which
was the better of the two implementations.
Verified locally: `make chk` and `make tst` clean on the Python side;
`pnpm -C packages/core test` 1095 passed, typecheck and lint clean.
This PR:
- centralizes filesystem path construction for API-provided slugs and
filenames in `composio.utils.safe_path`
- rejects traversal, Windows-invalid names, invalid Unicode, and
overlong encoded filenames before creating directories or writing files
- normalizes trusted roots consistently and routes both Python download
paths through the shared helpers
- adds a fail-closed AST guard for new dynamic path construction,
including direct `Path(...)` calls
- isolates provider initialization from the real home directory and
makes the home-write guard report changes without deleting them
- removes the obsolete download filename wrapper
## Verification
- `pytest -q`: 1,248 passed, 47 skipped
- repository-configured Ruff checks and formatting passed for every
changed Python file
- targeted mypy checks passed for the changed helpers and tests
The dynamic-key reference walker descended into every nested dict, so a
$ref-shaped value stored under const, default, enum, or examples was
treated as a schema reference and raised while the tool was wrapped.
Restrict the walk to Draft 7 schema positions.
## Summary
Telemetry error text is redacted before it leaves the process, but the
key/value rule only matches when the separator follows the key name
directly:
```
\b(authorization|api[-_]?key|...|pwd)\b(\s*[:=]+\s*)(["']?)([^\s"',}&]+)\3
```
In JSON — and in a Python `dict` repr — the key's own closing quote sits
between the name and the colon, so `{"api_key": "..."}` never matches
and the value is sent verbatim. That is the shape error messages usually
carry: an API error envelope, a rejected request body echoed back, or an
f-string interpolating a config dict. Both SDKs share the pattern and
both are affected.
The fix moves the optional quote into the separator group
(`(["']?\s*[:=]+\s*)`). The key name, separator, and quoting are all
preserved in the output; only the value is replaced.
Measured with `redact_sensitive_text` / `redactSensitiveText` directly,
before and after:
| input | before | after |
|---|---|---|
| `api_key=sk-1` | redacted | redacted |
| `api_key: "sk-1"` | redacted | redacted |
| `x-api-key: sk-live-hdr` | redacted | redacted |
| `{"api_key": "sk-live-abc"}` | **leaks** | `{"api_key": "[REDACTED]"}`
|
| `{"api_key":"sk-live-abc"}` | **leaks** | redacted |
| `{"api_key" : "sk-live-abc"}` | **leaks** | redacted |
| `{"refresh_token":"rt-abc.def-123"}` | **leaks** | redacted |
| `{"x-api-key":"sk-hdr","user":"bob"}` | **leaks** | redacted,
`"user":"bob"` kept |
| `{'client_secret': 'cs-live-abc'}` | **leaks** | redacted |
| `the password field is required` | untouched | untouched |
| `no separator here "api_key" and nothing else` | untouched | untouched
|
The end-to-end path in Python is `composio/core/models/base.py:60-61`,
which passes `str(e)` and `traceback.format_exc()` through the redactor
into the telemetry `error` field. A tool failure whose message
interpolates the rejected request body — `Error executing tool: request
body was rejected: {"toolkit": "GMAIL", "arguments": {"api_key": "...",
...}}` — leaked the customer's key today.
## Changes
- `python/composio/utils/redaction.py`,
`ts/packages/core/src/telemetry/redact.ts`: allow a quote between the
key name and the separator.
- Tests on both sides for JSON, minified JSON, spaced-colon JSON, dict
repr, the realistic serialized-payload shape, key/quote preservation,
and no-false-positive cases.
- Changeset for `@composio/core`.
## Type of change
- [x] Bug fix
## How Has This Been Tested?
Node 24.13.0 / pnpm 11.8.0 / Python 3.10.20 (uv), Windows.
- `uv run pytest tests/test_redaction.py` — 13 passed. With
`redaction.py` reverted and the new tests kept, 9 of them fail; the 4
that still pass are the pre-existing test plus the three
no-false-positive controls.
- `npx vitest run test/telemetry/redact.test.ts` in `ts/packages/core` —
10 passed. With `redact.ts` reverted, the 3 new cases fail.
- Full Python suite: 913 passed, same 4 failures as on `next` unchanged
(3 are `ModuleNotFoundError: composio_langchain` from providers not
installed in my env; 1 is `test_empty_name_safe_fails_at_write_time`,
which expects `IsADirectoryError` but Windows raises `PermissionError`
when opening a directory). Collection 944 → 956, exactly the 12 tests
added.
- Full `@composio/core` vitest: 798 passed / 2 failed, byte-identical to
the unchanged baseline (794 passed / same 2 failed — both Windows
path-and-symlink cases). 11 test files fail to load in both runs because
I installed with `--ignore-scripts`; the repo's `preinstall` hook
doesn't run in my shell.
- `ruff format --check` and `ruff check` clean on the two Python files;
`prettier --write` applied to the two TypeScript files.
## Checklist
- [x] I have read the Code of Conduct and this PR adheres to it
- [x] I ran linters/tests locally and they passed
- [ ] I updated documentation as needed — no user-facing behavior change
to document
- [x] I added tests or explain why not applicable
- [x] I added a changeset if this change affects published packages
## Additional context
This is defence-in-depth, so it stays deliberately narrow: same denylist
of key names, same value character class, no new rules. Bare-prefix
secrets (`sk-...`, `ghp_...`) and PEM blocks are still not matched by
either SDK — worth a separate look, but widening the pattern is a
different risk trade-off than closing a shape the rule already intends
to cover.
`json_schema_to_model` and `json_schema_to_pydantic_type` now share one
compiled object policy, so provider-facing Pydantic models stop silently
discarding valid free-form arguments and stop ignoring `patternProperties` and
explicit `additionalProperties` controls. Accepted dynamic keys are written back
into `__pydantic_extra__`, which is what `LangchainProvider.wrap_tool` re-reads
with `getattr`.
Acceptance and rejection are asserted from the shared cross-SDK corpus, so the
Python entry points cannot diverge from the TypeScript converters.
Property-less objects (`{ type: "object" }`, `properties: {}`) were being
collapsed to a strict empty object by the Zod converter and given an implicit
`additionalProperties: false` by the Effect converter, so valid free-form
payloads such as METABASE_POST_API_CARD.dataset_query were rejected at the CLI
boundary. `ToolSchema.parse` separately dropped root `patternProperties` and
rejected a schema-valued root `additionalProperties`.
Dynamic keys are now routed to exactly the schemas that apply to them, and the
shared acceptance contract lives in one checked-in corpus with byte-identical
TypeScript and Python copies.
## Problem
TriggerSubscription._handle_chunked_events is bound directly as a Pysher
channel callback. Pysher channel dispatch has no local exception
boundary, so malformed chunk frames reached websocket-client error
handling and marked the realtime connection failed. The normal payload
parser already skips malformed frames, but the chunked path did not.
## Fix
- Validate that each decoded frame is an object with string id/chunk,
integer non-boolean index, and boolean final fields.
- Contain decoder, validation, and reassembly failures at the callback
boundary and log only a bounded frame preview.
- Clear partial state only when the frame carries a validated string id,
allowing that id to be reused safely.
- Preserve the valid chunk reassembly path and fix the Chunked docstring
typo.
## Regression coverage
Tests cover invalid JSON, non-object frames, missing fields, unhashable
ids, wrong field types, unexpected decoder failures, and successful
same-id reassembly immediately after a bad frame clears partial state.
## Verification
- nox -s tst -- tests/test_triggers.py: 83 passed
- make chk: Ruff and mypy passed
---------
Signed-off-by: Checo <sergio@checo.cc>
Co-authored-by: Checo <sergio@checo.cc>
Co-authored-by: jkomyno <alberto@composio.dev>