Root cause
Path & link handling
The new TypeScript runtime added file-reference expansion with no authorized-root check.
How AI contributed: New attack surfacePublished Jul 17, 2026 in
microsoft/promptyAll 3 findings in this repository
Summary Prompty loaders expanded ${file:...} references in .prompty frontmatter without enforcing that the resolved path stayed within an authorized directory. An attacker-controlled prompt file could use path traversal or an absolute path to cause the host application to read files accessible to the process. Affected packages · PyPI prompty versions <= 2.0.0b1; fixed in 2.0.0b2 · npm @prompty/core versions <= 2.0.0-beta.1; fixed in 2.0.0-beta.2 · crates.io prompty versions <= 2.0.0-beta.1; fixed in 2.0.0-beta.2 · NuGet Prompty.Core versions <= 2.0.0-beta.1; fixed in 2.0.0-beta.2 Impact Applications that load untrusted .prompty files, user-provided prompt paths, or prompt bundles from less-trusted locations could disclose local files available to the application process when expanded values are logged, returned, or otherwise exposed. Remediation Upgrade to the fixed runtime version for your ecosystem. The fix makes file references secure by default: ${file:...} may only resolve within the directory tree containing the .prompty file. Host applications that need shared prompt assets outside that tree must explicitly provide allowed file roots through runtime load options. Absolute paths, .. traversal, and symlink escapes outside allowed roots are rejected. Fix details The patched runtimes canonicalize file-reference targets and allowed roots before reading referenced files, reject targets outside the prompt directory by default, and add regression coverage for traversal, absolute paths, explicit allowlists, and symlink escapes. The release commit is 88ac9948d7d37995edbb2f6d36913436626c39e1.
The TypeScript loader resolved file references relative to a prompt and read the result without proving that the target remained inside an authorized directory.
Root cause
The new TypeScript runtime added file-reference expansion with no authorized-root check.
Fix
The fix enforces canonical allowed roots and rechecks the real target to stop both traversal and symlink escape.
AI-assisted fix: GitHub Copilot · Seth Juarez
Case facts
@@ -0,0 +75,2 @@ selected from the new file+function makePreProcess(agentFile: string): (data: unknown) => unknown {+ const agentDir = dirname(agentFile);@@ -0,0 +112,13 @@ selected from the new file+ } else if (protocol === "file") {+ const filePath = resolve(agentDir, val);+ record[key] = loadFileContent(filePath);+ }+ }++ return record;+ };+}++function loadFileContent(path: string): unknown {+ const raw = readFileSync(path, "utf-8");+ const ext = extname(path).toLowerCase();@@ -8,12 +8,20 @@ * @module */ -import { readFileSync } from "node:fs";-import { resolve, dirname, extname } from "node:path";+import { existsSync, readFileSync, realpathSync } from "node:fs";+import { dirname, extname, isAbsolute, relative, resolve } from "node:path"; import matter from "gray-matter"; import { LoadContext, SaveContext } from "../model/context.js"; import { Prompty } from "../model/agent/prompty.js"; +export interface LoadOptions {+ /**+ * Additional directories that `${file:...}` references may read from.+ * The prompt file's directory is always allowed.+ */+ allowedFileRoots?: string[];+}+ // --------------------------------------------------------------------------- // Public API // ---------------------------------------------------------------------------@@ -22,12 +30,13 @@ import { Prompty } from "../model/agent/prompty.js"; * Load a `.prompty` file and return a typed `Prompty`. * * @param path - File system path to a `.prompty` file.+ * @param options - Optional load behavior, including additional allowed file roots. * @returns Fully typed Prompty definition. */-export function load(path: string): Prompty {+export function load(path: string, options: LoadOptions = {}): Prompty { const resolved = resolve(path); const raw = readFileSync(resolved, "utf-8");- return buildAgent(raw, resolved);+ return buildAgent(raw, resolved, options); } /**@@ -68,7 +77,7 @@ export function defaultSaveContext( // Internal pipeline // --------------------------------------------------------------------------- -function buildAgent(raw: string, filePath: string): Prompty {+function buildAgent(raw: string, filePath: string, options: LoadOptions): Prompty { // 1. Split frontmatter + body const { data, content } = matter(raw); @@ -80,7 +89,7 @@ function buildAgent(raw: string, filePath: string): Prompty { // 2. Load via Prompty.load() with preProcess for ${protocol:value} expansion const ctx = new LoadContext({- preProcess: makePreProcess(filePath) as (data: Record<string, unknown>) => Record<string, unknown>,+ preProcess: makePreProcess(filePath, options) as (data: Record<string, unknown>) => Record<string, unknown>, }); const agent = Prompty.load(frontmatter, ctx); @@ -105,9 +114,16 @@ function buildAgent(raw: string, filePath: string): Prompty { * - `${env:VAR_NAME}` — environment variable (required) * - `${env:VAR_NAME:default}` — environment variable with default * - `${file:relative/path}` — load file content (JSON/YAML/text)+ *+ * File references are limited to the prompt directory by default. Callers may+ * provide additional allowed roots via `allowedFileRoots`. */-function makePreProcess(agentFile: string): (data: unknown) => unknown {- const agentDir = dirname(agentFile);+function makePreProcess(agentFile: string, options: LoadOptions): (data: unknown) => unknown {+ const agentDir = realpathSync(dirname(agentFile));+ const allowedRoots = [+ agentDir,+ ...(options.allowedFileRoots ?? []).map((root) => canonicalizeExistingPath(resolve(root))),+ ]; return (data: unknown): unknown => { if (typeof data !== "object" || data === null || Array.isArray(data)) {@@ -144,7 +160,7 @@ function makePreProcess(agentFile: string): (data: unknown) => unknown { ); } } else if (protocol === "file") {- const filePath = resolve(agentDir, val);+ const filePath = resolveFileReference(agentDir, val, allowedRoots, key); record[key] = loadFileContent(filePath); } }@@ -153,6 +169,37 @@ function makePreProcess(agentFile: string): (data: unknown) => unknown { }; } +function resolveFileReference(agentDir: string, reference: string, allowedRoots: string[], key: string): string {+ const candidate = isAbsolute(reference) ? resolve(reference) : resolve(agentDir, reference);+ if (!isWithinAnyRoot(candidate, allowedRoots)) {+ throw new Error(+ `File reference '${reference}' resolves outside allowed roots for key '${key}'. Allowed roots: ${allowedRoots.join(", ")}`,+ );+ }+ if (!existsSync(candidate)) {+ throw new Error(`Referenced file '${reference}' not found for key '${key}' (resolved to ${candidate})`);+ }++ const resolved = realpathSync(candidate);+ if (!isWithinAnyRoot(resolved, allowedRoots)) {+ throw new Error(+ `File reference '${reference}' resolves outside allowed roots for key '${key}'. Allowed roots: ${allowedRoots.join(", ")}`,+ );+ }+ return resolved;+}++function isWithinAnyRoot(path: string, roots: string[]): boolean {+ return roots.some((root) => {+ const rel = relative(root, path);+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));+ });+}++function canonicalizeExistingPath(path: string): string {+ return existsSync(path) ? realpathSync(path) : resolve(path);+}+ function loadFileContent(path: string): unknown { const raw = readFileSync(path, "utf-8"); const ext = extname(path).toLowerCase();AI-assisted change 36d253c0d7c6d218017e5f44273b3d2c5cf9993bd0d4a93ec0e4974e23de788f · Fix 7f145a72d5f6281dcb973092bafd855cf7143b637d692de2b1e629b355408571