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 # ---------------------------------------------------------------------------