Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -741,11 +741,19 @@ private static bool ArgumentsMatch(IDictionary<string, string> ruleArguments, ID
/// <summary>
/// Serializes function call arguments to a string dictionary for storage and comparison.
/// </summary>
private static Dictionary<string, string>? SerializeArguments(IDictionary<string, object?>? arguments, JsonSerializerOptions jsonSerializerOptions)
/// <remarks>
/// Always returns a non-null dictionary so that an argument-scoped standing approval
/// (the <see cref="AlwaysApproveToolApprovalResponseContent.AlwaysApproveToolWithArguments"/>
/// path) records an exact-arguments rule. A <see langword="null"/> or empty source dictionary
/// yields an empty dictionary, which matches only future no-argument calls. A <see langword="null"/>
/// value is reserved on <see cref="ToolApprovalRule.Arguments"/> for tool-level rules and is never
/// produced here, preventing an exact-arguments approval from widening into a tool-level approval.
/// </remarks>
private static Dictionary<string, string> SerializeArguments(IDictionary<string, object?>? arguments, JsonSerializerOptions jsonSerializerOptions)
{
if (arguments is null || arguments.Count == 0)
{
return null;
return new Dictionary<string, string>(StringComparer.Ordinal);
}

var serialized = new Dictionary<string, string>(arguments.Count, StringComparer.Ordinal);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,16 @@ namespace Microsoft.Agents.AI;
/// <item><b>Tool-level</b>: When <see cref="Arguments"/> is <see langword="null"/>,
/// all calls to the tool identified by <see cref="ToolName"/> are auto-approved.</item>
/// <item><b>Tool+arguments</b>: When <see cref="Arguments"/> is non-null,
/// only calls to the specified tool with exactly matching argument values are auto-approved.</item>
/// only calls to the specified tool with exactly matching argument values are auto-approved.
/// A non-null but <b>empty</b> dictionary matches only calls that supply no arguments; it does
/// not widen into a tool-level rule.</item>
/// </list>
/// </para>
/// <para>
/// <see langword="null"/> is therefore reserved exclusively for tool-level approval. An
/// argument-scoped approval (including one created from a no-argument call) is always stored
/// as a non-null dictionary so it cannot be silently broadened to all invocations of the tool.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
internal sealed class ToolApprovalRule
Expand All @@ -34,7 +41,8 @@ internal sealed class ToolApprovalRule
/// <summary>
/// Gets or sets the specific argument values that must match for this rule to apply.
/// When <see langword="null"/>, the rule applies to all invocations of the tool
/// regardless of arguments.
/// regardless of arguments. A non-null but empty dictionary applies only to
/// invocations that supply no arguments.
/// </summary>
/// <remarks>
/// Argument values are stored as their JSON-serialized string representations
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,95 @@ public async Task RunAsync_ToolWithArgsRule_DoesNotAutoApproveDifferentArgsAsync
Assert.Equal("ReadFile", ((FunctionCallContent)requests[0].ToolCall).Name);
}

/// <summary>
/// Verify that approving a no-argument call with the "always approve with exact arguments"
/// option does NOT auto-approve a later same-tool call that supplies arguments. The later
/// call must still surface for explicit approval (security regression for #6486).
/// </summary>
[Fact]
public async Task RunAsync_EmptyArgsToolWithArgsRule_DoesNotAutoApproveCallWithArgumentsAsync()
{
// Arrange
var session = new ChatClientAgentSession();

// Approve a no-argument SendPayment call with "always approve with exact arguments".
var ruleRequest = new ToolApprovalRequestContent("req0", new FunctionCallContent("call0", "SendPayment"));
var alwaysApproveResponse = ruleRequest.CreateAlwaysApproveToolWithArgumentsResponse();

// The inner agent then requests SendPayment WITH sensitive arguments.
var sensitiveArgs = new Dictionary<string, object?>
{
["recipient"] = "attacker@example.test",
["amount"] = 5000,
};
var newApprovalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "SendPayment", sensitiveArgs));
var approvalResponseMsg = new AgentResponse([new ChatMessage(ChatRole.Assistant, [newApprovalRequest])]);

var innerAgent = CreateMockAgent(approvalResponseMsg);
var agent = new ToolApprovalAgent(innerAgent.Object);
var inputMessages = new List<ChatMessage>
{
new(ChatRole.User, [alwaysApproveResponse]),
};

// Act
var response = await agent.RunAsync(inputMessages, session);

// Assert — the argument-bearing request must surface, not be auto-approved.
var requests = response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().ToList();
Assert.Single(requests);
Assert.Equal("SendPayment", ((FunctionCallContent)requests[0].ToolCall).Name);
}

