diff --git a/src/tools/platform/files.ts b/src/tools/platform/files.ts index 944f754..209d157 100644 --- a/src/tools/platform/files.ts +++ b/src/tools/platform/files.ts @@ -202,11 +202,27 @@ interface WriteInput { description?: string; } -function handleWrite(registryPath: string, filesDir: string, args: WriteInput): object { +function handleWrite( + registryPath: string, + filesDir: string, + args: WriteInput, + maxFileSize: number, +): object { ensureDir(filesDir); const id = generateId(); const decoded = Buffer.from(args.base64_data, "base64"); + + // Defense-in-depth: enforce the configured per-file size limit at the point + // of write. The API body-limit middleware caps HTTP uploads, but + // agent-initiated tool calls reach this function without traversing that + // middleware — so the limit must hold here independently. + if (decoded.length > maxFileSize) { + throw new Error( + `File "${args.filename}" (${decoded.length} bytes) exceeds limit of ${maxFileSize} bytes`, + ); + } + const filePath = join(filesDir, `${id}_${args.filename}`); writeFileSync(filePath, decoded); @@ -450,7 +466,10 @@ export function createFilesSource(runtime: Runtime): InlineSource { handler: async (input: Record): Promise => { try { const { filesDir, registryPath } = getFilePaths(); - return ok(handleWrite(registryPath, filesDir, input as unknown as WriteInput)); + const { maxFileSize } = runtime.getFilesConfig(); + return ok( + handleWrite(registryPath, filesDir, input as unknown as WriteInput, maxFileSize), + ); } catch (err) { return fail(err instanceof Error ? err.message : String(err)); } diff --git a/test/unit/tools/platform-files.test.ts b/test/unit/tools/platform-files.test.ts new file mode 100644 index 0000000..4ee09c0 --- /dev/null +++ b/test/unit/tools/platform-files.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { FileConfig } from "../../../src/files/types.ts"; +import { DEFAULT_FILE_CONFIG } from "../../../src/files/types.ts"; +import type { Runtime } from "../../../src/runtime/runtime.ts"; +import { createFilesSource } from "../../../src/tools/platform/files.ts"; + +function makeStubRuntime(workDir: string, config: Partial = {}): Runtime { + const merged = { ...DEFAULT_FILE_CONFIG, ...config }; + return { + getWorkspaceScopedDir: () => workDir, + getFilesConfig: () => merged, + } as unknown as Runtime; +} + +function extractText(result: { content: { type: string; text?: string }[] }): string { + return result.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); +} + +describe("platform files source — write size enforcement", () => { + let workDir: string; + + beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), "nb-files-test-")); + mkdirSync(join(workDir, "files"), { recursive: true }); + }); + + afterEach(() => { + rmSync(workDir, { recursive: true, force: true }); + }); + + test("write succeeds when decoded size is within maxFileSize", async () => { + const runtime = makeStubRuntime(workDir, { maxFileSize: 1024 }); + const source = createFilesSource(runtime); + + const payload = Buffer.from("hello, world").toString("base64"); + const result = await source.execute("write", { + filename: "hello.txt", + base64_data: payload, + mime_type: "text/plain", + }); + + expect(result.isError).toBe(false); + }); + + test("write rejects when decoded size exceeds maxFileSize", async () => { + const runtime = makeStubRuntime(workDir, { maxFileSize: 8 }); + const source = createFilesSource(runtime); + + // 16 bytes decoded — over the 8-byte limit + const payload = Buffer.from("0123456789abcdef").toString("base64"); + const result = await source.execute("write", { + filename: "too-big.bin", + base64_data: payload, + mime_type: "application/octet-stream", + }); + + expect(result.isError).toBe(true); + expect(extractText(result)).toContain("exceeds limit"); + expect(extractText(result)).toContain("too-big.bin"); + }); + + test("write uses runtime config override (not just defaults)", async () => { + const runtime = makeStubRuntime(workDir, { maxFileSize: 100 }); + const source = createFilesSource(runtime); + + // 50-byte decoded — under the 100 limit. Pins the runtime config as the + // source of truth (not a hard-coded default). + const payload = Buffer.from("x".repeat(50)).toString("base64"); + const result = await source.execute("write", { + filename: "ok.txt", + base64_data: payload, + mime_type: "text/plain", + }); + + expect(result.isError).toBe(false); + }); +});