diff --git a/packages/media-core/src/mime.test.ts b/packages/media-core/src/mime.test.ts index 582b02f21cf7..5600036c3b4e 100644 --- a/packages/media-core/src/mime.test.ts +++ b/packages/media-core/src/mime.test.ts @@ -109,6 +109,20 @@ describe("mime detection", () => { }), expected: "text/javascript", }, + { + name: "uses extension mapping for YAML assets", + input: async () => ({ + filePath: "/tmp/config.yml", + }), + expected: "application/yaml", + }, + { + name: "uses extension mapping for YAML documents", + input: async () => ({ + filePath: "/tmp/config.yaml", + }), + expected: "application/yaml", + }, ] as const)("$name", async ({ input, expected }) => { await expectDetectedMime({ input: await input(), @@ -182,6 +196,8 @@ describe("mimeTypeFromFilePath", () => { { filePath: "clip.flv", expected: "video/x-flv" }, { filePath: "clip.wmv", expected: "video/x-ms-wmv" }, { filePath: "debug.log", expected: "text/plain" }, + { filePath: "config.yml", expected: "application/yaml" }, + { filePath: "config.yaml", expected: "application/yaml" }, { filePath: "page.xml", expected: "text/xml" }, { filePath: "unknown.bin", expected: undefined }, ] as const)("maps $filePath", ({ filePath, expected }) => { @@ -221,6 +237,7 @@ describe("extensionForMime", () => { { mime: "video/x-ms-wmv", expected: ".wmv" }, { mime: "video/quicktime", expected: ".mov" }, { mime: "application/pdf", expected: ".pdf" }, + { mime: "application/yaml", expected: ".yaml" }, { mime: "text/plain", expected: ".txt" }, { mime: "text/markdown", expected: ".md" }, { mime: "text/html", expected: ".html" }, diff --git a/packages/media-core/src/mime.ts b/packages/media-core/src/mime.ts index 4bfc54361513..eb37c0230110 100644 --- a/packages/media-core/src/mime.ts +++ b/packages/media-core/src/mime.ts @@ -38,6 +38,7 @@ const EXT_BY_MIME: Record = { "video/quicktime": ".mov", "application/pdf": ".pdf", "application/json": ".json", + "application/yaml": ".yaml", "application/zip": ".zip", "application/gzip": ".gz", "application/x-tar": ".tar", @@ -79,6 +80,7 @@ const MIME_BY_EXT: Record = { ".log": "text/plain", ".htm": "text/html", ".xml": "text/xml", + ".yml": "application/yaml", }; const AUDIO_FILE_EXTENSIONS = new Set([ diff --git a/src/infra/outbound/message-action-runner.media.test.ts b/src/infra/outbound/message-action-runner.media.test.ts index 53d5946c8c8c..9c80ba7bcbb9 100644 --- a/src/infra/outbound/message-action-runner.media.test.ts +++ b/src/infra/outbound/message-action-runner.media.test.ts @@ -515,7 +515,7 @@ describe("runMessageAction media behavior", () => { } }); - it("rejects host-local text attachments even when fs root expansion is enabled", async () => { + it("hydrates validated host-local text attachments when fs root expansion is enabled", async () => { await restoreRealMediaLoader(); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "msg-attachment-text-")); @@ -523,21 +523,30 @@ describe("runMessageAction media behavior", () => { const outsidePath = path.join(tempDir, "secret.txt"); await fs.writeFile(outsidePath, "secret", "utf8"); - await expect( - runMessageAction({ - cfg: { - ...cfg, - tools: { fs: { workspaceOnly: false } }, - }, - action: "sendAttachment", - params: { - channel: "attachmentchat", - target: "+15551234567", - media: outsidePath, - message: "caption", - }, - }), - ).rejects.toThrow(/Host-local media sends only allow/i); + const result = await runMessageAction({ + cfg: { + ...cfg, + tools: { fs: { workspaceOnly: false } }, + }, + action: "sendAttachment", + params: { + channel: "attachmentchat", + target: "+15551234567", + media: outsidePath, + message: "caption", + }, + }); + + expect(result.kind).toBe("action"); + expect(result.payload).toMatchObject({ + ok: true, + filename: "secret.txt", + caption: "caption", + contentType: "text/plain", + }); + expect((result.payload as { buffer?: string }).buffer).toBe( + Buffer.from("secret").toString("base64"), + ); } finally { await fs.rm(tempDir, { recursive: true, force: true }); } diff --git a/src/media/web-media.test.ts b/src/media/web-media.test.ts index 85304ae48a4e..07bc8a213ba3 100644 --- a/src/media/web-media.test.ts +++ b/src/media/web-media.test.ts @@ -597,18 +597,17 @@ describe("loadWebMedia", () => { } }); - it("rejects host-read text files outside local roots", async () => { - const secretFile = path.join(fixtureRoot, "secret.txt"); - await fs.writeFile(secretFile, "secret", "utf8"); - await expectLoadWebMediaErrorCode( - loadWebMedia(secretFile, { - maxBytes: 1024 * 1024, - localRoots: "any", - readFile: async (filePath) => await fs.readFile(filePath), - hostReadCapability: true, - }), - "path-not-allowed", - ); + it("allows validated host-read TXT files", async () => { + const txtFile = path.join(fixtureRoot, "notes.txt"); + await fs.writeFile(txtFile, "plain text\n", "utf8"); + const result = await loadWebMedia(txtFile, { + maxBytes: 1024 * 1024, + localRoots: "any", + readFile: async (filePath) => await fs.readFile(filePath), + hostReadCapability: true, + }); + expect(result.kind).toBe("document"); + expect(result.contentType).toBe("text/plain"); }); it("rejects renamed host-read text files even when the extension looks allowed", async () => { @@ -771,40 +770,51 @@ describe("loadWebMedia", () => { { label: "ZIP", fileName: "archive.zip", + body: Buffer.from([0x50, 0x4b, 0x03, 0x04]), contentType: "application/zip", - buffer: Buffer.from([0x50, 0x4b, 0x03, 0x04]), }, { label: "gzip", fileName: "archive.gz", + body: Buffer.from([0x1f, 0x8b, 0x08, 0x00, 0, 0, 0, 0, 0, 0x03]), contentType: "application/gzip", - buffer: Buffer.from([0x1f, 0x8b, 0x08, 0x00, 0, 0, 0, 0, 0, 0x03]), }, { label: "tar", fileName: "archive.tar", - contentType: "application/x-tar", - buffer: (() => { + body: (() => { const buffer = Buffer.alloc(512); buffer.write("ustar", 257, "ascii"); return buffer; })(), + contentType: "application/x-tar", }, { label: "7z", fileName: "archive.7z", + body: Buffer.from([0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c, 0, 4]), contentType: "application/x-7z-compressed", - buffer: Buffer.from([0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c, 0, 4]), }, - ])("allows host-read $label files", async ({ fileName, contentType, buffer }) => { - const archiveFile = path.join(fixtureRoot, fileName); - await fs.writeFile(archiveFile, buffer); - const result = await loadWebMedia(archiveFile, { - maxBytes: 1024 * 1024, - localRoots: "any", - readFile: async (filePath) => await fs.readFile(filePath), - hostReadCapability: true, - }); + { + label: "JSON", + fileName: "data.json", + body: '{"ok":true}\n', + contentType: "application/json", + }, + { + label: "YAML", + fileName: "config.yaml", + body: "ok: true\n", + contentType: "application/yaml", + }, + { + label: "YML", + fileName: "config.yml", + body: "ok: true\n", + contentType: "application/yaml", + }, + ])("allows host-read $label files", async ({ fileName, body, contentType }) => { + const result = await loadDocumentWithHostRead(fileName, body); expect(result.kind).toBe("document"); expect(result.contentType).toBe(contentType); }); @@ -829,7 +839,11 @@ describe("loadWebMedia", () => { { label: "CSV", fileName: "opaque.csv" }, { label: "HTML", fileName: "opaque.html" }, { label: "Markdown", fileName: "opaque.md" }, - ])("rejects opaque non-NUL binary data disguised as %s", async ({ fileName }) => { + { label: "TXT", fileName: "opaque.txt" }, + { label: "JSON", fileName: "opaque.json" }, + { label: "YAML", fileName: "opaque.yaml" }, + { label: "YML", fileName: "opaque.yml" }, + ])("rejects opaque non-NUL binary data disguised as $label", async ({ fileName }) => { const fakeTextFile = path.join(fixtureRoot, fileName); const opaqueBinary = Buffer.alloc(9000); for (let i = 0; i < opaqueBinary.length; i += 1) { diff --git a/src/media/web-media.ts b/src/media/web-media.ts index 7e190900d6a3..409bacad6d94 100644 --- a/src/media/web-media.ts +++ b/src/media/web-media.ts @@ -151,16 +151,26 @@ const HOST_READ_ALLOWED_DOCUMENT_MIMES = new Set([ "application/zip", "text/csv", "text/markdown", + "text/plain", + "application/json", + "application/yaml", +]); +// file-type returns undefined (no magic bytes) for plain-text formats like CSV, +// Markdown, TXT, JSON, and YAML, so host-read needs an explicit "this really +// decodes as text" fallback. +const HOST_READ_TEXT_PLAIN_ALIASES = new Set([ + "text/csv", + "text/markdown", + "text/plain", + "application/json", + "application/yaml", ]); -// file-type returns undefined (no magic bytes) for plain-text formats like CSV -// and Markdown, so host-read needs an explicit text validation fallback. -const HOST_READ_TEXT_PLAIN_ALIASES = new Set(["text/csv", "text/markdown"]); // HTML remains deliberately outside the host-read allowlist pending a separate // security-boundary review, but extension-declared .html files still need to // fail closed instead of falling through to binary/media sniffing. const HOST_READ_DECLARED_TEXT_MIMES = new Set([...HOST_READ_TEXT_PLAIN_ALIASES, "text/html"]); const HOST_READ_DECLARED_TEXT_ERROR = - "hostReadCapability permits only validated plain-text CSV/Markdown documents " + + "hostReadCapability permits only validated plain-text documents " + "and trusted generated HTML reports for local reads"; const MB = 1024 * 1024; @@ -381,10 +391,10 @@ function assertHostReadMediaAllowed(params: { ) { return; } - // CSV / Markdown exception: file-type v22 returns undefined (not "text/plain") for - // plain-text buffers that have no binary magic bytes. Allow these formats when: + // Plain-text document exception: file-type v22 returns undefined (not "text/plain") + // for text buffers that have no binary magic bytes. Allow these formats when: // - sniffedMime is undefined (no binary signature detected by file-type) - // - The extension-derived MIME is text/csv or text/markdown (operator intent) + // - The extension-derived MIME is an allowed text/document MIME (operator intent) // - The buffer decodes as actual text instead of opaque binary bytes if ( !sniffedMime && @@ -407,7 +417,7 @@ function assertHostReadMediaAllowed(params: { } throw new LocalMediaAccessError( "path-not-allowed", - `Host-local media sends only allow buffer-verified images, audio, video, PDF, Office documents, archives, CSV, and Markdown (got ${sniffedMime ?? normalizedMime ?? "unknown"}).`, + `Host-local media sends only allow buffer-verified images, audio, video, PDF, Office documents, archives, and validated plain-text documents (got ${sniffedMime ?? normalizedMime ?? "unknown"}).`, ); }