Skip to content

Commit adc3403

Browse files
Gkrumbach07Ambient Code Botclaudecoderabbitai[bot]
authored
feat(runner): add secret redaction middleware for AG-UI events (#1041)
## Summary - Adds `secret_redaction_middleware` that scrubs secrets from all text-bearing AG-UI events (assistant messages, tool call args/results, error messages, custom events) before they reach the frontend - Combines **value-based** redaction (env var secrets like `GITHUB_TOKEN`, `ANTHROPIC_API_KEY`) with **pattern-based** redaction (regex for known token formats like `ghp_*`, `sk-ant-*`) - Wired into all three bridges (Claude, Gemini CLI, LangGraph) as the innermost middleware layer so tracing/Langfuse also sees only redacted content - Pre-compiles regex patterns in `redact_secrets()` for better per-event performance ## Test plan - [x] 27 new tests covering all event types, both redaction approaches, edge cases (empty stream, no secrets, clean text passthrough) - [x] All 62 existing security tests still pass - [x] Ruff lint and format pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Ambient Code Bot <bot@ambient-code.local> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent b378c05 commit adc3403

7 files changed

Lines changed: 621 additions & 19 deletions

File tree

components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,10 +167,15 @@ async def run(
167167
try:
168168
message_stream = worker.query(user_msg, session_id=session_label)
169169

170-
from ambient_runner.middleware import tracing_middleware
170+
from ambient_runner.middleware import (
171+
secret_redaction_middleware,
172+
tracing_middleware,
173+
)
171174

172175
wrapped_stream = tracing_middleware(
173-
self._adapter.run(input_data, message_stream=message_stream),
176+
secret_redaction_middleware(
177+
self._adapter.run(input_data, message_stream=message_stream),
178+
),
174179
obs=self._obs,
175180
model=self._configured_model,
176181
prompt=user_msg,

components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/bridge.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,15 @@ async def _line_stream_with_capture():
120120
self._adapter = GeminiCLIAdapter()
121121

122122
async with self._session_manager.get_lock(thread_id):
123-
from ambient_runner.middleware import tracing_middleware
123+
from ambient_runner.middleware import (
124+
secret_redaction_middleware,
125+
tracing_middleware,
126+
)
124127

125128
wrapped_stream = tracing_middleware(
126-
self._adapter.run(input_data, line_stream=_line_stream_with_capture()),
129+
secret_redaction_middleware(
130+
self._adapter.run(input_data, line_stream=_line_stream_with_capture()),
131+
),
127132
obs=self._obs,
128133
model=self._configured_model,
129134
prompt=user_msg,

components/runners/ambient-runner/ambient_runner/bridges/langgraph/bridge.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,9 @@ async def run(self, input_data: RunAgentInput, **kwargs) -> AsyncIterator[BaseEv
7979
if self._adapter is None:
8080
self._create_adapter()
8181

82-
async for event in self._adapter.run(input_data):
82+
from ambient_runner.middleware import secret_redaction_middleware
83+
84+
async for event in secret_redaction_middleware(self._adapter.run(input_data)):
8385
yield event
8486

8587
async def interrupt(self, thread_id: Optional[str] = None) -> None:

components/runners/ambient-runner/ambient_runner/middleware/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"""
77

88
from ambient_runner.middleware.developer_events import emit_developer_message
9+
from ambient_runner.middleware.secret_redaction import secret_redaction_middleware
910
from ambient_runner.middleware.tracing import tracing_middleware
1011

11-
__all__ = ["tracing_middleware", "emit_developer_message"]
12+
__all__ = ["tracing_middleware", "secret_redaction_middleware", "emit_developer_message"]
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
"""
2+
AG-UI Secret Redaction Middleware — scrub secrets from outbound events.
3+
4+
Wraps an adapter's event stream to detect and redact secrets before they
5+
reach the frontend. Combines two approaches:
6+
7+
1. **Pattern-based**: regex patterns for known token formats (GitHub PATs,
8+
Anthropic keys, Langfuse keys, Google API keys, credential URLs, etc.)
9+
via the existing ``redact_secrets()`` utility.
10+
11+
2. **Value-based**: collects actual secret values from environment variables
12+
at middleware init time and replaces exact occurrences. This catches
13+
secrets that don't match any known pattern format.
14+
15+
Usage::
16+
17+
from ambient_runner.middleware import secret_redaction_middleware
18+
19+
async for event in secret_redaction_middleware(bridge.run(input)):
20+
yield encoder.encode(event)
21+
"""
22+
23+
import logging
24+
import os
25+
from typing import AsyncIterator
26+
27+
from ag_ui.core import (
28+
BaseEvent,
29+
CustomEvent,
30+
RunErrorEvent,
31+
TextMessageChunkEvent,
32+
TextMessageContentEvent,
33+
ToolCallArgsEvent,
34+
ToolCallChunkEvent,
35+
ToolCallResultEvent,
36+
)
37+
38+
from ambient_runner.platform.utils import redact_secrets
39+
40+
logger = logging.getLogger(__name__)
41+
42+
# Environment variables that may contain secret values.
43+
# Order matters: longer matches should come first to avoid partial replacements,
44+
# so we sort by value length descending at collection time.
45+
_SECRET_ENV_VARS = (
46+
"ANTHROPIC_API_KEY",
47+
"BOT_TOKEN",
48+
"GITHUB_TOKEN",
49+
"GITLAB_TOKEN",
50+
"JIRA_API_TOKEN",
51+
"GEMINI_API_KEY",
52+
"GOOGLE_API_KEY",
53+
"GOOGLE_OAUTH_CLIENT_SECRET",
54+
"LANGFUSE_SECRET_KEY",
55+
"LANGFUSE_PUBLIC_KEY",
56+
"LANGSMITH_API_KEY",
57+
)
58+
59+
60+
def _collect_secret_values() -> list[tuple[str, str]]:
61+
"""Collect current secret values from environment, sorted longest-first."""
62+
pairs = []
63+
for var in _SECRET_ENV_VARS:
64+
val = (os.getenv(var) or "").strip()
65+
if len(val) >= 8: # skip empty/trivially short values
66+
pairs.append((var, val))
67+
# Sort longest value first so longer tokens are replaced before shorter
68+
# substrings (e.g. a full PAT before a prefix that happens to match).
69+
pairs.sort(key=lambda p: len(p[1]), reverse=True)
70+
return pairs
71+
72+
73+
def _redact_text(text: str, secret_values: list[tuple[str, str]]) -> str:
74+
"""Apply both value-based and pattern-based redaction to a string."""
75+
for var_name, secret_val in secret_values:
76+
if secret_val in text:
77+
text = text.replace(secret_val, f"[REDACTED_{var_name}]")
78+
79+
text = redact_secrets(text)
80+
81+
return text
82+
83+
84+
def _redact_event(event: BaseEvent, secret_values: list[tuple[str, str]]) -> BaseEvent:
85+
"""Return a copy of the event with secrets redacted from text fields.
86+
87+
Only processes event types that carry user-visible text. All other events
88+
pass through unchanged (zero cost).
89+
"""
90+
if isinstance(event, (TextMessageContentEvent, TextMessageChunkEvent, ToolCallArgsEvent, ToolCallChunkEvent)):
91+
redacted = _redact_text(event.delta, secret_values)
92+
if redacted != event.delta:
93+
return event.model_copy(update={"delta": redacted})
94+
95+
elif isinstance(event, ToolCallResultEvent):
96+
redacted_content = _redact_value(event.content, secret_values)
97+
if redacted_content is not event.content:
98+
return event.model_copy(update={"content": redacted_content})
99+
100+
elif isinstance(event, RunErrorEvent):
101+
redacted = _redact_text(event.message, secret_values)
102+
if redacted != event.message:
103+
return event.model_copy(update={"message": redacted})
104+
105+
elif isinstance(event, CustomEvent):
106+
redacted_val = _redact_value(event.value, secret_values)
107+
if redacted_val is not event.value:
108+
return event.model_copy(update={"value": redacted_val})
109+
110+
return event
111+
112+
113+
def _redact_value(value: object, secret_values: list[tuple[str, str]]) -> object:
114+
"""Recursively redact secrets in str/dict/list structures.
115+
116+
Returns the original object unchanged when no secrets are found.
117+
"""
118+
if isinstance(value, str):
119+
redacted = _redact_text(value, secret_values)
120+
return redacted if redacted != value else value
121+
if isinstance(value, dict):
122+
return _redact_dict(value, secret_values)
123+
if isinstance(value, list):
124+
result: list | None = None
125+
for i, item in enumerate(value):
126+
redacted_item = _redact_value(item, secret_values)
127+
if redacted_item is not item:
128+
if result is None:
129+
result = list(value)
130+
result[i] = redacted_item
131+
return result if result is not None else value
132+
return value
133+
134+
135+
def _redact_dict(d: dict, secret_values: list[tuple[str, str]]) -> dict:
136+
"""Recursively redact keys and values in a dict. Returns original if unchanged."""
137+
result: dict | None = None
138+
for k, v in d.items():
139+
redacted_k = _redact_value(k, secret_values) if isinstance(k, str) else k
140+
redacted_v = _redact_value(v, secret_values)
141+
if redacted_k is not k or redacted_v is not v:
142+
if result is None:
143+
result = dict(d)
144+
if redacted_k is not k:
145+
del result[k]
146+
result[redacted_k] = redacted_v
147+
return result if result is not None else d
148+
149+
150+
async def secret_redaction_middleware(
151+
event_stream: AsyncIterator[BaseEvent],
152+
) -> AsyncIterator[BaseEvent]:
153+
"""Wrap an AG-UI event stream with secret redaction.
154+
155+
Collects secret values from the current environment at invocation time
156+
and scrubs them from all text-bearing events before yielding.
157+
158+
Args:
159+
event_stream: The upstream event stream.
160+
161+
Yields:
162+
Events with secrets redacted from text fields.
163+
"""
164+
secret_values = _collect_secret_values()
165+
166+
async for event in event_stream:
167+
yield _redact_event(event, secret_values)

components/runners/ambient-runner/ambient_runner/platform/utils.py

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -81,24 +81,31 @@ def timestamp() -> str:
8181
return datetime.now(timezone.utc).isoformat()
8282

8383

84+
_REDACT_PATTERNS = [
85+
(re.compile(r"gh[pousr]_[a-zA-Z0-9]{36,255}"), "gh*_***REDACTED***"),
86+
(re.compile(r"sk-ant-[a-zA-Z0-9\-_]{30,200}"), "sk-ant-***REDACTED***"),
87+
(re.compile(r"pk-lf-[a-zA-Z0-9\-_]{10,100}"), "pk-lf-***REDACTED***"),
88+
(re.compile(r"sk-lf-[a-zA-Z0-9\-_]{10,100}"), "sk-lf-***REDACTED***"),
89+
(re.compile(r"x-access-token:[^@\s]+@"), "x-access-token:***REDACTED***@"),
90+
(re.compile(r"oauth2:[^@\s]+@"), "oauth2:***REDACTED***@"),
91+
(re.compile(r"://[^:@\s]+:[^@\s]+@"), "://***REDACTED***@"),
92+
(re.compile(r"AIza[a-zA-Z0-9\-_]{30,}"), "AIza***REDACTED***"),
93+
(
94+
re.compile(
95+
r"(ANTHROPIC_API_KEY|LANGFUSE_SECRET_KEY|LANGFUSE_PUBLIC_KEY|BOT_TOKEN|GIT_TOKEN|GEMINI_API_KEY|GOOGLE_API_KEY)\s*=\s*[^\s\'\"]+",
96+
),
97+
r"\1=***REDACTED***",
98+
),
99+
]
100+
101+
84102
def redact_secrets(text: str) -> str:
85103
"""Redact tokens and secrets from text for safe logging."""
86104
if not text:
87105
return text
88106

89-
text = re.sub(r"gh[pousr]_[a-zA-Z0-9]{36,255}", "gh*_***REDACTED***", text)
90-
text = re.sub(r"sk-ant-[a-zA-Z0-9\-_]{30,200}", "sk-ant-***REDACTED***", text)
91-
text = re.sub(r"pk-lf-[a-zA-Z0-9\-_]{10,100}", "pk-lf-***REDACTED***", text)
92-
text = re.sub(r"sk-lf-[a-zA-Z0-9\-_]{10,100}", "sk-lf-***REDACTED***", text)
93-
text = re.sub(r"x-access-token:[^@\s]+@", "x-access-token:***REDACTED***@", text)
94-
text = re.sub(r"oauth2:[^@\s]+@", "oauth2:***REDACTED***@", text)
95-
text = re.sub(r"://[^:@\s]+:[^@\s]+@", "://***REDACTED***@", text)
96-
text = re.sub(r"AIza[a-zA-Z0-9\-_]{30,}", "AIza***REDACTED***", text)
97-
text = re.sub(
98-
r'(ANTHROPIC_API_KEY|LANGFUSE_SECRET_KEY|LANGFUSE_PUBLIC_KEY|BOT_TOKEN|GIT_TOKEN|GEMINI_API_KEY|GOOGLE_API_KEY)\s*=\s*[^\s\'"]+',
99-
r"\1=***REDACTED***",
100-
text,
101-
)
107+
for pattern, replacement in _REDACT_PATTERNS:
108+
text = pattern.sub(replacement, text)
102109
return text
103110

104111

0 commit comments

Comments
 (0)