diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index c36bc227d93..c755420cfdd 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -331,6 +331,9 @@ + + + @@ -616,6 +619,7 @@ + @@ -671,6 +675,7 @@ + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/.env.example new file mode 100644 index 00000000000..c5423f93f73 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/.env.example @@ -0,0 +1,6 @@ +FOUNDRY_PROJECT_ENDPOINT= +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development +FOUNDRY_MODEL=gpt-4o +AZURE_BEARER_TOKEN=DefaultAzureCredential +LOCAL_CODEACT_PYTHON=python3 diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/Dockerfile new file mode 100644 index 00000000000..1d201d0472d --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/Dockerfile @@ -0,0 +1,23 @@ +# Use the official .NET 10.0 ASP.NET runtime as a parent image +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app + +# Install Python 3 so LocalCodeAct can spawn the embedded runner / validator. +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 \ + && rm -rf /var/lib/apt/lists/* + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet restore +RUN dotnet publish -c Release -o /app/publish + +# Final stage +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENV LOCAL_CODEACT_PYTHON=python3 +ENTRYPOINT ["dotnet", "HostedLocalCodeAct.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/Dockerfile.contributor b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/Dockerfile.contributor new file mode 100644 index 00000000000..c27fb0ab416 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/Dockerfile.contributor @@ -0,0 +1,24 @@ +# Dockerfile for contributors building from the agent-framework repository source. +# +# This project uses ProjectReference to the local Microsoft.Agents.AI.Foundry and +# Microsoft.Agents.AI.LocalCodeAct sources, which means a standard multi-stage +# Docker build cannot resolve dependencies outside this folder. Instead, pre-publish +# the app targeting the container runtime and copy the output into the container: +# +# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +# docker build -f Dockerfile.contributor -t hosted-local-codeact . +# docker run --rm -p 8088:8088 -e AGENT_NAME=hosted-local-codeact -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN --env-file .env hosted-local-codeact +# +# For end-users consuming the NuGet package (not ProjectReference), use the standard +# Dockerfile which performs a full dotnet restore + publish inside the container. +FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final +WORKDIR /app + +# Install Python 3 so LocalCodeAct can spawn the embedded runner / validator. +RUN apk add --no-cache python3 + +COPY out/ . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENV LOCAL_CODEACT_PYTHON=python3 +ENTRYPOINT ["dotnet", "HostedLocalCodeAct.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/HostedLocalCodeAct.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/HostedLocalCodeAct.csproj new file mode 100644 index 00000000000..0c2a417ff7d --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/HostedLocalCodeAct.csproj @@ -0,0 +1,33 @@ + + + + net10.0 + enable + enable + false + $(NoWarn); + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/Program.cs new file mode 100644 index 00000000000..9e5a91503d1 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/Program.cs @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Hosted Local CodeAct sample. Wires Microsoft.Agents.AI.LocalCodeAct into a +// Foundry hosted agent. The model only sees a single `execute_code` tool; +// `compute` and `fetch_data` are registered as sandbox-only host tools that +// generated Python reaches via `await call_tool(...)`. This mirrors the Python +// `foundry_hosted_agent.py` sample for the local-codeact package. +// +// SECURITY: LocalCodeAct executes LLM-generated Python in the agent process. +// Only deploy this sample to an externally sandboxed environment such as a +// Foundry hosted-agent container. + +using System.ComponentModel; +using Azure.AI.Projects; +using Azure.Core; +using Azure.Identity; +using DotNetEnv; +using Hosted_Shared_Contributor_Setup; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.Agents.AI.LocalCodeAct; +using Microsoft.Extensions.AI; + +// Load .env file if present (for local development) +Env.TraversePath().Load(); + +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-4o"; +string pythonExecutable = Environment.GetEnvironmentVariable("LOCAL_CODEACT_PYTHON") + ?? (OperatingSystem.IsWindows() ? "python.exe" : "python3"); + +TokenCredential credential = new ChainedTokenCredential( + new DevTemporaryTokenCredential(), + new DefaultAzureCredential()); + +// ── Sandbox-only tools (model never sees these directly) ───────────────────── + +[Description("Perform a math operation: add, subtract, multiply, or divide.")] +static double Compute( + [Description("Operation: add, subtract, multiply, or divide.")] string operation, + [Description("First numeric operand.")] double a, + [Description("Second numeric operand.")] double b) => operation switch + { + "add" => a + b, + "subtract" => a - b, + "multiply" => a * b, + "divide" => b == 0 ? double.PositiveInfinity : a / b, + _ => throw new ArgumentException($"Unknown operation '{operation}'.", nameof(operation)), + }; + +[Description("Fetch records from a named simulated table (users or products).")] +static IReadOnlyList> FetchData( + [Description("Name of the simulated table to query.")] string table) +{ + Dictionary>> data = new() + { + ["users"] = + [ + new Dictionary { ["id"] = 1, ["name"] = "Alice", ["role"] = "admin" }, + new Dictionary { ["id"] = 2, ["name"] = "Bob", ["role"] = "user" }, + new Dictionary { ["id"] = 3, ["name"] = "Charlie", ["role"] = "admin" }, + ], + ["products"] = + [ + new Dictionary { ["id"] = 101, ["name"] = "Widget", ["price"] = 9.99 }, + new Dictionary { ["id"] = 102, ["name"] = "Gadget", ["price"] = 19.99 }, + ], + }; + + return data.TryGetValue(table, out var rows) ? rows : []; +} + +// ── LocalCodeAct provider with sandbox-only host tools ─────────────────────── + +var codeActOptions = new LocalCodeActProviderOptions +{ + Tools = + [ + AIFunctionFactory.Create(Compute, name: "compute"), + AIFunctionFactory.Create(FetchData, name: "fetch_data"), + ], + ExecutionLimits = new ProcessExecutionLimits { TimeoutSeconds = 5 }, +}; + +var codeAct = new LocalCodeActProvider(pythonExecutable, codeActOptions); + +// ── Build the hosted agent ─────────────────────────────────────────────────── + +AIAgent agent = new AIProjectClient(new Uri(endpoint), credential) + .AsAIAgent(new ChatClientAgentOptions + { + Name = Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-local-codeact", + Description = "Hosted CodeAct agent with sandbox-only compute and fetch_data tools.", + ChatOptions = new ChatOptions + { + ModelId = deploymentName, + Instructions = + """ + You are a helpful assistant. Keep your answers brief. Prefer orchestrating your work + in a single `execute_code` block using `await call_tool(...)` over issuing many + direct tool calls. The sandbox exposes `compute` and `fetch_data` via `call_tool`. + """, + }, + AIContextProviders = [codeAct], + }); + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddFoundryResponses(agent); +builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production. + +var app = builder.Build(); +app.MapFoundryResponses(); + +// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses +// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint). +// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path. +app.MapDevTemporaryLocalAgentEndpoint(); + +app.Run(); diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/README.md new file mode 100644 index 00000000000..229ff4f5839 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/README.md @@ -0,0 +1,159 @@ +# Hosted-LocalCodeAct + +A hosted agent that uses [`Microsoft.Agents.AI.LocalCodeAct`](../../../../../src/Microsoft.Agents.AI.LocalCodeAct/README.md) +to give the model a single `execute_code` tool. Two sandbox-only host tools, +`compute` and `fetch_data`, are registered on `LocalCodeActProvider` and are +reachable from inside generated Python via `await call_tool(...)` — never as +direct LLM tool calls. + +This mirrors the Python +[`foundry_hosted_agent.py`](https://github.com/microsoft/agent-framework/blob/main/python/packages/local_codeact/samples/foundry_hosted_agent.py) +sample for the `agent-framework-local-codeact` package. + +> **⚠️ Security:** LocalCodeAct executes LLM-generated Python in the agent +> process. The package is not a sandbox — it relies on the Foundry hosted-agent +> container (or another externally sandboxed environment) for process, +> filesystem, and network isolation. Do not run this outside of a sandbox. + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- Python 3 available on `PATH` (used by `LocalCodeActProvider` to execute the + embedded runner and validator). Override with the `LOCAL_CODEACT_PYTHON` + environment variable if you need a specific interpreter path. +- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`) +- Azure CLI logged in (`az login`) + +## Configuration + +Copy the template and fill in your project endpoint: + +```bash +cp .env.example .env +``` + +Edit `.env` and set your Azure AI Foundry project endpoint: + +```env +FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development +FOUNDRY_MODEL=gpt-4o +LOCAL_CODEACT_PYTHON=python3 +``` + +> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference. + +## Running directly (contributors) + +This project uses `ProjectReference` to build against the local Agent Framework +source, including the `Microsoft.Agents.AI.LocalCodeAct` package. + +```bash +cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct +AGENT_NAME=hosted-local-codeact dotnet run +``` + +The agent will start on `http://localhost:8088`. + +### Test it + +Using the Azure Developer CLI: + +```bash +azd ai agent invoke --local "Fetch all users, find the admins, multiply 7 by 6, and print the users, admins, and the multiplication result. Use execute_code with await call_tool(...)." +``` + +Or with curl: + +```bash +curl -X POST http://localhost:8088/responses \ + -H "Content-Type: application/json" \ + -d '{"input": "Fetch all users, find the admins, multiply 7 by 6, and print the users, admins, and the multiplication result. Use execute_code with await call_tool(...).", "model": "hosted-local-codeact"}' +``` + +## Running with Docker + +Since this project uses `ProjectReference`, use `Dockerfile.contributor` which +takes a pre-published output. The image installs Python 3 so the embedded +runner and validator scripts can execute. + +### 1. Publish for the container runtime (Linux Alpine) + +```bash +dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +``` + +### 2. Build the Docker image + +```bash +docker build -f Dockerfile.contributor -t hosted-local-codeact . +``` + +### 3. Run the container + +Generate a bearer token on your host and pass it to the container: + +```bash +# Generate token (expires in ~1 hour) +export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) + +# Run with token +docker run --rm -p 8088:8088 \ + -e AGENT_NAME=hosted-local-codeact \ + -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \ + --env-file .env \ + hosted-local-codeact +``` + +### 4. Test it + +```bash +azd ai agent invoke --local "Fetch all users and print the admins." +``` + +## How CodeAct works here + +`LocalCodeActProvider` is registered as an `AIContextProvider`. On every run it +injects: + +- A single `execute_code` tool that the model can call with a Python snippet. +- CodeAct instructions that teach the model to use `await call_tool(...)` for + the provider-owned host tools, rather than asking for direct tool calls. + +The provider-owned host tools in this sample: + +| Tool | Description | +|------|-------------| +| `compute(operation, a, b)` | Math operation: `add`, `subtract`, `multiply`, `divide`. | +| `fetch_data(table)` | Returns rows from a simulated `users` or `products` table. | + +`execute_code` runs the generated Python in a separate Python process governed +by `ProcessExecutionLimits` (5 second timeout in this sample) and the +default-on AST allow-list validator that rejects disallowed imports, builtins, +and dynamic-eval constructs before execution. + +## Deploying to Foundry (azd spec) + +This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent +spec (`agent.yaml`) for deployment to Foundry. + +Initialize an `azd` project from this sample's manifest: + +```bash +mkdir hosted-local-codeact && cd hosted-local-codeact +azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/agent.manifest.yaml +``` + +Then deploy: + +```bash +azd deploy +``` + +## NuGet package users + +If you are consuming the Agent Framework as a NuGet package (not building from +source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See +the commented section in `HostedLocalCodeAct.csproj` for the `PackageReference` +alternative. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/agent.manifest.yaml new file mode 100644 index 00000000000..f00aedf60e3 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/agent.manifest.yaml @@ -0,0 +1,30 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: hosted-local-codeact +displayName: "Hosted Local CodeAct Agent" + +description: > + A hosted agent that uses the CodeAct pattern via + Microsoft.Agents.AI.LocalCodeAct. The model only sees an `execute_code` + tool and orchestrates `compute` and `fetch_data` sandbox-only host tools + via `await call_tool(...)` from inside generated Python. + +metadata: + tags: + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Local CodeAct + - Agent Framework + +template: + name: hosted-local-codeact + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + resources: + cpu: "0.5" + memory: 1Gi +parameters: + properties: [] +resources: [] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/agent.yaml new file mode 100644 index 00000000000..cdf4b5c9a13 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/agent.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: hosted-local-codeact +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: "0.5" + memory: 1Gi diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/CodeValidationException.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/CodeValidationException.cs new file mode 100644 index 00000000000..c81f4d66275 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/CodeValidationException.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.LocalCodeAct; + +/// +/// Exception thrown when AST validation of generated Python code fails. +/// +public sealed class CodeValidationException : Exception +{ + /// Initializes a new instance of the class. + public CodeValidationException() + { + } + + /// Initializes a new instance of the class. + /// Validation error message. + public CodeValidationException(string message) : base(message) + { + } + + /// Initializes a new instance of the class. + /// Validation error message. + /// Underlying exception. + public CodeValidationException(string message, Exception innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/FileMount.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/FileMount.cs new file mode 100644 index 00000000000..4823bd4e033 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/FileMount.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.LocalCodeAct; + +/// +/// File mount access mode. +/// +public enum FileMountMode +{ + /// Read-only access. Files are not scanned for capture after execution. + ReadOnly, + + /// Read-write access. New or modified files are captured after execution. + ReadWrite, +} + +/// +/// Represents a host directory exposed to locally executed code. +/// +/// +/// +/// Unlike a true sandbox, mounts in this package expose +/// directly to the subprocess. The 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.). +/// +/// +public sealed class FileMount +{ + /// + /// Initializes a new instance of the class. + /// + /// Path on the host filesystem to expose to the subprocess. Must exist. + /// + /// Logical path used to describe the mount to the model (for example "/input/data.csv"). + /// + /// Access mode for the mount. Defaults to . + /// + /// Optional per-mount write capture limit (in bytes). When , the global + /// applies. + /// + 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; + } + + /// Gets the host filesystem path exposed to the subprocess. + public string HostPath { get; } + + /// Gets the logical mount path used to describe the mount to the model. + public string MountPath { get; } + + /// Gets the access mode for the mount. + public FileMountMode Mode { get; } + + /// Gets the optional per-mount write capture limit (in bytes). + public long? WriteBytesLimit { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/CodeExecutor.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/CodeExecutor.cs new file mode 100644 index 00000000000..51d6a7136d1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/CodeExecutor.cs @@ -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; + +/// +/// Coordinates a single execution: optional validation, snapshot of writable mounts, +/// running the subprocess, capturing written files, and assembling the final content list. +/// +internal sealed class CodeExecutor +{ + private readonly string _pythonExecutable; + private readonly string _runnerScript; + private readonly CodeValidator? _validator; + private readonly ProcessExecutionLimits _limits; + private readonly IReadOnlyDictionary? _environment; + private readonly string? _workingDirectory; + + public CodeExecutor( + string pythonExecutable, + string runnerScript, + CodeValidator? validator, + ProcessExecutionLimits limits, + IReadOnlyDictionary? environment, + string? workingDirectory) + { + this._pythonExecutable = pythonExecutable; + this._runnerScript = runnerScript; + this._validator = validator; + this._limits = limits; + this._environment = environment; + this._workingDirectory = workingDirectory; + } + + /// Immutable snapshot of provider state captured at the start of an invocation. + public sealed class RunSnapshot + { + public RunSnapshot(IReadOnlyList tools, IReadOnlyList fileMounts) + { + this.Tools = tools; + this.FileMounts = fileMounts; + } + + public IReadOnlyList Tools { get; } + + public IReadOnlyList FileMounts { get; } + } + + public async Task> 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 BuildContents(ProcessBridge.ExecutionResult result, List capturedFiles) + { + var contents = new List(); + + 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; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/CodeValidator.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/CodeValidator.cs new file mode 100644 index 00000000000..1650870a47d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/CodeValidator.cs @@ -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; + +/// +/// Runs the embedded Python AST validator in a child process with a strict timeout. +/// +internal sealed class CodeValidator +{ + private readonly string _pythonExecutable; + private readonly string _validatorScript; + private readonly TimeSpan _timeout; + private readonly IReadOnlyList? _allowedImports; + private readonly IReadOnlyList? _blockedImports; + private readonly IReadOnlyList? _allowedBuiltins; + private readonly IReadOnlyList? _blockedBuiltins; + + public CodeValidator( + string pythonExecutable, + string validatorScript, + TimeSpan timeout, + IReadOnlyList? allowedImports, + IReadOnlyList? blockedImports, + IReadOnlyList? allowedBuiltins, + IReadOnlyList? blockedBuiltins) + { + this._pythonExecutable = pythonExecutable; + this._validatorScript = validatorScript; + this._timeout = timeout; + this._allowedImports = allowedImports; + this._blockedImports = blockedImports; + this._allowedBuiltins = allowedBuiltins; + this._blockedBuiltins = blockedBuiltins; + } + + /// Validates Python source code against the configured allow-lists. + /// Thrown when validation fails. + 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? values) + { + if (values is null) + { + return; + } + + obj[key] = new JsonArray(values.Select(v => (JsonNode?)JsonValue.Create(v)).ToArray()); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/EmbeddedScripts.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/EmbeddedScripts.cs new file mode 100644 index 00000000000..cf56f64a994 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/EmbeddedScripts.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; + +namespace Microsoft.Agents.AI.LocalCodeAct.Internal; + +/// +/// Extracts the embedded Python runner.py and validator.py scripts to a temporary +/// directory and caches their paths for the lifetime of the process. +/// +internal static class EmbeddedScripts +{ + private static readonly object s_syncRoot = new(); + private static string? s_runnerPath; + private static string? s_validatorPath; + + /// Returns the path to the embedded runner.py, extracting it on first access. + public static string GetRunnerScriptPath() => GetOrExtract("runner.py", ref s_runnerPath); + + /// Returns the path to the embedded validator.py, extracting it on first access. + 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; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/ExecuteCodeFunction.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/ExecuteCodeFunction.cs new file mode 100644 index 00000000000..24adf474475 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/ExecuteCodeFunction.cs @@ -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; + +/// +/// Run-scoped that exposes execute_code to the model. +/// +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 InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) => + this._inner.InvokeAsync(arguments, cancellationToken); + + private async ValueTask 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); +} diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/FileMountHelper.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/FileMountHelper.cs new file mode 100644 index 00000000000..077b364f246 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/FileMountHelper.cs @@ -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; + +/// +/// Filesystem helpers for read-write mount snapshotting and capture. +/// +internal static class FileMountHelper +{ + /// Normalizes and validates a mount path (must be a clean absolute POSIX-style path). + 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); + } + + /// + /// Validates a FileMount and returns a normalized copy (resolved host path, normalized mount path). + /// + 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); + } + + /// Snapshot of (size, last-write-time ticks) per relative path under a writable mount. + public sealed class MountSnapshot + { + public MountSnapshot(IReadOnlyDictionary files) + { + this.Files = files; + } + + public IReadOnlyDictionary Files { get; } + } + + /// Captures the current file inventory of read-write mounts before execution. + public static Dictionary SnapshotWritableMounts(IReadOnlyList mounts) + { + var snapshot = new Dictionary(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()); + continue; + } + + var files = new Dictionary(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; + } + + /// Captures files that were created or modified in read-write mounts since the snapshot was taken. + public static List CaptureWrittenFiles( + IReadOnlyList mounts, + IReadOnlyDictionary preState, + ProcessExecutionLimits limits) + { + var captured = new List(); + 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(); + 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 EnumerateRealFiles(DirectoryInfo root) + { + var stack = new Stack(); + 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", + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/InstructionBuilder.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/InstructionBuilder.cs new file mode 100644 index 00000000000..c54cb6dc49d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/InstructionBuilder.cs @@ -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(\"\", **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 tools, + IReadOnlyList 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(\"\", **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(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/ProcessBridge.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/ProcessBridge.cs new file mode 100644 index 00000000000..f0a4498f8f4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/ProcessBridge.cs @@ -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; + +/// +/// Parent-side IPC bridge that launches the Python runner, sends a single execution request, +/// services tool calls, and returns the final execution result. +/// +internal sealed class ProcessBridge +{ + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + WriteIndented = false, + }; + + private readonly string _pythonExecutable; + private readonly string _runnerScript; + private readonly IReadOnlyDictionary _tools; + private readonly ProcessExecutionLimits _limits; + private readonly IReadOnlyDictionary? _environment; + private readonly string? _workingDirectory; + + public ProcessBridge( + string pythonExecutable, + string runnerScript, + IReadOnlyList tools, + ProcessExecutionLimits limits, + IReadOnlyDictionary? 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; + } + + /// Represents the parsed final result returned by the Python runner. + 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 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 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(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 + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProvider.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProvider.cs new file mode 100644 index 00000000000..dcf5532e9bd --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProvider.cs @@ -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; + +/// +/// An that injects a local Python execute_code tool +/// into the agent's tool surface. +/// +/// +/// +/// 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 mounts. +/// +/// +/// Security: 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.). +/// +/// +public sealed class LocalCodeActProvider : AIContextProvider, IDisposable +{ + /// Fixed state key used to enforce a single provider per agent. + internal const string FixedStateKey = "LocalCodeActProvider"; + + private static readonly IReadOnlyList s_stateKeys = [FixedStateKey]; + + private readonly CodeExecutor _executor; + + private readonly ConcurrentDictionary _tools = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _fileMounts = new(StringComparer.Ordinal); + private volatile bool _disposed; + + /// Initializes a new instance of the class. + /// Path to the Python interpreter used for execution and validation. + /// Optional provider configuration. + 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; + } + } + } + + /// + public override IReadOnlyList StateKeys => s_stateKeys; + + // ------------------------------------------------------------------- + // Tool registry + // ------------------------------------------------------------------- + + /// Adds tools to the provider-owned tool registry. Duplicate names replace existing entries. + 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; + } + } + + /// Returns the currently registered tools. + public IReadOnlyList GetTools() + { + return this._tools.Values.ToList(); + } + + /// Removes tools by name. + 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 _); + } + } + + /// Removes all registered tools. + public void ClearTools() + { + this.ThrowIfDisposed(); + this._tools.Clear(); + } + + // ------------------------------------------------------------------- + // File mounts + // ------------------------------------------------------------------- + + /// Adds file mounts. Duplicate mount paths replace existing entries. + 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; + } + } + + /// Returns the currently registered file mounts. + public IReadOnlyList GetFileMounts() + { + return this._fileMounts.Values.ToList(); + } + + /// Removes file mounts by mount path. + 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 _); + } + } + + /// Removes all registered file mounts. + public void ClearFileMounts() + { + this.ThrowIfDisposed(); + this._fileMounts.Clear(); + } + + // ------------------------------------------------------------------- + // AIContextProvider implementation + // ------------------------------------------------------------------- + + /// + protected override ValueTask 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(new AIContext + { + Instructions = instructions, + Tools = [executeCode], + }); + } + + private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(this._disposed, this); + + /// + public void Dispose() + { + this._disposed = true; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProviderOptions.cs new file mode 100644 index 00000000000..1dc86108abf --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProviderOptions.cs @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.LocalCodeAct; + +/// +/// Configuration options for and . +/// +public sealed class LocalCodeActProviderOptions +{ + /// Gets or sets the resource limits applied to subprocess execution and capture. + public ProcessExecutionLimits? ExecutionLimits { get; set; } + + /// + /// Gets or sets the initial set of host tools available to generated code via await call_tool(...). + /// + public IEnumerable? Tools { get; set; } + + /// + /// Gets or sets the initial set of file mounts exposed to generated code. + /// + public IEnumerable? FileMounts { get; set; } + + /// + /// Gets or sets environment variables passed to the subprocess. + /// + /// + /// When , the subprocess inherits the parent process environment + /// (the default behavior). To run with + /// a restricted environment, supply a dictionary containing only the variables the + /// subprocess should see — pass an empty dictionary for a fully scrubbed environment. + /// 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. + /// + public IReadOnlyDictionary? Environment { get; set; } + + /// + /// Gets or sets the working directory used for the subprocess. When + /// the current working directory of the host process is used. + /// + public string? WorkingDirectory { get; set; } + + /// + /// Gets or sets the optional override path to the Python runner script. When + /// the embedded runner.py is extracted to a temporary directory and used. + /// + public string? RunnerScriptPath { get; set; } + + /// + /// Gets or sets the optional override path to the Python validator script. When + /// the embedded validator.py is extracted to a temporary directory and used. + /// + public string? ValidatorScriptPath { get; set; } + + /// + /// Gets or sets whether AST allow-list validation is disabled. Defaults to . + /// + /// + /// 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. + /// + public bool ValidationDisabled { get; set; } + + /// + /// Gets or sets the set of imports allowed by the validator. When + /// the validator's built-in defaults are used. Setting a value replaces the defaults. + /// + public IEnumerable? AllowedImports { get; set; } + + /// + /// Gets or sets the set of imports blocked by the validator. When + /// the validator's built-in defaults are used. Setting a value replaces the defaults. + /// + public IEnumerable? BlockedImports { get; set; } + + /// + /// Gets or sets the set of builtins allowed by the validator. When + /// the validator's built-in defaults are used. Setting a value replaces the defaults. + /// + public IEnumerable? AllowedBuiltins { get; set; } + + /// + /// Gets or sets the set of builtins blocked by the validator. When + /// the validator's built-in defaults are used. Setting a value replaces the defaults. + /// + public IEnumerable? BlockedBuiltins { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalExecuteCodeFunction.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalExecuteCodeFunction.cs new file mode 100644 index 00000000000..96736ab4b16 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalExecuteCodeFunction.cs @@ -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; + +/// +/// Standalone execute_code that runs Python locally in a subprocess. +/// +/// +/// Use this when you want to expose code execution directly as a model-facing function without +/// the indirection. Tools and file mounts are captured at +/// construction time and immutable for the lifetime of the function. +/// +public sealed class LocalExecuteCodeFunction : AIFunction +{ + private const string ExecuteCodeName = "execute_code"; + + private readonly CodeExecutor _executor; + private readonly CodeExecutor.RunSnapshot _snapshot; + private readonly AIFunction _inner; + + /// Initializes a new instance of the class. + /// Path to the Python interpreter used for execution and validation. + /// Optional function configuration. + 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(); + var fileMounts = options.FileMounts?.Where(m => m is not null).Select(FileMountHelper.Normalize).ToList() ?? new List(); + + 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), + }); + } + + /// + public override string Name => this._inner.Name; + + /// + public override string Description => this._inner.Description; + + /// + public override JsonElement JsonSchema => this._inner.JsonSchema; + + /// + protected override ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) => + this._inner.InvokeAsync(arguments, cancellationToken); + + private async ValueTask 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); +} diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Microsoft.Agents.AI.LocalCodeAct.csproj b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Microsoft.Agents.AI.LocalCodeAct.csproj new file mode 100644 index 00000000000..2b41d7b81b2 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Microsoft.Agents.AI.LocalCodeAct.csproj @@ -0,0 +1,42 @@ + + + + preview + $(TargetFrameworksCore) + + + + true + + + + + + + + + + + + + + + Microsoft Agent Framework - Local CodeAct integration + Provides local Python code execution (CodeAct) with AST validation for Microsoft Agent Framework. Requires external sandboxing (e.g., container, VM). + README.md + + + + + + + + + + + + + + + + diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/ProcessExecutionLimits.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/ProcessExecutionLimits.cs new file mode 100644 index 00000000000..d3518abb0ea --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/ProcessExecutionLimits.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.LocalCodeAct; + +/// +/// Resource limits for subprocess code execution. +/// +/// +/// 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). +/// +public sealed class ProcessExecutionLimits +{ + /// Gets or sets the maximum execution time for the subprocess, in seconds. Default is 30. + public int TimeoutSeconds { get; set; } = 30; + + /// Gets or sets the maximum time the AST validator subprocess may run, in seconds. Default is 10. + public int ValidationTimeoutSeconds { get; set; } = 10; + + /// Gets or sets the maximum bytes of stdout captured from the subprocess. Default is 10 MiB. + public int MaxStdoutBytes { get; set; } = 10 * 1024 * 1024; + + /// Gets or sets the maximum bytes of stderr captured from the subprocess. Default is 10 MiB. + public int MaxStderrBytes { get; set; } = 10 * 1024 * 1024; + + /// Gets or sets the maximum serialized result size in bytes. Default is 10 MiB. + public int MaxResultBytes { get; set; } = 10 * 1024 * 1024; + + /// Gets or sets the maximum bytes captured per file under read-write mounts. Default is 1 MiB. + public int MaxCapturedFileBytes { get; set; } = 1024 * 1024; + + /// Gets or sets the maximum total bytes captured across all read-write mounts. Default is 10 MiB. + public int MaxTotalCapturedFileBytes { get; set; } = 10 * 1024 * 1024; +} diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/README.md b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/README.md new file mode 100644 index 00000000000..13ca7c38477 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/README.md @@ -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("", ...)`. +- **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 + { + ["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 diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/runner.py b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/runner.py new file mode 100644 index 00000000000..4a660cc75da --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/runner.py @@ -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, "", "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())) diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/validator.py b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/validator.py new file mode 100644 index 00000000000..69453999d57 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/validator.py @@ -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()) diff --git a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/FileMountHelperTests.cs b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/FileMountHelperTests.cs new file mode 100644 index 00000000000..d47679946fe --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/FileMountHelperTests.cs @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.IO; +using System.Linq; +using Microsoft.Agents.AI.LocalCodeAct.Internal; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests; + +/// +/// Unit tests for covering the capture-limit branches +/// (per-file, per-mount, and total) that produce textual omission placeholders +/// instead of . +/// +public sealed class FileMountHelperTests +{ + [Fact] + public void CaptureWrittenFiles_PerFileLimit_ReturnsTextPlaceholder() + { + var dir = Directory.CreateTempSubdirectory("fmh-perfile-").FullName; + try + { + var mount = FileMountHelper.Normalize(new FileMount(dir, "/output", FileMountMode.ReadWrite)); + var pre = FileMountHelper.SnapshotWritableMounts(new[] { mount }); + + File.WriteAllBytes(Path.Combine(dir, "big.bin"), new byte[2048]); + + // Per-file limit of 1024 bytes — file is 2048 -> should be omitted via TextContent. + var limits = new ProcessExecutionLimits { MaxCapturedFileBytes = 1024 }; + var captured = FileMountHelper.CaptureWrittenFiles(new[] { mount }, pre, limits); + + var text = Assert.Single(captured.OfType()); + Assert.Contains("/output/big.bin", text.Text); + Assert.Contains("per-file capture limit", text.Text); + Assert.Empty(captured.OfType()); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void CaptureWrittenFiles_PerMountLimit_OmitsSecondFile() + { + var dir = Directory.CreateTempSubdirectory("fmh-permount-").FullName; + try + { + // WriteBytesLimit caps total bytes captured *for this mount*. + var mount = FileMountHelper.Normalize( + new FileMount(dir, "/output", FileMountMode.ReadWrite, writeBytesLimit: 600)); + var pre = FileMountHelper.SnapshotWritableMounts(new[] { mount }); + + // Two files of 400 bytes each — first fits, second exceeds the 600-byte per-mount cap. + File.WriteAllBytes(Path.Combine(dir, "a.bin"), new byte[400]); + File.WriteAllBytes(Path.Combine(dir, "b.bin"), new byte[400]); + + var limits = new ProcessExecutionLimits(); // per-file/total caps high enough not to fire. + var captured = FileMountHelper.CaptureWrittenFiles(new[] { mount }, pre, limits); + + Assert.Single(captured.OfType()); + var text = Assert.Single(captured.OfType()); + Assert.Contains("per-mount capture limit", text.Text); + Assert.Contains("/output/b.bin", text.Text); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void CaptureWrittenFiles_TotalLimit_OmitsAcrossMounts() + { + var dirA = Directory.CreateTempSubdirectory("fmh-totalA-").FullName; + var dirB = Directory.CreateTempSubdirectory("fmh-totalB-").FullName; + try + { + var mountA = FileMountHelper.Normalize(new FileMount(dirA, "/a", FileMountMode.ReadWrite)); + var mountB = FileMountHelper.Normalize(new FileMount(dirB, "/b", FileMountMode.ReadWrite)); + var mounts = new[] { mountA, mountB }; + var pre = FileMountHelper.SnapshotWritableMounts(mounts); + + File.WriteAllBytes(Path.Combine(dirA, "a.bin"), new byte[500]); + File.WriteAllBytes(Path.Combine(dirB, "b.bin"), new byte[500]); + + // Total capture limit set so the first file fits and the second triggers + // the cross-mount total cap. + var limits = new ProcessExecutionLimits { MaxTotalCapturedFileBytes = 600 }; + var captured = FileMountHelper.CaptureWrittenFiles(mounts, pre, limits); + + Assert.Single(captured.OfType()); + var text = Assert.Single(captured.OfType()); + Assert.Contains("total capture limit", text.Text); + } + finally + { + Directory.Delete(dirA, recursive: true); + Directory.Delete(dirB, recursive: true); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/FileMountTests.cs b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/FileMountTests.cs new file mode 100644 index 00000000000..3a4d1dc0acd --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/FileMountTests.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests; + +public sealed class FileMountTests +{ + [Fact] + public void Constructor_AssignsProperties() + { + var tempDir = System.IO.Directory.CreateTempSubdirectory("filemount-test-").FullName; + try + { + var mount = new FileMount(tempDir, "/app/data", FileMountMode.ReadWrite, writeBytesLimit: 1024); + + Assert.Equal(tempDir, mount.HostPath); + Assert.Equal("/app/data", mount.MountPath); + Assert.Equal(FileMountMode.ReadWrite, mount.Mode); + Assert.Equal(1024L, mount.WriteBytesLimit); + } + finally + { + System.IO.Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public void Constructor_DefaultsAreReadWriteWithNoLimit() + { + var tempDir = System.IO.Directory.CreateTempSubdirectory("filemount-test-").FullName; + try + { + var mount = new FileMount(tempDir, "/app/data"); + Assert.Equal(FileMountMode.ReadWrite, mount.Mode); + Assert.Null(mount.WriteBytesLimit); + } + finally + { + System.IO.Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public void Constructor_RequiresPaths() + { + Assert.Throws(() => new FileMount("", "/app/data")); + Assert.Throws(() => new FileMount("/host/data", "")); + _ = Assert.Throws(() => new FileMount(null!, "/app/data")); + _ = Assert.Throws(() => new FileMount("/host/data", null!)); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/InstructionBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/InstructionBuilderTests.cs new file mode 100644 index 00000000000..3795ab39aee --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/InstructionBuilderTests.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Agents.AI.LocalCodeAct.Internal; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests; + +public sealed class InstructionBuilderTests +{ + [Fact] + public void BuildContextInstructions_ContainsExecuteCodeName() + { + var instructions = InstructionBuilder.BuildContextInstructions(); + Assert.Contains("execute_code", instructions); + } + + [Fact] + public void BuildExecuteCodeDescription_MentionsToolsWhenProvided() + { + var tools = new List { new TestTool("get_weather", "Returns current weather.") }; + var description = InstructionBuilder.BuildExecuteCodeDescription(tools, new List()); + + Assert.Contains("get_weather", description); + } + + [Fact] + public void BuildExecuteCodeDescription_MentionsMountsWhenProvided() + { + var mounts = new List { new("/host/data", "/app/data") }; + var description = InstructionBuilder.BuildExecuteCodeDescription(new List(), mounts); + + Assert.Contains("/app/data", description); + } + + private sealed class TestTool : AIFunction + { + public TestTool(string name, string description) + { + this.Name = name; + this.Description = description; + } + + public override string Name { get; } + + public override string Description { get; } + + protected override System.Threading.Tasks.ValueTask InvokeCoreAsync(AIFunctionArguments arguments, System.Threading.CancellationToken cancellationToken) => + new((object?)null); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalCodeActProviderOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalCodeActProviderOptionsTests.cs new file mode 100644 index 00000000000..d995638fc74 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalCodeActProviderOptionsTests.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests; + +public sealed class LocalCodeActProviderOptionsTests +{ + [Fact] + public void ProviderConstructor_RequiresPythonExecutablePath() + { + Assert.Throws(() => new LocalCodeActProvider("")); + Assert.Throws(() => new LocalCodeActProvider(" ")); + _ = Assert.Throws(() => new LocalCodeActProvider(null!)); + } + + [Fact] + public void ExecuteCodeFunctionConstructor_RequiresPythonExecutablePath() + { + Assert.Throws(() => new LocalExecuteCodeFunction("")); + Assert.Throws(() => new LocalExecuteCodeFunction(" ")); + _ = Assert.Throws(() => new LocalExecuteCodeFunction(null!)); + } + + [Fact] + public void ValidationDisabled_DefaultsToFalse() + { + var options = new LocalCodeActProviderOptions(); + Assert.False(options.ValidationDisabled); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalCodeActProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalCodeActProviderTests.cs new file mode 100644 index 00000000000..d6435f314ec --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalCodeActProviderTests.cs @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests; + +public sealed class LocalCodeActProviderTests +{ + private static readonly AIAgent s_mockAgent = new Mock().Object; + + private static AIContextProvider.InvokingContext NewInvokingContext() => + new(s_mockAgent, session: null, new AIContext()); + + private static LocalCodeActProviderOptions Options() => + new() + { + ValidationDisabled = true, // No subprocess will be launched in these tests + }; + + [Fact] + public async Task ProvideAIContextAsync_ReturnsExecuteCodeToolAndInstructionsAsync() + { + using var provider = new LocalCodeActProvider("/usr/bin/python3", Options()); + + var context = await provider.InvokingAsync(NewInvokingContext()); + + Assert.NotNull(context); + Assert.NotNull(context!.Tools); + var tools = context.Tools!.ToList(); + Assert.Single(tools); + var function = Assert.IsAssignableFrom(tools[0]); + Assert.Equal("execute_code", function.Name); + Assert.False(string.IsNullOrWhiteSpace(context.Instructions)); + } + + [Fact] + public void AddAndRemoveTools_RoundTrips() + { + using var provider = new LocalCodeActProvider("/usr/bin/python3", Options()); + + var tool = new TestTool("ping"); + provider.AddTools(tool); + + Assert.Contains(provider.GetTools(), t => t.Name == "ping"); + + provider.RemoveTools("ping"); + Assert.DoesNotContain(provider.GetTools(), t => t.Name == "ping"); + } + + [Fact] + public void AddAndRemoveFileMounts_RoundTrips() + { + using var provider = new LocalCodeActProvider("/usr/bin/python3", Options()); + + var tempDir = System.IO.Directory.CreateTempSubdirectory("localcodeact-test-").FullName; + try + { + var mount = new FileMount(tempDir, "/app/data"); + provider.AddFileMounts(mount); + + Assert.Contains(provider.GetFileMounts(), m => m.MountPath == "/app/data"); + + provider.RemoveFileMounts("/app/data"); + Assert.DoesNotContain(provider.GetFileMounts(), m => m.MountPath == "/app/data"); + } + finally + { + System.IO.Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public void ClearMethods_EmptyState() + { + using var provider = new LocalCodeActProvider("/usr/bin/python3", Options()); + + var tempDir1 = System.IO.Directory.CreateTempSubdirectory("localcodeact-test-").FullName; + var tempDir2 = System.IO.Directory.CreateTempSubdirectory("localcodeact-test-").FullName; + try + { + provider.AddTools(new TestTool("a"), new TestTool("b")); + provider.AddFileMounts(new FileMount(tempDir1, "/m/1"), new FileMount(tempDir2, "/m/2")); + + provider.ClearTools(); + provider.ClearFileMounts(); + + Assert.Empty(provider.GetTools()); + Assert.Empty(provider.GetFileMounts()); + } + finally + { + System.IO.Directory.Delete(tempDir1, recursive: true); + System.IO.Directory.Delete(tempDir2, recursive: true); + } + } + + private sealed class TestTool : AIFunction + { + public TestTool(string name) + { + this.Name = name; + } + + public override string Name { get; } + + public override string Description => "test tool"; + + protected override ValueTask InvokeCoreAsync(AIFunctionArguments arguments, System.Threading.CancellationToken cancellationToken) => + new((object?)null); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionIntegrationTests.cs new file mode 100644 index 00000000000..f1e6f6e8b99 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionIntegrationTests.cs @@ -0,0 +1,279 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests; + +/// +/// Integration tests that launch a real Python subprocess. Skipped automatically when +/// no Python interpreter is discoverable on PATH. +/// +public sealed class LocalExecuteCodeFunctionIntegrationTests +{ + private static readonly string? s_python = FindPython(); + + private static void SkipIfNoPython() + { + if (s_python is null) + { + Assert.Skip("No Python interpreter found on PATH; skipping integration test."); + } + } + + [Fact] + public async Task ExecuteCode_PrintsAndReturnsResultAsync() + { + SkipIfNoPython(); + + var function = new LocalExecuteCodeFunction(s_python!); + + var args = new AIFunctionArguments + { + ["code"] = "print('hello world')\n1 + 2", + }; + + var result = await function.InvokeAsync(args, CancellationToken.None); + + Assert.NotNull(result); + var combined = GetResultText(result); + Assert.Contains("hello world", combined); + Assert.Contains("3", combined); + } + + [Fact] + public async Task ExecuteCode_ValidationBlocksDisallowedImportAsync() + { + SkipIfNoPython(); + + var function = new LocalExecuteCodeFunction(s_python!); + + var args = new AIFunctionArguments + { + ["code"] = "import subprocess", + }; + + await Assert.ThrowsAsync(async () => + await function.InvokeAsync(args, CancellationToken.None)); + } + + [Fact] + public async Task ExecuteCode_CapturesFilesInWritableMountAsync() + { + SkipIfNoPython(); + + var hostDir = Directory.CreateTempSubdirectory("localcodeact-mount-").FullName; + try + { + var options = new LocalCodeActProviderOptions + { + FileMounts = new[] + { + new FileMount(hostDir, "/output", FileMountMode.ReadWrite), + }, + }; + + var function = new LocalExecuteCodeFunction(s_python!, options); + + // Use os.path.join via the actual host path - the mount path is descriptive metadata only + var escapedPath = hostDir.Replace("\\", "\\\\", StringComparison.Ordinal); + var args = new AIFunctionArguments + { + ["code"] = $"from pathlib import Path\nPath(r'{escapedPath}/out.txt').write_text('captured')", + }; + + var result = await function.InvokeAsync(args, CancellationToken.None); + + Assert.NotNull(result); + AssertResultContainsDataContent(result, "/output/out.txt"); + } + finally + { + Directory.Delete(hostDir, recursive: true); + } + } + + [Fact] + public async Task ExecuteCode_UnknownToolNameReturnsErrorToGeneratedCodeAsync() + { + SkipIfNoPython(); + + // No tools are registered, so any call_tool from generated code resolves to + // the "Unknown tool" branch in ProcessBridge.HandleToolCallAsync. + var function = new LocalExecuteCodeFunction(s_python!); + + var args = new AIFunctionArguments + { + ["code"] = @" +try: + await call_tool('definitely_not_registered', x=1) + print('NO_ERROR') +except Exception as exc: + print('GOT_ERROR:' + type(exc).__name__ + ':' + str(exc)) +", + }; + + var result = await function.InvokeAsync(args, CancellationToken.None); + var combined = GetResultText(result); + Assert.Contains("GOT_ERROR", combined); + Assert.Contains("definitely_not_registered", combined); + Assert.DoesNotContain("NO_ERROR", combined); + } + + [Fact] + public async Task ExecuteCode_ToolThrowingExceptionPropagatesToGeneratedCodeAsync() + { + SkipIfNoPython(); + + // Tool that always throws — exercises ProcessBridge.HandleToolCallAsync exception path + // which sends a structured error response back to the subprocess. + Func faulty = message => throw new InvalidOperationException("intentional: " + message); + var faultyTool = AIFunctionFactory.Create(faulty, name: "faulty"); + + var options = new LocalCodeActProviderOptions + { + Tools = new[] { faultyTool }, + }; + var function = new LocalExecuteCodeFunction(s_python!, options); + + var args = new AIFunctionArguments + { + ["code"] = @" +try: + await call_tool('faulty', message='boom') + print('NO_ERROR') +except Exception as exc: + print('GOT_ERROR:' + type(exc).__name__ + ':' + str(exc)) +", + }; + var result = await function.InvokeAsync(args, CancellationToken.None); + var combined = GetResultText(result); + Assert.Contains("GOT_ERROR", combined); + Assert.Contains("InvalidOperationException", combined); + Assert.Contains("intentional: boom", combined); + } + + [Fact] + public async Task Validator_TimeoutKillsProcessAndThrowsAsync() + { + SkipIfNoPython(); + + // Custom validator script that ignores stdin and blocks forever so the + // parent timeout fires and exercises the timeout catch in CodeValidator. + var tempDir = Directory.CreateTempSubdirectory("localcodeact-vtimeout-").FullName; + try + { + var scriptPath = Path.Combine(tempDir, "hang_validator.py"); + File.WriteAllText(scriptPath, "import time\nwhile True:\n time.sleep(60)\n"); + + var validator = new Internal.CodeValidator( + s_python!, + scriptPath, + TimeSpan.FromSeconds(1), + allowedImports: null, + blockedImports: null, + allowedBuiltins: null, + blockedBuiltins: null); + + var ex = await Assert.ThrowsAsync( + async () => await validator.ValidateAsync("print('x')", CancellationToken.None)); + Assert.Contains("exceeded", ex.Message); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + private static string GetResultText(object? result) => + result switch + { + IEnumerable contents => string.Join("\n", contents.OfType().Select(t => t.Text)), + JsonElement element => element.GetRawText(), + _ => result?.ToString() ?? string.Empty, + }; + + private static void AssertResultContainsDataContent(object? result, string expectedPath) + { + if (result is IEnumerable contents) + { + Assert.Contains(contents, c => c is DataContent); + return; + } + + var json = Assert.IsType(result).GetRawText(); + Assert.Contains(expectedPath, json); + } + + private static string? FindPython() + { + var configured = Environment.GetEnvironmentVariable("LOCAL_CODEACT_PYTHON"); + if (!string.IsNullOrWhiteSpace(configured) && IsUsablePython(configured)) + { + return configured; + } + + var executableNames = OperatingSystem.IsWindows() + ? new[] { "python3.exe", "python.exe" } + : new[] { "python3", "python" }; + + foreach (var name in executableNames) + { + var path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + foreach (var dir in path.Split(Path.PathSeparator)) + { + if (string.IsNullOrWhiteSpace(dir)) + { + continue; + } + + var candidate = Path.Combine(dir, name); + if (File.Exists(candidate) && IsUsablePython(candidate)) + { + return candidate; + } + } + } + + return null; + } + + private static bool IsUsablePython(string candidate) + { + try + { + using var process = Process.Start(new ProcessStartInfo + { + FileName = candidate, + ArgumentList = { "--version" }, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }); + if (process is null) + { + return false; + } + + if (!process.WaitForExit(milliseconds: 5000)) + { + process.Kill(entireProcessTree: true); + return false; + } + + return process.ExitCode == 0; + } + catch + { + return false; + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/Microsoft.Agents.AI.LocalCodeAct.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/Microsoft.Agents.AI.LocalCodeAct.UnitTests.csproj new file mode 100644 index 00000000000..da3a1727bc1 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/Microsoft.Agents.AI.LocalCodeAct.UnitTests.csproj @@ -0,0 +1,11 @@ + + + + $(TargetFrameworksCore) + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/ProcessExecutionLimitsTests.cs b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/ProcessExecutionLimitsTests.cs new file mode 100644 index 00000000000..6f98b2c79d9 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/ProcessExecutionLimitsTests.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests; + +public sealed class ProcessExecutionLimitsTests +{ + [Fact] + public void Defaults_AreReasonable() + { + var limits = new ProcessExecutionLimits(); + + Assert.True(limits.TimeoutSeconds > 0); + Assert.True(limits.MaxStdoutBytes > 0); + Assert.True(limits.MaxStderrBytes > 0); + Assert.True(limits.ValidationTimeoutSeconds > 0); + Assert.True(limits.MaxResultBytes > 0); + } + + [Fact] + public void Properties_AreMutable() + { + var limits = new ProcessExecutionLimits + { + TimeoutSeconds = 60, + MaxStdoutBytes = 1024, + MaxStderrBytes = 512, + ValidationTimeoutSeconds = 5, + MaxResultBytes = 2048, + }; + + Assert.Equal(60, limits.TimeoutSeconds); + Assert.Equal(1024, limits.MaxStdoutBytes); + Assert.Equal(512, limits.MaxStderrBytes); + Assert.Equal(5, limits.ValidationTimeoutSeconds); + Assert.Equal(2048, limits.MaxResultBytes); + } +}