From 931db287ced12352b119f08f09ec94708c998fce Mon Sep 17 00:00:00 2001 From: Seth Juarez Date: Mon, 20 Jul 2026 09:52:51 -0700 Subject: [PATCH 1/3] fix(csharp): resolve intermediate symlinks in file refs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3ecadcae-f3dd-4e5f-9411-69eb69340d10 --- .../csharp/Prompty.Core.Tests/LoaderTests.cs | 23 ++++++++++ .../csharp/Prompty.Core/ReferenceResolver.cs | 42 +++++++++++++++---- 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/runtime/csharp/Prompty.Core.Tests/LoaderTests.cs b/runtime/csharp/Prompty.Core.Tests/LoaderTests.cs index 8eaa3bd3a..6130d1c5d 100644 --- a/runtime/csharp/Prompty.Core.Tests/LoaderTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/LoaderTests.cs @@ -348,6 +348,29 @@ 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); + } + } + // --- Tools --- [Fact] 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; } } From 9c8e9d4bf14ddda1d48cb230375645f2f9440123 Mon Sep 17 00:00:00 2001 From: Seth Juarez Date: Mon, 20 Jul 2026 09:54:03 -0700 Subject: [PATCH 2/3] chore(csharp): release v2.0.0-beta.4 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3ecadcae-f3dd-4e5f-9411-69eb69340d10 --- runtime/csharp/Prompty.Anthropic/Prompty.Anthropic.csproj | 2 +- runtime/csharp/Prompty.Core/Prompty.Core.csproj | 2 +- runtime/csharp/Prompty.Foundry/Prompty.Foundry.csproj | 2 +- runtime/csharp/Prompty.OpenAI/Prompty.OpenAI.csproj | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) 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/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.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. From 87e2ff0e1c4362a3ee6700b231723a33103a5371 Mon Sep 17 00:00:00 2001 From: Seth Juarez Date: Mon, 20 Jul 2026 10:14:55 -0700 Subject: [PATCH 3/3] test(csharp): cover async symlink escapes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3ecadcae-f3dd-4e5f-9411-69eb69340d10 --- .../csharp/Prompty.Core.Tests/LoaderTests.cs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/runtime/csharp/Prompty.Core.Tests/LoaderTests.cs b/runtime/csharp/Prompty.Core.Tests/LoaderTests.cs index 6130d1c5d..f874eab7f 100644 --- a/runtime/csharp/Prompty.Core.Tests/LoaderTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/LoaderTests.cs @@ -371,6 +371,29 @@ public void Load_FileRef_IntermediateDirectorySymlinkEscape_Throws() } } + [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]