Skip to content

Commit 4c073ab

Browse files
committed
fix(security): deactivate inherited log captures
1 parent ece9f49 commit 4c073ab

2 files changed

Lines changed: 67 additions & 9 deletions

File tree

src/lfx/src/lfx/cli/common.py

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,19 @@
6363
_GITHUB_TOKEN_ENV = "GITHUB_TOKEN"
6464

6565

66+
class _StreamCaptureBinding:
67+
"""Shared capture state inherited by child execution contexts."""
68+
69+
def __init__(self, target) -> None:
70+
self.target = target
71+
self.active = True
72+
73+
def deactivate(self) -> None:
74+
"""Stop routing writes and release the completed capture buffer."""
75+
self.active = False
76+
self.target = None
77+
78+
6679
class _ContextualTextStream:
6780
"""Route writes to a task-local stream while preserving a process fallback.
6881
@@ -75,17 +88,25 @@ class _ContextualTextStream:
7588

7689
def __init__(self, fallback, *, context_name: str) -> None:
7790
self._fallback = fallback
78-
self._target: contextvars.ContextVar[Any | None] = contextvars.ContextVar(context_name, default=None)
91+
self._binding: contextvars.ContextVar[_StreamCaptureBinding | None] = contextvars.ContextVar(
92+
context_name, default=None
93+
)
7994

8095
def _current(self):
81-
target = self._target.get()
82-
return self._fallback if target is None else target
83-
84-
def activate(self, target) -> contextvars.Token[Any | None]:
85-
return self._target.set(target)
86-
87-
def reset(self, token: contextvars.Token[Any | None]) -> None:
88-
self._target.reset(token)
96+
binding = self._binding.get()
97+
return self._fallback if binding is None or not binding.active else binding.target
98+
99+
def activate(self, target) -> tuple[contextvars.Token[_StreamCaptureBinding | None], _StreamCaptureBinding]:
100+
binding = _StreamCaptureBinding(target)
101+
return self._binding.set(binding), binding
102+
103+
def reset(
104+
self,
105+
activation: tuple[contextvars.Token[_StreamCaptureBinding | None], _StreamCaptureBinding],
106+
) -> None:
107+
token, binding = activation
108+
binding.deactivate()
109+
self._binding.reset(token)
89110

90111
def write(self, data):
91112
return self._current().write(data)

src/lfx/tests/unit/cli/test_common.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,43 @@ async def mock_async_start(inputs, **kwargs): # noqa: ARG001
279279
assert process_stdout.getvalue() == "outside stdout\n"
280280
assert process_stderr.getvalue() == "outside stderr\n"
281281

282+
@pytest.mark.asyncio
283+
async def test_execute_graph_with_capture_deactivates_outliving_child_capture(self):
284+
"""A child task that outlives its graph writes to the process streams."""
285+
child_started = asyncio.Event()
286+
release_child = asyncio.Event()
287+
child_task = None
288+
289+
async def write_after_graph_returns():
290+
child_started.set()
291+
await release_child.wait()
292+
sys.stdout.write("late child stdout\n")
293+
sys.stderr.write("late child stderr\n")
294+
295+
async def mock_async_start(inputs, **kwargs): # noqa: ARG001
296+
nonlocal child_task
297+
child_task = asyncio.create_task(write_after_graph_returns())
298+
await child_started.wait()
299+
sys.stdout.write("captured stdout\n")
300+
sys.stderr.write("captured stderr\n")
301+
yield MagicMock(results={"text": "ok"})
302+
303+
mock_graph = MagicMock()
304+
mock_graph.context = {}
305+
mock_graph.async_start = mock_async_start
306+
307+
process_stdout = StringIO()
308+
process_stderr = StringIO()
309+
with patch.object(sys, "stdout", process_stdout), patch.object(sys, "stderr", process_stderr):
310+
_, logs = await execute_graph_with_capture(mock_graph, "test input")
311+
release_child.set()
312+
assert child_task is not None
313+
await child_task
314+
315+
assert logs == "captured stdout\ncaptured stderr\n"
316+
assert process_stdout.getvalue() == "late child stdout\n"
317+
assert process_stderr.getvalue() == "late child stderr\n"
318+
282319
@pytest.mark.asyncio
283320
async def test_execute_graph_with_capture_success(self):
284321
"""Test successful graph execution with output capture."""

0 commit comments

Comments
 (0)