fix(security): confine file resolution to project roots

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>
This commit is contained in:
YeonGyu-Kim
2026-04-02 14:55:35 +09:00
parent a637cca702
commit 98659783c0
8 changed files with 287 additions and 15 deletions
+33
View File
@@ -0,0 +1,33 @@
import { existsSync, realpathSync } from "fs"
import { basename, dirname, isAbsolute, join, normalize, relative, resolve } from "path"
function toCanonicalPath(pathToNormalize: string): string {
const resolvedPath = resolve(pathToNormalize)
if (existsSync(resolvedPath)) {
try {
return normalize(realpathSync.native(resolvedPath))
} catch {
return normalize(resolvedPath)
}
}
const parentDirectory = dirname(resolvedPath)
const canonicalParentDirectory = existsSync(parentDirectory)
? realpathSync.native(parentDirectory)
: parentDirectory
return normalize(join(canonicalParentDirectory, basename(resolvedPath)))
}
export function containsPath(rootPath: string, candidatePath: string): boolean {
const canonicalRootPath = toCanonicalPath(rootPath)
const canonicalCandidatePath = toCanonicalPath(candidatePath)
const relativePath = relative(canonicalRootPath, canonicalCandidatePath)
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath))
}
export function isWithinProject(candidatePath: string, projectRoot: string): boolean {
return containsPath(projectRoot, candidatePath)
}
@@ -0,0 +1,72 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test"
import { mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { resolveFileReferencesInText } from "./file-reference-resolver"
describe("resolveFileReferencesInText", () => {
const fixtureRoot = join(tmpdir(), `file-reference-resolver-${Date.now()}`)
const workspaceDir = join(fixtureRoot, "workspace")
const notesDir = join(workspaceDir, "notes")
const allowedFilePath = join(notesDir, "allowed.txt")
const linkedSecretPath = join(notesDir, "linked-secret.txt")
const outsideFilePath = join(fixtureRoot, "secret.txt")
beforeAll(() => {
mkdirSync(notesDir, { recursive: true })
writeFileSync(allowedFilePath, "allowed-content", "utf8")
writeFileSync(outsideFilePath, "secret-content", "utf8")
symlinkSync(outsideFilePath, linkedSecretPath)
})
afterAll(() => {
rmSync(fixtureRoot, { recursive: true, force: true })
})
test("resolves file references within cwd", async () => {
//#given
const input = "Read @notes/allowed.txt before continuing"
//#when
const resolved = await resolveFileReferencesInText(input, workspaceDir)
//#then
expect(resolved).toContain("allowed-content")
})
test("rejects traversal references that escape cwd", async () => {
//#given
const input = "Read @../secret.txt before continuing"
//#when
const resolved = await resolveFileReferencesInText(input, workspaceDir)
//#then
expect(resolved).toContain("[path rejected:")
expect(resolved).not.toContain("secret-content")
})
test("rejects absolute references outside cwd", async () => {
//#given
const input = `Read @${outsideFilePath} before continuing`
//#when
const resolved = await resolveFileReferencesInText(input, workspaceDir)
//#then
expect(resolved).toContain("[path rejected:")
expect(resolved).not.toContain("secret-content")
})
test("rejects symlink references that escape cwd", async () => {
//#given
const input = "Read @notes/linked-secret.txt before continuing"
//#when
const resolved = await resolveFileReferencesInText(input, workspaceDir)
//#then
expect(resolved).toContain("[path rejected:")
expect(resolved).not.toContain("secret-content")
})
})
+17 -3
View File
@@ -1,5 +1,7 @@
import { existsSync, readFileSync, statSync } from "fs"
import { join, isAbsolute } from "path"
import { isAbsolute, resolve } from "path"
import { isWithinProject } from "./contains-path"
import { log } from "./logger"
interface FileMatch {
fullMatch: string
@@ -30,9 +32,10 @@ function findFileReferences(text: string): FileMatch[] {
function resolveFilePath(filePath: string, cwd: string): string {
if (isAbsolute(filePath)) {
return filePath
return resolve(filePath)
}
return join(cwd, filePath)
return resolve(cwd, filePath)
}
function readFileContent(resolvedPath: string): string {
@@ -68,6 +71,17 @@ export async function resolveFileReferencesInText(
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)
}
+1
View File
@@ -1,5 +1,6 @@
export * from "./frontmatter"
export * from "./command-executor"
export * from "./contains-path"
export * from "./file-reference-resolver"
export * from "./model-sanitizer"
export * from "./logger"