hermes-agent/tests/agent/test_redact.py
Teknium 6b81590c55
test: prune low-value tests suite-wide (wave 1) — 46,820 → 28,106 test functions
Systematic prune per AGENTS.md test policy, one pass over every major
test tree (gateway, hermes_cli, tools, agent, run_agent, plugins, cli,
cron, tui_gateway, honcho/openviking, root-level):

- DELETE: source-reading tests (read_text/getsource on prod files),
  change-detector tests (exact catalog counts, model-name snapshots,
  config version literals), mock-echo tests (assert a mock returns what
  it was told), assertion-free/trivial tests, near-duplicate
  parametrizations (boundaries + one representative kept), async/sync
  twin duplicates, cosmetic within-file variations.
- KEEP (mandatory): security/redaction/approval guards, message-role
  alternation invariants, prompt-caching/deterministic-call-id
  invariants, issue-number regression tests (deduped), E2E tests.
- 6 test files deleted outright (script-style/no-assert or fully
  redundant); conftest.py, fakes/, fixtures/ untouched.
- tests/acp/conftest.py added: autouse fixture stubs the live
  models.dev/GitHub/Copilot/Anthropic inventory fetches that ACP server
  tests performed on every session create — test_server.py 147s → 3.4s,
  and the tests are now genuinely hermetic.
- Sleep-based slowness shrunk where safe (codex_ttfb_watchdog,
  compression_concurrent_fork, etc.); no wall-clock assertion tightened.

Verification: full hermetic suite via scripts/run_tests.sh —
2439 files, 31,130 tests passed, 0 failed, 0 flaky retries, 315s wall
(baseline: 583s wall, 13,564s subprocess CPU).
2026-07-29 13:10:23 -07:00

735 lines
25 KiB
Python

"""Tests for agent.redact -- secret masking in logs and output."""
import logging
import pytest
from agent.redact import redact_cdp_url, redact_sensitive_text, RedactingFormatter
@pytest.fixture(autouse=True)
def _ensure_redaction_enabled(monkeypatch):
"""Ensure HERMES_REDACT_SECRETS is not disabled by prior test imports."""
monkeypatch.delenv("HERMES_REDACT_SECRETS", raising=False)
# Also patch the module-level snapshot so it reflects the cleared env var
monkeypatch.setattr("agent.redact._REDACT_ENABLED", True)
class TestKnownPrefixes:
def test_slack_token(self):
token = "xoxb-" + "0" * 12 + "-" + "a" * 14
result = redact_sensitive_text(token)
assert "a" * 14 not in result
def test_fireworks_keys(self):
samples = [
"fw-" + "A" * 40,
"fw_" + "B" * 40,
"fpk_" + "C" * 40,
]
for token in samples:
result = redact_sensitive_text(f"provider error {token}")
assert token not in result
assert "..." in result
def test_short_fireworks_like_words_unchanged(self):
text = "fw-tooshort fw_tooshort fpk_tooshort"
assert redact_sensitive_text(text) == text
class TestEnvAssignments:
def test_export_api_key(self):
text = "export OPENAI_API_KEY=sk-proj-abc123def456ghi789jkl012"
result = redact_sensitive_text(text)
assert "OPENAI_API_KEY=" in result
assert "abc123def456" not in result
def test_non_secret_env_unchanged(self):
text = "HOME=/home/user"
result = redact_sensitive_text(text)
assert result == text
def test_export_whitespace_preserved(self):
# Regression: #4367 — whitespace before uppercase env var must be preserved
text = "export SECRET_TOKEN=mypassword"
result = redact_sensitive_text(text)
assert result.startswith("export ")
assert "SECRET_TOKEN=" in result
assert "mypassword" not in result
class TestEnvLookupPreserved:
"""Programmatic env var lookups must not be corrupted (issue #2852)."""
def test_os_getenv_single_quote_uppercase_key(self):
text = "MY_API_KEY=os.getenv('OPENAI_API_KEY')"
assert redact_sensitive_text(text, force=True) == text
def test_real_env_value_still_redacted(self):
text = "HOMEASSISTANT_TOKEN=eyJhbGciOiJIUzI1NiJ9.abc123.xyz"
result = redact_sensitive_text(text, force=True)
assert "eyJhbGciOiJIUzI1NiJ9" not in result
def test_multiline_prose_with_code_snippet(self):
text = """Set it up like this:
HA_TOKEN=os.getenv('HOMEASSISTANT_TOKEN')
if not HA_TOKEN:
raise ValueError('Missing credentials')"""
result = redact_sensitive_text(text, force=True)
assert "os.getenv('HOMEASSISTANT_TOKEN')" in result
class TestJsonFields:
def test_json_api_key(self):
text = '{"apiKey": "sk-proj-abc123def456ghi789jkl012"}'
result = redact_sensitive_text(text)
assert "abc123def456" not in result
def test_json_non_secret_unchanged(self):
text = '{"name": "John", "model": "gpt-4"}'
result = redact_sensitive_text(text)
assert result == text
class TestAuthHeaders:
def test_authorization_prose_unchanged(self):
# "authorization" without a colon-delimited value is plain prose.
text = "the authorization model is fully open"
assert redact_sensitive_text(text) == text
def test_token_flush_against_double_quote_preserves_quote(self):
# Regression for #43083: a token sitting flush against a closing
# double quote must NOT pull that quote into the mask. Greedy \S+
# used to eat it, turning value corruption into syntax corruption
# (unterminated quote → shell EOF).
text = 'curl -H "Authorization: Bearer sk-abcdef1234567890"'
result = redact_sensitive_text(text)
assert "sk-abcdef1234567890" not in result
assert result.count('"') == 2, result # both quotes survive
assert result.endswith('"'), result
class TestApiKeyHeaders:
def test_x_api_key_header_masked(self):
text = "x-api-key: opaque-provider-key-1234567890"
result = redact_sensitive_text(text)
assert "x-api-key:" in result
assert "opaque-provider-key" not in result
def test_x_api_key_in_curl_command_masked(self):
text = 'curl -H "x-api-key: sk-local-VERYsecret-999888" https://api.example.com'
result = redact_sensitive_text(text)
assert "VERYsecret" not in result
assert "https://api.example.com" in result
def test_api_key_header_masked(self):
text = "api-key: anotherOpaqueSecret1234567"
result = redact_sensitive_text(text)
assert "anotherOpaqueSecret" not in result
class TestTelegramTokens:
def test_bot_token(self):
text = "bot123456789:ABCDEfghij-KLMNopqrst_UVWXyz12345"
result = redact_sensitive_text(text)
assert "ABCDEfghij" not in result
assert "123456789:***" in result
def test_raw_token(self):
text = "12345678901:ABCDEfghijKLMNopqrstUVWXyz1234567890"
result = redact_sensitive_text(text)
assert "ABCDEfghij" not in result
class TestPassthrough:
def test_empty_string(self):
assert redact_sensitive_text("") == ""
def test_non_string_input_dict_coerced_and_redacted(self):
result = redact_sensitive_text({"token": "sk-proj-abc123def456ghi789jkl012"})
assert "abc123def456" not in result
class TestRedactingFormatter:
def test_formats_and_redacts(self):
formatter = RedactingFormatter("%(message)s")
record = logging.LogRecord(
name="test",
level=logging.INFO,
pathname="",
lineno=0,
msg="Key is sk-proj-abc123def456ghi789jkl012",
args=(),
exc_info=None,
)
result = formatter.format(record)
assert "abc123def456" not in result
assert "sk-pro" in result
class TestPrintenvSimulation:
"""Simulate what happens when the agent runs `env` or `printenv`."""
def test_full_env_dump(self):
env_dump = """HOME=/home/user
PATH=/usr/local/bin:/usr/bin
OPENAI_API_KEY=sk-proj-abc123def456ghi789jkl012mno345
OPENROUTER_API_KEY=sk-or-v1-reallyLongSecretKeyValue12345678
FIRECRAWL_API_KEY=fc-shortkey123456789012
TELEGRAM_BOT_TOKEN=bot987654321:ABCDEfghij-KLMNopqrst_UVWXyz12345
SHELL=/bin/bash
USER=teknium"""
result = redact_sensitive_text(env_dump)
# Secrets should be masked
assert "abc123def456" not in result
assert "reallyLongSecretKey" not in result
assert "ABCDEfghij" not in result
# Non-secrets should survive
assert "HOME=/home/user" in result
assert "SHELL=/bin/bash" in result
assert "USER=teknium" in result
class TestSecretCapturePayloadRedaction:
def test_secret_value_field_redacted(self):
text = '{"success": true, "secret_value": "sk-test-secret-1234567890"}'
result = redact_sensitive_text(text)
assert "sk-test-secret-1234567890" not in result
class TestElevenLabsTavilyExaKeys:
"""Regression tests for ElevenLabs (sk_), Tavily (tvly-), and Exa (exa_) keys."""
def test_elevenlabs_key_redacted(self):
text = "ELEVENLABS_API_KEY=sk_abc123def456ghi789jklmnopqrstu"
result = redact_sensitive_text(text)
assert "abc123def456ghi" not in result
def test_all_three_in_env_dump(self):
env_dump = (
"HOME=/home/user\n"
"ELEVENLABS_API_KEY=sk_abc123def456ghi789jklmnopqrstu\n"
"TAVILY_API_KEY=tvly-ABCdef123456789GHIJKL0000\n"
"EXA_API_KEY=exa_XYZ789abcdef000000000000000\n"
"SHELL=/bin/bash\n"
)
result = redact_sensitive_text(env_dump)
assert "abc123def456ghi" not in result
assert "ABCdef123456789" not in result
assert "XYZ789abcdef" not in result
assert "HOME=/home/user" in result
assert "SHELL=/bin/bash" in result
class TestJWTTokens:
"""JWT tokens start with eyJ (base64 for '{') and have dot-separated parts."""
def test_2part_jwt(self):
text = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0"
result = redact_sensitive_text(text)
assert "eyJzdWIi" not in result
def test_jwt_preserves_surrounding_text(self):
text = "before eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0 after"
result = redact_sensitive_text(text)
assert result.startswith("before ")
assert result.endswith(" after")
class TestDiscordMentions:
"""Discord mention snowflakes (<@ID> / <@!ID>) are public syntax, not
secrets — they must pass through the redactor unchanged so multi-bot
@-pings (DISCORD_ALLOW_BOTS=mentions) keep resolving. See issue #35611."""
def test_normal_mention_passes_through(self):
text = "Hello <@222589316709220353>"
assert redact_sensitive_text(text) == text
class TestWebUrlsNotRedacted:
"""Web URLs (http/https/wss) pass through unchanged — magic-link
checkouts, OAuth callbacks the agent is meant to follow, and pre-signed
share URLs must reach the tool intact. Known credential shapes inside
URLs (sk-, ghp_, JWTs) are still caught by the prefix and JWT regexes.
DB connection-string passwords are still caught by _DB_CONNSTR_RE.
"""
def test_oauth_callback_code_passes_through(self):
text = "GET https://api.example.com/oauth/cb?code=abc123xyz789&state=csrf_ok"
assert redact_sensitive_text(text) == text
def test_known_prefix_inside_url_still_redacted(self):
"""sk-/ghp_/JWT-shaped values inside a URL are still caught by
_PREFIX_RE / _JWT_RE — the carve-out is for opaque tokens only."""
text = "https://evil.com/steal?key=sk-" + "a" * 30
result = redact_sensitive_text(text)
assert "sk-" + "a" * 30 not in result
def test_db_connstr_password_still_redacted(self):
"""DB schemes (postgres/mysql/mongodb/redis/amqp) keep their
userinfo redaction via _DB_CONNSTR_RE — connection strings are
not web URLs the agent navigates to."""
text = "postgres://admin:dbpass@db.internal:5432/app"
result = redact_sensitive_text(text)
assert "dbpass" not in result
class TestStrictUrlCredentialRedaction:
@pytest.mark.parametrize(
("text", "secret", "expected"),
[
(
"https://x.test/#access_token=FRAG_SECRET&view=public",
"FRAG_SECRET",
"https://x.test/#access_token=***&view=public",
),
(
"/resume?token=REL_SECRET&view=public",
"REL_SECRET",
"/resume?token=***&view=public",
),
(
"https://x.test/cb?client%5Fsecret=ENC_SECRET&view=public",
"ENC_SECRET",
"https://x.test/cb?client%5Fsecret=***&view=public",
),
(
"https://x.test/cb?client%255Fsecret=DOUBLE_SECRET&view=public",
"DOUBLE_SECRET",
"https://x.test/cb?client%255Fsecret=***&view=public",
),
(
"/resume?token=SEMICOLON_SECRET;view=public",
"SEMICOLON_SECRET",
"/resume?token=***;view=public",
),
(
"//user:NET_SECRET@x.test/path",
"NET_SECRET",
"//user:***@x.test/path",
),
],
)
def test_masks_all_url_reference_forms_only_when_opted_in(
self, text, secret, expected
):
assert redact_sensitive_text(text) == text
result = redact_sensitive_text(text, redact_url_credentials=True)
assert secret not in result
assert result == expected
def test_similarly_named_public_params_remain_unchanged(self):
text = "/metrics?token_count=17&session_id=public"
assert redact_sensitive_text(text, redact_url_credentials=True) == text
class TestBareTokenUserinfoRedaction:
"""Regression tests for #6396 — a bare credential in URL userinfo
(``scheme://TOKEN@host``, no ``user:pass`` colon) is redacted. This is the
git-remote-with-embedded-password shape. The colon form ``user:pass@`` and
query-string tokens are deliberately left to pass through (#34029) so
magic-link / OAuth round-trip skills keep working — see
TestWebUrlsNotRedacted for those invariants.
"""
def test_git_remote_bare_password_redacted(self):
"""Exact bug scenario: password in a git remote URL."""
text = (
"git remote set-url origin "
"https://MYPASSWORDWASDISLAYEDHERE@github.com/unclehowell/FCUK.git"
)
result = redact_sensitive_text(text)
assert "MYPASSWORDWASDISLAYEDHERE" not in result
assert "@github.com" in result
assert "unclehowell/FCUK.git" in result
def test_ssh_bare_token_redacted(self):
text = "ssh://longtoken1234567@gitlab.com/project.git"
result = redact_sensitive_text(text)
assert "longtoken1234567" not in result
assert "@gitlab.com" in result
def test_ftp_bare_token_redacted(self):
text = "ftp://ftptoken123456@ftp.example.com/files"
result = redact_sensitive_text(text)
assert "ftptoken123456" not in result
def test_user_pass_form_still_passes_through(self):
"""The ``user:pass@`` colon form must NOT be redacted (#34029)."""
text = "URL: https://user:supersecretpw@host.example.com/path"
assert redact_sensitive_text(text) == text
def test_short_username_not_redacted(self):
"""Short userinfo (git, admin, deploy) below the 8-char floor passes."""
for text in (
"https://git@github.com/user/repo.git",
"https://admin@example.com/x",
"https://deploy@host.com/y",
):
assert redact_sensitive_text(text) == text
def test_email_in_path_not_redacted(self):
"""An ``@`` in a path/query is not userinfo — the token class stops at
``/``, so emails after the first slash are never treated as a credential."""
for text in (
"https://example.com/search?q=user@example.com",
"https://example.com/users/john@doe.com/profile",
):
assert redact_sensitive_text(text) == text
class TestFormBodyRedaction:
"""Form-urlencoded body redaction (k=v&k=v with no other text)."""
def test_pure_form_body(self):
text = "password=mysecret&username=bob&token=opaqueValue"
result = redact_sensitive_text(text)
assert "mysecret" not in result
assert "opaqueValue" not in result
assert "username=bob" in result
def test_non_form_text_unchanged(self):
"""Sentences with `&` should NOT trigger form redaction."""
text = "I have password=foo and other things" # contains spaces
result = redact_sensitive_text(text)
# The space breaks the form regex; passthrough expected.
assert "I have" in result
def test_multiline_text_not_form(self):
"""Multi-line text is never treated as form body."""
text = "first=1\nsecond=2"
# Should pass through (still subject to other redactors)
assert "first=1" in redact_sensitive_text(text)
class TestLowercaseDottedConfigKeys:
"""Issue #16413 — config-file passwords in lowercase/dotted/colon keys
must be redacted. The uppercase _ENV_ASSIGN_RE missed these, leaking
`spring.datasource.password=...` and `password: ...` from `cat`'d config
files. Carve-outs: prose, code (#4367), and web URLs are left untouched.
"""
def test_properties_file_dump(self):
text = (
"server.port=8080\n"
"spring.datasource.username=admin\n"
"spring.datasource.password=Sup3rS3cret!\n"
"logging.level.root=INFO"
)
result = redact_sensitive_text(text)
assert "Sup3rS3cret!" not in result
assert "server.port=8080" in result # non-secret keys preserved
assert "username=admin" in result
# --- carve-outs: must NOT redact ---
def test_prose_mid_sentence_password_unchanged(self):
# Not line-anchored, not dotted → conversational text, leave alone.
text = "I have password=foo and other things"
assert redact_sensitive_text(text) == text
class TestXaiToken:
KEY = "xai-ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstu"
def test_bare_token_masked(self):
result = redact_sensitive_text(f"using key {self.KEY}", force=True)
assert self.KEY not in result
assert "xai-AB" in result
def test_too_short_not_masked(self):
short = "xai-tooshort"
result = redact_sensitive_text(f"text {short} here", force=True)
assert short in result
class TestDbConnstrCodeOutput:
"""Regression tests for issue #33801 — _DB_CONNSTR_RE corrupting code output.
Two distinct flaws, both confined to displayed tool OUTPUT (read_file /
terminal / execute_code), never the on-disk content:
1. The password group ``[^@]+`` was greedy across newlines, so on a
multi-line block it scanned past the DSN line to the next stray ``@``
(e.g. a Python ``@decorator``), replacing everything in between with
``***`` — dropping lines and concatenating the next one.
2. An f-string DSN template (``f"postgresql://{user}:{pass}@{host}"``) is
not a live credential, but was redacted anyway. Under ``code_file=True``
a pure ``{...}`` brace password is now preserved.
"""
MULTILINE = (
' return f"postgresql://{auth}@{self.pg_host}:'
'{self.pg_port}/{self.pg_database}"\n'
"\n"
' @model_validator(mode="after")\n'
' def _validate_critical_settings(self) -> "Settings":'
)
def test_literal_connstr_still_redacted_with_code_file(self):
"""A real password in a literal DSN is still masked under code_file."""
text = "postgresql://admin:realpassword@db.internal:5432/app"
result = redact_sensitive_text(text, code_file=True, force=True)
assert "realpassword" not in result
assert "***" in result
def test_literal_connstr_redacted_all_schemes(self):
for scheme, secret in [
("postgres", "pgsecret1234"),
("mysql", "mysqlsecret99"),
("redis", "redissecret77"),
("mongodb+srv", "mongosecret55"),
("amqp", "amqpsecret33"),
]:
text = f"{scheme}://user:{secret}@host:1234/db"
result = redact_sensitive_text(text, code_file=True, force=True)
assert secret not in result, scheme
def test_literal_connstr_in_log_line_redacted(self):
text = "connected via postgres://user:s3cr3tpw@host:5432/db ok"
result = redact_sensitive_text(text, force=True)
assert "s3cr3tpw" not in result
class TestTerminalOutputRedaction:
"""is_env_dump_command + redact_terminal_output — issue #43025.
Terminal/process stdout must be redacted on every surface (foreground
`terminal` AND background `process(poll/log/wait)`). Env-dump commands get
the ENV-assignment pass so opaque tokens (no vendor prefix) are masked;
other commands stay on the code_file path to avoid false positives.
"""
def test_is_env_dump_command_detection(self):
from agent.redact import is_env_dump_command
assert is_env_dump_command("printenv")
assert is_env_dump_command("env")
assert is_env_dump_command("env | grep API")
assert is_env_dump_command("set")
assert is_env_dump_command("export")
assert is_env_dump_command("declare -x")
assert is_env_dump_command("cat /tmp/x && printenv")
assert not is_env_dump_command("python app.py")
assert not is_env_dump_command("cat config.py")
assert not is_env_dump_command("printf 'TOKEN=x'")
assert not is_env_dump_command("")
assert not is_env_dump_command(None)
def test_disabled_passes_through(self, monkeypatch):
from agent.redact import redact_terminal_output
monkeypatch.setattr("agent.redact._REDACT_ENABLED", False)
out = "CUSTOM_TOKEN=zzzopaque1234567890abcdef"
red = redact_terminal_output(out, "printenv")
assert "zzzopaque1234567890abcdef" in red
class TestFileReadNonReusableRedaction:
"""#35519: prefix-matched credentials in FILE CONTENT (read_file /
search_files / cat) must be redacted to a NON-REUSABLE sentinel — not a
head/tail mask that looks like a real-but-truncated key and gets written
back to config (corrupting the credential -> 401)."""
GHP = "ghp_S1abcdefghijklmnopqrstuvwxyz0Pn2T" # realistic GitHub PAT shape
SK = "sk-proj-abcdefghijklmnopqrstuvwxyz0123456789"
def test_file_read_does_not_leak_secret_body(self):
"""Crucial: file_read must NOT expose the real key (no un-redact)."""
out = redact_sensitive_text(f"token: {self.GHP}", force=True, file_read=True)
# No run of the secret body survives.
assert "S1abcdefghij" not in out
assert self.GHP not in out
assert "Pn2T" not in out # not even the tail (the old mask kept it)
def test_file_read_sentinel_is_not_a_plausible_key(self):
"""The sentinel can't be mistaken for / written back as a usable key:
the old mask was a 13-char `ghp_S1...Pn2T` that broke GitHub auth when
an agent re-saved it. The sentinel is syntactically invalid as a token
(contains « » … and ':'), so it can't round-trip into a dead key."""
out = redact_sensitive_text(f"GITHUB_PERSONAL_ACCESS_TOKEN: {self.GHP}",
force=True, file_read=True)
masked = out.split(": ", 1)[1].strip()
# Not a bare token: contains the sentinel delimiters.
assert masked.startswith("«") and masked.endswith("»")
assert "" in masked
class TestFireworksToken:
KEY = "fw_" + "A" * 40
def test_bare_token_masked(self):
result = redact_sensitive_text(f"fireworks error: key {self.KEY}", force=True)
assert self.KEY not in result
assert "fw_AA" in result
def test_too_short_not_masked(self):
short = "fw_tooshort"
result = redact_sensitive_text(f"text {short} here", force=True)
assert short in result
class TestRedactCdpUrl:
"""redact_cdp_url() is the single chokepoint for CDP endpoint log redaction.
Unlike the global pass (which deliberately lets web-URL query params and
userinfo through for OAuth/magic-link workflows), CDP endpoint credentials
are pure secrets and must always be masked. Both the browser tool's
session/discovery logs and the supervisor's attach-timeout error route
through this helper.
"""
def test_masks_multiple_query_credentials(self):
url = "wss://provider.example/session?token=aaa-secret&apikey=bbb-secret"
out = redact_cdp_url(url)
assert "aaa-secret" not in out
assert "bbb-secret" not in out
def test_none_returns_empty(self):
assert redact_cdp_url(None) == ""
class TestKeywordWordBoundary:
"""Ported from nearai/ironclaw#6129 — a secret keyword embedded inside a
larger prose word (``Secretary`` ⊃ ``secret``, ``tokenizer`` ⊃ ``token``,
``authored`` ⊃ ``auth``) must NOT trigger the lowercase/dotted/YAML config
passes. Real key shapes (separators, camelCase, acronyms, plurals, common
concatenated compounds, all-caps env style) must keep redacting.
"""
# ── prose words embedding a keyword are preserved ──────────────────
def test_secretary_yaml_value_preserved(self):
text = "Secretary: JanetYellen1234567890"
assert redact_sensitive_text(text) == text
# ── real key shapes still redact ────────────────────────────────────
def test_separator_keys_still_redacted(self):
for text in (
"client_secret: abc123def456ghi789jkl",
"auth_token: xyz789xyz789xyz789xyz",
"my_secret: topvalue123456789012345",
"db.password=hunter2verylongpassword",
):
result = redact_sensitive_text(text)
assert result != text, text
def test_camelcase_keys_still_redacted(self):
for text in (
"clientSecret: abc123def456ghi789jkl",
"secretKey: abc123def456ghi789jklmno",
"APIToken: abc123def456ghi789jklmn",
):
result = redact_sensitive_text(text)
assert result != text, text
def test_plural_keys_still_redacted(self):
text = "secrets: hunter2hunter2hunter2hh"
result = redact_sensitive_text(text)
assert "hunter2hunter2hunter2hh" not in result