Files
EJClaw/src/outbound-attachments.ts
Eyejoker 6197e90c8c Fix structured attachment rendering (#69)
* review: harden readonly git checks and lint verification

* test: satisfy readonly reviewer sandbox typing

* test: fix readonly reviewer sandbox assertions

* test: remove impossible readonly sandbox guards

* test: relax readonly sandbox assertions

* test: cast readonly sandbox expectation shape

* test: cast readonly sandbox expectation through unknown

* Phase 0 — STEP_DONE/TASK_DONE split + owner-follow-up integration smoke

* Add STEP_DONE guards, verdict storage, and stale delivery suppression

* style: format paired stepdone telemetry files

* Route STEP_DONE through reviewer

* Add structured Discord attachments

* style: format structured attachment test

* Fix room thread bot output parity

* Remove duplicate work item attachment migration from owner branch

* Remove legacy dashboard rooms renderer

* Extract dashboard parsed body renderer

* Extract dashboard redaction helpers

* Extract dashboard RoomCardV2 component

* Include RoomCardV2 test in vitest suite

* Extract dashboard RoomBoardV2 component

* Extract dashboard EmptyState component

* Extract dashboard InboxPanel component

* Split dashboard InboxPanel card renderer

* Extract dashboard TaskPanel component

* Extract dashboard UsagePanel component

* Fix dashboard room thread chunk rendering

* Extract dashboard ServicePanel component

* Render dashboard live progress markdown

* Extract dashboard SettingsPanel component

* Fix structured attachment rendering
2026-04-28 17:36:11 +09:00

219 lines
6.7 KiB
TypeScript

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 | null | undefined>): 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<string>();
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 };
}