import fs from 'fs'; import os from 'os'; import path from 'path'; import { DATA_DIR } from './config.js'; import type { OutboundAttachment } from './types.js'; export interface ValidatedOutboundAttachment { attachment: string; name: string; } export interface ValidateOutboundAttachmentsResult { files: ValidatedOutboundAttachment[]; rejected: Array<{ path: string; reason: string }>; } const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024; const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|bmp)$/i; const DEFAULT_TEMP_ATTACHMENT_DIR_PREFIXES = [ 'ejclaw-attachment-', 'ejclaw-discord-image-', ] as const; function unique(values: Array): string[] { return [ ...new Set(values.filter((value): value is string => Boolean(value))), ]; } function resolveExistingDir(dir: string): string | null { try { if (!fs.existsSync(dir)) return null; return fs.realpathSync(dir); } catch { return null; } } export function getDefaultAttachmentBaseDirs(): string[] { const home = process.env.HOME; const codexHome = process.env.CODEX_HOME || (home ? path.join(home, '.codex') : null); // Keep defaults narrow. Runtime-specific workspaces must be passed via // attachmentBaseDirs so one room cannot attach another room's files by path. // Ad-hoc generated files under os.tmpdir() are handled separately after // resolving symlinks, without allowlisting all of /tmp recursively. return unique([ path.join(DATA_DIR, 'attachments'), codexHome ? path.join(codexHome, 'generated_images') : null, ]) .map(resolveExistingDir) .filter((dir): dir is string => Boolean(dir)); } function isWithinBaseDir(realPath: string, baseDir: string): boolean { const relative = path.relative(baseDir, realPath); return ( relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative)) ); } function matchesAllowedBaseDir(realPath: string, baseDirs: string[]): boolean { return baseDirs.some((baseDir) => isWithinBaseDir(realPath, baseDir)); } function isWithinDefaultTempAttachmentDir( realPath: string, tempDir: string | null, ): boolean { if (!tempDir) return false; const relative = path.relative(tempDir, realPath); if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { return false; } const [firstSegment, ...rest] = relative.split(path.sep); return ( rest.length > 0 && DEFAULT_TEMP_ATTACHMENT_DIR_PREFIXES.some((prefix) => firstSegment.startsWith(prefix), ) ); } function isDirectTempAttachmentFile( realPath: string, tempDir: string | null, ): boolean { if (!tempDir) return false; const relative = path.relative(tempDir, realPath); if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { return false; } // Agents commonly write Playwright screenshots and generated images directly // under /tmp with task-specific names. Keep this limited to direct children; // nested temp directories still need an EJClaw prefix or explicit baseDir. return !relative.includes(path.sep); } function detectImageMime(filePath: string): string | null { const handle = fs.openSync(filePath, 'r'); try { const buffer = Buffer.alloc(512); const bytesRead = fs.readSync(handle, buffer, 0, buffer.length, 0); const header = buffer.subarray(0, bytesRead); if ( header .subarray(0, 8) .equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) ) { return 'image/png'; } if (header[0] === 0xff && header[1] === 0xd8 && header[2] === 0xff) { return 'image/jpeg'; } if ( header.subarray(0, 6).toString('ascii') === 'GIF87a' || header.subarray(0, 6).toString('ascii') === 'GIF89a' ) { return 'image/gif'; } if ( header.subarray(0, 4).toString('ascii') === 'RIFF' && header.subarray(8, 12).toString('ascii') === 'WEBP' ) { return 'image/webp'; } if (header[0] === 0x42 && header[1] === 0x4d) { return 'image/bmp'; } return null; } finally { fs.closeSync(handle); } } function normalizeAttachmentName( attachment: OutboundAttachment, realPath: string, ): string { const candidate = attachment.name ? path.basename(attachment.name) : path.basename(realPath); return candidate || 'attachment'; } export function validateOutboundAttachments( attachments: OutboundAttachment[] | undefined, options: { baseDirs?: string[] } = {}, ): ValidateOutboundAttachmentsResult { const baseDirs = unique([ ...getDefaultAttachmentBaseDirs(), ...(options.baseDirs ?? []).map(resolveExistingDir), ]).filter((dir): dir is string => Boolean(dir)); const defaultTempDir = resolveExistingDir(os.tmpdir()); const files: ValidatedOutboundAttachment[] = []; const rejected: Array<{ path: string; reason: string }> = []; const seen = new Set(); for (const attachment of attachments ?? []) { const requestedPath = attachment.path; try { if (!path.isAbsolute(requestedPath)) { rejected.push({ path: requestedPath, reason: 'not-absolute' }); continue; } if (!IMAGE_EXTS.test(requestedPath)) { rejected.push({ path: requestedPath, reason: 'unsupported-extension' }); continue; } if (!fs.existsSync(requestedPath)) { rejected.push({ path: requestedPath, reason: 'not-found' }); continue; } const realPath = fs.realpathSync(requestedPath); if (seen.has(realPath)) continue; const stat = fs.statSync(realPath); if (!stat.isFile()) { rejected.push({ path: requestedPath, reason: 'not-file' }); continue; } if (stat.size > MAX_ATTACHMENT_BYTES) { rejected.push({ path: requestedPath, reason: 'too-large' }); continue; } if ( !matchesAllowedBaseDir(realPath, baseDirs) && !isWithinDefaultTempAttachmentDir(realPath, defaultTempDir) && !isDirectTempAttachmentFile(realPath, defaultTempDir) ) { rejected.push({ path: requestedPath, reason: 'outside-allowed-dirs' }); continue; } const detectedMime = detectImageMime(realPath); if (!detectedMime) { rejected.push({ path: requestedPath, reason: 'invalid-image-signature', }); continue; } if (attachment.mime && attachment.mime !== detectedMime) { rejected.push({ path: requestedPath, reason: 'mime-mismatch' }); continue; } files.push({ attachment: realPath, name: normalizeAttachmentName(attachment, realPath), }); seen.add(realPath); } catch { rejected.push({ path: requestedPath, reason: 'validation-error' }); } } return { files, rejected }; }