From 1fbd6d5a2ef391309fe7177efa8a86d74dcb58fe Mon Sep 17 00:00:00 2001 From: Jason Carreira Date: Mon, 23 Feb 2026 18:44:34 -0500 Subject: [PATCH] Add send-file directive and Discord/CLI file support (#319) Co-authored-by: Jason Carreira Co-authored-by: Cameron Co-authored-by: Charles Packer Co-authored-by: Sarah Wooders Co-authored-by: Letta Co-authored-by: Claude Sonnet 4.6 --- docs/cli-tools.md | 2 + docs/configuration.md | 15 +++++ docs/directives.md | 38 ++++++++++--- lettabot.example.yaml | 3 + src/channels/discord.ts | 19 +++++++ src/cli/message.ts | 20 ++++--- src/config/types.ts | 6 ++ src/core/bot.ts | 107 +++++++++++++++++++++++++++++++++++- src/core/directives.test.ts | 38 +++++++++++++ src/core/directives.ts | 31 ++++++++++- src/core/send-file.test.ts | 98 +++++++++++++++++++++++++++++++++ src/core/system-prompt.ts | 3 +- src/core/types.ts | 3 + src/main.ts | 3 + 14 files changed, 364 insertions(+), 22 deletions(-) create mode 100644 src/core/send-file.test.ts diff --git a/docs/cli-tools.md b/docs/cli-tools.md index 5b06196..d923479 100644 --- a/docs/cli-tools.md +++ b/docs/cli-tools.md @@ -10,6 +10,7 @@ Send a message to the most recent chat, or target a specific channel/chat. ```bash lettabot-message send --text "Hello from a background task" lettabot-message send --text "Hello" --channel slack --chat C123456 +lettabot-message send --file /tmp/report.pdf --text "Report attached" --channel discord --chat 123456789 ``` ## lettabot-react @@ -34,3 +35,4 @@ Notes: - History fetch is not supported by the Telegram Bot API, Signal, or WhatsApp. - If you omit `--channel` or `--chat`, the CLI falls back to the last message target stored in `lettabot-agent.json`. - You need the channel-specific bot token set (`DISCORD_BOT_TOKEN` or `SLACK_BOT_TOKEN`). +- File sending uses the API server and requires `LETTABOT_API_KEY` (supported: telegram, slack, discord, whatsapp). diff --git a/docs/configuration.md b/docs/configuration.md index 981fc30..dc06cb2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -455,6 +455,21 @@ Precedence: `prompt` (inline YAML) > `HEARTBEAT_PROMPT` (env var) > `promptFile` | `features.heartbeat.prompt` | string | _(none)_ | Custom heartbeat prompt text | | `features.heartbeat.promptFile` | string | _(none)_ | Path to prompt file (relative to working dir) | +### Send-File Directory + +The `` [response directive](./directives.md) allows the agent to send files to channels. For security, file paths are restricted to a configurable directory: + +```yaml +features: + sendFileDir: ./data/outbound # Default: agent working directory +``` + +Only files inside this directory (and its subdirectories) can be sent. Paths that resolve outside it are blocked. This prevents prompt injection attacks from exfiltrating sensitive files. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `features.sendFileDir` | string | _(workingDir)_ | Directory that `` paths must be inside | + ### Cron Jobs ```yaml diff --git a/docs/directives.md b/docs/directives.md index c2c36ec..78b13b3 100644 --- a/docs/directives.md +++ b/docs/directives.md @@ -41,6 +41,28 @@ Adds an emoji reaction to a message. - Unicode emoji: direct characters like `👍` - `message` (optional) -- Target message ID. Defaults to the message that triggered the response. +### `` + +Sends a file or image to the same channel/chat as the triggering message. + +```xml + + + +``` + +**Attributes:** +- `path` / `file` (required) -- Local file path on the LettaBot server +- `caption` / `text` (optional) -- Caption text for the file +- `kind` (optional) -- `image` or `file` (defaults to auto-detect based on extension) +- `cleanup` (optional) -- `true` to delete the file after sending (default: false) + +**Security:** +- File paths are restricted to the configured `sendFileDir` directory (defaults to `data/outbound/` under the agent's working directory). Paths outside this directory are blocked and logged. +- Symlinks that resolve outside the allowed directory are also blocked. +- File size is limited to `sendFileMaxSize` (default: 50MB). +- The `cleanup` attribute only works when `sendFileCleanup: true` is set in the agent's features config (disabled by default). + ### `` Suppresses response delivery entirely. The agent's text is discarded. @@ -66,13 +88,13 @@ Backslash-escaped quotes (common when LLMs generate XML inside a JSON context) a ## Channel Support -| Channel | `addReaction` | Notes | -|-----------|:---:|-------| -| Telegram | Yes | Limited to Telegram's [allowed reaction set](https://core.telegram.org/bots/api#reactiontype) (~75 emoji) | -| Slack | Yes | Uses Slack emoji names (`:thumbsup:` style). Custom workspace emoji supported. | -| Discord | Yes | Unicode emoji and common aliases. Custom server emoji not yet supported. | -| WhatsApp | No | Directive is skipped with a warning | -| Signal | No | Directive is skipped with a warning | +| Channel | `addReaction` | `send-file` | Notes | +|-----------|:---:|:---:|-------| +| Telegram | Yes | Yes | Reactions limited to Telegram's [allowed reaction set](https://core.telegram.org/bots/api#reactiontype). | +| Slack | Yes | Yes | Reactions use Slack emoji names (`:thumbsup:` style). | +| Discord | Yes | Yes | Custom server emoji not yet supported. | +| WhatsApp | No | Yes | Reactions skipped with a warning. | +| Signal | No | No | Directive skipped with a warning. | When a channel doesn't implement `addReaction`, the directive is silently skipped and a warning is logged. This never blocks message delivery. @@ -112,7 +134,7 @@ The parser (`src/core/directives.ts`) is designed to be extensible. Adding a new 3. Add a parsing case in `parseChildDirectives()` 4. Add an execution case in `executeDirectives()` in `bot.ts` -See issue [#240](https://github.com/letta-ai/lettabot/issues/240) for planned directives like ``. +See issue [#240](https://github.com/letta-ai/lettabot/issues/240) for planned directives. ## Source diff --git a/lettabot.example.yaml b/lettabot.example.yaml index b1dbbe9..8c3db9a 100644 --- a/lettabot.example.yaml +++ b/lettabot.example.yaml @@ -73,6 +73,9 @@ features: enabled: false intervalMin: 30 # skipRecentUserMin: 5 # Skip auto-heartbeats for N minutes after user message (0 disables) + # sendFileDir: ./data/outbound # Restrict directive to this directory (default: data/outbound) + # sendFileMaxSize: 52428800 # Max file size in bytes for (default: 50MB) + # sendFileCleanup: false # Allow to delete files after send (default: false) # memfs: true # Enable memory filesystem (git-backed context repository). Syncs memory blocks to local files. # display: # showToolCalls: false # Show tool invocations in chat (e.g. "Using tool: Read (file_path: ...)") diff --git a/src/channels/discord.ts b/src/channels/discord.ts index d7cb605..437ee0c 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -12,6 +12,7 @@ import { isUserAllowed, upsertPairingRequest } from '../pairing/store.js'; import { buildAttachmentPath, downloadToFile } from './attachments.js'; import { HELP_TEXT } from '../core/commands.js'; import { isGroupAllowed, isGroupUserAllowed, resolveGroupMode, resolveReceiveBotMessages, type GroupModeConfig } from './group-mode.js'; +import { basename } from 'node:path'; // Dynamic import to avoid requiring Discord deps if not used let Client: typeof import('discord.js').Client; @@ -346,6 +347,23 @@ Ask the bot owner to approve with: return { messageId: result.id }; } + async sendFile(file: OutboundFile): Promise<{ messageId: string }> { + if (!this.client) throw new Error('Discord not started'); + const channel = await this.client.channels.fetch(file.chatId); + if (!channel || !channel.isTextBased() || !('send' in channel)) { + throw new Error(`Discord channel not found or not text-based: ${file.chatId}`); + } + + const payload = { + content: file.caption || undefined, + files: [ + { attachment: file.filePath, name: basename(file.filePath) }, + ], + }; + const result = await (channel as { send: (options: typeof payload) => Promise<{ id: string }> }).send(payload); + return { messageId: result.id }; + } + async editMessage(chatId: string, messageId: string, text: string): Promise { if (!this.client) throw new Error('Discord not started'); const channel = await this.client.channels.fetch(chatId); @@ -510,6 +528,7 @@ const DISCORD_EMOJI_ALIAS_TO_UNICODE: Record = { tada: '\u{1F389}', clap: '\u{1F44F}', ok_hand: '\u{1F44C}', + white_check_mark: '\u2705', }; function resolveDiscordEmoji(input: string): string { diff --git a/src/cli/message.ts b/src/cli/message.ts index fc189d1..6598fca 100644 --- a/src/cli/message.ts +++ b/src/cli/message.ts @@ -252,6 +252,7 @@ async function sendCommand(args: string[]): Promise { let kind: 'image' | 'file' | undefined = undefined; let channel = ''; let chatId = ''; + const fileCapableChannels = new Set(['telegram', 'slack', 'discord', 'whatsapp']); // Parse args for (let i = 0; i < args.length; i++) { @@ -304,16 +305,16 @@ async function sendCommand(args: string[]): Promise { } try { - // Use API for WhatsApp (unified multipart endpoint) - if (channel === 'whatsapp') { + if (filePath) { + if (!fileCapableChannels.has(channel)) { + throw new Error(`File sending not supported for ${channel}. Supported: telegram, slack, discord, whatsapp`); + } await sendViaApi(channel, chatId, { text, filePath, kind }); - } else if (filePath) { - // Other channels with files - not yet implemented via API - throw new Error(`File sending for ${channel} requires API (currently only WhatsApp supported via API)`); - } else { - // Other channels with text only - direct API calls - await sendToChannel(channel, chatId, text); + return; } + + // Text-only: direct platform APIs (WhatsApp uses API internally) + await sendToChannel(channel, chatId, text); } catch (error) { console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); process.exit(1); @@ -359,7 +360,8 @@ Environment variables: LETTABOT_API_URL API server URL (default: http://localhost:8080) SIGNAL_CLI_REST_API_URL Signal daemon URL (default: http://127.0.0.1:8090) -Note: WhatsApp uses the API server. Other channels use direct platform APIs. +Note: File sending uses the API server for supported channels (telegram, slack, discord, whatsapp). + Text-only messages use direct platform APIs (WhatsApp uses API). `); } diff --git a/src/config/types.ts b/src/config/types.ts index fdc5fd8..1acbb7c 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -77,6 +77,9 @@ export interface AgentConfig { }; memfs?: boolean; // Enable memory filesystem (git-backed context repository) for SDK sessions maxToolCalls?: number; + sendFileDir?: string; // Restrict directive to this directory (default: data/outbound) + sendFileMaxSize?: number; // Max file size in bytes for (default: 50MB) + sendFileCleanup?: boolean; // Allow to delete after send (default: false) display?: DisplayConfig; }; /** Polling config */ @@ -151,6 +154,9 @@ export interface LettaBotConfig { inlineImages?: boolean; // Send images directly to the LLM (default: true). Set false to only send file paths. memfs?: boolean; // Enable memory filesystem (git-backed context repository) for SDK sessions maxToolCalls?: number; // Abort if agent calls this many tools in one turn (default: 100) + sendFileDir?: string; // Restrict directive to this directory (default: data/outbound) + sendFileMaxSize?: number; // Max file size in bytes for (default: 50MB) + sendFileCleanup?: boolean; // Allow to delete after send (default: false) display?: DisplayConfig; // Show tool calls / reasoning in channel output }; diff --git a/src/core/bot.ts b/src/core/bot.ts index 5f0faea..6526b33 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -6,6 +6,8 @@ import { createAgent, createSession, resumeSession, imageFromFile, imageFromURL, type Session, type MessageContentItem, type SendMessage, type CanUseToolCallback } from '@letta-ai/letta-code-sdk'; import { mkdirSync } from 'node:fs'; +import { access, unlink, realpath, stat, constants } from 'node:fs/promises'; +import { extname, resolve, join } from 'node:path'; import type { ChannelAdapter } from '../channels/types.js'; import type { BotConfig, InboundMessage, TriggerContext } from './types.js'; import type { AgentSession } from './interfaces.js'; @@ -102,6 +104,44 @@ const SUPPORTED_IMAGE_MIMES = new Set([ 'image/png', 'image/jpeg', 'image/gif', 'image/webp', ]); +const IMAGE_FILE_EXTENSIONS = new Set([ + '.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tiff', +]); + +/** Infer whether a file is an image or generic file based on extension. */ +export function inferFileKind(filePath: string): 'image' | 'file' { + const ext = extname(filePath).toLowerCase(); + return IMAGE_FILE_EXTENSIONS.has(ext) ? 'image' : 'file'; +} + +/** + * Check whether a resolved file path is inside the allowed directory. + * Prevents path traversal attacks in the send-file directive. + * + * Uses realpath() for both the file and directory to follow symlinks, + * preventing symlink-based escapes (e.g., data/evil -> /etc/passwd). + * Falls back to textual resolve() when paths don't exist on disk. + */ +export async function isPathAllowed(filePath: string, allowedDir: string): Promise { + // Resolve the allowed directory -- use realpath if it exists, resolve() otherwise + let canonicalDir: string; + try { + canonicalDir = await realpath(allowedDir); + } catch { + canonicalDir = resolve(allowedDir); + } + + // Resolve the file -- use realpath if it exists, resolve() otherwise + let canonicalFile: string; + try { + canonicalFile = await realpath(filePath); + } catch { + canonicalFile = resolve(filePath); + } + + return canonicalFile === canonicalDir || canonicalFile.startsWith(canonicalDir + '/'); +} + async function buildMultimodalMessage( formattedText: string, msg: InboundMessage, @@ -406,6 +446,7 @@ export class LettaBot implements AgentSession { adapter: ChannelAdapter, chatId: string, fallbackMessageId?: string, + threadId?: string, ): Promise { let acted = false; for (const directive of directives) { @@ -424,6 +465,68 @@ export class LettaBot implements AgentSession { console.warn('[Bot] Directive react failed:', err instanceof Error ? err.message : err); } } + continue; + } + + if (directive.type === 'send-file') { + if (typeof adapter.sendFile !== 'function') { + console.warn(`[Bot] Directive send-file skipped: ${adapter.name} does not support sendFile`); + continue; + } + + // Path sandboxing: restrict to configured directory (default: data/outbound under workingDir) + const allowedDir = this.config.sendFileDir || join(this.config.workingDir, 'data', 'outbound'); + const resolvedPath = resolve(directive.path); + if (!await isPathAllowed(resolvedPath, allowedDir)) { + console.warn(`[Bot] Directive send-file blocked: ${directive.path} is outside allowed directory ${allowedDir}`); + continue; + } + + // Async file existence + readability check + try { + await access(resolvedPath, constants.R_OK); + } catch { + console.warn(`[Bot] Directive send-file skipped: file not found or not readable at ${directive.path}`); + continue; + } + + // File size guard (default: 50MB) + const maxSize = this.config.sendFileMaxSize ?? 50 * 1024 * 1024; + try { + const fileStat = await stat(resolvedPath); + if (fileStat.size > maxSize) { + console.warn(`[Bot] Directive send-file blocked: ${directive.path} is ${fileStat.size} bytes (max: ${maxSize})`); + continue; + } + } catch { + console.warn(`[Bot] Directive send-file skipped: could not stat ${directive.path}`); + continue; + } + + try { + await adapter.sendFile({ + chatId, + filePath: resolvedPath, + caption: directive.caption, + kind: directive.kind ?? inferFileKind(resolvedPath), + threadId, + }); + acted = true; + console.log(`[Bot] Directive: sent file ${resolvedPath}`); + + // Optional cleanup: delete file after successful send. + // Only honored when sendFileCleanup is enabled in config (defense-in-depth). + if (directive.cleanup && this.config.sendFileCleanup) { + try { + await unlink(resolvedPath); + console.warn(`[Bot] Directive: cleaned up ${resolvedPath}`); + } catch (cleanupErr) { + console.warn('[Bot] Directive send-file cleanup failed:', cleanupErr instanceof Error ? cleanupErr.message : cleanupErr); + } + } + } catch (err) { + console.warn('[Bot] Directive send-file failed:', err instanceof Error ? err.message : err); + } } } return acted; @@ -1133,7 +1236,7 @@ export class LettaBot implements AgentSession { if (response.trim()) { const { cleanText, directives } = parseDirectives(response); response = cleanText; - if (await this.executeDirectives(directives, adapter, msg.chatId, msg.messageId)) { + if (await this.executeDirectives(directives, adapter, msg.chatId, msg.messageId, msg.threadId)) { sentAnyMessage = true; } } @@ -1450,7 +1553,7 @@ export class LettaBot implements AgentSession { if (response.trim()) { const { cleanText, directives } = parseDirectives(response); response = cleanText; - if (await this.executeDirectives(directives, adapter, msg.chatId, msg.messageId)) { + if (await this.executeDirectives(directives, adapter, msg.chatId, msg.messageId, msg.threadId)) { sentAnyMessage = true; } } diff --git a/src/core/directives.test.ts b/src/core/directives.test.ts index d628fba..008496a 100644 --- a/src/core/directives.test.ts +++ b/src/core/directives.test.ts @@ -61,6 +61,44 @@ describe('parseDirectives', () => { ]); }); + it('parses send-file directive with path and caption', () => { + const result = parseDirectives(''); + expect(result.cleanText).toBe(''); + expect(result.directives).toEqual([ + { type: 'send-file', path: '/tmp/report.pdf', caption: 'Report' }, + ]); + }); + + it('parses send-file directive with file alias and kind', () => { + const result = parseDirectives(''); + expect(result.cleanText).toBe(''); + expect(result.directives).toEqual([ + { type: 'send-file', path: 'photo.png', kind: 'image' }, + ]); + }); + + it('parses send-file directive with cleanup attribute', () => { + const result = parseDirectives(''); + expect(result.cleanText).toBe(''); + expect(result.directives).toEqual([ + { type: 'send-file', path: '/tmp/report.pdf', cleanup: true }, + ]); + }); + + it('omits cleanup when not set to true', () => { + const result = parseDirectives(''); + expect(result.cleanText).toBe(''); + expect(result.directives).toEqual([ + { type: 'send-file', path: '/tmp/report.pdf' }, + ]); + }); + + it('ignores send-file directive without path or file attribute', () => { + const result = parseDirectives(''); + expect(result.cleanText).toBe(''); + expect(result.directives).toEqual([]); + }); + it('ignores react directive without emoji attribute', () => { const result = parseDirectives(''); expect(result.cleanText).toBe(''); diff --git a/src/core/directives.ts b/src/core/directives.ts index 9aab49a..42d0eaa 100644 --- a/src/core/directives.ts +++ b/src/core/directives.ts @@ -22,8 +22,16 @@ export interface ReactDirective { messageId?: string; } +export interface SendFileDirective { + type: 'send-file'; + path: string; + caption?: string; + kind?: 'image' | 'file'; + cleanup?: boolean; +} + // Union type — extend with more directive types later -export type Directive = ReactDirective; +export type Directive = ReactDirective | SendFileDirective; export interface ParseResult { cleanText: string; @@ -40,7 +48,7 @@ const ACTIONS_BLOCK_REGEX = /^\s*([\s\S]*?)<\/actions>/; * Match self-closing child directive tags inside the actions block. * Captures the tag name and the full attributes string. */ -const CHILD_DIRECTIVE_REGEX = /<(react)\b([^>]*)\/>/g; +const CHILD_DIRECTIVE_REGEX = /<(react|send-file)\b([^>]*)\/>/g; /** * Parse a single attribute string like: emoji="eyes" message="123" @@ -79,6 +87,25 @@ function parseChildDirectives(block: string): Directive[] { ...(attrs.message ? { messageId: attrs.message } : {}), }); } + continue; + } + + if (tagName === 'send-file') { + const attrs = parseAttributes(attrString); + const path = attrs.path || attrs.file; + if (!path) continue; + const caption = attrs.caption || attrs.text; + const kind = attrs.kind === 'image' || attrs.kind === 'file' + ? attrs.kind + : undefined; + const cleanup = attrs.cleanup === 'true'; + directives.push({ + type: 'send-file', + path, + ...(caption ? { caption } : {}), + ...(kind ? { kind } : {}), + ...(cleanup ? { cleanup } : {}), + }); } } diff --git a/src/core/send-file.test.ts b/src/core/send-file.test.ts new file mode 100644 index 0000000..9b348e8 --- /dev/null +++ b/src/core/send-file.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { inferFileKind, isPathAllowed } from './bot.js'; +import { mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +describe('inferFileKind', () => { + it('returns image for common image extensions', () => { + expect(inferFileKind('/tmp/photo.png')).toBe('image'); + expect(inferFileKind('/tmp/photo.jpg')).toBe('image'); + expect(inferFileKind('/tmp/photo.jpeg')).toBe('image'); + expect(inferFileKind('/tmp/photo.gif')).toBe('image'); + expect(inferFileKind('/tmp/photo.webp')).toBe('image'); + expect(inferFileKind('/tmp/photo.bmp')).toBe('image'); + expect(inferFileKind('/tmp/photo.tiff')).toBe('image'); + }); + + it('returns file for non-image extensions', () => { + expect(inferFileKind('/tmp/report.pdf')).toBe('file'); + expect(inferFileKind('/tmp/data.csv')).toBe('file'); + expect(inferFileKind('/tmp/document.docx')).toBe('file'); + expect(inferFileKind('/tmp/archive.zip')).toBe('file'); + expect(inferFileKind('/tmp/script.ts')).toBe('file'); + }); + + it('is case insensitive', () => { + expect(inferFileKind('/tmp/PHOTO.PNG')).toBe('image'); + expect(inferFileKind('/tmp/photo.JPG')).toBe('image'); + expect(inferFileKind('/tmp/photo.Jpeg')).toBe('image'); + }); + + it('returns file for extensionless paths', () => { + expect(inferFileKind('/tmp/noext')).toBe('file'); + }); +}); + +describe('isPathAllowed', () => { + // These use non-existent paths, so isPathAllowed falls back to resolve() (textual check) + it('allows files inside the allowed directory', async () => { + expect(await isPathAllowed('/home/bot/data/report.pdf', '/home/bot/data')).toBe(true); + }); + + it('allows files in nested subdirectories', async () => { + expect(await isPathAllowed('/home/bot/data/sub/deep/file.txt', '/home/bot/data')).toBe(true); + }); + + it('blocks files outside the allowed directory', async () => { + expect(await isPathAllowed('/etc/passwd', '/home/bot/data')).toBe(false); + expect(await isPathAllowed('/home/bot/.env', '/home/bot/data')).toBe(false); + }); + + it('blocks path traversal attempts', async () => { + expect(await isPathAllowed('/home/bot/data/../.env', '/home/bot/data')).toBe(false); + expect(await isPathAllowed('/home/bot/data/../../etc/passwd', '/home/bot/data')).toBe(false); + }); + + it('allows the directory itself', async () => { + expect(await isPathAllowed('/home/bot/data', '/home/bot/data')).toBe(true); + }); + + it('blocks sibling directories with similar prefixes', async () => { + // /home/bot/data-evil should NOT be allowed when allowedDir is /home/bot/data + expect(await isPathAllowed('/home/bot/data-evil/secret.txt', '/home/bot/data')).toBe(false); + }); + + it('handles trailing slashes in allowed directory', async () => { + expect(await isPathAllowed('/home/bot/data/file.txt', '/home/bot/data/')).toBe(true); + }); + + // Symlink escape test: symlink inside allowed dir pointing outside + describe('symlink handling', () => { + const testDir = join(tmpdir(), 'lettabot-test-sendfile-' + Date.now()); + const allowedDir = join(testDir, 'allowed'); + const outsideFile = join(testDir, 'secret.txt'); + const symlinkPath = join(allowedDir, 'evil-link'); + + beforeAll(() => { + mkdirSync(allowedDir, { recursive: true }); + writeFileSync(outsideFile, 'secret content'); + symlinkSync(outsideFile, symlinkPath); + }); + + afterAll(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it('blocks symlinks that resolve outside the allowed directory', async () => { + // The symlink is inside allowedDir textually, but resolves to outsideFile + expect(await isPathAllowed(symlinkPath, allowedDir)).toBe(false); + }); + + it('allows real files inside the allowed directory', async () => { + const realFile = join(allowedDir, 'legit.txt'); + writeFileSync(realFile, 'safe content'); + expect(await isPathAllowed(realFile, allowedDir)).toBe(true); + }); + }); +}); diff --git a/src/core/system-prompt.ts b/src/core/system-prompt.ts index d5f2539..2347b29 100644 --- a/src/core/system-prompt.ts +++ b/src/core/system-prompt.ts @@ -45,7 +45,7 @@ lettabot-react add --emoji :eyes: # Add a reaction to a specific message lettabot-react add --emoji :eyes: --channel telegram --chat 123456789 --message 987654321 -# Note: File sending supported on telegram, slack, whatsapp (via API) +# Note: File sending supported on telegram, slack, discord, whatsapp (via API) # Signal does not support files or reactions # Discover channel IDs (Discord and Slack) @@ -103,6 +103,7 @@ This sends "Great idea!" and reacts with thumbsup. - \`\` -- react to the message you are responding to. Emoji names (eyes, thumbsup, heart, fire, tada, clap) or unicode. - \`\` -- react to a specific message by ID. +- \`\` -- send a file or image to the same channel/chat. File paths are restricted to the configured send-file directory (default: \`data/outbound/\` in the working directory). Paths outside this directory are blocked. ### Actions-only response diff --git a/src/core/types.ts b/src/core/types.ts index 0d18eb0..6509973 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -145,6 +145,9 @@ export interface BotConfig { // Security allowedUsers?: string[]; // Empty = allow all + sendFileDir?: string; // Restrict directive to this directory (default: data/outbound) + sendFileMaxSize?: number; // Max file size in bytes for (default: 50MB) + sendFileCleanup?: boolean; // Allow to delete files after send (default: false) // Conversation routing conversationMode?: 'shared' | 'per-channel'; // Default: shared diff --git a/src/main.ts b/src/main.ts index 68143c3..52f36e2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -586,6 +586,9 @@ async function main() { disallowedTools: globalConfig.disallowedTools, displayName: agentConfig.displayName, maxToolCalls: agentConfig.features?.maxToolCalls, + sendFileDir: agentConfig.features?.sendFileDir, + sendFileMaxSize: agentConfig.features?.sendFileMaxSize, + sendFileCleanup: agentConfig.features?.sendFileCleanup, memfs: resolvedMemfs, display: agentConfig.features?.display, conversationMode: agentConfig.conversations?.mode || 'shared',