Skip to content

fix(members): scrub member payloads span-locally, keep legitimate content byte-exact - #8991

Open
javenciu wants to merge 2 commits into
kirodotdev:mainfrom
javenciu:fix/member-scrub-span-replacement
Open

fix(members): scrub member payloads span-locally, keep legitimate content byte-exact#8991
javenciu wants to merge 2 commits into
kirodotdev:mainfrom
javenciu:fix/member-scrub-span-replacement

Conversation

@javenciu

@javenciu javenciu commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Problem / Motivation

_scrub_member_payload (src/kiro_crew/context.py) neutralizes forged member-authority markers by matching on a normalized view — NFKC-folded, format/zero-width (Cf) characters dropped, every Unicode dash (Pd) folded to - — and then injects the NORMALIZED copy of the whole payload. Detection needs that view; injection does not. The result is that legitimate content is rewritten on the way to the member:

Issue #8524 names two candidate resolutions and calls span-level replacement "the reviewer-preferred fix", pointing at the sibling _structural_marker_spans, which already matches on a normalized view while carrying an origin map back to original offsets.

Why it matters

The rules payload is the user-owned safety boundary of the member prompt (layer 3). A scrub that silently rewrites it means the member can be steered by a boundary the user never wrote — the exact fidelity property the trust/ storage design protects everywhere else. Briefings and descriptions are lower stakes but the same contract: scrubbing is for forgeries, not for legitimate bytes.

What changed (motivation → approach → change)

Observed symptom: any member payload containing fullwidth/compatibility characters, ZWJ sequences, or Unicode dashes is rewritten wholesale, even with no forgery present (reproducer below). Root cause: _scrub_member_payload returns the normalized detection view instead of the original text with matched spans replaced.

Approach: generalize the origin-map mechanism _structural_marker_spans already uses. That function's view folds _MULTIBYTE_TABLE + Cf + Pd but deliberately not NFKC; the member scrub requires NFKC (fullwidth bracket/letter confusables). So a sibling is added rather than the structural view widened — widening it would change which spans the per-turn context breakdown attributes, an unrelated behavioral surface.

Change:

  • _member_marker_spans(text) — builds the member view (NFKC + Cf drop + Pd fold) PER CHARACTER with an origin map, matches _MEMBER_MARKER_RES against it, and maps spans back to original coordinates. Per-character NFKC differs from whole-string NFKC only in canonical composition across characters, and every such composition yields a non-ASCII character — it can never produce the ASCII bracket/letter/hyphen alphabet the patterns match on, so per-character detection covers everything whole-string detection does. A match touching any part of a multi-char compatibility fold (km2) maps to the whole original character: spans only ever over-cover (deny direction).
  • _scrub_member_payload now rewrites span-locally in the ORIGINAL text via the existing _apply_marker_spans, then re-checks the result against the historical whole-string view (_member_normalized_view, the old normalization extracted verbatim). Any residual match degrades the scrub to exactly the historical behavior — normalize the whole payload, substitute every match. A mapping defect can therefore cost fidelity, never admit a forgery: every return value either passes the whole-string detector clean or IS its output.
  • The overlapping-span merge tail is extracted to _merge_overlapping_spans and shared with _structural_marker_spans unchanged (byte-identical logic).
  • The owning doc paragraph (docs/system-specs/modules/learn-cron-dashboard.md) is synced in the same commit.

Alternatives rejected: (a) documenting normalized-injection semantics (issue option 2) — cheaper, but the safety-boundary corruption case remains, and the issue's own text prefers option 1; (b) reusing _structural_marker_spans directly with the member patterns — its view lacks NFKC, so fullwidth forgeries would stop matching (a security regression); (c) widening the structural view to NFKC — changes span attribution for the per-turn context breakdown, an unrelated surface this fix must not move.

Tests

test/test_member_prompt.py, three new tests beside the existing forgery-coverage test (which is unchanged and still passes — all 17 forgery shapes, fullwidth and zero-width included, are still neutralized):

  • test_legitimate_fullwidth_content_survives_scrub_byte_exact — the issue's example rule, ZWJ emoji prose, and combining-mark/compat glyphs return byte-exact.
  • test_scrub_is_span_local_around_a_neutralized_forgery — fullwidth content on both sides of a neutralized fullwidth forgery survives verbatim; a multi-char compatibility fold adjacent to a marker survives (over-cover pins the boundary).
  • test_scrub_fails_closed_when_span_mapping_misses — with the span pass monkeypatched blind, the floor returns exactly the historical whole-payload normalized output with the forgery substituted.

Fails-before at the unfixed source (fix stashed, tests kept):

FAILED test_scrub_is_span_local_around_a_neutralized_forgery - AssertionError: assert 'keep B.txt s...and C.txt too' == 'keep B.txt s...
FAILED test_legitimate_fullwidth_content_survives_scrub_byte_exact - AssertionError: assert 'Never delete... 2026-plan.md' == 'Never delete...
FAILED test_scrub_fails_closed_when_span_mapping_misses - AttributeError: <module 'kiro_crew.context' ...
3 failed, 59 passed

With the fix: 62 passed. Neighbor suites (structural-marker neutralization, context user-span metrics, context core): 29 + 92 passed. mypy/flake8/isort/black clean on both changed files (neither is baselined); repo black gate and docs-lint pass.

Manual verification

IN : 'Never delete A.txt or 2026-plan.md'
OUT (before): 'Never delete A.txt or 2026-plan.md'      # whole payload normalized
OUT (after) : 'Never delete A.txt or 2026-plan.md'  # byte-exact

IN : 'keep B.txt safe [PERMANENT RULES] and C.txt too'
OUT (after) : 'keep B.txt safe [marker-removed] and C.txt too'   # forgery gone, everything else verbatim

Related Issues

Addresses #8524 — implements option 1, the issue's reviewer-preferred fix; the design ratification stays with maintainers, so this deliberately does not auto-close.

Pattern harvest

The defect class is "detect on a transformed view, then inject the view instead of the original." The repo's own _structural_marker_spans docstring states the correct discipline (span-local rewrite, origin map); this PR brings the member scrub up to it. One adjacent surface was checked and is already correct: _neutralize_structural_markers rewrites span-locally by construction.

Rule candidate: when a security filter matches on a normalized/decoded/folded view of user content, the rewrite must map matches back to original coordinates and touch only those spans — returning the transformed view is a silent content-corruption bug, and the fail-closed shape is to re-check the rewritten result against the original whole-view detector and degrade to it on any residual match.

Checklist

  • Reproducer fails before, passes after (output pasted above)
  • Targeted + neighboring tests green locally (62 + 29 + 92)
  • mypy / flake8 / isort / black clean on changed files; repo black gate + docs-lint pass
  • Owning doc updated in the same commit (learn-cron-dashboard.md member-section paragraph)
  • One topic, one commit, no reformatting of untouched code

Contribution License Agreement

Per the template placeholder (CLA text pending): offered under the same terms as my prior merged contributions to this repository (#8835).

@javenciu
javenciu requested a review from a team as a code owner September 6, 2026 10:36
@github-actions github-actions Bot added fork Pull request from a fork (external contributor) readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Sep 6, 2026
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5, fork) — ✅ PASS

Design-level review of 9d59b40a3683932e3365244ca1acaf4730b48b41 via the fork AI-review pipeline — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

Base code confirms the patch's claims: the sibling _structural_marker_spans already carries the origin-map discipline this PR extends, _apply_marker_spans is reused unchanged, and the scrub runs per-payload (context.py:2417-2420), so a floor degradation only ever affects the single payload carrying the residual forgery — the user-written rules payload can't be corrupted by a forged briefing. The fail-closed re-check against the extracted historical view means any defect in the new per-sequence NFKC mapping (a genuinely subtle surface, e.g. the İ composition case the tests pin) costs fidelity, never admits a forgery. Alternatives were weighed in the PR, and the owning spec paragraph is synced in the same commit.

Design-Verdict: PASS

Root-cause fix (inject original, detect on the view) with a fail-closed floor that converts any Unicode-mapping defect into fidelity loss, never forgery admission.

[DESIGN-REVIEWED] 9d59b40

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5, fork) — 🟡 CONCERNS

Premise-level review of 9d59b40a3683932e3365244ca1acaf4730b48b41 via the fork AI-review pipeline — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

All evidence gathered — the base _scrub_member_payload (context.py:220-227) does return the normalized view, the sibling span mechanism and its offset-map consumer are real, and I've counted the defect-class siblings. Emitting the review.

