From 0a4e6b296e75f58720f6666ce86181260424da71 Mon Sep 17 00:00:00 2001 From: HugoXK Date: Sun, 15 Nov 2026 09:26:04 +0000 Subject: [PATCH 1/4] test(telemetry): pin a credential written as one word (#1095) PGPASSWORD, s3secretkey, AWSSecretAccessKey, client_secrets and api_keys all fall through the component rules, so the predicate and the export are pinned before the fix. The measurement counters and the recorded auth non-goal are pinned at the same time so the suffix rule cannot reach them. --- tests/test_span_redaction.py | 47 ++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/test_span_redaction.py b/tests/test_span_redaction.py index cb4be62a..94466bd6 100644 --- a/tests/test_span_redaction.py +++ b/tests/test_span_redaction.py @@ -177,3 +177,50 @@ def test_a_memory_block_keeps_its_counters_too() -> None: assert attributes["budget_tokens"] == 500 assert attributes["token_count"] == 3 + + +# --- a credential written as one word is still a credential (#1095) -------- +# +# `#1070` matches a whole component, so a credential name that arrives as one +# word falls through all three rules: `PGPASSWORD` is libpq's password variable, +# `s3secretkey` and `AWSSecretAccessKey` need a separator to split, and +# `client_secrets` / `api_keys` are the pair with a plural `s`. `auth` stays a +# non-goal: it names a mode (`auth_mode`, `auth_ok`) as often as a credential. + +ONE_WORD_CREDENTIAL_KEYS = ( + "PGPASSWORD", "pgpassword", "client_secrets", "api_keys", "s3secretkey", + "AWSSecretAccessKey", +) + +#: Names that are not credentials, including the recorded `auth` non-goal. +NOT_CREDENTIAL_KEYS = ("REDISCLI_AUTH", "auth", "sort_key") + + +def _is_secret_key(key: object) -> bool: + from agentdescent.rsi_telemetry import _is_secret_key as predicate + + return predicate(key) + + +def test_a_one_word_credential_key_is_dropped() -> None: + for key in ONE_WORD_CREDENTIAL_KEYS: + assert _is_secret_key(key) is True, key + + +def test_the_counters_and_an_auth_mode_are_not_credentials() -> None: + for key in list(MEASUREMENT_KEYS) + list(NOT_CREDENTIAL_KEYS): + assert _is_secret_key(key) is False, key + + +def test_a_one_word_credential_never_reaches_the_export() -> None: + telemetry = _telemetry() + span = telemetry.start_span( + "cost", attributes={"PGPASSWORD": "hunter2", "MAX_TOKENS": 16}) + + assert "PGPASSWORD" not in span.attributes + assert span.attributes["MAX_TOKENS"] == 16 + + (exported,) = telemetry.to_otel() + assert "PGPASSWORD" not in exported["attributes"] + assert exported["attributes"]["MAX_TOKENS"] == 16 + assert "hunter2" not in str(exported) From 0d78d300a763cbd622d573e75940d1198b952908 Mon Sep 17 00:00:00 2001 From: HugoXK Date: Sun, 15 Nov 2026 09:26:04 +0000 Subject: [PATCH 2/4] fix(telemetry): a credential written as one word is still a credential (#1095) A single-component key is matched by whole-word suffix (password, secretkey, apikey, ...), so PGPASSWORD and s3secretkey no longer reach a span; the adjacent-pair lookup tolerates one plural s, so client_secrets and api_keys match. Suffix rather than substring keeps tokens/max_tokens/secret_free alive (#1070), and auth stays a non-goal. --- agentdescent/rsi_telemetry.py | 32 ++++++++++++++++++++-- changelog.d/1095-credential-word-suffix.md | 7 +++++ 2 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 changelog.d/1095-credential-word-suffix.md diff --git a/agentdescent/rsi_telemetry.py b/agentdescent/rsi_telemetry.py index 7714348f..0081c29e 100644 --- a/agentdescent/rsi_telemetry.py +++ b/agentdescent/rsi_telemetry.py @@ -68,6 +68,14 @@ "password", "passwd", "pwd", "credential", "credentials", "authorization", )) _CREDENTIAL_NOUNS = frozenset(("token", "secret")) +#: A credential written as one word (#1095): `PGPASSWORD` is libpq's password +#: variable and `s3secretkey` / `AWSSecretAccessKey` carry no separator, so a +#: single-component key is matched by whole-word suffix. `tokens` ends `okens`, +#: which is why the suffix has to be a whole credential word and not a stem. +_CREDENTIAL_WORDS = frozenset(( + "password", "passwd", "secret", "secretkey", "apikey", "privatekey", + "accesskey", "clientsecret", "credential", +)) #: Two components of one credential spelling (`api` + `key`). _CREDENTIAL_PAIRS = frozenset(( "apikey", "accesskey", "privatekey", "secretkey", "clientsecret", @@ -81,12 +89,27 @@ r"Bearer\s+[A-Za-z0-9._-]{10,})") +def _singular(word: str) -> Tuple[str, ...]: + """``word`` and, when it ends in a plural ``s``, its singular (#1095).""" + if len(word) > 1 and word.endswith("s"): + return (word, word[:-1]) + return (word,) + + +def _pair_spells_a_credential(first: str, second: str) -> bool: + """`api` + `keys`, `client` + `secrets`: one plural `s` is still the pair.""" + return any(left + right in _CREDENTIAL_PAIRS + for left in _singular(first) for right in _singular(second)) + + def _is_secret_key(key: Any) -> bool: """True when a whole component of ``key`` names a credential (#1070). ``token_count`` and ``secret_free`` measure credentials, they are not one; - ``client_secret`` and ``api_key`` are, in any spelling. Values are still - filtered by :data:`_SECRET_VALUE` wherever they appear. + ``client_secret`` and ``api_key`` are, in any spelling. A credential that is + one word (`PGPASSWORD`, `s3secretkey`) has no component to see, so it is + matched by whole-word suffix instead (#1095). Values are still filtered by + :data:`_SECRET_VALUE` wherever they appear. """ parts = [part for part in _KEY_PARTS.split(str(key).lower()) if part] if not parts: @@ -95,7 +118,10 @@ def _is_secret_key(key: Any) -> bool: return True if parts[-1] in _CREDENTIAL_NOUNS: return True - return any(first + second in _CREDENTIAL_PAIRS + if len(parts) == 1 and any(parts[0].endswith(word) + for word in _CREDENTIAL_WORDS): + return True + return any(_pair_spells_a_credential(first, second) for first, second in zip(parts, parts[1:])) diff --git a/changelog.d/1095-credential-word-suffix.md b/changelog.d/1095-credential-word-suffix.md new file mode 100644 index 00000000..42ed621f --- /dev/null +++ b/changelog.d/1095-credential-word-suffix.md @@ -0,0 +1,7 @@ +Fixed +`_is_secret_key` no longer lets a credential through when it arrives as one +word: `PGPASSWORD`, `s3secretkey` and `AWSSecretAccessKey` are matched by +whole-word suffix on a single-component key, and the adjacent-pair lookup +tolerates one plural `s`, so `client_secrets` and `api_keys` are dropped too. +The `#1070` counters (`tokens`, `max_tokens`, `secret_free`) still survive, and +`auth` stays a recorded non-goal (#1095). From 33cc6eec77f062c3ea9fc2bec33bdf50a72a4bfb Mon Sep 17 00:00:00 2001 From: HugoXK Date: Sun, 15 Nov 2026 09:42:48 +0000 Subject: [PATCH 3/4] test(telemetry): pin the amended one-word credential rows (#1095) The amended contract adds the plural spellings: passwords, pgpasswords, db_passwords, db_secrets as credentials, and monkey, max_new_tokens, total_tokens, status, access_level as not. The export row keeps secrets_scanned alongside MAX_TOKENS so the noun rule cannot reach it. --- tests/test_span_redaction.py | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/tests/test_span_redaction.py b/tests/test_span_redaction.py index 94466bd6..e0455543 100644 --- a/tests/test_span_redaction.py +++ b/tests/test_span_redaction.py @@ -184,16 +184,22 @@ def test_a_memory_block_keeps_its_counters_too() -> None: # `#1070` matches a whole component, so a credential name that arrives as one # word falls through all three rules: `PGPASSWORD` is libpq's password variable, # `s3secretkey` and `AWSSecretAccessKey` need a separator to split, and -# `client_secrets` / `api_keys` are the pair with a plural `s`. `auth` stays a +# `client_secrets` / `api_keys` are the pair with a plural `s`. The amendment +# also pins the plain plurals (`passwords`, `pgpasswords`, `db_secrets`), which +# only the last-position noun rule and the plural suffixes reach. `auth` stays a # non-goal: it names a mode (`auth_mode`, `auth_ok`) as often as a credential. -ONE_WORD_CREDENTIAL_KEYS = ( - "PGPASSWORD", "pgpassword", "client_secrets", "api_keys", "s3secretkey", - "AWSSecretAccessKey", +CREDENTIAL_WORD_KEYS = ( + "PGPASSWORD", "pgpassword", "passwords", "pgpasswords", "db_passwords", + "db_secrets", "client_secrets", "CLIENT_SECRETS", "api_keys", + "secret_keys", "s3secretkey", "AWSSecretAccessKey", "AWS_SECRET_ACCESS_KEY", ) #: Names that are not credentials, including the recorded `auth` non-goal. -NOT_CREDENTIAL_KEYS = ("REDISCLI_AUTH", "auth", "sort_key") +NOT_CREDENTIAL_KEYS = ( + "REDISCLI_AUTH", "auth", "sort_key", "monkey", "max_new_tokens", + "total_tokens", "status", "access_level", +) def _is_secret_key(key: object) -> bool: @@ -202,25 +208,27 @@ def _is_secret_key(key: object) -> bool: return predicate(key) -def test_a_one_word_credential_key_is_dropped() -> None: - for key in ONE_WORD_CREDENTIAL_KEYS: - assert _is_secret_key(key) is True, key +@pytest.mark.parametrize("key", CREDENTIAL_WORD_KEYS) +def test_a_credential_key_is_dropped(key: str) -> None: + assert _is_secret_key(key) is True -def test_the_counters_and_an_auth_mode_are_not_credentials() -> None: - for key in list(MEASUREMENT_KEYS) + list(NOT_CREDENTIAL_KEYS): - assert _is_secret_key(key) is False, key +@pytest.mark.parametrize("key", tuple(MEASUREMENT_KEYS) + NOT_CREDENTIAL_KEYS) +def test_the_counters_and_an_auth_mode_are_not_credentials(key: str) -> None: + assert _is_secret_key(key) is False def test_a_one_word_credential_never_reaches_the_export() -> None: telemetry = _telemetry() - span = telemetry.start_span( - "cost", attributes={"PGPASSWORD": "hunter2", "MAX_TOKENS": 16}) + span = telemetry.start_span("cost", attributes={ + "PGPASSWORD": "hunter2", "MAX_TOKENS": 16, "secrets_scanned": 12}) assert "PGPASSWORD" not in span.attributes assert span.attributes["MAX_TOKENS"] == 16 + assert span.attributes["secrets_scanned"] == 12 (exported,) = telemetry.to_otel() assert "PGPASSWORD" not in exported["attributes"] assert exported["attributes"]["MAX_TOKENS"] == 16 + assert exported["attributes"]["secrets_scanned"] == 12 assert "hunter2" not in str(exported) From 1e82ec620aab6bdd2c4ca04e56d9493850ecbc41 Mon Sep 17 00:00:00 2001 From: HugoXK Date: Sun, 15 Nov 2026 09:42:48 +0000 Subject: [PATCH 4/4] fix(telemetry): match plural credential spellings (#1095) The plural compounds join the component set (db_passwords, in any position), secrets joins the last-position nouns (db_secrets), and the whole-word suffix list gains the plural spellings (passwords, pgpasswords). secrets stays out of the component set on purpose: it would drop secrets_scanned. --- agentdescent/rsi_telemetry.py | 7 ++++++- changelog.d/1095-credential-word-suffix.md | 6 ++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/agentdescent/rsi_telemetry.py b/agentdescent/rsi_telemetry.py index 0081c29e..f3345438 100644 --- a/agentdescent/rsi_telemetry.py +++ b/agentdescent/rsi_telemetry.py @@ -66,8 +66,11 @@ "apikey", "accesskey", "privatekey", "secretkey", "clientsecret", "apitoken", "accesstoken", "authtoken", "refreshtoken", "bearertoken", "password", "passwd", "pwd", "credential", "credentials", "authorization", + # A plural compound is still a credential in any position (`db_passwords`). + "passwords", "passwds", "secretkeys", "apikeys", "privatekeys", + "accesskeys", "clientsecrets", )) -_CREDENTIAL_NOUNS = frozenset(("token", "secret")) +_CREDENTIAL_NOUNS = frozenset(("token", "secret", "secrets")) #: A credential written as one word (#1095): `PGPASSWORD` is libpq's password #: variable and `s3secretkey` / `AWSSecretAccessKey` carry no separator, so a #: single-component key is matched by whole-word suffix. `tokens` ends `okens`, @@ -75,6 +78,8 @@ _CREDENTIAL_WORDS = frozenset(( "password", "passwd", "secret", "secretkey", "apikey", "privatekey", "accesskey", "clientsecret", "credential", + "passwords", "passwds", "secrets", "secretkeys", "apikeys", "privatekeys", + "accesskeys", "clientsecrets", "credentials", )) #: Two components of one credential spelling (`api` + `key`). _CREDENTIAL_PAIRS = frozenset(( diff --git a/changelog.d/1095-credential-word-suffix.md b/changelog.d/1095-credential-word-suffix.md index 42ed621f..892191da 100644 --- a/changelog.d/1095-credential-word-suffix.md +++ b/changelog.d/1095-credential-word-suffix.md @@ -3,5 +3,7 @@ Fixed word: `PGPASSWORD`, `s3secretkey` and `AWSSecretAccessKey` are matched by whole-word suffix on a single-component key, and the adjacent-pair lookup tolerates one plural `s`, so `client_secrets` and `api_keys` are dropped too. -The `#1070` counters (`tokens`, `max_tokens`, `secret_free`) still survive, and -`auth` stays a recorded non-goal (#1095). +The plural spellings (`passwords`, `pgpasswords`, `db_secrets`) match in the +last position or as a whole component. The `#1070` counters (`tokens`, +`max_tokens`, `secrets_scanned`) still survive, and `auth` stays a recorded +non-goal (#1095).