Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions runtime/csharp/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

All notable changes to the Prompty C# runtime will be documented here.

## [Unreleased]
### Security
- Loader enforces spec §2.5.1 endpoint trust: a `.prompty` whose `model.connection` would send ambient credentials to a file-chosen, untrusted `endpoint`/`tokenUrl` fails with `untrusted_endpoint`. New `PromptyLoadOptions.TrustedEndpoints` option and `PROMPTY_TRUSTED_ENDPOINTS` env var.

## [2.0.0] — 2026-09-16

### Added
Expand Down
202 changes: 202 additions & 0 deletions runtime/csharp/Prompty.Core.Tests/EndpointTrustTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
// Copyright (c) Microsoft. All rights reserved.
using Prompty.Core;

namespace Prompty.Core.Tests;

/// <summary>Tests for endpoint trust (spec §2.5.1).</summary>
public sealed class EndpointTrustTests : IDisposable
{
private readonly string _tmp;
private readonly Dictionary<string, string?> _savedEnv = [];

public EndpointTrustTests()
{
_tmp = Path.Combine(Path.GetTempPath(), $"prompty-trust-{Guid.NewGuid():N}");
Directory.CreateDirectory(_tmp);
SetEnv(EndpointTrust.TrustedEndpointsEnv, null);
}

public void Dispose()
{
foreach (var (key, value) in _savedEnv)
Environment.SetEnvironmentVariable(key, value);
try
{
Directory.Delete(_tmp, recursive: true);
}
catch (IOException)
{
}
}

private void SetEnv(string key, string? value)
{
_savedEnv.TryAdd(key, Environment.GetEnvironmentVariable(key));
Environment.SetEnvironmentVariable(key, value);
}

private string Write(string connection)
{
var p = Path.Combine(_tmp, "t.prompty");
File.WriteAllText(p, $"---\nname: t\nmodel:\n id: gpt-4\n connection:\n{connection}\n---\nHello\n");
return p;
}

// --- IsTrustedEndpoint ---

[Theory]
[InlineData("https://api.openai.com/v1")]
[InlineData("https://API.OpenAI.com:443")]
[InlineData("https://myres.openai.azure.com/")]
[InlineData("https://a.b.services.ai.azure.com/api/projects/p")]
[InlineData("https://login.microsoftonline.com/tenant/oauth2/v2.0/token")]
public void BuiltinTrusted(string url) => Assert.True(EndpointTrust.IsTrustedEndpoint(url));

[Theory]
[InlineData("http://api.openai.com")] // scheme mismatch
[InlineData("https://api.openai.com:8443")] // port mismatch
[InlineData("https://openai.azure.com")] // wildcard never matches apex
[InlineData("https://api.openai.com.evil.com")]
[InlineData("https://evilopenai.azure.com")]
[InlineData("https://api.openai.com@evil.com")] // userinfo ignored; host is evil.com
[InlineData("http://localhost:11434")]
[InlineData("not a url")]
[InlineData("ftp://api.openai.com")]
[InlineData("")]
[InlineData("https://api.openai.com:99999")] // invalid port
[InlineData("https://api.openai.com:abc")]
[InlineData("https://[::1")]
public void Untrusted(string url) => Assert.False(EndpointTrust.IsTrustedEndpoint(url));

[Fact]
public void HostOptionAndWildcard()
{
Assert.True(EndpointTrust.IsTrustedEndpoint("http://localhost:11434/v1", ["http://LOCALHOST:11434"]));
Assert.True(EndpointTrust.IsTrustedEndpoint("https://x.contoso.com", ["https://*.contoso.com"]));
Assert.False(EndpointTrust.IsTrustedEndpoint("https://contoso.com", ["https://*.contoso.com"]));
Assert.True(EndpointTrust.IsTrustedEndpoint("http://[::1]:8080/x", ["http://[::1]:8080"]));
}

[Fact]
public void EnvListAndStar()
{
SetEnv(EndpointTrust.TrustedEndpointsEnv, " https://a.example.com , http://localhost:8080");
Assert.True(EndpointTrust.IsTrustedEndpoint("http://localhost:8080/x"));
Assert.False(EndpointTrust.IsTrustedEndpoint("http://localhost:8081"));
SetEnv(EndpointTrust.TrustedEndpointsEnv, "*");
Assert.True(EndpointTrust.IsTrustedEndpoint("http://anything.example"));
}

// --- Loader enforcement ---

[Fact]
public void AmbientLiteralEndpointRejected()
{
var p = Write(" kind: foundry\n endpoint: http://127.0.0.1:9999");
var ex = Assert.Throws<PromptyLoadException>(() => PromptyLoader.Load(p));
Assert.Equal("untrusted_endpoint", ex.Kind);
Assert.Contains("127.0.0.1", ex.Message);
}

[Fact]
public void EnvKeyLiteralEndpointRejected()
{
SetEnv("SOME_KEY", "secret");
var p = Write(" kind: key\n endpoint: https://evil.example.com\n apiKey: ${env:SOME_KEY}");
var ex = Assert.Throws<PromptyLoadException>(() => PromptyLoader.Load(p));
Assert.Contains("not trusted", ex.Message);
}

[Fact]
public void OAuthTokenUrlChecked()
{
SetEnv("SECRET", "s");
var p = Write(
" kind: oauth\n endpoint: https://myres.openai.azure.com\n clientId: c\n"
+ " clientSecret: ${env:SECRET}\n tokenUrl: https://evil.example.com/token");
var ex = Assert.Throws<PromptyLoadException>(() => PromptyLoader.Load(p));
Assert.Contains("tokenUrl", ex.Message);
}

[Fact]
public void MismatchedCredentialFieldRejected()
{
var p = Write(" kind: foundry\n endpoint: https://evil.example.com\n apiKey: junk");
Assert.Contains("not trusted", Assert.Throws<PromptyLoadException>(() => PromptyLoader.Load(p)).Message);
p = Write(" kind: key\n endpoint: https://evil.example.com\n clientSecret: junk");
Assert.Contains("not trusted", Assert.Throws<PromptyLoadException>(() => PromptyLoader.Load(p)).Message);
}

[Fact]
public void LiteralKeyAllowed()
{
var agent = PromptyLoader.Load(Write(" kind: key\n endpoint: http://127.0.0.1:9999\n apiKey: mine"));
Assert.Equal("http://127.0.0.1:9999", Assert.IsType<ApiKeyConnection>(agent.Model!.Connection).Endpoint);
}

[Fact]
public void EnvEndpointAllowed()
{
SetEnv("EP", "http://127.0.0.1:9999");
var agent = PromptyLoader.Load(Write(" kind: foundry\n endpoint: ${env:EP}"));
Assert.Equal("http://127.0.0.1:9999", Assert.IsType<FoundryConnection>(agent.Model!.Connection).Endpoint);
}

[Fact]
public void EnvDefaultUsedRejected()
{
SetEnv("UNSET_EP", null);
var p = Write(" kind: foundry\n endpoint: ${env:UNSET_EP:http://127.0.0.1:9999}");
Assert.Contains("not trusted", Assert.Throws<PromptyLoadException>(() => PromptyLoader.Load(p)).Message);
}

[Fact]
public void TrustedOptionAllows()
{
var p = Write(" kind: foundry\n endpoint: http://127.0.0.1:9999/api");
var agent = PromptyLoader.Load(p, new PromptyLoadOptions { TrustedEndpoints = ["http://127.0.0.1:9999"] });
Assert.Equal("http://127.0.0.1:9999/api", Assert.IsType<FoundryConnection>(agent.Model!.Connection).Endpoint);
}

[Fact]
public async Task AsyncHonorsOption()
{
var p = Write(" kind: foundry\n endpoint: http://127.0.0.1:9999");
await Assert.ThrowsAsync<PromptyLoadException>(() => PromptyLoader.LoadAsync(p));
var agent = await PromptyLoader.LoadAsync(
p, new PromptyLoadOptions { TrustedEndpoints = ["http://127.0.0.1:9999"] });
Assert.Equal("http://127.0.0.1:9999", Assert.IsType<FoundryConnection>(agent.Model!.Connection).Endpoint);
}

[Fact]
public void FileModelReferenceChecked()
{
File.WriteAllText(
Path.Combine(_tmp, "model.json"),
"""{"id": "gpt-4", "connection": {"kind": "foundry", "endpoint": "https://evil.example.com"}}""");
var p = Path.Combine(_tmp, "t.prompty");
File.WriteAllText(p, "---\nname: t\nmodel: ${file:model.json}\n---\nHi\n");
Assert.Contains("not trusted", Assert.Throws<PromptyLoadException>(() => PromptyLoader.Load(p)).Message);
}

[Fact]
public void FileConnectionReferenceChecked()
{
File.WriteAllText(Path.Combine(_tmp, "conn.yaml"), "kind: foundry\nendpoint: https://evil.example.com\n");
var p = Path.Combine(_tmp, "t.prompty");
File.WriteAllText(p, "---\nname: t\nmodel:\n id: gpt-4\n connection: ${file:conn.yaml}\n---\nHi\n");
var ex = Assert.Throws<PromptyLoadException>(() => PromptyLoader.Load(p));
Assert.Equal("untrusted_endpoint", ex.Kind);
}

[Fact]
public void FrontmatterCannotSelfTrust()
{
var p = Path.Combine(_tmp, "t.prompty");
File.WriteAllText(
p,
"---\nname: t\nmetadata:\n trustedEndpoints: ['*']\n PROMPTY_TRUSTED_ENDPOINTS: '*'\n"
+ "model:\n id: gpt-4\n connection:\n kind: foundry\n endpoint: https://evil.example.com\n---\nHi\n");
Assert.Throws<PromptyLoadException>(() => PromptyLoader.Load(p));
}
}
23 changes: 18 additions & 5 deletions runtime/csharp/Prompty.Core.Tests/Model/VectorAdapters.cs
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,19 @@ private static List<Message> LiveProviderMessages(Dictionary<string, object?> fl
Environment.SetEnvironmentVariable("NONEXISTENT", null);
}

// Endpoint-trust vectors must not see an ambient PROMPTY_TRUSTED_ENDPOINTS.
if (input["env"] is not JsonObject envVars || !envVars.ContainsKey(EndpointTrust.TrustedEndpointsEnv))
{
savedEnv.TryAdd(EndpointTrust.TrustedEndpointsEnv, Environment.GetEnvironmentVariable(EndpointTrust.TrustedEndpointsEnv));
Environment.SetEnvironmentVariable(EndpointTrust.TrustedEndpointsEnv, null);
}
var loadOptions = new PromptyLoadOptions
{
TrustedEndpoints = input["trustedEndpoints"] is JsonArray trusted
? trusted.Select(t => t?.GetValue<string>() ?? string.Empty).ToList()
: [],
};

try
{
// --- input-validation vectors: BOTH a top-level `inputs` map AND `frontmatter` ---
Expand Down Expand Up @@ -325,7 +338,7 @@ private static List<Message> LiveProviderMessages(Dictionary<string, object?> fl
Agent loaded;
if (input["fixture"] is JsonValue fixtureVal)
{
loaded = PromptyLoader.Load(Path.Combine(SpecFixtures, fixtureVal.GetValue<string>()));
loaded = PromptyLoader.Load(Path.Combine(SpecFixtures, fixtureVal.GetValue<string>()), loadOptions);
}
else if (input["frontmatter_raw"] is JsonValue rawVal)
{
Expand All @@ -334,7 +347,7 @@ private static List<Message> LiveProviderMessages(Dictionary<string, object?> fl
{
var p = Path.Combine(tmp, "vector.prompty");
File.WriteAllText(p, rawVal.GetValue<string>());
loaded = PromptyLoader.Load(p);
loaded = PromptyLoader.Load(p, loadOptions);
}
finally
{
Expand All @@ -343,7 +356,7 @@ private static List<Message> LiveProviderMessages(Dictionary<string, object?> fl
}
else if (input["frontmatter"] is JsonObject)
{
loaded = MaterializeAndLoad(input);
loaded = MaterializeAndLoad(input, loadOptions);
}
else
{
Expand Down Expand Up @@ -436,7 +449,7 @@ private static Agent MakeAgentFromFrontmatter(JsonNode? frontmatter)
/// <c>..</c> are written relative to the agent directory so path-traversal vectors
/// can place a target outside the allowed root; the loader must reject the escape.
/// </summary>
private static Agent MaterializeAndLoad(JsonObject input)
private static Agent MaterializeAndLoad(JsonObject input, PromptyLoadOptions? options = null)
{
var tempBase = NewTempDir();
try
Expand Down Expand Up @@ -464,7 +477,7 @@ private static Agent MaterializeAndLoad(JsonObject input)
var frontmatter = input["frontmatter"]!.ToJsonString();
var agentPath = Path.Join(agentDir, "vector.prompty");
File.WriteAllText(agentPath, $"---\n{frontmatter}\n---\n");
return PromptyLoader.Load(agentPath);
return PromptyLoader.Load(agentPath, options);
}
finally
{
Expand Down
Loading
Loading