fix(dashboard): warn when redaction rewrites a URL in chat text - #8291
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Notice gap closed at the right seam: prefix constant owned beside the rewriter, counted in the shared helper — no drift, no new shape. Suggestions
[DESIGN-REVIEWED] 7b48fa5 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsThe diff is a small, well-contained change: it exports No findings. [OPUS-REVIEWED] 7b48fa5 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 All evidence gathered and counted. Emitting the review. First-Principles-Verdict: CONCERNS The dashboard fix is real and cause-level, but the same silent-URL-rewrite sits unfixed at three channel surfaces that already post the credential notice — and the description never says so. What this change shipsIntent: a user who copies chat text whose URL was redacted must be told, not find out at paste time — a FIX (issue #8132).
Watch
[FIRST-PRINCIPLES-REVIEWED] 7b48fa5 |
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: |
bbbf775 to
85c3432
Compare
Disposition — First Principles 🟡 CONCERNS (on
|
85c3432 to
04a78cf
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. |
|
🤖 Kiro Crew [operator: NicholasRBowers#a942f9ca]: This PR has been inactive for 7+ days with failing CI. I've assessed the blockers and they appear resolvable — I'll push fixes directly to this branch as a co-author. Assessment: All four AI review lanes are green on head If you'd prefer I don't touch this PR, add the |
04a78cf to
a2e2ea2
Compare
|
🤖 Kiro Crew [operator: NicholasRBowers#a942f9ca]: Pushed the rebase + conflict resolution as announced. Head is now What changed and why — every change, itemized:
Not changed: the redaction itself, the notice wording (credential-only string stays byte-identical to #8109's), the PR's five tests (they auto-merged and pass unmodified), and dwu96's First Principles disposition — the two adopted Watch items survive the rebase (the seven-site declaration graduated from "declared gap" to "covered via #8318's helper"). Verification on the pushed head: isort / flake8 / mypy clean; The |
a2e2ea2 to
41a884b
Compare
|
🤖 Kiro Crew [operator: NicholasRBowers#a942f9ca]: Round 1 fix pushed — head Finding: Fix: ran the pinned black on the file and amended into the single commit. Re-verified: black/isort/flake8/mypy clean, 1,188 targeted tests pass. The other lanes showing |
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>
41a884b to
7b48fa5
Compare
|
🤖 Kiro Crew [operator: NicholasRBowers#a942f9ca]: Round 2 pushed — head Why: main's #9189 ("never waive exfil redaction for model-authored URLs") landed after this PR's last rebase and rewrote the same Resolution: kept this PR's Verification on this head: rebased twice (main advanced mid-rebase); Also for the record: the |
|
🤖 Kiro Crew [operator: NicholasRBowers#a942f9ca] — Disposition: accepted-and-deferred (Watch item 1: three channel surfaces post the credential notice but stay silent on a URL rewrite)
self-added: no |
|
🤖 Kiro Crew [operator: NicholasRBowers#a942f9ca] — Disposition: accepted-and-deferred (Watch item 2:
self-added: no |
|
🤖 Kiro Crew [operator: NicholasRBowers#a942f9ca]: Review-ready at head
Remaining gate is human maintainer review. Auto-merge is deliberately NOT armed. |
bolichen97
left a comment
There was a problem hiding this comment.
Tech Lead review — approved.
Additive-only to the redaction pipeline, verified at source rather than from the description. redact_exfiltration_urls still redacts exactly what it redacted before: the substitution changed from the inline literal f"[REDACTED: suspicious URL to {domain}]" to f"{EXFILTRATION_REDACTION_TAG_PREFIX}{domain}]" with the constant defined as "[REDACTED: suspicious URL to " — byte-identical output. security/exfil.py is +14/-1 and touches neither _exfil_url_warning, _URL_RE, the heuristic tiers, _HARD_CREDENTIAL_RE, nor any carve-out. No detection path, no threshold, no exemption is altered. This adds a notice; it relaxes nothing.
The notice cannot leak what was redacted. Every branch of _redaction_notice composes from counts and fixed remedy prose. The redacted domain is never interpolated, and by construction the notice is built after the tag has already replaced the bytes. Prefix-counting is also fail-safe in the only direction it can be wrong: assistant text containing the literal prefix over-warns, never under-warns.
Scope is proportionate. 8 files, +207/-52, of which 108 lines are tests and the docs change is a single spec bullet. The behavioral surface is one prefix constant, one substitution rebuilt from it, and by-kind wording in one existing helper — no new shape, and the credential-only string stays byte-identical to #8109 under a regression guard.
i18n: no obligation added. The notice is a server-built English string rendered through #8109's pre-existing msg msg-info row; zero locale files are touched. The three channel surfaces that route through the localized credential_redaction_notice key are correctly left alone and tracked in #9313 rather than half-migrated here.
Findings: none blocking. First Principles 🟡 CONCERNS is advisory, and both Watch items are dispositioned accepted-and-deferred with a tracking issue. Design's suggestion — that a third body rewriter would make the hand-built by-kind branch matrix combinatorial — is correct and worth doing at that point, not at two kinds.
Fixes #8132
Symptom
When the dashboard finalizes an assistant chat segment,
_flush_segmentruns two independent redactors over the pasteable text. Since #8109 (merged), the credential redactor appends a user-visible notice row. The exfiltration-URL redactor, called a few lines earlier in the same function, rewrites a URL to[REDACTED: suspicious URL to <domain>]and reported that only to the server log — a user copying a command out of chat got silently altered text and found out when the paste failed. Same root cause as #6189, different rewriter.Root cause
The #8109 notice counts
CREDENTIAL_REDACTION_TAGSexactly — a deliberate, documented credentials-only scope. The URL tag interpolates the domain, so it has no constant form to equality-compare and could not join that tuple; and the #6189 notice wording ("a credential … supply the secret yourself") would name the wrong remedy for a rewritten URL. The in-placeSCOPE:comment recorded the case as tracked-separately; this PR is that fix.Fix
Mirrors #8109's own notice path — no new shape. (Originally written against the monolithic
security.py; rebased over the #9183 package split and the #8318 notice-helper hoist, so the same change now lands in the package modules and the shared helper.)security/exfil.py: export the URL tag's stable prefix asEXFILTRATION_REDACTION_TAG_PREFIXand build the substitution inredact_exfiltration_urlsfrom that constant, so the two can never drift. Docstring states why it is a prefix and not a member ofCREDENTIAL_REDACTION_TAGS; that tuple's docstring (security/redaction.py) now points at the prefix constant instead of describing the URL case as invisible to consumers. The constant is registered on the facade (security/__init__.py) and in the frozen export manifest (security/_exports.py), sofrom kiro_crew.security import EXFILTRATION_REDACTION_TAG_PREFIXresolves like every other export.chat_runner.py: prefix-count the constant in the persistedredactedtext beside the existing credential-tag sum, inside_append_redaction_notice— the shared helper fix(chat): fire redaction notice on the 7 exception-path assistant persists #8318 extracted (closing Redaction notice does not fire on the 7 exception-path assistant persists #8311), called by_flush_segmentand the seven exception/teardown persists, so the URL notice fires everywhere the credential notice does. Extend_redaction_notice(cred_count, url_count)to word the notice by kind — credentials only (byte-identical to fix(dashboard): warn when redaction rewrites a credential in chat text #8109's string, regression-guarded), URLs only (re-check the URL remedy), or both. The notice stays unconditional w.r.t.quiet_persist, per the existing rationale. Counting reads the TAG in the persisted text, notexfil_warnings— same reason as the credential path: on the streaming path chunks are redacted before they reachassistant_text, so the warning list is almost always empty here while the tag is present. Following the list would ship a notice that never fires.docs/system-specs/modules/security.md: same-commit spec update for the substitution/prefix contract and the by-kind notice, stating the helper-shared coverage (post-fix(chat): fire redaction notice on the 7 exception-path assistant persists #8318) rather than the seven-branch gap the pre-rebase body declared.Deliberately NOT changed:
redact_exfiltration_urlsitself — the redaction still happens; this is a notice, not a relaxation (the issue explicitly forbids keeping the URL pasteable). Display-string redaction call sites (titles, tool names, feed strings, stashed variants) stay notice-free, deliberately — they do not produce text the user copies commands from. The seven exception/teardown persists, declared as a shared gap in the pre-rebase body and tracked in #8311, are no longer a gap: main's #8318 routed them through_append_redaction_notice, and this PR's URL counting rides that helper — exactly the fix direction the First Principles disposition recorded. Locale ripple: none — the notice is a server-built English string appearing in zero locale files.Issue-body corrections
e4f5e13(call atchat_runner.py:3205, substitution atsecurity.py:11455, tags atsecurity.py:12579).Verification
test_chat_runner_coverage.py::TestFlushSegment(extended in place) andtest_security.py::TestRedactExfiltrationUrls.test_a_redacted_url_warns_the_user+ both-kinds test redtest_a_redacted_url_warns_the_userredsecurity.pysubstitution rebuilt from a diverged literal instead of the constant → prefix-pin test + chat-runner URL test redscripts/local-gate.py --base origin/main(backend-only diff plan: full backend + 206 frontend guard specs; the backend stage reports the environmental baseline as rc=1, so the guard stage was run explicitly with the gate's own spec selection — results above). black gate (pinned 26.3.1), isort, flake8, mypy all clean on touched files.test_chat_runner_coverage.py+test_security.py) run on the branch (1565 passed, 1 skipped, 0 failed) and on agit worktreeatorigin/main(1560 passed, 1 skipped, 0 failed); sorted failing-id sets byte-identical both directions (both empty). Full backend suite (pytest -q -n auto --dist loadgroup, testpathstest/+src/kiro_crew/apps/builtins) both directions: branch 223 failed + 2 errors / 83,887 passed; pristineorigin/mainworktree 223 failed + 3 errors / 83,883 passed — the sorted failing-id sets are identical after resolving two xdist log-interleaving artifacts (each apparent delta re-run in isolation fails identically on both trees:ops_mission_control/tests/test_slack_out.py::…::test_ampersand_is_escaped_firstand theconnections/tool_test.pycollection error). All failures are the known pre-existing environmental baseline; none involve the touched files..github/workflows/screenshot-evidence.ymlwatcheswebsite/src/{components,pages,apps}/**and this diff touches nowebsite/file; the notice renders through the pre-existingmsg msg-inforow shipped and evidenced by fix(dashboard): warn when redaction rewrites a credential in chat text #8109.Rebase re-verification (2026-09-07, rebased onto main
56f67aa43): the original verification above ran against the monolithicsecurity.py. After porting over the #9183 package split and the #8318 helper hoist: isort / flake8 / mypy clean;test/test_security.py+test/test_chat_runner_coverage.py1,178 passed 0 failed (includes all 5 tests this PR adds),test_security_facade.pyfrozen-manifest guards 10 passed; full backend suite re-run on the rebased branch — result posted in the PR thread with the push.Composition hazards checked before branching
All 37 open PRs touching
dashboard/chat_runner.pyorsecurity.pywere scanned for the spans this PR edits. None rewrites_redaction_noticeor the tag registry. Notable adjacency: #7666 rewrites the same substitution line inredact_exfiltration_urls(replace → span splice) but keeps the identical tag text — a textual conflict at most; whichever lands second rebases, and the prefix constant composes with the span-splice shape unchanged. #7626 removes nearby composition lines inchat_runner.py(adjacent-hunk conflict at most). The rest only add call sites elsewhere.Pattern harvest
Rule candidate: when a redactor substitutes a variable placeholder (interpolated domain, path, name), export its stable prefix as a module constant beside the rewriter, build the substitution from that constant, and have user-notice consumers prefix-count it — an exact-match registry silently under-reports every variable tag. Knowingly out-of-scope sibling sites: the non-pasteable
redact_exfiltration_urlscall sites inchat_runner.py(titles/tool names/feed strings) and every other module that discardsexfil_warnings; they redact display strings, not text a user copies commands from.