fix: redact full text before bounding at core-path slice sites (#7390) - #7424
Conversation
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All checks complete: the canonical helper exists ( First-Principles-Verdict: CONCERNS Solid cause-level fix of a real leak class, but the follow-up-label rewrite ships semantics the description misstates, and the sibling baseline deserves the count. What this change shipsIntent: stop credentials cut in half by a truncation bound from escaping redaction at the sites #7390 names — a FIX.
Watch
[FIRST-PRINCIPLES-REVIEWED] a331248 |
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound mechanical continuation of the established redact-then-bound fix class, but the hottest site — tool results in the ACP read loop — skipped the PR's own cost mitigation. Watch
Suggestions
[DESIGN-REVIEWED] a331248 |
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/acp/_dispatch.py:1026 -- False positive or not applicable? A repository writer can comment: |
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: |
5df27f7 to
2c400e8
Compare
2c400e8 to
79bae24
Compare
79bae24 to
a331248
Compare
|
Heads-up: this change added a 7th gate-side log site to |
…sed context The gate-side log line added by #7424 reached its text through `redact_and_truncate`, a BASELINE redactor, so a host with a companion loaded had that line scanned by the weaker OSS pass. This module's process composes -- `slack/gateway.py` calls `boot_platform` itself -- so the no-companion exemption that `redact_log_via_context` documents for `mcp_gateway.gatewayd` does not apply to it. Routed through `redact_log_via_context` and sliced afterwards, which is the contract that function states: truncating second is what keeps a credential from surviving as an unmatchable fragment. The census entry for the module therefore stays at 6 rather than being raised to 7.
…ntext redactor (#7761) `test_security_posture.py::TestGateSideLogRedactorSpelling:: test_no_new_gate_side_log_line_reads_the_baseline_redactor` is red on `main`: `slack/gateway.py` has 7 gate-side log sites reading the BASELINE redactor while `_BASELINE_LOG_SITE_CENSUS` records 6. Backend Tests (Windows) (3) therefore fails on the merge ref of every open PR. The 7th site is the heartbeat-incomplete `logger.info`, which #7424 gave `redact_and_truncate(task_text, 80)` -- a hand-rolled baseline pair plus a slice. The Slack gateway runs IN the composition process, so raising the census is the wrong branch of the test's own instruction: a companion's extra credential and cookie regexes are reachable here, and the baseline pass is a real downgrade. Routed it through `redact_log_via_context` instead, slicing AFTER redaction -- both the invariant #7424 established and the one that helper hands its callers by contract ("Callers keep their own truncation, and must apply it AFTER this returns"). The sibling two lines below stays on `redact_and_truncate` because it feeds a delivery rather than a log, and the two want different failure modes on a host that cannot compose. Census entry unchanged at 6: the count returns to what it records, so the two-way ratchet (`test_the_census_holds_no_slack`) stays satisfied too.
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. |
The shard-3 failure (test_no_new_gate_side_log_line_reads_the_baseline_redactor) was upstream drift from kirodotdev#7424, fixed on main by kirodotdev#7761. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Summary
Closes #7390.
A bounded slice applied to text BEFORE it is passed to a redactor can cut a credential at the slice boundary into fragments no redaction regex matches, so the raw fragment escapes redaction and reaches the sink (log line, error payload, external display surface). PRs #7316/#7350/#7383 fixed this class at subprocess-stderr sites; this PR applies the same mechanical reorder — feed the redactor the FULL text, bound AFTER (
security.redact_and_truncate(x, N)where both redactors apply,redact(x)[:N]otherwise) — at the core-path sites #7390 names.Per site, the current head/tail anchoring is preserved exactly: every fixed site was a head cut and stays a head cut. Head-vs-tail anchoring judgment remains #5555's scope.
Sites fixed
All sites from #7390's list, re-located by pattern on current main:
channel.py(approval-prompt tool input, 500)dashboard/chat_runner.py× 4: the cross-surface mirror echo (500), the PostToolUse hook output (2000), the model-refusal log head (600), plus a multi-line sibling the issue's single-line grep missed — the native sub-agent card task label (2000) and toolpurposemeta (200)acp/_dispatch.py(tool-result join, 8000 — see below)dashboard/handlers/artifacts.py: prefix snippet, match-context snippet lines (a multi-line same-class sibling in the same function family), comment anchor strings (2000), comment/replyauthor(256 × 2), deletereason(500)dashboard/handlers/discover.py(skill preview content, 64 KiB)mcp_tools/control.py(task label spec fallback, 80; thetask_namebranch is used whole, exactly as before)mcp_tools/spawn.py(sub-agent error labels, 60 × 2)vector_memory.py(episodic reject audit snippet, 200)knowledge/agent_fetch.py(fetch error snippet, 300)slack/gateway.py(heartbeat task label, 100; plus the adjacent unredactedtask_text[:80]log line, found in review)telegram/renderer.py(inline-keyboard button labels, 64 — bound now applied after redaction, upstream label size is schema-capped so work stays bounded)subagent_manager/continuation.py(follow-up label messages, per-element 120 — each message is redacted whole, then bounded; messages are separate strings so per-element redaction sees every secret intact)apps/builtins/auto_research/handlers.py(Knowledge Library source name, 60)acp/_dispatch.pyneeded more than the outer reorder:_build_tool_result_eventalso cut each output PART at 4000 chars before the join, so a credential straddling a part's own cut was severed before any redaction ran. Each part is now redacted whole before its per-part bound; parts join on a newline and no redaction pattern matches across whitespace, so the join needs no second pass and the outer cut applies to already-redacted text.Not touched (scope)
subagent.py,handlers_project.py,agent.py,spec_builder/backend/routes.py× 2, plus itscron_script.pyfold-ins.dashboard/handlers/files.pyredact(raw[:7])site (security: five slice-before-redact sites survive #5574's fix; route them through redact_and_truncate #5582's documented exemption — regex-verified short hex cannot carry a credential).chat_utils._redact_tool_field's 1 MB byte-level safety cap runs before its redaction passes; it is a pre-existing site outside Slice-before-redact: ~15 core-path sibling sites beyond subprocess stderr (channel, chat_runner, artifacts, mcp_tools, memory) #7390's list, the cut is marked with an explicit truncation notice, and a 1 MB head window is a different risk class. Left for a follow-up rather than folded in here.Event-loop safety (review finding, fixed in this PR)
Removing a pre-slice can turn a bounded redaction into a full-text pass over size-uncapped input. Where that input is externally scalable and the call ran on the asyncio event loop, the pass now runs off-loop (
asyncio.to_thread), with the bound applied to the redacted text:discover.pyskill preview content (registry-publisher-controlled, size-uncapped)channel.pyapproval tool input (model-authored, size-unbounded)artifacts.pycomment anchor/author/reason fields (request-sized)_scan_artifacts' snippet path already ran in an executor; its full-text pass is additionally memoized per artifact version (_cached_snippet) so a listing pays it once per version, not once per request.Two sites also swap redaction pass order as a side effect of adopting
redact_and_truncate(credentials-then-exfiltration became exfiltration-then-credentials): this is the stronger order — credential scrubbing first can shorten a URL query below the exfiltration matcher's minimum and suppress whole-URL redaction — and matches the canonical helper's composition.Tests
test/test_core_path_redact_before_bound.py(new), reusing the shape of PR #7383'stest_theme_clone_stderr_redact_before_bound.py:_dispatch.redact*call's argument expression. Slicing the redact call's RESULT is the sanctioned composition.test/test_cross_surface_mirror.py::test_truncates_and_redactsupdated: it pinned the old truncate-first order at the mirror-echo site, which #7390 names as a defect site; the assertion now pins redact-then-bound.Verification
mypy src/kiro_crew/(0 issues), brand gate, harness-parity gate.python -m pytest: 77 735 passed; the only failures (96 + 2 errors) reproduce identically on a pristine main worktree on the same host (host-environment classes: AF_UNIX path length, home-dir project-root classification, xdist budget) — zero net-new failures from this branch._dispatchper-part cuts, the event-loop offloads, the adjacent raw log line, the snippet memoization, and a duplicated redaction pass now shared. Five root-level scratch files that an earlier head accidentally shipped (caught again by the First Principles lane) are removed from the diff as of the current head.No frontend changes; no UI changes (no screenshots required).
Pattern harvest
Rule candidate: AST scan (landed in this PR, module-pinned)
Pattern: a bounded char slice inside a
redact*call's argument expression (slice-before-redact) — the recurring class behind #7316, #7350, #7383, and this PR.test/test_core_path_redact_before_bound.py::_find_slice_inside_redact_callpins all 13 modules this issue names and goes red on any new slice with a bound ≥ 10 (or any non-constant bound) nested in a redactor's arguments. Repo-wide promotion is deliberately deferred (same call as #7350/#7383) while an offender baseline exists outside this PR's files — #5582's claimed sites plus the shape-identicalsubagent_manager/run.py,subagent_manager/terminal.py, anddev_fleet/server.pyhits the First Principles lane enumerated. A second candidate the AST shape cannot express — a bounded slice on a value that only later flows into a redactor (the_dispatchper-part cuts fixed here) — needs data-flow awareness and is better suited to a semgrep taint rule than to this scan.