Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 16 additions & 9 deletions agent/redact.py
Original file line number Diff line number Diff line change
Expand Up @@ -507,22 +507,29 @@ def _mask_control_split_tokens(text: str, mask_fn) -> str:
body = m.group(1)
start_orig = orig_idx[m.start(1)]
end_orig = orig_idx[m.end(1) - 1] + 1
# If any fragment inside the original span already matches _PREFIX_RE
# on its own, the ordinary prefix pass will mask it — do NOT join.
# Joining here would swallow adjacent legitimate text: a complete
# token at end-of-line followed by a word line (``ghp_<token>\n
# button [ref=e3]``) joins into one stripped-copy match and the
# mask eats ``button``. Join only when fragments alone are too
# short/broken to match (the actual smuggling shape).
if _PREFIX_RE.search(text[start_orig:end_orig]):
# If a fragment inside the span already matches _PREFIX_RE on its
# own AND the span crosses a LINE boundary (\n / \r), do NOT join.
# A complete token at end-of-line followed by a word line
# (``ghp_<token>\nbutton [ref=e3]``) joins into one stripped-copy
# match and the mask eats ``button``. Line structure is legitimate;
# the self-matching fragment is handled by the ordinary prefix pass
# (any remainder past the newline is left unmasked — accepted
# residual to preserve line structure).
# For NON-newline controls (ESC, ZWSP, ...) the join proceeds even
# when a fragment self-matches: those bytes never legitimately sit
# between a token and adjacent prose, and skipping there let the
# non-matching remainder of a split token leak
# (``sk-<head>\x1b<tail>`` masked only the head).
span = text[start_orig:end_orig]
if ("\n" in span or "\r" in span) and _PREFIX_RE.search(span):
continue
# Reject matches whose original span crosses a non-token char
# (e.g. ``sk_abc…\nTAVILY_API_KEY=…`` — the ``=`` is not part of a
# token body, so the regex matched across unrelated lines). Also
# reject when the match runs into a ``KEY=`` name: a real token value
# is followed by a newline/space/end, not ``=``.
if (all(c in _TOKEN_BODY_CHARS or _CONTROL_CHARS_RE.match(c)
for c in text[start_orig:end_orig])
for c in span)
and (end_orig >= len(text) or text[end_orig] != "=")):
matches.append((start_orig, end_orig, mask_fn(body)))
for start_orig, end_orig, replacement in reversed(matches):
Expand Down
11 changes: 11 additions & 0 deletions tests/agent/test_redact.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,17 @@ def test_complete_token_does_not_swallow_next_line(self):
assert "button" in result
assert "ref=e3" in result

def test_selfmatching_head_esc_split_tail_masked(self):
# A split where the HEAD fragment alone already matches _PREFIX_RE
# (>= 10 body chars) but the tail doesn't: the join must still run
# for non-newline controls, or the tail leaks in cleartext. Only
# LINE-crossing spans skip the join (see the annotation test).
head = "sk-" + "a" * 15
tail = "b" * 25
result = redact_sensitive_text(head + "\x1b" + tail, force=True)
assert tail not in result
assert "a" * 12 not in result

def test_env_dump_lines_not_joined(self):
# Control-stripping must not join unrelated env lines into one match
env_dump = (
Expand Down
Loading