"""Unit tests for secret masking in tool-argument logs."""
import pytest
from navi.tools._internal.redact import REDACTED, is_sensitive_key, redact_args
class TestIsSensitiveKey:
@pytest.mark.parametrize(
"key",
[
"password",
"Password",
"PASSWORD",
"passwd",
"pwd",
"secret",
"client_secret",
"token",
"access_token",
"refresh_token",
"api_key",
"api-key",
"apikey",
"private_key",
"passphrase",
"authorization",
],
)
def test_credentials_are_sensitive(self, key: str):
assert is_sensitive_key(key) is True
@pytest.mark.parametrize(
"key",
[
"db_password",
"user_token",
"openai_api_key",
"ssh_private_key",
"my-passphrase",
],
)
def test_qualified_names_are_sensitive(self, key: str):
assert is_sensitive_key(key) is True
@pytest.mark.parametrize(
"key",
[
# Real ssh_exec parameter: a path, not a credential.
"key_path",
"command",
"host",
"username",
# Would be caught by a careless suffix rule, and must not be.
"max_tokens",
"token_count",
"token_limit",
"tokens",
],
)
def test_ordinary_parameters_stay_readable(self, key: str):
assert is_sensitive_key(key) is False
def test_non_string_key_is_not_sensitive(self):
assert is_sensitive_key(7) is False
class TestRedactArgs:
def test_masks_a_password_and_keeps_everything_else(self):
args = {"command": "uptime", "host": "10.0.0.1", "password": "hunter2"}
assert redact_args(args) == {
"command": "uptime",
"host": "10.0.0.1",
"password": REDACTED,
}
def test_masks_at_depth(self):
"""A credential nested in a tool's own arguments payload (test_mcp_tool)."""
args = {"server_name": "x", "arguments": {"user": "a", "api_key": "sk-1"}}
assert redact_args(args)["arguments"] == {"user": "a", "api_key": REDACTED}
def test_masks_inside_lists(self):
args = {"items": [{"password": "a"}, {"note": "b"}]}
assert redact_args(args) == {"items": [{"password": REDACTED}, {"note": "b"}]}
def test_masks_each_item_of_a_list_of_dicts(self):
args = [{"token": "a"}, {"token": "b"}]
assert redact_args(args) == [{"token": REDACTED}, {"token": REDACTED}]
def test_the_key_is_spelled_out_so_the_log_stays_useful(self):
assert redact_args({"authorization": "Bearer x"}) == {"authorization": REDACTED}
def test_a_secret_in_a_value_under_an_ordinary_key_is_not_caught(self):
"""The documented gap: only the *key* is inspected, so this stays visible.
Same shape as a password inside ``terminal``'s ``command`` or a token in
an unlabelled header value. Nothing name-based can see it.
"""
args = {"headers": [{"name": "Authorization", "value": "Bearer sekrit"}]}
assert redact_args(args) == args
def test_does_not_mutate_the_original(self):
"""The tool and the client still get the real value — only the log is masked."""
args = {"password": "hunter2", "nested": {"token": "t"}}
original = {"password": "hunter2", "nested": {"token": "t"}}
redact_args(args)
assert args == original
assert args["nested"]["token"] == "t"
def test_scalars_and_none_pass_through(self):
assert redact_args(None) is None
assert redact_args("plain") == "plain"
assert redact_args(3) == 3
def test_a_deeply_nested_structure_terminates(self):
"""The depth cap is a guard, not a feature — it just must not recurse forever."""
node: dict = {"password": "hunter2"}
for _ in range(30):
node = {"nested": node}
redact_args(node) # must return, not blow the stack