fix(redact): stop masking prose words that embed a secret keyword (Secretary, tokenizer, author=)

Port from nearai/ironclaw#6129: their sensitive-marker scrubber matched
markers as bare substrings, so tool results containing 'Secretary of the
Treasury' were scrubbed as 'secret' on replay, evicting legitimate content
and forcing the model into a re-fetch loop. Hermes' lowercase/dotted/YAML
config-key redaction patterns (_CFG_DOTTED_RE, _CFG_ANCHORED_RE,
_YAML_ASSIGN_RE) had the same false-positive class: their key classes allow
arbitrary alphanumeric affixes around the keyword, so ordinary document
text like 'Secretary: J.Smith', 'tokenizer: cl100k_base' (HF model cards),
and BibTeX 'author=Smith' got value-masked on the surfaces that run these
passes (browser snapshots, log lines, kanban summaries, CLI-echoed output).

Fix: post-match word-boundary validation of the keyword occurrence inside
the matched key. Boundaries: key edges, non-letters (_ - . digits),
camelCase transitions (clientSecret, secretKey, APIToken), plural 's'
(secrets:, tokens:). Concatenated real-world compounds keep matching via
explicit alternatives (authtoken, authkey, secretkey, accesstoken). ALL-CAPS
keys keep legacy embedded matching (MYTOKEN=...) — all-caps is almost never
prose, same rationale as _ENV_ASSIGN_RE. Same discipline the file already
applies to exact-match body/query keys (ported from ironclaw#2529) and the
deliberate 'auth' exclusion that keeps 'author:' from matching.
This commit is contained in:
Teknium 2026-07-19 17:16:00 -07:00
parent 474c84ed8d
commit b41eee450b
2 changed files with 184 additions and 0 deletions

View file

@ -175,6 +175,85 @@ _YAML_ASSIGN_RE = re.compile(
re.IGNORECASE | re.MULTILINE,
)
# Word-boundary validation for the mixed/lowercase key patterns above
# (_CFG_DOTTED_RE, _CFG_ANCHORED_RE, _YAML_ASSIGN_RE).
#
# Those key classes allow arbitrary alphanumeric affixes around the secret
# keyword so real key names like ``client_secret``, ``clientSecret``, and
# ``s3.secret-key`` match. The side effect: ordinary prose/document words that
# merely CONTAIN a keyword also matched — ``Secretary: J.Smith`` (secret),
# ``tokenizer: cl100k_base`` (token), ``author=Smith`` (auth) — mangling
# legitimate content on the surfaces that run these passes (browser snapshots,
# log lines, kanban summaries, CLI-echoed command output). Ported from
# nearai/ironclaw#6129, where the same substring false positive ("Secretary of
# the Treasury" matching the ``secret`` marker) scrubbed legitimate tool
# results from the replayed transcript and sent the model into a re-fetch
# loop.
#
# A keyword occurrence only counts when it sits at a word boundary within the
# key: at the key's edge, next to a non-letter (``_ - . 3``), or at a
# camelCase transition (``clientSecret``, ``secretKey``, ``APIToken``). A
# trailing plural ``s`` is treated as part of the keyword (``secrets:``,
# ``tokens:``). Common concatenated compounds keep matching via explicit
# alternatives (``authtoken`` ngrok, ``authkey`` tailscale, ``secretkey``
# minio, ``apikey``). Embedded occurrences inside a larger word
# (``secretary``, ``tokenizer``, ``authored``, ``credentialing``) no longer
# match. ALL-CAPS keys keep the legacy embedded matching (``MYTOKEN=…``) — an
# all-caps key is almost never prose, the same rationale as _ENV_ASSIGN_RE.
_KEY_KEYWORD_RE = re.compile(
r"(?:api|auth|access|refresh|session|secret)[ _.\-]?(?:key|token)"
r"|token|secret|passwd|password|credential|auth",
re.IGNORECASE,
)
def _is_word_start(s: str, i: int) -> bool:
"""True if position ``i`` in ``s`` begins a word (not mid-word)."""
if i == 0:
return True
prev, cur = s[i - 1], s[i]
if not prev.isalpha():
return True
if cur.isupper() and prev.islower():
return True # camelCase: clientSecret
# Acronym run ending: APIToken — the 'T' begins a new word when it is
# followed by lowercase while the preceding run is uppercase.
if cur.isupper() and prev.isupper() and i + 1 < len(s) and s[i + 1].islower():
return True
return False
def _is_word_end(s: str, j: int, *, allow_plural: bool = True) -> bool:
"""True if position ``j`` (exclusive end) in ``s`` ends a word."""
if j >= len(s):
return True
cur = s[j]
if not cur.isalpha():
return True
if cur.isupper() and s[j - 1].islower():
return True # camelCase continuation: secretKey
if allow_plural and cur in "sS":
return _is_word_end(s, j + 1, allow_plural=False)
return False
def _key_has_secret_keyword(key: str) -> bool:
"""True if ``key`` contains a secret keyword at a word boundary.
Post-match validator for _CFG_DOTTED_RE / _CFG_ANCHORED_RE /
_YAML_ASSIGN_RE hits rejects prose words that merely embed a keyword
(``secretary``, ``tokenizer``, ``authored``). Safe to call with the
_ENV_ASSIGN_RE key too: all-caps keys short-circuit to the legacy
embedded-match behavior.
"""
letters = [c for c in key if c.isalpha()]
if letters and all(c.isupper() for c in letters):
return True # legacy all-caps behavior (MYTOKEN=…)
for m in _KEY_KEYWORD_RE.finditer(key):
if _is_word_start(key, m.start()) and _is_word_end(key, m.end()):
return True
return False
# JSON field patterns: "apiKey": "value", "token": "value", etc.
_JSON_KEY_NAMES = r"(?:api_?[Kk]ey|token|secret|password|access_token|refresh_token|auth_token|bearer|secret_value|raw_secret|secret_input|key_material)"
_JSON_FIELD_RE = re.compile(
@ -614,6 +693,13 @@ def redact_sensitive_text(
# prose/log contexts (issue #2852): ``KEY=os.getenv('X')``.
if _ENV_LOOKUP_VALUE_RE.match(value):
return m.group(0)
# Keyword must sit at a word boundary within the key —
# ``author=Smith`` / ``press.secretary=…`` are prose, not
# credentials (ported from nearai/ironclaw#6129). All-caps
# keys (the _ENV_ASSIGN_RE shape) short-circuit to legacy
# embedded matching inside the helper.
if not _key_has_secret_keyword(name):
return m.group(0)
return f"{name}={quote}{_mask_token(value)}{quote}"
text = _ENV_ASSIGN_RE.sub(_redact_env, text)
# Lowercase/dotted config keys (issue #16413). Skip URLs entirely —
@ -647,6 +733,11 @@ def redact_sensitive_text(
# not a leaked secret value.
if _ENV_LOOKUP_VALUE_RE.match(value):
return m.group(0)
# Keyword must sit at a word boundary within the key —
# ``Secretary: J.Smith`` / ``tokenizer: cl100k_base`` are
# document text, not credentials (nearai/ironclaw#6129).
if not _key_has_secret_keyword(key):
return m.group(0)
return f"{key}{sep}{_mask_token(value)}"
text = _YAML_ASSIGN_RE.sub(_redact_yaml, text)

View file

@ -1097,3 +1097,96 @@ class TestRedactCdpUrl:
def test_none_returns_empty(self):
assert redact_cdp_url(None) == ""
class TestKeywordWordBoundary:
"""Ported from nearai/ironclaw#6129 — a secret keyword embedded inside a
larger prose word (``Secretary`` ``secret``, ``tokenizer`` ``token``,
``authored`` ``auth``) must NOT trigger the lowercase/dotted/YAML config
passes. Real key shapes (separators, camelCase, acronyms, plurals, common
concatenated compounds, all-caps env style) must keep redacting.
"""
# ── prose words embedding a keyword are preserved ──────────────────
def test_secretary_yaml_value_preserved(self):
text = "Secretary: JanetYellen1234567890"
assert redact_sensitive_text(text) == text
def test_undersecretary_preserved(self):
text = "Undersecretary: RobertSmith123456789"
assert redact_sensitive_text(text) == text
def test_tokenizer_yaml_value_preserved(self):
# HuggingFace model-card style metadata.
text = "tokenizer: cl100k_base_long_name_x"
assert redact_sensitive_text(text) == text
def test_secretariat_preserved(self):
text = "secretariat: GenevaOffice123456789"
assert redact_sensitive_text(text) == text
def test_secretary_equals_assignment_preserved(self):
text = "secretary=JohnSmith12345678901234"
assert redact_sensitive_text(text) == text
def test_dotted_secretary_preserved(self):
text = "press.secretary=KarineJeanPierre123"
assert redact_sensitive_text(text) == text
def test_bibtex_author_assignment_preserved(self):
# ``author`` embeds the ``auth`` keyword — citation keys are prose.
text = "author=Smith2020LongCitationKey1"
assert redact_sensitive_text(text) == text
def test_credentialing_preserved(self):
text = "credentialing=enabled_long_value_12345"
assert redact_sensitive_text(text) == text
# ── real key shapes still redact ────────────────────────────────────
def test_separator_keys_still_redacted(self):
for text in (
"client_secret: abc123def456ghi789jkl",
"auth_token: xyz789xyz789xyz789xyz",
"my_secret: topvalue123456789012345",
"db.password=hunter2verylongpassword",
):
result = redact_sensitive_text(text)
assert result != text, text
def test_camelcase_keys_still_redacted(self):
for text in (
"clientSecret: abc123def456ghi789jkl",
"secretKey: abc123def456ghi789jklmno",
"APIToken: abc123def456ghi789jklmn",
):
result = redact_sensitive_text(text)
assert result != text, text
def test_concatenated_compounds_still_redacted(self):
# ngrok authtoken, tailscale authkey, minio secretkey, accesstoken.
for text in (
"authtoken: 2abcdefghij0123456789_ngrok",
"authkey=tskey-auth-abcdef123456789",
"secretkey: abc123def456ghi789jklmno",
"accesstoken: abcdefghij0123456789xyz",
):
result = redact_sensitive_text(text)
assert result != text, text
def test_plural_keys_still_redacted(self):
text = "secrets: hunter2hunter2hunter2hh"
result = redact_sensitive_text(text)
assert "hunter2hunter2hunter2hh" not in result
def test_digit_boundary_still_redacted(self):
text = "oauth2_token: abcdefghij0123456789"
result = redact_sensitive_text(text)
assert "abcdefghij0123456789" not in result
def test_all_caps_embedded_keyword_still_redacted(self):
# All-caps keys keep legacy embedded matching (MYTOKEN=…).
text = "MYTOKEN=abcdefgh1234567890123456"
result = redact_sensitive_text(text)
assert "abcdefgh1234567890123456" not in result