feat(sdk): default-off auto file upload/download; add dangerouslyAllowAutoUploadDownloadFiles (#3260)

## Summary

Automatic tool file upload/download for `file_uploadable` fields is
**off by default** in TypeScript and Python. Callers must explicitly opt
in, and uploads from local paths are constrained by a fail-closed
allowlist.

## Changes

- **Removed (breaking):** `autoUploadDownloadFiles` (TS) /
`auto_upload_download_files` (Python) — the legacy default-on flag is
gone, not just deprecated.
- **New opt-in:** `dangerouslyAllowAutoUploadDownloadFiles` (TS) /
`dangerously_allow_auto_upload_download_files` (Python). When `true`,
`tools.get(...)` collapses `file_uploadable` schemas to `{ type:
'string', format: 'path' }` and the SDK stages local paths/URLs at
execute time.
- **New:** `fileUploadDirs?: string[] | false` — fail-closed allowlist
for local upload paths. `undefined` → `[<home>/.composio/temp]`; `false`
→ reject all local paths (URLs / `File` objects unaffected); explicit
`string[]` replaces the default. Components are matched on a path
boundary after `realpath`.
- **New:** `fileDownloadDir?: string` — directory where
`file_downloadable` results are staged.
- **New:** `beforeFileUpload` hook receives `source: 'path' | 'url' |
'file'` (TS) / `'path' | 'url'` (Python) so it can branch on input type.
- **New (TS):** when auto-upload is **off** and an LLM-driven
`tools.execute` is called against a tool with `file_uploadable` inputs,
the SDK emits a one-shot warning per tool slug pointing at
`composio.files.upload()` for manual staging.

## Migration

To restore previous behavior:

```ts
new Composio({
  apiKey: process.env.COMPOSIO_API_KEY!,
  dangerouslyAllowAutoUploadDownloadFiles: true,
  // Optional: tighten the allowlist beyond the default ~/.composio/temp
  fileUploadDirs: ['/srv/uploads'],
});
```

```python
Composio(api_key="...", dangerously_allow_auto_upload_download_files=True)
```

If you previously passed the legacy flag, remove it. There is no
transitional warning — TS and Python both reject the unknown property at
the type/keyword-arg level.

## Versioning

| Package | Bump |
| ------- | ---- |
| `@composio/core` | minor |
| `composio` (Python) | minor |
| Other `@composio/*` packages | patch (via changesets
`updateInternalDependencies: "patch"`) |

See
`docs/content/changelog/04-24-26-legacy-auto-upload-config-removal.mdx`
for the full migration writeup.
This commit is contained in:
Musthaq Ahamad
2026-04-28 10:55:03 +05:30
committed by GitHub
parent 7833aa1a31
commit ebc9778595
58 changed files with 2907 additions and 251 deletions
+16
View File
@@ -0,0 +1,16 @@
---
'@composio/core': minor
---
Make automatic file upload/download opt-in and add scoped allowlist controls.
- **Removed:** the legacy `autoUploadDownloadFiles` constructor option. Code that sets it must migrate to the new opt-in flag.
- **Added:** `dangerouslyAllowAutoUploadDownloadFiles` (default `false`). When `true`, the SDK collapses `file_uploadable` schemas to `{ type: 'string', format: 'path' }` for the model and stages local paths/URLs at execute time.
- **Added:** `fileUploadDirs?: string[] | false` — fail-closed allowlist for local upload paths. `undefined` defaults to `[<home>/.composio/temp]`; `false` rejects all local paths; an explicit `string[]` replaces the default.
- **Added:** `fileDownloadDir?: string` — directory used to stage S3 downloads on `file_downloadable` results.
- **Added:** `beforeFileUpload` modifier now receives `source: 'path' | 'url' | 'file'` so hooks can branch on the original input type.
- **Added:** when auto-upload is **off** but a tool with `file_uploadable` inputs is executed, the SDK emits a one-shot warning per tool slug pointing at `composio.files.upload()` for manual staging.
See the changelog entry at [`docs/content/changelog/04-24-26-legacy-auto-upload-config-removal.mdx`](https://github.com/ComposioHQ/composio/blob/master/docs/content/changelog/04-24-26-legacy-auto-upload-config-removal.mdx) for migration steps.
Provider packages that depend on `@composio/core` receive automatic patch bumps in the same release train via the changesets `updateInternalDependencies: "patch"` setting — no public-API change in those packages.
@@ -242,6 +242,10 @@ Changed the `autoUploadDownloadFiles` default configuration for the Cloudflare W
}
```
<Callout type="warn">
**Removed in a later release.** The `autoUploadDownloadFiles` flag described above was removed in the v0.7.0 / Python v0.12.0 release train. Automatic file handling is now off by default on every runtime, and you opt in with `dangerouslyAllowAutoUploadDownloadFiles` instead. See [SDKs remove legacy automatic file handling config](/docs/changelog/2026/04/24) for the full migration.
</Callout>
**Benefits:**
- **Zero config** for common use cases
@@ -23,6 +23,7 @@ Custom tools support three patterns — standalone tools for internal logic, ext
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio, experimental_createTool, experimental_createToolkit } from "@composio/core";
import { z } from "zod/v3";
// ---cut---
@@ -29,6 +29,7 @@ See the [importing connections guide](/docs/importing-existing-connections) for
New `PATCH /api/v3/connected_accounts/{nanoid}` endpoint and SDK method — update credentials and alias on existing connections without recreating them.
```typescript
// @noErrors
import { Composio } from '@composio/core';
const composio = new Composio({ apiKey: 'your-api-key' });
// ---cut---
@@ -11,6 +11,7 @@ This release adds two features for managing multiple accounts per toolkit.
Sessions now support a `multiAccount` config that lets users connect and use multiple accounts for the same toolkit (e.g., work and personal Gmail) within a single session.
```typescript
// @noErrors
import { Composio } from '@composio/core';
const composio = new Composio({ apiKey: 'your_api_key' });
// ---cut---
@@ -30,6 +31,7 @@ By default, multi-account mode is disabled and each session uses one account per
Connected accounts can now have human-readable aliases (e.g., `"work-gmail"`, `"personal-github"`). Aliases can be set during connection or updated after.
```typescript
// @noErrors
import { Composio } from '@composio/core';
const composio = new Composio({ apiKey: 'your_api_key' });
const session = await composio.create('user_123');
@@ -0,0 +1,156 @@
---
title: 'SDKs remove legacy automatic file handling config'
description: 'SDKs remove deprecated `autoUploadDownloadFiles` / `auto_upload_download_files`. Automatic file handling is off unless you set `dangerouslyAllowAutoUploadDownloadFiles` / `dangerously_allow_auto_upload_download_files`. Minor releases.'
date: '2026-04-24'
---
The Composio SDKs no longer accept the legacy flags that previously controlled automatic file upload and download during tool execution. Automatic handling of `file_uploadable` fields stays **off by default**; you opt in with the explicit `dangerously*` option only.
<Callout type="warn">
**Breaking change**
Those legacy properties are removed from the public constructors and config types. Code that still passes them must migrate. Anyone who **relied on the old default-on behavior** (no file flag in the constructor) must now **explicitly** set the `dangerously*` flag to `true` to keep automatic path/URL file handling.
</Callout>
### SDK versions (minor releases)
| Package | Previous | This release |
| ------- | -------- | ------------ |
| TypeScript `@composio/core` | v0.6.11 | **v0.7.0** |
| Python `composio` | v0.11.6 | **v0.12.0** |
Other `@composio/*` npm packages that depend on core are published as **patch** bumps in the same release train (via the changesets `updateInternalDependencies: "patch"` setting) so they pick up the updated `ComposioConfig` surface — they have no public-API changes of their own.
### Migration
Earlier SDK versions treated automatic file upload/download for `file_uploadable` tool fields as **on by default** (TypeScript: `autoUploadDownloadFiles` defaulted to `true` when unset; Python followed the same idea for `auto_upload_download_files` in prior releases). **That default is now off.** If you still want that behavior, you must **explicitly** set `dangerouslyAllowAutoUploadDownloadFiles: true` (TypeScript) or `dangerously_allow_auto_upload_download_files=True` (Python).
**TypeScript — before (legacy):**
```typescript
// @noErrors
// If you omitted autoUploadDownloadFiles, it used to default to true
new Composio({
apiKey: process.env.COMPOSIO_API_KEY!,
});
// Equivalent when you set it explicitly:
new Composio({
apiKey: process.env.COMPOSIO_API_KEY!,
autoUploadDownloadFiles: true,
});
```
**TypeScript — going forward** (set `true` to match either legacy pattern above):
```typescript
// @noErrors
new Composio({
apiKey: process.env.COMPOSIO_API_KEY!,
dangerouslyAllowAutoUploadDownloadFiles: true,
});
```
To leave automatic file handling **off** (new default), omit `dangerouslyAllowAutoUploadDownloadFiles` or set it to `false`.
**Python — before (legacy):**
```python
# If you omitted auto_upload_download_files, it used to default to True (prior releases)
Composio(api_key="...")
# Equivalent when you set it explicitly:
Composio(api_key="...", auto_upload_download_files=True)
```
**Python — going forward** (set `True` to match either legacy pattern above):
```python
Composio(api_key="...", dangerously_allow_auto_upload_download_files=True)
```
If you construct `ToolRouterSession` directly (unusual), the keyword argument is now `dangerously_allow_auto_upload_download_files` instead of `auto_upload_download_files`.
### Local paths must live inside an allowlisted directory
Even with `dangerouslyAllowAutoUploadDownloadFiles` enabled, the SDK will **only** auto-upload local file paths that resolve inside an allowlisted directory. Paths outside the allowlist are rejected with `ComposioFileUploadPathNotAllowed` (TypeScript) / `FileUploadPathNotAllowed` (Python) before any file is read or sent.
This is a behavior change. Earlier SDK versions had no allowlist: any local path the legacy default-on flag saw was uploaded.
**Default allowlist:**
| `fileUploadDirs` value | Effective allowlist |
| ---------------------- | ------------------- |
| omitted / `undefined` | `[ <home>/.composio/temp ]` (default staging dir) |
| `false` | `[]` — **all** local paths rejected (URLs and `File` objects still work) |
| `[]` | `[]` — same as `false`; explicit "no local paths" |
| `[<dir1>, <dir2>, …]` | exactly those directories (replaces the default; `~` is expanded, paths are `realpath`-resolved, comparison is on a path-component boundary) |
URLs (`http://…` / `https://…`) and JavaScript `File` objects are **not** subject to the allowlist — only local string paths are.
**TypeScript:**
```typescript
// @noErrors
// Match the *broadest* legacy behavior: auto-upload on, allowlist widened to
// the directories your code actually reads from. Set this scope as tightly
// as you can — every path inside these dirs becomes uploadable to Composio.
new Composio({
apiKey: process.env.COMPOSIO_API_KEY!,
dangerouslyAllowAutoUploadDownloadFiles: true,
fileUploadDirs: ['/srv/uploads', '~/data/inbox'],
});
// Disallow local-path uploads entirely; the SDK will only stage URLs and
// File objects, and reject string paths outright.
new Composio({
apiKey: process.env.COMPOSIO_API_KEY!,
dangerouslyAllowAutoUploadDownloadFiles: true,
fileUploadDirs: false,
});
```
**Python:**
```python
# Equivalent of the TypeScript example above.
Composio(
api_key="...",
dangerously_allow_auto_upload_download_files=True,
file_upload_dirs=["/srv/uploads", "~/data/inbox"],
)
# Reject all local paths.
Composio(
api_key="...",
dangerously_allow_auto_upload_download_files=True,
file_upload_dirs=False,
)
```
**Manual staging (TypeScript only):**
If you don't want to widen the allowlist, the TypeScript SDK lets you stage files yourself via `composio.files.upload(...)`. That call **bypasses the allowlist** by design (the caller is expected to control the path), and the returned `{ name, mimetype, s3key }` descriptor can be passed straight into `tools.execute`:
```typescript
// @noErrors
const staged = await composio.files.upload({
file: '/anywhere/on/disk/report.pdf',
toolSlug: 'GOOGLEDRIVE_UPLOAD_FILE',
toolkitSlug: 'googledrive',
});
await composio.tools.execute('GOOGLEDRIVE_UPLOAD_FILE', {
userId: 'user-1',
arguments: { file_to_upload: staged },
});
```
The Python SDK does not currently expose a top-level manual-staging API; opt in with `dangerously_allow_auto_upload_download_files=True` and configure `file_upload_dirs` to cover the directories your code reads from.
### What else changed
- TypeScript: `resolveAutoUploadDownloadFilesEnabled` only considers `dangerouslyAllowAutoUploadDownloadFiles`; the old deprecation warning for the legacy property is removed.
- Python: the small `composio.utils.auto_upload_download` helper that merged legacy + new flags is removed; `Tools` and `ToolRouter` take only the explicit opt-in boolean.
Documentation for [executing tools](/docs/tools-direct/executing-tools) and the TypeScript [Composio reference](/reference/sdk-reference/typescript/composio) is updated to describe defaults and the opt-in flag.
@@ -14,6 +14,7 @@ session = composio.create(user_id="user_123")
</Tab>
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio } from '@composio/core';
const composio = new Composio({ apiKey: 'your_api_key' });
// ---cut---
@@ -48,6 +49,7 @@ session = composio.create(
</Tab>
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio } from '@composio/core';
const composio = new Composio({ apiKey: 'your_api_key' });
// ---cut---
@@ -79,6 +81,7 @@ session = composio.create(
</Tab>
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio } from '@composio/core';
const composio = new Composio({ apiKey: 'your_api_key' });
// ---cut---
@@ -107,6 +110,7 @@ session = composio.create(
</Tab>
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio } from '@composio/core';
const composio = new Composio({ apiKey: 'your_api_key' });
// ---cut---
@@ -140,6 +144,7 @@ session = composio.create(
</Tab>
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio } from '@composio/core';
const composio = new Composio({ apiKey: 'your_api_key' });
// ---cut---
@@ -182,6 +187,7 @@ session = composio.create(
</Tab>
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio } from '@composio/core';
const composio = new Composio({ apiKey: 'your_api_key' });
// ---cut---
@@ -213,6 +219,7 @@ mcp_url = session.mcp.url
</Tab>
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio } from '@composio/core';
const composio = new Composio({ apiKey: 'your_api_key' });
const session = await composio.create("user_123");
@@ -237,6 +244,7 @@ tools = session.tools()
</Tab>
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio } from '@composio/core';
const composio = new Composio({ apiKey: 'your_api_key' });
const session = await composio.create("user_123");
@@ -262,6 +270,7 @@ connected_account = connection_request.wait_for_connection()
</Tab>
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio } from '@composio/core';
const composio = new Composio({ apiKey: 'your_api_key' });
const session = await composio.create("user_123");
@@ -295,6 +304,7 @@ for toolkit in toolkits.items:
</Tab>
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio } from '@composio/core';
const composio = new Composio({ apiKey: 'your_api_key' });
const session = await composio.create("user_123");
@@ -52,6 +52,7 @@ print(f"Connected: {connection.id}")
</Tab>
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio, AuthScheme } from '@composio/core';
const composio = new Composio({ apiKey: 'your-api-key' });
@@ -102,6 +103,7 @@ print(f"Connected: {connection.id}")
</Tab>
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio, AuthScheme } from '@composio/core';
const composio = new Composio({ apiKey: 'your-api-key' });
@@ -147,6 +149,7 @@ print(f"Connected: {connection.id}")
</Tab>
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio, AuthScheme } from '@composio/core';
const composio = new Composio({ apiKey: 'your-api-key' });
@@ -190,6 +193,7 @@ composio.connected_accounts.update(
</Tab>
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio } from '@composio/core';
const composio = new Composio({ apiKey: 'your-api-key' });
// ---cut---
@@ -230,6 +234,7 @@ composio.connected_accounts.update(
</Tab>
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio } from '@composio/core';
const composio = new Composio({ apiKey: 'your-api-key' });
// ---cut---
@@ -270,6 +275,7 @@ composio.connected_accounts.update(
</Tab>
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio } from '@composio/core';
const composio = new Composio({ apiKey: 'your-api-key' });
// ---cut---
@@ -311,6 +317,7 @@ session = composio.create(
</Tab>
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio } from '@composio/core';
const composio = new Composio({ apiKey: 'your-api-key' });
// ---cut---
@@ -25,6 +25,7 @@ session = composio.create(
</Tab>
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio } from '@composio/core';
const composio = new Composio({ apiKey: 'your_api_key' });
// ---cut---
@@ -71,6 +72,7 @@ personal_connection = personal_auth.wait_for_connection()
</Tab>
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio } from '@composio/core';
const composio = new Composio({ apiKey: 'your_api_key' });
// ---cut---
@@ -128,6 +130,7 @@ connection_request = composio.connected_accounts.link(
</Tab>
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio } from '@composio/core';
const composio = new Composio({ apiKey: 'your_api_key' });
const session = await composio.create("user_123");
@@ -166,6 +169,7 @@ composio.connected_accounts.update("ca_abc123", alias="")
</Tab>
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio } from '@composio/core';
const composio = new Composio({ apiKey: 'your_api_key' });
// ---cut---
@@ -196,6 +200,7 @@ session = composio.create(
</Tab>
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio } from '@composio/core';
const composio = new Composio({ apiKey: 'your_api_key' });
// ---cut---
@@ -225,6 +230,7 @@ for toolkit in toolkits.items:
</Tab>
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio } from '@composio/core';
const composio = new Composio({ apiKey: 'your_api_key' });
const session = await composio.create("user_123");
@@ -49,6 +49,7 @@ pip install composio
<Tabs groupId="language" items={['TypeScript', 'Python']} persist>
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio } from "@composio/core";
const composio = new Composio({ apiKey: "your_api_key" });
@@ -72,6 +73,7 @@ A standalone tool handles internal app logic that doesn't need Composio auth. `c
<Tabs groupId="language" items={['TypeScript', 'Python']} persist>
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio, experimental_createTool } from "@composio/core";
import { z } from "zod/v3";
// ---cut---
@@ -132,6 +134,7 @@ Pass custom tools via the `experimental` option. `session.tools()` returns both
<Tabs groupId="language" items={['TypeScript', 'Python']} persist>
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio, experimental_createTool } from "@composio/core";
import { z } from "zod/v3";
@@ -194,6 +197,7 @@ pip install composio
<Tabs groupId="language" items={['TypeScript', 'Python']} persist>
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio } from "@composio/core";
const composio = new Composio({ apiKey: "your_api_key" });
@@ -219,6 +223,7 @@ Prefer relative `endpoint` values in proxy calls. They resolve against the toolk
<Tabs groupId="language" items={['TypeScript', 'Python']} persist>
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio, experimental_createTool } from "@composio/core";
import { z } from "zod/v3";
// ---cut---
@@ -298,6 +303,7 @@ Pass custom tools via the `experimental` option. Extension tools inherit auth fr
<Tabs groupId="language" items={['TypeScript', 'Python']} persist>
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio, experimental_createTool } from "@composio/core";
import { z } from "zod/v3";
@@ -362,6 +368,7 @@ pip install composio
<Tabs groupId="language" items={['TypeScript', 'Python']} persist>
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio } from "@composio/core";
const composio = new Composio({ apiKey: "your_api_key" });
@@ -385,6 +392,7 @@ A custom toolkit groups related standalone tools under a namespace. Tools inside
<Tabs groupId="language" items={['TypeScript', 'Python']} persist>
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio, experimental_createTool, experimental_createToolkit } from "@composio/core";
import { z } from "zod/v3";
// ---cut---
@@ -443,6 +451,7 @@ Pass custom toolkits via the `experimental` option. `session.tools()` returns bo
<Tabs groupId="language" items={['TypeScript', 'Python']} persist>
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio, experimental_createToolkit } from "@composio/core";
import { z } from "zod/v3";
@@ -531,6 +540,7 @@ Use these methods to list registered tools and toolkits. Slugs include their fin
<Tabs groupId="language" items={['TypeScript', 'Python']} persist>
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio } from "@composio/core";
const composio = new Composio({ apiKey: "your_api_key" });
const session = await composio.create("user_1");
@@ -554,6 +564,7 @@ Use `session.execute()` to run custom tools directly, outside of an agent loop.
<Tabs groupId="language" items={['TypeScript', 'Python']} persist>
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio } from "@composio/core";
const composio = new Composio({ apiKey: "your_api_key" });
const session = await composio.create("user_1");
@@ -312,7 +312,7 @@ Need an API that isn't supported by any Composio toolkit, or want to extend an e
## Automatic File Handling
Composio handles file operations automatically. Pass file paths to tools that need them, and get local file paths back from tools that return files.
Automatic file handling is **off by default**. Enable it with `dangerouslyAllowAutoUploadDownloadFiles: true` (TypeScript) or `dangerously_allow_auto_upload_download_files=True` (Python). Once enabled, pass file paths to tools that accept files and get local paths back from tools that return files.
### File Upload
@@ -321,6 +321,11 @@ Pass local file paths, URLs, or File objects to tools that accept files:
<Tabs groupId="language" items={['Python', 'TypeScript']} persist>
<Tab value="Python">
```python
composio = Composio(
api_key="your_composio_key",
dangerously_allow_auto_upload_download_files=True,
)
# Upload a local file to Google Drive
result = composio.tools.execute(
slug="GOOGLEDRIVE_UPLOAD_FILE",
@@ -333,9 +338,13 @@ print(result) # Print Google Drive file details
</Tab>
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio } from '@composio/core';
import path from 'path';
const composio = new Composio({ apiKey: 'your_api_key' });
const composio = new Composio({
apiKey: 'your_api_key',
dangerouslyAllowAutoUploadDownloadFiles: true,
});
// ---cut---
// Upload a local file to Google Drive
const result = await composio.tools.execute('GOOGLEDRIVE_UPLOAD_FILE', {
@@ -359,7 +368,8 @@ When tools return files, Composio downloads them to the local directory and prov
```python
composio = Composio(
api_key="your_composio_key",
file_download_dir="./downloads" # Optional: Specify download directory
dangerously_allow_auto_upload_download_files=True,
file_download_dir="./downloads", # Optional: Specify download directory
)
result = composio.tools.execute(
@@ -374,8 +384,12 @@ print(result)
</Tab>
<Tab value="TypeScript">
```typescript
// @noErrors
import { Composio } from '@composio/core';
const composio = new Composio({ apiKey: 'your_api_key' });
const composio = new Composio({
apiKey: 'your_api_key',
dangerouslyAllowAutoUploadDownloadFiles: true,
});
// ---cut---
// Download a file from Google Drive
const result = await composio.tools.execute('GOOGLEDRIVE_DOWNLOAD_FILE', {
@@ -438,7 +452,13 @@ composio = Composio(
import { Composio, type beforeFileUploadModifier } from '@composio/core';
import path from 'node:path';
const composio = new Composio({ apiKey: 'your_api_key' });
const beforeFileUpload: beforeFileUploadModifier = async ctx => ctx.path;
// ctx.source is 'path' | 'url' | 'file' — useful if you only want to audit
// local paths and let URLs / File objects pass through unchanged.
const beforeFileUpload: beforeFileUploadModifier = async ctx => {
if (ctx.source !== 'path') return ctx.path;
// ...audit ctx.path...
return ctx.path;
};
// ---cut---
await composio.tools.execute(
'GOOGLEDRIVE_UPLOAD_FILE',
@@ -456,9 +476,14 @@ await composio.tools.execute(
import os
from composio import Composio, before_file_upload
# New form: take a single ``context`` arg; ``context["source"]`` is "path"
# or "url". The legacy ``(path, tool, toolkit)`` 3-arg form is still accepted.
@before_file_upload(tools=["GOOGLEDRIVE_UPLOAD_FILE"])
def audit_path(path: str, tool: str, toolkit: str) -> str | bool:
return path
def audit_path(ctx) -> str | bool:
if ctx["source"] != "path":
return ctx["path"]
# ...audit ctx["path"]...
return ctx["path"]
composio = Composio()
@@ -474,9 +499,40 @@ composio.tools.execute(
**Errors:** `ComposioSensitiveFilePathBlockedError` / `SensitiveFilePathBlockedError` when a path is blocked; `ComposioFileUploadAbortedError` / `FileUploadAbortedError` when the hook returns `false` (or equivalent abort).
### Disabling Auto File Handling
### Automatic file handling (off by default)
You can disable automatic file handling when initializing the TypeScript SDK. When disabled, handle file uploads and downloads manually using `files.upload` and `files.download`:
Both SDKs ship with automatic file handling **off**. Opt in with `dangerouslyAllowAutoUploadDownloadFiles: true` (TypeScript) or `dangerously_allow_auto_upload_download_files=True` (Python). Even with the flag enabled, **local paths must resolve inside an allowlisted directory** — see the matrix in the [legacy auto-upload removal changelog](/docs/changelog/2026/04/24) for the full `fileUploadDirs` / `file_upload_dirs` semantics.
When auto-handling is off, handle the staging yourself.
<Tabs groupId="language" items={['Python', 'TypeScript']} persist>
<Tab value="Python">
The Python SDK does not expose a top-level manual-staging API. Either:
1. Enable auto-handling and configure `file_upload_dirs` to cover the directories your code reads from:
```python
from composio import Composio
composio = Composio(
api_key="your_composio_key",
dangerously_allow_auto_upload_download_files=True,
file_upload_dirs=["/srv/agent/uploads", "~/.composio/temp"],
)
```
2. Or keep auto-handling off and pass an already-staged `{ "name", "mimetype", "s3key" }` dict directly into `tools.execute`. Pre-stage your files via your own pipeline (or an upstream tool) and inject the descriptor:
```python
result = composio.tools.execute(
"GOOGLEDRIVE_UPLOAD_FILE",
user_id="user-1",
arguments={"file_to_upload": prestaged_descriptor},
)
```
</Tab>
<Tab value="TypeScript">
With `dangerouslyAllowAutoUploadDownloadFiles: false` (the default), call `composio.files.upload(...)` to stage paths/URLs explicitly. That call bypasses `fileUploadDirs` by design — the caller controls the path:
```typescript
import { Composio } from '@composio/core';
@@ -484,16 +540,24 @@ import path from 'path';
// ---cut---
const composio = new Composio({
apiKey: process.env.COMPOSIO_API_KEY,
autoUploadDownloadFiles: false
// Default: dangerouslyAllowAutoUploadDownloadFiles is false; set true only to opt in
});
// Now you need to handle files manually using composio.files API
// Stage manually using composio.files API
const fileData = await composio.files.upload({
file: path.join(__dirname, 'document.pdf'),
toolSlug: 'GOOGLEDRIVE_UPLOAD_FILE',
toolkitSlug: 'googledrive'
});
// Then pass the { name, mimetype, s3key } descriptor into tools.execute
await composio.tools.execute('GOOGLEDRIVE_UPLOAD_FILE', {
userId: 'user-1',
arguments: { file_to_upload: fileData },
});
```
</Tab>
</Tabs>
## What to read next
@@ -184,13 +184,21 @@ For tools with `file_uploadable` parameters, the SDK can read a local path and u
See [Executing tools: security for local file paths](/docs/tools-direct/executing-tools#security-for-local-file-paths) for constructor options, errors, and a full tabbed example.
The hook takes either a single `context` argument (preferred) or the legacy
3-arg `(path, tool, toolkit)` form. The context object exposes
`context["source"]` — `"path"` for local filesystem inputs and `"url"` for
`http(s)://...` inputs — so you can scope audits to just local paths:
```python
from composio import Composio, before_file_upload
@before_file_upload(tools=["GOOGLEDRIVE_UPLOAD_FILE"])
def audit_path(path: str, tool: str, toolkit: str) -> str | bool:
def audit_path(ctx) -> str | bool:
# ctx = {"path": ..., "source": "path" | "url", "tool": ..., "toolkit": ...}
if ctx["source"] != "path":
return ctx["path"] # let URLs through untouched
# return a new path string, or False to abort the upload
return path
return ctx["path"]
composio = Composio()
composio.tools.execute(
@@ -15,3 +15,95 @@ description: "Composio SDK for Python. Generic parameters: TTool: The individua
| [`mcp`](/reference/sdk-reference/python/mcp) | `MCP` |
[View source](https://github.com/composiohq/composio/blob/next/python/composio/sdk.py#L46)
---
## Constructor
```python
Composio(
provider: BaseProvider | None = None,
*,
api_key: str | None = None,
base_url: str | None = None,
timeout: int | None = None,
max_retries: int | None = None,
toolkit_versions: dict[str, str] | str | None = None,
dangerously_allow_auto_upload_download_files: bool = False,
sensitive_file_upload_protection: bool = True,
file_upload_path_deny_segments: Sequence[str] | None = None,
file_upload_dirs: Sequence[str] | Literal[False] | None = None,
file_download_dir: str | None = None,
allow_tracking: bool = True,
environment: str = "production",
)
```
`api_key` falls back to `os.environ["COMPOSIO_API_KEY"]` when not passed.
### Common options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `provider` | `BaseProvider` | `OpenAIProvider()` | Provider used to wrap tools (Anthropic, LangChain, Gemini, etc.). |
| `api_key` | `str` | `$COMPOSIO_API_KEY` | API key. Required. |
| `base_url` | `str` | Composio default | Override the API base URL. |
| `timeout` | `int` | client default | Per-request timeout in seconds. |
| `max_retries` | `int` | `3` | HTTP client retry count. |
| `toolkit_versions` | `dict \| str \| None` | `'latest'` | Pin toolkit versions globally (string) or per-toolkit (dict). |
---
## File upload security
Automatic file handling for `file_uploadable` tool fields is **off by default**. Set `dangerously_allow_auto_upload_download_files=True` to opt in. Once on, the SDK can read local paths and stage them to S3 on your behalf, but only from an allowlisted set of directories.
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `dangerously_allow_auto_upload_download_files` | `bool` | `False` | Master opt-in. When `False`, paths and URLs in `file_uploadable` arguments are forwarded as-is — the backend will reject anything that isn't already a staged `{name, mimetype, s3key}` descriptor. Set to `True` to let the SDK stage local paths and URLs on your behalf at execute time. |
| `sensitive_file_upload_protection` | `bool` | `True` | Block local paths matching a built-in denylist of segments (`.ssh`, `.aws`, etc.) and credential-like file names. Disable only if you accept the tradeoff. |
| `file_upload_path_deny_segments` | `Sequence[str] \| None` | `None` | Extra single path components merged with the built-in denylist. |
| `file_upload_dirs` | `Sequence[str] \| Literal[False] \| None` | `[~/.composio/temp]` | Allowlist of directories the SDK may read during **automatic** upload. Pass `False` (or `[]`) to reject every local path — URLs still work. Providing a list **replaces** the default; include `~/.composio/temp` explicitly if you want the default staging dir to keep working. Comparison is on a path-component boundary after `os.path.realpath`. On Windows, entries are compared case-insensitively. |
| `file_download_dir` | `str \| None` | `~/.composio/files` | Directory where files from tool responses marked `file_downloadable` are streamed. |
Per-execution hooks live on the `modifiers=[...]` argument to `composio.tools.execute`, not on the constructor. Use the `@before_file_upload` decorator from `composio.core.models._modifiers` to inspect or rewrite each path before it is read. The hook context includes a `source` field (`'path' | 'url'`) so you can branch on local paths vs URLs.
**Related errors:** `FileUploadPathNotAllowed`, `SensitiveFilePathBlocked`, `FileUploadAbortedError`, `SDKFileNotFoundError` (all from `composio.exceptions`).
### Restricting automatic uploads to specific directories
When `dangerously_allow_auto_upload_download_files=True`, the SDK only reads local files from directories listed in `file_upload_dirs`. This stacks with (it does NOT replace) the sensitive-path denylist.
```python
from composio import Composio
composio = Composio(
api_key="your_composio_key",
dangerously_allow_auto_upload_download_files=True,
# Replaces the default `[~/.composio/temp]`. List every directory you want
# the SDK to read from during tool execution.
file_upload_dirs=["/srv/agent/uploads", "~/.composio/temp"],
)
```
Pass `file_upload_dirs=False` (or `[]`) to reject every filesystem path; URLs and in-memory bytes still upload normally:
```python
Composio(
api_key="your_composio_key",
dangerously_allow_auto_upload_download_files=True,
file_upload_dirs=False,
)
```
### Tool-Router session files
The Tool-Router session API exposes a separate files surface (`session.files.upload(...)` / `session.files.download(...)`) for streaming files into and out of a long-running router session. Those calls are unrelated to the `file_uploadable` schema flow on individual tool execution and are not gated by `dangerously_allow_auto_upload_download_files` or `file_upload_dirs`. See [`tool-router-session`](/reference/sdk-reference/python/tool-router-session) for usage.
---
## See also
- [Executing tools](/docs/tools-direct/executing-tools) — automatic vs. manual file handling, before-execute modifiers.
- [Before-execution modifiers](/docs/tools-direct/modify-tool-behavior/before-execution-modifiers#before-file-upload-python) — `@before_file_upload` decorator and the context-form hook signature.
- [Legacy auto-upload config removal changelog](/docs/changelog/2026/04/24) — migration from the removed `auto_upload_download_files` flag and the allowlist semantics matrix.
@@ -180,15 +180,39 @@ getConfig(): ComposioConfig
## File upload security
When `autoUploadDownloadFiles` is true (default), the SDK can read local file paths and upload them before tool execution. The constructor accepts options that work together with the optional `beforeFileUpload` modifier on `tools.execute` (see [Executing tools: security](/docs/tools-direct/executing-tools#security-for-local-file-paths)).
When `dangerouslyAllowAutoUploadDownloadFiles` is true, the SDK can read local file paths and upload them before tool execution (this behavior is **off** by default). The constructor accepts options that work together with the optional `beforeFileUpload` modifier on `tools.execute` (see [Executing tools: security](/docs/tools-direct/executing-tools#security-for-local-file-paths)).
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `sensitiveFileUploadProtection` | `boolean` | `true` | When true, local path strings for auto-upload and `files.upload` are checked against a built-in denylist of path segments (for example `.ssh`, `.aws`) and credential-like file names. Set to `false` only if you accept the security tradeoff. |
| `fileUploadPathDenySegments` | `string[]` | `undefined` | Additional single path *components* (directory or file names) merged with the built-in list; matched anywhere in the resolved path. |
| `fileUploadDirs` | `string[] \| false` | `[~/.composio/temp]` | Allowlist of directories the SDK may read during **automatic** upload (when `dangerouslyAllowAutoUploadDownloadFiles: true`). Pass `false` (or `[]`) to reject every local path; URLs and `File`/`Blob` objects still work. Providing a list **replaces** the default — include `~/.composio/temp` explicitly if you want the default staging dir to keep working. Does **not** affect manual `composio.files.upload()` calls. |
| `fileDownloadDir` | `string` | `~/.composio/files` | Directory where files from tool responses marked `file_downloadable` are streamed. |
Pass **`beforeFileUpload`** on the **third argument** to `composio.tools.execute` (with `beforeExecute` / `afterExecute`), not on the constructor. The hook runs for each `file_uploadable` value before read/upload: return a path string, `false` to abort, or throw.
Pass **`beforeFileUpload`** on the **third argument** to `composio.tools.execute` (with `beforeExecute` / `afterExecute`), not on the constructor. The hook runs for each `file_uploadable` value before read/upload: return a path string, `false` to abort, or throw. The hook's context includes a `source` field (`'path' | 'url' | 'file'`) so it can scope its logic to local paths vs URLs vs `File` objects.
**Related errors:** `ComposioSensitiveFilePathBlockedError`, `ComposioFileUploadAbortedError` (from `@composio/core`).
**Related errors:** `ComposioSensitiveFilePathBlockedError`, `ComposioFileUploadAbortedError`, `ComposioFileUploadPathNotAllowedError`, `ComposioFileNotFoundError` (from `@composio/core`).
### Restricting automatic uploads to specific directories
When `dangerouslyAllowAutoUploadDownloadFiles: true`, the SDK only reads local
files from directories in `fileUploadDirs`. This stacks with (it does NOT
replace) the sensitive-path denylist.
```typescript
// @noErrors
import { Composio } from '@composio/core';
const composio = new Composio({
apiKey: process.env.COMPOSIO_API_KEY!,
dangerouslyAllowAutoUploadDownloadFiles: true,
// Replaces the default `[~/.composio/temp]`. List every directory you want
// the SDK to read from during tool execution.
fileUploadDirs: ['/srv/agent/uploads', '~/.composio/temp'],
});
```
Pass `fileUploadDirs: false` (or `[]`) to reject every filesystem path; URLs
and in-memory `File`/`Blob` objects still upload normally.
---
+1 -1
View File
@@ -1,4 +1,4 @@
// @errors: 2307
// @noErrors
"use client";
import { useState } from "react";
+1
View File
@@ -1,3 +1,4 @@
// @noErrors
"use client";
import { useState } from "react";
+79 -27
View File
@@ -26,10 +26,14 @@ from composio.utils import mimetypes
from composio.utils.sensitive_file_upload_paths import (
assert_safe_local_file_upload_path,
)
from composio.utils.upload_dir_allowlist import (
assert_path_inside_upload_dirs,
)
from composio.utils.logging import WithLogger
if t.TYPE_CHECKING:
from .tools import ToolExecutionResponse
from ._modifiers import BeforeFileUploadContextCallable # noqa: F401
_DEFAULT_CHUNK_SIZE = 1024 * 1024
_FILE_UPLOAD = "/api/v3/files/upload/request"
@@ -86,9 +90,12 @@ except OSError as e:
) from e
LOCAL_OUTPUT_FILE_DIRECTORY = LOCAL_CACHE_DIRECTORY / "outputs"
LOCAL_OUTPUT_FILE_DIRECTORY = LOCAL_CACHE_DIRECTORY / "files"
"""
Local output file directory name for composio tools
Default local directory into which files downloaded during tool execution are
written. Previously ``<cache>/outputs``; now ``<cache>/files`` for parity with
the TypeScript SDK. Override by passing ``file_download_dir=...`` to Composio,
or by setting ``outdir`` on ``FileHelper`` directly.
"""
@@ -106,7 +113,11 @@ def get_md5(file: Path) -> str:
Returns:
Hexadecimal MD5 hash string
"""
obj = hashlib.md5()
# `usedforsecurity=False` lets this run on FIPS-mode systems, where
# `hashlib.md5()` without the flag raises `ValueError: [digital envelope
# routines] unsupported`. We're hashing for integrity / deduplication,
# not security — the API just needs the digest for upload verification.
obj = hashlib.md5(usedforsecurity=False)
with file.open("rb") as fp:
while True:
line = fp.read(_DEFAULT_CHUNK_SIZE)
@@ -345,7 +356,7 @@ def _upload_bytes_to_s3(
toolkit: str,
) -> str:
"""Upload bytes content to S3 and return the S3 key."""
md5_hash = hashlib.md5(content).hexdigest()
md5_hash = hashlib.md5(content, usedforsecurity=False).hexdigest()
s3meta = client.post(
path=_FILE_UPLOAD,
@@ -423,9 +434,8 @@ class FileUploadable(BaseModel):
*,
sensitive_file_upload_protection: bool = True,
file_upload_path_deny_segments: t.Optional[t.Sequence[str]] = None,
before_file_upload: t.Optional[
t.Callable[[str, str, str], t.Union[str, bool]]
] = None,
file_upload_allowlist: t.Optional[t.Sequence[Path]] = None,
before_file_upload: t.Optional["BeforeFileUploadContextCallable"] = None,
) -> te.Self:
"""Create a FileUploadable from a local file path or public URL.
@@ -439,27 +449,34 @@ class FileUploadable(BaseModel):
:param toolkit: The toolkit slug
:param sensitive_file_upload_protection: When True, block paths on the built-in denylist.
:param file_upload_path_deny_segments: Extra path segment names to merge with the built-in list.
:param before_file_upload: Optional ``(path, tool, toolkit) -> str | bool``; return a new path string, or False to abort.
:param file_upload_allowlist: When provided (not None), local paths must
resolve inside one of these directories on a component boundary.
Pass ``None`` to skip the check (e.g. manual upload APIs). URLs are
never checked against the allowlist. An empty sequence means
"no paths are allowed" (fail-closed).
:param before_file_upload: Optional context-form hook produced by
:func:`composio.core.models._modifiers.merge_before_file_upload`.
Receives ``{"path", "source", "tool", "toolkit"}`` where ``source``
is ``"url"`` for ``http(s)://...`` inputs and ``"path"`` for local
filesystem paths. Return a new string to substitute, or ``False``
to abort.
:return: FileUploadable instance with S3 key
"""
file_str = str(file) if isinstance(file, Path) else file
# Check if it's a URL
if isinstance(file_str, str) and _is_url(file_str):
path_in = file_str
if before_file_upload is not None:
out = before_file_upload(path_in, tool, toolkit)
if out is False:
raise FileUploadAbortedError(
"File upload was aborted because before_file_upload returned False."
)
if isinstance(out, str):
path_in = out
return cls.from_url(client=client, url=path_in, tool=tool, toolkit=toolkit)
path_in = file_str
source: t.Literal["url", "path"] = (
"url" if isinstance(file_str, str) and _is_url(file_str) else "path"
)
if before_file_upload is not None:
out = before_file_upload(path_in, tool, toolkit)
out = before_file_upload(
{
"path": path_in,
"source": source,
"tool": tool,
"toolkit": toolkit,
}
)
if out is False:
raise FileUploadAbortedError(
"File upload was aborted because before_file_upload returned False."
@@ -467,6 +484,21 @@ class FileUploadable(BaseModel):
if isinstance(out, str):
path_in = out
# Re-decide routing on the post-hook value: a URL-source hook may return
# a local path (and vice versa). Re-checking with `_is_url` keeps the
# URL fetch path and the local-file path properly separated, so a hook
# cannot, for example, smuggle `/etc/passwd` past the URL branch's
# missing allowlist/denylist by rewriting the URL into a path.
if isinstance(path_in, str) and _is_url(path_in):
return cls.from_url(client=client, url=path_in, tool=tool, toolkit=toolkit)
# Allowlist check runs BEFORE the denylist / existence checks when enabled,
# so the "configure file_upload_dirs" hint fires first for the common case
# (user has auto-upload on but hasn't configured dirs). Caller passes
# ``None`` to bypass the allowlist (manual upload APIs).
if file_upload_allowlist is not None:
assert_path_inside_upload_dirs(path_in, file_upload_allowlist)
assert_safe_local_file_upload_path(
path_in,
enabled=sensitive_file_upload_protection,
@@ -527,7 +559,11 @@ class FileDownloadable(BaseModel):
return outfile
BeforeFileUpload = t.Callable[[str, str, str], t.Union[str, bool]]
# Internal alias — ``FileHelper`` receives the already-adapted context-form
# callable produced by :func:`merge_before_file_upload`.
from composio.core.models._modifiers import ( # noqa: E402
BeforeFileUploadContextCallable as BeforeFileUpload,
)
class FileHelper(WithLogger):
@@ -538,12 +574,24 @@ class FileHelper(WithLogger):
*,
sensitive_file_upload_protection: bool = True,
file_upload_path_deny_segments: t.Optional[t.Sequence[str]] = None,
file_upload_allowlist: t.Optional[t.Sequence[Path]] = None,
) -> None:
"""
:param outdir: Where files downloaded during tool execution are written.
Defaults to ``~/.composio/files``.
:param file_upload_allowlist: Directories from which local files may be
auto-uploaded. ``None`` disables the allowlist check (used when
auto-upload is off, or for manual upload APIs). An empty list means
"no paths allowed" (fail-closed).
"""
super().__init__()
self._client = client
self._outdir = Path(outdir or LOCAL_OUTPUT_FILE_DIRECTORY)
self._outdir = Path(outdir) if outdir else LOCAL_OUTPUT_FILE_DIRECTORY
self._sensitive_file_upload_protection = sensitive_file_upload_protection
self._file_upload_path_deny_segments = file_upload_path_deny_segments
self._file_upload_allowlist: t.Optional[t.Sequence[Path]] = (
list(file_upload_allowlist) if file_upload_allowlist is not None else None
)
def _has_file_property(
self, schema: t.Dict, property_name: str = "file_uploadable"
@@ -672,7 +720,7 @@ class FileHelper(WithLogger):
- Required notes ("This parameter is required.")
This is separate from file processing and should always run
regardless of the auto_upload_download_files setting.
regardless of `dangerously_allow_auto_upload_download_files`.
"""
required = schema.get("required") or []
for _param, _schema in schema["properties"].items():
@@ -694,7 +742,8 @@ class FileHelper(WithLogger):
"""Process file_uploadable fields in schema.
This method converts file_uploadable fields to path format.
Should only be called when auto_upload_download_files is True.
Should only be called when the caller opted in via
`dangerously_allow_auto_upload_download_files=True`.
Recursively handles anyOf, oneOf, allOf, nested properties, and array items.
"""
if "properties" not in schema:
@@ -770,6 +819,7 @@ class FileHelper(WithLogger):
toolkit=tool.toolkit.slug,
sensitive_file_upload_protection=self._sensitive_file_upload_protection,
file_upload_path_deny_segments=self._file_upload_path_deny_segments,
file_upload_allowlist=self._file_upload_allowlist,
before_file_upload=before_file_upload,
).model_dump()
continue
@@ -790,6 +840,7 @@ class FileHelper(WithLogger):
toolkit=tool.toolkit.slug,
sensitive_file_upload_protection=self._sensitive_file_upload_protection,
file_upload_path_deny_segments=self._file_upload_path_deny_segments,
file_upload_allowlist=self._file_upload_allowlist,
before_file_upload=before_file_upload,
).model_dump()
continue
@@ -841,6 +892,7 @@ class FileHelper(WithLogger):
toolkit=tool.toolkit.slug,
sensitive_file_upload_protection=self._sensitive_file_upload_protection,
file_upload_path_deny_segments=self._file_upload_path_deny_segments,
file_upload_allowlist=self._file_upload_allowlist,
before_file_upload=before_file_upload,
).model_dump()
)
+123 -11
View File
@@ -66,7 +66,12 @@ class SchemaModifier(t.Protocol):
class BeforeFileUploadCallable(t.Protocol):
"""Called for each local file path before read/upload (``file_uploadable``)."""
"""Legacy positional form of the ``before_file_upload`` hook.
``(path, tool, toolkit) -> str | bool``. Still supported for back-compat,
but new code should use :class:`BeforeFileUploadContextCallable` so it can
discriminate local paths from URLs via ``context["source"]``.
"""
def __call__(
self,
@@ -76,6 +81,83 @@ class BeforeFileUploadCallable(t.Protocol):
) -> t.Union[str, bool]: ...
class BeforeFileUploadContext(te.TypedDict):
"""Context passed to the new-form ``before_file_upload`` hook.
- ``path``: the local filesystem path for ``source="path"``, or the URL
string for ``source="url"``.
- ``source``: discriminator — ``"path"`` for local paths, ``"url"`` for
``http(s)://...`` inputs. Mirrors the TypeScript SDK's ``source`` field
(TS additionally emits ``"file"`` for ``File`` objects; Python has no
equivalent runtime type).
- ``tool`` / ``toolkit``: slugs of the tool being executed.
"""
path: str
source: te.Literal["path", "url"]
tool: str
toolkit: str
class BeforeFileUploadContextCallable(t.Protocol):
"""Preferred form of the ``before_file_upload`` hook.
Takes a single :class:`BeforeFileUploadContext` argument and returns either
a new path/URL string, or ``False`` to abort the upload.
"""
def __call__(
self,
context: BeforeFileUploadContext,
) -> t.Union[str, bool]: ...
BeforeFileUploadLike = t.Union[
BeforeFileUploadCallable,
BeforeFileUploadContextCallable,
]
"""Either form of ``before_file_upload``. Adapted internally."""
def _count_positional_params(fn: t.Callable) -> int:
"""Return the number of positional (or positional-or-keyword) params, or
-1 if the signature can't be introspected (e.g. builtins)."""
import inspect
try:
sig = inspect.signature(fn)
except (TypeError, ValueError):
return -1
return sum(
1
for p in sig.parameters.values()
if p.kind
in (
inspect.Parameter.POSITIONAL_ONLY,
inspect.Parameter.POSITIONAL_OR_KEYWORD,
)
)
def _adapt_before_file_upload(
hook: BeforeFileUploadLike,
) -> BeforeFileUploadContextCallable:
"""Normalise a user-supplied hook to the context-object form.
A hook declared with exactly 3 positional parameters is treated as the
legacy ``(path, tool, toolkit)`` form; anything else (typically a single
positional ``context`` parameter) is treated as the new form.
"""
if _count_positional_params(hook) == 3:
legacy = t.cast(BeforeFileUploadCallable, hook)
def wrap(context: BeforeFileUploadContext) -> t.Union[str, bool]:
return legacy(context["path"], context["tool"], context["toolkit"])
return wrap
return t.cast(BeforeFileUploadContextCallable, hook)
ModifierSlug: t.TypeAlias = str
AfterExecuteModifierL: t.TypeAlias = t.Literal["after_execute"]
BeforeExecuteModifierL: t.TypeAlias = t.Literal["before_execute"]
@@ -93,6 +175,7 @@ class Modifier:
| BeforeExecuteMeta
| AfterExecuteMeta
| BeforeFileUploadCallable
| BeforeFileUploadContextCallable
],
type_: (
AfterExecuteModifierL
@@ -222,7 +305,7 @@ def before_execute(
@t.overload
def before_file_upload(modifier: t.Optional[BeforeFileUploadCallable]) -> Modifier: ...
def before_file_upload(modifier: t.Optional[BeforeFileUploadLike]) -> Modifier: ...
@t.overload
@@ -230,19 +313,30 @@ def before_file_upload(
*,
tools: t.Optional[t.List[str]] = None,
toolkits: t.Optional[t.List[str]] = None,
) -> t.Callable[[BeforeFileUploadCallable], Modifier]: ...
) -> t.Callable[[BeforeFileUploadLike], Modifier]: ...
def before_file_upload(
modifier: t.Optional[BeforeFileUploadCallable] = None,
modifier: t.Optional[BeforeFileUploadLike] = None,
*,
tools: t.Optional[t.List[str]] = None,
toolkits: t.Optional[t.List[str]] = None,
) -> Modifier | t.Callable[[BeforeFileUploadCallable], Modifier]:
) -> Modifier | t.Callable[[BeforeFileUploadLike], Modifier]:
"""
Build a ``Modifier`` for the file-upload hook (same scoping pattern as
:func:`before_execute`).
Your callable may take **either**:
- a single ``context`` argument (:class:`BeforeFileUploadContext`) — the
preferred form, exposes ``context["source"]`` (``"path"`` or ``"url"``),
or
- three positional arguments ``(path, tool, toolkit)`` — legacy form, kept
for back-compat.
Return a new path/URL string to substitute, or ``False`` to abort the
upload (raises :class:`~composio.exceptions.FileUploadAbortedError`).
Pass the returned ``Modifier`` in ``modifiers=[...]`` on
:meth:`composio.core.models.tools.Tools.execute` or ``tools.get``. Multiple
such modifiers are composed in list order.
@@ -257,7 +351,7 @@ def before_file_upload(
if tools is not None or toolkits is not None:
return t.cast(
t.Callable[[BeforeFileUploadCallable], Modifier],
t.Callable[[BeforeFileUploadLike], Modifier],
functools.partial(
before_file_upload,
tools=tools or [],
@@ -392,10 +486,15 @@ def merge_before_file_upload(
modifiers: t.Optional[Modifiers],
tool: str,
toolkit: str,
) -> t.Optional[BeforeFileUploadCallable]:
) -> t.Optional[BeforeFileUploadContextCallable]:
"""Compose ``before_file_upload``-type :class:`Modifier`\\ s for this *tool* / *toolkit*.
Scoping matches :class:`Modifier` (empty ``tools`` and ``toolkits`` = all tools).
Each user-supplied hook is adapted to the context form by
:func:`_adapt_before_file_upload`, so legacy 3-arg callables keep working
while new-form callables receive the full :class:`BeforeFileUploadContext`
(including ``source``).
"""
to_chain = [
m
@@ -410,12 +509,25 @@ def merge_before_file_upload(
return True
return tool in m.tools or toolkit in m.toolkits
def combined(path: str, tool: str, toolkit: str) -> t.Union[str, bool]:
p: str = path
for m in to_chain:
adapted_chain = [
(m, _adapt_before_file_upload(t.cast(BeforeFileUploadLike, m.modifier)))
for m in to_chain
]
def combined(context: BeforeFileUploadContext) -> t.Union[str, bool]:
p: str = context["path"]
for m, hook in adapted_chain:
if not _applies(m):
continue
out = t.cast(BeforeFileUploadCallable, m.modifier)(p, tool, toolkit)
# Preserve source/tool/toolkit while forwarding the (possibly
# rewritten) path to the next hook.
next_ctx: BeforeFileUploadContext = {
"path": p,
"source": context["source"],
"tool": context["tool"],
"toolkit": context["toolkit"],
}
out = hook(next_ctx)
if out is False:
return False
if isinstance(out, str):
+12 -5
View File
@@ -360,24 +360,29 @@ class ToolRouter(Resource, t.Generic[TTool, TToolCollection]):
self,
client: HttpClient,
provider: t.Optional["BaseProvider[TTool, TToolCollection]"] = None,
auto_upload_download_files: bool = True,
dangerously_allow_auto_upload_download_files: bool = False,
sensitive_file_upload_protection: bool = True,
file_upload_path_deny_segments: t.Optional[t.Sequence[str]] = None,
file_upload_dirs: t.Union[t.Sequence[str], t.Literal[False], None] = None,
):
"""
Initialize ToolRouter instance.
:param client: HTTP client for API calls
:param provider: Optional provider for tool wrapping
:param auto_upload_download_files: Whether to automatically upload and download files. Defaults to True.
:param dangerously_allow_auto_upload_download_files: Opt-in for automatic file upload/download. Defaults to False.
:param sensitive_file_upload_protection: When True, block local paths on the built-in sensitive-path denylist before upload.
:param file_upload_path_deny_segments: Extra path segment names to merge with the built-in denylist.
:param file_upload_dirs: Allowlist of directories for auto-upload. See ``Composio`` for details.
"""
super().__init__(client)
self._provider = provider
self._auto_upload_download_files = auto_upload_download_files
self._sensitive_file_upload_protection = sensitive_file_upload_protection
self._file_upload_path_deny_segments = file_upload_path_deny_segments
self._file_upload_dirs = file_upload_dirs
self._auto_upload_download_files = bool(
dangerously_allow_auto_upload_download_files
)
def _create_mcp_server_config(
self,
@@ -809,9 +814,10 @@ class ToolRouter(Resource, t.Generic[TTool, TToolCollection]):
return ToolRouterSession(
client=self._client,
provider=self._provider,
auto_upload_download_files=self._auto_upload_download_files,
dangerously_allow_auto_upload_download_files=self._auto_upload_download_files,
sensitive_file_upload_protection=self._sensitive_file_upload_protection,
file_upload_path_deny_segments=self._file_upload_path_deny_segments,
file_upload_dirs=self._file_upload_dirs,
session_id=session.session_id,
mcp=self._create_mcp_server_config(
mcp_type=ToolRouterMCPServerType(session.mcp.type.lower()),
@@ -864,9 +870,10 @@ class ToolRouter(Resource, t.Generic[TTool, TToolCollection]):
return ToolRouterSession(
client=self._client,
provider=self._provider,
auto_upload_download_files=self._auto_upload_download_files,
dangerously_allow_auto_upload_download_files=self._auto_upload_download_files,
sensitive_file_upload_protection=self._sensitive_file_upload_protection,
file_upload_path_deny_segments=self._file_upload_path_deny_segments,
file_upload_dirs=self._file_upload_dirs,
session_id=session.session_id,
mcp=self._create_mcp_server_config(
mcp_type=ToolRouterMCPServerType(session.mcp.type.lower()),
@@ -76,9 +76,10 @@ class ToolRouterSession(t.Generic[TTool, TToolCollection]):
*,
client: HttpClient,
provider: t.Optional[BaseProvider[t.Any, t.Any]],
auto_upload_download_files: bool,
dangerously_allow_auto_upload_download_files: bool,
sensitive_file_upload_protection: bool = True,
file_upload_path_deny_segments: t.Optional[t.Sequence[str]] = None,
file_upload_dirs: t.Union[t.Sequence[str], t.Literal[False], None] = None,
session_id: str,
mcp: t.Any,
experimental: "ToolRouterSessionExperimental",
@@ -87,9 +88,10 @@ class ToolRouterSession(t.Generic[TTool, TToolCollection]):
) -> None:
self._client = client
self._provider = provider
self._auto_upload_download_files = auto_upload_download_files
self._auto_upload_download_files = dangerously_allow_auto_upload_download_files
self._sensitive_file_upload_protection = sensitive_file_upload_protection
self._file_upload_path_deny_segments = file_upload_path_deny_segments
self._file_upload_dirs = file_upload_dirs
self.session_id = session_id
self.mcp = mcp
self.experimental = experimental
@@ -146,9 +148,10 @@ class ToolRouterSession(t.Generic[TTool, TToolCollection]):
tools_model = ToolsModel(
client=self._client,
provider=self._provider,
auto_upload_download_files=self._auto_upload_download_files,
dangerously_allow_auto_upload_download_files=self._auto_upload_download_files,
sensitive_file_upload_protection=self._sensitive_file_upload_protection,
file_upload_path_deny_segments=self._file_upload_path_deny_segments,
file_upload_dirs=self._file_upload_dirs,
)
router_tools = tools_model.get_raw_tool_router_meta_tools(
+26 -5
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import functools
import typing as t
from pathlib import Path
import typing_extensions as te
from pydantic import BaseModel as PydanticBaseModel
@@ -26,6 +27,7 @@ from composio.core.types import ToolkitVersionParam
from composio.exceptions import InvalidParams, NotFoundError, ToolVersionRequiredError
from composio.utils.pydantic import none_to_omit
from composio.utils.toolkit_version import get_toolkit_version
from composio.utils.upload_dir_allowlist import resolve_effective_upload_allowlist
from ._modifiers import (
Modifiers,
@@ -102,9 +104,10 @@ class Tools(Resource, t.Generic[TTool, TToolCollection]):
provider: BaseProvider[TTool, TToolCollection],
file_download_dir: t.Optional[str] = None,
toolkit_versions: t.Optional[ToolkitVersionParam] = None,
auto_upload_download_files: bool = True,
dangerously_allow_auto_upload_download_files: bool = False,
sensitive_file_upload_protection: bool = True,
file_upload_path_deny_segments: t.Optional[t.Sequence[str]] = None,
file_upload_dirs: t.Union[t.Sequence[str], t.Literal[False], None] = None,
):
"""
Initialize the tools resource.
@@ -113,10 +116,27 @@ class Tools(Resource, t.Generic[TTool, TToolCollection]):
:param provider: The provider to use for the tools resource.
:param file_download_dir: Output directory for downloadable files
:param toolkit_versions: The versions of the toolkits to use. Defaults to 'latest' if not provided.
:param auto_upload_download_files: Whether to automatically upload and download files. Defaults to True.
:param dangerously_allow_auto_upload_download_files: Opt-in for automatic file upload/download. Defaults to False.
:param sensitive_file_upload_protection: When True, block local paths on the built-in sensitive-path denylist before upload.
:param file_upload_path_deny_segments: Extra path segment names to merge with the built-in denylist.
:param file_upload_dirs: Allowlist of directories from which local files may
be auto-uploaded. Only consulted when
``dangerously_allow_auto_upload_download_files=True``. See Composio docs
for details.
"""
self._auto_upload_download_files = bool(
dangerously_allow_auto_upload_download_files
)
# Resolve allowlist once, but only if auto-upload is enabled. When it's
# disabled there's no auto-upload code path, so we pass ``None`` and
# FileHelper won't run the allowlist check either way. Manual APIs (if
# ever added in the future) should also pass ``None``.
resolved_allowlist: t.Optional[t.List[Path]] = (
resolve_effective_upload_allowlist(file_upload_dirs)
if self._auto_upload_download_files
else None
)
self._client = client
self._custom_tools = CustomTools(client)
self._tool_schemas: t.Dict[str, Tool] = {}
@@ -125,9 +145,9 @@ class Tools(Resource, t.Generic[TTool, TToolCollection]):
outdir=file_download_dir,
sensitive_file_upload_protection=sensitive_file_upload_protection,
file_upload_path_deny_segments=file_upload_path_deny_segments,
file_upload_allowlist=resolved_allowlist,
)
self._toolkit_versions = toolkit_versions
self._auto_upload_download_files = auto_upload_download_files
self.custom_tool = self._custom_tools.register
self.provider = provider
@@ -314,13 +334,14 @@ class Tools(Resource, t.Generic[TTool, TToolCollection]):
)
# Always enhance schema descriptions (type hints and required notes)
# regardless of auto_upload_download_files setting
# regardless of dangerously_allow_auto_upload_download_files
for tool in tools_list:
tool.input_parameters = self._file_helper.enhance_schema_descriptions(
schema=tool.input_parameters,
)
# Only process file_uploadable schemas when auto_upload_download_files is True
# Only process file_uploadable schemas when the caller opted in via
# `dangerously_allow_auto_upload_download_files=True`.
if self._auto_upload_download_files:
for tool in tools_list:
tool.input_parameters = (
+11
View File
@@ -172,6 +172,17 @@ class SensitiveFilePathBlockedError(FileError):
"""Raised when a local file path is refused before upload (sensitive directory or credential-like name)."""
class FileUploadPathNotAllowedError(FileError):
"""
Raised when automatic file upload during tool execution is attempted from a
path outside the configured ``file_upload_dirs`` allowlist.
Only fires for auto-upload (enabled via
``dangerously_allow_auto_upload_download_files=True``). Manual upload APIs
are not subject to this check.
"""
class FileUploadAbortedError(FileError):
"""Raised when a ``before_file_upload`` hook returns ``False``."""
+30 -4
View File
@@ -40,9 +40,10 @@ class SDKConfig(te.TypedDict):
allow_tracking: te.NotRequired[bool]
file_download_dir: te.NotRequired[str]
toolkit_versions: te.NotRequired[ToolkitVersionParam]
auto_upload_download_files: te.NotRequired[bool]
dangerously_allow_auto_upload_download_files: te.NotRequired[bool]
sensitive_file_upload_protection: te.NotRequired[bool]
file_upload_path_deny_segments: te.NotRequired[t.Sequence[str]]
file_upload_dirs: te.NotRequired[t.Union[t.Sequence[str], t.Literal[False]]]
class Composio(t.Generic[TTool, TToolCollection], WithLogger):
@@ -105,9 +106,25 @@ class Composio(t.Generic[TTool, TToolCollection], WithLogger):
- A dictionary mapping toolkit names to specific versions
- A string (e.g., 'latest', '20250906_01') to use the same version for all toolkits
- None or omitted to use 'latest' as default
:param auto_upload_download_files: Whether to automatically upload and download files. Defaults to True.
:param dangerously_allow_auto_upload_download_files: Opt-in for automatic file
upload and download during tool execution. Defaults to False.
:param sensitive_file_upload_protection: When True, block local paths on the built-in sensitive-path denylist before upload. Defaults to True.
:param file_upload_path_deny_segments: Extra path segment names merged with the built-in denylist.
:param file_upload_dirs: Allowlist of directories from which the SDK is allowed
to read local files during **automatic** file upload (the flow gated by
``dangerously_allow_auto_upload_download_files=True``).
- ``None`` (default) -> ``[~/.composio/temp]``.
- ``False`` -> reject every local path during auto-upload. URLs and
in-memory bytes still work because they aren't path-checked.
- ``Sequence[str]`` (non-empty) -> use as the allowlist. A file is accepted
iff its symlink-resolved absolute path is inside one of these directories
on a path-component boundary (``/tmp/foo`` allows ``/tmp/foo/bar`` but
NOT ``/tmp/foo-bar``).
- ``[]`` -> behaves like ``False`` (kept as an alias; prefer ``False``).
- Providing a value REPLACES the default. Include ``~/.composio/temp`` in
your list if you want the default staging dir to keep working.
- On Windows, entries are compared case-insensitively.
"""
WithLogger.__init__(self)
api_key = kwargs.get("api_key", os.environ.get("COMPOSIO_API_KEY"))
@@ -140,14 +157,20 @@ class Composio(t.Generic[TTool, TToolCollection], WithLogger):
file_upload_path_deny_segments: t.Optional[t.Sequence[str]] = kwargs.get(
"file_upload_path_deny_segments"
)
file_upload_dirs: t.Union[t.Sequence[str], t.Literal[False], None] = kwargs.get(
"file_upload_dirs"
)
self.tools = Tools(
client=self._client,
provider=actual_provider,
file_download_dir=kwargs.get("file_download_dir"),
toolkit_versions=toolkit_versions,
auto_upload_download_files=kwargs.get("auto_upload_download_files", True),
dangerously_allow_auto_upload_download_files=kwargs.get(
"dangerously_allow_auto_upload_download_files", False
),
sensitive_file_upload_protection=sensitive_file_upload_protection,
file_upload_path_deny_segments=file_upload_path_deny_segments,
file_upload_dirs=file_upload_dirs,
)
self.toolkits = Toolkits(client=self._client)
@@ -165,9 +188,12 @@ class Composio(t.Generic[TTool, TToolCollection], WithLogger):
self.tool_router = ToolRouter(
client=self._client,
provider=actual_provider,
auto_upload_download_files=kwargs.get("auto_upload_download_files", True),
dangerously_allow_auto_upload_download_files=kwargs.get(
"dangerously_allow_auto_upload_download_files", False
),
sensitive_file_upload_protection=sensitive_file_upload_protection,
file_upload_path_deny_segments=file_upload_path_deny_segments,
file_upload_dirs=file_upload_dirs,
)
self.create = self.tool_router.create
self.use = self.tool_router.use
@@ -0,0 +1,239 @@
"""Allowlist enforcement for automatic file upload during tool execution.
This is only consulted when
``dangerously_allow_auto_upload_download_files=True``. Manual upload APIs are
not subject to the allowlist (parity with the TypeScript SDK).
- A local path is accepted iff its symlink-resolved absolute path is inside one
of the allowed directories on a path-component boundary. ``/tmp/foo`` allows
``/tmp/foo/bar`` but NOT ``/tmp/foo-bar``.
- URLs never hit this check.
- User-provided ``file_upload_dirs`` fully REPLACES the default
``[~/.composio/temp]``.
- On Windows, path comparisons are case-insensitive.
"""
from __future__ import annotations
import os
import sys
import typing as t
from pathlib import Path
from composio.exceptions import (
FileUploadPathNotAllowedError,
SDKFileNotFoundError,
)
UPLOAD_TEMP_DIRECTORY_NAME = "temp"
def get_default_upload_dir() -> t.Optional[Path]:
"""Absolute path of the default upload staging directory
(``<home>/.composio/temp``), or None when a home directory cannot be
determined."""
try:
home = Path.home()
except (RuntimeError, OSError):
return None
return (home / ".composio" / UPLOAD_TEMP_DIRECTORY_NAME).resolve()
def ensure_dir_exists(p: Path) -> None:
"""Best-effort directory creation. Allowlist enforcement still works if
creation fails (e.g. read-only filesystem)."""
try:
p.mkdir(parents=True, exist_ok=True)
except OSError:
pass
FileUploadDirs = t.Union[t.Sequence[str], t.Literal[False], None]
"""User-facing type for ``file_upload_dirs``.
- ``None`` (default) -> use ``[<home>/.composio/temp]``.
- ``False`` -> reject every local path during auto-upload (URLs / bytes still
work).
- ``[]`` -> same as ``False`` (kept as an alias; prefer ``False``).
- ``Sequence[str]`` (non-empty) -> allowlist replacing the default.
"""
def resolve_effective_upload_allowlist(
user_dirs: FileUploadDirs,
) -> t.List[Path]:
"""Resolve the effective allowlist.
- ``None`` -> ``[<default>]`` when available, else ``[]`` (fail-closed).
- ``False`` -> ``[]`` (explicit "reject all local paths"). URLs and
in-memory bytes still work because they aren't path-checked.
- ``Sequence[str]`` (including ``[]``) REPLACES the default and is returned
verbatim after ``~``-expansion and ``resolve()``. ``[]`` is treated as
equivalent to ``False``.
- Empty / blank / non-string entries are skipped.
- Duplicates are removed (case-insensitive on Windows).
"""
if user_dirs is None:
default = get_default_upload_dir()
return [default] if default is not None else []
if user_dirs is False:
return []
seen: t.Set[str] = set()
out: t.List[Path] = []
for raw in user_dirs:
if not isinstance(raw, (str, os.PathLike)):
continue
s = str(raw).strip()
if not s:
continue
abs_path = Path(s).expanduser().resolve()
key = str(abs_path).lower() if sys.platform == "win32" else str(abs_path)
if key in seen:
continue
seen.add(key)
out.append(abs_path)
return out
def _is_inside_dir(child: Path, parent: Path) -> bool:
"""True iff ``child`` equals ``parent`` or is nested inside on a component
boundary. Assumes both are absolute and normalized."""
try:
child_str = str(child)
parent_str = str(parent)
if sys.platform == "win32":
child_str = child_str.lower()
parent_str = parent_str.lower()
if child_str == parent_str:
return True
sep = os.sep
parent_with_sep = parent_str if parent_str.endswith(sep) else parent_str + sep
return child_str.startswith(parent_with_sep)
except OSError:
return False
def _format_allowlist(dirs: t.Sequence[Path]) -> str:
if not dirs:
return " (none configured — all local paths are blocked)"
return "\n".join(f" - {d}" for d in dirs)
def _build_help_footer(allowlist: t.Sequence[Path]) -> str:
return "\n".join(
[
"",
"Allowed upload directories (file_upload_dirs):",
_format_allowlist(allowlist),
"",
"Fix one of the following:",
"",
" 1. Recommended — move the file under an allowed directory, or add your",
" directory to the allowlist:",
" Composio(",
" file_upload_dirs=['/abs/path/to/uploads', '~/.composio/temp'],",
" )",
" Note: user-provided `file_upload_dirs` REPLACES the default",
" `~/.composio/temp`. Include it explicitly if you want staged uploads",
" to keep working.",
"",
" 2. Pass the file by URL (http://... / https://...) instead of a",
" filesystem path. URLs are never path-checked.",
"",
" 3. (Dangerous) Bypass the allowlist entirely by opting into automatic",
" file upload/download from any readable path:",
" Composio(dangerously_allow_auto_upload_download_files=True, ...)",
" WARNING: This lets a prompt-injected or misbehaving tool read ANY",
" file the process can access. Only enable it in trusted, sandboxed",
" environments. The built-in sensitive-path denylist (.ssh, .aws,",
" .env, private SSH keys, etc.) still applies unless you also set",
" `sensitive_file_upload_protection=False`.",
]
)
def assert_path_inside_upload_dirs(
file_path: t.Union[str, Path],
allowlist: t.Sequence[Path],
) -> None:
"""Raise an elaborate error if the path is missing or outside the allowlist.
:raises SDKFileNotFoundError: when ``file_path`` does not exist on disk.
:raises FileUploadPathNotAllowedError: when resolved path is outside every
entry of ``allowlist`` (including when allowlist is empty).
"""
attempted = str(file_path)
abs_path = Path(attempted).expanduser()
try:
abs_path = abs_path.resolve(strict=False)
except OSError:
pass
if not abs_path.exists():
cwd = Path.cwd()
parent = abs_path.parent
parent_exists = parent.exists()
raise SDKFileNotFoundError(
"\n".join(
[
f'Refusing to auto-upload "{attempted}": the file does not exist on disk.',
"",
f"Path attempted: {attempted}",
f"Resolved to: {abs_path}",
f"Process cwd: {cwd}",
f"Parent exists: {'yes (' + str(parent) + ')' if parent_exists else 'no (' + str(parent) + ')'}",
"",
"Common causes:",
" - Typo in the filename passed to the tool.",
" - Relative path resolved against the wrong working directory",
" (relative paths use os.getcwd() at the moment of upload).",
" - File was deleted between the tool being called and the upload starting.",
"",
_build_help_footer(allowlist),
]
)
)
try:
real_path = abs_path.resolve(strict=True)
except OSError:
real_path = abs_path
if not allowlist:
raise FileUploadPathNotAllowedError(
"\n".join(
[
f'Refusing to auto-upload "{attempted}": no upload directories are configured.',
"",
f"Path attempted: {attempted}",
f"Resolved to: {real_path}",
"",
"Automatic file upload during tool execution is locked down by default",
"to prevent a prompt-injected tool from exfiltrating server files",
"(source code, .env, SSH keys, etc.).",
_build_help_footer(allowlist),
]
)
)
for dir_entry in allowlist:
try:
real_dir = Path(dir_entry).expanduser().resolve(strict=False)
except OSError:
real_dir = Path(dir_entry)
if _is_inside_dir(real_path, real_dir):
return
raise FileUploadPathNotAllowedError(
"\n".join(
[
f'Refusing to auto-upload "{attempted}": resolved path is not inside any',
"directory in the configured `file_upload_dirs` allowlist.",
"",
f"Path attempted: {attempted}",
f"Resolved to: {real_path}",
_build_help_footer(allowlist),
]
)
)
+32 -32
View File
@@ -75,7 +75,7 @@ class TestAutoUploadDownloadFilesEnabled:
tools = Tools(
client=mock_client,
provider=mock_provider,
auto_upload_download_files=True,
dangerously_allow_auto_upload_download_files=True,
toolkit_versions={"test_toolkit": "20251201_01"},
)
@@ -119,7 +119,7 @@ class TestAutoUploadDownloadFilesEnabled:
tools = Tools(
client=mock_client,
provider=mock_provider,
auto_upload_download_files=True,
dangerously_allow_auto_upload_download_files=True,
toolkit_versions={"test_toolkit": "20251201_01"},
)
@@ -182,7 +182,7 @@ class TestAutoUploadDownloadFilesEnabled:
tools = Tools(
client=mock_client,
provider=mock_provider,
auto_upload_download_files=True,
dangerously_allow_auto_upload_download_files=True,
toolkit_versions={"test_toolkit": "20251201_01"},
)
@@ -243,7 +243,7 @@ class TestAutoUploadDownloadFilesEnabled:
tools = Tools(
client=mock_client,
provider=mock_provider,
auto_upload_download_files=True,
dangerously_allow_auto_upload_download_files=True,
toolkit_versions={"test_toolkit": "20251201_01"},
)
@@ -314,7 +314,6 @@ class TestAutoUploadDownloadFilesDisabled:
tools = Tools(
client=mock_client,
provider=mock_provider,
auto_upload_download_files=False,
toolkit_versions={"test_toolkit": "20251201_01"},
)
@@ -363,7 +362,6 @@ class TestAutoUploadDownloadFilesDisabled:
tools = Tools(
client=mock_client,
provider=mock_provider,
auto_upload_download_files=False,
toolkit_versions={"test_toolkit": "20251201_01"},
)
@@ -419,7 +417,6 @@ class TestAutoUploadDownloadFilesDisabled:
tools = Tools(
client=mock_client,
provider=mock_provider,
auto_upload_download_files=False,
toolkit_versions={"test_toolkit": "20251201_01"},
)
@@ -464,7 +461,6 @@ class TestAutoUploadDownloadFilesDisabled:
tools = Tools(
client=mock_client,
provider=mock_provider,
auto_upload_download_files=False,
toolkit_versions={"test_toolkit": "20251201_01"},
)
@@ -518,28 +514,8 @@ class TestAutoUploadDownloadFilesDisabled:
class TestAutoUploadDownloadFilesWithSDK:
"""Test cases for auto_upload_download_files with SDK initialization."""
def test_sdk_passes_auto_upload_download_files_to_tools(self):
"""Test that Composio SDK passes auto_upload_download_files to Tools."""
from composio.sdk import Composio
with patch("composio.sdk.HttpClient"):
# Test with default (True)
with patch.object(Tools, "__init__", return_value=None) as mock_init:
mock_provider = Mock()
mock_provider.name = "test"
Composio(
provider=mock_provider,
api_key="test-key",
)
# Should have passed auto_upload_download_files=True (default)
mock_init.assert_called()
call_kwargs = mock_init.call_args[1]
assert call_kwargs.get("auto_upload_download_files", True) is True
def test_sdk_passes_false_to_tools(self):
"""Test that Composio SDK correctly passes auto_upload_download_files=False."""
def test_sdk_passes_auto_upload_download_off_to_tools_by_default(self):
"""Test that Composio SDK disables automatic file upload/download by default."""
from composio.sdk import Composio
with patch("composio.sdk.HttpClient"):
@@ -550,9 +526,33 @@ class TestAutoUploadDownloadFilesWithSDK:
Composio(
provider=mock_provider,
api_key="test-key",
auto_upload_download_files=False,
)
mock_init.assert_called()
call_kwargs = mock_init.call_args[1]
assert call_kwargs.get("auto_upload_download_files") is False
assert (
call_kwargs.get("dangerously_allow_auto_upload_download_files")
is False
)
def test_sdk_passes_true_when_dangerously_enabled(self):
"""Test that Composio SDK passes through dangerously_allow_auto_upload_download_files."""
from composio.sdk import Composio
with patch("composio.sdk.HttpClient"):
with patch.object(Tools, "__init__", return_value=None) as mock_init:
mock_provider = Mock()
mock_provider.name = "test"
Composio(
provider=mock_provider,
api_key="test-key",
dangerously_allow_auto_upload_download_files=True,
)
mock_init.assert_called()
call_kwargs = mock_init.call_args[1]
assert (
call_kwargs.get("dangerously_allow_auto_upload_download_files")
is True
)
+3 -3
View File
@@ -576,7 +576,7 @@ def _session(deps, **overrides):
kwargs = dict(
client=deps["client"],
provider=deps["provider"],
auto_upload_download_files=True,
dangerously_allow_auto_upload_download_files=True,
session_id="s",
mcp=MagicMock(),
experimental=deps["experimental"],
@@ -644,7 +644,7 @@ class TestToolRouterSessionCustomTools:
s = ToolRouterSession(
client=MagicMock(),
provider=MagicMock(),
auto_upload_download_files=True,
dangerously_allow_auto_upload_download_files=True,
session_id="s",
mcp=MagicMock(),
experimental=MagicMock(),
@@ -663,7 +663,7 @@ class TestMultiExecuteRouting:
return ToolRouterSession(
client=MagicMock(),
provider=MagicMock(),
auto_upload_download_files=True,
dangerously_allow_auto_upload_download_files=True,
session_id="s",
mcp=MagicMock(),
experimental=MagicMock(),
+117
View File
@@ -1322,6 +1322,123 @@ class TestFileUploadableFromUrl:
assert "Fetch failed" in str(exc_info.value)
def test_before_file_upload_hook_receives_source_url(self):
"""from_path emits ``source="url"`` to the hook for http(s) inputs.
We abort from the hook to avoid the downstream network path; the
pre-abort capture is what we're asserting on.
"""
from composio.exceptions import FileUploadAbortedError
mock_client = MagicMock()
seen = {}
def hook(ctx):
seen.update(ctx)
return False
with pytest.raises(FileUploadAbortedError):
FileUploadable.from_path(
client=mock_client,
file="https://example.com/photo.png",
tool="SEND_EMAIL",
toolkit="gmail",
before_file_upload=hook,
)
assert seen == {
"path": "https://example.com/photo.png",
"source": "url",
"tool": "SEND_EMAIL",
"toolkit": "gmail",
}
def test_before_file_upload_hook_receives_source_path(self, tmp_path):
"""from_path emits ``source="path"`` to the hook for local inputs."""
from composio.exceptions import FileUploadAbortedError
f = tmp_path / "doc.txt"
f.write_text("hello")
mock_client = MagicMock()
seen = {}
def hook(ctx):
seen.update(ctx)
return False
with pytest.raises(FileUploadAbortedError):
FileUploadable.from_path(
client=mock_client,
file=str(f),
tool="MY_TOOL",
toolkit="my_toolkit",
before_file_upload=hook,
)
assert seen == {
"path": str(f),
"source": "path",
"tool": "MY_TOOL",
"toolkit": "my_toolkit",
}
def test_url_hook_returning_local_path_routes_through_path_branch(self, tmp_path):
"""A hook that rewrites a URL into a local path must NOT be fed to
``from_url``. It has to route back into the local-file branch so the
allowlist / denylist / existence checks all run."""
from composio.exceptions import SDKFileNotFoundError
# A path that's syntactically a path but does not exist — if routing
# is correct, we'll get SDKFileNotFoundError from the local branch.
# If the bug is still there, we'd hit `from_url` and the URL fetch
# would explode (or worse, succeed) instead.
rewritten = str(tmp_path / "does-not-exist.txt")
def hook(ctx):
assert ctx["source"] == "url"
return rewritten
mock_client = MagicMock()
with pytest.raises(SDKFileNotFoundError):
FileUploadable.from_path(
client=mock_client,
file="https://example.com/photo.png",
tool="T",
toolkit="tk",
before_file_upload=hook,
)
@patch("composio.core.models._files._fetch_file_from_url")
@patch("composio.core.models._files._upload_bytes_to_s3")
def test_path_hook_returning_url_routes_through_url_branch(
self, mock_upload, mock_fetch, tmp_path
):
"""Inverse of the above: a hook on a local path that returns a URL
must route through ``from_url``, not stat the URL string as a file."""
f = tmp_path / "local.txt"
f.write_text("hi")
mock_fetch.return_value = ("photo.png", b"x", "image/png")
mock_upload.return_value = "s3-key"
def hook(ctx):
assert ctx["source"] == "path"
return "https://example.com/photo.png"
mock_client = MagicMock()
result = FileUploadable.from_path(
client=mock_client,
file=str(f),
tool="T",
toolkit="tk",
before_file_upload=hook,
)
# If routing worked, the URL fetch path was taken.
mock_fetch.assert_called_once_with("https://example.com/photo.png")
assert result.s3key == "s3-key"
class TestFileHelperWithUrls:
"""Test cases for FileHelper handling URLs in file uploads."""
+2 -1
View File
@@ -56,9 +56,10 @@ class TestComposioSDK:
"allow_tracking",
"file_download_dir",
"toolkit_versions",
"auto_upload_download_files",
"dangerously_allow_auto_upload_download_files",
"sensitive_file_upload_protection",
"file_upload_path_deny_segments",
"file_upload_dirs",
}
assert set(SDKConfig.__annotations__.keys()) == expected_fields
+116 -4
View File
@@ -855,12 +855,17 @@ class TestToolExecution:
scoped = before_file_upload(tools=["OTHER_TOOL"])(lambda p, t, k: p + "_X")
fn = merge_before_file_upload([scoped], tool="MY", toolkit="gh")
assert fn is not None
assert fn("/a", "MY", "gh") == "/a"
assert (
fn({"path": "/a", "source": "path", "tool": "MY", "toolkit": "gh"}) == "/a"
)
all_tools = before_file_upload(lambda p, t, k: p + "_Y")
fn2 = merge_before_file_upload([all_tools], tool="MY", toolkit="gh")
assert fn2 is not None
assert fn2("/a", "MY", "gh") == "/a_Y"
assert (
fn2({"path": "/a", "source": "path", "tool": "MY", "toolkit": "gh"})
== "/a_Y"
)
def test_merge_before_file_upload_chains_modifiers(self):
from composio.core.models._modifiers import (
@@ -872,7 +877,103 @@ class TestToolExecution:
m2 = before_file_upload(lambda p, t, k: f"{p}|2")
fn = merge_before_file_upload([m1, m2], tool="T", toolkit="k")
assert fn is not None
assert fn("p", "T", "k") == "p|1|2"
assert (
fn({"path": "p", "source": "path", "tool": "T", "toolkit": "k"}) == "p|1|2"
)
def test_before_file_upload_context_form_receives_source(self):
"""New-form hooks (single ``context`` arg) see ``source`` in the context."""
from composio.core.models._modifiers import (
before_file_upload,
merge_before_file_upload,
)
seen: t.Dict[str, t.Any] = {}
def hook(ctx):
seen.update(ctx)
return ctx["path"] + "!"
m = before_file_upload(hook)
fn = merge_before_file_upload([m], tool="T", toolkit="k")
assert fn is not None
out = fn({"path": "/x", "source": "path", "tool": "T", "toolkit": "k"})
assert out == "/x!"
assert seen == {"path": "/x", "source": "path", "tool": "T", "toolkit": "k"}
seen.clear()
out = fn(
{
"path": "https://example.com/a.pdf",
"source": "url",
"tool": "T",
"toolkit": "k",
}
)
assert out == "https://example.com/a.pdf!"
assert seen["source"] == "url"
def test_before_file_upload_legacy_3arg_form_still_works(self):
"""Hooks declared as ``(path, tool, toolkit)`` keep working unchanged."""
from composio.core.models._modifiers import (
before_file_upload,
merge_before_file_upload,
)
def legacy(path, tool, toolkit):
return f"{path}@{tool}/{toolkit}"
m = before_file_upload(legacy)
fn = merge_before_file_upload([m], tool="T", toolkit="k")
assert fn is not None
# Driver always passes context form; adapter unwraps to positional args.
assert (
fn({"path": "/x", "source": "path", "tool": "T", "toolkit": "k"})
== "/x@T/k"
)
def test_before_file_upload_chain_preserves_source(self):
"""``source`` stays stable as the ``path`` is rewritten down the chain."""
from composio.core.models._modifiers import (
before_file_upload,
merge_before_file_upload,
)
seen_sources: t.List[str] = []
def h1(ctx):
seen_sources.append(ctx["source"])
return ctx["path"] + "|1"
def h2(ctx):
seen_sources.append(ctx["source"])
return ctx["path"] + "|2"
fn = merge_before_file_upload(
[before_file_upload(h1), before_file_upload(h2)], tool="T", toolkit="k"
)
assert fn is not None
out = fn({"path": "p", "source": "url", "tool": "T", "toolkit": "k"})
assert out == "p|1|2"
assert seen_sources == ["url", "url"]
def test_before_file_upload_abort_propagates_as_false(self):
from composio.core.models._modifiers import (
before_file_upload,
merge_before_file_upload,
)
def abort(ctx):
return False
fn = merge_before_file_upload(
[before_file_upload(abort)], tool="T", toolkit="k"
)
assert fn is not None
assert (
fn({"path": "/x", "source": "path", "tool": "T", "toolkit": "k"}) is False
)
def test_execute_includes_before_file_upload_modifier_in_compose(
self,
@@ -888,6 +989,7 @@ class TestToolExecution:
client=mock_client,
provider=mock_provider,
toolkit_versions={"github": "20251201_01"},
dangerously_allow_auto_upload_download_files=True,
)
github_tool = self.create_mock_tool("GITHUB_GET_REPOS", "github")
@@ -923,7 +1025,17 @@ class TestToolExecution:
bfu = cap.get("bfu")
assert bfu is not None
assert bfu("/p", "GITHUB_GET_REPOS", "github") == "/p>"
assert (
bfu(
{
"path": "/p",
"source": "path",
"tool": "GITHUB_GET_REPOS",
"toolkit": "github",
}
)
== "/p>"
)
def test_execute_with_environment_variable_toolkit_version(self):
"""Test that execute uses environment variable for toolkit version."""
+6 -5
View File
@@ -966,13 +966,14 @@ class TestToolRouter:
session = tool_router.create(user_id="user_123")
result = session.tools()
# Verify Tools was instantiated
# Verify Tools was instantiated (resolved flag; default ToolRouter is off)
mock_tools_class.assert_called_once_with(
client=mock_client,
provider=mock_provider,
auto_upload_download_files=True,
dangerously_allow_auto_upload_download_files=False,
sensitive_file_upload_protection=True,
file_upload_path_deny_segments=None,
file_upload_dirs=None,
)
# Verify get_raw_tool_router_meta_tools was called
@@ -1267,7 +1268,7 @@ class TestToolRouterExecution:
real_tools = RealTools(
client=mock_client,
provider=mock_provider,
auto_upload_download_files=False,
dangerously_allow_auto_upload_download_files=False,
)
execute_fn = real_tools._wrap_execute_tool_for_tool_router(
session_id="session_123"
@@ -1318,7 +1319,7 @@ class TestToolRouterExecution:
real_tools = RealTools(
client=mock_client,
provider=mock_provider,
auto_upload_download_files=False,
dangerously_allow_auto_upload_download_files=False,
)
modifiers = [
@@ -1361,7 +1362,7 @@ class TestToolRouterExecution:
real_tools = RealTools(
client=mock_client,
provider=mock_provider,
auto_upload_download_files=False,
dangerously_allow_auto_upload_download_files=False,
)
execute_fn = real_tools._wrap_execute_tool_for_tool_router(
session_id="session_123"
+251
View File
@@ -0,0 +1,251 @@
"""Tests for the auto-upload directory allowlist (parity with TS sdk)."""
from __future__ import annotations
import os
import sys
from pathlib import Path
import pytest
from composio.core.models.base import allow_tracking
from composio.exceptions import (
FileUploadPathNotAllowedError,
SDKFileNotFoundError,
)
from composio.utils.upload_dir_allowlist import (
assert_path_inside_upload_dirs,
get_default_upload_dir,
resolve_effective_upload_allowlist,
)
@pytest.fixture(autouse=True)
def disable_telemetry():
token = allow_tracking.set(False)
yield
allow_tracking.reset(token)
class TestResolveEffectiveUploadAllowlist:
def test_none_returns_default(self):
result = resolve_effective_upload_allowlist(None)
default = get_default_upload_dir()
assert default is not None, "Expected a default upload dir in this env."
assert result == [default]
def test_empty_list_replaces_default(self):
assert resolve_effective_upload_allowlist([]) == []
def test_false_means_no_local_paths(self):
# Explicit "reject all local paths during auto-upload". Equivalent to []
# but reads better at call sites.
assert resolve_effective_upload_allowlist(False) == []
def test_explicit_list_replaces_default(self, tmp_path: Path):
result = resolve_effective_upload_allowlist([str(tmp_path)])
assert result == [tmp_path.resolve()]
def test_tilde_is_expanded(self):
result = resolve_effective_upload_allowlist(["~"])
assert result == [Path.home().resolve()]
def test_skips_blank_and_non_string_entries(self, tmp_path: Path):
result = resolve_effective_upload_allowlist([str(tmp_path), "", " "])
assert result == [tmp_path.resolve()]
def test_deduplicates_entries(self, tmp_path: Path):
result = resolve_effective_upload_allowlist([str(tmp_path), str(tmp_path)])
assert result == [tmp_path.resolve()]
class TestAssertPathInsideUploadDirs:
def test_accepts_direct_child(self, tmp_path: Path):
allowed = tmp_path
f = allowed / "a.txt"
f.write_text("hi")
assert_path_inside_upload_dirs(str(f), [allowed])
def test_accepts_nested_child(self, tmp_path: Path):
allowed = tmp_path
nested = allowed / "sub" / "deeper"
nested.mkdir(parents=True)
f = nested / "a.txt"
f.write_text("hi")
assert_path_inside_upload_dirs(str(f), [allowed])
def test_rejects_path_outside(self, tmp_path: Path):
allowed = tmp_path / "inside"
outside = tmp_path / "outside"
allowed.mkdir()
outside.mkdir()
f = outside / "a.txt"
f.write_text("hi")
with pytest.raises(FileUploadPathNotAllowedError):
assert_path_inside_upload_dirs(str(f), [allowed])
def test_enforces_component_boundary(self, tmp_path: Path):
# /tmp/foo must NOT accept /tmp/foo-bar/x.
allowed = tmp_path / "foo"
sibling = tmp_path / "foo-bar"
allowed.mkdir()
sibling.mkdir()
f = sibling / "x.txt"
f.write_text("hi")
with pytest.raises(FileUploadPathNotAllowedError):
assert_path_inside_upload_dirs(str(f), [allowed])
def test_raises_not_found_when_missing(self, tmp_path: Path):
allowed = tmp_path
missing = tmp_path / "does_not_exist.txt"
with pytest.raises(SDKFileNotFoundError) as excinfo:
assert_path_inside_upload_dirs(str(missing), [allowed])
assert "does not exist on disk" in str(excinfo.value)
assert "file_upload_dirs" in str(excinfo.value)
def test_empty_allowlist_fails_closed(self, tmp_path: Path):
f = tmp_path / "a.txt"
f.write_text("hi")
with pytest.raises(FileUploadPathNotAllowedError) as excinfo:
assert_path_inside_upload_dirs(str(f), [])
msg = str(excinfo.value)
assert "no upload directories are configured" in msg
assert "dangerously_allow_auto_upload_download_files" in msg
def test_error_message_lists_allowlist(self, tmp_path: Path):
allowed = tmp_path / "inside"
outside = tmp_path / "outside"
allowed.mkdir()
outside.mkdir()
f = outside / "a.txt"
f.write_text("hi")
with pytest.raises(FileUploadPathNotAllowedError) as excinfo:
assert_path_inside_upload_dirs(str(f), [allowed])
msg = str(excinfo.value)
assert str(allowed) in msg
assert "file_upload_dirs" in msg
assert "dangerously_allow_auto_upload_download_files" in msg
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only symlink semantics")
def test_rejects_symlink_pointing_outside(self, tmp_path: Path):
allowed = tmp_path / "inside"
outside = tmp_path / "outside"
allowed.mkdir()
outside.mkdir()
secret = outside / "secret.txt"
secret.write_text("pw")
link = allowed / "link.txt"
os.symlink(secret, link)
with pytest.raises(FileUploadPathNotAllowedError):
assert_path_inside_upload_dirs(str(link), [allowed])
class TestToolsWiresAllowlistCorrectly:
"""Verifies ``Tools`` forwards the right allowlist to ``FileHelper`` given
the full flag/value matrix."""
@staticmethod
def _make_tools(
dangerously_allow_auto_upload_download_files: bool,
file_upload_dirs,
):
from composio.core.models.tools import Tools
from unittest.mock import Mock
return Tools(
client=Mock(),
provider=Mock(name="provider"),
dangerously_allow_auto_upload_download_files=dangerously_allow_auto_upload_download_files,
file_upload_dirs=file_upload_dirs,
)
def test_flag_off_means_no_allowlist_is_ever_built(self):
# Flag off ⇒ auto-upload code path is never run. We pass ``None`` to
# FileHelper so manual/no-op paths don't accidentally enforce anything.
t = self._make_tools(False, None)
assert t._file_helper._file_upload_allowlist is None # noqa: SLF001
def test_flag_off_ignores_file_upload_dirs_entirely(self):
t = self._make_tools(False, ["/tmp/whatever"])
assert t._file_helper._file_upload_allowlist is None # noqa: SLF001
t = self._make_tools(False, False)
assert t._file_helper._file_upload_allowlist is None # noqa: SLF001
t = self._make_tools(False, [])
assert t._file_helper._file_upload_allowlist is None # noqa: SLF001
def test_flag_on_none_uses_default(self):
t = self._make_tools(True, None)
assert t._file_helper._file_upload_allowlist == [ # noqa: SLF001
get_default_upload_dir()
]
def test_flag_on_false_means_empty_allowlist(self):
t = self._make_tools(True, False)
assert t._file_helper._file_upload_allowlist == [] # noqa: SLF001
def test_flag_on_empty_list_means_empty_allowlist(self):
t = self._make_tools(True, [])
assert t._file_helper._file_upload_allowlist == [] # noqa: SLF001
def test_flag_on_explicit_dirs_replace_default(self, tmp_path: Path):
t = self._make_tools(True, [str(tmp_path)])
assert t._file_helper._file_upload_allowlist == [ # noqa: SLF001
tmp_path.resolve()
]
class TestFileUploadableAllowlistWiring:
"""Sanity: FileUploadable.from_path must honor the allowlist param."""
def test_from_path_with_allowlist_blocks_outside(self, tmp_path: Path):
from composio.core.models._files import FileUploadable
from unittest.mock import Mock
inside = tmp_path / "inside"
outside = tmp_path / "outside"
inside.mkdir()
outside.mkdir()
f = outside / "a.txt"
f.write_text("hi")
with pytest.raises(FileUploadPathNotAllowedError):
FileUploadable.from_path(
client=Mock(),
file=str(f),
tool="T",
toolkit="tk",
file_upload_allowlist=[inside],
)
def test_from_path_without_allowlist_skips_check(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
"""Passing ``file_upload_allowlist=None`` must NOT trigger the check.
This simulates manual upload paths and the no-auto-upload default.
"""
from composio.core.models import _files as files_mod
from composio.core.models._files import FileUploadable
from unittest.mock import Mock
f = tmp_path / "a.txt"
f.write_text("hi")
# Short-circuit the network-bound parts.
mock_client = Mock()
mock_client.post.return_value = Mock(
new_presigned_url="https://example/upload", key="k"
)
monkeypatch.setattr(files_mod, "upload", lambda url, file: True)
result = FileUploadable.from_path(
client=mock_client,
file=str(f),
tool="T",
toolkit="tk",
file_upload_allowlist=None,
)
assert result.s3key == "k"
+48 -17
View File
@@ -6,10 +6,11 @@ Composio SDK includes an automatic file handling system that manages file upload
The file handling system in Composio SDK is:
- **Opt-out**: Enabled by default but can be disabled
- **Automatic**: Handles file uploads and downloads transparently
- **Configurable**: Can be disabled for manual file handling
- **Sensitive path protection (Node)**: Local string paths used for upload are checked by default against a denylist of path segments and file names (see below)
- **Opt-in**: Disabled by default. Set `dangerouslyAllowAutoUploadDownloadFiles: true` to enable.
- **Automatic** (when enabled): Handles file uploads and downloads transparently.
- **Allowlisted**: Even when enabled, local upload paths must resolve inside one of the directories listed in `fileUploadDirs` (default `[~/.composio/temp]`). See [Restricting upload paths with `fileUploadDirs`](#restricting-upload-paths-with-fileuploaddirs) below.
- **Configurable**: Manual handling via `composio.files.upload()` / `composio.files.download()` is always available and is **not** subject to `fileUploadDirs`.
- **Sensitive path protection (Node)**: Local string paths used for upload are checked by default against a denylist of path segments and file names (see below).
## Security: sensitive local paths
@@ -67,10 +68,10 @@ const toolSchema = {
}
```
When executing a tool with file upload:
When executing a tool with file upload (auto-handling enabled):
```typescript
// With auto upload enabled (default)
// Requires `dangerouslyAllowAutoUploadDownloadFiles: true` AND a path inside `fileUploadDirs`
const result = await composio.tools.execute('your-tool', {
arguments: {
file: '/path/to/local/file.txt' // Local file path
@@ -124,22 +125,48 @@ Example tool response with file download:
}
```
## Disabling Auto File Handling
## Default: auto file handling is off
You can disable automatic file handling when initializing the SDK:
Automatic file upload/download during tool execution is **disabled** unless you pass `dangerouslyAllowAutoUploadDownloadFiles: true` to the `Composio` constructor.
## Restricting upload paths with `fileUploadDirs`
When auto-handling is on, the SDK only reads local files from directories you have explicitly allowlisted via `fileUploadDirs`. Anything outside the list is rejected with `ComposioFileUploadPathNotAllowedError` before the file is read.
| `fileUploadDirs` value | Effective allowlist |
| ---------------------- | ------------------- |
| omitted / `undefined` | `[~/.composio/temp]` (default staging dir) |
| `false` | `[]` — **all** local paths rejected (URLs and `File`/`Blob` still work) |
| `[]` | `[]` — same as `false`; explicit "no local paths" |
| `[<dir1>, <dir2>, …]` | exactly those directories. `~` is expanded; entries are `realpath`-resolved; comparison is on a path-component boundary (`/tmp/foo` allows `/tmp/foo/bar` but **not** `/tmp/foo-bar`) |
URLs (`http://…` / `https://…`) and JavaScript `File` / `Blob` objects are not subject to the allowlist — only local string paths are. The denylist (`sensitiveFileUploadProtection`) still runs in addition to the allowlist; both must accept the path.
```typescript
import { Composio } from '@composio/core';
// Widen the allowlist to the directories your code actually reads from.
const composio = new Composio({
apiKey: 'your-api-key',
autoUploadDownloadFiles: false
apiKey: process.env.COMPOSIO_API_KEY!,
dangerouslyAllowAutoUploadDownloadFiles: true,
// Replaces the default `[~/.composio/temp]`. Include it explicitly if you
// still want the default staging dir to keep working.
fileUploadDirs: ['/srv/agent/uploads', '~/.composio/temp'],
});
// Reject every local path; only URLs and File/Blob objects work.
const composioStrict = new Composio({
apiKey: process.env.COMPOSIO_API_KEY!,
dangerouslyAllowAutoUploadDownloadFiles: true,
fileUploadDirs: false,
});
```
If you want to upload from arbitrary paths without widening the allowlist, use the manual `composio.files.upload(...)` API below — it bypasses `fileUploadDirs` by design (the caller is expected to control the path).
## Manual File Handling
When auto file handling is disabled, you'll need to handle the file operations yourself using the `composio.files` API:
When auto file handling is not enabled, you'll need to handle the file operations yourself using the `composio.files` API:
### Manual Upload
@@ -193,12 +220,13 @@ if (result.data.file?.s3url) {
## File Storage Location
Downloaded files are stored in a temporary directory:
Downloaded files are stored in:
- Location: `~/.composio/files/` (user's home directory)
- Files are named using the pattern: `{toolSlug}_{timestamp}.{extension}`
- The directory is created automatically when needed
- File extensions are derived from the MIME type
- **Default**: `~/.composio/files/` (user's home directory).
- **Custom**: pass `fileDownloadDir: '/some/abs/path'` to the `Composio` constructor.
- Files are named using the pattern: `{toolSlug}_{timestamp}.{extension}` (server-synthesized; the S3 URL never affects the local filename).
- The directory is created automatically when needed.
- File extensions are derived from the MIME type.
## Error Handling
@@ -209,6 +237,7 @@ import {
ComposioFileUploadError,
ComposioSensitiveFilePathBlockedError,
ComposioFileUploadAbortedError,
ComposioFileUploadPathNotAllowedError,
} from '@composio/core';
try {
@@ -220,7 +249,9 @@ try {
dangerouslySkipVersionCheck: true,
});
} catch (error) {
if (error instanceof ComposioSensitiveFilePathBlockedError) {
if (error instanceof ComposioFileUploadPathNotAllowedError) {
console.error('Path is outside fileUploadDirs:', error.message);
} else if (error instanceof ComposioSensitiveFilePathBlockedError) {
console.error('Blocked sensitive path:', error.message);
} else if (error instanceof ComposioFileUploadAbortedError) {
console.error('Upload aborted by beforeFileUpload:', error.message);
+106 -5
View File
@@ -11,9 +11,11 @@ const composio = new Composio({
apiKey: 'your-api-key',
baseURL: 'https://api.composio.dev', // Optional: Custom API endpoint
allowTracking: true, // Optional: Enable/disable telemetry
autoUploadDownloadFiles: true, // Optional: Enable/disable automatic file handling
dangerouslyAllowAutoUploadDownloadFiles: false, // Optional: set to true to opt in to automatic file handling (default: false)
sensitiveFileUploadProtection: true, // Optional: block uploads from sensitive paths (Node; default true)
fileUploadPathDenySegments: undefined, // Optional: extra path component denylist
fileUploadDirs: undefined, // Optional: allowlist for automatic upload (defaults to [~/.composio/temp])
fileDownloadDir: undefined, // Optional: where auto-downloaded files are written (defaults to ~/.composio/files)
provider: new OpenAIProvider(), // Optional: Custom provider
});
```
@@ -27,9 +29,11 @@ The `Composio` constructor accepts a configuration object with the following pro
| `apiKey` | string | Yes | - | Your Composio API key |
| `baseURL` | string | No | `https://api.composio.dev` | The base URL for the Composio API |
| `allowTracking` | boolean | No | `true` | Whether to allow analytics/tracking |
| `autoUploadDownloadFiles`| boolean | No | `true` | Whether to automatically handle file operations |
| `dangerouslyAllowAutoUploadDownloadFiles` | boolean | No | `false` | Opt in to automatic file upload/download during tool execution |
| `sensitiveFileUploadProtection` | `boolean` | No | `true` (Node) | When true, local upload paths are checked against a denylist of sensitive segments and credential-like names before read/upload. |
| `fileUploadPathDenySegments` | `string[]` | No | `undefined` | Additional path *components* merged with the built-in denylist. |
| `fileUploadDirs` | `string[] \| false` | No | `[~/.composio/temp]` | Allowlist of directories from which the SDK may read local files during **automatic** upload (when `dangerouslyAllowAutoUploadDownloadFiles: true`). Pass `false` (or `[]`) to reject every local path; URLs and `File`/`Blob` objects still work. Providing a list **replaces** the default — include `~/.composio/temp` explicitly if you want the default staging dir to keep working. Does **not** affect manual `composio.files.upload()` calls. |
| `fileDownloadDir` | `string` | No | `~/.composio/files` | Directory where files downloaded during tool execution (and `composio.files.download()`) are written. Relative paths resolve against `process.cwd()` at SDK-init time. |
| `provider` | `BaseComposioProvider` | No | `new OpenAIProvider()` | The provider to use for this Composio instance |
## Properties
@@ -97,14 +101,49 @@ const composio = new Composio({
});
```
### Disable Automatic File Handling
### Automatic file handling (opt-in)
Automatic upload/download of file-marked tool fields is **off** by default. Set `dangerouslyAllowAutoUploadDownloadFiles: true` only if you intend to use that behavior:
```typescript
import { Composio } from '@composio/core';
const composio = new Composio({
apiKey: process.env.COMPOSIO_API_KEY,
autoUploadDownloadFiles: false,
dangerouslyAllowAutoUploadDownloadFiles: true,
});
```
#### What the LLM sees vs. what the SDK does
The flag is a **contract between the SDK and the model**. The schema handed
to the LLM always reflects what will actually happen at runtime:
- **Flag `true`** — `composio.tools.get(...)` rewrites `file_uploadable`
inputs down to `{ type: 'string', format: 'path' }`. The model passes a
path or URL, the SDK stages it, the backend receives a proper
`{ name, mimetype, s3key }` object. Works end-to-end.
- **Flag `false`** (or omitted) — the raw backend shape
(`{ name, mimetype, s3key }`) is preserved. You are expected to stage
files yourself via `composio.files.upload(...)` and inject the returned
descriptor into the tool arguments before calling `tools.execute`. Handing
this shape directly to an LLM is not recommended — the model cannot
produce a valid `s3key`. On first execution of a file-uploadable tool in
this mode, the SDK emits a single warning per tool slug nudging you
toward either enabling the flag or staging manually.
Manual staging example (flag off):
```typescript
const staged = await composio.files.upload({
file: '/tmp/report.pdf',
toolSlug: 'SOME_FILE_TOOL',
toolkitSlug: 'some-toolkit',
});
await composio.tools.execute('SOME_FILE_TOOL', {
userId: 'u',
arguments: { file: staged }, // { name, mimetype, s3key }
});
```
@@ -121,7 +160,12 @@ await composio.tools.execute(
'SOME_FILE_TOOL',
{ userId: 'u', arguments: { file: '/tmp/report.pdf' }, dangerouslySkipVersionCheck: true },
{
beforeFileUpload: async ({ path, toolSlug, toolkitSlug }) => {
beforeFileUpload: async ({ path, source, toolSlug, toolkitSlug }) => {
// `source` discriminates the input:
// 'path' — a local filesystem path
// 'url' — an http(s):// URL
// 'file' — a File object; `path` is `file.name` (filename only)
if (source !== 'path') return path; // let URLs / File objects through
// return path, a different path, or false to abort
return path;
},
@@ -130,3 +174,60 @@ await composio.tools.execute(
```
See [Auto upload and download](./advanced/auto-upload-download.md#security-sensitive-local-paths) for the security model and error types.
### Restricting automatic uploads to specific directories
When `dangerouslyAllowAutoUploadDownloadFiles: true`, the SDK only reads local
files from directories in `fileUploadDirs`. This stacks with (it does NOT
replace) the sensitive-path denylist.
```typescript
import { Composio } from '@composio/core';
const composio = new Composio({
apiKey: process.env.COMPOSIO_API_KEY!,
dangerouslyAllowAutoUploadDownloadFiles: true,
// User-provided list REPLACES the default `[~/.composio/temp]`.
// Include it explicitly if you want staged uploads to keep working.
fileUploadDirs: ['/srv/agent/uploads', '~/.composio/temp'],
});
```
A local path is accepted if its symlink-resolved absolute path is inside one
of these directories on a path-component boundary. Paths outside the allowlist
throw `ComposioFileUploadPathNotAllowedError`. Missing files throw
`ComposioFileNotFoundError`. URLs (`http(s)://...`) and `File`/`Blob` objects
are not path-checked.
**Manual upload API (`composio.files.upload(...)`) is _not_ subject to this
allowlist** — it bypasses the allowlist check entirely. Use the allowlist to
constrain what models/agents can ask the SDK to upload during tool execution.
#### Blocking local paths entirely
Pass `false` to allow only URLs and in-memory `File`/`Blob` objects during
automatic upload:
```typescript
const composio = new Composio({
apiKey: process.env.COMPOSIO_API_KEY!,
dangerouslyAllowAutoUploadDownloadFiles: true,
fileUploadDirs: false, // reject every filesystem path for auto-upload
});
```
`fileUploadDirs: []` behaves identically; prefer `false` for readability.
### Changing the download directory
```typescript
import { Composio } from '@composio/core';
const composio = new Composio({
apiKey: process.env.COMPOSIO_API_KEY!,
fileDownloadDir: '/var/app/composio-downloads',
});
```
Files returned by tools as `s3url` are streamed into this directory. The
default is `~/.composio/files`.
@@ -7,9 +7,9 @@ This package tests `@composio/core` file operations in Cloudflare Workers enviro
Verifies that:
1. `composio.files.upload()` throws the expected "not supported in Cloudflare Workers" error
2. `composio.files.download()` throws the expected "not supported in Cloudflare Workers" error
3. The `FileToolModifier.workerd.ts` is correctly loaded and provides the expected error message when `autoUploadDownloadFiles` is explicitly enabled
4. Composio initializes correctly with the default configuration (`autoUploadDownloadFiles: false` in workerd runtime)
5. Composio can be explicitly initialized with `autoUploadDownloadFiles: false`
3. The `FileToolModifier.workerd.ts` is correctly loaded and provides the expected error message when `dangerouslyAllowAutoUploadDownloadFiles` is explicitly enabled
4. Composio initializes correctly with the default configuration (`dangerouslyAllowAutoUploadDownloadFiles: false` in workerd runtime)
5. Composio can be explicitly initialized with `dangerouslyAllowAutoUploadDownloadFiles: false`
## Endpoints
@@ -18,8 +18,8 @@ Verifies that:
| `GET /` | Lists all available test endpoints |
| `GET /test/files/upload` | Tests that `files.upload()` throws the expected error |
| `GET /test/files/download` | Tests that `files.download()` throws the expected error |
| `GET /test/file-modifier/error-message` | Tests the error when `autoUploadDownloadFiles: true` is explicitly set |
| `GET /test/auto-upload-disabled` | Tests Composio initialization with explicit `autoUploadDownloadFiles: false` |
| `GET /test/file-modifier/error-message` | Tests the error when `dangerouslyAllowAutoUploadDownloadFiles: true` is explicitly set |
| `GET /test/auto-upload-disabled` | Tests Composio initialization with explicit `dangerouslyAllowAutoUploadDownloadFiles: false` |
| `GET /test/default-config` | Tests Composio initialization with default configuration (no explicit setting) |
## Running Tests
@@ -44,7 +44,7 @@ Note: For file operation tests, a dummy API key works since the operations throw
## Default Configuration
**In Cloudflare Workers (workerd runtime), `autoUploadDownloadFiles` defaults to `false`.**
**In Cloudflare Workers (workerd runtime), `dangerouslyAllowAutoUploadDownloadFiles` defaults to `false`.**
This means you can initialize Composio without any special configuration:
@@ -52,26 +52,26 @@ This means you can initialize Composio without any special configuration:
const composio = new Composio({
apiKey: 'your-key',
});
// autoUploadDownloadFiles is false by default in workerd
// dangerouslyAllowAutoUploadDownloadFiles is false by default in workerd
```
## FileToolModifier Error
If you explicitly set `autoUploadDownloadFiles: true` and execute a tool with file properties in Cloudflare Workers, the following error is thrown:
If you explicitly set `dangerouslyAllowAutoUploadDownloadFiles: true` and execute a tool with file properties in Cloudflare Workers, the following error is thrown:
```
File upload/download modifiers are not available on edge runtimes yet.
Please set `autoUploadDownloadFiles: false` or run Composio in another JS runtime (Node.js / Bun).
Please set `dangerouslyAllowAutoUploadDownloadFiles: false` (or unset it; it defaults to false) or run Composio in another JS runtime (Node.js / Bun).
```
### Best Practice
For edge runtimes, use the default configuration or explicitly set `autoUploadDownloadFiles: false`:
For edge runtimes, use the default configuration or explicitly set `dangerouslyAllowAutoUploadDownloadFiles: false`:
```typescript
const composio = new Composio({
apiKey: 'your-key',
autoUploadDownloadFiles: false, // This is the default for workerd
dangerouslyAllowAutoUploadDownloadFiles: false, // This is the default for workerd
});
```
@@ -94,10 +94,10 @@ app.get('/test/files/download', async c => {
});
/**
* Test: FileToolModifier behavior (triggers the autoUploadDownloadFiles error)
* Test: FileToolModifier behavior (triggers the auto-upload/download error)
*
* The FileToolModifier.workerd.ts throws an error when autoUploadDownloadFiles is enabled.
* In workerd runtime, the default is false, so we explicitly enable it here to test the error.
* The FileToolModifier.workerd.ts throws an error when automatic file upload/download is enabled.
* In workerd runtime, the default is off, so we explicitly opt in here to test the error.
*
* This endpoint actually executes a tool to trigger the real error from FileToolModifier.workerd.ts.
*/
@@ -105,11 +105,11 @@ app.get('/test/file-modifier/error-message', async c => {
try {
const composio = new Composio({
apiKey: c.env.COMPOSIO_API_KEY,
autoUploadDownloadFiles: true,
dangerouslyAllowAutoUploadDownloadFiles: true,
});
// Use a local custom tool to avoid depending on remote Composio tool registry.
// Any execute() call with autoUploadDownloadFiles enabled will trigger FileToolModifier
// Any execute() with automatic file handling enabled will trigger FileToolModifier
// in the workerd runtime.
await composio.tools.createCustomTool({
slug: 'CUSTOM_FILE_TOOL',
@@ -149,24 +149,24 @@ app.get('/test/file-modifier/error-message', async c => {
});
/**
* Test: Composio initialization with autoUploadDownloadFiles disabled
*
* In workerd runtime (Cloudflare Workers), autoUploadDownloadFiles defaults to false.
* Test: Composio initialization with automatic file upload/download disabled
*
* In workerd runtime (Cloudflare Workers), dangerouslyAllowAutoUploadDownloadFiles defaults to false.
* This test verifies that the default configuration works correctly and that
* Composio can be initialized successfully without file upload/download support.
*/
app.get('/test/auto-upload-disabled', async c => {
try {
// Explicitly set autoUploadDownloadFiles: false (which is the default for workerd)
// This is the recommended configuration for edge runtimes
// Explicitly keep automatic file handling off (default everywhere)
const composio = new Composio({
apiKey: c.env.COMPOSIO_API_KEY,
autoUploadDownloadFiles: false,
dangerouslyAllowAutoUploadDownloadFiles: false,
});
return c.json({
success: true,
message: 'Composio initialized successfully with autoUploadDownloadFiles: false',
message:
'Composio initialized successfully with dangerouslyAllowAutoUploadDownloadFiles: false',
hasProvider: typeof composio.provider !== 'undefined',
hasTools: typeof composio.tools !== 'undefined',
hasFiles: typeof composio.files !== 'undefined',
@@ -189,12 +189,12 @@ app.get('/test/auto-upload-disabled', async c => {
* Test: Composio initialization with default configuration
*
* This test verifies that Composio initializes correctly with the default
* configuration for workerd runtime (autoUploadDownloadFiles: false by default).
* configuration for workerd runtime (dangerouslyAllowAutoUploadDownloadFiles: false by default).
* No explicit configuration is provided, relying on the runtime defaults.
*/
app.get('/test/default-config', async c => {
try {
// Initialize Composio without explicitly setting autoUploadDownloadFiles
// Initialize Composio without explicitly setting dangerouslyAllowAutoUploadDownloadFiles
// Should use the workerd default (false)
const composio = new Composio({
apiKey: c.env.COMPOSIO_API_KEY,
@@ -206,7 +206,7 @@ app.get('/test/default-config', async c => {
hasProvider: typeof composio.provider !== 'undefined',
hasTools: typeof composio.tools !== 'undefined',
hasFiles: typeof composio.files !== 'undefined',
note: 'In workerd runtime, autoUploadDownloadFiles defaults to false',
note: 'In workerd runtime, dangerouslyAllowAutoUploadDownloadFiles defaults to false',
});
} catch (error) {
return c.json(
@@ -75,7 +75,7 @@ describe('@composio/core Files - Cloudflare Workers compatibility', () => {
expect(body.error).toContain('not available on edge runtimes');
});
it('should successfully initialize Composio with autoUploadDownloadFiles disabled', async () => {
it('should successfully initialize Composio with automatic file handling disabled', async () => {
const request = new IncomingRequest('http://localhost/test/auto-upload-disabled');
const ctx = createExecutionContext();
const response = await app.fetch(request, env, ctx);
@@ -93,7 +93,7 @@ describe('@composio/core Files - Cloudflare Workers compatibility', () => {
};
expect(body.success).toBe(true);
expect(body.message).toContain('autoUploadDownloadFiles: false');
expect(body.message).toContain('dangerouslyAllowAutoUploadDownloadFiles: false');
expect(body.hasProvider).toBe(true);
expect(body.hasTools).toBe(true);
expect(body.hasFiles).toBe(true);
@@ -121,6 +121,6 @@ describe('@composio/core Files - Cloudflare Workers compatibility', () => {
expect(body.hasProvider).toBe(true);
expect(body.hasTools).toBe(true);
expect(body.hasFiles).toBe(true);
expect(body.note).toContain('autoUploadDownloadFiles defaults to false');
expect(body.note).toContain('dangerouslyAllowAutoUploadDownloadFiles defaults to false');
});
});
+14 -1
View File
@@ -13,10 +13,23 @@ import 'dotenv/config';
import path from 'path';
/**
* Initialize Composio
* Initialize Composio.
*
* `dangerouslyAllowAutoUploadDownloadFiles: true` enables the SDK to read local
* files from disk during tool execution. When that flag is on, local paths are
* restricted to the directories listed in `fileUploadDirs`. Passing a value
* REPLACES the default `[~/.composio/temp]` — include it if you want staged
* uploads to keep working. Pass `false` (or `[]`) to reject every local path
* while still allowing URLs and `File`/`Blob` objects.
*
* `fileDownloadDir` controls where files in tool responses (`s3url` fields) are
* written. Defaults to `~/.composio/files`.
*/
const composio = new Composio({
apiKey: process.env.COMPOSIO_API_KEY,
dangerouslyAllowAutoUploadDownloadFiles: true,
fileUploadDirs: [path.join(__dirname, '..'), '~/.composio/temp'],
fileDownloadDir: path.join(__dirname, '..', '.composio-downloads'),
});
/**
+1 -1
View File
@@ -122,7 +122,7 @@ interface ComposioConfig {
apiKey?: string | null; // Your Composio API key
baseURL?: string | null; // Custom API base URL (optional)
allowTracking?: boolean; // Enable/disable telemetry (default: true)
autoUploadDownloadFiles?: boolean; // Whether to automatically upload and download files during tool execution (default: true)
dangerouslyAllowAutoUploadDownloadFiles?: boolean; // Opt-in for automatic file upload/download during tool execution (default: false)
provider?: TProvider; // Custom provider (default: OpenAIProvider)
host?: string; // Name of the host service which is using the SDK, this is for telemetry
defaultHeaders?: ComposioRequestHeaders; // Request headers to be passed to the Composio API client
+51 -11
View File
@@ -20,7 +20,7 @@ import { ToolkitVersionParam } from './types/tool.types';
import { ToolRouter } from './models/ToolRouter';
import { ToolRouterCreateSessionConfig, Session } from './types/toolRouter.types';
import { CONFIG_DEFAULTS } from './utils/config-defaults';
import { expandHomeAndResolve, expandHomeAndResolveMany } from './utils/fileDirs';
export type ComposioConfig<
TProvider extends BaseComposioProvider<unknown, unknown, unknown> = OpenAIProvider,
> = {
@@ -41,11 +41,11 @@ export type ComposioConfig<
*/
allowTracking?: boolean;
/**
* Whether to automatically upload and download files during tool execution.
* @example true, false
* @default true
* Opt in to automatic file upload and download during tool execution (reads local paths
* and fetches URLs marked as file-uploadable in tool schemas). Disabled by default.
* @default false
*/
autoUploadDownloadFiles?: boolean;
dangerouslyAllowAutoUploadDownloadFiles?: boolean;
/**
* When true, local file paths for auto-upload and `files.upload` are checked against
* a built-in denylist of sensitive path segments (e.g. `.ssh`, `.aws`) and
@@ -59,6 +59,33 @@ export type ComposioConfig<
* they appear anywhere in the resolved path. Merged with the built-in list.
*/
fileUploadPathDenySegments?: string[];
/**
* Allowlist of directories from which the SDK is allowed to read local files
* during **automatic** file upload (when
* `dangerouslyAllowAutoUploadDownloadFiles: true`). Manual
* `composio.files.upload()` calls are NOT subject to this allowlist.
*
* - `undefined` (default) → `[<home>/.composio/temp]`.
* - `false` → reject every local path during auto-upload. URLs
* (`http(s)://...`) and `File`/`Blob` objects continue to work.
* - `string[]` (non-empty) → use as the allowlist. A file is accepted iff
* its symlink-resolved absolute path is inside one of these directories
* on a path-component boundary (so `/tmp/foo` allows `/tmp/foo/bar` but
* NOT `/tmp/foo-bar`).
* - `[]` → behaves like `false` (kept as an alias; prefer `false` for
* readability).
* - Providing any value **replaces** the default. Include `~/.composio/temp`
* in your list if you want the default staging dir to keep working.
* - On Windows, entries are compared case-insensitively.
*/
fileUploadDirs?: string[] | false;
/**
* Directory where files downloaded during tool execution (and
* `composio.files.download()`) are written. Defaults to
* `<home>/.composio/files`. Path is expanded at SDK-init time; relative
* paths resolve against `process.cwd()`.
*/
fileDownloadDir?: string;
/**
* The tool provider to use for this Composio instance.
* @example new OpenAIProvider()
@@ -264,10 +291,13 @@ export class Composio<
apiKey: apiKeyParsed,
toolkitVersions: getToolkitVersionsFromEnv(config?.toolkitVersions),
allowTracking: config?.allowTracking ?? CONFIG_DEFAULTS.allowTracking,
autoUploadDownloadFiles:
config?.autoUploadDownloadFiles ?? CONFIG_DEFAULTS.autoUploadDownloadFiles,
dangerouslyAllowAutoUploadDownloadFiles:
config?.dangerouslyAllowAutoUploadDownloadFiles ??
CONFIG_DEFAULTS.dangerouslyAllowAutoUploadDownloadFiles,
sensitiveFileUploadProtection: config?.sensitiveFileUploadProtection,
fileUploadPathDenySegments: config?.fileUploadPathDenySegments,
fileUploadDirs: expandHomeAndResolveMany(config?.fileUploadDirs),
fileDownloadDir: expandHomeAndResolve(config?.fileDownloadDir),
provider: config?.provider ?? this.provider,
};
@@ -292,6 +322,7 @@ export class Composio<
this.files = new Files(this.client, {
sensitiveFileUploadProtection: this.config.sensitiveFileUploadProtection,
fileUploadPathDenySegments: this.config.fileUploadPathDenySegments,
fileDownloadDir: this.config.fileDownloadDir,
});
this.connectedAccounts = new ConnectedAccounts(this.client);
this.toolRouter = new ToolRouter(this.client, this.config);
@@ -343,11 +374,20 @@ export class Composio<
}
/**
* Get the configuration SDK is initialized with
* @returns {ComposioConfig<TProvider>} The configuration SDK is initialized with
* Get the configuration SDK is initialized with.
*
* Returns a frozen shallow clone — the SDK has already snapshotted
* configuration values such as `dangerouslyAllowAutoUploadDownloadFiles`,
* `fileUploadDirs`, and `fileDownloadDir` into its internal models, so
* mutating the live config object would silently no-op. Freezing makes
* that contract visible at the call site instead of letting the mutation
* appear successful.
*
* @returns {Readonly<ComposioConfig<TProvider>>} The frozen configuration
* the SDK is initialized with.
*/
getConfig(): ComposioConfig<TProvider> {
return this.config;
getConfig(): Readonly<ComposioConfig<TProvider>> {
return Object.freeze({ ...this.config });
}
/**
@@ -4,6 +4,8 @@ export const FileModifierErrorCodes = {
FILE_UPLOAD_FAILED: 'FILE_UPLOAD_FAILED',
SENSITIVE_FILE_PATH_BLOCKED: 'SENSITIVE_FILE_PATH_BLOCKED',
FILE_UPLOAD_ABORTED: 'FILE_UPLOAD_ABORTED',
FILE_UPLOAD_PATH_NOT_ALLOWED: 'FILE_UPLOAD_PATH_NOT_ALLOWED',
FILE_NOT_FOUND: 'FILE_NOT_FOUND',
} as const;
export class ComposioFileUploadError extends ComposioError {
@@ -52,3 +54,47 @@ export class ComposioFileUploadAbortedError extends ComposioFileUploadError {
this.name = 'ComposioFileUploadAbortedError';
}
}
/**
* Thrown when automatic file upload during tool execution is attempted from a path
* that is outside the configured {@link ComposioConfig.fileUploadDirs} allowlist.
*
* This only fires for auto-upload (enabled via
* `dangerouslyAllowAutoUploadDownloadFiles: true`). Manual `composio.files.upload()`
* calls are never subject to this check.
*/
export class ComposioFileUploadPathNotAllowedError extends ComposioFileUploadError {
constructor(message: string, options: ComposioErrorOptions = {}) {
super(message, {
...options,
code: FileModifierErrorCodes.FILE_UPLOAD_PATH_NOT_ALLOWED,
possibleFixes: options.possibleFixes ?? [
'Move the file under one of the directories in `fileUploadDirs`',
'Stage the file under the default upload dir (`~/.composio/temp`)',
'Add the containing directory to `fileUploadDirs` when constructing Composio',
'Pass the file by URL or as a File/Blob object instead of a filesystem path',
],
});
this.name = 'ComposioFileUploadPathNotAllowedError';
}
}
/**
* Thrown when a local file path passed for upload does not exist on disk.
* Provides extra debugging context (resolved absolute path, cwd, allowlist state)
* to help the caller distinguish between "typo" and "not-allowlisted" failures.
*/
export class ComposioFileNotFoundError extends ComposioFileUploadError {
constructor(message: string, options: ComposioErrorOptions = {}) {
super(message, {
...options,
code: FileModifierErrorCodes.FILE_NOT_FOUND,
possibleFixes: options.possibleFixes ?? [
'Double-check the path for typos',
'Remember relative paths are resolved against process.cwd() at upload time',
'Verify the file exists and the process has read permission',
],
});
this.name = 'ComposioFileNotFoundError';
}
}
+16 -6
View File
@@ -11,19 +11,21 @@ import { downloadFileFromS3, getFileDataAfterUploadingToS3 } from '../utils/file
import { telemetry } from '../telemetry/Telemetry';
export class Files {
private readonly fileUploadPathOptions: {
private readonly fileOptions: {
sensitiveFileUploadProtection?: boolean;
fileUploadPathDenySegments?: string[];
fileDownloadDir?: string;
};
constructor(
private readonly client: ComposioClient,
fileUploadPathOptions: {
fileOptions: {
sensitiveFileUploadProtection?: boolean;
fileUploadPathDenySegments?: string[];
fileDownloadDir?: string;
} = {}
) {
this.fileUploadPathOptions = fileUploadPathOptions;
this.fileOptions = fileOptions;
telemetry.instrument(this, 'Files');
}
@@ -53,12 +55,15 @@ export class Files {
toolSlug: string;
toolkitSlug: string;
}): Promise<FileUploadData> {
// Note: manual `composio.files.upload()` intentionally skips the
// `fileUploadDirs` allowlist. The allowlist only gates *automatic* upload
// during tool execution (see FileToolModifier).
const fileData = await getFileDataAfterUploadingToS3(file, {
toolSlug,
toolkitSlug,
client: this.client,
sensitiveFileUploadProtection: this.fileUploadPathOptions.sensitiveFileUploadProtection,
fileUploadPathDenySegments: this.fileUploadPathOptions.fileUploadPathDenySegments,
sensitiveFileUploadProtection: this.fileOptions.sensitiveFileUploadProtection,
fileUploadPathDenySegments: this.fileOptions.fileUploadPathDenySegments,
});
return fileData;
}
@@ -77,7 +82,12 @@ export class Files {
toolSlug: string;
mimeType: string;
}): Promise<FileDownloadData> {
const fileDownloadData = await downloadFileFromS3({ toolSlug, s3Url, mimeType });
const fileDownloadData = await downloadFileFromS3({
toolSlug,
s3Url,
mimeType,
fileDownloadDir: this.fileOptions.fileDownloadDir,
});
return fileDownloadData;
}
}
+54 -11
View File
@@ -56,6 +56,8 @@ import { handleToolExecutionError } from '../errors/ToolErrors';
import { ToolExecuteMetaParams } from '../types/tool.types';
import { SessionExecuteMetaParams } from '@composio/client/resources/tool-router.mjs';
import { CONFIG_DEFAULTS } from '../utils/config-defaults';
import { resolveEffectiveUploadAllowlist } from '../utils/fileDirs';
import { schemaHasFileUploadable } from '../utils/modifiers/FileToolModifier.utils.neutral';
/**
* This class is used to manage tools in the Composio SDK.
* It provides methods to list, get, and execute tools.
@@ -73,7 +75,15 @@ export class Tools<
private fileUploadPathOptions: {
sensitiveFileUploadProtection?: boolean;
fileUploadPathDenySegments?: string[];
fileUploadAllowlist?: string[];
fileDownloadDir?: string;
};
/**
* Tracks tool slugs we've already warned about to avoid spamming the log when
* the same file-input tool is executed repeatedly while auto-upload is off.
* Scoped per-instance so a fresh `Composio` starts with a clean slate.
*/
private readonly warnedAutoUploadDisabledForTool = new Set<string>();
constructor(client: ComposioClient, config?: ComposioConfig<TProvider>) {
if (!client) {
@@ -86,12 +96,17 @@ export class Tools<
this.client = client;
this.customTools = new CustomTools(client);
this.provider = config.provider;
this.autoUploadDownloadFiles =
config?.autoUploadDownloadFiles ?? CONFIG_DEFAULTS.autoUploadDownloadFiles;
this.autoUploadDownloadFiles = config?.dangerouslyAllowAutoUploadDownloadFiles === true;
this.toolkitVersions = config?.toolkitVersions ?? CONFIG_DEFAULTS.toolkitVersions;
this.fileUploadPathOptions = {
sensitiveFileUploadProtection: config?.sensitiveFileUploadProtection,
fileUploadPathDenySegments: config?.fileUploadPathDenySegments,
// The allowlist is only enforced during automatic upload (see
// FileToolModifier). Manual `composio.files.upload()` calls don't see it.
fileUploadAllowlist: this.autoUploadDownloadFiles
? resolveEffectiveUploadAllowlist(config?.fileUploadDirs)
: undefined,
fileDownloadDir: config?.fileDownloadDir,
};
// Bind the execute method to ensure correct 'this' context
this.execute = this.execute.bind(this);
@@ -150,21 +165,29 @@ export class Tools<
}
/**
* Applies the default schema modifiers to the tools
* Applies the default schema modifiers to the tools.
*
* The `file_uploadable` transform (collapsing the backend's internal
* `{ name, mimetype, s3key }` staging shape to `{ type: 'string',
* format: 'path' }`) is **gated on `dangerouslyAllowAutoUploadDownloadFiles`**
* — the collapsed shape is a promise that the SDK will stage local paths
* on your behalf at execute time, and that's only true when the flag is on.
*
* When the flag is off, the raw schema is returned unchanged; callers are
* expected to stage files themselves via `composio.files.upload()` and pass
* the resulting descriptor into `tools.execute`. If an LLM ends up calling
* a file-uploadable tool in this mode, the execute path emits a one-shot
* warning per tool slug (see `applyBeforeExecuteModifiers`).
*
* @param tools - The tools to apply the default schema modifiers to
* @returns The tools with the default schema modifiers applied
*/
private async applyDefaultSchemaModifiers(tools: Tool[]): Promise<Tool[]> {
if (this.autoUploadDownloadFiles) {
const fileToolModifier = new FileToolModifier(this.client, this.fileUploadPathOptions);
return await Promise.all(
tools.map(tool =>
fileToolModifier.modifyToolSchema(tool.slug, tool.toolkit?.slug ?? 'unknown', tool)
)
);
} else {
if (!this.autoUploadDownloadFiles) {
return tools;
}
const fileToolModifier = new FileToolModifier(this.client, this.fileUploadPathOptions);
return await Promise.all(tools.map(tool => fileToolModifier.modifyToolSchema(tool)));
}
/**
@@ -200,6 +223,26 @@ export class Tools<
toolkitSlug,
params: modifiedParams,
});
} else if (
schemaHasFileUploadable(tool.inputParameters) &&
!this.warnedAutoUploadDisabledForTool.has(toolSlug)
) {
// With auto-upload off, the raw `{ name, mimetype, s3key }` shape is
// what the LLM / caller sees on `tool.inputParameters`. LLMs can't
// produce a valid `s3key` and will hallucinate one, which then fails
// at the staging-lookup step on the backend. Nudge the caller toward
// manual staging or opting into auto-upload.
this.warnedAutoUploadDisabledForTool.add(toolSlug);
logger.warn(
`Tool "${toolSlug}" (toolkit "${toolkitSlug}") has a file-uploadable input, but ` +
`\`dangerouslyAllowAutoUploadDownloadFiles\` is disabled. The SDK will forward ` +
`the file argument as-is; if it isn't already a staged ` +
`{ name, mimetype, s3key } descriptor, the backend will reject the call. Either:\n` +
` 1) Stage the file yourself: \`const f = await composio.files.upload({ file, toolSlug, toolkitSlug }); ` +
`await composio.tools.execute('${toolSlug}', { userId, arguments: { <fileField>: f } })\`, or\n` +
` 2) Enable auto-upload with a scoped allowlist: ` +
`\`new Composio({ dangerouslyAllowAutoUploadDownloadFiles: true, fileUploadDirs: ['/safe/dir'] })\`.`
);
}
// apply the before execute modifiers
if (modifiers?.beforeExecute) {
+8
View File
@@ -22,6 +22,14 @@ export const platform = {
return path.join(...paths);
},
resolvePath(...paths: string[]): string {
return path.resolve(...paths);
},
isAbsolutePath(filePath: string): boolean {
return path.isAbsolute(filePath);
},
basename(filePath: string): string {
return path.basename(filePath);
},
+12
View File
@@ -16,6 +16,18 @@ export interface Platform {
*/
joinPath(...paths: string[]): string;
/**
* Resolves a sequence of paths into an absolute path. On Node this is
* `path.resolve`; on edge runtimes without a working directory it
* normalizes/joins the segments and returns the result verbatim.
*/
resolvePath(...paths: string[]): string;
/**
* Returns true if the given path is absolute.
*/
isAbsolutePath(filePath: string): boolean;
/**
* Returns the last portion of a path (the filename).
* @param filePath - The path to extract the basename from.
+12
View File
@@ -26,6 +26,18 @@ export const platform: Platform = {
.join('/');
},
resolvePath(...paths: string[]): string {
// No working directory in edge runtimes. Best-effort: join the segments
// and return the result. Callers that need a real `path.resolve` should
// gate on `supportsFileSystem`.
return this.joinPath(...paths);
},
isAbsolutePath(filePath: string): boolean {
// POSIX-only check; edge runtimes don't have Windows-style drive letters.
return filePath.startsWith('/');
},
basename(filePath: string): string {
// Simple basename extraction without Node.js path module
const segments = filePath.replace(/\/+$/, '').split('/');
+21 -3
View File
@@ -180,12 +180,30 @@ export type afterExecuteModifier = (context: {
}) => Promise<ToolExecuteResponse> | ToolExecuteResponse;
/**
* Called for each `file_uploadable` value before the SDK reads the file and uploads to storage.
* For a string, `path` is the local path or URL. For a `File` object, `path` is the file's name; you may return
* a different filesystem path to upload instead, or return `false` to abort the upload. Throwing an error also aborts.
* Discriminates what kind of input the hook is seeing:
*
* - `'path'` — a local filesystem path string.
* - `'url'` — an `http(s)://...` URL string.
* - `'file'` — a `File` object; `path` holds `file.name` (a filename, NOT a
* filesystem path). Returning a string from the hook in this case replaces
* the `File` with a local-path upload.
*/
export type BeforeFileUploadSource = 'path' | 'url' | 'file';
/**
* Called for each `file_uploadable` value before the SDK reads the file and
* uploads to storage.
*
* - `context.path` holds the local path, the URL, or the `File`'s name
* depending on `context.source`.
* - Return a string to replace the input (e.g. rewrite the path, redirect a
* URL, or swap a `File` for a filesystem path).
* - Return `false` to abort the upload with `ComposioFileUploadAbortedError`.
* - Throwing an error also aborts.
*/
export type beforeFileUploadModifier = (context: {
path: string;
source: BeforeFileUploadSource;
toolSlug: string;
toolkitSlug: string;
}) => Promise<string | false> | string | false;
@@ -1,7 +1,7 @@
import type { ConfigDefaults } from './types';
export const CONFIG_DEFAULTS: ConfigDefaults = {
autoUploadDownloadFiles: true,
dangerouslyAllowAutoUploadDownloadFiles: false,
allowTracking: true,
toolkitVersions: 'latest',
};
@@ -4,7 +4,7 @@ export const CONFIG_DEFAULTS: ConfigDefaults = {
/**
* We don't support auto-uploading/download files in Cloudflare Workers yet.
*/
autoUploadDownloadFiles: false,
dangerouslyAllowAutoUploadDownloadFiles: false,
allowTracking: true,
toolkitVersions: 'latest',
};
@@ -9,6 +9,6 @@ import type { BaseComposioProvider } from '../../provider/BaseProvider';
export type ConfigDefaults = Required<
Pick<
ComposioConfig<BaseComposioProvider<never, never, never>>,
'autoUploadDownloadFiles' | 'allowTracking' | 'toolkitVersions'
'dangerouslyAllowAutoUploadDownloadFiles' | 'allowTracking' | 'toolkitVersions'
>
>;
+81
View File
@@ -0,0 +1,81 @@
/**
* Runtime-neutral helpers for normalizing user-provided filesystem paths.
*
* In filesystem-less runtimes (Cloudflare Workers, etc.) the platform
* abstraction reports `supportsFileSystem: false` and `homedir()` returns
* `null`. We use that as the signal to drop unresolvable entries from the
* allowlist (fail-closed) rather than silently passing `~/foo` through as
* a literal directory name.
*/
import { platform } from '#platform';
/**
* Expands a leading `~` to the user's home directory and resolves to an
* absolute path. Returns `undefined` when expansion is required but no
* home directory is available — that signals "unusable, drop me" to
* {@link expandHomeAndResolveMany}, which is safer than letting the literal
* `~/foo` survive as a CWD-relative path.
*/
export function expandHomeAndResolve(p: string | undefined): string | undefined {
if (!p) return p;
let expanded = p;
if (p === '~' || p.startsWith('~/') || p.startsWith('~\\')) {
const home = platform.homedir();
if (!home) return undefined;
expanded = p === '~' ? home : platform.joinPath(home, p.slice(2));
}
return platform.resolvePath(expanded);
}
/**
* Applies {@link expandHomeAndResolve} to each entry, dropping empty/invalid
* values. Preserves `undefined` and `false` (so downstream can distinguish
* "user did not configure" from "user explicitly disabled local uploads"
* from "user configured []").
*/
export function expandHomeAndResolveMany(
list: string[] | false | undefined
): string[] | false | undefined {
if (list === undefined) return undefined;
if (list === false) return false;
const out: string[] = [];
for (const raw of list) {
if (typeof raw !== 'string' || raw.trim() === '') continue;
const resolved = expandHomeAndResolve(raw);
if (resolved) out.push(resolved);
}
return out;
}
/**
* Absolute path of the default upload staging directory
* (`<home>/.composio/temp`), or `null` when a home directory is unavailable
* (e.g. Cloudflare Workers).
*/
export function getDefaultUploadDir(): string | null {
const home = platform.homedir();
if (!home) return null;
return platform.resolvePath(platform.joinPath(home, '.composio', 'temp'));
}
/**
* Resolve the effective upload allowlist for a given user-provided value.
*
* - `undefined` → `[<default>]` when a home directory is available, else `[]`
* (fail-closed if we can't derive the default staging dir).
* - `false` → `[]` (explicit "reject all local paths"). URLs / File objects
* still work because they aren't path-checked.
* - `string[]` (including `[]`) replaces the default and is returned
* verbatim after `~` expansion / `path.resolve`. `[]` is treated as
* equivalent to `false`.
*/
export function resolveEffectiveUploadAllowlist(userDirs: string[] | false | undefined): string[] {
if (userDirs === undefined) {
const def = getDefaultUploadDir();
return def ? [def] : [];
}
if (userDirs === false) return [];
const resolved = expandHomeAndResolveMany(userDirs);
return Array.isArray(resolved) ? resolved : [];
}
+65 -14
View File
@@ -8,6 +8,7 @@ import { getRandomShortId } from './uuid';
import { base64ToUint8Array, uint8ArrayToBase64 } from './buffer';
import type { FileDownloadData, FileUploadData } from '../types/files.types';
import { assertSafeFileUploadPath } from './sensitiveFileUploadPaths.node';
import { assertPathInsideUploadDirs } from './uploadDirAllowlist.node';
/**
* Options for {@link getFileDataAfterUploadingToS3} (S3 presigned upload from local path, URL, or File).
@@ -26,6 +27,15 @@ export type GetFileDataAfterUploadingToS3Options = {
* merged with the built-in list.
*/
fileUploadPathDenySegments?: string[];
/**
* When provided, the local path must resolve inside one of these directories
* (after symlink resolution, on a component boundary) or upload is rejected.
* Intended for automatic upload during tool execution; manual
* `composio.files.upload()` calls pass `undefined` here to skip enforcement.
*
* An empty array means "no paths are allowed" (fail-closed).
*/
fileUploadAllowlist?: string[];
};
// Helper function to get file extension from MIME type
@@ -244,18 +254,23 @@ export const getFileDataAfterUploadingToS3 = async (
client,
sensitiveFileUploadProtection,
fileUploadPathDenySegments,
fileUploadAllowlist,
}: GetFileDataAfterUploadingToS3Options
): Promise<FileUploadData> => {
if (!file) {
throw new Error('Either path or blob must be provided');
}
if (
sensitiveFileUploadProtection !== false &&
typeof file === 'string' &&
!file.startsWith('http')
) {
assertSafeFileUploadPath(file, { additionalDenySegments: fileUploadPathDenySegments });
const isLocalPath = typeof file === 'string' && !file.startsWith('http');
if (isLocalPath && fileUploadAllowlist !== undefined) {
assertPathInsideUploadDirs(file as string, fileUploadAllowlist);
}
if (sensitiveFileUploadProtection !== false && isLocalPath) {
assertSafeFileUploadPath(file as string, {
additionalDenySegments: fileUploadPathDenySegments,
});
}
const fileData = await readFile(file);
@@ -281,10 +296,16 @@ export const downloadFileFromS3 = async ({
toolSlug,
s3Url,
mimeType,
fileDownloadDir,
}: {
toolSlug: string;
s3Url: string;
mimeType: string;
/**
* Absolute path to the directory to save the file into. When omitted, falls
* back to `<home>/.composio/files` (the legacy default).
*/
fileDownloadDir?: string;
}): Promise<FileDownloadData> => {
const response = await fetch(s3Url);
if (!response.ok) {
@@ -294,7 +315,10 @@ export const downloadFileFromS3 = async ({
const extension = getExtensionFromMimeType(mimeType);
const fileName = generateTimestampedFilename(extension, `${toolSlug}_`);
const filePath = saveFile(fileName, new Uint8Array(data), true);
const filePath = saveFile(fileName, new Uint8Array(data), {
isTempFile: fileDownloadDir === undefined,
outputDir: fileDownloadDir,
});
return {
name: fileName,
mimeType: mimeType,
@@ -354,23 +378,50 @@ export const getComposioTempFilesDir = (createDirIfNotExists: boolean = false) =
};
/**
* Saves a file to the Composio directory.
* @param file - The name of the file to save.
* @param content - The content of the file to save. Can be a string or Uint8Array.
* @param isTempFile - Whether the file is a temporary file.
* @returns The path to the saved file.
* Saves a file to a local directory.
*
* Directory precedence (highest first):
* 1. `options.outputDir` (explicit, absolute or relative; created if missing)
* 2. `options.isTempFile === true` → `<home>/.composio/files`
* 3. fallback → `<home>/.composio`
*
* @returns The absolute path to the saved file, or `null` if the runtime has
* no filesystem (e.g. Cloudflare Workers) or the save failed.
*/
export const saveFile = (
file: string,
content: string | Uint8Array,
isTempFile: boolean = false
options: { isTempFile?: boolean; outputDir?: string } | boolean = {}
) => {
// Back-compat: legacy callers passed a boolean `isTempFile` positional.
const opts: { isTempFile?: boolean; outputDir?: string } =
typeof options === 'boolean' ? { isTempFile: options } : options;
try {
if (!platform.supportsFileSystem) {
logger.debug('File system operations are not supported in this runtime environment');
return null;
}
const composioFilesDir = isTempFile ? getComposioTempFilesDir(true) : getComposioDir(true);
let composioFilesDir: string | null | undefined;
if (opts.outputDir) {
const absDir = platform.joinPath(opts.outputDir); // platform.joinPath normalizes
try {
if (!platform.existsSync(absDir)) {
platform.mkdirSync(absDir);
}
composioFilesDir = absDir;
} catch (err) {
logger.warn(
`fileDownloadDir "${absDir}" is not writable, falling back to the default ` +
`(~/.composio/files). Error: ${err}`
);
composioFilesDir = getComposioTempFilesDir(true);
}
} else {
composioFilesDir = opts.isTempFile ? getComposioTempFilesDir(true) : getComposioDir(true);
}
if (!composioFilesDir) {
return null;
}
@@ -7,8 +7,10 @@ import {
import ComposioClient from '@composio/client';
import logger from '../logger';
import {
ComposioFileNotFoundError,
ComposioFileUploadAbortedError,
ComposioFileUploadError,
ComposioFileUploadPathNotAllowedError,
ComposioSensitiveFilePathBlockedError,
} from '../../errors/FileModifierErrors';
import type { beforeFileUploadModifier } from '../../types/modifiers.types';
@@ -39,7 +41,7 @@ const hydrateFiles = async (
client: ComposioClient;
} & Pick<
GetFileDataAfterUploadingToS3Options,
'sensitiveFileUploadProtection' | 'fileUploadPathDenySegments'
'sensitiveFileUploadProtection' | 'fileUploadPathDenySegments' | 'fileUploadAllowlist'
> & {
beforeFileUpload?: beforeFileUploadModifier;
}
@@ -51,12 +53,16 @@ const hydrateFiles = async (
// Upload only if the runtime value is a string (i.e., a local path) or blob
if (typeof value !== 'string' && !(value instanceof File)) return value;
const runBeforeFileUpload = async (path: string): Promise<string> => {
const runBeforeFileUpload = async (
path: string,
source: 'path' | 'url' | 'file'
): Promise<string> => {
if (!ctx.beforeFileUpload) {
return path;
}
const out = await ctx.beforeFileUpload({
path,
source,
toolSlug: ctx.toolSlug,
toolkitSlug: ctx.toolkitSlug,
});
@@ -69,7 +75,10 @@ const hydrateFiles = async (
};
if (typeof value === 'string') {
const pathOrUrl = await runBeforeFileUpload(value);
// Match the URL/local-path split used downstream in
// getFileDataAfterUploadingToS3 so the hook sees the same categorisation.
const source = value.startsWith('http') ? 'url' : 'path';
const pathOrUrl = await runBeforeFileUpload(value, source);
logger.debug(`Uploading file "${pathOrUrl}"`);
return getFileDataAfterUploadingToS3(pathOrUrl, {
toolSlug: ctx.toolSlug,
@@ -77,13 +86,16 @@ const hydrateFiles = async (
client: ctx.client,
sensitiveFileUploadProtection: ctx.sensitiveFileUploadProtection,
fileUploadPathDenySegments: ctx.fileUploadPathDenySegments,
fileUploadAllowlist: ctx.fileUploadAllowlist,
});
}
// File
// File — `path` is the filename only; a string return replaces it with a
// local-path upload.
if (ctx.beforeFileUpload) {
const out = await ctx.beforeFileUpload({
path: value.name,
source: 'file',
toolSlug: ctx.toolSlug,
toolkitSlug: ctx.toolkitSlug,
});
@@ -100,10 +112,12 @@ const hydrateFiles = async (
client: ctx.client,
sensitiveFileUploadProtection: ctx.sensitiveFileUploadProtection,
fileUploadPathDenySegments: ctx.fileUploadPathDenySegments,
fileUploadAllowlist: ctx.fileUploadAllowlist,
});
}
}
logger.debug(`Uploading file "${value.name}"`);
// File/Blob values are not subject to the upload-dir allowlist.
return getFileDataAfterUploadingToS3(value, {
toolSlug: ctx.toolSlug,
toolkitSlug: ctx.toolkitSlug,
@@ -114,7 +128,14 @@ const hydrateFiles = async (
}
// ──────────────────────────────────────────────────────────────────────────
// 2. Handle anyOf/oneOf/allOf - try each variant that may contain file_uploadable
// 2. Handle anyOf/oneOf/allOf — pick the first variant that contains a
// file_uploadable property and hydrate against it.
//
// We deliberately do NOT loop over every uploadable variant. With `oneOf`,
// exactly one variant should apply at runtime, and applying multiple would
// upload the same file once per variant — two presigned-URL round-trips and
// two S3 PUTs for a two-variant `oneOf`. Matching the Python SDK
// (`_find_uploadable_schema_variant`), we short-circuit on the first match.
// ──────────────────────────────────────────────────────────────────────────
const schemaVariants = [
...(schema?.anyOf ?? []),
@@ -123,16 +144,9 @@ const hydrateFiles = async (
];
if (schemaVariants.length > 0) {
// Find variants that have file_uploadable properties
const uploadableVariants = schemaVariants.filter(schemaHasFileUploadable);
if (uploadableVariants.length > 0) {
// Process with each uploadable variant - we try all since we can't know which one matches at runtime
let result = value;
for (const variant of uploadableVariants) {
result = await hydrateFiles(result, variant, ctx);
}
return result;
const firstUploadableVariant = schemaVariants.find(schemaHasFileUploadable);
if (firstUploadableVariant) {
return hydrateFiles(value, firstUploadableVariant, ctx);
}
// If no uploadable variants found, fall through to check base properties
}
@@ -172,7 +186,7 @@ const hydrateFiles = async (
*/
const downloadS3File = async (
value: Record<string, unknown>,
ctx: { toolSlug: string }
ctx: { toolSlug: string; fileDownloadDir?: string }
): Promise<unknown> => {
const { s3url, mimetype } = value as {
s3url: string;
@@ -186,6 +200,7 @@ const downloadS3File = async (
toolSlug: ctx.toolSlug,
s3Url: s3url,
mimeType: mimetype ?? 'application/octet-stream',
fileDownloadDir: ctx.fileDownloadDir,
});
logger.debug(`Downloaded → ${dl.filePath}`);
@@ -223,7 +238,7 @@ const downloadS3File = async (
const hydrateDownloads = async (
value: unknown,
schema: JSONSchemaProperty | undefined,
ctx: { toolSlug: string }
ctx: { toolSlug: string; fileDownloadDir?: string }
): Promise<unknown> => {
// ──────────────────────────────────────────────────────────────────────────
// 1. Direct S3 reference (data-driven detection)
@@ -304,8 +319,8 @@ export class FileToolModifier {
private client: ComposioClient;
private fileUploadPathOptions: Pick<
GetFileDataAfterUploadingToS3Options,
'sensitiveFileUploadProtection' | 'fileUploadPathDenySegments'
> & { beforeFileUpload?: beforeFileUploadModifier };
'sensitiveFileUploadProtection' | 'fileUploadPathDenySegments' | 'fileUploadAllowlist'
> & { beforeFileUpload?: beforeFileUploadModifier; fileDownloadDir?: string };
constructor(
client: ComposioClient,
@@ -315,7 +330,7 @@ export class FileToolModifier {
this.fileUploadPathOptions = fileUploadPathOptions;
}
async modifyToolSchema(toolSlug: string, toolkitSlug: string, schema: Tool): Promise<Tool> {
async modifyToolSchema(schema: Tool): Promise<Tool> {
if (!schema.inputParameters?.properties) {
return schema;
}
@@ -356,7 +371,9 @@ export class FileToolModifier {
} catch (error) {
if (
error instanceof ComposioSensitiveFilePathBlockedError ||
error instanceof ComposioFileUploadAbortedError
error instanceof ComposioFileUploadAbortedError ||
error instanceof ComposioFileUploadPathNotAllowedError ||
error instanceof ComposioFileNotFoundError
) {
throw error;
}
@@ -379,6 +396,7 @@ export class FileToolModifier {
// Walk result.data without mutating the original, using output schema for guidance
const dataWithDownloads = await hydrateDownloads(result.data, tool.outputParameters, {
toolSlug,
fileDownloadDir: this.fileUploadPathOptions.fileDownloadDir,
});
return { ...result, data: dataWithDownloads as typeof result.data };
@@ -4,12 +4,12 @@ import { transformProperties } from './FileToolModifier.utils.neutral';
const UNSUPPORTED_MESSAGE =
'File upload/download modifiers are not available on edge runtimes yet. ' +
'Please set `autoUploadDownloadFiles: false` or run Composio in another JS runtime (Node.js / Bun).';
'Please set `dangerouslyAllowAutoUploadDownloadFiles: false` (or unset it; it defaults to false) or run Composio in another JS runtime (Node.js / Bun).';
export class FileToolModifier {
constructor(_client: ComposioClient) {}
async modifyToolSchema(toolSlug: string, toolkitSlug: string, schema: Tool): Promise<Tool> {
async modifyToolSchema(schema: Tool): Promise<Tool> {
if (!schema.inputParameters?.properties) {
return schema;
}
@@ -0,0 +1,178 @@
/**
* Allowlist for automatic file upload during tool execution.
*
* This is only consulted when `dangerouslyAllowAutoUploadDownloadFiles` is enabled.
* Manual `composio.files.upload()` calls are not subject to the allowlist.
*
* - A local path is accepted iff its real (symlink-resolved, absolute) path is
* inside one of the allowed directories on a path-component boundary.
* `/tmp/foo` allows `/tmp/foo/bar` but NOT `/tmp/foo-bar`.
* - URLs (`http(s)://…`) and `File`/`Blob` values never hit this check.
* - User-provided `fileUploadDirs` fully replaces the default `[~/.composio/temp]`.
* - On Windows, path comparisons are case-insensitive.
*/
import * as path from 'node:path';
import fs from 'node:fs';
import {
ComposioFileUploadPathNotAllowedError,
ComposioFileNotFoundError,
} from '../errors/FileModifierErrors';
/**
* Ensures the given directory exists (best-effort, non-throwing). Safe to call
* during SDK init; allowlist enforcement still works even if creation fails.
*/
export function ensureDirExists(dir: string): void {
try {
fs.mkdirSync(dir, { recursive: true });
} catch {
// ignore
}
}
function normalizeForCompare(p: string): string {
return process.platform === 'win32' ? p.toLowerCase() : p;
}
/**
* True if `child` is equal to `parent` or nested inside it on a component
* boundary. Assumes both paths are absolute and normalized.
*/
function isInsideDir(child: string, parent: string): boolean {
const a = normalizeForCompare(child);
const b = normalizeForCompare(parent);
if (a === b) return true;
const sep = path.sep;
const bWithSep = b.endsWith(sep) ? b : b + sep;
return a.startsWith(bWithSep);
}
/**
* Returns the real (symlink-resolved) absolute path when the target exists,
* else `null`. Mirrors the semantics of the sensitive-path util.
*/
function tryRealpath(absPath: string): string | null {
try {
if (fs.existsSync(absPath)) {
return fs.realpathSync(absPath);
}
} catch {
// ignore and fall through
}
return null;
}
function formatAllowlist(dirs: string[]): string {
if (dirs.length === 0) return ' (none configured \u2014 all local paths are blocked)';
return dirs.map(d => ` - ${d}`).join('\n');
}
function buildHelpFooter(allowlist: string[]): string {
return [
'',
'Allowed upload directories (fileUploadDirs):',
formatAllowlist(allowlist),
'',
'Fix one of the following:',
'',
' 1. Recommended \u2014 move the file under an allowed directory, or add your',
' directory to the allowlist:',
' new Composio({',
" fileUploadDirs: ['/abs/path/to/uploads', '~/.composio/temp'],",
' })',
' Note: user-provided `fileUploadDirs` REPLACES the default',
' `~/.composio/temp`. Include it explicitly if you want staged uploads',
' to keep working.',
'',
' 2. Pass the file by URL or as a File/Blob object instead of a filesystem',
' path. URLs and File objects are never path-checked.',
'',
' 3. (Dangerous) Bypass the allowlist entirely by opting into automatic',
' file upload/download from any readable path:',
' new Composio({ dangerouslyAllowAutoUploadDownloadFiles: true })',
' WARNING: This lets a prompt-injected or misbehaving tool read ANY',
' file the process can access. Only enable it in trusted, sandboxed',
' environments. The built-in sensitive-path denylist (.ssh, .aws,',
' .env, private SSH keys, etc.) still applies unless you also set',
' `sensitiveFileUploadProtection: false`.',
].join('\n');
}
/**
* @throws {ComposioFileNotFoundError} when `filePath` does not exist on disk.
* @throws {ComposioFileUploadPathNotAllowedError} when the resolved path is
* outside every entry of `allowlist`.
*/
export function assertPathInsideUploadDirs(filePath: string, allowlist: string[]): void {
const attempted = filePath;
const abs = path.resolve(filePath);
const real = tryRealpath(abs);
if (!real) {
const cwd = process.cwd();
const parent = path.dirname(abs);
const parentExists = (() => {
try {
return fs.existsSync(parent);
} catch {
return false;
}
})();
throw new ComposioFileNotFoundError(
[
`Refusing to auto-upload "${attempted}": the file does not exist on disk.`,
'',
`Path attempted: ${attempted}`,
`Resolved to: ${abs}`,
`Process cwd: ${cwd}`,
`Parent exists: ${parentExists ? 'yes (' + parent + ')' : 'no (' + parent + ')'}`,
'',
'Common causes:',
' - Typo in the filename passed to the tool.',
' - Relative path resolved against the wrong working directory',
' (relative paths use process.cwd() at the moment of upload).',
' - File was deleted between the tool being called and the upload starting.',
'',
buildHelpFooter(allowlist),
].join('\n'),
{ meta: { attempted, resolved: abs, cwd, allowlist } }
);
}
if (allowlist.length === 0) {
throw new ComposioFileUploadPathNotAllowedError(
[
`Refusing to auto-upload "${attempted}": no upload directories are configured.`,
'',
`Path attempted: ${attempted}`,
`Resolved to: ${real}`,
'',
'Automatic file upload during tool execution is locked down by default',
'to prevent a prompt-injected tool from exfiltrating server files',
'(source code, .env, SSH keys, etc.).',
buildHelpFooter(allowlist),
].join('\n'),
{ meta: { attempted, resolved: real, allowlist } }
);
}
for (const dir of allowlist) {
const realDir = tryRealpath(dir) ?? path.resolve(dir);
if (isInsideDir(real, realDir)) {
return;
}
}
throw new ComposioFileUploadPathNotAllowedError(
[
`Refusing to auto-upload "${attempted}": resolved path is not inside any`,
'directory in the configured `fileUploadDirs` allowlist.',
'',
`Path attempted: ${attempted}`,
`Resolved to: ${real}`,
buildHelpFooter(allowlist),
].join('\n'),
{ meta: { attempted, resolved: real, allowlist } }
);
}
+167 -10
View File
@@ -41,7 +41,7 @@ describe('FileToolModifier', () => {
availableVersions: ['20251201_01'],
};
const result = await fileToolModifier.modifyToolSchema('test-tool', 'test-toolkit', schema);
const result = await fileToolModifier.modifyToolSchema(schema);
expect(result).toEqual(schema);
});
@@ -67,7 +67,7 @@ describe('FileToolModifier', () => {
},
};
const result = await fileToolModifier.modifyToolSchema('test-tool', 'test-toolkit', schema);
const result = await fileToolModifier.modifyToolSchema(schema);
expect(result.inputParameters?.properties?.file).toHaveProperty('format', 'path');
expect(result.inputParameters?.properties?.text).not.toHaveProperty('format');
});
@@ -102,7 +102,7 @@ describe('FileToolModifier', () => {
},
};
const result = await fileToolModifier.modifyToolSchema('test-tool', 'test-toolkit', schema);
const result = await fileToolModifier.modifyToolSchema(schema);
expect(result.inputParameters?.properties?.fileInput?.anyOf?.[0]).toHaveProperty(
'format',
'path'
@@ -145,7 +145,7 @@ describe('FileToolModifier', () => {
},
};
const result = await fileToolModifier.modifyToolSchema('test-tool', 'test-toolkit', schema);
const result = await fileToolModifier.modifyToolSchema(schema);
expect(result.inputParameters?.properties?.fileInput?.oneOf?.[0]).toHaveProperty(
'format',
'path'
@@ -185,7 +185,7 @@ describe('FileToolModifier', () => {
},
};
const result = await fileToolModifier.modifyToolSchema('test-tool', 'test-toolkit', schema);
const result = await fileToolModifier.modifyToolSchema(schema);
expect(result.inputParameters?.properties?.fileInput?.allOf?.[0]).toHaveProperty(
'format',
'path'
@@ -230,7 +230,7 @@ describe('FileToolModifier', () => {
},
};
const result = await fileToolModifier.modifyToolSchema('test-tool', 'test-toolkit', schema);
const result = await fileToolModifier.modifyToolSchema(schema);
expect(
result.inputParameters?.properties?.content?.anyOf?.[0]?.properties?.attachment
).toHaveProperty('format', 'path');
@@ -268,7 +268,7 @@ describe('FileToolModifier', () => {
},
};
const result = await fileToolModifier.modifyToolSchema('test-tool', 'test-toolkit', schema);
const result = await fileToolModifier.modifyToolSchema(schema);
expect(result.inputParameters?.properties?.files?.items?.anyOf?.[0]).toHaveProperty(
'format',
'path'
@@ -371,6 +371,80 @@ describe('FileToolModifier', () => {
expect(result.arguments?.file).toEqual(mockFileData);
});
it('should pass source="path" to beforeFileUpload for local filesystem paths', async () => {
const mockFileData = {
name: 'file.txt',
mimetype: 'text/plain',
s3key: 'uploads/file.txt',
};
vi.mocked(fileUtils.getFileDataAfterUploadingToS3).mockResolvedValue(mockFileData);
const hook = vi.fn(async ({ path }) => path);
const withHook = new FileToolModifier(mockClient, { beforeFileUpload: hook });
await withHook.fileUploadModifier(mockTool, {
toolSlug: 'test-tool',
toolkitSlug: 'test-toolkit',
params: { arguments: { file: '/path/to/a.txt' }, userId: 'u' },
});
expect(hook).toHaveBeenCalledWith(
expect.objectContaining({ path: '/path/to/a.txt', source: 'path' })
);
});
it('should pass source="url" to beforeFileUpload for http(s) URLs', async () => {
const mockFileData = {
name: 'file.txt',
mimetype: 'text/plain',
s3key: 'uploads/file.txt',
};
vi.mocked(fileUtils.getFileDataAfterUploadingToS3).mockResolvedValue(mockFileData);
const hook = vi.fn(async ({ path }) => path);
const withHook = new FileToolModifier(mockClient, { beforeFileUpload: hook });
await withHook.fileUploadModifier(mockTool, {
toolSlug: 'test-tool',
toolkitSlug: 'test-toolkit',
params: {
arguments: { file: 'https://example.com/report.pdf' },
userId: 'u',
},
});
expect(hook).toHaveBeenCalledWith(
expect.objectContaining({
path: 'https://example.com/report.pdf',
source: 'url',
})
);
});
it('should pass source="file" to beforeFileUpload for File objects', async () => {
const mockFileData = {
name: 'file.txt',
mimetype: 'text/plain',
s3key: 'uploads/file.txt',
};
vi.mocked(fileUtils.getFileDataAfterUploadingToS3).mockResolvedValue(mockFileData);
const hook = vi.fn(async () => undefined as unknown as string);
const withHook = new FileToolModifier(mockClient, { beforeFileUpload: hook });
const fileObject = new File(['x'], 'uploaded.txt', { type: 'text/plain' });
await withHook.fileUploadModifier(mockTool, {
toolSlug: 'test-tool',
toolkitSlug: 'test-toolkit',
params: { arguments: { file: fileObject }, userId: 'u' },
});
// `path` is the File's name, not a filesystem path.
expect(hook).toHaveBeenCalledWith(
expect.objectContaining({ path: 'uploaded.txt', source: 'file' })
);
});
it('should throw ComposioFileUploadAbortedError when beforeFileUpload returns false', async () => {
const withHook = new FileToolModifier(mockClient, {
beforeFileUpload: async () => false,
@@ -668,6 +742,54 @@ describe('FileToolModifier', () => {
);
});
it('uploads once when multiple oneOf variants are file_uploadable', async () => {
// Regression: previously hydrateFiles iterated over every uploadable
// variant in anyOf/oneOf/allOf and re-uploaded the same file once per
// variant. With `oneOf` semantics this is doubly wrong (only one
// variant matches at runtime). Match the Python SDK and short-circuit
// on the first uploadable variant.
const mockFileData = {
name: 'file.txt',
mimetype: 'text/plain',
s3key: 'uploads/file.txt',
};
vi.mocked(fileUtils.getFileDataAfterUploadingToS3).mockResolvedValue(mockFileData);
const toolWithTwoUploadableVariants: Tool = {
slug: 'test-tool',
name: 'Test Tool',
description: 'A test tool',
tags: ['test'],
inputParameters: {
type: 'object',
properties: {
fileInput: {
oneOf: [
{ type: 'string', file_uploadable: true },
{ type: 'string', file_uploadable: true },
],
},
},
},
version: '20251201_01',
availableVersions: ['20251201_01'],
};
const params = {
arguments: { fileInput: '/path/to/file.txt' },
userId: 'test-user',
};
const result = await fileToolModifier.fileUploadModifier(toolWithTwoUploadableVariants, {
toolSlug: 'test-tool',
toolkitSlug: 'test-toolkit',
params,
});
expect(fileUtils.getFileDataAfterUploadingToS3).toHaveBeenCalledTimes(1);
expect(result.arguments?.fileInput).toEqual(mockFileData);
});
it('should not upload when value is null for anyOf with null variant', async () => {
const toolWithAnyOf: Tool = {
slug: 'test-tool',
@@ -1326,7 +1448,7 @@ describe('FileToolModifier', () => {
});
});
describe('Tools with autoUploadDownloadFiles', () => {
describe('Tools with dangerouslyAllowAutoUploadDownloadFiles', () => {
const context = createTestContext();
setupTest(context);
@@ -1410,11 +1532,11 @@ describe('Tools with autoUploadDownloadFiles', () => {
availableVersions: ['20251201_01'],
};
describe('when autoUploadDownloadFiles is false', () => {
describe('when dangerouslyAllowAutoUploadDownloadFiles is false', () => {
beforeEach(async () => {
context.tools = new Tools(mockClient as unknown as ComposioClient, {
provider: context.mockProvider,
autoUploadDownloadFiles: false,
dangerouslyAllowAutoUploadDownloadFiles: false,
});
// Mock the tool execution
@@ -1447,6 +1569,12 @@ describe('Tools with autoUploadDownloadFiles', () => {
});
it('should not modify tool schema for file upload', async () => {
// The collapsed `{ type: 'string', format: 'path' }` shape is a promise
// that the SDK will stage local paths for the caller. That promise is
// only true when auto-upload is on, so we leave the raw shape
// untouched when the flag is off — callers are expected to pre-stage
// via `composio.files.upload()`. Runtime behavior is documented by
// the warning emitted from `applyBeforeExecuteModifiers`.
const result = await context.tools.getRawComposioTools({ tools: ['COMPOSIO_TOOL'] });
expect(result[0].inputParameters?.properties?.file).not.toHaveProperty('format');
expect(fileUtils.getFileDataAfterUploadingToS3).not.toHaveBeenCalled();
@@ -1481,6 +1609,35 @@ describe('Tools with autoUploadDownloadFiles', () => {
});
});
it('warns once per tool when auto-upload is off and the tool has a file-uploadable input', async () => {
const warnSpy = vi.spyOn((await import('../../src/utils/logger')).default, 'warn');
warnSpy.mockImplementation(() => {}); // silence the log in test output
vi.spyOn(context.tools, 'getRawComposioToolBySlug').mockResolvedValue(mockToolWithFileUpload);
// First call emits the warning.
await context.tools.execute('COMPOSIO_TOOL', {
arguments: { file: '/path/to/file.txt' },
userId: 'u',
dangerouslySkipVersionCheck: true,
});
expect(warnSpy).toHaveBeenCalledTimes(1);
const msg = String(warnSpy.mock.calls[0]?.[0] ?? '');
expect(msg).toContain('COMPOSIO_TOOL');
expect(msg).toContain('dangerouslyAllowAutoUploadDownloadFiles');
expect(msg).toContain('composio.files.upload');
// Repeated executions of the same tool do NOT re-warn.
await context.tools.execute('COMPOSIO_TOOL', {
arguments: { file: '/path/to/file.txt' },
userId: 'u',
dangerouslySkipVersionCheck: true,
});
expect(warnSpy).toHaveBeenCalledTimes(1);
warnSpy.mockRestore();
});
it('should not download files from execution results', async () => {
// Mock getRawComposioToolBySlug for this test
vi.spyOn(context.tools, 'getRawComposioToolBySlug').mockResolvedValueOnce(
@@ -0,0 +1,281 @@
/**
* Behavior matrix for the file upload / download gating introduced with
* `fileUploadDirs` and `fileDownloadDir`. Exercises the interaction between:
*
* - `dangerouslyAllowAutoUploadDownloadFiles` (off | on)
* - Caller (automatic via `tools.execute` | manual via `composio.files.*`)
* - Direction (upload | download)
* - `fileUploadDirs` shape (`undefined`, `false`, `[]`, `string[]`)
*
* Runtime filesystem operations and HTTP are mocked; we only assert what the
* SDK tries to do and what errors it raises.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as os from 'node:os';
import * as path from 'node:path';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import ComposioClient from '@composio/client';
import { Tools } from '../../src/models/Tools';
import { Files } from '../../src/models/Files.node';
import * as fileUtilsModule from '../../src/utils/fileUtils.node';
import { mockClient } from '../utils/mocks/client.mock';
import { MockProvider } from '../utils/mocks/provider.mock';
import {
ComposioFileUploadPathNotAllowedError,
ComposioFileNotFoundError,
} from '../../src/errors/FileModifierErrors';
import { getDefaultUploadDir } from '../../src/utils/fileDirs';
// Mock network-bound helpers so we can assert inputs without hitting the API.
vi.mock('../../src/utils/fileUtils.node', async importOriginal => {
const actual = await importOriginal<typeof import('../../src/utils/fileUtils.node')>();
return {
...actual,
downloadFileFromS3: vi.fn(),
// Keep the real `getFileDataAfterUploadingToS3` so path checks run, but
// stub the S3 upload happening inside it.
uploadFileToS3: vi.fn(),
};
});
const mkTemp = () => mkdtempSync(path.join(os.tmpdir(), 'composio-matrix-'));
const makeTools = (opts: {
dangerouslyAllowAutoUploadDownloadFiles?: boolean;
fileUploadDirs?: string[] | false;
fileDownloadDir?: string;
}) => {
return new Tools(mockClient as unknown as ComposioClient, {
provider: new MockProvider(),
...opts,
});
};
describe('File handling matrix — Tools constructor resolves allowlist correctly', () => {
afterEach(() => vi.clearAllMocks());
describe('dangerouslyAllowAutoUploadDownloadFiles = false (default)', () => {
it('leaves fileUploadAllowlist undefined (auto-upload code path is never run)', () => {
const t = makeTools({});
// `fileUploadAllowlist` lives on the private options the class forwards
// to FileToolModifier. When the flag is off we leave it undefined so the
// modifier isn't even constructed.
expect(
(t as unknown as { fileUploadPathOptions: { fileUploadAllowlist?: string[] } })
.fileUploadPathOptions.fileUploadAllowlist
).toBeUndefined();
});
it('ignores fileUploadDirs entirely (including `false`)', () => {
const t = makeTools({ fileUploadDirs: false });
expect(
(t as unknown as { fileUploadPathOptions: { fileUploadAllowlist?: string[] } })
.fileUploadPathOptions.fileUploadAllowlist
).toBeUndefined();
});
});
describe('dangerouslyAllowAutoUploadDownloadFiles = true', () => {
it('defaults fileUploadAllowlist to [~/.composio/temp] when fileUploadDirs is unset', () => {
const t = makeTools({ dangerouslyAllowAutoUploadDownloadFiles: true });
const opts = (t as unknown as { fileUploadPathOptions: { fileUploadAllowlist?: string[] } })
.fileUploadPathOptions;
expect(opts.fileUploadAllowlist).toEqual([getDefaultUploadDir()]);
});
it('resolves to [] when fileUploadDirs is `false` (explicit "no local paths")', () => {
const t = makeTools({
dangerouslyAllowAutoUploadDownloadFiles: true,
fileUploadDirs: false,
});
const opts = (t as unknown as { fileUploadPathOptions: { fileUploadAllowlist?: string[] } })
.fileUploadPathOptions;
expect(opts.fileUploadAllowlist).toEqual([]);
});
it('resolves to [] when fileUploadDirs is `[]` (alias for `false`)', () => {
const t = makeTools({
dangerouslyAllowAutoUploadDownloadFiles: true,
fileUploadDirs: [],
});
const opts = (t as unknown as { fileUploadPathOptions: { fileUploadAllowlist?: string[] } })
.fileUploadPathOptions;
expect(opts.fileUploadAllowlist).toEqual([]);
});
it('uses user-provided dirs verbatim when fileUploadDirs is a non-empty array', () => {
const dir = mkTemp();
try {
const t = makeTools({
dangerouslyAllowAutoUploadDownloadFiles: true,
fileUploadDirs: [dir],
});
const opts = (t as unknown as { fileUploadPathOptions: { fileUploadAllowlist?: string[] } })
.fileUploadPathOptions;
expect(opts.fileUploadAllowlist).toEqual([path.resolve(dir)]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
});
describe('File handling matrix — manual Files API bypasses the allowlist', () => {
const mkClient = () =>
({
baseURL: 'https://api.composio.dev',
apiKey: 'test',
}) as unknown as ComposioClient;
beforeEach(() => vi.clearAllMocks());
it('composio.files.upload() accepts a local path that is NOT in any allowlist', async () => {
const tmp = mkTemp();
try {
const file = path.join(tmp, 'a.txt');
writeFileSync(file, 'x');
// Stub the actual S3 call so the manual upload can complete.
const stubbed = vi.spyOn(fileUtilsModule, 'getFileDataAfterUploadingToS3');
stubbed.mockResolvedValueOnce({ name: 'a.txt', mimetype: 'text/plain', s3key: 'k' });
const files = new Files(mkClient());
const result = await files.upload({
file,
toolSlug: 'T',
toolkitSlug: 'tk',
});
expect(result.s3key).toBe('k');
// The call must NOT include a `fileUploadAllowlist` — that's what keeps
// manual upload unconstrained.
const callArg = stubbed.mock.calls[0]?.[1] as { fileUploadAllowlist?: unknown };
expect(callArg.fileUploadAllowlist).toBeUndefined();
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
it('composio.files.download() works regardless of `dangerouslyAllowAutoUploadDownloadFiles`', async () => {
const downloadMock = vi.spyOn(fileUtilsModule, 'downloadFileFromS3');
downloadMock.mockResolvedValueOnce({
name: 'x.txt',
mimeType: 'text/plain',
s3Url: 'https://s3.example/x',
filePath: '/tmp/fake/x.txt',
});
const files = new Files(mkClient(), { fileDownloadDir: '/var/app/dl' });
const res = await files.download({
toolSlug: 'T',
s3Url: 'https://s3.example/x',
mimeType: 'text/plain',
});
expect(res.filePath).toBe('/tmp/fake/x.txt');
// The manual download path forwards the configured `fileDownloadDir`.
expect(downloadMock.mock.calls[0]?.[0]).toMatchObject({
fileDownloadDir: '/var/app/dl',
});
});
});
describe('File handling matrix — auto upload honors the resolved allowlist', () => {
let getFileDataSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
// Keep the real implementation so allowlist enforcement runs, but stub the
// S3 upload that happens inside it.
getFileDataSpy = vi.spyOn(fileUtilsModule, 'uploadFileToS3' as never);
(getFileDataSpy as unknown as { mockResolvedValue: (v: string) => void }).mockResolvedValue(
's3-key'
);
});
const callUpload = async (file: string, fileUploadAllowlist: string[] | undefined) => {
return fileUtilsModule.getFileDataAfterUploadingToS3(file, {
toolSlug: 'T',
toolkitSlug: 'tk',
client: mockClient as unknown as ComposioClient,
fileUploadAllowlist,
});
};
it('rejects local paths outside the allowlist with ComposioFileUploadPathNotAllowedError', async () => {
const allowed = mkTemp();
const outside = mkTemp();
try {
const file = path.join(outside, 'leak.txt');
writeFileSync(file, 'x');
await expect(callUpload(file, [allowed])).rejects.toBeInstanceOf(
ComposioFileUploadPathNotAllowedError
);
} finally {
rmSync(allowed, { recursive: true, force: true });
rmSync(outside, { recursive: true, force: true });
}
});
it('rejects ALL local paths when allowlist is [] (the `false` / fail-closed case)', async () => {
const tmp = mkTemp();
try {
const file = path.join(tmp, 'a.txt');
writeFileSync(file, 'x');
await expect(callUpload(file, [])).rejects.toBeInstanceOf(
ComposioFileUploadPathNotAllowedError
);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
it('allows URLs even when allowlist is [] (URLs are never path-checked)', async () => {
// Stub global fetch so `readFile` (URL branch) doesn't hit the network.
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockRejectedValueOnce(new Error('stubbed-network'));
// Passes the allowlist gate (because the URL isn't a local path), then
// fails at the network layer. Any non-allowlist error proves the gate
// didn't reject the URL.
const http = 'https://example.com/file.pdf';
await expect(callUpload(http, [])).rejects.toThrowError('stubbed-network');
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it('raises ComposioFileNotFoundError when the file is missing under auto-upload', async () => {
const allowed = mkTemp();
try {
const missing = path.join(allowed, 'nope.txt');
await expect(callUpload(missing, [allowed])).rejects.toBeInstanceOf(
ComposioFileNotFoundError
);
} finally {
rmSync(allowed, { recursive: true, force: true });
}
});
it('does NOT throw an allowlist error when the file is inside an allowed dir', async () => {
// We can't easily stub the S3 upload from outside the module, so we just
// assert the error (if any) is not the allowlist rejection. The happy
// path for `assertPathInsideUploadDirs` itself is covered directly in
// `test/utils/uploadDirAllowlist.test.ts`.
const allowed = mkTemp();
try {
const file = path.join(allowed, 'ok.txt');
writeFileSync(file, 'x');
try {
await callUpload(file, [allowed]);
} catch (err) {
expect(err).not.toBeInstanceOf(ComposioFileUploadPathNotAllowedError);
expect(err).not.toBeInstanceOf(ComposioFileNotFoundError);
}
} finally {
rmSync(allowed, { recursive: true, force: true });
}
});
});
@@ -335,6 +335,58 @@ describe('fileUtils', () => {
})
).rejects.toThrow('Failed to download file: Not Found');
});
it('synthesizes the filename server-side and ignores the s3Url path', async () => {
// Regression: lock the invariant that the saved filename comes from
// (toolSlug, mimeType, timestamp, random id) — never from the s3Url.
// If a future change started reflecting the s3Url path or query into
// the local filename, an attacker-controlled CDN response could write
// outside the download dir or stomp on existing files.
mockFetch.mockResolvedValue({
ok: true,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(10)),
});
const result = await downloadFileFromS3({
toolSlug: 'github',
// The URL path contains traversal-looking segments and a totally
// different "filename"; none of this should leak into result.name.
s3Url:
'https://s3.example.com/../../../etc/passwd?response-content-disposition=attachment%3Bfilename%3Devil.sh',
mimeType: 'text/plain',
});
expect(result.name).toBe('github_1640995200000abc12345.txt');
expect(result.name).not.toContain('..');
expect(result.name).not.toContain('/');
expect(result.name).not.toContain('passwd');
expect(result.name).not.toContain('evil');
});
it('strips path components from a path-laden toolSlug before saving', async () => {
// Regression: the prefix is `${toolSlug}_`, so a slug containing `/`
// could in theory smuggle path components into the saved filename.
// `saveFile` defends against that by running the assembled filename
// through `path.basename` before joining with the download dir.
mockFetch.mockResolvedValue({
ok: true,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(10)),
});
const result = await downloadFileFromS3({
toolSlug: 'AAA/../etc/passwd',
s3Url: 'https://s3.example.com/file.bin',
mimeType: 'text/plain',
});
// `result.name` is the pre-basename string (raw composed name); the
// actual on-disk path goes through `path.basename`. We assert that
// the on-disk path stays under the download dir by checking the
// returned filePath.
expect(result.filePath).toBeDefined();
expect(path.dirname(result.filePath as string)).not.toContain('etc');
expect(path.basename(result.filePath as string)).toBe('passwd_1640995200000abc12345.txt');
});
});
describe('File object handling', () => {
@@ -0,0 +1,148 @@
import { describe, it, expect } from 'vitest';
import * as path from 'node:path';
import * as os from 'node:os';
import { mkdtempSync, writeFileSync, symlinkSync, rmSync, mkdirSync } from 'node:fs';
import { assertPathInsideUploadDirs } from '../../src/utils/uploadDirAllowlist.node';
import { resolveEffectiveUploadAllowlist, getDefaultUploadDir } from '../../src/utils/fileDirs';
import {
ComposioFileNotFoundError,
ComposioFileUploadPathNotAllowedError,
} from '../../src/errors/FileModifierErrors';
describe('resolveEffectiveUploadAllowlist', () => {
it('returns the default [<home>/.composio/temp] when userDirs is undefined', () => {
const out = resolveEffectiveUploadAllowlist(undefined);
const def = getDefaultUploadDir();
expect(def).not.toBeNull();
expect(out).toEqual([def]);
});
it('returns [] (fail-closed) when user passes []', () => {
expect(resolveEffectiveUploadAllowlist([])).toEqual([]);
});
it('returns [] when user passes `false` (explicit "no local paths")', () => {
expect(resolveEffectiveUploadAllowlist(false)).toEqual([]);
});
it('replaces the default when user passes explicit dirs', () => {
const dirs = ['/tmp/one', '/tmp/two'];
const out = resolveEffectiveUploadAllowlist(dirs);
expect(out).toEqual(dirs.map(d => path.resolve(d)));
});
it('expands leading ~ in entries', () => {
const out = resolveEffectiveUploadAllowlist(['~/foo']);
expect(out[0]).toBe(path.resolve(os.homedir(), 'foo'));
});
});
describe('assertPathInsideUploadDirs', () => {
const makeTempRoot = () => mkdtempSync(path.join(os.tmpdir(), 'composio-allowlist-'));
it('accepts a file inside an allowed directory', () => {
const root = makeTempRoot();
try {
const file = path.join(root, 'hello.txt');
writeFileSync(file, 'hi');
expect(() => assertPathInsideUploadDirs(file, [root])).not.toThrow();
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('throws ComposioFileUploadPathNotAllowedError when path is outside all allowed dirs', () => {
const root = makeTempRoot();
const other = makeTempRoot();
try {
const file = path.join(root, 'hello.txt');
writeFileSync(file, 'hi');
expect(() => assertPathInsideUploadDirs(file, [other])).toThrow(
ComposioFileUploadPathNotAllowedError
);
} finally {
rmSync(root, { recursive: true, force: true });
rmSync(other, { recursive: true, force: true });
}
});
it('enforces component-boundary matching (does not treat sibling prefix as inside)', () => {
const root = mkdtempSync(path.join(os.tmpdir(), 'composio-boundary-'));
try {
const allowed = path.join(root, 'uploads');
const sibling = path.join(root, 'uploads-not-allowed');
mkdirSync(allowed);
mkdirSync(sibling);
const file = path.join(sibling, 'leak.txt');
writeFileSync(file, 'nope');
expect(() => assertPathInsideUploadDirs(file, [allowed])).toThrow(
ComposioFileUploadPathNotAllowedError
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('rejects paths that escape via symlink (realpath is enforced)', () => {
const root = mkdtempSync(path.join(os.tmpdir(), 'composio-symesc-'));
try {
const allowed = path.join(root, 'allowed');
const outside = path.join(root, 'outside');
mkdirSync(allowed);
mkdirSync(outside);
const target = path.join(outside, 'secret.txt');
writeFileSync(target, 'x');
const link = path.join(allowed, 'innocent.txt');
symlinkSync(target, link);
expect(() => assertPathInsideUploadDirs(link, [allowed])).toThrow(
ComposioFileUploadPathNotAllowedError
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('throws ComposioFileNotFoundError when the file does not exist', () => {
const root = makeTempRoot();
try {
const missing = path.join(root, 'does-not-exist.txt');
expect(() => assertPathInsideUploadDirs(missing, [root])).toThrow(ComposioFileNotFoundError);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('throws ComposioFileUploadPathNotAllowedError with empty allowlist (fail-closed)', () => {
const root = makeTempRoot();
try {
const file = path.join(root, 'a.txt');
writeFileSync(file, 'x');
expect(() => assertPathInsideUploadDirs(file, [])).toThrow(
ComposioFileUploadPathNotAllowedError
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('includes actionable guidance and the resolved path in the error message', () => {
const root = makeTempRoot();
try {
const file = path.join(root, 'a.txt');
writeFileSync(file, 'x');
try {
assertPathInsideUploadDirs(file, ['/tmp/definitely-not-here-xyz']);
throw new Error('expected to throw');
} catch (err) {
expect(err).toBeInstanceOf(ComposioFileUploadPathNotAllowedError);
const message = (err as Error).message;
expect(message).toContain('fileUploadDirs');
expect(message).toContain('dangerouslyAllowAutoUploadDownloadFiles');
expect(message).toContain('~/.composio/temp');
expect(message).toContain(file);
}
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});