feat: configure attachment allowlist dirs

This commit is contained in:
ejclaw
2026-05-02 22:37:55 +09:00
parent 6356b76836
commit 16d460eb85
4 changed files with 81 additions and 3 deletions

View File

@@ -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

View File

@@ -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는 선택 설정입니다

View File

@@ -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>');

View File

@@ -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));