From 249013522bc5d3fa7e94afc5e2804c7b06304002 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Tue, 30 Dec 2025 15:26:14 -0700 Subject: [PATCH] fix: enforce read_file fileRegex when files is JSON string --- .../tools/__tests__/validateToolUse.spec.ts | 23 ++++++ src/core/tools/validateToolUse.ts | 80 ++++++++++++++++--- 2 files changed, 93 insertions(+), 10 deletions(-) diff --git a/src/core/tools/__tests__/validateToolUse.spec.ts b/src/core/tools/__tests__/validateToolUse.spec.ts index 815db31a37..ebaebab00e 100644 --- a/src/core/tools/__tests__/validateToolUse.spec.ts +++ b/src/core/tools/__tests__/validateToolUse.spec.ts @@ -206,6 +206,29 @@ describe("mode-validator", () => { ).not.toThrow() }) + it("enforces read fileRegex restrictions when toolParams.files is a JSON string", () => { + const customModes: ModeConfig[] = [ + { + slug: "md-reader", + name: "Markdown Reader", + roleDefinition: "Read markdown only", + groups: [["read", { fileRegex: "\\.md$" }]] as const, + }, + ] + + expect(() => + validateToolUse("read_file", "md-reader", customModes, undefined, { + files: '[{"path":"src/index.ts"}]', + }), + ).toThrow(/can only read files matching pattern/) + + expect(() => + validateToolUse("read_file", "md-reader", customModes, undefined, { + files: '[{"path":"README.md"}]', + }), + ).not.toThrow() + }) + it("throws error when tool requirement is not met", () => { const requirements = { apply_diff: false } expect(() => validateToolUse("apply_diff", codeMode, [], requirements)).toThrow( diff --git a/src/core/tools/validateToolUse.ts b/src/core/tools/validateToolUse.ts index 995ffc91f1..86fc69cec5 100644 --- a/src/core/tools/validateToolUse.ts +++ b/src/core/tools/validateToolUse.ts @@ -78,6 +78,75 @@ function doesFileMatchRegex(filePath: string, pattern: string): boolean { } } +function tryParseJson(value: string): unknown | undefined { + const trimmed = value.trim() + if (!trimmed) { + return undefined + } + + // Avoid attempting to parse arbitrary strings. + if (!(trimmed.startsWith("{") || trimmed.startsWith("[") || trimmed.startsWith('"'))) { + return undefined + } + + try { + return JSON.parse(trimmed) as unknown + } catch { + return undefined + } +} + +function parseJsonStringDeep(value: string, maxDepth = 2): unknown { + let current: unknown = value + for (let i = 0; i < maxDepth; i++) { + if (typeof current !== "string") { + break + } + const parsed = tryParseJson(current) + if (parsed === undefined) { + break + } + current = parsed + } + return current +} + +function extractReadPathsFromFilesValue(value: unknown, paths: string[]): void { + if (Array.isArray(value)) { + for (const entry of value) { + if (typeof entry === "string" && entry.trim().length > 0) { + paths.push(entry.trim()) + continue + } + + if (typeof entry === "object" && entry !== null) { + const p = (entry as { path?: unknown }).path + if (typeof p === "string" && p.trim().length > 0) { + paths.push(p.trim()) + } + } + } + return + } + + // Support mis-typed payloads like: files: "[{ \"path\": \"README.md\" }]". + if (typeof value === "string") { + const parsed = parseJsonStringDeep(value) + if (parsed !== value) { + extractReadPathsFromFilesValue(parsed, paths) + } + return + } + + // Support nested payloads like: files: "{ \"files\": [...] }". + if (typeof value === "object" && value !== null) { + const nested = (value as { files?: unknown }).files + if (nested !== undefined) { + extractReadPathsFromFilesValue(nested, paths) + } + } +} + function extractReadFilePaths(toolParams: Record | undefined): string[] { if (!toolParams) { return [] @@ -87,16 +156,7 @@ function extractReadFilePaths(toolParams: Record | undefined): // Native protocol read_file: { files: [{ path: string }] } const files = (toolParams as { files?: unknown }).files - if (Array.isArray(files)) { - for (const entry of files) { - if (typeof entry === "object" && entry !== null) { - const p = (entry as { path?: unknown }).path - if (typeof p === "string" && p.trim().length > 0) { - paths.push(p.trim()) - } - } - } - } + extractReadPathsFromFilesValue(files, paths) // Legacy single-path read_file: { path: string } const legacyPath = (toolParams as { path?: unknown }).path