diff --git a/.env.example b/.env.example index bf43e02..199b292 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,11 @@ STATUS_CHANNEL_ID= # Discord channel ID for live status updat # STATUS_SHOW_ROOMS=true # Show room status in dashboard # STATUS_SHOW_ROOM_DETAILS=false # Show detailed room info +# --- Discord image attachments --- +# Extra local directories that agents may attach from. Keep this narrow: +# prefer screenshot/output folders over /home. Comma or path-delimiter separated. +# EJCLAW_ATTACHMENT_ALLOWED_DIRS=~/Pictures/Screenshots,~/Downloads/ejclaw-images + # --- Codex warm-up (optional, disabled by default) --- # Sends a tiny real Codex prompt only when both 5h and 7d usage are 0%, so # reset countdown starts once after a fresh quota window. Keep disabled if diff --git a/docs/configuration.md b/docs/configuration.md index 3547316..aaef8e3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -118,6 +118,17 @@ SESSION_COMMAND_ALLOWED_SENDERS= MAX_CONCURRENT_AGENTS=5 ``` +## Discord 이미지 첨부 allowlist + +```bash +EJCLAW_ATTACHMENT_ALLOWED_DIRS=~/Pictures/Screenshots,~/Downloads/ejclaw-images +``` + +- 콤마 또는 플랫폼 path delimiter(Linux는 `:`)로 여러 폴더를 지정합니다. +- 지정한 폴더 하위의 PNG/JPEG/GIF/WebP/BMP만 Discord 첨부 후보가 됩니다. +- `realpath`, 이미지 signature, size cap 검증은 그대로 적용됩니다. +- `/home/**` 전체보다 자주 쓰는 스크린샷/이미지 출력 폴더만 추가하는 것을 권장합니다. + - `ASSISTANT_NAME`은 owner trigger 기본 이름을 만듭니다 - paired room에서도 사용자 진입점은 owner가 기준입니다 - status dashboard와 session command는 선택 설정입니다 diff --git a/src/outbound-attachments.test.ts b/src/outbound-attachments.test.ts index 6911bb9..42b427a 100644 --- a/src/outbound-attachments.test.ts +++ b/src/outbound-attachments.test.ts @@ -2,7 +2,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { validateOutboundAttachments } from './outbound-attachments.js'; @@ -31,6 +31,7 @@ function writeFile( } afterEach(() => { + vi.unstubAllEnvs(); for (const dir of cleanupDirs.splice(0)) { fs.rmSync(dir, { force: true, recursive: true }); } @@ -154,6 +155,30 @@ describe('validateOutboundAttachments', () => { ]); }); + it('accepts user-configured attachment directories from env', () => { + const unusedCommaDir = makeTempDir(process.cwd(), '.ejclaw-unused-images-'); + const unusedDelimiterDir = makeTempDir( + process.cwd(), + '.ejclaw-more-unused-images-', + ); + const dir = makeTempDir(process.cwd(), '.ejclaw-user-images-'); + const imagePath = writeFile(dir, 'manual-shot.png', ONE_PIXEL_PNG); + vi.stubEnv( + 'EJCLAW_ATTACHMENT_ALLOWED_DIRS', + `${unusedCommaDir},${unusedDelimiterDir}${path.delimiter}${dir}`, + ); + + const result = validateOutboundAttachments([{ path: imagePath }]); + + expect(result.rejected).toEqual([]); + expect(result.files).toEqual([ + { + attachment: fs.realpathSync(imagePath), + name: 'manual-shot.png', + }, + ]); + }); + it('rejects symlink attempts that escape the allowed directory', () => { const workspaceDir = makeTempDir(process.cwd(), '.ejclaw-attachment-'); const targetPath = writeFile( @@ -173,6 +198,22 @@ describe('validateOutboundAttachments', () => { ]); }); + it('rejects symlink attempts that escape a user-configured directory', () => { + const allowedDir = makeTempDir(process.cwd(), '.ejclaw-user-images-'); + const secretDir = makeTempDir(process.cwd(), '.ejclaw-secret-images-'); + const targetPath = writeFile(secretDir, 'secret-shot.png', ONE_PIXEL_PNG); + const linkPath = path.join(allowedDir, 'linked-shot.png'); + fs.symlinkSync(targetPath, linkPath); + vi.stubEnv('EJCLAW_ATTACHMENT_ALLOWED_DIRS', allowedDir); + + const result = validateOutboundAttachments([{ path: linkPath }]); + + expect(result.files).toEqual([]); + expect(result.rejected).toEqual([ + { path: linkPath, reason: 'outside-allowed-dirs' }, + ]); + }); + it('rejects SVG attachments before inspecting file content', () => { const dir = makeTempDir(os.tmpdir(), 'ejclaw-attachment-'); const svgPath = writeFile(dir, 'vector.svg', ''); diff --git a/src/outbound-attachments.ts b/src/outbound-attachments.ts index 2787f67..b3a5ae5 100644 --- a/src/outbound-attachments.ts +++ b/src/outbound-attachments.ts @@ -3,6 +3,7 @@ import os from 'os'; import path from 'path'; import { DATA_DIR } from './config.js'; +import { getEnv } from './env.js'; import type { OutboundAttachment } from './types.js'; export interface ValidatedOutboundAttachment { @@ -32,10 +33,29 @@ function resolveExistingDir(dir: string): string | null { } } +function expandHomeDir(dir: string): string { + const home = getEnv('HOME') || os.homedir(); + if (dir === '~') return home; + if (dir.startsWith(`~${path.sep}`)) return path.join(home, dir.slice(2)); + return dir; +} + +export function getConfiguredAttachmentBaseDirs(): string[] { + const raw = getEnv('EJCLAW_ATTACHMENT_ALLOWED_DIRS') ?? ''; + return unique( + raw + .split(/[,\n]/) + .flatMap((part) => part.split(path.delimiter)) + .map((value) => value.trim()) + .filter(Boolean) + .map(expandHomeDir), + ); +} + export function getDefaultAttachmentBaseDirs(): string[] { - const home = process.env.HOME; + const home = getEnv('HOME'); const codexHome = - process.env.CODEX_HOME || (home ? path.join(home, '.codex') : null); + getEnv('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 @@ -43,6 +63,7 @@ export function getDefaultAttachmentBaseDirs(): string[] { return unique([ path.join(DATA_DIR, 'attachments'), codexHome ? path.join(codexHome, 'generated_images') : null, + ...getConfiguredAttachmentBaseDirs(), ]) .map(resolveExistingDir) .filter((dir): dir is string => Boolean(dir));