Configure attachment allowlist dirs
Add EJCLAW_ATTACHMENT_ALLOWED_DIRS for explicit local image folders while keeping outbound attachment validation strict.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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는 선택 설정입니다
|
||||
|
||||
@@ -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', '<svg></svg>');
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user