Skip to content

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

Open
dracic wants to merge 1 commit into
bmad-code-org:mainfrom
dracic:fix/win32-atomic-replace-hardening
Open

fix(platform): harden win32 atomic replaces and path segments#98
dracic wants to merge 1 commit 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 handling of config files so Unity-related setup works more reliably with UTF-8 files that include a BOM.
    • Added safer file and process handling to avoid accidental cleanup of reused process IDs and to reduce write-related failures.
    • Made run/session, resolve, workspace, and archive paths more robust by safely handling unusual names and invalid characters.
  • Tests

    • Added coverage for safer file replacement, path sanitization, teardown logging, and config parsing behavior.

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

Walkthrough

This PR introduces atomic_replace and safe_segment utilities in platform_util.py for Windows-safe file replacement and filename sanitization, adopts them across persistence (decisions, journal, runs, sweep, settings) and path-construction sites (engine, resolve, workspace), hardens Unity plugin config reading and teardown identity checks, and extends the portability guard's os.kill detection.

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 os.replace/tmp.replace calls with atomic_replace in store, journal, archive, sweep decisions, and settings persistence, with tests covering transient sharing-violation retries.
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/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 PRs

  • bmad-code-org/bmad-loop#62: Both changes intersect on Engine session identity/resume behavior via the composed task_id used to match persisted SessionRecord results.

Suggested reviewers: pbean

Poem

A rabbit hops through paths untamed,
Trims each name that Windows shamed,
Replace with care, atomic and true,
No half-writ file will trouble you.
Force-kill skipped when ghosts appear,
This burrow's safe, the code is clear! 🐰✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 53.66% 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 clearly summarizes the main Windows platform hardening work around atomic replaces 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...

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

1 participant