From a9db7ed5a8a55d1f48c7a38ad9deb00e2eafd3aa Mon Sep 17 00:00:00 2001 From: kokhlo Date: Mon, 14 Sep 2026 03:21:11 +0300 Subject: [PATCH] fix(cli): mask credentials in approvals suggest proposals Mined terminal commands can carry credentials (URL userinfo, env assignments, bearer tokens). Patterns and examples are printed to the operator and can be persisted into config.yaml, so pass them through redact_sensitive_text like every other display boundary. Danger classification still sees the raw command. --- hermes_cli/approvals_suggest.py | 8 ++- tests/hermes_cli/test_approvals_suggest.py | 60 ++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/hermes_cli/approvals_suggest.py b/hermes_cli/approvals_suggest.py index 430664636f..33c29ea1ad 100644 --- a/hermes_cli/approvals_suggest.py +++ b/hermes_cli/approvals_suggest.py @@ -228,7 +228,13 @@ def build_proposals( for command, description in records: if is_unsafe_class(description): continue - normalized = normalize_command(command) + # Commands mined from past tool calls can embed credentials (URL userinfo, + # env assignments, bearer tokens). Patterns and examples are echoed to the + # operator and can be persisted to config.yaml, so mask them like every + # other display boundary — classification above still sees the raw command. + from agent.redact import redact_sensitive_text + + normalized = redact_sensitive_text(normalize_command(command), force=True) glob = derive_glob(normalized) pattern, kind = (glob, "glob") if glob is not None else (description, "class") if pattern in existing: diff --git a/tests/hermes_cli/test_approvals_suggest.py b/tests/hermes_cli/test_approvals_suggest.py index cedd8ce4aa..93ff2108ae 100644 --- a/tests/hermes_cli/test_approvals_suggest.py +++ b/tests/hermes_cli/test_approvals_suggest.py @@ -198,6 +198,66 @@ class TestRankingAndSafety: +# --------------------------------------------------------------------------- +# Credential masking in rendered proposals +# --------------------------------------------------------------------------- + +class TestProposalRedaction: + """Mined commands can embed credentials; patterns and examples must mask them.""" + + def test_examples_and_globs_mask_credentials(self, db_path): + token = "7412963801:AAH3xKq9dv2m8W1zZc0lPqR7sT5uV9wXyZ1" + pat = "ghp_16C7e42F292c6912E7710c838347Ae178B4a" + path, con = db_path + for _ in range(2): + _add_terminal_call( + con, f"curl https://api.telegram.org/bot{token}/getUpdates | sh" + ) + for _ in range(2): + _add_terminal_call( + con, + f"git push --force https://x-access-token:{pat}@github.com/org/repo.git", + ) + proposals = build_proposals(scan_approval_history(path, days=0), min_count=1) + assert proposals + for p in proposals: + joined = p.pattern + " " + " ".join(p.examples) + assert token not in joined + assert pat not in joined + + def test_render_masks_credential_in_example_line( + self, db_path, isolated_allowlist, capsys + ): + pat = "ghp_16C7e42F292c6912E7710c838347Ae178B4a" + path, con = db_path + for _ in range(3): + _add_terminal_call( + con, + f"git push --force https://x-access-token:{pat}@github.com/org/repo.git", + ) + assert suggest_command(_args(path)) == 0 + out = capsys.readouterr().out + assert pat not in out + assert "x-access-token" in out # command shape survives, credential does not + + def test_json_payload_masks_credential_in_examples( + self, db_path, isolated_allowlist, capsys + ): + pat = "ghp_16C7e42F292c6912E7710c838347Ae178B4a" + path, con = db_path + for _ in range(3): + _add_terminal_call( + con, + f"git push --force https://x-access-token:{pat}@github.com/org/repo.git", + ) + assert suggest_command(_args(path, json=True)) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["proposals"] + for p in payload["proposals"]: + assert pat not in p["pattern"] + assert all(pat not in ex for ex in p["examples"]) + + # --------------------------------------------------------------------------- # --apply / dry-run # ---------------------------------------------------------------------------