fix(messaging): warn when credential redaction rewrites pasteable text - #8136
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Real silent-corruption harm, fixed at the right seam: one shared sentence, per-site best-effort delivery, scope gaps stated honestly with a follow-up path. Suggestions
[DESIGN-REVIEWED] 2bafe3e |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsFINDING -- src/kiro_crew/slack/handler.py:4011 -- False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All counts verified. The one substantive finding: the notice sentence now lives in two places — the PR's new shared builder and the dashboard's pre-existing private First-Principles-Verdict: CONCERNS "The sentence lives once" is false: What this change shipsIntent: tell a Slack/iMessage reader when credential redaction rewrote a command they may paste (issue #6189 / #8123). FIX — the signal half of a reported defect, redaction unchanged.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 2bafe3e |
Credential redaction rewrites outbound assistant text on Slack and iMessage without telling the reader, so a command the assistant wrote is silently altered and will not run when pasted -- a scheme://user:pass@host connection string arrives as [REDACTED: credential]. Each site computed a warning list and dropped it into a log the reader never sees. Redaction is not relaxed: a channel is an egress path, so the credential must stay out of it. The missing half is the signal. Slack posts one threaded notice per turn after the answer is committed, covering the answer and the thinking block on a single tally; approving a review-mode draft posts the same notice, because that publishes to the whole channel; iMessage sends it as an additional follow-up message, since a sent iMessage cannot be edited. The count is read from the placeholder in the text that actually shipped rather than from the redactor's warnings list, which is empty on the streaming path: each chunk is redacted upstream, so re-redacting the assembled text reports nothing while the placeholders are plainly visible. It sums every tag in CREDENTIAL_REDACTION_TAGS so an encoded-credential-only reply is not missed. Every notice post is best-effort -- a failed notice must never turn a delivered answer into a failed turn. The sentence itself lives once, in messaging.renderer beside display_safe: that function keeps the credential out of the channel and this one says it happened. Sharing it keeps the wording from forking into per-channel spellings that each need their own audit for leaked bytes, and it carries no markup or emoji because one string ships to platforms that render different dialects, or none.
a839e29 to
2bafe3e
Compare
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
Tells the reader when credential redaction rewrote text a channel already delivered, so they do not paste a command that cannot run. Refs #8123, Refs #6189.
Problem
Credential redaction rewrites outbound assistant text on Slack and iMessage without telling the reader. A
scheme://user:pass@hostconnection string arrives as[REDACTED: credential], so a command the assistant wrote is silently altered and fails when pasted. Each site computed a warning list and dropped it into a log the reader never sees:slack/handler.py— final response text and thinking textslack/interactions.py—_handle_review_approve, which posts an approved draft to the whole channelimessage/renderer.py—out, _ = redact_credentials(out), warnings discarded#8109 has since fixed the same root cause on the dashboard, with a notice row. Slack and iMessage need their own channel-appropriate delivery, which is why they were scoped out of it.
Approach
Redaction is NOT relaxed — a channel is an egress path, so the credential must stay out of it. The missing half is the signal, exactly as in #8109.
CREDENTIAL_REDACTION_TAGSso an encoded-credential-only reply is not undercounted.Across the channels the sentence lives once, as
messaging.renderer.credential_redaction_notice, besidedisplay_safe— that function keeps the credential out of the channel and this one says it happened. It carries only a count, never secret bytes, and no markup or emoji, because one string ships to platforms that render different dialects or none.It is not the repo's only spelling, and this description should not claim otherwise:
dashboard/chat_runner.py's private_redaction_notice, which #8109 landed while this PR was open, is the same sentence with page-specific phrasing ("before it reached this page", "any command shown above"). Unifying them is a user-visible wording change to a surface that shipped today, on a path this PR does not otherwise touch, so it is named as a follow-up below rather than folded in here.What changed since the last head
security.pyhalf is gone. fix(dashboard): warn when redaction rewrites a credential in chat text #8109 landed the same registry onmain(CREDENTIAL_REDACTION_TAGS,_REDACTED_ENCODED_CREDENTIAL_TAGkept deliberately private, pass 2 emitting the constant). Rebasing removed this PR's wholesecurity.pydiff, which resolves the First Principles blocker at the root: there is no public alias left to justify.mainkeeps that tag private on purpose, so exposing it would also have contradicted the module's own stated intent._credential_redaction_warningcopies became the one shared builder, which also removesinteractions.py's in-functionfrom kiro_crew.slack.handler import _credential_redaction_warning— a private cross-module import that GPT flagged.main's tag ratchet from_REDACTED_*_TAGto_REDACTED_*_CREDENTIAL_TAG. That loosens a bound this PR does not need, sotest_security.pyis now byte-identical tomain. The observation stands on its own:main's ratchet is broader than the tuple's documented credentials-only scope, so a future non-credential tag would be forced into it. Worth a separate look, not this PR.Scope, and what stays silent
Deliberately not widened, and no longer overclaimed in this description:
slack/transport_dispatch.pydrivesSlackRendererthrough the shared redactor inmessaging/renderer.py, which discards warnings. That is a live Slack turn path with no notice, so this PR does not close "the last silent Slack egress path".messaging/driver.py.An earlier version of this description claimed those need a send seam on the
RendererABC first. That is wrong, and this PR's own design is the counter-example: it stopped consuming the redactor's warnings list and counts placeholders in the text that actually shipped, so the iMessage fix needed no seam and neither would the others. What actually keeps them out is that each is a separate per-platform decision about where the notice belongs — edit in place, follow-up message, or thread reply — which is eight judgements, not one loop. The shared builder added here is the piece each of them consumes.Named follow-ups
dashboard/chat_runner.py's_redaction_noticeand call the shared builder at its one consumer. The dashboard's notice row also renders below the message it describes, so the shared wording holds there; the only cost is changing text that shipped today.slack/transport_dispatch.py, per the paragraph above.test_security.py, per the note above:main's_REDACTED_*_TAGprefix is broader than the tuple's documented credentials-only scope.Testing
test_credential_redaction_notice.py(new) pins the shared builder — singular/plural, no secret bytes, no channel markup — and drives the Slack path end to end throughhandle_message.test_imessage_renderer.pycovers iMessage delivery: the follow-up is sent, a clean answer sends none, no secret reaches the wire, and a failing notice does not fail a delivered turn.test_review_mode.pycovers the review-approve egress, including that redaction is not relaxed and that a failing notice does not abort approve.messaging/renderer.pyrow inmessaging.mdand a Streaming UX bullet inslack-gateway.md.Pattern harvest
The blocking finding was a new public constant with zero consumers. It did not need arguing, because the constant already existed on
main: #8109 fixed the same defect on the dashboard while this PR was open and landed the shared registry as part of it. A rebase deleted the entire contested surface. The failure mode was reaching for a shared constant without first checking whether the sibling fix for the same root cause had already introduced it upstream — which is exactly when it is most likely to exist, since both PRs need the same seam.The second half is scope hygiene: the same head also carried a ratchet-narrowing test edit and a public alias that the fix did not need. Both were pure ride-along, and both are what the reviewer spent its budget on instead of the fix.
Rule candidate: when a defect has a sibling fix in flight for another surface, rebase onto the base branch before defending any newly added shared constant or helper — the sibling probably landed it, and the finding evaporates instead of needing a rebuttal.