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
3 changes: 3 additions & 0 deletions dotnet/agent-framework-dotnet.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,9 @@
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/HostedFiles.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Harness-Research/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Harness-Research/HostedHarnessResearch.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/HostedLocalTools.csproj" />
</Folder>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,27 @@ namespace SampleApp;
/// </summary>
internal sealed partial class WebBrowsingTool : AIFunction
{
private static readonly HttpClient s_httpClient = new();
private const int MaxRedirects = 10;
private readonly AIFunction _inner;
private readonly WebBrowsingToolOptions _options;
private readonly Func<string, CancellationToken, Task<IPAddress[]>> _resolveHostAddressesAsync;

/// <summary>
/// Initializes a new instance of the <see cref="WebBrowsingTool"/> class.
/// </summary>
/// <param name="options">Options controlling which URLs are permitted. By default, no hosts are accessible.</param>
public WebBrowsingTool(WebBrowsingToolOptions options)
: this(options, Dns.GetHostAddressesAsync)
{
}

internal WebBrowsingTool(
WebBrowsingToolOptions options,
Func<string, CancellationToken, Task<IPAddress[]>> resolveHostAddressesAsync)
{
this._options = options ?? throw new ArgumentNullException(nameof(options));
this._resolveHostAddressesAsync = resolveHostAddressesAsync ??
throw new ArgumentNullException(nameof(resolveHostAddressesAsync));
this._inner = AIFunctionFactory.Create(this.DownloadUriAsync);
}

Expand All @@ -45,7 +55,7 @@ public WebBrowsingTool(WebBrowsingToolOptions options)
this._inner.InvokeAsync(arguments, cancellationToken);

[Description("Fetch the html from the given url as markdown")]
private async Task<string> DownloadUriAsync(
internal async Task<string> DownloadUriAsync(
[Description("The URL to download")] string uri,
CancellationToken cancellationToken = default)
{
Expand All @@ -59,17 +69,58 @@ private async Task<string> DownloadUriAsync(
return $"Error: Only HTTP and HTTPS URLs are supported. Got: '{parsedUri.Scheme}'.";
}

// Check access policy.
string? accessError = await this.CheckAccessAsync(parsedUri, cancellationToken);
if (accessError is not null)
AccessCheckResult access = await this.CheckAccessAsync(parsedUri, cancellationToken);
if (access.Error is not null)
{
return accessError;
return access.Error;
}

try
{
string html = await s_httpClient.GetStringAsync(parsedUri, cancellationToken);
return HtmlToMarkdownConverter.Convert(html);
// Follow redirects manually, re-checking the access policy on every hop so a permitted
// URL cannot redirect into a blocked (private/metadata/localhost) target.
Uri currentUri = parsedUri;
for (int hop = 0; ; hop++)
{
if (hop > MaxRedirects)
{
return $"Error downloading {uri}: too many redirects.";
}

using HttpClient httpClient = CreateHttpClient(currentUri, access.Addresses);
using var response = await httpClient.GetAsync(
currentUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken);

if (response.StatusCode is HttpStatusCode.Moved or HttpStatusCode.Found
or HttpStatusCode.SeeOther or HttpStatusCode.TemporaryRedirect
or HttpStatusCode.PermanentRedirect)
{
Uri? location = response.Headers.Location;
if (location is null)
{
return $"Error downloading {uri}: redirect with no location.";
}

// Resolve relative redirects against the current URL, then re-check access.
currentUri = new Uri(currentUri, location);
if (currentUri.Scheme is not "http" and not "https")
{
return $"Error: redirect to unsupported scheme '{currentUri.Scheme}'.";
}

access = await this.CheckAccessAsync(currentUri, cancellationToken);
if (access.Error is not null)
{
return access.Error;
}

continue;
}

response.EnsureSuccessStatusCode();
string html = await response.Content.ReadAsStringAsync(cancellationToken);
return HtmlToMarkdownConverter.Convert(html);
}
}
catch (HttpRequestException ex)
{
Expand All @@ -79,11 +130,14 @@ private async Task<string> DownloadUriAsync(

/// <summary>
/// Checks whether the given URI is permitted by the configured access policy.
/// Returns null if allowed, or an error message string if blocked.
/// Returns the addresses that the HTTP connection may use, or an error message if blocked.
/// </summary>
private async Task<string?> CheckAccessAsync(Uri uri, CancellationToken cancellationToken)
private async Task<AccessCheckResult> CheckAccessAsync(
Uri uri,
CancellationToken cancellationToken)
{
string host = uri.Host;
bool isAllowedHost = false;

// 1. Check AllowedHosts.
if (this._options.AllowedHosts is { Count: > 0 } allowedHosts)
Expand All @@ -92,59 +146,145 @@ private async Task<string> DownloadUriAsync(
{
if (HostMatchesPattern(host, pattern))
{
return null; // Allowed by explicit host list.
isAllowedHost = true;
break;
}
}
}

// 2. Short-circuit when the policy is guaranteed to block.
if (!this._options.AllowPublicNetworks &&
if (!isAllowedHost &&
!this._options.AllowPublicNetworks &&
!this._options.AllowPrivateNetworks &&
!this._options.AllowAllHosts)
{
return $"Error: Access to '{host}' is blocked by the current access policy. Configure WebBrowsingToolOptions to allow access.";
return AccessCheckResult.Blocked(
$"Error: Access to '{host}' is blocked by the current access policy. Configure WebBrowsingToolOptions to allow access.");
}

// 3. Resolve DNS to determine if the host is public or private.
// 3. Resolve exactly once. The resulting addresses are also used for the socket connection.
IPAddress[] addresses;
try
{
addresses = await Dns.GetHostAddressesAsync(host, cancellationToken);
addresses = IPAddress.TryParse(uri.IdnHost, out IPAddress? literalAddress)
? [literalAddress]
: await this._resolveHostAddressesAsync(uri.IdnHost, cancellationToken);
}
catch (SocketException)
{
return $"Error: Could not resolve host '{host}'.";
return AccessCheckResult.Blocked($"Error: Could not resolve host '{host}'.");
}

if (addresses.Length == 0)
{
return $"Error: Could not resolve host '{host}'.";
return AccessCheckResult.Blocked($"Error: Could not resolve host '{host}'.");
}

bool isPrivate = Array.Exists(addresses, IsPrivateAddress);
IPAddress[] distinctAddresses = addresses.Distinct().ToArray();
if (isAllowedHost || this._options.AllowAllHosts)
{
return AccessCheckResult.Allowed(distinctAddresses);
}

// 4. If public and AllowPublicNetworks is true → allow.
if (!isPrivate && this._options.AllowPublicNetworks)
// Permit only addresses in enabled network classes. Mixed DNS answers cannot cause the
// connection callback to reach an address class that the policy did not authorize.
IPAddress[] allowedAddresses = distinctAddresses
.Where(address =>
IsPrivateAddress(address)
? this._options.AllowPrivateNetworks
: this._options.AllowPublicNetworks)
.ToArray();
if (allowedAddresses.Length > 0)
{
return null;
return AccessCheckResult.Allowed(allowedAddresses);
}

// 5. If private and AllowPrivateNetworks is true → allow.
if (isPrivate && this._options.AllowPrivateNetworks)
bool hasPrivateAddress = Array.Exists(distinctAddresses, IsPrivateAddress);
bool hasPublicAddress = Array.Exists(distinctAddresses, address => !IsPrivateAddress(address));
string networkType = (hasPrivateAddress, hasPublicAddress) switch
{
(true, true) => "private/internal and public network",
(true, false) => "private/internal network",
_ => "public network",
};
return AccessCheckResult.Blocked(
$"Error: Access to '{host}' is blocked. The host resolves to a {networkType} address and the current access policy does not permit this. " +
"Configure WebBrowsingToolOptions to allow access.");
}

private static HttpClient CreateHttpClient(Uri uri, IPAddress[] addresses)
{
// Redirects are followed by DownloadUriAsync so each hop is re-checked. A proxy is
// disabled because the connection callback would otherwise see the proxy endpoint and
// could no longer pin the socket to the addresses approved by the access policy.
var handler = new SocketsHttpHandler
{
AllowAutoRedirect = false,
UseProxy = false,
ConnectCallback = (context, cancellationToken) =>
ConnectAsync(context, uri.IdnHost, uri.Port, addresses, cancellationToken),
};
return new HttpClient(handler);
}

private static async ValueTask<Stream> ConnectAsync(
SocketsHttpConnectionContext context,
string expectedHost,
int expectedPort,
IPAddress[] addresses,
CancellationToken cancellationToken)
{
if (!string.Equals(
context.DnsEndPoint.Host,
expectedHost,
StringComparison.OrdinalIgnoreCase) ||
context.DnsEndPoint.Port != expectedPort)
{
return null;
throw new HttpRequestException(
$"Unexpected HTTP connection destination '{context.DnsEndPoint.Host}:{context.DnsEndPoint.Port}'.");
}

// 6. If AllowAllHosts is true → allow.
if (this._options.AllowAllHosts)
var failures = new List<SocketException>(addresses.Length);
foreach (IPAddress address in addresses)
{
return null;
Socket? socket = null;
bool streamOwnsSocket = false;

try
{
socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp)
{
NoDelay = true,
};
await socket.ConnectAsync(
new IPEndPoint(address, expectedPort),
cancellationToken);
streamOwnsSocket = true;
return new NetworkStream(socket, ownsSocket: true);
}
catch (SocketException ex) when (!cancellationToken.IsCancellationRequested)
{
failures.Add(ex);
}
finally
{
if (!streamOwnsSocket)
{
socket?.Dispose();
}
}
}

// 7. Block.
string networkType = isPrivate ? "private/internal network" : "public network";
return $"Error: Access to '{host}' is blocked. The host resolves to a {networkType} address and the current access policy does not permit this. " +
"Configure WebBrowsingToolOptions to allow access.";
throw new HttpRequestException(
$"Could not connect to any policy-approved address for '{expectedHost}:{expectedPort}'.",
new AggregateException(failures));
}

private readonly record struct AccessCheckResult(string? Error, IPAddress[] Addresses)
{
public static AccessCheckResult Allowed(IPAddress[] addresses) => new(null, addresses);

public static AccessCheckResult Blocked(string error) => new(error, []);
}

/// <summary>
Expand Down Expand Up @@ -177,6 +317,11 @@ private static bool IsPrivateAddress(IPAddress address)
address = address.MapToIPv4();
}

if (address.Equals(IPAddress.Any) || address.Equals(IPAddress.IPv6Any))
{
return true;
}

if (IPAddress.IsLoopback(address))
{
return true;
Expand Down
8 changes: 8 additions & 0 deletions dotnet/samples/02-agents/Harness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ Samples accompanying the [*Build your own agent harness or claw with Microsoft A
| [Claw_Step03_ScalingCapabilities](./BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/README.md) | Post 3 — scaling the claw with skills (plus optional Foundry skills), a confined shell, CodeAct, and background agents |
| [Claw_Step04_ProductionReady](./BuildYourOwnClaw/Claw_Step04_ProductionReady/README.md) | Post 4 — production-ready shared claw library with observability, opt-in Purview governance, Foundry hosted deployment, and evals |

## Foundry hosted Harness sample

The Research sample hosts a `HarnessAgent` as a Foundry hosted agent using source ZIP deployment with the default `AllowStoredOutputEnabled = false`, so the model service never stores the conversation.

| Sample | Description |
| --- | --- |
| [Hosted-Harness-Research](../../04-hosting/FoundryHostedAgents/responses/Hosted-Harness-Research/README.md) | Hosts the research harness with web search, a browsing tool, planning, a todo loop, and session-scoped file memory |

## Security Considerations

Several harness providers extend the agent's trust boundary to external systems the developer
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Controls which files are excluded from the source-deploy ZIP (.gitignore syntax).
azure.yaml
.agentignore

.env
.env.*
.azure/
.git/

bin/
obj/
out/
*.user
*.suo
.vs/

agent-files/
.checkpoints/

# Include local-feed/ and nuget.config so remote restore uses the prepared framework packages.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
FOUNDRY_PROJECT_ENDPOINT=https://<account>.services.ai.azure.com/api/projects/<project>
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-5.4-mini
AZURE_TOKEN_CREDENTIALS=dev
ASPNETCORE_URLS=http://localhost:8088
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.env
out/
agent-files/
.checkpoints/
Loading
Loading