Skip to content

Commit f139ac8

Browse files
authored
Merge pull request #221 from GitHubSecurityLab/anticomputer/env-globals
Pass globals context to env block template rendering
2 parents 7f64c4e + 87d5078 commit f139ac8

3 files changed

Lines changed: 124 additions & 19 deletions

File tree

src/seclab_taskflow_agent/env_utils.py

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,49 +12,68 @@
1212

1313

1414

15-
def swap_env(s: str) -> str:
16-
"""Replace {{ env('VAR') }} patterns in string with environment values.
15+
def swap_env(s: str, context: dict[str, Any] | None = None) -> str:
16+
"""Render Jinja template expressions in a string.
17+
18+
Supports expressions such as ``{{ env('VAR') }}``. Template variables
19+
like ``{{ globals.X }}`` are only available when provided by the caller
20+
via ``context`` (e.g. ``{'globals': {...}}``).
1721
1822
Args:
19-
s: String potentially containing env templates
23+
s: String potentially containing templates.
24+
context: Optional template context. Variables such as ``globals``
25+
must be supplied here to be available during rendering.
2026
2127
Returns:
22-
String with env templates replaced
28+
String with templates replaced.
2329
2430
Raises:
25-
LookupError: If required env var not found
31+
LookupError: If a required environment variable or template
32+
variable is not found during rendering.
2633
"""
27-
# Quick check if templating needed
28-
if '{{' not in s:
29-
return s
30-
3134
try:
32-
# Import here to avoid circular dependency
3335
from .template_utils import create_jinja_environment
3436
from .available_tools import AvailableTools
3537

3638
available_tools = AvailableTools()
3739
jinja_env = create_jinja_environment(available_tools)
3840
template = jinja_env.from_string(s)
39-
return template.render()
41+
# Filter out keys that collide with built-in template globals
42+
# (e.g. the env() helper) to prevent callers from breaking them.
43+
reserved_keys = set(jinja_env.globals)
44+
render_context = {
45+
key: value for key, value in (context or {}).items()
46+
if key not in reserved_keys
47+
}
48+
return template.render(**render_context)
4049
except jinja2.UndefinedError as e:
41-
# Convert Jinja undefined to LookupError for compatibility
4250
raise LookupError(str(e))
43-
except jinja2.TemplateError:
44-
# Not a template or failed to render, return as-is
45-
return s
51+
except jinja2.TemplateError as e:
52+
raise LookupError(f"Template rendering failed for: {s!r}: {e}")
4653

4754

4855
class TmpEnv:
4956
"""Context manager that temporarily sets environment variables."""
5057

51-
def __init__(self, env: dict[str, str]) -> None:
58+
def __init__(self, env: dict[str, str],
59+
context: dict[str, Any] | None = None) -> None:
5260
self.env = dict(env)
61+
self.context = context
5362
self.restore_env = dict(os.environ)
5463

5564
def __enter__(self) -> None:
56-
for k, v in self.env.items():
57-
os.environ[k] = swap_env(v)
65+
applied: list[str] = []
66+
try:
67+
for k, v in self.env.items():
68+
os.environ[k] = swap_env(v, self.context)
69+
applied.append(k)
70+
except Exception:
71+
for k in applied:
72+
if k in self.restore_env:
73+
os.environ[k] = self.restore_env[k]
74+
else:
75+
os.environ.pop(k, None)
76+
raise
5877

5978
def __exit__(self, exc_type: type | None, exc_val: BaseException | None, exc_tb: Any | None) -> None:
6079
for k, v in self.env.items():

src/seclab_taskflow_agent/runner.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -579,7 +579,7 @@ async def on_handoff_hook(context: RunContextWrapper[TContext], agent: Agent[TCo
579579
logging.error(f"Template rendering error: {e}")
580580
raise ValueError(f"Failed to render prompt template: {e}") from e
581581

582-
with TmpEnv(env):
582+
with TmpEnv(env, context={"globals": global_variables}):
583583
prompts_to_run: list[str] = await _build_prompts_to_run(
584584
task_prompt, repeat_prompt, last_mcp_tool_results,
585585
available_tools, global_variables, inputs,

tests/test_env_utils.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# SPDX-FileCopyrightText: GitHub, Inc.
2+
# SPDX-License-Identifier: MIT
3+
4+
"""Tests for env_utils: swap_env and TmpEnv with globals context."""
5+
6+
import os
7+
8+
import pytest
9+
10+
from seclab_taskflow_agent.env_utils import TmpEnv, swap_env
11+
12+
13+
class TestSwapEnv:
14+
"""Tests for swap_env template rendering."""
15+
16+
def test_plain_string_unchanged(self):
17+
assert swap_env("no templates here") == "no templates here"
18+
19+
def test_env_function_works(self):
20+
os.environ["TEST_SWAP_ENV_VAR"] = "hello"
21+
try:
22+
assert swap_env('{{ env("TEST_SWAP_ENV_VAR") }}') == "hello"
23+
finally:
24+
del os.environ["TEST_SWAP_ENV_VAR"]
25+
26+
def test_globals_with_context(self):
27+
result = swap_env(
28+
"key-{{ globals.ghsa_id }}",
29+
context={"globals": {"ghsa_id": "GHSA-1234"}},
30+
)
31+
assert result == "key-GHSA-1234"
32+
33+
def test_globals_without_context_raises(self):
34+
with pytest.raises(LookupError):
35+
swap_env("{{ globals.missing }}")
36+
37+
def test_context_cannot_override_env_helper(self):
38+
"""Passing an 'env' key in context must not shadow the env() function."""
39+
os.environ["TEST_SWAP_RESERVED"] = "works"
40+
try:
41+
result = swap_env(
42+
'{{ env("TEST_SWAP_RESERVED") }}',
43+
context={"env": "should be filtered"},
44+
)
45+
assert result == "works"
46+
finally:
47+
del os.environ["TEST_SWAP_RESERVED"]
48+
49+
def test_no_context_backward_compat(self):
50+
assert swap_env("plain") == "plain"
51+
52+
53+
class TestTmpEnv:
54+
"""Tests for TmpEnv context manager with globals."""
55+
56+
def test_globals_rendered_in_env_block(self):
57+
env = {"MY_KEY": "pvr-{{ globals.ghsa }}"}
58+
ctx = {"globals": {"ghsa": "GHSA-5678"}}
59+
with TmpEnv(env, context=ctx):
60+
assert os.environ["MY_KEY"] == "pvr-GHSA-5678"
61+
assert "MY_KEY" not in os.environ
62+
63+
def test_env_function_still_works_in_tmpenv(self):
64+
os.environ["SOURCE_VAR"] = "value"
65+
try:
66+
env = {"DEST_VAR": '{{ env("SOURCE_VAR") }}'}
67+
with TmpEnv(env):
68+
assert os.environ["DEST_VAR"] == "value"
69+
finally:
70+
del os.environ["SOURCE_VAR"]
71+
72+
def test_tmpenv_restores_original(self):
73+
os.environ["RESTORE_TEST"] = "original"
74+
env = {"RESTORE_TEST": "overwritten"}
75+
with TmpEnv(env):
76+
assert os.environ["RESTORE_TEST"] == "overwritten"
77+
assert os.environ["RESTORE_TEST"] == "original"
78+
del os.environ["RESTORE_TEST"]
79+
80+
def test_tmpenv_rollback_on_error(self):
81+
"""Partial env modification is rolled back if swap_env raises."""
82+
env = {"GOOD_KEY": "value", "BAD_KEY": "{{ globals.missing }}"}
83+
with pytest.raises(LookupError), TmpEnv(env):
84+
pass
85+
assert "GOOD_KEY" not in os.environ
86+
assert "BAD_KEY" not in os.environ

0 commit comments

Comments
 (0)