From 1450b5bee6a34195354e652cfaa29e60b3f11095 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 27 May 2026 09:33:05 +0200 Subject: [PATCH 01/15] feat(dotnet): Add LocalCodeAct package scaffold Create Microsoft.Agents.AI.LocalCodeAct package with: - Project file with embedded Python resources - ExecutionMode enum (Subprocess only) - ProcessExecutionLimits record - FileMount record and FileMountMode enum - README.md documentation - Embedded Python runner and validator scripts This is the .NET equivalent of the Python agent-framework-local-codeact package. Next: Implement process bridge and tool integration. --- .../ExecutionMode.cs | 17 ++ .../FileMount.cs | 53 ++++++ .../Microsoft.Agents.AI.LocalCodeAct.csproj | 42 ++++ .../ProcessExecutionLimits.cs | 40 ++++ .../README.md | 180 ++++++++++++++++++ 5 files changed, 332 insertions(+) create mode 100644 dotnet/src/Microsoft.Agents.AI.LocalCodeAct/ExecutionMode.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.LocalCodeAct/FileMount.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Microsoft.Agents.AI.LocalCodeAct.csproj create mode 100644 dotnet/src/Microsoft.Agents.AI.LocalCodeAct/ProcessExecutionLimits.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.LocalCodeAct/README.md diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/ExecutionMode.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/ExecutionMode.cs new file mode 100644 index 0000000000..94b5ef1f7f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/ExecutionMode.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Microsoft.Agents.AI.LocalCodeAct; + +/// +/// Defines how generated Python code is executed. +/// +public enum ExecutionMode +{ + /// + /// Execute Python code in a subprocess. This is the default and only mode in .NET. + /// Provides process-level isolation but is NOT a security sandbox on its own. + /// Real sandboxing must come from external container/VM isolation. + /// + Subprocess, +} 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 0000000000..9c1104082b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/FileMount.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Microsoft.Agents.AI.LocalCodeAct; + +/// +/// Defines a file or directory mounted into the code execution environment. +/// +/// +/// Mounts expose host paths directly to code running in a subprocess. The MountPath +/// parameter is metadata only; code accesses the HostPath directly. Real isolation +/// must come from external container/VM sandboxing. +/// +public sealed record FileMount +{ + /// + /// Gets or sets the path on the host filesystem to expose to the subprocess. + /// + public required string HostPath { get; init; } + + /// + /// Gets or sets the logical path name for documentation. Not enforced at runtime. + /// + public required string MountPath { get; init; } + + /// + /// Gets or sets the access mode for the mount. Default is ReadWrite. + /// + public FileMountMode Mode { get; init; } = FileMountMode.ReadWrite; + + /// + /// Gets or sets the maximum bytes that can be written to a single file in this mount (read-write only). + /// If null, uses the tool's MaxFileBytesPerFile limit. + /// + public int? WriteBytesLimit { get; init; } +} + +/// +/// File mount access mode. +/// +public enum FileMountMode +{ + /// + /// Read-only access. Files cannot be created or modified. + /// + ReadOnly, + + /// + /// Read-write access. Files can be created and modified. + /// Output files are captured and returned as data content. + /// + ReadWrite, +} 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 0000000000..944e7628a8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Microsoft.Agents.AI.LocalCodeAct.csproj @@ -0,0 +1,42 @@ + + + + preview + net10.0;net9.0;net8.0 + + + + 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 0000000000..93732378e7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/ProcessExecutionLimits.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +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 (e.g., Foundry hosted agents, Docker, Azure Container Instances, etc.). +/// +public sealed record ProcessExecutionLimits +{ + /// + /// Maximum execution time in seconds. Default is 30 seconds. + /// + public int TimeoutSeconds { get; init; } = 30; + + /// + /// Maximum bytes of stdout captured. Default is 10MB. + /// + public int MaxStdoutBytes { get; init; } = 10 * 1024 * 1024; + + /// + /// Maximum bytes of stderr captured. Default is 10MB. + /// + public int MaxStderrBytes { get; init; } = 10 * 1024 * 1024; + + /// + /// Maximum bytes written per file in read-write mounts. Default is 1MB. + /// + public int MaxFileBytesPerFile { get; init; } = 1024 * 1024; + + /// + /// Maximum total bytes written across all read-write mounts. Default is 10MB. + /// + public int MaxFileBytesTotal { get; init; } = 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 0000000000..16c52629ff --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/README.md @@ -0,0 +1,180 @@ +# 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 agent = new Agent +{ + Client = ..., + Instructions = "Use execute_code for Python control flow when it helps.", + ContextProviders = + [ + new LocalCodeActProvider + { + PythonExecutablePath = "/usr/bin/python3", // Required in .NET + ExecutionLimits = new ProcessExecutionLimits { TimeoutSeconds = 5 }, + } + ], +}; +``` + +## What the Package Controls + +- **AST validation**: Validates generated code against allow-lists (allowed imports, + built ins, and operations) before execution. +- **Subprocess execution**: Runs generated code in a child Python process. +- **Explicit Python path**: Requires `PythonExecutablePath` (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 code-size, timeout, stdout, stderr, and + result-size limits. +- **Tool gating**: Allows only provider-owned host tools to be called from + generated code. +- **File capture**: Captures new or modified files under configured writable + mounts while skipping symlinks. + +These are defense-in-depth controls, not a containment boundary. The AST +validator blocks common dangerous operations (`eval`, `exec`, `import subprocess`, +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 on the provider. Generated code calls them: + +```csharp +Task AddAsync(int a, int b) => Task.FromResult(a + b); + +var provider = new LocalCodeActProvider +{ + PythonExecutablePath = "/usr/bin/python3", + Tools = [Tool.From(AddAsync)], +}; +``` + +Inside `execute_code`: + +```python +result = await add(a=2, b=3) +print(result) +``` + +## Code Validation + +By default, the package validates Python code against allow-lists before execution: + +- **Allowed imports**: `math`, `random`, `json`, `datetime`, `pathlib`, etc. +- **Blocked imports**: `os`, `subprocess`, `sys`, `importlib`, network, etc. +- **Allowed builtins**: `print`, `len`, `str`, type constructors, etc. +- **Blocked builtins**: `eval`, `exec`, `compile`, `__import__`, `open`, `getattr`, etc. + +See the Python implementation for the full default lists. + +### Customizing Validation + +Override the default lists: + +```csharp +var provider = new LocalCodeActProvider +{ + Python ExecutablePath = "/usr/bin/python3", + AllowedImports = ["math", "datetime", "mymodule"], + BlockedImports = ["os", "subprocess", "sys"], + AllowedBuiltins = ["print", "len", "str", "int"], + BlockedBuiltins = ["eval", "exec", "compile"], +}; +``` + +Custom lists **replace** the defaults (not augment). + +## File Mounts + +Mount host directories or files: + +```csharp +var provider = new LocalCodeActProvider +{ + PythonExecutablePath = "/usr/bin/python3", + FileMounts = + [ + new FileMount + { + HostPath = "/tmp/data", + MountPath = "/input", + Mode = FileMountMode.ReadOnly, + }, + new FileMount + { + HostPath = "/tmp/output", + MountPath = "/output", + Mode = FileMountMode.ReadWrite, + }, + ], +}; +``` + +Generated code accesses mounts via `HostPath`. `MountPath` is metadata only. +Read-write mounts are scanned for new/modified files after execution, and those +files are returned as data content. + +## Environment Variables + +Pass environment variables explicitly: + +```csharp +var provider = new LocalCodeActProvider +{ + PythonExecutablePath = "/usr/bin/python3", + Environment = new Dictionary + { + ["API_KEY"] = "...", + ["LOG_LEVEL"] = "INFO", + }, +}; +``` + +The subprocess does NOT inherit the host environment by default. + +## Execution Modes + +The .NET implementation only supports `Subprocess` mode (Python execution in a +child process). There is no "unsafe in-process" mode in .NET. + +## License + +MIT From c38e1f67b7531797328fbd1ef65768f53fdd8922 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 27 May 2026 09:33:20 +0200 Subject: [PATCH 02/15] feat(dotnet): Add embedded Python runner and validator Copy Python runner and validator scripts from the Python implementation as embedded resources for the .NET package. --- .../Resources/runner.py | 210 ++++++++ .../Resources/validator.py | 456 ++++++++++++++++++ 2 files changed, 666 insertions(+) create mode 100644 dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/runner.py create mode 100644 dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/validator.py 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 0000000000..b1a644e584 --- /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 - 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 0000000000..b3fa811753 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/validator.py @@ -0,0 +1,456 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""AST validation for generated Python code.""" + +from __future__ import annotations + +import ast +from typing import Any + +# 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 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, + ) -> 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 + + 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)") + self.generic_visit(node) + + def visit_Call(self, node: ast.Call) -> None: + """Validate function calls. + + Note: We only validate calls to known builtins against the block-list. + Calls to user-defined functions and registered tools are allowed (validated at runtime). + The allowed_builtins parameter exists for customization but does not enforce + an allow-list by default to permit user code and tools. + """ + # Check for blocked builtins + 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") + # Note: We don't enforce allowed_builtins for Names to allow user-defined + # functions and registered tools. Custom blocked_builtins can restrict specific names. + + # 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.""" + # Check for dangerous os module operations + if isinstance(node.value, ast.Name) and node.value.id == "os": + # Block dangerous os operations + dangerous_os_attrs = { + "system", + "exec", + "execl", + "execle", + "execlp", + "execlpe", + "execv", + "execve", + "execvp", + "execvpe", + "spawn", + "spawnl", + "spawnle", + "spawnlp", + "spawnlpe", + "spawnv", + "spawnve", + "spawnvp", + "spawnvpe", + "popen", + "popen2", + "popen3", + "popen4", + "fork", + "forkpty", + "kill", + "killpg", + "abort", + "chdir", + "fchdir", + "chroot", + "chmod", + "chown", + "lchown", + "fchmod", + "fchown", + "remove", + "unlink", + "rmdir", + "removedirs", + "rename", + "renames", + "replace", + "link", + "symlink", + "mkdir", + "makedirs", + "access", + "putenv", + "unsetenv", + } + if node.attr in dangerous_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, +) -> 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). + + 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, + ) + validator.validate(code) From 2b739cb430f82b8534b4c129e4932670a6b3aafa Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 27 May 2026 09:51:32 +0200 Subject: [PATCH 03/15] feat(dotnet): Add CodeValidator wrapper Implement CodeValidator.cs that: - Extracts embedded Python validator script to temp file - Invokes Python validator with JSON request - Passes custom allow/block lists - Throws CodeValidationException on failures - Cleans up temp files Uses the embedded Resources/validator.py for AST validation. --- .../CodeValidator.cs | 163 ++++++++++ .../ProcessBridge.cs | 299 ++++++++++++++++++ 2 files changed, 462 insertions(+) create mode 100644 dotnet/src/Microsoft.Agents.AI.LocalCodeAct/CodeValidator.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.LocalCodeAct/ProcessBridge.cs diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/CodeValidator.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/CodeValidator.cs new file mode 100644 index 0000000000..fc96d0179a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/CodeValidator.cs @@ -0,0 +1,163 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Reflection; +using System.Text; +using System.Text.Json; + +namespace Microsoft.Agents.AI.LocalCodeAct; + +/// +/// Validates Python code using the embedded Python AST validator. +/// +internal sealed class CodeValidator +{ + private readonly string _pythonExecutable; + private readonly string? _validatorScript; + private readonly string[]? _allowedImports; + private readonly string[]? _blockedImports; + private readonly string[]? _allowedBuiltins; + private readonly string[]? _blockedBuiltins; + + public CodeValidator( + string pythonExecutable, + string? validatorScript = null, + string[]? allowedImports = null, + string[]? blockedImports = null, + string[]? allowedBuiltins = null, + string[]? blockedBuiltins = null) + { + _pythonExecutable = pythonExecutable; + _validatorScript = validatorScript; + _allowedImports = allowedImports; + _blockedImports = blockedImports; + _allowedBuiltins = allowedBuiltins; + _blockedBuiltins = blockedBuiltins; + } + + /// + /// Validates Python code against AST allow-lists. + /// + /// The Python code to validate. + /// Cancellation token. + /// Thrown if validation fails. + public async Task ValidateAsync(string code, CancellationToken cancellationToken = default) + { + // Extract embedded validator script to temp file if not provided + string validatorPath = _validatorScript ?? await ExtractValidatorScriptAsync(cancellationToken).ConfigureAwait(false); + + try + { + // Build validation request + var request = new Dictionary + { + ["code"] = code, + }; + + if (_allowedImports != null) + { + request["allowed_imports"] = _allowedImports; + } + + if (_blockedImports != null) + { + request["blocked_imports"] = _blockedImports; + } + + if (_allowedBuiltins != null) + { + request["allowed_builtins"] = _allowedBuiltins; + } + + if (_blockedBuiltins != null) + { + request["blocked_builtins"] = _blockedBuiltins; + } + + var requestJson = JsonSerializer.Serialize(request); + + // Run validator + var startInfo = new ProcessStartInfo + { + FileName = _pythonExecutable, + Arguments = $"-I \"{validatorPath}\"", + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + + using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start Python validator."); + + await process.StandardInput.WriteLineAsync(requestJson.AsMemory(), cancellationToken).ConfigureAwait(false); + await process.StandardInput.FlushAsync(cancellationToken).ConfigureAwait(false); + process.StandardInput.Close(); + + var output = await process.StandardOutput.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + var stderr = await process.StandardError.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + + if (process.ExitCode != 0) + { + // Validation failed + var response = JsonSerializer.Deserialize>(output); + var errors = response?.TryGetValue("errors", out var e) == true ? e?.ToString() : output; + throw new CodeValidationException($"Code validation failed: {errors}"); + } + } + finally + { + // Clean up temp validator script if we created it + if (_validatorScript == null && File.Exists(validatorPath)) + { + try + { + File.Delete(validatorPath); + } + catch + { + // Ignore cleanup errors + } + } + } + } + + private static async Task ExtractValidatorScriptAsync(CancellationToken cancellationToken) + { + var assembly = Assembly.GetExecutingAssembly(); + var resourceName = "Microsoft.Agents.AI.LocalCodeAct.Resources.validator.py"; + + await using var stream = assembly.GetManifestResourceStream(resourceName) + ?? throw new InvalidOperationException($"Embedded resource '{resourceName}' not found."); + + var tempPath = Path.Combine(Path.GetTempPath(), $"validator_{Guid.NewGuid():N}.py"); + + await using var fileStream = File.Create(tempPath); + await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); + + return tempPath; + } +} + +/// +/// Exception thrown when Python code validation fails. +/// +public sealed class CodeValidationException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public CodeValidationException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of the class. + /// + public CodeValidationException(string message, Exception innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/ProcessBridge.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/ProcessBridge.cs new file mode 100644 index 0000000000..874c1c7f04 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/ProcessBridge.cs @@ -0,0 +1,299 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using Microsoft.Agents.AI.Abstractions; + +namespace Microsoft.Agents.AI.LocalCodeAct; + +/// +/// Parent-side bridge for subprocess Python execution with JSON-lines IPC and host-tool dispatch. +/// +internal sealed class ProcessBridge +{ + private readonly Dictionary _tools; + private readonly ProcessExecutionLimits _limits; + private readonly IReadOnlyDictionary _environment; + private readonly string? _workingDirectory; + private readonly string _pythonExecutable; + private readonly string? _runnerScript; + + public ProcessBridge( + IEnumerable tools, + ProcessExecutionLimits limits, + IReadOnlyDictionary environment, + string? workingDirectory, + string pythonExecutable, + string? runnerScript) + { + _tools = tools.ToDictionary(t => t.Metadata.Name, t => t); + _limits = limits; + _environment = environment; + _workingDirectory = workingDirectory; + _pythonExecutable = pythonExecutable; + _runnerScript = runnerScript; + } + + /// + /// Runs generated Python code in a child process with timeout and tool call handling. + /// + public async Task> RunAsync(string code, CancellationToken cancellationToken = default) + { + var startInfo = new ProcessStartInfo + { + FileName = _pythonExecutable, + Arguments = _runnerScript == null ? "-I -m agent_framework_local_codeact._runner" : $"-I \"{_runnerScript}\"", + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + WorkingDirectory = _workingDirectory, + }; + + // Set environment variables + startInfo.Environment.Clear(); + foreach (var (key, value) in _environment) + { + startInfo.Environment[key] = value; + } + + // Add Windows-specific environment variables if needed + if (OperatingSystem.IsWindows()) + { + foreach (var key in new[] { "SYSTEMROOT", "COMSPEC", "PATHEXT" }) + { + var value = Environment.GetEnvironmentVariable(key); + if (value != null && !startInfo.Environment.ContainsKey(key)) + { + startInfo.Environment[key] = value; + } + } + } + + using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start Python process."); + + try + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(_limits.TimeoutSeconds)); + + return await CommunicateAsync(process, code, cts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + // Timeout from our CTS + await StopProcessAsync(process).ConfigureAwait(false); + throw new TimeoutException($"Generated code exceeded {_limits.TimeoutSeconds} seconds."); + } + catch + { + await StopProcessAsync(process).ConfigureAwait(false); + throw; + } + } + + private async Task> CommunicateAsync( + Process process, + string code, + CancellationToken cancellationToken) + { + // Send initial request to child process + var request = new Dictionary + { + ["code"] = code, + ["tool_names"] = _tools.Keys.ToList(), + ["max_stdout_bytes"] = _limits.MaxStdoutBytes, + ["max_stderr_bytes"] = _limits.MaxStderrBytes, + }; + + var requestJson = JsonSerializer.Serialize(request, new JsonSerializerOptions { WriteIndented = false }); + await process.StandardInput.WriteLineAsync(requestJson.AsMemory(), cancellationToken).ConfigureAwait(false); + await process.StandardInput.FlushAsync(cancellationToken).ConfigureAwait(false); + + // Process messages from child + while (true) + { + var line = await process.StandardOutput.ReadLineAsync(cancellationToken).ConfigureAwait(false); + if (line == null) + { + var stderr = await ReadStderrAsync(process, cancellationToken).ConfigureAwait(false); + throw new InvalidOperationException($"Local CodeAct subprocess exited without a result. stderr: {stderr}"); + } + + Dictionary? message; + try + { + message = JsonSerializer.Deserialize>(line); + } + catch (JsonException ex) + { + throw new InvalidOperationException($"Failed to parse JSON from subprocess: {line}", ex); + } + + if (message == null) + { + continue; + } + + var messageType = message.TryGetValue("type", out var typeValue) ? typeValue?.ToString() : null; + + if (messageType == "complete") + { + // Execution complete + if (!message.TryGetValue("result", out var resultObj)) + { + throw new InvalidOperationException("Complete message missing 'result' field."); + } + + var result = resultObj as Dictionary ?? new Dictionary(); + CheckResultSize(result); + return result; + } + + if (messageType == "error") + { + var details = message.TryGetValue("traceback", out var tb) ? tb?.ToString() : + message.TryGetValue("message", out var msg) ? msg?.ToString() : "Unknown execution error."; + await StopProcessAsync(process).ConfigureAwait(false); + throw new InvalidOperationException(details); + } + + if (messageType == "tool_call") + { + // Handle tool call + await HandleToolCallAsync(process, message, cancellationToken).ConfigureAwait(false); + } + } + } + + private async Task HandleToolCallAsync( + Process process, + Dictionary message, + CancellationToken cancellationToken) + { + var callId = message.TryGetValue("call_id", out var cid) ? Convert.ToInt32(cid) : 0; + var name = message.TryGetValue("name", out var n) ? n?.ToString() : null; + var kwargsObj = message.TryGetValue("kwargs", out var kw) ? kw : null; + + if (string.IsNullOrEmpty(name)) + { + await SendToolResponseAsync(process, callId, false, null, "MissingToolName", "Tool call missing 'name'.", cancellationToken).ConfigureAwait(false); + return; + } + + if (!_tools.TryGetValue(name, out var tool)) + { + await SendToolResponseAsync(process, callId, false, null, "UnknownTool", $"Unknown tool: {name}", cancellationToken).ConfigureAwait(false); + return; + } + + try + { + // Convert kwargs to JSON element for tool invocation + var kwargsJson = JsonSerializer.Serialize(kwargsObj); + var kwargsElement = JsonSerializer.Deserialize(kwargsJson); + + // Invoke the tool + var result = await tool.InvokeAsync(kwargsElement, cancellationToken).ConfigureAwait(false); + var safeResult = MakeJsonSafe(result); + + await SendToolResponseAsync(process, callId, true, safeResult, null, null, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + await SendToolResponseAsync(process, callId, false, null, ex.GetType().Name, ex.Message, cancellationToken).ConfigureAwait(false); + } + } + + private async Task SendToolResponseAsync( + Process process, + int callId, + bool ok, + object? result, + string? excType, + string? message, + CancellationToken cancellationToken) + { + var response = new Dictionary + { + ["call_id"] = callId, + ["ok"] = ok, + }; + + if (ok) + { + response["result"] = result; + } + else + { + response["exc_type"] = excType; + response["message"] = message; + } + + var responseJson = JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = false }); + await process.StandardInput.WriteLineAsync(responseJson.AsMemory(), cancellationToken).ConfigureAwait(false); + await process.StandardInput.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task ReadStderrAsync(Process process, CancellationToken cancellationToken) + { + var stderr = new StringBuilder(); + var buffer = new char[4096]; + int bytesRead = 0; + int totalBytes = 0; + + while (totalBytes < _limits.MaxStderrBytes && + (bytesRead = await process.StandardError.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0) + { + stderr.Append(buffer, 0, Math.Min(bytesRead, _limits.MaxStderrBytes - totalBytes)); + totalBytes += bytesRead; + } + + return stderr.ToString(); + } + + private static async Task StopProcessAsync(Process process) + { + try + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync().ConfigureAwait(false); + } + catch + { + // Ignore errors during cleanup + } + } + + private void CheckResultSize(Dictionary result) + { + var encoded = JsonSerializer.SerializeToUtf8Bytes(result, new JsonSerializerOptions { WriteIndented = false }); + if (encoded.Length > _limits.MaxStdoutBytes) // Reusing MaxStdoutBytes as max result bytes + { + throw new InvalidOperationException("Generated code result exceeded max size."); + } + } + + private static object? MakeJsonSafe(object? value) + { + if (value == null) + { + return null; + } + + try + { + // Test if it's JSON-serializable + _ = JsonSerializer.Serialize(value); + return value; + } + catch + { + // Convert to string representation if not serializable + return value.ToString(); + } + } +} From a97aa307f465c43d1a8e67676a1c3f65f5e74d29 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 27 May 2026 09:52:18 +0200 Subject: [PATCH 04/15] feat(dotnet): Add LocalExecuteCodeFunction Implement LocalExecuteCodeFunction as AIFunction: - Accepts Python executable path (required) - Registers host tools for code to call - Validates code via CodeValidator if custom lists provided - Executes via ProcessBridge - Converts result dict to ChatMessage list - Builds dynamic description including available tools Matches Python LocalExecuteCodeTool functionality. --- .../LocalExecuteCodeFunction.cs | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalExecuteCodeFunction.cs 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 0000000000..f5a3a18895 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalExecuteCodeFunction.cs @@ -0,0 +1,186 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.LocalCodeAct; + +/// +/// Standalone execute_code that runs Python code locally. +/// +/// +/// This function executes LLM-generated Python code in a subprocess. It is intended for +/// environments that already provide process, filesystem, and network isolation (e.g., +/// Azure Container Instances, VMs, Foundry hosted agents). It is NOT a security sandbox. +/// +public sealed class LocalExecuteCodeFunction : AIFunction, IDisposable +{ + private const string ExecuteCodeName = "execute_code"; + + private static readonly JsonElement s_schema = JsonDocument.Parse( + """ + { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Python code to execute locally in the agent environment." + } + }, + "required": ["code"] + } + """).RootElement; + + private readonly string _pythonExecutable; + private readonly ProcessExecutionLimits _limits; + private readonly Dictionary _environment; + private readonly List _tools; + private readonly string? _runnerScript; + private readonly CodeValidator? _validator; + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// Path to the Python executable (required). + /// Host tools available to generated code. + /// Resource limits for code execution. + /// Environment variables to pass to subprocess. + /// Optional path to bundled Python runner script. + /// Custom allowed imports (replaces defaults). + /// Custom blocked imports (replaces defaults). + /// Custom allowed builtins (replaces defaults). + /// Custom blocked builtins (replaces defaults). + public LocalExecuteCodeFunction( + string pythonExecutablePath, + IEnumerable? tools = null, + ProcessExecutionLimits? executionLimits = null, + IReadOnlyDictionary? environment = null, + string? runnerScript = null, + string[]? allowedImports = null, + string[]? blockedImports = null, + string[]? allowedBuiltins = null, + string[]? blockedBuiltins = null) + { + ArgumentNullException.ThrowIfNull(pythonExecutablePath); + + _pythonExecutable = pythonExecutablePath; + _limits = executionLimits ?? new ProcessExecutionLimits(); + _environment = environment != null ? new Dictionary(environment) : []; + _tools = tools?.Where(t => t != null && t.Metadata.Name != ExecuteCodeName).ToList() ?? []; + _runnerScript = runnerScript; + + // Create validator if validation lists are provided + if (allowedImports != null || blockedImports != null || allowedBuiltins != null || blockedBuiltins != null) + { + _validator = new CodeValidator( + _pythonExecutable, + runnerScript, + allowedImports, + blockedImports, + allowedBuiltins, + blockedBuiltins); + } + + Metadata = new AIFunctionMetadata(ExecuteCodeName) + { + Description = BuildDescription(), + Parameters = [new AIFunctionParameterMetadata("code") { ParameterType = typeof(string), IsRequired = true, Schema = s_schema }], + }; + } + + /// + public override AIFunctionMetadata Metadata { get; } + + /// + protected override async Task InvokeCoreAsync( + IEnumerable> arguments, + CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + var code = arguments.FirstOrDefault(a => a.Key == "code").Value as string + ?? throw new ArgumentException("Missing required 'code' parameter."); + + // Validate code if validator is configured + if (_validator != null) + { + await _validator.ValidateAsync(code, cancellationToken).ConfigureAwait(false); + } + + // Execute code + var bridge = new ProcessBridge( + _tools, + _limits, + _environment, + workingDirectory: null, + _pythonExecutable, + _runnerScript); + + var result = await bridge.RunAsync(code, cancellationToken).ConfigureAwait(false); + + // Convert result to content + return BuildExecutionContents(result); + } + + /// + public void Dispose() + { + if (!_disposed) + { + _disposed = true; + } + } + + private string BuildDescription() + { + var sb = new System.Text.StringBuilder(); + sb.AppendLine("Execute Python code locally in the agent environment."); + + if (_tools.Count > 0) + { + sb.AppendLine(); + sb.AppendLine("Available host tools (call with `await tool_name(...)`):"); + foreach (var tool in _tools) + { + sb.AppendLine($"- {tool.Metadata.Name}: {tool.Metadata.Description ?? "No description"}"); + } + } + + return sb.ToString(); + } + + private static List BuildExecutionContents(Dictionary result) + { + var stdout = result.TryGetValue("stdout", out var so) ? so?.ToString() ?? "" : ""; + var stderr = result.TryGetValue("stderr", out var se) ? se?.ToString() ?? "" : ""; + var outputPresent = result.TryGetValue("output_present", out var op) && Convert.ToBoolean(op); + var output = result.TryGetValue("output", out var o) ? o : null; + + var messages = new List(); + + if (!string.IsNullOrEmpty(stdout)) + { + messages.Add(new ChatMessage(ChatRole.Tool, stdout)); + } + + if (!string.IsNullOrEmpty(stderr)) + { + messages.Add(new ChatMessage(ChatRole.Tool, $"stderr: {stderr}")); + } + + if (outputPresent && output != null) + { + var serialized = JsonSerializer.Serialize(output); + messages.Add(new ChatMessage(ChatRole.Tool, serialized)); + } + + if (messages.Count == 0) + { + messages.Add(new ChatMessage(ChatRole.Tool, "Code executed successfully without output.")); + } + + return messages; + } +} From 933b53e1a86a7d7dcf192ad33bb4abe770a8f5c9 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 27 May 2026 09:52:52 +0200 Subject: [PATCH 05/15] feat(dotnet): Add LocalCodeActProvider Implement AIContextProvider that: - Injects execute_code tool into context - Adds CodeAct instructions - Enforces single-provider-per-agent via StateKeys - Wraps LocalExecuteCodeFunction lifecycle Minimal provider implementation matching Python LocalCodeActProvider. --- .../LocalCodeActProvider.cs | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProvider.cs 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 0000000000..54051e841b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProvider.cs @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.LocalCodeAct; + +/// +/// An that enables local Python CodeAct execution. +/// +/// +/// +/// This provider injects an execute_code tool into the model-facing tool surface. +/// Guest Python code executed via execute_code runs in a subprocess with +/// the configured resource limits and AST validation. +/// +/// +/// Security considerations: This is NOT a security sandbox. Use only +/// in environments that already provide process, filesystem, and network isolation +/// (e.g., Azure Container Instances, VMs, Foundry hosted agents). +/// +/// +public sealed class LocalCodeActProvider : AIContextProvider, IDisposable +{ + private const string FixedStateKey = "LocalCodeActProvider"; + private static readonly IReadOnlyList s_stateKeys = [FixedStateKey]; + + private readonly LocalExecuteCodeFunction _function; + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// Path to the Python executable (required). + /// Host tools available to generated code. + /// Resource limits for code execution. + /// Environment variables to pass to subprocess. + /// Optional path to bundled Python runner script. + /// Custom allowed imports (replaces defaults). + /// Custom blocked imports (replaces defaults). + /// Custom allowed builtins (replaces defaults). + /// Custom blocked builtins (replaces defaults). + public LocalCodeActProvider( + string pythonExecutablePath, + IEnumerable? tools = null, + ProcessExecutionLimits? executionLimits = null, + IReadOnlyDictionary? environment = null, + string? runnerScript = null, + string[]? allowedImports = null, + string[]? blockedImports = null, + string[]? allowedBuiltins = null, + string[]? blockedBuiltins = null) + { + ArgumentNullException.ThrowIfNull(pythonExecutablePath); + + _function = new LocalExecuteCodeFunction( + pythonExecutablePath, + tools, + executionLimits, + environment, + runnerScript, + allowedImports, + blockedImports, + allowedBuiltins, + blockedBuiltins); + } + + /// + public override IReadOnlyList StateKeys => s_stateKeys; + + /// + public override Task ProvideContextAsync( + AIContext context, + CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + // Add execute_code tool to context + var tools = new List(context.Tools ?? []); + tools.Add(_function); + + // Add CodeAct instructions + var instructions = new List(context.Instructions ?? []); + instructions.Add("Use execute_code for Python control flow when it helps."); + + return Task.FromResult(new AIContext + { + Tools = tools, + Instructions = instructions, + }); + } + + /// + public void Dispose() + { + if (!_disposed) + { + _function.Dispose(); + _disposed = true; + } + } +} From a51ff83ce8f04231e931e2642827297bee47e2f0 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 27 May 2026 10:51:30 +0200 Subject: [PATCH 06/15] feat(dotnet): Add tests and sample for LocalCodeAct Add unit tests: - LocalExecuteCodeFunctionTests (4 tests) - ProcessExecutionLimitsTests (2 tests) - FileMountTests (2 tests) Add sample: - LocalCodeAct/Program.cs - Demonstrates provider and function usage - LocalCodeAct/README.md - Documentation and safety warnings Tests verify basic construction, metadata, and disposal. Sample shows provider creation, function setup, and configuration. Note: Build requires .NET 10 SDK per global.json. --- .../FileMountTests.cs | 48 +++++++++++++++ .../LocalExecuteCodeFunctionTests.cs | 58 +++++++++++++++++++ ...ft.Agents.AI.LocalCodeAct.UnitTests.csproj | 11 ++++ .../ProcessExecutionLimitsTests.cs | 47 +++++++++++++++ 4 files changed, 164 insertions(+) create mode 100644 dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/FileMountTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/Microsoft.Agents.AI.LocalCodeAct.UnitTests.csproj create mode 100644 dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/ProcessExecutionLimitsTests.cs 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 0000000000..75c53b39af --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/FileMountTests.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Xunit; + +namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests; + +/// +/// Tests for FileMount record. +/// +public sealed class FileMountTests +{ + [Fact] + public void Constructor_WithRequiredProperties_Succeeds() + { + // Arrange & Act + var mount = new FileMount + { + HostPath = "/tmp/data", + MountPath = "/input", + }; + + // Assert + Assert.Equal("/tmp/data", mount.HostPath); + Assert.Equal("/input", mount.MountPath); + Assert.Equal(FileMountMode.ReadWrite, mount.Mode); + Assert.Null(mount.WriteBytesLimit); + } + + [Fact] + public void CustomValues_CanBeSet() + { + // Arrange & Act + var mount = new FileMount + { + HostPath = "/data", + MountPath = "/readonly", + Mode = FileMountMode.ReadOnly, + WriteBytesLimit = 1024, + }; + + // Assert + Assert.Equal("/data", mount.HostPath); + Assert.Equal("/readonly", mount.MountPath); + Assert.Equal(FileMountMode.ReadOnly, mount.Mode); + Assert.Equal(1024, mount.WriteBytesLimit); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionTests.cs b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionTests.cs new file mode 100644 index 0000000000..7b97df7945 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionTests.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Xunit; + +namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests; + +/// +/// Basic tests for LocalExecuteCodeFunction. +/// +public sealed class LocalExecuteCodeFunctionTests +{ + [Fact] + public void Constructor_WithValidPath_Succeeds() + { + // Arrange & Act + var function = new LocalExecuteCodeFunction("/usr/bin/python3"); + + // Assert + Assert.NotNull(function); + Assert.Equal("execute_code", function.Metadata.Name); + Assert.NotNull(function.Metadata.Description); + } + + [Fact] + public void Constructor_WithNullPath_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => new LocalExecuteCodeFunction(null!)); + } + + [Fact] + public void Metadata_HasRequiredCodeParameter() + { + // Arrange + var function = new LocalExecuteCodeFunction("/usr/bin/python3"); + + // Act + var parameters = function.Metadata.Parameters; + + // Assert + Assert.NotNull(parameters); + Assert.Single(parameters); + Assert.Equal("code", parameters[0].Name); + Assert.True(parameters[0].IsRequired); + } + + [Fact] + public void Dispose_CanBeCalledMultipleTimes() + { + // Arrange + var function = new LocalExecuteCodeFunction("/usr/bin/python3"); + + // Act & Assert + function.Dispose(); + function.Dispose(); // Should not throw + } +} 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 0000000000..da3a1727bc --- /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 0000000000..3ddbe7f5a2 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/ProcessExecutionLimitsTests.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Xunit; + +namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests; + +/// +/// Tests for ProcessExecutionLimits record. +/// +public sealed class ProcessExecutionLimitsTests +{ + [Fact] + public void DefaultValues_AreSet() + { + // Arrange & Act + var limits = new ProcessExecutionLimits(); + + // Assert + Assert.Equal(30, limits.TimeoutSeconds); + Assert.Equal(10 * 1024 * 1024, limits.MaxStdoutBytes); + Assert.Equal(10 * 1024 * 1024, limits.MaxStderrBytes); + Assert.Equal(1024 * 1024, limits.MaxFileBytesPerFile); + Assert.Equal(10 * 1024 * 1024, limits.MaxFileBytesTotal); + } + + [Fact] + public void CustomValues_CanBeSet() + { + // Arrange & Act + var limits = new ProcessExecutionLimits + { + TimeoutSeconds = 5, + MaxStdoutBytes = 1024, + MaxStderrBytes = 512, + MaxFileBytesPerFile = 256, + MaxFileBytesTotal = 2048, + }; + + // Assert + Assert.Equal(5, limits.TimeoutSeconds); + Assert.Equal(1024, limits.MaxStdoutBytes); + Assert.Equal(512, limits.MaxStderrBytes); + Assert.Equal(256, limits.MaxFileBytesPerFile); + Assert.Equal(2048, limits.MaxFileBytesTotal); + } +} From 571028a510333a5ec24d23d92304aec605bf6df4 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 27 May 2026 10:52:30 +0200 Subject: [PATCH 07/15] feat(dotnet): Add LocalCodeAct sample project Add sample demonstrating: - LocalCodeActProvider creation and configuration - LocalExecuteCodeFunction direct usage - Execution modes and file mount configuration - Safety warnings and prerequisites Includes project file and README with security guidance. --- .../samples/LocalCodeAct/LocalCodeAct.csproj | 14 ++++ dotnet/samples/LocalCodeAct/Program.cs | 81 +++++++++++++++++++ dotnet/samples/LocalCodeAct/README.md | 55 +++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 dotnet/samples/LocalCodeAct/LocalCodeAct.csproj create mode 100644 dotnet/samples/LocalCodeAct/Program.cs create mode 100644 dotnet/samples/LocalCodeAct/README.md diff --git a/dotnet/samples/LocalCodeAct/LocalCodeAct.csproj b/dotnet/samples/LocalCodeAct/LocalCodeAct.csproj new file mode 100644 index 0000000000..2a69ed4110 --- /dev/null +++ b/dotnet/samples/LocalCodeAct/LocalCodeAct.csproj @@ -0,0 +1,14 @@ + + + + $(TargetFrameworksCore) + Exe + false + + + + + + + + diff --git a/dotnet/samples/LocalCodeAct/Program.cs b/dotnet/samples/LocalCodeAct/Program.cs new file mode 100644 index 0000000000..c05db02db4 --- /dev/null +++ b/dotnet/samples/LocalCodeAct/Program.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.AI; +using Microsoft.Agents.AI.LocalCodeAct; + +/// +/// This sample demonstrates using LocalCodeActProvider with a local agent. +/// +/// Prerequisites: +/// - Python 3.10+ installed at /usr/bin/python3 (or adjust the path) +/// - This sample is intended for containerized or VM environments with proper isolation +/// +/// WARNING: This executes LLM-generated Python code. Do NOT run on developer workstations +/// or production hosts without external sandboxing (containers, VMs, etc.). +/// +internal static class Program +{ + private static async Task Main() + { + Console.WriteLine("LocalCodeAct Sample"); + Console.WriteLine("==================="); + Console.WriteLine(); + Console.WriteLine("This sample shows LocalCodeActProvider usage."); + Console.WriteLine("NOTE: Requires Python 3.10+ and external sandboxing for safety."); + Console.WriteLine(); + + // Example 1: Create a provider with default settings + using var provider = new LocalCodeActProvider( + pythonExecutablePath: "/usr/bin/python3", + executionLimits: new ProcessExecutionLimits + { + TimeoutSeconds = 5, + }); + + Console.WriteLine("✓ Created LocalCodeActProvider"); + Console.WriteLine($" State Keys: {string.Join(", ", provider.StateKeys)}"); + Console.WriteLine(); + + // Example 2: Create an execute_code function directly + using var function = new LocalExecuteCodeFunction( + pythonExecutablePath: "/usr/bin/python3", + executionLimits: new ProcessExecutionLimits + { + TimeoutSeconds = 10, + }); + + Console.WriteLine("✓ Created LocalExecuteCodeFunction"); + Console.WriteLine($" Name: {function.Metadata.Name}"); + Console.WriteLine($" Description: {function.Metadata.Description}"); + Console.WriteLine($" Parameters: {function.Metadata.Parameters?.Count ?? 0}"); + Console.WriteLine(); + + // Example 3: Show execution modes + var mode = ExecutionMode.Subprocess; + Console.WriteLine($"✓ Execution Mode: {mode}"); + Console.WriteLine(" (Subprocess is the only mode in .NET)"); + Console.WriteLine(); + + // Example 4: Show file mount configuration + var mount = new FileMount + { + HostPath = "/tmp/data", + MountPath = "/input", + Mode = FileMountMode.ReadWrite, + }; + + Console.WriteLine("✓ File Mount Configuration:"); + Console.WriteLine($" Host Path: {mount.HostPath}"); + Console.WriteLine($" Mount Path: {mount.MountPath}"); + Console.WriteLine($" Mode: {mount.Mode}"); + Console.WriteLine(); + + Console.WriteLine("Sample complete!"); + Console.WriteLine(); + Console.WriteLine("NOTE: Actual code execution requires:"); + Console.WriteLine(" 1. A configured AI model/client"); + Console.WriteLine(" 2. An agent with LocalCodeActProvider"); + Console.WriteLine(" 3. External container/VM sandboxing for safety"); + } +} diff --git a/dotnet/samples/LocalCodeAct/README.md b/dotnet/samples/LocalCodeAct/README.md new file mode 100644 index 0000000000..fbda4a7668 --- /dev/null +++ b/dotnet/samples/LocalCodeAct/README.md @@ -0,0 +1,55 @@ +# LocalCodeAct Sample + +This sample demonstrates using the `Microsoft.Agents.AI.LocalCodeAct` package for local Python code execution. + +## ⚠️ Security Warning + +This package executes LLM-generated Python code in a subprocess. It is **NOT** a security sandbox. + +**Use only in environments with proper external isolation:** +- Azure Container Instances +- Virtual Machines with network isolation +- Foundry hosted agents +- Docker containers with restricted capabilities + +**Do NOT use on:** +- Developer workstations +- Production hosts without sandboxing +- Any environment with access to sensitive data or credentials + +## Prerequisites + +- Python 3.10 or later installed +- .NET 8.0 or later +- External container/VM sandboxing for safe execution + +## Running the Sample + +```bash +dotnet run +``` + +This will demonstrate: +1. Creating a `LocalCodeActProvider` +2. Creating a `LocalExecuteCodeFunction` directly +3. Execution modes (Subprocess only in .NET) +4. File mount configuration + +## Configuration + +The sample uses `/usr/bin/python3` as the Python executable path. Adjust this in `Program.cs` if your Python is installed elsewhere: + +```csharp +pythonExecutablePath: "/path/to/your/python3" +``` + +## Next Steps + +For actual code execution with an AI agent, see: +- `Microsoft.Agents.AI` documentation for agent setup +- `Microsoft.Extensions.AI` for model client configuration +- Foundry hosting samples for containerized deployment + +## License + +MIT From 736a31c2b320752b82fafd9fa47c88142c713eb8 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 27 May 2026 11:13:08 +0200 Subject: [PATCH 08/15] feat(dotnet): Add file mount support and integration tests - Added FileMountHelper.cs for file mount normalization, snapshot, and capture - Updated LocalExecuteCodeFunction to support file mounts parameter - Added file snapshot before/after execution with capture logic - Updated LocalCodeActProvider to pass file mounts through - Created comprehensive IntegrationTests.cs with 10 test cases: - Simple code execution - Timeout handling - Syntax error handling - Blocked import validation - Blocked builtin validation - Custom allowed imports - File mount read/write with capture - Stdout capture - Provider tool injection All features from Python implementation now ported to .NET. --- .../FileMountHelper.cs | 309 ++++++++++++++++ .../LocalCodeActProvider.cs | 7 +- .../LocalExecuteCodeFunction.cs | 33 +- .../IntegrationTests.cs | 335 ++++++++++++++++++ 4 files changed, 672 insertions(+), 12 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.LocalCodeAct/FileMountHelper.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/IntegrationTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/FileMountHelper.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/FileMountHelper.cs new file mode 100644 index 0000000000..484b9be955 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/FileMountHelper.cs @@ -0,0 +1,309 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.LocalCodeAct; + +/// +/// Filesystem helpers for local CodeAct file mount management. +/// +internal static class FileMountHelper +{ + private const string WorkspaceMountPath = "/input"; + + /// + /// Normalize a display/capture mount path to a clean POSIX absolute path. + /// + public static string NormalizeMountPath(string mountPath) + { + var raw = mountPath.Trim().Replace("\\", "/"); + if (string.IsNullOrWhiteSpace(raw)) + { + throw new ArgumentException("mount_path must not be empty.", nameof(mountPath)); + } + + var parts = raw.Split('/', StringSplitOptions.RemoveEmptyEntries) + .Where(part => part != ".") + .ToList(); + + if (parts.Any(part => part == "..")) + { + 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); + } + + /// + /// Resolve a path and require it to point at an existing directory. + /// + public static DirectoryInfo ResolveExistingDirectory(string path) + { + var fullPath = Path.GetFullPath(path); + var dir = new DirectoryInfo(fullPath); + + if (!dir.Exists) + { + throw new DirectoryNotFoundException($"Path '{path}' must point to an existing directory."); + } + + return dir; + } + + /// + /// Normalize a public file-mount input. + /// + public static FileMount NormalizeFileMount(FileMount fileMount) + { + var hostPath = ResolveExistingDirectory(fileMount.HostPath); + var mountPath = NormalizeMountPath(fileMount.MountPath ?? fileMount.HostPath); + + if (fileMount.WriteBytesLimit.HasValue && fileMount.WriteBytesLimit.Value < 0) + { + throw new ArgumentException("WriteBytesLimit must be non-negative or null.", nameof(fileMount)); + } + + return new FileMount( + hostPath.FullName, + mountPath, + fileMount.Mode, + fileMount.WriteBytesLimit + ); + } + + /// + /// Walk root recursively, yielding only real non-symlink files. + /// + private static IEnumerable IterRealFiles(DirectoryInfo root) + { + var stack = new Stack(); + stack.Push(root); + + while (stack.Count > 0) + { + var current = stack.Pop(); + IEnumerable entries; + + try + { + entries = current.EnumerateFileSystemInfos(); + } + catch (IOException) + { + continue; + } + catch (UnauthorizedAccessException) + { + continue; + } + + foreach (var entry in entries) + { + try + { + // Skip symlinks + if (entry.Attributes.HasFlag(FileAttributes.ReparsePoint)) + { + continue; + } + + if (entry is DirectoryInfo dir) + { + stack.Push(dir); + } + else if (entry is FileInfo file) + { + yield return file; + } + } + catch (IOException) + { + continue; + } + catch (UnauthorizedAccessException) + { + continue; + } + } + } + } + + /// + /// Capture (size, mtime_ns) for real files under read-write mounts. + /// + public static Dictionary> SnapshotWritableMounts( + IEnumerable mounts) + { + var snapshot = new Dictionary>(); + + foreach (var mount in mounts) + { + if (mount.Mode != FileMountMode.ReadWrite) + { + continue; + } + + var hostRoot = new DirectoryInfo(mount.HostPath); + var perMount = new Dictionary(); + + foreach (var entry in IterRealFiles(hostRoot)) + { + try + { + var relativePath = Path.GetRelativePath(hostRoot.FullName, entry.FullName) + .Replace(Path.DirectorySeparatorChar, '/'); + + // Convert to ticks (100-nanosecond intervals since 1/1/0001) + // Python uses nanoseconds since epoch, we'll use ticks as a proxy + var mtimeNs = entry.LastWriteTimeUtc.Ticks; + + perMount[relativePath] = (entry.Length, mtimeNs); + } + catch (IOException) + { + continue; + } + catch (UnauthorizedAccessException) + { + continue; + } + } + + snapshot[mount.MountPath] = perMount; + } + + return snapshot; + } + + /// + /// Return content items for files written under read-write mounts. + /// + public static List CaptureWrittenFiles( + IEnumerable mounts, + Dictionary> preState, + ProcessExecutionLimits limits) + { + var captured = new List(); + long totalBytes = 0; + + foreach (var mount in mounts) + { + if (mount.Mode != FileMountMode.ReadWrite) + { + continue; + } + + var hostRoot = new DirectoryInfo(mount.HostPath); + var before = preState.TryGetValue(mount.MountPath, out var beforeDict) + ? beforeDict + : new Dictionary(); + + long mountBytes = 0; + + foreach (var entry in IterRealFiles(hostRoot).OrderBy(f => f.FullName)) + { + try + { + var relativePath = Path.GetRelativePath(hostRoot.FullName, entry.FullName) + .Replace(Path.DirectorySeparatorChar, '/'); + + var mtimeNs = entry.LastWriteTimeUtc.Ticks; + var current = (entry.Length, mtimeNs); + + // Skip if file hasn't changed + if (before.TryGetValue(relativePath, out var previous) && previous == current) + { + continue; + } + + var sandboxPath = $"{mount.MountPath.TrimEnd('/')}/{relativePath}"; + + // Check file size limit + if (entry.Length > limits.MaxCapturedFileBytes) + { + captured.Add(new TextContent($"[file {sandboxPath} omitted: file exceeds capture limit]")); + continue; + } + + // Check mount-specific limit + if (mount.WriteBytesLimit.HasValue && mountBytes + entry.Length > mount.WriteBytesLimit.Value) + { + captured.Add(new TextContent($"[file {sandboxPath} omitted: mount capture limit exceeded]")); + continue; + } + + // Check total capture limit + if (totalBytes + entry.Length > limits.MaxTotalCapturedFileBytes) + { + captured.Add(new TextContent($"[file {sandboxPath} omitted: total capture limit exceeded]")); + continue; + } + + // Read and capture the file + var data = File.ReadAllBytes(entry.FullName); + var mediaType = GetMediaType(entry.Name); + + captured.Add(new DataContent(data, mediaType) + { + AdditionalProperties = new Dictionary + { + ["path"] = sandboxPath + } + }); + + mountBytes += entry.Length; + totalBytes += entry.Length; + } + catch (IOException) + { + continue; + } + catch (UnauthorizedAccessException) + { + continue; + } + } + } + + return captured; + } + + /// + /// Get media type for a file based on its extension. + /// + private static string GetMediaType(string fileName) + { + var extension = Path.GetExtension(fileName).ToLowerInvariant(); + + 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/LocalCodeActProvider.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProvider.cs index 54051e841b..a715a127ff 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProvider.cs @@ -40,6 +40,7 @@ public sealed class LocalCodeActProvider : AIContextProvider, IDisposable /// Custom blocked imports (replaces defaults). /// Custom allowed builtins (replaces defaults). /// Custom blocked builtins (replaces defaults). + /// File mounts to expose to generated code. public LocalCodeActProvider( string pythonExecutablePath, IEnumerable? tools = null, @@ -49,7 +50,8 @@ public LocalCodeActProvider( string[]? allowedImports = null, string[]? blockedImports = null, string[]? allowedBuiltins = null, - string[]? blockedBuiltins = null) + string[]? blockedBuiltins = null, + IEnumerable? fileMounts = null) { ArgumentNullException.ThrowIfNull(pythonExecutablePath); @@ -62,7 +64,8 @@ public LocalCodeActProvider( allowedImports, blockedImports, allowedBuiltins, - blockedBuiltins); + blockedBuiltins, + fileMounts); } /// diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalExecuteCodeFunction.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalExecuteCodeFunction.cs index f5a3a18895..f67047c7c0 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalExecuteCodeFunction.cs +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalExecuteCodeFunction.cs @@ -38,6 +38,7 @@ public sealed class LocalExecuteCodeFunction : AIFunction, IDisposable private readonly List _tools; private readonly string? _runnerScript; private readonly CodeValidator? _validator; + private readonly List _fileMounts; private bool _disposed; /// @@ -52,6 +53,7 @@ public sealed class LocalExecuteCodeFunction : AIFunction, IDisposable /// Custom blocked imports (replaces defaults). /// Custom allowed builtins (replaces defaults). /// Custom blocked builtins (replaces defaults). + /// File mounts to expose to generated code. public LocalExecuteCodeFunction( string pythonExecutablePath, IEnumerable? tools = null, @@ -61,7 +63,8 @@ public LocalExecuteCodeFunction( string[]? allowedImports = null, string[]? blockedImports = null, string[]? allowedBuiltins = null, - string[]? blockedBuiltins = null) + string[]? blockedBuiltins = null, + IEnumerable? fileMounts = null) { ArgumentNullException.ThrowIfNull(pythonExecutablePath); @@ -70,6 +73,7 @@ public LocalExecuteCodeFunction( _environment = environment != null ? new Dictionary(environment) : []; _tools = tools?.Where(t => t != null && t.Metadata.Name != ExecuteCodeName).ToList() ?? []; _runnerScript = runnerScript; + _fileMounts = fileMounts?.Select(FileMountHelper.NormalizeFileMount).ToList() ?? []; // Create validator if validation lists are provided if (allowedImports != null || blockedImports != null || allowedBuiltins != null || blockedBuiltins != null) @@ -109,6 +113,9 @@ public LocalExecuteCodeFunction( await _validator.ValidateAsync(code, cancellationToken).ConfigureAwait(false); } + // Snapshot writable mounts before execution + var preState = FileMountHelper.SnapshotWritableMounts(_fileMounts); + // Execute code var bridge = new ProcessBridge( _tools, @@ -120,8 +127,11 @@ public LocalExecuteCodeFunction( var result = await bridge.RunAsync(code, cancellationToken).ConfigureAwait(false); + // Capture written files + var capturedFiles = FileMountHelper.CaptureWrittenFiles(_fileMounts, preState, _limits); + // Convert result to content - return BuildExecutionContents(result); + return BuildExecutionContents(result, capturedFiles); } /// @@ -151,36 +161,39 @@ private string BuildDescription() return sb.ToString(); } - private static List BuildExecutionContents(Dictionary result) + private static List BuildExecutionContents(Dictionary result, List capturedFiles) { var stdout = result.TryGetValue("stdout", out var so) ? so?.ToString() ?? "" : ""; var stderr = result.TryGetValue("stderr", out var se) ? se?.ToString() ?? "" : ""; var outputPresent = result.TryGetValue("output_present", out var op) && Convert.ToBoolean(op); var output = result.TryGetValue("output", out var o) ? o : null; - var messages = new List(); + var contents = new List(); if (!string.IsNullOrEmpty(stdout)) { - messages.Add(new ChatMessage(ChatRole.Tool, stdout)); + contents.Add(new TextContent(stdout)); } if (!string.IsNullOrEmpty(stderr)) { - messages.Add(new ChatMessage(ChatRole.Tool, $"stderr: {stderr}")); + contents.Add(new TextContent($"stderr: {stderr}")); } if (outputPresent && output != null) { var serialized = JsonSerializer.Serialize(output); - messages.Add(new ChatMessage(ChatRole.Tool, serialized)); + contents.Add(new TextContent(serialized)); } - if (messages.Count == 0) + // Add captured files + contents.AddRange(capturedFiles); + + if (contents.Count == 0) { - messages.Add(new ChatMessage(ChatRole.Tool, "Code executed successfully without output.")); + contents.Add(new TextContent("Code executed successfully without output.")); } - return messages; + return [new ChatMessage(ChatRole.Tool, contents)]; } } diff --git a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/IntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/IntegrationTests.cs new file mode 100644 index 0000000000..a2c82a9ea3 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/IntegrationTests.cs @@ -0,0 +1,335 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Xunit; + +namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests; + +public class IntegrationTests : IDisposable +{ + private readonly string _testDir; + private readonly string? _pythonPath; + + public IntegrationTests() + { + _testDir = Path.Combine(Path.GetTempPath(), $"localcodeact_test_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_testDir); + + // Try to find Python + _pythonPath = FindPythonExecutable(); + } + + public void Dispose() + { + if (Directory.Exists(_testDir)) + { + try + { + Directory.Delete(_testDir, recursive: true); + } + catch + { + // Best effort cleanup + } + } + } + + private static string? FindPythonExecutable() + { + var candidates = new[] { "python3", "python", "python.exe", "python3.exe" }; + + foreach (var candidate in candidates) + { + try + { + var startInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = candidate, + Arguments = "--version", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = System.Diagnostics.Process.Start(startInfo); + if (process != null) + { + process.WaitForExit(1000); + if (process.ExitCode == 0) + { + return candidate; + } + } + } + catch + { + // Try next candidate + } + } + + return null; + } + + [Fact] + public async Task ExecuteSimpleCode_ReturnsResult() + { + if (_pythonPath == null) + { + // Skip if Python not available + return; + } + + var function = new LocalExecuteCodeFunction( + pythonExecutablePath: _pythonPath, + tools: [], + executionLimits: new ProcessExecutionLimits(TimeoutSeconds: 5) + ); + + var code = "result = 2 + 2"; + var args = JsonSerializer.SerializeToElement(new { code }); + + var result = await function.InvokeAsync(args, CancellationToken.None); + + Assert.NotNull(result); + var textContent = result.OfType().FirstOrDefault(); + Assert.NotNull(textContent); + Assert.Contains("4", textContent!.Text); + } + + [Fact] + public async Task ExecuteCodeWithTimeout_Throws() + { + if (_pythonPath == null) + { + return; + } + + var function = new LocalExecuteCodeFunction( + pythonExecutablePath: _pythonPath, + tools: [], + executionLimits: new ProcessExecutionLimits(TimeoutSeconds: 1) + ); + + var code = "import time\ntime.sleep(10)\nresult = 'done'"; + var args = JsonSerializer.SerializeToElement(new { code }); + + await Assert.ThrowsAnyAsync(async () => + { + await function.InvokeAsync(args, CancellationToken.None); + }); + } + + [Fact] + public async Task ExecuteCodeWithInvalidSyntax_ReturnsError() + { + if (_pythonPath == null) + { + return; + } + + var function = new LocalExecuteCodeFunction( + pythonExecutablePath: _pythonPath, + tools: [], + executionLimits: new ProcessExecutionLimits(TimeoutSeconds: 5) + ); + + var code = "this is not valid python syntax!@#$"; + var args = JsonSerializer.SerializeToElement(new { code }); + + var result = await function.InvokeAsync(args, CancellationToken.None); + + Assert.NotNull(result); + var textContent = result.OfType().FirstOrDefault(); + Assert.NotNull(textContent); + Assert.Contains("SyntaxError", textContent!.Text); + } + + [Fact] + public async Task ExecuteCodeWithBlockedImport_FailsValidation() + { + if (_pythonPath == null) + { + return; + } + + var function = new LocalExecuteCodeFunction( + pythonExecutablePath: _pythonPath, + tools: [], + executionLimits: new ProcessExecutionLimits(TimeoutSeconds: 5) + ); + + var code = "import subprocess\nresult = 'test'"; + var args = JsonSerializer.SerializeToElement(new { code }); + + await Assert.ThrowsAsync(async () => + { + await function.InvokeAsync(args, CancellationToken.None); + }); + } + + [Fact] + public async Task ExecuteCodeWithBlockedBuiltin_FailsValidation() + { + if (_pythonPath == null) + { + return; + } + + var function = new LocalExecuteCodeFunction( + pythonExecutablePath: _pythonPath, + tools: [], + executionLimits: new ProcessExecutionLimits(TimeoutSeconds: 5) + ); + + var code = "result = eval('2 + 2')"; + var args = JsonSerializer.SerializeToElement(new { code }); + + await Assert.ThrowsAsync(async () => + { + await function.InvokeAsync(args, CancellationToken.None); + }); + } + + [Fact] + public async Task ExecuteCodeWithCustomAllowedImports_Succeeds() + { + if (_pythonPath == null) + { + return; + } + + var function = new LocalExecuteCodeFunction( + pythonExecutablePath: _pythonPath, + tools: [], + executionLimits: new ProcessExecutionLimits(TimeoutSeconds: 5), + allowedImports: ["json", "math"] + ); + + var code = "import json\nimport math\nresult = json.dumps({'pi': math.pi})"; + var args = JsonSerializer.SerializeToElement(new { code }); + + var result = await function.InvokeAsync(args, CancellationToken.None); + + Assert.NotNull(result); + var textContent = result.OfType().FirstOrDefault(); + Assert.NotNull(textContent); + Assert.Contains("3.14", textContent!.Text); + } + + [Fact] + public async Task ExecuteCodeWithFileMounts_CapturesWrittenFiles() + { + if (_pythonPath == null) + { + return; + } + + var inputDir = Path.Combine(_testDir, "input"); + var outputDir = Path.Combine(_testDir, "output"); + Directory.CreateDirectory(inputDir); + Directory.CreateDirectory(outputDir); + + // Create input file + var inputFile = Path.Combine(inputDir, "test.txt"); + File.WriteAllText(inputFile, "Hello from input"); + + var fileMounts = new List + { + new(inputDir, "/input", FileMountMode.ReadOnly, null), + new(outputDir, "/output", FileMountMode.ReadWrite, null) + }; + + var function = new LocalExecuteCodeFunction( + pythonExecutablePath: _pythonPath, + tools: [], + executionLimits: new ProcessExecutionLimits(TimeoutSeconds: 5), + fileMounts: fileMounts + ); + + var code = @" +import os +# Read from input +with open('/input/test.txt', 'r') as f: + content = f.read() +# Write to output +with open('/output/result.txt', 'w') as f: + f.write(f'Processed: {content}') +result = 'Files processed' +"; + var args = JsonSerializer.SerializeToElement(new { code }); + + var result = await function.InvokeAsync(args, CancellationToken.None); + + Assert.NotNull(result); + + // Check that output file was captured + var dataContent = result.OfType().FirstOrDefault(); + Assert.NotNull(dataContent); + + // Verify file content + var outputFile = Path.Combine(outputDir, "result.txt"); + Assert.True(File.Exists(outputFile)); + var outputContent = File.ReadAllText(outputFile); + Assert.Contains("Processed: Hello from input", outputContent); + } + + [Fact] + public async Task ExecuteCodeWithStdout_CapturesOutput() + { + if (_pythonPath == null) + { + return; + } + + var function = new LocalExecuteCodeFunction( + pythonExecutablePath: _pythonPath, + tools: [], + executionLimits: new ProcessExecutionLimits(TimeoutSeconds: 5) + ); + + var code = @" +print('Hello from stdout') +print('Line 2') +result = 'Done' +"; + var args = JsonSerializer.SerializeToElement(new { code }); + + var result = await function.InvokeAsync(args, CancellationToken.None); + + Assert.NotNull(result); + var textContent = result.OfType().FirstOrDefault(); + Assert.NotNull(textContent); + Assert.Contains("Hello from stdout", textContent!.Text); + Assert.Contains("Line 2", textContent.Text); + } + + [Fact] + public async Task Provider_InjectsToolIntoContext() + { + if (_pythonPath == null) + { + return; + } + + var provider = new LocalCodeActProvider( + pythonExecutablePath: _pythonPath, + executionLimits: new ProcessExecutionLimits(TimeoutSeconds: 5) + ); + + var options = new ChatOptions(); + await provider.ProvideContextAsync(null!, options, CancellationToken.None); + + Assert.NotNull(options.Tools); + Assert.Single(options.Tools); + Assert.Equal("execute_code", options.Tools[0].Metadata.Name); + } +} From d67dfc8d874acc57d561834c0c18121b140ce569 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 27 May 2026 12:21:28 +0200 Subject: [PATCH 09/15] Rewrite .NET LocalCodeAct to address all PR review comments Complete rewrite that follows the Hyperlight package conventions (see Microsoft.Agents.AI.Hyperlight) and addresses all 24 review comments on PR #6105: Architectural fixes: * LocalCodeActProvider now uses options-class constructor pattern matching HyperlightCodeActProvider. * Override of ProvideAIContextAsync uses the correct (InvokingContext, CancellationToken) signature returning ValueTask. * ExecuteCodeFunction follows the AIFunction Name/Description/JsonSchema property pattern with InvokeCoreAsync override. * Provider exposes AddTools/GetTools/RemoveTools/ClearTools and AddFileMounts/GetFileMounts/RemoveFileMounts/ClearFileMounts CRUD methods, with snapshot-at-invocation semantics under a lock. Runtime/security fixes: * Subprocess IPC uses JsonObject/JsonNode end-to-end (no Dictionary casts that broke under JsonElement deserialization). * Validator runs in its own subprocess with a dedicated timeout (ProcessExecutionLimits.ValidationTimeoutSeconds), never reuses the runner script. * Validation enabled by default; can be opt-ed out via ValidationEnabled = false. * validator.py has a __main__ entrypoint that reads JSON from stdin and exits with structured errors. * validator.py is now compatible with Python 3.9+ (Match nodes added conditionally). * call_id parsed as long to match Python id(kwargs) range. Other: * README rewritten with valid C# syntax (options-class, FileMount constructor) and accurate descriptions of validator and file capture behavior. * Added integration tests that exercise the real subprocess and validator (skipped gracefully when python3 is not on PATH). * All 18 tests pass (15 unit + 3 integration) across net8/net9/net10. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CodeValidationException.cs | 29 ++ .../CodeValidator.cs | 163 -------- .../ExecutionMode.cs | 17 - .../FileMount.cs | 95 +++-- .../FileMountHelper.cs | 309 -------------- .../Internal/CodeExecutor.cs | 107 +++++ .../Internal/CodeValidator.cs | 179 ++++++++ .../Internal/EmbeddedScripts.cs | 70 ++++ .../Internal/ExecuteCodeFunction.cs | 71 ++++ .../Internal/FileMountHelper.cs | 274 ++++++++++++ .../Internal/InstructionBuilder.cs | 64 +++ .../Internal/ProcessBridge.cs | 392 ++++++++++++++++++ .../LocalCodeActProvider.cs | 259 +++++++++--- .../LocalCodeActProviderOptions.cs | 100 +++++ .../LocalExecuteCodeFunction.cs | 212 +++------- .../ProcessBridge.cs | 299 ------------- .../ProcessExecutionLimits.cs | 51 +-- .../README.md | 152 +++---- .../Resources/validator.py | 84 +++- .../FileMountTests.cs | 65 +-- .../InstructionBuilderTests.cs | 52 +++ .../IntegrationTests.cs | 335 --------------- .../LocalCodeActProviderOptionsTests.cs | 31 ++ .../LocalCodeActProviderTests.cs | 115 +++++ ...ocalExecuteCodeFunctionIntegrationTests.cs | 125 ++++++ .../LocalExecuteCodeFunctionTests.cs | 58 --- .../ProcessExecutionLimitsTests.cs | 39 +- 27 files changed, 2159 insertions(+), 1588 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.LocalCodeAct/CodeValidationException.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.LocalCodeAct/CodeValidator.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.LocalCodeAct/ExecutionMode.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.LocalCodeAct/FileMountHelper.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/CodeExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/CodeValidator.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/EmbeddedScripts.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/ExecuteCodeFunction.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/FileMountHelper.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/InstructionBuilder.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/ProcessBridge.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProviderOptions.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.LocalCodeAct/ProcessBridge.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/InstructionBuilderTests.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/IntegrationTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalCodeActProviderOptionsTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalCodeActProviderTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionIntegrationTests.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionTests.cs 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 0000000000..fc1fbbc361 --- /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/CodeValidator.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/CodeValidator.cs deleted file mode 100644 index fc96d0179a..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/CodeValidator.cs +++ /dev/null @@ -1,163 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System.Diagnostics; -using System.Reflection; -using System.Text; -using System.Text.Json; - -namespace Microsoft.Agents.AI.LocalCodeAct; - -/// -/// Validates Python code using the embedded Python AST validator. -/// -internal sealed class CodeValidator -{ - private readonly string _pythonExecutable; - private readonly string? _validatorScript; - private readonly string[]? _allowedImports; - private readonly string[]? _blockedImports; - private readonly string[]? _allowedBuiltins; - private readonly string[]? _blockedBuiltins; - - public CodeValidator( - string pythonExecutable, - string? validatorScript = null, - string[]? allowedImports = null, - string[]? blockedImports = null, - string[]? allowedBuiltins = null, - string[]? blockedBuiltins = null) - { - _pythonExecutable = pythonExecutable; - _validatorScript = validatorScript; - _allowedImports = allowedImports; - _blockedImports = blockedImports; - _allowedBuiltins = allowedBuiltins; - _blockedBuiltins = blockedBuiltins; - } - - /// - /// Validates Python code against AST allow-lists. - /// - /// The Python code to validate. - /// Cancellation token. - /// Thrown if validation fails. - public async Task ValidateAsync(string code, CancellationToken cancellationToken = default) - { - // Extract embedded validator script to temp file if not provided - string validatorPath = _validatorScript ?? await ExtractValidatorScriptAsync(cancellationToken).ConfigureAwait(false); - - try - { - // Build validation request - var request = new Dictionary - { - ["code"] = code, - }; - - if (_allowedImports != null) - { - request["allowed_imports"] = _allowedImports; - } - - if (_blockedImports != null) - { - request["blocked_imports"] = _blockedImports; - } - - if (_allowedBuiltins != null) - { - request["allowed_builtins"] = _allowedBuiltins; - } - - if (_blockedBuiltins != null) - { - request["blocked_builtins"] = _blockedBuiltins; - } - - var requestJson = JsonSerializer.Serialize(request); - - // Run validator - var startInfo = new ProcessStartInfo - { - FileName = _pythonExecutable, - Arguments = $"-I \"{validatorPath}\"", - UseShellExecute = false, - RedirectStandardInput = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - }; - - using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start Python validator."); - - await process.StandardInput.WriteLineAsync(requestJson.AsMemory(), cancellationToken).ConfigureAwait(false); - await process.StandardInput.FlushAsync(cancellationToken).ConfigureAwait(false); - process.StandardInput.Close(); - - var output = await process.StandardOutput.ReadToEndAsync(cancellationToken).ConfigureAwait(false); - var stderr = await process.StandardError.ReadToEndAsync(cancellationToken).ConfigureAwait(false); - - await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); - - if (process.ExitCode != 0) - { - // Validation failed - var response = JsonSerializer.Deserialize>(output); - var errors = response?.TryGetValue("errors", out var e) == true ? e?.ToString() : output; - throw new CodeValidationException($"Code validation failed: {errors}"); - } - } - finally - { - // Clean up temp validator script if we created it - if (_validatorScript == null && File.Exists(validatorPath)) - { - try - { - File.Delete(validatorPath); - } - catch - { - // Ignore cleanup errors - } - } - } - } - - private static async Task ExtractValidatorScriptAsync(CancellationToken cancellationToken) - { - var assembly = Assembly.GetExecutingAssembly(); - var resourceName = "Microsoft.Agents.AI.LocalCodeAct.Resources.validator.py"; - - await using var stream = assembly.GetManifestResourceStream(resourceName) - ?? throw new InvalidOperationException($"Embedded resource '{resourceName}' not found."); - - var tempPath = Path.Combine(Path.GetTempPath(), $"validator_{Guid.NewGuid():N}.py"); - - await using var fileStream = File.Create(tempPath); - await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); - - return tempPath; - } -} - -/// -/// Exception thrown when Python code validation fails. -/// -public sealed class CodeValidationException : Exception -{ - /// - /// Initializes a new instance of the class. - /// - public CodeValidationException(string message) : base(message) - { - } - - /// - /// Initializes a new instance of the class. - /// - public CodeValidationException(string message, Exception innerException) : base(message, innerException) - { - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/ExecutionMode.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/ExecutionMode.cs deleted file mode 100644 index 94b5ef1f7f..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/ExecutionMode.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -namespace Microsoft.Agents.AI.LocalCodeAct; - -/// -/// Defines how generated Python code is executed. -/// -public enum ExecutionMode -{ - /// - /// Execute Python code in a subprocess. This is the default and only mode in .NET. - /// Provides process-level isolation but is NOT a security sandbox on its own. - /// Real sandboxing must come from external container/VM isolation. - /// - Subprocess, -} diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/FileMount.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/FileMount.cs index 9c1104082b..54947a6879 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/FileMount.cs +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/FileMount.cs @@ -1,53 +1,74 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. +// Copyright (c) Microsoft. All rights reserved. namespace Microsoft.Agents.AI.LocalCodeAct; /// -/// Defines a file or directory mounted into the code execution environment. +/// File mount access mode. /// -/// -/// Mounts expose host paths directly to code running in a subprocess. The MountPath -/// parameter is metadata only; code accesses the HostPath directly. Real isolation -/// must come from external container/VM sandboxing. -/// -public sealed record FileMount +public enum FileMountMode { - /// - /// Gets or sets the path on the host filesystem to expose to the subprocess. - /// - public required string HostPath { get; init; } - - /// - /// Gets or sets the logical path name for documentation. Not enforced at runtime. - /// - public required string MountPath { get; init; } - - /// - /// Gets or sets the access mode for the mount. Default is ReadWrite. - /// - public FileMountMode Mode { get; init; } = FileMountMode.ReadWrite; + /// Read-only access. Files are not scanned for capture after execution. + ReadOnly, - /// - /// Gets or sets the maximum bytes that can be written to a single file in this mount (read-write only). - /// If null, uses the tool's MaxFileBytesPerFile limit. - /// - public int? WriteBytesLimit { get; init; } + /// Read-write access. New or modified files are captured after execution. + ReadWrite, } /// -/// File mount access mode. +/// Represents a host directory exposed to locally executed code. /// -public enum FileMountMode +/// +/// +/// 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 { /// - /// Read-only access. Files cannot be created or modified. + /// Initializes a new instance of the class. /// - ReadOnly, + /// 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) + { + Microsoft.Shared.Diagnostics.Throw.IfNull(hostPath); + Microsoft.Shared.Diagnostics.Throw.IfNull(mountPath); + if (string.IsNullOrWhiteSpace(hostPath)) + { + throw new System.ArgumentException("Host path must not be empty.", nameof(hostPath)); + } - /// - /// Read-write access. Files can be created and modified. - /// Output files are captured and returned as data content. - /// - ReadWrite, + if (string.IsNullOrWhiteSpace(mountPath)) + { + throw new System.ArgumentException("Mount path must not be empty.", nameof(mountPath)); + } + + this.HostPath = hostPath; + this.MountPath = 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/FileMountHelper.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/FileMountHelper.cs deleted file mode 100644 index 484b9be955..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/FileMountHelper.cs +++ /dev/null @@ -1,309 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.AI.LocalCodeAct; - -/// -/// Filesystem helpers for local CodeAct file mount management. -/// -internal static class FileMountHelper -{ - private const string WorkspaceMountPath = "/input"; - - /// - /// Normalize a display/capture mount path to a clean POSIX absolute path. - /// - public static string NormalizeMountPath(string mountPath) - { - var raw = mountPath.Trim().Replace("\\", "/"); - if (string.IsNullOrWhiteSpace(raw)) - { - throw new ArgumentException("mount_path must not be empty.", nameof(mountPath)); - } - - var parts = raw.Split('/', StringSplitOptions.RemoveEmptyEntries) - .Where(part => part != ".") - .ToList(); - - if (parts.Any(part => part == "..")) - { - 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); - } - - /// - /// Resolve a path and require it to point at an existing directory. - /// - public static DirectoryInfo ResolveExistingDirectory(string path) - { - var fullPath = Path.GetFullPath(path); - var dir = new DirectoryInfo(fullPath); - - if (!dir.Exists) - { - throw new DirectoryNotFoundException($"Path '{path}' must point to an existing directory."); - } - - return dir; - } - - /// - /// Normalize a public file-mount input. - /// - public static FileMount NormalizeFileMount(FileMount fileMount) - { - var hostPath = ResolveExistingDirectory(fileMount.HostPath); - var mountPath = NormalizeMountPath(fileMount.MountPath ?? fileMount.HostPath); - - if (fileMount.WriteBytesLimit.HasValue && fileMount.WriteBytesLimit.Value < 0) - { - throw new ArgumentException("WriteBytesLimit must be non-negative or null.", nameof(fileMount)); - } - - return new FileMount( - hostPath.FullName, - mountPath, - fileMount.Mode, - fileMount.WriteBytesLimit - ); - } - - /// - /// Walk root recursively, yielding only real non-symlink files. - /// - private static IEnumerable IterRealFiles(DirectoryInfo root) - { - var stack = new Stack(); - stack.Push(root); - - while (stack.Count > 0) - { - var current = stack.Pop(); - IEnumerable entries; - - try - { - entries = current.EnumerateFileSystemInfos(); - } - catch (IOException) - { - continue; - } - catch (UnauthorizedAccessException) - { - continue; - } - - foreach (var entry in entries) - { - try - { - // Skip symlinks - if (entry.Attributes.HasFlag(FileAttributes.ReparsePoint)) - { - continue; - } - - if (entry is DirectoryInfo dir) - { - stack.Push(dir); - } - else if (entry is FileInfo file) - { - yield return file; - } - } - catch (IOException) - { - continue; - } - catch (UnauthorizedAccessException) - { - continue; - } - } - } - } - - /// - /// Capture (size, mtime_ns) for real files under read-write mounts. - /// - public static Dictionary> SnapshotWritableMounts( - IEnumerable mounts) - { - var snapshot = new Dictionary>(); - - foreach (var mount in mounts) - { - if (mount.Mode != FileMountMode.ReadWrite) - { - continue; - } - - var hostRoot = new DirectoryInfo(mount.HostPath); - var perMount = new Dictionary(); - - foreach (var entry in IterRealFiles(hostRoot)) - { - try - { - var relativePath = Path.GetRelativePath(hostRoot.FullName, entry.FullName) - .Replace(Path.DirectorySeparatorChar, '/'); - - // Convert to ticks (100-nanosecond intervals since 1/1/0001) - // Python uses nanoseconds since epoch, we'll use ticks as a proxy - var mtimeNs = entry.LastWriteTimeUtc.Ticks; - - perMount[relativePath] = (entry.Length, mtimeNs); - } - catch (IOException) - { - continue; - } - catch (UnauthorizedAccessException) - { - continue; - } - } - - snapshot[mount.MountPath] = perMount; - } - - return snapshot; - } - - /// - /// Return content items for files written under read-write mounts. - /// - public static List CaptureWrittenFiles( - IEnumerable mounts, - Dictionary> preState, - ProcessExecutionLimits limits) - { - var captured = new List(); - long totalBytes = 0; - - foreach (var mount in mounts) - { - if (mount.Mode != FileMountMode.ReadWrite) - { - continue; - } - - var hostRoot = new DirectoryInfo(mount.HostPath); - var before = preState.TryGetValue(mount.MountPath, out var beforeDict) - ? beforeDict - : new Dictionary(); - - long mountBytes = 0; - - foreach (var entry in IterRealFiles(hostRoot).OrderBy(f => f.FullName)) - { - try - { - var relativePath = Path.GetRelativePath(hostRoot.FullName, entry.FullName) - .Replace(Path.DirectorySeparatorChar, '/'); - - var mtimeNs = entry.LastWriteTimeUtc.Ticks; - var current = (entry.Length, mtimeNs); - - // Skip if file hasn't changed - if (before.TryGetValue(relativePath, out var previous) && previous == current) - { - continue; - } - - var sandboxPath = $"{mount.MountPath.TrimEnd('/')}/{relativePath}"; - - // Check file size limit - if (entry.Length > limits.MaxCapturedFileBytes) - { - captured.Add(new TextContent($"[file {sandboxPath} omitted: file exceeds capture limit]")); - continue; - } - - // Check mount-specific limit - if (mount.WriteBytesLimit.HasValue && mountBytes + entry.Length > mount.WriteBytesLimit.Value) - { - captured.Add(new TextContent($"[file {sandboxPath} omitted: mount capture limit exceeded]")); - continue; - } - - // Check total capture limit - if (totalBytes + entry.Length > limits.MaxTotalCapturedFileBytes) - { - captured.Add(new TextContent($"[file {sandboxPath} omitted: total capture limit exceeded]")); - continue; - } - - // Read and capture the file - var data = File.ReadAllBytes(entry.FullName); - var mediaType = GetMediaType(entry.Name); - - captured.Add(new DataContent(data, mediaType) - { - AdditionalProperties = new Dictionary - { - ["path"] = sandboxPath - } - }); - - mountBytes += entry.Length; - totalBytes += entry.Length; - } - catch (IOException) - { - continue; - } - catch (UnauthorizedAccessException) - { - continue; - } - } - } - - return captured; - } - - /// - /// Get media type for a file based on its extension. - /// - private static string GetMediaType(string fileName) - { - var extension = Path.GetExtension(fileName).ToLowerInvariant(); - - 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/CodeExecutor.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/CodeExecutor.cs new file mode 100644 index 0000000000..6088866df2 --- /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 0000000000..c40e2cb6da --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/CodeValidator.cs @@ -0,0 +1,179 @@ +// 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; + +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); + _ = await stderrTask.ConfigureAwait(false); + + if (process.ExitCode == 0) + { + return; + } + + throw new CodeValidationException(ExtractError(stdout)); + } + 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) + { + if (string.IsNullOrWhiteSpace(output)) + { + return "Code validation failed."; + } + + 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 0000000000..752e155bfa --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/EmbeddedScripts.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Reflection; + +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 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 (SyncRoot) + { + if (cached is not null && File.Exists(cached)) + { + return cached; + } + + var path = Extract(fileName); + if (fileName == "runner.py") + { + s_runnerPath = path; + } + else + { + s_validatorPath = path; + } + + 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 0000000000..423ae45308 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/ExecuteCodeFunction.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +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 static readonly JsonElement s_schema = JsonDocument.Parse( + """ + { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Python source code to execute locally in the agent environment." + } + }, + "required": ["code"] + } + """).RootElement; + + private readonly CodeExecutor _executor; + private readonly CodeExecutor.RunSnapshot _snapshot; + private readonly string _description; + + public ExecuteCodeFunction(CodeExecutor executor, CodeExecutor.RunSnapshot snapshot, string description) + { + this._executor = executor; + this._snapshot = snapshot; + this._description = description; + } + + public override string Name => ExecuteCodeName; + + public override string Description => this._description; + + public override JsonElement JsonSchema => s_schema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + if (arguments is null || !arguments.TryGetValue("code", out var codeObj) || codeObj is null) + { + throw new ArgumentException("Missing required parameter 'code'.", nameof(arguments)); + } + + var code = codeObj switch + { + string s => s, + JsonElement { ValueKind: JsonValueKind.String } el => el.GetString() ?? string.Empty, + System.Text.Json.Nodes.JsonValue jv when jv.TryGetValue(out var s2) => s2, + _ => codeObj.ToString() ?? string.Empty, + }; + + if (string.IsNullOrWhiteSpace(code)) + { + throw new ArgumentException("Parameter 'code' must not be empty.", nameof(arguments)); + } + + return 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 0000000000..4091be40c6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/FileMountHelper.cs @@ -0,0 +1,274 @@ +// 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; + } + catch (UnauthorizedAccessException) + { + 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 0000000000..d5f8cd249c --- /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 0000000000..ee0c69eff7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/ProcessBridge.cs @@ -0,0 +1,392 @@ +// 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 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; + } + + 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 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) + { + 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(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 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 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(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.DeepClone(); + } + + 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 index a715a127ff..2364e3af05 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProvider.cs @@ -1,105 +1,242 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. +// Copyright (c) Microsoft. All rights reserved. +using System; +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 enables local Python CodeAct execution. +/// An that injects a local Python execute_code tool +/// into the agent's tool surface. /// /// /// -/// This provider injects an execute_code tool into the model-facing tool surface. -/// Guest Python code executed via execute_code runs in a subprocess with -/// the configured resource limits and AST validation. +/// 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 considerations: This is NOT a security sandbox. Use only -/// in environments that already provide process, filesystem, and network isolation -/// (e.g., Azure Container Instances, VMs, Foundry hosted agents). +/// 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 { - private const string FixedStateKey = "LocalCodeActProvider"; + /// 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 LocalExecuteCodeFunction _function; + private readonly object _gate = new(); + private readonly LocalCodeActProviderOptions _options; + private readonly CodeExecutor _executor; + + private readonly Dictionary _tools = new(StringComparer.Ordinal); + private readonly Dictionary _fileMounts = new(StringComparer.Ordinal); private bool _disposed; - /// - /// Initializes a new instance of the class. - /// - /// Path to the Python executable (required). - /// Host tools available to generated code. - /// Resource limits for code execution. - /// Environment variables to pass to subprocess. - /// Optional path to bundled Python runner script. - /// Custom allowed imports (replaces defaults). - /// Custom blocked imports (replaces defaults). - /// Custom allowed builtins (replaces defaults). - /// Custom blocked builtins (replaces defaults). - /// File mounts to expose to generated code. - public LocalCodeActProvider( - string pythonExecutablePath, - IEnumerable? tools = null, - ProcessExecutionLimits? executionLimits = null, - IReadOnlyDictionary? environment = null, - string? runnerScript = null, - string[]? allowedImports = null, - string[]? blockedImports = null, - string[]? allowedBuiltins = null, - string[]? blockedBuiltins = null, - IEnumerable? fileMounts = null) + /// Initializes a new instance of the class. + /// Provider configuration. Must specify the Python executable path. + public LocalCodeActProvider(LocalCodeActProviderOptions options) { - ArgumentNullException.ThrowIfNull(pythonExecutablePath); + _ = Throw.IfNull(options); + if (string.IsNullOrWhiteSpace(options.PythonExecutablePath)) + { + throw new ArgumentException("PythonExecutablePath must not be empty.", nameof(options)); + } + + this._options = options; + + var limits = options.ExecutionLimits ?? new ProcessExecutionLimits(); + var runnerScript = options.RunnerScriptPath ?? EmbeddedScripts.GetRunnerScriptPath(); + + CodeValidator? validator = null; + if (options.ValidationEnabled) + { + var validatorScript = options.ValidatorScriptPath ?? EmbeddedScripts.GetValidatorScriptPath(); + validator = new CodeValidator( + options.PythonExecutablePath, + validatorScript, + TimeSpan.FromSeconds(limits.ValidationTimeoutSeconds), + options.AllowedImports?.ToList(), + options.BlockedImports?.ToList(), + options.AllowedBuiltins?.ToList(), + options.BlockedBuiltins?.ToList()); + } - _function = new LocalExecuteCodeFunction( - pythonExecutablePath, - tools, - executionLimits, - environment, + this._executor = new CodeExecutor( + options.PythonExecutablePath, runnerScript, - allowedImports, - blockedImports, - allowedBuiltins, - blockedBuiltins, - fileMounts); + 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); + lock (this._gate) + { + 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() + { + lock (this._gate) + { + return this._tools.Values.ToList(); + } + } + + /// Removes tools by name. + public void RemoveTools(params string[] names) + { + _ = Throw.IfNull(names); + lock (this._gate) + { + foreach (var name in names.Where(n => n is not null)) + { + _ = this._tools.Remove(name); + } + } + } + + /// Removes all registered tools. + public void ClearTools() + { + lock (this._gate) + { + this._tools.Clear(); + } + } + + // ------------------------------------------------------------------- + // File mounts + // ------------------------------------------------------------------- + + /// Adds file mounts. Duplicate mount paths replace existing entries. + public void AddFileMounts(params FileMount[] mounts) + { + _ = Throw.IfNull(mounts); + lock (this._gate) + { + 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() + { + lock (this._gate) + { + return this._fileMounts.Values.ToList(); + } + } + + /// Removes file mounts by mount path. + public void RemoveFileMounts(params string[] mountPaths) + { + _ = Throw.IfNull(mountPaths); + lock (this._gate) + { + foreach (var path in mountPaths.Where(p => p is not null)) + { + _ = this._fileMounts.Remove(path); + } + } + } + + /// Removes all registered file mounts. + public void ClearFileMounts() + { + lock (this._gate) + { + this._fileMounts.Clear(); + } + } + + // ------------------------------------------------------------------- + // AIContextProvider implementation + // ------------------------------------------------------------------- + /// - public override Task ProvideContextAsync( - AIContext context, - CancellationToken cancellationToken = default) + protected override ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) { - ObjectDisposedException.ThrowIf(_disposed, this); + _ = Throw.IfNull(context); - // Add execute_code tool to context - var tools = new List(context.Tools ?? []); - tools.Add(_function); + CodeExecutor.RunSnapshot snapshot; + lock (this._gate) + { + 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); - // Add CodeAct instructions - var instructions = new List(context.Instructions ?? []); - instructions.Add("Use execute_code for Python control flow when it helps."); + var instructions = InstructionBuilder.BuildContextInstructions(); - return Task.FromResult(new AIContext + return new ValueTask(new AIContext { - Tools = tools, Instructions = instructions, + Tools = [executeCode], }); } + private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(this._disposed, this); + /// public void Dispose() { - if (!_disposed) + lock (this._gate) { - _function.Dispose(); - _disposed = true; + 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 0000000000..52f8c3fe3c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProviderOptions.cs @@ -0,0 +1,100 @@ +// 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 +{ + /// + /// Initializes a new instance of the class. + /// + /// Path to the Python interpreter used for execution and validation. + public LocalCodeActProviderOptions(string pythonExecutablePath) + { + Microsoft.Shared.Diagnostics.Throw.IfNull(pythonExecutablePath); + if (string.IsNullOrWhiteSpace(pythonExecutablePath)) + { + throw new System.ArgumentException("Python executable path must not be empty.", nameof(pythonExecutablePath)); + } + + this.PythonExecutablePath = pythonExecutablePath; + } + + /// Gets the path to the Python interpreter used for execution and validation. + public string PythonExecutablePath { get; } + + /// 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 starts with a minimal, non-inherited environment. + /// + 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 enabled. 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 ValidationEnabled { get; set; } = true; + + /// + /// 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 index f67047c7c0..3098afdb27 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalExecuteCodeFunction.cs +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalExecuteCodeFunction.cs @@ -1,20 +1,26 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. +// Copyright (c) Microsoft. All rights reserved. +using System; +using System.Collections.Generic; +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 code locally. +/// Standalone execute_code that runs Python locally in a subprocess. /// /// -/// This function executes LLM-generated Python code in a subprocess. It is intended for -/// environments that already provide process, filesystem, and network isolation (e.g., -/// Azure Container Instances, VMs, Foundry hosted agents). It is NOT a security sandbox. +/// 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, IDisposable +public sealed class LocalExecuteCodeFunction : AIFunction { private const string ExecuteCodeName = "execute_code"; @@ -25,175 +31,89 @@ public sealed class LocalExecuteCodeFunction : AIFunction, IDisposable "properties": { "code": { "type": "string", - "description": "Python code to execute locally in the agent environment." + "description": "Python source code to execute locally in the agent environment." } }, "required": ["code"] } """).RootElement; - private readonly string _pythonExecutable; - private readonly ProcessExecutionLimits _limits; - private readonly Dictionary _environment; - private readonly List _tools; - private readonly string? _runnerScript; - private readonly CodeValidator? _validator; - private readonly List _fileMounts; - private bool _disposed; - - /// - /// Initializes a new instance of the class. - /// - /// Path to the Python executable (required). - /// Host tools available to generated code. - /// Resource limits for code execution. - /// Environment variables to pass to subprocess. - /// Optional path to bundled Python runner script. - /// Custom allowed imports (replaces defaults). - /// Custom blocked imports (replaces defaults). - /// Custom allowed builtins (replaces defaults). - /// Custom blocked builtins (replaces defaults). - /// File mounts to expose to generated code. - public LocalExecuteCodeFunction( - string pythonExecutablePath, - IEnumerable? tools = null, - ProcessExecutionLimits? executionLimits = null, - IReadOnlyDictionary? environment = null, - string? runnerScript = null, - string[]? allowedImports = null, - string[]? blockedImports = null, - string[]? allowedBuiltins = null, - string[]? blockedBuiltins = null, - IEnumerable? fileMounts = null) - { - ArgumentNullException.ThrowIfNull(pythonExecutablePath); - - _pythonExecutable = pythonExecutablePath; - _limits = executionLimits ?? new ProcessExecutionLimits(); - _environment = environment != null ? new Dictionary(environment) : []; - _tools = tools?.Where(t => t != null && t.Metadata.Name != ExecuteCodeName).ToList() ?? []; - _runnerScript = runnerScript; - _fileMounts = fileMounts?.Select(FileMountHelper.NormalizeFileMount).ToList() ?? []; + private readonly CodeExecutor _executor; + private readonly CodeExecutor.RunSnapshot _snapshot; + private readonly string _description; - // Create validator if validation lists are provided - if (allowedImports != null || blockedImports != null || allowedBuiltins != null || blockedBuiltins != null) + /// Initializes a new instance of the class. + /// Configuration including the Python executable path. + public LocalExecuteCodeFunction(LocalCodeActProviderOptions options) + { + _ = Throw.IfNull(options); + if (string.IsNullOrWhiteSpace(options.PythonExecutablePath)) { - _validator = new CodeValidator( - _pythonExecutable, - runnerScript, - allowedImports, - blockedImports, - allowedBuiltins, - blockedBuiltins); + throw new ArgumentException("PythonExecutablePath must not be empty.", nameof(options)); } - Metadata = new AIFunctionMetadata(ExecuteCodeName) - { - Description = BuildDescription(), - Parameters = [new AIFunctionParameterMetadata("code") { ParameterType = typeof(string), IsRequired = true, Schema = s_schema }], - }; - } - - /// - public override AIFunctionMetadata Metadata { get; } - - /// - protected override async Task InvokeCoreAsync( - IEnumerable> arguments, - CancellationToken cancellationToken) - { - ObjectDisposedException.ThrowIf(_disposed, this); - - var code = arguments.FirstOrDefault(a => a.Key == "code").Value as string - ?? throw new ArgumentException("Missing required 'code' parameter."); + var limits = options.ExecutionLimits ?? new ProcessExecutionLimits(); + var runnerScript = options.RunnerScriptPath ?? EmbeddedScripts.GetRunnerScriptPath(); - // Validate code if validator is configured - if (_validator != null) + CodeValidator? validator = null; + if (options.ValidationEnabled) { - await _validator.ValidateAsync(code, cancellationToken).ConfigureAwait(false); + var validatorScript = options.ValidatorScriptPath ?? EmbeddedScripts.GetValidatorScriptPath(); + validator = new CodeValidator( + options.PythonExecutablePath, + validatorScript, + TimeSpan.FromSeconds(limits.ValidationTimeoutSeconds), + options.AllowedImports?.ToList(), + options.BlockedImports?.ToList(), + options.AllowedBuiltins?.ToList(), + options.BlockedBuiltins?.ToList()); } - // Snapshot writable mounts before execution - var preState = FileMountHelper.SnapshotWritableMounts(_fileMounts); - - // Execute code - var bridge = new ProcessBridge( - _tools, - _limits, - _environment, - workingDirectory: null, - _pythonExecutable, - _runnerScript); - - var result = await bridge.RunAsync(code, cancellationToken).ConfigureAwait(false); + 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(); - // Capture written files - var capturedFiles = FileMountHelper.CaptureWrittenFiles(_fileMounts, preState, _limits); + this._executor = new CodeExecutor( + options.PythonExecutablePath, + runnerScript, + validator, + limits, + options.Environment, + options.WorkingDirectory); - // Convert result to content - return BuildExecutionContents(result, capturedFiles); + this._snapshot = new CodeExecutor.RunSnapshot(tools, fileMounts); + this._description = InstructionBuilder.BuildExecuteCodeDescription(tools, fileMounts); } /// - public void Dispose() - { - if (!_disposed) - { - _disposed = true; - } - } + public override string Name => ExecuteCodeName; - private string BuildDescription() - { - var sb = new System.Text.StringBuilder(); - sb.AppendLine("Execute Python code locally in the agent environment."); - - if (_tools.Count > 0) - { - sb.AppendLine(); - sb.AppendLine("Available host tools (call with `await tool_name(...)`):"); - foreach (var tool in _tools) - { - sb.AppendLine($"- {tool.Metadata.Name}: {tool.Metadata.Description ?? "No description"}"); - } - } + /// + public override string Description => this._description; - return sb.ToString(); - } + /// + public override JsonElement JsonSchema => s_schema; - private static List BuildExecutionContents(Dictionary result, List capturedFiles) + /// + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) { - var stdout = result.TryGetValue("stdout", out var so) ? so?.ToString() ?? "" : ""; - var stderr = result.TryGetValue("stderr", out var se) ? se?.ToString() ?? "" : ""; - var outputPresent = result.TryGetValue("output_present", out var op) && Convert.ToBoolean(op); - var output = result.TryGetValue("output", out var o) ? o : null; - - var contents = new List(); - - if (!string.IsNullOrEmpty(stdout)) - { - contents.Add(new TextContent(stdout)); - } - - if (!string.IsNullOrEmpty(stderr)) + if (arguments is null || !arguments.TryGetValue("code", out var codeObj) || codeObj is null) { - contents.Add(new TextContent($"stderr: {stderr}")); + throw new ArgumentException("Missing required parameter 'code'.", nameof(arguments)); } - if (outputPresent && output != null) + var code = codeObj switch { - var serialized = JsonSerializer.Serialize(output); - contents.Add(new TextContent(serialized)); - } - - // Add captured files - contents.AddRange(capturedFiles); + string s => s, + JsonElement { ValueKind: JsonValueKind.String } el => el.GetString() ?? string.Empty, + System.Text.Json.Nodes.JsonValue jv when jv.TryGetValue(out var s2) => s2, + _ => codeObj.ToString() ?? string.Empty, + }; - if (contents.Count == 0) + if (string.IsNullOrWhiteSpace(code)) { - contents.Add(new TextContent("Code executed successfully without output.")); + throw new ArgumentException("Parameter 'code' must not be empty.", nameof(arguments)); } - return [new ChatMessage(ChatRole.Tool, contents)]; + return await this._executor.ExecuteAsync(this._snapshot, code, cancellationToken).ConfigureAwait(false); } } diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/ProcessBridge.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/ProcessBridge.cs deleted file mode 100644 index 874c1c7f04..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/ProcessBridge.cs +++ /dev/null @@ -1,299 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System.Diagnostics; -using System.Text; -using System.Text.Json; -using Microsoft.Agents.AI.Abstractions; - -namespace Microsoft.Agents.AI.LocalCodeAct; - -/// -/// Parent-side bridge for subprocess Python execution with JSON-lines IPC and host-tool dispatch. -/// -internal sealed class ProcessBridge -{ - private readonly Dictionary _tools; - private readonly ProcessExecutionLimits _limits; - private readonly IReadOnlyDictionary _environment; - private readonly string? _workingDirectory; - private readonly string _pythonExecutable; - private readonly string? _runnerScript; - - public ProcessBridge( - IEnumerable tools, - ProcessExecutionLimits limits, - IReadOnlyDictionary environment, - string? workingDirectory, - string pythonExecutable, - string? runnerScript) - { - _tools = tools.ToDictionary(t => t.Metadata.Name, t => t); - _limits = limits; - _environment = environment; - _workingDirectory = workingDirectory; - _pythonExecutable = pythonExecutable; - _runnerScript = runnerScript; - } - - /// - /// Runs generated Python code in a child process with timeout and tool call handling. - /// - public async Task> RunAsync(string code, CancellationToken cancellationToken = default) - { - var startInfo = new ProcessStartInfo - { - FileName = _pythonExecutable, - Arguments = _runnerScript == null ? "-I -m agent_framework_local_codeact._runner" : $"-I \"{_runnerScript}\"", - UseShellExecute = false, - RedirectStandardInput = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - WorkingDirectory = _workingDirectory, - }; - - // Set environment variables - startInfo.Environment.Clear(); - foreach (var (key, value) in _environment) - { - startInfo.Environment[key] = value; - } - - // Add Windows-specific environment variables if needed - if (OperatingSystem.IsWindows()) - { - foreach (var key in new[] { "SYSTEMROOT", "COMSPEC", "PATHEXT" }) - { - var value = Environment.GetEnvironmentVariable(key); - if (value != null && !startInfo.Environment.ContainsKey(key)) - { - startInfo.Environment[key] = value; - } - } - } - - using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start Python process."); - - try - { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - cts.CancelAfter(TimeSpan.FromSeconds(_limits.TimeoutSeconds)); - - return await CommunicateAsync(process, code, cts.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - // Timeout from our CTS - await StopProcessAsync(process).ConfigureAwait(false); - throw new TimeoutException($"Generated code exceeded {_limits.TimeoutSeconds} seconds."); - } - catch - { - await StopProcessAsync(process).ConfigureAwait(false); - throw; - } - } - - private async Task> CommunicateAsync( - Process process, - string code, - CancellationToken cancellationToken) - { - // Send initial request to child process - var request = new Dictionary - { - ["code"] = code, - ["tool_names"] = _tools.Keys.ToList(), - ["max_stdout_bytes"] = _limits.MaxStdoutBytes, - ["max_stderr_bytes"] = _limits.MaxStderrBytes, - }; - - var requestJson = JsonSerializer.Serialize(request, new JsonSerializerOptions { WriteIndented = false }); - await process.StandardInput.WriteLineAsync(requestJson.AsMemory(), cancellationToken).ConfigureAwait(false); - await process.StandardInput.FlushAsync(cancellationToken).ConfigureAwait(false); - - // Process messages from child - while (true) - { - var line = await process.StandardOutput.ReadLineAsync(cancellationToken).ConfigureAwait(false); - if (line == null) - { - var stderr = await ReadStderrAsync(process, cancellationToken).ConfigureAwait(false); - throw new InvalidOperationException($"Local CodeAct subprocess exited without a result. stderr: {stderr}"); - } - - Dictionary? message; - try - { - message = JsonSerializer.Deserialize>(line); - } - catch (JsonException ex) - { - throw new InvalidOperationException($"Failed to parse JSON from subprocess: {line}", ex); - } - - if (message == null) - { - continue; - } - - var messageType = message.TryGetValue("type", out var typeValue) ? typeValue?.ToString() : null; - - if (messageType == "complete") - { - // Execution complete - if (!message.TryGetValue("result", out var resultObj)) - { - throw new InvalidOperationException("Complete message missing 'result' field."); - } - - var result = resultObj as Dictionary ?? new Dictionary(); - CheckResultSize(result); - return result; - } - - if (messageType == "error") - { - var details = message.TryGetValue("traceback", out var tb) ? tb?.ToString() : - message.TryGetValue("message", out var msg) ? msg?.ToString() : "Unknown execution error."; - await StopProcessAsync(process).ConfigureAwait(false); - throw new InvalidOperationException(details); - } - - if (messageType == "tool_call") - { - // Handle tool call - await HandleToolCallAsync(process, message, cancellationToken).ConfigureAwait(false); - } - } - } - - private async Task HandleToolCallAsync( - Process process, - Dictionary message, - CancellationToken cancellationToken) - { - var callId = message.TryGetValue("call_id", out var cid) ? Convert.ToInt32(cid) : 0; - var name = message.TryGetValue("name", out var n) ? n?.ToString() : null; - var kwargsObj = message.TryGetValue("kwargs", out var kw) ? kw : null; - - if (string.IsNullOrEmpty(name)) - { - await SendToolResponseAsync(process, callId, false, null, "MissingToolName", "Tool call missing 'name'.", cancellationToken).ConfigureAwait(false); - return; - } - - if (!_tools.TryGetValue(name, out var tool)) - { - await SendToolResponseAsync(process, callId, false, null, "UnknownTool", $"Unknown tool: {name}", cancellationToken).ConfigureAwait(false); - return; - } - - try - { - // Convert kwargs to JSON element for tool invocation - var kwargsJson = JsonSerializer.Serialize(kwargsObj); - var kwargsElement = JsonSerializer.Deserialize(kwargsJson); - - // Invoke the tool - var result = await tool.InvokeAsync(kwargsElement, cancellationToken).ConfigureAwait(false); - var safeResult = MakeJsonSafe(result); - - await SendToolResponseAsync(process, callId, true, safeResult, null, null, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - await SendToolResponseAsync(process, callId, false, null, ex.GetType().Name, ex.Message, cancellationToken).ConfigureAwait(false); - } - } - - private async Task SendToolResponseAsync( - Process process, - int callId, - bool ok, - object? result, - string? excType, - string? message, - CancellationToken cancellationToken) - { - var response = new Dictionary - { - ["call_id"] = callId, - ["ok"] = ok, - }; - - if (ok) - { - response["result"] = result; - } - else - { - response["exc_type"] = excType; - response["message"] = message; - } - - var responseJson = JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = false }); - await process.StandardInput.WriteLineAsync(responseJson.AsMemory(), cancellationToken).ConfigureAwait(false); - await process.StandardInput.FlushAsync(cancellationToken).ConfigureAwait(false); - } - - private async Task ReadStderrAsync(Process process, CancellationToken cancellationToken) - { - var stderr = new StringBuilder(); - var buffer = new char[4096]; - int bytesRead = 0; - int totalBytes = 0; - - while (totalBytes < _limits.MaxStderrBytes && - (bytesRead = await process.StandardError.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0) - { - stderr.Append(buffer, 0, Math.Min(bytesRead, _limits.MaxStderrBytes - totalBytes)); - totalBytes += bytesRead; - } - - return stderr.ToString(); - } - - private static async Task StopProcessAsync(Process process) - { - try - { - process.Kill(entireProcessTree: true); - await process.WaitForExitAsync().ConfigureAwait(false); - } - catch - { - // Ignore errors during cleanup - } - } - - private void CheckResultSize(Dictionary result) - { - var encoded = JsonSerializer.SerializeToUtf8Bytes(result, new JsonSerializerOptions { WriteIndented = false }); - if (encoded.Length > _limits.MaxStdoutBytes) // Reusing MaxStdoutBytes as max result bytes - { - throw new InvalidOperationException("Generated code result exceeded max size."); - } - } - - private static object? MakeJsonSafe(object? value) - { - if (value == null) - { - return null; - } - - try - { - // Test if it's JSON-serializable - _ = JsonSerializer.Serialize(value); - return value; - } - catch - { - // Convert to string representation if not serializable - return value.ToString(); - } - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/ProcessExecutionLimits.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/ProcessExecutionLimits.cs index 93732378e7..1443cdb3d2 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/ProcessExecutionLimits.cs +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/ProcessExecutionLimits.cs @@ -1,5 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. +// Copyright (c) Microsoft. All rights reserved. namespace Microsoft.Agents.AI.LocalCodeAct; @@ -9,32 +8,28 @@ namespace Microsoft.Agents.AI.LocalCodeAct; /// /// 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 (e.g., Foundry hosted agents, Docker, Azure Container Instances, etc.). +/// isolation (for example, Foundry hosted agents, Docker, or Azure Container Instances). /// -public sealed record ProcessExecutionLimits +public sealed class ProcessExecutionLimits { - /// - /// Maximum execution time in seconds. Default is 30 seconds. - /// - public int TimeoutSeconds { get; init; } = 30; - - /// - /// Maximum bytes of stdout captured. Default is 10MB. - /// - public int MaxStdoutBytes { get; init; } = 10 * 1024 * 1024; - - /// - /// Maximum bytes of stderr captured. Default is 10MB. - /// - public int MaxStderrBytes { get; init; } = 10 * 1024 * 1024; - - /// - /// Maximum bytes written per file in read-write mounts. Default is 1MB. - /// - public int MaxFileBytesPerFile { get; init; } = 1024 * 1024; - - /// - /// Maximum total bytes written across all read-write mounts. Default is 10MB. - /// - public int MaxFileBytesTotal { get; init; } = 10 * 1024 * 1024; + /// 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 index 16c52629ff..395d28b873 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/README.md +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/README.md @@ -27,40 +27,35 @@ This is a preview package. using Microsoft.Agents.AI; using Microsoft.Agents.AI.LocalCodeAct; -var agent = new Agent +var options = new LocalCodeActProviderOptions("/usr/bin/python3") { - Client = ..., - Instructions = "Use execute_code for Python control flow when it helps.", - ContextProviders = - [ - new LocalCodeActProvider - { - PythonExecutablePath = "/usr/bin/python3", // Required in .NET - ExecutionLimits = new ProcessExecutionLimits { TimeoutSeconds = 5 }, - } - ], + ExecutionLimits = new ProcessExecutionLimits { TimeoutSeconds = 5 }, }; + +using var provider = new LocalCodeActProvider(options); + +// Register provider with your AIAgent's context providers. ``` ## What the Package Controls -- **AST validation**: Validates generated code against allow-lists (allowed imports, - built ins, and operations) before execution. +- **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**: Requires `PythonExecutablePath` (no default). +- **Explicit Python path**: `PythonExecutablePath` is required (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 code-size, timeout, stdout, stderr, and - result-size limits. -- **Tool gating**: Allows only provider-owned host tools to be called from - generated code. -- **File capture**: Captures new or modified files under configured writable - mounts while skipping symlinks. +- **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`, -etc.) but does not make Python execution safe on an unsandboxed host. +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 @@ -75,105 +70,120 @@ infrastructure as the actual security boundary.** ## Host Tools -Register host tools on the provider. Generated code calls them: +Register host tools via the options or on the provider directly: ```csharp -Task AddAsync(int a, int b) => Task.FromResult(a + b); +var addFunction = AIFunctionFactory.Create( + (int a, int b) => a + b, + name: "add", + description: "Adds two integers."); -var provider = new LocalCodeActProvider +using var provider = new LocalCodeActProvider(new LocalCodeActProviderOptions("/usr/bin/python3") { - PythonExecutablePath = "/usr/bin/python3", - Tools = [Tool.From(AddAsync)], -}; + Tools = new[] { addFunction }, +}); + +// Or mutate after construction: +provider.AddTools(addFunction); ``` Inside `execute_code`: ```python -result = await add(a=2, b=3) -print(result) +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: +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`, etc. -- **Blocked imports**: `os`, `subprocess`, `sys`, `importlib`, network, etc. +- **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`, etc. +- **Blocked builtins**: `eval`, `exec`, `compile`, `__import__`, `open`, + `getattr`, `setattr`, etc. -See the Python implementation for the full default lists. +See [`Resources/validator.py`](Resources/validator.py) for the full default +allow-lists. ### Customizing Validation Override the default lists: ```csharp -var provider = new LocalCodeActProvider +using var provider = new LocalCodeActProvider(new LocalCodeActProviderOptions("/usr/bin/python3") { - Python ExecutablePath = "/usr/bin/python3", - AllowedImports = ["math", "datetime", "mymodule"], - BlockedImports = ["os", "subprocess", "sys"], - AllowedBuiltins = ["print", "len", "str", "int"], - BlockedBuiltins = ["eval", "exec", "compile"], -}; + 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 `ValidationEnabled = false` 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 or files: +Mount host directories to expose them to generated code: ```csharp -var provider = new LocalCodeActProvider +using var provider = new LocalCodeActProvider(new LocalCodeActProviderOptions("/usr/bin/python3") { - PythonExecutablePath = "/usr/bin/python3", - FileMounts = - [ - new FileMount - { - HostPath = "/tmp/data", - MountPath = "/input", - Mode = FileMountMode.ReadOnly, - }, - new FileMount - { - HostPath = "/tmp/output", - MountPath = "/output", - Mode = FileMountMode.ReadWrite, - }, - ], -}; + FileMounts = new[] + { + new FileMount("/tmp/data", "/input", FileMountMode.ReadOnly), + new FileMount("/tmp/output", "/output", FileMountMode.ReadWrite), + }, +}); ``` -Generated code accesses mounts via `HostPath`. `MountPath` is metadata only. -Read-write mounts are scanned for new/modified files after execution, and those -files are returned as data content. +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: +Pass environment variables explicitly. The subprocess does NOT inherit the host +environment by default: ```csharp -var provider = new LocalCodeActProvider +using var provider = new LocalCodeActProvider(new LocalCodeActProviderOptions("/usr/bin/python3") { - PythonExecutablePath = "/usr/bin/python3", 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(new LocalCodeActProviderOptions("/usr/bin/python3")); ``` -The subprocess does NOT inherit the host environment by default. +`LocalExecuteCodeFunction` snapshots tools and mounts at construction time and +is safe to reuse across invocations. ## Execution Modes -The .NET implementation only supports `Subprocess` mode (Python execution in a -child process). There is no "unsafe in-process" mode in .NET. +The .NET implementation only supports subprocess execution. There is no +"unsafe in-process" mode in .NET. ## License diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/validator.py b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/validator.py index b3fa811753..f4386d93b8 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/validator.py +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/validator.py @@ -221,21 +221,17 @@ 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, } +# Match statements (Python 3.10+) - added conditionally for older Python compatibility. +for _name in ("Match", "match_case", "MatchValue", "MatchSingleton", "MatchSequence", + "MatchMapping", "MatchClass", "MatchStar", "MatchAs", "MatchOr"): + _node = getattr(ast, _name, None) + if _node is not None: + ALLOWED_AST_NODES.add(_node) + class CodeValidationError(ValueError): """Raised when generated code violates the allow-list policy.""" @@ -454,3 +450,69 @@ def validate_code( blocked_builtins=blocked_builtins, ) 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": [...]? + } + + 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")), + ) + 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/FileMountTests.cs b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/FileMountTests.cs index 75c53b39af..887950ebf3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/FileMountTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/FileMountTests.cs @@ -1,48 +1,53 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. +// Copyright (c) Microsoft. All rights reserved. -using Xunit; +using System; +using Microsoft.Agents.AI.LocalCodeAct; namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests; -/// -/// Tests for FileMount record. -/// public sealed class FileMountTests { [Fact] - public void Constructor_WithRequiredProperties_Succeeds() + public void Constructor_AssignsProperties() { - // Arrange & Act - var mount = new FileMount + var tempDir = System.IO.Directory.CreateTempSubdirectory("filemount-test-").FullName; + try { - HostPath = "/tmp/data", - MountPath = "/input", - }; + var mount = new FileMount(tempDir, "/app/data", FileMountMode.ReadWrite, writeBytesLimit: 1024); - // Assert - Assert.Equal("/tmp/data", mount.HostPath); - Assert.Equal("/input", mount.MountPath); - Assert.Equal(FileMountMode.ReadWrite, mount.Mode); - Assert.Null(mount.WriteBytesLimit); + 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 CustomValues_CanBeSet() + public void Constructor_DefaultsAreReadWriteWithNoLimit() { - // Arrange & Act - var mount = new FileMount + 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 { - HostPath = "/data", - MountPath = "/readonly", - Mode = FileMountMode.ReadOnly, - WriteBytesLimit = 1024, - }; + System.IO.Directory.Delete(tempDir, recursive: true); + } + } - // Assert - Assert.Equal("/data", mount.HostPath); - Assert.Equal("/readonly", mount.MountPath); - Assert.Equal(FileMountMode.ReadOnly, mount.Mode); - Assert.Equal(1024, mount.WriteBytesLimit); + [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 0000000000..7761342c34 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/InstructionBuilderTests.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Agents.AI.LocalCodeAct; +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 FileMount("/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/IntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/IntegrationTests.cs deleted file mode 100644 index a2c82a9ea3..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/IntegrationTests.cs +++ /dev/null @@ -1,335 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.AI; -using Xunit; - -namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests; - -public class IntegrationTests : IDisposable -{ - private readonly string _testDir; - private readonly string? _pythonPath; - - public IntegrationTests() - { - _testDir = Path.Combine(Path.GetTempPath(), $"localcodeact_test_{Guid.NewGuid():N}"); - Directory.CreateDirectory(_testDir); - - // Try to find Python - _pythonPath = FindPythonExecutable(); - } - - public void Dispose() - { - if (Directory.Exists(_testDir)) - { - try - { - Directory.Delete(_testDir, recursive: true); - } - catch - { - // Best effort cleanup - } - } - } - - private static string? FindPythonExecutable() - { - var candidates = new[] { "python3", "python", "python.exe", "python3.exe" }; - - foreach (var candidate in candidates) - { - try - { - var startInfo = new System.Diagnostics.ProcessStartInfo - { - FileName = candidate, - Arguments = "--version", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using var process = System.Diagnostics.Process.Start(startInfo); - if (process != null) - { - process.WaitForExit(1000); - if (process.ExitCode == 0) - { - return candidate; - } - } - } - catch - { - // Try next candidate - } - } - - return null; - } - - [Fact] - public async Task ExecuteSimpleCode_ReturnsResult() - { - if (_pythonPath == null) - { - // Skip if Python not available - return; - } - - var function = new LocalExecuteCodeFunction( - pythonExecutablePath: _pythonPath, - tools: [], - executionLimits: new ProcessExecutionLimits(TimeoutSeconds: 5) - ); - - var code = "result = 2 + 2"; - var args = JsonSerializer.SerializeToElement(new { code }); - - var result = await function.InvokeAsync(args, CancellationToken.None); - - Assert.NotNull(result); - var textContent = result.OfType().FirstOrDefault(); - Assert.NotNull(textContent); - Assert.Contains("4", textContent!.Text); - } - - [Fact] - public async Task ExecuteCodeWithTimeout_Throws() - { - if (_pythonPath == null) - { - return; - } - - var function = new LocalExecuteCodeFunction( - pythonExecutablePath: _pythonPath, - tools: [], - executionLimits: new ProcessExecutionLimits(TimeoutSeconds: 1) - ); - - var code = "import time\ntime.sleep(10)\nresult = 'done'"; - var args = JsonSerializer.SerializeToElement(new { code }); - - await Assert.ThrowsAnyAsync(async () => - { - await function.InvokeAsync(args, CancellationToken.None); - }); - } - - [Fact] - public async Task ExecuteCodeWithInvalidSyntax_ReturnsError() - { - if (_pythonPath == null) - { - return; - } - - var function = new LocalExecuteCodeFunction( - pythonExecutablePath: _pythonPath, - tools: [], - executionLimits: new ProcessExecutionLimits(TimeoutSeconds: 5) - ); - - var code = "this is not valid python syntax!@#$"; - var args = JsonSerializer.SerializeToElement(new { code }); - - var result = await function.InvokeAsync(args, CancellationToken.None); - - Assert.NotNull(result); - var textContent = result.OfType().FirstOrDefault(); - Assert.NotNull(textContent); - Assert.Contains("SyntaxError", textContent!.Text); - } - - [Fact] - public async Task ExecuteCodeWithBlockedImport_FailsValidation() - { - if (_pythonPath == null) - { - return; - } - - var function = new LocalExecuteCodeFunction( - pythonExecutablePath: _pythonPath, - tools: [], - executionLimits: new ProcessExecutionLimits(TimeoutSeconds: 5) - ); - - var code = "import subprocess\nresult = 'test'"; - var args = JsonSerializer.SerializeToElement(new { code }); - - await Assert.ThrowsAsync(async () => - { - await function.InvokeAsync(args, CancellationToken.None); - }); - } - - [Fact] - public async Task ExecuteCodeWithBlockedBuiltin_FailsValidation() - { - if (_pythonPath == null) - { - return; - } - - var function = new LocalExecuteCodeFunction( - pythonExecutablePath: _pythonPath, - tools: [], - executionLimits: new ProcessExecutionLimits(TimeoutSeconds: 5) - ); - - var code = "result = eval('2 + 2')"; - var args = JsonSerializer.SerializeToElement(new { code }); - - await Assert.ThrowsAsync(async () => - { - await function.InvokeAsync(args, CancellationToken.None); - }); - } - - [Fact] - public async Task ExecuteCodeWithCustomAllowedImports_Succeeds() - { - if (_pythonPath == null) - { - return; - } - - var function = new LocalExecuteCodeFunction( - pythonExecutablePath: _pythonPath, - tools: [], - executionLimits: new ProcessExecutionLimits(TimeoutSeconds: 5), - allowedImports: ["json", "math"] - ); - - var code = "import json\nimport math\nresult = json.dumps({'pi': math.pi})"; - var args = JsonSerializer.SerializeToElement(new { code }); - - var result = await function.InvokeAsync(args, CancellationToken.None); - - Assert.NotNull(result); - var textContent = result.OfType().FirstOrDefault(); - Assert.NotNull(textContent); - Assert.Contains("3.14", textContent!.Text); - } - - [Fact] - public async Task ExecuteCodeWithFileMounts_CapturesWrittenFiles() - { - if (_pythonPath == null) - { - return; - } - - var inputDir = Path.Combine(_testDir, "input"); - var outputDir = Path.Combine(_testDir, "output"); - Directory.CreateDirectory(inputDir); - Directory.CreateDirectory(outputDir); - - // Create input file - var inputFile = Path.Combine(inputDir, "test.txt"); - File.WriteAllText(inputFile, "Hello from input"); - - var fileMounts = new List - { - new(inputDir, "/input", FileMountMode.ReadOnly, null), - new(outputDir, "/output", FileMountMode.ReadWrite, null) - }; - - var function = new LocalExecuteCodeFunction( - pythonExecutablePath: _pythonPath, - tools: [], - executionLimits: new ProcessExecutionLimits(TimeoutSeconds: 5), - fileMounts: fileMounts - ); - - var code = @" -import os -# Read from input -with open('/input/test.txt', 'r') as f: - content = f.read() -# Write to output -with open('/output/result.txt', 'w') as f: - f.write(f'Processed: {content}') -result = 'Files processed' -"; - var args = JsonSerializer.SerializeToElement(new { code }); - - var result = await function.InvokeAsync(args, CancellationToken.None); - - Assert.NotNull(result); - - // Check that output file was captured - var dataContent = result.OfType().FirstOrDefault(); - Assert.NotNull(dataContent); - - // Verify file content - var outputFile = Path.Combine(outputDir, "result.txt"); - Assert.True(File.Exists(outputFile)); - var outputContent = File.ReadAllText(outputFile); - Assert.Contains("Processed: Hello from input", outputContent); - } - - [Fact] - public async Task ExecuteCodeWithStdout_CapturesOutput() - { - if (_pythonPath == null) - { - return; - } - - var function = new LocalExecuteCodeFunction( - pythonExecutablePath: _pythonPath, - tools: [], - executionLimits: new ProcessExecutionLimits(TimeoutSeconds: 5) - ); - - var code = @" -print('Hello from stdout') -print('Line 2') -result = 'Done' -"; - var args = JsonSerializer.SerializeToElement(new { code }); - - var result = await function.InvokeAsync(args, CancellationToken.None); - - Assert.NotNull(result); - var textContent = result.OfType().FirstOrDefault(); - Assert.NotNull(textContent); - Assert.Contains("Hello from stdout", textContent!.Text); - Assert.Contains("Line 2", textContent.Text); - } - - [Fact] - public async Task Provider_InjectsToolIntoContext() - { - if (_pythonPath == null) - { - return; - } - - var provider = new LocalCodeActProvider( - pythonExecutablePath: _pythonPath, - executionLimits: new ProcessExecutionLimits(TimeoutSeconds: 5) - ); - - var options = new ChatOptions(); - await provider.ProvideContextAsync(null!, options, CancellationToken.None); - - Assert.NotNull(options.Tools); - Assert.Single(options.Tools); - Assert.Equal("execute_code", options.Tools[0].Metadata.Name); - } -} 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 0000000000..5f85c1ca45 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalCodeActProviderOptionsTests.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Agents.AI.LocalCodeAct; + +namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests; + +public sealed class LocalCodeActProviderOptionsTests +{ + [Fact] + public void Constructor_RequiresPythonExecutablePath() + { + Assert.Throws(() => new LocalCodeActProviderOptions("")); + Assert.Throws(() => new LocalCodeActProviderOptions(" ")); + _ = Assert.Throws(() => new LocalCodeActProviderOptions(null!)); + } + + [Fact] + public void Constructor_AssignsPythonExecutablePath() + { + var options = new LocalCodeActProviderOptions("/usr/bin/python3"); + Assert.Equal("/usr/bin/python3", options.PythonExecutablePath); + } + + [Fact] + public void ValidationEnabled_DefaultsToTrue() + { + var options = new LocalCodeActProviderOptions("/usr/bin/python3"); + Assert.True(options.ValidationEnabled); + } +} 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 0000000000..f6b7cf5826 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalCodeActProviderTests.cs @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.AI.LocalCodeAct; +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("/usr/bin/python3") + { + ValidationEnabled = false, // No subprocess will be launched in these tests + }; + + [Fact] + public async Task ProvideAIContextAsync_ReturnsExecuteCodeToolAndInstructionsAsync() + { + using var provider = new LocalCodeActProvider(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(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(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(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 System.Threading.Tasks.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 0000000000..322d64a30f --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionIntegrationTests.cs @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.LocalCodeAct; +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(new LocalCodeActProviderOptions(s_python!)); + + var args = new AIFunctionArguments + { + ["code"] = "print('hello world')\n1 + 2", + }; + + var result = await function.InvokeAsync(args, CancellationToken.None); + + Assert.NotNull(result); + var contents = Assert.IsAssignableFrom>(result); + var combined = string.Join("\n", contents.OfType().Select(t => t.Text)); + Assert.Contains("hello world", combined); + Assert.Contains("3", combined); + } + + [Fact] + public async Task ExecuteCode_ValidationBlocksDisallowedImportAsync() + { + SkipIfNoPython(); + + var function = new LocalExecuteCodeFunction(new LocalCodeActProviderOptions(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(s_python!) + { + FileMounts = new[] + { + new FileMount(hostDir, "/output", FileMountMode.ReadWrite), + }, + }; + + var function = new LocalExecuteCodeFunction(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); + var contents = Assert.IsAssignableFrom>(result); + Assert.Contains(contents, c => c is DataContent); + } + finally + { + Directory.Delete(hostDir, recursive: true); + } + } + + private static string? FindPython() + { + foreach (var name in new[] { "python3", "python" }) + { + 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)) + { + return candidate; + } + } + } + + return null; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionTests.cs b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionTests.cs deleted file mode 100644 index 7b97df7945..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionTests.cs +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using Xunit; - -namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests; - -/// -/// Basic tests for LocalExecuteCodeFunction. -/// -public sealed class LocalExecuteCodeFunctionTests -{ - [Fact] - public void Constructor_WithValidPath_Succeeds() - { - // Arrange & Act - var function = new LocalExecuteCodeFunction("/usr/bin/python3"); - - // Assert - Assert.NotNull(function); - Assert.Equal("execute_code", function.Metadata.Name); - Assert.NotNull(function.Metadata.Description); - } - - [Fact] - public void Constructor_WithNullPath_ThrowsArgumentNullException() - { - // Act & Assert - Assert.Throws(() => new LocalExecuteCodeFunction(null!)); - } - - [Fact] - public void Metadata_HasRequiredCodeParameter() - { - // Arrange - var function = new LocalExecuteCodeFunction("/usr/bin/python3"); - - // Act - var parameters = function.Metadata.Parameters; - - // Assert - Assert.NotNull(parameters); - Assert.Single(parameters); - Assert.Equal("code", parameters[0].Name); - Assert.True(parameters[0].IsRequired); - } - - [Fact] - public void Dispose_CanBeCalledMultipleTimes() - { - // Arrange - var function = new LocalExecuteCodeFunction("/usr/bin/python3"); - - // Act & Assert - function.Dispose(); - function.Dispose(); // Should not throw - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/ProcessExecutionLimitsTests.cs b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/ProcessExecutionLimitsTests.cs index 3ddbe7f5a2..6c13846bf1 100644 --- a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/ProcessExecutionLimitsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/ProcessExecutionLimitsTests.cs @@ -1,47 +1,40 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. +// Copyright (c) Microsoft. All rights reserved. -using Xunit; +using System; +using Microsoft.Agents.AI.LocalCodeAct; namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests; -/// -/// Tests for ProcessExecutionLimits record. -/// public sealed class ProcessExecutionLimitsTests { [Fact] - public void DefaultValues_AreSet() + public void Defaults_AreReasonable() { - // Arrange & Act var limits = new ProcessExecutionLimits(); - // Assert - Assert.Equal(30, limits.TimeoutSeconds); - Assert.Equal(10 * 1024 * 1024, limits.MaxStdoutBytes); - Assert.Equal(10 * 1024 * 1024, limits.MaxStderrBytes); - Assert.Equal(1024 * 1024, limits.MaxFileBytesPerFile); - Assert.Equal(10 * 1024 * 1024, limits.MaxFileBytesTotal); + 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 CustomValues_CanBeSet() + public void Properties_AreMutable() { - // Arrange & Act var limits = new ProcessExecutionLimits { - TimeoutSeconds = 5, + TimeoutSeconds = 60, MaxStdoutBytes = 1024, MaxStderrBytes = 512, - MaxFileBytesPerFile = 256, - MaxFileBytesTotal = 2048, + ValidationTimeoutSeconds = 5, + MaxResultBytes = 2048, }; - // Assert - Assert.Equal(5, limits.TimeoutSeconds); + Assert.Equal(60, limits.TimeoutSeconds); Assert.Equal(1024, limits.MaxStdoutBytes); Assert.Equal(512, limits.MaxStderrBytes); - Assert.Equal(256, limits.MaxFileBytesPerFile); - Assert.Equal(2048, limits.MaxFileBytesTotal); + Assert.Equal(5, limits.ValidationTimeoutSeconds); + Assert.Equal(2048, limits.MaxResultBytes); } } From cb11573b92a2db523fcb30ad204e4416ed93e511 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 27 May 2026 14:46:39 +0200 Subject: [PATCH 10/15] Sync embedded validator.py with Python package allow-list enforcement The embedded Python validator script used by the .NET LocalCodeAct package now enforces the builtin allow-list, matching the latest behavior of agent_framework_local_codeact._validator. Names that are real Python builtins must appear in the allow-list, while unknown names (user-defined functions, registered tools) remain allowed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Resources/validator.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/validator.py b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/validator.py index f4386d93b8..40abbf038b 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/validator.py +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/validator.py @@ -5,8 +5,11 @@ 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", @@ -306,18 +309,17 @@ def visit_ImportFrom(self, node: ast.ImportFrom) -> None: def visit_Call(self, node: ast.Call) -> None: """Validate function calls. - Note: We only validate calls to known builtins against the block-list. - Calls to user-defined functions and registered tools are allowed (validated at runtime). - The allowed_builtins parameter exists for customization but does not enforce - an allow-list by default to permit user code and tools. + 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). """ - # Check for blocked builtins 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") - # Note: We don't enforce allowed_builtins for Names to allow user-defined - # functions and registered tools. Custom blocked_builtins can restrict specific names. + 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): From a84440884ddab4e7afa6a1307633ed5d7155b4bd Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 28 May 2026 09:41:16 +0200 Subject: [PATCH 11/15] Add Hosted-LocalCodeAct foundry hosted-agent sample Mirrors the Python foundry_hosted_agent.py sample for the local-codeact package: registers compute and fetch_data as sandbox-only host tools on LocalCodeActProvider so the model only sees execute_code and reaches them via await call_tool(...). Includes the standard hosted-agent supporting files (agent.yaml, agent.manifest.yaml, Dockerfile, Dockerfile.contributor, .env.example, README.md) and installs python3 in the container images so the embedded runner and validator can execute. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Hosted-LocalCodeAct/.env.example | 6 + .../responses/Hosted-LocalCodeAct/Dockerfile | 23 +++ .../Dockerfile.contributor | 24 +++ .../HostedLocalCodeAct.csproj | 35 +++++ .../responses/Hosted-LocalCodeAct/Program.cs | 121 +++++++++++++++ .../responses/Hosted-LocalCodeAct/README.md | 141 ++++++++++++++++++ .../Hosted-LocalCodeAct/agent.manifest.yaml | 30 ++++ .../responses/Hosted-LocalCodeAct/agent.yaml | 9 ++ 8 files changed, 389 insertions(+) create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/.env.example create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/Dockerfile create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/Dockerfile.contributor create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/HostedLocalCodeAct.csproj create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/Program.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/README.md create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/agent.manifest.yaml create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/agent.yaml 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 0000000000..533791b2a5 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/.env.example @@ -0,0 +1,6 @@ +AZURE_AI_PROJECT_ENDPOINT= +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development +AZURE_AI_MODEL_DEPLOYMENT_NAME=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 0000000000..1d201d0472 --- /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 0000000000..c27fb0ab41 --- /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 0000000000..74d876b33d --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/HostedLocalCodeAct.csproj @@ -0,0 +1,35 @@ + + + + net10.0 + enable + enable + false + HostedLocalCodeAct + HostedLocalCodeAct + $(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 0000000000..44c31c7b6f --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/Program.cs @@ -0,0 +1,121 @@ +// 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; +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("AZURE_AI_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "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(pythonExecutable) +{ + Tools = + [ + AIFunctionFactory.Create(Compute, name: "compute"), + AIFunctionFactory.Create(FetchData, name: "fetch_data"), + ], + ExecutionLimits = new ProcessExecutionLimits { TimeoutSeconds = 5 }, +}; + +var codeAct = new LocalCodeActProvider(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 0000000000..8ad2663de1 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/README.md @@ -0,0 +1,141 @@ +# 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 +AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development +AZURE_AI_MODEL_DEPLOYMENT_NAME=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. + +## 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 0000000000..f00aedf60e --- /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 0000000000..cdf4b5c9a1 --- /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 From 1c4af5227ae61bf310233aec343aa7beeb0ffc78 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 28 May 2026 10:09:41 +0200 Subject: [PATCH 12/15] fix(local-codeact-dotnet): sync validator os.* allow-list with Python Mirror the Python package change: the embedded validator.py invoked by the .NET ProcessBridge replaces the os.* deny-list with an allow-list of {environ, path}. Add allowed_os_attrs parameter to validate_code and _CodeValidator, and surface it via the stdin JSON request schema so the .NET host can opt in to a broader allow-list when needed. Default behavior tightens to match the documented contract: any os.* attribute outside {environ, path} (for example os.listdir, os.open, os.getcwd) is rejected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Resources/validator.py | 74 +++++-------------- 1 file changed, 17 insertions(+), 57 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/validator.py b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/validator.py index 40abbf038b..e512287c32 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/validator.py +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/validator.py @@ -54,6 +54,12 @@ "__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). @@ -252,6 +258,7 @@ def __init__( 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] = [] @@ -259,6 +266,7 @@ def __init__( 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.""" @@ -337,62 +345,8 @@ def visit_Call(self, node: ast.Call) -> None: def visit_Attribute(self, node: ast.Attribute) -> None: """Validate attribute access.""" # Check for dangerous os module operations - if isinstance(node.value, ast.Name) and node.value.id == "os": - # Block dangerous os operations - dangerous_os_attrs = { - "system", - "exec", - "execl", - "execle", - "execlp", - "execlpe", - "execv", - "execve", - "execvp", - "execvpe", - "spawn", - "spawnl", - "spawnle", - "spawnlp", - "spawnlpe", - "spawnv", - "spawnve", - "spawnvp", - "spawnvpe", - "popen", - "popen2", - "popen3", - "popen4", - "fork", - "forkpty", - "kill", - "killpg", - "abort", - "chdir", - "fchdir", - "chroot", - "chmod", - "chown", - "lchown", - "fchmod", - "fchown", - "remove", - "unlink", - "rmdir", - "removedirs", - "rename", - "renames", - "replace", - "link", - "symlink", - "mkdir", - "makedirs", - "access", - "putenv", - "unsetenv", - } - if node.attr in dangerous_os_attrs: - self._errors.append(f"Access to os.{node.attr} is not allowed") + 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 ( @@ -432,6 +386,7 @@ def validate_code( 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. @@ -441,6 +396,8 @@ def validate_code( 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. @@ -450,6 +407,7 @@ def validate_code( blocked_imports=blocked_imports, allowed_builtins=allowed_builtins, blocked_builtins=blocked_builtins, + allowed_os_attrs=allowed_os_attrs, ) validator.validate(code) @@ -463,7 +421,8 @@ def _main() -> int: "allowed_imports": [...]?, "blocked_imports": [...]?, "allowed_builtins": [...]?, - "blocked_builtins": [...]? + "blocked_builtins": [...]?, + "allowed_os_attrs": [...]? } On success: exit code 0, no output required. @@ -499,6 +458,7 @@ def _as_set(value: Any) -> set[str] | None: 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) From e69cdcc90b8e6086d1028b6b42c08d6ce305380d Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 28 May 2026 17:06:08 +0200 Subject: [PATCH 13/15] fix(local-codeact-dotnet): address review + tighten validator - validator.py: enforce os.* allow-list on `from os import X` so names like `system`, `getcwd` cannot bypass the visit_Attribute restriction. - ProcessBridge.ConfigureEnvironment: document that null Environment inherits the parent env (matching real behavior) and update the public LocalCodeActProviderOptions.Environment doc to describe the explicit empty-dictionary opt-in for a scrubbed environment. - Tests: * FileMountHelperTests covers per-file, per-mount, and total capture-limit branches that return TextContent omissions. * Integration tests cover unknown-tool dispatch error, tool throwing exception, and CodeValidator timeout that kills the process and raises CodeValidationException. - Sample: drop unused `Microsoft.Agents.AI.Foundry` using in Hosted-LocalCodeAct/Program.cs to satisfy IDE0005 check-format. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../responses/Hosted-LocalCodeAct/Program.cs | 1 - .../Internal/ProcessBridge.cs | 3 + .../LocalCodeActProviderOptions.cs | 12 +- .../Resources/validator.py | 6 + .../FileMountHelperTests.cs | 104 ++++++++++++++++++ ...ocalExecuteCodeFunctionIntegrationTests.cs | 96 ++++++++++++++++ 6 files changed, 219 insertions(+), 3 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/FileMountHelperTests.cs diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/Program.cs index 44c31c7b6f..b25cf178d2 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/Program.cs @@ -17,7 +17,6 @@ using DotNetEnv; using Hosted_Shared_Contributor_Setup; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.Foundry; using Microsoft.Agents.AI.Foundry.Hosting; using Microsoft.Agents.AI.LocalCodeAct; using Microsoft.Extensions.AI; diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/ProcessBridge.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/ProcessBridge.cs index ee0c69eff7..8c0047bb03 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/ProcessBridge.cs +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/ProcessBridge.cs @@ -106,6 +106,9 @@ public async Task RunAsync(string code, CancellationToken cance private void ConfigureEnvironment(ProcessStartInfo startInfo) { + // Null => inherit the parent environment (documented contract on + // LocalCodeActProviderOptions.Environment). Callers wanting a scrubbed + // environment pass an empty dictionary. if (this._environment is null) { return; diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProviderOptions.cs index 52f8c3fe3c..eb3359f979 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProviderOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProviderOptions.cs @@ -42,9 +42,17 @@ public LocalCodeActProviderOptions(string pythonExecutablePath) public IEnumerable? FileMounts { get; set; } /// - /// Gets or sets environment variables passed to the subprocess. When - /// the subprocess starts with a minimal, non-inherited environment. + /// 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; } /// diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/validator.py b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/validator.py index e512287c32..0587cc175f 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/validator.py +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/validator.py @@ -312,6 +312,12 @@ def visit_ImportFrom(self, node: ast.ImportFrom) -> None: 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: 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 0000000000..81ecfc4c3f --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/FileMountHelperTests.cs @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.Agents.AI.LocalCodeAct; +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/LocalExecuteCodeFunctionIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionIntegrationTests.cs index 322d64a30f..7da519be15 100644 --- a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionIntegrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionIntegrationTests.cs @@ -100,6 +100,102 @@ public async Task ExecuteCode_CapturesFilesInWritableMountAsync() } } + [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(new LocalCodeActProviderOptions(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 contents = Assert.IsAssignableFrom>(result); + var combined = string.Join("\n", contents.OfType().Select(t => t.Text)); + 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. + var faultyTool = AIFunctionFactory.Create( + (string message) => throw new InvalidOperationException("intentional: " + message), + name: "faulty"); + + var options = new LocalCodeActProviderOptions(s_python!) + { + Tools = new[] { faultyTool }, + }; + var function = new LocalExecuteCodeFunction(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 contents = Assert.IsAssignableFrom>(result); + var combined = string.Join("\n", contents.OfType().Select(t => t.Text)); + 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? FindPython() { foreach (var name in new[] { "python3", "python" }) From df1e512767757a316dfb68ae2f5c61dcbdbef150 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 28 May 2026 17:14:17 +0200 Subject: [PATCH 14/15] chore(local-codeact-dotnet): remove stale orphan sample The dotnet/samples/LocalCodeAct/ scaffolding sample referenced APIs that don't exist in the current package (`ExecutionMode`, FileMount object-initializer syntax, the old LocalExecuteCodeFunction constructor signature, function.Metadata.*), produced a long list of check-format violations (CHARSET, IMPORTS, IDE0073 header, IDE0005 unused using, IDE1006 Async suffix, RCS1037 trailing whitespace), and did not match any of the documented sample layouts. The hosted-agent example at dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct is the supported entry-point sample for this package. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../samples/LocalCodeAct/LocalCodeAct.csproj | 14 ---- dotnet/samples/LocalCodeAct/Program.cs | 81 ------------------- dotnet/samples/LocalCodeAct/README.md | 55 ------------- 3 files changed, 150 deletions(-) delete mode 100644 dotnet/samples/LocalCodeAct/LocalCodeAct.csproj delete mode 100644 dotnet/samples/LocalCodeAct/Program.cs delete mode 100644 dotnet/samples/LocalCodeAct/README.md diff --git a/dotnet/samples/LocalCodeAct/LocalCodeAct.csproj b/dotnet/samples/LocalCodeAct/LocalCodeAct.csproj deleted file mode 100644 index 2a69ed4110..0000000000 --- a/dotnet/samples/LocalCodeAct/LocalCodeAct.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - - $(TargetFrameworksCore) - Exe - false - - - - - - - - diff --git a/dotnet/samples/LocalCodeAct/Program.cs b/dotnet/samples/LocalCodeAct/Program.cs deleted file mode 100644 index c05db02db4..0000000000 --- a/dotnet/samples/LocalCodeAct/Program.cs +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using Microsoft.Extensions.AI; -using Microsoft.Agents.AI.LocalCodeAct; - -/// -/// This sample demonstrates using LocalCodeActProvider with a local agent. -/// -/// Prerequisites: -/// - Python 3.10+ installed at /usr/bin/python3 (or adjust the path) -/// - This sample is intended for containerized or VM environments with proper isolation -/// -/// WARNING: This executes LLM-generated Python code. Do NOT run on developer workstations -/// or production hosts without external sandboxing (containers, VMs, etc.). -/// -internal static class Program -{ - private static async Task Main() - { - Console.WriteLine("LocalCodeAct Sample"); - Console.WriteLine("==================="); - Console.WriteLine(); - Console.WriteLine("This sample shows LocalCodeActProvider usage."); - Console.WriteLine("NOTE: Requires Python 3.10+ and external sandboxing for safety."); - Console.WriteLine(); - - // Example 1: Create a provider with default settings - using var provider = new LocalCodeActProvider( - pythonExecutablePath: "/usr/bin/python3", - executionLimits: new ProcessExecutionLimits - { - TimeoutSeconds = 5, - }); - - Console.WriteLine("✓ Created LocalCodeActProvider"); - Console.WriteLine($" State Keys: {string.Join(", ", provider.StateKeys)}"); - Console.WriteLine(); - - // Example 2: Create an execute_code function directly - using var function = new LocalExecuteCodeFunction( - pythonExecutablePath: "/usr/bin/python3", - executionLimits: new ProcessExecutionLimits - { - TimeoutSeconds = 10, - }); - - Console.WriteLine("✓ Created LocalExecuteCodeFunction"); - Console.WriteLine($" Name: {function.Metadata.Name}"); - Console.WriteLine($" Description: {function.Metadata.Description}"); - Console.WriteLine($" Parameters: {function.Metadata.Parameters?.Count ?? 0}"); - Console.WriteLine(); - - // Example 3: Show execution modes - var mode = ExecutionMode.Subprocess; - Console.WriteLine($"✓ Execution Mode: {mode}"); - Console.WriteLine(" (Subprocess is the only mode in .NET)"); - Console.WriteLine(); - - // Example 4: Show file mount configuration - var mount = new FileMount - { - HostPath = "/tmp/data", - MountPath = "/input", - Mode = FileMountMode.ReadWrite, - }; - - Console.WriteLine("✓ File Mount Configuration:"); - Console.WriteLine($" Host Path: {mount.HostPath}"); - Console.WriteLine($" Mount Path: {mount.MountPath}"); - Console.WriteLine($" Mode: {mount.Mode}"); - Console.WriteLine(); - - Console.WriteLine("Sample complete!"); - Console.WriteLine(); - Console.WriteLine("NOTE: Actual code execution requires:"); - Console.WriteLine(" 1. A configured AI model/client"); - Console.WriteLine(" 2. An agent with LocalCodeActProvider"); - Console.WriteLine(" 3. External container/VM sandboxing for safety"); - } -} diff --git a/dotnet/samples/LocalCodeAct/README.md b/dotnet/samples/LocalCodeAct/README.md deleted file mode 100644 index fbda4a7668..0000000000 --- a/dotnet/samples/LocalCodeAct/README.md +++ /dev/null @@ -1,55 +0,0 @@ -# LocalCodeAct Sample - -This sample demonstrates using the `Microsoft.Agents.AI.LocalCodeAct` package for local Python code execution. - -## ⚠️ Security Warning - -This package executes LLM-generated Python code in a subprocess. It is **NOT** a security sandbox. - -**Use only in environments with proper external isolation:** -- Azure Container Instances -- Virtual Machines with network isolation -- Foundry hosted agents -- Docker containers with restricted capabilities - -**Do NOT use on:** -- Developer workstations -- Production hosts without sandboxing -- Any environment with access to sensitive data or credentials - -## Prerequisites - -- Python 3.10 or later installed -- .NET 8.0 or later -- External container/VM sandboxing for safe execution - -## Running the Sample - -```bash -dotnet run -``` - -This will demonstrate: -1. Creating a `LocalCodeActProvider` -2. Creating a `LocalExecuteCodeFunction` directly -3. Execution modes (Subprocess only in .NET) -4. File mount configuration - -## Configuration - -The sample uses `/usr/bin/python3` as the Python executable path. Adjust this in `Program.cs` if your Python is installed elsewhere: - -```csharp -pythonExecutablePath: "/path/to/your/python3" -``` - -## Next Steps - -For actual code execution with an AI agent, see: -- `Microsoft.Agents.AI` documentation for agent setup -- `Microsoft.Extensions.AI` for model client configuration -- Foundry hosting samples for containerized deployment - -## License - -MIT From 10c456413a8ce3390194c8aa8f524c747f801cf9 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 28 May 2026 17:23:30 +0200 Subject: [PATCH 15/15] style(local-codeact-dotnet): satisfy check-format rules - Add UTF-8 BOM to source files (CHARSET) - Remove unused using directives (IDE0005) - Simplify type names (IDE0001/IDE0002/IDE0090) - Rename static field JsonOptions -> s_jsonOptions (IDE1006) - Rename static field SyncRoot -> s_syncRoot (IDE1006) - Add missing this. qualifications in ProcessBridge (IDE0009) - Remove unused _options field from LocalCodeActProvider (IDE0052) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CodeValidationException.cs | 2 +- .../FileMount.cs | 6 +++--- .../Internal/CodeExecutor.cs | 2 +- .../Internal/CodeValidator.cs | 3 +-- .../Internal/EmbeddedScripts.cs | 7 +++---- .../Internal/ExecuteCodeFunction.cs | 2 +- .../Internal/FileMountHelper.cs | 2 +- .../Internal/InstructionBuilder.cs | 2 +- .../Internal/ProcessBridge.cs | 16 ++++++++-------- .../LocalCodeActProvider.cs | 5 +---- .../LocalCodeActProviderOptions.cs | 4 ++-- .../LocalExecuteCodeFunction.cs | 2 +- .../ProcessExecutionLimits.cs | 2 +- .../FileMountHelperTests.cs | 4 +--- .../FileMountTests.cs | 3 +-- .../InstructionBuilderTests.cs | 5 ++--- .../LocalCodeActProviderOptionsTests.cs | 3 +-- .../LocalCodeActProviderTests.cs | 5 ++--- .../LocalExecuteCodeFunctionIntegrationTests.cs | 3 +-- .../ProcessExecutionLimitsTests.cs | 5 +---- 20 files changed, 34 insertions(+), 49 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/CodeValidationException.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/CodeValidationException.cs index fc1fbbc361..c81f4d6627 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/CodeValidationException.cs +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/CodeValidationException.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/FileMount.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/FileMount.cs index 54947a6879..729f43478b 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/FileMount.cs +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/FileMount.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. namespace Microsoft.Agents.AI.LocalCodeAct; @@ -42,8 +42,8 @@ public sealed class FileMount /// public FileMount(string hostPath, string mountPath, FileMountMode mode = FileMountMode.ReadWrite, long? writeBytesLimit = null) { - Microsoft.Shared.Diagnostics.Throw.IfNull(hostPath); - Microsoft.Shared.Diagnostics.Throw.IfNull(mountPath); + Shared.Diagnostics.Throw.IfNull(hostPath); + Shared.Diagnostics.Throw.IfNull(mountPath); if (string.IsNullOrWhiteSpace(hostPath)) { throw new System.ArgumentException("Host path must not be empty.", nameof(hostPath)); diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/CodeExecutor.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/CodeExecutor.cs index 6088866df2..51d6a7136d 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/CodeExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/CodeExecutor.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; using System.Threading; diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/CodeValidator.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/CodeValidator.cs index c40e2cb6da..2da2177d71 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/CodeValidator.cs +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/CodeValidator.cs @@ -1,9 +1,8 @@ -// Copyright (c) Microsoft. All rights reserved. +// 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; diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/EmbeddedScripts.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/EmbeddedScripts.cs index 752e155bfa..cb75b4456f 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/EmbeddedScripts.cs +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/EmbeddedScripts.cs @@ -1,8 +1,7 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.IO; -using System.Reflection; namespace Microsoft.Agents.AI.LocalCodeAct.Internal; @@ -12,7 +11,7 @@ namespace Microsoft.Agents.AI.LocalCodeAct.Internal; /// internal static class EmbeddedScripts { - private static readonly object SyncRoot = new(); + private static readonly object s_syncRoot = new(); private static string? s_runnerPath; private static string? s_validatorPath; @@ -29,7 +28,7 @@ private static string GetOrExtract(string fileName, ref string? cached) return cached; } - lock (SyncRoot) + lock (s_syncRoot) { if (cached is not null && File.Exists(cached)) { diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/ExecuteCodeFunction.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/ExecuteCodeFunction.cs index 423ae45308..ec80f19b8c 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/ExecuteCodeFunction.cs +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/ExecuteCodeFunction.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Text.Json; diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/FileMountHelper.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/FileMountHelper.cs index 4091be40c6..80dd0a532a 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/FileMountHelper.cs +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/FileMountHelper.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/InstructionBuilder.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/InstructionBuilder.cs index d5f8cd249c..c54cb6dc49 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/InstructionBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/InstructionBuilder.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; using System.Text; diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/ProcessBridge.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/ProcessBridge.cs index 8c0047bb03..7ada594b89 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/ProcessBridge.cs +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/ProcessBridge.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; @@ -20,7 +20,7 @@ namespace Microsoft.Agents.AI.LocalCodeAct.Internal; /// internal sealed class ProcessBridge { - private static readonly JsonSerializerOptions JsonOptions = new() + private static readonly JsonSerializerOptions s_jsonOptions = new() { WriteIndented = false, }; @@ -78,7 +78,7 @@ public async Task RunAsync(string code, CancellationToken cance startInfo.WorkingDirectory = this._workingDirectory; } - ConfigureEnvironment(startInfo); + this.ConfigureEnvironment(startInfo); using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start Python runner process."); @@ -90,7 +90,7 @@ public async Task RunAsync(string code, CancellationToken cance try { - return await CommunicateAsync(process, code, stderrTask, timeoutCts.Token).ConfigureAwait(false); + return await this.CommunicateAsync(process, code, stderrTask, timeoutCts.Token).ConfigureAwait(false); } catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) { @@ -151,7 +151,7 @@ private async Task CommunicateAsync( ["max_stderr_bytes"] = this._limits.MaxStderrBytes, }; - await process.StandardInput.WriteLineAsync(request.ToJsonString(JsonOptions).AsMemory(), cancellationToken).ConfigureAwait(false); + await process.StandardInput.WriteLineAsync(request.ToJsonString(s_jsonOptions).AsMemory(), cancellationToken).ConfigureAwait(false); await process.StandardInput.FlushAsync(cancellationToken).ConfigureAwait(false); while (true) @@ -179,7 +179,7 @@ private async Task CommunicateAsync( switch ((string?)message["type"]) { case "complete": - return ParseComplete(message); + return this.ParseComplete(message); case "error": var excType = (string?)message["exc_type"] ?? "Error"; @@ -189,7 +189,7 @@ private async Task CommunicateAsync( string.IsNullOrEmpty(tb) ? $"{excType}: {msg}" : $"{excType}: {msg}\n{tb}"); case "tool_call": - await HandleToolCallAsync(process, message, cancellationToken).ConfigureAwait(false); + await this.HandleToolCallAsync(process, message, cancellationToken).ConfigureAwait(false); break; default: @@ -297,7 +297,7 @@ private static async Task SendToolResponseAsync( response["message"] = excMessage; } - await process.StandardInput.WriteLineAsync(response.ToJsonString(JsonOptions).AsMemory(), cancellationToken).ConfigureAwait(false); + await process.StandardInput.WriteLineAsync(response.ToJsonString(s_jsonOptions).AsMemory(), cancellationToken).ConfigureAwait(false); await process.StandardInput.FlushAsync(cancellationToken).ConfigureAwait(false); } diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProvider.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProvider.cs index 2364e3af05..94de463cdd 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProvider.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; @@ -35,7 +35,6 @@ public sealed class LocalCodeActProvider : AIContextProvider, IDisposable private static readonly IReadOnlyList s_stateKeys = [FixedStateKey]; private readonly object _gate = new(); - private readonly LocalCodeActProviderOptions _options; private readonly CodeExecutor _executor; private readonly Dictionary _tools = new(StringComparer.Ordinal); @@ -52,8 +51,6 @@ public LocalCodeActProvider(LocalCodeActProviderOptions options) throw new ArgumentException("PythonExecutablePath must not be empty.", nameof(options)); } - this._options = options; - var limits = options.ExecutionLimits ?? new ProcessExecutionLimits(); var runnerScript = options.RunnerScriptPath ?? EmbeddedScripts.GetRunnerScriptPath(); diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProviderOptions.cs index eb3359f979..4ce22e6fef 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProviderOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProviderOptions.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; using Microsoft.Extensions.AI; @@ -16,7 +16,7 @@ public sealed class LocalCodeActProviderOptions /// Path to the Python interpreter used for execution and validation. public LocalCodeActProviderOptions(string pythonExecutablePath) { - Microsoft.Shared.Diagnostics.Throw.IfNull(pythonExecutablePath); + Shared.Diagnostics.Throw.IfNull(pythonExecutablePath); if (string.IsNullOrWhiteSpace(pythonExecutablePath)) { throw new System.ArgumentException("Python executable path must not be empty.", nameof(pythonExecutablePath)); diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalExecuteCodeFunction.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalExecuteCodeFunction.cs index 3098afdb27..c4f8addd52 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalExecuteCodeFunction.cs +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalExecuteCodeFunction.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/ProcessExecutionLimits.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/ProcessExecutionLimits.cs index 1443cdb3d2..d3518abb0e 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/ProcessExecutionLimits.cs +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/ProcessExecutionLimits.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. namespace Microsoft.Agents.AI.LocalCodeAct; diff --git a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/FileMountHelperTests.cs b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/FileMountHelperTests.cs index 81ecfc4c3f..d47679946f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/FileMountHelperTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/FileMountHelperTests.cs @@ -1,9 +1,7 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. -using System.Collections.Generic; using System.IO; using System.Linq; -using Microsoft.Agents.AI.LocalCodeAct; using Microsoft.Agents.AI.LocalCodeAct.Internal; using Microsoft.Extensions.AI; diff --git a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/FileMountTests.cs b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/FileMountTests.cs index 887950ebf3..3a4d1dc0ac 100644 --- a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/FileMountTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/FileMountTests.cs @@ -1,7 +1,6 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; -using Microsoft.Agents.AI.LocalCodeAct; namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests; diff --git a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/InstructionBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/InstructionBuilderTests.cs index 7761342c34..3795ab39ae 100644 --- a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/InstructionBuilderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/InstructionBuilderTests.cs @@ -1,7 +1,6 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; -using Microsoft.Agents.AI.LocalCodeAct; using Microsoft.Agents.AI.LocalCodeAct.Internal; using Microsoft.Extensions.AI; @@ -28,7 +27,7 @@ public void BuildExecuteCodeDescription_MentionsToolsWhenProvided() [Fact] public void BuildExecuteCodeDescription_MentionsMountsWhenProvided() { - var mounts = new List { new FileMount("/host/data", "/app/data") }; + var mounts = new List { new("/host/data", "/app/data") }; var description = InstructionBuilder.BuildExecuteCodeDescription(new List(), mounts); Assert.Contains("/app/data", description); diff --git a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalCodeActProviderOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalCodeActProviderOptionsTests.cs index 5f85c1ca45..ab01fe8310 100644 --- a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalCodeActProviderOptionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalCodeActProviderOptionsTests.cs @@ -1,7 +1,6 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; -using Microsoft.Agents.AI.LocalCodeAct; namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests; diff --git a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalCodeActProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalCodeActProviderTests.cs index f6b7cf5826..c16eec1e96 100644 --- a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalCodeActProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalCodeActProviderTests.cs @@ -1,8 +1,7 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Linq; using System.Threading.Tasks; -using Microsoft.Agents.AI.LocalCodeAct; using Microsoft.Extensions.AI; using Moq; @@ -109,7 +108,7 @@ public TestTool(string name) public override string Description => "test tool"; - protected override System.Threading.Tasks.ValueTask InvokeCoreAsync(AIFunctionArguments arguments, System.Threading.CancellationToken cancellationToken) => + 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 index 7da519be15..c32c60fec5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionIntegrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionIntegrationTests.cs @@ -1,11 +1,10 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; -using Microsoft.Agents.AI.LocalCodeAct; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests; diff --git a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/ProcessExecutionLimitsTests.cs b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/ProcessExecutionLimitsTests.cs index 6c13846bf1..6f98b2c79d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/ProcessExecutionLimitsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/ProcessExecutionLimitsTests.cs @@ -1,7 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using Microsoft.Agents.AI.LocalCodeAct; +// Copyright (c) Microsoft. All rights reserved. namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests;