Skip to content

fix(deferredwork): sanitize free text at the ledger writer chokepoint (#305) - #331

Merged
pbean merged 2 commits into
mainfrom
fix/deferredwork-line-injection
Jul 27, 2026
Merged

fix(deferredwork): sanitize free text at the ledger writer chokepoint (#305)#331
pbean merged 2 commits into
mainfrom
fix/deferredwork-line-injection

Conversation

@pbean

@pbean pbean commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Closes #305. Finding and prior art from @Haven2026 on #274.

The bug

The deferred-work ledger is line-oriented, but every mutator in deferredwork.py interpolated its
string arguments verbatim. A value carrying a line break injects ledger lines — and two live paths
feed model-authored text into these writers: a triage session's already_resolved[].evidence
and a decision option's resolution/intent.

Three distinct corruptions, all reproduced and each pinned by its own test:

Shape Effect
fixed\n### DW-99: fake\nstatus: open mints a phantom entry
a break mid-note the entry carries two status: lines
a break followed by - source_spec: truncates the entry span at FLAT_ENTRY_RE, re-surfacing the tail as a phantom legacy item

Worth stating because it cost a worthless test during review: STATUS_RE takes the first match,
so an injected status: never changes what parse_ledger reports. A test asserting entry.status
or entry.open passes with the guard deleted. The observables are line structure, not parsed state.

The fix

One layer: _one_line collapses line breaks at the point each writer serializes its arguments, and
never raises — the close paths (sweep._close_resolved, decisions.apply_pre_answer) are called
bare, so a ValueError would reach the run loop's catch-all and end the sweep as crashed.

Three placement decisions carry the correctness, each pinned by a move ablation rather than only
a delete:

  • In _apply_done, not the mark_done wrapper. Engine._apply_deferred_closes calls
    mark_done_many directly, so a wrapper-side guard never sees a story close.
  • Before append_entry's idempotence scan, which compares the caller's raw value against the
    stored one via field_line_present — sanitizing afterwards would make every replay of the same
    multiline defer append a duplicate.
  • Before append_decision's emptiness test, so the separator drops with the detail.

The break set is str.splitlines()'s, not \n alone: parse_legacy scans with splitlines() while
parse_ledger uses re.MULTILINE, so a U+2028 splits an entry for one reader and is invisible to
the other — the two then disagree about what the ledger says. A test enumerates all 0x110000 code
points asserting set-equality.

A value with no break is returned untouched, so a clean write is byte-identical and no existing
ledger is reformatted.

date, and append_entry's status/severity, are orchestrator-owned enumerables and raise
instead — a new exception path on four released functions, called out in the CHANGELOG.

What is deliberately not here

No gate at the schema boundary. An earlier revision added one to validate_triage; it was
removed after ablation showed it did no integrity work — deleting its entire body left every
ledger-integrity test green, because _one_line already neutralizes the break losslessly. Its only
live effect was cost: one line break discarded the whole triage plan, and with
max_triage_attempts = 2 the second failure escalates to RunPaused — paging a human who has
nothing to fix, since the ledger is intact and the model cannot be edited. validate_triage
accepting multiline free text is now required behavior with its own test and an inverse ablation.

The skill docs state the expectation as guidance instead, so a compliant session never emits a
break, and nothing gates on it.

Coverage is partial by construction

The dev session appends flat blocks to the ledger directly, and the migration session rewrites the
whole file — neither passes through these writers. Structured deferrals are #326.

Follow-ups filed from this review

Verification

  • Full suite 3351 passed, 24 skipped; trunk check clean
  • 23/23 ablations bite, each requiring a green baseline and the literal word failed in the
    ablated run — a bare nonzero exit lies (a stale node id reads as "failed", so does an
    IndentationError in the patch)
  • Inverse ablations hold for every placement claim, including re-adding the removed schema gate
  • Test inputs are derived from the str.splitlines() oracle rather than typed: a literal
    U+2028 normalizes to a plain space in transit, which silently turned five cases into
    space-testing cases during development and would defeat any review-by-reading

Summary by CodeRabbit

  • Bug Fixes

    • Prevented multiline text from creating unintended or duplicate ledger entries.
    • Added validation for dates, statuses, and severity values.
    • Improved handling of invalid decision dates with clear CLI errors instead of crashes.
    • Prevented invalid dates from creating partial or corrupted records.
    • Improved TUI error handling when recording decisions fails.
  • Documentation

    • Clarified ledger formatting requirements and multiline text handling.

The deferred-work ledger is line-oriented, but every mutator interpolated
its string arguments verbatim, so a value carrying a line break injected
ledger lines: a resolution note of `fixed\n### DW-99: fake\nstatus: open`
minted a phantom entry, and a break followed by `- source_spec:` truncated
the entry span and re-surfaced its tail as a legacy item.

Collapse line breaks in free text at the point each writer serializes it,
and never raise there — the close paths are called bare, so a ValueError
would end the sweep run as crashed. The break set is `str.splitlines()`'s,
not `\n` alone: `parse_legacy` scans with it, so a U+2028 splits an entry
for that reader while `parse_ledger`'s `re.MULTILINE` never sees it.

The guards sit in `_apply_done`, not the `mark_done` wrapper, because the
story-close path calls `mark_done_many` directly; and ahead of
`append_entry`'s idempotence scan, which compares the caller's raw value
against the stored one. A value with no break is returned untouched, so a
clean write stays byte-identical and no existing ledger is reformatted.

Orchestrator-owned enumerable fields — `date`, and `append_entry`'s
`status`/`severity` — are validated and raise instead, a new exception path
on four released functions.

No gate at the schema boundary: ablation showed it did no integrity work
while one line break could discard a whole triage plan and escalate to
RunPaused, paging a human with nothing to fix. The skill docs state the
expectation as guidance instead. Coverage is partial by construction — the
dev and migration sessions write the ledger directly (#326).

Finding and prior art from @Haven2026 on #274.

Closes #305
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@pbean, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 25 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 78b2198f-d541-41b2-8c8f-0eaf5d12cb1d

📥 Commits

Reviewing files that changed from the base of the PR and between eaadd15 and 7416149.

📒 Files selected for processing (3)
  • src/bmad_loop/cli.py
  • src/bmad_loop/deferredwork.py
  • tests/test_cli.py

Walkthrough

Deferred-work ledger writers now collapse multiline free text to single lines and validate dates, statuses, and severities. CLI and TUI decision recording handle validation failures, while sweep, decision, writer, and documentation tests cover sanitization and rejection behavior.

Changes

Ledger hardening

Layer / File(s) Summary
Writer sanitization and validation
src/bmad_loop/deferredwork.py, src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md, CHANGELOG.md
Ledger writers flatten line breaks, validate canonical fields, sanitize before deduplication, and handle unusable titles.
Decision recording error handling
src/bmad_loop/decisions.py, src/bmad_loop/cli.py, src/bmad_loop/tui/app.py, tests/test_decisions.py, tests/test_cli.py
Decision recording documents and handles invalid dates, while tests verify clean failures and sanitized details.
Writer sanitization and validation tests
tests/test_deferredwork.py
Tests cover injection resistance, normalization, validation, idempotence, title handling, and unchanged output for safe values.
Sweep ledger integration and guidance
src/bmad_loop/data/skills/bmad-loop-sweep/automation-mode.md, tests/test_sweep.py
Sweep guidance and tests cover sanitized evidence, decision details, resolution notes, and close operations.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers: dracic

Poem

A rabbit found newlines in ledger hay,
And pressed them to one line today.
Dates stand straight, severities align,
Phantom entries vanish from the sign.
Decisions fail with a gentle thump—
Clean ledgers make this bunny jump!

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR covers chokepoint sanitization and field validation, but it omits the linked validate_triage feedback layer for multiline free text. Add validate_triage rejection/feedback for multiline free-text fields so invalid triage input is surfaced before writer serialization.
Docstring Coverage ⚠️ Warning Docstring coverage is 67.39% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly names the main change: sanitizing deferred-work free text at the ledger writer chokepoint.
Out of Scope Changes check ✅ Passed The code, tests, and docs all support the deferred-work sanitization and validation work, with no clear unrelated changes.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/deferredwork-line-injection

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jul 27, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes a ledger-injection vulnerability in the deferred-work writers: because the ledger is line-oriented but mutators interpolated their arguments verbatim, a model-authored value containing a line break could mint phantom entries, inject extra status: lines, or truncate an entry's span at a flat-appender bullet.

  • Core fix (_one_line): collapses every run of Unicode line-break characters (the full str.splitlines() set, not just \ ) to a single space at the point each writer serializes its arguments. Values without breaks are returned byte-for-byte unchanged. Placement is deliberate: inside _apply_done (not the mark_done wrapper) so Engine._apply_deferred_closes is covered, and before append_entry's idempotence scan so replay of multiline defers is correctly suppressed.
  • New validators (_require_iso_date, _require_canonical_status, severity whitelist): orchestrator-owned fields raise ValueError on invalid input rather than silently writing malformed ledger lines; both cmd_decisions and the TUI now catch this exception and degrade gracefully per-decision.

Confidence Score: 5/5

Safe to merge. The sanitization is placed correctly at every interpolation point, values without line breaks are returned byte-for-byte unchanged, and the new validators fire before any file I/O.

The fix is narrowly scoped to the three writer functions, placement decisions are each pinned by move-ablation tests, and Unicode line-break coverage is verified by exhaustive enumeration over all 0x110000 code points. Both callers that receive ValueError now degrade gracefully per-decision rather than crashing.

Files Needing Attention: No files require special attention. The ordering of _CANONICAL_SEVERITIES after append_entry in the file is intentional and noted in the inline comment.

Important Files Changed

Filename Overview
src/bmad_loop/deferredwork.py Core of the fix: adds _one_line, _require_iso_date, _require_canonical_status, LINE_BREAK_RE, _ISO_DATE_RE, and _CANONICAL_SEVERITIES. Sanitization is placed correctly in _apply_done (not mark_done), before the idempotence scan in append_entry, and before the empty-detail check in append_decision.
src/bmad_loop/decisions.py Docstring update only: documents the date precondition and that free text is sanitized not refused.
src/bmad_loop/cli.py Adds ValueError to the cmd_decisions exception handler so a failure is attributed to the specific decision, rather than surfacing as an unattributed traceback.
src/bmad_loop/tui/app.py Adds ValueError to the TUI's apply_pre_answer handler to prevent an uncaught exception from crashing the dashboard.
tests/test_deferredwork.py Adds 211 lines of tests covering the full Unicode line-break set by enumeration, all three injection shapes, idempotence-under-sanitization, and validator rejection before any write.
tests/test_sweep.py Pins the two live writer paths and confirms validate_triage accepts multiline free text without raising.
tests/test_decisions.py Tests multiline sanitization through apply_pre_answer and that a bad-date ValueError fires before any side-effect.
tests/test_cli.py Tests cmd_decisions error handler attribution for both the ValueError and BmadConfigError paths.
src/bmad_loop/data/skills/bmad-loop-sweep/automation-mode.md Documents the single-line expectation as guidance (not a validation rule) and exempts both intent fields with rationale.
src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md Adds a section explaining the three corruption shapes and directing authors to keep breaks out of field values.
CHANGELOG.md Documents the bug, the fix, the new exception paths, and the remaining out-of-scope producers.

Reviews (2): Last reviewed commit: "fix(cli): attribute a failed pre-answer ..." | Re-trigger Greptile

Comment thread src/bmad_loop/deferredwork.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
tests/test_deferredwork.py (1)

170-183: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Single-pass the codepoint scan to halve this test's cost.

Two independent range(0x110000) loops build ~2.2M throwaway strings; one loop over the same range collecting both sets keeps the oracle exact while cutting the work in half.

♻️ Single-pass variant
-    splits = {chr(c) for c in range(0x110000) if len(("a" + chr(c) + "b").splitlines()) > 1}
-    matches = {chr(c) for c in range(0x110000) if LINE_BREAK_RE.fullmatch(chr(c))}
+    splits: set[str] = set()
+    matches: set[str] = set()
+    for c in range(0x110000):
+        ch = chr(c)
+        if len(("a" + ch + "b").splitlines()) > 1:
+            splits.add(ch)
+        if LINE_BREAK_RE.fullmatch(ch):
+            matches.add(ch)
     assert matches == splits
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_deferredwork.py` around lines 170 - 183, Update
test_line_break_set_is_exactly_what_str_splitlines_splits_on to iterate over
range(0x110000) once, compute each character’s splitlines() result and
LINE_BREAK_RE.fullmatch result within that loop, and add the character to the
corresponding set(s). Preserve the existing exact-set comparison and
ordinary-space/tab assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/bmad_loop/cli.py`:
- Around line 1499-1508: Extend the exception tuple in the decision-recording
try/except around decisions.apply_pre_answer to include
bmadconfig.BmadConfigError, matching the handling in _record_decision. Keep the
existing per-decision error message and return behavior unchanged.

In `@src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md`:
- Line 45: Remove the trailing spaces inside both Markdown code spans in
deferred-work-format.md, including the spans around the ### examples, so the
closing backticks immediately follow the final character and MD038 passes.

---

Nitpick comments:
In `@tests/test_deferredwork.py`:
- Around line 170-183: Update
test_line_break_set_is_exactly_what_str_splitlines_splits_on to iterate over
range(0x110000) once, compute each character’s splitlines() result and
LINE_BREAK_RE.fullmatch result within that loop, and add the character to the
corresponding set(s). Preserve the existing exact-set comparison and
ordinary-space/tab assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f9835be2-2df1-48df-bc61-6ad5d8233ce2

📥 Commits

Reviewing files that changed from the base of the PR and between fb9aafa and eaadd15.

📒 Files selected for processing (11)
  • CHANGELOG.md
  • src/bmad_loop/cli.py
  • src/bmad_loop/data/skills/bmad-loop-sweep/automation-mode.md
  • src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md
  • src/bmad_loop/decisions.py
  • src/bmad_loop/deferredwork.py
  • src/bmad_loop/tui/app.py
  • tests/test_cli.py
  • tests/test_decisions.py
  • tests/test_deferredwork.py
  • tests/test_sweep.py

Comment thread src/bmad_loop/cli.py
Comment thread src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md
Adds `bmadconfig.BmadConfigError` to `cmd_decisions`' per-decision handler,
beside the `ValueError` it already caught, and corrects what that handler is
for.

The review that prompted this said an escaping `BmadConfigError` would reach
argparse as a traceback. It would not: `main`'s tail catches it by name and
catches everything else through a bare `except Exception`, so the command
already exited 1 with a message either way. What the handler actually buys is
the `DW-<n>` in that message — the loop has already printed an outcome line per
answered decision, and a bare `error: <msg>` after them does not say which one
did not land.

`BmadConfigError` is the reachable case, unlike the `ValueError` the handler was
written for: `apply_pre_answer` re-reads the BMAD config on every call and
`prompter.ask` blocks on the human in between, so a config removed or broken
mid-prompt raises here even though the read at the top of the command succeeded.
Leaving it out gave the likelier failure the worse message. The TUI sibling
already caught it.

Both tests assert the id prefix and say why: ablating the handler leaves the exit
code and the message body intact, so those assertions are the message's shape,
not the guard. The new one reproduces the window by removing the config from
inside the stub prompter's `ask`, so the live `load_paths` call is what fails
rather than a patched raise.

Also corrects `_one_line`'s docstring, which cross-referenced `append_entry` and
`append_decision` as if they handled an empty value the same way. They do not —
one substitutes `(untitled DW-<n>)`, the other drops the ` — ` separator — and
`append_decision`'s `label` needs neither, since every `LINE_BREAK_RE` member is
`str.isspace()` and `validate_triage` builds each `DecisionOption` with
`.strip() or key`.
@pbean

pbean commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

Triaged the two bot reviews. Two findings were valid and are fixed in 7416149; two are declined with the evidence on their threads. Answering the two pre-merge check warnings here.

Findings

Source Finding Verdict
CodeRabbit cli.py missing BmadConfigError fixed — though not for the stated reason; see thread
Greptile _one_line docstring cross-reference fixed — hazard is unreachable; see thread
CodeRabbit MD038 trailing space in code spans declined — rule not enabled here, and the space is load-bearing
CodeRabbit single-pass the codepoint scan declined — measured; the speedup does not exist

The one worth reading is the first. CodeRabbit said an escaping BmadConfigError would "crash this interactive per-decision loop as an uncaught traceback". It would not — main's tail catches it by name and catches everything else through a bare except Exception, so the command already exited 1 with a message either way. What the handler buys is the DW-<n> in that message, and BmadConfigError belongs in the tuple because it is the reachable case (the config can break while prompter.ask blocks on the human) unlike the ValueError the handler was written for. The comment now says that. The pre-existing sibling test made the same traceback claim in its docstring and is corrected too; ablation confirms the exit code and the message body both survive deleting the handler, so only the id prefix is a real assertion.

On the codepoint-scan nitpick

The claim was that merging the two range(0x110000) comprehensions would "halve this test's cost". Measured, 5 runs each, median:

variant median
two comprehensions (current) 0.150 s
single loop (proposed) 0.149 s

Indistinguishable, and 0.15 s total inside a 3352-test suite. The dominant work is chr() + splitlines() over 1.1M codepoints, which the merged loop still does once per codepoint; only the cheap fullmatch pass folds in, and an explicit for + .add() loop is slower than a set comprehension, cancelling that. Keeping the two-set form, which states the oracle more directly.

Pre-merge checks

Linked Issues — "omits the validate_triage feedback layer." That layer is not missing, it was removed, and the removal is the argued part of this PR (see What is deliberately not here). Deleting its entire body left every ledger-integrity test green, because _one_line already neutralizes the break losslessly. Its only live effect was cost: one line break discarded the whole triage plan, and with max_triage_attempts = 2 the second failure escalates to RunPaused — paging a human who has nothing to fix, since the ledger is intact and the model cannot be edited. validate_triage accepting multiline free text is now required behavior with its own test and an inverse ablation. Adding the gate back is the one change this PR should not take.

Docstring coverage 67.39%. No such gate exists in this repo (see .github/workflows/ci.yml), and the module this PR touches is among the most heavily documented here.

Verification after 7416149

  • Full suite 3352 passed, 24 skipped (+1 test)
  • trunk check clean, pyright@1.1.411 0 errors
  • Ablation on the new test bites: reverting the tuple to (OSError, ValueError) fails it on the could not record DW-1 assertion, printing the bare error: BMAD config not found: ... at exit 1 — which is also what proves the traceback claim wrong

@pbean
pbean merged commit 3c68153 into main Jul 27, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant