Skip to content

Commit 4b8a545

Browse files
Python: add powerfx safe mode (#3028)
* add powerfx safe mode * improved docstring and aligned env_file loading * ensured test uses reset
1 parent 5ab4759 commit 4b8a545

File tree

4 files changed

+242
-23
lines changed

4 files changed

+242
-23
lines changed

python/packages/declarative/agent_framework_declarative/_loader.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
RemoteConnection,
3838
Tool,
3939
WebSearchTool,
40+
_safe_mode_context,
4041
agent_schema_dispatch,
4142
)
4243

@@ -118,7 +119,9 @@ def __init__(
118119
client_kwargs: Mapping[str, Any] | None = None,
119120
additional_mappings: Mapping[str, ProviderTypeMapping] | None = None,
120121
default_provider: str = "AzureAIClient",
121-
env_file: str | None = None,
122+
safe_mode: bool = True,
123+
env_file_path: str | None = None,
124+
env_file_encoding: str | None = None,
122125
) -> None:
123126
"""Create the agent factory, with bindings.
124127
@@ -151,15 +154,24 @@ def __init__(
151154
that accepts the model.id value.
152155
default_provider: The default provider used when model.provider is not specified,
153156
default is "AzureAIClient".
154-
env_file: An optional path to a .env file to load environment variables from.
157+
safe_mode: Whether to run in safe mode, default is True.
158+
When safe_mode is True, environment variables are not accessible in the powerfx expressions.
159+
You can still use environment variables, but through the constructors of the classes.
160+
Which means you must make sure you are using the standard env variable names of the classes
161+
you are using and not custom ones and remove the powerfx statements that start with `=Env.`.
162+
Only when you trust the source of your yaml files, you can set safe_mode to False
163+
via the AgentFactory constructor.
164+
env_file_path: The path to the .env file to load environment variables from.
165+
env_file_encoding: The encoding of the .env file, defaults to 'utf-8'.
155166
"""
156167
self.chat_client = chat_client
157168
self.bindings = bindings
158169
self.connections = connections
159170
self.client_kwargs = client_kwargs or {}
160171
self.additional_mappings = additional_mappings or {}
161172
self.default_provider: str = default_provider
162-
load_dotenv(dotenv_path=env_file)
173+
self.safe_mode = safe_mode
174+
load_dotenv(dotenv_path=env_file_path, encoding=env_file_encoding)
163175

164176
def create_agent_from_yaml_path(self, yaml_path: str | Path) -> ChatAgent:
165177
"""Create a ChatAgent from a YAML file path.
@@ -215,6 +227,8 @@ def create_agent_from_yaml(self, yaml_str: str) -> ChatAgent:
215227
ModuleNotFoundError: If the required module for the provider type cannot be imported.
216228
AttributeError: If the required class for the provider type cannot be found in the module.
217229
"""
230+
# Set safe_mode context before parsing YAML to control PowerFx environment variable access
231+
_safe_mode_context.set(self.safe_mode)
218232
prompt_agent = agent_schema_dispatch(yaml.safe_load(yaml_str))
219233
if not isinstance(prompt_agent, PromptAgent):
220234
raise DeclarativeLoaderError("Only yaml definitions for a PromptAgent are supported for agent creation.")

python/packages/declarative/agent_framework_declarative/_models.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import os
33
import sys
44
from collections.abc import MutableMapping
5+
from contextvars import ContextVar
56
from typing import Any, Literal, TypeVar, Union
67

78
from agent_framework import get_logger
@@ -21,6 +22,11 @@
2122

2223
logger = get_logger("agent_framework.declarative")
2324

25+
# Context variable for safe_mode setting.
26+
# When True (default), environment variables are NOT accessible in PowerFx expressions.
27+
# When False, environment variables CAN be accessed via Env symbol in PowerFx.
28+
_safe_mode_context: ContextVar[bool] = ContextVar("safe_mode", default=True)
29+
2430

2531
@overload
2632
def _try_powerfx_eval(value: None, log_value: bool = True) -> None: ...
@@ -49,6 +55,9 @@ def _try_powerfx_eval(value: str | None, log_value: bool = True) -> str | None:
4955
)
5056
return value
5157
try:
58+
safe_mode = _safe_mode_context.get()
59+
if safe_mode:
60+
return engine.eval(value[1:])
5261
return engine.eval(value[1:], symbols={"Env": dict(os.environ)})
5362
except Exception as exc:
5463
if log_value:

python/packages/declarative/tests/test_declarative_loader.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,3 +454,140 @@ def test_agent_schema_dispatch_agent_samples(yaml_file: Path, agent_samples_dir:
454454
result = agent_schema_dispatch(yaml.safe_load(content))
455455
# Result can be None for unknown kinds, but should not raise exceptions
456456
assert result is not None, f"agent_schema_dispatch returned None for {yaml_file.relative_to(agent_samples_dir)}"
457+
458+
459+
class TestAgentFactorySafeMode:
460+
"""Tests for AgentFactory safe_mode parameter."""
461+
462+
def test_agent_factory_safe_mode_default_is_true(self):
463+
"""Test that safe_mode is True by default."""
464+
from agent_framework_declarative._loader import AgentFactory
465+
466+
factory = AgentFactory()
467+
assert factory.safe_mode is True
468+
469+
def test_agent_factory_safe_mode_can_be_set_false(self):
470+
"""Test that safe_mode can be explicitly set to False."""
471+
from agent_framework_declarative._loader import AgentFactory
472+
473+
factory = AgentFactory(safe_mode=False)
474+
assert factory.safe_mode is False
475+
476+
def test_agent_factory_safe_mode_blocks_env_in_yaml(self, monkeypatch):
477+
"""Test that safe_mode=True blocks environment variable access in YAML parsing."""
478+
from unittest.mock import MagicMock
479+
480+
from agent_framework_declarative._loader import AgentFactory
481+
482+
monkeypatch.setenv("TEST_MODEL_ID", "gpt-4-from-env")
483+
484+
# Create a mock chat client to avoid needing real provider
485+
mock_client = MagicMock()
486+
487+
yaml_content = """
488+
kind: Prompt
489+
name: test-agent
490+
description: =Env.TEST_DESCRIPTION
491+
instructions: Hello world
492+
"""
493+
monkeypatch.setenv("TEST_DESCRIPTION", "Description from env")
494+
495+
# With safe_mode=True (default), Env access should fail and return original value
496+
factory = AgentFactory(chat_client=mock_client, safe_mode=True)
497+
agent = factory.create_agent_from_yaml(yaml_content)
498+
499+
# The description should NOT be resolved from env (PowerFx fails, returns original)
500+
assert agent.description == "=Env.TEST_DESCRIPTION"
501+
502+
def test_agent_factory_safe_mode_false_allows_env_in_yaml(self, monkeypatch):
503+
"""Test that safe_mode=False allows environment variable access in YAML parsing."""
504+
from unittest.mock import MagicMock
505+
506+
from agent_framework_declarative._loader import AgentFactory
507+
508+
monkeypatch.setenv("TEST_DESCRIPTION", "Description from env")
509+
510+
# Create a mock chat client to avoid needing real provider
511+
mock_client = MagicMock()
512+
513+
yaml_content = """
514+
kind: Prompt
515+
name: test-agent
516+
description: =Env.TEST_DESCRIPTION
517+
instructions: Hello world
518+
"""
519+
520+
# With safe_mode=False, Env access should work
521+
factory = AgentFactory(chat_client=mock_client, safe_mode=False)
522+
agent = factory.create_agent_from_yaml(yaml_content)
523+
524+
# The description should be resolved from env
525+
assert agent.description == "Description from env"
526+
527+
def test_agent_factory_safe_mode_with_api_key_connection(self, monkeypatch):
528+
"""Test safe_mode with API key connection containing env variable."""
529+
from agent_framework_declarative._models import _safe_mode_context
530+
531+
monkeypatch.setenv("MY_API_KEY", "secret-key-123")
532+
533+
yaml_content = """
534+
kind: Prompt
535+
name: test-agent
536+
description: Test agent
537+
instructions: Hello
538+
model:
539+
id: gpt-4
540+
provider: OpenAI
541+
apiType: Chat
542+
connection:
543+
kind: key
544+
apiKey: =Env.MY_API_KEY
545+
"""
546+
547+
# Manually trigger the YAML parsing to check the context is set correctly
548+
import yaml as yaml_module
549+
550+
from agent_framework_declarative._models import agent_schema_dispatch
551+
552+
token = _safe_mode_context.set(True) # Ensure we're in safe mode
553+
try:
554+
result = agent_schema_dispatch(yaml_module.safe_load(yaml_content))
555+
556+
# The API key should NOT be resolved (still has the PowerFx expression)
557+
assert result.model.connection.apiKey == "=Env.MY_API_KEY"
558+
finally:
559+
_safe_mode_context.reset(token)
560+
561+
def test_agent_factory_safe_mode_false_resolves_api_key(self, monkeypatch):
562+
"""Test safe_mode=False resolves API key from environment."""
563+
from agent_framework_declarative._models import _safe_mode_context
564+
565+
monkeypatch.setenv("MY_API_KEY", "secret-key-123")
566+
567+
yaml_content = """
568+
kind: Prompt
569+
name: test-agent
570+
description: Test agent
571+
instructions: Hello
572+
model:
573+
id: gpt-4
574+
provider: OpenAI
575+
apiType: Chat
576+
connection:
577+
kind: key
578+
apiKey: =Env.MY_API_KEY
579+
"""
580+
581+
# With safe_mode=False, the API key should be resolved
582+
import yaml as yaml_module
583+
584+
from agent_framework_declarative._models import agent_schema_dispatch
585+
586+
token = _safe_mode_context.set(False) # Disable safe mode
587+
try:
588+
result = agent_schema_dispatch(yaml_module.safe_load(yaml_content))
589+
590+
# The API key should be resolved from environment
591+
assert result.model.connection.apiKey == "secret-key-123"
592+
finally:
593+
_safe_mode_context.reset(token)

python/packages/declarative/tests/test_declarative_models.py

Lines changed: 79 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
Template,
4242
ToolResource,
4343
WebSearchTool,
44+
_safe_mode_context,
4445
_try_powerfx_eval,
4546
)
4647

@@ -874,35 +875,50 @@ def test_env_variable_access(self, monkeypatch):
874875
monkeypatch.setenv("API_KEY", "secret123")
875876
monkeypatch.setenv("PORT", "8080")
876877

877-
# Test basic env access
878-
assert _try_powerfx_eval("=Env.TEST_VAR") == "test_value"
879-
assert _try_powerfx_eval("=Env.API_KEY") == "secret123"
880-
assert _try_powerfx_eval("=Env.PORT") == "8080"
878+
# Set safe_mode=False to allow environment variable access
879+
token = _safe_mode_context.set(False)
880+
try:
881+
# Test basic env access
882+
assert _try_powerfx_eval("=Env.TEST_VAR") == "test_value"
883+
assert _try_powerfx_eval("=Env.API_KEY") == "secret123"
884+
assert _try_powerfx_eval("=Env.PORT") == "8080"
885+
finally:
886+
_safe_mode_context.reset(token)
881887

882888
def test_env_variable_with_string_concatenation(self, monkeypatch):
883889
"""Test env variables with string concatenation operator."""
884890
monkeypatch.setenv("BASE_URL", "https://api.example.com")
885891
monkeypatch.setenv("API_VERSION", "v1")
886892

887-
# Test concatenation with &
888-
result = _try_powerfx_eval('=Env.BASE_URL & "/" & Env.API_VERSION')
889-
assert result == "https://api.example.com/v1"
893+
# Set safe_mode=False to allow environment variable access
894+
token = _safe_mode_context.set(False)
895+
try:
896+
# Test concatenation with &
897+
result = _try_powerfx_eval('=Env.BASE_URL & "/" & Env.API_VERSION')
898+
assert result == "https://api.example.com/v1"
890899

891-
# Test concatenation with literals
892-
result = _try_powerfx_eval('="API Key: " & Env.API_VERSION')
893-
assert result == "API Key: v1"
900+
# Test concatenation with literals
901+
result = _try_powerfx_eval('="API Key: " & Env.API_VERSION')
902+
assert result == "API Key: v1"
903+
finally:
904+
_safe_mode_context.reset(token)
894905

