Skip to content

Commit 5b4d26e

Browse files
xr843claude
andcommitted
feat(code_executors): add opt-in sandbox flag to LocalCommandLineCodeExecutor (#7462)
Adds a ``sandbox: Optional[bool]`` parameter (plus ``sandbox_memory_bytes``) to LocalCommandLineCodeExecutor and its Config, addressing #7462. Three modes: - ``None`` (default): emits DeprecationWarning + logger.warning that a future release will make the parameter required. Execution unchanged -- fully backward compatible. Replaces the previous UserWarning. - ``False``: explicit acknowledgement of unsandboxed execution; no warning. - ``True``: best-effort in-process hardening on POSIX via ``preexec_fn`` applying ``RLIMIT_CPU`` (timeout + 5s) and ``RLIMIT_AS`` (default 512 MiB), plus credential env-var scrub (``*_API_KEY``, ``*_TOKEN``, ``*_SECRET``, ``AWS_*``, ``OPENAI_*``, ``ANTHROPIC_*``, ``GITHUB_TOKEN``, etc.) applied to both code-execution and pip-install subprocesses. On Windows, resource limits are unavailable and a warning is logged; env scrub still applies. Docstring is explicit that this is NOT a substitute for DockerCommandLineCodeExecutor. This PR is draft pending maintainer direction on Windows strategy before further investment. Supersedes #7467 with broader scope. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 027ecf0 commit 5b4d26e

2 files changed

Lines changed: 281 additions & 9 deletions

File tree

python/packages/autogen-ext/src/autogen_ext/code_executors/local/__init__.py

Lines changed: 158 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@
44
import asyncio
55
import logging
66
import os
7+
import re
78
import sys
89
import tempfile
910
import warnings
1011
from hashlib import sha256
1112
from pathlib import Path
1213
from string import Template
1314
from types import SimpleNamespace
14-
from typing import Any, Callable, ClassVar, List, Optional, Sequence, Union
15+
from typing import Any, Callable, ClassVar, Dict, List, Optional, Sequence, Tuple, Union
1516

1617
from autogen_core import CancellationToken, Component
1718
from autogen_core.code_executor import CodeBlock, CodeExecutor, FunctionWithRequirements, FunctionWithRequirementsStr
@@ -32,6 +33,71 @@
3233

3334
A = ParamSpec("A")
3435

36+
logger = logging.getLogger(__name__)
37+
38+
# Default hard memory cap applied via RLIMIT_AS when sandbox=True (POSIX only).
39+
# 512 MiB is enough for most small Python scripts while still bounding runaway
40+
# LLM-generated code. Callers that need more can override via the
41+
# ``sandbox_memory_bytes`` constructor argument.
42+
_DEFAULT_SANDBOX_MEMORY_BYTES: int = 512 * 1024 * 1024
43+
44+
# Env-var name patterns scrubbed when sandbox=True. Matched case-insensitively
45+
# against variable *names* (not values). Best-effort only: this is not a
46+
# substitute for a real sandbox, and secrets stored under non-matching names
47+
# will still leak to the subprocess.
48+
_CREDENTIAL_ENV_PATTERNS: Tuple[re.Pattern[str], ...] = (
49+
re.compile(r".*_API_KEY$", re.IGNORECASE),
50+
re.compile(r".*_TOKEN$", re.IGNORECASE),
51+
re.compile(r".*_SECRET$", re.IGNORECASE),
52+
re.compile(r".*PASSWORD.*", re.IGNORECASE),
53+
re.compile(r"^AWS_.*", re.IGNORECASE),
54+
re.compile(r"^AZURE_.*", re.IGNORECASE),
55+
re.compile(r"^GCP_.*", re.IGNORECASE),
56+
re.compile(r"^GOOGLE_.*", re.IGNORECASE),
57+
re.compile(r"^OPENAI_.*", re.IGNORECASE),
58+
re.compile(r"^ANTHROPIC_.*", re.IGNORECASE),
59+
re.compile(r"^HF_.*", re.IGNORECASE),
60+
re.compile(r"^HUGGINGFACE_.*", re.IGNORECASE),
61+
re.compile(r"^GITHUB_TOKEN$", re.IGNORECASE),
62+
re.compile(r"^GH_TOKEN$", re.IGNORECASE),
63+
re.compile(r"^NPM_TOKEN$", re.IGNORECASE),
64+
re.compile(r"^PYPI_.*", re.IGNORECASE),
65+
)
66+
67+
68+
def _scrub_credentials_from_env(env: Dict[str, str]) -> Dict[str, str]:
69+
"""Return a copy of ``env`` with credential-shaped variable names removed."""
70+
scrubbed: Dict[str, str] = {}
71+
for key, value in env.items():
72+
if any(pattern.match(key) for pattern in _CREDENTIAL_ENV_PATTERNS):
73+
continue
74+
scrubbed[key] = value
75+
return scrubbed
76+
77+
78+
def _make_sandbox_preexec_fn(cpu_seconds: int, memory_bytes: int) -> Optional[Callable[[], None]]:
79+
"""Build a ``preexec_fn`` that applies POSIX resource limits before ``exec``.
80+
81+
Returns ``None`` on platforms where the ``resource`` module is unavailable
82+
(notably Windows). The returned closure is intended to be passed as
83+
``preexec_fn`` to :func:`asyncio.create_subprocess_exec`.
84+
"""
85+
try:
86+
import resource # type: ignore[import-not-found]
87+
except ImportError:
88+
return None
89+
90+
def _apply_limits() -> None: # pragma: no cover - runs in child process
91+
resource.setrlimit(resource.RLIMIT_CPU, (cpu_seconds, cpu_seconds))
92+
try:
93+
resource.setrlimit(resource.RLIMIT_AS, (memory_bytes, memory_bytes))
94+
except (ValueError, OSError):
95+
# Some platforms (e.g. macOS) refuse RLIMIT_AS; continue so
96+
# sandbox=True still provides env-scrub + CPU capping.
97+
pass
98+
99+
return _apply_limits
100+
35101

36102
class LocalCommandLineCodeExecutorConfig(BaseModel):
37103
"""Configuration for LocalCommandLineCodeExecutor"""
@@ -40,6 +106,8 @@ class LocalCommandLineCodeExecutorConfig(BaseModel):
40106
work_dir: Optional[str] = None
41107
functions_module: str = "functions"
42108
cleanup_temp_files: bool = True
109+
sandbox: Optional[bool] = None
110+
sandbox_memory_bytes: int = _DEFAULT_SANDBOX_MEMORY_BYTES
43111

44112

45113
class LocalCommandLineCodeExecutor(CodeExecutor, Component[LocalCommandLineCodeExecutorConfig]):
@@ -81,6 +149,25 @@ class LocalCommandLineCodeExecutor(CodeExecutor, Component[LocalCommandLineCodeE
81149
functions_module (str, optional): The name of the module that will be created to store the functions. Defaults to "functions".
82150
cleanup_temp_files (bool, optional): Whether to automatically clean up temporary files after execution. Defaults to True.
83151
virtual_env_context (Optional[SimpleNamespace], optional): The virtual environment context. Defaults to None.
152+
sandbox (Optional[bool], optional): Opt-in best-effort hardening for LLM-generated code.
153+
This is **NOT** a substitute for :class:`~autogen_ext.code_executors.docker.DockerCommandLineCodeExecutor`
154+
and provides no strong isolation guarantees; it is only a defense-in-depth layer for
155+
users who cannot run Docker.
156+
157+
* ``None`` (default): backward-compatible behavior. Emits a :class:`DeprecationWarning`
158+
noting that a future release will make this parameter required, and executes code
159+
without any added hardening.
160+
* ``False``: explicit acknowledgement that no sandboxing should be applied. No
161+
warning is emitted.
162+
* ``True``: applies POSIX ``RLIMIT_CPU`` (= ``timeout + 5`` seconds) and
163+
``RLIMIT_AS`` (default 512 MiB, see ``sandbox_memory_bytes``) via ``preexec_fn``
164+
on code-execution and pip-install subprocesses, and scrubs credential-shaped
165+
environment variables (``*_API_KEY``, ``*_TOKEN``, ``*_SECRET``, ``AWS_*``,
166+
``OPENAI_*``, etc.) from the subprocess environment. On Windows, resource limits
167+
are unavailable and a warning is logged; env scrub still applies.
168+
sandbox_memory_bytes (int, optional): Address-space cap (``RLIMIT_AS``) applied when
169+
``sandbox=True`` on POSIX. Defaults to 512 MiB. Ignored when ``sandbox`` is ``None``
170+
or ``False``, and on platforms without the ``resource`` module.
84171
85172
.. note::
86173
Using the current directory (".") as working directory is deprecated. Using it will raise a deprecation warning.
@@ -158,15 +245,38 @@ def __init__(
158245
functions_module: str = "functions",
159246
cleanup_temp_files: bool = True,
160247
virtual_env_context: Optional[SimpleNamespace] = None,
248+
sandbox: Optional[bool] = None,
249+
sandbox_memory_bytes: int = _DEFAULT_SANDBOX_MEMORY_BYTES,
161250
):
162-
# Issue warning about using LocalCommandLineCodeExecutor
163-
warnings.warn(
164-
"Using LocalCommandLineCodeExecutor may execute code on the local machine which can be unsafe. "
165-
"For security, it is recommended to use DockerCommandLineCodeExecutor instead. "
166-
"To install Docker, visit: https://docs.docker.com/get-docker/",
167-
UserWarning,
168-
stacklevel=2,
169-
)
251+
# Warn based on the caller's choice of ``sandbox``. When ``sandbox`` is
252+
# left at its default (``None``), we emit a ``DeprecationWarning`` so
253+
# callers are nudged toward making an explicit decision before a future
254+
# release makes the parameter required. An explicit ``False`` opts out
255+
# of the warning (but preserves the insecure behavior), while ``True``
256+
# enables best-effort hardening. See issue #7462.
257+
if sandbox is None:
258+
deprecation_msg = (
259+
"LocalCommandLineCodeExecutor was constructed without an explicit "
260+
"`sandbox` argument. A future release will require this parameter. "
261+
"Pass `sandbox=True` for best-effort in-process hardening (POSIX "
262+
"resource limits + credential env-var scrub), or `sandbox=False` to "
263+
"explicitly acknowledge unsandboxed execution. For real isolation, "
264+
"prefer DockerCommandLineCodeExecutor. See "
265+
"https://github.com/microsoft/autogen/issues/7462."
266+
)
267+
warnings.warn(deprecation_msg, DeprecationWarning, stacklevel=2)
268+
logger.warning(deprecation_msg)
269+
270+
self._sandbox: Optional[bool] = sandbox
271+
self._sandbox_memory_bytes: int = sandbox_memory_bytes
272+
if sandbox is True and sys.platform == "win32":
273+
logger.warning(
274+
"LocalCommandLineCodeExecutor(sandbox=True) on Windows: POSIX "
275+
"resource limits (RLIMIT_CPU, RLIMIT_AS) are not available on "
276+
"this platform. Credential env-var scrubbing still applies, but "
277+
"no CPU/memory caps will be enforced. Use "
278+
"DockerCommandLineCodeExecutor for real isolation."
279+
)
170280

171281
if timeout < 1:
172282
raise ValueError("Timeout must be greater than or equal to 1.")
@@ -270,6 +380,33 @@ def cleanup_temp_files(self) -> bool:
270380
"""(Experimental) Whether to automatically clean up temporary files after execution."""
271381
return self._cleanup_temp_files
272382

383+
@property
384+
def sandbox(self) -> Optional[bool]:
385+
"""(Experimental) Whether sandbox hardening is enabled. See ``__init__`` docs."""
386+
return self._sandbox
387+
388+
def _prepare_sandbox_subprocess_kwargs(self, env: Dict[str, str]) -> Tuple[Dict[str, str], Dict[str, Any]]:
389+
"""Apply sandbox policy to a subprocess env + kwargs pair.
390+
391+
Returns ``(env, extra_kwargs)``. When ``sandbox`` is not ``True``, the
392+
env is returned unchanged and extra_kwargs is empty. When ``sandbox`` is
393+
``True``, credential-shaped env vars are stripped and (on POSIX) a
394+
``preexec_fn`` applying RLIMIT_CPU and RLIMIT_AS is attached.
395+
"""
396+
if self._sandbox is not True:
397+
return env, {}
398+
399+
scrubbed = _scrub_credentials_from_env(env)
400+
extra_kwargs: Dict[str, Any] = {}
401+
if sys.platform != "win32":
402+
preexec = _make_sandbox_preexec_fn(
403+
cpu_seconds=self._timeout + 5,
404+
memory_bytes=self._sandbox_memory_bytes,
405+
)
406+
if preexec is not None:
407+
extra_kwargs["preexec_fn"] = preexec
408+
return scrubbed, extra_kwargs
409+
273410
async def _setup_functions(self, cancellation_token: CancellationToken) -> None:
274411
func_file_content = build_python_functions_file(self._functions)
275412
func_file = self.work_dir / f"{self._functions_module}.py"
@@ -290,13 +427,17 @@ async def _setup_functions(self, cancellation_token: CancellationToken) -> None:
290427
else:
291428
py_executable = sys.executable
292429

430+
pip_env, pip_extra_kwargs = self._prepare_sandbox_subprocess_kwargs(os.environ.copy())
431+
293432
task = asyncio.create_task(
294433
asyncio.create_subprocess_exec(
295434
py_executable,
296435
*cmd_args,
297436
cwd=self.work_dir,
298437
stdout=asyncio.subprocess.PIPE,
299438
stderr=asyncio.subprocess.PIPE,
439+
env=pip_env,
440+
**pip_extra_kwargs,
300441
)
301442
)
302443
cancellation_token.link_future(task)
@@ -422,6 +563,9 @@ async def _execute_code_dont_check_setup(
422563
# Shell commands (bash, sh, etc.)
423564
extra_args = [str(written_file.absolute())]
424565

566+
# Apply sandbox policy (env scrub + POSIX rlimits) if enabled.
567+
env, sandbox_kwargs = self._prepare_sandbox_subprocess_kwargs(env)
568+
425569
# Create a subprocess and run
426570
task = asyncio.create_task(
427571
asyncio.create_subprocess_exec(
@@ -431,6 +575,7 @@ async def _execute_code_dont_check_setup(
431575
stdout=asyncio.subprocess.PIPE,
432576
stderr=asyncio.subprocess.PIPE,
433577
env=env,
578+
**sandbox_kwargs,
434579
)
435580
)
436581
cancellation_token.link_future(task)
@@ -514,6 +659,8 @@ def _to_config(self) -> LocalCommandLineCodeExecutorConfig:
514659
work_dir=str(self.work_dir),
515660
functions_module=self._functions_module,
516661
cleanup_temp_files=self._cleanup_temp_files,
662+
sandbox=self._sandbox,
663+
sandbox_memory_bytes=self._sandbox_memory_bytes,
517664
)
518665

519666
@classmethod
@@ -523,4 +670,6 @@ def _from_config(cls, config: LocalCommandLineCodeExecutorConfig) -> Self:
523670
work_dir=Path(config.work_dir) if config.work_dir is not None else None,
524671
functions_module=config.functions_module,
525672
cleanup_temp_files=config.cleanup_temp_files,
673+
sandbox=config.sandbox,
674+
sandbox_memory_bytes=config.sandbox_memory_bytes,
526675
)
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# Tests for the opt-in ``sandbox`` parameter on LocalCommandLineCodeExecutor.
2+
# See https://github.com/microsoft/autogen/issues/7462 for context.
3+
4+
import os
5+
import sys
6+
import warnings
7+
8+
import pytest
9+
from autogen_core import CancellationToken
10+
from autogen_core.code_executor import CodeBlock
11+
from autogen_ext.code_executors.local import LocalCommandLineCodeExecutor
12+
from autogen_ext.code_executors.local import (
13+
LocalCommandLineCodeExecutorConfig,
14+
)
15+
16+
17+
def test_sandbox_none_emits_deprecation_warning() -> None:
18+
with warnings.catch_warnings(record=True) as caught:
19+
warnings.simplefilter("always")
20+
LocalCommandLineCodeExecutor()
21+
22+
deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)]
23+
user_warnings = [
24+
w
25+
for w in caught
26+
if issubclass(w.category, UserWarning) and "DockerCommandLineCodeExecutor" in str(w.message)
27+
]
28+
assert deprecations, "sandbox=None must emit a DeprecationWarning"
29+
assert not user_warnings, "legacy UserWarning should be replaced by DeprecationWarning when sandbox=None"
30+
31+
32+
def test_sandbox_false_emits_no_warning() -> None:
33+
with warnings.catch_warnings(record=True) as caught:
34+
warnings.simplefilter("always")
35+
LocalCommandLineCodeExecutor(sandbox=False)
36+
37+
sandbox_related = [
38+
w
39+
for w in caught
40+
if "sandbox" in str(w.message).lower() or issubclass(w.category, DeprecationWarning)
41+
]
42+
assert not sandbox_related, f"sandbox=False should not emit warnings, got: {[str(w.message) for w in caught]}"
43+
44+
45+
def test_sandbox_true_no_deprecation_warning() -> None:
46+
with warnings.catch_warnings(record=True) as caught:
47+
warnings.simplefilter("always")
48+
LocalCommandLineCodeExecutor(sandbox=True)
49+
50+
deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)]
51+
assert not deprecations, "sandbox=True should not emit DeprecationWarning"
52+
53+
54+
def test_config_round_trip_preserves_sandbox() -> None:
55+
for sandbox_value in (None, True, False):
56+
with warnings.catch_warnings():
57+
warnings.simplefilter("ignore")
58+
executor = LocalCommandLineCodeExecutor(sandbox=sandbox_value, sandbox_memory_bytes=123456789)
59+
config = executor._to_config()
60+
assert config.sandbox == sandbox_value
61+
assert config.sandbox_memory_bytes == 123456789
62+
63+
with warnings.catch_warnings():
64+
warnings.simplefilter("ignore")
65+
rebuilt = LocalCommandLineCodeExecutor._from_config(config)
66+
assert rebuilt.sandbox == sandbox_value
67+
assert rebuilt._sandbox_memory_bytes == 123456789
68+
69+
70+
def test_config_model_defaults() -> None:
71+
cfg = LocalCommandLineCodeExecutorConfig()
72+
assert cfg.sandbox is None
73+
assert cfg.sandbox_memory_bytes == 512 * 1024 * 1024
74+
75+
76+
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only: env scrub + rlimits")
77+
@pytest.mark.asyncio
78+
async def test_sandbox_true_scrubs_credential_env_vars(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
79+
monkeypatch.setenv("FAKE_API_KEY", "xyz-should-not-leak")
80+
monkeypatch.setenv("HARMLESS_VAR", "keep-me")
81+
82+
with warnings.catch_warnings():
83+
warnings.simplefilter("ignore")
84+
executor = LocalCommandLineCodeExecutor(work_dir=tmp_path, sandbox=True)
85+
86+
result = await executor.execute_code_blocks(
87+
code_blocks=[
88+
CodeBlock(
89+
language="bash",
90+
code='echo "API=${FAKE_API_KEY:-MISSING}"; echo "OK=${HARMLESS_VAR:-MISSING}"',
91+
)
92+
],
93+
cancellation_token=CancellationToken(),
94+
)
95+
assert result.exit_code == 0, result.output
96+
assert "API=MISSING" in result.output, f"FAKE_API_KEY was not scrubbed. Output: {result.output!r}"
97+
assert "OK=keep-me" in result.output, f"HARMLESS_VAR should have been preserved. Output: {result.output!r}"
98+
99+
100+
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only")
101+
@pytest.mark.asyncio
102+
async def test_sandbox_false_preserves_env_vars(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
103+
monkeypatch.setenv("FAKE_API_KEY", "xyz-should-be-visible")
104+
105+
with warnings.catch_warnings():
106+
warnings.simplefilter("ignore")
107+
executor = LocalCommandLineCodeExecutor(work_dir=tmp_path, sandbox=False)
108+
109+
result = await executor.execute_code_blocks(
110+
code_blocks=[CodeBlock(language="bash", code='echo "API=${FAKE_API_KEY:-MISSING}"')],
111+
cancellation_token=CancellationToken(),
112+
)
113+
assert result.exit_code == 0, result.output
114+
assert "API=xyz-should-be-visible" in result.output
115+
116+
117+
def test_sandbox_true_imports_cleanly_on_windows() -> None:
118+
# Smoke test: on any platform, constructing with sandbox=True must not raise.
119+
# On Windows the Windows degrade path only logs a warning.
120+
with warnings.catch_warnings():
121+
warnings.simplefilter("ignore")
122+
executor = LocalCommandLineCodeExecutor(sandbox=True)
123+
assert executor.sandbox is True

0 commit comments

Comments
 (0)