Web search restrictions after internal content (#1074)

Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
Peer HogeterpandThorsten Sommer authored and GitHub committed 2026-10-18 19:39:55 +02:00
1 parent f38c4aa563
commit 1f8d72aa45
42 files changed
+1654 -228

No files matched your search

@@ -7,18 +7,20 @@ using AIStudio.Tools;
namespace AIStudio.Tests.Chat;
/// <summary>
/// Checks how the mailboxes a chat read from restrict where it may still send data.
/// Checks how the internal content a chat holds restricts where it may still send data.
/// </summary>
/// <remarks>
/// Once mail content is in a chat, every tool which runs afterwards could carry it out. The chat
/// therefore keeps the strictest restriction of every mailbox it read from, and it keeps it beyond
/// a restart, because the content stays in the chat as well.
/// Once internal content is in a chat, mails or pages of the wiki or the intranet, every tool which
/// runs afterwards could carry it out. The chat therefore keeps the strictest restriction of every
/// source it read from, and it keeps it beyond a restart, because the content stays in the chat as
/// well.
/// </remarks>
[TestFixture]
public sealed class ChatThreadOutboundDataRestrictionTests
{
private const string PRIVATE_MAILBOX_ID = "5c9e2a7b-3f1d-4e8a-b6c4-2d7f9a1e3b5c";
private const string WORK_MAILBOX_ID = "8d1f4b6a-2e9c-4a7d-9b3e-6c5a8f2d1e4b";
private const string INTRANET_HOST = "intranet.example.org";
[Test]
public void AChatWhichReadNoMailboxDemandsNothing() =>
@@ -30,10 +32,10 @@ public sealed class ChatThreadOutboundDataRestrictionTests
public void AStricterMailboxTightensTheChat(OutboundDataRestriction held, OutboundDataRestriction arriving)
{
var thread = new ChatThread();
thread.RequireOutboundDataRestriction(new(held, PRIVATE_MAILBOX_ID));
thread.RequireOutboundDataRestriction(new(arriving, WORK_MAILBOX_ID));
thread.RequireOutboundDataRestriction(new(held, OutboundDataOrigin.MAILBOX, PRIVATE_MAILBOX_ID));
thread.RequireOutboundDataRestriction(new(arriving, OutboundDataOrigin.MAILBOX, WORK_MAILBOX_ID));
Assert.That(thread.RequiredOutboundDataRestriction, Is.EqualTo(new OutboundDataRequirement(arriving, WORK_MAILBOX_ID)), "The stricter mailbox is the one which keeps a tool from running, so it is the one to name.");
Assert.That(thread.RequiredOutboundDataRestriction, Is.EqualTo(new OutboundDataRequirement(arriving, OutboundDataOrigin.MAILBOX, WORK_MAILBOX_ID)), "The stricter mailbox is the one which keeps a tool from running, so it is the one to name.");
}
[TestCase(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, OutboundDataRestriction.ONLY_LINKS_FROM_CHAT)]
@@ -42,41 +44,74 @@ public sealed class ChatThreadOutboundDataRestrictionTests
public void ARestrictionStays(OutboundDataRestriction held, OutboundDataRestriction arriving)
{
var thread = new ChatThread();
thread.RequireOutboundDataRestriction(new(held, PRIVATE_MAILBOX_ID));
thread.RequireOutboundDataRestriction(new(arriving, WORK_MAILBOX_ID));
thread.RequireOutboundDataRestriction(new(held, OutboundDataOrigin.MAILBOX, PRIVATE_MAILBOX_ID));
thread.RequireOutboundDataRestriction(new(arriving, OutboundDataOrigin.MAILBOX, WORK_MAILBOX_ID));
Assert.That(thread.RequiredOutboundDataRestriction, Is.EqualTo(new OutboundDataRequirement(held, PRIVATE_MAILBOX_ID)), "The mail content was read by this chat. A more permissive mailbox read later cannot undo that.");
Assert.That(thread.RequiredOutboundDataRestriction, Is.EqualTo(new OutboundDataRequirement(held, OutboundDataOrigin.MAILBOX, PRIVATE_MAILBOX_ID)), "The mail content was read by this chat. A more permissive mailbox read later cannot undo that.");
}
[Test]
public void OnATieTheMailboxReadFirstStaysNamed()
{
var thread = new ChatThread();
thread.RequireOutboundDataRestriction(new(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, PRIVATE_MAILBOX_ID));
thread.RequireOutboundDataRestriction(new(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, WORK_MAILBOX_ID));
thread.RequireOutboundDataRestriction(new(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, OutboundDataOrigin.MAILBOX, PRIVATE_MAILBOX_ID));
thread.RequireOutboundDataRestriction(new(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, OutboundDataOrigin.MAILBOX, WORK_MAILBOX_ID));
Assert.That(thread.RequiredOutboundDataRestriction.DataSourceId, Is.EqualTo(PRIVATE_MAILBOX_ID), "Otherwise the chat would name another mailbox with every search.");
Assert.That(thread.RequiredOutboundDataRestriction.Reference, Is.EqualTo(PRIVATE_MAILBOX_ID), "Otherwise the chat would name another mailbox with every search.");
}
[Test]
public void AStricterSourceOfAnotherKindTightensTheChat()
{
var thread = new ChatThread();
thread.RequireOutboundDataRestriction(new(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, OutboundDataOrigin.MAILBOX, WORK_MAILBOX_ID));
thread.RequireOutboundDataRestriction(new(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, OutboundDataOrigin.INTERNAL_WEB_PAGE, INTRANET_HOST));
Assert.That(thread.RequiredOutboundDataRestriction, Is.EqualTo(new OutboundDataRequirement(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, OutboundDataOrigin.INTERNAL_WEB_PAGE, INTRANET_HOST)), "The level decides, not the kind of source, and the stricter source is the one to name.");
}
[Test]
public void OnATieTheSourceReadFirstStaysNamedWhateverItsKind()
{
var thread = new ChatThread();
thread.RequireOutboundDataRestriction(new(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, OutboundDataOrigin.CONFLUENCE_WIKI, string.Empty));
thread.RequireOutboundDataRestriction(new(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, OutboundDataOrigin.MAILBOX, WORK_MAILBOX_ID));
thread.RequireOutboundDataRestriction(new(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, OutboundDataOrigin.INTERNAL_WEB_PAGE, INTRANET_HOST));
Assert.That(thread.RequiredOutboundDataRestriction, Is.EqualTo(new OutboundDataRequirement(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, OutboundDataOrigin.CONFLUENCE_WIKI, string.Empty)));
}
[Test]
public void ResultsWhichDemandNothingChangeNothing()
{
var thread = new ChatThread();
thread.RequireOutboundDataRestriction(new(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, PRIVATE_MAILBOX_ID));
thread.RequireOutboundDataRestriction(new(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, OutboundDataOrigin.MAILBOX, PRIVATE_MAILBOX_ID));
thread.RequireOutboundDataRestriction(OutboundDataRequirement.NONE);
Assert.That(thread.RequiredOutboundDataRestriction, Is.EqualTo(new OutboundDataRequirement(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, PRIVATE_MAILBOX_ID)), "A search of the local files, say, says nothing about mailboxes and must leave the chat as it was.");
Assert.That(thread.RequiredOutboundDataRestriction, Is.EqualTo(new OutboundDataRequirement(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, OutboundDataOrigin.MAILBOX, PRIVATE_MAILBOX_ID)), "A search of the local files, say, says nothing about mailboxes and must leave the chat as it was.");
}
[Test]
public void TheRestrictionSurvivesSavingTheChat()
{
var thread = new ChatThread { ChatId = Guid.NewGuid() };
thread.RequireOutboundDataRestriction(new(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, WORK_MAILBOX_ID));
thread.RequireOutboundDataRestriction(new(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, OutboundDataOrigin.MAILBOX, WORK_MAILBOX_ID));
var loaded = JsonSerializer.Deserialize<ChatThread>(JsonSerializer.Serialize(thread, WorkspaceBehaviour.JSON_OPTIONS), WorkspaceBehaviour.JSON_OPTIONS);
Assert.That(loaded?.RequiredOutboundDataRestriction, Is.EqualTo(new OutboundDataRequirement(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, WORK_MAILBOX_ID)), "After a restart, the mail content is still in the chat, so the restriction has to be as well.");
Assert.That(loaded?.RequiredOutboundDataRestriction, Is.EqualTo(new OutboundDataRequirement(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, OutboundDataOrigin.MAILBOX, WORK_MAILBOX_ID)), "After a restart, the mail content is still in the chat, so the restriction has to be as well.");
}
[TestCase(OutboundDataOrigin.CONFLUENCE_WIKI, "")]
[TestCase(OutboundDataOrigin.INTERNAL_WEB_PAGE, INTRANET_HOST)]
public void TheOriginSurvivesSavingTheChat(OutboundDataOrigin origin, string reference)
{
var thread = new ChatThread { ChatId = Guid.NewGuid() };
thread.RequireOutboundDataRestriction(new(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, origin, reference));
var loaded = JsonSerializer.Deserialize<ChatThread>(JsonSerializer.Serialize(thread, WorkspaceBehaviour.JSON_OPTIONS), WorkspaceBehaviour.JSON_OPTIONS);
Assert.That(loaded?.RequiredOutboundDataRestriction, Is.EqualTo(new OutboundDataRequirement(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, origin, reference)), "Otherwise a restored chat would name a mailbox for content it read from elsewhere.");
}
[Test]
+1 -1
View File
@@ -144,7 +144,7 @@ public sealed class ChatThreadRollbackTests
],
};
var restriction = new OutboundDataRequirement(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, "5c9e2a7b-3f1d-4e8a-b6c4-2d7f9a1e3b5c");
var restriction = new OutboundDataRequirement(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, OutboundDataOrigin.MAILBOX, "5c9e2a7b-3f1d-4e8a-b6c4-2d7f9a1e3b5c");
thread.RequireProviderConfidence(ConfidenceLevel.HIGH);
thread.RequireOutboundDataRestriction(restriction);
@@ -37,7 +37,7 @@ public sealed class MailboxOutboundDataRestrictionTests
{
var (_, outboundData) = MailToolResults.GetRequirements([OPEN], OutboundDataRestriction.ONLY_LINKS_FROM_CHAT);
Assert.That(outboundData, Is.EqualTo(new OutboundDataRequirement(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, OPEN.Id)), "The chat names the mailbox whose mails it read, even though the level comes from the organization.");
Assert.That(outboundData, Is.EqualTo(new OutboundDataRequirement(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, OutboundDataOrigin.MAILBOX, OPEN.Id)), "The chat names the mailbox whose mails it read, even though the level comes from the organization.");
}
[Test]
@@ -0,0 +1,133 @@
using AIStudio.Chat;
using AIStudio.Provider;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.ToolCallingSystem;
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations;
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch;
using Microsoft.Extensions.Logging.Abstractions;
namespace AIStudio.Tests.Tools.ToolCalling;
/// <summary>
/// Checks where a chat may still send data once it searched the Confluence wiki.
/// </summary>
/// <remarks>
/// What the wiki returns is internal to the organization. A web search or an address the model
/// writes itself would carry it to a third party, so a search restricts the chat as a mailbox
/// does. The level is the one Read Web Page applies to every internal page, so a search and the
/// wiki pages opened after it restrict the chat alike. By default, the chat keeps the links from
/// the chat and the wiki itself, and an organization can tighten that to the wiki alone. The
/// search itself needs a real Confluence and is left to a manual test; these tests start from what
/// a successful search demands.
/// </remarks>
[TestFixture]
[NonParallelizable]
public sealed class ConfluenceOutboundDataRestrictionTests : ToolRegistryTestBase
{
private const string WIKI = "https://wiki.example.org/confluence/";
private const string WIKI_PAGE = "https://wiki.example.org/confluence/display/TEAM/Budget";
private const string PASTED = "https://example.org/newsletter/2026-10";
private const string CHOSEN_BY_THE_MODEL = "https://attacker.example/?wiki=budget-2027";
private static readonly Uri WIKI_URL = new(WIKI);
private static readonly Func<string, bool> NO_PRIVATE_HOSTS = _ => false;
[Test]
public async Task ASearchRestrictsTheChatToLinksFromTheChatByDefault()
{
var requirement = ConfluenceSearchTool.GetOutboundDataRequirement(await ReadWebPageTool.ReadInternalPageOutboundDataRestrictionAsync(this.CreateToolSettingsService()));
Assert.That(requirement, Is.EqualTo(new OutboundDataRequirement(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, OutboundDataOrigin.CONFLUENCE_WIKI, string.Empty)), "The chat names the wiki, so the user learns why web search is gone.");
}
[TestCase(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES)]
[TestCase(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT)]
[TestCase(OutboundDataRestriction.UNRESTRICTED)]
public async Task ASearchTakesTheLevelOfInternalPages(OutboundDataRestriction configured)
{
this.SettingsManager.ConfigurationData.Tools.Settings[ToolSelectionRules.READ_WEB_PAGE_TOOL_ID] = new() { ["internalPageOutboundDataRestriction"] = configured.ToString() };
var restriction = await ReadWebPageTool.ReadInternalPageOutboundDataRestrictionAsync(this.CreateToolSettingsService());
Assert.That(restriction, Is.EqualTo(configured), "One setting of Read Web Page decides for the search and for the wiki pages opened after it.");
}
[Test]
public void TheSettingOffersEveryLevel()
{
var values = ToolSettingsOptionSources.GetValues(ToolSettingsOptionSources.OUTBOUND_DATA_RESTRICTION);
Assert.That(values, Is.EquivalentTo(Enum.GetNames<OutboundDataRestriction>()), "The values are the names an organization writes into its configuration.");
}
[Test]
public void AfterASearchWebSearchIsNotOffered()
{
IToolImplementation webSearch = new WebSearchTool([], null!, null!, NullLogger<WebSearchTool>.Instance);
Assert.That(ToolSelectionRules.IsOutboundDataAllowed(ChatAfterASearch().RequiredOutboundDataRestriction.Restriction, webSearch), Is.False);
}
[Test]
public async Task AfterASearchAWebSearchIsTurnedDownBeforeItRuns()
{
//
// The request offered the web search while the chat was still unrestricted. Then the
// wiki search brought in its results, and the model calls the web search in the same request:
//
var hasRun = false;
var webSearch = new TestTool(Definition(), execute: _ =>
{
hasRun = true;
return new ToolExecutionResult { TextContent = "Results about the budget." };
}) { OutboundData = ToolOutboundData.THIRD_PARTY_QUERIES };
var executor = new ToolExecutor(this.CreateToolSettingsService(), NullLogger<ToolExecutor>.Instance);
var outcome = await executor.ExecuteAsync("call-1", TOOL_ID, "{}", [(webSearch.GetDefinition(), webSearch)], new NoProvider(), ChatAfterASearch(), order: 1);
Assert.Multiple(() =>
{
Assert.That(hasRun, Is.False, "The wiki content would already have left AI Studio.");
Assert.That(outcome.Trace.Status, Is.EqualTo(ToolInvocationTraceStatus.BLOCKED));
Assert.That(outcome.Content, Does.Contain("Confluence wiki"));
});
}
[TestCase(WIKI_PAGE, true)]
[TestCase(PASTED, true)]
[TestCase(CHOSEN_BY_THE_MODEL, false)]
public void AfterASearchWikiPagesAndLinksFromTheChatAreRead(string address, bool expectedAllowed)
{
var allowed = ReadWebPageTool.IsAllowedByOutboundDataRestriction(new Uri(address), ChatAfterASearch().RequiredOutboundDataRestriction.Restriction, ChatAfterASearch(), WIKI_URL, NO_PRIVATE_HOSTS, out _);
Assert.That(allowed, Is.EqualTo(expectedAllowed), "The model may follow the wiki's results and the user's links, but it cannot write an address of its own.");
}
[TestCase(WIKI_PAGE, true)]
[TestCase(PASTED, false)]
[TestCase(CHOSEN_BY_THE_MODEL, false)]
public void AfterASearchForConfiguredServicesOnlyOnlyTheWikiIsRead(string address, bool expectedAllowed)
{
var chat = ChatAfterASearch(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES);
var allowed = ReadWebPageTool.IsAllowedByOutboundDataRestriction(new Uri(address), chat.RequiredOutboundDataRestriction.Restriction, chat, WIKI_URL, NO_PRIVATE_HOSTS, out _);
Assert.That(allowed, Is.EqualTo(expectedAllowed), "Not even an address the user pasted is read.");
}
private static ChatThread ChatAfterASearch(OutboundDataRestriction internalPageRestriction = OutboundDataRestriction.ONLY_LINKS_FROM_CHAT)
{
var thread = new ChatThread
{
Blocks =
[
new ContentBlock
{
Time = new DateTimeOffset(2026, 10, 16, 9, 0, 0, TimeSpan.Zero),
ContentType = ContentType.TEXT,
Content = new ContentText { Text = $"Our wiki has the budget, and {PASTED} has the background. What changed?" },
Role = ChatRole.USER,
},
],
};
thread.RequireOutboundDataRestriction(ConfluenceSearchTool.GetOutboundDataRequirement(internalPageRestriction));
return thread;
}
}
@@ -0,0 +1,79 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
namespace AIStudio.Tests.Tools.ToolCalling;
/// <summary>
/// A minimal HTTP server on the loopback address, which stands in for the intranet in tests: it
/// is the one non-public address a test can serve from.
/// </summary>
/// <remarks>
/// /redirect sends the client on, to /page of this server unless the test names another address,
/// and every other path answers with a small HTML page.
/// </remarks>
internal sealed class LoopbackWebServer : IDisposable
{
private readonly TcpListener listener = new(IPAddress.Loopback, 0);
private readonly CancellationTokenSource stop = new();
private readonly string? redirectLocation;
/// <param name="redirectLocation">Where /redirect sends the client, or null for /page of this server.</param>
public LoopbackWebServer(string? redirectLocation = null)
{
this.redirectLocation = redirectLocation;
this.listener.Start();
_ = this.ServeAsync();
}
public int Port => ((IPEndPoint)this.listener.LocalEndpoint).Port;
public Uri Address(string path) => new($"http://127.0.0.1:{this.Port}{path}");
private async Task ServeAsync()
{
try
{
while (!this.stop.IsCancellationRequested)
{
using var client = await this.listener.AcceptTcpClientAsync(this.stop.Token);
await using var stream = client.GetStream();
var request = await ReadRequestHeadAsync(stream, this.stop.Token);
var response = request.StartsWith("GET /redirect ", StringComparison.Ordinal)
? $"HTTP/1.1 302 Found\r\nLocation: {this.redirectLocation ?? this.Address("/page").ToString()}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
: Page("<html><head><title>Travel</title></head><body><main><p>The travel policy of the intranet.</p></main></body></html>");
await stream.WriteAsync(Encoding.UTF8.GetBytes(response), this.stop.Token);
}
}
catch (Exception exception) when (exception is OperationCanceledException or ObjectDisposedException or SocketException)
{
// The test is over.
}
}
private static string Page(string html) => $"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {Encoding.UTF8.GetByteCount(html)}\r\nConnection: close\r\n\r\n{html}";
private static async Task<string> ReadRequestHeadAsync(NetworkStream stream, CancellationToken token)
{
var head = new StringBuilder();
var buffer = new byte[1024];
while (!head.ToString().Contains("\r\n\r\n", StringComparison.Ordinal))
{
var read = await stream.ReadAsync(buffer, token);
if (read == 0)
break;
head.Append(Encoding.ASCII.GetString(buffer, 0, read));
}
return head.ToString();
}
public void Dispose()
{
this.stop.Cancel();
this.listener.Stop();
this.stop.Dispose();
}
}
@@ -0,0 +1,221 @@
using AIStudio.Chat;
using AIStudio.Provider;
using AIStudio.Settings.DataModel;
using AIStudio.Tools;
using AIStudio.Tools.ToolCallingSystem;
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations;
using AIStudio.Tools.Web;
using Microsoft.Extensions.Logging.Abstractions;
namespace AIStudio.Tests.Tools.ToolCalling;
/// <summary>
/// Checks that the allowed private hosts count as services configured in AI Studio once a chat
/// may no longer send data to third parties.
/// </summary>
/// <remarks>
/// Without this, a chat which read one intranet page could open a second one only if its address
/// already stood in the chat. A host pattern such as *.example.org matches public hosts as well,
/// though, and an intranet server may redirect anywhere. An address the model chose on such a
/// host may therefore only reach non-public addresses, on every request, redirects included. This
/// holds for every source of the restriction, a mailbox included.
/// </remarks>
[TestFixture]
[NonParallelizable]
public sealed class ReadWebPageAllowedPrivateHostTests : ToolRegistryTestBase
{
private const string ALLOWED_PRIVATE_HOSTS = "intranet.example.org, *.corp.example.org";
private const string WIKI = "https://wiki.corp.example.org/confluence/";
private const string GIVEN = "https://intranet.example.org/newsletter/2026-10";
// A public address written as an IP address, so the check needs no name resolution, and the
// request is refused before anything is sent:
private const string PUBLIC_ADDRESS = "http://93.184.215.14/";
private static readonly Uri WIKI_URL = new(WIKI);
private static readonly Func<string, bool> IS_ALLOWED_PRIVATE_HOST = ReadWebPageTool.ReadAllowedPrivateHosts(ALLOWED_PRIVATE_HOSTS, out _);
[TestCase(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, "https://intranet.example.org/handbook/travel")]
[TestCase(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, "https://intranet.example.org/handbook/travel")]
[TestCase(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, "https://projects.corp.example.org/apollo")]
[TestCase(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, "https://projects.corp.example.org/apollo")]
public void AnAddressTheModelChoseOnAnAllowedPrivateHostIsReadUnderEveryRestriction(OutboundDataRestriction restriction, string address)
{
var allowed = ReadWebPageTool.IsAllowedByOutboundDataRestriction(new Uri(address), restriction, ChatWithTheUserWriting(GIVEN), WIKI_URL, IS_ALLOWED_PRIVATE_HOST, out var redirectLimit);
Assert.Multiple(() =>
{
Assert.That(allowed, Is.True, "The allowed private hosts are services configured in AI Studio, as the wiki is.");
Assert.That(redirectLimit, Is.EqualTo(ReadWebPageTool.RedirectLimit.NON_PUBLIC_ONLY), "A matching name is not enough: the request may only reach non-public addresses.");
});
}
[Test]
public void AChatWithoutRestrictionIsNotLimited()
{
var allowed = ReadWebPageTool.IsAllowedByOutboundDataRestriction(new Uri("https://intranet.example.org/handbook/travel"), OutboundDataRestriction.UNRESTRICTED, new ChatThread(), WIKI_URL, IS_ALLOWED_PRIVATE_HOST, out var redirectLimit);
Assert.Multiple(() =>
{
Assert.That(allowed, Is.True);
Assert.That(redirectLimit, Is.EqualTo(ReadWebPageTool.RedirectLimit.NONE));
});
}
[TestCase(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, "https://example.org/chosen-by-the-model")]
[TestCase(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, "https://example.org/chosen-by-the-model")]
[TestCase(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, "https://corp.example.org/")]
[TestCase(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, "https://intranet.example.org.attacker.example/")]
public void AnAddressOnAnyOtherHostIsStillRefused(OutboundDataRestriction restriction, string address)
{
var allowed = ReadWebPageTool.IsAllowedByOutboundDataRestriction(new Uri(address), restriction, ChatWithTheUserWriting(GIVEN), WIKI_URL, IS_ALLOWED_PRIVATE_HOST, out _);
Assert.That(allowed, Is.False, "A wildcard pattern matches the hosts below its domain only, not the domain itself or a host which merely starts with it.");
}
[Test]
public void AWikiOnAnAllowedPrivateHostStaysConfinedToTheWiki()
{
var allowed = ReadWebPageTool.IsAllowedByOutboundDataRestriction(new Uri("https://wiki.corp.example.org/confluence/display/TEAM/Budget"), OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, new ChatThread(), WIKI_URL, IS_ALLOWED_PRIVATE_HOST, out var redirectLimit);
Assert.Multiple(() =>
{
Assert.That(allowed, Is.True);
Assert.That(redirectLimit, Is.EqualTo(ReadWebPageTool.RedirectLimit.WITHIN_WIKI), "The stricter of both limits: a redirect out of the wiki is refused even when it stays in the intranet.");
});
}
[Test]
public void ALinkFromTheChatOnAnAllowedPrivateHostMayBeRedirectedAnywhere()
{
var allowed = ReadWebPageTool.IsAllowedByOutboundDataRestriction(new Uri(GIVEN), OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, ChatWithTheUserWriting(GIVEN), WIKI_URL, IS_ALLOWED_PRIVATE_HOST, out var redirectLimit);
Assert.Multiple(() =>
{
Assert.That(allowed, Is.True);
Assert.That(redirectLimit, Is.EqualTo(ReadWebPageTool.RedirectLimit.NONE), "The model did not write the address, so whatever a redirect carries came from the server.");
});
}
[Test]
public void AChatRestrictedByAMailboxNowReadsAllowedPrivateHosts()
{
//
// A deliberate loosening: mail content may now reach the allowed intranet hosts through
// an address the model chose, as it already could reach the wiki:
//
var chat = new ChatThread();
chat.RequireOutboundDataRestriction(new(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, OutboundDataOrigin.MAILBOX, "4b8e2c6a-1d3f-4a7e-9c5b-2e8d1f6a3c7b"));
var allowed = ReadWebPageTool.IsAllowedByOutboundDataRestriction(new Uri("https://intranet.example.org/handbook/travel"), chat.RequiredOutboundDataRestriction.Restriction, chat, wiki: null, IS_ALLOWED_PRIVATE_HOST, out _);
Assert.That(allowed, Is.True);
}
[TestCase(null, false)]
[TestCase("", false)]
[TestCase("intranet.example.org", true)]
[TestCase("https://intranet.example.org/", false)]
public void AnAllowlistWhichCannotBeReadAllowsNoHost(string? rawValue, bool expectedHasAny)
{
var isAllowedPrivateHost = ReadWebPageTool.ReadAllowedPrivateHosts(rawValue, out var hasAny);
Assert.Multiple(() =>
{
Assert.That(hasAny, Is.EqualTo(expectedHasAny));
Assert.That(isAllowedPrivateHost("intranet.example.org"), Is.EqualTo(expectedHasAny));
});
}
[Test]
public async Task WithAllowedPrivateHostsTheToolIsOfferedForConfiguredServicesOnly()
{
this.SettingsManager.ConfigurationData.Tools.Settings[ToolSelectionRules.READ_WEB_PAGE_TOOL_ID] = new() { ["allowedPrivateHosts"] = ALLOWED_PRIVATE_HOSTS };
var tool = new ReadWebPageTool(null!, null!, this.CreateToolSettingsService(), NullLogger<ReadWebPageTool>.Instance);
var chat = new ChatThread();
chat.RequireOutboundDataRestriction(new(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, OutboundDataOrigin.INTERNAL_WEB_PAGE, "intranet.example.org"));
var provider = ToolCapableProvider();
var function = await tool.ResolveFunctionAsync(tool.GetDefinition(), new()
{
Provider = provider,
Component = AIStudio.Tools.Components.CHAT,
ProviderConfidence = provider.UsedLLMProvider.GetConfidence(this.SettingsManager).Level,
ChatThread = chat,
});
Assert.That(function, Is.Not.Null, "Without a wiki, the allowed private hosts are what the chat can still read.");
}
[Test]
public void AnAllowedNameWhichResolvesToAPublicAddressIsRefused()
{
var service = new WebPageRetrievalService(new HTMLParser());
var exception = Assert.ThrowsAsync<WebPageAccessBlockedException>(() => service.RetrieveAsync(new Uri(PUBLIC_ADDRESS), new WebPageRetrievalOptions
{
TimeoutSeconds = 10,
ProviderConfidence = ConfidenceLevel.HIGH,
IsPrivateHostAllowed = _ => true,
NonPublicTargetsOnly = true,
}));
Assert.That(exception!.Reason, Is.EqualTo(WebPageAccessBlockReason.PUBLIC_TARGET_NOT_ALLOWED));
}
[Test]
public void ARedirectFromAnAllowedPrivateHostToAPublicAddressIsRefused()
{
using var server = new LoopbackWebServer(redirectLocation: PUBLIC_ADDRESS);
var service = new WebPageRetrievalService(new HTMLParser());
//
// The loopback address stands in for the intranet. It is reachable for an address the user
// chose only, which the limit holds for all the same, because it guards what the address
// may carry:
//
var exception = Assert.ThrowsAsync<WebPageAccessBlockedException>(() => service.RetrieveAsync(server.Address("/redirect"), new WebPageRetrievalOptions
{
TimeoutSeconds = 10,
TargetChosenByUser = true,
NonPublicTargetsOnly = true,
}));
Assert.Multiple(() =>
{
Assert.That(exception!.Reason, Is.EqualTo(WebPageAccessBlockReason.PUBLIC_TARGET_NOT_ALLOWED));
Assert.That(exception.Message, Does.Not.Contain("93.184.215.14"), "The refusal reaches the model and must not hand it an address.");
});
}
[Test]
public async Task APageWhichStaysOnNonPublicAddressesIsRead()
{
using var server = new LoopbackWebServer();
var service = new WebPageRetrievalService(new HTMLParser());
var retrievedPage = await service.RetrieveAsync(server.Address("/redirect"), new WebPageRetrievalOptions
{
TimeoutSeconds = 10,
TargetChosenByUser = true,
NonPublicTargetsOnly = true,
});
Assert.That(retrievedPage.Page.FinalUrl.AbsolutePath, Is.EqualTo("/page"));
}
private static ChatThread ChatWithTheUserWriting(string address) => new()
{
Blocks =
[
new ContentBlock
{
Time = new DateTimeOffset(2026, 10, 16, 9, 0, 0, TimeSpan.Zero),
ContentType = ContentType.TEXT,
Content = new ContentText { Text = $"What does {address} say about the budget?" },
Role = ChatRole.USER,
},
],
};
}
@@ -0,0 +1,125 @@
using AIStudio.Settings.DataModel;
using AIStudio.Tools;
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations;
using AIStudio.Tools.Web;
using Microsoft.Extensions.Logging.Abstractions;
namespace AIStudio.Tests.Tools.ToolCalling;
/// <summary>
/// Checks where a chat may still send data once Read Web Page brought an internal page into it.
/// </summary>
/// <remarks>
/// A page in the intranet or behind a VPN is internal to the organization, and so is every page
/// of its wiki, whatever its address. A web search or an address the model writes itself would
/// carry it to a third party, so such a page restricts the chat as a mailbox does. Both kinds
/// count alike, and only the page as it was read in the end decides. A public page demands nothing.
/// </remarks>
[TestFixture]
[NonParallelizable]
public sealed class ReadWebPageInternalPageOutboundDataRestrictionTests : ToolRegistryTestBase
{
private const string INTRANET_HOST = "intranet.example.org";
private const string INTRANET_PAGE = "https://intranet.example.org/handbook/travel";
private const string PUBLIC_PAGE = "https://example.org/newsletter/2026-10";
private const string WIKI_PAGE = "https://wiki.example.org/confluence/display/TEAM/Budget";
private static readonly Uri WIKI = new("https://wiki.example.org/confluence/");
[Test]
public void APublicPageDemandsNothing()
{
var requirement = ReadWebPageTool.GetOutboundDataRequirement(new Uri(PUBLIC_PAGE), reachedNonPublicAddress: false, OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, WIKI);
Assert.That(requirement, Is.EqualTo(OutboundDataRequirement.NONE));
}
[TestCase(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT)]
[TestCase(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES)]
public void AnIntranetPageRestrictsTheChatAndNamesItsHost(OutboundDataRestriction level)
{
var requirement = ReadWebPageTool.GetOutboundDataRequirement(new Uri(INTRANET_PAGE), reachedNonPublicAddress: true, level, WIKI);
Assert.That(requirement, Is.EqualTo(new OutboundDataRequirement(level, OutboundDataOrigin.INTERNAL_WEB_PAGE, INTRANET_HOST)));
}
[Test]
public void APublicAddressWhichRedirectsIntoTheIntranetRestrictsTheChat()
{
//
// The model asked for a public address, and the server sent it on to the intranet. What
// the model reads came from there, which the retrieval reports for the page as read:
//
var requirement = ReadWebPageTool.GetOutboundDataRequirement(new Uri(INTRANET_PAGE), reachedNonPublicAddress: true, OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, wiki: null);
Assert.That(requirement, Is.EqualTo(new OutboundDataRequirement(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, OutboundDataOrigin.INTERNAL_WEB_PAGE, INTRANET_HOST)));
}
[Test]
public void AnIntranetAddressWhichRedirectsToAPublicPageDemandsNothing()
{
// A sign-in page outside the intranet, say. Its content is public:
var requirement = ReadWebPageTool.GetOutboundDataRequirement(new Uri(PUBLIC_PAGE), reachedNonPublicAddress: false, OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, wiki: null);
Assert.That(requirement, Is.EqualTo(OutboundDataRequirement.NONE), "Only the page as it was read in the end decides.");
}
[TestCase(false)]
[TestCase(true)]
public void AWikiPageCountsAsAnInternalPageWhateverItsAddress(bool reachedNonPublicAddress)
{
var requirement = ReadWebPageTool.GetOutboundDataRequirement(new Uri(WIKI_PAGE), reachedNonPublicAddress, OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, WIKI);
Assert.That(requirement, Is.EqualTo(new OutboundDataRequirement(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, OutboundDataOrigin.INTERNAL_WEB_PAGE, WIKI.Host)), "The wiki holds internal content whatever its address, and it is restricted like any other internal page.");
}
[Test]
public void APageBesideTheWikiOnItsPublicHostDemandsNothing()
{
var requirement = ReadWebPageTool.GetOutboundDataRequirement(new Uri("https://wiki.example.org/blog/"), reachedNonPublicAddress: false, OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, WIKI);
Assert.That(requirement, Is.EqualTo(OutboundDataRequirement.NONE), "Only the path below the base URL is the wiki.");
}
[TestCase(null, OutboundDataRestriction.ONLY_LINKS_FROM_CHAT)]
[TestCase("", OutboundDataRestriction.ONLY_LINKS_FROM_CHAT)]
[TestCase(nameof(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES), OutboundDataRestriction.ONLY_CONFIGURED_SERVICES)]
[TestCase(nameof(OutboundDataRestriction.UNRESTRICTED), OutboundDataRestriction.UNRESTRICTED)]
[TestCase("ONLY_LINKS", OutboundDataRestriction.ONLY_CONFIGURED_SERVICES)]
[TestCase("2", OutboundDataRestriction.ONLY_CONFIGURED_SERVICES)]
[TestCase("UNRESTRICTED, ONLY_LINKS_FROM_CHAT", OutboundDataRestriction.ONLY_CONFIGURED_SERVICES)]
public void TheLevelOfInternalPagesIsReadFromItsSetting(string? configuredValue, OutboundDataRestriction expected)
{
Assert.That(ReadWebPageTool.ReadInternalPageOutboundDataRestriction(configuredValue), Is.EqualTo(expected), "A value which names no level counts as the strictest, because a level guessed too loosely would let internal content out.");
}
[Test]
public async Task AValueWhichNamesNoLevelIsReported()
{
var tool = new ReadWebPageTool(null!, null!, null!, NullLogger<ReadWebPageTool>.Instance);
var state = await tool.ValidateConfigurationAsync(tool.GetDefinition(), new Dictionary<string, string> { ["internalPageOutboundDataRestriction"] = "ONLY_LINKS" });
Assert.That(state?.IsConfigured, Is.False, "Guessing would decide on the organization's behalf where internal content may go.");
}
[Test]
public async Task TheRetrievalReportsANonPublicAddressOfThePageAsRead()
{
//
// The loopback address stands in for the intranet: it is the one non-public address a test
// can serve from. A redirect between a public address and it needs a public server, which
// a test cannot rely on; the service judges the last request the same way, whatever came
// before, and this one follows a redirect on the way:
//
using var server = new LoopbackWebServer();
var service = new WebPageRetrievalService(new HTMLParser());
var retrievedPage = await service.RetrieveAsync(server.Address("/redirect"), new WebPageRetrievalOptions
{
TimeoutSeconds = 10,
TargetChosenByUser = true,
});
Assert.Multiple(() =>
{
Assert.That(retrievedPage.Page.FinalUrl.AbsolutePath, Is.EqualTo("/page"), "The redirect was followed.");
Assert.That(retrievedPage.ReachedNonPublicAddress, Is.True);
});
}
}
@@ -30,15 +30,17 @@ public sealed class ReadWebPageOutboundDataRestrictionTests : ToolRegistryTestBa
private static readonly Uri WIKI_URL = new(WIKI);
private static readonly Func<string, bool> NO_PRIVATE_HOSTS = _ => false;
[Test]
public void AChatWhichReadNoMailboxReadsAnyPage()
{
var allowed = ReadWebPageTool.IsAllowedByOutboundDataRestriction(new Uri("https://example.org/chosen-by-the-model"), OutboundDataRestriction.UNRESTRICTED, ChatWithTheUserWriting(GIVEN), WIKI_URL, out var mustStayInWiki);
var allowed = ReadWebPageTool.IsAllowedByOutboundDataRestriction(new Uri("https://example.org/chosen-by-the-model"), OutboundDataRestriction.UNRESTRICTED, ChatWithTheUserWriting(GIVEN), WIKI_URL, NO_PRIVATE_HOSTS, out var redirectLimit);
Assert.Multiple(() =>
{
Assert.That(allowed, Is.True);
Assert.That(mustStayInWiki, Is.False);
Assert.That(redirectLimit, Is.EqualTo(ReadWebPageTool.RedirectLimit.NONE));
});
}
@@ -50,12 +52,12 @@ public sealed class ReadWebPageOutboundDataRestrictionTests : ToolRegistryTestBa
[TestCase("https://wiki.example.org/elsewhere/", false, false)]
public void ALinkFromTheChatOrAWikiPageIsRead(string address, bool expectedAllowed, bool expectedMustStayInWiki)
{
var allowed = ReadWebPageTool.IsAllowedByOutboundDataRestriction(new Uri(address), OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, ChatWithTheUserWriting(GIVEN), WIKI_URL, out var mustStayInWiki);
var allowed = ReadWebPageTool.IsAllowedByOutboundDataRestriction(new Uri(address), OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, ChatWithTheUserWriting(GIVEN), WIKI_URL, NO_PRIVATE_HOSTS, out var redirectLimit);
Assert.Multiple(() =>
{
Assert.That(allowed, Is.EqualTo(expectedAllowed));
Assert.That(mustStayInWiki, Is.EqualTo(expectedMustStayInWiki), "Only an address the model chose has to stay in the wiki, redirects included.");
Assert.That(redirectLimit is ReadWebPageTool.RedirectLimit.WITHIN_WIKI, Is.EqualTo(expectedMustStayInWiki), "Only an address the model chose has to stay in the wiki, redirects included.");
});
}
@@ -64,7 +66,7 @@ public sealed class ReadWebPageOutboundDataRestrictionTests : ToolRegistryTestBa
[TestCase("https://attacker.example/?mail=board-meeting", false)]
public void OnlyTheWikiIsReadWhenTheMailboxAllowsConfiguredServicesOnly(string address, bool expectedAllowed)
{
var allowed = ReadWebPageTool.IsAllowedByOutboundDataRestriction(new Uri(address), OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, ChatWithTheUserWriting(GIVEN), WIKI_URL, out _);
var allowed = ReadWebPageTool.IsAllowedByOutboundDataRestriction(new Uri(address), OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, ChatWithTheUserWriting(GIVEN), WIKI_URL, NO_PRIVATE_HOSTS, out _);
Assert.That(allowed, Is.EqualTo(expectedAllowed), "Not even an address the user wrote is read: the mailbox allows the configured services only.");
}
@@ -75,14 +77,14 @@ public sealed class ReadWebPageOutboundDataRestrictionTests : ToolRegistryTestBa
Assert.Multiple(() =>
{
Assert.That(ReadWebPageTool.IsAllowedByOutboundDataRestriction(new Uri(GIVEN), OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, chat, wiki: null, out _), Is.True);
Assert.That(ReadWebPageTool.IsAllowedByOutboundDataRestriction(new Uri(GIVEN), OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, chat, wiki: null, out _), Is.False);
Assert.That(ReadWebPageTool.IsAllowedByOutboundDataRestriction(new Uri(GIVEN), OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, chat, wiki: null, NO_PRIVATE_HOSTS, out _), Is.True);
Assert.That(ReadWebPageTool.IsAllowedByOutboundDataRestriction(new Uri(GIVEN), OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, chat, wiki: null, NO_PRIVATE_HOSTS, out _), Is.False);
});
}
[Test]
public void ALevelThisVersionDoesNotKnowCountsAsStrict() =>
Assert.That(ReadWebPageTool.IsAllowedByOutboundDataRestriction(new Uri(GIVEN), (OutboundDataRestriction)99, ChatWithTheUserWriting(GIVEN), WIKI_URL, out _), Is.False);
Assert.That(ReadWebPageTool.IsAllowedByOutboundDataRestriction(new Uri(GIVEN), (OutboundDataRestriction)99, ChatWithTheUserWriting(GIVEN), WIKI_URL, NO_PRIVATE_HOSTS, out _), Is.False);
[Test]
public void ARefusalDoesNotRepeatTheAddress()
@@ -191,7 +193,7 @@ public sealed class ReadWebPageOutboundDataRestrictionTests : ToolRegistryTestBa
private static ChatThread RestrictedChat(OutboundDataRestriction restriction)
{
var thread = ChatWithTheUserWriting(GIVEN);
thread.RequireOutboundDataRestriction(new(restriction, MAILBOX_ID));
thread.RequireOutboundDataRestriction(new(restriction, OutboundDataOrigin.MAILBOX, MAILBOX_ID));
return thread;
}
@@ -210,7 +210,7 @@ public sealed class SearchMailsToolTests : ToolRegistryTestBase
Assert.Multiple(() =>
{
Assert.That(confidence, Is.EqualTo(ConfidenceLevel.HIGH), "The private mail is in the chat now, so a provider has to be trusted as much as that mailbox asks.");
Assert.That(outboundData, Is.EqualTo(new OutboundDataRequirement(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, PRIVATE.Id)));
Assert.That(outboundData, Is.EqualTo(new OutboundDataRequirement(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, OutboundDataOrigin.MAILBOX, PRIVATE.Id)));
});
}
@@ -219,7 +219,7 @@ public sealed class SearchMailsToolTests : ToolRegistryTestBase
{
var (_, outboundData) = MailToolResults.GetRequirements([WORK, WORK with { Id = "1b5e9c3a-7d2f-4e6b-a8c1-0f4d6e2b9a75" }], OutboundDataRestriction.UNRESTRICTED);
Assert.That(outboundData.DataSourceId, Is.EqualTo(WORK.Id), "Otherwise the chat would name another mailbox with every search.");
Assert.That(outboundData.Reference, Is.EqualTo(WORK.Id), "Otherwise the chat would name another mailbox with every search.");
}
[Test]
@@ -30,7 +30,7 @@ public sealed class ToolExecutorTests : ToolRegistryTestBase
[Test]
public async Task WhatAResultDemandsReachesTheLoop()
{
var restriction = new OutboundDataRequirement(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, MAILBOX_ID);
var restriction = new OutboundDataRequirement(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, OutboundDataOrigin.MAILBOX, MAILBOX_ID);
var tool = new TestTool(Definition(), execute: _ => new ToolExecutionResult
{
TextContent = "A passage from the handbook.",
@@ -96,7 +96,7 @@ public sealed class ToolExecutorTests : ToolRegistryTestBase
[Test]
public async Task WhatAResultDemandsTightensTheChat()
{
var restriction = new OutboundDataRequirement(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, MAILBOX_ID);
var restriction = new OutboundDataRequirement(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, OutboundDataOrigin.MAILBOX, MAILBOX_ID);
var tool = new TestTool(Definition(), execute: _ => new ToolExecutionResult
{
TextContent = "A mail about the budget.",
@@ -0,0 +1,201 @@
using AIStudio.Chat;
using AIStudio.Provider;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.ToolCallingSystem;
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations;
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.Mailboxes;
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch;
using Microsoft.Extensions.Logging.Abstractions;
namespace AIStudio.Tests.Tools.ToolCalling;
/// <summary>
/// Checks that the model learns beforehand which tools end the web search of a chat.
/// </summary>
/// <remarks>
/// Once internal content enters a chat, it is no longer offered the web search. A model which does
/// not know that reads its mails or the wiki first and finds the search gone when it needs it. The
/// instructions of the tools which bring such content in therefore tell it to search the web first
/// when a task needs both, and they tell it what is left once a chat is restricted.
/// </remarks>
[TestFixture]
[NonParallelizable]
public sealed class ToolOrderInstructionsTests : ToolRegistryTestBase
{
private static readonly Uri WIKI = new("https://wiki.example.org/confluence/");
[Test]
public void TheWebSearchLeavesTheOrderToTheToolsWhichEndIt()
{
var instructions = new WebSearchTool([], null!, null!, NullLogger<WebSearchTool>.Instance).GetDefinition().SystemPromptInstructions;
Assert.Multiple(() =>
{
foreach (var toolId in new[] { ToolSelectionRules.SEARCH_MAILS_TOOL_ID, ToolSelectionRules.READ_MAIL_TOOL_ID, ToolSelectionRules.COUNT_MAILS_TOOL_ID, ToolSelectionRules.SEARCH_CONFLUENCE_TOOL_ID })
Assert.That(instructions, Does.Not.Contain($"`{toolId}`"), "The web search would name tools the request may not offer, and each of these words the rule itself.");
});
}
[TestCase(true)]
[TestCase(false)]
public async Task TheMailSearchSaysSoWhenTheRequestOffersTheWebSearch(bool offersWebSearch)
{
var tool = new SearchMailsTool(null!, null!, null!, null!, NullLogger<SearchMailsTool>.Instance);
string[] candidates = offersWebSearch ? [ToolSelectionRules.SEARCH_MAILS_TOOL_ID, ToolSelectionRules.WEB_SEARCH_TOOL_ID] : [ToolSelectionRules.SEARCH_MAILS_TOOL_ID];
var instructions = await tool.ResolveSystemPromptInstructionsAsync(tool.GetDefinition(), this.ResolutionContext(new ChatThread(), candidates));
Assert.That(instructions.Contains("search the web before you use the mail tools", StringComparison.Ordinal), Is.EqualTo(offersWebSearch));
}
[Test]
public void TheMailSearchSpeaksForTheWholeMailboxCollection()
{
Assert.Multiple(() =>
{
Assert.That(new SearchMailsTool(null!, null!, null!, null!, NullLogger<SearchMailsTool>.Instance).GetDefinition().SystemPromptInstructions, Does.Contain("search the web before you use the mail tools"), "The token count assumes a request which offers the web search.");
Assert.That(new CountMailsTool(null!, null!, null!, null!, NullLogger<CountMailsTool>.Instance).GetDefinition().SystemPromptInstructions, Does.Not.Contain("web_search"), "The collection is always offered together, so the rule would stand twice in the same request.");
});
}
[TestCase(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, true)]
[TestCase(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, true)]
[TestCase(OutboundDataRestriction.UNRESTRICTED, false)]
public async Task TheConfluenceSearchSaysSoWhenItEndsTheWebSearch(OutboundDataRestriction internalPageRestriction, bool expected)
{
this.SettingsManager.ConfigurationData.Tools.Settings[ToolSelectionRules.READ_WEB_PAGE_TOOL_ID] = new() { ["internalPageOutboundDataRestriction"] = internalPageRestriction.ToString() };
var tool = new ConfluenceSearchTool(null!, null!, this.CreateToolSettingsService());
var instructions = await tool.ResolveSystemPromptInstructionsAsync(tool.GetDefinition(), this.ResolutionContext(new ChatThread()));
Assert.That(instructions.Contains("search the web before you search the wiki", StringComparison.Ordinal), Is.EqualTo(expected), "Without a restriction, the search leaves the web search in place, and the rule would only cost the model a detour.");
}
[Test]
public async Task TheConfluenceSearchLeavesTheOrderOutWithoutAWebSearch()
{
var tool = new ConfluenceSearchTool(null!, null!, this.CreateToolSettingsService());
var instructions = await tool.ResolveSystemPromptInstructionsAsync(tool.GetDefinition(), this.ResolutionContext(new ChatThread(), ToolSelectionRules.SEARCH_CONFLUENCE_TOOL_ID, ToolSelectionRules.READ_WEB_PAGE_TOOL_ID));
Assert.That(instructions, Does.Not.Contain("web_search"), "The request does not offer the web search, so a rule about it would only confuse the model.");
}
[TestCase(true)]
[TestCase(false)]
public void ReadWebPageSaysSoWhenAnInternalPageEndsTheWebSearch(bool internalPageEndsWebSearch)
{
var instructions = ReadWebPageTool.BuildSystemPromptInstructions(FreeAddressChoice.OFF, OutboundDataRestriction.UNRESTRICTED, WIKI, internalPageEndsWebSearch: internalPageEndsWebSearch);
Assert.That(instructions.Contains("search the web before you read such a page", StringComparison.Ordinal), Is.EqualTo(internalPageEndsWebSearch));
}
[TestCase(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, true, true, false, true, TestName = "An internal page of the wiki ends the web search")]
[TestCase(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, true, false, true, true, TestName = "An internal page on an allowed private host ends the web search")]
[TestCase(OutboundDataRestriction.UNRESTRICTED, true, true, true, false, TestName = "Without a restriction, an internal page leaves the web search in place")]
[TestCase(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, false, true, true, false, TestName = "A request without a web search has none to lose")]
[TestCase(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, true, false, false, false, TestName = "Without a wiki and without allowed private hosts, no internal page can be read")]
public void ReadWebPageWarnsOnlyWhenAnInternalPageCanEndTheWebSearch(OutboundDataRestriction internalPageRestriction, bool offersWebSearch, bool hasWiki, bool hasAllowedPrivateHosts, bool expected)
{
var endsWebSearch = ReadWebPageTool.InternalPageEndsWebSearch(internalPageRestriction, offersWebSearch, hasWiki ? WIKI : null, hasAllowedPrivateHosts ? new[] { "intranet.example.org" } : null);
Assert.That(endsWebSearch, Is.EqualTo(expected));
}
[TestCase("intranet.example.org", true, true)]
[TestCase("intranet.example.org", false, false)]
[TestCase("", true, false)]
public async Task ARequestWarnsOfTheEndOfTheWebSearchOnlyWhenItCanHappen(string allowedPrivateHosts, bool offersWebSearch, bool expected)
{
this.SettingsManager.ConfigurationData.Tools.Settings[ToolSelectionRules.READ_WEB_PAGE_TOOL_ID] = new() { ["allowedPrivateHosts"] = allowedPrivateHosts };
var tool = new ReadWebPageTool(null!, null!, this.CreateToolSettingsService(), NullLogger<ReadWebPageTool>.Instance);
string[] candidates = offersWebSearch ? [ToolSelectionRules.READ_WEB_PAGE_TOOL_ID, ToolSelectionRules.WEB_SEARCH_TOOL_ID] : [ToolSelectionRules.READ_WEB_PAGE_TOOL_ID];
var instructions = await tool.ResolveSystemPromptInstructionsAsync(tool.GetDefinition(), this.ResolutionContext(new ChatThread(), candidates));
Assert.That(instructions.Contains("search the web before you read such a page", StringComparison.Ordinal), Is.EqualTo(expected));
}
[Test]
public async Task EveryToolLearnsWhichOthersTheRequestMayOffer()
{
var webSearch = new TestTool(Definition("web_search_test")) { OutboundData = ToolOutboundData.THIRD_PARTY_QUERIES };
var wiki = new TestTool(Definition("wiki_search_test")) { OutboundData = ToolOutboundData.CONFIGURED_SERVICE };
var registry = this.CreateRegistry(webSearch, wiki);
var restrictedChat = new ChatThread();
restrictedChat.RequireOutboundDataRestriction(new(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, OutboundDataOrigin.CONFLUENCE_WIKI, string.Empty));
await registry.GetRunnableToolsAsync(this.ResolutionContext(new ChatThread()), ["web_search_test", "wiki_search_test"], mayRunTools: true);
var unrestricted = wiki.LastInstructionsContext?.CandidateToolIds;
await registry.GetRunnableToolsAsync(this.ResolutionContext(restrictedChat), ["web_search_test", "wiki_search_test"], mayRunTools: true);
var restricted = wiki.LastInstructionsContext?.CandidateToolIds;
Assert.Multiple(() =>
{
Assert.That(unrestricted, Is.EquivalentTo(new[] { "web_search_test", "wiki_search_test" }));
Assert.That(restricted, Is.EquivalentTo(new[] { "wiki_search_test" }), "The chat may no longer search the web, so no tool may tell the model to search it first.");
});
}
[Test]
public void ARestrictedChatDoesNotRepeatTheOrder()
{
var instructions = ReadWebPageTool.BuildSystemPromptInstructions(FreeAddressChoice.OFF, OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, WIKI);
Assert.That(instructions, Does.Not.Contain("search the web before"), "The web search is gone already.");
}
[Test]
public void TheRulesOfARestrictedChatNameTheAllowedPrivateHosts()
{
var configuredServices = ReadWebPageTool.BuildSystemPromptInstructions(FreeAddressChoice.ON, OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, WIKI, ["intranet.example.org", "*.corp.example.org"]);
var linksFromChat = ReadWebPageTool.BuildSystemPromptInstructions(FreeAddressChoice.ON, OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, wiki: null, ["intranet.example.org"]);
Assert.Multiple(() =>
{
Assert.That(configuredServices, Does.Contain($"only reads pages of the wiki at {WIKI} or on the internal hosts intranet.example.org, *.corp.example.org, whatever the rules above allow"));
Assert.That(linksFromChat, Does.Contain("or a page on the internal hosts intranet.example.org, whatever the rules above allow"), "Without a wiki, the allowed hosts are the addresses the model may still choose.");
Assert.That(configuredServices, Does.Contain("This chat holds internal content").And.Not.Contain("content of e-mails"), "The restriction may come from the wiki or an internal page as well.");
});
}
[Test]
public async Task ARequestNamesTheAllowedPrivateHostsOfTheSettings()
{
this.SettingsManager.ConfigurationData.Tools.Settings[ToolSelectionRules.READ_WEB_PAGE_TOOL_ID] = new() { ["allowedPrivateHosts"] = "intranet.example.org, *.corp.example.org", ["freeAddressChoice"] = "ON" };
var tool = new ReadWebPageTool(null!, null!, this.CreateToolSettingsService(), NullLogger<ReadWebPageTool>.Instance);
var chat = new ChatThread();
chat.RequireOutboundDataRestriction(new(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, OutboundDataOrigin.INTERNAL_WEB_PAGE, "intranet.example.org"));
var instructions = await tool.ResolveSystemPromptInstructionsAsync(tool.GetDefinition(), this.ResolutionContext(chat));
Assert.That(instructions, Does.Contain("only reads pages on the internal hosts intranet.example.org, *.corp.example.org, whatever the rules above allow"));
}
[Test]
public void WithoutAWikiTheRulesNameNoWikiSearch()
{
var withoutWiki = ReadWebPageTool.BuildSystemPromptInstructions(FreeAddressChoice.OFF, OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, wiki: null, ["intranet.example.org"]);
var withWiki = ReadWebPageTool.BuildSystemPromptInstructions(FreeAddressChoice.OFF, OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, WIKI, ["intranet.example.org"]);
Assert.Multiple(() =>
{
Assert.That(withoutWiki, Does.Contain("only reads pages on the internal hosts intranet.example.org whose URL appears word for word in this conversation, and refuses every other URL."));
Assert.That(withoutWiki, Does.Not.Contain("wiki search"), "There is no wiki whose hits could be meant.");
Assert.That(withWiki, Does.Contain("whose URL appears word for word in this conversation, such as the hits of a wiki search, and refuses every other URL."));
});
}
/// <param name="thread">The chat the request continues.</param>
/// <param name="candidateToolIds">The tools the request may offer; when left out, unknown, so every tool may be there.</param>
private ToolResolutionContext ResolutionContext(ChatThread thread, params string[] candidateToolIds)
{
var provider = ToolCapableProvider();
return new()
{
Provider = provider,
Component = AIStudio.Tools.Components.CHAT,
ProviderConfidence = provider.UsedLLMProvider.GetConfidence(this.SettingsManager).Level,
ChatThread = thread,
CandidateToolIds = candidateToolIds.Length > 0 ? candidateToolIds.ToHashSet(StringComparer.Ordinal) : null,
};
}
}
@@ -81,7 +81,7 @@ public sealed class ToolOutboundDataTests : ToolRegistryTestBase
Assert.Multiple(() =>
{
Assert.That(restricted, Is.EqualTo(new[] { "wiki_search_test" }), "The configured wiki stays allowed, the web search is left out.");
Assert.That(unrestricted, Is.EquivalentTo(new[] { "web_search_test", "wiki_search_test" }), "A chat which read no mailbox gets every tool it selected.");
Assert.That(unrestricted, Is.EquivalentTo(new[] { "web_search_test", "wiki_search_test" }), "A chat which holds no internal content gets every tool it selected.");
});
}
@@ -146,8 +146,8 @@ public sealed class ToolOutboundDataTests : ToolRegistryTestBase
[Test]
public void AMailboxRemovedSinceIsNotNamed()
{
var requirement = new OutboundDataRequirement(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, MAILBOX_ID);
var message = requirement.GetToolBlockedMessage([new DataSourceMailbox { Id = "another-mailbox", Name = "Private mailbox" }]);
var requirement = new OutboundDataRequirement(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, OutboundDataOrigin.MAILBOX, MAILBOX_ID);
var message = requirement.GetToolBlockedMessage(new Data { Mailboxes = [new DataSourceMailbox { Id = "another-mailbox", Name = "Private mailbox" }] });
Assert.Multiple(() =>
{
@@ -157,10 +157,61 @@ public sealed class ToolOutboundDataTests : ToolRegistryTestBase
});
}
[Test]
public void AConfluenceSearchIsNamedAsTheWiki()
{
var requirement = new OutboundDataRequirement(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, OutboundDataOrigin.CONFLUENCE_WIKI, string.Empty);
var message = requirement.GetToolBlockedMessage(new Data { Mailboxes = [new DataSourceMailbox { Id = MAILBOX_ID, Name = "Work mailbox" }] });
Assert.Multiple(() =>
{
Assert.That(message, Does.Contain("Confluence wiki"), "The user learns that the wiki stands in the way.");
Assert.That(message, Does.Not.Contain("mailbox"), "No mailbox restricted the chat.");
Assert.That(message, Does.Contain(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT.GetName()));
});
}
[Test]
public void AnInternalWebPageIsNamedByItsHost()
{
var requirement = new OutboundDataRequirement(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, OutboundDataOrigin.INTERNAL_WEB_PAGE, "intranet.example.org");
var message = requirement.GetToolBlockedMessage(new Data());
Assert.Multiple(() =>
{
Assert.That(message, Does.Contain("intranet.example.org"), "The user learns which page stands in the way.");
Assert.That(message, Does.Not.Contain("mailbox"), "No mailbox restricted the chat.");
Assert.That(message, Does.Contain(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES.GetName()));
});
}
[Test]
public async Task ACallTurnedDownByTheWikiNamesTheWiki()
{
var hasRun = false;
var webSearch = new TestTool(Definition(), execute: _ =>
{
hasRun = true;
return new ToolExecutionResult { TextContent = "Results about the budget." };
}) { OutboundData = ToolOutboundData.THIRD_PARTY_QUERIES };
var thread = new ChatThread();
thread.RequireOutboundDataRestriction(new(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, OutboundDataOrigin.CONFLUENCE_WIKI, string.Empty));
var executor = new ToolExecutor(this.CreateToolSettingsService(), NullLogger<ToolExecutor>.Instance);
var outcome = await executor.ExecuteAsync("call-1", TOOL_ID, "{}", [(webSearch.GetDefinition(), webSearch)], new NoProvider(), thread, order: 1);
Assert.Multiple(() =>
{
Assert.That(hasRun, Is.False, "The wiki content would already have left AI Studio.");
Assert.That(outcome.Trace.Status, Is.EqualTo(ToolInvocationTraceStatus.BLOCKED));
Assert.That(outcome.Content, Does.Contain("Confluence wiki"));
});
}
private static ChatThread RestrictedChat(OutboundDataRestriction restriction)
{
var thread = new ChatThread();
thread.RequireOutboundDataRestriction(new(restriction, MAILBOX_ID));
thread.RequireOutboundDataRestriction(new(restriction, OutboundDataOrigin.MAILBOX, MAILBOX_ID));
return thread;
}
@@ -139,8 +139,16 @@ public abstract class ToolRegistryTestBase
return ValueTask.FromResult(resolve is null ? registeredDefinition.Function : resolve(registeredDefinition));
}
public ValueTask<string> ResolveSystemPromptInstructionsAsync(ToolDefinition registeredDefinition, ToolResolutionContext context, CancellationToken token = default) =>
ValueTask.FromResult(resolveInstructions is null ? registeredDefinition.SystemPromptInstructions : resolveInstructions(registeredDefinition));
/// <summary>
/// The context the tool was last asked for its instructions in, or null before that.
/// </summary>
public ToolResolutionContext? LastInstructionsContext { get; private set; }
public ValueTask<string> ResolveSystemPromptInstructionsAsync(ToolDefinition registeredDefinition, ToolResolutionContext context, CancellationToken token = default)
{
this.LastInstructionsContext = context;
return ValueTask.FromResult(resolveInstructions is null ? registeredDefinition.SystemPromptInstructions : resolveInstructions(registeredDefinition));
}
public IReadOnlySet<string> SensitiveTraceArgumentNames { get; } = new HashSet<string>(StringComparer.Ordinal);