From 3838f675a49d0805485c14b470aa29d27b23e76a Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 28 Oct 2026 17:43:28 +0100 Subject: [PATCH 1/2] .NET: Harden LocalCodeAct provenance and process capability checks Signed-off-by: eavanvalkenburg --- .../README.md | 10 +- .../Resources/validator.py | 101 ++++++-- ...ocalExecuteCodeFunctionIntegrationTests.cs | 220 +++++++++++++++++- 3 files changed, 307 insertions(+), 24 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/README.md b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/README.md index fc4c748a52d..f7c5331f6e5 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/README.md +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/README.md @@ -40,7 +40,8 @@ using var provider = new LocalCodeActProvider("/usr/bin/python3", options); ## What the Package Controls - **AST validation** (default on): Validates generated code against allow-lists - before execution. + before execution. OS-derived aliases and containers are collected before + their uses are checked, including references in helpers and later loop iterations. - **Subprocess execution**: Runs generated code in a child Python process. - **Explicit Python path**: the provider and standalone function constructors require a Python executable path (no default). - **Isolated environment**: Does not inherit host environment variables unless @@ -57,6 +58,13 @@ 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. +OS provenance collection uses a finite set of permitted chains and a forbidden +state so cyclic aliases cannot grow the analysis indefinitely. Tracking is +conservative across scopes and assignments; reusing a name for unrelated values +can cause validation to reject otherwise benign code. Capability restrictions +also apply to private asyncio process transports and Windows process helpers, +including aliases and from-imports. + ## What the Package Does NOT Protect - Malicious Python code working within allowed imports and operations. diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/validator.py b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/validator.py index 5fefc76d552..7c20b6a42d4 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/validator.py +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/validator.py @@ -116,10 +116,15 @@ _BLOCKED_CAPABILITY_ATTRS: frozenset[str] = frozenset( { + "Popen", + "_UnixSubprocessTransport", + "_WindowsSubprocessTransport", "__builtins__", "_eval_type", "_evaluate", + "_make_subprocess_transport", "_sys", + "_winapi", "ag_frame", "builtins", "connect_accepted_socket", @@ -172,6 +177,7 @@ _OS_ROOT_CHAIN: tuple[str, ...] = ("os",) _OS_PATH_CHAIN: tuple[str, ...] = ("os", "path") _OS_ENVIRON_CHAIN: tuple[str, ...] = ("os", "environ") +_OS_FORBIDDEN_CHAIN: tuple[str, ...] = ("",) # Allowed builtin function names that generated code may call. # Note: getattr/setattr/hasattr/delattr are NOT included because they can bypass @@ -389,6 +395,7 @@ def __init__( self._allowed_os_attrs = allowed_os_attrs if allowed_os_attrs is not None else ALLOWED_OS_ATTRS self._os_aliases: dict[str, set[tuple[str, ...]]] = {"os": {_OS_ROOT_CHAIN}} self._os_containers: dict[str, set[tuple[str, ...]]] = {} + self._collecting_provenance = False self._shadowed_os_value_builtins: set[str] = set() def validate(self, code: str) -> None: @@ -402,6 +409,7 @@ def validate(self, code: str) -> None: self._os_aliases = {"os": {_OS_ROOT_CHAIN}} self._os_containers = {} self._shadowed_os_value_builtins = self._find_shadowed_os_value_builtins(tree) + self._collect_os_provenance(tree) self.visit(tree) if self._errors: @@ -409,6 +417,60 @@ def validate(self, code: str) -> None: "Generated code violates allow-list policy:\n" + "\n".join(f"- {err}" for err in self._errors) ) + def _collect_os_provenance(self, tree: ast.AST) -> None: + """Collect finite, conservative provenance before checking any uses.""" + nodes = list(ast.walk(tree)) + producers: list[ast.AST] = [] + for node in nodes: + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name == "os" or alias.name.startswith("os."): + chain = tuple(alias.name.split(".")) if alias.asname else _OS_ROOT_CHAIN + self._remember_os_alias(alias.asname or "os", chain) + elif isinstance(node, ast.ImportFrom) and node.module is not None: + for alias in node.names: + if node.module == "os" or node.module.startswith("os."): + chain = (*tuple(node.module.split(".")), alias.name) + self._remember_os_alias(alias.asname or alias.name, chain) + elif alias.name in {"os", "_os"}: + self._remember_os_alias(alias.asname or alias.name, _OS_ROOT_CHAIN) + elif isinstance(node, (ast.Assign, ast.AnnAssign, ast.For, ast.AsyncFor)): + producers.append(node) + + names = {node.id for node in nodes if isinstance(node, ast.Name)} | self._os_aliases.keys() + chain_count = 2 + len(self._allowed_os_attrs) # Root, forbidden, and configured top-level members. + if "path" in self._allowed_os_attrs: + chain_count += len(ALLOWED_OS_PATH_ATTRS) + if "environ" in self._allowed_os_attrs: + chain_count += len(ALLOWED_OS_ENVIRON_ATTRS) + + # Each changing sweep adds a direct or contained name/chain fact. + max_facts = 2 * len(names) * chain_count + previous_fact_count = -1 + self._collecting_provenance = True + try: + for _ in range(max_facts + 1): + fact_count = sum( + len(chains) + for mapping in (self._os_aliases, self._os_containers) + for chains in mapping.values() + ) + if fact_count == previous_fact_count: + return + previous_fact_count = fact_count + for node in producers: + if isinstance(node, ast.Assign): + for target in node.targets: + self._track_os_provenance(target, node.value) + elif isinstance(node, ast.AnnAssign) and node.value is not None: + self._track_os_provenance(node.target, node.value) + elif isinstance(node, (ast.For, ast.AsyncFor)): + self._track_os_iteration_target(node.target, node.iter) + finally: + self._collecting_provenance = False + + raise CodeValidationError("Generated code is not allowed: OS provenance analysis exceeded its finite bound") + def visit(self, node: ast.AST) -> Any: """Visit a node and check if its type is allowed.""" node_type = type(node) @@ -426,16 +488,8 @@ def visit_Import(self, node: ast.Import) -> None: elif module_name not in self._allowed_imports: self._errors.append(f"Import of '{alias_node.name}' is not allowed (not in allow-list)") - if alias_node.name == "os": - self._remember_os_alias(alias_node.asname or "os", _OS_ROOT_CHAIN) - elif alias_node.name.startswith("os."): - chain = tuple(alias_node.name.split(".")) - if chain != _OS_PATH_CHAIN: - self._errors.append(f"Import of '{alias_node.name}' is not allowed") - elif alias_node.asname is not None: - self._remember_os_alias(alias_node.asname, chain) - else: - self._remember_os_alias("os", _OS_ROOT_CHAIN) + if alias_node.name.startswith("os.") and alias_node.name != "os.path": + self._errors.append(f"Import of '{alias_node.name}' is not allowed") self.generic_visit(node) def visit_ImportFrom(self, node: ast.ImportFrom) -> None: @@ -454,19 +508,14 @@ def visit_ImportFrom(self, node: ast.ImportFrom) -> None: self._errors.append(f"Import from '{node.module}' is not allowed (not in allow-list)") elif module_name == "os": for alias_node in node.names: - chain = (*tuple(node.module.split(".")), alias_node.name) if node.module != "os" or alias_node.name not in self._allowed_os_attrs: self._errors.append(f"Import from 'os' of '{alias_node.name}' is not allowed") - else: - self._remember_os_alias(alias_node.asname or alias_node.name, chain) else: for alias_node in node.names: if alias_node.name.startswith("__") and alias_node.name.endswith("__"): self._errors.append( f"Import from '{node.module}' of reflective attribute '{alias_node.name}' is not allowed" ) - elif alias_node.name in {"os", "_os"}: - self._remember_os_alias(alias_node.asname or alias_node.name, _OS_ROOT_CHAIN) elif alias_node.name in _BLOCKED_CAPABILITY_ATTRS: self._errors.append( f"Import from '{node.module}' of capability '{alias_node.name}' is not allowed" @@ -570,7 +619,10 @@ def visit_Lambda(self, node: ast.Lambda) -> None: self.generic_visit(node) def _remember_os_alias(self, name: str, chain: tuple[str, ...]) -> None: - self._os_aliases.setdefault(name, set()).add(chain) + self._os_aliases.setdefault(name, set()).add(self._normalize_os_chain(chain)) + + def _normalize_os_chain(self, chain: tuple[str, ...]) -> tuple[str, ...]: + return chain if self._is_allowed_os_chain(chain) else _OS_FORBIDDEN_CHAIN def _remember_os_chains( self, @@ -582,15 +634,19 @@ def _remember_os_chains( target = target.value if isinstance(target, ast.Name): + if not self._collecting_provenance: + return if direct: - self._os_aliases.setdefault(target.id, set()).update(direct) + self._os_aliases.setdefault(target.id, set()).update(self._normalize_os_chain(chain) for chain in direct) if contained: - self._os_containers.setdefault(target.id, set()).update(contained) + self._os_containers.setdefault(target.id, set()).update( + self._normalize_os_chain(chain) for chain in contained + ) elif isinstance(target, (ast.Tuple, ast.List)): possible = direct | contained for target_item in target.elts: self._remember_os_chains(target_item, possible, set()) - elif direct or contained: + elif not self._collecting_provenance and (direct or contained): self._errors.append("Storing an OS-derived value on an object is not allowed") def _track_os_provenance(self, target: ast.AST, value: ast.AST) -> None: @@ -660,9 +716,10 @@ def _get_os_provenance( if isinstance(node, ast.Attribute): direct, _ = self._get_os_provenance(node.value) - if not direct and node.attr in {"os", "_os"}: - return ({_OS_ROOT_CHAIN}, set()) - return ({(*chain, node.attr) for chain in direct}, set()) + chains = {(*chain, node.attr) for chain in direct} + if node.attr in {"os", "_os"}: + chains.add(_OS_ROOT_CHAIN) + return chains, set() if isinstance(node, ast.Subscript): direct, contained = self._get_os_provenance(node.value) diff --git a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionIntegrationTests.cs index 50f5523d58d..e974a4c77a6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionIntegrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionIntegrationTests.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using System.IO; using System.Linq; +using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -181,6 +182,34 @@ public async Task ExecuteCode_ValidationBlocksFrameAccessBeforeRunnerStartsAsync Assert.Contains($"'{capability}'", exception.Message, StringComparison.Ordinal); } + [Theory] + [InlineData("import asyncio\nfactory = asyncio.get_running_loop()._make_subprocess_transport", "_make_subprocess_transport")] + [InlineData("import asyncio as a\nfactories = [a.get_event_loop()._make_subprocess_transport]", "_make_subprocess_transport")] + [InlineData("def use(loop):\n return loop._make_subprocess_transport", "_make_subprocess_transport")] + [InlineData("import asyncio.unix_events as events\nfactory = events._UnixSubprocessTransport", "_UnixSubprocessTransport")] + [InlineData("from asyncio import unix_events\nfactory = unix_events._UnixSubprocessTransport", "_UnixSubprocessTransport")] + [InlineData("from asyncio.unix_events import _UnixSubprocessTransport as factory", "_UnixSubprocessTransport")] + [InlineData("import asyncio.windows_events as events\nfactory = events._WindowsSubprocessTransport", "_WindowsSubprocessTransport")] + [InlineData("from asyncio import windows_events\nfactory = windows_events._WindowsSubprocessTransport", "_WindowsSubprocessTransport")] + [InlineData("from asyncio.windows_events import _WindowsSubprocessTransport as factory", "_WindowsSubprocessTransport")] + [InlineData("import asyncio.windows_utils as helpers\nfactory = helpers.Popen", "Popen")] + [InlineData("from asyncio.windows_utils import Popen as factory", "Popen")] + [InlineData("import asyncio.windows_events as events\napi = events._winapi", "_winapi")] + [InlineData("from asyncio.windows_events import _winapi as api", "_winapi")] + public async Task ExecuteCode_ValidationBlocksPrivateProcessCapabilitiesBeforeRunnerStartsAsync( + string code, string capability) + { + // Arrange + SkipIfNoPython(); + + // Act + var exception = await AssertValidationBlocksBeforeRunnerStartsAsync(code); + + // Assert + Assert.Contains("capability", exception.Message, StringComparison.Ordinal); + Assert.Contains($"'{capability}'", exception.Message, StringComparison.Ordinal); + } + [Theory] [InlineData("import typing\nresolve = typing.get_type_hints", "get_type_hints", true)] [InlineData("import typing\nresolve = typing.get_type_hints", "get_type_hints", false)] @@ -200,6 +229,21 @@ public async Task ExecuteCode_ValidationBlocksFrameAccessBeforeRunnerStartsAsync [InlineData("import asyncio\nframe = asyncio.current_task().get_stack()[-1].f_locals", "f_locals", false)] [InlineData("from typing import get_type_hints as resolve", "get_type_hints", false)] [InlineData("from functools import singledispatch as dispatch", "singledispatch", false)] + [InlineData("import asyncio\nfactory = asyncio.get_event_loop()._make_subprocess_transport", "_make_subprocess_transport", true)] + [InlineData("import asyncio\nfactory = asyncio.get_event_loop()._make_subprocess_transport", "_make_subprocess_transport", false)] + [InlineData("import asyncio.unix_events as events\nfactory = events._UnixSubprocessTransport", "_UnixSubprocessTransport", true)] + [InlineData("import asyncio.unix_events as events\nfactory = events._UnixSubprocessTransport", "_UnixSubprocessTransport", false)] + [InlineData("import asyncio.windows_events as events\nfactory = events._WindowsSubprocessTransport", "_WindowsSubprocessTransport", true)] + [InlineData("import asyncio.windows_events as events\nfactory = events._WindowsSubprocessTransport", "_WindowsSubprocessTransport", false)] + [InlineData("import asyncio.windows_utils as helpers\nfactory = helpers.Popen", "Popen", true)] + [InlineData("import asyncio.windows_utils as helpers\nfactory = helpers.Popen", "Popen", false)] + [InlineData("import asyncio.windows_events as events\napi = events._winapi", "_winapi", true)] + [InlineData("import asyncio.windows_events as events\napi = events._winapi", "_winapi", false)] + [InlineData("from injected import _make_subprocess_transport as factory", "_make_subprocess_transport", false)] + [InlineData("from injected import _UnixSubprocessTransport as factory", "_UnixSubprocessTransport", false)] + [InlineData("from injected import _WindowsSubprocessTransport as factory", "_WindowsSubprocessTransport", false)] + [InlineData("from injected import Popen as factory", "Popen", false)] + [InlineData("from injected import _winapi as api", "_winapi", false)] public async Task ExecuteCode_ValidationCapabilityRestrictionsSurviveCustomListsAsync( string code, string capability, bool emptyLists) { @@ -208,7 +252,7 @@ public async Task ExecuteCode_ValidationCapabilityRestrictionsSurviveCustomLists // Arrange var options = new LocalCodeActProviderOptions { - AllowedImports = emptyLists ? Array.Empty() : new[] { "typing", "functools", "asyncio" }, + AllowedImports = emptyLists ? Array.Empty() : new[] { "typing", "functools", "asyncio", "injected" }, BlockedImports = Array.Empty(), AllowedBuiltins = emptyLists ? Array.Empty() : new[] { "int" }, BlockedBuiltins = Array.Empty(), @@ -251,6 +295,7 @@ public async Task ExecuteCode_ValidationBlocksExplicitYieldBeforeRunnerStartsAsy "async def add(a, b):\n return a + b\nsum(value * 2 for value in [1, 2, 3]) + await add(1, 1)", "14")] [InlineData("import asyncio\nprint(len(asyncio.current_task().get_stack()) > 0)", "True")] + [InlineData("import asyncio\nprint(asyncio.get_running_loop().is_running())", "True")] [InlineData("from collections.abc import MutableMapping\nprint(MutableMapping.register(dict) is dict)", "True")] [InlineData("def evaluate(value):\n return value\nevaluate(5)", "5")] public async Task ExecuteCode_AllowsNonEvaluatingTypingAndHelpersAsync(string code, string expected) @@ -404,6 +449,171 @@ public async Task ExecuteCode_ValidationBlocksDisallowedOsAccessBeforeRunnerStar await AssertValidationBlocksBeforeRunnerStartsAsync(code); } + [Theory] + [InlineData("import os\ndef use():\n cap.popen()\ncap = os\nuse()")] + [InlineData("import os\nasync def use():\n cap.popen()\ncap = os\nawait use()")] + [InlineData("import os\nuse = lambda: cap.popen()\ncap = os\nuse()")] + [InlineData("import os\ndef outer():\n def use():\n cap.popen()\n cap = os\n use()\nouter()")] + [InlineData("def use():\n cap.popen()\nimport os as cap\nuse()")] + [InlineData("import os\ndef use():\n cap.popen()\ncap: object = os\nuse()")] + [InlineData("import os\ndef use():\n cap.popen()\ncap = other = os\nuse()")] + [InlineData("import os\ndef use():\n cap.popen()\ncap, _ = (os, 0)\nuse()")] + [InlineData("import os\ndef use():\n cap.popen()\n[cap, _] = [os, 0]\nuse()")] + [InlineData("import os\ndef use():\n caps[0].popen()\n_, *caps = (0, os)\nuse()")] + [InlineData("import os\nfor iteration in range(2):\n if iteration:\n cap.popen()\n cap = os")] + [InlineData("import os\niteration = 0\nwhile iteration < 2:\n if iteration:\n cap.popen()\n cap = os\n iteration += 1")] + [InlineData("import os\ndef use():\n for cap in values:\n cap.popen()\nvalues = [os]\nuse()")] + [InlineData("import os\nasync def use():\n async for cap in values:\n cap.popen()\nvalues = [os]")] + [InlineData("import os\ndef use():\n caps[0].popen()\ncaps = [os]\nuse()")] + [InlineData("import os\ndef use():\n caps['module'].popen()\ncaps = {'module': os}\nuse()")] + [InlineData("import os\ndef use():\n caps[0]['module'].popen()\nvalues = [{'module': os}]\ncaps = values\nuse()")] + [InlineData("import os\ndef use():\n return cap\ncap = os\nuse()")] + [InlineData("import os\nuse = lambda: cap\ncap = os\nuse()")] + [InlineData("import os\ndef use():\n def inner(value=cap):\n return 1\n return inner()\ncap = os\nuse()")] + [InlineData("import os\ndef identity(value):\n return value\ndef use():\n identity(cap)\ncap = os\nuse()")] + [InlineData("import os\ndef use(target):\n target.value = cap\ncap = os")] + [InlineData("import os\ndef use(target):\n target[0] = cap\ncap = os")] + [InlineData("import os\ndef use():\n env['KEY'] = 'value'\nenv = os.environ\nuse()")] + [InlineData("import os\ndef use():\n caps = []\n caps += [cap]\ncap = os\nuse()")] + [InlineData("import os\ndef use():\n return [value for value in caps]\ncaps = [os]\nuse()")] + [InlineData("import os\ndef use():\n match cap:\n case value:\n print(value)\ncap = os\nuse()")] + [InlineData("import os\ndef use():\n print(cap.path.exists('file'))\ncap = os\nuse()")] + [InlineData("def use(cap):\n cap.popen()\ndef other():\n import os as cap")] + public async Task ExecuteCode_ValidationBlocksLateBoundOsAccessBeforeRunnerStartsAsync(string code) + { + // Arrange + SkipIfNoPython(); + + // Act + var exception = await AssertValidationBlocksBeforeRunnerStartsAsync(code); + + // Assert + Assert.DoesNotContain("finite bound", exception.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData(2)] + [InlineData(8)] + [InlineData(64)] + public async Task ExecuteCode_ValidationBlocksLongLoopCarriedAliasesBeforeRunnerStartsAsync(int aliasCount) + { + SkipIfNoPython(); + + // Arrange + var code = new StringBuilder("import os\n"); + for (var index = 0; index < aliasCount; index++) + { + code.Append($"cap{index} = None\n"); + } + + code.Append($"cap{aliasCount} = os\nfor iteration in range({aliasCount + 1}):\n"); + code.Append($" if iteration == {aliasCount}:\n cap0.popen()\n"); + for (var index = 0; index < aliasCount; index++) + { + code.Append($" cap{index} = cap{index + 1}\n"); + } + + // Act + var exception = await AssertValidationBlocksBeforeRunnerStartsAsync(code.ToString()); + + // Assert + Assert.DoesNotContain("finite bound", exception.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("first = second\nsecond = first", true)] + [InlineData("first = [second]\nsecond = [first]", true)] + [InlineData("import os\nfirst = os\nsecond = first\nfirst = second\nfirst.popen()", false)] + [InlineData("import os\nvalues = [os]\nvalues = [values]\nvalues[0][0].popen()", false)] + [InlineData("import os\ncap = os\ncap = cap.path", false)] + [InlineData("import os\nfirst = os\nsecond = first.path\nfirst = second.path", false)] + public async Task Validator_ProvenanceCyclesConvergeAsync(string code, bool allowed) + { + SkipIfNoPython(); + + // Arrange + var validator = new Internal.CodeValidator( + s_python!, + Internal.EmbeddedScripts.GetValidatorScriptPath(), + TimeSpan.FromSeconds(10), + allowedImports: null, + blockedImports: null, + allowedBuiltins: null, + blockedBuiltins: null); + + // Act & Assert + if (allowed) + { + await validator.ValidateAsync(code, CancellationToken.None); + } + else + { + var exception = await Assert.ThrowsAsync( + async () => await validator.ValidateAsync(code, CancellationToken.None)); + Assert.Contains("not allowed", exception.Message, StringComparison.Ordinal); + Assert.DoesNotContain("finite bound", exception.Message, StringComparison.Ordinal); + Assert.DoesNotContain("exceeded", exception.Message, StringComparison.Ordinal); + } + } + + [Fact] + public async Task Validator_ResetsProvenanceAndPreservesOsPolicyOverridesAsync() + { + SkipIfNoPython(); + + // Arrange + var tempDir = Directory.CreateTempSubdirectory("localcodeact-validator-policy-").FullName; + try + { + var scriptPath = Path.Combine(tempDir, "probe_validator.py"); + File.WriteAllText( + scriptPath, + $$""" + import runpy + import sys + + sys.stdin.read() + namespace = runpy.run_path({{JsonSerializer.Serialize(Internal.EmbeddedScripts.GetValidatorScriptPath())}}) + validator_type = namespace["_CodeValidator"] + validation_error = namespace["CodeValidationError"] + + def assert_rejected(validator, code): + try: + validator.validate(code) + except validation_error: + return + raise AssertionError("Expected OS policy rejection") + + validator = validator_type() + validator.validate("import os\ncap = os") + validator.validate("cap.popen()") + assert_rejected(validator, "import os\nos.popen()") + validator.validate("print('safe')") + + assert_rejected(validator_type(allowed_os_attrs=set()), "import os\nos.path") + custom = validator_type(allowed_os_attrs={"getpid"}) + custom.validate("def use():\n return cap.getpid()\nimport os\ncap = os\nuse()") + assert_rejected(custom, "import os\nos.getpid.unknown") + assert_rejected(custom, "import os\nos.path") + """); + var validator = new Internal.CodeValidator( + s_python!, + scriptPath, + TimeSpan.FromSeconds(10), + allowedImports: null, + blockedImports: null, + allowedBuiltins: null, + blockedBuiltins: null); + + // Act & Assert + await validator.ValidateAsync("print('safe')", CancellationToken.None); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + private static async Task AssertValidationBlocksBeforeRunnerStartsAsync( string code, LocalCodeActProviderOptions? options = null) { @@ -451,6 +661,14 @@ private static async Task AssertValidationBlocksBeforeR [InlineData("import os\np = os.path\nprint(p.normpath('a/../b'))")] [InlineData("import os\np, env = (os.path, os.environ)\nprint(p.join('a', 'b'))\nprint(env.get('PATH'))")] [InlineData("import os\njoin = os.path.join\nprint(join('a', 'b'))")] + [InlineData("import os\ndef use():\n print(path.join('a', 'b'))\npath = os.path\nuse()")] + [InlineData("import os\nasync def use():\n print(path.basename('a/b'))\npath = os.path\nawait use()")] + [InlineData("def use():\n print(path.join('a', 'b'))\nfrom os import path\nuse()")] + [InlineData("def use():\n print(path.join('a', 'b'))\nimport os.path as path\nuse()")] + [InlineData("import os\nuse = lambda: join('a', 'b')\njoin = os.path.join\nprint(use())")] + [InlineData("import os\ndef use():\n print(path.join('a', 'b'))\n print(env.get('PATH'))\npath, env = (os.path, os.environ)\nuse()")] + [InlineData("import os\ndef use():\n print(env.copy())\n print(list(env))\nenv = os.environ\nuse()")] + [InlineData("import os\ndef use():\n for key in env:\n print(key)\nenv = os.environ\nuse()")] public async Task ExecuteCode_AllowsPermittedOsAccessAsync(string code) { SkipIfNoPython(); From 5498d84b55196a742ed8ab1aff390187347ea8f2 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 28 Oct 2026 19:22:56 +0100 Subject: [PATCH 2/2] .NET: Propagate LocalCodeAct provenance with a dependency worklist Signed-off-by: eavanvalkenburg --- .../README.md | 7 +- .../Resources/validator.py | 68 ++++++++++++------- ...ocalExecuteCodeFunctionIntegrationTests.cs | 58 ++++++++++++++++ 3 files changed, 107 insertions(+), 26 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/README.md b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/README.md index f7c5331f6e5..e4ae51298bf 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/README.md +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/README.md @@ -59,9 +59,10 @@ validator blocks common dangerous operations (`eval`, `exec`, does not make Python execution safe on an unsandboxed host. OS provenance collection uses a finite set of permitted chains and a forbidden -state so cyclic aliases cannot grow the analysis indefinitely. Tracking is -conservative across scopes and assignments; reusing a name for unrelated values -can cause validation to reject otherwise benign code. Capability restrictions +state so cyclic aliases cannot grow the analysis indefinitely. A dependency +worklist revisits only assignments that depend on newly discovered provenance. +Tracking is conservative across scopes and assignments; reusing a name for +unrelated values can cause validation to reject otherwise benign code. Capability restrictions also apply to private asyncio process transports and Windows process helpers, including aliases and from-imports. diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/validator.py b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/validator.py index 7c20b6a42d4..7ec0db4cb45 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/validator.py +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/validator.py @@ -6,6 +6,7 @@ import ast import builtins as _builtins +from collections import deque from typing import Any _PYTHON_BUILTIN_NAMES: frozenset[str] = frozenset(dir(_builtins)) @@ -396,6 +397,7 @@ def __init__( self._os_aliases: dict[str, set[tuple[str, ...]]] = {"os": {_OS_ROOT_CHAIN}} self._os_containers: dict[str, set[tuple[str, ...]]] = {} self._collecting_provenance = False + self._changed_os_names: set[str] = set() self._shadowed_os_value_builtins: set[str] = set() def validate(self, code: str) -> None: @@ -421,6 +423,7 @@ def _collect_os_provenance(self, tree: ast.AST) -> None: """Collect finite, conservative provenance before checking any uses.""" nodes = list(ast.walk(tree)) producers: list[ast.AST] = [] + dependents: dict[str, list[int]] = {} for node in nodes: if isinstance(node, ast.Import): for alias in node.names: @@ -435,41 +438,52 @@ def _collect_os_provenance(self, tree: ast.AST) -> None: elif alias.name in {"os", "_os"}: self._remember_os_alias(alias.asname or alias.name, _OS_ROOT_CHAIN) elif isinstance(node, (ast.Assign, ast.AnnAssign, ast.For, ast.AsyncFor)): + value = node.iter if isinstance(node, (ast.For, ast.AsyncFor)) else node.value + if value is None: + continue + index = len(producers) producers.append(node) + for name in {child.id for child in ast.walk(value) if isinstance(child, ast.Name)}: + dependents.setdefault(name, []).append(index) - names = {node.id for node in nodes if isinstance(node, ast.Name)} | self._os_aliases.keys() chain_count = 2 + len(self._allowed_os_attrs) # Root, forbidden, and configured top-level members. if "path" in self._allowed_os_attrs: chain_count += len(ALLOWED_OS_PATH_ATTRS) if "environ" in self._allowed_os_attrs: chain_count += len(ALLOWED_OS_ENVIRON_ATTRS) - # Each changing sweep adds a direct or contained name/chain fact. - max_facts = 2 * len(names) * chain_count - previous_fact_count = -1 + pending = deque(range(len(producers))) + scheduled = set(pending) + # Each direct/contained name fact can schedule each dependent at most once. + remaining_propagations = len(producers) + 2 * chain_count * sum(len(items) for items in dependents.values()) self._collecting_provenance = True try: - for _ in range(max_facts + 1): - fact_count = sum( - len(chains) - for mapping in (self._os_aliases, self._os_containers) - for chains in mapping.values() - ) - if fact_count == previous_fact_count: - return - previous_fact_count = fact_count - for node in producers: - if isinstance(node, ast.Assign): - for target in node.targets: - self._track_os_provenance(target, node.value) - elif isinstance(node, ast.AnnAssign) and node.value is not None: - self._track_os_provenance(node.target, node.value) - elif isinstance(node, (ast.For, ast.AsyncFor)): - self._track_os_iteration_target(node.target, node.iter) + while pending: + if remaining_propagations == 0: + raise CodeValidationError( + "Generated code is not allowed: OS provenance analysis exceeded its finite bound" + ) + remaining_propagations -= 1 + index = pending.popleft() + scheduled.remove(index) + self._changed_os_names.clear() + node = producers[index] + if isinstance(node, ast.Assign): + for target in node.targets: + self._track_os_provenance(target, node.value) + elif isinstance(node, ast.AnnAssign) and node.value is not None: + self._track_os_provenance(node.target, node.value) + elif isinstance(node, (ast.For, ast.AsyncFor)): + self._track_os_iteration_target(node.target, node.iter) + + for name in self._changed_os_names: + for dependent in dependents.get(name, []): + if dependent not in scheduled: + pending.append(dependent) + scheduled.add(dependent) finally: self._collecting_provenance = False - - raise CodeValidationError("Generated code is not allowed: OS provenance analysis exceeded its finite bound") + self._changed_os_names.clear() def visit(self, node: ast.AST) -> Any: """Visit a node and check if its type is allowed.""" @@ -636,12 +650,20 @@ def _remember_os_chains( if isinstance(target, ast.Name): if not self._collecting_provenance: return + previous_count = len(self._os_aliases.get(target.id, set())) + len( + self._os_containers.get(target.id, set()) + ) if direct: self._os_aliases.setdefault(target.id, set()).update(self._normalize_os_chain(chain) for chain in direct) if contained: self._os_containers.setdefault(target.id, set()).update( self._normalize_os_chain(chain) for chain in contained ) + fact_count = len(self._os_aliases.get(target.id, set())) + len( + self._os_containers.get(target.id, set()) + ) + if fact_count > previous_count: + self._changed_os_names.add(target.id) elif isinstance(target, (ast.Tuple, ast.List)): possible = direct | contained for target_item in target.elts: diff --git a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionIntegrationTests.cs index e974a4c77a6..c7118a452ab 100644 --- a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionIntegrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionIntegrationTests.cs @@ -520,6 +520,64 @@ public async Task ExecuteCode_ValidationBlocksLongLoopCarriedAliasesBeforeRunner Assert.DoesNotContain("finite bound", exception.Message, StringComparison.Ordinal); } + [Theory] + [InlineData(250)] + [InlineData(2000)] + public async Task Validator_ProvenanceVisitsScaleWithAliasChainLengthAsync(int aliasCount) + { + SkipIfNoPython(); + + // Arrange + var tempDir = Directory.CreateTempSubdirectory("localcodeact-validator-worklist-").FullName; + try + { + var scriptPath = Path.Combine(tempDir, "probe_validator.py"); + File.WriteAllText( + scriptPath, + $$""" + import runpy + import sys + + sys.stdin.read() + namespace = runpy.run_path({{JsonSerializer.Serialize(Internal.EmbeddedScripts.GetValidatorScriptPath())}}) + + class CountingValidator(namespace["_CodeValidator"]): + def __init__(self): + super().__init__() + self.propagations = 0 + + def _track_os_provenance(self, target, value): + if self._collecting_provenance: + self.propagations += 1 + super()._track_os_provenance(target, value) + + alias_count = {{aliasCount}} + code = "import os\nif False:\n" + code += "".join(f" cap{index} = cap{index + 1}\n" for index in range(alias_count)) + code += f" cap{alias_count} = os\n" + validator = CountingValidator() + validator.validate(code) + assert validator._os_aliases["cap0"] == {("os",)} + assert validator.propagations <= 2 * (alias_count + 1), validator.propagations + """); + var validator = new Internal.CodeValidator( + s_python!, + scriptPath, + TimeSpan.FromSeconds(10), + allowedImports: null, + blockedImports: null, + allowedBuiltins: null, + blockedBuiltins: null); + + // Act & Assert + await validator.ValidateAsync("print('safe')", CancellationToken.None); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + [Theory] [InlineData("first = second\nsecond = first", true)] [InlineData("first = [second]\nsecond = [first]", true)]