mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-25 11:02:03 +00:00
Tirith redacts its own findings, but the approval-request callbacks built the
operator prompt from the RAW command string, so a credential-shaped value
Tirith flagged was sent verbatim to clients, undoing the redaction one layer up.
Two egress transports carried the leak; both are fixed via a shared
module-level seam _redact_approval_command() (redact_sensitive_text force=True):
1. chat platforms — _approval_notify_sync (gateway/run.py): redact before
both the button path (send_exec_approval) and the plain-text /approve
fallback.
2. SSE/API stream — _approval_notify (gateway/platforms/api_server.py):
redact event['command'] before it is enqueued to API/desktop clients.
(whole-bug-class: sibling call path on a separate transport.)
force=True so the prompt — a hard secret-egress boundary — honors redaction
even when security.redact_secrets is off. Clean commands pass through unchanged.
Tests bind the seam (synthetic credential-format fixtures, force-when-disabled) AND assert
BOTH callbacks ASSIGN the redacted result before the send/enqueue sink, via an
AST contract that rejects a discarded-result call. All mutation-checked.
128 lines
5.9 KiB
Python
128 lines
5.9 KiB
Python
"""Regression test for approval prompt credential redaction (issue #48456).
|
|
|
|
When Tirith flags a command for containing a credential-shaped pattern, the
|
|
gateway approval prompt must redact the credential from the command text
|
|
before sending it to the chat platform. Without this fix, the raw command
|
|
(with the credential in plaintext) is sent verbatim to Telegram/Discord/etc.,
|
|
undoing Tirith's redaction one layer up.
|
|
|
|
The redaction is wired through the module-level ``_redact_approval_command``
|
|
seam. These tests bind that seam -- the production wiring -- not just the
|
|
underlying ``redact_sensitive_text`` helper, so they fail if the redaction
|
|
call is removed from either approval path.
|
|
|
|
Credential fixtures are built at runtime from a benign prefix + a run of
|
|
``X`` characters (the same trick tests/agent/test_redact.py uses): they match
|
|
the redactor regexes so the assertions stay meaningful, but contain no real
|
|
or real-looking key, so secret scanners do not flag this file.
|
|
"""
|
|
|
|
from gateway.run import _redact_approval_command
|
|
|
|
# Synthetic, scanner-safe credential fixtures. Each matches its redactor
|
|
# regex (ghp_/sk-/JWT) but is unmistakably fake -- a run of X's, never a
|
|
# real or real-format key.
|
|
_FAKE_GHP = "ghp_" + "X" * 36
|
|
_FAKE_OPENAI = "sk-proj-" + "X" * 40
|
|
_FAKE_JWT = "eyJ" + "X" * 20 + "." + "eyJ" + "X" * 24 + "." + "X" * 30
|
|
|
|
|
|
class TestRedactApprovalCommand:
|
|
"""Contract for the approval-prompt redaction seam used by the gateway."""
|
|
|
|
def test_redacts_github_pat(self):
|
|
raw = "curl -H 'Authorization: token " + _FAKE_GHP + "' https://api.github.com/user"
|
|
out = _redact_approval_command(raw)
|
|
assert _FAKE_GHP not in out
|
|
# command structure preserved so the operator can still judge the action
|
|
assert "curl" in out
|
|
assert "github.com" in out
|
|
|
|
def test_redacts_openai_key(self):
|
|
raw = "export OPENAI_API_KEY=" + _FAKE_OPENAI + " && python s.py"
|
|
out = _redact_approval_command(raw)
|
|
assert _FAKE_OPENAI not in out
|
|
assert "python s.py" in out
|
|
|
|
def test_redacts_bearer_token(self):
|
|
raw = "curl -H 'Authorization: Bearer " + _FAKE_JWT + "' https://api.example.com"
|
|
out = _redact_approval_command(raw)
|
|
assert _FAKE_JWT not in out
|
|
|
|
def test_clean_command_passes_through_unchanged(self):
|
|
raw = "ls -la /tmp && echo hello"
|
|
assert _redact_approval_command(raw) == raw
|
|
|
|
def test_forces_redaction_even_when_disabled(self, monkeypatch):
|
|
"""force=True must redact even if security.redact_secrets is off -- the
|
|
approval prompt is a hard secret-egress boundary regardless of config."""
|
|
raw = "curl -H 'Authorization: token " + _FAKE_GHP + "' https://api.github.com"
|
|
# With redaction globally disabled, the seam must STILL redact (force=True).
|
|
monkeypatch.setattr("agent.redact._REDACT_ENABLED", False, raising=False)
|
|
out = _redact_approval_command(raw)
|
|
assert _FAKE_GHP not in out
|
|
|
|
def test_handles_none_and_empty(self):
|
|
assert _redact_approval_command("") == ""
|
|
assert _redact_approval_command(None) == ""
|
|
|
|
|
|
class TestApprovalCommandWiring:
|
|
"""Guard the production wiring on BOTH approval-notify transports:
|
|
1. the chat-platform path (_approval_notify_sync in gateway/run.py), and
|
|
2. the SSE/API path (_approval_notify in gateway/platforms/api_server.py),
|
|
each of which must route the command through _redact_approval_command and
|
|
REASSIGN the redacted value before any send/enqueue (so the raw command
|
|
cannot reach a client). Uses AST (not char-offset string slicing) so a
|
|
benign refactor doesn't cause a false failure, and so a discarded-result
|
|
call (`_redact(cmd); send(cmd)`) does NOT pass."""
|
|
|
|
def _assert_redacts_then_uses(self, module, func_name: str, sink_substr: str):
|
|
"""Parse `module`'s full AST, locate the (possibly nested) function
|
|
`func_name`, and assert it contains an assignment
|
|
`<x> = _redact_approval_command(...)` whose result is then used by a
|
|
statement matching `sink_substr` on a LATER line. Walking the real AST
|
|
(not a source slice) is refactor-robust and rejects discarded-result
|
|
calls (the call must be an assignment, not a bare expression)."""
|
|
import ast
|
|
import inspect
|
|
|
|
source = inspect.getsource(module)
|
|
tree = ast.parse(source)
|
|
target_fn = None
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == func_name:
|
|
target_fn = node
|
|
break
|
|
assert target_fn is not None, f"function {func_name} not found in {module.__name__}"
|
|
|
|
redact_line = None
|
|
for node in ast.walk(target_fn):
|
|
if isinstance(node, ast.Assign) and isinstance(node.value, ast.Call):
|
|
fn = node.value.func
|
|
if isinstance(fn, ast.Name) and fn.id == "_redact_approval_command":
|
|
redact_line = node.lineno
|
|
assert redact_line is not None, (
|
|
f"{func_name} must assign the result of _redact_approval_command(...) "
|
|
"(a discarded-result call would still leak the raw command)"
|
|
)
|
|
|
|
sink_line = None
|
|
for node in ast.walk(target_fn):
|
|
seg = ast.get_source_segment(source, node)
|
|
if seg and sink_substr in seg and getattr(node, "lineno", 0) > redact_line:
|
|
sink_line = node.lineno
|
|
break
|
|
assert sink_line is not None, (
|
|
f"`{sink_substr}` sink not found after the redaction in {func_name}"
|
|
)
|
|
|
|
def test_chat_platform_path_redacts_before_send(self):
|
|
import gateway.run as run
|
|
|
|
self._assert_redacts_then_uses(run, "_approval_notify_sync", "send_exec_approval")
|
|
|
|
def test_sse_api_path_redacts_before_enqueue(self):
|
|
from gateway.platforms import api_server
|
|
|
|
self._assert_redacts_then_uses(api_server, "_approval_notify", "put_nowait")
|