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.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user