diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/ProcessBridge.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/ProcessBridge.cs index f0a4498f8f4..dee86154c38 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/ProcessBridge.cs +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/ProcessBridge.cs @@ -106,18 +106,14 @@ public async Task RunAsync(string code, CancellationToken cance private void ConfigureEnvironment(ProcessStartInfo startInfo) { - // Null => inherit the parent environment (documented contract on - // LocalCodeActProviderOptions.Environment). Callers wanting a scrubbed - // environment pass an empty dictionary. - if (this._environment is null) - { - return; - } - startInfo.Environment.Clear(); - foreach (var kvp in this._environment) + + if (this._environment is not null) { - startInfo.Environment[kvp.Key] = kvp.Value; + foreach (var kvp in this._environment) + { + startInfo.Environment[kvp.Key] = kvp.Value; + } } // Without these on Windows, Python may fail to load its standard library. diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProviderOptions.cs index 1dc86108abf..208b9fe24f5 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProviderOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProviderOptions.cs @@ -27,10 +27,9 @@ public sealed class LocalCodeActProviderOptions /// Gets or sets environment variables passed to the subprocess. /// /// - /// When , the subprocess inherits the parent process environment - /// (the default behavior). To run with - /// a restricted environment, supply a dictionary containing only the variables the - /// subprocess should see — pass an empty dictionary for a fully scrubbed environment. + /// The subprocess does not inherit the parent process environment. When this property is + /// or empty, the subprocess runs with a scrubbed environment. + /// Otherwise, the subprocess receives only the variables in the supplied dictionary. /// On Windows, a small set of system variables (SYSTEMROOT, SYSTEMDRIVE, COMSPEC, /// PATHEXT, TEMP, TMP) is back-filled from the parent environment when not already /// present so Python can locate its standard library. diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/README.md b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/README.md index 13ca7c38477..ef212e27115 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/README.md +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/README.md @@ -156,7 +156,8 @@ scanned for **new** files after execution, and those files are returned as ## Environment Variables Pass environment variables explicitly. The subprocess does NOT inherit the host -environment by default: +environment by default. On Windows, the system variables required for Python to +load its standard library are retained: ```csharp using var provider = new LocalCodeActProvider("/usr/bin/python3", new LocalCodeActProviderOptions diff --git a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionIntegrationTests.cs index 45952809de8..c95ce790862 100644 --- a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionIntegrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionIntegrationTests.cs @@ -108,6 +108,84 @@ public async Task ExecuteCode_AllowsPermittedOsAccessAsync(string code) Assert.NotNull(result); } + [Fact] + public async Task ExecuteCode_DefaultEnvironmentDoesNotInheritParentVariablesAsync() + { + SkipIfNoPython(); + + // Arrange + var variableName = $"AF_LOCALCODEACT_PARENT_{Guid.NewGuid():N}"; + var parentValue = $"parent-value-{Guid.NewGuid():N}"; + var originalValue = Environment.GetEnvironmentVariable(variableName); + Environment.SetEnvironmentVariable(variableName, parentValue); + + try + { + var function = new LocalExecuteCodeFunction(s_python!); + var args = new AIFunctionArguments + { + ["code"] = $"import os\nprint(os.environ.get('{variableName}', 'NOT_FOUND'))", + }; + + // Act + var result = await function.InvokeAsync(args, CancellationToken.None); + + // Assert + var combined = GetResultText(result); + Assert.Contains("NOT_FOUND", combined, StringComparison.Ordinal); + Assert.DoesNotContain(parentValue, combined, StringComparison.Ordinal); + } + finally + { + Environment.SetEnvironmentVariable(variableName, originalValue); + } + } + + [Fact] + public async Task ExecuteCode_ExplicitEnvironmentDoesNotInheritOtherParentVariablesAsync() + { + SkipIfNoPython(); + + // Arrange + var parentVariableName = $"AF_LOCALCODEACT_PARENT_{Guid.NewGuid():N}"; + var childVariableName = $"AF_LOCALCODEACT_CHILD_{Guid.NewGuid():N}"; + var parentValue = $"parent-value-{Guid.NewGuid():N}"; + var childValue = $"child-value-{Guid.NewGuid():N}"; + var originalValue = Environment.GetEnvironmentVariable(parentVariableName); + Environment.SetEnvironmentVariable(parentVariableName, parentValue); + + try + { + var options = new LocalCodeActProviderOptions + { + Environment = new Dictionary + { + [childVariableName] = childValue, + }, + }; + var function = new LocalExecuteCodeFunction(s_python!, options); + var args = new AIFunctionArguments + { + ["code"] = + $"import os\nprint(os.environ.get('{childVariableName}', 'NOT_FOUND'))\n" + + $"print(os.environ.get('{parentVariableName}', 'NOT_FOUND'))", + }; + + // Act + var result = await function.InvokeAsync(args, CancellationToken.None); + + // Assert + var combined = GetResultText(result); + Assert.Contains(childValue, combined, StringComparison.Ordinal); + Assert.Contains("NOT_FOUND", combined, StringComparison.Ordinal); + Assert.DoesNotContain(parentValue, combined, StringComparison.Ordinal); + } + finally + { + Environment.SetEnvironmentVariable(parentVariableName, originalValue); + } + } + [Fact] public async Task ExecuteCode_CapturesFilesInWritableMountAsync() {