98659783c0
Block traversal, out-of-root absolute path, and symlink escapes for @file references, file:// URIs, and config skill file loading while logging rejected attempts. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
100 lines
2.4 KiB
TypeScript
100 lines
2.4 KiB
TypeScript
import { existsSync, readFileSync, statSync } from "fs"
|
|
import { isAbsolute, resolve } from "path"
|
|
import { isWithinProject } from "./contains-path"
|
|
import { log } from "./logger"
|
|
|
|
interface FileMatch {
|
|
fullMatch: string
|
|
filePath: string
|
|
start: number
|
|
end: number
|
|
}
|
|
|
|
const FILE_REFERENCE_PATTERN = /@([^\s@]+)/g
|
|
|
|
function findFileReferences(text: string): FileMatch[] {
|
|
const matches: FileMatch[] = []
|
|
let match: RegExpExecArray | null
|
|
|
|
FILE_REFERENCE_PATTERN.lastIndex = 0
|
|
|
|
while ((match = FILE_REFERENCE_PATTERN.exec(text)) !== null) {
|
|
matches.push({
|
|
fullMatch: match[0],
|
|
filePath: match[1],
|
|
start: match.index,
|
|
end: match.index + match[0].length,
|
|
})
|
|
}
|
|
|
|
return matches
|
|
}
|
|
|
|
function resolveFilePath(filePath: string, cwd: string): string {
|
|
if (isAbsolute(filePath)) {
|
|
return resolve(filePath)
|
|
}
|
|
|
|
return resolve(cwd, filePath)
|
|
}
|
|
|
|
function readFileContent(resolvedPath: string): string {
|
|
if (!existsSync(resolvedPath)) {
|
|
return `[file not found: ${resolvedPath}]`
|
|
}
|
|
|
|
const stat = statSync(resolvedPath)
|
|
if (stat.isDirectory()) {
|
|
return `[cannot read directory: ${resolvedPath}]`
|
|
}
|
|
|
|
const content = readFileSync(resolvedPath, "utf-8")
|
|
return content
|
|
}
|
|
|
|
export async function resolveFileReferencesInText(
|
|
text: string,
|
|
cwd: string = process.cwd(),
|
|
depth: number = 0,
|
|
maxDepth: number = 3
|
|
): Promise<string> {
|
|
if (depth >= maxDepth) {
|
|
return text
|
|
}
|
|
|
|
const matches = findFileReferences(text)
|
|
if (matches.length === 0) {
|
|
return text
|
|
}
|
|
|
|
const replacements = new Map<string, string>()
|
|
|
|
for (const match of matches) {
|
|
const resolvedPath = resolveFilePath(match.filePath, cwd)
|
|
|
|
if (!isWithinProject(resolvedPath, cwd)) {
|
|
log("[file-reference-resolver] Rejected file reference outside project root", {
|
|
filePath: match.filePath,
|
|
resolvedPath,
|
|
projectRoot: cwd,
|
|
})
|
|
replacements.set(match.fullMatch, `[path rejected: ${match.filePath}]`)
|
|
continue
|
|
}
|
|
|
|
const content = readFileContent(resolvedPath)
|
|
replacements.set(match.fullMatch, content)
|
|
}
|
|
|
|
let resolved = text
|
|
for (const [pattern, replacement] of replacements.entries()) {
|
|
resolved = resolved.replaceAll(pattern, replacement)
|
|
}
|
|
|
|
if (findFileReferences(resolved).length > 0 && depth + 1 < maxDepth) {
|
|
return resolveFileReferencesInText(resolved, cwd, depth + 1, maxDepth)
|
|
}
|
|
|
|
return resolved
|
|
}
|