895906
def test_string_comparison_operators(self, monkeypatch):
896907
"""Test PowerFx string comparison operators."""
897908
monkeypatch.setenv("ENV_MODE", "production")
898909

899-
# Equal to - returns bool
900-
assert _try_powerfx_eval('=Env.ENV_MODE = "production"') is True
901-
assert _try_powerfx_eval('=Env.ENV_MODE = "development"') is False
910+
# Set safe_mode=False to allow environment variable access
911+
token = _safe_mode_context.set(False)
912+
try:
913+
# Equal to - returns bool
914+
assert _try_powerfx_eval('=Env.ENV_MODE = "production"') is True
915+
assert _try_powerfx_eval('=Env.ENV_MODE = "development"') is False
902916

903-
# Not equal to - returns bool
904-
assert _try_powerfx_eval('=Env.ENV_MODE <> "development"') is True
905-
assert _try_powerfx_eval('=Env.ENV_MODE <> "production"') is False
917+
# Not equal to - returns bool
918+
assert _try_powerfx_eval('=Env.ENV_MODE <> "development"') is True
919+
assert _try_powerfx_eval('=Env.ENV_MODE <> "production"') is False
920+
finally:
921+
_safe_mode_context.reset(token)
906922

907923
def test_string_in_operator(self):
908924
"""Test PowerFx 'in' operator for substring testing (case-insensitive)."""
@@ -958,11 +974,54 @@ def test_env_with_special_characters(self, monkeypatch):
958974
monkeypatch.setenv("URL_WITH_QUERY", "https://example.com?param=value")
959975
monkeypatch.setenv("PATH_WITH_SPACES", "C:\\Program Files\\App")
960976

961-
result = _try_powerfx_eval("=Env.URL_WITH_QUERY")
962-
assert result == "https://example.com?param=value"
977+
# Set safe_mode=False to allow environment variable access
978+
token = _safe_mode_context.set(False)
979+
try:
980+
result = _try_powerfx_eval("=Env.URL_WITH_QUERY")
981+
assert result == "https://example.com?param=value"
982+
983+
result = _try_powerfx_eval("=Env.PATH_WITH_SPACES")
984+
assert result == "C:\\Program Files\\App"
985+
finally:
986+
_safe_mode_context.reset(token)
987+
988+
def test_safe_mode_blocks_env_access(self, monkeypatch):
989+
"""Test that safe_mode=True (default) blocks environment variable access."""
990+
monkeypatch.setenv("SECRET_VAR", "secret_value")
991+
992+
# Set safe_mode=True (default)
993+
token = _safe_mode_context.set(True)
994+
try:
995+
# When safe_mode=True, Env is not available and the expression fails,
996+
# returning the original value
997+
result = _try_powerfx_eval("=Env.SECRET_VAR")
998+
assert result == "=Env.SECRET_VAR"
999+
finally:
1000+
_safe_mode_context.reset(token)
1001+
1002+
def test_safe_mode_context_isolation(self, monkeypatch):
1003+
"""Test that safe_mode context variable properly isolates env access."""
1004+
monkeypatch.setenv("TEST_VAR", "test_value")
9631005

964-
result = _try_powerfx_eval("=Env.PATH_WITH_SPACES")
965-
assert result == "C:\\Program Files\\App"
1006+
# First, set safe_mode=True - should NOT allow env access
1007+
token = _safe_mode_context.set(True)
1008+
try:
1009+
result_safe = _try_powerfx_eval("=Env.TEST_VAR")
1010+
assert result_safe == "=Env.TEST_VAR"
1011+
1012+
# Then, set safe_mode=False - should allow env access
1013+
token2 = _safe_mode_context.set(False)
1014+
try:
1015+
result_unsafe = _try_powerfx_eval("=Env.TEST_VAR")
1016+
assert result_unsafe == "test_value"
1017+
finally:
1018+
_safe_mode_context.reset(token2)
1019+
1020+
# After reset, should block again
1021+
result_safe_again = _try_powerfx_eval("=Env.TEST_VAR")
1022+
assert result_safe_again == "=Env.TEST_VAR"
1023+
finally:
1024+
_safe_mode_context.reset(token)
9661025

9671026

9681027
class TestAgentManifest:

0 commit comments

Comments
 (0)