fix(dashboard): de-duplicate redacted path listings so a collision can't crash the tree (#7671) - #7678
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Crash traced to its real mechanism (many-to-one redaction meets a uniqueness-assuming consumer), fixed at the producing boundary with a lane-preserving key, plus frontend backstop. Suggestions
[DESIGN-REVIEWED] 088b668 |
UX Review (Fable 5) — ✅ PASSUX-level review of UX-Verdict: PASS Crash-to-degraded fix with no new UI surface: a route that previously died on redaction collisions now renders, and the collapsed-row tradeoff is deliberate and rare. [UX-REVIEWED] 088b668 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsNo findings. The sole candidate — GitPanel's React key [OPUS-REVIEWED] 088b668 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. Producing the review. First-Principles-Verdict: CONCERNS The crash fix lands at the cause; the frontend What this change shipsIntent: stop a redaction collision from crashing the dashboard file-tree route (#7671) — a FIX.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 088b668 |
bolichen97
left a comment
There was a problem hiding this comment.
Description ↔ code mismatch: the dedup key collapses dual-status files, which the description does not disclose.
What the description claims. "api_project_git_status: rework the redaction loop to keep a seen_paths set, dropping later entries whose redacted path duplicates an earlier one. repoRoot/branch redaction and the files[:500] cap semantics unchanged." The whole change is scoped as the "Same collision class as api_project_tree" — i.e. a redaction-collision fix.
What the code does. The new guard keys on the path alone:
if f["path"] in seen_paths:
continueBut _run() already emits two entries with the same path and different staged for a file that is both staged and modified — src/kiro_crew/dashboard/handlers/files.py:4727-4731 on main sets staged=True when x not in (" ", "?", "!") and staged=False when y not in (" ", "?", "!"). Those two path values are byte-identical, so the second (unstaged) row is dropped. This fires on ordinary input, with or without any redaction collision.
Why it is user-visible. website/src/components/GitPanel.tsx:144-146 maps over status.files and renders one row per entry with key={${f.path}:${f.staged}} — it deliberately keys on .staged in order to show the staged and unstaged rows for the same file as two distinct rows. It also derives fileCount from status.files.length (line 85). So after this change, any staged-and-then-modified file loses its unstaged row in GitPanel and fileCount under-reports.
(For contrast, the Pierre changed panel is unaffected: PierreWorkspaceTreeImpl.tsx:203 already collapses per path, so nothing regresses there. GitPanel is the consumer that relies on both rows.)
Required fix — either one:
- Key the dedup on the pair, so only genuine redaction collisions collapse:
if (f["path"], f.get("staged")) in seen_paths: continue; or - Keep the path-only key and disclose it in the description — state that dual-status files are collapsed to one row and that GitPanel's row list and
fileCountchange accordingly.
Option 1 preserves the stated scope ("git-status semantics unchanged") and is what the description already promises.
Related: Opus 4.8 Review flagged this same files.py dedup key as an advisory, but scoped it to the handler without tracing the GitPanel consumer, so it did not escalate.
Posted as a comment rather than a change request because the reviewer account is the PR author; GitHub does not allow REQUEST_CHANGES on one's own PR. Treat it as blocking.
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/dashboard/handlers/files.py:4802 -- [GPT-REVIEWED] 088b668 False positive or not applicable? A repository writer can comment: |
Egress redact() collapses genuinely-different paths (e.g. distinct credential-shaped filename tokens) to the same placeholder, producing duplicate entries. The dashboard tree hands these straight to @pierre/trees, whose appendPresortedPaths throws 'Duplicate path' on adjacent identical rows and crashes the workspace panel. De-duplicate after redaction in both api_project_tree and api_project_git_status, preserving order and first occurrence. Does not change 'truncated' or the files[:500] cap (both applied to the raw listing before redaction). Adds backend regression tests exercising a real ls-files/status collision. git-status keys the de-dup on (path, status, staged) rather than path alone: one file with both staged and unstaged changes legitimately emits two entries sharing a path, and GitPanel renders them as separate rows keyed on path+staged. A real redaction collision has an identical tuple, so it still collapses. Also de-dup the full-workspace branch of the frontend paths memo (mirroring the changed branch's existing statusEntries seen-Set), since the appendPresortedPaths throw fires from inside the resetPaths useLayoutEffect and takes down the whole workspace route uncaught.
b0a9f28 to
088b668
Compare
iamwhatever
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (5 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: fix with a clear root cause -- redact() can collapse two genuinely-different paths onto one placeholder and @pierre/trees throws on adjacent duplicates, crashing the tree; the fix de-duplicates after redaction, keying on (path, status, staged) so a file's staged and unstaged lanes both survive (#7671).
chenmingwei23
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (5 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: fix, de-duplicates already-redacted path listings so a redaction collision cannot crash the @pierre/trees panel (consumes redact() output; no change to redaction/secret handling, no new parsing/auth).
… survive (kirodotdev#8042) redact()'s bare-secret matcher has '/' in its payload class (base64 alphabet), so a deep slash-only path chains into ONE >=40-char candidate run; the whole-run amplification in redact_credentials then replaces the entire path with a single placeholder. Two genuinely different files collapse to a byte-identical key, and the kirodotdev#7678 de-dup then silently drops the later entries from the workspace tree. The listing endpoints (api_project_tree, api_project_git_status) KNOW their values are project-relative POSIX paths, so a separator is a hard boundary there: _redact_path_display() redacts per segment on the slow path (fast path: unchanged values return after one redact() call). A genuine >=40-char secret as a single segment still redacts; the general redact() behaviour for unknown-provenance text is untouched.
… survive (kirodotdev#8042) redact()'s bare-secret matcher has '/' in its payload class (base64 alphabet), so a deep slash-only path chains into ONE >=40-char candidate run; the whole-run amplification in redact_credentials then replaces the entire path with a single placeholder. Two genuinely different files collapse to a byte-identical key, and the kirodotdev#7678 de-dup then silently drops the later entries from the workspace tree. The listing endpoints (api_project_tree, api_project_git_status) KNOW their values are project-relative POSIX paths, so a separator is a hard boundary there: _redact_path_display() redacts per segment on the slow path (fast path: unchanged values return after one redact() call). A genuine >=40-char secret as a single segment still redacts; the general redact() behaviour for unknown-provenance text is untouched.
… survive (kirodotdev#8042) redact()'s bare-secret matcher has '/' in its payload class (base64 alphabet), so a deep slash-only path chains into ONE >=40-char candidate run; the whole-run amplification in redact_credentials then replaces the entire path with a single placeholder. Two genuinely different files collapse to a byte-identical key, and the kirodotdev#7678 de-dup then silently drops the later entries from the workspace tree. The listing endpoints (api_project_tree, api_project_git_status) KNOW their values are project-relative POSIX paths, so a separator is a hard boundary there: _redact_path_display() redacts per segment on the slow path (fast path: unchanged values return after one redact() call). A genuine >=40-char secret as a single segment still redacts; the general redact() behaviour for unknown-provenance text is untouched.
… survive (kirodotdev#8042) redact()'s bare-secret matcher has '/' in its payload class (base64 alphabet), so a deep slash-only path chains into ONE >=40-char candidate run; the whole-run amplification in redact_credentials then replaces the entire path with a single placeholder. Two genuinely different files collapse to a byte-identical key, and the kirodotdev#7678 de-dup then silently drops the later entries from the workspace tree. The listing endpoints (api_project_tree, api_project_git_status) KNOW their values are project-relative POSIX paths, so a separator is a hard boundary there: _redact_path_display() redacts per segment on the slow path (fast path: unchanged values return after one redact() call). A genuine >=40-char secret as a single segment still redacts; the general redact() behaviour for unknown-provenance text is untouched.
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. |
Fixes #7671.
Root cause
Egress
redact()is a many-to-one map, so two genuinely different repo paths whose only differing segments are redacted collapse to the same placeholder string (e.g. both asrc/...andtarget/...Maven path flatten to[Manually redacted][REDACTED: credential]_model.txt).GET /api/project/treehanded that list straight to the frontend, and@pierre/treesappendPresortedPathsthrew an uncaughtDuplicate pathinside auseLayoutEffect, crashing the whole route. Thechangedpanel survived the same input because itsstatusEntriesmemo already de-duplicated via aseenSet.Note: the reported filename is a placeholder string and does not exist on disk; no credential was leaked or present — the matcher was over-eager on a filename token.
Changes
Server (
src/kiro_crew/dashboard/handlers/files.py):api_project_tree: de-duplicatepathsafter redaction withlist(dict.fromkeys(...)), preserving order and first occurrence.api_project_git_status: rework the redaction loop to drop later entries that duplicate an earlier one, preserving order and first occurrence. The key is the(path, status, staged)tuple, notpathalone: one file with both staged and unstaged changes (MM,AM,MD) legitimately yields two entries sharing a path, andGitPanelrenders them as separate rows keyed${path}:${staged}. A real redaction collision has an identical tuple, so it still collapses.repoRoot/branchredaction and thefiles[:500]cap semantics unchanged.Frontend (
website/src/pierre/PierreWorkspaceTreeImpl.tsx):pathsmemo now wraps its list inArray.from(new Set(...)), mirroring thechangedbranch, so any duplicate from a future source degrades to a missing row instead of a render crash. Other branches/memos untouched.Tests
test/test_project_tree.pyandtest/test_project_git_status_log.py: regression tests using a real collision (two distinctAKIA…_model.txttokens that both redact to[REDACTED: credential]_model.txt), asserting the placeholder appears exactly once, no duplicate paths, and raw tokens never leak.test/test_project_git_status_log.pyalso pins that a file staged and then modified again keeps both lanes (("M", True)and("M", False)), so the de-dup cannot regress into dropping the unstaged row.website/src/test/PierreWorkspaceTreeImpl.test.tsx: assertsresetPathsreceives the de-duplicated list.Verification
Run locally against a CI-parity environment on the rebased branch:
pytest test/test_project_tree.py test/test_project_git_status_log.py test/test_dashboard_files_coverage.py test/test_dashboard_file_io.py test/test_core_path_redact_before_bound.py— 167 passed.isort --check-only,flake8 src/kiro_crew test,mypy src/kiro_crew/,scripts/check_black_formatting.py— all clean.npx tsc -b,npx eslint src/ --max-warnings 603(601 measured),npm run i18n:check,npm run lint:phantom-classes— all clean.npx vitest run src/test/PierreWorkspaceTreeImpl.test.tsx— 42 passed.Known non-blocking notes
files[:500]/ tree entry caps are computed on the raw listing before redaction and de-dup, so a response can return fewer than the cap while still flaggedtruncated(intentional, documented). Moving the cap after redaction would mean redacting the entire listing on the hot path.key_a/key_b, withkey_bkept as a split literal so Semgrep'sdetected-aws-access-key-id-valuerule does not read a synthetic fixture as a leaked key. Two distinct ids are required for the test to prove de-duplication at all.Pattern harvest
Rule candidate: review-prompt
Pattern: the output of a many-to-one map — an egress
redact()— handed to aconsumer that requires unique keys, where
appendPresortedPathsthrowsDuplicate path. Review prompt: wherever a redacted, normalized or truncatedstring list crosses an API boundary, ask whether the consumer assumes injectivity,
and de-duplicate on the producing side.