Skip to content

Commit ddef201

Browse files
authored
Merge pull request #277 from GitHubSecurityLab/anticomputer/redact-secrets-in-logs
Log tool-call environment variable names instead of values
2 parents 8de19ce + a77a378 commit ddef201

2 files changed

Lines changed: 65 additions & 4 deletions

File tree

src/seclab_taskflow_agent/mcp_utils.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,16 @@ async def call_tool(self, *args: Any, **kwargs: Any) -> Any:
172172
ClientParamsMap = dict[str, tuple[dict[str, Any], list[str], str | None, int | None]]
173173

174174

175+
def _env_names(env: dict[str, Any] | None) -> list[str] | None:
176+
"""Return only the environment variable names, for safe debug logging.
177+
178+
Tool-call environments routinely carry credentials (e.g. ``GH_TOKEN``), so
179+
their values must never be written to logs. Callers log the names to keep
180+
debug output useful without leaking secrets.
181+
"""
182+
return sorted(env) if env is not None else None
183+
184+
175185
def mcp_client_params(
176186
available_tools: AvailableTools,
177187
requested_toolboxes: list[str],
@@ -202,7 +212,7 @@ def mcp_client_params(
202212
case "stdio":
203213
env = dict(sp.env) if sp.env else None
204214
args = list(sp.args) if sp.args else None
205-
logging.debug("Initializing toolbox: %s\nargs:\n%s\nenv:\n%s\n", tb, args, env)
215+
logging.debug("Initializing toolbox: %s\nargs:\n%s\nenv names:\n%s\n", tb, args, _env_names(env))
206216
if env:
207217
for k, v in list(env.items()):
208218
try:
@@ -217,7 +227,7 @@ def mcp_client_params(
217227
"http_proxy", "https_proxy", "no_proxy"):
218228
if proxy_var not in env and proxy_var in os.environ:
219229
env[proxy_var] = os.environ[proxy_var]
220-
logging.debug("Tool call environment: %s", env)
230+
logging.debug("Tool call environment names: %s", _env_names(env))
221231
if args:
222232
for i, v in enumerate(args):
223233
args[i] = swap_env(v)
@@ -241,7 +251,7 @@ def mcp_client_params(
241251
if sp.command is not None:
242252
env = dict(sp.env) if sp.env else None
243253
args = list(sp.args) if sp.args else None
244-
logging.debug("Initializing streamable toolbox: %s\nargs:\n%s\nenv:\n%s\n", tb, args, env)
254+
logging.debug("Initializing streamable toolbox: %s\nargs:\n%s\nenv names:\n%s\n", tb, args, _env_names(env))
245255
exe = shutil.which(sp.command)
246256
if exe is None:
247257
raise FileNotFoundError(f"Could not resolve path to {sp.command}")

tests/test_mcp_utils.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,18 @@
66
from __future__ import annotations
77

88
import asyncio
9+
import logging
910
from types import SimpleNamespace
1011
from unittest.mock import AsyncMock, MagicMock
1112

1213
import pytest
1314

14-
from seclab_taskflow_agent.mcp_utils import MCPNamespaceWrap, compress_name
15+
from seclab_taskflow_agent.mcp_utils import (
16+
MCPNamespaceWrap,
17+
_env_names,
18+
compress_name,
19+
mcp_client_params,
20+
)
1521

1622

1723
class _FakeTool:
@@ -145,3 +151,48 @@ def test_list_tools_existing_behaviour_unchanged():
145151
obj.list_tools.assert_awaited_once_with(run_context="ctx", agent="agent")
146152
ns = compress_name("RepoContext")
147153
assert result[0].name == f"{ns}read_file"
154+
155+
156+
# -- secret redaction in debug logs --
157+
158+
159+
def test_mcp_client_params_logs_env_names_not_secret_values(monkeypatch, caplog):
160+
# A tool-call environment routinely resolves credentials like GH_TOKEN;
161+
# debug logging must record only the variable names, never the values.
162+
sentinel = "do-not-log-this-value"
163+
monkeypatch.setenv("GH_TOKEN", sentinel)
164+
server_params = SimpleNamespace(
165+
kind="stdio",
166+
reconnecting=False,
167+
env={"GH_TOKEN": "{{ env('GH_TOKEN') }}", "LOG_DIR": "/tmp/logs"},
168+
args=None,
169+
command="echo",
170+
)
171+
toolbox = SimpleNamespace(
172+
server_params=server_params,
173+
confirm=[],
174+
server_prompt=None,
175+
client_session_timeout=None,
176+
)
177+
available_tools = MagicMock()
178+
available_tools.get_toolbox.return_value = toolbox
179+
180+
with caplog.at_level(logging.DEBUG):
181+
params = mcp_client_params(available_tools, ["pkg.tb"])
182+
183+
# The resolved secret value never reaches the logs ...
184+
assert sentinel not in caplog.text
185+
# ... but the variable names are still logged for debuggability.
186+
assert "GH_TOKEN" in caplog.text
187+
assert "LOG_DIR" in caplog.text
188+
# Redaction is log-only: the real env (with the resolved secret) is still
189+
# passed through to the MCP server.
190+
assert params["pkg.tb"][0]["env"]["GH_TOKEN"] == sentinel
191+
192+
193+
def test_env_names_returns_sorted_names_or_none():
194+
# Non-empty env -> sorted names; empty dict -> empty list; None -> None
195+
# (honouring the list[str] | None contract, never leaking values).
196+
assert _env_names({"B_VAR": "x", "A_VAR": "{{ env('A') }}"}) == ["A_VAR", "B_VAR"]
197+
assert _env_names({}) == []
198+
assert _env_names(None) is None

0 commit comments

Comments
 (0)