Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion agent/redact.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import os
import re
import shlex
from typing import Any
from urllib.parse import unquote_plus

# Basenames treated as ``.env`` files by _command_reads_env_file. Imported
Expand Down Expand Up @@ -90,6 +91,7 @@
r"pplx-[A-Za-z0-9]{10,}", # Perplexity
r"fal_[A-Za-z0-9_-]{10,}", # Fal.ai
r"fc-[A-Za-z0-9]{10,}", # Firecrawl
r"GOCSPX-[A-Za-z0-9_-]{10,}", # Google OAuth client secret
r"bb_live_[A-Za-z0-9_-]{10,}", # BrowserBase
r"gAAAA[A-Za-z0-9_=-]{20,}", # Codex encrypted tokens
r"AKIA[A-Z0-9]{16}", # AWS Access Key ID
Expand Down Expand Up @@ -314,7 +316,7 @@ def _key_has_secret_keyword(key: str) -> bool:
return False

# JSON field patterns: "apiKey": "value", "token": "value", etc.
_JSON_KEY_NAMES = r"(?:api_?[Kk]ey|token|secret|password|access_token|refresh_token|auth_token|bearer|secret_value|raw_secret|secret_input|key_material)"
_JSON_KEY_NAMES = r"(?:api_?[Kk]ey|secret_key|client_secret|app_secret|token|secret|password|access_token|refresh_token|auth_token|bearer|secret_value|raw_secret|secret_input|key_material)"
_JSON_FIELD_RE = re.compile(
rf'("{_JSON_KEY_NAMES}")\s*:\s*"([^"]+)"',
re.IGNORECASE,
Expand Down Expand Up @@ -1007,6 +1009,51 @@ def _redact_phone(m):
return text


def redact_structured(value: Any, *, force: bool = True) -> Any:
"""Deep-redact credential values inside a JSON-like structure.

Walks dicts/lists/tuples recursively and:

- runs every string through :func:`redact_sensitive_text` so prefixed
credentials (``sk-``, ``GOCSPX-``, …) are masked anywhere they appear;
- for a dict entry whose *key* names a credential field (``client_secret``,
``apiKey``, ``token``, ``password``, …), masks the value entirely even
when it has no recognizable prefix — the key context alone identifies
it as a secret. This is what text-only redaction misses for structured
content: once serialized to JSONL the key is quoted (``\\"client_secret\\"``)
and the value is opaque, so no pattern fires (issue #20785 follow-up).

Non-string scalars and empty strings pass through unchanged so structure
and JSON types survive (the output stays parseable).

``force=True`` is the default because this is an egress boundary: exports
must never carry credential values even when ``security.redact_secrets``
is disabled globally.
"""
if isinstance(value, str):
if not value:
return value
return redact_sensitive_text(value, force=force)
if isinstance(value, dict):
out: Dict[Any, Any] = {}
for key, item in value.items():
if isinstance(key, str) and _key_has_secret_keyword(key):
if isinstance(item, str) and item:
out[key] = _mask_token_nonreusable(item)
elif isinstance(item, (dict, list)):
out[key] = redact_structured(item, force=force)
else:
out[key] = item
else:
out[key] = redact_structured(item, force=force)
return out
if isinstance(value, list):
return [redact_structured(item, force=force) for item in value]
if isinstance(value, tuple):
return tuple(redact_structured(item, force=force) for item in value)
return value


# Commands whose stdout is an environment-variable dump (KEY=value lines),
# NOT source code. For these, terminal-output redaction must run the
# ENV-assignment pass (code_file=False) so opaque tokens with no recognized
Expand Down
4 changes: 4 additions & 0 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -14413,6 +14413,10 @@ def run_agent():
display_reasoning += f"\n{_DIM} ... ({len(lines) - 10} more lines — /reasoning full to show){_RST}"
else:
display_reasoning = reasoning.strip()
# Scrub credential patterns from scratch thinking before
# it renders (#20785) — reasoning is display-only text.
from agent.redact import redact_sensitive_text
display_reasoning = redact_sensitive_text(display_reasoning, force=True)
_cprint(f"\n{r_top}\n{_DIM}{display_reasoning}{_RST}\n{r_bot}")

if response and not response_previewed:
Expand Down
4 changes: 4 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -17783,6 +17783,10 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g
display_reasoning += f"\n_... ({len(lines) - 15} more lines)_"
else:
display_reasoning = last_reasoning.strip()
# Never let reasoning/thinking scratch text carry
# credential values into user-facing chat (#20785).
from agent.redact import redact_sensitive_text
display_reasoning = redact_sensitive_text(display_reasoning, force=True)
# Render style is per-platform: Discord defaults to "-# "
# subtext (native small grey metadata text); other
# platforms keep the fenced code block.
Expand Down
19 changes: 17 additions & 2 deletions hermes_cli/session_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,24 @@ def render_sessions_export(
export_format = normalize_export_format(fmt)
export_only = normalize_export_only(only)

# Redact structured content BEFORE serialization (#20785 follow-up).
# A text pass alone misses credential values nested in structured
# messages: once JSONL-serialized, the key is escaped (\"client_secret\")
# so the JSON-field regex never fires, and opaque values have no
# recognizable prefix. Walking the structure first masks values under
# credential-named keys regardless of shape, and force=True keeps this
# egress boundary active even when security.redact_secrets is disabled.
from agent.redact import redact_structured
session_list = redact_structured(session_list)

if export_format == "jsonl":
return _render_jsonl(session_list, only=export_only)
return _render_markdown(session_list, only=export_only)
rendered = _render_jsonl(session_list, only=export_only)
else:
rendered = _render_markdown(session_list, only=export_only)
# Belt-and-suspenders text pass over the serialized output so
# prose-embedded credentials (URLs, headers, KEY=value) are also masked.
from agent.redact import redact_sensitive_text
return redact_sensitive_text(rendered, force=True)


def export_record_count(
Expand Down
51 changes: 50 additions & 1 deletion tests/agent/test_redact.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import pytest

from agent.redact import mask_secret, redact_cdp_url, redact_sensitive_text, RedactingFormatter
from agent.redact import mask_secret, redact_cdp_url, redact_structured, redact_sensitive_text, RedactingFormatter


@pytest.fixture(autouse=True)
Expand Down Expand Up @@ -1051,3 +1051,52 @@ def test_printable_mask_unchanged(self):
def test_all_control_value_returns_empty_fallback(self):
assert mask_secret("\n\x85\u200b") == ""
assert mask_secret("\n\x85\u200b", empty="(not set)") == "(not set)"


class TestStructuredRedaction:
"""redact_structured: deep-walk of JSON-like structures for egress
boundaries (session exports). Key context alone identifies opaque
credential values that no text pattern could match."""

def test_redacts_opaque_value_under_secret_key(self):
out = redact_structured(
{"client_secret": "opaque-client-secret-abc123", "client_id": "cid-9"}
)
assert "opaque-client-secret-abc123" not in str(out)
assert out["client_secret"] != "opaque-client-secret-abc123"
assert out["client_id"] == "cid-9" # non-secret key untouched

def test_redacts_nested_structures(self):
out = redact_structured(
{
"messages": [
{"role": "tool", "content": {"apiKey": "sk-opaque-abc", "text": "hi"}},
]
}
)
assert "sk-opaque-abc" not in str(out)
assert out["messages"][0]["content"]["text"] == "hi"

def test_redacts_json_string_payloads(self):
import json as _json

payload = _json.dumps({"client_secret": "opaque-xyz-789"})
out = redact_structured({"content": payload})
assert "opaque-xyz-789" not in str(out)

def test_force_redacts_when_global_toggle_disabled(self, monkeypatch):
monkeypatch.setattr("agent.redact._REDACT_ENABLED", False)
out = redact_structured(
{"client_secret": "GOCSPX-abcdefghij1234567890", "note": "hello"}
)
assert "GOCSPX-abcdefghij1234567890" not in str(out)
assert out["note"] == "hello"

def test_leaves_non_secret_structure_unchanged(self):
value = {
"id": "sess-1",
"title": "Debug auth flow",
"message_count": 5,
"messages": [{"role": "user", "content": "Why is login broken?"}],
}
assert redact_structured(value) == value
64 changes: 64 additions & 0 deletions tests/hermes_cli/test_session_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,70 @@ def test_export_record_count_switches_unit_for_prompt_only_exports():
)


def test_jsonl_export_redacts_embedded_client_secret_dict_value():
"""#20785 follow-up: opaque client_secret nested in structured message
content must not survive export. The key context alone identifies the
value as a secret, even though 'opaque-client-secret-...' has no
recognizable prefix and the serialized key is JSON-escaped."""
opaque = "opaque-client-secret-abc123XYZ"
session = _sample_session()
session["messages"].append(
{
"id": 6,
"role": "tool",
"tool_name": "google_api",
"content": {"client_secret": opaque, "client_id": "cid-123"},
"timestamp": 1700000005,
}
)
rendered = render_sessions_export([session], fmt="jsonl")
assert opaque not in rendered
# JSONL stays parseable after redaction.
for line in rendered.strip().splitlines():
json.loads(line)


def test_jsonl_export_redacts_embedded_client_secret_json_string():
"""Same leak through a JSON *string* payload (tool output that embeds a
credential blob as text)."""
opaque = "opaque-client-secret-abc123XYZ"
session = _sample_session()
session["messages"].append(
{
"id": 6,
"role": "tool",
"tool_name": "google_api",
"content": json.dumps({"client_secret": opaque}),
"timestamp": 1700000005,
}
)
rendered = render_sessions_export([session], fmt="jsonl")
assert opaque not in rendered
for line in rendered.strip().splitlines():
json.loads(line)


def test_export_redaction_forced_even_when_global_toggle_disabled(monkeypatch):
"""Egress boundary must redact regardless of security.redact_secrets."""
import agent.redact as redact_mod

opaque = "GOCSPX-abcdefghij1234567890"
monkeypatch.setattr(redact_mod, "_REDACT_ENABLED", False)
session = _sample_session()
session["messages"].append(
{
"id": 6,
"role": "tool",
"tool_name": "google_api",
"content": {"client_secret": opaque},
"timestamp": 1700000005,
}
)
rendered = render_sessions_export([session], fmt="jsonl")
assert opaque not in rendered
assert "GOCSPX" not in rendered


def test_sessions_export_cli_prompt_only_stdout(monkeypatch, capsys):
import hermes_cli.main as main_mod
import hermes_state
Expand Down