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
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:
@@ -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())
|
||||
Reference in New Issue
Block a user