From 2c17c4dcb0958b99ab591229142ef82613db75dd Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:57:58 +0000 Subject: [PATCH] Implement same approval process for LocalCodeAct as is used by Hyperlight --- .../LocalCodeActApprovalMode.cs | 25 ++ .../LocalCodeActProvider.cs | 24 +- .../LocalCodeActProviderOptions.cs | 13 ++ .../LocalExecuteCodeFunction.cs | 22 ++ .../README.md | 27 +++ .../ApprovalPropagationTests.cs | 218 ++++++++++++++++++ 6 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActApprovalMode.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/ApprovalPropagationTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActApprovalMode.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActApprovalMode.cs new file mode 100644 index 00000000000..5e10b88a64c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActApprovalMode.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.LocalCodeAct; + +/// +/// Controls the approval behavior for the execute_code tool exposed by +/// and . +/// +public enum LocalCodeActApprovalMode +{ + /// + /// execute_code always requires user approval before invocation. + /// + AlwaysRequire, + + /// + /// Approval is derived from the provider-owned CodeAct tool registry. + /// If any configured tool is an + /// , + /// execute_code also requires approval. Otherwise it does not. + /// + NeverRequire, +} diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProvider.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProvider.cs index 9d36c5e5706..33f9205f735 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProvider.cs @@ -36,6 +36,7 @@ public sealed class LocalCodeActProvider : AIContextProvider, IDisposable private static readonly IReadOnlyList s_stateKeys = [FixedStateKey]; private readonly CodeExecutor _executor; + private readonly LocalCodeActApprovalMode _approvalMode; private readonly ConcurrentDictionary _tools = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _fileMounts = new(StringComparer.Ordinal); @@ -66,6 +67,8 @@ public LocalCodeActProvider(string pythonExecutablePath, LocalCodeActProviderOpt options.BlockedBuiltins?.ToList()); } + this._approvalMode = options.ApprovalMode; + this._executor = new CodeExecutor( pythonExecutablePath, runnerScript, @@ -190,8 +193,15 @@ protected override ValueTask ProvideAIContextAsync(InvokingContext co this._fileMounts.Values.ToList()); FeatureUsageMarker.MarkUsed(); + var approvalRequired = ComputeApprovalRequired(this._approvalMode, snapshot.Tools); + var description = InstructionBuilder.BuildExecuteCodeDescription(snapshot.Tools, snapshot.FileMounts); - var executeCode = new ExecuteCodeFunction(this._executor, snapshot, description); + + AIFunction executeCode = new ExecuteCodeFunction(this._executor, snapshot, description); + if (approvalRequired) + { + executeCode = new ApprovalRequiredAIFunction(executeCode); + } var instructions = InstructionBuilder.BuildContextInstructions(); @@ -202,6 +212,18 @@ protected override ValueTask ProvideAIContextAsync(InvokingContext co }); } + /// + /// Computes whether execute_code must require approval for the supplied tool set. + /// + /// + /// Approval is bundled: because generated code can reach any registered tool through + /// call_tool(...) after execution has begun, a single approval-required tool escalates + /// the approval requirement to the whole execute_code invocation. + /// + internal static bool ComputeApprovalRequired(LocalCodeActApprovalMode mode, IReadOnlyList tools) => + mode == LocalCodeActApprovalMode.AlwaysRequire + || tools.Any(t => t.GetService() is not null); + private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(this._disposed, this); /// diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProviderOptions.cs index 208b9fe24f5..51a161a20ea 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProviderOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProviderOptions.cs @@ -18,6 +18,19 @@ public sealed class LocalCodeActProviderOptions /// public IEnumerable? Tools { get; set; } + /// + /// Gets or sets the approval mode for execute_code. + /// Defaults to . + /// + /// + /// Under , approval still propagates from the + /// tools in : if any of them is an , + /// execute_code requires approval as well. This is required because generated code can + /// invoke any registered tool via call_tool(...) once execution has started, at which + /// point per-tool approval can no longer be enforced. + /// + public LocalCodeActApprovalMode ApprovalMode { get; set; } = LocalCodeActApprovalMode.NeverRequire; + /// /// Gets or sets the initial set of file mounts exposed to generated code. /// diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalExecuteCodeFunction.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalExecuteCodeFunction.cs index 96736ab4b16..a8b0fd34e22 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalExecuteCodeFunction.cs +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalExecuteCodeFunction.cs @@ -20,6 +20,11 @@ namespace Microsoft.Agents.AI.LocalCodeAct; /// Use this when you want to expose code execution directly as a model-facing function without /// the indirection. Tools and file mounts are captured at /// construction time and immutable for the lifetime of the function. +/// When the configuration requires approval (per +/// or because a configured tool is itself +/// an ), the instance surfaces an +/// via , +/// which is how the rest of the framework discovers approval requirements. /// public sealed class LocalExecuteCodeFunction : AIFunction { @@ -28,6 +33,8 @@ public sealed class LocalExecuteCodeFunction : AIFunction private readonly CodeExecutor _executor; private readonly CodeExecutor.RunSnapshot _snapshot; private readonly AIFunction _inner; + private readonly bool _approvalRequired; + private ApprovalRequiredAIFunction? _approvalProxy; /// Initializes a new instance of the class. /// Path to the Python interpreter used for execution and validation. @@ -73,6 +80,8 @@ public LocalExecuteCodeFunction(string pythonExecutablePath, LocalCodeActProvide Name = ExecuteCodeName, Description = InstructionBuilder.BuildExecuteCodeDescription(tools, fileMounts), }); + + this._approvalRequired = LocalCodeActProvider.ComputeApprovalRequired(options.ApprovalMode, tools); } /// @@ -84,6 +93,19 @@ public LocalExecuteCodeFunction(string pythonExecutablePath, LocalCodeActProvide /// public override JsonElement JsonSchema => this._inner.JsonSchema; + /// + public override object? GetService(Type serviceType, object? serviceKey = null) + { + if (serviceKey is null + && this._approvalRequired + && serviceType == typeof(ApprovalRequiredAIFunction)) + { + return this._approvalProxy ??= new ApprovalRequiredAIFunction(this); + } + + return base.GetService(serviceType, serviceKey); + } + /// protected override ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) => this._inner.InvokeAsync(arguments, cancellationToken); diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/README.md b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/README.md index c8ac9e694d7..4bcd95720fc 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/README.md +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/README.md @@ -94,6 +94,33 @@ total = await call_tool("add", a=2, b=3) print(total) ``` +## Tool Approval + +Generated code reaches registered tools through `call_tool(...)`, which invokes +them directly. A per-tool approval interaction cannot be surfaced at that point, +so approval is **bundled** onto `execute_code` instead: + +* If any registered tool is an `ApprovalRequiredAIFunction`, `execute_code` + itself requires approval before the code runs. +* `LocalCodeActApprovalMode.AlwaysRequire` makes `execute_code` require approval + regardless of the registered tools. + +```csharp +var deploy = new ApprovalRequiredAIFunction( + AIFunctionFactory.Create(RunDeployment, name: "deploy")); + +using var provider = new LocalCodeActProvider("/usr/bin/python3", new LocalCodeActProviderOptions +{ + Tools = new[] { deploy }, + // ApprovalMode = LocalCodeActApprovalMode.AlwaysRequire, // optional, opt-in +}); +``` + +For `LocalCodeActProvider`, approval is recomputed on every run, so tools added +via `AddTools` after construction are taken into account. `LocalExecuteCodeFunction` +captures its tools at construction time and exposes the approval requirement +through `GetService()`. + ## Code Validation By default, the package validates Python code against allow-lists before diff --git a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/ApprovalPropagationTests.cs b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/ApprovalPropagationTests.cs new file mode 100644 index 00000000000..8228d853253 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/ApprovalPropagationTests.cs @@ -0,0 +1,218 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests; + +/// +/// Regression tests for approval propagation from provider-owned CodeAct tools to the +/// model-facing execute_code function. +/// +/// +/// Generated code reaches registered tools through call_tool(...), which invokes them +/// directly and therefore cannot surface a per-tool approval interaction. Approval must be +/// bundled onto execute_code instead. +/// +public sealed class ApprovalPropagationTests +{ + private static readonly AIAgent s_mockAgent = new Mock().Object; + + private static AIContextProvider.InvokingContext NewInvokingContext() => + new(s_mockAgent, session: null, new AIContext()); + + private static ApprovalRequiredAIFunction GatedTool() => + new(AIFunctionFactory.Create(() => "ok", name: "approval_gated_shell")); + + [Fact] + public void ComputeApprovalRequired_AlwaysRequire_NoTools_ReturnsTrue() + { + // Act / Assert + Assert.True(LocalCodeActProvider.ComputeApprovalRequired(LocalCodeActApprovalMode.AlwaysRequire, tools: [])); + } + + [Fact] + public void ComputeApprovalRequired_AlwaysRequire_WithoutGatedTool_ReturnsTrue() + { + // Arrange + var tool = AIFunctionFactory.Create(() => "ok", name: "t"); + + // Act / Assert + Assert.True(LocalCodeActProvider.ComputeApprovalRequired(LocalCodeActApprovalMode.AlwaysRequire, tools: [tool])); + } + + [Fact] + public void ComputeApprovalRequired_NeverRequire_NoTools_ReturnsFalse() + { + // Act / Assert + Assert.False(LocalCodeActProvider.ComputeApprovalRequired(LocalCodeActApprovalMode.NeverRequire, tools: [])); + } + + [Fact] + public void ComputeApprovalRequired_NeverRequire_WithoutGatedTool_ReturnsFalse() + { + // Arrange + var tool = AIFunctionFactory.Create(() => "ok", name: "t"); + + // Act / Assert + Assert.False(LocalCodeActProvider.ComputeApprovalRequired(LocalCodeActApprovalMode.NeverRequire, tools: [tool])); + } + + [Fact] + public void ComputeApprovalRequired_NeverRequire_WithGatedTool_ReturnsTrue() + { + // Arrange + var tool = GatedTool(); + + // Act / Assert + Assert.True(LocalCodeActProvider.ComputeApprovalRequired(LocalCodeActApprovalMode.NeverRequire, tools: [tool])); + } + + [Fact] + public async Task ProvideAIContextAsync_WithGatedTool_WrapsExecuteCodeInApprovalRequiredAsync() + { + // Arrange + using var provider = new LocalCodeActProvider( + "/usr/bin/python3", + new LocalCodeActProviderOptions + { + ValidationDisabled = true, + Tools = [GatedTool()], + }); + + // Act + var context = await provider.InvokingAsync(NewInvokingContext()); + + // Assert + var tool = Assert.IsType(context!.Tools!.Single()); + Assert.Equal("execute_code", tool.Name); + Assert.NotNull(tool.GetService()); + } + + [Fact] + public async Task ProvideAIContextAsync_ToolAddedAfterConstruction_WrapsExecuteCodeInApprovalRequiredAsync() + { + // Arrange + using var provider = new LocalCodeActProvider( + "/usr/bin/python3", + new LocalCodeActProviderOptions { ValidationDisabled = true }); + provider.AddTools(GatedTool()); + + // Act + var context = await provider.InvokingAsync(NewInvokingContext()); + + // Assert + _ = Assert.IsType(context!.Tools!.Single()); + } + + [Fact] + public async Task ProvideAIContextAsync_AlwaysRequire_WrapsExecuteCodeInApprovalRequiredAsync() + { + // Arrange + using var provider = new LocalCodeActProvider( + "/usr/bin/python3", + new LocalCodeActProviderOptions + { + ValidationDisabled = true, + ApprovalMode = LocalCodeActApprovalMode.AlwaysRequire, + }); + + // Act + var context = await provider.InvokingAsync(NewInvokingContext()); + + // Assert + _ = Assert.IsType(context!.Tools!.Single()); + } + + [Fact] + public async Task ProvideAIContextAsync_WithoutGatedTool_DoesNotRequireApprovalAsync() + { + // Arrange + using var provider = new LocalCodeActProvider( + "/usr/bin/python3", + new LocalCodeActProviderOptions + { + ValidationDisabled = true, + Tools = [AIFunctionFactory.Create(() => "ok", name: "ping")], + }); + + // Act + var context = await provider.InvokingAsync(NewInvokingContext()); + + // Assert + var tool = Assert.IsAssignableFrom(context!.Tools!.Single()); + Assert.IsNotType(tool); + Assert.Null(tool.GetService()); + } + + [Fact] + public void LocalExecuteCodeFunction_WithGatedTool_ExposesApprovalRequiredService() + { + // Arrange + var function = new LocalExecuteCodeFunction( + "/usr/bin/python3", + new LocalCodeActProviderOptions + { + ValidationDisabled = true, + Tools = [GatedTool()], + }); + + // Act + var marker = function.GetService(); + + // Assert + Assert.NotNull(marker); + Assert.Same(marker, function.GetService()); + Assert.Equal("execute_code", marker!.Name); + } + + [Fact] + public void LocalExecuteCodeFunction_AlwaysRequire_ExposesApprovalRequiredService() + { + // Arrange + var function = new LocalExecuteCodeFunction( + "/usr/bin/python3", + new LocalCodeActProviderOptions + { + ValidationDisabled = true, + ApprovalMode = LocalCodeActApprovalMode.AlwaysRequire, + }); + + // Act / Assert + Assert.NotNull(function.GetService()); + } + + [Fact] + public void LocalExecuteCodeFunction_WithoutGatedTool_DoesNotExposeApprovalRequiredService() + { + // Arrange + var function = new LocalExecuteCodeFunction( + "/usr/bin/python3", + new LocalCodeActProviderOptions + { + ValidationDisabled = true, + Tools = [AIFunctionFactory.Create(() => "ok", name: "ping")], + }); + + // Act / Assert + Assert.Null(function.GetService()); + } + + [Fact] + public void LocalExecuteCodeFunction_WithServiceKey_DoesNotExposeApprovalRequiredService() + { + // Arrange + var function = new LocalExecuteCodeFunction( + "/usr/bin/python3", + new LocalCodeActProviderOptions + { + ValidationDisabled = true, + Tools = [GatedTool()], + }); + + // Act / Assert + Assert.Null(function.GetService(typeof(ApprovalRequiredAIFunction), serviceKey: "key")); + } +}