revert: drop unsafe outbound attachment relocation, keep only the visible failure notice

The staging logic in 9c46cf6 copied any agent-declared file from outside the
room's allowed directories into a safe folder and attached it, which bypassed
the attachment directory allowlist (cross-room isolation / sensitive-file
protection). Removing it.

Kept: appendRejectionNotice / describeRejectedAttachments so rejected
attachments are surfaced in the visible message instead of being silently
dropped. This changes no security behavior — it only adds text when an
attachment was already going to be rejected.

Verified: outbound-attachments + final-delivery + discord tests 70/70, tsc clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Codex
2026-06-12 00:52:42 +09:00
parent 9c46cf6761
commit 73fd71b39a
3 changed files with 11 additions and 148 deletions

View File

@@ -7,7 +7,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import {
appendRejectionNotice,
describeRejectedAttachments,
stageOutboundAttachments,
validateOutboundAttachments,
} from './outbound-attachments.js';
@@ -19,11 +18,6 @@ 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]);
@@ -343,7 +337,10 @@ describe('describeRejectedAttachments', () => {
it('summarizes rejected attachments by basename and reason', () => {
const note = describeRejectedAttachments([
{ path: '/home/claude/jarvis-tts/sample.wav', reason: 'outside-allowed-dirs' },
{
path: '/home/claude/jarvis-tts/sample.wav',
reason: 'outside-allowed-dirs',
},
{ path: '/tmp/missing.png', reason: 'not-found' },
]);
@@ -381,56 +378,10 @@ describe('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' }]);
expect(body).toBe(
describeRejectedAttachments([
{ path: '/tmp/missing.png', reason: 'not-found' },
]),
);
});
});