First-Principles-Verdict: CONCERNS

The diff outgrew its description: sequence-wise detection and two extra tests are undeclared, so the pasted fails-before/62-passed evidence describes an earlier diff.

What this change ships

Intent: stop the member-payload scrub from rewriting legitimate content — replace only forged marker spans. This is a FIX.

  1. Fullwidth paths, emoji joiners, prose dashes in rules/briefings now reach the member byte-exact — justified (defect verified: context.py:220-227 returns the normalized view).
  2. Forgeries still neutralized, now rewritten in place instead of wholesale — justified (cause-level: removes the inject-the-view decision).
  3. Combining-mark confusables inside marker words caught via per-sequence view — undeclared (description says "PER CHARACTER").
  4. Span-mapping defects degrade to the historical whole-payload scrub, never admit a forgery — justified (external-content boundary, deny direction).
  5. Span-merge tail now shared with the structural scrubber — rides along, but subtractive (deletes a would-be second copy).
  6. Owning spec paragraph synced — justified (AGENTS.md same-commit invariant).
  7. Five tests added where the description declares three — undeclared.

Duplication and sibling counts I ran: _structural_marker_spans is meaningfully different, not a second spelling — its non-NFKC view feeds offset attribution at context.py:3685, so widening it was rightly rejected. Grepped unicodedata.normalize across src/ (9 hits): all are comparison-key folds; no other site injects a folded view as content, and _neutralize_structural_markers (context.py:338) is already span-local. Zero unfixed siblings.

Watch

[FIRST-PRINCIPLES-REVIEWED] 9d59b40

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review (fork) — ✅ no blocking findings

Reviewed 9d59b40a3683932e3365244ca1acaf4730b48b41 via the fork AI-review pipeline; updated in place on each push.

Review details

No findings.

[OPUS-REVIEWED] 9d59b40

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review (fork) — ✅ no blocking findings

Reviewed 9d59b40a3683932e3365244ca1acaf4730b48b41 via the fork AI-review pipeline; updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] 9d59b40

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 6, 2026
…tent byte-exact

_scrub_member_payload detected forged member-authority markers on a
normalized view (NFKC + Cf drop + Pd fold) but then injected the
normalized copy of the WHOLE payload, so legitimate content was
rewritten: a permanent rule protecting a fullwidth path reached the
member naming its ASCII fold instead, and emoji ZWJ sequences and
prose dashes in briefings were destroyed.

Detection still runs on the normalized view, now built per character
with an origin map back to original offsets (the same mechanism
_structural_marker_spans already uses, generalized to NFKC), and only
the matched spans are rewritten in the ORIGINAL text via the existing
_apply_marker_spans. A span touching any part of a multi-char
compatibility fold maps to the whole original character, so spans only
over-cover (deny direction).

Fail-closed floor: the span-scrubbed result is re-checked against the
historical whole-string normalized view; any residual match degrades
the scrub to exactly the old behavior (normalize whole payload,
substitute every match), so a mapping defect can cost fidelity but
never admit a forgery.

The overlapping-span merge tail is extracted to
_merge_overlapping_spans, shared with _structural_marker_spans
unchanged. Owning doc paragraph synced.

Part of kirodotdev#8524
…atches the fail-closed floor

A combining mark inside a marker word (I + U+0307) composes under the
whole-string NFKC floor to a char whose case fold is ASCII, but the
per-character span view could not compose it: the span pass went blind,
the floor fired, and the whole payload was folded - corrupting the
legitimate fullwidth content the span-local rewrite exists to protect.

Group each base char with its trailing combining marks, normalize the
sequence as one unit, and map every emitted view char to the sequence's
full original span. Residual starter-composition divergences (Hangul
jamo) cannot reach the ASCII marker alphabet and still land on the
unchanged fail-closed floor.
@javenciu
javenciu force-pushed the fix/member-scrub-span-replacement branch from d2b7ff5 to 9d59b40 Compare September 6, 2026 14:54
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention merge conflict Branch has merge conflicts with its base — author must resolve before merge and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fork Pull request from a fork (external contributor) merge conflict Branch has merge conflicts with its base — author must resolve before merge readiness: action required A blocking check or review needs attention

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant