Skip to content

fix(platform): harden win32 atomic replaces and path segments#98

Merged
pbean merged 2 commits into
bmad-code-org:mainfrom
dracic:fix/win32-atomic-replace-hardening
Jul 9, 2026
Merged

fix(platform): harden win32 atomic replaces and path segments#98
pbean merged 2 commits into
bmad-code-org:mainfrom
dracic:fix/win32-atomic-replace-hardening

Conversation

@dracic

@dracic dracic commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Problem

os.replace over a file another process holds open fails on Windows with WinError 5 (Python readers take no FILE_SHARE_DELETE), which froze a live run when the TUI poller collided with the engine's state write. POSIX rename-over-open never raises, so none of this bites on Linux/macOS. The same audit surfaced two more Windows defect classes with no cover: no filename sanitizer for user-derived path segments, and an unguarded force-kill.

Changes

  • atomic_replace: win32-gated, jittered-backoff retry around os.replace (~5 s worst case, sized for AV/indexer handle holds); every rename-over-open site routes through it — state, decisions, sweep answers, TUI settings, run archives.
  • safe_segment: Windows-filename sanitizer (illegal/control chars, reserved device names incl. COM0/superscripts/CONIN$/CONOUT$, trailing dot/space, length cap, digest suffix on any change for practical collision resistance; identity for clean input) applied wherever story/unit keys become path or window segments: task ids, feedback files, resolve dirs, worktree and failed dirs, deferred artifacts. Composed task ids (key + plugin label) are sanitized as one segment so two individually capped parts can't exceed the filename limit.
  • Agent MCP configs read BOM-tolerantly, failing closed on undecodable content; unity teardown explains identity-guard skips.
  • os.kill usage gated outside the process host (test tripwire).

Testing

  • New unit tests: retry/backoff matrix (incl. POSIX zero-retry), sanitizer contract (reserved names, collision suffix, length cap), composed task-id cap, dirty-key resolve dir, save_state regression under a transient PermissionError.
  • Full suite on Windows: 1707 passed; only the pre-existing skill-sync baseline failures.
  • Lint verified locally at CI-pinned versions (ruff, black 26.5.1, isort 8.0.1, bandit 1.9.4 with repo config).

Closes #74

Summary by CodeRabbit

  • Bug Fixes

    • Improved Unity config parsing when JSON files include a UTF-8 BOM.
    • Hardened shutdown and run persistence to avoid issues from reused process IDs and improve reliability of atomic file writes (with Windows retry behavior).
    • Sanitized story/unit task and directory naming to prevent failures from invalid or reserved filename characters.
  • Tests

    • Added/expanded coverage for BOM parsing, atomic replacement retries, path sanitization/collision handling, safer teardown behavior, and the portability guard for restricted os.kill usage.

os.replace over a file another process holds open fails on Windows with
WinError 5 (Python readers take no FILE_SHARE_DELETE), which froze a live
run when the TUI poller collided with the engine's state write. POSIX
rename-over-open never raises, so none of this bites on Linux/macOS.

- add atomic_replace: a win32-gated, jittered-backoff retry around
  os.replace (~5 s worst case, sized for AV/indexer handle holds), and
  route every rename-over-open site through it: state, decisions, sweep
  answers, TUI settings, run archives
- add safe_segment: an injective Windows-filename sanitizer (illegal and
  control chars, reserved device names incl. COM0/superscripts, trailing
  dot/space, length cap, digest suffix on any change; identity for clean
  input) and apply it wherever story/unit keys become path or window
  segments: task ids, feedback files, resolve dirs, worktree and failed
  dirs, deferred artifacts
- read agent MCP configs BOM-tolerantly and fail closed on undecodable
  content; explain identity-guard skips in unity teardown
- gate os.kill usage outside the process host
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ff1f28d5-e54f-42f9-b9d6-d1c2e1611d38

📥 Commits

Reviewing files that changed from the base of the PR and between 2edf133 and c768e9d.

📒 Files selected for processing (2)
  • src/bmad_loop/engine.py
  • tests/test_engine.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/bmad_loop/engine.py

Walkthrough

This PR adds Windows-safe atomic replacement and path-segment sanitization helpers, uses them in persistence and path construction code, tightens Unity config and teardown handling, and broadens the portability guard’s os.kill scan.

Changes

Windows path-safety and atomic-write hardening

Layer / File(s) Summary
atomic_replace and safe_segment primitives
src/bmad_loop/platform_util.py
Adds Windows retry constants, reserved-basename detection, atomic_replace() for retrying transient rename failures, and safe_segment() for sanitizing filesystem-illegal segments with digest suffixing.
Adopt atomic_replace at persistence sites
src/bmad_loop/decisions.py, journal.py, runs.py, sweep.py, tui/settings.py, tests/test_journal.py, tests/test_platform_util.py
Replaces temp-file commit calls with atomic_replace in store, journal, archive, sweep decisions, and settings persistence, with tests covering retry behavior and persistence after replacement.
Adopt safe_segment for path/id sanitization
src/bmad_loop/engine.py, resolve.py, workspace.py, tests/test_engine.py, tests/test_resolve.py, tests/test_platform_util.py
Sanitizes story-key and unit-key derived task IDs, directory names, and patch output paths using safe_segment, with tests validating length capping and sanitized path construction.
Unity plugin robustness fixes
src/bmad_loop/data/plugins/unity/unity_setup.py, unity_teardown.py, tests/test_engine_plugin.py
Uses BOM-tolerant UTF-8 reads with broadened error handling in setup, and adds explicit liveness/identity checks with a skip-and-log path before force-kill in teardown.
Portability guard os.kill scope check
tests/test_portability_guard.py
Extends AST scanning to detect any os.kill call and restricts it to process_host.py via a new allowlist and regression test.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related issues

Poem

A rabbit hops on safer ground,
With tidy paths and writes profound.
No bommed-up JSON trips the way,
No ghost PID gets a kill today.
Soft thumps, clear logs, and file names neat —
This burrow’s code is snug and sweet. 🐰

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 58.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main Windows-focused changes to atomic replacement and path segment sanitization.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/bmad_loop/engine.py (1)

1166-1191: 🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

_resumable_session's task_id formula diverges from _run_session's, breaking resume for sanitized story keys.

_run_session (Lines 1996-1998) sanitizes the whole composed "{story_key}-{role}-{seq}" string, per its own comment ("Sanitize the whole composition, not the parts"). _resumable_session (Line 1179) instead sanitizes only story_key and appends -{role}-{seq} afterward. Since safe_segment appends a hash digest of the exact input it sanitized, these two produce different strings whenever the story key actually needs sanitization (illegal chars, trailing dot/space, or length overflow) — exactly the scenario this PR is meant to harden. The result: on crash-resume, _resumable_session will never match the persisted SessionRecord, so the engine falls through to the destructive resume-restart path (rollback + re-run) instead of resuming completed work.

🐛 Proposed fix — mirror `_run_session`'s composition
-        task_id = f"{safe_segment(task.story_key)}-{role}-{seq}"
+        task_id = safe_segment(f"{task.story_key}-{role}-{seq}")

Also applies to: 1985-1998

🤖 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 `@src/bmad_loop/engine.py` around lines 1166 - 1191, The task/session lookup in
_resumable_session uses a different task_id composition than _run_session, so
sanitized story keys will not match persisted SessionRecord entries on resume.
Update _resumable_session to build task_id the same way as _run_session does for
the session record (sanitize the full composed story_key-role-seq string, not
just story_key), using the same safe_segment-based logic so resumed sessions can
be found reliably.
🧹 Nitpick comments (2)
src/bmad_loop/data/plugins/unity/unity_setup.py (1)

428-431: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add tests for BOM-prefixed and undecodable config content.

The PR objectives call out "BOM-tolerant reading" and "fail-closed behavior on undecodable content," but no test exercises either path. A BOM-prefixed file would verify utf-8-sig stripping works, and a binary/invalid-UTF-8 file would verify the UnicodeDecodeError branch returns False.

🧪 Suggested test additions
# In tests/test_engine_plugin.py, extend test_verify_agent_isolation:

def test_verify_agent_isolation_bom_prefixed_config(tmp_path):
    """A BOM-prefixed config file is read correctly (utf-8-sig strips the BOM)."""
    mod = _load_unity_setup()
    url = "http://localhost:23723"
    cdir = tmp_path / ".codex"
    cdir.mkdir()
    bom = b"\xef\xbb\xbf"
    content = '[mcp_servers.ai-game-developer]\nurl = "http://localhost:23723"\n'
    (cdir / "config.toml").write_bytes(bom + content.encode("utf-8"))
    assert mod._verify_agent_isolation("codex", tmp_path, url) is True


def test_verify_agent_isolation_undecodable_config(tmp_path, capsys):
    """Undecodable config content triggers fail-closed (returns False)."""
    mod = _load_unity_setup()
    url = "http://localhost:23723"
    cdir = tmp_path / ".codex"
    cdir.mkdir()
    (cdir / "config.toml").write_bytes(b"\xff\xfe\x00\x01binary garbage")
    assert mod._verify_agent_isolation("codex", tmp_path, url) is False
    assert "missing or unreadable" in capsys.readouterr().err
🤖 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 `@src/bmad_loop/data/plugins/unity/unity_setup.py` around lines 428 - 431, Add
tests for the BOM-tolerant and fail-closed config read paths in
_verify_agent_isolation. Extend the existing test coverage around unity_setup so
one test writes a UTF-8 BOM-prefixed config and asserts the config is parsed
successfully, and another writes invalid UTF-8/binary content and asserts the
function returns False and emits the unreadable-config message. Use the
_verify_agent_isolation helper and the config.toml setup in the existing plugin
tests to locate the behavior.
src/bmad_loop/platform_util.py (1)

122-123: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Ruff S311 will still fire despite the # nosec comment.

# nosec B311 only suppresses Bandit; Ruff's S311 rule uses its own # noqa syntax, so this line will keep failing lint even though the randomness use (retry jitter, not crypto) is legitimate.

🔧 Suggested fix
-            time.sleep(delay + random.uniform(0, _REPLACE_BASE_S))  # nosec B311 - retry jitter
+            time.sleep(delay + random.uniform(0, _REPLACE_BASE_S))  # nosec B311  # noqa: S311 - retry jitter, not crypto
🤖 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 `@src/bmad_loop/platform_util.py` around lines 122 - 123, The retry jitter in
platform_util is still triggering Ruff S311 because the existing suppression
only targets Bandit. Update the time.sleep call in the retry loop to use Ruff’s
own noqa suppression on the random.uniform expression or line, and keep the
context tied to the retry backoff logic in the same block so the legitimate
non-crypto randomness is accepted by lint.

Source: Linters/SAST tools

🤖 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.

Outside diff comments:
In `@src/bmad_loop/engine.py`:
- Around line 1166-1191: The task/session lookup in _resumable_session uses a
different task_id composition than _run_session, so sanitized story keys will
not match persisted SessionRecord entries on resume. Update _resumable_session
to build task_id the same way as _run_session does for the session record
(sanitize the full composed story_key-role-seq string, not just story_key),
using the same safe_segment-based logic so resumed sessions can be found
reliably.

---

Nitpick comments:
In `@src/bmad_loop/data/plugins/unity/unity_setup.py`:
- Around line 428-431: Add tests for the BOM-tolerant and fail-closed config
read paths in _verify_agent_isolation. Extend the existing test coverage around
unity_setup so one test writes a UTF-8 BOM-prefixed config and asserts the
config is parsed successfully, and another writes invalid UTF-8/binary content
and asserts the function returns False and emits the unreadable-config message.
Use the _verify_agent_isolation helper and the config.toml setup in the existing
plugin tests to locate the behavior.

In `@src/bmad_loop/platform_util.py`:
- Around line 122-123: The retry jitter in platform_util is still triggering
Ruff S311 because the existing suppression only targets Bandit. Update the
time.sleep call in the retry loop to use Ruff’s own noqa suppression on the
random.uniform expression or line, and keep the context tied to the retry
backoff logic in the same block so the legitimate non-crypto randomness is
accepted by lint.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8fe2299f-250b-42c5-bd3e-8be507614711

📥 Commits

Reviewing files that changed from the base of the PR and between 37a01f1 and 2edf133.

📒 Files selected for processing (17)
  • src/bmad_loop/data/plugins/unity/unity_setup.py
  • src/bmad_loop/data/plugins/unity/unity_teardown.py
  • src/bmad_loop/decisions.py
  • src/bmad_loop/engine.py
  • src/bmad_loop/journal.py
  • src/bmad_loop/platform_util.py
  • src/bmad_loop/resolve.py
  • src/bmad_loop/runs.py
  • src/bmad_loop/sweep.py
  • src/bmad_loop/tui/settings.py
  • src/bmad_loop/workspace.py
  • tests/test_engine.py
  • tests/test_engine_plugin.py
  • tests/test_journal.py
  • tests/test_platform_util.py
  • tests/test_portability_guard.py
  • tests/test_resolve.py

@dracic

dracic commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

@pbean please see the outside diff comment...

_run_session sanitizes the whole composed "{key}-{part}-{seq}" string while
_resumable_session sanitized only the story key and re-appended "-{role}-{seq}"
raw. Whenever safe_segment actually changes the key (illegal chars, or a clean
key whose composed id overflows the segment cap) the two ids differ — different
digest input, different truncation — so the stored SessionRecord never matches
and _finish_inflight falls through to the destructive resume-restart, defeating
the bmad-code-org#61 post-kill rescue for exactly the keys this PR hardens.

SessionRecord stores no seq, so exact-id matching is load-bearing; route both
sites through one _session_task_id helper instead of mirroring the formula
(composing inline is how they diverged), and pin the parity with a regression
test over a dirty key and a cap-overflowing clean key.
@pbean

pbean commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

@dracic Confirmed — the outside-diff critical is real, and it's a good catch by the rabbit. I traced the consequence: for any story key that safe_segment actually changes (dirty chars, or a clean key whose composed id overflows the 120-char cap), _resumable_session's part-only composition never matches the stored SessionRecord.task_id, so _finish_inflight falls through to the destructive resume-restart (worktree discard / baseline rollback) — silently defeating the #61 post-kill rescue for exactly the keys this PR hardens.

Rather than round-trip, I pushed the fix to your branch (c768e9d): both sites now route through one _session_task_id helper — SessionRecord stores no seq, so exact-id matching is load-bearing, and a shared composition point beats mirroring the formula (composing inline is how the two diverged). Plus a parity regression test (dirty key + cap-overflowing clean key) that's red on your previous head and green now. Full suite + trunk clean locally.

On the rest of the review sweep:

  • CodeRabbit's noqa: S311 nitpick is moot — ruff's S family isn't enabled in this repo (no [tool.ruff.lint] select); bandit runs separately via trunk, so your # nosec B311 is exactly right. No change needed.
  • Non-blocking, take or leave: resolve.py's f"{safe_segment(story_key)}-resolve-1" uses the part-only style. It's harmless today (write-only id, no sibling composition to disagree with), but if you're touching the file anyway, safe_segment(f"{story_key}-resolve-1") keeps the whole-composition principle uniform.
  • Non-blocking: CodeRabbit's suggested BOM/undecodable-config tests for _verify_agent_isolation would be cheap coverage for the two paths you added; fine as a follow-up too.

I also ran a full consumer audit of every safe_segment site: everything else is consistent (readers use stored values — task.worktree_path, SessionRecord.task_id, returned Paths — funnel through a single chokepoint like resolve._story_dir, or match by prefix+mtime like the marker discovery), and atomic_replace covers both TUI-polled rename targets (state.json, decisions store). Two adjacent pre-existing gaps the audit surfaced are now #101 (_stash_deferred_artifacts shutil.moveFileExistsError on Windows re-defer) and #102 (unit_branch_name passes the raw key to git as a ref) — neither blocks this PR.

Will approve once CI is green on the new head. Thanks for a genuinely thorough piece of work — the win32-gated retry sizing rationale and the reserved-name coverage (COM0/superscripts/CONIN$) are better than what most of the ecosystem ships.

@pbean pbean left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Validated end to end: full consumer audit of every safe_segment site and every rename-over site came back clean (details in my comment above), the one critical (resume task-id divergence) is fixed on-branch with a red/green parity test, and CI is green on the new head including both Windows jobs. Follow-ups tracked in #101/#102.

@pbean pbean merged commit 975c044 into bmad-code-org:main Jul 9, 2026
9 checks passed
pbean added a commit that referenced this pull request Jul 9, 2026
…ames (#103)

* fix(platform): overwrite-safe deferred stash + ref-safe unit branch names

#101 — _stash_deferred_artifacts staged a re-defer's spec straight over the
prior stash with shutil.move. Its os.rename raises FileExistsError on Windows,
which move catches and papers over with a non-atomic copy2 + unlink: that tears
the stash on a mid-copy crash, and re-fails outright when an AV/indexer handle
turns the rename into a sharing violation and copy2 cannot open the locked
target either. Stage a copy inside the destination dir, then route through
platform_util.atomic_replace — one-step overwrite, #98's win32 retry, and the
replace stays same-filesystem so move's cross-device tolerance is preserved.

#102 — unit_branch_name built bmad-loop/<run_id>/<unit_key> from the raw ids, so
a key or --run-id carrying `:`, `..`, `@{`, a space or a trailing `.lock` passed
#98's directory sanitization only to die at `git worktree add` with "is not a
valid branch name". New platform_util.safe_ref_segment mirrors safe_segment's
contract (identity for clean input, -<hex8> digest suffix whenever anything
changed, never raises) on git's alphabet rather than Windows' — the two rule
sets are disjoint in both directions, so a consumer deriving both a directory
and a branch from one key must run both. Applied to both segments; a
`git check-ref-format` oracle test pins the agreement over a nasty corpus.

* fix(platform): retry the source unlink in the deferred stash too

Review catch on #103. Windows denies a *delete* against an open handle exactly
as it denies a rename-over, so hardening only the replace left the staged move
half-hardened: an AV/indexer handle on the just-written source spec fails
`spec_path.unlink()` after the stash has already landed. `_defer` calls
_stash_deferred_artifacts before the rollback and the `story-deferred` journal
append, so that raise aborts the whole deferral.

Suppressing the unlink is wrong — it silently leaves the stale in-review spec
this function exists to remove. Instead extract atomic_replace's win32 backoff
into _retry_on_sharing_violation and reuse it for a new retrying_unlink, giving
both halves of the move the same transient-lock tolerance. A permanent failure
still propagates, as before. Replace-then-unlink ordering is deliberate: a
failure can leave a duplicate spec, never a hole where the work was.

* refactor(engine): reuse safe_ref_segment for attempt-preserve slugs

Both recovery-ref sites re-implemented ref sanitization inline
(alnum/_- join, 64-char cap). Never illegal — but a second rule set to
keep in agreement with git, and lossier than the real one: dots in a
clean run id were mangled, and distinct exotic ids could collapse to
the same slug. Route both through safe_ref_segment: identity for clean
ids, digest suffix keeps dirty ids distinct, single source of truth.

Characterization test only — no red-first guard is possible because the
old inline slug also produced legal refs; the test pins the invariant
(real git resolves the slugged ref) and the new digest-suffixed shape.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Windows: engine silently dies ("engine gone - run was interrupted") - save_state os.replace() WinError 5 sharing violation with TUI reader

2 participants