Skip to content

fix(telemetry): a credential written as one word is still a credential (PGPASSWORD, s3secretkey, client_secrets) #1095

Closed

Description

@HugoXK

Problem

rsi_telemetry._is_secret_key (agentdescent/rsi_telemetry.py:84, from #1070) matches a credential by whole component: a key is dropped when a component is in _CREDENTIAL_COMPONENTS (:65), when its last component is token/secret (:70), or when two adjacent components spell one of _CREDENTIAL_PAIRS (:72). Credential names that are one word fall through all three, and one of them is a standard variable:

  • PGPASSWORD — libpq's password variable, read by psql; a harness that records the environment it ran under records the password.
  • s3secretkey, AWSSecretAccessKey spelled without separators — the split at :76 needs a separator to see secret + key.
  • a plural on the pair or the noun: client_secrets, api_keys, db_secrets, db_passwords.

Measured at 85b2974 (this file is unchanged from f5e93335, #1088 does not touch it):

PGPASSWORD  False    client_secrets  False    s3secretkey  False
api_keys    False    db_secrets      False    db_passwords False
passwords   False    secret_keys     False    REDISCLI_AUTH False

The #1070 invariant holds at the same time, which is the constraint any fix must keep: tokens, max_tokens, input_tokens, output_tokens, token_count, tokens_in, budget_tokens, secret_free, secrets_scanned are all False.

Reachability today is the boundary's own gap, not a live leak: no in-tree caller records os.environ into span attributes (set_attribute (:542) has zero callers under agentdescent/, the module holds no environ reference, and every attributes= call site passes caller-supplied data). #947/#1063/#1070 exist so that no caller has to remember, which is why the filter is where this closes.

Ruling (Lead)

  1. Component matching stays. No return to substring matching — the counters fix(telemetry): a credential key is matched by component, so tokens/max_tokens stop being dropped from spans #1070 restored must keep surviving.
  2. A single-component key that ends with a whole credential word is a credential: password(s), passwd(s), secret(s), secretkey(s), apikey(s), privatekey(s), accesskey(s), credential(s). Bare key is not in that suffix list (monkey, and sort_key at the last position).
  3. _CREDENTIAL_COMPONENTS gains the plural compound spellings only — passwords, passwds, secretkeys, apikeys, privatekeys, accesskeys, clientsecrets — and _CREDENTIAL_NOUNS gains secrets (last position only). The pair lookup tolerates one plural s on either component and always keeps the unfolded spelling (see the second trap).
  4. REDISCLI_AUTH and a bare auth component stay False — auth names a mode as often as a credential (auth_mode, auth_ok), and _SECRET_VALUE (:77) still redacts a token-shaped value wherever it appears. Recorded non-goal; not to be re-filed.
  5. A prefixed one-word token spelling stays out. token is deliberately not in the one-word suffix list, because tokens/max_tokens/total_tokens are the fix(telemetry): a credential key is matched by component, so tokens/max_tokens stop being dropped from spans #1070 counters this filter exists to preserve. Measured at 1e82ec6: token, MY_TOKEN, authtoken, OAUTH_TOKEN are already True (the noun and component rules reach them); mytoken, xauthtoken, GITLABTOKEN, NPMTOKEN are False. No in-tree key has that shape — the 9508-literal sweep shows no such change — so it is recorded, not fixed here. If a later card takes it, adding token to _CREDENTIAL_WORDS cannot break the fix(telemetry): a credential key is matched by component, so tokens/max_tokens stop being dropped from spans #1070 rows: tokens ends okens, not token, and the suffix test is single-component only.

Two traps, both measured (Security's pre-PR read, verified here)

  • secrets must not go into _CREDENTIAL_COMPONENTS. That set is checked at any position, so secrets_scanned becomes a credential and a pinned MEASUREMENT_KEYS row dies. Simulated on the current predicate: secrets in components → secrets_scanned: True. It goes into the last-position noun set instead, which catches db_secrets/client_secrets and leaves secrets_scanned alone.
  • The plural fold must keep the unfolded spelling. Stripping a trailing s and returning only the stripped form turns access into acces, so AWS_SECRET_ACCESS_KEY stops matching accesskey — a credential the predicate already catches would regress. The branch keeps both (_singular returns (word, word[:-1]), original first), so it folds either component of a pair and (access, key) still spells accesskey. The invariant is that the unfolded spelling is always tried, not that the first component is untouched. Measured at 1e82ec6: AWS_SECRET_ACCESS_KEY True, tokens False, and the only widening the first-component fold gives is apis_key → apikey, which names a credential.

A candidate implementation of rules 1-4, run against every case below, gets all of them right in one pass — that is the shape, not a patch to copy blindly.

Acceptance

True: PGPASSWORD, pgpassword, pgpasswords, client_secrets, CLIENT_SECRETS, api_keys, secret_keys, s3secretkey, db_secrets, db_passwords, passwords, plus everything already true today (api_key, client_secret, S3_SECRET_KEY, AWS_SECRET_ACCESS_KEY, MYSQL_ROOT_PASSWORD, credentials, authorization).

False: every key in tests/test_span_redaction.py's MEASUREMENT_KEYS; REDISCLI_AUTH, auth, sort_key, monkey, max_new_tokens, total_tokens, status, access_level.

End-to-end, not just the predicate: a span opened with {"PGPASSWORD": "hunter2", "MAX_TOKENS": 16, "secrets_scanned": 12} keeps both counters and exports no PGPASSWORD, in span.attributes and in to_otel().

One changelog fragment; pytest -q offline, python -m tools.gen_api_docs --check, python -m tools.changelog_fragments check, mkdocs build --strict where mkdocs is installed.

Measured at 1e82ec620aab6bdd2c4ca04e56d9493850ecbc41 (branch agent/1095-credential-suffix, base live main f5e93335): red re-taken at clean f5e93335 with only the amended test file — 13 failed, 29 passed; 13 True and 17 False acceptance rows, 0 mismatches; test_span_redaction.py + test_memory_spans.py 48 passed; 9508-literal sweep main vs tip — 22 verdict changes, every one a credential spelling or a fixture, no MEASUREMENT_KEYS row moved.

Tier: T2 by .github/agent/risk-rules.json (agentdescent/**) — one filtered module, its tests and a fragment; the PR waits for the owner's click after a PASS at the tip.

Written-by: AgentDescent Lead
Bot: AgentDescent Security (found in the #1088 re-read at 85b2974)

Activity

  1. HugoXK commented on Nov 15, 2026

    @HugoXK
    CollaboratorAuthor

    Branch agent/1095-credential-suffix at 1e82ec620aab6bdd2c4ca04e56d9493850ecbc41 (base live main f5e93335c5fe626136b8a19fcc93e2e62d3186d7) measured from this seat, not taken from the report.

    Red re-taken at clean f5e93335, only the amended test file: 13 failed, 29 passed — the 12 single-key credential rows (PGPASSWORD, pgpassword, passwords, pgpasswords, db_passwords, db_secrets, client_secrets, CLIENT_SECRETS, api_keys, secret_keys, s3secretkey, AWSSecretAccessKey) plus the export row. AWS_SECRET_ACCESS_KEY is the one credential row that already passes at main by the pair rule, so it is not in the red list.

    Acceptance at the tip (own probe): 13 True rows and 17 False rows — the nine MEASUREMENT_KEYS plus REDISCLI_AUTH, auth, sort_key, monkey, max_new_tokens, total_tokens, status, access_level — 0 mismatches. secrets is in _CREDENTIAL_NOUNS and not in _CREDENTIAL_COMPONENTS; the export row keeps MAX_TOKENS and secrets_scanned; tokens stays False.

    Collateral: all 9508 string literals under agentdescent/** and tests/** run through both predicates, main vs tip — 22 verdicts change, every one a credential spelling or a fixture (abc123secret is a payload value, invalidapikey an error code, secrets. a sentence fragment). No measurement key changes.

    Detail for review: the pair fold is applied to both components, not the second alone — _singular returns the original word first, so (access, key) still yields accesskey and AWS_SECRET_ACCESS_KEY stays True. The invariant that matters is that the unfolded spelling is always tried; apis_key → apikey is the one widening I found, and it names a credential.

    Green at the tip: tests/test_span_redaction.py + tests/test_memory_spans.py 48 passed. Diff 3 files, +99/−4, no .github/**, no dependency or lockfile, no secret-shaped added line. T2 by agentdescent/** (limits 250 lines / 12 files).

    Branch frozen at this sha; the single Fixes #1095 PR opens once the open-PR count is at most two (three now: #1088, #1084, external #1009).

  2. added a commit that references this issue on Nov 15, 2026
  3. added
    bugSomething isn't working
    area:evolveThe evolve loop: evolution, async_evolve, aggregator, population, policies, strategies
    on Nov 15, 2026
  4. HugoXK commented on Nov 15, 2026

    @HugoXK
    CollaboratorAuthor

    Record: the branch is now PR #1097 at tip c390e7ae27b2cb9837f45c318e635d484e62ab0f.

    The tip is 1e82ec620aab6bdd2c4ca04e56d9493850ecbc41 plus one forward merge of live main 09492927f614b5303e54cc3f87b0bb809929e336, taken in by merge rather than rebase: 1e82ec62 is still an ancestor and nothing was force-pushed. The merge carries no change of its own — git diff 09492927 c390e7ae and git diff f5e93335 1e82ec62 are the same patch, sha256 5344f2abc631… for both, 3 files +99/−4.

    Red, re-taken at live main 09492927 rather than carried over from f5e93335, by two independent runs (the dev's and mine in a clean worktree): 13 failed, 29 passed with only the amended tests/test_span_redaction.py present — the twelve test_a_credential_key_is_dropped[...] params plus test_a_one_word_credential_never_reaches_the_export. AWS_SECRET_ACCESS_KEY passes at main through the pair rule and is therefore not in the red set.

    Green at the tip: test_span_redaction.py + test_memory_spans.py 48 passed (my own worktree), the six rsi_telemetry files 85 passed, changelog + api_reference + repo_hygiene 76 passed, gen_api_docs --check up to date, changelog fragments check OK. Tier T2 by agentdescent/** (rsi_telemetry.py is not a T3 path) and inside the T2 size cap.

    The acceptance list in this issue body is unchanged; the non-goals stand as written, including non-goal 5 for prefixed token spellings.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:evolveThe evolve loop: evolution, async_evolve, aggregator, population, policies, strategiesbugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions