chore: import upstream snapshot with attribution
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.LocalCodeAct;
/// <summary>
/// Exception thrown when AST validation of generated Python code fails.
/// </summary>
public sealed class CodeValidationException : Exception
{
/// <summary>Initializes a new instance of the <see cref="CodeValidationException"/> class.</summary>
public CodeValidationException()
{
}
/// <summary>Initializes a new instance of the <see cref="CodeValidationException"/> class.</summary>
/// <param name="message">Validation error message.</param>
public CodeValidationException(string message) : base(message)
{
}
/// <summary>Initializes a new instance of the <see cref="CodeValidationException"/> class.</summary>
/// <param name="message">Validation error message.</param>
/// <param name="innerException">Underlying exception.</param>
public CodeValidationException(string message, Exception innerException) : base(message, innerException)
{
}
}
@@ -0,0 +1,64 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.LocalCodeAct;
/// <summary>
/// File mount access mode.
/// </summary>
public enum FileMountMode
{
/// <summary>Read-only access. Files are not scanned for capture after execution.</summary>
ReadOnly,
/// <summary>Read-write access. New or modified files are captured after execution.</summary>
ReadWrite,
}
/// <summary>
/// Represents a host directory exposed to locally executed code.
/// </summary>
/// <remarks>
/// <para>
/// Unlike a true sandbox, mounts in this package expose <see cref="HostPath"/>
/// directly to the subprocess. The <see cref="MountPath"/> is metadata used to
/// describe the mount to the model in the function description and to label
/// captured files. Real isolation must come from the surrounding sandbox
/// (container, VM, Foundry hosted agent, etc.).
/// </para>
/// </remarks>
public sealed class FileMount
{
/// <summary>
/// Initializes a new instance of the <see cref="FileMount"/> class.
/// </summary>
/// <param name="hostPath">Path on the host filesystem to expose to the subprocess. Must exist.</param>
/// <param name="mountPath">
/// Logical path used to describe the mount to the model (for example <c>"/input/data.csv"</c>).
/// </param>
/// <param name="mode">Access mode for the mount. Defaults to <see cref="FileMountMode.ReadWrite"/>.</param>
/// <param name="writeBytesLimit">
/// Optional per-mount write capture limit (in bytes). When <see langword="null"/>, the global
/// <see cref="ProcessExecutionLimits.MaxCapturedFileBytes"/> applies.
/// </param>
public FileMount(string hostPath, string mountPath, FileMountMode mode = FileMountMode.ReadWrite, long? writeBytesLimit = null)
{
this.HostPath = Throw.IfNullOrWhitespace(hostPath);
this.MountPath = Throw.IfNullOrWhitespace(mountPath);
this.Mode = mode;
this.WriteBytesLimit = writeBytesLimit;
}
/// <summary>Gets the host filesystem path exposed to the subprocess.</summary>
public string HostPath { get; }
/// <summary>Gets the logical mount path used to describe the mount to the model.</summary>
public string MountPath { get; }
/// <summary>Gets the access mode for the mount.</summary>
public FileMountMode Mode { get; }
/// <summary>Gets the optional per-mount write capture limit (in bytes).</summary>
public long? WriteBytesLimit { get; }
}
@@ -0,0 +1,107 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.LocalCodeAct.Internal;
/// <summary>
/// Coordinates a single execution: optional validation, snapshot of writable mounts,
/// running the subprocess, capturing written files, and assembling the final content list.
/// </summary>
internal sealed class CodeExecutor
{
private readonly string _pythonExecutable;
private readonly string _runnerScript;
private readonly CodeValidator? _validator;
private readonly ProcessExecutionLimits _limits;
private readonly IReadOnlyDictionary<string, string>? _environment;
private readonly string? _workingDirectory;
public CodeExecutor(
string pythonExecutable,
string runnerScript,
CodeValidator? validator,
ProcessExecutionLimits limits,
IReadOnlyDictionary<string, string>? environment,
string? workingDirectory)
{
this._pythonExecutable = pythonExecutable;
this._runnerScript = runnerScript;
this._validator = validator;
this._limits = limits;
this._environment = environment;
this._workingDirectory = workingDirectory;
}
/// <summary>Immutable snapshot of provider state captured at the start of an invocation.</summary>
public sealed class RunSnapshot
{
public RunSnapshot(IReadOnlyList<AIFunction> tools, IReadOnlyList<FileMount> fileMounts)
{
this.Tools = tools;
this.FileMounts = fileMounts;
}
public IReadOnlyList<AIFunction> Tools { get; }
public IReadOnlyList<FileMount> FileMounts { get; }
}
public async Task<List<AIContent>> ExecuteAsync(RunSnapshot snapshot, string code, CancellationToken cancellationToken)
{
if (this._validator is not null)
{
await this._validator.ValidateAsync(code, cancellationToken).ConfigureAwait(false);
}
var preState = FileMountHelper.SnapshotWritableMounts(snapshot.FileMounts);
var bridge = new ProcessBridge(
this._pythonExecutable,
this._runnerScript,
snapshot.Tools,
this._limits,
this._environment,
this._workingDirectory);
var result = await bridge.RunAsync(code, cancellationToken).ConfigureAwait(false);
var captured = FileMountHelper.CaptureWrittenFiles(snapshot.FileMounts, preState, this._limits);
return BuildContents(result, captured);
}
private static List<AIContent> BuildContents(ProcessBridge.ExecutionResult result, List<AIContent> capturedFiles)
{
var contents = new List<AIContent>();
if (!string.IsNullOrEmpty(result.Stdout))
{
var stdoutText = result.StdoutTruncated ? result.Stdout + "\n[stdout truncated]" : result.Stdout;
contents.Add(new TextContent(stdoutText));
}
if (!string.IsNullOrEmpty(result.Stderr))
{
var stderrText = result.StderrTruncated ? result.Stderr + "\n[stderr truncated]" : result.Stderr;
contents.Add(new TextContent("stderr:\n" + stderrText));
}
if (result.OutputPresent && result.Output.HasValue)
{
contents.Add(new TextContent("result:\n" + result.Output.Value.GetRawText()));
}
contents.AddRange(capturedFiles);
if (contents.Count == 0)
{
contents.Add(new TextContent("Code executed successfully without output."));
}
return contents;
}
}
@@ -0,0 +1,178 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.LocalCodeAct.Internal;
/// <summary>
/// Runs the embedded Python AST validator in a child process with a strict timeout.
/// </summary>
internal sealed class CodeValidator
{
private readonly string _pythonExecutable;
private readonly string _validatorScript;
private readonly TimeSpan _timeout;
private readonly IReadOnlyList<string>? _allowedImports;
private readonly IReadOnlyList<string>? _blockedImports;
private readonly IReadOnlyList<string>? _allowedBuiltins;
private readonly IReadOnlyList<string>? _blockedBuiltins;
public CodeValidator(
string pythonExecutable,
string validatorScript,
TimeSpan timeout,
IReadOnlyList<string>? allowedImports,
IReadOnlyList<string>? blockedImports,
IReadOnlyList<string>? allowedBuiltins,
IReadOnlyList<string>? blockedBuiltins)
{
this._pythonExecutable = pythonExecutable;
this._validatorScript = validatorScript;
this._timeout = timeout;
this._allowedImports = allowedImports;
this._blockedImports = blockedImports;
this._allowedBuiltins = allowedBuiltins;
this._blockedBuiltins = blockedBuiltins;
}
/// <summary>Validates Python source code against the configured allow-lists.</summary>
/// <exception cref="CodeValidationException">Thrown when validation fails.</exception>
public async Task ValidateAsync(string code, CancellationToken cancellationToken)
{
var request = new JsonObject
{
["code"] = code,
};
AddList(request, "allowed_imports", this._allowedImports);
AddList(request, "blocked_imports", this._blockedImports);
AddList(request, "allowed_builtins", this._allowedBuiltins);
AddList(request, "blocked_builtins", this._blockedBuiltins);
var requestJson = request.ToJsonString();
var startInfo = new ProcessStartInfo
{
FileName = this._pythonExecutable,
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
startInfo.ArgumentList.Add("-I");
startInfo.ArgumentList.Add(this._validatorScript);
using var process = Process.Start(startInfo)
?? throw new InvalidOperationException("Failed to start Python validator process.");
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(this._timeout);
try
{
await process.StandardInput.WriteLineAsync(requestJson.AsMemory(), timeoutCts.Token).ConfigureAwait(false);
await process.StandardInput.FlushAsync(timeoutCts.Token).ConfigureAwait(false);
process.StandardInput.Close();
var stdoutTask = process.StandardOutput.ReadToEndAsync(timeoutCts.Token);
var stderrTask = process.StandardError.ReadToEndAsync(timeoutCts.Token);
await process.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false);
var stdout = await stdoutTask.ConfigureAwait(false);
var stderr = await stderrTask.ConfigureAwait(false);
if (process.ExitCode == 0)
{
return;
}
throw new CodeValidationException(ExtractError(stdout, stderr));
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
{
TryKill(process);
throw new CodeValidationException($"Code validation exceeded {this._timeout.TotalSeconds:F0} seconds.");
}
catch
{
TryKill(process);
throw;
}
}
private static string ExtractError(string output, string errorOutput)
{
if (string.IsNullOrWhiteSpace(output))
{
return string.IsNullOrWhiteSpace(errorOutput) ? "Code validation failed." : errorOutput;
}
try
{
using var doc = JsonDocument.Parse(output);
if (doc.RootElement.TryGetProperty("errors", out var errors) && errors.ValueKind == JsonValueKind.Array)
{
var sb = new StringBuilder();
foreach (var err in errors.EnumerateArray())
{
if (sb.Length > 0)
{
sb.Append("; ");
}
sb.Append(err.ValueKind == JsonValueKind.String ? err.GetString() : err.ToString());
}
return sb.Length > 0 ? sb.ToString() : output;
}
if (doc.RootElement.TryGetProperty("message", out var message) && message.ValueKind == JsonValueKind.String)
{
return message.GetString() ?? output;
}
}
catch (JsonException)
{
// fall through
}
return output;
}
private static void TryKill(Process process)
{
try
{
if (!process.HasExited)
{
process.Kill(entireProcessTree: true);
}
}
catch
{
#pragma warning disable CA1031 // Do not catch general exception types
// best-effort cleanup
#pragma warning restore CA1031
}
}
private static void AddList(JsonObject obj, string key, IReadOnlyList<string>? values)
{
if (values is null)
{
return;
}
obj[key] = new JsonArray(values.Select(v => (JsonNode?)JsonValue.Create(v)).ToArray());
}
}
@@ -0,0 +1,60 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.IO;
namespace Microsoft.Agents.AI.LocalCodeAct.Internal;
/// <summary>
/// Extracts the embedded Python <c>runner.py</c> and <c>validator.py</c> scripts to a temporary
/// directory and caches their paths for the lifetime of the process.
/// </summary>
internal static class EmbeddedScripts
{
private static readonly object s_syncRoot = new();
private static string? s_runnerPath;
private static string? s_validatorPath;
/// <summary>Returns the path to the embedded <c>runner.py</c>, extracting it on first access.</summary>
public static string GetRunnerScriptPath() => GetOrExtract("runner.py", ref s_runnerPath);
/// <summary>Returns the path to the embedded <c>validator.py</c>, extracting it on first access.</summary>
public static string GetValidatorScriptPath() => GetOrExtract("validator.py", ref s_validatorPath);
private static string GetOrExtract(string fileName, ref string? cached)
{
if (cached is not null && File.Exists(cached))
{
return cached;
}
lock (s_syncRoot)
{
if (cached is not null && File.Exists(cached))
{
return cached;
}
var path = Extract(fileName);
cached = path;
return path;
}
}
private static string Extract(string fileName)
{
var assembly = typeof(EmbeddedScripts).Assembly;
var resourceName = $"Microsoft.Agents.AI.LocalCodeAct.Resources.{fileName}";
using var stream = assembly.GetManifestResourceStream(resourceName)
?? throw new InvalidOperationException($"Embedded resource '{resourceName}' not found.");
var dir = Path.Combine(Path.GetTempPath(), "agentframework-localcodeact-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, fileName);
using var fileStream = File.Create(path);
stream.CopyTo(fileStream);
return path;
}
}
@@ -0,0 +1,51 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ComponentModel;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.LocalCodeAct.Internal;
/// <summary>
/// Run-scoped <see cref="AIFunction"/> that exposes <c>execute_code</c> to the model.
/// </summary>
internal sealed class ExecuteCodeFunction : AIFunction
{
private const string ExecuteCodeName = "execute_code";
private readonly CodeExecutor _executor;
private readonly CodeExecutor.RunSnapshot _snapshot;
private readonly AIFunction _inner;
public ExecuteCodeFunction(CodeExecutor executor, CodeExecutor.RunSnapshot snapshot, string description)
{
this._executor = executor;
this._snapshot = snapshot;
this._inner = AIFunctionFactory.Create(
this.ExecuteCodeAsync,
new AIFunctionFactoryOptions
{
Name = ExecuteCodeName,
Description = description,
});
}
public override string Name => this._inner.Name;
public override string Description => this._inner.Description;
public override JsonElement JsonSchema => this._inner.JsonSchema;
protected override ValueTask<object?> InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) =>
this._inner.InvokeAsync(arguments, cancellationToken);
private async ValueTask<object?> ExecuteCodeAsync(
[Description("Python source code to execute locally in the agent environment.")] string code,
CancellationToken cancellationToken)
=> string.IsNullOrWhiteSpace(code)
? throw new ArgumentException("Parameter 'code' must not be empty.", nameof(code))
: await this._executor.ExecuteAsync(this._snapshot, code, cancellationToken).ConfigureAwait(false);
}
@@ -0,0 +1,270 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.LocalCodeAct.Internal;
/// <summary>
/// Filesystem helpers for read-write mount snapshotting and capture.
/// </summary>
internal static class FileMountHelper
{
/// <summary>Normalizes and validates a mount path (must be a clean absolute POSIX-style path).</summary>
public static string NormalizeMountPath(string mountPath)
{
if (string.IsNullOrWhiteSpace(mountPath))
{
throw new ArgumentException("Mount path must not be empty.", nameof(mountPath));
}
var raw = mountPath.Trim().Replace('\\', '/');
var parts = raw.Split('/', StringSplitOptions.RemoveEmptyEntries)
.Where(p => p != ".")
.ToList();
if (parts.Any(p => p == ".."))
{
throw new ArgumentException("Mount path must not contain '..' segments.", nameof(mountPath));
}
if (parts.Count == 0)
{
throw new ArgumentException("Mount path must point to a concrete absolute path.", nameof(mountPath));
}
return "/" + string.Join("/", parts);
}
/// <summary>
/// Validates a FileMount and returns a normalized copy (resolved host path, normalized mount path).
/// </summary>
public static FileMount Normalize(FileMount mount)
{
if (mount is null)
{
throw new ArgumentNullException(nameof(mount));
}
if (string.IsNullOrWhiteSpace(mount.HostPath))
{
throw new ArgumentException("HostPath must not be empty.", nameof(mount));
}
var fullHost = Path.GetFullPath(mount.HostPath);
if (!Directory.Exists(fullHost) && !File.Exists(fullHost))
{
throw new DirectoryNotFoundException($"FileMount host path '{mount.HostPath}' does not exist.");
}
if (mount.WriteBytesLimit.HasValue && mount.WriteBytesLimit.Value < 0)
{
throw new ArgumentException("WriteBytesLimit must be non-negative when set.", nameof(mount));
}
return new FileMount(fullHost, NormalizeMountPath(mount.MountPath), mount.Mode, mount.WriteBytesLimit);
}
/// <summary>Snapshot of (size, last-write-time ticks) per relative path under a writable mount.</summary>
public sealed class MountSnapshot
{
public MountSnapshot(IReadOnlyDictionary<string, (long Size, long Ticks)> files)
{
this.Files = files;
}
public IReadOnlyDictionary<string, (long Size, long Ticks)> Files { get; }
}
/// <summary>Captures the current file inventory of read-write mounts before execution.</summary>
public static Dictionary<string, MountSnapshot> SnapshotWritableMounts(IReadOnlyList<FileMount> mounts)
{
var snapshot = new Dictionary<string, MountSnapshot>(StringComparer.Ordinal);
foreach (var mount in mounts)
{
if (mount.Mode != FileMountMode.ReadWrite)
{
continue;
}
var root = new DirectoryInfo(mount.HostPath);
if (!root.Exists)
{
snapshot[mount.MountPath] = new MountSnapshot(new Dictionary<string, (long, long)>());
continue;
}
var files = new Dictionary<string, (long Size, long Ticks)>(StringComparer.Ordinal);
foreach (var file in EnumerateRealFiles(root))
{
var rel = MakeRelative(root.FullName, file.FullName);
files[rel] = (file.Length, file.LastWriteTimeUtc.Ticks);
}
snapshot[mount.MountPath] = new MountSnapshot(files);
}
return snapshot;
}
/// <summary>Captures files that were created or modified in read-write mounts since the snapshot was taken.</summary>
public static List<AIContent> CaptureWrittenFiles(
IReadOnlyList<FileMount> mounts,
IReadOnlyDictionary<string, MountSnapshot> preState,
ProcessExecutionLimits limits)
{
var captured = new List<AIContent>();
long totalBytes = 0;
foreach (var mount in mounts)
{
if (mount.Mode != FileMountMode.ReadWrite)
{
continue;
}
var root = new DirectoryInfo(mount.HostPath);
if (!root.Exists)
{
continue;
}
preState.TryGetValue(mount.MountPath, out var before);
var beforeFiles = before?.Files ?? new Dictionary<string, (long, long)>();
long mountBytes = 0;
var perMountLimit = mount.WriteBytesLimit ?? limits.MaxCapturedFileBytes;
foreach (var file in EnumerateRealFiles(root).OrderBy(f => f.FullName, StringComparer.Ordinal))
{
var rel = MakeRelative(root.FullName, file.FullName);
var current = (file.Length, file.LastWriteTimeUtc.Ticks);
if (beforeFiles.TryGetValue(rel, out var previous) && previous == current)
{
continue;
}
var sandboxPath = mount.MountPath.TrimEnd('/') + "/" + rel;
if (file.Length > limits.MaxCapturedFileBytes)
{
captured.Add(new TextContent($"[file {sandboxPath} omitted: exceeds per-file capture limit]"));
continue;
}
if (mountBytes + file.Length > perMountLimit)
{
captured.Add(new TextContent($"[file {sandboxPath} omitted: per-mount capture limit reached]"));
continue;
}
if (totalBytes + file.Length > limits.MaxTotalCapturedFileBytes)
{
captured.Add(new TextContent($"[file {sandboxPath} omitted: total capture limit reached]"));
continue;
}
byte[] data;
try
{
data = File.ReadAllBytes(file.FullName);
}
catch (IOException)
{
continue;
}
catch (UnauthorizedAccessException)
{
continue;
}
captured.Add(new DataContent(data, GuessMediaType(file.Name))
{
AdditionalProperties = new AdditionalPropertiesDictionary
{
["path"] = sandboxPath,
},
});
mountBytes += file.Length;
totalBytes += file.Length;
}
}
return captured;
}
private static string MakeRelative(string root, string full)
{
var rel = Path.GetRelativePath(root, full);
return rel.Replace(Path.DirectorySeparatorChar, '/');
}
private static IEnumerable<FileInfo> EnumerateRealFiles(DirectoryInfo root)
{
var stack = new Stack<DirectoryInfo>();
stack.Push(root);
while (stack.Count > 0)
{
var current = stack.Pop();
FileSystemInfo[] entries;
try
{
entries = current.GetFileSystemInfos();
}
catch (IOException)
{
continue;
}
foreach (var entry in entries)
{
if (entry.Attributes.HasFlag(FileAttributes.ReparsePoint))
{
continue;
}
if (entry is DirectoryInfo dir)
{
stack.Push(dir);
}
else if (entry is FileInfo file)
{
yield return file;
}
}
}
}
private static string GuessMediaType(string fileName)
{
#pragma warning disable CA1308 // Normalize strings to uppercase - file extensions are conventionally lowercase
var extension = Path.GetExtension(fileName).ToLowerInvariant();
#pragma warning restore CA1308
return extension switch
{
".txt" => "text/plain",
".json" => "application/json",
".xml" => "application/xml",
".html" => "text/html",
".css" => "text/css",
".js" => "application/javascript",
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".svg" => "image/svg+xml",
".pdf" => "application/pdf",
".zip" => "application/zip",
".csv" => "text/csv",
".md" => "text/markdown",
".py" => "text/x-python",
".cs" => "text/x-csharp",
_ => "application/octet-stream",
};
}
}
@@ -0,0 +1,64 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.LocalCodeAct.Internal;
internal static class InstructionBuilder
{
public static string BuildContextInstructions() =>
"You can execute Python code locally by calling the `execute_code` tool. "
+ "Any tools listed in the tool's description are only accessible from within the executed "
+ "code via `await call_tool(\"<name>\", **kwargs)` — they cannot be invoked directly. "
+ "State does not persist between calls; pass any required values in the code you execute.";
public static string BuildExecuteCodeDescription(
IReadOnlyList<AIFunction> tools,
IReadOnlyList<FileMount> fileMounts)
{
var sb = new StringBuilder();
sb.Append("Executes Python code locally in the agent environment. ");
sb.Append("Pass the full source to execute via the `code` parameter. ");
sb.Append("Returns the captured stdout/stderr and the value of a top-level `result` variable when set.");
if (tools.Count > 0)
{
sb.AppendLine();
sb.AppendLine();
sb.AppendLine("The following host tools are available inside the executed code via `await call_tool(\"<name>\", **kwargs)`:");
foreach (var tool in tools)
{
sb.Append("- `");
sb.Append(tool.Name);
sb.Append('`');
if (!string.IsNullOrWhiteSpace(tool.Description))
{
sb.Append(": ");
sb.Append(tool.Description);
}
sb.AppendLine();
}
}
if (fileMounts.Count > 0)
{
sb.AppendLine();
sb.AppendLine("Filesystem access (host paths are exposed directly; mount paths shown are for description):");
foreach (var mount in fileMounts)
{
sb.Append("- `");
sb.Append(mount.MountPath);
sb.Append("` -> `");
sb.Append(mount.HostPath);
sb.Append("` (");
sb.Append(mount.Mode);
sb.AppendLine(")");
}
}
return sb.ToString().TrimEnd();
}
}
@@ -0,0 +1,395 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.LocalCodeAct.Internal;
/// <summary>
/// Parent-side IPC bridge that launches the Python runner, sends a single execution request,
/// services tool calls, and returns the final execution result.
/// </summary>
internal sealed class ProcessBridge
{
private static readonly JsonSerializerOptions s_jsonOptions = new()
{
WriteIndented = false,
};
private readonly string _pythonExecutable;
private readonly string _runnerScript;
private readonly IReadOnlyDictionary<string, AIFunction> _tools;
private readonly ProcessExecutionLimits _limits;
private readonly IReadOnlyDictionary<string, string>? _environment;
private readonly string? _workingDirectory;
public ProcessBridge(
string pythonExecutable,
string runnerScript,
IReadOnlyList<AIFunction> tools,
ProcessExecutionLimits limits,
IReadOnlyDictionary<string, string>? environment,
string? workingDirectory)
{
this._pythonExecutable = pythonExecutable;
this._runnerScript = runnerScript;
this._tools = tools.ToDictionary(t => t.Name, StringComparer.Ordinal);
this._limits = limits;
this._environment = environment;
this._workingDirectory = workingDirectory;
}
/// <summary>Represents the parsed final result returned by the Python runner.</summary>
public sealed class ExecutionResult
{
public string Stdout { get; init; } = string.Empty;
public string Stderr { get; init; } = string.Empty;
public bool OutputPresent { get; init; }
public JsonElement? Output { get; init; }
public bool StdoutTruncated { get; init; }
public bool StderrTruncated { get; init; }
}
public async Task<ExecutionResult> RunAsync(string code, CancellationToken cancellationToken)
{
var startInfo = new ProcessStartInfo
{
FileName = this._pythonExecutable,
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
startInfo.ArgumentList.Add("-I");
startInfo.ArgumentList.Add(this._runnerScript);
if (!string.IsNullOrEmpty(this._workingDirectory))
{
startInfo.WorkingDirectory = this._workingDirectory;
}
this.ConfigureEnvironment(startInfo);
using var process = Process.Start(startInfo)
?? throw new InvalidOperationException("Failed to start Python runner process.");
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(this._limits.TimeoutSeconds));
var stderrTask = ReadCappedAsync(process.StandardError, this._limits.MaxStderrBytes, timeoutCts.Token);
try
{
return await this.CommunicateAsync(process, code, stderrTask, timeoutCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
{
TryKill(process);
throw new TimeoutException($"Generated code exceeded {this._limits.TimeoutSeconds} seconds.");
}
catch
{
TryKill(process);
throw;
}
}
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)
{
startInfo.Environment[kvp.Key] = kvp.Value;
}
// Without these on Windows, Python may fail to load its standard library.
if (OperatingSystem.IsWindows())
{
foreach (var key in new[] { "SYSTEMROOT", "SYSTEMDRIVE", "COMSPEC", "PATHEXT", "TEMP", "TMP" })
{
if (!startInfo.Environment.ContainsKey(key))
{
var existing = Environment.GetEnvironmentVariable(key);
if (!string.IsNullOrEmpty(existing))
{
startInfo.Environment[key] = existing;
}
}
}
}
}
private async Task<ExecutionResult> CommunicateAsync(
Process process,
string code,
Task<(string Text, bool Truncated)> stderrTask,
CancellationToken cancellationToken)
{
var request = new JsonObject
{
["code"] = code,
["tool_names"] = new JsonArray(this._tools.Keys.Select(k => (JsonNode?)JsonValue.Create(k)).ToArray()),
["max_stdout_bytes"] = this._limits.MaxStdoutBytes,
["max_stderr_bytes"] = this._limits.MaxStderrBytes,
};
await process.StandardInput.WriteLineAsync(request.ToJsonString(s_jsonOptions).AsMemory(), cancellationToken).ConfigureAwait(false);
await process.StandardInput.FlushAsync(cancellationToken).ConfigureAwait(false);
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
var line = await process.StandardOutput.ReadLineAsync(cancellationToken).ConfigureAwait(false);
if (line is null)
{
var stderr = await stderrTask.ConfigureAwait(false);
throw new InvalidOperationException(
$"Local CodeAct subprocess exited without a result. stderr: {stderr.Text}");
}
JsonObject message;
try
{
message = JsonNode.Parse(line) as JsonObject
?? throw new InvalidOperationException("Subprocess produced a non-object JSON message.");
}
catch (JsonException ex)
{
throw new InvalidOperationException($"Failed to parse JSON message from subprocess: {line}", ex);
}
switch ((string?)message["type"])
{
case "complete":
return this.ParseComplete(message);
case "error":
var excType = (string?)message["exc_type"] ?? "Error";
var msg = (string?)message["message"] ?? "Unknown subprocess error.";
var tb = (string?)message["traceback"];
throw new InvalidOperationException(
string.IsNullOrEmpty(tb) ? $"{excType}: {msg}" : $"{excType}: {msg}\n{tb}");
case "tool_call":
await this.HandleToolCallAsync(process, message, cancellationToken).ConfigureAwait(false);
break;
default:
// Unknown message types are ignored to remain forward compatible.
break;
}
}
}
private ExecutionResult ParseComplete(JsonObject message)
{
var result = message["result"] as JsonObject ?? new JsonObject();
var json = result.ToJsonString();
if (Encoding.UTF8.GetByteCount(json) > this._limits.MaxResultBytes)
{
throw new InvalidOperationException(
$"Generated code result exceeded the configured max of {this._limits.MaxResultBytes} bytes.");
}
JsonElement? output = null;
if (result["output"] is JsonNode outputNode)
{
output = JsonDocument.Parse(outputNode.ToJsonString()).RootElement.Clone();
}
return new ExecutionResult
{
Stdout = (string?)result["stdout"] ?? string.Empty,
Stderr = (string?)result["stderr"] ?? string.Empty,
OutputPresent = (bool?)result["output_present"] ?? false,
Output = output,
StdoutTruncated = (bool?)result["stdout_truncated"] ?? false,
StderrTruncated = (bool?)result["stderr_truncated"] ?? false,
};
}
private async Task HandleToolCallAsync(Process process, JsonObject message, CancellationToken cancellationToken)
{
// call_id is Python's id(kwargs) which can be a 64-bit value on 64-bit Python.
long callId = 0;
if (message["call_id"] is JsonValue cidValue && cidValue.TryGetValue<long>(out var parsedId))
{
callId = parsedId;
}
var name = (string?)message["name"];
if (string.IsNullOrEmpty(name))
{
await SendToolResponseAsync(process, callId, ok: false, result: null,
excType: "ToolError", excMessage: "Tool call missing 'name'.", cancellationToken).ConfigureAwait(false);
return;
}
if (!this._tools.TryGetValue(name!, out var tool))
{
await SendToolResponseAsync(process, callId, ok: false, result: null,
excType: "UnknownTool", excMessage: $"Unknown tool: {name}", cancellationToken).ConfigureAwait(false);
return;
}
var kwargs = message["kwargs"] as JsonObject ?? new JsonObject();
var arguments = new AIFunctionArguments();
foreach (var (key, value) in kwargs)
{
arguments[key] = value;
}
try
{
var result = await tool.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false);
await SendToolResponseAsync(process, callId, ok: true, result, excType: null, excMessage: null, cancellationToken).ConfigureAwait(false);
}
#pragma warning disable CA1031
catch (Exception ex)
#pragma warning restore CA1031
{
await SendToolResponseAsync(process, callId, ok: false, result: null,
excType: ex.GetType().Name, excMessage: ex.Message, cancellationToken).ConfigureAwait(false);
}
}
private static async Task SendToolResponseAsync(
Process process,
long callId,
bool ok,
object? result,
string? excType,
string? excMessage,
CancellationToken cancellationToken)
{
var response = new JsonObject
{
["call_id"] = callId,
["ok"] = ok,
};
if (ok)
{
response["result"] = SerializeResult(result);
}
else
{
response["exc_type"] = excType;
response["message"] = excMessage;
}
await process.StandardInput.WriteLineAsync(response.ToJsonString(s_jsonOptions).AsMemory(), cancellationToken).ConfigureAwait(false);
await process.StandardInput.FlushAsync(cancellationToken).ConfigureAwait(false);
}
private static JsonNode? SerializeResult(object? value)
{
if (value is null)
{
return null;
}
if (value is JsonNode node)
{
return node;
}
try
{
var typeInfo = AIJsonUtilities.DefaultOptions.GetTypeInfo(value.GetType());
var json = JsonSerializer.Serialize(value, typeInfo);
return JsonNode.Parse(json);
}
#pragma warning disable CA1031
catch
#pragma warning restore CA1031
{
return JsonValue.Create(value.ToString());
}
}
private static async Task<(string Text, bool Truncated)> ReadCappedAsync(StreamReader reader, int maxBytes, CancellationToken cancellationToken)
{
var sb = new StringBuilder();
var buffer = new char[4096];
var truncated = false;
var totalBytes = 0;
try
{
while (true)
{
var read = await reader.ReadAsync(buffer.AsMemory(), cancellationToken).ConfigureAwait(false);
if (read == 0)
{
break;
}
var chunk = new string(buffer, 0, read);
var chunkBytes = Encoding.UTF8.GetByteCount(chunk);
if (totalBytes + chunkBytes > maxBytes)
{
var remaining = Math.Max(0, maxBytes - totalBytes);
if (remaining > 0)
{
sb.Append(chunk[..Math.Min(chunk.Length, remaining)]);
}
truncated = true;
break;
}
sb.Append(chunk);
totalBytes += chunkBytes;
}
}
catch (OperationCanceledException)
{
// Allow caller to propagate the timeout exception.
}
#pragma warning disable CA1031
catch
#pragma warning restore CA1031
{
// Best effort: return what we have so far.
}
return (sb.ToString(), truncated);
}
private static void TryKill(Process process)
{
try
{
if (!process.HasExited)
{
process.Kill(entireProcessTree: true);
}
}
#pragma warning disable CA1031
catch
#pragma warning restore CA1031
{
// best-effort
}
}
}
@@ -0,0 +1,211 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.LocalCodeAct.Internal;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.LocalCodeAct;
/// <summary>
/// An <see cref="AIContextProvider"/> that injects a local Python <c>execute_code</c> tool
/// into the agent's tool surface.
/// </summary>
/// <remarks>
/// <para>
/// Generated code is executed in a child Python process with default-on AST allow-list
/// validation, configurable resource limits, an isolated environment, and capture of files
/// written under <see cref="FileMountMode.ReadWrite"/> mounts.
/// </para>
/// <para>
/// <strong>Security:</strong> This package is NOT a sandbox. It is intended for environments
/// that already provide process, filesystem, and network isolation (Foundry hosted agents,
/// Azure Container Instances, dedicated VMs, etc.).
/// </para>
/// </remarks>
public sealed class LocalCodeActProvider : AIContextProvider, IDisposable
{
/// <summary>Fixed state key used to enforce a single provider per agent.</summary>
internal const string FixedStateKey = "LocalCodeActProvider";
private static readonly IReadOnlyList<string> s_stateKeys = [FixedStateKey];
private readonly CodeExecutor _executor;
private readonly ConcurrentDictionary<string, AIFunction> _tools = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, FileMount> _fileMounts = new(StringComparer.Ordinal);
private volatile bool _disposed;
/// <summary>Initializes a new instance of the <see cref="LocalCodeActProvider"/> class.</summary>
/// <param name="pythonExecutablePath">Path to the Python interpreter used for execution and validation.</param>
/// <param name="options">Optional provider configuration.</param>
public LocalCodeActProvider(string pythonExecutablePath, LocalCodeActProviderOptions? options = null)
{
_ = Throw.IfNullOrWhitespace(pythonExecutablePath);
options ??= new LocalCodeActProviderOptions();
var limits = options.ExecutionLimits ?? new ProcessExecutionLimits();
var runnerScript = options.RunnerScriptPath ?? EmbeddedScripts.GetRunnerScriptPath();
CodeValidator? validator = null;
if (!options.ValidationDisabled)
{
var validatorScript = options.ValidatorScriptPath ?? EmbeddedScripts.GetValidatorScriptPath();
validator = new CodeValidator(
pythonExecutablePath,
validatorScript,
TimeSpan.FromSeconds(limits.ValidationTimeoutSeconds),
options.AllowedImports?.ToList(),
options.BlockedImports?.ToList(),
options.AllowedBuiltins?.ToList(),
options.BlockedBuiltins?.ToList());
}
this._executor = new CodeExecutor(
pythonExecutablePath,
runnerScript,
validator,
limits,
options.Environment,
options.WorkingDirectory);
if (options.Tools is not null)
{
foreach (var tool in options.Tools.Where(t => t is not null))
{
this._tools[tool.Name] = tool;
}
}
if (options.FileMounts is not null)
{
foreach (var mount in options.FileMounts.Where(m => m is not null))
{
var normalized = FileMountHelper.Normalize(mount);
this._fileMounts[normalized.MountPath] = normalized;
}
}
}
/// <inheritdoc/>
public override IReadOnlyList<string> StateKeys => s_stateKeys;
// -------------------------------------------------------------------
// Tool registry
// -------------------------------------------------------------------
/// <summary>Adds tools to the provider-owned tool registry. Duplicate names replace existing entries.</summary>
public void AddTools(params AIFunction[] tools)
{
_ = Throw.IfNull(tools);
this.ThrowIfDisposed();
foreach (var tool in tools.Where(t => t is not null))
{
this._tools[tool.Name] = tool;
}
}
/// <summary>Returns the currently registered tools.</summary>
public IReadOnlyList<AIFunction> GetTools()
{
return this._tools.Values.ToList();
}
/// <summary>Removes tools by name.</summary>
public void RemoveTools(params string[] names)
{
_ = Throw.IfNull(names);
this.ThrowIfDisposed();
foreach (var name in names.Where(n => n is not null))
{
_ = this._tools.TryRemove(name, out _);
}
}
/// <summary>Removes all registered tools.</summary>
public void ClearTools()
{
this.ThrowIfDisposed();
this._tools.Clear();
}
// -------------------------------------------------------------------
// File mounts
// -------------------------------------------------------------------
/// <summary>Adds file mounts. Duplicate mount paths replace existing entries.</summary>
public void AddFileMounts(params FileMount[] mounts)
{
_ = Throw.IfNull(mounts);
this.ThrowIfDisposed();
foreach (var mount in mounts.Where(m => m is not null))
{
var normalized = FileMountHelper.Normalize(mount);
this._fileMounts[normalized.MountPath] = normalized;
}
}
/// <summary>Returns the currently registered file mounts.</summary>
public IReadOnlyList<FileMount> GetFileMounts()
{
return this._fileMounts.Values.ToList();
}
/// <summary>Removes file mounts by mount path.</summary>
public void RemoveFileMounts(params string[] mountPaths)
{
_ = Throw.IfNull(mountPaths);
this.ThrowIfDisposed();
foreach (var path in mountPaths.Where(p => p is not null))
{
_ = this._fileMounts.TryRemove(path, out _);
}
}
/// <summary>Removes all registered file mounts.</summary>
public void ClearFileMounts()
{
this.ThrowIfDisposed();
this._fileMounts.Clear();
}
// -------------------------------------------------------------------
// AIContextProvider implementation
// -------------------------------------------------------------------
/// <inheritdoc/>
protected override ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(context);
CodeExecutor.RunSnapshot snapshot;
this.ThrowIfDisposed();
snapshot = new CodeExecutor.RunSnapshot(
this._tools.Values.ToList(),
this._fileMounts.Values.ToList());
var description = InstructionBuilder.BuildExecuteCodeDescription(snapshot.Tools, snapshot.FileMounts);
var executeCode = new ExecuteCodeFunction(this._executor, snapshot, description);
var instructions = InstructionBuilder.BuildContextInstructions();
return new ValueTask<AIContext>(new AIContext
{
Instructions = instructions,
Tools = [executeCode],
});
}
private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(this._disposed, this);
/// <inheritdoc/>
public void Dispose()
{
this._disposed = true;
}
}
@@ -0,0 +1,90 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.LocalCodeAct;
/// <summary>
/// Configuration options for <see cref="LocalCodeActProvider"/> and <see cref="LocalExecuteCodeFunction"/>.
/// </summary>
public sealed class LocalCodeActProviderOptions
{
/// <summary>Gets or sets the resource limits applied to subprocess execution and capture.</summary>
public ProcessExecutionLimits? ExecutionLimits { get; set; }
/// <summary>
/// Gets or sets the initial set of host tools available to generated code via <c>await call_tool(...)</c>.
/// </summary>
public IEnumerable<AIFunction>? Tools { get; set; }
/// <summary>
/// Gets or sets the initial set of file mounts exposed to generated code.
/// </summary>
public IEnumerable<FileMount>? FileMounts { get; set; }
/// <summary>
/// Gets or sets environment variables passed to the subprocess.
/// </summary>
/// <remarks>
/// When <see langword="null"/>, the subprocess inherits the parent process environment
/// (the default <see cref="System.Diagnostics.ProcessStartInfo"/> 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.
/// 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.
/// </remarks>
public IReadOnlyDictionary<string, string>? Environment { get; set; }
/// <summary>
/// Gets or sets the working directory used for the subprocess. When <see langword="null"/>
/// the current working directory of the host process is used.
/// </summary>
public string? WorkingDirectory { get; set; }
/// <summary>
/// Gets or sets the optional override path to the Python runner script. When <see langword="null"/>
/// the embedded <c>runner.py</c> is extracted to a temporary directory and used.
/// </summary>
public string? RunnerScriptPath { get; set; }
/// <summary>
/// Gets or sets the optional override path to the Python validator script. When <see langword="null"/>
/// the embedded <c>validator.py</c> is extracted to a temporary directory and used.
/// </summary>
public string? ValidatorScriptPath { get; set; }
/// <summary>
/// Gets or sets whether AST allow-list validation is disabled. Defaults to <see langword="false"/>.
/// </summary>
/// <remarks>
/// Disabling validation removes a critical defense-in-depth control. Only disable when the
/// generated code is trusted or when running inside a strong external sandbox.
/// </remarks>
public bool ValidationDisabled { get; set; }
/// <summary>
/// Gets or sets the set of imports allowed by the validator. When <see langword="null"/>
/// the validator's built-in defaults are used. Setting a value replaces the defaults.
/// </summary>
public IEnumerable<string>? AllowedImports { get; set; }
/// <summary>
/// Gets or sets the set of imports blocked by the validator. When <see langword="null"/>
/// the validator's built-in defaults are used. Setting a value replaces the defaults.
/// </summary>
public IEnumerable<string>? BlockedImports { get; set; }
/// <summary>
/// Gets or sets the set of builtins allowed by the validator. When <see langword="null"/>
/// the validator's built-in defaults are used. Setting a value replaces the defaults.
/// </summary>
public IEnumerable<string>? AllowedBuiltins { get; set; }
/// <summary>
/// Gets or sets the set of builtins blocked by the validator. When <see langword="null"/>
/// the validator's built-in defaults are used. Setting a value replaces the defaults.
/// </summary>
public IEnumerable<string>? BlockedBuiltins { get; set; }
}
@@ -0,0 +1,97 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.LocalCodeAct.Internal;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.LocalCodeAct;
/// <summary>
/// Standalone <c>execute_code</c> <see cref="AIFunction"/> that runs Python locally in a subprocess.
/// </summary>
/// <remarks>
/// Use this when you want to expose code execution directly as a model-facing function without
/// the <see cref="LocalCodeActProvider"/> indirection. Tools and file mounts are captured at
/// construction time and immutable for the lifetime of the function.
/// </remarks>
public sealed class LocalExecuteCodeFunction : AIFunction
{
private const string ExecuteCodeName = "execute_code";
private readonly CodeExecutor _executor;
private readonly CodeExecutor.RunSnapshot _snapshot;
private readonly AIFunction _inner;
/// <summary>Initializes a new instance of the <see cref="LocalExecuteCodeFunction"/> class.</summary>
/// <param name="pythonExecutablePath">Path to the Python interpreter used for execution and validation.</param>
/// <param name="options">Optional function configuration.</param>
public LocalExecuteCodeFunction(string pythonExecutablePath, LocalCodeActProviderOptions? options = null)
{
_ = Throw.IfNullOrWhitespace(pythonExecutablePath);
options ??= new LocalCodeActProviderOptions();
var limits = options.ExecutionLimits ?? new ProcessExecutionLimits();
var runnerScript = options.RunnerScriptPath ?? EmbeddedScripts.GetRunnerScriptPath();
CodeValidator? validator = null;
if (!options.ValidationDisabled)
{
var validatorScript = options.ValidatorScriptPath ?? EmbeddedScripts.GetValidatorScriptPath();
validator = new CodeValidator(
pythonExecutablePath,
validatorScript,
TimeSpan.FromSeconds(limits.ValidationTimeoutSeconds),
options.AllowedImports?.ToList(),
options.BlockedImports?.ToList(),
options.AllowedBuiltins?.ToList(),
options.BlockedBuiltins?.ToList());
}
var tools = options.Tools?.Where(t => t is not null).ToList() ?? new List<AIFunction>();
var fileMounts = options.FileMounts?.Where(m => m is not null).Select(FileMountHelper.Normalize).ToList() ?? new List<FileMount>();
this._executor = new CodeExecutor(
pythonExecutablePath,
runnerScript,
validator,
limits,
options.Environment,
options.WorkingDirectory);
this._snapshot = new CodeExecutor.RunSnapshot(tools, fileMounts);
this._inner = AIFunctionFactory.Create(
this.ExecuteCodeAsync,
new AIFunctionFactoryOptions
{
Name = ExecuteCodeName,
Description = InstructionBuilder.BuildExecuteCodeDescription(tools, fileMounts),
});
}
/// <inheritdoc/>
public override string Name => this._inner.Name;
/// <inheritdoc/>
public override string Description => this._inner.Description;
/// <inheritdoc/>
public override JsonElement JsonSchema => this._inner.JsonSchema;
/// <inheritdoc/>
protected override ValueTask<object?> InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) =>
this._inner.InvokeAsync(arguments, cancellationToken);
private async ValueTask<object?> ExecuteCodeAsync(
[Description("Python source code to execute locally in the agent environment.")] string code,
CancellationToken cancellationToken)
=> string.IsNullOrWhiteSpace(code)
? throw new ArgumentException("Parameter 'code' must not be empty.", nameof(code))
: await this._executor.ExecuteAsync(this._snapshot, code, cancellationToken).ConfigureAwait(false);
}
@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<VersionSuffix>preview</VersionSuffix>
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
</PropertyGroup>
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<ItemGroup>
<PackageReference Include="System.Text.Json" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
</ItemGroup>
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework - Local CodeAct integration</Title>
<Description>Provides local Python code execution (CodeAct) with AST validation for Microsoft Agent Framework. Requires external sandboxing (e.g., container, VM).</Description>
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="/" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.LocalCodeAct.UnitTests" />
</ItemGroup>
<!-- Embed Python runner and validator scripts as resources -->
<ItemGroup>
<EmbeddedResource Include="Resources\**\*.py" />
</ItemGroup>
</Project>
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.LocalCodeAct;
/// <summary>
/// Resource limits for subprocess code execution.
/// </summary>
/// <remarks>
/// These limits provide defense-in-depth controls to prevent runaway code execution,
/// but are NOT a security sandbox. Real sandboxing must come from external container/VM
/// isolation (for example, Foundry hosted agents, Docker, or Azure Container Instances).
/// </remarks>
public sealed class ProcessExecutionLimits
{
/// <summary>Gets or sets the maximum execution time for the subprocess, in seconds. Default is 30.</summary>
public int TimeoutSeconds { get; set; } = 30;
/// <summary>Gets or sets the maximum time the AST validator subprocess may run, in seconds. Default is 10.</summary>
public int ValidationTimeoutSeconds { get; set; } = 10;
/// <summary>Gets or sets the maximum bytes of stdout captured from the subprocess. Default is 10 MiB.</summary>
public int MaxStdoutBytes { get; set; } = 10 * 1024 * 1024;
/// <summary>Gets or sets the maximum bytes of stderr captured from the subprocess. Default is 10 MiB.</summary>
public int MaxStderrBytes { get; set; } = 10 * 1024 * 1024;
/// <summary>Gets or sets the maximum serialized result size in bytes. Default is 10 MiB.</summary>
public int MaxResultBytes { get; set; } = 10 * 1024 * 1024;
/// <summary>Gets or sets the maximum bytes captured per file under read-write mounts. Default is 1 MiB.</summary>
public int MaxCapturedFileBytes { get; set; } = 1024 * 1024;
/// <summary>Gets or sets the maximum total bytes captured across all read-write mounts. Default is 10 MiB.</summary>
public int MaxTotalCapturedFileBytes { get; set; } = 10 * 1024 * 1024;
}
@@ -0,0 +1,190 @@
# Microsoft.Agents.AI.LocalCodeAct
Local CodeAct integration for Microsoft Agent Framework.
> [!WARNING]
> This package runs LLM-generated Python code in the local environment. It is **NOT**
> a Python security sandbox and is not safe for untrusted prompts or code on a
> developer workstation or production host without an external sandbox.
`Microsoft.Agents.AI.LocalCodeAct` is intended for environments that already
provide process, filesystem, network, and credential isolation (e.g., Azure
container instances, VMs, or Foundry hosted agents). It provides the familiar
CodeAct provider pattern used by the Hyperlight package while executing Python
locally in the agent environment.
## Installation
```bash
dotnet add package Microsoft.Agents.AI.LocalCodeAct --prerelease
```
This is a preview package.
## Basic Usage
```csharp
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.LocalCodeAct;
var options = new LocalCodeActProviderOptions()
{
ExecutionLimits = new ProcessExecutionLimits { TimeoutSeconds = 5 },
};
using var provider = new LocalCodeActProvider("/usr/bin/python3", options);
// Register provider with your AIAgent's context providers.
```
## What the Package Controls
- **AST validation** (default on): Validates generated code against allow-lists
before execution.
- **Subprocess execution**: Runs generated code in a child Python process.
- **Explicit Python path**: the provider and standalone function constructors require a Python executable path (no default).
- **Isolated environment**: Does not inherit host environment variables unless
explicitly provided.
- **No shell invocation**: Launches Python directly without a shell.
- **Resource limits**: Applies timeout, stdout, stderr, and result-size limits.
- **Tool gating**: Only provider-owned host tools can be invoked from generated
code via `await call_tool("<name>", ...)`.
- **File capture**: Captures new files under configured **read-write** mounts
while skipping symlinks. Modifications to pre-existing files are not captured.
These are defense-in-depth controls, not a containment boundary. The AST
validator blocks common dangerous operations (`eval`, `exec`,
`import subprocess`, attribute access for `os.system`, `__class__`, etc.) but
does not make Python execution safe on an unsandboxed host.
## What the Package Does NOT Protect
- Malicious Python code working within allowed imports and operations.
- Network access unless the surrounding environment blocks it.
- Prompt-injected exfiltration through allowed host tools.
- Resource exhaustion outside the configured limits.
- Log, stdout, stderr, or result poisoning.
**Use Azure container instances, VMs, Foundry hosted agents, or equivalent
infrastructure as the actual security boundary.**
## Host Tools
Register host tools via the options or on the provider directly:
```csharp
var addFunction = AIFunctionFactory.Create(
(int a, int b) => a + b,
name: "add",
description: "Adds two integers.");
using var provider = new LocalCodeActProvider("/usr/bin/python3", new LocalCodeActProviderOptions
{
Tools = new[] { addFunction },
});
// Or mutate after construction:
provider.AddTools(addFunction);
```
Inside `execute_code`:
```python
total = await call_tool("add", a=2, b=3)
print(total)
```
## Code Validation
By default, the package validates Python code against allow-lists before
execution. The validator runs in its own short-lived Python subprocess with a
dedicated timeout (`ProcessExecutionLimits.ValidationTimeoutSeconds`).
- **Allowed imports**: `math`, `random`, `json`, `datetime`, `pathlib`, `os`
(only `os.environ`, `os.path` attributes are reachable), etc.
- **Blocked imports**: `subprocess`, `sys`, `socket`, `importlib`, network and
threading modules, etc.
- **Allowed builtins**: `print`, `len`, `str`, type constructors, etc.
- **Blocked builtins**: `eval`, `exec`, `compile`, `__import__`, `open`,
`getattr`, `setattr`, etc.
See [`Resources/validator.py`](Resources/validator.py) for the full default
allow-lists.
### Customizing Validation
Override the default lists:
```csharp
using var provider = new LocalCodeActProvider("/usr/bin/python3", new LocalCodeActProviderOptions
{
AllowedImports = new[] { "math", "datetime", "mymodule" },
BlockedImports = new[] { "subprocess", "sys" },
AllowedBuiltins = new[] { "print", "len", "str", "int" },
BlockedBuiltins = new[] { "eval", "exec", "compile" },
});
```
Custom lists **replace** the defaults (not augment).
### Disabling Validation
Set `ValidationDisabled = true` to skip the AST validator entirely. Doing so
removes a critical defense-in-depth control. Only disable when the generated
code is trusted or when running inside a strong external sandbox.
## File Mounts
Mount host directories to expose them to generated code:
```csharp
using var provider = new LocalCodeActProvider("/usr/bin/python3", new LocalCodeActProviderOptions
{
FileMounts = new[]
{
new FileMount("/tmp/data", "/input", FileMountMode.ReadOnly),
new FileMount("/tmp/output", "/output", FileMountMode.ReadWrite),
},
});
```
Generated code accesses mounts via `HostPath`. `MountPath` is descriptive
metadata only — the subprocess sees the real host path. Read-write mounts are
scanned for **new** files after execution, and those files are returned as
`DataContent`. Symlinks are skipped.
## Environment Variables
Pass environment variables explicitly. The subprocess does NOT inherit the host
environment by default:
```csharp
using var provider = new LocalCodeActProvider("/usr/bin/python3", new LocalCodeActProviderOptions
{
Environment = new Dictionary<string, string>
{
["API_KEY"] = "...",
["LOG_LEVEL"] = "INFO",
},
});
```
## Standalone Function
If you do not want the provider machinery you can expose `execute_code` directly:
```csharp
var function = new LocalExecuteCodeFunction("/usr/bin/python3");
```
`LocalExecuteCodeFunction` snapshots tools and mounts at construction time and
is safe to reuse across invocations.
## Execution Modes
The .NET implementation only supports subprocess execution. There is no
"unsafe in-process" mode in .NET.
## License
MIT
@@ -0,0 +1,210 @@
# Copyright (c) Microsoft. All rights reserved.
"""Child-process runner for local CodeAct subprocess mode."""
from __future__ import annotations
import ast
import asyncio
import contextlib
import io
import json
import keyword
import sys
import traceback
from collections.abc import Mapping, Sequence
from typing import Any, TextIO, cast
class _CappedTextIO(io.TextIOBase):
def __init__(self, limit: int) -> None:
super().__init__()
self._limit = max(0, limit)
self._buffer = io.StringIO()
self.truncated = False
def writable(self) -> bool:
return True
def write(self, value: str) -> int:
text = str(value)
current = self._buffer.tell()
remaining = max(0, self._limit - current)
if remaining:
self._buffer.write(text[:remaining])
if len(text) > remaining:
self.truncated = True
return len(text)
def getvalue(self) -> str:
return self._buffer.getvalue()
def _json_safe_mapping(value: Mapping[Any, Any]) -> dict[str, object]:
return {str(key): _json_safe(item) for key, item in value.items()}
def _json_safe_sequence(value: Sequence[Any]) -> list[object]:
return [_json_safe(item) for item in value]
def _json_safe(value: object) -> object:
try:
json.dumps(value)
except (TypeError, ValueError):
if isinstance(value, Mapping):
return _json_safe_mapping(cast("Mapping[Any, Any]", value)) # type: ignore[redundant-cast]
if isinstance(value, (list, tuple)):
return _json_safe_sequence(cast("Sequence[Any]", value))
return repr(value)
return value
def _compile_main(code: str) -> tuple[Any, bool]:
module = ast.parse(code, mode="exec")
body = list(module.body)
output_present = bool(body and isinstance(body[-1], ast.Expr))
if output_present:
last_expr = body[-1]
if isinstance(last_expr, ast.Expr):
body[-1] = ast.Return(value=last_expr.value)
else:
body.append(ast.Return(value=ast.Constant(value=None)))
async_function_def = cast(Any, ast.AsyncFunctionDef)
function = async_function_def(
name="__local_codeact_main__",
args=ast.arguments(
posonlyargs=[],
args=[],
kwonlyargs=[],
kw_defaults=[],
defaults=[],
),
body=body,
decorator_list=[],
returns=None,
type_comment=None,
)
wrapped = ast.Module(body=[function], type_ignores=[])
ast.fix_missing_locations(wrapped)
return compile(wrapped, "<local-codeact>", "exec"), output_present
def _send(control: TextIO, payload: Mapping[str, Any]) -> None:
control.write(json.dumps(payload, separators=(",", ":")) + "\n")
control.flush()
async def _read_response(call_id: int) -> dict[str, Any]:
line = await asyncio.to_thread(sys.stdin.readline)
if not line:
raise RuntimeError("Parent process closed the tool bridge.")
response_value: Any = json.loads(line)
if not isinstance(response_value, dict):
raise RuntimeError("Received an invalid tool bridge response.")
response = cast("dict[str, Any]", response_value)
if response.get("call_id") != call_id:
raise RuntimeError("Received an invalid tool bridge response.")
if not response.get("ok"):
exc_type = str(response.get("exc_type") or "RuntimeError")
message = str(response.get("message") or "Tool call failed.")
raise RuntimeError(f"{exc_type}: {message}")
return response
def _make_tool(name: str, *, control: TextIO, bridge_lock: asyncio.Lock) -> Any:
async def _tool(**kwargs: Any) -> Any:
return await _call_tool(name, control=control, bridge_lock=bridge_lock, kwargs=kwargs)
_tool.__name__ = name
return _tool
async def _call_tool(
name: str,
*,
control: TextIO,
bridge_lock: asyncio.Lock,
kwargs: Mapping[str, Any],
) -> Any:
call_id = id(kwargs)
async with bridge_lock:
_send(
control,
{
"type": "tool_call",
"call_id": call_id,
"name": name,
"kwargs": _json_safe(dict(kwargs)),
},
)
response = await _read_response(call_id)
return response.get("result")
async def _execute(request: Mapping[str, Any], control: TextIO) -> dict[str, Any]:
code = str(request.get("code") or "")
stdout = _CappedTextIO(int(request.get("max_stdout_bytes") or 0))
stderr = _CappedTextIO(int(request.get("max_stderr_bytes") or 0))
tool_names_value = request.get("tool_names")
tool_names = (
[str(name) for name in cast("Sequence[Any]", tool_names_value)] if isinstance(tool_names_value, list) else []
)
bridge_lock = asyncio.Lock()
async def call_tool(name: str, **kwargs: Any) -> Any:
return await _call_tool(name, control=control, bridge_lock=bridge_lock, kwargs=kwargs)
globals_dict: dict[str, Any] = {
"__builtins__": __builtins__,
"asyncio": asyncio,
"call_tool": call_tool,
}
for tool_name in tool_names:
if tool_name.isidentifier() and not keyword.iskeyword(tool_name):
globals_dict[tool_name] = _make_tool(tool_name, control=control, bridge_lock=bridge_lock)
compiled, output_present = _compile_main(code)
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
exec(compiled, globals_dict, globals_dict) # noqa: S102 # nosec B102 - this runner exists to execute generated code.
output = await globals_dict["__local_codeact_main__"]()
return {
"stdout": stdout.getvalue(),
"stderr": stderr.getvalue(),
"stdout_truncated": stdout.truncated,
"stderr_truncated": stderr.truncated,
"output_present": output_present,
"output": _json_safe(output),
}
async def _main() -> int:
control = sys.stdout
line = await asyncio.to_thread(sys.stdin.readline)
if not line:
return 1
try:
request_value: Any = json.loads(line)
if not isinstance(request_value, dict):
raise ValueError("Expected a JSON object request.")
request = cast("dict[str, Any]", request_value)
result = await _execute(request, control)
_send(control, {"type": "complete", "result": result})
return 0
except BaseException as exc:
_send(
control,
{
"type": "error",
"exc_type": type(exc).__name__,
"message": str(exc),
"traceback": traceback.format_exc(limit=20),
},
)
return 1
if __name__ == "__main__":
raise SystemExit(asyncio.run(_main()))
@@ -0,0 +1,492 @@
# Copyright (c) Microsoft. All rights reserved.
"""AST validation for generated Python code."""
from __future__ import annotations
import ast
import builtins as _builtins
from typing import Any
_PYTHON_BUILTIN_NAMES: frozenset[str] = frozenset(dir(_builtins))
# Allowed imports that generated code may use.
ALLOWED_IMPORTS: set[str] = {
"asyncio",
"pathlib",
"json",
"math",
"datetime",
"time",
"itertools",
"functools",
"collections",
"typing",
"dataclasses",
"decimal",
"fractions",
"re",
"base64",
"hashlib",
"uuid",
"random",
"os", # Limited to os.environ, os.path - validated via attribute access
}
# Blocked imports that expose dangerous capabilities.
BLOCKED_IMPORTS: set[str] = {
"sys",
"subprocess",
"socket",
"urllib",
"requests",
"http",
"ftplib",
"smtplib",
"telnetlib",
"multiprocessing",
"threading",
"ctypes",
"shutil",
"tempfile",
"importlib",
"builtins",
"__builtin__",
}
# Allowed `os` attribute names. Generated code may only touch `os.environ` and
# `os.path`; everything else (file I/O, process control, mutating helpers, etc.)
# is rejected by default. Users may pass a custom allow-list via
# ``allowed_os_attrs`` on the validator entry points.
ALLOWED_OS_ATTRS: set[str] = {"environ", "path"}
# Allowed builtin function names that generated code may call.
# Note: getattr/setattr/hasattr/delattr are NOT included because they can bypass
# AST attribute restrictions (e.g., getattr(os, 'system')('...') avoids os.system check).
# User-defined functions and registered tools are allowed at runtime.
ALLOWED_BUILTINS: set[str] = {
"print",
"len",
"str",
"int",
"float",
"bool",
"list",
"dict",
"tuple",
"set",
"frozenset",
"range",
"enumerate",
"zip",
"map",
"filter",
"sorted",
"reversed",
"sum",
"min",
"max",
"abs",
"round",
"pow",
"divmod",
"all",
"any",
"chr",
"ord",
"hex",
"oct",
"bin",
"format",
"repr",
"ascii",
"bytes",
"bytearray",
"memoryview",
"isinstance",
"issubclass",
"callable",
"type",
"id",
"hash",
"next",
"iter",
"slice",
}
# Blocked builtin function names that expose dangerous capabilities.
BLOCKED_BUILTINS: set[str] = {
"eval",
"exec",
"compile",
"__import__",
"globals",
"locals",
"vars",
"dir",
"open", # File I/O must go through pathlib with explicit mounts
"input",
"help",
"breakpoint",
"exit",
"quit",
"copyright",
"credits",
"license",
"delattr",
"getattr", # Can bypass AST attribute checks: getattr(os, 'system')
"setattr", # Can bypass AST attribute checks
"hasattr", # Can probe for dangerous attributes
}
# Allowed AST node types for code structure and operations.
ALLOWED_AST_NODES: set[type[ast.AST]] = {
ast.Module,
ast.Expr,
ast.Assign,
ast.AugAssign,
ast.AnnAssign,
ast.For,
ast.AsyncFor,
ast.While,
ast.If,
ast.With,
ast.AsyncWith,
ast.Try,
ast.ExceptHandler,
ast.Pass,
ast.Break,
ast.Continue,
ast.Return,
ast.Await,
# Comparisons and boolean operations
ast.Compare,
ast.BoolOp,
ast.UnaryOp,
ast.And,
ast.Or,
ast.Not,
ast.Eq,
ast.NotEq,
ast.Lt,
ast.LtE,
ast.Gt,
ast.GtE,
ast.In,
ast.NotIn,
ast.Is,
ast.IsNot,
ast.UAdd,
ast.USub,
ast.Invert,
# Data access
ast.Name,
ast.Load,
ast.Store,
ast.Del,
ast.Attribute,
ast.Subscript,
ast.Slice,
# Literals
ast.Constant,
ast.List,
ast.Tuple,
ast.Set,
ast.Dict,
# Arithmetic and bitwise operations
ast.BinOp,
ast.Add,
ast.Sub,
ast.Mult,
ast.Div,
ast.Mod,
ast.FloorDiv,
ast.Pow,
ast.LShift,
ast.RShift,
ast.BitOr,
ast.BitXor,
ast.BitAnd,
# Function calls and comprehensions
ast.Call,
ast.keyword,
ast.ListComp,
ast.SetComp,
ast.DictComp,
ast.GeneratorExp,
ast.comprehension,
# Control flow helpers
ast.IfExp,
ast.JoinedStr,
ast.FormattedValue,
# Imports (validated separately)
ast.Import,
ast.ImportFrom,
ast.alias,
# Function definitions (for local helpers)
ast.FunctionDef,
ast.AsyncFunctionDef,
ast.arguments,
ast.arg,
# Lambda expressions
ast.Lambda,
# Match statements (Python 3.10+)
ast.Match,
ast.match_case,
ast.MatchValue,
ast.MatchSingleton,
ast.MatchSequence,
ast.MatchMapping,
ast.MatchClass,
ast.MatchStar,
ast.MatchAs,
ast.MatchOr,
# Starred expressions
ast.Starred,
}
class CodeValidationError(ValueError):
"""Raised when generated code violates the allow-list policy."""
pass
class _CodeValidator(ast.NodeVisitor):
"""AST visitor that validates generated code against allow-lists."""
def __init__(
self,
*,
allowed_imports: set[str] | None = None,
blocked_imports: set[str] | None = None,
allowed_builtins: set[str] | None = None,
blocked_builtins: set[str] | None = None,
allowed_os_attrs: set[str] | None = None,
) -> None:
super().__init__()
self._errors: list[str] = []
self._allowed_imports = allowed_imports if allowed_imports is not None else ALLOWED_IMPORTS
self._blocked_imports = blocked_imports if blocked_imports is not None else BLOCKED_IMPORTS
self._allowed_builtins = allowed_builtins if allowed_builtins is not None else ALLOWED_BUILTINS
self._blocked_builtins = blocked_builtins if blocked_builtins is not None else BLOCKED_BUILTINS
self._allowed_os_attrs = allowed_os_attrs if allowed_os_attrs is not None else ALLOWED_OS_ATTRS
def validate(self, code: str) -> None:
"""Validate code and raise CodeValidationError if it violates policy."""
try:
tree = ast.parse(code, mode="exec")
except SyntaxError as exc:
raise CodeValidationError(f"Syntax error in generated code: {exc}") from exc
self._errors = []
self.visit(tree)
if self._errors:
raise CodeValidationError(
"Generated code violates allow-list policy:\n" + "\n".join(f"- {err}" for err in self._errors)
)
def visit(self, node: ast.AST) -> Any:
"""Visit a node and check if its type is allowed."""
node_type = type(node)
if node_type not in ALLOWED_AST_NODES:
self._errors.append(f"AST node type '{node_type.__name__}' is not allowed")
return None
return super().visit(node)
def visit_Import(self, node: ast.Import) -> None:
"""Validate import statements."""
for alias_node in node.names:
module_name = alias_node.name.split(".")[0]
if module_name in self._blocked_imports:
self._errors.append(f"Import of '{alias_node.name}' is not allowed (blocked: {module_name})")
elif module_name not in self._allowed_imports:
self._errors.append(f"Import of '{alias_node.name}' is not allowed (not in allow-list)")
self.generic_visit(node)
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
"""Validate from-import statements."""
if node.module is None:
self._errors.append("Relative imports are not allowed")
return
module_name = node.module.split(".")[0]
if module_name in self._blocked_imports:
self._errors.append(f"Import from '{node.module}' is not allowed (blocked: {module_name})")
elif module_name not in self._allowed_imports:
self._errors.append(f"Import from '{node.module}' is not allowed (not in allow-list)")
elif module_name == "os":
# Mirror the os.* attribute allow-list for ``from os import X``,
# otherwise ``from os import system`` would bypass visit_Attribute.
for alias_node in node.names:
if alias_node.name not in self._allowed_os_attrs:
self._errors.append(f"Import from 'os' of '{alias_node.name}' is not allowed")
self.generic_visit(node)
def visit_Call(self, node: ast.Call) -> None:
"""Validate function calls.
For names that match a real Python builtin we enforce both the block-list
and the allow-list. Names that are not builtins are treated as user-defined
functions or registered tools and are allowed (validated at runtime).
"""
if isinstance(node.func, ast.Name):
func_name = node.func.id
if func_name in self._blocked_builtins:
self._errors.append(f"Call to builtin '{func_name}' is not allowed")
elif func_name in _PYTHON_BUILTIN_NAMES and func_name not in self._allowed_builtins:
# Real builtin that wasn't explicitly allowed — reject so the allow-list is meaningful.
self._errors.append(f"Call to builtin '{func_name}' is not in the allowed builtins list")
# Check for attribute access to dangerous methods
if isinstance(node.func, ast.Attribute):
attr_name = node.func.attr
# Block common dangerous attribute methods
if (
attr_name.startswith("__")
and attr_name.endswith("__")
and attr_name not in {"__init__", "__str__", "__repr__", "__eq__", "__hash__"}
):
self._errors.append(f"Call to dunder method '{attr_name}' is not allowed")
self.generic_visit(node)
def visit_Attribute(self, node: ast.Attribute) -> None:
"""Validate attribute access."""
# Enforce the `os` attribute allow-list. Anything outside `ALLOWED_OS_ATTRS`
# (file I/O, process control, mutating helpers, etc.) is rejected so the
# validator matches the documented `os.environ` / `os.path`-only contract.
if isinstance(node.value, ast.Name) and node.value.id == "os" and node.attr not in self._allowed_os_attrs:
self._errors.append(f"Access to os.{node.attr} is not allowed")
# Block access to certain dangerous attributes
if (
node.attr.startswith("__")
and node.attr.endswith("__")
and node.attr
not in {
"__name__",
"__doc__",
"__dict__",
"__class__",
"__module__",
"__file__",
"__init__",
"__str__",
"__repr__",
"__eq__",
"__hash__",
"__len__",
"__iter__",
"__next__",
"__enter__",
"__exit__",
"__aenter__",
"__aexit__",
}
):
self._errors.append(f"Access to attribute '{node.attr}' is not allowed")
self.generic_visit(node)
def validate_code(
code: str,
*,
allowed_imports: set[str] | None = None,
blocked_imports: set[str] | None = None,
allowed_builtins: set[str] | None = None,
blocked_builtins: set[str] | None = None,
allowed_os_attrs: set[str] | None = None,
) -> None:
"""Validate generated code against AST allow-lists.
Args:
code: Python source code to validate.
allowed_imports: Custom set of allowed module names (replaces defaults).
blocked_imports: Custom set of blocked module names (replaces defaults).
allowed_builtins: Custom set of allowed builtin names (replaces defaults).
blocked_builtins: Custom set of blocked builtin names (replaces defaults).
allowed_os_attrs: Custom set of allowed ``os`` attribute names
(replaces the default ``{"environ", "path"}`` allow-list).
Raises:
CodeValidationError: If the code violates the allow-list policy.
"""
validator = _CodeValidator(
allowed_imports=allowed_imports,
blocked_imports=blocked_imports,
allowed_builtins=allowed_builtins,
blocked_builtins=blocked_builtins,
allowed_os_attrs=allowed_os_attrs,
)
validator.validate(code)
def _main() -> int:
"""Script entrypoint: read a JSON request from stdin and validate it.
Request shape:
{
"code": "...",
"allowed_imports": [...]?,
"blocked_imports": [...]?,
"allowed_builtins": [...]?,
"blocked_builtins": [...]?,
"allowed_os_attrs": [...]?
}
On success: exit code 0, no output required.
On validation failure: exit code 1, JSON {"errors": ["..."]} on stdout.
On request error: exit code 2, JSON {"message": "..."} on stdout.
"""
import json
import sys
raw = sys.stdin.read()
try:
request = json.loads(raw) if raw.strip() else {}
if not isinstance(request, dict):
raise ValueError("Validator request must be a JSON object.")
code = request.get("code")
if not isinstance(code, str):
raise ValueError("Validator request must include a 'code' string field.")
except Exception as exc: # noqa: BLE001 - report any parse error to caller
json.dump({"message": f"Invalid validator request: {exc}"}, sys.stdout)
return 2
def _as_set(value: Any) -> set[str] | None:
if value is None:
return None
if not isinstance(value, list):
raise ValueError("Validator allow/block lists must be arrays of strings.")
return {str(item) for item in value}
try:
validate_code(
code,
allowed_imports=_as_set(request.get("allowed_imports")),
blocked_imports=_as_set(request.get("blocked_imports")),
allowed_builtins=_as_set(request.get("allowed_builtins")),
blocked_builtins=_as_set(request.get("blocked_builtins")),
allowed_os_attrs=_as_set(request.get("allowed_os_attrs")),
)
except CodeValidationError as exc:
message = str(exc)
lines = [line.lstrip("- ").rstrip() for line in message.splitlines() if line.strip()]
if lines and lines[0].startswith("Generated code violates"):
lines = lines[1:]
if not lines:
lines = [message]
json.dump({"errors": lines}, sys.stdout)
return 1
except Exception as exc: # noqa: BLE001 - convert unexpected errors to a structured response
json.dump({"errors": [f"{type(exc).__name__}: {exc}"]}, sys.stdout)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(_main())