fix: relocate out-of-allowed outbound attachments so files actually send
Agent-generated files written to an arbitrary working path (e.g. TTS audio under /home/claude/jarvis-tts) were rejected by validateOutboundAttachments as "outside-allowed-dirs". The rejection was only logged; the MEDIA: directive had already been stripped from the text, so the user got a message claiming a file was attached with no file and no error. - Stage attachments outside the room's allowed dirs into a safe per-group dir (data/attachments/outbound/<group>) at the universal delivery choke point, so the path delivery uses and revalidates is one the validator accepts. Files already inside an allowed dir are untouched, preserving isolation checks. - Surface any still-rejected attachment in the visible Discord body via appendRejectionNotice, so delivery can never again silently drop a file. Verified: outbound-attachments 22/22, discord 46/46 (incl. new integration test asserting the notice lands in the sent body), final-delivery 5/5, tsc clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,12 @@ import path from 'path';
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { validateOutboundAttachments } from './outbound-attachments.js';
|
||||
import {
|
||||
appendRejectionNotice,
|
||||
describeRejectedAttachments,
|
||||
stageOutboundAttachments,
|
||||
validateOutboundAttachments,
|
||||
} from './outbound-attachments.js';
|
||||
|
||||
const ONE_PIXEL_PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
|
||||
@@ -14,6 +19,11 @@ const MINIMAL_MP4 = Buffer.from([
|
||||
0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d, 0x00,
|
||||
0x00, 0x02, 0x00, 0x69, 0x73, 0x6f, 0x6d, 0x69, 0x73, 0x6f, 0x32,
|
||||
]);
|
||||
const WAV_BYTES = Buffer.concat([
|
||||
Buffer.from('RIFF', 'ascii'),
|
||||
Buffer.from([0x24, 0x00, 0x00, 0x00]),
|
||||
Buffer.from('WAVE', 'ascii'),
|
||||
]);
|
||||
const MINIMAL_PDF = Buffer.from('%PDF-1.4\n', 'ascii');
|
||||
const MINIMAL_ZIP = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x14, 0x00]);
|
||||
|
||||
@@ -325,3 +335,102 @@ describe('validateOutboundAttachments policy checks', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeRejectedAttachments', () => {
|
||||
it('returns an empty string when nothing was rejected', () => {
|
||||
expect(describeRejectedAttachments([])).toBe('');
|
||||
});
|
||||
|
||||
it('summarizes rejected attachments by basename and reason', () => {
|
||||
const note = describeRejectedAttachments([
|
||||
{ path: '/home/claude/jarvis-tts/sample.wav', reason: 'outside-allowed-dirs' },
|
||||
{ path: '/tmp/missing.png', reason: 'not-found' },
|
||||
]);
|
||||
|
||||
expect(note).toContain('첨부 2건을 전송하지 못했습니다');
|
||||
expect(note).toContain('sample.wav (허용된 폴더 밖의 경로)');
|
||||
expect(note).toContain('missing.png (파일을 찾을 수 없음)');
|
||||
});
|
||||
|
||||
it('falls back to the raw reason code when no label is mapped', () => {
|
||||
const note = describeRejectedAttachments([
|
||||
{ path: '/tmp/x.bin', reason: 'some-new-reason' },
|
||||
]);
|
||||
|
||||
expect(note).toContain('x.bin (some-new-reason)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendRejectionNotice', () => {
|
||||
it('leaves the body untouched when nothing was rejected', () => {
|
||||
expect(appendRejectionNotice('보고서입니다', [])).toBe('보고서입니다');
|
||||
});
|
||||
|
||||
it('appends the failure notice to the visible body', () => {
|
||||
const body = appendRejectionNotice('샘플을 첨부합니다', [
|
||||
{ path: '/home/claude/jarvis-tts/a.wav', reason: 'outside-allowed-dirs' },
|
||||
]);
|
||||
|
||||
expect(body).toContain('샘플을 첨부합니다');
|
||||
expect(body).toContain('첨부 1건을 전송하지 못했습니다');
|
||||
expect(body).toContain('a.wav (허용된 폴더 밖의 경로)');
|
||||
});
|
||||
|
||||
it('returns the notice alone when the body would otherwise be empty', () => {
|
||||
const body = appendRejectionNotice('', [
|
||||
{ path: '/tmp/missing.png', reason: 'not-found' },
|
||||
]);
|
||||
|
||||
expect(body).toBe(describeRejectedAttachments([
|
||||
{ path: '/tmp/missing.png', reason: 'not-found' },
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('stageOutboundAttachments', () => {
|
||||
it('copies attachments outside the allowed dirs into the stage dir and keeps them deliverable', () => {
|
||||
const sourceDir = makeTempDir(process.cwd(), '.ejclaw-external-src-');
|
||||
const stageDir = makeTempDir(os.tmpdir(), 'ejclaw-outbound-stage-');
|
||||
const sourcePath = writeFile(sourceDir, 'sample.wav', WAV_BYTES);
|
||||
|
||||
const [staged] = stageOutboundAttachments([{ path: sourcePath }], {
|
||||
stageDir,
|
||||
});
|
||||
|
||||
expect(staged.path.startsWith(stageDir)).toBe(true);
|
||||
expect(staged.path).not.toBe(sourcePath);
|
||||
expect(fs.readFileSync(staged.path)).toEqual(WAV_BYTES);
|
||||
|
||||
// The relocated copy is now accepted by the validator (delivery succeeds),
|
||||
// which is the regression the silent drop used to break.
|
||||
const validation = validateOutboundAttachments([{ path: staged.path }], {
|
||||
baseDirs: [stageDir],
|
||||
});
|
||||
expect(validation.rejected).toEqual([]);
|
||||
expect(validation.files).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('leaves attachments already inside an allowed dir untouched', () => {
|
||||
const allowedDir = makeTempDir(os.tmpdir(), 'ejclaw-attachment-');
|
||||
const stageDir = makeTempDir(os.tmpdir(), 'ejclaw-outbound-stage-');
|
||||
const sourcePath = writeFile(allowedDir, 'inside.png', ONE_PIXEL_PNG);
|
||||
|
||||
const [staged] = stageOutboundAttachments([{ path: sourcePath }], {
|
||||
baseDirs: [allowedDir],
|
||||
stageDir,
|
||||
});
|
||||
|
||||
expect(staged.path).toBe(sourcePath);
|
||||
});
|
||||
|
||||
it('returns missing or relative paths unchanged so validation can reject them', () => {
|
||||
const stageDir = makeTempDir(os.tmpdir(), 'ejclaw-outbound-stage-');
|
||||
|
||||
expect(
|
||||
stageOutboundAttachments(
|
||||
[{ path: '/does/not/exist.png' }, { path: 'relative.png' }],
|
||||
{ stageDir },
|
||||
),
|
||||
).toEqual([{ path: '/does/not/exist.png' }, { path: 'relative.png' }]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user