fix: enforce read_file fileRegex when files is JSON string

This commit is contained in:
Hannes Rudolph 2025-12-30 15:26:14 -07:00
parent ceb026efd9
commit 249013522b
2 changed files with 93 additions and 10 deletions

View file

@ -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(

View file

@ -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<string, unknown> | undefined): string[] {
if (!toolParams) {
return []
@ -87,16 +156,7 @@ function extractReadFilePaths(toolParams: Record<string, unknown> | 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