fix(dashboard): warn when redaction rewrites a credential in chat text - #8109
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Real harm, right half fixed: warns instead of weakening a persisted-egress redaction, with tag ownership moved to the emitter and channel gaps explicitly filed (#8123). [DESIGN-REVIEWED] ae91e2e |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsNo findings. The sole candidate requires the assistant's own output to contain the literal string [OPUS-REVIEWED] ae91e2e Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of I have what I need. Verified: the notice mechanism reuses the existing First-Principles-Verdict: CONCERNS The dashboard notice earns its place against issue #6189, but it is one surface out of at least four that silently rewrite chat text. What this change shipsIntent: stop a user from unknowingly copying a chat command whose credential was silently replaced — a FIX (of the silence, deliberately not the substitution).
Watch
[FIRST-PRINCIPLES-REVIEWED] ae91e2e |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
6451c25 to
ba71885
Compare
Credential redaction rewrote a scheme://user:pass@host connection string to [REDACTED: credential] in the assistant text the user copies, and said nothing about it. The user pasted a command that could not run and met an opaque downstream error far from the real cause. Keep redacting. The chat bubble is the egress the redactor guards, so the only way to leave the command intact is to hand the credential to the browser and persist it in the transcript -- that trades a usability bug for a credential leak. Add the missing signal instead: a segment whose persisted text carries redaction placeholders now gets a notice row stating that a credential was replaced and that the command will not run as pasted. The count is read from the tag in the persisted text rather than from the cred_warnings list, which is empty here on the streaming path: the run loop redacts each chunk before it reaches the accumulator, so the flush-time call re-redacts already-clean text and reports nothing. Reading the artifact also keeps the notice correct for all three sites that write the tag (per-chunk, the StreamRedactor wire pass, and the flush). The count sums every tag the redactor can emit, not just the plaintext one: pass 2 replaces a base64-encoded credential with a distinct [REDACTED: encoded credential] tag that is not a substring of the plaintext tag, so counting one tag left an encoded-credential-only segment silently rewritten - the same defect, reached by another pass. security.py owns that list as CREDENTIAL_REDACTION_TAGS, beside the passes that write the tags, rather than each caller enumerating them. Enumerating at the call site is what produced the undercount in the first place, so the list lives next to the constants a new tag would be added to, and a ratchet fails if a _REDACTED_*_TAG constant is not registered in it. The encoded tag itself stays private: consumers ask the registry, so a public alias for it would have no caller. This warns; it does not make the command pasteable. That residue is deliberate and is why this is Refs rather than Closes. Refs #6189
ba71885 to
ae91e2e
Compare
The exfiltration-URL redactor rewrites a URL in the pasteable assistant chat body to [REDACTED: suspicious URL to <domain>] and reported it only to the server log, so a user copying a command out of chat got silently altered text. Same root cause as #6189 (fixed for credentials in #8109), different rewriter. Export the URL tag's stable prefix as EXFILTRATION_REDACTION_TAG_PREFIX in security.py and build the substitution from it; prefix-count it in _flush_segment beside the credential-tag sum; extend _redaction_notice to word the notice by kind, because the remedies differ (re-enter the secret vs re-check the URL). The credential-only wording is byte- identical to #8109's. The redaction itself is unchanged. Fixes #8132
The exfiltration-URL redactor rewrites a URL in the pasteable assistant chat body to [REDACTED: suspicious URL to <domain>] and reported it only to the server log, so a user copying a command out of chat got silently altered text. Same root cause as #6189 (fixed for credentials in #8109), different rewriter. Export the URL tag's stable prefix as EXFILTRATION_REDACTION_TAG_PREFIX in security.py and build the substitution from it; prefix-count it in _flush_segment beside the credential-tag sum; extend _redaction_notice to word the notice by kind, because the remedies differ (re-enter the secret vs re-check the URL). The credential-only wording is byte- identical to #8109's. The redaction itself is unchanged. Fixes #8132
The exfiltration-URL redactor rewrites a URL in the pasteable assistant chat body to [REDACTED: suspicious URL to <domain>] and reported it only to the server log, so a user copying a command out of chat got silently altered text. Same root cause as #6189 (fixed for credentials in #8109), different rewriter. Export the URL tag's stable prefix as EXFILTRATION_REDACTION_TAG_PREFIX in the security package (exfil.py, where the rewriter now lives after the #9183 package split) and build the substitution from it; register it on the facade and in the frozen export manifest. Count it in _append_redaction_notice beside the credential-tag sum -- main hoisted the notice into that shared helper (#8311), so every persist site that carries the credential notice now carries the URL notice too. Extend _redaction_notice to word the notice by kind, because the remedies differ (re-enter the secret vs re-check the URL). The credential-only wording is byte-identical to #8109's. The redaction itself is unchanged. Original change by dwu96; rebased over the security-package split (#9183) and the notice-helper hoist by Kiro Crew. Fixes #8132 Co-authored-by: Kiro Crew <noreply@kirodotdev.github.io>
The exfiltration-URL redactor rewrites a URL in the pasteable assistant chat body to [REDACTED: suspicious URL to <domain>] and reported it only to the server log, so a user copying a command out of chat got silently altered text. Same root cause as #6189 (fixed for credentials in #8109), different rewriter. Export the URL tag's stable prefix as EXFILTRATION_REDACTION_TAG_PREFIX in the security package (exfil.py, where the rewriter now lives after the #9183 package split) and build the substitution from it; register it on the facade and in the frozen export manifest. Count it in _append_redaction_notice beside the credential-tag sum -- main hoisted the notice into that shared helper (#8311), so every persist site that carries the credential notice now carries the URL notice too. Extend _redaction_notice to word the notice by kind, because the remedies differ (re-enter the secret vs re-check the URL). The credential-only wording is byte-identical to #8109's. The redaction itself is unchanged. Original change by dwu96; rebased over the security-package split (#9183) and the notice-helper hoist by Kiro Crew. Fixes #8132 Co-authored-by: Kiro Crew <noreply@kirodotdev.github.io>
The exfiltration-URL redactor rewrites a URL in the pasteable assistant chat body to [REDACTED: suspicious URL to <domain>] and reported it only to the server log, so a user copying a command out of chat got silently altered text. Same root cause as #6189 (fixed for credentials in #8109), different rewriter. Export the URL tag's stable prefix as EXFILTRATION_REDACTION_TAG_PREFIX in the security package (exfil.py, where the rewriter now lives after the #9183 package split) and build the substitution from it; register it on the facade and in the frozen export manifest. Count it in _append_redaction_notice beside the credential-tag sum -- main hoisted the notice into that shared helper (#8311), so every persist site that carries the credential notice now carries the URL notice too. Extend _redaction_notice to word the notice by kind, because the remedies differ (re-enter the secret vs re-check the URL). The credential-only wording is byte-identical to #8109's. The redaction itself is unchanged. Original change by dwu96; rebased over the security-package split (#9183) and the notice-helper hoist by Kiro Crew. Fixes #8132 Co-authored-by: Kiro Crew <noreply@kirodotdev.github.io>
The exfiltration-URL redactor rewrites a URL in the pasteable assistant chat body to [REDACTED: suspicious URL to <domain>] and reported it only to the server log, so a user copying a command out of chat got silently altered text. Same root cause as #6189 (fixed for credentials in #8109), different rewriter. Export the URL tag's stable prefix as EXFILTRATION_REDACTION_TAG_PREFIX in the security package (exfil.py, where the rewriter now lives after the #9183 package split) and build the substitution from it; register it on the facade and in the frozen export manifest. Count it in _append_redaction_notice beside the credential-tag sum -- main hoisted the notice into that shared helper (#8311), so every persist site that carries the credential notice now carries the URL notice too. Extend _redaction_notice to word the notice by kind, because the remedies differ (re-enter the secret vs re-check the URL). The credential-only wording is byte-identical to #8109's. The redaction itself is unchanged. Original change by dwu96; rebased over the security-package split (#9183) and the notice-helper hoist by Kiro Crew. Fixes #8132 Co-authored-by: dwu96 <dwu96@users.noreply.github.com> Co-authored-by: Kiro Crew <noreply@kirodotdev.github.io>
1. What is the problem?
Credential redaction rewrites a
scheme://user:pass@hostconnection string to[REDACTED: credential]in the assistant text the user copies out of thedashboard chat, and tells the user nothing about it.
Reproduced byte-identical to the report, by calling the shipped redactor on the
reporter's exact string:
The matching branch is one alternative inside
_CREDENTIAL_PATTERNS(
security.py:11527-11537):(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|...)://[^\s:/@]*:[^\s/]+@.The password class
[^\s/]+is greedy and excludes/but allows@, so thematch ends at the final
@of the authority and stops before the host. That iswhy
host:5432/dbsurvives and the result is a syntactically plausible butbroken URL rather than an obviously empty one.
Two things are wrong, and only one of them is the substitution:
The second is what turns a visible redaction into a debugging problem. Every
call site on this path discards the signal it already has.
redact_credentialsreturns
(text, warnings), and the chat path spells itsafe_chunk, _ = redact_credentials(safe_chunk)(chat_runner.py:6603)._flush_segmentdoes bind the list, then routes it only tologger.warning(chat_runner.py:3036-3038). Slack is identical(
slack/handler.py:3824-3828). No surface has ever told a chat user that theirmessage was rewritten.
2. Why this issue matters to the user
The user copies a command the assistant wrote, runs it, and it silently writes a
broken value. The failure then surfaces far from its cause: the reporter got
getaddrinfo EAI_AGAINout of a container at boot, because the.envline was74 chars instead of ~130 and had no
@. Nothing in that error points back atthe chat message, so the time goes on debugging DNS and Prisma instead.
It reproduces 100% of the time whenever the assistant emits a full connection
string, and the assistant emits one whenever it is asked to write a
DATABASE_URL.3. How our fix solves it
The redactor is not weakened, and that is a deliberate choice, not an
omission. The chat bubble IS the egress this control guards: the same text is
broadcast to WS/SSE clients (
chat_runner.py:6631) and persisted into thetranscript (
chat_runner.py:3044). The issue suggests keeping the valuecopyable by redacting only at the display layer; on this path that means
transmitting the real credential to the browser and storing it in the session
transcript. Today the raw string exists only as the in-flight chunk and is never
persisted. Making the command paste-able means undoing that. That trades a
usability bug for a credential leak, so this PR does not do it.
So this PR fixes the half that can be fixed without that trade: the silence. A
segment whose persisted text carries redaction placeholders now appends a
noticerow stating that a credential was replaced and, explicitly, that thecommand will not run as pasted:
That second clause is the point. A notice that only said "a credential was
removed" would still leave the user pasting text that cannot run.
Two implementation notes, both load-bearing:
The count is read from the tag in the persisted text, not from
cred_warnings. On the streaming path that list is essentially always emptyat the flush: the run loop redacts every chunk before it enters
assistant_text(
chat_runner.py:6602-6605), so the flush-time call re-redacts already-cleantext and reports nothing.
cred_warningsonly fires for a credential splitacross chunk boundaries. Counting the tag also makes the notice correct
regardless of WHICH of the three sites did the substitution - per-chunk, the
StreamRedactorwire pass, or the flush - because all three write the sameREDACTED_CREDENTIAL_TAG. Hooking the event would have required hooking allthree; reading the artifact hooks none, and answers the question the user
actually has: is what I am about to copy still what the assistant wrote?
The notice broadcasts even under
quiet_persist. That flag exists tosuppress a duplicate of pre-steer assistant text clients already rendered; a
notice row has no streamed counterpart to duplicate, and suppressing it would
drop the warning on one of the paths this fix exists to cover.
The count sums every tag the redactor can emit, and
security.pyowns thelist. This was a real gap in the first revision, found by the Opus lane. Pass 2
replaces a base64-encoded credential with
[REDACTED: encoded credential], whichis NOT a substring of the plaintext tag, so counting one tag left an
encoded-credential-only segment silently rewritten - the same #6189 failure
reached through a different pass - and undercounted a mixed segment.
The first fix for that enumerated both tags at the call site, which the First
Principles lane correctly called out as the very mechanism that produced the
undercount: a third tag added in
security.pywould escape the notice again. Sothe list is now
CREDENTIAL_REDACTION_TAGS, owned bysecurity.pybeside thepasses that write the tags, and
chat_runnerasks for it instead of naming tagsitself - a net subtraction, since the two-constant import and the local tuple both
go away.
test_every_redaction_tag_constant_is_registeredfails if a_REDACTED_*_TAGconstant is not registered in it, so the drift cannot recursilently rather than merely being relocated. A second test pins the non-overlap
invariant that makes summing per-tag counts safe.
The encoded tag stays PRIVATE. An earlier revision gave it a public alias mirroring
REDACTED_CREDENTIAL_TAG, but once callers ask the registry that alias had zeronon-test consumers, so it was unjustified API surface and the First Principles lane
was right to ask for it back. Tests import the private constant, which
test_credential_prefilter.pyalready does for_REDACTED_CREDENTIAL_TAG.No frontend change is needed: the
noticerole already renders as aNoticeCard(website/src/pages/ChatPage.tsx:6856).4. What tests we did
Three new tests in
TestFlushSegment, plus two added after the Opus finding, plusone existing test corrected.
New:
test_a_redacted_connection_string_warns_the_user- the reporter's exactcommand; asserts the assistant row still has the credential removed (redaction
intact), that a notice follows it, that the notice says the command will not
work as pasted, and that the notice itself carries no secret.
test_the_notice_counts_multiple_credentials- two connection strings report"2 credentials".
test_a_clean_segment_gets_no_notice- no placeholders, no notice row.test_an_encoded_credential_also_warns- the issue's own connection string,base64-encoded so only pass 2 fires; asserts the encoded tag is present, the
plaintext tag is not, and a notice is still raised.
test_the_two_credential_tags_do_not_overlap- pins the invariant that makessumming per-tag counts safe.
test_every_redaction_tag_constant_is_registered(intest_security.py) - thedrift ratchet: a
_REDACTED_*_TAGconstant that is not inCREDENTIAL_REDACTION_TAGSfails here, naming the offender.test_pass_two_emits_a_registered_tag(intest_security.py) - the encoded passsubstitutes a tag consumers actually look for.
Corrected:
test_credentials_in_the_segment_are_redactedasserted onslot.messages[-1], which is now the notice row. Left alone it would have keptpassing while no longer checking the assistant text at all - a security test
passing vacuously. It now selects the assistant row by role.
Mutation-verified against the revision this body is published with, each mutation
applied to the committed tree and reverted with
git checkout(neverstash).Keyed by test NAME, not position, and every red reported with the error TYPE it
arrived as - because a red that arrives as an EXCEPTION proves nothing about the
predicate: it shows the machinery stopped running, not that the value changed. Each
mutation below changes what the count ANSWERS while leaving every name bound.
count forced to
0(notice never appends) -> 3 red, all assertions:test_a_redacted_connection_string_warns_the_user-AssertionError: assert ['assistant'] == ['assistant', 'notice']test_the_notice_counts_multiple_credentials-AssertionError: expected exactly one notice row, got []test_an_encoded_credential_also_warns-AssertionError: unexpected rows: ['assistant']The
['assistant']in those messages is load-bearing: it witnesses that_flush_segmentran to completion and only the notice was absent.zero-guard dropped (
if True:) -> 4 red. Three are assertions on exact rolelists (
test_a_clean_segment_gets_no_notice,test_trailing_stop_event_is_replaced_below_the_segment,test_unparseable_cls_is_not_a_stop_event) - an extra row genuinely changesthem. The fourth,
test_pending_variants_are_attached_and_broadcast, fails withKeyError: 'variant_idx'because it readsslot.messages[-1], which the noticedisplaces. That red is EXCLUDED from the evidence: it arrived as an exception, so
it says nothing about the predicate. It is also not a production defect -
_flush_segmentbindslast_msgbefore appending the notice, and the load-pathanalogue
_attach_variantsruns immediately after its ownappend, so itsmessages[-1]is always its own row.plurality hardcoded singular -> 1 red, assertion:
test_the_notice_counts_multiple_credentials-AssertionError: assert '2 credentials' in 'Security notice: A credential in...'tag set narrowed to
CREDENTIAL_REDACTION_TAGS[:1]-> 1 red, assertion:test_an_encoded_credential_also_warns-AssertionError: unexpected rows: ['assistant']an unregistered
_REDACTED_MUTANT5_TAGadded tosecurity.py-> 1 red,assertion:
test_every_redaction_tag_constant_is_registerednames the offender.No test in this file locates a row with a bare
next(...), deliberately:nextover a generator raises
StopIterationwhen the row is missing, and that raisecannot distinguish "no notice was appended" from "the flush threw before appending
one". Every lookup is a list plus an assertion that prints what WAS there, so no red
here can arrive as an exception.
Harness provenance, so a reviewer can tell which tree these numbers came from
without re-running anything. pytest resolves
rootdirto this worktree and readsits
setup.cfg, whose[tool:pytest]setspythonpath = src; that PREPENDS tosys.path, so every number above was measured againstkirocrew-fix-6189/src/kiro_crew, not against the main checkout. The worktree'sown venv agrees independently - its editable install resolves
kiro_crewto thesame path.
This matters here specifically because
mainalready performs the redaction thesetests assert, so a run against the wrong module would show the baseline rows
passing and only the new rows failing - indistinguishable from a genuinely weak
test. Two things rule that out: the provenance above, and the fact that
_redaction_noticeandCREDENTIAL_REDACTION_TAGSdo not exist onmainat all,so the notice tests could not have passed against it under any conditions.
Suites run (each file its own invocation,
-p no:randomly -n0):test_chat_runner_coverage.py272 passed;test_security.py1265 passed, 1skipped;
test_dashboard_chat.py735 passed;test_post_compaction_continuation.py55 passed (this one asserts on the
_flush_segmentcall-site source text, andstays green because this change adds no call-site argument);
test_slot_chunk_retention.py16,test_chat_steer.py19,test_inline_tool_cards_props.py7. isort, flake8 and mypy clean on all fourchanged files, and the project's own
scripts/check_black_formatting.pygatepasses -
security.pyis in the black baseline on main, so it is deliberately NOTreformatted here; the diff to it is additive.
5. Any other suggestions on the work
This warns; it does not make the command paste-able. That residue is
deliberate, so the trailer is
Refs, notCloses. Of the four fixes the issueproposes, only "warn explicitly" is implementable without changing the security
posture. The other three - skip redaction inside execution-intended fences,
display-layer-only redaction with the clipboard intact, and distinguishing an
assistant-GENERATED value from an echoed user secret - all require either
shipping the credential to the client or adding provenance the redactor does not
have. Those remain a maintainer call, which is what the earlier
needs-humanrouting was right about; this PR is the part that did not need that decision.
The reporter also asked for the copy action itself to be blocked. Not done here:
that is frontend behaviour on a control this diff does not touch, and the notice
is the backend half it would need either way.
Two adjacent gaps found while tracing this, deliberately NOT fixed to keep the
diff narrow:
exact silence this PR ends for the dashboard, and they are filed as
#8123: Slack final text
(
slack/handler.py:3826), Slack thinking (slack/handler.py:3961), iMessage(
imessage/renderer.py:56), and the shared messaging scrubbers(
messaging/renderer.py:230,messaging/driver.py:266). The First Principleslane asked for this deferral to have an owner, and it was right to - the sharpest
case is
slack/handler.py:3935, where the branchif _stream_had_redaction or _render_redacted or exfil_warnings or cred_warnings:already reads the signal and rewrites the posted message because of it, and still
tells the user nothing. Each surface needs its own delivery (the
noticerow is adashboard mechanism), which is what makes the general fix larger than this diff
rather than a line of it.
(
exfil_warningslogged only,chat_runner.py:3034-3035). It rewrites text theuser copies just as credential redaction does. Not filed separately: it is the
same structural point as Credential redaction silently rewrites pasteable text on Slack and iMessage #8123 and worth deciding once, rather than opening a
second issue that the same change would close.
Checked against the sibling issue #8042 / PR #8055:
no overlap. That PR lives entirely in
dashboard/handlers/files.pyplustest/test_project_tree.pywith zero lines insecurity.py, and it concernspass 3 (the bare-secret run amplification) whereas this is pass 1
(
_CREDENTIAL_PATTERNS). This PR's onlysecurity.pychange is additive andnowhere near that: a new tag constant beside the existing
REDACTED_CREDENTIAL_TAGalias, and pass 2's inline literal swapped for it (byte-identical value). No
regex, no matching behaviour, and no line either issue's mechanism depends on is
touched.
Pattern harvest
Rule candidate: semgrep (or a targeted lint)
Pattern: a redactor's warnings channel discarded at a user-facing surface
redact_credentialsreturns(text, warnings). That warnings list is thesubsystem's only way to say it acted, and on this path every caller threw it
away:
safe_chunk, _ = redact_credentials(safe_chunk)at the chunk site(
chat_runner.py:6603), and alogger.warning-only consumer at the flush(
chat_runner.py:3036-3038). The effect is a security control that silentlyrewrites text the user is about to copy.
Not a one-off: the same discard shape appears at roughly 550 call sites in the
tree, and two are the adjacent gaps named above - exfiltration-URL redaction on
this very path, and the Slack final-text path. The bug is structural, not local.
The generalizable rule: in a module that renders to a human surface, binding a
redactor's warnings element to
_, or consuming it only through a logger, shouldbe flagged, because it turns a visible mutation into a silent one.
The honest caveat is scope, and it is why this PR does not ship that lint. Most
of those ~550 sites redact log lines, tool titles and internal metadata, where a
user-facing notice would be pure noise. A useful rule has to key on "this string
is rendered to a person", which the call site alone does not say. So this PR
fixes the one path where that is provably true and leaves the rule as a
candidate rather than asserting a tree-wide invariant it cannot yet enforce.
Refs #6189