")
}
@@ -473,19 +481,19 @@ func unwrapProperties(data map[string]any) map[string]any {
return data
}
-func loadFromRaw(raw string) (prompty.Agent, error) {
+func loadFromRaw(raw string, opts prompty.LoadOptions) (prompty.Agent, error) {
tmp, err := os.MkdirTemp("", "prompty-load-")
if err != nil {
return prompty.Agent{}, err
}
defer os.RemoveAll(tmp)
- return prompty.LoadPromptyContent(strings.ReplaceAll(raw, "\r\n", "\n"), tmp, []string{tmp})
+ return prompty.LoadPromptyContentWithOptions(strings.ReplaceAll(raw, "\r\n", "\n"), tmp, []string{tmp}, opts)
}
// materializeAndLoad writes the vector's files (honoring agent_subdir and
// allowing `..` keys so path-traversal vectors can plant a target outside the
// allowed root), resolves references against the agent directory, and loads.
-func materializeAndLoad(input map[string]any) (prompty.Agent, error) {
+func materializeAndLoad(input map[string]any, opts prompty.LoadOptions) (prompty.Agent, error) {
tempBase, err := os.MkdirTemp("", "prompty-load-")
if err != nil {
return prompty.Agent{}, err
@@ -516,6 +524,9 @@ func materializeAndLoad(input map[string]any) (prompty.Agent, error) {
frontmatter := toMapAny(input["frontmatter"])
roots := []string{canonicalDir(agentDir)}
+ if err := prompty.CheckEndpointTrust(frontmatter, agentDir, roots, opts.TrustedEndpoints); err != nil {
+ return prompty.Agent{}, err
+ }
if err := prompty.ResolveReferences(frontmatter, agentDir, roots); err != nil {
return prompty.Agent{}, err
}
@@ -647,6 +658,8 @@ func loadErrorPayload(err error) (map[string]any, bool) {
kind = "invalid_template"
case "required_input":
kind = "missing_required_input"
+ case "untrusted_endpoint":
+ kind = "untrusted_endpoint"
default:
return nil, false
}
diff --git a/runtime/java/README.md b/runtime/java/README.md
index 2d691db36..3ef0b6966 100644
--- a/runtime/java/README.md
+++ b/runtime/java/README.md
@@ -63,6 +63,20 @@ cd runtime/java
./gradlew :prompty:test # unit tests for the core module only
```
+## Endpoint trust
+
+A `.prompty` file is untrusted input. When its `model.connection` carries no
+file-supplied credential (a literal `apiKey` on `kind: key`), executors fall back to ambient credentials,
+so the loader refuses — with `LoadException.Kind.UNTRUSTED_ENDPOINT` — any
+file-chosen `endpoint` / `tokenUrl` whose origin is not trusted (spec §2.5.1).
+Built-in provider origins are always trusted; hosts add more through
+`LoadOptions.trustedEndpoints` or the comma-separated
+`PROMPTY_TRUSTED_ENDPOINTS` variable (`*` trusts everything):
+
+```java
+Agent agent = Loader.load(path, LoadOptions.withTrustedEndpoints("http://localhost:11434"));
+```
+
## Live provider tests
Tests tagged `live` call real providers and are excluded from the default `test`
diff --git a/runtime/java/prompty/src/main/java/com/microsoft/prompty/EndpointTrust.java b/runtime/java/prompty/src/main/java/com/microsoft/prompty/EndpointTrust.java
new file mode 100644
index 000000000..97d675b9d
--- /dev/null
+++ b/runtime/java/prompty/src/main/java/com/microsoft/prompty/EndpointTrust.java
@@ -0,0 +1,277 @@
+package com.microsoft.prompty;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Endpoint trust — keeps ambient credentials on host-approved destinations (spec §2.5.1).
+ *
+ * A {@code .prompty} file is untrusted input. When its connection carries no file-supplied
+ * credential, executors fall back to ambient credentials ({@code DefaultAzureCredential},
+ * {@code *_API_KEY} environment variables). This class decides whether the file-chosen destination
+ * may receive them.
+ *
+ *
Trusted origins are the union of {@link #BUILTIN_TRUSTED_ENDPOINTS}, the host's
+ * {@link LoadOptions#trustedEndpoints()}, and the comma-separated {@value #TRUSTED_ENDPOINTS_ENV}
+ * variable (read through {@link Environment}). Frontmatter can never contribute to that set.
+ */
+public final class EndpointTrust {
+
+ /** Environment variable listing additional trusted origins, comma-separated. */
+ public static final String TRUSTED_ENDPOINTS_ENV = "PROMPTY_TRUSTED_ENDPOINTS";
+
+ /** First-party provider origins trusted without host configuration. */
+ public static final List BUILTIN_TRUSTED_ENDPOINTS =
+ List.of(
+ "https://api.openai.com",
+ "https://api.anthropic.com",
+ "https://*.openai.azure.com",
+ "https://*.services.ai.azure.com",
+ "https://*.cognitiveservices.azure.com",
+ "https://login.microsoftonline.com");
+
+ private static final Map CREDENTIAL_KEYS =
+ Map.of("key", "apiKey");
+ private static final List DESTINATION_KEYS = List.of("endpoint", "tokenUrl");
+
+ private EndpointTrust() {}
+
+ /** An origin: lowercased scheme and host plus an explicit (or default) port. */
+ record Origin(String scheme, String host, int port) {}
+
+ /**
+ * Parse {@code url} into its origin, or {@code null} when it is not an absolute http(s) URL.
+ *
+ * Hand-rolled rather than built on {@link java.net.URI}, which rejects hosts the other runtimes
+ * accept (underscores, for instance) and would make the same file trusted in one runtime and not
+ * another. The rules follow the reference runtime's {@code urlsplit}-based parsing.
+ */
+ static Origin origin(String url) {
+ // HTTP clients treat '\' as '/', so parsers disagree on the host; reject outright.
+ if (url.indexOf('\\') >= 0) {
+ return null;
+ }
+ String s = url.strip().replace("\t", "").replace("\r", "").replace("\n", "");
+ int colon = s.indexOf(':');
+ if (colon <= 0 || !isScheme(s.substring(0, colon))) {
+ return null;
+ }
+ String scheme = s.substring(0, colon).toLowerCase(Locale.ROOT);
+ int defaultPort;
+ if ("http".equals(scheme)) {
+ defaultPort = 80;
+ } else if ("https".equals(scheme)) {
+ defaultPort = 443;
+ } else {
+ return null;
+ }
+ String rest = s.substring(colon + 1);
+ if (!rest.startsWith("//")) {
+ return null;
+ }
+ rest = rest.substring(2);
+ int end = rest.length();
+ for (char delimiter : new char[] {'/', '?', '#'}) {
+ int index = rest.indexOf(delimiter);
+ if (index >= 0 && index < end) {
+ end = index;
+ }
+ }
+ String netloc = rest.substring(0, end);
+ boolean open = netloc.indexOf('[') >= 0;
+ boolean close = netloc.indexOf(']') >= 0;
+ if (open != close) {
+ return null;
+ }
+
+ String hostinfo = netloc.substring(netloc.lastIndexOf('@') + 1);
+ String host;
+ String port;
+ int bracket = hostinfo.indexOf('[');
+ if (bracket >= 0) {
+ String bracketed = hostinfo.substring(bracket + 1);
+ int closing = bracketed.indexOf(']');
+ host = closing < 0 ? bracketed : bracketed.substring(0, closing);
+ String after = closing < 0 ? "" : bracketed.substring(closing + 1);
+ int portColon = after.indexOf(':');
+ port = portColon < 0 ? "" : after.substring(portColon + 1);
+ } else {
+ int portColon = hostinfo.indexOf(':');
+ host = portColon < 0 ? hostinfo : hostinfo.substring(0, portColon);
+ port = portColon < 0 ? "" : hostinfo.substring(portColon + 1);
+ }
+ host = host.toLowerCase(Locale.ROOT);
+ if (host.isEmpty()) {
+ return null;
+ }
+ if (port.isEmpty()) {
+ return new Origin(scheme, host, defaultPort);
+ }
+ for (int i = 0; i < port.length(); i++) {
+ char c = port.charAt(i);
+ if (c < '0' || c > '9') {
+ return null;
+ }
+ }
+ int number;
+ try {
+ number = Integer.parseInt(port);
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ if (number > 65535) {
+ return null;
+ }
+ return new Origin(scheme, host, number);
+ }
+
+ private static boolean isScheme(String candidate) {
+ if (!Character.isLetter(candidate.charAt(0)) || candidate.charAt(0) > 127) {
+ return false;
+ }
+ for (int i = 0; i < candidate.length(); i++) {
+ char c = candidate.charAt(i);
+ boolean ok =
+ (c >= 'a' && c <= 'z')
+ || (c >= 'A' && c <= 'Z')
+ || (c >= '0' && c <= '9')
+ || c == '+'
+ || c == '-'
+ || c == '.';
+ if (!ok) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static List trustedEntries(List trustedEndpoints) {
+ List raw = new ArrayList<>(BUILTIN_TRUSTED_ENDPOINTS);
+ if (trustedEndpoints != null) {
+ raw.addAll(trustedEndpoints);
+ }
+ for (String part : Environment.lookup(TRUSTED_ENDPOINTS_ENV).orElse("").split(",", -1)) {
+ raw.add(part);
+ }
+ List entries = new ArrayList<>();
+ for (String entry : raw) {
+ if (entry != null && !entry.strip().isEmpty()) {
+ entries.add(entry.strip());
+ }
+ }
+ return entries;
+ }
+
+ /** Whether {@code url}'s origin matches a built-in, host, or environment trusted origin. */
+ public static boolean isTrustedEndpoint(String url) {
+ return isTrustedEndpoint(url, List.of());
+ }
+
+ /**
+ * Whether {@code url}'s origin matches a built-in origin, one of {@code trustedEndpoints}, or an
+ * entry of {@value #TRUSTED_ENDPOINTS_ENV}.
+ */
+ public static boolean isTrustedEndpoint(String url, List trustedEndpoints) {
+ List entries = trustedEntries(trustedEndpoints);
+ if (entries.contains("*")) {
+ return true;
+ }
+ Origin target = url == null ? null : origin(url);
+ if (target == null) {
+ return false;
+ }
+ for (String entry : entries) {
+ Origin allowed = origin(entry);
+ if (allowed == null
+ || !allowed.scheme().equals(target.scheme())
+ || allowed.port() != target.port()) {
+ continue;
+ }
+ String pattern = allowed.host();
+ if (pattern.startsWith("*.")) {
+ if (target.host().endsWith(pattern.substring(1))) {
+ return true;
+ }
+ } else if (pattern.equals(target.host())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ static boolean isReference(String value) {
+ return value.startsWith("${") && value.endsWith("}");
+ }
+
+ private static boolean fileSuppliedCredential(Map, ?> connection) {
+ // Only the credential field the declared kind actually consumes counts; any other kind is
+ // ambient.
+ String key = connection.get("kind") instanceof String kind ? CREDENTIAL_KEYS.get(kind) : null;
+ if (key == null) {
+ return false;
+ }
+ // Blank keys count as absent: executors that trim fall back to ambient credentials.
+ return connection.get(key) instanceof String value && !value.isBlank() && !isReference(value);
+ }
+
+ /** The effective destination if the file chose it, or {@code null} when the host did. */
+ private static String fileControlledDestination(Object value) {
+ if (value == null) {
+ return null;
+ }
+ if (!(value instanceof String s)) {
+ return String.valueOf(value);
+ }
+ if (isReference(s)) {
+ String inner = s.substring(2, s.length() - 1);
+ int colon = inner.indexOf(':');
+ String protocol = colon < 0 ? inner : inner.substring(0, colon);
+ if ("env".equals(protocol.toLowerCase(Locale.ROOT))) {
+ String rest = colon < 0 ? "" : inner.substring(colon + 1);
+ int defaultColon = rest.indexOf(':');
+ String varName = defaultColon < 0 ? rest : rest.substring(0, defaultColon);
+ String defaultValue = defaultColon < 0 ? "" : rest.substring(defaultColon + 1);
+ Optional actual = Environment.lookup(varName);
+ if (actual.isPresent()) {
+ return null;
+ }
+ return defaultValue.isEmpty() ? null : defaultValue;
+ }
+ }
+ return s;
+ }
+
+ /**
+ * Throw {@link LoadException.Kind#UNTRUSTED_ENDPOINT} if ambient credentials could reach a
+ * file-chosen, untrusted destination.
+ *
+ * @param connection the RAW (unresolved) {@code model.connection} value
+ * @param trustedEndpoints host-provided trusted origins; may be {@code null}
+ */
+ public static void checkConnectionTrust(Object connection, List trustedEndpoints) {
+ if (!(connection instanceof Map, ?> map) || "reference".equals(map.get("kind"))) {
+ return;
+ }
+ if (fileSuppliedCredential(map)) {
+ return;
+ }
+ for (String key : DESTINATION_KEYS) {
+ String destination = fileControlledDestination(map.get(key));
+ if (destination != null && !isTrustedEndpoint(destination, trustedEndpoints)) {
+ throw LoadException.untrustedEndpoint(
+ "Connection "
+ + key
+ + " '"
+ + destination
+ + "' is not trusted to receive ambient credentials. Set the "
+ + key
+ + " via ${env:VAR}, add its origin to trustedEndpoints or "
+ + TRUSTED_ENDPOINTS_ENV
+ + ", or use 'kind: reference'.");
+ }
+ }
+ }
+}
diff --git a/runtime/java/prompty/src/main/java/com/microsoft/prompty/LoadException.java b/runtime/java/prompty/src/main/java/com/microsoft/prompty/LoadException.java
index e91556bfe..69da01f20 100644
--- a/runtime/java/prompty/src/main/java/com/microsoft/prompty/LoadException.java
+++ b/runtime/java/prompty/src/main/java/com/microsoft/prompty/LoadException.java
@@ -27,6 +27,11 @@ public enum Kind {
* {@code format} and {@code parser}.
*/
INVALID_TEMPLATE,
+ /**
+ * The connection would send ambient credentials to a destination the file chose and the host
+ * has not trusted (spec §2.5.1).
+ */
+ UNTRUSTED_ENDPOINT,
/** Any other load failure. */
OTHER
}
@@ -75,6 +80,10 @@ public static LoadException invalidTemplate(String detail) {
return new LoadException(Kind.INVALID_TEMPLATE, "Invalid template format: " + detail);
}
+ public static LoadException untrustedEndpoint(String detail) {
+ return new LoadException(Kind.UNTRUSTED_ENDPOINT, detail);
+ }
+
public static LoadException other(String detail) {
return new LoadException(Kind.OTHER, "Load error: " + detail);
}
diff --git a/runtime/java/prompty/src/main/java/com/microsoft/prompty/LoadOptions.java b/runtime/java/prompty/src/main/java/com/microsoft/prompty/LoadOptions.java
index 9e1a07064..fd345d787 100644
--- a/runtime/java/prompty/src/main/java/com/microsoft/prompty/LoadOptions.java
+++ b/runtime/java/prompty/src/main/java/com/microsoft/prompty/LoadOptions.java
@@ -8,13 +8,24 @@
*
* @param allowedFileRoots additional directories that {@code ${file:...}} references may read from.
* The prompt file's own directory is always allowed and need not be listed.
+ * @param trustedEndpoints additional origins (e.g. {@code "https://my-proxy.contoso.com"} or
+ * {@code "https://*.contoso.com"}) that may receive ambient credentials when a file chooses the
+ * connection endpoint (spec §2.5.1). Built-in provider origins and the
+ * {@code PROMPTY_TRUSTED_ENDPOINTS} variable are always consulted; {@code "*"} trusts every
+ * endpoint.
*/
-public record LoadOptions(List allowedFileRoots) {
+public record LoadOptions(List allowedFileRoots, List trustedEndpoints) {
- private static final LoadOptions DEFAULT = new LoadOptions(List.of());
+ private static final LoadOptions DEFAULT = new LoadOptions(List.of(), List.of());
public LoadOptions {
allowedFileRoots = List.copyOf(allowedFileRoots);
+ trustedEndpoints = trustedEndpoints == null ? List.of() : List.copyOf(trustedEndpoints);
+ }
+
+ /** Options with additional {@code ${file:...}} roots and no extra trusted endpoints. */
+ public LoadOptions(List allowedFileRoots) {
+ this(allowedFileRoots, List.of());
}
/** Default options: {@code ${file:...}} is confined to the prompt's own directory tree. */
@@ -26,4 +37,14 @@ public static LoadOptions defaults() {
public static LoadOptions withAllowedFileRoots(Path... roots) {
return new LoadOptions(List.of(roots));
}
+
+ /** Options trusting the given endpoint origins to receive ambient credentials. */
+ public static LoadOptions withTrustedEndpoints(String... origins) {
+ return new LoadOptions(List.of(), List.of(origins));
+ }
+
+ /** A copy of these options with {@code trustedEndpoints} replaced. */
+ public LoadOptions trustingEndpoints(List origins) {
+ return new LoadOptions(allowedFileRoots, origins);
+ }
}
diff --git a/runtime/java/prompty/src/main/java/com/microsoft/prompty/Loader.java b/runtime/java/prompty/src/main/java/com/microsoft/prompty/Loader.java
index 15a015d6f..fcb991451 100644
--- a/runtime/java/prompty/src/main/java/com/microsoft/prompty/Loader.java
+++ b/runtime/java/prompty/src/main/java/com/microsoft/prompty/Loader.java
@@ -23,6 +23,8 @@
* Normalise authored shorthands — dictionary-form inputs and outputs whose value is a bare
* scalar become full properties.
* Inject {@code kind: "prompt"}; a {@code .prompty} document always describes a prompt agent.
+ * Check endpoint trust on the raw {@code model.connection} (spec §2.5.1): ambient credentials
+ * may only flow to a file-chosen destination whose origin the host trusts.
* Resolve {@code ${env:...}} and {@code ${file:...}} references.
* Hand the resulting map to the generated model's {@code load}.
* Record the source path in {@code metadata.__source_path}.
@@ -111,6 +113,11 @@ private static com.microsoft.prompty.model.Agent buildAgent(
data.put("kind", "prompt");
Path agentDir = filePath.getParent() == null ? Path.of(".") : filePath.getParent();
+
+ // Endpoint trust (spec §2.5.1) runs on the RAW connection, before references resolve.
+ EndpointTrust.checkConnectionTrust(
+ rawConnection(data, agentDir, options.allowedFileRoots()), options.trustedEndpoints());
+
References.resolveReferences(data, agentDir, options.allowedFileRoots());
LoadContext context = makeLoadContext(agentDir, options.allowedFileRoots());
@@ -124,6 +131,31 @@ private static com.microsoft.prompty.model.Agent buildAgent(
return agent;
}
+ /** The unresolved {@code model.connection}, following whole-value {@code ${file:}} references. */
+ private static Object rawConnection(
+ Map data, Path agentDir, List allowedFileRoots) {
+ Object model = followFileReference(data.get("model"), agentDir, allowedFileRoots, "model");
+ if (!(model instanceof Map, ?> modelMap)) {
+ return null;
+ }
+ return followFileReference(modelMap.get("connection"), agentDir, allowedFileRoots, "connection");
+ }
+
+ private static Object followFileReference(
+ Object value, Path agentDir, List allowedFileRoots, String key) {
+ if (value instanceof String s && EndpointTrust.isReference(s)) {
+ String inner = s.substring(2, s.length() - 1);
+ int colon = inner.indexOf(':');
+ String protocol = colon < 0 ? inner : inner.substring(0, colon);
+ if ("file".equals(protocol.toLowerCase(java.util.Locale.ROOT))) {
+ String reference = colon < 0 ? "" : inner.substring(colon + 1);
+ return References.readContainedFile(reference, agentDir, allowedFileRoots, key)
+ .orElse(null);
+ }
+ }
+ return value;
+ }
+
/**
* A {@code LoadContext} whose {@code preProcess} resolves references as the model tree is walked.
*
diff --git a/runtime/java/prompty/src/main/java/com/microsoft/prompty/References.java b/runtime/java/prompty/src/main/java/com/microsoft/prompty/References.java
index 9a8679fe0..1329ca8d6 100644
--- a/runtime/java/prompty/src/main/java/com/microsoft/prompty/References.java
+++ b/runtime/java/prompty/src/main/java/com/microsoft/prompty/References.java
@@ -100,6 +100,22 @@ public static Optional