/// <summary>
/// Verify that approving a no-argument call with the "always approve with exact arguments"
/// option still auto-approves a later same-tool call that also supplies no arguments,
/// preserving the intended narrow behavior.
/// </summary>
[Fact]
public async Task RunAsync_EmptyArgsToolWithArgsRule_AutoApprovesLaterEmptyArgsCallAsync()
{
// Arrange
var session = new ChatClientAgentSession();

var ruleRequest = new ToolApprovalRequestContent("req0", new FunctionCallContent("call0", "SendPayment"));
var alwaysApproveResponse = ruleRequest.CreateAlwaysApproveToolWithArgumentsResponse();

// Inner agent first re-requests SendPayment with no arguments, then returns a final response.
var emptyArgsRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "SendPayment"));
var approvalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, [emptyArgsRequest])]);
var finalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Payment sent")]);

var callCount = 0;
var innerAgent = new Mock<AIAgent>();
innerAgent
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(() =>
{
callCount++;
return callCount == 1 ? approvalResponse : finalResponse;
});

var agent = new ToolApprovalAgent(innerAgent.Object);
var inputMessages = new List<ChatMessage>
{
new(ChatRole.User, [alwaysApproveResponse]),
};

// Act
var response = await agent.RunAsync(inputMessages, session);

// Assert — the no-argument call is auto-approved and the inner agent reaches its final response.
Assert.Equal("Payment sent", response.Text);
var requests = response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().ToList();
Assert.Empty(requests);
}

#endregion

#region Mixed Auto-Approve
Expand Down Expand Up @@ -728,6 +817,56 @@ public void MatchesRule_JsonElementArgs_MatchesCorrectly()
Assert.True(ToolApprovalAgent.MatchesRule(request, rules, AgentJsonUtilities.DefaultOptions));
}

/// <summary>
/// Verify that an empty-arguments rule (non-null but empty) matches only a call that
/// supplies no arguments, and never widens into a tool-level match.
/// </summary>
[Fact]
public void MatchesRule_EmptyArgumentsRule_MatchesEmptyArgumentCall_ReturnsTrue()
{
// Arrange — an exact-arguments approval of a no-argument call is stored as an empty dictionary.
var rules = new List<ToolApprovalRule>
{
new()
{
ToolName = "SendPayment",
Arguments = new Dictionary<string, string>(),
},
};
var request = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "SendPayment"));

// Act & Assert
Assert.True(ToolApprovalAgent.MatchesRule(request, rules, AgentJsonUtilities.DefaultOptions));
}

/// <summary>
/// Verify that an empty-arguments rule does NOT match a later same-tool call that supplies
/// arguments. This guards against an exact-arguments approval of a no-argument call being
/// widened into a tool-level approval.
/// </summary>
[Fact]
public void MatchesRule_EmptyArgumentsRule_DoesNotMatchCallWithArguments_ReturnsFalse()
{
// Arrange
var rules = new List<ToolApprovalRule>
{
new()
{
ToolName = "SendPayment",
Arguments = new Dictionary<string, string>(),
},
};
var request = new ToolApprovalRequestContent("req1",
new FunctionCallContent("call1", "SendPayment", new Dictionary<string, object?>
{
["recipient"] = "attacker@example.test",
["amount"] = 5000,
}));

// Act & Assert
Assert.False(ToolApprovalAgent.MatchesRule(request, rules, AgentJsonUtilities.DefaultOptions));
}

#endregion

#region Extension Methods
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,32 @@ public void Serialize_ToolWithArgsRule_RoundTrips()
Assert.Equal("utf-8", deserialized.Arguments["encoding"]);
}

/// <summary>
/// Verify that an empty-arguments rule round-trips as a non-null empty dictionary, keeping it
/// distinct from a tool-level (null arguments) rule so persisted session state preserves the
/// narrower scope.
/// </summary>
[Fact]
public void Serialize_EmptyArgsRule_RoundTrips()
{
// Arrange
var rule = new ToolApprovalRule
{
ToolName = "SendPayment",
Arguments = new Dictionary<string, string>(),
};

// Act
var json = JsonSerializer.Serialize(rule, AgentJsonUtilities.DefaultOptions);
var deserialized = JsonSerializer.Deserialize<ToolApprovalRule>(json, AgentJsonUtilities.DefaultOptions);

// Assert
Assert.NotNull(deserialized);
Assert.Equal("SendPayment", deserialized!.ToolName);
Assert.NotNull(deserialized.Arguments);
Assert.Empty(deserialized.Arguments!);
}

/// <summary>
/// Verify that JSON property names are correctly applied.
/// </summary>
Expand Down
Loading