diff --git a/runtime/csharp/Prompty.Anthropic/Prompty.Anthropic.csproj b/runtime/csharp/Prompty.Anthropic/Prompty.Anthropic.csproj
index cff64b5b0..24346c1cf 100644
--- a/runtime/csharp/Prompty.Anthropic/Prompty.Anthropic.csproj
+++ b/runtime/csharp/Prompty.Anthropic/Prompty.Anthropic.csproj
@@ -4,7 +4,7 @@
net9.0
enable
enable
- 2.0.0-beta.3
+ 2.0.0-beta.4
Prompty.Anthropic
Microsoft
Anthropic provider for Prompty — executor and processor for Claude models via the Anthropic Messages API.
diff --git a/runtime/csharp/Prompty.Core.Tests/LoaderTests.cs b/runtime/csharp/Prompty.Core.Tests/LoaderTests.cs
index 8eaa3bd3a..f874eab7f 100644
--- a/runtime/csharp/Prompty.Core.Tests/LoaderTests.cs
+++ b/runtime/csharp/Prompty.Core.Tests/LoaderTests.cs
@@ -348,6 +348,52 @@ public void Load_FileRef_SymlinkEscape_Throws()
}
}
+ [Fact]
+ public void Load_FileRef_IntermediateDirectorySymlinkEscape_Throws()
+ {
+ var root = Directory.CreateTempSubdirectory("prompty-loader-");
+ try
+ {
+ var promptDir = Directory.CreateDirectory(Path.Combine(root.FullName, "prompts"));
+ var externalDir = Directory.CreateDirectory(Path.Combine(root.FullName, "external"));
+ File.WriteAllText(Path.Combine(externalDir.FullName, "secret.txt"), "secret");
+ Directory.CreateSymbolicLink(Path.Combine(promptDir.FullName, "assets"), externalDir.FullName);
+
+ var prompt = Path.Combine(promptDir.FullName, "bad.prompty");
+ File.WriteAllText(prompt, "---\nname: bad\ndescription: \"${file:assets/secret.txt}\"\n---\nHello\n");
+
+ var ex = Assert.Throws(() => PromptyLoader.Load(prompt));
+ Assert.Contains("outside allowed roots", ex.Message);
+ }
+ finally
+ {
+ root.Delete(recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task LoadAsync_FileRef_IntermediateDirectorySymlinkEscape_Throws()
+ {
+ var root = Directory.CreateTempSubdirectory("prompty-loader-");
+ try
+ {
+ var promptDir = Directory.CreateDirectory(Path.Combine(root.FullName, "prompts"));
+ var externalDir = Directory.CreateDirectory(Path.Combine(root.FullName, "external"));
+ File.WriteAllText(Path.Combine(externalDir.FullName, "secret.txt"), "secret");
+ Directory.CreateSymbolicLink(Path.Combine(promptDir.FullName, "assets"), externalDir.FullName);
+
+ var prompt = Path.Combine(promptDir.FullName, "bad.prompty");
+ File.WriteAllText(prompt, "---\nname: bad\ndescription: \"${file:assets/secret.txt}\"\n---\nHello\n");
+
+ var ex = await Assert.ThrowsAsync(() => PromptyLoader.LoadAsync(prompt));
+ Assert.Contains("outside allowed roots", ex.Message);
+ }
+ finally
+ {
+ root.Delete(recursive: true);
+ }
+ }
+
// --- Tools ---
[Fact]
diff --git a/runtime/csharp/Prompty.Core/Prompty.Core.csproj b/runtime/csharp/Prompty.Core/Prompty.Core.csproj
index 0298cb923..151ca0d14 100644
--- a/runtime/csharp/Prompty.Core/Prompty.Core.csproj
+++ b/runtime/csharp/Prompty.Core/Prompty.Core.csproj
@@ -4,7 +4,7 @@
net9.0
enable
enable
- 2.0.0-beta.3
+ 2.0.0-beta.4
Prompty.Core
Microsoft
Prompty is an asset class and format for LLM prompts. Core library with loader, pipeline, renderers, parsers, and tracing.
diff --git a/runtime/csharp/Prompty.Core/ReferenceResolver.cs b/runtime/csharp/Prompty.Core/ReferenceResolver.cs
index 1f613d2c1..3e765f42d 100644
--- a/runtime/csharp/Prompty.Core/ReferenceResolver.cs
+++ b/runtime/csharp/Prompty.Core/ReferenceResolver.cs
@@ -171,18 +171,42 @@ private static bool IsWithinRoot(string path, string root)
private static string GetCanonicalPath(string path)
{
var fullPath = Path.GetFullPath(path);
- if (File.Exists(fullPath))
+ var root = Path.GetPathRoot(fullPath);
+ if (string.IsNullOrEmpty(root))
{
- var file = new FileInfo(fullPath);
- var target = file.ResolveLinkTarget(returnFinalTarget: true);
- return Path.GetFullPath(target?.FullName ?? file.FullName);
+ return fullPath;
}
- if (Directory.Exists(fullPath))
+
+ var canonicalPath = root;
+ var components = fullPath[root.Length..].Split(
+ [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
+ StringSplitOptions.RemoveEmptyEntries);
+
+ foreach (var component in components)
+ {
+ canonicalPath = Path.Combine(canonicalPath, component);
+ var target = ResolveLinkTarget(canonicalPath);
+ if (target is not null)
+ {
+ canonicalPath = Path.GetFullPath(target.FullName);
+ }
+ }
+
+ return canonicalPath;
+ }
+
+ private static FileSystemInfo? ResolveLinkTarget(string path)
+ {
+ if (Directory.Exists(path))
{
- var directory = new DirectoryInfo(fullPath);
- var target = directory.ResolveLinkTarget(returnFinalTarget: true);
- return Path.GetFullPath(target?.FullName ?? directory.FullName);
+ return new DirectoryInfo(path).ResolveLinkTarget(returnFinalTarget: true);
}
- return fullPath;
+
+ if (File.Exists(path))
+ {
+ return new FileInfo(path).ResolveLinkTarget(returnFinalTarget: true);
+ }
+
+ return null;
}
}
diff --git a/runtime/csharp/Prompty.Foundry/Prompty.Foundry.csproj b/runtime/csharp/Prompty.Foundry/Prompty.Foundry.csproj
index d67aae80a..ec3d0ba7f 100644
--- a/runtime/csharp/Prompty.Foundry/Prompty.Foundry.csproj
+++ b/runtime/csharp/Prompty.Foundry/Prompty.Foundry.csproj
@@ -6,7 +6,7 @@
enable
OPENAI001;CS1591
true
- 2.0.0-beta.3
+ 2.0.0-beta.4
Prompty.Foundry
Microsoft
Microsoft Foundry (Azure OpenAI) provider for Prompty — executor and processor for Azure-hosted models.
diff --git a/runtime/csharp/Prompty.OpenAI/Prompty.OpenAI.csproj b/runtime/csharp/Prompty.OpenAI/Prompty.OpenAI.csproj
index 91968fb23..6c8c5d436 100644
--- a/runtime/csharp/Prompty.OpenAI/Prompty.OpenAI.csproj
+++ b/runtime/csharp/Prompty.OpenAI/Prompty.OpenAI.csproj
@@ -6,7 +6,7 @@
enable
OPENAI001;CS1591
true
- 2.0.0-beta.3
+ 2.0.0-beta.4
Prompty.OpenAI
Microsoft
OpenAI provider for Prompty — executor and processor for OpenAI chat, embedding, image, and agent APIs.